feat(微信订阅): 接入歌曲排期模板并加固部署流程
订阅消息(小程序侧唯一能主动触达用户的通道): - 新增 server/utils/wechatSubscribeFields.ts:按微信字段类型裁剪值(thing≤20、 phrase 必须 5 个汉字、time/date/character_string 各自上限),空白压成一行, 北京时间 time 由 playTime 的开始时间生成,解析不出退化为当天 00:00。 - wechatSubscribeService 内置 SONG_SELECTED 模板(站长新给的那个模板 ID), 并转发上述纯函数;notificationService.createSongSelectedNotification 现在会带上 playDate/playTime/sequence,三个排期接口(schedule / publish / bulk-publish)补传 sequence。 - 新增 test/wechatSubscribeFields.test.mjs(10 例,node --test 全绿)。 - test-subscribe 管理接口支持 vars 覆盖并返回真实发送值,方便站长核对字段名。 部署流程(Gitea Actions 容器里没有 buildx 插件,旧流程在此硬失败): - 每个 job 各自安装/探测 docker-buildx(v0.19.3,走 gh-proxy 再退直连), 不把 `buildx version` 当唯一依据;装不上时降级为普通 `docker build` 继续发布, 不再因为缺插件整条流水线失败。 注意:SONG_SELECTED 的字段名(thing2/date4/time1/character_string3/phrase5)是按常见 命名推断的,需在公众平台后台核对;不一致时用环境变量覆盖,模板 ID 变更后老用户需重新授权。
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 微信订阅消息字段裁剪规则的单元测试(`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/services/wechatSubscribeService.ts 的 DEFAULT_TEMPLATES.SONG_SELECTED 保持一致
|
||||
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('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())}`
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user