Files

221 lines
8.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/utils/wechatSubscribeTemplates.ts 的 BUILTIN_TEMPLATES,也可用环境变量 WECHAT_MINI_SUBSCRIBE_TEMPLATES(合法 JSON)覆盖;一键体检用 /api/admin/wechat/diagnose',
configuredKinds: kinds,
tokenOk: !!token,
openid: openid ? openid.slice(0, 6) + '****' : null
}
}
/*
* 各通知类型的**真实例子**:拿这些值走一遍 buildTemplateData → clampField,
* 站长看到的 data 就是线上真会发出去的样子(比 '测试歌曲' 更能验字段名/长度)。
*/
const SAMPLE_VARS: Record<string, Record<string, unknown>> = {
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
}
})