feat: 添加实时会议状态处理和转录内容检查

- 在 `AiTaskServiceImpl` 中添加 `buildTranscriptText` 和 `failPendingSummaryTask` 方法,用于构建转录文本和处理失败的摘要任务
- 更新 `doDispatchSummaryTask` 和 `dispatchTasks` 方法,以在转录内容为空时处理失败情况
- 在前端 `Meetings.tsx` 中添加实时会议状态处理逻辑,支持实时会议的暂停、进行中和待开始状态
- 更新测试类 `AiTaskServiceImplTest` 以包含新的测试用例,验证转录内容为空时的任务处理逻辑
dev_na
chenhao 2026-04-23 15:47:29 +08:00
parent 8d0ef246f3
commit 4e38580258
22 changed files with 731 additions and 397 deletions

View File

@ -1,11 +1,7 @@
# 鏁版嵁搴撶粨鏋勬枃妗紙PostgreSQL锛?
# 鏁版嵁搴撶粨鏋勬枃妗紙PostgreSQL锛?
鏈枃妗f牴鎹?`backend/design/db_schema_pgsql.sql` 鐢熸垚锛屾弿杩板綋鍓嶆牳蹇冭〃缁撴瀯銆佸瓧娈点€佺害鏉熶笌绱㈠紩銆?
## 0. 绉熸埛涓庣粍缁?
### 0.1 `sys_tenant`锛堢鎴疯〃锛?
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
### 0.1 `sys_tenant`锛堢鎴疯〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 绉熸埛ID |
| tenant_code | VARCHAR(64) | NOT NULL, UNIQUE | 绉熸埛缂栫爜 |
@ -19,11 +15,9 @@
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 鏇存柊鏃堕棿 |
| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
绱㈠紩锛?
- `uk_tenant_code`锛歚UNIQUE (tenant_code) WHERE is_deleted = FALSE`
绱㈠紩锛?- `uk_tenant_code`锛歚UNIQUE (tenant_code) WHERE is_deleted = FALSE`
### 0.2 `sys_org`锛堢粍缁囨灦鏋勮〃锛?
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
### 0.2 `sys_org`锛堢粍缁囨灦鏋勮〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 缁勭粐ID |
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
@ -37,17 +31,13 @@
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 鏇存柊鏃堕棿 |
| 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)`
绱㈠紩锛?
- `idx_org_tenant`锛歚(tenant_id)`
绱㈠紩锛?- `idx_org_tenant`锛歚(tenant_id)`
## 1. 鐢ㄦ埛涓庤鑹?
### 1.1 `sys_user`锛堢敤鎴疯〃锛?
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
### 1.1 `sys_user`锛堢敤鎴疯〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| user_id | BIGSERIAL | PK | 鐢ㄦ埛ID |
| username | VARCHAR(50) | NOT NULL, UNIQUE | 鐧诲綍鍚?|
@ -62,11 +52,9 @@
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
| is_platform_admin | BOOLEAN | DEFAULT false | 鏄惁骞冲彴绠$悊鍛?|
绱㈠紩锛?
- `uk_user_username`锛歚UNIQUE (username) WHERE is_deleted = FALSE`
绱㈠紩锛?- `uk_user_username`锛歚UNIQUE (username) WHERE is_deleted = FALSE`
### 1.2 `sys_role`锛堣鑹茶〃锛?
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
### 1.2 `sys_role`锛堣鑹茶〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| role_id | BIGSERIAL | PK | 瑙掕壊ID |
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
@ -78,8 +66,7 @@
| created_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`
### 1.3 `sys_user_role`锛堢敤鎴?瑙掕壊鍏宠仈琛紝绉熸埛寮虹害鏉燂級
@ -93,11 +80,9 @@
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
鍞竴绾︽潫锛?
- `UNIQUE (tenant_id, user_id, role_id) WHERE is_deleted = 0`
鍞竴绾︽潫锛?- `UNIQUE (tenant_id, user_id, role_id) WHERE is_deleted = 0`
### 1.4 `sys_tenant_user`锛堢鎴锋垚鍛樺叧鑱旇〃锛?
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
### 1.4 `sys_tenant_user`锛堢鎴锋垚鍛樺叧鑱旇〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 鍏宠仈ID |
| user_id | BIGINT | NOT NULL | 鐢ㄦ埛ID |
@ -108,13 +93,10 @@
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
绱㈠紩锛?
- `uk_tenant_user`锛歚UNIQUE (user_id, tenant_id) WHERE is_deleted = 0`
绱㈠紩锛?- `uk_tenant_user`锛歚UNIQUE (user_id, tenant_id) WHERE is_deleted = 0`
## 2. 鏉冮檺/瀛楀吀/鍙傛暟锛堝叏灞€鍏变韩锛?
### 2.1 `sys_permission`锛堟潈闄愯〃锛?
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
### 2.1 `sys_permission`锛堟潈闄愯〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| perm_id | BIGSERIAL | PK | 鏉冮檺ID |
| parent_id | BIGINT | | 鐖剁骇鏉冮檺ID |
@ -134,8 +116,7 @@
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
### 2.2 `sys_dict_type`锛堝瓧鍏哥被鍨嬭〃锛?
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
### 2.2 `sys_dict_type`锛堝瓧鍏哥被鍨嬭〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| dict_type_id | BIGSERIAL | PK | 绫诲瀷ID |
| type_code | VARCHAR(50) | NOT NULL, UNIQUE | 绫诲瀷缂栫爜 |
@ -168,12 +149,10 @@
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鏇存柊鏃堕棿 |
| 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)`
### 2.4 `sys_param`锛堢郴缁熷弬鏁拌〃锛?
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
### 2.4 `sys_param`锛堢郴缁熷弬鏁拌〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 鍙傛暟ID |
| param_key | VARCHAR(100) | NOT NULL, UNIQUE | 鍙傛暟閿?|
@ -186,8 +165,7 @@
## 3. 鏃ュ織锛堢鎴烽殧绂伙級
### 3.1 `sys_log`锛堢郴缁熸棩蹇楄〃锛?
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
### 3.1 `sys_log`锛堢郴缁熸棩蹇楄〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 鏃ュ織ID |
| tenant_id | BIGINT | NOT NULL, DEFAULT 0 | 绉熸埛ID |
@ -202,13 +180,11 @@
| duration | BIGINT | | 鑰楁椂锛坢s锛?|
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鍒涘缓鏃堕棿 |
绱㈠紩锛?
- `idx_log_tenant_type`锛歚(tenant_id, log_type, created_at)`
绱㈠紩锛?- `idx_log_tenant_type`锛歚(tenant_id, log_type, created_at)`
## 4. 骞冲彴閰嶇疆
### 4.1 `sys_platform_config`锛堝钩鍙扮鐞嗚〃锛?
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
### 4.1 `sys_platform_config`锛堝钩鍙扮鐞嗚〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGINT | PK | 鍥哄畾涓?1 |
| project_name | VARCHAR(128) | NOT NULL | 椤圭洰鍚嶇О |
@ -223,225 +199,192 @@
## 5. 涓氬姟妯″潡
### 5.1 `biz_speakers`锛堝0绾瑰彂瑷€浜鸿〃锛?
### 5.1 `biz_speakers`锛堝0绾瑰彂瑷€浜鸿〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 涓婚敭ID |
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
| creator_id | BIGINT | NOT NULL | 鍒涘缓浜篒D锛岀敤浜庡绾瑰簱褰掑睘 |
| user_id | BIGINT | | 鍏宠仈绯荤粺鐢ㄦ埛ID |
| external_speaker_id | VARCHAR(100) | | 绗笁鏂瑰绾瑰簱涓殑浜哄憳ID |
| name | VARCHAR(100) | NOT NULL | 鍙戣█浜哄鍚?|
| voice_path | VARCHAR(512) | | 鍘熷澹扮汗鏂囦欢璺緞 |
| voice_ext | VARCHAR(10) | | 鏂囦欢鍚庣紑 |
| voice_size | BIGINT | | 鏂囦欢澶у皬 |
| status | SMALLINT | DEFAULT 1 | 鐘舵€侊紙1=宸蹭繚瀛橈紝2=娉ㄥ唽涓紝3=宸叉敞鍐岋紝4=澶辫触锛?|
| embedding | VECTOR(512) | | 澹扮汗鐗瑰緛鍚戦噺 |
| remark | TEXT | | 澶囨敞 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎 |
绱㈠紩锛?- `idx_speaker_tenant`锛歚(tenant_id) WHERE is_deleted = 0`
- `idx_speaker_creator`锛歚(creator_id) WHERE is_deleted = 0`
- `idx_speaker_user`锛歚(user_id) WHERE is_deleted = 0`
- `idx_speaker_external`锛歚(external_speaker_id) WHERE is_deleted = 0`
- `uk_speaker_tenant_name`锛歚UNIQUE (tenant_id, name) WHERE is_deleted = 0`
### 5.2 `biz_hot_word_groups`锛堢儹璇嶇粍琛級
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 涓婚敭ID |
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
| user_id | BIGINT | | 鍏宠仈绯荤粺鐢ㄦ埛ID |
| name | VARCHAR(100) | NOT NULL | 鍙戣█浜哄鍚?|
| voice_path | VARCHAR(512) | | 鍘熷鏂囦欢璺緞 |
| voice_ext | VARCHAR(10) | | 鏂囦欢鍚庣紑 |
| voice_size | BIGINT | | 鏂囦欢澶у皬 |
| status | SMALLINT | DEFAULT 1 | 鐘舵€?(1:宸蹭繚瀛? 2:娉ㄥ唽涓? 3:宸叉敞鍐? |
| embedding | VECTOR | | 澹扮汗鐗瑰緛鍚戦噺 |
| remark | TEXT | | 澶囨敞 |
| group_name | VARCHAR(100) | NOT NULL | 鐑瘝缁勫悕绉?|
| creator_id | BIGINT | | 鍒涘缓浜篒D |
| status | SMALLINT | DEFAULT 1 | 鐘舵€侊紙1:鍚敤锛?:绂佺敤锛?|
| remark | VARCHAR(255) | | 澶囨敞 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎 |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎 |
绱㈠紩锛?
- `idx_speaker_tenant`: `(tenant_id)`
- `idx_speaker_user`: `(user_id) WHERE is_deleted = 0`
绱㈠紩锛?- `idx_hot_word_group_tenant`锛歚(tenant_id) WHERE is_deleted = 0`
- `uk_hot_word_group_name_scope`锛歚UNIQUE (tenant_id, group_name) WHERE is_deleted = 0`
### 5.2 `biz_hot_words`锛堢儹璇嶇鐞嗚〃锛?
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
### 5.3 `biz_hot_words`锛堢儹璇嶇鐞嗚〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 涓婚敭ID |
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
| word | VARCHAR(100) | NOT NULL | 鐑瘝鍘熸枃 |
| pinyin_list | JSONB | | 鎷奸煶鏁扮粍 |
| match_strategy | SMALLINT | DEFAULT 1 | 鍖归厤绛栫暐 (1:绮剧‘, 2:妯$硦) |
| category | VARCHAR(50) | | 绫诲埆 (浜哄悕銆佹湳璇瓑) |
| hot_word_group_id | BIGINT | | 所属热词组 ID |
| weight | INTEGER | DEFAULT 10 | 鏉冮噸 (1-100) |
| status | SMALLINT | DEFAULT 1 | 鐘舵€?(1:鍚敤, 0:绂佺敤) |
| is_synced | SMALLINT | DEFAULT 0 | 宸插悓姝ョ涓夋柟鏍囪 |
| is_public | SMALLINT | DEFAULT 0 | 鏄惁绉熸埛鍏紑锛?:鍏紑锛?:涓汉绉佹湁锛?|
| creator_id | BIGINT | | 鍒涘缓鑰匢D |
| pinyin_list | TEXT | | 鎷奸煶鏁扮粍 |
| match_strategy | SMALLINT | DEFAULT 1 | 鍖归厤绛栫暐锛?:绮剧‘鍖归厤锛?:鎷奸煶妯$硦鍖归厤锛?|
| category | VARCHAR(50) | | 绫诲埆锛堜汉鍚嶃€佹湳璇€佸湴鍚嶏級 |
| hot_word_group_id | BIGINT | | 鎵€灞炵儹璇嶇粍ID |
| weight | INTEGER | DEFAULT 10 | 鏉冮噸锛?-100锛?|
| status | SMALLINT | DEFAULT 1 | 鐘舵€侊紙1:鍚敤锛?:绂佺敤锛?|
| is_synced | SMALLINT | DEFAULT 0 | 鏄惁宸插悓姝ョ涓夋柟寮曟搸锛?:鏈悓姝ワ紝1:宸插悓姝ワ級 |
| remark | TEXT | | 澶囨敞 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎 |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎 |
绱㈠紩锛?
- `idx_hotword_tenant`: `(tenant_id)`
- `idx_hotword_word`: `(word) WHERE is_deleted = 0`
- `idx_hotword_group`: `(hot_word_group_id) WHERE is_deleted = 0`
### 5.3 `biz_hot_word_groups`(热词组表)
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 主键ID |
| tenant_id | BIGINT | NOT NULL | 租户ID |
| group_name | VARCHAR(100) | NOT NULL | 热词组名称 |
| creator_id | BIGINT | | 创建人ID |
| status | SMALLINT | DEFAULT 1 | 状态(1:启用, 0:禁用) |
| remark | VARCHAR(255) | | 备注 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 |
索引:
- `idx_hot_word_group_tenant`: `(tenant_id) WHERE is_deleted = 0`
- `uk_hot_word_group_name_scope`: `(tenant_id, group_name) WHERE is_deleted = 0`
绱㈠紩锛?- `idx_hotword_tenant`锛歚(tenant_id)`
- `idx_hotword_word`锛歚(word) WHERE is_deleted = 0`
- `idx_hotword_group`锛歚(hot_word_group_id) WHERE is_deleted = 0`
### 5.4 `biz_prompt_templates`锛堟彁绀鸿瘝妯℃澘琛級
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 涓婚敭ID |
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
| tenant_id | BIGINT | NOT NULL, DEFAULT 0 | 绉熸埛ID锛? 涓虹郴缁熺骇锛?|
| template_name | VARCHAR(100) | NOT NULL | 妯℃澘鍚嶇О |
| category | VARCHAR(20) | | 鍒嗙被 (瀛楀吀: biz_prompt_category) |
| is_system | SMALLINT | DEFAULT 0 | 鏄惁棰勭疆 (1:鏄? 0:鍚? |
| description | VARCHAR(255) | | 妯℃澘鎻忚堪 |
| category | VARCHAR(20) | | 鍒嗙被锛堝瓧鍏革細`biz_prompt_category`锛?|
| is_system | SMALLINT | DEFAULT 0 | 鏄惁绯荤粺棰勭疆锛?:鏄紝0:鍚︼級 |
| creator_id | BIGINT | | 鍒涘缓浜篒D |
| tags | JSONB | | 鏍囩鏁扮粍 |
| hot_word_group_id | BIGINT | | 绑定热词组 ID |
| tags | TEXT | | 鏍囩鏁扮粍 |
| hot_word_group_id | BIGINT | | 缁戝畾鐑瘝缁処D |
| usage_count | INTEGER | DEFAULT 0 | 浣跨敤娆℃暟 |
| prompt_content | TEXT | NOT NULL | 鎻愮ず璇嶅唴瀹?|
| status | SMALLINT | DEFAULT 1 | 鐘舵€?(1:鍚敤, 0:绂佺敤) |
| status | SMALLINT | DEFAULT 1 | 鐘舵€侊紙1:鍚敤锛?:绂佺敤锛?|
| remark | VARCHAR(255) | | 澶囨敞 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎 |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎 |
绱㈠紩锛?
- `idx_prompt_tenant`: `(tenant_id)`
- `idx_prompt_system`: `(is_system) WHERE is_deleted = 0`
- `idx_prompt_group`: `(hot_word_group_id) WHERE is_deleted = 0`
绱㈠紩锛?- `idx_prompt_tenant`锛歚(tenant_id)`
- `idx_prompt_system`锛歚(is_system) WHERE is_deleted = 0`
- `idx_prompt_group`锛歚(hot_word_group_id) WHERE is_deleted = 0`
### 5.5 `biz_asr_models`ASR 模型管理表)
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 主键ID |
| tenant_id | BIGINT | NOT NULL | 租户ID |
| model_name | VARCHAR(100) | NOT NULL | 模型显示名称 |
| provider | VARCHAR(50) | | 提供商 |
| base_url | VARCHAR(255) | | 接口基础地址 |
| api_key | VARCHAR(255) | | API 密钥 |
| model_code | VARCHAR(100) | | 模型代码 |
| ws_url | VARCHAR(255) | | WebSocket 地址 |
| media_config | JSON/TEXT | | 媒体参数 |
| 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 | 逻辑删除 |
索引:
- `idx_asr_model_tenant`: `(tenant_id)`
- `idx_asr_model_default`: `(is_default) WHERE is_deleted = 0`
### 5.5 `biz_llm_models`LLM 模型管理表)
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 主键ID |
| tenant_id | BIGINT | NOT NULL | 租户ID |
| model_name | VARCHAR(100) | NOT NULL | 模型显示名称 |
| provider | VARCHAR(50) | | 提供商 |
| base_url | VARCHAR(255) | | 接口基础地址 |
| api_path | VARCHAR(100) | | API 路径 |
| api_key | VARCHAR(255) | | API 密钥 |
| model_code | VARCHAR(100) | | 模型代码 |
| temperature | DECIMAL | DEFAULT 0.7 | 随机性 |
| top_p | DECIMAL | DEFAULT 0.9 | 核采样 |
| is_default | SMALLINT | DEFAULT 0 | 默认模型标记 |
| status | SMALLINT | DEFAULT 1 | 状态 |
| remark | VARCHAR(255) | | 备注 |
| created_at | TIMESTAMP(6) | NOT NULL | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL | 更新时间 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 |
索引:
- `idx_llm_model_tenant`: `(tenant_id)`
- `idx_llm_model_default`: `(is_default) WHERE is_deleted = 0`
### 5.6 `biz_meetings`锛堜細璁富琛級
### 5.5 `biz_asr_models`锛圓SR 妯″瀷閰嶇疆琛級
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 涓婚敭ID |
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
| tenant_id | BIGINT | NOT NULL, DEFAULT 0 | 绉熸埛ID |
| model_name | VARCHAR(100) | NOT NULL | 妯″瀷鏄剧ず鍚嶇О |
| provider | VARCHAR(50) | | 鎻愪緵鍟?|
| base_url | VARCHAR(255) | | 鎺ュ彛鍩虹鍦板潃 |
| api_key | VARCHAR(255) | | API 瀵嗛挜 |
| model_code | VARCHAR(100) | | 妯″瀷浠g爜 |
| ws_url | VARCHAR(255) | | WebSocket 鍦板潃 |
| media_config | TEXT | | 濯掍綋鍙傛暟 |
| is_default | SMALLINT | DEFAULT 0 | 榛樿妯″瀷鏍囪 |
| sort_order | INTEGER | NOT NULL, 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 | 閫昏緫鍒犻櫎 |
绱㈠紩锛?- `idx_asr_model_tenant`锛歚(tenant_id)`
- `idx_asr_model_default`锛歚(is_default) WHERE is_deleted = 0`
- `idx_asr_model_sort_order`锛歚(tenant_id, is_default, sort_order) WHERE is_deleted = 0`
- `uk_asr_model_default_enabled_tenant`锛歚(tenant_id) WHERE is_deleted = 0 AND status = 1 AND is_default = 1`
### 5.6 `biz_llm_models`锛圠LM 妯″瀷閰嶇疆琛級
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 涓婚敭ID |
| tenant_id | BIGINT | NOT NULL, DEFAULT 0 | 绉熸埛ID |
| model_name | VARCHAR(100) | NOT NULL | 妯″瀷鏄剧ず鍚嶇О |
| provider | VARCHAR(50) | | 鎻愪緵鍟?|
| base_url | VARCHAR(255) | | 鎺ュ彛鍩虹鍦板潃 |
| api_path | VARCHAR(100) | | API 璺緞 |
| api_key | VARCHAR(255) | | API 瀵嗛挜 |
| model_code | VARCHAR(100) | | 妯″瀷浠g爜 |
| temperature | DECIMAL(3,2) | DEFAULT 0.7 | 娓╁害鍙傛暟 |
| top_p | DECIMAL(3,2) | DEFAULT 0.9 | Top P 鍙傛暟 |
| is_default | SMALLINT | DEFAULT 0 | 榛樿妯″瀷鏍囪 |
| sort_order | INTEGER | NOT NULL, 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 | 閫昏緫鍒犻櫎 |
绱㈠紩锛?- `idx_llm_model_tenant`锛歚(tenant_id)`
- `idx_llm_model_default`锛歚(is_default) WHERE is_deleted = 0`
- `idx_llm_model_sort_order`锛歚(tenant_id, is_default, sort_order) WHERE is_deleted = 0`
- `uk_llm_model_default_enabled_tenant`锛歚(tenant_id) WHERE is_deleted = 0 AND status = 1 AND is_default = 1`
### 5.7 `biz_meetings`锛堜細璁富琛級
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 涓婚敭ID |
| tenant_id | BIGINT | NOT NULL, DEFAULT 0 | 绉熸埛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:澶辫触 |
| meeting_time | TIMESTAMP(6) | | 浼氳鏃堕棿 |
| participants | TEXT | | 鍙備細浜轰俊鎭?|
| tags | VARCHAR(255) | | 鏍囩 |
| audio_url | VARCHAR(500) | | 涓撳睘闊抽璺緞 |
| creator_id | BIGINT | | 鍙戣捣浜篒D |
| creator_name | VARCHAR(100) | | 鍙戣捣浜哄鍚?|
| latest_summary_task_id | BIGINT | | 鏈€鏂版垚鍔熸€荤粨浠诲姟ID |
| status | SMALLINT | DEFAULT 0 | 鐘舵€侊紙0:寰呭鐞嗭紝1:澶勭悊涓紝2:鎴愬姛锛?:澶辫触锛?|
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎 |
### 5.6 `biz_meeting_transcripts`锛堣浆褰曟槑缁嗚〃锛?
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
绱㈠紩锛?- `idx_meeting_tenant`锛歚(tenant_id)`
### 5.8 `biz_meeting_transcripts`锛堣浆褰曟槑缁嗚〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 涓婚敭ID |
| meeting_id | BIGINT | NOT NULL | 鍏宠仈浼氳ID |
| speaker_id | VARCHAR(50) | | ASR 杩斿洖鐨勫彂瑷€浜烘爣璇?|
| speaker_name | VARCHAR(100) | | 淇敼鍚庣殑鍙戣█浜哄鍚?|
| speaker_label | VARCHAR(50) | | 鍙戣█浜烘爣绛?|
| content | TEXT | | 杞綍鏂囧瓧 |
| start_time | INTEGER | | 寮€濮嬫椂闂?(ms) |
| content | TEXT | | 杞綍鍐呭 |
| start_time | INTEGER | | 寮€濮嬫椂闂达紙ms锛?|
| end_time | INTEGER | | 缁撴潫鏃堕棿锛坢s锛?|
| sort_order | INTEGER | | 鎺掑簭 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
### 5.7 `biz_ai_tasks`锛圓I 浠诲姟娴佹按琛級
绱㈠紩锛?- `idx_transcript_meeting`锛歚(meeting_id)`
### 5.9 `biz_ai_tasks`锛圓I 浠诲姟娴佹按琛級
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 涓婚敭ID |
| meeting_id | BIGINT | NOT NULL | 鍏宠仈浼氳ID |
| task_type | VARCHAR(20) | | ASR / SUMMARY |
| request_data | JSONB | | 璇锋眰鍘熷鏁版嵁 |
| response_data | JSONB | | 鍝嶅簲鍘熷鏁版嵁 |
| task_config | TEXT | | **[蹇収]** 浠诲姟閰嶇疆(妯″瀷ID銆佹彁绀鸿瘝妯℃澘绛? |
| result_file_path | VARCHAR(500) | | 缁撴灉鏂囦欢鐩稿璺緞 (濡侻D鎬荤粨鏂囦欢) |
| status | SMALLINT | | 0:鎺掗槦, 1:澶勭悊涓? 2:鎴愬姛, 3:澶辫触 |
## 6. 屏保模块
### 6.1 `biz_screen_savers`(屏保素材表)
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 屏保ID |
| tenant_id | BIGINT | NOT NULL | 租户ID |
| scope_type | VARCHAR(32) | NOT NULL, DEFAULT 'PLATFORM' | 作用域PLATFORM / USER |
| owner_user_id | BIGINT | | 当作用域为 USER 时的归属用户 |
| name | VARCHAR(128) | NOT NULL | 屏保名称 |
| image_url | VARCHAR(512) | NOT NULL | 图片地址 |
| description | VARCHAR(255) | | 屏保描述 |
| display_duration_sec | INTEGER | NOT NULL, DEFAULT 15 | 旧版素材级展示时长,兼容期保留 |
| image_width | INTEGER | | 图片宽度 |
| image_height | INTEGER | | 图片高度 |
| image_format | VARCHAR(16) | | 图片格式 |
| sort_order | INTEGER | NOT NULL, DEFAULT 0 | 排序值 |
| created_by | BIGINT | | 创建人ID |
| status | SMALLINT | NOT NULL, DEFAULT 1 | 启用状态 |
| remark | VARCHAR(255) | | 备注 |
| created_at | TIMESTAMP(6) | NOT NULL | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL | 更新时间 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 |
索引:
- `idx_screen_savers_status_sort`: `(status, sort_order)`
- `idx_screen_savers_scope_owner_status_sort`: `(scope_type, owner_user_id, status, sort_order)`
### 6.2 `biz_screen_saver_user_config`(屏保用户状态覆盖表)
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 配置ID |
| tenant_id | BIGINT | NOT NULL | 租户ID |
| user_id | BIGINT | NOT NULL | 用户ID |
| screen_saver_id | BIGINT | NOT NULL | 屏保素材ID |
| status | SMALLINT | NOT NULL, DEFAULT 1 | 用户覆盖启停状态 |
| created_at | TIMESTAMP(6) | NOT NULL | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL | 更新时间 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 |
索引:
- `uk_screen_saver_user_cfg_user_item`: `UNIQUE (tenant_id, user_id, screen_saver_id) WHERE is_deleted = 0`
- `idx_screen_saver_user_cfg_item`: `(screen_saver_id) WHERE is_deleted = 0`
### 6.3 `biz_screen_saver_user_settings`(屏保用户播放设置表)
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 设置ID |
| tenant_id | BIGINT | NOT NULL | 租户ID |
| user_id | BIGINT | NOT NULL | 用户ID |
| display_duration_sec | INTEGER | NOT NULL, DEFAULT 15 | 当前用户统一屏保展示时长(秒) |
| created_at | TIMESTAMP(6) | NOT NULL | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL | 更新时间 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除 |
索引:
- `uk_screen_saver_user_settings_user`: `UNIQUE (tenant_id, user_id) WHERE is_deleted = 0`
| task_type | VARCHAR(20) | | 浠诲姟绫诲瀷锛圓SR / SUMMARY锛?|
| status | SMALLINT | DEFAULT 0 | 鐘舵€侊紙0:鎺掗槦锛?:鎵ц涓紝2:鎴愬姛锛?:澶辫触锛?|
| request_data | TEXT | | 璇锋眰涓夋柟鍘熷 JSON |
| response_data | TEXT | | 涓夋柟杩斿洖鍘熷 JSON |
| task_config | TEXT | | 浠诲姟閰嶇疆鍙傛暟蹇収 |
| result_file_path | VARCHAR(500) | | 缁撴灉鏂囦欢璺緞 |
| error_msg | TEXT | | 閿欒鍫嗘爤 |
| started_at | TIMESTAMP(6) | | 寮€濮嬫椂闂?|
| completed_at | TIMESTAMP(6) | | 瀹屾垚鏃堕棿 |
绱㈠紩锛?- `idx_aitask_meeting`锛歚(meeting_id)`

View File

@ -350,6 +350,7 @@ CREATE TABLE biz_asr_models (
ws_url VARCHAR(255),
media_config text,
is_default SMALLINT DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
status SMALLINT DEFAULT 1,
remark VARCHAR(255),
created_at TIMESTAMP(6) NOT NULL DEFAULT now(),
@ -370,6 +371,7 @@ CREATE TABLE biz_llm_models (
temperature DECIMAL(3,2) DEFAULT 0.7,
top_p DECIMAL(3,2) DEFAULT 0.9,
is_default SMALLINT DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
status SMALLINT DEFAULT 1,
remark VARCHAR(255),
created_at TIMESTAMP(6) NOT NULL DEFAULT now(),
@ -379,8 +381,12 @@ CREATE TABLE biz_llm_models (
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_asr_model_sort_order ON biz_asr_models (tenant_id, is_default, sort_order) WHERE is_deleted = 0;
CREATE UNIQUE INDEX uk_asr_model_default_enabled_tenant ON biz_asr_models (tenant_id) WHERE is_deleted = 0 AND status = 1 AND is_default = 1;
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;
CREATE INDEX idx_llm_model_sort_order ON biz_llm_models (tenant_id, is_default, sort_order) WHERE is_deleted = 0;
CREATE UNIQUE INDEX uk_llm_model_default_enabled_tenant ON biz_llm_models (tenant_id) WHERE is_deleted = 0 AND status = 1 AND is_default = 1;
COMMENT ON TABLE biz_asr_models IS 'ASR 模型配置表';
COMMENT ON TABLE biz_llm_models IS 'LLM 模型配置表';

View File

@ -17,12 +17,16 @@ public class LegacyLlmModelItemResponse {
@JsonProperty("is_default")
private Integer isDefault;
@JsonProperty("sort_order")
private Integer sortOrder;
public static LegacyLlmModelItemResponse from(AiModelVO source, boolean defaultItem) {
LegacyLlmModelItemResponse response = new LegacyLlmModelItemResponse();
response.setModelCode(source.getModelCode());
response.setModelName(source.getModelName());
response.setProvider(source.getProvider());
response.setIsDefault(defaultItem ? 1 : 0);
response.setSortOrder(source.getSortOrder());
return response;
}
}

View File

@ -1,25 +1,45 @@
package com.imeeting.dto.biz;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Map;
@Schema(description = "AI 模型配置请求")
@Data
public class AiModelDTO {
@Schema(description = "模型 ID")
private Long id;
@Schema(description = "模型类型")
private String modelType;
@Schema(description = "模型显示名称")
private String modelName;
@Schema(description = "提供商")
private String provider;
@Schema(description = "基础地址")
private String baseUrl;
@Schema(description = "接口路径")
private String apiPath;
@Schema(description = "接口密钥")
private String apiKey;
@Schema(description = "连通性测试消息")
private String testMessage;
@Schema(description = "模型编码")
private String modelCode;
@Schema(description = "WebSocket 地址")
private String wsUrl;
@Schema(description = "温度参数")
private BigDecimal temperature;
@Schema(description = "TopP 参数")
private BigDecimal topP;
@Schema(description = "媒体配置")
private Map<String, Object> mediaConfig;
@Schema(description = "是否默认")
private Integer isDefault;
@Schema(description = "状态")
private Integer status;
@Schema(description = "排序值,越小越靠前")
private Integer sortOrder;
@Schema(description = "备注")
private String remark;
}

View File

@ -39,6 +39,8 @@ public class AiModelVO {
private Integer isDefault;
@Schema(description = "启用状态")
private Integer status;
@Schema(description = "排序值,越小越靠前")
private Integer sortOrder;
@Schema(description = "备注")
private String remark;
@Schema(description = "创建时间")

View File

@ -46,6 +46,9 @@ public class AsrModel extends BaseEntity {
@Schema(description = "是否默认模型")
private Integer isDefault;
@Schema(description = "排序值,越小越靠前")
private Integer sortOrder;
@Schema(description = "备注")
private String remark;
}

View File

@ -46,6 +46,9 @@ public class LlmModel extends BaseEntity {
@Schema(description = "是否默认模型")
private Integer isDefault;
@Schema(description = "排序值,越小越靠前")
private Integer sortOrder;
@Schema(description = "备注")
private String remark;
}

View File

@ -44,6 +44,7 @@ public class AiModelServiceImpl implements AiModelService {
private static final String TYPE_ASR = "ASR";
private static final String TYPE_LLM = "LLM";
private static final int DEFAULT_SORT_ORDER = 0;
private static final String DEFAULT_LLM_API_PATH = "/v1/chat/completions";
private static final String DEFAULT_ANTHROPIC_API_PATH = "/messages";
private static final String CONNECTIVITY_TEST_SYSTEM_PROMPT = """
@ -126,6 +127,8 @@ public class AiModelServiceImpl implements AiModelService {
LambdaQueryWrapper<AsrModel> wrapper = new LambdaQueryWrapper<AsrModel>()
.and(w -> w.eq(AsrModel::getTenantId, tenantId).or().eq(AsrModel::getTenantId, 0L))
.like(name != null && !name.isBlank(), AsrModel::getModelName, name)
.orderByDesc(AsrModel::getIsDefault)
.orderByAsc(AsrModel::getSortOrder)
.orderByDesc(AsrModel::getTenantId)
.orderByDesc(AsrModel::getCreatedAt);
Page<AsrModel> resultPage = asrModelMapper.selectPage(page, wrapper);
@ -143,6 +146,8 @@ public class AiModelServiceImpl implements AiModelService {
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::getIsDefault)
.orderByAsc(LlmModel::getSortOrder)
.orderByDesc(LlmModel::getTenantId)
.orderByDesc(LlmModel::getCreatedAt);
Page<LlmModel> resultPage = llmModelMapper.selectPage(page, wrapper);
@ -438,10 +443,10 @@ public class AiModelServiceImpl implements AiModelService {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new RuntimeException("闂佸搫鐗滈崜娆忥耿閺夋嚦鐔煎灳瀹曞洠鍋撻悜鑺ョ厐鐎广儱娲ㄩ弸鍌毲庨崶銊х畵闁宦板妽瀵板嫭娼忛銉? HTTP " + response.statusCode());
throw new RuntimeException("本地模型配置保存失败: HTTP " + response.statusCode());
}
} catch (Exception e) {
throw new RuntimeException("闂佸搫鐗滈崜娆忥耿閺夋嚦鐔煎灳瀹曞洠鍋撻悜鑺ョ厐鐎广儱娲ㄩ弸鍌毲庨崶銊х畵闁宦板妽瀵板嫭娼忛銉? " + e.getMessage(), e);
throw new RuntimeException("本地模型配置保存失败: " + e.getMessage(), e);
}
}
@ -564,6 +569,21 @@ public class AiModelServiceImpl implements AiModelService {
return chatCompletionContent;
}
String chatCompletionReasoning = root.path("choices").path(0).path("message").path("reasoning").asText(null);
if (chatCompletionReasoning != null && !chatCompletionReasoning.isBlank()) {
return chatCompletionReasoning;
}
JsonNode reasoningContentNode = root.path("choices").path(0).path("message").path("reasoning_content");
if (reasoningContentNode.isArray()) {
for (JsonNode reasoningItem : reasoningContentNode) {
String text = reasoningItem.path("text").asText(null);
if (text != null && !text.isBlank()) {
return text;
}
}
}
String textCompletionContent = root.path("choices").path(0).path("text").asText(null);
if (textCompletionContent != null && !textCompletionContent.isBlank()) {
return textCompletionContent;
@ -720,6 +740,9 @@ public class AiModelServiceImpl implements AiModelService {
if (dto == null) {
throw new RuntimeException("模型配置不能为空");
}
if (Integer.valueOf(1).equals(dto.getIsDefault()) && !Integer.valueOf(1).equals(dto.getStatus())) {
throw new RuntimeException("默认模型必须为启用状态");
}
if ("custom".equals(normalizeProvider(dto.getProvider()))) {
if (TYPE_ASR.equals(normalizeType(dto.getModelType()))) {
Map<String, Object> mediaConfig = dto.getMediaConfig() == null ? Collections.emptyMap() : dto.getMediaConfig();
@ -738,17 +761,23 @@ public class AiModelServiceImpl implements AiModelService {
String resolvedType = normalizeType(type);
if (TYPE_ASR.equals(resolvedType)) {
AsrModel model = asrModelMapper.selectOne(new LambdaQueryWrapper<AsrModel>()
.eq(AsrModel::getStatus, 1)
.eq(AsrModel::getIsDefault, 1)
.and(w -> w.eq(AsrModel::getTenantId, tenantId).or().eq(AsrModel::getTenantId, 0L))
.orderByDesc(AsrModel::getTenantId)
.orderByAsc(AsrModel::getSortOrder)
.orderByDesc(AsrModel::getCreatedAt)
.last("LIMIT 1"));
return model == null ? null : toAsrVO(model);
}
LlmModel model = llmModelMapper.selectOne(new LambdaQueryWrapper<LlmModel>()
.eq(LlmModel::getStatus, 1)
.eq(LlmModel::getIsDefault, 1)
.and(w -> w.eq(LlmModel::getTenantId, tenantId).or().eq(LlmModel::getTenantId, 0L))
.orderByDesc(LlmModel::getTenantId)
.orderByAsc(LlmModel::getSortOrder)
.orderByDesc(LlmModel::getCreatedAt)
.last("LIMIT 1"));
return model == null ? null : toLlmVO(model);
}
@ -855,6 +884,7 @@ public class AiModelServiceImpl implements AiModelService {
entity.setMediaConfig(dto.getMediaConfig());
entity.setIsDefault(dto.getIsDefault());
entity.setStatus(dto.getStatus());
entity.setSortOrder(normalizeSortOrder(dto.getSortOrder()));
entity.setRemark(dto.getRemark());
}
@ -914,6 +944,7 @@ public class AiModelServiceImpl implements AiModelService {
entity.setTopP(dto.getTopP() == null ? BigDecimal.valueOf(0.9) : dto.getTopP());
entity.setIsDefault(dto.getIsDefault());
entity.setStatus(dto.getStatus());
entity.setSortOrder(normalizeSortOrder(dto.getSortOrder()));
entity.setRemark(dto.getRemark());
}
@ -931,6 +962,7 @@ public class AiModelServiceImpl implements AiModelService {
vo.setMediaConfig(entity.getMediaConfig());
vo.setIsDefault(entity.getIsDefault());
vo.setStatus(entity.getStatus());
vo.setSortOrder(entity.getSortOrder());
vo.setRemark(entity.getRemark());
vo.setCreatedAt(entity.getCreatedAt());
return vo;
@ -951,11 +983,16 @@ public class AiModelServiceImpl implements AiModelService {
vo.setTopP(entity.getTopP());
vo.setIsDefault(entity.getIsDefault());
vo.setStatus(entity.getStatus());
vo.setSortOrder(entity.getSortOrder());
vo.setRemark(entity.getRemark());
vo.setCreatedAt(entity.getCreatedAt());
return vo;
}
private Integer normalizeSortOrder(Integer sortOrder) {
return sortOrder == null ? DEFAULT_SORT_ORDER : sortOrder;
}
private String normalizeType(String type) {
if (type == null || type.isBlank()) {
return TYPE_ASR;

View File

@ -96,7 +96,7 @@ public class PromptTemplateServiceImpl extends ServiceImpl<PromptTemplateMapper,
if (template == null) {
throw new IllegalArgumentException("模板不存在");
}
Map<Long, HotWordGroup> hotWordGroupMap = queryHotWordGroupMap(List.of(template.getHotWordGroupId()));
Map<Long, HotWordGroup> hotWordGroupMap = queryHotWordGroupMap(java.util.Collections.singletonList(template.getHotWordGroupId()));
PromptTemplateVO vo = toVO(template, template.getStatus(), hotWordGroupMap);
vo.setHotWords(resolveHotWords(template.getHotWordGroupId()));
return vo;

View File

@ -61,6 +61,8 @@ unisbase:
- /api/auth/**
- /api/static/**
- /api/public/meetings/**
- /api/android/auth/login
- /api/android/auth/refresh
- /api/android/screensavers/active
- /api/screensavers/active
- /v3/api-docs/**

View File

@ -25,6 +25,7 @@ import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@ -67,7 +68,7 @@ class AiModelServiceImplTest {
dto.setApiPath("/v1/chat/completions");
dto.setApiKey("test-key");
dto.setModelCode("gpt-test");
dto.setTestMessage("璇峰洖澶嶏細杩炴帴姝e父");
dto.setTestMessage("请回复:连接正常");
service.testLlmConnectivity(dto);
@ -84,7 +85,7 @@ class AiModelServiceImplTest {
.orElse("")
.trim()
);
assertEquals("璇峰洖澶嶏細杩炴帴姝e父", requestJson.path("messages").path(1).path("content").asText());
assertEquals("请回复:连接正常", requestJson.path("messages").path(1).path("content").asText());
}
@Test
@ -137,6 +138,41 @@ class AiModelServiceImplTest {
service.testLlmConnectivity(dto);
}
@Test
void testLlmConnectivityShouldAcceptReasoningWhenContentMissing() throws Exception {
server = HttpServer.create(new InetSocketAddress(0), 0);
server.createContext("/v1/chat/completions", exchange -> {
captureRequest(exchange, new AtomicReference<>(), new AtomicReference<>(), new AtomicReference<>());
writeJson(exchange, 200, """
{
"choices": [
{
"message": {
"content": null,
"reasoning": "The model produced reasoning text before emitting the final answer."
},
"finish_reason": "length"
}
]
}
""");
});
server.start();
AiModelServiceImpl service = new AiModelServiceImpl(
objectMapper,
mock(AsrModelMapper.class),
mock(LlmModelMapper.class)
);
AiModelDTO dto = new AiModelDTO();
dto.setBaseUrl("http://127.0.0.1:" + server.getAddress().getPort());
dto.setApiPath("/v1/chat/completions");
dto.setModelCode("gpt-test");
service.testLlmConnectivity(dto);
}
@Test
void testLlmConnectivityShouldUseHttp11ClientForCompatibility() throws Exception {
AiModelServiceImpl service = new AiModelServiceImpl(
@ -181,6 +217,57 @@ class AiModelServiceImplTest {
assertNull(captor.getValue().getApiKey());
}
@Test
void saveModelShouldPersistSortOrderForLlm() {
AsrModelMapper asrModelMapper = mock(AsrModelMapper.class);
LlmModelMapper llmModelMapper = mock(LlmModelMapper.class);
when(llmModelMapper.insert(any(LlmModel.class))).thenReturn(1);
AiModelServiceImpl service = new AiModelServiceImpl(
objectMapper,
asrModelMapper,
llmModelMapper
);
AiModelDTO dto = new AiModelDTO();
dto.setModelType("LLM");
dto.setModelName("ordered-llm");
dto.setProvider("openai");
dto.setBaseUrl("http://127.0.0.1:9000");
dto.setApiPath("/v1/chat/completions");
dto.setModelCode("gpt-test");
dto.setIsDefault(0);
dto.setStatus(1);
dto.setSortOrder(7);
service.saveModel(dto);
ArgumentCaptor<LlmModel> captor = ArgumentCaptor.forClass(LlmModel.class);
verify(llmModelMapper, times(1)).insert(captor.capture());
assertEquals(7, captor.getValue().getSortOrder());
}
@Test
void saveModelShouldRejectDisabledDefaultModel() {
AiModelServiceImpl service = new AiModelServiceImpl(
objectMapper,
mock(AsrModelMapper.class),
mock(LlmModelMapper.class)
);
AiModelDTO dto = new AiModelDTO();
dto.setModelType("LLM");
dto.setModelName("invalid-default");
dto.setProvider("openai");
dto.setBaseUrl("http://127.0.0.1:9000");
dto.setApiPath("/v1/chat/completions");
dto.setModelCode("gpt-test");
dto.setIsDefault(1);
dto.setStatus(0);
assertThrows(RuntimeException.class, () -> service.saveModel(dto));
}
@Test
void saveModelShouldAllowCustomAsrWithoutApiKeyAndSkipSync() {
AsrModelMapper asrModelMapper = mock(AsrModelMapper.class);

View File

@ -3,7 +3,9 @@ package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile;
import com.imeeting.entity.biz.AsrModel;
import com.imeeting.entity.biz.HotWord;
import com.imeeting.entity.biz.LlmModel;
import com.imeeting.entity.biz.PromptTemplate;
import com.imeeting.mapper.biz.AsrModelMapper;
import com.imeeting.mapper.biz.LlmModelMapper;
@ -18,6 +20,7 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertIterableEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ -146,6 +149,48 @@ class MeetingRuntimeProfileResolverImplTest {
assertIterableEquals(List.of("OpenAI", "Codex"), profile.getResolvedHotWords());
}
@Test
void resolveShouldFallbackToFirstEnabledModelUsingSortOrder() {
AiModelService aiModelService = mock(AiModelService.class);
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
HotWordService hotWordService = mock(HotWordService.class);
AsrModelMapper asrModelMapper = mock(AsrModelMapper.class);
LlmModelMapper llmModelMapper = mock(LlmModelMapper.class);
MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
aiModelService,
promptTemplateService,
hotWordService,
asrModelMapper,
llmModelMapper
);
when(aiModelService.getDefaultModel("ASR", 1L)).thenReturn(null);
when(aiModelService.getDefaultModel("LLM", 1L)).thenReturn(null);
when(asrModelMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(asrEntity(11L));
when(llmModelMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(llmEntity(22L));
when(aiModelService.getModelById(11L, "ASR")).thenReturn(enabledModel(11L, 1L, "ASR-Model"));
when(aiModelService.getModelById(22L, "LLM")).thenReturn(enabledModel(22L, 1L, "LLM-Model"));
when(promptTemplateService.getOne(any(LambdaQueryWrapper.class))).thenReturn(enabledPrompt(33L, 1L, "Default Prompt"));
RealtimeMeetingRuntimeProfile profile = resolver.resolve(
1L,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
List.of()
);
assertEquals(11L, profile.getResolvedAsrModelId());
assertEquals(22L, profile.getResolvedSummaryModelId());
}
private AiModelVO enabledModel(Long id, Long tenantId, String name) {
AiModelVO model = new AiModelVO();
model.setId(id);
@ -163,4 +208,18 @@ class MeetingRuntimeProfileResolverImplTest {
template.setStatus(1);
return template;
}
private AsrModel asrEntity(Long id) {
AsrModel entity = new AsrModel();
entity.setId(id);
entity.setStatus(1);
return entity;
}
private LlmModel llmEntity(Long id) {
LlmModel entity = new LlmModel();
entity.setId(id);
entity.setStatus(1);
return entity;
}
}

View File

@ -16,6 +16,7 @@ export interface AiModelVO {
mediaConfig?: Record<string, any>;
isDefault: number;
status: number;
sortOrder?: number;
remark?: string;
createdAt: string;
}
@ -45,6 +46,7 @@ export interface AiModelDTO {
mediaConfig?: Record<string, any>;
isDefault: number;
status: number;
sortOrder?: number;
remark?: string;
}

View File

@ -0,0 +1,61 @@
import http from "../http";
export interface HotWordGroupVO {
id: number;
tenantId: number;
groupName: string;
creatorId: number;
status: number;
hotWordCount: number;
remark?: string;
createdAt: string;
updatedAt: string;
}
export interface HotWordGroupDTO {
id?: number;
tenantId?: number;
groupName: string;
status: number;
remark?: string;
}
export const getHotWordGroupPage = (params: {
current: number;
size: number;
name?: string;
tenantId?: number;
}) => {
return http.get<{ code: string; data: { records: HotWordGroupVO[]; total: number }; msg: string }>(
"/api/biz/hotword-group/page",
{ params }
);
};
export const getHotWordGroupOptions = (tenantId?: number) => {
return http.get<{ code: string; data: HotWordGroupVO[]; msg: string }>(
"/api/biz/hotword-group/options",
{ params: { tenantId } }
);
};
export const saveHotWordGroup = (data: HotWordGroupDTO) => {
return http.post<{ code: string; data: HotWordGroupVO; msg: string }>(
"/api/biz/hotword-group",
data
);
};
export const updateHotWordGroup = (data: HotWordGroupDTO) => {
return http.put<{ code: string; data: HotWordGroupVO; msg: string }>(
"/api/biz/hotword-group",
data
);
};
export const deleteHotWordGroup = (id: number, tenantId?: number) => {
return http.delete<{ code: string; data: boolean; msg: string }>(
`/api/biz/hotword-group/${id}`,
{ params: { tenantId } }
);
};

View File

@ -16,7 +16,8 @@
"error": "Error",
"total": "Total {{total}} items",
"loading": "Loading...",
"view": "View"
"view": "View",
"maxLength": "exceeds the maximum length the maximum length is{{length}}"
},
"layout": {
"profile": "Profile",
@ -170,6 +171,7 @@
"drawerTitleEdit": "Edit Tenant",
"tenantName": "Tenant Name",
"tenantCode": "Tenant Code",
"defaultAdminUsername": "Default Admin Username",
"contactName": "Contact Name",
"contactPhone": "Phone"
},
@ -231,7 +233,8 @@
"copyright": "Copyright",
"desc": "System Description",
"uploadHint": "Click or drag to upload",
"uploadLimit": "Recommend 1:1 ratio, max 2MB",
"uploadLimit": "Recommend 1:1 ratio, max {{size}}",
"uploadTooLarge": "Image size must not exceed {{size}}",
"basicInfo": "Basic Information",
"brandAssets": "Brand Assets",
"complianceFooter": "Compliance and Footer",
@ -261,13 +264,13 @@
"updatePassword": "Update Password",
"passwordsDoNotMatch": "Passwords do not match.",
"standardUser": "Standard User",
"avatarUrl": "Avatar URL",
"avatarUrlPlaceholder": "Enter avatar image URL",
"uploadAvatar": "Upload Avatar",
"botCredentialTab": "Bot Credential",
"botCredentialHint": "Use this credential pair to access /mcp with X-Bot-Id and X-Bot-Secret.",
"botCredentialHintDesc": "The secret is shown only after generation. Store it securely after copying.",
"botBindStatus": "Binding Status",
"avatarUrl": "Avatar URL",
"avatarUrlPlaceholder": "Enter avatar image URL",
"uploadAvatar": "Upload Avatar",
"botBound": "Bound",
"botUnbound": "Not Generated",
"botSecretHidden": "Hidden. Generate or reset to get a new secret.",
@ -310,7 +313,11 @@
"failed": "Failed",
"system": "System",
"platform": "Platform",
"close": "Close"
"close": "Close",
"cleanCurrent": "Clear {{type}}",
"cleanConfirmTitle": "Clear {{type}}?",
"cleanConfirmDescription": "This will remove all records of the current log type and ignores the active filters.",
"cleanSuccess": "{{type}} cleared"
},
"devicesExt": {
"operationSucceeded": "Operation succeeded",
@ -358,6 +365,9 @@
"expired": "Expired",
"tenantNamePlaceholder": "Example: Cloud Intelligence",
"tenantCodePlaceholder": "Example: UNIS",
"defaultAdminUsernamePlaceholder": "Default: amdin@tenantcode",
"defaultAdminUsernameTip": "The default value is generated from the lowercase tenant code and can be changed manually. Username uniqueness is validated on save.",
"defaultAdminUsernameFormatTip": "Username may contain only lowercase letters, numbers, @, and _.",
"contactNamePlaceholder": "Contact person",
"contactPhonePlaceholder": "Mobile or phone",
"remarkPlaceholder": "Optional tenant description",
@ -374,6 +384,8 @@
"emailPlaceholder": "example@domain.com",
"passwordKeepPlaceholder": "Leave blank to keep current password",
"passwordInitPlaceholder": "Set initial password",
"confirmPassword": "Confirm Password",
"confirmPasswordPlaceholder": "Please confirm the password",
"selectOrgPlaceholder": "Select organization or department",
"membershipsTitle": "Tenant Memberships",
"membershipTitle": "Membership #{{index}}",

View File

@ -14,9 +14,10 @@
"refresh": "刷新",
"success": "操作成功",
"error": "操作失败",
"total": "共{{total}}条数据",
"total": "共 {{total}} 条数据",
"loading": "加载中...",
"view": "查看"
"view": "查看",
"maxLength": "超出最大长度,最大长度为{{length}}"
},
"layout": {
"profile": "个人信息",
@ -170,6 +171,7 @@
"drawerTitleEdit": "编辑租户信息",
"tenantName": "租户名称",
"tenantCode": "租户编码",
"defaultAdminUsername": "默认管理员用户名",
"contactName": "联系人姓名",
"contactPhone": "联系电话"
},
@ -231,7 +233,8 @@
"copyright": "版权信息",
"desc": "系统描述",
"uploadHint": "点击或拖拽上传图片",
"uploadLimit": "建议比例 1:1大小不超过 2MB",
"uploadLimit": "建议比例 1:1大小不超过 {{size}}",
"uploadTooLarge": "上传图片不能超过 {{size}}",
"basicInfo": "基础信息",
"brandAssets": "品牌资源",
"complianceFooter": "合规与页脚",
@ -260,15 +263,14 @@
"saveChanges": "保存修改",
"updatePassword": "更新密码",
"passwordsDoNotMatch": "两次输入的密码不一致。",
"standardUser": "普通用户",
"botCredentialTab": "Bot 凭证",
"botCredentialHint": "使用这组凭证通过 X-Bot-Id 和 X-Bot-Secret 访问 /mcp。",
"botCredentialHintDesc": "Secret 只会在生成后显示一次,请复制后妥善保管。",
"botBindStatus": "绑定状态",
"standardUser": "\u666e\u901a\u7528\u6237",
"avatarUrl": "\u5934\u50cf URL",
"avatarUrlPlaceholder": "\u8bf7\u8f93\u5165\u5934\u50cf\u56fe\u7247\u5730\u5740",
"uploadAvatar": "\u4e0a\u4f20\u5934\u50cf",
"botCredentialTab": "Bot \u51ed\u8bc1",
"botCredentialHint": "使用这组凭证通过 X-Bot-Id 和 X-Bot-Secret 访问 /mcp。",
"botCredentialHintDesc": "Secret 只会在生成后显示一次,请复制后妥善保管。",
"botBindStatus": "绑定状态",
"botBound": "已绑定",
"botUnbound": "未生成",
"botSecretHidden": "已隐藏。如需查看新的 Secret请重新生成。",
@ -311,7 +313,11 @@
"failed": "失败",
"system": "系统",
"platform": "平台",
"close": "关闭"
"close": "关闭",
"cleanCurrent": "清空{{type}}",
"cleanConfirmTitle": "确认清空{{type}}",
"cleanConfirmDescription": "将清空当前日志类型的全部记录,不受当前筛选条件影响。",
"cleanSuccess": "{{type}}已清空"
},
"devicesExt": {
"operationSucceeded": "操作成功",
@ -359,6 +365,9 @@
"expired": "已过期",
"tenantNamePlaceholder": "例如:云智协同",
"tenantCodePlaceholder": "例如UNIS",
"defaultAdminUsernamePlaceholder": "默认amdin@租户编码小写",
"defaultAdminUsernameTip": "默认值会根据租户编码自动生成,并且可以手动修改;保存时会校验用户名是否重复。",
"defaultAdminUsernameFormatTip": "用户名只能输入小写字母、数字、@ 和 _。",
"contactNamePlaceholder": "请输入联系人姓名",
"contactPhonePlaceholder": "请输入手机号或座机号",
"remarkPlaceholder": "选填,填写租户说明",
@ -375,6 +384,8 @@
"emailPlaceholder": "example@domain.com",
"passwordKeepPlaceholder": "不填写则保留当前密码",
"passwordInitPlaceholder": "请设置初始密码",
"confirmPassword": "确认密码",
"confirmPasswordPlaceholder": "请再次输入密码",
"selectOrgPlaceholder": "请选择组织或部门",
"membershipsTitle": "租户归属",
"membershipTitle": "归属 #{{index}}",

View File

@ -1,5 +1,5 @@
.users-page {
padding: 24px;
min-height: 100%;
}
.users-header {
@ -19,7 +19,7 @@
}
.users-table-toolbar {
margin-bottom: 20px;
margin-bottom: 0;
}
.users-search-input {

View File

@ -397,9 +397,18 @@ export default function Users() {
</Card>
<Card className="app-page__content-card flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
<div className="flex-1 min-h-0 h-full">
<Table rowKey="userId" columns={columns} dataSource={filteredData} loading={loading} size="middle" scroll={{ y: "calc(100vh - 420px)" }} pagination={getStandardPagination(filteredData.length, current, pageSize, (page, size) => { setCurrent(page); setPageSize(size); })} />
</div>
<Table
rowKey="userId"
columns={columns}
dataSource={filteredData}
loading={loading}
size="middle"
scroll={{ y: "calc(100vh - 380px)" }}
pagination={getStandardPagination(filteredData.length, current, pageSize, (page, size) => {
setCurrent(page);
setPageSize(size);
})}
/>
</Card>
<Drawer title={<div className="user-drawer-title"><UserOutlined className="mr-2" aria-hidden="true" />{editing ? t("users.drawerTitleEdit") : t("users.drawerTitleCreate")}</div>} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>

View File

@ -236,7 +236,7 @@ export default function RolePermissionBinding() {
title: t("common.status"),
dataIndex: "status",
width: 80,
render: (value: number) => (value === 1 ? <Tag color="green" className="m-0"></Tag> : <Tag className="m-0"></Tag>)
render: (value: number) => (value === 1 ? <Tag color="green" className="m-0"></Tag> : <Tag className="m-0"></Tag>)
}
]}
/>
@ -266,7 +266,7 @@ export default function RolePermissionBinding() {
}}
defaultExpandAll
/>
{!permissions.length && !loadingPerms ? <Empty description="鏆傛棤鏉冮檺鏁版嵁" /> : null}
{!permissions.length && !loadingPerms ? <Empty description="暂无权限数据" /> : null}
</div>
) : (
<div className="flex flex-col items-center justify-center py-20 bg-gray-50 rounded-lg border border-dashed border-gray-200">

View File

@ -67,6 +67,7 @@ const AiModels: React.FC = () => {
const localProfileLoadedRef = useRef(false);
const provider = Form.useWatch("provider", form);
const isDefaultChecked = Form.useWatch("isDefaultChecked", form);
const isLocalProvider = String(provider || "").toLowerCase() === "custom";
const isPlatformAdmin = useMemo(() => {
@ -152,6 +153,7 @@ const AiModels: React.FC = () => {
modelType: activeType,
isDefaultChecked: false,
statusChecked: true,
sortOrder: 0,
temperature: 0.7,
topP: 0.9,
apiPath: "/v1/chat/completions",
@ -239,6 +241,10 @@ const AiModels: React.FC = () => {
const handleSubmit = async () => {
const values = await form.validateFields();
if (values.isDefaultChecked && !values.statusChecked) {
message.warning("默认模型必须保持启用状态");
return;
}
const payload: AiModelDTO = {
id: editingId ?? undefined,
modelType: values.modelType,
@ -260,6 +266,7 @@ const AiModels: React.FC = () => {
topP: values.topP,
isDefault: values.isDefaultChecked ? 1 : 0,
status: values.statusChecked ? 1 : 0,
sortOrder: values.sortOrder ?? 0,
remark: values.remark,
};
@ -360,6 +367,12 @@ const AiModels: React.FC = () => {
},
},
{ title: "模型名称(code)", dataIndex: "modelCode", key: "modelCode" },
{
title: "排序",
dataIndex: "sortOrder",
key: "sortOrder",
render: (value: number | undefined) => value ?? 0,
},
{
title: "状态",
dataIndex: "status",
@ -504,6 +517,14 @@ const AiModels: React.FC = () => {
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="sortOrder" label="排序值">
<InputNumber min={0} style={{ width: "100%" }} />
</Form.Item>
</Col>
</Row>
<Form.Item name="baseUrl" label="Base URL" rules={[{ required: true, message: "请输入 Base URL" }]}>
<Input placeholder="https://api.example.com/v1" />
</Form.Item>
@ -618,12 +639,20 @@ const AiModels: React.FC = () => {
<Row gutter={16}>
<Col span={8}>
<Form.Item name="isDefaultChecked" label="设为默认" valuePropName="checked">
<Switch checkedChildren="是" unCheckedChildren="否" />
<Switch
checkedChildren="是"
unCheckedChildren="否"
onChange={(checked) => {
if (checked) {
form.setFieldValue("statusChecked", true);
}
}}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="statusChecked" label="状态" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
<Switch checkedChildren="启用" unCheckedChildren="禁用" disabled={Boolean(isDefaultChecked)} />
</Form.Item>
</Col>
</Row>

View File

@ -8,6 +8,7 @@ import {
Form,
Input,
InputNumber,
List,
Modal,
Popconfirm,
Row,
@ -20,9 +21,7 @@ import {
import {
DeleteOutlined,
EditOutlined,
FolderOpenOutlined,
PlusOutlined,
SearchOutlined,
} from "@ant-design/icons";
import { useTranslation } from "react-i18next";
import { useDict } from "../../hooks/useDict";
@ -43,6 +42,7 @@ import {
type HotWordGroupVO,
} from "../../api/business/hotwordGroup";
import AppPagination from "../../components/shared/AppPagination";
import ListActionBar from "../../components/shared/ListActionBar/ListActionBar";
const { Option } = Select;
const { Text } = Typography;
@ -90,13 +90,14 @@ const HotWords: React.FC = () => {
const [submitLoading, setSubmitLoading] = useState(false);
const [groupOptions, setGroupOptions] = useState<HotWordGroupVO[]>([]);
const [groupManageVisible, setGroupManageVisible] = useState(false);
const [groupEditorVisible, setGroupEditorVisible] = useState(false);
const [groupLoading, setGroupLoading] = useState(false);
const [groupSubmitLoading, setGroupSubmitLoading] = useState(false);
const [groupData, setGroupData] = useState<HotWordGroupVO[]>([]);
const [editingGroupId, setEditingGroupId] = useState<number | null>(null);
const [filterVisible, setFilterVisible] = useState(false);
const groupNameMap = useMemo(
() => Object.fromEntries(groupOptions.map((item) => [item.id, item.groupName])) as Record<number, string>,
[groupOptions]
@ -104,10 +105,11 @@ const HotWords: React.FC = () => {
useEffect(() => {
void fetchData();
}, [current, searchCategory, searchGroupId, searchWord, size]);
}, [current, searchCategory, searchGroupId, size]);
useEffect(() => {
void loadGroupOptions();
void loadGroupPage();
}, []);
const fetchData = async () => {
@ -165,7 +167,7 @@ const HotWords: React.FC = () => {
} else {
setEditingId(null);
form.resetFields();
form.setFieldsValue({ weight: 2, status: 1 });
form.setFieldsValue({ weight: 2, status: 1, hotWordGroupId: searchGroupId });
}
setModalVisible(true);
};
@ -174,6 +176,7 @@ const HotWords: React.FC = () => {
await deleteHotWord(id);
message.success("删除成功");
await fetchData();
await loadGroupPage();
};
const handleSubmit = async () => {
@ -194,7 +197,7 @@ const HotWords: React.FC = () => {
message.success("新增成功");
}
setModalVisible(false);
await Promise.all([fetchData(), loadGroupOptions(), groupManageVisible ? loadGroupPage() : Promise.resolve()]);
await Promise.all([fetchData(), loadGroupOptions(), loadGroupPage()]);
} finally {
setSubmitLoading(false);
}
@ -216,12 +219,8 @@ const HotWords: React.FC = () => {
}
};
const openGroupManager = async () => {
setGroupManageVisible(true);
await loadGroupPage();
};
const openGroupEditor = (record?: HotWordGroupVO) => {
const openGroupEditor = (record?: HotWordGroupVO, e?: React.MouseEvent) => {
e?.stopPropagation();
if (record) {
setEditingGroupId(record.id);
groupForm.setFieldsValue({
@ -255,9 +254,13 @@ const HotWords: React.FC = () => {
}
};
const handleDeleteGroup = async (id: number) => {
const handleDeleteGroup = async (id: number, e?: React.MouseEvent) => {
e?.stopPropagation();
await deleteHotWordGroup(id, isPlatformAdmin ? activeTenantId : undefined);
message.success("热词组删除成功");
if (searchGroupId === id) {
setSearchGroupId(undefined);
}
await Promise.all([loadGroupOptions(), loadGroupPage(), fetchData()]);
};
@ -325,121 +328,178 @@ const HotWords: React.FC = () => {
},
];
const groupColumns = [
{
title: "热词组名称",
dataIndex: "groupName",
key: "groupName",
render: (value: string) => <Text strong>{value}</Text>,
},
{
title: "热词数量",
dataIndex: "hotWordCount",
key: "hotWordCount",
render: (value: number) => <Tag color={value >= 200 ? "red" : "processing"}>{value}/200</Tag>,
},
{
title: "状态",
dataIndex: "status",
key: "status",
render: (value: number) => value === 1 ? <Badge status="success" text="启用" /> : <Badge status="default" text="禁用" />,
},
{
title: "备注",
dataIndex: "remark",
key: "remark",
render: (value?: string) => value || "-",
},
{
title: "操作",
key: "action",
render: (_: unknown, record: HotWordGroupVO) => (
<Space>
<Button type="link" size="small" onClick={() => openGroupEditor(record)}>
</Button>
<Popconfirm
title="确定删除这个热词组吗?"
description="删除前必须先解除模板引用并清空组内热词。"
onConfirm={() => handleDeleteGroup(record.id)}
okText={t("common.confirm")}
cancelText={t("common.cancel")}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
const filterContent = (
<div style={{ width: 200 }}>
<div style={{ marginBottom: 8 }}></div>
<Select
placeholder="按分类筛选"
style={{ width: '100%' }}
allowClear
value={searchCategory}
onChange={(value) => {
setSearchCategory(value);
setCurrent(1);
setFilterVisible(false);
}}
>
{categories.map((item) => (
<Option key={item.itemValue} value={item.itemValue}>
{item.itemLabel}
</Option>
))}
</Select>
</div>
);
return (
<div className="app-page">
<Card
className="app-page__content-card shadow-sm"
style={{ flex: 1, minHeight: 0 }}
styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}
title="热词管理"
extra={
<Space wrap>
<Select
placeholder="按分类筛选"
style={{ width: 150 }}
allowClear
onChange={(value) => {
setSearchCategory(value);
setCurrent(1);
<div className="app-page" style={{ padding: '16px', display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ display: 'flex', gap: '16px', flex: 1, minHeight: 0 }}>
{/* Left Panel: Hotword Groups */}
<Card
className="shadow-sm"
title="热词组"
style={{ width: '25%', display: 'flex', flexDirection: 'column', minWidth: 280 }}
styles={{ body: { padding: 0, flex: 1, overflow: 'auto' } }}
extra={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => openGroupEditor()}
size="small"
/>
}
>
<List
loading={groupLoading}
dataSource={[{ id: undefined, groupName: '全部热词' } as any, ...groupData]}
renderItem={(item) => {
const isSelected = searchGroupId === item.id;
const actions = [];
if (item.id) {
actions.push(
<Button
type="text"
icon={<EditOutlined />}
onClick={(e) => openGroupEditor(item, e)}
size="small"
/>
);
actions.push(
<Popconfirm
title="确定删除这个热词组吗?"
description="删除前必须先解除模板引用并清空组内热词。"
onConfirm={(e) => handleDeleteGroup(item.id, e)}
onCancel={e => e?.stopPropagation()}
>
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={e => e.stopPropagation()}
size="small"
/>
</Popconfirm>
);
}
return (
<List.Item
onClick={() => {
setSearchGroupId(item.id);
setCurrent(1);
}}
style={{
cursor: 'pointer',
padding: '12px 24px',
background: isSelected ? 'var(--ant-color-primary-bg, #e6f4ff)' : 'transparent',
borderBottom: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
transition: 'background 0.3s'
}}
actions={actions}
>
<List.Item.Meta
title={
<Text strong style={{ color: isSelected ? 'var(--ant-color-primary, #1677ff)' : 'inherit' }}>
{item.groupName}
</Text>
}
description={
item.id
? <><Tag color={item.hotWordCount >= 200 ? "red" : "processing"}>{item.hotWordCount}/200</Tag> {item.remark}</>
: '查看所有热词'
}
/>
</List.Item>
);
}}
/>
</Card>
{/* Right Panel: Hotwords */}
<Card
className="shadow-sm"
title={searchGroupId ? groupData.find(g => g.id === searchGroupId)?.groupName || '热词列表' : '全部热词'}
style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}
styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}
>
<div style={{ padding: '16px 24px 0' }}>
<ListActionBar
actions={[
{
key: 'add',
label: '新增热词',
type: 'primary',
icon: <PlusOutlined />,
onClick: () => handleOpenModal()
}
]}
search={{
placeholder: '搜索热词原文',
value: searchWord,
onChange: (val) => setSearchWord(val),
onSearch: () => {
setCurrent(1);
void fetchData();
}
}}
>
{categories.map((item) => (
<Option key={item.itemValue} value={item.itemValue}>
{item.itemLabel}
</Option>
))}
</Select>
<Select
placeholder="按热词组筛选"
style={{ width: 180 }}
allowClear
options={groupOptions.map((item) => ({ label: item.groupName, value: item.id }))}
onChange={(value) => {
setSearchGroupId(value);
filter={{
content: filterContent,
title: '高级筛选',
visible: filterVisible,
onVisibleChange: setFilterVisible,
isActive: !!searchCategory,
selectedLabel: searchCategory ? categories.find(c => c.itemValue === searchCategory)?.itemLabel : '筛选'
}}
showRefresh
onRefresh={() => {
setCurrent(1);
void fetchData();
void loadGroupPage();
}}
/>
<Input
placeholder="搜索热词原文"
prefix={<SearchOutlined />}
allowClear
onPressEnter={(e) => {
setSearchWord((e.target as HTMLInputElement).value);
setCurrent(1);
}}
style={{ width: 200 }}
</div>
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "16px 24px 0" }}>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
scroll={{ x: "max-content" }}
pagination={false}
/>
<Button icon={<FolderOpenOutlined />} onClick={() => void openGroupManager()}>
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenModal()}>
</Button>
</Space>
}
>
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "24px 24px 0" }}>
<Table columns={columns} dataSource={data} rowKey="id" loading={loading} scroll={{ x: "max-content" }} pagination={false} />
</div>
<AppPagination
current={current}
pageSize={size}
total={total}
onChange={(page, pageSize) => {
setCurrent(page);
setSize(pageSize);
}}
/>
</Card>
</div>
<AppPagination
current={current}
pageSize={size}
total={total}
onChange={(page, pageSize) => {
setCurrent(page);
setSize(pageSize);
}}
/>
</Card>
</div>
<Modal
title={editingId ? "编辑热词" : "新增热词"}
@ -500,22 +560,6 @@ const HotWords: React.FC = () => {
</Form>
</Modal>
<Modal
title="热词组管理"
open={groupManageVisible}
onCancel={() => setGroupManageVisible(false)}
footer={null}
width={900}
destroyOnHidden
>
<div style={{ marginBottom: 16, display: "flex", justifyContent: "flex-end" }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openGroupEditor()}>
</Button>
</div>
<Table rowKey="id" columns={groupColumns} dataSource={groupData} loading={groupLoading} pagination={false} />
</Modal>
<Modal
title={editingGroupId ? "编辑热词组" : "新增热词组"}
open={groupEditorVisible}

View File

@ -154,10 +154,10 @@ export default function Tenants() {
<Card className="app-page__filter-card border-0" style={{ borderRadius: "12px" }} styles={{ body: { padding: "16px" } }}>
<Space wrap size="middle" className="app-page__toolbar" style={{ justifyContent: "space-between", width: "100%" }}>
<Space wrap size="middle" className="app-page__toolbar">
<Input placeholder={t("tenants.tenantName")} prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />} style={{ width: 220, borderRadius: "6px" }} value={params.name} onChange={(event) => setParams({ ...params, name: event.target.value })} allowClear />
<Input placeholder={t("tenants.tenantCode")} style={{ width: 180, borderRadius: "6px" }} value={params.code} onChange={(event) => setParams({ ...params, code: event.target.value })} allowClear />
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={handleSearch} style={{ borderRadius: "6px" }}>{t("common.search")}</Button>
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={handleReset} style={{ borderRadius: "6px" }}>{t("common.reset")}</Button>
<Input placeholder={t("tenants.tenantName")} prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />} style={{ width: 220, borderRadius: "6px" }} value={params.name} onChange={(event) => setParams({ ...params, name: event.target.value })} allowClear />
<Input placeholder={t("tenants.tenantCode")} style={{ width: 180, borderRadius: "6px" }} value={params.code} onChange={(event) => setParams({ ...params, code: event.target.value })} allowClear />
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={handleSearch} style={{ borderRadius: "6px" }}>{t("common.search")}</Button>
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={handleReset} style={{ borderRadius: "6px" }}>{t("common.reset")}</Button>
</Space>
{can("sys_tenant:create") && <Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate} style={{ borderRadius: "6px" }}>{t("common.create")}</Button>}
</Space>
@ -202,23 +202,23 @@ export default function Tenants() {
</Row>
{!editing && (
<Form.Item
label={t("tenants.defaultAdminUsername", { defaultValue: "默认管理员账户" })}
label={t("tenants.defaultAdminUsername")}
name="defaultAdminUsername"
rules={[
{ required: true, message: t("tenants.defaultAdminUsername", { defaultValue: "默认管理员账户" }) },
{ required: true, message: t("tenants.defaultAdminUsername") },
{
pattern: LOGIN_NAME_PATTERN,
message: t("tenantsExt.defaultAdminUsernameFormatTip", { defaultValue: "登录名只能输入数字、小写英文、@ 和 _" })
message: t("tenantsExt.defaultAdminUsernameFormatTip")
}
]}
getValueFromEvent={(event) => {
setAdminAccountTouched(true);
return sanitizeLoginName(event?.target?.value);
}}
extra={t("tenantsExt.defaultAdminUsernameTip", { defaultValue: "默认值会根据租户编码自动生成,可手动修改;保存时会校验登录名是否重复。登录名只能输入数字、小写英文、@ 和 _。" })}
extra={t("tenantsExt.defaultAdminUsernameTip")}
>
<Input
placeholder={t("tenantsExt.defaultAdminUsernamePlaceholder", { defaultValue: "默认admin@租户编码小写" })}
placeholder={t("tenantsExt.defaultAdminUsernamePlaceholder")}
className="tabular-nums"
/>
</Form.Item>