fix(微信订阅): 不再按本地记账拦截发送,一律交给微信裁决
This commit is contained in:
@@ -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`.
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 条'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 白名单')
|
||||
}
|
||||
templateStatuses.forEach((t) => {
|
||||
if (t.remainingApprox <= 0) {
|
||||
// 只要前面几关都过了,**最省事的确认方式就是真发一条**,而不是看我们自己的记账
|
||||
if (openid && notif.enabled && token) {
|
||||
actions.push(
|
||||
`模板「${t.title}」(${t.kind}) 没有可用授权:在小程序里点「开启」重新授权(一次点「允许」= 1 条,两个模板各算各的)`
|
||||
'想确认到底能不能收到:打开 /api/admin/wechat/diagnose?send=1 真发一条,' +
|
||||
'errcode=0 就是能收到;43101 才是微信侧真没额度(此时让用户在小程序里点「开启」,一次「允许」= 1 条,两张模板各算各的)'
|
||||
)
|
||||
}
|
||||
templateStatuses.forEach((t) => {
|
||||
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(),
|
||||
|
||||
Reference in New Issue
Block a user