From 09e7f53a1aa967ebdde3b4961f567e4e524b917b Mon Sep 17 00:00:00 2001 From: HenryDu8133 <813367384@qq.com> Date: Sun, 11 Oct 2026 15:37:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=BE=AE=E4=BF=A1=E8=AE=A2=E9=98=85):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=B8=80=E9=94=AE=E4=BD=93=E6=A3=80=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=20/api/admin/wechat/diagnose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 线上排查「订阅消息没发出去」以前只能靠猜,因为没有查询额度的接口,四条跳过路径 也不写日志。这个 GET 接口(仅超管,浏览器直接打开即可)一次返回所有事实: - access_token 能不能拿到、长度多少 - WECHAT_MINI_SUBSCRIBE_TEMPLATES / APPID / SECRET 是否注入、模板开关是否启用 - 当前账号有没有绑微信 openid(没有就永远发不出去)、哪些 userId 绑了微信 - 每个模板的 ID、字段映射,以及 buildTemplateData 算出的、真发出去会用的 data - 加 ?send=1 时顺手给当前用户真发一条(两个模板各一次),把微信的 errcode 原样返回:0=通、43101=没额度、47003=字段名不对、40003=openid 不对、40001=token 失效 用法:登录后台后打开 /api/admin/wechat/diagnose (或 ?send=1) --- server/api/admin/wechat/diagnose.get.ts | 121 ++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 server/api/admin/wechat/diagnose.get.ts diff --git a/server/api/admin/wechat/diagnose.get.ts b/server/api/admin/wechat/diagnose.get.ts new file mode 100644 index 0000000..42a581b --- /dev/null +++ b/server/api/admin/wechat/diagnose.get.ts @@ -0,0 +1,121 @@ +import { + buildTemplateData, + getMiniAccessToken, + getSubscribeTemplates, + getWechatOpenid, + isWechatSubscribeEnabled, + sendWechatSubscribeToUser +} from '~~/server/services/wechatSubscribeService' +import { db } from '~/drizzle/db' +import { userIdentities } from '~/drizzle/schema' +import { eq } from 'drizzle-orm' + +/** + * 微信订阅消息的**一键体检**(仅超级管理员,用 GET,浏览器直接打开就能看)。 + * + * 为什么要有它:线上「通知没发出去」以前只能靠猜——服务端日志里四条跳过路径 + * 一句不写。现在把判断要用的事实全部一次返回: + * 1. access_token 能不能拿到(appid / secret 是否有效) + * 2. 配置了哪些模板、ID 是什么、环境变量有没有覆盖 + * 3. 每个用户有没有绑微信 openid、通知总开关开没开 + * 4. 「用户是不是授权过这张模板」——微信没有查额度的接口,唯一办法是调 + * getwxacode 的同类接口 /api/cgi-bin/message/subscribe/… 没法查,所以这里 + * 退而求其次:用 `?send=1` 真发一条给当前用户,把微信的 errcode 原样返回 + * (0=通、43101=没额度、47003=字段名不对、40003=openid 不对、40001=token 失效)。 + * + * 用法(在已登录后台的浏览器里直接打开): + * /api/admin/wechat/diagnose → 只体检,不发消息 + * /api/admin/wechat/diagnose?send=1 → 顺手给当前登录用户发一条(消耗 1 条额度) + */ +export default defineEventHandler(async (event) => { + const user = event.context.user + if (!user || user.role !== 'SUPER_ADMIN') { + throw createError({ statusCode: 403, message: '只有超级管理员可以使用微信订阅体检' }) + } + + const query = getQuery(event) + const doSend = query.send === '1' || query.send === 'true' + const selfId = Number(user.id) + + const templates = getSubscribeTemplates() + const kinds = Object.keys(templates) + + const token = await getMiniAccessToken() + const selfOpenid = await getWechatOpenid(selfId) + + // 绑了微信的账号(有 openid 才可能收到订阅消息) + const bound = await db + .select({ userId: userIdentities.userId }) + .from(userIdentities) + .where(eq(userIdentities.provider, 'wechat-mp')) + const boundUserIds = Array.from(new Set(bound.map((r) => r.userId))) + + // 每个模板算一遍「真发出去会是什么值」,方便直接比对公众平台后台的字段名 + const previews = kinds.map((kind) => { + const conf = templates[kind] + const vars: Record = { + songTitle: '测试歌曲《晴天》', + playDate: '2026-02-14', + playTime: '2026-02-14 12:30', + sequence: '1', + status: '已排期', + reason: '测试驳回原因', + message: '这是一条测试通知' + } + return { + kind, + templateId: conf?.id || null, + fields: conf?.fields || null, + // 真正发出去就是这一份 data(buildTemplateData 内部走 clampField 截断/补齐) + data: conf + ? buildTemplateData(conf, Object.assign({ songTitle: '宝一外校园点歌系统' }, vars)) + : null + } + }) + + const result: Record = { + now: new Date().toISOString(), + env: { + // 环境变量里只要不是合法 JSON,服务端会忽略它并回退到内置模板 + WECHAT_MINI_SUBSCRIBE_TEMPLATES: process.env.WECHAT_MINI_SUBSCRIBE_TEMPLATES || null, + appidSet: !!process.env.WECHAT_MINI_APPID, + secretSet: !!process.env.WECHAT_MINI_SECRET, + enabled: isWechatSubscribeEnabled(), + nodeEnv: process.env.NODE_ENV || null + }, + tokenOk: !!token, + tokenLen: token ? token.length : 0, + me: { + userId: selfId, + username: user.username, + // openid 只露前 6 位,避免日志/截图泄露 + openid: selfOpenid ? selfOpenid.slice(0, 6) + '****' : null, + canReceive: !!selfOpenid + }, + boundWechatUserIds: boundUserIds, + templates: previews + } + + if (doSend && selfOpenid) { + const attempts: Array<{ kind: string; result: unknown }> = [] + for (const kind of kinds) { + attempts.push({ + kind, + result: await sendWechatSubscribeToUser(selfId, kind, { + songTitle: '测试歌曲《晴天》', + playDate: '2026-02-14', + playTime: '2026-02-14 12:30', + sequence: '1', + status: '已排期', + reason: '测试驳回原因', + message: '这是一条测试通知' + }) + }) + } + result.sendAttempts = attempts + } else if (doSend && !selfOpenid) { + result.sendSkipped = 'no-wechat-openid:当前登录账号没有绑定微信,发不出去' + } + + return result +})