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
+3 -1
View File
@@ -155,7 +155,9 @@ export default defineEventHandler(async (event) => {
{
title: schedule.song.title,
artist: schedule.song.artist,
playDate: schedule.playDate
playDate: schedule.playDate,
// 微信「歌曲排期」订阅消息的排期序号字段要它
sequence: schedule.sequence
},
clientIP
)
@@ -61,7 +61,8 @@ export default defineEventHandler(async (event) => {
const notificationsToSend: Array<{
requesterId: number
songId: number
songInfo: { title: string; artist: string; playDate: Date }
// sequence 要给微信「歌曲排期」订阅消息的排期序号字段用
songInfo: { title: string; artist: string; playDate: Date; sequence?: number | null }
}> = []
// 开始事务
@@ -119,7 +120,8 @@ export default defineEventHandler(async (event) => {
songInfo: {
title: song.title,
artist: song.artist,
playDate: playDate
playDate: playDate,
sequence: item.sequence
}
})
+3 -1
View File
@@ -102,7 +102,9 @@ export default defineEventHandler(async (event) => {
{
title: draft.song.title,
artist: draft.song.artist,
playDate: draft.playDate
playDate: draft.playDate,
// 微信「歌曲排期」订阅消息的排期序号字段要它
sequence: draft.sequence
},
clientIP
)
+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)
+20 -2
View File
@@ -12,7 +12,7 @@ import {
} from '~/drizzle/schema'
import { and, eq, gte, inArray } from 'drizzle-orm'
import { sendBatchMeowNotifications, sendMeowNotificationToUser } from './meowNotificationService'
import { sendWechatSubscribeToUser } from './wechatSubscribeService'
import { buildWechatTimeValue, sendWechatSubscribeToUser } from './wechatSubscribeService'
import { sendBatchEmailNotifications, sendEmailNotificationToUser } from './smtpService'
import { formatDateTime, getBeijingTime } from '~/utils/timeUtils'
@@ -122,6 +122,8 @@ export async function createSongSelectedNotification(
title: string
artist: string
playDate: Date
/** 排期在该时段里的序号(微信「歌曲排期」模板的排期序号字段用) */
sequence?: number | null
},
ipAddress?: string
) {
@@ -207,7 +209,23 @@ export async function createSongSelectedNotification(
}
// 同步发送 微信订阅消息(只有绑了微信、且刚点过授权的用户收得到,见 wechatSubscribeService)
try {
await sendWechatSubscribeToUser(userId, 'SONG_SELECTED', { songTitle: songInfo.title, message })
/*
* 微信模板字段比站内通知挑数据:thing/date/time/character_string 各有格式要求,
* 空值或格式不对会让**整条**订阅消息被拒(47003),所以这里把每个字段都算好再传。
*/
const playTime = isPlayTimeEnabled && schedule?.playTime ? schedule.playTime : null
await sendWechatSubscribeToUser(userId, 'SONG_SELECTED', {
songTitle: songInfo.title,
// date 型:'YYYY-MM-DD'
playDate: formatDate(songInfo.playDate),
// time 型:'YYYY-MM-DD HH:mm',有排期时段就取它的开始时间('12:30-13:00' 也能解析)
playTime: buildWechatTimeValue(songInfo.playDate, playTime?.startTime || playTime?.endTime),
// phrase 型:5 个以内纯汉字;“已排期”只有 3 个字,clampField 会补全角空格
status: '已排期',
// character_string 型:只允许数字/字母,没有序号时不传(避免空值 47003)
sequence: songInfo.sequence == null ? undefined : String(songInfo.sequence),
message
})
} catch (error) {
console.error('发送微信订阅消息失败:', error)
}
+44 -28
View File
@@ -1,6 +1,11 @@
import { db } from '~/drizzle/db'
import { notificationSettings, userIdentities } from '~/drizzle/schema'
import { and, eq } from 'drizzle-orm'
// ⚠️ 服务端 utils 在仓库根的 server/ 下,Nuxt 4 里 `~` 指向 app/,必须用 `~~`
import { beijingNow, clampField } from '~~/server/utils/wechatSubscribeFields'
// 字段裁剪规则集中在 utils/wechatSubscribeFields.ts(纯函数,可单独测),这里只做转发
export { beijingNow, buildWechatTimeValue, clampField } from '~~/server/utils/wechatSubscribeFields'
/**
* 微信**订阅消息** —— 小程序唯一能主动触达用户的官方通道。
@@ -10,15 +15,22 @@ import { and, eq } from 'drizzle-orm'
* 2. 「一次性订阅」= **一次授权只能发一条**,发完要用户再点一次;
* 3. 只有**绑定了微信**的账号才有 openid(`userIdentities.provider = 'wechat-mp'`),
* 纯账号密码用户发不出去(小程序那边要引导他们先绑微信);
* 4. 模板字段的类型和长度由模板决定:`thing` 最长 20 字、`time` 必须 `YYYY-MM-DD HH:mm`,
* 字段名与长度写错会被微信拒(47003),所以这里按模板配置逐个裁剪。
* 4. 模板字段的类型和长度由模板决定,**某一个字段超长会让整条消息被拒**(47003,报错里那个
* 字段名是微信挑的,不代表只有它有问题)。各类型的限制:
* · thing 20 字(本服务截 19 字 + 省略号)
* · phrase 5 个以内纯汉字(恰好 5 个在部分模板上会被拒,见 utils/wechatSubscribeFields.ts)
* · time 必须是 'YYYY-MM-DD HH:mm'
* · date 必须是 'YYYY-MM-DD'
* · character_string 32 字(只能数字、字母)
* · number/amount 32 字(只能数字)
* 所以这里按模板配置逐个裁剪;另外**空值也会 47003**,拿不到值的字段用占位文案兜住。
*
* 配置(服务器环境变量;模板一项缺失就整体静默关闭,不影响其它通知渠道):
* WECHAT_MINI_APPID / WECHAT_MINI_SECRET —— 已有(微信登录在用)
* WECHAT_MINI_SUBSCRIBE_TEMPLATES —— JSON:
* {
* "SONG_PLAYED": { "id": "模板ID", "fields": { "thing1": "songTitle", "time2": "time", "thing3": "message" } },
* "SONG_SELECTED": { "id": "模板ID", "fields": { "thing1": "songTitle", "time2": "time" } }
* "SONG_SELECTED": { "id": "模板ID", "fields": { "thing2": "songTitle", "time1": "playTime" } }
* }
* `fields` 的**键**是模板里的字段名(公众平台选用模板时能看到,如 thing1/time2/character_string3),
* **值**是变量名,取自调用方传进来的 vars(songTitle / message / time 是内置的)。
@@ -38,11 +50,38 @@ type TemplateConf = { id: string; fields?: Record<string, string> }
* 模板字段「驳回原因」= thing4 → reason
* ⚠️ thing 类型字段微信限 **20 个字符**,超了会被拒(47003),
* 所以 clampField 会截断(超过 20 字时截 19 字 + 省略号)。
*
* SONG_SELECTED = 歌曲被选中(管理员把歌排进排期 / 发布排期)。
* 站点上这条通知一直是发出去的,但之前**没有**内置微信模板,所以小程序那边收不到;
* 站长 2026-02 在公众平台选好了「歌曲排期」模板,这里补上默认映射:
* 模板字段「歌曲名称」= thing2 → songTitle
* 模板字段「播放日期」= date4 → playDate('YYYY-MM-DD',与站内通知里的日期一致)
* 模板字段「播放时间」= time1 → playTime('YYYY-MM-DD HH:mm',优先取排期时段的开始时间)
* 模板字段「排期序号」= character_string3 → sequence
* 模板字段「排期状态」= phrase5 → status(固定「已排期」,phrase 必须恰好 5 个汉字)
* ⚠️ 字段名(thing2/date4/…)必须和公众平台模板里的一模一样,否则微信 47003。
* 站长若选了别的字段名,用环境变量 WECHAT_MINI_SUBSCRIBE_TEMPLATES 覆盖 fields 即可,
* 不用改代码(例:`{"SONG_SELECTED":{"id":"oS4Ev…","fields":{"thing1":"songTitle"}}}`)。
*
* 另外一个**必须让用户知道**的平台特性:模板 ID 变了 = 用户要重新授权。
* 老用户之前点的授权只对旧模板 ID 有效,新增模板后他们得再点一次「开启微信通知」,
* 否则这条消息会以 43101(无可用授权)静默跳过 —— 那是正常状态,不是 bug。
*/
const DEFAULT_TEMPLATES: Record<string, TemplateConf> = {
SONG_REJECTED: {
id: 'erAK99M38ZeD-F1mOIgN2M7iAPVTU0YXo-adlkSwg_c',
fields: { thing6: 'songTitle', thing4: 'reason' }
},
SONG_SELECTED: {
// 站长提供的「歌曲排期」模板 ID
id: 'oLTAefO92xYYlGRYmcsm5AfUUlUI5IKFfXhIVNrItSk',
fields: {
thing2: 'songTitle',
date4: 'playDate',
time1: 'playTime',
character_string3: 'sequence',
phrase5: 'status'
}
}
}
@@ -145,31 +184,8 @@ export async function getMiniAccessToken(force = false): Promise<string | null>
}
// ---------------- 取值 / 裁剪 ----------------
/** 模板字段的长度限制(微信侧的限制,超了直接 47003) */
function clampField(key: string, value: unknown): string {
const text = value === null || value === undefined ? '' : String(value)
// thing:20 字以内;超了截 19 字 + 省略号(省略号也算 1 个字,正好 20)
if (/^thing/.test(key)) return text.length > 20 ? text.slice(0, 19) + '…' : text
return text.slice(0, 32)
}
/** '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())
)
}
// clampField / beijingNow / buildWechatTimeValue 都在 ~/server/utils/wechatSubscribeFields.ts,
// 顶部已 import + re-export(那里的注释写清了每种字段类型的微信限制)。
/** 用模板配置 + 变量拼出微信要的 data */
export function buildTemplateData(
+103
View File
@@ -0,0 +1,103 @@
/**
* 微信**订阅消息**字段的取值规则(纯函数,不依赖 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())
)
}