从抢货接口读取货物包

This commit is contained in:
2026-05-27 08:13:30 +08:00
parent 947bb21c8b
commit 0258079d49
12 changed files with 281 additions and 19 deletions

View File

@@ -68,7 +68,22 @@ public class HxrAdminProperties {
/**
* 货品列表 API 请求头 {@code token}。
*/
private String goodsApiToken = "c2c8cd18-90ff-4114-982b-6658d1121d51";
private String goodsApiToken = "04e2a8aa-d178-4da4-b97f-057849607824";
/**
* 货品 API 请求头 {@code Origin},与浏览器前端域一致。
*/
private String goodsApiOrigin = "https://hxrdweb.hxrdsm.cn";
/**
* 货品 API 请求头 {@code Referer}。
*/
private String goodsApiReferer = "https://hxrdweb.hxrdsm.cn/";
/**
* 货品 API 签名密钥(前端 configs.appStr
*/
private String goodsApiAppStr = "ssniQQ3UP2Vr8mXwaugssgaOLzQo0cX5";
/**
* 组装 Cookie 请求头:优先 {@link #cookie},否则 {@code PHPSID=}{@link #phpsid}。

View File

@@ -115,8 +115,8 @@ public class LbGoodsController {
@Operation(
summary = "从 hxrd 第三方同步货品",
description =
"分页调用 GET /api/order/goods每页 limit=20请求头 token"
+ "解析 data.list 后 upsert 到 lb_goods需配置 hxr.admin.goods-api-token")
"分页调用 GET /api/order/goods每页 limit=20自动计算 S/T/N 签名"
+ "解析 data.list 后 upsert 到 lb_goods需配置 hxr.admin.goods-api-token、goods-api-app-str")
public ResponseEntity<Map<String, Object>> syncFromHxr(
@Parameter(description = "租户 id", required = true) @RequestParam String tenantId) {
Map<String, Object> result = lbGoodsService.syncFromHxrGoods(tenantId);

View File

@@ -0,0 +1,66 @@
package com.rj.dto.hxr;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.type.CollectionType;
import com.rj.entity.LbGoods;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* 兼容第三方 {@code data} 两种形态:
* <ul>
* <li>对象:{@code {"list":[...],"hasmore":true,"last_page":13}}</li>
* <li>数组:{@code [...]}(按每页条数推断是否还有下一页)</li>
* </ul>
*/
public class HxrLbGoodsPageDataDeserializer extends JsonDeserializer<HxrLbGoodsPageData> {
private static final int DEFAULT_PAGE_SIZE = 20;
@Override
public HxrLbGoodsPageData deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
JsonNode node = p.getCodec().readTree(p);
if (node == null || node.isNull()) {
return emptyPage();
}
if (node.isArray()) {
if (node.isEmpty()) {
return emptyPage();
}
if (node.get(0).isTextual() || node.get(0).isNumber()) {
return emptyPage();
}
List<LbGoods> list = readGoodsList(ctxt, node);
boolean hasMore = list.size() >= DEFAULT_PAGE_SIZE;
int lastPage = hasMore ? Integer.MAX_VALUE : 1;
return new HxrLbGoodsPageData(list, hasMore, lastPage);
}
JsonNode listNode = node.get("list");
List<LbGoods> list = listNode != null && listNode.isArray()
? readGoodsList(ctxt, listNode)
: List.of();
boolean hasmore = node.path("hasmore").asBoolean(false);
int lastPage = node.path("last_page").asInt(1);
if (lastPage < 1) {
lastPage = 1;
}
return new HxrLbGoodsPageData(list, hasmore, lastPage);
}
private static List<LbGoods> readGoodsList(DeserializationContext ctxt, JsonNode listNode)
throws IOException {
CollectionType listType =
ctxt.getTypeFactory().constructCollectionType(List.class, LbGoods.class);
List<LbGoods> list = ctxt.readTreeAsValue(listNode, listType);
return list != null ? list : new ArrayList<>();
}
private static HxrLbGoodsPageData emptyPage() {
return new HxrLbGoodsPageData(List.of(), false, 1);
}
}

