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:
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user