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:
@@ -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 || '—'
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user