feat(admin): 新增管理后台(登录/用户管理/日志查看) + 行为日志文件采集(请求中间件 + log_event)

This commit is contained in:
2026-10-15 18:43:19 +08:00
parent ac07cb2008
commit c78df3a6a6
20 changed files with 2493 additions and 1 deletions
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>蜂群中枢 · PlantAI 管理后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1147
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "plantai-admin",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "3.5.13"
},
"devDependencies": {
"vite": "5.4.11",
"@vitejs/plugin-vue": "5.2.1"
}
}
+124
View File
@@ -0,0 +1,124 @@
<script setup>
import { ref, onMounted } from 'vue';
import { getToken, setToken, api } from './api/admin.js';
import Login from './views/Login.vue';
import Users from './views/Users.vue';
import Logs from './views/Logs.vue';
const authed = ref(false);
const page = ref('overview');
const user = ref(null);
const stats = ref({ users: 0, total: 0, denied: 0, p95: null });
const PAGES = { overview: '概览', users: '用户管理', logs: '日志查看' };
function onUnauthorized() { setToken(''); authed.value = false; }
async function loadStats() {
try {
const [u, l] = await Promise.all([api.users(), api.logs({ page: 1, page_size: 1 })]);
stats.value.users = u.users.length;
stats.value.total = l.total;
stats.value.denied = (l.aggregates && l.aggregates.denied) || 0;
stats.value.p95 = (l.aggregates && l.aggregates.p95_latency_ms) || null;
} catch (e) {
if (e.status === 401) onUnauthorized();
}
}
async function init() {
if (!getToken()) { authed.value = false; return; }
try {
const r = await api.me();
user.value = r.user;
authed.value = true;
loadStats();
} catch (e) {
setToken('');
authed.value = false;
}
}
function onLogin(u) { user.value = u; authed.value = true; page.value = 'overview'; loadStats(); }
async function onLogout() { try { await api.logout(); } catch {} setToken(''); authed.value = false; }
onMounted(init);
</script>
<template>
<Login v-if="!authed" @login="onLogin" />
<div v-else class="shell">
<aside class="sidebar">
<div class="side-brand">
<div class="hex">蜂</div>
<div><div class="brand-name">蜂群中枢</div><div class="brand-sub">PlantAI 管理后台</div></div>
</div>
<nav class="nav">
<div class="nav-item" :class="{ active: page === 'overview' }" @click="page = 'overview'"><span>◈</span> 概览</div>
<div class="nav-item" :class="{ active: page === 'users' }" @click="page = 'users'"><span>◉</span> 用户管理</div>
<div class="nav-item" :class="{ active: page === 'logs' }" @click="page = 'logs'"><span>≣</span> 日志查看</div>
</nav>
<div class="side-foot">
<div class="who">
<div class="avatar">蜂</div>
<div><div class="who-name">{{ user?.username }}</div><div class="who-role">系统管理员</div></div>
<span class="logout" @click="onLogout">退出</span>
</div>
</div>
</aside>
<main class="main">
<div class="topbar"><h1>{{ PAGES[page] }}</h1><span class="crumb" style="color:var(--text-3);font-size:13px">中枢</span></div>
<div class="content">
<section v-if="page === 'overview'">
<div class="stats">
<div class="stat"><div class="k">{{ stats.users }}</div><div class="l">注册用户</div></div>
<div class="stat"><div class="k amber">{{ stats.total }}</div><div class="l">累计请求</div></div>
<div class="stat"><div class="k">{{ stats.denied }}</div><div class="l">越权 / 失败</div></div>
<div class="stat"><div class="k">{{ stats.p95 ?? '—' }}</div><div class="l">P95 耗时 ms</div></div>
</div>
</section>
<Users v-else-if="page === 'users'" @unauthorized="onUnauthorized" />
<Logs v-else @unauthorized="onUnauthorized" />
</div>
</main>
</div>
</template>
<style>
.shell { display: flex; height: 100vh; }
.sidebar { width: 218px; flex: none; background: var(--bg-2); border-right: 1px solid var(--border); display: flex; flex-direction: column; }
.side-brand { display: flex; align-items: center; gap: 10px; padding: 20px 18px 18px; }
.hex { width: 30px; height: 30px; flex: none; background: linear-gradient(135deg, #f2b95a, #d98f22); clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%); display: grid; place-items: center; color: #1a1206; font-weight: 700; font-size: 12px; }
.brand-name { font-size: 15px; font-weight: 600; }
.brand-sub { font-size: 11px; color: var(--text-3); }
.nav { padding: 6px 10px; flex: 1; }
.nav-item { display: flex; align-items: center; gap: 9px; padding: 9px 12px; margin-bottom: 3px; border-radius: var(--radius-sm); color: var(--text-2); font-size: 13.5px; cursor: pointer; }
.nav-item:hover { background: var(--surface); color: var(--text); }
.nav-item.active { background: var(--accent-soft); color: var(--accent); }
.side-foot { padding: 14px 18px; border-top: 1px solid var(--border); }
.who { display: flex; align-items: center; gap: 10px; }
.avatar { width: 30px; height: 30px; border-radius: 50%; flex: none; background: linear-gradient(135deg, #3a4a5e, #243146); display: grid; place-items: center; font-size: 12px; color: var(--text); }
.who-name { font-size: 13px; }
.who-role { font-size: 11px; color: var(--text-3); }
.logout { margin-left: auto; color: var(--text-3); cursor: pointer; font-size: 12px; }
.logout:hover { color: var(--red); }
.main { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
.topbar { display: flex; align-items: center; gap: 14px; padding: 18px 28px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: rgba(12, 14, 20, 0.9); backdrop-filter: blur(6px); z-index: 5; }
.topbar h1 { font-size: 17px; margin: 0; font-weight: 600; }
.content { padding: 26px 28px 40px; }
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
.stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; }
.stat .k { font-size: 26px; font-weight: 600; letter-spacing: -0.5px; }
.stat .k.amber { color: var(--accent); }
.stat .l { color: var(--text-2); font-size: 12.5px; margin-top: 3px; }
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
.toolbar .spacer { flex: 1; }
.search { display: flex; align-items: center; gap: 8px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 12px; width: 240px; }
.search input { background: none; border: none; outline: none; color: var(--text); font-size: 13px; width: 100%; }
select { background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius-sm); padding: 8px 10px; font-size: 13px; outline: none; }
.pag { display: flex; align-items: center; gap: 8px; padding: 12px 18px; border-top: 1px solid var(--border); color: var(--text-3); font-size: 12.5px; }
.pag .spacer { flex: 1; }
.pag button { background: var(--surface-2); border: 1px solid var(--border); color: var(--text-2); border-radius: 6px; padding: 5px 11px; font-size: 12.5px; }
.pag button.on { color: var(--accent); border-color: var(--accent); }
.pag button:disabled { opacity: 0.4; cursor: default; }
</style>
+46
View File
@@ -0,0 +1,46 @@
const TOKEN_KEY = 'pa-admin-token';
let token = localStorage.getItem(TOKEN_KEY) || '';
export function getToken() { return token; }
export function setToken(t) {
token = t || '';
if (t) localStorage.setItem(TOKEN_KEY, t);
else localStorage.removeItem(TOKEN_KEY);
}
async function request(path, { method = 'GET', body } = {}) {
const res = await fetch('/api/admin' + path, {
method,
headers: {
...(body ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: 'Bearer ' + token } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
let data = null;
try { data = await res.json(); } catch { data = null; }
if (!res.ok) {
const err = new Error((data && data.error && data.error.message) || ('请求失败 HTTP ' + res.status));
err.status = res.status;
err.code = data && data.error && data.error.code;
throw err;
}
return data;
}
export const api = {
login: (username, password) => request('/login', { method: 'POST', body: { username, password } }),
logout: () => request('/logout', { method: 'POST' }),
me: () => request('/me'),
units: () => request('/units'),
users: () => request('/users'),
createUser: (body) => request('/users', { method: 'POST', body }),
updateUser: (id, body) => request('/users/' + id, { method: 'PATCH', body }),
deleteUser: (id) => request('/users/' + id, { method: 'DELETE' }),
logs: (params = {}) => {
const q = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') q.set(k, v); });
const qs = q.toString();
return request('/logs' + (qs ? '?' + qs : ''));
},
};
+101
View File
@@ -0,0 +1,101 @@
<script setup>
import { ref, watch } from 'vue';
import { api } from '../api/admin.js';
const props = defineProps({
open: Boolean,
mode: { type: String, default: 'create' },
user: Object,
});
const emit = defineEmits(['close', 'saved']);
const UNITS = [
['qa', '问答'], ['kg', '知识图谱'], ['extraction', '文献抽取'], ['docgen', '文档生成'],
['figures', '图片图库'], ['private_docs', '私有资料'], ['bioflow', '生信分析'],
['paper', '论文撰写'], ['legacy', '旧版接口'],
];
const username = ref('');
const password = ref('');
const role = ref('researcher');
const authorizations = ref([]);
const busy = ref(false);
const error = ref('');
watch(() => props.open, (open) => {
if (open) {
error.value = '';
busy.value = false;
if (props.mode === 'edit' && props.user) {
username.value = props.user.username;
password.value = '';
role.value = props.user.role === 'admin' ? 'admin' : (props.user.role === 'guest' ? 'guest' : 'researcher');
authorizations.value = [...(props.user.authorizations || [])];
} else {
username.value = '';
password.value = '';
role.value = 'researcher';
authorizations.value = ['qa'];
}
}
});
async function save() {
if (busy.value) return;
if (props.mode === 'create' && (!username.value.trim() || !password.value)) {
error.value = '用户名和密码必填';
return;
}
busy.value = true;
error.value = '';
try {
if (props.mode === 'create') {
await api.createUser({ username: username.value.trim(), password: password.value, role: role.value, authorizations: authorizations.value });
} else {
const body = { role: role.value, authorizations: authorizations.value };
if (password.value) body.password = password.value;
await api.updateUser(props.user.id, body);
}
emit('saved');
} catch (e) {
error.value = e.message || '保存失败';
} finally {
busy.value = false;
}
}
</script>
<template>
<div v-if="open" class="overlay" @click.self="emit('close')">
<div class="modal">
<div class="modal-head">{{ mode === 'create' ? '新建用户' : '编辑用户' }}</div>
<div class="modal-body">
<div class="field"><label>用户名</label><input v-model="username" :disabled="mode === 'edit'"></div>
<div class="field"><label>{{ mode === 'create' ? '初始密码' : '重置密码(留空不改)' }}</label><input v-model="password" type="password"></div>
<div class="field">
<label>角色</label>
<div class="seg">
<button :class="{ on: role === 'researcher' }" @click="role = 'researcher'">研究员</button>
<button :class="{ on: role === 'guest' }" @click="role = 'guest'">访客</button>
<button :class="{ on: role === 'admin' }" @click="role = 'admin'">管理员</button>
</div>
</div>
<div class="field" style="margin-bottom: 0">
<label>授权清单</label>
<div class="checks">
<label v-for="[k, label] in UNITS" :key="k" class="check"><input type="checkbox" :value="k" v-model="authorizations"> {{ label }}</label>
</div>
</div>
<p v-if="error" class="err">{{ error }}</p>
</div>
<div class="modal-foot">
<button class="btn btn-ghost" @click="emit('close')">取消</button>
<button class="btn btn-primary" :disabled="busy" @click="save">{{ busy ? '保存中…' : (mode === 'create' ? '创建用户' : '保存') }}</button>
</div>
</div>
</div>
</template>
<style scoped>
.err { color: var(--red); font-size: 12px; margin: 12px 0 0; }
</style>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue';
import App from './App.vue';
import './style.css';
createApp(App).mount('#app');
+96
View File
@@ -0,0 +1,96 @@
:root {
--bg: #0c0e14;
--bg-2: #10131b;
--surface: #151924;
--surface-2: #1a1f2c;
--border: #232a38;
--border-2: #2e3648;
--text: #e9edf5;
--text-2: #9aa4b6;
--text-3: #5f6a7d;
--accent: #e8a33d;
--accent-ink: #14100a;
--accent-soft: rgba(232, 163, 61, 0.14);
--green: #3ecf8e;
--green-soft: rgba(62, 207, 142, 0.14);
--red: #ef6b6b;
--red-soft: rgba(239, 107, 107, 0.14);
--blue: #6ea8ff;
--radius: 10px;
--radius-sm: 7px;
}
* { box-sizing: border-box; }
html, body, #app { margin: 0; height: 100%; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.mono { font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; }
button { font-family: inherit; cursor: pointer; }
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
padding: 9px 15px; font-size: 13.5px; border-radius: var(--radius-sm);
border: 1px solid transparent; transition: filter .15s; background: transparent;
}
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn-primary { background: var(--accent); color: var(--accent-ink); font-weight: 600; }
.btn-primary:hover { filter: brightness(1.06); }
.btn-ghost { border-color: var(--border-2); color: var(--text-2); }
.btn-ghost:hover { color: var(--text); border-color: var(--text-3); }
.btn-danger { background: var(--red-soft); color: var(--red); }
.btn-block { width: 100%; }
.field { margin-bottom: 15px; }
.field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 7px; }
.field input, .field select {
width: 100%; padding: 10px 12px; font-size: 14px; color: var(--text);
background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--radius-sm); outline: none;
}
.field input:focus, .field select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.chip {
display: inline-block; padding: 3px 9px; border-radius: 20px; font-size: 11.5px;
border: 1px solid var(--border-2); color: var(--text-2); margin: 1.5px 3px 1.5px 0;
}
.chip.amber { border-color: transparent; background: var(--accent-soft); color: var(--accent); }
.badge { display: inline-block; padding: 2px 8px; border-radius: 5px; font-size: 11.5px; font-family: ui-monospace, Menlo, monospace; }
.badge.green { background: var(--green-soft); color: var(--green); }
.badge.red { background: var(--red-soft); color: var(--red); }
.badge.amber { background: var(--accent-soft); color: var(--accent); }
.badge.muted { background: var(--surface-2); color: var(--text-3); }
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; font-size: 11.5px; font-weight: 500; color: var(--text-3); padding: 10px 18px; border-bottom: 1px solid var(--border); }
td { padding: 13px 18px; border-bottom: 1px solid var(--border); font-size: 13.5px; }
tr:last-child td { border-bottom: none; }
tbody tr:hover { background: rgba(255,255,255,0.015); }
.cell-id { color: var(--text-2); }
.row-actions { display: flex; gap: 6px; }
.row-actions .act { color: var(--blue); cursor: pointer; font-size: 13px; padding: 3px 6px; border-radius: 5px; }
.row-actions .act:hover { background: rgba(110,168,255,0.1); }
.row-actions .act.danger { color: var(--red); }
.row-actions .act.danger:hover { background: var(--red-soft); }
.role { font-weight: 600; }
.role.admin { color: var(--accent); }
.role.researcher { color: var(--blue); }
.role.guest { color: var(--text-2); }
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); display: flex; align-items: center; justify-content: center; z-index: 50; }
.modal { width: 460px; max-width: calc(100vw - 32px); background: var(--surface); border: 1px solid var(--border-2); border-radius: 14px; }
.modal-head { padding: 18px 22px 0; font-size: 15px; font-weight: 600; }
.modal-body { padding: 18px 22px; }
.modal-foot { padding: 14px 22px 20px; display: flex; justify-content: flex-end; gap: 10px; border-top: 1px solid var(--border); }
.checks { display: grid; grid-template-columns: 1fr 1fr; gap: 9px 14px; margin-top: 8px; }
.check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-2); }
.check input { accent-color: var(--accent); width: 15px; height: 15px; }
.seg { display: flex; gap: 6px; }
.seg button { background: transparent; border: 1px solid var(--border-2); color: var(--text-2); border-radius: 7px; padding: 7px 14px; font-size: 13px; }
.seg button.on { background: var(--accent-soft); border-color: transparent; color: var(--accent); }
+53
View File
@@ -0,0 +1,53 @@
<script setup>
import { ref } from 'vue';
import { api, setToken } from '../api/admin.js';
const emit = defineEmits(['login']);
const username = ref('');
const password = ref('');
const error = ref('');
const busy = ref(false);
async function submit() {
if (busy.value) return;
if (!username.value || !password.value) { error.value = '请输入用户名和密码'; return; }
busy.value = true;
error.value = '';
try {
const r = await api.login(username.value, password.value);
setToken(r.token);
emit('login', r.user);
} catch (e) {
error.value = e.message || '登录失败';
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="login">
<div class="card">
<div class="brand">
<div class="hex">蜂</div>
<div><div class="brand-name">蜂群中枢</div><div class="brand-sub">PlantAI 管理后台</div></div>
</div>
<div class="field"><label>用户名</label><input v-model="username" autocomplete="username" @keyup.enter="submit"></div>
<div class="field"><label>密码</label><input v-model="password" type="password" autocomplete="current-password" @keyup.enter="submit"></div>
<button class="btn btn-primary btn-block" :disabled="busy" @click="submit">{{ busy ? '登录中…' : '登录' }}</button>
<p v-if="error" class="err">{{ error }}</p>
<div class="hint">仅限中枢管理员。登录与所有操作均会留痕。</div>
</div>
</div>
</template>
<style scoped>
.login { height: 100vh; display: flex; align-items: center; justify-content: center; background: radial-gradient(1200px 600px at 70% -10%, rgba(232, 163, 61, 0.08), transparent 60%), var(--bg); }
.card { width: 360px; background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 34px 32px 30px; }
.brand { display: flex; align-items: center; gap: 11px; margin-bottom: 26px; }
.hex { width: 34px; height: 34px; flex: none; background: linear-gradient(135deg, #f2b95a, #d98f22); clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%); display: grid; place-items: center; color: #1a1206; font-weight: 700; font-size: 14px; }
.brand-name { font-size: 16px; font-weight: 600; }
.brand-sub { color: var(--text-3); font-size: 12px; }
.err { color: var(--red); font-size: 12px; margin: 10px 0 0; }
.hint { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border); font-size: 12px; color: var(--text-3); }
</style>
+102
View File
@@ -0,0 +1,102 @@
<script setup>
import { ref, onMounted } from 'vue';
import { api } from '../api/admin.js';
const emit = defineEmits(['unauthorized']);
const from = ref('');
const event = ref('');
const q = ref('');
const page = ref(1);
const pageSize = 30;
const items = ref([]);
const total = ref(0);
const aggregates = ref({});
const loading = ref(false);
const EVENT_LABEL = {
http_request: '调用', admin_login_ok: '登录成功', admin_login_fail: '登录失败',
call_denied: '越权', user_created: '建用户', user_updated: '改用户', user_deleted: '删用户',
};
function statusBadge(s) {
if (!s) return 'muted';
if (s >= 200 && s < 300) return 'green';
if (s >= 500) return 'red';
return 'amber';
}
async function load() {
loading.value = true;
try {
const r = await api.logs({
from_date: from.value || undefined,
event: event.value || undefined,
q: q.value || undefined,
page: page.value,
page_size: pageSize,
});
items.value = r.items;
total.value = r.total;
aggregates.value = r.aggregates || {};
} catch (e) {
if (e.status === 401) emit('unauthorized');
} finally {
loading.value = false;
}
}
function search() { page.value = 1; load(); }
function prev() { if (page.value > 1) { page.value -= 1; load(); } }
function next() { if (page.value * pageSize < total.value) { page.value += 1; load(); } }
onMounted(load);
</script>
<template>
<div>
<div class="stats" style="margin-bottom: 16px">
<div class="stat"><div class="k">{{ total }}</div><div class="l">匹配日志</div></div>
<div class="stat"><div class="k amber">{{ aggregates.denied ?? 0 }}</div><div class="l">越权 / 失败</div></div>
<div class="stat"><div class="k">{{ aggregates.avg_latency_ms ?? '—' }}</div><div class="l">平均耗时 ms</div></div>
<div class="stat"><div class="k">{{ aggregates.p95_latency_ms ?? '—' }}</div><div class="l">P95 耗时 ms</div></div>
</div>
<div class="toolbar">
<input class="date" v-model="from" type="date">
<select v-model="event">
<option value="">全部事件</option>
<option v-for="(l, k) in EVENT_LABEL" :key="k" :value="k">{{ l }}</option>
</select>
<div class="search"><input v-model="q" placeholder="按用户 / 路径过滤…" @keyup.enter="search"></div>
<button class="btn btn-ghost" @click="search">查询</button>
</div>
<div class="panel">
<table>
<thead><tr><th>时间</th><th>用户</th><th>事件</th><th>方法</th><th>路径</th><th>状态</th><th>耗时</th></tr></thead>
<tbody>
<tr v-if="loading"><td colspan="7" class="cell-id">加载中…</td></tr>
<tr v-else-if="!items.length"><td colspan="7" class="cell-id">暂无日志</td></tr>
<tr v-for="(e, i) in items" :key="i">
<td class="cell-id mono">{{ (e.ts || '').replace('T', ' ').slice(0, 19) }}</td>
<td class="mono cell-id">{{ e.user_id ? e.user_id.slice(0, 8) : '—' }}</td>
<td>{{ EVENT_LABEL[e.event] || e.event }}</td>
<td class="cell-id mono">{{ e.method || '—' }}</td>
<td class="mono">{{ e.path || '—' }}</td>
<td><span v-if="e.status" class="badge" :class="statusBadge(e.status)">{{ e.status }}</span><span v-else class="badge muted">—</span></td>
<td class="cell-id mono">{{ e.latency_ms != null ? e.latency_ms + 'ms' : '—' }}</td>
</tr>
</tbody>
</table>
<div class="pag">
<span>共 {{ total }} 条</span><span class="spacer"></span>
<button :disabled="page <= 1" @click="prev">上一页</button>
<button class="on">{{ page }}</button>
<button :disabled="page * pageSize >= total" @click="next">下一页</button>
</div>
</div>
</div>
</template>
<style scoped>
input.date { background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius-sm); padding: 8px 10px; font-size: 13px; outline: none; }
</style>
+72
View File
@@ -0,0 +1,72 @@
<script setup>
import { ref, onMounted } from 'vue';
import { api } from '../api/admin.js';
import UserForm from '../components/UserForm.vue';
const emit = defineEmits(['unauthorized']);
const users = ref([]);
const loading = ref(true);
const formOpen = ref(false);
const formMode = ref('create');
const editing = ref(null);
const ROLE_LABEL = { admin: '管理员', researcher: '研究员', guest: '访客' };
const UNIT_LABEL = { qa: '问答', kg: '知识图谱', extraction: '文献抽取', docgen: '文档生成', figures: '图片图库', private_docs: '私有资料', bioflow: '生信分析', paper: '论文撰写', legacy: '旧版接口' };
async function load() {
loading.value = true;
try {
const r = await api.users();
users.value = r.users;
} catch (e) {
if (e.status === 401) emit('unauthorized');
} finally {
loading.value = false;
}
}
function create() { formMode.value = 'create'; editing.value = null; formOpen.value = true; }
function edit(u) { formMode.value = 'edit'; editing.value = u; formOpen.value = true; }
async function del(u) {
if (!confirm(`删除用户「${u.username}」?此操作不可撤销。`)) return;
try { await api.deleteUser(u.id); load(); } catch (e) { alert(e.message || '删除失败'); }
}
function onSaved() { formOpen.value = false; load(); }
onMounted(load);
</script>
<template>
<div>
<div class="toolbar">
<div class="spacer"></div>
<button class="btn btn-primary" @click="create">+ 新建用户</button>
</div>
<div class="panel">
<table>
<thead><tr><th>用户</th><th>角色</th><th>授权范围</th><th>创建时间</th><th style="width: 120px"></th></tr></thead>
<tbody>
<tr v-if="loading"><td colspan="5" class="cell-id">加载中…</td></tr>
<tr v-else-if="!users.length"><td colspan="5" class="cell-id">暂无用户</td></tr>
<tr v-for="u in users" :key="u.id">
<td>
<div style="display: flex; align-items: center; gap: 10px">
<div class="avatar">{{ u.username[0] }}</div>
<div><div>{{ u.username }}</div><div class="cell-id" style="font-size: 12px">{{ u.id.slice(0, 8) }}</div></div>
</div>
</td>
<td><span class="role" :class="u.role">{{ ROLE_LABEL[u.role] || u.role }}</span></td>
<td>
<span v-if="u.role === 'admin'" class="chip amber">全部权限</span>
<template v-else><span v-for="a in u.authorizations" :key="a" class="chip">{{ UNIT_LABEL[a] || a }}</span></template>
</td>
<td class="cell-id mono">{{ (u.created_at || '').slice(0, 10) }}</td>
<td><div class="row-actions"><span class="act" @click="edit(u)">编辑</span><span class="act danger" @click="del(u)">删除</span></div></td>
</tr>
</tbody>
</table>
</div>
<UserForm :open="formOpen" :mode="formMode" :user="editing" @close="formOpen = false" @saved="onSaved" />
</div>
</template>
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
base: '/admin/',
plugins: [vue()],
server: {
host: '127.0.0.1',
port: 5181,
proxy: {
'/api': { target: 'http://127.0.0.1:8090', changeOrigin: false },
},
},
build: { outDir: 'dist', target: 'es2020' },
});
+35
View File
@@ -0,0 +1,35 @@
"""auth.py —— 管理后台鉴权依赖 + 鉴权异常。"""
from __future__ import annotations
from fastapi import Depends, Request
from .db import Database, get_db
from .logwriter import log_event
from .services.auth import resolve_token
class AuthError(Exception):
def __init__(self, status: int, code: str, message: str) -> None:
super().__init__(message)
self.status = status
self.code = code
self.message = message
def _bearer(request: Request) -> str:
header = request.headers.get("Authorization", "")
return header[7:] if header.startswith("Bearer ") else ""
async def require_admin(request: Request, db: Database = Depends(get_db)) -> dict:
"""校验 Authorization: Bearer <token>,并要求角色为 admin;失败抛 AuthError 并留痕。"""
token = _bearer(request)
user = await resolve_token(db, token) if token else None
if user is None:
log_event("call_denied", method=request.method, path=request.url.path, detail={"reason": "unauthorized"})
raise AuthError(401, "UNAUTHORIZED", "未登录或登录已过期")
if user["role"] != "admin":
log_event("call_denied", user_id=user["id"], method=request.method, path=request.url.path, detail={"reason": "not_admin"})
raise AuthError(403, "FORBIDDEN", "非管理员账号")
request.state.user = user
return user
+8
View File
@@ -86,6 +86,14 @@ class Settings(BaseSettings):
review_service_url: str = "http://127.0.0.1:8100"
review_read_timeout: float = 360.0 # presubmit 上传+LLM 长调用读超时(秒)
# ---- 管理后台(admin console + 行为日志)----
admin_seed_username: str = "admin" # 种子管理员用户名(启动时若无 admin 则创建)
admin_seed_password: str = "admin" # 种子管理员初始密码
admin_token_ttl_seconds: int = 86400 # 管理员会话凭据有效期(秒)
admin_dist_dir: str = "admin/dist" # 管理后台前端构建产物(BFF 托管 /admin)
log_dir: str = "data/logs" # 行为日志目录(每日一个 JSONL 文件)
log_retention_days: int = 0 # 日志保留天数(0=不清理)
# ---- 生产形态:BFF 静态托管 ui/dist(ARCHITECTURE.md §3,M6 挂载)----
frontend_dist_dir: str = "ui/dist" # 生产形态:静态托管 ui/dist
+29
View File
@@ -125,6 +125,30 @@ _DOCGEN_MIGRATIONS = (
)
# 管理后台/鉴权增量表(users + auth_tokens;行为日志走文件,不建表)
_ADMIN_SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
authorizations TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS auth_tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id);
"""
def _now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -175,6 +199,11 @@ class Database:
)
except Exception:
logger.exception("docgen 表迁移失败(docgen 端点将不可用,其余不受影响)")
# 管理后台/鉴权增量表(users + auth_tokens):单独执行;失败只记日志
try:
await self._conn.executescript(_ADMIN_SCHEMA)
except Exception:
logger.exception("admin 表迁移失败(管理后台将不可用,其余不受影响)")
await self._conn.commit()
async def close(self) -> None:
+154
View File
@@ -0,0 +1,154 @@
"""logwriter.py —— 行为日志异步文件写入器(JSONL,固定目录,每日一个文件)。
设计:请求/业务只入队(不落盘),后台单任务批量 append,避免日志拖慢接口;
文件按日期命名 audit-YYYY-MM-DD.log,跨天自动切文件。写失败仅记 stderr,绝不影响业务。
"""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
_writer: "LogWriter | None" = None # 全局单例
def _now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
def _today() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
class LogWriter:
def __init__(self, log_dir: str, retention_days: int = 0) -> None:
self.log_dir = Path(log_dir)
self.retention_days = retention_days
self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=20000)
self._task: asyncio.Task | None = None
self._date = ""
self._fh = None
async def start(self) -> None:
self.log_dir.mkdir(parents=True, exist_ok=True)
self._task = asyncio.create_task(self._run())
async def stop(self) -> None:
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
self._close_fh()
def enqueue(self, entry: dict[str, Any]) -> None:
try:
self._queue.put_nowait(entry)
except asyncio.QueueFull:
logger.warning("行为日志队列已满,丢弃一条")
def _close_fh(self) -> None:
if self._fh is not None:
try:
self._fh.close()
except Exception:
pass
self._fh = None
def _open_for_date(self, date_str: str) -> None:
self._close_fh()
path = self.log_dir / f"audit-{date_str}.log"
self._fh = open(path, "a", encoding="utf-8")
self._date = date_str
self._cleanup()
async def _run(self) -> None:
try:
while True:
batch: list[dict[str, Any]] = []
try:
batch.append(await asyncio.wait_for(self._queue.get(), timeout=1.0))
except asyncio.TimeoutError:
continue
while len(batch) < 200:
try:
batch.append(self._queue.get_nowait())
except asyncio.QueueEmpty:
break
await asyncio.to_thread(self._write_sync, batch)
except asyncio.CancelledError:
raise
def _write_sync(self, entries: list[dict[str, Any]]) -> None:
today = _today()
if self._date != today:
self._open_for_date(today)
for e in entries:
self._fh.write(json.dumps(e, ensure_ascii=False) + "\n")
self._fh.flush()
def _cleanup(self) -> None:
if not self.retention_days:
return
try:
import time as _time
cutoff = _time.time() - self.retention_days * 86400
for p in self.log_dir.glob("audit-*.log"):
try:
if p.stat().st_mtime < cutoff:
p.unlink()
except OSError:
continue
except Exception:
logger.exception("日志清理失败")
def init_writer(log_dir: str, retention_days: int = 0) -> None:
global _writer
_writer = LogWriter(log_dir, retention_days)
async def start_writer() -> None:
if _writer is not None:
await _writer.start()
async def stop_writer() -> None:
global _writer
if _writer is not None:
await _writer.stop()
_writer = None
def log_event(
event: str,
*,
user_id: str | None = None,
method: str | None = None,
path: str | None = None,
status: int | None = None,
latency_ms: float | None = None,
detail: dict[str, Any] | None = None,
) -> None:
"""通用业务埋点;writer 未初始化时静默 no-op。"""
if _writer is None:
return
entry = {
"ts": _now_iso(),
"user_id": user_id,
"event": event,
"method": method,
"path": path,
"status": status,
"latency_ms": latency_ms,
"detail": detail or {},
}
_writer.enqueue(entry)
+38 -1
View File
@@ -11,6 +11,7 @@
from __future__ import annotations
import logging
import time
from contextlib import asynccontextmanager
from pathlib import Path
@@ -27,6 +28,10 @@ from .routers import bioflow_proxy
from .routers import paper
from .routers import private_docs
from .routers import review_proxy
from .routers import admin
from . import logwriter
from .auth import AuthError
from .services.auth import seed_admin
from .upstream import close_client
logging.basicConfig(level=logging.INFO)
@@ -49,6 +54,29 @@ def create_app() -> FastAPI:
allow_headers=["*"],
)
# 行为日志请求中间件:自动采集所有 /api/* 请求(user_id 待鉴权上线后补)
@app.middleware("http")
async def _log_requests(request: Request, call_next):
start = time.monotonic()
response = await call_next(request)
if request.url.path.startswith("/api/"):
logwriter.log_event(
"http_request",
method=request.method,
path=request.url.path,
status=response.status_code,
latency_ms=round((time.monotonic() - start) * 1000, 1),
)
return response
# 鉴权异常 → 统一错误信封
@app.exception_handler(AuthError)
async def _auth_error_handler(_: Request, exc: AuthError) -> JSONResponse:
return JSONResponse(
status_code=exc.status,
content={"ok": False, "error": {"code": exc.code, "message": exc.message, "degraded": False}},
)
app.include_router(health.router)
app.include_router(sessions.router)
app.include_router(qa.router)
@@ -65,6 +93,7 @@ def create_app() -> FastAPI:
app.include_router(paper.router) # 论文撰写(PAPER_WRITER_ENABLED 开关)
if get_settings().review_enabled:
app.include_router(review_proxy.router) # 科研审稿反代(REVIEW_ENABLED 开关)
app.include_router(admin.router) # 管理后台(/api/admin/*)
@app.exception_handler(RequestValidationError)
async def _validation_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
@@ -79,10 +108,14 @@ def create_app() -> FastAPI:
@asynccontextmanager
async def _lifespan(_app: FastAPI):
await init_db()
_db = await init_db()
logwriter.init_writer(settings.log_dir, settings.log_retention_days)
await logwriter.start_writer()
await seed_admin(_db)
try:
yield
finally:
await logwriter.stop_writer()
await close_client()
try:
from .services.neo4j import close_neo4j # A3 提供;缺失则跳过
@@ -98,6 +131,10 @@ def create_app() -> FastAPI:
# 2026-08-25 PM 修复:dist 路径必须基于项目根解析(BFF 工作目录是 sdk/,
# 相对路径会解析成 sdk/ui/dist);sync.sh 排除 dist,需单独同步。
project_root = Path(__file__).resolve().parent.parent.parent
# 管理后台静态托管(/admin;须在 "/" 之前挂载)
admin_dist = project_root / settings.admin_dist_dir if not Path(settings.admin_dist_dir).is_absolute() else Path(settings.admin_dist_dir)
if admin_dist.is_dir():
app.mount("/admin", StaticFiles(directory=str(admin_dist), html=True), name="admin")
dist = project_root / settings.frontend_dist_dir if not Path(settings.frontend_dist_dir).is_absolute() else Path(settings.frontend_dist_dir)
if dist.is_dir():
app.mount("/", StaticFiles(directory=str(dist), html=True), name="frontend")
+149
View File
@@ -0,0 +1,149 @@
"""routers/admin.py —— 管理后台端点(登录/用户管理/日志查看)。"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from ..auth import require_admin
from ..config import get_settings
from ..db import Database, get_db
from ..logwriter import log_event
from ..services.auth import (
AUTHORIZATION_UNITS,
verify_login,
issue_token,
revoke_token,
list_users,
get_user_by_username,
create_user,
update_user,
delete_user,
)
from ..services.logview import query_logs
from ..upstream import envelope
router = APIRouter(prefix="/api/admin", tags=["admin"])
class LoginBody(BaseModel):
username: str
password: str
class UserCreate(BaseModel):
username: str
password: str
role: str = "user"
authorizations: list[str] = []
class UserPatch(BaseModel):
role: str | None = None
authorizations: list[str] | None = None
password: str | None = None
@router.post("/login")
async def login(body: LoginBody, db: Database = Depends(get_db)):
user = await verify_login(db, body.username, body.password)
if user is None:
log_event("admin_login_fail", detail={"username": body.username})
content, status = envelope("UNAUTHORIZED", "用户名或密码错误", status=401)
return JSONResponse(status_code=status, content=content)
if user["role"] != "admin":
log_event("admin_login_fail", detail={"username": body.username, "reason": "not_admin"})
content, status = envelope("FORBIDDEN", "非管理员账号", status=403)
return JSONResponse(status_code=status, content=content)
token = await issue_token(db, user["id"])
log_event("admin_login_ok", user_id=user["id"], detail={"username": body.username})
return {"ok": True, "token": token, "user": user}
@router.post("/logout")
async def logout(request: Request, db: Database = Depends(get_db), _user: dict = Depends(require_admin)):
header = request.headers.get("Authorization", "")
token = header[7:] if header.startswith("Bearer ") else ""
if token:
await revoke_token(db, token)
return {"ok": True}
@router.get("/me")
async def me(_user: dict = Depends(require_admin)):
return {"ok": True, "user": _user}
@router.get("/units")
async def units(_user: dict = Depends(require_admin)):
return {"ok": True, "units": list(AUTHORIZATION_UNITS)}
@router.get("/users")
async def users(db: Database = Depends(get_db), _user: dict = Depends(require_admin)):
return {"ok": True, "users": await list_users(db)}
@router.post("/users")
async def create_user_api(body: UserCreate, db: Database = Depends(get_db), _user: dict = Depends(require_admin)):
username = body.username.strip()
if not username:
content, _ = envelope("VALIDATION", "用户名不能为空", status=422)
return JSONResponse(status_code=422, content=content)
if not body.password:
content, _ = envelope("VALIDATION", "密码不能为空", status=422)
return JSONResponse(status_code=422, content=content)
if await get_user_by_username(db, username):
return JSONResponse(
status_code=409,
content={"ok": False, "error": {"code": "CONFLICT", "message": "用户名已存在", "degraded": False}},
)
user = await create_user(db, username, body.password, body.role, body.authorizations)
return {"ok": True, "user": user}
@router.patch("/users/{user_id}")
async def patch_user(user_id: str, body: UserPatch, db: Database = Depends(get_db), _user: dict = Depends(require_admin)):
user = await update_user(db, user_id, role=body.role, authorizations=body.authorizations, password=body.password)
if user is None:
content, _ = envelope("NOT_FOUND", status=404)
return JSONResponse(status_code=404, content=content)
return {"ok": True, "user": user}
@router.delete("/users/{user_id}")
async def delete_user_api(user_id: str, db: Database = Depends(get_db), _user: dict = Depends(require_admin)):
if user_id == _user["id"]:
return JSONResponse(
status_code=422,
content={"ok": False, "error": {"code": "VALIDATION", "message": "不能删除当前登录账号", "degraded": False}},
)
await delete_user(db, user_id)
return {"ok": True}
@router.get("/logs")
async def logs(
from_date: str | None = Query(None),
to_date: str | None = Query(None),
event: str | None = Query(None),
user_id: str | None = Query(None),
q: str | None = Query(None),
status: str | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
_user: dict = Depends(require_admin),
):
settings = get_settings()
result = await query_logs(
settings.log_dir,
from_date=from_date,
to_date=to_date,
event=event,
user_id=user_id,
q=q,
status=status,
page=page,
page_size=page_size,
)
return {"ok": True, **result}
+189
View File
@@ -0,0 +1,189 @@
"""services/auth.py —— 管理员鉴权:密码哈希、会话凭据、用户 CRUD、种子管理员。
密码用 stdlib pbkdf2_hmac + 每用户随机盐(不新增依赖);
会话凭据用不透明 token 存 auth_tokens,逐次查库(吊销/过期即时生效)。
所有 DB 访问经 `db` 参数注入(与 services/history.py 一致,避免依赖全局单例)。
"""
from __future__ import annotations
import hashlib
import logging
import secrets
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
from ..config import get_settings
from ..db import Database, dumps_json, loads_json
from ..logwriter import log_event
logger = logging.getLogger(__name__)
# 授权清单功能单元(9 项,与管理后台界面一致;后续可调)
AUTHORIZATION_UNITS = (
"qa", "kg", "extraction", "docgen", "figures",
"private_docs", "bioflow", "paper", "legacy",
)
_PBKDF2_ITERATIONS = 200_000
def _now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def hash_password(password: str, salt: str | None = None) -> tuple[str, str]:
salt = salt or secrets.token_hex(16)
digest = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt.encode("utf-8"), _PBKDF2_ITERATIONS
)
return digest.hex(), salt
def verify_password(password: str, salt: str, expected_hex: str) -> bool:
digest, _ = hash_password(password, salt)
return secrets.compare_digest(digest, expected_hex)
def _row_to_user(row: Any) -> dict[str, Any]:
return {
"id": row["id"],
"username": row["username"],
"role": row["role"],
"authorizations": loads_json(row["authorizations"], default=[]),
"created_at": row["created_at"],
}
# ---- 登录校验 ----
async def verify_login(db: Database, username: str, password: str) -> dict[str, Any] | None:
row = await db.fetchone(
"SELECT id, username, password_hash, salt, role, authorizations, created_at "
"FROM users WHERE username = ?",
(username,),
)
if row is None:
return None
if not verify_password(password, row["salt"], row["password_hash"]):
return None
return _row_to_user(row)
# ---- 会话凭据 ----
async def issue_token(db: Database, user_id: str) -> str:
settings = get_settings()
token = secrets.token_urlsafe(32)
now = _now_iso()
expires = datetime.now(timezone.utc) + timedelta(seconds=settings.admin_token_ttl_seconds)
await db.execute(
"INSERT INTO auth_tokens (token, user_id, expires_at, created_at, revoked) "
"VALUES (?, ?, ?, ?, 0)",
(token, user_id, expires.strftime("%Y-%m-%dT%H:%M:%SZ"), now),
)
return token
async def resolve_token(db: Database, token: str) -> dict[str, Any] | None:
row = await db.fetchone(
"SELECT t.expires_at, t.revoked, u.id, u.username, u.role, u.authorizations, u.created_at "
"FROM auth_tokens t JOIN users u ON u.id = t.user_id WHERE t.token = ?",
(token,),
)
if row is None or row["revoked"]:
return None
try:
expires = datetime.strptime(row["expires_at"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
except ValueError:
return None
if datetime.now(timezone.utc) >= expires:
return None
return _row_to_user(row)
async def revoke_token(db: Database, token: str) -> None:
await db.execute("UPDATE auth_tokens SET revoked = 1 WHERE token = ?", (token,))
# ---- 用户 CRUD ----
async def list_users(db: Database) -> list[dict[str, Any]]:
rows = await db.fetchall(
"SELECT id, username, role, authorizations, created_at FROM users ORDER BY created_at ASC"
)
return [_row_to_user(r) for r in rows]
async def get_user(db: Database, user_id: str) -> dict[str, Any] | None:
row = await db.fetchone(
"SELECT id, username, role, authorizations, created_at FROM users WHERE id = ?", (user_id,)
)
return _row_to_user(row) if row else None
async def get_user_by_username(db: Database, username: str) -> dict[str, Any] | None:
row = await db.fetchone(
"SELECT id, username, role, authorizations, created_at FROM users WHERE username = ?", (username,)
)
return _row_to_user(row) if row else None
async def create_user(
db: Database, username: str, password: str, role: str, authorizations: list[str]
) -> dict[str, Any]:
user_id = str(uuid.uuid4())
ph, salt = hash_password(password)
await db.execute(
"INSERT INTO users (id, username, password_hash, salt, role, authorizations, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(user_id, username, ph, salt, role, dumps_json(authorizations), _now_iso()),
)
log_event("user_created", detail={"target": username, "role": role})
return {"id": user_id, "username": username, "role": role, "authorizations": authorizations, "created_at": _now_iso()}
async def update_user(
db: Database,
user_id: str,
*,
role: str | None = None,
authorizations: list[str] | None = None,
password: str | None = None,
) -> dict[str, Any] | None:
fields: list[str] = []
params: list[Any] = []
if role is not None:
fields.append("role = ?")
params.append(role)
if authorizations is not None:
fields.append("authorizations = ?")
params.append(dumps_json(authorizations))
if password is not None:
ph, salt = hash_password(password)
fields.append("password_hash = ?")
params.append(ph)
fields.append("salt = ?")
params.append(salt)
if fields:
params.append(user_id)
await db.execute(f"UPDATE users SET {', '.join(fields)} WHERE id = ?", params)
log_event("user_updated", detail={"target_id": user_id, "role": role, "password_reset": password is not None})
return await get_user(db, user_id)
async def delete_user(db: Database, user_id: str) -> None:
await db.execute("DELETE FROM users WHERE id = ?", (user_id,))
log_event("user_deleted", detail={"target_id": user_id})
# ---- 种子管理员 ----
async def seed_admin(db: Database) -> None:
"""启动时确保存在至少一个 admin 用户(默认 admin/admin)。"""
row = await db.fetchone("SELECT id FROM users WHERE role = 'admin' LIMIT 1")
if row is not None:
return
settings = get_settings()
await create_user(db, settings.admin_seed_username, settings.admin_seed_password, "admin", list(AUTHORIZATION_UNITS))
logger.info("已创建种子管理员 %s", settings.admin_seed_username)
+100
View File
@@ -0,0 +1,100 @@
"""services/logview.py —— 读取行为日志文件(JSONL)+ 内存聚合,供管理后台日志查看。"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from typing import Any
def _select_files(log_dir: Path, from_date: str | None, to_date: str | None) -> list[Path]:
"""按日期范围选出 audit-YYYY-MM-DD.log(升序)。"""
if not log_dir.is_dir():
return []
out: list[Path] = []
for p in sorted(log_dir.glob("audit-*.log")):
name = p.name
if not (name.startswith("audit-") and name.endswith(".log")):
continue
stem = name[len("audit-"):-len(".log")]
if from_date and stem < from_date:
continue
if to_date and stem > to_date:
continue
out.append(p)
return out
def _read_lines(path: Path) -> list[dict[str, Any]]:
try:
with open(path, "r", encoding="utf-8") as f:
text = f.read()
except (FileNotFoundError, OSError):
return []
out: list[dict[str, Any]] = []
for line in text.splitlines():
line = line.strip()
if not line:
continue
try:
out.append(json.loads(line))
except (json.JSONDecodeError, TypeError):
continue
return out
def _match(e: dict[str, Any], event, user_id, q, status) -> bool:
if event and e.get("event") != event:
return False
if user_id and e.get("user_id") != user_id:
return False
if status is not None and str(e.get("status")) != str(status):
return False
if q:
q = str(q)
if q not in str(e.get("path") or "") and q not in str(e.get("user_id") or ""):
return False
return True
async def query_logs(
log_dir: str,
*,
from_date: str | None = None,
to_date: str | None = None,
event: str | None = None,
user_id: str | None = None,
q: str | None = None,
status: str | None = None,
page: int = 1,
page_size: int = 50,
) -> dict[str, Any]:
files = _select_files(Path(log_dir), from_date, to_date)
# 文件 I/O 放线程池,不阻塞事件循环
all_lines = await asyncio.to_thread(lambda: [ln for p in files for ln in _read_lines(p)])
items = [e for e in all_lines if _match(e, event, user_id, q, status)]
items.sort(key=lambda e: str(e.get("ts") or ""), reverse=True)
total = len(items)
by_status: dict[str, int] = {}
latencies: list[float] = []
denied = 0
for e in items:
st = e.get("status")
if st is not None:
key = str(st)
by_status[key] = by_status.get(key, 0) + 1
if e.get("event") in ("call_denied", "login_fail", "admin_login_fail"):
denied += 1
lm = e.get("latency_ms")
if isinstance(lm, (int, float)):
latencies.append(float(lm))
latencies.sort()
start = (page - 1) * page_size
aggregates = {
"by_status": by_status,
"denied": denied,
"avg_latency_ms": round(sum(latencies) / len(latencies), 1) if latencies else None,
"p95_latency_ms": latencies[int(len(latencies) * 0.95)] if latencies else None,
}
return {"items": items[start:start + page_size], "total": total, "aggregates": aggregates}