fix(微信订阅): 用微信真实关键词修正歌曲排期模板(thing1/phrase2/time3/character_string4),并修掉字段认领顺序导致的校准失效

This commit is contained in:
2026-10-24 11:56:50 +08:00
parent dc2565da34
commit f5a43b37cc
7 changed files with 192 additions and 56 deletions
+6 -2
View File
@@ -125,7 +125,9 @@ WECHAT_MINI_SECRET=
# 微信「订阅消息」模板(可选:**留空也能用**,代码里内置了站长选好的模板) # 微信「订阅消息」模板(可选:**留空也能用**,代码里内置了站长选好的模板)
# 内置模板(唯一来源:server/utils/wechatSubscribeTemplates.ts 的 BUILTIN_TEMPLATES): # 内置模板(唯一来源:server/utils/wechatSubscribeTemplates.ts 的 BUILTIN_TEMPLATES):
# SONG_SELECTED「歌曲排期通知」—— 管理员把歌排进排期/发布排期时发 # SONG_SELECTED「歌曲排期通知」—— 管理员把歌排进排期/发布排期时发
# thing2→songTitle, date4→playDate, time1→playTime, character_string3→sequence, phrase5→status # thing1→songTitle, phrase2→status, time3→playTime, character_string4→sequence
# (微信侧真实关键词,模板原文:歌曲名称/排期状态/播放时间/排期序号;**没有日期字段**,
# 日期含在 playTime 的 'YYYY-MM-DD HH:mm' 里)
# SONG_REJECTED「审核驳回通知」—— 投稿被驳回时发 # SONG_REJECTED「审核驳回通知」—— 投稿被驳回时发
# thing6→songTitle, thing4→reason # thing6→songTitle, thing4→reason
# 下面这条环境变量**只用来覆盖内置映射**(比如站长在公众平台选了别的模板或字段名)。 # 下面这条环境变量**只用来覆盖内置映射**(比如站长在公众平台选了别的模板或字段名)。
@@ -134,7 +136,9 @@ WECHAT_MINI_SECRET=
# {"SONG_SELECTED":{"id":"模板ID"}} ← 只换 ID,沿用内置字段映射 # {"SONG_SELECTED":{"id":"模板ID"}} ← 只换 ID,沿用内置字段映射
# {"SONG_REJECTED":{"id":"模板ID","fields":{"thing6":"songTitle"}}} ← 同时换字段映射 # {"SONG_REJECTED":{"id":"模板ID","fields":{"thing6":"songTitle"}}} ← 同时换字段映射
# {"SONG_PLAYED":{"id":"模板ID","fields":{"thing1":"songTitle"}}} ← 也可以补一张内置表里没有的模板 # {"SONG_PLAYED":{"id":"模板ID","fields":{"thing1":"songTitle"}}} ← 也可以补一张内置表里没有的模板
# fields 的键是模板里的字段名(thing2/date4/time1/character_string3/phrase5,公众平台选用模板时能看到), # fields 的键是模板里的字段名(thing1/phrase2/time3/character_string4,公众平台选用模板时能看到;
# **编号是微信自动分配的,不能靠命名规律猜** —— 猜错一位就是整条 47003。核不准就别写,
# 服务端会自动调 /wxaapi/newtmpl/gettemplate 读模板原文来校准),
# 值是变量名(可用:songTitle / message / time / playDate / playTime / sequence / status / reason)。 # 值是变量名(可用:songTitle / message / time / playDate / playTime / sequence / status / reason)。
# ⚠️ 平台限制:一次性订阅=一次授权只能发一条;只有绑定了微信的账号才有 openid; # ⚠️ 平台限制:一次性订阅=一次授权只能发一条;只有绑定了微信的账号才有 openid;
# 模板 ID 变了老用户要**重新点一次「开启微信通知」**才有额度(旧授权按模板 ID 记账,直接作废)。 # 模板 ID 变了老用户要**重新点一次「开启微信通知」**才有额度(旧授权按模板 ID 记账,直接作废)。
+15
View File
@@ -350,6 +350,7 @@ export async function resolveTemplate(
reason: `拿不到微信模板列表(${error || '未知原因'}),沿用配置里的字段名`, reason: `拿不到微信模板列表(${error || '未知原因'}),沿用配置里的字段名`,
unresolvedVariables: [], unresolvedVariables: [],
invalidConfiguredCodes: [], invalidConfiguredCodes: [],
unclaimedCodes: [],
remoteError: error, remoteError: error,
remoteTitle: null, remoteTitle: null,
remoteContent: null, remoteContent: null,
@@ -818,6 +819,9 @@ export async function sendWechatSubscribe(
: '本次字段名是【沿用配置】的:' : '本次字段名是【沿用配置】的:'
detail = detail =
`微信回 47003:模板字段名或字段值不合规。${hint}` + `微信回 47003:模板字段名或字段值不合规。${hint}` +
(resolution?.unclaimedCodes?.length
? `还有关键词没被任何字段填上(${resolution.unclaimedCodes.join('、')}),微信会报「value is empty」。`
: '') +
'用 /api/admin/wechat/diagnose 可以看到微信侧的真实关键词名。' + '用 /api/admin/wechat/diagnose 可以看到微信侧的真实关键词名。' +
(resolution?.remoteKeywords?.length (resolution?.remoteKeywords?.length
? ` 微信侧关键词:${resolution.remoteKeywords ? ` 微信侧关键词:${resolution.remoteKeywords
@@ -973,6 +977,8 @@ export type TemplateStatus = {
fieldReason: string fieldReason: string
/** 配置里有、但微信模板里根本没有的关键词名(有值就一定会 47003) */ /** 配置里有、但微信模板里根本没有的关键词名(有值就一定会 47003) */
invalidConfiguredCodes: string[] invalidConfiguredCodes: string[]
/** 微信模板里有、但没有任何字段去填的关键词(微信会回 `data.xxx.value is empty`) */
unclaimedCodes: string[]
/** 微信侧真实的关键词表(拿不到时为 null) */ /** 微信侧真实的关键词表(拿不到时为 null) */
remoteKeywords: Array<{ code: string; type: string; label: string }> | null remoteKeywords: Array<{ code: string; type: string; label: string }> | null
/** 微信侧的模板原文(能看到真实的关键词与顺序) */ /** 微信侧的模板原文(能看到真实的关键词与顺序) */
@@ -1109,6 +1115,7 @@ export async function inspectWechatSubscribe(
fieldSource: resolution?.source === 'wechat-api' ? 'wechat-api' : 'config', fieldSource: resolution?.source === 'wechat-api' ? 'wechat-api' : 'config',
fieldReason: resolution?.reason || '没有模板 ID,无法校准', fieldReason: resolution?.reason || '没有模板 ID,无法校准',
invalidConfiguredCodes: resolution?.invalidConfiguredCodes || [], invalidConfiguredCodes: resolution?.invalidConfiguredCodes || [],
unclaimedCodes: resolution?.unclaimedCodes || [],
remoteKeywords: resolution?.remoteKeywords?.length remoteKeywords: resolution?.remoteKeywords?.length
? resolution.remoteKeywords.map((k) => ({ ? resolution.remoteKeywords.map((k) => ({
code: k.code, code: k.code,
@@ -1166,6 +1173,14 @@ export async function inspectWechatSubscribe(
) )
} }
templateStatuses.forEach((t) => { templateStatuses.forEach((t) => {
// 「少填一个关键词」是 47003 `data.xxx.value is empty` 的直接原因,比字段名错更常见,先说
if (t.unclaimedCodes.length) {
actions.push(
`模板「${t.title}」里有关键词没被任何字段填上:${t.unclaimedCodes.join('、')} —— ` +
`微信会回 47003「data.${t.unclaimedCodes[0]}.value is empty」。` +
`对照 remoteContent 把这几个关键词补进 fields 映射(少填一个就整条发不出去)`
)
}
// 字段名是 47003 的头号嫌疑,体检里把它放在前面说清楚 // 字段名是 47003 的头号嫌疑,体检里把它放在前面说清楚
if (t.remoteError) { if (t.remoteError) {
actions.push( actions.push(
+54 -21
View File
@@ -153,6 +153,11 @@ export type ResolveTemplateFieldsResult = {
unresolvedVariables: string[] unresolvedVariables: string[]
/** 配置里有、但微信模板里根本不存在的关键词名(这些字段微信会直接 47003) */ /** 配置里有、但微信模板里根本不存在的关键词名(这些字段微信会直接 47003) */
invalidConfiguredCodes: string[] invalidConfiguredCodes: string[]
/**
* 微信模板里有、但**没有任何变量去填**的关键词(形如 `thing1`)。
* 微信对缺失的关键词会回 `47003 data.thing1.value is empty` —— 这种「少填」比「填错」更常见。
*/
unclaimedCodes: string[]
} }
/** /**
@@ -180,6 +185,8 @@ export function resolveFieldsFromKeywords(
reason, reason,
unresolvedVariables: [] as string[], unresolvedVariables: [] as string[],
invalidConfiguredCodes, invalidConfiguredCodes,
// 配置里没覆盖到的关键词 = 微信会报 value is empty 的那些
unclaimedCodes: keywords.map((k) => k.code).filter((code) => !(code in config)),
...extra ...extra
}) })
@@ -201,37 +208,44 @@ export function resolveFieldsFromKeywords(
const resolved: Record<string, string> = {} const resolved: Record<string, string> = {}
const unresolved: 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) { for (const variable of variables) {
const allowed = VARIABLE_TYPES[variable] const allowed = VARIABLE_TYPES[variable]
let best: { code: string; score: number } | null = null
for (const kw of keywords) { for (const kw of keywords) {
if (used.has(kw.code)) continue
const typeRank = allowed.indexOf(kw.type) const typeRank = allowed.indexOf(kw.type)
if (typeRank < 0) continue if (typeRank < 0) continue
// 首选类型 +2,备选类型递减;中文标签命中 +3(目的就是压过纯类型匹配)
const score = (allowed.length - typeRank) * 2 + (labelHits(kw.label, variable) ? 3 : 0) const score = (allowed.length - typeRank) * 2 + (labelHits(kw.label, variable) ? 3 : 0)
if (!best || score > best.score) best = { code: kw.code, score } pairs.push({ variable, code: kw.code, score })
} }
if (!best) {
unresolved.push(variable)
continue
} }
used.add(best.code) // 同分时优先「标签命中」的(score 里已经含了,这里只是让顺序稳定)
resolved[best.code] = variable 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
} }
if (unresolved.length) { for (const variable of variables) {
return configResult( if (!Object.values(resolved).includes(variable)) unresolved.push(variable)
`有变量在微信模板里找不到对应的关键词(${unresolved.join('、')}),沿用配置`,
{ unresolvedVariables: unresolved }
)
} }
const unclaimed = keywords.map((k) => k.code).filter((code) => !(code in resolved))
/* /*
* 安全检查:同一类型出现多个关键词时,必须靠中文标签区分开。 * 安全检查(先做,因为它是"不可采纳"的硬条件):同一类型出现多个关键词时,
* 例如驳回模板有两个 thing,如果不看标签只按类型分配,songTitle 和 reason 会互换 —— * 必须靠中文标签区分开。例如驳回模板有两个 thing,如果不看标签只按类型分配,
* 能把消息发出去,但内容张冠李戴,比发不出去更糟。 * songTitle 和 reason 会互换 —— 能把消息发出去,但内容张冠李戴,比发不出去更糟。
*/ */
const byType = new Map<KeywordType, TemplateKeyword[]>() const byType = new Map<KeywordType, TemplateKeyword[]>()
for (const kw of keywords) { for (const kw of keywords) {
@@ -260,6 +274,23 @@ export function resolveFieldsFromKeywords(
} }
} }
/*
* 到这里已经通过了同类型区分的安全检查,剩下的唯一问题是「能不能把模板填满」:
* - 模板里每个关键词都有变量认领(unclaimed 为空)→ 采用微信侧映射。
* 此时即使有变量在模板里找不到位置(比如配置里写了模板没有的 date),也**直接忽略**它 ——
* 发一个微信不认识的字段没有任何好处,只会让整条消息 47003。
* - 还有关键词没人填 → 配置可能本来就指望它(例如配置里写了正确的 phrase5),
* 这时候不该自作聪明覆盖配置,沿用配置更安全。
*/
if (unclaimed.length) {
return configResult(
`微信模板里还有关键词没被任何字段填上(${unclaimed.join('、')}),` +
(unresolved.length ? `同时有变量在模板里找不到位置(${unresolved.join('、')}):` : '') +
'为避免把模板改坏,沿用配置',
{ unresolvedVariables: unresolved }
)
}
// 配置里的关键词名只要有一个在微信模板里不存在,就说明配置已经过期,改用校准结果 // 配置里的关键词名只要有一个在微信模板里不存在,就说明配置已经过期,改用校准结果
const changed = const changed =
configuredCodes.some((code) => !(code in resolved)) || configuredCodes.some((code) => !(code in resolved)) ||
@@ -271,9 +302,11 @@ export function resolveFieldsFromKeywords(
reason: changed reason: changed
? `按微信真实模板校准了字段名:${Object.entries(resolved) ? `按微信真实模板校准了字段名:${Object.entries(resolved)
.map(([code, variable]) => `${code}=${variable}`) .map(([code, variable]) => `${code}=${variable}`)
.join(',')}` .join(',')}` +
(unresolved.length ? `(模板里没有对应关键词的变量已忽略:${unresolved.join('、')})` : '')
: '配置与微信真实模板一致', : '配置与微信真实模板一致',
unresolvedVariables: [], unresolvedVariables: unresolved,
invalidConfiguredCodes invalidConfiguredCodes,
unclaimedCodes: unclaimed
} }
} }
+17 -6
View File
@@ -19,7 +19,7 @@
* *
* | key | 用途 | 模板字段 → 变量 | * | key | 用途 | 模板字段 → 变量 |
* |----------------|----------------------------|----------------------------------------------------------------------------| * |----------------|----------------------------|----------------------------------------------------------------------------|
* | SONG_SELECTED | 歌曲被排进排期/发布 | thing2→songTitle, date4→playDate, time1→playTime, character_string3→sequence, phrase5→status | * | SONG_SELECTED | 歌曲被排进排期/发布 | thing1→songTitle, phrase2→status, time3→playTime, character_string4→sequence |
* | SONG_REJECTED | 投稿被驳回 | thing6→songTitle, thing4→reason | * | SONG_REJECTED | 投稿被驳回 | thing6→songTitle, thing4→reason |
* *
* 换模板/改字段**不用改代码**:环境变量 `WECHAT_MINI_SUBSCRIBE_TEMPLATES` 是 JSON,按 * 换模板/改字段**不用改代码**:环境变量 `WECHAT_MINI_SUBSCRIBE_TEMPLATES` 是 JSON,按
@@ -55,12 +55,23 @@ export const BUILTIN_TEMPLATES: Record<string, TemplateConf> = {
SONG_SELECTED: { SONG_SELECTED: {
id: 'oLTAefO92xYYlGRYmcsm5AfUUlUI5IKFfXhIVNrItSk', id: 'oLTAefO92xYYlGRYmcsm5AfUUlUI5IKFfXhIVNrItSk',
title: '歌曲排期通知', title: '歌曲排期通知',
/*
* 这张模板的**真实**关键词表(从微信 /wxaapi/newtmpl/gettemplate 读出来的原文):
* 歌曲名称:{{thing1.DATA}} → songTitle
* 排期状态:{{phrase2.DATA}} → status
* 播放时间:{{time3.DATA}} → playTime('YYYY-MM-DD HH:mm',日期就含在里面)
* 排期序号:{{character_string4.DATA}} → sequence
*
* ⚠️ **没有日期字段**。早先这里是「按常见命名猜的」thing2/date4/time1/character_string3/phrase5,
* 五个编号全错、还多出一个模板里根本不存在的 date4 —— 微信的表现是
* `47003 argument invalid! data.thing1.value is empty`(我们没给 thing1,
* 而它才是模板真正要的「歌曲名称」)。换模板时请对着后台的模板详情逐个核,不要猜编号。
*/
fields: { fields: {
thing2: 'songTitle', thing1: 'songTitle',
date4: 'playDate', phrase2: 'status',
time1: 'playTime', time3: 'playTime',
character_string3: 'sequence', character_string4: 'sequence'
phrase5: 'status'
} }
} }
} }
+21 -13
View File
@@ -84,17 +84,23 @@ test('空白与换行会被压成一行(微信不接受换行)', () => {
}) })
test('SONG_SELECTED 的内置字段映射:每个字段名后缀都要跟发出去的值类型匹配', () => { test('SONG_SELECTED 的内置字段映射:每个字段名后缀都要跟发出去的值类型匹配', () => {
// 字段映射的唯一来源是 server/utils/wechatSubscribeTemplates.ts(那里有更完整的测试) /*
* 字段映射的唯一来源是 server/utils/wechatSubscribeTemplates.ts(那里有更完整的测试)。
* 这里的重点不是"是不是这几个编号",而是**每个键的后缀(类型)和值匹配**:
* thing1 / phrase2 / time3 / character_string4
* 真实模板内容(微信 /wxaapi/newtmpl/gettemplate 读出来):
* 歌曲名称:{{thing1.DATA}} 排期状态:{{phrase2.DATA}}
* 播放时间:{{time3.DATA}} 排期序号:{{character_string4.DATA}}
* ⚠️ 这张模板**没有日期字段**,日期含在 time3 的 'YYYY-MM-DD HH:mm' 里。
*/
const fields = { const fields = {
thing2: 'songTitle', thing1: 'songTitle',
date4: 'playDate', phrase2: 'status',
time1: 'playTime', time3: 'playTime',
character_string3: 'sequence', character_string4: 'sequence'
phrase5: 'status'
} }
const vars = { const vars = {
songTitle: '晴天', songTitle: '晴天',
playDate: '2026-02-14',
playTime: '2026-02-14 12:30', playTime: '2026-02-14 12:30',
sequence: '1', sequence: '1',
status: '已排期' status: '已排期'
@@ -102,11 +108,10 @@ test('SONG_SELECTED 的内置字段映射:每个字段名后缀都要跟发出
const out = {} const out = {}
for (const key of Object.keys(fields)) out[key] = clampField(key, vars[fields[key]]) for (const key of Object.keys(fields)) out[key] = clampField(key, vars[fields[key]])
assert.equal(out.thing2, '晴天') assert.equal(out.thing1, '晴天')
assert.equal(out.date4, '2026-02-14') assert.match(out.time3, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)
assert.match(out.time1, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/) assert.equal(out.character_string4, '1')
assert.equal(out.character_string3, '1') assert.equal(out.phrase2, '已排期') // 3 个汉字本来就合规,不补全角空格
assert.equal(out.phrase5, '已排期') // 3 个汉字本来就合规,不补全角空格
// 不能有任何字段是空值:空值同样会被微信拒 // 不能有任何字段是空值:空值同样会被微信拒
for (const key of Object.keys(out)) assert.ok(out[key].length > 0, key + ' 不能为空') for (const key of Object.keys(out)) assert.ok(out[key].length > 0, key + ' 不能为空')
}) })
@@ -160,9 +165,12 @@ test('内置模板与线上代码一致(直接读源码,防止映射被改
const selected = block('SONG_SELECTED') const selected = block('SONG_SELECTED')
assert.match(selected, /oLTAefO92xYYlGRYmcsm5AfUUlUI5IKFfXhIVNrItSk/) assert.match(selected, /oLTAefO92xYYlGRYmcsm5AfUUlUI5IKFfXhIVNrItSk/)
for (const f of ['thing2', 'date4', 'time1', 'character_string3', 'phrase5']) { // 这四个编号是微信侧真实分配的(见上方模板原文),改了就必须同步改这里
for (const f of ['thing1', 'phrase2', 'time3', 'character_string4']) {
assert.match(selected, new RegExp(f + ":\\s*'"), f + ' 的映射丢了') assert.match(selected, new RegExp(f + ":\\s*'"), f + ' 的映射丢了')
} }
// 这张模板没有日期字段,配置里不该出现 date 型键
assert.doesNotMatch(selected, /date\d+:/, 'SONG_SELECTED 的模板里没有日期关键词')
}) })
test('beijingNow 是合法的 time 型值,且与北京时间一致', () => { test('beijingNow 是合法的 time 型值,且与北京时间一致', () => {
+60
View File
@@ -123,6 +123,66 @@ test('resolveFieldsFromKeywords:拿不到关键词表时一律沿用配置', (
assert.deepEqual(r.fields, configured) assert.deepEqual(r.fields, configured)
}) })
/**
* 回归:线上真实踩过的坑。
*
* 旧配置是「按常见命名猜的」thing2/date4/time1/character_string3/phrase5,五个编号全错,
* 而且模板里**根本没有日期关键词**。当时按变量顺序贪心认领,playDate(首选 date,模板里
* 没有 date)先把 character_string4 抢走,轮到 sequence 就没位置了,整张模板退回旧配置,
* 发出去就是 `47003 data.thing1.value is empty`。
*/
const REAL_SONG_SELECTED_CONTENT =
'歌曲名称:{{thing1.DATA}}\n排期状态:{{phrase2.DATA}}\n播放时间:{{time3.DATA}}\n排期序号:{{character_string4.DATA}}\n'
test('回归:真实排期模板 + 猜错的旧配置 → 校准成正确的四个编号,并丢掉模板里没有的 playDate', () => {
const wrongConfigured = {
thing2: 'songTitle',
date4: 'playDate',
time1: 'playTime',
character_string3: 'sequence',
phrase5: 'status'
}
const r = resolveFieldsFromKeywords(
parseTemplateContent(REAL_SONG_SELECTED_CONTENT),
wrongConfigured
)
assert.equal(r.source, 'wechat-api')
assert.deepEqual(r.fields, {
thing1: 'songTitle',
phrase2: 'status',
time3: 'playTime',
character_string4: 'sequence'
})
// 模板里没有日期关键词 → playDate 被忽略(发了微信也不认)
assert.deepEqual(r.unresolvedVariables, ['playDate'])
assert.deepEqual(r.unclaimedCodes, [], '四个关键词必须全部填上,否则微信会报 value is empty')
assert.match(r.reason, /已忽略/)
})
test('回归:真实排期模板 + 已修正的内置配置 → 一致,不做任何改动', () => {
const good = {
thing1: 'songTitle',
phrase2: 'status',
time3: 'playTime',
character_string4: 'sequence'
}
const r = resolveFieldsFromKeywords(parseTemplateContent(REAL_SONG_SELECTED_CONTENT), good)
assert.equal(r.source, 'wechat-api')
assert.deepEqual(r.fields, good)
assert.deepEqual(r.invalidConfiguredCodes, [])
assert.deepEqual(r.unclaimedCodes, [])
assert.equal(r.reason, '配置与微信真实模板一致')
})
test('unclaimedCodes:模板里有、配置里没有的关键词要被点名(这是 value is empty 的来源)', () => {
const r = resolveFieldsFromKeywords(parseTemplateContent(REAL_SONG_SELECTED_CONTENT), {
thing1: 'songTitle'
})
assert.deepEqual(r.unclaimedCodes.sort(), ['character_string4', 'phrase2', 'time3'])
// 填不满模板时不动配置
assert.equal(r.source, 'config')
})
test('resolveFieldsFromKeywords:变量类型未知时不做自动映射', () => { test('resolveFieldsFromKeywords:变量类型未知时不做自动映射', () => {
const r = resolveFieldsFromKeywords(parseTemplateContent(SONG_SELECTED_CONTENT), { const r = resolveFieldsFromKeywords(parseTemplateContent(SONG_SELECTED_CONTENT), {
thing1: 'whateverCustomVar' thing1: 'whateverCustomVar'
+18 -13
View File
@@ -30,12 +30,18 @@ test('内置模板:两个模板的 ID 与字段映射和公众平台一致', (
const selected = BUILTIN_TEMPLATES.SONG_SELECTED const selected = BUILTIN_TEMPLATES.SONG_SELECTED
assert.equal(selected.id, 'oLTAefO92xYYlGRYmcsm5AfUUlUI5IKFfXhIVNrItSk') assert.equal(selected.id, 'oLTAefO92xYYlGRYmcsm5AfUUlUI5IKFfXhIVNrItSk')
/*
* 这四个键是微信侧真实分配的编号(模板原文:
* 歌曲名称:{{thing1.DATA}} 排期状态:{{phrase2.DATA}}
* 播放时间:{{time3.DATA}} 排期序号:{{character_string4.DATA}}
* )。这张模板没有日期关键词 —— 曾经在这里写过一个不存在的 date4,
* 微信就回 `47003 data.thing1.value is empty`。
*/
assert.deepEqual(selected.fields, { assert.deepEqual(selected.fields, {
thing2: 'songTitle', thing1: 'songTitle',
date4: 'playDate', phrase2: 'status',
time1: 'playTime', time3: 'playTime',
character_string3: 'sequence', character_string4: 'sequence'
phrase5: 'status'
}) })
// 每个字段名都要带类型后缀(thing2/date4/…),否则 clampField 认不出类型、不会裁剪 // 每个字段名都要带类型后缀(thing2/date4/…),否则 clampField 认不出类型、不会裁剪
@@ -139,17 +145,16 @@ test('findEmptyFields:变量没给或全是空白 → 点名到字段和变量
findEmptyFields(conf, {}) findEmptyFields(conf, {})
.map((f) => f.field) .map((f) => f.field)
.sort(), .sort(),
['character_string3', 'date4', 'phrase5', 'thing2', 'time1'] ['character_string4', 'phrase2', 'thing1', 'time3']
) )
const partial = findEmptyFields(conf, { const partial = findEmptyFields(conf, {
songTitle: '晴天', songTitle: '晴天',
playDate: ' ', playTime: ' ',
playTime: '2026-02-14 12:30',
sequence: '1', sequence: '1',
status: '已排期' status: '已排期'
}) })
assert.deepEqual(partial, [{ field: 'date4', variable: 'playDate' }]) assert.deepEqual(partial, [{ field: 'time3', variable: 'playTime' }])
}) })
test('findEmptyDataFields:拼完 data 再兜一层空值检查', () => { test('findEmptyDataFields:拼完 data 再兜一层空值检查', () => {
@@ -173,10 +178,10 @@ test('buildTemplateData:按映射取值,并交给 clamp 处理(time 有兜
}, },
clampField clampField
) )
assert.deepEqual(Object.keys(data), ['thing2', 'date4', 'time1', 'character_string3', 'phrase5']) assert.deepEqual(Object.keys(data), ['thing1', 'phrase2', 'time3', 'character_string4'])
assert.equal(data.thing2.value, '晴天') assert.equal(data.thing1.value, '晴天')
assert.equal(data.character_string3.value, '3') assert.equal(data.character_string4.value, '3')
assert.equal(data.phrase5.value, '已排期') assert.equal(data.phrase2.value, '已排期')
assert.deepEqual(findEmptyDataFields(data), [], '有完整变量时不该出现空字段') assert.deepEqual(findEmptyDataFields(data), [], '有完整变量时不该出现空字段')
}) })