feat(open): §14 开放接口(V16)—— HMAC 签名对接认定机构

密钥存 AES 密文(HMAC 要能还原,不能只存哈希);签名 = HMAC-SHA256 + 5 分钟时间窗
+ nonce 防重放,先验签再占 nonce;数据接口一律不返回明文证件号,文件走 30 分钟签名 URL;
推送重试的 next_retry_at 落库(内存定时器重启后就没了),推送挂在 afterCommit 上。

契约漏掉的接入方管理(F-67)一并补上;规则模板两个接口修回契约路径(F-68)。

验证:e2e-m8-open 60/60。
This commit is contained in:
2026-09-30 09:02:54 +08:00
parent 0d3aae75fa
commit aa30fd2383
10 changed files with 1638 additions and 0 deletions
@@ -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)—— 供认定机构对接。
*
* <h3>鉴权:两条路都支持</h3>
*
* <ol>
* <li><b>HMAC 头</b>(契约的鉴权方式):{@code X-App-Key} + {@code X-Timestamp} +
* {@code X-Nonce} + {@code X-Sign = HMAC-SHA256(appSecret, appKey+timestamp+nonce+body)};</li>
* <li><b>访问令牌</b>:先调 {@code /auth/token} 换一个 2 小时的 token,
* 之后 {@code Authorization: Bearer <token>}。</li>
* </ol>
*
* <p>契约同时定义了这两种凭据,所以两种都实现。为什么要两种:
* HMAC 每次请求都要算签名(更安全,但批量对接时对方要写更多代码),
* 令牌适合"先换一次、后面连调几十次"。机构那边的实现习惯两种都有。
*
* <h3>⚠️ 它是 {@code @PublicApi}:匿名、靠 HMAC 自证</h3>
*
* <p>这个标注意味着**不检查登录态**。放行的理由不是"外部系统没有账号"这种方便说法,
* 而是:它的鉴权强度**不低于**登录态(HMAC 覆盖 appKey+时间戳+nonce+报文体,
* 且带 5 分钟时间窗与 nonce 防重放),并且每一步都要显式校验 scope。
* 换句话说,这里的"公开"是**换了一套凭据**,而不是"不要凭据"。
*
* <h3>⚠️ 这里的 Body 必须读"原文"</h3>
*
* <p>签名覆盖的是**原始 JSON 字符串**(未格式化、未排序)。所以校验必须在
* Controller 里拿 {@code @RequestBody String}(原文)来做,**不能**先反序列化成对象
* 再序列化回去算签名 —— 字段顺序与空白一变签名就不一样,而错误表现只是"签名不通过",
* 排查方向会完全跑偏。
*
* <h3>⚠️ 路径前缀为什么是 {@code /api/v1/open/v1/...}</h3>
*
* <p>契约 §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<OpenSignService.TokenVO> token(@RequestBody(required = false) String rawBody) {
Map<String, Object> 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<OpenDataService.SubmissionPage> 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<Map<String, Object>> files(
@PathVariable Long id,
@RequestParam(required = false) String submissionIds,
HttpServletRequest req) {
CollectOpenApp app = auth(req, "");
signService.requireScope(app, CollectOpenApp.SCOPE_FILE_READ);
List<Map<String, Object>> files = dataService.files(app, id, submissionIds);
Map<String, Object> 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<Map<String, Object>> repush(@RequestBody(required = false) String rawBody,
HttpServletRequest req) {
CollectOpenApp app = auth(req, rawBody);
signService.requireScope(app, CollectOpenApp.SCOPE_SUBMISSION_READ);
Map<String, Object> 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 头。
*
* <p>⚠️ 顺序有意义:令牌是"已经用密钥换过一次"的凭据,优先用它可以让
* 批量对接的请求少算 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 <accessToken>,"
+ "或带 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<String, Object> 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);
}
}
@@ -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)。
*
* <h3>为什么必须补</h3>
*
* <p>契约 §14 定义了"机构怎么调我们",却没定义"appKey 从哪来" ——
* 没有这一层,接入方只能靠改库产生,而 `appSecret` 是密文列,
* 改库意味着有人要手工加密一个字符串。也就是说 §14 的四个接口**在真实流程里跑不起来**。
*
* <h3>两条安全要求</h3>
*
* <ol>
* <li><b>appSecret 只在创建/重置时返回一次</b>,之后任何接口都不再给
* (库里存的是 AES 密文,服务端能解出来算 HMAC,但不该把它发出去);</li>
* <li><b>密钥重置要显式动作</b>(`POST /{id}/reset-secret`)而不是「编辑时顺手改」 ——
* 重置会让对方现有的签名**立刻失效**,这是个要交代清楚的运维动作。</li>
* </ol>
*/
@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<Map<String, Object>>> list() {
List<Map<String, Object>> out = new ArrayList<>();
for (CollectOpenApp a : appMapper.selectList(Wrappers.<CollectOpenApp>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<Map<String, Object>> create(@RequestBody Map<String, Object> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<List<Map<String, Object>>> 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<String, Object> view(CollectOpenApp a, boolean withSecret) {
Map<String, Object> 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);
}
}
@@ -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)。
*
* <h3>为什么密钥存密文而不是哈希</h3>
*
* <p>用户的密码存哈希(服务端永远不需要知道原文),而**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);
}
}
@@ -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)。
*
* <h3>为什么"下次重试时间"要落库</h3>
*
* <p>契约写明失败重试 5 次、间隔 **1/5/30/120/600 秒**。
* 要做到"1 秒后重试"又不阻塞请求线程,就得有个地方记住"什么时候再试":
* <ul>
* <li>放内存里 —— 服务重启就全没了。而"审核通过后机构没收到"
* 是**交付链路断了一环**,不能因为一次发版就永远丢掉;</li>
* <li>落库 + 定时任务扫 —— 重启后自动接着重试,且人工重推有据可查。</li>
* </ul>
*
* <h3>为什么一个(接入方, 填报单)只留一条</h3>
*
* <p>唯一索引 {@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];
}
}
@@ -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;
/**
* 开放接口接入方。
*
* <p>⚠️ 鉴权发生在**进入租户上下文之前** —— 那时还不知道请求属于哪个租户,
* 而"appKey 属于哪个租户"正是要在这一步查出来的。所以这里的查询必须
* {@code @InterceptorIgnore(tenantLine="true")},隔离由 {@code app_key} 的唯一约束保证
* (一个 appKey 只属于一个租户)。这是"默认拒绝"体系里必须主动开的口子,
* 范围就是这一个查询(与 `MpSceneMapper`、微信回调同一做法)。
*/
public interface OpenAppMapper extends BaseMapper<CollectOpenApp> {
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT * FROM collect_open_app
WHERE app_key = #{appKey} AND deleted = 0
""")
CollectOpenApp findByAppKey(@Param("appKey") String appKey);
/**
* 按 id 取接入方(**无租户上下文**时用)。
*
* <p>⚠️ 必须用它而不是 `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);
}
@@ -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<CollectOpenPushLog> {
/** 到点该重试的(定时任务用)。只取"失败待重试"的,不碰成功与最终失败的。 */
@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<CollectOpenPushLog> dueRetries();
/**
* 按填报单找最近的推送记录(**无租户上下文**时用)。
*
* <p>⚠️ 人工重推可能从开放接口进来(匿名),而第一步要"先找到这条记录属于哪个租户"
* 才能摆上下文 —— 用 `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);
}
@@ -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)。
*
* <h3>三条不能含糊的边界</h3>
*
* <ol>
* <li><b>永远不返回明文证件号</b>(契约 §14.2 明确写了)。
* 库里 `id_card_no` 是 AES 密文、另有 `id_card_mask`,所以这里**只用脱敏值** ——
* 连解密都不做,从代码上就不可能泄露;</li>
* <li><b>只返回"已通过"的填报单</b>(默认 {@code status=3})。
* 开放接口是给认定机构"拿数据去做认定"的,把待审/驳回的单子给他们
* 会导致对方按未确认的数据办事;允许传 status 只是为了兼容客户要求的
* "拉过去自己再筛"的场景,而**默认值必须是 3**;</li>
* <li><b>文件给的是签名 URL(30 分钟)</b>。七牛空间是私有的,
* 链接不能入库、不能长期有效 —— 契约 §14.3 也写明"过期需重新获取"。</li>
* </ol>
*
* <h3>为什么每个方法都要先摆租户上下文</h3>
*
* <p>开放接口的调用方是外部系统,请求进来时**没有登录态**,
* 而租户隔离靠 {@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> T inTenant(CollectOpenApp app, java.util.function.Supplier<T> 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.<CollectSubmission>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<CollectSubmission> rows = page.getRecords();
Map<Long, CollectRoster> rosters = new HashMap<>();
List<Long> 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<Map<String, Object>> list = new ArrayList<>();
for (CollectSubmission s : rows) {
CollectRoster r = s.getRosterId() == null ? null : rosters.get(s.getRosterId());
Map<String, Object> 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<Map<String, Object>> 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<Long> 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<Long, CollectSubmission> 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<Long, CollectRoster> rosters = new HashMap<>();
List<Long> 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<CollectFile> files = fileMapper.selectList(Wrappers.<CollectFile>lambdaQuery()
.in(CollectFile::getSubmissionId, ids)
.eq(CollectFile::getIsCurrent, CollectFile.CURRENT)
.orderByAsc(CollectFile::getSubmissionId)
.orderByAsc(CollectFile::getMaterialCode));
List<Map<String, Object>> 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<String, Object> 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;
});
}
/**
* 增量拉取的时间参数。
*
* <p>接受两种写法: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<Map<String, Object>> list = new ArrayList<>();
private long total;
private String taskCode;
private String taskName;
}
}
@@ -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)。
*
* <h3>为什么重试要靠定时任务 + 库里的 {@code next_retry_at}</h3>
*
* <p>见 {@link CollectOpenPushLog} 的类注释:内存定时器在重启后就没了,
* 而"审核通过后机构没收到"是交付链路断了一环。所以:
* <ul>
* <li>推送失败 → 算出下次重试时间写库(间隔 1/5/30/120/600 秒);</li>
* <li>定时任务每 10 秒扫一次到点的记录({@code next_retry_at <= now});</li>
* <li>5 次都失败 → 状态置"最终失败",<b>不再自动重试</b>,等人工。</li>
* </ul>
*
* <h3>为什么"最终失败"要停而不是一直重试</h3>
*
* <p>一直重试看起来更"努力",但对方的回调地址如果是配错的(或者他们的服务已经下线),
* 无限重试只会把日志刷满、把我们自己的线程占住,而**问题不会自己好**。
* 停下来的代价是"需要有人看一眼",而这个代价是必要的:
* 交付链路断了,本来就该有人知道。
*
* <h3>HTTP 调用不放事务里</h3>
*
* <p>推送是外部调用,可能慢到几十秒。把它放进审核的事务里会让
* "终审通过"这个动作被外部系统的响应时间绑架 —— 对方挂了,我们的审核就卡住。
* 所以推送**在事务提交之后**单独做(由调用方在事务外触发),失败也只影响推送记录。
*/
@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();
// ── 对外的三个入口 ────────────────────────────
/**
* 终审通过后触发推送(**在事务外调用**)。
*
* <p>对每一家"配了回调地址且启用"的接入方建一条推送记录并立刻尝试一次。
* 没有接入方配回调时**什么都不做**(不建空记录)—— 否则表里会堆满
* "没有目标"的记录,而运维会以为有推送在失败。
*/
public void onSubmissionApproved(Long tenantId, Long taskId, Long submissionId) {
// ⚠️ 要拿返回值就必须用 callAs(runAs 是 void 版本)—— 这一点编译器会拦,
// 但写的时候很容易顺手写成 runAs
List<CollectOpenApp> apps = TenantScope.callAs(tenantId, null, Set.of(),
() -> appMapper.selectList(Wrappers.<CollectOpenApp>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 的"支持人工重推")。
*
* <p>⚠️ 这个入口可能被**开放接口调用**(那时没有租户上下文),
* 所以它先用忽略租户的查询找到推送记录与接入方,再摆好租户上下文做后续查询。
*/
public Map<String, Object> 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<String, Object> doManualRepush(Long submissionId) {
CollectSubmission sub = submissionMapper.selectById(submissionId);
if (sub == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "填报单不存在或已删除");
}
CollectOpenPushLog row = pushLogMapper.selectOne(
Wrappers.<CollectOpenPushLog>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<Map<String, Object>> logs(Long taskId, Integer status, int limit) {
var q = Wrappers.<CollectOpenPushLog>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<Map<String, Object>> out = new ArrayList<>();
for (CollectOpenPushLog r : pushLogMapper.selectList(q)) {
out.add(describe(r));
}
return out;
}
// ── 定时重试 ──────────────────────────────────
/**
* 每 10 秒扫一次到点的重试。
*
* <p>⚠️ 定时任务是"没有请求也会发生的事",所以它必须能回答"失败了谁会知道":
* 这里的答案是 —— 5 次之后状态变成 3(最终失败),
* 而那个数字会显示在任务的推送记录里(`GET /open/v1/callback/logs`),
* 运维据此打电话催。**不是"重试到天荒地老"。**
*/
@Scheduled(fixedDelay = 10_000)
public void retryDue() {
List<CollectOpenPushLog> 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.<CollectOpenPushLog>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.<CollectFile>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<String> 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<String, Object> describe(CollectOpenPushLog r) {
Map<String, Object> 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;
}
}
@@ -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)。
*
* <h3>签名规则(契约原文)</h3>
*
* <pre>
* X-App-Key: jxjd_2026
* X-Timestamp: 1790000000
* X-Nonce: 8f3a9c2e
* X-Sign: HMAC-SHA256(appSecret, appKey + timestamp + nonce + body)
* </pre>
*
* <p><b>body 是原始 JSON 字符串</b>(未格式化、未排序)——
* 所以签名校验必须在**读到请求体原文之后**做,不能拿反序列化再序列化的结果去算:
* 字段顺序与空白一变,签名就不一样了,而错误表现只是"签名不通过"。
*
* <h3>三重防护,各挡一类问题</h3>
*
* <ol>
* <li><b>时间戳偏差 &gt; 5 分钟拒绝</b>:挡住"把昨天抓到的包今天重放"。
* 它挡不住 5 分钟内的重放 —— 那由下一条管;</li>
* <li><b>nonce 5 分钟内不可重复</b>:挡住同一时间窗内的重放。
* 用 Redis 的 {@code SET NX EX} —— **必须是原子的**,
* 否则并发重放会同时通过"查不到"这一步;</li>
* <li><b>HMAC 签名</b>:挡住伪造与篡改(改了 body 签名就对不上)。</li>
* </ol>
*
* <p>⚠️ 时间戳比的是**偏差绝对值**(前后 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 小时的访问令牌。
*
* <p>契约给了两种凭据(HMAC 头 与 accessToken),所以两者都支持:
* 数据接口既接受 HMAC 头,也接受 {@code Authorization: Bearer <token>}。
* 为什么不砍掉一种: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;
}
/**
* 用令牌换回接入方。
*
* <p>令牌里只存 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<String> scope;
}
}
@@ -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;