/** * 从微信「获取已有模板列表」接口返回的模板内容里,反解出**真实的关键词名**。 * * 为什么需要它:后台创建模板时微信会自动给关键词编号(`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 = { 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 = { 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 /** `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 | 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 = {}) => ({ 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() const resolved: Record = {} 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() 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 } }