refactor(微信订阅): 重写订阅消息链路,每一步失败都可见
把「微信订阅消息」这套代码按验收标准推翻重写:用户能自己看懂为什么收不到,
任何一步失败都必须看得见。对外函数名不变,notificationService / test-subscribe
等调用点无需改动。
新增 server/utils/wechatSubscribeTemplates.ts(纯函数,零依赖):
- 模板登记表唯一来源(内置 SONG_SELECTED / SONG_REJECTED),服务层不再自己写死
- parseSubscribeTemplates 如实报告环境变量覆盖情况:坏 JSON 只报告不抛、
只给 id 时沿用内置字段映射、缺 id 的项记进 unknownKeys 并忽略
- findEmptyFields / findEmptyDataFields 发出前自查空字段(微信空值整条 47003)
- explainWechatErrcode 把 43101/47003/40003/40037/40001/45009 翻成人话
重写 server/services/wechatSubscribeService.ts:
- sendWechatSubscribe 返回 SendOutcome,用 stage 明确「卡在哪一步」:
no-template / disabled-by-user / no-wechat-openid / no-access-token /
empty-field / no-grant / rejected-no-quota / rejected-bad-field / rejected-other
- 每一次返回都写一行日志,不再有静默跳过(这是「发不出去全靠猜」的根因)
- inspectWechatSubscribe 只读体检:不发消息、不消耗额度,返回逐项 verdict + actions
- grantWechatSubscribe 依靠新增的 WechatSubscribeGrant 表记账:微信没有查询
剩余授权的接口,只能自己数(用户点允许 +1、发送成功 -1)
- 记账表不可用时失败开放(fail-open):额度预检关闭、照常发送,避免通知整体哑掉
- access_token 显式 responseType: json 并做字符串兜底解析
新增接口:
- GET /api/user/wechat-subscribe-status 小程序显示「还能收到几条 / 为什么收不到」
- POST /api/user/wechat-subscribe-grant 小程序授权成功后上报 accept 的模板
- GET /api/admin/wechat/diagnose 重写:人话结论 + actions,?send=1 真发一次
其他:新增 WechatSubscribeGrant 表(schema + 手写迁移)、.env.example 说明 JSON 覆盖
写法、.gitattributes 补 *.sql eol=lf。
测试:新增 test/wechatSubscribeTemplates.test.mjs(13 个用例,可直接 import .ts,
不依赖 Nuxt 别名),npm test 里订阅相关 25/25 通过。
This commit is contained in:
+16
-10
@@ -123,15 +123,21 @@ WECHAT_MINI_APPID=
|
||||
WECHAT_MINI_SECRET=
|
||||
|
||||
# 微信「订阅消息」模板(可选:**留空也能用**,代码里内置了站长选好的模板)
|
||||
# 内置模板(见 server/services/wechatSubscribeService.ts 的 DEFAULT_TEMPLATES):
|
||||
# SONG_SELECTED「歌曲排期」—— 管理员把歌排进排期/发布排期时发
|
||||
# SONG_REJECTED「歌曲驳回」—— 投稿被驳回时发
|
||||
# 下面这条环境变量**只用来覆盖内置映射**(比如站长在公众平台选了别的字段名):
|
||||
# {"SONG_SELECTED":{"id":"模板ID","fields":{"thing2":"songTitle","time1":"playTime"}}}
|
||||
# 键是通知类型(SONG_SELECTED / SONG_PLAYED / SONG_REJECTED / SYSTEM_NOTICE),
|
||||
# fields 的键是模板里的字段名(thing2/date4/time1/character_string3/phrase5,选用模板时能看到),
|
||||
# 值是变量名(内置变量:songTitle / message / time / playDate / playTime / sequence / status)。
|
||||
# 同键只给 id 不给 fields 时,会沿用内置的字段映射(不会退化成 thing1/time2/thing3)。
|
||||
# 内置模板(唯一来源:server/utils/wechatSubscribeTemplates.ts 的 BUILTIN_TEMPLATES):
|
||||
# SONG_SELECTED「歌曲排期通知」—— 管理员把歌排进排期/发布排期时发
|
||||
# thing2→songTitle, date4→playDate, time1→playTime, character_string3→sequence, phrase5→status
|
||||
# SONG_REJECTED「审核驳回通知」—— 投稿被驳回时发
|
||||
# thing6→songTitle, thing4→reason
|
||||
# 下面这条环境变量**只用来覆盖内置映射**(比如站长在公众平台选了别的模板或字段名)。
|
||||
# ⚠️ 必须是合法 JSON;写了坏值(例如只填一个光秃秃的模板 ID)服务端会**忽略它**并在启动日志里
|
||||
# 打一行「不是合法 JSON,已忽略(仍用内置默认模板)」,属于可恢复状态,但请在体检接口里确认。
|
||||
# {"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,公众平台选用模板时能看到),
|
||||
# 值是变量名(可用:songTitle / message / time / playDate / playTime / sequence / status / reason)。
|
||||
# ⚠️ 平台限制:一次性订阅=一次授权只能发一条;只有绑定了微信的账号才有 openid;
|
||||
# 模板 ID 变了老用户要**重新点一次「开启微信通知」**才有额度。
|
||||
# 模板 ID 变了老用户要**重新点一次「开启微信通知」**才有额度(旧授权按模板 ID 记账,直接作废)。
|
||||
# 发不出去时先看 GET /api/admin/wechat/diagnose(超管,只读不消耗额度):
|
||||
# 它会把「拿不拿到 token / 有没有绑微信 / 通知开关开没开 / 还剩几条授权」逐项说清。
|
||||
WECHAT_MINI_SUBSCRIBE_TEMPLATES=
|
||||
|
||||
@@ -6,4 +6,6 @@
|
||||
*.js text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.json text eol=lf
|
||||
# 迁移 SQL 也用 LF:这些文件会在容器里被 drizzle-kit 读取执行,不要混进 CRLF
|
||||
*.sql text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
-- 微信订阅消息的授权记账表
|
||||
--
|
||||
-- 背景:微信没有「还剩几条订阅授权」的查询接口,而一次性订阅是发一条扣一条。
|
||||
-- 这里按 (userId, templateId) 记账,让服务端和体检接口能说清「为什么收不到」。
|
||||
-- 记账只是近似值,不参与是否发送的硬判定(可用 force 绕过)。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "WechatSubscribeGrant" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"userId" integer NOT NULL,
|
||||
"templateId" text NOT NULL,
|
||||
"kind" text,
|
||||
"grantedCount" integer DEFAULT 0 NOT NULL,
|
||||
"sentCount" integer DEFAULT 0 NOT NULL,
|
||||
"blockedCount" integer DEFAULT 0 NOT NULL,
|
||||
"lastGrantedAt" timestamp,
|
||||
"lastSentAt" timestamp,
|
||||
"lastBlockedAt" timestamp,
|
||||
"lastErrorCode" integer,
|
||||
"lastErrorMessage" text,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "WechatSubscribeGrant_userId_templateId_unq"
|
||||
ON "WechatSubscribeGrant" ("userId", "templateId");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "WechatSubscribeGrant_userId_idx"
|
||||
ON "WechatSubscribeGrant" ("userId");
|
||||
@@ -620,6 +620,38 @@ export const userIdentities = pgTable('UserIdentity', {
|
||||
unq: unique().on(t.provider, t.providerUserId),
|
||||
}));
|
||||
|
||||
/**
|
||||
* 微信订阅消息的授权记账表。
|
||||
*
|
||||
* 为什么需要它:微信**不提供**「这个用户还剩几条订阅授权」的查询接口,
|
||||
* 而「一次性订阅」是发一条扣一条。所以这里按 (userId, templateId) 记账:
|
||||
* 用户在小程序里点「允许」时 grantedCount +1,服务端发送成功时 sentCount +1,
|
||||
* 被微信拒绝(如 43101)时 blockedCount +1 并记下 errcode。
|
||||
*
|
||||
* ⚠️ 这里算出的额度是**我们自己数的近似值**:用户可能在别处授权、或微信侧额度
|
||||
* 过期,真值只有发一条收到 43101 才知道。所以 `no-grant` 预检只用于「省一次请求」,
|
||||
* 不能当成权威判断(自测接口用 force 绕过)。
|
||||
*/
|
||||
export const wechatSubscribeGrants = pgTable('WechatSubscribeGrant', {
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('userId').notNull(),
|
||||
templateId: text('templateId').notNull(),
|
||||
/** 对应的模板键(如 SONG_SELECTED),模板被换掉后这里可能对不上,仅作展示用 */
|
||||
kind: text('kind'),
|
||||
grantedCount: integer('grantedCount').default(0).notNull(),
|
||||
sentCount: integer('sentCount').default(0).notNull(),
|
||||
blockedCount: integer('blockedCount').default(0).notNull(),
|
||||
lastGrantedAt: timestamp('lastGrantedAt'),
|
||||
lastSentAt: timestamp('lastSentAt'),
|
||||
lastBlockedAt: timestamp('lastBlockedAt'),
|
||||
lastErrorCode: integer('lastErrorCode'),
|
||||
lastErrorMessage: text('lastErrorMessage'),
|
||||
createdAt: timestamp('createdAt').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
|
||||
}, (t) => ({
|
||||
unq: unique().on(t.userId, t.templateId),
|
||||
}));
|
||||
|
||||
export const changelogEntries = pgTable('ChangelogEntry', {
|
||||
id: serial('id').primaryKey(),
|
||||
createdAt: timestamp('createdAt').defaultNow().notNull(),
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
import {
|
||||
buildTemplateData,
|
||||
getMiniAccessToken,
|
||||
getSubscribeTemplates,
|
||||
getWechatOpenid,
|
||||
isWechatSubscribeEnabled,
|
||||
sendWechatSubscribeToUser
|
||||
inspectWechatSubscribe,
|
||||
sendWechatSubscribe
|
||||
} from '~~/server/services/wechatSubscribeService'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { userIdentities } from '~/drizzle/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
/**
|
||||
* 微信订阅消息的**一键体检**(仅超级管理员,用 GET,浏览器直接打开就能看)。
|
||||
* 微信订阅消息的**一键体检**(仅超级管理员,GET,浏览器里直接打开就能看)。
|
||||
*
|
||||
* 为什么要有它:线上「通知没发出去」以前只能靠猜——服务端日志里四条跳过路径
|
||||
* 一句不写。现在把判断要用的事实全部一次返回:
|
||||
* 1. access_token 能不能拿到(appid / secret 是否有效)
|
||||
* 2. 配置了哪些模板、ID 是什么、环境变量有没有覆盖
|
||||
* 3. 每个用户有没有绑微信 openid、通知总开关开没开
|
||||
* 4. 「用户是不是授权过这张模板」——微信没有查额度的接口,唯一办法是调
|
||||
* getwxacode 的同类接口 /api/cgi-bin/message/subscribe/… 没法查,所以这里
|
||||
* 退而求其次:用 `?send=1` 真发一条给当前用户,把微信的 errcode 原样返回
|
||||
* (0=通、43101=没额度、47003=字段名不对、40003=openid 不对、40001=token 失效)。
|
||||
* 为什么要有它:线上「通知没发出去」以前只能靠猜 —— 发送前的四条跳过路径一句日志
|
||||
* 都不写。现在体检接口把判断要用的事实一次全返回,并且每一项都配一句人话结论。
|
||||
*
|
||||
* 用法(在已登录后台的浏览器里直接打开):
|
||||
* /api/admin/wechat/diagnose → 只体检,不发消息
|
||||
* /api/admin/wechat/diagnose?send=1 → 顺手给当前登录用户发一条(消耗 1 条额度)
|
||||
* 用法(在已登录后台的浏览器里打开):
|
||||
* /api/admin/wechat/diagnose → 只体检(**不发消息、不消耗额度**)
|
||||
* /api/admin/wechat/diagnose?send=1 → 顺手给这个用户每个模板真发一条(消耗额度,返回微信原始 errcode)
|
||||
* /api/admin/wechat/diagnose?userId=5 → 体检别的用户
|
||||
* /api/admin/wechat/diagnose?send=1&force=1 → 忽略本地记账的额度预检,强制发(仍可能被微信 43101 拒)
|
||||
*
|
||||
* 读返回值:先看 `user.verdict` 与 `templates[].verdict`(人话),再看 `actions[]`
|
||||
* (照着做就能修),最后需要原始错误码时看 `?send=1` 的 `sendAttempts[].errcode`。
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
@@ -35,73 +26,32 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const query = getQuery(event)
|
||||
const doSend = query.send === '1' || query.send === 'true'
|
||||
const selfId = Number(user.id)
|
||||
const force = query.force === '1' || query.force === 'true'
|
||||
const requestedId = query.userId != null ? Number(query.userId) : NaN
|
||||
const targetUserId =
|
||||
Number.isFinite(requestedId) && requestedId > 0 ? requestedId : Number(user.id)
|
||||
|
||||
const templates = getSubscribeTemplates()
|
||||
const kinds = Object.keys(templates)
|
||||
|
||||
const token = await getMiniAccessToken()
|
||||
const selfOpenid = await getWechatOpenid(selfId)
|
||||
|
||||
// 绑了微信的账号(有 openid 才可能收到订阅消息)
|
||||
const bound = await db
|
||||
.select({ userId: userIdentities.userId })
|
||||
.from(userIdentities)
|
||||
.where(eq(userIdentities.provider, 'wechat-mp'))
|
||||
const boundUserIds = Array.from(new Set(bound.map((r) => r.userId)))
|
||||
|
||||
// 每个模板算一遍「真发出去会是什么值」,方便直接比对公众平台后台的字段名
|
||||
const previews = kinds.map((kind) => {
|
||||
const conf = templates[kind]
|
||||
const vars: Record<string, unknown> = {
|
||||
songTitle: '测试歌曲《晴天》',
|
||||
playDate: '2026-02-14',
|
||||
playTime: '2026-02-14 12:30',
|
||||
sequence: '1',
|
||||
status: '已排期',
|
||||
reason: '测试驳回原因',
|
||||
message: '这是一条测试通知'
|
||||
}
|
||||
return {
|
||||
kind,
|
||||
templateId: conf?.id || null,
|
||||
fields: conf?.fields || null,
|
||||
// 真正发出去就是这一份 data(buildTemplateData 内部走 clampField 截断/补齐)
|
||||
data: conf
|
||||
? buildTemplateData(conf, Object.assign({ songTitle: '宝一外校园点歌系统' }, vars))
|
||||
: null
|
||||
}
|
||||
// 只读体检:不发消息、不消耗额度。所有导出函数都不抛异常,失败信息都在返回值里
|
||||
const inspection = await inspectWechatSubscribe(targetUserId, {
|
||||
username: targetUserId === Number(user.id) ? user.username : null
|
||||
})
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
now: new Date().toISOString(),
|
||||
env: {
|
||||
// 环境变量里只要不是合法 JSON,服务端会忽略它并回退到内置模板
|
||||
WECHAT_MINI_SUBSCRIBE_TEMPLATES: process.env.WECHAT_MINI_SUBSCRIBE_TEMPLATES || null,
|
||||
appidSet: !!process.env.WECHAT_MINI_APPID,
|
||||
secretSet: !!process.env.WECHAT_MINI_SECRET,
|
||||
enabled: isWechatSubscribeEnabled(),
|
||||
nodeEnv: process.env.NODE_ENV || null
|
||||
},
|
||||
tokenOk: !!token,
|
||||
tokenLen: token ? token.length : 0,
|
||||
me: {
|
||||
userId: selfId,
|
||||
username: user.username,
|
||||
// openid 只露前 6 位,避免日志/截图泄露
|
||||
openid: selfOpenid ? selfOpenid.slice(0, 6) + '****' : null,
|
||||
canReceive: !!selfOpenid
|
||||
},
|
||||
boundWechatUserIds: boundUserIds,
|
||||
templates: previews
|
||||
...inspection,
|
||||
requestedBy: { userId: Number(user.id), username: user.username },
|
||||
note: doSend
|
||||
? '已执行真实发送:sendAttempts 里的 errcode 就是微信的原始回复(0=成功 / 43101=没有可用授权 / 47003=字段名或值不合规 / 40003=openid 不对 / 40001=token 失效)'
|
||||
: '本次只体检、没有发消息,也没有消耗任何授权额度;要真发一次加 ?send=1'
|
||||
}
|
||||
|
||||
if (doSend && selfOpenid) {
|
||||
const attempts: Array<{ kind: string; result: unknown }> = []
|
||||
if (doSend) {
|
||||
const attempts: Array<Record<string, unknown>> = []
|
||||
const kinds = Array.from(new Set(inspection.templates.map((t) => t.kind)))
|
||||
for (const kind of kinds) {
|
||||
attempts.push({
|
||||
const outcome = await sendWechatSubscribe(
|
||||
targetUserId,
|
||||
kind,
|
||||
result: await sendWechatSubscribeToUser(selfId, kind, {
|
||||
{
|
||||
songTitle: '测试歌曲《晴天》',
|
||||
playDate: '2026-02-14',
|
||||
playTime: '2026-02-14 12:30',
|
||||
@@ -109,12 +59,19 @@ export default defineEventHandler(async (event) => {
|
||||
status: '已排期',
|
||||
reason: '测试驳回原因',
|
||||
message: '这是一条测试通知'
|
||||
})
|
||||
},
|
||||
{ force, source: 'diagnose' }
|
||||
)
|
||||
attempts.push({
|
||||
kind,
|
||||
ok: outcome.ok,
|
||||
stage: outcome.stage,
|
||||
errcode: outcome.errcode ?? null,
|
||||
errmsg: outcome.errmsg ?? null,
|
||||
detail: outcome.detail
|
||||
})
|
||||
}
|
||||
result.sendAttempts = attempts
|
||||
} else if (doSend && !selfOpenid) {
|
||||
result.sendSkipped = 'no-wechat-openid:当前登录账号没有绑定微信,发不出去'
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { grantWechatSubscribe } from '~~/server/services/wechatSubscribeService'
|
||||
|
||||
/**
|
||||
* 小程序在 `wx.requestSubscribeMessage` 成功之后来报账。
|
||||
*
|
||||
* 为什么要上报:微信**没有**「这个用户还剩几条授权」的查询接口,而一次性订阅是
|
||||
* 发一条扣一条。服务端自己记账,才能在小程序里说清「还能收到几条」,也才能在
|
||||
* 发送前就告诉用户「没额度了,去点一下开启」。
|
||||
*
|
||||
* 约定:
|
||||
* - `templateIds` **只传微信真的返回 `accept` 的模板**(`reject` / 没弹出来的别传);
|
||||
* - 上报失败不影响用户已经拿到的授权,只是我们的计数会不准;
|
||||
* - 服务端会把不在配置里的模板 ID 过滤掉,并写一行日志说明。
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: '未授权访问' })
|
||||
}
|
||||
|
||||
const body = await readBody(event).catch(() => null)
|
||||
const raw = Array.isArray(body?.templateIds) ? body.templateIds : []
|
||||
const templateIds = raw.filter(
|
||||
(id: unknown): id is string => typeof id === 'string' && id.length > 0
|
||||
)
|
||||
|
||||
if (!templateIds.length) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'templateIds 不能为空(只传微信返回 accept 的模板 ID)'
|
||||
})
|
||||
}
|
||||
|
||||
const result = await grantWechatSubscribe(Number(user.id), templateIds, 'miniprogram')
|
||||
return {
|
||||
ok: result.ok,
|
||||
// 只回模板键,不回 ID 清单,日志里也不打印完整 ID
|
||||
grantedKinds: result.granted.map((g) => g.kind || 'unknown'),
|
||||
granted: result.granted.length,
|
||||
error: result.error || null,
|
||||
hint: result.ok
|
||||
? '已记账:每点一次「允许」记 1 条额度,服务端发一条扣一条'
|
||||
: '记账失败,但不影响你已经拿到的微信授权'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { inspectWechatSubscribe } from '~~/server/services/wechatSubscribeService'
|
||||
|
||||
/**
|
||||
* 小程序「微信通知」卡片读的**只读**状态:能不能收到、为什么收不到、还能收几条。
|
||||
*
|
||||
* 为什么不直接放 `wx.getSetting` 的结果:那只是「用户上次在弹窗里选了什么」,
|
||||
* 不代表服务端还有额度(微信按「发一条扣一条」算,也没有查询余额的接口)。
|
||||
* 这里返回的是服务端自己记的账 + 真正能发出去的前提条件。
|
||||
*
|
||||
* 只返回结论与人话,不回传 openid 原文。
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: '未授权访问' })
|
||||
}
|
||||
|
||||
const inspection = await inspectWechatSubscribe(Number(user.id))
|
||||
const remainingTotal = inspection.templates.reduce((sum, t) => sum + t.remainingApprox, 0)
|
||||
|
||||
return {
|
||||
enabled: inspection.enabled,
|
||||
canReceive:
|
||||
inspection.user.hasOpenid && inspection.user.notificationEnabled && inspection.tokenOk,
|
||||
hasOpenid: inspection.user.hasOpenid,
|
||||
notificationEnabled: inspection.user.notificationEnabled,
|
||||
verdict: inspection.user.verdict,
|
||||
/** 所有模板加起来还剩几条(近似值) */
|
||||
remainingTotal,
|
||||
templates: inspection.templates.map((t) => ({
|
||||
kind: t.kind,
|
||||
title: t.title,
|
||||
remaining: t.remainingApprox,
|
||||
granted: t.grantedCount,
|
||||
sent: t.sentCount,
|
||||
lastErrorCode: t.lastErrorCode,
|
||||
verdict: t.verdict
|
||||
})),
|
||||
actions: inspection.actions,
|
||||
note: 'remaining 是服务端自己记的近似额度:微信不提供剩余授权查询,一次性订阅发一条扣一条,发完要用户再点一次「开启」'
|
||||
}
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* 微信订阅消息的**模板登记表**(纯函数,不依赖 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<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: '歌曲排期通知',
|
||||
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<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}`
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,7 @@ test('空白与换行会被压成一行(微信不接受换行)', () => {
|
||||
})
|
||||
|
||||
test('SONG_SELECTED 的内置字段映射:每个字段名后缀都要跟发出去的值类型匹配', () => {
|
||||
// 与 server/services/wechatSubscribeService.ts 的 DEFAULT_TEMPLATES.SONG_SELECTED 保持一致
|
||||
// 字段映射的唯一来源是 server/utils/wechatSubscribeTemplates.ts(那里有更完整的测试)
|
||||
const fields = {
|
||||
thing2: 'songTitle',
|
||||
date4: 'playDate',
|
||||
@@ -136,14 +136,15 @@ test('SONG_REJECTED 的内置字段映射:长驳回原因必须裁进 thing
|
||||
|
||||
test('内置模板与线上代码一致(直接读源码,防止映射被改坏没人发现)', async () => {
|
||||
/*
|
||||
* 为什么不 import:wechatSubscribeService.ts 顶部 import 了 `~~/server/...` 这类 Nuxt 别名,
|
||||
* 纯 `node --test` 解析不了。这里只校验「模板 ID 和字段映射」这类静态事实,读文本足够,
|
||||
* 而且不需要跑起来就能在 CI 里拦住「手滑改了模板 ID / 字段名」。
|
||||
* 模板定义现在的唯一来源是 server/utils/wechatSubscribeTemplates.ts(纯 .ts,无 Nuxt 别名),
|
||||
* 但这里仍然用「读源码 + 正则」而不是 import:这个测试文件只依赖 wechatSubscribeFields.ts,
|
||||
* 不想因为模板文件偶尔引入别名而整个挂掉。详细的解析行为在
|
||||
* test/wechatSubscribeTemplates.test.mjs 里测。
|
||||
*/
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const { fileURLToPath } = await import('node:url')
|
||||
const src = readFileSync(
|
||||
fileURLToPath(new URL('../server/services/wechatSubscribeService.ts', import.meta.url)),
|
||||
fileURLToPath(new URL('../server/utils/wechatSubscribeTemplates.ts', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const block = (kind) => {
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* 微信订阅消息**模板登记表**的单元测试(`npm test` 跑)。
|
||||
*
|
||||
* 为什么值得测:模板 ID 或字段名写错,线上表现只有一句 `47003 argument invalid`,
|
||||
* 整条通知发不出去且看不出哪里错。这里把「认哪些模板、字段怎么映射、环境变量覆盖
|
||||
* 怎么合并」全部钉死,改坏了在 CI 就红。
|
||||
*
|
||||
* 可以直接 `import` 这个 .ts 文件,是因为 Node 22+ 会剥离类型标注;本文件刻意不依赖
|
||||
* Nuxt 别名(`~~/server/...`),所以纯 `node --test` 就能跑。
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
BUILTIN_TEMPLATES,
|
||||
TEMPLATES_ENV_KEY,
|
||||
buildTemplateData,
|
||||
explainWechatErrcode,
|
||||
findEmptyDataFields,
|
||||
findEmptyFields,
|
||||
listTemplateIds,
|
||||
parseSubscribeTemplates
|
||||
} from '../server/utils/wechatSubscribeTemplates.ts'
|
||||
|
||||
test('内置模板:两个模板的 ID 与字段映射和公众平台一致', () => {
|
||||
assert.deepEqual(Object.keys(BUILTIN_TEMPLATES).sort(), ['SONG_REJECTED', 'SONG_SELECTED'])
|
||||
|
||||
const rejected = BUILTIN_TEMPLATES.SONG_REJECTED
|
||||
assert.equal(rejected.id, 'erAK99M38ZeD-F1mOIgN2M7iAPVTU0YXo-adlkSwg_c')
|
||||
assert.deepEqual(rejected.fields, { thing6: 'songTitle', thing4: 'reason' })
|
||||
|
||||
const selected = BUILTIN_TEMPLATES.SONG_SELECTED
|
||||
assert.equal(selected.id, 'oLTAefO92xYYlGRYmcsm5AfUUlUI5IKFfXhIVNrItSk')
|
||||
assert.deepEqual(selected.fields, {
|
||||
thing2: 'songTitle',
|
||||
date4: 'playDate',
|
||||
time1: 'playTime',
|
||||
character_string3: 'sequence',
|
||||
phrase5: 'status'
|
||||
})
|
||||
|
||||
// 每个字段名都要带类型后缀(thing2/date4/…),否则 clampField 认不出类型、不会裁剪
|
||||
for (const kind of Object.keys(BUILTIN_TEMPLATES)) {
|
||||
for (const field of Object.keys(BUILTIN_TEMPLATES[kind].fields)) {
|
||||
assert.match(
|
||||
field,
|
||||
/^(thing|phrase|time|date|character_string|number|letter|symbol|amount)\d+$/,
|
||||
`${kind}.${field} 字段名没有类型后缀`
|
||||
)
|
||||
assert.ok(BUILTIN_TEMPLATES[kind].title, `${kind} 缺 title(体检报告里要显示)`)
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(listTemplateIds(BUILTIN_TEMPLATES).length, 2)
|
||||
assert.equal(TEMPLATES_ENV_KEY, 'WECHAT_MINI_SUBSCRIBE_TEMPLATES')
|
||||
})
|
||||
|
||||
test('没有环境变量时:原样用内置模板,且不产生任何告警项', () => {
|
||||
const r = parseSubscribeTemplates(undefined)
|
||||
assert.equal(r.envJsonValid, true)
|
||||
assert.equal(r.envRaw, null)
|
||||
assert.deepEqual(r.overriddenKeys, [])
|
||||
assert.deepEqual(r.unknownKeys, [])
|
||||
assert.deepEqual(r.newAdditionsKeys, [])
|
||||
assert.deepEqual(listTemplateIds(r.templates), [
|
||||
BUILTIN_TEMPLATES.SONG_REJECTED.id,
|
||||
BUILTIN_TEMPLATES.SONG_SELECTED.id
|
||||
])
|
||||
})
|
||||
|
||||
test('环境变量坏 JSON:只报告不抛异常,仍然能用内置模板发消息', () => {
|
||||
// 线上真的出现过:Gitea secret 里填了一个光秃秃的模板 ID
|
||||
const r = parseSubscribeTemplates('erAK99M38ZeD-F1mOIgN2M7iAPVTU0YXo-adlkSwg_c')
|
||||
assert.equal(r.envJsonValid, false)
|
||||
assert.equal(r.templates.SONG_REJECTED.id, BUILTIN_TEMPLATES.SONG_REJECTED.id)
|
||||
assert.equal(listTemplateIds(r.templates).length, 2)
|
||||
assert.ok(String(r.envRaw).includes('erAK99'))
|
||||
})
|
||||
|
||||
test('只给 id:沿用内置的字段映射(避免退化成微信必拒的空映射)', () => {
|
||||
const r = parseSubscribeTemplates(JSON.stringify({ SONG_SELECTED: { id: 'brand-new-id' } }))
|
||||
assert.equal(r.envJsonValid, true)
|
||||
assert.deepEqual(r.overriddenKeys, ['SONG_SELECTED'])
|
||||
assert.deepEqual(r.inheritedFieldsKeys, ['SONG_SELECTED'])
|
||||
assert.deepEqual(r.unknownKeys, [])
|
||||
assert.equal(r.templates.SONG_SELECTED.id, 'brand-new-id')
|
||||
assert.deepEqual(r.templates.SONG_SELECTED.fields, BUILTIN_TEMPLATES.SONG_SELECTED.fields)
|
||||
// 另一张模板不受影响
|
||||
assert.equal(r.templates.SONG_REJECTED.id, BUILTIN_TEMPLATES.SONG_REJECTED.id)
|
||||
})
|
||||
|
||||
test('给 fields:换字段映射,title 沿用内置的', () => {
|
||||
const r = parseSubscribeTemplates(
|
||||
JSON.stringify({ SONG_REJECTED: { id: 'x', fields: { thing1: 'reason' } } })
|
||||
)
|
||||
assert.deepEqual(r.templates.SONG_REJECTED.fields, { thing1: 'reason' })
|
||||
assert.equal(r.templates.SONG_REJECTED.title, BUILTIN_TEMPLATES.SONG_REJECTED.title)
|
||||
assert.deepEqual(r.inheritedFieldsKeys, [])
|
||||
})
|
||||
|
||||
test('新增一个内置表里没有的模板 key 是合法的(只要给全 id/fields)', () => {
|
||||
const r = parseSubscribeTemplates(
|
||||
JSON.stringify({
|
||||
SONG_PLAYED: { id: 'played-id', title: '已播放', fields: { thing1: 'songTitle' } }
|
||||
})
|
||||
)
|
||||
assert.deepEqual(r.newAdditionsKeys, ['SONG_PLAYED'])
|
||||
assert.deepEqual(r.unknownKeys, [])
|
||||
assert.equal(r.templates.SONG_PLAYED.id, 'played-id')
|
||||
assert.equal(listTemplateIds(r.templates).length, 3)
|
||||
})
|
||||
|
||||
test('用不了的项:缺 id / 类型不对 → 记进 unknownKeys 并忽略,其余配置照常生效', () => {
|
||||
const r = parseSubscribeTemplates(
|
||||
JSON.stringify({
|
||||
NO_ID: { fields: { thing1: 'songTitle' } },
|
||||
WRONG_TYPE: 'not-an-object',
|
||||
NULL_ITEM: null,
|
||||
SONG_SELECTED: { id: 'kept-id' }
|
||||
})
|
||||
)
|
||||
assert.deepEqual(r.unknownKeys.sort(), ['NO_ID', 'NULL_ITEM', 'WRONG_TYPE'])
|
||||
assert.deepEqual(r.overriddenKeys, ['SONG_SELECTED'])
|
||||
// 坏项没有污染模板表:仍然只有两个内置模板,且好的那项生效了
|
||||
assert.equal(listTemplateIds(r.templates).length, 2)
|
||||
assert.equal(r.templates.SONG_SELECTED.id, 'kept-id')
|
||||
})
|
||||
|
||||
test('环境变量是数组或标量时也算坏 JSON(不会把模板表搞乱)', () => {
|
||||
for (const bad of ['[]', '123', '"abc"', 'null']) {
|
||||
const r = parseSubscribeTemplates(bad)
|
||||
assert.equal(r.envJsonValid, false, bad + ' 应该被判定为不可用')
|
||||
assert.equal(listTemplateIds(r.templates).length, 2)
|
||||
}
|
||||
})
|
||||
|
||||
test('findEmptyFields:变量没给或全是空白 → 点名到字段和变量', () => {
|
||||
const conf = BUILTIN_TEMPLATES.SONG_SELECTED
|
||||
assert.deepEqual(
|
||||
findEmptyFields(conf, {})
|
||||
.map((f) => f.field)
|
||||
.sort(),
|
||||
['character_string3', 'date4', 'phrase5', 'thing2', 'time1']
|
||||
)
|
||||
|
||||
const partial = findEmptyFields(conf, {
|
||||
songTitle: '晴天',
|
||||
playDate: ' ',
|
||||
playTime: '2026-02-14 12:30',
|
||||
sequence: '1',
|
||||
status: '已排期'
|
||||
})
|
||||
assert.deepEqual(partial, [{ field: 'date4', variable: 'playDate' }])
|
||||
})
|
||||
|
||||
test('findEmptyDataFields:拼完 data 再兜一层空值检查', () => {
|
||||
assert.deepEqual(findEmptyDataFields({ a: { value: ' ' }, b: { value: 'ok' } }), [
|
||||
{ field: 'a', value: ' ' }
|
||||
])
|
||||
assert.deepEqual(findEmptyDataFields({ b: { value: 'ok' } }), [])
|
||||
})
|
||||
|
||||
test('buildTemplateData:按映射取值,并交给 clamp 处理(time 有兜底值)', async () => {
|
||||
const { clampField } = await import('../server/utils/wechatSubscribeFields.ts')
|
||||
const conf = BUILTIN_TEMPLATES.SONG_SELECTED
|
||||
const data = buildTemplateData(
|
||||
conf,
|
||||
{
|
||||
songTitle: '晴天',
|
||||
playDate: '2026-02-14',
|
||||
playTime: '2026-02-14 12:30',
|
||||
sequence: 3,
|
||||
status: '已排期'
|
||||
},
|
||||
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(Array.from(data.phrase5.value).length, 5)
|
||||
assert.deepEqual(findEmptyDataFields(data), [], '有完整变量时不该出现空字段')
|
||||
})
|
||||
|
||||
test('explainWechatErrcode:把会遇到的错误码翻成人话', () => {
|
||||
assert.match(explainWechatErrcode(0), /成功/)
|
||||
assert.match(explainWechatErrcode(43101), /没有可用授权/)
|
||||
assert.match(explainWechatErrcode(47003), /字段/)
|
||||
assert.match(explainWechatErrcode(40003), /openid/)
|
||||
assert.match(explainWechatErrcode(40037), /模板 ID/)
|
||||
assert.match(explainWechatErrcode(40001), /access_token/)
|
||||
assert.match(explainWechatErrcode(45009), /上限/)
|
||||
assert.match(explainWechatErrcode(undefined), /未知/)
|
||||
assert.match(explainWechatErrcode(99999), /99999/)
|
||||
})
|
||||
|
||||
test('服务层不再自己写死模板(模板定义只应有一处)', async () => {
|
||||
/*
|
||||
* 以前 `DEFAULT_TEMPLATES` 直接写在 wechatSubscribeService.ts 里,
|
||||
* 于是「模板 ID」和「字段映射」在源码里有两份容易走样的副本。
|
||||
* 现在唯一来源是 server/utils/wechatSubscribeTemplates.ts,这里防止回退。
|
||||
*/
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const { fileURLToPath } = await import('node:url')
|
||||
const src = readFileSync(
|
||||
fileURLToPath(new URL('../server/services/wechatSubscribeService.ts', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
assert.ok(!/DEFAULT_TEMPLATES/.test(src), '服务层不应再定义 DEFAULT_TEMPLATES')
|
||||
assert.ok(
|
||||
!/erAK99M38ZeD/.test(src),
|
||||
'模板 ID 不应硬编码在服务层(应来自 wechatSubscribeTemplates.ts)'
|
||||
)
|
||||
assert.ok(
|
||||
!/oLTAefO92xYYlGRY/.test(src),
|
||||
'模板 ID 不应硬编码在服务层(应来自 wechatSubscribeTemplates.ts)'
|
||||
)
|
||||
// 旧调用点的函数名必须保留,否则 notificationService 会编译失败
|
||||
for (const fn of [
|
||||
'sendWechatSubscribeToUser',
|
||||
'sendWechatSubscribe',
|
||||
'buildTemplateData',
|
||||
'getSubscribeTemplates',
|
||||
'getSubscribeTemplateIds',
|
||||
'isWechatSubscribeEnabled',
|
||||
'getMiniAccessToken',
|
||||
'getWechatOpenid',
|
||||
'sendRawSubscribe',
|
||||
'inspectWechatSubscribe',
|
||||
'grantWechatSubscribe'
|
||||
]) {
|
||||
assert.match(src, new RegExp('export (async )?function ' + fn + '\\b'), `缺少导出 ${fn}`)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user