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
+244
View File
@@ -0,0 +1,244 @@
<template>
<GiPageLayout>
<a-card :bordered="false">
<template #title>通知记录</template>
<template #extra>
<span class="notify__hint">
「已发出」= 站内信已写进他的消息中心;微信推送要看学生有没有授权
</span>
</template>
<!--
⚠️ 这一行是刻意留的:一期的推送渠道只有微信订阅消息(一次性授权),
拿不到授权时消息**只落站内信**。不写清这一点,
运营会把「额度不足跳过」当成系统故障去查通道。
-->
<a-alert v-if="rate" type="normal" class="notify__rate">
<template #title>
送达率 <b>{{ rate }}</b>
—— 分母含「额度不足跳过」。
跳过**不是失败**:内容已经进了学生的消息中心,只是没有推送。
要提升这个数字,该做的是引导学生授权,而不是查通道。
</template>
</a-alert>
<div class="g-row-tool">
<a-space wrap>
<a-select v-model="query.taskId" placeholder="全部任务" allow-clear size="small"
class="notify__task" :loading="taskLoading" @change="search">
<a-option v-for="t in taskOptions" :key="t.id" :value="t.id">{{ t.taskName }}</a-option>
</a-select>
<a-select v-model="query.bizType" placeholder="全部类型" allow-clear size="small"
class="notify__biz" @change="search">
<a-option v-for="b in BIZ_TYPES" :key="b.value" :value="b.value">{{ b.label }}</a-option>
</a-select>
<a-select v-model="query.status" placeholder="全部状态" allow-clear size="small"
class="notify__status" @change="search">
<a-option v-for="(v, k) in NOTIFY_STATUS" :key="k" :value="Number(k)">{{ v.text }}</a-option>
</a-select>
<a-button size="small" @click="load">
<template #icon><IconRefresh /></template>
刷新
</a-button>
</a-space>
</div>
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
:scroll="{ x: 1100 }" @page-change="onPageChange">
<template #columns>
<a-table-column title="时间" :width="150">
<template #cell="{ record }">
<span class="notify__muted">{{ fmt(record.sendTime) }}</span>
</template>
</a-table-column>
<a-table-column title="收件人" :width="140">
<template #cell="{ record }">
<div>{{ record.receiverName || '—' }}</div>
<div class="notify__muted">ID {{ record.receiverId }}</div>
</template>
</a-table-column>
<a-table-column title="类型" :width="110">
<template #cell="{ record }">
<a-tag size="small">{{ bizTypeText(record.bizType) }}</a-tag>
</template>
</a-table-column>
<a-table-column title="内容" :width="300">
<template #cell="{ record }">
<div class="notify__content">{{ record.content || '—' }}</div>
</template>
</a-table-column>
<a-table-column title="渠道" :width="130">
<template #cell="{ record }">
<span class="notify__muted">{{ channelText(record.channel) }}</span>
</template>
</a-table-column>
<a-table-column title="状态" :width="150">
<template #cell="{ record }">
<a-tag size="small" :color="notifyStatus(record.status).color">
{{ record.statusText || notifyStatus(record.status).text }}
</a-tag>
<!-- 失败原因要露出来:不然「发送失败」这四个字没法排查 -->
<div v-if="record.failReason" class="notify__fail" :title="record.failReason">
{{ record.failReason }}
</div>
</template>
</a-table-column>
</template>
</a-table>
</a-card>
</GiPageLayout>
</template>
<script setup lang="ts">
import type { NotifyRecord } from '@/apis/xt/notify'
import { IconRefresh } from '@arco-design/web-vue/es/icon'
import { computed, reactive, ref } from 'vue'
import {
BIZ_TYPES,
bizTypeText,
channelText,
getNotifyRecords,
NOTIFY_STATUS,
notifyStatus
} from '@/apis/xt/notify'
import { getTaskList } from '@/apis/xt/task'
/**
* 通知记录(《03-接口设计》§13.3)。
*
* ## 这一屏存在的意义:把「通知到底发出去没有」变成可回答的
*
* 在此之前,驳回与审批只能回 `notifySent=false` 并让老师口头告知。
* 现在每次通知都会写一条发送记录,所以运营能看到:
* · **已送达**:站内信写进去了;
* · **额度不足跳过**(3):内容进了站内信,但没推送 —— **这不是故障**,
* 该做的是引导学生授权;
* · **发送失败**(2):真的出了问题(微信接口异常等),要查通道。
*
* 把 2 和 3 混在一起显示成"失败",会让运营一直去查通道故障,
* 而真正的原因(学生没授权)永远没人管。
*/
defineOptions({ name: 'NotifyRecords' })
const loading = ref(false)
const taskLoading = ref(false)
const list = ref<NotifyRecord[]>([])
const total = ref(0)
const rate = ref('')
const taskOptions = ref<Array<{ id: number, taskName: string }>>([])
const query = reactive<{
taskId?: number
bizType?: number
status?: number
pageNum: number
pageSize: number
}>({
taskId: undefined,
bizType: undefined,
status: undefined,
pageNum: 1,
pageSize: 20
})
const pagination = computed(() => ({
current: query.pageNum,
pageSize: query.pageSize,
total: total.value,
showTotal: true,
showPageSize: true
}))
function fmt(v?: string) {
return v ? v.replace('T', ' ').slice(0, 16) : '—'
}
async function load() {
loading.value = true
try {
const res = await getNotifyRecords({
taskId: query.taskId,
bizType: query.bizType,
status: query.status,
pageNum: query.pageNum,
pageSize: query.pageSize
})
list.value = res.data?.list ?? []
total.value = res.data?.total ?? 0
rate.value = res.data?.deliveryRate ?? ''
} finally {
loading.value = false
}
}
async function loadTasks() {
taskLoading.value = true
try {
const res = await getTaskList({ pageNum: 1, pageSize: 200 })
taskOptions.value = (res.data?.list ?? []).map((t) => ({ id: t.id, taskName: t.taskName }))
} finally {
taskLoading.value = false
}
}
function search() {
query.pageNum = 1
load()
}
function onPageChange(page: number) {
query.pageNum = page
load()
}
load()
loadTasks()
</script>
<style lang="scss" scoped>
.notify {
&__hint {
font-size: 12px;
color: var(--color-text-3);
}
&__rate {
margin-bottom: 12px;
}
&__task {
width: 200px;
}
&__biz {
width: 140px;
}
&__status {
width: 180px;
}
&__muted {
font-size: 12px;
color: var(--color-text-3);
}
&__content {
display: -webkit-box;
overflow: hidden;
font-size: 13px;
line-height: 1.5;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
&__fail {
margin-top: 4px;
overflow: hidden;
font-size: 12px;
color: rgb(var(--red-6));
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>