Files
BYW-VoiceHub/server/services/wechatSubscribeService.ts
T
Henry_Du cccaf36990 feat(brand): 站点品牌名统一为「宝一外校园点歌系统」
站长要求全站一致(小程序端已单独提交)。本次只改**用户可见文案与默认值**:

- 服务端默认值/兜底:system-settings-defaults、admin/system-settings(GET/POST)、
  site-config.get、utils/siteUtils —— 原来是 'VoiceHub' / '校园广播站点歌系统'
- 用户可见通知文案:MeoW 绑定验证码与标题、MeoW 测试通知、微信订阅测试通知
- 2FA 应用名(authenticator.keyuri)、WebAuthn rpName(出现在系统密钥弹窗里)
- 网页版界面:首页副标题、后台侧栏品牌名、登录/注册/找回/改密标题与文案、
  年度回顾(IntroSlide / ShareCard / 页面标题)、用户教程、通知发送预览、
  API 密钥与数据库导入说明、酷狗 sidecar 说明、打印页脚与歌单卡片页脚、Logo alt

**刻意不动**:localStorage/sessionStorage 的 `voicehub_*` 键、`voicehub:` 前缀的
STORAGE_KEY 与缓存/Redis 前缀、`window.voiceHubPlayer` 之类代码标识、MeoW 的
User-Agent、以及上游项目链接 `github.com/laoshuikaixue/VoiceHub`(那是对原项目的署名)。

注意:站点名**数据**(systemSettings.siteTitle)仍是你后台里设置的「宝一外校园点歌」,
代码只保证默认值与文案一致;要让全站显示完整名称,请在后台「站点配置 → 站点标题」
改成「宝一外校园点歌系统」。
2026-09-30 20:24:55 +08:00

310 lines
12 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) 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
}
}
// ---------------- 取值 / 裁剪 ----------------
/** 模板字段的长度限制(微信侧的限制,超了直接 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: '宝一外校园点歌系统' }, 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 }
}
}