Files
BYW-VoiceHub/server/services/wechatSubscribeService.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

326 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
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'
/**
* 微信**订阅消息** —— 小程序唯一能主动触达用户的官方通道。
*
* ⚠️ 平台侧的四条硬限制(不是实现问题,绕不过去):
* 1. 必须由**用户点击**触发 `wx.requestSubscribeMessage` 授权,不能静默订阅;
* 2. 「一次性订阅」= **一次授权只能发一条**,发完要用户再点一次;
* 3. 只有**绑定了微信**的账号才有 openid(`userIdentities.provider = 'wechat-mp'`),
* 纯账号密码用户发不出去(小程序那边要引导他们先绑微信);
* 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": { "thing2": "songTitle", "time1": "playTime" } }
* }
* `fields` 的**键**是模板里的字段名(公众平台选用模板时能看到,如 thing1/time2/character_string3),
* **值**是变量名,取自调用方传进来的 vars(songTitle / message / time 是内置的)。
*/
const PROVIDER = 'wechat-mp'
const TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/token'
const SEND_URL = 'https://api.weixin.qq.com/cgi-bin/message/subscribe/send'
type TemplateConf = { id: string; fields?: Record<string, string> }
/**
* **内置默认模板**(站长在公众平台选好的,可直接用;环境变量里的配置会按键覆盖它)。
*
* SONG_REJECTED = 歌曲被驳回:
* 模板字段「审核内容」= thing6 → songTitle
* 模板字段「驳回原因」= 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'
}
}
}
/** 解析模板配置:内置默认 + 环境变量(同键以环境变量为准;坏 JSON 只报错一次) */
let templatesCache: Record<string, TemplateConf> | null = null
export function getSubscribeTemplates(): Record<string, TemplateConf> {
if (templatesCache) return templatesCache
const merged: Record<string, TemplateConf> = Object.assign({}, DEFAULT_TEMPLATES)
const raw = process.env.WECHAT_MINI_SUBSCRIBE_TEMPLATES
if (!raw) {
templatesCache = merged
return templatesCache
}
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
Object.keys(parsed).forEach((k) => {
const conf = parsed[k]
if (!conf || !conf.id) return
const base = merged[k]
/*
* 环境变量**按键覆盖内置模板**,但只覆盖它给到的部分:
* · 给了 id 但没给 fields → 沿用内置的字段映射(否则会退化成
* thing1/time2/thing3 的默认映射,发给微信直接 47003);
* · 给了 fields → 用它。
* 这样"只想换个模板 ID"时不用把 fields 一起抄一遍。
*/
merged[k] = {
id: conf.id,
fields: conf.fields && typeof conf.fields === 'object' ? conf.fields : base && base.fields
}
})
}
} catch (err) {
console.error('[WeChatSubscribe] WECHAT_MINI_SUBSCRIBE_TEMPLATES 不是合法 JSON,已忽略(仍用内置默认模板)')
}
templatesCache = merged
return templatesCache
}
/** 有没有配置任何模板(没配置时所有发送都直接跳过) */
export function isWechatSubscribeEnabled(): boolean {
return Object.keys(getSubscribeTemplates()).length > 0
}
/** 模板 ID 列表(给小程序去申请授权用) */
export function getSubscribeTemplateIds(): string[] {
return Object.values(getSubscribeTemplates())
.map((t) => t && t.id)
.filter((id): id is string => !!id)
}
// ---------------- access_token(2 小时有效,必须缓存) ----------------
let tokenCache: { token: string; expiresAt: number } | null = null
/** 提前 5 分钟过期,避免边界上用到刚失效的 token */
const TOKEN_SAFETY_MS = 5 * 60 * 1000
export async function getMiniAccessToken(force = false): Promise<string | null> {
if (!force && tokenCache && tokenCache.expiresAt > Date.now()) return tokenCache.token
const appid = process.env.WECHAT_MINI_APPID
const secret = process.env.WECHAT_MINI_SECRET
if (!appid || !secret) {
console.error('[WeChatSubscribe] 未配置 WECHAT_MINI_APPID / WECHAT_MINI_SECRET')
return null
}
try {
/*
* 与 jscode2session 同一类坑:微信这个接口的 content-type 也不一定是 application/json,
* ofetch 不解析就返回字符串(线上「微信登录一直 401」的根因就是这个)。
* 这里显式 responseType: 'json',并且再做一次字符串兜底解析。
*/
let res: any = await $fetch(TOKEN_URL, {
query: { grant_type: 'client_credential', appid, secret },
timeout: 8000,
responseType: 'json'
})
if (typeof res === 'string') {
try {
res = JSON.parse(res)
} catch {
res = null
}
}
if (!res || !res.access_token) {
console.error('[WeChatSubscribe] 获取 access_token 失败:', res?.errcode, res?.errmsg)
return null
}
tokenCache = {
token: res.access_token,
expiresAt: Date.now() + Math.max(60, (res.expires_in || 7200) * 1000) - TOKEN_SAFETY_MS
}
return tokenCache.token
} catch (err: any) {
console.error('[WeChatSubscribe] 获取 access_token 异常:', err?.message)
return null
}
}
// ---------------- 取值 / 裁剪 ----------------
// clampField / beijingNow / buildWechatTimeValue 都在 ~/server/utils/wechatSubscribeFields.ts,
// 顶部已 import + re-export(那里的注释写清了每种字段类型的微信限制)。
/** 用模板配置 + 变量拼出微信要的 data */
export function buildTemplateData(
conf: TemplateConf,
vars: Record<string, unknown>
): Record<string, { value: string }> {
const fields = conf.fields || { thing1: 'songTitle', time2: 'time', thing3: 'message' }
const all: Record<string, unknown> = Object.assign({ time: beijingNow() }, vars)
const data: Record<string, { value: string }> = {}
for (const key of Object.keys(fields)) {
data[key] = { value: clampField(key, all[fields[key]]) }
}
return data
}
/** 取用户的小程序 openid(没绑微信就没有) */
export async function getWechatOpenid(userId: number): Promise<string | null> {
try {
const rows = await db
.select({ providerUserId: userIdentities.providerUserId })
.from(userIdentities)
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.provider, PROVIDER)))
.limit(1)
return rows[0]?.providerUserId || null
} catch (err: any) {
console.error('[WeChatSubscribe] 查询 openid 失败:', err?.message)
return null
}
}
// ---------------- 发送 ----------------
export type SendResult = { ok: boolean; errcode?: number; errmsg?: string; skipped?: string }
/**
* 给某个用户发一条订阅消息。
* **永不抛异常**(通知失败不该影响主流程),结果用返回值 + 日志体现。
*
* @param kind 模板键(与 WECHAT_MINI_SUBSCRIBE_TEMPLATES 里的键对应)
* @param vars 变量:songTitle / message / time 为内置,其余按模板 fields 配置取
*/
export async function sendWechatSubscribeToUser(
userId: number,
kind: string,
vars: Record<string, unknown>
): Promise<SendResult> {
try {
const conf = getSubscribeTemplates()[kind]
if (!conf || !conf.id) return { ok: false, skipped: 'no-template' }
// 用户的通知总开关(与 MeoW / 邮件共用同一份设置)
const settings = await db
.select({ enabled: notificationSettings.enabled })
.from(notificationSettings)
.where(eq(notificationSettings.userId, userId))
.limit(1)
if (settings[0] && settings[0].enabled === false) {
return { ok: false, skipped: 'disabled-by-user' }
}
const openid = await getWechatOpenid(userId)
if (!openid) return { ok: false, skipped: 'no-wechat-openid' }
const token = await getMiniAccessToken()
if (!token) return { ok: false, skipped: 'no-access-token' }
const data = buildTemplateData(conf, Object.assign({ songTitle: '宝一外校园点歌系统' }, vars))
return await postSubscribe(token, openid, conf.id, data, userId, kind)
} catch (err: any) {
console.error('[WeChatSubscribe] 发送异常:', err?.message)
return { ok: false, errcode: -1, errmsg: err?.message }
}
}
async function postSubscribe(
token: string,
openid: string,
templateId: string,
data: Record<string, { value: string }>,
userId: number,
kind: string
): Promise<SendResult> {
let res: any = await $fetch(SEND_URL, {
method: 'POST',
query: { access_token: token },
body: {
touser: openid,
template_id: templateId,
page: 'pages/home/home',
data,
miniprogram_state: process.env.NODE_ENV === 'production' ? 'formal' : 'developer'
},
timeout: 8000,
responseType: 'json'
})
if (typeof res === 'string') {
try {
res = JSON.parse(res)
} catch {
res = { errcode: -1, errmsg: 'unparsable response' }
}
}
const errcode = res?.errcode
if (errcode === 0) {
console.log('[WeChatSubscribe] 已发送 ' + kind + ' → user ' + userId)
return { ok: true, errcode: 0 }
}
/*
* 43101 = 用户拒收 / 没有剩余授权次数 —— 一次性订阅的正常状态
* (用户没点授权、或上一次已经用掉了)。属于**预期内**,只记 info。
*/
if (errcode === 43101) {
console.log('[WeChatSubscribe] ' + kind + ' → user ' + userId + ' 没有可用授权(43101),跳过')
} else {
console.error(
'[WeChatSubscribe] 发送失败 ' + kind + ' → user ' + userId + ': ' + errcode + ' ' + res?.errmsg
)
}
return { ok: false, errcode, errmsg: res?.errmsg }
}
/** 测试用:按模板 ID 直发,把微信的原始响应带回去(字段映射对不对一眼看出来) */
export async function sendRawSubscribe(
openid: string,
templateId: string,
data: Record<string, { value: string }>
): Promise<SendResult & { raw?: any }> {
try {
const token = await getMiniAccessToken()
if (!token) return { ok: false, skipped: 'no-access-token' }
const result = await postSubscribe(token, openid, templateId, data, 0, 'TEST')
return Object.assign({ raw: { errcode: result.errcode, errmsg: result.errmsg } }, result)
} catch (err: any) {
return { ok: false, errcode: -1, errmsg: err?.message }
}
}