From 1445c01c781d7873c0d998686bd0210e481fab8e Mon Sep 17 00:00:00 2001 From: cst61 Date: Wed, 3 Jun 2026 23:10:26 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=B2=E8=B6=85=E5=87=BA=E5=BD=93=E5=A4=A9?= =?UTF-8?q?=E5=8F=AF=E6=8A=A2=E8=AE=A2=E5=8D=95=E6=95=B0=20=EF=BC=8C?= =?UTF-8?q?=E5=81=9C=E6=AD=A2=E6=8A=A2=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/rj/dto/hxr/HxrFansApiContext.java | 13 ++ .../com/rj/dto/hxr/HxrLbFansPageData.java | 11 ++ .../hxr/HxrLbFansPageDataDeserializer.java | 72 ++++++++ .../rj/dto/hxr/HxrLbFansSelectResponse.java | 10 ++ .../com/rj/service/HxrAdminFansService.java | 170 ++++++++++++++++++ ..._fan_management_alter_add_bonus_fields.sql | 7 + 6 files changed, 283 insertions(+) create mode 100644 src/main/java/com/rj/dto/hxr/HxrFansApiContext.java create mode 100644 src/main/java/com/rj/dto/hxr/HxrLbFansPageData.java create mode 100644 src/main/java/com/rj/dto/hxr/HxrLbFansPageDataDeserializer.java create mode 100644 src/main/java/com/rj/dto/hxr/HxrLbFansSelectResponse.java create mode 100644 src/main/java/com/rj/service/HxrAdminFansService.java create mode 100644 src/main/sql/lb_fan_management_alter_add_bonus_fields.sql diff --git a/src/main/java/com/rj/dto/hxr/HxrFansApiContext.java b/src/main/java/com/rj/dto/hxr/HxrFansApiContext.java new file mode 100644 index 0000000..9a82c46 --- /dev/null +++ b/src/main/java/com/rj/dto/hxr/HxrFansApiContext.java @@ -0,0 +1,13 @@ +package com.rj.dto.hxr; + +/** + * 调用 hxrd {@code /api/share/select} 粉丝列表 API 所需的运行时配置(由 {@code lb_third_integration_config} 解析而来)。 + */ +public record HxrFansApiContext( + String fansApiBaseUrl, + int pageLimit, + String origin, + String referer, + String token, + String appStr) { +} diff --git a/src/main/java/com/rj/dto/hxr/HxrLbFansPageData.java b/src/main/java/com/rj/dto/hxr/HxrLbFansPageData.java new file mode 100644 index 0000000..eb422bd --- /dev/null +++ b/src/main/java/com/rj/dto/hxr/HxrLbFansPageData.java @@ -0,0 +1,11 @@ +package com.rj.dto.hxr; + +import java.util.List; +import java.util.Map; + +/** hxrd {@code /api/share/select} 响应中的 {@code data} 节点。 */ +public record HxrLbFansPageData( + List> list, + boolean hasmore, + int lastPage) { +} diff --git a/src/main/java/com/rj/dto/hxr/HxrLbFansPageDataDeserializer.java b/src/main/java/com/rj/dto/hxr/HxrLbFansPageDataDeserializer.java new file mode 100644 index 0000000..fa33316 --- /dev/null +++ b/src/main/java/com/rj/dto/hxr/HxrLbFansPageDataDeserializer.java @@ -0,0 +1,72 @@ +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.MapType; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 兼容第三方 {@code data} 两种形态: + * + */ +public class HxrLbFansPageDataDeserializer extends JsonDeserializer { + + private static final int DEFAULT_PAGE_SIZE = 10; + + @Override + public HxrLbFansPageData 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 = readMapList(ctxt, node); + boolean hasMore = list.size() >= DEFAULT_PAGE_SIZE; + int lastPage = hasMore ? Integer.MAX_VALUE : 1; + return new HxrLbFansPageData(list, hasMore, lastPage); + } + JsonNode listNode = node.get("list"); + List> list = listNode != null && listNode.isArray() + ? readMapList(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 HxrLbFansPageData(list, hasmore, lastPage); + } + + private static List> readMapList(DeserializationContext ctxt, JsonNode listNode) + throws IOException { + MapType mapType = ctxt.getTypeFactory().constructMapType(LinkedHashMap.class, String.class, Object.class); + List> list = new ArrayList<>(); + for (JsonNode item : listNode) { + Map row = ctxt.readTreeAsValue(item, mapType); + if (row != null) { + list.add(row); + } + } + return list; + } + + private static HxrLbFansPageData emptyPage() { + return new HxrLbFansPageData(List.of(), false, 1); + } +} diff --git a/src/main/java/com/rj/dto/hxr/HxrLbFansSelectResponse.java b/src/main/java/com/rj/dto/hxr/HxrLbFansSelectResponse.java new file mode 100644 index 0000000..c7560c4 --- /dev/null +++ b/src/main/java/com/rj/dto/hxr/HxrLbFansSelectResponse.java @@ -0,0 +1,10 @@ +package com.rj.dto.hxr; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +/** hxrd {@code /api/share/select} 顶层 JSON。 */ +public record HxrLbFansSelectResponse( + int code, + String msg, + @JsonDeserialize(using = HxrLbFansPageDataDeserializer.class) HxrLbFansPageData data) { +} diff --git a/src/main/java/com/rj/service/HxrAdminFansService.java b/src/main/java/com/rj/service/HxrAdminFansService.java new file mode 100644 index 0000000..d5f35af --- /dev/null +++ b/src/main/java/com/rj/service/HxrAdminFansService.java @@ -0,0 +1,170 @@ +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.rj.dto.hxr.HxrFansApiContext; +import com.rj.dto.hxr.HxrLbFansSelectResponse; +import com.rj.util.HxrGoodsSignUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +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 GET /api/share/select} 分页拉取粉丝列表(请求头 {@code token} + {@code S/T/N} 鉴权)。 + */ +@Slf4j +@Service +public class HxrAdminFansService { + + public static final int FANS_PAGE_SIZE = 10; + + 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); + + /** + * 分页拉取粉丝列表(使用租户 {@code lb_third_integration_config} 解析出的运行时配置)。 + * + * @param page 页码,从 1 开始 + */ + public Optional fetchFansPage(int page, HxrFansApiContext ctx) throws Exception { + if (ctx == null) { + log.warn("粉丝 API 配置为空,跳过 /api/share/select"); + return Optional.empty(); + } + String resolvedToken = ctx.token(); + if (resolvedToken == null || resolvedToken.isBlank()) { + log.warn("未提供 token,跳过 /api/share/select"); + return Optional.empty(); + } + String appStr = ctx.appStr(); + if (appStr == null || appStr.isBlank()) { + log.warn("未配置 goodsApiAppStr,跳过 /api/share/select"); + return Optional.empty(); + } + String fansApiBaseUrl = ctx.fansApiBaseUrl(); + if (fansApiBaseUrl == null || fansApiBaseUrl.isBlank()) { + log.warn("未配置 fansApiBaseUrl,跳过 /api/share/select"); + return Optional.empty(); + } + + int pageLimit = ctx.pageLimit() > 0 ? ctx.pageLimit() : FANS_PAGE_SIZE; + int safePage = Math.max(1, page); + String uri = UriComponentsBuilder.fromUriString(fansApiBaseUrl) + .replaceQueryParam("page", safePage) + .replaceQueryParam("limit", pageLimit) + .build() + .encode() + .toUriString(); + + long timestamp = System.currentTimeMillis() / 1000; + String noncestr = randomNoncestr(); + Map 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.trim()); + + 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 response = client.send(request, HttpResponse.BodyHandlers.ofString()); + int status = response.statusCode(); + if (status < 200 || status >= 300) { + log.warn("hxr /api/share/select HTTP {} page={} bodyPrefix={}", + status, safePage, abbreviate(response.body(), 400)); + return Optional.empty(); + } + + String bodyText = response.body(); + if (bodyText == null || bodyText.isBlank()) { + log.warn("hxr /api/share/select empty body page={}", safePage); + return Optional.empty(); + } + + 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/share/select 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/share/select 返回异常 data={} page={} msg={}", dataNode, safePage, msg); + return Optional.empty(); + } + + HxrLbFansSelectResponse body = JSON.treeToValue(root, HxrLbFansSelectResponse.class); + return Optional.of(body); + } + + 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) + "..."; + } +} diff --git a/src/main/sql/lb_fan_management_alter_add_bonus_fields.sql b/src/main/sql/lb_fan_management_alter_add_bonus_fields.sql new file mode 100644 index 0000000..a94dd58 --- /dev/null +++ b/src/main/sql/lb_fan_management_alter_add_bonus_fields.sql @@ -0,0 +1,7 @@ +-- 升级脚本:lb_fan_management 增加优惠券与奖金字段 +SET NAMES utf8mb4; + +ALTER TABLE `lb_fan_management` + ADD COLUMN `coupon` DECIMAL(12,3) NOT NULL DEFAULT 0.000 COMMENT '优惠券金额' AFTER `token`, + ADD COLUMN `self_bonus` DECIMAL(12,3) NOT NULL DEFAULT 0.000 COMMENT '自购奖金' AFTER `coupon`, + ADD COLUMN `share_bonus` DECIMAL(12,3) NOT NULL DEFAULT 0.000 COMMENT '分享奖金' AFTER `self_bonus`;