feat: 订阅消息通知体系——登录后请求订阅授权(额度记账)+服务端subscribe/send(access_token缓存/原子扣减/失败回补/发送记录)+管理后台通知推送页;版本v1.4.4

This commit is contained in:
Hermes Agent
2026-09-19 19:28:40 +08:00
parent 9ea09c95f2
commit 6d463b9296
11 changed files with 297 additions and 4 deletions
+20
View File
@@ -0,0 +1,20 @@
const mongoose = require('mongoose')
const { Schema } = mongoose
/**
* 订阅消息发送记录
*/
const NotifyLogSchema = new Schema({
openid: { type: String, index: true },
userId: { type: Schema.Types.ObjectId, ref: 'User', default: null },
templateId: { type: String, default: '' },
title: { type: String, default: '' }, // 消息标题(管理端检索用)
scene: { type: String, default: 'manual' }, // manual=手动群发 / release=版本发布
status: { type: String, enum: ['sent', 'failed', 'skipped'], default: 'sent' },
errCode: { type: Number, default: 0 },
errMsg: { type: String, default: '' },
adminName: { type: String, default: '' },
createdAt: { type: Date, default: Date.now, index: true }
})
module.exports = mongoose.model('NotifyLog', NotifyLogSchema)
+2 -1
View File
@@ -160,7 +160,8 @@ const UserSchema = new Schema({
default: Date.now
},
// vpay 用户态签名必需(code2Session 返回,有过期时间,过期后重登录刷新)
sessionKey: { type: String, default: '', select: false }, // [安全] 默认查询不返回(支付签名凭据)
sessionKey: { type: String, default: '', select: false },
notifyQuota: { type: Number, default: 0, min: 0 }, // 订阅消息可发送额度(每次授权+1,发送-1) // [安全] 默认查询不返回(支付签名凭据)
vpayCoins: { type: Number, default: 0 },
lastLoginAt: {
type: Date,
+3 -1
View File
@@ -13,6 +13,7 @@ const Trend = require('./Trend')
const VerificationCode = require('./VerificationCode')
const CoinLedger = require('./CoinLedger')
const VisitLog = require('./VisitLog')
const NotifyLog = require('./NotifyLog')
module.exports = {
User,
@@ -29,5 +30,6 @@ module.exports = {
Trend,
VerificationCode,
CoinLedger,
VisitLog
VisitLog,
NotifyLog
}
+96 -1
View File
@@ -1352,7 +1352,102 @@ router.get('/dashboard-v2', requirePermission('dashboard:read'), async (req, res
/**
* ===== 虚拟支付订单管理 =====
* ===== 订阅消息通知 =====
*/
// 通知统计:可通知人数、模板配置状态
router.get('/notify/stats', requirePermission('dashboard:read'), async (req, res) => {
try {
const [subscribed, totalUsers, sentToday, logs] = await Promise.all([
User.countDocuments({ notifyQuota: { $gt: 0 } }),
User.countDocuments({}),
NotifyLog.countDocuments({ createdAt: { $gte: new Date(new Date().setHours(0, 0, 0, 0)) }, status: 'sent' }),
NotifyLog.countDocuments({}),
])
return ApiResponse.success(res, {
subscribed, totalUsers, sentToday, totalSent: logs,
templateId: process.env.WX_NOTIFY_TEMPLATE_ID || '',
templateConfigured: !!(process.env.WX_NOTIFY_TEMPLATE_ID),
})
} catch (error) {
logger.error('通知统计失败:', error)
return ApiResponse.serverError(res, error.message)
}
})
// 发送订阅消息(scene=manual 手动 / release 版本发布)
router.post('/notify/send', requirePermission('dashboard:read'), async (req, res) => {
try {
const { title, thing1, thing2, scene = 'manual' } = req.body || {}
const templateId = process.env.WX_NOTIFY_TEMPLATE_ID
if (!templateId) return ApiResponse.error(res, '未配置订阅消息模板(环境变量 WX_NOTIFY_TEMPLATE_ID)', 400)
if (!title || !thing1) return ApiResponse.error(res, '请填写标题和内容', 400)
const users = await User.find({ notifyQuota: { $gt: 0 }, openid: { $exists: true, $ne: '' } })
.select('openid nickName notifyQuota').lean()
if (!users.length) return ApiResponse.error(res, '没有可通知的用户(需用户在小程序内订阅授权)', 400)
const token = await getAccessToken()
let sent = 0, failed = 0, skipped = 0
for (const u of users) {
// 原子扣减额度(防并发双发)
const r = await User.updateOne({ _id: u._id, notifyQuota: { $gt: 0 } }, { $inc: { notifyQuota: -1 } })
if (r.modifiedCount === 0) { skipped++; continue }
try {
const resp = await fetch(`https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
touser: u.openid,
template_id: templateId,
page: 'pages/index/index',
data: {
thing1: { value: String(thing1).slice(0, 20) },
thing2: { value: String(thing2 || '').slice(0, 20) },
},
miniprogram_state: process.env.WX_MINIPROGRAM_STATE || 'formal',
}),
signal: AbortSignal.timeout(15000),
}).then(x => x.json())
if (resp.errcode === 0) {
sent++
await NotifyLog.create({ openid: u.openid, userId: u._id, templateId, title, scene, status: 'sent', adminName: req.admin.username })
} else {
failed++
await User.updateOne({ _id: u._id }, { $inc: { notifyQuota: 1 } })
await NotifyLog.create({ openid: u.openid, userId: u._id, templateId, title, scene, status: 'failed', errCode: resp.errcode, errMsg: String(resp.errmsg || '').slice(0, 100), adminName: req.admin.username })
}
} catch (e) {
failed++
await User.updateOne({ _id: u._id }, { $inc: { notifyQuota: 1 } })
await NotifyLog.create({ openid: u.openid, userId: u._id, templateId, title, scene, status: 'failed', errCode: -1, errMsg: String(e.message || '').slice(0, 100), adminName: req.admin.username })
}
}
return ApiResponse.success(res, { sent, failed, skipped, total: users.length }, `发送完成:成功 ${sent},失败 ${failed},跳过 ${skipped}`)
} catch (error) {
logger.error('通知发送失败:', error)
return ApiResponse.serverError(res, error.message)
}
})
// 发送记录
router.get('/notify/logs', requirePermission('dashboard:read'), async (req, res) => {
try {
const page = Math.max(1, parseInt(req.query.page) || 1)
const limit = Math.min(50, parseInt(req.query.limit) || 20)
const [total, items] = await Promise.all([
NotifyLog.countDocuments({}),
NotifyLog.find({}).sort({ createdAt: -1 }).skip((page - 1) * limit).limit(limit)
.populate('userId', 'nickName').lean(),
])
return ApiResponse.success(res, { items, total, page, limit, pagination: { total } })
} catch (error) {
return ApiResponse.serverError(res, error.message)
}
})
/**
*/
// 订单列表(含虚拟支付 + 旧订单)
+17
View File
@@ -554,6 +554,23 @@ router.get('/me', async (req, res) => {
}
})
/**
* 订阅消息授权上报(前端 requestSubscribeMessage accept 后调用)
* POST /api/auth/subscribe-report { accepted: 1 }
*/
router.put('/subscribe-report', userAuthMiddleware, async (req, res) => {
try {
const accepted = Math.max(0, Math.min(3, parseInt(req.body?.accepted) || 0))
if (accepted > 0) {
await User.updateOne({ _id: req.user._id }, { $inc: { notifyQuota: accepted } })
}
return ApiResponse.success(res, { ok: true, accepted }, accepted ? '感谢订阅' : '未订阅')
} catch (error) {
logger.error('订阅上报失败:', error)
return ApiResponse.serverError(res, error.message)
}
})
/**
* 修改昵称
* PUT /api/auth/nickname
+15
View File
@@ -0,0 +1,15 @@
/**
* 微信 access_token 缓存(进程内 + 提前5分钟刷新)
*/
let cached = { token: '', expireAt: 0 }
async function getAccessToken() {
if (cached.token && Date.now() < cached.expireAt) return cached.token
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${process.env.WX_APPID}&secret=${process.env.WX_SECRET}`
const r = await fetch(url, { signal: AbortSignal.timeout(10000) }).then(x => x.json())
if (!r.access_token) throw new Error('获取access_token失败: ' + JSON.stringify(r).slice(0, 120))
cached = { token: r.access_token, expireAt: Date.now() + (r.expires_in - 300) * 1000 }
return cached.token
}
module.exports = { getAccessToken }