fix(微信订阅): 不再按本地记账拦截发送,一律交给微信裁决

This commit is contained in:
2026-10-11 16:59:25 +08:00
parent 6f9def0ffd
commit c51b7c660a
4 changed files with 67 additions and 41 deletions
@@ -25,7 +25,7 @@ export default defineEventHandler(async (event) => {
hasOpenid: inspection.user.hasOpenid,
notificationEnabled: inspection.user.notificationEnabled,
verdict: inspection.user.verdict,
/** 所有模板加起来还剩几条(近似值) */
/** 所有模板加起来还剩几条(只是服务端自己记的账,不代表微信侧的真实余额) */
remainingTotal,
templates: inspection.templates.map((t) => ({
kind: t.kind,
@@ -37,6 +37,6 @@ export default defineEventHandler(async (event) => {
verdict: t.verdict
})),
actions: inspection.actions,
note: 'remaining 是服务端自己记的近似额度:微信不提供剩余授权查询,一次性订阅发一条扣一条,发完要用户再点一次「开启」'
note: 'remaining 只是服务端自己记的账(微信不提供剩余授权查询),**不影响能不能发**:服务端一律照常尝试发送,真没额度时微信会回 43101,此时用户在小程序里点一次「开启」就补 1 条'
}
})
+44 -36
View File
@@ -29,7 +29,6 @@
* | `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 |
@@ -146,9 +145,10 @@ let trackingTableChecked = false
/**
* 记账表是否存在可用(迁移没跑时为 false,只查一次并缓存)。
*
* **这条很关键**:如果表不存在,`readGrantRows` 会返回空 Map,看起来就像
* 「所有用户都没有额度」,于是发送全被 `no-grant` 预检挡掉 —— 通知会整个哑掉。
* 所以拿不准的时候要**失败开放**(fail-open):照发,让微信自己回 43101。
* ⚠️ 这张表**只用于展示**(体检接口里的「大约还剩几条」)和排查,**绝不用来决定发不发**。
* 早先的版本拿它做额度预检:记账显示没额度就跳过不发送 —— 结果是记账一旦和微信侧
* 不同步(比如小程序换了新包、授权上报没发出去),本地一厢情愿地记着 0 条,
* 通知就会被无声地全部丢掉。现在一律**照发给微信裁决**,43101 才是唯一真值。
*/
async function trackingTableAvailable(): Promise<boolean> {
if (trackingTableChecked) return true
@@ -158,7 +158,7 @@ async function trackingTableAvailable(): Promise<boolean> {
return true
} catch (err: any) {
console.error(
`[WeChatSubscribe] 授权记账表不可用(迁移没跑?),额度预检已关闭、通知照常发送:${err?.message}`
`[WeChatSubscribe] 授权记账表不可用(迁移没跑?),体检里的额度展示会缺失:${err?.message}`
)
return false
}
@@ -421,7 +421,6 @@ export type SendStage =
| 'no-wechat-openid'
| 'no-access-token'
| 'empty-field'
| 'no-grant'
| 'rejected-no-quota'
| 'rejected-bad-field'
| 'rejected-other'
@@ -434,7 +433,7 @@ export type SendOutcome = {
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 */
* empty-field / no-quota(43101) / bad-field(47003) / wechat-error */
reason: string
/** 人话解释(能直接展示给用户看) */
detail: string
@@ -443,12 +442,12 @@ export type SendOutcome = {
errmsg?: string | null
/** 空字段清单(stage=empty-field 时有值) */
emptyFields?: Array<{ field: string; variable: string }>
/** 本次真的调用微信了吗(no-grant/empty-field 等预检不通过时为 false,不消耗额度) */
/** 本次真的调用微信了吗(empty-field / dryRun 等预检不通过时为 false,不消耗额度) */
attempted: boolean
}
type SendOptions = {
/** 忽略本地记账的额度预检,强制发一次(自测接口用) */
/** @deprecated 已不再按本地记账拦截发送,这个开关现在没有作用,仅为兼容旧调用保留 */
force?: boolean
/** 不真的发送,只做预检和拼包(自测接口用) */
dryRun?: boolean
@@ -463,8 +462,8 @@ function logOutcome(o: SendOutcome, source?: string) {
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)
// 43101 是预期内状态(用户没额度了),用 warn;其它都是要人处理的错误,用 error
if (o.stage === 'rejected-no-quota') console.warn(line)
else console.error(line)
}
@@ -541,21 +540,16 @@ export async function sendWechatSubscribe(
})
}
// 本地记账的额度预检:没有可用授权时直接跳过,省一次请求(force 可绕过)
// 只有确认记账表可用时才敢据此跳过 —— 表不存在时一律失败开放,照发给微信裁决
// ⚠️ 这里**故意不按本地记账拦截发送**。
//
// 记账(WechatSubscribeGrant)是我们自己数的,微信既不提供「还剩几条」的接口、
// 也无法在服务端补授权。一旦本地计数和微信侧不同步(小程序换了新包、上报请求
// 没发出去、用户换了手机重新授权……),按记账拦截就会**无声地丢掉通知** ——
// 而 43101(没有可用授权)本来就是微信会明确回给我们的错误码。
//
// 所以策略是:**能发就发,发不出去让微信告诉我们**。记账只用于体检展示。
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 = {
@@ -565,7 +559,9 @@ export async function sendWechatSubscribe(
userId,
templateId: conf.id,
reason: 'dry-run',
detail: `dryRun:没有真的发送(预检通过,剩余授权约 ${remaining} 条)`,
detail:
`dryRun:没有真的发送(预检通过、字段都有值;本地记账显示大约还剩 ${remaining} 条,` +
'但这个数字仅供参考,真正有没有额度要看微信回的 errcode)',
attempted: false
}
console.log(
@@ -869,10 +865,15 @@ export async function inspectWechatSubscribe(
else if (!notif.enabled) verdict = '收不到:用户关了通知总开关'
else if (!token) verdict = '收不到:服务端拿不到 access_token(站长检查 appid/secret)'
else if (grantedCount === 0)
verdict = '收不到:用户从没授权过这张模板,需要在小程序里点「开启」'
verdict =
'能不能收到未知:本地记账里从没见过这张模板的授权(可能真没授权,也可能是小程序上报没发出去)。' +
'注意这个数字只是我们自己数的,微信不提供查询;唯一真值是真发一次看 errcode'
else if (remainingApprox <= 0)
verdict = `收不到:授权已用完(授权 ${grantedCount} 次 / 已发 ${sentCount} 次),需要再点一次「开启」`
else verdict = `可以收到:本地记账还有约 ${remainingApprox} 条额度`
verdict =
`本地记账显示授权已用完(授权 ${grantedCount} 次 / 已发 ${sentCount} 次)—— ` +
'但这只是我们自己数的,服务端仍会照发一条;真没额度时微信会回 43101'
else
verdict = `本地记账显示还有约 ${remainingApprox} 条额度(我们自己的计数,不是微信侧的余额)`
return {
kind,
@@ -918,17 +919,24 @@ export async function inspectWechatSubscribe(
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) => {
if (t.remainingApprox <= 0) {
actions.push(
`模板「${t.title}」(${t.kind}) 没有可用授权:在小程序里点「开启」重新授权(一次点「允许」= 1 条,两个模板各算各的)`
)
}
if (t.lastErrorCode === 47003) {
actions.push(
`模板「${t.title}」上次被微信以 47003 拒绝:核对公众平台的字段名是否与 fields 映射一致`
)
}
if (t.lastErrorCode === 43101) {
actions.push(
`模板「${t.title}」上次被微信以 43101 拒绝(没有可用授权):让用户在小程序里点「开启」重新授权`
)
}
})
if (!trackingTableOk) {
actions.push('授权记账表还不存在(迁移没跑),额度显示会一直是 0;发消息本身不受影响')
@@ -938,9 +946,9 @@ export async function inspectWechatSubscribe(
? '这个账号没有绑定微信,任何微信通知都发不出去'
: !notif.enabled
? '这个账号关闭了通知总开关,微信通知发不出去'
: templateStatuses.some((t) => t.remainingApprox > 0)
? '这个账号至少有一张模板还有可用授权,能收到通知'
: '这个账号绑了微信、也开着通知,但两张模板都没有可用授权了 —— 去小程序点「开启」'
: !token
? '服务端拿不到 access_token,任何微信通知都发不出去'
: '账号绑了微信、通知开关也开着,服务端会照常尝试发送;到底收不收得到,要真发一条看微信回的 errcode(?send=1)'
return {
now: new Date().toISOString(),