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:
@@ -84,7 +84,7 @@ test('空白与换行会被压成一行(微信不接受换行)', () => {
|
||||
})
|
||||
|
||||
test('SONG_SELECTED 的内置字段映射:每个字段名后缀都要跟发出去的值类型匹配', () => {
|
||||
// 与 server/services/wechatSubscribeService.ts 的 DEFAULT_TEMPLATES.SONG_SELECTED 保持一致
|
||||
// 字段映射的唯一来源是 server/utils/wechatSubscribeTemplates.ts(那里有更完整的测试)
|
||||
const fields = {
|
||||
thing2: 'songTitle',
|
||||
date4: 'playDate',
|
||||
@@ -136,14 +136,15 @@ test('SONG_REJECTED 的内置字段映射:长驳回原因必须裁进 thing
|
||||
|
||||
test('内置模板与线上代码一致(直接读源码,防止映射被改坏没人发现)', async () => {
|
||||
/*
|
||||
* 为什么不 import:wechatSubscribeService.ts 顶部 import 了 `~~/server/...` 这类 Nuxt 别名,
|
||||
* 纯 `node --test` 解析不了。这里只校验「模板 ID 和字段映射」这类静态事实,读文本足够,
|
||||
* 而且不需要跑起来就能在 CI 里拦住「手滑改了模板 ID / 字段名」。
|
||||
* 模板定义现在的唯一来源是 server/utils/wechatSubscribeTemplates.ts(纯 .ts,无 Nuxt 别名),
|
||||
* 但这里仍然用「读源码 + 正则」而不是 import:这个测试文件只依赖 wechatSubscribeFields.ts,
|
||||
* 不想因为模板文件偶尔引入别名而整个挂掉。详细的解析行为在
|
||||
* test/wechatSubscribeTemplates.test.mjs 里测。
|
||||
*/
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const { fileURLToPath } = await import('node:url')
|
||||
const src = readFileSync(
|
||||
fileURLToPath(new URL('../server/services/wechatSubscribeService.ts', import.meta.url)),
|
||||
fileURLToPath(new URL('../server/utils/wechatSubscribeTemplates.ts', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const block = (kind) => {
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* 微信订阅消息**模板登记表**的单元测试(`npm test` 跑)。
|
||||
*
|
||||
* 为什么值得测:模板 ID 或字段名写错,线上表现只有一句 `47003 argument invalid`,
|
||||
* 整条通知发不出去且看不出哪里错。这里把「认哪些模板、字段怎么映射、环境变量覆盖
|
||||
* 怎么合并」全部钉死,改坏了在 CI 就红。
|
||||
*
|
||||
* 可以直接 `import` 这个 .ts 文件,是因为 Node 22+ 会剥离类型标注;本文件刻意不依赖
|
||||
* Nuxt 别名(`~~/server/...`),所以纯 `node --test` 就能跑。
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
BUILTIN_TEMPLATES,
|
||||
TEMPLATES_ENV_KEY,
|
||||
buildTemplateData,
|
||||
explainWechatErrcode,
|
||||
findEmptyDataFields,
|
||||
findEmptyFields,
|
||||
listTemplateIds,
|
||||
parseSubscribeTemplates
|
||||
} from '../server/utils/wechatSubscribeTemplates.ts'
|
||||
|
||||
test('内置模板:两个模板的 ID 与字段映射和公众平台一致', () => {
|
||||
assert.deepEqual(Object.keys(BUILTIN_TEMPLATES).sort(), ['SONG_REJECTED', 'SONG_SELECTED'])
|
||||
|
||||
const rejected = BUILTIN_TEMPLATES.SONG_REJECTED
|
||||
assert.equal(rejected.id, 'erAK99M38ZeD-F1mOIgN2M7iAPVTU0YXo-adlkSwg_c')
|
||||
assert.deepEqual(rejected.fields, { thing6: 'songTitle', thing4: 'reason' })
|
||||
|
||||
const selected = BUILTIN_TEMPLATES.SONG_SELECTED
|
||||
assert.equal(selected.id, 'oLTAefO92xYYlGRYmcsm5AfUUlUI5IKFfXhIVNrItSk')
|
||||
assert.deepEqual(selected.fields, {
|
||||
thing2: 'songTitle',
|
||||
date4: 'playDate',
|
||||
time1: 'playTime',
|
||||
character_string3: 'sequence',
|
||||
phrase5: 'status'
|
||||
})
|
||||
|
||||
// 每个字段名都要带类型后缀(thing2/date4/…),否则 clampField 认不出类型、不会裁剪
|
||||
for (const kind of Object.keys(BUILTIN_TEMPLATES)) {
|
||||
for (const field of Object.keys(BUILTIN_TEMPLATES[kind].fields)) {
|
||||
assert.match(
|
||||
field,
|
||||
/^(thing|phrase|time|date|character_string|number|letter|symbol|amount)\d+$/,
|
||||
`${kind}.${field} 字段名没有类型后缀`
|
||||
)
|
||||
assert.ok(BUILTIN_TEMPLATES[kind].title, `${kind} 缺 title(体检报告里要显示)`)
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(listTemplateIds(BUILTIN_TEMPLATES).length, 2)
|
||||
assert.equal(TEMPLATES_ENV_KEY, 'WECHAT_MINI_SUBSCRIBE_TEMPLATES')
|
||||
})
|
||||
|
||||
test('没有环境变量时:原样用内置模板,且不产生任何告警项', () => {
|
||||
const r = parseSubscribeTemplates(undefined)
|
||||
assert.equal(r.envJsonValid, true)
|
||||
assert.equal(r.envRaw, null)
|
||||
assert.deepEqual(r.overriddenKeys, [])
|
||||
assert.deepEqual(r.unknownKeys, [])
|
||||
assert.deepEqual(r.newAdditionsKeys, [])
|
||||
assert.deepEqual(listTemplateIds(r.templates), [
|
||||
BUILTIN_TEMPLATES.SONG_REJECTED.id,
|
||||
BUILTIN_TEMPLATES.SONG_SELECTED.id
|
||||
])
|
||||
})
|
||||
|
||||
test('环境变量坏 JSON:只报告不抛异常,仍然能用内置模板发消息', () => {
|
||||
// 线上真的出现过:Gitea secret 里填了一个光秃秃的模板 ID
|
||||
const r = parseSubscribeTemplates('erAK99M38ZeD-F1mOIgN2M7iAPVTU0YXo-adlkSwg_c')
|
||||
assert.equal(r.envJsonValid, false)
|
||||
assert.equal(r.templates.SONG_REJECTED.id, BUILTIN_TEMPLATES.SONG_REJECTED.id)
|
||||
assert.equal(listTemplateIds(r.templates).length, 2)
|
||||
assert.ok(String(r.envRaw).includes('erAK99'))
|
||||
})
|
||||
|
||||
test('只给 id:沿用内置的字段映射(避免退化成微信必拒的空映射)', () => {
|
||||
const r = parseSubscribeTemplates(JSON.stringify({ SONG_SELECTED: { id: 'brand-new-id' } }))
|
||||
assert.equal(r.envJsonValid, true)
|
||||
assert.deepEqual(r.overriddenKeys, ['SONG_SELECTED'])
|
||||
assert.deepEqual(r.inheritedFieldsKeys, ['SONG_SELECTED'])
|
||||
assert.deepEqual(r.unknownKeys, [])
|
||||
assert.equal(r.templates.SONG_SELECTED.id, 'brand-new-id')
|
||||
assert.deepEqual(r.templates.SONG_SELECTED.fields, BUILTIN_TEMPLATES.SONG_SELECTED.fields)
|
||||
// 另一张模板不受影响
|
||||
assert.equal(r.templates.SONG_REJECTED.id, BUILTIN_TEMPLATES.SONG_REJECTED.id)
|
||||
})
|
||||
|
||||
test('给 fields:换字段映射,title 沿用内置的', () => {
|
||||
const r = parseSubscribeTemplates(
|
||||
JSON.stringify({ SONG_REJECTED: { id: 'x', fields: { thing1: 'reason' } } })
|
||||
)
|
||||
assert.deepEqual(r.templates.SONG_REJECTED.fields, { thing1: 'reason' })
|
||||
assert.equal(r.templates.SONG_REJECTED.title, BUILTIN_TEMPLATES.SONG_REJECTED.title)
|
||||
assert.deepEqual(r.inheritedFieldsKeys, [])
|
||||
})
|
||||
|
||||
test('新增一个内置表里没有的模板 key 是合法的(只要给全 id/fields)', () => {
|
||||
const r = parseSubscribeTemplates(
|
||||
JSON.stringify({
|
||||
SONG_PLAYED: { id: 'played-id', title: '已播放', fields: { thing1: 'songTitle' } }
|
||||
})
|
||||
)
|
||||
assert.deepEqual(r.newAdditionsKeys, ['SONG_PLAYED'])
|
||||
assert.deepEqual(r.unknownKeys, [])
|
||||
assert.equal(r.templates.SONG_PLAYED.id, 'played-id')
|
||||
assert.equal(listTemplateIds(r.templates).length, 3)
|
||||
})
|
||||
|
||||
test('用不了的项:缺 id / 类型不对 → 记进 unknownKeys 并忽略,其余配置照常生效', () => {
|
||||
const r = parseSubscribeTemplates(
|
||||
JSON.stringify({
|
||||
NO_ID: { fields: { thing1: 'songTitle' } },
|
||||
WRONG_TYPE: 'not-an-object',
|
||||
NULL_ITEM: null,
|
||||
SONG_SELECTED: { id: 'kept-id' }
|
||||
})
|
||||
)
|
||||
assert.deepEqual(r.unknownKeys.sort(), ['NO_ID', 'NULL_ITEM', 'WRONG_TYPE'])
|
||||
assert.deepEqual(r.overriddenKeys, ['SONG_SELECTED'])
|
||||
// 坏项没有污染模板表:仍然只有两个内置模板,且好的那项生效了
|
||||
assert.equal(listTemplateIds(r.templates).length, 2)
|
||||
assert.equal(r.templates.SONG_SELECTED.id, 'kept-id')
|
||||
})
|
||||
|
||||
test('环境变量是数组或标量时也算坏 JSON(不会把模板表搞乱)', () => {
|
||||
for (const bad of ['[]', '123', '"abc"', 'null']) {
|
||||
const r = parseSubscribeTemplates(bad)
|
||||
assert.equal(r.envJsonValid, false, bad + ' 应该被判定为不可用')
|
||||
assert.equal(listTemplateIds(r.templates).length, 2)
|
||||
}
|
||||
})
|
||||
|
||||
test('findEmptyFields:变量没给或全是空白 → 点名到字段和变量', () => {
|
||||
const conf = BUILTIN_TEMPLATES.SONG_SELECTED
|
||||
assert.deepEqual(
|
||||
findEmptyFields(conf, {})
|
||||
.map((f) => f.field)
|
||||
.sort(),
|
||||
['character_string3', 'date4', 'phrase5', 'thing2', 'time1']
|
||||
)
|
||||
|
||||
const partial = findEmptyFields(conf, {
|
||||
songTitle: '晴天',
|
||||
playDate: ' ',
|
||||
playTime: '2026-02-14 12:30',
|
||||
sequence: '1',
|
||||
status: '已排期'
|
||||
})
|
||||
assert.deepEqual(partial, [{ field: 'date4', variable: 'playDate' }])
|
||||
})
|
||||
|
||||
test('findEmptyDataFields:拼完 data 再兜一层空值检查', () => {
|
||||
assert.deepEqual(findEmptyDataFields({ a: { value: ' ' }, b: { value: 'ok' } }), [
|
||||
{ field: 'a', value: ' ' }
|
||||
])
|
||||
assert.deepEqual(findEmptyDataFields({ b: { value: 'ok' } }), [])
|
||||
})
|
||||
|
||||
test('buildTemplateData:按映射取值,并交给 clamp 处理(time 有兜底值)', async () => {
|
||||
const { clampField } = await import('../server/utils/wechatSubscribeFields.ts')
|
||||
const conf = BUILTIN_TEMPLATES.SONG_SELECTED
|
||||
const data = buildTemplateData(
|
||||
conf,
|
||||
{
|
||||
songTitle: '晴天',
|
||||
playDate: '2026-02-14',
|
||||
playTime: '2026-02-14 12:30',
|
||||
sequence: 3,
|
||||
status: '已排期'
|
||||
},
|
||||
clampField
|
||||
)
|
||||
assert.deepEqual(Object.keys(data), ['thing2', 'date4', 'time1', 'character_string3', 'phrase5'])
|
||||
assert.equal(data.thing2.value, '晴天')
|
||||
assert.equal(data.character_string3.value, '3')
|
||||
assert.equal(Array.from(data.phrase5.value).length, 5)
|
||||
assert.deepEqual(findEmptyDataFields(data), [], '有完整变量时不该出现空字段')
|
||||
})
|
||||
|
||||
test('explainWechatErrcode:把会遇到的错误码翻成人话', () => {
|
||||
assert.match(explainWechatErrcode(0), /成功/)
|
||||
assert.match(explainWechatErrcode(43101), /没有可用授权/)
|
||||
assert.match(explainWechatErrcode(47003), /字段/)
|
||||
assert.match(explainWechatErrcode(40003), /openid/)
|
||||
assert.match(explainWechatErrcode(40037), /模板 ID/)
|
||||
assert.match(explainWechatErrcode(40001), /access_token/)
|
||||
assert.match(explainWechatErrcode(45009), /上限/)
|
||||
assert.match(explainWechatErrcode(undefined), /未知/)
|
||||
assert.match(explainWechatErrcode(99999), /99999/)
|
||||
})
|
||||
|
||||
test('服务层不再自己写死模板(模板定义只应有一处)', async () => {
|
||||
/*
|
||||
* 以前 `DEFAULT_TEMPLATES` 直接写在 wechatSubscribeService.ts 里,
|
||||
* 于是「模板 ID」和「字段映射」在源码里有两份容易走样的副本。
|
||||
* 现在唯一来源是 server/utils/wechatSubscribeTemplates.ts,这里防止回退。
|
||||
*/
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const { fileURLToPath } = await import('node:url')
|
||||
const src = readFileSync(
|
||||
fileURLToPath(new URL('../server/services/wechatSubscribeService.ts', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
assert.ok(!/DEFAULT_TEMPLATES/.test(src), '服务层不应再定义 DEFAULT_TEMPLATES')
|
||||
assert.ok(
|
||||
!/erAK99M38ZeD/.test(src),
|
||||
'模板 ID 不应硬编码在服务层(应来自 wechatSubscribeTemplates.ts)'
|
||||
)
|
||||
assert.ok(
|
||||
!/oLTAefO92xYYlGRY/.test(src),
|
||||
'模板 ID 不应硬编码在服务层(应来自 wechatSubscribeTemplates.ts)'
|
||||
)
|
||||
// 旧调用点的函数名必须保留,否则 notificationService 会编译失败
|
||||
for (const fn of [
|
||||
'sendWechatSubscribeToUser',
|
||||
'sendWechatSubscribe',
|
||||
'buildTemplateData',
|
||||
'getSubscribeTemplates',
|
||||
'getSubscribeTemplateIds',
|
||||
'isWechatSubscribeEnabled',
|
||||
'getMiniAccessToken',
|
||||
'getWechatOpenid',
|
||||
'sendRawSubscribe',
|
||||
'inspectWechatSubscribe',
|
||||
'grantWechatSubscribe'
|
||||
]) {
|
||||
assert.match(src, new RegExp('export (async )?function ' + fn + '\\b'), `缺少导出 ${fn}`)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user