Merge feature/admin-console: 管理后台 + 用户端鉴权(登录/授权/数据隔离/日志)

This commit is contained in:
2026-10-16 16:01:13 +08:00
29 changed files with 2887 additions and 56 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"
}
}
+143
View File
@@ -0,0 +1,143 @@
<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';
import HubSettings from './components/HubSettings.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 hub = ref({ hub_id: '', hub_name: '蜂群中枢' });
const settingsOpen = ref(false);
const PAGES = { overview: '概览', users: '用户管理', logs: '日志查看' };
function onUnauthorized() { setToken(''); authed.value = false; }
async function loadHub() {
try {
const r = await api.settings();
hub.value = r.settings;
} catch (e) {
if (e.status === 401) onUnauthorized();
}
}
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;
loadHub();
loadStats();
} catch (e) {
setToken('');
authed.value = false;
}
}
function onLogin(u) { user.value = u; authed.value = true; page.value = 'overview'; loadHub(); loadStats(); }
async function onLogout() { try { await api.logout(); } catch {} setToken(''); authed.value = false; }
function onSettingsSaved(s) { hub.value = s; settingsOpen.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">{{ hub.hub_name }}</div><div class="brand-sub">{{ hub.hub_id.slice(0, 8) }}</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 style="color: var(--text-3); font-size: 13px">{{ hub.hub_name }}</span>
<button class="btn btn-ghost" style="margin-left: auto" @click="settingsOpen = true">⚙ 设置</button>
</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>
<HubSettings :open="settingsOpen" :hub="hub" @close="settingsOpen = false" @saved="onSettingsSaved" />
</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>
+48
View File
@@ -0,0 +1,48 @@
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 : ''));
},
settings: () => request('/settings'),
updateSettings: (body) => request('/settings', { method: 'PUT', body }),
};
+57
View File
@@ -0,0 +1,57 @@
<script setup>
import { ref, watch } from 'vue';
import { api } from '../api/admin.js';
const props = defineProps({ open: Boolean, hub: Object });
const emit = defineEmits(['close', 'saved']);
const hubId = ref('');
const hubName = ref('');
const busy = ref(false);
const error = ref('');
watch(() => props.open, (open) => {
if (open) {
hubId.value = (props.hub && props.hub.hub_id) || '';
hubName.value = (props.hub && props.hub.hub_name) || '';
error.value = '';
busy.value = false;
}
});
async function save() {
if (busy.value) return;
if (!hubName.value.trim()) { error.value = '中枢名称不能为空'; return; }
busy.value = true;
error.value = '';
try {
const r = await api.updateSettings({ hub_id: hubId.value.trim(), hub_name: hubName.value.trim() });
emit('saved', r.settings);
} 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">中枢设置</div>
<div class="modal-body">
<div class="field"><label>中枢名称</label><input v-model="hubName" placeholder="例如:湘湖实验室中枢"></div>
<div class="field" style="margin-bottom: 0"><label>中枢 ID</label><input v-model="hubId" class="mono" placeholder="唯一标识,留空则由系统生成"></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 ? '保存中…' : '保存' }}</button>
</div>
</div>
</div>
</template>
<style scoped>
.err { color: var(--red); font-size: 12px; margin: 12px 0 0; }
</style>
+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' },
});
+75
View File
@@ -0,0 +1,75 @@
"""auth.py —— 鉴权依赖(管理员 + 用户级)+ 鉴权异常。
- `get_current_user`:受 auth_enabled 开关控制;关=匿名(None),开=校验 token(401 失败)。
- `require_user`:强制登录(不受开关控制),用于 /api/auth/me、/logout 等必须登录的端点。
- `require_admin`:管理员;复用 require_user + role 校验。
- `require_authorization(unit)`:功能门禁;关=放行,开=admin 或 unit 在 authorizations 才放行。
"""
from __future__ import annotations
from fastapi import Depends, Request
from .config import get_settings
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 get_current_user(request: Request, db: Database = Depends(get_db)) -> dict | None:
"""用户级鉴权;auth_enabled 为假时返回 None(匿名放行)。"""
if not get_settings().auth_enabled:
return None
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", "未登录或登录已过期")
request.state.user = user
return user
async def require_user(request: Request, db: Database = Depends(get_db)) -> dict:
"""强制登录(不受 auth_enabled 开关控制)。"""
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", "未登录或登录已过期")
request.state.user = user
return user
async def require_admin(request: Request, db: Database = Depends(get_db)) -> dict:
user = await require_user(request, db)
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", "非管理员账号")
return user
def require_authorization(unit: str):
"""功能门禁依赖工厂;auth_enabled 关=放行,开=admin 或 unit 在 authorizations 放行。"""
async def _dep(request: Request, db: Database = Depends(get_db)) -> dict | None:
if not get_settings().auth_enabled:
return None
user = await require_user(request, db)
if user["role"] != "admin" and unit not in (user.get("authorizations") or []):
log_event("call_denied", user_id=user["id"], method=request.method, path=request.url.path, detail={"reason": "forbidden", "unit": unit})
raise AuthError(403, "FORBIDDEN", "无权访问该功能")
return user
return _dep
+12
View File
@@ -86,6 +86,18 @@ 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=不清理)
# ---- 用户端鉴权(普通用户登录;AUTH_ENABLED 为假=匿名放行,兼容浸泡期)----
auth_enabled: bool = False
user_token_ttl_seconds: int = 86400 # 普通用户会话凭据有效期(秒)
# ---- 生产形态:BFF 静态托管 ui/dist(ARCHITECTURE.md §3,M6 挂载)----
frontend_dist_dir: str = "ui/dist" # 生产形态:静态托管 ui/dist
+42
View File
@@ -125,6 +125,35 @@ _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);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"""
def _now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -175,6 +204,19 @@ class Database:
)
except Exception:
logger.exception("docgen 表迁移失败(docgen 端点将不可用,其余不受影响)")
# 管理后台/鉴权增量表(users + auth_tokens):单独执行;失败只记日志
try:
await self._conn.executescript(_ADMIN_SCHEMA)
except Exception:
logger.exception("admin 表迁移失败(管理后台将不可用,其余不受影响)")
# sessions 加 owner_id(用户级数据隔离,幂等 ALTER)
try:
cur = await self._conn.execute("PRAGMA table_info(sessions)")
cols = {row[1] for row in await cur.fetchall()}
if "owner_id" not in cols:
await self._conn.execute("ALTER TABLE sessions ADD COLUMN owner_id TEXT")
except Exception:
logger.exception("sessions owner_id 迁移失败")
await self._conn.commit()
async def close(self) -> None:
+159
View File
@@ -0,0 +1,159 @@
"""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, hub_id: str = "") -> None:
self.log_dir = Path(log_dir)
self.retention_days = retention_days
self.hub_id = hub_id
self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=20000)
self._task: asyncio.Task | None = None
self._date = ""
self._fh = None
def set_hub_id(self, hub_id: str) -> None:
self.hub_id = hub_id or ""
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, hub_id: str = "") -> None:
global _writer
_writer = LogWriter(log_dir, retention_days, hub_id=hub_id)
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(),
"hub_id": _writer.hub_id,
"user_id": user_id,
"event": event,
"method": method,
"path": path,
"status": status,
"latency_ms": latency_ms,
"detail": detail or {},
}
_writer.enqueue(entry)
+57 -14
View File
@@ -11,10 +11,11 @@
from __future__ import annotations
import logging
import time
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi import Depends, FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
@@ -27,6 +28,12 @@ from .routers import bioflow_proxy
from .routers import paper
from .routers import private_docs
from .routers import review_proxy
from .routers import admin
from .routers import auth
from . import logwriter
from .auth import AuthError, get_current_user, require_authorization
from .services.auth import seed_admin
from .services.settings import get_hub_settings
from .upstream import close_client
logging.basicConfig(level=logging.INFO)
@@ -49,22 +56,49 @@ 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/"):
user = getattr(request.state, "user", None)
logwriter.log_event(
"http_request",
user_id=user["id"] if user else None,
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)
app.include_router(capabilities.router)
app.include_router(kg.router)
app.include_router(extraction.router)
app.include_router(figures.router)
app.include_router(legacy.router)
app.include_router(docgen.router) # docgen(P2)
app.include_router(private_docs.router) # 私有资料问答(FR-PRIV-004)
app.include_router(auth.router) # /api/auth/*(login/status 免鉴权,me/logout 需登录)
app.include_router(sessions.router, dependencies=[Depends(get_current_user)])
app.include_router(qa.router, dependencies=[Depends(require_authorization("qa"))])
app.include_router(capabilities.router, dependencies=[Depends(get_current_user)])
app.include_router(kg.router, dependencies=[Depends(require_authorization("kg"))])
app.include_router(extraction.router, dependencies=[Depends(require_authorization("extraction"))])
app.include_router(figures.router, dependencies=[Depends(require_authorization("figures"))])
app.include_router(legacy.router, dependencies=[Depends(require_authorization("legacy"))])
app.include_router(docgen.router, dependencies=[Depends(require_authorization("docgen"))]) # docgen(P2)
app.include_router(private_docs.router, dependencies=[Depends(require_authorization("private_docs"))]) # 私有资料问答(FR-PRIV-004)
if get_settings().bioflow_enabled:
app.include_router(bioflow_proxy.router) # 生信反代(BIOFLOW_ENABLED 开关,FR-BIO-028)
app.include_router(bioflow_proxy.router, dependencies=[Depends(require_authorization("bioflow"))]) # 生信反代(BIOFLOW_ENABLED 开关,FR-BIO-028)
if get_settings().paper_writer_enabled:
app.include_router(paper.router) # 论文撰写(PAPER_WRITER_ENABLED 开关)
app.include_router(paper.router, dependencies=[Depends(require_authorization("paper"))]) # 论文撰写(PAPER_WRITER_ENABLED 开关)
if get_settings().review_enabled:
app.include_router(review_proxy.router) # 科研审稿反代(REVIEW_ENABLED 开关)
app.include_router(review_proxy.router, dependencies=[Depends(get_current_user)]) # 科研审稿反代(REVIEW_ENABLED 开关)
app.include_router(admin.router) # 管理后台(/api/admin/*)
@app.exception_handler(RequestValidationError)
async def _validation_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
@@ -79,10 +113,15 @@ def create_app() -> FastAPI:
@asynccontextmanager
async def _lifespan(_app: FastAPI):
await init_db()
_db = await init_db()
await seed_admin(_db)
hub = await get_hub_settings(_db)
logwriter.init_writer(settings.log_dir, settings.log_retention_days, hub_id=hub["hub_id"])
await logwriter.start_writer()
try:
yield
finally:
await logwriter.stop_writer()
await close_client()
try:
from .services.neo4j import close_neo4j # A3 提供;缺失则跳过
@@ -98,6 +137,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")
+168
View File
@@ -0,0 +1,168 @@
"""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 .. import logwriter
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 ..services.settings import get_hub_settings, set_hub_settings
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
class HubSettingsBody(BaseModel):
hub_id: str | None = None
hub_name: 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("/settings")
async def get_settings_api(db: Database = Depends(get_db), _user: dict = Depends(require_admin)):
return {"ok": True, "settings": await get_hub_settings(db)}
@router.put("/settings")
async def put_settings_api(body: HubSettingsBody, db: Database = Depends(get_db), _user: dict = Depends(require_admin)):
settings = await set_hub_settings(db, body.hub_id, body.hub_name)
logwriter.set_hub_id(settings["hub_id"])
return {"ok": True, "settings": settings}
@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}
+52
View File
@@ -0,0 +1,52 @@
"""routers/auth.py —— 用户端鉴权端点(登录/登出/me/status)。"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from ..auth import require_user
from ..config import get_settings
from ..db import Database, get_db
from ..logwriter import log_event
from ..services.auth import verify_login, issue_token, revoke_token
from ..upstream import envelope
router = APIRouter(prefix="/api/auth", tags=["auth"])
class LoginBody(BaseModel):
username: str
password: str
@router.get("/status")
async def status():
"""免鉴权:告知前端是否开启鉴权(决定是否显示登录页)。"""
return {"ok": True, "auth_enabled": get_settings().auth_enabled}
@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("login_fail", detail={"username": body.username})
content, status = envelope("UNAUTHORIZED", "用户名或密码错误", status=401)
return JSONResponse(status_code=status, content=content)
token = await issue_token(db, user["id"], ttl_seconds=get_settings().user_token_ttl_seconds)
log_event("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_user)):
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_user)):
return {"ok": True, "user": _user}
+6
View File
@@ -138,6 +138,12 @@ async def register_report(request: Request, db: Database = Depends(get_db)) -> A
import uuid
data = payload.get("data", payload) if isinstance(payload, dict) else {}
sid = str(data.get("session_id") or "")
user = getattr(request.state, "user", None)
if user and sid:
owner_row = await db.fetchone("SELECT owner_id FROM sessions WHERE id = ?", (sid,))
if owner_row is None or owner_row["owner_id"] != user["id"]:
return proxied # 归属不符:不落库,仍透传生信响应
artifact_id = uuid.uuid4().hex[:16]
await db.execute(
"""INSERT INTO doc_artifacts (id, session_id, status, title, template_name,
+5
View File
@@ -36,7 +36,12 @@ async def list_capabilities(
session_caps: Optional[dict] = None
if session_id:
try:
user = getattr(request.state, "user", None)
owner_id = user["id"] if user else None
if owner_id is None:
row = await db.fetchone("SELECT meta FROM sessions WHERE id = ?", (session_id,))
else:
row = await db.fetchone("SELECT meta FROM sessions WHERE id = ? AND owner_id = ?", (session_id, owner_id))
if row is not None:
meta = loads_json(row["meta"], default={})
if isinstance(meta, dict):
+22 -1
View File
@@ -49,7 +49,7 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile
from fastapi import APIRouter, Depends, File, Form, Query, Request, Response, UploadFile
from fastapi.responses import FileResponse, JSONResponse
from ..config import get_settings
@@ -109,6 +109,13 @@ def _envelope(code: str, message: str | None = None, degraded: bool | None = Non
return JSONResponse(status_code=http, content=body)
async def _session_owned(db: Database, session_id: str, owner_id: str | None) -> bool:
if owner_id is None:
return True
row = await db.fetchone("SELECT owner_id FROM sessions WHERE id = ?", (session_id,))
return row is not None and row["owner_id"] == owner_id
def _validation(message: str) -> JSONResponse:
return _envelope("VALIDATION", message=message, status=422)
@@ -646,6 +653,7 @@ async def _run_docgen_task(
@router.post("/generate")
async def generate_doc(
payload: DocgenGenerateRequest,
request: Request,
db: Database = Depends(get_db),
):
"""异步生成(缺陷 #52,2026-09-10):快路径校验 → 插任务行 → 后台生成 → 202。
@@ -657,6 +665,9 @@ async def generate_doc(
202 {ok, task_id, status:"queued"}(契约见 docs/acceptance-r3.md 附录 B)。
chat 消息落库由前端走现有 sessions 机制,本端点不碰。
"""
user = getattr(request.state, "user", None)
if user and payload.session_id and not await _session_owned(db, payload.session_id, user["id"]):
return _envelope("NOT_FOUND", message="会话不存在", status=404)
bad = _check_description(payload.description)
if bad is not None:
return bad
@@ -767,15 +778,25 @@ async def get_docgen_task(task_id: str, db: Database = Depends(get_db)):
@router.get("/tasks")
async def list_docgen_tasks(
request: Request,
session_id: str = Query("", description="按会话过滤;空=全部(上限 100)"),
db: Database = Depends(get_db),
):
"""会话任务列表(缺陷 #52 契约,附录 B)。"""
user = getattr(request.state, "user", None)
if user and session_id and not await _session_owned(db, session_id, user["id"]):
return _envelope("NOT_FOUND", message="会话不存在", status=404)
if session_id:
rows = await db.fetchall(
"SELECT id FROM docgen_tasks WHERE session_id = ? ORDER BY created_at DESC LIMIT 100",
(session_id,),
)
elif user:
rows = await db.fetchall(
"SELECT t.id FROM docgen_tasks t JOIN sessions s ON s.id = t.session_id "
"WHERE s.owner_id = ? ORDER BY t.created_at DESC LIMIT 100",
(user["id"],),
)
else:
rows = await db.fetchall(
"SELECT id FROM docgen_tasks ORDER BY created_at DESC LIMIT 100"
+22 -4
View File
@@ -9,13 +9,15 @@ import asyncio
from typing import Any
import httpx
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from ..config import get_settings
from ..db import Database, get_db
from ..services.paper import agent
from ..services.paper.mcp_client import PaperMcpClient
from ..upstream import envelope
router = APIRouter(prefix="/api/paper", tags=["paper"])
@@ -36,6 +38,13 @@ def _client() -> PaperMcpClient:
return PaperMcpClient(s.paper_writer_url, s.paper_writer_read_timeout)
async def _session_owned(db: Database, session_id: str, owner_id: str | None) -> bool:
if owner_id is None:
return True
row = await db.fetchone("SELECT owner_id FROM sessions WHERE id = ?", (session_id,))
return row is not None and row["owner_id"] == owner_id
@router.get("/health")
async def paper_health() -> dict[str, Any]:
"""探活反代 8097 /health;离线返回 offline 标记(前端据此隐藏 paper 入口)。"""
@@ -50,19 +59,28 @@ async def paper_health() -> dict[str, Any]:
@router.post("/turns")
async def paper_turn(payload: PaperTurnRequest, db: Database = Depends(get_db)) -> dict[str, Any]:
async def paper_turn(payload: PaperTurnRequest, request: Request, db: Database = Depends(get_db)) -> dict[str, Any]:
user = getattr(request.state, "user", None)
if user and not await _session_owned(db, payload.session_id, user["id"]):
return JSONResponse(status_code=404, content=envelope("NOT_FOUND", message="会话不存在", status=404)[0])
blocks = await agent.handle_turn(db, payload.session_id, payload.text, payload.paper_id, payload.template_key)
return {"ok": True, "blocks": blocks}
@router.post("/papers/{paper_id}/confirm")
async def paper_confirm(paper_id: str, payload: PaperConfirmRequest, db: Database = Depends(get_db)) -> dict[str, Any]:
async def paper_confirm(paper_id: str, payload: PaperConfirmRequest, request: Request, db: Database = Depends(get_db)) -> dict[str, Any]:
user = getattr(request.state, "user", None)
if user and not await _session_owned(db, payload.session_id, user["id"]):
return JSONResponse(status_code=404, content=envelope("NOT_FOUND", message="会话不存在", status=404)[0])
asyncio.create_task(agent.run_writing_loop(db, paper_id, payload.session_id))
return {"ok": True, "paper_id": paper_id}
@router.post("/papers/{paper_id}/resume")
async def paper_resume(paper_id: str, payload: PaperConfirmRequest, db: Database = Depends(get_db)) -> dict[str, Any]:
async def paper_resume(paper_id: str, payload: PaperConfirmRequest, request: Request, db: Database = Depends(get_db)) -> dict[str, Any]:
user = getattr(request.state, "user", None)
if user and not await _session_owned(db, payload.session_id, user["id"]):
return JSONResponse(status_code=404, content=envelope("NOT_FOUND", message="会话不存在", status=404)[0])
asyncio.create_task(agent.run_writing_loop(db, paper_id, payload.session_id))
return {"ok": True, "paper_id": paper_id}
+17 -5
View File
@@ -100,13 +100,14 @@ async def _proxy(
retry_invalid_citation: bool,
mode: str,
normalizer=None,
owner_id: str | None = None,
) -> Any:
"""非流式上游代理:重试、错误信封、归一化。返回 (JSONResponse)。"""
client = sse_relay.get_qa_client()
normalizer = normalizer or sse_relay.get_qa_normalizer()
url = f"{sse_relay.qa_base_url()}{_upstream_path(endpoint)}"
timeout = _read_timeout(read_timeout)
json_body, _humanize = await _inject_history(body)
json_body, _humanize = await _inject_history(body, owner_id=owner_id)
for attempt in (1, 2):
try:
@@ -206,8 +207,10 @@ async def qa_info():
async def qa_chat(request: Request):
"""非流式问答:上游 /chat + 归一化 + invalid_llm_citation 重试一次。"""
body = await request.json()
user = getattr(request.state, "user", None)
owner_id = user["id"] if user else None
return await _proxy(
"chat", body, read_timeout=CHAT_READ_TIMEOUT, retry_invalid_citation=True, mode="chat"
"chat", body, read_timeout=CHAT_READ_TIMEOUT, retry_invalid_citation=True, mode="chat", owner_id=owner_id
)
@@ -224,7 +227,9 @@ async def qa_chat_stream(request: Request):
except Exception:
return _envelope("VALIDATION", 422)
evidence_first = bool(body.pop("evidence_first", False))
body, humanize_on = await _inject_history(body)
user = getattr(request.state, "user", None)
owner_id = user["id"] if user else None
body, humanize_on = await _inject_history(body, owner_id=owner_id)
return StreamingResponse(
sse_relay.relay_stream(body, evidence_first=evidence_first, humanize_enabled=humanize_on),
media_type="text/event-stream",
@@ -242,8 +247,10 @@ async def qa_search(request: Request):
"""
body = await request.json()
body = {**body, "use_llm": False, "native_chain_enabled": False}
user = getattr(request.state, "user", None)
owner_id = user["id"] if user else None
return await _proxy(
"search", body, read_timeout=SEARCH_READ_TIMEOUT, retry_invalid_citation=False, mode="search"
"search", body, read_timeout=SEARCH_READ_TIMEOUT, retry_invalid_citation=False, mode="search", owner_id=owner_id
)
@@ -251,7 +258,7 @@ async def qa_search(request: Request):
# 多轮记忆(S2):剥离 session_id → 组装 history 注入上游
# ---------------------------------------------------------------------------
async def _inject_history(body: Optional[dict]) -> tuple[dict, bool]:
async def _inject_history(body: Optional[dict], owner_id: str | None = None) -> tuple[dict, bool]:
"""剥离 BFF 契约字段 session_id,读会话 meta 注入 history/capabilities。
- 不传 session_id / 会话不存在 → 行为与现役一致(history/capabilities 缺省)
@@ -270,9 +277,14 @@ async def _inject_history(body: Optional[dict]) -> tuple[dict, bool]:
if body.get("history"):
return body, False # 显式传入的历史优先(上游直传场景;capabilities 由请求体自带)
try:
if owner_id is None:
row = await bff_db.fetchone(
"SELECT meta FROM sessions WHERE id = ?", (session_id,)
)
else:
row = await bff_db.fetchone(
"SELECT meta FROM sessions WHERE id = ? AND owner_id = ?", (session_id, owner_id)
)
if row is None:
return body, False
meta = loads_json(row["meta"], default={})
+47 -28
View File
@@ -8,7 +8,8 @@
- GET /api/sessions/{id}/sources —— 全会话来源聚合:按 paper_id 去重(无 paper_id 按 id),
带 count(出现次数)。
404 → NOT_FOUND 信封;时间 ISO 8601 UTC。
用户级数据隔离:auth_enabled 开启时按 sessions.owner_id 过滤/校验(get_current_user 注入当前用户;
匿名模式下 user=None 则不过滤,兼容旧形态)。404 → NOT_FOUND 信封;时间 ISO 8601 UTC。
"""
from __future__ import annotations
@@ -20,6 +21,7 @@ from typing import Any
from fastapi import APIRouter, Depends, Response, status as http_status
from fastapi.responses import JSONResponse
from ..auth import get_current_user
from ..db import Database, dumps_json, get_db, loads_json
from ..schemas import (
MessageCreate,
@@ -80,10 +82,14 @@ def _row_to_message(row: Any) -> MessageOut:
async def _get_session_or_none(
db: Database, session_id: str, *, with_count: bool = True
db: Database, session_id: str, *, with_count: bool = True, owner_id: str | None = None
) -> SessionOut | None:
sql = _SESSION_SELECT + " WHERE s.id = ?"
row = await db.fetchone(sql, (session_id,))
params: list[Any] = [session_id]
if owner_id is not None:
sql += " AND s.owner_id = ?"
params.append(owner_id)
row = await db.fetchone(sql, tuple(params))
return _row_to_session(row) if row else None
@@ -101,31 +107,40 @@ async def _get_message_or_none(
# ---------------------------------------------------------------------------
@router.get("", response_model=list[SessionOut])
async def list_sessions(db: Database = Depends(get_db)) -> list[SessionOut]:
rows = await db.fetchall(_SESSION_SELECT + " ORDER BY s.updated_at DESC")
async def list_sessions(db: Database = Depends(get_db), user: dict | None = Depends(get_current_user)) -> list[SessionOut]:
owner_id = user["id"] if user else None
sql = _SESSION_SELECT
params: list[Any] = []
if owner_id is not None:
sql += " WHERE s.owner_id = ?"
params.append(owner_id)
sql += " ORDER BY s.updated_at DESC"
rows = await db.fetchall(sql, tuple(params))
return [_row_to_session(r) for r in rows]
@router.post("", response_model=SessionOut, status_code=http_status.HTTP_201_CREATED)
async def create_session(
payload: SessionCreate, db: Database = Depends(get_db)
payload: SessionCreate, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user)
) -> SessionOut:
now = _db_now()
session_id = str(uuid.uuid4())
title = payload.normalized_title()
owner_id = user["id"] if user else None
await db.execute(
"INSERT INTO sessions (id, title, status, meta, created_at, updated_at) "
"VALUES (?, ?, 'idle', ?, ?, ?)",
(session_id, title, dumps_json({"title_manual": False}), now, now),
"INSERT INTO sessions (id, title, status, meta, owner_id, created_at, updated_at) "
"VALUES (?, ?, 'idle', ?, ?, ?, ?)",
(session_id, title, dumps_json({"title_manual": False}), owner_id, now, now),
)
out = await _get_session_or_none(db, session_id)
out = await _get_session_or_none(db, session_id, owner_id=owner_id)
assert out is not None # 刚插入必存在
return out
@router.get("/{session_id}", response_model=SessionDetail)
async def get_session(session_id: str, db: Database = Depends(get_db)):
session = await _get_session_or_none(db, session_id)
async def get_session(session_id: str, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user)):
owner_id = user["id"] if user else None
session = await _get_session_or_none(db, session_id, owner_id=owner_id)
if session is None:
return _not_found(session_id)
rows = await db.fetchall(
@@ -136,9 +151,10 @@ async def get_session(session_id: str, db: Database = Depends(get_db)):
@router.patch("/{session_id}", response_model=SessionOut)
async def patch_session(
session_id: str, payload: SessionPatch, db: Database = Depends(get_db)
session_id: str, payload: SessionPatch, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user)
):
session = await _get_session_or_none(db, session_id)
owner_id = user["id"] if user else None
session = await _get_session_or_none(db, session_id, owner_id=owner_id)
if session is None:
return _not_found(session_id)
@@ -169,14 +185,15 @@ async def patch_session(
(dumps_json(meta), _db_now(), session_id),
)
out = await _get_session_or_none(db, session_id)
out = await _get_session_or_none(db, session_id, owner_id=owner_id)
assert out is not None
return out
@router.delete("/{session_id}", status_code=http_status.HTTP_204_NO_CONTENT)
async def delete_session(session_id: str, db: Database = Depends(get_db)) -> Response:
session = await _get_session_or_none(db, session_id)
async def delete_session(session_id: str, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user)) -> Response:
owner_id = user["id"] if user else None
session = await _get_session_or_none(db, session_id, owner_id=owner_id)
if session is None:
return _not_found_response(session_id)
await db.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
@@ -189,9 +206,10 @@ async def delete_session(session_id: str, db: Database = Depends(get_db)) -> Res
@router.post("/{session_id}/messages", response_model=MessageOut, status_code=201)
async def create_message(
session_id: str, payload: MessageCreate, db: Database = Depends(get_db)
session_id: str, payload: MessageCreate, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user)
):
session = await _get_session_or_none(db, session_id)
owner_id = user["id"] if user else None
session = await _get_session_or_none(db, session_id, owner_id=owner_id)
if session is None:
return _not_found(session_id)
@@ -206,7 +224,6 @@ async def create_message(
(message_id, session_id, payload.kind, text, now, now),
)
else:
# 助手流式骨架:text 置空,blocks 落库初始块
blocks = [b.model_dump() for b in payload.blocks]
await db.execute(
"INSERT INTO messages (id, session_id, role, kind, status, blocks, raw, text, "
@@ -215,7 +232,6 @@ async def create_message(
dumps_json(blocks), now, now),
)
# 消息活动 → 会话 updated_at 前移、idle → running
await db.execute(
"UPDATE sessions SET status = CASE WHEN status = 'idle' THEN 'running' ELSE status END, "
"updated_at = ? WHERE id = ?",
@@ -233,7 +249,13 @@ async def patch_message(
message_id: str,
payload: MessagePatch,
db: Database = Depends(get_db),
user: dict | None = Depends(get_current_user),
):
owner_id = user["id"] if user else None
session = await _get_session_or_none(db, session_id, owner_id=owner_id)
if session is None:
return _not_found(session_id)
message = await _get_message_or_none(db, session_id, message_id)
if message is None:
return _not_found(session_id, message_id)
@@ -258,7 +280,6 @@ async def patch_message(
await db.execute(
f"UPDATE messages SET {', '.join(sets)} WHERE id = ?", params
)
# 同步前移会话 updated_at
await db.execute(
"UPDATE sessions SET updated_at = ? WHERE id = ?",
(_db_now(), session_id),
@@ -274,12 +295,10 @@ async def patch_message(
# ---------------------------------------------------------------------------
@router.get("/{session_id}/sources")
async def get_session_sources(session_id: str, db: Database = Depends(get_db)):
"""按 paper_id 去重(无 paper_id 时按 id),带 count(出现次数)。
来源来自该会话全部助手消息 blocks 里的 sources 块(§2 块联合)。
"""
session = await _get_session_or_none(db, session_id)
async def get_session_sources(session_id: str, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user)):
"""按 paper_id 去重(无 paper_id 时按 id),带 count(出现次数)。"""
owner_id = user["id"] if user else None
session = await _get_session_or_none(db, session_id, owner_id=owner_id)
if session is None:
return _not_found(session_id)
+190
View File
@@ -0,0 +1,190 @@
"""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, ttl_seconds: int | None = None) -> str:
settings = get_settings()
ttl = ttl_seconds if ttl_seconds is not None else settings.admin_token_ttl_seconds
token = secrets.token_urlsafe(32)
now = _now_iso()
expires = datetime.now(timezone.utc) + timedelta(seconds=ttl)
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}
+40
View File
@@ -0,0 +1,40 @@
"""services/settings.py —— 中枢级键值设置(hub_id / hub_name)。"""
from __future__ import annotations
import uuid
from ..db import Database
_HUB_ID_KEY = "hub_id"
_HUB_NAME_KEY = "hub_name"
DEFAULT_HUB_NAME = "蜂群中枢"
async def get_setting(db: Database, key: str, default: str | None = None) -> str | None:
row = await db.fetchone("SELECT value FROM settings WHERE key = ?", (key,))
return row["value"] if row else default
async def set_setting(db: Database, key: str, value: str) -> None:
row = await db.fetchone("SELECT 1 FROM settings WHERE key = ?", (key,))
if row is not None:
await db.execute("UPDATE settings SET value = ? WHERE key = ?", (value, key))
else:
await db.execute("INSERT INTO settings (key, value) VALUES (?, ?)", (key, value))
async def get_hub_settings(db: Database) -> dict:
hub_id = await get_setting(db, _HUB_ID_KEY)
if not hub_id:
hub_id = str(uuid.uuid4())
await set_setting(db, _HUB_ID_KEY, hub_id)
hub_name = (await get_setting(db, _HUB_NAME_KEY, DEFAULT_HUB_NAME)) or DEFAULT_HUB_NAME
return {"hub_id": hub_id, "hub_name": hub_name}
async def set_hub_settings(db: Database, hub_id: str | None, hub_name: str | None) -> dict:
if hub_id and hub_id.strip():
await set_setting(db, _HUB_ID_KEY, hub_id.strip())
if hub_name and hub_name.strip():
await set_setting(db, _HUB_NAME_KEY, hub_name.strip())
return await get_hub_settings(db)