把「微信订阅消息」这套代码按验收标准推翻重写:用户能自己看懂为什么收不到,
任何一步失败都必须看得见。对外函数名不变,notificationService / test-subscribe
等调用点无需改动。
新增 server/utils/wechatSubscribeTemplates.ts(纯函数,零依赖):
- 模板登记表唯一来源(内置 SONG_SELECTED / SONG_REJECTED),服务层不再自己写死
- parseSubscribeTemplates 如实报告环境变量覆盖情况:坏 JSON 只报告不抛、
只给 id 时沿用内置字段映射、缺 id 的项记进 unknownKeys 并忽略
- findEmptyFields / findEmptyDataFields 发出前自查空字段(微信空值整条 47003)
- explainWechatErrcode 把 43101/47003/40003/40037/40001/45009 翻成人话
重写 server/services/wechatSubscribeService.ts:
- sendWechatSubscribe 返回 SendOutcome,用 stage 明确「卡在哪一步」:
no-template / disabled-by-user / no-wechat-openid / no-access-token /
empty-field / no-grant / rejected-no-quota / rejected-bad-field / rejected-other
- 每一次返回都写一行日志,不再有静默跳过(这是「发不出去全靠猜」的根因)
- inspectWechatSubscribe 只读体检:不发消息、不消耗额度,返回逐项 verdict + actions
- grantWechatSubscribe 依靠新增的 WechatSubscribeGrant 表记账:微信没有查询
剩余授权的接口,只能自己数(用户点允许 +1、发送成功 -1)
- 记账表不可用时失败开放(fail-open):额度预检关闭、照常发送,避免通知整体哑掉
- access_token 显式 responseType: json 并做字符串兜底解析
新增接口:
- GET /api/user/wechat-subscribe-status 小程序显示「还能收到几条 / 为什么收不到」
- POST /api/user/wechat-subscribe-grant 小程序授权成功后上报 accept 的模板
- GET /api/admin/wechat/diagnose 重写:人话结论 + actions,?send=1 真发一次
其他:新增 WechatSubscribeGrant 表(schema + 手写迁移)、.env.example 说明 JSON 覆盖
写法、.gitattributes 补 *.sql eol=lf。
测试:新增 test/wechatSubscribeTemplates.test.mjs(13 个用例,可直接 import .ts,
不依赖 Nuxt 别名),npm test 里订阅相关 25/25 通过。
79 lines
3.3 KiB
TypeScript
79 lines
3.3 KiB
TypeScript
import {
|
|
inspectWechatSubscribe,
|
|
sendWechatSubscribe
|
|
} from '~~/server/services/wechatSubscribeService'
|
|
|
|
/**
|
|
* 微信订阅消息的**一键体检**(仅超级管理员,GET,浏览器里直接打开就能看)。
|
|
*
|
|
* 为什么要有它:线上「通知没发出去」以前只能靠猜 —— 发送前的四条跳过路径一句日志
|
|
* 都不写。现在体检接口把判断要用的事实一次全返回,并且每一项都配一句人话结论。
|
|
*
|
|
* 用法(在已登录后台的浏览器里打开):
|
|
* /api/admin/wechat/diagnose → 只体检(**不发消息、不消耗额度**)
|
|
* /api/admin/wechat/diagnose?send=1 → 顺手给这个用户每个模板真发一条(消耗额度,返回微信原始 errcode)
|
|
* /api/admin/wechat/diagnose?userId=5 → 体检别的用户
|
|
* /api/admin/wechat/diagnose?send=1&force=1 → 忽略本地记账的额度预检,强制发(仍可能被微信 43101 拒)
|
|
*
|
|
* 读返回值:先看 `user.verdict` 与 `templates[].verdict`(人话),再看 `actions[]`
|
|
* (照着做就能修),最后需要原始错误码时看 `?send=1` 的 `sendAttempts[].errcode`。
|
|
*/
|
|
export default defineEventHandler(async (event) => {
|
|
const user = event.context.user
|
|
if (!user || user.role !== 'SUPER_ADMIN') {
|
|
throw createError({ statusCode: 403, message: '只有超级管理员可以使用微信订阅体检' })
|
|
}
|
|
|
|
const query = getQuery(event)
|
|
const doSend = query.send === '1' || query.send === 'true'
|
|
const force = query.force === '1' || query.force === 'true'
|
|
const requestedId = query.userId != null ? Number(query.userId) : NaN
|
|
const targetUserId =
|
|
Number.isFinite(requestedId) && requestedId > 0 ? requestedId : Number(user.id)
|
|
|
|
// 只读体检:不发消息、不消耗额度。所有导出函数都不抛异常,失败信息都在返回值里
|
|
const inspection = await inspectWechatSubscribe(targetUserId, {
|
|
username: targetUserId === Number(user.id) ? user.username : null
|
|
})
|
|
|
|
const result: Record<string, unknown> = {
|
|
...inspection,
|
|
requestedBy: { userId: Number(user.id), username: user.username },
|
|
note: doSend
|
|
? '已执行真实发送:sendAttempts 里的 errcode 就是微信的原始回复(0=成功 / 43101=没有可用授权 / 47003=字段名或值不合规 / 40003=openid 不对 / 40001=token 失效)'
|
|
: '本次只体检、没有发消息,也没有消耗任何授权额度;要真发一次加 ?send=1'
|
|
}
|
|
|
|
if (doSend) {
|
|
const attempts: Array<Record<string, unknown>> = []
|
|
const kinds = Array.from(new Set(inspection.templates.map((t) => t.kind)))
|
|
for (const kind of kinds) {
|
|
const outcome = await sendWechatSubscribe(
|
|
targetUserId,
|
|
kind,
|
|
{
|
|
songTitle: '测试歌曲《晴天》',
|
|
playDate: '2026-02-14',
|
|
playTime: '2026-02-14 12:30',
|
|
sequence: '1',
|
|
status: '已排期',
|
|
reason: '测试驳回原因',
|
|
message: '这是一条测试通知'
|
|
},
|
|
{ force, source: 'diagnose' }
|
|
)
|
|
attempts.push({
|
|
kind,
|
|
ok: outcome.ok,
|
|
stage: outcome.stage,
|
|
errcode: outcome.errcode ?? null,
|
|
errmsg: outcome.errmsg ?? null,
|
|
detail: outcome.detail
|
|
})
|
|
}
|
|
result.sendAttempts = attempts
|
|
}
|
|
|
|
return result
|
|
})
|