diff --git a/src/main/java/com/rj/config/HxrAdminProperties.java b/src/main/java/com/rj/config/HxrAdminProperties.java index 8b5a1be..8ee6ef4 100644 --- a/src/main/java/com/rj/config/HxrAdminProperties.java +++ b/src/main/java/com/rj/config/HxrAdminProperties.java @@ -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}。 diff --git a/src/main/java/com/rj/controller/LbGoodsController.java b/src/main/java/com/rj/controller/LbGoodsController.java index 2e50fa0..dd13aad 100644 --- a/src/main/java/com/rj/controller/LbGoodsController.java +++ b/src/main/java/com/rj/controller/LbGoodsController.java @@ -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> syncFromHxr( @Parameter(description = "租户 id", required = true) @RequestParam String tenantId) { Map result = lbGoodsService.syncFromHxrGoods(tenantId); diff --git a/src/main/java/com/rj/dto/hxr/HxrLbGoodsPageDataDeserializer.java b/src/main/java/com/rj/dto/hxr/HxrLbGoodsPageDataDeserializer.java new file mode 100644 index 0000000..aec0bf7 --- /dev/null +++ b/src/main/java/com/rj/dto/hxr/HxrLbGoodsPageDataDeserializer.java @@ -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} 两种形态: + * + */ +public class HxrLbGoodsPageDataDeserializer extends JsonDeserializer { + + 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 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 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 readGoodsList(DeserializationContext ctxt, JsonNode listNode) + throws IOException { + CollectionType listType = + ctxt.getTypeFactory().constructCollectionType(List.class, LbGoods.class); + List list = ctxt.readTreeAsValue(listNode, listType); + return list != null ? list : new ArrayList<>(); + } + + private static HxrLbGoodsPageData emptyPage() { + return new HxrLbGoodsPageData(List.of(), false, 1); + } +} diff --git a/src/main/java/com/rj/dto/hxr/HxrLbGoodsSelectResponse.java b/src/main/java/com/rj/dto/hxr/HxrLbGoodsSelectResponse.java index 18c9042..24647ab 100644 --- a/src/main/java/com/rj/dto/hxr/HxrLbGoodsSelectResponse.java +++ b/src/main/java/com/rj/dto/hxr/HxrLbGoodsSelectResponse.java @@ -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) {} diff --git a/src/main/java/com/rj/entity/LbDeductionAmount.java b/src/main/java/com/rj/entity/LbDeductionAmount.java index 1a4c099..aa98612 100644 --- a/src/main/java/com/rj/entity/LbDeductionAmount.java +++ b/src/main/java/com/rj/entity/LbDeductionAmount.java @@ -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; diff --git a/src/main/java/com/rj/service/HxrAdminGoodsService.java b/src/main/java/com/rj/service/HxrAdminGoodsService.java index 76e0a33..e09d781 100644 --- a/src/main/java/com/rj/service/HxrAdminGoodsService.java +++ b/src/main/java/com/rj/service/HxrAdminGoodsService.java @@ -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 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 ""; diff --git a/src/main/java/com/rj/service/impl/LbDeductionAmountServiceImpl.java b/src/main/java/com/rj/service/impl/LbDeductionAmountServiceImpl.java index 3443f64..9960e41 100644 --- a/src/main/java/com/rj/service/impl/LbDeductionAmountServiceImpl.java +++ b/src/main/java/com/rj/service/impl/LbDeductionAmountServiceImpl.java @@ -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( - "parseFromTextByLlmAndSave:userName={} userPhone={} dikouAmt={} createTime={}", + "parseFromTextByLlmAndSave:userName={} 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()); diff --git a/src/main/java/com/rj/service/impl/LbGoodsServiceImpl.java b/src/main/java/com/rj/service/impl/LbGoodsServiceImpl.java index cc70ce4..c06af61 100644 --- a/src/main/java/com/rj/service/impl/LbGoodsServiceImpl.java +++ b/src/main/java/com/rj/service/impl/LbGoodsServiceImpl.java @@ -262,7 +262,7 @@ public class LbGoodsServiceImpl extends ServiceImpl 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; } diff --git a/src/main/java/com/rj/util/HxrGoodsSignUtil.java b/src/main/java/com/rj/util/HxrGoodsSignUtil.java new file mode 100644 index 0000000..3510bcc --- /dev/null +++ b/src/main/java/com/rj/util/HxrGoodsSignUtil.java @@ -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 params, String appStr) { + TreeMap sorted = new TreeMap<>(); + if (params != null) { + for (Map.Entry 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); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0fa9a86..630017f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -22,6 +22,11 @@ hxr: pull-initial-delay-ms: 0 cookie: PHPSID=f3710baf6381da418aa832660ccb9d08 phpsid: "f3710baf6381da418aa832660ccb9d08" + # 货品同步 /api/order/goods(LbGoodsController#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: diff --git a/src/main/sql/lb_deduction_amount_alter_add_tixian_amt.sql b/src/main/sql/lb_deduction_amount_alter_add_tixian_amt.sql new file mode 100644 index 0000000..7a6dd6b --- /dev/null +++ b/src/main/sql/lb_deduction_amount_alter_add_tixian_amt.sql @@ -0,0 +1,2 @@ +ALTER TABLE `lb_deduction_amount` + ADD COLUMN `tixian_amt` DECIMAL(18, 2) NULL DEFAULT 0 COMMENT '提现金额' AFTER `dikou_amt`; diff --git a/src/test/java/com/rj/util/HxrGoodsSignUtilTest.java b/src/test/java/com/rj/util/HxrGoodsSignUtilTest.java new file mode 100644 index 0000000..208fd73 --- /dev/null +++ b/src/test/java/com/rj/util/HxrGoodsSignUtilTest.java @@ -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 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); + } +}