/** * 微信**订阅消息** —— 小程序唯一能主动触达用户的官方通道。 * * ## 平台侧的硬限制(不是实现问题,绕不过去) * * 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 | 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,只查一次并缓存)。 * * **这条很关键**:如果表不存在,`readGrantRows` 会返回空 Map,看起来就像 * 「所有用户都没有额度」,于是发送全被 `no-grant` 预检挡掉 —— 通知会整个哑掉。 * 所以拿不准的时候要**失败开放**(fail-open):照发,让微信自己回 43101。 */ async function trackingTableAvailable(): Promise { if (trackingTableChecked) return true try { await db.select({ id: wechatSubscribeGrants.id }).from(wechatSubscribeGrants).limit(1) trackingTableChecked = true return true } catch (err: any) { console.error( `[WeChatSubscribe] 授权记账表不可用(迁移没跑?),额度预检已关闭、通知照常发送:${err?.message}` ) return false } } // ---------------- access_token(2 小时有效,必须缓存) ---------------- let tokenCache: { token: string; expiresAt: number } | null = null 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 } } // ---------------- 用户侧查询 ---------------- /** 取用户的小程序 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' | '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, opts: SendOptions = {} ): Promise { const base = ( partial: Partial & { stage: SendStage; reason: string; detail: string } ): SendOutcome => { const outcome: SendOutcome = { ok: false, kind, userId, attempted: false, ...partial } logOutcome(outcome, opts.source) return outcome } try { const conf = getSubscribeTemplates()[kind] if (!conf || !conf.id) { return base({ stage: 'no-template', reason: 'no-template', detail: `服务端没有配置「${kind}」的模板 ID:内置模板见 server/utils/wechatSubscribeTemplates.ts,也可用环境变量 ${TEMPLATES_ENV_KEY} 配置` }) } const notif = await getNotificationEnabled(userId) if (!notif.enabled) { return base({ stage: 'disabled-by-user', reason: 'disabled-by-user', templateId: conf.id, detail: '该用户关闭了通知总开关(站内/微信/邮件一起关),要先在设置里打开' }) } const openid = await getWechatOpenid(userId) if (!openid) { return base({ stage: 'no-wechat-openid', reason: 'no-wechat-openid', templateId: conf.id, detail: '该账号没有绑定微信(UserIdentity 里没有 wechat-mp 记录),微信订阅消息发不出去,需要用户先在小程序里登录/绑定微信' }) } // 发之前先查空字段:微信对空值是整条拒绝,与其浪费一次请求和一条额度,不如提前说清楚 const emptyByVar = findEmptyFields(conf, vars) const data = buildTemplateData(conf, vars) const emptyInData = findEmptyDataFields(data) if (emptyByVar.length || emptyInData.length) { return base({ stage: 'empty-field', reason: 'empty-field', templateId: conf.id, emptyFields: emptyByVar, detail: `有字段的变量是空的,微信会整条拒绝(47003):${JSON.stringify( (emptyByVar.length ? emptyByVar : emptyInData).map((f: any) => f.field || f.variable) )}` }) } // 本地记账的额度预检:没有可用授权时直接跳过,省一次请求(force 可绕过) // 只有确认记账表可用时才敢据此跳过 —— 表不存在时一律失败开放,照发给微信裁决 const grant = (await readGrantRows(userId, [conf.id])).get(conf.id) const remaining = grant ? grant.grantedCount - grant.sentCount : 0 if (!opts.force && grant && remaining <= 0 && (await trackingTableAvailable())) { return base({ stage: 'no-grant', reason: 'no-quota', templateId: conf.id, detail: `本地记账显示这张模板没有可用授权了(授权 ${grant.grantedCount} 次、已成功发送 ${grant.sentCount} 次` + (grant.lastErrorCode ? `、上次微信 errcode=${grant.lastErrorCode}` : '') + '):一次性订阅发一条扣一条,需要用户在小程序里重新点「开启」' }) } if (opts.dryRun) { const outcome: SendOutcome = { ok: false, stage: 'sent', kind, userId, templateId: conf.id, reason: 'dry-run', detail: `dryRun:没有真的发送(预检通过,剩余授权约 ${remaining} 条)`, attempted: false } console.log( `[WeChatSubscribe]${opts.source ? `[${opts.source}]` : ''} ${kind} → user ${userId} dry-run 通过,预览 data=${JSON.stringify(data)}` ) return outcome } const token = await getMiniAccessToken() if (!token) { return base({ stage: 'no-access-token', reason: 'no-access-token', templateId: conf.id, detail: '拿不到小程序 access_token:检查 WECHAT_MINI_APPID / WECHAT_MINI_SECRET,以及小程序后台是否放行了服务器出口 IP' }) } const raw = await callSubscribeSend(token, openid, conf.id, data) const errcode = raw?.errcode if (errcode === 0 || errcode === undefined) { await recordSendOutcome(userId, conf.id, kind, { ok: true, errcode: 0 }) const outcome: SendOutcome = { ok: true, stage: 'sent', kind, userId, templateId: conf.id, reason: 'sent', detail: '微信已受理,消息会出现在用户的「服务通知」里', errcode: 0, errmsg: raw?.errmsg ?? null, attempted: true } logOutcome(outcome, opts.source) return outcome } await recordSendOutcome(userId, conf.id, kind, { ok: false, errcode, errmsg: raw?.errmsg ?? null }) let stage: SendStage = 'rejected-other' let reason = 'wechat-error' let detail = `微信拒绝了这条消息:errcode=${errcode} errmsg=${raw?.errmsg || ''}` if (errcode === 43101) { stage = 'rejected-no-quota' reason = 'no-quota' detail = '微信回 43101:该用户对这张模板没有可用授权。一次性订阅发一条扣一条,需要用户在小程序里重新点「开启」(模板 ID 换过的话,旧授权全部作废)' } else if (errcode === 47003) { stage = 'rejected-bad-field' reason = 'bad-field' detail = '微信回 47003:模板字段名或字段值不合规。去公众平台核对模板字段名是否和 fields 映射一致(也可能是某个字段超长/为空)' } const outcome: SendOutcome = { ok: false, stage, kind, userId, templateId: conf.id, reason, detail, errcode, errmsg: raw?.errmsg ?? null, attempted: true } logOutcome(outcome, opts.source) return outcome } catch (err: any) { const outcome: SendOutcome = { ok: false, stage: 'rejected-other', kind, userId, reason: 'exception', detail: `发送过程抛异常:${err?.message}`, errcode: -1, errmsg: err?.message, attempted: true } logOutcome(outcome, opts.source) return outcome } } /** * 兼容旧调用点(notificationService 的 4 处 + test-subscribe): * 保留 `sendWechatSubscribeToUser(userId, kind, vars)` 这个名字,内部走新实现。 */ export type SendResult = { ok: boolean errcode?: number errmsg?: string skipped?: string stage?: SendStage reason?: string detail?: string } export async function sendWechatSubscribeToUser( userId: number, kind: string, vars: Record ): Promise { 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 /** 这些字段映射到的变量,调用方有没有给(给了模板里也没用时是另一回事) */ fieldsWithSample: Array<{ field: string; variable: string; sampleValue: string }> /** 本地记账:用户点过几次授权、成功发过几条、被拒过几次 */ grantedCount: number sentCount: number blockedCount: number /** 我们自己数的剩余额度(近似值,微信没有查询接口) */ remainingApprox: number lastGrantedAt: string | null lastSentAt: string | null lastBlockedAt: string | null lastErrorCode: number | null lastErrorMessage: string | null /** 用人话总结「为什么收不到 / 现在能不能收到」 */ verdict: string } export type SubscribeInspection = { now: string enabled: boolean tokenOk: boolean tokenLen: number env: { templatesRaw: string | null envJsonValid: boolean overriddenKeys: string[] unknownKeys: string[] newAdditionsKeys: string[] inheritedFieldsKeys: string[] appidSet: boolean secretSet: boolean nodeEnv: string | null } user: { userId: number openidPrefix: string | null hasOpenid: boolean notificationEnabled: boolean notificationSettingsExist: boolean /** 一句话说清这个用户现在能不能收到微信通知 */ verdict: string } templates: TemplateStatus[] /** 一条条「该做什么」,给用户/管理员照着做 */ actions: string[] /** 记账表可用吗(迁移没跑时会 false,其它功能不受影响) */ trackingTableOk: boolean } const SAMPLE_VARS: Record = { songTitle: '测试歌曲《晴天》', playDate: '2026-02-14', playTime: '2026-02-14 12:30', sequence: '1', status: '已排期', reason: '测试驳回原因', message: '这是一条测试通知', time: beijingNow() } /** 用一组示例变量把模板拼出来看一眼(体检报告用) */ export function sampleDataFor(conf: TemplateConf): Record { return buildTemplateData(conf, SAMPLE_VARS) } /** * 只读体检:不发消息、不消耗额度。 * 把「能不能发、为什么发不出去、用户该做什么」需要的事实一次说清楚。 */ export async function inspectWechatSubscribe( userId: number, opts: { username?: string | null } = {} ): Promise { const report = getTemplatesReport() const templates = report.templates const kinds = Object.keys(templates) const token = await getMiniAccessToken() const openid = await getWechatOpenid(userId) const notif = await getNotificationEnabled(userId) let trackingTableOk = await trackingTableAvailable() const grantMap = await readGrantRows( userId, kinds.map((k) => templates[k].id) ) const templateStatuses: TemplateStatus[] = kinds.map((kind) => { const conf = templates[kind] const grant = grantMap.get(conf.id) const grantedCount = grant?.grantedCount ?? 0 const sentCount = grant?.sentCount ?? 0 const blockedCount = grant?.blockedCount ?? 0 const remainingApprox = Math.max(0, grantedCount - sentCount) const fieldsWithSample = Object.keys(conf.fields || {}).map((field) => ({ field, variable: conf.fields[field], sampleValue: String(sampleDataFor(conf)[field]?.value ?? '') })) let verdict: string if (!openid) verdict = '收不到:该账号没绑定微信' else if (!notif.enabled) verdict = '收不到:用户关了通知总开关' else if (!token) verdict = '收不到:服务端拿不到 access_token(站长检查 appid/secret)' else if (grantedCount === 0) verdict = '收不到:用户从没授权过这张模板,需要在小程序里点「开启」' else if (remainingApprox <= 0) verdict = `收不到:授权已用完(授权 ${grantedCount} 次 / 已发 ${sentCount} 次),需要再点一次「开启」` else verdict = `可以收到:本地记账还有约 ${remainingApprox} 条额度` return { kind, title: conf.title || kind, templateId: conf.id, fields: conf.fields || {}, fieldsWithSample, grantedCount, sentCount, blockedCount, remainingApprox, lastGrantedAt: grant?.lastGrantedAt ? new Date(grant.lastGrantedAt).toISOString() : null, lastSentAt: grant?.lastSentAt ? new Date(grant.lastSentAt).toISOString() : null, lastBlockedAt: grant?.lastBlockedAt ? new Date(grant.lastBlockedAt).toISOString() : null, lastErrorCode: grant?.lastErrorCode ?? null, lastErrorMessage: grant?.lastErrorMessage ?? null, verdict } }) const actions: string[] = [] if (!report.envJsonValid) { actions.push( `环境变量 ${TEMPLATES_ENV_KEY} 不是合法 JSON(当前值 ${String(report.envRaw).slice(0, 60)}…),服务端已忽略它、继续用内置模板;建议在 Gitea secret 里改成合法 JSON 或直接删掉` ) } if (report.unknownKeys.length) { actions.push( `环境变量里有无法使用的项:${JSON.stringify(report.unknownKeys)}(缺 id 或类型不对;这些项已被忽略,其余照常生效)` ) } if (report.newAdditionsKeys.length) { actions.push( `环境变量新增了模板 ${JSON.stringify(report.newAdditionsKeys)}(内置表里没有,但配置合法)` ) } if (!openid) { actions.push('让用户在小程序里登录并绑定微信(服务端需要 openid 才能发订阅消息)') } if (!notif.enabled) { actions.push('用户在「通知设置」里把通知总开关打开了才能收到') } if (!token) { actions.push('检查 WECHAT_MINI_APPID / WECHAT_MINI_SECRET,以及小程序后台的服务器 IP 白名单') } templateStatuses.forEach((t) => { if (t.remainingApprox <= 0) { actions.push( `模板「${t.title}」(${t.kind}) 没有可用授权:在小程序里点「开启」重新授权(一次点「允许」= 1 条,两个模板各算各的)` ) } if (t.lastErrorCode === 47003) { actions.push( `模板「${t.title}」上次被微信以 47003 拒绝:核对公众平台的字段名是否与 fields 映射一致` ) } }) if (!trackingTableOk) { actions.push('授权记账表还不存在(迁移没跑),额度显示会一直是 0;发消息本身不受影响') } const userVerdict = !openid ? '这个账号没有绑定微信,任何微信通知都发不出去' : !notif.enabled ? '这个账号关闭了通知总开关,微信通知发不出去' : templateStatuses.some((t) => t.remainingApprox > 0) ? '这个账号至少有一张模板还有可用授权,能收到通知' : '这个账号绑了微信、也开着通知,但两张模板都没有可用授权了 —— 去小程序点「开启」' return { now: new Date().toISOString(), enabled: isWechatSubscribeEnabled(), tokenOk: !!token, tokenLen: token ? token.length : 0, env: { templatesRaw: report.envRaw, envJsonValid: report.envJsonValid, overriddenKeys: report.overriddenKeys, unknownKeys: report.unknownKeys, newAdditionsKeys: report.newAdditionsKeys, inheritedFieldsKeys: report.inheritedFieldsKeys, appidSet: !!process.env.WECHAT_MINI_APPID, secretSet: !!process.env.WECHAT_MINI_SECRET, nodeEnv: process.env.NODE_ENV || null }, user: { userId, openidPrefix: openid ? openid.slice(0, 6) + '****' : null, hasOpenid: !!openid, notificationEnabled: notif.enabled, notificationSettingsExist: notif.hasRecord, verdict: userVerdict }, templates: templateStatuses, actions, trackingTableOk } }