feat(notify): 消息中心、催报、通知模板与微信回调 + 校验问题清单(V8 / V14)

消息中心 + 订阅额度(原子扣减,先扣再发——订阅消息是一次性授权)+ 催报(定向/一键,
按 bizType 去重 24 小时);通知降级链只做能真正走通的两级(订阅消息 → 站内信),
公众号模板与短信如实少做并写明理由:永远失败的通道会把真正的原因淹掉。

校验问题清单对齐语义:已有→只更新内容不碰 resolved / 新增→插入 / 消失→标已解决;
上传时把「系统改过文件名」记成 VAL-06 自动修复,让 byRule.autoFixed 有真实数据。

微信回调必须偏离两条全局约定(匿名入口、响应体是 {"errcode":0}):只写推送状态不读
业务数据 + 必须验签 + 凭发出时记的 id 反查租户(查不到就报错,绝不猜一个租户去写)。

验证:e2e-m5-notify 48/48、e2e-m5-urge 40/40、e2e-m7-validation 70/70。
This commit is contained in:
2026-09-30 09:02:53 +08:00
parent bb91515590
commit 29c8e932ae
34 changed files with 5027 additions and 1 deletions
@@ -0,0 +1,84 @@
-- ============================================================================
-- V14:校验问题清单的审计列 + 通知模板表
--
-- 一、`collect_validation_issue` 早就建好了,但**一直没有代码用它**
-- ---------------------------------------------------------------------------
-- V1 建表时列的列与契约 §3.9 的 `ValidationIssueVO` 完全一致(连
-- `expected_val` / `actual_val` / `auto_fixed` / `resolved_by` 都有),
-- 也就是说"当初就设计好了",只是 §12.1~12.3 三个接口没做(见盘点文档 F-50)。
--
-- 这里补的是**实体基类要求的四列**:实体继承 `BaseEntity` 就必须有
-- `create_by` / `update_by` / `update_time` / `deleted`,否则运行时才炸
-- (F-24/F-35 那类"编译期不报、运行期 column does not exist")。
-- `scripts/check-entity-columns.py` 会机械校验这一点。
--
-- ⚠️ 业务上**从不删**问题行:`deleted` 这一列会一直是 0。
-- 问题不是"删掉"而是"解决"(`resolved`)—— 保留历史才能回答
-- "上轮 47 个问题,这轮还剩 5 个"这种问题,而那正是这张表的价值。
--
-- 二、`collect_notify_template`(契约 §12.7)
-- ---------------------------------------------------------------------------
-- 契约 §12.7 有 `GET/PUT /notify/templates`,但**没有这张表**。
-- 内置模板写在代码里(与字典、内置角色同一套做法),库里只存"被改过的那些" ——
-- 这样升级版本时新增/修正的内置文案能自动生效,
-- 而用户改过的那几条保持不变(`sys_dict_disabled` 用的是同一个思路,见 V10)。
--
-- `task_id` 可空:为空是**租户级**模板,有值是该任务专属(契约里
-- `GET /notify/templates?taskId=` 的语义)。
-- ============================================================================
-- ── 一、校验问题清单 ──────────────────────────
ALTER TABLE collect_validation_issue ADD COLUMN IF NOT EXISTS create_by BIGINT;
ALTER TABLE collect_validation_issue ADD COLUMN IF NOT EXISTS update_by BIGINT;
ALTER TABLE collect_validation_issue ADD COLUMN IF NOT EXISTS update_time TIMESTAMP;
ALTER TABLE collect_validation_issue ADD COLUMN IF NOT EXISTS deleted SMALLINT NOT NULL DEFAULT 0;
COMMENT ON COLUMN collect_validation_issue.deleted IS '业务上恒为 0:问题不删只"解决"(resolved),保留历史才能对比两轮校验';
-- 同一个"人 + 规则 + 材料/字段"只该有一行未解决的问题 —— 重复跑校验靠它做 upsert。
-- ⚠️ 用 COALESCE 把 NULL 归一,因为 PostgreSQL 的唯一索引里 NULL 互不相等,
-- 而 file_id / field_key 大量为空(表单类问题没有文件)。不归一的话
-- "同一个表单问题"会被反复插入,清单越跑越长。
CREATE UNIQUE INDEX IF NOT EXISTS uk_issue_key ON collect_validation_issue (
task_id, submission_id, rule_code,
COALESCE(file_id, 0), COALESCE(field_key, '')
);
-- ── 二、通知模板 ──────────────────────────────
CREATE TABLE IF NOT EXISTS collect_notify_template (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
task_id BIGINT,
template_code VARCHAR(64) NOT NULL,
biz_type SMALLINT NOT NULL,
channel VARCHAR(16) NOT NULL DEFAULT 'IN_APP',
title VARCHAR(128) NOT NULL,
content VARCHAR(1024) NOT NULL,
enabled SMALLINT NOT NULL DEFAULT 1,
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_notify_template IS '通知模板(契约 §12.7):库里只存**被改过**的;没改过的走代码里的内置模板,这样升级时新文案能自动生效';
COMMENT ON COLUMN collect_notify_template.task_id IS 'NULL = 租户级模板;有值 = 该任务专属(对应 GET /notify/templates?taskId=)';
COMMENT ON COLUMN collect_notify_template.channel IS 'IN_APP 站内信 / WX_SUBSCRIBE 订阅消息 / WX_OFFICIAL 公众号 / SMS 短信';
COMMENT ON COLUMN collect_notify_template.content IS '支持契约 §12.7 的变量白名单({姓名}{班级}{任务名}{截止日}{剩余天数}{驳回原因}{材料名}{提交进度}{导出包名}{下载链接});越界变量渲染为空并告警';
CREATE UNIQUE INDEX IF NOT EXISTS uk_notify_tpl ON collect_notify_template (
tenant_id, COALESCE(task_id, 0), template_code, channel
);
-- ── 三、通知记录补 `msg_id` ──────────────────────
-- 微信推送出去之后,回调里带回来的是它自己的 MsgID(不一定回我们透传的 recordId)。
-- 没有这一列的话,"这条推送最后到没到"就永远查不到 —— 而 `collect_notify_record`
-- 的整个存在意义就是回答"发出去没有、到没到"(见契约 §13.5 的说明)。
--
-- 可空:非微信通道(站内信/短信)没有 MsgID。
ALTER TABLE collect_notify_record ADD COLUMN IF NOT EXISTS msg_id VARCHAR(64);
COMMENT ON COLUMN collect_notify_record.msg_id IS '微信推送的 MsgID:回调凭它反查是哪一条(微信不一定回我们透传的 recordId,所以两条路都要有)';
CREATE INDEX IF NOT EXISTS idx_notify_msgid ON collect_notify_record (msg_id);
@@ -0,0 +1,55 @@
-- ============================================================================
-- V8:站内信(消息中心)
--
-- 为什么需要一张**新表**,而不是复用 collect_notify_record
-- ---------------------------------------------------------------------------
-- 契约 §13.5 已经把两者的分工写清楚了:
-- · `collect_notify_record` 记的是**发送通道结果**(发出去没有、到没到),
-- 字段是 channel / status / fail_reason;
-- · 消息中心记的是**站内信内容**(用户看到什么),字段是 title / content / read。
-- 二者通过 biz_type 关联。
--
-- 用一张表混装两个用途的后果很具体:
-- · "已读"是用户维度、可反复改的状态;而发送记录是**只增不改**的凭据。
-- 混在一起之后,"标记已读"就变成了修改发送记录 —— 那批数据再也不能当证据用。
-- · 推送降级链会为同一条消息写多条发送记录(订阅消息失败 → 公众号 → 短信),
-- 而消息中心里用户只该看到**一条**。
--
-- 另一个用途:**降级链的终点**。通知发不出去(额度耗尽/未关注/短信超套餐)时,
-- 系统仍然写一条站内信 —— 学生进小程序就能看到,而不是"什么都不知道"。
-- 这也是为什么驳回/审批的 notifySent 现在能如实回 true/false:
-- 站内信一定写得进,所以"用户能不能看到"这件事有了确定的答案。
-- ============================================================================
CREATE TABLE IF NOT EXISTS collect_message (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
-- 与 collect_notify_record.biz_type 同一套码(1任务发布 2临期 3驳回 4通过 5催报 6导出 7加入审批)
biz_type SMALLINT NOT NULL DEFAULT 0,
title VARCHAR(128) NOT NULL,
content VARCHAR(1024),
-- 点开消息跳哪儿:SUBMISSION / TASK / NONE
link_type VARCHAR(16),
link_id BIGINT,
is_read SMALLINT NOT NULL DEFAULT 0,
read_time TIMESTAMP,
-- 这条站内信是通过哪条链路最终落地的(WECHAT_SUB / MP_TEMPLATE / SMS / IN_APP)
channel VARCHAR(16) NOT NULL DEFAULT 'IN_APP',
-- 审计列:业务表都带(create_by/update_by 由 FillMetaObjectHandler 填)。
-- ⚠️ 少了这两列的表现是"插入直接报 column does not exist" —— 本轮踩过一次(F-24)
create_by BIGINT,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by BIGINT,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted SMALLINT NOT NULL DEFAULT 0
);
COMMENT ON TABLE collect_message IS '站内信(消息中心内容),与 collect_notify_record(发送通道结果)通过 biz_type 关联';
COMMENT ON COLUMN collect_message.biz_type IS '1任务发布 2临期提醒 3驳回 4通过 5催报 6导出完成 7加入审批';
COMMENT ON COLUMN collect_message.link_type IS 'SUBMISSION/TASK/NONE —— 点开消息跳哪儿';
COMMENT ON COLUMN collect_message.channel IS '最终落地的通道:WECHAT_SUB/MP_TEMPLATE/SMS/IN_APP';
COMMENT ON COLUMN collect_message.is_read IS '0未读 1已读(用户维度、可改;所以不能塞进只增不改的发送记录表)';
-- 消息中心的主查询:某人的消息按时间倒序 + 未读数
CREATE INDEX IF NOT EXISTS idx_message_user ON collect_message (tenant_id, user_id, is_read, create_time DESC);