Files
BYW-VoiceHub/server/services/wechatSubscribeService.ts
T
Henry_Du d1b643bd93 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 通过。
2026-10-11 16:02:33 +08:00

974 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 微信**订阅消息** —— 小程序唯一能主动触达用户的官方通道。
*
* ## 平台侧的硬限制(不是实现问题,绕不过去)
*
* 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
// ---------------- 模板配置 ----------------
let templatesCache: ReturnType<typeof parseSubscribeTemplates> | null = null
/**
* 解析模板配置(内置表 + 环境变量覆盖),结果按进程缓存。
* 环境变量写坏了只会记一次 error,然后照常用内置表 —— **坏配置不该让通知整个停摆**。
*/
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)}`
: '')
)
}
}
return templatesCache
}
/** 最终生效的模板表 */
export function getSubscribeTemplates(): Record<string, TemplateConf> {
return getTemplatesReport().templates
}
/** 有没有配置任何模板(没配置时所有发送都会以 no-template 跳过) */
export function isWechatSubscribeEnabled(): boolean {
return Object.keys(getSubscribeTemplates()).length > 0
}
/** 模板 ID 列表(给小程序去申请授权用) */
export function getSubscribeTemplateIds(): string[] {
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<string, unknown>
): Record<string, { value: string }> {
return buildDataFromConf(conf, Object.assign({ time: beijingNow() }, vars), clampField)
}
// ---------------- 授权记账表可用吗 ----------------
let trackingTableChecked = false
/**
* 记账表是否存在可用(迁移没跑时为 false,只查一次并缓存)。
*
* **这条很关键**:如果表不存在,`readGrantRows` 会返回空 Map,看起来就像
* 「所有用户都没有额度」,于是发送全被 `no-grant` 预检挡掉 —— 通知会整个哑掉。
* 所以拿不准的时候要**失败开放**(fail-open):照发,让微信自己回 43101。
*/
async function trackingTableAvailable(): Promise<boolean> {
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
export async function getMiniAccessToken(force = false): Promise<string | null> {
if (!force && tokenCache && tokenCache.expiresAt > Date.now()) return tokenCache.token
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,无法获取 access_token'
)
return null
}
try {
/*
* 与 jscode2session 同一类坑:微信这个接口的 content-type 不一定是 application/json,
* ofetch 不解析就返回字符串(线上「微信登录一直 401」的根因就是这个)。
* 这里显式 responseType: 'json',并且再做一次字符串兜底解析。
*/
let res: any = await $fetch(TOKEN_URL, {
query: { grant_type: 'client_credential', appid, secret },
timeout: 8000,
responseType: 'json'
})
if (typeof res === 'string') {
try {
res = JSON.parse(res)
} catch {
res = null
}
}
if (!res || !res.access_token) {
console.error(
`[WeChatSubscribe] 获取 access_token 失败:errcode=${res?.errcode} errmsg=${res?.errmsg}(检查 appid/secret,以及小程序后台的 IP 白名单)`
)
return null
}
tokenCache = {
token: res.access_token,
expiresAt: Date.now() + Math.max(60, (res.expires_in || 7200) * 1000) - TOKEN_SAFETY_MS
}
return tokenCache.token
} catch (err: any) {
console.error('[WeChatSubscribe] 获取 access_token 异常:', err?.message)
return null
}
}
// ---------------- 用户侧查询 ----------------
/** 取用户的小程序 openid(没绑微信就是 null) */
export async function getWechatOpenid(userId: number): Promise<string | null> {
try {
const rows = await db
.select({ providerUserId: userIdentities.providerUserId })
.from(userIdentities)
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.provider, PROVIDER)))
.limit(1)
return rows[0]?.providerUserId || null
} catch (err: any) {
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<Map<string, GrantRow>> {
const map = new Map<string, GrantRow>()
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<void> {
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 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 模板键(默认内置 SONG_SELECTED / SONG_REJECTED)
* @param vars 变量表;模板字段通过 `fields` 映射到这里的键
*/
export async function sendWechatSubscribe(
userId: number,
kind: string,
vars: Record<string, unknown>,
opts: SendOptions = {}
): Promise<SendOutcome> {
const base = (
partial: Partial<SendOutcome> & { 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<string, unknown>
): Promise<SendResult> {
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
}
}
/** 真正打微信那一次请求;响应可能是字符串(微信 Content-Type 不保证是 json),这里兜底解析 */
async function callSubscribeSend(
token: string,
openid: string,
templateId: string,
data: Record<string, { value: string }>
): Promise<any> {
let res: any = await $fetch(SEND_URL, {
method: 'POST',
query: { access_token: token },
body: {
touser: openid,
template_id: templateId,
page: NOTIFY_PAGE,
data,
miniprogram_state: process.env.NODE_ENV === 'production' ? 'formal' : 'developer'
},
timeout: 8000,
responseType: 'json'
})
if (typeof res === 'string') {
try {
res = JSON.parse(res)
} catch {
res = { errcode: -1, errmsg: 'unparsable response: ' + String(res).slice(0, 120) }
}
}
return res
}
/** 按模板 ID 直发(自测用):不做预检,把微信的原始响应带回去 */
export async function sendRawSubscribe(
openid: string,
templateId: string,
data: Record<string, { value: string }>
): Promise<SendResult & { raw?: any }> {
try {
const token = await getMiniAccessToken()
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<string, string>
/** 这些字段映射到的变量,调用方有没有给(给了模板里也没用时是另一回事) */
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<string, unknown> = {
songTitle: '测试歌曲《晴天》',
playDate: '2026-02-14',
playTime: '2026-02-14 12:30',
sequence: '1',
status: '已排期',
reason: '测试驳回原因',
message: '这是一条测试通知',
time: beijingNow()
}
/** 用一组示例变量把模板拼出来看一眼(体检报告用) */
export function sampleDataFor(conf: TemplateConf): Record<string, { value: string }> {
return buildTemplateData(conf, SAMPLE_VARS)
}
/**
* 只读体检:不发消息、不消耗额度。
* 把「能不能发、为什么发不出去、用户该做什么」需要的事实一次说清楚。
*/
export async function inspectWechatSubscribe(
userId: number,
opts: { username?: string | null } = {}
): Promise<SubscribeInspection> {
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
}
}