From fd2da26d104cbab58edd94431b56e99c025ef0e9 Mon Sep 17 00:00:00 2001 From: Nelson <1475262689@qq.com> Date: Tue, 11 Aug 2026 17:17:59 +0800 Subject: [PATCH] feat: add admin permissions voice input analytics and feedback --- ai_knowledge_base_v2/.env.example | 2 + .../apps/admin-web/package-lock.json | 26 ++ .../apps/admin-web/package.json | 1 + .../apps/admin-web/src/App.vue | 78 ++++- .../src/components/AdminManagementView.vue | 109 +++++++ .../src/components/DashboardView.vue | 285 +++++++----------- .../src/components/FeedbackManagementView.vue | 70 +++++ .../src/components/SystemConfigView.vue | 12 + .../admin-web/src/config/systemSettings.ts | 16 + .../apps/admin-web/src/main.ts | 6 + .../apps/admin-web/src/services/api.ts | 15 + .../apps/admin-web/src/types/api.ts | 58 ++++ .../apps/backend/.env.example | 2 + .../0031_admin_permission_management.py | 30 ++ .../alembic/versions/0032_message_feedback.py | 36 +++ .../apps/backend/app/api/admin_auth.py | 10 +- .../apps/backend/app/api/admin_dashboard.py | 10 + .../apps/backend/app/api/admin_management.py | 120 ++++++++ .../apps/backend/app/api/feedback.py | 92 ++++++ .../apps/backend/app/api/router.py | 30 +- .../apps/backend/app/api/voice.py | 27 ++ .../apps/backend/app/core/config.py | 4 + .../apps/backend/app/core/dependencies.py | 43 ++- .../apps/backend/app/core/observability.py | 7 + .../apps/backend/app/models/__init__.py | 2 + .../apps/backend/app/models/admin.py | 5 +- .../apps/backend/app/models/feedback.py | 26 ++ .../apps/backend/app/schemas/admin.py | 23 ++ .../app/services/admin_permission_service.py | 41 +++ .../backend/app/services/admin_service.py | 15 +- .../app/services/request_traffic_service.py | 153 ++++++++++ .../app/services/voice_input_service.py | 184 +++++++++++ .../apps/backend/requirements.txt | 1 + .../backend/tests/test_admin_permissions.py | 24 ++ .../backend/tests/test_message_feedback.py | 35 +++ .../backend/tests/test_request_traffic.py | 21 ++ .../apps/backend/tests/test_voice_input.py | 70 +++++ .../apps/user-client/src/App.vue | 43 +++ .../src/components/ChatComposer.vue | 87 +++++- .../src/components/ChatMessage.vue | 7 +- .../src/components/MessageList.vue | 2 + .../src/composables/useVoiceRecorder.ts | 118 ++++++++ .../apps/user-client/src/services/api.ts | 28 ++ .../apps/user-client/src/styles.css | 43 ++- .../apps/user-client/src/types/api.ts | 10 + ai_knowledge_base_v2/docker-compose.dev.yml | 1 + 46 files changed, 1836 insertions(+), 192 deletions(-) create mode 100644 ai_knowledge_base_v2/apps/admin-web/src/components/AdminManagementView.vue create mode 100644 ai_knowledge_base_v2/apps/admin-web/src/components/FeedbackManagementView.vue create mode 100644 ai_knowledge_base_v2/apps/backend/alembic/versions/0031_admin_permission_management.py create mode 100644 ai_knowledge_base_v2/apps/backend/alembic/versions/0032_message_feedback.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/api/admin_management.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/api/feedback.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/api/voice.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/models/feedback.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/services/admin_permission_service.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/services/request_traffic_service.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/services/voice_input_service.py create mode 100644 ai_knowledge_base_v2/apps/backend/tests/test_admin_permissions.py create mode 100644 ai_knowledge_base_v2/apps/backend/tests/test_message_feedback.py create mode 100644 ai_knowledge_base_v2/apps/backend/tests/test_request_traffic.py create mode 100644 ai_knowledge_base_v2/apps/backend/tests/test_voice_input.py create mode 100644 ai_knowledge_base_v2/apps/user-client/src/composables/useVoiceRecorder.ts diff --git a/ai_knowledge_base_v2/.env.example b/ai_knowledge_base_v2/.env.example index d49c19e..f23dc74 100644 --- a/ai_knowledge_base_v2/.env.example +++ b/ai_knowledge_base_v2/.env.example @@ -47,3 +47,5 @@ FRP_LOCAL_IP=gateway FRP_LOCAL_PORT=80 # 自定义域名,逗号分隔,如:qa.huiyushuyuan.cn FRP_CUSTOM_DOMAINS=qa.huiyushuyuan.cn +ALIYUN_NLS_APP_KEY= +ALIYUN_NLS_ENDPOINT=https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/asr diff --git a/ai_knowledge_base_v2/apps/admin-web/package-lock.json b/ai_knowledge_base_v2/apps/admin-web/package-lock.json index d8c4093..b270867 100644 --- a/ai_knowledge_base_v2/apps/admin-web/package-lock.json +++ b/ai_knowledge_base_v2/apps/admin-web/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@vitejs/plugin-vue": "^6.0.1", + "echarts": "^6.1.0", "element-plus": "^2.11.0", "markdown-it": "^14.3.0", "vite": "^7.1.0", @@ -1155,6 +1156,16 @@ "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", "license": "MIT" }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, "node_modules/element-plus": { "version": "2.14.2", "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.2.tgz", @@ -1535,6 +1546,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1679,6 +1696,15 @@ "peerDependencies": { "typescript": ">=5.0.0" } + }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } } } } diff --git a/ai_knowledge_base_v2/apps/admin-web/package.json b/ai_knowledge_base_v2/apps/admin-web/package.json index e005f8b..94453d5 100644 --- a/ai_knowledge_base_v2/apps/admin-web/package.json +++ b/ai_knowledge_base_v2/apps/admin-web/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@vitejs/plugin-vue": "^6.0.1", + "echarts": "^6.1.0", "element-plus": "^2.11.0", "markdown-it": "^14.3.0", "vite": "^7.1.0", diff --git a/ai_knowledge_base_v2/apps/admin-web/src/App.vue b/ai_knowledge_base_v2/apps/admin-web/src/App.vue index 4c7f68b..e712ea6 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/App.vue +++ b/ai_knowledge_base_v2/apps/admin-web/src/App.vue @@ -49,10 +49,26 @@ const SsoIntegrationView = defineAsyncComponent( const SystemConfigView = defineAsyncComponent( () => import("./components/SystemConfigView.vue"), ); +const AdminManagementView = defineAsyncComponent( + () => import("./components/AdminManagementView.vue"), +); +const FeedbackManagementView = defineAsyncComponent(() => import("./components/FeedbackManagementView.vue")); const admin = ref(null); const activeMenu = ref("dashboard"); const loading = ref(false); +const passwordDialogVisible = ref(false); +const passwordSaving = ref(false); +const passwordForm = reactive({ currentPassword: "", newPassword: "", confirmPassword: "" }); + +const can = (permission: string) => Boolean(admin.value?.isSuperAdmin || admin.value?.permissions.includes(permission)); +const menuPermissions: Record = { + dashboard: "dashboard.view", users: "users.view", entitlements: "entitlements.view", + knowledge: "knowledge.view", prompt: "prompt.view", models: "models.view", + "content-generation": "content-generation.view", configs: "configs.view", sso: "sso.view", + records: "records.view", retrievals: "retrievals.view", attention: "attention.view", admins: "admins.view", + feedback: "feedback.view", +}; const entitlementPlans = ref([]); const models = ref([]); @@ -190,7 +206,8 @@ onMounted(async () => { if (!getToken()) return; try { admin.value = await api.profile(); - await loadCurrentMenu(); + passwordDialogVisible.value = admin.value.mustChangePassword; + if (!admin.value.mustChangePassword) await enterFirstAvailableMenu(); } catch { clearToken(); } @@ -202,7 +219,8 @@ async function login(credentials: { username: string; password: string }) { const result = await api.login(credentials.username, credentials.password); saveToken(result.token); admin.value = result.admin; - await loadCurrentMenu(); + passwordDialogVisible.value = result.admin.mustChangePassword; + if (!result.admin.mustChangePassword) await enterFirstAvailableMenu(); } catch (error) { ElMessage.error(error instanceof Error ? error.message : "登录失败"); } finally { @@ -220,10 +238,32 @@ async function logout() { } async function switchMenu(menu: string) { + if (!can(menuPermissions[menu])) return; activeMenu.value = menu; await loadCurrentMenu(); } +async function enterFirstAvailableMenu() { + const first = Object.keys(menuPermissions).find((menu) => can(menuPermissions[menu])); + activeMenu.value = first ?? "dashboard"; + if (first) await loadCurrentMenu(); +} + +async function changeInitialPassword() { + if (passwordForm.newPassword.length < 8) return ElMessage.warning("新密码至少 8 位"); + if (passwordForm.newPassword !== passwordForm.confirmPassword) return ElMessage.warning("两次输入的新密码不一致"); + passwordSaving.value = true; + try { + await api.changeAdminPassword({ currentPassword: passwordForm.currentPassword, newPassword: passwordForm.newPassword }); + if (admin.value) admin.value.mustChangePassword = false; + passwordDialogVisible.value = false; + Object.assign(passwordForm, { currentPassword: "", newPassword: "", confirmPassword: "" }); + ElMessage.success("密码已修改,请使用新密码登录"); + await logout(); + } catch (error) { ElMessage.error(error instanceof Error ? error.message : "密码修改失败"); } + finally { passwordSaving.value = false; } +} + async function previewKnowledge(knowledgeId: number) { previewKnowledgeId.value = knowledgeId; await switchMenu("prompt"); @@ -596,77 +636,99 @@ async function clearFeishuCache() {
@@ -1057,8 +1119,20 @@ async function clearFeishuCache() { + +
+ + + + + + + + + + diff --git a/ai_knowledge_base_v2/apps/admin-web/src/components/AdminManagementView.vue b/ai_knowledge_base_v2/apps/admin-web/src/components/AdminManagementView.vue new file mode 100644 index 0000000..699f9d3 --- /dev/null +++ b/ai_knowledge_base_v2/apps/admin-web/src/components/AdminManagementView.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/ai_knowledge_base_v2/apps/admin-web/src/components/DashboardView.vue b/ai_knowledge_base_v2/apps/admin-web/src/components/DashboardView.vue index ee36ffe..32a6efa 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/components/DashboardView.vue +++ b/ai_knowledge_base_v2/apps/admin-web/src/components/DashboardView.vue @@ -1,58 +1,99 @@ + + diff --git a/ai_knowledge_base_v2/apps/admin-web/src/components/FeedbackManagementView.vue b/ai_knowledge_base_v2/apps/admin-web/src/components/FeedbackManagementView.vue new file mode 100644 index 0000000..7da7cc3 --- /dev/null +++ b/ai_knowledge_base_v2/apps/admin-web/src/components/FeedbackManagementView.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/ai_knowledge_base_v2/apps/admin-web/src/components/SystemConfigView.vue b/ai_knowledge_base_v2/apps/admin-web/src/components/SystemConfigView.vue index a969464..c295244 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/components/SystemConfigView.vue +++ b/ai_knowledge_base_v2/apps/admin-web/src/components/SystemConfigView.vue @@ -81,6 +81,11 @@ const groupBlueprints: Record = { "chat_active_lease_seconds", ], }, + { + title: "语音输入", + description: "控制用户端麦克风入口和单次录音上限。", + keys: ["voice_input_enabled", "voice_max_duration_seconds"], + }, ], 外部服务: [ { @@ -175,6 +180,13 @@ const statusItems = computed(() => [ value: `${props.values.chat_max_active_requests || 0} 并发`, tone: "neutral", }, + { + label: "语音输入", + value: props.values.voice_input_enabled + ? `已开启 · ${props.values.voice_max_duration_seconds || 60} 秒` + : "已关闭", + tone: props.values.voice_input_enabled ? "success" : "neutral", + }, ]); function updateSetting(key: string, value: unknown) { diff --git a/ai_knowledge_base_v2/apps/admin-web/src/config/systemSettings.ts b/ai_knowledge_base_v2/apps/admin-web/src/config/systemSettings.ts index 9c1958c..fcc67c0 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/config/systemSettings.ts +++ b/ai_knowledge_base_v2/apps/admin-web/src/config/systemSettings.ts @@ -215,6 +215,22 @@ export const systemSettingSections: SystemSettingSection[] = [ max: 86400, description: "Redis 全局队列中执行名额的自动过期时间,防止 worker 异常退出后长期占用并发名额。", }, + { + key: "voice_input_enabled", + label: "启用语音输入", + type: "switch", + defaultValue: false, + description: "关闭后用户端不展示麦克风入口,后端也会拒绝语音转写请求。", + }, + { + key: "voice_max_duration_seconds", + label: "单条语音最大时长(秒)", + type: "number", + defaultValue: 60, + min: 5, + max: 60, + description: "达到设定时长后自动停止并转成文字,阿里云一句话识别上限为 60 秒。", + }, ], }, { diff --git a/ai_knowledge_base_v2/apps/admin-web/src/main.ts b/ai_knowledge_base_v2/apps/admin-web/src/main.ts index 6e18553..0cf864f 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/main.ts +++ b/ai_knowledge_base_v2/apps/admin-web/src/main.ts @@ -1,6 +1,7 @@ import { ElAlert, ElButton, + ElCard, ElCheckbox, ElCollapse, ElCollapseItem, @@ -28,11 +29,13 @@ import { ElTabs, ElTag, ElTooltip, + ElTree, vLoading, } from "element-plus"; import "element-plus/theme-chalk/base.css"; import "element-plus/theme-chalk/el-alert.css"; import "element-plus/theme-chalk/el-button.css"; +import "element-plus/theme-chalk/el-card.css"; import "element-plus/theme-chalk/el-checkbox.css"; import "element-plus/theme-chalk/el-collapse.css"; import "element-plus/theme-chalk/el-date-picker.css"; @@ -60,6 +63,7 @@ import "element-plus/theme-chalk/el-table.css"; import "element-plus/theme-chalk/el-tabs.css"; import "element-plus/theme-chalk/el-tag.css"; import "element-plus/theme-chalk/el-tooltip.css"; +import "element-plus/theme-chalk/el-tree.css"; import { createApp } from "vue"; import zhCn from "element-plus/es/locale/lang/zh-cn"; @@ -72,6 +76,7 @@ app.config.globalProperties.$ELEMENT = { locale: zhCn }; [ ElAlert, ElButton, + ElCard, ElCheckbox, ElCollapse, ElCollapseItem, @@ -99,6 +104,7 @@ app.config.globalProperties.$ELEMENT = { locale: zhCn }; ElTabs, ElTag, ElTooltip, + ElTree, ].forEach((component) => { app.use(component); }); diff --git a/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts index e279547..ec1d870 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts +++ b/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts @@ -1,5 +1,7 @@ import type { AdminProfile, + ManagedAdmin, + PermissionNode, AdminUser, AdminUserDetail, AgentDebugResult, @@ -16,6 +18,8 @@ import type { ContentGenerationHistoryItem, ContentGenerationType, DashboardStats, + PeakTrafficResult, + TrafficGrain, EntitlementPlan, EntitlementBatchRenewResult, KnowledgeItem, @@ -129,6 +133,12 @@ export const api = { }), logout: () => request("/admin/logout", { method: "POST", body: "{}" }), profile: () => request("/admin/profile"), + permissionTree: () => request("/admin/permission-tree"), + administrators: () => request("/admin/administrator/list"), + createAdministrator: (payload: Record) => request("/admin/administrator", { method: "POST", body: JSON.stringify(payload) }), + updateAdministrator: (id: number, payload: Record) => request(`/admin/administrator/${id}`, { method: "PUT", body: JSON.stringify(payload) }), + deleteAdministrator: (id: number) => request(`/admin/administrator/${id}`, { method: "DELETE" }), + changeAdminPassword: (payload: { currentPassword: string; newPassword: string }) => request("/admin/password", { method: "POST", body: JSON.stringify(payload) }), dashboard: (start?: string, end?: string) => { const params = new URLSearchParams(); if (start) params.set("start", start); @@ -136,6 +146,8 @@ export const api = { const qs = params.toString(); return request(`/admin/dashboard${qs ? "?" + qs : ""}`); }, + peakTraffic: (grain: TrafficGrain) => + request(`/admin/dashboard/traffic${queryString({ grain })}`), users: (query: { keyword?: string; planId?: number; entitlementStatus?: string; page?: number; pageSize?: number } = {}) => request>(`/admin/user/list${queryString(query)}`), userDetail: (id: number) => request(`/admin/user/${id}/detail`), userTopics: (id: number, query: { keyword?: string; page?: number; pageSize?: number } = {}) => @@ -316,6 +328,9 @@ export const api = { deleteAttention: (id: number) => request(`/admin/attention/${id}`, { method: "DELETE" }), clearFeishuCache: () => request<{ cleared: number; message: string }>("/admin/feishu/cache/clear", { method: "POST", body: "{}" }), + feedbackList: (query: { readStatus?: string; page?: number; pageSize?: number } = {}) => request>(`/feedback/admin/list${queryString(query)}`), + feedbackDetail: (id: number) => request(`/feedback/admin/${id}`), + deleteFeedback: (id: number) => request(`/feedback/admin/${id}`, { method: "DELETE" }), }; export async function streamDebugAgent( diff --git a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts index c2036f3..07e1a8e 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts +++ b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts @@ -16,6 +16,28 @@ export interface AdminProfile { username: string; name: string; status: number; + isSuperAdmin: boolean; + mustChangePassword: boolean; + permissions: string[]; +} + +export interface ManagedAdmin { + id: number; + username: string; + name: string; + status: number; + isSuperAdmin: boolean; + mustChangePassword: boolean; + permissions: string[]; + lastLoginAt?: string | null; + createdAt?: string | null; +} + +export interface PermissionNode { + code: string; + name: string; + superOnly?: boolean; + children?: PermissionNode[]; } export interface SsoClientItem { @@ -84,6 +106,25 @@ export interface DashboardStats { }>; } +export type TrafficGrain = "minute" | "hour" | "day" | "week"; + +export interface PeakTrafficRow { + period: string; + periodLabel: string; + requestCount: number; + errorCount: number; + errorRate: number; + avgResponseMs: number; +} + +export interface PeakTrafficResult { + grain: TrafficGrain; + peakPeriod?: string | null; + peakRequests: number; + totalRequests: number; + rows: PeakTrafficRow[]; +} + export interface PromptDetail { id: number | null; promptContent: string; @@ -730,3 +771,20 @@ export interface ChatRecordQuery { page?: number; pageSize?: number; } + +export interface FeedbackItem { + id: number; + userId: number; + userName: string; + userPhone: string; + messageId: number; + messageContent: string; + content: string; + isRead: boolean; + createdAt: string; +} + +export interface FeedbackDetail extends FeedbackItem { + sessionTitle: string; + messages: Array<{ id: number; role: "user" | "assistant"; content: string; createdAt: string; isTarget: boolean }>; +} diff --git a/ai_knowledge_base_v2/apps/backend/.env.example b/ai_knowledge_base_v2/apps/backend/.env.example index a6c25b2..4b13dca 100644 --- a/ai_knowledge_base_v2/apps/backend/.env.example +++ b/ai_knowledge_base_v2/apps/backend/.env.example @@ -58,3 +58,5 @@ TOPIC_SETTLEMENT_MAX_ATTEMPTS=3 BOOTSTRAP_ADMIN_USERNAME=admin BOOTSTRAP_ADMIN_PASSWORD=admin123456 BOOTSTRAP_ADMIN_NAME=系统管理员 +ALIYUN_NLS_APP_KEY= +ALIYUN_NLS_ENDPOINT=https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/asr diff --git a/ai_knowledge_base_v2/apps/backend/alembic/versions/0031_admin_permission_management.py b/ai_knowledge_base_v2/apps/backend/alembic/versions/0031_admin_permission_management.py new file mode 100644 index 0000000..830c42a --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/alembic/versions/0031_admin_permission_management.py @@ -0,0 +1,30 @@ +"""add administrator permission management + +Revision ID: 0031_admin_permissions +Revises: 0030_sso_chat_source +""" + +from alembic import op +import sqlalchemy as sa + +revision = "0031_admin_permissions" +down_revision = "0030_sso_chat_source" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # MySQL does not permit a DEFAULT on TEXT on all supported versions. + op.add_column("sys_role", sa.Column("permissions", sa.Text(), nullable=True)) + op.execute("UPDATE sys_role SET permissions = '[]' WHERE permissions IS NULL") + op.alter_column("sys_role", "permissions", existing_type=sa.Text(), nullable=False) + op.add_column("sys_admin", sa.Column("must_change_password", sa.Integer(), nullable=False, server_default="1")) + op.add_column("sys_admin", sa.Column("is_super_admin", sa.Integer(), nullable=False, server_default="0")) + # Existing installations have one bootstrap administrator. Preserve it as the immutable super administrator. + op.execute("UPDATE sys_admin SET is_super_admin = 1, must_change_password = 0 WHERE id = (SELECT id FROM (SELECT MIN(id) AS id FROM sys_admin) AS first_admin)") + + +def downgrade() -> None: + op.drop_column("sys_admin", "is_super_admin") + op.drop_column("sys_admin", "must_change_password") + op.drop_column("sys_role", "permissions") diff --git a/ai_knowledge_base_v2/apps/backend/alembic/versions/0032_message_feedback.py b/ai_knowledge_base_v2/apps/backend/alembic/versions/0032_message_feedback.py new file mode 100644 index 0000000..fdf6348 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/alembic/versions/0032_message_feedback.py @@ -0,0 +1,36 @@ +"""add message feedback management + +Revision ID: 0032_message_feedback +Revises: 0031_admin_permissions +""" +from alembic import op +import sqlalchemy as sa + +revision = "0032_message_feedback" +down_revision = "0031_admin_permissions" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "sys_message_feedback", + sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False), + sa.Column("session_id", sa.BigInteger(), sa.ForeignKey("sys_chat_session.id"), nullable=False), + sa.Column("message_id", sa.BigInteger(), sa.ForeignKey("sys_chat_message.id"), nullable=False), + sa.Column("content", sa.String(200), nullable=False), + sa.Column("is_read", sa.Integer(), nullable=False, server_default="0"), + sa.Column("read_by", sa.BigInteger(), sa.ForeignKey("sys_admin.id"), nullable=True), + sa.Column("read_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("user_id", "message_id", name="uq_message_feedback_user_message"), + ) + op.create_index("ix_message_feedback_user_id", "sys_message_feedback", ["user_id"]) + op.create_index("ix_message_feedback_session_id", "sys_message_feedback", ["session_id"]) + op.create_index("ix_message_feedback_message_id", "sys_message_feedback", ["message_id"]) + op.create_index("ix_message_feedback_read_created", "sys_message_feedback", ["is_read", "created_at"]) + + +def downgrade() -> None: + op.drop_table("sys_message_feedback") diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_auth.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_auth.py index ae69f2d..9bac395 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/admin_auth.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_auth.py @@ -7,7 +7,8 @@ from app.core.database import get_db from app.core.dependencies import get_current_admin from app.core.responses import api_success from app.models.admin import Admin -from app.schemas.admin import AdminLoginRequest, AdminLoginResponse, AdminRead +from app.schemas.admin import AdminLoginRequest, AdminLoginResponse +from app.services.admin_permission_service import permissions_for from app.services.admin_service import AdminAuthService from app.services.auth_service import AuthService from app.services.security_state_service import client_ip @@ -24,7 +25,12 @@ def login(payload: AdminLoginRequest, request: Request, db: Session = Depends(ge @router.get("/profile") def profile(current_admin: Admin = Depends(get_current_admin)) -> dict: - return api_success(AdminRead.model_validate(current_admin).model_dump()) + return api_success({ + "id": current_admin.id, "username": current_admin.username, "name": current_admin.name, + "status": current_admin.status, "isSuperAdmin": bool(current_admin.is_super_admin), + "mustChangePassword": bool(current_admin.must_change_password), + "permissions": sorted(permissions_for(current_admin)), + }) @router.post("/logout") diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_dashboard.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_dashboard.py index 6809f5e..1d0f6da 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/admin_dashboard.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_dashboard.py @@ -5,6 +5,7 @@ from datetime import datetime, timedelta from fastapi import APIRouter, Depends, Query import json from pathlib import Path +from typing import Literal from sqlalchemy import select, text from sqlalchemy.orm import Session @@ -16,6 +17,7 @@ from app.schemas.admin import DashboardStats from app.services.admin_service import AdminDashboardService from app.models.logs import StorageSnapshot from app.services.redis_client import get_sync_redis_client +from app.services.request_traffic_service import RequestTrafficService router = APIRouter() @@ -37,6 +39,14 @@ def dashboard( return api_success(DashboardStats.model_validate(stats).model_dump()) +@router.get("/dashboard/traffic") +async def peak_traffic( + grain: Literal["minute", "hour", "day", "week"] = Query(default="hour"), + current_admin: Admin = Depends(get_current_admin), +) -> dict: + return api_success(await RequestTrafficService.peak_traffic(grain)) + + @router.get("/dashboard/storage") def storage_stats( refresh: bool = Query(default=False), diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_management.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_management.py new file mode 100644 index 0000000..730eda8 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_management.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import json + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from app.core.database import get_db +from app.core.dependencies import get_current_admin +from app.core.responses import api_success +from app.core.security import hash_password, verify_password +from app.models.admin import Admin, Role +from app.schemas.admin import AdminPasswordChangeRequest, ManagedAdminCreateRequest, ManagedAdminUpdateRequest +from app.services.admin_permission_service import ALL_PERMISSION_CODES, PERMISSION_TREE, permissions_for, require_permission + +router = APIRouter() + + +def _serialize(admin: Admin) -> dict: + return { + "id": admin.id, "username": admin.username, "name": admin.name, "status": admin.status, + "isSuperAdmin": bool(admin.is_super_admin), "mustChangePassword": bool(admin.must_change_password), + "permissions": sorted(permissions_for(admin)), "lastLoginAt": admin.last_login_at, + "createdAt": admin.created_at, + } + + +def _validated_permissions(values: list[str]) -> list[str]: + unknown = set(values) - ALL_PERMISSION_CODES + if unknown: + raise HTTPException(status_code=422, detail=f"包含未知权限:{', '.join(sorted(unknown))}") + return sorted(set(values)) + + +def _require_super(admin: Admin) -> None: + if not admin.is_super_admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅超级管理员可管理管理员账号") + + +@router.get("/permission-tree") +def permission_tree(current: Admin = Depends(get_current_admin)) -> dict: + _require_super(current) + return api_success(PERMISSION_TREE) + + +@router.get("/administrator/list") +def list_administrators(db: Session = Depends(get_db), current: Admin = Depends(get_current_admin)) -> dict: + _require_super(current) + rows = db.scalars(select(Admin).options(selectinload(Admin.role)).order_by(Admin.is_super_admin.desc(), Admin.id)).all() + return api_success([_serialize(item) for item in rows]) + + +@router.post("/administrator") +def create_administrator(payload: ManagedAdminCreateRequest, db: Session = Depends(get_db), current: Admin = Depends(get_current_admin)) -> dict: + _require_super(current) + if db.scalar(select(Admin.id).where(Admin.username == payload.username.strip())): + raise HTTPException(status_code=409, detail="管理员账号已存在") + permissions = _validated_permissions(payload.permissions) + role = Role(code=f"admin_{payload.username.strip().lower()}", name=f"{payload.name.strip()}的权限", permissions=json.dumps(permissions)) + db.add(role) + db.flush() + item = Admin(username=payload.username.strip(), name=payload.name.strip(), password=hash_password(payload.initialPassword), role_id=role.id, status=payload.status, must_change_password=1, is_super_admin=0) + db.add(item) + db.commit() + db.refresh(item) + item.role = role + return api_success(_serialize(item)) + + +@router.put("/administrator/{admin_id}") +def update_administrator(admin_id: int, payload: ManagedAdminUpdateRequest, db: Session = Depends(get_db), current: Admin = Depends(get_current_admin)) -> dict: + _require_super(current) + item = db.scalar(select(Admin).options(selectinload(Admin.role)).where(Admin.id == admin_id)) + if item is None: + raise HTTPException(status_code=404, detail="管理员不存在") + if item.is_super_admin: + raise HTTPException(status_code=403, detail="超级管理员账号由环境变量管理,不可修改") + item.name, item.status = payload.name.strip(), payload.status + if item.role is None: + item.role = Role(code=f"admin_{item.username.lower()}", name=f"{item.name}的权限") + item.role.name = f"{item.name}的权限" + item.role.permissions = json.dumps(_validated_permissions(payload.permissions)) + if payload.resetPassword: + item.password = hash_password(payload.resetPassword) + item.must_change_password = 1 + db.commit() + db.refresh(item) + return api_success(_serialize(item)) + + +@router.delete("/administrator/{admin_id}") +def delete_administrator(admin_id: int, db: Session = Depends(get_db), current: Admin = Depends(get_current_admin)) -> dict: + _require_super(current) + item = db.scalar(select(Admin).options(selectinload(Admin.role)).where(Admin.id == admin_id)) + if item is None: + raise HTTPException(status_code=404, detail="管理员不存在") + if item.is_super_admin: + raise HTTPException(status_code=403, detail="超级管理员不可删除") + role = item.role + db.delete(item) + db.flush() + if role is not None: + db.delete(role) + db.commit() + return api_success() + + +@router.post("/password") +def change_password(payload: AdminPasswordChangeRequest, db: Session = Depends(get_db), current: Admin = Depends(get_current_admin)) -> dict: + if current.is_super_admin: + raise HTTPException(status_code=403, detail="超级管理员密码由环境变量管理,不可在后台修改") + if not verify_password(payload.currentPassword, current.password): + raise HTTPException(status_code=400, detail="当前密码不正确") + if payload.currentPassword == payload.newPassword: + raise HTTPException(status_code=400, detail="新密码不能与当前密码相同") + current.password = hash_password(payload.newPassword) + current.must_change_password = 0 + db.commit() + return api_success() diff --git a/ai_knowledge_base_v2/apps/backend/app/api/feedback.py b/ai_knowledge_base_v2/apps/backend/app/api/feedback.py new file mode 100644 index 0000000..1984e31 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/api/feedback.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel, Field +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.api.pagination import page_result +from app.core.database import get_db +from app.core.dependencies import get_current_admin, get_current_user +from app.core.responses import api_success +from app.models.admin import Admin +from app.models.chat import ChatMessage, ChatSession +from app.models.feedback import MessageFeedback +from app.models.user import User +from app.services.admin_service import OperationLogService +from app.services.admin_permission_service import require_permission + +router = APIRouter() + + +class FeedbackCreate(BaseModel): + messageId: int = Field(gt=0) + content: str = Field(min_length=1, max_length=200) + + +@router.post("") +def create_feedback(payload: FeedbackCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: + message = db.scalar(select(ChatMessage).where(ChatMessage.id == payload.messageId, ChatMessage.user_id == user.id)) + if message is None or message.role != "assistant" or message.message_status != "FINISHED": + raise HTTPException(status_code=404, detail="反馈的回答不存在") + content = payload.content.strip() + if not content: + raise HTTPException(status_code=400, detail="请填写反馈内容") + existing = db.scalar(select(MessageFeedback).where(MessageFeedback.user_id == user.id, MessageFeedback.message_id == message.id)) + if existing: + raise HTTPException(status_code=409, detail="这条回答已经反馈过了") + item = MessageFeedback(user_id=user.id, session_id=message.session_id, message_id=message.id, content=content) + db.add(item) + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=409, detail="这条回答已经反馈过了") from exc + db.refresh(item) + return api_success({"id": item.id}) + + +@router.get("/admin/list") +def feedback_list(readStatus: str = Query(default="all", pattern="^(all|read|unread)$"), page: int = Query(default=1, ge=1), pageSize: int = Query(default=20, ge=10, le=100), db: Session = Depends(get_db), _admin: Admin = Depends(get_current_admin)) -> dict: + require_permission(_admin, "feedback.view") + query = select(MessageFeedback, User, ChatMessage).join(User, User.id == MessageFeedback.user_id).join(ChatMessage, ChatMessage.id == MessageFeedback.message_id) + if readStatus != "all": + query = query.where(MessageFeedback.is_read == (1 if readStatus == "read" else 0)) + total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0 + rows = db.execute(query.order_by(MessageFeedback.created_at.desc()).offset((page - 1) * pageSize).limit(pageSize)).all() + return api_success(page_result([_summary(*row) for row in rows], total=total, page=page, page_size=pageSize)) + + +@router.get("/admin/{feedback_id}") +def feedback_detail(feedback_id: int, db: Session = Depends(get_db), admin: Admin = Depends(get_current_admin)) -> dict: + row = db.execute(select(MessageFeedback, User, ChatMessage, ChatSession).join(User, User.id == MessageFeedback.user_id).join(ChatMessage, ChatMessage.id == MessageFeedback.message_id).join(ChatSession, ChatSession.id == MessageFeedback.session_id).where(MessageFeedback.id == feedback_id)).first() + if row is None: + raise HTTPException(status_code=404, detail="反馈不存在") + require_permission(admin, "feedback.view") + feedback, user, target, session = row + if not feedback.is_read: + feedback.is_read = 1 + feedback.read_by = admin.id + feedback.read_at = datetime.now(UTC).replace(tzinfo=None) + db.commit() + messages = db.scalars(select(ChatMessage).where(ChatMessage.session_id == feedback.session_id, ChatMessage.id <= target.id).order_by(ChatMessage.id.asc()).limit(200)).all() + return api_success({**_summary(feedback, user, target), "sessionTitle": session.title, "messages": [{"id": m.id, "role": m.role, "content": m.content, "createdAt": m.created_at, "isTarget": m.id == target.id} for m in messages]}) + + +@router.delete("/admin/{feedback_id}") +def delete_feedback(feedback_id: int, db: Session = Depends(get_db), admin: Admin = Depends(get_current_admin)) -> dict: + require_permission(admin, "feedback.delete") + item = db.get(MessageFeedback, feedback_id) + if item is None: + raise HTTPException(status_code=404, detail="反馈不存在") + db.delete(item) + OperationLogService.write(db, admin_id=admin.id, module="feedback", action="delete", target_id=feedback_id) + db.commit() + return api_success() + + +def _summary(item: MessageFeedback, user: User, message: ChatMessage) -> dict: + return {"id": item.id, "userId": user.id, "userName": user.name, "userPhone": user.phone, "messageId": message.id, "messageContent": message.content, "content": item.content, "isRead": bool(item.is_read), "createdAt": item.created_at} diff --git a/ai_knowledge_base_v2/apps/backend/app/api/router.py b/ai_knowledge_base_v2/apps/backend/app/api/router.py index b89d879..948cb4b 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/router.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/router.py @@ -1,6 +1,7 @@ from __future__ import annotations -from fastapi import APIRouter +from fastapi import APIRouter, Depends +from app.core.dependencies import enforce_admin_access from app.api import ( admin_auth, @@ -10,15 +11,18 @@ from app.api import ( admin_entitlements, admin_knowledge, admin_knowledge_lifecycle, + admin_management, admin_records, admin_settings, admin_sso, admin_users, auth, + feedback, chat, health, integration_sso, user, + voice, ) api_router = APIRouter() @@ -26,15 +30,19 @@ api_router.include_router(health.router, tags=["health"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(integration_sso.router, prefix="/integration/sso", tags=["integration-sso"]) api_router.include_router(user.router, prefix="/user", tags=["user"]) +api_router.include_router(voice.router, prefix="/voice", tags=["voice"]) api_router.include_router(chat.router, prefix="/chat", tags=["chat"]) +api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"]) api_router.include_router(admin_auth.router, prefix="/admin", tags=["admin"]) -api_router.include_router(admin_content_generation.router, prefix="/admin", tags=["admin-content-generation"]) -api_router.include_router(admin_agent_records.router, prefix="/admin", tags=["admin-agent-records"]) -api_router.include_router(admin_dashboard.router, prefix="/admin", tags=["admin"]) -api_router.include_router(admin_entitlements.router, prefix="/admin", tags=["admin-entitlements"]) -api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"]) -api_router.include_router(admin_knowledge.router, prefix="/admin", tags=["admin"]) -api_router.include_router(admin_knowledge_lifecycle.router, prefix="/admin", tags=["admin-knowledge-lifecycle"]) -api_router.include_router(admin_settings.router, prefix="/admin", tags=["admin"]) -api_router.include_router(admin_sso.router, prefix="/admin", tags=["admin-sso"]) -api_router.include_router(admin_records.router, prefix="/admin", tags=["admin"]) +api_router.include_router(admin_management.router, prefix="/admin", tags=["admin-management"]) +guard = [Depends(enforce_admin_access)] +api_router.include_router(admin_content_generation.router, prefix="/admin", tags=["admin-content-generation"], dependencies=guard) +api_router.include_router(admin_agent_records.router, prefix="/admin", tags=["admin-agent-records"], dependencies=guard) +api_router.include_router(admin_dashboard.router, prefix="/admin", tags=["admin"], dependencies=guard) +api_router.include_router(admin_entitlements.router, prefix="/admin", tags=["admin-entitlements"], dependencies=guard) +api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"], dependencies=guard) +api_router.include_router(admin_knowledge.router, prefix="/admin", tags=["admin"], dependencies=guard) +api_router.include_router(admin_knowledge_lifecycle.router, prefix="/admin", tags=["admin-knowledge-lifecycle"], dependencies=guard) +api_router.include_router(admin_settings.router, prefix="/admin", tags=["admin"], dependencies=guard) +api_router.include_router(admin_sso.router, prefix="/admin", tags=["admin-sso"], dependencies=guard) +api_router.include_router(admin_records.router, prefix="/admin", tags=["admin"], dependencies=guard) diff --git a/ai_knowledge_base_v2/apps/backend/app/api/voice.py b/ai_knowledge_base_v2/apps/backend/app/api/voice.py new file mode 100644 index 0000000..1b6ee56 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/api/voice.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, File, UploadFile +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.dependencies import get_current_user +from app.core.responses import api_success +from app.models.user import User +from app.services.voice_input_service import MAX_UPLOAD_BYTES, VoiceInputService + +router = APIRouter() + + +@router.get("/config") +def voice_config(db: Session = Depends(get_db), _user: User = Depends(get_current_user)) -> dict: + return api_success(VoiceInputService.public_config(db)) + + +@router.post("/transcribe") +def transcribe( + audio: UploadFile = File(...), + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> dict: + content = audio.file.read(MAX_UPLOAD_BYTES + 1) + return api_success(VoiceInputService.transcribe(db, user.id, content, audio.content_type)) diff --git a/ai_knowledge_base_v2/apps/backend/app/core/config.py b/ai_knowledge_base_v2/apps/backend/app/core/config.py index 8c5f4d4..06ac7d2 100644 --- a/ai_knowledge_base_v2/apps/backend/app/core/config.py +++ b/ai_knowledge_base_v2/apps/backend/app/core/config.py @@ -48,6 +48,8 @@ class Settings(BaseSettings): aliyun_sms_template_code: str = "" aliyun_sms_template_param_key: str = "code" aliyun_sms_endpoint: str = "dysmsapi.aliyuncs.com" + aliyun_nls_app_key: str = "" + aliyun_nls_endpoint: str = "https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/asr" mock_rag_enabled: bool = False mock_model_enabled: bool = True feishu_mock_enabled: bool = False @@ -63,6 +65,8 @@ class Settings(BaseSettings): chat_max_queue_size: int = 90 chat_queue_timeout_seconds: int = 90 chat_active_lease_seconds: int = 900 + voice_input_enabled: bool = False + voice_max_duration_seconds: int = 60 periodic_report_worker_enabled: bool = True periodic_report_poll_seconds: int = 5 periodic_report_stale_minutes: int = 30 diff --git a/ai_knowledge_base_v2/apps/backend/app/core/dependencies.py b/ai_knowledge_base_v2/apps/backend/app/core/dependencies.py index bffa586..160625c 100644 --- a/ai_knowledge_base_v2/apps/backend/app/core/dependencies.py +++ b/ai_knowledge_base_v2/apps/backend/app/core/dependencies.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import UTC, datetime -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session @@ -77,3 +77,44 @@ def get_current_admin( if admin.status != 1: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="管理员已禁用") return admin + + +def enforce_admin_access( + request: Request, + admin: Admin = Depends(get_current_admin), +) -> Admin: + """Block first-login accounts and enforce permissions on every existing admin API.""" + from app.services.admin_permission_service import require_permission + + if admin.must_change_password: + raise HTTPException(status_code=status.HTTP_428_PRECONDITION_REQUIRED, detail="首次登录请先修改初始密码") + path = request.url.path.split("/admin/", 1)[-1].strip("/") + method = request.method.upper() + if path.startswith("dashboard"): + permission = "dashboard.view" + elif path.startswith("user/") and ("/entitlement" in path or path.startswith("user/entitlement")): + permission = "entitlements.edit" + elif path.startswith("entitlement/"): + permission = "entitlements.view" if method == "GET" else "entitlements.edit" + elif path.startswith("user/"): + permission = "users.delete" if method == "DELETE" else ("users.view" if method == "GET" else ("users.create" if method == "POST" and (path in {"user", "user/import", "user/import/excel"}) else "users.edit")) + elif path.startswith("knowledge"): + permission = "knowledge.delete" if method == "DELETE" else ("knowledge.view" if method == "GET" else ("knowledge.publish" if path.endswith("open-status") or path.endswith("lifecycle") else "knowledge.edit")) + elif path.startswith("prompt") or path.startswith("agent/"): + permission = "prompt.view" if method == "GET" else "prompt.edit" + elif path.startswith("model"): + permission = "models.delete" if method == "DELETE" else ("models.view" if method == "GET" else "models.edit") + elif path.startswith("content-generation"): + permission = "content-generation.view" if method == "GET" else "content-generation.edit" + elif path.startswith("config") or path.startswith("feishu/cache"): + permission = "configs.view" if method == "GET" else "configs.edit" + elif path.startswith("sso/"): + permission = "sso.view" if method == "GET" else "sso.edit" + elif path.startswith("retrieval-log"): + permission = "retrievals.view" if method == "GET" else "configs.edit" + elif path.startswith("attention"): + permission = "attention.view" if method == "GET" else "attention.edit" + else: + permission = "records.view" + require_permission(admin, permission) + return admin diff --git a/ai_knowledge_base_v2/apps/backend/app/core/observability.py b/ai_knowledge_base_v2/apps/backend/app/core/observability.py index f20d416..0521b51 100644 --- a/ai_knowledge_base_v2/apps/backend/app/core/observability.py +++ b/ai_knowledge_base_v2/apps/backend/app/core/observability.py @@ -80,6 +80,13 @@ class RequestObservabilityMiddleware: finally: if response_finished: self._log(scope, request_id, status_code, started_at, logging.INFO, "request_completed") + from app.services.request_traffic_service import RequestTrafficService + + await RequestTrafficService.record( + scope.get("path", ""), + status_code, + (perf_counter() - started_at) * 1000, + ) request_id_context.reset(token) def _log(self, scope, request_id: str, status_code: int, started_at: float, level: int, message: str, **kwargs) -> None: diff --git a/ai_knowledge_base_v2/apps/backend/app/models/__init__.py b/ai_knowledge_base_v2/apps/backend/app/models/__init__.py index 2f959bc..dd5e7ed 100644 --- a/ai_knowledge_base_v2/apps/backend/app/models/__init__.py +++ b/ai_knowledge_base_v2/apps/backend/app/models/__init__.py @@ -3,6 +3,7 @@ from app.models.ai_config import ContentGenerationConfig, ModelConfig, Prompt, S from app.models.base import Base from app.models.chat import ChatMessage, ChatSession, TopicSession from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog +from app.models.feedback import MessageFeedback from app.models.growth import GrowthProfileRevision, PeriodicReport, ShareDraft, TeacherHelpCard, TopicSummary, UserGrowthProfile from app.models.insight import QuestionInsightCleanedQuestion from app.models.knowledge import ( @@ -50,6 +51,7 @@ __all__ = [ "HumanAttentionHistory", "HumanAttentionRecord", "ModelConfig", + "MessageFeedback", "OperationLog", "PeriodicReport", "LogRetentionPolicy", diff --git a/ai_knowledge_base_v2/apps/backend/app/models/admin.py b/ai_knowledge_base_v2/apps/backend/app/models/admin.py index 579b7fc..cc46d55 100644 --- a/ai_knowledge_base_v2/apps/backend/app/models/admin.py +++ b/ai_knowledge_base_v2/apps/backend/app/models/admin.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import datetime -from sqlalchemy import BigInteger, DateTime, ForeignKey, String +from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from app.models.base import Base, TimestampMixin @@ -15,6 +15,7 @@ class Role(Base, TimestampMixin): code: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False) name: Mapped[str] = mapped_column(String(50), nullable=False) remark: Mapped[str | None] = mapped_column(String(255), nullable=True) + permissions: Mapped[str] = mapped_column(Text, nullable=False, default="[]") admins: Mapped[list["Admin"]] = relationship("Admin", back_populates="role") @@ -29,5 +30,7 @@ class Admin(Base, TimestampMixin): role_id: Mapped[int | None] = mapped_column(ForeignKey("sys_role.id"), nullable=True) status: Mapped[int] = mapped_column(default=1, nullable=False) last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + must_change_password: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + is_super_admin: Mapped[int] = mapped_column(Integer, nullable=False, default=0) role: Mapped[Role | None] = relationship("Role", back_populates="admins") diff --git a/ai_knowledge_base_v2/apps/backend/app/models/feedback.py b/ai_knowledge_base_v2/apps/backend/app/models/feedback.py new file mode 100644 index 0000000..b3c34fb --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/models/feedback.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class MessageFeedback(Base): + __tablename__ = "sys_message_feedback" + __table_args__ = ( + UniqueConstraint("user_id", "message_id", name="uq_message_feedback_user_message"), + Index("ix_message_feedback_read_created", "is_read", "created_at"), + ) + + id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), nullable=False, index=True) + session_id: Mapped[int] = mapped_column(ForeignKey("sys_chat_session.id"), nullable=False, index=True) + message_id: Mapped[int] = mapped_column(ForeignKey("sys_chat_message.id"), nullable=False, index=True) + content: Mapped[str] = mapped_column(String(200), nullable=False) + is_read: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + read_by: Mapped[int | None] = mapped_column(ForeignKey("sys_admin.id"), nullable=True) + read_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False) diff --git a/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py b/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py index 5ac48dd..d378a3a 100644 --- a/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py +++ b/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py @@ -24,6 +24,29 @@ class AdminRead(ORMModel): username: str name: str status: int + isSuperAdmin: bool = False + mustChangePassword: bool = False + permissions: list[str] = Field(default_factory=list) + + +class ManagedAdminCreateRequest(BaseModel): + username: str = Field(min_length=3, max_length=50, pattern=r"^[A-Za-z0-9_.-]+$") + name: str = Field(min_length=1, max_length=50) + initialPassword: str = Field(min_length=8, max_length=100) + status: int = Field(default=1, ge=0, le=1) + permissions: list[str] = Field(default_factory=list, max_length=100) + + +class ManagedAdminUpdateRequest(BaseModel): + name: str = Field(min_length=1, max_length=50) + status: int = Field(default=1, ge=0, le=1) + permissions: list[str] = Field(default_factory=list, max_length=100) + resetPassword: str | None = Field(default=None, min_length=8, max_length=100) + + +class AdminPasswordChangeRequest(BaseModel): + currentPassword: str = Field(min_length=1, max_length=100) + newPassword: str = Field(min_length=8, max_length=100) class CostBreakdownItem(BaseModel): diff --git a/ai_knowledge_base_v2/apps/backend/app/services/admin_permission_service.py b/ai_knowledge_base_v2/apps/backend/app/services/admin_permission_service.py new file mode 100644 index 0000000..5773275 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/admin_permission_service.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import json + +from fastapi import HTTPException, status + +from app.models.admin import Admin + + +PERMISSION_TREE = [ + {"code": "dashboard", "name": "数据看板", "children": [{"code": "dashboard.view", "name": "查看看板"}]}, + {"code": "users", "name": "用户管理", "children": [{"code": "users.view", "name": "查看用户"}, {"code": "users.create", "name": "新增/导入"}, {"code": "users.edit", "name": "编辑/权益续期"}, {"code": "users.delete", "name": "删除用户"}]}, + {"code": "entitlements", "name": "权益管理", "children": [{"code": "entitlements.view", "name": "查看权益"}, {"code": "entitlements.edit", "name": "编辑权益"}]}, + {"code": "knowledge", "name": "知识库管理", "children": [{"code": "knowledge.view", "name": "查看知识库"}, {"code": "knowledge.edit", "name": "新增/编辑/同步"}, {"code": "knowledge.publish", "name": "开放/归档"}, {"code": "knowledge.delete", "name": "删除知识库"}]}, + {"code": "prompt", "name": "Agent 管理", "children": [{"code": "prompt.view", "name": "查看 Agent"}, {"code": "prompt.edit", "name": "编辑/测试 Agent"}]}, + {"code": "models", "name": "模型管理", "children": [{"code": "models.view", "name": "查看模型"}, {"code": "models.edit", "name": "新增/编辑/测试"}, {"code": "models.delete", "name": "删除模型"}]}, + {"code": "content-generation", "name": "内容生成", "children": [{"code": "content-generation.view", "name": "查看配置"}, {"code": "content-generation.edit", "name": "编辑/测试配置"}]}, + {"code": "configs", "name": "系统配置", "children": [{"code": "configs.view", "name": "查看配置"}, {"code": "configs.edit", "name": "修改配置"}]}, + {"code": "sso", "name": "应用接入", "children": [{"code": "sso.view", "name": "查看应用"}, {"code": "sso.edit", "name": "管理应用"}]}, + {"code": "records", "name": "记录审计", "children": [{"code": "records.view", "name": "查看/导出记录"}]}, + {"code": "retrievals", "name": "检索日志", "children": [{"code": "retrievals.view", "name": "查看检索日志"}]}, + {"code": "attention", "name": "人工关注", "children": [{"code": "attention.view", "name": "查看关注项"}, {"code": "attention.edit", "name": "处理关注项"}]}, + {"code": "feedback", "name": "反馈管理", "children": [{"code": "feedback.view", "name": "查看反馈"}, {"code": "feedback.delete", "name": "删除反馈"}]}, + {"code": "admins", "name": "管理员与权限", "superOnly": True, "children": [{"code": "admins.view", "name": "查看管理员"}, {"code": "admins.edit", "name": "新增/编辑管理员"}, {"code": "admins.delete", "name": "删除管理员"}]}, +] + +ALL_PERMISSION_CODES = {child["code"] for group in PERMISSION_TREE for child in group["children"]} + + +def permissions_for(admin: Admin) -> set[str]: + if admin.is_super_admin: + return set(ALL_PERMISSION_CODES) + try: + return set(json.loads(admin.role.permissions if admin.role else "[]")) & ALL_PERMISSION_CODES + except (TypeError, ValueError, json.JSONDecodeError): + return set() + + +def require_permission(admin: Admin, permission: str) -> None: + if permission not in permissions_for(admin): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="当前管理员无此操作权限") diff --git a/ai_knowledge_base_v2/apps/backend/app/services/admin_service.py b/ai_knowledge_base_v2/apps/backend/app/services/admin_service.py index dca30a5..986ec41 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/admin_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/admin_service.py @@ -14,6 +14,7 @@ from app.models.knowledge import Knowledge from app.models.logs import AiRequestLog, OperationLog from app.models.user import User from app.services.security_state_service import SecurityStateService +from app.services.admin_permission_service import permissions_for DEVELOPMENT_ENVS = {"local", "dev", "development", "docker", "test", "testing"} @@ -66,7 +67,14 @@ class AdminAuthService: ) cls.ensure_bootstrap_admin(db) admin = db.scalar(select(Admin).where(Admin.username == username)) - if admin is None or not verify_password(password, admin.password): + password_valid = False + if admin is not None: + password_valid = ( + password == get_settings().bootstrap_admin_password + if admin.is_super_admin + else verify_password(password, admin.password) + ) + if admin is None or not password_valid: SecurityStateService.record_failure( failure_key, limit=5, @@ -92,6 +100,9 @@ class AdminAuthService: "username": admin.username, "name": admin.name, "status": admin.status, + "isSuperAdmin": bool(admin.is_super_admin), + "mustChangePassword": bool(admin.must_change_password), + "permissions": sorted(permissions_for(admin)), }, } @@ -106,6 +117,8 @@ class AdminAuthService: password=hash_password(password), name=name, status=1, + must_change_password=0, + is_super_admin=1, ) db.add(admin) db.commit() diff --git a/ai_knowledge_base_v2/apps/backend/app/services/request_traffic_service.py b/ai_knowledge_base_v2/apps/backend/app/services/request_traffic_service.py new file mode 100644 index 0000000..9ea9891 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/request_traffic_service.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +import logging +from zoneinfo import ZoneInfo + +from app.services.redis_client import get_redis_client + + +logger = logging.getLogger("app.traffic") +LOCAL_TIMEZONE = ZoneInfo("Asia/Shanghai") +GRAIN_FORMATS = { + "minute": "%Y%m%d%H%M", + "hour": "%Y%m%d%H", + "day": "%Y%m%d", + "week": "%G-W%V", +} +GRAIN_LABELS = { + "minute": "%Y-%m-%d %H:%M", + "hour": "%Y-%m-%d %H:00", + "day": "%Y-%m-%d", + "week": "%G 年第 %V 周", +} +RETENTION_SECONDS = 60 * 60 * 24 * 8 +HISTORY_DAYS = 7 +EXCLUDED_PATHS = {"/api/health", "/api/ready", "/api/admin/dashboard/traffic"} + + +class RequestTrafficService: + @staticmethod + async def record(path: str, status_code: int, duration_ms: float, now: datetime | None = None) -> None: + if not path.startswith("/api/") or path in EXCLUDED_PATHS: + return + redis = get_redis_client() + if redis is None: + return + current = (now or datetime.now(LOCAL_TIMEZONE)).astimezone(LOCAL_TIMEZONE) + try: + key = f"metrics:http:minute:{current.strftime(GRAIN_FORMATS['minute'])}" + async with redis.pipeline(transaction=False) as pipeline: + pipeline.hincrby(key, "requests", 1) + pipeline.hincrby(key, "errors", 1 if status_code >= 500 else 0) + pipeline.hincrbyfloat(key, "duration_ms", max(duration_ms, 0)) + pipeline.expire(key, RETENTION_SECONDS) + await pipeline.execute() + except Exception: + logger.warning("request_traffic_record_failed", exc_info=True) + + @staticmethod + async def peak_traffic(grain: str) -> dict: + if grain not in GRAIN_FORMATS: + raise ValueError("不支持的时间粒度") + redis = get_redis_client() + empty = {"grain": grain, "peakPeriod": None, "peakRequests": 0, "totalRequests": 0, "rows": []} + if redis is None: + return empty + try: + keys = [key async for key in redis.scan_iter(match="metrics:http:minute:*", count=500)] + if not keys: + return empty + cutoff = datetime.now(LOCAL_TIMEZONE) - timedelta(days=HISTORY_DAYS) + prefix = "metrics:http:minute:" + parsed_keys = [] + expired_keys = [] + for key in keys: + try: + minute = datetime.strptime(key.removeprefix(prefix), GRAIN_FORMATS["minute"]).replace(tzinfo=LOCAL_TIMEZONE) + except ValueError: + continue + if minute >= cutoff: + parsed_keys.append((key, minute)) + else: + expired_keys.append(key) + parsed_keys.sort(key=lambda item: item[1]) + async with redis.pipeline(transaction=False) as retention_pipeline: + for key in expired_keys: + retention_pipeline.unlink(key) + for key, minute in parsed_keys: + retention_pipeline.expireat(key, int((minute + timedelta(seconds=RETENTION_SECONDS)).timestamp())) + await retention_pipeline.execute() + if not parsed_keys: + return empty + async with redis.pipeline(transaction=False) as pipeline: + for key, _minute in parsed_keys: + pipeline.hgetall(key) + values = await pipeline.execute() + except Exception: + logger.warning("request_traffic_query_failed", exc_info=True) + return empty + + buckets: dict[str, dict[str, float]] = {} + for (_key, minute), value in zip(parsed_keys, values, strict=False): + request_count = int(float(value.get("requests", 0))) + if request_count <= 0: + continue + error_count = int(float(value.get("errors", 0))) + duration_ms = float(value.get("duration_ms", 0)) + period = minute.strftime(GRAIN_FORMATS[grain]) + bucket = buckets.setdefault(period, {"requests": 0, "errors": 0, "duration_ms": 0}) + bucket["requests"] += request_count + bucket["errors"] += error_count + bucket["duration_ms"] += duration_ms + + rows = [] + for period in _period_sequence(grain, cutoff, datetime.now(LOCAL_TIMEZONE)): + bucket = buckets.get(period, {"requests": 0, "errors": 0, "duration_ms": 0}) + request_count = int(bucket["requests"]) + error_count = int(bucket["errors"]) + rows.append( + { + "period": period, + "periodLabel": _format_period(grain, period), + "requestCount": request_count, + "errorCount": error_count, + "errorRate": round(error_count / request_count * 100, 2) if request_count else 0, + "avgResponseMs": round(bucket["duration_ms"] / request_count, 2) if request_count else 0, + } + ) + peak = max(rows, key=lambda item: item["requestCount"], default=None) + return { + "grain": grain, + "peakPeriod": peak["periodLabel"] if peak else None, + "peakRequests": peak["requestCount"] if peak else 0, + "totalRequests": sum(row["requestCount"] for row in rows), + "rows": rows, + } + + +def _format_period(grain: str, value: str) -> str: + if grain == "week": + year, week = value.split("-W", 1) + return f"{year} 年第 {week} 周" + parsed = datetime.strptime(value, GRAIN_FORMATS[grain]) + return parsed.strftime(GRAIN_LABELS[grain]) + + +def _period_sequence(grain: str, start: datetime, end: datetime) -> list[str]: + if grain == "minute": + current, step = start.replace(second=0, microsecond=0), timedelta(minutes=1) + elif grain == "hour": + current, step = start.replace(minute=0, second=0, microsecond=0), timedelta(hours=1) + elif grain == "day": + current, step = start.replace(hour=0, minute=0, second=0, microsecond=0), timedelta(days=1) + elif grain == "week": + current = start.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=start.weekday()) + step = timedelta(days=7) + else: + raise ValueError("不支持的时间粒度") + periods = [] + while current <= end: + periods.append(current.strftime(GRAIN_FORMATS[grain])) + current += step + return periods diff --git a/ai_knowledge_base_v2/apps/backend/app/services/voice_input_service.py b/ai_knowledge_base_v2/apps/backend/app/services/voice_input_service.py new file mode 100644 index 0000000..15bf0ac --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/voice_input_service.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import json +import io +import logging +import threading +import time +import wave +from dataclasses import dataclass + +import httpx +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.models.ai_config import SystemConfig +from app.services.secret_service import SecretService +from app.services.security_state_service import SecurityStateService + +logger = logging.getLogger(__name__) +MAX_UPLOAD_BYTES = 8 * 1024 * 1024 +ALIYUN_MAX_AUDIO_BYTES = 2 * 1024 * 1024 +_token_cache: dict[str, tuple[str, float]] = {} +_token_lock = threading.Lock() + + +@dataclass(frozen=True) +class VoiceInputConfig: + enabled: bool + max_duration_seconds: int + app_key: str + access_key_id: str + access_key_secret: str + endpoint: str + + +class VoiceInputService: + @staticmethod + def public_config(db: Session) -> dict: + config = load_voice_config(db) + return {"enabled": config.enabled, "maxDurationSeconds": config.max_duration_seconds} + + @staticmethod + def transcribe(db: Session, user_id: int, audio: bytes, content_type: str | None) -> dict: + config = load_voice_config(db) + if not config.enabled: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="语音输入功能未开启") + if not audio: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="录音内容为空") + if len(audio) > MAX_UPLOAD_BYTES: + raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="录音文件过大") + SecurityStateService.enforce_limit( + f"voice:transcribe:{user_id}", + limit=10, + window_seconds=60, + message="语音识别请求过于频繁,请稍后再试", + ) + missing = [name for name, value in (("AppKey", config.app_key), ("AccessKey ID", config.access_key_id), ("AccessKey Secret", config.access_key_secret)) if not value] + if missing: + raise HTTPException(status_code=503, detail=f"语音识别配置不完整:{', '.join(missing)}") + + wav = _normalize_audio(audio, content_type) + duration = _wav_duration(wav) + if duration < 0.2: + raise HTTPException(status_code=400, detail="录音时间太短,请重新录制") + if duration > config.max_duration_seconds + 0.5 or duration > 60.5: + raise HTTPException(status_code=400, detail=f"单条语音不能超过 {config.max_duration_seconds} 秒") + if len(wav) > ALIYUN_MAX_AUDIO_BYTES: + raise HTTPException(status_code=413, detail="转码后的录音文件过大") + + token = _create_aliyun_token(config) + params = { + "appkey": config.app_key, + "format": "wav", + "sample_rate": 16000, + "enable_punctuation_prediction": "true", + "enable_inverse_text_normalization": "true", + "enable_voice_detection": "true", + } + try: + response = httpx.post( + config.endpoint, + params=params, + headers={"X-NLS-Token": token, "Content-Type": "application/octet-stream"}, + content=wav, + timeout=httpx.Timeout(20.0, connect=5.0), + ) + payload = response.json() + except (httpx.HTTPError, ValueError) as exc: + logger.warning("Aliyun NLS request failed: %s", exc.__class__.__name__) + raise HTTPException(status_code=502, detail="语音识别服务暂不可用,请稍后重试") from exc + if response.status_code != 200 or int(payload.get("status", 0)) != 20000000: + logger.warning("Aliyun NLS rejected request: status=%s code=%s", response.status_code, payload.get("status")) + raise HTTPException(status_code=502, detail=_safe_provider_message(payload)) + text = str(payload.get("result") or "").strip() + if not text: + raise HTTPException(status_code=422, detail="没有识别到有效语音,请重新录制") + return {"text": text, "durationSeconds": round(duration, 1)} + + +def load_voice_config(db: Session) -> VoiceInputConfig: + settings = get_settings() + rows = db.scalars(select(SystemConfig)).all() + values = {row.config_key: row.config_value for row in rows} + enabled = _bool(values.get("voice_input_enabled"), settings.voice_input_enabled) + duration = _int(values.get("voice_max_duration_seconds"), settings.voice_max_duration_seconds, 5, 60) + access_key_id = str(values.get("aliyun_sms_access_key_id") or settings.aliyun_sms_access_key_id).strip() + encrypted_secret = str(values.get("aliyun_sms_access_key_secret") or settings.aliyun_sms_access_key_secret).strip() + return VoiceInputConfig( + enabled=enabled, + max_duration_seconds=duration, + app_key=settings.aliyun_nls_app_key.strip(), + access_key_id=access_key_id, + access_key_secret=SecretService.decrypt(encrypted_secret), + endpoint=settings.aliyun_nls_endpoint.strip(), + ) + + +def _normalize_audio(audio: bytes, content_type: str | None) -> bytes: + if audio[:4] != b"RIFF" or audio[8:12] != b"WAVE": + raise HTTPException(status_code=422, detail="当前录音格式无法处理,请更换浏览器后重试") + return audio + + +def _wav_duration(audio: bytes) -> float: + try: + with wave.open(io.BytesIO(audio), "rb") as wav_file: + if wav_file.getnchannels() != 1 or wav_file.getsampwidth() != 2 or wav_file.getframerate() != 16000: + raise HTTPException(status_code=422, detail="录音参数不正确,请重新录制") + return wav_file.getnframes() / float(wav_file.getframerate()) + except wave.Error as exc: + raise HTTPException(status_code=422, detail="录音文件已损坏,请重新录制") from exc + + +def _create_aliyun_token(config: VoiceInputConfig) -> str: + cached = _token_cache.get(config.access_key_id) + if cached and cached[1] - 300 > time.time(): + return cached[0] + try: + from aliyunsdkcore.client import AcsClient + from aliyunsdkcore.request import CommonRequest + + client = AcsClient(config.access_key_id, config.access_key_secret, "cn-shanghai") + request = CommonRequest() + request.set_method("POST") + request.set_domain("nls-meta.cn-shanghai.aliyuncs.com") + request.set_version("2019-02-28") + request.set_action_name("CreateToken") + with _token_lock: + cached = _token_cache.get(config.access_key_id) + if cached and cached[1] - 300 > time.time(): + return cached[0] + payload = json.loads(client.do_action_with_exception(request)) + token = str(payload["Token"]["Id"]) + expires_at = float(payload["Token"]["ExpireTime"]) + _token_cache[config.access_key_id] = (token, expires_at) + return token + except Exception as exc: + logger.warning("Aliyun NLS token creation failed: %s", exc.__class__.__name__) + raise HTTPException(status_code=502, detail="语音识别鉴权失败,请联系管理员检查阿里云权限") from exc + + +def _safe_provider_message(payload: dict) -> str: + code = str(payload.get("status") or "") + if code in {"40070001", "40070002", "40070004"}: + return "没有识别到有效语音,请重新录制" + if code in {"40000001", "40000002", "40020503"}: + return "语音识别鉴权失败,请联系管理员检查阿里云权限" + return "语音识别失败,请稍后重试" + + +def _bool(value: str | None, default: bool) -> bool: + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def _int(value: str | None, default: int, minimum: int, maximum: int) -> int: + try: + parsed = int(value) if value is not None else default + except (TypeError, ValueError): + parsed = default + return max(minimum, min(maximum, parsed)) diff --git a/ai_knowledge_base_v2/apps/backend/requirements.txt b/ai_knowledge_base_v2/apps/backend/requirements.txt index f2ce035..e1645ba 100644 --- a/ai_knowledge_base_v2/apps/backend/requirements.txt +++ b/ai_knowledge_base_v2/apps/backend/requirements.txt @@ -13,4 +13,5 @@ redis==7.4.1 openpyxl==3.1.5 python-multipart==0.0.32 alibabacloud-dysmsapi20170525==4.6.0 +aliyun-python-sdk-core==2.16.0 Pillow==12.3.0 diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_admin_permissions.py b/ai_knowledge_base_v2/apps/backend/tests/test_admin_permissions.py new file mode 100644 index 0000000..b22babc --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/tests/test_admin_permissions.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import json + +import pytest +from fastapi import HTTPException + +from app.models.admin import Admin, Role +from app.services.admin_permission_service import ALL_PERMISSION_CODES, permissions_for, require_permission + + +def test_super_admin_has_all_permissions() -> None: + admin = Admin(id=1, username="root", password="unused", name="root", status=1, is_super_admin=1) + assert permissions_for(admin) == ALL_PERMISSION_CODES + + +def test_role_permissions_are_restricted_to_catalog() -> None: + role = Role(code="operator", name="operator", permissions=json.dumps(["users.view", "unknown.permission"])) + admin = Admin(id=2, username="operator", password="hash", name="operator", status=1, is_super_admin=0, role=role) + assert permissions_for(admin) == {"users.view"} + require_permission(admin, "users.view") + with pytest.raises(HTTPException) as exc: + require_permission(admin, "users.delete") + assert exc.value.status_code == 403 diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_message_feedback.py b/ai_knowledge_base_v2/apps/backend/tests/test_message_feedback.py new file mode 100644 index 0000000..2fff13b --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/tests/test_message_feedback.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from app.api.feedback import FeedbackCreate, create_feedback, delete_feedback, feedback_detail, feedback_list +from app.models import Base +from app.models.admin import Admin +from app.models.chat import ChatMessage, ChatSession +from app.models.user import User + + +def test_feedback_binds_target_answer_and_exposes_context() -> None: + engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool) + Base.metadata.create_all(engine) + with Session(engine) as db: + user = User(id=1, phone="13800000000", name="测试用户") + admin = Admin(id=1, username="admin", password="hash", name="管理员", is_super_admin=1, must_change_password=0) + db.add_all([user, admin]); db.flush() + session = ChatSession(id=1, user_id=user.id, title="测试会话", message_count=2) + db.add(session); db.flush() + question = ChatMessage(id=1, session_id=session.id, user_id=user.id, role="user", content="问题", message_status="FINISHED") + answer = ChatMessage(id=2, session_id=session.id, user_id=user.id, role="assistant", content="回答", message_status="FINISHED") + db.add_all([question, answer]); db.commit() + + created = create_feedback(FeedbackCreate(messageId=answer.id, content="这条回答不准确"), db=db, user=user)["data"] + listed = feedback_list(readStatus="unread", page=1, pageSize=20, db=db, _admin=admin)["data"] + assert listed["total"] == 1 + detail = feedback_detail(created["id"], db=db, admin=admin)["data"] + assert detail["messageId"] == answer.id + assert [item["content"] for item in detail["messages"]] == ["问题", "回答"] + assert detail["messages"][-1]["isTarget"] is True + delete_feedback(created["id"], db=db, admin=admin) + assert feedback_list(readStatus="all", page=1, pageSize=20, db=db, _admin=admin)["data"]["total"] == 0 diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_request_traffic.py b/ai_knowledge_base_v2/apps/backend/tests/test_request_traffic.py new file mode 100644 index 0000000..dd4d41a --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/tests/test_request_traffic.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from datetime import datetime +from zoneinfo import ZoneInfo + +from app.services.request_traffic_service import _format_period, _period_sequence + + +def test_peak_traffic_period_labels_cover_all_grains() -> None: + assert _format_period("minute", "202608111405") == "2026-08-11 14:05" + assert _format_period("hour", "2026081114") == "2026-08-11 14:00" + assert _format_period("day", "20260811") == "2026-08-11" + assert _format_period("week", "2026-W33") == "2026 年第 33 周" + + +def test_period_sequence_includes_empty_time_buckets() -> None: + timezone = ZoneInfo("Asia/Shanghai") + start = datetime(2026, 8, 11, 14, 58, tzinfo=timezone) + end = datetime(2026, 8, 11, 15, 1, tzinfo=timezone) + assert _period_sequence("minute", start, end) == ["202608111458", "202608111459", "202608111500", "202608111501"] + assert _period_sequence("hour", start, end) == ["2026081114", "2026081115"] diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_voice_input.py b/ai_knowledge_base_v2/apps/backend/tests/test_voice_input.py new file mode 100644 index 0000000..97fb4a8 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/tests/test_voice_input.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException +from sqlalchemy import create_engine +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from app.models import Base +from app.models.ai_config import SystemConfig +from app.services.voice_input_service import VoiceInputService, load_voice_config + + +def database() -> Session: + engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool) + Base.metadata.create_all(engine) + return Session(engine) + + +def settings(**overrides): + values = { + "voice_input_enabled": False, + "voice_max_duration_seconds": 60, + "aliyun_nls_app_key": "app-key", + "aliyun_nls_endpoint": "https://nls.example/asr", + "aliyun_sms_access_key_id": "access-id", + "aliyun_sms_access_key_secret": "access-secret", + } + values.update(overrides) + return SimpleNamespace(**values) + + +def test_public_config_is_hidden_by_default_and_duration_is_clamped(monkeypatch): + monkeypatch.setattr("app.services.voice_input_service.get_settings", lambda: settings()) + with database() as db: + db.add_all([ + SystemConfig(config_key="voice_input_enabled", config_value="true"), + SystemConfig(config_key="voice_max_duration_seconds", config_value="999"), + ]) + db.commit() + assert VoiceInputService.public_config(db) == {"enabled": True, "maxDurationSeconds": 60} + + +def test_transcription_endpoint_is_rejected_when_switch_is_off(monkeypatch): + monkeypatch.setattr("app.services.voice_input_service.get_settings", lambda: settings()) + with database() as db, pytest.raises(HTTPException) as exc: + VoiceInputService.transcribe(db, 1, b"audio", "audio/webm") + assert exc.value.status_code == 404 + + +def test_transcription_returns_provider_text(monkeypatch): + monkeypatch.setattr("app.services.voice_input_service.get_settings", lambda: settings(voice_input_enabled=True)) + monkeypatch.setattr("app.services.voice_input_service._normalize_audio", lambda *_: b"wav") + monkeypatch.setattr("app.services.voice_input_service._wav_duration", lambda *_: 8.4) + monkeypatch.setattr("app.services.voice_input_service._create_aliyun_token", lambda *_: "token") + monkeypatch.setattr("app.services.voice_input_service.SecurityStateService.enforce_limit", lambda *_, **__: None) + + class Response: + status_code = 200 + + @staticmethod + def json(): + return {"status": 20000000, "result": "这是转写结果。"} + + monkeypatch.setattr("app.services.voice_input_service.httpx.post", lambda *_, **__: Response()) + with database() as db: + result = VoiceInputService.transcribe(db, 1, b"audio", "audio/webm") + assert result == {"text": "这是转写结果。", "durationSeconds": 8.4} diff --git a/ai_knowledge_base_v2/apps/user-client/src/App.vue b/ai_knowledge_base_v2/apps/user-client/src/App.vue index eada973..fac9103 100644 --- a/ai_knowledge_base_v2/apps/user-client/src/App.vue +++ b/ai_knowledge_base_v2/apps/user-client/src/App.vue @@ -44,6 +44,10 @@ const statusText = ref("连接后端中"); const toastText = ref(""); const followingOutput = ref(true); const messageList = ref | null>(null); +const feedbackDialogOpen = ref(false); +const feedbackMessageId = ref(null); +const feedbackContent = ref(""); +const feedbackSubmitting = ref(false); const activeAbortController = ref(null); let toastTimer: number | null = null; let settlementPollVersion = 0; @@ -234,6 +238,13 @@ async function runGeneration(sessionId: number, message: string, assistantIndex: currentAssistant().streaming = false; currentAssistant().retryQuestion = undefined; currentAssistant().createdAt = new Date().toISOString(); + try { + const latestHistory = await api.history(sessionId); + const latestAssistant = [...latestHistory].reverse().find((item) => item.role === "assistant"); + if (latestAssistant) currentAssistant().id = String(latestAssistant.id); + } catch { + // 回答已经成功,历史记录偶发刷新失败不应将本次回答标记为失败。 + } await refreshSessionList(); await refreshProfile(); } catch (error) { @@ -252,6 +263,29 @@ async function runGeneration(sessionId: number, message: string, assistantIndex: } } +function openFeedback(messageId: string) { + const parsed = Number(messageId); + if (!Number.isInteger(parsed)) return; + feedbackMessageId.value = parsed; + feedbackContent.value = ""; + feedbackDialogOpen.value = true; +} + +async function submitFeedback() { + const content = feedbackContent.value.trim(); + if (!feedbackMessageId.value || !content || feedbackSubmitting.value) return; + feedbackSubmitting.value = true; + try { + await api.submitFeedback(feedbackMessageId.value, content); + feedbackDialogOpen.value = false; + showToast("感谢反馈,管理员会查看你提交的问题"); + } catch (error) { + showToast(error instanceof Error ? error.message : "反馈提交失败"); + } finally { + feedbackSubmitting.value = false; + } +} + function chatErrorMessage(error: unknown) { const message = error instanceof Error ? error.message : ""; if (/429|too many|请求过多|排队/i.test(message)) return "当前请求较多,AI 服务暂时繁忙。"; @@ -607,6 +641,7 @@ async function copyText(text: string) { :loading-session="loadingSession" @follow-change="followingOutput = $event" @retry="retryMessage" + @feedback="openFeedback" /> {{ toastText }} + +