修改权限逻辑
This commit is contained in:
parent
4c0474fcfe
commit
4cd86d97bd
|
|
@ -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 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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模型
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
"""
|
||||||
|
领导团队管理 API(role=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(),
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
# 注册全局异常处理器
|
# 注册全局异常处理器
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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, []
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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 '会话线程 ID(UUID 格式)';
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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 → 任意用户 | 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> ·
|
||||||
|
路由层:<code>backend/api/kb_file_router.py</code> ·
|
||||||
|
团队 API:<code>backend/api/team_router.py</code>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue