feat: 订阅消息通知体系——登录后请求订阅授权(额度记账)+服务端subscribe/send(access_token缓存/原子扣减/失败回补/发送记录)+管理后台通知推送页;版本v1.4.4
This commit is contained in:
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
|
||||
// 订单列表(含虚拟支付 + 旧订单)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user