/** * 微信订阅消息的**模板登记表**(纯函数,不依赖 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 | 歌曲被排进排期/发布 | thing2→songTitle, date4→playDate, time1→playTime, character_string3→sequence, phrase5→status | * | 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 /** 这个模板用来干什么(写日志 / 体检报告时给人看) */ title: string } /** * 内置模板(站长在公众平台选好的,可直接用)。 * * ⚠️ 模板 ID 变了 = **老用户之前的授权全部作废**(微信的授权按 (openid, 模板 ID) 记), * 他们必须在小程序里重新点一次「开启」才能再收到;在那之前发送会返回 * `43101`(无可用授权)—— 这是**正常状态,不是 bug**。 */ export const BUILTIN_TEMPLATES: Record = { SONG_REJECTED: { id: 'erAK99M38ZeD-F1mOIgN2M7iAPVTU0YXo-adlkSwg_c', title: '审核驳回通知', fields: { thing6: 'songTitle', thing4: 'reason' } }, SONG_SELECTED: { id: 'oLTAefO92xYYlGRYmcsm5AfUUlUI5IKFfXhIVNrItSk', title: '歌曲排期通知', fields: { thing2: 'songTitle', date4: 'playDate', time1: 'playTime', character_string3: 'sequence', phrase5: 'status' } } } /** 环境变量名(模板配置用 JSON 覆盖内置表) */ export const TEMPLATES_ENV_KEY = 'WECHAT_MINI_SUBSCRIBE_TEMPLATES' export type TemplateParseReport = { templates: Record /** 环境变量是不是合法 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 = {} 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[] { 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, clamp: (key: string, value: unknown) => string ): Record { const fields = conf.fields || {} const data: Record = {} 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 ): 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 ): 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}` } }