把「微信订阅消息」这套代码按验收标准推翻重写:用户能自己看懂为什么收不到,
任何一步失败都必须看得见。对外函数名不变,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 通过。
178 lines
7.7 KiB
JavaScript
178 lines
7.7 KiB
JavaScript
/**
|
||
* 微信订阅消息字段裁剪规则的单元测试(`npm test` 跑)。
|
||
*
|
||
* 为什么值得单独测:微信对订阅消息的字段校验是**整条消息级别**的 —— 一个字段超长、
|
||
* 格式不对或为空,整条消息就被拒(47003),而且报错里点名的字段不一定是真凶。
|
||
* 这些规则一旦写错,线上表现就是「通知发不出去」,却看不出哪里错。
|
||
*/
|
||
import { test } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import {
|
||
beijingNow,
|
||
buildWechatTimeValue,
|
||
clampField
|
||
} from '../server/utils/wechatSubscribeFields.ts'
|
||
|
||
const len = (s) => Array.from(s).length
|
||
|
||
test('thing 型:20 字以内原样,超了截 19 字 + 省略号(正好 20)', () => {
|
||
assert.equal(clampField('thing2', '晴天'), '晴天')
|
||
const twenty = '一二三四五六七八九十一二三四五六七八九十'
|
||
assert.equal(clampField('thing2', twenty), twenty)
|
||
assert.equal(len(clampField('thing2', twenty)), 20)
|
||
|
||
const long = twenty + '崩坏的世界'
|
||
const clamped = clampField('thing2', long)
|
||
assert.equal(len(clamped), 20)
|
||
assert.ok(clamped.endsWith('…'))
|
||
assert.ok(long.startsWith(clamped.slice(0, 19)))
|
||
})
|
||
|
||
test('phrase 型:5 个以内纯汉字——“已排期”会被补成全角 5 字,不是原样发出去', () => {
|
||
const padded = clampField('phrase5', '已排期')
|
||
assert.equal(len(padded), 5)
|
||
assert.ok(padded.startsWith('已排期'))
|
||
|
||
// 刚好 5 个汉字原样保留
|
||
assert.equal(clampField('phrase5', '已排期播放'), '已排期播放')
|
||
// 超了截到 5 个
|
||
assert.equal(len(clampField('phrase5', '已排期播放中啦啦啦')), 5)
|
||
})
|
||
|
||
test('time 型:必须是 YYYY-MM-DD HH:mm 且不超过 24 字', () => {
|
||
const w = (v) => ({ value: clampField('time1', v) })
|
||
assert.deepEqual(w(buildWechatTimeValue(new Date('2026-02-14T00:00:00Z'), '12:30')), {
|
||
value: '2026-02-14 12:30'
|
||
})
|
||
assert.match(clampField('time1', '2026-02-14 12:30'), /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)
|
||
assert.equal(len(clampField('time1', '2026-02-14 12:30')), 16)
|
||
})
|
||
|
||
test('buildWechatTimeValue:取时段开始时间,兼容各种写法,兜底当天 00:00', () => {
|
||
const day = new Date('2026-02-14T00:00:00Z')
|
||
assert.equal(buildWechatTimeValue(day, '12:30-13:00'), '2026-02-14 12:30')
|
||
assert.equal(buildWechatTimeValue(day, '12:30~13:00'), '2026-02-14 12:30')
|
||
assert.equal(buildWechatTimeValue(day, '12:30'), '2026-02-14 12:30')
|
||
assert.equal(buildWechatTimeValue(day, '9:05'), '2026-02-14 09:05')
|
||
assert.equal(buildWechatTimeValue(day, '13:00'), '2026-02-14 13:00')
|
||
// time 型字段不能为空,解析不出来也要给个合法值
|
||
assert.equal(buildWechatTimeValue(day, ''), '2026-02-14 00:00')
|
||
assert.equal(buildWechatTimeValue(day, null), '2026-02-14 00:00')
|
||
assert.equal(buildWechatTimeValue(day, '午间'), '2026-02-14 00:00')
|
||
})
|
||
|
||
test('buildWechatTimeValue:以北京时间取日期(UTC 16:00 已是次日)', () => {
|
||
assert.equal(buildWechatTimeValue(new Date('2026-02-13T16:00:00Z'), '12:30'), '2026-02-14 12:30')
|
||
assert.equal(buildWechatTimeValue(new Date('2026-02-13T15:59:00Z'), '12:30'), '2026-02-13 12:30')
|
||
})
|
||
|
||
test('character_string 型:序号原样发(不补零、不加字)', () => {
|
||
assert.equal(clampField('character_string3', 1), '1')
|
||
assert.equal(clampField('character_string3', '12'), '12')
|
||
assert.equal(clampField('character_string3', 0), '0')
|
||
})
|
||
|
||
test('date 型:YYYY-MM-DD 原样,脏值截到 10 字', () => {
|
||
assert.equal(clampField('date4', '2026-02-14'), '2026-02-14')
|
||
assert.equal(clampField('date4', '2026-02-14 12:30:00'), '2026-02-14')
|
||
})
|
||
|
||
test('空白与换行会被压成一行(微信不接受换行)', () => {
|
||
assert.equal(clampField('thing1', ' 晴天\n 周杰伦 '), '晴天 周杰伦')
|
||
assert.equal(clampField('thing1', null), '')
|
||
assert.equal(clampField('thing1', undefined), '')
|
||
})
|
||
|
||
test('SONG_SELECTED 的内置字段映射:每个字段名后缀都要跟发出去的值类型匹配', () => {
|
||
// 字段映射的唯一来源是 server/utils/wechatSubscribeTemplates.ts(那里有更完整的测试)
|
||
const fields = {
|
||
thing2: 'songTitle',
|
||
date4: 'playDate',
|
||
time1: 'playTime',
|
||
character_string3: 'sequence',
|
||
phrase5: 'status'
|
||
}
|
||
const vars = {
|
||
songTitle: '晴天',
|
||
playDate: '2026-02-14',
|
||
playTime: '2026-02-14 12:30',
|
||
sequence: '1',
|
||
status: '已排期'
|
||
}
|
||
const out = {}
|
||
for (const key of Object.keys(fields)) out[key] = clampField(key, vars[fields[key]])
|
||
|
||
assert.equal(out.thing2, '晴天')
|
||
assert.equal(out.date4, '2026-02-14')
|
||
assert.match(out.time1, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)
|
||
assert.equal(out.character_string3, '1')
|
||
assert.equal(len(out.phrase5), 5) // “已排期” 3 字 → 补齐到 5 字,避开 47003
|
||
// 不能有任何字段是空值:空值同样会被微信拒
|
||
for (const key of Object.keys(out)) assert.ok(out[key].length > 0, key + ' 不能为空')
|
||
})
|
||
|
||
test('SONG_REJECTED 的内置字段映射:长驳回原因必须裁进 thing 的 20 字内', () => {
|
||
/*
|
||
* 模板字段(站长在公众平台选好的):
|
||
* 审核内容 = thing6 → songTitle
|
||
* 驳回原因 = thing4 → reason
|
||
* 驳回原因是管理员手填的自由文本,经常远超 20 字;thing 超长是整条消息 47003 的头号原因,
|
||
* 所以这里专门盯住「长原因不会被原样发出去」。
|
||
*/
|
||
const fields = { thing6: 'songTitle', thing4: 'reason' }
|
||
const vars = {
|
||
songTitle: '晴天 - 周杰伦',
|
||
reason:
|
||
'已有同名单曲,暂不重复收录;另外上传的音频码率过低,建议重新投稿 320kbps 版本后再试一次'
|
||
}
|
||
const out = {}
|
||
for (const key of Object.keys(fields)) out[key] = clampField(key, vars[fields[key]])
|
||
|
||
assert.equal(out.thing6, '晴天 - 周杰伦')
|
||
assert.ok(len(out.thing4) <= 20)
|
||
assert.ok(out.thing4.endsWith('…'))
|
||
for (const key of Object.keys(out)) assert.ok(out[key].length > 0, key + ' 不能为空')
|
||
})
|
||
|
||
test('内置模板与线上代码一致(直接读源码,防止映射被改坏没人发现)', async () => {
|
||
/*
|
||
* 模板定义现在的唯一来源是 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/utils/wechatSubscribeTemplates.ts', import.meta.url)),
|
||
'utf8'
|
||
)
|
||
const block = (kind) => {
|
||
const m = src.match(new RegExp(kind + ':\\s*\\{([\\s\\S]*?)\\n \\}'))
|
||
assert.ok(m, '源码里找不到 ' + kind + ' 的内置模板')
|
||
return m[1]
|
||
}
|
||
|
||
const rejected = block('SONG_REJECTED')
|
||
assert.match(rejected, /erAK99M38ZeD-F1mOIgN2M7iAPVTU0YXo-adlkSwg_c/)
|
||
assert.match(rejected, /thing6:\s*'songTitle'/)
|
||
assert.match(rejected, /thing4:\s*'reason'/)
|
||
|
||
const selected = block('SONG_SELECTED')
|
||
assert.match(selected, /oLTAefO92xYYlGRYmcsm5AfUUlUI5IKFfXhIVNrItSk/)
|
||
for (const f of ['thing2', 'date4', 'time1', 'character_string3', 'phrase5']) {
|
||
assert.match(selected, new RegExp(f + ":\\s*'"), f + ' 的映射丢了')
|
||
}
|
||
})
|
||
|
||
test('beijingNow 是合法的 time 型值,且与北京时间一致', () => {
|
||
assert.match(beijingNow(), /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)
|
||
// beijingNow() 内部按 UTC+8 取值,用同一算法反推对比
|
||
const t = new Date(Date.now() + 8 * 3600 * 1000)
|
||
const p = (n) => (n < 10 ? '0' + n : String(n))
|
||
assert.equal(
|
||
beijingNow(),
|
||
`${t.getUTCFullYear()}-${p(t.getUTCMonth() + 1)}-${p(t.getUTCDate())} ${p(t.getUTCHours())}:${p(t.getUTCMinutes())}`
|
||
)
|
||
})
|