From 364e49b3df9cf5b3f70a4db3971090cbb7ccb1b2 Mon Sep 17 00:00:00 2001 From: chenhao Date: Mon, 9 Mar 2026 16:10:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84AI=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E5=99=A8=E5=92=8C=E6=9C=8D=E5=8A=A1=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81ASR=E5=92=8CLLM=E6=A8=A1=E5=9E=8B=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新 `AiModelController` 中的保存、更新和删除方法,增加模型类型校验 - 重构 `AiModelServiceImpl`,分离ASR和LLM模型处理逻辑 - 更新数据库表结构,新增 `biz_asr_models` 和 `biz_llm_models` 表 - 添加前端API接口,支持按类型删除AI模型 --- backend/design/db_schema.md | 551 +++++++++--------- backend/design/db_schema_pgsql.sql | 61 +- .../imeeting/config/MybatisPlusConfig.java | 2 +- .../controller/biz/AiModelController.java | 42 +- .../controller/biz/MeetingController.java | 22 + .../java/com/imeeting/dto/biz/MeetingVO.java | 2 + .../biz/{AiModel.java => AsrModel.java} | 13 +- .../com/imeeting/entity/biz/LlmModel.java | 38 ++ .../entity/biz/PromptTemplateUserConfig.java | 22 + .../listener/MeetingTaskRecoveryListener.java | 91 +++ ...AiModelMapper.java => AsrModelMapper.java} | 4 +- .../imeeting/mapper/biz/LlmModelMapper.java | 9 + .../biz/PromptTemplateUserConfigMapper.java | 10 + .../imeeting/service/biz/AiModelService.java | 6 +- .../imeeting/service/biz/MeetingService.java | 1 + .../service/biz/impl/AiModelServiceImpl.java | 436 ++++++++++---- .../service/biz/impl/AiTaskServiceImpl.java | 6 +- .../service/biz/impl/MeetingServiceImpl.java | 89 ++- frontend/src/api/business/aimodel.ts | 7 + frontend/src/api/business/meeting.ts | 8 + frontend/src/layouts/AppLayout.tsx | 61 +- frontend/src/pages/business/AiModels.tsx | 475 +++++++++------ frontend/src/pages/business/MeetingCreate.tsx | 2 +- frontend/src/pages/business/Meetings.tsx | 75 ++- 24 files changed, 1370 insertions(+), 663 deletions(-) rename backend/src/main/java/com/imeeting/entity/biz/{AiModel.java => AsrModel.java} (77%) create mode 100644 backend/src/main/java/com/imeeting/entity/biz/LlmModel.java create mode 100644 backend/src/main/java/com/imeeting/entity/biz/PromptTemplateUserConfig.java create mode 100644 backend/src/main/java/com/imeeting/listener/MeetingTaskRecoveryListener.java rename backend/src/main/java/com/imeeting/mapper/biz/{AiModelMapper.java => AsrModelMapper.java} (58%) create mode 100644 backend/src/main/java/com/imeeting/mapper/biz/LlmModelMapper.java create mode 100644 backend/src/main/java/com/imeeting/mapper/biz/PromptTemplateUserConfigMapper.java diff --git a/backend/design/db_schema.md b/backend/design/db_schema.md index ffdfca9..5db62cb 100644 --- a/backend/design/db_schema.md +++ b/backend/design/db_schema.md @@ -1,343 +1,368 @@ -# 数据库结构文档(PostgreSQL) +# 鏁版嵁搴撶粨鏋勬枃妗o紙PostgreSQL锛? -本文档根据 `backend/design/db_schema_pgsql.sql` 生成,描述当前核心表结构、字段、约束与索引。 +鏈枃妗f牴鎹?`backend/design/db_schema_pgsql.sql` 鐢熸垚锛屾弿杩板綋鍓嶆牳蹇冭〃缁撴瀯銆佸瓧娈点€佺害鏉熶笌绱㈠紩銆? -## 0. 租户与组织 +## 0. 绉熸埛涓庣粍缁? -### 0.1 `sys_tenant`(租户表) -| 字段 | 类型 | 约束 | 说明 | +### 0.1 `sys_tenant`锛堢鎴疯〃锛? +| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | -| id | BIGSERIAL | PK | 租户ID | -| tenant_code | VARCHAR(64) | NOT NULL, UNIQUE | 租户编码 | -| tenant_name | VARCHAR(128) | NOT NULL | 租户名称 | -| status | SMALLINT | NOT NULL, DEFAULT 1 | 状态 | -| expire_time | TIMESTAMP(6) | | 过期时间 | -| contact_name | VARCHAR(64) | | 联系人 | -| contact_phone | VARCHAR(32) | | 联系电话 | -| remark | VARCHAR(255) | | 备注 | -| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 创建时间 | -| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 更新时间 | -| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 | +| id | BIGSERIAL | PK | 绉熸埛ID | +| tenant_code | VARCHAR(64) | NOT NULL, UNIQUE | 绉熸埛缂栫爜 | +| tenant_name | VARCHAR(128) | NOT NULL | 绉熸埛鍚嶇О | +| status | SMALLINT | NOT NULL, DEFAULT 1 | 鐘舵€?| +| expire_time | TIMESTAMP(6) | | 杩囨湡鏃堕棿 | +| contact_name | VARCHAR(64) | | 鑱旂郴浜?| +| contact_phone | VARCHAR(32) | | 鑱旂郴鐢佃瘽 | +| remark | VARCHAR(255) | | 澶囨敞 | +| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 鍒涘缓鏃堕棿 | +| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 鏇存柊鏃堕棿 | +| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 | -索引: -- `uk_tenant_code`:`UNIQUE (tenant_code) WHERE is_deleted = FALSE` +绱㈠紩锛? +- `uk_tenant_code`锛歚UNIQUE (tenant_code) WHERE is_deleted = FALSE` -### 0.2 `sys_org`(组织架构表) -| 字段 | 类型 | 约束 | 说明 | +### 0.2 `sys_org`锛堢粍缁囨灦鏋勮〃锛? +| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | -| id | BIGSERIAL | PK | 组织ID | -| tenant_id | BIGINT | NOT NULL | 租户ID | -| parent_id | BIGINT | | 父级组织ID | -| org_name | VARCHAR(128) | NOT NULL | 组织名称 | -| org_code | VARCHAR(64) | | 组织编码 | -| org_path | VARCHAR(512) | | 组织路径 | -| sort_order | INTEGER | DEFAULT 0 | 排序 | -| status | SMALLINT | DEFAULT 1 | 状态 | -| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 创建时间 | -| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 更新时间 | -| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 | +| id | BIGSERIAL | PK | 缁勭粐ID | +| tenant_id | BIGINT | NOT NULL | 绉熸埛ID | +| parent_id | BIGINT | | 鐖剁骇缁勭粐ID | +| org_name | VARCHAR(128) | NOT NULL | 缁勭粐鍚嶇О | +| org_code | VARCHAR(64) | | 缁勭粐缂栫爜 | +| org_path | VARCHAR(512) | | 缁勭粐璺緞 | +| sort_order | INTEGER | DEFAULT 0 | 鎺掑簭 | +| status | SMALLINT | DEFAULT 1 | 鐘舵€?| +| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 鍒涘缓鏃堕棿 | +| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 鏇存柊鏃堕棿 | +| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 | -外键: -- `fk_org_parent`:`parent_id -> sys_org(id)` -- `fk_org_tenant`:`tenant_id -> sys_tenant(id)` +澶栭敭锛? +- `fk_org_parent`锛歚parent_id -> sys_org(id)` +- `fk_org_tenant`锛歚tenant_id -> sys_tenant(id)` -索引: -- `idx_org_tenant`:`(tenant_id)` +绱㈠紩锛? +- `idx_org_tenant`锛歚(tenant_id)` -## 1. 用户与角色 +## 1. 鐢ㄦ埛涓庤鑹? -### 1.1 `sys_user`(用户表) -| 字段 | 类型 | 约束 | 说明 | +### 1.1 `sys_user`锛堢敤鎴疯〃锛? +| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | -| user_id | BIGSERIAL | PK | 用户ID | -| username | VARCHAR(50) | NOT NULL, UNIQUE | 登录名 | -| display_name | VARCHAR(50) | NOT NULL | 显示名 | -| email | VARCHAR(100) | | 邮箱 | -| phone | VARCHAR(30) | UNIQUE | 手机号 | -| password_hash | VARCHAR(255) | NOT NULL | 密码哈希 | -| status | SMALLINT | NOT NULL, DEFAULT 1 | 状态 | -| pwd_reset_required | SMALLINT | DEFAULT 1 | 首次登录是否需改密 | -| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 逻辑删除标记 | -| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 | -| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 | -| is_platform_admin | BOOLEAN | DEFAULT false | 是否平台管理员 | +| user_id | BIGSERIAL | PK | 鐢ㄦ埛ID | +| username | VARCHAR(50) | NOT NULL, UNIQUE | 鐧诲綍鍚?| +| display_name | VARCHAR(50) | NOT NULL | 鏄剧ず鍚?| +| email | VARCHAR(100) | | 閭 | +| phone | VARCHAR(30) | UNIQUE | 鎵嬫満鍙?| +| password_hash | VARCHAR(255) | NOT NULL | 瀵嗙爜鍝堝笇 | +| status | SMALLINT | NOT NULL, DEFAULT 1 | 鐘舵€?| +| pwd_reset_required | SMALLINT | DEFAULT 1 | 棣栨鐧诲綍鏄惁闇€鏀瑰瘑 | +| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 | +| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 | +| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 | +| is_platform_admin | BOOLEAN | DEFAULT false | 鏄惁骞冲彴绠$悊鍛?| -索引: -- `uk_user_username`:`UNIQUE (username) WHERE is_deleted = FALSE` +绱㈠紩锛? +- `uk_user_username`锛歚UNIQUE (username) WHERE is_deleted = FALSE` -### 1.2 `sys_role`(角色表) -| 字段 | 类型 | 约束 | 说明 | +### 1.2 `sys_role`锛堣鑹茶〃锛? +| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | -| role_id | BIGSERIAL | PK | 角色ID | -| tenant_id | BIGINT | NOT NULL | 租户ID | -| role_code | VARCHAR(50) | NOT NULL | 角色编码(租户内唯一) | -| role_name | VARCHAR(50) | NOT NULL | 角色名称 | -| status | SMALLINT | NOT NULL, DEFAULT 1 | 状态 | -| remark | TEXT | | 备注 | -| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 逻辑删除标记 | -| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 | -| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 | +| role_id | BIGSERIAL | PK | 瑙掕壊ID | +| tenant_id | BIGINT | NOT NULL | 绉熸埛ID | +| role_code | VARCHAR(50) | NOT NULL | 瑙掕壊缂栫爜锛堢鎴峰唴鍞竴锛?| +| role_name | VARCHAR(50) | NOT NULL | 瑙掕壊鍚嶇О | +| status | SMALLINT | NOT NULL, DEFAULT 1 | 鐘舵€?| +| remark | TEXT | | 澶囨敞 | +| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 | +| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 | +| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 | -索引: -- `idx_sys_role_tenant`:`(tenant_id)` -- `uk_role_code`:`UNIQUE (tenant_id, role_code) WHERE is_deleted = FALSE` +绱㈠紩锛? +- `idx_sys_role_tenant`锛歚(tenant_id)` +- `uk_role_code`锛歚UNIQUE (tenant_id, role_code) WHERE is_deleted = FALSE` -### 1.3 `sys_user_role`(用户-角色关联表,租户强约束) -| 字段 | 类型 | 约束 | 说明 | +### 1.3 `sys_user_role`锛堢敤鎴?瑙掕壊鍏宠仈琛紝绉熸埛寮虹害鏉燂級 +| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | -| id | BIGSERIAL | PK | 关联ID | -| tenant_id | BIGINT | NOT NULL | 租户ID | -| user_id | BIGINT | NOT NULL | 用户ID | -| role_id | BIGINT | NOT NULL | 角色ID | -| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 逻辑删除标记 | -| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 | -| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 | +| id | BIGSERIAL | PK | 鍏宠仈ID | +| tenant_id | BIGINT | NOT NULL | 绉熸埛ID | +| user_id | BIGINT | NOT NULL | 鐢ㄦ埛ID | +| role_id | BIGINT | NOT NULL | 瑙掕壊ID | +| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 | +| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 | +| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 | -唯一约束: +鍞竴绾︽潫锛? - `UNIQUE (tenant_id, user_id, role_id) WHERE is_deleted = 0` -### 1.4 `sys_tenant_user`(租户成员关联表) -| 字段 | 类型 | 约束 | 说明 | +### 1.4 `sys_tenant_user`锛堢鎴锋垚鍛樺叧鑱旇〃锛? +| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | -| id | BIGSERIAL | PK | 关联ID | -| user_id | BIGINT | NOT NULL | 用户ID | -| tenant_id | BIGINT | NOT NULL | 租户ID | -| org_id | BIGINT | | 组织ID | -| status | SMALLINT | DEFAULT 1 | 状态 | -| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 | -| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 | -| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 | +| id | BIGSERIAL | PK | 鍏宠仈ID | +| user_id | BIGINT | NOT NULL | 鐢ㄦ埛ID | +| tenant_id | BIGINT | NOT NULL | 绉熸埛ID | +| org_id | BIGINT | | 缁勭粐ID | +| status | SMALLINT | DEFAULT 1 | 鐘舵€?| +| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 | +| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 | +| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 | -索引: -- `uk_tenant_user`:`UNIQUE (user_id, tenant_id) WHERE is_deleted = 0` +绱㈠紩锛? +- `uk_tenant_user`锛歚UNIQUE (user_id, tenant_id) WHERE is_deleted = 0` -## 2. 权限/字典/参数(全局共享) +## 2. 鏉冮檺/瀛楀吀/鍙傛暟锛堝叏灞€鍏变韩锛? -### 2.1 `sys_permission`(权限表) -| 字段 | 类型 | 约束 | 说明 | +### 2.1 `sys_permission`锛堟潈闄愯〃锛? +| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | -| perm_id | BIGSERIAL | PK | 权限ID | -| parent_id | BIGINT | | 父级权限ID | -| name | VARCHAR(100) | NOT NULL | 权限名称 | -| code | VARCHAR(100) | NOT NULL, UNIQUE | 权限编码 | -| perm_type | VARCHAR(20) | NOT NULL | 权限类型 | -| level | INTEGER | NOT NULL | 层级 | -| path | VARCHAR(255) | | 路径 | -| component | VARCHAR(255) | | 组件 | -| icon | VARCHAR(100) | | 图标 | -| sort_order | INTEGER | NOT NULL, DEFAULT 0 | 排序 | -| is_visible | SMALLINT | NOT NULL, DEFAULT 1 | 是否可见 | -| status | SMALLINT | NOT NULL, DEFAULT 1 | 状态 | -| description | TEXT | | 描述 | -| meta | JSONB | | 扩展信息 | -| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 逻辑删除标记 | -| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 | -| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 | +| perm_id | BIGSERIAL | PK | 鏉冮檺ID | +| parent_id | BIGINT | | 鐖剁骇鏉冮檺ID | +| name | VARCHAR(100) | NOT NULL | 鏉冮檺鍚嶇О | +| code | VARCHAR(100) | NOT NULL, UNIQUE | 鏉冮檺缂栫爜 | +| perm_type | VARCHAR(20) | NOT NULL | 鏉冮檺绫诲瀷 | +| level | INTEGER | NOT NULL | 灞傜骇 | +| path | VARCHAR(255) | | 璺緞 | +| component | VARCHAR(255) | | 缁勪欢 | +| icon | VARCHAR(100) | | 鍥炬爣 | +| sort_order | INTEGER | NOT NULL, DEFAULT 0 | 鎺掑簭 | +| is_visible | SMALLINT | NOT NULL, DEFAULT 1 | 鏄惁鍙 | +| status | SMALLINT | NOT NULL, DEFAULT 1 | 鐘舵€?| +| description | TEXT | | 鎻忚堪 | +| meta | JSONB | | 鎵╁睍淇℃伅 | +| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 | +| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 | +| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 | -### 2.2 `sys_dict_type`(字典类型表) -| 字段 | 类型 | 约束 | 说明 | +### 2.2 `sys_dict_type`锛堝瓧鍏哥被鍨嬭〃锛? +| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | -| dict_type_id | BIGSERIAL | PK | 类型ID | -| type_code | VARCHAR(50) | NOT NULL, UNIQUE | 类型编码 | -| type_name | VARCHAR(50) | NOT NULL | 类型名称 | -| status | SMALLINT | DEFAULT 1 | 状态 | -| remark | TEXT | | 备注 | -| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 | -| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 更新时间 | -| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 | +| dict_type_id | BIGSERIAL | PK | 绫诲瀷ID | +| type_code | VARCHAR(50) | NOT NULL, UNIQUE | 绫诲瀷缂栫爜 | +| type_name | VARCHAR(50) | NOT NULL | 绫诲瀷鍚嶇О | +| status | SMALLINT | DEFAULT 1 | 鐘舵€?| +| remark | TEXT | | 澶囨敞 | +| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鍒涘缓鏃堕棿 | +| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鏇存柊鏃堕棿 | +| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 | -**初始化数据:** -- `sys_common_status`: 通用状态 (启用/禁用) -- `sys_permission_type`: 权限类型 (目录/菜单/按钮) -- `sys_common_visibility`: 可见性 (显示/隐藏) -- `sys_permission_level`: 权限层级 (1, 2, 3) -- `sys_log_type`: 日志类型 (LOGIN/OPERATION) -- `sys_param_type`: 参数类型 (String/Number/Boolean/JSON) -- `sys_log_status`: 操作状态 (成功/失败) +**鍒濆鍖栨暟鎹細** +- `sys_common_status`: 閫氱敤鐘舵€?(鍚敤/绂佺敤) +- `sys_permission_type`: 鏉冮檺绫诲瀷 (鐩綍/鑿滃崟/鎸夐挳) +- `sys_common_visibility`: 鍙鎬?(鏄剧ず/闅愯棌) +- `sys_permission_level`: 鏉冮檺灞傜骇 (1, 2, 3) +- `sys_log_type`: 鏃ュ織绫诲瀷 (LOGIN/OPERATION) +- `sys_param_type`: 鍙傛暟绫诲瀷 (String/Number/Boolean/JSON) +- `sys_log_status`: 鎿嶄綔鐘舵€?(鎴愬姛/澶辫触) -### 2.3 `sys_dict_item`(字典项表) -| 字段 | 类型 | 约束 | 说明 | +### 2.3 `sys_dict_item`锛堝瓧鍏搁」琛級 +| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | -| dict_item_id | BIGSERIAL | PK | 字典项ID | -| type_code | VARCHAR(50) | NOT NULL | 字典类型编码 | -| item_label | VARCHAR(100) | NOT NULL | 展示文本 | -| item_value | VARCHAR(100) | NOT NULL | 存储值 | -| sort_order | INT | DEFAULT 0 | 排序 | -| status | SMALLINT | DEFAULT 1 | 状态 | -| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 | -| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 更新时间 | -| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 | +| dict_item_id | BIGSERIAL | PK | 瀛楀吀椤笽D | +| type_code | VARCHAR(50) | NOT NULL | 瀛楀吀绫诲瀷缂栫爜 | +| item_label | VARCHAR(100) | NOT NULL | 灞曠ず鏂囨湰 | +| item_value | VARCHAR(100) | NOT NULL | 瀛樺偍鍊?| +| sort_order | INT | DEFAULT 0 | 鎺掑簭 | +| status | SMALLINT | DEFAULT 1 | 鐘舵€?| +| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鍒涘缓鏃堕棿 | +| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鏇存柊鏃堕棿 | +| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 | -索引: -- `idx_dict_item_type`:`(type_code)` -- `uk_dict_item_value`:`UNIQUE (type_code, item_value)` +绱㈠紩锛? +- `idx_dict_item_type`锛歚(type_code)` +- `uk_dict_item_value`锛歚UNIQUE (type_code, item_value)` -### 2.4 `sys_param`(系统参数表) -| 字段 | 类型 | 约束 | 说明 | +### 2.4 `sys_param`锛堢郴缁熷弬鏁拌〃锛? +| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | -| id | BIGSERIAL | PK | 参数ID | -| param_key | VARCHAR(100) | NOT NULL, UNIQUE | 参数键 | -| param_value | TEXT | NOT NULL | 参数值 | -| param_type | VARCHAR(20) | NOT NULL | 参数类型 | -| is_system | SMALLINT | DEFAULT 0 | 是否系统内置 | -| status | SMALLINT | DEFAULT 1 | 状态 | -| description | TEXT | | 描述 | -| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 | +| id | BIGSERIAL | PK | 鍙傛暟ID | +| param_key | VARCHAR(100) | NOT NULL, UNIQUE | 鍙傛暟閿?| +| param_value | TEXT | NOT NULL | 鍙傛暟鍊?| +| param_type | VARCHAR(20) | NOT NULL | 鍙傛暟绫诲瀷 | +| is_system | SMALLINT | DEFAULT 0 | 鏄惁绯荤粺鍐呯疆 | +| status | SMALLINT | DEFAULT 1 | 鐘舵€?| +| description | TEXT | | 鎻忚堪 | +| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鍒涘缓鏃堕棿 | -## 3. 日志(租户隔离) +## 3. 鏃ュ織锛堢鎴烽殧绂伙級 -### 3.1 `sys_log`(系统日志表) -| 字段 | 类型 | 约束 | 说明 | +### 3.1 `sys_log`锛堢郴缁熸棩蹇楄〃锛? +| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | -| id | BIGSERIAL | PK | 日志ID | -| tenant_id | BIGINT | NOT NULL, DEFAULT 0 | 租户ID | -| user_id | BIGINT | | 用户ID | -| username | VARCHAR(50) | | 用户名 | -| log_type | VARCHAR(20) | | 日志类型(如 LOGIN、OPERATION) | -| operation | VARCHAR(100) | NOT NULL | 操作描述 | -| method | VARCHAR(200) | | 方法 | -| params | TEXT | | 请求参数 | -| status | SMALLINT | DEFAULT 1 | 状态 | +| id | BIGSERIAL | PK | 鏃ュ織ID | +| tenant_id | BIGINT | NOT NULL, DEFAULT 0 | 绉熸埛ID | +| user_id | BIGINT | | 鐢ㄦ埛ID | +| username | VARCHAR(50) | | 鐢ㄦ埛鍚?| +| log_type | VARCHAR(20) | | 鏃ュ織绫诲瀷锛堝 LOGIN銆丱PERATION锛?| +| operation | VARCHAR(100) | NOT NULL | 鎿嶄綔鎻忚堪 | +| method | VARCHAR(200) | | 鏂规硶 | +| params | TEXT | | 璇锋眰鍙傛暟 | +| status | SMALLINT | DEFAULT 1 | 鐘舵€?| | ip | VARCHAR(50) | | IP | -| duration | BIGINT | | 耗时(ms) | -| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 | +| duration | BIGINT | | 鑰楁椂锛坢s锛?| +| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鍒涘缓鏃堕棿 | -索引: -- `idx_log_tenant_type`:`(tenant_id, log_type, created_at)` +绱㈠紩锛? +- `idx_log_tenant_type`锛歚(tenant_id, log_type, created_at)` -## 4. 平台配置 +## 4. 骞冲彴閰嶇疆 -### 4.1 `sys_platform_config`(平台管理表) -| 字段 | 类型 | 约束 | 说明 | +### 4.1 `sys_platform_config`锛堝钩鍙扮鐞嗚〃锛? +| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | -| id | BIGINT | PK | 固定为 1 | -| project_name | VARCHAR(128) | NOT NULL | 项目名称 | +| id | BIGINT | PK | 鍥哄畾涓?1 | +| project_name | VARCHAR(128) | NOT NULL | 椤圭洰鍚嶇О | | logo_url | VARCHAR(512) | | Logo URL | | icon_url | VARCHAR(512) | | Icon URL | -| login_bg_url | VARCHAR(512) | | 登录页背景 | -| icp_info | VARCHAR(128) | | 备案信息 | -| copyright_info | VARCHAR(255) | | 版权信息 | -| system_description | TEXT | | 系统描述 | -| created_at | TIMESTAMP | NOT NULL | 创建时间 | -| updated_at | TIMESTAMP | NOT NULL | 更新时间 | +| login_bg_url | VARCHAR(512) | | 鐧诲綍椤佃儗鏅?| +| icp_info | VARCHAR(128) | | 澶囨淇℃伅 | +| copyright_info | VARCHAR(255) | | 鐗堟潈淇℃伅 | +| system_description | TEXT | | 绯荤粺鎻忚堪 | +| created_at | TIMESTAMP | NOT NULL | 鍒涘缓鏃堕棿 | +| updated_at | TIMESTAMP | NOT NULL | 鏇存柊鏃堕棿 | -## 5. 业务模块 +## 5. 涓氬姟妯″潡 -### 5.1 `biz_speakers`(声纹发言人表) -| 字段 | 类型 | 约束 | 说明 | +### 5.1 `biz_speakers`锛堝0绾瑰彂瑷€浜鸿〃锛? +| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | -| id | BIGSERIAL | PK | 主键ID | -| tenant_id | BIGINT | NOT NULL | 租户ID | -| user_id | BIGINT | | 关联系统用户ID | -| name | VARCHAR(100) | NOT NULL | 发言人姓名 | -| voice_path | VARCHAR(512) | | 原始文件路径 | -| voice_ext | VARCHAR(10) | | 文件后缀 | -| voice_size | BIGINT | | 文件大小 | -| status | SMALLINT | DEFAULT 1 | 状态 (1:已保存, 2:注册中, 3:已注册) | -| embedding | VECTOR | | 声纹特征向量 | -| remark | TEXT | | 备注 | -| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 | -| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 | -| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 | +| id | BIGSERIAL | PK | 涓婚敭ID | +| tenant_id | BIGINT | NOT NULL | 绉熸埛ID | +| user_id | BIGINT | | 鍏宠仈绯荤粺鐢ㄦ埛ID | +| name | VARCHAR(100) | NOT NULL | 鍙戣█浜哄鍚?| +| voice_path | VARCHAR(512) | | 鍘熷鏂囦欢璺緞 | +| voice_ext | VARCHAR(10) | | 鏂囦欢鍚庣紑 | +| voice_size | BIGINT | | 鏂囦欢澶у皬 | +| status | SMALLINT | DEFAULT 1 | 鐘舵€?(1:宸蹭繚瀛? 2:娉ㄥ唽涓? 3:宸叉敞鍐? | +| embedding | VECTOR | | 澹扮汗鐗瑰緛鍚戦噺 | +| remark | TEXT | | 澶囨敞 | +| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 | +| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 | +| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎 | -索引: +绱㈠紩锛? - `idx_speaker_tenant`: `(tenant_id)` - `idx_speaker_user`: `(user_id) WHERE is_deleted = 0` -### 5.2 `biz_hot_words`(热词管理表) -| 字段 | 类型 | 约束 | 说明 | +### 5.2 `biz_hot_words`锛堢儹璇嶇鐞嗚〃锛? +| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | -| id | BIGSERIAL | PK | 主键ID | -| tenant_id | BIGINT | NOT NULL | 租户ID | -| word | VARCHAR(100) | NOT NULL | 热词原文 | -| pinyin_list | JSONB | | 拼音数组 | -| match_strategy | SMALLINT | DEFAULT 1 | 匹配策略 (1:精确, 2:模糊) | -| category | VARCHAR(50) | | 类别 (人名、术语等) | -| weight | INTEGER | DEFAULT 10 | 权重 (1-100) | -| status | SMALLINT | DEFAULT 1 | 状态 (1:启用, 0:禁用) | -| is_synced | SMALLINT | DEFAULT 0 | 已同步第三方标记 | -| remark | TEXT | | 备注 | -| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 | -| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 | -| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 | +| id | BIGSERIAL | PK | 涓婚敭ID | +| tenant_id | BIGINT | NOT NULL | 绉熸埛ID | +| word | VARCHAR(100) | NOT NULL | 鐑瘝鍘熸枃 | +| pinyin_list | JSONB | | 鎷奸煶鏁扮粍 | +| match_strategy | SMALLINT | DEFAULT 1 | 鍖归厤绛栫暐 (1:绮剧‘, 2:妯$硦) | +| category | VARCHAR(50) | | 绫诲埆 (浜哄悕銆佹湳璇瓑) | +| weight | INTEGER | DEFAULT 10 | 鏉冮噸 (1-100) | +| status | SMALLINT | DEFAULT 1 | 鐘舵€?(1:鍚敤, 0:绂佺敤) | +| is_synced | SMALLINT | DEFAULT 0 | 宸插悓姝ョ涓夋柟鏍囪 | +| remark | TEXT | | 澶囨敞 | +| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 | +| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 | +| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎 | -索引: +绱㈠紩锛? - `idx_hotword_tenant`: `(tenant_id)` - `idx_hotword_word`: `(word) WHERE is_deleted = 0` -### 5.3 `biz_prompt_templates`(提示词模板表) -| 字段 | 类型 | 约束 | 说明 | +### 5.3 `biz_prompt_templates`锛堟彁绀鸿瘝妯℃澘琛級 +| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | -| id | BIGSERIAL | PK | 主键ID | -| tenant_id | BIGINT | NOT NULL | 租户ID | -| template_name | VARCHAR(100) | NOT NULL | 模板名称 | -| category | VARCHAR(20) | | 分类 (字典: biz_prompt_category) | -| is_system | SMALLINT | DEFAULT 0 | 是否预置 (1:是, 0:否) | -| creator_id | BIGINT | | 创建人ID | -| tags | JSONB | | 标签数组 | -| usage_count | INTEGER | DEFAULT 0 | 使用次数 | -| prompt_content | TEXT | NOT NULL | 提示词内容 | -| status | SMALLINT | DEFAULT 1 | 状态 (1:启用, 0:禁用) | -| remark | VARCHAR(255) | | 备注 | -| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 | -| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 | -| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 | +| id | BIGSERIAL | PK | 涓婚敭ID | +| tenant_id | BIGINT | NOT NULL | 绉熸埛ID | +| template_name | VARCHAR(100) | NOT NULL | 妯℃澘鍚嶇О | +| category | VARCHAR(20) | | 鍒嗙被 (瀛楀吀: biz_prompt_category) | +| is_system | SMALLINT | DEFAULT 0 | 鏄惁棰勭疆 (1:鏄? 0:鍚? | +| creator_id | BIGINT | | 鍒涘缓浜篒D | +| tags | JSONB | | 鏍囩鏁扮粍 | +| usage_count | INTEGER | DEFAULT 0 | 浣跨敤娆℃暟 | +| prompt_content | TEXT | NOT NULL | 鎻愮ず璇嶅唴瀹?| +| status | SMALLINT | DEFAULT 1 | 鐘舵€?(1:鍚敤, 0:绂佺敤) | +| remark | VARCHAR(255) | | 澶囨敞 | +| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 | +| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 | +| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎 | -索引: +绱㈠紩锛? - `idx_prompt_tenant`: `(tenant_id)` - `idx_prompt_system`: `(is_system) WHERE is_deleted = 0` -### 5.4 `biz_ai_models`(AI 模型管理表) +### 5.4 `biz_asr_models`(ASR 模型管理表) | 字段 | 类型 | 约束 | 说明 | | --- | --- | --- | --- | | id | BIGSERIAL | PK | 主键ID | | tenant_id | BIGINT | NOT NULL | 租户ID | -| model_type | VARCHAR(20) | NOT NULL | ASR (语音) 或 LLM (总结) | -| model_name | VARCHAR(100) | NOT NULL | 自定义名称 | -| provider | VARCHAR(50) | | 提供商 (Aliyun, OpenAI等) | -| base_url | VARCHAR(255) | | 基础请求地址 | +| model_name | VARCHAR(100) | NOT NULL | 模型显示名称 | +| provider | VARCHAR(50) | | 提供商 | +| base_url | VARCHAR(255) | | 接口基础地址 | +| api_key | VARCHAR(255) | | API 密钥 | | model_code | VARCHAR(100) | | 模型代码 | -| ws_url | VARCHAR(255) | | WebSocket 地址 (ASR) | -| temperature | DECIMAL | DEFAULT 0.7 | 随机性 (LLM) | -| media_config | JSONB | | 采样率、协议等 | +| ws_url | VARCHAR(255) | | WebSocket 地址 | +| media_config | JSON/TEXT | | 媒体参数 | | is_default | SMALLINT | DEFAULT 0 | 默认模型标记 | -| status | SMALLINT | DEFAULT 1 | 启用状态 | -| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 | -| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 | +| status | SMALLINT | DEFAULT 1 | 状态 | +| remark | VARCHAR(255) | | 备注 | +| created_at | TIMESTAMP(6) | NOT NULL | 创建时间 | +| updated_at | TIMESTAMP(6) | NOT NULL | 更新时间 | | is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 | 索引: -- `idx_aimodel_tenant`: `(tenant_id)` -- `idx_aimodel_type`: `(model_type, is_default) WHERE is_deleted = 0` +- `idx_asr_model_tenant`: `(tenant_id)` +- `idx_asr_model_default`: `(is_default) WHERE is_deleted = 0` -### 5.5 `biz_meetings`(会议主表) +### 5.5 `biz_llm_models`(LLM 模型管理表) | 字段 | 类型 | 约束 | 说明 | | --- | --- | --- | --- | | id | BIGSERIAL | PK | 主键ID | | tenant_id | BIGINT | NOT NULL | 租户ID | -| title | VARCHAR(200) | NOT NULL | 会议标题 | -| audio_url | VARCHAR(500) | | 专属音频路径 | -| latest_summary_task_id | BIGINT | | 最新成功的总结任务ID | -| status | SMALLINT | DEFAULT 0 | 0:待处理, 1:识别中, 2:总结中, 3:已完成, 4:失败 | +| model_name | VARCHAR(100) | NOT NULL | 模型显示名称 | +| provider | VARCHAR(50) | | 提供商 | +| base_url | VARCHAR(255) | | 接口基础地址 | +| api_path | VARCHAR(100) | | API 路径 | +| api_key | VARCHAR(255) | | API 密钥 | +| model_code | VARCHAR(100) | | 模型代码 | +| temperature | DECIMAL | DEFAULT 0.7 | 随机性 | +| top_p | DECIMAL | DEFAULT 0.9 | 核采样 | +| is_default | SMALLINT | DEFAULT 0 | 默认模型标记 | +| status | SMALLINT | DEFAULT 1 | 状态 | +| remark | VARCHAR(255) | | 备注 | +| created_at | TIMESTAMP(6) | NOT NULL | 创建时间 | +| updated_at | TIMESTAMP(6) | NOT NULL | 更新时间 | +| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 | -### 5.6 `biz_meeting_transcripts`(转录明细表) -| 字段 | 类型 | 约束 | 说明 | -| --- | --- | --- | --- | -| id | BIGSERIAL | PK | 主键ID | -| meeting_id | BIGINT | NOT NULL | 关联会议ID | -| speaker_label | VARCHAR(50) | | 发言人标签 | -| content | TEXT | | 转录文字 | -| start_time | INTEGER | | 开始时间 (ms) | +索引: +- `idx_llm_model_tenant`: `(tenant_id)` +- `idx_llm_model_default`: `(is_default) WHERE is_deleted = 0` -### 5.7 `biz_ai_tasks`(AI 任务流水表) -| 字段 | 类型 | 约束 | 说明 | +### 5.6 `biz_meetings`锛堜細璁富琛級 +| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | | --- | --- | --- | --- | -| id | BIGSERIAL | PK | 主键ID | -| meeting_id | BIGINT | NOT NULL | 关联会议ID | +| id | BIGSERIAL | PK | 涓婚敭ID | +| tenant_id | BIGINT | NOT NULL | 绉熸埛ID | +| title | VARCHAR(200) | NOT NULL | 浼氳鏍囬 | +| audio_url | VARCHAR(500) | | 涓撳睘闊抽璺緞 | +| latest_summary_task_id | BIGINT | | 鏈€鏂版垚鍔熺殑鎬荤粨浠诲姟ID | +| status | SMALLINT | DEFAULT 0 | 0:寰呭鐞? 1:璇嗗埆涓? 2:鎬荤粨涓? 3:宸插畬鎴? 4:澶辫触 | + +### 5.6 `biz_meeting_transcripts`锛堣浆褰曟槑缁嗚〃锛? +| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | +| --- | --- | --- | --- | +| id | BIGSERIAL | PK | 涓婚敭ID | +| meeting_id | BIGINT | NOT NULL | 鍏宠仈浼氳ID | +| speaker_label | VARCHAR(50) | | 鍙戣█浜烘爣绛?| +| content | TEXT | | 杞綍鏂囧瓧 | +| start_time | INTEGER | | 寮€濮嬫椂闂?(ms) | + +### 5.7 `biz_ai_tasks`锛圓I 浠诲姟娴佹按琛級 +| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 | +| --- | --- | --- | --- | +| id | BIGSERIAL | PK | 涓婚敭ID | +| meeting_id | BIGINT | NOT NULL | 鍏宠仈浼氳ID | | task_type | VARCHAR(20) | | ASR / SUMMARY | -| request_data | JSONB | | 请求原始数据 | -| response_data | JSONB | | 响应原始数据 | -| task_config | TEXT | | **[快照]** 任务配置(模型ID、提示词模板等) | -| result_file_path | VARCHAR(500) | | 结果文件相对路径 (如MD总结文件) | -| status | SMALLINT | | 0:排队, 1:处理中, 2:成功, 3:失败 | +| request_data | JSONB | | 璇锋眰鍘熷鏁版嵁 | +| response_data | JSONB | | 鍝嶅簲鍘熷鏁版嵁 | +| task_config | TEXT | | **[蹇収]** 浠诲姟閰嶇疆(妯″瀷ID銆佹彁绀鸿瘝妯℃澘绛? | +| result_file_path | VARCHAR(500) | | 缁撴灉鏂囦欢鐩稿璺緞 (濡侻D鎬荤粨鏂囦欢) | +| status | SMALLINT | | 0:鎺掗槦, 1:澶勭悊涓? 2:鎴愬姛, 3:澶辫触 | + diff --git a/backend/design/db_schema_pgsql.sql b/backend/design/db_schema_pgsql.sql index 2aaf5be..031b756 100644 --- a/backend/design/db_schema_pgsql.sql +++ b/backend/design/db_schema_pgsql.sql @@ -1,4 +1,4 @@ --- PostgreSQL Database Schema for iMeeting (Multi-tenant) +-- PostgreSQL Database Schema for iMeeting (Multi-tenant) -- 0 为系统预留租户 ID -- ---------------------------- @@ -299,33 +299,52 @@ COMMENT ON TABLE biz_prompt_templates IS '会议总结提示词模板表'; -- ---------------------------- -- 9. 业务模块 - AI 模型管理 -- ---------------------------- -DROP TABLE IF EXISTS biz_ai_models CASCADE; -CREATE TABLE biz_ai_models ( +DROP TABLE IF EXISTS biz_asr_models CASCADE; +CREATE TABLE biz_asr_models ( id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL DEFAULT 0, -- 租户ID - model_type VARCHAR(20) NOT NULL, -- 类型: ASR, LLM - model_name VARCHAR(100) NOT NULL, -- 模型显示名称 - provider VARCHAR(50), -- 提供商 (Aliyun, OpenAI, Tencent等) - base_url VARCHAR(255), -- 接口基础地址 - api_path VARCHAR(100), -- API路径 - api_key VARCHAR(255), -- API密钥 (加密存储) - model_code VARCHAR(100), -- 模型真实编码 (如 gpt-4o) - ws_url VARCHAR(255), -- WebSocket 地址 (ASR 专用) - temperature DECIMAL(3,2) DEFAULT 0.7, -- LLM 温度 - top_p DECIMAL(3,2) DEFAULT 0.9, -- LLM 核采样 - media_config text, -- 媒体参数 (采样率、声道等) - is_default SMALLINT DEFAULT 0, -- 是否默认 - status SMALLINT DEFAULT 1, -- 状态: 1:启用, 0:禁用 - remark VARCHAR(255), -- 备注 + tenant_id BIGINT NOT NULL DEFAULT 0, + model_name VARCHAR(100) NOT NULL, + provider VARCHAR(50), + base_url VARCHAR(255), + api_key VARCHAR(255), + model_code VARCHAR(100), + ws_url VARCHAR(255), + media_config text, + is_default SMALLINT DEFAULT 0, + status SMALLINT DEFAULT 1, + remark VARCHAR(255), created_at TIMESTAMP(6) NOT NULL DEFAULT now(), updated_at TIMESTAMP(6) NOT NULL DEFAULT now(), is_deleted SMALLINT NOT NULL DEFAULT 0 ); -CREATE INDEX idx_aimodel_tenant ON biz_ai_models (tenant_id); -CREATE INDEX idx_aimodel_type ON biz_ai_models (model_type, is_default) WHERE is_deleted = 0; +DROP TABLE IF EXISTS biz_llm_models CASCADE; +CREATE TABLE biz_llm_models ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL DEFAULT 0, + model_name VARCHAR(100) NOT NULL, + provider VARCHAR(50), + base_url VARCHAR(255), + api_path VARCHAR(100), + api_key VARCHAR(255), + model_code VARCHAR(100), + temperature DECIMAL(3,2) DEFAULT 0.7, + top_p DECIMAL(3,2) DEFAULT 0.9, + is_default SMALLINT DEFAULT 0, + status SMALLINT DEFAULT 1, + remark VARCHAR(255), + created_at TIMESTAMP(6) NOT NULL DEFAULT now(), + updated_at TIMESTAMP(6) NOT NULL DEFAULT now(), + is_deleted SMALLINT NOT NULL DEFAULT 0 +); -COMMENT ON TABLE biz_ai_models IS 'AI 识别与总结模型配置表'; +CREATE INDEX idx_asr_model_tenant ON biz_asr_models (tenant_id); +CREATE INDEX idx_asr_model_default ON biz_asr_models (is_default) WHERE is_deleted = 0; +CREATE INDEX idx_llm_model_tenant ON biz_llm_models (tenant_id); +CREATE INDEX idx_llm_model_default ON biz_llm_models (is_default) WHERE is_deleted = 0; + +COMMENT ON TABLE biz_asr_models IS 'ASR 模型配置表'; +COMMENT ON TABLE biz_llm_models IS 'LLM 模型配置表'; -- ---------------------------- -- 10. 业务模块 - 会议主表 diff --git a/backend/src/main/java/com/imeeting/config/MybatisPlusConfig.java b/backend/src/main/java/com/imeeting/config/MybatisPlusConfig.java index d248c26..d92c137 100644 --- a/backend/src/main/java/com/imeeting/config/MybatisPlusConfig.java +++ b/backend/src/main/java/com/imeeting/config/MybatisPlusConfig.java @@ -55,7 +55,7 @@ public class MybatisPlusConfig { } // 公共表始终忽略过滤 - return List.of("sys_tenant","sys_platform_config", "sys_user", "sys_tenant_user", "sys_permission", "sys_role_permission", "sys_user_role", "sys_dict_type", "sys_dict_item", "sys_param", "biz_speakers", "biz_prompt_templates", "biz_ai_models", "biz_meetings", "biz_meeting_transcripts", "biz_ai_tasks").contains(tableName.toLowerCase()); + return List.of("sys_tenant","sys_platform_config", "sys_user", "sys_tenant_user", "sys_permission", "sys_role_permission", "sys_user_role", "sys_dict_type", "sys_dict_item", "sys_param", "biz_speakers", "biz_prompt_templates", "biz_asr_models", "biz_llm_models", "biz_meetings", "biz_meeting_transcripts", "biz_ai_tasks").contains(tableName.toLowerCase()); } })); interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); diff --git a/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java b/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java index 5969971..ef47514 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java @@ -4,7 +4,6 @@ import com.imeeting.common.ApiResponse; import com.imeeting.common.PageResult; import com.imeeting.dto.biz.AiModelDTO; import com.imeeting.dto.biz.AiModelVO; -import com.imeeting.entity.biz.AiModel; import com.imeeting.security.LoginUser; import com.imeeting.service.biz.AiModelService; import org.springframework.security.access.prepost.PreAuthorize; @@ -26,38 +25,50 @@ public class AiModelController { @PostMapping @PreAuthorize("isAuthenticated()") public ApiResponse save(@RequestBody AiModelDTO dto) { - LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - // Permission Check: only platform admin can save system models (tenantId=0 implicit via dto field or context) - // If normal user tries to set isSystem or similar (handled by tenantId in service) return ApiResponse.ok(aiModelService.saveModel(dto)); } @PutMapping @PreAuthorize("isAuthenticated()") public ApiResponse update(@RequestBody AiModelDTO dto) { + if (dto.getId() == null) { + return ApiResponse.error("模型ID不能为空"); + } + if (dto.getModelType() == null || dto.getModelType().isBlank()) { + return ApiResponse.error("模型类型不能为空"); + } + LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - AiModel existing = aiModelService.getById(dto.getId()); - if (existing == null) return ApiResponse.error("模型不存在"); - - if (Long.valueOf(0).equals(existing.getTenantId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) { + AiModelVO existing = aiModelService.getModelById(dto.getId(), dto.getModelType()); + if (existing == null) { + return ApiResponse.error("模型不存在"); + } + + if (Long.valueOf(0L).equals(existing.getTenantId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) { return ApiResponse.error("无权修改系统级模型"); } - + return ApiResponse.ok(aiModelService.updateModel(dto)); } @DeleteMapping("/{id}") @PreAuthorize("isAuthenticated()") - public ApiResponse delete(@PathVariable Long id) { - LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - AiModel existing = aiModelService.getById(id); - if (existing == null) return ApiResponse.ok(true); + public ApiResponse delete(@PathVariable Long id, @RequestParam String type) { + if (type == null || type.isBlank()) { + return ApiResponse.error("模型类型不能为空"); + } - if (Long.valueOf(0).equals(existing.getTenantId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) { + LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + AiModelVO existing = aiModelService.getModelById(id, type); + if (existing == null) { + return ApiResponse.ok(true); + } + + if (Long.valueOf(0L).equals(existing.getTenantId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) { return ApiResponse.error("无权删除系统级模型"); } - return ApiResponse.ok(aiModelService.removeById(id)); + return ApiResponse.ok(aiModelService.removeModelById(id, type)); } @GetMapping("/page") @@ -67,7 +78,6 @@ public class AiModelController { @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) String name, @RequestParam(required = false) String type) { - LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); return ApiResponse.ok(aiModelService.pageModels(current, size, name, type, loginUser.getTenantId())); } diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java index df68d07..8120de8 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -218,6 +218,28 @@ public class MeetingController { return ApiResponse.ok(true); } + @PutMapping("/participants") + @PreAuthorize("isAuthenticated()") + public ApiResponse updateParticipants(@RequestBody Map params) { + LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + Long meetingId = Long.valueOf(params.get("meetingId").toString()); + String participants = params.get("participants") != null ? params.get("participants").toString() : ""; + + Meeting existing = meetingService.getById(meetingId); + if (existing == null) { + return ApiResponse.error("会议不存在"); + } + + if (!existing.getCreatorId().equals(loginUser.getUserId()) + && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) + && !Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) { + return ApiResponse.error("无权修改此会议参会人"); + } + + meetingService.updateMeetingParticipants(meetingId, participants); + return ApiResponse.ok(true); + } + @PostMapping("/re-summary") @PreAuthorize("isAuthenticated()") public ApiResponse reSummary(@RequestBody Map params) { diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java index 14fce4e..8e2a981 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java @@ -3,6 +3,7 @@ package com.imeeting.dto.biz; import com.fasterxml.jackson.annotation.JsonFormat; import lombok.Data; import java.time.LocalDateTime; +import java.util.List; @Data public class MeetingVO { @@ -16,6 +17,7 @@ public class MeetingVO { private LocalDateTime meetingTime; private String participants; + private List participantIds; private String tags; private String audioUrl; private String summaryContent; diff --git a/backend/src/main/java/com/imeeting/entity/biz/AiModel.java b/backend/src/main/java/com/imeeting/entity/biz/AsrModel.java similarity index 77% rename from backend/src/main/java/com/imeeting/entity/biz/AiModel.java rename to backend/src/main/java/com/imeeting/entity/biz/AsrModel.java index 0769411..3aafd38 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/AiModel.java +++ b/backend/src/main/java/com/imeeting/entity/biz/AsrModel.java @@ -9,36 +9,27 @@ import com.imeeting.entity.BaseEntity; import lombok.Data; import lombok.EqualsAndHashCode; -import java.math.BigDecimal; import java.util.Map; @Data @EqualsAndHashCode(callSuper = true) -@TableName(value = "biz_ai_models", autoResultMap = true) -public class AiModel extends BaseEntity { +@TableName(value = "biz_asr_models", autoResultMap = true) +public class AsrModel extends BaseEntity { @TableId(value = "id", type = IdType.AUTO) private Long id; - private String modelType; - private String modelName; private String provider; private String baseUrl; - private String apiPath; - private String apiKey; private String modelCode; private String wsUrl; - private BigDecimal temperature; - - private BigDecimal topP; - @TableField(typeHandler = JacksonTypeHandler.class) private Map mediaConfig; diff --git a/backend/src/main/java/com/imeeting/entity/biz/LlmModel.java b/backend/src/main/java/com/imeeting/entity/biz/LlmModel.java new file mode 100644 index 0000000..ea375ca --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/LlmModel.java @@ -0,0 +1,38 @@ +package com.imeeting.entity.biz; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.imeeting.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("biz_llm_models") +public class LlmModel extends BaseEntity { + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + private String modelName; + + private String provider; + + private String baseUrl; + + private String apiPath; + + private String apiKey; + + private String modelCode; + + private BigDecimal temperature; + + private BigDecimal topP; + + private Integer isDefault; + + private String remark; +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/PromptTemplateUserConfig.java b/backend/src/main/java/com/imeeting/entity/biz/PromptTemplateUserConfig.java new file mode 100644 index 0000000..3655e41 --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/PromptTemplateUserConfig.java @@ -0,0 +1,22 @@ +package com.imeeting.entity.biz; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.imeeting.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("biz_prompt_template_user_config") +public class PromptTemplateUserConfig extends BaseEntity { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + private Long userId; + + private Long templateId; +} + diff --git a/backend/src/main/java/com/imeeting/listener/MeetingTaskRecoveryListener.java b/backend/src/main/java/com/imeeting/listener/MeetingTaskRecoveryListener.java new file mode 100644 index 0000000..b706984 --- /dev/null +++ b/backend/src/main/java/com/imeeting/listener/MeetingTaskRecoveryListener.java @@ -0,0 +1,91 @@ +package com.imeeting.listener; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.imeeting.common.RedisKeys; +import com.imeeting.entity.biz.Meeting; +import com.imeeting.mapper.biz.MeetingMapper; +import com.imeeting.service.biz.AiTaskService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * 任务自愈监听器:在系统重启后自动恢复挂起的 AI 任务 + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class MeetingTaskRecoveryListener implements ApplicationRunner { + + private final MeetingMapper meetingMapper; + private final AiTaskService aiTaskService; + private final StringRedisTemplate redisTemplate; + + @Override + public void run(ApplicationArguments args) { + log.info("Starting meeting task self-healing check..."); + + // 1. 查询状态为 1(识别中) 或 2(总结中) 且未删除的会议 + List pendingMeetings = meetingMapper.selectList(new LambdaQueryWrapper() + .in(Meeting::getStatus, 1, 2) + .eq(Meeting::getIsDeleted, 0)); + + if (pendingMeetings.isEmpty()) { + log.info("No pending tasks found. Recovery check completed."); + return; + } + + LocalDateTime oneHourAgo = LocalDateTime.now().minusHours(1); + + for (Meeting meeting : pendingMeetings) { + try { + // 处理 1 小时以上的死任务 + if (meeting.getUpdatedAt() != null && meeting.getUpdatedAt().isBefore(oneHourAgo)) { + log.warn("Meeting {} is stuck for more than 1 hour, marking as failed", meeting.getId()); + markAsFailed(meeting, "任务因系统维护超时,请重新发起分析"); + continue; + } + + // 2. 清理旧的 Redis 锁和进度缓存,确保恢复线程能拿到控制权 + redisTemplate.delete(RedisKeys.meetingPollingLockKey(meeting.getId())); + + // 3. 根据状态重新派发任务 (平滑拉起) + if (meeting.getStatus() == 1) { + log.info("Resuming ASR task for meeting {}", meeting.getId()); + aiTaskService.dispatchTasks(meeting.getId()); + } else if (meeting.getStatus() == 2) { + log.info("Resuming Summary task for meeting {}", meeting.getId()); + aiTaskService.dispatchSummaryTask(meeting.getId()); + } + + // 增加小延迟防止惊群效应 + TimeUnit.MILLISECONDS.sleep(200); + + } catch (Exception e) { + log.error("Failed to recover meeting task {}", meeting.getId(), e); + } + } + + log.info("Successfully processed {} pending tasks for recovery.", pendingMeetings.size()); + } + + private void markAsFailed(Meeting m, String reason) { + Meeting update = new Meeting(); + update.setId(m.getId()); + update.setStatus(4); // 失败 + meetingMapper.updateById(update); + + // 同步 Redis 进度为失败 + String progressKey = RedisKeys.meetingProgressKey(m.getId()); + redisTemplate.opsForValue().set(progressKey, + "{\"percent\":-1, \"message\":\"" + reason + "\", \"updateAt\":" + System.currentTimeMillis() + "}", + 1, TimeUnit.HOURS); + } +} diff --git a/backend/src/main/java/com/imeeting/mapper/biz/AiModelMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/AsrModelMapper.java similarity index 58% rename from backend/src/main/java/com/imeeting/mapper/biz/AiModelMapper.java rename to backend/src/main/java/com/imeeting/mapper/biz/AsrModelMapper.java index aadec7a..65163a9 100644 --- a/backend/src/main/java/com/imeeting/mapper/biz/AiModelMapper.java +++ b/backend/src/main/java/com/imeeting/mapper/biz/AsrModelMapper.java @@ -1,9 +1,9 @@ package com.imeeting.mapper.biz; import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.imeeting.entity.biz.AiModel; +import com.imeeting.entity.biz.AsrModel; import org.apache.ibatis.annotations.Mapper; @Mapper -public interface AiModelMapper extends BaseMapper { +public interface AsrModelMapper extends BaseMapper { } diff --git a/backend/src/main/java/com/imeeting/mapper/biz/LlmModelMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/LlmModelMapper.java new file mode 100644 index 0000000..6318782 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/biz/LlmModelMapper.java @@ -0,0 +1,9 @@ +package com.imeeting.mapper.biz; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.imeeting.entity.biz.LlmModel; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface LlmModelMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/imeeting/mapper/biz/PromptTemplateUserConfigMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/PromptTemplateUserConfigMapper.java new file mode 100644 index 0000000..eb64222 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/biz/PromptTemplateUserConfigMapper.java @@ -0,0 +1,10 @@ +package com.imeeting.mapper.biz; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.imeeting.entity.biz.PromptTemplateUserConfig; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PromptTemplateUserConfigMapper extends BaseMapper { +} + diff --git a/backend/src/main/java/com/imeeting/service/biz/AiModelService.java b/backend/src/main/java/com/imeeting/service/biz/AiModelService.java index bfede83..63ba0e8 100644 --- a/backend/src/main/java/com/imeeting/service/biz/AiModelService.java +++ b/backend/src/main/java/com/imeeting/service/biz/AiModelService.java @@ -1,17 +1,17 @@ package com.imeeting.service.biz; -import com.baomidou.mybatisplus.extension.service.IService; import com.imeeting.common.PageResult; import com.imeeting.dto.biz.AiModelDTO; import com.imeeting.dto.biz.AiModelVO; -import com.imeeting.entity.biz.AiModel; import java.util.List; -public interface AiModelService extends IService { +public interface AiModelService { AiModelVO saveModel(AiModelDTO dto); AiModelVO updateModel(AiModelDTO dto); PageResult> pageModels(Integer current, Integer size, String name, String type, Long tenantId); List fetchRemoteModels(String provider, String baseUrl, String apiKey); AiModelVO getDefaultModel(String type, Long tenantId); + AiModelVO getModelById(Long id, String type); + boolean removeModelById(Long id, String type); } diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingService.java index 477017c..135f87e 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingService.java @@ -17,6 +17,7 @@ public interface MeetingService extends IService { MeetingVO getDetail(Long id); List getTranscripts(Long meetingId); void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label); + void updateMeetingParticipants(Long meetingId, String participants); void reSummary(Long meetingId, Long summaryModelId, Long promptId); java.util.Map getDashboardStats(Long tenantId, Long userId, boolean isAdmin); List getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java index eead4e6..dcb6a44 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java @@ -3,36 +3,49 @@ package com.imeeting.service.biz.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.imeeting.common.PageResult; import com.imeeting.dto.biz.AiModelDTO; import com.imeeting.dto.biz.AiModelVO; -import com.imeeting.entity.biz.AiModel; -import com.imeeting.mapper.biz.AiModelMapper; +import com.imeeting.entity.biz.AsrModel; +import com.imeeting.entity.biz.LlmModel; +import com.imeeting.mapper.biz.AsrModelMapper; +import com.imeeting.mapper.biz.LlmModelMapper; import com.imeeting.service.biz.AiModelService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; import java.net.URI; +import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; +import java.util.Set; @Service @Slf4j @RequiredArgsConstructor -public class AiModelServiceImpl extends ServiceImpl implements AiModelService { +public class AiModelServiceImpl implements AiModelService { + + private static final String TYPE_ASR = "ASR"; + private static final String TYPE_LLM = "LLM"; private final ObjectMapper objectMapper; + private final AsrModelMapper asrModelMapper; + private final LlmModelMapper llmModelMapper; + private final HttpClient httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); @@ -40,158 +53,296 @@ public class AiModelServiceImpl extends ServiceImpl impl @Override @Transactional(rollbackFor = Exception.class) public AiModelVO saveModel(AiModelDTO dto) { - AiModel entity = new AiModel(); - copyProperties(dto, entity); + String type = normalizeType(dto.getModelType()); + if (TYPE_ASR.equals(type)) { + AsrModel entity = new AsrModel(); + copyAsrProperties(dto, entity); + pushAsrConfig(entity); + handleAsrWsUrl(entity); + handleAsrDefaultLogic(entity); + asrModelMapper.insert(entity); + return toAsrVO(entity); + } - pushAsrConfig(entity); - handleAsrWsUrl(entity); - handleDefaultLogic(entity); - - this.save(entity); - return toVO(entity); + LlmModel entity = new LlmModel(); + copyLlmProperties(dto, entity); + handleLlmDefaultLogic(entity); + llmModelMapper.insert(entity); + return toLlmVO(entity); } @Override @Transactional(rollbackFor = Exception.class) public AiModelVO updateModel(AiModelDTO dto) { - AiModel entity = this.getById(dto.getId()); - if (entity == null) throw new RuntimeException("Model not found"); + String type = normalizeType(dto.getModelType()); + if (TYPE_ASR.equals(type)) { + AsrModel entity = asrModelMapper.selectById(dto.getId()); + if (entity == null) { + throw new RuntimeException("Model not found"); + } + copyAsrProperties(dto, entity); + pushAsrConfig(entity); + handleAsrWsUrl(entity); + handleAsrDefaultLogic(entity); + asrModelMapper.updateById(entity); + return toAsrVO(entity); + } - copyProperties(dto, entity); - pushAsrConfig(entity); - handleAsrWsUrl(entity); - handleDefaultLogic(entity); - - this.updateById(entity); - return toVO(entity); + LlmModel entity = llmModelMapper.selectById(dto.getId()); + if (entity == null) { + throw new RuntimeException("Model not found"); + } + copyLlmProperties(dto, entity); + handleLlmDefaultLogic(entity); + llmModelMapper.updateById(entity); + return toLlmVO(entity); } @Override public PageResult> pageModels(Integer current, Integer size, String name, String type, Long tenantId) { - Page page = this.page(new Page<>(current, size), - new LambdaQueryWrapper() - .and(wrapper -> wrapper.eq(AiModel::getTenantId, tenantId).or().eq(AiModel::getTenantId, 0L)) - .eq(type != null && !type.isEmpty(), AiModel::getModelType, type) - .like(name != null && !name.isEmpty(), AiModel::getModelName, name) - .orderByDesc(AiModel::getTenantId) - .orderByDesc(AiModel::getCreatedAt)); + String resolvedType = normalizeType(type); + if (TYPE_ASR.equals(resolvedType)) { + Page page = new Page<>(current, size); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .and(w -> w.eq(AsrModel::getTenantId, tenantId).or().eq(AsrModel::getTenantId, 0L)) + .like(name != null && !name.isBlank(), AsrModel::getModelName, name) + .orderByDesc(AsrModel::getTenantId) + .orderByDesc(AsrModel::getCreatedAt); + Page resultPage = asrModelMapper.selectPage(page, wrapper); + List records = new ArrayList<>(); + for (AsrModel entity : resultPage.getRecords()) { + records.add(toAsrVO(entity)); + } + PageResult> result = new PageResult<>(); + result.setTotal(resultPage.getTotal()); + result.setRecords(records); + return result; + } - List vos = page.getRecords().stream().map(this::toVO).collect(Collectors.toList()); + Page page = new Page<>(current, size); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .and(w -> w.eq(LlmModel::getTenantId, tenantId).or().eq(LlmModel::getTenantId, 0L)) + .like(name != null && !name.isBlank(), LlmModel::getModelName, name) + .orderByDesc(LlmModel::getTenantId) + .orderByDesc(LlmModel::getCreatedAt); + Page resultPage = llmModelMapper.selectPage(page, wrapper); + List records = new ArrayList<>(); + for (LlmModel entity : resultPage.getRecords()) { + records.add(toLlmVO(entity)); + } PageResult> result = new PageResult<>(); - result.setTotal(page.getTotal()); - result.setRecords(vos); + result.setTotal(resultPage.getTotal()); + result.setRecords(records); return result; } @Override public List fetchRemoteModels(String provider, String baseUrl, String apiKey) { try { - // 针对 ASR 模型,优先使用指定的 3050 地址进行探测 - String targetUrl; - if (baseUrl != null && baseUrl.contains("3050")) { - targetUrl = "http://10.100.51.199:3050/api/asrconfig"; - } else if (baseUrl != null && !baseUrl.isEmpty()) { - // LLM 类型:通用 OpenAI 风格探测 - targetUrl = baseUrl.endsWith("/") ? baseUrl + "models" : baseUrl + "/models"; - } else { + String providerKey = normalizeProvider(provider); + String resolvedBaseUrl = resolveBaseUrl(providerKey, baseUrl); + if (resolvedBaseUrl == null || resolvedBaseUrl.isBlank()) { + return Collections.emptyList(); + } + String targetUrl = resolveModelListUrl(providerKey, resolvedBaseUrl, apiKey); + if (targetUrl == null || targetUrl.isBlank()) { return Collections.emptyList(); } - java.net.http.HttpClient client = java.net.http.HttpClient.newBuilder() - .followRedirects(java.net.http.HttpClient.Redirect.ALWAYS) - .build(); - - java.net.http.HttpRequest.Builder requestBuilder = java.net.http.HttpRequest.newBuilder() - .uri(java.net.URI.create(targetUrl)) - .timeout(java.time.Duration.ofSeconds(10)) + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(targetUrl)) + .timeout(Duration.ofSeconds(10)) .GET(); - if (apiKey != null && !apiKey.isEmpty()) { + if ("anthropic".equals(providerKey)) { + if (apiKey != null && !apiKey.isBlank()) { + requestBuilder.header("x-api-key", apiKey); + } + requestBuilder.header("anthropic-version", "2023-06-01"); + } else if (!"gemini".equals(providerKey) && apiKey != null && !apiKey.isBlank()) { requestBuilder.header("Authorization", "Bearer " + apiKey); } - java.net.http.HttpResponse response = client.send(requestBuilder.build(), - java.net.http.HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() == 200) { - String body = response.body(); - com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); - com.fasterxml.jackson.databind.JsonNode node = mapper.readTree(body); - - List models = new java.util.ArrayList<>(); - - // 1. 适配特定的 ASR 接口格式: { "data": { "available_models": [...] } } - if (node.has("data") && node.get("data").has("available_models") && node.get("data").get("available_models").isArray()) { - for (com.fasterxml.jackson.databind.JsonNode m : node.get("data").get("available_models")) { - models.add(m.asText()); - } - } - // 2. 适配标准 OpenAI 格式: { "data": [ { "id": "..." } ] } - else if (node.has("data") && node.get("data").isArray()) { - for (com.fasterxml.jackson.databind.JsonNode m : node.get("data")) { - models.add(m.has("id") ? m.get("id").asText() : m.asText()); - } - } - // 3. 适配简单数组格式: [ "..." ] - else if (node.isArray()) { - for (com.fasterxml.jackson.databind.JsonNode m : node) { - models.add(m.asText()); - } - } - // 4. 适配带有 models 字段的格式 - else if (node.has("models") && node.get("models").isArray()) { - for (com.fasterxml.jackson.databind.JsonNode m : node.get("models")) { - models.add(m.asText()); - } - } - - return models; + HttpResponse response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + return Collections.emptyList(); } + + JsonNode node = objectMapper.readTree(response.body()); + Set models = new LinkedHashSet<>(); + if (node.has("data") && node.get("data").has("available_models") && node.get("data").get("available_models").isArray()) { + for (JsonNode item : node.get("data").get("available_models")) { + models.add(item.asText()); + } + return new ArrayList<>(models); + } + + if (node.has("data") && node.get("data").isArray()) { + for (JsonNode item : node.get("data")) { + if (item.has("id")) { + models.add(item.get("id").asText()); + } else if (item.has("name")) { + models.add(sanitizeModelName(item.get("name").asText())); + } else { + models.add(item.asText()); + } + } + return new ArrayList<>(models); + } + + if (node.isArray()) { + for (JsonNode item : node) { + models.add(item.asText()); + } + return new ArrayList<>(models); + } + + if (node.has("models") && node.get("models").isArray()) { + for (JsonNode item : node.get("models")) { + if (item.has("name")) { + models.add(sanitizeModelName(item.get("name").asText())); + } else if (item.has("id")) { + models.add(item.get("id").asText()); + } else { + models.add(item.asText()); + } + } + } + return new ArrayList<>(models); } catch (Exception e) { - log.error("Fetch remote models error: {}", e.getMessage()); + log.error("Fetch remote models error: {}", e.getMessage(), e); + return Collections.emptyList(); } - return Collections.emptyList(); + } + + private String resolveBaseUrl(String providerKey, String baseUrl) { + if (baseUrl != null && !baseUrl.isBlank()) { + return baseUrl; + } + return switch (providerKey) { + case "openai" -> "https://api.openai.com/v1"; + case "deepseek" -> "https://api.deepseek.com"; + case "aliyun", "qwen", "dashscope" -> "https://dashscope.aliyuncs.com/compatible-mode/v1"; + case "moonshot", "kimi" -> "https://api.moonshot.cn/v1"; + case "groq" -> "https://api.groq.com/openai/v1"; + case "anthropic" -> "https://api.anthropic.com/v1"; + case "gemini", "google" -> "https://generativelanguage.googleapis.com/v1beta"; + default -> ""; + }; + } + + private String resolveModelListUrl(String providerKey, String baseUrl, String apiKey) { + if (baseUrl.contains("3050")) { + return "http://10.100.51.199:3050/api/asrconfig"; + } + if ("gemini".equals(providerKey) || "google".equals(providerKey)) { + if (apiKey == null || apiKey.isBlank()) { + return ""; + } + String key = URLEncoder.encode(apiKey, StandardCharsets.UTF_8); + return appendPath(baseUrl, "models") + "?key=" + key; + } + return appendPath(baseUrl, "models"); + } + + private String appendPath(String baseUrl, String path) { + return baseUrl.endsWith("/") ? baseUrl + path : baseUrl + "/" + path; + } + + private String sanitizeModelName(String rawName) { + if (rawName == null) { + return ""; + } + if (rawName.startsWith("models/")) { + return rawName.substring("models/".length()); + } + return rawName; + } + + private String normalizeProvider(String provider) { + if (provider == null) { + return ""; + } + return provider.trim().toLowerCase(); } @Override public AiModelVO getDefaultModel(String type, Long tenantId) { - AiModel model = this.getOne(new LambdaQueryWrapper() - .eq(AiModel::getModelType, type) - .eq(AiModel::getIsDefault, 1) - .and(w -> w.eq(AiModel::getTenantId, tenantId).or().eq(AiModel::getTenantId, 0L)) - .orderByDesc(AiModel::getTenantId) // 租户优先 + String resolvedType = normalizeType(type); + if (TYPE_ASR.equals(resolvedType)) { + AsrModel model = asrModelMapper.selectOne(new LambdaQueryWrapper() + .eq(AsrModel::getIsDefault, 1) + .and(w -> w.eq(AsrModel::getTenantId, tenantId).or().eq(AsrModel::getTenantId, 0L)) + .orderByDesc(AsrModel::getTenantId) + .last("LIMIT 1")); + return model == null ? null : toAsrVO(model); + } + + LlmModel model = llmModelMapper.selectOne(new LambdaQueryWrapper() + .eq(LlmModel::getIsDefault, 1) + .and(w -> w.eq(LlmModel::getTenantId, tenantId).or().eq(LlmModel::getTenantId, 0L)) + .orderByDesc(LlmModel::getTenantId) .last("LIMIT 1")); - return model != null ? toVO(model) : null; + return model == null ? null : toLlmVO(model); } - private void handleDefaultLogic(AiModel entity) { - if (Integer.valueOf(1).equals(entity.getIsDefault())) { - // Unset other defaults for the same tenant and type - this.update(new LambdaUpdateWrapper() - .set(AiModel::getIsDefault, 0) - .eq(AiModel::getTenantId, entity.getTenantId()) - .eq(AiModel::getModelType, entity.getModelType()) - .eq(AiModel::getIsDefault, 1)); + @Override + public AiModelVO getModelById(Long id, String type) { + String resolvedType = normalizeType(type); + if (TYPE_ASR.equals(resolvedType)) { + AsrModel entity = asrModelMapper.selectById(id); + return entity == null ? null : toAsrVO(entity); } + + LlmModel entity = llmModelMapper.selectById(id); + return entity == null ? null : toLlmVO(entity); } - private void handleAsrWsUrl(AiModel entity) { - if ("ASR".equals(entity.getModelType()) && (entity.getWsUrl() == null || entity.getWsUrl().isEmpty())) { - if (entity.getBaseUrl() != null) { - String ws = entity.getBaseUrl().replace("http://", "ws://").replace("https://", "wss://"); - entity.setWsUrl(ws); - } + @Override + public boolean removeModelById(Long id, String type) { + String resolvedType = normalizeType(type); + if (TYPE_ASR.equals(resolvedType)) { + return asrModelMapper.deleteById(id) > 0; } + return llmModelMapper.deleteById(id) > 0; } - private void pushAsrConfig(AiModel entity) { - if (!"ASR".equals(entity.getModelType())) { + private void handleAsrDefaultLogic(AsrModel entity) { + if (!Integer.valueOf(1).equals(entity.getIsDefault())) { return; } - if (entity.getBaseUrl() == null || entity.getBaseUrl().trim().isEmpty()) { + asrModelMapper.update(null, new LambdaUpdateWrapper() + .set(AsrModel::getIsDefault, 0) + .eq(AsrModel::getTenantId, entity.getTenantId()) + .eq(AsrModel::getIsDefault, 1)); + } + + private void handleLlmDefaultLogic(LlmModel entity) { + if (!Integer.valueOf(1).equals(entity.getIsDefault())) { + return; + } + llmModelMapper.update(null, new LambdaUpdateWrapper() + .set(LlmModel::getIsDefault, 0) + .eq(LlmModel::getTenantId, entity.getTenantId()) + .eq(LlmModel::getIsDefault, 1)); + } + + private void handleAsrWsUrl(AsrModel entity) { + if (entity.getWsUrl() != null && !entity.getWsUrl().isBlank()) { + return; + } + if (entity.getBaseUrl() == null || entity.getBaseUrl().isBlank()) { + return; + } + String ws = entity.getBaseUrl().replace("http://", "ws://").replace("https://", "wss://"); + entity.setWsUrl(ws); + } + + private void pushAsrConfig(AsrModel entity) { + if (entity.getBaseUrl() == null || entity.getBaseUrl().isBlank()) { throw new RuntimeException("baseUrl is required for ASR model"); } - if (entity.getModelCode() == null || entity.getModelCode().trim().isEmpty()) { + if (entity.getModelCode() == null || entity.getModelCode().isBlank()) { throw new RuntimeException("modelCode is required for ASR model"); } @@ -212,49 +363,51 @@ public class AiModelServiceImpl extends ServiceImpl impl HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() < 200 || response.statusCode() >= 300) { - log.error("Push ASR config failed, url={}, code={}, body={}", targetUrl, response.statusCode(), response.body()); throw new RuntimeException("Third-party ASR config save failed: HTTP " + response.statusCode()); } } catch (Exception e) { - log.error("Push ASR config error, url={}, msg={}", targetUrl, e.getMessage(), e); throw new RuntimeException("Third-party ASR config save failed: " + e.getMessage(), e); } } - private void copyProperties(AiModelDTO dto, AiModel entity) { - entity.setModelType(dto.getModelType()); + private void copyAsrProperties(AiModelDTO dto, AsrModel entity) { entity.setModelName(dto.getModelName()); entity.setProvider(dto.getProvider()); entity.setBaseUrl(dto.getBaseUrl()); - entity.setApiPath(dto.getApiPath()); entity.setApiKey(dto.getApiKey()); entity.setModelCode(dto.getModelCode()); entity.setWsUrl(dto.getWsUrl()); - entity.setTemperature(dto.getTemperature()); - entity.setTopP(dto.getTopP()); entity.setMediaConfig(dto.getMediaConfig()); entity.setIsDefault(dto.getIsDefault()); entity.setStatus(dto.getStatus()); entity.setRemark(dto.getRemark()); } - private AiModelVO toVO(AiModel entity) { + private void copyLlmProperties(AiModelDTO dto, LlmModel entity) { + entity.setModelName(dto.getModelName()); + entity.setProvider(dto.getProvider()); + entity.setBaseUrl(dto.getBaseUrl()); + entity.setApiPath(dto.getApiPath()); + entity.setApiKey(dto.getApiKey()); + entity.setModelCode(dto.getModelCode()); + entity.setTemperature(dto.getTemperature() == null ? BigDecimal.valueOf(0.7) : dto.getTemperature()); + entity.setTopP(dto.getTopP() == null ? BigDecimal.valueOf(0.9) : dto.getTopP()); + entity.setIsDefault(dto.getIsDefault()); + entity.setStatus(dto.getStatus()); + entity.setRemark(dto.getRemark()); + } + + private AiModelVO toAsrVO(AsrModel entity) { AiModelVO vo = new AiModelVO(); vo.setId(entity.getId()); vo.setTenantId(entity.getTenantId()); - vo.setModelType(entity.getModelType()); + vo.setModelType(TYPE_ASR); vo.setModelName(entity.getModelName()); vo.setProvider(entity.getProvider()); vo.setBaseUrl(entity.getBaseUrl()); - vo.setApiPath(entity.getApiPath()); - // Mask ApiKey - if (entity.getApiKey() != null && entity.getApiKey().length() > 8) { - vo.setApiKey(entity.getApiKey().substring(0, 4) + "****" + entity.getApiKey().substring(entity.getApiKey().length() - 4)); - } + vo.setApiKey(entity.getApiKey()); vo.setModelCode(entity.getModelCode()); vo.setWsUrl(entity.getWsUrl()); - vo.setTemperature(entity.getTemperature()); - vo.setTopP(entity.getTopP()); vo.setMediaConfig(entity.getMediaConfig()); vo.setIsDefault(entity.getIsDefault()); vo.setStatus(entity.getStatus()); @@ -262,4 +415,35 @@ public class AiModelServiceImpl extends ServiceImpl impl vo.setCreatedAt(entity.getCreatedAt()); return vo; } + + private AiModelVO toLlmVO(LlmModel entity) { + AiModelVO vo = new AiModelVO(); + vo.setId(entity.getId()); + vo.setTenantId(entity.getTenantId()); + vo.setModelType(TYPE_LLM); + vo.setModelName(entity.getModelName()); + vo.setProvider(entity.getProvider()); + vo.setBaseUrl(entity.getBaseUrl()); + vo.setApiPath(entity.getApiPath()); + vo.setApiKey(entity.getApiKey()); + vo.setModelCode(entity.getModelCode()); + vo.setTemperature(entity.getTemperature()); + vo.setTopP(entity.getTopP()); + vo.setIsDefault(entity.getIsDefault()); + vo.setStatus(entity.getStatus()); + vo.setRemark(entity.getRemark()); + vo.setCreatedAt(entity.getCreatedAt()); + return vo; + } + + private String normalizeType(String type) { + if (type == null || type.isBlank()) { + return TYPE_ASR; + } + String normalized = type.trim().toUpperCase(); + if (!TYPE_ASR.equals(normalized) && !TYPE_LLM.equals(normalized)) { + throw new RuntimeException("Unsupported model type: " + type); + } + return normalized; + } } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java index 40a8ac2..f56a287 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java @@ -5,8 +5,8 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.imeeting.common.RedisKeys; +import com.imeeting.dto.biz.AiModelVO; import com.imeeting.entity.SysUser; -import com.imeeting.entity.biz.AiModel; import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.HotWord; import com.imeeting.entity.biz.Meeting; @@ -158,7 +158,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme this.updateById(taskRecord); Long asrModelId = Long.valueOf(taskRecord.getTaskConfig().get("asrModelId").toString()); - AiModel asrModel = aiModelService.getById(asrModelId); + AiModelVO asrModel = aiModelService.getModelById(asrModelId, "ASR"); if (asrModel == null) throw new RuntimeException("ASR模型配置不存在"); String submitUrl = asrModel.getBaseUrl().endsWith("/") ? asrModel.getBaseUrl() + "api/tasks/recognition" : asrModel.getBaseUrl() + "/api/tasks/recognition"; @@ -298,7 +298,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme this.updateById(taskRecord); Long summaryModelId = Long.valueOf(taskRecord.getTaskConfig().get("summaryModelId").toString()); - AiModel llmModel = aiModelService.getById(summaryModelId); + AiModelVO llmModel = aiModelService.getModelById(summaryModelId, "LLM"); if (llmModel == null) return; String promptContent = taskRecord.getTaskConfig().get("promptContent") != null ? diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java index b8bb797..b2a0607 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java @@ -9,12 +9,12 @@ import com.imeeting.dto.biz.MeetingDTO; import com.imeeting.dto.biz.MeetingVO; import com.imeeting.dto.biz.MeetingTranscriptVO; import com.imeeting.entity.biz.Meeting; -import com.imeeting.entity.biz.AiModel; import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.PromptTemplate; import com.imeeting.entity.biz.MeetingTranscript; import com.imeeting.entity.biz.HotWord; import com.imeeting.entity.SysUser; +import com.imeeting.mapper.biz.AiTaskMapper; import com.imeeting.mapper.biz.MeetingMapper; import com.imeeting.mapper.biz.MeetingTranscriptMapper; import com.imeeting.mapper.SysUserMapper; @@ -53,6 +53,7 @@ public class MeetingServiceImpl extends ServiceImpl impl private final AiModelService aiModelService; private final PromptTemplateService promptTemplateService; private final AiTaskService aiTaskService; + private final AiTaskMapper aiTaskMapper; private final MeetingTranscriptMapper transcriptMapper; private final HotWordService hotWordService; private final SysUserMapper sysUserMapper; @@ -144,7 +145,7 @@ public class MeetingServiceImpl extends ServiceImpl impl aiTaskService.save(sumTask); eventPublisher.publishEvent(new MeetingCreatedEvent(meeting.getId())); - return toVO(meeting); + return toVO(meeting, false); } @Override @@ -175,7 +176,7 @@ public class MeetingServiceImpl extends ServiceImpl impl wrapper.orderByDesc(Meeting::getCreatedAt); Page page = this.page(new Page<>(current, size), wrapper); - List vos = page.getRecords().stream().map(this::toVO).collect(Collectors.toList()); + List vos = page.getRecords().stream().map(m -> toVO(m, false)).collect(Collectors.toList()); PageResult> result = new PageResult<>(); result.setTotal(page.getTotal()); @@ -192,7 +193,7 @@ public class MeetingServiceImpl extends ServiceImpl impl @Override public MeetingVO getDetail(Long id) { Meeting meeting = this.getById(id); - return meeting != null ? toVO(meeting) : null; + return meeting != null ? toVO(meeting, true) : null; } @Override @@ -223,6 +224,14 @@ public class MeetingServiceImpl extends ServiceImpl impl .set(label != null, MeetingTranscript::getSpeakerLabel, label)); } + @Override + @Transactional(rollbackFor = Exception.class) + public void updateMeetingParticipants(Long meetingId, String participants) { + this.update(new LambdaUpdateWrapper() + .eq(Meeting::getId, meetingId) + .set(Meeting::getParticipants, participants == null ? "" : participants)); + } + @Override @Transactional(rollbackFor = Exception.class) public void reSummary(Long meetingId, Long summaryModelId, Long promptId) { @@ -255,7 +264,10 @@ public class MeetingServiceImpl extends ServiceImpl impl java.util.Map stats = new java.util.HashMap<>(); LambdaQueryWrapper baseWrapper = new LambdaQueryWrapper().eq(Meeting::getTenantId, tenantId); if (!isAdmin) { - baseWrapper.and(w -> w.eq(Meeting::getCreatorId, userId).or().like(Meeting::getParticipants, String.valueOf(userId))); + String userIdStr = String.valueOf(userId); + baseWrapper.and(w -> w.eq(Meeting::getCreatorId, userId) + .or() + .apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr)); } stats.put("totalMeetings", this.count(baseWrapper.clone())); @@ -273,13 +285,16 @@ public class MeetingServiceImpl extends ServiceImpl impl public List getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper().eq(Meeting::getTenantId, tenantId); if (!isAdmin) { - wrapper.and(w -> w.eq(Meeting::getCreatorId, userId).or().like(Meeting::getParticipants, String.valueOf(userId))); + String userIdStr = String.valueOf(userId); + wrapper.and(w -> w.eq(Meeting::getCreatorId, userId) + .or() + .apply("',' || participants || ',' LIKE '%,' || {0} || ',%'", userIdStr)); } wrapper.orderByDesc(Meeting::getCreatedAt).last("LIMIT " + limit); - return this.list(wrapper).stream().map(this::toVO).collect(Collectors.toList()); + return this.list(wrapper).stream().map(m -> toVO(m, false)).collect(Collectors.toList()); } - private MeetingVO toVO(Meeting meeting) { + private MeetingVO toVO(Meeting meeting, boolean includeSummary) { MeetingVO vo = new MeetingVO(); vo.setId(meeting.getId()); vo.setTenantId(meeting.getTenantId()); @@ -296,15 +311,73 @@ public class MeetingServiceImpl extends ServiceImpl impl try { List userIds = Arrays.stream(meeting.getParticipants().split(",")) .map(String::trim).filter(s -> !s.isEmpty()).map(Long::valueOf).collect(Collectors.toList()); + vo.setParticipantIds(userIds); if (!userIds.isEmpty()) { List users = sysUserMapper.selectBatchIds(userIds); String names = users.stream().map(u -> u.getDisplayName() != null ? u.getDisplayName() : u.getUsername()).collect(Collectors.joining(", ")); vo.setParticipants(names); } } catch (Exception e) { + vo.setParticipantIds(Collections.emptyList()); vo.setParticipants(meeting.getParticipants()); } + } else { + vo.setParticipantIds(Collections.emptyList()); + } + if (includeSummary) { + vo.setSummaryContent(loadSummaryContent(meeting)); } return vo; } + + private String loadSummaryContent(Meeting meeting) { + try { + AiTask summaryTask = null; + if (meeting.getLatestSummaryTaskId() != null) { + summaryTask = aiTaskMapper.selectById(meeting.getLatestSummaryTaskId()); + } + if (summaryTask == null || summaryTask.getResultFilePath() == null || summaryTask.getResultFilePath().isBlank()) { + summaryTask = aiTaskMapper.selectOne(new LambdaQueryWrapper() + .eq(AiTask::getMeetingId, meeting.getId()) + .eq(AiTask::getTaskType, "SUMMARY") + .eq(AiTask::getStatus, 2) + .isNotNull(AiTask::getResultFilePath) + .orderByDesc(AiTask::getId) + .last("LIMIT 1")); + } + if (summaryTask == null || summaryTask.getResultFilePath() == null || summaryTask.getResultFilePath().isBlank()) { + return null; + } + + String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/"; + Path summaryPath = Paths.get(basePath, summaryTask.getResultFilePath().replace("\\", "/")); + if (!Files.exists(summaryPath)) { + return null; + } + + String content = Files.readString(summaryPath, StandardCharsets.UTF_8); + return stripFrontMatter(content); + } catch (Exception e) { + log.warn("Load summary content failed for meeting {}", meeting.getId(), e); + return null; + } + } + + private String stripFrontMatter(String markdown) { + if (markdown == null || markdown.isBlank()) { + return markdown; + } + if (!markdown.startsWith("---")) { + return markdown; + } + int second = markdown.indexOf("\n---", 3); + if (second < 0) { + return markdown; + } + int contentStart = second + 4; + if (contentStart < markdown.length() && markdown.charAt(contentStart) == '\n') { + contentStart++; + } + return markdown.substring(contentStart).trim(); + } } diff --git a/frontend/src/api/business/aimodel.ts b/frontend/src/api/business/aimodel.ts index 6178741..7419575 100644 --- a/frontend/src/api/business/aimodel.ts +++ b/frontend/src/api/business/aimodel.ts @@ -70,6 +70,13 @@ export const deleteAiModel = (id: number) => { ); }; +export const deleteAiModelByType = (id: number, type: 'ASR' | 'LLM') => { + return http.delete( + `/api/biz/aimodel/${id}`, + { params: { type } } + ); +}; + export const getRemoteModelList = (params: { provider: string; baseUrl: string; apiKey?: string }) => { return http.get( "/api/biz/aimodel/remote-list", diff --git a/frontend/src/api/business/meeting.ts b/frontend/src/api/business/meeting.ts index e7f6241..08b857e 100644 --- a/frontend/src/api/business/meeting.ts +++ b/frontend/src/api/business/meeting.ts @@ -9,6 +9,7 @@ export interface MeetingVO { title: string; meetingTime: string; participants: string; + participantIds?: number[]; tags: string; audioUrl: string; summaryContent: string; @@ -95,6 +96,13 @@ export const updateMeeting = (data: Partial) => { ); }; +export const updateMeetingParticipants = (params: { meetingId: number; participants: string }) => { + return http.put( + "/api/biz/meeting/participants", + params + ); +}; + export const uploadAudio = (file: File) => { const formData = new FormData(); formData.append("file", file); diff --git a/frontend/src/layouts/AppLayout.tsx b/frontend/src/layouts/AppLayout.tsx index 140743a..08bffa7 100644 --- a/frontend/src/layouts/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout.tsx @@ -59,6 +59,40 @@ export default function AppLayout() { const { logout } = useAuth(); const { load: loadPermissions, can } = usePermission(); + const buildPermissionTree = (list: SysPermission[]) => { + const map = new Map(); + const roots: (SysPermission & { children?: SysPermission[] })[] = []; + list.forEach((m) => map.set(m.permId, { ...m, children: [] })); + map.forEach((node) => { + if (node.parentId && map.has(node.parentId)) { + map.get(node.parentId)!.children!.push(node); + } else { + roots.push(node); + } + }); + const sortNodes = (nodes: (SysPermission & { children?: SysPermission[] })[]) => { + nodes.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)); + nodes.forEach((n) => n.children && sortNodes(n.children)); + }; + sortNodes(roots); + return roots; + }; + + const findFirstMenuPath = (nodes: (SysPermission & { children?: SysPermission[] })[]): string | null => { + for (const node of nodes) { + if (node.permType === "menu" && node.path) { + return node.path; + } + if (node.children && node.children.length > 0) { + const firstChildPath = findFirstMenuPath(node.children as any); + if (firstChildPath) { + return firstChildPath; + } + } + } + return null; + }; + const fetchInitialData = async () => { try { // Load tenants from localStorage @@ -91,10 +125,10 @@ export default function AppLayout() { // 如果当前是根路径,自动跳转到第一个有权限的菜单 if (location.pathname === '/' && filtered.length > 0) { - // 查找第一个类型为 'menu' 且有路径的项 - const firstMenu = filtered.find(m => m.permType === 'menu' && m.path); - if (firstMenu && firstMenu.path !== '/') { - navigate(firstMenu.path, { replace: true }); + const menuTree = buildPermissionTree(filtered); + const firstPath = findFirstMenuPath(menuTree); + if (firstPath && firstPath !== '/') { + navigate(firstPath, { replace: true }); } } } catch (e) { @@ -138,24 +172,7 @@ export default function AppLayout() { message.success(lng === 'zh-CN' ? '已切换至中文' : 'Switched to English'); }, [i18n]); - const buildMenuTree = useCallback((list: SysPermission[]) => { - const map = new Map(); - const roots: (SysPermission & { children?: SysPermission[] })[] = []; - list.forEach((m) => map.set(m.permId, { ...m, children: [] })); - map.forEach((node) => { - if (node.parentId && map.has(node.parentId)) { - map.get(node.parentId)!.children!.push(node); - } else { - roots.push(node); - } - }); - const sortNodes = (nodes: (SysPermission & { children?: SysPermission[] })[]) => { - nodes.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)); - nodes.forEach((n) => n.children && sortNodes(n.children)); - }; - sortNodes(roots); - return roots; - }, []); + const buildMenuTree = useCallback((list: SysPermission[]) => buildPermissionTree(list), []); const menuItems = useMemo(() => { const toMenuItems = (nodes: (SysPermission & { children?: SysPermission[] })[]): any[] => { diff --git a/frontend/src/pages/business/AiModels.tsx b/frontend/src/pages/business/AiModels.tsx index 995da02..2be0265 100644 --- a/frontend/src/pages/business/AiModels.tsx +++ b/frontend/src/pages/business/AiModels.tsx @@ -1,311 +1,432 @@ -import React, { useState, useEffect } from 'react'; -import { Table, Card, Button, Input, Space, Drawer, Form, Select, Tag, message, Popconfirm, Typography, Divider, Tooltip, Row, Col, InputNumber, Switch, Radio } from 'antd'; -import { PlusOutlined, EditOutlined, DeleteOutlined, SyncOutlined, SearchOutlined, SafetyCertificateOutlined, SaveOutlined, ApiOutlined, CheckCircleOutlined } from '@ant-design/icons'; -import { useDict } from '../../hooks/useDict'; -import { - getAiModelPage, - saveAiModel, - updateAiModel, - deleteAiModel, - getRemoteModelList, +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { + Button, + Card, + Col, + Divider, + Drawer, + Form, + Input, + InputNumber, + Popconfirm, + Row, + Select, + Space, + Switch, + Table, + Tabs, + Tag, + Tooltip, + Typography, + message, +} from "antd"; +import { + DeleteOutlined, + EditOutlined, + PlusOutlined, + SafetyCertificateOutlined, + SaveOutlined, + SearchOutlined, + SyncOutlined, +} from "@ant-design/icons"; +import { useDict } from "../../hooks/useDict"; +import { + AiModelDTO, AiModelVO, - AiModelDTO -} from '../../api/business/aimodel'; + deleteAiModelByType, + getAiModelPage, + getRemoteModelList, + saveAiModel, + updateAiModel, +} from "../../api/business/aimodel"; const { Option } = Select; -const { Text, Title } = Typography; +const { Title } = Typography; + +type ModelType = "ASR" | "LLM"; + +const PROVIDER_BASE_URL_MAP: Record = { + openai: "https://api.openai.com/v1", + deepseek: "https://api.deepseek.com", + aliyun: "https://dashscope.aliyuncs.com/compatible-mode/v1", + qwen: "https://dashscope.aliyuncs.com/compatible-mode/v1", + dashscope: "https://dashscope.aliyuncs.com/compatible-mode/v1", + moonshot: "https://api.moonshot.cn/v1", + kimi: "https://api.moonshot.cn/v1", + groq: "https://api.groq.com/openai/v1", +}; const AiModels: React.FC = () => { const [form] = Form.useForm(); - const { items: providers } = useDict('biz_ai_provider'); + const { items: providers } = useDict("biz_ai_provider"); + + const [activeType, setActiveType] = useState("ASR"); const [loading, setLoading] = useState(false); const [data, setData] = useState([]); const [total, setTotal] = useState(0); const [current, setCurrent] = useState(1); const [size, setSize] = useState(10); - const [searchName, setSearchName] = useState(''); - const [searchType, setSearchType] = useState(undefined); - + const [searchName, setSearchName] = useState(""); + const [drawerVisible, setDrawerVisible] = useState(false); const [editingId, setEditingId] = useState(null); const [submitLoading, setSubmitLoading] = useState(false); const [fetchLoading, setFetchLoading] = useState(false); const [remoteModels, setRemoteModels] = useState([]); - - const [modelType, setModelType] = useState<'ASR' | 'LLM'>('ASR'); - const watchedModelType = Form.useWatch('modelType', form); - const provider = Form.useWatch('provider', form); + const modelNameAutoFilledRef = useRef(false); - // Check if current user is platform admin - const isPlatformAdmin = React.useMemo(() => { + const provider = Form.useWatch("provider", form); + + const isPlatformAdmin = useMemo(() => { const profileStr = sessionStorage.getItem("userProfile"); - if (profileStr) { - const profile = JSON.parse(profileStr); - return profile.isPlatformAdmin === true; + if (!profileStr) { + return false; } - return false; + const profile = JSON.parse(profileStr); + return profile.isPlatformAdmin === true; }, []); useEffect(() => { fetchData(); - }, [current, size, searchName, searchType]); + }, [current, size, searchName, activeType]); + + useEffect(() => { + if (!drawerVisible || !provider) { + return; + } + const providerItem = providers.find((i) => i.itemValue === provider); + const providerLabel = providerItem?.itemLabel || provider; + const currentDisplayName = form.getFieldValue("modelName"); + if (!editingId && (!currentDisplayName || modelNameAutoFilledRef.current)) { + form.setFieldValue("modelName", providerLabel); + modelNameAutoFilledRef.current = true; + } + + const baseUrl = form.getFieldValue("baseUrl"); + const providerKey = String(provider).toLowerCase(); + const defaultBaseUrl = PROVIDER_BASE_URL_MAP[providerKey]; + if (!baseUrl && defaultBaseUrl) { + form.setFieldValue("baseUrl", defaultBaseUrl); + } + }, [provider, drawerVisible, editingId, providers, form]); const fetchData = async () => { setLoading(true); try { - const res = await getAiModelPage({ current, size, name: searchName, type: searchType }); - if (res.data && res.data.data && res.data.data.records) { - setData(res.data.data.records); - setTotal(res.data.data.total); - } - } catch (err) { - console.error(err); + const res = await getAiModelPage({ + current, + size, + name: searchName || undefined, + type: activeType, + }); + const pageData = (res as any)?.data?.data ?? (res as any); + setData(pageData?.records || []); + setTotal(pageData?.total || 0); } finally { setLoading(false); } }; - const handleOpenDrawer = (record?: AiModelVO) => { + const openDrawer = (record?: AiModelVO) => { setRemoteModels([]); + modelNameAutoFilledRef.current = false; if (record) { setEditingId(record.id); - setModelType(record.modelType); - form.setFieldsValue(record); - if (record.modelCode) setRemoteModels([record.modelCode]); + form.setFieldsValue({ + ...record, + modelType: record.modelType, + isDefaultChecked: record.isDefault === 1, + statusChecked: record.status === 1, + }); + if (record.modelCode) { + setRemoteModels([record.modelCode]); + } } else { setEditingId(null); - setModelType('ASR'); form.resetFields(); - form.setFieldsValue({ status: 1, isDefault: 0, temperature: 0.7, topP: 0.9, modelType: 'ASR' }); + form.setFieldsValue({ + modelType: activeType, + isDefaultChecked: false, + statusChecked: true, + temperature: 0.7, + topP: 0.9, + apiPath: "/v1/chat/completions", + }); } setDrawerVisible(true); }; const handleFetchRemote = async () => { - const vals = form.getFieldsValue(['provider', 'baseUrl', 'apiKey']); + const vals = form.getFieldsValue(["provider", "baseUrl", "apiKey"]); if (!vals.provider || !vals.baseUrl) { - message.warning('请先填写提供商和基础地址'); + message.warning("请先填写提供商和 Base URL"); return; } setFetchLoading(true); try { const res = await getRemoteModelList(vals); - // res.data 是后端的 ApiResponse, res.data.data 才是模型字符串数组 - if (res.data && Array.isArray(res.data.data)) { - setRemoteModels(res.data.data); - message.success(`成功获取 ${res.data.data.length} 个模型`); - } else { - setRemoteModels([]); - } - } catch (err) { - console.error(err); + const rawModels = (res as any)?.data?.data ?? (Array.isArray(res) ? res : []); + const models = Array.isArray(rawModels) ? rawModels : []; + setRemoteModels(models); + message.success(`获取到 ${models.length} 个模型`); } finally { setFetchLoading(false); } }; const handleSubmit = async () => { + const values = await form.validateFields(); + const payload: AiModelDTO = { + id: editingId ?? undefined, + modelType: values.modelType, + modelName: values.modelName, + provider: values.provider, + baseUrl: values.baseUrl, + apiPath: values.apiPath, + apiKey: values.apiKey, + modelCode: values.modelCode, + wsUrl: values.wsUrl, + temperature: values.temperature, + topP: values.topP, + isDefault: values.isDefaultChecked ? 1 : 0, + status: values.statusChecked ? 1 : 0, + remark: values.remark, + }; + + setSubmitLoading(true); try { - const values = await form.validateFields(); - setSubmitLoading(true); - if (editingId) { - await updateAiModel({ ...values, id: editingId }); - message.success('更新成功'); + await updateAiModel(payload); + message.success("更新成功"); } else { - await saveAiModel(values); - message.success('添加成功'); + await saveAiModel(payload); + message.success("新增成功"); } - setDrawerVisible(false); fetchData(); - } catch (err) { - console.error(err); } finally { setSubmitLoading(false); } }; + const handleDelete = async (record: AiModelVO) => { + await deleteAiModelByType(record.id, record.modelType); + message.success("删除成功"); + fetchData(); + }; + const columns = [ { - title: '模型名称', - dataIndex: 'modelName', - key: 'modelName', + title: "模型名称", + dataIndex: "modelName", + key: "modelName", render: (text: string, record: AiModelVO) => ( {text} {record.isDefault === 1 && 默认} - {record.tenantId === 0 && } + {record.tenantId === 0 && ( + + + + )} - ) + ), }, { - title: '类型', - dataIndex: 'modelType', - key: 'modelType', - render: (type: string) => {type === 'ASR' ? '语音识别' : '会议总结'} - }, - { - title: '提供商', - dataIndex: 'provider', - key: 'provider', + title: "提供商", + dataIndex: "provider", + key: "provider", render: (val: string) => { - const item = providers.find(i => i.itemValue === val); + const item = providers.find((i) => i.itemValue === val); return item ? {item.itemLabel} : val; - } + }, + }, + { title: "模型名称(code)", dataIndex: "modelCode", key: "modelCode" }, + { + title: "状态", + dataIndex: "status", + key: "status", + render: (status: number) => + status === 1 ? 启用 : 禁用, }, { - title: '模型代码', - dataIndex: 'modelCode', - key: 'modelCode', - }, - { - title: '状态', - dataIndex: 'status', - key: 'status', - render: (status: number) => status === 1 ? 启用 : 禁用 - }, - { - title: '操作', - key: 'action', - render: (_: any, record: AiModelVO) => { + title: "操作", + key: "action", + render: (_: unknown, record: AiModelVO) => { const canEdit = record.tenantId !== 0 || isPlatformAdmin; return ( - - {canEdit && } + {canEdit && ( - deleteAiModel(record.id).then(() => fetchData())}> - + + )} + {canEdit && ( + handleDelete(record)}> + )} ); - } - } + }, + }, ]; return ( -
- - setSearchType(e.target.value)} buttonStyle="solid"> - 全部 - 语音识别 - 会议总结 - - } - allowClear - onPressEnter={(e) => setSearchName((e.target as any).value)} - style={{ width: 180 }} - /> - - - }> - { setCurrent(p); setSize(s); }}} +
+ + } + allowClear + onPressEnter={(e) => setSearchName((e.target as HTMLInputElement).value)} + style={{ width: 220 }} + /> + + + } + > + { + setActiveType(key as ModelType); + setCurrent(1); + }} + items={[ + { key: "ASR", label: "ASR 模型" }, + { key: "LLM", label: "LLM 模型" }, + ]} + /> + +
{ + setCurrent(p); + setSize(s); + }, + }} /> {editingId ? '编辑模型配置' : '添加模型配置'}} width={600} - onClose={() => setDrawerVisible(false)} open={drawerVisible} + onClose={() => setDrawerVisible(false)} + title={{editingId ? "编辑模型" : "新增模型"}} extra={ - + } >
- - setModelType(e.target.value)} disabled={!!editingId}> - 语音识别 (ASR) - 会议总结 (LLM) - + + + + + {activeType === "ASR" ? "语音识别 (ASR)" : "总结模型 (LLM)"} +
- - + + { + modelNameAutoFilledRef.current = false; + }} + /> - - + {providers.map((item) => ( + ))} - + - {!(watchedModelType === 'ASR' && provider === 'Custom') && ( - - + {!(activeType === "ASR" && provider === "Custom") && ( + + )} - 业务参数 + + 模型参数 + - - - { - // 如果是数组(tags模式返回数组),取最后一个值作为最终模型编码 - return Array.isArray(value) ? value[value.length - 1] : value; - }} - > - - {remoteModels.map(m => )} + {remoteModels.map((m) => ( + + ))} - + - {modelType === 'ASR' && ( - + {activeType === "ASR" && ( + )} - {modelType === 'LLM' && ( + {activeType === "LLM" && ( <> - - + + - - + + - - + + @@ -314,24 +435,18 @@ const AiModels: React.FC = () => { - - form.setFieldsValue({ isDefault: checked ? 1 : 0 })} - checked={form.getFieldValue('isDefault') === 1} - /> + + - - form.setFieldsValue({ status: checked ? 1 : 0 })} - checked={form.getFieldValue('status') === 1} - /> + + - + diff --git a/frontend/src/pages/business/MeetingCreate.tsx b/frontend/src/pages/business/MeetingCreate.tsx index fa8ac20..46a46b7 100644 --- a/frontend/src/pages/business/MeetingCreate.tsx +++ b/frontend/src/pages/business/MeetingCreate.tsx @@ -189,7 +189,7 @@ const MeetingCreate: React.FC = () => { - {userList.map(u => ())} + {userList.map(u => ())} @@ -254,7 +254,7 @@ const MeetingCreateForm: React.FC<{ }; // --- 卡片项组件 --- -const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => void, t: any }> = ({ item, config, fetchData, t }) => { +const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => void, t: any, onEditParticipants: (meeting: MeetingVO) => void }> = ({ item, config, fetchData, t, onEditParticipants }) => { const navigate = useNavigate(); // 注入自动刷新回调 const progress = useMeetingProgress(item, () => fetchData()); @@ -267,7 +267,7 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
e.stopPropagation()}> -
navigate(`/meetings/${item.id}`)}>
+
onEditParticipants(item)}>
deleteMeeting(item.id).then(fetchData)} @@ -354,6 +354,11 @@ const Meetings: React.FC = () => { const [audioUrl, setAudioUrl] = useState(''); const [uploadProgress, setUploadProgress] = useState(0); const [fileList, setFileList] = useState([]); + const [userList, setUserList] = useState([]); + const [participantsEditVisible, setParticipantsEditVisible] = useState(false); + const [editingMeeting, setEditingMeeting] = useState(null); + const [participantsEditLoading, setParticipantsEditLoading] = useState(false); + const [participantsEditForm] = Form.useForm(); const hasRunningTasks = data.some(item => item.status === 0 || item.status === 1 || item.status === 2); useEffect(() => { fetchData(); }, [current, size, searchTitle, viewType]); @@ -362,6 +367,9 @@ const Meetings: React.FC = () => { const timer = setInterval(() => fetchData(true), 5000); return () => clearInterval(timer); }, [hasRunningTasks, current, size, searchTitle, viewType]); + useEffect(() => { + listUsers().then((users) => setUserList(users || [])).catch(() => setUserList([])); + }, []); const fetchData = async (silent = false) => { if (!silent) setLoading(true); @@ -394,6 +402,34 @@ const Meetings: React.FC = () => { } }; + const openEditParticipants = (meeting: MeetingVO) => { + setEditingMeeting(meeting); + participantsEditForm.setFieldsValue({ + participantIds: meeting.participantIds || [] + }); + setParticipantsEditVisible(true); + }; + + const handleUpdateParticipants = async () => { + if (!editingMeeting) { + return; + } + const values = await participantsEditForm.validateFields(); + const participantIds: number[] = values.participantIds || []; + setParticipantsEditLoading(true); + try { + await updateMeetingParticipants({ + meetingId: editingMeeting.id, + participants: participantIds.join(",") + }); + message.success("参会人已更新"); + setParticipantsEditVisible(false); + fetchData(); + } finally { + setParticipantsEditLoading(false); + } + }; + const statusConfig: Record = { 0: { text: '排队中', color: '#8c8c8c', bgColor: '#f5f5f5' }, 1: { text: '识别中', color: '#1890ff', bgColor: '#e6f7ff' }, @@ -430,7 +466,7 @@ const Meetings: React.FC = () => { { const config = statusConfig[item.status] || statusConfig[0]; - return ; + return ; }} locale={{ emptyText: }} />
@@ -471,6 +507,33 @@ const Meetings: React.FC = () => { /> + setParticipantsEditVisible(false)} + onOk={handleUpdateParticipants} + confirmLoading={participantsEditLoading} + destroyOnClose + > +
+ + + + +
+