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
+38 -8
View File
@@ -13,12 +13,15 @@ import {
* 1. access_token 能不能拿到(appid / secret 是否有效)
* 2. 目标用户有没有绑微信(有没有 openid;纯密码用户必然是 null)
* 3. 模板字段名 / 长度对不对(微信会回 47003 / 43101 之类的错误码)
* 4. 微信返回的原文
* 4. 微信返回的原文(data 里能直接看到每个字段最终发了什么值)
*
* body(都可省):
* { userId?: number, kind?: string, openid?: string, templateId?: string, data?: object }
* { userId?: number, kind?: string, openid?: string, templateId?: string,
* vars?: object, data?: object }
* · 默认给自己发、用配置里的第一个模板
* · 想直接指定 openid / 模板 ID / data 也可以(用于排查)
* · vars 覆盖**变量**(songTitle / playDate / playTime / sequence / status / message…),
* 仍走 buildTemplateData → clampField,所以看到的是真发出去的值
* · data 直接当微信的 data 用(最后的兜底排查手段)
*/
export default defineEventHandler(async (event) => {
const user = event.context.user
@@ -41,7 +44,7 @@ export default defineEventHandler(async (event) => {
return {
ok: false,
reason: 'no-template',
hint: '服务器环境变量 WECHAT_MINI_SUBSCRIBE_TEMPLATES 没配置模板 ID(见 wechatSubscribeService.ts 头部注释)',
hint: '没有可用的模板 ID:内置默认模板见 server/services/wechatSubscribeService.ts 的 DEFAULT_TEMPLATES,也可用环境变量 WECHAT_MINI_SUBSCRIBE_TEMPLATES 配置',
configuredKinds: kinds,
tokenOk: !!token,
openid: openid ? openid.slice(0, 6) + '****' : null
@@ -60,13 +63,40 @@ export default defineEventHandler(async (event) => {
}
}
/*
* 各通知类型的**真实例子**:拿这些值走一遍 buildTemplateData → clampField,
* 站长看到的 data 就是线上真会发出去的样子(比 '测试歌曲' 更能验字段名/长度)。
*/
const SAMPLE_VARS: Record<string, Record<string, unknown>> = {
SONG_SELECTED: {
songTitle: '测试歌曲《晴天》',
playDate: '2026-02-14',
playTime: '2026-02-14 12:30',
sequence: '1',
status: '已排期',
message: '您投稿的歌曲《晴天》已被安排播放,播放日期:2026-02-14。'
},
SONG_REJECTED: {
songTitle: '测试歌曲《晴天》',
reason: '已有同名单曲,暂不重复收录',
message: '您投稿的歌曲《晴天》未通过审核。'
}
}
const data =
body?.data && typeof body.data === 'object'
? body.data
: buildTemplateData(conf || { id: templateId }, {
songTitle: '测试歌曲',
message: '这是一条来自宝一外校园点歌系统的测试通知'
})
: buildTemplateData(
conf || { id: templateId },
Object.assign(
{
songTitle: '测试歌曲',
message: '这是一条来自宝一外校园点歌系统的测试通知'
},
SAMPLE_VARS[kind] || {},
body?.vars && typeof body.vars === 'object' ? body.vars : {}
)
)
const result = await sendRawSubscribe(openid, templateId, data)