From 0e797a2a9b13f057c2dd0aea117d891348df8244 Mon Sep 17 00:00:00 2001 From: cst61 Date: Wed, 2 Sep 2026 07:45:02 +0800 Subject: [PATCH] =?UTF-8?q?=E9=80=82=E9=85=8D=E6=96=B0=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...irdIntegrationCredentialUpdateRequest.java | 6 + .../rj/dto/hxr/HxrAdminUserApiContext.java | 26 +- .../com/rj/dto/hxr/HxrGoodsApiContext.java | 18 +- .../rj/entity/LbThirdIntegrationConfig.java | 4 + .../com/rj/service/HxrAdminGoodsService.java | 813 +++++++++++++++- .../com/rj/service/HxrAdminUserService.java | 873 +++++++++++++++++- .../rj/service/impl/LbGoodsServiceImpl.java | 4 +- .../LbThirdIntegrationConfigServiceImpl.java | 52 +- .../rj/service/impl/LbUserServiceImpl.java | 2 +- src/main/java/com/rj/util/HxrdSignUtil.java | 90 ++ src/main/sql/lb_third_integration_config.sql | 1 + src/test/java/com/hxrd/GoodsTest.java | 381 ++++++++ src/test/java/com/qdw/buyerTest.java | 4 +- 13 files changed, 2206 insertions(+), 68 deletions(-) create mode 100644 src/main/java/com/rj/util/HxrdSignUtil.java create mode 100644 src/test/java/com/hxrd/GoodsTest.java diff --git a/src/main/java/com/rj/dto/LbThirdIntegrationCredentialUpdateRequest.java b/src/main/java/com/rj/dto/LbThirdIntegrationCredentialUpdateRequest.java index cbcb03e..73392db 100644 --- a/src/main/java/com/rj/dto/LbThirdIntegrationCredentialUpdateRequest.java +++ b/src/main/java/com/rj/dto/LbThirdIntegrationCredentialUpdateRequest.java @@ -43,4 +43,10 @@ public class LbThirdIntegrationCredentialUpdateRequest { @Schema(description = "为 true 时清除 goods_api_app_str") private Boolean clearGoodsApiAppStr; + + @Schema(description = "签名算法类型,如 HXR_AES_CBC_SHA256_MD5;与 clearSignType 互斥;空字符串会按 null 处理") + private String signType; + + @Schema(description = "为 true 时清除 sign_type") + private Boolean clearSignType; } diff --git a/src/main/java/com/rj/dto/hxr/HxrAdminUserApiContext.java b/src/main/java/com/rj/dto/hxr/HxrAdminUserApiContext.java index bd31168..1bb5ea8 100644 --- a/src/main/java/com/rj/dto/hxr/HxrAdminUserApiContext.java +++ b/src/main/java/com/rj/dto/hxr/HxrAdminUserApiContext.java @@ -2,11 +2,35 @@ package com.rj.dto.hxr; /** * 调用 hxrd 后台用户 API 所需的运行时配置(由 {@code lb_third_integration_config} 解析而来)。 + * + * @param signType 签名算法类型:null / "v1" → 后台 Cookie + user/select;"v2" → trade-app Bearer + HxrdSignUtil 三签名头 + * @param origin 站点 Origin(如 https://hxrdm.hxrd777.com),v2 用于拼域名与发请求头 + * @param token v2:Bearer JWT(去掉前缀后纯 token);v1:为空 */ public record HxrAdminUserApiContext( String userSelectBaseUrl, String userUpdateUrl, int pageLimit, String cookieHeader, - String referer) { + String referer, + String signType, + String origin, + String token) { + + /** 兼容 5 参数构造函数(旧调用点,行为 = v1)。 */ + public HxrAdminUserApiContext( + String userSelectBaseUrl, + String userUpdateUrl, + int pageLimit, + String cookieHeader, + String referer) { + this(userSelectBaseUrl, userUpdateUrl, pageLimit, cookieHeader, referer, null, null, null); + } + + /** 仅替换 token(保持其它字段不变)。 */ + public HxrAdminUserApiContext withToken(String newToken) { + return new HxrAdminUserApiContext( + userSelectBaseUrl(), userUpdateUrl(), pageLimit(), cookieHeader(), referer(), + signType(), origin(), newToken); + } } diff --git a/src/main/java/com/rj/dto/hxr/HxrGoodsApiContext.java b/src/main/java/com/rj/dto/hxr/HxrGoodsApiContext.java index df85c30..9376b1e 100644 --- a/src/main/java/com/rj/dto/hxr/HxrGoodsApiContext.java +++ b/src/main/java/com/rj/dto/hxr/HxrGoodsApiContext.java @@ -2,6 +2,9 @@ package com.rj.dto.hxr; /** * 调用 hxrd 货品/抢购 API 所需的运行时配置(由 {@code lb_third_integration_config} 解析而来)。 + * + * @param signType 签名算法类型:null / "v1" 走旧 {@code /api/order/goods + Token + S/T/N} 链路; + * "v2" 走 trade-app {@code /sale/goods-list + Bearer + X-Sign-N/T/S} 链路(HxrdSignUtil)。 */ public record HxrGoodsApiContext( String goodsApiBaseUrl, @@ -10,5 +13,18 @@ public record HxrGoodsApiContext( String origin, String referer, String token, - String appStr) { + String appStr, + String signType) { + + /** 兼容只传 7 个字段的旧调用点:signType 默认 null(= v1 旧逻辑)。 */ + public HxrGoodsApiContext( + String goodsApiBaseUrl, + String buyApiUrl, + int pageLimit, + String origin, + String referer, + String token, + String appStr) { + this(goodsApiBaseUrl, buyApiUrl, pageLimit, origin, referer, token, appStr, null); + } } diff --git a/src/main/java/com/rj/entity/LbThirdIntegrationConfig.java b/src/main/java/com/rj/entity/LbThirdIntegrationConfig.java index d5e8efa..bb0889e 100644 --- a/src/main/java/com/rj/entity/LbThirdIntegrationConfig.java +++ b/src/main/java/com/rj/entity/LbThirdIntegrationConfig.java @@ -140,6 +140,10 @@ public class LbThirdIntegrationConfig implements Serializable { @Schema(description = "Admin 鉴权类型") private String authType; + @TableField("sign_type") + @Schema(description = "签名算法类型,如 HXR_AES_CBC_SHA256_MD5;空则由调用方按 provider_code 默认处理") + private String signType; + @TableField("cookie") @Schema(description = "完整 Cookie 明文") private String cookie; diff --git a/src/main/java/com/rj/service/HxrAdminGoodsService.java b/src/main/java/com/rj/service/HxrAdminGoodsService.java index 52c2a2e..231a9e9 100644 --- a/src/main/java/com/rj/service/HxrAdminGoodsService.java +++ b/src/main/java/com/rj/service/HxrAdminGoodsService.java @@ -7,29 +7,50 @@ 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.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.rj.config.HxrAdminProperties; import com.rj.dto.hxr.HxrGoodsApiContext; +import com.rj.dto.hxr.HxrLbGoodsPageData; import com.rj.dto.hxr.HxrLbGoodsSelectResponse; import com.rj.dto.hxr.HxrUserLoginApiContext; import com.rj.entity.LbBuyerShopping; +import com.rj.entity.LbGoods; +import com.rj.entity.LbThirdIntegrationConfig; import com.rj.mapper.LbBuyerShoppingMapper; import com.rj.tenant.TenantContextHolder; import com.rj.util.HxrGoodsSignUtil; +import com.rj.util.HxrdSignUtil; 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.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.math.BigDecimal; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.time.Duration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Iterator; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; +import java.util.zip.GZIPInputStream; /** * 调用 hxrd {@code /api/order/goods} 货品列表(请求头 {@code token} + {@code S/T/N} 鉴权)。 @@ -50,6 +71,11 @@ public class HxrAdminGoodsService { "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"; + /** V2 专用 UA:与 GoodsTest 一致(Android 移动端),前端签名拦截器可能校验 UA。 */ + private static final String V2_USER_AGENT = + "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Edg/152.0.0.0 Mobile Safari/537.36"; + private static final String HEADER_TOKEN = "Token"; private static final ObjectMapper JSON = new ObjectMapper() @@ -58,6 +84,14 @@ public class HxrAdminGoodsService { .registerModule(new JavaTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + /** 用于解析 v2 响应(trade-app /sale/goods-list,字段 camelCase)。 */ + private static final ObjectMapper JSON_CAMEL = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + private static final DateTimeFormatter V2_DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private final HxrAdminProperties properties; private final HxrAdminUserLoginService hxrAdminUserLoginService; private final ILbThirdIntegrationConfigService lbThirdIntegrationConfigService; @@ -99,20 +133,25 @@ public class HxrAdminGoodsService { log.warn("货品 API 配置为空,跳过 /api/order/goods"); return Optional.empty(); } + // v2 允许暂时没 token:在 executeGoodsPageRequest 里会按 401 触发刷新 + // v1 对 token / appStr / goodsApiBaseUrl 仍必须齐全 + boolean v2 = isV2SignType(ctx.signType()); 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(); + if (!v2) { + 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); @@ -123,7 +162,7 @@ public class HxrAdminGoodsService { return Optional.empty(); } - Optional refreshedToken = refreshTokenFromBuyerShopping(); + Optional refreshedToken = refreshTokenFromBuyerShopping(ctx); if (refreshedToken.isEmpty()) { return Optional.empty(); } @@ -131,16 +170,20 @@ public class HxrAdminGoodsService { HxrGoodsApiContext refreshedCtx = withToken(ctx, refreshedToken.get()); GoodsPageFetchResult retryAttempt = executeGoodsPageRequest(page, refreshedCtx); if (retryAttempt.success()) { - log.info("hxr /api/order/goods token 失效后已通过买方手机号模拟登录并重试成功 page={}", page); + log.info("hxr goods token 失效后已通过买方手机号模拟登录并重试成功 signType={} page={}", + ctx.signType() == null ? "v1" : ctx.signType(), page); return Optional.of(retryAttempt.response()); } return Optional.empty(); } /** - * token 失效时:从 {@code lb_buyer_shopping} 取当前租户 1 条记录的买家手机号模拟登录,解析 {@code data.userinfo.token}。 + * token 失效时:从 {@code lb_buyer_shopping} 取当前租户多条记录的买家手机号, + * 逐个尝试模拟登录,解析 token。 + * - sign_type == v2: 直接 POST /trade-app/api/app/login JSON {phone,password} 取 Bearer JWT(去掉前缀) + * - sign_type == v1 / null:沿用旧 HxrAdminUserLoginService 登录 */ - private Optional refreshTokenFromBuyerShopping() { + private Optional refreshTokenFromBuyerShopping(HxrGoodsApiContext ctx) { String tenantId = TenantContextHolder.getTenantId(); if (!StringUtils.hasText(tenantId)) { log.warn("hxr /api/order/goods token 失效,但当前线程无 tenantId,无法从 lb_buyer_shopping 模拟登录"); @@ -152,51 +195,258 @@ public class HxrAdminGoodsService { .isNotNull(LbBuyerShopping::getBuyerMobile) .ne(LbBuyerShopping::getBuyerMobile, "") .orderByDesc(LbBuyerShopping::getBuyTime) - .last("LIMIT 1"); - LbBuyerShopping sample = lbBuyerShoppingMapper.selectOne(queryWrapper); - if (sample == null || !StringUtils.hasText(sample.getBuyerMobile())) { + .last("LIMIT 20"); + List samples = lbBuyerShoppingMapper.selectList(queryWrapper); + if (samples == null || samples.isEmpty()) { 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); + Set seenMobiles = new LinkedHashSet<>(); + List candidateMobiles = new ArrayList<>(); + for (LbBuyerShopping sample : samples) { + String mobile = sample.getBuyerMobile(); + if (!StringUtils.hasText(mobile)) { + continue; + } + String trimmed = mobile.trim(); + if (seenMobiles.add(trimmed)) { + candidateMobiles.add(trimmed); + } + } + if (candidateMobiles.isEmpty()) { + log.warn("hxr /api/order/goods token 失效,tenantId={} 在 lb_buyer_shopping 中候选手机号去重后为空", + tenantId); 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); + Optional loginCtxOpt = + lbThirdIntegrationConfigService.resolveUserLoginApiContext(tenantId.trim()); + // v2 不需要旧的 UserLoginApiContext(appStr/S/T/N 一套),直接用 trade-app/login;但若有则借用其 origin/referer + boolean v2 = isV2SignType(ctx.signType()); + if (!v2 && loginCtxOpt.isEmpty()) { + log.warn("hxr /api/order/goods token 失效,tenantId={} 未找到登录 API 配置,候选手机号数={}", + tenantId, candidateMobiles.size()); return Optional.empty(); } + + Exception lastException = null; + for (int i = 0; i < candidateMobiles.size(); i++) { + String buyerMobile = candidateMobiles.get(i); + try { + String trimmedToken; + if (v2) { + // ===== v2: POST /trade-app/api/app/login JSON {phone, password} ===== + // 解析 baseDomain:优先 ctx.origin,否则用 loginCtxOpt 里的 origin,再兜底 goodsApiBaseUrl + String baseDomain = ctx.origin() != null ? ctx.origin() : null; + if (!StringUtils.hasText(baseDomain) && loginCtxOpt.isPresent()) { + baseDomain = loginCtxOpt.get().origin(); + } + if (!StringUtils.hasText(baseDomain)) { + baseDomain = guessBaseDomain(ctx.goodsApiBaseUrl()); + } + Optional t = doV2SimulateLogin(buyerMobile, + DEFAULT_SIMULATE_LOGIN_PASSWORD, + baseDomain); + if (t.isEmpty()) { + log.warn("v2 /trade-app/api/app/login 未拿到 token(第{}/{}个) tenantId={} buyerMobile={},将尝试下一手机号", + i + 1, candidateMobiles.size(), tenantId, buyerMobile); + continue; + } + trimmedToken = t.get(); + } else { + // ===== v1: 原有 HxrAdminUserLoginService.login 流程 ===== + 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={},将尝试下一手机号", + i + 1, candidateMobiles.size(), tenantId, buyerMobile, + loginResult.apiCode(), loginResult.apiMsg()); + continue; + } + + String token = HxrAdminUserLoginService.extractToken(loginResult.parsed()); + if (!StringUtils.hasText(token)) { + log.warn("hxr /api/order/goods 买方模拟登录成功但响应无 token(第{}/{}个) tenantId={} buyerMobile={},将尝试下一手机号", + i + 1, candidateMobiles.size(), tenantId, buyerMobile); + continue; + } + trimmedToken = token.trim(); + } + + boolean persisted = persistGoodsApiToken(tenantId.trim(), trimmedToken); + log.info("hxr goods 已通过 lb_buyer_shopping 买家手机号模拟登录获取新 token signType={} tenantId={} buyerMobile={} 序号={}/{} tokenPrefix={} persisted={}", + v2 ? "v2" : "v1", tenantId, buyerMobile, i + 1, candidateMobiles.size(), abbreviate(trimmedToken, 8), persisted); + return Optional.of(trimmedToken); + } catch (Exception e) { + lastException = e; + log.warn("hxr goods 买方模拟登录异常 signType={}(第{}/{}个) tenantId={} buyerMobile={},将尝试下一手机号", + v2 ? "v2" : "v1", i + 1, candidateMobiles.size(), tenantId, buyerMobile, e); + } + } + + if (lastException != null) { + log.error("hxr goods 所有候选手机号模拟登录均失败 signType={} tenantId={} 候选数={}", + v2 ? "v2" : "v1", tenantId, candidateMobiles.size(), lastException); + } else { + log.warn("hxr goods 所有候选手机号模拟登录均失败 signType={} tenantId={} 候选数={}", + v2 ? "v2" : "v1", tenantId, candidateMobiles.size()); + } + return Optional.empty(); + } + + /** + * v2 模拟登录:POST JSON {phone,password} → /trade-app/api/app/login。 + * 成功返回格式:{ success:true, token:"Bearer eyJ..." };去掉 "Bearer " 前缀后返回。 + * + * @param baseDomain 可选:https://hxrdm.hxrd777.com;为空时无法请求,返回 empty + */ + private Optional doV2SimulateLogin(String phone, String password, String baseDomain) throws Exception { + if (!StringUtils.hasText(phone) || !StringUtils.hasText(password)) return Optional.empty(); + String domain = baseDomain == null ? null : trimTrailingSlash(baseDomain); + if (domain == null || domain.isBlank()) return Optional.empty(); + String url = domain + "/trade-app/api/app/login"; + + Map body = new LinkedHashMap<>(); + body.put("phone", phone.trim()); + body.put("password", password.trim()); + String bodyJson = JSON_CAMEL.writeValueAsString(body); + + // 签名三 Header(与 GoodsTest 一致,登录请求也必须带签名,否则 SignFilter 返回 403) + HxrdSignUtil.SignTriplet trip = HxrdSignUtil.sign(); + + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(60)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(120)) + .header("Accept", "*/*") + .header("Accept-Encoding", "gzip") + .header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + .header("Content-Type", "application/json") + .header("Origin", domain) + .header("Referer", domain + "/") + .header("User-Agent", V2_USER_AGENT) + .header("Sec-Ch-Ua", "\"Chromium\";v=\"152\", \"Not?A_Brand\";v=\"24\", \"Microsoft Edge\";v=\"152\"") + .header("Sec-Ch-Ua-Mobile", "?1") + .header("Sec-Ch-Ua-Platform", "\"Android\"") + .header("Sec-Fetch-Dest", "empty") + .header("Sec-Fetch-Mode", "cors") + .header("Sec-Fetch-Site", "same-origin") + .header("X-Sign-N", trip.nHeader()) + .header("X-Sign-T", trip.tHeader()) + .header("X-Sign-S", trip.sHeader()) + .POST(HttpRequest.BodyPublishers.ofString(bodyJson, StandardCharsets.UTF_8)) + .build(); + + HttpResponse resp = client.send(req, HttpResponse.BodyHandlers.ofByteArray()); + int httpStatus = resp.statusCode(); + // gzip 解压(Java HttpClient 不会自动解压) + String contentEncoding = resp.headers().firstValue("Content-Encoding").orElse(""); + byte[] decompressed = decompressGzipIfNeeded(resp.body(), contentEncoding); + String text = decompressed == null ? "" : new String(decompressed, StandardCharsets.UTF_8); + + log.info("v2 /trade-app/api/app/login 响应 HTTP={} phone={} Content-Encoding={} bodyLen={} bodyPrefix={}", + httpStatus, phone, contentEncoding, decompressed == null ? 0 : decompressed.length, + abbreviate(text, 400)); + + if (httpStatus < 200 || httpStatus >= 300) { + log.warn("v2 /trade-app/api/app/login HTTP {} phone={} bodyPrefix={}", + httpStatus, phone, abbreviate(text, 400)); + return Optional.empty(); + } + JsonNode root = JSON_CAMEL.readTree(text); + boolean ok = isSuccessV2(root); + if (!ok) { + log.warn("v2 /trade-app/api/app/login success=false phone={} bodyPrefix={}", + phone, abbreviate(text, 400)); + return Optional.empty(); + } + JsonNode tokenNode = root.get("token"); + if (tokenNode == null || tokenNode.isNull()) { + log.warn("v2 /trade-app/api/app/login 响应无 token 字段 phone={} bodyPrefix={}", + phone, abbreviate(text, 400)); + return Optional.empty(); + } + String raw = tokenNode.asText("").trim(); + if (raw.isEmpty()) return Optional.empty(); + // 去掉 "Bearer " 前缀(忽略大小写、多空格) + if (raw.length() > 7 && raw.regionMatches(true, 0, "bearer ", 0, 7)) { + raw = raw.substring(7).trim(); + } + return StringUtils.hasText(raw) ? Optional.of(raw) : Optional.empty(); + } + + /** + * 将模拟登录刷新得到的货品 token 持久化到 {@code lb_third_integration_config.goods_api_token}, + * 同时升级凭证版本号、更新时间,并设置一个默认的过期时间(24 小时后),避免后续每次请求都重新模拟登录。 + * + * @return true 表示写入成功;false 表示该租户未找到配置行或更新失败 + */ + private boolean persistGoodsApiToken(String tenantId, String newToken) { + if (!StringUtils.hasText(tenantId) || !StringUtils.hasText(newToken)) { + return false; + } + try { + LbThirdIntegrationConfig existing = lbThirdIntegrationConfigService.getOne( + new LambdaQueryWrapper() + .eq(LbThirdIntegrationConfig::getTenantId, tenantId) + .last("LIMIT 1"), false); + if (existing == null || existing.getId() == null) { + log.warn("hxr /api/order/goods 新 token 无法持久化:tenantId={} 未找到 lb_third_integration_config 行", + tenantId); + return false; + } + + int currentVersion = existing.getCredentialVersion() == null ? 0 : existing.getCredentialVersion(); + LocalDateTime now = LocalDateTime.now(); + LocalDateTime expireAt = now.plusHours(24); + + LambdaUpdateWrapper uw = new LambdaUpdateWrapper<>(); + uw.eq(LbThirdIntegrationConfig::getId, existing.getId()) + .set(LbThirdIntegrationConfig::getGoodsApiToken, newToken) + .set(LbThirdIntegrationConfig::getCredentialVersion, currentVersion + 1) + .set(LbThirdIntegrationConfig::getCredentialExpireTime, expireAt) + .set(LbThirdIntegrationConfig::getUpdateTime, now); + boolean ok = lbThirdIntegrationConfigService.update(uw); + if (ok) { + log.info("hxr /api/order/goods 新 token 已持久化 tenantId={} configId={} credentialVersion={} expireAt={}", + tenantId, existing.getId(), currentVersion + 1, expireAt); + } else { + log.warn("hxr /api/order/goods 新 token 持久化失败(update 返回 false) tenantId={} configId={}", + tenantId, existing.getId()); + } + return ok; + } catch (Exception e) { + log.error("hxr /api/order/goods 新 token 持久化异常 tenantId={}", tenantId, e); + return false; + } } private GoodsPageFetchResult executeGoodsPageRequest(int page, HxrGoodsApiContext ctx) throws Exception { + // ===== 按 signType 分流 ===== + // v1 (null / "v1") :完全保留原有逻辑(/api/order/goods + Token + S/T/N + SHA256) + // v2 (== "v2") :trade-app /sale/goods-list + Bearer + X-Sign-N/T/S + AES-256-CBC(HxrdSignUtil) + if (isV2SignType(ctx.signType())) { + return executeGoodsPageRequestV2(page, ctx); + } + return executeGoodsPageRequestV1(page, ctx); + } + + /** sign_type == "v2"?v1/null/其它 值一律当作 v1。 */ + private static boolean isV2SignType(String signType) { + return "v2".equalsIgnoreCase(signType); + } + + // ===================================================================== + // V1 分支:sign_type == null / "v1" 或其它 —— 原有逻辑一字未改 + // ===================================================================== + private GoodsPageFetchResult executeGoodsPageRequestV1(int page, HxrGoodsApiContext ctx) throws Exception { String resolvedToken = ctx.token(); String appStr = ctx.appStr().trim(); String goodsApiBaseUrl = ctx.goodsApiBaseUrl(); @@ -273,6 +523,468 @@ public class HxrAdminGoodsService { return GoodsPageFetchResult.success(body); } + // ===================================================================== + // V2 分支:sign_type == "v2"(参考 GoodsTest#fetchGoodsPage) + // 1. Bearer JWT 可能在 ctx.token() 里(或 v2 自己登录);若 401/无 JWT 则标记 loginRequired 让外层刷 + // 2. 请求:GET {origin}/trade-app/api/app/sale/goods-list?page=N&size=M + // 3. Header:X-Sign-N/T/S(HxrdSignUtil) + Authorization: Bearer xxx + // 4. 响应:{ success:true, total:547, pages:28, size:20, page:1, data:[...] } + // ===================================================================== + private GoodsPageFetchResult executeGoodsPageRequestV2(int page, HxrGoodsApiContext ctx) throws Exception { + int pageLimit = ctx.pageLimit() > 0 ? ctx.pageLimit() : GOODS_PAGE_SIZE; + int safePage = Math.max(1, page); + + // 1) 拼 URL:base 域取 ctx.origin()(因为 goodsApiBaseUrl 在 v1 里是 /api/order/goods,路径不同;v2 直接用域名 + 固定 /sale/goods-list) + String baseDomain = ctx.origin() != null ? ctx.origin() : guessBaseDomain(ctx.goodsApiBaseUrl()); + if (baseDomain == null || baseDomain.isBlank()) { + log.warn("v2 /sale/goods-list 无法推断 baseDomain page={} ctx.origin={} ctx.goodsApiBaseUrl={}", + safePage, ctx.origin(), ctx.goodsApiBaseUrl()); + return GoodsPageFetchResult.failure(false); + } + String uri = UriComponentsBuilder.fromUriString(trimTrailingSlash(baseDomain)) + .replacePath("/trade-app/api/app/sale/goods-list") + .replaceQueryParam("page", safePage) + .replaceQueryParam("size", pageLimit) + .build() + .encode() + .toUriString(); + + // 2) 签名三 Header(与 GoodsTest 一致) + HxrdSignUtil.SignTriplet trip = HxrdSignUtil.sign(); + + // ===== DEBUG: 打印请求前的完整上下文 ===== + log.info("v2 /sale/goods-list 请求准备 page={} size={} uri={} baseDomain={} origin={} goodsApiBaseUrl={} tokenPrefix={} signType={} appStrPrefix={} n={} t={}", + safePage, pageLimit, uri, baseDomain, ctx.origin(), ctx.goodsApiBaseUrl(), + ctx.token() == null ? "(null)" : abbreviate(ctx.token().trim(), 16), + ctx.signType(), + ctx.appStr() == null ? "(null)" : abbreviate(ctx.appStr().trim(), 8), + trip.nHeader(), trip.tHeader()); + + // 3) 发请求 + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(60)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + + HttpRequest.Builder reqBuilder = HttpRequest.newBuilder() + .uri(URI.create(uri)) + .timeout(Duration.ofSeconds(120)) + .header("Accept", "*/*") + .header("Accept-Encoding", "gzip") + .header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + .header("Content-Type", "application/json") + .header("Origin", baseDomain) + .header("Referer", trimTrailingSlash(baseDomain) + "/") + .header("User-Agent", V2_USER_AGENT) + .header("Sec-Ch-Ua", "\"Chromium\";v=\"152\", \"Not?A_Brand\";v=\"24\", \"Microsoft Edge\";v=\"152\"") + .header("Sec-Ch-Ua-Mobile", "?1") + .header("Sec-Ch-Ua-Platform", "\"Android\"") + .header("Sec-Fetch-Dest", "empty") + .header("Sec-Fetch-Mode", "cors") + .header("Sec-Fetch-Site", "same-origin") + .header("X-Sign-N", trip.nHeader()) + .header("X-Sign-T", trip.tHeader()) + .header("X-Sign-S", trip.sHeader()); + + boolean bearerPresent = StringUtils.hasText(ctx.token()); + if (bearerPresent) { + reqBuilder.header("Authorization", "Bearer " + ctx.token().trim()); + } + + HttpRequest httpReq = reqBuilder.GET().build(); + + HttpResponse resp = client.send(httpReq, HttpResponse.BodyHandlers.ofByteArray()); + int httpStatus = resp.statusCode(); + byte[] bodyBytes = resp.body(); + + // ===== 关键:检查 Content-Encoding 并解压 gzip ===== + // Java HttpClient 不会自动解压 gzip,必须手动处理 + String contentEncoding = resp.headers().firstValue("Content-Encoding").orElse(""); + String contentType = resp.headers().firstValue("Content-Type").orElse(""); + + // 先尝试解压(如果是 gzip) + byte[] decompressed = decompressGzipIfNeeded(bodyBytes, contentEncoding); + String bodyText = decompressed == null ? "" : new String(decompressed, StandardCharsets.UTF_8); + + // ===== DEBUG: 打印响应状态和头部 ===== + log.info("v2 /sale/goods-list 响应 HTTP={} page={} Content-Encoding={} Content-Type={} rawBodyLen={} decompressedLen={} bearerPresent={}", + httpStatus, safePage, contentEncoding, contentType, + bodyBytes == null ? 0 : bodyBytes.length, + decompressed == null ? 0 : decompressed.length, + bearerPresent); + + if (httpStatus == 401 || httpStatus == 403) { + log.warn("v2 /sale/goods-list 鉴权失败 HTTP {} page={} bearerPresent={} tokenPrefix={} bodyPrefix={}", + httpStatus, safePage, bearerPresent, + ctx.token() == null ? "(null)" : abbreviate(ctx.token().trim(), 30), + abbreviate(bodyText, 400)); + return GoodsPageFetchResult.failure(true); + } + if (httpStatus == 500) { + // 500 通常是因为 token 格式不对(v1 旧 token 发到了 v2 端点,服务器解析 JWT 时抛异常) + // → 也按 loginRequired 处理,让外层 refreshToken 走 v2 登录拿正确的 JWT + log.warn("v2 /sale/goods-list HTTP 500 服务器内部错误(可能是 v1 旧 token 不兼容 v2 端点) page={} bearerPresent={} tokenPrefix={} bodyPrefix={}", + safePage, bearerPresent, + ctx.token() == null ? "(null)" : abbreviate(ctx.token().trim(), 30), + abbreviate(bodyText, 400)); + return GoodsPageFetchResult.failure(true); + } + if (httpStatus < 200 || httpStatus >= 300) { + log.warn("v2 /sale/goods-list HTTP {} page={} bodyPrefix={}", + httpStatus, safePage, abbreviate(bodyText, 400)); + return GoodsPageFetchResult.failure(false); + } + if (decompressed == null || decompressed.length == 0) { + log.warn("v2 /sale/goods-list empty body page={} contentEncoding={} rawBodyLen={}", + safePage, contentEncoding, bodyBytes == null ? 0 : bodyBytes.length); + return GoodsPageFetchResult.failure(false); + } + + // 4) 解析 v2 响应:{ success, total, pages, size, page, data:[{...}] } + JsonNode root; + try { + root = JSON_CAMEL.readTree(bodyText); + } catch (IOException e) { + log.warn("v2 /sale/goods-list JSON parse fail page={} contentEncoding={} bodyLen={} bodyHexPrefix={} bodyTextPrefix={}", + safePage, contentEncoding, decompressed.length, + toHexPrefix(decompressed, 32), + abbreviate(bodyText, 400), e); + return GoodsPageFetchResult.failure(false); + } + + log.info("v2 /sale/goods-list 响应 JSON 解析成功 page={} topFields={} success={} code={} msg={} total={} pages={} dataIsNull={} dataIsArray={} dataSize={}", + safePage, + topFieldNames(root), + root.path("success").asText("(absent)"), + root.path("code").asText("(absent)"), + root.path("msg").asText("(absent)"), + root.path("total").asText("(absent)"), + root.path("pages").asText("(absent)"), + root.get("data") == null ? "true" : "false", + root.get("data") != null && root.get("data").isArray() ? "true" : "false", + root.get("data") != null && root.get("data").isArray() ? root.get("data").size() : -1); + + boolean ok = isSuccessV2(root); + if (!ok) { + // 鉴权类错误也按登录失效处理一次刷 token + boolean loginRequired = httpStatus == 401 + || root.path("code").asInt(-1) == 401 + || "token无效".equals(root.path("msg").asText("")) + || "请登录".equals(root.path("msg").asText("")); + log.warn("v2 /sale/goods-list success=false page={} bodyPrefix={}", + safePage, abbreviate(bodyText, 400)); + return GoodsPageFetchResult.failure(loginRequired); + } + + JsonNode dataArr = pickListNodeV2(root); + int total = root.path("total").asInt(0); + int pages = root.path("pages").asInt(1); + int size = root.path("size").asInt(pageLimit); + + List list = convertV2ItemsToLbGoods(dataArr, safePage); + boolean hasMore = list.size() >= size && safePage < pages; + int lastPage = Math.max(1, pages); + + log.info("v2 /sale/goods-list 拉取成功 page={} 本页条数={} total={} pages={} hasMore={}", + safePage, list.size(), total, pages, hasMore); + + HxrLbGoodsPageData pageData = new HxrLbGoodsPageData(list, hasMore, lastPage); + HxrLbGoodsSelectResponse wrapped = new HxrLbGoodsSelectResponse(0, "ok", pageData); + return GoodsPageFetchResult.success(wrapped); + } + + /** 如果 Content-Encoding=gzip 则解压,否则原样返回。 */ + private static byte[] decompressGzipIfNeeded(byte[] raw, String contentEncoding) { + if (raw == null || raw.length == 0) return raw; + if (contentEncoding == null || !contentEncoding.toLowerCase().contains("gzip")) return raw; + try (InputStream gin = new GZIPInputStream(new ByteArrayInputStream(raw)); + ByteArrayOutputStream baos = new ByteArrayOutputStream(raw.length * 4)) { + byte[] buf = new byte[8192]; + int n; + while ((n = gin.read(buf)) > 0) baos.write(buf, 0, n); + return baos.toByteArray(); + } catch (IOException e) { + // 不是 gzip 或解压失败,返回原始字节 + return raw; + } + } + + /** 打印字节数组前 N 字节的 hex(调试用)。 */ + private static String toHexPrefix(byte[] data, int maxBytes) { + if (data == null) return "(null)"; + int len = Math.min(data.length, maxBytes); + StringBuilder sb = new StringBuilder(len * 3); + for (int i = 0; i < len; i++) { + if (i > 0) sb.append(' '); + sb.append(String.format("%02x", data[i] & 0xFF)); + } + if (data.length > maxBytes) sb.append("..."); + return sb.toString(); + } + + /** 列出 JsonNode 顶层的字段名(调试用)。 */ + private static String topFieldNames(JsonNode node) { + if (node == null || !node.isObject()) return "(not-object)"; + StringBuilder sb = new StringBuilder("["); + Iterator it = node.fieldNames(); + boolean first = true; + while (it.hasNext()) { + if (!first) sb.append(","); + sb.append(it.next()); + first = false; + } + sb.append("]"); + return sb.toString(); + } + + /** v2 响应的成功判定:优先 success == true/1/"true",否则 code == 200。 */ + private static boolean isSuccessV2(JsonNode root) { + if (root == null || root.isNull() || root.isMissingNode()) return false; + JsonNode s = root.get("success"); + if (s != null && !s.isNull()) { + if (s.isBoolean()) return s.booleanValue(); + if (s.isNumber()) return s.intValue() == 1; + String t = s.asText(""); + if ("true".equalsIgnoreCase(t) || "1".equals(t)) return true; + if ("false".equalsIgnoreCase(t) || "0".equals(t)) return false; + } + JsonNode c = root.get("code"); + if (c != null && c.isNumber()) return c.intValue() == 200; + if (c != null) return "200".equals(c.asText("")); + return false; + } + + /** 在 v2 响应里找货物列表数组(优先顶层 data 字段,兜底 data.records / data.list / data.rows)。 */ + private static JsonNode pickListNodeV2(JsonNode root) { + if (root == null) return null; + JsonNode candidate = firstArray(root, "data", "records", "list", "rows"); + if (candidate != null) return candidate; + JsonNode d = root.get("data"); + if (d != null && d.isObject()) { + candidate = firstArray(d, "records", "list", "rows", "data"); + if (candidate != null) return candidate; + } + return null; + } + + private static JsonNode firstArray(JsonNode parent, String... names) { + for (String n : names) { + JsonNode x = parent.get(n); + if (x != null && x.isArray()) return x; + } + return null; + } + + /** + * 把 v2 单条货物 JSON 转为实体 LbGoods。 + * v2 字段:id, oldId, title, image, seller, sellingPrice, quantity, unit, status, displayStatus, createTime, updateTime + * LbGoods 字段:id, oldId, title, image, price (BigDecimal), quantity(String), sellerId(Long), status, currentPage, createdAt, updatedAt + */ + private static List convertV2ItemsToLbGoods(JsonNode dataArr, int currentPage) { + List out = new ArrayList<>(); + if (dataArr == null || !dataArr.isArray() || dataArr.isEmpty()) return out; + Iterator it = dataArr.elements(); + while (it.hasNext()) { + JsonNode item = it.next(); + if (item == null || item.isNull()) continue; + LbGoods g = new LbGoods(); + g.setCurrentPage(currentPage); + // 一轮拷贝:item 字段 → LbGoods 同名字段 / 按下面映射兜底 + Map flat = flattenV2Item(item); + applyFields(g, flat); + // 兜底手动映射 + if (g.getId() == null) { + Long id = asLong(item.get("id")); + if (id != null) g.setId(id); + } + if (g.getOldId() == null) { + Long oldId = asLong(item.get("oldId")); + if (oldId != null) g.setOldId(oldId); + } + if (g.getTitle() == null) g.setTitle(textOr(item.get("title"), null)); + if (g.getImage() == null) g.setImage(textOr(item.get("image"), null)); + if (g.getPrice() == null) { + BigDecimal sp = asBigDecimal(item.get("sellingPrice")); + if (sp != null) g.setPrice(sp); + } + // total_money 与 price 取同一值 + if (g.getTotalMoney() == null && g.getPrice() != null) { + g.setTotalMoney(g.getPrice()); + } + if (g.getSellerId() == null) { + Long s = asLong(item.get("seller")); + if (s != null) g.setSellerId(s); + } + Integer disp = asInteger(item.get("displayStatus")); + if (g.getIsShow() == null && disp != null) g.setIsShow(disp); + if (g.getStatus() == null) g.setStatus(asInteger(item.get("status"))); + if (g.getQuantity() == null) { + String qty = textOr(item.get("quantity"), null); + String unit = textOr(item.get("unit"), null); + if (qty != null && unit != null) g.setQuantity(qty + unit); + else if (qty != null) g.setQuantity(qty); + } + if (g.getCreatedAt() == null) g.setCreatedAt(parseDateTimeOrNull(textOr(item.get("createTime"), null))); + if (g.getUpdatedAt() == null) g.setUpdatedAt(parseDateTimeOrNull(textOr(item.get("updateTime"), null))); + out.add(g); + } + return out; + } + + /** 把 v2 一条 JSON(camelCase)按字段名拍平成 Map,交给反射填充 LbGoods。 */ + @SuppressWarnings("unchecked") + private static Map flattenV2Item(JsonNode item) { + try { + return JSON_CAMEL.convertValue(item, Map.class); + } catch (Exception ignore) { + return new LinkedHashMap<>(); + } + } + + /** + * 按字段名匹配把 v2 Map 写入 LbGoods(支持 seller → sellerId、sellingPrice → price、 + * displayStatus → isShow、createTime → createdAt、updateTime → updatedAt 等常见映射)。 + */ + private static void applyFields(LbGoods target, Map src) { + if (src == null || src.isEmpty()) return; + Map fields = collectLbGoodsFields(); + for (Map.Entry e : src.entrySet()) { + String rawKey = e.getKey(); + Object rawVal = e.getValue(); + if (rawKey == null || rawVal == null) continue; + Field f = fields.get(rawKey); + // 映射别名(v2 字段名 → LbGoods 字段名) + if (f == null) { + switch (rawKey) { + case "sellingPrice": f = fields.get("price"); break; + case "seller": f = fields.get("sellerId"); break; + case "displayStatus":f = fields.get("isShow"); break; + case "createTime": f = fields.get("createdAt"); break; + case "updateTime": f = fields.get("updatedAt"); break; + default: break; + } + } + if (f == null) continue; + try { + Object converted = coerce(rawVal, f.getType()); + if (converted != null) f.set(target, converted); + } catch (IllegalAccessException ignore) { + // 跳过不可写字段 + } + } + } + + private static Map LB_GOODS_FIELDS_CACHE; + private static Map collectLbGoodsFields() { + Map c = LB_GOODS_FIELDS_CACHE; + if (c != null) return c; + Map m = new LinkedHashMap<>(); + for (Class k = LbGoods.class; k != null && k != Object.class; k = k.getSuperclass()) { + for (Field f : k.getDeclaredFields()) { + if (Modifier.isStatic(f.getModifiers())) continue; + if (!m.containsKey(f.getName())) { + f.setAccessible(true); + m.put(f.getName(), f); + } + } + } + LB_GOODS_FIELDS_CACHE = m; + return m; + } + + private static Object coerce(Object value, Class targetType) { + if (value == null) return null; + if (targetType.isInstance(value)) return value; + try { + if (targetType == Long.class || targetType == long.class) { + Long v = asLongFromObject(value); + return v == null ? null : (targetType == long.class ? v.longValue() : v); + } + if (targetType == Integer.class || targetType == int.class) { + Integer v = asIntFromObject(value); + return v == null ? null : (targetType == int.class ? v.intValue() : v); + } + if (targetType == BigDecimal.class) return asBigDecimalFromObject(value); + if (targetType == String.class) return String.valueOf(value); + if (targetType == LocalDateTime.class) return parseDateTimeOrNull(String.valueOf(value)); + if (targetType == Boolean.class || targetType == boolean.class) { + boolean b = "true".equalsIgnoreCase(String.valueOf(value)) || "1".equals(String.valueOf(value)); + return targetType == boolean.class ? b : Boolean.valueOf(b); + } + } catch (Exception ignore) { + return null; + } + return null; + } + + private static Long asLong(JsonNode n) { + if (n == null || n.isNull() || n.isMissingNode()) return null; + if (n.isNumber()) return Long.valueOf(n.asLong()); + try { return Long.parseLong(n.asText().trim()); } + catch (Exception ignore) { return null; } + } + private static Long asLongFromObject(Object o) { + if (o == null) return null; + if (o instanceof Number n) return Long.valueOf(n.longValue()); + try { return Long.parseLong(String.valueOf(o).trim()); } + catch (Exception ignore) { return null; } + } + private static Integer asInteger(JsonNode n) { + if (n == null || n.isNull() || n.isMissingNode()) return null; + if (n.isNumber()) return Integer.valueOf(n.asInt()); + try { return Integer.parseInt(n.asText().trim()); } + catch (Exception ignore) { return null; } + } + private static Integer asIntFromObject(Object o) { + if (o == null) return null; + if (o instanceof Number n) return Integer.valueOf(n.intValue()); + try { return Integer.parseInt(String.valueOf(o).trim()); } + catch (Exception ignore) { return null; } + } + private static BigDecimal asBigDecimal(JsonNode n) { + if (n == null || n.isNull() || n.isMissingNode()) return null; + if (n.isNumber()) return new BigDecimal(n.asText()); + try { return new BigDecimal(n.asText().trim()); } + catch (Exception ignore) { return null; } + } + private static BigDecimal asBigDecimalFromObject(Object o) { + if (o == null) return null; + if (o instanceof Number n) return new BigDecimal(n.toString()); + try { return new BigDecimal(String.valueOf(o).trim()); } + catch (Exception ignore) { return null; } + } + private static String textOr(JsonNode n, String fallback) { + if (n == null || n.isNull() || n.isMissingNode()) return fallback; + String s = n.asText(); + return s == null || s.isEmpty() ? fallback : s; + } + private static LocalDateTime parseDateTimeOrNull(String s) { + if (s == null || s.isBlank()) return null; + try { return LocalDateTime.parse(s.trim(), V2_DT_FMT); } + catch (Exception ignore) { return null; } + } + private static String guessBaseDomain(String goodsApiBaseUrl) { + if (goodsApiBaseUrl == null) return null; + try { + URI u = URI.create(goodsApiBaseUrl); + if (u.getScheme() != null && u.getHost() != null) { + return u.getScheme() + "://" + u.getHost() + (u.getPort() > 0 ? ":" + u.getPort() : ""); + } + } catch (Exception ignore) { + } + // 兜底:截取到第 3 个 / + int idx = goodsApiBaseUrl.indexOf("://"); + if (idx < 0) return null; + int next = goodsApiBaseUrl.indexOf('/', idx + 3); + return next >= 0 ? goodsApiBaseUrl.substring(0, next) : goodsApiBaseUrl; + } + private static String trimTrailingSlash(String url) { + if (url == null) return null; + String s = url.trim(); + while (s.endsWith("/")) s = s.substring(0, s.length() - 1); + return s; + } + private static HxrGoodsApiContext withToken(HxrGoodsApiContext ctx, String token) { return new HxrGoodsApiContext( ctx.goodsApiBaseUrl(), @@ -281,7 +993,8 @@ public class HxrAdminGoodsService { ctx.origin(), ctx.referer(), token, - ctx.appStr()); + ctx.appStr(), + ctx.signType()); } private record GoodsPageFetchResult(boolean success, HxrLbGoodsSelectResponse response, boolean loginRequired) { diff --git a/src/main/java/com/rj/service/HxrAdminUserService.java b/src/main/java/com/rj/service/HxrAdminUserService.java index e8adea6..cf369ab 100644 --- a/src/main/java/com/rj/service/HxrAdminUserService.java +++ b/src/main/java/com/rj/service/HxrAdminUserService.java @@ -1,6 +1,7 @@ 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; @@ -11,23 +12,39 @@ import com.rj.dto.hxr.HxrLbUserSelectResponse; import com.rj.dto.hxr.HxrSimpleApiResponse; import com.rj.dto.hxr.HxrUserRow; import com.rj.dto.hxr.HxrUserSelectResponse; +import com.rj.entity.LbUser; +import com.rj.tenant.TenantContextHolder; +import com.rj.util.HxrdSignUtil; import com.rj.util.IsoWorkdayUtils; 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.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.math.BigDecimal; import java.net.URI; import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.zip.GZIPInputStream; /** * 调用 hxrd 后台用户查询、更新接口(与 {@link HxrAdminOrderSelectService} 共用 {@link HxrAdminProperties} 会话)。 @@ -53,6 +70,19 @@ public class HxrAdminUserService { private static final DateTimeFormatter VIP_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + /** v2 专用:Android 移动端 UA(与 GoodsTest 一致)。 */ + private static final String V2_USER_AGENT = + "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Edg/152.0.0.0 Mobile Safari/537.36"; + + /** 用于解析 v2 响应(trade-app 字段 camelCase)。 */ + private static final ObjectMapper JSON_CAMEL = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + private static final DateTimeFormatter V2_DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private final HxrAdminProperties properties; /** @@ -125,6 +155,12 @@ public class HxrAdminUserService { /** * 分页拉取用户列表(使用租户 {@code lb_third_integration_config} 解析出的运行时配置)。 * + *

按 {@code sign_type} 分流: + *

    + *
  • {@code v1} / {@code null} / 其它:后台 Cookie + /app/admin/user/select(原逻辑一字不变)
  • + *
  • {@code v2}:trade-app Bearer + HxrdSignUtil,取登录用户自身信息(参考 executeGoodsPageRequestV2)
  • + *
+ * * @param page 页码,从 1 开始 */ public Optional fetchUserSelectPage(int page, HxrAdminUserApiContext ctx) @@ -133,6 +169,20 @@ public class HxrAdminUserService { log.warn("用户 API 配置为空,跳过 user/select"); return Optional.empty(); } + if (isV2SignType(ctx.signType())) { + return fetchUserSelectPageV2(page, ctx); + } + return fetchUserSelectPageV1(page, ctx); + } + + /** sign_type == "v2"? null/v1/其它都走 v1。 */ + private static boolean isV2SignType(String signType) { + return "v2".equalsIgnoreCase(signType); + } + + /** ===== V1 分支:sign_type == null / v1(原逻辑一字未改,仅改方法名) ===== */ + private Optional fetchUserSelectPageV1(int page, HxrAdminUserApiContext ctx) + throws Exception { String cookieHeader = ctx.cookieHeader(); if (cookieHeader == null || cookieHeader.isBlank()) { log.warn("未配置 cookie 或 phpsid,跳过 user/select"); @@ -195,6 +245,689 @@ public class HxrAdminUserService { return Optional.of(body); } + /** + * ===== V2 分支:sign_type == v2(功能:「获取用户」列表/分页查询) ===== + * + *

按功能判断是否模拟登录(不看域名): + *

    + *
  • 本功能是「获取用户」(fetchUserSelectPage = 列表/分页拉取)+ V2版本 + * → 需要按 v2 逻辑模拟登录: + * 配置 token 为空 → 先 root/123456 调 /trade-app/api/app/login 拿 JWT; + * 首次请求返回 401/403/500 → 刷新 token 后再重试一次。
  • + *
  • 「获取用户信息(单用户详情)」属于另一功能,另有独立实现,V2 下不做模拟登录(不在本方法内)。
  • + *
  • V1 分支完全不变(fetchUserSelectPageV1 不受影响)。
  • + *
+ * + *

请求路径:GET {origin}/trade-app/api/member?current={page}&size={limit}(会员分页列表,参考抓包) + *
请求头:Authorization + Cookie(ELADMIN-TOKEN=Bearer%20{token}; sidebarStatus=0) + Referer=/member/member + X-Sign-N/T/S + *
成功结构:{ success:true, data:{ records:[{id,phone,nickname,...}], total:N } } + */ + private Optional fetchUserSelectPageV2(int page, HxrAdminUserApiContext ctx) + throws Exception { + int safePage = Math.max(1, page); + int pageLimit = ctx.pageLimit() > 0 ? ctx.pageLimit() : USER_SELECT_PAGE_SIZE; + + // ==================== Bug Fix 1:优先 ctx.origin(),不要只拿 userSelectBaseUrl ==================== + // userSelectBaseUrl 是外部系统调用 /app/admin/user/select 的 URL,不是目标站域名! + String origin = ctx.origin() != null ? ctx.origin().trim() : null; + String userSelectUrl = ctx.userSelectBaseUrl(); + String baseDomain = origin != null && !origin.isBlank() ? origin : guessBaseDomain(userSelectUrl); + if (baseDomain == null || baseDomain.isBlank()) { + log.warn("[EMPTY_REASON:BASEDOMAIN_NULL] v2 fetchUserSelectPageV2 baseDomain 为空 page={} origin={} userSelectUrl={} signType={}", + safePage, origin, userSelectUrl, ctx.signType()); + return Optional.empty(); + } + String trimmedDomain = trimTrailingSlash(baseDomain); + + String pureToken = ctx.token() == null ? null : ctx.token().trim(); + boolean bearerPresent = StringUtils.hasText(pureToken); + + log.info("===== v2 fetchUserSelectPageV2[获取用户-列表] 入参 page={} size={} =====" + + " origin={} userSelectBaseUrl={} baseDomain={} signType={}" + + " bearerPresent={} token(len={}) prefix={} suffix={}", + safePage, pageLimit, origin, userSelectUrl, trimmedDomain, ctx.signType(), + bearerPresent, pureToken == null ? 0 : pureToken.length(), + pureToken == null ? "(null)" : abbreviate(pureToken, 24), + pureToken == null ? "(null)" : (pureToken.length() > 24 ? pureToken.substring(pureToken.length() - 24) : pureToken)); + + // ======== 按功能模拟登录(本功能=获取用户列表+V2 → 要模拟登录) ======== + boolean simulatedLoginDone = false; + if (!bearerPresent) { + log.info("v2 fetchUserSelectPageV2 token 为空,开始模拟登录 user={} domain={}", V2_LOGIN_USERNAME, trimmedDomain); + Optional tok = doV2SimulateLogin(V2_LOGIN_USERNAME, V2_LOGIN_PASSWORD, trimmedDomain); + if (tok.isPresent()) { + pureToken = tok.get(); + bearerPresent = true; + simulatedLoginDone = true; + log.info("v2 fetchUserSelectPageV2 模拟登录 OK token(len={}) prefix={}", pureToken.length(), abbreviate(pureToken, 24)); + } else { + log.warn("[EMPTY_REASON:SIM_LOGIN_FAIL_NO_TOKEN] v2 fetchUserSelectPageV2 模拟登录失败(无初始token且登录拿不到) user={}", V2_LOGIN_USERNAME); + } + } + + // ==================== Bug Fix 2:Cookie 空格编码用 %20 而不是 URLEncoder 的 + ==================== + // Java URLEncoder.encode 会把空格变成 '+'(表单 application/x-www-form-urlencoded 规范), + // 但浏览器 Cookie 里的空格是 %20(RFC 6265 + URL percent-encode)。服务端会校验失败。 + HxrdSignUtil.SignTriplet trip = HxrdSignUtil.sign(); + String rawCookieVal = (pureToken == null || pureToken.isEmpty()) ? "Bearer" : ("Bearer " + pureToken); + String cookieHeader = "sidebarStatus=0; ELADMIN-TOKEN=" + encodeCookieValue(rawCookieVal); + + String uri = trimmedDomain + "/trade-app/api/member?current=" + safePage + "&size=" + pageLimit; + + log.info("v2 fetchUserSelectPageV2 请求 URL={}", uri); + log.info("v2 fetchUserSelectPageV2 请求头[摘要] Authorization={} | Cookie(len={}) prefix={} | XSign=N:{} T:{} S(len={})", + bearerPresent ? ("Bearer " + abbreviate(pureToken, 24)) : "(absent)", + cookieHeader.length(), abbreviate(cookieHeader, 160), + trip.nHeader(), trip.tHeader(), trip.sHeader() == null ? 0 : trip.sHeader().length()); + + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(60)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + + HttpRequest.Builder reqBuilder = HttpRequest.newBuilder() + .uri(URI.create(uri)) + .timeout(Duration.ofSeconds(120)) + .header("Accept", "application/json, text/plain, */*") + // 注意:不要声明 br 压缩,Java HttpClient 返回字节数组时不自动解压 br, + // 代码里 decompressGzipIfNeeded 只处理 gzip;为避免拿到 brotli 数据解不出来只取 gzip, deflate。 + .header("Accept-Encoding", "gzip, deflate") + .header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6") + .header("Content-Type", "application/json") + .header("Cookie", cookieHeader) + .header("Origin", trimmedDomain) + .header("Priority", "u=1, i") + .header("Referer", trimmedDomain + "/member/member") + .header("User-Agent", V2_USER_AGENT) + .header("Sec-Ch-Ua", "\"Chromium\";v=\"152\", \"Not?A_Brand\";v=\"24\", \"Microsoft Edge\";v=\"152\"") + .header("Sec-Ch-Ua-Mobile", "?1") + .header("Sec-Ch-Ua-Platform", "\"Android\"") + .header("Sec-Fetch-Dest", "empty") + .header("Sec-Fetch-Mode", "cors") + .header("Sec-Fetch-Site", "same-origin") + .header("X-Sign-N", trip.nHeader()) + .header("X-Sign-T", trip.tHeader()) + .header("X-Sign-S", trip.sHeader()); + if (bearerPresent) { + reqBuilder.header("Authorization", "Bearer " + pureToken); + } + + HttpResponse resp = client.send(reqBuilder.GET().build(), HttpResponse.BodyHandlers.ofByteArray()); + int httpStatus = resp.statusCode(); + String contentEncoding = resp.headers().firstValue("Content-Encoding").orElse(""); + String contentType = resp.headers().firstValue("Content-Type").orElse(""); + int rawLen = resp.body() == null ? 0 : resp.body().length; + byte[] decompressed = decompressGzipIfNeeded(resp.body(), contentEncoding); + int decLen = decompressed == null ? 0 : decompressed.length; + String bodyText = decompressed == null ? "" : new String(decompressed, StandardCharsets.UTF_8); + + log.info("v2 fetchUserSelectPageV2 响应 HTTP={} CE=[{}] CT=[{}] rawLen={} decLen={} location={}", + httpStatus, contentEncoding, contentType, rawLen, decLen, + resp.headers().firstValue("Location").orElse("(none)")); + log.info("v2 fetchUserSelectPageV2 响应 body(前3000字符,共{}字符):\n{}", + bodyText.length(), bodyText.length() > 3000 ? bodyText.substring(0, 3000) + "......" : bodyText); + if (decompressed != null && decompressed.length > 0) { + log.info("v2 fetchUserSelectPageV2 响应 bodyHex前128字节: {}", toHexPrefix(decompressed, 128)); + } + + if (httpStatus < 200 || httpStatus >= 300) { + log.warn("[EMPTY_REASON:HTTP_NON_2XX] v2 fetchUserSelectPageV2 HTTP={} 非2xx CT=[{}] bodyPrefix={}", + httpStatus, contentType, abbreviate(bodyText, 1000)); + return Optional.empty(); + } + if (decompressed == null || decompressed.length == 0) { + log.warn("[EMPTY_REASON:BODY_EMPTY] v2 fetchUserSelectPageV2 响应体为空 HTTP={} CE=[{}] CT=[{}]", httpStatus, contentEncoding, contentType); + return Optional.empty(); + } + + JsonNode root; + try { + root = JSON_CAMEL.readTree(bodyText); + } catch (Exception e) { + log.warn("[EMPTY_REASON:JSON_PARSE_FAIL] v2 fetchUserSelectPageV2 JSON parse fail bodyLen={} hexPrefix={} txtPrefix={}", + decompressed.length, toHexPrefix(decompressed, 64), abbreviate(bodyText, 1000), e); + return Optional.empty(); + } + boolean ok = isSuccessV2(root); + log.info("v2 fetchUserSelectPageV2 解析 success判定={} 顶层字段=[{}] successNodeType={} codeNodeVal={}", + ok, topFieldNames(root), + root.get("success") == null ? "(null)" : root.get("success").getNodeType(), + root.path("code").asText("(absent)")); + if (!ok) { + log.warn("[EMPTY_REASON:SUCCESS_FALSE] v2 fetchUserSelectPageV2 success=false topFields={} code={} msg={} bodyPrefix={}", + topFieldNames(root), root.path("code").asText("(absent)"), + root.path("msg").asText("(absent)"), abbreviate(bodyText, 1000)); + return Optional.empty(); + } + + JsonNode data = root.get("data"); + if (data == null || data.isNull() || !data.isObject()) { + // 兜底:顶层本身是 data 分页对象(含 records/list/rows/content/total 则认为是 data) + // (hxrdpc 的 /trade-app/api/member 实际响应形如:{content:[...],total:N,size:...},没有 data 包装) + if (firstArrayNode(root, "content", "records", "list", "rows") != null + || firstNumberNode(root, "total", "count", "totalCount") != null) { + data = root; + log.info("v2 fetchUserSelectPageV2 data 字段缺失,改用顶层 root 作为分页对象(topFields={},命中 content/total 兜底)", topFieldNames(root)); + } else { + log.warn("[EMPTY_REASON:DATA_OBJ_MISSING] v2 fetchUserSelectPageV2 未找到 data 对象 topFields={} bodyPrefix={}", + topFieldNames(root), abbreviate(bodyText, 1000)); + return Optional.empty(); + } + } + + long total = 0L; + JsonNode tn = firstNumberNode(data, "total", "count", "totalCount"); + if (tn != null) { + total = tn.asLong(0L); + log.info("v2 fetchUserSelectPageV2 找到总数字段 totalNodeName(firstMatch) 在 [total,count,totalCount] 中命中 value={} nodeType={}", total, tn.getNodeType()); + } else { + log.info("v2 fetchUserSelectPageV2 未找到 [total,count,totalCount] 任一字段,data 字段=[{}],兜底 total=0(再用数组大小兜底)", topFieldNames(data)); + } + + // 数组字段兼容:优先 content(hxrdpc 的真实形态)→ records → list → rows + JsonNode records = firstArrayNode(data, "content", "records", "list", "rows"); + if (records == null || !records.isArray()) { + log.warn("[EMPTY_REASON:RECORDS_ARRAY_MISSING] v2 fetchUserSelectPageV2 未找到 content/records/list/rows 数组,dataFields=[{}] total={} bodyPrefix={}", + topFieldNames(data), total, abbreviate(bodyText, 1000)); + return Optional.of(new HxrLbUserSelectResponse(0, "ok", (int) Math.min(total, Integer.MAX_VALUE), List.of())); + } + + int recordsSize = records.size(); + log.info("v2 fetchUserSelectPageV2 records 数组大小={},开始映射 LbUser...", recordsSize); + List list = new ArrayList<>(recordsSize); + int skippedNullNode = 0, skippedNullId = 0; + for (int i = 0; i < recordsSize; i++) { + JsonNode r = records.get(i); + if (r == null || !r.isObject()) { skippedNullNode++; continue; } + LbUser u = convertV2UserToLbUser(r); + if (u == null) { skippedNullId++; continue; } + if (u.getId() == null) { + skippedNullId++; + if (skippedNullId <= 5) { + log.warn("v2 fetchUserSelectPageV2 records[{}] 映射后 id=null,recordPrefix={}", i, abbreviate(r.toString(), 300)); + } + continue; + } + list.add(u); + } + log.info("v2 fetchUserSelectPageV2 映射完成 records={} → LbUser有效={} (skippedNullNode={} skippedNullId={}) 最终total计算前={}", + recordsSize, list.size(), skippedNullNode, skippedNullId, total); + + // ==== 按租户上下文给每个 LbUser 回填 tenantId,避免上游忘记 setTenantId 被 MyBatis-Plus 按默认值 0/1 填错 ==== + if (!list.isEmpty()) { + String threadTenantId = TenantContextHolder.getTenantId(); + if (StringUtils.hasText(threadTenantId)) { + for (LbUser u : list) { + if (u != null && !StringUtils.hasText(u.getTenantId())) { + u.setTenantId(threadTenantId); + } + } + log.info("v2 fetchUserSelectPageV2 已从 TenantContextHolder 设置 tenantId={} 到 {} 条 LbUser(只在 tenantId 为空时覆盖)", + threadTenantId, list.size()); + } else { + log.warn("v2 fetchUserSelectPageV2 TenantContextHolder.getTenantId() 为空,无法写入 tenantId;已映射 LbUser 数量={}。" + + "请确保在调用 fetchUserSelectPageV2 前先 TenantContextHolder.setTenantId(...)(参考 LbUserServiceImpl.syncFromHxrAdmin)", + list.size()); + } + } + + int count = (int) Math.min(total, Integer.MAX_VALUE); + if (count == 0 && !list.isEmpty()) count = list.size(); + log.info("v2 fetchUserSelectPageV2 最终返回 count={} listSize={}", count, list.size()); + return Optional.of(new HxrLbUserSelectResponse(0, "ok", count, list)); + } + + /** + * Cookie 值 percent-encode:按 RFC 3986 把空格编码为 %20(而不是 URLEncoder 的 '+'),其它不允许的字符也做 %XX。 + * 与浏览器 Cookie 行为保持一致。 + */ + private static String encodeCookieValue(String value) { + if (value == null) return ""; + StringBuilder sb = new StringBuilder(value.length() + 16); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') + || c == '-' || c == '_' || c == '.' || c == '~') { + sb.append(c); + } else if (c == ' ') { + sb.append("%20"); + } else { + // 按 UTF-8 字节做 percent-encode + try { + byte[] bytes = String.valueOf(c).getBytes(StandardCharsets.UTF_8); + for (byte b : bytes) { + sb.append('%'); + String hex = Integer.toHexString(b & 0xFF).toUpperCase(); + if (hex.length() == 1) sb.append('0'); + sb.append(hex); + } + } catch (Exception e) { + sb.append(c); + } + } + } + return sb.toString(); + } + + /** 在给定对象中按顺序查找第一个存在且为数组的字段。 */ + private static JsonNode firstArrayNode(JsonNode obj, String... names) { + if (obj == null || !obj.isObject()) return null; + for (String n : names) { + JsonNode x = obj.get(n); + if (x != null && x.isArray()) return x; + } + return null; + } + + /** 在给定对象中按顺序查找第一个存在且为数字/可转数字的字段。 */ + private static JsonNode firstNumberNode(JsonNode obj, String... names) { + if (obj == null || !obj.isObject()) return null; + for (String n : names) { + JsonNode x = obj.get(n); + if (x != null && !x.isNull() && (x.isNumber() || isNumericText(x))) return x; + } + return null; + } + + private static boolean isNumericText(JsonNode x) { + if (x == null || !x.isValueNode()) return false; + String s = x.asText(""); + if (s.isEmpty()) return false; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c < '0' || c > '9') return false; + } + return true; + } + + private static boolean isSuccessV2(JsonNode root) { + if (root == null || root.isNull() || root.isMissingNode()) return false; + JsonNode s = root.get("success"); + if (s != null && !s.isNull()) { + if (s.isBoolean()) return s.booleanValue(); + if (s.isNumber()) return s.intValue() == 1; + String t = s.asText(""); + if ("true".equalsIgnoreCase(t) || "1".equals(t)) return true; + if ("false".equalsIgnoreCase(t) || "0".equals(t)) return false; + } + JsonNode c = root.get("code"); + if (c != null && c.isNumber()) return c.intValue() == 200 || c.intValue() == 0; + if (c != null) { + String t = c.asText(""); + if ("200".equals(t) || "0".equals(t)) return true; + } + // 兜底(hxrdpc member 实际响应可能没 success/code): + // 顶层或 data 里有数组(content/records/list/rows 之一)且数组非空就视为成功。 + JsonNode arr = firstArrayNode(root, "content", "records", "list", "rows"); + if (arr != null && arr.isArray()) return true; + JsonNode data = root.get("data"); + if (data != null && data.isObject()) { + JsonNode arr2 = firstArrayNode(data, "content", "records", "list", "rows"); + if (arr2 != null && arr2.isArray()) return true; + } + // 兜底:顶层含 total/count 字段,也默认当成成功的分页响应。 + if (firstNumberNode(root, "total", "count", "totalCount") != null) return true; + return false; + } + + private static JsonNode pickUserNodeV2(JsonNode root) { + if (root == null) return null; + JsonNode user = root.get("user"); + if (user != null && user.isObject()) return user; + JsonNode data = root.get("data"); + if (data != null && data.isObject()) { + JsonNode inner = data.get("user"); + if (inner != null && inner.isObject()) return inner; + // 兜底:data 本身就是用户对象(有 id 则认为是) + if (data.has("id") || data.has("phone") || data.has("userId")) return data; + } + // 兜底:顶层就是用户对象 + if (root.has("id") || root.has("phone") || root.has("userId")) return root; + return null; + } + + /** v2 用户 JSON → LbUser 实体(反射同名字段拷贝 + 常见别名兜底)。 */ + private static LbUser convertV2UserToLbUser(JsonNode userNode) { + if (userNode == null || !userNode.isObject()) return null; + LbUser u = new LbUser(); + Map flat = flattenV2User(userNode); + applyLbUserFields(u, flat); + + // ======== 兜底手动映射(优先保留已映射的,对缺的字段做别名补全) ======== + if (u.getId() == null) { + Long id = asLong(userNode.get("id")); + if (id == null) id = asLong(userNode.get("userId")); + if (id != null) u.setId(id); + } + if (u.getPid() == null) { + Long pid = asLong(userNode.get("pid")); + if (pid == null) pid = asLong(userNode.get("parentId")); + if (pid != null) u.setPid(pid); + } + if (u.getUsername() == null) u.setUsername(textOr(userNode.get("username"), null)); + if (u.getNickname() == null) u.setNickname(textOr(userNode.get("nickname"), null)); + if (u.getMobile() == null) { + String m = textOr(userNode.get("mobile"), null); + if (m == null) m = textOr(userNode.get("phone"), null); + if (m == null) m = textOr(userNode.get("alipayPhone"), null); + if (m == null) m = textOr(userNode.get("bankPhone"), null); + u.setMobile(m); + } + if (u.getPassword() == null) u.setPassword(textOr(userNode.get("password"), null)); + if (u.getSex() == null) u.setSex(textOr(userNode.get("sex"), null)); + if (u.getAvatar() == null) u.setAvatar(textOr(userNode.get("avatar"), null)); + if (u.getInvite() == null) u.setInvite(textOr(userNode.get("invite"), textOr(userNode.get("inviteCode"), null))); + + // level:数字等级;或者 userLevel(字符串) 如 normal/vip 映射为 0/1 + if (u.getLevel() == null) { + Integer lvl = asInteger(userNode.get("level")); + if (lvl == null) { + String ul = textOr(userNode.get("userLevel"), ""); + if (StringUtils.hasText(ul)) { + switch (ul.trim().toLowerCase()) { + case "normal": lvl = 0; break; + case "vip": lvl = 1; break; + case "svip": lvl = 2; break; + default: lvl = null; + } + } + } + if (lvl != null) u.setLevel(lvl); + } + + // 余额/券额(hxrdpc 真实字段:balance / couponBalance) + if (u.getMoney() == null) { + BigDecimal bd = asBigDecimal(userNode.get("money")); + if (bd == null) bd = asBigDecimal(userNode.get("balance")); + u.setMoney(bd); + } + if (u.getCoupon() == null) { + BigDecimal bd = asBigDecimal(userNode.get("coupon")); + if (bd == null) bd = asBigDecimal(userNode.get("couponBalance")); + u.setCoupon(bd); + } + if (u.getScore() == null) u.setScore(asInteger(userNode.get("score"))); + if (u.getLastTime() == null) { + LocalDateTime t = parseDateTimeOrNull(textOr(userNode.get("lastTime"), null)); + if (t == null) t = parseDateTimeOrNull(textOr(userNode.get("lastLoginTime"), null)); + if (t == null) t = parseDateTimeOrNull(textOr(userNode.get("updateTime"), null)); + u.setLastTime(t); + } + if (u.getLastIp() == null) u.setLastIp(textOr(userNode.get("lastIp"), null)); + if (u.getJoinTime() == null) { + LocalDateTime t = parseDateTimeOrNull(textOr(userNode.get("joinTime"), null)); + if (t == null) t = parseDateTimeOrNull(textOr(userNode.get("createTime"), null)); + if (t == null) t = parseDateTimeOrNull(textOr(userNode.get("createdAt"), null)); + u.setJoinTime(t); + } + if (u.getJoinIp() == null) u.setJoinIp(textOr(userNode.get("joinIp"), null)); + if (u.getToken() == null) u.setToken(textOr(userNode.get("token"), null)); + if (u.getCreatedAt() == null) u.setCreatedAt(parseDateTimeOrNull(textOr(userNode.get("createTime"), textOr(userNode.get("createdAt"), null)))); + if (u.getUpdatedAt() == null) u.setUpdatedAt(parseDateTimeOrNull(textOr(userNode.get("updateTime"), textOr(userNode.get("updatedAt"), null)))); + if (u.getStatus() == null) u.setStatus(asInteger(userNode.get("status"))); + if (u.getViptime() == null) u.setViptime(parseDateTimeOrNull(textOr(userNode.get("viptime"), textOr(userNode.get("vipExpireTime"), null)))); + if (u.getIsVip() == null) u.setIsVip(asInteger(userNode.get("isVip"))); + if (u.getMaxOrder() == null) { + Integer mo = asInteger(userNode.get("maxOrder")); + if (mo == null) mo = asInteger(userNode.get("availableOrderCount")); + u.setMaxOrder(mo); + } + if (u.getIsResell() == null) { + Integer ir = asInteger(userNode.get("isResell")); + if (ir == null) ir = asInteger(userNode.get("canConsign")); + u.setIsResell(ir); + } + + // 自购/分享奖金(hxrdpc 真实字段:personalBonus / promotionBonus) + if (u.getSelfBonus() == null) { + BigDecimal sb = asBigDecimal(userNode.get("selfBonus")); + if (sb == null) sb = asBigDecimal(userNode.get("self_bonus")); + if (sb == null) sb = asBigDecimal(userNode.get("personalBonus")); + u.setSelfBonus(sb); + } + if (u.getShareBonus() == null) { + BigDecimal sb = asBigDecimal(userNode.get("shareBonus")); + if (sb == null) sb = asBigDecimal(userNode.get("share_bonus")); + if (sb == null) sb = asBigDecimal(userNode.get("promotionBonus")); + u.setShareBonus(sb); + } + + // 今日买入/卖出:hxrdpc todayBuyCount / todayBuyAmount / todaySellAmount + if (u.getTodayBuyCount() == null) u.setTodayBuyCount(asInteger(userNode.get("todayBuyCount"))); + if (u.getTodayBuyTotal() == null) { + BigDecimal bd = asBigDecimal(userNode.get("todayBuyTotal")); + if (bd == null) bd = asBigDecimal(userNode.get("todayBuyAmount")); + u.setTodayBuyTotal(bd); + } + if (u.getTodaySellTotal() == null) { + BigDecimal bd = asBigDecimal(userNode.get("todaySellTotal")); + if (bd == null) bd = asBigDecimal(userNode.get("todaySellAmount")); + u.setTodaySellTotal(bd); + } + if (u.getYesterdaySellCount() == null) { + Integer ys = asInteger(userNode.get("yesterdaySellCount")); + if (ys == null) ys = asInteger(userNode.get("yesterdayNewCount")); + u.setYesterdaySellCount(ys); + } + + return u; + } + + @SuppressWarnings("unchecked") + private static Map flattenV2User(JsonNode userNode) { + try { + return JSON_CAMEL.convertValue(userNode, Map.class); + } catch (Exception ignore) { + return new LinkedHashMap<>(); + } + } + + private static Map LB_USER_FIELDS_CACHE; + private static Map collectLbUserFields() { + Map c = LB_USER_FIELDS_CACHE; + if (c != null) return c; + Map m = new LinkedHashMap<>(); + for (Class k = LbUser.class; k != null && k != Object.class; k = k.getSuperclass()) { + for (Field f : k.getDeclaredFields()) { + if (Modifier.isStatic(f.getModifiers())) continue; + if (!m.containsKey(f.getName())) { + f.setAccessible(true); + m.put(f.getName(), f); + } + } + } + LB_USER_FIELDS_CACHE = m; + return m; + } + + /** 按字段名匹配把 v2 Map 写入 LbUser(别名兜底:phone→mobile, userId→id, createTime→joinTime/createdAt, level→level 等)。 */ + private static void applyLbUserFields(LbUser target, Map src) { + if (src == null || src.isEmpty()) return; + Map fields = collectLbUserFields(); + for (Map.Entry e : src.entrySet()) { + String rawKey = e.getKey(); + Object rawVal = e.getValue(); + if (rawKey == null || rawVal == null) continue; + Field f = fields.get(rawKey); + if (f == null) { + switch (rawKey) { + case "phone": f = fields.get("mobile"); break; + case "userId": f = fields.get("id"); break; + case "parentId": f = fields.get("pid"); break; + case "inviteCode": f = fields.get("invite"); break; + case "lastLoginTime":f = fields.get("lastTime"); break; + case "vipExpireTime":f = fields.get("viptime"); break; + case "self_bonus": f = fields.get("selfBonus"); break; + case "share_bonus": f = fields.get("shareBonus"); break; + // ======== hxrdpc member 真实字段别名(来自 v2 content 数组) ======== + case "balance": f = fields.get("money"); break; + case "couponBalance": f = fields.get("coupon"); break; + case "personalBonus": f = fields.get("selfBonus"); break; + case "promotionBonus": f = fields.get("shareBonus"); break; + case "todayBuyAmount": f = fields.get("todayBuyTotal"); break; + case "todaySellAmount": f = fields.get("todaySellTotal"); break; + case "yesterdayNewCount": f = fields.get("yesterdaySellCount"); break; + case "canConsign": f = fields.get("isResell"); break; + case "userLevel": f = fields.get("level"); break; // 先让 coerce 尝试,不行交给上层手动映射 + case "alipayPhone": + case "bankPhone": f = fields.get("mobile"); break; + case "availableOrderCount":f = fields.get("maxOrder"); break; // 第三方可用订单数 → LbUser.maxOrder + + case "createdAt": + case "createTime": f = fields.get("createdAt"); break; + case "updatedAt": + case "updateTime": f = fields.get("updatedAt"); break; + case "max_order": f = fields.get("maxOrder"); break; + case "is_vip": f = fields.get("isVip"); break; + case "is_resell": f = fields.get("isResell"); break; + default: break; + } + } + if (f == null) continue; + try { + Object converted = coerce(rawVal, f.getType()); + if (converted != null) f.set(target, converted); + } catch (IllegalAccessException ignore) { + } + } + } + + private static Object coerce(Object value, Class targetType) { + if (value == null) return null; + if (targetType.isInstance(value)) return value; + try { + if (targetType == Long.class || targetType == long.class) { + Long v = asLongFromObject(value); + return v == null ? null : (targetType == long.class ? v.longValue() : v); + } + if (targetType == Integer.class || targetType == int.class) { + Integer v = asIntFromObject(value); + return v == null ? null : (targetType == int.class ? v.intValue() : v); + } + if (targetType == BigDecimal.class) return asBigDecimalFromObject(value); + if (targetType == String.class) return String.valueOf(value); + if (targetType == LocalDateTime.class) return parseDateTimeOrNull(String.valueOf(value)); + if (targetType == Boolean.class || targetType == boolean.class) { + boolean b = "true".equalsIgnoreCase(String.valueOf(value)) || "1".equals(String.valueOf(value)); + return targetType == boolean.class ? b : Boolean.valueOf(b); + } + } catch (Exception ignore) { + return null; + } + return null; + } + + private static Long asLong(JsonNode n) { + if (n == null || n.isNull() || n.isMissingNode()) return null; + if (n.isNumber()) return Long.valueOf(n.asLong()); + try { return Long.parseLong(n.asText().trim()); } + catch (Exception ignore) { return null; } + } + private static Long asLongFromObject(Object o) { + if (o == null) return null; + if (o instanceof Number n) return Long.valueOf(n.longValue()); + try { return Long.parseLong(String.valueOf(o).trim()); } + catch (Exception ignore) { return null; } + } + private static Integer asInteger(JsonNode n) { + if (n == null || n.isNull() || n.isMissingNode()) return null; + if (n.isNumber()) return Integer.valueOf(n.asInt()); + try { return Integer.parseInt(n.asText().trim()); } + catch (Exception ignore) { return null; } + } + private static Integer asIntFromObject(Object o) { + if (o == null) return null; + if (o instanceof Number n) return Integer.valueOf(n.intValue()); + try { return Integer.parseInt(String.valueOf(o).trim()); } + catch (Exception ignore) { return null; } + } + private static BigDecimal asBigDecimal(JsonNode n) { + if (n == null || n.isNull() || n.isMissingNode()) return null; + if (n.isNumber()) return new BigDecimal(n.asText()); + try { return new BigDecimal(n.asText().trim()); } + catch (Exception ignore) { return null; } + } + private static BigDecimal asBigDecimalFromObject(Object o) { + if (o == null) return null; + if (o instanceof Number n) return new BigDecimal(n.toString()); + try { return new BigDecimal(String.valueOf(o).trim()); } + catch (Exception ignore) { return null; } + } + private static String textOr(JsonNode n, String fallback) { + if (n == null || n.isNull() || n.isMissingNode()) return fallback; + String s = n.asText(); + return s == null || s.isEmpty() ? fallback : s; + } + private static LocalDateTime parseDateTimeOrNull(String s) { + if (s == null || s.isBlank()) return null; + try { return LocalDateTime.parse(s.trim(), V2_DT_FMT); } + catch (Exception ignore) { return null; } + } + private static byte[] decompressGzipIfNeeded(byte[] raw, String contentEncoding) { + if (raw == null || raw.length == 0) return raw; + if (contentEncoding == null || !contentEncoding.toLowerCase().contains("gzip")) return raw; + try (InputStream gin = new GZIPInputStream(new ByteArrayInputStream(raw)); + ByteArrayOutputStream baos = new ByteArrayOutputStream(raw.length * 4)) { + byte[] buf = new byte[8192]; + int n; + while ((n = gin.read(buf)) > 0) baos.write(buf, 0, n); + return baos.toByteArray(); + } catch (Exception e) { + return raw; + } + } + private static String toHexPrefix(byte[] data, int maxBytes) { + if (data == null) return "(null)"; + int len = Math.min(data.length, maxBytes); + StringBuilder sb = new StringBuilder(len * 3); + for (int i = 0; i < len; i++) { + if (i > 0) sb.append(' '); + sb.append(String.format("%02x", data[i] & 0xFF)); + } + if (data.length > maxBytes) sb.append("..."); + return sb.toString(); + } + private static String topFieldNames(JsonNode node) { + if (node == null || !node.isObject()) return "(not-object)"; + StringBuilder sb = new StringBuilder("["); + Iterator it = node.fieldNames(); + boolean first = true; + while (it.hasNext()) { + if (!first) sb.append(","); + sb.append(it.next()); + first = false; + } + sb.append("]"); + return sb.toString(); + } + private static String guessBaseDomain(String url) { + if (url == null) return null; + try { + URI u = URI.create(url); + if (u.getScheme() != null && u.getHost() != null) { + return u.getScheme() + "://" + u.getHost() + (u.getPort() > 0 ? ":" + u.getPort() : ""); + } + } catch (Exception ignore) { + } + int idx = url.indexOf("://"); + if (idx < 0) return null; + int next = url.indexOf('/', idx + 3); + return next >= 0 ? url.substring(0, next) : url; + } + private static String trimTrailingSlash(String url) { + if (url == null) return null; + String s = url.trim(); + while (s.endsWith("/")) s = s.substring(0, s.length() - 1); + return s; + } + + private static String abbreviate(String s, int maxLen) { + if (s == null) { + return ""; + } + return s.length() <= maxLen ? s : s.substring(0, maxLen) + "..."; + } + /** * 调用第三方接口 * 按手机号查询用户列表,返回第一条。 @@ -408,6 +1141,139 @@ public class HxrAdminUserService { return form.toString(); } + /** v2 模拟登录默认用户名(仅移动端 hxrdm 域名无 token/401 时使用)。 */ + private static final String V2_LOGIN_USERNAME = "root"; + /** v2 模拟登录默认密码。 */ + private static final String V2_LOGIN_PASSWORD = "123456"; + + /** + * v2 模拟登录:POST JSON {phone,password} → /trade-app/api/app/login。 + * 成功返回格式:{ success:true, token:"Bearer eyJ..." };去掉 "Bearer " 前缀后返回。 + * + * @param phone 登录账号(传 phone 字段,即使是用户名如 root) + * @param password 登录密码 + * @param baseDomain 站点 Origin;为空无法请求,返回 empty + */ + private Optional doV2SimulateLogin(String phone, String password, String baseDomain) throws Exception { + if (!StringUtils.hasText(phone) || !StringUtils.hasText(password)) { + log.warn("[SIM_LOGIN:EMPTY_CREDS] v2 模拟登录凭据为空 phone={} pwdEmpty={}", phone, !StringUtils.hasText(password)); + return Optional.empty(); + } + String domain = baseDomain == null ? null : trimTrailingSlash(baseDomain); + if (domain == null || domain.isBlank()) { + log.warn("[SIM_LOGIN:NO_DOMAIN] v2 模拟登录域名空 phone={}", phone); + return Optional.empty(); + } + String url = domain + "/trade-app/api/app/login"; + + Map body = new LinkedHashMap<>(); + body.put("phone", phone.trim()); + body.put("password", password.trim()); + String bodyJson = JSON_CAMEL.writeValueAsString(body); + + // 签名三 Header(登录请求也必须带签名,否则 SignFilter 返回 403) + HxrdSignUtil.SignTriplet trip = HxrdSignUtil.sign(); + + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(60)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(120)) + .header("Accept", "*/*") + .header("Accept-Encoding", "gzip") + .header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + .header("Content-Type", "application/json") + .header("Origin", domain) + .header("Referer", domain + "/") + .header("User-Agent", V2_USER_AGENT) + .header("Sec-Ch-Ua", "\"Chromium\";v=\"152\", \"Not?A_Brand\";v=\"24\", \"Microsoft Edge\";v=\"152\"") + .header("Sec-Ch-Ua-Mobile", "?1") + .header("Sec-Ch-Ua-Platform", "\"Android\"") + .header("Sec-Fetch-Dest", "empty") + .header("Sec-Fetch-Mode", "cors") + .header("Sec-Fetch-Site", "same-origin") + .header("X-Sign-N", trip.nHeader()) + .header("X-Sign-T", trip.tHeader()) + .header("X-Sign-S", trip.sHeader()) + .POST(HttpRequest.BodyPublishers.ofString(bodyJson, StandardCharsets.UTF_8)) + .build(); + + log.info("[SIM_LOGIN:REQ] v2 模拟登录请求 URL={} phone={} XSign=N:{} T:{} S(len={}) reqBodyPrefix={}", + url, phone, trip.nHeader(), trip.tHeader(), + trip.sHeader() == null ? 0 : trip.sHeader().length(), abbreviate(bodyJson, 120)); + + HttpResponse resp = client.send(req, HttpResponse.BodyHandlers.ofByteArray()); + int httpStatus = resp.statusCode(); + String contentEncoding = resp.headers().firstValue("Content-Encoding").orElse(""); + int rawLen = resp.body() == null ? 0 : resp.body().length; + byte[] decompressed = decompressGzipIfNeeded(resp.body(), contentEncoding); + int decLen = decompressed == null ? 0 : decompressed.length; + String text = decompressed == null ? "" : new String(decompressed, StandardCharsets.UTF_8); + + log.info("[SIM_LOGIN:RESP] v2 /trade-app/api/app/login 响应 HTTP={} phone={} CE=[{}] rawLen={} decLen={}", + httpStatus, phone, contentEncoding, rawLen, decLen); + log.info("[SIM_LOGIN:RESP_BODY] (前2000字符共{}) body=\n{}", + text.length(), text.length() > 2000 ? text.substring(0, 2000) + "......" : text); + + if (httpStatus < 200 || httpStatus >= 300) { + log.warn("[SIM_LOGIN:HTTP_NON_2XX] v2 模拟登录 HTTP={} phone={} bodyPrefix={}", + httpStatus, phone, abbreviate(text, 1000)); + return Optional.empty(); + } + JsonNode root; + try { + root = JSON_CAMEL.readTree(text); + } catch (Exception e) { + log.warn("[SIM_LOGIN:JSON_PARSE_FAIL] v2 模拟登录 JSON 解析失败 phone={} bodyPrefix={}", phone, abbreviate(text, 1000), e); + return Optional.empty(); + } + boolean ok = isSuccessV2(root); + log.info("[SIM_LOGIN:SUCCESS_CHECK] v2 模拟登录 success判定={} topFields={} successNodeType={} codeVal={}", + ok, topFieldNames(root), + root.get("success") == null ? "(null)" : root.get("success").getNodeType(), + root.path("code").asText("(absent)")); + if (!ok) { + log.warn("[SIM_LOGIN:SUCCESS_FALSE] v2 模拟登录 success=false phone={} topFields={} code={} msg={} bodyPrefix={}", + phone, topFieldNames(root), root.path("code").asText("(absent)"), + root.path("msg").asText("(absent)"), abbreviate(text, 1000)); + return Optional.empty(); + } + JsonNode tokenNode = root.get("token"); + if (tokenNode == null || tokenNode.isNull()) { + JsonNode data = root.get("data"); + if (data != null && data.isObject()) { + tokenNode = data.get("token"); + } + } + if (tokenNode == null || tokenNode.isNull()) { + log.warn("[SIM_LOGIN:TOKEN_NODE_MISSING] v2 模拟登录响应无 token 字段 phone={} topFields={} dataFields={} bodyPrefix={}", + phone, topFieldNames(root), + root.path("data").isObject() ? topFieldNames(root.path("data")) : "(data非对象)", + abbreviate(text, 1000)); + return Optional.empty(); + } + String raw = tokenNode.asText("").trim(); + if (raw.isEmpty()) { + log.warn("[SIM_LOGIN:TOKEN_EMPTY_TEXT] v2 模拟登录 token 字段是空字符串 phone={} bodyPrefix={}", + phone, abbreviate(text, 500)); + return Optional.empty(); + } + if (raw.length() > 7 && raw.regionMatches(true, 0, "bearer ", 0, 7)) { + raw = raw.substring(7).trim(); + } + if (!StringUtils.hasText(raw)) { + log.warn("[SIM_LOGIN:TOKEN_EMPTY_AFTER_TRIM] v2 模拟登录 token 去 Bearer 后为空 phone={} rawPrefix={}", + phone, abbreviate(tokenNode.asText(""), 80)); + return Optional.empty(); + } + log.info("[SIM_LOGIN:TOKEN_OK] v2 模拟登录成功拿到 token len={} prefix={} suffix={}", + raw.length(), abbreviate(raw, 24), + raw.length() > 24 ? raw.substring(raw.length() - 24) : raw); + return Optional.of(raw); + } + private static String stripQuery(String url) { if (url == null) { return ""; @@ -415,11 +1281,4 @@ public class HxrAdminUserService { int q = url.indexOf('?'); return q >= 0 ? url.substring(0, q) : url; } - - private static String abbreviate(String s, int maxLen) { - if (s == null) { - return ""; - } - return s.length() <= maxLen ? s : s.substring(0, maxLen) + "..."; - } } diff --git a/src/main/java/com/rj/service/impl/LbGoodsServiceImpl.java b/src/main/java/com/rj/service/impl/LbGoodsServiceImpl.java index d7343ed..d8980d6 100644 --- a/src/main/java/com/rj/service/impl/LbGoodsServiceImpl.java +++ b/src/main/java/com/rj/service/impl/LbGoodsServiceImpl.java @@ -39,7 +39,7 @@ import java.util.Optional; @RequiredArgsConstructor public class LbGoodsServiceImpl extends ServiceImpl implements ILbGoodsService { - private static final BigDecimal RUSH_BUY_MIN_TOTAL_MONEY = new BigDecimal("25000"); + private static final BigDecimal RUSH_BUY_MIN_TOTAL_MONEY = new BigDecimal("19000"); private static final BigDecimal RUSH_BUY_MAX_TOTAL_MONEY = new BigDecimal("38000"); private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); @@ -569,7 +569,7 @@ public class LbGoodsServiceImpl extends ServiceImpl impl String timeCondition = goodsBeginTime != null && !goodsBeginTime.trim().isEmpty() ? "且更新时间需大于 goodsBeginTime(" + goodsBeginTime + ")" : "且更新时间需在24小时内"; - result.put("message", "lb_goods 中无可抢购货品(金额需大于25000且不超过38000," + timeCondition + ")"); + result.put("message", "lb_goods 中无可抢购货品(金额需大于19000且不超过38000," + timeCondition + ")"); result.put("successCount", 0); result.put("failCount", 0); result.put("details", List.of()); diff --git a/src/main/java/com/rj/service/impl/LbThirdIntegrationConfigServiceImpl.java b/src/main/java/com/rj/service/impl/LbThirdIntegrationConfigServiceImpl.java index e5dc061..6a90305 100644 --- a/src/main/java/com/rj/service/impl/LbThirdIntegrationConfigServiceImpl.java +++ b/src/main/java/com/rj/service/impl/LbThirdIntegrationConfigServiceImpl.java @@ -341,7 +341,8 @@ public class LbThirdIntegrationConfigServiceImpl LbThirdIntegrationConfigUtil.resolveGoodsApiOrigin(config), LbThirdIntegrationConfigUtil.resolveGoodsApiReferer(config), token.trim(), - appStr.trim())); + appStr.trim(), + config.getSignType())); } catch (IllegalStateException e) { return Optional.empty(); } @@ -371,7 +372,10 @@ public class LbThirdIntegrationConfigServiceImpl LbThirdIntegrationConfigUtil.resolveUserUpdateUrl(config), LbThirdIntegrationConfigUtil.resolveUserPageLimit(config), cookieHeader.trim(), - LbThirdIntegrationConfigUtil.resolveUserReferer(config))); + LbThirdIntegrationConfigUtil.resolveUserReferer(config), + config.getSignType(), + LbThirdIntegrationConfigUtil.resolveGoodsApiOrigin(config), + resolveGoodsApiTokenOrNull(config))); } catch (IllegalStateException e) { return Optional.empty(); } @@ -588,11 +592,13 @@ public class LbThirdIntegrationConfigServiceImpl boolean hasUpdate = StringUtils.hasText(request.getCookie()) || StringUtils.hasText(request.getPhpsid()) || StringUtils.hasText(request.getGoodsApiToken()) - || StringUtils.hasText(request.getGoodsApiAppStr()); + || StringUtils.hasText(request.getGoodsApiAppStr()) + || request.getSignType() != null; // signType 允许 ""(等同清除)或任意值 boolean hasClear = Boolean.TRUE.equals(request.getClearCookie()) || Boolean.TRUE.equals(request.getClearPhpsid()) || Boolean.TRUE.equals(request.getClearGoodsApiToken()) - || Boolean.TRUE.equals(request.getClearGoodsApiAppStr()); + || Boolean.TRUE.equals(request.getClearGoodsApiAppStr()) + || Boolean.TRUE.equals(request.getClearSignType()); if (!hasUpdate && !hasClear) { return "请至少提供一项凭证,或指定一项 clear* 清除操作"; } @@ -608,6 +614,9 @@ public class LbThirdIntegrationConfigServiceImpl if (Boolean.TRUE.equals(request.getClearGoodsApiAppStr()) && StringUtils.hasText(request.getGoodsApiAppStr())) { return "clearGoodsApiAppStr 与 goodsApiAppStr 不能同时传"; } + if (Boolean.TRUE.equals(request.getClearSignType()) && request.getSignType() != null) { + return "clearSignType 与 signType 不能同时传"; + } return null; } @@ -637,6 +646,14 @@ public class LbThirdIntegrationConfigServiceImpl } else if (StringUtils.hasText(request.getGoodsApiAppStr())) { uw.set(LbThirdIntegrationConfig::getGoodsApiAppStr, request.getGoodsApiAppStr().trim()); } + + // signType: 允许显式传 "" → 视作 null 入库;clearSignType=true 也置 null;其它按字符串保留 + if (Boolean.TRUE.equals(request.getClearSignType())) { + uw.set(LbThirdIntegrationConfig::getSignType, null); + } else if (request.getSignType() != null) { + String s = request.getSignType().trim(); + uw.set(LbThirdIntegrationConfig::getSignType, s.isEmpty() ? null : s); + } } private Map buildCredentialStatus(LbThirdIntegrationConfig config, boolean includePlaintext) { @@ -645,6 +662,7 @@ public class LbThirdIntegrationConfigServiceImpl data.put("tenantId", config.getTenantId()); data.put("providerCode", config.getProviderCode()); data.put("authType", config.getAuthType()); + data.put("signType", config.getSignType()); data.put("cookieConfigured", isConfigured(config.getCookie())); data.put("phpsidConfigured", isConfigured(config.getPhpsid())); data.put("goodsApiTokenConfigured", isConfigured(config.getGoodsApiToken())); @@ -790,6 +808,18 @@ public class LbThirdIntegrationConfigServiceImpl entity.setGoodsApiAppStr(existing.getGoodsApiAppStr()); } + // signType: 请求体里显式传 null → 走已有值;传 "" 或具体值 → 入库(""→null) + if (entity.getSignType() != null) { + String s = entity.getSignType().trim(); + String next = s.isEmpty() ? null : s; + if (existing == null || !java.util.Objects.equals(next, existing.getSignType())) { + updated = true; + } + entity.setSignType(next); + } else if (existing != null) { + entity.setSignType(existing.getSignType()); + } + return updated; } @@ -812,4 +842,18 @@ public class LbThirdIntegrationConfigServiceImpl private static boolean isConfigured(String value) { return StringUtils.hasText(value); } + + /** 用于 v2 用户 API:取纯 goods_api_token(无 Bearer 前缀),未配则 null。 */ + private static String resolveGoodsApiTokenOrNull(LbThirdIntegrationConfig config) { + if (config == null) return null; + String raw = config.getGoodsApiToken(); + if (raw == null) return null; + String t = raw.trim(); + if (t.isEmpty()) return null; + // 兼容数据库里可能残留的 Bearer 前缀 + if (t.length() > 7 && t.regionMatches(true, 0, "bearer ", 0, 7)) { + t = t.substring(7).trim(); + } + return t.isEmpty() ? null : t; + } } diff --git a/src/main/java/com/rj/service/impl/LbUserServiceImpl.java b/src/main/java/com/rj/service/impl/LbUserServiceImpl.java index 8035c1c..ceae24b 100644 --- a/src/main/java/com/rj/service/impl/LbUserServiceImpl.java +++ b/src/main/java/com/rj/service/impl/LbUserServiceImpl.java @@ -229,7 +229,7 @@ public class LbUserServiceImpl extends ServiceImpl impleme w.le(LbUser::getUpdatedAt, updatedEnd); } - w.orderByDesc(LbUser::getUpdatedAt).orderByDesc(LbUser::getId); + w.orderByDesc(LbUser::getJoinTime).orderByDesc(LbUser::getId); Page page = this.page(new Page<>(current, size), w); result.put("success", true); diff --git a/src/main/java/com/rj/util/HxrdSignUtil.java b/src/main/java/com/rj/util/HxrdSignUtil.java new file mode 100644 index 0000000..b7a1819 --- /dev/null +++ b/src/main/java/com/rj/util/HxrdSignUtil.java @@ -0,0 +1,90 @@ +package com.rj.util; + +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; + +/** + * hxrd 前端 {@code lx()} 签名函数的 Java 等价实现(通过 index-BmvESZ9Z.js 反推并已在两组真实抓包样本上逐 hex 精确相等验证)。 + * + *

返回三元组用于 HTTP Header: + *

+ *   x-sign-n  : 5 位随机小写字母数字 (A-Za-z0-9 → toLowerCase)
+ *   x-sign-t  : String(floor(currentTimeMillis / 1000))
+ *   x-sign-s  : AES-256-CBC.hex(
+ *                  PLAINTEXT = "5e0dd8b5bb3a37519d3e3b46c2c6b200",
+ *                  KEY       = SHA256(n),
+ *                  IV        = MD5(t),
+ *                  Padding   = PKCS7 / PKCS5
+ *              )
+ * 
+ */ +public final class HxrdSignUtil { + + public static final String SECRET = "5e0dd8b5bb3a37519d3e3b46c2c6b200"; + private static final String ALPHA = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + private static final SecureRandom RND = new SecureRandom(); + + private HxrdSignUtil() {} + + /** HTTP Header 三元组。 */ + public static final class SignTriplet { + public final String n; + public final String t; + public final String s; + public SignTriplet(String n, String t, String s) { this.n = n; this.t = t; this.s = s; } + public String nHeader() { return n; } + public String tHeader() { return t; } + public String sHeader() { return s; } + } + + /** 生成一次请求所需的三个 header。 */ + public static SignTriplet sign() { + String n = randomNonce(5); + String t = String.valueOf(System.currentTimeMillis() / 1000L); + return new SignTriplet(n, t, computeSign(n, t)); + } + + /** + * 给定时戳+nonce,返回 x-sign-s(可用于单元测试/抓包回放)。 + */ + public static String computeSign(String nonce, String timestampSecStr) { + try { + byte[] key = sha256(nonce); // 32 bytes → AES-256 + byte[] iv = md5(timestampSecStr); // 16 bytes → IV + byte[] pt = SECRET.getBytes(StandardCharsets.UTF_8); // 32 bytes as UTF-8 text + Cipher c = Cipher.getInstance("AES/CBC/PKCS5Padding"); // JCE 下 PKCS5 ≡ PKCS7 + c.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new IvParameterSpec(iv)); + byte[] ct = c.doFinal(pt); + return toHex(ct); + } catch (Exception e) { + throw new IllegalStateException("AES sign failed: " + e.getMessage(), e); + } + } + + /** {@code len} 位随机字母数字串(大写+小写+数字),结果统一 toLowerCase。 */ + public static String randomNonce(int len) { + StringBuilder sb = new StringBuilder(len); + for (int i = 0; i < len; i++) { + sb.append(ALPHA.charAt(RND.nextInt(ALPHA.length()))); + } + return sb.toString().toLowerCase(); + } + + // ================== hash/hex helpers ================== + private static byte[] md5(String in) { return digest("MD5", in); } + private static byte[] sha256(String in) { return digest("SHA-256", in); } + private static byte[] digest(String alg, String in) { + try { return MessageDigest.getInstance(alg).digest(in.getBytes(StandardCharsets.UTF_8)); } + catch (NoSuchAlgorithmException e) { throw new RuntimeException(alg + " not available", e); } + } + private static String toHex(byte[] h) { + StringBuilder sb = new StringBuilder(h.length * 2); + for (byte b : h) sb.append(String.format("%02x", b)); + return sb.toString(); + } +} diff --git a/src/main/sql/lb_third_integration_config.sql b/src/main/sql/lb_third_integration_config.sql index eca62b7..580219e 100644 --- a/src/main/sql/lb_third_integration_config.sql +++ b/src/main/sql/lb_third_integration_config.sql @@ -34,6 +34,7 @@ CREATE TABLE `lb_third_integration_config` ( `coupon_update_path` VARCHAR(128) NOT NULL DEFAULT '/app/admin/coupon/update' COMMENT '优惠券更新 API 路径', `auth_type` VARCHAR(32) NOT NULL DEFAULT 'COOKIE_PHPSID' COMMENT 'Admin 鉴权类型', + `sign_type` VARCHAR(64) DEFAULT NULL COMMENT '签名算法类型,如 HXR_AES_CBC_SHA256_MD5;空则按 provider_code 默认处理', `cookie` VARCHAR(1024) DEFAULT NULL COMMENT '完整 Cookie 明文(优先使用)', `phpsid` VARCHAR(512) DEFAULT NULL COMMENT 'PHPSID 值明文', `goods_api_token` VARCHAR(512) DEFAULT NULL COMMENT '货品/抢购 token 明文', diff --git a/src/test/java/com/hxrd/GoodsTest.java b/src/test/java/com/hxrd/GoodsTest.java new file mode 100644 index 0000000..57b310a --- /dev/null +++ b/src/test/java/com/hxrd/GoodsTest.java @@ -0,0 +1,381 @@ +package com.hxrd; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.rj.util.HxrdSignUtil; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.zip.GZIPInputStream; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * hxrd 拉货包两步测试: + *
    + *
  1. 模拟登录 POST {@code /trade-app/api/app/login} → 保存 token/user 信息(静态字段)。
  2. + *
  3. 带 {@code Authorization: Bearer } 和签名头,GET {@code /trade-app/api/app/sale/goods-list?page=1&size=20} → 控制台打印货物列表。
  4. + *
+ * + *

签名由 {@link HxrdSignUtil#sign()} 生成(已与前端 sign.js lx() 对齐,并在真实抓包两组样本上逐 hex 相等验证通过)。 + */ +class GoodsTest { + + // ---------- 环境配置 ---------- + private static final String BASE_URL = "https://hxrdm.hxrd777.com"; + private static final String UA = + "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Edg/152.0.0.0 Mobile Safari/537.36"; + + // ---------- 登录信息 ---------- + private static final String PHONE = "19290117050"; + private static final String PASSWORD = "123456"; + + // ---------- 登录态缓存(被第二步引用) ---------- + /** 登录接口返回的 JWT token(不含 "Bearer " 前缀)。 */ + private static String loginToken; + /** 原始登录响应,便于调试。 */ + private static JSONObject loginResponse; + + // ==================================================================== + // 第一步:模拟登录 + // ==================================================================== + @Test + void step1_login() throws IOException { + JSONObject body = new JSONObject(); + body.put("phone", PHONE); + body.put("password", PASSWORD); + + JSONObject resp = request("POST", BASE_URL + "/trade-app/api/app/login", null, body.toJSONString(), null); + loginResponse = resp; + System.out.println("===== 登录响应 ====="); + System.out.println(JSON.toJSONString(resp, true)); + + // 兼容多种返回格式 + assertTrue(isSuccessResponse(resp), "登录失败,响应: " + JSON.toJSONString(resp)); + + // 取 token(兼容多种常见返回格式 + 去掉已经自带的 "Bearer " 前缀) + String rawToken = null; + Object data = resp.get("data"); + if (data instanceof JSONObject d) { + rawToken = d.getString("token"); + if (rawToken == null) rawToken = d.getString("accessToken"); + if (rawToken == null) rawToken = d.getString("jwt"); + } + if (rawToken == null) rawToken = resp.getString("token"); + if (rawToken == null) rawToken = resp.getString("accessToken"); + assertNotNull(rawToken, "登录成功,但响应中未找到 token 字段,请检查 data 结构: " + JSON.toJSONString(resp)); + // 后端可能直接返回 "Bearer xxx",去重前缀 + if (rawToken.regionMatches(true, 0, "bearer ", 0, 7)) { + rawToken = rawToken.substring(7); + } + loginToken = rawToken; + System.out.println("Token 前缀: " + loginToken.substring(0, Math.min(24, loginToken.length())) + "..."); + } + + // ==================================================================== + // 第二步:根据登录 token 拉取货物包 + // ==================================================================== + static boolean isSuccessResponse(JSONObject resp) { + Object success = resp.get("success"); + if (success != null) { + return Boolean.TRUE.equals(success) + || "true".equalsIgnoreCase(String.valueOf(success)) + || (success instanceof Number && ((Number) success).intValue() == 1); + } + Object code = resp.get("code"); + return (code instanceof Number && ((Number) code).intValue() == 200) || "200".equals(String.valueOf(code)); + } + + @Test + void step2_fetchGoodsList_print() throws IOException { + if (loginToken == null) step1_login(); // 单步跑 step2 也能自动登录 + + String url = BASE_URL + "/trade-app/api/app/sale/goods-list?page=1&size=20"; + JSONObject resp = request("GET", url, null, null, loginToken); + + assertTrue(isSuccessResponse(resp), "拉货失败,响应: " + JSON.toJSONString(resp)); + + System.out.println(); + System.out.println("===== 拉取货物包 goods-list page=1 size=20 ====="); + + // 分页数据兼容两种位置:顶层(当前后端实现)/ data.records 包裹 + Object total = pickFirst(resp, "total", "count", "totalCount"); + Object pages = pickFirst(resp, "pages", "totalPages"); + Object size = pickFirst(resp, "size", "pageSize"); + Object current = pickFirst(resp, "page", "current", "pageNum"); + System.out.println("总条数 : " + (total != null ? total : "(未知)")); + System.out.println("总页数 : " + (pages != null ? pages : "(未知)")); + System.out.println("本页大小 : " + (size != null ? size : "(未知)")); + System.out.println("当前页 : " + (current != null ? current : "(未知)")); + + // 先在顶层找列表,找不到再去 data 对象里(防止后端有不同封装) + Object list = pickFirst(resp, "records", "list", "rows", "data"); + Object data = resp.get("data"); + List rows = null; + if (list instanceof List l) { + rows = l; + } else if (data instanceof List l) { + rows = l; + } else if (data instanceof JSONObject d) { + Object inner = pickFirst(d, "records", "list", "rows", "data"); + if (inner instanceof List l) rows = l; + } + if (rows == null || rows.isEmpty()) { + System.out.println("本页无数据。resp = " + JSON.toJSONString(resp)); + return; + } + System.out.println("本页条数 : " + rows.size()); + System.out.println(); + System.out.println("货物列表(逐行打印):"); + for (int i = 0; i < rows.size(); i++) { + System.out.println(" [" + (i + 1) + "] " + JSON.toJSONString(rows.get(i))); + } + } + + /** 快速手动全跑:登录 → 拉货 → 打印。 */ + public static void main(String[] args) throws IOException { + GoodsTest t = new GoodsTest(); + t.step1_login(); + t.step3_fetchAllGoods(); + } + + // ==================================================================== + // 拉取一页货物(纯数据,不打印) + // ==================================================================== + /** + * 拉取一页货物,返回分页对象。 + * 解析后的字段(与当前后端对齐): + * - total : 总条数(Integer) + * - pages : 总页数(Integer) + * - size : 每页大小(Integer) + * - page : 当前页(Integer) + * - rows : List(本页货物列表) + */ + static class GoodsPage { + public final int total; + public final int pages; + public final int size; + public final int page; + public final java.util.List rows; + GoodsPage(int total, int pages, int size, int page, java.util.List rows) { + this.total = total; this.pages = pages; this.size = size; this.page = page; this.rows = rows; + } + } + + static GoodsPage fetchGoodsPage(int page, int size, String token) throws IOException { + String url = BASE_URL + "/trade-app/api/app/sale/goods-list?page=" + page + "&size=" + size; + JSONObject resp = request("GET", url, null, null, token); + if (!isSuccessResponse(resp)) throw new IOException("拉第 " + page + " 页失败: " + JSON.toJSONString(resp)); + + int total = toInt(pickFirst(resp, "total", "count", "totalCount"), 0); + int pages = toInt(pickFirst(resp, "pages", "totalPages"), 0); + int psize = toInt(pickFirst(resp, "size", "pageSize"), size); + int ppage = toInt(pickFirst(resp, "page", "current", "pageNum"), page); + + // 列表查找 + Object list = pickFirst(resp, "records", "list", "rows", "data"); + Object data = resp.get("data"); + java.util.List rows = null; + if (list instanceof java.util.List l) rows = l; + else if (data instanceof java.util.List l) rows = l; + else if (data instanceof JSONObject d) { + Object inner = pickFirst(d, "records", "list", "rows", "data"); + if (inner instanceof java.util.List l) rows = l; + } + java.util.List out = new java.util.ArrayList<>(); + if (rows != null) for (Object r : rows) if (r instanceof JSONObject o) out.add(o); + return new GoodsPage(total, pages, psize, ppage, out); + } + + // ==================================================================== + // 第三步:循环拉取全部货物,控制台打印进度 + 最终汇总 + // ==================================================================== + @Test + void step3_fetchAllGoods() throws IOException { + if (loginToken == null) step1_login(); + + final int PAGE_SIZE = 20; + java.util.List all = new java.util.ArrayList<>(512); + int retry = 0, maxRetryPerPage = 3; + + // 先拿第一页(确定总条数 & 总页数) + GoodsPage first = fetchGoodsPage(1, PAGE_SIZE, loginToken); + all.addAll(first.rows); + int total = first.total; + int pages = first.pages; + if (pages == 0) { + // 根据 total 兜底算页数,避免后端没返回 pages + pages = (total + PAGE_SIZE - 1) / PAGE_SIZE; + } + System.out.printf("=== [1/%d] 第一页 总条数=%d 总页数=%d 本页=%d条 累计=%d%n", + pages, total, pages, first.rows.size(), all.size()); + + // 拉第 2..pages 页 + for (int p = 2; p <= pages; p++) { + GoodsPage gp; + try { + gp = fetchGoodsPage(p, PAGE_SIZE, loginToken); + retry = 0; + } catch (IOException e) { + if (++retry > maxRetryPerPage) throw e; + System.out.println(" !! 第 " + p + " 页失败,第 " + retry + " 次重试:" + e.getMessage()); + try { Thread.sleep(500L * retry); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } + p--; + continue; + } + all.addAll(gp.rows); + // 每 5 页打印一次进度(最后一页强制打) + if (p % 5 == 0 || p == pages) { + System.out.printf(" ... [%d/%d] 累计 %d 条%n", p, pages, all.size()); + } + } + + // 打印最终汇总 + System.out.println(); + System.out.println("===== 全部货物拉取完成 ====="); + System.out.println("后端声明总条数 : " + total); + System.out.println("实际拉取条数 : " + all.size()); + if (total > 0) { + System.out.println("数量一致? : " + (all.size() == total ? "是 ✅" : "否(差 " + (total - all.size()) + " 条)")); + } + System.out.println("总页数 : " + pages); + System.out.println(); + System.out.println("前 3 条示例:"); + for (int i = 0; i < Math.min(3, all.size()); i++) { + System.out.println(" [" + (i + 1) + "] id=" + all.get(i).get("id") + + " seller=" + all.get(i).get("seller") + + " price=" + all.get(i).get("sellingPrice") + + " qty=" + all.get(i).get("quantity") + + " title=" + all.get(i).get("title")); + } + System.out.println(); + System.out.println("后 3 条示例:"); + for (int i = Math.max(0, all.size() - 3); i < all.size(); i++) { + System.out.println(" [" + (i + 1) + "/" + all.size() + "] id=" + all.get(i).get("id") + + " seller=" + all.get(i).get("seller") + + " price=" + all.get(i).get("sellingPrice") + + " qty=" + all.get(i).get("quantity")); + } + + // ======================================================= + // 在控制台输出每个货物包的完整信息(共 all.size() 条) + // ======================================================= + System.out.println(); + System.out.println("=============================================================="); + System.out.println("【全部货物明细】 共 " + all.size() + " 条,逐条打印:"); + System.out.println("=============================================================="); + for (int i = 0; i < all.size(); i++) { + JSONObject g = all.get(i); + System.out.println("--- [" + (i + 1) + "/" + all.size() + "] ---------------------------"); + System.out.println(" id : " + g.get("id")); + System.out.println(" oldId : " + g.get("oldId")); + System.out.println(" title : " + g.get("title")); + System.out.println(" seller : " + g.get("seller")); + System.out.println(" sellingPrice : " + g.get("sellingPrice")); + System.out.println(" quantity : " + g.get("quantity") + " unit=" + g.get("unit")); + System.out.println(" status : " + g.get("status") + " displayStatus=" + g.get("displayStatus")); + System.out.println(" image : " + g.get("image")); + System.out.println(" createTime : " + g.get("createTime")); + System.out.println(" updateTime : " + g.get("updateTime")); + } + System.out.println("=============================================================="); + System.out.println("打印完成,共 " + all.size() + " 条。"); + } + + static int toInt(Object o, int d) { + if (o == null) return d; + if (o instanceof Number n) return n.intValue(); + try { return Integer.parseInt(String.valueOf(o).trim()); } + catch (Exception ignore) { return d; } + } + + // ==================================================================== + // 底层:带签名的 HTTP 请求封装 + // ==================================================================== + static JSONObject request(String method, String url, String query, String bodyJson, String bearer) throws IOException { + String full = url; + if (query != null && !query.isEmpty()) full += (url.contains("?") ? "&" : "?") + query; + HttpURLConnection conn = (HttpURLConnection) new URL(full).openConnection(); + try { + conn.setRequestMethod(method); + conn.setConnectTimeout(15_000); + conn.setReadTimeout(30_000); + conn.setInstanceFollowRedirects(true); + conn.setUseCaches(false); + + // 通用头(对齐浏览器抓包) + conn.setRequestProperty("Accept", "*/*"); + conn.setRequestProperty("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8"); + conn.setRequestProperty("Accept-Encoding", "gzip"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("Origin", BASE_URL); + conn.setRequestProperty("Referer", BASE_URL + "/"); + conn.setRequestProperty("User-Agent", UA); + conn.setRequestProperty("Sec-Ch-Ua", "\"Chromium\";v=\"152\", \"Not?A_Brand\";v=\"24\", \"Microsoft Edge\";v=\"152\""); + conn.setRequestProperty("Sec-Ch-Ua-Mobile", "?1"); + conn.setRequestProperty("Sec-Ch-Ua-Platform", "\"Android\""); + conn.setRequestProperty("Sec-Fetch-Dest", "empty"); + conn.setRequestProperty("Sec-Fetch-Mode", "cors"); + conn.setRequestProperty("Sec-Fetch-Site", "same-origin"); + + // ---- 签名三 Header(核心,对应前端 lx() ) ---- + HxrdSignUtil.SignTriplet trip = HxrdSignUtil.sign(); + conn.setRequestProperty("X-Sign-N", trip.nHeader()); + conn.setRequestProperty("X-Sign-T", trip.tHeader()); + conn.setRequestProperty("X-Sign-S", trip.sHeader()); + + // ---- 登录后才有 Authorization ---- + if (bearer != null && !bearer.isEmpty()) { + conn.setRequestProperty("Authorization", "Bearer " + bearer); + } + + // ---- 写 body(POST/PUT) ---- + if (bodyJson != null) { + byte[] bb = bodyJson.getBytes(StandardCharsets.UTF_8); + conn.setDoOutput(true); + conn.setFixedLengthStreamingMode(bb.length); + try (OutputStream os = conn.getOutputStream()) { os.write(bb); } + } + + int http = conn.getResponseCode(); + InputStream is = (http >= 200 && http < 400) ? conn.getInputStream() : conn.getErrorStream(); + byte[] raw = readAllAndDecompress(is, conn.getContentEncoding()); + String text = new String(raw, StandardCharsets.UTF_8); + + if (http != 200) { + System.err.println("HTTP " + http + " for " + method + " " + full); + System.err.println("Response Headers: " + conn.getHeaderFields()); + System.err.println("BODY: " + text); + } + if (text.isEmpty()) throw new IOException("HTTP " + http + " empty body"); + return JSON.parseObject(text); + } finally { + conn.disconnect(); + } + } + + // ====================== 工具方法 ====================== + private static byte[] readAllAndDecompress(InputStream is, String encoding) throws IOException { + if (is == null) return new byte[0]; + try (InputStream in = ("gzip".equalsIgnoreCase(encoding)) ? new GZIPInputStream(is) : is) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(4096); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) baos.write(buf, 0, n); + return baos.toByteArray(); + } + } + + private static Object pickFirst(JSONObject data, String... keys) { + for (String k : keys) if (data.containsKey(k)) return data.get(k); + return null; + } +} diff --git a/src/test/java/com/qdw/buyerTest.java b/src/test/java/com/qdw/buyerTest.java index 442c922..2c0220c 100644 --- a/src/test/java/com/qdw/buyerTest.java +++ b/src/test/java/com/qdw/buyerTest.java @@ -25,8 +25,8 @@ public class buyerTest { private static final String CLAIM_ORDER_URL = "https://web.qdwsmgs.com/api/order/%d/claim"; private static final String PHONE = "15313852029"; private static final String PASSWORD = "123456"; - private static final int MAX_PRICE = 38000; - private static final int MAX_BUY_NUM = 2; + private static final int MAX_PRICE = 30000; + private static final int MAX_BUY_NUM = 1; private static final int PAGE_SIZE = 20;