From f5a43b37cca45d199bf4ebdeb8b8f218a439a4c3 Mon Sep 17 00:00:00 2001 From: HenryDu8133 <813367384@qq.com> Date: Sat, 24 Oct 2026 11:56:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=BE=AE=E4=BF=A1=E8=AE=A2=E9=98=85):=20?= =?UTF-8?q?=E7=94=A8=E5=BE=AE=E4=BF=A1=E7=9C=9F=E5=AE=9E=E5=85=B3=E9=94=AE?= =?UTF-8?q?=E8=AF=8D=E4=BF=AE=E6=AD=A3=E6=AD=8C=E6=9B=B2=E6=8E=92=E6=9C=9F?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=EF=BC=88thing1/phrase2/time3/character=5Fstr?= =?UTF-8?q?ing4=EF=BC=89=EF=BC=8C=E5=B9=B6=E4=BF=AE=E6=8E=89=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E8=AE=A4=E9=A2=86=E9=A1=BA=E5=BA=8F=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E7=9A=84=E6=A0=A1=E5=87=86=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 ++- server/services/wechatSubscribeService.ts | 15 +++++ server/utils/wechatSubscribeRemote.ts | 77 ++++++++++++++++------- server/utils/wechatSubscribeTemplates.ts | 23 +++++-- test/wechatSubscribeFields.test.mjs | 34 ++++++---- test/wechatSubscribeRemote.test.mjs | 60 ++++++++++++++++++ test/wechatSubscribeTemplates.test.mjs | 31 +++++---- 7 files changed, 192 insertions(+), 56 deletions(-) diff --git a/.env.example b/.env.example index f9b883a..2cf1d05 100644 --- a/.env.example +++ b/.env.example @@ -125,7 +125,9 @@ WECHAT_MINI_SECRET= # 微信「订阅消息」模板(可选:**留空也能用**,代码里内置了站长选好的模板) # 内置模板(唯一来源:server/utils/wechatSubscribeTemplates.ts 的 BUILTIN_TEMPLATES): # 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「审核驳回通知」—— 投稿被驳回时发 # thing6→songTitle, thing4→reason # 下面这条环境变量**只用来覆盖内置映射**(比如站长在公众平台选了别的模板或字段名)。 @@ -134,7 +136,9 @@ WECHAT_MINI_SECRET= # {"SONG_SELECTED":{"id":"模板ID"}} ← 只换 ID,沿用内置字段映射 # {"SONG_REJECTED":{"id":"模板ID","fields":{"thing6":"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)。 # ⚠️ 平台限制:一次性订阅=一次授权只能发一条;只有绑定了微信的账号才有 openid; # 模板 ID 变了老用户要**重新点一次「开启微信通知」**才有额度(旧授权按模板 ID 记账,直接作废)。 diff --git a/server/services/wechatSubscribeService.ts b/server/services/wechatSubscribeService.ts index 071d216..4accbda 100644 --- a/server/services/wechatSubscribeService.ts +++ b/server/services/wechatSubscribeService.ts @@ -350,6 +350,7 @@ export async function resolveTemplate( reason: `拿不到微信模板列表(${error || '未知原因'}),沿用配置里的字段名`, unresolvedVariables: [], invalidConfiguredCodes: [], + unclaimedCodes: [], remoteError: error, remoteTitle: null, remoteContent: null, @@ -818,6 +819,9 @@ export async function sendWechatSubscribe( : '本次字段名是【沿用配置】的:' detail = `微信回 47003:模板字段名或字段值不合规。${hint}` + + (resolution?.unclaimedCodes?.length + ? `还有关键词没被任何字段填上(${resolution.unclaimedCodes.join('、')}),微信会报「value is empty」。` + : '') + '用 /api/admin/wechat/diagnose 可以看到微信侧的真实关键词名。' + (resolution?.remoteKeywords?.length ? ` 微信侧关键词:${resolution.remoteKeywords @@ -973,6 +977,8 @@ export type TemplateStatus = { fieldReason: string /** 配置里有、但微信模板里根本没有的关键词名(有值就一定会 47003) */ invalidConfiguredCodes: string[] + /** 微信模板里有、但没有任何字段去填的关键词(微信会回 `data.xxx.value is empty`) */ + unclaimedCodes: 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', fieldReason: resolution?.reason || '没有模板 ID,无法校准', invalidConfiguredCodes: resolution?.invalidConfiguredCodes || [], + unclaimedCodes: resolution?.unclaimedCodes || [], remoteKeywords: resolution?.remoteKeywords?.length ? resolution.remoteKeywords.map((k) => ({ code: k.code, @@ -1166,6 +1173,14 @@ export async function inspectWechatSubscribe( ) } 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 的头号嫌疑,体检里把它放在前面说清楚 if (t.remoteError) { actions.push( diff --git a/server/utils/wechatSubscribeRemote.ts b/server/utils/wechatSubscribeRemote.ts index 72f1aa8..f7f6d02 100644 --- a/server/utils/wechatSubscribeRemote.ts +++ b/server/utils/wechatSubscribeRemote.ts @@ -153,6 +153,11 @@ export type ResolveTemplateFieldsResult = { unresolvedVariables: string[] /** 配置里有、但微信模板里根本不存在的关键词名(这些字段微信会直接 47003) */ invalidConfiguredCodes: string[] + /** + * 微信模板里有、但**没有任何变量去填**的关键词(形如 `thing1`)。 + * 微信对缺失的关键词会回 `47003 data.thing1.value is empty` —— 这种「少填」比「填错」更常见。 + */ + unclaimedCodes: string[] } /** @@ -180,6 +185,8 @@ export function resolveFieldsFromKeywords( reason, unresolvedVariables: [] as string[], invalidConfiguredCodes, + // 配置里没覆盖到的关键词 = 微信会报 value is empty 的那些 + unclaimedCodes: keywords.map((k) => k.code).filter((code) => !(code in config)), ...extra }) @@ -201,37 +208,44 @@ export function resolveFieldsFromKeywords( const resolved: Record = {} 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) { 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 } + pairs.push({ variable, code: kw.code, score }) } - if (!best) { - unresolved.push(variable) - continue - } - used.add(best.code) - resolved[best.code] = variable + } + // 同分时优先「标签命中」的(score 里已经含了,这里只是让顺序稳定) + 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) { - return configResult( - `有变量在微信模板里找不到对应的关键词(${unresolved.join('、')}),沿用配置`, - { unresolvedVariables: unresolved } - ) + for (const variable of variables) { + if (!Object.values(resolved).includes(variable)) unresolved.push(variable) } + const unclaimed = keywords.map((k) => k.code).filter((code) => !(code in resolved)) /* - * 安全检查:同一类型出现多个关键词时,必须靠中文标签区分开。 - * 例如驳回模板有两个 thing,如果不看标签只按类型分配,songTitle 和 reason 会互换 —— - * 能把消息发出去,但内容张冠李戴,比发不出去更糟。 + * 安全检查(先做,因为它是"不可采纳"的硬条件):同一类型出现多个关键词时, + * 必须靠中文标签区分开。例如驳回模板有两个 thing,如果不看标签只按类型分配, + * songTitle 和 reason 会互换 —— 能把消息发出去,但内容张冠李戴,比发不出去更糟。 */ const byType = new Map() 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 = configuredCodes.some((code) => !(code in resolved)) || @@ -271,9 +302,11 @@ export function resolveFieldsFromKeywords( reason: changed ? `按微信真实模板校准了字段名:${Object.entries(resolved) .map(([code, variable]) => `${code}=${variable}`) - .join(',')}` + .join(',')}` + + (unresolved.length ? `(模板里没有对应关键词的变量已忽略:${unresolved.join('、')})` : '') : '配置与微信真实模板一致', - unresolvedVariables: [], - invalidConfiguredCodes + unresolvedVariables: unresolved, + invalidConfiguredCodes, + unclaimedCodes: unclaimed } } diff --git a/server/utils/wechatSubscribeTemplates.ts b/server/utils/wechatSubscribeTemplates.ts index 104a49a..83d986b 100644 --- a/server/utils/wechatSubscribeTemplates.ts +++ b/server/utils/wechatSubscribeTemplates.ts @@ -19,7 +19,7 @@ * * | 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 | * * 换模板/改字段**不用改代码**:环境变量 `WECHAT_MINI_SUBSCRIBE_TEMPLATES` 是 JSON,按 @@ -55,12 +55,23 @@ export const BUILTIN_TEMPLATES: Record = { SONG_SELECTED: { id: 'oLTAefO92xYYlGRYmcsm5AfUUlUI5IKFfXhIVNrItSk', 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: { - thing2: 'songTitle', - date4: 'playDate', - time1: 'playTime', - character_string3: 'sequence', - phrase5: 'status' + thing1: 'songTitle', + phrase2: 'status', + time3: 'playTime', + character_string4: 'sequence' } } } diff --git a/test/wechatSubscribeFields.test.mjs b/test/wechatSubscribeFields.test.mjs index 3c610ae..7024d47 100644 --- a/test/wechatSubscribeFields.test.mjs +++ b/test/wechatSubscribeFields.test.mjs @@ -84,17 +84,23 @@ test('空白与换行会被压成一行(微信不接受换行)', () => { }) 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 = { - thing2: 'songTitle', - date4: 'playDate', - time1: 'playTime', - character_string3: 'sequence', - phrase5: 'status' + thing1: 'songTitle', + phrase2: 'status', + time3: 'playTime', + character_string4: 'sequence' } const vars = { songTitle: '晴天', - playDate: '2026-02-14', playTime: '2026-02-14 12:30', sequence: '1', status: '已排期' @@ -102,11 +108,10 @@ test('SONG_SELECTED 的内置字段映射:每个字段名后缀都要跟发出 const out = {} for (const key of Object.keys(fields)) out[key] = clampField(key, vars[fields[key]]) - assert.equal(out.thing2, '晴天') - 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(out.phrase5, '已排期') // 3 个汉字本来就合规,不补全角空格 + assert.equal(out.thing1, '晴天') + assert.match(out.time3, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/) + assert.equal(out.character_string4, '1') + assert.equal(out.phrase2, '已排期') // 3 个汉字本来就合规,不补全角空格 // 不能有任何字段是空值:空值同样会被微信拒 for (const key of Object.keys(out)) assert.ok(out[key].length > 0, key + ' 不能为空') }) @@ -160,9 +165,12 @@ test('内置模板与线上代码一致(直接读源码,防止映射被改 const selected = block('SONG_SELECTED') 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 + ' 的映射丢了') } + // 这张模板没有日期字段,配置里不该出现 date 型键 + assert.doesNotMatch(selected, /date\d+:/, 'SONG_SELECTED 的模板里没有日期关键词') }) test('beijingNow 是合法的 time 型值,且与北京时间一致', () => { diff --git a/test/wechatSubscribeRemote.test.mjs b/test/wechatSubscribeRemote.test.mjs index bdafbb1..8dc6503 100644 --- a/test/wechatSubscribeRemote.test.mjs +++ b/test/wechatSubscribeRemote.test.mjs @@ -123,6 +123,66 @@ test('resolveFieldsFromKeywords:拿不到关键词表时一律沿用配置', ( 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:变量类型未知时不做自动映射', () => { const r = resolveFieldsFromKeywords(parseTemplateContent(SONG_SELECTED_CONTENT), { thing1: 'whateverCustomVar' diff --git a/test/wechatSubscribeTemplates.test.mjs b/test/wechatSubscribeTemplates.test.mjs index 11103ec..fcd347a 100644 --- a/test/wechatSubscribeTemplates.test.mjs +++ b/test/wechatSubscribeTemplates.test.mjs @@ -30,12 +30,18 @@ test('内置模板:两个模板的 ID 与字段映射和公众平台一致', ( const selected = BUILTIN_TEMPLATES.SONG_SELECTED 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, { - thing2: 'songTitle', - date4: 'playDate', - time1: 'playTime', - character_string3: 'sequence', - phrase5: 'status' + thing1: 'songTitle', + phrase2: 'status', + time3: 'playTime', + character_string4: 'sequence' }) // 每个字段名都要带类型后缀(thing2/date4/…),否则 clampField 认不出类型、不会裁剪 @@ -139,17 +145,16 @@ test('findEmptyFields:变量没给或全是空白 → 点名到字段和变量 findEmptyFields(conf, {}) .map((f) => f.field) .sort(), - ['character_string3', 'date4', 'phrase5', 'thing2', 'time1'] + ['character_string4', 'phrase2', 'thing1', 'time3'] ) const partial = findEmptyFields(conf, { songTitle: '晴天', - playDate: ' ', - playTime: '2026-02-14 12:30', + playTime: ' ', sequence: '1', status: '已排期' }) - assert.deepEqual(partial, [{ field: 'date4', variable: 'playDate' }]) + assert.deepEqual(partial, [{ field: 'time3', variable: 'playTime' }]) }) test('findEmptyDataFields:拼完 data 再兜一层空值检查', () => { @@ -173,10 +178,10 @@ test('buildTemplateData:按映射取值,并交给 clamp 处理(time 有兜 }, clampField ) - 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(data.phrase5.value, '已排期') + assert.deepEqual(Object.keys(data), ['thing1', 'phrase2', 'time3', 'character_string4']) + assert.equal(data.thing1.value, '晴天') + assert.equal(data.character_string4.value, '3') + assert.equal(data.phrase2.value, '已排期') assert.deepEqual(findEmptyDataFields(data), [], '有完整变量时不该出现空字段') })