Files
BYW-VoiceHub/server/utils/wechatSubscribeTemplates.ts
T

245 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 微信订阅消息的**模板登记表**(纯函数,不依赖 Nitro / 数据库,可直接用 node 测)。
*
* 为什么单独一个文件:以前模板定义、字段裁剪、发送、日志全挤在
* `server/services/wechatSubscribeService.ts` 里(340 行),任何一个环节出问题都要
* 启动整个 Nuxt 才能验证。现在拆成三份:
* · 本文件 —— 认哪些模板、字段怎么映射(纯数据 + 纯函数)
* · wechatSubscribeFields.ts —— 每个值怎么裁成微信能接受的样子(纯函数)
* · wechatSubscribeService.ts —— 拿 token、查 openid、发、记账、写日志
*
* ## 字段映射怎么读
*
* `fields` 的**键**是模板里的字段名(公众平台选用模板时能看到,形如 thing2 / date4 /
* character_string3,后缀就是字段类型),**值**是调用方传进来的变量名。
* 键写错 = 微信回 `47003 argument invalid` 且**整条消息发不出去**(微信只挑一个字段名报,
* 不代表只有它有问题),所以这张表要和公众平台后台逐字核对。
*
* ## 站长的模板
*
* | key | 用途 | 模板字段 → 变量 |
* |----------------|----------------------------|----------------------------------------------------------------------------|
* | SONG_SELECTED | 歌曲被排进排期/发布 | thing1→songTitle, phrase2→status, time3→playTime, character_string4→sequence |
* | SONG_REJECTED | 投稿被驳回 | thing6→songTitle, thing4→reason |
*
* 换模板/改字段**不用改代码**:环境变量 `WECHAT_MINI_SUBSCRIBE_TEMPLATES` 是 JSON,按
* 同一个 key 覆盖,只给 `id` 就沿用下面内置的字段映射,只给 `fields` 就换映射,都给就都换:
*
* ```json
* { "SONG_SELECTED": { "id": "新模板ID" } }
* { "SONG_SELECTED": { "fields": { "thing1": "songTitle" } } }
* ```
*/
/** 一个模板的配置:模板 ID + 字段名→变量名 的映射 */
export type TemplateConf = {
id: string
fields: Record<string, string>
/** 这个模板用来干什么(写日志 / 体检报告时给人看) */
title: string
}
/**
* 内置模板(站长在公众平台选好的,可直接用)。
*
* ⚠️ 模板 ID 变了 = **老用户之前的授权全部作废**(微信的授权按 (openid, 模板 ID) 记),
* 他们必须在小程序里重新点一次「开启」才能再收到;在那之前发送会返回
* `43101`(无可用授权)—— 这是**正常状态,不是 bug**。
*/
export const BUILTIN_TEMPLATES: Record<string, TemplateConf> = {
SONG_REJECTED: {
id: 'erAK99M38ZeD-F1mOIgN2M7iAPVTU0YXo-adlkSwg_c',
title: '审核驳回通知',
fields: { thing6: 'songTitle', thing4: 'reason' }
},
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: {
thing1: 'songTitle',
phrase2: 'status',
time3: 'playTime',
character_string4: 'sequence'
}
}
}
/** 环境变量名(模板配置用 JSON 覆盖内置表) */
export const TEMPLATES_ENV_KEY = 'WECHAT_MINI_SUBSCRIBE_TEMPLATES'
export type TemplateParseReport = {
templates: Record<string, TemplateConf>
/** 环境变量是不是合法 JSON(坏 JSON 不是致命错误:忽略它,用内置表继续跑) */
envJsonValid: boolean
/** 环境变量原文(体检报告里要显示,方便一眼看出写没写坏) */
envRaw: string | null
/** 环境变量覆盖/新增了哪些 key */
overriddenKeys: string[]
/** 环境变量里**用不了**的项(缺 id、类型不对;这一项会被忽略,其余照常生效) */
unknownKeys: string[]
/** 环境变量里新增的模板键(内置表里没有的 key,只要带 id 就是合法的补充) */
newAdditionsKeys: string[]
/** 环境变量没给 fields、沿用了内置映射的 key */
inheritedFieldsKeys: string[]
}
/** 解析出「内置模板 + 环境变量覆盖」的最终模板表,并把解析过程如实报告出来 */
export function parseSubscribeTemplates(
raw: string | null | undefined = process.env[TEMPLATES_ENV_KEY]
): TemplateParseReport {
const templates: Record<string, TemplateConf> = {}
Object.keys(BUILTIN_TEMPLATES).forEach((k) => {
templates[k] = { ...BUILTIN_TEMPLATES[k], fields: { ...BUILTIN_TEMPLATES[k].fields } }
})
const report: TemplateParseReport = {
templates,
envJsonValid: true,
envRaw: raw ? String(raw) : null,
overriddenKeys: [],
unknownKeys: [],
newAdditionsKeys: [],
inheritedFieldsKeys: []
}
if (!raw) return report
let parsed: any = null
try {
parsed = JSON.parse(String(raw))
} catch {
report.envJsonValid = false
return report
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
report.envJsonValid = false
return report
}
Object.keys(parsed).forEach((key) => {
const conf = parsed[key]
/*
* 没有 id 的项一律忽略,并记进 unknownKeys 提醒写错了。
* 为什么不能「只给 fields」:模板 ID 是必须的,缺了它这一项根本发不出去,
* 与其留下一个 id 为空、发送时才发现不对的条目,不如当场报告。
*/
if (!conf || typeof conf !== 'object' || Array.isArray(conf) || !conf.id) {
report.unknownKeys.push(key)
return
}
const base = templates[key]
// 内置表里没有的 key 不是错误:站长完全可以再加一张模板,只要给全 id(+ fields)
if (!base) report.newAdditionsKeys.push(key)
let fields = base ? base.fields : undefined
if (conf.fields && typeof conf.fields === 'object' && !Array.isArray(conf.fields)) {
fields = conf.fields
} else if (base) {
// 只想换模板 ID 时的常见情况:沿用内置字段映射,避免退化成微信必拒的映射
report.inheritedFieldsKeys.push(key)
}
templates[key] = {
id: String(conf.id),
title: (conf.title && String(conf.title)) || base?.title || key,
fields: fields || {}
}
report.overriddenKeys.push(key)
})
return report
}
/** 模板 ID 列表(给小程序去申请授权用;微信一次最多 3 个,调用方自己截) */
export function listTemplateIds(templates: Record<string, TemplateConf>): string[] {
return Object.keys(templates)
.map((k) => templates[k]?.id)
.filter((id): id is string => !!id)
}
/**
* 把模板配置 + 变量拼成微信要求的 `data`。
*
* @param vars 变量表。内置 `time` 兜底为「北京时间现在」,所以模板里用 time 型字段也不会空。
*/
export function buildTemplateData(
conf: TemplateConf,
vars: Record<string, unknown>,
clamp: (key: string, value: unknown) => string
): Record<string, { value: string }> {
const fields = conf.fields || {}
const data: Record<string, { value: string }> = {}
for (const key of Object.keys(fields)) {
data[key] = { value: clamp(key, vars[fields[key]]) }
}
return data
}
/**
* 找出「映射到了变量、但变量是空的」字段。
*
* 微信对空值是**整条拒绝**(47003),而报错只点一个字段名,所以发送前自己先查一遍,
* 把问题字段列清楚 —— 这是「任何一步失败都必须看得见」里最值钱的一条。
*/
export function findEmptyFields(
conf: TemplateConf,
vars: Record<string, unknown>
): Array<{ field: string; variable: string }> {
const fields = conf.fields || {}
const empty: Array<{ field: string; variable: string }> = []
for (const field of Object.keys(fields)) {
const variable = fields[field]
const raw = vars[variable]
if (raw === null || raw === undefined || String(raw).trim() === '') {
empty.push({ field, variable })
}
}
return empty
}
/**
* 发送前的最后一道自检:把映射算成微信的 data 之后,再核对一次
* 「有没有字段最终是空字符串」——变量给了但裁完为空(比如全是空白字符)也算。
*/
export function findEmptyDataFields(
data: Record<string, { value: string }>
): Array<{ field: string; value: string }> {
return Object.keys(data)
.filter((field) => !data[field] || String(data[field].value).trim() === '')
.map((field) => ({ field, value: data[field]?.value ?? '' }))
}
/** 微信 errcode → 人话(体检报告、日志、接口返回值都用它,统一口径) */
export function explainWechatErrcode(errcode: number | undefined | null): string {
switch (errcode) {
case 0:
return '发送成功'
case 40001:
return 'access_token 无效或已过期(会重新获取,通常重试即可)'
case 40003:
return 'openid 无效:这个微信号和当前小程序不是同一个 appid'
case 40037:
return '模板 ID 无效:模板不存在或还没通过审核'
case 43101:
return '用户没有可用授权:要么从没授权过这张模板,要么上一次已经用掉了(一次性订阅,发一条扣一条),需要用户在小程序里重新点「开启」'
case 45009:
return '接口调用超过每日上限'
case 47003:
return '模板字段名或字段值不合规(常见原因:字段名和公众平台不一致、字段超长、字段为空)'
case -1:
return '微信侧系统繁忙,稍后重试'
default:
return errcode == null ? '未知错误' : `微信返回错误码 ${errcode}`
}
}