feat: 重构AI模型控制器和服务,支持ASR和LLM模型类型
- 更新 `AiModelController` 中的保存、更新和删除方法,增加模型类型校验 - 重构 `AiModelServiceImpl`,分离ASR和LLM模型处理逻辑 - 更新数据库表结构,新增 `biz_asr_models` 和 `biz_llm_models` 表 - 添加前端API接口,支持按类型删除AI模型dev_na
parent
86b3616786
commit
364e49b3df
|
|
@ -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 |
|
| id | BIGSERIAL | PK | 绉熸埛ID |
|
||||||
| tenant_code | VARCHAR(64) | NOT NULL, UNIQUE | 租户编码 |
|
| tenant_code | VARCHAR(64) | NOT NULL, UNIQUE | 绉熸埛缂栫爜 |
|
||||||
| tenant_name | VARCHAR(128) | NOT NULL | 租户名称 |
|
| tenant_name | VARCHAR(128) | NOT NULL | 绉熸埛鍚嶇О |
|
||||||
| status | SMALLINT | NOT NULL, DEFAULT 1 | 状态 |
|
| status | SMALLINT | NOT NULL, DEFAULT 1 | 鐘舵€?|
|
||||||
| expire_time | TIMESTAMP(6) | | 过期时间 |
|
| expire_time | TIMESTAMP(6) | | 杩囨湡鏃堕棿 |
|
||||||
| contact_name | VARCHAR(64) | | 联系人 |
|
| contact_name | VARCHAR(64) | | 鑱旂郴浜?|
|
||||||
| contact_phone | VARCHAR(32) | | 联系电话 |
|
| contact_phone | VARCHAR(32) | | 鑱旂郴鐢佃瘽 |
|
||||||
| remark | VARCHAR(255) | | 备注 |
|
| remark | VARCHAR(255) | | 澶囨敞 |
|
||||||
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 创建时间 |
|
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 鍒涘缓鏃堕棿 |
|
||||||
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 更新时间 |
|
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 鏇存柊鏃堕棿 |
|
||||||
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 |
|
| 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 |
|
| id | BIGSERIAL | PK | 缁勭粐ID |
|
||||||
| tenant_id | BIGINT | NOT NULL | 租户ID |
|
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
|
||||||
| parent_id | BIGINT | | 父级组织ID |
|
| parent_id | BIGINT | | 鐖剁骇缁勭粐ID |
|
||||||
| org_name | VARCHAR(128) | NOT NULL | 组织名称 |
|
| org_name | VARCHAR(128) | NOT NULL | 缁勭粐鍚嶇О |
|
||||||
| org_code | VARCHAR(64) | | 组织编码 |
|
| org_code | VARCHAR(64) | | 缁勭粐缂栫爜 |
|
||||||
| org_path | VARCHAR(512) | | 组织路径 |
|
| org_path | VARCHAR(512) | | 缁勭粐璺緞 |
|
||||||
| sort_order | INTEGER | DEFAULT 0 | 排序 |
|
| sort_order | INTEGER | DEFAULT 0 | 鎺掑簭 |
|
||||||
| status | SMALLINT | DEFAULT 1 | 状态 |
|
| status | SMALLINT | DEFAULT 1 | 鐘舵€?|
|
||||||
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 创建时间 |
|
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 鍒涘缓鏃堕棿 |
|
||||||
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 更新时间 |
|
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 鏇存柊鏃堕棿 |
|
||||||
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 |
|
| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
|
||||||
|
|
||||||
外键:
|
澶栭敭锛?
|
||||||
- `fk_org_parent`:`parent_id -> sys_org(id)`
|
- `fk_org_parent`锛歚parent_id -> sys_org(id)`
|
||||||
- `fk_org_tenant`:`tenant_id -> sys_tenant(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 |
|
| user_id | BIGSERIAL | PK | 鐢ㄦ埛ID |
|
||||||
| username | VARCHAR(50) | NOT NULL, UNIQUE | 登录名 |
|
| username | VARCHAR(50) | NOT NULL, UNIQUE | 鐧诲綍鍚?|
|
||||||
| display_name | VARCHAR(50) | NOT NULL | 显示名 |
|
| display_name | VARCHAR(50) | NOT NULL | 鏄剧ず鍚?|
|
||||||
| email | VARCHAR(100) | | 邮箱 |
|
| email | VARCHAR(100) | | 閭 |
|
||||||
| phone | VARCHAR(30) | UNIQUE | 手机号 |
|
| phone | VARCHAR(30) | UNIQUE | 鎵嬫満鍙?|
|
||||||
| password_hash | VARCHAR(255) | NOT NULL | 密码哈希 |
|
| password_hash | VARCHAR(255) | NOT NULL | 瀵嗙爜鍝堝笇 |
|
||||||
| status | SMALLINT | NOT NULL, DEFAULT 1 | 状态 |
|
| status | SMALLINT | NOT NULL, DEFAULT 1 | 鐘舵€?|
|
||||||
| pwd_reset_required | SMALLINT | DEFAULT 1 | 首次登录是否需改密 |
|
| pwd_reset_required | SMALLINT | DEFAULT 1 | 棣栨鐧诲綍鏄惁闇€鏀瑰瘑 |
|
||||||
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 逻辑删除标记 |
|
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
|
||||||
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
|
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
|
||||||
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 |
|
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
|
||||||
| is_platform_admin | BOOLEAN | DEFAULT false | 是否平台管理员 |
|
| 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 |
|
| role_id | BIGSERIAL | PK | 瑙掕壊ID |
|
||||||
| tenant_id | BIGINT | NOT NULL | 租户ID |
|
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
|
||||||
| role_code | VARCHAR(50) | NOT NULL | 角色编码(租户内唯一) |
|
| role_code | VARCHAR(50) | NOT NULL | 瑙掕壊缂栫爜锛堢鎴峰唴鍞竴锛?|
|
||||||
| role_name | VARCHAR(50) | NOT NULL | 角色名称 |
|
| role_name | VARCHAR(50) | NOT NULL | 瑙掕壊鍚嶇О |
|
||||||
| status | SMALLINT | NOT NULL, DEFAULT 1 | 状态 |
|
| status | SMALLINT | NOT NULL, DEFAULT 1 | 鐘舵€?|
|
||||||
| remark | TEXT | | 备注 |
|
| remark | TEXT | | 澶囨敞 |
|
||||||
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 逻辑删除标记 |
|
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
|
||||||
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
|
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
|
||||||
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 |
|
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
|
||||||
|
|
||||||
索引:
|
绱㈠紩锛?
|
||||||
- `idx_sys_role_tenant`:`(tenant_id)`
|
- `idx_sys_role_tenant`锛歚(tenant_id)`
|
||||||
- `uk_role_code`:`UNIQUE (tenant_id, role_code) WHERE is_deleted = FALSE`
|
- `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 |
|
| id | BIGSERIAL | PK | 鍏宠仈ID |
|
||||||
| tenant_id | BIGINT | NOT NULL | 租户ID |
|
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
|
||||||
| user_id | BIGINT | NOT NULL | 用户ID |
|
| user_id | BIGINT | NOT NULL | 鐢ㄦ埛ID |
|
||||||
| role_id | BIGINT | NOT NULL | 角色ID |
|
| role_id | BIGINT | NOT NULL | 瑙掕壊ID |
|
||||||
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 逻辑删除标记 |
|
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
|
||||||
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
|
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
|
||||||
| updated_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`
|
- `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 |
|
| id | BIGSERIAL | PK | 鍏宠仈ID |
|
||||||
| user_id | BIGINT | NOT NULL | 用户ID |
|
| user_id | BIGINT | NOT NULL | 鐢ㄦ埛ID |
|
||||||
| tenant_id | BIGINT | NOT NULL | 租户ID |
|
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
|
||||||
| org_id | BIGINT | | 组织ID |
|
| org_id | BIGINT | | 缁勭粐ID |
|
||||||
| status | SMALLINT | DEFAULT 1 | 状态 |
|
| status | SMALLINT | DEFAULT 1 | 鐘舵€?|
|
||||||
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 |
|
| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
|
||||||
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
|
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
|
||||||
| updated_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 |
|
| perm_id | BIGSERIAL | PK | 鏉冮檺ID |
|
||||||
| parent_id | BIGINT | | 父级权限ID |
|
| parent_id | BIGINT | | 鐖剁骇鏉冮檺ID |
|
||||||
| name | VARCHAR(100) | NOT NULL | 权限名称 |
|
| name | VARCHAR(100) | NOT NULL | 鏉冮檺鍚嶇О |
|
||||||
| code | VARCHAR(100) | NOT NULL, UNIQUE | 权限编码 |
|
| code | VARCHAR(100) | NOT NULL, UNIQUE | 鏉冮檺缂栫爜 |
|
||||||
| perm_type | VARCHAR(20) | NOT NULL | 权限类型 |
|
| perm_type | VARCHAR(20) | NOT NULL | 鏉冮檺绫诲瀷 |
|
||||||
| level | INTEGER | NOT NULL | 层级 |
|
| level | INTEGER | NOT NULL | 灞傜骇 |
|
||||||
| path | VARCHAR(255) | | 路径 |
|
| path | VARCHAR(255) | | 璺緞 |
|
||||||
| component | VARCHAR(255) | | 组件 |
|
| component | VARCHAR(255) | | 缁勪欢 |
|
||||||
| icon | VARCHAR(100) | | 图标 |
|
| icon | VARCHAR(100) | | 鍥炬爣 |
|
||||||
| sort_order | INTEGER | NOT NULL, DEFAULT 0 | 排序 |
|
| sort_order | INTEGER | NOT NULL, DEFAULT 0 | 鎺掑簭 |
|
||||||
| is_visible | SMALLINT | NOT NULL, DEFAULT 1 | 是否可见 |
|
| is_visible | SMALLINT | NOT NULL, DEFAULT 1 | 鏄惁鍙 |
|
||||||
| status | SMALLINT | NOT NULL, DEFAULT 1 | 状态 |
|
| status | SMALLINT | NOT NULL, DEFAULT 1 | 鐘舵€?|
|
||||||
| description | TEXT | | 描述 |
|
| description | TEXT | | 鎻忚堪 |
|
||||||
| meta | JSONB | | 扩展信息 |
|
| meta | JSONB | | 鎵╁睍淇℃伅 |
|
||||||
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 逻辑删除标记 |
|
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
|
||||||
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
|
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
|
||||||
| updated_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 |
|
| dict_type_id | BIGSERIAL | PK | 绫诲瀷ID |
|
||||||
| type_code | VARCHAR(50) | NOT NULL, UNIQUE | 类型编码 |
|
| type_code | VARCHAR(50) | NOT NULL, UNIQUE | 绫诲瀷缂栫爜 |
|
||||||
| type_name | VARCHAR(50) | NOT NULL | 类型名称 |
|
| type_name | VARCHAR(50) | NOT NULL | 绫诲瀷鍚嶇О |
|
||||||
| status | SMALLINT | DEFAULT 1 | 状态 |
|
| status | SMALLINT | DEFAULT 1 | 鐘舵€?|
|
||||||
| remark | TEXT | | 备注 |
|
| remark | TEXT | | 澶囨敞 |
|
||||||
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 |
|
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鍒涘缓鏃堕棿 |
|
||||||
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 更新时间 |
|
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鏇存柊鏃堕棿 |
|
||||||
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 |
|
| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
|
||||||
|
|
||||||
**初始化数据:**
|
**鍒濆鍖栨暟鎹細**
|
||||||
- `sys_common_status`: 通用状态 (启用/禁用)
|
- `sys_common_status`: 閫氱敤鐘舵€?(鍚敤/绂佺敤)
|
||||||
- `sys_permission_type`: 权限类型 (目录/菜单/按钮)
|
- `sys_permission_type`: 鏉冮檺绫诲瀷 (鐩綍/鑿滃崟/鎸夐挳)
|
||||||
- `sys_common_visibility`: 可见性 (显示/隐藏)
|
- `sys_common_visibility`: 鍙鎬?(鏄剧ず/闅愯棌)
|
||||||
- `sys_permission_level`: 权限层级 (1, 2, 3)
|
- `sys_permission_level`: 鏉冮檺灞傜骇 (1, 2, 3)
|
||||||
- `sys_log_type`: 日志类型 (LOGIN/OPERATION)
|
- `sys_log_type`: 鏃ュ織绫诲瀷 (LOGIN/OPERATION)
|
||||||
- `sys_param_type`: 参数类型 (String/Number/Boolean/JSON)
|
- `sys_param_type`: 鍙傛暟绫诲瀷 (String/Number/Boolean/JSON)
|
||||||
- `sys_log_status`: 操作状态 (成功/失败)
|
- `sys_log_status`: 鎿嶄綔鐘舵€?(鎴愬姛/澶辫触)
|
||||||
|
|
||||||
### 2.3 `sys_dict_item`(字典项表)
|
### 2.3 `sys_dict_item`锛堝瓧鍏搁」琛級
|
||||||
| 字段 | 类型 | 约束 | 说明 |
|
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| dict_item_id | BIGSERIAL | PK | 字典项ID |
|
| dict_item_id | BIGSERIAL | PK | 瀛楀吀椤笽D |
|
||||||
| type_code | VARCHAR(50) | NOT NULL | 字典类型编码 |
|
| type_code | VARCHAR(50) | NOT NULL | 瀛楀吀绫诲瀷缂栫爜 |
|
||||||
| item_label | VARCHAR(100) | NOT NULL | 展示文本 |
|
| item_label | VARCHAR(100) | NOT NULL | 灞曠ず鏂囨湰 |
|
||||||
| item_value | VARCHAR(100) | NOT NULL | 存储值 |
|
| item_value | VARCHAR(100) | NOT NULL | 瀛樺偍鍊?|
|
||||||
| sort_order | INT | DEFAULT 0 | 排序 |
|
| sort_order | INT | DEFAULT 0 | 鎺掑簭 |
|
||||||
| status | SMALLINT | DEFAULT 1 | 状态 |
|
| status | SMALLINT | DEFAULT 1 | 鐘舵€?|
|
||||||
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 |
|
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鍒涘缓鏃堕棿 |
|
||||||
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 更新时间 |
|
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鏇存柊鏃堕棿 |
|
||||||
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 |
|
| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
|
||||||
|
|
||||||
索引:
|
绱㈠紩锛?
|
||||||
- `idx_dict_item_type`:`(type_code)`
|
- `idx_dict_item_type`锛歚(type_code)`
|
||||||
- `uk_dict_item_value`:`UNIQUE (type_code, item_value)`
|
- `uk_dict_item_value`锛歚UNIQUE (type_code, item_value)`
|
||||||
|
|
||||||
### 2.4 `sys_param`(系统参数表)
|
### 2.4 `sys_param`锛堢郴缁熷弬鏁拌〃锛?
|
||||||
| 字段 | 类型 | 约束 | 说明 |
|
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| id | BIGSERIAL | PK | 参数ID |
|
| id | BIGSERIAL | PK | 鍙傛暟ID |
|
||||||
| param_key | VARCHAR(100) | NOT NULL, UNIQUE | 参数键 |
|
| param_key | VARCHAR(100) | NOT NULL, UNIQUE | 鍙傛暟閿?|
|
||||||
| param_value | TEXT | NOT NULL | 参数值 |
|
| param_value | TEXT | NOT NULL | 鍙傛暟鍊?|
|
||||||
| param_type | VARCHAR(20) | NOT NULL | 参数类型 |
|
| param_type | VARCHAR(20) | NOT NULL | 鍙傛暟绫诲瀷 |
|
||||||
| is_system | SMALLINT | DEFAULT 0 | 是否系统内置 |
|
| is_system | SMALLINT | DEFAULT 0 | 鏄惁绯荤粺鍐呯疆 |
|
||||||
| status | SMALLINT | DEFAULT 1 | 状态 |
|
| status | SMALLINT | DEFAULT 1 | 鐘舵€?|
|
||||||
| description | TEXT | | 描述 |
|
| description | TEXT | | 鎻忚堪 |
|
||||||
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 |
|
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鍒涘缓鏃堕棿 |
|
||||||
|
|
||||||
## 3. 日志(租户隔离)
|
## 3. 鏃ュ織锛堢鎴烽殧绂伙級
|
||||||
|
|
||||||
### 3.1 `sys_log`(系统日志表)
|
### 3.1 `sys_log`锛堢郴缁熸棩蹇楄〃锛?
|
||||||
| 字段 | 类型 | 约束 | 说明 |
|
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| id | BIGSERIAL | PK | 日志ID |
|
| id | BIGSERIAL | PK | 鏃ュ織ID |
|
||||||
| tenant_id | BIGINT | NOT NULL, DEFAULT 0 | 租户ID |
|
| tenant_id | BIGINT | NOT NULL, DEFAULT 0 | 绉熸埛ID |
|
||||||
| user_id | BIGINT | | 用户ID |
|
| user_id | BIGINT | | 鐢ㄦ埛ID |
|
||||||
| username | VARCHAR(50) | | 用户名 |
|
| username | VARCHAR(50) | | 鐢ㄦ埛鍚?|
|
||||||
| log_type | VARCHAR(20) | | 日志类型(如 LOGIN、OPERATION) |
|
| log_type | VARCHAR(20) | | 鏃ュ織绫诲瀷锛堝 LOGIN銆丱PERATION锛?|
|
||||||
| operation | VARCHAR(100) | NOT NULL | 操作描述 |
|
| operation | VARCHAR(100) | NOT NULL | 鎿嶄綔鎻忚堪 |
|
||||||
| method | VARCHAR(200) | | 方法 |
|
| method | VARCHAR(200) | | 鏂规硶 |
|
||||||
| params | TEXT | | 请求参数 |
|
| params | TEXT | | 璇锋眰鍙傛暟 |
|
||||||
| status | SMALLINT | DEFAULT 1 | 状态 |
|
| status | SMALLINT | DEFAULT 1 | 鐘舵€?|
|
||||||
| ip | VARCHAR(50) | | IP |
|
| ip | VARCHAR(50) | | IP |
|
||||||
| duration | BIGINT | | 耗时(ms) |
|
| duration | BIGINT | | 鑰楁椂锛坢s锛?|
|
||||||
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 |
|
| 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 |
|
| id | BIGINT | PK | 鍥哄畾涓?1 |
|
||||||
| project_name | VARCHAR(128) | NOT NULL | 项目名称 |
|
| project_name | VARCHAR(128) | NOT NULL | 椤圭洰鍚嶇О |
|
||||||
| logo_url | VARCHAR(512) | | Logo URL |
|
| logo_url | VARCHAR(512) | | Logo URL |
|
||||||
| icon_url | VARCHAR(512) | | Icon URL |
|
| icon_url | VARCHAR(512) | | Icon URL |
|
||||||
| login_bg_url | VARCHAR(512) | | 登录页背景 |
|
| login_bg_url | VARCHAR(512) | | 鐧诲綍椤佃儗鏅?|
|
||||||
| icp_info | VARCHAR(128) | | 备案信息 |
|
| icp_info | VARCHAR(128) | | 澶囨淇℃伅 |
|
||||||
| copyright_info | VARCHAR(255) | | 版权信息 |
|
| copyright_info | VARCHAR(255) | | 鐗堟潈淇℃伅 |
|
||||||
| system_description | TEXT | | 系统描述 |
|
| system_description | TEXT | | 绯荤粺鎻忚堪 |
|
||||||
| created_at | TIMESTAMP | NOT NULL | 创建时间 |
|
| created_at | TIMESTAMP | NOT NULL | 鍒涘缓鏃堕棿 |
|
||||||
| updated_at | TIMESTAMP | NOT NULL | 更新时间 |
|
| updated_at | TIMESTAMP | NOT NULL | 鏇存柊鏃堕棿 |
|
||||||
|
|
||||||
## 5. 业务模块
|
## 5. 涓氬姟妯″潡
|
||||||
|
|
||||||
### 5.1 `biz_speakers`(声纹发言人表)
|
### 5.1 `biz_speakers`锛堝0绾瑰彂瑷€浜鸿〃锛?
|
||||||
| 字段 | 类型 | 约束 | 说明 |
|
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| id | BIGSERIAL | PK | 主键ID |
|
| id | BIGSERIAL | PK | 涓婚敭ID |
|
||||||
| tenant_id | BIGINT | NOT NULL | 租户ID |
|
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
|
||||||
| user_id | BIGINT | | 关联系统用户ID |
|
| user_id | BIGINT | | 鍏宠仈绯荤粺鐢ㄦ埛ID |
|
||||||
| name | VARCHAR(100) | NOT NULL | 发言人姓名 |
|
| name | VARCHAR(100) | NOT NULL | 鍙戣█浜哄鍚?|
|
||||||
| voice_path | VARCHAR(512) | | 原始文件路径 |
|
| voice_path | VARCHAR(512) | | 鍘熷鏂囦欢璺緞 |
|
||||||
| voice_ext | VARCHAR(10) | | 文件后缀 |
|
| voice_ext | VARCHAR(10) | | 鏂囦欢鍚庣紑 |
|
||||||
| voice_size | BIGINT | | 文件大小 |
|
| voice_size | BIGINT | | 鏂囦欢澶у皬 |
|
||||||
| status | SMALLINT | DEFAULT 1 | 状态 (1:已保存, 2:注册中, 3:已注册) |
|
| status | SMALLINT | DEFAULT 1 | 鐘舵€?(1:宸蹭繚瀛? 2:娉ㄥ唽涓? 3:宸叉敞鍐? |
|
||||||
| embedding | VECTOR | | 声纹特征向量 |
|
| embedding | VECTOR | | 澹扮汗鐗瑰緛鍚戦噺 |
|
||||||
| remark | TEXT | | 备注 |
|
| remark | TEXT | | 澶囨敞 |
|
||||||
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
|
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
|
||||||
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 |
|
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
|
||||||
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 |
|
| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎 |
|
||||||
|
|
||||||
索引:
|
绱㈠紩锛?
|
||||||
- `idx_speaker_tenant`: `(tenant_id)`
|
- `idx_speaker_tenant`: `(tenant_id)`
|
||||||
- `idx_speaker_user`: `(user_id) WHERE is_deleted = 0`
|
- `idx_speaker_user`: `(user_id) WHERE is_deleted = 0`
|
||||||
|
|
||||||
### 5.2 `biz_hot_words`(热词管理表)
|
### 5.2 `biz_hot_words`锛堢儹璇嶇鐞嗚〃锛?
|
||||||
| 字段 | 类型 | 约束 | 说明 |
|
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| id | BIGSERIAL | PK | 主键ID |
|
| id | BIGSERIAL | PK | 涓婚敭ID |
|
||||||
| tenant_id | BIGINT | NOT NULL | 租户ID |
|
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
|
||||||
| word | VARCHAR(100) | NOT NULL | 热词原文 |
|
| word | VARCHAR(100) | NOT NULL | 鐑瘝鍘熸枃 |
|
||||||
| pinyin_list | JSONB | | 拼音数组 |
|
| pinyin_list | JSONB | | 鎷奸煶鏁扮粍 |
|
||||||
| match_strategy | SMALLINT | DEFAULT 1 | 匹配策略 (1:精确, 2:模糊) |
|
| match_strategy | SMALLINT | DEFAULT 1 | 鍖归厤绛栫暐 (1:绮剧‘, 2:妯$硦) |
|
||||||
| category | VARCHAR(50) | | 类别 (人名、术语等) |
|
| category | VARCHAR(50) | | 绫诲埆 (浜哄悕銆佹湳璇瓑) |
|
||||||
| weight | INTEGER | DEFAULT 10 | 权重 (1-100) |
|
| weight | INTEGER | DEFAULT 10 | 鏉冮噸 (1-100) |
|
||||||
| status | SMALLINT | DEFAULT 1 | 状态 (1:启用, 0:禁用) |
|
| status | SMALLINT | DEFAULT 1 | 鐘舵€?(1:鍚敤, 0:绂佺敤) |
|
||||||
| is_synced | SMALLINT | DEFAULT 0 | 已同步第三方标记 |
|
| is_synced | SMALLINT | DEFAULT 0 | 宸插悓姝ョ涓夋柟鏍囪 |
|
||||||
| remark | TEXT | | 备注 |
|
| remark | TEXT | | 澶囨敞 |
|
||||||
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
|
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
|
||||||
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 |
|
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
|
||||||
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 |
|
| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎 |
|
||||||
|
|
||||||
索引:
|
绱㈠紩锛?
|
||||||
- `idx_hotword_tenant`: `(tenant_id)`
|
- `idx_hotword_tenant`: `(tenant_id)`
|
||||||
- `idx_hotword_word`: `(word) WHERE is_deleted = 0`
|
- `idx_hotword_word`: `(word) WHERE is_deleted = 0`
|
||||||
|
|
||||||
### 5.3 `biz_prompt_templates`(提示词模板表)
|
### 5.3 `biz_prompt_templates`锛堟彁绀鸿瘝妯℃澘琛級
|
||||||
| 字段 | 类型 | 约束 | 说明 |
|
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| id | BIGSERIAL | PK | 主键ID |
|
| id | BIGSERIAL | PK | 涓婚敭ID |
|
||||||
| tenant_id | BIGINT | NOT NULL | 租户ID |
|
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
|
||||||
| template_name | VARCHAR(100) | NOT NULL | 模板名称 |
|
| template_name | VARCHAR(100) | NOT NULL | 妯℃澘鍚嶇О |
|
||||||
| category | VARCHAR(20) | | 分类 (字典: biz_prompt_category) |
|
| category | VARCHAR(20) | | 鍒嗙被 (瀛楀吀: biz_prompt_category) |
|
||||||
| is_system | SMALLINT | DEFAULT 0 | 是否预置 (1:是, 0:否) |
|
| is_system | SMALLINT | DEFAULT 0 | 鏄惁棰勭疆 (1:鏄? 0:鍚? |
|
||||||
| creator_id | BIGINT | | 创建人ID |
|
| creator_id | BIGINT | | 鍒涘缓浜篒D |
|
||||||
| tags | JSONB | | 标签数组 |
|
| tags | JSONB | | 鏍囩鏁扮粍 |
|
||||||
| usage_count | INTEGER | DEFAULT 0 | 使用次数 |
|
| usage_count | INTEGER | DEFAULT 0 | 浣跨敤娆℃暟 |
|
||||||
| prompt_content | TEXT | NOT NULL | 提示词内容 |
|
| prompt_content | TEXT | NOT NULL | 鎻愮ず璇嶅唴瀹?|
|
||||||
| status | SMALLINT | DEFAULT 1 | 状态 (1:启用, 0:禁用) |
|
| status | SMALLINT | DEFAULT 1 | 鐘舵€?(1:鍚敤, 0:绂佺敤) |
|
||||||
| remark | VARCHAR(255) | | 备注 |
|
| remark | VARCHAR(255) | | 澶囨敞 |
|
||||||
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
|
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
|
||||||
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 |
|
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
|
||||||
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 |
|
| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎 |
|
||||||
|
|
||||||
索引:
|
绱㈠紩锛?
|
||||||
- `idx_prompt_tenant`: `(tenant_id)`
|
- `idx_prompt_tenant`: `(tenant_id)`
|
||||||
- `idx_prompt_system`: `(is_system) WHERE is_deleted = 0`
|
- `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 |
|
| id | BIGSERIAL | PK | 主键ID |
|
||||||
| tenant_id | BIGINT | NOT NULL | 租户ID |
|
| tenant_id | BIGINT | NOT NULL | 租户ID |
|
||||||
| model_type | VARCHAR(20) | NOT NULL | ASR (语音) 或 LLM (总结) |
|
| model_name | VARCHAR(100) | NOT NULL | 模型显示名称 |
|
||||||
| model_name | VARCHAR(100) | NOT NULL | 自定义名称 |
|
| provider | VARCHAR(50) | | 提供商 |
|
||||||
| provider | VARCHAR(50) | | 提供商 (Aliyun, OpenAI等) |
|
| base_url | VARCHAR(255) | | 接口基础地址 |
|
||||||
| base_url | VARCHAR(255) | | 基础请求地址 |
|
| api_key | VARCHAR(255) | | API 密钥 |
|
||||||
| model_code | VARCHAR(100) | | 模型代码 |
|
| model_code | VARCHAR(100) | | 模型代码 |
|
||||||
| ws_url | VARCHAR(255) | | WebSocket 地址 (ASR) |
|
| ws_url | VARCHAR(255) | | WebSocket 地址 |
|
||||||
| temperature | DECIMAL | DEFAULT 0.7 | 随机性 (LLM) |
|
| media_config | JSON/TEXT | | 媒体参数 |
|
||||||
| media_config | JSONB | | 采样率、协议等 |
|
|
||||||
| is_default | SMALLINT | DEFAULT 0 | 默认模型标记 |
|
| is_default | SMALLINT | DEFAULT 0 | 默认模型标记 |
|
||||||
| status | SMALLINT | DEFAULT 1 | 启用状态 |
|
| status | SMALLINT | DEFAULT 1 | 状态 |
|
||||||
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
|
| remark | VARCHAR(255) | | 备注 |
|
||||||
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 |
|
| created_at | TIMESTAMP(6) | NOT NULL | 创建时间 |
|
||||||
|
| updated_at | TIMESTAMP(6) | NOT NULL | 更新时间 |
|
||||||
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 |
|
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 |
|
||||||
|
|
||||||
索引:
|
索引:
|
||||||
- `idx_aimodel_tenant`: `(tenant_id)`
|
- `idx_asr_model_tenant`: `(tenant_id)`
|
||||||
- `idx_aimodel_type`: `(model_type, is_default) WHERE is_deleted = 0`
|
- `idx_asr_model_default`: `(is_default) WHERE is_deleted = 0`
|
||||||
|
|
||||||
### 5.5 `biz_meetings`(会议主表)
|
### 5.5 `biz_llm_models`(LLM 模型管理表)
|
||||||
| 字段 | 类型 | 约束 | 说明 |
|
| 字段 | 类型 | 约束 | 说明 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| id | BIGSERIAL | PK | 主键ID |
|
| id | BIGSERIAL | PK | 主键ID |
|
||||||
| tenant_id | BIGINT | NOT NULL | 租户ID |
|
| tenant_id | BIGINT | NOT NULL | 租户ID |
|
||||||
| title | VARCHAR(200) | NOT NULL | 会议标题 |
|
| model_name | VARCHAR(100) | NOT NULL | 模型显示名称 |
|
||||||
| audio_url | VARCHAR(500) | | 专属音频路径 |
|
| provider | VARCHAR(50) | | 提供商 |
|
||||||
| latest_summary_task_id | BIGINT | | 最新成功的总结任务ID |
|
| base_url | VARCHAR(255) | | 接口基础地址 |
|
||||||
| status | SMALLINT | DEFAULT 0 | 0:待处理, 1:识别中, 2:总结中, 3:已完成, 4:失败 |
|
| 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`(转录明细表)
|
索引:
|
||||||
| 字段 | 类型 | 约束 | 说明 |
|
- `idx_llm_model_tenant`: `(tenant_id)`
|
||||||
| --- | --- | --- | --- |
|
- `idx_llm_model_default`: `(is_default) WHERE is_deleted = 0`
|
||||||
| 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`(AI 任务流水表)
|
### 5.6 `biz_meetings`锛堜細璁富琛級
|
||||||
| 字段 | 类型 | 约束 | 说明 |
|
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| id | BIGSERIAL | PK | 主键ID |
|
| id | BIGSERIAL | PK | 涓婚敭ID |
|
||||||
| meeting_id | BIGINT | NOT NULL | 关联会议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 |
|
| task_type | VARCHAR(20) | | ASR / SUMMARY |
|
||||||
| request_data | JSONB | | 请求原始数据 |
|
| request_data | JSONB | | 璇锋眰鍘熷鏁版嵁 |
|
||||||
| response_data | JSONB | | 响应原始数据 |
|
| response_data | JSONB | | 鍝嶅簲鍘熷鏁版嵁 |
|
||||||
| task_config | TEXT | | **[快照]** 任务配置(模型ID、提示词模板等) |
|
| task_config | TEXT | | **[蹇収]** 浠诲姟閰嶇疆(妯″瀷ID銆佹彁绀鸿瘝妯℃澘绛? |
|
||||||
| result_file_path | VARCHAR(500) | | 结果文件相对路径 (如MD总结文件) |
|
| result_file_path | VARCHAR(500) | | 缁撴灉鏂囦欢鐩稿璺緞 (濡侻D鎬荤粨鏂囦欢) |
|
||||||
| status | SMALLINT | | 0:排队, 1:处理中, 2:成功, 3:失败 |
|
| status | SMALLINT | | 0:鎺掗槦, 1:澶勭悊涓? 2:鎴愬姛, 3:澶辫触 |
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
-- PostgreSQL Database Schema for iMeeting (Multi-tenant)
|
-- PostgreSQL Database Schema for iMeeting (Multi-tenant)
|
||||||
-- 0 为系统预留租户 ID
|
-- 0 为系统预留租户 ID
|
||||||
|
|
||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
|
|
@ -299,33 +299,52 @@ COMMENT ON TABLE biz_prompt_templates IS '会议总结提示词模板表';
|
||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
-- 9. 业务模块 - AI 模型管理
|
-- 9. 业务模块 - AI 模型管理
|
||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
DROP TABLE IF EXISTS biz_ai_models CASCADE;
|
DROP TABLE IF EXISTS biz_asr_models CASCADE;
|
||||||
CREATE TABLE biz_ai_models (
|
CREATE TABLE biz_asr_models (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
tenant_id BIGINT NOT NULL DEFAULT 0, -- 租户ID
|
tenant_id BIGINT NOT NULL DEFAULT 0,
|
||||||
model_type VARCHAR(20) NOT NULL, -- 类型: ASR, LLM
|
model_name VARCHAR(100) NOT NULL,
|
||||||
model_name VARCHAR(100) NOT NULL, -- 模型显示名称
|
provider VARCHAR(50),
|
||||||
provider VARCHAR(50), -- 提供商 (Aliyun, OpenAI, Tencent等)
|
base_url VARCHAR(255),
|
||||||
base_url VARCHAR(255), -- 接口基础地址
|
api_key VARCHAR(255),
|
||||||
api_path VARCHAR(100), -- API路径
|
model_code VARCHAR(100),
|
||||||
api_key VARCHAR(255), -- API密钥 (加密存储)
|
ws_url VARCHAR(255),
|
||||||
model_code VARCHAR(100), -- 模型真实编码 (如 gpt-4o)
|
media_config text,
|
||||||
ws_url VARCHAR(255), -- WebSocket 地址 (ASR 专用)
|
is_default SMALLINT DEFAULT 0,
|
||||||
temperature DECIMAL(3,2) DEFAULT 0.7, -- LLM 温度
|
status SMALLINT DEFAULT 1,
|
||||||
top_p DECIMAL(3,2) DEFAULT 0.9, -- LLM 核采样
|
remark VARCHAR(255),
|
||||||
media_config text, -- 媒体参数 (采样率、声道等)
|
|
||||||
is_default SMALLINT DEFAULT 0, -- 是否默认
|
|
||||||
status SMALLINT DEFAULT 1, -- 状态: 1:启用, 0:禁用
|
|
||||||
remark VARCHAR(255), -- 备注
|
|
||||||
created_at TIMESTAMP(6) NOT NULL DEFAULT now(),
|
created_at TIMESTAMP(6) NOT NULL DEFAULT now(),
|
||||||
updated_at TIMESTAMP(6) NOT NULL DEFAULT now(),
|
updated_at TIMESTAMP(6) NOT NULL DEFAULT now(),
|
||||||
is_deleted SMALLINT NOT NULL DEFAULT 0
|
is_deleted SMALLINT NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_aimodel_tenant ON biz_ai_models (tenant_id);
|
DROP TABLE IF EXISTS biz_llm_models CASCADE;
|
||||||
CREATE INDEX idx_aimodel_type ON biz_ai_models (model_type, is_default) WHERE is_deleted = 0;
|
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. 业务模块 - 会议主表
|
-- 10. 业务模块 - 会议主表
|
||||||
|
|
|
||||||
|
|
@ -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());
|
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import com.imeeting.common.ApiResponse;
|
||||||
import com.imeeting.common.PageResult;
|
import com.imeeting.common.PageResult;
|
||||||
import com.imeeting.dto.biz.AiModelDTO;
|
import com.imeeting.dto.biz.AiModelDTO;
|
||||||
import com.imeeting.dto.biz.AiModelVO;
|
import com.imeeting.dto.biz.AiModelVO;
|
||||||
import com.imeeting.entity.biz.AiModel;
|
|
||||||
import com.imeeting.security.LoginUser;
|
import com.imeeting.security.LoginUser;
|
||||||
import com.imeeting.service.biz.AiModelService;
|
import com.imeeting.service.biz.AiModelService;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
|
@ -26,38 +25,50 @@ public class AiModelController {
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
public ApiResponse<AiModelVO> save(@RequestBody AiModelDTO dto) {
|
public ApiResponse<AiModelVO> 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));
|
return ApiResponse.ok(aiModelService.saveModel(dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping
|
@PutMapping
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
public ApiResponse<AiModelVO> update(@RequestBody AiModelDTO dto) {
|
public ApiResponse<AiModelVO> 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();
|
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||||
AiModel existing = aiModelService.getById(dto.getId());
|
AiModelVO existing = aiModelService.getModelById(dto.getId(), dto.getModelType());
|
||||||
if (existing == null) return ApiResponse.error("模型不存在");
|
if (existing == null) {
|
||||||
|
return ApiResponse.error("模型不存在");
|
||||||
if (Long.valueOf(0).equals(existing.getTenantId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
|
}
|
||||||
|
|
||||||
|
if (Long.valueOf(0L).equals(existing.getTenantId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
|
||||||
return ApiResponse.error("无权修改系统级模型");
|
return ApiResponse.error("无权修改系统级模型");
|
||||||
}
|
}
|
||||||
|
|
||||||
return ApiResponse.ok(aiModelService.updateModel(dto));
|
return ApiResponse.ok(aiModelService.updateModel(dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
public ApiResponse<Boolean> delete(@PathVariable Long id) {
|
public ApiResponse<Boolean> delete(@PathVariable Long id, @RequestParam String type) {
|
||||||
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
if (type == null || type.isBlank()) {
|
||||||
AiModel existing = aiModelService.getById(id);
|
return ApiResponse.error("模型类型不能为空");
|
||||||
if (existing == null) return ApiResponse.ok(true);
|
}
|
||||||
|
|
||||||
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.error("无权删除系统级模型");
|
||||||
}
|
}
|
||||||
|
|
||||||
return ApiResponse.ok(aiModelService.removeById(id));
|
return ApiResponse.ok(aiModelService.removeModelById(id, type));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
|
|
@ -67,7 +78,6 @@ public class AiModelController {
|
||||||
@RequestParam(defaultValue = "10") Integer size,
|
@RequestParam(defaultValue = "10") Integer size,
|
||||||
@RequestParam(required = false) String name,
|
@RequestParam(required = false) String name,
|
||||||
@RequestParam(required = false) String type) {
|
@RequestParam(required = false) String type) {
|
||||||
|
|
||||||
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||||
return ApiResponse.ok(aiModelService.pageModels(current, size, name, type, loginUser.getTenantId()));
|
return ApiResponse.ok(aiModelService.pageModels(current, size, name, type, loginUser.getTenantId()));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,28 @@ public class MeetingController {
|
||||||
return ApiResponse.ok(true);
|
return ApiResponse.ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping("/participants")
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
public ApiResponse<Boolean> updateParticipants(@RequestBody Map<String, Object> 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")
|
@PostMapping("/re-summary")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
public ApiResponse<Boolean> reSummary(@RequestBody Map<String, Object> params) {
|
public ApiResponse<Boolean> reSummary(@RequestBody Map<String, Object> params) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.imeeting.dto.biz;
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class MeetingVO {
|
public class MeetingVO {
|
||||||
|
|
@ -16,6 +17,7 @@ public class MeetingVO {
|
||||||
private LocalDateTime meetingTime;
|
private LocalDateTime meetingTime;
|
||||||
|
|
||||||
private String participants;
|
private String participants;
|
||||||
|
private List<Long> participantIds;
|
||||||
private String tags;
|
private String tags;
|
||||||
private String audioUrl;
|
private String audioUrl;
|
||||||
private String summaryContent;
|
private String summaryContent;
|
||||||
|
|
|
||||||
|
|
@ -9,36 +9,27 @@ import com.imeeting.entity.BaseEntity;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.EqualsAndHashCode;
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@EqualsAndHashCode(callSuper = true)
|
@EqualsAndHashCode(callSuper = true)
|
||||||
@TableName(value = "biz_ai_models", autoResultMap = true)
|
@TableName(value = "biz_asr_models", autoResultMap = true)
|
||||||
public class AiModel extends BaseEntity {
|
public class AsrModel extends BaseEntity {
|
||||||
@TableId(value = "id", type = IdType.AUTO)
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
private String modelType;
|
|
||||||
|
|
||||||
private String modelName;
|
private String modelName;
|
||||||
|
|
||||||
private String provider;
|
private String provider;
|
||||||
|
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
|
|
||||||
private String apiPath;
|
|
||||||
|
|
||||||
private String apiKey;
|
private String apiKey;
|
||||||
|
|
||||||
private String modelCode;
|
private String modelCode;
|
||||||
|
|
||||||
private String wsUrl;
|
private String wsUrl;
|
||||||
|
|
||||||
private BigDecimal temperature;
|
|
||||||
|
|
||||||
private BigDecimal topP;
|
|
||||||
|
|
||||||
@TableField(typeHandler = JacksonTypeHandler.class)
|
@TableField(typeHandler = JacksonTypeHandler.class)
|
||||||
private Map<String, Object> mediaConfig;
|
private Map<String, Object> mediaConfig;
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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<Meeting> pendingMeetings = meetingMapper.selectList(new LambdaQueryWrapper<Meeting>()
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
package com.imeeting.mapper.biz;
|
package com.imeeting.mapper.biz;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
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;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface AiModelMapper extends BaseMapper<AiModel> {
|
public interface AsrModelMapper extends BaseMapper<AsrModel> {
|
||||||
}
|
}
|
||||||
|
|
@ -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<LlmModel> {
|
||||||
|
}
|
||||||
|
|
@ -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<PromptTemplateUserConfig> {
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
package com.imeeting.service.biz;
|
package com.imeeting.service.biz;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.extension.service.IService;
|
|
||||||
import com.imeeting.common.PageResult;
|
import com.imeeting.common.PageResult;
|
||||||
import com.imeeting.dto.biz.AiModelDTO;
|
import com.imeeting.dto.biz.AiModelDTO;
|
||||||
import com.imeeting.dto.biz.AiModelVO;
|
import com.imeeting.dto.biz.AiModelVO;
|
||||||
import com.imeeting.entity.biz.AiModel;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public interface AiModelService extends IService<AiModel> {
|
public interface AiModelService {
|
||||||
AiModelVO saveModel(AiModelDTO dto);
|
AiModelVO saveModel(AiModelDTO dto);
|
||||||
AiModelVO updateModel(AiModelDTO dto);
|
AiModelVO updateModel(AiModelDTO dto);
|
||||||
PageResult<List<AiModelVO>> pageModels(Integer current, Integer size, String name, String type, Long tenantId);
|
PageResult<List<AiModelVO>> pageModels(Integer current, Integer size, String name, String type, Long tenantId);
|
||||||
List<String> fetchRemoteModels(String provider, String baseUrl, String apiKey);
|
List<String> fetchRemoteModels(String provider, String baseUrl, String apiKey);
|
||||||
AiModelVO getDefaultModel(String type, Long tenantId);
|
AiModelVO getDefaultModel(String type, Long tenantId);
|
||||||
|
AiModelVO getModelById(Long id, String type);
|
||||||
|
boolean removeModelById(Long id, String type);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ public interface MeetingService extends IService<Meeting> {
|
||||||
MeetingVO getDetail(Long id);
|
MeetingVO getDetail(Long id);
|
||||||
List<MeetingTranscriptVO> getTranscripts(Long meetingId);
|
List<MeetingTranscriptVO> getTranscripts(Long meetingId);
|
||||||
void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label);
|
void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label);
|
||||||
|
void updateMeetingParticipants(Long meetingId, String participants);
|
||||||
void reSummary(Long meetingId, Long summaryModelId, Long promptId);
|
void reSummary(Long meetingId, Long summaryModelId, Long promptId);
|
||||||
java.util.Map<String, Object> getDashboardStats(Long tenantId, Long userId, boolean isAdmin);
|
java.util.Map<String, Object> getDashboardStats(Long tenantId, Long userId, boolean isAdmin);
|
||||||
List<MeetingVO> getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit);
|
List<MeetingVO> getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit);
|
||||||
|
|
|
||||||
|
|
@ -3,36 +3,49 @@ package com.imeeting.service.biz.impl;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
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.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.imeeting.common.PageResult;
|
import com.imeeting.common.PageResult;
|
||||||
import com.imeeting.dto.biz.AiModelDTO;
|
import com.imeeting.dto.biz.AiModelDTO;
|
||||||
import com.imeeting.dto.biz.AiModelVO;
|
import com.imeeting.dto.biz.AiModelVO;
|
||||||
import com.imeeting.entity.biz.AiModel;
|
import com.imeeting.entity.biz.AsrModel;
|
||||||
import com.imeeting.mapper.biz.AiModelMapper;
|
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 com.imeeting.service.biz.AiModelService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.Set;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AiModelServiceImpl extends ServiceImpl<AiModelMapper, AiModel> 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 ObjectMapper objectMapper;
|
||||||
|
private final AsrModelMapper asrModelMapper;
|
||||||
|
private final LlmModelMapper llmModelMapper;
|
||||||
|
|
||||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||||
.connectTimeout(Duration.ofSeconds(10))
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
.build();
|
.build();
|
||||||
|
|
@ -40,158 +53,296 @@ public class AiModelServiceImpl extends ServiceImpl<AiModelMapper, AiModel> impl
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public AiModelVO saveModel(AiModelDTO dto) {
|
public AiModelVO saveModel(AiModelDTO dto) {
|
||||||
AiModel entity = new AiModel();
|
String type = normalizeType(dto.getModelType());
|
||||||
copyProperties(dto, entity);
|
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);
|
LlmModel entity = new LlmModel();
|
||||||
handleAsrWsUrl(entity);
|
copyLlmProperties(dto, entity);
|
||||||
handleDefaultLogic(entity);
|
handleLlmDefaultLogic(entity);
|
||||||
|
llmModelMapper.insert(entity);
|
||||||
this.save(entity);
|
return toLlmVO(entity);
|
||||||
return toVO(entity);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public AiModelVO updateModel(AiModelDTO dto) {
|
public AiModelVO updateModel(AiModelDTO dto) {
|
||||||
AiModel entity = this.getById(dto.getId());
|
String type = normalizeType(dto.getModelType());
|
||||||
if (entity == null) throw new RuntimeException("Model not found");
|
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);
|
LlmModel entity = llmModelMapper.selectById(dto.getId());
|
||||||
pushAsrConfig(entity);
|
if (entity == null) {
|
||||||
handleAsrWsUrl(entity);
|
throw new RuntimeException("Model not found");
|
||||||
handleDefaultLogic(entity);
|
}
|
||||||
|
copyLlmProperties(dto, entity);
|
||||||
this.updateById(entity);
|
handleLlmDefaultLogic(entity);
|
||||||
return toVO(entity);
|
llmModelMapper.updateById(entity);
|
||||||
|
return toLlmVO(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public PageResult<List<AiModelVO>> pageModels(Integer current, Integer size, String name, String type, Long tenantId) {
|
public PageResult<List<AiModelVO>> pageModels(Integer current, Integer size, String name, String type, Long tenantId) {
|
||||||
Page<AiModel> page = this.page(new Page<>(current, size),
|
String resolvedType = normalizeType(type);
|
||||||
new LambdaQueryWrapper<AiModel>()
|
if (TYPE_ASR.equals(resolvedType)) {
|
||||||
.and(wrapper -> wrapper.eq(AiModel::getTenantId, tenantId).or().eq(AiModel::getTenantId, 0L))
|
Page<AsrModel> page = new Page<>(current, size);
|
||||||
.eq(type != null && !type.isEmpty(), AiModel::getModelType, type)
|
LambdaQueryWrapper<AsrModel> wrapper = new LambdaQueryWrapper<AsrModel>()
|
||||||
.like(name != null && !name.isEmpty(), AiModel::getModelName, name)
|
.and(w -> w.eq(AsrModel::getTenantId, tenantId).or().eq(AsrModel::getTenantId, 0L))
|
||||||
.orderByDesc(AiModel::getTenantId)
|
.like(name != null && !name.isBlank(), AsrModel::getModelName, name)
|
||||||
.orderByDesc(AiModel::getCreatedAt));
|
.orderByDesc(AsrModel::getTenantId)
|
||||||
|
.orderByDesc(AsrModel::getCreatedAt);
|
||||||
|
Page<AsrModel> resultPage = asrModelMapper.selectPage(page, wrapper);
|
||||||
|
List<AiModelVO> records = new ArrayList<>();
|
||||||
|
for (AsrModel entity : resultPage.getRecords()) {
|
||||||
|
records.add(toAsrVO(entity));
|
||||||
|
}
|
||||||
|
PageResult<List<AiModelVO>> result = new PageResult<>();
|
||||||
|
result.setTotal(resultPage.getTotal());
|
||||||
|
result.setRecords(records);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
List<AiModelVO> vos = page.getRecords().stream().map(this::toVO).collect(Collectors.toList());
|
Page<LlmModel> page = new Page<>(current, size);
|
||||||
|
LambdaQueryWrapper<LlmModel> wrapper = new LambdaQueryWrapper<LlmModel>()
|
||||||
|
.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<LlmModel> resultPage = llmModelMapper.selectPage(page, wrapper);
|
||||||
|
List<AiModelVO> records = new ArrayList<>();
|
||||||
|
for (LlmModel entity : resultPage.getRecords()) {
|
||||||
|
records.add(toLlmVO(entity));
|
||||||
|
}
|
||||||
PageResult<List<AiModelVO>> result = new PageResult<>();
|
PageResult<List<AiModelVO>> result = new PageResult<>();
|
||||||
result.setTotal(page.getTotal());
|
result.setTotal(resultPage.getTotal());
|
||||||
result.setRecords(vos);
|
result.setRecords(records);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<String> fetchRemoteModels(String provider, String baseUrl, String apiKey) {
|
public List<String> fetchRemoteModels(String provider, String baseUrl, String apiKey) {
|
||||||
try {
|
try {
|
||||||
// 针对 ASR 模型,优先使用指定的 3050 地址进行探测
|
String providerKey = normalizeProvider(provider);
|
||||||
String targetUrl;
|
String resolvedBaseUrl = resolveBaseUrl(providerKey, baseUrl);
|
||||||
if (baseUrl != null && baseUrl.contains("3050")) {
|
if (resolvedBaseUrl == null || resolvedBaseUrl.isBlank()) {
|
||||||
targetUrl = "http://10.100.51.199:3050/api/asrconfig";
|
return Collections.emptyList();
|
||||||
} else if (baseUrl != null && !baseUrl.isEmpty()) {
|
}
|
||||||
// LLM 类型:通用 OpenAI 风格探测
|
String targetUrl = resolveModelListUrl(providerKey, resolvedBaseUrl, apiKey);
|
||||||
targetUrl = baseUrl.endsWith("/") ? baseUrl + "models" : baseUrl + "/models";
|
if (targetUrl == null || targetUrl.isBlank()) {
|
||||||
} else {
|
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
|
|
||||||
java.net.http.HttpClient client = java.net.http.HttpClient.newBuilder()
|
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
|
||||||
.followRedirects(java.net.http.HttpClient.Redirect.ALWAYS)
|
.uri(URI.create(targetUrl))
|
||||||
.build();
|
.timeout(Duration.ofSeconds(10))
|
||||||
|
|
||||||
java.net.http.HttpRequest.Builder requestBuilder = java.net.http.HttpRequest.newBuilder()
|
|
||||||
.uri(java.net.URI.create(targetUrl))
|
|
||||||
.timeout(java.time.Duration.ofSeconds(10))
|
|
||||||
.GET();
|
.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);
|
requestBuilder.header("Authorization", "Bearer " + apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
java.net.http.HttpResponse<String> response = client.send(requestBuilder.build(),
|
HttpResponse<String> response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString());
|
||||||
java.net.http.HttpResponse.BodyHandlers.ofString());
|
if (response.statusCode() != 200) {
|
||||||
|
return Collections.emptyList();
|
||||||
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<String> 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
JsonNode node = objectMapper.readTree(response.body());
|
||||||
|
Set<String> 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) {
|
} 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
|
@Override
|
||||||
public AiModelVO getDefaultModel(String type, Long tenantId) {
|
public AiModelVO getDefaultModel(String type, Long tenantId) {
|
||||||
AiModel model = this.getOne(new LambdaQueryWrapper<AiModel>()
|
String resolvedType = normalizeType(type);
|
||||||
.eq(AiModel::getModelType, type)
|
if (TYPE_ASR.equals(resolvedType)) {
|
||||||
.eq(AiModel::getIsDefault, 1)
|
AsrModel model = asrModelMapper.selectOne(new LambdaQueryWrapper<AsrModel>()
|
||||||
.and(w -> w.eq(AiModel::getTenantId, tenantId).or().eq(AiModel::getTenantId, 0L))
|
.eq(AsrModel::getIsDefault, 1)
|
||||||
.orderByDesc(AiModel::getTenantId) // 租户优先
|
.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<LlmModel>()
|
||||||
|
.eq(LlmModel::getIsDefault, 1)
|
||||||
|
.and(w -> w.eq(LlmModel::getTenantId, tenantId).or().eq(LlmModel::getTenantId, 0L))
|
||||||
|
.orderByDesc(LlmModel::getTenantId)
|
||||||
.last("LIMIT 1"));
|
.last("LIMIT 1"));
|
||||||
return model != null ? toVO(model) : null;
|
return model == null ? null : toLlmVO(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleDefaultLogic(AiModel entity) {
|
@Override
|
||||||
if (Integer.valueOf(1).equals(entity.getIsDefault())) {
|
public AiModelVO getModelById(Long id, String type) {
|
||||||
// Unset other defaults for the same tenant and type
|
String resolvedType = normalizeType(type);
|
||||||
this.update(new LambdaUpdateWrapper<AiModel>()
|
if (TYPE_ASR.equals(resolvedType)) {
|
||||||
.set(AiModel::getIsDefault, 0)
|
AsrModel entity = asrModelMapper.selectById(id);
|
||||||
.eq(AiModel::getTenantId, entity.getTenantId())
|
return entity == null ? null : toAsrVO(entity);
|
||||||
.eq(AiModel::getModelType, entity.getModelType())
|
|
||||||
.eq(AiModel::getIsDefault, 1));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LlmModel entity = llmModelMapper.selectById(id);
|
||||||
|
return entity == null ? null : toLlmVO(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleAsrWsUrl(AiModel entity) {
|
@Override
|
||||||
if ("ASR".equals(entity.getModelType()) && (entity.getWsUrl() == null || entity.getWsUrl().isEmpty())) {
|
public boolean removeModelById(Long id, String type) {
|
||||||
if (entity.getBaseUrl() != null) {
|
String resolvedType = normalizeType(type);
|
||||||
String ws = entity.getBaseUrl().replace("http://", "ws://").replace("https://", "wss://");
|
if (TYPE_ASR.equals(resolvedType)) {
|
||||||
entity.setWsUrl(ws);
|
return asrModelMapper.deleteById(id) > 0;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return llmModelMapper.deleteById(id) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void pushAsrConfig(AiModel entity) {
|
private void handleAsrDefaultLogic(AsrModel entity) {
|
||||||
if (!"ASR".equals(entity.getModelType())) {
|
if (!Integer.valueOf(1).equals(entity.getIsDefault())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (entity.getBaseUrl() == null || entity.getBaseUrl().trim().isEmpty()) {
|
asrModelMapper.update(null, new LambdaUpdateWrapper<AsrModel>()
|
||||||
|
.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<LlmModel>()
|
||||||
|
.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");
|
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");
|
throw new RuntimeException("modelCode is required for ASR model");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,49 +363,51 @@ public class AiModelServiceImpl extends ServiceImpl<AiModelMapper, AiModel> impl
|
||||||
|
|
||||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
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());
|
throw new RuntimeException("Third-party ASR config save failed: HTTP " + response.statusCode());
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} 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);
|
throw new RuntimeException("Third-party ASR config save failed: " + e.getMessage(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void copyProperties(AiModelDTO dto, AiModel entity) {
|
private void copyAsrProperties(AiModelDTO dto, AsrModel entity) {
|
||||||
entity.setModelType(dto.getModelType());
|
|
||||||
entity.setModelName(dto.getModelName());
|
entity.setModelName(dto.getModelName());
|
||||||
entity.setProvider(dto.getProvider());
|
entity.setProvider(dto.getProvider());
|
||||||
entity.setBaseUrl(dto.getBaseUrl());
|
entity.setBaseUrl(dto.getBaseUrl());
|
||||||
entity.setApiPath(dto.getApiPath());
|
|
||||||
entity.setApiKey(dto.getApiKey());
|
entity.setApiKey(dto.getApiKey());
|
||||||
entity.setModelCode(dto.getModelCode());
|
entity.setModelCode(dto.getModelCode());
|
||||||
entity.setWsUrl(dto.getWsUrl());
|
entity.setWsUrl(dto.getWsUrl());
|
||||||
entity.setTemperature(dto.getTemperature());
|
|
||||||
entity.setTopP(dto.getTopP());
|
|
||||||
entity.setMediaConfig(dto.getMediaConfig());
|
entity.setMediaConfig(dto.getMediaConfig());
|
||||||
entity.setIsDefault(dto.getIsDefault());
|
entity.setIsDefault(dto.getIsDefault());
|
||||||
entity.setStatus(dto.getStatus());
|
entity.setStatus(dto.getStatus());
|
||||||
entity.setRemark(dto.getRemark());
|
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();
|
AiModelVO vo = new AiModelVO();
|
||||||
vo.setId(entity.getId());
|
vo.setId(entity.getId());
|
||||||
vo.setTenantId(entity.getTenantId());
|
vo.setTenantId(entity.getTenantId());
|
||||||
vo.setModelType(entity.getModelType());
|
vo.setModelType(TYPE_ASR);
|
||||||
vo.setModelName(entity.getModelName());
|
vo.setModelName(entity.getModelName());
|
||||||
vo.setProvider(entity.getProvider());
|
vo.setProvider(entity.getProvider());
|
||||||
vo.setBaseUrl(entity.getBaseUrl());
|
vo.setBaseUrl(entity.getBaseUrl());
|
||||||
vo.setApiPath(entity.getApiPath());
|
vo.setApiKey(entity.getApiKey());
|
||||||
// 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.setModelCode(entity.getModelCode());
|
vo.setModelCode(entity.getModelCode());
|
||||||
vo.setWsUrl(entity.getWsUrl());
|
vo.setWsUrl(entity.getWsUrl());
|
||||||
vo.setTemperature(entity.getTemperature());
|
|
||||||
vo.setTopP(entity.getTopP());
|
|
||||||
vo.setMediaConfig(entity.getMediaConfig());
|
vo.setMediaConfig(entity.getMediaConfig());
|
||||||
vo.setIsDefault(entity.getIsDefault());
|
vo.setIsDefault(entity.getIsDefault());
|
||||||
vo.setStatus(entity.getStatus());
|
vo.setStatus(entity.getStatus());
|
||||||
|
|
@ -262,4 +415,35 @@ public class AiModelServiceImpl extends ServiceImpl<AiModelMapper, AiModel> impl
|
||||||
vo.setCreatedAt(entity.getCreatedAt());
|
vo.setCreatedAt(entity.getCreatedAt());
|
||||||
return vo;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.imeeting.common.RedisKeys;
|
import com.imeeting.common.RedisKeys;
|
||||||
|
import com.imeeting.dto.biz.AiModelVO;
|
||||||
import com.imeeting.entity.SysUser;
|
import com.imeeting.entity.SysUser;
|
||||||
import com.imeeting.entity.biz.AiModel;
|
|
||||||
import com.imeeting.entity.biz.AiTask;
|
import com.imeeting.entity.biz.AiTask;
|
||||||
import com.imeeting.entity.biz.HotWord;
|
import com.imeeting.entity.biz.HotWord;
|
||||||
import com.imeeting.entity.biz.Meeting;
|
import com.imeeting.entity.biz.Meeting;
|
||||||
|
|
@ -158,7 +158,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
this.updateById(taskRecord);
|
this.updateById(taskRecord);
|
||||||
|
|
||||||
Long asrModelId = Long.valueOf(taskRecord.getTaskConfig().get("asrModelId").toString());
|
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模型配置不存在");
|
if (asrModel == null) throw new RuntimeException("ASR模型配置不存在");
|
||||||
|
|
||||||
String submitUrl = asrModel.getBaseUrl().endsWith("/") ? asrModel.getBaseUrl() + "api/tasks/recognition" : asrModel.getBaseUrl() + "/api/tasks/recognition";
|
String submitUrl = asrModel.getBaseUrl().endsWith("/") ? asrModel.getBaseUrl() + "api/tasks/recognition" : asrModel.getBaseUrl() + "/api/tasks/recognition";
|
||||||
|
|
@ -298,7 +298,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
this.updateById(taskRecord);
|
this.updateById(taskRecord);
|
||||||
|
|
||||||
Long summaryModelId = Long.valueOf(taskRecord.getTaskConfig().get("summaryModelId").toString());
|
Long summaryModelId = Long.valueOf(taskRecord.getTaskConfig().get("summaryModelId").toString());
|
||||||
AiModel llmModel = aiModelService.getById(summaryModelId);
|
AiModelVO llmModel = aiModelService.getModelById(summaryModelId, "LLM");
|
||||||
if (llmModel == null) return;
|
if (llmModel == null) return;
|
||||||
|
|
||||||
String promptContent = taskRecord.getTaskConfig().get("promptContent") != null ?
|
String promptContent = taskRecord.getTaskConfig().get("promptContent") != null ?
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,12 @@ import com.imeeting.dto.biz.MeetingDTO;
|
||||||
import com.imeeting.dto.biz.MeetingVO;
|
import com.imeeting.dto.biz.MeetingVO;
|
||||||
import com.imeeting.dto.biz.MeetingTranscriptVO;
|
import com.imeeting.dto.biz.MeetingTranscriptVO;
|
||||||
import com.imeeting.entity.biz.Meeting;
|
import com.imeeting.entity.biz.Meeting;
|
||||||
import com.imeeting.entity.biz.AiModel;
|
|
||||||
import com.imeeting.entity.biz.AiTask;
|
import com.imeeting.entity.biz.AiTask;
|
||||||
import com.imeeting.entity.biz.PromptTemplate;
|
import com.imeeting.entity.biz.PromptTemplate;
|
||||||
import com.imeeting.entity.biz.MeetingTranscript;
|
import com.imeeting.entity.biz.MeetingTranscript;
|
||||||
import com.imeeting.entity.biz.HotWord;
|
import com.imeeting.entity.biz.HotWord;
|
||||||
import com.imeeting.entity.SysUser;
|
import com.imeeting.entity.SysUser;
|
||||||
|
import com.imeeting.mapper.biz.AiTaskMapper;
|
||||||
import com.imeeting.mapper.biz.MeetingMapper;
|
import com.imeeting.mapper.biz.MeetingMapper;
|
||||||
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
|
||||||
import com.imeeting.mapper.SysUserMapper;
|
import com.imeeting.mapper.SysUserMapper;
|
||||||
|
|
@ -53,6 +53,7 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
|
||||||
private final AiModelService aiModelService;
|
private final AiModelService aiModelService;
|
||||||
private final PromptTemplateService promptTemplateService;
|
private final PromptTemplateService promptTemplateService;
|
||||||
private final AiTaskService aiTaskService;
|
private final AiTaskService aiTaskService;
|
||||||
|
private final AiTaskMapper aiTaskMapper;
|
||||||
private final MeetingTranscriptMapper transcriptMapper;
|
private final MeetingTranscriptMapper transcriptMapper;
|
||||||
private final HotWordService hotWordService;
|
private final HotWordService hotWordService;
|
||||||
private final SysUserMapper sysUserMapper;
|
private final SysUserMapper sysUserMapper;
|
||||||
|
|
@ -144,7 +145,7 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
|
||||||
aiTaskService.save(sumTask);
|
aiTaskService.save(sumTask);
|
||||||
|
|
||||||
eventPublisher.publishEvent(new MeetingCreatedEvent(meeting.getId()));
|
eventPublisher.publishEvent(new MeetingCreatedEvent(meeting.getId()));
|
||||||
return toVO(meeting);
|
return toVO(meeting, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -175,7 +176,7 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
|
||||||
wrapper.orderByDesc(Meeting::getCreatedAt);
|
wrapper.orderByDesc(Meeting::getCreatedAt);
|
||||||
|
|
||||||
Page<Meeting> page = this.page(new Page<>(current, size), wrapper);
|
Page<Meeting> page = this.page(new Page<>(current, size), wrapper);
|
||||||
List<MeetingVO> vos = page.getRecords().stream().map(this::toVO).collect(Collectors.toList());
|
List<MeetingVO> vos = page.getRecords().stream().map(m -> toVO(m, false)).collect(Collectors.toList());
|
||||||
|
|
||||||
PageResult<List<MeetingVO>> result = new PageResult<>();
|
PageResult<List<MeetingVO>> result = new PageResult<>();
|
||||||
result.setTotal(page.getTotal());
|
result.setTotal(page.getTotal());
|
||||||
|
|
@ -192,7 +193,7 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
|
||||||
@Override
|
@Override
|
||||||
public MeetingVO getDetail(Long id) {
|
public MeetingVO getDetail(Long id) {
|
||||||
Meeting meeting = this.getById(id);
|
Meeting meeting = this.getById(id);
|
||||||
return meeting != null ? toVO(meeting) : null;
|
return meeting != null ? toVO(meeting, true) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -223,6 +224,14 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
|
||||||
.set(label != null, MeetingTranscript::getSpeakerLabel, label));
|
.set(label != null, MeetingTranscript::getSpeakerLabel, label));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void updateMeetingParticipants(Long meetingId, String participants) {
|
||||||
|
this.update(new LambdaUpdateWrapper<Meeting>()
|
||||||
|
.eq(Meeting::getId, meetingId)
|
||||||
|
.set(Meeting::getParticipants, participants == null ? "" : participants));
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void reSummary(Long meetingId, Long summaryModelId, Long promptId) {
|
public void reSummary(Long meetingId, Long summaryModelId, Long promptId) {
|
||||||
|
|
@ -255,7 +264,10 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
|
||||||
java.util.Map<String, Object> stats = new java.util.HashMap<>();
|
java.util.Map<String, Object> stats = new java.util.HashMap<>();
|
||||||
LambdaQueryWrapper<Meeting> baseWrapper = new LambdaQueryWrapper<Meeting>().eq(Meeting::getTenantId, tenantId);
|
LambdaQueryWrapper<Meeting> baseWrapper = new LambdaQueryWrapper<Meeting>().eq(Meeting::getTenantId, tenantId);
|
||||||
if (!isAdmin) {
|
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()));
|
stats.put("totalMeetings", this.count(baseWrapper.clone()));
|
||||||
|
|
@ -273,13 +285,16 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
|
||||||
public List<MeetingVO> getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit) {
|
public List<MeetingVO> getRecentMeetings(Long tenantId, Long userId, boolean isAdmin, int limit) {
|
||||||
LambdaQueryWrapper<Meeting> wrapper = new LambdaQueryWrapper<Meeting>().eq(Meeting::getTenantId, tenantId);
|
LambdaQueryWrapper<Meeting> wrapper = new LambdaQueryWrapper<Meeting>().eq(Meeting::getTenantId, tenantId);
|
||||||
if (!isAdmin) {
|
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);
|
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();
|
MeetingVO vo = new MeetingVO();
|
||||||
vo.setId(meeting.getId());
|
vo.setId(meeting.getId());
|
||||||
vo.setTenantId(meeting.getTenantId());
|
vo.setTenantId(meeting.getTenantId());
|
||||||
|
|
@ -296,15 +311,73 @@ public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> impl
|
||||||
try {
|
try {
|
||||||
List<Long> userIds = Arrays.stream(meeting.getParticipants().split(","))
|
List<Long> userIds = Arrays.stream(meeting.getParticipants().split(","))
|
||||||
.map(String::trim).filter(s -> !s.isEmpty()).map(Long::valueOf).collect(Collectors.toList());
|
.map(String::trim).filter(s -> !s.isEmpty()).map(Long::valueOf).collect(Collectors.toList());
|
||||||
|
vo.setParticipantIds(userIds);
|
||||||
if (!userIds.isEmpty()) {
|
if (!userIds.isEmpty()) {
|
||||||
List<SysUser> users = sysUserMapper.selectBatchIds(userIds);
|
List<SysUser> users = sysUserMapper.selectBatchIds(userIds);
|
||||||
String names = users.stream().map(u -> u.getDisplayName() != null ? u.getDisplayName() : u.getUsername()).collect(Collectors.joining(", "));
|
String names = users.stream().map(u -> u.getDisplayName() != null ? u.getDisplayName() : u.getUsername()).collect(Collectors.joining(", "));
|
||||||
vo.setParticipants(names);
|
vo.setParticipants(names);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
vo.setParticipantIds(Collections.emptyList());
|
||||||
vo.setParticipants(meeting.getParticipants());
|
vo.setParticipants(meeting.getParticipants());
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
vo.setParticipantIds(Collections.emptyList());
|
||||||
|
}
|
||||||
|
if (includeSummary) {
|
||||||
|
vo.setSummaryContent(loadSummaryContent(meeting));
|
||||||
}
|
}
|
||||||
return vo;
|
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<AiTask>()
|
||||||
|
.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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,13 @@ export const deleteAiModel = (id: number) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const deleteAiModelByType = (id: number, type: 'ASR' | 'LLM') => {
|
||||||
|
return http.delete<any, { code: string; data: boolean; msg: string }>(
|
||||||
|
`/api/biz/aimodel/${id}`,
|
||||||
|
{ params: { type } }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const getRemoteModelList = (params: { provider: string; baseUrl: string; apiKey?: string }) => {
|
export const getRemoteModelList = (params: { provider: string; baseUrl: string; apiKey?: string }) => {
|
||||||
return http.get<any, { code: string; data: string[]; msg: string }>(
|
return http.get<any, { code: string; data: string[]; msg: string }>(
|
||||||
"/api/biz/aimodel/remote-list",
|
"/api/biz/aimodel/remote-list",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export interface MeetingVO {
|
||||||
title: string;
|
title: string;
|
||||||
meetingTime: string;
|
meetingTime: string;
|
||||||
participants: string;
|
participants: string;
|
||||||
|
participantIds?: number[];
|
||||||
tags: string;
|
tags: string;
|
||||||
audioUrl: string;
|
audioUrl: string;
|
||||||
summaryContent: string;
|
summaryContent: string;
|
||||||
|
|
@ -95,6 +96,13 @@ export const updateMeeting = (data: Partial<MeetingVO>) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const updateMeetingParticipants = (params: { meetingId: number; participants: string }) => {
|
||||||
|
return http.put<any, { code: string; data: boolean; msg: string }>(
|
||||||
|
"/api/biz/meeting/participants",
|
||||||
|
params
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const uploadAudio = (file: File) => {
|
export const uploadAudio = (file: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,40 @@ export default function AppLayout() {
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
const { load: loadPermissions, can } = usePermission();
|
const { load: loadPermissions, can } = usePermission();
|
||||||
|
|
||||||
|
const buildPermissionTree = (list: SysPermission[]) => {
|
||||||
|
const map = new Map<number, SysPermission & { children?: SysPermission[] }>();
|
||||||
|
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 () => {
|
const fetchInitialData = async () => {
|
||||||
try {
|
try {
|
||||||
// Load tenants from localStorage
|
// Load tenants from localStorage
|
||||||
|
|
@ -91,10 +125,10 @@ export default function AppLayout() {
|
||||||
|
|
||||||
// 如果当前是根路径,自动跳转到第一个有权限的菜单
|
// 如果当前是根路径,自动跳转到第一个有权限的菜单
|
||||||
if (location.pathname === '/' && filtered.length > 0) {
|
if (location.pathname === '/' && filtered.length > 0) {
|
||||||
// 查找第一个类型为 'menu' 且有路径的项
|
const menuTree = buildPermissionTree(filtered);
|
||||||
const firstMenu = filtered.find(m => m.permType === 'menu' && m.path);
|
const firstPath = findFirstMenuPath(menuTree);
|
||||||
if (firstMenu && firstMenu.path !== '/') {
|
if (firstPath && firstPath !== '/') {
|
||||||
navigate(firstMenu.path, { replace: true });
|
navigate(firstPath, { replace: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -138,24 +172,7 @@ export default function AppLayout() {
|
||||||
message.success(lng === 'zh-CN' ? '已切换至中文' : 'Switched to English');
|
message.success(lng === 'zh-CN' ? '已切换至中文' : 'Switched to English');
|
||||||
}, [i18n]);
|
}, [i18n]);
|
||||||
|
|
||||||
const buildMenuTree = useCallback((list: SysPermission[]) => {
|
const buildMenuTree = useCallback((list: SysPermission[]) => buildPermissionTree(list), []);
|
||||||
const map = new Map<number, SysPermission & { children?: SysPermission[] }>();
|
|
||||||
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 menuItems = useMemo(() => {
|
const menuItems = useMemo(() => {
|
||||||
const toMenuItems = (nodes: (SysPermission & { children?: SysPermission[] })[]): any[] => {
|
const toMenuItems = (nodes: (SysPermission & { children?: SysPermission[] })[]): any[] => {
|
||||||
|
|
|
||||||
|
|
@ -1,311 +1,432 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } 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 {
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SyncOutlined, SearchOutlined, SafetyCertificateOutlined, SaveOutlined, ApiOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
Button,
|
||||||
import { useDict } from '../../hooks/useDict';
|
Card,
|
||||||
import {
|
Col,
|
||||||
getAiModelPage,
|
Divider,
|
||||||
saveAiModel,
|
Drawer,
|
||||||
updateAiModel,
|
Form,
|
||||||
deleteAiModel,
|
Input,
|
||||||
getRemoteModelList,
|
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,
|
AiModelVO,
|
||||||
AiModelDTO
|
deleteAiModelByType,
|
||||||
} from '../../api/business/aimodel';
|
getAiModelPage,
|
||||||
|
getRemoteModelList,
|
||||||
|
saveAiModel,
|
||||||
|
updateAiModel,
|
||||||
|
} from "../../api/business/aimodel";
|
||||||
|
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
const { Text, Title } = Typography;
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
type ModelType = "ASR" | "LLM";
|
||||||
|
|
||||||
|
const PROVIDER_BASE_URL_MAP: Record<string, string> = {
|
||||||
|
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 AiModels: React.FC = () => {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const { items: providers } = useDict('biz_ai_provider');
|
const { items: providers } = useDict("biz_ai_provider");
|
||||||
|
|
||||||
|
const [activeType, setActiveType] = useState<ModelType>("ASR");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [data, setData] = useState<AiModelVO[]>([]);
|
const [data, setData] = useState<AiModelVO[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [current, setCurrent] = useState(1);
|
const [current, setCurrent] = useState(1);
|
||||||
const [size, setSize] = useState(10);
|
const [size, setSize] = useState(10);
|
||||||
const [searchName, setSearchName] = useState('');
|
const [searchName, setSearchName] = useState("");
|
||||||
const [searchType, setSearchType] = useState<string | undefined>(undefined);
|
|
||||||
|
|
||||||
const [drawerVisible, setDrawerVisible] = useState(false);
|
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [submitLoading, setSubmitLoading] = useState(false);
|
const [submitLoading, setSubmitLoading] = useState(false);
|
||||||
const [fetchLoading, setFetchLoading] = useState(false);
|
const [fetchLoading, setFetchLoading] = useState(false);
|
||||||
const [remoteModels, setRemoteModels] = useState<string[]>([]);
|
const [remoteModels, setRemoteModels] = useState<string[]>([]);
|
||||||
|
const modelNameAutoFilledRef = useRef(false);
|
||||||
const [modelType, setModelType] = useState<'ASR' | 'LLM'>('ASR');
|
|
||||||
const watchedModelType = Form.useWatch('modelType', form);
|
|
||||||
const provider = Form.useWatch('provider', form);
|
|
||||||
|
|
||||||
// Check if current user is platform admin
|
const provider = Form.useWatch("provider", form);
|
||||||
const isPlatformAdmin = React.useMemo(() => {
|
|
||||||
|
const isPlatformAdmin = useMemo(() => {
|
||||||
const profileStr = sessionStorage.getItem("userProfile");
|
const profileStr = sessionStorage.getItem("userProfile");
|
||||||
if (profileStr) {
|
if (!profileStr) {
|
||||||
const profile = JSON.parse(profileStr);
|
return false;
|
||||||
return profile.isPlatformAdmin === true;
|
|
||||||
}
|
}
|
||||||
return false;
|
const profile = JSON.parse(profileStr);
|
||||||
|
return profile.isPlatformAdmin === true;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
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 () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await getAiModelPage({ current, size, name: searchName, type: searchType });
|
const res = await getAiModelPage({
|
||||||
if (res.data && res.data.data && res.data.data.records) {
|
current,
|
||||||
setData(res.data.data.records);
|
size,
|
||||||
setTotal(res.data.data.total);
|
name: searchName || undefined,
|
||||||
}
|
type: activeType,
|
||||||
} catch (err) {
|
});
|
||||||
console.error(err);
|
const pageData = (res as any)?.data?.data ?? (res as any);
|
||||||
|
setData(pageData?.records || []);
|
||||||
|
setTotal(pageData?.total || 0);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenDrawer = (record?: AiModelVO) => {
|
const openDrawer = (record?: AiModelVO) => {
|
||||||
setRemoteModels([]);
|
setRemoteModels([]);
|
||||||
|
modelNameAutoFilledRef.current = false;
|
||||||
if (record) {
|
if (record) {
|
||||||
setEditingId(record.id);
|
setEditingId(record.id);
|
||||||
setModelType(record.modelType);
|
form.setFieldsValue({
|
||||||
form.setFieldsValue(record);
|
...record,
|
||||||
if (record.modelCode) setRemoteModels([record.modelCode]);
|
modelType: record.modelType,
|
||||||
|
isDefaultChecked: record.isDefault === 1,
|
||||||
|
statusChecked: record.status === 1,
|
||||||
|
});
|
||||||
|
if (record.modelCode) {
|
||||||
|
setRemoteModels([record.modelCode]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setModelType('ASR');
|
|
||||||
form.resetFields();
|
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);
|
setDrawerVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFetchRemote = async () => {
|
const handleFetchRemote = async () => {
|
||||||
const vals = form.getFieldsValue(['provider', 'baseUrl', 'apiKey']);
|
const vals = form.getFieldsValue(["provider", "baseUrl", "apiKey"]);
|
||||||
if (!vals.provider || !vals.baseUrl) {
|
if (!vals.provider || !vals.baseUrl) {
|
||||||
message.warning('请先填写提供商和基础地址');
|
message.warning("请先填写提供商和 Base URL");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFetchLoading(true);
|
setFetchLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await getRemoteModelList(vals);
|
const res = await getRemoteModelList(vals);
|
||||||
// res.data 是后端的 ApiResponse, res.data.data 才是模型字符串数组
|
const rawModels = (res as any)?.data?.data ?? (Array.isArray(res) ? res : []);
|
||||||
if (res.data && Array.isArray(res.data.data)) {
|
const models = Array.isArray(rawModels) ? rawModels : [];
|
||||||
setRemoteModels(res.data.data);
|
setRemoteModels(models);
|
||||||
message.success(`成功获取 ${res.data.data.length} 个模型`);
|
message.success(`获取到 ${models.length} 个模型`);
|
||||||
} else {
|
|
||||||
setRemoteModels([]);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
} finally {
|
||||||
setFetchLoading(false);
|
setFetchLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
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 {
|
try {
|
||||||
const values = await form.validateFields();
|
|
||||||
setSubmitLoading(true);
|
|
||||||
|
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
await updateAiModel({ ...values, id: editingId });
|
await updateAiModel(payload);
|
||||||
message.success('更新成功');
|
message.success("更新成功");
|
||||||
} else {
|
} else {
|
||||||
await saveAiModel(values);
|
await saveAiModel(payload);
|
||||||
message.success('添加成功');
|
message.success("新增成功");
|
||||||
}
|
}
|
||||||
|
|
||||||
setDrawerVisible(false);
|
setDrawerVisible(false);
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitLoading(false);
|
setSubmitLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (record: AiModelVO) => {
|
||||||
|
await deleteAiModelByType(record.id, record.modelType);
|
||||||
|
message.success("删除成功");
|
||||||
|
fetchData();
|
||||||
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: '模型名称',
|
title: "模型名称",
|
||||||
dataIndex: 'modelName',
|
dataIndex: "modelName",
|
||||||
key: 'modelName',
|
key: "modelName",
|
||||||
render: (text: string, record: AiModelVO) => (
|
render: (text: string, record: AiModelVO) => (
|
||||||
<Space>
|
<Space>
|
||||||
{text}
|
{text}
|
||||||
{record.isDefault === 1 && <Tag color="gold">默认</Tag>}
|
{record.isDefault === 1 && <Tag color="gold">默认</Tag>}
|
||||||
{record.tenantId === 0 && <Tooltip title="系统预置"><SafetyCertificateOutlined style={{ color: '#52c41a' }} /></Tooltip>}
|
{record.tenantId === 0 && (
|
||||||
|
<Tooltip title="系统预置">
|
||||||
|
<SafetyCertificateOutlined style={{ color: "#52c41a" }} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
)
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '类型',
|
title: "提供商",
|
||||||
dataIndex: 'modelType',
|
dataIndex: "provider",
|
||||||
key: 'modelType',
|
key: "provider",
|
||||||
render: (type: string) => <Tag color={type === 'ASR' ? 'blue' : 'purple'}>{type === 'ASR' ? '语音识别' : '会议总结'}</Tag>
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '提供商',
|
|
||||||
dataIndex: 'provider',
|
|
||||||
key: 'provider',
|
|
||||||
render: (val: string) => {
|
render: (val: string) => {
|
||||||
const item = providers.find(i => i.itemValue === val);
|
const item = providers.find((i) => i.itemValue === val);
|
||||||
return item ? <Tag>{item.itemLabel}</Tag> : val;
|
return item ? <Tag>{item.itemLabel}</Tag> : val;
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
{ title: "模型名称(code)", dataIndex: "modelCode", key: "modelCode" },
|
||||||
|
{
|
||||||
|
title: "状态",
|
||||||
|
dataIndex: "status",
|
||||||
|
key: "status",
|
||||||
|
render: (status: number) =>
|
||||||
|
status === 1 ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '模型代码',
|
title: "操作",
|
||||||
dataIndex: 'modelCode',
|
key: "action",
|
||||||
key: 'modelCode',
|
render: (_: unknown, record: AiModelVO) => {
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'status',
|
|
||||||
key: 'status',
|
|
||||||
render: (status: number) => status === 1 ? <Tag color="green">启用</Tag> : <Tag color="default">禁用</Tag>
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'action',
|
|
||||||
render: (_: any, record: AiModelVO) => {
|
|
||||||
const canEdit = record.tenantId !== 0 || isPlatformAdmin;
|
const canEdit = record.tenantId !== 0 || isPlatformAdmin;
|
||||||
return (
|
return (
|
||||||
<Space size="middle">
|
<Space>
|
||||||
{canEdit && <Button type="link" icon={<EditOutlined />} onClick={() => handleOpenDrawer(record)}>编辑</Button>}
|
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Popconfirm title="确定删除吗?" onConfirm={() => deleteAiModel(record.id).then(() => fetchData())}>
|
<Button type="link" icon={<EditOutlined />} onClick={() => openDrawer(record)}>
|
||||||
<Button type="link" danger icon={<DeleteOutlined />}>删除</Button>
|
编辑
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<Popconfirm title="确定删除吗?" onConfirm={() => handleDelete(record)}>
|
||||||
|
<Button type="link" danger icon={<DeleteOutlined />}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '24px' }}>
|
<div style={{ padding: 24 }}>
|
||||||
<Card title="AI 模型配置" extra={
|
<Card
|
||||||
<Space wrap>
|
title="AI 模型配置"
|
||||||
<Radio.Group value={searchType} onChange={e => setSearchType(e.target.value)} buttonStyle="solid">
|
extra={
|
||||||
<Radio.Button value={undefined}>全部</Radio.Button>
|
<Space>
|
||||||
<Radio.Button value="ASR">语音识别</Radio.Button>
|
<Input
|
||||||
<Radio.Button value="LLM">会议总结</Radio.Button>
|
placeholder="搜索模型名称"
|
||||||
</Radio.Group>
|
prefix={<SearchOutlined />}
|
||||||
<Input
|
allowClear
|
||||||
placeholder="搜索模型名称"
|
onPressEnter={(e) => setSearchName((e.target as HTMLInputElement).value)}
|
||||||
prefix={<SearchOutlined />}
|
style={{ width: 220 }}
|
||||||
allowClear
|
/>
|
||||||
onPressEnter={(e) => setSearchName((e.target as any).value)}
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>
|
||||||
style={{ width: 180 }}
|
新增模型
|
||||||
/>
|
</Button>
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenDrawer()}>配置模型</Button>
|
</Space>
|
||||||
</Space>
|
}
|
||||||
}>
|
>
|
||||||
<Table columns={columns} dataSource={data} rowKey="id" loading={loading}
|
<Tabs
|
||||||
pagination={{ current, pageSize: size, total, onChange: (p, s) => { setCurrent(p); setSize(s); }}}
|
activeKey={activeType}
|
||||||
|
onChange={(key) => {
|
||||||
|
setActiveType(key as ModelType);
|
||||||
|
setCurrent(1);
|
||||||
|
}}
|
||||||
|
items={[
|
||||||
|
{ key: "ASR", label: "ASR 模型" },
|
||||||
|
{ key: "LLM", label: "LLM 模型" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data}
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
current,
|
||||||
|
pageSize: size,
|
||||||
|
total,
|
||||||
|
onChange: (p, s) => {
|
||||||
|
setCurrent(p);
|
||||||
|
setSize(s);
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
title={<Title level={4} style={{ margin: 0 }}>{editingId ? '编辑模型配置' : '添加模型配置'}</Title>}
|
|
||||||
width={600}
|
width={600}
|
||||||
onClose={() => setDrawerVisible(false)}
|
|
||||||
open={drawerVisible}
|
open={drawerVisible}
|
||||||
|
onClose={() => setDrawerVisible(false)}
|
||||||
|
title={<Title level={4} style={{ margin: 0 }}>{editingId ? "编辑模型" : "新增模型"}</Title>}
|
||||||
extra={
|
extra={
|
||||||
<Space>
|
<Space>
|
||||||
<Button onClick={() => setDrawerVisible(false)}>取消</Button>
|
<Button onClick={() => setDrawerVisible(false)}>取消</Button>
|
||||||
<Button type="primary" icon={<SaveOutlined />} loading={submitLoading} onClick={handleSubmit}>保存配置</Button>
|
<Button type="primary" icon={<SaveOutlined />} loading={submitLoading} onClick={handleSubmit}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical">
|
||||||
<Form.Item name="modelType" label="模型用途" rules={[{ required: true }]}>
|
<Form.Item name="modelType" hidden>
|
||||||
<Radio.Group onChange={e => setModelType(e.target.value)} disabled={!!editingId}>
|
<Input />
|
||||||
<Radio.Button value="ASR">语音识别 (ASR)</Radio.Button>
|
</Form.Item>
|
||||||
<Radio.Button value="LLM">会议总结 (LLM)</Radio.Button>
|
|
||||||
</Radio.Group>
|
<Form.Item label="模型类型">
|
||||||
|
<Tag color={activeType === "ASR" ? "blue" : "purple"}>
|
||||||
|
{activeType === "ASR" ? "语音识别 (ASR)" : "总结模型 (LLM)"}
|
||||||
|
</Tag>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item name="modelName" label="配置显示名称" rules={[{ required: true, message: '请输入显示名称' }]}>
|
<Form.Item name="modelName" label="显示名称" rules={[{ required: true, message: "请输入显示名称" }]}>
|
||||||
<Input placeholder="如: 阿里云语音-高速版" />
|
<Input
|
||||||
|
onChange={() => {
|
||||||
|
modelNameAutoFilledRef.current = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item name="provider" label="提供商" rules={[{ required: true }]}>
|
<Form.Item name="provider" label="提供商" rules={[{ required: true, message: "请选择提供商" }]}>
|
||||||
<Select placeholder="选择厂商" allowClear>
|
<Select allowClear placeholder="请选择">
|
||||||
{providers.map(item => (
|
{providers.map((item) => (
|
||||||
<Option key={item.itemValue} value={item.itemValue}>{item.itemLabel}</Option>
|
<Option key={item.itemValue} value={item.itemValue}>
|
||||||
|
{item.itemLabel}
|
||||||
|
</Option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Form.Item name="baseUrl" label="API 基础地址 (Base URL)" rules={[{ required: true }]}>
|
<Form.Item name="baseUrl" label="Base URL" rules={[{ required: true, message: "请输入 Base URL" }]}>
|
||||||
<Input placeholder="https://api.example.com/v1" />
|
<Input placeholder="https://api.example.com/v1" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{!(watchedModelType === 'ASR' && provider === 'Custom') && (
|
{!(activeType === "ASR" && provider === "Custom") && (
|
||||||
<Form.Item name="apiKey" label="API Key / Secret" tooltip="密钥将加密存储,仅在更新时需重新输入">
|
<Form.Item name="apiKey" label="API Key">
|
||||||
<Input.Password placeholder="输入您的 API 密钥" />
|
<Input.Password />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Divider orientation="left" style={{ fontSize: '14px', color: '#999' }}>业务参数</Divider>
|
<Divider orientation="left" style={{ fontSize: 14, color: "#999" }}>
|
||||||
|
模型参数
|
||||||
|
</Divider>
|
||||||
|
|
||||||
<Form.Item label="模型编码 (Model Code)" required>
|
<Form.Item label="模型名称" required tooltip="单选,值将作为模型 code 传给后端">
|
||||||
<Space.Compact style={{ width: '100%' }}>
|
<Space.Compact style={{ width: "100%" }}>
|
||||||
<Form.Item
|
<Form.Item name="modelCode" noStyle rules={[{ required: true, message: "请选择模型名称" }]}>
|
||||||
name="modelCode"
|
<Select
|
||||||
noStyle
|
allowClear
|
||||||
rules={[{ required: true, message: '请输入或选择模型编码' }]}
|
showSearch
|
||||||
getValueFromEvent={(value) => {
|
style={{ width: "calc(100% - 100px)" }}
|
||||||
// 如果是数组(tags模式返回数组),取最后一个值作为最终模型编码
|
placeholder="选择模型名称"
|
||||||
return Array.isArray(value) ? value[value.length - 1] : value;
|
optionFilterProp="children"
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
placeholder="选择建议或直接输入 (回车确认)"
|
|
||||||
mode="tags"
|
|
||||||
maxCount={1}
|
|
||||||
showSearch
|
|
||||||
allowClear
|
|
||||||
style={{ width: 'calc(100% - 100px)' }}
|
|
||||||
filterOption={(input, option) =>
|
|
||||||
(option?.children as unknown as string).toLowerCase().includes(input.toLowerCase())
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{remoteModels.map(m => <Option key={m} value={m}>{m}</Option>)}
|
{remoteModels.map((m) => (
|
||||||
|
<Option key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button icon={<SyncOutlined spin={fetchLoading} />} onClick={handleFetchRemote} style={{ width: 100 }}>刷新列表</Button>
|
<Button icon={<SyncOutlined spin={fetchLoading} />} onClick={handleFetchRemote} style={{ width: 100 }}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{modelType === 'ASR' && (
|
{activeType === "ASR" && (
|
||||||
<Form.Item name="wsUrl" label="WebSocket 地址" tooltip="留空则根据 Base URL 自动推断 (http->ws)">
|
<Form.Item name="wsUrl" label="WebSocket 地址">
|
||||||
<Input placeholder="wss://api.example.com/v1/ws" />
|
<Input placeholder="wss://api.example.com/v1/ws" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{modelType === 'LLM' && (
|
{activeType === "LLM" && (
|
||||||
<>
|
<>
|
||||||
<Form.Item name="apiPath" label="API 路径" initialValue="/chat/completions">
|
<Form.Item name="apiPath" label="API 路径" initialValue="/v1/chat/completions">
|
||||||
<Input placeholder="/chat/completions" />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item name="temperature" label="Temperature (随机性)">
|
<Form.Item name="temperature" label="Temperature">
|
||||||
<InputNumber min={0} max={2} step={0.1} style={{ width: '100%' }} />
|
<InputNumber min={0} max={2} step={0.1} style={{ width: "100%" }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item name="topP" label="Top P (核采样)">
|
<Form.Item name="topP" label="Top P">
|
||||||
<InputNumber min={0} max={1} step={0.1} style={{ width: '100%' }} />
|
<InputNumber min={0} max={1} step={0.1} style={{ width: "100%" }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
@ -314,24 +435,18 @@ const AiModels: React.FC = () => {
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={8}>
|
<Col span={8}>
|
||||||
<Form.Item name="isDefault" label="设为默认" valuePropName="checked">
|
<Form.Item name="isDefaultChecked" label="设为默认" valuePropName="checked">
|
||||||
<Switch checkedChildren="是" unCheckedChildren="否"
|
<Switch checkedChildren="是" unCheckedChildren="否" />
|
||||||
onChange={checked => form.setFieldsValue({ isDefault: checked ? 1 : 0 })}
|
|
||||||
checked={form.getFieldValue('isDefault') === 1}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={8}>
|
<Col span={8}>
|
||||||
<Form.Item name="status" label="启用状态" valuePropName="checked">
|
<Form.Item name="statusChecked" label="状态" valuePropName="checked">
|
||||||
<Switch checkedChildren="开" unCheckedChildren="关"
|
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
|
||||||
onChange={checked => form.setFieldsValue({ status: checked ? 1 : 0 })}
|
|
||||||
checked={form.getFieldValue('status') === 1}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Form.Item name="remark" label="备注说明">
|
<Form.Item name="remark" label="备注">
|
||||||
<Input.TextArea rows={2} />
|
<Input.TextArea rows={2} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,7 @@ const MeetingCreate: React.FC = () => {
|
||||||
<Form.Item name="participants" label="参会人员" style={{ marginBottom: 0 }}>
|
<Form.Item name="participants" label="参会人员" style={{ marginBottom: 0 }}>
|
||||||
<Select mode="multiple" placeholder="选择人员" showSearch optionFilterProp="children">
|
<Select mode="multiple" placeholder="选择人员" showSearch optionFilterProp="children">
|
||||||
{userList.map(u => (
|
{userList.map(u => (
|
||||||
<Option key={u.userId} value={u.displayName || u.username}>
|
<Option key={u.userId} value={u.userId}>
|
||||||
<Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space>
|
<Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space>
|
||||||
</Option>
|
</Option>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Card, Button, Input, Space, Tag, message, Popconfirm, Typography, Row, Col, List, Badge, Empty, Skeleton, Tooltip, Radio, Pagination, Progress, Drawer, Form, DatePicker, Upload, Avatar, Divider, Switch, Select } from 'antd';
|
import { Card, Button, Input, Space, Tag, message, Popconfirm, Typography, Row, Col, List, Badge, Empty, Skeleton, Tooltip, Radio, Pagination, Progress, Drawer, Form, DatePicker, Upload, Avatar, Divider, Switch, Select, Modal } from 'antd';
|
||||||
import {
|
import {
|
||||||
PlusOutlined, DeleteOutlined, SearchOutlined, CheckCircleOutlined,
|
PlusOutlined, DeleteOutlined, SearchOutlined, CheckCircleOutlined,
|
||||||
LoadingOutlined, UserOutlined, CalendarOutlined, PlayCircleOutlined,
|
LoadingOutlined, UserOutlined, CalendarOutlined, PlayCircleOutlined,
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
AudioOutlined
|
AudioOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { getMeetingPage, deleteMeeting, MeetingVO, getMeetingProgress, MeetingProgress, createMeeting, uploadAudio } from '../../api/business/meeting';
|
import { getMeetingPage, deleteMeeting, MeetingVO, getMeetingProgress, MeetingProgress, createMeeting, uploadAudio, updateMeetingParticipants } from '../../api/business/meeting';
|
||||||
import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel';
|
import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel';
|
||||||
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
|
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
|
||||||
import { getHotWordPage, HotWordVO } from '../../api/business/hotword';
|
import { getHotWordPage, HotWordVO } from '../../api/business/hotword';
|
||||||
|
|
@ -151,7 +151,7 @@ const MeetingCreateForm: React.FC<{
|
||||||
</Row>
|
</Row>
|
||||||
<Form.Item name="participants" label="参会人员" style={{ marginBottom: 0 }}>
|
<Form.Item name="participants" label="参会人员" style={{ marginBottom: 0 }}>
|
||||||
<Select mode="multiple" placeholder="选择人员" showSearch optionFilterProp="children" size="large">
|
<Select mode="multiple" placeholder="选择人员" showSearch optionFilterProp="children" size="large">
|
||||||
{userList.map(u => (<Option key={u.userId} value={u.displayName || u.username}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
|
{userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -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 navigate = useNavigate();
|
||||||
// 注入自动刷新回调
|
// 注入自动刷新回调
|
||||||
const progress = useMeetingProgress(item, () => fetchData());
|
const progress = useMeetingProgress(item, () => fetchData());
|
||||||
|
|
@ -267,7 +267,7 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
|
||||||
<div style={{ flex: 1, padding: '20px 24px', position: 'relative', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ flex: 1, padding: '20px 24px', position: 'relative', display: 'flex', flexDirection: 'column' }}>
|
||||||
<div className="card-actions" style={{ position: 'absolute', top: 16, right: 16, zIndex: 10 }} onClick={e => e.stopPropagation()}>
|
<div className="card-actions" style={{ position: 'absolute', top: 16, right: 16, zIndex: 10 }} onClick={e => e.stopPropagation()}>
|
||||||
<Space size={8}>
|
<Space size={8}>
|
||||||
<Tooltip title="编辑"><div className="icon-btn edit" onClick={() => navigate(`/meetings/${item.id}`)}><EditOutlined /></div></Tooltip>
|
<Tooltip title="编辑参会人"><div className="icon-btn edit" onClick={() => onEditParticipants(item)}><EditOutlined /></div></Tooltip>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="确定删除?"
|
title="确定删除?"
|
||||||
onConfirm={() => deleteMeeting(item.id).then(fetchData)}
|
onConfirm={() => deleteMeeting(item.id).then(fetchData)}
|
||||||
|
|
@ -354,6 +354,11 @@ const Meetings: React.FC = () => {
|
||||||
const [audioUrl, setAudioUrl] = useState('');
|
const [audioUrl, setAudioUrl] = useState('');
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const [fileList, setFileList] = useState<any[]>([]);
|
const [fileList, setFileList] = useState<any[]>([]);
|
||||||
|
const [userList, setUserList] = useState<SysUser[]>([]);
|
||||||
|
const [participantsEditVisible, setParticipantsEditVisible] = useState(false);
|
||||||
|
const [editingMeeting, setEditingMeeting] = useState<MeetingVO | null>(null);
|
||||||
|
const [participantsEditLoading, setParticipantsEditLoading] = useState(false);
|
||||||
|
const [participantsEditForm] = Form.useForm();
|
||||||
const hasRunningTasks = data.some(item => item.status === 0 || item.status === 1 || item.status === 2);
|
const hasRunningTasks = data.some(item => item.status === 0 || item.status === 1 || item.status === 2);
|
||||||
|
|
||||||
useEffect(() => { fetchData(); }, [current, size, searchTitle, viewType]);
|
useEffect(() => { fetchData(); }, [current, size, searchTitle, viewType]);
|
||||||
|
|
@ -362,6 +367,9 @@ const Meetings: React.FC = () => {
|
||||||
const timer = setInterval(() => fetchData(true), 5000);
|
const timer = setInterval(() => fetchData(true), 5000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [hasRunningTasks, current, size, searchTitle, viewType]);
|
}, [hasRunningTasks, current, size, searchTitle, viewType]);
|
||||||
|
useEffect(() => {
|
||||||
|
listUsers().then((users) => setUserList(users || [])).catch(() => setUserList([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchData = async (silent = false) => {
|
const fetchData = async (silent = false) => {
|
||||||
if (!silent) setLoading(true);
|
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<number, { text: string; color: string; bgColor: string }> = {
|
const statusConfig: Record<number, { text: string; color: string; bgColor: string }> = {
|
||||||
0: { text: '排队中', color: '#8c8c8c', bgColor: '#f5f5f5' },
|
0: { text: '排队中', color: '#8c8c8c', bgColor: '#f5f5f5' },
|
||||||
1: { text: '识别中', color: '#1890ff', bgColor: '#e6f7ff' },
|
1: { text: '识别中', color: '#1890ff', bgColor: '#e6f7ff' },
|
||||||
|
|
@ -430,7 +466,7 @@ const Meetings: React.FC = () => {
|
||||||
<Skeleton loading={loading} active paragraph={{ rows: 10 }}>
|
<Skeleton loading={loading} active paragraph={{ rows: 10 }}>
|
||||||
<List grid={{ gutter: 24, xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }} dataSource={data} renderItem={(item) => {
|
<List grid={{ gutter: 24, xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }} dataSource={data} renderItem={(item) => {
|
||||||
const config = statusConfig[item.status] || statusConfig[0];
|
const config = statusConfig[item.status] || statusConfig[0];
|
||||||
return <MeetingCardItem item={item} config={config} fetchData={fetchData} t={t} />;
|
return <MeetingCardItem item={item} config={config} fetchData={fetchData} t={t} onEditParticipants={openEditParticipants} />;
|
||||||
}} locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }} />
|
}} locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }} />
|
||||||
</Skeleton>
|
</Skeleton>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -471,6 +507,33 @@ const Meetings: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="编辑参会人"
|
||||||
|
open={participantsEditVisible}
|
||||||
|
onCancel={() => setParticipantsEditVisible(false)}
|
||||||
|
onOk={handleUpdateParticipants}
|
||||||
|
confirmLoading={participantsEditLoading}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={participantsEditForm} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
name="participantIds"
|
||||||
|
label="参会人员"
|
||||||
|
>
|
||||||
|
<Select mode="multiple" placeholder="请选择参会人" showSearch optionFilterProp="children">
|
||||||
|
{userList.map(u => (
|
||||||
|
<Option key={u.userId} value={u.userId}>
|
||||||
|
<Space>
|
||||||
|
<Avatar size="small" icon={<UserOutlined />} />
|
||||||
|
{u.displayName || u.username}
|
||||||
|
</Space>
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
.meeting-card:hover { transform: translateY(-4px); box-shadow: 0 12px 24px rgba(0,0,0,0.08) !important; }
|
.meeting-card:hover { transform: translateY(-4px); box-shadow: 0 12px 24px rgba(0,0,0,0.08) !important; }
|
||||||
.status-bar-active { animation: statusBreathing 2s infinite ease-in-out; }
|
.status-bar-active { animation: statusBreathing 2s infinite ease-in-out; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue