feat(微信订阅): 新增一键体检接口 /api/admin/wechat/diagnose
线上排查「订阅消息没发出去」以前只能靠猜,因为没有查询额度的接口,四条跳过路径 也不写日志。这个 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)
This commit is contained in:
@@ -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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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
|
||||
})
|
||||
Reference in New Issue
Block a user