1240 lines
46 KiB
TypeScript
1240 lines
46 KiB
TypeScript
/**
|
||
* 微信**订阅消息** —— 小程序唯一能主动触达用户的官方通道。
|
||
*
|
||
* ## 平台侧的硬限制(不是实现问题,绕不过去)
|
||
*
|
||
* 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: [],
|
||
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}` +
|
||
'用 /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[]
|
||
/** 微信侧真实的关键词表(拿不到时为 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 || [],
|
||
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
|
||
}
|
||
}
|