From aa30fd23836ad1af19da4a39e0ef1d3b44c1b242 Mon Sep 17 00:00:00 2001
From: lovife <474239041@qq.com>
Date: Wed, 30 Sep 2026 09:02:54 +0800
Subject: [PATCH] =?UTF-8?q?feat(open):=20=C2=A714=20=E5=BC=80=E6=94=BE?=
=?UTF-8?q?=E6=8E=A5=E5=8F=A3=EF=BC=88V16=EF=BC=89=E2=80=94=E2=80=94=20HMA?=
=?UTF-8?q?C=20=E7=AD=BE=E5=90=8D=E5=AF=B9=E6=8E=A5=E8=AE=A4=E5=AE=9A?=
=?UTF-8?q?=E6=9C=BA=E6=9E=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
密钥存 AES 密文(HMAC 要能还原,不能只存哈希);签名 = HMAC-SHA256 + 5 分钟时间窗
+ nonce 防重放,先验签再占 nonce;数据接口一律不返回明文证件号,文件走 30 分钟签名 URL;
推送重试的 next_retry_at 落库(内存定时器重启后就没了),推送挂在 afterCommit 上。
契约漏掉的接入方管理(F-67)一并补上;规则模板两个接口修回契约路径(F-68)。
验证:e2e-m8-open 60/60。
---
.../open/controller/OpenApiController.java | 194 +++++++++
.../controller/OpenAppAdminController.java | 202 +++++++++
.../module/open/entity/CollectOpenApp.java | 66 +++
.../open/entity/CollectOpenPushLog.java | 81 ++++
.../module/open/mapper/OpenAppMapper.java | 41 ++
.../module/open/mapper/OpenPushLogMapper.java | 46 ++
.../module/open/service/OpenDataService.java | 279 ++++++++++++
.../module/open/service/OpenPushService.java | 398 ++++++++++++++++++
.../module/open/service/OpenSignService.java | 249 +++++++++++
.../resources/db/migration/V16__open_api.sql | 82 ++++
10 files changed, 1638 insertions(+)
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/controller/OpenApiController.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/controller/OpenAppAdminController.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/entity/CollectOpenApp.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/entity/CollectOpenPushLog.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/mapper/OpenAppMapper.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/mapper/OpenPushLogMapper.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/service/OpenDataService.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/service/OpenPushService.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/service/OpenSignService.java
create mode 100644 backend/xt-server/src/main/resources/db/migration/V16__open_api.sql
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/controller/OpenApiController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/controller/OpenApiController.java
new file mode 100644
index 0000000..13d9e68
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/controller/OpenApiController.java
@@ -0,0 +1,194 @@
+package cn.tianbaotong.fill.module.open.controller;
+
+import cn.tianbaotong.fill.common.api.ApiResult;
+import cn.tianbaotong.fill.common.api.ErrorCode;
+import cn.tianbaotong.fill.common.exception.BizException;
+import cn.tianbaotong.fill.security.PublicApi;
+import cn.tianbaotong.fill.module.open.entity.CollectOpenApp;
+import cn.tianbaotong.fill.module.open.service.OpenDataService;
+import cn.tianbaotong.fill.module.open.service.OpenPushService;
+import cn.tianbaotong.fill.module.open.service.OpenSignService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 开放接口(《03-接口设计》§14)—— 供认定机构对接。
+ *
+ *
鉴权:两条路都支持
+ *
+ *
+ * - HMAC 头(契约的鉴权方式):{@code X-App-Key} + {@code X-Timestamp} +
+ * {@code X-Nonce} + {@code X-Sign = HMAC-SHA256(appSecret, appKey+timestamp+nonce+body)};
+ * - 访问令牌:先调 {@code /auth/token} 换一个 2 小时的 token,
+ * 之后 {@code Authorization: Bearer }。
+ *
+ *
+ * 契约同时定义了这两种凭据,所以两种都实现。为什么要两种:
+ * HMAC 每次请求都要算签名(更安全,但批量对接时对方要写更多代码),
+ * 令牌适合"先换一次、后面连调几十次"。机构那边的实现习惯两种都有。
+ *
+ *
⚠️ 它是 {@code @PublicApi}:匿名、靠 HMAC 自证
+ *
+ * 这个标注意味着**不检查登录态**。放行的理由不是"外部系统没有账号"这种方便说法,
+ * 而是:它的鉴权强度**不低于**登录态(HMAC 覆盖 appKey+时间戳+nonce+报文体,
+ * 且带 5 分钟时间窗与 nonce 防重放),并且每一步都要显式校验 scope。
+ * 换句话说,这里的"公开"是**换了一套凭据**,而不是"不要凭据"。
+ *
+ *
⚠️ 这里的 Body 必须读"原文"
+ *
+ * 签名覆盖的是**原始 JSON 字符串**(未格式化、未排序)。所以校验必须在
+ * Controller 里拿 {@code @RequestBody String}(原文)来做,**不能**先反序列化成对象
+ * 再序列化回去算签名 —— 字段顺序与空白一变签名就不一样,而错误表现只是"签名不通过",
+ * 排查方向会完全跑偏。
+ *
+ *
⚠️ 路径前缀为什么是 {@code /api/v1/open/v1/...}
+ *
+ * 契约 §14 的详情节写的是 {@code /open/v1/auth/token}(不带 {@code /api/v1}),
+ * 而 §1 声明全部接口在生产是 {@code https://api.tianbaotong.cn/api/v1/**}。
+ * 两处不一致,这里按 §1 走(与其它 100 多个接口同一个前缀,网关只需一条规则),
+ * 差异记在 F-65。
+ */
+@Slf4j
+@PublicApi
+@Tag(name = "开放接口(认定机构对接)")
+@RestController
+@RequestMapping("/api/v1/open/v1")
+@RequiredArgsConstructor
+public class OpenApiController {
+
+ private final OpenSignService signService;
+ private final OpenDataService dataService;
+ private final OpenPushService pushService;
+
+ // ── §14.1 换令牌 ──────────────────────────────
+
+ @PostMapping("/auth/token")
+ @Operation(summary = "换取访问令牌(§14.1)",
+ description = "请求体 `{appKey, appSecret}`,返回 2 小时的 accessToken 与 scope 列表。"
+ + "**这个接口不需要签名头**:它本身就是用密钥换令牌的地方 —— "
+ + "要求它再带一次 HMAC 会让第一次对接多写一层代码,而安全性并没有提升"
+ + "(密钥已经在请求体里了)")
+ public ApiResult token(@RequestBody(required = false) String rawBody) {
+ Map body = parseBody(rawBody);
+ String appKey = str(body.get("appKey"));
+ String appSecret = str(body.get("appSecret"));
+ return ApiResult.ok(signService.issueToken(appKey, appSecret));
+ }
+
+ // ── §14.2 填报单 ──────────────────────────────
+
+ @GetMapping("/tasks/{id}/submissions")
+ @Operation(summary = "拉取任务的填报单(§14.2)",
+ description = "默认只返回**已通过**(status=3)的单子;`since` 支持增量拉取"
+ + "(ISO 时间或毫秒时间戳)。"
+ + "⚠️ **一律不返回明文证件号** —— 库里 `id_card_no` 是 AES 密文、"
+ + "另有 `id_card_mask`,这里只用脱敏值,连解密都不做")
+ public ApiResult submissions(
+ @PathVariable Long id,
+ @RequestParam(required = false) String since,
+ @RequestParam(required = false) Integer status,
+ @RequestParam(defaultValue = "1") int pageNum,
+ @RequestParam(defaultValue = "100") int pageSize,
+ HttpServletRequest req) {
+ CollectOpenApp app = auth(req, "");
+ signService.requireScope(app, CollectOpenApp.SCOPE_SUBMISSION_READ);
+ return ApiResult.ok(dataService.submissions(app, id, since, status, pageNum, pageSize));
+ }
+
+ // ── §14.3 文件 ────────────────────────────────
+
+ @GetMapping("/tasks/{id}/files")
+ @Operation(summary = "拉取材料文件(§14.3)",
+ description = "`submissionIds` 逗号分隔、**最多 200 份**。返回的是**签名 URL**"
+ + "(有效期 30 分钟):七牛空间是私有的,链接不能入库、不能长期有效,"
+ + "过期请重新获取。"
+ + "⚠️ 会校验这些填报单确实属于 path 里那个任务 —— "
+ + "不校验就能拿 A 任务的路径去拉 B 任务的文件")
+ public ApiResult