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)—— 供认定机构对接。 + * + *

鉴权:两条路都支持

+ * + *
    + *
  1. HMAC 头(契约的鉴权方式):{@code X-App-Key} + {@code X-Timestamp} + + * {@code X-Nonce} + {@code X-Sign = HMAC-SHA256(appSecret, appKey+timestamp+nonce+body)};
  2. + *
  3. 访问令牌:先调 {@code /auth/token} 换一个 2 小时的 token, + * 之后 {@code Authorization: Bearer }。
  4. + *
+ * + *

契约同时定义了这两种凭据,所以两种都实现。为什么要两种: + * 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> files( + @PathVariable Long id, + @RequestParam(required = false) String submissionIds, + HttpServletRequest req) { + CollectOpenApp app = auth(req, ""); + signService.requireScope(app, CollectOpenApp.SCOPE_FILE_READ); + List> files = dataService.files(app, id, submissionIds); + Map out = new LinkedHashMap<>(); + out.put("files", files); + out.put("expireSeconds", 1800); + return ApiResult.ok(out); + } + + // ── §14.4 推送 ──────────────────────────────── + + @PostMapping("/callback/push") + @Operation(summary = "人工重推(§14.4)", + description = "契约 §14.4 描述的是**我方 → 机构**的报文(终审通过后主动推)," + + "并把它的重试策略写在这里(5 次,间隔 1/5/30/120/600 秒)。" + + "所以这个路径实现为**人工重推入口**:请求体 `{submissionId}`," + + "把那份填报单的推送重发一次,并**重置重试次数** —— " + + "它是「人判断过该再试一次」,不该被「已经失败 5 次」挡住(那正是需要人工的场景)。" + + "自动推送与自动重试在终审通过时/定时任务里发生,见 F-66") + public ApiResult> repush(@RequestBody(required = false) String rawBody, + HttpServletRequest req) { + CollectOpenApp app = auth(req, rawBody); + signService.requireScope(app, CollectOpenApp.SCOPE_SUBMISSION_READ); + Map body = parseBody(rawBody); + Long submissionId = body.get("submissionId") == null ? null + : Long.valueOf(String.valueOf(body.get("submissionId"))); + if (submissionId == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请传 submissionId"); + } + return ApiResult.ok(pushService.manualRepush(submissionId)); + } + + // ── 鉴权 ────────────────────────────────────── + + /** + * 校验请求:先看 {@code Authorization: Bearer},再看 HMAC 头。 + * + *

