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