Files
BYW-VoiceHub/test/wechatSubscribeFields.test.mjs
T
Henry_Du d1b643bd93 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 通过。
2026-10-11 16:02:33 +08:00

178 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 微信订阅消息字段裁剪规则的单元测试(`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())}`
)
})