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
+138
View File
@@ -0,0 +1,138 @@
/**
* 催报与通知记录(《03-接口设计》§8.6、§10.7、§13.3)。
*
* ## 两个语义要先说清,不然界面文案一定写错
*
* 1. **只催未提交者**(未填 + 草稿)。已驳回的人**不催** —— 他要的是"改哪里",
* 催报文案("请尽快完成填报")对他没有意义。
* 所以 `sent` 小于"我班上的总人数"是**正常的**,不是漏了人。
* 2. **`sent` 只代表"站内信写进去了"**,不代表微信推送发出去了。
* 一期的推送靠学生的一次性订阅授权,拿不到授权就只落站内信
* (见后端 `NotificationService` 的降级链)。所以按钮文案不能写"已通知到人"。
*
* ## 状态 3 不是失败
*
* `status = 3`(额度不足跳过)意味着"内容已进站内信,但没有推送"。
* 把它显示成红色"失败",运营会去查通道故障;而实际该做的是**引导学生授权**。
* 这两种要做的动作完全不同,所以标签颜色与文案都分开。
*/
import http from '@/utils/http'
export interface UrgeDetail {
rosterId?: number
userId?: number
realName?: string
orgName?: string
skipped?: boolean
reason?: string
}
export interface UrgeResult {
sent: number
failed: number
skipped: number
details: UrgeDetail[]
}
export interface NotifyRecord {
id: number
taskId?: number
channel?: string
/** **字符串**形式的业务类型码(库里那列是 varchar,见 F-26) */
bizType?: string
receiverId?: number
receiverName?: string
receiverRole?: string
templateCode?: string
content?: string
/** 0 待发 / 1 成功 / 2 失败 / 3 额度不足跳过 */
status?: number
statusText?: string
failReason?: string
sendTime?: string
}
export interface NotifyRecordPage {
list: NotifyRecord[]
total: number
deliveryRate: string
}
/** 一键催报未提交者(§8.6)。`excludeRecentHours` 默认 24,由服务端去重。 */
export function urgeUnsubmitted(taskId: number, data: {
orgIds?: number[]
channel?: number
templateCode?: string
extraText?: string
excludeRecentHours?: number
} = {}) {
return http.post<UrgeResult>(`/tasks/${taskId}/roster/notify-unsubmitted`, {
...data,
taskId
})
}
/** 定向催报一个人(§8.6)。 */
export function urgeOne(rosterId: number, data: {
channel?: number
templateCode?: string
extraText?: string
} = {}) {
return http.post<UrgeResult>(`/roster/${rosterId}/notify`, data)
}
/** 通知记录(§13.3 管理端)。 */
export function getNotifyRecords(params: {
taskId?: number
bizType?: number
status?: number
pageNum?: number
pageSize?: number
} = {}) {
return http.get<NotifyRecordPage>('/notify/records', params)
}
/** 业务类型码 → 文案。**与后端 `CollectMessage` 的常量一致**。 */
export const BIZ_TYPES: Array<{ value: number, label: string }> = [
{ value: 1, label: '任务发布' },
{ value: 2, label: '临期提醒' },
{ value: 3, label: '驳回通知' },
{ value: 4, label: '审核通过' },
{ value: 5, label: '催报' },
{ value: 6, label: '导出完成' },
{ value: 7, label: '加入审批' }
]
export function bizTypeText(v?: string | number) {
const n = typeof v === 'string' ? Number(v) : v
return BIZ_TYPES.find((b) => b.value === n)?.label || `其它(${v})`
}
/**
* 发送状态 → 标签颜色 + 文案。
*
* ⚠️ `3`(额度不足跳过)**不是失败**:内容已经进了站内信,学生进小程序能看到。
* 显示成红色"失败"会让运营去查通道故障,而实际该做的是引导学生授权。
*/
export const NOTIFY_STATUS: Record<number, { text: string, color: string }> = {
0: { text: '待发送', color: 'gray' },
1: { text: '已送达', color: 'green' },
2: { text: '发送失败', color: 'red' },
3: { text: '额度不足,仅站内信', color: 'orange' }
}
export function notifyStatus(status?: number) {
return NOTIFY_STATUS[status ?? -1] || { text: '未知', color: 'gray' }
}
/** 渠道码 → 文案。 */
export const CHANNEL_TEXT: Record<string, string> = {
WECHAT_SUB: '微信订阅消息',
MP_TEMPLATE: '公众号模板',
SMS: '短信',
IN_APP: '仅站内信'
}
export function channelText(c?: string) {
return CHANNEL_TEXT[c || ''] || c || '—'
}