import { buildTemplateData, getMiniAccessToken, getSubscribeTemplates, getWechatOpenid, sendRawSubscribe } from '~~/server/services/wechatSubscribeService' import { db } from '~/drizzle/db' import { userIdentities } from '~/drizzle/schema' import { eq } from 'drizzle-orm' /** * 微信订阅消息的**自测接口**(仅超级管理员)—— 站长部署完用它「试一下」。 * * 一次调用能看出四件事: * 1. access_token 能不能拿到(appid / secret 是否有效) * 2. 目标用户有没有绑微信(有没有 openid;纯密码用户必然是 null) * 3. 模板字段名 / 长度对不对(微信会回 47003 / 43101 之类的错误码) * 4. 微信返回的原文(data 里能直接看到每个字段最终发了什么值) * * body(都可省): * { userId?: number, kind?: string, openid?: string, templateId?: string, * vars?: object, data?: object, dryRun?: boolean, sendToAll?: boolean } * · 默认给自己发、用配置里的第一个模板 * · vars 覆盖**变量**(songTitle / playDate / playTime / sequence / status / message…), * 仍走 buildTemplateData → clampField,所以看到的是真发出去的值 * · data 直接当微信的 data 用(最后的兜底排查手段) * · **dryRun: true —— 只算出 data、一个字都不发,不消耗任何订阅额度**(排查字段时先用它) * · **sendToAll: true —— 给所有绑了微信的用户各试一次**(一次性订阅的额度是 * 每用户一条,逐个人发能立刻区分「额度用光了」和「字段名不对」: * 43101 for all = 没额度;47003 = 字段问题;只要有一个 0 = 字段其实是对的) */ 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 dryRun = body?.dryRun === true const sendToAll = body?.sendToAll === true const token = await getMiniAccessToken() const openid = body?.openid || (await getWechatOpenid(targetUserId)) if (!templateId) { return { ok: false, reason: 'no-template', hint: '没有可用的模板 ID:内置默认模板见 server/services/wechatSubscribeService.ts 的 DEFAULT_TEMPLATES,也可用环境变量 WECHAT_MINI_SUBSCRIBE_TEMPLATES 配置', configuredKinds: kinds, tokenOk: !!token, openid: openid ? openid.slice(0, 6) + '****' : null } } /* * 各通知类型的**真实例子**:拿这些值走一遍 buildTemplateData → clampField, * 站长看到的 data 就是线上真会发出去的样子(比 '测试歌曲' 更能验字段名/长度)。 */ const SAMPLE_VARS: Record> = { SONG_SELECTED: { songTitle: '测试歌曲《晴天》', playDate: '2026-02-14', playTime: '2026-02-14 12:30', sequence: '1', status: '已排期', message: '您投稿的歌曲《晴天》已被安排播放,播放日期:2026-02-14。' }, SONG_REJECTED: { songTitle: '测试歌曲《晴天》', reason: '已有同名单曲,暂不重复收录', message: '您投稿的歌曲《晴天》未通过审核。' } } const buildData = (override?: any) => override && typeof override === 'object' ? override : buildTemplateData( conf || { id: templateId }, Object.assign( { songTitle: '测试歌曲', message: '这是一条来自宝一外校园点歌系统的测试通知' }, SAMPLE_VARS[kind] || {}, body?.vars && typeof body.vars === 'object' ? body.vars : {} ) ) /* * 我们代码里假定的字段映射(公众平台模板的关键词名)—— 微信模板是允许自己起关键词名的, * 站长选的模板如果不是我们猜的这几个名字,就会 47003(而且报错点名的字段不一定是真凶)。 */ const assumedFields = conf?.fields ? Object.keys(conf.fields) : [] const previewData = buildData(body?.data) const fieldMeta = assumedFields.map((name) => { const v = previewData[name]?.value ?? '' return { field: name, type: String(name).replace(/[0-9]+$/, ''), value: v === '' ? '' : v, empty: v === '' } }) if (dryRun) { return { ok: true, dryRun: true, hint: '⚠️ 这次**没有发送**,不消耗订阅额度。看上表:空值(empty)的字段微信会直接 47003;字段名也要和公众平台模板里的关键词名完全一致', configuredKinds: kinds, kind: kind, templateId: templateId, targetUserId: targetUserId, tokenOk: !!token, openid: openid ? openid.slice(0, 6) + '****' : null, assumedFields: assumedFields, fields: fieldMeta, quotaNote: '微信**不提供**「还剩几条授权」的查询接口:服务端只有真发一条、收到 43101 才知道没额度了。所以界面上那个「增加一条额度」只是本地计数,不代表微信侧的真实剩余量', data: previewData } } if (sendToAll) { const rows: Array<{ userId: number; providerUserId: string }> = await db .select({ userId: userIdentities.userId, providerUserId: userIdentities.providerUserId }) .from(userIdentities) .where(eq(userIdentities.provider, 'wechat-mp')) const uniq = Array.from(new Map(rows.map((r) => [r.userId, r])).values()).slice(0, 30) if (!uniq.length) { return { ok: false, reason: 'no-wechat-openid', hint: '一个绑了微信的用户都没有(userIdentities 里没有 provider=wechat-mp),微信发不到任何人', kind: kind, templateId: templateId, tokenOk: !!token } } const results: any[] = [] for (const r of uniq) { const res = await sendRawSubscribe(r.providerUserId, templateId, previewData) results.push({ userId: r.userId, openid: String(r.providerUserId).slice(0, 6) + '****', ok: res.ok, errcode: res.errcode, errmsg: res.errmsg }) if (res.ok) break // 有一个人成功就够证明字段名是对的,别再挨个烧额度 } const anyOk = results.some((r) => r.ok) const codes = Array.from(new Set(results.map((r) => r.errcode))) return { ok: anyOk, hint: anyOk ? '至少有一个用户发送成功 —— 字段名是对的,收不到的人只是「没有可用授权」(一次性订阅,每点一次授权只够发一条)' : codes.includes(47003) ? '47003 = 字段名/长度不对:去公众平台模板里核对关键词名,或先用 dryRun 看 fields' : codes.every((c) => c === 43101) ? '全部 43101 = 所有人当前都没有可用授权(用户要在小程序里点一次「开启微信通知」,且只够发一条)' : '看每条的 errmsg', configuredKinds: kinds, kind: kind, templateId: templateId, attempted: uniq.length, assumedFields: assumedFields, fields: fieldMeta, results: results } } if (!openid) { return { ok: false, reason: 'no-wechat-openid', hint: '该用户没有绑定微信(userIdentities 里没有 provider=wechat-mp 的记录),微信发不到他那里', kind: kind, templateId: templateId, tokenOk: !!token, targetUserId: targetUserId } } const data = previewData 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, assumedFields: assumedFields, fields: fieldMeta, quotaNote: '微信**不提供**「还剩几条授权」的查询接口:服务端只有真发一条、收到 43101 才知道没额度了。一次授权只够发一条,发完要用户再点一次「开启」', data: data, raw: result.raw } })