diff --git a/src/main/java/com/rj/controller/LbBuyAccountController.java b/src/main/java/com/rj/controller/LbBuyAccountController.java index b94dd04..1358428 100644 --- a/src/main/java/com/rj/controller/LbBuyAccountController.java +++ b/src/main/java/com/rj/controller/LbBuyAccountController.java @@ -1,17 +1,41 @@ package com.rj.controller; +import com.rj.dto.LbBuyAccountRushBuyContext; import com.rj.dto.LbBuyAccountRushBuyRequest; +import com.rj.dto.LbRushBuyGoodsCoordinator; +import com.rj.dto.hxr.HxrUserLoginApiContext; import com.rj.entity.LbBuyAccount; +import com.rj.entity.LbThirdIntegrationConfig; +import com.rj.entity.LbUser; +import com.rj.service.HxrAdminUserLoginService; +import com.rj.service.HxrAdminUserService; import com.rj.service.ILbBuyAccountService; +import com.rj.service.ILbGoodsService; +import com.rj.service.ILbThirdIntegrationConfigService; +import com.rj.service.ILbUserService; +import com.rj.tenant.TenantContextHolder; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +@Slf4j @RestController @RequestMapping("/api/lbBuyAccount") @Tag(name = "LB抢单账号配置", description = "lb_buy_account 增删改查与分页") @@ -20,6 +44,21 @@ public class LbBuyAccountController { @Autowired private ILbBuyAccountService lbBuyAccountService; + @Autowired + private ILbUserService lbUserService; + + @Autowired + private HxrAdminUserService hxrAdminUserService; + + @Autowired + private HxrAdminUserLoginService hxrAdminUserLoginService; + + @Autowired + private ILbGoodsService lbGoodsService; + + @Autowired + private ILbThirdIntegrationConfigService lbThirdIntegrationConfigService; + @PostMapping("/add") @Operation(summary = "新增") public ResponseEntity> add( @@ -106,4 +145,285 @@ public class LbBuyAccountController { } return ResponseEntity.ok(result); } + + @PostMapping("/simulate-rush-buy_all") + @Operation( + summary = "模拟指定租户所有用户抢单", + description = + "根据前端传入的租户 id,从 lb_user 表读取该租户所有用户,根据 sign_type 模拟登录" + + "(V2 用 doV2SimulateLogin POST /trade-app/api/app/login;" + + "V1 用 HxrAdminUserLoginService.login POST /api/user/login);" + + "第二步:根据登录后解析到的 token 调用 rushBuy 从第三方抢单") + public ResponseEntity> simulateRushBuy( + @Parameter(description = "租户ID(必填)", required = true) + @RequestParam String tenantId, + @Parameter(description = "货品开始时间(可选),格式 yyyy-MM-dd HH:mm:ss") + @RequestParam(required = false) String goodsBeginTime) { + + log.info("模拟租户用户抢单开始 tenantId={} goodsBeginTime={}", + tenantId, goodsBeginTime); + + Map result = new HashMap<>(); + List> userResults = Collections.synchronizedList(new ArrayList<>()); + AtomicInteger totalUsers = new AtomicInteger(0); + AtomicInteger loginSuccess = new AtomicInteger(0); + AtomicInteger loginFail = new AtomicInteger(0); + AtomicInteger rushBuySuccess = new AtomicInteger(0); + AtomicInteger rushBuyFail = new AtomicInteger(0); + // 方法进入时间(仅作为 catch 异常时的兜底计时起点) + long methodEnteredAt = System.currentTimeMillis(); + // 总耗时起点:由第一个真正开始执行(模拟登录)的用户线程记录,0 表示还没有用户开始 + // 总耗时定义:第一个用户开始 → 最后一个用户抢单结束(墙钟时间,并发实际占用时长) + AtomicLong firstUserStartAt = new AtomicLong(0L); + + try { + if (tenantId == null || tenantId.trim().isEmpty()) { + result.put("success", false); + result.put("message", "tenantId不能为空"); + return ResponseEntity.badRequest().body(result); + } + final String tid = tenantId.trim(); + log.info("当前租户 tenantId={}", tid); + + // 查询当前租户的第三方集成配置 + LbThirdIntegrationConfig config = lbThirdIntegrationConfigService.lambdaQuery() + .eq(LbThirdIntegrationConfig::getTenantId, tid) + .one(); + if (config == null) { + result.put("success", false); + result.put("message", "未找到当前租户的第三方集成配置(lb_third_integration_config)"); + return ResponseEntity.badRequest().body(result); + } + if (config.getEnabled() == null || config.getEnabled() != 1) { + result.put("success", false); + result.put("message", "当前租户的第三方集成配置未启用"); + return ResponseEntity.badRequest().body(result); + } + + String signType = config.getSignType(); + String origin = config.getGoodsApiOrigin(); + boolean isV2 = "v2".equalsIgnoreCase(signType); + log.info("当前租户配置 signType={} origin={} isV2={}", signType, origin, isV2); + + // 查询当前租户的所有 lb_user(租户上下文已由请求拦截器设置) + List users = lbUserService.list(); + if (users == null || users.isEmpty()) { + result.put("success", false); + result.put("message", "当前租户的 lb_user 表为空"); + return ResponseEntity.ok(result); + } + + // 过滤出有效用户 + List validUsers = new ArrayList<>(); + for (LbUser user : users) { + if (user == null || user.getId() == null) continue; + if (user.getMobile() == null || user.getMobile().trim().isEmpty()) continue; + if (user.getPassword() == null || user.getPassword().trim().isEmpty()) continue; + if (user.getNickname() != null && user.getNickname().contains("**")) continue; + validUsers.add(user); + } + totalUsers.set(validUsers.size()); + log.info("租户 {} 共 {} 个有效用户,开始多线程模拟登录+抢单", tenantId, validUsers.size()); + + LbRushBuyGoodsCoordinator coordinator = new LbRushBuyGoodsCoordinator(); + + // 每个用户一个线程并发抢单 + int threadCount = Math.min(validUsers.size(), 50); + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + List> futures = new ArrayList<>(); + log.info("批次抢单提交 {} 个用户到线程池(线程池大小={}),等待第一个用户开始计时", + validUsers.size(), threadCount); + + for (LbUser user : validUsers) { + final LbUser u = user; + CompletableFuture future = CompletableFuture.runAsync(() -> { + // 第一个真正开始执行(模拟登录)的线程记录总耗时起点,后续线程不再覆盖 + long nowMs = System.currentTimeMillis(); + if (firstUserStartAt.compareAndSet(0L, nowMs)) { + log.info("总耗时起点:第一个用户[{}]开始模拟登录,时间戳={}", u.getMobile(), nowMs); + } + String userLabel = "用户[" + u.getMobile() + "]"; + Map userResult = new LinkedHashMap<>(); + userResult.put("userId", u.getId()); + userResult.put("mobile", u.getMobile()); + userResult.put("tenantId", tid); + long userStartedAt = System.currentTimeMillis(); + + try { + // ====== Step 1: 模拟登录 ====== + String token = null; + long loginStartedAt = System.currentTimeMillis(); + + if (isV2) { + log.info("V2 模拟登录 userId={} mobile={} origin={}", + u.getId(), u.getMobile(), origin); + Optional tok = hxrAdminUserService.doV2SimulateLogin( + u.getMobile().trim(), + u.getPassword().trim(), + origin); + if (tok.isPresent()) { + token = tok.get(); + loginSuccess.incrementAndGet(); + log.info("V2 模拟登录成功 userId={} mobile={} tokenPrefix={}", + u.getId(), u.getMobile(), + token.length() > 8 ? token.substring(0, 8) : token); + } else { + loginFail.incrementAndGet(); + userResult.put("success", false); + userResult.put("message", "V2模拟登录失败"); + userResults.add(userResult); + log.warn("V2 模拟登录失败 userId={} mobile={}", u.getId(), u.getMobile()); + return; + } + } else { + // V1 登录 + Optional loginCtxOpt = + lbThirdIntegrationConfigService.resolveUserLoginApiContext(tid); + if (loginCtxOpt.isEmpty()) { + loginFail.incrementAndGet(); + userResult.put("success", false); + userResult.put("message", "V1登录配置解析失败"); + userResults.add(userResult); + log.warn("V1 登录配置解析失败 tid={}", tid); + return; + } + log.info("V1 模拟登录 userId={} mobile={}", u.getId(), u.getMobile()); + HxrAdminUserLoginService.LoginApiResult loginResult = + hxrAdminUserLoginService.login( + u.getMobile().trim(), + u.getPassword().trim(), + loginCtxOpt.get()); + if (loginResult.success()) { + token = HxrAdminUserLoginService.extractToken(loginResult.parsed()); + if (token != null && !token.trim().isEmpty()) { + token = token.trim(); + loginSuccess.incrementAndGet(); + log.info("V1 模拟登录成功 userId={} mobile={} tokenPrefix={}", + u.getId(), u.getMobile(), + token.length() > 8 ? token.substring(0, 8) : token); + } else { + loginFail.incrementAndGet(); + userResult.put("success", false); + userResult.put("message", "V1登录成功但响应无token"); + userResults.add(userResult); + return; + } + } else { + loginFail.incrementAndGet(); + userResult.put("success", false); + userResult.put("message", "V1登录失败: code=" + loginResult.apiCode() + " msg=" + loginResult.apiMsg()); + userResults.add(userResult); + log.warn("V1 登录失败 userId={} mobile={} code={} msg={}", + u.getId(), u.getMobile(), loginResult.apiCode(), loginResult.apiMsg()); + return; + } + } + + long loginElapsedMs = System.currentTimeMillis() - loginStartedAt; + userResult.put("loginElapsedMs", loginElapsedMs); + + // ====== Step 2: 用 token 抢单,maxBuyCount 从 lb_user.max_order 读取 ====== + // V2 场景下,每人的最大抢单量扩大 3 倍;V1 保持原值 + int baseMaxOrder = (u.getMaxOrder() != null && u.getMaxOrder() > 0) + ? u.getMaxOrder() : 1; + int maxOrder = isV2 ? baseMaxOrder * 3 : baseMaxOrder; + + LbBuyAccountRushBuyContext refreshCtx = new LbBuyAccountRushBuyContext( + String.valueOf(u.getId()), + u.getMobile().trim(), + u.getPassword().trim()); + + log.info("开始抢单 userId={} mobile={} tid={} maxOrder={}{} 登录耗时={} ms", + u.getId(), u.getMobile(), tid, maxOrder, + isV2 ? "(V2 已扩大3倍,原始 max_order=" + baseMaxOrder + ")" : "", + loginElapsedMs); + + long rushStartedAt = System.currentTimeMillis(); + Map rushBuyResult = lbGoodsService.rushBuy( + tid, + token, + maxOrder, + userLabel, + refreshCtx, + coordinator, + String.valueOf(u.getId()), + null, + goodsBeginTime); + long rushElapsedMs = System.currentTimeMillis() - rushStartedAt; + + Boolean rushSuccess = rushBuyResult.get("success") instanceof Boolean + ? (Boolean) rushBuyResult.get("success") : null; + if (Boolean.TRUE.equals(rushSuccess)) { + rushBuySuccess.incrementAndGet(); + } else { + rushBuyFail.incrementAndGet(); + } + long userElapsedMs = System.currentTimeMillis() - userStartedAt; + userResult.put("success", rushSuccess); + userResult.put("rushBuyResult", rushBuyResult); + userResult.put("rushElapsedMs", rushElapsedMs); + userResult.put("totalElapsedMs", userElapsedMs); + userResults.add(userResult); + log.info("{} 抢单结束 success={} 抢单耗时={} ms,用户总耗时={} ms ({} s)", + userLabel, rushSuccess, rushElapsedMs, userElapsedMs, + String.format("%.2f", userElapsedMs / 1000.0)); + + } catch (Exception e) { + loginFail.incrementAndGet(); + long userElapsedMs = System.currentTimeMillis() - userStartedAt; + userResult.put("success", false); + userResult.put("message", "异常: " + e.getMessage()); + userResult.put("totalElapsedMs", userElapsedMs); + userResults.add(userResult); + log.error("模拟抢单异常 userId={} mobile={} 耗时={} ms", + u.getId(), u.getMobile(), userElapsedMs, e); + } + }, executor); + futures.add(future); + } + + // 等待所有线程完成:join 返回的这一刻,就是最后一个用户抢单结束的时间点 + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + executor.shutdown(); + long batchEndAt = System.currentTimeMillis(); + log.info("总耗时终点:最后一个用户抢单结束,时间戳={}", batchEndAt); + // 总耗时 = 最后一个用户结束时间 - 第一个用户开始时间(墙钟时间) + long batchStartRef = firstUserStartAt.get() > 0 ? firstUserStartAt.get() : methodEnteredAt; + long batchElapsedMs = batchEndAt - batchStartRef; + + result.put("success", true); + result.put("message", "模拟抢单完成,总用户=" + totalUsers.get() + + " 登录成功=" + loginSuccess.get() + " 登录失败=" + loginFail.get() + + " 抢单成功=" + rushBuySuccess.get() + " 抢单失败=" + rushBuyFail.get() + + " 总耗时=" + batchElapsedMs + " ms (" + String.format("%.2f", batchElapsedMs / 1000.0) + " s)"); + result.put("totalUsers", totalUsers.get()); + result.put("loginSuccess", loginSuccess.get()); + result.put("loginFail", loginFail.get()); + result.put("rushBuySuccess", rushBuySuccess.get()); + result.put("rushBuyFail", rushBuyFail.get()); + result.put("elapsedMs", batchElapsedMs); + result.put("userResults", userResults); + log.info("模拟当前租户用户抢单完成 tid={} totalUsers={} loginSuccess={} loginFail={} rushBuySuccess={} rushBuyFail={} 总耗时={} ms ({} s)(第一个用户开始→最后一个用户结束)", + tid, totalUsers.get(), loginSuccess.get(), loginFail.get(), + rushBuySuccess.get(), rushBuyFail.get(), batchElapsedMs, + String.format("%.2f", batchElapsedMs / 1000.0)); + return ResponseEntity.ok(result); + + } catch (Exception e) { + long nowMs = System.currentTimeMillis(); + long batchStartRef = firstUserStartAt.get() > 0 ? firstUserStartAt.get() : methodEnteredAt; + long batchElapsedMs = nowMs - batchStartRef; + log.error("模拟当前租户用户抢单异常,总耗时={} ms(第一个用户开始→异常发生)", batchElapsedMs, e); + result.put("success", false); + result.put("message", "异常: " + e.getMessage()); + result.put("elapsedMs", batchElapsedMs); + result.put("totalUsers", totalUsers.get()); + result.put("loginSuccess", loginSuccess.get()); + result.put("loginFail", loginFail.get()); + result.put("rushBuySuccess", rushBuySuccess.get()); + result.put("rushBuyFail", rushBuyFail.get()); + result.put("userResults", userResults); + return ResponseEntity.internalServerError().body(result); + } + } } diff --git a/src/main/java/com/rj/service/HxrAdminBuyService.java b/src/main/java/com/rj/service/HxrAdminBuyService.java index 6f7a602..6f214b0 100644 --- a/src/main/java/com/rj/service/HxrAdminBuyService.java +++ b/src/main/java/com/rj/service/HxrAdminBuyService.java @@ -6,10 +6,15 @@ import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.rj.config.HxrAdminProperties; import com.rj.dto.hxr.HxrGoodsApiContext; 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.IOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -19,6 +24,7 @@ import java.security.SecureRandom; import java.time.Duration; import java.util.LinkedHashMap; import java.util.Map; +import java.util.zip.GZIPInputStream; /** * 调用 hxrd {@code POST /api/order/buy} 抢购(请求头 {@code token} + {@code S/T/N} 鉴权)。 @@ -52,6 +58,11 @@ public class HxrAdminBuyService { /** 外部系统返回此文案时表示该货品已被他人抢订,同批次其他账号不应再尝试。 */ public static final String MSG_ORDER_ALREADY_GRABBED = "此订单已被抢订"; + /** V2 使用的 User-Agent(与 HxrAdminGoodsService V2 请求一致)。 */ + private static final String V2_USER_AGENT = + "Mozilla/5.0 (Linux; Android 13; PEYM00 Build/TP1A.220905.001) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36"; + private static final ObjectMapper JSON = new ObjectMapper() .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); @@ -195,25 +206,274 @@ public class HxrAdminBuyService { return null; } + // ===================================================================== + // V2 抢购:POST {origin}/trade-app/api/app/sale/flash-sale?goodsId=&buyerId= + // 鉴权:Authorization: Bearer {jwt} + Cookie: ELADMIN-TOKEN + X-Sign-N/T/S + // ===================================================================== + + /** + * V2 抢购(flash-sale)。 + * + * @param goodsId 货品 ID + * @param buyerId 买家 ID(即 lb_user.id) + * @param ctx 运行时配置(含 origin/token/signType 等) + * @return 抢购结果 + */ + public BuyApiResult buyV2(long goodsId, long buyerId, HxrGoodsApiContext ctx) throws Exception { + if (ctx == null) { + return BuyApiResult.failure(-1, "抢购 API 配置为空"); + } + String resolvedToken = ctx.token(); + if (resolvedToken == null || resolvedToken.isBlank()) { + return BuyApiResult.failure(-1, "未提供 token"); + } + String baseDomain = ctx.origin(); + if (baseDomain == null || baseDomain.isBlank()) { + return BuyApiResult.failure(-1, "未配置 origin"); + } + + // 1) 拼 URL + String trimmedDomain = baseDomain.trim(); + while (trimmedDomain.endsWith("/")) { + trimmedDomain = trimmedDomain.substring(0, trimmedDomain.length() - 1); + } + String uri = UriComponentsBuilder.fromUriString(trimmedDomain) + .replacePath("/trade-app/api/app/sale/flash-sale") + .replaceQueryParam("goodsId", goodsId) + .replaceQueryParam("buyerId", buyerId) + .build() + .encode() + .toUriString(); + + // 2) 签名三 Header + HxrdSignUtil.SignTriplet trip = HxrdSignUtil.sign(); + + // 3) Cookie(ELADMIN-TOKEN 空格编码为 %20) + String cookieHeader = "sidebarStatus=0; ELADMIN-TOKEN=" + + encodeCookieValue("Bearer " + resolvedToken.trim()); + + log.info("v2 flash-sale 请求 goodsId={} buyerId={} uri={} tokenPrefix={} cookiePrefix={} n={} t={}", + goodsId, buyerId, uri, abbreviate(resolvedToken, 8), + abbreviate(cookieHeader, 40), trip.nHeader(), trip.tHeader()); + + // 4) 发请求 + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(60)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(uri)) + .timeout(Duration.ofSeconds(120)) + .header("Accept", "application/json, text/plain, */*") + .header("Accept-Encoding", "gzip, deflate") + .header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + .header("Origin", trimmedDomain) + .header("Referer", trimmedDomain + "/") + .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("Authorization", "Bearer " + resolvedToken.trim()) + .header("Cookie", cookieHeader) + .header("X-Sign-N", trip.nHeader()) + .header("X-Sign-T", trip.tHeader()) + .header("X-Sign-S", trip.sHeader()) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + HttpResponse resp = client.send(request, HttpResponse.BodyHandlers.ofByteArray()); + int status = resp.statusCode(); + byte[] bodyBytes = resp.body(); + String contentEncoding = resp.headers().firstValue("Content-Encoding").orElse(""); + byte[] decompressed = decompressGzipIfNeeded(bodyBytes, contentEncoding); + String bodyText = decompressed == null ? "" : new String(decompressed, StandardCharsets.UTF_8); + + if (bodyText == null || bodyText.isBlank()) { + if (status < 200 || status >= 300) { + log.warn("v2 flash-sale HTTP {} goodsId={} buyerId={} 空响应", status, goodsId, buyerId); + return BuyApiResult.failure(status, "HTTP " + status); + } + log.warn("v2 flash-sale empty body goodsId={} buyerId={}", goodsId, buyerId); + return BuyApiResult.failure(-1, "响应为空"); + } + + // 5) 解析响应(HTTP 400 也可能带业务 JSON,如 {"success":false,"message":"商品已售出"}) + // 注意:V2 消息字段名是 message(不是 msg),状态字段可能是 code 或 status; + // 服务端返回的中文是 backslash-uXXXX 形式的 ASCII 转义,Jackson 解析后自动转成汉字。 + JsonNode root = null; + try { + root = JSON.readTree(bodyText); + } catch (Exception parseEx) { + log.warn("v2 flash-sale 响应非JSON HTTP={} goodsId={} buyerId={} bodyPrefix={}", + status, goodsId, buyerId, abbreviate(unescapeUnicode(bodyText), 400)); + return BuyApiResult.failure(status, "HTTP " + status); + } + + boolean ok = false; + int code = -1; + // 消息字段:优先 message,兜底 msg + JsonNode msgNode = root.get("message"); + if (msgNode == null || msgNode.isNull()) { + msgNode = root.get("msg"); + } + String msg = msgNode != null && !msgNode.isNull() ? msgNode.asText("") : ""; + + // 状态码字段:优先 code,兜底 status(HTTP 状态码) + JsonNode codeNode = root.get("code"); + if (codeNode != null && !codeNode.isNull() && codeNode.isNumber()) { + code = codeNode.asInt(-1); + } else { + JsonNode statusNode = root.get("status"); + if (statusNode != null && !statusNode.isNull() && statusNode.isNumber()) { + code = statusNode.asInt(-1); + } + } + + // 优先看 success 字段 + JsonNode successNode = root.get("success"); + if (successNode != null && !successNode.isNull()) { + if (successNode.isBoolean()) { + ok = successNode.booleanValue(); + } else if (successNode.isNumber()) { + ok = successNode.intValue() == 1; + } else { + String t = successNode.asText(""); + ok = "true".equalsIgnoreCase(t) || "1".equals(t); + } + } + // 再看 code 字段 + if (code != -1 && !ok) { + ok = (code == 0 || code == 200); + } + + if (status < 200 || status >= 300) { + // HTTP 层失败,但 body 里可能有业务消息(如 400 + "商品已售出"),带上解码后的中文消息 + log.warn("v2 flash-sale HTTP {} goodsId={} buyerId={} message=[{}] bodyPrefix={}", + status, goodsId, buyerId, msg, abbreviate(unescapeUnicode(bodyText), 400)); + String resultMsg = msg.isEmpty() ? ("HTTP " + status) : msg; + return new BuyApiResult(false, code == -1 ? status : code, resultMsg); + } + + if (!ok) { + log.warn("v2 flash-sale 失败 code={} message=[{}] goodsId={} buyerId={} bodyPrefix={}", + code, msg, goodsId, buyerId, abbreviate(unescapeUnicode(bodyText), 400)); + if (msg.contains(MSG_ACTIVITY_NOT_STARTED)) { + log.warn("v2 flash-sale 活动未开始 goodsId={} buyerId={}", goodsId, buyerId); + } + if (msg.contains(MSG_ACTIVITY_ENDED)) { + log.warn("v2 flash-sale 活动已结束 goodsId={} buyerId={}", goodsId, buyerId); + } + if (msg.contains(MSG_DAILY_LIMIT_EXCEEDED)) { + log.warn("v2 flash-sale 当日可抢订单数已满 goodsId={} buyerId={}", goodsId, buyerId); + } + if (code == 401 || msg.contains(MSG_LOGIN_REQUIRED)) { + log.warn("v2 flash-sale token 失效 goodsId={} buyerId={}", goodsId, buyerId); + } + } else { + log.info("v2 flash-sale 成功 goodsId={} buyerId={}", goodsId, buyerId); + } + return new BuyApiResult(ok, code, msg); + } + + /** + * 将 JSON 原始文本中的 backslash-uXXXX 转义解码为汉字(仅用于日志展示,不影响业务解析)。 + * 服务端返回 {"message":"转义形式的商品已售出"},日志里直接打印会显示 unicode 编码, + * 解码后显示为 {"message":"商品已售出"}。 + */ + private static String unescapeUnicode(String s) { + if (s == null || s.indexOf('\\') < 0) { + return s; + } + StringBuilder sb = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '\\' && i + 5 < s.length() && s.charAt(i + 1) == 'u') { + try { + int hex = Integer.parseInt(s.substring(i + 2, i + 6), 16); + sb.append((char) hex); + i += 5; + continue; + } catch (NumberFormatException ignore) { + // 不是合法的 4 位十六进制转义,按原样输出 + } + } + sb.append(c); + } + return sb.toString(); + } + + /** gzip 解压(V2 响应可能是 gzip 压缩的)。 */ + private static byte[] decompressGzipIfNeeded(byte[] body, String contentEncoding) { + if (body == null || body.length == 0) { + return body; + } + if (contentEncoding == null || !contentEncoding.toLowerCase().contains("gzip")) { + return body; + } + try (GZIPInputStream gis = new GZIPInputStream(new ByteArrayInputStream(body))) { + return gis.readAllBytes(); + } catch (IOException e) { + log.warn("gzip 解压失败,返回原始 body", e); + return body; + } + } + + /** + * Cookie 值 percent-encode:空格编码为 %20(不是 URLEncoder 的 '+')。 + * 与 HxrAdminUserService.encodeCookieValue 一致,保证 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 { + for (byte b : String.valueOf(c).getBytes(StandardCharsets.UTF_8)) { + sb.append(String.format("%%%02X", b)); + } + } + } + return sb.toString(); + } + public record BuyApiResult(boolean success, int apiCode, String apiMsg) { public static BuyApiResult failure(int code, String msg) { return new BuyApiResult(false, code, msg); } - /** {@code code=1} 且 msg 为「已超出当天可抢订单数」时,调用方应停止该账号后续抢购。 */ + /** msg 包含「已超出当天可抢订单数」时,调用方应停止该账号后续抢购。 */ public boolean dailyLimitExceeded() { - return apiCode == 1 && MSG_DAILY_LIMIT_EXCEEDED.equals(apiMsg); + return apiMsg != null && apiMsg.contains(MSG_DAILY_LIMIT_EXCEEDED); } - /** {@code code=401} 或 msg 为「请登录」时,调用方应停止该账号后续抢购。 */ + /** {@code code=401} 或 msg 包含「请登录」时,调用方应停止该账号后续抢购。 */ public boolean loginRequired() { - return apiCode == 401 || MSG_LOGIN_REQUIRED.equals(apiMsg); + return apiCode == 401 || (apiMsg != null && apiMsg.contains(MSG_LOGIN_REQUIRED)); } - /** msg 包含「此订单已被抢订」时,同租户该货品在批量抢单中应标记为不可再抢。 */ + /** + * msg 表示货品已被抢订/售出时,同租户该货品在批量抢单中应标记为不可再抢。 + * 兼容 V1「此订单已被抢订」与 V2「商品已售出」「已抢光」「已售罄」等文案。 + */ public boolean orderAlreadyGrabbed() { - return apiMsg != null && apiMsg.contains(MSG_ORDER_ALREADY_GRABBED); + if (apiMsg == null) { + return false; + } + return apiMsg.contains(MSG_ORDER_ALREADY_GRABBED) + || apiMsg.contains("已售出") + || apiMsg.contains("已抢光") + || apiMsg.contains("已售罄") + || apiMsg.contains("已被抢"); } /** msg 包含「活动未开始」时,调用方应立刻停止该账号及本批次后续抢购。 */ diff --git a/src/main/java/com/rj/service/HxrAdminGoodsService.java b/src/main/java/com/rj/service/HxrAdminGoodsService.java index 231a9e9..018e444 100644 --- a/src/main/java/com/rj/service/HxrAdminGoodsService.java +++ b/src/main/java/com/rj/service/HxrAdminGoodsService.java @@ -13,10 +13,10 @@ 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.entity.LbUser; +import com.rj.mapper.LbUserMapper; import com.rj.tenant.TenantContextHolder; import com.rj.util.HxrGoodsSignUtil; import com.rj.util.HxrdSignUtil; @@ -95,7 +95,7 @@ public class HxrAdminGoodsService { private final HxrAdminProperties properties; private final HxrAdminUserLoginService hxrAdminUserLoginService; private final ILbThirdIntegrationConfigService lbThirdIntegrationConfigService; - private final LbBuyerShoppingMapper lbBuyerShoppingMapper; + private final LbUserMapper lbUserMapper; /** * 分页拉取货品列表(使用 {@code hxr.admin} 配置文件,兼容旧调用)。 @@ -178,7 +178,7 @@ public class HxrAdminGoodsService { } /** - * token 失效时:从 {@code lb_buyer_shopping} 取当前租户多条记录的买家手机号, + * token 失效时:从 {@code lb_user} 取当前租户多条记录的买家手机号, * 逐个尝试模拟登录,解析 token。 * - sign_type == v2: 直接 POST /trade-app/api/app/login JSON {phone,password} 取 Bearer JWT(去掉前缀) * - sign_type == v1 / null:沿用旧 HxrAdminUserLoginService 登录 @@ -186,27 +186,27 @@ public class HxrAdminGoodsService { private Optional refreshTokenFromBuyerShopping(HxrGoodsApiContext ctx) { String tenantId = TenantContextHolder.getTenantId(); if (!StringUtils.hasText(tenantId)) { - log.warn("hxr /api/order/goods token 失效,但当前线程无 tenantId,无法从 lb_buyer_shopping 模拟登录"); + log.warn("hxr /api/order/goods token 失效,但当前线程无 tenantId,无法从 lb_user 模拟登录"); return Optional.empty(); } - LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - queryWrapper.eq(LbBuyerShopping::getTenantId, tenantId.trim()) - .isNotNull(LbBuyerShopping::getBuyerMobile) - .ne(LbBuyerShopping::getBuyerMobile, "") - .orderByDesc(LbBuyerShopping::getBuyTime) + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(LbUser::getTenantId, tenantId.trim()) + // 昵称为 NULL 不排除;昵称非 NULL 时不能包含 ** + .and(w -> w.isNull(LbUser::getNickname).or().notLike(LbUser::getNickname, "**")) + .orderByDesc(LbUser::getJoinTime) .last("LIMIT 20"); - List samples = lbBuyerShoppingMapper.selectList(queryWrapper); + List samples = lbUserMapper.selectList(queryWrapper); if (samples == null || samples.isEmpty()) { - log.warn("hxr /api/order/goods token 失效,tenantId={} 在 lb_buyer_shopping 中未找到可用买家手机号", + log.warn("hxr /api/order/goods token 失效,tenantId={} 在 lb_user 中未找到可用买家手机号", tenantId); return Optional.empty(); } Set seenMobiles = new LinkedHashSet<>(); List candidateMobiles = new ArrayList<>(); - for (LbBuyerShopping sample : samples) { - String mobile = sample.getBuyerMobile(); + for (LbUser sample : samples) { + String mobile = sample.getMobile(); if (!StringUtils.hasText(mobile)) { continue; } @@ -216,7 +216,7 @@ public class HxrAdminGoodsService { } } if (candidateMobiles.isEmpty()) { - log.warn("hxr /api/order/goods token 失效,tenantId={} 在 lb_buyer_shopping 中候选手机号去重后为空", + log.warn("hxr /api/order/goods token 失效,tenantId={} 在 lb_user 中候选手机号去重后为空", tenantId); return Optional.empty(); } @@ -278,7 +278,7 @@ public class HxrAdminGoodsService { } boolean persisted = persistGoodsApiToken(tenantId.trim(), trimmedToken); - log.info("hxr goods 已通过 lb_buyer_shopping 买家手机号模拟登录获取新 token signType={} tenantId={} buyerMobile={} 序号={}/{} tokenPrefix={} persisted={}", + log.info("hxr goods 已通过 lb_user 买家手机号模拟登录获取新 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) { diff --git a/src/main/java/com/rj/service/HxrAdminUserService.java b/src/main/java/com/rj/service/HxrAdminUserService.java index cf369ab..2dcea4a 100644 --- a/src/main/java/com/rj/service/HxrAdminUserService.java +++ b/src/main/java/com/rj/service/HxrAdminUserService.java @@ -248,13 +248,11 @@ public class HxrAdminUserService { /** * ===== V2 分支:sign_type == v2(功能:「获取用户」列表/分页查询) ===== * - *

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

本功能不做模拟登录,永远使用数据库 {@code lb_third_integration_config} 中配置的 token + * ({@code ctx.token()},即 goods_api_token): *

    - *
  • 本功能是「获取用户」(fetchUserSelectPage = 列表/分页拉取)+ V2版本 - * → 需要按 v2 逻辑模拟登录: - * 配置 token 为空 → 先 root/123456 调 /trade-app/api/app/login 拿 JWT; - * 首次请求返回 401/403/500 → 刷新 token 后再重试一次。
  • - *
  • 「获取用户信息(单用户详情)」属于另一功能,另有独立实现,V2 下不做模拟登录(不在本方法内)。
  • + *
  • 数据库 token 为空 → 直接跳过(返回 empty),不会调用 /trade-app/api/app/login;
  • + *
  • 请求返回 401/403 等鉴权失败 → 不自动重登、不重试,仅记录日志并返回 empty,需人工在数据库更新 token;
  • *
  • V1 分支完全不变(fetchUserSelectPageV1 不受影响)。
  • *
* @@ -290,20 +288,15 @@ public class HxrAdminUserService { pureToken == null ? "(null)" : abbreviate(pureToken, 24), pureToken == null ? "(null)" : (pureToken.length() > 24 ? pureToken.substring(pureToken.length() - 24) : pureToken)); - // ======== 按功能模拟登录(本功能=获取用户列表+V2 → 要模拟登录) ======== - boolean simulatedLoginDone = false; + // ======== 本功能不模拟登录:永远使用数据库 lb_third_integration_config 配置的 token(ctx.token()) ======== + // token 缺失直接跳过,不调用 /trade-app/api/app/login;鉴权失败也不自动重登,需人工更新数据库 token。 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); - } + log.warn("[EMPTY_REASON:DB_TOKEN_EMPTY] v2 fetchUserSelectPageV2 数据库 token 为空,不模拟登录,直接跳过 domain={}", + trimmedDomain); + return Optional.empty(); } + log.info("v2 fetchUserSelectPageV2 使用数据库 token(不模拟登录) domain={} token(len={}) prefix={}", + trimmedDomain, pureToken.length(), abbreviate(pureToken, 24)); // ==================== Bug Fix 2:Cookie 空格编码用 %20 而不是 URLEncoder 的 + ==================== // Java URLEncoder.encode 会把空格变成 '+'(表单 application/x-www-form-urlencoded 规范), @@ -613,7 +606,8 @@ public class HxrAdminUserService { if (m == null) m = textOr(userNode.get("bankPhone"), null); u.setMobile(m); } - if (u.getPassword() == null) u.setPassword(textOr(userNode.get("password"), null)); + // 密码统一为 123456(第三方返回的是 BCrypt 哈希,后续模拟登录需要明文密码) + u.setPassword("123456"); 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))); @@ -1141,11 +1135,6 @@ 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 " 前缀后返回。 @@ -1154,7 +1143,7 @@ public class HxrAdminUserService { * @param password 登录密码 * @param baseDomain 站点 Origin;为空无法请求,返回 empty */ - private Optional doV2SimulateLogin(String phone, String password, String baseDomain) throws Exception { + public 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(); diff --git a/src/main/java/com/rj/service/impl/LbGoodsServiceImpl.java b/src/main/java/com/rj/service/impl/LbGoodsServiceImpl.java index d8980d6..410c373 100644 --- a/src/main/java/com/rj/service/impl/LbGoodsServiceImpl.java +++ b/src/main/java/com/rj/service/impl/LbGoodsServiceImpl.java @@ -533,6 +533,7 @@ public class LbGoodsServiceImpl extends ServiceImpl impl String goodsBeginTime) { Map result = new HashMap<>(); String accountTag = formatRushBuyAccountLogTag(rushBuyAccountLabel); + long rushBuyStartedAt = System.currentTimeMillis(); try { if (tenantId == null || tenantId.trim().isEmpty()) { result.put("success", false); @@ -593,6 +594,7 @@ public class LbGoodsServiceImpl extends ServiceImpl impl boolean tokenRefreshAttempted = false; boolean tokenRefreshed = false; boolean restartAfterTokenRefresh; + boolean isV2 = "v2".equalsIgnoreCase(apiContext.signType()); do { restartAfterTokenRefresh = false; @@ -617,7 +619,7 @@ public class LbGoodsServiceImpl extends ServiceImpl impl if (goods.getId() == null) { continue; } - if(goods.getPrice().intValue() >maxGrabAmount){ + if (maxGrabAmount != null && goods.getPrice().intValue() > maxGrabAmount) { log.info("跳过货品 {},因为超过最大抢单金额:{}", goods.getId(), goods.getPrice().intValue()); continue; } @@ -638,10 +640,13 @@ public class LbGoodsServiceImpl extends ServiceImpl impl : "该货品已被其他账号占用,我自动跳过"; skippedItem.put("message", skipReason); details.add(skippedItem); - if (accountTag.isEmpty()) { - log.info("跳过货品 {},{}", goods.getId(), skipReason); - } else { - log.info("{} 跳过货品 {},{}", accountTag, goods.getId(), skipReason); + // V2 全部抢单场景下用户数×货品数会产生海量跳过日志,静默不输出;V1 保持原日志 + if (!isV2) { + if (accountTag.isEmpty()) { + log.info("跳过货品 {},{}", goods.getId(), skipReason); + } else { + log.info("{} 跳过货品 {},{}", accountTag, goods.getId(), skipReason); + } } continue; } @@ -658,8 +663,25 @@ public class LbGoodsServiceImpl extends ServiceImpl impl } else { log.info("{} 正在抢购货品:{}", accountTag, item); } - HxrAdminBuyService.BuyApiResult buyResult = - hxrAdminBuyService.buy(goods.getId(), goods.getSellerId(), apiContext); + HxrAdminBuyService.BuyApiResult buyResult; + try { + if (isV2) { + // V2 抢购:GET /trade-app/api/app/sale/flash-sale?goodsId=&buyerId= + long buyerId = Long.parseLong(accountId); + buyResult = hxrAdminBuyService.buyV2(goods.getId(), buyerId, apiContext); + } else { + // V1 抢购:POST /api/order/buy + buyResult = hxrAdminBuyService.buy(goods.getId(), goods.getSellerId(), apiContext); + } + } catch (Exception itemEx) { + log.error("{} 抢单货品 {} 异常: {}", accountTag, goods.getId(), itemEx.getMessage(), itemEx); + failCount++; + item.put("apiCode", -1); + item.put("message", "抢单异常: " + itemEx.getMessage()); + item.put("success", false); + details.add(item); + continue; + } item.put("apiCode", buyResult.apiCode()); item.put("message", buyResult.apiMsg()); item.put("success", buyResult.success()); @@ -667,8 +689,14 @@ public class LbGoodsServiceImpl extends ServiceImpl impl if (buyResult.success()) { successCount++; - successTotalAmount += goods.getTotalMoney().intValue(); - Thread.sleep(3000); + if (goods.getTotalMoney() != null) { + successTotalAmount += goods.getTotalMoney().intValue(); + } + // V2 抢到后立即标记为不可抢,防止其他用户重复尝试同一货品 + if (isV2 && goodsCoordinator != null) { + goodsCoordinator.markUnavailable(tid, goods.getId()); + } + Thread.sleep(isV2 ? 200 : 3000); } else { failCount++; if (buyResult.orderAlreadyGrabbed()) { @@ -820,10 +848,16 @@ public class LbGoodsServiceImpl extends ServiceImpl impl result.put("stoppedByActivityEnded", stoppedByActivityEnded); result.put("tokenRefreshed", tokenRefreshed); result.put("details", details); + long rushBuyElapsedMs = System.currentTimeMillis() - rushBuyStartedAt; + result.put("elapsedMs", rushBuyElapsedMs); if (accountTag.isEmpty()) { - log.info("message={},抢购结束,success={}", result.get("message"),successCount > 0); + log.info("message={},抢购结束,success={}, 耗时={} ms ({} s)", + result.get("message"), successCount > 0, rushBuyElapsedMs, + String.format("%.2f", rushBuyElapsedMs / 1000.0)); } else { - log.info("message={}, {} 抢购结束,success={}", result.get("message"), accountTag, successCount > 0); + log.info("message={}, {} 抢购结束,success={}, 耗时={} ms ({} s)", + result.get("message"), accountTag, successCount > 0, rushBuyElapsedMs, + String.format("%.2f", rushBuyElapsedMs / 1000.0)); } return result; } finally { @@ -834,10 +868,12 @@ public class LbGoodsServiceImpl extends ServiceImpl impl } } } catch (Exception e) { + long rushBuyElapsedMs = System.currentTimeMillis() - rushBuyStartedAt; + result.put("elapsedMs", rushBuyElapsedMs); if (accountTag.isEmpty()) { - log.error("rushBuy failed", e); + log.error("rushBuy failed, 耗时={} ms", rushBuyElapsedMs, e); } else { - log.error("{} rushBuy failed", accountTag, e); + log.error("{} rushBuy failed, 耗时={} ms", accountTag, rushBuyElapsedMs, e); } result.put("success", false); result.put("message", "抢购异常:" + e.getMessage());