Compare commits
40 Commits
7a5dcf8e0e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b9ff56360 | |||
| df8dd3d3dc | |||
| 726e5125a7 | |||
| de125dae67 | |||
| a87a631459 | |||
| df52c039d8 | |||
| 65c6802995 | |||
| feac6371ea | |||
| 7849bd067e | |||
| f2fa96b81b | |||
| 6d463b9296 | |||
| 9ea09c95f2 | |||
| acca4fe379 | |||
| 1d742dc6dd | |||
| 61069cd095 | |||
| 7cd01d8104 | |||
| ce9f69a76e | |||
| 5f3809831f | |||
| 49946c6d21 | |||
| b7d085c475 | |||
| 1e972291d9 | |||
| 5e525b3b7c | |||
| ab3d231e73 | |||
| 15eb4727bb | |||
| bcb239c51b | |||
| 1fc358d47c | |||
| 1f0b8e7740 | |||
| c549eb532d | |||
| c5e12c67fa | |||
| 7fd32685ce | |||
| f91d8f5418 | |||
| 62fb40e415 | |||
| 1e8a0aeead | |||
| b866eb1e70 | |||
| 5959edf37e | |||
| 2bc58d0c6a | |||
| 5e098a3584 | |||
| c5da01063c | |||
| 6adf9f1c6a | |||
| 9ab08254e3 |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
+29
-2
@@ -6,6 +6,7 @@
|
||||
<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-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 v-else class="layout">
|
||||
@@ -17,11 +18,16 @@
|
||||
<div class="side-foot"><el-button size="small" @click="logout">退出</el-button></div>
|
||||
</aside>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, ref, provide, onBeforeUnmount } from 'vue'
|
||||
import { getToken, setToken, clearToken, apiPost } from './api'
|
||||
import Dashboard from './views/Dashboard.vue'
|
||||
import Users from './views/Users.vue'
|
||||
@@ -30,10 +36,18 @@ import Trends from './views/Trends.vue'
|
||||
import Quotas from './views/Quotas.vue'
|
||||
import Feedbacks from './views/Feedbacks.vue'
|
||||
import Logs from './views/Logs.vue'
|
||||
import Notify from './views/Notify.vue'
|
||||
import VpayOrders from './views/VpayOrders.vue'
|
||||
|
||||
const token = ref(getToken())
|
||||
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 menus = [
|
||||
@@ -45,6 +59,7 @@ const menus = [
|
||||
{ key: 'feedbacks', label: '反馈管理', icon: '📝', comp: Feedbacks },
|
||||
{ key: 'vpay', label: '订单/余额', icon: '💰', comp: VpayOrders },
|
||||
{ key: 'logs', label: '访问日志', icon: '🗂️', comp: Logs },
|
||||
{ key: 'notify', label: '通知推送', icon: '📣', comp: Notify },
|
||||
]
|
||||
|
||||
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__inner-wrapper { overflow-x: auto; }
|
||||
.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
@@ -5,7 +5,14 @@ export function clearToken() { localStorage.removeItem(TOKEN_KEY) }
|
||||
|
||||
export function apiGet(path) {
|
||||
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)
|
||||
.catch(e => { throw new Error(e.message) })
|
||||
}
|
||||
@@ -14,6 +21,11 @@ export function apiGet(path) {
|
||||
function handleResponse(r) {
|
||||
return r.json().then(j => {
|
||||
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})`)
|
||||
}
|
||||
return j
|
||||
|
||||
@@ -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?.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>{{ 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 class="row">
|
||||
@@ -20,20 +35,31 @@
|
||||
<p v-if="!(d.litByDim || []).some(b => b.count)" class="empty">还没有点亮记录</p>
|
||||
</div>
|
||||
<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-column prop="createdAt" label="时间" width="160">
|
||||
<template #default="{ row }">{{ fmt(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="openid" label="用户" width="150">
|
||||
<template #default="{ row }">{{ (row.openid || '-').slice(0, 12) }}</template>
|
||||
<el-table-column label="用户/来源" width="130">
|
||||
<template #default="{ row }">{{ row.userName || row.openid || '-' }}</template>
|
||||
</el-table-column>
|
||||
<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 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 }">
|
||||
<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>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -44,16 +70,24 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, inject } from 'vue'
|
||||
import { apiGet } from '../api'
|
||||
const d = ref({})
|
||||
const goto = inject('goto', () => {})
|
||||
function goVisitLog() { goto('logs') }
|
||||
const visit = ref({})
|
||||
function pct(n) {
|
||||
const max = Math.max(1, ...(d.value.litByDim || []).map(b => b.count))
|
||||
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 }) : '-' }
|
||||
onMounted(async () => {
|
||||
d.value = (await apiGet('/api/admin/dashboard-v2')) || {}
|
||||
try { visit.value = (await apiGet('/api/admin/visit-stats')) || {} } catch (e) { visit.value = {} }
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -78,4 +112,11 @@ onMounted(async () => {
|
||||
.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; }
|
||||
.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>
|
||||
@@ -21,6 +21,9 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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">
|
||||
<template #default="{ row }">
|
||||
<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() }
|
||||
catch (e) { ElMessage.error(e.message || '删除失败') }
|
||||
}
|
||||
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—' }
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<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-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">
|
||||
<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="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="method" label="方式" 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>
|
||||
</template>
|
||||
</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 v-else>
|
||||
<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 { apiGet } from '../api'
|
||||
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 }) : '—' }
|
||||
// adminlog 的时间字段是 timestamp,loginlog 是 createdAt
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const ep = tab.value === 'login' ? 'login-logs' : 'logs'
|
||||
const r = await apiGet(`/api/admin/${ep}?page=${page.value}&limit=${pageSize}`)
|
||||
const ep = tab.value === 'login' ? 'login-logs' : tab.value === 'visit' ? 'visits' : 'logs'
|
||||
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 || []
|
||||
total.value = r.pagination?.total || 0
|
||||
if (tab.value === 'visit') {
|
||||
vStats.value = r.stats || {}
|
||||
vTotal.value = r.total || 0
|
||||
}
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
@@ -53,4 +94,12 @@ onMounted(load)
|
||||
|
||||
<style scoped>
|
||||
.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>
|
||||
@@ -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>
|
||||
@@ -3,6 +3,8 @@
|
||||
<div class="toolbar">
|
||||
<el-input v-model="q" placeholder="搜索标题" style="width: 220px" clearable @keyup.enter="search" />
|
||||
<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>
|
||||
</div>
|
||||
<el-table :data="list" stripe v-loading="loading">
|
||||
@@ -18,7 +20,7 @@
|
||||
<el-table-column label="人话解读" min-width="170" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<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>
|
||||
</el-table-column>
|
||||
<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 form = ref({})
|
||||
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() {
|
||||
loading.value = true
|
||||
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 || []
|
||||
total.value = r.pagination?.total || 0
|
||||
missingCount.value = r.missingCount ?? 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
function openEdit(row) {
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<template #default="{ row }">¥{{ row.amount }}</template>
|
||||
</el-table-column>
|
||||
<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 label="状态" width="70">
|
||||
<template #default="{ row }">
|
||||
@@ -92,7 +92,7 @@
|
||||
<p><b>金额:</b>¥{{ refundTarget?.amount }}</p>
|
||||
<p><b>将扣回星尘:</b>{{ refundCoins }}(余额不足将拒绝退款)</p>
|
||||
<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"
|
||||
title="流程:先冻结星尘 → 调微信退款 → 成功后完成 | 微信失败自动恢复星尘" />
|
||||
<template #footer>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta name="sogou_site_verification" content="bZ8oAfjvQm" />
|
||||
<meta name="360-site-verification" content="0cc596886cce77073d8fcba9d5ec7d9c" />
|
||||
<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" />
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||
|
||||
@@ -9,6 +9,30 @@ import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
|
||||
|
||||
onLaunch(() => {
|
||||
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(() => {})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "宇之然AI维度",
|
||||
"appid": "wxdad62baf4ccd09e3",
|
||||
"versionName": "1.4.0",
|
||||
"versionName": "1.4.9",
|
||||
"versionCode": "100",
|
||||
"description": "AI 知识探索社区 — 从5个维度系统理解人工智能",
|
||||
"uni-app": {
|
||||
@@ -21,5 +21,11 @@
|
||||
"darkmode": true,
|
||||
"themeLocation": "theme.json"
|
||||
},
|
||||
"app-plus": {}
|
||||
"app-plus": {},
|
||||
"h5": {
|
||||
"router": {
|
||||
"mode": "hash",
|
||||
"base": "/h5/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI 维度",
|
||||
"navigationBarTitleText": "宇之然AI维度-AI知识学习与人工智能资讯",
|
||||
"navigationStyle": "custom",
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundTextStyle": "dark"
|
||||
@@ -19,7 +19,7 @@
|
||||
{
|
||||
"path": "pages/detail/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI 知识",
|
||||
"navigationBarTitleText": "AI知识详情-人工智能知识点",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
@@ -33,21 +33,21 @@
|
||||
{
|
||||
"path": "pages/favorites/favorites",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的收藏",
|
||||
"navigationBarTitleText": "我的AI知识收藏",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/achievements/achievements",
|
||||
"style": {
|
||||
"navigationBarTitleText": "成就",
|
||||
"navigationBarTitleText": "我的AI学习成就",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/leaderboard/leaderboard",
|
||||
"style": {
|
||||
"navigationBarTitleText": "排行榜",
|
||||
"navigationBarTitleText": "AI学习排行榜",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
@@ -82,7 +82,7 @@
|
||||
{
|
||||
"path": "pages/news/news",
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI 资讯",
|
||||
"navigationBarTitleText": "AI资讯速递-人工智能热点解读",
|
||||
"navigationStyle": "custom",
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundTextStyle": "dark"
|
||||
@@ -91,21 +91,21 @@
|
||||
{
|
||||
"path": "pages/dimension/dimension",
|
||||
"style": {
|
||||
"navigationBarTitleText": "",
|
||||
"navigationBarTitleText": "AI知识星域-五大维度学人工智能",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/chat/chat",
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI 问答",
|
||||
"navigationBarTitleText": "AI问答-人工智能知识解答",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/trend/trend",
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI 趋势",
|
||||
"navigationBarTitleText": "AI趋势-每日人工智能行业动态",
|
||||
"navigationStyle": "custom",
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundTextStyle": "dark"
|
||||
|
||||
@@ -118,7 +118,7 @@ function onLoad(fn) {
|
||||
// 微信小程序分享
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: 'AI 维度 - 你的AI知识助手',
|
||||
title: 'AI知识社区 · 宇之然AI维度',
|
||||
path: '/pages/index/index'
|
||||
}))
|
||||
// #endif
|
||||
|
||||
@@ -208,8 +208,15 @@ function goLogin() {
|
||||
uni.navigateTo({ url: '/pages/login/login' })
|
||||
}
|
||||
|
||||
function onQuotaTap() {
|
||||
async function onQuotaTap() {
|
||||
if (isGuest.value) {
|
||||
// 静默登录一键升级(微信 code 换 token 无感),失败再显式引导
|
||||
const ok = await userStore.ensureLogin()
|
||||
if (ok) {
|
||||
uni.showToast({ title: '已升级为登录用户,每天 5 次', icon: 'success' })
|
||||
loadQuota()
|
||||
return
|
||||
}
|
||||
uni.showModal({
|
||||
title: '游客体验',
|
||||
content: '每天可免费提问 3 次;登录后每天 5 次,答对检测题还能点亮知识星球',
|
||||
|
||||
@@ -229,18 +229,22 @@ function retryQuiz() {
|
||||
|
||||
async function toggleCollect() {
|
||||
if (!userStore.isLoggedIn) {
|
||||
uni.showModal({
|
||||
title: '登录后收藏',
|
||||
content: '登录后即可收藏喜欢的 AI 知识',
|
||||
confirmText: '去登录',
|
||||
cancelText: '暂不',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
uni.navigateTo({ url: '/pages/login/login' })
|
||||
// 静默登录(微信 code 换 token,用户无感);失败再显式引导
|
||||
const ok = await userStore.ensureLogin()
|
||||
if (!ok) {
|
||||
uni.showModal({
|
||||
title: '登录后收藏',
|
||||
content: '登录后即可收藏喜欢的 AI 知识',
|
||||
confirmText: '去登录',
|
||||
cancelText: '暂不',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
uni.navigateTo({ url: '/pages/login/login' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (isCollected.value) {
|
||||
|
||||
@@ -232,7 +232,7 @@ function goBack() {
|
||||
// 微信小程序分享
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: 'AI 维度 - 你的AI知识助手',
|
||||
title: '帮AI维度变得更好 · 意见反馈',
|
||||
path: '/pages/index/index'
|
||||
}))
|
||||
// #endif
|
||||
|
||||
@@ -221,6 +221,8 @@ function goFirstUnlit() {
|
||||
function resumeRead() {
|
||||
if (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}` })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ function dimDotColor(id) {
|
||||
// 微信小程序分享
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: 'AI 维度 - 你的AI知识助手',
|
||||
title: 'AI学习排行榜 · 宇之然AI维度',
|
||||
path: '/pages/index/index'
|
||||
}))
|
||||
// #endif
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
</view>
|
||||
<!-- 微信小程序一键登录 -->
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<view class="wx-login-row" v-if="isMiniApp">
|
||||
<view class="wx-login-row">
|
||||
<view class="wx-login-btn" @click="wechatLogin">
|
||||
<text class="wx-icon">💬</text>
|
||||
<text>微信一键登录</text>
|
||||
@@ -129,21 +129,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { askSubscribe } from '../../utils/notify'
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
|
||||
import { ref } from 'vue'
|
||||
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 loading = ref(false)
|
||||
const agreed = ref(false)
|
||||
@@ -217,6 +208,7 @@ const wechatLogin = async () => {
|
||||
loading.value = true
|
||||
await userStore.login(loginRes.code, {})
|
||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||
setTimeout(() => askSubscribe(), 800) // 登录成功后请求订阅授权(接受新版通知等)
|
||||
setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 1000)
|
||||
} catch (err) {
|
||||
const msg = (err && (err.message || err.errMsg)) || ''
|
||||
@@ -245,6 +237,7 @@ const handleLogin = async () => {
|
||||
: { username: identifier, password: loginForm.value.password }
|
||||
await userStore.h5Login(loginData)
|
||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||
setTimeout(() => askSubscribe(), 800) // 登录成功后请求订阅授权(接受新版通知等)
|
||||
setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 1000)
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '登录失败', icon: 'none' })
|
||||
@@ -324,7 +317,7 @@ function goForgotPassword() {
|
||||
// 微信小程序分享
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: 'AI 维度 - 你的AI知识助手',
|
||||
title: '登录解锁AI学习完整功能 · 宇之然AI维度',
|
||||
path: '/pages/index/index'
|
||||
}))
|
||||
// #endif
|
||||
|
||||
@@ -11,11 +11,6 @@
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-y class="content">
|
||||
<!-- 资讯头图 -->
|
||||
<view class="news-hero" :style="{background: heroBg}">
|
||||
<text class="hero-emoji">{{ heroEmoji }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 分类标签 -->
|
||||
<view class="category-tag">
|
||||
<text class="tag-text">{{ categoryLabel }}</text>
|
||||
@@ -50,6 +45,14 @@
|
||||
<text class="summary-text">{{ news.summary }}</text>
|
||||
</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">
|
||||
<rich-text :nodes="newsBody"></rich-text>
|
||||
@@ -91,6 +94,8 @@
|
||||
|
||||
<script setup>
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
const userStore = useUserStore()
|
||||
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { trendApi, knowledgeApi } from '@/utils/api'
|
||||
@@ -170,16 +175,37 @@ function goBack() {
|
||||
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
|
||||
uni.showToast({ title: isCollected.value ? '已收藏' : '已取消收藏', icon: 'none' })
|
||||
}
|
||||
|
||||
function onShare() {
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.showModal({
|
||||
title: '分享给朋友',
|
||||
content: '点击右上角「···」→ 转发给朋友或分享到朋友圈',
|
||||
showCancel: false,
|
||||
})
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
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' })
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
|
||||
function openSource() {
|
||||
|
||||
@@ -291,7 +291,7 @@ function goBack() {
|
||||
// 微信小程序分享
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: 'AI 维度 - 你的AI知识助手',
|
||||
title: 'AI知识社区 · 宇之然AI维度',
|
||||
path: '/pages/index/index'
|
||||
}))
|
||||
// #endif
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
<AppIcon name="planet-white" :size="26" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="profile-main">
|
||||
<text class="profile-name">{{ userStore.userInfo?.nickName || '探索者' }}</text>
|
||||
<view class="profile-main" @click="editNickname">
|
||||
<text class="profile-name">{{ userStore.userInfo?.nickName || '探索者' }}<text v-if="userStore.isLoggedIn" class="nick-edit"> ✏️</text></text>
|
||||
<text class="profile-sub">{{ profileSubText }}</text>
|
||||
</view>
|
||||
<view class="streak-chip" v-if="stats && stats.streak > 0">
|
||||
@@ -104,6 +104,20 @@
|
||||
</view>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -229,6 +243,41 @@ function dimPercent(dimId) {
|
||||
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) {
|
||||
uni.navigateTo({ url: `/pages/dimension/dimension?id=${id}` })
|
||||
}
|
||||
@@ -325,6 +374,7 @@ onShareTimeline(() => ({
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.nick-edit { font-size: 14px; opacity: 0.7; }
|
||||
.profile-sub {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
@@ -656,6 +706,29 @@ onShareTimeline(() => ({
|
||||
</style>
|
||||
|
||||
<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; }
|
||||
.recharge-go.outline { background: transparent; border: 1.5px solid rgba(255,255,255,.6); color: #fff; }
|
||||
</style>
|
||||
|
||||
@@ -225,7 +225,7 @@ function goBack() {
|
||||
// 微信小程序分享
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: 'AI 维度 - 你的AI知识助手',
|
||||
title: '我的AI学习进度 · 宇之然AI维度',
|
||||
path: '/pages/index/index'
|
||||
}))
|
||||
// #endif
|
||||
|
||||
@@ -31,11 +31,13 @@
|
||||
<text class="loading-text">搜索中...</text>
|
||||
</view>
|
||||
|
||||
<!-- 空搜索提示 -->
|
||||
<!-- 未搜索:热门词快捷入口 -->
|
||||
<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-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>
|
||||
|
||||
<!-- 无结果提示 -->
|
||||
@@ -92,6 +94,11 @@ import { knowledgeApi } from '@/utils/api'
|
||||
import { DIMENSIONS } from '@/utils/dimensions'
|
||||
|
||||
const searchText = ref('')
|
||||
const HOT_WORDS = ['大模型', 'Transformer', 'DeepSeek', '提示词', 'AI绘画', '智能体', 'RAG', '具身智能']
|
||||
function quickSearch(w) {
|
||||
searchText.value = w
|
||||
doSearch()
|
||||
}
|
||||
const results = ref([])
|
||||
const loading = ref(false)
|
||||
const hasSearched = ref(false)
|
||||
@@ -192,7 +199,7 @@ onUnmounted(() => {
|
||||
// 微信小程序分享
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: 'AI 维度 - 你的AI知识助手',
|
||||
title: '搜一搜想了解的AI知识 · 宇之然AI维度',
|
||||
path: '/pages/index/index'
|
||||
}))
|
||||
// #endif
|
||||
@@ -395,4 +402,20 @@ onShareAppMessage(() => ({
|
||||
font-size: 11px;
|
||||
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>
|
||||
@@ -20,6 +20,7 @@
|
||||
<view class="s-switch-knob" :class="{ on: pushOn }"></view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #ifdef H5 -->
|
||||
<view class="setting-item" @click="toggleDarkMode">
|
||||
<view class="s-icon" style="background:rgba(77,208,225,0.1);color:#4dd0e1">🌙</view>
|
||||
<view class="s-text">
|
||||
@@ -30,6 +31,17 @@
|
||||
<view class="s-switch-knob" :class="{ on: darkMode }"></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>
|
||||
|
||||
<!-- 其他 -->
|
||||
@@ -85,6 +97,14 @@ function togglePush() {
|
||||
})
|
||||
}
|
||||
|
||||
function showDarkTip() {
|
||||
uni.showModal({
|
||||
title: '深色模式',
|
||||
content: '小程序深色模式跟随系统:在手机的 系统设置 → 显示与亮度 中切换深色/浅色,小程序将自动适配。',
|
||||
showCancel: false,
|
||||
})
|
||||
}
|
||||
|
||||
function toggleDarkMode() {
|
||||
darkMode.value = !darkMode.value
|
||||
const theme = darkMode.value ? 'dark' : 'light'
|
||||
@@ -148,7 +168,7 @@ function goBack() {
|
||||
// 微信小程序分享
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({
|
||||
title: 'AI 维度 - 你的AI知识助手',
|
||||
title: 'AI知识社区 · 宇之然AI维度',
|
||||
path: '/pages/index/index'
|
||||
}))
|
||||
// #endif
|
||||
|
||||
@@ -121,8 +121,7 @@ async function fetchTrends() {
|
||||
items.value = data.list || data.items || []
|
||||
} catch (err) {
|
||||
console.error('获取趋势失败:', err)
|
||||
// 降级:本地示例
|
||||
items.value = getSampleData()
|
||||
uni.showToast({ title: '加载失败,下拉重试', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -159,57 +158,11 @@ function formatDate(dateStr) {
|
||||
}
|
||||
|
||||
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.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
|
||||
onShareAppMessage(() => {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
@@ -28,6 +28,27 @@ export const useUserStore = defineStore('user', () => {
|
||||
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) {
|
||||
const data = await authApi.h5Login(loginData)
|
||||
token.value = data.token
|
||||
@@ -110,6 +131,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
isSubscribed,
|
||||
subscriptionLevel,
|
||||
login,
|
||||
ensureLogin,
|
||||
h5Login,
|
||||
h5Register,
|
||||
fetchMe,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
- 关键链路回归:管理员登录/模型配置/知识搜索/排行榜/分享奖励 全部通过
|
||||
Generated
+9
-2
@@ -19,6 +19,7 @@
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"express-validator": "^7.0.1",
|
||||
"helmet": "^7.1.0",
|
||||
"ip2region": "^2.3.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"moment": "^2.29.4",
|
||||
"mongoose": "^8.0.3",
|
||||
@@ -1096,7 +1097,6 @@
|
||||
"version": "25.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
@@ -2819,6 +2819,14 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"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": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -5510,7 +5518,6 @@
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"express-validator": "^7.0.1",
|
||||
"helmet": "^7.1.0",
|
||||
"ip2region": "^2.3.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"moment": "^2.29.4",
|
||||
"mongoose": "^8.0.3",
|
||||
|
||||
@@ -8,6 +8,7 @@ const morgan = require('morgan')
|
||||
const rateLimit = require('express-rate-limit')
|
||||
|
||||
const connectDB = require('./config/database')
|
||||
const { VisitLog } = require('./models')
|
||||
const initRoutes = require('./routes')
|
||||
const seoRoutes = require('./routes/seo')
|
||||
const registerWxMsgRoutes = require('./routes/wxmsg')
|
||||
@@ -65,6 +66,46 @@ const limiter = rateLimit({
|
||||
})
|
||||
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) => {
|
||||
res.status(200).json({
|
||||
|
||||
+158
-158
@@ -1,158 +1,158 @@
|
||||
const jwt = require('jsonwebtoken')
|
||||
const { Admin, User } = require('../models')
|
||||
|
||||
/**
|
||||
* JWT 认证中间件(管理员)
|
||||
*/
|
||||
const authMiddleware = async (req, res, next) => {
|
||||
try {
|
||||
// 从 header 获取 token
|
||||
const authHeader = req.header('Authorization')
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '未提供认证令牌'
|
||||
})
|
||||
}
|
||||
|
||||
const token = authHeader.replace('Bearer ', '')
|
||||
|
||||
// 验证 token
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||
|
||||
// 查找管理员
|
||||
const admin = await Admin.findById(decoded.adminId)
|
||||
if (!admin) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '管理员不存在'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查状态
|
||||
if (admin.status !== 'active') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: '管理员账户已禁用'
|
||||
})
|
||||
}
|
||||
|
||||
// 将管理员信息附加到请求对象
|
||||
req.admin = admin
|
||||
next()
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '无效的认证令牌'
|
||||
})
|
||||
}
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '认证令牌已过期'
|
||||
})
|
||||
}
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限检查中间件
|
||||
* @param {string} permission - 需要的权限
|
||||
*/
|
||||
const requirePermission = (permission) => {
|
||||
return (req, res, next) => {
|
||||
if (!req.admin) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '未认证'
|
||||
})
|
||||
}
|
||||
|
||||
if (!req.admin.hasPermission(permission)) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: '权限不足'
|
||||
})
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序用户 JWT 认证中间件
|
||||
*/
|
||||
const userAuthMiddleware = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.header('Authorization')
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '未提供认证令牌'
|
||||
})
|
||||
}
|
||||
|
||||
const token = authHeader.replace('Bearer ', '')
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||
|
||||
// 兼容两种token格式:包含userId或openid
|
||||
let user = null
|
||||
if (decoded.userId) {
|
||||
user = await User.findById(decoded.userId)
|
||||
} else if (decoded.openid) {
|
||||
user = await User.findOne({ openid: decoded.openid })
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '用户不存在或令牌无效'
|
||||
})
|
||||
}
|
||||
|
||||
req.user = user
|
||||
// 优先使用 openid,如果没有则使用 userId 作为标识
|
||||
req.openid = user.openid || user._id.toString()
|
||||
next()
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '认证令牌无效或已过期'
|
||||
})
|
||||
}
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 可选的用户认证中间件(不强制要求登录)
|
||||
*/
|
||||
const optionalAuthMiddleware = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.header('Authorization')
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.replace('Bearer ', '')
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||
const user = await User.findById(decoded.userId)
|
||||
if (user) {
|
||||
req.user = user
|
||||
req.openid = user.openid
|
||||
}
|
||||
}
|
||||
next()
|
||||
} catch (error) {
|
||||
// 即使认证失败也继续,只是不附加用户信息
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
authMiddleware,
|
||||
requirePermission,
|
||||
userAuthMiddleware,
|
||||
optionalAuthMiddleware
|
||||
}
|
||||
const jwt = require('jsonwebtoken')
|
||||
const { Admin, User } = require('../models')
|
||||
|
||||
/**
|
||||
* JWT 认证中间件(管理员)
|
||||
*/
|
||||
const authMiddleware = async (req, res, next) => {
|
||||
try {
|
||||
// 从 header 获取 token
|
||||
const authHeader = req.header('Authorization')
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '未提供认证令牌'
|
||||
})
|
||||
}
|
||||
|
||||
const token = authHeader.replace('Bearer ', '')
|
||||
|
||||
// 验证 token
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||
|
||||
// 查找管理员
|
||||
const admin = await Admin.findById(decoded.adminId)
|
||||
if (!admin) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '管理员不存在'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查状态
|
||||
if (admin.status !== 'active') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: '管理员账户已禁用'
|
||||
})
|
||||
}
|
||||
|
||||
// 将管理员信息附加到请求对象
|
||||
req.admin = admin
|
||||
next()
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '无效的认证令牌'
|
||||
})
|
||||
}
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '认证令牌已过期'
|
||||
})
|
||||
}
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限检查中间件
|
||||
* @param {string} permission - 需要的权限
|
||||
*/
|
||||
const requirePermission = (permission) => {
|
||||
return (req, res, next) => {
|
||||
if (!req.admin) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '未认证'
|
||||
})
|
||||
}
|
||||
|
||||
if (!req.admin.hasPermission(permission)) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: '权限不足'
|
||||
})
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序用户 JWT 认证中间件
|
||||
*/
|
||||
const userAuthMiddleware = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.header('Authorization')
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '未提供认证令牌'
|
||||
})
|
||||
}
|
||||
|
||||
const token = authHeader.replace('Bearer ', '')
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||
|
||||
// 兼容两种token格式:包含userId或openid
|
||||
let user = null
|
||||
if (decoded.userId) {
|
||||
user = await User.findById(decoded.userId).select('+sessionKey') // vpay 签名需要
|
||||
} else if (decoded.openid) {
|
||||
user = await User.findOne({ openid: decoded.openid })
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '用户不存在或令牌无效'
|
||||
})
|
||||
}
|
||||
|
||||
req.user = user
|
||||
// 优先使用 openid,如果没有则使用 userId 作为标识
|
||||
req.openid = user.openid || user._id.toString()
|
||||
next()
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '认证令牌无效或已过期'
|
||||
})
|
||||
}
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 可选的用户认证中间件(不强制要求登录)
|
||||
*/
|
||||
const optionalAuthMiddleware = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.header('Authorization')
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.replace('Bearer ', '')
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||
const user = await User.findById(decoded.userId)
|
||||
if (user) {
|
||||
req.user = user
|
||||
req.openid = user.openid
|
||||
}
|
||||
}
|
||||
next()
|
||||
} catch (error) {
|
||||
// 即使认证失败也继续,只是不附加用户信息
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
authMiddleware,
|
||||
requirePermission,
|
||||
userAuthMiddleware,
|
||||
optionalAuthMiddleware
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
const mongoose = require('mongoose')
|
||||
const { Schema } = mongoose
|
||||
|
||||
/**
|
||||
* 订阅消息发送记录
|
||||
*/
|
||||
const NotifyLogSchema = new Schema({
|
||||
openid: { type: String, index: true },
|
||||
userId: { type: Schema.Types.ObjectId, ref: 'User', default: null },
|
||||
templateId: { type: String, default: '' },
|
||||
title: { type: String, default: '' }, // 消息标题(管理端检索用)
|
||||
scene: { type: String, default: 'manual' }, // manual=手动群发 / release=版本发布
|
||||
status: { type: String, enum: ['sent', 'failed', 'skipped'], default: 'sent' },
|
||||
errCode: { type: Number, default: 0 },
|
||||
errMsg: { type: String, default: '' },
|
||||
adminName: { type: String, default: '' },
|
||||
createdAt: { type: Date, default: Date.now, index: true }
|
||||
})
|
||||
|
||||
module.exports = mongoose.model('NotifyLog', NotifyLogSchema)
|
||||
@@ -160,7 +160,8 @@ const UserSchema = new Schema({
|
||||
default: Date.now
|
||||
},
|
||||
// vpay 用户态签名必需(code2Session 返回,有过期时间,过期后重登录刷新)
|
||||
sessionKey: { type: String, default: '' },
|
||||
sessionKey: { type: String, default: '', select: false },
|
||||
notifyQuota: { type: Number, default: 0, min: 0 }, // 订阅消息可发送额度(每次授权+1,发送-1) // [安全] 默认查询不返回(支付签名凭据)
|
||||
vpayCoins: { type: Number, default: 0 },
|
||||
lastLoginAt: {
|
||||
type: Date,
|
||||
|
||||
@@ -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)
|
||||
@@ -12,6 +12,8 @@ const Feedback = require('./Feedback')
|
||||
const Trend = require('./Trend')
|
||||
const VerificationCode = require('./VerificationCode')
|
||||
const CoinLedger = require('./CoinLedger')
|
||||
const VisitLog = require('./VisitLog')
|
||||
const NotifyLog = require('./NotifyLog')
|
||||
|
||||
module.exports = {
|
||||
User,
|
||||
@@ -27,5 +29,7 @@ module.exports = {
|
||||
Feedback,
|
||||
Trend,
|
||||
VerificationCode,
|
||||
CoinLedger
|
||||
CoinLedger,
|
||||
VisitLog,
|
||||
NotifyLog
|
||||
}
|
||||
|
||||
+281
-46
@@ -1,10 +1,12 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
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 ApiResponse = require('../utils/response')
|
||||
const logger = require('../utils/logger')
|
||||
const { escapeRegExp } = require('../utils/escapeRegex')
|
||||
|
||||
// 所有管理路由都需要认证
|
||||
router.use(authMiddleware)
|
||||
@@ -33,6 +35,88 @@ const logAction = async (req, action, resource, resourceId = null, details = {})
|
||||
* 获取仪表盘数据
|
||||
* 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) => {
|
||||
try {
|
||||
const today = new Date()
|
||||
@@ -56,7 +140,6 @@ router.get('/dashboard', requirePermission('dashboard:read'), async (req, res) =
|
||||
Order.getWeekTrend()
|
||||
])
|
||||
|
||||
await logAction(req, 'read', 'dashboard')
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
today: {
|
||||
@@ -89,7 +172,7 @@ router.get('/users', requirePermission('users:read'), async (req, res) => {
|
||||
const query = {}
|
||||
|
||||
if (search) {
|
||||
query.nickName = { $regex: search, $options: 'i' }
|
||||
query.nickName = { $regex: escapeRegExp(search), $options: 'i' }
|
||||
}
|
||||
|
||||
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, {
|
||||
page: parseInt(page),
|
||||
@@ -213,7 +295,6 @@ router.get('/users/:id', requirePermission('users:read'), async (req, res) => {
|
||||
|
||||
// 画廊(Gallery)功能已移除,作品数为 0
|
||||
|
||||
await logAction(req, 'read', 'users', req.params.id)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
...user,
|
||||
@@ -252,7 +333,6 @@ router.get('/knowledge', requirePermission('knowledge:read'), async (req, res) =
|
||||
.limit(parseInt(limit))
|
||||
.lean()
|
||||
|
||||
await logAction(req, 'read', 'knowledge', null, { page, limit, dim, status })
|
||||
|
||||
return ApiResponse.paginated(res, knowledge, {
|
||||
page: parseInt(page),
|
||||
@@ -385,7 +465,6 @@ router.get('/orders', requirePermission('orders:read'), async (req, res) => {
|
||||
.limit(parseInt(limit))
|
||||
.lean()
|
||||
|
||||
await logAction(req, 'read', 'orders', null, { page, limit, status })
|
||||
|
||||
return ApiResponse.paginated(res, orders, {
|
||||
page: parseInt(page),
|
||||
@@ -414,7 +493,6 @@ router.get('/shop', requirePermission('shop:read'), async (req, res) => {
|
||||
.limit(parseInt(limit))
|
||||
.lean()
|
||||
|
||||
await logAction(req, 'read', 'shop', null, { page, limit, status })
|
||||
|
||||
return ApiResponse.paginated(res, items, {
|
||||
page: parseInt(page),
|
||||
@@ -505,7 +583,6 @@ router.get('/admins', requirePermission('*'), async (req, res) => {
|
||||
.sort({ createdAt: -1 })
|
||||
.lean()
|
||||
|
||||
await logAction(req, 'read', 'admins')
|
||||
|
||||
return ApiResponse.success(res, admins)
|
||||
|
||||
@@ -530,7 +607,6 @@ router.get('/shop-items', requirePermission('shop:read'), async (req, res) => {
|
||||
.limit(parseInt(limit))
|
||||
.lean()
|
||||
|
||||
await logAction(req, 'read', 'shop', null, { page, limit, status })
|
||||
|
||||
return ApiResponse.paginated(res, items, {
|
||||
page: parseInt(page),
|
||||
@@ -701,7 +777,7 @@ router.get('/ai-chat/quota-list', requirePermission('user:read'), async (req, re
|
||||
try {
|
||||
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([
|
||||
AIChatQuota.find(query)
|
||||
@@ -812,9 +888,9 @@ router.get('/feedback', requirePermission('feedback:read'), async (req, res) =>
|
||||
if (priority) query.priority = priority
|
||||
if (search) {
|
||||
query.$or = [
|
||||
{ title: { $regex: search, $options: 'i' } },
|
||||
{ content: { $regex: search, $options: 'i' } },
|
||||
{ userName: { $regex: search, $options: 'i' } }
|
||||
{ title: { $regex: escapeRegExp(search), $options: 'i' } },
|
||||
{ content: { $regex: escapeRegExp(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
|
||||
}))
|
||||
|
||||
await logAction(req, 'read', 'feedback', null, { page, limit, status, type })
|
||||
|
||||
return ApiResponse.paginated(res, formattedFeedbacks, {
|
||||
page: parseInt(page),
|
||||
@@ -868,7 +943,6 @@ router.get('/feedback/:id', requirePermission('feedback:read'), async (req, res)
|
||||
return ApiResponse.notFound(res, '反馈不存在')
|
||||
}
|
||||
|
||||
await logAction(req, 'read', 'feedback', req.params.id)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
id: feedback._id,
|
||||
@@ -1050,6 +1124,7 @@ router.get('/login-logs', requirePermission('user:read'), async (req, res) => {
|
||||
const formattedLogs = logs.map(log => ({
|
||||
id: log._id,
|
||||
userName: log.userId?.nickName || '未知用户',
|
||||
ipLoc: ipLocation(log.ip),
|
||||
userAvatar: log.userId?.avatarUrl || '',
|
||||
type: log.type,
|
||||
method: log.method,
|
||||
@@ -1063,7 +1138,6 @@ router.get('/login-logs', requirePermission('user:read'), async (req, res) => {
|
||||
createdAt: log.createdAt
|
||||
}))
|
||||
|
||||
await logAction(req, 'read', 'login-logs', null, { page, limit })
|
||||
|
||||
return ApiResponse.paginated(res, formattedLogs, {
|
||||
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) => {
|
||||
try {
|
||||
const { page = 1, limit = 20, keyword = '' } = req.query
|
||||
const query = keyword ? { title: { $regex: keyword, $options: 'i' } } : {}
|
||||
const { page = 1, limit = 20, keyword = '', missing } = req.query
|
||||
const query = keyword ? { title: { $regex: escapeRegExp(keyword), $options: 'i' } } : {}
|
||||
if (missing === '1') query.plainExplain = { $in: ['', null, undefined] }
|
||||
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 })
|
||||
.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) {
|
||||
logger.error('获取趋势列表失败:', error)
|
||||
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) => {
|
||||
try {
|
||||
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 [
|
||||
totalUsers, newUsers7d, totalKnowledge, approvedKnowledge, withQuiz,
|
||||
totalTrends, trendsWithExplain, totalFeedbacks, pendingFeedbacks, totalOrders, paidOrders,
|
||||
totalLits, litUsers, recentLogins, litByDimAgg, aiStatsRaw
|
||||
totalLits, litUsers, recentLogins, recentVisits, litByDimAgg, aiStatsRaw
|
||||
] = await Promise.all([
|
||||
User.countDocuments({}),
|
||||
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' }),
|
||||
KnowledgeLit.countDocuments({}),
|
||||
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 } } }]),
|
||||
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 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, {
|
||||
stats: {
|
||||
totalUsers, newUsers7d,
|
||||
@@ -1182,16 +1342,7 @@ router.get('/dashboard-v2', requirePermission('dashboard:read'), async (req, res
|
||||
aiChatUsed: ai.used, aiChatUsers: ai.users,
|
||||
},
|
||||
litByDim: Object.entries(litByDim).map(([dim, count]) => ({ dim: Number(dim), name: DIM_NAMES[dim], count })),
|
||||
recentLogins: recentLogins.map(l => ({
|
||||
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,
|
||||
})),
|
||||
recentLogins: merged,
|
||||
})
|
||||
} catch (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 query = {}
|
||||
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 items = await Order.find(query)
|
||||
.sort({ createdAt: -1 })
|
||||
@@ -1257,17 +1503,6 @@ router.post('/vpay/orders/:id/refund', requirePermission('orders:write'), async
|
||||
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 条件 = 原子扣减:
|
||||
// 如果在预检和这里之间用户消费了星尘导致余额不足,此操作会失败(返回 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 query = {}
|
||||
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 items = await CoinLedger.find(query)
|
||||
.sort({ createdAt: -1 })
|
||||
|
||||
@@ -227,6 +227,13 @@ router.post('/share-gain', userAuthMiddleware, async (req, res) => {
|
||||
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)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
|
||||
@@ -22,7 +22,7 @@ router.get('/current', async (req, res) => {
|
||||
name: '默认模型',
|
||||
modelId: process.env.OPENAI_MODEL || 'gpt-3.5-turbo',
|
||||
apiUrl: process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions',
|
||||
apiKey: process.env.OPENAI_API_KEY || '',
|
||||
apiKey: '', // [安全] 不向客户端下发密钥
|
||||
config: {
|
||||
temperature: 0.7,
|
||||
maxTokens: 800,
|
||||
|
||||
+118
-59
@@ -1,10 +1,25 @@
|
||||
const express = require('express')
|
||||
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 { User, Admin, AdminLog, LoginLog, VerificationCode } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const weChatService = require('../utils/weixin')
|
||||
const logger = require('../utils/logger')
|
||||
const { userAuthMiddleware } = require('../middleware/auth')
|
||||
const { sendVerificationCode } = require('../utils/mail')
|
||||
const axios = require('axios')
|
||||
|
||||
@@ -107,7 +122,7 @@ router.post('/login', async (req, res) => {
|
||||
// 新用户
|
||||
user = new User({
|
||||
openid,
|
||||
nickName: userInfo?.nickName || '星辰旅行者',
|
||||
nickName: userInfo?.nickName || (await genDefaultNickName()),
|
||||
avatarUrl: userInfo?.avatarUrl || '',
|
||||
unlockedDims: [1]
|
||||
})
|
||||
@@ -195,7 +210,7 @@ router.post('/webview-login', async (req, res) => {
|
||||
// 新用户 - 使用微信提供的信息创建账户
|
||||
user = new User({
|
||||
openid,
|
||||
nickName: userInfo?.nickName || '星辰旅行者',
|
||||
nickName: userInfo?.nickName || (await genDefaultNickName()),
|
||||
avatarUrl: userInfo?.avatarUrl || '',
|
||||
unlockedDims: [1]
|
||||
})
|
||||
@@ -319,7 +334,7 @@ router.post('/user/login', async (req, res) => {
|
||||
try {
|
||||
const { username, email, password } = req.body
|
||||
// 支持邮箱或用户名登录
|
||||
const identifier = email || username
|
||||
const identifier = String(email || username || '') // [安全] 强转防对象注入 $ne
|
||||
|
||||
// 查找用户(显式包含密码字段)
|
||||
const user = await User.findOne({
|
||||
@@ -426,8 +441,9 @@ router.post('/user/login', async (req, res) => {
|
||||
*/
|
||||
router.post('/user/register', async (req, res) => {
|
||||
try {
|
||||
const { username, email, password, nickName, avatarUrl, verifyCode } = req.body
|
||||
const identifier = email || username
|
||||
const { username, email, password, nickName, avatarUrl } = req.body
|
||||
const verifyCode = String(req.body?.verifyCode || '') // [安全] 强转防注入
|
||||
const identifier = String(email || username || '') // [安全] 强转防对象注入 $ne
|
||||
|
||||
if (!identifier) {
|
||||
return ApiResponse.error(res, '请输入邮箱或用户名', 400)
|
||||
@@ -479,7 +495,7 @@ router.post('/user/register', async (req, res) => {
|
||||
username: finalUsername,
|
||||
email: email || '',
|
||||
password,
|
||||
nickName: nickName || '星辰旅行者',
|
||||
nickName: nickName || (await genDefaultNickName()),
|
||||
avatarUrl: avatarUrl || '',
|
||||
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 交换接口
|
||||
* POST /api/auth/webview-token
|
||||
* 用于小程序 WebView 内嵌 H5 时,通过 openid 获取登录 token
|
||||
* 小程序在 URL 中传递 openid,H5 用这个接口换取 token
|
||||
*/
|
||||
router.post('/webview-token', async (req, res) => {
|
||||
try {
|
||||
const { openid } = req.body
|
||||
|
||||
if (!openid) {
|
||||
return ApiResponse.error(res, '缺少用户标识', 400)
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
const user = await User.findOne({ openid })
|
||||
|
||||
if (!user) {
|
||||
return ApiResponse.error(res, '用户不存在', 404)
|
||||
}
|
||||
|
||||
// 生成新的 JWT token
|
||||
const token = jwt.sign(
|
||||
{ openid, userId: user._id },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRE }
|
||||
)
|
||||
|
||||
// 更新最后登录时间
|
||||
user.lastLoginAt = new Date()
|
||||
await user.save()
|
||||
|
||||
// 记录登录日志
|
||||
await recordLoginLog(
|
||||
user._id,
|
||||
openid,
|
||||
'auto_login',
|
||||
'wechat_webview',
|
||||
'webview_h5',
|
||||
req,
|
||||
true
|
||||
)
|
||||
|
||||
logger.info(`WebView Token交换成功: ${openid}`)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
token,
|
||||
openid,
|
||||
userInfo: {
|
||||
nickName: user.nickName,
|
||||
avatarUrl: user.avatarUrl
|
||||
}
|
||||
}, '登录成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('WebView Token交换失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
// [安全下线 2026-09-19] 该接口仅凭 openid 即可换任意用户 token(配合公开 openid 列表=账号接管)。客户端已确认无调用,如需 H5 登录用 code 换 token 方案。
|
||||
/* 已下线:router.post('/webview-token', async (req, res) => {
|
||||
// try {
|
||||
// const { openid } = req.body
|
||||
//
|
||||
// if (!openid) {
|
||||
// return ApiResponse.error(res, '缺少用户标识', 400)
|
||||
// }
|
||||
//
|
||||
// // 查找用户
|
||||
// const user = await User.findOne({ openid })
|
||||
//
|
||||
// if (!user) {
|
||||
// return ApiResponse.error(res, '用户不存在', 404)
|
||||
// }
|
||||
//
|
||||
// // 生成新的 JWT token
|
||||
// const token = jwt.sign(
|
||||
// { openid, userId: user._id },
|
||||
// process.env.JWT_SECRET,
|
||||
// { expiresIn: process.env.JWT_EXPIRE }
|
||||
// )
|
||||
//
|
||||
// // 更新最后登录时间
|
||||
// user.lastLoginAt = new Date()
|
||||
// await user.save()
|
||||
//
|
||||
// // 记录登录日志
|
||||
// await recordLoginLog(
|
||||
// user._id,
|
||||
// openid,
|
||||
// 'auto_login',
|
||||
// 'wechat_webview',
|
||||
// 'webview_h5',
|
||||
// req,
|
||||
// true
|
||||
// )
|
||||
//
|
||||
// logger.info(`WebView Token交换成功: ${openid}`)
|
||||
//
|
||||
// return ApiResponse.success(res, {
|
||||
// token,
|
||||
// openid,
|
||||
// userInfo: {
|
||||
// nickName: user.nickName,
|
||||
// avatarUrl: user.avatarUrl
|
||||
// }
|
||||
// }, '登录成功')
|
||||
//
|
||||
// } catch (error) {
|
||||
// logger.error('WebView Token交换失败:', error)
|
||||
// return ApiResponse.serverError(res, error.message)
|
||||
// }
|
||||
// })
|
||||
//
|
||||
*/
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -5,6 +5,7 @@ const { Knowledge, KnowledgeLit } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const { userAuthMiddleware, optionalAuthMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
const { escapeRegExp } = require('../utils/escapeRegex')
|
||||
|
||||
const isValidId = (id) => mongoose.Types.ObjectId.isValid(id)
|
||||
|
||||
@@ -41,9 +42,9 @@ router.get('/search', async (req, res) => {
|
||||
const query = {
|
||||
status: 'approved',
|
||||
$or: [
|
||||
{ title: { $regex: q, $options: 'i' } },
|
||||
{ content: { $regex: q, $options: 'i' } },
|
||||
{ tags: { $regex: q, $options: 'i' } }
|
||||
{ title: { $regex: escapeRegExp(q), $options: 'i' } },
|
||||
{ content: { $regex: escapeRegExp(q), $options: 'i' } },
|
||||
{ tags: { $regex: escapeRegExp(q), $options: 'i' } }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -330,7 +331,12 @@ router.get('/:dim/questions', async (req, res) => {
|
||||
.limit(parseInt(limit))
|
||||
.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) {
|
||||
logger.error('获取问答失败:', error)
|
||||
return ApiResponse.serverError(res, '获取问答失败')
|
||||
|
||||
@@ -32,12 +32,18 @@ router.get('/robots.txt', (req, res) => {
|
||||
'Allow: /',
|
||||
'Disallow: /api/',
|
||||
'',
|
||||
'User-agent: GPTBot',
|
||||
'Allow: /',
|
||||
'User-agent: ClaudeBot',
|
||||
'Allow: /',
|
||||
'User-agent: PerplexityBot',
|
||||
'Allow: /',
|
||||
'# AI 引擎与搜索爬虫(SEO/GEO):全量欢迎抓取',
|
||||
...[
|
||||
// 国际 AI 引擎
|
||||
'GPTBot', 'OAI-SearchBot', 'ChatGPT-User',
|
||||
'ClaudeBot', 'Claude-User', 'anthropic-ai',
|
||||
'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`,
|
||||
'',
|
||||
@@ -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:真实内容直出,搜索引擎可收录) =====
|
||||
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
@@ -1,398 +1,395 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { User } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const { userAuthMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 获取排行榜(公开接口,无需登录)
|
||||
* GET /api/user/leaderboard
|
||||
*/
|
||||
router.get('/leaderboard', async (req, res) => {
|
||||
try {
|
||||
const { limit = 50, dim } = req.query
|
||||
|
||||
const users = await User.getLeaderboard(dim ? parseInt(dim) : null, parseInt(limit))
|
||||
|
||||
const data = users.map((u, i) => ({
|
||||
rank: i + 1,
|
||||
openid: u.openid,
|
||||
nickname: u.nickName || '星辰旅者',
|
||||
avatar: u.avatarUrl || '',
|
||||
totalScore: u.totalScore || 0,
|
||||
dim1Score: u.dim1Score || 0,
|
||||
dim2Score: u.dim2Score || 0,
|
||||
dim3Score: u.dim3Score || 0,
|
||||
dim4Score: u.dim4Score || 0,
|
||||
dim5Score: u.dim5Score || 0,
|
||||
unlockedDims: u.unlockedDims || [1]
|
||||
}))
|
||||
|
||||
return ApiResponse.success(res, data)
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取排行榜失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
// 应用用户认证中间件到所有路由(排行榜已在上方公开)
|
||||
router.use(userAuthMiddleware)
|
||||
|
||||
/**
|
||||
* 获取用户进度
|
||||
* GET /api/user/progress
|
||||
*/
|
||||
router.get('/progress', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
|
||||
if (!user) {
|
||||
// 新用户,返回默认进度
|
||||
return ApiResponse.success(res, {
|
||||
isNewUser: true,
|
||||
data: {
|
||||
unlockedDims: [1],
|
||||
dim1Score: 0,
|
||||
dim2Score: 0,
|
||||
dim3Score: 0,
|
||||
dim4Score: 0,
|
||||
dim5Score: 0,
|
||||
totalScore: 0,
|
||||
ownedSkins: [],
|
||||
isAdFree: false,
|
||||
isSubscriber: false,
|
||||
subscribeExpiry: null,
|
||||
totalPlayTime: 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
isNewUser: false,
|
||||
data: user
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取进度失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 保存用户进度
|
||||
* POST /api/user/progress
|
||||
*/
|
||||
router.post('/progress', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const openid = user.openid
|
||||
|
||||
const {
|
||||
dim,
|
||||
data,
|
||||
dim1Score,
|
||||
dim2Score,
|
||||
dim3Score,
|
||||
dim4Score,
|
||||
dim5Score,
|
||||
unlockedDims,
|
||||
ownedSkins,
|
||||
isAdFree,
|
||||
isSubscriber,
|
||||
subscribeExpiry,
|
||||
totalPlayTime
|
||||
} = req.body
|
||||
|
||||
// 查找或创建用户(理论上已通过中间件找到)
|
||||
let dbUser = user
|
||||
|
||||
// 新版接口:按维度更新
|
||||
if (dim && data) {
|
||||
const dimData = dbUser.exploreData[dim] || {}
|
||||
let updatedDim = { ...dimData }
|
||||
|
||||
if (dim === 'dim1') {
|
||||
updatedDim.completed = data.completed || dimData.completed || false
|
||||
updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0)
|
||||
updatedDim.collectedEggs = (dimData.collectedEggs || 0) + (data.newEggs || 0)
|
||||
updatedDim.totalLength = Math.max(data.totalLength || 0, dimData.totalLength || 0)
|
||||
} else if (dim === 'dim2') {
|
||||
updatedDim.completed = data.completed || dimData.completed || false
|
||||
updatedDim.bestArea = Math.max(data.bestArea || 0, dimData.bestArea || 0)
|
||||
updatedDim.createdShapes = (dimData.createdShapes || 0) + (data.newShapes || 0)
|
||||
} else if (dim === 'dim3') {
|
||||
updatedDim.completed = data.completed || dimData.completed || false
|
||||
updatedDim.exploredFaces = Math.max(data.exploredFaces || 0, dimData.exploredFaces || 0)
|
||||
updatedDim.rotationTime = (dimData.rotationTime || 0) + (data.rotationTime || 0)
|
||||
updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0)
|
||||
} else if (dim === 'dim4') {
|
||||
updatedDim.completed = data.completed || dimData.completed || false
|
||||
updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0)
|
||||
updatedDim.exploredEvents = (dimData.exploredEvents || 0) + (data.newEvents || 0)
|
||||
} else if (dim === 'dim5') {
|
||||
updatedDim.completed = data.completed || dimData.completed || false
|
||||
updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0)
|
||||
updatedDim.exploredThoughts = (dimData.exploredThoughts || 0) + (data.newThoughts || 0)
|
||||
}
|
||||
|
||||
dbUser.exploreData[dim] = updatedDim
|
||||
|
||||
// 同步更新已解锁维度
|
||||
if (data.completed && !dbUser.unlockedDims.includes(parseInt(dim.replace('dim', '')) + 1)) {
|
||||
const dimNum = parseInt(dim.replace('dim', ''))
|
||||
if (!dbUser.unlockedDims.includes(dimNum + 1)) {
|
||||
dbUser.unlockedDims.push(dimNum + 1)
|
||||
}
|
||||
}
|
||||
|
||||
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 (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 (dim4Score !== null && dim4Score !== undefined) {
|
||||
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 || {}
|
||||
dbUser.exploreData.dim5.bestScore = Math.max(dim5Score, dbUser.exploreData.dim5?.bestScore || 0)
|
||||
}
|
||||
|
||||
// 更新其他字段
|
||||
if (ownedSkins !== null && ownedSkins !== undefined) dbUser.ownedSkins = ownedSkins
|
||||
if (isAdFree !== null && isAdFree !== undefined) dbUser.isAdFree = isAdFree
|
||||
if (isSubscriber !== null && isSubscriber !== undefined) dbUser.isSubscriber = isSubscriber
|
||||
if (subscribeExpiry !== null && subscribeExpiry !== undefined) dbUser.subscribeExpiry = subscribeExpiry
|
||||
|
||||
await dbUser.save()
|
||||
|
||||
// 计算总分
|
||||
const totalScore = dbUser.calculateTotalScore()
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
success: true,
|
||||
totalScore,
|
||||
exploreData: user.exploreData
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('保存进度失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取用户成就
|
||||
* GET /api/user/achievements
|
||||
*/
|
||||
router.get('/achievements', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
|
||||
// 生成成就数据(AI 维度主题)
|
||||
const achievements = [
|
||||
{
|
||||
name: '知识探索',
|
||||
achievements: [
|
||||
{
|
||||
id: 'dim1_read',
|
||||
name: 'AI起源探索者',
|
||||
description: '完成 AI 起源维度的知识阅读',
|
||||
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 发展维度的知识阅读',
|
||||
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 当前格局维度的知识阅读',
|
||||
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 学习与工具维度的知识阅读',
|
||||
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 趋势追踪维度的知识阅读',
|
||||
icon: '⚡',
|
||||
unlocked: user.exploreData.dim5?.completed || false,
|
||||
date: user.exploreData.dim5?.completed ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '收藏成就',
|
||||
achievements: [
|
||||
{
|
||||
id: 'first_collection',
|
||||
name: '初次收藏',
|
||||
description: '收藏你的第一个 AI 知识',
|
||||
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 知识',
|
||||
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 知识',
|
||||
icon: '💛',
|
||||
unlocked: user.collectedKnowledge?.length >= 10,
|
||||
date: user.collectedKnowledge?.length >= 10 ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'AI 问答',
|
||||
achievements: [
|
||||
{
|
||||
id: 'first_ask',
|
||||
name: '初次提问',
|
||||
description: '向 AI 助手提出第一个问题',
|
||||
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 个问题',
|
||||
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 个问题',
|
||||
icon: '🧠',
|
||||
unlocked: user.achievements?.includes('fifty_asks') || false,
|
||||
date: user.achievements?.includes('fifty_asks') ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '社交成就',
|
||||
achievements: [
|
||||
{
|
||||
id: 'first_share',
|
||||
name: '初次分享',
|
||||
description: '分享你的第一个知识给朋友',
|
||||
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 次知识给朋友',
|
||||
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) {
|
||||
logger.error('获取成就失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取用户统计数据
|
||||
* GET /api/user/stats
|
||||
*/
|
||||
router.get('/stats', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
|
||||
// 获取等级信息
|
||||
const levelInfo = user.getLevelInfo()
|
||||
|
||||
// 计算探索时长(分钟转小时,保留1位小数)
|
||||
const playTimeHours = Math.round((user.totalPlayTime || 0) / 60 * 10) / 10
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
level: levelInfo.level,
|
||||
levelName: levelInfo.name,
|
||||
levelPoints: levelInfo.points,
|
||||
levelProgress: levelInfo.progress,
|
||||
totalProgress: levelInfo.totalProgress,
|
||||
nextLevelPoints: levelInfo.nextLevelPoints,
|
||||
totalPlayTime: playTimeHours,
|
||||
totalScore: user.totalScore || 0,
|
||||
worksCount: 0,
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取用户作品列表
|
||||
* GET /api/user/works
|
||||
* 说明:画廊(Gallery)功能已移除,统一返回空列表
|
||||
*/
|
||||
router.get('/works', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { page = 1, limit = 20 } = req.query
|
||||
|
||||
return ApiResponse.paginated(res, [], {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total: 0
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取用户作品失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { User } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const { userAuthMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 获取排行榜(公开接口,无需登录)
|
||||
* GET /api/user/leaderboard
|
||||
*/
|
||||
router.get('/leaderboard', async (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(100, parseInt(req.query.limit) || 50) // 封顶防拖库
|
||||
const dim = req.query.dim
|
||||
|
||||
const users = await User.getLeaderboard(dim ? parseInt(dim) : null, limit)
|
||||
|
||||
const data = users.map((u, i) => ({
|
||||
rank: i + 1,
|
||||
// [安全] 不再下发 openid(配合已下线的 webview-token 防接管;昵称已足够展示)
|
||||
nickname: u.nickName || '星辰旅者',
|
||||
avatar: u.avatarUrl || '',
|
||||
totalScore: u.totalScore || 0,
|
||||
dim1Score: u.dim1Score || 0,
|
||||
dim2Score: u.dim2Score || 0,
|
||||
dim3Score: u.dim3Score || 0,
|
||||
dim4Score: u.dim4Score || 0,
|
||||
dim5Score: u.dim5Score || 0,
|
||||
unlockedDims: u.unlockedDims || [1]
|
||||
}))
|
||||
|
||||
return ApiResponse.success(res, data)
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取排行榜失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
// 应用用户认证中间件到所有路由(排行榜已在上方公开)
|
||||
router.use(userAuthMiddleware)
|
||||
|
||||
/**
|
||||
* 获取用户进度
|
||||
* GET /api/user/progress
|
||||
*/
|
||||
router.get('/progress', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
|
||||
if (!user) {
|
||||
// 新用户,返回默认进度
|
||||
return ApiResponse.success(res, {
|
||||
isNewUser: true,
|
||||
data: {
|
||||
unlockedDims: [1],
|
||||
dim1Score: 0,
|
||||
dim2Score: 0,
|
||||
dim3Score: 0,
|
||||
dim4Score: 0,
|
||||
dim5Score: 0,
|
||||
totalScore: 0,
|
||||
ownedSkins: [],
|
||||
isAdFree: false,
|
||||
isSubscriber: false,
|
||||
subscribeExpiry: null,
|
||||
totalPlayTime: 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
isNewUser: false,
|
||||
data: user
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取进度失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 保存用户进度
|
||||
* POST /api/user/progress
|
||||
*/
|
||||
router.post('/progress', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const openid = user.openid
|
||||
|
||||
const {
|
||||
dim,
|
||||
data,
|
||||
dim1Score,
|
||||
dim2Score,
|
||||
dim3Score,
|
||||
dim4Score,
|
||||
dim5Score,
|
||||
unlockedDims,
|
||||
ownedSkins,
|
||||
isAdFree,
|
||||
isSubscriber,
|
||||
subscribeExpiry,
|
||||
totalPlayTime
|
||||
} = req.body
|
||||
|
||||
// 查找或创建用户(理论上已通过中间件找到)
|
||||
let dbUser = user
|
||||
|
||||
// 新版接口:按维度更新
|
||||
if (dim && data) {
|
||||
const dimData = dbUser.exploreData[dim] || {}
|
||||
let updatedDim = { ...dimData }
|
||||
|
||||
if (dim === 'dim1') {
|
||||
updatedDim.completed = data.completed || dimData.completed || false
|
||||
updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0)
|
||||
updatedDim.collectedEggs = (dimData.collectedEggs || 0) + (data.newEggs || 0)
|
||||
updatedDim.totalLength = Math.max(data.totalLength || 0, dimData.totalLength || 0)
|
||||
} else if (dim === 'dim2') {
|
||||
updatedDim.completed = data.completed || dimData.completed || false
|
||||
updatedDim.bestArea = Math.max(data.bestArea || 0, dimData.bestArea || 0)
|
||||
updatedDim.createdShapes = (dimData.createdShapes || 0) + (data.newShapes || 0)
|
||||
} else if (dim === 'dim3') {
|
||||
updatedDim.completed = data.completed || dimData.completed || false
|
||||
updatedDim.exploredFaces = Math.max(data.exploredFaces || 0, dimData.exploredFaces || 0)
|
||||
updatedDim.rotationTime = (dimData.rotationTime || 0) + (data.rotationTime || 0)
|
||||
updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0)
|
||||
} else if (dim === 'dim4') {
|
||||
updatedDim.completed = data.completed || dimData.completed || false
|
||||
updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0)
|
||||
updatedDim.exploredEvents = (dimData.exploredEvents || 0) + (data.newEvents || 0)
|
||||
} else if (dim === 'dim5') {
|
||||
updatedDim.completed = data.completed || dimData.completed || false
|
||||
updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0)
|
||||
updatedDim.exploredThoughts = (dimData.exploredThoughts || 0) + (data.newThoughts || 0)
|
||||
}
|
||||
|
||||
dbUser.exploreData[dim] = updatedDim
|
||||
|
||||
// 同步更新已解锁维度
|
||||
if (data.completed && !dbUser.unlockedDims.includes(parseInt(dim.replace('dim', '')) + 1)) {
|
||||
const dimNum = parseInt(dim.replace('dim', ''))
|
||||
if (!dbUser.unlockedDims.includes(dimNum + 1)) {
|
||||
dbUser.unlockedDims.push(dimNum + 1)
|
||||
}
|
||||
}
|
||||
|
||||
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 (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 (dim4Score !== null && dim4Score !== undefined) {
|
||||
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 || {}
|
||||
dbUser.exploreData.dim5.bestScore = Math.max(dim5Score, dbUser.exploreData.dim5?.bestScore || 0)
|
||||
}
|
||||
|
||||
// [安全] 付费权益字段(ownedSkins/isAdFree/isSubscriber/subscribeExpiry)不接受客户端写入,仅支付回调/管理员可改
|
||||
|
||||
await dbUser.save()
|
||||
|
||||
// 计算总分
|
||||
const totalScore = dbUser.calculateTotalScore()
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
success: true,
|
||||
totalScore,
|
||||
exploreData: user.exploreData
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('保存进度失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取用户成就
|
||||
* GET /api/user/achievements
|
||||
*/
|
||||
router.get('/achievements', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
|
||||
// 生成成就数据(AI 维度主题)
|
||||
const achievements = [
|
||||
{
|
||||
name: '知识探索',
|
||||
achievements: [
|
||||
{
|
||||
id: 'dim1_read',
|
||||
name: 'AI起源探索者',
|
||||
description: '完成 AI 起源维度的知识阅读',
|
||||
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 发展维度的知识阅读',
|
||||
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 当前格局维度的知识阅读',
|
||||
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 学习与工具维度的知识阅读',
|
||||
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 趋势追踪维度的知识阅读',
|
||||
icon: '⚡',
|
||||
unlocked: user.exploreData.dim5?.completed || false,
|
||||
date: user.exploreData.dim5?.completed ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '收藏成就',
|
||||
achievements: [
|
||||
{
|
||||
id: 'first_collection',
|
||||
name: '初次收藏',
|
||||
description: '收藏你的第一个 AI 知识',
|
||||
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 知识',
|
||||
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 知识',
|
||||
icon: '💛',
|
||||
unlocked: user.collectedKnowledge?.length >= 10,
|
||||
date: user.collectedKnowledge?.length >= 10 ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'AI 问答',
|
||||
achievements: [
|
||||
{
|
||||
id: 'first_ask',
|
||||
name: '初次提问',
|
||||
description: '向 AI 助手提出第一个问题',
|
||||
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 个问题',
|
||||
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 个问题',
|
||||
icon: '🧠',
|
||||
unlocked: user.achievements?.includes('fifty_asks') || false,
|
||||
date: user.achievements?.includes('fifty_asks') ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '社交成就',
|
||||
achievements: [
|
||||
{
|
||||
id: 'first_share',
|
||||
name: '初次分享',
|
||||
description: '分享你的第一个知识给朋友',
|
||||
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 次知识给朋友',
|
||||
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) {
|
||||
logger.error('获取成就失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取用户统计数据
|
||||
* GET /api/user/stats
|
||||
*/
|
||||
router.get('/stats', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
|
||||
// 获取等级信息
|
||||
const levelInfo = user.getLevelInfo()
|
||||
|
||||
// 计算探索时长(分钟转小时,保留1位小数)
|
||||
const playTimeHours = Math.round((user.totalPlayTime || 0) / 60 * 10) / 10
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
level: levelInfo.level,
|
||||
levelName: levelInfo.name,
|
||||
levelPoints: levelInfo.points,
|
||||
levelProgress: levelInfo.progress,
|
||||
totalProgress: levelInfo.totalProgress,
|
||||
nextLevelPoints: levelInfo.nextLevelPoints,
|
||||
totalPlayTime: playTimeHours,
|
||||
totalScore: user.totalScore || 0,
|
||||
worksCount: 0,
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取用户作品列表
|
||||
* GET /api/user/works
|
||||
* 说明:画廊(Gallery)功能已移除,统一返回空列表
|
||||
*/
|
||||
router.get('/works', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { page = 1, limit = 20 } = req.query
|
||||
|
||||
return ApiResponse.paginated(res, [], {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total: 0
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取用户作品失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -38,6 +38,11 @@ module.exports = function registerWxMsgRoutes(app) {
|
||||
|
||||
app.post('/api/wx/msg', async (req, res) => {
|
||||
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 时是字符串
|
||||
let fields = {}
|
||||
if (req.body && typeof req.body === 'object') {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/** 用户输入进 $regex 前转义特殊字符(防 ReDoS/正则注入) */
|
||||
function escapeRegExp(s) {
|
||||
return String(s == null ? '' : s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
module.exports = { escapeRegExp }
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 微信 access_token 缓存(进程内 + 提前5分钟刷新)
|
||||
*/
|
||||
let cached = { token: '', expireAt: 0 }
|
||||
|
||||
async function getAccessToken() {
|
||||
if (cached.token && Date.now() < cached.expireAt) return cached.token
|
||||
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${process.env.WX_APPID}&secret=${process.env.WX_SECRET}`
|
||||
const r = await fetch(url, { signal: AbortSignal.timeout(10000) }).then(x => x.json())
|
||||
if (!r.access_token) throw new Error('获取access_token失败: ' + JSON.stringify(r).slice(0, 120))
|
||||
cached = { token: r.access_token, expireAt: Date.now() + (r.expires_in - 300) * 1000 }
|
||||
return cached.token
|
||||
}
|
||||
|
||||
module.exports = { getAccessToken }
|
||||
Reference in New Issue
Block a user