fix(微信订阅): 字段名按微信真实模板校准,不再靠猜编号;phrase 只截断不补齐

This commit is contained in:
2026-10-24 10:18:57 +08:00
parent c51b7c660a
commit dc2565da34
8 changed files with 701 additions and 49 deletions
+273 -15
View File
@@ -67,14 +67,24 @@ export {
TEMPLATES_ENV_KEY,
type TemplateConf
} from '~~/server/utils/wechatSubscribeTemplates'
import {
parseTemplateContent,
resolveFieldsFromKeywords,
type ResolveTemplateFieldsResult,
type TemplateKeyword
} from '~~/server/utils/wechatSubscribeRemote'
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'
/** 获取账号下已有的订阅消息模板(含**真实的关键词名**,用来校准我们的字段映射) */
const TEMPLATE_LIST_URL = 'https://api.weixin.qq.com/wxaapi/newtmpl/gettemplate'
/** 订阅消息落地的小程序页面(用户点开通知卡片跳到哪) */
const NOTIFY_PAGE = 'pages/home/home'
/** 提前 5 分钟作废 token,避免卡在边界上 */
const TOKEN_SAFETY_MS = 5 * 60 * 1000
/** 模板列表缓存 10 分钟:模板偶尔会变,但不能每次发送都去问微信 */
const TEMPLATE_CACHE_MS = 10 * 60 * 1000
// ---------------- 模板配置 ----------------
@@ -215,6 +225,167 @@ export async function getMiniAccessToken(force = false): Promise<string | null>
}
}
// ---------------- 用微信真实模板校准字段名 ----------------
export type RemoteTemplateInfo = {
id: string
title: string
content: string
type: number | null
keywords: TemplateKeyword[]
}
const remoteTemplateCache = new Map<
string,
{ at: number; info: RemoteTemplateInfo | null; error: string | null }
>()
/** 每个模板只唠叨一次「字段名被校准过」,避免日志刷屏 */
const remoteNoticeOnce = new Set<string>()
/**
* 拿账号下已有的模板列表(**带真实关键词名**),按模板 ID 找出来。
*
* 这是唯一能知道「微信给这个模板的关键词编成了 thing1 还是 thing2」的途径 ——
* 后台创建模板时编号是微信自动打的,靠猜必然出错,错了就是整条 47003。
*
* 失败一律返回 `null` 并**不影响发送**(调用方会退回配置里的字段名)。
*/
export async function getRemoteTemplate(
templateId: string,
opts: { force?: boolean } = {}
): Promise<{ info: RemoteTemplateInfo | null; error: string | null }> {
if (!templateId) return { info: null, error: 'no-template-id' }
const cached = remoteTemplateCache.get(templateId)
if (!opts.force && cached && Date.now() - cached.at < TEMPLATE_CACHE_MS) {
return { info: cached.info, error: cached.error }
}
const token = await getMiniAccessToken(opts.force)
if (!token) return { info: null, error: 'no-access-token' }
let error: string | null = null
try {
let res: any = await $fetch(TEMPLATE_LIST_URL, {
query: { access_token: token },
timeout: 8000,
responseType: 'json'
})
if (typeof res === 'string') {
try {
res = JSON.parse(res)
} catch {
res = null
}
}
if (!res || (res.errcode !== undefined && res.errcode !== 0)) {
error = `获取模板列表失败:errcode=${res?.errcode} errmsg=${res?.errmsg}`
console.error(`[WeChatSubscribe] ${error}`)
remoteTemplateCache.set(templateId, { at: Date.now(), info: null, error })
return { info: null, error }
}
const list: any[] = Array.isArray(res.data) ? res.data : []
const hit = list.find((t) => String(t?.priTmplId) === String(templateId))
if (!hit) {
error =
'这个模板 ID 不在账号的模板列表里(可能已删除,或属于另一个小程序)—— ' +
`账号下现有 ${list.length} 个模板:${list.map((t) => `${t?.title}(${t?.priTmplId})`).join('、') || '无'}`
console.error(`[WeChatSubscribe] ${error}`)
remoteTemplateCache.set(templateId, { at: Date.now(), info: null, error })
return { info: null, error }
}
const info: RemoteTemplateInfo = {
id: String(hit.priTmplId),
title: String(hit.title || ''),
content: String(hit.content || ''),
type: typeof hit.type === 'number' ? hit.type : null,
keywords: parseTemplateContent(String(hit.content || ''))
}
remoteTemplateCache.set(templateId, { at: Date.now(), info, error: null })
return { info, error: null }
} catch (err: any) {
error = `获取模板列表异常:${err?.message}`
console.error(`[WeChatSubscribe] ${error}`)
remoteTemplateCache.set(templateId, { at: Date.now(), info: null, error })
return { info: null, error }
}
}
export type ResolvedTemplate = {
/** 真正用来发消息的模板配置(字段名可能已被微信侧校准过) */
conf: TemplateConf
/** 校准过程与结论;null = 没走到联网校准(没模板 ID) */
resolution:
| (ResolveTemplateFieldsResult & {
remoteError: string | null
remoteTitle: string | null
remoteContent: string | null
remoteKeywords: TemplateKeyword[]
})
| null
}
/**
* 把配置里的模板「校准」成微信侧真实的样子。
*
* 优先级:微信模板列表里反解出的真实关键词名 > 配置里的字段名。
* 拿不到模板列表时**原样返回配置**(绝不因为校准失败而让通知停摆)。
*/
export async function resolveTemplate(
conf: TemplateConf,
opts: { forceRemote?: boolean } = {}
): Promise<ResolvedTemplate> {
if (!conf || !conf.id) {
return {
conf,
resolution: null
}
}
const { info, error } = await getRemoteTemplate(conf.id, { force: opts.forceRemote })
if (!info) {
return {
conf,
resolution: {
fields: conf.fields || {},
source: 'config',
reason: `拿不到微信模板列表(${error || '未知原因'}),沿用配置里的字段名`,
unresolvedVariables: [],
invalidConfiguredCodes: [],
remoteError: error,
remoteTitle: null,
remoteContent: null,
remoteKeywords: []
}
}
}
const resolved = resolveFieldsFromKeywords(info.keywords, conf.fields)
const effective: TemplateConf =
resolved.source === 'wechat-api' ? { ...conf, fields: resolved.fields } : conf
if (resolved.source === 'wechat-api') {
const before = JSON.stringify(conf.fields || {})
const after = JSON.stringify(resolved.fields)
if (before !== after && !remoteNoticeOnce.has(conf.id)) {
remoteNoticeOnce.add(conf.id)
console.warn(
`[WeChatSubscribe] 模板「${info.title || conf.title || conf.id}」的字段名已按微信真实模板校准:` +
`${before} → ${after}(配置里的编号是猜的,微信自动分配的编号以这里为准)`
)
}
}
return {
conf: effective,
resolution: {
...resolved,
remoteError: null,
remoteTitle: info.title || null,
remoteContent: info.content || null,
remoteKeywords: info.keywords
}
}
}
// ---------------- 用户侧查询 ----------------
/** 取用户的小程序 openid(没绑微信就是 null) */
@@ -444,6 +615,8 @@ export type SendOutcome = {
emptyFields?: Array<{ field: string; variable: string }>
/** 本次真的调用微信了吗(empty-field / dryRun 等预检不通过时为 false,不消耗额度) */
attempted: boolean
/** 本次的字段名来自哪里:`config`=配置里写的,`wechat-api`=按微信真实模板校准出来的 */
fieldsResolved?: 'config' | 'wechat-api'
}
type SendOptions = {
@@ -453,6 +626,8 @@ type SendOptions = {
dryRun?: boolean
/** 日志前缀里的来源标记(例如 'test-subscribe' / 'diagnose') */
source?: string
/** 强制刷新微信侧模板列表缓存(改了模板后自测用) */
forceRemote?: boolean
}
function logOutcome(o: SendOutcome, source?: string) {
@@ -479,6 +654,8 @@ export async function sendWechatSubscribe(
vars: Record<string, unknown>,
opts: SendOptions = {}
): Promise<SendOutcome> {
/** 本次到底是用配置里的字段名,还是用微信真实模板校准出来的(进日志/返回值,方便排查) */
let fieldsFromWechat = false
const base = (
partial: Partial<SendOutcome> & { stage: SendStage; reason: string; detail: string }
): SendOutcome => {
@@ -487,6 +664,7 @@ export async function sendWechatSubscribe(
kind,
userId,
attempted: false,
fieldsResolved: fieldsFromWechat ? 'wechat-api' : 'config',
...partial
}
logOutcome(outcome, opts.source)
@@ -494,8 +672,8 @@ export async function sendWechatSubscribe(
}
try {
const conf = getSubscribeTemplates()[kind]
if (!conf || !conf.id) {
const configured = getSubscribeTemplates()[kind]
if (!configured || !configured.id) {
return base({
stage: 'no-template',
reason: 'no-template',
@@ -503,6 +681,13 @@ export async function sendWechatSubscribe(
})
}
// 用微信真实模板校准字段名(后台给关键词自动编号,猜的编号会整条 47003)。
// 校准失败不影响发送:resolveTemplate 会原样退回配置。
const { conf, resolution } = await resolveTemplate(configured, {
forceRemote: opts.forceRemote
})
fieldsFromWechat = resolution?.source === 'wechat-api'
const notif = await getNotificationEnabled(userId)
if (!notif.enabled) {
return base({
@@ -562,7 +747,8 @@ export async function sendWechatSubscribe(
detail:
`dryRun:没有真的发送(预检通过、字段都有值;本地记账显示大约还剩 ${remaining} 条,` +
'但这个数字仅供参考,真正有没有额度要看微信回的 errcode)',
attempted: false
attempted: false,
fieldsResolved: fieldsFromWechat ? 'wechat-api' : 'config'
}
console.log(
`[WeChatSubscribe]${opts.source ? `[${opts.source}]` : ''} ${kind} → user ${userId} dry-run 通过,预览 data=${JSON.stringify(data)}`
@@ -596,7 +782,8 @@ export async function sendWechatSubscribe(
detail: '微信已受理,消息会出现在用户的「服务通知」里',
errcode: 0,
errmsg: raw?.errmsg ?? null,
attempted: true
attempted: true,
fieldsResolved: fieldsFromWechat ? 'wechat-api' : 'config'
}
logOutcome(outcome, opts.source)
return outcome
@@ -619,8 +806,24 @@ export async function sendWechatSubscribe(
} else if (errcode === 47003) {
stage = 'rejected-bad-field'
reason = 'bad-field'
/*
* 47003 是最难查的一个:微信只点名一个字段,但真正不合规的可能在别处。
* 我们能提供的确定性信息有两个 —— 这次用的是配置里的字段名还是微信真实模板
* 校准过的,以及校准结论。都写进 detail,省得下次还要猜。
*/
const hint = resolution
? resolution.source === 'wechat-api'
? `本次字段名是【按微信真实模板校准】的(${resolution.reason}),所以问题更可能在字段值本身:`
: `本次字段名是【沿用配置】的(${resolution.reason}),很可能是配置里的编号和微信模板不一致:`
: '本次字段名是【沿用配置】的:'
detail =
'微信回 47003:模板字段名或字段值不合规。去公众平台核对模板字段名是否和 fields 映射一致(也可能是某个字段超长/为空)'
`微信回 47003:模板字段名或字段值不合规。${hint}` +
'用 /api/admin/wechat/diagnose 可以看到微信侧的真实关键词名。' +
(resolution?.remoteKeywords?.length
? ` 微信侧关键词:${resolution.remoteKeywords
.map((k) => `${k.code}${k.label ? `(${k.label})` : ''}`)
.join('、')}`
: '')
}
const outcome: SendOutcome = {
ok: false,
@@ -632,7 +835,8 @@ export async function sendWechatSubscribe(
detail,
errcode,
errmsg: raw?.errmsg ?? null,
attempted: true
attempted: true,
fieldsResolved: fieldsFromWechat ? 'wechat-api' : 'config'
}
logOutcome(outcome, opts.source)
return outcome
@@ -759,8 +963,24 @@ export type TemplateStatus = {
kind: string
title: string
templateId: string
/** 字段名 → 变量名 */
/** 实际用来发消息的字段名 → 变量名(可能已按微信真实模板校准过) */
fields: Record<string, string>
/** 配置里(代码/环境变量)写的字段名,用来和微信侧对比 */
configuredFields: Record<string, string>
/** `config`=用的是配置里的字段名;`wechat-api`=已按微信真实模板校准 */
fieldSource: 'config' | 'wechat-api'
/** 这次字段名是怎么定的(人话) */
fieldReason: string
/** 配置里有、但微信模板里根本没有的关键词名(有值就一定会 47003) */
invalidConfiguredCodes: string[]
/** 微信侧真实的关键词表(拿不到时为 null) */
remoteKeywords: Array<{ code: string; type: string; label: string }> | null
/** 微信侧的模板原文(能看到真实的关键词与顺序) */
remoteContent: string | null
/** 微信侧的模板标题(和本地配置的标题不一致时,说明配置指向了别的模板) */
remoteTitle: string | null
/** 拿微信模板列表失败的原因(null = 成功) */
remoteError: string | null
/** 这些字段映射到的变量,调用方有没有给(给了模板里也没用时是另一回事) */
fieldsWithSample: Array<{ field: string; variable: string; sampleValue: string }>
/** 本地记账:用户点过几次授权、成功发过几条、被拒过几次 */
@@ -832,7 +1052,7 @@ export function sampleDataFor(conf: TemplateConf): Record<string, { value: strin
*/
export async function inspectWechatSubscribe(
userId: number,
opts: { username?: string | null } = {}
opts: { username?: string | null; forceRemote?: boolean } = {}
): Promise<SubscribeInspection> {
const report = getTemplatesReport()
const templates = report.templates
@@ -847,17 +1067,22 @@ export async function inspectWechatSubscribe(
kinds.map((k) => templates[k].id)
)
const templateStatuses: TemplateStatus[] = kinds.map((kind) => {
const conf = templates[kind]
const templateStatuses: TemplateStatus[] = []
for (const kind of kinds) {
const configured = templates[kind]
const { conf, resolution } = await resolveTemplate(configured, {
forceRemote: opts.forceRemote
})
const grant = grantMap.get(conf.id)
const grantedCount = grant?.grantedCount ?? 0
const sentCount = grant?.sentCount ?? 0
const blockedCount = grant?.blockedCount ?? 0
const remainingApprox = Math.max(0, grantedCount - sentCount)
const sampleData = sampleDataFor(conf)
const fieldsWithSample = Object.keys(conf.fields || {}).map((field) => ({
field,
variable: conf.fields[field],
sampleValue: String(sampleDataFor(conf)[field]?.value ?? '')
sampleValue: String(sampleData[field]?.value ?? '')
}))
let verdict: string
@@ -875,11 +1100,25 @@ export async function inspectWechatSubscribe(
else
verdict = `本地记账显示还有约 ${remainingApprox} 条额度(我们自己的计数,不是微信侧的余额)`
return {
templateStatuses.push({
kind,
title: conf.title || kind,
templateId: conf.id,
fields: conf.fields || {},
configuredFields: configured.fields || {},
fieldSource: resolution?.source === 'wechat-api' ? 'wechat-api' : 'config',
fieldReason: resolution?.reason || '没有模板 ID,无法校准',
invalidConfiguredCodes: resolution?.invalidConfiguredCodes || [],
remoteKeywords: resolution?.remoteKeywords?.length
? resolution.remoteKeywords.map((k) => ({
code: k.code,
type: k.type,
label: k.label
}))
: null,
remoteContent: resolution?.remoteContent ?? null,
remoteTitle: resolution?.remoteTitle ?? null,
remoteError: resolution?.remoteError ?? null,
fieldsWithSample,
grantedCount,
sentCount,
@@ -891,8 +1130,8 @@ export async function inspectWechatSubscribe(
lastErrorCode: grant?.lastErrorCode ?? null,
lastErrorMessage: grant?.lastErrorMessage ?? null,
verdict
}
})
})
}
const actions: string[] = []
if (!report.envJsonValid) {
@@ -927,9 +1166,28 @@ export async function inspectWechatSubscribe(
)
}
templateStatuses.forEach((t) => {
// 字段名是 47003 的头号嫌疑,体检里把它放在前面说清楚
if (t.remoteError) {
actions.push(
`模板「${t.title}」拿不到微信侧的关键词列表(${t.remoteError}),本次只能沿用配置里的字段名` +
(t.invalidConfiguredCodes.length
? `;而配置里的 ${t.invalidConfiguredCodes.join('、')} 在微信模板里查不到,很可能就是 47003 的原因`
: '')
)
} else if (t.fieldSource === 'wechat-api' && t.invalidConfiguredCodes.length) {
actions.push(
`模板「${t.title}」配置里的字段名 ${t.invalidConfiguredCodes.join('、')} 在微信模板里不存在;` +
`服务端已自动改用微信真实的关键词(${Object.entries(t.fields)
.map(([code, v]) => `${code}=${v}`)
.join(',')})。建议把这些编号写回 BUILTIN_TEMPLATES 或环境变量,省掉每次的校准请求`
)
}
if (t.lastErrorCode === 47003) {
actions.push(
`模板「${t.title}」上次被微信以 47003 拒绝:核对公众平台的字段名是否与 fields 映射一致`
`模板「${t.title}」上次被微信以 47003 拒绝:` +
(t.fieldSource === 'wechat-api'
? '字段名已按微信真实模板校准,问题更可能在**字段值**(为空/超长/类型不符)'
: '字段名还是配置里写的,看上面 remoteKeywords 与配置的差异')
)
}
if (t.lastErrorCode === 43101) {