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 } /** * **内置默认模板**(站长在公众平台选好的,可直接用;环境变量里的配置会按键覆盖它)。 * * 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 = { 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 | null = null export function getSubscribeTemplates(): Record { if (templatesCache) return templatesCache const merged: Record = 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 { 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 ): Record { const fields = conf.fields || { thing1: 'songTitle', time2: 'time', thing3: 'message' } const all: Record = Object.assign({ time: beijingNow() }, vars) const data: Record = {} 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 { 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 ): Promise { try { const conf = getSubscribeTemplates()[kind] if (!conf || !conf.id) { // 这四条跳过路径以前是静默 return,线上排查时日志里什么都没有,只能靠猜。 // 现在每条都留一行,grep [WeChatSubscribe] 就能看出卡在哪一步。 console.error(`[WeChatSubscribe] ${kind} → user ${userId} 跳过:没有配置模板 ID(no-template)`) 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) { console.warn(`[WeChatSubscribe] ${kind} → user ${userId} 跳过:该用户的通知总开关是关的(disabled-by-user)`) return { ok: false, skipped: 'disabled-by-user' } } const openid = await getWechatOpenid(userId) if (!openid) { // 用户没有绑定微信(userIdentities 里没有 provider='wechat-mp' 的行)→ 永远发不出去 console.warn(`[WeChatSubscribe] ${kind} → user ${userId} 跳过:该用户没有绑定微信 openid(no-wechat-openid)`) return { ok: false, skipped: 'no-wechat-openid' } } const token = await getMiniAccessToken() if (!token) { console.error( `[WeChatSubscribe] ${kind} → user ${userId} 跳过:拿不到小程序 access_token(no-access-token,检查 WECHAT_MINI_APPID / WECHAT_MINI_SECRET)` ) 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, userId: number, kind: string ): Promise { 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 ): Promise { 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 } } }