From c51b7c660abf212a3693800d6783465cf43c1f26 Mon Sep 17 00:00:00 2001 From: HenryDu8133 <813367384@qq.com> Date: Sun, 11 Oct 2026 16:59:25 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=BE=AE=E4=BF=A1=E8=AE=A2=E9=98=85):=20?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=8C=89=E6=9C=AC=E5=9C=B0=E8=AE=B0=E8=B4=A6?= =?UTF-8?q?=E6=8B=A6=E6=88=AA=E5=8F=91=E9=80=81=EF=BC=8C=E4=B8=80=E5=BE=8B?= =?UTF-8?q?=E4=BA=A4=E7=BB=99=E5=BE=AE=E4=BF=A1=E8=A3=81=E5=86=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .tmp-tsc.txt | 15 ++++ app/drizzle/schema.ts | 9 ++- .../api/user/wechat-subscribe-status.get.ts | 4 +- server/services/wechatSubscribeService.ts | 80 ++++++++++--------- 4 files changed, 67 insertions(+), 41 deletions(-) create mode 100644 .tmp-tsc.txt diff --git a/.tmp-tsc.txt b/.tmp-tsc.txt new file mode 100644 index 0000000..120d5d4 --- /dev/null +++ b/.tmp-tsc.txt @@ -0,0 +1,15 @@ +server/services/wechatSubscribeService.ts(38,20): error TS2307: Cannot find module '~/drizzle/db' or its corresponding type declarations. +server/services/wechatSubscribeService.ts(39,77): error TS2307: Cannot find module '~/drizzle/schema' or its corresponding type declarations. +server/services/wechatSubscribeService.ts(40,39): error TS2307: Cannot find module 'drizzle-orm' or its corresponding type declarations. +server/services/wechatSubscribeService.ts(42,40): error TS2307: Cannot find module '~~/server/utils/wechatSubscribeFields' or its corresponding type declarations. +server/services/wechatSubscribeService.ts(51,8): error TS2307: Cannot find module '~~/server/utils/wechatSubscribeTemplates' or its corresponding type declarations. +server/services/wechatSubscribeService.ts(60,8): error TS2307: Cannot find module '~~/server/utils/wechatSubscribeFields' or its corresponding type declarations. +server/services/wechatSubscribeService.ts(69,8): error TS2307: Cannot find module '~~/server/utils/wechatSubscribeTemplates' or its corresponding type declarations. +server/services/wechatSubscribeService.ts(174,17): error TS2580: Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node`. +server/services/wechatSubscribeService.ts(175,18): error TS2580: Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node`. +server/services/wechatSubscribeService.ts(189,26): error TS2552: Cannot find name '$fetch'. Did you mean 'fetch'? +server/services/wechatSubscribeService.ts(694,24): error TS2552: Cannot find name '$fetch'. Did you mean 'fetch'? +server/services/wechatSubscribeService.ts(702,26): error TS2580: Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node`. +server/services/wechatSubscribeService.ts(964,19): error TS2580: Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node`. +server/services/wechatSubscribeService.ts(965,20): error TS2580: Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node`. +server/services/wechatSubscribeService.ts(966,16): error TS2580: Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node`. diff --git a/app/drizzle/schema.ts b/app/drizzle/schema.ts index 7d79568..6663b52 100644 --- a/app/drizzle/schema.ts +++ b/app/drizzle/schema.ts @@ -628,9 +628,12 @@ export const userIdentities = pgTable('UserIdentity', { * 用户在小程序里点「允许」时 grantedCount +1,服务端发送成功时 sentCount +1, * 被微信拒绝(如 43101)时 blockedCount +1 并记下 errcode。 * - * ⚠️ 这里算出的额度是**我们自己数的近似值**:用户可能在别处授权、或微信侧额度 - * 过期,真值只有发一条收到 43101 才知道。所以 `no-grant` 预检只用于「省一次请求」, - * 不能当成权威判断(自测接口用 force 绕过)。 + * ⚠️ 这里算出的额度是**我们自己数的近似值**:用户可能在别处授权、上报可能没发出去、 + * 微信侧额度也可能过期,真值只有发一条收到 43101 才知道。 + * + * **所以这张表只用于展示和排查,绝不参与「发不发」的决策** —— 早先的版本拿它做额度 + * 预检(记账为 0 就跳过不发送),结果记账一旦和微信侧不同步,通知就被无声地全部丢掉。 + * 现在服务端一律照常尝试发送,让微信回 errcode 来裁决。 */ export const wechatSubscribeGrants = pgTable('WechatSubscribeGrant', { id: serial('id').primaryKey(), diff --git a/server/api/user/wechat-subscribe-status.get.ts b/server/api/user/wechat-subscribe-status.get.ts index 5fb2f4d..baa7899 100644 --- a/server/api/user/wechat-subscribe-status.get.ts +++ b/server/api/user/wechat-subscribe-status.get.ts @@ -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 条' } }) diff --git a/server/services/wechatSubscribeService.ts b/server/services/wechatSubscribeService.ts index f572033..649a226 100644 --- a/server/services/wechatSubscribeService.ts +++ b/server/services/wechatSubscribeService.ts @@ -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 { if (trackingTableChecked) return true @@ -158,7 +158,7 @@ async function trackingTableAvailable(): Promise { 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(),