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:
+84
@@ -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);
|
||||
Reference in New Issue
Block a user