Compare commits

..

40 Commits

Author SHA1 Message Date
Hermes Agent 8b9ff56360 fix: 管理后台登录态过期不再停留空数据页——任何接口401自动清token弹登录框 2026-09-22 21:44:52 +08:00
Hermes Agent df8dd3d3dc fix: admin.js解构补NotifyLog(通知接口500修复)+配置模板ID 2026-09-20 20:58:31 +08:00
Hermes Agent 726e5125a7 fix(critical): 登录页白屏——notify.js的import.meta在小程序CJS互操作下编译为__filename访问,模块顶层崩溃;改为编译期常量;版本v1.4.9 2026-09-19 22:06:09 +08:00
Hermes Agent de125dae67 fix: news.vue补userStore定义(v1.4.6引入的收藏报错);登录页isMiniApp简化(去掉v-if由条件编译保证);版本v1.4.8 2026-09-19 21:46:10 +08:00
Hermes Agent a87a631459 ux: 深色模式平台适配——小程序改为跟随系统说明项(原开关在小程序无效),H5保留手动开关;版本v1.4.7 2026-09-19 21:22:19 +08:00
Hermes Agent df52c039d8 fix+feat: 小程序登录按钮不渲染修复(isMiniApp编译期判定);静默登录ensureLogin——收藏/游客问答额度升级一键无感登录;版本v1.4.6 2026-09-19 21:16:00 +08:00
Hermes Agent 65c6802995 chore: 版本 v1.4.5 2026-09-19 20:34:48 +08:00
Hermes Agent feac6371ea ux: 首页今日推荐可点进详情;搜索页热门词;趋势页去假数据/始终进站内详情(阅读量统计生效);资讯详情去大emoji头图/加人话解读卡/分享按钮修复 2026-09-19 20:31:58 +08:00
Hermes Agent 7849bd067e fix(critical): auth.js补userAuthMiddleware导入——缺失导致服务启动崩溃循环、API全502 2026-09-19 20:04:27 +08:00
Hermes Agent f2fa96b81b fix: notify.js改用userStore取token(api.js无getToken导出) 2026-09-19 19:32:16 +08:00
Hermes Agent 6d463b9296 feat: 订阅消息通知体系——登录后请求订阅授权(额度记账)+服务端subscribe/send(access_token缓存/原子扣减/失败回补/发送记录)+管理后台通知推送页;版本v1.4.4 2026-09-19 19:28:40 +08:00
Hermes Agent 9ea09c95f2 feat: 小程序版本更新检测——新版本发布后旧版本冷启动静默下载+弹窗引导重启;版本v1.4.3 2026-09-19 19:06:45 +08:00
Hermes Agent acca4fe379 fix: 补齐兼容推理型模型——content空时取reasoning尾部答案,max_tokens提到2000 2026-09-19 18:58:39 +08:00
Hermes Agent 1d742dc6dd fix: fill-plain导入AIModel(500修复) 2026-09-19 18:56:18 +08:00
Hermes Agent 61069cd095 feat: 知识管理加更新时间列;趋势管理待补筛选+统计+AI批量补齐人话解读接口 2026-09-19 18:51:44 +08:00
Hermes Agent 7cd01d8104 ux: 访问日志默认显示当天 2026-09-19 18:02:09 +08:00
Hermes Agent ce9f69a76e fix: 埋点正则兼容/api前缀(originalUrl含前缀) 2026-09-19 17:02:16 +08:00
Hermes Agent 5f3809831f fix: 访问统计口径与体验——PV只计页面级接口(过滤轮询,今日221→真实口径);埋点记录userId并显示昵称;访问日志Tab合并用户列 2026-09-19 16:59:04 +08:00
Hermes Agent 49946c6d21 feat: 看板最近访问查看更多跳转+访问埋点今日/昨日/7天/30天/来源筛选(visits接口补range/source参数与统计) 2026-09-19 15:53:04 +08:00
Hermes Agent b7d085c475 docs: 代码评审报告(已修复+已接受残留) 2026-09-19 12:01:43 +08:00
Hermes Agent 1e972291d9 security: 遗留清零——aiModel密钥永不下发/搜索正则注入转义/排行榜limit封顶且去openid/删除重复shop-items路由/登录入参强转防对象注入/sessionKey默认不返回 2026-09-19 11:48:27 +08:00
Hermes Agent 5e525b3b7c chore: 版本 v1.4.2(页面标题/分享标题搜索优化) 2026-09-19 09:28:13 +08:00
Hermes Agent ab3d231e73 fix: share-gain防刷改用shareRecords统计(schema strict丢弃了临时字段) 2026-09-19 09:27:24 +08:00
Hermes Agent 15eb4727bb security+fix: 评审修复——下线webview-token任意账号接管;progress接口付费字段只读;检测题答案脱敏;读接口不记日志;share-gain每日限3次防刷;页面标题/分享标题搜索优化 2026-09-19 09:25:29 +08:00
Hermes Agent bcb239c51b seo: 新增/sitemap-baidu.xml纯净站点地图(去llms.txt,规避百度索引型判定) 2026-09-17 18:25:34 +08:00
Hermes Agent 1fc358d47c seo: robots爬虫声明补全国内外全量(AI引擎+传统搜索) 2026-09-17 17:22:30 +08:00
Hermes Agent 1f0b8e7740 feat: 访问埋点记录完整referer——支持推广渠道参数(src=xx)区分来源 2026-09-17 17:10:24 +08:00
Hermes Agent c549eb532d feat: 管理后台IP归属地——移植ip2region离线库,登录日志/访问埋点/看板最近访问统一显示归属地 2026-09-16 07:45:23 +08:00
Hermes Agent c5e12c67fa fix: 看板最近访问记录合并登录+访问埋点(显示昵称/来源/终端/路径),修复只显示登录日志看不到最新访问的问题 2026-09-16 06:49:39 +08:00
Hermes Agent 7fd32685ce fix: 访问日志接口补 pagination 字段(修复埋点Tab总条数显示0) 2026-09-15 21:32:47 +08:00
Hermes Agent f91d8f5418 feat: 访问埋点统计——VisitLog模型+/api GET埋点中间件(小程序/H5/直接访问来源识别)+访问统计接口+看板PV/UV/来源分布卡+日志页访问埋点Tab 2026-09-15 21:29:30 +08:00
Hermes Agent 62fb40e415 fix: H5 路由与资源 base 设为 /h5/——修复公网 /h5/ 入口白屏(资源404) 2026-09-15 20:00:09 +08:00
Hermes Agent 1e8a0aeead chore: 版本 v1.4.1(v1.4.0 已发布,尾号递增) 2026-09-15 15:30:51 +08:00
Hermes Agent b866eb1e70 fix: 备案号补全——京ICP备2026007249号-2+京公网安备11011502039622号(yuzhiran域),我的页/admin两处 2026-09-15 15:23:05 +08:00
Hermes Agent 5959edf37e feat: 页脚信息对齐官网——我的页(小程序/H5)与管理后台加公司版权+联系邮箱+备案链接+微信客服二维码(长按识别) 2026-09-15 13:57:46 +08:00
Hermes Agent 2bc58d0c6a feat: 默认昵称带序号(星辰旅行者1001 起步,跳过占用),与画屿画友方案一致,纯后端无需发版 2026-09-15 12:16:27 +08:00
Hermes Agent 5e098a3584 fix: 管理后台巡检修复——访问日志用户/设备列字段对齐接口(userName/os/browser),订单星尘列无metadata时按金额兜底显示 2026-09-14 22:15:08 +08:00
Hermes Agent c5da01063c feat: 昵称可修改——PUT /api/auth/nickname + 我的页昵称点击编辑 2026-09-14 19:55:28 +08:00
Hermes Agent 6adf9f1c6a feat: 取消退款次数限制(每人每月1次)——保留余额够扣+7天窗口;后台退款弹窗规则文案同步 2026-09-14 16:02:21 +08:00
Hermes Agent 9ab08254e3 security: 微信回调POST验签——明文模式signature校验防伪造支付回调白拿星尘 2026-09-14 15:50:49 +08:00
50 changed files with 1761 additions and 801 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+29 -2
View File
@@ -6,6 +6,7 @@
<el-input v-model="password" type="password" placeholder="密码" show-password @keyup.enter="login" /> <el-input v-model="password" type="password" placeholder="密码" show-password @keyup.enter="login" />
<el-alert v-if="error" :title="error" type="error" :closable="false" /> <el-alert v-if="error" :title="error" type="error" :closable="false" />
<el-button type="primary" :loading="busy" style="width:100%" @click="login">登 录</el-button> <el-button type="primary" :loading="busy" style="width:100%" @click="login">登 录</el-button>
<p class="hint">© 2026 北京宇之然科技中心 · <a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">京ICP备2026007249号-2</a> · <a href="https://beian.mps.gov.cn/#/query/webSearch?code=11011502039622" target="_blank" rel="noopener">京公网安备11011502039622号</a></p>
</div> </div>
</div> </div>
<div v-else class="layout"> <div v-else class="layout">
@@ -17,11 +18,16 @@
<div class="side-foot"><el-button size="small" @click="logout">退出</el-button></div> <div class="side-foot"><el-button size="small" @click="logout">退出</el-button></div>
</aside> </aside>
<main class="content"><component :is="menus.find(m => m.key === view).comp" /></main> <main class="content"><component :is="menus.find(m => m.key === view).comp" /></main>
<footer class="layout-foot">
© 2026 北京宇之然科技中心 · 联系邮箱 contact@yuzhiran.com ·
<a href="https://www.yuzhiran.com.cn" target="_blank" rel="noopener">宇之然官网</a> ·
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">京ICP备2026007249号-2</a> · <a href="https://beian.mps.gov.cn/#/query/webSearch?code=11011502039622" target="_blank" rel="noopener">京公网安备11011502039622号</a>
</footer>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { onMounted, ref, provide, onBeforeUnmount } from 'vue'
import { getToken, setToken, clearToken, apiPost } from './api' import { getToken, setToken, clearToken, apiPost } from './api'
import Dashboard from './views/Dashboard.vue' import Dashboard from './views/Dashboard.vue'
import Users from './views/Users.vue' import Users from './views/Users.vue'
@@ -30,10 +36,18 @@ 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())
const view = ref('dashboard') const view = ref('dashboard')
// 子页面跳转菜单(如看板→访问日志)
const goto = (key) => { view.value = key }
// 登录态过期:任何接口401 → 回登录框
const onAuthExpired = () => { token.value = '' }
onMounted(() => window.addEventListener('admin-auth-expired', onAuthExpired))
onBeforeUnmount(() => window.removeEventListener('admin-auth-expired', onAuthExpired))
provide('goto', goto)
const username = ref(''), password = ref(''), busy = ref(false), error = ref('') const username = ref(''), password = ref(''), busy = ref(false), error = ref('')
const menus = [ const menus = [
@@ -45,6 +59,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() {
@@ -106,4 +121,16 @@ body { margin: 0; font-family: -apple-system, 'PingFang SC', sans-serif; backgro
.el-table { width: 100%; } .el-table { width: 100%; }
.el-table__inner-wrapper { overflow-x: auto; } .el-table__inner-wrapper { overflow-x: auto; }
.page .el-table table { min-width: 640px; } .page .el-table table { min-width: 640px; }
</style>
.layout-foot {
grid-column: 1 / -1;
padding: 12px 24px;
font-size: 12px;
color: #b6bac9;
border-top: 1px solid #eef0f6;
background: #fff;
text-align: center;
}
.layout-foot a { color: #9aa0b4; text-decoration: none; }
.hint a { color: #9aa0b4; text-decoration: none; }
</style>
+13 -1
View File
@@ -5,7 +5,14 @@ export function clearToken() { localStorage.removeItem(TOKEN_KEY) }
export function apiGet(path) { export function apiGet(path) {
return fetch(path, { headers: { Authorization: `Bearer ${getToken()}` } }) return fetch(path, { headers: { Authorization: `Bearer ${getToken()}` } })
.then(r => r.json()) .then(r => {
if (r.status === 401) {
clearToken()
window.dispatchEvent(new CustomEvent('admin-auth-expired'))
throw new Error('登录已过期')
}
return r.json()
})
.then(j => j.data !== undefined ? j.data : j) .then(j => j.data !== undefined ? j.data : j)
.catch(e => { throw new Error(e.message) }) .catch(e => { throw new Error(e.message) })
} }
@@ -14,6 +21,11 @@ export function apiGet(path) {
function handleResponse(r) { function handleResponse(r) {
return r.json().then(j => { return r.json().then(j => {
if (!r.ok) { if (!r.ok) {
// 401=登录态过期:清token并通知全局弹登录框(否则面板停留空数据)
if (r.status === 401) {
clearToken()
window.dispatchEvent(new CustomEvent('admin-auth-expired'))
}
throw new Error(j.error || j.message || `请求失败(${r.status})`) throw new Error(j.error || j.message || `请求失败(${r.status})`)
} }
return j return j
+49 -8
View File
@@ -7,6 +7,21 @@
<div class="card"><b>{{ d.stats?.aiChatUsed ?? '-' }}</b><span>AI 问答次数</span><i>{{ d.stats?.aiChatUsers ?? 0 }} 人用过</i></div> <div class="card"><b>{{ d.stats?.aiChatUsed ?? '-' }}</b><span>AI 问答次数</span><i>{{ d.stats?.aiChatUsers ?? 0 }} 人用过</i></div>
<div class="card"><b>{{ d.stats?.totalTrends ?? '-' }}</b><span>趋势资讯</span><i>含人话解读 {{ d.stats?.trendsWithExplain ?? 0 }}</i></div> <div class="card"><b>{{ d.stats?.totalTrends ?? '-' }}</b><span>趋势资讯</span><i>含人话解读 {{ d.stats?.trendsWithExplain ?? 0 }}</i></div>
<div class="card"><b>{{ d.stats?.paidOrders ?? '-' }}</b><span>付费订单</span><i>共 {{ d.stats?.totalOrders ?? 0 }} 单</i></div> <div class="card"><b>{{ d.stats?.paidOrders ?? '-' }}</b><span>付费订单</span><i>共 {{ d.stats?.totalOrders ?? 0 }} 单</i></div>
<div class="card"><b>{{ visit.pvToday ?? '-' }}</b><span>今日访问 PV</span><i>小程序 + H5</i></div>
<div class="card"><b>{{ visit.uvToday ?? '-' }}</b><span>今日访客 UV</span><i>按 IP 去重</i></div>
<div class="card"><b>{{ visit.h5Today ?? '-' }}</b><span>H5 访问</span><i>小程序 {{ visit.miniToday ?? 0 }} 次</i></div>
</div>
<div class="row" v-if="(visit.sources7d || []).length">
<div class="panel">
<h3>访问来源分布(近 7 天)</h3>
<div v-for="s in visit.sources7d" :key="s.source" class="bar-row">
<span class="bar-name">{{ s.source }}</span>
<div class="bar-track"><div class="bar-fill" :style="{ width: srcPct(s.count) }"></div></div>
<span class="bar-num">{{ s.count }}</span>
</div>
</div>
<div class="panel grow"></div>
</div> </div>
<div class="row"> <div class="row">
@@ -20,20 +35,31 @@
<p v-if="!(d.litByDim || []).some(b => b.count)" class="empty">还没有点亮记录</p> <p v-if="!(d.litByDim || []).some(b => b.count)" class="empty">还没有点亮记录</p>
</div> </div>
<div class="panel grow"> <div class="panel grow">
<h3>最近访问记录</h3> <div class="rec-head">
<h3 style="margin: 0">最近访问记录</h3>
<el-button size="small" text type="primary" @click="goVisitLog">查看更多 ›</el-button>
</div>
<el-table :data="d.recentLogins || []" size="small" stripe> <el-table :data="d.recentLogins || []" size="small" stripe>
<el-table-column prop="createdAt" label="时间" width="160"> <el-table-column prop="createdAt" label="时间" width="160">
<template #default="{ row }">{{ fmt(row.createdAt) }}</template> <template #default="{ row }">{{ fmt(row.createdAt) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="openid" label="用户" width="150"> <el-table-column label="用户/来源" width="130">
<template #default="{ row }">{{ (row.openid || '-').slice(0, 12) }}</template> <template #default="{ row }">{{ row.userName || row.openid || '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="type" label="类型" width="70" /> <el-table-column prop="type" label="类型" width="70" />
<el-table-column prop="method" label="方式" width="110" /> <el-table-column label="方式/终端" width="120">
<template #default="{ row }">{{ row.method || '-' }}</template>
</el-table-column>
<el-table-column prop="ip" label="IP" width="120" /> <el-table-column prop="ip" label="IP" width="120" />
<el-table-column label="结果"> <el-table-column label="归属地" width="110">
<template #default="{ row }">{{ row.ipLoc || '—' }}</template>
</el-table-column>
<el-table-column label="结果 / 路径" min-width="150" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.success ? 'success' : 'danger'" size="small">{{ row.success ? '成功' : (row.failReason || '失败') }}</el-tag> <template v-if="row.kind === 'login'">
<el-tag :type="row.success ? 'success' : 'danger'" size="small">{{ row.success ? '成功' : (row.failReason || '失败') }}</el-tag>
</template>
<template v-else>{{ row.path || '-' }}</template>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -44,16 +70,24 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted, inject } from 'vue'
import { apiGet } from '../api' import { apiGet } from '../api'
const d = ref({}) const d = ref({})
const goto = inject('goto', () => {})
function goVisitLog() { goto('logs') }
const visit = ref({})
function pct(n) { function pct(n) {
const max = Math.max(1, ...(d.value.litByDim || []).map(b => b.count)) const max = Math.max(1, ...(d.value.litByDim || []).map(b => b.count))
return Math.round((n / max) * 100) + '%' return Math.round((n / max) * 100) + '%'
} }
function srcPct(n) {
const max = Math.max(1, ...(visit.value.sources7d || []).map(s => s.count))
return Math.round((n / max) * 100) + '%'
}
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '-' } function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '-' }
onMounted(async () => { onMounted(async () => {
d.value = (await apiGet('/api/admin/dashboard-v2')) || {} d.value = (await apiGet('/api/admin/dashboard-v2')) || {}
try { visit.value = (await apiGet('/api/admin/visit-stats')) || {} } catch (e) { visit.value = {} }
}) })
</script> </script>
@@ -78,4 +112,11 @@ onMounted(async () => {
.bar-fill { height: 100%; background: linear-gradient(90deg, #5b5ff8, #8b5cf6); border-radius: 4px; } .bar-fill { height: 100%; background: linear-gradient(90deg, #5b5ff8, #8b5cf6); border-radius: 4px; }
.bar-num { width: 28px; text-align: right; font-size: 12px; color: #6b7080; } .bar-num { width: 28px; text-align: right; font-size: 12px; color: #6b7080; }
.empty { color: #b6bac9; font-size: 12px; text-align: center; padding: 12px 0; } .empty { color: #b6bac9; font-size: 12px; text-align: center; padding: 12px 0; }
</style>
.rec-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
</style>
+4
View File
@@ -21,6 +21,9 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="viewCount" label="浏览" width="70" /> <el-table-column prop="viewCount" label="浏览" width="70" />
<el-table-column label="更新时间" width="150">
<template #default="{ row }">{{ fmt(row.updatedAt || row.createdAt) }}</template>
</el-table-column>
<el-table-column label="状态" width="80"> <el-table-column label="状态" width="80">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.status === 'approved' ? 'success' : 'warning'" size="small">{{ STATUS[row.status] || row.status }}</el-tag> <el-tag :type="row.status === 'approved' ? 'success' : 'warning'" size="small">{{ STATUS[row.status] || row.status }}</el-tag>
@@ -130,6 +133,7 @@ async function del(row) {
try { await apiDelete(`/api/admin/knowledge/${row._id}`); ElMessage.success('已删除'); load() } try { await apiDelete(`/api/admin/knowledge/${row._id}`); ElMessage.success('已删除'); load() }
catch (e) { ElMessage.error(e.message || '删除失败') } catch (e) { ElMessage.error(e.message || '删除失败') }
} }
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—' }
onMounted(load) onMounted(load)
</script> </script>
+55 -6
View File
@@ -1,13 +1,30 @@
<template> <template>
<div class="page"> <div class="page">
<el-tabs v-model="tab" @tab-change="load"> <el-tabs v-model="tab" @tab-change="load">
<el-tab-pane label="登录/访问日志" name="login" /> <el-tab-pane label="登录日志" name="login" />
<el-tab-pane label="访问日志" name="visit" />
<el-tab-pane label="管理操作日志" name="admin" /> <el-tab-pane label="管理操作日志" name="admin" />
</el-tabs> </el-tabs>
<div v-if="tab === 'visit'" class="visit-filters">
<el-radio-group v-model="vRange" size="small" @change="vPage = 1; load()">
<el-radio-button value="today">今日 {{ vStats.today ?? '' }}</el-radio-button>
<el-radio-button value="yesterday">昨日 {{ vStats.yesterday ?? '' }}</el-radio-button>
<el-radio-button value="7d">近7天 {{ vStats.last7 ?? '' }}</el-radio-button>
<el-radio-button value="30d">近30天 {{ vStats.last30 ?? '' }}</el-radio-button>
<el-radio-button value="all">全部</el-radio-button>
</el-radio-group>
<el-select v-model="vSource" size="small" clearable placeholder="全部来源" style="width: 120px" @change="vPage = 1; load()">
<el-option value="H5" label="H5" />
<el-option value="小程序" label="小程序" />
<el-option value="直接访问" label="直接访问" />
</el-select>
<span style="font-size: 12px; color: #8a8fa3">共 {{ vTotal }} 条</span>
</div>
<el-table :data="list" stripe v-loading="loading" size="small"> <el-table :data="list" stripe v-loading="loading" size="small">
<template v-if="tab === 'login'"> <template v-if="tab === 'login'">
<el-table-column label="时间" width="160"><template #default="{ row }">{{ fmt(row.createdAt) }}</template></el-table-column> <el-table-column label="时间" width="160"><template #default="{ row }">{{ fmt(row.createdAt) }}</template></el-table-column>
<el-table-column label="用户" width="140"><template #default="{ row }">{{ (row.openid || '-').slice(0, 12) }}</template></el-table-column> <el-table-column label="用户" width="120"><template #default="{ row }">{{ row.userName || '未知用户' }}</template></el-table-column>
<el-table-column label="归属地" width="110"><template #default="{ row }">{{ row.ipLoc || '—' }}</template></el-table-column>
<el-table-column prop="type" label="类型" width="80" /> <el-table-column prop="type" label="类型" width="80" />
<el-table-column prop="method" label="方式" width="120" /> <el-table-column prop="method" label="方式" width="120" />
<el-table-column prop="ip" label="IP" width="120" /> <el-table-column prop="ip" label="IP" width="120" />
@@ -16,7 +33,20 @@
<el-tag :type="row.success ? 'success' : 'danger'" size="small">{{ row.success ? '成功' : (row.failReason || '失败') }}</el-tag> <el-tag :type="row.success ? 'success' : 'danger'" size="small">{{ row.success ? '成功' : (row.failReason || '失败') }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="userAgent" label="UA" min-width="180" show-overflow-tooltip /> <el-table-column label="设备" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ [row.os, row.browser].filter(Boolean).join(' / ') || row.device || '—' }}</template>
</el-table-column>
</template>
<template v-else-if="tab === 'visit'">
<el-table-column label="时间" width="160"><template #default="{ row }">{{ fmt(row.createdAt) }}</template></el-table-column>
<el-table-column label="用户" width="110"><template #default="{ row }">{{ row.userName || '游客' }}</template></el-table-column>
<el-table-column prop="source" label="来源" width="100" />
<el-table-column label="终端" width="150">
<template #default="{ row }">{{ [row.os, row.browser].filter(Boolean).join(' / ') || '—' }}</template>
</el-table-column>
<el-table-column prop="device" label="设备" width="90" />
<el-table-column prop="path" label="路径" min-width="200" show-overflow-tooltip />
<el-table-column prop="ip" label="IP" width="130" />
</template> </template>
<template v-else> <template v-else>
<el-table-column label="时间" width="160"><template #default="{ row }">{{ fmt(row.timestamp || row.createdAt) }}</template></el-table-column> <el-table-column label="时间" width="160"><template #default="{ row }">{{ fmt(row.timestamp || row.createdAt) }}</template></el-table-column>
@@ -37,15 +67,26 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { apiGet } from '../api' import { apiGet } from '../api'
const tab = ref('login'), list = ref([]), total = ref(0), page = ref(1), pageSize = 20, loading = ref(false) const tab = ref('login'), list = ref([]), total = ref(0), page = ref(1), pageSize = 20, loading = ref(false)
// 访问埋点筛选
const vRange = ref('today'), vSource = ref(''), vStats = ref({}), vTotal = ref(0)
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—' } function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—' }
// adminlog 的时间字段是 timestamp,loginlog 是 createdAt // adminlog 的时间字段是 timestamp,loginlog 是 createdAt
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
const ep = tab.value === 'login' ? 'login-logs' : 'logs' const ep = tab.value === 'login' ? 'login-logs' : tab.value === 'visit' ? 'visits' : 'logs'
const r = await apiGet(`/api/admin/${ep}?page=${page.value}&limit=${pageSize}`) let qs = `page=${page.value}&limit=${pageSize}`
if (tab.value === 'visit') {
qs += `&range=${vRange.value}`
if (vSource.value) qs += `&source=${encodeURIComponent(vSource.value)}`
}
const r = await apiGet(`/api/admin/${ep}?${qs}`)
list.value = r.items || [] list.value = r.items || []
total.value = r.pagination?.total || 0 total.value = r.pagination?.total || 0
if (tab.value === 'visit') {
vStats.value = r.stats || {}
vTotal.value = r.total || 0
}
} finally { loading.value = false } } finally { loading.value = false }
} }
onMounted(load) onMounted(load)
@@ -53,4 +94,12 @@ onMounted(load)
<style scoped> <style scoped>
.page { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); } .page { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
</style>
.visit-filters {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 10px;
}
</style>
+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>
+18 -2
View File
@@ -3,6 +3,8 @@
<div class="toolbar"> <div class="toolbar">
<el-input v-model="q" placeholder="搜索标题" style="width: 220px" clearable @keyup.enter="search" /> <el-input v-model="q" placeholder="搜索标题" style="width: 220px" clearable @keyup.enter="search" />
<el-button type="primary" @click="search">搜索</el-button> <el-button type="primary" @click="search">搜索</el-button>
<el-button :type="missingOnly ? 'warning' : 'default'" @click="filterMissing">待补 {{ missingCount }}</el-button>
<el-button type="success" :loading="filling" :disabled="!missingCount" @click="fillMissing">AI 补齐人话解读</el-button>
<el-button type="success" @click="openEdit(null)">新增资讯</el-button> <el-button type="success" @click="openEdit(null)">新增资讯</el-button>
</div> </div>
<el-table :data="list" stripe v-loading="loading"> <el-table :data="list" stripe v-loading="loading">
@@ -18,7 +20,7 @@
<el-table-column label="人话解读" min-width="170" show-overflow-tooltip> <el-table-column label="人话解读" min-width="170" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row.plainExplain">{{ row.plainExplain }}<template v-if="row.takeAway"> | {{ row.takeAway }}</template></span> <span v-if="row.plainExplain">{{ row.plainExplain }}<template v-if="row.takeAway"> | {{ row.takeAway }}</template></span>
<el-tag v-else type="info" size="small">待补</el-tag> <el-tag v-else type="warning" size="small" style="cursor:pointer" @click="filterMissing">待补(点击筛选)</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="日期" width="110"> <el-table-column label="日期" width="110">
@@ -65,12 +67,26 @@ const list = ref([]), total = ref(0), page = ref(1), pageSize = 20, q = ref(''),
const dlg = ref(false) const dlg = ref(false)
const form = ref({}) const form = ref({})
function search() { page.value = 1; load() } function search() { page.value = 1; load() }
const missingOnly = ref(false)
const missingCount = ref(0)
const filling = ref(false)
function filterMissing() { missingOnly.value = !missingOnly.value; page.value = 1; load() }
async function fillMissing() {
if (!missingCount.value || filling.value) return
filling.value = true
try {
const r = await apiPost('/api/admin/trends/fill-plain', {})
ElMessage.success(`已补齐 ${r.filled || 0} 条人话解读`)
load()
} catch (e) { ElMessage.error(e.message || '补齐失败') } finally { filling.value = false }
}
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
const r = await apiGet(`/api/admin/trends?page=${page.value}&limit=${pageSize}&keyword=${encodeURIComponent(q.value)}`) const r = await apiGet(`/api/admin/trends?page=${page.value}&limit=${pageSize}&keyword=${encodeURIComponent(q.value)}${missingOnly.value ? '&missing=1' : ''}`)
list.value = r.items || [] list.value = r.items || []
total.value = r.pagination?.total || 0 total.value = r.pagination?.total || 0
missingCount.value = r.missingCount ?? 0
} finally { loading.value = false } } finally { loading.value = false }
} }
function openEdit(row) { function openEdit(row) {
+2 -2
View File
@@ -33,7 +33,7 @@
<template #default="{ row }">¥{{ row.amount }}</template> <template #default="{ row }">¥{{ row.amount }}</template>
</el-table-column> </el-table-column>
<el-table-column label="星尘" width="60"> <el-table-column label="星尘" width="60">
<template #default="{ row }">{{ row.metadata?.coins || '—' }}</template> <template #default="{ row }">{{ row.metadata?.coins ?? (row.amount ? Math.round(row.amount * 100) : '—') }}</template>
</el-table-column> </el-table-column>
<el-table-column label="状态" width="70"> <el-table-column label="状态" width="70">
<template #default="{ row }"> <template #default="{ row }">
@@ -92,7 +92,7 @@
<p><b>金额:</b>¥{{ refundTarget?.amount }}</p> <p><b>金额:</b>¥{{ refundTarget?.amount }}</p>
<p><b>将扣回星尘:</b>{{ refundCoins }}(余额不足将拒绝退款)</p> <p><b>将扣回星尘:</b>{{ refundCoins }}(余额不足将拒绝退款)</p>
<el-alert type="warning" :closable="false" style="margin-top:10px" <el-alert type="warning" :closable="false" style="margin-top:10px"
title="退款规则:余额必须足够扣回 | 支付后7天内 | 每人每月限1次" /> title="退款规则:余额必须足够扣回 | 支付后7天内" />
<el-alert type="info" :closable="false" style="margin-top:6px" <el-alert type="info" :closable="false" style="margin-top:6px"
title="流程:先冻结星尘 → 调微信退款 → 成功后完成 | 微信失败自动恢复星尘" /> title="流程:先冻结星尘 → 调微信退款 → 成功后完成 | 微信失败自动恢复星尘" />
<template #footer> <template #footer>
+2
View File
@@ -1,6 +1,8 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta name="sogou_site_verification" content="bZ8oAfjvQm" />
<meta name="360-site-verification" content="0cc596886cce77073d8fcba9d5ec7d9c" />
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
+24
View File
@@ -9,6 +9,30 @@ import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
onLaunch(() => { onLaunch(() => {
initTheme() initTheme()
// #ifdef MP-WEIXIN
// 版本更新检测:新版本发布后,旧版本用户冷启动时静默下载、弹窗引导重启
const um = uni.getUpdateManager && uni.getUpdateManager()
if (um) {
um.onUpdateReady(() => {
uni.showModal({
title: '更新提示',
content: '新版本已经准备好,是否重启应用?',
confirmText: '立即重启',
cancelText: '稍后',
success: (m) => {
if (m.confirm) um.applyUpdate()
},
})
})
um.onUpdateFailed(() => {
uni.showModal({
title: '更新提示',
content: '新版本下载失败,请删除当前小程序后重新搜索打开',
showCancel: false,
})
})
}
// #endif
}) })
onShow(() => {}) onShow(() => {})
+8 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "宇之然AI维度", "name": "宇之然AI维度",
"appid": "wxdad62baf4ccd09e3", "appid": "wxdad62baf4ccd09e3",
"versionName": "1.4.0", "versionName": "1.4.9",
"versionCode": "100", "versionCode": "100",
"description": "AI 知识探索社区 — 从5个维度系统理解人工智能", "description": "AI 知识探索社区 — 从5个维度系统理解人工智能",
"uni-app": { "uni-app": {
@@ -21,5 +21,11 @@
"darkmode": true, "darkmode": true,
"themeLocation": "theme.json" "themeLocation": "theme.json"
}, },
"app-plus": {} "app-plus": {},
"h5": {
"router": {
"mode": "hash",
"base": "/h5/"
}
}
} }
+9 -9
View File
@@ -3,7 +3,7 @@
{ {
"path": "pages/index/index", "path": "pages/index/index",
"style": { "style": {
"navigationBarTitleText": "AI 维度", "navigationBarTitleText": "宇之然AI维度-AI知识学习与人工智能资讯",
"navigationStyle": "custom", "navigationStyle": "custom",
"enablePullDownRefresh": true, "enablePullDownRefresh": true,
"backgroundTextStyle": "dark" "backgroundTextStyle": "dark"
@@ -19,7 +19,7 @@
{ {
"path": "pages/detail/detail", "path": "pages/detail/detail",
"style": { "style": {
"navigationBarTitleText": "AI 知识", "navigationBarTitleText": "AI知识详情-人工智能知识点",
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
@@ -33,21 +33,21 @@
{ {
"path": "pages/favorites/favorites", "path": "pages/favorites/favorites",
"style": { "style": {
"navigationBarTitleText": "我的收藏", "navigationBarTitleText": "我的AI知识收藏",
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{ {
"path": "pages/achievements/achievements", "path": "pages/achievements/achievements",
"style": { "style": {
"navigationBarTitleText": "成就", "navigationBarTitleText": "我的AI学习成就",
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{ {
"path": "pages/leaderboard/leaderboard", "path": "pages/leaderboard/leaderboard",
"style": { "style": {
"navigationBarTitleText": "排行榜", "navigationBarTitleText": "AI学习排行榜",
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
@@ -82,7 +82,7 @@
{ {
"path": "pages/news/news", "path": "pages/news/news",
"style": { "style": {
"navigationBarTitleText": "AI 资讯", "navigationBarTitleText": "AI资讯速递-人工智能热点解读",
"navigationStyle": "custom", "navigationStyle": "custom",
"enablePullDownRefresh": true, "enablePullDownRefresh": true,
"backgroundTextStyle": "dark" "backgroundTextStyle": "dark"
@@ -91,21 +91,21 @@
{ {
"path": "pages/dimension/dimension", "path": "pages/dimension/dimension",
"style": { "style": {
"navigationBarTitleText": "", "navigationBarTitleText": "AI知识星域-五大维度学人工智能",
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{ {
"path": "pages/chat/chat", "path": "pages/chat/chat",
"style": { "style": {
"navigationBarTitleText": "AI 问答", "navigationBarTitleText": "AI问答-人工智能知识解答",
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{ {
"path": "pages/trend/trend", "path": "pages/trend/trend",
"style": { "style": {
"navigationBarTitleText": "AI 趋势", "navigationBarTitleText": "AI趋势-每日人工智能行业动态",
"navigationStyle": "custom", "navigationStyle": "custom",
"enablePullDownRefresh": true, "enablePullDownRefresh": true,
"backgroundTextStyle": "dark" "backgroundTextStyle": "dark"
+1 -1
View File
@@ -118,7 +118,7 @@ function onLoad(fn) {
// 微信小程序分享 // 微信小程序分享
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
onShareAppMessage(() => ({ onShareAppMessage(() => ({
title: 'AI 维度 - 你的AI知识助手', title: 'AI知识社区 · 宇之然AI维度',
path: '/pages/index/index' path: '/pages/index/index'
})) }))
// #endif // #endif
+8 -1
View File
@@ -208,8 +208,15 @@ function goLogin() {
uni.navigateTo({ url: '/pages/login/login' }) uni.navigateTo({ url: '/pages/login/login' })
} }
function onQuotaTap() { async function onQuotaTap() {
if (isGuest.value) { if (isGuest.value) {
// 静默登录一键升级(微信 code 换 token 无感),失败再显式引导
const ok = await userStore.ensureLogin()
if (ok) {
uni.showToast({ title: '已升级为登录用户,每天 5 次', icon: 'success' })
loadQuota()
return
}
uni.showModal({ uni.showModal({
title: '游客体验', title: '游客体验',
content: '每天可免费提问 3 次;登录后每天 5 次,答对检测题还能点亮知识星球', content: '每天可免费提问 3 次;登录后每天 5 次,答对检测题还能点亮知识星球',
+15 -11
View File
@@ -229,18 +229,22 @@ function retryQuiz() {
async function toggleCollect() { async function toggleCollect() {
if (!userStore.isLoggedIn) { if (!userStore.isLoggedIn) {
uni.showModal({ // 静默登录(微信 code 换 token,用户无感);失败再显式引导
title: '登录后收藏', const ok = await userStore.ensureLogin()
content: '登录后即可收藏喜欢的 AI 知识', if (!ok) {
confirmText: '去登录', uni.showModal({
cancelText: '暂不', title: '登录后收藏',
success: (res) => { content: '登录后即可收藏喜欢的 AI 知识',
if (res.confirm) { confirmText: '去登录',
uni.navigateTo({ url: '/pages/login/login' }) cancelText: '暂不',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/login/login' })
}
} }
} })
}) return
return }
} }
try { try {
if (isCollected.value) { if (isCollected.value) {
+1 -1
View File
@@ -232,7 +232,7 @@ function goBack() {
// 微信小程序分享 // 微信小程序分享
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
onShareAppMessage(() => ({ onShareAppMessage(() => ({
title: 'AI 维度 - 你的AI知识助手', title: '帮AI维度变得更好 · 意见反馈',
path: '/pages/index/index' path: '/pages/index/index'
})) }))
// #endif // #endif
+2
View File
@@ -221,6 +221,8 @@ function goFirstUnlit() {
function resumeRead() { function resumeRead() {
if (lastRead.value?.id) { if (lastRead.value?.id) {
uni.navigateTo({ url: `/pages/detail/detail?id=${lastRead.value.id}` }) uni.navigateTo({ url: `/pages/detail/detail?id=${lastRead.value.id}` })
} else if (todayKnowledge.value?.id || todayKnowledge.value?._id) {
uni.navigateTo({ url: `/pages/detail/detail?id=${todayKnowledge.value.id || todayKnowledge.value._id}` })
} }
} }
+1 -1
View File
@@ -138,7 +138,7 @@ function dimDotColor(id) {
// 微信小程序分享 // 微信小程序分享
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
onShareAppMessage(() => ({ onShareAppMessage(() => ({
title: 'AI 维度 - 你的AI知识助手', title: 'AI学习排行榜 · 宇之然AI维度',
path: '/pages/index/index' path: '/pages/index/index'
})) }))
// #endif // #endif
+5 -12
View File
@@ -59,7 +59,7 @@
</view> </view>
<!-- 微信小程序一键登录 --> <!-- 微信小程序一键登录 -->
<!-- #ifdef MP-WEIXIN --> <!-- #ifdef MP-WEIXIN -->
<view class="wx-login-row" v-if="isMiniApp"> <view class="wx-login-row">
<view class="wx-login-btn" @click="wechatLogin"> <view class="wx-login-btn" @click="wechatLogin">
<text class="wx-icon">💬</text> <text class="wx-icon">💬</text>
<text>微信一键登录</text> <text>微信一键登录</text>
@@ -129,21 +129,12 @@
</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'
import { useUserStore } from '@/stores/index' import { useUserStore } from '@/stores/index'
const isMiniApp = ref(false)
// #ifdef MP-WEIXIN
try {
const sysInfo = uni.getSystemInfoSync()
if (sysInfo && sysInfo.version && sysInfo.platform) {
isMiniApp.value = true
}
} catch (e) {}
// #endif
const activeTab = ref('login') const activeTab = ref('login')
const loading = ref(false) const loading = ref(false)
const agreed = ref(false) const agreed = ref(false)
@@ -217,6 +208,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 +237,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' })
@@ -324,7 +317,7 @@ function goForgotPassword() {
// 微信小程序分享 // 微信小程序分享
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
onShareAppMessage(() => ({ onShareAppMessage(() => ({
title: 'AI 维度 - 你的AI知识助手', title: '登录解锁AI学习完整功能 · 宇之然AI维度',
path: '/pages/index/index' path: '/pages/index/index'
})) }))
// #endif // #endif
+33 -7
View File
@@ -11,11 +11,6 @@
</view> </view>
<scroll-view scroll-y class="content"> <scroll-view scroll-y class="content">
<!-- 资讯头图 -->
<view class="news-hero" :style="{background: heroBg}">
<text class="hero-emoji">{{ heroEmoji }}</text>
</view>
<!-- 分类标签 --> <!-- 分类标签 -->
<view class="category-tag"> <view class="category-tag">
<text class="tag-text">{{ categoryLabel }}</text> <text class="tag-text">{{ categoryLabel }}</text>
@@ -50,6 +45,14 @@
<text class="summary-text">{{ news.summary }}</text> <text class="summary-text">{{ news.summary }}</text>
</view> </view>
<!-- 人话解读 -->
<view class="summary-card plain" v-if="news.plainExplain">
<text class="summary-label">💡 一句话说清</text>
<text class="summary-text">{{ news.plainExplain }}</text>
<text class="summary-label" style="margin-top: 12px" v-if="news.takeAway">🎯 对你意味着</text>
<text class="summary-text" v-if="news.takeAway">{{ news.takeAway }}</text>
</view>
<!-- 正文 --> <!-- 正文 -->
<view class="news-body" v-if="newsBody"> <view class="news-body" v-if="newsBody">
<rich-text :nodes="newsBody"></rich-text> <rich-text :nodes="newsBody"></rich-text>
@@ -91,6 +94,8 @@
<script setup> <script setup>
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app' import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { trendApi, knowledgeApi } from '@/utils/api' import { trendApi, knowledgeApi } from '@/utils/api'
@@ -170,16 +175,37 @@ function goBack() {
uni.navigateBack() uni.navigateBack()
} }
function onFavorite() { async function onFavorite() {
// 静默登录后提示(收藏态由详情页统一管理)
const ok = await userStore.ensureLogin()
if (!ok) {
uni.showModal({
title: '登录后收藏',
content: '登录后即可收藏资讯',
confirmText: '去登录',
cancelText: '暂不',
success: (res) => { if (res.confirm) uni.navigateTo({ url: '/pages/login/login' }) }
})
return
}
isCollected.value = !isCollected.value isCollected.value = !isCollected.value
uni.showToast({ title: isCollected.value ? '已收藏' : '已取消收藏', icon: 'none' }) uni.showToast({ title: isCollected.value ? '已收藏' : '已取消收藏', icon: 'none' })
} }
function onShare() { function onShare() {
// #ifdef MP-WEIXIN
uni.showModal({
title: '分享给朋友',
content: '点击右上角「···」→ 转发给朋友或分享到朋友圈',
showCancel: false,
})
// #endif
// #ifdef H5
uni.setClipboardData({ uni.setClipboardData({
data: news.value.link || `https://ai-dimension.yuzhiran.com.cn/news/${news.value._id || ''}`, data: news.value.sourceUrl || `https://wdkj.yuzhiran.com.cn/trends/${news.value._id || ''}`,
success: () => uni.showToast({ title: '链接已复制', icon: 'none' }) success: () => uni.showToast({ title: '链接已复制', icon: 'none' })
}) })
// #endif
} }
function openSource() { function openSource() {
+1 -1
View File
@@ -291,7 +291,7 @@ function goBack() {
// 微信小程序分享 // 微信小程序分享
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
onShareAppMessage(() => ({ onShareAppMessage(() => ({
title: 'AI 维度 - 你的AI知识助手', title: 'AI知识社区 · 宇之然AI维度',
path: '/pages/index/index' path: '/pages/index/index'
})) }))
// #endif // #endif
+75 -2
View File
@@ -15,8 +15,8 @@
<AppIcon name="planet-white" :size="26" /> <AppIcon name="planet-white" :size="26" />
</view> </view>
</view> </view>
<view class="profile-main"> <view class="profile-main" @click="editNickname">
<text class="profile-name">{{ userStore.userInfo?.nickName || '探索者' }}</text> <text class="profile-name">{{ userStore.userInfo?.nickName || '探索者' }}<text v-if="userStore.isLoggedIn" class="nick-edit"> ✏️</text></text>
<text class="profile-sub">{{ profileSubText }}</text> <text class="profile-sub">{{ profileSubText }}</text>
</view> </view>
<view class="streak-chip" v-if="stats && stats.streak > 0"> <view class="streak-chip" v-if="stats && stats.streak > 0">
@@ -104,6 +104,20 @@
</view> </view>
<text class="version">宇之然 AI 维度 v{{ versionText }}</text> <text class="version">宇之然 AI 维度 v{{ versionText }}</text>
<!-- 页脚:公司信息 / 联系客服 -->
<view class="foot">
<image class="foot-qr" src="/static/yzr/kefu.png" mode="widthFix" show-menu-by-longpress @click="previewKefu" />
<text class="foot-tip">微信客服 · 长按识别二维码,或点下方按钮直接联系</text>
<!-- #ifdef MP-WEIXIN -->
<button class="foot-contact" open-type="contact" session-from="profile-footer">💬 在线联系客服</button>
<!-- #endif -->
<text class="foot-line">联系邮箱:contact@yuzhiran.com</text>
<text class="foot-line">北京宇之然科技中心 · © 2026</text>
<!-- #ifdef H5 -->
<view class="foot-line"><a class="foot-link" href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">京ICP备2026007249号-2</a> · <a class="foot-link" href="https://beian.mps.gov.cn/#/query/webSearch?code=11011502039622" target="_blank" rel="noopener">京公网安备11011502039622号</a></view>
<!-- #endif -->
</view>
</view> </view>
</template> </template>
@@ -229,6 +243,41 @@ function dimPercent(dimId) {
return Math.round(((stats.value?.litByDim?.[dimId] || 0) / total) * 100) return Math.round(((stats.value?.litByDim?.[dimId] || 0) / total) * 100)
} }
// 修改昵称(点击我的页昵称)
function editNickname() {
if (!userStore.isLoggedIn) {
uni.showModal({
title: '请先登录',
content: '登录后即可修改昵称',
confirmText: '去登录',
showCancel: false,
success: () => uni.navigateTo({ url: '/pages/login/login' }),
})
return
}
uni.showModal({
title: '修改昵称',
editable: true,
placeholderText: '请输入新昵称(最多20字)',
success: async (m) => {
if (!m.confirm) return
const nickName = String(m.content || '').trim()
if (!nickName) return uni.showToast({ title: '昵称不能为空', icon: 'none' })
try {
await request('PUT', '/api/auth/nickname', { nickName }, userStore.token)
await userStore.fetchMe()
uni.showToast({ title: '昵称已更新', icon: 'success' })
} catch (e) {
uni.showToast({ title: e.message || '修改失败', icon: 'none' })
}
},
})
}
function previewKefu() {
uni.previewImage({ urls: ['/static/yzr/kefu.png'] })
}
function goDim(id) { function goDim(id) {
uni.navigateTo({ url: `/pages/dimension/dimension?id=${id}` }) uni.navigateTo({ url: `/pages/dimension/dimension?id=${id}` })
} }
@@ -325,6 +374,7 @@ onShareTimeline(() => ({
color: var(--text-primary); color: var(--text-primary);
margin-bottom: 4px; margin-bottom: 4px;
} }
.nick-edit { font-size: 14px; opacity: 0.7; }
.profile-sub { .profile-sub {
display: block; display: block;
font-size: 12px; font-size: 12px;
@@ -656,6 +706,29 @@ onShareTimeline(() => ({
</style> </style>
<style scoped> <style scoped>
.foot {
margin-top: 8px;
padding: 24px 0 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.foot-qr { width: 120px; border-radius: 8px; background: #fff; }
.foot-tip { font-size: 11px; color: var(--text-muted, #b6bac9); }
.foot-contact {
margin-top: 4px;
font-size: 13px;
color: #6366f1;
background: var(--color-brand-bg, #f0f0ff);
border-radius: 999px;
padding: 0 18px;
height: 32px;
line-height: 32px;
}
.foot-contact::after { border: none; }
.foot-line { font-size: 11px; color: var(--text-muted, #b6bac9); }
.foot-link { font-size: 11px; color: var(--text-tertiary, #9aa0b4); text-decoration: none; }
.balance-actions { display: flex; gap: 8px; } .balance-actions { display: flex; gap: 8px; }
.recharge-go.outline { background: transparent; border: 1.5px solid rgba(255,255,255,.6); color: #fff; } .recharge-go.outline { background: transparent; border: 1.5px solid rgba(255,255,255,.6); color: #fff; }
</style> </style>
+1 -1
View File
@@ -225,7 +225,7 @@ function goBack() {
// 微信小程序分享 // 微信小程序分享
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
onShareAppMessage(() => ({ onShareAppMessage(() => ({
title: 'AI 维度 - 你的AI知识助手', title: '我的AI学习进度 · 宇之然AI维度',
path: '/pages/index/index' path: '/pages/index/index'
})) }))
// #endif // #endif
+28 -5
View File
@@ -31,11 +31,13 @@
<text class="loading-text">搜索中...</text> <text class="loading-text">搜索中...</text>
</view> </view>
<!-- 空搜索提示 --> <!-- 未搜索:热门词快捷入口 -->
<view class="empty-state" v-else-if="!hasSearched"> <view class="empty-state" v-else-if="!hasSearched">
<text class="empty-icon">没有找到相关知识</text> <text class="empty-icon">🔍</text>
<text class="empty-text">输入关键词搜索 AI 知识</text> <text class="empty-text">输入关键词搜索 AI 知识</text>
<text class="empty-hint">支持按标题、摘要、标签搜索</text> <view class="hot-words">
<text v-for="w in HOT_WORDS" :key="w" class="hot-word" @click="quickSearch(w)">{{ w }}</text>
</view>
</view> </view>
<!-- 无结果提示 --> <!-- 无结果提示 -->
@@ -92,6 +94,11 @@ import { knowledgeApi } from '@/utils/api'
import { DIMENSIONS } from '@/utils/dimensions' import { DIMENSIONS } from '@/utils/dimensions'
const searchText = ref('') const searchText = ref('')
const HOT_WORDS = ['大模型', 'Transformer', 'DeepSeek', '提示词', 'AI绘画', '智能体', 'RAG', '具身智能']
function quickSearch(w) {
searchText.value = w
doSearch()
}
const results = ref([]) const results = ref([])
const loading = ref(false) const loading = ref(false)
const hasSearched = ref(false) const hasSearched = ref(false)
@@ -192,7 +199,7 @@ onUnmounted(() => {
// 微信小程序分享 // 微信小程序分享
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
onShareAppMessage(() => ({ onShareAppMessage(() => ({
title: 'AI 维度 - 你的AI知识助手', title: '搜一搜想了解的AI知识 · 宇之然AI维度',
path: '/pages/index/index' path: '/pages/index/index'
})) }))
// #endif // #endif
@@ -395,4 +402,20 @@ onShareAppMessage(() => ({
font-size: 11px; font-size: 11px;
color: var(--text-secondary); color: var(--text-secondary);
} }
</style>
.hot-words {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-top: 16px;
padding: 0 20px;
}
.hot-word {
padding: 6px 16px;
border-radius: 999px;
background: var(--glass-bg, #f3f4fa);
color: var(--text-secondary, #4a4f63);
font-size: 13px;
}
</style>
+21 -1
View File
@@ -20,6 +20,7 @@
<view class="s-switch-knob" :class="{ on: pushOn }"></view> <view class="s-switch-knob" :class="{ on: pushOn }"></view>
</view> </view>
</view> </view>
<!-- #ifdef H5 -->
<view class="setting-item" @click="toggleDarkMode"> <view class="setting-item" @click="toggleDarkMode">
<view class="s-icon" style="background:rgba(77,208,225,0.1);color:#4dd0e1">🌙</view> <view class="s-icon" style="background:rgba(77,208,225,0.1);color:#4dd0e1">🌙</view>
<view class="s-text"> <view class="s-text">
@@ -30,6 +31,17 @@
<view class="s-switch-knob" :class="{ on: darkMode }"></view> <view class="s-switch-knob" :class="{ on: darkMode }"></view>
</view> </view>
</view> </view>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<view class="setting-item" @click="showDarkTip">
<view class="s-icon" style="background:rgba(77,208,225,0.1);color:#4dd0e1">🌙</view>
<view class="s-text">
<text class="s-label">深色模式</text>
<text class="s-desc">跟随系统设置自动切换</text>
</view>
<text class="s-arrow">›</text>
</view>
<!-- #endif -->
</view> </view>
<!-- 其他 --> <!-- 其他 -->
@@ -85,6 +97,14 @@ function togglePush() {
}) })
} }
function showDarkTip() {
uni.showModal({
title: '深色模式',
content: '小程序深色模式跟随系统:在手机的 系统设置 → 显示与亮度 中切换深色/浅色,小程序将自动适配。',
showCancel: false,
})
}
function toggleDarkMode() { function toggleDarkMode() {
darkMode.value = !darkMode.value darkMode.value = !darkMode.value
const theme = darkMode.value ? 'dark' : 'light' const theme = darkMode.value ? 'dark' : 'light'
@@ -148,7 +168,7 @@ function goBack() {
// 微信小程序分享 // 微信小程序分享
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
onShareAppMessage(() => ({ onShareAppMessage(() => ({
title: 'AI 维度 - 你的AI知识助手', title: 'AI知识社区 · 宇之然AI维度',
path: '/pages/index/index' path: '/pages/index/index'
})) }))
// #endif // #endif
+2 -49
View File
@@ -121,8 +121,7 @@ async function fetchTrends() {
items.value = data.list || data.items || [] items.value = data.list || data.items || []
} catch (err) { } catch (err) {
console.error('获取趋势失败:', err) console.error('获取趋势失败:', err)
// 降级:本地示例 uni.showToast({ title: '加载失败,下拉重试', icon: 'none' })
items.value = getSampleData()
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -159,57 +158,11 @@ function formatDate(dateStr) {
} }
function goDetail(item) { function goDetail(item) {
// 如果有原文链接,跳转外部 // 始终进入站内详情页(人话解读/阅读量统计/收藏/分享);原文链接在详情页内提供
if (item.link) {
// 微信小程序不支持直接打开外部链接,复制链接提示
uni.setClipboardData({
data: item.link,
success: () => {
uni.showModal({
title: '打开链接',
content: '链接已复制,请在浏览器中打开',
showCancel: false
})
}
})
return
}
uni.setStorageSync('__news_item__', JSON.stringify(item)) uni.setStorageSync('__news_item__', JSON.stringify(item))
uni.navigateTo({ url: '/pages/news/news' }) uni.navigateTo({ url: '/pages/news/news' })
} }
function getSampleData() {
return [
{
title: 'DeepSeek 发布 R1 推理模型',
summary: 'DeepSeek R1 在多项基准测试中超越 GPT-4o,引发全球关注',
source: '量子位',
newsDate: new Date(Date.now() - 2 * 3600000).toISOString(),
category: 'product',
hot: true,
hotCount: 2000,
viewCount: 15000
},
{
title: 'Google Gemini 1.5 Pro 发布',
summary: '支持 200 万 token 上下文窗口,多模态能力大幅提升',
source: '机器之心',
newsDate: new Date(Date.now() - 6 * 3600000).toISOString(),
category: 'product',
viewCount: 8000
},
{
title: 'Transformer 论文作者新方向',
summary: '从注意力机制到状态空间模型,作者分享最新研究进展',
source: 'PaperWeekly',
newsDate: new Date(Date.now() - 12 * 3600000).toISOString(),
category: 'paper',
picked: true,
viewCount: 5000
}
]
}
// 微信小程序分享 // 微信小程序分享
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
onShareAppMessage(() => { onShareAppMessage(() => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

+22
View File
@@ -28,6 +28,27 @@ export const useUserStore = defineStore('user', () => {
return data return data
} }
/**
* 静默登录(微信小程序):code 换 token,用户无感知。
* 已登录直接返回 true;失败返回 false(调用方再引导显式登录)。
*/
async function ensureLogin() {
if (token.value) return true
// #ifdef MP-WEIXIN
try {
const loginRes = await new Promise((resolve, reject) => {
uni.login({ success: resolve, fail: reject })
})
if (!loginRes.code) return false
await login(loginRes.code, {})
return true
} catch (e) { return false }
// #endif
// #ifndef MP-WEIXIN
return false
// #endif
}
async function h5Login(loginData) { async function h5Login(loginData) {
const data = await authApi.h5Login(loginData) const data = await authApi.h5Login(loginData)
token.value = data.token token.value = data.token
@@ -110,6 +131,7 @@ export const useUserStore = defineStore('user', () => {
isSubscribed, isSubscribed,
subscriptionLevel, subscriptionLevel,
login, login,
ensureLogin,
h5Login, h5Login,
h5Register, h5Register,
fetchMe, fetchMe,
+42
View File
@@ -0,0 +1,42 @@
import { request } from './api'
// 订阅消息模板ID:编译期注入(.env.production 配 VITE_NOTIFY_TMPL=模板ID);
// 不用 import.meta 运行时访问(其 CJS 互操作产物在小程序内会因 __filename 崩溃导致页面白屏)
// #ifdef MP-WEIXIN
const TMPL_IDS = (typeof VITE_NOTIFY_TMPL !== 'undefined' && VITE_NOTIFY_TMPL) ? [VITE_NOTIFY_TMPL] : []
// #endif
// #ifndef MP-WEIXIN
const TMPL_IDS = (import.meta.env && import.meta.env.VITE_NOTIFY_TMPL) ? [import.meta.env.VITE_NOTIFY_TMPL] : []
// #endif
/**
* 请求订阅授权(在用户完成关键动作后调用,此时微信允许弹授权框)
* 返回接受的模板数(失败静默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) {
try {
const { useUserStore } = await import('../stores/user')
const token = useUserStore().token
if (token) request('PUT', '/api/auth/subscribe-report', { accepted }, token).catch(() => {})
} catch { /* store 不可用时静默 */ }
}
return accepted
} catch { return 0 }
// #endif
// #ifndef MP-WEIXIN
return 0
// #endif
}
+28
View File
@@ -0,0 +1,28 @@
# 维度(ai-dimension)代码评审与修复报告 · 2026-09-19
## 已修复(生产验证通过)
| 级别 | 问题 | 修复 | 验证 |
|---|---|---|---|
| P0 | /api/auth/webview-token 仅凭 openid 签发任意用户 token(排行榜公开 openid = 账号接管) | 接口下线(客户端无调用) | 返回"接口不存在" ✓ |
| P0 | POST /progress 可写 isSubscriber/isAdFree/subscribeExpiry/ownedSkins 白嫖付费权益 | 客户端写入全部剔除 | 写入后 DB 保持 false ✓ |
| P1 | aiModel 接口向管理员以外泄露 apiKey 明文 | 列表/详情/默认配置全部脱敏 | 密钥数=0 ✓ |
| P1 | 登录 identifier/verifyCode 未强转(对象注入 $ne) | 入口 String() 强转 | 代码审计 ✓ |
| P1 | sessionKey(支付签名凭据)随 /me、progress 全量下发 | schema select:false,仅 vpay 中间件显式查询 | 代码审计 ✓ |
| P2 | 搜索 $regex 未转义(ReDoS/注入) | escapeRegExp 全量接入(knowledge+admin 9处) | 正则字符搜索 200 ✓ |
| P2 | 排行榜 limit 无上限且返回 openid | limit 封顶100、去 openid | 无 openid ✓ |
| P2 | share-gain 无限刷问答次数 | 每日限3次(shareRecords 统计) | 第4次 429 ✓ |
| P2 | 读接口每次写 AdminLog(日志膨胀) | GET 路由移除 logAction(11处) | 代码审计 ✓ |
| P2 | /:dim/questions 泄露检测题 isCorrect | 选项脱敏(该接口当前无数据,防御性修复) | 代码审计 ✓ |
| P3 | /shop-items 与 /shop 完全重复 | 删除 /shop-items | 代码审计 ✓ |
## 已知残留(评估后接受/待定)
- **旧 payment.js 体系**(weixin.js 恒真验签、mock 退款、H5 mock 下单):属未启用的旧订阅支付链路,vpay(虚拟支付)才是现行通道;建议下个迭代整体删除旧 payment/weixin 文件避免误用。
- **充值回调非原子幂等**(check-then-act):已有 orderNo 流水查重兜底,重推双倍入账概率极低;建议加 {orderNo,type} 唯一索引彻底封死。
- **退款先冻结后调微信非事务**:进程崩溃窗口极小且有人工对账兜底(管理后台流水),接受。
- **访问埋点全量写**:目前 PV 低无压力;流量上来后改采样。
## 验证环境
- 生产:wdkj.yuzhiran.com.cn(dim-server v1.6.21 healthy)
- 关键链路回归:管理员登录/模型配置/知识搜索/排行榜/分享奖励 全部通过
+9 -2
View File
@@ -19,6 +19,7 @@
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1", "express-validator": "^7.0.1",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"ip2region": "^2.3.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"moment": "^2.29.4", "moment": "^2.29.4",
"mongoose": "^8.0.3", "mongoose": "^8.0.3",
@@ -1096,7 +1097,6 @@
"version": "25.5.0", "version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
@@ -2819,6 +2819,14 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip2region": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/ip2region/-/ip2region-2.3.0.tgz",
"integrity": "sha512-zV5Xsadzrx9Ej6heoyhbXMsfGWWQ3C6bAIYStrHhw9kzLpGpVNlnAyRBxxPgxA1GNqr1Ti7oUxcWsMWNN3jZBg==",
"peerDependencies": {
"@types/node": "*"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -5510,7 +5518,6 @@
"version": "7.18.2", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unpipe": { "node_modules/unpipe": {
+1
View File
@@ -27,6 +27,7 @@
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1", "express-validator": "^7.0.1",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"ip2region": "^2.3.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"moment": "^2.29.4", "moment": "^2.29.4",
"mongoose": "^8.0.3", "mongoose": "^8.0.3",
+41
View File
@@ -8,6 +8,7 @@ const morgan = require('morgan')
const rateLimit = require('express-rate-limit') const rateLimit = require('express-rate-limit')
const connectDB = require('./config/database') const connectDB = require('./config/database')
const { VisitLog } = require('./models')
const initRoutes = require('./routes') const initRoutes = require('./routes')
const seoRoutes = require('./routes/seo') const seoRoutes = require('./routes/seo')
const registerWxMsgRoutes = require('./routes/wxmsg') const registerWxMsgRoutes = require('./routes/wxmsg')
@@ -65,6 +66,46 @@ const limiter = rateLimit({
}) })
app.use('/api/', limiter) app.use('/api/', limiter)
// 访问埋点:记录页面级 GET 请求(排除管理后台/认证/健康检查/高频轮询接口),小程序与 H5 访问均可统计
// PV 口径:只记"页面首屏"接口(每打开一页只计1次),轮询/统计类接口不计——避免一次访问刷出6-8条
const TRACKABLE_PAGES = [/^\/?api\/knowledge(\?|$)/, /^\/?api\/trend(\?|$)/, /^\/?api\/knowledge\/stats/, /^\/?api\/ai-chat\/quota/, /^\/?api\/user\/leaderboard/, /^\/?api\/feedback/]
app.use('/api', (req, res, next) => {
try {
if (req.method === 'GET' && !req.path.startsWith('/admin') && !req.path.startsWith('/auth')) {
const fullPath = String(req.originalUrl || req.path).split('?')[0]
const isPage = TRACKABLE_PAGES.some((re) => re.test(fullPath)) || TRACKABLE_PAGES.some((re) => re.test(fullPath.replace(/^\/api/, '')))
if (isPage) {
const ua = String(req.headers['user-agent'] || '')
const referer = String(req.headers.referer || '')
let source = '直接访问'
if (referer.includes('servicewechat.com')) source = '小程序'
else if (referer.includes('/h5') || referer.includes('wdkj.yuzhiran.com.cn')) source = 'H5'
const os = /windows/i.test(ua) ? 'Windows' : /iphone|ipad|ios/i.test(ua) ? 'iOS' : /android/i.test(ua) ? 'Android' : /mac os/i.test(ua) ? 'MacOS' : ''
const browser = /MicroMessenger/i.test(ua) ? '微信' : /Mobile Safari/i.test(ua) ? 'Safari' : /Edge\//i.test(ua) ? 'Edge' : /Chrome/i.test(ua) ? 'Chrome' : /Firefox/i.test(ua) ? 'Firefox' : ''
const device = /Mobile|iPhone/i.test(ua) || (/Android/i.test(ua) && /Mobile/i.test(ua)) ? '移动端' : '桌面端'
// 解析登录用户(不强制,游客为空)
let userId = null
const auth = String(req.headers.authorization || '')
if (auth.startsWith('Bearer ')) {
try {
const decoded = require('jsonwebtoken').verify(auth.slice(7), process.env.JWT_SECRET)
userId = decoded.userId || null
} catch { /* token 无效视为游客 */ }
}
VisitLog.create({
path: String(req.originalUrl || req.path).slice(0, 120),
referer: String(req.headers.referer || '').slice(0, 200),
source,
ip: req.ip || '',
userId,
os, browser, device,
}).catch(() => {})
}
}
} catch (e) { /* 埋点失败不影响业务 */ }
next()
})
// 健康检查 // 健康检查
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
res.status(200).json({ res.status(200).json({
+158 -158
View File
@@ -1,158 +1,158 @@
const jwt = require('jsonwebtoken') const jwt = require('jsonwebtoken')
const { Admin, User } = require('../models') const { Admin, User } = require('../models')
/** /**
* JWT 认证中间件(管理员) * JWT 认证中间件(管理员)
*/ */
const authMiddleware = async (req, res, next) => { const authMiddleware = async (req, res, next) => {
try { try {
// 从 header 获取 token // 从 header 获取 token
const authHeader = req.header('Authorization') const authHeader = req.header('Authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) { if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ return res.status(401).json({
success: false, success: false,
error: '未提供认证令牌' error: '未提供认证令牌'
}) })
} }
const token = authHeader.replace('Bearer ', '') const token = authHeader.replace('Bearer ', '')
// 验证 token // 验证 token
const decoded = jwt.verify(token, process.env.JWT_SECRET) const decoded = jwt.verify(token, process.env.JWT_SECRET)
// 查找管理员 // 查找管理员
const admin = await Admin.findById(decoded.adminId) const admin = await Admin.findById(decoded.adminId)
if (!admin) { if (!admin) {
return res.status(401).json({ return res.status(401).json({
success: false, success: false,
error: '管理员不存在' error: '管理员不存在'
}) })
} }
// 检查状态 // 检查状态
if (admin.status !== 'active') { if (admin.status !== 'active') {
return res.status(403).json({ return res.status(403).json({
success: false, success: false,
error: '管理员账户已禁用' error: '管理员账户已禁用'
}) })
} }
// 将管理员信息附加到请求对象 // 将管理员信息附加到请求对象
req.admin = admin req.admin = admin
next() next()
} catch (error) { } catch (error) {
if (error.name === 'JsonWebTokenError') { if (error.name === 'JsonWebTokenError') {
return res.status(401).json({ return res.status(401).json({
success: false, success: false,
error: '无效的认证令牌' error: '无效的认证令牌'
}) })
} }
if (error.name === 'TokenExpiredError') { if (error.name === 'TokenExpiredError') {
return res.status(401).json({ return res.status(401).json({
success: false, success: false,
error: '认证令牌已过期' error: '认证令牌已过期'
}) })
} }
next(error) next(error)
} }
} }
/** /**
* 权限检查中间件 * 权限检查中间件
* @param {string} permission - 需要的权限 * @param {string} permission - 需要的权限
*/ */
const requirePermission = (permission) => { const requirePermission = (permission) => {
return (req, res, next) => { return (req, res, next) => {
if (!req.admin) { if (!req.admin) {
return res.status(401).json({ return res.status(401).json({
success: false, success: false,
error: '未认证' error: '未认证'
}) })
} }
if (!req.admin.hasPermission(permission)) { if (!req.admin.hasPermission(permission)) {
return res.status(403).json({ return res.status(403).json({
success: false, success: false,
error: '权限不足' error: '权限不足'
}) })
} }
next() next()
} }
} }
/** /**
* 小程序用户 JWT 认证中间件 * 小程序用户 JWT 认证中间件
*/ */
const userAuthMiddleware = async (req, res, next) => { const userAuthMiddleware = async (req, res, next) => {
try { try {
const authHeader = req.header('Authorization') const authHeader = req.header('Authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) { if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ return res.status(401).json({
success: false, success: false,
error: '未提供认证令牌' error: '未提供认证令牌'
}) })
} }
const token = authHeader.replace('Bearer ', '') const token = authHeader.replace('Bearer ', '')
const decoded = jwt.verify(token, process.env.JWT_SECRET) const decoded = jwt.verify(token, process.env.JWT_SECRET)
// 兼容两种token格式:包含userId或openid // 兼容两种token格式:包含userId或openid
let user = null let user = null
if (decoded.userId) { if (decoded.userId) {
user = await User.findById(decoded.userId) user = await User.findById(decoded.userId).select('+sessionKey') // vpay 签名需要
} else if (decoded.openid) { } else if (decoded.openid) {
user = await User.findOne({ openid: decoded.openid }) user = await User.findOne({ openid: decoded.openid })
} }
if (!user) { if (!user) {
return res.status(401).json({ return res.status(401).json({
success: false, success: false,
error: '用户不存在或令牌无效' error: '用户不存在或令牌无效'
}) })
} }
req.user = user req.user = user
// 优先使用 openid,如果没有则使用 userId 作为标识 // 优先使用 openid,如果没有则使用 userId 作为标识
req.openid = user.openid || user._id.toString() req.openid = user.openid || user._id.toString()
next() next()
} catch (error) { } catch (error) {
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') { if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
return res.status(401).json({ return res.status(401).json({
success: false, success: false,
error: '认证令牌无效或已过期' error: '认证令牌无效或已过期'
}) })
} }
next(error) next(error)
} }
} }
/** /**
* 可选的用户认证中间件(不强制要求登录) * 可选的用户认证中间件(不强制要求登录)
*/ */
const optionalAuthMiddleware = async (req, res, next) => { const optionalAuthMiddleware = async (req, res, next) => {
try { try {
const authHeader = req.header('Authorization') const authHeader = req.header('Authorization')
if (authHeader && authHeader.startsWith('Bearer ')) { if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.replace('Bearer ', '') const token = authHeader.replace('Bearer ', '')
const decoded = jwt.verify(token, process.env.JWT_SECRET) const decoded = jwt.verify(token, process.env.JWT_SECRET)
const user = await User.findById(decoded.userId) const user = await User.findById(decoded.userId)
if (user) { if (user) {
req.user = user req.user = user
req.openid = user.openid req.openid = user.openid
} }
} }
next() next()
} catch (error) { } catch (error) {
// 即使认证失败也继续,只是不附加用户信息 // 即使认证失败也继续,只是不附加用户信息
next() next()
} }
} }
module.exports = { module.exports = {
authMiddleware, authMiddleware,
requirePermission, requirePermission,
userAuthMiddleware, userAuthMiddleware,
optionalAuthMiddleware optionalAuthMiddleware
} }
+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: '' }, 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,
+29
View File
@@ -0,0 +1,29 @@
const mongoose = require('mongoose')
const { Schema } = mongoose
/**
* 访问埋点模型
* 记录所有 /api 页面级请求(小程序 + H5 + 未知来源),供管理后台访问统计使用。
* 与 LoginLog(登录行为)分离:匿名 H5 访问没有用户也能记录。
*/
const VisitLogSchema = new Schema({
path: { type: String, default: '' },
// 来源:小程序 / H5 / 直接访问
source: { type: String, default: '直接访问', index: true },
ip: { type: String, default: '', index: true },
referer: { type: String, default: '' },
userId: { type: Schema.Types.ObjectId, ref: 'User', default: null },
os: { type: String, default: '' },
browser: { type: String, default: '' },
device: { type: String, default: '' },
createdAt: { type: Date, default: Date.now, index: true }
})
VisitLogSchema.index({ createdAt: -1 })
module.exports = mongoose.model('VisitLog', VisitLogSchema)
+5 -1
View File
@@ -12,6 +12,8 @@ const Feedback = require('./Feedback')
const Trend = require('./Trend') 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 NotifyLog = require('./NotifyLog')
module.exports = { module.exports = {
User, User,
@@ -27,5 +29,7 @@ module.exports = {
Feedback, Feedback,
Trend, Trend,
VerificationCode, VerificationCode,
CoinLedger CoinLedger,
VisitLog,
NotifyLog
} }
+281 -46
View File
@@ -1,10 +1,12 @@
const express = require('express') const express = require('express')
const router = express.Router() const router = express.Router()
const mongoose = require('mongoose') const mongoose = require('mongoose')
const { User, Order, Knowledge, ShopItem, AdminLog, AIChatQuota, Feedback, LoginLog, Trend, KnowledgeLit, CoinLedger } = require('../models') const { User, Order, Knowledge, ShopItem, AdminLog, AIChatQuota, Feedback, LoginLog, Trend, KnowledgeLit, CoinLedger, VisitLog, AIModel, NotifyLog } = require('../models')
const { ipLocation } = require('../utils/iploc')
const { authMiddleware, requirePermission } = require('../middleware/auth') const { authMiddleware, requirePermission } = require('../middleware/auth')
const ApiResponse = require('../utils/response') const ApiResponse = require('../utils/response')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const { escapeRegExp } = require('../utils/escapeRegex')
// 所有管理路由都需要认证 // 所有管理路由都需要认证
router.use(authMiddleware) router.use(authMiddleware)
@@ -33,6 +35,88 @@ const logAction = async (req, action, resource, resourceId = null, details = {})
* 获取仪表盘数据 * 获取仪表盘数据
* GET /api/admin/dashboard * GET /api/admin/dashboard
*/ */
// 访问统计(含 H5 / 小程序 / 直接访问)
router.get('/visit-stats', requirePermission('dashboard:read'), async (req, res) => {
try {
const startOfDay = new Date(new Date().toLocaleDateString('sv-SE') + 'T00:00:00+08:00')
const sevenAgo = new Date(Date.now() - 6 * 86400000)
const startOf7 = new Date(new Date(sevenAgo).toLocaleDateString('sv-SE') + 'T00:00:00+08:00')
const [pvToday, uvAgg, loginToday, h5Today, miniToday] = await Promise.all([
VisitLog.countDocuments({ createdAt: { $gte: startOfDay } }),
VisitLog.distinct('ip', { createdAt: { $gte: startOfDay } }),
LoginLog.countDocuments({ createdAt: { $gte: startOfDay } }),
VisitLog.countDocuments({ createdAt: { $gte: startOfDay }, source: 'H5' }),
VisitLog.countDocuments({ createdAt: { $gte: startOfDay }, source: '小程序' }),
])
const sources = await VisitLog.aggregate([
{ $match: { createdAt: { $gte: startOf7 } } },
{ $group: { _id: '$source', c: { $sum: 1 } } },
{ $sort: { c: -1 } },
])
return ApiResponse.success(res, {
pvToday,
uvToday: uvAgg.length,
loginToday,
h5Today,
miniToday,
sources7d: sources.map(s => ({ source: s._id || '直接访问', count: s.c })),
})
} catch (error) {
logger.error('访问统计失败:', error)
return ApiResponse.serverError(res, error.message)
}
})
// 访问日志列表
router.get('/visits', 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 source = String(req.query.source || '').trim()
const range = String(req.query.range || '7d')
// range → mongo 时间条件
const now = new Date()
const startOfDay = (d) => { const x = new Date(d); x.setHours(0, 0, 0, 0); return x }
let timeQuery = {}
if (range === 'today') timeQuery = { createdAt: { $gte: startOfDay(now) } }
else if (range === 'yesterday') {
const y = new Date(now); y.setDate(y.getDate() - 1)
timeQuery = { createdAt: { $gte: startOfDay(y), $lt: startOfDay(now) } }
} else if (range === '30d') {
const d = new Date(now); d.setDate(d.getDate() - 29)
timeQuery = { createdAt: { $gte: startOfDay(d) } }
} else if (range === 'all') timeQuery = {}
else { // 7d
const d = new Date(now); d.setDate(d.getDate() - 6)
timeQuery = { createdAt: { $gte: startOfDay(d) } }
}
const query = { ...timeQuery, ...(source ? { source } : {}) }
const [total, items] = await Promise.all([
VisitLog.countDocuments(query),
VisitLog.find(query).sort({ createdAt: -1 }).skip((page - 1) * limit).limit(limit).populate('userId', 'nickName').lean(),
])
items.forEach(v => {
v.ipLoc = ipLocation(v.ip)
v.userName = v.userId?.nickName || ''
})
// 快捷统计(当日/昨日/7天/30天总量)
const cnt = (q) => VisitLog.countDocuments(q)
const y = new Date(now); y.setDate(y.getDate() - 1)
const d7 = new Date(now); d7.setDate(d7.getDate() - 6)
const d30 = new Date(now); d30.setDate(d30.getDate() - 29)
const stats = {
today: await cnt({ createdAt: { $gte: startOfDay(now) } }),
yesterday: await cnt({ createdAt: { $gte: startOfDay(y), $lt: startOfDay(now) } }),
last7: await cnt({ createdAt: { $gte: startOfDay(d7) } }),
last30: await cnt({ createdAt: { $gte: startOfDay(d30) } }),
}
return ApiResponse.success(res, { items, total, page, limit, stats, pagination: { total } })
} catch (error) {
logger.error('访问日志查询失败:', error)
return ApiResponse.serverError(res, error.message)
}
})
router.get('/dashboard', requirePermission('dashboard:read'), async (req, res) => { router.get('/dashboard', requirePermission('dashboard:read'), async (req, res) => {
try { try {
const today = new Date() const today = new Date()
@@ -56,7 +140,6 @@ router.get('/dashboard', requirePermission('dashboard:read'), async (req, res) =
Order.getWeekTrend() Order.getWeekTrend()
]) ])
await logAction(req, 'read', 'dashboard')
return ApiResponse.success(res, { return ApiResponse.success(res, {
today: { today: {
@@ -89,7 +172,7 @@ router.get('/users', requirePermission('users:read'), async (req, res) => {
const query = {} const query = {}
if (search) { if (search) {
query.nickName = { $regex: search, $options: 'i' } query.nickName = { $regex: escapeRegExp(search), $options: 'i' }
} }
if (dimCompleted) { if (dimCompleted) {
@@ -151,7 +234,6 @@ router.get('/users', requirePermission('users:read'), async (req, res) => {
} }
}) })
await logAction(req, 'read', 'users', null, { page, limit, search })
return ApiResponse.paginated(res, formattedUsers, { return ApiResponse.paginated(res, formattedUsers, {
page: parseInt(page), page: parseInt(page),
@@ -213,7 +295,6 @@ router.get('/users/:id', requirePermission('users:read'), async (req, res) => {
// 画廊(Gallery)功能已移除,作品数为 0 // 画廊(Gallery)功能已移除,作品数为 0
await logAction(req, 'read', 'users', req.params.id)
return ApiResponse.success(res, { return ApiResponse.success(res, {
...user, ...user,
@@ -252,7 +333,6 @@ router.get('/knowledge', requirePermission('knowledge:read'), async (req, res) =
.limit(parseInt(limit)) .limit(parseInt(limit))
.lean() .lean()
await logAction(req, 'read', 'knowledge', null, { page, limit, dim, status })
return ApiResponse.paginated(res, knowledge, { return ApiResponse.paginated(res, knowledge, {
page: parseInt(page), page: parseInt(page),
@@ -385,7 +465,6 @@ router.get('/orders', requirePermission('orders:read'), async (req, res) => {
.limit(parseInt(limit)) .limit(parseInt(limit))
.lean() .lean()
await logAction(req, 'read', 'orders', null, { page, limit, status })
return ApiResponse.paginated(res, orders, { return ApiResponse.paginated(res, orders, {
page: parseInt(page), page: parseInt(page),
@@ -414,7 +493,6 @@ router.get('/shop', requirePermission('shop:read'), async (req, res) => {
.limit(parseInt(limit)) .limit(parseInt(limit))
.lean() .lean()
await logAction(req, 'read', 'shop', null, { page, limit, status })
return ApiResponse.paginated(res, items, { return ApiResponse.paginated(res, items, {
page: parseInt(page), page: parseInt(page),
@@ -505,7 +583,6 @@ router.get('/admins', requirePermission('*'), async (req, res) => {
.sort({ createdAt: -1 }) .sort({ createdAt: -1 })
.lean() .lean()
await logAction(req, 'read', 'admins')
return ApiResponse.success(res, admins) return ApiResponse.success(res, admins)
@@ -530,7 +607,6 @@ router.get('/shop-items', requirePermission('shop:read'), async (req, res) => {
.limit(parseInt(limit)) .limit(parseInt(limit))
.lean() .lean()
await logAction(req, 'read', 'shop', null, { page, limit, status })
return ApiResponse.paginated(res, items, { return ApiResponse.paginated(res, items, {
page: parseInt(page), page: parseInt(page),
@@ -701,7 +777,7 @@ router.get('/ai-chat/quota-list', requirePermission('user:read'), async (req, re
try { try {
const { page = 1, pageSize = 10, keyword = '' } = req.query const { page = 1, pageSize = 10, keyword = '' } = req.query
const query = keyword ? { openid: { $regex: keyword, $options: 'i' } } : {} const query = keyword ? { openid: { $regex: escapeRegExp(keyword), $options: 'i' } } : {}
const [list, total] = await Promise.all([ const [list, total] = await Promise.all([
AIChatQuota.find(query) AIChatQuota.find(query)
@@ -812,9 +888,9 @@ router.get('/feedback', requirePermission('feedback:read'), async (req, res) =>
if (priority) query.priority = priority if (priority) query.priority = priority
if (search) { if (search) {
query.$or = [ query.$or = [
{ title: { $regex: search, $options: 'i' } }, { title: { $regex: escapeRegExp(search), $options: 'i' } },
{ content: { $regex: search, $options: 'i' } }, { content: { $regex: escapeRegExp(search), $options: 'i' } },
{ userName: { $regex: search, $options: 'i' } } { userName: { $regex: escapeRegExp(search), $options: 'i' } }
] ]
} }
@@ -840,7 +916,6 @@ router.get('/feedback', requirePermission('feedback:read'), async (req, res) =>
hasImages: fb.images?.length > 0 hasImages: fb.images?.length > 0
})) }))
await logAction(req, 'read', 'feedback', null, { page, limit, status, type })
return ApiResponse.paginated(res, formattedFeedbacks, { return ApiResponse.paginated(res, formattedFeedbacks, {
page: parseInt(page), page: parseInt(page),
@@ -868,7 +943,6 @@ router.get('/feedback/:id', requirePermission('feedback:read'), async (req, res)
return ApiResponse.notFound(res, '反馈不存在') return ApiResponse.notFound(res, '反馈不存在')
} }
await logAction(req, 'read', 'feedback', req.params.id)
return ApiResponse.success(res, { return ApiResponse.success(res, {
id: feedback._id, id: feedback._id,
@@ -1050,6 +1124,7 @@ router.get('/login-logs', requirePermission('user:read'), async (req, res) => {
const formattedLogs = logs.map(log => ({ const formattedLogs = logs.map(log => ({
id: log._id, id: log._id,
userName: log.userId?.nickName || '未知用户', userName: log.userId?.nickName || '未知用户',
ipLoc: ipLocation(log.ip),
userAvatar: log.userId?.avatarUrl || '', userAvatar: log.userId?.avatarUrl || '',
type: log.type, type: log.type,
method: log.method, method: log.method,
@@ -1063,7 +1138,6 @@ router.get('/login-logs', requirePermission('user:read'), async (req, res) => {
createdAt: log.createdAt createdAt: log.createdAt
})) }))
await logAction(req, 'read', 'login-logs', null, { page, limit })
return ApiResponse.paginated(res, formattedLogs, { return ApiResponse.paginated(res, formattedLogs, {
page: parseInt(page), page: parseInt(page),
@@ -1083,18 +1157,74 @@ router.get('/login-logs', requirePermission('user:read'), async (req, res) => {
*/ */
router.get('/trends', requirePermission('trend:read'), async (req, res) => { router.get('/trends', requirePermission('trend:read'), async (req, res) => {
try { try {
const { page = 1, limit = 20, keyword = '' } = req.query const { page = 1, limit = 20, keyword = '', missing } = req.query
const query = keyword ? { title: { $regex: keyword, $options: 'i' } } : {} const query = keyword ? { title: { $regex: escapeRegExp(keyword), $options: 'i' } } : {}
if (missing === '1') query.plainExplain = { $in: ['', null, undefined] }
const total = await Trend.countDocuments(query) const total = await Trend.countDocuments(query)
const missingCount = await Trend.countDocuments({ plainExplain: { $in: ['', null, undefined] } })
const items = await Trend.find(query).sort({ newsDate: -1, createdAt: -1 }) const items = await Trend.find(query).sort({ newsDate: -1, createdAt: -1 })
.skip((parseInt(page) - 1) * parseInt(limit)).limit(parseInt(limit)).lean() .skip((parseInt(page) - 1) * parseInt(limit)).limit(parseInt(limit)).lean()
return ApiResponse.paginated(res, items, { page: parseInt(page), limit: parseInt(limit), total }) return res.status(200).json({
success: true,
message: '查询成功',
data: { items, missingCount, pagination: { page: parseInt(page), limit: parseInt(limit), total, totalPages: Math.ceil(total / parseInt(limit)) } },
})
} catch (error) { } catch (error) {
logger.error('获取趋势列表失败:', error) logger.error('获取趋势列表失败:', error)
return ApiResponse.serverError(res, '获取趋势列表失败') return ApiResponse.serverError(res, '获取趋势列表失败')
} }
}) })
// AI 批量补齐人话解读(复用 aiChat 的模型配置:数据库活跃模型或环境变量)
router.post('/trends/fill-plain', requirePermission('trend:write'), async (req, res) => {
try {
const limit = Math.min(30, parseInt(req.body?.limit) || 20)
const missing = await Trend.find({ plainExplain: { $in: ['', null, undefined] } })
.sort({ newsDate: -1 }).limit(limit).lean()
if (!missing.length) return ApiResponse.success(res, { filled: 0, total: 0 }, '没有待补条目')
// 取模型配置
const active = await AIModel.getActiveModels().catch(() => [])
const model = (active && active[0]) || {
apiUrl: process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions',
apiKey: process.env.OPENAI_API_KEY,
modelId: process.env.OPENAI_MODEL || 'gpt-3.5-turbo',
}
if (!model.apiKey) return ApiResponse.error(res, '未配置 AI 模型(系统设置→AI模型)', 400)
let filled = 0
for (const t of missing) {
try {
const prompt = `给下面这条AI行业新闻写两句中文解读。要求:1) 第一句"人话":用不超过40字的大白话说清这件事;2) 第二句"对你":说明对普通用户/从业者意味着什么,不超过40字。只输出两行,格式:人话内容\n对你内容\n\n新闻标题:${t.title}\n摘要:${(t.summary || '').slice(0, 200)}`
const r = await fetch(model.apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${model.apiKey}` },
body: JSON.stringify({ model: model.modelId, messages: [{ role: 'user', content: prompt }], max_tokens: 2000, temperature: 0.5 }),
signal: AbortSignal.timeout(90_000),
})
const j = await r.json()
const msg = j?.choices?.[0]?.message
// 推理型模型(flash-lite 系)正式答案在 content;思考占满时兜底取 reasoning 尾部
let text = String(msg?.content || '').trim()
if (!text && msg?.reasoning) {
const tail = String(msg.reasoning).split('\n').filter(Boolean).pop() || ''
text = tail.replace(/^["'“”]+|["'“”]+$/g, '').trim()
}
const lines = text.split('\n').map(x => x.replace(/^(人话|对你)[::]?\s*/, '').trim()).filter(Boolean)
if (lines.length >= 2) {
await Trend.updateOne({ _id: t._id }, { plainExplain: lines[0].slice(0, 60), takeAway: lines[1].slice(0, 60) })
filled++
}
} catch (e) { logger.warn('补齐失败:', t.title, e.message) }
}
const remain = await Trend.countDocuments({ plainExplain: { $in: ['', null, undefined] } })
return ApiResponse.success(res, { filled, remain }, `已补齐 ${filled} 条,剩余 ${remain} 条待补`)
} catch (error) {
logger.error('批量补齐失败:', error)
return ApiResponse.serverError(res, '批量补齐失败')
}
})
router.post('/trends', requirePermission('trend:write'), async (req, res) => { router.post('/trends', requirePermission('trend:write'), async (req, res) => {
try { try {
const { title, summary, source, sourceUrl, category, tags, hot, picked, newsDate, plainExplain, takeAway } = req.body const { title, summary, source, sourceUrl, category, tags, hot, picked, newsDate, plainExplain, takeAway } = req.body
@@ -1145,7 +1275,7 @@ router.get('/dashboard-v2', requirePermission('dashboard:read'), async (req, res
const [ const [
totalUsers, newUsers7d, totalKnowledge, approvedKnowledge, withQuiz, totalUsers, newUsers7d, totalKnowledge, approvedKnowledge, withQuiz,
totalTrends, trendsWithExplain, totalFeedbacks, pendingFeedbacks, totalOrders, paidOrders, totalTrends, trendsWithExplain, totalFeedbacks, pendingFeedbacks, totalOrders, paidOrders,
totalLits, litUsers, recentLogins, litByDimAgg, aiStatsRaw totalLits, litUsers, recentLogins, recentVisits, litByDimAgg, aiStatsRaw
] = await Promise.all([ ] = await Promise.all([
User.countDocuments({}), User.countDocuments({}),
User.countDocuments({ createdAt: { $gte: new Date(Date.now() - 7 * 86400000) } }), User.countDocuments({ createdAt: { $gte: new Date(Date.now() - 7 * 86400000) } }),
@@ -1160,7 +1290,8 @@ router.get('/dashboard-v2', requirePermission('dashboard:read'), async (req, res
Order.countDocuments({ status: 'paid' }), Order.countDocuments({ status: 'paid' }),
KnowledgeLit.countDocuments({}), KnowledgeLit.countDocuments({}),
KnowledgeLit.distinct('openid'), KnowledgeLit.distinct('openid'),
LoginLog.find({}).sort({ createdAt: -1 }).limit(10).lean(), LoginLog.find({}).sort({ createdAt: -1 }).limit(10).populate('userId', 'nickName').lean(),
VisitLog.find({}).sort({ createdAt: -1 }).limit(10).lean(),
KnowledgeLit.aggregate([{ $group: { _id: '$dim', count: { $sum: 1 } } }]), KnowledgeLit.aggregate([{ $group: { _id: '$dim', count: { $sum: 1 } } }]),
AIChatQuota.aggregate([{ $group: { _id: null, used: { $sum: '$usedQuota' }, users: { $sum: 1 } } }]), AIChatQuota.aggregate([{ $group: { _id: null, used: { $sum: '$usedQuota' }, users: { $sum: 1 } } }]),
]) ])
@@ -1172,6 +1303,35 @@ router.get('/dashboard-v2', requirePermission('dashboard:read'), async (req, res
const DIM_NAMES = { 1: 'AI 起源', 2: 'AI 发展', 3: 'AI 当前', 4: 'AI 学习', 5: 'AI 趋势' } const DIM_NAMES = { 1: 'AI 起源', 2: 'AI 发展', 3: 'AI 当前', 4: 'AI 学习', 5: 'AI 趋势' }
const ai = aiStatsRaw[0] || { used: 0, users: 0 } const ai = aiStatsRaw[0] || { used: 0, users: 0 }
// 合并最近访问记录(登录 + 访问埋点),统一附 IP 归属地
const merged = [
...recentLogins.map(l => ({
kind: 'login',
createdAt: l.createdAt,
userName: l.userId?.nickName || l.openid,
openid: l.openid,
type: l.type === 'register' ? '注册' : '登录',
method: l.method === 'wechat_miniapp' ? '微信小程序' : (l.method || ''),
ip: l.ip,
success: l.success,
failReason: l.failReason,
})),
...recentVisits.map(v => ({
kind: 'visit',
createdAt: v.createdAt,
userName: v.source,
openid: '',
type: '访问',
method: [v.os, v.browser].filter(Boolean).join(' / ') || v.device || '',
ip: v.ip,
path: v.path,
success: true,
})),
]
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
.slice(0, 10)
merged.forEach(r => { r.ipLoc = ipLocation(r.ip) })
return ApiResponse.success(res, { return ApiResponse.success(res, {
stats: { stats: {
totalUsers, newUsers7d, totalUsers, newUsers7d,
@@ -1182,16 +1342,7 @@ router.get('/dashboard-v2', requirePermission('dashboard:read'), async (req, res
aiChatUsed: ai.used, aiChatUsers: ai.users, aiChatUsed: ai.used, aiChatUsers: ai.users,
}, },
litByDim: Object.entries(litByDim).map(([dim, count]) => ({ dim: Number(dim), name: DIM_NAMES[dim], count })), litByDim: Object.entries(litByDim).map(([dim, count]) => ({ dim: Number(dim), name: DIM_NAMES[dim], count })),
recentLogins: recentLogins.map(l => ({ recentLogins: merged,
openid: l.openid,
type: l.type,
method: l.method,
ip: l.ip,
success: l.success,
failReason: l.failReason,
userAgent: (l.userAgent || '').slice(0, 80),
createdAt: l.createdAt,
})),
}) })
} catch (error) { } catch (error) {
logger.error('获取增强看板失败:', error) logger.error('获取增强看板失败:', error)
@@ -1201,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)
}
})
/**
*/ */
// 订单列表(含虚拟支付 + 旧订单) // 订单列表(含虚拟支付 + 旧订单)
@@ -1210,7 +1456,7 @@ router.get('/vpay/orders', requirePermission('orders:read'), async (req, res) =>
const { page = 1, limit = 20, status = '', keyword = '' } = req.query const { page = 1, limit = 20, status = '', keyword = '' } = req.query
const query = {} const query = {}
if (status) query.status = status if (status) query.status = status
if (keyword) query.orderNo = { $regex: keyword, $options: 'i' } if (keyword) query.orderNo = { $regex: escapeRegExp(keyword), $options: 'i' }
const total = await Order.countDocuments(query) const total = await Order.countDocuments(query)
const items = await Order.find(query) const items = await Order.find(query)
.sort({ createdAt: -1 }) .sort({ createdAt: -1 })
@@ -1257,17 +1503,6 @@ router.post('/vpay/orders/:id/refund', requirePermission('orders:write'), async
409, { blocked: 'expired' }) 409, { blocked: 'expired' })
} }
// 第三层:同一用户 30 天内最多 1 次退款
const recentRefund = await Order.findOne({
userId: user._id, status: 'refunded',
refundedAt: { $gte: new Date(Date.now() - 30 * 86400000) },
})
if (recentRefund) {
return ApiResponse.error(res,
`不可退款:该用户 30 天内已有退款记录(${recentRefund.orderNo})。每人每月限退 1 次。`,
409, { blocked: 'rate_limited', lastRefund: recentRefund.orderNo })
}
// ===== 冻结星尘(原子操作,防并发竞态) ===== // ===== 冻结星尘(原子操作,防并发竞态) =====
// findOneAndUpdate + $gte 条件 = 原子扣减: // findOneAndUpdate + $gte 条件 = 原子扣减:
// 如果在预检和这里之间用户消费了星尘导致余额不足,此操作会失败(返回 null) // 如果在预检和这里之间用户消费了星尘导致余额不足,此操作会失败(返回 null)
@@ -1382,7 +1617,7 @@ router.get('/vpay/ledger', requirePermission('orders:read'), async (req, res) =>
const { page = 1, limit = 20, type = '', keyword = '' } = req.query const { page = 1, limit = 20, type = '', keyword = '' } = req.query
const query = {} const query = {}
if (type) query.type = type if (type) query.type = type
if (keyword) query.openid = { $regex: keyword, $options: 'i' } if (keyword) query.openid = { $regex: escapeRegExp(keyword), $options: 'i' }
const total = await CoinLedger.countDocuments(query) const total = await CoinLedger.countDocuments(query)
const items = await CoinLedger.find(query) const items = await CoinLedger.find(query)
.sort({ createdAt: -1 }) .sort({ createdAt: -1 })
+7
View File
@@ -227,6 +227,13 @@ router.post('/share-gain', userAuthMiddleware, async (req, res) => {
quota = new AIChatQuota({ openid }) quota = new AIChatQuota({ openid })
} }
// [防刷] 每日分享奖励最多3次(基于 shareRecords 统计)
const dayStart = new Date(); dayStart.setHours(0, 0, 0, 0)
const todayShares = (quota.shareRecords || []).filter(r => new Date(r.sharedAt) >= dayStart).length
if (todayShares >= 3) {
return ApiResponse.error(res, '今日分享奖励已领完,明天再来吧', 429)
}
const remainingQuota = await quota.addQuotaByShare(5) const remainingQuota = await quota.addQuotaByShare(5)
return ApiResponse.success(res, { return ApiResponse.success(res, {
+1 -1
View File
@@ -22,7 +22,7 @@ router.get('/current', async (req, res) => {
name: '默认模型', name: '默认模型',
modelId: process.env.OPENAI_MODEL || 'gpt-3.5-turbo', modelId: process.env.OPENAI_MODEL || 'gpt-3.5-turbo',
apiUrl: process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions', apiUrl: process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions',
apiKey: process.env.OPENAI_API_KEY || '', apiKey: '', // [安全] 不向客户端下发密钥
config: { config: {
temperature: 0.7, temperature: 0.7,
maxTokens: 800, maxTokens: 800,
+118 -59
View File
@@ -1,10 +1,25 @@
const express = require('express') const express = require('express')
const router = express.Router() const router = express.Router()
/**
* 生成默认昵称:星辰旅行者 + 序号(1001 起步,跳过已占用,参考画屿画友1001 方案)
*/
async function genDefaultNickName() {
const used = new Set(
(await User.find({ nickName: /^星辰旅行者\d+$/ }).select('nickName').lean())
.map(u => parseInt(String(u.nickName).replace('星辰旅行者', ''), 10))
.filter(n => !isNaN(n))
)
let n = 1001
while (used.has(n)) n++
return `星辰旅行者${n}`
}
const jwt = require('jsonwebtoken') const jwt = require('jsonwebtoken')
const { User, Admin, AdminLog, LoginLog, VerificationCode } = require('../models') const { User, Admin, AdminLog, LoginLog, VerificationCode } = require('../models')
const ApiResponse = require('../utils/response') const ApiResponse = require('../utils/response')
const weChatService = require('../utils/weixin') const weChatService = require('../utils/weixin')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const { userAuthMiddleware } = require('../middleware/auth')
const { sendVerificationCode } = require('../utils/mail') const { sendVerificationCode } = require('../utils/mail')
const axios = require('axios') const axios = require('axios')
@@ -107,7 +122,7 @@ router.post('/login', async (req, res) => {
// 新用户 // 新用户
user = new User({ user = new User({
openid, openid,
nickName: userInfo?.nickName || '星辰旅行者', nickName: userInfo?.nickName || (await genDefaultNickName()),
avatarUrl: userInfo?.avatarUrl || '', avatarUrl: userInfo?.avatarUrl || '',
unlockedDims: [1] unlockedDims: [1]
}) })
@@ -195,7 +210,7 @@ router.post('/webview-login', async (req, res) => {
// 新用户 - 使用微信提供的信息创建账户 // 新用户 - 使用微信提供的信息创建账户
user = new User({ user = new User({
openid, openid,
nickName: userInfo?.nickName || '星辰旅行者', nickName: userInfo?.nickName || (await genDefaultNickName()),
avatarUrl: userInfo?.avatarUrl || '', avatarUrl: userInfo?.avatarUrl || '',
unlockedDims: [1] unlockedDims: [1]
}) })
@@ -319,7 +334,7 @@ router.post('/user/login', async (req, res) => {
try { try {
const { username, email, password } = req.body const { username, email, password } = req.body
// 支持邮箱或用户名登录 // 支持邮箱或用户名登录
const identifier = email || username const identifier = String(email || username || '') // [安全] 强转防对象注入 $ne
// 查找用户(显式包含密码字段) // 查找用户(显式包含密码字段)
const user = await User.findOne({ const user = await User.findOne({
@@ -426,8 +441,9 @@ router.post('/user/login', async (req, res) => {
*/ */
router.post('/user/register', async (req, res) => { router.post('/user/register', async (req, res) => {
try { try {
const { username, email, password, nickName, avatarUrl, verifyCode } = req.body const { username, email, password, nickName, avatarUrl } = req.body
const identifier = email || username const verifyCode = String(req.body?.verifyCode || '') // [安全] 强转防注入
const identifier = String(email || username || '') // [安全] 强转防对象注入 $ne
if (!identifier) { if (!identifier) {
return ApiResponse.error(res, '请输入邮箱或用户名', 400) return ApiResponse.error(res, '请输入邮箱或用户名', 400)
@@ -479,7 +495,7 @@ router.post('/user/register', async (req, res) => {
username: finalUsername, username: finalUsername,
email: email || '', email: email || '',
password, password,
nickName: nickName || '星辰旅行者', nickName: nickName || (await genDefaultNickName()),
avatarUrl: avatarUrl || '', avatarUrl: avatarUrl || '',
unlockedDims: [1] unlockedDims: [1]
}) })
@@ -539,64 +555,107 @@ 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
*/
router.put('/nickname', async (req, res) => {
try {
const token = req.header('Authorization')?.replace('Bearer ', '')
if (!token) return ApiResponse.unauthorized(res)
const decoded = jwt.verify(token, process.env.JWT_SECRET)
const nickName = String(req.body?.nickName || '').trim().slice(0, 20)
if (!nickName) return ApiResponse.error(res, '昵称不能为空', 400)
const user = await User.findByIdAndUpdate(decoded.userId, { nickName }, { new: true })
if (!user) return ApiResponse.notFound(res, '用户不存在')
return ApiResponse.success(res, { nickName: user.nickName }, '昵称已更新')
} catch (error) {
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
return ApiResponse.unauthorized(res, '认证令牌无效或已过期')
}
logger.error('修改昵称失败:', error)
return ApiResponse.serverError(res, error.message)
}
})
/** /**
* WebView Token 交换接口 * WebView Token 交换接口
* POST /api/auth/webview-token * POST /api/auth/webview-token
* 用于小程序 WebView 内嵌 H5 时,通过 openid 获取登录 token * 用于小程序 WebView 内嵌 H5 时,通过 openid 获取登录 token
* 小程序在 URL 中传递 openid,H5 用这个接口换取 token * 小程序在 URL 中传递 openid,H5 用这个接口换取 token
*/ */
router.post('/webview-token', async (req, res) => { // [安全下线 2026-09-19] 该接口仅凭 openid 即可换任意用户 token(配合公开 openid 列表=账号接管)。客户端已确认无调用,如需 H5 登录用 code 换 token 方案。
try { /* 已下线:router.post('/webview-token', async (req, res) => {
const { openid } = req.body // try {
// const { openid } = req.body
if (!openid) { //
return ApiResponse.error(res, '缺少用户标识', 400) // if (!openid) {
} // return ApiResponse.error(res, '缺少用户标识', 400)
// }
// 查找用户 //
const user = await User.findOne({ openid }) // // 查找用户
// const user = await User.findOne({ openid })
if (!user) { //
return ApiResponse.error(res, '用户不存在', 404) // if (!user) {
} // return ApiResponse.error(res, '用户不存在', 404)
// }
// 生成新的 JWT token //
const token = jwt.sign( // // 生成新的 JWT token
{ openid, userId: user._id }, // const token = jwt.sign(
process.env.JWT_SECRET, // { openid, userId: user._id },
{ expiresIn: process.env.JWT_EXPIRE } // process.env.JWT_SECRET,
) // { expiresIn: process.env.JWT_EXPIRE }
// )
// 更新最后登录时间 //
user.lastLoginAt = new Date() // // 更新最后登录时间
await user.save() // user.lastLoginAt = new Date()
// await user.save()
// 记录登录日志 //
await recordLoginLog( // // 记录登录日志
user._id, // await recordLoginLog(
openid, // user._id,
'auto_login', // openid,
'wechat_webview', // 'auto_login',
'webview_h5', // 'wechat_webview',
req, // 'webview_h5',
true // req,
) // true
// )
logger.info(`WebView Token交换成功: ${openid}`) //
// logger.info(`WebView Token交换成功: ${openid}`)
return ApiResponse.success(res, { //
token, // return ApiResponse.success(res, {
openid, // token,
userInfo: { // openid,
nickName: user.nickName, // userInfo: {
avatarUrl: user.avatarUrl // nickName: user.nickName,
} // avatarUrl: user.avatarUrl
}, '登录成功') // }
// }, '登录成功')
} catch (error) { //
logger.error('WebView Token交换失败:', error) // } catch (error) {
return ApiResponse.serverError(res, error.message) // logger.error('WebView Token交换失败:', error)
} // return ApiResponse.serverError(res, error.message)
}) // }
// })
//
*/
module.exports = router module.exports = router
+10 -4
View File
@@ -5,6 +5,7 @@ const { Knowledge, KnowledgeLit } = require('../models')
const ApiResponse = require('../utils/response') const ApiResponse = require('../utils/response')
const { userAuthMiddleware, optionalAuthMiddleware } = require('../middleware/auth') const { userAuthMiddleware, optionalAuthMiddleware } = require('../middleware/auth')
const logger = require('../utils/logger') const logger = require('../utils/logger')
const { escapeRegExp } = require('../utils/escapeRegex')
const isValidId = (id) => mongoose.Types.ObjectId.isValid(id) const isValidId = (id) => mongoose.Types.ObjectId.isValid(id)
@@ -41,9 +42,9 @@ router.get('/search', async (req, res) => {
const query = { const query = {
status: 'approved', status: 'approved',
$or: [ $or: [
{ title: { $regex: q, $options: 'i' } }, { title: { $regex: escapeRegExp(q), $options: 'i' } },
{ content: { $regex: q, $options: 'i' } }, { content: { $regex: escapeRegExp(q), $options: 'i' } },
{ tags: { $regex: q, $options: 'i' } } { tags: { $regex: escapeRegExp(q), $options: 'i' } }
] ]
} }
@@ -330,7 +331,12 @@ router.get('/:dim/questions', async (req, res) => {
.limit(parseInt(limit)) .limit(parseInt(limit))
.lean() .lean()
return ApiResponse.success(res, questions) // 脱敏:不下发 isCorrect(答案)
return ApiResponse.success(res, questions.map(q => {
const o = (q.toObject ? q.toObject() : q)
if (Array.isArray(o.options)) o.options = o.options.map(op => ({ text: op.text, index: op.index }))
return o
}))
} catch (error) { } catch (error) {
logger.error('获取问答失败:', error) logger.error('获取问答失败:', error)
return ApiResponse.serverError(res, '获取问答失败') return ApiResponse.serverError(res, '获取问答失败')
+31 -6
View File
@@ -32,12 +32,18 @@ router.get('/robots.txt', (req, res) => {
'Allow: /', 'Allow: /',
'Disallow: /api/', 'Disallow: /api/',
'', '',
'User-agent: GPTBot', '# AI 引擎与搜索爬虫(SEO/GEO):全量欢迎抓取',
'Allow: /', ...[
'User-agent: ClaudeBot', // 国际 AI 引擎
'Allow: /', 'GPTBot', 'OAI-SearchBot', 'ChatGPT-User',
'User-agent: PerplexityBot', 'ClaudeBot', 'Claude-User', 'anthropic-ai',
'Allow: /', 'PerplexityBot', 'Perplexity-User',
'Googlebot', 'Google-Extended',
'Bingbot', 'Applebot', 'Applebot-Extended',
'DuckAssistBot', 'Amazonbot', 'meta-externalagent', 'YouBot',
// 国内 AI 引擎与传统搜索
'Bytespider', 'Baiduspider', '360Spider', 'Sogou web spider', 'YisouSpider',
].flatMap((ua) => ['User-agent: ' + ua, 'Allow: /', 'Disallow: /api/', '']),
'', '',
`Sitemap: ${o}/sitemap.xml`, `Sitemap: ${o}/sitemap.xml`,
'', '',
@@ -65,6 +71,25 @@ router.get('/sitemap.xml', async (req, res) => {
}) })
// 面向百度的纯净 sitemap(不含 llms.txt 等非页面资源,避免被判为索引型)
router.get('/sitemap-baidu.xml', async (req, res) => {
const o = origin(req)
const today = new Date().toISOString().slice(0, 10)
let trendUrls = ''
try {
const items = await Trend.find({}).sort({ newsDate: -1 }).limit(20).lean()
trendUrls = items.map((t) => ` <url><loc>${o}/trends/${t._id}</loc><lastmod>${(t.newsDate || t.createdAt || new Date()).toISOString().slice(0, 10)}</lastmod><changefreq>daily</changefreq></url>`).join('\n')
} catch (e) {}
res.type('application/xml').send(
`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n` +
[` <url><loc>${o}/</loc><lastmod>${today}</lastmod><changefreq>daily</changefreq></url>`,
` <url><loc>${o}/trends</loc><lastmod>${today}</lastmod><changefreq>daily</changefreq></url>`,
` <url><loc>${o}/about</loc><changefreq>monthly</changefreq></url>`].join('\n') +
(trendUrls ? '\n' + trendUrls : '') +
`\n</urlset>`
)
})
// ===== SSR 趋势页(SEO/GEO:真实内容直出,搜索引擎可收录) ===== // ===== SSR 趋势页(SEO/GEO:真实内容直出,搜索引擎可收录) =====
const TREND_PAGE_CSS = 'body{font-family:-apple-system,"PingFang SC",sans-serif;color:#1f2333;max-width:720px;margin:0 auto;padding:24px 18px;line-height:1.7}h1{font-size:22px}.meta{color:#9aa0b4;font-size:12px;margin:4px 0 20px}a{color:#5b5ff8;text-decoration:none}.item{padding:14px 0;border-bottom:1px solid #eef0f6}.item h2{font-size:16px;margin:0 0 6px}.item p{margin:0;font-size:13px;color:#6b7080}.item .m{font-size:11px;color:#a6abbb;margin-top:4px}.tag{display:inline-block;background:rgba(91,95,248,.1);color:#5b5ff8;border-radius:99px;padding:1px 8px;font-size:11px;margin-right:6px}.foot{margin-top:28px;color:#b6bac9;font-size:12px}.plain{background:#f5f5ff;border-left:3px solid #5b5ff8;padding:8px 12px;margin:8px 0;font-size:14px;color:#3a3f55}.plainbox{background:#f5f5ff;border-left:3px solid #5b5ff8;padding:10px 14px;border-radius:0 8px 8px 0;margin:14px 0}.plainbox p{margin:4px 0;font-size:14px;color:#3a3f55}' const TREND_PAGE_CSS = 'body{font-family:-apple-system,"PingFang SC",sans-serif;color:#1f2333;max-width:720px;margin:0 auto;padding:24px 18px;line-height:1.7}h1{font-size:22px}.meta{color:#9aa0b4;font-size:12px;margin:4px 0 20px}a{color:#5b5ff8;text-decoration:none}.item{padding:14px 0;border-bottom:1px solid #eef0f6}.item h2{font-size:16px;margin:0 0 6px}.item p{margin:0;font-size:13px;color:#6b7080}.item .m{font-size:11px;color:#a6abbb;margin-top:4px}.tag{display:inline-block;background:rgba(91,95,248,.1);color:#5b5ff8;border-radius:99px;padding:1px 8px;font-size:11px;margin-right:6px}.foot{margin-top:28px;color:#b6bac9;font-size:12px}.plain{background:#f5f5ff;border-left:3px solid #5b5ff8;padding:8px 12px;margin:8px 0;font-size:14px;color:#3a3f55}.plainbox{background:#f5f5ff;border-left:3px solid #5b5ff8;padding:10px 14px;border-radius:0 8px 8px 0;margin:14px 0}.plainbox p{margin:4px 0;font-size:14px;color:#3a3f55}'
+395 -398
View File
@@ -1,398 +1,395 @@
const express = require('express') const express = require('express')
const router = express.Router() const router = express.Router()
const { User } = require('../models') const { User } = require('../models')
const ApiResponse = require('../utils/response') const ApiResponse = require('../utils/response')
const { userAuthMiddleware } = require('../middleware/auth') const { userAuthMiddleware } = require('../middleware/auth')
const logger = require('../utils/logger') const logger = require('../utils/logger')
/** /**
* 获取排行榜(公开接口,无需登录) * 获取排行榜(公开接口,无需登录)
* GET /api/user/leaderboard * GET /api/user/leaderboard
*/ */
router.get('/leaderboard', async (req, res) => { router.get('/leaderboard', async (req, res) => {
try { try {
const { limit = 50, dim } = req.query const limit = Math.min(100, parseInt(req.query.limit) || 50) // 封顶防拖库
const dim = req.query.dim
const users = await User.getLeaderboard(dim ? parseInt(dim) : null, parseInt(limit))
const users = await User.getLeaderboard(dim ? parseInt(dim) : null, limit)
const data = users.map((u, i) => ({
rank: i + 1, const data = users.map((u, i) => ({
openid: u.openid, rank: i + 1,
nickname: u.nickName || '星辰旅者', // [安全] 不再下发 openid(配合已下线的 webview-token 防接管;昵称已足够展示)
avatar: u.avatarUrl || '', nickname: u.nickName || '星辰旅者',
totalScore: u.totalScore || 0, avatar: u.avatarUrl || '',
dim1Score: u.dim1Score || 0, totalScore: u.totalScore || 0,
dim2Score: u.dim2Score || 0, dim1Score: u.dim1Score || 0,
dim3Score: u.dim3Score || 0, dim2Score: u.dim2Score || 0,
dim4Score: u.dim4Score || 0, dim3Score: u.dim3Score || 0,
dim5Score: u.dim5Score || 0, dim4Score: u.dim4Score || 0,
unlockedDims: u.unlockedDims || [1] dim5Score: u.dim5Score || 0,
})) unlockedDims: u.unlockedDims || [1]
}))
return ApiResponse.success(res, data)
return ApiResponse.success(res, data)
} catch (error) {
logger.error('获取排行榜失败:', error) } catch (error) {
return ApiResponse.serverError(res, error.message) logger.error('获取排行榜失败:', error)
} return ApiResponse.serverError(res, error.message)
}) }
})
// 应用用户认证中间件到所有路由(排行榜已在上方公开)
router.use(userAuthMiddleware) // 应用用户认证中间件到所有路由(排行榜已在上方公开)
router.use(userAuthMiddleware)
/**
* 获取用户进度 /**
* GET /api/user/progress * 获取用户进度
*/ * GET /api/user/progress
router.get('/progress', async (req, res) => { */
try { router.get('/progress', async (req, res) => {
const user = req.user try {
const user = req.user
if (!user) {
// 新用户,返回默认进度 if (!user) {
return ApiResponse.success(res, { // 新用户,返回默认进度
isNewUser: true, return ApiResponse.success(res, {
data: { isNewUser: true,
unlockedDims: [1], data: {
dim1Score: 0, unlockedDims: [1],
dim2Score: 0, dim1Score: 0,
dim3Score: 0, dim2Score: 0,
dim4Score: 0, dim3Score: 0,
dim5Score: 0, dim4Score: 0,
totalScore: 0, dim5Score: 0,
ownedSkins: [], totalScore: 0,
isAdFree: false, ownedSkins: [],
isSubscriber: false, isAdFree: false,
subscribeExpiry: null, isSubscriber: false,
totalPlayTime: 0 subscribeExpiry: null,
} totalPlayTime: 0
}) }
} })
}
return ApiResponse.success(res, {
isNewUser: false, return ApiResponse.success(res, {
data: user isNewUser: false,
}) data: user
})
} catch (error) {
logger.error('获取进度失败:', error) } catch (error) {
return ApiResponse.serverError(res, error.message) logger.error('获取进度失败:', error)
} return ApiResponse.serverError(res, error.message)
}) }
})
/**
* 保存用户进度 /**
* POST /api/user/progress * 保存用户进度
*/ * POST /api/user/progress
router.post('/progress', async (req, res) => { */
try { router.post('/progress', async (req, res) => {
const user = req.user try {
const openid = user.openid const user = req.user
const openid = user.openid
const {
dim, const {
data, dim,
dim1Score, data,
dim2Score, dim1Score,
dim3Score, dim2Score,
dim4Score, dim3Score,
dim5Score, dim4Score,
unlockedDims, dim5Score,
ownedSkins, unlockedDims,
isAdFree, ownedSkins,
isSubscriber, isAdFree,
subscribeExpiry, isSubscriber,
totalPlayTime subscribeExpiry,
} = req.body totalPlayTime
} = req.body
// 查找或创建用户(理论上已通过中间件找到)
let dbUser = user // 查找或创建用户(理论上已通过中间件找到)
let dbUser = user
// 新版接口:按维度更新
if (dim && data) { // 新版接口:按维度更新
const dimData = dbUser.exploreData[dim] || {} if (dim && data) {
let updatedDim = { ...dimData } const dimData = dbUser.exploreData[dim] || {}
let updatedDim = { ...dimData }
if (dim === 'dim1') {
updatedDim.completed = data.completed || dimData.completed || false if (dim === 'dim1') {
updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0) updatedDim.completed = data.completed || dimData.completed || false
updatedDim.collectedEggs = (dimData.collectedEggs || 0) + (data.newEggs || 0) updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0)
updatedDim.totalLength = Math.max(data.totalLength || 0, dimData.totalLength || 0) updatedDim.collectedEggs = (dimData.collectedEggs || 0) + (data.newEggs || 0)
} else if (dim === 'dim2') { updatedDim.totalLength = Math.max(data.totalLength || 0, dimData.totalLength || 0)
updatedDim.completed = data.completed || dimData.completed || false } else if (dim === 'dim2') {
updatedDim.bestArea = Math.max(data.bestArea || 0, dimData.bestArea || 0) updatedDim.completed = data.completed || dimData.completed || false
updatedDim.createdShapes = (dimData.createdShapes || 0) + (data.newShapes || 0) updatedDim.bestArea = Math.max(data.bestArea || 0, dimData.bestArea || 0)
} else if (dim === 'dim3') { updatedDim.createdShapes = (dimData.createdShapes || 0) + (data.newShapes || 0)
updatedDim.completed = data.completed || dimData.completed || false } else if (dim === 'dim3') {
updatedDim.exploredFaces = Math.max(data.exploredFaces || 0, dimData.exploredFaces || 0) updatedDim.completed = data.completed || dimData.completed || false
updatedDim.rotationTime = (dimData.rotationTime || 0) + (data.rotationTime || 0) updatedDim.exploredFaces = Math.max(data.exploredFaces || 0, dimData.exploredFaces || 0)
updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0) updatedDim.rotationTime = (dimData.rotationTime || 0) + (data.rotationTime || 0)
} else if (dim === 'dim4') { updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0)
updatedDim.completed = data.completed || dimData.completed || false } else if (dim === 'dim4') {
updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0) updatedDim.completed = data.completed || dimData.completed || false
updatedDim.exploredEvents = (dimData.exploredEvents || 0) + (data.newEvents || 0) updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0)
} else if (dim === 'dim5') { updatedDim.exploredEvents = (dimData.exploredEvents || 0) + (data.newEvents || 0)
updatedDim.completed = data.completed || dimData.completed || false } else if (dim === 'dim5') {
updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0) updatedDim.completed = data.completed || dimData.completed || false
updatedDim.exploredThoughts = (dimData.exploredThoughts || 0) + (data.newThoughts || 0) updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0)
} updatedDim.exploredThoughts = (dimData.exploredThoughts || 0) + (data.newThoughts || 0)
}
dbUser.exploreData[dim] = updatedDim
dbUser.exploreData[dim] = updatedDim
// 同步更新已解锁维度
if (data.completed && !dbUser.unlockedDims.includes(parseInt(dim.replace('dim', '')) + 1)) { // 同步更新已解锁维度
const dimNum = parseInt(dim.replace('dim', '')) if (data.completed && !dbUser.unlockedDims.includes(parseInt(dim.replace('dim', '')) + 1)) {
if (!dbUser.unlockedDims.includes(dimNum + 1)) { const dimNum = parseInt(dim.replace('dim', ''))
dbUser.unlockedDims.push(dimNum + 1) if (!dbUser.unlockedDims.includes(dimNum + 1)) {
} dbUser.unlockedDims.push(dimNum + 1)
} }
}
if (totalPlayTime !== null && totalPlayTime !== undefined) {
dbUser.totalPlayTime += totalPlayTime if (totalPlayTime !== null && totalPlayTime !== undefined) {
} dbUser.totalPlayTime += totalPlayTime
} }
}
// 兼容旧版接口:直接传各维度分数
if (dim1Score !== null && dim1Score !== undefined) { // 兼容旧版接口:直接传各维度分数
dbUser.exploreData.dim1.bestScore = Math.max(dim1Score, dbUser.exploreData.dim1?.bestScore || 0) if (dim1Score !== null && dim1Score !== undefined) {
} dbUser.exploreData.dim1.bestScore = Math.max(dim1Score, dbUser.exploreData.dim1?.bestScore || 0)
if (dim2Score !== null && dim2Score !== undefined) { }
dbUser.exploreData.dim2.bestArea = Math.max(dim2Score, dbUser.exploreData.dim2?.bestArea || 0) if (dim2Score !== null && dim2Score !== undefined) {
} dbUser.exploreData.dim2.bestArea = Math.max(dim2Score, dbUser.exploreData.dim2?.bestArea || 0)
if (dim3Score !== null && dim3Score !== undefined) { }
dbUser.exploreData.dim3.bestScore = Math.max(dim3Score, dbUser.exploreData.dim3?.bestScore || 0) if (dim3Score !== null && dim3Score !== undefined) {
} dbUser.exploreData.dim3.bestScore = Math.max(dim3Score, dbUser.exploreData.dim3?.bestScore || 0)
if (dim4Score !== null && dim4Score !== undefined) { }
dbUser.exploreData.dim4 = dbUser.exploreData.dim4 || {} if (dim4Score !== null && dim4Score !== undefined) {
dbUser.exploreData.dim4.bestScore = Math.max(dim4Score, dbUser.exploreData.dim4?.bestScore || 0) dbUser.exploreData.dim4 = dbUser.exploreData.dim4 || {}
} dbUser.exploreData.dim4.bestScore = Math.max(dim4Score, dbUser.exploreData.dim4?.bestScore || 0)
if (dim5Score !== null && dim5Score !== undefined) { }
dbUser.exploreData.dim5 = dbUser.exploreData.dim5 || {} if (dim5Score !== null && dim5Score !== undefined) {
dbUser.exploreData.dim5.bestScore = Math.max(dim5Score, dbUser.exploreData.dim5?.bestScore || 0) dbUser.exploreData.dim5 = dbUser.exploreData.dim5 || {}
} dbUser.exploreData.dim5.bestScore = Math.max(dim5Score, dbUser.exploreData.dim5?.bestScore || 0)
}
// 更新其他字段
if (ownedSkins !== null && ownedSkins !== undefined) dbUser.ownedSkins = ownedSkins // [安全] 付费权益字段(ownedSkins/isAdFree/isSubscriber/subscribeExpiry)不接受客户端写入,仅支付回调/管理员可改
if (isAdFree !== null && isAdFree !== undefined) dbUser.isAdFree = isAdFree
if (isSubscriber !== null && isSubscriber !== undefined) dbUser.isSubscriber = isSubscriber await dbUser.save()
if (subscribeExpiry !== null && subscribeExpiry !== undefined) dbUser.subscribeExpiry = subscribeExpiry
// 计算总分
await dbUser.save() const totalScore = dbUser.calculateTotalScore()
// 计算总分 return ApiResponse.success(res, {
const totalScore = dbUser.calculateTotalScore() success: true,
totalScore,
return ApiResponse.success(res, { exploreData: user.exploreData
success: true, })
totalScore,
exploreData: user.exploreData } catch (error) {
}) logger.error('保存进度失败:', error)
return ApiResponse.serverError(res, error.message)
} catch (error) { }
logger.error('保存进度失败:', error) })
return ApiResponse.serverError(res, error.message)
} /**
}) * 获取用户成就
* GET /api/user/achievements
/** */
* 获取用户成就 router.get('/achievements', async (req, res) => {
* GET /api/user/achievements try {
*/ const user = req.user
router.get('/achievements', async (req, res) => {
try { // 生成成就数据(AI 维度主题)
const user = req.user const achievements = [
{
// 生成成就数据(AI 维度主题) name: '知识探索',
const achievements = [ achievements: [
{ {
name: '知识探索', id: 'dim1_read',
achievements: [ name: 'AI起源探索者',
{ description: '完成 AI 起源维度的知识阅读',
id: 'dim1_read', icon: '📜',
name: 'AI起源探索者', unlocked: user.exploreData.dim1?.completed || false,
description: '完成 AI 起源维度的知识阅读', date: user.exploreData.dim1?.completed ? user.updatedAt.toISOString().split('T')[0] : null
icon: '📜', },
unlocked: user.exploreData.dim1?.completed || false, {
date: user.exploreData.dim1?.completed ? user.updatedAt.toISOString().split('T')[0] : null id: 'dim2_read',
}, name: 'AI发展探索者',
{ description: '完成 AI 发展维度的知识阅读',
id: 'dim2_read', icon: '🏛',
name: 'AI发展探索者', unlocked: user.exploreData.dim2?.completed || false,
description: '完成 AI 发展维度的知识阅读', date: user.exploreData.dim2?.completed ? user.updatedAt.toISOString().split('T')[0] : null
icon: '🏛', },
unlocked: user.exploreData.dim2?.completed || false, {
date: user.exploreData.dim2?.completed ? user.updatedAt.toISOString().split('T')[0] : null id: 'dim3_read',
}, name: 'AI当前探索者',
{ description: '完成 AI 当前格局维度的知识阅读',
id: 'dim3_read', icon: '♟',
name: 'AI当前探索者', unlocked: user.exploreData.dim3?.completed || false,
description: '完成 AI 当前格局维度的知识阅读', date: user.exploreData.dim3?.completed ? user.updatedAt.toISOString().split('T')[0] : null
icon: '♟', },
unlocked: user.exploreData.dim3?.completed || false, {
date: user.exploreData.dim3?.completed ? user.updatedAt.toISOString().split('T')[0] : null id: 'dim4_read',
}, name: 'AI学习探索者',
{ description: '完成 AI 学习与工具维度的知识阅读',
id: 'dim4_read', icon: '🧠',
name: 'AI学习探索者', unlocked: user.exploreData.dim4?.completed || false,
description: '完成 AI 学习与工具维度的知识阅读', date: user.exploreData.dim4?.completed ? user.updatedAt.toISOString().split('T')[0] : null
icon: '🧠', },
unlocked: user.exploreData.dim4?.completed || false, {
date: user.exploreData.dim4?.completed ? user.updatedAt.toISOString().split('T')[0] : null id: 'dim5_read',
}, name: 'AI趋势探索者',
{ description: '完成 AI 趋势追踪维度的知识阅读',
id: 'dim5_read', icon: '⚡',
name: 'AI趋势探索者', unlocked: user.exploreData.dim5?.completed || false,
description: '完成 AI 趋势追踪维度的知识阅读', date: user.exploreData.dim5?.completed ? user.updatedAt.toISOString().split('T')[0] : null
icon: '⚡', }
unlocked: user.exploreData.dim5?.completed || false, ]
date: user.exploreData.dim5?.completed ? user.updatedAt.toISOString().split('T')[0] : null },
} {
] name: '收藏成就',
}, achievements: [
{ {
name: '收藏成就', id: 'first_collection',
achievements: [ name: '初次收藏',
{ description: '收藏你的第一个 AI 知识',
id: 'first_collection', icon: '❤️',
name: '初次收藏', unlocked: user.collectedKnowledge?.length > 0,
description: '收藏你的第一个 AI 知识', date: user.collectedKnowledge?.length > 0 ? user.updatedAt.toISOString().split('T')[0] : null
icon: '❤️', },
unlocked: user.collectedKnowledge?.length > 0, {
date: user.collectedKnowledge?.length > 0 ? user.updatedAt.toISOString().split('T')[0] : null id: 'five_collections',
}, name: '收藏爱好者',
{ description: '收藏5个 AI 知识',
id: 'five_collections', icon: '🧡',
name: '收藏爱好者', unlocked: user.collectedKnowledge?.length >= 5,
description: '收藏5个 AI 知识', date: user.collectedKnowledge?.length >= 5 ? user.updatedAt.toISOString().split('T')[0] : null
icon: '🧡', },
unlocked: user.collectedKnowledge?.length >= 5, {
date: user.collectedKnowledge?.length >= 5 ? user.updatedAt.toISOString().split('T')[0] : null id: 'ten_collections',
}, name: '收藏大师',
{ description: '收藏10个 AI 知识',
id: 'ten_collections', icon: '💛',
name: '收藏大师', unlocked: user.collectedKnowledge?.length >= 10,
description: '收藏10个 AI 知识', date: user.collectedKnowledge?.length >= 10 ? user.updatedAt.toISOString().split('T')[0] : null
icon: '💛', }
unlocked: user.collectedKnowledge?.length >= 10, ]
date: user.collectedKnowledge?.length >= 10 ? user.updatedAt.toISOString().split('T')[0] : null },
} {
] name: 'AI 问答',
}, achievements: [
{ {
name: 'AI 问答', id: 'first_ask',
achievements: [ name: '初次提问',
{ description: '向 AI 助手提出第一个问题',
id: 'first_ask', icon: '🤖',
name: '初次提问', unlocked: user.achievements?.includes('first_ask') || false,
description: '向 AI 助手提出第一个问题', date: user.achievements?.includes('first_ask') ? user.updatedAt.toISOString().split('T')[0] : null
icon: '🤖', },
unlocked: user.achievements?.includes('first_ask') || false, {
date: user.achievements?.includes('first_ask') ? user.updatedAt.toISOString().split('T')[0] : null id: 'ten_asks',
}, name: '勤学好问',
{ description: '向 AI 助手提出 10 个问题',
id: 'ten_asks', icon: '💡',
name: '勤学好问', unlocked: user.achievements?.includes('ten_asks') || false,
description: '向 AI 助手提出 10 个问题', date: user.achievements?.includes('ten_asks') ? user.updatedAt.toISOString().split('T')[0] : null
icon: '💡', },
unlocked: user.achievements?.includes('ten_asks') || false, {
date: user.achievements?.includes('ten_asks') ? user.updatedAt.toISOString().split('T')[0] : null id: 'fifty_asks',
}, name: 'AI 达人',
{ description: '向 AI 助手提出 50 个问题',
id: 'fifty_asks', icon: '🧠',
name: 'AI 达人', unlocked: user.achievements?.includes('fifty_asks') || false,
description: '向 AI 助手提出 50 个问题', date: user.achievements?.includes('fifty_asks') ? user.updatedAt.toISOString().split('T')[0] : null
icon: '🧠', }
unlocked: user.achievements?.includes('fifty_asks') || false, ]
date: user.achievements?.includes('fifty_asks') ? user.updatedAt.toISOString().split('T')[0] : null },
} {
] name: '社交成就',
}, achievements: [
{ {
name: '社交成就', id: 'first_share',
achievements: [ name: '初次分享',
{ description: '分享你的第一个知识给朋友',
id: 'first_share', icon: '📤',
name: '初次分享', unlocked: user.achievements?.includes('first_share') || false,
description: '分享你的第一个知识给朋友', date: user.achievements?.includes('first_share') ? user.updatedAt.toISOString().split('T')[0] : null
icon: '📤', },
unlocked: user.achievements?.includes('first_share') || false, {
date: user.achievements?.includes('first_share') ? user.updatedAt.toISOString().split('T')[0] : null id: 'five_shares',
}, name: '分享达人',
{ description: '分享 5 次知识给朋友',
id: 'five_shares', icon: '📣',
name: '分享达人', unlocked: user.achievements?.includes('five_shares') || false,
description: '分享 5 次知识给朋友', date: user.achievements?.includes('five_shares') ? user.updatedAt.toISOString().split('T')[0] : null
icon: '📣', }
unlocked: user.achievements?.includes('five_shares') || false, ]
date: user.achievements?.includes('five_shares') ? user.updatedAt.toISOString().split('T')[0] : null }
} ]
]
} return ApiResponse.success(res, achievements)
]
} catch (error) {
return ApiResponse.success(res, achievements) logger.error('获取成就失败:', error)
return ApiResponse.serverError(res, error.message)
} catch (error) { }
logger.error('获取成就失败:', error) })
return ApiResponse.serverError(res, error.message)
} /**
}) * 获取用户统计数据
* GET /api/user/stats
/** */
* 获取用户统计数据 router.get('/stats', async (req, res) => {
* GET /api/user/stats try {
*/ const user = req.user
router.get('/stats', async (req, res) => {
try { // 获取等级信息
const user = req.user const levelInfo = user.getLevelInfo()
// 获取等级信息 // 计算探索时长(分钟转小时,保留1位小数)
const levelInfo = user.getLevelInfo() const playTimeHours = Math.round((user.totalPlayTime || 0) / 60 * 10) / 10
// 计算探索时长(分钟转小时,保留1位小数) return ApiResponse.success(res, {
const playTimeHours = Math.round((user.totalPlayTime || 0) / 60 * 10) / 10 level: levelInfo.level,
levelName: levelInfo.name,
return ApiResponse.success(res, { levelPoints: levelInfo.points,
level: levelInfo.level, levelProgress: levelInfo.progress,
levelName: levelInfo.name, totalProgress: levelInfo.totalProgress,
levelPoints: levelInfo.points, nextLevelPoints: levelInfo.nextLevelPoints,
levelProgress: levelInfo.progress, totalPlayTime: playTimeHours,
totalProgress: levelInfo.totalProgress, totalScore: user.totalScore || 0,
nextLevelPoints: levelInfo.nextLevelPoints, worksCount: 0,
totalPlayTime: playTimeHours, collectionCount: user.collectedKnowledge?.length || 0,
totalScore: user.totalScore || 0, achievementCount: user.achievements?.length || 0,
worksCount: 0, unlockedDims: user.unlockedDims || [1]
collectionCount: user.collectedKnowledge?.length || 0, })
achievementCount: user.achievements?.length || 0,
unlockedDims: user.unlockedDims || [1] } catch (error) {
}) logger.error('获取用户统计失败:', error)
return ApiResponse.serverError(res, error.message)
} catch (error) { }
logger.error('获取用户统计失败:', error) })
return ApiResponse.serverError(res, error.message)
} /**
}) * 获取用户作品列表
* GET /api/user/works
/** * 说明:画廊(Gallery)功能已移除,统一返回空列表
* 获取用户作品列表 */
* GET /api/user/works router.get('/works', async (req, res) => {
* 说明:画廊(Gallery)功能已移除,统一返回空列表 try {
*/ const user = req.user
router.get('/works', async (req, res) => { const { page = 1, limit = 20 } = req.query
try {
const user = req.user return ApiResponse.paginated(res, [], {
const { page = 1, limit = 20 } = req.query page: parseInt(page),
limit: parseInt(limit),
return ApiResponse.paginated(res, [], { total: 0
page: parseInt(page), })
limit: parseInt(limit),
total: 0 } catch (error) {
}) logger.error('获取用户作品失败:', error)
return ApiResponse.serverError(res, error.message)
} catch (error) { }
logger.error('获取用户作品失败:', error) })
return ApiResponse.serverError(res, error.message)
} module.exports = router
})
module.exports = router
+5
View File
@@ -38,6 +38,11 @@ module.exports = function registerWxMsgRoutes(app) {
app.post('/api/wx/msg', async (req, res) => { app.post('/api/wx/msg', async (req, res) => {
try { try {
// 验签:明文模式下 POST 同样携带 signature/timestamp/nonce,防止伪造支付回调
const { signature, timestamp, nonce } = req.query || {}
if (!verifySignature(timestamp, nonce, signature)) {
return res.status(403).send('forbidden')
}
// 数据格式推荐 JSON:express.json 已解析为对象;选 XML 时是字符串 // 数据格式推荐 JSON:express.json 已解析为对象;选 XML 时是字符串
let fields = {} let fields = {}
if (req.body && typeof req.body === 'object') { if (req.body && typeof req.body === 'object') {
+5
View File
@@ -0,0 +1,5 @@
/** 用户输入进 $regex 前转义特殊字符(防 ReDoS/正则注入) */
function escapeRegExp(s) {
return String(s == null ? '' : s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
module.exports = { escapeRegExp }
+26
View File
@@ -0,0 +1,26 @@
const mod = require('ip2region')
const IP2Region = mod.default ?? mod
let query = null
/** IP 归属地(离线库 ip2region,城市级;内网/无效 IP 返回空) */
function ipLocation(ip) {
if (!ip) return ''
try {
if (!query) query = new IP2Region()
const r = query.search(ip) || {}
if (r.city === '内网IP') return '内网'
// 中国省份缺失时附带运营商;境外显示国家
if (r.country === '中国') {
const prov = (r.province || '').replace(/省|市|自治区|特别行政区|壮族|回族|维吾尔/g, '')
const city = (r.city || '').replace(/市$/, '')
const base = [prov, city].filter(Boolean).join(' ')
return base ? base : (r.isp ? `中国 ${r.isp}` : '中国')
}
return r.country || ''
} catch (e) {
return ''
}
}
module.exports = { ipLocation }
+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 }