Files
BYW-VoiceHub/server/services/wechatSubscribeService.ts
T

1255 lines
47 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) | 改代码/模板 |
* | `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'
import {
parseTemplateContent,
resolveFieldsFromKeywords,
type ResolveTemplateFieldsResult,
type TemplateKeyword
} from '~~/server/utils/wechatSubscribeRemote'
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 TEMPLATE_LIST_URL = 'https://api.weixin.qq.com/wxaapi/newtmpl/gettemplate'
/** 订阅消息落地的小程序页面(用户点开通知卡片跳到哪) */
const NOTIFY_PAGE = 'pages/home/home'
/** 提前 5 分钟作废 token,避免卡在边界上 */
const TOKEN_SAFETY_MS = 5 * 60 * 1000
/** 模板列表缓存 10 分钟:模板偶尔会变,但不能每次发送都去问微信 */
const TEMPLATE_CACHE_MS = 10 * 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,只查一次并缓存)。
*
* ⚠️ 这张表**只用于展示**(体检接口里的「大约还剩几条」)和排查,**绝不用来决定发不发**。
* 早先的版本拿它做额度预检:记账显示没额度就跳过不发送 —— 结果是记账一旦和微信侧
* 不同步(比如小程序换了新包、授权上报没发出去),本地一厢情愿地记着 0 条,
* 通知就会被无声地全部丢掉。现在一律**照发给微信裁决**,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
}
}
// ---------------- 用微信真实模板校准字段名 ----------------
export type RemoteTemplateInfo = {
id: string
title: string
content: string
type: number | null
keywords: TemplateKeyword[]
}
const remoteTemplateCache = new Map<
string,
{ at: number; info: RemoteTemplateInfo | null; error: string | null }
>()
/** 每个模板只唠叨一次「字段名被校准过」,避免日志刷屏 */
const remoteNoticeOnce = new Set<string>()
/**
* 拿账号下已有的模板列表(**带真实关键词名**),按模板 ID 找出来。
*
* 这是唯一能知道「微信给这个模板的关键词编成了 thing1 还是 thing2」的途径 ——
* 后台创建模板时编号是微信自动打的,靠猜必然出错,错了就是整条 47003。
*
* 失败一律返回 `null` 并**不影响发送**(调用方会退回配置里的字段名)。
*/
export async function getRemoteTemplate(
templateId: string,
opts: { force?: boolean } = {}
): Promise<{ info: RemoteTemplateInfo | null; error: string | null }> {
if (!templateId) return { info: null, error: 'no-template-id' }
const cached = remoteTemplateCache.get(templateId)
if (!opts.force && cached && Date.now() - cached.at < TEMPLATE_CACHE_MS) {
return { info: cached.info, error: cached.error }
}
const token = await getMiniAccessToken(opts.force)
if (!token) return { info: null, error: 'no-access-token' }
let error: string | null = null
try {
let res: any = await $fetch(TEMPLATE_LIST_URL, {
query: { access_token: token },
timeout: 8000,
responseType: 'json'
})
if (typeof res === 'string') {
try {
res = JSON.parse(res)
} catch {
res = null
}
}
if (!res || (res.errcode !== undefined && res.errcode !== 0)) {
error = `获取模板列表失败:errcode=${res?.errcode} errmsg=${res?.errmsg}`
console.error(`[WeChatSubscribe] ${error}`)
remoteTemplateCache.set(templateId, { at: Date.now(), info: null, error })
return { info: null, error }
}
const list: any[] = Array.isArray(res.data) ? res.data : []
const hit = list.find((t) => String(t?.priTmplId) === String(templateId))
if (!hit) {
error =
'这个模板 ID 不在账号的模板列表里(可能已删除,或属于另一个小程序)—— ' +
`账号下现有 ${list.length} 个模板:${list.map((t) => `${t?.title}(${t?.priTmplId})`).join('、') || '无'}`
console.error(`[WeChatSubscribe] ${error}`)
remoteTemplateCache.set(templateId, { at: Date.now(), info: null, error })
return { info: null, error }
}
const info: RemoteTemplateInfo = {
id: String(hit.priTmplId),
title: String(hit.title || ''),
content: String(hit.content || ''),
type: typeof hit.type === 'number' ? hit.type : null,
keywords: parseTemplateContent(String(hit.content || ''))
}
remoteTemplateCache.set(templateId, { at: Date.now(), info, error: null })
return { info, error: null }
} catch (err: any) {
error = `获取模板列表异常:${err?.message}`
console.error(`[WeChatSubscribe] ${error}`)
remoteTemplateCache.set(templateId, { at: Date.now(), info: null, error })
return { info: null, error }
}
}
export type ResolvedTemplate = {
/** 真正用来发消息的模板配置(字段名可能已被微信侧校准过) */
conf: TemplateConf
/** 校准过程与结论;null = 没走到联网校准(没模板 ID) */
resolution:
| (ResolveTemplateFieldsResult & {
remoteError: string | null
remoteTitle: string | null
remoteContent: string | null
remoteKeywords: TemplateKeyword[]
})
| null
}
/**
* 把配置里的模板「校准」成微信侧真实的样子。
*
* 优先级:微信模板列表里反解出的真实关键词名 > 配置里的字段名。
* 拿不到模板列表时**原样返回配置**(绝不因为校准失败而让通知停摆)。
*/
export async function resolveTemplate(
conf: TemplateConf,
opts: { forceRemote?: boolean } = {}
): Promise<ResolvedTemplate> {
if (!conf || !conf.id) {
return {
conf,
resolution: null
}
}
const { info, error } = await getRemoteTemplate(conf.id, { force: opts.forceRemote })
if (!info) {
return {
conf,
resolution: {
fields: conf.fields || {},
source: 'config',
reason: `拿不到微信模板列表(${error || '未知原因'}),沿用配置里的字段名`,
unresolvedVariables: [],
invalidConfiguredCodes: [],
unclaimedCodes: [],
remoteError: error,
remoteTitle: null,
remoteContent: null,
remoteKeywords: []
}
}
}
const resolved = resolveFieldsFromKeywords(info.keywords, conf.fields)
const effective: TemplateConf =
resolved.source === 'wechat-api' ? { ...conf, fields: resolved.fields } : conf
if (resolved.source === 'wechat-api') {
const before = JSON.stringify(conf.fields || {})
const after = JSON.stringify(resolved.fields)
if (before !== after && !remoteNoticeOnce.has(conf.id)) {
remoteNoticeOnce.add(conf.id)
console.warn(
`[WeChatSubscribe] 模板「${info.title || conf.title || conf.id}」的字段名已按微信真实模板校准:` +
`${before} → ${after}(配置里的编号是猜的,微信自动分配的编号以这里为准)`
)
}
}
return {
conf: effective,
resolution: {
...resolved,
remoteError: null,
remoteTitle: info.title || null,
remoteContent: info.content || null,
remoteKeywords: info.keywords
}
}
}
// ---------------- 用户侧查询 ----------------
/** 取用户的小程序 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'
| '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-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 }>
/** 本次真的调用微信了吗(empty-field / dryRun 等预检不通过时为 false,不消耗额度) */
attempted: boolean
/** 本次的字段名来自哪里:`config`=配置里写的,`wechat-api`=按微信真实模板校准出来的 */
fieldsResolved?: 'config' | 'wechat-api'
}
type SendOptions = {
/** @deprecated 已不再按本地记账拦截发送,这个开关现在没有作用,仅为兼容旧调用保留 */
force?: boolean
/** 不真的发送,只做预检和拼包(自测接口用) */
dryRun?: boolean
/** 日志前缀里的来源标记(例如 'test-subscribe' / 'diagnose') */
source?: string
/** 强制刷新微信侧模板列表缓存(改了模板后自测用) */
forceRemote?: boolean
}
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') 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> {
/** 本次到底是用配置里的字段名,还是用微信真实模板校准出来的(进日志/返回值,方便排查) */
let fieldsFromWechat = false
const base = (
partial: Partial<SendOutcome> & { stage: SendStage; reason: string; detail: string }
): SendOutcome => {
const outcome: SendOutcome = {
ok: false,
kind,
userId,
attempted: false,
fieldsResolved: fieldsFromWechat ? 'wechat-api' : 'config',
...partial
}
logOutcome(outcome, opts.source)
return outcome
}
try {
const configured = getSubscribeTemplates()[kind]
if (!configured || !configured.id) {
return base({
stage: 'no-template',
reason: 'no-template',
detail: `服务端没有配置「${kind}」的模板 ID:内置模板见 server/utils/wechatSubscribeTemplates.ts,也可用环境变量 ${TEMPLATES_ENV_KEY} 配置`
})
}
// 用微信真实模板校准字段名(后台给关键词自动编号,猜的编号会整条 47003)。
// 校准失败不影响发送:resolveTemplate 会原样退回配置。
const { conf, resolution } = await resolveTemplate(configured, {
forceRemote: opts.forceRemote
})
fieldsFromWechat = resolution?.source === 'wechat-api'
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)
)}`
})
}
// ⚠️ 这里**故意不按本地记账拦截发送**。
//
// 记账(WechatSubscribeGrant)是我们自己数的,微信既不提供「还剩几条」的接口、
// 也无法在服务端补授权。一旦本地计数和微信侧不同步(小程序换了新包、上报请求
// 没发出去、用户换了手机重新授权……),按记账拦截就会**无声地丢掉通知** ——
// 而 43101(没有可用授权)本来就是微信会明确回给我们的错误码。
//
// 所以策略是:**能发就发,发不出去让微信告诉我们**。记账只用于体检展示。
const grant = (await readGrantRows(userId, [conf.id])).get(conf.id)
const remaining = grant ? grant.grantedCount - grant.sentCount : 0
if (opts.dryRun) {
const outcome: SendOutcome = {
ok: false,
stage: 'sent',
kind,
userId,
templateId: conf.id,
reason: 'dry-run',
detail:
`dryRun:没有真的发送(预检通过、字段都有值;本地记账显示大约还剩 ${remaining} 条,` +
'但这个数字仅供参考,真正有没有额度要看微信回的 errcode)',
attempted: false,
fieldsResolved: fieldsFromWechat ? 'wechat-api' : 'config'
}
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,
fieldsResolved: fieldsFromWechat ? 'wechat-api' : 'config'
}
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'
/*
* 47003 是最难查的一个:微信只点名一个字段,但真正不合规的可能在别处。
* 我们能提供的确定性信息有两个 —— 这次用的是配置里的字段名还是微信真实模板
* 校准过的,以及校准结论。都写进 detail,省得下次还要猜。
*/
const hint = resolution
? resolution.source === 'wechat-api'
? `本次字段名是【按微信真实模板校准】的(${resolution.reason}),所以问题更可能在字段值本身:`
: `本次字段名是【沿用配置】的(${resolution.reason}),很可能是配置里的编号和微信模板不一致:`
: '本次字段名是【沿用配置】的:'
detail =
`微信回 47003:模板字段名或字段值不合规。${hint}` +
(resolution?.unclaimedCodes?.length
? `还有关键词没被任何字段填上(${resolution.unclaimedCodes.join('、')}),微信会报「value is empty」。`
: '') +
'用 /api/admin/wechat/diagnose 可以看到微信侧的真实关键词名。' +
(resolution?.remoteKeywords?.length
? ` 微信侧关键词:${resolution.remoteKeywords
.map((k) => `${k.code}${k.label ? `(${k.label})` : ''}`)
.join('、')}`
: '')
}
const outcome: SendOutcome = {
ok: false,
stage,
kind,
userId,
templateId: conf.id,
reason,
detail,
errcode,
errmsg: raw?.errmsg ?? null,
attempted: true,
fieldsResolved: fieldsFromWechat ? 'wechat-api' : 'config'
}
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>
/** 配置里(代码/环境变量)写的字段名,用来和微信侧对比 */
configuredFields: Record<string, string>
/** `config`=用的是配置里的字段名;`wechat-api`=已按微信真实模板校准 */
fieldSource: 'config' | 'wechat-api'
/** 这次字段名是怎么定的(人话) */
fieldReason: string
/** 配置里有、但微信模板里根本没有的关键词名(有值就一定会 47003) */
invalidConfiguredCodes: string[]
/** 微信模板里有、但没有任何字段去填的关键词(微信会回 `data.xxx.value is empty`) */
unclaimedCodes: string[]
/** 微信侧真实的关键词表(拿不到时为 null) */
remoteKeywords: Array<{ code: string; type: string; label: string }> | null
/** 微信侧的模板原文(能看到真实的关键词与顺序) */
remoteContent: string | null
/** 微信侧的模板标题(和本地配置的标题不一致时,说明配置指向了别的模板) */
remoteTitle: string | null
/** 拿微信模板列表失败的原因(null = 成功) */
remoteError: string | null
/** 这些字段映射到的变量,调用方有没有给(给了模板里也没用时是另一回事) */
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; forceRemote?: boolean } = {}
): 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[] = []
for (const kind of kinds) {
const configured = templates[kind]
const { conf, resolution } = await resolveTemplate(configured, {
forceRemote: opts.forceRemote
})
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 sampleData = sampleDataFor(conf)
const fieldsWithSample = Object.keys(conf.fields || {}).map((field) => ({
field,
variable: conf.fields[field],
sampleValue: String(sampleData[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 =
'能不能收到未知:本地记账里从没见过这张模板的授权(可能真没授权,也可能是小程序上报没发出去)。' +
'注意这个数字只是我们自己数的,微信不提供查询;唯一真值是真发一次看 errcode'
else if (remainingApprox <= 0)
verdict =
`本地记账显示授权已用完(授权 ${grantedCount} 次 / 已发 ${sentCount} 次)—— ` +
'但这只是我们自己数的,服务端仍会照发一条;真没额度时微信会回 43101'
else
verdict = `本地记账显示还有约 ${remainingApprox} 条额度(我们自己的计数,不是微信侧的余额)`
templateStatuses.push({
kind,
title: conf.title || kind,
templateId: conf.id,
fields: conf.fields || {},
configuredFields: configured.fields || {},
fieldSource: resolution?.source === 'wechat-api' ? 'wechat-api' : 'config',
fieldReason: resolution?.reason || '没有模板 ID,无法校准',
invalidConfiguredCodes: resolution?.invalidConfiguredCodes || [],
unclaimedCodes: resolution?.unclaimedCodes || [],
remoteKeywords: resolution?.remoteKeywords?.length
? resolution.remoteKeywords.map((k) => ({
code: k.code,
type: k.type,
label: k.label
}))
: null,
remoteContent: resolution?.remoteContent ?? null,
remoteTitle: resolution?.remoteTitle ?? null,
remoteError: resolution?.remoteError ?? null,
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 白名单')
}
// 只要前面几关都过了,**最省事的确认方式就是真发一条**,而不是看我们自己的记账
if (openid && notif.enabled && token) {
actions.push(
'想确认到底能不能收到:打开 /api/admin/wechat/diagnose?send=1 真发一条,' +
'errcode=0 就是能收到;43101 才是微信侧真没额度(此时让用户在小程序里点「开启」,一次「允许」= 1 条,两张模板各算各的)'
)
}
templateStatuses.forEach((t) => {
// 「少填一个关键词」是 47003 `data.xxx.value is empty` 的直接原因,比字段名错更常见,先说
if (t.unclaimedCodes.length) {
actions.push(
`模板「${t.title}」里有关键词没被任何字段填上:${t.unclaimedCodes.join('、')} —— ` +
`微信会回 47003「data.${t.unclaimedCodes[0]}.value is empty」。` +
`对照 remoteContent 把这几个关键词补进 fields 映射(少填一个就整条发不出去)`
)
}
// 字段名是 47003 的头号嫌疑,体检里把它放在前面说清楚
if (t.remoteError) {
actions.push(
`模板「${t.title}」拿不到微信侧的关键词列表(${t.remoteError}),本次只能沿用配置里的字段名` +
(t.invalidConfiguredCodes.length
? `;而配置里的 ${t.invalidConfiguredCodes.join('、')} 在微信模板里查不到,很可能就是 47003 的原因`
: '')
)
} else if (t.fieldSource === 'wechat-api' && t.invalidConfiguredCodes.length) {
actions.push(
`模板「${t.title}」配置里的字段名 ${t.invalidConfiguredCodes.join('、')} 在微信模板里不存在;` +
`服务端已自动改用微信真实的关键词(${Object.entries(t.fields)
.map(([code, v]) => `${code}=${v}`)
.join(',')})。建议把这些编号写回 BUILTIN_TEMPLATES 或环境变量,省掉每次的校准请求`
)
}
if (t.lastErrorCode === 47003) {
actions.push(
`模板「${t.title}」上次被微信以 47003 拒绝:` +
(t.fieldSource === 'wechat-api'
? '字段名已按微信真实模板校准,问题更可能在**字段值**(为空/超长/类型不符)'
: '字段名还是配置里写的,看上面 remoteKeywords 与配置的差异')
)
}
if (t.lastErrorCode === 43101) {
actions.push(
`模板「${t.title}」上次被微信以 43101 拒绝(没有可用授权):让用户在小程序里点「开启」重新授权`
)
}
})
if (!trackingTableOk) {
actions.push('授权记账表还不存在(迁移没跑),额度显示会一直是 0;发消息本身不受影响')
}
const userVerdict = !openid
? '这个账号没有绑定微信,任何微信通知都发不出去'
: !notif.enabled
? '这个账号关闭了通知总开关,微信通知发不出去'
: !token
? '服务端拿不到 access_token,任何微信通知都发不出去'
: '账号绑了微信、通知开关也开着,服务端会照常尝试发送;到底收不收得到,要真发一条看微信回的 errcode(?send=1)'
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
}
}