fix(微信订阅): 字段名按微信真实模板校准,不再靠猜编号;phrase 只截断不补齐
This commit is contained in:
@@ -7,8 +7,7 @@
|
||||
*
|
||||
* 各类型限制(公众平台模板字段名后缀就是类型):
|
||||
* thing 20 字
|
||||
* phrase 5 个以内纯汉字(官方文档的写法;社区反馈**恰好 5 个汉字**在部分模板上会报
|
||||
* `47003 data.phraseN.value invalid`,实测 4 个汉字正常)
|
||||
* phrase 5 个以内纯汉字(**只截断不补齐**:补齐会塞进非汉字字符,更容易 47003)
|
||||
* time 必须是 'YYYY-MM-DD HH:mm'
|
||||
* date 必须是 'YYYY-MM-DD'
|
||||
* character_string 32 字(只能数字、字母)
|
||||
@@ -50,16 +49,13 @@ export function clampField(key: string, value: unknown): string {
|
||||
}
|
||||
if (type === 'phrase') {
|
||||
/*
|
||||
* phrase = 5 个以内纯汉字。官方文档是「5个以内」,但社区反馈**恰好 5 个汉字**在部分模板上
|
||||
* 会被判 47003 invalid(4 个正常,见 wxopen.club/topic/61daa41e947eea50368f28ec),所以
|
||||
* 按最保守的写法统一补到 5 个字:短了补全角空格,长到 5 个字保持原样,超了截断。
|
||||
* “已排期”只有 3 个字 —— 真发出去不是被拒就是显示得莫名其妙,这里补齐后不会超长。
|
||||
* phrase = 5 个以内纯汉字。**只截断、不补齐**。
|
||||
*
|
||||
* 早先的版本为了绕开「社区反馈恰好 5 个汉字会报 47003」,把短词用全角空格补到 5 个字 ——
|
||||
* 结果补进去的 U+3000 不是汉字,反而更容易被判「不是纯汉字」。现在按官方文档字面执行:
|
||||
* 汉字数 ≤ 5 就原样发(「已排期」3 个字完全合规),超了才截。
|
||||
*/
|
||||
const chars = Array.from(text)
|
||||
if (chars.length >= 5) return chars.slice(0, 5).join('')
|
||||
let padded = text
|
||||
while (Array.from(padded).length < 5) padded += ' '
|
||||
return padded
|
||||
return Array.from(text).slice(0, 5).join('')
|
||||
}
|
||||
const max = FIELD_MAX_LEN[type] || 32
|
||||
return text.slice(0, max)
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* 从微信「获取已有模板列表」接口返回的模板内容里,反解出**真实的关键词名**。
|
||||
*
|
||||
* 为什么需要它:后台创建模板时微信会自动给关键词编号(`thing1` / `date2` / `time3` …),
|
||||
* 这个编号**无法靠名字推测**,只能从模板本身读。我们自己猜的字段名一旦和它不一致,
|
||||
* 发送就会整条被拒(`47003 data.xxx.value invalid`),而且报错点名的字段常常不是真凶。
|
||||
*
|
||||
* 本文件是纯函数:不联网、不读环境变量,只负责「把微信给的 content 解析成关键词表,
|
||||
* 再按类型/中文标签把我们的变量名对应到真实的关键词名」,可以单独跑单测。
|
||||
*
|
||||
* 微信 `GET /wxaapi/newtmpl/gettemplate` 返回的 `content` 形如:
|
||||
* `歌曲名称:{{thing1.DATA}}\n播放日期:{{date2.DATA}}\n播放时间:{{time3.DATA}}\n`
|
||||
*/
|
||||
|
||||
/** 关键词的类型前缀(微信文档里叫「关键词类型」) */
|
||||
export type KeywordType =
|
||||
| 'thing'
|
||||
| 'date'
|
||||
| 'time'
|
||||
| 'character_string'
|
||||
| 'phrase'
|
||||
| 'number'
|
||||
| 'amount'
|
||||
| 'name'
|
||||
| 'phone_number'
|
||||
| 'car_number'
|
||||
| 'symbol'
|
||||
| 'letter'
|
||||
| 'digit'
|
||||
| 'unknown'
|
||||
|
||||
export type TemplateKeyword = {
|
||||
/** 完整的关键词名,例如 thing1 / date2 */
|
||||
code: string
|
||||
/** 去掉尾部数字的类型,例如 thing */
|
||||
type: KeywordType
|
||||
/** 在模板里的顺序(从 0 开始) */
|
||||
index: number
|
||||
/** 模板里显示的中文标签,例如「歌曲名称」(可能为空串) */
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个变量「可能/应该」落在哪种关键词类型上。
|
||||
*
|
||||
* 第一个是**首选类型**(用来在同类关键词里挑),后面的都算可用。
|
||||
* 只列我们真的会用的变量名,其它变量走兜底(不做自动映射)。
|
||||
*/
|
||||
const VARIABLE_TYPES: Record<string, KeywordType[]> = {
|
||||
songTitle: ['thing', 'name', 'character_string'],
|
||||
playDate: ['date', 'thing', 'character_string'],
|
||||
playTime: ['time', 'thing', 'character_string'],
|
||||
sequence: ['character_string', 'number', 'thing'],
|
||||
status: ['phrase', 'thing', 'character_string'],
|
||||
reason: ['thing', 'character_string'],
|
||||
message: ['thing', 'character_string'],
|
||||
time: ['time', 'thing', 'character_string']
|
||||
}
|
||||
|
||||
/** 中文标签的同义词(用来在「多个同类型关键词」里认出谁是谁) */
|
||||
const VARIABLE_LABEL_HINTS: Record<string, string[]> = {
|
||||
songTitle: ['歌曲', '歌名', '名称', '标题', '作品', '曲目', '内容', '审核内容'],
|
||||
playDate: ['日期', '哪一天', '播放日'],
|
||||
playTime: ['时间', '时段', '几点', '播放时间'],
|
||||
sequence: ['序号', '顺序', '编号', '排位', '第几'],
|
||||
status: ['状态', '进度', '结果'],
|
||||
reason: ['原因', '理由', '驳回原因', '驳回理由', '说明', '备注', '未通过'],
|
||||
message: ['说明', '提示', '内容', '消息', '备注'],
|
||||
time: ['时间']
|
||||
}
|
||||
|
||||
const KNOWN_TYPES: KeywordType[] = [
|
||||
'thing',
|
||||
'date',
|
||||
'time',
|
||||
'character_string',
|
||||
'phrase',
|
||||
'number',
|
||||
'amount',
|
||||
'name',
|
||||
'phone_number',
|
||||
'car_number',
|
||||
'symbol',
|
||||
'letter',
|
||||
'digit'
|
||||
]
|
||||
|
||||
/** `thing12` → `thing`;认不出的前缀返回 `unknown` */
|
||||
export function keywordTypeOf(code: string): KeywordType {
|
||||
const bare = String(code || '')
|
||||
.replace(/\d+$/, '')
|
||||
.toLowerCase()
|
||||
return (KNOWN_TYPES as string[]).includes(bare) ? (bare as KeywordType) : 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* 从模板 content 里解析出关键词表(保持出现顺序)。
|
||||
*
|
||||
* 微信的占位符统一是 `{{关键词.DATA}}`,前面通常会跟一段中文标签,例如
|
||||
* `歌曲名称:{{thing1.DATA}}`。标签截到上一个占位符结束或行首为止。
|
||||
*/
|
||||
export function parseTemplateContent(content: string): TemplateKeyword[] {
|
||||
const text = String(content || '')
|
||||
const keywords: TemplateKeyword[] = []
|
||||
const pattern = /\{\{\s*([A-Za-z_]+)(\d+)\s*\.\s*DATA\s*\}\}/g
|
||||
let match: RegExpExecArray | null
|
||||
let cursor = 0
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
const code = `${match[1].toLowerCase()}${match[2]}`
|
||||
const between = text.slice(cursor, match.index)
|
||||
keywords.push({
|
||||
code,
|
||||
type: keywordTypeOf(code),
|
||||
index: keywords.length,
|
||||
label: cleanLabel(between)
|
||||
})
|
||||
cursor = match.index + match[0].length
|
||||
}
|
||||
return keywords
|
||||
}
|
||||
|
||||
/** 把占位符前面那段文字收拾成「干净的中文标签」 */
|
||||
function cleanLabel(raw: string): string {
|
||||
// 只取最后一个换行之后的部分,去掉首尾空白、冒号、冒号后面的空白、以及示例值
|
||||
const tail = String(raw || '')
|
||||
.split(/\r?\n/)
|
||||
.pop()!
|
||||
.replace(/[::]\s*$/, '')
|
||||
.replace(/[::]\s*[^::]*$/, '')
|
||||
.trim()
|
||||
// 去掉可能残留的示例值(中文标签通常是 2-8 个字,太长就只留前半段)
|
||||
return tail.slice(0, 24)
|
||||
}
|
||||
|
||||
/** 标签是否命中这个变量的同义词(只做包含判断,简单可靠) */
|
||||
function labelHits(label: string, variable: string): boolean {
|
||||
const hints = VARIABLE_LABEL_HINTS[variable]
|
||||
if (!hints || !label) return false
|
||||
return hints.some((h) => label.includes(h))
|
||||
}
|
||||
|
||||
export type ResolveTemplateFieldsResult = {
|
||||
/**
|
||||
* 最终用的字段映射(关键词名 → 变量名)。
|
||||
* `source` 为 `config` 时就是传进来的配置原样返回。
|
||||
*/
|
||||
fields: Record<string, string>
|
||||
/** `wechat-api` = 按微信真实模板反解出来的;`config` = 沿用配置(没联网/反解不完整/没把握) */
|
||||
source: 'config' | 'wechat-api'
|
||||
/** 人话说明这次是怎么决定的(直接写进日志和体检结果) */
|
||||
reason: string
|
||||
/** 反解失败的变量(source=config 时有意义) */
|
||||
unresolvedVariables: string[]
|
||||
/** 配置里有、但微信模板里根本不存在的关键词名(这些字段微信会直接 47003) */
|
||||
invalidConfiguredCodes: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 把配置里的字段映射,按微信真实模板「校准」一遍。
|
||||
*
|
||||
* 只有在**所有变量都能唯一认领一个关键词**时才采用微信侧的映射(source=wechat-api),
|
||||
* 否则原样返回配置 —— 宁可保持现状,也不要因为一次猜测把本来能发的模板改坏。
|
||||
*
|
||||
* @param keywords 微信模板里真实的关键词表(`parseTemplateContent` 的结果)
|
||||
* @param configured 配置里的字段映射(关键词名 → 变量名)
|
||||
*/
|
||||
export function resolveFieldsFromKeywords(
|
||||
keywords: TemplateKeyword[],
|
||||
configured: Record<string, string> | undefined
|
||||
): ResolveTemplateFieldsResult {
|
||||
const config = configured && typeof configured === 'object' ? configured : {}
|
||||
const configuredCodes = Object.keys(config)
|
||||
const invalidConfiguredCodes = configuredCodes.filter(
|
||||
(code) => !keywords.some((k) => k.code === code)
|
||||
)
|
||||
|
||||
const configResult = (reason: string, extra: Partial<ResolveTemplateFieldsResult> = {}) => ({
|
||||
fields: config,
|
||||
source: 'config' as const,
|
||||
reason,
|
||||
unresolvedVariables: [] as string[],
|
||||
invalidConfiguredCodes,
|
||||
...extra
|
||||
})
|
||||
|
||||
if (!keywords.length) {
|
||||
return configResult('微信模板内容里没解析出任何关键词,无法校准,沿用配置')
|
||||
}
|
||||
|
||||
// 我们要落到模板上的变量 = 配置里出现过的变量名(保持稳定顺序)
|
||||
const variables = Array.from(new Set(Object.values(config))).filter(Boolean)
|
||||
if (!variables.length) {
|
||||
return configResult('配置里没有任何字段映射,无法校准')
|
||||
}
|
||||
const unknownVars = variables.filter((v) => !VARIABLE_TYPES[v])
|
||||
if (unknownVars.length) {
|
||||
return configResult(`有变量的类型未知(${unknownVars.join('、')}),不做自动校准,沿用配置`)
|
||||
}
|
||||
|
||||
const used = new Set<string>()
|
||||
const resolved: Record<string, string> = {}
|
||||
const unresolved: string[] = []
|
||||
|
||||
// 一轮:类型 + 标签双重命中,优先认领把握最大的
|
||||
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 }
|
||||
}
|
||||
if (!best) {
|
||||
unresolved.push(variable)
|
||||
continue
|
||||
}
|
||||
used.add(best.code)
|
||||
resolved[best.code] = variable
|
||||
}
|
||||
|
||||
if (unresolved.length) {
|
||||
return configResult(
|
||||
`有变量在微信模板里找不到对应的关键词(${unresolved.join('、')}),沿用配置`,
|
||||
{ unresolvedVariables: unresolved }
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* 安全检查:同一类型出现多个关键词时,必须靠中文标签区分开。
|
||||
* 例如驳回模板有两个 thing,如果不看标签只按类型分配,songTitle 和 reason 会互换 ——
|
||||
* 能把消息发出去,但内容张冠李戴,比发不出去更糟。
|
||||
*/
|
||||
const byType = new Map<KeywordType, TemplateKeyword[]>()
|
||||
for (const kw of keywords) {
|
||||
const list = byType.get(kw.type) || []
|
||||
list.push(kw)
|
||||
byType.set(kw.type, list)
|
||||
}
|
||||
for (const [type, list] of byType) {
|
||||
if (list.length < 2) continue
|
||||
const claimants = list
|
||||
.map((kw) => resolved[kw.code])
|
||||
.filter((v): v is string => typeof v === 'string')
|
||||
if (claimants.length < 2) continue
|
||||
// 参与分配的两个以上变量里,只要有一个没靠标签命中,就认为这次分配不可靠
|
||||
const anyWeak = list.some((kw) => {
|
||||
const variable = resolved[kw.code]
|
||||
if (!variable) return false
|
||||
return !labelHits(kw.label, variable)
|
||||
})
|
||||
if (anyWeak) {
|
||||
return configResult(
|
||||
`模板里有 ${list.length} 个 ${type} 类型的关键词(${list
|
||||
.map((k) => k.code)
|
||||
.join('、')}),中文标签不足以区分 ${claimants.join('、')},沿用配置`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 配置里的关键词名只要有一个在微信模板里不存在,就说明配置已经过期,改用校准结果
|
||||
const changed =
|
||||
configuredCodes.some((code) => !(code in resolved)) ||
|
||||
Object.keys(resolved).some((code) => config[code] === undefined)
|
||||
|
||||
return {
|
||||
fields: resolved,
|
||||
source: 'wechat-api',
|
||||
reason: changed
|
||||
? `按微信真实模板校准了字段名:${Object.entries(resolved)
|
||||
.map(([code, variable]) => `${code}=${variable}`)
|
||||
.join(',')}`
|
||||
: '配置与微信真实模板一致',
|
||||
unresolvedVariables: [],
|
||||
invalidConfiguredCodes
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user