From d1b643bd93c87f4736bc3639f8566196a6c2bdeb Mon Sep 17 00:00:00 2001 From: HenryDu8133 <813367384@qq.com> Date: Sun, 11 Oct 2026 16:02:33 +0800 Subject: [PATCH] =?UTF-8?q?refactor(=E5=BE=AE=E4=BF=A1=E8=AE=A2=E9=98=85):?= =?UTF-8?q?=20=E9=87=8D=E5=86=99=E8=AE=A2=E9=98=85=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=EF=BC=8C=E6=AF=8F=E4=B8=80=E6=AD=A5=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E9=83=BD=E5=8F=AF=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把「微信订阅消息」这套代码按验收标准推翻重写:用户能自己看懂为什么收不到, 任何一步失败都必须看得见。对外函数名不变,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 通过。 --- .env.example | 26 +- .gitattributes | 2 + ...0418113000_add_wechat_subscribe_grants.sql | 28 + app/drizzle/schema.ts | 32 + server/api/admin/wechat/diagnose.get.ts | 125 +- .../api/user/wechat-subscribe-grant.post.ts | 45 + .../api/user/wechat-subscribe-status.get.ts | 42 + server/services/wechatSubscribeService.ts | 1051 +++++++++++++---- server/utils/wechatSubscribeTemplates.ts | 233 ++++ test/wechatSubscribeFields.test.mjs | 11 +- test/wechatSubscribeTemplates.test.mjs | 232 ++++ 11 files changed, 1519 insertions(+), 308 deletions(-) create mode 100644 app/drizzle/migrations/20260418113000_add_wechat_subscribe_grants.sql create mode 100644 server/api/user/wechat-subscribe-grant.post.ts create mode 100644 server/api/user/wechat-subscribe-status.get.ts create mode 100644 server/utils/wechatSubscribeTemplates.ts create mode 100644 test/wechatSubscribeTemplates.test.mjs diff --git a/.env.example b/.env.example index 80972c5..f9b883a 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.gitattributes b/.gitattributes index a37c243..7af878f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/app/drizzle/migrations/20260418113000_add_wechat_subscribe_grants.sql b/app/drizzle/migrations/20260418113000_add_wechat_subscribe_grants.sql new file mode 100644 index 0000000..eea8b9f --- /dev/null +++ b/app/drizzle/migrations/20260418113000_add_wechat_subscribe_grants.sql @@ -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"); diff --git a/app/drizzle/schema.ts b/app/drizzle/schema.ts index de0e33a..7d79568 100644 --- a/app/drizzle/schema.ts +++ b/app/drizzle/schema.ts @@ -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(), diff --git a/server/api/admin/wechat/diagnose.get.ts b/server/api/admin/wechat/diagnose.get.ts index 42a581b..938044d 100644 --- a/server/api/admin/wechat/diagnose.get.ts +++ b/server/api/admin/wechat/diagnose.get.ts @@ -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 = { - 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 = { - 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> = [] + 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 diff --git a/server/api/user/wechat-subscribe-grant.post.ts b/server/api/user/wechat-subscribe-grant.post.ts new file mode 100644 index 0000000..90fcac6 --- /dev/null +++ b/server/api/user/wechat-subscribe-grant.post.ts @@ -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 条额度,服务端发一条扣一条' + : '记账失败,但不影响你已经拿到的微信授权' + } +}) diff --git a/server/api/user/wechat-subscribe-status.get.ts b/server/api/user/wechat-subscribe-status.get.ts new file mode 100644 index 0000000..5fb2f4d --- /dev/null +++ b/server/api/user/wechat-subscribe-status.get.ts @@ -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 是服务端自己记的近似额度:微信不提供剩余授权查询,一次性订阅发一条扣一条,发完要用户再点一次「开启」' + } +}) diff --git a/server/services/wechatSubscribeService.ts b/server/services/wechatSubscribeService.ts index 55ad8c0..f572033 100644 --- a/server/services/wechatSubscribeService.ts +++ b/server/services/wechatSubscribeService.ts @@ -1,144 +1,172 @@ -import { db } from '~/drizzle/db' -import { notificationSettings, userIdentities } from '~/drizzle/schema' -import { and, eq } from 'drizzle-orm' -// ⚠️ 服务端 utils 在仓库根的 server/ 下,Nuxt 4 里 `~` 指向 app/,必须用 `~~` -import { beijingNow, clampField } from '~~/server/utils/wechatSubscribeFields' - -// 字段裁剪规则集中在 utils/wechatSubscribeFields.ts(纯函数,可单独测),这里只做转发 -export { beijingNow, buildWechatTimeValue, clampField } from '~~/server/utils/wechatSubscribeFields' - /** * 微信**订阅消息** —— 小程序唯一能主动触达用户的官方通道。 * - * ⚠️ 平台侧的四条硬限制(不是实现问题,绕不过去): - * 1. 必须由**用户点击**触发 `wx.requestSubscribeMessage` 授权,不能静默订阅; - * 2. 「一次性订阅」= **一次授权只能发一条**,发完要用户再点一次; - * 3. 只有**绑定了微信**的账号才有 openid(`userIdentities.provider = 'wechat-mp'`), - * 纯账号密码用户发不出去(小程序那边要引导他们先绑微信); - * 4. 模板字段的类型和长度由模板决定,**某一个字段超长会让整条消息被拒**(47003,报错里那个 - * 字段名是微信挑的,不代表只有它有问题)。各类型的限制: - * · thing 20 字(本服务截 19 字 + 省略号) - * · phrase 5 个以内纯汉字(恰好 5 个在部分模板上会被拒,见 utils/wechatSubscribeFields.ts) - * · time 必须是 'YYYY-MM-DD HH:mm' - * · date 必须是 'YYYY-MM-DD' - * · character_string 32 字(只能数字、字母) - * · number/amount 32 字(只能数字) - * 所以这里按模板配置逐个裁剪;另外**空值也会 47003**,拿不到值的字段用占位文案兜住。 + * ## 平台侧的硬限制(不是实现问题,绕不过去) * - * 配置(服务器环境变量;模板一项缺失就整体静默关闭,不影响其它通知渠道): - * WECHAT_MINI_APPID / WECHAT_MINI_SECRET —— 已有(微信登录在用) - * WECHAT_MINI_SUBSCRIBE_TEMPLATES —— JSON: - * { - * "SONG_PLAYED": { "id": "模板ID", "fields": { "thing1": "songTitle", "time2": "time", "thing3": "message" } }, - * "SONG_SELECTED": { "id": "模板ID", "fields": { "thing2": "songTitle", "time1": "playTime" } } - * } - * `fields` 的**键**是模板里的字段名(公众平台选用模板时能看到,如 thing1/time2/character_string3), - * **值**是变量名,取自调用方传进来的 vars(songTitle / message / time 是内置的)。 + * 1. 必须由**用户点击**触发 `wx.requestSubscribeMessage` 授权,不能静默订阅; + * 2. 「一次性订阅」= **一次授权只能发一条**,发完要用户再点一次; + * 3. 只有**绑定了微信**的账号才有 openid(`UserIdentity.provider = 'wechat-mp'`); + * 4. 模板字段的类型/长度/空值校验是**整条消息级别**的:任何一个字段不对,整条发不出去, + * 微信只回 `47003` 并挑一个字段名报(不代表只有它有问题); + * 5. **微信没有「还剩几条授权」的查询接口**。所以下面用 `WechatSubscribeGrant` 表按 + * 模板记账(用户授权 +1、发送成功 -1),给出的只是**我们自己数的近似值**, + * 真值只有发一条收到 `43101` 才知道。 + * + * ## 这个文件的设计原则 + * + * - **零静默失败**:每一次调用都以 `SendOutcome` 返回(含 `stage` 卡在哪一步、`reason` + * 机器可读的原因、`detail` 人话、`errcode` 微信原始码),并且一定写一行日志。 + * - **查询与动作分离**:`inspectWechatSubscribe(userId)` 只读、不发消息、不消耗额度; + * `grantWechatSubscribe(userId, ids)` 只记账;`sendWechatSubscribe(userId, kind, vars)` 才真发。 + * - **失败不影响主流程**:本模块所有导出函数都不抛异常(通知失败不该让投稿/排期失败)。 + * + * ## 发不出去时怎么定位(按 stage 对号入座) + * + * | stage | 含义 | 谁去修 | + * |------------------------|---------------------------------------------|------------------| + * | `no-template` | 没有这个 kind 的模板配置 | 改代码/环境变量 | + * | `disabled-by-user` | 用户关了通知总开关 | 用户 | + * | `no-wechat-openid` | 这个账号没绑微信 | 用户(先绑微信) | + * | `no-access-token` | appid/secret 不对或微信侧故障 | 站长 | + * | `empty-field` | 某个字段的变量是空的(微信必 47003) | 改代码/模板 | + * | `no-grant` | 本地记账显示额度已用完(**没发**,省额度) | 用户(再点开启) | + * | `rejected-no-quota` | 微信回了 43101:没有可用授权 | 用户(再点开启) | + * | `rejected-bad-field` | 微信回了 47003:字段名/值不合规 | 站长(核对模板) | + * | `rejected-other` | 其它 errcode | 看 errcode | + * | `sent` | 发出去了(微信已受理) | — | */ +import { db } from '~/drizzle/db' +import { notificationSettings, userIdentities, wechatSubscribeGrants } from '~/drizzle/schema' +import { and, eq, inArray, sql } from 'drizzle-orm' +// ⚠️ 服务端 utils 在仓库根的 server/ 下,Nuxt 4 里 `~` 指向 app/,必须用 `~~` +import { beijingNow, clampField } from '~~/server/utils/wechatSubscribeFields' +import { + TEMPLATES_ENV_KEY, + buildTemplateData as buildDataFromConf, + findEmptyDataFields, + findEmptyFields, + listTemplateIds, + parseSubscribeTemplates, + type TemplateConf +} from '~~/server/utils/wechatSubscribeTemplates' + +// 字段裁剪规则集中在 utils/wechatSubscribeFields.ts(纯函数,可单独测),这里只做转发 +export { + beijingNow, + buildWechatTimeValue, + clampField, + fieldType, + FIELD_MAX_LEN +} from '~~/server/utils/wechatSubscribeFields' +export { + BUILTIN_TEMPLATES, + explainWechatErrcode, + findEmptyDataFields, + findEmptyFields, + parseSubscribeTemplates, + TEMPLATES_ENV_KEY, + type TemplateConf +} from '~~/server/utils/wechatSubscribeTemplates' + const PROVIDER = 'wechat-mp' const TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/token' const SEND_URL = 'https://api.weixin.qq.com/cgi-bin/message/subscribe/send' +/** 订阅消息落地的小程序页面(用户点开通知卡片跳到哪) */ +const NOTIFY_PAGE = 'pages/home/home' +/** 提前 5 分钟作废 token,避免卡在边界上 */ +const TOKEN_SAFETY_MS = 5 * 60 * 1000 -type TemplateConf = { id: string; fields?: Record } +// ---------------- 模板配置 ---------------- + +let templatesCache: ReturnType | null = null /** - * **内置默认模板**(站长在公众平台选好的,可直接用;环境变量里的配置会按键覆盖它)。 - * - * SONG_REJECTED = 歌曲被驳回: - * 模板字段「审核内容」= thing6 → songTitle - * 模板字段「驳回原因」= thing4 → reason - * ⚠️ thing 类型字段微信限 **20 个字符**,超了会被拒(47003), - * 所以 clampField 会截断(超过 20 字时截 19 字 + 省略号)。 - * - * SONG_SELECTED = 歌曲被选中(管理员把歌排进排期 / 发布排期)。 - * 站点上这条通知一直是发出去的,但之前**没有**内置微信模板,所以小程序那边收不到; - * 站长 2026-02 在公众平台选好了「歌曲排期」模板,这里补上默认映射: - * 模板字段「歌曲名称」= thing2 → songTitle - * 模板字段「播放日期」= date4 → playDate('YYYY-MM-DD',与站内通知里的日期一致) - * 模板字段「播放时间」= time1 → playTime('YYYY-MM-DD HH:mm',优先取排期时段的开始时间) - * 模板字段「排期序号」= character_string3 → sequence - * 模板字段「排期状态」= phrase5 → status(固定「已排期」,phrase 必须恰好 5 个汉字) - * ⚠️ 字段名(thing2/date4/…)必须和公众平台模板里的一模一样,否则微信 47003。 - * 站长若选了别的字段名,用环境变量 WECHAT_MINI_SUBSCRIBE_TEMPLATES 覆盖 fields 即可, - * 不用改代码(例:`{"SONG_SELECTED":{"id":"oS4Ev…","fields":{"thing1":"songTitle"}}}`)。 - * - * 另外一个**必须让用户知道**的平台特性:模板 ID 变了 = 用户要重新授权。 - * 老用户之前点的授权只对旧模板 ID 有效,新增模板后他们得再点一次「开启微信通知」, - * 否则这条消息会以 43101(无可用授权)静默跳过 —— 那是正常状态,不是 bug。 + * 解析模板配置(内置表 + 环境变量覆盖),结果按进程缓存。 + * 环境变量写坏了只会记一次 error,然后照常用内置表 —— **坏配置不该让通知整个停摆**。 */ -const DEFAULT_TEMPLATES: Record = { - SONG_REJECTED: { - id: 'erAK99M38ZeD-F1mOIgN2M7iAPVTU0YXo-adlkSwg_c', - fields: { thing6: 'songTitle', thing4: 'reason' } - }, - SONG_SELECTED: { - // 站长提供的「歌曲排期」模板 ID - id: 'oLTAefO92xYYlGRYmcsm5AfUUlUI5IKFfXhIVNrItSk', - fields: { - thing2: 'songTitle', - date4: 'playDate', - time1: 'playTime', - character_string3: 'sequence', - phrase5: 'status' +export function getTemplatesReport() { + if (!templatesCache) { + templatesCache = parseSubscribeTemplates() + const r = templatesCache + if (!r.envJsonValid) { + console.error( + `[WeChatSubscribe] ${TEMPLATES_ENV_KEY} 不是合法 JSON,已忽略它并继续使用内置模板:${String(r.envRaw).slice(0, 120)}` + ) + } else if (r.envRaw) { + console.log( + `[WeChatSubscribe] ${TEMPLATES_ENV_KEY} 已生效:覆盖 ${JSON.stringify(r.overriddenKeys)}` + + (r.newAdditionsKeys.length ? `;新增模板 ${JSON.stringify(r.newAdditionsKeys)}` : '') + + (r.unknownKeys.length ? `;无法使用的项(已忽略)${JSON.stringify(r.unknownKeys)}` : '') + + (r.inheritedFieldsKeys.length + ? `;这些 key 沿用了内置字段映射 ${JSON.stringify(r.inheritedFieldsKeys)}` + : '') + ) } } -} - -/** 解析模板配置:内置默认 + 环境变量(同键以环境变量为准;坏 JSON 只报错一次) */ -let templatesCache: Record | null = null -export function getSubscribeTemplates(): Record { - if (templatesCache) return templatesCache - const merged: Record = Object.assign({}, DEFAULT_TEMPLATES) - const raw = process.env.WECHAT_MINI_SUBSCRIBE_TEMPLATES - if (!raw) { - templatesCache = merged - return templatesCache - } - try { - const parsed = JSON.parse(raw) - if (parsed && typeof parsed === 'object') { - Object.keys(parsed).forEach((k) => { - const conf = parsed[k] - if (!conf || !conf.id) return - const base = merged[k] - /* - * 环境变量**按键覆盖内置模板**,但只覆盖它给到的部分: - * · 给了 id 但没给 fields → 沿用内置的字段映射(否则会退化成 - * thing1/time2/thing3 的默认映射,发给微信直接 47003); - * · 给了 fields → 用它。 - * 这样"只想换个模板 ID"时不用把 fields 一起抄一遍。 - */ - merged[k] = { - id: conf.id, - fields: conf.fields && typeof conf.fields === 'object' ? conf.fields : base && base.fields - } - }) - } - } catch (err) { - console.error('[WeChatSubscribe] WECHAT_MINI_SUBSCRIBE_TEMPLATES 不是合法 JSON,已忽略(仍用内置默认模板)') - } - templatesCache = merged return templatesCache } -/** 有没有配置任何模板(没配置时所有发送都直接跳过) */ +/** 最终生效的模板表 */ +export function getSubscribeTemplates(): Record { + return getTemplatesReport().templates +} + +/** 有没有配置任何模板(没配置时所有发送都会以 no-template 跳过) */ export function isWechatSubscribeEnabled(): boolean { return Object.keys(getSubscribeTemplates()).length > 0 } /** 模板 ID 列表(给小程序去申请授权用) */ export function getSubscribeTemplateIds(): string[] { - return Object.values(getSubscribeTemplates()) - .map((t) => t && t.id) - .filter((id): id is string => !!id) + return listTemplateIds(getSubscribeTemplates()) +} + +/** 模板 ID → key(记账和状态查询用) */ +function kindByTemplateId(templateId: string): string | null { + const t = getSubscribeTemplates() + for (const kind of Object.keys(t)) { + if (t[kind].id === templateId) return kind + } + return null +} + +/** 用模板配置 + 变量拼出微信要的 data(`time` 会自动兜底成北京时间现在) */ +export function buildTemplateData( + conf: TemplateConf, + vars: Record +): Record { + return buildDataFromConf(conf, Object.assign({ time: beijingNow() }, vars), clampField) +} + +// ---------------- 授权记账表可用吗 ---------------- + +let trackingTableChecked = false + +/** + * 记账表是否存在可用(迁移没跑时为 false,只查一次并缓存)。 + * + * **这条很关键**:如果表不存在,`readGrantRows` 会返回空 Map,看起来就像 + * 「所有用户都没有额度」,于是发送全被 `no-grant` 预检挡掉 —— 通知会整个哑掉。 + * 所以拿不准的时候要**失败开放**(fail-open):照发,让微信自己回 43101。 + */ +async function trackingTableAvailable(): Promise { + if (trackingTableChecked) return true + try { + await db.select({ id: wechatSubscribeGrants.id }).from(wechatSubscribeGrants).limit(1) + trackingTableChecked = true + return true + } catch (err: any) { + console.error( + `[WeChatSubscribe] 授权记账表不可用(迁移没跑?),额度预检已关闭、通知照常发送:${err?.message}` + ) + return false + } } // ---------------- access_token(2 小时有效,必须缓存) ---------------- let tokenCache: { token: string; expiresAt: number } | null = null -/** 提前 5 分钟过期,避免边界上用到刚失效的 token */ -const TOKEN_SAFETY_MS = 5 * 60 * 1000 export async function getMiniAccessToken(force = false): Promise { if (!force && tokenCache && tokenCache.expiresAt > Date.now()) return tokenCache.token @@ -146,13 +174,15 @@ export async function getMiniAccessToken(force = false): Promise const appid = process.env.WECHAT_MINI_APPID const secret = process.env.WECHAT_MINI_SECRET if (!appid || !secret) { - console.error('[WeChatSubscribe] 未配置 WECHAT_MINI_APPID / WECHAT_MINI_SECRET') + console.error( + '[WeChatSubscribe] 未配置 WECHAT_MINI_APPID / WECHAT_MINI_SECRET,无法获取 access_token' + ) return null } try { /* - * 与 jscode2session 同一类坑:微信这个接口的 content-type 也不一定是 application/json, + * 与 jscode2session 同一类坑:微信这个接口的 content-type 不一定是 application/json, * ofetch 不解析就返回字符串(线上「微信登录一直 401」的根因就是这个)。 * 这里显式 responseType: 'json',并且再做一次字符串兜底解析。 */ @@ -169,7 +199,9 @@ export async function getMiniAccessToken(force = false): Promise } } if (!res || !res.access_token) { - console.error('[WeChatSubscribe] 获取 access_token 失败:', res?.errcode, res?.errmsg) + console.error( + `[WeChatSubscribe] 获取 access_token 失败:errcode=${res?.errcode} errmsg=${res?.errmsg}(检查 appid/secret,以及小程序后台的 IP 白名单)` + ) return null } tokenCache = { @@ -178,30 +210,14 @@ export async function getMiniAccessToken(force = false): Promise } return tokenCache.token } catch (err: any) { - console.error('[WeChatSubscribe] 获取 access_token 异常:', err?.message) + console.error('[WeChatSubscribe] 获取 access_token 异常:', err?.message) return null } } -// ---------------- 取值 / 裁剪 ---------------- -// clampField / beijingNow / buildWechatTimeValue 都在 ~/server/utils/wechatSubscribeFields.ts, -// 顶部已 import + re-export(那里的注释写清了每种字段类型的微信限制)。 +// ---------------- 用户侧查询 ---------------- -/** 用模板配置 + 变量拼出微信要的 data */ -export function buildTemplateData( - conf: TemplateConf, - vars: Record -): Record { - const fields = conf.fields || { thing1: 'songTitle', time2: 'time', thing3: 'message' } - const all: Record = Object.assign({ time: beijingNow() }, vars) - const data: Record = {} - for (const key of Object.keys(fields)) { - data[key] = { value: clampField(key, all[fields[key]]) } - } - return data -} - -/** 取用户的小程序 openid(没绑微信就没有) */ +/** 取用户的小程序 openid(没绑微信就是 null) */ export async function getWechatOpenid(userId: number): Promise { try { const rows = await db @@ -211,85 +227,481 @@ export async function getWechatOpenid(userId: number): Promise { .limit(1) return rows[0]?.providerUserId || null } catch (err: any) { - console.error('[WeChatSubscribe] 查询 openid 失败:', err?.message) + console.error('[WeChatSubscribe] 查询 openid 失败:', err?.message) return null } } +/** 用户有没有关掉通知总开关(MeoW / 微信 / 邮件共用这一份设置,没记录=开) */ +async function getNotificationEnabled( + userId: number +): Promise<{ enabled: boolean; hasRecord: boolean }> { + try { + const rows = await db + .select({ enabled: notificationSettings.enabled }) + .from(notificationSettings) + .where(eq(notificationSettings.userId, userId)) + .limit(1) + if (!rows[0]) return { enabled: true, hasRecord: false } + return { enabled: rows[0].enabled !== false, hasRecord: true } + } catch (err: any) { + console.error('[WeChatSubscribe] 查询通知开关失败:', err?.message) + return { enabled: true, hasRecord: false } + } +} + +// ---------------- 授权记账(本地近似值) ---------------- + +export type GrantRow = { + id: number + userId: number + templateId: string + kind: string | null + grantedCount: number + sentCount: number + blockedCount: number + lastGrantedAt: Date | null + lastSentAt: Date | null + lastBlockedAt: Date | null + lastErrorCode: number | null + lastErrorMessage: string | null +} + +/** 读一批 (userId, templateId) 的记账行(表刚建好/迁移没跑时返回空,不抛) */ +async function readGrantRows( + userId: number, + templateIds: string[] +): Promise> { + const map = new Map() + const unique = Array.from(new Set(templateIds.filter(Boolean))) + if (!unique.length) return map + try { + // 分批查:IN 列表太长会撞 PostgreSQL 的参数上限 + const size = 200 + for (let i = 0; i < unique.length; i += size) { + const chunk = unique.slice(i, i + size) + const rows = (await db + .select() + .from(wechatSubscribeGrants) + .where( + and( + eq(wechatSubscribeGrants.userId, userId), + inArray(wechatSubscribeGrants.templateId, chunk) + ) + )) as GrantRow[] + rows.forEach((row) => map.set(row.templateId, row)) + } + } catch (err: any) { + console.error('[WeChatSubscribe] 读取授权记账失败(迁移没跑?):', err?.message) + } + return map +} + +/** + * 记录「用户刚刚点了授权」。 + * 由 `POST /api/user/wechat-subscribe-grant`(小程序 `wx.requestSubscribeMessage` 成功后调用, + * **只传微信真的返回 accept 的模板 ID**)触发。 + */ +export async function grantWechatSubscribe( + userId: number, + templateIds: string[], + source = 'miniprogram' +): Promise<{ + ok: boolean + granted: Array<{ templateId: string; kind: string | null }> + error?: string +}> { + const accepted: Array<{ templateId: string; kind: string | null }> = [] + const configIds = new Set(getSubscribeTemplateIds()) + const unique = Array.from(new Set(templateIds.filter((id) => !!id && configIds.has(id)))) + if (!unique.length) { + console.warn( + `[WeChatSubscribe] 授权记账:user ${userId} 上报的模板 ID 没有一个在服务端配置里(${JSON.stringify(templateIds)})` + ) + return { ok: false, granted: [], error: 'template-id-not-configured' } + } + + const now = new Date() + for (const templateId of unique) { + const kind = kindByTemplateId(templateId) + accepted.push({ templateId, kind }) + try { + await db + .insert(wechatSubscribeGrants) + .values({ + userId, + templateId, + kind: kind || null, + grantedCount: 1, + lastGrantedAt: now + }) + .onConflictDoUpdate({ + target: [wechatSubscribeGrants.userId, wechatSubscribeGrants.templateId], + set: { + kind: kind || null, + lastGrantedAt: now, + grantedCount: sqlIncrement(wechatSubscribeGrants.grantedCount, 1) + } + }) + } catch (err: any) { + // 记账失败不能影响用户已经拿到的授权,只留痕 + console.error( + `[WeChatSubscribe] 授权记账写入失败:user ${userId} template ${templateId} :: ${err?.message}` + ) + return { ok: false, granted: accepted, error: err?.message } + } + } + console.log( + `[WeChatSubscribe] 授权记账:user ${userId} +${accepted.length}(来源 ${source})${JSON.stringify( + accepted.map((a) => a.kind || a.templateId) + )}` + ) + return { ok: true, granted: accepted } +} + +/** + * drizzle 的「列 = 列 + n」表达式。 + * 用 sql 模板交给数据库自己算,避免「先读后写」在并发下把计数覆盖掉。 + */ +function sqlIncrement(column: any, by: number) { + return sql`${column} + ${by}` +} + +/** 发送结果记账(成功 +1、被拒 +1,都写 lastErrorCode 便于以后追溯) */ +async function recordSendOutcome( + userId: number, + templateId: string, + kind: string, + outcome: { ok: boolean; errcode?: number | null; errmsg?: string | null } +): Promise { + const now = new Date() + try { + await db + .insert(wechatSubscribeGrants) + .values({ + userId, + templateId, + kind, + sentCount: outcome.ok ? 1 : 0, + blockedCount: outcome.ok ? 0 : 1, + lastSentAt: outcome.ok ? now : null, + lastBlockedAt: outcome.ok ? null : now, + lastErrorCode: outcome.errcode ?? null, + lastErrorMessage: outcome.errmsg ?? null + }) + .onConflictDoUpdate({ + target: [wechatSubscribeGrants.userId, wechatSubscribeGrants.templateId], + set: outcome.ok + ? { + sentCount: sqlIncrement(wechatSubscribeGrants.sentCount, 1), + lastSentAt: now, + lastErrorCode: 0, + lastErrorMessage: null + } + : { + blockedCount: sqlIncrement(wechatSubscribeGrants.blockedCount, 1), + lastBlockedAt: now, + lastErrorCode: outcome.errcode ?? null, + lastErrorMessage: outcome.errmsg ?? null + } + }) + } catch (err: any) { + console.error( + `[WeChatSubscribe] 发送记账写入失败:user ${userId} template ${templateId} :: ${err?.message}` + ) + } +} + // ---------------- 发送 ---------------- -export type SendResult = { ok: boolean; errcode?: number; errmsg?: string; skipped?: string } +export type SendStage = + | 'sent' + | 'no-template' + | 'disabled-by-user' + | 'no-wechat-openid' + | 'no-access-token' + | 'empty-field' + | 'no-grant' + | 'rejected-no-quota' + | 'rejected-bad-field' + | 'rejected-other' + +export type SendOutcome = { + ok: boolean + /** 卡在哪一步(对号入座表见本文件头部注释) */ + stage: SendStage + kind: string + userId: number + templateId?: string + /** 机器可读的原因码:no-template / disabled-by-user / no-wechat-openid / no-access-token / + * empty-field / no-grant / no-quota(43101) / bad-field(47003) / wechat-error */ + reason: string + /** 人话解释(能直接展示给用户看) */ + detail: string + /** 微信原始 errcode(只有真发到微信才有) */ + errcode?: number | null + errmsg?: string | null + /** 空字段清单(stage=empty-field 时有值) */ + emptyFields?: Array<{ field: string; variable: string }> + /** 本次真的调用微信了吗(no-grant/empty-field 等预检不通过时为 false,不消耗额度) */ + attempted: boolean +} + +type SendOptions = { + /** 忽略本地记账的额度预检,强制发一次(自测接口用) */ + force?: boolean + /** 不真的发送,只做预检和拼包(自测接口用) */ + dryRun?: boolean + /** 日志前缀里的来源标记(例如 'test-subscribe' / 'diagnose') */ + source?: string +} + +function logOutcome(o: SendOutcome, source?: string) { + const tag = `[WeChatSubscribe]${source ? `[${source}]` : ''} ${o.kind} → user ${o.userId}` + if (o.ok) { + console.log(`${tag} 已发送(微信已受理)`) + return + } + const line = `${tag} ${o.stage}:${o.detail}${o.errcode != null ? `(errcode=${o.errcode} errmsg=${o.errmsg || ''})` : ''}` + // 43101 与「本地记账没额度」是预期内状态,用 warn;其它都是要人处理的错误,用 error + if (o.stage === 'rejected-no-quota' || o.stage === 'no-grant') console.warn(line) + else console.error(line) +} /** - * 给某个用户发一条订阅消息。 - * **永不抛异常**(通知失败不该影响主流程),结果用返回值 + 日志体现。 + * 给某个用户发一条订阅消息。**永不抛异常**,结果全在返回值里,并且一定写日志。 * - * @param kind 模板键(与 WECHAT_MINI_SUBSCRIBE_TEMPLATES 里的键对应) - * @param vars 变量:songTitle / message / time 为内置,其余按模板 fields 配置取 + * @param kind 模板键(默认内置 SONG_SELECTED / SONG_REJECTED) + * @param vars 变量表;模板字段通过 `fields` 映射到这里的键 */ +export async function sendWechatSubscribe( + userId: number, + kind: string, + vars: Record, + opts: SendOptions = {} +): Promise { + const base = ( + partial: Partial & { stage: SendStage; reason: string; detail: string } + ): SendOutcome => { + const outcome: SendOutcome = { + ok: false, + kind, + userId, + attempted: false, + ...partial + } + logOutcome(outcome, opts.source) + return outcome + } + + try { + const conf = getSubscribeTemplates()[kind] + if (!conf || !conf.id) { + return base({ + stage: 'no-template', + reason: 'no-template', + detail: `服务端没有配置「${kind}」的模板 ID:内置模板见 server/utils/wechatSubscribeTemplates.ts,也可用环境变量 ${TEMPLATES_ENV_KEY} 配置` + }) + } + + const notif = await getNotificationEnabled(userId) + if (!notif.enabled) { + return base({ + stage: 'disabled-by-user', + reason: 'disabled-by-user', + templateId: conf.id, + detail: '该用户关闭了通知总开关(站内/微信/邮件一起关),要先在设置里打开' + }) + } + + const openid = await getWechatOpenid(userId) + if (!openid) { + return base({ + stage: 'no-wechat-openid', + reason: 'no-wechat-openid', + templateId: conf.id, + detail: + '该账号没有绑定微信(UserIdentity 里没有 wechat-mp 记录),微信订阅消息发不出去,需要用户先在小程序里登录/绑定微信' + }) + } + + // 发之前先查空字段:微信对空值是整条拒绝,与其浪费一次请求和一条额度,不如提前说清楚 + const emptyByVar = findEmptyFields(conf, vars) + const data = buildTemplateData(conf, vars) + const emptyInData = findEmptyDataFields(data) + if (emptyByVar.length || emptyInData.length) { + return base({ + stage: 'empty-field', + reason: 'empty-field', + templateId: conf.id, + emptyFields: emptyByVar, + detail: `有字段的变量是空的,微信会整条拒绝(47003):${JSON.stringify( + (emptyByVar.length ? emptyByVar : emptyInData).map((f: any) => f.field || f.variable) + )}` + }) + } + + // 本地记账的额度预检:没有可用授权时直接跳过,省一次请求(force 可绕过) + // 只有确认记账表可用时才敢据此跳过 —— 表不存在时一律失败开放,照发给微信裁决 + const grant = (await readGrantRows(userId, [conf.id])).get(conf.id) + const remaining = grant ? grant.grantedCount - grant.sentCount : 0 + if (!opts.force && grant && remaining <= 0 && (await trackingTableAvailable())) { + return base({ + stage: 'no-grant', + reason: 'no-quota', + templateId: conf.id, + detail: + `本地记账显示这张模板没有可用授权了(授权 ${grant.grantedCount} 次、已成功发送 ${grant.sentCount} 次` + + (grant.lastErrorCode ? `、上次微信 errcode=${grant.lastErrorCode}` : '') + + '):一次性订阅发一条扣一条,需要用户在小程序里重新点「开启」' + }) + } + + if (opts.dryRun) { + const outcome: SendOutcome = { + ok: false, + stage: 'sent', + kind, + userId, + templateId: conf.id, + reason: 'dry-run', + detail: `dryRun:没有真的发送(预检通过,剩余授权约 ${remaining} 条)`, + attempted: false + } + console.log( + `[WeChatSubscribe]${opts.source ? `[${opts.source}]` : ''} ${kind} → user ${userId} dry-run 通过,预览 data=${JSON.stringify(data)}` + ) + return outcome + } + + const token = await getMiniAccessToken() + if (!token) { + return base({ + stage: 'no-access-token', + reason: 'no-access-token', + templateId: conf.id, + detail: + '拿不到小程序 access_token:检查 WECHAT_MINI_APPID / WECHAT_MINI_SECRET,以及小程序后台是否放行了服务器出口 IP' + }) + } + + const raw = await callSubscribeSend(token, openid, conf.id, data) + const errcode = raw?.errcode + + if (errcode === 0 || errcode === undefined) { + await recordSendOutcome(userId, conf.id, kind, { ok: true, errcode: 0 }) + const outcome: SendOutcome = { + ok: true, + stage: 'sent', + kind, + userId, + templateId: conf.id, + reason: 'sent', + detail: '微信已受理,消息会出现在用户的「服务通知」里', + errcode: 0, + errmsg: raw?.errmsg ?? null, + attempted: true + } + logOutcome(outcome, opts.source) + return outcome + } + + await recordSendOutcome(userId, conf.id, kind, { + ok: false, + errcode, + errmsg: raw?.errmsg ?? null + }) + + let stage: SendStage = 'rejected-other' + let reason = 'wechat-error' + let detail = `微信拒绝了这条消息:errcode=${errcode} errmsg=${raw?.errmsg || ''}` + if (errcode === 43101) { + stage = 'rejected-no-quota' + reason = 'no-quota' + detail = + '微信回 43101:该用户对这张模板没有可用授权。一次性订阅发一条扣一条,需要用户在小程序里重新点「开启」(模板 ID 换过的话,旧授权全部作废)' + } else if (errcode === 47003) { + stage = 'rejected-bad-field' + reason = 'bad-field' + detail = + '微信回 47003:模板字段名或字段值不合规。去公众平台核对模板字段名是否和 fields 映射一致(也可能是某个字段超长/为空)' + } + const outcome: SendOutcome = { + ok: false, + stage, + kind, + userId, + templateId: conf.id, + reason, + detail, + errcode, + errmsg: raw?.errmsg ?? null, + attempted: true + } + logOutcome(outcome, opts.source) + return outcome + } catch (err: any) { + const outcome: SendOutcome = { + ok: false, + stage: 'rejected-other', + kind, + userId, + reason: 'exception', + detail: `发送过程抛异常:${err?.message}`, + errcode: -1, + errmsg: err?.message, + attempted: true + } + logOutcome(outcome, opts.source) + return outcome + } +} + +/** + * 兼容旧调用点(notificationService 的 4 处 + test-subscribe): + * 保留 `sendWechatSubscribeToUser(userId, kind, vars)` 这个名字,内部走新实现。 + */ +export type SendResult = { + ok: boolean + errcode?: number + errmsg?: string + skipped?: string + stage?: SendStage + reason?: string + detail?: string +} + export async function sendWechatSubscribeToUser( userId: number, kind: string, vars: Record ): Promise { - try { - const conf = getSubscribeTemplates()[kind] - if (!conf || !conf.id) { - // 这四条跳过路径以前是静默 return,线上排查时日志里什么都没有,只能靠猜。 - // 现在每条都留一行,grep [WeChatSubscribe] 就能看出卡在哪一步。 - console.error(`[WeChatSubscribe] ${kind} → user ${userId} 跳过:没有配置模板 ID(no-template)`) - return { ok: false, skipped: 'no-template' } - } - - // 用户的通知总开关(与 MeoW / 邮件共用同一份设置) - const settings = await db - .select({ enabled: notificationSettings.enabled }) - .from(notificationSettings) - .where(eq(notificationSettings.userId, userId)) - .limit(1) - if (settings[0] && settings[0].enabled === false) { - console.warn(`[WeChatSubscribe] ${kind} → user ${userId} 跳过:该用户的通知总开关是关的(disabled-by-user)`) - return { ok: false, skipped: 'disabled-by-user' } - } - - const openid = await getWechatOpenid(userId) - if (!openid) { - // 用户没有绑定微信(userIdentities 里没有 provider='wechat-mp' 的行)→ 永远发不出去 - console.warn(`[WeChatSubscribe] ${kind} → user ${userId} 跳过:该用户没有绑定微信 openid(no-wechat-openid)`) - return { ok: false, skipped: 'no-wechat-openid' } - } - - const token = await getMiniAccessToken() - if (!token) { - console.error( - `[WeChatSubscribe] ${kind} → user ${userId} 跳过:拿不到小程序 access_token(no-access-token,检查 WECHAT_MINI_APPID / WECHAT_MINI_SECRET)` - ) - return { ok: false, skipped: 'no-access-token' } - } - - const data = buildTemplateData(conf, Object.assign({ songTitle: '宝一外校园点歌系统' }, vars)) - return await postSubscribe(token, openid, conf.id, data, userId, kind) - } catch (err: any) { - console.error('[WeChatSubscribe] 发送异常:', err?.message) - return { ok: false, errcode: -1, errmsg: err?.message } + const o = await sendWechatSubscribe(userId, kind, vars) + return { + ok: o.ok, + errcode: o.errcode ?? undefined, + errmsg: o.errmsg ?? undefined, + skipped: o.ok ? undefined : o.reason, + stage: o.stage, + reason: o.reason, + detail: o.detail } } -async function postSubscribe( +/** 真正打微信那一次请求;响应可能是字符串(微信 Content-Type 不保证是 json),这里兜底解析 */ +async function callSubscribeSend( token: string, openid: string, templateId: string, - data: Record, - userId: number, - kind: string -): Promise { + data: Record +): Promise { let res: any = await $fetch(SEND_URL, { method: 'POST', query: { access_token: token }, body: { touser: openid, template_id: templateId, - page: 'pages/home/home', + page: NOTIFY_PAGE, data, miniprogram_state: process.env.NODE_ENV === 'production' ? 'formal' : 'developer' }, @@ -300,30 +712,13 @@ async function postSubscribe( try { res = JSON.parse(res) } catch { - res = { errcode: -1, errmsg: 'unparsable response' } + res = { errcode: -1, errmsg: 'unparsable response: ' + String(res).slice(0, 120) } } } - - const errcode = res?.errcode - if (errcode === 0) { - console.log('[WeChatSubscribe] 已发送 ' + kind + ' → user ' + userId) - return { ok: true, errcode: 0 } - } - /* - * 43101 = 用户拒收 / 没有剩余授权次数 —— 一次性订阅的正常状态 - * (用户没点授权、或上一次已经用掉了)。属于**预期内**,只记 info。 - */ - if (errcode === 43101) { - console.log('[WeChatSubscribe] ' + kind + ' → user ' + userId + ' 没有可用授权(43101),跳过') - } else { - console.error( - '[WeChatSubscribe] 发送失败 ' + kind + ' → user ' + userId + ': ' + errcode + ' ' + res?.errmsg - ) - } - return { ok: false, errcode, errmsg: res?.errmsg } + return res } -/** 测试用:按模板 ID 直发,把微信的原始响应带回去(字段映射对不对一眼看出来) */ +/** 按模板 ID 直发(自测用):不做预检,把微信的原始响应带回去 */ export async function sendRawSubscribe( openid: string, templateId: string, @@ -331,10 +726,248 @@ export async function sendRawSubscribe( ): Promise { try { const token = await getMiniAccessToken() - if (!token) return { ok: false, skipped: 'no-access-token' } - const result = await postSubscribe(token, openid, templateId, data, 0, 'TEST') - return Object.assign({ raw: { errcode: result.errcode, errmsg: result.errmsg } }, result) + if (!token) { + console.error('[WeChatSubscribe][raw] 拿不到 access_token,直发中止') + return { + ok: false, + skipped: 'no-access-token', + reason: 'no-access-token', + attempted: false + } as any + } + const raw = await callSubscribeSend(token, openid, templateId, data) + const errcode = raw?.errcode + if (errcode === 0 || errcode === undefined) { + console.log(`[WeChatSubscribe][raw] template ${templateId.slice(0, 8)}… 直发成功`) + return { ok: true, errcode: 0, raw } + } + console.error( + `[WeChatSubscribe][raw] template ${templateId.slice(0, 8)}… 直发失败:errcode=${errcode} errmsg=${raw?.errmsg}` + ) + return { + ok: false, + errcode, + errmsg: raw?.errmsg, + skipped: errcode === 43101 ? 'no-quota' : 'wechat-error', + raw + } } catch (err: any) { + console.error('[WeChatSubscribe][raw] 直发异常:', err?.message) return { ok: false, errcode: -1, errmsg: err?.message } } } + +// ---------------- 体检 / 状态(只读,不发消息) ---------------- + +export type TemplateStatus = { + kind: string + title: string + templateId: string + /** 字段名 → 变量名 */ + fields: Record + /** 这些字段映射到的变量,调用方有没有给(给了模板里也没用时是另一回事) */ + fieldsWithSample: Array<{ field: string; variable: string; sampleValue: string }> + /** 本地记账:用户点过几次授权、成功发过几条、被拒过几次 */ + grantedCount: number + sentCount: number + blockedCount: number + /** 我们自己数的剩余额度(近似值,微信没有查询接口) */ + remainingApprox: number + lastGrantedAt: string | null + lastSentAt: string | null + lastBlockedAt: string | null + lastErrorCode: number | null + lastErrorMessage: string | null + /** 用人话总结「为什么收不到 / 现在能不能收到」 */ + verdict: string +} + +export type SubscribeInspection = { + now: string + enabled: boolean + tokenOk: boolean + tokenLen: number + env: { + templatesRaw: string | null + envJsonValid: boolean + overriddenKeys: string[] + unknownKeys: string[] + newAdditionsKeys: string[] + inheritedFieldsKeys: string[] + appidSet: boolean + secretSet: boolean + nodeEnv: string | null + } + user: { + userId: number + openidPrefix: string | null + hasOpenid: boolean + notificationEnabled: boolean + notificationSettingsExist: boolean + /** 一句话说清这个用户现在能不能收到微信通知 */ + verdict: string + } + templates: TemplateStatus[] + /** 一条条「该做什么」,给用户/管理员照着做 */ + actions: string[] + /** 记账表可用吗(迁移没跑时会 false,其它功能不受影响) */ + trackingTableOk: boolean +} + +const SAMPLE_VARS: Record = { + songTitle: '测试歌曲《晴天》', + playDate: '2026-02-14', + playTime: '2026-02-14 12:30', + sequence: '1', + status: '已排期', + reason: '测试驳回原因', + message: '这是一条测试通知', + time: beijingNow() +} + +/** 用一组示例变量把模板拼出来看一眼(体检报告用) */ +export function sampleDataFor(conf: TemplateConf): Record { + return buildTemplateData(conf, SAMPLE_VARS) +} + +/** + * 只读体检:不发消息、不消耗额度。 + * 把「能不能发、为什么发不出去、用户该做什么」需要的事实一次说清楚。 + */ +export async function inspectWechatSubscribe( + userId: number, + opts: { username?: string | null } = {} +): Promise { + const report = getTemplatesReport() + const templates = report.templates + const kinds = Object.keys(templates) + const token = await getMiniAccessToken() + const openid = await getWechatOpenid(userId) + const notif = await getNotificationEnabled(userId) + + let trackingTableOk = await trackingTableAvailable() + const grantMap = await readGrantRows( + userId, + kinds.map((k) => templates[k].id) + ) + + const templateStatuses: TemplateStatus[] = kinds.map((kind) => { + const conf = templates[kind] + const grant = grantMap.get(conf.id) + const grantedCount = grant?.grantedCount ?? 0 + const sentCount = grant?.sentCount ?? 0 + const blockedCount = grant?.blockedCount ?? 0 + const remainingApprox = Math.max(0, grantedCount - sentCount) + const fieldsWithSample = Object.keys(conf.fields || {}).map((field) => ({ + field, + variable: conf.fields[field], + sampleValue: String(sampleDataFor(conf)[field]?.value ?? '') + })) + + let verdict: string + if (!openid) verdict = '收不到:该账号没绑定微信' + else if (!notif.enabled) verdict = '收不到:用户关了通知总开关' + else if (!token) verdict = '收不到:服务端拿不到 access_token(站长检查 appid/secret)' + else if (grantedCount === 0) + verdict = '收不到:用户从没授权过这张模板,需要在小程序里点「开启」' + else if (remainingApprox <= 0) + verdict = `收不到:授权已用完(授权 ${grantedCount} 次 / 已发 ${sentCount} 次),需要再点一次「开启」` + else verdict = `可以收到:本地记账还有约 ${remainingApprox} 条额度` + + return { + kind, + title: conf.title || kind, + templateId: conf.id, + fields: conf.fields || {}, + fieldsWithSample, + grantedCount, + sentCount, + blockedCount, + remainingApprox, + lastGrantedAt: grant?.lastGrantedAt ? new Date(grant.lastGrantedAt).toISOString() : null, + lastSentAt: grant?.lastSentAt ? new Date(grant.lastSentAt).toISOString() : null, + lastBlockedAt: grant?.lastBlockedAt ? new Date(grant.lastBlockedAt).toISOString() : null, + lastErrorCode: grant?.lastErrorCode ?? null, + lastErrorMessage: grant?.lastErrorMessage ?? null, + verdict + } + }) + + const actions: string[] = [] + if (!report.envJsonValid) { + actions.push( + `环境变量 ${TEMPLATES_ENV_KEY} 不是合法 JSON(当前值 ${String(report.envRaw).slice(0, 60)}…),服务端已忽略它、继续用内置模板;建议在 Gitea secret 里改成合法 JSON 或直接删掉` + ) + } + if (report.unknownKeys.length) { + actions.push( + `环境变量里有无法使用的项:${JSON.stringify(report.unknownKeys)}(缺 id 或类型不对;这些项已被忽略,其余照常生效)` + ) + } + if (report.newAdditionsKeys.length) { + actions.push( + `环境变量新增了模板 ${JSON.stringify(report.newAdditionsKeys)}(内置表里没有,但配置合法)` + ) + } + if (!openid) { + actions.push('让用户在小程序里登录并绑定微信(服务端需要 openid 才能发订阅消息)') + } + if (!notif.enabled) { + actions.push('用户在「通知设置」里把通知总开关打开了才能收到') + } + if (!token) { + actions.push('检查 WECHAT_MINI_APPID / WECHAT_MINI_SECRET,以及小程序后台的服务器 IP 白名单') + } + templateStatuses.forEach((t) => { + if (t.remainingApprox <= 0) { + actions.push( + `模板「${t.title}」(${t.kind}) 没有可用授权:在小程序里点「开启」重新授权(一次点「允许」= 1 条,两个模板各算各的)` + ) + } + if (t.lastErrorCode === 47003) { + actions.push( + `模板「${t.title}」上次被微信以 47003 拒绝:核对公众平台的字段名是否与 fields 映射一致` + ) + } + }) + if (!trackingTableOk) { + actions.push('授权记账表还不存在(迁移没跑),额度显示会一直是 0;发消息本身不受影响') + } + + const userVerdict = !openid + ? '这个账号没有绑定微信,任何微信通知都发不出去' + : !notif.enabled + ? '这个账号关闭了通知总开关,微信通知发不出去' + : templateStatuses.some((t) => t.remainingApprox > 0) + ? '这个账号至少有一张模板还有可用授权,能收到通知' + : '这个账号绑了微信、也开着通知,但两张模板都没有可用授权了 —— 去小程序点「开启」' + + return { + now: new Date().toISOString(), + enabled: isWechatSubscribeEnabled(), + tokenOk: !!token, + tokenLen: token ? token.length : 0, + env: { + templatesRaw: report.envRaw, + envJsonValid: report.envJsonValid, + overriddenKeys: report.overriddenKeys, + unknownKeys: report.unknownKeys, + newAdditionsKeys: report.newAdditionsKeys, + inheritedFieldsKeys: report.inheritedFieldsKeys, + appidSet: !!process.env.WECHAT_MINI_APPID, + secretSet: !!process.env.WECHAT_MINI_SECRET, + nodeEnv: process.env.NODE_ENV || null + }, + user: { + userId, + openidPrefix: openid ? openid.slice(0, 6) + '****' : null, + hasOpenid: !!openid, + notificationEnabled: notif.enabled, + notificationSettingsExist: notif.hasRecord, + verdict: userVerdict + }, + templates: templateStatuses, + actions, + trackingTableOk + } +} diff --git a/server/utils/wechatSubscribeTemplates.ts b/server/utils/wechatSubscribeTemplates.ts new file mode 100644 index 0000000..104a49a --- /dev/null +++ b/server/utils/wechatSubscribeTemplates.ts @@ -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 + /** 这个模板用来干什么(写日志 / 体检报告时给人看) */ + 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}` + } +} diff --git a/test/wechatSubscribeFields.test.mjs b/test/wechatSubscribeFields.test.mjs index ac231e9..d2052d6 100644 --- a/test/wechatSubscribeFields.test.mjs +++ b/test/wechatSubscribeFields.test.mjs @@ -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) => { diff --git a/test/wechatSubscribeTemplates.test.mjs b/test/wechatSubscribeTemplates.test.mjs new file mode 100644 index 0000000..05e91da --- /dev/null +++ b/test/wechatSubscribeTemplates.test.mjs @@ -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}`) + } +})