/** * 微信**订阅消息** —— 小程序唯一能主动触达用户的官方通道。 * * ## 平台侧的硬限制(不是实现问题,绕不过去) * * 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 | 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 { 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 ): Record { return buildDataFromConf(conf, Object.assign({ time: beijingNow() }, vars), clampField) } // ---------------- 授权记账表可用吗 ---------------- let trackingTableChecked = false /** * 记账表是否存在可用(迁移没跑时为 false,只查一次并缓存)。 * * ⚠️ 这张表**只用于展示**(体检接口里的「大约还剩几条」)和排查,**绝不用来决定发不发**。 * 早先的版本拿它做额度预检:记账显示没额度就跳过不发送 —— 结果是记账一旦和微信侧 * 不同步(比如小程序换了新包、授权上报没发出去),本地一厢情愿地记着 0 条, * 通知就会被无声地全部丢掉。现在一律**照发给微信裁决**,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 export async function getMiniAccessToken(force = false): Promise { 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() /** * 拿账号下已有的模板列表(**带真实关键词名**),按模板 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 { 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: [], 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 { 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> { 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 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, opts: SendOptions = {} ): Promise { /** 本次到底是用配置里的字段名,还是用微信真实模板校准出来的(进日志/返回值,方便排查) */ let fieldsFromWechat = false const base = ( partial: Partial & { 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}` + '用 /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 ): Promise { 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 ): Promise { 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 ): Promise { 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 /** 配置里(代码/环境变量)写的字段名,用来和微信侧对比 */ configuredFields: Record /** `config`=用的是配置里的字段名;`wechat-api`=已按微信真实模板校准 */ fieldSource: 'config' | 'wechat-api' /** 这次字段名是怎么定的(人话) */ fieldReason: string /** 配置里有、但微信模板里根本没有的关键词名(有值就一定会 47003) */ invalidConfiguredCodes: 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 = { 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; forceRemote?: boolean } = {} ): 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[] = [] 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 || [], 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 的头号嫌疑,体检里把它放在前面说清楚 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 } }