-- ============================================================
-- 表单中心 v2 · 快照投递与可信提交上下文 DDL(MySQL 5.7 兼容复核版,TASK_2)
-- 范围:《answer-delivery-v2.md》§2/§3/§6 = answer.answer_revision 加列 + 建两张表
-- answer_delivery(outbox)/ submission_context(可信提交上下文)
-- 依据:任务书 TASK_2 B.1(单调修订号与不可变 outbox)、B.3(逐授权订阅一行)、
-- A.2/A.3(令牌摘要与意图↔答卷一对一原子关联)
-- 用途:生产 ddl-auto=none 时的升级脚本;开发期(本机 bsch_formcenter,ddl-auto=update)由
-- Hibernate 自动追加,本脚本同时是"实表应当长什么样"的评审权威(沿 v3/t16 先例)。
-- 5.7 复核要点:①无 CTE/WITH、无函数索引、无生成列、无 CHECK 约束;②utf8mb4 显式写
-- CHARSET/COLLATE;③不建物理外键——answer_uuid / form_uuid 都是逻辑引用
-- (答卷无物理删除但表单可软删,投递行与上下文必须能在其之后继续存在以支撑溯源);
-- ④唯一键里的 answer_revision 是 BIGINT,不占 utf8mb4 长度预算。
-- 身份口径:consumer_app_key / source_app_key 存平台 appKey(UserBase 注册分配、
-- 验签三件套里的 X-App-Key),不是 form.app_code 那种人读 slug——
-- 资源 appCode 当消费者会把"同应用多消费方"合并成一行(任务书 B.3 点名禁止)。
-- 双时刻纪律:broker_accepted_at 与 consumer_applied_at 是两列、两个写入方,
-- 前者只由 confirm 回调写、后者只由签名回执端点写;任何一侧都不许替另一侧作证(B.4/B.6)。
-- 存量:answer.answer_revision 默认 0,不回填历史版本、不建双轨(任务书 A.5 末句)——
-- 存量行此后第一次变更取号 1,此前的对外快照本来就没有 hash 事实,回填等于伪造历史。
-- 非幂等:MySQL 5.7 无评审友好的 ADD/CREATE IF NOT EXISTS 写法(写了反而掩盖"表已存在但列不对")。
-- 执行前先 `SHOW TABLES LIKE 'answer_delivery'` / `'submission_context'` 确认不存在,
-- 并 `SHOW COLUMNS FROM answer LIKE 'answer_revision'` 确认列不存在。
-- ============================================================
ALTER TABLE `answer`
ADD COLUMN `answer_revision` BIGINT NOT NULL DEFAULT 0
COMMENT '对外快照修订号:单调递增,凡改变对外快照的路径(创建/值编辑/改状态/软删,含批量)在同一事务内 +1 并写 outbox;与钉版 version 是两个维度';
CREATE TABLE `answer_delivery` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`event_id` VARCHAR(36) NOT NULL COMMENT '信封事件 id(uuid):broker 相关键与消费端幂等键同一件',
`protocol_version` VARCHAR(32) NOT NULL COMMENT '固定 fc.answer.snapshot.v2(锁死 fc.canon.v1 哈希口径)',
`event_type` VARCHAR(16) NOT NULL COMMENT 'created / updated / tombstoned(值改与状态改同归 updated)',
`source_app_key` VARCHAR(64) DEFAULT NULL COMMENT '发布方应用身份(服务端配置,不接受消费方申报)',
`consumer_app_key` VARCHAR(64) NOT NULL COMMENT '投递目标:显式消费应用平台 appKey',
`consumer_school_id` BIGINT NOT NULL COMMENT '投递目标租户域(与 appKey 一起构成授权坐标,不是资源归属校)',
`grant_version` INT NOT NULL COMMENT '入队时刻的授权纪元;发布前再核一次,进位或撤销即不重放',
`form_uuid` VARCHAR(32) NOT NULL COMMENT '资源坐标(逻辑引用,不建 FK)',
`form_version` INT NOT NULL COMMENT '答卷钉住的 schema 版本(与 answer.version 同值)',
`answer_uuid` VARCHAR(32) NOT NULL COMMENT '答卷 uuid(对外身份,库内 id 不外露)',
`answer_revision` BIGINT NOT NULL COMMENT '本行对应的快照修订号(与 answer.answer_revision 同值)',
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '墓碑标记;软删行的快照照常投递,靠它表达删除',
`content_hash` CHAR(64) NOT NULL COMMENT 'fc.canon.v1 下核心快照(剔除 intent)的 SHA-256 十六进制小写',
`occurred_at` DATETIME NOT NULL COMMENT '业务发生时刻(= 答卷 update_time;投递时间不在此列)',
`snapshot_json` LONGTEXT NOT NULL COMMENT '不可变载荷:该消费方看到的完整快照(含仅对其可见的 intent 关联块)',
`state` VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING / BROKER_ACCEPTED / FAILED / DEAD / GRANT_REVOKED',
`retry_count` INT NOT NULL DEFAULT 0 COMMENT '投递元数据:重试计数不进快照、不参与哈希',
`next_retry_at` DATETIME DEFAULT NULL COMMENT '退避到点才再扫(base×2^n 封顶 1h),DEAD 置 NULL 不再自动重投',
`broker_accepted_at` DATETIME DEFAULT NULL COMMENT '总线 confirm(ack=true) 时刻——只证明总线收下,不证明业务应用',
`consumer_applied_at` DATETIME DEFAULT NULL COMMENT '消费方签名回执落定时刻(只有回执端点写得了)',
`consumer_outcome` VARCHAR(16) DEFAULT NULL COMMENT 'APPLIED / IGNORED / CONFLICT;重复上报不同结论时保留首次',
`last_replay_operation_id` VARCHAR(64) DEFAULT NULL COMMENT '最近一次授权重投的操作幂等键(同键不重复刷新退避)',
`last_error` VARCHAR(500) DEFAULT NULL COMMENT '末次失败原因或回执 localRef(截断存)',
`create_by` VARCHAR(255) DEFAULT NULL,
`create_time` DATETIME DEFAULT NULL,
`update_by` VARCHAR(255) DEFAULT NULL,
`update_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_delivery_event` (`event_id`),
UNIQUE KEY `uk_delivery_revision` (`answer_uuid`, `answer_revision`, `consumer_app_key`, `consumer_school_id`),
KEY `idx_delivery_scan` (`state`, `next_retry_at`),
KEY `idx_delivery_consumer` (`consumer_app_key`, `consumer_school_id`, `id`),
KEY `idx_delivery_answer` (`answer_uuid`, `answer_revision`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='答卷快照 outbox(v2 §3:与答卷同事务落库、逐授权订阅一行、双时刻分列;不提供任何删除方法)';
CREATE TABLE `submission_context` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`consumer_app_key` VARCHAR(64) NOT NULL COMMENT '消费应用平台 appKey(取自已验签的 yh.app.auth,非请求体申报)',
`consumer_school_id` BIGINT NOT NULL COMMENT '消费租户域(≥1;签发时即过 read+subscribe 两把授权)',
`intent_id` VARCHAR(64) NOT NULL COMMENT '消费方业务意图键(服务端生成;浏览器不可注入)',
`form_uuid` VARCHAR(32) NOT NULL COMMENT '钉定的表单 uuid(成对校验过版本行才落)',
`form_version` INT NOT NULL COMMENT '钉定的确切版本号(hosted 永不回落到当前发布版)',
`app_code` VARCHAR(64) NOT NULL COMMENT '解析出来的资源 appCode(提交时按它寻址,不再走 code 遮蔽)',
`code` VARCHAR(64) NOT NULL COMMENT '解析出来的表单 code(与 app_code 一起构成提交体坐标核对)',
`submitter_id` INT NOT NULL COMMENT '消费方服务端断言的填报人;兑换时与 CAS 会话逐字复核',
`token_digest` CHAR(64) NOT NULL COMMENT 'contextToken 的 SHA-256(明文不入库,只在签发响应里出现一次)',
`embed_origin` VARCHAR(200) NOT NULL COMMENT '可嵌入父页面精确 origin(服务端配置锁定,被嵌页 postMessage 的唯一目标)',
`callback_url` VARCHAR(500) NOT NULL COMMENT '消费方确认入口绝对 URL(服务端配置;浏览器传入的一律不采信)',
`expires_at` DATETIME NOT NULL COMMENT '有效期(默认 30 分钟,form-center.hosted-fill.token-ttl-minutes)',
`answer_uuid` VARCHAR(32) DEFAULT NULL COMMENT '答卷原子关联(提交事务内写;NULL=尚未填报。一个意图只挂一份答卷)',
`linked_revision` BIGINT DEFAULT NULL COMMENT '关联时刻的快照修订号(收据与投影合并的判据之一)',
`linked_at` DATETIME DEFAULT NULL COMMENT '关联时刻',
`create_by` VARCHAR(255) DEFAULT NULL,
`create_time` DATETIME DEFAULT NULL,
`update_by` VARCHAR(255) DEFAULT NULL,
`update_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_ctx_token_digest` (`token_digest`),
UNIQUE KEY `uk_ctx_intent` (`consumer_app_key`, `intent_id`),
UNIQUE KEY `uk_ctx_answer` (`answer_uuid`),
KEY `idx_ctx_form` (`form_uuid`, `form_version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='可信提交上下文(v2 §6:意图↔答卷一对一、令牌只存摘要、坐标与 origin/回调都在签发时锁死)';
-- 自验(执行后逐条核对):
-- SHOW CREATE TABLE answer_delivery\G -- 应含 uk_delivery_event / uk_delivery_revision 两个唯一键
-- SHOW CREATE TABLE submission_context\G -- 应含 uk_ctx_intent(同意图不新建第二行)与 uk_ctx_answer
-- SHOW COLUMNS FROM answer LIKE 'answer_revision'; -- 必须存在且 NOT NULL DEFAULT 0
-- SELECT COUNT(*) FROM information_schema.columns
-- WHERE table_schema=DATABASE() AND table_name IN ('answer_delivery','submission_context')
-- AND column_name REGEXP 'secret|token$|retry_policy'; -- 必须 0 行(密钥与明文令牌都不入库)
-- SELECT COUNT(*) FROM information_schema.referential_constraints
-- WHERE table_name IN ('answer_delivery','submission_context'); -- 必须 0 行(无物理外键)
-- SELECT COUNT(*) FROM answer WHERE answer_revision <> 0; -- 刚执行完必须 0(不回填历史)