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