fix(微信订阅): 用微信真实关键词修正歌曲排期模板(thing1/phrase2/time3/character_string4),并修掉字段认领顺序导致的校准失效

This commit is contained in:
2026-10-24 11:56:50 +08:00
parent dc2565da34
commit f5a43b37cc
7 changed files with 192 additions and 56 deletions
+15
View File
@@ -350,6 +350,7 @@ export async function resolveTemplate(
reason: `拿不到微信模板列表(${error || '未知原因'}),沿用配置里的字段名`,
unresolvedVariables: [],
invalidConfiguredCodes: [],
unclaimedCodes: [],
remoteError: error,
remoteTitle: null,
remoteContent: null,
@@ -818,6 +819,9 @@ export async function sendWechatSubscribe(
: '本次字段名是【沿用配置】的:'
detail =
`微信回 47003:模板字段名或字段值不合规。${hint}` +
(resolution?.unclaimedCodes?.length
? `还有关键词没被任何字段填上(${resolution.unclaimedCodes.join('、')}),微信会报「value is empty」。`
: '') +
'用 /api/admin/wechat/diagnose 可以看到微信侧的真实关键词名。' +
(resolution?.remoteKeywords?.length
? ` 微信侧关键词:${resolution.remoteKeywords
@@ -973,6 +977,8 @@ export type TemplateStatus = {
fieldReason: string
/** 配置里有、但微信模板里根本没有的关键词名(有值就一定会 47003) */
invalidConfiguredCodes: string[]
/** 微信模板里有、但没有任何字段去填的关键词(微信会回 `data.xxx.value is empty`) */
unclaimedCodes: string[]
/** 微信侧真实的关键词表(拿不到时为 null) */
remoteKeywords: Array<{ code: string; type: string; label: string }> | null
/** 微信侧的模板原文(能看到真实的关键词与顺序) */
@@ -1109,6 +1115,7 @@ export async function inspectWechatSubscribe(
fieldSource: resolution?.source === 'wechat-api' ? 'wechat-api' : 'config',
fieldReason: resolution?.reason || '没有模板 ID,无法校准',
invalidConfiguredCodes: resolution?.invalidConfiguredCodes || [],
unclaimedCodes: resolution?.unclaimedCodes || [],
remoteKeywords: resolution?.remoteKeywords?.length
? resolution.remoteKeywords.map((k) => ({
code: k.code,
@@ -1166,6 +1173,14 @@ export async function inspectWechatSubscribe(
)
}
templateStatuses.forEach((t) => {
// 「少填一个关键词」是 47003 `data.xxx.value is empty` 的直接原因,比字段名错更常见,先说
if (t.unclaimedCodes.length) {
actions.push(
`模板「${t.title}」里有关键词没被任何字段填上:${t.unclaimedCodes.join('、')} —— ` +
`微信会回 47003「data.${t.unclaimedCodes[0]}.value is empty」。` +
`对照 remoteContent 把这几个关键词补进 fields 映射(少填一个就整条发不出去)`
)
}
// 字段名是 47003 的头号嫌疑,体检里把它放在前面说清楚
if (t.remoteError) {
actions.push(
+55 -22
View File
@@ -153,6 +153,11 @@ export type ResolveTemplateFieldsResult = {
unresolvedVariables: string[]
/** 配置里有、但微信模板里根本不存在的关键词名(这些字段微信会直接 47003) */
invalidConfiguredCodes: string[]
/**
* 微信模板里有、但**没有任何变量去填**的关键词(形如 `thing1`)。
* 微信对缺失的关键词会回 `47003 data.thing1.value is empty` —— 这种「少填」比「填错」更常见。
*/
unclaimedCodes: string[]
}
/**
@@ -180,6 +185,8 @@ export function resolveFieldsFromKeywords(
reason,
unresolvedVariables: [] as string[],
invalidConfiguredCodes,
// 配置里没覆盖到的关键词 = 微信会报 value is empty 的那些
unclaimedCodes: keywords.map((k) => k.code).filter((code) => !(code in config)),
...extra
})
@@ -201,37 +208,44 @@ export function resolveFieldsFromKeywords(
const resolved: Record<string, string> = {}
const unresolved: string[] = []
// 一轮:类型 + 标签双重命中,优先认领把握最大的
/*
* 评分规则:首选类型 +2、次选递减;中文标签命中再 +3(目的就是压过纯类型匹配)。
*
* ⚠️ **必须全局按分数从高到低认领**,不能「按变量顺序逐个挑」—— 早先就是这么写的,
* 结果 playDate(首选 date,但模板里没有 date 字段)先把 character_string4 抢走了,
* 因为它的次选类型里也有 character_string;轮到 sequence 时已经没有可用关键词,
* 整张模板退回旧配置,发出去就是 47003。按分数排序后 sequence↔character_string4(分高)先成对。
*/
const pairs: Array<{ variable: string; code: string; score: number }> = []
for (const variable of variables) {
const allowed = VARIABLE_TYPES[variable]
let best: { code: string; score: number } | null = null
for (const kw of keywords) {
if (used.has(kw.code)) continue
const typeRank = allowed.indexOf(kw.type)
if (typeRank < 0) continue
// 首选类型 +2,备选类型递减;中文标签命中 +3(目的就是压过纯类型匹配)
const score = (allowed.length - typeRank) * 2 + (labelHits(kw.label, variable) ? 3 : 0)
if (!best || score > best.score) best = { code: kw.code, score }
pairs.push({ variable, code: kw.code, score })
}
if (!best) {
unresolved.push(variable)
continue
}
used.add(best.code)
resolved[best.code] = variable
}
// 同分时优先「标签命中」的(score 里已经含了,这里只是让顺序稳定)
pairs.sort((a, b) => b.score - a.score)
for (const pair of pairs) {
if (resolved[pair.code] !== undefined) continue
if (Object.values(resolved).includes(pair.variable)) continue
if (used.has(pair.code)) continue
used.add(pair.code)
resolved[pair.code] = pair.variable
}
if (unresolved.length) {
return configResult(
`有变量在微信模板里找不到对应的关键词(${unresolved.join('、')}),沿用配置`,
{ unresolvedVariables: unresolved }
)
for (const variable of variables) {
if (!Object.values(resolved).includes(variable)) unresolved.push(variable)
}
const unclaimed = keywords.map((k) => k.code).filter((code) => !(code in resolved))
/*
* 安全检查:同一类型出现多个关键词时,必须靠中文标签区分开。
* 例如驳回模板有两个 thing,如果不看标签只按类型分配,songTitle 和 reason 会互换 ——
* 能把消息发出去,但内容张冠李戴,比发不出去更糟。
* 安全检查(先做,因为它是"不可采纳"的硬条件):同一类型出现多个关键词时,
* 必须靠中文标签区分开。例如驳回模板有两个 thing,如果不看标签只按类型分配,
* songTitle 和 reason 会互换 —— 能把消息发出去,但内容张冠李戴,比发不出去更糟。
*/
const byType = new Map<KeywordType, TemplateKeyword[]>()
for (const kw of keywords) {
@@ -260,6 +274,23 @@ export function resolveFieldsFromKeywords(
}
}
/*
* 到这里已经通过了同类型区分的安全检查,剩下的唯一问题是「能不能把模板填满」:
* - 模板里每个关键词都有变量认领(unclaimed 为空)→ 采用微信侧映射。
* 此时即使有变量在模板里找不到位置(比如配置里写了模板没有的 date),也**直接忽略**它 ——
* 发一个微信不认识的字段没有任何好处,只会让整条消息 47003。
* - 还有关键词没人填 → 配置可能本来就指望它(例如配置里写了正确的 phrase5),
* 这时候不该自作聪明覆盖配置,沿用配置更安全。
*/
if (unclaimed.length) {
return configResult(
`微信模板里还有关键词没被任何字段填上(${unclaimed.join('、')}),` +
(unresolved.length ? `同时有变量在模板里找不到位置(${unresolved.join('、')}):` : '') +
'为避免把模板改坏,沿用配置',
{ unresolvedVariables: unresolved }
)
}
// 配置里的关键词名只要有一个在微信模板里不存在,就说明配置已经过期,改用校准结果
const changed =
configuredCodes.some((code) => !(code in resolved)) ||
@@ -271,9 +302,11 @@ export function resolveFieldsFromKeywords(
reason: changed
? `按微信真实模板校准了字段名:${Object.entries(resolved)
.map(([code, variable]) => `${code}=${variable}`)
.join(',')}`
.join(',')}` +
(unresolved.length ? `(模板里没有对应关键词的变量已忽略:${unresolved.join('、')})` : '')
: '配置与微信真实模板一致',
unresolvedVariables: [],
invalidConfiguredCodes
unresolvedVariables: unresolved,
invalidConfiguredCodes,
unclaimedCodes: unclaimed
}
}
+17 -6
View File
@@ -19,7 +19,7 @@
*
* | key | 用途 | 模板字段 → 变量 |
* |----------------|----------------------------|----------------------------------------------------------------------------|
* | SONG_SELECTED | 歌曲被排进排期/发布 | thing2→songTitle, date4→playDate, time1→playTime, character_string3→sequence, phrase5→status |
* | SONG_SELECTED | 歌曲被排进排期/发布 | thing1→songTitle, phrase2→status, time3→playTime, character_string4→sequence |
* | SONG_REJECTED | 投稿被驳回 | thing6→songTitle, thing4→reason |
*
* 换模板/改字段**不用改代码**:环境变量 `WECHAT_MINI_SUBSCRIBE_TEMPLATES` 是 JSON,按
@@ -55,12 +55,23 @@ export const BUILTIN_TEMPLATES: Record<string, TemplateConf> = {
SONG_SELECTED: {
id: 'oLTAefO92xYYlGRYmcsm5AfUUlUI5IKFfXhIVNrItSk',
title: '歌曲排期通知',
/*
* 这张模板的**真实**关键词表(从微信 /wxaapi/newtmpl/gettemplate 读出来的原文):
* 歌曲名称:{{thing1.DATA}} → songTitle
* 排期状态:{{phrase2.DATA}} → status
* 播放时间:{{time3.DATA}} → playTime('YYYY-MM-DD HH:mm',日期就含在里面)
* 排期序号:{{character_string4.DATA}} → sequence
*
* ⚠️ **没有日期字段**。早先这里是「按常见命名猜的」thing2/date4/time1/character_string3/phrase5,
* 五个编号全错、还多出一个模板里根本不存在的 date4 —— 微信的表现是
* `47003 argument invalid! data.thing1.value is empty`(我们没给 thing1,
* 而它才是模板真正要的「歌曲名称」)。换模板时请对着后台的模板详情逐个核,不要猜编号。
*/
fields: {
thing2: 'songTitle',
date4: 'playDate',
time1: 'playTime',
character_string3: 'sequence',
phrase5: 'status'
thing1: 'songTitle',
phrase2: 'status',
time3: 'playTime',
character_string4: 'sequence'
}
}
}