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