From a2ba15c3ca4591583697495e6bc63fa7e418c636 Mon Sep 17 00:00:00 2001 From: HenryDu8133 <813367384@qq.com> Date: Sun, 27 Sep 2026 13:55:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(notify):=20=E5=BE=AE=E4=BF=A1=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E6=B6=88=E6=81=AF=E6=8E=A8=E9=80=81=EF=BC=88=E9=A9=B3?= =?UTF-8?q?=E5=9B=9E=E9=80=9A=E7=9F=A5=E5=B7=B2=E5=AF=B9=E6=8E=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 下发 --- .env.example | 9 + .gitea/workflows/deploy.yml | 3 + .../api/admin/wechat/test-subscribe.post.ts | 92 ++++++ .../user/wechat-subscribe-templates.get.ts | 19 ++ server/services/notificationService.ts | 25 ++ server/services/wechatSubscribeService.ts | 297 ++++++++++++++++++ 6 files changed, 445 insertions(+) create mode 100644 server/api/admin/wechat/test-subscribe.post.ts create mode 100644 server/api/user/wechat-subscribe-templates.get.ts create mode 100644 server/services/wechatSubscribeService.ts diff --git a/.env.example b/.env.example index d471272..4ed5092 100644 --- a/.env.example +++ b/.env.example @@ -121,3 +121,12 @@ AGGREGATE_OAUTH_ENDPOINT= # 注意:这与上方聚合登录的 wx 通道不是一回事——小程序 openid 按 AppID 维度生成,两者不通用 WECHAT_MINI_APPID= WECHAT_MINI_SECRET= + +# 微信「订阅消息」模板(可选,但**想让通知发到微信里就必须配**) +# 公众平台「功能 → 订阅消息 → 选用模板」拿到模板 ID 后,按下面的 JSON 填: +# {"SONG_PLAYED":{"id":"模板ID","fields":{"thing1":"songTitle","time2":"time","thing3":"message"}}} +# 键是通知类型(SONG_SELECTED / SONG_PLAYED / SONG_REJECTED / SYSTEM_NOTICE), +# fields 的键是模板里的字段名(thing1/time2/... 选用模板时能看到),值是变量名 +# (内置变量:songTitle / message / time)。不配就等于关闭微信推送,其它渠道照常。 +# ⚠️ 平台限制:一次性订阅=一次授权只能发一条;且只有绑定了微信的账号才有 openid。 +WECHAT_MINI_SUBSCRIBE_TEMPLATES= diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 6b90d27..b33e229 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -975,6 +975,9 @@ jobs: upsert_env_key AGGREGATE_OAUTH_ENDPOINT "${AGGREGATE_OAUTH_ENDPOINT}" || MERGE_OK=0 upsert_env_key WECHAT_MINI_APPID "${WECHAT_MINI_APPID}" || MERGE_OK=0 upsert_env_key WECHAT_MINI_SECRET "${WECHAT_MINI_SECRET}" || MERGE_OK=0 + # 微信订阅消息模板(JSON)。仓库 Secret 里没设这项时 upsert_env_key 直接返回, + # 不会清掉服务器 .env 里手工配置的值。 + upsert_env_key WECHAT_MINI_SUBSCRIBE_TEMPLATES "${WECHAT_MINI_SUBSCRIBE_TEMPLATES}" || MERGE_OK=0 rm -f "${ENV_FILE}.tmp.$$" if [ "${MERGE_OK}" = "1" ]; then echo "[Runtime-env] 运行期配置已合并写入 ${ENV_FILE}(OAuth=${AGGREGATE_OAUTH_APP_ID:+已配置} WECHAT_MINI_APPID=${WECHAT_MINI_APPID:+已配置})" diff --git a/server/api/admin/wechat/test-subscribe.post.ts b/server/api/admin/wechat/test-subscribe.post.ts new file mode 100644 index 0000000..1a68ebd --- /dev/null +++ b/server/api/admin/wechat/test-subscribe.post.ts @@ -0,0 +1,92 @@ +import { + buildTemplateData, + getMiniAccessToken, + getSubscribeTemplates, + getWechatOpenid, + sendRawSubscribe +} from '~~/server/services/wechatSubscribeService' + +/** + * 微信订阅消息的**自测接口**(仅超级管理员)—— 站长部署完用它「试一下」。 + * + * 一次调用能看出四件事: + * 1. access_token 能不能拿到(appid / secret 是否有效) + * 2. 目标用户有没有绑微信(有没有 openid;纯密码用户必然是 null) + * 3. 模板字段名 / 长度对不对(微信会回 47003 / 43101 之类的错误码) + * 4. 微信返回的原文 + * + * body(都可省): + * { userId?: number, kind?: string, openid?: string, templateId?: string, data?: object } + * · 默认给自己发、用配置里的第一个模板 + * · 想直接指定 openid / 模板 ID / data 也可以(用于排查) + */ +export default defineEventHandler(async (event) => { + const user = event.context.user + if (!user || user.role !== 'SUPER_ADMIN') { + throw createError({ statusCode: 403, message: '只有超级管理员可以测试微信订阅消息' }) + } + + const body = await readBody(event) + const templates = getSubscribeTemplates() + const kinds = Object.keys(templates) + const kind = body?.kind || kinds[0] || '' + const conf = kind ? templates[kind] : null + const templateId = body?.templateId || conf?.id || '' + const targetUserId = Number(body?.userId) || Number(user.id) + + const token = await getMiniAccessToken() + const openid = body?.openid || (await getWechatOpenid(targetUserId)) + + if (!templateId) { + return { + ok: false, + reason: 'no-template', + hint: '服务器环境变量 WECHAT_MINI_SUBSCRIBE_TEMPLATES 没配置模板 ID(见 wechatSubscribeService.ts 头部注释)', + configuredKinds: kinds, + tokenOk: !!token, + openid: openid ? openid.slice(0, 6) + '****' : null + } + } + + if (!openid) { + return { + ok: false, + reason: 'no-wechat-openid', + hint: '该用户没有绑定微信(userIdentities 里没有 provider=wechat-mp 的记录),微信发不到他那里', + kind: kind, + templateId: templateId, + tokenOk: !!token, + targetUserId: targetUserId + } + } + + const data = + body?.data && typeof body.data === 'object' + ? body.data + : buildTemplateData(conf || { id: templateId }, { + songTitle: '测试歌曲', + message: '这是一条来自 VoiceHub 的测试通知' + }) + + const result = await sendRawSubscribe(openid, templateId, data) + + return { + ok: result.ok, + errcode: result.errcode, + errmsg: result.errmsg, + hint: + result.errcode === 0 + ? '发送成功,去微信里看「服务通知」' + : result.errcode === 43101 + ? '用户没有可用授权(一次性订阅:需要用户在小程序里点过「开启微信通知」才行)' + : '看 errmsg:47003 = 模板字段名/长度不对;40003 = openid 不对;40001 = token 失效', + configuredKinds: kinds, + kind: kind, + templateId: templateId, + targetUserId: targetUserId, + openid: openid.slice(0, 6) + '****', + tokenOk: !!token, + data: data, + raw: result.raw + } +}) diff --git a/server/api/user/wechat-subscribe-templates.get.ts b/server/api/user/wechat-subscribe-templates.get.ts new file mode 100644 index 0000000..67db452 --- /dev/null +++ b/server/api/user/wechat-subscribe-templates.get.ts @@ -0,0 +1,19 @@ +import { getSubscribeTemplateIds, isWechatSubscribeEnabled } from '~~/server/services/wechatSubscribeService' + +/** + * 小程序申请「订阅消息」授权时需要的模板 ID。 + * + * 模板 ID 不是秘密:它本来就要出现在客户端 `wx.requestSubscribeMessage({ tmplIds })` 里。 + * 没配置模板(环境变量 WECHAT_MINI_SUBSCRIBE_TEMPLATES 为空)时 enabled=false, + * 小程序那边会静默隐藏「开启微信通知」的入口。 + */ +export default defineEventHandler((event) => { + const user = event.context.user + if (!user) { + throw createError({ statusCode: 401, message: '未授权访问' }) + } + return { + enabled: isWechatSubscribeEnabled(), + tmplIds: getSubscribeTemplateIds() + } +}) diff --git a/server/services/notificationService.ts b/server/services/notificationService.ts index 716bb0d..828fe53 100644 --- a/server/services/notificationService.ts +++ b/server/services/notificationService.ts @@ -12,6 +12,7 @@ import { } from '~/drizzle/schema' import { and, eq, gte, inArray } from 'drizzle-orm' import { sendBatchMeowNotifications, sendMeowNotificationToUser } from './meowNotificationService' +import { sendWechatSubscribeToUser } from './wechatSubscribeService' import { sendBatchEmailNotifications, sendEmailNotificationToUser } from './smtpService' import { formatDateTime, getBeijingTime } from '~/utils/timeUtils' @@ -204,6 +205,12 @@ export async function createSongSelectedNotification( } catch (error) { console.error('发送 MeoW 通知失败:', error) } + // 同步发送 微信订阅消息(只有绑了微信、且刚点过授权的用户收得到,见 wechatSubscribeService) + try { + await sendWechatSubscribeToUser(userId, 'SONG_SELECTED', { songTitle: songInfo.title, message }) + } catch (error) { + console.error('发送微信订阅消息失败:', error) + } // 同步发送 邮件通知 try { @@ -323,6 +330,12 @@ export async function createSongPlayedNotification(songId: number, ipAddress?: s } catch (error) { console.error(`发送 MeoW 通知失败 (User: ${targetUserId}):`, error) } + // 同步发送 微信订阅消息(只有绑了微信、且刚点过授权的用户收得到,见 wechatSubscribeService) + try { + await sendWechatSubscribeToUser(targetUserId, 'SONG_PLAYED', { songTitle: song.title, message: userMessage }) + } catch (error) { + console.error('发送微信订阅消息失败:', error) + } // 同步发送 邮件通知 try { @@ -521,6 +534,12 @@ export async function createSongRejectedNotification( } catch (error) { console.error('发送 MeoW 通知失败:', error) } + // 同步发送 微信订阅消息(只有绑了微信、且刚点过授权的用户收得到,见 wechatSubscribeService) + try { + await sendWechatSubscribeToUser(userId, 'SONG_REJECTED', { songTitle: songInfo.title, reason: reason, message }) + } catch (error) { + console.error('发送微信订阅消息失败:', error) + } // 同步发送邮件通知 try { @@ -612,6 +631,12 @@ export async function createSystemNotification( } catch (error) { console.error('发送 MeoW 通知失败:', error) } + // 同步发送 微信订阅消息(只有绑了微信、且刚点过授权的用户收得到,见 wechatSubscribeService) + try { + await sendWechatSubscribeToUser(userId, 'SYSTEM_NOTICE', { songTitle: title, message: content }) + } catch (error) { + console.error('发送微信订阅消息失败:', error) + } // 同步发送邮件通知 try { diff --git a/server/services/wechatSubscribeService.ts b/server/services/wechatSubscribeService.ts new file mode 100644 index 0000000..c5fa14f --- /dev/null +++ b/server/services/wechatSubscribeService.ts @@ -0,0 +1,297 @@ +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 } + +/** + * **内置默认模板**(站长在公众平台选好的,可直接用;环境变量里的配置会按键覆盖它)。 + * + * SONG_REJECTED = 歌曲被驳回: + * 模板字段「审核内容」= thing6 → songTitle + * 模板字段「驳回原因」= thing4 → reason + * ⚠️ thing 类型字段微信限 **20 个字符**,超了会被拒(47003), + * 所以 clampField 会截断(超过 20 字时截 19 字 + 省略号)。 + */ +const DEFAULT_TEMPLATES: Record = { + SONG_REJECTED: { + id: 'erAK99M38ZeD-F1mOIgN2M7iAPVTU0YXo-adlkSwg_c', + fields: { thing6: 'songTitle', thing4: 'reason' } + } +} + +/** 解析模板配置:内置默认 + 环境变量(同键以环境变量为准;坏 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) 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 { + 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 +): 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 { 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, + 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 } + } +}