Files
BYW-VoiceHub/server/utils/wechatSubscribeRemote.ts
T

313 lines
13 KiB
TypeScript
Raw 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.
/**
* 从微信「获取已有模板列表」接口返回的模板内容里,反解出**真实的关键词名**。
*
* 为什么需要它:后台创建模板时微信会自动给关键词编号(`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[]
/**
* 微信模板里有、但**没有任何变量去填**的关键词(形如 `thing1`)。
* 微信对缺失的关键词会回 `47003 data.thing1.value is empty` —— 这种「少填」比「填错」更常见。
*/
unclaimedCodes: 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,
// 配置里没覆盖到的关键词 = 微信会报 value is empty 的那些
unclaimedCodes: keywords.map((k) => k.code).filter((code) => !(code in config)),
...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[] = []
/*
* 评分规则:首选类型 +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]
for (const kw of keywords) {
const typeRank = allowed.indexOf(kw.type)
if (typeRank < 0) continue
const score = (allowed.length - typeRank) * 2 + (labelHits(kw.label, variable) ? 3 : 0)
pairs.push({ variable, code: kw.code, score })
}
}
// 同分时优先「标签命中」的(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
}
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 会互换 —— 能把消息发出去,但内容张冠李戴,比发不出去更糟。
*/
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('、')},沿用配置`
)
}
}
/*
* 到这里已经通过了同类型区分的安全检查,剩下的唯一问题是「能不能把模板填满」:
* - 模板里每个关键词都有变量认领(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)) ||
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(',')}` +
(unresolved.length ? `(模板里没有对应关键词的变量已忽略:${unresolved.join('、')})` : '')
: '配置与微信真实模板一致',
unresolvedVariables: unresolved,
invalidConfiguredCodes,
unclaimedCodes: unclaimed
}
}