fix(微信订阅): 字段名按微信真实模板校准,不再靠猜编号;phrase 只截断不补齐
This commit is contained in:
@@ -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`.
|
||||
@@ -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<string, unknown> = {
|
||||
@@ -60,7 +62,7 @@ export default defineEventHandler(async (event) => {
|
||||
reason: '测试驳回原因',
|
||||
message: '这是一条测试通知'
|
||||
},
|
||||
{ force, source: 'diagnose' }
|
||||
{ force, forceRemote, source: 'diagnose' }
|
||||
)
|
||||
attempts.push({
|
||||
kind,
|
||||
|
||||
@@ -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<string | null>
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 用微信真实模板校准字段名 ----------------
|
||||
|
||||
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<string>()
|
||||
|
||||
/**
|
||||
* 拿账号下已有的模板列表(**带真实关键词名**),按模板 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<ResolvedTemplate> {
|
||||
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<string, unknown>,
|
||||
opts: SendOptions = {}
|
||||
): Promise<SendOutcome> {
|
||||
/** 本次到底是用配置里的字段名,还是用微信真实模板校准出来的(进日志/返回值,方便排查) */
|
||||
let fieldsFromWechat = false
|
||||
const base = (
|
||||
partial: Partial<SendOutcome> & { 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<string, string>
|
||||
/** 配置里(代码/环境变量)写的字段名,用来和微信侧对比 */
|
||||
configuredFields: Record<string, string>
|
||||
/** `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<string, { value: strin
|
||||
*/
|
||||
export async function inspectWechatSubscribe(
|
||||
userId: number,
|
||||
opts: { username?: string | null } = {}
|
||||
opts: { username?: string | null; forceRemote?: boolean } = {}
|
||||
): Promise<SubscribeInspection> {
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 + ' 不能为空')
|
||||
})
|
||||
|
||||
@@ -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, /类型未知/)
|
||||
})
|
||||
@@ -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), [], '有完整变量时不该出现空字段')
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user