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

280 lines
10 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[]
}
/**
* 把配置里的字段映射,按微信真实模板「校准」一遍。
*
* 只有在**所有变量都能唯一认领一个关键词**时才采用微信侧的映射(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
}
}