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,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;