diff --git a/.tmp-tsc.txt b/.tmp-tsc.txt deleted file mode 100644 index 120d5d4..0000000 --- a/.tmp-tsc.txt +++ /dev/null @@ -1,15 +0,0 @@ -server/services/wechatSubscribeService.ts(38,20): error TS2307: Cannot find module '~/drizzle/db' or its corresponding type declarations. -server/services/wechatSubscribeService.ts(39,77): error TS2307: Cannot find module '~/drizzle/schema' or its corresponding type declarations. -server/services/wechatSubscribeService.ts(40,39): error TS2307: Cannot find module 'drizzle-orm' or its corresponding type declarations. -server/services/wechatSubscribeService.ts(42,40): error TS2307: Cannot find module '~~/server/utils/wechatSubscribeFields' or its corresponding type declarations. -server/services/wechatSubscribeService.ts(51,8): error TS2307: Cannot find module '~~/server/utils/wechatSubscribeTemplates' or its corresponding type declarations. -server/services/wechatSubscribeService.ts(60,8): error TS2307: Cannot find module '~~/server/utils/wechatSubscribeFields' or its corresponding type declarations. -server/services/wechatSubscribeService.ts(69,8): error TS2307: Cannot find module '~~/server/utils/wechatSubscribeTemplates' or its corresponding type declarations. -server/services/wechatSubscribeService.ts(174,17): error TS2580: Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node`. -server/services/wechatSubscribeService.ts(175,18): error TS2580: Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node`. -server/services/wechatSubscribeService.ts(189,26): error TS2552: Cannot find name '$fetch'. Did you mean 'fetch'? -server/services/wechatSubscribeService.ts(694,24): error TS2552: Cannot find name '$fetch'. Did you mean 'fetch'? -server/services/wechatSubscribeService.ts(702,26): error TS2580: Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node`. -server/services/wechatSubscribeService.ts(964,19): error TS2580: Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node`. -server/services/wechatSubscribeService.ts(965,20): error TS2580: Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node`. -server/services/wechatSubscribeService.ts(966,16): error TS2580: Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node`. diff --git a/server/api/admin/wechat/diagnose.get.ts b/server/api/admin/wechat/diagnose.get.ts index 938044d..d1ae731 100644 --- a/server/api/admin/wechat/diagnose.get.ts +++ b/server/api/admin/wechat/diagnose.get.ts @@ -27,13 +27,15 @@ export default defineEventHandler(async (event) => { const query = getQuery(event) const doSend = query.send === '1' || query.send === 'true' const force = query.force === '1' || query.force === 'true' + const forceRemote = query.remote === '1' || query.remote === 'true' const requestedId = query.userId != null ? Number(query.userId) : NaN const targetUserId = Number.isFinite(requestedId) && requestedId > 0 ? requestedId : Number(user.id) // 只读体检:不发消息、不消耗额度。所有导出函数都不抛异常,失败信息都在返回值里 const inspection = await inspectWechatSubscribe(targetUserId, { - username: targetUserId === Number(user.id) ? user.username : null + username: targetUserId === Number(user.id) ? user.username : null, + forceRemote }) const result: Record = { @@ -60,7 +62,7 @@ export default defineEventHandler(async (event) => { reason: '测试驳回原因', message: '这是一条测试通知' }, - { force, source: 'diagnose' } + { force, forceRemote, source: 'diagnose' } ) attempts.push({ kind, diff --git a/server/services/wechatSubscribeService.ts b/server/services/wechatSubscribeService.ts index 649a226..071d216 100644 --- a/server/services/wechatSubscribeService.ts +++ b/server/services/wechatSubscribeService.ts @@ -67,14 +67,24 @@ export { TEMPLATES_ENV_KEY, type TemplateConf } from '~~/server/utils/wechatSubscribeTemplates' +import { + parseTemplateContent, + resolveFieldsFromKeywords, + type ResolveTemplateFieldsResult, + type TemplateKeyword +} from '~~/server/utils/wechatSubscribeRemote' const PROVIDER = 'wechat-mp' const TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/token' const SEND_URL = 'https://api.weixin.qq.com/cgi-bin/message/subscribe/send' +/** 获取账号下已有的订阅消息模板(含**真实的关键词名**,用来校准我们的字段映射) */ +const TEMPLATE_LIST_URL = 'https://api.weixin.qq.com/wxaapi/newtmpl/gettemplate' /** 订阅消息落地的小程序页面(用户点开通知卡片跳到哪) */ const NOTIFY_PAGE = 'pages/home/home' /** 提前 5 分钟作废 token,避免卡在边界上 */ const TOKEN_SAFETY_MS = 5 * 60 * 1000 +/** 模板列表缓存 10 分钟:模板偶尔会变,但不能每次发送都去问微信 */ +const TEMPLATE_CACHE_MS = 10 * 60 * 1000 // ---------------- 模板配置 ---------------- @@ -215,6 +225,167 @@ export async function getMiniAccessToken(force = false): Promise } } +// ---------------- 用微信真实模板校准字段名 ---------------- + +export type RemoteTemplateInfo = { + id: string + title: string + content: string + type: number | null + keywords: TemplateKeyword[] +} + +const remoteTemplateCache = new Map< + string, + { at: number; info: RemoteTemplateInfo | null; error: string | null } +>() +/** 每个模板只唠叨一次「字段名被校准过」,避免日志刷屏 */ +const remoteNoticeOnce = new Set() + +/** + * 拿账号下已有的模板列表(**带真实关键词名**),按模板 ID 找出来。 + * + * 这是唯一能知道「微信给这个模板的关键词编成了 thing1 还是 thing2」的途径 —— + * 后台创建模板时编号是微信自动打的,靠猜必然出错,错了就是整条 47003。 + * + * 失败一律返回 `null` 并**不影响发送**(调用方会退回配置里的字段名)。 + */ +export async function getRemoteTemplate( + templateId: string, + opts: { force?: boolean } = {} +): Promise<{ info: RemoteTemplateInfo | null; error: string | null }> { + if (!templateId) return { info: null, error: 'no-template-id' } + const cached = remoteTemplateCache.get(templateId) + if (!opts.force && cached && Date.now() - cached.at < TEMPLATE_CACHE_MS) { + return { info: cached.info, error: cached.error } + } + + const token = await getMiniAccessToken(opts.force) + if (!token) return { info: null, error: 'no-access-token' } + + let error: string | null = null + try { + let res: any = await $fetch(TEMPLATE_LIST_URL, { + query: { access_token: token }, + timeout: 8000, + responseType: 'json' + }) + if (typeof res === 'string') { + try { + res = JSON.parse(res) + } catch { + res = null + } + } + if (!res || (res.errcode !== undefined && res.errcode !== 0)) { + error = `获取模板列表失败:errcode=${res?.errcode} errmsg=${res?.errmsg}` + console.error(`[WeChatSubscribe] ${error}`) + remoteTemplateCache.set(templateId, { at: Date.now(), info: null, error }) + return { info: null, error } + } + const list: any[] = Array.isArray(res.data) ? res.data : [] + const hit = list.find((t) => String(t?.priTmplId) === String(templateId)) + if (!hit) { + error = + '这个模板 ID 不在账号的模板列表里(可能已删除,或属于另一个小程序)—— ' + + `账号下现有 ${list.length} 个模板:${list.map((t) => `${t?.title}(${t?.priTmplId})`).join('、') || '无'}` + console.error(`[WeChatSubscribe] ${error}`) + remoteTemplateCache.set(templateId, { at: Date.now(), info: null, error }) + return { info: null, error } + } + const info: RemoteTemplateInfo = { + id: String(hit.priTmplId), + title: String(hit.title || ''), + content: String(hit.content || ''), + type: typeof hit.type === 'number' ? hit.type : null, + keywords: parseTemplateContent(String(hit.content || '')) + } + remoteTemplateCache.set(templateId, { at: Date.now(), info, error: null }) + return { info, error: null } + } catch (err: any) { + error = `获取模板列表异常:${err?.message}` + console.error(`[WeChatSubscribe] ${error}`) + remoteTemplateCache.set(templateId, { at: Date.now(), info: null, error }) + return { info: null, error } + } +} + +export type ResolvedTemplate = { + /** 真正用来发消息的模板配置(字段名可能已被微信侧校准过) */ + conf: TemplateConf + /** 校准过程与结论;null = 没走到联网校准(没模板 ID) */ + resolution: + | (ResolveTemplateFieldsResult & { + remoteError: string | null + remoteTitle: string | null + remoteContent: string | null + remoteKeywords: TemplateKeyword[] + }) + | null +} + +/** + * 把配置里的模板「校准」成微信侧真实的样子。 + * + * 优先级:微信模板列表里反解出的真实关键词名 > 配置里的字段名。 + * 拿不到模板列表时**原样返回配置**(绝不因为校准失败而让通知停摆)。 + */ +export async function resolveTemplate( + conf: TemplateConf, + opts: { forceRemote?: boolean } = {} +): Promise { + if (!conf || !conf.id) { + return { + conf, + resolution: null + } + } + const { info, error } = await getRemoteTemplate(conf.id, { force: opts.forceRemote }) + if (!info) { + return { + conf, + resolution: { + fields: conf.fields || {}, + source: 'config', + reason: `拿不到微信模板列表(${error || '未知原因'}),沿用配置里的字段名`, + unresolvedVariables: [], + invalidConfiguredCodes: [], + remoteError: error, + remoteTitle: null, + remoteContent: null, + remoteKeywords: [] + } + } + } + + const resolved = resolveFieldsFromKeywords(info.keywords, conf.fields) + const effective: TemplateConf = + resolved.source === 'wechat-api' ? { ...conf, fields: resolved.fields } : conf + + if (resolved.source === 'wechat-api') { + const before = JSON.stringify(conf.fields || {}) + const after = JSON.stringify(resolved.fields) + if (before !== after && !remoteNoticeOnce.has(conf.id)) { + remoteNoticeOnce.add(conf.id) + console.warn( + `[WeChatSubscribe] 模板「${info.title || conf.title || conf.id}」的字段名已按微信真实模板校准:` + + `${before} → ${after}(配置里的编号是猜的,微信自动分配的编号以这里为准)` + ) + } + } + + return { + conf: effective, + resolution: { + ...resolved, + remoteError: null, + remoteTitle: info.title || null, + remoteContent: info.content || null, + remoteKeywords: info.keywords + } + } +} + // ---------------- 用户侧查询 ---------------- /** 取用户的小程序 openid(没绑微信就是 null) */ @@ -444,6 +615,8 @@ export type SendOutcome = { emptyFields?: Array<{ field: string; variable: string }> /** 本次真的调用微信了吗(empty-field / dryRun 等预检不通过时为 false,不消耗额度) */ attempted: boolean + /** 本次的字段名来自哪里:`config`=配置里写的,`wechat-api`=按微信真实模板校准出来的 */ + fieldsResolved?: 'config' | 'wechat-api' } type SendOptions = { @@ -453,6 +626,8 @@ type SendOptions = { dryRun?: boolean /** 日志前缀里的来源标记(例如 'test-subscribe' / 'diagnose') */ source?: string + /** 强制刷新微信侧模板列表缓存(改了模板后自测用) */ + forceRemote?: boolean } function logOutcome(o: SendOutcome, source?: string) { @@ -479,6 +654,8 @@ export async function sendWechatSubscribe( vars: Record, opts: SendOptions = {} ): Promise { + /** 本次到底是用配置里的字段名,还是用微信真实模板校准出来的(进日志/返回值,方便排查) */ + let fieldsFromWechat = false const base = ( partial: Partial & { stage: SendStage; reason: string; detail: string } ): SendOutcome => { @@ -487,6 +664,7 @@ export async function sendWechatSubscribe( kind, userId, attempted: false, + fieldsResolved: fieldsFromWechat ? 'wechat-api' : 'config', ...partial } logOutcome(outcome, opts.source) @@ -494,8 +672,8 @@ export async function sendWechatSubscribe( } try { - const conf = getSubscribeTemplates()[kind] - if (!conf || !conf.id) { + const configured = getSubscribeTemplates()[kind] + if (!configured || !configured.id) { return base({ stage: 'no-template', reason: 'no-template', @@ -503,6 +681,13 @@ export async function sendWechatSubscribe( }) } + // 用微信真实模板校准字段名(后台给关键词自动编号,猜的编号会整条 47003)。 + // 校准失败不影响发送:resolveTemplate 会原样退回配置。 + const { conf, resolution } = await resolveTemplate(configured, { + forceRemote: opts.forceRemote + }) + fieldsFromWechat = resolution?.source === 'wechat-api' + const notif = await getNotificationEnabled(userId) if (!notif.enabled) { return base({ @@ -562,7 +747,8 @@ export async function sendWechatSubscribe( detail: `dryRun:没有真的发送(预检通过、字段都有值;本地记账显示大约还剩 ${remaining} 条,` + '但这个数字仅供参考,真正有没有额度要看微信回的 errcode)', - attempted: false + attempted: false, + fieldsResolved: fieldsFromWechat ? 'wechat-api' : 'config' } console.log( `[WeChatSubscribe]${opts.source ? `[${opts.source}]` : ''} ${kind} → user ${userId} dry-run 通过,预览 data=${JSON.stringify(data)}` @@ -596,7 +782,8 @@ export async function sendWechatSubscribe( detail: '微信已受理,消息会出现在用户的「服务通知」里', errcode: 0, errmsg: raw?.errmsg ?? null, - attempted: true + attempted: true, + fieldsResolved: fieldsFromWechat ? 'wechat-api' : 'config' } logOutcome(outcome, opts.source) return outcome @@ -619,8 +806,24 @@ export async function sendWechatSubscribe( } else if (errcode === 47003) { stage = 'rejected-bad-field' reason = 'bad-field' + /* + * 47003 是最难查的一个:微信只点名一个字段,但真正不合规的可能在别处。 + * 我们能提供的确定性信息有两个 —— 这次用的是配置里的字段名还是微信真实模板 + * 校准过的,以及校准结论。都写进 detail,省得下次还要猜。 + */ + const hint = resolution + ? resolution.source === 'wechat-api' + ? `本次字段名是【按微信真实模板校准】的(${resolution.reason}),所以问题更可能在字段值本身:` + : `本次字段名是【沿用配置】的(${resolution.reason}),很可能是配置里的编号和微信模板不一致:` + : '本次字段名是【沿用配置】的:' detail = - '微信回 47003:模板字段名或字段值不合规。去公众平台核对模板字段名是否和 fields 映射一致(也可能是某个字段超长/为空)' + `微信回 47003:模板字段名或字段值不合规。${hint}` + + '用 /api/admin/wechat/diagnose 可以看到微信侧的真实关键词名。' + + (resolution?.remoteKeywords?.length + ? ` 微信侧关键词:${resolution.remoteKeywords + .map((k) => `${k.code}${k.label ? `(${k.label})` : ''}`) + .join('、')}` + : '') } const outcome: SendOutcome = { ok: false, @@ -632,7 +835,8 @@ export async function sendWechatSubscribe( detail, errcode, errmsg: raw?.errmsg ?? null, - attempted: true + attempted: true, + fieldsResolved: fieldsFromWechat ? 'wechat-api' : 'config' } logOutcome(outcome, opts.source) return outcome @@ -759,8 +963,24 @@ export type TemplateStatus = { kind: string title: string templateId: string - /** 字段名 → 变量名 */ + /** 实际用来发消息的字段名 → 变量名(可能已按微信真实模板校准过) */ fields: Record + /** 配置里(代码/环境变量)写的字段名,用来和微信侧对比 */ + configuredFields: Record + /** `config`=用的是配置里的字段名;`wechat-api`=已按微信真实模板校准 */ + fieldSource: 'config' | 'wechat-api' + /** 这次字段名是怎么定的(人话) */ + fieldReason: string + /** 配置里有、但微信模板里根本没有的关键词名(有值就一定会 47003) */ + invalidConfiguredCodes: string[] + /** 微信侧真实的关键词表(拿不到时为 null) */ + remoteKeywords: Array<{ code: string; type: string; label: string }> | null + /** 微信侧的模板原文(能看到真实的关键词与顺序) */ + remoteContent: string | null + /** 微信侧的模板标题(和本地配置的标题不一致时,说明配置指向了别的模板) */ + remoteTitle: string | null + /** 拿微信模板列表失败的原因(null = 成功) */ + remoteError: string | null /** 这些字段映射到的变量,调用方有没有给(给了模板里也没用时是另一回事) */ fieldsWithSample: Array<{ field: string; variable: string; sampleValue: string }> /** 本地记账:用户点过几次授权、成功发过几条、被拒过几次 */ @@ -832,7 +1052,7 @@ export function sampleDataFor(conf: TemplateConf): Record { const report = getTemplatesReport() const templates = report.templates @@ -847,17 +1067,22 @@ export async function inspectWechatSubscribe( kinds.map((k) => templates[k].id) ) - const templateStatuses: TemplateStatus[] = kinds.map((kind) => { - const conf = templates[kind] + const templateStatuses: TemplateStatus[] = [] + for (const kind of kinds) { + const configured = templates[kind] + const { conf, resolution } = await resolveTemplate(configured, { + forceRemote: opts.forceRemote + }) const grant = grantMap.get(conf.id) const grantedCount = grant?.grantedCount ?? 0 const sentCount = grant?.sentCount ?? 0 const blockedCount = grant?.blockedCount ?? 0 const remainingApprox = Math.max(0, grantedCount - sentCount) + const sampleData = sampleDataFor(conf) const fieldsWithSample = Object.keys(conf.fields || {}).map((field) => ({ field, variable: conf.fields[field], - sampleValue: String(sampleDataFor(conf)[field]?.value ?? '') + sampleValue: String(sampleData[field]?.value ?? '') })) let verdict: string @@ -875,11 +1100,25 @@ export async function inspectWechatSubscribe( else verdict = `本地记账显示还有约 ${remainingApprox} 条额度(我们自己的计数,不是微信侧的余额)` - return { + templateStatuses.push({ kind, title: conf.title || kind, templateId: conf.id, fields: conf.fields || {}, + configuredFields: configured.fields || {}, + fieldSource: resolution?.source === 'wechat-api' ? 'wechat-api' : 'config', + fieldReason: resolution?.reason || '没有模板 ID,无法校准', + invalidConfiguredCodes: resolution?.invalidConfiguredCodes || [], + remoteKeywords: resolution?.remoteKeywords?.length + ? resolution.remoteKeywords.map((k) => ({ + code: k.code, + type: k.type, + label: k.label + })) + : null, + remoteContent: resolution?.remoteContent ?? null, + remoteTitle: resolution?.remoteTitle ?? null, + remoteError: resolution?.remoteError ?? null, fieldsWithSample, grantedCount, sentCount, @@ -891,8 +1130,8 @@ export async function inspectWechatSubscribe( lastErrorCode: grant?.lastErrorCode ?? null, lastErrorMessage: grant?.lastErrorMessage ?? null, verdict - } - }) + }) + } const actions: string[] = [] if (!report.envJsonValid) { @@ -927,9 +1166,28 @@ export async function inspectWechatSubscribe( ) } templateStatuses.forEach((t) => { + // 字段名是 47003 的头号嫌疑,体检里把它放在前面说清楚 + if (t.remoteError) { + actions.push( + `模板「${t.title}」拿不到微信侧的关键词列表(${t.remoteError}),本次只能沿用配置里的字段名` + + (t.invalidConfiguredCodes.length + ? `;而配置里的 ${t.invalidConfiguredCodes.join('、')} 在微信模板里查不到,很可能就是 47003 的原因` + : '') + ) + } else if (t.fieldSource === 'wechat-api' && t.invalidConfiguredCodes.length) { + actions.push( + `模板「${t.title}」配置里的字段名 ${t.invalidConfiguredCodes.join('、')} 在微信模板里不存在;` + + `服务端已自动改用微信真实的关键词(${Object.entries(t.fields) + .map(([code, v]) => `${code}=${v}`) + .join(',')})。建议把这些编号写回 BUILTIN_TEMPLATES 或环境变量,省掉每次的校准请求` + ) + } if (t.lastErrorCode === 47003) { actions.push( - `模板「${t.title}」上次被微信以 47003 拒绝:核对公众平台的字段名是否与 fields 映射一致` + `模板「${t.title}」上次被微信以 47003 拒绝:` + + (t.fieldSource === 'wechat-api' + ? '字段名已按微信真实模板校准,问题更可能在**字段值**(为空/超长/类型不符)' + : '字段名还是配置里写的,看上面 remoteKeywords 与配置的差异') ) } if (t.lastErrorCode === 43101) { diff --git a/server/utils/wechatSubscribeFields.ts b/server/utils/wechatSubscribeFields.ts index 8a49c79..87f357a 100644 --- a/server/utils/wechatSubscribeFields.ts +++ b/server/utils/wechatSubscribeFields.ts @@ -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) diff --git a/server/utils/wechatSubscribeRemote.ts b/server/utils/wechatSubscribeRemote.ts new file mode 100644 index 0000000..72f1aa8 --- /dev/null +++ b/server/utils/wechatSubscribeRemote.ts @@ -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 = { + 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 + } +} diff --git a/test/wechatSubscribeFields.test.mjs b/test/wechatSubscribeFields.test.mjs index d2052d6..3c610ae 100644 --- a/test/wechatSubscribeFields.test.mjs +++ b/test/wechatSubscribeFields.test.mjs @@ -28,10 +28,10 @@ test('thing 型:20 字以内原样,超了截 19 字 + 省略号(正好 20 assert.ok(long.startsWith(clamped.slice(0, 19))) }) -test('phrase 型:5 个以内纯汉字——“已排期”会被补成全角 5 字,不是原样发出去', () => { - const padded = clampField('phrase5', '已排期') - assert.equal(len(padded), 5) - assert.ok(padded.startsWith('已排期')) +test('phrase 型:5 个以内纯汉字——短的原样发(不补全角空格),超了截到 5', () => { + // 「已排期」3 个汉字本来就合规,绝不能补成 "已排期  "(U+3000 不是汉字,会 47003) + assert.equal(clampField('phrase5', '已排期'), '已排期') + assert.equal(clampField('phrase5', '已排期').includes(' '), false) // 刚好 5 个汉字原样保留 assert.equal(clampField('phrase5', '已排期播放'), '已排期播放') @@ -106,7 +106,7 @@ test('SONG_SELECTED 的内置字段映射:每个字段名后缀都要跟发出 assert.equal(out.date4, '2026-02-14') assert.match(out.time1, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/) assert.equal(out.character_string3, '1') - assert.equal(len(out.phrase5), 5) // “已排期” 3 字 → 补齐到 5 字,避开 47003 + assert.equal(out.phrase5, '已排期') // 3 个汉字本来就合规,不补全角空格 // 不能有任何字段是空值:空值同样会被微信拒 for (const key of Object.keys(out)) assert.ok(out[key].length > 0, key + ' 不能为空') }) diff --git a/test/wechatSubscribeRemote.test.mjs b/test/wechatSubscribeRemote.test.mjs new file mode 100644 index 0000000..bdafbb1 --- /dev/null +++ b/test/wechatSubscribeRemote.test.mjs @@ -0,0 +1,132 @@ +/** + * `server/utils/wechatSubscribeRemote.ts` 的单测:把微信模板原文解析成关键词表, + * 再按类型/中文标签把我们的变量名对应到微信**真实分配**的关键词编号。 + * + * 背景:公众平台创建模板时,关键词编号(thing1 / date2 …)是微信自动打的,猜不出来。 + * 猜错的表现就是整条消息 47003,而报错点名的字段往往不是真凶 —— 所以解析和映射必须能单独测。 + * + * 运行:node --test test/wechatSubscribeRemote.test.mjs + */ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { + keywordTypeOf, + parseTemplateContent, + resolveFieldsFromKeywords +} from '../server/utils/wechatSubscribeRemote.ts' + +/** 微信「获取已有模板列表」返回里 content 的真实形状 */ +const SONG_SELECTED_CONTENT = + '歌曲名称:{{thing1.DATA}}\n播放日期:{{date2.DATA}}\n播放时间:{{time3.DATA}}\n序号:{{character_string4.DATA}}\n状态:{{phrase5.DATA}}\n' + +const SONG_REJECTED_CONTENT = '审核内容:{{thing6.DATA}}\n驳回原因:{{thing4.DATA}}\n' + +test('keywordTypeOf:去掉尾部数字得到关键词类型,认不出的记为 unknown', () => { + assert.equal(keywordTypeOf('thing1'), 'thing') + assert.equal(keywordTypeOf('character_string3'), 'character_string') + assert.equal(keywordTypeOf('phone_number2'), 'phone_number') + assert.equal(keywordTypeOf('foo2'), 'unknown') + assert.equal(keywordTypeOf(''), 'unknown') +}) + +test('parseTemplateContent:按出现顺序解析出 code / type / 中文标签', () => { + const kws = parseTemplateContent(SONG_SELECTED_CONTENT) + assert.deepEqual( + kws.map((k) => k.code), + ['thing1', 'date2', 'time3', 'character_string4', 'phrase5'] + ) + assert.deepEqual( + kws.map((k) => k.type), + ['thing', 'date', 'time', 'character_string', 'phrase'] + ) + assert.deepEqual( + kws.map((k) => k.label), + ['歌曲名称', '播放日期', '播放时间', '序号', '状态'] + ) + assert.deepEqual( + kws.map((k) => k.index), + [0, 1, 2, 3, 4] + ) +}) + +test('parseTemplateContent:没有占位符就返回空表,不会抛', () => { + assert.deepEqual(parseTemplateContent(''), []) + assert.deepEqual(parseTemplateContent('随便一段没有占位符的文字'), []) + assert.deepEqual(parseTemplateContent(undefined), []) +}) + +test('resolveFieldsFromKeywords:配置的编号和微信一致时保持 config 以外的等价结果', () => { + const configured = { + thing1: 'songTitle', + date2: 'playDate', + time3: 'playTime', + character_string4: 'sequence', + phrase5: 'status' + } + const r = resolveFieldsFromKeywords(parseTemplateContent(SONG_SELECTED_CONTENT), configured) + assert.equal(r.source, 'wechat-api') + assert.deepEqual(r.fields, configured) + assert.deepEqual(r.invalidConfiguredCodes, []) + assert.match(r.reason, /一致/) +}) + +test('resolveFieldsFromKeywords:配置编号猜错了也能按类型/标签纠正回真实编号', () => { + // 我们原来猜的是 thing2/date4/time1/character_string3/phrase5,实际是 thing1/date2/time3/character_string4/phrase5 + const configured = { + thing2: 'songTitle', + date4: 'playDate', + time1: 'playTime', + character_string3: 'sequence', + phrase5: 'status' + } + const r = resolveFieldsFromKeywords(parseTemplateContent(SONG_SELECTED_CONTENT), configured) + assert.equal(r.source, 'wechat-api') + assert.deepEqual(r.fields, { + thing1: 'songTitle', + date2: 'playDate', + time3: 'playTime', + character_string4: 'sequence', + phrase5: 'status' + }) + // 配置里 4 个编号在微信模板里根本不存在 —— 这就是 47003 的直接证据 + assert.deepEqual(r.invalidConfiguredCodes.sort(), [ + 'character_string3', + 'date4', + 'thing2', + 'time1' + ]) + assert.match(r.reason, /校准/) +}) + +test('resolveFieldsFromKeywords:两个同类型关键词必须靠中文标签区分(驳回模板)', () => { + const configured = { thing6: 'songTitle', thing4: 'reason' } + const r = resolveFieldsFromKeywords(parseTemplateContent(SONG_REJECTED_CONTENT), configured) + assert.equal(r.source, 'wechat-api') + assert.deepEqual(r.fields, { thing6: 'songTitle', thing4: 'reason' }) + assert.deepEqual(r.invalidConfiguredCodes, []) +}) + +test('resolveFieldsFromKeywords:两个 thing 但标签认不出来时,不冒险互换,沿用配置', () => { + const content = '{{thing1.DATA}}\n{{thing2.DATA}}\n' + const configured = { thing6: 'songTitle', thing4: 'reason' } + const r = resolveFieldsFromKeywords(parseTemplateContent(content), configured) + assert.equal(r.source, 'config') + assert.deepEqual(r.fields, configured) + assert.match(r.reason, /标签不足以区分/) +}) + +test('resolveFieldsFromKeywords:拿不到关键词表时一律沿用配置', () => { + const configured = { thing2: 'songTitle' } + const r = resolveFieldsFromKeywords([], configured) + assert.equal(r.source, 'config') + assert.deepEqual(r.fields, configured) +}) + +test('resolveFieldsFromKeywords:变量类型未知时不做自动映射', () => { + const r = resolveFieldsFromKeywords(parseTemplateContent(SONG_SELECTED_CONTENT), { + thing1: 'whateverCustomVar' + }) + assert.equal(r.source, 'config') + assert.match(r.reason, /类型未知/) +}) diff --git a/test/wechatSubscribeTemplates.test.mjs b/test/wechatSubscribeTemplates.test.mjs index 05e91da..11103ec 100644 --- a/test/wechatSubscribeTemplates.test.mjs +++ b/test/wechatSubscribeTemplates.test.mjs @@ -176,7 +176,7 @@ test('buildTemplateData:按映射取值,并交给 clamp 处理(time 有兜 assert.deepEqual(Object.keys(data), ['thing2', 'date4', 'time1', 'character_string3', 'phrase5']) assert.equal(data.thing2.value, '晴天') assert.equal(data.character_string3.value, '3') - assert.equal(Array.from(data.phrase5.value).length, 5) + assert.equal(data.phrase5.value, '已排期') assert.deepEqual(findEmptyDataFields(data), [], '有完整变量时不该出现空字段') })