新系统模拟抢单

This commit is contained in:
2026-09-06 18:09:33 +08:00
parent 0e797a2a9b
commit 5b5c3bdb41
5 changed files with 665 additions and 60 deletions

View File

@@ -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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> result = new HashMap<>();
List<Map<String, Object>> 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<LbUser> users = lbUserService.list();
if (users == null || users.isEmpty()) {
result.put("success", false);
result.put("message", "当前租户的 lb_user 表为空");
return ResponseEntity.ok(result);
}
// 过滤出有效用户
List<LbUser> 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<CompletableFuture<Void>> futures = new ArrayList<>();
log.info("批次抢单提交 {} 个用户到线程池(线程池大小={}),等待第一个用户开始计时",
validUsers.size(), threadCount);
for (LbUser user : validUsers) {
final LbUser u = user;
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
// 第一个真正开始执行(模拟登录)的线程记录总耗时起点,后续线程不再覆盖
long nowMs = System.currentTimeMillis();
if (firstUserStartAt.compareAndSet(0L, nowMs)) {
log.info("总耗时起点:第一个用户[{}]开始模拟登录,时间戳={}", u.getMobile(), nowMs);
}
String userLabel = "用户[" + u.getMobile() + "]";
Map<String, Object> 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<String> 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<HxrUserLoginApiContext> 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<String, Object> 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);
}
}
}

View File

@@ -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<byte[]> 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 包含「活动未开始」时,调用方应立刻停止该账号及本批次后续抢购。 */

View File

@@ -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<String> 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<LbBuyerShopping> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(LbBuyerShopping::getTenantId, tenantId.trim())
.isNotNull(LbBuyerShopping::getBuyerMobile)
.ne(LbBuyerShopping::getBuyerMobile, "")
.orderByDesc(LbBuyerShopping::getBuyTime)
LambdaQueryWrapper<LbUser> 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<LbBuyerShopping> samples = lbBuyerShoppingMapper.selectList(queryWrapper);
List<LbUser> 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<String> seenMobiles = new LinkedHashSet<>();
List<String> 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) {

View File

@@ -248,13 +248,11 @@ public class HxrAdminUserService {
/**
* ===== V2 分支:sign_type == v2(功能:「获取用户」列表/分页查询) =====
*
* <p>按功能判断是否模拟登录(不看域名):
* <p><b>本功能不做模拟登录,永远使用数据库 {@code lb_third_integration_config} 中配置的 token</b>
* ({@code ctx.token()},即 goods_api_token):
* <ul>
* <li>本功能是「获取用户」(fetchUserSelectPage = 列表/分页拉取)+ V2版本
* → <b>需要按 v2 逻辑模拟登录</b>:
* 配置 token 为空 → 先 root/123456 调 /trade-app/api/app/login 拿 JWT;
* 首次请求返回 401/403/500 → 刷新 token 后再重试一次。</li>
* <li>「获取用户信息(单用户详情)」属于另一功能,另有独立实现,V2 下不做模拟登录(不在本方法内)。</li>
* <li>数据库 token 为空 → 直接跳过(返回 empty),不会调用 /trade-app/api/app/login;</li>
* <li>请求返回 401/403 等鉴权失败 → 不自动重登、不重试,仅记录日志并返回 empty,需人工在数据库更新 token;</li>
* <li>V1 分支完全不变(fetchUserSelectPageV1 不受影响)。</li>
* </ul>
*
@@ -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<String> 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<String> doV2SimulateLogin(String phone, String password, String baseDomain) throws Exception {
public Optional<String> 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();

View File

@@ -533,6 +533,7 @@ public class LbGoodsServiceImpl extends ServiceImpl<LbGoodsMapper, LbGoods> impl
String goodsBeginTime) {
Map<String, Object> 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<LbGoodsMapper, LbGoods> 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<LbGoodsMapper, LbGoods> 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<LbGoodsMapper, LbGoods> 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<LbGoodsMapper, LbGoods> 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<LbGoodsMapper, LbGoods> 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<LbGoodsMapper, LbGoods> 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<LbGoodsMapper, LbGoods> 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());