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
+6
View File
@@ -176,6 +176,7 @@ import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
import { compressForUpload, planFor, releaseCompressedPath, type CompressOutcome } from '@/utils/image-compress'
import { requestSubscribe, SUBSCRIBE_TPL } from '@/utils/subscribe'
import { API_BASE_URL as API_BASE } from '@/config'
/**
@@ -721,6 +722,11 @@ async function submitNow(confirmWarnings: boolean) {
content: r.statusText || '已提交,等待班主任初审',
showCancel: false
})
// 提交成功后索取订阅授权(契约 §13.6:SUBMIT_SUCCESS + AUDIT_RESULT 的索取时机)。
// **不 await**:用户可能要在弹窗上停几秒,等它会把"提交成功"的反馈拖住。
// 而且这件事失败也不影响任何东西(拿不到授权就只发站内信)。
requestSubscribe([SUBSCRIBE_TPL.SUBMIT_SUCCESS, SUBSCRIBE_TPL.AUDIT_RESULT])
.catch(() => undefined)
} else {
report.value = r
}
+335
View File
@@ -0,0 +1,335 @@
<template>
<view class="msg">
<view class="msg__wrap">
<view class="msg__bar">
<text class="msg__count">
{{ unread ? `${unread} 条未读` : '没有未读消息' }}
</text>
<text v-if="unread" class="msg__read-all" @click="readAll">全部标为已读</text>
</view>
<!-- 筛选:学生只关心"老师让我改的"和"我过了没有" -->
<view class="msg__tabs">
<text v-for="t in tabs" :key="String(t.value)"
class="msg__tab" :class="{ 'msg__tab--on': bizType === t.value }"
@click="pickBiz(t.value)">{{ t.label }}</text>
</view>
<view v-if="loading" class="msg__empty">正在读取…</view>
<view v-else-if="!list.length" class="msg__empty">
<text>还没有消息。</text>
<text class="msg__empty-hint">
材料被退回、审核通过、临近截止时,这里都会有记录。
</text>
</view>
<view v-for="m in list" :key="m.id" class="msg__card"
:class="{ 'msg__card--unread': !m.read }" @click="open(m)">
<view class="msg__head">
<text class="msg__dot" :class="dotClass(m.bizType)">{{ bizText(m.bizType) }}</text>
<text class="msg__time">{{ fmt(m.createTime) }}</text>
</view>
<text class="msg__title">{{ m.title }}</text>
<text v-if="m.content" class="msg__content">{{ m.content }}</text>
<view v-if="m.linkType && m.linkType !== 'NONE'" class="msg__link">
{{ m.linkType === 'SUBMISSION' ? '查看这份填报 ›' : '打开任务 ›' }}
</view>
</view>
<view class="msg__foot">
一期的推送渠道只有微信订阅消息(一次性授权),拿不到授权时消息**只在这里**,
请偶尔进来看看。
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
/**
* 消息中心(《03-接口设计》§13.5,学生端)。
*
* ## 为什么这一屏是"通知"的最后一环
*
* 一期的推送渠道只有微信订阅消息,而它是**一次性授权**(用户每点一次「允许」只能发一条)。
* 也就是说:只要学生没授权,或授权用完了,**推送一定发不出去**。
* 那时唯一能看到消息的地方就是这里 —— 所以服务端**每次都写一条站内信**,
* 不管推送成没成功。
*
* 页面底部那句话是刻意留的:让学生知道"别只等推送"。
* 不说的话,没收到推送的人会以为"什么事都没发生"。
*/
defineOptions({ name: 'MyMessages' })
interface Message {
id: number
bizType: number
title: string
content?: string
read: boolean
linkType?: string
linkId?: number
channel?: string
createTime?: string
}
const loading = ref(false)
const list = ref<Message[]>([])
const unread = ref(0)
const bizType = ref<number | undefined>(undefined)
const tabs: Array<{ label: string, value: number | undefined }> = [
{ label: '全部', value: undefined },
{ label: '退回', value: 3 },
{ label: '通过', value: 4 },
{ label: '提醒', value: 2 }
]
const unreadText = computed(() => (unread.value ? `${unread.value} 条未读` : '没有未读消息'))
function token(): string {
try {
return uni.getStorageSync('token') || ''
} catch {
return ''
}
}
function fmt(v?: string) {
return v ? v.replace('T', ' ').slice(0, 16) : ''
}
/** 业务类型 → 角标文字(与后端 CollectMessage 的码一致)。 */
function bizText(t: number) {
switch (t) {
case 1: return '任务'
case 2: return '提醒'
case 3: return '退回'
case 4: return '通过'
case 5: return '催报'
case 6: return '导出'
case 7: return '审批'
default: return '消息'
}
}
function dotClass(t: number) {
if (t === 3) return 'msg__dot--bad'
if (t === 4) return 'msg__dot--ok'
return 'msg__dot--info'
}
async function load() {
loading.value = true
try {
const q = bizType.value === undefined ? '' : `&bizType=${bizType.value}`
const d = await request<{ list: Message[], total: number, unreadCount: number }>({
url: `/api/v1/mp/messages?pageSize=50${q}`,
token: token()
})
list.value = d.list || []
unread.value = d.unreadCount || 0
} catch (e) {
uni.showToast({ title: e instanceof Error ? e.message : '读取失败', icon: 'none' })
} finally {
loading.value = false
}
}
function pickBiz(v: number | undefined) {
bizType.value = v
load()
}
async function readAll() {
try {
const d = await request<{ unreadCount: number }>({
url: '/api/v1/mp/messages/read',
method: 'POST',
token: token(),
data: { all: true }
})
unread.value = d?.unreadCount ?? 0
list.value = list.value.map((m) => ({ ...m, read: true }))
} catch (e) {
uni.showToast({ title: e instanceof Error ? e.message : '操作失败', icon: 'none' })
}
}
/** 点开一条:标已读 + 跳到对应页面。 */
async function open(m: Message) {
if (!m.read) {
try {
const d = await request<{ unreadCount: number }>({
url: '/api/v1/mp/messages/read',
method: 'POST',
token: token(),
data: { messageIds: [m.id] }
})
unread.value = d?.unreadCount ?? unread.value
m.read = true
} catch {
// 标已读失败不拦跳转:用户点这条消息的目的是去看那件事
}
}
if (m.linkType === 'SUBMISSION' && m.linkId) {
// 填报单详情 → 进度页(那里有材料清单、问题与撤回)
uni.navigateTo({ url: `/pages/progress/progress?submissionId=${m.linkId}` })
} else if (m.linkType === 'TASK' && m.linkId) {
uni.navigateTo({ url: `/pages/fill/fill?taskId=${m.linkId}` })
}
}
onShow(load)
</script>
<style lang="scss">
$primary: #0052d9;
$success: #00a870;
$danger: #e34d59;
$warn: #ed7b2f;
$page-bg: #f3f3f3;
page {
background-color: $page-bg;
}
.msg {
min-height: 100vh;
padding: 24rpx;
&__wrap {
max-width: 720rpx;
margin: 0 auto;
}
&__bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 4rpx 16rpx;
}
&__count {
font-size: 28rpx;
font-weight: 600;
color: #1b2430;
}
&__read-all {
font-size: 25rpx;
color: $primary;
}
&__tabs {
display: flex;
gap: 16rpx;
margin-bottom: 20rpx;
}
&__tab {
padding: 8rpx 24rpx;
font-size: 25rpx;
color: #4a5768;
background: #fff;
border-radius: 28rpx;
&--on {
color: #fff;
background: $primary;
}
}
&__empty {
display: flex;
flex-direction: column;
gap: 12rpx;
padding: 48rpx 28rpx;
font-size: 27rpx;
line-height: 1.7;
color: #4a5768;
background: #fff;
border-radius: 16rpx;
}
&__empty-hint {
font-size: 24rpx;
color: #8b95a3;
}
&__card {
padding: 24rpx 28rpx;
margin-bottom: 16rpx;
background: #fff;
border-radius: 16rpx;
&--unread {
border-left: 8rpx solid $primary;
}
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10rpx;
}
&__dot {
padding: 4rpx 14rpx;
font-size: 21rpx;
border-radius: 6rpx;
&--bad {
color: $danger;
background: #fdecee;
}
&--ok {
color: $success;
background: #e8f6f0;
}
&--info {
color: $primary;
background: #eaf1fd;
}
}
&__time {
font-size: 22rpx;
color: #b3b9c1;
}
&__title {
display: block;
font-size: 29rpx;
font-weight: 600;
color: #1b2430;
}
&__content {
display: block;
margin-top: 8rpx;
font-size: 25rpx;
line-height: 1.7;
color: #4a5768;
}
&__link {
margin-top: 12rpx;
font-size: 25rpx;
color: $primary;
}
&__foot {
margin-top: 32rpx;
font-size: 23rpx;
line-height: 1.8;
color: #b3b9c1;
}
}
</style>
+105
View File
@@ -0,0 +1,105 @@
/**
* 订阅消息授权(《03-接口设计》§13.6)。
*
* ## 为什么要"索取授权"
*
* 微信订阅消息是**一次性授权**:用户每点一次「允许」只能发一条。
* 所以额度必须在关键节点**主动攒** —— 事后想发的时候已经来不及了
* (微信不允许"没授权就发",接口会直接返回 43101)。
*
* ## 索取时机(契约 §13.6 的表)
*
* | 模板码 | 何时要 |
* |---|---|
* | `SUBMIT_SUCCESS` | 提交成功后 |
* | `AUDIT_RESULT` | 提交成功后 |
* | `DEADLINE_REMIND` | 进入填报页时 |
* | `JOIN_APPROVE` | 申请加入名单时 |
*
* ## 三件必须守住的事
*
* 1. **H5 没有订阅消息**。H5 走公众号网页授权,不是小程序,
* 所以这里只在**小程序端**真正调用 `requestSubscribeMessage`;
* H5 上直接返回,不做无意义的调用(那会报 `requestSubscribeMessage:fail`)。
* 2. **用户拒绝不算错误**。他点「取消」是正常选择,不能弹错误提示、不能拦他的下一步
* (提交还是成功的)。所以整个函数**不抛异常**。
* 3. **登记失败不影响业务**。授权拿到了但登记接口挂了,顶多是"这次推送发不出去" ——
* 站内信仍然会写(见后端 `NotificationService`),所以他进小程序还是能看到。
* 绝不能因为登记失败让学生以为"提交没成功"。
*/
import { request } from '@/utils/request'
/** 模板码(与后端 NotificationService 的常量一致,改一边要改两边)。 */
export const SUBSCRIBE_TPL = {
SUBMIT_SUCCESS: 'SUBMIT_SUCCESS',
AUDIT_RESULT: 'AUDIT_RESULT',
DEADLINE_REMIND: 'DEADLINE_REMIND',
JOIN_APPROVE: 'JOIN_APPROVE'
} as const
export type SubscribeTemplateCode = typeof SUBSCRIBE_TPL[keyof typeof SUBSCRIBE_TPL]
function token(): string {
try {
return uni.getStorageSync('token') || ''
} catch {
return ''
}
}
/**
* 索取订阅授权并登记额度。
*
* @param codes 要索取的模板码(一次最多 3 个 —— 微信的限制)
* @returns 实际登记成功的模板码;H5 或用户拒绝时返回空数组(**不是错误**)
*/
export async function requestSubscribe(codes: SubscribeTemplateCode[]): Promise<string[]> {
// 用**运行期判断**而不是 `#ifdef` 条件编译:
// ① 条件编译要靠重复声明来分叉,而这里两种情况返回的是同一个 Promise ——
// 在 async 函数里写两段反而出现"不可达代码";
// ② H5 上这个 API 根本不存在,判一下就够,而且判了之后小程序端的行为完全一样
// (不依赖编译产物,出问题好排查)。
const api = (uni as unknown as {
requestSubscribeMessage?: (opt: {
tmplIds: string[]
success?: (res: Record<string, string>) => void
fail?: () => void
}) => void
}).requestSubscribeMessage
if (typeof api !== 'function') {
// H5 / 其它平台没有订阅消息。刻意**不**提示"请去小程序授权":
// 他一样能收到站内信,弹一句他做不到的事只会添乱。
return []
}
const list = codes.slice(0, 3)
const granted = await new Promise<string[]>((resolve) => {
try {
api({
tmplIds: list,
success: (res: Record<string, string>) => {
// 微信返回的形如 { ERR_OK: 'accept', AUDIT_RESULT: 'reject' },
// 只有值是 'accept' 的才算真的授权到手
resolve(list.filter((c) => res[c] === 'accept'))
},
// 用户拒绝 / 没配置模板 / 开发者工具限制 —— 都走到这里,一律当"没拿到"
fail: () => resolve([])
})
} catch {
resolve([])
}
})
if (!granted.length) {
return []
}
// 逐个登记。用 allSettled:一个模板登记失败不该影响另一个(额度是分开算的)
const results = await Promise.allSettled(granted.map((code) => request({
url: '/api/v1/mp/subscribe',
method: 'POST',
token: token(),
data: { templateCode: code, subscribeCount: 1 }
})))
return granted.filter((_, i) => results[i].status === 'fulfilled')
}