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
+2
View File
@@ -36,6 +36,7 @@ import Trends from './views/Trends.vue'
import Quotas from './views/Quotas.vue' import Quotas from './views/Quotas.vue'
import Feedbacks from './views/Feedbacks.vue' import Feedbacks from './views/Feedbacks.vue'
import Logs from './views/Logs.vue' import Logs from './views/Logs.vue'
import Notify from './views/Notify.vue'
import VpayOrders from './views/VpayOrders.vue' import VpayOrders from './views/VpayOrders.vue'
const token = ref(getToken()) const token = ref(getToken())
@@ -54,6 +55,7 @@ const menus = [
{ key: 'feedbacks', label: '反馈管理', icon: '📝', comp: Feedbacks }, { key: 'feedbacks', label: '反馈管理', icon: '📝', comp: Feedbacks },
{ key: 'vpay', label: '订单/余额', icon: '💰', comp: VpayOrders }, { key: 'vpay', label: '订单/余额', icon: '💰', comp: VpayOrders },
{ key: 'logs', label: '访问日志', icon: '🗂️', comp: Logs }, { key: 'logs', label: '访问日志', icon: '🗂️', comp: Logs },
{ key: 'notify', label: '通知推送', icon: '📣', comp: Notify },
] ]
async function login() { async function login() {
+103
View File
@@ -0,0 +1,103 @@
<template>
<div class="page">
<div class="cards">
<div class="card"><b>{{ stats.subscribed ?? '-' }}</b><span>可通知用户</span><i>已订阅授权 {{ stats.totalUsers ?? 0 }} 用户中</i></div>
<div class="card"><b>{{ stats.sentToday ?? '-' }}</b><span>今日已发送</span><i>累计 {{ stats.totalSent ?? 0 }} 条</i></div>
<div class="card"><b>{{ stats.templateConfigured ? '已配置' : '未配置' }}</b><span>消息模板</span><i>{{ stats.templateId ? stats.templateId.slice(0, 14) + '…' : '设置 WX_NOTIFY_TEMPLATE_ID' }}</i></div>
</div>
<div class="panel">
<h3>发送通知</h3>
<el-form label-width="90px" style="max-width: 560px">
<el-form-item label="场景">
<el-radio-group v-model="scene">
<el-radio value="release">版本发布</el-radio>
<el-radio value="manual">运营通知</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="标题"><el-input v-model="title" placeholder="通知标题(20字内)" maxlength="20" show-word-limit /></el-form-item>
<el-form-item label="内容"><el-input v-model="thing1" type="textarea" :rows="2" placeholder="主要内容(20字内,模板字段限制)" maxlength="20" show-word-limit /></el-form-item>
<el-form-item label="补充"><el-input v-model="thing2" placeholder="补充说明(20字内,选填)" maxlength="20" show-word-limit /></el-form-item>
<el-form-item>
<el-button type="primary" :loading="sending" :disabled="!stats.subscribed" @click="send">发送给 {{ stats.subscribed || 0 }} 位订阅用户</el-button>
</el-form-item>
</el-form>
<el-alert v-if="!stats.templateConfigured" type="warning" :closable="false"
title="未配置模板:微信公众平台→功能→订阅消息→选「新版上线提醒」类模板→复制模板ID→服务器 compose 加 WX_NOTIFY_TEMPLATE_ID 环境变量" />
<el-alert v-else type="info" :closable="false"
title="订阅消息为一次性授权:每次用户登录小程序会弹一次授权,授权一次可发一条。发完需等用户下次登录再授权。" />
</div>
<div class="panel">
<h3>发送记录</h3>
<el-table :data="logs" size="small" stripe v-loading="loadingLogs">
<el-table-column label="时间" width="160"><template #default="{ row }">{{ fmt(row.createdAt) }}</template></el-table-column>
<el-table-column label="用户" width="120"><template #default="{ row }">{{ row.userId?.nickName || row.openid?.slice(0, 8) || '—' }}</template></el-table-column>
<el-table-column prop="title" label="标题" min-width="140" show-overflow-tooltip />
<el-table-column label="场景" width="90">
<template #default="{ row }">{{ row.scene === 'release' ? '版本发布' : '运营通知' }}</template>
</el-table-column>
<el-table-column label="结果" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'sent' ? 'success' : 'danger'" size="small">{{ row.status === 'sent' ? '成功' : '失败' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="errMsg" label="失败原因" min-width="140" show-overflow-tooltip />
</el-table>
<el-pagination background layout="total, prev, pager, next" :total="logsTotal" :page-size="20" v-model:current-page="logsPage" @current-change="loadLogs" style="margin-top: 12px" />
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { apiGet, apiPost } from '../api'
const stats = ref({})
const scene = ref('release')
const title = ref('')
const thing1 = ref('')
const thing2 = ref('')
const sending = ref(false)
const logs = ref([])
const logsTotal = ref(0)
const logsPage = ref(1)
const loadingLogs = ref(false)
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—' }
async function loadStats() {
try { stats.value = await apiGet('/api/admin/notify/stats') || {} } catch (e) { stats.value = {} }
}
async function loadLogs() {
loadingLogs.value = true
try {
const r = await apiGet(`/api/admin/notify/logs?page=${logsPage.value}&limit=20`)
logs.value = r.items || []
logsTotal.value = r.total || 0
} finally { loadingLogs.value = false }
}
async function send() {
if (!title.value.trim() || !thing1.value.trim()) return ElMessage.warning('请填写标题和内容')
sending.value = true
try {
const r = await apiPost('/api/admin/notify/send', { title: title.value, thing1: thing1.value, thing2: thing2.value, scene: scene.value })
ElMessage.success(r.message || '发送完成')
loadStats(); loadLogs()
title.value = ''; thing1.value = ''; thing2.value = ''
} catch (e) { ElMessage.error(e.message || '发送失败') } finally { sending.value = false }
}
onMounted(() => { loadStats(); loadLogs() })
</script>
<style scoped>
.page { display: flex; flex-direction: column; gap: 14px; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; }
.card { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
.card b { font-size: 22px; color: #1f2333; }
.card span { font-size: 13px; color: #6b7080; display: block; }
.card i { font-size: 11px; color: #a6abbb; font-style: normal; }
.panel { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
.panel h3 { margin: 0 0 14px; font-size: 14px; }
</style>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "宇之然AI维度", "name": "宇之然AI维度",
"appid": "wxdad62baf4ccd09e3", "appid": "wxdad62baf4ccd09e3",
"versionName": "1.4.3", "versionName": "1.4.4",
"versionCode": "100", "versionCode": "100",
"description": "AI 知识探索社区 — 从5个维度系统理解人工智能", "description": "AI 知识探索社区 — 从5个维度系统理解人工智能",
"uni-app": { "uni-app": {
+3
View File
@@ -129,6 +129,7 @@
</template> </template>
<script setup> <script setup>
import { askSubscribe } from '../../utils/notify'
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app' import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref } from 'vue' import { ref } from 'vue'
@@ -217,6 +218,7 @@ const wechatLogin = async () => {
loading.value = true loading.value = true
await userStore.login(loginRes.code, {}) await userStore.login(loginRes.code, {})
uni.showToast({ title: '登录成功', icon: 'success' }) uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => askSubscribe(), 800) // 登录成功后请求订阅授权(接受新版通知等)
setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 1000) setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 1000)
} catch (err) { } catch (err) {
const msg = (err && (err.message || err.errMsg)) || '' const msg = (err && (err.message || err.errMsg)) || ''
@@ -245,6 +247,7 @@ const handleLogin = async () => {
: { username: identifier, password: loginForm.value.password } : { username: identifier, password: loginForm.value.password }
await userStore.h5Login(loginData) await userStore.h5Login(loginData)
uni.showToast({ title: '登录成功', icon: 'success' }) uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => askSubscribe(), 800) // 登录成功后请求订阅授权(接受新版通知等)
setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 1000) setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 1000)
} catch (err) { } catch (err) {
uni.showToast({ title: err.message || '登录失败', icon: 'none' }) uni.showToast({ title: err.message || '登录失败', icon: 'none' })
+35
View File
@@ -0,0 +1,35 @@
import { request } from './api'
import { getToken } from './api'
// 订阅消息模板ID(微信公众平台→订阅消息→我的模板)
const TMPL_IDS = [
(typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.VITE_NOTIFY_TMPL) || '',
].filter(Boolean)
/**
* 请求订阅授权(在用户完成关键动作后调用,此时微信允许弹授权框)
* 返回接受的模板数(失败静默0)
*/
export async function askSubscribe() {
// #ifdef MP-WEIXIN
if (!TMPL_IDS.length) return 0
try {
const res = await new Promise((resolve) => {
uni.requestSubscribeMessage({
tmplIds: TMPL_IDS,
success: resolve,
fail: () => resolve(null),
})
})
if (!res) return 0
const accepted = TMPL_IDS.filter(id => res[id] === 'accept').length
if (accepted > 0 && getToken()) {
request('PUT', '/api/auth/subscribe-report', { accepted }, getToken()).catch(() => {})
}
return accepted
} catch { return 0 }
// #endif
// #ifndef MP-WEIXIN
return 0
// #endif
}
+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 default: Date.now
}, },
// vpay 用户态签名必需(code2Session 返回,有过期时间,过期后重登录刷新) // 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 }, vpayCoins: { type: Number, default: 0 },
lastLoginAt: { lastLoginAt: {
type: Date, type: Date,
+3 -1
View File
@@ -13,6 +13,7 @@ const Trend = require('./Trend')
const VerificationCode = require('./VerificationCode') const VerificationCode = require('./VerificationCode')
const CoinLedger = require('./CoinLedger') const CoinLedger = require('./CoinLedger')
const VisitLog = require('./VisitLog') const VisitLog = require('./VisitLog')
const NotifyLog = require('./NotifyLog')
module.exports = { module.exports = {
User, User,
@@ -29,5 +30,6 @@ module.exports = {
Trend, Trend,
VerificationCode, VerificationCode,
CoinLedger, 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 * 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 }