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:
2026-10-11 00:04:14 +08:00
parent ebf1c3a127
commit 3eff710d5d
31 changed files with 659 additions and 189 deletions
+123
View File
@@ -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())}`
)
})