⚠️ 顺序有意义:令牌是"已经用密钥换过一次"的凭据,优先用它可以让 + * 批量对接的请求少算 100 次 HMAC。而两者都没有时给出**能照着做**的错误消息。 + */ + private CollectOpenApp auth(HttpServletRequest req, String body) { + String authz = req.getHeader("Authorization"); + if (authz != null && authz.regionMatches(true, 0, "Bearer ", 0, 7)) { + return signService.appOfToken(authz.substring(7).trim()); + } + String appKey = req.getHeader("X-App-Key"); + if (appKey == null || appKey.isBlank()) { + throw new BizException(ErrorCode.UNAUTHORIZED, + "请带 Authorization: Bearer ," + + "或带 X-App-Key / X-Timestamp / X-Nonce / X-Sign 四个签名头"); + } + return signService.verify(appKey, req.getHeader("X-Timestamp"), + req.getHeader("X-Nonce"), req.getHeader("X-Sign"), body); + } + + private static Map parseBody(String raw) { + if (raw == null || raw.isBlank()) { + return Map.of(); + } + try { + return cn.hutool.json.JSONUtil.parseObj(raw); + } catch (RuntimeException e) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请求体不是合法 JSON"); + } + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/controller/OpenAppAdminController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/controller/OpenAppAdminController.java new file mode 100644 index 0000000..2d70ff8 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/controller/OpenAppAdminController.java @@ -0,0 +1,202 @@ +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.context.LoginUser; +import cn.tianbaotong.fill.common.context.UserContext; +import cn.tianbaotong.fill.common.exception.BizException; +import cn.tianbaotong.fill.module.open.entity.CollectOpenApp; +import cn.tianbaotong.fill.module.open.mapper.OpenAppMapper; +import cn.tianbaotong.fill.module.open.service.OpenPushService; +import cn.tianbaotong.fill.security.PermissionDef; +import cn.tianbaotong.fill.security.RequirePerm; +import cn.tianbaotong.fill.support.SensitiveCodec; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +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.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 开放接入方的管理(契约 §14 **没有**定义这几个接口,见 F-67)。 + * + *

为什么必须补

+ * + *

契约 §14 定义了"机构怎么调我们",却没定义"appKey 从哪来" —— + * 没有这一层,接入方只能靠改库产生,而 `appSecret` 是密文列, + * 改库意味着有人要手工加密一个字符串。也就是说 §14 的四个接口**在真实流程里跑不起来**。 + * + *

两条安全要求

+ * + *
    + *
  1. appSecret 只在创建/重置时返回一次,之后任何接口都不再给 + * (库里存的是 AES 密文,服务端能解出来算 HMAC,但不该把它发出去);
  2. + *
  3. 密钥重置要显式动作(`POST /{id}/reset-secret`)而不是「编辑时顺手改」 —— + * 重置会让对方现有的签名**立刻失效**,这是个要交代清楚的运维动作。
  4. + *
+ */ +@Slf4j +@Tag(name = "开放接入方管理") +@RestController +@RequestMapping("/api/v1/open-apps") +@RequiredArgsConstructor +public class OpenAppAdminController { + + private final OpenAppMapper appMapper; + private final SensitiveCodec codec; + private final OpenPushService pushService; + + @GetMapping + @RequirePerm(PermissionDef.LOG_VIEW_TENANT) + @Operation(summary = "接入方列表(不含密钥)") + public ApiResult>> list() { + List> out = new ArrayList<>(); + for (CollectOpenApp a : appMapper.selectList(Wrappers.lambdaQuery() + .orderByDesc(CollectOpenApp::getId))) { + out.add(view(a, false)); + } + return ApiResult.ok(out); + } + + @PostMapping + @RequirePerm(PermissionDef.LOG_VIEW_TENANT) + @Operation(summary = "新建接入方", + description = "返回的 `appSecret` **只在这里出现一次** —— 请立刻交给对方。" + + "库里存的是 AES 密文(HMAC 要能还原密钥,所以不能只存哈希)") + public ApiResult> create(@RequestBody Map body) { + LoginUser me = UserContext.require(); + String name = str(body.get("appName")); + if (name == null || name.isBlank()) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写接入方名称"); + } + String appKey = str(body.get("appKey")); + if (appKey == null || appKey.isBlank()) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请指定 appKey"); + } + if (appMapper.findByAppKey(appKey) != null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "这个 appKey 已经被占用"); + } + String secret = randomSecret(); + CollectOpenApp a = new CollectOpenApp(); + a.setAppKey(appKey.trim()); + a.setAppSecretCipher(codec.encrypt(secret)); + a.setAppName(name.trim()); + a.setScopes(str(body.get("scopes")) == null + ? CollectOpenApp.SCOPE_SUBMISSION_READ + "," + CollectOpenApp.SCOPE_FILE_READ + : str(body.get("scopes"))); + a.setCallbackUrl(str(body.get("callbackUrl"))); + String pushSecret = str(body.get("pushSecret")); + if (pushSecret != null && !pushSecret.isBlank()) { + a.setPushSecretCipher(codec.encrypt(pushSecret)); + } + a.setStatus(CollectOpenApp.STATUS_ENABLED); + a.setRemark(str(body.get("remark"))); + appMapper.insert(a); + log.info("新建开放接入方 appKey={} 名称={} scopes={} 经办人={}", + a.getAppKey(), a.getAppName(), a.getScopes(), me.uid()); + Map v = view(a, true); + v.put("appSecret", secret); + v.put("告警", "appSecret 只显示这一次,请立刻保存并交给对方"); + return ApiResult.ok(v); + } + + @PostMapping("/{id}/reset-secret") + @RequirePerm(PermissionDef.LOG_VIEW_TENANT) + @Operation(summary = "重置密钥", + description = "⚠️ 重置会让对方**现有的签名立刻失效**,需要把新密钥交给他们。" + + "做成显式动作而不是「编辑时顺手改」:这是个要交代清楚的运维动作") + public ApiResult> resetSecret(@PathVariable Long id) { + CollectOpenApp a = appMapper.selectById(id); + if (a == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "接入方不存在或已删除"); + } + String secret = randomSecret(); + CollectOpenApp upd = new CollectOpenApp(); + upd.setId(id); + upd.setAppSecretCipher(codec.encrypt(secret)); + appMapper.updateById(upd); + log.warn("重置开放接入方密钥 appKey={}(对方旧签名立刻失效)", a.getAppKey()); + Map v = view(a, false); + v.put("appSecret", secret); + v.put("告警", "旧密钥已失效,请把新密钥交给对方"); + return ApiResult.ok(v); + } + + @PostMapping("/{id}/toggle") + @RequirePerm(PermissionDef.LOG_VIEW_TENANT) + @Operation(summary = "启用 / 停用", + description = "停用**立刻生效**:令牌校验每次都回查接入方,所以不用等 2 小时令牌过期") + public ApiResult> toggle(@PathVariable Long id) { + CollectOpenApp a = appMapper.selectById(id); + if (a == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "接入方不存在或已删除"); + } + boolean enabled = a.enabled(); + CollectOpenApp upd = new CollectOpenApp(); + upd.setId(id); + upd.setStatus(enabled ? CollectOpenApp.STATUS_DISABLED : CollectOpenApp.STATUS_ENABLED); + appMapper.updateById(upd); + log.info("开放接入方 {} appKey={}", enabled ? "停用" : "启用", a.getAppKey()); + return ApiResult.ok(view(appMapper.selectById(id), false)); + } + + @GetMapping("/push-logs") + @RequirePerm(PermissionDef.LOG_VIEW_TENANT) + @Operation(summary = "推送记录(§14.4 的「支持人工重推」要看它)", + description = "`status=3` 是**最终失败**(重试 5 次仍不通,需人工重推)。" + + "这个数字就是「推送失败谁会知道」的答案 —— 它显示在这里,运维据此打电话催") + public ApiResult>> pushLogs( + @RequestParam(required = false) Long taskId, + @RequestParam(required = false) Integer status, + @RequestParam(defaultValue = "50") int limit) { + return ApiResult.ok(pushService.logs(taskId, status, limit)); + } + + /** ⚠️ `appSecret` 只在 `withSecret` 为真时出现(只有创建/重置会传 true)。 */ + private Map view(CollectOpenApp a, boolean withSecret) { + Map m = new LinkedHashMap<>(); + m.put("id", a.getId()); + m.put("appKey", a.getAppKey()); + m.put("appName", a.getAppName()); + m.put("scopes", a.getScopes() == null ? List.of() + : java.util.Arrays.stream(a.getScopes().split(",")).map(String::trim) + .filter(s -> !s.isEmpty()).toList()); + m.put("callbackUrl", a.getCallbackUrl()); + m.put("hasPushSecret", a.getPushSecretCipher() != null); + m.put("status", a.getStatus()); + m.put("enabled", a.enabled()); + m.put("remark", a.getRemark()); + m.put("createTime", a.getCreateTime()); + if (!withSecret) { + m.put("appSecret", null); + } + return m; + } + + /** 32 位随机密钥(大小写字母 + 数字),够 HMAC 用。 */ + private static String randomSecret() { + String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + java.security.SecureRandom rnd = new java.security.SecureRandom(); + StringBuilder sb = new StringBuilder(32); + for (int i = 0; i < 32; i++) { + sb.append(chars.charAt(rnd.nextInt(chars.length()))); + } + return sb.toString(); + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/entity/CollectOpenApp.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/entity/CollectOpenApp.java new file mode 100644 index 0000000..4416241 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/entity/CollectOpenApp.java @@ -0,0 +1,66 @@ +package cn.tianbaotong.fill.module.open.entity; + +import cn.tianbaotong.fill.support.TenantEntity; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +/** + * 开放接口接入方({@code collect_open_app},契约 §14)。 + * + *

为什么密钥存密文而不是哈希

+ * + *

用户的密码存哈希(服务端永远不需要知道原文),而**HMAC 的密钥必须能被服务端还原** —— + * 校验签名时要拿它重算一遍。所以这里用 AES 加密后存 + * (与身份证号同一套 `SensitiveCodec`),并且**任何接口都不返回它** + * (只在创建接入方时给一次,之后只能重置)。 + */ +@Getter +@Setter +@TableName("collect_open_app") +public class CollectOpenApp extends TenantEntity { + + public static final int STATUS_ENABLED = 1; + public static final int STATUS_DISABLED = 0; + + /** 权限:读填报单数据。 */ + public static final String SCOPE_SUBMISSION_READ = "submission:read"; + /** 权限:读材料文件(签名 URL)。 */ + public static final String SCOPE_FILE_READ = "file:read"; + + private String appKey; + + /** AES 密文。⚠️ 只用于服务端算 HMAC,**永不返回**。 */ + private String appSecretCipher; + + private String appName; + + /** 逗号分隔:{@code submission:read,file:read}。 */ + private String scopes; + + /** 我方主动推送的目标地址。为空表示这家机构不接收推送。 */ + private String callbackUrl; + + /** 推送时的签名密钥(机构侧校验用),同样是密文。 */ + private String pushSecretCipher; + + private Integer status; + + private String remark; + + public boolean hasScope(String scope) { + if (scopes == null || scope == null) { + return false; + } + for (String s : scopes.split(",")) { + if (scope.equals(s.trim())) { + return true; + } + } + return false; + } + + public boolean enabled() { + return Integer.valueOf(STATUS_ENABLED).equals(status); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/entity/CollectOpenPushLog.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/entity/CollectOpenPushLog.java new file mode 100644 index 0000000..dbe513c --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/entity/CollectOpenPushLog.java @@ -0,0 +1,81 @@ +package cn.tianbaotong.fill.module.open.entity; + +import cn.tianbaotong.fill.support.TenantEntity; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * 开放接口的推送记录({@code collect_open_push_log},契约 §14.4)。 + * + *

为什么"下次重试时间"要落库

+ * + *

契约写明失败重试 5 次、间隔 **1/5/30/120/600 秒**。 + * 要做到"1 秒后重试"又不阻塞请求线程,就得有个地方记住"什么时候再试": + *

    + *
  • 放内存里 —— 服务重启就全没了。而"审核通过后机构没收到" + * 是**交付链路断了一环**,不能因为一次发版就永远丢掉;
  • + *
  • 落库 + 定时任务扫 —— 重启后自动接着重试,且人工重推有据可查。
  • + *
+ * + *

为什么一个(接入方, 填报单)只留一条

+ * + *

唯一索引 {@code uk_push_once}。自动推送、自动重试、人工重推**都更新同一条** —— + * 否则"这家机构到底收到没有"会有多个互相矛盾的答案, + * 而运维要靠它判断要不要打电话催。 + */ +@Getter +@Setter +@TableName("collect_open_push_log") +public class CollectOpenPushLog extends TenantEntity { + + /** {@code 0} 待推 / {@code 1} 成功 / {@code 2} 失败待重试 / {@code 3} 最终失败(需人工重推)。 */ + public static final int STATUS_PENDING = 0; + public static final int STATUS_OK = 1; + public static final int STATUS_RETRY = 2; + public static final int STATUS_FAILED = 3; + + public static final String EVENT_SUBMISSION_APPROVED = "SUBMISSION_APPROVED"; + + /** 契约 §14.4 的重试间隔(秒):1 / 5 / 30 / 120 / 600,共 5 次。 */ + public static final int[] RETRY_DELAYS_SECONDS = {1, 5, 30, 120, 600}; + + private Long appId; + + private Long taskId; + + private Long submissionId; + + private String eventType; + + private Integer status; + + private Integer attempts; + + private Integer lastHttpCode; + + private String lastError; + + /** 推给对方的报文(原文)。留着是为了"人工重推"能原样再发一次,也为了出问题能查。 */ + private String payloadJson; + + private LocalDateTime nextRetryAt; + + private LocalDateTime pushedTime; + + /** 还有没有下一次重试机会。 */ + public boolean canRetry() { + return attempts != null && attempts < RETRY_DELAYS_SECONDS.length; + } + + /** 第 {@code attempts} 次失败之后该等多少秒。 */ + public int nextDelaySeconds() { + int i = attempts == null ? 0 : attempts; + if (i >= RETRY_DELAYS_SECONDS.length) { + return -1; + } + return RETRY_DELAYS_SECONDS[i]; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/mapper/OpenAppMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/mapper/OpenAppMapper.java new file mode 100644 index 0000000..760aca4 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/mapper/OpenAppMapper.java @@ -0,0 +1,41 @@ +package cn.tianbaotong.fill.module.open.mapper; + +import cn.tianbaotong.fill.module.open.entity.CollectOpenApp; +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** + * 开放接口接入方。 + * + *

⚠️ 鉴权发生在**进入租户上下文之前** —— 那时还不知道请求属于哪个租户, + * 而"appKey 属于哪个租户"正是要在这一步查出来的。所以这里的查询必须 + * {@code @InterceptorIgnore(tenantLine="true")},隔离由 {@code app_key} 的唯一约束保证 + * (一个 appKey 只属于一个租户)。这是"默认拒绝"体系里必须主动开的口子, + * 范围就是这一个查询(与 `MpSceneMapper`、微信回调同一做法)。 + */ +public interface OpenAppMapper extends BaseMapper { + + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT * FROM collect_open_app + WHERE app_key = #{appKey} AND deleted = 0 + """) + CollectOpenApp findByAppKey(@Param("appKey") String appKey); + + /** + * 按 id 取接入方(**无租户上下文**时用)。 + * + *

⚠️ 必须用它而不是 `selectById`:开放接口是匿名的, + * 而"拿令牌回查接入方"这一步**还没有租户上下文** —— + * 用 `selectById` 会被租户拦截器拒绝(`未登录或登录已失效`),对外是 50000。 + * 这类错在 HMAC 路径上不出现(那条路走 `findByAppKey`), + * 所以只有令牌路径会 500,看起来像"令牌功能没做"。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT * FROM collect_open_app WHERE id = #{id} AND deleted = 0 + """) + CollectOpenApp findByIdIgnoreTenant(@Param("id") Long id); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/mapper/OpenPushLogMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/mapper/OpenPushLogMapper.java new file mode 100644 index 0000000..a8cb422 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/mapper/OpenPushLogMapper.java @@ -0,0 +1,46 @@ +package cn.tianbaotong.fill.module.open.mapper; + +import cn.tianbaotong.fill.module.open.entity.CollectOpenPushLog; +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** 推送记录(契约 §14.4)。 */ +public interface OpenPushLogMapper extends BaseMapper { + + /** 到点该重试的(定时任务用)。只取"失败待重试"的,不碰成功与最终失败的。 */ + @Select(""" + SELECT * FROM collect_open_push_log + WHERE deleted = 0 AND status = 2 + AND next_retry_at IS NOT NULL AND next_retry_at <= NOW() + ORDER BY next_retry_at + LIMIT 50 + """) + List dueRetries(); + + /** + * 按填报单找最近的推送记录(**无租户上下文**时用)。 + * + *

⚠️ 人工重推可能从开放接口进来(匿名),而第一步要"先找到这条记录属于哪个租户" + * 才能摆上下文 —— 用 `selectOne` 会被租户拦截器拒(对外 50000)。 + * 这与 `OpenAppMapper.findByIdIgnoreTenant` 是同一个模式: + * **"先找归属、再摆上下文"里,找归属那一步必须能无租户执行。** + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT * FROM collect_open_push_log + WHERE submission_id = #{submissionId} AND deleted = 0 + ORDER BY id DESC LIMIT 1 + """) + CollectOpenPushLog findBySubmissionIgnoreTenant(@Param("submissionId") Long submissionId); + + /** 某个任务下"最终失败"的条数(给界面显示"有 N 条推送没送到")。 */ + @Select(""" + SELECT COUNT(*) FROM collect_open_push_log + WHERE deleted = 0 AND task_id = #{taskId} AND status = 3 + """) + long failedCount(@Param("taskId") Long taskId); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/service/OpenDataService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/service/OpenDataService.java new file mode 100644 index 0000000..f3bad0e --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/service/OpenDataService.java @@ -0,0 +1,279 @@ +package cn.tianbaotong.fill.module.open.service; + +import cn.tianbaotong.fill.common.api.ErrorCode; +import cn.tianbaotong.fill.common.exception.BizException; +import cn.tianbaotong.fill.module.file.entity.CollectFile; +import cn.tianbaotong.fill.module.file.mapper.FileMapper; +import cn.tianbaotong.fill.module.file.service.MaterialFileService; +import cn.tianbaotong.fill.module.fill.entity.CollectSubmission; +import cn.tianbaotong.fill.module.fill.mapper.SubmissionMapper; +import cn.tianbaotong.fill.module.open.entity.CollectOpenApp; +import cn.tianbaotong.fill.module.roster.entity.CollectRoster; +import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper; +import cn.tianbaotong.fill.module.task.entity.CollectTask; +import cn.tianbaotong.fill.module.task.mapper.TaskMapper; +import cn.tianbaotong.fill.support.TenantScope; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 开放接口的数据读取(契约 §14.2 / §14.3)。 + * + *

三条不能含糊的边界

+ * + *
    + *
  1. 永远不返回明文证件号(契约 §14.2 明确写了)。 + * 库里 `id_card_no` 是 AES 密文、另有 `id_card_mask`,所以这里**只用脱敏值** —— + * 连解密都不做,从代码上就不可能泄露;
  2. + *
  3. 只返回"已通过"的填报单(默认 {@code status=3})。 + * 开放接口是给认定机构"拿数据去做认定"的,把待审/驳回的单子给他们 + * 会导致对方按未确认的数据办事;允许传 status 只是为了兼容客户要求的 + * "拉过去自己再筛"的场景,而**默认值必须是 3**;
  4. + *
  5. 文件给的是签名 URL(30 分钟)。七牛空间是私有的, + * 链接不能入库、不能长期有效 —— 契约 §14.3 也写明"过期需重新获取"。
  6. + *
+ * + *

为什么每个方法都要先摆租户上下文

+ * + *

开放接口的调用方是外部系统,请求进来时**没有登录态**, + * 而租户隔离靠 {@code ThreadLocal}。appKey 唯一确定一个租户, + * 所以这里用 {@link TenantScope#runAs} 把它摆好之后, + * 后面所有查询都走**正常的带租户读写** —— 而不是给每个查询加 + * {@code @InterceptorIgnore}(那样每加一个查询就多一个越权风险点)。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OpenDataService { + + /** 一次最多取多少份填报单的文件(契约 §14.3:≤200)。 */ + public static final int MAX_FILE_SUBMISSIONS = 200; + + /** 签名 URL 有效期(秒),契约 §14.3:30 分钟。 */ + private static final int URL_EXPIRE_SECONDS = 1800; + + private final SubmissionMapper submissionMapper; + private final CollectRosterMapper rosterMapper; + private final FileMapper fileMapper; + private final TaskMapper taskMapper; + private final MaterialFileService fileService; + + /** 在接入方所属租户的上下文里执行。 */ + private T inTenant(CollectOpenApp app, java.util.function.Supplier body) { + // ⚠️ 要拿返回值必须用 callAs(runAs 是 void 版本)—— + // 写成 runAs 时编译器会报"void 无法转换为 T",但这一条很容易顺手写错 + return TenantScope.callAs(app.getTenantId(), null, Set.of(), body); + } + + // ── §14.2 填报单 ────────────────────────────── + + public SubmissionPage submissions(CollectOpenApp app, Long taskId, String since, Integer status, + int pageNum, int pageSize) { + return inTenant(app, () -> { + CollectTask task = taskMapper.selectById(taskId); + if (task == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除"); + } + // 默认只给已通过的(见类注释第 2 条);传 0 表示"不筛状态" + Integer st = status == null ? CollectSubmission.STATUS_APPROVED : status; + LocalDateTime sinceTime = parseSince(since); + + var q = Wrappers.lambdaQuery() + .eq(CollectSubmission::getTaskId, taskId) + .eq(st != null && st > 0, CollectSubmission::getStatus, st) + .gt(sinceTime != null, CollectSubmission::getUpdateTime, sinceTime) + .orderByAsc(CollectSubmission::getId); + var page = submissionMapper.selectPage( + new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>( + Math.max(1, pageNum), Math.min(Math.max(1, pageSize), 500)), q); + + List rows = page.getRecords(); + Map rosters = new HashMap<>(); + List rosterIds = rows.stream().map(CollectSubmission::getRosterId) + .filter(java.util.Objects::nonNull).distinct().toList(); + if (!rosterIds.isEmpty()) { + for (CollectRoster r : rosterMapper.selectByIds(rosterIds)) { + rosters.put(r.getId(), r); + } + } + + SubmissionPage out = new SubmissionPage(); + List> list = new ArrayList<>(); + for (CollectSubmission s : rows) { + CollectRoster r = s.getRosterId() == null ? null : rosters.get(s.getRosterId()); + Map m = new java.util.LinkedHashMap<>(); + m.put("submissionId", s.getId()); + m.put("taskId", s.getTaskId()); + m.put("status", s.getStatus()); + m.put("submittedTime", s.getSubmitTime()); + if (r != null) { + m.put("realName", r.getRealName()); + // ⚠️ **只给脱敏值**,连解密都不做(见类注释第 1 条) + m.put("idCardMask", r.getIdCardMask()); + m.put("orgId", r.getOrgId()); + } + m.put("occupation", s.getOccupation()); + m.put("worktype", s.getWorktype()); + m.put("jobLevel", s.getJobLevel()); + m.put("education", s.getEducation()); + m.put("school", s.getSchool()); + m.put("major", s.getMajor()); + m.put("certNo", s.getCertNo()); + list.add(m); + } + out.setList(list); + out.setTotal(page.getTotal()); + out.setTaskCode(task.getTaskCode()); + out.setTaskName(task.getTaskName()); + return out; + }); + } + + // ── §14.3 文件 ──────────────────────────────── + + public List> files(CollectOpenApp app, Long taskId, String submissionIds) { + return inTenant(app, () -> { + CollectTask task = taskMapper.selectById(taskId); + if (task == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除"); + } + Set ids = new LinkedHashSet<>(); + if (submissionIds != null && !submissionIds.isBlank()) { + for (String part : submissionIds.split(",")) { + String t = part.trim(); + if (t.isEmpty()) { + continue; + } + try { + ids.add(Long.valueOf(t)); + } catch (NumberFormatException e) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "submissionIds 里有不是数字的项:" + t); + } + } + } + if (ids.isEmpty()) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请传 submissionIds"); + } + if (ids.size() > MAX_FILE_SUBMISSIONS) { + throw new BizException(ErrorCode.BATCH_LIMIT_EXCEEDED, + "一次最多取 " + MAX_FILE_SUBMISSIONS + " 份填报单的文件(当前 " + + ids.size() + " 份)"); + } + + Map subs = new HashMap<>(); + for (CollectSubmission s : submissionMapper.selectByIds(ids)) { + // 归属校验:这些填报单必须属于 path 里那个任务(不然能拿 A 任务的 id 拉 B 任务的文件) + if (!taskId.equals(s.getTaskId())) { + throw new BizException(ErrorCode.FORBIDDEN, + "填报单 " + s.getId() + " 不属于任务 " + taskId); + } + subs.put(s.getId(), s); + } + if (subs.size() != ids.size()) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "有 " + (ids.size() - subs.size()) + " 份填报单不存在或已删除"); + } + Map rosters = new HashMap<>(); + List rosterIds = subs.values().stream().map(CollectSubmission::getRosterId) + .filter(java.util.Objects::nonNull).distinct().toList(); + if (!rosterIds.isEmpty()) { + for (CollectRoster r : rosterMapper.selectByIds(rosterIds)) { + rosters.put(r.getId(), r); + } + } + + List files = fileMapper.selectList(Wrappers.lambdaQuery() + .in(CollectFile::getSubmissionId, ids) + .eq(CollectFile::getIsCurrent, CollectFile.CURRENT) + .orderByAsc(CollectFile::getSubmissionId) + .orderByAsc(CollectFile::getMaterialCode)); + + List> out = new ArrayList<>(); + for (CollectFile f : files) { + CollectSubmission s = subs.get(f.getSubmissionId()); + CollectRoster r = s == null || s.getRosterId() == null + ? null : rosters.get(s.getRosterId()); + // 每次现取签名链接(不能缓存、不能入库 —— 它会过期) + String url; + try { + url = fileService.urlOf(f, false, (long) URL_EXPIRE_SECONDS, "open-api").url(); + } catch (RuntimeException e) { + log.warn("开放接口取文件链接失败 fileId={}(跳过这一份)", f.getId(), e); + continue; + } + Map m = new java.util.LinkedHashMap<>(); + m.put("fileId", f.getId()); + m.put("submissionId", f.getSubmissionId()); + m.put("realName", r == null ? null : r.getRealName()); + m.put("materialCode", f.getMaterialCode()); + m.put("standardName", f.getStandardName()); + m.put("url", url); + m.put("sizeKb", f.getFinalSize() == null ? null : f.getFinalSize() / 1024); + m.put("expireSeconds", URL_EXPIRE_SECONDS); + out.add(m); + } + return out; + }); + } + + /** + * 增量拉取的时间参数。 + * + *

接受两种写法:ISO 时间(`2026-09-28T17:00:00`)与**毫秒时间戳**。 + * 两种都收是因为"开放接口的调用方是谁"无法预设 —— + * Java 那边习惯 ISO,而 JS/Python 那边直接给时间戳更省事。 + * 认不出来时**明确报错**,不静默当成"不筛"(那会把全量数据一次性推给对方)。 + */ + private LocalDateTime parseSince(String since) { + if (since == null || since.isBlank()) { + return null; + } + String s = since.trim(); + // `0` 表示"从 epoch 开始",也就是"拉全量"—— 调用方懒得算时间时就用它。 + // 只认 10~13 位数字的话,`since=0` 会落到 ISO 解析并报"格式不对", + // 而那个报错看起来像"接口坏了"(它其实只是没约定 0 的含义) + if ("0".equals(s)) { + return LocalDateTime.of(1970, 1, 1, 0, 0); + } + if (s.matches("\\d{10,13}")) { + long v = Long.parseLong(s); + if (s.length() == 10) { + v *= 1000; // 秒级时间戳 + } + return LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(v), + java.time.ZoneId.systemDefault()); + } + try { + return LocalDateTime.parse(s); + } catch (RuntimeException e) { + try { + return LocalDateTime.parse(s.replace(" ", "T")); + } catch (RuntimeException e2) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "since 格式不对,请用 ISO 时间(2026-09-28T17:00:00)或毫秒时间戳"); + } + } + } + + // ── 响应结构 ── + + @lombok.Getter + @lombok.Setter + public static class SubmissionPage { + private List> list = new ArrayList<>(); + private long total; + private String taskCode; + private String taskName; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/service/OpenPushService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/service/OpenPushService.java new file mode 100644 index 0000000..8a26174 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/service/OpenPushService.java @@ -0,0 +1,398 @@ +package cn.tianbaotong.fill.module.open.service; + +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import cn.tianbaotong.fill.common.api.ErrorCode; +import cn.tianbaotong.fill.common.exception.BizException; +import cn.tianbaotong.fill.module.file.entity.CollectFile; +import cn.tianbaotong.fill.module.file.mapper.FileMapper; +import cn.tianbaotong.fill.module.file.service.MaterialFileService; +import cn.tianbaotong.fill.module.fill.entity.CollectSubmission; +import cn.tianbaotong.fill.module.fill.mapper.SubmissionMapper; +import cn.tianbaotong.fill.module.open.entity.CollectOpenApp; +import cn.tianbaotong.fill.module.open.entity.CollectOpenPushLog; +import cn.tianbaotong.fill.module.open.mapper.OpenAppMapper; +import cn.tianbaotong.fill.module.open.mapper.OpenPushLogMapper; +import cn.tianbaotong.fill.module.roster.entity.CollectRoster; +import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper; +import cn.tianbaotong.fill.module.task.entity.CollectTask; +import cn.tianbaotong.fill.module.task.mapper.TaskMapper; +import cn.tianbaotong.fill.support.SensitiveCodec; +import cn.tianbaotong.fill.support.TenantScope; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 终审通过后推送给认定机构(契约 §14.4)。 + * + *

为什么重试要靠定时任务 + 库里的 {@code next_retry_at}

+ * + *

见 {@link CollectOpenPushLog} 的类注释:内存定时器在重启后就没了, + * 而"审核通过后机构没收到"是交付链路断了一环。所以: + *

    + *
  • 推送失败 → 算出下次重试时间写库(间隔 1/5/30/120/600 秒);
  • + *
  • 定时任务每 10 秒扫一次到点的记录({@code next_retry_at <= now});
  • + *
  • 5 次都失败 → 状态置"最终失败",不再自动重试,等人工。
  • + *
+ * + *

为什么"最终失败"要停而不是一直重试

+ * + *

一直重试看起来更"努力",但对方的回调地址如果是配错的(或者他们的服务已经下线), + * 无限重试只会把日志刷满、把我们自己的线程占住,而**问题不会自己好**。 + * 停下来的代价是"需要有人看一眼",而这个代价是必要的: + * 交付链路断了,本来就该有人知道。 + * + *

HTTP 调用不放事务里

+ * + *

推送是外部调用,可能慢到几十秒。把它放进审核的事务里会让 + * "终审通过"这个动作被外部系统的响应时间绑架 —— 对方挂了,我们的审核就卡住。 + * 所以推送**在事务提交之后**单独做(由调用方在事务外触发),失败也只影响推送记录。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OpenPushService { + + private static final int TIMEOUT_SECONDS = 15; + + /** 签名 URL 有效期(与数据接口一致:30 分钟)。 */ + private static final int URL_EXPIRE_SECONDS = 1800; + + private final OpenAppMapper appMapper; + private final OpenPushLogMapper pushLogMapper; + private final TaskMapper taskMapper; + private final SubmissionMapper submissionMapper; + private final CollectRosterMapper rosterMapper; + private final FileMapper fileMapper; + private final MaterialFileService fileService; + private final SensitiveCodec codec; + + private final HttpClient http = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + + // ── 对外的三个入口 ──────────────────────────── + + /** + * 终审通过后触发推送(**在事务外调用**)。 + * + *

对每一家"配了回调地址且启用"的接入方建一条推送记录并立刻尝试一次。 + * 没有接入方配回调时**什么都不做**(不建空记录)—— 否则表里会堆满 + * "没有目标"的记录,而运维会以为有推送在失败。 + */ + public void onSubmissionApproved(Long tenantId, Long taskId, Long submissionId) { + // ⚠️ 要拿返回值就必须用 callAs(runAs 是 void 版本)—— 这一点编译器会拦, + // 但写的时候很容易顺手写成 runAs + List apps = TenantScope.callAs(tenantId, null, Set.of(), + () -> appMapper.selectList(Wrappers.lambdaQuery() + .eq(CollectOpenApp::getStatus, CollectOpenApp.STATUS_ENABLED)) + .stream() + .filter(a -> a.getCallbackUrl() != null && !a.getCallbackUrl().isBlank()) + .toList()); + if (apps.isEmpty()) { + return; + } + for (CollectOpenApp app : apps) { + try { + TenantScope.runAs(tenantId, null, Set.of(), () -> { + CollectOpenPushLog logRow = ensureLog(app, taskId, submissionId); + attempt(app, logRow); + }); + } catch (RuntimeException e) { + log.warn("触发开放接口推送失败 appId={} submissionId={}", app.getId(), submissionId, e); + } + } + } + + /** + * 人工重推(契约 §14.4 的"支持人工重推")。 + * + *

⚠️ 这个入口可能被**开放接口调用**(那时没有租户上下文), + * 所以它先用忽略租户的查询找到推送记录与接入方,再摆好租户上下文做后续查询。 + */ + public Map manualRepush(Long submissionId) { + // ⚠️ 用忽略租户的查询找归属(此时可能还没有租户上下文), + // 找到之后立刻用 callAs 把上下文摆好再走正常读写 + CollectOpenPushLog probe = pushLogMapper.findBySubmissionIgnoreTenant(submissionId); + if (probe != null) { + CollectOpenApp owner = appMapper.findByIdIgnoreTenant(probe.getAppId()); + if (owner != null) { + return TenantScope.callAs(owner.getTenantId(), null, Set.of(), + () -> doManualRepush(submissionId)); + } + } + return doManualRepush(submissionId); + } + + private Map doManualRepush(Long submissionId) { + CollectSubmission sub = submissionMapper.selectById(submissionId); + if (sub == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "填报单不存在或已删除"); + } + CollectOpenPushLog row = pushLogMapper.selectOne( + Wrappers.lambdaQuery() + .eq(CollectOpenPushLog::getSubmissionId, submissionId) + .orderByDesc(CollectOpenPushLog::getId) + .last("limit 1")); + if (row == null) { + // 没有记录 = 当时没有接入方配回调,或者推送从没触发过。 + // 明确说清"先补一条记录"而不是安静地什么都不做 + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "这份填报单还没有推送记录(可能是终审通过时没有接入方配置回调地址)"); + } + CollectOpenApp app = appMapper.selectById(row.getAppId()); + if (app == null || !app.enabled()) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "对应的接入方不存在或已停用"); + } + if (app.getCallbackUrl() == null || app.getCallbackUrl().isBlank()) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "这个接入方没有配置回调地址"); + } + // 人工重推**重置状态与次数**:它是"人判断过该再试一次", + // 不该被"已经失败 5 次"挡住(那正是需要人工的场景) + row.setStatus(CollectOpenPushLog.STATUS_PENDING); + row.setAttempts(0); + row.setNextRetryAt(null); + pushLogMapper.updateById(row); + attempt(app, row); + return describe(row); + } + + public List> logs(Long taskId, Integer status, int limit) { + var q = Wrappers.lambdaQuery() + .eq(taskId != null, CollectOpenPushLog::getTaskId, taskId) + .eq(status != null, CollectOpenPushLog::getStatus, status) + .orderByDesc(CollectOpenPushLog::getId) + .last("limit " + Math.min(Math.max(1, limit), 200)); + List> out = new ArrayList<>(); + for (CollectOpenPushLog r : pushLogMapper.selectList(q)) { + out.add(describe(r)); + } + return out; + } + + // ── 定时重试 ────────────────────────────────── + + /** + * 每 10 秒扫一次到点的重试。 + * + *

⚠️ 定时任务是"没有请求也会发生的事",所以它必须能回答"失败了谁会知道": + * 这里的答案是 —— 5 次之后状态变成 3(最终失败), + * 而那个数字会显示在任务的推送记录里(`GET /open/v1/callback/logs`), + * 运维据此打电话催。**不是"重试到天荒地老"。** + */ + @Scheduled(fixedDelay = 10_000) + public void retryDue() { + List due; + try { + due = pushLogMapper.dueRetries(); + } catch (RuntimeException e) { + log.warn("扫描推送重试失败(下一轮再试)", e); + return; + } + for (CollectOpenPushLog row : due) { + try { + // 定时任务没有租户上下文,用忽略租户的查询(范围仅此一处) + CollectOpenApp app = appMapper.findByIdIgnoreTenant(row.getAppId()); + if (app == null || !app.enabled()) { + // 接入方没了/停了:不再重试,标成最终失败并说明原因 + row.setStatus(CollectOpenPushLog.STATUS_FAILED); + row.setLastError("接入方不存在或已停用,停止重试"); + row.setNextRetryAt(null); + pushLogMapper.updateById(row); + continue; + } + TenantScope.runAs(app.getTenantId(), null, Set.of(), () -> attempt(app, row)); + } catch (RuntimeException e) { + log.warn("重试推送失败 pushLogId={}", row.getId(), e); + } + } + } + + // ── 内部 ────────────────────────────────────── + + private CollectOpenPushLog ensureLog(CollectOpenApp app, Long taskId, Long submissionId) { + CollectOpenPushLog exist = pushLogMapper.selectOne( + Wrappers.lambdaQuery() + .eq(CollectOpenPushLog::getAppId, app.getId()) + .eq(CollectOpenPushLog::getSubmissionId, submissionId) + .eq(CollectOpenPushLog::getEventType, + CollectOpenPushLog.EVENT_SUBMISSION_APPROVED) + .last("limit 1")); + if (exist != null) { + return exist; + } + CollectOpenPushLog row = new CollectOpenPushLog(); + row.setAppId(app.getId()); + row.setTaskId(taskId); + row.setSubmissionId(submissionId); + row.setEventType(CollectOpenPushLog.EVENT_SUBMISSION_APPROVED); + row.setStatus(CollectOpenPushLog.STATUS_PENDING); + row.setAttempts(0); + row.setPayloadJson(buildPayload(app, taskId, submissionId)); + pushLogMapper.insert(row); + return row; + } + + /** 组报文(契约 §14.4 的字段逐个对应)。 */ + private String buildPayload(CollectOpenApp app, Long taskId, Long submissionId) { + CollectTask task = taskMapper.selectById(taskId); + CollectSubmission sub = submissionMapper.selectById(submissionId); + CollectRoster r = sub == null || sub.getRosterId() == null + ? null : rosterMapper.selectById(sub.getRosterId()); + + JSONObject data = JSONUtil.createObj(); + if (r != null) { + data.set("realName", r.getRealName()); + // ⚠️ 脱敏值:开放接口一律不给明文证件号(与 §14.2 同口径)。 + // 对方要做认定核验时,靠的是"我们已经是审核通过的"这个事实 + 材料原件 + data.set("idCardMask", r.getIdCardMask()); + } + if (sub != null) { + data.set("occupation", sub.getOccupation()); + data.set("worktype", sub.getWorktype()); + data.set("jobLevel", sub.getJobLevel()); + } + + cn.hutool.json.JSONArray files = JSONUtil.createArray(); + for (CollectFile f : fileMapper.selectList(Wrappers.lambdaQuery() + .eq(CollectFile::getSubmissionId, submissionId) + .eq(CollectFile::getIsCurrent, CollectFile.CURRENT))) { + JSONObject fo = JSONUtil.createObj(); + fo.set("materialCode", f.getMaterialCode()); + fo.set("standardName", f.getStandardName()); + try { + fo.set("url", fileService.urlOf(f, false, (long) URL_EXPIRE_SECONDS, "open-api").url()); + } catch (RuntimeException e) { + // 取不到链接时**仍然推送**,只是这一项没有 url —— + // 整条不推会让机构完全不知道这个人通过了,那更糟 + log.warn("推送报文取文件链接失败 fileId={}", f.getId(), e); + fo.set("url", null); + } + fo.set("expireSeconds", URL_EXPIRE_SECONDS); + files.add(fo); + } + + JSONObject body = JSONUtil.createObj(); + body.set("eventType", CollectOpenPushLog.EVENT_SUBMISSION_APPROVED); + body.set("occurTime", OffsetDateTime.now(ZoneId.systemDefault()).toString()); + body.set("taskId", taskId); + body.set("taskCode", task == null ? null : task.getTaskCode()); + body.set("submissionId", submissionId); + body.set("data", data); + body.set("files", files); + return body.toString(); + } + + /** 尝试推一次,并按结果更新记录。 */ + private void attempt(CollectOpenApp app, CollectOpenPushLog row) { + String payload = row.getPayloadJson(); + if (payload == null || payload.isBlank()) { + payload = buildPayload(app, row.getTaskId(), row.getSubmissionId()); + row.setPayloadJson(payload); + } + int attempts = (row.getAttempts() == null ? 0 : row.getAttempts()) + 1; + row.setAttempts(attempts); + + String secret = app.getPushSecretCipher() == null ? null + : codec.decrypt(app.getPushSecretCipher()); + String timestamp = String.valueOf(System.currentTimeMillis() / 1000); + String nonce = java.util.UUID.randomUUID().toString().substring(0, 8); + String sign = secret == null ? null + : OpenSignService.hmacSha256(secret, app.getAppKey() + timestamp + nonce + payload); + + try { + HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(app.getCallbackUrl())) + .timeout(Duration.ofSeconds(TIMEOUT_SECONDS)) + .header("Content-Type", "application/json") + .header("X-App-Key", app.getAppKey()) + .header("X-Timestamp", timestamp) + .header("X-Nonce", nonce); + if (sign != null) { + b.header("X-Sign", sign); + } + HttpResponse resp = http.send( + b.POST(HttpRequest.BodyPublishers.ofString(payload, + java.nio.charset.StandardCharsets.UTF_8)).build(), + HttpResponse.BodyHandlers.ofString()); + row.setLastHttpCode(resp.statusCode()); + if (resp.statusCode() >= 200 && resp.statusCode() < 300) { + row.setStatus(CollectOpenPushLog.STATUS_OK); + row.setPushedTime(LocalDateTime.now()); + row.setLastError(null); + row.setNextRetryAt(null); + log.info("开放接口推送成功 appId={} submissionId={} 第 {} 次", + app.getId(), row.getSubmissionId(), attempts); + } else { + fail(row, "HTTP " + resp.statusCode() + ":" + clip(resp.body(), 200)); + } + } catch (Exception e) { + row.setLastHttpCode(null); + fail(row, e.getClass().getSimpleName() + ":" + clip(e.getMessage(), 200)); + } + pushLogMapper.updateById(row); + } + + private void fail(CollectOpenPushLog row, String why) { + row.setLastError(clip(why, 500)); + int delay = row.nextDelaySeconds(); + if (delay < 0) { + row.setStatus(CollectOpenPushLog.STATUS_FAILED); + row.setNextRetryAt(null); + log.error("开放接口推送最终失败(已重试 {} 次,需人工重推)appId={} submissionId={} 原因={}", + row.getAttempts(), row.getAppId(), row.getSubmissionId(), why); + } else { + row.setStatus(CollectOpenPushLog.STATUS_RETRY); + row.setNextRetryAt(LocalDateTime.now().plusSeconds(delay)); + log.warn("开放接口推送失败(第 {} 次,{} 秒后重试):{}", + row.getAttempts(), delay, why); + } + } + + private static String clip(String s, int max) { + if (s == null) { + return null; + } + return s.length() <= max ? s : s.substring(0, max); + } + + private Map describe(CollectOpenPushLog r) { + Map m = new LinkedHashMap<>(); + m.put("id", r.getId()); + m.put("appId", r.getAppId()); + m.put("taskId", r.getTaskId()); + m.put("submissionId", r.getSubmissionId()); + m.put("eventType", r.getEventType()); + m.put("status", r.getStatus()); + m.put("statusText", switch (r.getStatus() == null ? 0 : r.getStatus()) { + case CollectOpenPushLog.STATUS_PENDING -> "待推送"; + case CollectOpenPushLog.STATUS_OK -> "已送达"; + case CollectOpenPushLog.STATUS_RETRY -> "失败待重试"; + case CollectOpenPushLog.STATUS_FAILED -> "最终失败(需人工重推)"; + default -> "未知"; + }); + m.put("attempts", r.getAttempts()); + m.put("lastHttpCode", r.getLastHttpCode()); + m.put("lastError", r.getLastError()); + m.put("nextRetryAt", r.getNextRetryAt()); + m.put("pushedTime", r.getPushedTime()); + return m; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/service/OpenSignService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/service/OpenSignService.java new file mode 100644 index 0000000..e9dc986 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/open/service/OpenSignService.java @@ -0,0 +1,249 @@ +package cn.tianbaotong.fill.module.open.service; + +import cn.hutool.json.JSONUtil; +import cn.tianbaotong.fill.common.api.ErrorCode; +import cn.tianbaotong.fill.common.exception.BizException; +import cn.tianbaotong.fill.module.open.entity.CollectOpenApp; +import cn.tianbaotong.fill.module.open.mapper.OpenAppMapper; +import cn.tianbaotong.fill.support.SensitiveCodec; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.HexFormat; + +/** + * 开放接口的签名校验与令牌(契约 §14)。 + * + *

签名规则(契约原文)

+ * + *
+ * X-App-Key:   jxjd_2026
+ * X-Timestamp: 1790000000
+ * X-Nonce:     8f3a9c2e
+ * X-Sign:      HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)
+ * 
+ * + *

body 是原始 JSON 字符串(未格式化、未排序)—— + * 所以签名校验必须在**读到请求体原文之后**做,不能拿反序列化再序列化的结果去算: + * 字段顺序与空白一变,签名就不一样了,而错误表现只是"签名不通过"。 + * + *

三重防护,各挡一类问题

+ * + *
    + *
  1. 时间戳偏差 > 5 分钟拒绝:挡住"把昨天抓到的包今天重放"。 + * 它挡不住 5 分钟内的重放 —— 那由下一条管;
  2. + *
  3. nonce 5 分钟内不可重复:挡住同一时间窗内的重放。 + * 用 Redis 的 {@code SET NX EX} —— **必须是原子的**, + * 否则并发重放会同时通过"查不到"这一步;
  4. + *
  5. HMAC 签名:挡住伪造与篡改(改了 body 签名就对不上)。
  6. + *
+ * + *

⚠️ 时间戳比的是**偏差绝对值**(前后 5 分钟都算),不是"不能太旧"—— + * 机构服务器时间比我们快 6 分钟是很常见的,只挡旧的会让他们全部请求失败, + * 而排查方向会变成"我们的服务出问题了"。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OpenSignService { + + /** 允许的时间偏差(秒)。 */ + private static final long MAX_SKEW_SECONDS = 300; + + /** nonce 的记忆时长(秒)。 */ + private static final long NONCE_TTL_SECONDS = 300; + + private static final String NONCE_KEY = "xt:open:nonce:"; + + /** 令牌有效期(秒),契约 §14.1 写 7200。 */ + public static final long TOKEN_TTL_SECONDS = 7200L; + + private static final String TOKEN_KEY = "xt:open:token:"; + + private final OpenAppMapper appMapper; + private final SensitiveCodec codec; + private final StringRedisTemplate redis; + + /** 取一个启用中的接入方;不存在或停用都报同一个错(不透露 appKey 是否存在)。 */ + public CollectOpenApp requireEnabled(String appKey) { + if (appKey == null || appKey.isBlank()) { + throw new BizException(ErrorCode.UNAUTHORIZED, "缺少 X-App-Key"); + } + CollectOpenApp app = appMapper.findByAppKey(appKey); + if (app == null || !app.enabled()) { + // 刻意不区分"没有这个 appKey"与"已停用":前者能被用来枚举有效 appKey + throw new BizException(ErrorCode.UNAUTHORIZED, "接入方不存在或已停用"); + } + return app; + } + + /** 明文密钥(只在服务端内部用,绝不返回给调用方)。 */ + public String plainSecret(CollectOpenApp app) { + return codec.decrypt(app.getAppSecretCipher()); + } + + /** + * 校验一次请求的签名与重放。 + * + * @param body 请求体**原文**(GET 请求传空串) + */ + public CollectOpenApp verify(String appKey, String timestamp, String nonce, String sign, + String body) { + CollectOpenApp app = requireEnabled(appKey); + if (timestamp == null || timestamp.isBlank()) { + throw new BizException(ErrorCode.UNAUTHORIZED, "缺少 X-Timestamp"); + } + if (nonce == null || nonce.isBlank()) { + throw new BizException(ErrorCode.UNAUTHORIZED, "缺少 X-Nonce"); + } + if (sign == null || sign.isBlank()) { + throw new BizException(ErrorCode.UNAUTHORIZED, "缺少 X-Sign"); + } + long ts; + try { + ts = Long.parseLong(timestamp.trim()); + } catch (NumberFormatException e) { + throw new BizException(ErrorCode.UNAUTHORIZED, "X-Timestamp 必须是秒级时间戳"); + } + long now = System.currentTimeMillis() / 1000; + if (Math.abs(now - ts) > MAX_SKEW_SECONDS) { + throw new BizException(ErrorCode.UNAUTHORIZED, + "请求时间与服务器相差超过 " + (MAX_SKEW_SECONDS / 60) + " 分钟,请校准服务器时间"); + } + + // ★ 顺序很重要:**先验签,再占 nonce**。 + // + // 反过来的话,任何人发一个签名错误的请求就能把那个 nonce 消耗掉 —— + // 这是**拒绝服务**(而错误签名的请求本来该被立刻拒掉,不该产生任何副作用)。 + // 先验签的话:签名错 → 直接拒,nonce 没被碰;签名对但 nonce 重复 → 才拒。 + String expect = hmacSha256(plainSecret(app), + app.getAppKey() + timestamp.trim() + nonce.trim() + (body == null ? "" : body)); + if (!constantTimeEquals(expect, sign.trim())) { + throw new BizException(ErrorCode.UNAUTHORIZED, "签名不通过"); + } + + // nonce 的原子占位:SET NX EX(必须是原子的,否则并发重放会同时通过"查不到"这一步) + boolean first = Boolean.TRUE.equals(redis.opsForValue().setIfAbsent( + NONCE_KEY + app.getAppKey() + ":" + nonce, "1", + Duration.ofSeconds(NONCE_TTL_SECONDS))); + if (!first) { + throw new BizException(ErrorCode.UNAUTHORIZED, + "这个 nonce 已经用过(5 分钟内不可重复),请生成新的随机串"); + } + return app; + } + + // ── 令牌(契约 §14.1)────────────────────────── + + /** + * 用 appKey + appSecret 换一个 2 小时的访问令牌。 + * + *

契约给了两种凭据(HMAC 头 与 accessToken),所以两者都支持: + * 数据接口既接受 HMAC 头,也接受 {@code Authorization: Bearer }。 + * 为什么不砍掉一种:HMAC 每次请求都算签名(更安全但不方便), + * 令牌更适合"先换一次、后面连调几十次"的批量对接场景 —— 而机构那边两种写法都常见。 + */ + public TokenVO issueToken(String appKey, String appSecret) { + CollectOpenApp app = requireEnabled(appKey); + if (appSecret == null || appSecret.isBlank()) { + throw new BizException(ErrorCode.UNAUTHORIZED, "请提供 appSecret"); + } + // 常量时间比较:普通 equals 会在第一个不同字符处返回,理论上可被逐字节探测 + if (!constantTimeEquals(plainSecret(app), appSecret.trim())) { + throw new BizException(ErrorCode.UNAUTHORIZED, "appKey 与 appSecret 不匹配"); + } + String token = java.util.UUID.randomUUID().toString().replace("-", "") + + Long.toString(System.nanoTime(), 36); + // 令牌存 Redis:它是一次性的、短命的凭据,不值得为它建表 + redis.opsForValue().set(TOKEN_KEY + token, + app.getId() + "|" + app.getTenantId(), Duration.ofSeconds(TOKEN_TTL_SECONDS)); + TokenVO vo = new TokenVO(); + vo.setAccessToken(token); + vo.setExpireSeconds((int) TOKEN_TTL_SECONDS); + vo.setScope(java.util.Arrays.stream(app.getScopes().split(",")) + .map(String::trim).filter(s -> !s.isEmpty()).toList()); + log.info("开放接口发放令牌:appKey={} appId={} tenantId={}", + appKey, app.getId(), app.getTenantId()); + return vo; + } + + /** + * 用令牌换回接入方。 + * + *

令牌里只存 id 与 tenantId,**每次都回查接入方** —— + * 这样"停用一个接入方"能立刻生效,而不是等 2 小时令牌自然过期。 + */ + public CollectOpenApp appOfToken(String token) { + String v = redis.opsForValue().get(TOKEN_KEY + token); + if (v == null) { + throw new BizException(ErrorCode.UNAUTHORIZED, "令牌无效或已过期,请重新获取"); + } + Long appId; + try { + appId = Long.valueOf(v.split("\\|")[0]); + } catch (RuntimeException e) { + throw new BizException(ErrorCode.UNAUTHORIZED, "令牌内容损坏,请重新获取"); + } + // 匿名入口:这一步没有租户上下文,必须用忽略租户的查询 + CollectOpenApp app = appMapper.findByIdIgnoreTenant(appId); + if (app == null || !app.enabled()) { + throw new BizException(ErrorCode.UNAUTHORIZED, "接入方不存在或已停用"); + } + return app; + } + + /** 权限校验:令牌/接入方必须带这个 scope。 */ + public void requireScope(CollectOpenApp app, String scope) { + if (!app.hasScope(scope)) { + throw new BizException(ErrorCode.FORBIDDEN, + "这个接入方没有 " + scope + " 权限,请联系平台开通"); + } + } + + // ── 工具 ────────────────────────────────────── + + /** HMAC-SHA256,小写十六进制(契约给的示例是小写)。 */ + public static String hmacSha256(String secret, String data) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return HexFormat.of().formatHex(mac.doFinal(data.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new IllegalStateException("计算 HMAC 失败", e); + } + } + + /** 常量时间比较(避免逐字节探测签名)。 */ + private static boolean constantTimeEquals(String a, String b) { + if (a == null || b == null) { + return false; + } + byte[] x = a.getBytes(StandardCharsets.UTF_8); + byte[] y = b.getBytes(StandardCharsets.UTF_8); + return java.security.MessageDigest.isEqual(x, y); + } + + /** 供测试与排查:把接入方与它的明文密钥一起看(只在日志里用)。 */ + public String describe(CollectOpenApp app) { + return JSONUtil.createObj() + .set("appKey", app.getAppKey()) + .set("appName", app.getAppName()) + .set("scopes", app.getScopes()) + .set("callbackUrl", app.getCallbackUrl()) + .toString(); + } + + @lombok.Getter + @lombok.Setter + public static class TokenVO { + private String accessToken; + private Integer expireSeconds; + private java.util.List scope; + } +} diff --git a/backend/xt-server/src/main/resources/db/migration/V16__open_api.sql b/backend/xt-server/src/main/resources/db/migration/V16__open_api.sql new file mode 100644 index 0000000..bd9ad9c --- /dev/null +++ b/backend/xt-server/src/main/resources/db/migration/V16__open_api.sql @@ -0,0 +1,82 @@ +-- ============================================================================ +-- V16:开放接口(供认定机构对接,契约 §14) +-- +-- 一、`collect_open_app`:接入方(认定机构) +-- --------------------------------------------------------------------------- +-- 鉴权是 `appKey + timestamp + nonce + HMAC-SHA256(appSecret, appKey+ts+nonce+body)`。 +-- ⚠️ 所以 `appSecret` **必须能被服务端还原**(要拿它算 HMAC),不能只存哈希。 +-- 做法:用现成的 `SensitiveCodec`(AES)加密后存 `app_secret_cipher` —— +-- 与身份证号同一套处理:库里不是明文,但服务端能解出来用。 +-- 接口**永不返回** appSecret(只在创建时给一次)。 +-- +-- `scopes` 用逗号串(`submission:read,file:read`)而不是 JSON: +-- 它是个短小的固定枚举集合,逗号串能直接被 SQL 的 `LIKE` 与代码的 `split` 用, +-- 而 JSON 每次都要序列化/反序列化(这个字段在**每次请求**的鉴权路径上)。 +-- +-- 二、`collect_open_push_log`:推送记录与重试 +-- --------------------------------------------------------------------------- +-- 契约 §14.4 写明重试策略:**失败重试 5 次,间隔 1/5/30/120/600 秒**; +-- 全部失败后标记并告警,**支持人工重推**。 +-- 要能做到"间隔 1 秒后重试"而又不阻塞请求线程,就必须把"下次什么时候重试" +-- 落到库里(`next_retry_at`),由定时任务扫 —— 内存里的定时器在重启后就没了, +-- 而"审核通过后机构没收到"这种事不能因为一次重启就永远丢掉。 +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS collect_open_app ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + app_key VARCHAR(64) NOT NULL, + app_secret_cipher VARCHAR(256) NOT NULL, + app_name VARCHAR(128) NOT NULL, + scopes VARCHAR(256) NOT NULL DEFAULT 'submission:read,file:read', + -- 我方主动推送时的目标地址与它自己的验签密钥(契约 §14.4 是我方 → 机构) + callback_url VARCHAR(512), + push_secret_cipher VARCHAR(256), + status SMALLINT NOT NULL DEFAULT 1, + remark VARCHAR(256), + create_by BIGINT, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by BIGINT, + update_time TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); + +COMMENT ON TABLE collect_open_app IS '开放接口接入方(契约 §14):鉴权用 appKey + HMAC-SHA256(appSecret, appKey+ts+nonce+body)'; +COMMENT ON COLUMN collect_open_app.app_secret_cipher IS 'AES 密文:HMAC 要用明文密钥,所以**不能只存哈希**;接口永不返回它'; +COMMENT ON COLUMN collect_open_app.scopes IS '逗号分隔的权限集合(submission:read / file:read)。用逗号串而不是 JSON:它在每次请求的鉴权路径上'; +COMMENT ON COLUMN collect_open_app.push_secret_cipher IS '推送给我方时的签名密钥(机构侧校验用),同样是密文'; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_open_app_key ON collect_open_app (app_key) WHERE deleted = 0; + +CREATE TABLE IF NOT EXISTS collect_open_push_log ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + app_id BIGINT NOT NULL, + task_id BIGINT, + submission_id BIGINT NOT NULL, + event_type VARCHAR(32) NOT NULL DEFAULT 'SUBMISSION_APPROVED', + status SMALLINT NOT NULL DEFAULT 0, + attempts SMALLINT NOT NULL DEFAULT 0, + last_http_code INTEGER, + last_error VARCHAR(512), + payload_json TEXT, + next_retry_at TIMESTAMP, + pushed_time TIMESTAMP, + create_by BIGINT, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by BIGINT, + update_time TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); + +COMMENT ON TABLE collect_open_push_log IS '推送记录(契约 §14.4):失败重试 5 次,间隔 1/5/30/120/600 秒;全失败后标记并支持人工重推'; +COMMENT ON COLUMN collect_open_push_log.status IS '0 待推 / 1 成功 / 2 失败待重试 / 3 最终失败(需人工重推)'; +COMMENT ON COLUMN collect_open_push_log.next_retry_at IS '下次重试时间。**落库而不是放内存**:内存定时器在重启后就没了,而"审核通过后机构没收到"不能因为一次重启就永远丢掉'; + +CREATE INDEX IF NOT EXISTS idx_push_retry ON collect_open_push_log (status, next_retry_at) + WHERE deleted = 0; +-- 同一个(接入方, 填报单)只留一条主记录:人工重推与自动重试都更新它, +-- 否则"这家机构到底收到没有"会有多个互相矛盾的答案 +CREATE UNIQUE INDEX IF NOT EXISTS uk_push_once ON collect_open_push_log ( + app_id, submission_id, event_type +) WHERE deleted = 0;