Merge feature/admin-console: 管理后台 + 用户端鉴权(登录/授权/数据隔离/日志)
This commit is contained in:
@@ -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>
|
||||
Generated
+1147
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import './style.css';
|
||||
|
||||
createApp(App).mount('#app');
|
||||
@@ -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); }
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
|
||||
@@ -36,7 +36,12 @@ async def list_capabilities(
|
||||
session_caps: Optional[dict] = None
|
||||
if session_id:
|
||||
try:
|
||||
row = await db.fetchone("SELECT meta FROM sessions WHERE id = ?", (session_id,))
|
||||
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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
+20
-8
@@ -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:
|
||||
row = await bff_db.fetchone(
|
||||
"SELECT meta FROM sessions WHERE id = ?", (session_id,)
|
||||
)
|
||||
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
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user