View File

@@ -1,4 +1,9 @@
package com.rj.dto.hxr;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
/** hxrd {@code /api/order/goods} 顶层 JSON。 */
public record HxrLbGoodsSelectResponse(int code, String msg, HxrLbGoodsPageData data) {}
public record HxrLbGoodsSelectResponse(
int code,
String msg,
@JsonDeserialize(using = HxrLbGoodsPageDataDeserializer.class) HxrLbGoodsPageData data) {}

View File

@@ -39,6 +39,10 @@ public class LbDeductionAmount implements Serializable {
@Schema(description = "抵扣金额")
private BigDecimal dikouAmt;
@TableField("tixian_amt")
@Schema(description = "提现金额")
private BigDecimal tixianAmt;
@TableField("original_text")
@Schema(description = "原始文本信息")
private String originalText;

View File

@@ -1,12 +1,14 @@
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.rj.config.HxrAdminProperties;
import com.rj.dto.hxr.HxrLbGoodsSelectResponse;
import com.rj.util.HxrGoodsSignUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -16,11 +18,14 @@ 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} 鉴权)。
* 调用 hxrd {@code /api/order/goods} 货品列表(请求头 {@code token} + {@code S/T/N} 鉴权)。
*/
@Slf4j
@Service
@@ -29,6 +34,12 @@ public class HxrAdminGoodsService {
public static final int GOODS_PAGE_SIZE = 20;
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 ObjectMapper JSON = new ObjectMapper()
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
@@ -48,25 +59,46 @@ public class HxrAdminGoodsService {
log.warn("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();
}
int safePage = Math.max(1, page);
String uri = UriComponentsBuilder.fromUriString(properties.getGoodsApiUrl())
.replaceQueryParam("page", Math.max(1, page))
.replaceQueryParam("page", safePage)
.replaceQueryParam("limit", GOODS_PAGE_SIZE)
.build()
.encode()
.toUriString();
long timestamp = System.currentTimeMillis() / 1000;
String noncestr = randomNoncestr();
Map<String, Object> signParams = new LinkedHashMap<>();
signParams.put("page", safePage);
signParams.put("limit", GOODS_PAGE_SIZE);
signParams.put("timestamp", timestamp);
signParams.put("noncestr", noncestr);
String sign = HxrGoodsSignUtil.computeSign(signParams, appStr.trim());
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(35))
.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", "application/json,*/*")
.header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
.header("Origin", properties.getGoodsApiOrigin())
.header("Referer", properties.getGoodsApiReferer())
.header("User-Agent", USER_AGENT)
.header("token", token.trim())
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
.header("S", sign)
.header("T", String.valueOf(timestamp))
.header("N", noncestr)
.GET()
.build();
@@ -74,24 +106,58 @@ public class HxrAdminGoodsService {
int status = response.statusCode();
if (status < 200 || status >= 300) {
log.warn("hxr /api/order/goods HTTP {} page={} bodyPrefix={}",
status, page, abbreviate(response.body(), 400));
status, safePage, abbreviate(response.body(), 400));
return Optional.empty();
}
String bodyText = response.body();
if (bodyText == null || bodyText.isBlank()) {
log.warn("hxr /api/order/goods empty body page={}", page);
log.warn("hxr /api/order/goods empty body page={}", safePage);
return Optional.empty();
}
HxrLbGoodsSelectResponse body = JSON.readValue(bodyText, HxrLbGoodsSelectResponse.class);
if (body.code() != 0) {
log.warn("hxr /api/order/goods api code={} msg={} page={}", body.code(), body.msg(), page);
JsonNode root = JSON.readTree(bodyText);
int code = root.path("code").asInt(-1);
String msg = root.path("msg").asText("");
if (code != 0) {
log.warn("hxr /api/order/goods api code={} msg={} page={} bodyPrefix={}",
code, msg, safePage, abbreviate(bodyText, 400));
return Optional.empty();
}
JsonNode dataNode = root.get("data");
if (isErrorPayload(dataNode)) {
log.warn("hxr /api/order/goods 返回异常 data={} page={} msg={}", dataNode, safePage, msg);
return Optional.empty();
}
HxrLbGoodsSelectResponse body = JSON.treeToValue(root, HxrLbGoodsSelectResponse.class);
return Optional.of(body);
}
/** 与前端 {@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 "";

View File

@@ -52,6 +52,8 @@ public class LbDeductionAmountServiceImpl
/** 优先匹配「申请…抵扣…金额数字」,避免误用「已提现金额」。 */
private static final Pattern DIKOU_AMT_PATTERN =
Pattern.compile("申请[^\\n]*?抵扣[^\\n]*?金额\\s*(\\d+(?:\\.\\d+)?)", Pattern.CASE_INSENSITIVE);
private static final Pattern TIXIAN_AMT_PATTERN =
Pattern.compile("已提现金额\\s*(\\d+(?:\\.\\d+)?)", Pattern.CASE_INSENSITIVE);
private static final String PARSE_SYSTEM_PROMPT = """
你是信息抽取助手。用户会提供「抵扣金记录」自然语言,常见为多行固定格式,例如:
@@ -64,11 +66,12 @@ public class LbDeductionAmountServiceImpl
1. 首行或文中「M月d日」如 5月21日仅作业务日期参考不要写入 JSON服务端会从原文补 createTime
2. 「姓名 + 空格 + 11位手机号」同一行userName 为姓名userPhone 为手机号。
3. dikouAmt 取含「申请」且含「抵扣」且含「金额」那一行中的数字(如上例 2678不要用「已提现金额」作为 dikouAmt。
4. FXJ、SJF 等字母为业务代号,忽略即可
5. 金额只输出数字,不要带「元」等单位
4. tixianAmt 取含「已提现金额」那一行中的数字(如上例 2678若无该行可省略
5. FXJ、SJF 等字母为业务代号,忽略即可
6. 金额只输出数字,不要带「元」等单位。
请只输出一个 JSON 对象,不要 markdown 代码块,不要解释性文字。
字段名必须完全一致userName、userPhone、dikouAmt。
字段名必须完全一致userName、userPhone、dikouAmt、tixianAmt
createTime 若填写,格式 yyyy-MM-dd HH:mm:ss服务端仅将非当前年的年份校正为系统当前年月日不变。
不要输出 id、tenantId、originalText、updateTime。
""";
@@ -132,10 +135,11 @@ public class LbDeductionAmountServiceImpl
enrichFromDeductionText(entity, trimmedText);
log.debug(
"parseFromTextByLlmAndSaveuserName={} userPhone={} dikouAmt={} createTime={}",
"parseFromTextByLlmAndSaveuserName={} userPhone={} dikouAmt={} tixianAmt={} createTime={}",
entity.getUserName(),
entity.getUserPhone(),
entity.getDikouAmt(),
entity.getTixianAmt(),
entity.getCreateTime());
return add(entity);
@@ -185,6 +189,7 @@ public class LbDeductionAmountServiceImpl
fillCreateTimeFromText(entity, text);
// 含「申请…抵扣…金额」时以该行为准,避免模型误用「已提现金额」
fillDikouAmtFromText(entity, text);
fillTixianAmtFromText(entity, text);
if (entity.getUserPhone() == null || entity.getUserPhone().isBlank()) {
fillPhoneFromText(entity, text);
}
@@ -221,6 +226,13 @@ public class LbDeductionAmountServiceImpl
}
}
private void fillTixianAmtFromText(LbDeductionAmount entity, String text) {
Matcher matcher = TIXIAN_AMT_PATTERN.matcher(text);
if (matcher.find()) {
entity.setTixianAmt(new BigDecimal(matcher.group(1)));
}
}
private void fillPhoneFromText(LbDeductionAmount entity, String text) {
Matcher matcher = MOBILE_PHONE_PATTERN.matcher(text);
if (matcher.find()) {
@@ -283,6 +295,9 @@ public class LbDeductionAmountServiceImpl
if (entity.getDikouAmt() == null) {
entity.setDikouAmt(BigDecimal.ZERO);
}
if (entity.getTixianAmt() == null) {
entity.setTixianAmt(BigDecimal.ZERO);
}
if (entity.getId() == null || entity.getId().trim().isEmpty()) {
entity.setId(UUID.randomUUID().toString());

View File

@@ -262,7 +262,7 @@ public class LbGoodsServiceImpl extends ServiceImpl<LbGoodsMapper, LbGoods> impl
if (opt.isEmpty()) {
if (page == 1) {
result.put("success", false);
result.put("message", "未拉取到货品(请检查 hxr.admin.goods-api-token、网络或第三方返回");
result.put("message", "未拉取到货品(请检查 hxr.admin.goods-api-token、goods-api-app-str 或网络)");
result.put("synced", 0);
return result;
}

View File

@@ -0,0 +1,58 @@
package com.rj.util;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
/** hxrd 前端 {@code getSignHeader} 对应的 SHA256 签名。 */
public final class HxrGoodsSignUtil {
private HxrGoodsSignUtil() {}
/**
* 按前端规则生成签名:参数键排序 → {@code k=v&...} → 拼接 appStr → SHA256 hex。
*
* @param params 请求参数 + {@code timestamp} + {@code noncestr}
* @param appStr 前端 configs.appStr
*/
public static String computeSign(Map<String, Object> params, String appStr) {
TreeMap<String, Object> sorted = new TreeMap<>();
if (params != null) {
for (Map.Entry<String, Object> entry : params.entrySet()) {
Object value = entry.getValue();
if (value == null) {
continue;
}
String text = String.valueOf(value);
if (text.isEmpty()) {
continue;
}
sorted.put(entry.getKey(), value);
}
}
String payload = sorted.entrySet().stream()
.map(e -> e.getKey() + "=" + e.getValue())
.collect(Collectors.joining("&"));
if (appStr != null && !appStr.isEmpty()) {
payload += appStr;
}
return sha256Hex(payload);
}
private static String sha256Hex(String input) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(hash.length * 2);
for (byte b : hash) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
}

View File

@@ -22,6 +22,11 @@ hxr:
pull-initial-delay-ms: 0
cookie: PHPSID=f3710baf6381da418aa832660ccb9d08
phpsid: "f3710baf6381da418aa832660ccb9d08"
# 货品同步 /api/order/goodsLbGoodsController#sync-from-hxr
goods-api-token: "04e2a8aa-d178-4da4-b97f-057849607824"
goods-api-app-str: "ssniQQ3UP2Vr8mXwaugssgaOLzQo0cX5"
goods-api-origin: "https://hxrdweb.hxrdsm.cn"
goods-api-referer: "https://hxrdweb.hxrdsm.cn/"
# DashScope API配置
dashscope:
api:

View File

@@ -0,0 +1,2 @@
ALTER TABLE `lb_deduction_amount`
ADD COLUMN `tixian_amt` DECIMAL(18, 2) NULL DEFAULT 0 COMMENT '提现金额' AFTER `dikou_amt`;

View File

@@ -0,0 +1,26 @@
package com.rj.util;
import org.junit.jupiter.api.Test;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
class HxrGoodsSignUtilTest {
@Test
void computeSign_matchesBrowserDevToolsSample() {
Map<String, Object> params = new LinkedHashMap<>();
params.put("page", 11);
params.put("limit", 20);
params.put("timestamp", 1779789545L);
params.put("noncestr", "4s3yi");
String sign = HxrGoodsSignUtil.computeSign(params, "ssniQQ3UP2Vr8mXwaugssgaOLzQo0cX5");
assertEquals(
"b9da2cce2cabf074401de36687dc90669ffe5a9ab9cc841bdaed412a13cbe536",
sign);
}
}