package com.rj.service; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.rj.config.HxrAdminProperties; import com.rj.dto.hxr.HxrGoodsApiContext; import com.rj.dto.hxr.HxrLbGoodsSelectResponse; import com.rj.dto.hxr.HxrUserLoginApiContext; import com.rj.entity.LbBuyerShopping; import com.rj.mapper.LbBuyerShoppingMapper; import com.rj.tenant.TenantContextHolder; import com.rj.util.HxrGoodsSignUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.springframework.web.util.UriComponentsBuilder; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.security.SecureRandom; import java.time.Duration; import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; /** * 调用 hxrd {@code /api/order/goods} 货品列表(请求头 {@code token} + {@code S/T/N} 鉴权)。 */ @Slf4j @Service @RequiredArgsConstructor public class HxrAdminGoodsService { public static final int GOODS_PAGE_SIZE = 20; /** 与 {@link com.rj.service.impl.LbBuyerShoppingServiceImpl} 模拟登录默认密码一致。 */ private static final String DEFAULT_SIMULATE_LOGIN_PASSWORD = "123456"; private static final SecureRandom RANDOM = new SecureRandom(); private static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36 Edg/148.0.0.0"; private static final String HEADER_TOKEN = "Token"; private static final ObjectMapper JSON = new ObjectMapper() .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .registerModule(new JavaTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); private final HxrAdminProperties properties; private final HxrAdminUserLoginService hxrAdminUserLoginService; private final ILbThirdIntegrationConfigService lbThirdIntegrationConfigService; private final LbBuyerShoppingMapper lbBuyerShoppingMapper; /** * 分页拉取货品列表(使用 {@code hxr.admin} 配置文件,兼容旧调用)。 * * @param page 页码,从 1 开始 * @param token 可选;非空时作为请求头 token,否则使用 {@code hxr.admin.goods-api-token} */ public Optional fetchGoodsPage(int page, String token) throws Exception { String resolvedToken = resolveToken(token); if (resolvedToken == null) { log.warn("未提供 token 且 hxr.admin 未配置 goods-api-token,跳过 /api/order/goods"); return Optional.empty(); } String appStr = properties.getGoodsApiAppStr(); if (appStr == null || appStr.isBlank()) { log.warn("hxr.admin 未配置 goods-api-app-str,跳过 /api/order/goods"); return Optional.empty(); } HxrGoodsApiContext ctx = new HxrGoodsApiContext( stripQuery(properties.getGoodsApiUrl()), properties.getBuyApiUrl(), GOODS_PAGE_SIZE, properties.getGoodsApiOrigin(), properties.getGoodsApiReferer(), resolvedToken, appStr.trim()); return fetchGoodsPage(page, ctx); } /** * 分页拉取货品列表(使用租户 {@code lb_third_integration_config} 解析出的运行时配置)。 */ public Optional fetchGoodsPage(int page, HxrGoodsApiContext ctx) throws Exception { if (ctx == null) { log.warn("货品 API 配置为空,跳过 /api/order/goods"); return Optional.empty(); } String resolvedToken = ctx.token(); if (resolvedToken == null || resolvedToken.isBlank()) { log.warn("未提供 token,跳过 /api/order/goods"); return Optional.empty(); } String appStr = ctx.appStr(); if (appStr == null || appStr.isBlank()) { log.warn("未配置 goodsApiAppStr,跳过 /api/order/goods"); return Optional.empty(); } String goodsApiBaseUrl = ctx.goodsApiBaseUrl(); if (goodsApiBaseUrl == null || goodsApiBaseUrl.isBlank()) { log.warn("未配置 goodsApiBaseUrl,跳过 /api/order/goods"); return Optional.empty(); } GoodsPageFetchResult firstAttempt = executeGoodsPageRequest(page, ctx); if (firstAttempt.success()) { return Optional.of(firstAttempt.response()); } if (!firstAttempt.loginRequired()) { return Optional.empty(); } Optional refreshedToken = refreshTokenFromBuyerShopping(); if (refreshedToken.isEmpty()) { return Optional.empty(); } HxrGoodsApiContext refreshedCtx = withToken(ctx, refreshedToken.get()); GoodsPageFetchResult retryAttempt = executeGoodsPageRequest(page, refreshedCtx); if (retryAttempt.success()) { log.info("hxr /api/order/goods token 失效后已通过买方手机号模拟登录并重试成功 page={}", page); return Optional.of(retryAttempt.response()); } return Optional.empty(); } /** * token 失效时:从 {@code lb_buyer_shopping} 取当前租户 1 条记录的买家手机号模拟登录,解析 {@code data.userinfo.token}。 */ private Optional refreshTokenFromBuyerShopping() { String tenantId = TenantContextHolder.getTenantId(); if (!StringUtils.hasText(tenantId)) { log.warn("hxr /api/order/goods token 失效,但当前线程无 tenantId,无法从 lb_buyer_shopping 模拟登录"); return Optional.empty(); } LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(LbBuyerShopping::getTenantId, tenantId.trim()) .isNotNull(LbBuyerShopping::getBuyerMobile) .ne(LbBuyerShopping::getBuyerMobile, "") .orderByDesc(LbBuyerShopping::getBuyTime) .last("LIMIT 1"); LbBuyerShopping sample = lbBuyerShoppingMapper.selectOne(queryWrapper); if (sample == null || !StringUtils.hasText(sample.getBuyerMobile())) { log.warn("hxr /api/order/goods token 失效,tenantId={} 在 lb_buyer_shopping 中未找到可用买家手机号", tenantId); return Optional.empty(); } String buyerMobile = sample.getBuyerMobile().trim(); Optional loginCtxOpt = lbThirdIntegrationConfigService.resolveUserLoginApiContext(tenantId.trim()); if (loginCtxOpt.isEmpty()) { log.warn("hxr /api/order/goods token 失效,tenantId={} 未找到登录 API 配置,buyerMobile={}", tenantId, buyerMobile); return Optional.empty(); } try { HxrAdminUserLoginService.LoginApiResult loginResult = hxrAdminUserLoginService.login( buyerMobile, DEFAULT_SIMULATE_LOGIN_PASSWORD, loginCtxOpt.get()); if (!loginResult.success()) { log.warn("hxr /api/order/goods 买方模拟登录失败 tenantId={} buyerMobile={} apiCode={} apiMsg={}", tenantId, buyerMobile, loginResult.apiCode(), loginResult.apiMsg()); return Optional.empty(); } String token = HxrAdminUserLoginService.extractToken(loginResult.parsed()); if (!StringUtils.hasText(token)) { log.warn("hxr /api/order/goods 买方模拟登录成功但响应无 token tenantId={} buyerMobile={}", tenantId, buyerMobile); return Optional.empty(); } log.info("hxr /api/order/goods 已通过 lb_buyer_shopping 买家手机号模拟登录获取新 token tenantId={} buyerMobile={} tokenPrefix={}", tenantId, buyerMobile, abbreviate(token.trim(), 8)); return Optional.of(token.trim()); } catch (Exception e) { log.error("hxr /api/order/goods 买方模拟登录异常 tenantId={} buyerMobile={}", tenantId, buyerMobile, e); return Optional.empty(); } } private GoodsPageFetchResult executeGoodsPageRequest(int page, HxrGoodsApiContext ctx) throws Exception { String resolvedToken = ctx.token(); String appStr = ctx.appStr().trim(); String goodsApiBaseUrl = ctx.goodsApiBaseUrl(); int pageLimit = ctx.pageLimit() > 0 ? ctx.pageLimit() : GOODS_PAGE_SIZE; int safePage = Math.max(1, page); String uri = UriComponentsBuilder.fromUriString(goodsApiBaseUrl) .replaceQueryParam("page", safePage) .replaceQueryParam("limit", pageLimit) .build() .encode() .toUriString(); long timestamp = System.currentTimeMillis() / 1000; String noncestr = randomNoncestr(); Map signParams = new LinkedHashMap<>(); signParams.put("page", safePage); signParams.put("limit", pageLimit); signParams.put("timestamp", timestamp); signParams.put("noncestr", noncestr); String sign = HxrGoodsSignUtil.computeSign(signParams, appStr); HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(60)) .followRedirects(HttpClient.Redirect.NORMAL) .build(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(uri)) .timeout(Duration.ofSeconds(120)) .header("Accept", "application/json,*/*") .header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") .header("Origin", ctx.origin()) .header("Referer", ctx.referer()) .header("User-Agent", USER_AGENT) .header(HEADER_TOKEN, resolvedToken) .header("S", sign) .header("T", String.valueOf(timestamp)) .header("N", noncestr) .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); int status = response.statusCode(); if (status < 200 || status >= 300) { log.warn("hxr /api/order/goods HTTP {} page={} bodyPrefix={}", status, safePage, abbreviate(response.body(), 400)); return GoodsPageFetchResult.failure(false); } String bodyText = response.body(); if (bodyText == null || bodyText.isBlank()) { log.warn("hxr /api/order/goods empty body page={}", safePage); return GoodsPageFetchResult.failure(false); } JsonNode root = JSON.readTree(bodyText); int code = root.path("code").asInt(-1); String msg = root.path("msg").asText(""); if (code != 0) { boolean loginRequired = code == 401 || HxrAdminBuyService.MSG_LOGIN_REQUIRED.equals(msg); log.warn("hxr /api/order/goods api code={} msg={} page={} bodyPrefix={}", code, msg, safePage, abbreviate(bodyText, 400)); return GoodsPageFetchResult.failure(loginRequired); } JsonNode dataNode = root.get("data"); if (isErrorPayload(dataNode)) { log.warn("hxr /api/order/goods 返回异常 data={} page={} msg={}", dataNode, safePage, msg); return GoodsPageFetchResult.failure(false); } HxrLbGoodsSelectResponse body = JSON.treeToValue(root, HxrLbGoodsSelectResponse.class); return GoodsPageFetchResult.success(body); } private static HxrGoodsApiContext withToken(HxrGoodsApiContext ctx, String token) { return new HxrGoodsApiContext( ctx.goodsApiBaseUrl(), ctx.buyApiUrl(), ctx.pageLimit(), ctx.origin(), ctx.referer(), token, ctx.appStr()); } private record GoodsPageFetchResult(boolean success, HxrLbGoodsSelectResponse response, boolean loginRequired) { static GoodsPageFetchResult success(HxrLbGoodsSelectResponse response) { return new GoodsPageFetchResult(true, response, false); } static GoodsPageFetchResult failure(boolean loginRequired) { return new GoodsPageFetchResult(false, null, loginRequired); } } /** 从完整 URL 中去掉 query,供配置文件回退路径使用。 */ private static String stripQuery(String url) { if (url == null || url.isBlank()) { return url; } int q = url.indexOf('?'); return q >= 0 ? url.substring(0, q) : url; } /** 优先使用入参 token,否则回退到配置 {@code goods-api-token}。 */ private String resolveToken(String token) { if (token != null && !token.isBlank()) { return token.trim(); } String configured = properties.getGoodsApiToken(); if (configured != null && !configured.isBlank()) { return configured.trim(); } return null; } /** 与前端 {@code Math.random().toString(36).slice(-5)} 接近的 5 位随机串。 */ private static String randomNoncestr() { String base36 = Long.toUnsignedString(Math.abs(RANDOM.nextLong()), 36); if (base36.length() >= 5) { return base36.substring(base36.length() - 5); } StringBuilder sb = new StringBuilder(base36); while (sb.length() < 5) { sb.append(Integer.toString(RANDOM.nextInt(36), 36)); } return sb.toString(); } private static boolean isErrorPayload(JsonNode dataNode) { if (dataNode == null || dataNode.isNull()) { return false; } if (!dataNode.isArray() || dataNode.isEmpty()) { return false; } return dataNode.get(0).isTextual() || dataNode.get(0).isNumber(); } private static String abbreviate(String s, int maxLen) { if (s == null) { return ""; } return s.length() <= maxLen ? s : s.substring(0, maxLen) + "..."; } }