Files
BYW-VoiceHub/server/utils/wechatSubscribeFields.ts
T
Henry_Du 3eff710d5d 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 变更后老用户需重新授权。
2026-10-11 00:04:14 +08:00

104 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
/**
* 微信**订阅消息**字段的取值规则(纯函数,不依赖 Nitro / 数据库,可直接用 node 跑验证)。
*
* 为什么单独放一个文件:微信的字段校验是**整条消息级别**的 —— 只要有一个字段超长、
* 格式不对或为空,整条消息发不出去,微信只回一个 `47003 argument invalid! data.xxx.value invalid`
* (而且报错里点名的字段不一定是真正有问题的那一个)。所以这些裁剪规则必须能单独测。
*
* 各类型限制(公众平台模板字段名后缀就是类型):
* thing 20 字
* phrase 5 个以内纯汉字(官方文档的写法;社区反馈**恰好 5 个汉字**在部分模板上会报
* `47003 data.phraseN.value invalid`,实测 4 个汉字正常)
* time 必须是 'YYYY-MM-DD HH:mm'
* date 必须是 'YYYY-MM-DD'
* character_string 32 字(只能数字、字母)
* number/amount 32 字(只能数字)
*/
/** 各类型字段的长度上限 */
export const FIELD_MAX_LEN: Record<string, number> = {
thing: 20,
phrase: 5,
time: 24,
date: 10,
character_string: 32,
number: 32,
amount: 32,
digit: 32,
letter: 32,
symbol: 5
}
/** 字段名 → 类型(thing1/character_string3 → thing/character_string) */
export function fieldType(key: string): string {
return String(key || '').replace(/[0-9]+$/, '')
}
/**
* 按字段类型裁剪成微信能接受的值。
* ⚠️ 返回空字符串仍然会被微信拒,调用方要保证变量有值(见 wechatSubscribeService 的占位逻辑)。
*/
export function clampField(key: string, value: unknown): string {
const raw = value === null || value === undefined ? '' : String(value)
// 微信不认换行,先压成一行(站内通知的 message 是多行拼的)
const text = raw.replace(/\s+/g, ' ').trim()
const type = fieldType(key)
if (type === 'thing') {
// thing:20 字以内;超了截 19 字 + 省略号(省略号也算 1 个字,正好 20)
return Array.from(text).length > 20 ? Array.from(text).slice(0, 19).join('') + '…' : text
}
if (type === 'phrase') {
/*
* phrase = 5 个以内纯汉字。官方文档是「5个以内」,但社区反馈**恰好 5 个汉字**在部分模板上
* 会被判 47003 invalid(4 个正常,见 wxopen.club/topic/61daa41e947eea50368f28ec),所以
* 按最保守的写法统一补到 5 个字:短了补全角空格,长到 5 个字保持原样,超了截断。
* “已排期”只有 3 个字 —— 真发出去不是被拒就是显示得莫名其妙,这里补齐后不会超长。
*/
const chars = Array.from(text)
if (chars.length >= 5) return chars.slice(0, 5).join('')
let padded = text
while (Array.from(padded).length < 5) padded += ' '
return padded
}
const max = FIELD_MAX_LEN[type] || 32
return text.slice(0, max)
}
/** 北京时间(UTC+8)的 'YYYY-MM-DD' */
export function beijingDateString(date: Date): string {
const t = new Date(date.getTime() + 8 * 60 * 60 * 1000)
const p = (n: number) => (n < 10 ? '0' + n : String(n))
return t.getUTCFullYear() + '-' + p(t.getUTCMonth() + 1) + '-' + p(t.getUTCDate())
}
/**
* 把播放日期 + 排期时段揉成微信 time 型字段要求的 'YYYY-MM-DD HH:mm'。
* 支持 '12:30'、'12:30-13:00'、'12:30~13:00'、'12:30'(全角冒号),取**开始时间**。
* 解析不出来时退化成当天 00:00 —— time 型字段不能为空,否则整条消息 47003。
*/
export function buildWechatTimeValue(date: Date, timeRange?: string | null): string {
const day = beijingDateString(date)
const m = String(timeRange || '').match(/(\d{1,2})\s*[::]\s*(\d{2})/)
if (!m) return day + ' 00:00'
const hh = String(Math.min(23, parseInt(m[1], 10))).padStart(2, '0')
return day + ' ' + hh + ':' + m[2]
}
/** 'YYYY-MM-DD HH:mm'(微信 time 型字段要求的格式,北京时间) */
export function beijingNow(): string {
const now = new Date(Date.now() + 8 * 60 * 60 * 1000)
const p = (n: number) => (n < 10 ? '0' + n : String(n))
return (
now.getUTCFullYear() +
'-' +
p(now.getUTCMonth() + 1) +
'-' +
p(now.getUTCDate()) +
' ' +
p(now.getUTCHours()) +
':' +
p(now.getUTCMinutes())
)
}