Files
BYW-VoiceHub/server/services/wechatSubscribeService.ts
T
Henry_Du a2ba15c3ca feat(notify): 微信订阅消息推送(驳回通知已对接)
- 新增 server/services/wechatSubscribeService.ts:access_token 缓存 + 模板配置 + 字段裁剪 + subscribe/send
  · 内置默认模板 SONG_REJECTED(模板 ID erAK**…,审核内容=thing6←歌名,驳回原因=thing4←驳回原因)
  · 环境变量 WECHAT_MINI_SUBSCRIBE_TEMPLATES 可按键覆盖默认模板
  · thing 字段按微信限制裁到 20 字(超长截 19 字 + 省略号),避免 47003
  · 发送永不抛异常;43101(用户没授权/没额度)只记 info
- 新增 server/api/user/wechat-subscribe-templates.get.ts:给小程序返回模板 ID(客户端要拿它去申请授权)
- 新增 server/api/admin/wechat/test-subscribe.post.ts:超管自测接口(token/openid/字段/微信原文一次看清)
- notificationService.ts:歌曲被选中/已播放/被驳回/系统通知 四处顺带发微信订阅消息
- .env.example + 部署流水线:WECHAT_MINI_SUBSCRIBE_TEMPLATES 可用仓库 Secret 下发
2026-09-27 13:55:55 +08:00

298 lines
11 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'
/**
* 微信**订阅消息** —— 小程序唯一能主动触达用户的官方通道。
*
* ⚠️ 平台侧的四条硬限制(不是实现问题,绕不过去):
* 1. 必须由**用户点击**触发 `wx.requestSubscribeMessage` 授权,不能静默订阅;
* 2. 「一次性订阅」= **一次授权只能发一条**,发完要用户再点一次;
* 3. 只有**绑定了微信**的账号才有 openid(`userIdentities.provider = 'wechat-mp'`),
* 纯账号密码用户发不出去(小程序那边要引导他们先绑微信);
* 4. 模板字段的类型和长度由模板决定:`thing` 最长 20 字、`time` 必须 `YYYY-MM-DD HH:mm`,
* 字段名与长度写错会被微信拒(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" } }
* }
* `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 字 + 省略号)。
*/
const DEFAULT_TEMPLATES: Record<string, TemplateConf> = {
SONG_REJECTED: {
id: 'erAK99M38ZeD-F1mOIgN2M7iAPVTU0YXo-adlkSwg_c',
fields: { thing6: 'songTitle', thing4: 'reason' }
}
}
/** 解析模板配置:内置默认 + 环境变量(同键以环境变量为准;坏 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) merged[k] = conf
})
}
} 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
}
}
// ---------------- 取值 / 裁剪 ----------------
/** 模板字段的长度限制(微信侧的限制,超了直接 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())
)
}
/** 用模板配置 + 变量拼出微信要的 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: 'VoiceHub' }, 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 }
}
}