refactor(微信订阅): 重写订阅消息链路,每一步失败都可见
把「微信订阅消息」这套代码按验收标准推翻重写:用户能自己看懂为什么收不到,
任何一步失败都必须看得见。对外函数名不变,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 通过。
This commit is contained in:
@@ -1,31 +1,22 @@
|
||||
import {
|
||||
buildTemplateData,
|
||||
getMiniAccessToken,
|
||||
getSubscribeTemplates,
|
||||
getWechatOpenid,
|
||||
isWechatSubscribeEnabled,
|
||||
sendWechatSubscribeToUser
|
||||
inspectWechatSubscribe,
|
||||
sendWechatSubscribe
|
||||
} from '~~/server/services/wechatSubscribeService'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { userIdentities } from '~/drizzle/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
/**
|
||||
* 微信订阅消息的**一键体检**(仅超级管理员,用 GET,浏览器直接打开就能看)。
|
||||
* 微信订阅消息的**一键体检**(仅超级管理员,GET,浏览器里直接打开就能看)。
|
||||
*
|
||||
* 为什么要有它:线上「通知没发出去」以前只能靠猜——服务端日志里四条跳过路径
|
||||
* 一句不写。现在把判断要用的事实全部一次返回:
|
||||
* 1. access_token 能不能拿到(appid / secret 是否有效)
|
||||
* 2. 配置了哪些模板、ID 是什么、环境变量有没有覆盖
|
||||
* 3. 每个用户有没有绑微信 openid、通知总开关开没开
|
||||
* 4. 「用户是不是授权过这张模板」——微信没有查额度的接口,唯一办法是调
|
||||
* getwxacode 的同类接口 /api/cgi-bin/message/subscribe/… 没法查,所以这里
|
||||
* 退而求其次:用 `?send=1` 真发一条给当前用户,把微信的 errcode 原样返回
|
||||
* (0=通、43101=没额度、47003=字段名不对、40003=openid 不对、40001=token 失效)。
|
||||
* 为什么要有它:线上「通知没发出去」以前只能靠猜 —— 发送前的四条跳过路径一句日志
|
||||
* 都不写。现在体检接口把判断要用的事实一次全返回,并且每一项都配一句人话结论。
|
||||
*
|
||||
* 用法(在已登录后台的浏览器里直接打开):
|
||||
* /api/admin/wechat/diagnose → 只体检,不发消息
|
||||
* /api/admin/wechat/diagnose?send=1 → 顺手给当前登录用户发一条(消耗 1 条额度)
|
||||
* 用法(在已登录后台的浏览器里打开):
|
||||
* /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
|
||||
@@ -35,73 +26,32 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const query = getQuery(event)
|
||||
const doSend = query.send === '1' || query.send === 'true'
|
||||
const selfId = Number(user.id)
|
||||
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 templates = getSubscribeTemplates()
|
||||
const kinds = Object.keys(templates)
|
||||
|
||||
const token = await getMiniAccessToken()
|
||||
const selfOpenid = await getWechatOpenid(selfId)
|
||||
|
||||
// 绑了微信的账号(有 openid 才可能收到订阅消息)
|
||||
const bound = await db
|
||||
.select({ userId: userIdentities.userId })
|
||||
.from(userIdentities)
|
||||
.where(eq(userIdentities.provider, 'wechat-mp'))
|
||||
const boundUserIds = Array.from(new Set(bound.map((r) => r.userId)))
|
||||
|
||||
// 每个模板算一遍「真发出去会是什么值」,方便直接比对公众平台后台的字段名
|
||||
const previews = kinds.map((kind) => {
|
||||
const conf = templates[kind]
|
||||
const vars: Record<string, unknown> = {
|
||||
songTitle: '测试歌曲《晴天》',
|
||||
playDate: '2026-02-14',
|
||||
playTime: '2026-02-14 12:30',
|
||||
sequence: '1',
|
||||
status: '已排期',
|
||||
reason: '测试驳回原因',
|
||||
message: '这是一条测试通知'
|
||||
}
|
||||
return {
|
||||
kind,
|
||||
templateId: conf?.id || null,
|
||||
fields: conf?.fields || null,
|
||||
// 真正发出去就是这一份 data(buildTemplateData 内部走 clampField 截断/补齐)
|
||||
data: conf
|
||||
? buildTemplateData(conf, Object.assign({ songTitle: '宝一外校园点歌系统' }, vars))
|
||||
: null
|
||||
}
|
||||
// 只读体检:不发消息、不消耗额度。所有导出函数都不抛异常,失败信息都在返回值里
|
||||
const inspection = await inspectWechatSubscribe(targetUserId, {
|
||||
username: targetUserId === Number(user.id) ? user.username : null
|
||||
})
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
now: new Date().toISOString(),
|
||||
env: {
|
||||
// 环境变量里只要不是合法 JSON,服务端会忽略它并回退到内置模板
|
||||
WECHAT_MINI_SUBSCRIBE_TEMPLATES: process.env.WECHAT_MINI_SUBSCRIBE_TEMPLATES || null,
|
||||
appidSet: !!process.env.WECHAT_MINI_APPID,
|
||||
secretSet: !!process.env.WECHAT_MINI_SECRET,
|
||||
enabled: isWechatSubscribeEnabled(),
|
||||
nodeEnv: process.env.NODE_ENV || null
|
||||
},
|
||||
tokenOk: !!token,
|
||||
tokenLen: token ? token.length : 0,
|
||||
me: {
|
||||
userId: selfId,
|
||||
username: user.username,
|
||||
// openid 只露前 6 位,避免日志/截图泄露
|
||||
openid: selfOpenid ? selfOpenid.slice(0, 6) + '****' : null,
|
||||
canReceive: !!selfOpenid
|
||||
},
|
||||
boundWechatUserIds: boundUserIds,
|
||||
templates: previews
|
||||
...inspection,
|
||||
requestedBy: { userId: Number(user.id), username: user.username },
|
||||
note: doSend
|
||||
? '已执行真实发送:sendAttempts 里的 errcode 就是微信的原始回复(0=成功 / 43101=没有可用授权 / 47003=字段名或值不合规 / 40003=openid 不对 / 40001=token 失效)'
|
||||
: '本次只体检、没有发消息,也没有消耗任何授权额度;要真发一次加 ?send=1'
|
||||
}
|
||||
|
||||
if (doSend && selfOpenid) {
|
||||
const attempts: Array<{ kind: string; result: unknown }> = []
|
||||
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) {
|
||||
attempts.push({
|
||||
const outcome = await sendWechatSubscribe(
|
||||
targetUserId,
|
||||
kind,
|
||||
result: await sendWechatSubscribeToUser(selfId, kind, {
|
||||
{
|
||||
songTitle: '测试歌曲《晴天》',
|
||||
playDate: '2026-02-14',
|
||||
playTime: '2026-02-14 12:30',
|
||||
@@ -109,12 +59,19 @@ export default defineEventHandler(async (event) => {
|
||||
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
|
||||
} else if (doSend && !selfOpenid) {
|
||||
result.sendSkipped = 'no-wechat-openid:当前登录账号没有绑定微信,发不出去'
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { grantWechatSubscribe } from '~~/server/services/wechatSubscribeService'
|
||||
|
||||
/**
|
||||
* 小程序在 `wx.requestSubscribeMessage` 成功之后来报账。
|
||||
*
|
||||
* 为什么要上报:微信**没有**「这个用户还剩几条授权」的查询接口,而一次性订阅是
|
||||
* 发一条扣一条。服务端自己记账,才能在小程序里说清「还能收到几条」,也才能在
|
||||
* 发送前就告诉用户「没额度了,去点一下开启」。
|
||||
*
|
||||
* 约定:
|
||||
* - `templateIds` **只传微信真的返回 `accept` 的模板**(`reject` / 没弹出来的别传);
|
||||
* - 上报失败不影响用户已经拿到的授权,只是我们的计数会不准;
|
||||
* - 服务端会把不在配置里的模板 ID 过滤掉,并写一行日志说明。
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: '未授权访问' })
|
||||
}
|
||||
|
||||
const body = await readBody(event).catch(() => null)
|
||||
const raw = Array.isArray(body?.templateIds) ? body.templateIds : []
|
||||
const templateIds = raw.filter(
|
||||
(id: unknown): id is string => typeof id === 'string' && id.length > 0
|
||||
)
|
||||
|
||||
if (!templateIds.length) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'templateIds 不能为空(只传微信返回 accept 的模板 ID)'
|
||||
})
|
||||
}
|
||||
|
||||
const result = await grantWechatSubscribe(Number(user.id), templateIds, 'miniprogram')
|
||||
return {
|
||||
ok: result.ok,
|
||||
// 只回模板键,不回 ID 清单,日志里也不打印完整 ID
|
||||
grantedKinds: result.granted.map((g) => g.kind || 'unknown'),
|
||||
granted: result.granted.length,
|
||||
error: result.error || null,
|
||||
hint: result.ok
|
||||
? '已记账:每点一次「允许」记 1 条额度,服务端发一条扣一条'
|
||||
: '记账失败,但不影响你已经拿到的微信授权'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { inspectWechatSubscribe } from '~~/server/services/wechatSubscribeService'
|
||||
|
||||
/**
|
||||
* 小程序「微信通知」卡片读的**只读**状态:能不能收到、为什么收不到、还能收几条。
|
||||
*
|
||||
* 为什么不直接放 `wx.getSetting` 的结果:那只是「用户上次在弹窗里选了什么」,
|
||||
* 不代表服务端还有额度(微信按「发一条扣一条」算,也没有查询余额的接口)。
|
||||
* 这里返回的是服务端自己记的账 + 真正能发出去的前提条件。
|
||||
*
|
||||
* 只返回结论与人话,不回传 openid 原文。
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: '未授权访问' })
|
||||
}
|
||||
|
||||
const inspection = await inspectWechatSubscribe(Number(user.id))
|
||||
const remainingTotal = inspection.templates.reduce((sum, t) => sum + t.remainingApprox, 0)
|
||||
|
||||
return {
|
||||
enabled: inspection.enabled,
|
||||
canReceive:
|
||||
inspection.user.hasOpenid && inspection.user.notificationEnabled && inspection.tokenOk,
|
||||
hasOpenid: inspection.user.hasOpenid,
|
||||
notificationEnabled: inspection.user.notificationEnabled,
|
||||
verdict: inspection.user.verdict,
|
||||
/** 所有模板加起来还剩几条(近似值) */
|
||||
remainingTotal,
|
||||
templates: inspection.templates.map((t) => ({
|
||||
kind: t.kind,
|
||||
title: t.title,
|
||||
remaining: t.remainingApprox,
|
||||
granted: t.grantedCount,
|
||||
sent: t.sentCount,
|
||||
lastErrorCode: t.lastErrorCode,
|
||||
verdict: t.verdict
|
||||
})),
|
||||
actions: inspection.actions,
|
||||
note: 'remaining 是服务端自己记的近似额度:微信不提供剩余授权查询,一次性订阅发一条扣一条,发完要用户再点一次「开启」'
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user