refactor(微信订阅): 重写订阅消息链路,每一步失败都可见
把「微信订阅消息」这套代码按验收标准推翻重写:用户能自己看懂为什么收不到,
任何一步失败都必须看得见。对外函数名不变,notificationService / test-subscribe
等调用点无需改动。
新增 server/utils/wechatSubscribeTemplates.ts(纯函数,零依赖):
- 模板登记表唯一来源(内置 SONG_SELECTED / SONG_REJECTED),服务层不再自己写死
- parseSubscribeTemplates 如实报告环境变量覆盖情况:坏 JSON 只报告不抛、
只给 id 时沿用内置字段映射、缺 id 的项记进 unknownKeys 并忽略
- findEmptyFields / findEmptyDataFields 发出前自查空字段(微信空值整条 47003)
- explainWechatErrcode 把 43101/47003/40003/40037/40001/45009 翻成人话
重写 server/services/wechatSubscribeService.ts:
- sendWechatSubscribe 返回 SendOutcome,用 stage 明确「卡在哪一步」:
no-template / disabled-by-user / no-wechat-openid / no-access-token /
empty-field / no-grant / rejected-no-quota / rejected-bad-field / rejected-other
- 每一次返回都写一行日志,不再有静默跳过(这是「发不出去全靠猜」的根因)
- inspectWechatSubscribe 只读体检:不发消息、不消耗额度,返回逐项 verdict + actions
- grantWechatSubscribe 依靠新增的 WechatSubscribeGrant 表记账:微信没有查询
剩余授权的接口,只能自己数(用户点允许 +1、发送成功 -1)
- 记账表不可用时失败开放(fail-open):额度预检关闭、照常发送,避免通知整体哑掉
- access_token 显式 responseType: json 并做字符串兜底解析
新增接口:
- GET /api/user/wechat-subscribe-status 小程序显示「还能收到几条 / 为什么收不到」
- POST /api/user/wechat-subscribe-grant 小程序授权成功后上报 accept 的模板
- GET /api/admin/wechat/diagnose 重写:人话结论 + actions,?send=1 真发一次
其他:新增 WechatSubscribeGrant 表(schema + 手写迁移)、.env.example 说明 JSON 覆盖
写法、.gitattributes 补 *.sql eol=lf。
测试:新增 test/wechatSubscribeTemplates.test.mjs(13 个用例,可直接 import .ts,
不依赖 Nuxt 别名),npm test 里订阅相关 25/25 通过。
This commit is contained in:
@@ -620,6 +620,38 @@ export const userIdentities = pgTable('UserIdentity', {
|
||||
unq: unique().on(t.provider, t.providerUserId),
|
||||
}));
|
||||
|
||||
/**
|
||||
* 微信订阅消息的授权记账表。
|
||||
*
|
||||
* 为什么需要它:微信**不提供**「这个用户还剩几条订阅授权」的查询接口,
|
||||
* 而「一次性订阅」是发一条扣一条。所以这里按 (userId, templateId) 记账:
|
||||
* 用户在小程序里点「允许」时 grantedCount +1,服务端发送成功时 sentCount +1,
|
||||
* 被微信拒绝(如 43101)时 blockedCount +1 并记下 errcode。
|
||||
*
|
||||
* ⚠️ 这里算出的额度是**我们自己数的近似值**:用户可能在别处授权、或微信侧额度
|
||||
* 过期,真值只有发一条收到 43101 才知道。所以 `no-grant` 预检只用于「省一次请求」,
|
||||
* 不能当成权威判断(自测接口用 force 绕过)。
|
||||
*/
|
||||
export const wechatSubscribeGrants = pgTable('WechatSubscribeGrant', {
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('userId').notNull(),
|
||||
templateId: text('templateId').notNull(),
|
||||
/** 对应的模板键(如 SONG_SELECTED),模板被换掉后这里可能对不上,仅作展示用 */
|
||||
kind: text('kind'),
|
||||
grantedCount: integer('grantedCount').default(0).notNull(),
|
||||
sentCount: integer('sentCount').default(0).notNull(),
|
||||
blockedCount: integer('blockedCount').default(0).notNull(),
|
||||
lastGrantedAt: timestamp('lastGrantedAt'),
|
||||
lastSentAt: timestamp('lastSentAt'),
|
||||
lastBlockedAt: timestamp('lastBlockedAt'),
|
||||
lastErrorCode: integer('lastErrorCode'),
|
||||
lastErrorMessage: text('lastErrorMessage'),
|
||||
createdAt: timestamp('createdAt').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
|
||||
}, (t) => ({
|
||||
unq: unique().on(t.userId, t.templateId),
|
||||
}));
|
||||
|
||||
export const changelogEntries = pgTable('ChangelogEntry', {
|
||||
id: serial('id').primaryKey(),
|
||||
createdAt: timestamp('createdAt').defaultNow().notNull(),
|
||||
|
||||
Reference in New Issue
Block a user