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:
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* 微信订阅消息的**模板登记表**(纯函数,不依赖 Nitro / 数据库,可直接用 node 测)。
|
||||
*
|
||||
* 为什么单独一个文件:以前模板定义、字段裁剪、发送、日志全挤在
|
||||
* `server/services/wechatSubscribeService.ts` 里(340 行),任何一个环节出问题都要
|
||||
* 启动整个 Nuxt 才能验证。现在拆成三份:
|
||||
* · 本文件 —— 认哪些模板、字段怎么映射(纯数据 + 纯函数)
|
||||
* · wechatSubscribeFields.ts —— 每个值怎么裁成微信能接受的样子(纯函数)
|
||||
* · wechatSubscribeService.ts —— 拿 token、查 openid、发、记账、写日志
|
||||
*
|
||||
* ## 字段映射怎么读
|
||||
*
|
||||
* `fields` 的**键**是模板里的字段名(公众平台选用模板时能看到,形如 thing2 / date4 /
|
||||
* character_string3,后缀就是字段类型),**值**是调用方传进来的变量名。
|
||||
* 键写错 = 微信回 `47003 argument invalid` 且**整条消息发不出去**(微信只挑一个字段名报,
|
||||
* 不代表只有它有问题),所以这张表要和公众平台后台逐字核对。
|
||||
*
|
||||
* ## 站长的模板
|
||||
*
|
||||
* | key | 用途 | 模板字段 → 变量 |
|
||||
* |----------------|----------------------------|----------------------------------------------------------------------------|
|
||||
* | SONG_SELECTED | 歌曲被排进排期/发布 | thing2→songTitle, date4→playDate, time1→playTime, character_string3→sequence, phrase5→status |
|
||||
* | SONG_REJECTED | 投稿被驳回 | thing6→songTitle, thing4→reason |
|
||||
*
|
||||
* 换模板/改字段**不用改代码**:环境变量 `WECHAT_MINI_SUBSCRIBE_TEMPLATES` 是 JSON,按
|
||||
* 同一个 key 覆盖,只给 `id` 就沿用下面内置的字段映射,只给 `fields` 就换映射,都给就都换:
|
||||
*
|
||||
* ```json
|
||||
* { "SONG_SELECTED": { "id": "新模板ID" } }
|
||||
* { "SONG_SELECTED": { "fields": { "thing1": "songTitle" } } }
|
||||
* ```
|
||||
*/
|
||||
|
||||
/** 一个模板的配置:模板 ID + 字段名→变量名 的映射 */
|
||||
export type TemplateConf = {
|
||||
id: string
|
||||
fields: Record<string, string>
|
||||
/** 这个模板用来干什么(写日志 / 体检报告时给人看) */
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 内置模板(站长在公众平台选好的,可直接用)。
|
||||
*
|
||||
* ⚠️ 模板 ID 变了 = **老用户之前的授权全部作废**(微信的授权按 (openid, 模板 ID) 记),
|
||||
* 他们必须在小程序里重新点一次「开启」才能再收到;在那之前发送会返回
|
||||
* `43101`(无可用授权)—— 这是**正常状态,不是 bug**。
|
||||
*/
|
||||
export const BUILTIN_TEMPLATES: Record<string, TemplateConf> = {
|
||||
SONG_REJECTED: {
|
||||
id: 'erAK99M38ZeD-F1mOIgN2M7iAPVTU0YXo-adlkSwg_c',
|
||||
title: '审核驳回通知',
|
||||
fields: { thing6: 'songTitle', thing4: 'reason' }
|
||||
},
|
||||
SONG_SELECTED: {
|
||||
id: 'oLTAefO92xYYlGRYmcsm5AfUUlUI5IKFfXhIVNrItSk',
|
||||
title: '歌曲排期通知',
|
||||
fields: {
|
||||
thing2: 'songTitle',
|
||||
date4: 'playDate',
|
||||
time1: 'playTime',
|
||||
character_string3: 'sequence',
|
||||
phrase5: 'status'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 环境变量名(模板配置用 JSON 覆盖内置表) */
|
||||
export const TEMPLATES_ENV_KEY = 'WECHAT_MINI_SUBSCRIBE_TEMPLATES'
|
||||
|
||||
export type TemplateParseReport = {
|
||||
templates: Record<string, TemplateConf>
|
||||
/** 环境变量是不是合法 JSON(坏 JSON 不是致命错误:忽略它,用内置表继续跑) */
|
||||
envJsonValid: boolean
|
||||
/** 环境变量原文(体检报告里要显示,方便一眼看出写没写坏) */
|
||||
envRaw: string | null
|
||||
/** 环境变量覆盖/新增了哪些 key */
|
||||
overriddenKeys: string[]
|
||||
/** 环境变量里**用不了**的项(缺 id、类型不对;这一项会被忽略,其余照常生效) */
|
||||
unknownKeys: string[]
|
||||
/** 环境变量里新增的模板键(内置表里没有的 key,只要带 id 就是合法的补充) */
|
||||
newAdditionsKeys: string[]
|
||||
/** 环境变量没给 fields、沿用了内置映射的 key */
|
||||
inheritedFieldsKeys: string[]
|
||||
}
|
||||
|
||||
/** 解析出「内置模板 + 环境变量覆盖」的最终模板表,并把解析过程如实报告出来 */
|
||||
export function parseSubscribeTemplates(
|
||||
raw: string | null | undefined = process.env[TEMPLATES_ENV_KEY]
|
||||
): TemplateParseReport {
|
||||
const templates: Record<string, TemplateConf> = {}
|
||||
Object.keys(BUILTIN_TEMPLATES).forEach((k) => {
|
||||
templates[k] = { ...BUILTIN_TEMPLATES[k], fields: { ...BUILTIN_TEMPLATES[k].fields } }
|
||||
})
|
||||
|
||||
const report: TemplateParseReport = {
|
||||
templates,
|
||||
envJsonValid: true,
|
||||
envRaw: raw ? String(raw) : null,
|
||||
overriddenKeys: [],
|
||||
unknownKeys: [],
|
||||
newAdditionsKeys: [],
|
||||
inheritedFieldsKeys: []
|
||||
}
|
||||
|
||||
if (!raw) return report
|
||||
|
||||
let parsed: any = null
|
||||
try {
|
||||
parsed = JSON.parse(String(raw))
|
||||
} catch {
|
||||
report.envJsonValid = false
|
||||
return report
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
report.envJsonValid = false
|
||||
return report
|
||||
}
|
||||
|
||||
Object.keys(parsed).forEach((key) => {
|
||||
const conf = parsed[key]
|
||||
/*
|
||||
* 没有 id 的项一律忽略,并记进 unknownKeys 提醒写错了。
|
||||
* 为什么不能「只给 fields」:模板 ID 是必须的,缺了它这一项根本发不出去,
|
||||
* 与其留下一个 id 为空、发送时才发现不对的条目,不如当场报告。
|
||||
*/
|
||||
if (!conf || typeof conf !== 'object' || Array.isArray(conf) || !conf.id) {
|
||||
report.unknownKeys.push(key)
|
||||
return
|
||||
}
|
||||
const base = templates[key]
|
||||
// 内置表里没有的 key 不是错误:站长完全可以再加一张模板,只要给全 id(+ fields)
|
||||
if (!base) report.newAdditionsKeys.push(key)
|
||||
let fields = base ? base.fields : undefined
|
||||
if (conf.fields && typeof conf.fields === 'object' && !Array.isArray(conf.fields)) {
|
||||
fields = conf.fields
|
||||
} else if (base) {
|
||||
// 只想换模板 ID 时的常见情况:沿用内置字段映射,避免退化成微信必拒的映射
|
||||
report.inheritedFieldsKeys.push(key)
|
||||
}
|
||||
templates[key] = {
|
||||
id: String(conf.id),
|
||||
title: (conf.title && String(conf.title)) || base?.title || key,
|
||||
fields: fields || {}
|
||||
}
|
||||
report.overriddenKeys.push(key)
|
||||
})
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
/** 模板 ID 列表(给小程序去申请授权用;微信一次最多 3 个,调用方自己截) */
|
||||
export function listTemplateIds(templates: Record<string, TemplateConf>): string[] {
|
||||
return Object.keys(templates)
|
||||
.map((k) => templates[k]?.id)
|
||||
.filter((id): id is string => !!id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把模板配置 + 变量拼成微信要求的 `data`。
|
||||
*
|
||||
* @param vars 变量表。内置 `time` 兜底为「北京时间现在」,所以模板里用 time 型字段也不会空。
|
||||
*/
|
||||
export function buildTemplateData(
|
||||
conf: TemplateConf,
|
||||
vars: Record<string, unknown>,
|
||||
clamp: (key: string, value: unknown) => string
|
||||
): Record<string, { value: string }> {
|
||||
const fields = conf.fields || {}
|
||||
const data: Record<string, { value: string }> = {}
|
||||
for (const key of Object.keys(fields)) {
|
||||
data[key] = { value: clamp(key, vars[fields[key]]) }
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* 找出「映射到了变量、但变量是空的」字段。
|
||||
*
|
||||
* 微信对空值是**整条拒绝**(47003),而报错只点一个字段名,所以发送前自己先查一遍,
|
||||
* 把问题字段列清楚 —— 这是「任何一步失败都必须看得见」里最值钱的一条。
|
||||
*/
|
||||
export function findEmptyFields(
|
||||
conf: TemplateConf,
|
||||
vars: Record<string, unknown>
|
||||
): Array<{ field: string; variable: string }> {
|
||||
const fields = conf.fields || {}
|
||||
const empty: Array<{ field: string; variable: string }> = []
|
||||
for (const field of Object.keys(fields)) {
|
||||
const variable = fields[field]
|
||||
const raw = vars[variable]
|
||||
if (raw === null || raw === undefined || String(raw).trim() === '') {
|
||||
empty.push({ field, variable })
|
||||
}
|
||||
}
|
||||
return empty
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送前的最后一道自检:把映射算成微信的 data 之后,再核对一次
|
||||
* 「有没有字段最终是空字符串」——变量给了但裁完为空(比如全是空白字符)也算。
|
||||
*/
|
||||
export function findEmptyDataFields(
|
||||
data: Record<string, { value: string }>
|
||||
): Array<{ field: string; value: string }> {
|
||||
return Object.keys(data)
|
||||
.filter((field) => !data[field] || String(data[field].value).trim() === '')
|
||||
.map((field) => ({ field, value: data[field]?.value ?? '' }))
|
||||
}
|
||||
|
||||
/** 微信 errcode → 人话(体检报告、日志、接口返回值都用它,统一口径) */
|
||||
export function explainWechatErrcode(errcode: number | undefined | null): string {
|
||||
switch (errcode) {
|
||||
case 0:
|
||||
return '发送成功'
|
||||
case 40001:
|
||||
return 'access_token 无效或已过期(会重新获取,通常重试即可)'
|
||||
case 40003:
|
||||
return 'openid 无效:这个微信号和当前小程序不是同一个 appid'
|
||||
case 40037:
|
||||
return '模板 ID 无效:模板不存在或还没通过审核'
|
||||
case 43101:
|
||||
return '用户没有可用授权:要么从没授权过这张模板,要么上一次已经用掉了(一次性订阅,发一条扣一条),需要用户在小程序里重新点「开启」'
|
||||
case 45009:
|
||||
return '接口调用超过每日上限'
|
||||
case 47003:
|
||||
return '模板字段名或字段值不合规(常见原因:字段名和公众平台不一致、字段超长、字段为空)'
|
||||
case -1:
|
||||
return '微信侧系统繁忙,稍后重试'
|
||||
default:
|
||||
return errcode == null ? '未知错误' : `微信返回错误码 ${errcode}`
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user