修改权限逻辑

This commit is contained in:
silk 2026-05-31 10:59:54 +08:00
parent 4c0474fcfe
commit 4cd86d97bd
26 changed files with 3249 additions and 167 deletions

View File

@ -4,6 +4,7 @@ import Layout from "../views/Layout.vue";
import Enterprise from "../views/Enterprise.vue"; import Enterprise from "../views/Enterprise.vue";
import Departments from "../views/Departments.vue"; import Departments from "../views/Departments.vue";
import Users from "../views/Users.vue"; import Users from "../views/Users.vue";
import AuditLog from "../views/AuditLog.vue";
const routes = [ const routes = [
{ path: "/login", name: "login", component: Login, meta: { public: true } }, { path: "/login", name: "login", component: Login, meta: { public: true } },
@ -15,6 +16,7 @@ const routes = [
{ path: "enterprise", name: "enterprise", component: Enterprise }, { path: "enterprise", name: "enterprise", component: Enterprise },
{ path: "departments", name: "departments", component: Departments }, { path: "departments", name: "departments", component: Departments },
{ path: "users", name: "users", component: Users }, { path: "users", name: "users", component: Users },
{ path: "audit-logs", name: "audit-logs", component: AuditLog },
], ],
}, },
]; ];

View File

@ -0,0 +1,173 @@
<template>
<div>
<h2 class="h4 mb-4">知识库操作日志</h2>
<!-- 筛选区 -->
<div class="card card-body mb-4 shadow-sm">
<div class="row g-2 align-items-end">
<div class="col-6 col-md-3">
<label class="form-label small mb-0">操作类型</label>
<select v-model="filter.action" class="form-select form-select-sm">
<option value="">全部</option>
<option value="upload">upload上传</option>
<option value="download">download下载</option>
<option value="delete">delete删除</option>
<option value="create_kb">create_kb创建知识库</option>
<option value="delete_kb">delete_kb删除知识库</option>
<option value="permission_change">permission_change权限变更</option>
</select>
</div>
<div class="col-6 col-md-2">
<label class="form-label small mb-0">操作者 ID</label>
<input v-model.number="filter.actor_id" type="number" class="form-control form-control-sm" placeholder="留空=全部" />
</div>
<div class="col-6 col-md-2">
<label class="form-label small mb-0">知识库 ID</label>
<input v-model.number="filter.kb_id" type="number" class="form-control form-control-sm" placeholder="留空=全部" />
</div>
<div class="col-auto d-flex gap-2">
<button class="btn btn-sm btn-primary" @click="applyFilter">搜索</button>
<button class="btn btn-sm btn-outline-secondary" @click="resetFilter">重置</button>
</div>
</div>
</div>
<div v-if="loading" class="text-muted">加载中</div>
<div v-else>
<div class="table-responsive">
<table class="table table-sm table-hover bg-white shadow-sm align-middle small">
<thead class="table-light">
<tr>
<th>时间</th>
<th>操作者</th>
<th>操作</th>
<th>被操作用户</th>
<th>部门</th>
<th>知识库</th>
<th>文件</th>
<th>IP</th>
<th>详情</th>
</tr>
</thead>
<tbody>
<tr v-for="r in items" :key="r.id">
<td class="text-nowrap text-muted">{{ fmt(r.created_at) }}</td>
<td>
<span class="fw-semibold">{{ r.actor_name || r.actor_id }}</span>
</td>
<td>
<span class="badge" :class="actionBadge(r.action)">{{ r.action }}</span>
</td>
<td>{{ r.target_name || (r.target_user_id ? '#' + r.target_user_id : '—') }}</td>
<td>{{ r.department_name || (r.department_id ? '#' + r.department_id : '—') }}</td>
<td>{{ r.kb_name || (r.kb_id ? '#' + r.kb_id : '—') }}</td>
<td class="text-truncate" style="max-width:140px" :title="r.file_name">
{{ r.file_name || '—' }}
</td>
<td>{{ r.ip || '—' }}</td>
<td>
<span v-if="r.metadata" class="text-muted small">{{ metaStr(r.metadata) }}</span>
<span v-else></span>
</td>
</tr>
</tbody>
</table>
<p v-if="!items.length" class="text-muted small">暂无日志记录</p>
</div>
<!-- 分页 -->
<nav v-if="total > pageSize" class="mt-2">
<ul class="pagination pagination-sm">
<li class="page-item" :class="{ disabled: page <= 1 }">
<a class="page-link" href="#" @click.prevent="page > 1 && page-- && load()">上一页</a>
</li>
<li class="page-item disabled">
<span class="page-link">{{ page }} / {{ totalPages }} {{ total }} </span>
</li>
<li class="page-item" :class="{ disabled: page >= totalPages }">
<a class="page-link" href="#" @click.prevent="page < totalPages && page++ && load()">下一页</a>
</li>
</ul>
</nav>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import http from "../api/http";
const items = ref([]);
const total = ref(0);
const loading = ref(false);
const page = ref(1);
const pageSize = 30;
const filter = reactive({ action: "", actor_id: null, kb_id: null });
const applied = reactive({ action: "", actor_id: null, kb_id: null });
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
async function load() {
loading.value = true;
try {
const params = { page: page.value, page_size: pageSize };
if (applied.action) params.action = applied.action;
if (applied.actor_id) params.actor_id = applied.actor_id;
if (applied.kb_id) params.kb_id = applied.kb_id;
const { data } = await http.get("/admin/audit-logs", { params });
const raw = data?.data;
items.value = raw?.items || [];
total.value = raw?.total || 0;
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
}
function applyFilter() {
Object.assign(applied, filter);
page.value = 1;
load();
}
function resetFilter() {
Object.assign(filter, { action: "", actor_id: null, kb_id: null });
Object.assign(applied, { action: "", actor_id: null, kb_id: null });
page.value = 1;
load();
}
function fmt(t) {
if (!t) return "—";
try { return new Date(t).toLocaleString("zh-CN"); } catch { return t; }
}
function actionBadge(action) {
const m = {
upload: "bg-success",
download: "bg-info text-dark",
delete: "bg-danger",
archive: "bg-secondary",
create_kb: "bg-primary",
delete_kb: "bg-danger",
permission_change: "bg-warning text-dark",
};
return m[action] || "bg-secondary";
}
function metaStr(meta) {
if (!meta) return "";
try {
const obj = typeof meta === "string" ? JSON.parse(meta) : meta;
return Object.entries(obj)
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
.join(" ");
} catch {
return String(meta);
}
}
onMounted(load);
</script>

View File

@ -1,54 +1,209 @@
<template> <template>
<div> <div>
<h2 class="h4 mb-4">部门管理</h2> <h2 class="h4 mb-4">部门管理</h2>
<div class="card card-body mb-4" style="max-width: 480px">
<div class="card card-body mb-4 shadow-sm">
<h6 class="mb-3">新建部门</h6> <h6 class="mb-3">新建部门</h6>
<form class="row g-2 align-items-end" @submit.prevent="create"> <p class="text-muted small mb-3 mb-md-4">
<div class="col-8"> 对应接口 <code class="small">POST /api/admin/departments</code>可为空指定上级部门树形
<input v-model="newName" class="form-control" placeholder="部门名称" required /> </p>
<form class="row g-3 align-items-end" @submit.prevent="create">
<div class="col-md-4">
<label class="form-label small mb-1">部门名称</label>
<input v-model="newDept.name" class="form-control" placeholder="必填" required />
</div> </div>
<div class="col-4"> <div class="col-md-4">
<button class="btn btn-primary w-100" type="submit" :disabled="creating">添加</button> <label class="form-label small mb-1">上级部门可选</label>
<select v-model="newDept.parent_id" class="form-select">
<option value="">顶级</option>
<option v-for="d in items" :key="d.id" :value="String(d.id)">
{{ d.name }}#{{ d.id }}
</option>
</select>
</div>
<div class="col-md-auto">
<button class="btn btn-primary" type="submit" :disabled="creating">{{ creating ? "提交中…" : "创建" }}</button>
</div> </div>
</form> </form>
</div> </div>
<div v-if="loading" class="text-muted">加载中</div> <div v-if="loading" class="text-muted">加载中</div>
<div v-else class="table-responsive"> <div v-else class="table-responsive">
<table class="table table-sm table-hover bg-white shadow-sm"> <table class="table table-sm table-hover bg-white shadow-sm align-middle">
<thead> <thead class="table-light">
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>名称</th> <th>名称</th>
<th>上级部门</th> <th>上级部门</th>
<th>部门负责人</th>
<th>创建时间</th>
<th style="min-width: 200px">操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="d in items" :key="d.id"> <tr v-for="d in items" :key="d.id">
<td>{{ d.id }}</td> <td>{{ d.id }}</td>
<td>{{ d.name }}</td> <td>{{ d.name }}</td>
<td>{{ d.parent_id ?? "—" }}</td> <td>{{ parentLabel(d.parent_id) }}</td>
<td>
<span v-if="d.leader_name" class="badge bg-primary">{{ d.leader_name }}</span>
<span v-else class="text-muted small">未设置</span>
</td>
<td class="text-muted small">{{ formatTime(d.created_at) }}</td>
<td class="text-nowrap">
<button type="button" class="btn btn-outline-primary btn-sm me-1" @click="openEdit(d)">
编辑
</button>
<button type="button" class="btn btn-outline-secondary btn-sm me-1" @click="openSetLeader(d)">
设负责人
</button>
<button type="button" class="btn btn-outline-danger btn-sm" @click="confirmDelete(d)">删除</button>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<p v-if="!items.length" class="text-muted small">暂无部门请先创建</p>
</div>
<!-- 编辑部门 PUT /api/admin/departments/{id} -->
<div id="modalDeptEdit" class="modal fade" tabindex="-1" ref="modalEditEl">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">编辑部门</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">部门名称</label>
<input v-model="editForm.name" type="text" class="form-control" required maxlength="255" />
</div>
<div class="mb-0">
<label class="form-label">上级部门</label>
<select v-model="editForm.parent_id" class="form-select">
<option value="">顶级</option>
<option v-for="o in editParentOptions" :key="o.id" :value="String(o.id)">
{{ o.name }}#{{ o.id }}
</option>
</select>
<div class="form-text">不能将上级设为自己</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" :disabled="savingEdit" @click="saveEdit">
{{ savingEdit ? "保存中…" : "保存" }}
</button>
</div>
</div>
</div>
</div>
<!-- 设置负责人 PUT /api/admin/departments/{id}/leader -->
<div id="modalSetLeader" class="modal fade" tabindex="-1" ref="modalLeaderEl">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">设置{{ leaderForm.deptName }}负责人</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
</div>
<div class="modal-body">
<div class="mb-0">
<label class="form-label">负责人role=leader/admin 的用户</label>
<select v-model="leaderForm.leader_user_id" class="form-select">
<option value=""> 清除负责人 </option>
<option v-for="u in leaderCandidates" :key="u.id" :value="u.id">
{{ u.display_name || u.username }}{{ u.role }}
</option>
</select>
<div class="form-text">仅显示 role=leader admin 的用户</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" :disabled="savingLeader" @click="saveLeader">
{{ savingLeader ? "保存中…" : "保存" }}
</button>
</div>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { Modal } from "bootstrap";
import http from "../api/http"; import http from "../api/http";
const items = ref([]); const items = ref([]);
const loading = ref(true); const loading = ref(true);
const newName = ref("");
const creating = ref(false); const creating = ref(false);
const savingEdit = ref(false);
const savingLeader = ref(false);
const newDept = reactive({ name: "", parent_id: "" });
const modalEditEl = ref(null);
const modalLeaderEl = ref(null);
let modalEdit = null;
let modalLeader = null;
//
const leaderForm = reactive({ deptId: null, deptName: "", leader_user_id: "" });
const leaderCandidates = ref([]); // role=leader admin
const editForm = reactive({
id: null,
name: "",
parent_id: "",
});
/** 编辑时可选上级:排除自身,避免「自己挂自己」 */
const editParentOptions = computed(() => {
const self = editForm.id;
return items.value.filter((d) => d.id !== self);
});
function unwrap(resData) {
return resData?.data !== undefined ? resData.data : resData;
}
function detail(e) {
const d = e.response?.data?.detail;
if (typeof d === "string") return d;
if (Array.isArray(d)) return d.map((x) => x.msg || JSON.stringify(x)).join("\n");
return e.message || "请求失败";
}
function parentLabel(parentId) {
if (parentId == null) return "—(顶级)";
const d = items.value.find((x) => x.id === parentId);
return d ? `${d.name}#${d.id}` : `#${parentId}`;
}
function formatTime(t) {
if (!t) return "—";
try {
const d = new Date(t);
return Number.isNaN(d.getTime()) ? String(t) : d.toLocaleString();
} catch {
return String(t);
}
}
function deptPayloadParentId(v) {
if (v === "" || v === null || v === undefined) return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
async function load() { async function load() {
loading.value = true; loading.value = true;
try { try {
const { data } = await http.get("/admin/departments"); const { data } = await http.get("/admin/departments");
const raw = data.data || data; const raw = unwrap(data);
items.value = raw.items || []; items.value = raw?.items || [];
} finally { } finally {
loading.value = false; loading.value = false;
} }
@ -57,15 +212,98 @@ async function load() {
async function create() { async function create() {
creating.value = true; creating.value = true;
try { try {
await http.post("/admin/departments", { name: newName.value.trim() }); const body = { name: newDept.name.trim() };
newName.value = ""; const pid = deptPayloadParentId(newDept.parent_id);
if (pid != null) body.parent_id = pid;
await http.post("/admin/departments", body);
newDept.name = "";
newDept.parent_id = "";
await load(); await load();
} catch (e) { } catch (e) {
alert(e.response?.data?.detail || e.message); alert(detail(e));
} finally { } finally {
creating.value = false; creating.value = false;
} }
} }
onMounted(load); function openEdit(d) {
editForm.id = d.id;
editForm.name = d.name;
editForm.parent_id = d.parent_id != null ? String(d.parent_id) : "";
modalEdit?.show();
}
async function saveEdit() {
if (!editForm.id) return;
savingEdit.value = true;
try {
const body = { name: editForm.name.trim() };
body.parent_id = deptPayloadParentId(editForm.parent_id);
await http.put(`/admin/departments/${editForm.id}`, body);
modalEdit?.hide();
await load();
} catch (e) {
alert(detail(e));
} finally {
savingEdit.value = false;
}
}
function confirmDelete(d) {
if (
!confirm(
`确定删除部门「${d.name}」吗?\n若部门下仍有用户接口将返回错误需先调整用户部门`
)
)
return;
doDelete(d.id);
}
async function doDelete(id) {
try {
await http.delete(`/admin/departments/${id}`);
await load();
} catch (e) {
alert(detail(e));
}
}
async function openSetLeader(d) {
leaderForm.deptId = d.id;
leaderForm.deptName = d.name;
leaderForm.leader_user_id = d.leader_user_id ?? "";
// leader / admin
try {
const { data } = await http.get("/admin/users", { params: { page_size: 200 } });
const raw = unwrap(data);
leaderCandidates.value = (raw?.items || []).filter(
(u) => u.role === "leader" || u.role === "admin"
);
} catch (e) {
leaderCandidates.value = [];
}
modalLeader?.show();
}
async function saveLeader() {
if (!leaderForm.deptId) return;
savingLeader.value = true;
try {
const leader_user_id =
leaderForm.leader_user_id === "" ? null : Number(leaderForm.leader_user_id);
await http.put(`/admin/departments/${leaderForm.deptId}/leader`, { leader_user_id });
modalLeader?.hide();
await load();
} catch (e) {
alert(detail(e));
} finally {
savingLeader.value = false;
}
}
onMounted(async () => {
modalEdit = new Modal(modalEditEl.value);
modalLeader = new Modal(modalLeaderEl.value);
await load();
});
</script> </script>

View File

@ -37,9 +37,9 @@ async function load() {
error.value = ""; error.value = "";
try { try {
const { data } = await http.get("/admin/enterprise"); const { data } = await http.get("/admin/enterprise");
const d = data.data || data; const d = data?.data ?? data;
name.value = d.name || ""; name.value = d.name || "";
aiDisplayName.value = d.ai_display_name || "能助手 AI"; aiDisplayName.value = d.ai_display_name || "能助手 AI";
code.value = d.code || ""; code.value = d.code || "";
} catch (e) { } catch (e) {
error.value = e.response?.data?.detail || e.message; error.value = e.response?.data?.detail || e.message;

View File

@ -12,6 +12,9 @@
<li class="nav-item"> <li class="nav-item">
<router-link class="nav-link text-white-50" active-class="text-white fw-semibold" to="/users">用户</router-link> <router-link class="nav-link text-white-50" active-class="text-white fw-semibold" to="/users">用户</router-link>
</li> </li>
<li class="nav-item">
<router-link class="nav-link text-white-50" active-class="text-white fw-semibold" to="/audit-logs">操作日志</router-link>
</li>
<li class="nav-item mt-3"> <li class="nav-item mt-3">
<button type="button" class="btn btn-outline-light btn-sm" @click="logout">退出</button> <button type="button" class="btn btn-outline-light btn-sm" @click="logout">退出</button>
</li> </li>

View File

@ -81,10 +81,12 @@
<th>ID</th> <th>ID</th>
<th>用户名</th> <th>用户名</th>
<th>邮箱</th> <th>邮箱</th>
<th>手机号</th>
<th>显示名</th> <th>显示名</th>
<th>角色</th> <th>角色</th>
<th>部门</th> <th>部门</th>
<th>状态</th> <th>状态</th>
<th title="知识库上传权限">上传权限</th>
<th style="min-width: 280px">操作</th> <th style="min-width: 280px">操作</th>
</tr> </tr>
</thead> </thead>
@ -93,6 +95,7 @@
<td>{{ u.id }}</td> <td>{{ u.id }}</td>
<td>{{ u.username }}</td> <td>{{ u.username }}</td>
<td>{{ u.email }}</td> <td>{{ u.email }}</td>
<td>{{ u.phone || "—" }}</td>
<td>{{ u.display_name || "—" }}</td> <td>{{ u.display_name || "—" }}</td>
<td>{{ roleLabel(u.role) }}</td> <td>{{ roleLabel(u.role) }}</td>
<td>{{ deptLabel(u.department_id) }}</td> <td>{{ deptLabel(u.department_id) }}</td>
@ -101,6 +104,17 @@
{{ u.is_active ? "正常" : "已禁用" }} {{ u.is_active ? "正常" : "已禁用" }}
</span> </span>
</td> </td>
<td>
<button
type="button"
class="btn btn-sm"
:class="u.allow_kb_upload ? 'btn-outline-success' : 'btn-outline-danger'"
:title="u.allow_kb_upload ? '点击关闭上传权限' : '点击开启上传权限'"
@click="toggleUploadPerm(u)"
>
{{ u.allow_kb_upload ? '✓ 允许' : '✗ 禁止' }}
</button>
</td>
<td class="text-nowrap"> <td class="text-nowrap">
<button type="button" class="btn btn-outline-primary btn-sm me-1" @click="openEdit(u)"> <button type="button" class="btn btn-outline-primary btn-sm me-1" @click="openEdit(u)">
编辑 编辑
@ -422,7 +436,7 @@ function deptLabel(id) {
async function loadDepartments() { async function loadDepartments() {
try { try {
const { data } = await http.get("/admin/departments"); const { data } = await http.get("/admin/departments");
const raw = data.data || data; const raw = data?.data ?? data;
departments.value = raw.items || []; departments.value = raw.items || [];
} catch { } catch {
departments.value = []; departments.value = [];
@ -432,7 +446,7 @@ async function loadDepartments() {
async function loadMe() { async function loadMe() {
try { try {
const { data } = await http.get("/auth/me"); const { data } = await http.get("/auth/me");
const u = data.data !== undefined ? data.data : data; const u = data?.data ?? data;
currentUserId.value = u.id ?? null; currentUserId.value = u.id ?? null;
} catch { } catch {
currentUserId.value = null; currentUserId.value = null;
@ -479,7 +493,7 @@ async function load() {
const dept = payloadDepartmentId(filterApplied.department_id); const dept = payloadDepartmentId(filterApplied.department_id);
if (dept != null) params.department_id = dept; if (dept != null) params.department_id = dept;
const { data } = await http.get("/admin/users", { params }); const { data } = await http.get("/admin/users", { params });
const raw = data.data || data; const raw = data?.data ?? data;
items.value = raw.items || []; items.value = raw.items || [];
total.value = raw.total || 0; total.value = raw.total || 0;
} finally { } finally {
@ -524,17 +538,34 @@ async function createUser() {
} }
} }
function openEdit(u) { function applyEditForm(row) {
editForm.id = u.id; editForm.id = row.id;
editForm.username = u.username; editForm.username = row.username;
editForm.email = u.email; editForm.email = row.email ?? "";
editForm.phone = u.phone; editForm.phone = row.phone ?? "";
editForm.display_name = u.display_name || ""; editForm.display_name = row.display_name || "";
editForm.role = u.role; editForm.role = row.role || "employee";
editForm.department_id = u.department_id != null ? String(u.department_id) : ""; editForm.department_id = row.department_id != null ? String(row.department_id) : "";
}
async function openEdit(u) {
try {
const { data } = await http.get(`/admin/users/${u.id}`);
const row = data?.data ?? data;
applyEditForm(row);
} catch (e) {
applyEditForm(u);
loggerOrConsole(e);
}
modalEdit?.show(); modalEdit?.show();
} }
function loggerOrConsole(e) {
if (typeof console !== "undefined" && console.warn) {
console.warn("拉取用户详情失败,使用列表数据:", detail(e));
}
}
async function saveEdit() { async function saveEdit() {
if (!editForm.id) return; if (!editForm.id) return;
savingEdit.value = true; savingEdit.value = true;
@ -598,6 +629,18 @@ async function toggleActive(u) {
} }
} }
async function toggleUploadPerm(u) {
const next = !u.allow_kb_upload;
const action = next ? "开启" : "关闭";
if (!confirm(`确定要${action}用户「${u.display_name || u.username}」的知识库上传权限吗?`)) return;
try {
await http.patch(`/admin/users/${u.id}/permissions`, { allow_kb_upload: next });
u.allow_kb_upload = next; //
} catch (e) {
alert(detail(e));
}
}
function confirmDelete(u) { function confirmDelete(u) {
if (!confirm(`确定删除用户「${u.username}」吗?\n若该用户仍有会话、知识库等关联数据删除可能失败请先禁用或清理数据。`)) return; if (!confirm(`确定删除用户「${u.username}」吗?\n若该用户仍有会话、知识库等关联数据删除可能失败请先禁用或清理数据。`)) return;
doDelete(u.id); doDelete(u.id);

View File

@ -89,7 +89,7 @@ DASHSCOPE_API_KEY=changeme_in_production
#DEEPSEEK_API_BASE=https://api.deepseek.com/v1 #DEEPSEEK_API_BASE=https://api.deepseek.com/v1
DASHSCOPE_API_BASE=https://dashscope.aliyuncs.com/compatible-mode/v1 DASHSCOPE_API_BASE=https://dashscope.aliyuncs.com/compatible-mode/v1
# 当此处为 true 时,表示聊天模型,使用的是原生的厂商模型以及原生厂商地址,否则,使用的是中垒的d大模型网关 # 当此处为 true 时,表示聊天模型,使用的是原生的厂商模型以及原生厂商地址,否则,使用的是中垒的大模型网关
USE_ORIGIN_MODEL=True USE_ORIGIN_MODEL=True
# 此处为必填表示一定要使用中垒的dashscope网关设计到的服务有text_to_image, text_to_video, text_to_poster以及向量化服务的embedding模型 # 此处为必填表示一定要使用中垒的dashscope网关设计到的服务有text_to_image, text_to_video, text_to_poster以及向量化服务的embedding模型

View File

@ -11,15 +11,20 @@ from admin.schemas import (
AdminUserListItem, AdminUserListItem,
AdminUserListResponse, AdminUserListResponse,
AdminUserUpdate, AdminUserUpdate,
AuditLogItem,
AuditLogListResponse,
DepartmentCreate, DepartmentCreate,
DepartmentResponse, DepartmentResponse,
DepartmentSetLeader,
DepartmentUpdate, DepartmentUpdate,
EnterpriseResponse, EnterpriseResponse,
EnterpriseUpdate, EnterpriseUpdate,
UserPermissionUpdate,
) )
from core.dependencies import get_db, get_current_admin_user from core.dependencies import get_db, get_current_admin_user
from models.user import User from models.user import User
from services.admin_user_service import AdminUserService from services.admin_user_service import AdminUserService
from services.audit_service import AuditService
from services.department_service import DepartmentService from services.department_service import DepartmentService
from services.enterprise_service import EnterpriseService from services.enterprise_service import EnterpriseService
from utils.helpers import BaseResponse from utils.helpers import BaseResponse
@ -75,6 +80,8 @@ async def list_departments(
enterprise_id=r["enterprise_id"], enterprise_id=r["enterprise_id"],
name=r["name"], name=r["name"],
parent_id=r["parent_id"], parent_id=r["parent_id"],
leader_user_id=r.get("leader_user_id"),
leader_name=r.get("leader_name"),
created_at=r["created_at"], created_at=r["created_at"],
).model_dump() ).model_dump()
for r in rows for r in rows
@ -104,6 +111,7 @@ async def create_department(
enterprise_id=row["enterprise_id"], enterprise_id=row["enterprise_id"],
name=row["name"], name=row["name"],
parent_id=row["parent_id"], parent_id=row["parent_id"],
leader_user_id=None,
created_at=row["created_at"], created_at=row["created_at"],
).model_dump(), ).model_dump(),
) )
@ -135,6 +143,7 @@ async def update_department(
enterprise_id=row["enterprise_id"], enterprise_id=row["enterprise_id"],
name=row["name"], name=row["name"],
parent_id=row["parent_id"], parent_id=row["parent_id"],
leader_user_id=row.get("leader_user_id"),
created_at=row["created_at"], created_at=row["created_at"],
).model_dump(), ).model_dump(),
) )
@ -267,3 +276,112 @@ async def delete_user(
status_code=400, status_code=400,
detail="该用户仍存在关联数据(如会话、知识库归属等),无法直接删除,请先禁用账号", detail="该用户仍存在关联数据(如会话、知识库归属等),无法直接删除,请先禁用账号",
) )
# ────────────────────────────────────────────────────────────
# 部门负责人绑定admin 专用)
# ────────────────────────────────────────────────────────────
@admin_router.put(
"/departments/{dept_id}/leader",
response_model=BaseResponse,
summary="设置/清除部门负责人",
)
async def set_department_leader(
dept_id: int,
body: DepartmentSetLeader,
admin: User = Depends(get_current_admin_user),
conn: asyncpg.Connection = Depends(get_db),
):
# 若指定了 user_id确认该用户属于本企业且是 leader
if body.leader_user_id is not None:
u = await conn.fetchrow(
"SELECT id, role, enterprise_id FROM user_list WHERE id = $1 AND enterprise_id = $2",
body.leader_user_id, admin.enterprise_id,
)
if not u:
raise HTTPException(status_code=400, detail="用户不存在或不属于本企业")
if u["role"] not in ("leader", "admin"):
raise HTTPException(status_code=400, detail="只能将 role=leader 或 admin 的用户设为负责人")
row = await DepartmentService.set_leader(conn, dept_id, admin.enterprise_id, body.leader_user_id)
if not row:
raise HTTPException(status_code=404, detail="部门不存在")
return BaseResponse(code=200, msg="设置成功", data=DepartmentResponse(
id=row["id"],
enterprise_id=row["enterprise_id"],
name=row["name"],
parent_id=row["parent_id"],
leader_user_id=row.get("leader_user_id"),
).model_dump())
# ────────────────────────────────────────────────────────────
# 用户上传权限开关admin 可设置任意用户)
# ────────────────────────────────────────────────────────────
@admin_router.patch(
"/users/{user_id}/permissions",
response_model=BaseResponse,
summary="设置用户知识库上传权限",
)
async def set_user_permission(
user_id: int,
body: UserPermissionUpdate,
admin: User = Depends(get_current_admin_user),
conn: asyncpg.Connection = Depends(get_db),
):
target = await conn.fetchrow(
"SELECT id FROM user_list WHERE id = $1 AND enterprise_id = $2",
user_id, admin.enterprise_id,
)
if not target:
raise HTTPException(status_code=404, detail="用户不存在")
await conn.execute(
"UPDATE user_list SET allow_kb_upload = $1 WHERE id = $2",
body.allow_kb_upload, user_id,
)
await AuditService.write(
conn,
enterprise_id=admin.enterprise_id or 0,
actor_id=admin.id,
action="permission_change",
target_user_id=user_id,
metadata={"field": "allow_kb_upload", "new": body.allow_kb_upload, "by": "admin"},
)
return BaseResponse(code=200, msg="权限已更新", data={"allow_kb_upload": body.allow_kb_upload})
# ────────────────────────────────────────────────────────────
# 全企业审计日志admin 专用)
# ────────────────────────────────────────────────────────────
@admin_router.get(
"/audit-logs",
response_model=BaseResponse,
summary="查看全企业知识库操作日志",
)
async def list_audit_logs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
action: Optional[str] = Query(None, description="操作类型过滤"),
actor_id: Optional[int] = Query(None, description="操作者 user_id"),
kb_id: Optional[int] = Query(None, description="知识库 ID"),
admin: User = Depends(get_current_admin_user),
conn: asyncpg.Connection = Depends(get_db),
):
rows, total = await AuditService.list_logs(
conn,
admin.enterprise_id,
department_ids=None, # admin 查全量
actor_id=actor_id,
kb_id=kb_id,
action=action,
page=page,
page_size=page_size,
)
items = [AuditLogItem(**r).model_dump() for r in rows]
return BaseResponse(
code=200,
msg="ok",
data=AuditLogListResponse(total=total, items=items).model_dump(),
)

View File

@ -1,6 +1,6 @@
"""后台管理 API 请求/响应模型""" """后台管理 API 请求/响应模型"""
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Any, Dict, List, Optional
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field
@ -33,11 +33,17 @@ class DepartmentUpdate(BaseModel):
parent_id: Optional[int] = None parent_id: Optional[int] = None
class DepartmentSetLeader(BaseModel):
leader_user_id: Optional[int] = Field(None, description="负责人 user_id传 null 表示清除")
class DepartmentResponse(BaseModel): class DepartmentResponse(BaseModel):
id: int id: int
enterprise_id: int enterprise_id: int
name: str name: str
parent_id: Optional[int] = None parent_id: Optional[int] = None
leader_user_id: Optional[int] = None
leader_name: Optional[str] = None
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
@ -59,6 +65,12 @@ class AdminUserUpdate(BaseModel):
role: Optional[str] = Field(None, description="admin | leader | employee") role: Optional[str] = Field(None, description="admin | leader | employee")
is_active: Optional[bool] = None is_active: Optional[bool] = None
password: Optional[str] = Field(None, min_length=6) password: Optional[str] = Field(None, min_length=6)
allow_kb_upload: Optional[bool] = None
class UserPermissionUpdate(BaseModel):
"""领导更新下属权限(仅 allow_kb_upload"""
allow_kb_upload: bool
class AdminUserListItem(BaseModel): class AdminUserListItem(BaseModel):
@ -72,6 +84,7 @@ class AdminUserListItem(BaseModel):
role: str role: str
is_active: bool is_active: bool
is_first_login: bool = True is_first_login: bool = True
allow_kb_upload: bool = True
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
last_login_at: Optional[datetime] = None last_login_at: Optional[datetime] = None
@ -79,3 +92,43 @@ class AdminUserListItem(BaseModel):
class AdminUserListResponse(BaseModel): class AdminUserListResponse(BaseModel):
total: int total: int
items: list[AdminUserListItem] items: list[AdminUserListItem]
# ── 审计日志 ──────────────────────────────────
class AuditLogItem(BaseModel):
id: int
enterprise_id: int
actor_id: int
actor_name: Optional[str] = None
action: str
target_user_id: Optional[int] = None
target_name: Optional[str] = None
department_id: Optional[int] = None
department_name: Optional[str] = None
kb_id: Optional[int] = None
kb_name: Optional[str] = None
file_id: Optional[int] = None
file_name: Optional[str] = None
ip: Optional[str] = None
metadata: Optional[Any] = None
created_at: Optional[datetime] = None
class AuditLogListResponse(BaseModel):
total: int
items: List[AuditLogItem]
# ── 团队成员(领导视角) ──────────────────────
class TeamMemberItem(BaseModel):
id: int
username: str
display_name: Optional[str] = None
department_id: Optional[int] = None
department_name: Optional[str] = None
role: str
is_active: bool
allow_kb_upload: bool
created_at: Optional[datetime] = None

View File

@ -16,10 +16,12 @@ from core.config import settings
from core.dependencies import get_db, get_current_user from core.dependencies import get_db, get_current_user
from core.database import get_db_pool from core.database import get_db_pool
from core.exceptions import NotFoundError, BadRequestError from core.exceptions import NotFoundError, BadRequestError
from core.permissions import can_delete_file, can_upload_to_kb
from models.user import User from models.user import User
from models.knowledge_base_file import FileUploadResponse, FileListResponse from models.knowledge_base_file import FileUploadResponse, FileListResponse, KnowledgeBaseFile
from services.knowledge_base_service import KnowledgeBaseService from services.knowledge_base_service import KnowledgeBaseService
from services.knowledge_base_file_service import KnowledgeBaseFileService from services.knowledge_base_file_service import KnowledgeBaseFileService
from services.audit_service import AuditService
from services.vector_service import get_vector_service from services.vector_service import get_vector_service
from services.oss_service import get_oss_service from services.oss_service import get_oss_service
from utils.helpers import BaseResponse from utils.helpers import BaseResponse
@ -327,8 +329,12 @@ async def upload_file(
"""上传文件到知识库并进行向量化处理""" """上传文件到知识库并进行向量化处理"""
try: try:
logger.info(f"📤 开始上传文件到知识库 {kb_id}: {file.filename}, 用户: {current_user.username}") logger.info(f"📤 开始上传文件到知识库 {kb_id}: {file.filename}, 用户: {current_user.username}")
await _check_kb_access(conn, kb_id, current_user) kb = await _check_kb_access(conn, kb_id, current_user)
# 检查上传权限allow_kb_upload 开关)
if not can_upload_to_kb(current_user, kb):
raise BadRequestError("您的上传权限已被关闭,请联系部门领导或管理员")
# 检查文件类型 # 检查文件类型
file_ext = Path(file.filename).suffix.lower() file_ext = Path(file.filename).suffix.lower()
@ -431,7 +437,19 @@ async def upload_file(
conn, kb_id, current_user.id, file.filename, file_path, file_size, file_type conn, kb_id, current_user.id, file.filename, file_path, file_size, file_type
) )
logger.info(f"✅ 文件记录已创建: ID={file_record.id}, 状态={file_record.status}") logger.info(f"✅ 文件记录已创建: ID={file_record.id}, 状态={file_record.status}")
# 审计日志:上传
await AuditService.write(
conn,
enterprise_id=current_user.enterprise_id or 0,
actor_id=current_user.id,
action="upload",
department_id=current_user.department_id,
kb_id=kb_id,
file_id=file_record.id,
metadata={"file_name": file.filename, "file_size": file_size, "file_type": file_type},
)
# 添加后台任务 # 添加后台任务
logger.info(f"🚀 添加后台向量化任务: file_id={file_record.id}, type={file_type}") logger.info(f"🚀 添加后台向量化任务: file_id={file_record.id}, type={file_type}")
background_tasks.add_task(process_file_background, file_record.id, file_path, kb_id, file_type) background_tasks.add_task(process_file_background, file_record.id, file_path, kb_id, file_type)
@ -470,8 +488,10 @@ async def upload_url(
conn: asyncpg.Connection = Depends(get_db) conn: asyncpg.Connection = Depends(get_db)
): ):
"""上传 URL 到知识库并进行向量化处理""" """上传 URL 到知识库并进行向量化处理"""
await _check_kb_access(conn, kb_id, current_user) kb = await _check_kb_access(conn, kb_id, current_user)
if not can_upload_to_kb(current_user, kb):
raise BadRequestError("您的上传权限已被关闭,请联系部门领导或管理员")
url = request.url.strip() url = request.url.strip()
if not url.startswith(('http://', 'https://')): if not url.startswith(('http://', 'https://')):
raise BadRequestError("URL 格式不正确,必须以 http:// 或 https:// 开头") raise BadRequestError("URL 格式不正确,必须以 http:// 或 https:// 开头")
@ -515,28 +535,31 @@ async def get_knowledge_base_files(
): ):
"""获取知识库的文件列表""" """获取知识库的文件列表"""
await _check_kb_access(conn, kb_id, current_user) await _check_kb_access(conn, kb_id, current_user)
files, total = await KnowledgeBaseFileService.get_files_by_kb( file_rows, total = await KnowledgeBaseFileService.get_files_by_kb(
conn, kb_id, current_user.id, page, page_size conn, kb_id, current_user.id, page, page_size
) )
items = [ items = [
FileUploadResponse( {
id=f.id, "id": r["id"],
file_name=f.file_name, "file_name": r["file_name"],
file_size=f.file_size, "file_size": r["file_size"],
status=f.status, "file_type": r["file_type"],
chunk_count=f.chunk_count, "status": r["status"],
created_at=f.created_at, "chunk_count": r["chunk_count"],
file_url=f.file_path "created_at": r["created_at"].isoformat() if r.get("created_at") else None,
).dict() "file_url": r["file_path"],
for f in files "uploader_name": r.get("uploader_name"),
"is_mine": r["user_id"] == current_user.id,
}
for r in file_rows
] ]
return BaseResponse( return BaseResponse(
code=200, code=200,
msg="获取文件列表成功", msg="获取文件列表成功",
data=FileListResponse(total=total, items=items).dict() data={"total": total, "items": items},
) )
@ -615,18 +638,42 @@ async def delete_file(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
conn: asyncpg.Connection = Depends(get_db) conn: asyncpg.Connection = Depends(get_db)
): ):
"""删除知识库中的文件""" """删除知识库中的文件本人、领导管辖范围内、admin 均可)"""
await _check_kb_access(conn, kb_id, current_user) kb = await _check_kb_access(conn, kb_id, current_user)
file = await KnowledgeBaseFileService.get_file_by_id(conn, file_id, current_user.id) file = await KnowledgeBaseFileService.get_file_by_id(conn, file_id, current_user.id)
if not file or file.knowledge_base_id != kb_id: if not file or file.knowledge_base_id != kb_id:
raise NotFoundError("文件") raise NotFoundError("文件")
# 删除文件记录 # 权限校验can_delete_file 内部判断本人/领导/admin
success, vector_ids = await KnowledgeBaseFileService.delete_file(conn, file_id, current_user.id) allowed = await can_delete_file(conn, current_user, file, kb)
if not allowed:
from fastapi import HTTPException, status as http_status
raise HTTPException(
status_code=http_status.HTTP_403_FORBIDDEN,
detail="无权限删除该文件,仅文件上传者、部门领导或管理员可删除",
)
bypass = (current_user.role in ("admin", "leader") and file.user_id != current_user.id)
success, vector_ids = await KnowledgeBaseFileService.delete_file(
conn, file_id, current_user.id, bypass_owner_check=bypass
)
if not success: if not success:
raise NotFoundError("文件") raise NotFoundError("文件")
# 审计日志
await AuditService.write(
conn,
enterprise_id=current_user.enterprise_id or 0,
actor_id=current_user.id,
action="delete",
target_user_id=file.user_id if file.user_id != current_user.id else None,
department_id=current_user.department_id,
kb_id=kb_id,
file_id=file_id,
metadata={"file_name": file.file_name, "by_role": current_user.role},
)
# 删除向量 # 删除向量
if vector_ids: if vector_ids:
try: try:
@ -635,7 +682,7 @@ async def delete_file(
logger.info(f"已删除 {len(vector_ids)} 个向量") logger.info(f"已删除 {len(vector_ids)} 个向量")
except Exception as e: except Exception as e:
logger.warning(f"删除向量库中的向量失败: {e}") logger.warning(f"删除向量库中的向量失败: {e}")
# 删除物理文件 # 删除物理文件
try: try:
oss_service = get_oss_service() oss_service = get_oss_service()
@ -649,7 +696,7 @@ async def delete_file(
logger.info(f"已删除本地文件: {file.file_path}") logger.info(f"已删除本地文件: {file.file_path}")
except Exception as e: except Exception as e:
logger.warning(f"删除物理文件失败: {e}") logger.warning(f"删除物理文件失败: {e}")
return BaseResponse(code=200, msg="删除文件成功", data={"id": file_id}) return BaseResponse(code=200, msg="删除文件成功", data={"id": file_id})

196
backend/api/team_router.py Normal file
View File

@ -0,0 +1,196 @@
"""
领导团队管理 APIrole=leader admin 可访问
路由前缀/api/team
接口列表
GET /api/team/members 查看本部门及下级成员
GET /api/team/members/{user_id} 查看成员详情
PATCH /api/team/members/{user_id}/permissions 修改成员上传权限
GET /api/team/audit-logs 查看本部门及下级操作日志
"""
from typing import List, Optional
import asyncpg
from fastapi import APIRouter, Depends, HTTPException, Query, status
from admin.schemas import AuditLogItem, AuditLogListResponse, TeamMemberItem, UserPermissionUpdate
from core.dependencies import get_db, get_current_user
from core.permissions import get_managed_dept_ids, is_subordinate
from models.user import User
from services.audit_service import AuditService
from utils.helpers import BaseResponse
from logger.logging import get_logger
logger = get_logger(__name__)
team_router = APIRouter(prefix="/api/team", tags=["领导团队管理"])
async def _require_leader_or_admin(user: User) -> User:
"""只有 leader 或 admin 才能访问团队管理接口。"""
if user.role not in ("leader", "admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="仅部门领导或管理员可访问团队管理功能",
)
return user
# ─────────────────────────────────────────────
# 成员列表
# ─────────────────────────────────────────────
@team_router.get("/members", response_model=BaseResponse, summary="查看管辖范围内的成员")
async def list_team_members(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
conn: asyncpg.Connection = Depends(get_db),
):
await _require_leader_or_admin(current_user)
dept_ids = await get_managed_dept_ids(conn, current_user)
if not dept_ids:
return BaseResponse(code=200, msg="ok", data={"total": 0, "items": []})
offset = (page - 1) * page_size
total = await conn.fetchval(
"""
SELECT COUNT(*) FROM user_list
WHERE enterprise_id = $1
AND department_id = ANY($2::int[])
AND id != $3
""",
current_user.enterprise_id, dept_ids, current_user.id,
)
rows = await conn.fetch(
"""
SELECT u.id, u.username, u.display_name, u.department_id,
d.name AS department_name, u.role, u.is_active,
u.allow_kb_upload, u.created_at
FROM user_list u
LEFT JOIN department d ON d.id = u.department_id
WHERE u.enterprise_id = $1
AND u.department_id = ANY($2::int[])
AND u.id != $3
ORDER BY u.department_id, u.id
LIMIT $4 OFFSET $5
""",
current_user.enterprise_id, dept_ids, current_user.id, page_size, offset,
)
items = [TeamMemberItem(**dict(r)).model_dump() for r in rows]
return BaseResponse(code=200, msg="ok", data={"total": int(total or 0), "items": items})
@team_router.get("/members/{user_id}", response_model=BaseResponse, summary="查看成员详情")
async def get_team_member(
user_id: int,
current_user: User = Depends(get_current_user),
conn: asyncpg.Connection = Depends(get_db),
):
await _require_leader_or_admin(current_user)
if not await is_subordinate(conn, current_user, user_id):
raise HTTPException(status_code=403, detail="无权查看该成员,不在管辖范围内")
row = await conn.fetchrow(
"""
SELECT u.id, u.username, u.display_name, u.department_id,
d.name AS department_name, u.role, u.is_active,
u.allow_kb_upload, u.created_at
FROM user_list u
LEFT JOIN department d ON d.id = u.department_id
WHERE u.id = $1
""",
user_id,
)
if not row:
raise HTTPException(status_code=404, detail="用户不存在")
return BaseResponse(code=200, msg="ok", data=TeamMemberItem(**dict(row)).model_dump())
# ─────────────────────────────────────────────
# 修改成员权限
# ─────────────────────────────────────────────
@team_router.patch(
"/members/{user_id}/permissions",
response_model=BaseResponse,
summary="设置下属成员知识库上传权限",
)
async def update_member_permission(
user_id: int,
body: UserPermissionUpdate,
current_user: User = Depends(get_current_user),
conn: asyncpg.Connection = Depends(get_db),
):
await _require_leader_or_admin(current_user)
if not await is_subordinate(conn, current_user, user_id):
raise HTTPException(status_code=403, detail="越级操作:该用户不在您的管辖范围内")
await conn.execute(
"UPDATE user_list SET allow_kb_upload = $1 WHERE id = $2",
body.allow_kb_upload, user_id,
)
# 审计日志
target_row = await conn.fetchrow(
"SELECT department_id FROM user_list WHERE id = $1", user_id
)
await AuditService.write(
conn,
enterprise_id=current_user.enterprise_id or 0,
actor_id=current_user.id,
action="permission_change",
target_user_id=user_id,
department_id=current_user.department_id,
metadata={
"field": "allow_kb_upload",
"new": body.allow_kb_upload,
"by_role": current_user.role,
},
)
return BaseResponse(
code=200,
msg="权限已更新",
data={"user_id": user_id, "allow_kb_upload": body.allow_kb_upload},
)
# ─────────────────────────────────────────────
# 审计日志(部门范围)
# ─────────────────────────────────────────────
@team_router.get(
"/audit-logs",
response_model=BaseResponse,
summary="查看管辖部门内的操作日志",
)
async def list_team_audit_logs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
action: Optional[str] = Query(None, description="操作类型过滤"),
actor_id: Optional[int] = Query(None, description="指定操作者"),
kb_id: Optional[int] = Query(None, description="指定知识库"),
current_user: User = Depends(get_current_user),
conn: asyncpg.Connection = Depends(get_db),
):
await _require_leader_or_admin(current_user)
dept_ids = await get_managed_dept_ids(conn, current_user)
# admin 传 None 查全量leader 传 dept_ids
filter_depts = None if current_user.role == "admin" else dept_ids
rows, total = await AuditService.list_logs(
conn,
current_user.enterprise_id,
department_ids=filter_depts,
actor_id=actor_id,
kb_id=kb_id,
action=action,
page=page,
page_size=page_size,
)
items = [AuditLogItem(**r).model_dump() for r in rows]
return BaseResponse(
code=200,
msg="ok",
data=AuditLogListResponse(total=total, items=items).model_dump(),
)

View File

@ -45,6 +45,7 @@ from api.kb_file_router import kb_file_router
from api.kb_processing_router import kb_processing_router from api.kb_processing_router import kb_processing_router
from api.knowledge_graph_router import knowledge_graph_router from api.knowledge_graph_router import knowledge_graph_router
from api.user_setting import user_setting_router from api.user_setting import user_setting_router
from api.team_router import team_router
from admin import admin_router from admin import admin_router
from core.config import settings from core.config import settings
from core.database import close_db_pool from core.database import close_db_pool
@ -146,6 +147,9 @@ def create_app() -> FastAPI:
# 用户设置路由 # 用户设置路由
app.include_router(user_setting_router) app.include_router(user_setting_router)
# 领导团队管理路由
app.include_router(team_router)
app.include_router(knowledge_graph_router) app.include_router(knowledge_graph_router)
# 注册全局异常处理器 # 注册全局异常处理器

View File

@ -1,17 +1,92 @@
""" """
企业版知识库访问控制RBAC + ABAC可见性 企业版知识库访问控制RBAC + ABAC可见性
quanxianfangan.md 中规则一致
规则汇总
--------
- admin : 全企业范围无限制
- leader : 管辖本部门及所有子孙部门内的用户与文件
- employee: 只能操作自己的资源可查看同部门 department/enterprise 可见知识库及库内全部文件
- 越级 : 不在管辖子树内的操作一律拒绝
""" """
from typing import Literal from typing import List, Literal, Optional
import asyncpg
from models.graph_metadata import GraphRecord from models.graph_metadata import GraphRecord
from models.knowledge_base import KnowledgeBase from models.knowledge_base import KnowledgeBase
from models.knowledge_base_file import KnowledgeBaseFile
from models.user import User from models.user import User
UserRole = Literal["admin", "leader", "employee"] UserRole = Literal["admin", "leader", "employee"]
KbVisibility = Literal["private", "department", "enterprise"] KbVisibility = Literal["private", "department", "enterprise"]
# ──────────────────────────────────────────────
# 部门子树查询(方案 A 核心辅助)
# ──────────────────────────────────────────────
async def get_managed_dept_ids(conn: asyncpg.Connection, user: User) -> List[int]:
"""
返回 user 可管辖的部门 ID 列表
- admin -> 企业全部部门
- leader -> 本部门及其所有子孙部门递归 CTE
- employee -> 空列表
"""
if user.enterprise_id is None:
return []
if user.role == "admin":
rows = await conn.fetch(
"SELECT id FROM department WHERE enterprise_id = $1",
user.enterprise_id,
)
return [r["id"] for r in rows]
if user.role == "leader" and user.department_id is not None:
rows = await conn.fetch(
"""
WITH RECURSIVE sub AS (
SELECT id FROM department WHERE id = $1
UNION ALL
SELECT d.id FROM department d JOIN sub ON d.parent_id = sub.id
)
SELECT id FROM sub
""",
user.department_id,
)
return [r["id"] for r in rows]
return []
async def is_subordinate(
conn: asyncpg.Connection, actor: User, target_user_id: int
) -> bool:
"""
判断 target_user_id 是否在 actor 的管辖范围内
- admin 对同企业全员返回 True
- leader 对子树内非本人成员返回 True
"""
if actor.id == target_user_id:
return False
if actor.enterprise_id is None:
return False
# 确认被管辖者在同一企业
row = await conn.fetchrow(
"SELECT department_id, enterprise_id FROM user_list WHERE id = $1",
target_user_id,
)
if not row or row["enterprise_id"] != actor.enterprise_id:
return False
if actor.role == "admin":
return True
if actor.role == "leader":
managed = await get_managed_dept_ids(conn, actor)
return row["department_id"] in managed if row["department_id"] else False
return False
# ──────────────────────────────────────────────
# 知识库级权限(同步,无需数据库)
# ──────────────────────────────────────────────
def can_view_kb(user: User, kb: KnowledgeBase) -> bool: def can_view_kb(user: User, kb: KnowledgeBase) -> bool:
"""判断用户是否可查看该知识库。""" """判断用户是否可查看该知识库。"""
if user.role == "admin": if user.role == "admin":
@ -39,6 +114,52 @@ def can_manage_kb(user: User, kb: KnowledgeBase) -> bool:
return False return False
# ──────────────────────────────────────────────
# 文件级权限(需要异步 DB 查询)
# ──────────────────────────────────────────────
async def can_delete_file(
conn: asyncpg.Connection,
user: User,
file: KnowledgeBaseFile,
kb: Optional[KnowledgeBase] = None,
) -> bool:
"""
判断用户是否可删除该文件
- 文件上传者本人
- admin同企业
- leader下级成员上传的文件基于子树管辖范围
"""
# 本人上传
if file.user_id == user.id:
return True
# admin 可删同企业内任意文件
if user.role == "admin":
if kb is not None:
return kb.enterprise_id == user.enterprise_id
return True
# leader 判断上传者是否在子树内
if user.role == "leader":
return await is_subordinate(conn, user, file.user_id)
return False
def can_upload_to_kb(user: User, kb: KnowledgeBase) -> bool:
"""
判断用户是否可向知识库上传文件
- 必须能 view KB
- allow_kb_upload 未被关闭
- leader admin 也受 allow_kb_upload 约束管理员初始化时均为 True
"""
if not can_view_kb(user, kb):
return False
return bool(user.allow_kb_upload)
# ──────────────────────────────────────────────
# 知识图谱级权限(保持不变)
# ──────────────────────────────────────────────
def can_view_graph(user: User, g: GraphRecord) -> bool: def can_view_graph(user: User, g: GraphRecord) -> bool:
"""判断用户是否可查看该知识图谱(规则与知识库一致)。""" """判断用户是否可查看该知识图谱(规则与知识库一致)。"""
if user.role == "admin": if user.role == "admin":

View File

@ -31,6 +31,7 @@ class User(BaseModel):
department_id: Optional[int] = None department_id: Optional[int] = None
role: str = Field("employee", description="admin | leader | employee") role: str = Field("employee", description="admin | leader | employee")
is_first_login: bool = True is_first_login: bool = True
allow_kb_upload: bool = Field(True, description="是否允许上传文件到知识库")
class Config: class Config:
from_attributes = True from_attributes = True

444
backend/seed_test_data.py Normal file
View File

@ -0,0 +1,444 @@
"""
权限方案 A 测试数据种子脚本
运行方式 backend 目录下
python seed_test_data.py
前提
1. 已执行 update-ddl.txt 中的 ALTER/CREATE 语句
2. .env 文件中的数据库配置正确
数据概览
--------
企业火焰企业id=1若已存在则跳过
部门结构三级树形
技术部id serial 自动生成
前端组
后端组
产品部
角色矩阵
admin1 admin 无部门
leader_tech leader 技术部兼管前端组后端组
leader_frontend leader 前端组
emp1_frontend employee 前端组 allow_kb_upload=true
emp2_frontend employee 前端组 allow_kb_upload=false被关闭
emp1_backend employee 后端组
leader_product leader 产品部
emp1_product employee 产品部
所有账号密码均为Test@123456
"""
import asyncio
import json
from datetime import datetime, timezone
import asyncpg
from dotenv import load_dotenv
from pathlib import Path
load_dotenv(Path(__file__).resolve().parent.parent / ".env")
from core.config import settings
from core.security import get_password_hash
# ──────────────────────────────────────────────
# 测试数据定义
# ──────────────────────────────────────────────
PASSWORD = "Test@123456"
DEPARTMENTS = [
{"key": "tech", "name": "技术部", "parent_key": None},
{"key": "frontend", "name": "前端组", "parent_key": "tech"},
{"key": "backend", "name": "后端组", "parent_key": "tech"},
{"key": "product", "name": "产品部", "parent_key": None},
]
USERS = [
{
"username": "admin1",
"email": "admin1@test.example",
"phone": "13800000001",
"display_name": "系统管理员",
"role": "admin",
"dept_key": None,
"allow_kb_upload": True,
},
{
"username": "leader_tech",
"email": "leader_tech@test.example",
"phone": "13800000002",
"display_name": "技术部总监",
"role": "leader",
"dept_key": "tech",
"allow_kb_upload": True,
},
{
"username": "leader_frontend",
"email": "leader_frontend@test.example",
"phone": "13800000003",
"display_name": "前端组组长",
"role": "leader",
"dept_key": "frontend",
"allow_kb_upload": True,
},
{
"username": "emp1_frontend",
"email": "emp1_frontend@test.example",
"phone": "13800000004",
"display_name": "前端员工甲",
"role": "employee",
"dept_key": "frontend",
"allow_kb_upload": True,
},
{
"username": "emp2_frontend",
"email": "emp2_frontend@test.example",
"phone": "13800000005",
"display_name": "前端员工乙(上传已禁)",
"role": "employee",
"dept_key": "frontend",
"allow_kb_upload": False, # 领导关闭了上传权限
},
{
"username": "emp1_backend",
"email": "emp1_backend@test.example",
"phone": "13800000006",
"display_name": "后端员工甲",
"role": "employee",
"dept_key": "backend",
"allow_kb_upload": True,
},
{
"username": "leader_product",
"email": "leader_product@test.example",
"phone": "13800000007",
"display_name": "产品部负责人",
"role": "leader",
"dept_key": "product",
"allow_kb_upload": True,
},
{
"username": "emp1_product",
"email": "emp1_product@test.example",
"phone": "13800000008",
"display_name": "产品员工甲",
"role": "employee",
"dept_key": "product",
"allow_kb_upload": True,
},
]
# ──────────────────────────────────────────────
# 种子函数
# ──────────────────────────────────────────────
async def seed():
conn: asyncpg.Connection = await asyncpg.connect(settings.db_uri)
hashed_pw = get_password_hash(PASSWORD)
now = datetime.now(timezone.utc)
print("=== 开始写入测试数据 ===\n")
# 1. 企业
enterprise_id = await conn.fetchval(
"SELECT id FROM enterprise LIMIT 1"
)
if enterprise_id is None:
enterprise_id = await conn.fetchval(
"""
INSERT INTO enterprise (name, code, created_at, updated_at)
VALUES ('火焰企业(测试)', 'huoyan-test', $1, $1)
RETURNING id
""",
now,
)
print(f"✅ 创建企业 id={enterprise_id}")
else:
print(f" 使用已有企业 id={enterprise_id}")
# 2. 部门
dept_map: dict[str, int] = {}
for d in DEPARTMENTS:
existing = await conn.fetchval(
"SELECT id FROM department WHERE enterprise_id = $1 AND name = $2",
enterprise_id, d["name"],
)
if existing:
dept_map[d["key"]] = existing
print(f" 部门「{d['name']}」已存在 id={existing}")
continue
parent_id = dept_map.get(d["parent_key"]) if d["parent_key"] else None
row = await conn.fetchrow(
"""
INSERT INTO department (enterprise_id, name, parent_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $4)
RETURNING id
""",
enterprise_id, d["name"], parent_id, now,
)
dept_map[d["key"]] = row["id"]
print(f"✅ 创建部门「{d['name']}」id={row['id']} parent={parent_id}")
# 3. 用户
user_map: dict[str, int] = {}
for u in USERS:
existing = await conn.fetchval(
"SELECT id FROM user_list WHERE username = $1", u["username"]
)
if existing:
user_map[u["username"]] = existing
print(f" 用户「{u['username']}」已存在 id={existing}")
continue
dept_id = dept_map.get(u["dept_key"]) if u["dept_key"] else None
row = await conn.fetchrow(
"""
INSERT INTO user_list (
username, email, phone, hashed_password, display_name,
enterprise_id, department_id, role, allow_kb_upload,
is_active, is_first_login, email_verified,
created_at, updated_at
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,TRUE,FALSE,TRUE,$10,$10)
RETURNING id
""",
u["username"], u["email"], u["phone"], hashed_pw,
u["display_name"], enterprise_id, dept_id, u["role"],
u["allow_kb_upload"], now,
)
user_map[u["username"]] = row["id"]
print(
f"✅ 创建用户「{u['display_name']}」({u['role']}) id={row['id']}"
f" dept={dept_id} allow_upload={u['allow_kb_upload']}"
)
# 4. 绑定部门负责人leader_user_id
leader_bindings = [
("tech", "leader_tech"),
("frontend", "leader_frontend"),
("product", "leader_product"),
]
for dept_key, leader_key in leader_bindings:
dept_id = dept_map.get(dept_key)
leader_id = user_map.get(leader_key)
if dept_id and leader_id:
await conn.execute(
"UPDATE department SET leader_user_id = $1 WHERE id = $2",
leader_id, dept_id,
)
print(f"🔗 部门 id={dept_id} 负责人 -> user id={leader_id}")
# 5. 知识库
kb_defs = [
{
"name": "前端组公共手册",
"description": "前端组对内共享的技术规范与操作手册",
"visibility": "department",
"creator_key": "leader_frontend",
"dept_key": "frontend",
},
{
"name": "前端员工甲的私人笔记",
"description": "个人学习笔记(私有)",
"visibility": "private",
"creator_key": "emp1_frontend",
"dept_key": "frontend",
},
{
"name": "企业全员知识库",
"description": "全企业可见,由管理员维护",
"visibility": "enterprise",
"creator_key": "admin1",
"dept_key": None,
},
{
"name": "产品部资料库",
"description": "产品部内部共享文档",
"visibility": "department",
"creator_key": "leader_product",
"dept_key": "product",
},
]
kb_map: dict[str, int] = {}
for kb in kb_defs:
creator_id = user_map.get(kb["creator_key"])
dept_id = dept_map.get(kb["dept_key"]) if kb["dept_key"] else None
existing = await conn.fetchval(
"""
SELECT id FROM knowledge_base
WHERE name = $1 AND enterprise_id = $2 AND is_deleted = FALSE
""",
kb["name"], enterprise_id,
)
if existing:
kb_map[kb["name"]] = existing
print(f" 知识库「{kb['name']}」已存在 id={existing}")
continue
row = await conn.fetchrow(
"""
INSERT INTO knowledge_base
(user_id, enterprise_id, department_id, creator_id, name,
description, visibility, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$8)
RETURNING id
""",
creator_id, enterprise_id, dept_id, creator_id,
kb["name"], kb["description"], kb["visibility"], now,
)
kb_map[kb["name"]] = row["id"]
print(f"✅ 创建知识库「{kb['name']}」({kb['visibility']}) id={row['id']}")
# 6. 文件记录(模拟已处理完成)
file_defs = [
{
"kb_name": "前端组公共手册",
"uploader_key": "leader_frontend",
"file_name": "前端规范v1.0.pdf",
"file_size": 204800,
"file_type": "pdf",
},
{
"kb_name": "前端组公共手册",
"uploader_key": "emp1_frontend",
"file_name": "组件库使用指南.docx",
"file_size": 153600,
"file_type": "docx",
},
{
"kb_name": "前端组公共手册",
"uploader_key": "emp1_backend",
"file_name": "接口文档摘要.txt",
"file_size": 8192,
"file_type": "txt",
},
{
"kb_name": "前端员工甲的私人笔记",
"uploader_key": "emp1_frontend",
"file_name": "Vue3学习笔记.txt",
"file_size": 4096,
"file_type": "txt",
},
{
"kb_name": "企业全员知识库",
"uploader_key": "admin1",
"file_name": "员工手册2026.pdf",
"file_size": 512000,
"file_type": "pdf",
},
{
"kb_name": "产品部资料库",
"uploader_key": "emp1_product",
"file_name": "需求评审记录.xlsx",
"file_size": 65536,
"file_type": "xlsx",
},
]
file_id_map: dict[str, int] = {}
for fd in file_defs:
kb_id = kb_map.get(fd["kb_name"])
uploader_id = user_map.get(fd["uploader_key"])
if not kb_id or not uploader_id:
continue
existing = await conn.fetchval(
"""
SELECT id FROM knowledge_base_file
WHERE knowledge_base_id = $1 AND file_name = $2 AND is_deleted = FALSE
""",
kb_id, fd["file_name"],
)
if existing:
file_id_map[fd["file_name"]] = existing
print(f" 文件「{fd['file_name']}」已存在 id={existing}")
continue
row = await conn.fetchrow(
"""
INSERT INTO knowledge_base_file
(knowledge_base_id, user_id, file_name, file_path, file_size,
file_type, status, chunk_count, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,'completed',$7,$8,$8)
RETURNING id
""",
kb_id, uploader_id, fd["file_name"],
f"/fake/path/{fd['file_name']}", fd["file_size"],
fd["file_type"], 5, now,
)
file_id_map[fd["file_name"]] = row["id"]
print(f"✅ 创建文件「{fd['file_name']}」id={row['id']} uploader={fd['uploader_key']}")
# 7. 审计日志(模拟历史操作)
audit_entries = [
# 上传操作
{"actor": "emp1_frontend", "action": "upload",
"kb": "前端组公共手册", "file": "组件库使用指南.docx",
"dept_key": "frontend"},
{"actor": "emp1_backend", "action": "upload",
"kb": "前端组公共手册", "file": "接口文档摘要.txt",
"dept_key": "backend"},
# 删除操作(领导删下属文件)
{"actor": "leader_frontend", "action": "delete",
"kb": "前端组公共手册", "file": "接口文档摘要.txt",
"target": "emp1_backend", "dept_key": "frontend"},
# 权限变更
{"actor": "leader_frontend", "action": "permission_change",
"target": "emp2_frontend", "dept_key": "frontend",
"meta": {"field": "allow_kb_upload", "old": True, "new": False}},
# 下载
{"actor": "emp1_frontend", "action": "download",
"kb": "前端组公共手册", "file": "前端规范v1.0.pdf",
"dept_key": "frontend"},
# Admin 全局操作
{"actor": "admin1", "action": "create_kb",
"kb": "企业全员知识库", "dept_key": None},
]
for ae in audit_entries:
actor_id = user_map.get(ae["actor"])
target_id = user_map.get(ae.get("target")) if ae.get("target") else None
kb_id = kb_map.get(ae.get("kb")) if ae.get("kb") else None
file_id_val = file_id_map.get(ae.get("file")) if ae.get("file") else None
dept_id = dept_map.get(ae.get("dept_key")) if ae.get("dept_key") else None
meta_str = json.dumps(ae.get("meta", {})) if ae.get("meta") else None
await conn.execute(
"""
INSERT INTO kb_audit_log
(enterprise_id, actor_id, action, target_user_id, department_id,
kb_id, file_id, ip, metadata, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,'127.0.0.1',$8::jsonb,$9)
""",
enterprise_id, actor_id, ae["action"], target_id, dept_id,
kb_id, file_id_val, meta_str, now,
)
print(f"✅ 写入 {len(audit_entries)} 条审计日志")
await conn.close()
print("\n=== 测试数据写入完成 ===")
print(f"\n所有测试账号密码:{PASSWORD}")
print("\n账号列表:")
headers = ["用户名", "角色", "部门", "allow_upload"]
rows_display = [
("admin1", "admin", "", ""),
("leader_tech", "leader", "技术部", ""),
("leader_frontend", "leader", "前端组", ""),
("emp1_frontend", "employee", "前端组", ""),
("emp2_frontend", "employee", "前端组", "✗(已禁)"),
("emp1_backend", "employee", "后端组", ""),
("leader_product", "leader", "产品部", ""),
("emp1_product", "employee", "产品部", ""),
]
col_w = [18, 10, 8, 12]
sep = "+" + "+".join("-" * (w + 2) for w in col_w) + "+"
fmt = "| " + " | ".join(f"{{:<{w}}}" for w in col_w) + " |"
print(sep)
print(fmt.format(*headers))
print(sep)
for r in rows_display:
print(fmt.format(*r))
print(sep)
print("\n知识库:")
for name, kid in kb_map.items():
print(f" id={kid} {name}")
if __name__ == "__main__":
asyncio.run(seed())

View File

@ -79,7 +79,7 @@ class AdminUserService:
rows = await conn.fetch( rows = await conn.fetch(
f""" f"""
SELECT id, username, email, phone, display_name, enterprise_id, department_id, SELECT id, username, email, phone, display_name, enterprise_id, department_id,
role, is_active, is_first_login, created_at, last_login_at role, is_active, is_first_login, allow_kb_upload, created_at, last_login_at
FROM user_list FROM user_list
WHERE {where_sql} WHERE {where_sql}
ORDER BY id DESC ORDER BY id DESC
@ -98,7 +98,7 @@ class AdminUserService:
row = await conn.fetchrow( row = await conn.fetchrow(
""" """
SELECT id, username, email, phone, display_name, enterprise_id, department_id, SELECT id, username, email, phone, display_name, enterprise_id, department_id,
role, is_active, is_first_login, created_at, last_login_at role, is_active, is_first_login, allow_kb_upload, created_at, last_login_at
FROM user_list FROM user_list
WHERE id = $1 AND enterprise_id = $2 WHERE id = $1 AND enterprise_id = $2
""", """,
@ -136,7 +136,7 @@ class AdminUserService:
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, TRUE, $10, $10) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, TRUE, $10, $10)
RETURNING id, username, email, phone, display_name, enterprise_id, department_id, RETURNING id, username, email, phone, display_name, enterprise_id, department_id,
role, is_active, is_first_login, created_at, last_login_at role, is_active, is_first_login, allow_kb_upload, created_at, last_login_at
""", """,
data.username, data.username,
str(data.email), str(data.email),
@ -173,7 +173,7 @@ class AdminUserService:
row = await conn.fetchrow( row = await conn.fetchrow(
""" """
SELECT id, username, email, phone, display_name, enterprise_id, department_id, SELECT id, username, email, phone, display_name, enterprise_id, department_id,
role, is_active, is_first_login, created_at, last_login_at role, is_active, is_first_login, allow_kb_upload, created_at, last_login_at
FROM user_list WHERE id = $1 AND enterprise_id = $2 FROM user_list WHERE id = $1 AND enterprise_id = $2
""", """,
user_id, user_id,
@ -217,6 +217,7 @@ class AdminUserService:
"role", "role",
"is_active", "is_active",
"hashed_password", "hashed_password",
"allow_kb_upload",
) )
for key, val in updates.items(): for key, val in updates.items():
if key not in allowed: if key not in allowed:
@ -228,7 +229,7 @@ class AdminUserService:
row = await conn.fetchrow( row = await conn.fetchrow(
""" """
SELECT id, username, email, phone, display_name, enterprise_id, department_id, SELECT id, username, email, phone, display_name, enterprise_id, department_id,
role, is_active, is_first_login, created_at, last_login_at role, is_active, is_first_login, allow_kb_upload, created_at, last_login_at
FROM user_list WHERE id = $1 AND enterprise_id = $2 FROM user_list WHERE id = $1 AND enterprise_id = $2
""", """,
user_id, user_id,
@ -244,7 +245,7 @@ class AdminUserService:
SET {", ".join(fields)}, updated_at = CURRENT_TIMESTAMP SET {", ".join(fields)}, updated_at = CURRENT_TIMESTAMP
WHERE id = ${wid} AND enterprise_id = ${we} WHERE id = ${wid} AND enterprise_id = ${we}
RETURNING id, username, email, phone, display_name, enterprise_id, department_id, RETURNING id, username, email, phone, display_name, enterprise_id, department_id,
role, is_active, is_first_login, created_at, last_login_at role, is_active, is_first_login, allow_kb_upload, created_at, last_login_at
""" """
row = await conn.fetchrow(q, *params) row = await conn.fetchrow(q, *params)
return dict(row) if row else None return dict(row) if row else None

View File

@ -0,0 +1,155 @@
"""
知识库操作审计日志服务
action 枚举值
-------------
upload 上传文件到知识库
download 下载/查看文件 URL 预览
delete 删除文件
archive 归档文件预留
create_kb 创建知识库
delete_kb 删除知识库
permission_change 修改下级用户权限如关闭/开启上传权限
"""
from typing import Any, Dict, List, Optional, Tuple
import asyncpg
from logger.logging import get_logger
logger = get_logger(__name__)
VALID_ACTIONS = frozenset({
"upload",
"download",
"delete",
"archive",
"create_kb",
"delete_kb",
"permission_change",
})
class AuditService:
@staticmethod
async def write(
conn: asyncpg.Connection,
*,
enterprise_id: int,
actor_id: int,
action: str,
target_user_id: Optional[int] = None,
department_id: Optional[int] = None,
kb_id: Optional[int] = None,
file_id: Optional[int] = None,
ip: Optional[str] = None,
user_agent: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""写入一条审计日志(非阻塞,异常仅记录不抛出)。"""
try:
import json
meta_json = json.dumps(metadata, ensure_ascii=False) if metadata else None
await conn.execute(
"""
INSERT INTO kb_audit_log
(enterprise_id, actor_id, action, target_user_id, department_id,
kb_id, file_id, ip, user_agent, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
""",
enterprise_id,
actor_id,
action,
target_user_id,
department_id,
kb_id,
file_id,
ip,
user_agent,
meta_json,
)
except Exception as e:
logger.warning(f"写入审计日志失败(不影响主流程): {e}")
@staticmethod
async def list_logs(
conn: asyncpg.Connection,
enterprise_id: int,
*,
department_ids: Optional[List[int]] = None, # None 表示不过滤admin 全量)
actor_id: Optional[int] = None,
kb_id: Optional[int] = None,
action: Optional[str] = None,
page: int = 1,
page_size: int = 20,
) -> Tuple[List[dict], int]:
"""
查询审计日志
- department_ids=None -> 全企业admin
- department_ids=[...] -> 限制在给定部门内leader actor 所在部门过滤
"""
offset = (page - 1) * page_size
conds = ["l.enterprise_id = $1"]
params: list = [enterprise_id]
if department_ids is not None:
if not department_ids:
return [], 0
conds.append(f"l.department_id = ANY(${len(params)+1}::int[])")
params.append(department_ids)
if actor_id is not None:
conds.append(f"l.actor_id = ${len(params)+1}")
params.append(actor_id)
if kb_id is not None:
conds.append(f"l.kb_id = ${len(params)+1}")
params.append(kb_id)
if action is not None:
conds.append(f"l.action = ${len(params)+1}")
params.append(action)
where = " AND ".join(conds)
count_sql = f"SELECT COUNT(*) FROM kb_audit_log l WHERE {where}"
total = await conn.fetchval(count_sql, *params)
lim_ph = len(params) + 1
off_ph = len(params) + 2
params.extend([page_size, offset])
rows = await conn.fetch(
f"""
SELECT
l.id, l.enterprise_id, l.actor_id, l.action,
l.target_user_id, l.department_id, l.kb_id, l.file_id,
l.ip, l.metadata, l.created_at,
COALESCE(NULLIF(TRIM(u.display_name),''), u.username) AS actor_name,
COALESCE(NULLIF(TRIM(tu.display_name),''), tu.username) AS target_name,
d.name AS department_name,
kb.name AS kb_name,
kf.file_name
FROM kb_audit_log l
LEFT JOIN user_list u ON u.id = l.actor_id
LEFT JOIN user_list tu ON tu.id = l.target_user_id
LEFT JOIN department d ON d.id = l.department_id
LEFT JOIN knowledge_base kb ON kb.id = l.kb_id
LEFT JOIN knowledge_base_file kf ON kf.id = l.file_id
WHERE {where}
ORDER BY l.created_at DESC
LIMIT ${lim_ph} OFFSET ${off_ph}
""",
*params,
)
import json as _json
result = []
for r in rows:
row = dict(r)
meta = row.get("metadata")
if isinstance(meta, str):
try:
row["metadata"] = _json.loads(meta)
except Exception:
row["metadata"] = None
result.append(row)
return result, int(total or 0)

View File

@ -3,6 +3,22 @@ from typing import List, Optional
import asyncpg import asyncpg
async def get_subtree_ids(conn: asyncpg.Connection, dept_id: int) -> List[int]:
"""递归查询某部门及其所有子孙部门的 ID 列表。"""
rows = await conn.fetch(
"""
WITH RECURSIVE sub AS (
SELECT id FROM department WHERE id = $1
UNION ALL
SELECT d.id FROM department d JOIN sub ON d.parent_id = sub.id
)
SELECT id FROM sub
""",
dept_id,
)
return [r["id"] for r in rows]
class DepartmentService: class DepartmentService:
@staticmethod @staticmethod
async def list_by_enterprise( async def list_by_enterprise(
@ -10,10 +26,13 @@ class DepartmentService:
) -> List[dict]: ) -> List[dict]:
rows = await conn.fetch( rows = await conn.fetch(
""" """
SELECT id, enterprise_id, name, parent_id, created_at, updated_at SELECT d.id, d.enterprise_id, d.name, d.parent_id,
FROM department d.leader_user_id, d.created_at, d.updated_at,
WHERE enterprise_id = $1 COALESCE(NULLIF(TRIM(u.display_name),''), u.username) AS leader_name
ORDER BY id ASC FROM department d
LEFT JOIN user_list u ON u.id = d.leader_user_id
WHERE d.enterprise_id = $1
ORDER BY d.id ASC
""", """,
enterprise_id, enterprise_id,
) )
@ -25,15 +44,39 @@ class DepartmentService:
) -> Optional[dict]: ) -> Optional[dict]:
row = await conn.fetchrow( row = await conn.fetchrow(
""" """
SELECT id, enterprise_id, name, parent_id, created_at, updated_at SELECT d.id, d.enterprise_id, d.name, d.parent_id,
FROM department d.leader_user_id, d.created_at, d.updated_at,
WHERE id = $1 AND enterprise_id = $2 COALESCE(NULLIF(TRIM(u.display_name),''), u.username) AS leader_name
FROM department d
LEFT JOIN user_list u ON u.id = d.leader_user_id
WHERE d.id = $1 AND d.enterprise_id = $2
""", """,
dept_id, dept_id,
enterprise_id, enterprise_id,
) )
return dict(row) if row else None return dict(row) if row else None
@staticmethod
async def set_leader(
conn: asyncpg.Connection,
dept_id: int,
enterprise_id: int,
leader_user_id: Optional[int],
) -> Optional[dict]:
"""设置或清除部门负责人。leader_user_id=None 表示清除。"""
row = await conn.fetchrow(
"""
UPDATE department
SET leader_user_id = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2 AND enterprise_id = $3
RETURNING id, enterprise_id, name, parent_id, leader_user_id, created_at, updated_at
""",
leader_user_id,
dept_id,
enterprise_id,
)
return dict(row) if row else None
@staticmethod @staticmethod
async def create( async def create(
conn: asyncpg.Connection, conn: asyncpg.Connection,

View File

@ -159,28 +159,22 @@ class KnowledgeBaseFileService:
async def get_file_by_id( async def get_file_by_id(
conn: asyncpg.Connection, conn: asyncpg.Connection,
file_id: int, file_id: int,
user_id: int user_id: int,
) -> Optional[KnowledgeBaseFile]: ) -> Optional[KnowledgeBaseFile]:
""" """
根据 ID 获取文件 根据 ID 获取文件不再强制 user_id 过滤权限校验由路由层完成
Args: user_id 参数保留以兼容旧调用签名不再作为查询条件
conn: 数据库连接
file_id: 文件 ID
user_id: 用户 ID
Returns:
Optional[KnowledgeBaseFile]: 文件对象
""" """
try: try:
row = await conn.fetchrow( row = await conn.fetchrow(
""" """
SELECT id, knowledge_base_id, user_id, file_name, file_path, file_size, SELECT id, knowledge_base_id, user_id, file_name, file_path, file_size,
file_type, status, chunk_count, created_at, updated_at, is_deleted, deleted_at file_type, status, chunk_count, created_at, updated_at, is_deleted, deleted_at
FROM knowledge_base_file FROM knowledge_base_file
WHERE id = $1 AND user_id = $2 AND is_deleted = FALSE WHERE id = $1 AND is_deleted = FALSE
""", """,
file_id, user_id file_id,
) )
if row: if row:
@ -197,49 +191,44 @@ class KnowledgeBaseFileService:
knowledge_base_id: int, knowledge_base_id: int,
user_id: int, user_id: int,
page: int = 1, page: int = 1,
page_size: int = 20 page_size: int = 20,
) -> Tuple[List[KnowledgeBaseFile], int]: ) -> Tuple[List[dict], int]:
""" """
获取知识库的文件列表 获取知识库的文件列表返回该 KB 内所有文件的原始 dict
Args: KB 访问权限应由调用方路由层 _check_kb_access提前完成校验
conn: 数据库连接 文件可见性随 KB 可见性走能看到 KB 即可看到库内全部文件
knowledge_base_id: 知识库 ID 使同部门员工互相查看对方上传的文件成为可能
user_id: 用户 ID user_id 参数保留以兼容旧调用签名不再作为过滤条件
page: 页码
page_size: 每页数量
Returns:
Tuple[List[KnowledgeBaseFile], int]: (文件列表, 总数量)
""" """
try: try:
offset = (page - 1) * page_size offset = (page - 1) * page_size
# 获取总数
total = await conn.fetchval( total = await conn.fetchval(
""" """
SELECT COUNT(*) FROM knowledge_base_file SELECT COUNT(*) FROM knowledge_base_file
WHERE knowledge_base_id = $1 AND user_id = $2 AND is_deleted = FALSE WHERE knowledge_base_id = $1 AND is_deleted = FALSE
""", """,
knowledge_base_id, user_id knowledge_base_id,
) )
# 获取列表
rows = await conn.fetch( rows = await conn.fetch(
""" """
SELECT id, knowledge_base_id, user_id, file_name, file_path, file_size, SELECT f.id, f.knowledge_base_id, f.user_id, f.file_name, f.file_path,
file_type, status, chunk_count, created_at, updated_at, is_deleted, deleted_at f.file_size, f.file_type, f.status, f.chunk_count,
FROM knowledge_base_file f.created_at, f.updated_at, f.is_deleted, f.deleted_at,
WHERE knowledge_base_id = $1 AND user_id = $2 AND is_deleted = FALSE COALESCE(NULLIF(TRIM(u.display_name),''), u.username) AS uploader_name
ORDER BY created_at DESC FROM knowledge_base_file f
LIMIT $3 OFFSET $4 LEFT JOIN user_list u ON u.id = f.user_id
WHERE f.knowledge_base_id = $1 AND f.is_deleted = FALSE
ORDER BY f.created_at DESC
LIMIT $2 OFFSET $3
""", """,
knowledge_base_id, user_id, page_size, offset knowledge_base_id, page_size, offset,
) )
files = [KnowledgeBaseFile(**dict(row)) for row in rows] return [dict(r) for r in rows], int(total or 0)
return files, total
except Exception as e: except Exception as e:
logger.error(f"获取文件列表失败: {e}") logger.error(f"获取文件列表失败: {e}")
raise Exception(f"获取文件列表失败: {str(e)}") raise Exception(f"获取文件列表失败: {str(e)}")
@ -500,50 +489,60 @@ class KnowledgeBaseFileService:
async def delete_file( async def delete_file(
conn: asyncpg.Connection, conn: asyncpg.Connection,
file_id: int, file_id: int,
user_id: int user_id: int,
bypass_owner_check: bool = False,
) -> Tuple[bool, List[str]]: ) -> Tuple[bool, List[str]]:
""" """
删除文件软删除 删除文件软删除同时删除文件的所有文档块
同时删除文件的所有文档块
bypass_owner_check=True 时跳过 user_id 校验 admin/leader 路径使用
Args: 调用方须已在路由层完成 can_delete_file 权限判断
conn: 数据库连接
file_id: 文件 ID
user_id: 用户 ID
Returns:
Tuple[bool, List[str]]: (是否删除成功, 向量 ID 列表)
""" """
try: try:
# 先检查文件是否存在且属于该用户 if bypass_owner_check:
file_record = await conn.fetchrow( file_record = await conn.fetchrow(
""" """
SELECT id, knowledge_base_id, file_name SELECT id, knowledge_base_id, file_name
FROM knowledge_base_file FROM knowledge_base_file
WHERE id = $1 AND user_id = $2 AND is_deleted = FALSE WHERE id = $1 AND is_deleted = FALSE
""", """,
file_id, user_id file_id,
) )
else:
file_record = await conn.fetchrow(
"""
SELECT id, knowledge_base_id, file_name
FROM knowledge_base_file
WHERE id = $1 AND user_id = $2 AND is_deleted = FALSE
""",
file_id, user_id,
)
if not file_record: if not file_record:
return False, [] return False, []
# 获取文件的向量 ID 列表(在删除 chunk 之前获取)
vector_ids = await KnowledgeBaseFileService.get_file_vector_ids(conn, file_id) vector_ids = await KnowledgeBaseFileService.get_file_vector_ids(conn, file_id)
# 删除文件的所有文档块(物理删除)
deleted_chunks = await KnowledgeBaseFileService.delete_file_chunks(conn, file_id) deleted_chunks = await KnowledgeBaseFileService.delete_file_chunks(conn, file_id)
# 执行软删除文件记录 if bypass_owner_check:
result = await conn.execute( result = await conn.execute(
""" """
UPDATE knowledge_base_file UPDATE knowledge_base_file
SET is_deleted = TRUE, deleted_at = CURRENT_TIMESTAMP SET is_deleted = TRUE, deleted_at = CURRENT_TIMESTAMP
WHERE id = $1 AND user_id = $2 AND is_deleted = FALSE WHERE id = $1 AND is_deleted = FALSE
""", """,
file_id, user_id file_id,
) )
else:
result = await conn.execute(
"""
UPDATE knowledge_base_file
SET is_deleted = TRUE, deleted_at = CURRENT_TIMESTAMP
WHERE id = $1 AND user_id = $2 AND is_deleted = FALSE
""",
file_id, user_id,
)
if result == "UPDATE 1": if result == "UPDATE 1":
logger.info( logger.info(
f"删除文件 ID: {file_id}, 文件名: {file_record['file_name']}, " f"删除文件 ID: {file_id}, 文件名: {file_record['file_name']}, "
@ -551,7 +550,7 @@ class KnowledgeBaseFileService:
) )
return True, vector_ids return True, vector_ids
return False, [] return False, []
except Exception as e: except Exception as e:
logger.error(f"删除文件失败: {e}") logger.error(f"删除文件失败: {e}")
return False, [] return False, []

View File

@ -12,9 +12,29 @@
<i class="bi bi-book"></i> <i class="bi bi-book"></i>
<span>知识图谱</span> <span>知识图谱</span>
</router-link> </router-link>
<router-link
v-if="isLeaderOrAdmin"
to="/team"
class="nav-menu-item"
active-class="router-link-active"
>
<i class="bi bi-people"></i>
<span>团队管理</span>
</router-link>
</div> </div>
</template> </template>
<script setup>
import { computed } from 'vue'
import { useAuthStore } from '../stores/auth'
const authStore = useAuthStore()
const isLeaderOrAdmin = computed(() => {
const role = authStore.user?.role
return role === 'leader' || role === 'admin'
})
</script>
<style scoped> <style scoped>
.nav-menu-section { .nav-menu-section {
border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important; border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important;

View File

@ -35,6 +35,12 @@ const routes = [
{ path: '/star-graph', redirect: '/knowledge-graph' }, { path: '/star-graph', redirect: '/knowledge-graph' },
{ path: '/relation-graph', redirect: '/knowledge-graph' }, { path: '/relation-graph', redirect: '/knowledge-graph' },
{ path: '/novel-kg', redirect: '/knowledge-graph' }, { path: '/novel-kg', redirect: '/knowledge-graph' },
{
path: '/team',
name: 'TeamManagement',
component: () => import('../views/TeamManagement.vue'),
meta: { requiresAuth: true }
},
{ {
path: '/auth/github/callback', path: '/auth/github/callback',
name: 'GithubCallback', name: 'GithubCallback',

View File

@ -178,8 +178,14 @@
<i class="bi bi-files"></i> 文件管理 <i class="bi bi-files"></i> 文件管理
</h5> </h5>
<!-- 上传被禁用提示 -->
<div v-if="!canUpload" class="alert alert-warning py-2 small mb-3">
<i class="bi bi-lock-fill me-1"></i>
您的知识库文件上传权限已被关闭如需开启请联系部门领导或管理员
</div>
<!-- 上传方式切换 --> <!-- 上传方式切换 -->
<div class="upload-mode-tabs"> <div class="upload-mode-tabs" v-if="canUpload">
<button <button
class="upload-mode-tab" class="upload-mode-tab"
:class="{ active: uploadMode === 'file' }" :class="{ active: uploadMode === 'file' }"
@ -199,7 +205,7 @@
</div> </div>
<!-- 文件上传 --> <!-- 文件上传 -->
<div v-if="uploadMode === 'file'" class="upload-section"> <div v-if="uploadMode === 'file' && canUpload" class="upload-section">
<label class="upload-btn"> <label class="upload-btn">
<i class="bi bi-upload"></i> 上传文件 <i class="bi bi-upload"></i> 上传文件
<input <input
@ -214,7 +220,7 @@
</div> </div>
<!-- URL 上传 --> <!-- URL 上传 -->
<div v-if="uploadMode === 'url'" class="upload-section"> <div v-if="uploadMode === 'url' && canUpload" class="upload-section">
<label class="upload-label"> <label class="upload-label">
<i class="bi bi-link-45deg"></i> 输入网页 URL <i class="bi bi-link-45deg"></i> 输入网页 URL
</label> </label>
@ -263,6 +269,7 @@
<div class="file-table-cell">大小</div> <div class="file-table-cell">大小</div>
<div class="file-table-cell">状态</div> <div class="file-table-cell">状态</div>
<div class="file-table-cell">文档块</div> <div class="file-table-cell">文档块</div>
<div class="file-table-cell">上传者</div>
<div class="file-table-cell">上传时间</div> <div class="file-table-cell">上传时间</div>
<div class="file-table-cell">操作</div> <div class="file-table-cell">操作</div>
</div> </div>
@ -322,12 +329,18 @@
</span> </span>
</div> </div>
<div class="file-table-cell">{{ file.chunk_count }}</div> <div class="file-table-cell">{{ file.chunk_count }}</div>
<div class="file-table-cell">
<span class="small" :class="file.is_mine ? 'text-muted' : 'text-info'">
{{ file.is_mine ? '我' : (file.uploader_name || '他人') }}
</span>
</div>
<div class="file-table-cell">{{ formatDate(file.created_at) }}</div> <div class="file-table-cell">{{ formatDate(file.created_at) }}</div>
<div class="file-table-cell"> <div class="file-table-cell">
<button <button
@click="confirmDeleteFile(file.id)" v-if="file.is_mine || canDeleteOthers"
@click="confirmDeleteFile(file.id, file.file_name, file.is_mine)"
class="file-delete-btn" class="file-delete-btn"
title="删除" :title="file.is_mine ? '删除' : '以领导身份删除下属文件'"
> >
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
@ -864,6 +877,19 @@ const canCreateTask = computed(() => {
processingForm.value.selectedFiles.length > 0 processingForm.value.selectedFiles.length > 0
}) })
// 403
const canUpload = computed(() => {
const u = authStore.user
if (!u) return false
return u.allow_kb_upload !== false
})
// admin
const canDeleteOthers = computed(() => {
const u = authStore.user
return u && (u.role === 'leader' || u.role === 'admin')
})
// //
function formatDate(dateString) { function formatDate(dateString) {
if (!dateString) return '' if (!dateString) return ''
@ -1239,11 +1265,12 @@ async function loadFiles(page = 1) {
} }
// //
function confirmDeleteFile(fileId) { function confirmDeleteFile(fileId, fileName, isMine) {
const file = files.value.find(f => f.id === fileId) const name = fileName || (files.value.find(f => f.id === fileId)?.file_name) || '该文件'
if (!file) return const tip = isMine
? `确定要删除文件"${name}"吗?此操作不可恢复。`
if (confirm(`确定要删除文件"${file.file_name}"吗?此操作不可恢复。`)) { : `您正在以领导身份删除下属上传的文件"${name}",确认继续?`
if (confirm(tip)) {
deleteFile(fileId) deleteFile(fileId)
} }
} }

View File

@ -0,0 +1,363 @@
<template>
<AppSidebarShell>
<!-- 侧边栏内容审计日志快速入口 -->
<div class="p-3">
<p class="text-white-50 small mb-2">团队管理</p>
<div
class="nav-sub-item"
:class="{ active: activeTab === 'members' }"
@click="activeTab = 'members'"
>
<i class="bi bi-person-lines-fill"></i> 成员列表
</div>
<div
class="nav-sub-item"
:class="{ active: activeTab === 'logs' }"
@click="activeTab = 'logs'"
>
<i class="bi bi-clipboard-data"></i> 操作日志
</div>
</div>
</AppSidebarShell>
<!-- 主内容区 -->
<div class="team-main flex-grow-1 p-4">
<!-- 成员列表 Tab -->
<template v-if="activeTab === 'members'">
<div class="d-flex align-items-center justify-content-between mb-3">
<h4 class="mb-0">
<i class="bi bi-people me-2"></i>我管辖的成员
</h4>
</div>
<div v-if="loadingMembers" class="text-muted">加载中</div>
<div v-else>
<div class="table-responsive">
<table class="table table-hover align-middle table-sm bg-white rounded shadow-sm">
<thead class="table-light">
<tr>
<th>姓名</th>
<th>用户名</th>
<th>部门</th>
<th>角色</th>
<th>状态</th>
<th title="知识库上传权限">上传权限</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="m in members" :key="m.id">
<td class="fw-semibold">{{ m.display_name || m.username }}</td>
<td class="text-muted small">{{ m.username }}</td>
<td>{{ m.department_name || '—' }}</td>
<td>
<span class="badge" :class="roleBadge(m.role)">{{ m.role }}</span>
</td>
<td>
<span :class="m.is_active ? 'text-success' : 'text-danger'">
{{ m.is_active ? '正常' : '停用' }}
</span>
</td>
<td>
<span :class="m.allow_kb_upload ? 'text-success' : 'text-danger'">
{{ m.allow_kb_upload ? '✓ 允许' : '✗ 禁止' }}
</span>
</td>
<td>
<button
class="btn btn-sm"
:class="m.allow_kb_upload ? 'btn-outline-warning' : 'btn-outline-success'"
@click="toggleUpload(m)"
:disabled="saving === m.id"
>
{{ m.allow_kb_upload ? '关闭上传' : '开启上传' }}
</button>
</td>
</tr>
</tbody>
</table>
<p v-if="!members.length" class="text-muted small">暂无下属成员</p>
</div>
<!-- 分页 -->
<nav v-if="memberTotal > memberPageSize" class="mt-2">
<ul class="pagination pagination-sm">
<li class="page-item" :class="{ disabled: memberPage <= 1 }">
<a class="page-link" href="#" @click.prevent="memberPage > 1 && memberPage-- && loadMembers()">上一页</a>
</li>
<li class="page-item disabled">
<span class="page-link">{{ memberPage }} / {{ memberTotalPages }} {{ memberTotal }} </span>
</li>
<li class="page-item" :class="{ disabled: memberPage >= memberTotalPages }">
<a class="page-link" href="#" @click.prevent="memberPage < memberTotalPages && memberPage++ && loadMembers()">下一页</a>
</li>
</ul>
</nav>
</div>
</template>
<!-- 操作日志 Tab -->
<template v-if="activeTab === 'logs'">
<div class="d-flex align-items-center justify-content-between mb-3">
<h4 class="mb-0">
<i class="bi bi-clipboard-data me-2"></i>部门操作日志
</h4>
</div>
<!-- 筛选 -->
<div class="card card-body mb-3 shadow-sm p-3">
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label small mb-0">操作类型</label>
<select v-model="logFilter.action" class="form-select form-select-sm">
<option value="">全部</option>
<option value="upload">upload</option>
<option value="download">download</option>
<option value="delete">delete</option>
<option value="permission_change">permission_change</option>
</select>
</div>
<div class="col-auto d-flex gap-2 align-items-end">
<button class="btn btn-sm btn-primary" @click="applyLogFilter">搜索</button>
<button class="btn btn-sm btn-outline-secondary" @click="resetLogFilter">重置</button>
</div>
</div>
</div>
<div v-if="loadingLogs" class="text-muted">加载中</div>
<div v-else>
<div class="table-responsive">
<table class="table table-sm table-hover bg-white rounded shadow-sm align-middle small">
<thead class="table-light">
<tr>
<th>时间</th>
<th>操作者</th>
<th>操作</th>
<th>被操作用户</th>
<th>知识库</th>
<th>文件</th>
<th>详情</th>
</tr>
</thead>
<tbody>
<tr v-for="r in logs" :key="r.id">
<td class="text-nowrap text-muted">{{ fmt(r.created_at) }}</td>
<td class="fw-semibold">{{ r.actor_name || r.actor_id }}</td>
<td>
<span class="badge" :class="actionBadge(r.action)">{{ r.action }}</span>
</td>
<td>{{ r.target_name || (r.target_user_id ? '#' + r.target_user_id : '—') }}</td>
<td>{{ r.kb_name || (r.kb_id ? '#' + r.kb_id : '—') }}</td>
<td class="text-truncate" style="max-width:120px" :title="r.file_name">
{{ r.file_name || '—' }}
</td>
<td class="text-muted">{{ metaStr(r.metadata) }}</td>
</tr>
</tbody>
</table>
<p v-if="!logs.length" class="text-muted small">暂无操作日志</p>
</div>
<nav v-if="logTotal > logPageSize" class="mt-2">
<ul class="pagination pagination-sm">
<li class="page-item" :class="{ disabled: logPage <= 1 }">
<a class="page-link" href="#" @click.prevent="logPage > 1 && logPage-- && loadLogs()">上一页</a>
</li>
<li class="page-item disabled">
<span class="page-link">{{ logPage }} / {{ logTotalPages }} {{ logTotal }} </span>
</li>
<li class="page-item" :class="{ disabled: logPage >= logTotalPages }">
<a class="page-link" href="#" @click.prevent="logPage < logTotalPages && logPage++ && loadLogs()">下一页</a>
</li>
</ul>
</nav>
</div>
</template>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import AppSidebarShell from '../components/AppSidebarShell.vue'
import { useAuthStore } from '../stores/auth'
import { apiUrl } from '../utils/apiUrl'
const authStore = useAuthStore()
const router = useRouter()
// leader/admin
if (!authStore.user || !['leader', 'admin'].includes(authStore.user.role)) {
router.replace('/chat')
}
const activeTab = ref('members')
//
const members = ref([])
const memberTotal = ref(0)
const memberPage = ref(1)
const memberPageSize = 20
const loadingMembers = ref(false)
const saving = ref(null)
const memberTotalPages = computed(() => Math.max(1, Math.ceil(memberTotal.value / memberPageSize)))
function authHeaders() {
return { Authorization: `Bearer ${authStore.token}` }
}
async function loadMembers() {
loadingMembers.value = true
try {
const { data } = await axios.get(
apiUrl(`/api/team/members?page=${memberPage.value}&page_size=${memberPageSize}`),
{ headers: authHeaders() },
)
const raw = data?.data
members.value = raw?.items || []
memberTotal.value = raw?.total || 0
} catch (e) {
console.error(e)
} finally {
loadingMembers.value = false
}
}
async function toggleUpload(m) {
const next = !m.allow_kb_upload
if (!confirm(`确定要${next ? '开启' : '关闭'}${m.display_name || m.username}」的知识库上传权限吗?`)) return
saving.value = m.id
try {
await axios.patch(
apiUrl(`/api/team/members/${m.id}/permissions`),
{ allow_kb_upload: next },
{ headers: authHeaders() },
)
m.allow_kb_upload = next
} catch (e) {
alert(e.response?.data?.detail || '操作失败')
} finally {
saving.value = null
}
}
//
const logs = ref([])
const logTotal = ref(0)
const logPage = ref(1)
const logPageSize = 30
const loadingLogs = ref(false)
const logFilter = reactive({ action: '' })
const appliedLogFilter = reactive({ action: '' })
const logTotalPages = computed(() => Math.max(1, Math.ceil(logTotal.value / logPageSize)))
async function loadLogs() {
loadingLogs.value = true
try {
const qs = new URLSearchParams({ page: logPage.value, page_size: logPageSize })
if (appliedLogFilter.action) qs.set('action', appliedLogFilter.action)
const { data } = await axios.get(
apiUrl(`/api/team/audit-logs?${qs}`),
{ headers: authHeaders() },
)
const raw = data?.data
logs.value = raw?.items || []
logTotal.value = raw?.total || 0
} catch (e) {
console.error(e)
} finally {
loadingLogs.value = false
}
}
function applyLogFilter() {
Object.assign(appliedLogFilter, logFilter)
logPage.value = 1
loadLogs()
}
function resetLogFilter() {
logFilter.action = ''
appliedLogFilter.action = ''
logPage.value = 1
loadLogs()
}
//
function roleBadge(role) {
return { admin: 'bg-danger', leader: 'bg-primary', employee: 'bg-secondary' }[role] || 'bg-secondary'
}
function actionBadge(action) {
const m = {
upload: 'bg-success',
download: 'bg-info text-dark',
delete: 'bg-danger',
permission_change: 'bg-warning text-dark',
create_kb: 'bg-primary',
}
return m[action] || 'bg-secondary'
}
function fmt(t) {
if (!t) return '—'
try { return new Date(t).toLocaleString('zh-CN') } catch { return t }
}
function metaStr(meta) {
if (!meta) return ''
try {
const obj = typeof meta === 'string' ? JSON.parse(meta) : meta
return Object.entries(obj).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(' ')
} catch {
return String(meta)
}
}
watch(activeTab, (tab) => {
if (tab === 'members' && !members.value.length) loadMembers()
if (tab === 'logs' && !logs.value.length) loadLogs()
})
onMounted(() => {
loadMembers()
})
</script>
<style scoped>
.team-main {
background: #f5f7fa;
min-height: 100vh;
overflow-y: auto;
}
.nav-sub-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.75rem;
border-radius: 8px;
cursor: pointer;
color: rgba(255, 255, 255, 0.7);
font-size: 0.9rem;
margin-bottom: 0.2rem;
transition: background 0.15s;
}
.nav-sub-item:hover,
.nav-sub-item.active {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.nav-sub-item.active {
background: rgba(74, 158, 255, 0.18);
color: #4a9eff;
}
.nav-sub-item i {
font-size: 0.95rem;
}
</style>

675
sql.txt Normal file
View File

@ -0,0 +1,675 @@
create table public.chat_messages
(
id serial
primary key,
thread_id varchar(255) not null,
checkpoint_id varchar(255) not null,
message_index integer not null,
role varchar(20) not null,
content text not null,
injected_content text,
has_files boolean default false,
metadata jsonb,
created_at timestamp with time zone default CURRENT_TIMESTAMP,
name varchar(255),
constraint uk_checkpoint_message
unique (checkpoint_id, message_index)
);
comment on table public.chat_messages is '聊天消息表存储用户原始消息和AI响应的关键信息';
comment on column public.chat_messages.thread_id is '会话线程ID';
comment on column public.chat_messages.checkpoint_id is '关联的checkpoint ID';
comment on column public.chat_messages.message_index is '消息在checkpoint中的索引从0开始';
comment on column public.chat_messages.role is '消息角色user、assistant、system、tool';
comment on column public.chat_messages.content is '用户的原始问题或AI的响应';
comment on column public.chat_messages.injected_content is '注入给AI的完整内容包含文件内容';
comment on column public.chat_messages.has_files is '是否关联了文件';
comment on column public.chat_messages.metadata is '额外信息token、模型、推理内容等';
comment on column public.chat_messages.name is '工具消息时的工具名称(如 internet_search、text_to_image';
alter table public.chat_messages
owner to zuoleiroot;
create index idx_chat_messages_thread_id
on public.chat_messages (thread_id);
create index idx_chat_messages_checkpoint_id
on public.chat_messages (checkpoint_id);
create index idx_chat_messages_thread_created
on public.chat_messages (thread_id asc, created_at desc);
create index idx_chat_messages_role
on public.chat_messages (role);
create index idx_chat_messages_has_files
on public.chat_messages (has_files);
create index idx_chat_messages_metadata
on public.chat_messages using gin (metadata);
create index idx_chat_messages_content_search
on public.chat_messages using gin (to_tsvector('simple'::regconfig, content));
create table public.checkpoint_migrations
(
v integer not null
primary key
);
alter table public.checkpoint_migrations
owner to zuoleiroot;
create table public.checkpoints
(
thread_id text not null,
checkpoint_ns text default ''::text not null,
checkpoint_id text not null,
parent_checkpoint_id text,
type text,
checkpoint jsonb not null,
metadata jsonb default '{}'::jsonb not null,
primary key (thread_id, checkpoint_ns, checkpoint_id)
);
alter table public.checkpoints
owner to zuoleiroot;
create index checkpoints_thread_id_idx
on public.checkpoints (thread_id);
create table public.checkpoint_blobs
(
thread_id text not null,
checkpoint_ns text default ''::text not null,
channel text not null,
version text not null,
type text not null,
blob bytea,
primary key (thread_id, checkpoint_ns, channel, version)
);
alter table public.checkpoint_blobs
owner to zuoleiroot;
create index checkpoint_blobs_thread_id_idx
on public.checkpoint_blobs (thread_id);
create table public.checkpoint_writes
(
thread_id text not null,
checkpoint_ns text default ''::text not null,
checkpoint_id text not null,
task_id text not null,
idx integer not null,
channel text not null,
type text,
blob bytea not null,
task_path text default ''::text not null,
primary key (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
);
alter table public.checkpoint_writes
owner to zuoleiroot;
create index checkpoint_writes_thread_id_idx
on public.checkpoint_writes (thread_id);
create table public.enterprise
(
id serial
primary key,
name varchar(255) not null,
code varchar(64)
unique,
created_at timestamp with time zone default CURRENT_TIMESTAMP not null,
updated_at timestamp with time zone default CURRENT_TIMESTAMP not null
);
comment on table public.enterprise is '企业(单租户部署通常仅一条记录)';
alter table public.enterprise
owner to zuoleiroot;
create table public.department
(
id serial
primary key,
enterprise_id integer not null
references public.enterprise
on delete cascade,
name varchar(255) not null,
parent_id integer
references public.department
on delete set null,
created_at timestamp with time zone default CURRENT_TIMESTAMP not null,
updated_at timestamp with time zone default CURRENT_TIMESTAMP not null,
constraint uq_department_enterprise_name
unique (enterprise_id, name)
);
comment on table public.department is '部门';
alter table public.department
owner to zuoleiroot;
create table public.user_list
(
id serial
primary key,
username varchar(50) not null
unique,
email varchar(255) not null
constraint unique_email
unique,
phone varchar(255) not null
unique,
github_id varchar(100)
constraint unique_github_id
unique,
github_username varchar(100),
github_avatar_url text,
github_access_token text,
github_token_expires_at timestamp with time zone,
display_name varchar(100),
avatar_url text,
bio text,
is_active boolean default true,
email_verified boolean default false,
created_at timestamp with time zone default CURRENT_TIMESTAMP,
updated_at timestamp with time zone default CURRENT_TIMESTAMP,
last_login_at timestamp with time zone,
hashed_password varchar(255),
is_search boolean default false,
is_reasoner boolean default false,
enterprise_id integer default 1 not null
references public.enterprise,
department_id integer
references public.department
on delete set null,
role varchar(32) default 'employee'::character varying not null,
is_first_login boolean default true not null
);
comment on column public.user_list.role is 'admin | leader | employee';
comment on column public.user_list.is_first_login is '首次登录可强制改密(可选业务)';
alter table public.user_list
owner to zuoleiroot;
create index idx_user_list_github_id
on public.user_list (github_id);
create index idx_user_list_email
on public.user_list (email);
create index idx_user_list_username
on public.user_list (username);
create index idx_user_list_created_at
on public.user_list (created_at);
create index idx_user_list_enterprise_id
on public.user_list (enterprise_id);
create index idx_user_list_department_id
on public.user_list (department_id);
create index idx_user_list_role
on public.user_list (role);
create table public.knowledge_base
(
id serial
primary key,
user_id integer not null,
name varchar(255) not null,
description text,
created_at timestamp with time zone default CURRENT_TIMESTAMP,
updated_at timestamp with time zone default CURRENT_TIMESTAMP,
is_deleted boolean default false,
deleted_at timestamp with time zone,
enterprise_id integer default 1 not null
references public.enterprise,
department_id integer
references public.department
on delete set null,
creator_id integer not null
references public.user_list
on delete set null,
visibility varchar(32) default 'private'::character varying not null
constraint ck_knowledge_base_visibility
check ((visibility)::text = ANY
((ARRAY ['private'::character varying, 'department'::character varying, 'enterprise'::character varying])::text[]))
);
comment on column public.knowledge_base.creator_id is '创建者(与 user_id 通常一致,用于权限判断)';
comment on column public.knowledge_base.visibility is 'private | department | enterprise';
alter table public.knowledge_base
owner to zuoleiroot;
create index idx_knowledge_base_user_id
on public.knowledge_base (user_id);
create index idx_knowledge_base_user_name
on public.knowledge_base (user_id, name);
create index idx_knowledge_base_created_at
on public.knowledge_base (created_at);
create index idx_knowledge_base_is_deleted
on public.knowledge_base (is_deleted);
create index idx_knowledge_base_user_deleted
on public.knowledge_base (user_id, is_deleted);
create unique index uk_user_knowledge_base_name_active
on public.knowledge_base (user_id, name)
where (is_deleted = false);
create index idx_knowledge_base_enterprise
on public.knowledge_base (enterprise_id);
create index idx_knowledge_base_creator
on public.knowledge_base (creator_id);
create index idx_knowledge_base_ent_vis
on public.knowledge_base (enterprise_id, visibility)
where (is_deleted = false);
create table public.knowledge_base_file
(
id serial
primary key,
knowledge_base_id integer not null
constraint fk_knowledge_base
references public.knowledge_base
on delete cascade,
user_id integer not null,
file_name varchar(255) not null,
file_path varchar(500) not null,
file_size bigint not null,
file_type varchar(50) default 'pdf'::character varying not null,
status varchar(20) default 'processing'::character varying not null,
chunk_count integer default 0,
created_at timestamp with time zone default CURRENT_TIMESTAMP,
updated_at timestamp with time zone default CURRENT_TIMESTAMP,
is_deleted boolean default false,
deleted_at timestamp with time zone
);
alter table public.knowledge_base_file
owner to zuoleiroot;
create index idx_kb_file_kb_id
on public.knowledge_base_file (knowledge_base_id);
create index idx_kb_file_user_id
on public.knowledge_base_file (user_id);
create index idx_kb_file_status
on public.knowledge_base_file (status);
create index idx_kb_file_created_at
on public.knowledge_base_file (created_at);
create unique index idx_kb_file_unique_active
on public.knowledge_base_file (knowledge_base_id, file_name)
where (is_deleted = false);
create table public.knowledge_base_chunk
(
id serial
primary key,
file_id integer not null
constraint fk_kb_file
references public.knowledge_base_file
on delete cascade,
knowledge_base_id integer not null
constraint fk_kb
references public.knowledge_base
on delete cascade,
chunk_index integer not null,
content text not null,
metadata jsonb,
vector_id varchar(255),
created_at timestamp with time zone default CURRENT_TIMESTAMP,
summary text
);
alter table public.knowledge_base_chunk
owner to zuoleiroot;
create index idx_kb_chunk_file_id
on public.knowledge_base_chunk (file_id);
create index idx_kb_chunk_kb_id
on public.knowledge_base_chunk (knowledge_base_id);
create index idx_kb_chunk_vector_id
on public.knowledge_base_chunk (vector_id);
create index idx_kb_chunk_metadata
on public.knowledge_base_chunk using gin (metadata);
create table public.chat_thread_file
(
id serial
primary key,
thread_id varchar(255) not null,
user_id integer not null
constraint fk_chat_thread_file_user
references public.user_list
on delete cascade,
file_name varchar(255) not null,
file_path varchar(500) not null,
file_size integer default 0,
file_type varchar(50) default 'pdf'::character varying,
status varchar(20) default 'processing'::character varying,
chunk_count integer default 0,
created_at timestamp with time zone default CURRENT_TIMESTAMP,
updated_at timestamp with time zone default CURRENT_TIMESTAMP,
is_deleted boolean default false,
deleted_at timestamp with time zone
);
alter table public.chat_thread_file
owner to zuoleiroot;
create index idx_chat_thread_file_thread_id
on public.chat_thread_file (thread_id);
create index idx_chat_thread_file_user_id
on public.chat_thread_file (user_id);
create index idx_chat_thread_file_thread_user
on public.chat_thread_file (thread_id, user_id);
create index idx_chat_thread_file_status
on public.chat_thread_file (status);
create index idx_chat_thread_file_is_deleted
on public.chat_thread_file (is_deleted);
create index idx_chat_thread_file_created_at
on public.chat_thread_file (created_at);
create index idx_chat_thread_file_thread_deleted
on public.chat_thread_file (thread_id, is_deleted);
create unique index uk_chat_thread_file_thread_name_active
on public.chat_thread_file (thread_id, file_name)
where (is_deleted = false);
create table public.chat_thread_chunk
(
id serial
primary key,
file_id integer not null
constraint fk_chat_thread_chunk_file
references public.chat_thread_file
on delete cascade,
thread_id varchar(255) not null,
chunk_index integer not null,
content text not null,
metadata jsonb,
vector_id varchar(255),
created_at timestamp with time zone default CURRENT_TIMESTAMP,
summary text
);
alter table public.chat_thread_chunk
owner to zuoleiroot;
create index idx_chat_thread_chunk_file_id
on public.chat_thread_chunk (file_id);
create index idx_chat_thread_chunk_thread_id
on public.chat_thread_chunk (thread_id);
create index idx_chat_thread_chunk_file_thread
on public.chat_thread_chunk (file_id, thread_id);
create index idx_chat_thread_chunk_vector_id
on public.chat_thread_chunk (vector_id);
create index idx_chat_thread_chunk_created_at
on public.chat_thread_chunk (created_at);
create table public.chat_message_file
(
id serial
primary key,
thread_id varchar(255) not null,
checkpoint_id varchar(255) not null,
message_index integer not null,
file_id integer not null
constraint fk_chat_message_file_file
references public.chat_thread_file
on delete cascade,
created_at timestamp with time zone default CURRENT_TIMESTAMP,
constraint uk_message_file
unique (checkpoint_id, message_index, file_id)
);
alter table public.chat_message_file
owner to zuoleiroot;
create index idx_chat_message_file_thread_id
on public.chat_message_file (thread_id);
create index idx_chat_message_file_checkpoint
on public.chat_message_file (checkpoint_id, message_index);
create index idx_chat_message_file_file_id
on public.chat_message_file (file_id);
create index idx_chat_message_file_thread_checkpoint
on public.chat_message_file (thread_id, checkpoint_id);
create table public.graphs
(
id serial
primary key,
user_id integer not null
constraint fk_graphs_user
references public.user_list
on delete cascade,
name varchar(255) not null,
description text,
csv_file_name varchar(255),
node_count integer default 0,
edge_count integer default 0,
neo4j_graph_id varchar(100) not null
unique,
graph_type varchar(20) default 'knowledge'::character varying not null,
build_status varchar(20),
build_error text,
rag_chunk_count integer default 0 not null,
created_at timestamp with time zone default CURRENT_TIMESTAMP,
updated_at timestamp with time zone default CURRENT_TIMESTAMP,
enterprise_id integer not null
references public.enterprise,
department_id integer
references public.department
on delete set null,
creator_id integer not null
references public.user_list
on delete set null,
visibility varchar(32) default 'private'::character varying not null
constraint ck_graphs_visibility
check ((visibility)::text = ANY
((ARRAY ['private'::character varying, 'department'::character varying, 'enterprise'::character varying])::text[]))
);
comment on table public.graphs is '知识图谱元数据表,图数据在 Neo4j向量块数量见 rag_chunk_count';
comment on column public.graphs.neo4j_graph_id is 'Neo4j 中图谱唯一标识';
comment on column public.graphs.graph_type is '兼容字段,默认 knowledge';
comment on column public.graphs.build_status is '构建状态pending/processing/completed/failed';
comment on column public.graphs.build_error is '构建失败时的错误信息';
comment on column public.graphs.rag_chunk_count is 'Chroma 中知识图谱 RAG 分块数量';
comment on column public.graphs.creator_id is '创建者(与 user_id 通常一致,用于权限判断)';
comment on column public.graphs.visibility is 'private | department | enterprise';
alter table public.graphs
owner to zuoleiroot;
create index idx_graphs_user_id
on public.graphs (user_id);
create index idx_graphs_created_at
on public.graphs (created_at desc);
create index idx_graphs_neo4j_id
on public.graphs (neo4j_graph_id);
create index idx_graphs_graph_type
on public.graphs (user_id, graph_type);
create index idx_graphs_enterprise
on public.graphs (enterprise_id);
create index idx_graphs_creator
on public.graphs (creator_id);
create index idx_graphs_ent_vis
on public.graphs (enterprise_id, visibility);
create table public.chat_threads
(
id serial
primary key,
thread_id varchar(255) not null
constraint uk_thread_id
unique,
user_id integer not null
constraint fk_user_id
references public.user_list
on delete cascade,
title varchar(50) not null,
first_query text not null,
created_at timestamp with time zone default CURRENT_TIMESTAMP,
updated_at timestamp with time zone default CURRENT_TIMESTAMP,
message_count integer default 1,
is_deleted boolean default false,
knowledge_base_id integer,
novel_graph_id integer,
knowledge_graph_id integer
constraint fk_chat_threads_knowledge_graph
references public.graphs
on delete set null,
ip varchar(128)
);
comment on table public.chat_threads is '聊天会话记录表,记录每个用户的会话基本信息';
comment on column public.chat_threads.id is '主键 ID';
comment on column public.chat_threads.thread_id is '会话线程 IDUUID 格式)';
comment on column public.chat_threads.user_id is '用户 ID关联 user_list 表';
comment on column public.chat_threads.title is '会话标题首次请求内容的前10个字';
comment on column public.chat_threads.first_query is '首次请求的完整内容';
comment on column public.chat_threads.created_at is '会话创建时间';
comment on column public.chat_threads.updated_at is '最后更新时间';
comment on column public.chat_threads.message_count is '该会话的消息总数';
comment on column public.chat_threads.is_deleted is '是否已删除(软删除标记)';
comment on column public.chat_threads.knowledge_graph_id is '绑定的知识图谱 graphs.id与 knowledge_base_id 二选一';
comment on column public.chat_threads.ip is '最近一次发起聊天时的客户端 IP可选';
alter table public.chat_threads
owner to zuoleiroot;
create index idx_chat_threads_user_id
on public.chat_threads (user_id);
create index idx_chat_threads_created_at
on public.chat_threads (created_at desc);
create index idx_chat_threads_user_created
on public.chat_threads (user_id asc, created_at desc);
create index idx_chat_threads_novel_graph_id
on public.chat_threads (novel_graph_id);
create index idx_chat_threads_knowledge_graph_id
on public.chat_threads (knowledge_graph_id);
create table public.knowledge_processing_task
(
id serial
primary key,
user_id integer not null
constraint fk_kb_processing_user
references public.user_list
on delete cascade,
knowledge_base_id integer not null
constraint fk_kb_processing_kb
references public.knowledge_base
on delete cascade,
task_name varchar(255) not null,
instruction text not null,
file_ids integer[] not null,
task_type varchar(50) not null,
status varchar(20) default 'pending'::character varying,
result text,
result_file_url text,
error_message text,
created_at timestamp with time zone default CURRENT_TIMESTAMP,
updated_at timestamp with time zone default CURRENT_TIMESTAMP,
started_at timestamp with time zone,
completed_at timestamp with time zone
);
comment on table public.knowledge_processing_task is '知识加工任务表:合并、对比、总结等异步任务';
comment on column public.knowledge_processing_task.result_file_url is '加工结果文件的 OSS 下载链接';
alter table public.knowledge_processing_task
owner to zuoleiroot;
create index idx_kb_processing_user_id
on public.knowledge_processing_task (user_id);
create index idx_kb_processing_kb_id
on public.knowledge_processing_task (knowledge_base_id);
create index idx_kb_processing_status
on public.knowledge_processing_task (status);
create index idx_kb_processing_created_at
on public.knowledge_processing_task (created_at desc);
create index idx_kb_processing_user_status
on public.knowledge_processing_task (user_id, status);
create index idx_department_enterprise_id
on public.department (enterprise_id);

73
update-ddl.txt Normal file
View File

@ -0,0 +1,73 @@
-- ================================================================
-- 权限方案 A 升级 DDL在已有表结构基础上执行
-- 执行顺序:按文件从上到下顺序执行
-- ================================================================
-- ------------------------------------------------------------
-- 1. department新增部门负责人字段
-- ------------------------------------------------------------
ALTER TABLE public.department
ADD COLUMN IF NOT EXISTS leader_user_id INTEGER
REFERENCES public.user_list(id) ON DELETE SET NULL;
COMMENT ON COLUMN public.department.leader_user_id IS '部门负责人 user_id对应 role=leader 的用户';
CREATE INDEX IF NOT EXISTS idx_department_leader_user_id
ON public.department (leader_user_id);
-- ------------------------------------------------------------
-- 2. user_list新增「是否允许上传文件到知识库」开关
-- ------------------------------------------------------------
ALTER TABLE public.user_list
ADD COLUMN IF NOT EXISTS allow_kb_upload BOOLEAN NOT NULL DEFAULT TRUE;
COMMENT ON COLUMN public.user_list.allow_kb_upload IS '是否允许上传文件到知识库(上级领导或 admin 可关闭)';
-- ------------------------------------------------------------
-- 3. kb_audit_log知识库操作审计日志表新建
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.kb_audit_log
(
id SERIAL PRIMARY KEY,
enterprise_id INTEGER NOT NULL
REFERENCES public.enterprise ON DELETE CASCADE,
actor_id INTEGER NOT NULL
REFERENCES public.user_list (id) ON DELETE CASCADE,
target_user_id INTEGER
REFERENCES public.user_list (id) ON DELETE SET NULL,
department_id INTEGER
REFERENCES public.department (id) ON DELETE SET NULL,
kb_id INTEGER
REFERENCES public.knowledge_base (id) ON DELETE SET NULL,
file_id INTEGER
REFERENCES public.knowledge_base_file (id) ON DELETE SET NULL,
action VARCHAR(50) NOT NULL,
-- upload | download | delete | archive | create_kb | delete_kb | permission_change
ip VARCHAR(128),
user_agent TEXT,
metadata JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
);
COMMENT ON TABLE public.kb_audit_log IS '知识库操作审计日志';
COMMENT ON COLUMN public.kb_audit_log.action IS
'upload | download | delete | archive | create_kb | delete_kb | permission_change';
CREATE INDEX IF NOT EXISTS idx_kb_audit_log_enterprise_id
ON public.kb_audit_log (enterprise_id);
CREATE INDEX IF NOT EXISTS idx_kb_audit_log_actor_id
ON public.kb_audit_log (actor_id);
CREATE INDEX IF NOT EXISTS idx_kb_audit_log_target_user_id
ON public.kb_audit_log (target_user_id);
CREATE INDEX IF NOT EXISTS idx_kb_audit_log_department_id
ON public.kb_audit_log (department_id);
CREATE INDEX IF NOT EXISTS idx_kb_audit_log_kb_id
ON public.kb_audit_log (kb_id);
CREATE INDEX IF NOT EXISTS idx_kb_audit_log_action
ON public.kb_audit_log (action);
CREATE INDEX IF NOT EXISTS idx_kb_audit_log_created_at
ON public.kb_audit_log (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_kb_audit_log_ent_dept_created
ON public.kb_audit_log (enterprise_id, department_id, created_at DESC);
ALTER TABLE public.kb_audit_log OWNER TO zuoleiroot;

277
权限.html Normal file
View File

@ -0,0 +1,277 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>知识库权限说明</title>
<style>
:root {
--bg: #f6f8fa;
--card: #ffffff;
--border: #e1e4e8;
--hdr: #24292f;
--text: #333;
--muted: #6e7781;
--blue: #0969da;
--green: #1a7f37;
--orange: #bc4c00;
--red: #cf222e;
--purple: #8250df;
--tag-bg: #ddf4ff;
--tag-text: #0969da;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg); color: var(--text); padding: 32px 24px; line-height: 1.6; }
h1 { font-size: 22px; color: var(--hdr); margin-bottom: 6px; }
.subtitle { color: var(--muted); font-size: 13px; margin-bottom: 32px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 20px; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 20px; }
.card h2 { font-size: 14px; font-weight: 600; color: var(--hdr); margin-bottom: 14px;
display: flex; align-items: center; gap: 6px; }
.card h2 .icon { width: 20px; height: 20px; border-radius: 5px; display: inline-flex;
align-items: center; justify-content: center; font-size: 12px; }
.icon-kb { background: #ddf4ff; color: var(--blue); }
.icon-file { background: #dafbe1; color: var(--green); }
.icon-role { background: #fff8c5; color: #9a6700; }
.icon-dept { background: #ffefe7; color: var(--orange); }
.icon-perm { background: #fbefff; color: var(--purple); }
.icon-audit{ background: #ffe4e4; color: var(--red); }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { background: var(--bg); color: var(--muted); font-weight: 600;
text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border); }
td { padding: 7px 10px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }
tr:last-child td { border-bottom: none; }
.badge { display: inline-block; padding: 1px 7px; border-radius: 20px; font-size: 11px; font-weight: 500; }
.b-blue { background: #ddf4ff; color: var(--blue); }
.b-green { background: #dafbe1; color: var(--green); }
.b-orange { background: #ffefe7; color: var(--orange); }
.b-red { background: #ffe4e4; color: var(--red); }
.b-purple { background: #fbefff; color: var(--purple); }
.b-gray { background: #f0f0f0; color: #555; }
.b-yellow { background: #fff8c5; color: #9a6700; }
.check { color: var(--green); font-weight: 700; }
.cross { color: var(--red); }
.note { font-size: 12px; color: var(--muted); margin-top: 10px; padding-top: 8px;
border-top: 1px dashed var(--border); }
.full { grid-column: 1 / -1; }
/* 矩阵表 */
.matrix th:not(:first-child), .matrix td:not(:first-child) { text-align: center; }
</style>
</head>
<body>
<h1>🔐 知识库权限说明</h1>
<p class="subtitle">方案 A — 基于角色 + 部门层级 + 可见性的多维权限体系</p>
<div class="grid">
<!-- 1. 角色定义 -->
<div class="card">
<h2><span class="icon icon-role">👤</span> 角色定义</h2>
<table>
<tr><th>角色</th><th>标识</th><th>说明</th></tr>
<tr><td>超级管理员</td><td><span class="badge b-red">admin</span></td><td>企业内全部权限,不受部门限制</td></tr>
<tr><td>部门领导</td><td><span class="badge b-blue">leader</span></td><td>管辖本部门及所有子孙部门</td></tr>
<tr><td>普通员工</td><td><span class="badge b-gray">employee</span></td><td>仅操作自己的资源</td></tr>
</table>
</div>
<!-- 2. 部门管辖范围 -->
<div class="card">
<h2><span class="icon icon-dept">🏢</span> 部门管辖范围</h2>
<table>
<tr><th>角色</th><th>可管辖的部门</th></tr>
<tr><td><span class="badge b-red">admin</span></td><td>企业所有部门</td></tr>
<tr>
<td><span class="badge b-blue">leader</span></td>
<td>
所在部门 + 全部子孙部门<br/>
<span style="color:var(--muted);font-size:12px">(递归 CTE 计算)</span>
</td>
</tr>
<tr><td><span class="badge b-gray">employee</span></td><td>无管辖,仅属于一个部门</td></tr>
</table>
<p class="note">示例:技术部领导 → 管辖 技术部、前端组、后端组(全子树)</p>
</div>
<!-- 3. 知识库可见性 -->
<div class="card">
<h2><span class="icon icon-kb">📚</span> 知识库可见性</h2>
<table>
<tr><th>visibility</th><th>可见范围</th></tr>
<tr><td><span class="badge b-gray">private</span></td><td>仅创建者本人</td></tr>
<tr><td><span class="badge b-blue">department</span></td><td>同部门所有成员 + admin</td></tr>
<tr><td><span class="badge b-green">enterprise</span></td><td>企业全员 + admin</td></tr>
</table>
<p class="note">leader 额外规则:本部门内任何可见性的知识库均可查看</p>
</div>
<!-- 4. 知识库操作权限矩阵 -->
<div class="card full">
<h2><span class="icon icon-kb">📋</span> 知识库操作权限矩阵</h2>
<table class="matrix">
<tr>
<th>操作</th>
<th>admin</th>
<th>leader本部门KB</th>
<th>leader子部门KB</th>
<th>employee自己创建</th>
<th>employee他人department/enterprise KB</th>
</tr>
<tr>
<td>查看知识库</td>
<td><span class="check"></span></td>
<td><span class="check"></span></td>
<td><span class="check"></span> 若可见性匹配</td>
<td><span class="check"></span></td>
<td><span class="check"></span></td>
</tr>
<tr>
<td>编辑/删除知识库</td>
<td><span class="check"></span></td>
<td>仅自己创建的</td>
<td>仅自己创建的</td>
<td><span class="check"></span></td>
<td><span class="cross"></span></td>
</tr>
<tr>
<td>上传文件</td>
<td>需 allow_kb_upload=true</td>
<td>需 allow_kb_upload=true</td>
<td></td>
<td>需 allow_kb_upload=true</td>
<td>需 allow_kb_upload=true</td>
</tr>
<tr>
<td>查看文件列表</td>
<td><span class="check">✓ 全部文件</span></td>
<td><span class="check">✓ 全部文件</span></td>
<td></td>
<td><span class="check">✓ 全部文件</span></td>
<td><span class="check">✓ 全部文件</span></td>
</tr>
</table>
</div>
<!-- 5. 文件删除权限 -->
<div class="card">
<h2><span class="icon icon-file">🗑️</span> 文件删除权限</h2>
<table>
<tr><th>谁能删</th><th>条件</th></tr>
<tr><td>文件上传者本人</td><td>无条件</td></tr>
<tr><td><span class="badge b-red">admin</span></td><td>同企业内任意文件</td></tr>
<tr>
<td><span class="badge b-blue">leader</span></td>
<td>上传者在其管辖子树内(即是其下属)</td>
</tr>
<tr><td><span class="badge b-gray">employee</span></td><td>仅自己上传的文件</td></tr>
</table>
</div>
<!-- 6. 上传权限控制 -->
<div class="card">
<h2><span class="icon icon-perm">🔒</span> 上传权限控制</h2>
<table>
<tr><th>条件</th><th>能否上传</th></tr>
<tr><td>allow_kb_upload = true <br/>且 能查看该 KB</td><td><span class="check">✓ 允许</span></td></tr>
<tr><td>allow_kb_upload = false</td><td><span class="cross">✗ 禁止</span>(不论角色)</td></tr>
<tr><td>无权查看 KB</td><td><span class="cross">✗ 禁止</span></td></tr>
</table>
<p class="note">
<b>谁能修改 allow_kb_upload</b><br/>
admin → 任意用户 &nbsp;&nbsp; leader → 自己管辖的下属
</p>
</div>
<!-- 7. 审计日志可见范围 -->
<div class="card">
<h2><span class="icon icon-audit">📝</span> 审计日志可见范围</h2>
<table>
<tr><th>角色</th><th>可查看的日志</th></tr>
<tr><td><span class="badge b-red">admin</span></td><td>企业全员所有操作日志</td></tr>
<tr>
<td><span class="badge b-blue">leader</span></td>
<td>本部门及子孙部门成员的操作日志</td>
</tr>
<tr><td><span class="badge b-gray">employee</span></td><td>无权查看审计日志</td></tr>
</table>
<p class="note">记录动作upload / download / delete / create_kb / delete_kb / permission_change</p>
</div>
<!-- 8. 团队管理入口 -->
<div class="card">
<h2><span class="icon icon-role">⚙️</span> 管理功能入口</h2>
<table>
<tr><th>功能</th><th>入口</th><th>谁能用</th></tr>
<tr>
<td>设置部门负责人</td>
<td><span class="badge b-gray">管理后台</span></td>
<td><span class="badge b-red">admin</span></td>
</tr>
<tr>
<td>修改任意用户上传权限</td>
<td><span class="badge b-gray">管理后台</span></td>
<td><span class="badge b-red">admin</span></td>
</tr>
<tr>
<td>修改下属上传权限</td>
<td><span class="badge b-blue">前台·团队管理</span></td>
<td><span class="badge b-blue">leader</span></td>
</tr>
<tr>
<td>查看团队操作日志</td>
<td><span class="badge b-blue">前台·团队管理</span></td>
<td><span class="badge b-blue">leader</span></td>
</tr>
<tr>
<td>查看全企业操作日志</td>
<td><span class="badge b-gray">管理后台</span></td>
<td><span class="badge b-red">admin</span></td>
</tr>
</table>
</div>
<!-- 9. 越级禁止 -->
<div class="card full">
<h2><span class="icon icon-perm">🚫</span> 越级操作一律拒绝</h2>
<table>
<tr><th>场景</th><th>结果</th><th>说明</th></tr>
<tr>
<td>leader 操作非下属用户的权限</td>
<td><span class="cross">403 Forbidden</span></td>
<td>is_subordinate() 检测不在子树内</td>
</tr>
<tr>
<td>leader 删除非下属上传的文件</td>
<td><span class="cross">403 Forbidden</span></td>
<td>can_delete_file() 返回 False</td>
</tr>
<tr>
<td>employee 访问 private KB非自己创建</td>
<td><span class="cross">403 Forbidden</span></td>
<td>can_view_kb() 返回 False</td>
</tr>
<tr>
<td>allow_kb_upload=false 的用户上传文件</td>
<td><span class="cross">400 Bad Request</span></td>
<td>can_upload_to_kb() 返回 False</td>
</tr>
<tr>
<td>跨企业访问任何资源</td>
<td><span class="cross">403 Forbidden</span></td>
<td>enterprise_id 强制校验</td>
</tr>
</table>
</div>
</div>
<p style="margin-top:28px; font-size:12px; color:var(--muted);">
实现位置:<code>backend/core/permissions.py</code> &nbsp;·&nbsp;
路由层:<code>backend/api/kb_file_router.py</code> &nbsp;·&nbsp;
团队 API<code>backend/api/team_router.py</code>
</p>
</body>
</html>