把「微信订阅消息」这套代码按验收标准推翻重写:用户能自己看懂为什么收不到,
任何一步失败都必须看得见。对外函数名不变,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 通过。
144 lines
7.1 KiB
Bash
144 lines
7.1 KiB
Bash
# 数据库连接地址(必填)
|
||
# 格式:postgresql://用户名:密码@主机:端口/数据库名?sslmode=require
|
||
# 示例:postgresql://myuser:mypassword@localhost:5432/voicehub?sslmode=require
|
||
DATABASE_URL="postgresql://username:password@host:port/database?sslmode=require"
|
||
|
||
# JWT 认证密钥(必填)
|
||
# 请使用强随机字符串,建议至少 32 位
|
||
JWT_SECRET="your-very-secure-jwt-secret-key"
|
||
|
||
# 受信的 CDN/反向代理真实 IP 头(可选)
|
||
# 多个头用逗号分隔;未配置时只使用 TCP 连接地址,避免客户端伪造转发头。
|
||
# 示例:EdgeOne 使用 eo-connecting-ip,Cloudflare 使用 cf-connecting-ip
|
||
# TRUSTED_CLIENT_IP_HEADERS=eo-connecting-ip
|
||
|
||
# WebAuthn / Passkey 配置(可选)
|
||
# 依赖方 ID (RP ID),必须是你域名的一个有效域名
|
||
# 规则:必须是请求域名的精确匹配或父域名
|
||
#
|
||
# 示例:
|
||
# - 请求域名为 voicehub.example.com,RP ID 可设为 voicehub.example.com 或 example.com
|
||
# - 不配置时,默认使用当前请求的 hostname(适合开发/预览环境)
|
||
# - 生产环境建议配置为你的主域名,如:voicehub.example.com
|
||
WEBAUTHN_RP_ID=
|
||
|
||
# 允许的 Origin 列表(可选)
|
||
# WebAuthn 要求精确匹配 Origin,此配置用于支持多域名部署
|
||
# 多个域名用逗号分隔,必须包含 https:// 前缀
|
||
#
|
||
# 示例:
|
||
# - 单域名:https://voicehub.example.com
|
||
# - 多域名(Vercel 预览 + 生产):https://voicehub.example.com,https://voicehub-xxx.vercel.app
|
||
# - 二级域名:https://voicehub.example.com,https://sub.voicehub.example.com
|
||
#
|
||
# 不配置时:默认信任请求的 Origin(适合开发环境)
|
||
# 生产环境:强烈建议配置,以防止恶意站点伪造 Origin 攻击
|
||
WEBAUTHN_ORIGIN=
|
||
|
||
# 应用运行环境(可选)
|
||
# development: 开发环境
|
||
# production: 生产环境
|
||
NODE_ENV=development
|
||
|
||
# Sentry 配置(可选)
|
||
# 错误监控与性能追踪,配置后系统将自动上报运行时异常
|
||
#
|
||
# SENTRY_DSN 客户端(浏览器)使用的 DSN(公开可暴露)
|
||
# 示例:https://publicKey@o0.ingest.sentry.io/0
|
||
# SENTRY_DSN=
|
||
#
|
||
# SENTRY_SERVER_DSN 服务端(Node)使用的 DSN(保密,不应暴露给浏览器)
|
||
# 如未单独配置,服务端会自动降级使用 SENTRY_DSN
|
||
# SENTRY_SERVER_DSN=
|
||
#
|
||
# 采样率配置(0~1 的小数,默认 1.0 表示 100%)
|
||
# 错误采样率
|
||
# SENTRY_ERROR_SAMPLE_RATE=1.0
|
||
# 性能追踪(Traces)采样率
|
||
# SENTRY_TRACES_SAMPLE_RATE=1.0
|
||
# 浏览器会话回放(Replay)采样率,需同时设置下面两项才会生效
|
||
# 生产环境建议分别设为 0.1 和 1.0,即 10% 正常用户 + 100% 错误用户进行回放
|
||
# SENTRY_REPLAY_SESSION_SAMPLE_RATE=
|
||
# SENTRY_REPLAY_ON_ERROR_SAMPLE_RATE=
|
||
#
|
||
# 项目版本号,用于区分不同版本的错误;未填时自动使用 package.json 的 version
|
||
# SENTRY_RELEASE=
|
||
#
|
||
# 环境标签,默认继承 NODE_ENV
|
||
# SENTRY_ENVIRONMENT=
|
||
|
||
# Redis 缓存服务(可选,暂不推荐)
|
||
# 如果设置了 REDIS_URL,系统将自动启用 Redis 缓存功能
|
||
# 格式:redis://用户名:密码@主机:端口
|
||
# REDIS_URL="redis://default:password@host:port"
|
||
|
||
# Docker Compose 自带的 Redis 容器(compose 自带 redis 服务,仅在启用自带 redis 时用)
|
||
# - 留空(或空字符串):不设置 Redis 密码,内网部署常用(默认)
|
||
# - 填写强密码:Redis 会启用 requirepass,连接时 REDIS_URL 里也要带上相同密码
|
||
REDIS_PASSWORD=
|
||
|
||
# Redis 键命名空间,多个 VoiceHub 环境共用 Redis 时应分别设置
|
||
# REDIS_KEY_PREFIX="voicehub:v2:"
|
||
|
||
# OAuth 配置(可选)
|
||
# 启用相应的 OAuth 提供商需要填写对应的 Client ID 和 Client Secret
|
||
#
|
||
# 注意:OAuth 运行时配置已经迁移到管理员后台;以下变量仅用于兼容旧部署和后台一键导入。
|
||
# 导入完成后,系统以数据库中保存的配置为准。
|
||
|
||
# OAuth 回调地址(必填,如果启用 OAuth)
|
||
# 注意:[provider] 是占位符,实际使用时需要根据具体情况填写
|
||
OAUTH_REDIRECT_URI="http://localhost:3000/api/auth/[provider]/callback"
|
||
|
||
# 用于加密 OAuth state 的密钥(必填,如果启用 OAuth)
|
||
OAUTH_STATE_SECRET=your-secure-random-string-for-oauth-state
|
||
|
||
# GitHub OAuth
|
||
GITHUB_CLIENT_ID=
|
||
GITHUB_CLIENT_SECRET=
|
||
|
||
# Casdoor OAuth
|
||
CASDOOR_CLIENT_ID=
|
||
CASDOOR_CLIENT_SECRET=
|
||
CASDOOR_ENDPOINT=
|
||
|
||
# Google OAuth
|
||
GOOGLE_CLIENT_ID=
|
||
GOOGLE_CLIENT_SECRET=
|
||
|
||
# 聚合登陆 OAuth(如 auxx.cn 等聚合登录服务,支持微信/QQ 等通道
|
||
AGGREGATE_OAUTH_APP_ID=
|
||
AGGREGATE_OAUTH_APP_KEY=
|
||
# 支持单值、逗号分隔或 JSON 数组,例如:qq,wx,sina,baidu
|
||
# 可用值:qq,wx,alipay,sina,baidu,douyin,huawei,xiaomi,gitee,gitea,bilibili,kuaishou
|
||
AGGREGATE_OAUTH_LOGIN_TYPE=wx
|
||
# 聚合登录服务基地址(https,不带 /oauth/... 路径),例如:https://www.auxx.cn
|
||
AGGREGATE_OAUTH_ENDPOINT=
|
||
|
||
# 微信小程序登录(可选)
|
||
# 用于小程序端 wx.login() 的 code 换取 openid,对应接口 POST /api/auth/wechat/login
|
||
# AppID / AppSecret 在微信公众平台「开发管理 → 开发设置 → 开发者ID」中获取
|
||
# 注意:这与上方聚合登录的 wx 通道不是一回事——小程序 openid 按 AppID 维度生成,两者不通用
|
||
WECHAT_MINI_APPID=
|
||
WECHAT_MINI_SECRET=
|
||
|
||
# 微信「订阅消息」模板(可选:**留空也能用**,代码里内置了站长选好的模板)
|
||
# 内置模板(唯一来源:server/utils/wechatSubscribeTemplates.ts 的 BUILTIN_TEMPLATES):
|
||
# SONG_SELECTED「歌曲排期通知」—— 管理员把歌排进排期/发布排期时发
|
||
# thing2→songTitle, date4→playDate, time1→playTime, character_string3→sequence, phrase5→status
|
||
# SONG_REJECTED「审核驳回通知」—— 投稿被驳回时发
|
||
# thing6→songTitle, thing4→reason
|
||
# 下面这条环境变量**只用来覆盖内置映射**(比如站长在公众平台选了别的模板或字段名)。
|
||
# ⚠️ 必须是合法 JSON;写了坏值(例如只填一个光秃秃的模板 ID)服务端会**忽略它**并在启动日志里
|
||
# 打一行「不是合法 JSON,已忽略(仍用内置默认模板)」,属于可恢复状态,但请在体检接口里确认。
|
||
# {"SONG_SELECTED":{"id":"模板ID"}} ← 只换 ID,沿用内置字段映射
|
||
# {"SONG_REJECTED":{"id":"模板ID","fields":{"thing6":"songTitle"}}} ← 同时换字段映射
|
||
# {"SONG_PLAYED":{"id":"模板ID","fields":{"thing1":"songTitle"}}} ← 也可以补一张内置表里没有的模板
|
||
# fields 的键是模板里的字段名(thing2/date4/time1/character_string3/phrase5,公众平台选用模板时能看到),
|
||
# 值是变量名(可用:songTitle / message / time / playDate / playTime / sequence / status / reason)。
|
||
# ⚠️ 平台限制:一次性订阅=一次授权只能发一条;只有绑定了微信的账号才有 openid;
|
||
# 模板 ID 变了老用户要**重新点一次「开启微信通知」**才有额度(旧授权按模板 ID 记账,直接作废)。
|
||
# 发不出去时先看 GET /api/admin/wechat/diagnose(超管,只读不消耗额度):
|
||
# 它会把「拿不拿到 token / 有没有绑微信 / 通知开关开没开 / 还剩几条授权」逐项说清。
|
||
WECHAT_MINI_SUBSCRIBE_TEMPLATES=
|