Files
BYW-VoiceHub/server/api/user/wechat-subscribe-grant.post.ts
Henry_Du d1b643bd93 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 通过。
2026-10-11 16:02:33 +08:00

46 lines
1.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { grantWechatSubscribe } from '~~/server/services/wechatSubscribeService'
/**
* 小程序在 `wx.requestSubscribeMessage` 成功之后来报账。
*
* 为什么要上报:微信**没有**「这个用户还剩几条授权」的查询接口,而一次性订阅是
* 发一条扣一条。服务端自己记账,才能在小程序里说清「还能收到几条」,也才能在
* 发送前就告诉用户「没额度了,去点一下开启」。
*
* 约定:
* - `templateIds` **只传微信真的返回 `accept` 的模板**(`reject` / 没弹出来的别传);
* - 上报失败不影响用户已经拿到的授权,只是我们的计数会不准;
* - 服务端会把不在配置里的模板 ID 过滤掉,并写一行日志说明。
*/
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: '未授权访问' })
}
const body = await readBody(event).catch(() => null)
const raw = Array.isArray(body?.templateIds) ? body.templateIds : []
const templateIds = raw.filter(
(id: unknown): id is string => typeof id === 'string' && id.length > 0
)
if (!templateIds.length) {
throw createError({
statusCode: 400,
message: 'templateIds 不能为空(只传微信返回 accept 的模板 ID)'
})
}
const result = await grantWechatSubscribe(Number(user.id), templateIds, 'miniprogram')
return {
ok: result.ok,
// 只回模板键,不回 ID 清单,日志里也不打印完整 ID
grantedKinds: result.granted.map((g) => g.kind || 'unknown'),
granted: result.granted.length,
error: result.error || null,
hint: result.ok
? '已记账:每点一次「允许」记 1 条额度,服务端发一条扣一条'
: '记账失败,但不影响你已经拿到的微信授权'
}
})