Merge pull request 'dev_na' (#1) from dev_na into master

Reviewed-on: https://git.unissense.tech/chenh/imeeting/pulls/1
master
chenh 2026-09-04 09:00:27 +00:00
commit 151236a2cd
796 changed files with 99802 additions and 17774 deletions

45
.gitignore vendored 100644
View File

@ -0,0 +1,45 @@
# Local environment files
.env
.env.*
backend/.env
backend/.env.*
backend/src/main/resources/application-local.yml
# Logs
*.log
!backend/.env.example
.omx/
/backend/target/
/.idea
/.vscode
/.codegraph
/.agents
/backend/.env.example
/backend/.mvn-settings-ali.xml
/backend/.mvn-settings-codex.xml
/backend/imeeting-backend.iml
/backend/lombok.config
/database/
/deploy/
/docs/
/rebel.xml
/.editorconfig
/frontend/design/
/components/
/bat/
/.agents/
/.codex/
/.gemini/
/.idea/
/.m2-temp/
/.m2-test/
/.omx/
/APP_LOG_PATH_IS_UNDEFINED/
/backend/.m2repo/
/backend/m2repo_local/
/backend/src/test/
/backend/target/
/.claude
/web-fe/
/.idea/
/.idea/*

View File

@ -1,227 +0,0 @@
# AGENTS.mdBackend
## 一、项目定位
这是一个 **智能语音识别与总结系统的后台服务**,主要职责包括:
* 后台管理(用户 / 角色 / 权限)
* 设备接入与管理
* 任务调度与数据管理
* 对接外部 AI 转录服务(仅接口调用,不实现 AI
本模块为 **Java 后端服务**,不包含前端页面逻辑。
---
## 二、技术栈(必须遵守)
* Java: **17**
* Spring Boot: **3.x**
* Web: Spring MVC
* Security: **Spring Security + JWT**
* ORM: **MyBatis / MyBatis-Plus禁止 Hibernate / JPA**
* Database: **PostgreSQL**
* Cache: Redis
* Build Tool: Maven
⚠️ 禁止引入与以上技术选型冲突的框架与中间件。
---
## 三、架构与包结构约定
### 基础包结构
```
com.xxx.project
├── common # 通用工具、常量、异常
├── config # Spring / 安全 / Web 配置
├── security # JWT、Filter、Security 配置
├── auth # 登录、鉴权
├── user # 用户管理
├── role # 角色管理
├── permission # 权限管理
├── device # 设备管理
├── dict # 字典/配置
└── task # 转录/业务任务
```
### 分层规范
* Controller仅负责协议与参数校验
* Service业务编排与事务边界
* Mapper只写数据库访问
* DTO/VO显式数据模型不透传实体
* 禁止 Controller 直接调用 Mapper
---
## 四、角色与定位
你是一位**务实型后端开发者 Agent**,目标是:
> 以最清晰、最朴素、最可验证的方式交付可工作的 Java 服务。
> 基本原则
> 1. 生成内容必须完整、可运行、不可省略。
> 2. 不允许伪代码。
> 3. 不允许使用"示例代码"字样。
> 4. 不允许省略 import。
> 5. 不允许省略异常处理。
> 6. 所有写操作必须考虑事务控制。
> 7. 所有删除操作必须为逻辑删除is_deleted
> 8. 所有表必须包含:
> - created_at TIMESTAMP(6)
> - updated_at TIMESTAMP(6)
> - is_deleted SMALLINT DEFAULT 0
### 核心理念
* 清晰的意图胜于巧妙的代码
* 显而易见 > 精妙复杂
* 奥卡姆剃刀:不应无必要地增加复杂度
* 组合优于继承
* 接口优于单例
* 显式数据流优于隐式魔法
### 风格约束
* 准确、简洁、可维护
* 小修改**不输出摘要**
* 不炫技、不做“聪明设计”
---
## 五、工作流程(强制)
### 5.1 规划阶段(复杂任务必需)
### 行为约束
1. 在执行任何修改前,必须**阅读并遵守**本项目的设计文档(位于 `docs/design/`)。
2. 所有功能改动都必须更新设计文档
3. 遵循代码风格、目录结构和 Git 工作流规则
需求必须先创建:
`IMPLEMENTATION_PLAN.md`
```
## Stage N: [Name]
Goal:
- 明确可交付物
Success Criteria:
- 可测试的验收标准
Tests:
- 具体测试用例
Status:
- Not Started | In Progress | Complete
```
规则:
* 35 个阶段
* 未完成前不得删除
* 未规划禁止直接写实现
---
### 5.2 实现循环TDD Only
严格顺序:
1. 理解
* 查找 ≥3 个相似实现
* 遵循现有项目约定
2. 测试Red
* 先写失败测试
* 只描述行为
3. 实现Green
* 最小代码通过
* 拒绝过度设计
4. 重构Refactor
* 在测试保护下清理
---
### 5.3 三次机会规则
同一问题最多尝试 **3 次**
若失败,必须停止并输出:
* 已尝试操作
* 完整错误
* 23 个相似方案
* 根本性反思
---
### 5.4. 变更同步规则
当数据库结构发生变更时,必须同步生成:
- Entity
- Mapper
- Service
- Controller
- DTO
- VO
- 前端类型定义
- API 封装
- 权限校验调整
同步修改backend/design/db_schema.md和backend/design/db_schema_pgsql.sql
禁止只修改数据库而不同步代码。
## 六、质量关卡DoD
交付前必须:
* 可编译
* 通过全部测试
* 新功能必有测试
* 无警告
* 不得随意引入新依赖
---
## 七、后端设计准则
* 显式优于隐式
* 数据流可追踪
* 依赖可替换
* 行为可测试
* 错误可观测
**禁止:**
* 魔法单例
* 全局状态
* 过早抽象
* 与技术栈冲突的框架
---
## 八、接口与安全规范
* 统一返回:`Result<T>`
* 必须参数校验
* 认证JWT
* 权限Spring Security
* 日志:结构化
* 异常:统一处理
---
**一句话原则:**
> 用最朴素的设计 + 最小的改动 + 最确定的测试,
> 构建显而易见正确的 Java 后端。

View File

@ -1,223 +1,480 @@
# 数据库结构文档PostgreSQL
本文档根据 `backend/design/db_schema_pgsql.sql` 生成,描述当前核心表结构、字段、约束与索引。
## 0. 租户与组织
### 0.1 `sys_tenant`(租户表)
| 字段 | 类型 | 约束 | 说明 |
# 鏁版嵁搴撶粨鏋勬枃妗紙PostgreSQL锛?
鏈枃妗f牴鎹?`backend/design/db_schema_pgsql.sql` 鐢熸垚锛屾弿杩板綋鍓嶆牳蹇冭〃缁撴瀯銆佸瓧娈点€佺害鏉熶笌绱㈠紩銆?
## 0. 绉熸埛涓庣粍缁?
### 0.1 `sys_tenant`锛堢鎴疯〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 租户ID |
| tenant_code | VARCHAR(64) | NOT NULL, UNIQUE | 租户编码 |
| tenant_name | VARCHAR(128) | NOT NULL | 租户名称 |
| status | SMALLINT | NOT NULL, DEFAULT 1 | 状态 |
| expire_time | TIMESTAMP(6) | | 过期时间 |
| contact_name | VARCHAR(64) | | 联系人 |
| contact_phone | VARCHAR(32) | | 联系电话 |
| remark | VARCHAR(255) | | 备注 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 更新时间 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 |
| id | BIGSERIAL | PK | 绉熸埛ID |
| tenant_code | VARCHAR(64) | NOT NULL, UNIQUE | 绉熸埛缂栫爜 |
| tenant_name | VARCHAR(128) | NOT NULL | 绉熸埛鍚嶇О |
| status | SMALLINT | NOT NULL, DEFAULT 1 | 鐘舵€?|
| expire_time | TIMESTAMP(6) | | 杩囨湡鏃堕棿 |
| contact_name | VARCHAR(64) | | 鑱旂郴浜?|
| contact_phone | VARCHAR(32) | | 鑱旂郴鐢佃瘽 |
| remark | VARCHAR(255) | | 澶囨敞 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 鏇存柊鏃堕棿 |
| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
索引:
- `uk_tenant_code``UNIQUE (tenant_code) WHERE is_deleted = FALSE`
绱㈠紩锛?- `uk_tenant_code`锛歚UNIQUE (tenant_code) WHERE is_deleted = FALSE`
### 0.2 `sys_org`(组织架构表)
| 字段 | 类型 | 约束 | 说明 |
### 0.2 `sys_org`锛堢粍缁囨灦鏋勮〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 组织ID |
| tenant_id | BIGINT | NOT NULL | 租户ID |
| parent_id | BIGINT | | 父级组织ID |
| org_name | VARCHAR(128) | NOT NULL | 组织名称 |
| org_code | VARCHAR(64) | | 组织编码 |
| org_path | VARCHAR(512) | | 组织路径 |
| sort_order | INTEGER | DEFAULT 0 | 排序 |
| status | SMALLINT | DEFAULT 1 | 状态 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 更新时间 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 |
| id | BIGSERIAL | PK | 缁勭粐ID |
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
| parent_id | BIGINT | | 鐖剁骇缁勭粐ID |
| org_name | VARCHAR(128) | NOT NULL | 缁勭粐鍚嶇О |
| org_code | VARCHAR(64) | | 缁勭粐缂栫爜 |
| org_path | VARCHAR(512) | | 缁勭粐璺緞 |
| sort_order | INTEGER | DEFAULT 0 | 鎺掑簭 |
| status | SMALLINT | DEFAULT 1 | 鐘舵€?|
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 鏇存柊鏃堕棿 |
| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
外键:
- `fk_org_parent``parent_id -> sys_org(id)`
- `fk_org_tenant``tenant_id -> sys_tenant(id)`
澶栭敭锛?- `fk_org_parent`锛歚parent_id -> sys_org(id)`
- `fk_org_tenant`锛歚tenant_id -> sys_tenant(id)`
索引:
- `idx_org_tenant``(tenant_id)`
绱㈠紩锛?- `idx_org_tenant`锛歚(tenant_id)`
## 1. 用户与角色
### 1.1 `sys_user`(用户表)
| 字段 | 类型 | 约束 | 说明 |
## 1. 鐢ㄦ埛涓庤鑹?
### 1.1 `sys_user`锛堢敤鎴疯〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| user_id | BIGSERIAL | PK | 用户ID |
| username | VARCHAR(50) | NOT NULL, UNIQUE | 登录名 |
| display_name | VARCHAR(50) | NOT NULL | 显示名 |
| email | VARCHAR(100) | | 邮箱 |
| phone | VARCHAR(30) | UNIQUE | 手机号 |
| password_hash | VARCHAR(255) | NOT NULL | 密码哈希 |
| status | SMALLINT | NOT NULL, DEFAULT 1 | 状态 |
| pwd_reset_required | SMALLINT | DEFAULT 1 | 首次登录是否需改密 |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 逻辑删除标记 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 |
| is_platform_admin | BOOLEAN | DEFAULT false | 是否平台管理员 |
| user_id | BIGSERIAL | PK | 鐢ㄦ埛ID |
| username | VARCHAR(50) | NOT NULL, UNIQUE | 鐧诲綍鍚?|
| display_name | VARCHAR(50) | NOT NULL | 鏄剧ず鍚?|
| email | VARCHAR(100) | | 閭 |
| phone | VARCHAR(30) | UNIQUE | 鎵嬫満鍙?|
| password_hash | VARCHAR(255) | NOT NULL | 瀵嗙爜鍝堝笇 |
| status | SMALLINT | NOT NULL, DEFAULT 1 | 鐘舵€?|
| pwd_reset_required | SMALLINT | DEFAULT 1 | 棣栨鐧诲綍鏄惁闇€鏀瑰瘑 |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
| is_platform_admin | BOOLEAN | DEFAULT false | 鏄惁骞冲彴绠$悊鍛?|
索引:
- `uk_user_username``UNIQUE (username) WHERE is_deleted = FALSE`
绱㈠紩锛?- `uk_user_username`锛歚UNIQUE (username) WHERE is_deleted = FALSE`
### 1.2 `sys_role`(角色表)
| 字段 | 类型 | 约束 | 说明 |
### 1.2 `sys_role`锛堣鑹茶〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| role_id | BIGSERIAL | PK | 角色ID |
| tenant_id | BIGINT | NOT NULL | 租户ID |
| role_code | VARCHAR(50) | NOT NULL | 角色编码(租户内唯一) |
| role_name | VARCHAR(50) | NOT NULL | 角色名称 |
| status | SMALLINT | NOT NULL, DEFAULT 1 | 状态 |
| remark | TEXT | | 备注 |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 逻辑删除标记 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 |
| role_id | BIGSERIAL | PK | 瑙掕壊ID |
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
| role_code | VARCHAR(50) | NOT NULL | 瑙掕壊缂栫爜锛堢鎴峰唴鍞竴锛?|
| role_name | VARCHAR(50) | NOT NULL | 瑙掕壊鍚嶇О |
| status | SMALLINT | NOT NULL, DEFAULT 1 | 鐘舵€?|
| remark | TEXT | | 澶囨敞 |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
索引:
- `idx_sys_role_tenant``(tenant_id)`
- `uk_role_code``UNIQUE (tenant_id, role_code) WHERE is_deleted = FALSE`
绱㈠紩锛?- `idx_sys_role_tenant`锛歚(tenant_id)`
- `uk_role_code`锛歚UNIQUE (tenant_id, role_code) WHERE is_deleted = FALSE`
### 1.3 `sys_user_role`(用户-角色关联表,租户强约束)
| 字段 | 类型 | 约束 | 说明 |
### 1.3 `sys_user_role`锛堢敤鎴?瑙掕壊鍏宠仈琛紝绉熸埛寮虹害鏉燂級
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 关联ID |
| tenant_id | BIGINT | NOT NULL | 租户ID |
| user_id | BIGINT | NOT NULL | 用户ID |
| role_id | BIGINT | NOT NULL | 角色ID |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 逻辑删除标记 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 |
| id | BIGSERIAL | PK | 鍏宠仈ID |
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
| user_id | BIGINT | NOT NULL | 鐢ㄦ埛ID |
| role_id | BIGINT | NOT NULL | 瑙掕壊ID |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
唯一约束:
- `UNIQUE (tenant_id, user_id, role_id) WHERE is_deleted = 0`
鍞竴绾︽潫锛?- `UNIQUE (tenant_id, user_id, role_id) WHERE is_deleted = 0`
### 1.4 `sys_tenant_user`(租户成员关联表)
| 字段 | 类型 | 约束 | 说明 |
### 1.4 `sys_tenant_user`锛堢鎴锋垚鍛樺叧鑱旇〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 关联ID |
| user_id | BIGINT | NOT NULL | 用户ID |
| tenant_id | BIGINT | NOT NULL | 租户ID |
| org_id | BIGINT | | 组织ID |
| status | SMALLINT | DEFAULT 1 | 状态 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 |
| id | BIGSERIAL | PK | 鍏宠仈ID |
| user_id | BIGINT | NOT NULL | 鐢ㄦ埛ID |
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
| org_id | BIGINT | | 缁勭粐ID |
| status | SMALLINT | DEFAULT 1 | 鐘舵€?|
| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
索引:
- `uk_tenant_user``UNIQUE (user_id, tenant_id) WHERE is_deleted = 0`
绱㈠紩锛?- `uk_tenant_user`锛歚UNIQUE (user_id, tenant_id) WHERE is_deleted = 0`
## 2. 权限/字典/参数(全局共享)
### 2.1 `sys_permission`(权限表)
| 字段 | 类型 | 约束 | 说明 |
## 2. 鏉冮檺/瀛楀吀/鍙傛暟锛堝叏灞€鍏变韩锛?
### 2.1 `sys_permission`锛堟潈闄愯〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| perm_id | BIGSERIAL | PK | 权限ID |
| parent_id | BIGINT | | 父级权限ID |
| name | VARCHAR(100) | NOT NULL | 权限名称 |
| code | VARCHAR(100) | NOT NULL, UNIQUE | 权限编码 |
| perm_type | VARCHAR(20) | NOT NULL | 权限类型 |
| level | INTEGER | NOT NULL | 层级 |
| path | VARCHAR(255) | | 路径 |
| component | VARCHAR(255) | | 组件 |
| icon | VARCHAR(100) | | 图标 |
| sort_order | INTEGER | NOT NULL, DEFAULT 0 | 排序 |
| is_visible | SMALLINT | NOT NULL, DEFAULT 1 | 是否可见 |
| status | SMALLINT | NOT NULL, DEFAULT 1 | 状态 |
| description | TEXT | | 描述 |
| meta | JSONB | | 扩展信息 |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 逻辑删除标记 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 |
| perm_id | BIGSERIAL | PK | 鏉冮檺ID |
| parent_id | BIGINT | | 鐖剁骇鏉冮檺ID |
| name | VARCHAR(100) | NOT NULL | 鏉冮檺鍚嶇О |
| code | VARCHAR(100) | NOT NULL, UNIQUE | 鏉冮檺缂栫爜 |
| perm_type | VARCHAR(20) | NOT NULL | 鏉冮檺绫诲瀷 |
| level | INTEGER | NOT NULL | 灞傜骇 |
| path | VARCHAR(255) | | 璺緞 |
| component | VARCHAR(255) | | 缁勪欢 |
| icon | VARCHAR(100) | | 鍥炬爣 |
| sort_order | INTEGER | NOT NULL, DEFAULT 0 | 鎺掑簭 |
| is_visible | SMALLINT | NOT NULL, DEFAULT 1 | 鏄惁鍙 |
| status | SMALLINT | NOT NULL, DEFAULT 1 | 鐘舵€?|
| description | TEXT | | 鎻忚堪 |
| meta | JSONB | | 鎵╁睍淇℃伅 |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
### 2.2 `sys_dict_type`(字典类型表)
| 字段 | 类型 | 约束 | 说明 |
### 2.2 `sys_dict_type`锛堝瓧鍏哥被鍨嬭〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| dict_type_id | BIGSERIAL | PK | 类型ID |
| type_code | VARCHAR(50) | NOT NULL, UNIQUE | 类型编码 |
| type_name | VARCHAR(50) | NOT NULL | 类型名称 |
| status | SMALLINT | DEFAULT 1 | 状态 |
| remark | TEXT | | 备注 |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 更新时间 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 |
| dict_type_id | BIGSERIAL | PK | 绫诲瀷ID |
| type_code | VARCHAR(50) | NOT NULL, UNIQUE | 绫诲瀷缂栫爜 |
| type_name | VARCHAR(50) | NOT NULL | 绫诲瀷鍚嶇О |
| status | SMALLINT | DEFAULT 1 | 鐘舵€?|
| remark | TEXT | | 澶囨敞 |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鏇存柊鏃堕棿 |
| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
**初始化数据:**
- `sys_common_status`: 通用状态 (启用/禁用)
- `sys_permission_type`: 权限类型 (目录/菜单/按钮)
- `sys_common_visibility`: 可见性 (显示/隐藏)
- `sys_permission_level`: 权限层级 (1, 2, 3)
- `sys_log_type`: 日志类型 (LOGIN/OPERATION)
- `sys_param_type`: 参数类型 (String/Number/Boolean/JSON)
- `sys_log_status`: 操作状态 (成功/失败)
**鍒濆鍖栨暟鎹細**
- `sys_common_status`: 閫氱敤鐘舵€?(鍚敤/绂佺敤)
- `sys_permission_type`: 鏉冮檺绫诲瀷 (鐩綍/鑿滃崟/鎸夐挳)
- `sys_common_visibility`: 鍙鎬?(鏄剧ず/闅愯棌)
- `sys_permission_level`: 鏉冮檺灞傜骇 (1, 2, 3)
- `sys_log_type`: 鏃ュ織绫诲瀷 (LOGIN/OPERATION)
- `sys_param_type`: 鍙傛暟绫诲瀷 (String/Number/Boolean/JSON)
- `sys_log_status`: 鎿嶄綔鐘舵€?(鎴愬姛/澶辫触)
### 2.3 `sys_dict_item`(字典项表)
| 字段 | 类型 | 约束 | 说明 |
### 2.3 `sys_dict_item`锛堝瓧鍏搁」琛級
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| dict_item_id | BIGSERIAL | PK | 字典项ID |
| type_code | VARCHAR(50) | NOT NULL | 字典类型编码 |
| item_label | VARCHAR(100) | NOT NULL | 展示文本 |
| item_value | VARCHAR(100) | NOT NULL | 存储值 |
| sort_order | INT | DEFAULT 0 | 排序 |
| status | SMALLINT | DEFAULT 1 | 状态 |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 更新时间 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 |
| dict_item_id | BIGSERIAL | PK | 瀛楀吀椤笽D |
| type_code | VARCHAR(50) | NOT NULL | 瀛楀吀绫诲瀷缂栫爜 |
| item_label | VARCHAR(100) | NOT NULL | 灞曠ず鏂囨湰 |
| item_value | VARCHAR(100) | NOT NULL | 瀛樺偍鍊?|
| sort_order | INT | DEFAULT 0 | 鎺掑簭 |
| status | SMALLINT | DEFAULT 1 | 鐘舵€?|
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鏇存柊鏃堕棿 |
| is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
索引:
- `idx_dict_item_type``(type_code)`
- `uk_dict_item_value``UNIQUE (type_code, item_value)`
绱㈠紩锛?- `idx_dict_item_type`锛歚(type_code)`
- `uk_dict_item_value`锛歚UNIQUE (type_code, item_value)`
### 2.4 `sys_param`(系统参数表)
| 字段 | 类型 | 约束 | 说明 |
### 2.4 `sys_param`锛堢郴缁熷弬鏁拌〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 参数ID |
| param_key | VARCHAR(100) | NOT NULL, UNIQUE | 参数键 |
| param_value | TEXT | NOT NULL | 参数值 |
| param_type | VARCHAR(20) | NOT NULL | 参数类型 |
| is_system | SMALLINT | DEFAULT 0 | 是否系统内置 |
| status | SMALLINT | DEFAULT 1 | 状态 |
| description | TEXT | | 描述 |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 |
| id | BIGSERIAL | PK | 鍙傛暟ID |
| param_key | VARCHAR(100) | NOT NULL, UNIQUE | 鍙傛暟閿?|
| param_value | TEXT | NOT NULL | 鍙傛暟鍊?|
| param_type | VARCHAR(20) | NOT NULL | 鍙傛暟绫诲瀷 |
| is_system | SMALLINT | DEFAULT 0 | 鏄惁绯荤粺鍐呯疆 |
| status | SMALLINT | DEFAULT 1 | 鐘舵€?|
| description | TEXT | | 鎻忚堪 |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鍒涘缓鏃堕棿 |
## 3. 日志(租户隔离)
## 3. 鏃ュ織锛堢鎴烽殧绂伙級
### 3.1 `sys_log`(系统日志表)
| 字段 | 类型 | 约束 | 说明 |
### 3.1 `sys_log`锛堢郴缁熸棩蹇楄〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 日志ID |
| tenant_id | BIGINT | NOT NULL, DEFAULT 0 | 租户ID |
| user_id | BIGINT | | 用户ID |
| username | VARCHAR(50) | | 用户名 |
| log_type | VARCHAR(20) | | 日志类型(如 LOGIN、OPERATION |
| operation | VARCHAR(100) | NOT NULL | 操作描述 |
| method | VARCHAR(200) | | 方法 |
| params | TEXT | | 请求参数 |
| status | SMALLINT | DEFAULT 1 | 状态 |
| id | BIGSERIAL | PK | 鏃ュ織ID |
| tenant_id | BIGINT | NOT NULL, DEFAULT 0 | 绉熸埛ID |
| user_id | BIGINT | | 鐢ㄦ埛ID |
| username | VARCHAR(50) | | 鐢ㄦ埛鍚?|
| log_type | VARCHAR(20) | | 鏃ュ織绫诲瀷锛堝 LOGIN銆丱PERATION锛?|
| operation | VARCHAR(100) | NOT NULL | 鎿嶄綔鎻忚堪 |
| method | VARCHAR(200) | | 鏂规硶 |
| params | TEXT | | 璇锋眰鍙傛暟 |
| status | SMALLINT | DEFAULT 1 | 鐘舵€?|
| ip | VARCHAR(50) | | IP |
| duration | BIGINT | | 耗时ms |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 |
| duration | BIGINT | | 鑰楁椂锛坢s锛?|
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鍒涘缓鏃堕棿 |
索引:
- `idx_log_tenant_type``(tenant_id, log_type, created_at)`
绱㈠紩锛?- `idx_log_tenant_type`锛歚(tenant_id, log_type, created_at)`
## 4. 平台配置
## 4. 骞冲彴閰嶇疆
### 4.1 `sys_platform_config`(平台管理表)
| 字段 | 类型 | 约束 | 说明 |
### 4.1 `sys_platform_config`锛堝钩鍙扮鐞嗚〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGINT | PK | 固定为 1 |
| project_name | VARCHAR(128) | NOT NULL | 项目名称 |
| id | BIGINT | PK | 鍥哄畾涓?1 |
| project_name | VARCHAR(128) | NOT NULL | 椤圭洰鍚嶇О |
| logo_url | VARCHAR(512) | | Logo URL |
| icon_url | VARCHAR(512) | | Icon URL |
| login_bg_url | VARCHAR(512) | | 登录页背景 |
| icp_info | VARCHAR(128) | | 备案信息 |
| copyright_info | VARCHAR(255) | | 版权信息 |
| system_description | TEXT | | 系统描述 |
| created_at | TIMESTAMP | NOT NULL | 创建时间 |
| updated_at | TIMESTAMP | NOT NULL | 更新时间 |
| login_bg_url | VARCHAR(512) | | 鐧诲綍椤佃儗鏅?|
| icp_info | VARCHAR(128) | | 澶囨淇℃伅 |
| copyright_info | VARCHAR(255) | | 鐗堟潈淇℃伅 |
| system_description | TEXT | | 绯荤粺鎻忚堪 |
| created_at | TIMESTAMP | NOT NULL | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP | NOT NULL | 鏇存柊鏃堕棿 |
## 5. 涓氬姟妯″潡
### 5.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 |
| 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 | NOT NULL, DEFAULT 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.3 `biz_hot_words`锛堢儹璇嶇鐞嗚〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 涓婚敭ID |
| tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
| word | VARCHAR(100) | NOT NULL | 鐑瘝鍘熸枃 |
| 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 | 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.4 `biz_prompt_templates`锛堟彁绀鸿瘝妯℃澘琛級
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| id | BIGSERIAL | PK | 涓婚敭ID |
| tenant_id | BIGINT | NOT NULL, DEFAULT 0 | 绉熸埛ID锛? 涓虹郴缁熺骇锛?|
| template_name | VARCHAR(100) | NOT NULL | 妯℃澘鍚嶇О |
| description | VARCHAR(255) | | 妯℃澘鎻忚堪 |
| category | VARCHAR(20) | | 鍒嗙被锛堝瓧鍏革細`biz_prompt_category`锛?|
| is_system | SMALLINT | DEFAULT 0 | 鏄惁绯荤粺棰勭疆锛?:鏄紝0:鍚︼級 |
| creator_id | BIGINT | | 鍒涘缓浜篒D |
| tags | TEXT | | 鏍囩鏁扮粍 |
| hot_word_group_id | BIGINT | | 缁戝畾鐑瘝缁処D |
| usage_count | INTEGER | DEFAULT 0 | 浣跨敤娆℃暟 |
| prompt_content | TEXT | NOT NULL | 鎻愮ず璇嶅唴瀹?|
| 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 | 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`
### 5.5 `biz_asr_models`锛圓SR 妯″瀷閰嶇疆琛級
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
| 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_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 | 浼氳鏍囬 |
| meeting_time | TIMESTAMP(6) | | 浼氳鏃堕棿 |
| participants | TEXT | | 鍙備細浜轰俊鎭?|
| tags | VARCHAR(255) | | 鏍囩 |
| audio_url | VARCHAR(500) | | 涓撳睘闊抽璺緞 |
| meeting_type | VARCHAR(32) | | 会议类型OFFLINE / REALTIME |
| meeting_source | VARCHAR(32) | | 会议来源平台WEB / ANDROID |
| 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 | 閫昏緫鍒犻櫎 |
绱㈠紩锛?- `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锛?|
| end_time | INTEGER | | 缁撴潫鏃堕棿锛坢s锛?|
| sort_order | INTEGER | | 鎺掑簭 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
绱㈠紩锛?- `idx_transcript_meeting`锛歚(meeting_id)`
### 5.9 `biz_ai_tasks`锛圓I 浠诲姟娴佹按琛級
| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- |
## 6. 会议积分模式增量
### 6.1 `biz_meetings` 增量字段
- `effective_audio_duration_seconds`
- 类型:`INTEGER`
- 说明:会议最终有效录音时长(秒),作为会议统计与积分计费统一口径。
### 6.2 `biz_meeting_points_accounts`
- 用途:当前版本按租户维护统一积分余额与累计消耗。
- 关键字段:
- `tenant_id`
- `user_id`
- `0` 表示公共账户
- 非 `0` 表示个人账户
- `current_balance`
- `total_points_used`
- `total_asr_points_used`
- `total_llm_points_used`
- 关键索引:
- `uk_biz_meeting_points_accounts_tenant_user`
### 6.3 `biz_meeting_summary_charge_records`
- 用途:每次 SUMMARY 任务保留一条计费快照记录,并按 ASR / LLM 成功节点累计实际扣费。
- 关键字段:
- `meeting_id`
- `summary_task_id`
- `user_id`
- 记录所属会议 owner / 创建人
- `audio_duration_seconds`
- `charged_minutes`
- `billing_units`
- `unit_minutes_snapshot`
- `cost_per_unit_snapshot`
- `total_points`
- 当前记录应计总积分;重新总结场景仅记录 LLM 应计积分
- `charged_total_points`
- `asr_points`
- `charged_asr_points`
- `llm_points`
- `charged_llm_points`
- `asr_ratio_snapshot`
- `llm_ratio_snapshot`
- `balance_before`
- `balance_after`
- `points_delta`
- `charge_trigger_type`
- `summary_status`
- `points_mode_enabled`
- `failure_reason`
- `charged_at`
- `asr_charged_at`
- `llm_charged_at`
- 关键索引:
- `idx_biz_meeting_summary_charge_records_meeting`
- `idx_biz_meeting_summary_charge_records_user`
- `idx_biz_meeting_summary_charge_records_task`
### 6.5 `biz_meeting_points_ledgers`
- 用途:实际发生积分变化时记录 ASR / LLM / INIT / RECHARGE 流水。
- 关键字段:
- `user_id`
- `0` 表示公共账户
- 非 `0` 表示个人账户
- `meeting_id`
- `summary_task_id`
- `charge_record_id`
- `points_delta`
- `points_type`
- `balance_before`
- `balance_after`
- `remark`
### 6.6 当前计费口径
- 当前支持两种扣费账户:
- `PUBLIC`:租户公共账户
- `PERSONAL`:会议 owner / 创建人的个人账户
- 当前优先扣费账户由系统参数 `meeting.points.account_mode` 控制:
- `PUBLIC`
- `PERSONAL`
- 自动总结:
- `ASR` 成功后扣减 `ASR` 比例积分
- `LLM / SUMMARY` 成功后扣减 `LLM` 比例积分
- 重新总结:
- 只在 `LLM / SUMMARY` 成功后扣减 `LLM` 比例积分
- 失败不扣费:
- `ASR` 失败不扣 `ASR`
- `SUMMARY` 失败不扣 `LLM`
| id | BIGSERIAL | PK | 涓婚敭ID |
| meeting_id | BIGINT | NOT NULL | 鍏宠仈浼氳ID |
| 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

@ -1,10 +1,10 @@
-- PostgreSQL Database Schema for iMeeting (Multi-tenant)
-- PostgreSQL Database Schema for iMeeting (Multi-tenant)
-- 0 为系统预留租户 ID
-- ----------------------------
-- 0. 租户与组织
-- ----------------------------
CREATE EXTENSION IF NOT EXISTS vector;
-- 租户表
CREATE TABLE sys_tenant (
id BIGSERIAL PRIMARY KEY,
@ -19,7 +19,7 @@ CREATE TABLE sys_tenant (
updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_deleted SMALLINT DEFAULT 0
);
CREATE UNIQUE INDEX uk_tenant_code ON sys_tenant (tenant_code) WHERE is_deleted = 0;
CREATE INDEX uk_tenant_code ON sys_tenant (tenant_code) WHERE is_deleted = 0;
-- 组织架构表
DROP TABLE IF EXISTS sys_org CASCADE;
@ -55,6 +55,7 @@ CREATE TABLE sys_user (
user_id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
display_name VARCHAR(50) NOT NULL,
avatar_url VARCHAR(500),
email VARCHAR(100),
phone VARCHAR(30) UNIQUE,
password_hash VARCHAR(255) NOT NULL,
@ -75,6 +76,7 @@ CREATE TABLE sys_role (
tenant_id BIGINT NOT NULL,
role_code VARCHAR(50) NOT NULL,
role_name VARCHAR(50) NOT NULL,
data_scope_type VARCHAR(32) NOT NULL DEFAULT 'SELF',
status SMALLINT NOT NULL DEFAULT 1,
remark TEXT,
is_deleted SMALLINT NOT NULL DEFAULT 0,
@ -83,7 +85,20 @@ CREATE TABLE sys_role (
);
CREATE INDEX idx_sys_role_tenant ON sys_role (tenant_id);
CREATE UNIQUE INDEX uk_role_code ON sys_role (tenant_id, role_code) WHERE is_deleted = 0;
CREATE INDEX uk_role_code ON sys_role (tenant_id, role_code) WHERE is_deleted = 0;
DROP TABLE IF EXISTS sys_role_data_scope_org CASCADE;
CREATE TABLE sys_role_data_scope_org (
id BIGSERIAL PRIMARY KEY,
role_id BIGINT NOT NULL,
org_id BIGINT NOT NULL,
created_at TIMESTAMP(6) NOT NULL DEFAULT now(),
updated_at TIMESTAMP(6) NOT NULL DEFAULT now()
);
CREATE INDEX idx_role_data_scope_role_id ON sys_role_data_scope_org (role_id);
CREATE INDEX idx_role_data_scope_org_id ON sys_role_data_scope_org (org_id);
-- 用户-角色关联表 (按 tenant_id 强约束,避免跨租户角色污染)
DROP TABLE IF EXISTS sys_user_role CASCADE;
@ -109,7 +124,7 @@ CREATE TABLE sys_permission (
perm_id BIGSERIAL PRIMARY KEY,
parent_id BIGINT,
name VARCHAR(100) NOT NULL,
code VARCHAR(100) NOT NULL UNIQUE,
code VARCHAR(100) NOT NULL ,
perm_type VARCHAR(20) NOT NULL,
level INTEGER NOT NULL,
path VARCHAR(255),
@ -138,7 +153,7 @@ CREATE TABLE sys_tenant_user (
updated_at TIMESTAMP(6) NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX uk_tenant_user
CREATE INDEX uk_tenant_user
ON sys_tenant_user (user_id, tenant_id)
WHERE is_deleted = 0;
CREATE TABLE sys_dict_type (
@ -161,13 +176,14 @@ CREATE TABLE sys_dict_item (
status SMALLINT DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
is_deleted SMALLINT DEFAULT 0
is_deleted SMALLINT DEFAULT 0,
remark varchar(255)
);
CREATE INDEX idx_dict_item_type ON sys_dict_item (type_code);
CREATE UNIQUE INDEX uk_dict_item_value ON sys_dict_item (type_code, item_value);
CREATE INDEX uk_dict_item_value ON sys_dict_item (type_code, item_value);
CREATE TABLE sys_param (
id BIGSERIAL PRIMARY KEY,
param_id BIGSERIAL PRIMARY KEY,
param_key VARCHAR(100) UNIQUE NOT NULL,
param_value TEXT NOT NULL,
param_type VARCHAR(20) NOT NULL,
@ -179,6 +195,36 @@ CREATE TABLE sys_param (
is_deleted SMALLINT DEFAULT 0
);
CREATE TABLE sys_role_permission (
"id" BIGSERIAL PRIMARY KEY,
"role_id" int8 NOT NULL,
"perm_id" int8 NOT NULL,
"is_deleted" int2 NOT NULL DEFAULT 0,
"created_at" timestamp(6) NOT NULL DEFAULT now(),
"updated_at" timestamp(6) NOT NULL DEFAULT now()
);
DROP TABLE IF EXISTS sys_bot_credential CASCADE;
CREATE TABLE sys_bot_credential (
id BIGSERIAL PRIMARY KEY,
bot_id VARCHAR(64) NOT NULL,
secret_hash VARCHAR(64) NOT NULL,
secret_salt VARCHAR(32),
user_id BIGINT NOT NULL,
status CHAR(1) NOT NULL DEFAULT '0',
expire_time TIMESTAMP(6),
last_access_time TIMESTAMP(6),
last_access_ip VARCHAR(128),
remark VARCHAR(500),
create_by VARCHAR(64),
create_time TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by VARCHAR(64),
update_time TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX uk_sys_bot_credential_bot_id ON sys_bot_credential (bot_id);
CREATE INDEX idx_sys_bot_credential_user_id ON sys_bot_credential (user_id);
-- ----------------------------
-- 3. 日志 (租户隔离)
-- ----------------------------
@ -188,7 +234,10 @@ CREATE TABLE sys_log (
tenant_id BIGINT NOT NULL DEFAULT 0,
user_id BIGINT,
username VARCHAR(50),
source_system VARCHAR(64),
log_type VARCHAR(20), -- LOGIN, OPERATION
module_name VARCHAR(100),
action_name VARCHAR(100),
operation VARCHAR(100) NOT NULL,
method VARCHAR(200),
params TEXT,
@ -198,6 +247,9 @@ CREATE TABLE sys_log (
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_log_tenant_type ON sys_log (tenant_id, log_type, created_at);
CREATE INDEX idx_log_tenant_type_module_time ON sys_log (tenant_id, log_type, module_name, created_at);
CREATE INDEX idx_log_tenant_type_user_time ON sys_log (tenant_id, log_type, username, created_at);
CREATE INDEX idx_log_tenant_type_source_time ON sys_log (tenant_id, log_type, source_system, created_at);
-- ----------------------------
-- 4. 平台配置 (系统品牌化)
@ -217,49 +269,478 @@ CREATE TABLE sys_platform_config (
is_deleted SMALLINT DEFAULT 0
);
INSERT INTO sys_platform_config (id, project_name, copyright_info)
VALUES (1, 'iMeeting 智能会议系统', '© 2026 iMeeting Team. All rights reserved.');
-- ----------------------------
-- 6. 业务模块 - 声纹管理
-- ----------------------------
DROP TABLE IF EXISTS biz_speakers CASCADE;
CREATE TABLE biz_speakers (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL, -- 租户ID
creator_id BIGINT NOT NULL, -- 创建人ID用于声纹库管理归属
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), -- 声纹特征向量 (预留 pgvector 字段)
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
);
CREATE INDEX idx_speaker_tenant ON biz_speakers (tenant_id) WHERE is_deleted = 0;
CREATE INDEX idx_speaker_creator ON biz_speakers (creator_id) WHERE is_deleted = 0;
CREATE INDEX idx_speaker_user ON biz_speakers (user_id) WHERE is_deleted = 0;
CREATE INDEX idx_speaker_external ON biz_speakers (external_speaker_id) WHERE is_deleted = 0;
CREATE UNIQUE INDEX uk_speaker_tenant_name ON biz_speakers (tenant_id, name) WHERE is_deleted = 0;
COMMENT ON TABLE biz_speakers IS '声纹发言人基础信息表 (声纹库资源)';
-- ----------------------------
-- 7. 业务模块 - 热词管理
-- ----------------------------
DROP TABLE IF EXISTS biz_hot_word_groups CASCADE;
CREATE TABLE biz_hot_word_groups (
id BIGSERIAL PRIMARY KEY,
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 NOT NULL DEFAULT 0
);
CREATE INDEX idx_hot_word_group_tenant ON biz_hot_word_groups (tenant_id) WHERE is_deleted = 0;
CREATE UNIQUE INDEX uk_hot_word_group_name_scope ON biz_hot_word_groups (tenant_id, group_name) WHERE is_deleted = 0;
COMMENT ON TABLE biz_hot_word_groups IS '热词组表';
DROP TABLE IF EXISTS biz_hot_words CASCADE;
CREATE TABLE biz_hot_words (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL, -- 租户ID (强制隔离)
word VARCHAR(100) NOT NULL, -- 热词原文
is_public SMALLINT DEFAULT 0, -- 1:租户公开, 0:个人私有
creator_id BIGINT, -- 创建者ID
pinyin_list text, -- 拼音数组(支持多音字, 如 ["i mi ting", "i mei ting"])
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, -- 是否已同步至第三方引擎: 0:未同步, 1:已同步
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
);
CREATE INDEX idx_hotword_tenant ON biz_hot_words (tenant_id);
CREATE INDEX idx_hotword_word ON biz_hot_words (word) WHERE is_deleted = 0;
CREATE INDEX idx_hotword_group ON biz_hot_words (hot_word_group_id) WHERE is_deleted = 0;
COMMENT ON TABLE biz_hot_words IS '语音识别热词表';
-- ----------------------------
-- 8. 业务模块 - 提示词模板
-- ----------------------------
DROP TABLE IF EXISTS biz_prompt_templates CASCADE;
CREATE TABLE biz_prompt_templates (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 0, -- 租户ID (0为系统级)
template_name VARCHAR(100) NOT NULL, -- 模板名称
description VARCHAR(255), -- 模板描述
category VARCHAR(20), -- 分类 (字典: biz_prompt_category)
is_system SMALLINT DEFAULT 0, -- 是否系统预置 (1:是, 0:否)
creator_id BIGINT, -- 创建人ID
tags text, -- 标签数组 (JSONB)
hot_word_group_id BIGINT, -- 绑定热词组 ID
usage_count INTEGER DEFAULT 0, -- 使用次数
prompt_content TEXT NOT NULL, -- 提示词内容
status SMALLINT DEFAULT 1, -- 状态: 1:启用, 0:禁用
remark VARCHAR(255), -- 备注
created_at TIMESTAMP(6) NOT NULL DEFAULT now(),
updated_at TIMESTAMP(6) NOT NULL DEFAULT now(),
is_deleted SMALLINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_prompt_tenant ON biz_prompt_templates (tenant_id);
CREATE INDEX idx_prompt_system ON biz_prompt_templates (is_system) WHERE is_deleted = 0;
CREATE INDEX idx_prompt_group ON biz_prompt_templates (hot_word_group_id) WHERE is_deleted = 0;
COMMENT ON TABLE biz_prompt_templates IS '会议总结提示词模板表';
-- ----------------------------
-- 9. 业务模块 - AI 模型管理
-- ----------------------------
DROP TABLE IF EXISTS biz_asr_models CASCADE;
CREATE TABLE biz_asr_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_key VARCHAR(255),
model_code VARCHAR(100),
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(),
updated_at TIMESTAMP(6) NOT NULL DEFAULT now(),
is_deleted SMALLINT NOT NULL DEFAULT 0
);
DROP TABLE IF EXISTS biz_llm_models CASCADE;
CREATE TABLE biz_llm_models (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 0,
model_name VARCHAR(100) NOT NULL,
provider VARCHAR(50),
base_url VARCHAR(255),
api_path VARCHAR(100),
api_key VARCHAR(255),
model_code VARCHAR(100),
temperature DECIMAL(3,2) DEFAULT 0.7,
top_p DECIMAL(3,2) DEFAULT 0.9,
max_tokens BIGINT NOT NULL DEFAULT 30000,
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
);
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 模型配置表';
-- ----------------------------
-- 10. 业务模块 - 会议主表
-- ----------------------------
DROP TABLE IF EXISTS biz_meetings CASCADE;
CREATE TABLE biz_meetings (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 0,
title VARCHAR(200) NOT NULL,
meeting_time TIMESTAMP(6),
participants TEXT,
tags VARCHAR(255),
audio_url VARCHAR(500),
meeting_type VARCHAR(32), -- OFFLINE / REALTIME
meeting_source VARCHAR(32), -- WEB / ANDROID
creator_id BIGINT, -- 发起人ID
creator_name VARCHAR(100), -- 发起人姓名
host_user_id BIGINT, -- 主持人用户ID
host_name VARCHAR(100), -- 主持人展示名称
access_password VARCHAR(128), -- 兼容旧版安卓预览访问的会议访问密码
latest_summary_task_id BIGINT, -- 最新成功总结任务ID
audio_save_status VARCHAR(20) DEFAULT 'NONE', -- 实时音频保存状态NONE/SUCCESS/FAILED
audio_save_message VARCHAR(500), -- 实时音频保存失败提示信息
status SMALLINT DEFAULT 0, -- 0:待处理, 1:处理中, 2:成功, 3:失败
created_at TIMESTAMP(6) NOT NULL DEFAULT now(),
updated_at TIMESTAMP(6) NOT NULL DEFAULT now(),
is_deleted SMALLINT NOT NULL DEFAULT 0
);
-- ----------------------------
-- 11. 业务模块 - 转录明细表
-- ----------------------------
DROP TABLE IF EXISTS biz_meeting_transcripts CASCADE;
CREATE TABLE biz_meeting_transcripts (
id BIGSERIAL PRIMARY KEY,
meeting_id BIGINT NOT NULL,
speaker_id VARCHAR(50), -- ASR返回的发言人标识
speaker_name VARCHAR(100), -- 修改后的发言人姓名
speaker_label VARCHAR(50), -- 发言人标签
content TEXT, -- 转录内容
start_time INTEGER, -- 开始时间(ms)
end_time INTEGER, -- 结束时间(ms)
sort_order INTEGER,
created_at TIMESTAMP(6) NOT NULL DEFAULT now()
);
-- ----------------------------
-- 12. 业务模块 - AI 异步任务日志表
-- ----------------------------
DROP TABLE IF EXISTS biz_ai_tasks CASCADE;
CREATE TABLE biz_ai_tasks (
id BIGSERIAL PRIMARY KEY,
meeting_id BIGINT NOT NULL,
task_type VARCHAR(20), -- ASR / SUMMARY
status SMALLINT DEFAULT 0, -- 0:排队, 1:执行中, 2:成功, 3:失败
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)
);
CREATE INDEX idx_meeting_tenant ON biz_meetings (tenant_id);
CREATE INDEX idx_transcript_meeting ON biz_meeting_transcripts (meeting_id);
CREATE INDEX idx_aitask_meeting ON biz_ai_tasks (meeting_id);
COMMENT ON TABLE biz_meetings IS '会议管理主表';
COMMENT ON TABLE biz_meeting_transcripts IS '会议转录明细表';
COMMENT ON TABLE biz_ai_tasks IS 'AI 任务流水日志表';
-- ----------------------------
-- 13. 会议积分模式增量结构
-- ----------------------------
ALTER TABLE biz_meetings
ADD COLUMN IF NOT EXISTS effective_audio_duration_seconds INTEGER;
COMMENT ON COLUMN biz_meetings.effective_audio_duration_seconds IS '会议最终有效录音时长(秒),用于统计与计费口径';
DROP TABLE IF EXISTS biz_meeting_points_accounts CASCADE;
CREATE TABLE biz_meeting_points_accounts (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 1,
user_id BIGINT NOT NULL,
current_balance BIGINT NOT NULL DEFAULT 0,
total_points_used BIGINT NOT NULL DEFAULT 0,
total_asr_points_used BIGINT NOT NULL DEFAULT 0,
total_llm_points_used BIGINT NOT NULL DEFAULT 0,
is_deleted SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX uk_biz_meeting_points_accounts_tenant_user
ON biz_meeting_points_accounts (tenant_id, user_id);
COMMENT ON TABLE biz_meeting_points_accounts IS '会议积分账户表';
COMMENT ON COLUMN biz_meeting_points_accounts.user_id IS '0表示公共账户非0表示个人账户';
DROP TABLE IF EXISTS biz_meeting_summary_charge_records CASCADE;
CREATE TABLE biz_meeting_summary_charge_records (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 1,
meeting_id BIGINT NOT NULL,
summary_task_id BIGINT,
user_id BIGINT NOT NULL,
audio_duration_seconds INTEGER NOT NULL DEFAULT 0,
charged_minutes INTEGER NOT NULL DEFAULT 0,
billing_units INTEGER NOT NULL DEFAULT 0,
unit_minutes_snapshot INTEGER NOT NULL DEFAULT 1,
cost_per_unit_snapshot INTEGER NOT NULL DEFAULT 0,
total_points BIGINT NOT NULL DEFAULT 0,
asr_points BIGINT NOT NULL DEFAULT 0,
llm_points BIGINT NOT NULL DEFAULT 0,
asr_ratio_snapshot INTEGER NOT NULL DEFAULT 0,
llm_ratio_snapshot INTEGER NOT NULL DEFAULT 0,
balance_before BIGINT,
balance_after BIGINT,
points_delta BIGINT NOT NULL DEFAULT 0,
charge_trigger_type VARCHAR(32) NOT NULL,
summary_status VARCHAR(32) NOT NULL DEFAULT 'CREATED',
points_mode_enabled SMALLINT NOT NULL DEFAULT 0,
blocked_reason VARCHAR(64),
failure_reason VARCHAR(500),
charged_at TIMESTAMP(6),
is_deleted SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_biz_meeting_summary_charge_records_meeting
ON biz_meeting_summary_charge_records (meeting_id);
CREATE INDEX idx_biz_meeting_summary_charge_records_user
ON biz_meeting_summary_charge_records (user_id);
CREATE INDEX idx_biz_meeting_summary_charge_records_task
ON biz_meeting_summary_charge_records (summary_task_id);
COMMENT ON TABLE biz_meeting_summary_charge_records IS '会议总结消耗记录表';
COMMENT ON COLUMN biz_meeting_summary_charge_records.total_points IS '本次记录应计总积分重新总结场景仅记录LLM应计积分';
DROP TABLE IF EXISTS biz_meeting_points_ledgers CASCADE;
CREATE TABLE biz_meeting_points_ledgers (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 1,
user_id BIGINT NOT NULL,
meeting_id BIGINT,
summary_task_id BIGINT,
charge_record_id BIGINT,
points_delta BIGINT NOT NULL,
points_type VARCHAR(32) NOT NULL,
balance_before BIGINT NOT NULL,
balance_after BIGINT NOT NULL,
remark VARCHAR(500),
is_deleted SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_biz_meeting_points_ledgers_user
ON biz_meeting_points_ledgers (user_id);
CREATE INDEX idx_biz_meeting_points_ledgers_meeting
ON biz_meeting_points_ledgers (meeting_id);
COMMENT ON TABLE biz_meeting_points_ledgers IS '会议积分流水表';
COMMENT ON COLUMN biz_meeting_points_ledgers.user_id IS '0表示公共账户非0表示个人账户';
DROP TABLE IF EXISTS "biz_prompt_template_user_config";
CREATE TABLE "biz_prompt_template_user_config" (
"id" BIGSERIAL PRIMARY KEY,
"tenant_id" int8 NOT NULL DEFAULT 0,
"user_id" int8 NOT NULL,
"template_id" int8 NOT NULL,
"status" int2 DEFAULT 1,
"created_at" timestamp(6) NOT NULL DEFAULT now(),
"updated_at" timestamp(6) NOT NULL DEFAULT now(),
"is_deleted" int2 NOT NULL DEFAULT 0
);
-- ----------------------------
-- 13. 业务模块 - 旧版安卓兼容
-- ----------------------------
DROP TABLE IF EXISTS biz_client_downloads CASCADE;
CREATE TABLE biz_client_downloads (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 0,
platform_type VARCHAR(32), -- 平台类型
platform_name VARCHAR(64), -- 平台显示名称
platform_code VARCHAR(64) NOT NULL, -- 平台编码,如 android / ios / windows
version VARCHAR(64) NOT NULL, -- 版本名称
version_code BIGINT, -- 版本号
download_url VARCHAR(512) NOT NULL, -- 下载地址
file_size BIGINT, -- 文件大小
release_notes TEXT, -- 发布说明
is_latest SMALLINT NOT NULL DEFAULT 0, -- 是否当前平台最新版本1-是0-否
min_system_version VARCHAR(64), -- 最低系统版本要求
created_by BIGINT, -- 创建人
status SMALLINT NOT NULL DEFAULT 1, -- 状态
remark VARCHAR(255), -- 备注
created_at TIMESTAMP(6) NOT NULL DEFAULT now(),
updated_at TIMESTAMP(6) NOT NULL DEFAULT now(),
is_deleted SMALLINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_client_downloads_platform_code
ON biz_client_downloads (platform_code);
CREATE INDEX idx_client_downloads_latest
ON biz_client_downloads (platform_code, is_latest)
WHERE is_deleted = 0;
COMMENT ON TABLE biz_client_downloads IS '旧版安卓客户端版本兼容表';
DROP TABLE IF EXISTS biz_external_apps CASCADE;
CREATE TABLE biz_external_apps (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 0,
app_name VARCHAR(128) NOT NULL, -- 应用名称
app_type VARCHAR(32) NOT NULL, -- 应用类型native / web
app_info JSONB, -- 应用附加信息,如 web_url / package_name / apk_url
icon_url VARCHAR(512), -- 图标地址
description VARCHAR(255), -- 描述
sort_order INTEGER NOT NULL DEFAULT 0, -- 排序值
created_by BIGINT, -- 创建人
status SMALLINT NOT NULL DEFAULT 1, -- 状态
remark VARCHAR(255), -- 备注
created_at TIMESTAMP(6) NOT NULL DEFAULT now(),
updated_at TIMESTAMP(6) NOT NULL DEFAULT now(),
is_deleted SMALLINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_external_apps_status_sort
ON biz_external_apps (status, sort_order);
COMMENT ON TABLE biz_external_apps IS '旧版安卓首页外部应用兼容表';
-- ----------------------------
-- 5. 基础初始化数据
-- ----------------------------
-- 字典初始化数据
-- sys_common_status
INSERT INTO sys_dict_type (type_code, type_name, remark) VALUES ('sys_common_status', '通用状态', '0=禁用, 1=启用');
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order) VALUES ('sys_common_status', '启用', '1', 1);
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order) VALUES ('sys_common_status', '禁用', '0', 2);
-- sys_permission_type
INSERT INTO sys_dict_type (type_code, type_name, remark) VALUES ('sys_permission_type', '权限类型', 'directory=目录, menu=菜单, button=按钮');
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order) VALUES ('sys_permission_type', '目录', 'directory', 1);
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order) VALUES ('sys_permission_type', '菜单', 'menu', 2);
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order) VALUES ('sys_permission_type', '按钮', 'button', 3);
-- ----------------------------
-- 6. 屏保模块
-- ----------------------------
-- sys_common_visibility
INSERT INTO sys_dict_type (type_code, type_name, remark) VALUES ('sys_common_visibility', '可见性', '0=隐藏, 1=显示');
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order) VALUES ('sys_common_visibility', '显示', '1', 1);
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order) VALUES ('sys_common_visibility', '隐藏', '0', 2);
CREATE TABLE biz_screen_savers (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 0,
scope_type VARCHAR(32) NOT NULL DEFAULT 'PLATFORM',
owner_user_id BIGINT,
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,
status SMALLINT NOT NULL 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
);
-- sys_permission_level
INSERT INTO sys_dict_type (type_code, type_name, remark) VALUES ('sys_permission_level', '权限层级', '1=一级入口, 2=二级子项, 3=三级按钮');
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order) VALUES ('sys_permission_level', '一级入口', '1', 1);
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order) VALUES ('sys_permission_level', '二级子项', '2', 2);
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order) VALUES ('sys_permission_level', '三级按钮', '3', 3);
CREATE INDEX idx_screen_savers_status_sort
ON biz_screen_savers (status, sort_order);
-- sys_log_type
INSERT INTO sys_dict_type (type_code, type_name, remark) VALUES ('sys_log_type', '日志类型', 'LOGIN=登录, OPERATION=操作');
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order) VALUES ('sys_log_type', '登录', 'LOGIN', 1);
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order) VALUES ('sys_log_type', '操作', 'OPERATION', 2);
CREATE INDEX idx_screen_savers_scope_owner_status_sort
ON biz_screen_savers (scope_type, owner_user_id, status, sort_order);
-- sys_param_type
INSERT INTO sys_dict_type (type_code, type_name, remark) VALUES ('sys_param_type', '参数类型', 'String, Number, Boolean, JSON');
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order) VALUES ('sys_param_type', 'String', 'String', 1);
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order) VALUES ('sys_param_type', 'Number', 'Number', 2);
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order) VALUES ('sys_param_type', 'Boolean', 'Boolean', 3);
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order) VALUES ('sys_param_type', 'JSON', 'JSON', 4);
CREATE TABLE biz_screen_saver_user_config (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 0,
user_id BIGINT NOT NULL,
screen_saver_id BIGINT NOT NULL,
status SMALLINT NOT NULL DEFAULT 1,
created_at TIMESTAMP(6) NOT NULL DEFAULT now(),
updated_at TIMESTAMP(6) NOT NULL DEFAULT now(),
is_deleted SMALLINT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX uk_screen_saver_user_cfg_user_item
ON biz_screen_saver_user_config (tenant_id, user_id, screen_saver_id)
WHERE is_deleted = 0;
CREATE INDEX idx_screen_saver_user_cfg_item
ON biz_screen_saver_user_config (screen_saver_id)
WHERE is_deleted = 0;
CREATE TABLE biz_screen_saver_user_settings (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 0,
user_id BIGINT NOT NULL,
display_duration_sec INTEGER NOT NULL DEFAULT 15,
created_at TIMESTAMP(6) NOT NULL DEFAULT now(),
updated_at TIMESTAMP(6) NOT NULL DEFAULT now(),
is_deleted SMALLINT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX uk_screen_saver_user_settings_user
ON biz_screen_saver_user_settings (tenant_id, user_id)
WHERE is_deleted = 0;
-- sys_log_status
INSERT INTO sys_dict_type (type_code, type_name, remark) VALUES ('sys_log_status', '操作状态', '1=成功, 0=失败');
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order) VALUES ('sys_log_status', '成功', '1', 1);
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order) VALUES ('sys_log_status', '失败', '0', 2);

View File

@ -0,0 +1 @@
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier

View File

@ -1,4 +1,4 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
@ -20,6 +20,11 @@
<mybatis-plus.version>3.5.6</mybatis-plus.version>
<jjwt.version>0.11.5</jjwt.version>
<easycaptcha.version>1.6.2</easycaptcha.version>
<grpc.version>1.76.1</grpc.version>
<protobuf.version>3.25.8</protobuf.version>
<protobuf.plugin.version>0.6.1</protobuf.plugin.version>
<os.maven.plugin.version>1.7.1</os.maven.plugin.version>
<unisbase.version>1.0.1</unisbase.version>
</properties>
<dependencies>
@ -27,6 +32,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
@ -47,6 +56,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
@ -79,6 +92,38 @@
<artifactId>easy-captcha</artifactId>
<version>${easycaptcha.version}</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.38</version>
</dependency>
<dependency>
<groupId>com.belerweb</groupId>
<artifactId>pinyin4j</artifactId>
<version>2.5.1</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-netty-shaded</artifactId>
<version>${grpc.version}</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
<version>${grpc.version}</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
<version>${grpc.version}</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-services</artifactId>
<version>${grpc.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
@ -89,10 +134,94 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>2.0.30</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.5</version>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
<version>0.21.0</version>
</dependency>
<dependency>
<groupId>com.openhtmltopdf</groupId>
<artifactId>openhtmltopdf-core</artifactId>
<version>1.0.10</version>
</dependency>
<dependency>
<groupId>com.openhtmltopdf</groupId>
<artifactId>openhtmltopdf-pdfbox</artifactId>
<version>1.0.10</version>
</dependency>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.17.2</version>
</dependency>
<dependency>
<groupId>com.unisbase</groupId>
<artifactId>unisbase-spring-boot-starter</artifactId>
<version>${unisbase.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.3.0</version>
</dependency>
<!-- Source: https://mvnrepository.com/artifact/com.tencentcloudapi/tencentcloud-speech-sdk-java -->
<dependency>
<groupId>com.tencentcloudapi</groupId>
<artifactId>tencentcloud-speech-sdk-java</artifactId>
<version>1.0.67</version>
</dependency>
<dependency>
<groupId>com.tencentcloudapi</groupId>
<artifactId>tencentcloud-sdk-java-asr</artifactId>
<version>3.1.1470</version>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
</dependencies>
<build>
<extensions>
<extension>
<groupId>kr.motd.maven</groupId>
<artifactId>os-maven-plugin</artifactId>
<version>${os.maven.plugin.version}</version>
</extension>
</extensions>
<plugins>
<plugin>
<groupId>org.xolstice.maven.plugins</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<version>${protobuf.plugin.version}</version>
<configuration>
<protocArtifact>com.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier}</protocArtifact>
<pluginId>grpc-java</pluginId>
<pluginArtifact>io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}</pluginArtifact>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>compile-custom</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>

View File

@ -3,9 +3,11 @@ package com.imeeting;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableAsync
@EnableScheduling
public class ImeetingApplication {
public static void main(String[] args) {
SpringApplication.run(ImeetingApplication.class, args);

View File

@ -1,167 +0,0 @@
package com.imeeting.auth;
import com.imeeting.common.RedisKeys;
import com.imeeting.entity.SysTenant;
import com.imeeting.entity.SysUser;
import com.imeeting.security.LoginUser;
import com.imeeting.service.AuthScopeService;
import com.imeeting.service.AuthVersionService;
import com.imeeting.service.SysParamService;
import com.imeeting.service.SysPermissionService;
import com.imeeting.mapper.SysTenantMapper;
import com.imeeting.mapper.SysUserMapper;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Lazy;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.Set;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final SysPermissionService sysPermissionService;
private final SysTenantMapper sysTenantMapper;
private final SysUserMapper sysUserMapper;
private final SysParamService sysParamService;
private final StringRedisTemplate redisTemplate;
private final AuthScopeService authScopeService;
private final AuthVersionService authVersionService;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider,
@Lazy SysPermissionService sysPermissionService,
SysTenantMapper sysTenantMapper,
SysUserMapper sysUserMapper,
@Lazy SysParamService sysParamService,
StringRedisTemplate redisTemplate,
AuthScopeService authScopeService,
AuthVersionService authVersionService) {
this.jwtTokenProvider = jwtTokenProvider;
this.sysPermissionService = sysPermissionService;
this.sysTenantMapper = sysTenantMapper;
this.sysUserMapper = sysUserMapper;
this.sysParamService = sysParamService;
this.redisTemplate = redisTemplate;
this.authScopeService = authScopeService;
this.authVersionService = authVersionService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String uri = request.getRequestURI();
// Skip filter for public endpoints
if (uri.startsWith("/auth/") || uri.equals("/api/params/value")) {
filterChain.doFilter(request, response);
return;
}
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
Claims claims = jwtTokenProvider.parseToken(token);
String username = claims.get("username", String.class);
Long userId = claims.get("userId", Long.class);
Long tenantId = claims.get("tenantId", Long.class);
Number tokenAuthVersionNum = claims.get("authVersion", Number.class);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
// 1. Validate User Status (Ignore Tenant isolation here)
SysUser user = sysUserMapper.selectByIdIgnoreTenant(userId);
if (user == null || user.getStatus() != 1 || user.getIsDeleted() != 0) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":\"401\",\"msg\":\"User account is disabled or deleted\"}");
return;
}
// 2. Validate Tenant Status & Grace Period
// Skip validation for system platform tenant (ID=0)
Long activeTenantId = tenantId;
if (activeTenantId != null && !Long.valueOf(0).equals(activeTenantId)) {
SysTenant tenant = sysTenantMapper.selectByIdIgnoreTenant(activeTenantId);
if (tenant == null || tenant.getStatus() != 1) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":\"401\",\"msg\":\"Tenant is disabled\"}");
return;
}
if (tenant.getExpireTime() != null) {
LocalDateTime now = LocalDateTime.now();
if (now.isAfter(tenant.getExpireTime())) {
String graceDaysStr = sysParamService.getParamValue("sys.tenant.grace_period_days", "0");
int graceDays = Integer.parseInt(graceDaysStr);
if (now.isAfter(tenant.getExpireTime().plusDays(graceDays))) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":\"401\",\"msg\":\"Tenant subscription expired\"}");
return;
}
}
}
}
long currentAuthVersion = authVersionService.getVersion(userId, activeTenantId);
long requestAuthVersion = tokenAuthVersionNum == null ? 0L : tokenAuthVersionNum.longValue();
if (currentAuthVersion != requestAuthVersion) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":\"401\",\"msg\":\"Token revoked\"}");
return;
}
// 3. Get Permissions (With Redis Cache, Key must include tenantId)
String permKey = RedisKeys.authPermKey(userId, activeTenantId, currentAuthVersion);
Set<String> permissions;
String cachedPerms = redisTemplate.opsForValue().get(permKey);
if (cachedPerms != null && !cachedPerms.trim().isEmpty()) {
permissions = Set.of(cachedPerms.split(","));
} else {
permissions = sysPermissionService.listPermissionCodesByUserId(userId, activeTenantId);
if (permissions != null && !permissions.isEmpty()) {
redisTemplate.opsForValue().set(permKey, String.join(",", permissions), java.time.Duration.ofHours(2));
} else {
permissions = Collections.emptySet();
}
}
boolean isTenantAdmin = authScopeService.isTenantAdmin(userId, activeTenantId);
LoginUser loginUser = new LoginUser(userId, activeTenantId, username, user.getIsPlatformAdmin(), isTenantAdmin, permissions);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (io.jsonwebtoken.ExpiredJwtException e) {
SecurityContextHolder.clearContext();
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":\"401\",\"msg\":\"Token expired\"}");
return;
} catch (io.jsonwebtoken.JwtException e) {
SecurityContextHolder.clearContext();
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":\"401\",\"msg\":\"Invalid token\"}");
return;
} catch (Exception ignored) {
SecurityContextHolder.clearContext();
}
}
filterChain.doFilter(request, response);
}
}

View File

@ -1,37 +0,0 @@
package com.imeeting.auth;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
import java.util.Map;
@Component
public class JwtTokenProvider {
private final Key key;
public JwtTokenProvider(@Value("${security.jwt.secret}") String secret) {
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String createToken(Map<String, Object> claims, long ttlMillis) {
Date now = new Date();
Date exp = new Date(now.getTime() + ttlMillis);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(exp)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public Claims parseToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
}

View File

@ -1,11 +0,0 @@
package com.imeeting.auth.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class CaptchaResponse {
private String captchaId;
private String imageBase64;
}

View File

@ -1,15 +0,0 @@
package com.imeeting.auth.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class DeviceCodeRequest {
@NotBlank
private String username;
@NotBlank
private String password;
private String captchaId;
private String captchaCode;
private String deviceName;
}

View File

@ -1,16 +0,0 @@
package com.imeeting.auth.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class LoginRequest {
private String tenantCode;
@NotBlank
private String username;
@NotBlank
private String password;
private String captchaId;
private String captchaCode;
private String deviceCode;
}

View File

@ -1,10 +0,0 @@
package com.imeeting.auth.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class RefreshRequest {
@NotBlank
private String refreshToken;
}

View File

@ -1,23 +0,0 @@
package com.imeeting.auth.dto;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class TokenResponse {
private String accessToken;
private String refreshToken;
private long accessExpiresInMinutes;
private long refreshExpiresInDays;
private List<TenantInfo> availableTenants;
@Data
@Builder
public static class TenantInfo {
private Long tenantId;
private String tenantCode;
private String tenantName;
}
}

View File

@ -1,22 +0,0 @@
package com.imeeting.common;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {
private String code;
private String msg;
private T data;
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>("0", "OK", data);
}
public static <T> ApiResponse<T> error(String msg) {
return new ApiResponse<>("-1", msg, null);
}
}

View File

@ -1,29 +0,0 @@
package com.imeeting.common;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(IllegalArgumentException.class)
public ApiResponse<Void> handleIllegalArgument(IllegalArgumentException ex) {
log.warn("Business error: {}", ex.getMessage());
return ApiResponse.error(ex.getMessage());
}
@ExceptionHandler(org.springframework.security.access.AccessDeniedException.class)
public ApiResponse<Void> handleAccessDenied(org.springframework.security.access.AccessDeniedException ex) {
log.warn("Access denied: {}", ex.getMessage());
return ApiResponse.error("无权限操作");
}
@ExceptionHandler(Exception.class)
public ApiResponse<Void> handleGeneric(Exception ex) {
log.error("Unhandled exception", ex);
return ApiResponse.error("系统异常");
}
}

View File

@ -0,0 +1,9 @@
package com.imeeting.common;
public final class LicenseConstants {
private LicenseConstants() {
}
public static final String TEMP_SERIAL_PREFIX = "SN";
public static final String IMPORT_BATCH_PREFIX = "LIC";
}

View File

@ -0,0 +1,26 @@
package com.imeeting.common;
public final class MeetingConstants {
public static final String TYPE_OFFLINE = "OFFLINE";
public static final String TYPE_REALTIME = "REALTIME";
public static final String DEVICE_MODE_PUBLIC = "PUBLIC";
public static final String DEVICE_MODE_PRIVATE = "PRIVATE";
public static final String DEVICE_DELIVERY_NONE = "NONE";
public static final String DEVICE_DELIVERY_PENDING = "PENDING";
public static final String DEVICE_DELIVERY_ACKED = "ACKED";
public static final String DEVICE_DELIVERY_EXPIRED = "EXPIRED";
public static final String DEVICE_DELIVERY_CANCELLED = "CANCELLED";
public static final String OFFLINE_RECORDING_ACTIVE = "ACTIVE";
public static final String OFFLINE_RECORDING_PRE_END = "PRE_END";
public static final String OFFLINE_RECORDING_UPLOAD_FINISHED = "UPLOAD_FINISHED";
public static final String SUMMARY_DETAIL_DETAILED = "DETAILED";
public static final String SUMMARY_DETAIL_STANDARD = "STANDARD";
public static final String SUMMARY_DETAIL_BRIEF = "BRIEF";
private MeetingConstants() {
}
}

View File

@ -0,0 +1,34 @@
package com.imeeting.common;
public enum MeetingProgressStage {
QUEUED("queued", 10, false),
ASR_SUBMITTED("asr_submitted", 20, false),
ASR_RUNNING("asr_running", 30, false),
ASR_COMPLETED("asr_completed", 40, false),
CHAPTER_RUNNING("chapter_running", 50, false),
SUMMARY_RUNNING("summary_running", 60, false),
COMPLETED("completed", 100, true),
FAILED("failed", 100, true);
private final String code;
private final int order;
private final boolean terminal;
MeetingProgressStage(String code, int order, boolean terminal) {
this.code = code;
this.order = order;
this.terminal = terminal;
}
public String getCode() {
return code;
}
public int getOrder() {
return order;
}
public boolean isTerminal() {
return terminal;
}
}

View File

@ -1,9 +0,0 @@
package com.imeeting.common;
import lombok.Data;
@Data
public class PageResult<T> {
private long total;
private T records;
}

View File

@ -35,6 +35,113 @@ public final class RedisKeys {
return "sys:platform:config";
}
public static String meetingProgressKey(Long meetingId) {
return "biz:meeting:progress:" + meetingId;
}
public static String meetingPollingLockKey(Long meetingId) {
return "biz:meeting:polling:lock:" + meetingId;
}
public static String meetingSummaryLockKey(Long meetingId) {
return "biz:meeting:summary:lock:" + meetingId;
}
public static String meetingAsrScheduleLockKey() {
return "biz:meeting:asr:schedule:lock";
}
public static String meetingAsrPermitSetKey(String queueKey) {
return "biz:meeting:asr:permit:set:" + normalizeQueueKey(queueKey);
}
public static String meetingAsrPermitQueueKey(Long meetingId) {
return "biz:meeting:asr:permit:meeting:" + meetingId;
}
public static String meetingAsrPermitSyncLockKey() {
return "biz:meeting:asr:permit:sync:lock";
}
public static String meetingAsrRefillLockKey() {
return "biz:meeting:asr:refill:lock";
}
public static String realtimeMeetingSocketSessionKey(String sessionToken) {
return "biz:meeting:realtime:socket:" + sessionToken;
}
public static String realtimeMeetingSessionStateKey(Long meetingId) {
return "biz:meeting:realtime:state:" + meetingId;
}
public static String realtimeMeetingTranscriptCacheKey(Long meetingId) {
return "biz:meeting:realtime:transcript-cache:" + meetingId;
}
public static String realtimeMeetingResumeTimeoutKey(Long meetingId) {
return realtimeMeetingResumeTimeoutPrefix() + meetingId;
}
public static String realtimeMeetingEmptyTimeoutKey(Long meetingId) {
return realtimeMeetingEmptyTimeoutPrefix() + meetingId;
}
public static String realtimeMeetingTimeoutLockKey(Long meetingId) {
return "biz:meeting:realtime:timeout:lock:" + meetingId;
}
public static String realtimeMeetingResumeTimeoutPrefix() {
return "biz:meeting:realtime:resume-timeout:";
}
public static String realtimeMeetingEmptyTimeoutPrefix() {
return "biz:meeting:realtime:empty-timeout:";
}
public static String androidDeviceOnlineKey(String deviceId) {
return "biz:android:device:online:" + deviceId;
}
public static String androidDeviceActiveConnectionKey(String deviceId) {
return "biz:android:device:active-conn:" + deviceId;
}
public static String androidDeviceConnectionKey(String connectionId) {
return "biz:android:device:conn:" + connectionId;
}
public static String androidDeviceTopicsKey(String deviceId) {
return "biz:android:device:topics:" + deviceId;
}
public static String realtimeMeetingEventSeqKey(Long meetingId) {
return "biz:meeting:realtime:event-seq:" + meetingId;
}
public static String publicMeetingSessionKey(String sessionId) {
return "biz:meeting:public-session:" + sessionId;
}
public static String androidChunkUploadSessionKey(Long meetingId) {
return "biz:meeting:android:chunk-upload:" + meetingId;
}
public static String androidPendingMeetingDraftKey(Long meetingId) {
return "biz:meeting:android:draft:" + meetingId;
}
public static String androidDeviceWeatherKey(String cityName) {
return "biz:android:device:weather:" + cityName;
}
private static String normalizeQueueKey(String queueKey) {
if (queueKey == null || queueKey.isBlank()) {
return "unknown";
}
return queueKey.trim();
}
public static final String CACHE_EMPTY_MARKER = "EMPTY_MARKER";
public static final String SYS_PARAM_FIELD_VALUE = "value";
public static final String SYS_PARAM_FIELD_TYPE = "type";

View File

@ -1,7 +1,71 @@
package com.imeeting.common;
/**
* Key
*/
public final class SysParamKeys {
private SysParamKeys() {}
private SysParamKeys() {
}
public static final String CAPTCHA_ENABLED = "security.captcha.enabled";
// 会议总结系统提示词模板,控制总结任务的角色设定和输出约束
public static final String MEETING_SUMMARY_SYSTEM_PROMPT = "meeting.summary.system_prompt";
// 会议章节系统提示词模板,控制章节切分任务的角色设定和输出约束
public static final String MEETING_CHAPTER_SYSTEM_PROMPT = "meeting.chapter.prompt_template";
// 会议总结用户提示词模板,承载会议字段和业务占位符
public static final String MEETING_SUMMARY_USER_TEMPLATE = "meeting.summary.user_template";
// 会议章节用户提示词模板,承载转录分段占位符
public static final String MEETING_CHAPTER_USER_TEMPLATE = "meeting.chapter.user_template";
/** 是否启用 AI 目录。 */
public static final String MEETING_AI_CATALOG_ENABLED = "meeting.ai_catalog.enabled";
/** 会议总结派发模式PARALLEL / SERIAL。 */
public static final String MEETING_SUMMARY_DISPATCH_MODE = "meeting.summary.dispatch_mode";
/** 离线会议音频上传大小上限,单位 MB。 */
public static final String MEETING_OFFLINE_AUDIO_MAX_SIZE_MB = "meeting.offline_audio.max_size_mb";
/** 是否允许创建离线会议。 */
public static final String MEETING_CREATE_OFFLINE_ENABLED = "meeting.create.offline_enabled";
/** 是否允许创建实时会议。 */
public static final String MEETING_CREATE_REALTIME_ENABLED = "meeting.create.realtime_enabled";
/** ASR 任务最大并发数。 */
public static final String MEETING_ASR_MAX_CONCURRENT = "meeting.asr.max_concurrent";
/** 会议暂停最长时长,单位秒。 */
public static final String MEETING_MAX_PAUSE_DURATION = "meeting.max_pause_duration";
/** 单场会议最大时长,单位分钟。 */
public static final String MEETING_MAX_MEETING_DURATION = "meeting.max_meeting_duration";
/**
*
*/
public static final String MEETING_MIN_MEETING_DURATION = "meeting.min_meeting_duration";
/** 会议音频传输丢包率配置值。 */
public static final String MEETING_PACKET_LOSS_RATE = "meeting.packet_loss_rate";
/** 安卓端是否启用音频分片上传。 */
public static final String MEETING_ANDROID_AUDIO_CHUNK_UPLOAD_ENABLED = "meeting.android.audio.chunk_upload_enabled";
/** 安卓端音频分片上传时每片时长,单位秒。 */
public static final String MEETING_ANDROID_AUDIO_CHUNK_DURATION_SECONDS = "meeting.android.audio.chunk_duration_seconds";
/** 会议积分功能总开关。 */
public static final String MEETING_POINTS_ENABLED = "meeting.points.enabled";
/** 积分计费单位时长,单位分钟。 */
public static final String MEETING_POINTS_UNIT_MINUTES = "meeting.points.unit_minutes";
/** 每个计费单位消耗的积分数。 */
public static final String MEETING_POINTS_COST_PER_UNIT = "meeting.points.cost_per_unit";
/** 积分拆分时分配给 ASR 的比例。 */
public static final String MEETING_POINTS_ASR_RATIO = "meeting.points.asr_ratio";
/** 积分拆分时分配给 LLM 的比例。 */
public static final String MEETING_POINTS_LLM_RATIO = "meeting.points.llm_ratio";
/** 租户初始化公共积分账户时的初始积分余额。 */
public static final String MEETING_POINTS_INITIAL_BALANCE = "meeting.points.initial_balance";
/** 会议积分账户模式PUBLIC / PERSONAL / BOTH。 */
public static final String MEETING_POINTS_ACCOUNT_MODE = "meeting.points.account_mode";
/** 会议积分扣费优先级PERSONAL_FIRST / PUBLIC_FIRST。 */
public static final String MEETING_POINTS_CHARGE_PRIORITY = "meeting.points.charge_priority";
/** 临时授权默认下发数量。 */
public static final String LICENSE_TEMP_DEFAULT_COUNT = "license.temp.default.count";
/** 临时授权默认有效期,单位月。 */
public static final String LICENSE_TEMP_DEFAULT_EXPIRE_MONTHS = "license.temp.default.expire.months";
/** 默认授权对应的产品编码。 */
public static final String LICENSE_DEFAULT_PRODUCT_CODE = "license.default.product.code";
/** 和风天气接口基础地址。 */
public static final String DEVICE_WEATHER_QWEATHER_BASE_URL = "device.weather.qweather.base_url";
/** 和风天气接口访问 Key。 */
public static final String DEVICE_WEATHER_QWEATHER_KEY = "device.weather.qweather.key";
}

View File

@ -1,11 +0,0 @@
package com.imeeting.common.annotation;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Log {
String value() default ""; // 操作描述
String type() default ""; // 资源类型/模块名
}

View File

@ -1,111 +0,0 @@
package com.imeeting.common.aspect;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.annotation.Log;
import com.imeeting.entity.SysLog;
import com.imeeting.security.LoginUser;
import com.imeeting.service.SysLogService;
import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
@Aspect
@Component
public class LogAspect {
private final SysLogService sysLogService;
private final ObjectMapper objectMapper;
public LogAspect(SysLogService sysLogService, ObjectMapper objectMapper) {
this.sysLogService = sysLogService;
this.objectMapper = objectMapper;
}
@Around("@annotation(com.imeeting.common.annotation.Log)")
public Object around(ProceedingJoinPoint point) throws Throwable {
long start = System.currentTimeMillis();
Object result = null;
Exception exception = null;
try {
result = point.proceed();
return result;
} catch (Exception e) {
exception = e;
throw e;
} finally {
saveLog(point, result, exception, System.currentTimeMillis() - start);
}
}
private void saveLog(ProceedingJoinPoint joinPoint, Object result, Exception e, long duration) {
try {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) return;
HttpServletRequest request = attributes.getRequest();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Log logAnnotation = method.getAnnotation(Log.class);
SysLog sysLog = new SysLog();
sysLog.setLogType("OPERATION");
sysLog.setOperation(logAnnotation.value());
sysLog.setMethod(request.getMethod() + " " + request.getRequestURI());
sysLog.setDuration(duration);
sysLog.setIp(request.getRemoteAddr());
sysLog.setCreatedAt(LocalDateTime.now());
// 仅保留请求参数,移除响应结果
sysLog.setParams(getArgsJson(joinPoint));
// 获取当前租户和用户信息
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof LoginUser) {
LoginUser user = (LoginUser) auth.getPrincipal();
sysLog.setUserId(user.getUserId());
sysLog.setTenantId(user.getTenantId());
sysLog.setUsername(user.getUsername());
}
sysLog.setStatus(e != null ? 0 : 1);
sysLogService.recordLog(sysLog);
} catch (Exception ex) {
ex.printStackTrace();
}
}
private String getArgsJson(ProceedingJoinPoint joinPoint) {
try {
Object[] args = joinPoint.getArgs();
if (args == null || args.length == 0) return null;
StringBuilder sb = new StringBuilder();
for (Object arg : args) {
if (arg instanceof jakarta.servlet.ServletRequest
|| arg instanceof jakarta.servlet.ServletResponse
|| arg instanceof org.springframework.web.multipart.MultipartFile) {
continue;
}
try {
sb.append(objectMapper.writeValueAsString(arg)).append(" ");
} catch (Exception e) {
sb.append("[Unserializable Argument] ");
}
}
return sb.toString().trim();
} catch (Exception e) {
return "[Error capturing params]";
}
}
}

View File

@ -0,0 +1,116 @@
package com.imeeting.config;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.unisbase.config.properties.UnisBaseProperties;
import com.unisbase.entity.SysTenant;
import com.unisbase.mapper.SysTenantMapper;
import com.unisbase.security.SpringSecurityTenantProvider;
import com.unisbase.spi.UnisBaseTenantProvider;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
@Configuration
public class AndroidTenantProviderConfig {
private static final String TENANT_ID_HEADER = "X-Tenant-Id";
private static final String TENANT_CODE_HEADER = "X-Tenant-Code";
@Bean
public UnisBaseTenantProvider unisBaseTenantProvider(UnisBaseProperties properties,
ObjectProvider<SysTenantMapper> sysTenantMapperProvider) {
return new AndroidTenantProvider(properties, sysTenantMapperProvider);
}
private static final class AndroidTenantProvider extends SpringSecurityTenantProvider {
private final UnisBaseProperties properties;
private final ObjectProvider<SysTenantMapper> sysTenantMapperProvider;
private AndroidTenantProvider(UnisBaseProperties properties, ObjectProvider<SysTenantMapper> sysTenantMapperProvider) {
super(properties);
this.properties = properties;
this.sysTenantMapperProvider = sysTenantMapperProvider;
}
@Override
public Long getCurrentTenantId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof com.unisbase.security.LoginUser user && user.getTenantId() != null) {
return user.getTenantId();
}
Long tenantId = parseTenantIdHeader();
if (tenantId != null) {
return tenantId;
}
String tenantCode = header(TENANT_CODE_HEADER);
if (StringUtils.hasText(tenantCode)) {
Long resolvedTenantId = resolveTenantIdByCode(tenantCode.trim());
if (resolvedTenantId != null) {
return resolvedTenantId;
}
}
if (isSingleTenantMode()) {
Long defaultTenantId = properties.getTenant().getDefaultTenantId();
return defaultTenantId == null || defaultTenantId <= 0 ? 1L : defaultTenantId;
}
return 0L;
}
private Long parseTenantIdHeader() {
String value = header(TENANT_ID_HEADER);
if (!StringUtils.hasText(value)) {
return null;
}
try {
return Long.valueOf(value.trim());
} catch (NumberFormatException ex) {
return null;
}
}
private Long resolveTenantIdByCode(String tenantCode) {
SysTenantMapper sysTenantMapper = sysTenantMapperProvider.getIfAvailable();
if (sysTenantMapper == null) {
return null;
}
try {
SysTenant tenant = sysTenantMapper.selectOne(new LambdaQueryWrapper<SysTenant>()
.eq(SysTenant::getTenantCode, tenantCode)
.eq(SysTenant::getIsDeleted, 0)
.last("LIMIT 1"));
return tenant == null ? null : tenant.getId();
} catch (Exception ex) {
return null;
}
}
private String header(String name) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return null;
}
HttpServletRequest request = attributes.getRequest();
return request == null ? null : request.getHeader(name);
}
private boolean isSingleTenantMode() {
if (properties == null || properties.getTenant() == null) {
return false;
}
String mode = properties.getTenant().getMode();
if (mode != null && !mode.isBlank()) {
return "single".equalsIgnoreCase(mode.trim());
}
return !properties.getTenant().isEnabled();
}
}
}

View File

@ -0,0 +1,54 @@
package com.imeeting.config;
import cn.hutool.core.util.StrUtil;
import com.unisbase.common.ApiResponse;
import com.unisbase.common.exception.BusinessException;
import com.unisbase.common.exception.ErrorCodeEnum;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import java.util.stream.Collectors;
@RestControllerAdvice
@Slf4j
public class ApiResponseSuccessCodeAdvice implements ResponseBodyAdvice<Object> {
private static final String LEGACY_SUCCESS_CODE = "0";
private static final String SUCCESS_CODE = "200";
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}
@Override
public Object beforeBodyWrite(Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response) {
if (body instanceof ApiResponse<?> apiResponse && LEGACY_SUCCESS_CODE.equals(apiResponse.getCode())) {
apiResponse.setCode(SUCCESS_CODE);
}
return body;
}
@ExceptionHandler({MethodArgumentNotValidException.class})
public ApiResponse<Void> handleBusinessException(MethodArgumentNotValidException ex) {
String msg = ex.getBindingResult().getAllErrors()
.stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.collect(Collectors.joining(", "));
return new ApiResponse(ErrorCodeEnum.SYSTEM_ERROR.getCode(), msg, (Object) null);
}
}

View File

@ -1,29 +0,0 @@
package com.imeeting.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
@Configuration
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1)) // Default TTL
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues();
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}
}

View File

@ -0,0 +1,42 @@
package com.imeeting.config;
import io.lettuce.core.RedisClient;
import io.lettuce.core.RedisURI;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.codec.StringCodec;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
import java.time.Duration;
@Configuration
public class LettuceRedisConfig {
@Bean(destroyMethod = "shutdown")
public RedisClient redisClient(RedisProperties redisProperties) {
RedisURI.Builder builder = RedisURI.builder()
.withHost(redisProperties.getHost())
.withPort(redisProperties.getPort())
.withDatabase(redisProperties.getDatabase());
if (StringUtils.hasText(redisProperties.getUsername())) {
builder.withAuthentication(redisProperties.getUsername(), redisProperties.getPassword());
} else if (StringUtils.hasText(redisProperties.getPassword())) {
builder.withPassword(redisProperties.getPassword().toCharArray());
}
Duration timeout = redisProperties.getTimeout();
if (timeout != null && !timeout.isZero() && !timeout.isNegative()) {
builder.withTimeout(timeout);
}
return RedisClient.create(builder.build());
}
@Bean(destroyMethod = "close")
public StatefulRedisConnection<String, String> redisConnection(RedisClient redisClient) {
return redisClient.connect(StringCodec.UTF8);
}
}

View File

@ -0,0 +1,51 @@
package com.imeeting.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
@Configuration
public class MeetingAsyncExecutorConfig {
@Bean("asrTaskExecutor")
public Executor asrTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(20);
executor.setThreadNamePrefix("imeeting-asr-worker-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
@Bean("summaryTaskExecutor")
public Executor summaryTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(20);
executor.setThreadNamePrefix("imeeting-summary-worker-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
@Bean("chunkMergeExecutor")
public Executor chunkMergeExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(10);
executor.setThreadNamePrefix("imeeting-chunk-merge-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}

View File

@ -1,82 +0,0 @@
package com.imeeting.config;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import com.imeeting.security.LoginUser;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import java.time.LocalDateTime;
import java.util.List;
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
@Override
public Expression getTenantId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof LoginUser) {
LoginUser user = (LoginUser) auth.getPrincipal();
if (user.getTenantId() != null) {
return new LongValue(user.getTenantId());
}
}
// If no tenant context (e.g. system task or error), return 0
return new LongValue(0);
}
@Override
public String getTenantIdColumn() {
return "tenant_id";
}
@Override
public boolean ignoreTable(String tableName) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof LoginUser) {
LoginUser user = (LoginUser) auth.getPrincipal();
// 只有当平台管理员处于系统租户(0)时,才忽略所有过滤。
// 如果他切换到了具体租户(>0),则必须接受过滤,确保只能看到当前租户数据。
if (Boolean.TRUE.equals(user.getIsPlatformAdmin()) && Long.valueOf(0).equals(user.getTenantId())) {
return true;
}
}
// 公共表始终忽略过滤
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").contains(tableName.toLowerCase());
}
}));
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
@Bean
public MetaObjectHandler metaObjectHandler() {
return new MetaObjectHandler() {
@Override
public void insertFill(MetaObject metaObject) {
strictInsertFill(metaObject, "createdAt", LocalDateTime::now, LocalDateTime.class);
strictInsertFill(metaObject, "updatedAt", LocalDateTime::now, LocalDateTime.class);
strictInsertFill(metaObject, "status", () -> 1, Integer.class);
strictInsertFill(metaObject, "isDeleted", () -> 0, Integer.class);
}
@Override
public void updateFill(MetaObject metaObject) {
strictUpdateFill(metaObject, "updatedAt", LocalDateTime::now, LocalDateTime.class);
}
};
}
}

View File

@ -0,0 +1,40 @@
package com.imeeting.config;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme.In;
import io.swagger.v3.oas.models.security.SecurityScheme.Type;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@SecurityScheme(
name = OpenApiConfig.BEARER_AUTH_SCHEME,
type = SecuritySchemeType.HTTP,
scheme = "bearer",
bearerFormat = "JWT",
in = SecuritySchemeIn.HEADER
)
public class OpenApiConfig {
public static final String BEARER_AUTH_SCHEME = "bearerAuth";
@Bean
public OpenAPI imeetingOpenApi() {
return new OpenAPI()
.info(new Info()
.title("iMeeting 接口文档")
.description("iMeeting 后端 REST 接口与兼容接口文档")
.version("v0.1.0"))
.addSecurityItem(new SecurityRequirement().addList(BEARER_AUTH_SCHEME))
.schemaRequirement(BEARER_AUTH_SCHEME, new io.swagger.v3.oas.models.security.SecurityScheme()
.type(Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.in(In.HEADER));
}
}

View File

@ -0,0 +1,55 @@
package com.imeeting.config;
import com.imeeting.websocket.RealtimeMeetingProxyWebSocketHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.boot.web.servlet.ServletContextInitializer;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class RealtimeMeetingWebSocketConfig implements WebSocketConfigurer {
private static final String TOMCAT_WS_TEXT_BUFFER_SIZE = "org.apache.tomcat.websocket.textBufferSize";
private static final String WS_TEXT_BUFFER_SIZE = "1048576";
private final RealtimeMeetingProxyWebSocketHandler realtimeMeetingProxyWebSocketHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(realtimeMeetingProxyWebSocketHandler, "/ws/meeting/realtime")
.setAllowedOriginPatterns("*");
}
/**
* Tomcat WebSocket keepalive ping sessionIdleTimeout
* <p>
* Tomcat WebSocket session sessionIdleTimeout-1 -1
* 线 Ping Pong
* code=1011 "keepalive ping timeout"
* ASR
* sessionIdleTimeout -1
* </p>
*/
@Bean
public WebServerFactoryCustomizer<TomcatServletWebServerFactory> wsSessionIdleTimeoutCustomizer() {
return factory -> factory.addConnectorCustomizers(connector -> {
// 通过系统属性通知 Tomcat WS 容器关闭 sessionIdleTimeout 检查
// org.apache.tomcat.websocket.DEFAULT_SESSION_IDLE_TIMEOUT=-1
if (System.getProperty("org.apache.tomcat.websocket.DEFAULT_SESSION_IDLE_TIMEOUT") == null) {
System.setProperty("org.apache.tomcat.websocket.DEFAULT_SESSION_IDLE_TIMEOUT", "-1");
}
});
}
@Bean
public ServletContextInitializer realtimeWebSocketBufferInitializer() {
return servletContext -> servletContext.setInitParameter(TOMCAT_WS_TEXT_BUFFER_SIZE, WS_TEXT_BUFFER_SIZE);
}
}

View File

@ -0,0 +1,17 @@
package com.imeeting.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
@Configuration
public class RedisKeyExpirationConfig {
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory connectionFactory) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
return container;
}
}

View File

@ -1,62 +0,0 @@
package com.imeeting.config;
import com.imeeting.auth.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
http.csrf(csrf -> csrf.disable())
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.requestMatchers("/api/open/**").permitAll()
.requestMatchers("/api/static/**").permitAll()
.requestMatchers("/api/params/value").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of("*"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}

View File

@ -1,20 +0,0 @@
package com.imeeting.config;
import com.imeeting.service.SysParamService;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
public class SysParamCacheInitializer implements ApplicationRunner {
private final SysParamService sysParamService;
public SysParamCacheInitializer(SysParamService sysParamService) {
this.sysParamService = sysParamService;
}
@Override
public void run(ApplicationArguments args) {
sysParamService.syncAllToCache();
}
}

View File

@ -2,6 +2,8 @@ package com.imeeting.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@ -10,12 +12,17 @@ import java.io.File;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Value("${app.upload-path}")
@Value("${unisbase.app.upload-path}")
private String uploadPath;
@Value("${app.resource-prefix}")
@Value("${unisbase.app.resource-prefix}")
private String resourcePrefix;
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer.mediaType("m4a", MediaType.parseMediaType("audio/mp4"));
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 确保目录存在

View File

@ -0,0 +1,12 @@
package com.imeeting.config.grpc;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
@Data
@ConfigurationProperties(prefix = "imeeting.grpc.auth")
public class AndroidGrpcAuthProperties {
private boolean enabled = false;
private boolean allowAnonymous = true;
}

View File

@ -0,0 +1,77 @@
package com.imeeting.config.grpc;
import io.grpc.ForwardingServerCallListener;
import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.Status;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.concurrent.atomic.AtomicBoolean;
@Slf4j
@Component
public class GrpcExceptionLoggingInterceptor implements ServerInterceptor {
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(ServerCall<ReqT, RespT> call,
Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
String methodName = call.getMethodDescriptor().getFullMethodName();
AtomicBoolean closed = new AtomicBoolean(false);
ServerCall.Listener<ReqT> delegate;
try {
delegate = next.startCall(call, headers);
} catch (RuntimeException ex) {
log.error("gRPC startCall failed, method={}", methodName, ex);
closeCall(call, closed, ex);
return new ServerCall.Listener<>() {
};
}
return new ForwardingServerCallListener.SimpleForwardingServerCallListener<>(delegate) {
@Override
public void onMessage(ReqT message) {
runSafely("onMessage", () -> super.onMessage(message));
}
@Override
public void onHalfClose() {
runSafely("onHalfClose", super::onHalfClose);
}
@Override
public void onCancel() {
runSafely("onCancel", super::onCancel);
}
@Override
public void onComplete() {
runSafely("onComplete", super::onComplete);
}
@Override
public void onReady() {
runSafely("onReady", super::onReady);
}
private void runSafely(String phase, Runnable action) {
try {
action.run();
} catch (RuntimeException ex) {
log.error("gRPC request handling failed, method={}, phase={}", methodName, phase, ex);
closeCall(call, closed, ex);
}
}
};
}
private <ReqT, RespT> void closeCall(ServerCall<ReqT, RespT> call, AtomicBoolean closed, RuntimeException ex) {
if (!closed.compareAndSet(false, true)) {
return;
}
call.close(Status.UNKNOWN.withDescription("应用处理 RPC 时发生异常").withCause(ex), new Metadata());
}
}

View File

@ -0,0 +1,56 @@
package com.imeeting.config.grpc;
import io.grpc.BindableService;
import io.grpc.Server;
import io.grpc.protobuf.services.ProtoReflectionService;
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
@EnableConfigurationProperties({GrpcServerProperties.class, AndroidGrpcAuthProperties.class})
public class GrpcServerLifecycle {
private final GrpcServerProperties properties;
private final GrpcExceptionLoggingInterceptor grpcExceptionLoggingInterceptor;
private final List<BindableService> bindableServices;
private Server server;
@PostConstruct
public void start() throws IOException {
if (!properties.isEnabled()) {
log.info("gRPC server is disabled by configuration");
return;
}
NettyServerBuilder builder = NettyServerBuilder.forPort(properties.getPort())
.maxInboundMessageSize(properties.getMaxInboundMessageSize())
.intercept(grpcExceptionLoggingInterceptor);
bindableServices.forEach(builder::addService);
if (properties.isReflectionEnabled()) {
builder.addService(ProtoReflectionService.newInstance());
}
server = builder.build();
server.start();
log.info("gRPC server started on port {} with {} services", properties.getPort(), bindableServices.size());
}
@PreDestroy
public void stop() {
if (server == null) {
return;
}
log.info("Stopping gRPC server");
server.shutdown();
}
}

View File

@ -0,0 +1,31 @@
package com.imeeting.config.grpc;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
@Data
@ConfigurationProperties(prefix = "imeeting.grpc")
public class GrpcServerProperties {
private boolean enabled = true;
private int port = 19090;
private int maxInboundMessageSize = 4194304;
private boolean reflectionEnabled = true;
private Gateway gateway = new Gateway();
private Realtime realtime = new Realtime();
@Data
public static class Gateway {
private long heartbeatIntervalSeconds = 15;
private long heartbeatTimeoutSeconds = 45;
}
@Data
public static class Realtime {
private long sessionTtlSeconds = 600;
private int sampleRate = 16000;
private int channels = 1;
private String encoding = "PCM16LE";
private long connectionTtlSeconds = 1800;
}
}

View File

@ -1,100 +0,0 @@
package com.imeeting.controller;
import com.imeeting.auth.JwtTokenProvider;
import com.imeeting.auth.dto.CaptchaResponse;
import com.imeeting.auth.dto.DeviceCodeRequest;
import com.imeeting.auth.dto.LoginRequest;
import com.imeeting.auth.dto.RefreshRequest;
import com.imeeting.auth.dto.TokenResponse;
import com.imeeting.common.ApiResponse;
import com.imeeting.common.RedisKeys;
import com.imeeting.common.SysParamKeys;
import com.imeeting.service.SysParamService;
import com.imeeting.service.AuthService;
import com.wf.captcha.SpecCaptcha;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.*;
import java.time.Duration;
import java.util.UUID;
@RestController
@RequestMapping("/auth")
public class AuthController {
private final AuthService authService;
private final StringRedisTemplate stringRedisTemplate;
private final JwtTokenProvider jwtTokenProvider;
private final SysParamService sysParamService;
@Value("${app.captcha.ttl-seconds:120}")
private long captchaTtlSeconds;
public AuthController(AuthService authService, StringRedisTemplate stringRedisTemplate,
JwtTokenProvider jwtTokenProvider, SysParamService sysParamService) {
this.authService = authService;
this.stringRedisTemplate = stringRedisTemplate;
this.jwtTokenProvider = jwtTokenProvider;
this.sysParamService = sysParamService;
}
@GetMapping("/captcha")
public ApiResponse<CaptchaResponse> captcha() {
if (!isCaptchaEnabled()) {
return ApiResponse.error("Captcha disabled");
}
SpecCaptcha captcha = new SpecCaptcha(130, 48, 4);
String code = captcha.text();
String imageBase64 = captcha.toBase64();
String captchaId = UUID.randomUUID().toString().replace("-", "");
stringRedisTemplate.opsForValue().set(RedisKeys.captchaKey(captchaId), code, Duration.ofSeconds(captchaTtlSeconds));
return ApiResponse.ok(new CaptchaResponse(captchaId, imageBase64));
}
@PostMapping("/device-code")
public ApiResponse<String> deviceCode(@Valid @RequestBody DeviceCodeRequest request) {
LoginRequest loginRequest = new LoginRequest();
loginRequest.setUsername(request.getUsername());
loginRequest.setPassword(request.getPassword());
loginRequest.setCaptchaId(request.getCaptchaId());
loginRequest.setCaptchaCode(request.getCaptchaCode());
String deviceCode = authService.createDeviceCode(loginRequest, request.getDeviceName());
return ApiResponse.ok(deviceCode);
}
@PostMapping("/login")
public ApiResponse<TokenResponse> login(@Valid @RequestBody LoginRequest request) {
return ApiResponse.ok(authService.login(request));
}
@PostMapping("/refresh")
public ApiResponse<TokenResponse> refresh(@Valid @RequestBody RefreshRequest request) {
return ApiResponse.ok(authService.refresh(request.getRefreshToken()));
}
@PostMapping("/switch-tenant")
public ApiResponse<TokenResponse> switchTenant(@RequestParam Long tenantId, @RequestHeader("Authorization") String authorization) {
String token = authorization.replace("Bearer ", "");
var claims = jwtTokenProvider.parseToken(token);
Long userId = claims.get("userId", Long.class);
String deviceCode = claims.get("deviceCode", String.class);
return ApiResponse.ok(authService.switchTenant(userId, tenantId, deviceCode));
}
@PostMapping("/logout")
public ApiResponse<Void> logout(@RequestHeader("Authorization") String authorization) {
String token = authorization.replace("Bearer ", "");
var claims = jwtTokenProvider.parseToken(token);
Long userId = claims.get("userId", Long.class);
String deviceCode = claims.get("deviceCode", String.class);
authService.logout(userId, deviceCode);
return ApiResponse.ok(null);
}
private boolean isCaptchaEnabled() {
String value = sysParamService.getCachedParamValue(SysParamKeys.CAPTCHA_ENABLED, "true");
return Boolean.parseBoolean(value);
}
}

View File

@ -1,44 +0,0 @@
package com.imeeting.controller;
import com.imeeting.common.ApiResponse;
import com.imeeting.entity.Device;
import com.imeeting.service.DeviceService;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/devices")
public class DeviceController {
private final DeviceService deviceService;
public DeviceController(DeviceService deviceService) {
this.deviceService = deviceService;
}
@GetMapping
public ApiResponse<List<Device>> list() {
return ApiResponse.ok(deviceService.list());
}
@GetMapping("/{id}")
public ApiResponse<Device> get(@PathVariable Long id) {
return ApiResponse.ok(deviceService.getById(id));
}
@PostMapping
public ApiResponse<Boolean> create(@RequestBody Device device) {
return ApiResponse.ok(deviceService.save(device));
}
@PutMapping("/{id}")
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody Device device) {
device.setDeviceId(id);
return ApiResponse.ok(deviceService.updateById(device));
}
@DeleteMapping("/{id}")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
return ApiResponse.ok(deviceService.removeById(id));
}
}

View File

@ -1,62 +0,0 @@
package com.imeeting.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.common.ApiResponse;
import com.imeeting.entity.SysDictItem;
import com.imeeting.service.SysDictItemService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/dict-items")
public class DictItemController {
private final SysDictItemService sysDictItemService;
public DictItemController(SysDictItemService sysDictItemService) {
this.sysDictItemService = sysDictItemService;
}
@GetMapping
@PreAuthorize("@ss.hasPermi('sys_dict:list')")
public ApiResponse<List<SysDictItem>> list(@RequestParam(required = false) String typeCode) {
LambdaQueryWrapper<SysDictItem> queryWrapper = new LambdaQueryWrapper<>();
if (typeCode != null && !typeCode.isEmpty()) {
queryWrapper.eq(SysDictItem::getTypeCode, typeCode);
}
queryWrapper.orderByAsc(SysDictItem::getSortOrder);
return ApiResponse.ok(sysDictItemService.list(queryWrapper));
}
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys_dict:query')")
public ApiResponse<SysDictItem> get(@PathVariable Long id) {
return ApiResponse.ok(sysDictItemService.getById(id));
}
@PostMapping
@PreAuthorize("@ss.hasPermi('sys_dict:create')")
public ApiResponse<Boolean> create(@RequestBody SysDictItem dictItem) {
return ApiResponse.ok(sysDictItemService.save(dictItem));
}
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys_dict:update')")
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysDictItem dictItem) {
dictItem.setDictItemId(id);
return ApiResponse.ok(sysDictItemService.updateById(dictItem));
}
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys_dict:delete')")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
return ApiResponse.ok(sysDictItemService.removeById(id));
}
@GetMapping("/type/{typeCode}")
// @PreAuthorize("@ss.hasPermi('sys_dict:query')")
public ApiResponse<List<SysDictItem>> getByType(@PathVariable String typeCode) {
return ApiResponse.ok(sysDictItemService.getItemsByTypeCode(typeCode));
}
}

View File

@ -1,63 +0,0 @@
package com.imeeting.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.imeeting.common.ApiResponse;
import com.imeeting.entity.SysDictType;
import com.imeeting.service.SysDictTypeService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/dict-types")
public class DictTypeController {
private final SysDictTypeService sysDictTypeService;
public DictTypeController(SysDictTypeService sysDictTypeService) {
this.sysDictTypeService = sysDictTypeService;
}
@GetMapping
@PreAuthorize("@ss.hasPermi('sys_dict:list')")
public ApiResponse<Page<SysDictType>> list(
@RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String typeCode,
@RequestParam(required = false) String typeName) {
Page<SysDictType> page = new Page<>(current, size);
LambdaQueryWrapper<SysDictType> queryWrapper = new LambdaQueryWrapper<>();
if (typeCode != null && !typeCode.isEmpty()) {
queryWrapper.like(SysDictType::getTypeCode, typeCode);
}
if (typeName != null && !typeName.isEmpty()) {
queryWrapper.like(SysDictType::getTypeName, typeName);
}
queryWrapper.orderByAsc(SysDictType::getTypeCode);
return ApiResponse.ok(sysDictTypeService.page(page, queryWrapper));
}
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys_dict:query')")
public ApiResponse<SysDictType> get(@PathVariable Long id) {
return ApiResponse.ok(sysDictTypeService.getById(id));
}
@PostMapping
@PreAuthorize("@ss.hasPermi('sys_dict:create')")
public ApiResponse<Boolean> create(@RequestBody SysDictType dictType) {
return ApiResponse.ok(sysDictTypeService.save(dictType));
}
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys_dict:update')")
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysDictType dictType) {
dictType.setDictTypeId(id);
return ApiResponse.ok(sysDictTypeService.updateById(dictType));
}
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys_dict:delete')")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
return ApiResponse.ok(sysDictTypeService.removeById(id));
}
}

View File

@ -1,231 +0,0 @@
package com.imeeting.controller;
import com.imeeting.common.ApiResponse;
import com.imeeting.dto.PermissionNode;
import com.imeeting.entity.SysPermission;
import com.imeeting.entity.SysRole;
import com.imeeting.mapper.SysRolePermissionMapper;
import com.imeeting.mapper.SysUserRoleMapper;
import com.imeeting.service.AuthVersionService;
import com.imeeting.service.SysPermissionService;
import com.imeeting.service.SysRoleService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/permissions")
public class PermissionController {
private final SysPermissionService sysPermissionService;
private final SysRolePermissionMapper sysRolePermissionMapper;
private final SysUserRoleMapper sysUserRoleMapper;
private final SysRoleService sysRoleService;
private final AuthVersionService authVersionService;
public PermissionController(SysPermissionService sysPermissionService,
SysRolePermissionMapper sysRolePermissionMapper, SysUserRoleMapper sysUserRoleMapper,
SysRoleService sysRoleService, AuthVersionService authVersionService) {
this.sysPermissionService = sysPermissionService;
this.sysRolePermissionMapper = sysRolePermissionMapper;
this.sysUserRoleMapper = sysUserRoleMapper;
this.sysRoleService = sysRoleService;
this.authVersionService = authVersionService;
}
@GetMapping
@PreAuthorize("@ss.hasPermi('sys:permission:list')")
public ApiResponse<List<SysPermission>> list() {
Long tenantId = getCurrentTenantId();
// 平台管理员查询所有
if (Long.valueOf(0).equals(tenantId)) {
return ApiResponse.ok(sysPermissionService.list());
}
// 非平台管理员只能查询自己拥有的权限
return ApiResponse.ok(sysPermissionService.listByUserId(getCurrentUserId(), tenantId));
}
@GetMapping("/me")
public ApiResponse<List<SysPermission>> myPermissions() {
return ApiResponse.ok(sysPermissionService.listByUserId(getCurrentUserId(), getCurrentTenantId()));
}
@GetMapping("/tree")
@PreAuthorize("@ss.hasPermi('sys:permission:list')")
public ApiResponse<List<PermissionNode>> tree() {
Long tenantId = getCurrentTenantId();
List<SysPermission> list;
if (Long.valueOf(0).equals(tenantId)) {
list = sysPermissionService.list();
} else {
list = sysPermissionService.listByUserId(getCurrentUserId(), tenantId);
}
return ApiResponse.ok(buildTree(list));
}
@GetMapping("/tree/me")
public ApiResponse<List<PermissionNode>> myTree() {
return ApiResponse.ok(buildTree(sysPermissionService.listByUserId(getCurrentUserId(), getCurrentTenantId())));
}
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys:permission:query')")
public ApiResponse<SysPermission> get(@PathVariable Long id) {
return ApiResponse.ok(sysPermissionService.getById(id));
}
@PostMapping
@PreAuthorize("@ss.hasPermi('sys:permission:create')")
public ApiResponse<Boolean> create(@RequestBody SysPermission perm) {
String error = validateParent(perm);
if (error != null) {
return ApiResponse.error(error);
}
return ApiResponse.ok(sysPermissionService.save(perm));
}
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys:permission:update')")
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysPermission perm) {
List<Long> roleIds = sysRolePermissionMapper.selectRoleIdsByPermId(id);
perm.setPermId(id);
String error = validateParent(perm);
if (error != null) {
return ApiResponse.error(error);
}
boolean updated = sysPermissionService.updateById(perm);
if (perm.getLevel() != null && perm.getLevel() == 1) {
sysPermissionService.lambdaUpdate()
.set(SysPermission::getParentId, null)
.eq(SysPermission::getPermId, id)
.update();
}
if (updated) {
invalidateRoleUsers(roleIds);
}
return ApiResponse.ok(updated);
}
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys:permission:delete')")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
List<Long> roleIds = sysRolePermissionMapper.selectRoleIdsByPermId(id);
boolean removed = sysPermissionService.removeById(id);
if (removed) {
invalidateRoleUsers(roleIds);
}
return ApiResponse.ok(removed);
}
private Long getCurrentUserId() {
org.springframework.security.core.Authentication authentication = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof com.imeeting.security.LoginUser) {
return ((com.imeeting.security.LoginUser) authentication.getPrincipal()).getUserId();
}
return null;
}
private Long getCurrentTenantId() {
org.springframework.security.core.Authentication authentication = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof com.imeeting.security.LoginUser) {
return ((com.imeeting.security.LoginUser) authentication.getPrincipal()).getTenantId();
}
return null;
}
private String validateParent(SysPermission perm) {
if (perm.getLevel() == null) {
return null;
}
if (perm.getPermType() != null && "button".equalsIgnoreCase(perm.getPermType())) {
if (perm.getCode() == null || perm.getCode().trim().isEmpty()) {
return "Code required for button permission";
}
}
if (perm.getLevel() == 1) {
perm.setParentId(null);
return null;
}
if (perm.getLevel() == 2) {
if (perm.getParentId() == null) {
return "ParentId required for level 2";
}
SysPermission parent = sysPermissionService.getById(perm.getParentId());
if (parent == null) {
return "Parent not found";
}
if (parent.getLevel() == null || parent.getLevel() != 1) {
return "Parent must be level 1";
}
}
return null;
}
private List<PermissionNode> buildTree(List<SysPermission> list) {
Map<Long, PermissionNode> map = new HashMap<>();
List<PermissionNode> roots = new ArrayList<>();
for (SysPermission p : list) {
PermissionNode node = toNode(p);
map.put(node.getPermId(), node);
}
for (PermissionNode node : map.values()) {
Long parentId = node.getParentId();
if (parentId != null && map.containsKey(parentId)) {
map.get(parentId).getChildren().add(node);
} else {
roots.add(node);
}
}
sortTree(roots);
return roots;
}
private void sortTree(List<PermissionNode> nodes) {
nodes.sort(Comparator.comparingInt(n -> n.getSortOrder() == null ? 0 : n.getSortOrder()));
for (PermissionNode node : nodes) {
if (node.getChildren() != null && !node.getChildren().isEmpty()) {
sortTree(node.getChildren());
}
}
}
private PermissionNode toNode(SysPermission p) {
PermissionNode node = new PermissionNode();
node.setPermId(p.getPermId());
node.setParentId(p.getParentId());
node.setName(p.getName());
node.setCode(p.getCode());
node.setPermType(p.getPermType());
node.setLevel(p.getLevel());
node.setPath(p.getPath());
node.setComponent(p.getComponent());
node.setIcon(p.getIcon());
node.setSortOrder(p.getSortOrder());
node.setIsVisible(p.getIsVisible());
node.setStatus(p.getStatus());
node.setDescription(p.getDescription());
node.setMeta(p.getMeta());
return node;
}
private void invalidateRoleUsers(List<Long> roleIds) {
if (roleIds == null || roleIds.isEmpty()) {
return;
}
for (Long roleId : roleIds) {
if (roleId == null) {
continue;
}
SysRole role = sysRoleService.getById(roleId);
if (role == null || role.getTenantId() == null) {
continue;
}
List<Long> userIds = sysUserRoleMapper.selectUserIdsByRoleId(roleId);
authVersionService.invalidateUsersTenantAuth(userIds, role.getTenantId());
}
}
}

View File

@ -1,55 +0,0 @@
package com.imeeting.controller;
import com.imeeting.common.ApiResponse;
import com.imeeting.dto.PlatformConfigVO;
import com.imeeting.entity.SysPlatformConfig;
import com.imeeting.service.SysPlatformConfigService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api")
public class PlatformConfigController {
private final SysPlatformConfigService platformConfigService;
public PlatformConfigController(SysPlatformConfigService platformConfigService) {
this.platformConfigService = platformConfigService;
}
/**
* (favicon)
*/
@GetMapping("/open/platform/config")
public ApiResponse<PlatformConfigVO> getOpenConfig() {
return ApiResponse.ok(platformConfigService.getConfig());
}
/**
* ()
*/
@GetMapping("/admin/platform/config")
@PreAuthorize("isAuthenticated()")
public ApiResponse<PlatformConfigVO> getAdminConfig() {
return ApiResponse.ok(platformConfigService.getConfig());
}
/**
* ()
*/
@PutMapping("/admin/platform/config")
@PreAuthorize("hasRole('ADMIN') or @ss.hasPermi('sys_platform:config:update')")
public ApiResponse<Boolean> updateConfig(@RequestBody SysPlatformConfig config) {
return ApiResponse.ok(platformConfigService.updateConfig(config));
}
/**
* ()
*/
@PostMapping("/admin/platform/config/upload")
@PreAuthorize("hasRole('ADMIN') or @ss.hasPermi('sys_platform:config:update')")
public ApiResponse<String> upload(@RequestParam("file") MultipartFile file) {
return ApiResponse.ok(platformConfigService.uploadAsset(file));
}
}

View File

@ -1,344 +0,0 @@
package com.imeeting.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.imeeting.common.ApiResponse;
import com.imeeting.common.annotation.Log;
import com.imeeting.entity.SysRole;
import com.imeeting.entity.SysRolePermission;
import com.imeeting.entity.SysUser;
import com.imeeting.entity.SysUserRole;
import com.imeeting.mapper.SysRolePermissionMapper;
import com.imeeting.mapper.SysUserRoleMapper;
import com.imeeting.service.AuthScopeService;
import com.imeeting.service.AuthVersionService;
import com.imeeting.service.SysRoleService;
import com.imeeting.service.SysUserService;
import com.imeeting.service.SysPermissionService;
import com.imeeting.service.SysTenantUserService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import java.util.Set;
@RestController
@RequestMapping("/api/roles")
public class RoleController {
private final SysRoleService sysRoleService;
private final SysUserService sysUserService;
private final SysRolePermissionMapper sysRolePermissionMapper;
private final SysUserRoleMapper sysUserRoleMapper;
private final SysPermissionService sysPermissionService;
private final AuthScopeService authScopeService;
private final AuthVersionService authVersionService;
private final SysTenantUserService sysTenantUserService;
public RoleController(SysRoleService sysRoleService, SysUserService sysUserService,
SysRolePermissionMapper sysRolePermissionMapper, SysUserRoleMapper sysUserRoleMapper,
SysPermissionService sysPermissionService,
AuthScopeService authScopeService,
AuthVersionService authVersionService,
SysTenantUserService sysTenantUserService) {
this.sysRoleService = sysRoleService;
this.sysUserService = sysUserService;
this.sysRolePermissionMapper = sysRolePermissionMapper;
this.sysUserRoleMapper = sysUserRoleMapper;
this.sysPermissionService = sysPermissionService;
this.authScopeService = authScopeService;
this.authVersionService = authVersionService;
this.sysTenantUserService = sysTenantUserService;
}
@GetMapping
@PreAuthorize("@ss.hasPermi('sys:role:list')")
public ApiResponse<List<SysRole>> list(@RequestParam(required = false) Long tenantId) {
QueryWrapper<SysRole> wrapper = new QueryWrapper<>();
if (authScopeService.isCurrentPlatformAdmin()) {
if (tenantId != null) {
wrapper.eq("tenant_id", tenantId);
}
} else {
Long currentTenantId = getCurrentTenantId();
wrapper.eq("tenant_id", currentTenantId);
}
return ApiResponse.ok(sysRoleService.list(wrapper));
}
@GetMapping("/{id}/users")
@PreAuthorize("@ss.hasPermi('sys:role:query')")
public ApiResponse<List<SysUser>> listUsers(@PathVariable Long id) {
SysRole role = sysRoleService.getById(id);
if (role == null) {
return ApiResponse.error("角色不存在");
}
if (!canAccessTenant(role.getTenantId())) {
return ApiResponse.error("禁止跨租户查看角色用户");
}
return ApiResponse.ok(sysUserService.listUsersByRoleId(id));
}
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys:role:query')")
public ApiResponse<SysRole> get(@PathVariable Long id) {
SysRole role = sysRoleService.getById(id);
if (role == null) {
return ApiResponse.error("角色不存在");
}
if (!canAccessTenant(role.getTenantId())) {
return ApiResponse.error("禁止跨租户查看角色");
}
return ApiResponse.ok(role);
}
@PostMapping
@PreAuthorize("@ss.hasPermi('sys:role:create')")
@Log(value = "新增角色", type = "角色管理")
public ApiResponse<Boolean> create(@RequestBody SysRole role) {
Long currentTenantId = getCurrentTenantId();
if (currentTenantId == null) {
return ApiResponse.error("Tenant ID required");
}
if (!authScopeService.isCurrentPlatformAdmin()) {
role.setTenantId(currentTenantId);
} else if (role.getTenantId() == null) {
return ApiResponse.error("tenantId required for platform role creation");
}
return ApiResponse.ok(sysRoleService.save(role));
}
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys:role:update')")
@Log(value = "修改角色", type = "角色管理")
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysRole role) {
SysRole existing = sysRoleService.getById(id);
if (existing == null) {
return ApiResponse.error("角色不存在");
}
if (!canAccessTenant(existing.getTenantId())) {
return ApiResponse.error("禁止跨租户修改角色");
}
role.setRoleId(id);
role.setTenantId(existing.getTenantId());
return ApiResponse.ok(sysRoleService.updateById(role));
}
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys:role:delete')")
@Log(value = "删除角色", type = "角色管理")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
SysRole existing = sysRoleService.getById(id);
if (existing == null) {
return ApiResponse.error("角色不存在");
}
if (!canAccessTenant(existing.getTenantId())) {
return ApiResponse.error("禁止跨租户删除角色");
}
if ("TENANT_ADMIN".equalsIgnoreCase(existing.getRoleCode()) && !authScopeService.isCurrentPlatformAdmin()) {
return ApiResponse.error("租户管理员角色只能由平台管理员删除");
}
List<Long> userIds = sysUserRoleMapper.selectUserIdsByRoleId(id);
boolean removed = sysRoleService.removeById(id);
if (removed) {
authVersionService.invalidateUsersTenantAuth(userIds, existing.getTenantId());
}
return ApiResponse.ok(removed);
}
@GetMapping("/{id}/permissions")
@PreAuthorize("@ss.hasPermi('sys:role:permission:list')")
public ApiResponse<List<Long>> listRolePermissions(@PathVariable Long id) {
SysRole targetRole = sysRoleService.getById(id);
if (targetRole == null) {
return ApiResponse.error("角色不存在");
}
if (!canAccessTenant(targetRole.getTenantId())) {
return ApiResponse.error("禁止跨租户查看角色权限");
}
List<SysRolePermission> rows = sysRolePermissionMapper.selectList(
new QueryWrapper<SysRolePermission>().eq("role_id", id)
);
List<Long> permIds = new ArrayList<>();
for (SysRolePermission row : rows) {
if (row.getPermId() != null) {
permIds.add(row.getPermId());
}
}
return ApiResponse.ok(permIds);
}
@PostMapping("/{id}/permissions")
@PreAuthorize("@ss.hasPermi('sys:role:permission:save')")
@Transactional(rollbackFor = Exception.class)
public ApiResponse<Boolean> saveRolePermissions(@PathVariable Long id, @RequestBody PermissionBindingPayload payload) {
List<Long> permIds = payload == null ? null : payload.getPermIds();
// 权限越权校验
Long currentTenantId = getCurrentTenantId();
if (currentTenantId == null) {
return ApiResponse.error("Tenant ID required");
}
SysRole targetRole = sysRoleService.getById(id);
if (targetRole == null) {
return ApiResponse.error("角色不存在");
}
// 关键校验:只有平台管理员可以修改 TENANT_ADMIN 角色的权限
if ("TENANT_ADMIN".equalsIgnoreCase(targetRole.getRoleCode())) {
if (!authScopeService.isCurrentPlatformAdmin()) {
return ApiResponse.error("租户管理员角色的权限只能由平台管理员修改");
}
}
if (!canAccessTenant(targetRole.getTenantId())) {
return ApiResponse.error("禁止跨租户修改角色权限");
}
if (!authScopeService.isCurrentPlatformAdmin()) {
List<com.imeeting.entity.SysPermission> myPerms = sysPermissionService.listByUserId(getCurrentUserId(), currentTenantId);
Set<Long> myPermIds = myPerms.stream()
.map(com.imeeting.entity.SysPermission::getPermId)
.collect(Collectors.toSet());
if (permIds != null) {
for (Long pId : permIds) {
if (!myPermIds.contains(pId)) {
return ApiResponse.error("越权分配权限:" + pId);
}
}
}
}
sysRolePermissionMapper.delete(new QueryWrapper<SysRolePermission>().eq("role_id", id));
if (permIds == null || permIds.isEmpty()) {
authVersionService.invalidateUsersTenantAuth(sysUserRoleMapper.selectUserIdsByRoleId(id), targetRole.getTenantId());
return ApiResponse.ok(true);
}
for (Long permId : permIds) {
if (permId == null) {
continue;
}
SysRolePermission item = new SysRolePermission();
item.setRoleId(id);
item.setPermId(permId);
sysRolePermissionMapper.insert(item);
}
authVersionService.invalidateUsersTenantAuth(sysUserRoleMapper.selectUserIdsByRoleId(id), targetRole.getTenantId());
return ApiResponse.ok(true);
}
@PostMapping("/{id}/users")
@PreAuthorize("@ss.hasPermi('sys:role:update')")
@Log(value = "角色关联用户", type = "角色管理")
@Transactional(rollbackFor = Exception.class)
public ApiResponse<Boolean> bindUsers(@PathVariable Long id, @RequestBody UserBindingPayload payload) {
if (payload == null || payload.getUserIds() == null) {
return ApiResponse.ok(true);
}
SysRole role = sysRoleService.getById(id);
if (role == null || role.getRoleId() == null || role.getTenantId() == null) {
return ApiResponse.error("角色不存在");
}
if (!canAccessTenant(role.getTenantId())) {
return ApiResponse.error("禁止跨租户绑定用户");
}
List<Long> toInsertUserIds = new ArrayList<>();
for (Long userId : payload.getUserIds()) {
if (userId == null) {
continue;
}
// 修复:处理逻辑删除导致的唯一键冲突
// 执行物理删除,彻底清除旧记录(包括已逻辑删除的)
sysUserRoleMapper.physicalDelete(id, userId, role.getTenantId());
// 确保该用户属于该租户
boolean hasMembership = sysTenantUserService.count(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.imeeting.entity.SysTenantUser>()
.eq(com.imeeting.entity.SysTenantUser::getUserId, userId)
.eq(com.imeeting.entity.SysTenantUser::getTenantId, role.getTenantId())
) > 0;
if (!hasMembership) {
return ApiResponse.error("用户不属于角色所在租户:" + role.getTenantId());
}
toInsertUserIds.add(userId);
}
for (Long userId : toInsertUserIds) {
SysUserRole ur = new SysUserRole();
ur.setTenantId(role.getTenantId());
ur.setRoleId(id);
ur.setUserId(userId);
sysUserRoleMapper.insert(ur);
authVersionService.invalidateUserTenantAuth(userId, role.getTenantId());
}
return ApiResponse.ok(true);
}
@DeleteMapping("/{id}/users/{userId}")
@PreAuthorize("@ss.hasPermi('sys:role:update')")
@Log(value = "角色取消关联用户", type = "角色管理")
@Transactional(rollbackFor = Exception.class)
public ApiResponse<Boolean> unbindUser(@PathVariable Long id, @PathVariable Long userId) {
SysRole role = sysRoleService.getById(id);
if (role == null || role.getRoleId() == null || role.getTenantId() == null) {
return ApiResponse.error("角色不存在");
}
if (!canAccessTenant(role.getTenantId())) {
return ApiResponse.error("禁止跨租户解绑用户");
}
sysUserRoleMapper.physicalDelete(id, userId, role.getTenantId());
authVersionService.invalidateUserTenantAuth(userId, role.getTenantId());
return ApiResponse.ok(true);
}
private Long getCurrentUserId() {
org.springframework.security.core.Authentication authentication = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof com.imeeting.security.LoginUser) {
return ((com.imeeting.security.LoginUser) authentication.getPrincipal()).getUserId();
}
return null;
}
private Long getCurrentTenantId() {
org.springframework.security.core.Authentication authentication = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof com.imeeting.security.LoginUser) {
return ((com.imeeting.security.LoginUser) authentication.getPrincipal()).getTenantId();
}
return null;
}
private boolean canAccessTenant(Long targetTenantId) {
if (targetTenantId == null) {
return false;
}
if (authScopeService.isCurrentPlatformAdmin()) {
return true;
}
Long currentTenantId = getCurrentTenantId();
return currentTenantId != null && currentTenantId.equals(targetTenantId);
}
public static class UserBindingPayload {
private List<Long> userIds;
public List<Long> getUserIds() { return userIds; }
public void setUserIds(List<Long> userIds) { this.userIds = userIds; }
}
public static class PermissionBindingPayload {
private List<Long> permIds;
public List<Long> getPermIds() {
return permIds;
}
public void setPermIds(List<Long> permIds) {
this.permIds = permIds;
}
}
}

View File

@ -1,87 +0,0 @@
package com.imeeting.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.imeeting.common.ApiResponse;
import com.imeeting.entity.SysLog;
import com.imeeting.security.LoginUser;
import com.imeeting.service.SysLogService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/logs")
public class SysLogController {
private final SysLogService sysLogService;
public SysLogController(SysLogService sysLogService) {
this.sysLogService = sysLogService;
}
@GetMapping
@PreAuthorize("@ss.hasPermi('sys_log:list')")
public ApiResponse<IPage<SysLog>> list(
@RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String username,
@RequestParam(required = false) String logType,
@RequestParam(required = false) String operation,
@RequestParam(required = false) Integer status,
@RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder
) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
LoginUser loginUser = (LoginUser) auth.getPrincipal();
// 判定平台管理员: isPlatformAdmin=true 且 tenantId=0
boolean isPlatformAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && Long.valueOf(0).equals(loginUser.getTenantId());
QueryWrapper<SysLog> query = new QueryWrapper<>();
// 只有联表查询才需要前缀 'l.'
String prefix = isPlatformAdmin ? "l." : "";
if (logType != null && !logType.isEmpty()) {
query.eq(prefix + "log_type", logType);
}
if (username != null && !username.isEmpty()) {
query.like(prefix + "username", username);
}
if (operation != null && !operation.isEmpty()) {
query.like(prefix + "operation", operation);
}
if (status != null) {
query.eq(prefix + "status", status);
}
if (startDate != null && !startDate.isEmpty()) {
query.ge(prefix + "created_at", startDate + " 00:00:00");
}
if (endDate != null && !endDate.isEmpty()) {
query.le(prefix + "created_at", endDate + " 23:59:59");
}
// 动态排序逻辑
if (sortField != null && !sortField.isEmpty()) {
String column = "created_at";
if ("duration".equals(sortField)) column = "duration";
if ("ascend".equals(sortOrder)) {
query.orderByAsc(prefix + column);
} else {
query.orderByDesc(prefix + column);
}
} else {
query.orderByDesc(prefix + "created_at");
}
if (isPlatformAdmin) {
return ApiResponse.ok(sysLogService.selectPageWithTenant(new Page<>(current, size), query));
} else {
return ApiResponse.ok(sysLogService.page(new Page<>(current, size), query));
}
}
}

View File

@ -1,60 +0,0 @@
package com.imeeting.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.common.ApiResponse;
import com.imeeting.common.annotation.Log;
import com.imeeting.entity.SysOrg;
import com.imeeting.service.SysOrgService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/orgs")
public class SysOrgController {
private final SysOrgService sysOrgService;
public SysOrgController(SysOrgService sysOrgService) {
this.sysOrgService = sysOrgService;
}
@GetMapping
@PreAuthorize("@ss.hasPermi('sys:org:list')")
public ApiResponse<List<SysOrg>> list(@RequestParam(required = false) Long tenantId) {
return ApiResponse.ok(sysOrgService.listTree(tenantId));
}
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys:org:query')")
public ApiResponse<SysOrg> get(@PathVariable Long id) {
return ApiResponse.ok(sysOrgService.getById(id));
}
@PostMapping
@PreAuthorize("@ss.hasPermi('sys:org:create')")
@Log(value = "新增组织", type = "组织管理")
public ApiResponse<Boolean> create(@RequestBody SysOrg org) {
return ApiResponse.ok(sysOrgService.save(org));
}
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys:org:update')")
@Log(value = "修改组织", type = "组织管理")
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysOrg org) {
org.setId(id);
return ApiResponse.ok(sysOrgService.updateById(org));
}
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys:org:delete')")
@Log(value = "删除组织", type = "组织管理")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
// Check if has children
long count = sysOrgService.count(new LambdaQueryWrapper<SysOrg>().eq(SysOrg::getParentId, id));
if (count > 0) {
return ApiResponse.error("存在下级组织,无法删除");
}
return ApiResponse.ok(sysOrgService.removeById(id));
}
}

View File

@ -1,94 +0,0 @@
package com.imeeting.controller;
import com.imeeting.common.ApiResponse;
import com.imeeting.common.PageResult;
import com.imeeting.dto.SysParamQueryDTO;
import com.imeeting.dto.SysParamVO;
import com.imeeting.entity.SysParam;
import com.imeeting.service.SysParamService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/params")
public class SysParamController {
private final SysParamService sysParamService;
public SysParamController(SysParamService sysParamService) {
this.sysParamService = sysParamService;
}
@GetMapping("/page")
@PreAuthorize("@ss.hasPermi('sys_param:list')")
public ApiResponse<PageResult<List<SysParamVO>>> page(SysParamQueryDTO query) {
return ApiResponse.ok(sysParamService.page(query));
}
@GetMapping
@PreAuthorize("@ss.hasPermi('sys_param:list')")
public ApiResponse<List<SysParamVO>> list() {
return ApiResponse.ok(sysParamService.list().stream().map(this::toVO).collect(Collectors.toList()));
}
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys_param:query')")
public ApiResponse<SysParamVO> get(@PathVariable Long id) {
return ApiResponse.ok(toVO(sysParamService.getById(id)));
}
@PostMapping
@PreAuthorize("@ss.hasPermi('sys_param:create')")
public ApiResponse<Boolean> create(@RequestBody SysParam param) {
boolean saved = sysParamService.save(param);
if (saved) {
sysParamService.syncParamToCache(param);
}
return ApiResponse.ok(saved);
}
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys_param:update')")
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysParam param) {
param.setParamId(id);
boolean updated = sysParamService.updateById(param);
if (updated) {
sysParamService.syncParamToCache(param);
}
return ApiResponse.ok(updated);
}
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys_param:delete')")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
SysParam param = sysParamService.getById(id);
boolean removed = sysParamService.removeById(id);
if (removed && param != null) {
sysParamService.deleteParamCache(param.getParamKey());
}
return ApiResponse.ok(removed);
}
@GetMapping("/value")
public ApiResponse<String> getValue(@RequestParam("key") String key,
@RequestParam(value = "defaultValue", required = false) String defaultValue) {
return ApiResponse.ok(sysParamService.getCachedParamValue(key, defaultValue));
}
private SysParamVO toVO(SysParam entity) {
if (entity == null) return null;
SysParamVO vo = new SysParamVO();
vo.setParamId(entity.getParamId());
vo.setParamKey(entity.getParamKey());
vo.setParamValue(entity.getParamValue());
vo.setParamType(entity.getParamType());
vo.setIsSystem(entity.getIsSystem());
vo.setDescription(entity.getDescription());
vo.setStatus(entity.getStatus());
vo.setCreatedAt(entity.getCreatedAt());
vo.setUpdatedAt(entity.getUpdatedAt());
return vo;
}
}

View File

@ -1,69 +0,0 @@
package com.imeeting.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.imeeting.common.ApiResponse;
import com.imeeting.common.annotation.Log;
import com.imeeting.entity.SysTenant;
import com.imeeting.service.SysTenantService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/tenants")
public class SysTenantController {
private final SysTenantService sysTenantService;
public SysTenantController(SysTenantService sysTenantService) {
this.sysTenantService = sysTenantService;
}
@GetMapping
@PreAuthorize("@ss.hasPermi('sys_tenant:list')")
public ApiResponse<Page<SysTenant>> list(
@RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String name,
@RequestParam(required = false) String code
) {
LambdaQueryWrapper<SysTenant> query = new LambdaQueryWrapper<>();
if (name != null && !name.isEmpty()) {
query.like(SysTenant::getTenantName, name);
}
if (code != null && !code.isEmpty()) {
query.like(SysTenant::getTenantCode, code);
}
query.orderByDesc(SysTenant::getCreatedAt);
return ApiResponse.ok(sysTenantService.page(new Page<>(current, size), query));
}
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys_tenant:query')")
public ApiResponse<SysTenant> get(@PathVariable Long id) {
return ApiResponse.ok(sysTenantService.getById(id));
}
@PostMapping
@PreAuthorize("@ss.hasPermi('sys_tenant:create')")
@Log(value = "新增租户", type = "租户管理")
public ApiResponse<Long> create(@RequestBody com.imeeting.dto.CreateTenantDTO tenantDto) {
return ApiResponse.ok(sysTenantService.createTenantWithAdmin(tenantDto));
}
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys_tenant:update')")
@Log(value = "修改租户", type = "租户管理")
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysTenant tenant) {
tenant.setId(id);
return ApiResponse.ok(sysTenantService.updateById(tenant));
}
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys_tenant:delete')")
@Log(value = "删除租户", type = "租户管理")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
return ApiResponse.ok(sysTenantService.removeById(id));
}
}

View File

@ -1,371 +0,0 @@
package com.imeeting.controller;
import com.imeeting.common.ApiResponse;
import com.imeeting.dto.PasswordUpdateDTO;
import com.imeeting.dto.UserProfile;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.security.LoginUser;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.imeeting.entity.SysUser;
import com.imeeting.entity.SysUserRole;
import com.imeeting.mapper.SysUserRoleMapper;
import com.imeeting.service.AuthScopeService;
import com.imeeting.service.AuthVersionService;
import com.imeeting.service.SysUserService;
import com.imeeting.common.annotation.Log;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final SysUserService sysUserService;
private final PasswordEncoder passwordEncoder;
private final SysUserRoleMapper sysUserRoleMapper;
private final com.imeeting.service.SysTenantUserService sysTenantUserService;
private final com.imeeting.service.SysRoleService sysRoleService;
private final AuthScopeService authScopeService;
private final AuthVersionService authVersionService;
public UserController(SysUserService sysUserService, PasswordEncoder passwordEncoder,
SysUserRoleMapper sysUserRoleMapper,
com.imeeting.service.SysTenantUserService sysTenantUserService,
com.imeeting.service.SysRoleService sysRoleService,
AuthScopeService authScopeService,
AuthVersionService authVersionService) {
this.sysUserService = sysUserService;
this.passwordEncoder = passwordEncoder;
this.sysUserRoleMapper = sysUserRoleMapper;
this.sysTenantUserService = sysTenantUserService;
this.sysRoleService = sysRoleService;
this.authScopeService = authScopeService;
this.authVersionService = authVersionService;
}
@GetMapping
@PreAuthorize("@ss.hasPermi('sys:user:list')")
public ApiResponse<List<SysUser>> list(@RequestParam(required = false) Long tenantId, @RequestParam(required = false) Long orgId) {
Long currentTenantId = getCurrentTenantId();
List<SysUser> users;
Long targetTenantId = null;
if (Long.valueOf(0).equals(currentTenantId) && tenantId == null) {
users = sysUserService.list();
} else {
targetTenantId = tenantId != null ? tenantId : currentTenantId;
if (targetTenantId == null) {
return ApiResponse.error("Tenant ID required");
}
users = sysUserService.listUsersByTenant(targetTenantId, orgId);
}
if (users != null && !users.isEmpty()) {
for (SysUser user : users) {
// 加载租户关系
user.setMemberships(sysTenantUserService.listByUserId(user.getUserId()));
// 加载角色信息
QueryWrapper<SysUserRole> roleQuery = new QueryWrapper<SysUserRole>().eq("user_id", user.getUserId());
if (targetTenantId != null) {
roleQuery.eq("tenant_id", targetTenantId);
}
List<SysUserRole> userRoles = sysUserRoleMapper.selectList(roleQuery);
if (userRoles != null && !userRoles.isEmpty()) {
List<Long> roleIds = userRoles.stream()
.map(SysUserRole::getRoleId)
.collect(java.util.stream.Collectors.toList());
user.setRoles(sysRoleService.listByIds(roleIds));
}
}
}
return ApiResponse.ok(users);
}
@GetMapping("/me")
public ApiResponse<UserProfile> me() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser)) {
return ApiResponse.error("Unauthorized");
}
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
Long userId = loginUser.getUserId();
SysUser user = sysUserService.getByIdIgnoreTenant(userId);
if (user == null) {
return ApiResponse.error("User not found");
}
UserProfile profile = new UserProfile();
profile.setUserId(user.getUserId());
profile.setUsername(user.getUsername());
profile.setDisplayName(user.getDisplayName());
profile.setEmail(user.getEmail());
profile.setPhone(user.getPhone());
profile.setStatus(user.getStatus());
profile.setAdmin(userId == 1L);
profile.setIsPlatformAdmin(user.getIsPlatformAdmin());
profile.setIsTenantAdmin(loginUser.getIsTenantAdmin());
profile.setPwdResetRequired(user.getPwdResetRequired());
return ApiResponse.ok(profile);
}
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys:user:query')")
public ApiResponse<SysUser> get(@PathVariable Long id) {
Long currentTenantId = getCurrentTenantId();
if (currentTenantId == null) {
return ApiResponse.error("Tenant ID required");
}
if (!authScopeService.isCurrentPlatformAdmin() && !isUserInTenant(id, currentTenantId)) {
return ApiResponse.error("禁止跨租户查看用户");
}
SysUser user = sysUserService.getByIdIgnoreTenant(id);
if (user != null) {
user.setMemberships(sysTenantUserService.listByUserId(id));
}
return ApiResponse.ok(user);
}
private Long getCurrentTenantId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof LoginUser) {
return ((LoginUser) auth.getPrincipal()).getTenantId();
}
return null;
}
@PostMapping
@PreAuthorize("@ss.hasPermi('sys:user:create')")
@Log(value = "新增用户", type = "用户管理")
public ApiResponse<Boolean> create(@RequestBody SysUser user) {
Long currentTenantId = getCurrentTenantId();
if (currentTenantId == null) {
return ApiResponse.error("Tenant ID required");
}
// 非平台管理员强制设置为当前租户
if (!Long.valueOf(0).equals(currentTenantId)) {
if (user.getMemberships() != null && !user.getMemberships().isEmpty()) {
user.getMemberships().forEach(m -> m.setTenantId(currentTenantId));
} else {
// 如果没传身份,补齐当前租户身份
List<com.imeeting.entity.SysTenantUser> memberships = new java.util.ArrayList<>();
com.imeeting.entity.SysTenantUser m = new com.imeeting.entity.SysTenantUser();
m.setTenantId(currentTenantId);
memberships.add(m);
user.setMemberships(memberships);
}
}
if (user.getPasswordHash() != null && !user.getPasswordHash().isEmpty()) {
user.setPasswordHash(passwordEncoder.encode(user.getPasswordHash()));
}
boolean saved = sysUserService.save(user);
if (saved) {
sysTenantUserService.syncMemberships(user.getUserId(), user.getMemberships());
}
return ApiResponse.ok(saved);
}
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys:user:update')")
@Log(value = "修改用户", type = "用户管理")
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysUser user) {
Long currentTenantId = getCurrentTenantId();
if (currentTenantId == null) {
return ApiResponse.error("Tenant ID required");
}
user.setUserId(id);
if (!authScopeService.isCurrentPlatformAdmin() && !isUserInTenant(id, currentTenantId)) {
return ApiResponse.error("禁止跨租户修改用户");
}
// 非平台管理员强制约束租户身份
if (!Long.valueOf(0).equals(currentTenantId)) {
if (user.getMemberships() != null) {
user.getMemberships().forEach(m -> m.setTenantId(currentTenantId));
}
}
if (user.getPasswordHash() != null && !user.getPasswordHash().isEmpty()) {
user.setPasswordHash(passwordEncoder.encode(user.getPasswordHash()));
}
boolean updated = sysUserService.updateById(user);
if (updated) {
sysTenantUserService.syncMemberships(id, user.getMemberships());
}
return ApiResponse.ok(updated);
}
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys:user:delete')")
@Log(value = "删除用户", type = "用户管理")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
Long currentTenantId = getCurrentTenantId();
if (currentTenantId == null) {
return ApiResponse.error("Tenant ID required");
}
if (!authScopeService.isCurrentPlatformAdmin() && !isUserInTenant(id, currentTenantId)) {
return ApiResponse.error("禁止跨租户删除用户");
}
return ApiResponse.ok(sysUserService.removeById(id));
}
@PutMapping("/profile")
public ApiResponse<Boolean> updateProfile(@RequestBody SysUser user) {
Long userId = getCurrentUserId();
SysUser existing = sysUserService.getByIdIgnoreTenant(userId);
if (existing == null) return ApiResponse.error("用户不存在");
existing.setDisplayName(user.getDisplayName());
existing.setEmail(user.getEmail());
existing.setPhone(user.getPhone());
return ApiResponse.ok(sysUserService.updateById(existing));
}
@PutMapping("/password")
public ApiResponse<Boolean> updatePassword(@RequestBody PasswordUpdateDTO dto) {
Long userId = getCurrentUserId();
SysUser user = sysUserService.getByIdIgnoreTenant(userId);
if (user == null) return ApiResponse.error("用户不存在");
if (!passwordEncoder.matches(dto.getOldPassword(), user.getPasswordHash())) {
return ApiResponse.error("旧密码不正确");
}
user.setPasswordHash(passwordEncoder.encode(dto.getNewPassword()));
user.setPwdResetRequired(0); // 重置标志位
return ApiResponse.ok(sysUserService.updateById(user));
}
private Long getCurrentUserId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof LoginUser) {
return ((LoginUser) auth.getPrincipal()).getUserId();
}
return null;
}
@GetMapping("/{id}/roles")
@PreAuthorize("@ss.hasPermi('sys:user:role:list')")
public ApiResponse<List<Long>> listUserRoles(@PathVariable Long id) {
Long currentTenantId = getCurrentTenantId();
if (currentTenantId == null) {
return ApiResponse.error("Tenant ID required");
}
if (!authScopeService.isCurrentPlatformAdmin() && !isUserInTenant(id, currentTenantId)) {
return ApiResponse.error("禁止跨租户查看用户角色");
}
QueryWrapper<SysUserRole> query = new QueryWrapper<SysUserRole>().eq("user_id", id);
if (!authScopeService.isCurrentPlatformAdmin()) {
query.eq("tenant_id", currentTenantId);
}
List<SysUserRole> rows = sysUserRoleMapper.selectList(query);
List<Long> roleIds = new ArrayList<>();
for (SysUserRole row : rows) {
if (row.getRoleId() != null) {
roleIds.add(row.getRoleId());
}
}
return ApiResponse.ok(roleIds);
}
@PostMapping("/{id}/roles")
@PreAuthorize("@ss.hasPermi('sys:user:role:save')")
@Transactional(rollbackFor = Exception.class)
public ApiResponse<Boolean> saveUserRoles(@PathVariable Long id, @RequestBody RoleBindingPayload payload) {
Long currentTenantId = getCurrentTenantId();
if (currentTenantId == null) {
return ApiResponse.error("Tenant ID required");
}
if (!authScopeService.isCurrentPlatformAdmin() && !isUserInTenant(id, currentTenantId)) {
return ApiResponse.error("禁止跨租户分配角色");
}
List<Long> roleIds = payload == null ? null : payload.getRoleIds();
List<com.imeeting.entity.SysRole> rolesToBind = new ArrayList<>();
if (roleIds != null) {
for (Long roleId : roleIds) {
if (roleId == null) {
continue;
}
com.imeeting.entity.SysRole role = sysRoleService.getById(roleId);
if (role == null || role.getRoleId() == null || role.getTenantId() == null) {
return ApiResponse.error("角色不存在:" + roleId);
}
Long roleTenantId = role.getTenantId();
if (!authScopeService.isCurrentPlatformAdmin() && !currentTenantId.equals(roleTenantId)) {
return ApiResponse.error("禁止跨租户分配角色:" + roleId);
}
boolean hasMembership = sysTenantUserService.count(
new LambdaQueryWrapper<com.imeeting.entity.SysTenantUser>()
.eq(com.imeeting.entity.SysTenantUser::getUserId, id)
.eq(com.imeeting.entity.SysTenantUser::getTenantId, roleTenantId)
) > 0;
if (!hasMembership) {
return ApiResponse.error("用户不属于角色所在租户:" + roleTenantId);
}
rolesToBind.add(role);
}
}
QueryWrapper<SysUserRole> scopeQuery = new QueryWrapper<SysUserRole>().eq("user_id", id);
if (!authScopeService.isCurrentPlatformAdmin()) {
scopeQuery.eq("tenant_id", currentTenantId);
}
List<SysUserRole> existingRows = sysUserRoleMapper.selectList(scopeQuery);
java.util.Set<Long> affectedTenantIds = new java.util.HashSet<>();
for (SysUserRole row : existingRows) {
if (row.getTenantId() != null) {
affectedTenantIds.add(row.getTenantId());
}
}
for (com.imeeting.entity.SysRole role : rolesToBind) {
if (role.getTenantId() != null) {
affectedTenantIds.add(role.getTenantId());
}
}
sysUserRoleMapper.delete(scopeQuery);
for (com.imeeting.entity.SysRole role : rolesToBind) {
SysUserRole item = new SysUserRole();
item.setTenantId(role.getTenantId());
item.setUserId(id);
item.setRoleId(role.getRoleId());
sysUserRoleMapper.insert(item);
}
for (Long tenantId : affectedTenantIds) {
authVersionService.invalidateUserTenantAuth(id, tenantId);
}
return ApiResponse.ok(true);
}
private boolean isUserInTenant(Long userId, Long tenantId) {
if (userId == null || tenantId == null) {
return false;
}
return sysTenantUserService.count(
new LambdaQueryWrapper<com.imeeting.entity.SysTenantUser>()
.eq(com.imeeting.entity.SysTenantUser::getUserId, userId)
.eq(com.imeeting.entity.SysTenantUser::getTenantId, tenantId)
) > 0;
}
public static class RoleBindingPayload {
private List<Long> roleIds;
public List<Long> getRoleIds() {
return roleIds;
}
public void setRoleIds(List<Long> roleIds) {
this.roleIds = roleIds;
}
}
}

View File

@ -0,0 +1,143 @@
package com.imeeting.controller.android;
import com.imeeting.service.android.AndroidDeviceBindingService;
import com.imeeting.service.android.AndroidDeviceRegistrationService;
import com.imeeting.support.AndroidRequestLogHelper;
import com.unisbase.auth.JwtTokenProvider;
import com.unisbase.common.ApiResponse;
import com.unisbase.common.exception.UnAuthException;
import com.unisbase.dto.LoginRequest;
import com.unisbase.dto.RefreshRequest;
import com.unisbase.dto.TokenResponse;
import com.unisbase.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "Android认证接口")
@RestController
@RequestMapping("/api/android/auth")
@RequiredArgsConstructor
@Slf4j
public class AndroidAuthController {
private final AuthService authService;
private final AndroidDeviceBindingService androidDeviceBindingService;
private final AndroidDeviceRegistrationService androidDeviceRegistrationService;
private final JwtTokenProvider jwtTokenProvider;
@Operation(summary = "Android登录")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回登录成功后的访问令牌、刷新令牌和当前用户信息",
content = @Content(schema = @Schema(implementation = TokenResponse.class))
)
})
@PostMapping("/login")
public ApiResponse<TokenResponse> login(@Valid @RequestBody LoginRequest request,
@RequestHeader(value = "X-Android-Device-Id", required = false) String deviceId,
@RequestHeader(value = "X-Android-App-Version", required = false) String appVersion,
@RequestHeader(value = "X-Android-Platform", required = false) String platform) {
AndroidRequestLogHelper.logRequest(log, "Android认证", "登录接口",
"request", request,
"deviceId", deviceId,
"appVersion", appVersion,
"platform", platform);
if (!StringUtils.hasText(deviceId)) {
throw new IllegalArgumentException("X-Android-Device-Id不能为空");
}
androidDeviceRegistrationService.requireRegistered(deviceId.trim());
TokenResponse response = authService.login(request, true);
if (response != null && response.getUser() != null && response.getCurrentTenantId() != null && StringUtils.hasText(deviceId)) {
androidDeviceBindingService.recordLogin(
deviceId.trim(),
response.getCurrentTenantId(),
response.getUser().getUserId()
);
}
return ApiResponse.ok(response);
}
@Operation(summary = "Android刷新令牌")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回刷新后的访问令牌、刷新令牌和当前用户信息",
content = @Content(schema = @Schema(implementation = TokenResponse.class))
)
})
@PostMapping("/refresh")
public ApiResponse<TokenResponse> refresh(@RequestBody(required = false) RefreshRequest request,
@RequestHeader(value = "Authorization", required = false) String authorization,
@RequestHeader(value = "X-Android-Access-Token", required = false) String androidAccessToken) {
AndroidRequestLogHelper.logRequest(log, "Android认证", "刷新令牌接口",
"request", request,
"authorization", authorization,
"androidAccessToken", androidAccessToken);
TokenResponse refresh = null;
try {
refresh = authService.refresh(resolveRefreshToken(request, authorization, androidAccessToken));
} catch (Exception e) {
throw new IllegalArgumentException("刷新令牌已失效,请重新登录");
}
return ApiResponse.ok(refresh);
}
@Operation(summary = "Android退出登录")
@PostMapping("/logout")
public ApiResponse<Void> logout(HttpServletRequest request,
@RequestHeader(value = "Authorization", required = false) String authorization,
@RequestHeader(value = "X-Android-Device-Id", required = false) String deviceId) {
AndroidRequestLogHelper.logRequest(log, "Android认证", "退出登录接口",
"authorization", authorization,
"deviceId", deviceId);
String token = extractToken(authorization);
var claims = jwtTokenProvider.parseToken(token);
Long userId = claims.get("userId", Long.class);
Long tenantId = claims.get("tenantId", Long.class);
String sessionId = claims.get("sessionId", String.class);
authService.logout(userId, tenantId, sessionId);
if (StringUtils.hasText(deviceId)) {
androidDeviceBindingService.unbindPrivateDevice(deviceId.trim());
}
return ApiResponse.ok(null);
}
private String resolveRefreshToken(RefreshRequest request, String authorization, String androidAccessToken) {
if (request != null && StringUtils.hasText(request.getRefreshToken())) {
return request.getRefreshToken().trim();
}
if (StringUtils.hasText(androidAccessToken)) {
return androidAccessToken.trim();
}
if (StringUtils.hasText(authorization)) {
String value = authorization.trim();
if (value.startsWith("Bearer ")) {
return value.substring(7).trim();
}
return value;
}
throw new IllegalArgumentException("refreshToken不能为空");
}
private String extractToken(String authorization) {
if (!StringUtils.hasText(authorization)) {
throw new IllegalArgumentException("Authorization不能为空");
}
String value = authorization.trim();
return value.startsWith("Bearer ") ? value.substring(7).trim() : value;
}
}

View File

@ -0,0 +1,76 @@
package com.imeeting.controller.android;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.support.AndroidRequestLogHelper;
import com.imeeting.entity.biz.ClientDownload;
import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.biz.ClientDownloadService;
import com.unisbase.annotation.Anonymous;
import com.unisbase.common.ApiResponse;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "Android客户端接口")
@RestController
@RequestMapping("/api/android/clients")
@RequiredArgsConstructor
@Slf4j
public class AndroidClientController {
private final AndroidAuthService androidAuthService;
private final ClientDownloadService clientDownloadService;
@Operation(summary = "查询平台最新客户端")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回指定平台当前生效的最新客户端安装包信息",
content = @Content(schema = @Schema(implementation = ClientDownload.class))
)
})
@GetMapping("/latest/by-platform")
@Anonymous
public ApiResponse<ClientDownload> latestByPlatform(HttpServletRequest request,
@RequestParam(value = "platform_code", required = false) String platformCode,
@RequestParam(value = "platform_type", required = false) String platformType,
@RequestParam(value = "platform_name", required = false) String platformName) {
AndroidRequestLogHelper.logRequest(log, "Android客户端", "查询平台最新客户端接口",
"platformCode", platformCode,
"platformType", platformType,
"platformName", platformName);
androidAuthService.authenticateHttp(request);
if ((platformCode == null || platformCode.isBlank())
&& ((platformType == null || platformType.isBlank()) || (platformName == null || platformName.isBlank()))) {
return ApiResponse.error("请提供 platform_code 参数");
}
LambdaQueryWrapper<ClientDownload> wrapper = new LambdaQueryWrapper<ClientDownload>()
.eq(ClientDownload::getStatus, 1)
.eq(ClientDownload::getIsLatest, 1);
if (platformCode != null && !platformCode.isBlank()) {
wrapper.apply("LOWER(platform_code) = {0}", platformCode.trim().toLowerCase());
} else {
wrapper.apply("LOWER(platform_type) = {0}", platformType.trim().toLowerCase())
.apply("LOWER(platform_name) = {0}", platformName.trim().toLowerCase());
}
wrapper.orderByDesc(ClientDownload::getVersionCode)
.orderByDesc(ClientDownload::getId)
.last("LIMIT 1");
ClientDownload clientDownload = clientDownloadService.getOne(wrapper);
if (clientDownload == null) {
return ApiResponse.error("暂无最新客户端");
}
return ApiResponse.ok(clientDownload);
}
}

View File

@ -0,0 +1,122 @@
package com.imeeting.controller.android;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidDeviceHomeStatsVO;
import com.imeeting.dto.android.AndroidDeviceRegisterRequest;
import com.imeeting.dto.android.AndroidDeviceRegisterResponse;
import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.android.AndroidDeviceService;
import com.imeeting.service.android.AndroidDeviceRegistrationService;
import com.imeeting.support.AndroidRequestLogHelper;
import com.unisbase.annotation.Anonymous;
import com.unisbase.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "Android设备接口")
@RestController
@RequestMapping("/api/android/devices")
@RequiredArgsConstructor
@Slf4j
public class AndroidDeviceController {
private static final String TENANT_CODE_HEADER = "X-Tenant-Code";
private final AndroidAuthService androidAuthService;
private final AndroidDeviceRegistrationService androidDeviceRegistrationService;
private final AndroidDeviceService androidDeviceService;
@Operation(summary = "设备自注册")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回设备注册后的基础信息",
content = @Content(schema = @Schema(implementation = AndroidDeviceRegisterResponse.class))
)
})
@PostMapping("/register")
@Anonymous
public ApiResponse<AndroidDeviceRegisterResponse> register(HttpServletRequest request,
@RequestBody(required = false) AndroidDeviceRegisterRequest command) {
if (command == null) {
throw new IllegalArgumentException("注册请求不能为空");
}
String tenantCode = resolveTenantCode(request, command);
AndroidRequestLogHelper.logRequest(log, "Android设备", "设备自注册", "request", command, "tenantCode", tenantCode);
AndroidAuthContext authContext = androidAuthService.authenticateHttpIgnoreToken(request, false);
AndroidDeviceRegisterResponse response = androidDeviceRegistrationService.register(
tenantCode,
authContext.getDeviceId(),
command.getDeviceName(),
command.getTerminalType() == null ? authContext.getPlatform() : command.getTerminalType(),
command.getTerminalVersion() == null ? authContext.getAppVersion() : command.getTerminalVersion()
);
return ApiResponse.ok(response);
}
@Operation(summary = "更新设备")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "更新设备信息",
content = @Content(schema = @Schema(implementation = AndroidDeviceRegisterResponse.class))
)
})
@PostMapping("/update")
@Anonymous
public ApiResponse update(HttpServletRequest request,
@RequestBody(required = false) AndroidDeviceRegisterRequest command) {
if (command == null) {
throw new IllegalArgumentException("更新设备请求参数不能为空");
}
String tenantCode = request == null ? null : request.getHeader(TENANT_CODE_HEADER);
AndroidRequestLogHelper.logRequest(log, "Android设备", "更新设备", "request", command, "tenantCode", tenantCode);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request, false);
androidDeviceService.updateDevice(
tenantCode,
authContext.getDeviceId(),
command.getDeviceName(),
command.getTerminalType() == null ? authContext.getPlatform() : command.getTerminalType(),
command.getTerminalVersion() == null ? authContext.getAppVersion() : command.getTerminalVersion()
);
return ApiResponse.ok(null);
}
@Operation(summary = "查询设备首页统计")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回设备首页统计信息",
content = @Content(schema = @Schema(implementation = AndroidDeviceHomeStatsVO.class))
)
})
@GetMapping("/home")
@Anonymous
public ApiResponse<AndroidDeviceHomeStatsVO> home(HttpServletRequest request) {
AndroidRequestLogHelper.logRequest(log, "Android设备", "查询设备首页统计");
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request, true, true);
return ApiResponse.ok(androidDeviceService.getHomeStats(authContext));
}
private String resolveTenantCode(HttpServletRequest request, AndroidDeviceRegisterRequest command) {
if (command != null && StringUtils.hasText(command.getTenantCode())) {
return command.getTenantCode().trim();
}
String tenantCodeFromHeader = request == null ? null : request.getHeader(TENANT_CODE_HEADER);
if (StringUtils.hasText(tenantCodeFromHeader)) {
return tenantCodeFromHeader.trim();
}
throw new IllegalArgumentException("tenantCode不能为空");
}
}

View File

@ -0,0 +1,54 @@
package com.imeeting.controller.android;
import com.imeeting.support.AndroidRequestLogHelper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.entity.biz.ExternalApp;
import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.biz.ExternalAppService;
import com.unisbase.common.ApiResponse;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "Android外部应用接口")
@RestController
@RequestMapping("/api/android/external-apps")
@RequiredArgsConstructor
@Slf4j
public class AndroidExternalAppController {
private final AndroidAuthService androidAuthService;
private final ExternalAppService externalAppService;
@Operation(summary = "查询启用的外部应用")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回当前启用的外部应用列表",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ExternalApp.class)))
)
})
@GetMapping("/active")
public ApiResponse<List<ExternalApp>> active(HttpServletRequest request,
@RequestParam(value = "is_active", required = false) Integer ignoredIsActive) {
AndroidRequestLogHelper.logRequest(log, "Android外部应用", "查询启用外部应用接口", "isActive", ignoredIsActive);
androidAuthService.authenticateHttp(request);
List<ExternalApp> apps = externalAppService.list(new LambdaQueryWrapper<ExternalApp>()
.eq(ExternalApp::getStatus, 1)
.orderByAsc(ExternalApp::getSortOrder)
.orderByDesc(ExternalApp::getCreatedAt));
return ApiResponse.ok(apps);
}
}

View File

@ -0,0 +1,57 @@
package com.imeeting.controller.android;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.biz.AiModelService;
import com.imeeting.support.AndroidRequestLogHelper;
import com.unisbase.common.ApiResponse;
import com.unisbase.dto.PageResult;
import com.unisbase.security.LoginUser;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "Android模型接口")
@RestController
@RequestMapping("/api/android/llm-models")
@RequiredArgsConstructor
@Slf4j
public class AndroidLlmModelController {
private final AndroidAuthService androidAuthService;
private final AiModelService aiModelService;
@Operation(summary = "查询启用的大模型列表")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回当前租户下启用的大语言模型列表",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = AiModelVO.class)))
)
})
@GetMapping("/active")
public ApiResponse<List<AiModelVO>> activeModels(HttpServletRequest request) {
AndroidRequestLogHelper.logRequest(log, "Android模型", "查询启用大模型列表接口");
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext);
PageResult<List<AiModelVO>> result = aiModelService.pageModels(1, 1000, null, "LLM", loginUser.getTenantId(), false);
List<AiModelVO> enabledModels = result.getRecords() == null
? List.of()
: result.getRecords().stream()
.filter(item -> Integer.valueOf(1).equals(item.getStatus()))
.toList();
return ApiResponse.ok(enabledModels);
}
}

View File

@ -0,0 +1,54 @@
package com.imeeting.controller.android;
import com.imeeting.dto.android.AndroidAuthContext;
import com.unisbase.security.LoginUser;
final class AndroidLoginUserSupport {
private AndroidLoginUserSupport() {
}
static LoginUser requireLoginUser(AndroidAuthContext authContext) {
LoginUser loginUser = toLoginUser(authContext);
if (loginUser == null) {
throw new RuntimeException("Android用户未登录或认证无效");
}
return loginUser;
}
static LoginUser toLoginUser(AndroidAuthContext authContext) {
if (authContext == null || authContext.isAnonymous()
|| authContext.getUserId() == null || authContext.getTenantId() == null) {
return null;
}
LoginUser loginUser = new LoginUser(
authContext.getUserId(),
authContext.getTenantId(),
authContext.getUsername(),
authContext.getPlatformAdmin(),
authContext.getTenantAdmin(),
authContext.getPermissions()
);
loginUser.setDisplayName(authContext.getDisplayName());
return loginUser;
}
static boolean isAdmin(AndroidAuthContext authContext) {
return authContext != null
&& (Boolean.TRUE.equals(authContext.getPlatformAdmin())
|| Boolean.TRUE.equals(authContext.getTenantAdmin()));
}
static String resolveDisplayName(AndroidAuthContext authContext) {
if (authContext == null) {
return null;
}
if (authContext.getDisplayName() != null && !authContext.getDisplayName().isBlank()) {
return authContext.getDisplayName().trim();
}
if (authContext.getUsername() != null && !authContext.getUsername().isBlank()) {
return authContext.getUsername().trim();
}
return authContext.getDeviceId();
}
}

View File

@ -0,0 +1,77 @@
package com.imeeting.controller.android;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse;
import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.android.AndroidChunkUploadService;
import com.imeeting.support.AndroidRequestLogHelper;
import com.unisbase.annotation.Anonymous;
import com.unisbase.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
@Tag(name = "Android会议分片上传接口")
@RestController
@RequestMapping("/api/android/meetings/upload-audio")
@RequiredArgsConstructor
@Slf4j
public class AndroidMeetingChunkUploadController {
private final AndroidAuthService androidAuthService;
private final AndroidChunkUploadService androidChunkUploadService;
@Operation(summary = "上传会议音频分片")
@io.swagger.v3.oas.annotations.responses.ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "分片上传成功返回 true",
content = @Content(schema = @Schema(implementation = Boolean.class))
)
})
@PostMapping("/chunk")
@Anonymous
public ApiResponse<Boolean> uploadChunk(HttpServletRequest request,
@RequestParam("meeting_id") Long meetingId,
@RequestParam("chunk_index") Integer chunkIndex,
@RequestParam("chunk_file") MultipartFile chunkFile) throws IOException {
AndroidRequestLogHelper.logRequest(log, "Android会议", "上传会议音频分片",
"meetingId", meetingId,
"chunkIndex", chunkIndex,
"chunkFile", chunkFile);
AndroidAuthContext authContext = androidAuthService.authenticateHttpIgnoreToken(request,true);
androidChunkUploadService.saveChunk(meetingId, chunkIndex, chunkFile, authContext);
return ApiResponse.ok(true);
}
@Operation(summary = "完成分片上传并触发会议音频处理")
@io.swagger.v3.oas.annotations.responses.ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回上传后的会议 ID 和音频地址",
content = @Content(schema = @Schema(implementation = LegacyUploadAudioResponse.class))
)
})
@PostMapping("/complete")
@Anonymous
public ApiResponse<LegacyUploadAudioResponse> completeUpload(HttpServletRequest request,
@RequestParam("meeting_id") Long meetingId,
@RequestParam("total_chunks") Integer totalChunks) throws IOException {
AndroidRequestLogHelper.logRequest(log, "Android会议", "完成分片上传",
"meetingId", meetingId,
"totalChunks", totalChunks);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
androidChunkUploadService.completeUploadAsync(meetingId, totalChunks, authContext);
return ApiResponse.ok(new LegacyUploadAudioResponse(meetingId, null, "后台合并上传中"));
}
}

View File

@ -0,0 +1,616 @@
package com.imeeting.controller.android;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.imeeting.common.MeetingConstants;
import com.imeeting.common.SysParamKeys;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidOfflineMeetingCreateCommand;
import com.imeeting.dto.android.AndroidMeetingCreateResponse;
import com.imeeting.dto.android.AndroidMeetingConfigVo;
import com.imeeting.dto.android.QtMeetingUpdateCommand;
import com.imeeting.dto.android.AndroidMeetingListItemVO;
import com.imeeting.dto.android.AndroidOfflineMeetingConflictVO;
import com.imeeting.dto.android.AndroidOfflineMeetingFinishRequest;
import com.imeeting.dto.android.AndroidUnifiedMeetingStatusRequest;
import com.imeeting.dto.android.AndroidUnifiedMeetingStatusResponse;
import com.imeeting.dto.android.legacy.LegacyMeetingAccessPasswordRequest;
import com.imeeting.dto.android.legacy.LegacyMeetingAttendeeResponse;
import com.imeeting.dto.android.legacy.LegacyMeetingPreviewDataResponse;
import com.imeeting.dto.android.legacy.LegacyMeetingPreviewResult;
import com.imeeting.dto.android.legacy.LegacyMeetingProcessingStatusResponse;
import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse;
import com.imeeting.dto.biz.*;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.PromptTemplate;
import com.imeeting.enums.BusinessErrorCodeEnum;
import com.imeeting.enums.MeetingStatusEnum;
import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.android.AndroidChunkUploadService;
import com.imeeting.service.android.AndroidGatewayPushService;
import com.imeeting.service.android.AndroidMeetingPushService;
import com.imeeting.service.android.legacy.LegacyMeetingAdapterService;
import com.imeeting.support.AndroidRequestLogHelper;
import com.imeeting.service.biz.*;
import com.unisbase.annotation.Anonymous;
import com.unisbase.common.ApiResponse;
import com.unisbase.common.annotation.Log;
import com.unisbase.common.exception.BusinessException;
import com.unisbase.dto.PageResult;
import com.unisbase.entity.SysTenant;
import com.unisbase.entity.SysUser;
import com.unisbase.mapper.SysTenantMapper;
import com.unisbase.mapper.SysUserMapper;
import com.unisbase.security.LoginUser;
import com.unisbase.service.SysDictItemService;
import com.unisbase.service.SysParamService;
import com.unisbase.service.impl.SysDictItemServiceImpl;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.util.StopWatch;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
@Tag(name = "Android会议接口")
@RestController
@RequestMapping("/api/android/meetings")
@Slf4j
public class AndroidMeetingController {
private static final String STAGE_DATA_INITIALIZATION = "data_initialization";
private static final String STAGE_AUDIO_TRANSCRIPTION = "audio_transcription";
private static final String STAGE_SUMMARY_GENERATION = "summary_generation";
private static final String STAGE_COMPLETED = "completed";
private static final String TENANT_CODE_HEADER = "X-Tenant-Code";
@Value("${imeeting.h5.base-url:}")
private String h5BaseUrl;
private final AndroidAuthService androidAuthService;
private final AndroidMeetingPushService androidMeetingPushService;
private final AndroidChunkUploadService androidChunkUploadService;
private final LegacyMeetingAdapterService legacyMeetingAdapterService;
private final MeetingQueryService meetingQueryService;
private final MeetingAccessService meetingAccessService;
private final MeetingCommandService meetingCommandService;
private final MeetingService meetingService;
private final AiTaskService aiTaskService;
private final PromptTemplateService promptTemplateService;
private final SysTenantMapper sysTenantMapper;
private final SysUserMapper sysUserMapper;
private final AiModelService aiModelService;
private final HotWordGroupService hotWordGroupService;
private final SysDictItemService dictItemService;
private final SysParamService paramService;
private final MeetingProgressService meetingProgressService;
private final MeetingUnifiedStatusService meetingUnifiedStatusService;
@Autowired
public AndroidMeetingController(AndroidAuthService androidAuthService,
AndroidChunkUploadService androidChunkUploadService,
LegacyMeetingAdapterService legacyMeetingAdapterService,
MeetingQueryService meetingQueryService,
MeetingAccessService meetingAccessService,
MeetingCommandService meetingCommandService,
MeetingService meetingService,
AiTaskService aiTaskService,
PromptTemplateService promptTemplateService,
SysTenantMapper sysTenantMapper,
SysUserMapper sysUserMapper,
AiModelService aiModelService,
HotWordGroupService hotWordGroupService,
SysDictItemService dictItemService,
SysParamService paramService,
MeetingProgressService meetingProgressService,
AndroidMeetingPushService androidMeetingPushService,
MeetingUnifiedStatusService meetingUnifiedStatusService) {
this.androidAuthService = androidAuthService;
this.androidChunkUploadService = androidChunkUploadService;
this.legacyMeetingAdapterService = legacyMeetingAdapterService;
this.meetingQueryService = meetingQueryService;
this.meetingAccessService = meetingAccessService;
this.meetingCommandService = meetingCommandService;
this.meetingService = meetingService;
this.aiTaskService = aiTaskService;
this.promptTemplateService = promptTemplateService;
this.sysTenantMapper = sysTenantMapper;
this.sysUserMapper = sysUserMapper;
this.meetingProgressService = meetingProgressService;
this.aiModelService = aiModelService;
this.hotWordGroupService = hotWordGroupService;
this.paramService = paramService;
this.dictItemService = dictItemService;
this.meetingUnifiedStatusService = meetingUnifiedStatusService;
this.androidMeetingPushService = androidMeetingPushService;
}
@Operation(summary = "创建Android离线会议")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回新创建的会议详情",
content = @Content(schema = @Schema(implementation = AndroidMeetingCreateResponse.class))
)
})
@PostMapping("/create")
@Anonymous
@Log(value = "新增Android会议", type = "Android会议管理")
public ApiResponse<Object> create(HttpServletRequest request,
@RequestBody AndroidOfflineMeetingCreateCommand command) {
AndroidRequestLogHelper.logRequest(log, "Android会议", "创建离线会议接口", "request", command);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
resolvePublicDeviceTenantId(request, command, authContext);
LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext);
// Meeting existingMeeting = findLatestUnfinishedMeetingByDevice(authContext.getDeviceId());
// if (existingMeeting != null) {
// return new ApiResponse<>("409", "设备端已有会议", meetingQueryService.getDetailIgnoreTenant(existingMeeting.getId()));
// }
MeetingVO meeting = legacyMeetingAdapterService.createMeeting(command, authContext, loginUser);
return ApiResponse.ok(buildAndroidMeetingCreateResponse(meeting));
}
@Operation(summary = "上传Android会议音频")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回上传后的会议 ID 和音频地址",
content = @Content(schema = @Schema(implementation = LegacyUploadAudioResponse.class))
)
})
@PostMapping("/upload-audio")
@Anonymous
public ApiResponse<LegacyUploadAudioResponse> uploadAudio(HttpServletRequest request,
@RequestParam("id") Long meetingId,
@RequestParam(value = "prompt_id", required = false) Long promptId,
@RequestParam(value = "model_code", required = false) String modelCode,
@RequestParam(value = "force_replace", defaultValue = "false") boolean forceReplace,
@RequestParam("audio_file") MultipartFile audioFile) throws IOException {
AndroidRequestLogHelper.logRequest(log, "Android会议", "上传会议音频接口",
"meetingId", meetingId,
"promptId", promptId,
"modelCode", modelCode,
"forceReplace", forceReplace,
"audioFile", audioFile);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
if (authContext.isAnonymous()) {
return ApiResponse.ok(legacyMeetingAdapterService.uploadAndTriggerOfflineProcessForPublicDevice(
meetingId,
promptId,
modelCode,
forceReplace,
audioFile,
authContext
));
}
LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext);
return ApiResponse.ok(legacyMeetingAdapterService.uploadAndTriggerOfflineProcess(
meetingId,
promptId,
modelCode,
forceReplace,
audioFile,
authContext,
loginUser
));
}
@Operation(summary = "结束 Android 离线会议录音阶段")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "结束成功返回 true",
content = @Content(schema = @Schema(implementation = Boolean.class))
)
})
@PostMapping("/{meetingId}/finish")
@Anonymous
public ApiResponse<Object> finishOfflineMeeting(HttpServletRequest request,
@PathVariable Long meetingId,
@RequestBody(required = false) AndroidOfflineMeetingFinishRequest command) throws IOException {
AndroidRequestLogHelper.logRequest(log, "Android会议", "结束离线会议录音阶段",
"meetingId", meetingId,
"request", command);
AndroidAuthContext authContext = androidAuthService.authenticateHttpIgnoreToken(request, true);
LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext);
requireOperableOfflineMeeting(meetingId, authContext, loginUser);
MeetingVO meeting = meetingQueryService.getDetailIgnoreTenant(meetingId);
LegacyUploadAudioResponse uploadResult = new LegacyUploadAudioResponse();
if (isUploadFinishedStage(command)) {
androidChunkUploadService.completeUploadAsync(
meeting.getId(),
command.getTotalChunks(),
authContext
);
// if (uploadResult == null) {
// throw new RuntimeException("分片上传完成后未生成结果");
// }
uploadResult.setMeetingId(meetingId);
}
meetingCommandService.finishOfflineMeeting(meeting.getId(), command == null ? null : command.getFinishStage());
return ApiResponse.ok(uploadResult);
}
@Operation(summary = "分页查询Android会议")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回当前用户可见的会议分页结果",
content = @Content(schema = @Schema(implementation = PageResult.class))
)
})
@GetMapping
public ApiResponse<PageResult<List<AndroidMeetingListItemVO>>> list(HttpServletRequest request,
@RequestParam(value = "user_id", required = false) Long ignoredUserId,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(value = "page_size", defaultValue = "10") Integer pageSize,
@RequestParam(required = false) String title) {
AndroidRequestLogHelper.logRequest(log, "Android会议", "分页查询会议接口",
"userId", ignoredUserId,
"page", page,
"pageSize", pageSize,
"title", title);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext);
PageResult<List<MeetingVO>> result = meetingQueryService.pageMeetings(
page,
pageSize,
title,
loginUser.getTenantId(),
loginUser.getUserId(),
AndroidLoginUserSupport.resolveDisplayName(authContext),
"created",
null,
AndroidLoginUserSupport.isAdmin(authContext)
);
return ApiResponse.ok(buildAndroidMeetingListPage(result));
}
@Operation(summary = "查询Android会议统一状态")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回统一状态及可选内容",
content = @Content(schema = @Schema(implementation = AndroidUnifiedMeetingStatusResponse.class))
)
})
@PostMapping("/{meetingId}/status")
@Anonymous
public ApiResponse<AndroidUnifiedMeetingStatusResponse> getUnifiedStatus(HttpServletRequest request,
@PathVariable Long meetingId,
@RequestBody(required = false) AndroidUnifiedMeetingStatusRequest command) {
AndroidRequestLogHelper.logRequest(log, "Android会议", "查询会议统一状态",
"meetingId", meetingId,
"request", command);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext);
requireOperableOfflineMeeting(meetingId, authContext, loginUser);
MeetingVO meeting = meetingQueryService.getDetailIgnoreTenant(meetingId,false);
UnifiedMeetingStatusVO status = meetingUnifiedStatusService.resolve(meetingId);
boolean includeTranscript = Boolean.TRUE.equals(command == null ? null : command.getIncludeTranscript());
boolean includeSummary = Boolean.TRUE.equals(command == null ? null : command.getIncludeSummary());
List<MeetingTranscriptVO> transcripts = includeTranscript ? meetingQueryService.getTranscripts(meetingId) : null;
String summaryContent = includeSummary ? meeting.getSummaryContent() : null;
AndroidUnifiedMeetingStatusResponse build = AndroidUnifiedMeetingStatusResponse.builder()
.meetingId(meetingId)
.status(status)
.meeting(meeting)
.includesTranscript(includeTranscript)
.transcripts(transcripts)
.includesSummary(includeSummary)
.summaryContent(summaryContent)
.build();
log.info("[{}]{}.返回数据:[{}]", "Android会议", "查询会议统一状态", build);
return ApiResponse.ok(build);
}
@Operation(summary = "重试 Android 会议 ASR 识别")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "重试成功返回 true",
content = @Content(schema = @Schema(implementation = Boolean.class))
)
})
@PostMapping("/{meetingId}/transcripts/retry")
@Anonymous
public ApiResponse<Boolean> retryTranscription(HttpServletRequest request, @PathVariable Long meetingId) {
AndroidRequestLogHelper.logRequest(log, "Android会议", "重试会议 ASR 识别接口", "meetingId", meetingId);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext);
requireOperableOfflineMeeting(meetingId, authContext, loginUser);
meetingCommandService.retryTranscription(meetingId);
androidMeetingPushService.pushMeetingStatusChanged(meetingId, UnifiedMeetingStatusStage.TRANSCRIBING.getCode());
return ApiResponse.ok(true);
}
@Operation(summary = "重试 Android 会议总结")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "重试成功返回 true",
content = @Content(schema = @Schema(implementation = Boolean.class))
)
})
@PostMapping("/{meetingId}/summary/retry")
@Anonymous
public ApiResponse<Boolean> retrySummary(HttpServletRequest request, @PathVariable Long meetingId) {
AndroidRequestLogHelper.logRequest(log, "Android会议", "重试会议总结接口", "meetingId", meetingId);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext);
requireOperableOfflineMeeting(meetingId, authContext, loginUser);
meetingCommandService.retrySummary(meetingId);
androidMeetingPushService.pushMeetingStatusChanged(meetingId, UnifiedMeetingStatusStage.SUMMARIZING.getCode());
return ApiResponse.ok(true);
}
@Operation(summary = "更新Android会议访问密码")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回更新后的会议访问密码,传空时表示清空访问密码",
content = @Content(schema = @Schema(implementation = String.class))
)
})
@PutMapping("/{meetingId}/access-password")
@Log(value = "修改Android会议访问密码", type = "Android会议管理")
public ApiResponse<String> updateAccessPassword(HttpServletRequest request,
@PathVariable Long meetingId,
@RequestBody(required = false) LegacyMeetingAccessPasswordRequest command) {
AndroidRequestLogHelper.logRequest(log, "Android会议", "更新会议访问密码接口",
"meetingId", meetingId,
"request", command);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext);
Meeting meeting = meetingAccessService.requireMeeting(meetingId);
if (!Objects.equals(meeting.getCreatorId(), loginUser.getUserId())) {
return ApiResponse.error("仅会议创建人可设置访问密码");
}
String password = normalizePassword(command == null ? null : command.getPassword());
meetingService.update(new LambdaUpdateWrapper<Meeting>()
.eq(Meeting::getId, meeting.getId())
.set(Meeting::getAccessPassword, password));
return ApiResponse.ok(password);
}
@Operation(summary = "QT更新会议信息")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "更新会议标题、参会人和总结内容",
content = @Content(schema = @Schema(implementation = Boolean.class))
)
})
@PutMapping("/{meetingId}/info")
@Log(value = "QT修改会议信息", type = "Android会议管理")
public ApiResponse<Boolean> updateMeetingForQt(HttpServletRequest request,
@PathVariable Long meetingId,
@Valid @RequestBody QtMeetingUpdateCommand command) {
AndroidRequestLogHelper.logRequest(log, "QT会议", "修改会议信息接口", "meetingId", meetingId);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext);
Meeting meeting = meetingAccessService.requireMeeting(meetingId);
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
meetingCommandService.updateMeetingForQt(meetingId, command);
return ApiResponse.ok(true);
}
@Operation(summary = "删除Android会议")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回是否删除成功",
content = @Content(schema = @Schema(implementation = Boolean.class))
)
})
@DeleteMapping("/{meetingId}")
@Log(value = "删除Android会议", type = "Android会议管理")
public ApiResponse<Boolean> delete(HttpServletRequest request, @PathVariable Long meetingId) {
AndroidRequestLogHelper.logRequest(log, "Android会议", "删除会议接口", "meetingId", meetingId);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext);
Meeting meeting = meetingAccessService.requireMeeting(meetingId);
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
meetingCommandService.deleteMeeting(meetingId);
return ApiResponse.ok(true);
}
@GetMapping("/config")
@Log(value = "获取会议配置", type = "Android会议管理")
@Operation(summary = "获取会议配置")
@Anonymous
public ApiResponse<AndroidMeetingConfigVo> config(HttpServletRequest request) {
AndroidRequestLogHelper.logRequest(log, "Android会议", "获取会议配置接口");
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
LoginUser loginUser = AndroidLoginUserSupport.toLoginUser(authContext);
Long tenantId = loginUser != null ? loginUser.getTenantId() : authContext.getTenantId();
Long userId = loginUser != null ? loginUser.getUserId() : null;
boolean isPlatformAdmin = loginUser != null && Boolean.TRUE.equals(loginUser.getIsPlatformAdmin());
boolean isTenantAdmin = loginUser != null && Boolean.TRUE.equals(loginUser.getIsTenantAdmin());
AndroidMeetingConfigVo resultVo = new AndroidMeetingConfigVo();
PageResult<List<PromptTemplateVO>> promptTemplateList = promptTemplateService.pageTemplates(
1,
1000,
null,
null,
tenantId,
userId,
isPlatformAdmin,
isTenantAdmin
);
List<PromptTemplateVO> enabledTemplates = promptTemplateList.getRecords() == null
? List.of()
: promptTemplateList.getRecords().stream()
.filter(item -> Integer.valueOf(1).equals(item.getStatus()))
.collect(Collectors.toList());
PromptTemplate effectiveDefault = promptTemplateService.findEffectiveUserDefaultTemplate(tenantId, userId);
if (effectiveDefault != null) {
enabledTemplates.sort(Comparator.comparing(
item -> !Objects.equals(item.getId(), effectiveDefault.getId())
));
}
resultVo.setTemplateList(enabledTemplates);
PageResult<List<AiModelVO>> modelList = aiModelService.pageModels(1, 1000, null, "LLM", tenantId, false);
List<AiModelVO> enabledModels = modelList.getRecords() == null
? List.of()
: modelList.getRecords().stream()
.filter(item -> Integer.valueOf(1).equals(item.getStatus()) && Integer.valueOf(1).equals(item.getTenantEnabled()))
.toList();
resultVo.setModelsList(enabledModels);
resultVo.setHotWordGroupList(hotWordGroupService.listVisibleOptions(tenantId));
resultVo.setSummaryDegreeOfDetail(dictItemService.getItemsByTypeCode("summary_degree_detail"));
resultVo.setMaxMeetingDuration(Integer.valueOf(paramService.getParamValue(SysParamKeys.MEETING_MAX_MEETING_DURATION, "30")));
resultVo.setMinMeetingDuration(Integer.valueOf(paramService.getParamValue(SysParamKeys.MEETING_MIN_MEETING_DURATION, "10")));
resultVo.setMaxPauseDuration(Integer.valueOf(paramService.getParamValue(SysParamKeys.MEETING_MAX_PAUSE_DURATION, String.valueOf(60 * 4))));
BigDecimal bigDecimal = new BigDecimal(paramService.getParamValue(SysParamKeys.MEETING_MAX_PAUSE_DURATION, "99"));
bigDecimal = bigDecimal.setScale(2, RoundingMode.HALF_UP);
resultVo.setPacketLossRate(bigDecimal);
resultVo.setChunkUploadEnabled(Boolean.parseBoolean(paramService.getParamValue(SysParamKeys.MEETING_ANDROID_AUDIO_CHUNK_UPLOAD_ENABLED, "false")));
resultVo.setChunkDurationSeconds(Integer.valueOf(paramService.getParamValue(SysParamKeys.MEETING_ANDROID_AUDIO_CHUNK_DURATION_SECONDS, "60")));
return ApiResponse.ok(resultVo);
}
private void resolvePublicDeviceTenantId(HttpServletRequest request,
AndroidOfflineMeetingCreateCommand command,
AndroidAuthContext authContext) {
if (command == null || command.getTenantId() != null || authContext == null || !authContext.isAnonymous()) {
return;
}
String tenantCode = request == null ? null : request.getHeader(TENANT_CODE_HEADER);
if (!StringUtils.hasText(tenantCode)) {
throw new IllegalArgumentException("tenantCode不能为空");
}
SysTenant tenant = sysTenantMapper.selectOne(new LambdaQueryWrapper<SysTenant>()
.eq(SysTenant::getTenantCode, tenantCode.trim())
.eq(SysTenant::getIsDeleted, 0)
.last("LIMIT 1"));
if (tenant == null || tenant.getId() == null) {
throw new IllegalArgumentException("tenantCode无效无法获取tenantId");
}
command.setTenantId(tenant.getId());
}
private AndroidMeetingCreateResponse buildAndroidMeetingCreateResponse(MeetingVO meeting) {
AndroidMeetingCreateResponse response = new AndroidMeetingCreateResponse();
if (meeting == null) {
return response;
}
BeanUtils.copyProperties(meeting, response);
response.setPreviewUrl(buildPreviewUrl(meeting.getId()));
return response;
}
private PageResult<List<AndroidMeetingListItemVO>> buildAndroidMeetingListPage(PageResult<List<MeetingVO>> source) {
PageResult<List<AndroidMeetingListItemVO>> result = new PageResult<>();
if (source == null) {
result.setTotal(0L);
result.setRecords(List.of());
return result;
}
result.setTotal(source.getTotal());
result.setRecords(source.getRecords() == null
? List.of()
: source.getRecords().stream().map(this::buildAndroidMeetingListItem).toList());
return result;
}
private AndroidMeetingListItemVO buildAndroidMeetingListItem(MeetingVO meeting) {
AndroidMeetingListItemVO item = new AndroidMeetingListItemVO();
if (meeting == null) {
return item;
}
BeanUtils.copyProperties(meeting, item);
item.setPreviewUrl(buildPreviewUrl(meeting.getId()));
item.setDayOffset(resolveDayOffset(meeting.getMeetingTime()));
return item;
}
private String buildPreviewUrl(Long meetingId) {
if (meetingId == null) {
return null;
}
String baseUrl = normalizeH5BaseUrl();
if (!StringUtils.hasText(baseUrl)) {
return null;
}
return baseUrl + "/meetings/" + meetingId + "/preview";
}
private String normalizeH5BaseUrl() {
String baseUrl = StringUtils.hasText(h5BaseUrl) ? h5BaseUrl.trim() : "";
if (!StringUtils.hasText(baseUrl)) {
return "";
}
return baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
}
private Long resolveDayOffset(LocalDateTime meetingTime) {
if (meetingTime == null) {
return null;
}
return ChronoUnit.DAYS.between(LocalDate.now(), meetingTime.toLocalDate());
}
private Meeting requireOperableOfflineMeeting(Long meetingId, AndroidAuthContext authContext, LoginUser loginUser) {
Meeting meeting = meetingAccessService.requireMeetingIgnoreTenant(meetingId);
if (!MeetingConstants.TYPE_OFFLINE.equals(meeting.getMeetingType())) {
throw new RuntimeException("当前会议不是离线会议");
}
if (authContext == null || authContext.getDeviceId() == null || authContext.getDeviceId().isBlank()) {
throw new RuntimeException("设备ID不能为空");
}
return meeting;
}
private boolean isUploadFinishedStage(AndroidOfflineMeetingFinishRequest command) {
return command != null
&& MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED.equalsIgnoreCase(command.getFinishStage());
}
private String normalizePassword(String password) {
if (password == null) {
return null;
}
String normalized = password.trim();
return normalized.isEmpty() ? null : normalized;
}
}

View File

@ -0,0 +1,277 @@
package com.imeeting.controller.android;
import com.imeeting.common.MeetingConstants;
import com.imeeting.config.grpc.GrpcServerProperties;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidCreateRealtimeMeetingCommand;
import com.imeeting.dto.android.AndroidCreateRealtimeMeetingVO;
import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
import com.imeeting.dto.biz.MeetingTranscriptVO;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.RealtimeMeetingCompleteDTO;
import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile;
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.enums.MeetingTerminalEnum;
import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.biz.MeetingAccessService;
import com.imeeting.service.biz.MeetingAuthorizationService;
import com.imeeting.service.biz.MeetingCommandService;
import com.imeeting.service.biz.MeetingQueryService;
import com.imeeting.service.biz.MeetingRuntimeProfileResolver;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.support.AndroidRequestLogHelper;
import com.unisbase.common.ApiResponse;
import com.unisbase.common.annotation.Log;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
@Tag(name = "Android实时会议")
@RestController
@RequestMapping("/api/android/meeting")
@RequiredArgsConstructor
@Slf4j
public class AndroidMeetingRealtimeController {
private static final DateTimeFormatter TITLE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final AndroidAuthService androidAuthService;
private final MeetingAccessService meetingAccessService;
private final MeetingAuthorizationService meetingAuthorizationService;
private final MeetingQueryService meetingQueryService;
private final MeetingCommandService meetingCommandService;
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
private final MeetingRuntimeProfileResolver meetingRuntimeProfileResolver;
private final GrpcServerProperties grpcServerProperties;
@Operation(summary = "创建 Android 实时会议")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回实时会议创建结果和当前生效的运行时参数",
content = @Content(schema = @Schema(implementation = AndroidCreateRealtimeMeetingVO.class))
)
})
@PostMapping("/realtime/create")
@Log(value = "新增Android实时会议", type = "Android实时会议")
public ApiResponse<AndroidCreateRealtimeMeetingVO> createRealtimeMeeting(HttpServletRequest request,
@RequestBody(required = false) AndroidCreateRealtimeMeetingCommand command) {
AndroidRequestLogHelper.logRequest(log, "Android实时会议", "创建实时会议接口", "command", command);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
meetingAuthorizationService.assertCanCreateMeeting(authContext);
RealtimeMeetingRuntimeProfile runtimeProfile = meetingRuntimeProfileResolver.resolve(
authContext.getTenantId(),
authContext.getUserId(),
command == null ? null : command.getAsrModelId(),
command == null ? null : command.getSummaryModelId(),
command == null ? null : command.getPromptId(),
command == null ? null : command.getMode(),
command == null ? null : command.getLanguage(),
command == null ? null : command.getUseSpkId(),
command == null ? null : command.getEnablePunctuation(),
command == null ? null : command.getEnableItn(),
command == null ? null : command.getEnableTextRefine(),
command == null ? null : command.getSaveAudio(),
command == null ? null : command.getHotWordGroupId(),
command == null ? null : command.getHotWords()
);
CreateRealtimeMeetingCommand createCommand = buildCreateCommand(command, authContext, runtimeProfile);
MeetingVO meeting = meetingCommandService.createRealtimeMeeting(
createCommand,
authContext.getTenantId(),
authContext.getUserId(),
resolveCreatorName(authContext),
MeetingTerminalEnum.CUSTOM_TERMINAL.getCode()
);
RealtimeMeetingSessionStatusVO status = realtimeMeetingSessionStateService.getStatus(meeting.getId());
AndroidCreateRealtimeMeetingVO vo = new AndroidCreateRealtimeMeetingVO();
vo.setMeetingId(meeting.getId());
vo.setTitle(meeting.getTitle());
vo.setHostUserId(meeting.getHostUserId());
vo.setHostName(meeting.getHostName());
vo.setSampleRate(grpcServerProperties.getRealtime().getSampleRate());
vo.setChannels(grpcServerProperties.getRealtime().getChannels());
vo.setEncoding(grpcServerProperties.getRealtime().getEncoding());
vo.setResolvedAsrModelId(runtimeProfile.getResolvedAsrModelId());
vo.setResolvedAsrModelName(runtimeProfile.getResolvedAsrModelName());
vo.setResolvedSummaryModelId(runtimeProfile.getResolvedSummaryModelId());
vo.setResolvedSummaryModelName(runtimeProfile.getResolvedSummaryModelName());
vo.setResolvedPromptId(runtimeProfile.getResolvedPromptId());
vo.setResolvedPromptName(runtimeProfile.getResolvedPromptName());
vo.setResumeConfig(status == null ? null : status.getResumeConfig());
vo.setStatus(status);
return ApiResponse.ok(vo);
}
@Operation(summary = "查询 Android 实时会议状态")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回实时会议当前状态和恢复信息",
content = @Content(schema = @Schema(implementation = RealtimeMeetingSessionStatusVO.class))
)
})
@GetMapping("/{id}/realtime/session-status")
public ApiResponse<RealtimeMeetingSessionStatusVO> getRealtimeSessionStatus(@PathVariable Long id, HttpServletRequest request) {
AndroidRequestLogHelper.logRequest(log, "Android实时会议", "查询实时会议状态接口", "meetingId", id);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAuthorizationService.assertCanManageRealtimeMeeting(meeting, authContext);
return ApiResponse.ok(realtimeMeetingSessionStateService.getStatus(id));
}
@Operation(summary = "查询 Android 会议转写")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回会议转写记录列表",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = MeetingTranscriptVO.class)))
)
})
@GetMapping("/{id}/transcripts")
public ApiResponse<List<MeetingTranscriptVO>> getTranscripts(@PathVariable Long id, HttpServletRequest request) {
AndroidRequestLogHelper.logRequest(log, "Android实时会议", "查询会议转写接口", "meetingId", id);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAuthorizationService.assertCanViewMeeting(meeting, authContext);
return ApiResponse.ok(meetingQueryService.getTranscripts(id));
}
@Operation(summary = "暂停 Android 实时会议")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回暂停后的实时会议状态",
content = @Content(schema = @Schema(implementation = RealtimeMeetingSessionStatusVO.class))
)
})
@PostMapping("/{id}/realtime/pause")
public ApiResponse<RealtimeMeetingSessionStatusVO> pauseRealtimeMeeting(@PathVariable Long id, HttpServletRequest request) {
AndroidRequestLogHelper.logRequest(log, "Android实时会议", "暂停实时会议接口", "meetingId", id);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAuthorizationService.assertCanControlRealtimeMeeting(meeting, authContext, MeetingTerminalEnum.CUSTOM_TERMINAL.getCode());
return ApiResponse.ok(realtimeMeetingSessionStateService.pause(id));
}
@Operation(summary = "完成 Android 实时会议")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回实时会议完成是否成功",
content = @Content(schema = @Schema(implementation = Boolean.class))
)
})
@PostMapping("/{id}/realtime/complete")
public ApiResponse<Boolean> completeRealtimeMeeting(@PathVariable Long id,
HttpServletRequest request,
@RequestBody(required = false) RealtimeMeetingCompleteDTO dto) {
AndroidRequestLogHelper.logRequest(log, "Android实时会议", "完成实时会议接口", "meetingId", id, "request", dto);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAuthorizationService.assertCanControlRealtimeMeeting(meeting, authContext, MeetingTerminalEnum.CUSTOM_TERMINAL.getCode());
meetingCommandService.completeRealtimeMeeting(
id,
dto != null ? dto.getAudioUrl() : null,
dto != null && Boolean.TRUE.equals(dto.getOverwriteAudio())
);
return ApiResponse.ok(true);
}
private CreateRealtimeMeetingCommand buildCreateCommand(AndroidCreateRealtimeMeetingCommand command,
AndroidAuthContext authContext,
RealtimeMeetingRuntimeProfile runtimeProfile) {
CreateRealtimeMeetingCommand createCommand = new CreateRealtimeMeetingCommand();
LocalDateTime meetingTime = command != null && command.getMeetingTime() != null ? command.getMeetingTime() : LocalDateTime.now();
createCommand.setTitle(resolveMeetingTitle(command, meetingTime));
createCommand.setMeetingTime(meetingTime);
createCommand.setParticipants(command == null ? "" : normalize(command.getParticipants(), ""));
createCommand.setTags(command == null ? "" : normalize(command.getTags()));
createCommand.setHostUserId(resolveHostUserId(command, authContext));
createCommand.setHostName(resolveHostName(command, authContext, createCommand.getHostUserId()));
createCommand.setAsrModelId(runtimeProfile.getResolvedAsrModelId());
createCommand.setSummaryModelId(runtimeProfile.getResolvedSummaryModelId());
createCommand.setPromptId(runtimeProfile.getResolvedPromptId());
createCommand.setHotWordGroupId(runtimeProfile.getResolvedHotWordGroupId());
createCommand.setSummaryDetailLevel(command == null ? null : command.getSummaryDetailLevel());
createCommand.setMode(runtimeProfile.getResolvedMode());
createCommand.setLanguage(runtimeProfile.getResolvedLanguage());
createCommand.setUseSpkId(runtimeProfile.getResolvedUseSpkId());
createCommand.setEnablePunctuation(runtimeProfile.getResolvedEnablePunctuation());
createCommand.setEnableItn(runtimeProfile.getResolvedEnableItn());
createCommand.setEnableTextRefine(runtimeProfile.getResolvedEnableTextRefine());
createCommand.setSaveAudio(runtimeProfile.getResolvedSaveAudio());
createCommand.setHotWords(runtimeProfile.getResolvedHotWords());
return createCommand;
}
private String resolveMeetingTitle(AndroidCreateRealtimeMeetingCommand command, LocalDateTime meetingTime) {
String title = command == null ? null : normalize(command.getTitle());
if (title != null && !title.isBlank()) {
return title;
}
return "Android-Realtime-Meeting-" + TITLE_TIME_FORMATTER.format(meetingTime);
}
private Long resolveHostUserId(AndroidCreateRealtimeMeetingCommand command, AndroidAuthContext authContext) {
if (command != null && command.getHostUserId() != null) {
return command.getHostUserId();
}
return authContext.getUserId();
}
private String resolveHostName(AndroidCreateRealtimeMeetingCommand command, AndroidAuthContext authContext, Long hostUserId) {
if (command != null && command.getHostName() != null && !command.getHostName().isBlank()) {
return command.getHostName().trim();
}
if (hostUserId != null && hostUserId.equals(authContext.getUserId())) {
return resolveCreatorName(authContext);
}
return null;
}
private String resolveCreatorName(AndroidAuthContext authContext) {
if (authContext == null) {
return "android";
}
if (authContext.getDisplayName() != null && !authContext.getDisplayName().isBlank()) {
return authContext.getDisplayName().trim();
}
if (authContext.getUsername() != null && !authContext.getUsername().isBlank()) {
return authContext.getUsername().trim();
}
return authContext.getDeviceId() == null || authContext.getDeviceId().isBlank()
? "android"
: "android:" + authContext.getDeviceId().trim();
}
private String normalize(String value) {
return normalize(value, null);
}
private String normalize(String value, String defaultValue) {
if (value == null) {
return defaultValue;
}
String normalized = value.trim();
return normalized.isEmpty() ? defaultValue : normalized;
}
}

View File

@ -0,0 +1,73 @@
package com.imeeting.controller.android;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.biz.PromptTemplateVO;
import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.biz.PromptTemplateService;
import com.imeeting.support.AndroidRequestLogHelper;
import com.unisbase.common.ApiResponse;
import com.unisbase.dto.PageResult;
import com.unisbase.security.LoginUser;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "Android提示词接口")
@RestController
@RequestMapping("/api/android/prompts")
@RequiredArgsConstructor
@Slf4j
public class AndroidPromptController {
private static final String LEGACY_MEETING_SCENE = "MEETING_TASK";
private final AndroidAuthService androidAuthService;
private final PromptTemplateService promptTemplateService;
@Operation(summary = "查询场景提示词")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回指定场景下当前用户可用且启用的提示词模板列表",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PromptTemplateVO.class)))
)
})
@GetMapping("/active/{scene}")
public ApiResponse<List<PromptTemplateVO>> activePrompts(HttpServletRequest request, @PathVariable String scene) {
AndroidRequestLogHelper.logRequest(log, "Android提示词", "查询场景提示词接口", "scene", scene);
if (!LEGACY_MEETING_SCENE.equals(scene)) {
return ApiResponse.error("scene 仅支持 MEETING_TASK");
}
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext);
PageResult<List<PromptTemplateVO>> result = promptTemplateService.pageTemplates(
1,
1000,
null,
null,
loginUser.getTenantId(),
loginUser.getUserId(),
loginUser.getIsPlatformAdmin(),
loginUser.getIsTenantAdmin()
);
List<PromptTemplateVO> enabledTemplates = result.getRecords() == null
? List.of()
: result.getRecords().stream()
.filter(item -> Integer.valueOf(1).equals(item.getStatus()))
.toList();
return ApiResponse.ok(enabledTemplates);
}
}

View File

@ -0,0 +1,137 @@
package com.imeeting.controller.android;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidPushMessageVO;
import com.imeeting.dto.android.AndroidPublicMeetingSessionResultVO;
import com.imeeting.dto.android.AndroidPublicMeetingSessionRequest;
import com.imeeting.entity.biz.AndroidPushMessage;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.enums.MeetingStatusEnum;
import com.imeeting.enums.MeetingPushTypeEnum;
import com.imeeting.mapper.DeviceInfoMapper;
import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.android.AndroidPushMessageService;
import com.imeeting.service.android.AndroidPublicMeetingSessionService;
import com.imeeting.service.biz.MeetingCommandService;
import com.imeeting.service.biz.MeetingService;
import com.imeeting.support.AndroidRequestLogHelper;
import com.unisbase.annotation.Anonymous;
import com.unisbase.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "Android公有设备会议接口")
@RestController
@RequestMapping("/api/android/public-meetings")
@RequiredArgsConstructor
@Slf4j
public class AndroidPublicMeetingController {
private final AndroidAuthService androidAuthService;
private final AndroidPublicMeetingSessionService androidPublicMeetingSessionService;
private final AndroidPushMessageService androidPushMessageService;
private final MeetingCommandService meetingCommandService;
private final MeetingService meetingService;
private final DeviceInfoMapper deviceInfoMapper;
@Operation(summary = "创建公有设备扫码发会会话")
@io.swagger.v3.oas.annotations.responses.ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "优先返回待处理扫码消息,否则返回扫码会话二维码",
content = @Content(schema = @Schema(implementation = AndroidPublicMeetingSessionResultVO.class))
)
})
@PostMapping("/session")
@Anonymous
public ApiResponse<AndroidPublicMeetingSessionResultVO> createSession(HttpServletRequest request,
@RequestBody(required = false) AndroidPublicMeetingSessionRequest command) {
AndroidRequestLogHelper.logRequest(log, "Android公有会议", "创建扫码发会会话",
"request", command);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
assertPublicDevice(authContext.getDeviceId());
AndroidPushMessage pendingMessage = androidPushMessageService.findLatestPendingMessage(
authContext.getDeviceId(),
MeetingPushTypeEnum.PUBLIC_MEETING_LOGIN_CONFIRM.getCode()
);
AndroidPublicMeetingSessionResultVO result = new AndroidPublicMeetingSessionResultVO();
if (pendingMessage != null) {
result.setMode("PENDING_MESSAGE");
result.setMessage(androidPushMessageService.toPushMessageVO(pendingMessage));
return ApiResponse.ok(result);
}
result.setMode("QR_CODE");
result.setQrCode(androidPublicMeetingSessionService.create(
authContext.getDeviceId(),
command == null ? null : command.getTitle()
));
return ApiResponse.ok(result);
}
@Operation(summary = "主动拉取未确认扫码消息")
@GetMapping("/pending-login-message")
@Anonymous
public ApiResponse<AndroidPushMessageVO> pullPendingLoginMessage(HttpServletRequest request) {
AndroidRequestLogHelper.logRequest(log, "Android公有会议", "主动拉取未确认扫码消息");
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
assertPublicDevice(authContext.getDeviceId());
AndroidPushMessage pendingMessage = androidPushMessageService.findLatestPendingMessage(
authContext.getDeviceId(),
MeetingPushTypeEnum.PUBLIC_MEETING_LOGIN_CONFIRM.getCode()
);
return ApiResponse.ok(androidPushMessageService.toPushMessageVO(pendingMessage));
}
@Operation(summary = "公有设备删除未开始会议")
@io.swagger.v3.oas.annotations.responses.ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "删除成功返回 true",
content = @Content(schema = @Schema(implementation = Boolean.class))
)
})
@DeleteMapping("/{meetingId}")
@Anonymous
public ApiResponse<Boolean> deletePendingMeeting(HttpServletRequest request, @PathVariable Long meetingId) {
AndroidRequestLogHelper.logRequest(log, "Android公有会议", "删除未开始会议", "meetingId", meetingId);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
assertPublicDevice(authContext.getDeviceId());
Meeting meeting = meetingService.getById(meetingId);
if (meeting == null) {
throw new RuntimeException("会议不存在");
}
if (meeting.getSourceDeviceCode() == null || !meeting.getSourceDeviceCode().equals(authContext.getDeviceId())) {
throw new RuntimeException("当前会议不属于该设备");
}
if (MeetingStatusEnum.isCode(meeting.getStatus(), MeetingStatusEnum.COMPLETED)
|| MeetingStatusEnum.isCode(meeting.getStatus(), MeetingStatusEnum.FAILED)) {
throw new RuntimeException("当前会议状态不允许删除");
}
meetingCommandService.deleteMeeting(meetingId);
return ApiResponse.ok(true);
}
private void assertPublicDevice(String deviceId) {
if (deviceId == null || deviceId.isBlank()) {
throw new RuntimeException("设备ID不能为空");
}
var device = deviceInfoMapper.selectByDeviceCodeIgnoreTenant(deviceId);
if (device == null) {
throw new RuntimeException("设备未注册,请先完成设备注册");
}
if (device != null && device.getUserId() != null) {
throw new RuntimeException("当前设备为私有设备,请走私有设备发会流程");
}
}
}

View File

@ -0,0 +1,81 @@
package com.imeeting.controller.android;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidScreenSaverCatalogVO;
import com.imeeting.dto.android.AndroidScreenSaverItemVO;
import com.imeeting.dto.biz.ScreenSaverSelectionResult;
import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.biz.ScreenSaverService;
import com.imeeting.support.AndroidRequestLogHelper;
import com.imeeting.support.TaskSecurityContextRunner;
import com.unisbase.common.ApiResponse;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "Android屏保")
@RestController
@RequestMapping("/api/android/screensavers")
@RequiredArgsConstructor
@Slf4j
public class AndroidScreenSaverController {
private final AndroidAuthService androidAuthService;
private final ScreenSaverService screenSaverService;
private final TaskSecurityContextRunner taskSecurityContextRunner;
@Value("${imeeting.h5.base-url:}")
private String h5BaseUrl;
@Operation(summary = "获取当前生效屏保")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回当前生效的屏保配置和轮播项列表",
content = @Content(schema = @Schema(implementation = AndroidScreenSaverCatalogVO.class))
)
})
@GetMapping("/active")
public ApiResponse<AndroidScreenSaverCatalogVO> active(HttpServletRequest request) {
AndroidRequestLogHelper.logRequest(log, "Android屏保", "获取当前生效屏保接口");
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
ScreenSaverSelectionResult selection = querySelection(authContext);
AndroidScreenSaverCatalogVO vo = new AndroidScreenSaverCatalogVO();
vo.setRefreshIntervalSec(300);
vo.setH5BaseUrl(StringUtils.hasText(h5BaseUrl) ? h5BaseUrl.trim() : null);
vo.setPlayMode("SEQUENTIAL");
vo.setDisplayDurationSec(selection.getDisplayDurationSec());
vo.setSourceScope(selection.getSourceScope());
vo.setItems(selection.getItems().stream().map(item -> {
AndroidScreenSaverItemVO child = new AndroidScreenSaverItemVO();
child.setId(item.getId());
child.setName(item.getName());
child.setImageUrl(item.getImageUrl());
child.setDescription(item.getDescription());
child.setSortOrder(item.getSortOrder());
child.setUpdatedAt(item.getUpdatedAt());
return child;
}).toList());
return ApiResponse.ok(vo);
}
private ScreenSaverSelectionResult querySelection(AndroidAuthContext authContext) {
if (authContext == null || authContext.isAnonymous()
|| authContext.getUserId() == null || authContext.getTenantId() == null) {
return screenSaverService.getActiveSelection(null);
}
return taskSecurityContextRunner.callAsTenantUser(
authContext.getTenantId(),
authContext.getUserId(),
() -> screenSaverService.getActiveSelection(authContext.getUserId())
);
}
}

View File

@ -0,0 +1,126 @@
package com.imeeting.controller.android.legacy;
import com.google.protobuf.ServiceException;
import com.imeeting.dto.android.legacy.LegacyApiResponse;
import com.imeeting.dto.android.legacy.LegacyLoginResponse;
import com.imeeting.dto.android.legacy.LegacyLoginUserResponse;
import com.imeeting.dto.android.legacy.LegacyRefreshTokenResponse;
import com.imeeting.support.AndroidRequestLogHelper;
import com.unisbase.dto.LoginRequest;
import com.unisbase.dto.RefreshRequest;
import com.unisbase.dto.SysRoleDTO;
import com.unisbase.dto.SysUserDTO;
import com.unisbase.dto.TokenResponse;
import com.unisbase.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.util.StringUtils;
import java.util.List;
@Tag(name = "兼容认证接口")
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
@Slf4j
public class LegacyAuthController {
private final AuthService authService;
@Operation(summary = "兼容登录")
@PostMapping("/login")
public LegacyApiResponse<LegacyLoginResponse> login(@Valid @RequestBody LoginRequest request) {
AndroidRequestLogHelper.logRequest(log, "兼容认证", "登录接口", "request", request);
TokenResponse tokenResponse = null;
try {
tokenResponse = authService.login(request, true);
} catch (Exception e) {
return LegacyApiResponse.error("400",e.getMessage());
}
try {
tokenResponse.getUser().setTenantId(tokenResponse.getAvailableTenants().stream().findFirst().orElseThrow(()-> new ServiceException("未绑定租户")).getTenantId());
} catch (ServiceException e) {
return LegacyApiResponse.error("400",e.getMessage());
}
return LegacyApiResponse.ok(new LegacyLoginResponse(
tokenResponse.getAccessToken(),
tokenResponse.getRefreshToken(),
toLegacyUser(tokenResponse.getUser())
));
}
@Operation(summary = "兼容刷新令牌")
@PostMapping("/refresh")
public LegacyApiResponse<LegacyRefreshTokenResponse> refresh(@RequestBody(required = false) RefreshRequest request,
@RequestHeader(value = "Authorization", required = false) String authorization,
@RequestHeader(value = "X-Android-Access-Token", required = false) String androidAccessToken) {
AndroidRequestLogHelper.logRequest(log, "兼容认证", "刷新令牌接口",
"request", request,
"authorization", authorization,
"androidAccessToken", androidAccessToken);
TokenResponse tokenResponse = null;
try {
tokenResponse = authService.refresh(resolveRefreshToken(request, authorization, androidAccessToken));
} catch (Exception e) {
return LegacyApiResponse.error("400",e.getMessage());
}
return LegacyApiResponse.ok(new LegacyRefreshTokenResponse(tokenResponse.getAccessToken(),tokenResponse.getRefreshToken()));
}
private LegacyLoginUserResponse toLegacyUser(SysUserDTO user) {
if (user == null) {
return null;
}
SysRoleDTO primaryRole = resolvePrimaryRole(user);
return new LegacyLoginUserResponse(
user.getUserId(),
user.getTenantId(),
user.getUsername(),
user.getDisplayName(),
user.getAvatarUrl(),
user.getEmail(),
primaryRole == null ? null : primaryRole.getRoleId(),
primaryRole == null ? null : primaryRole.getRoleName(),
user.getCreatedAt()
);
}
private SysRoleDTO resolvePrimaryRole(SysUserDTO user) {
List<SysRoleDTO> roles = user.getRoles();
if (roles != null && !roles.isEmpty()) {
return roles.get(0);
}
List<Long> roleIds = user.getRoleIds();
if (roleIds != null && !roleIds.isEmpty()) {
SysRoleDTO role = new SysRoleDTO();
role.setRoleId(roleIds.get(0));
return role;
}
return null;
}
private String resolveRefreshToken(RefreshRequest request, String authorization, String androidAccessToken) {
if (request != null && StringUtils.hasText(request.getRefreshToken())) {
return request.getRefreshToken().trim();
}
if (StringUtils.hasText(androidAccessToken)) {
return androidAccessToken.trim();
}
if (StringUtils.hasText(authorization)) {
String value = authorization.trim();
if (value.startsWith("Bearer ")) {
return value.substring(7).trim();
}
return value;
}
throw new IllegalArgumentException("refreshToken不能为空");
}
}

View File

@ -0,0 +1,45 @@
package com.imeeting.controller.android.legacy;
import com.imeeting.dto.android.legacy.LegacyApiResponse;
import com.imeeting.dto.android.legacy.LegacyClientDownloadResponse;
import com.imeeting.service.android.legacy.LegacyCatalogAdapterService;
import com.imeeting.support.AndroidRequestLogHelper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "兼容客户端下载接口")
@RestController
@RequestMapping("/api/clients")
@RequiredArgsConstructor
@Slf4j
public class LegacyClientController {
private final LegacyCatalogAdapterService legacyCatalogAdapterService;
@Operation(summary = "查询平台最新客户端")
@GetMapping("/latest/by-platform")
public LegacyApiResponse<LegacyClientDownloadResponse> latestByPlatform(@RequestParam(value = "platform_code", required = false) String platformCode,
@RequestParam(value = "platform_type", required = false) String platformType,
@RequestParam(value = "platform_name", required = false) String platformName) {
AndroidRequestLogHelper.logRequest(log, "兼容客户端", "查询平台最新客户端接口",
"platformCode", platformCode,
"platformType", platformType,
"platformName", platformName);
if ((platformCode == null || platformCode.isBlank())
&& ((platformType == null || platformType.isBlank()) || (platformName == null || platformName.isBlank()))) {
return LegacyApiResponse.error("400", "请提供 platform_code 参数");
}
LegacyClientDownloadResponse response = legacyCatalogAdapterService.getLatestClient(platformCode, platformType, platformName);
if (response == null) {
return LegacyApiResponse.error("404", "暂无最新客户端");
}
return LegacyApiResponse.ok(response);
}
}

View File

@ -0,0 +1,33 @@
package com.imeeting.controller.android.legacy;
import com.imeeting.dto.android.legacy.LegacyApiResponse;
import com.imeeting.dto.android.legacy.LegacyExternalAppItemResponse;
import com.imeeting.service.android.legacy.LegacyCatalogAdapterService;
import com.imeeting.support.AndroidRequestLogHelper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "兼容外部应用接口")
@RestController
@RequestMapping("/api/external-apps")
@RequiredArgsConstructor
@Slf4j
public class LegacyExternalAppController {
private final LegacyCatalogAdapterService legacyCatalogAdapterService;
@Operation(summary = "查询启用的外部应用")
@GetMapping("/active")
public LegacyApiResponse<List<LegacyExternalAppItemResponse>> active(@RequestParam(value = "is_active", required = false) Integer ignoredIsActive) {
AndroidRequestLogHelper.logRequest(log, "兼容外部应用", "查询启用外部应用接口", "isActive", ignoredIsActive);
return LegacyApiResponse.ok(legacyCatalogAdapterService.listActiveExternalApps());
}
}

View File

@ -0,0 +1,59 @@
package com.imeeting.controller.android.legacy;
import com.imeeting.dto.android.legacy.LegacyApiResponse;
import com.imeeting.dto.android.legacy.LegacyLlmModelItemResponse;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.service.biz.AiModelService;
import com.imeeting.support.AndroidRequestLogHelper;
import com.unisbase.dto.PageResult;
import com.unisbase.security.LoginUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Objects;
@Tag(name = "兼容模型接口")
@RestController
@RequestMapping("/api/llm-models")
@RequiredArgsConstructor
@Slf4j
public class LegacyLlmModelController {
private final AiModelService aiModelService;
@Operation(summary = "查询启用的大模型列表")
@GetMapping("/active")
@PreAuthorize("isAuthenticated()")
public LegacyApiResponse<List<LegacyLlmModelItemResponse>> activeModels() {
AndroidRequestLogHelper.logRequest(log, "兼容模型", "查询启用大模型列表接口");
LoginUser loginUser = currentLoginUser();
PageResult<List<AiModelVO>> result = aiModelService.pageModels(1, 1000, null, "LLM", loginUser.getTenantId(), false);
List<AiModelVO> enabledModels = result.getRecords() == null
? List.of()
: result.getRecords().stream()
.filter(item -> Integer.valueOf(1).equals(item.getStatus()))
.toList();
boolean hasExplicitDefault = enabledModels.stream().anyMatch(item -> Integer.valueOf(1).equals(item.getIsDefault()));
Long fallbackDefaultId = enabledModels.isEmpty() ? null : enabledModels.get(0).getId();
List<LegacyLlmModelItemResponse> models = enabledModels.stream()
.map(item -> LegacyLlmModelItemResponse.from(
item,
Integer.valueOf(1).equals(item.getIsDefault())
|| (!hasExplicitDefault && Objects.equals(item.getId(), fallbackDefaultId))
))
.toList();
return LegacyApiResponse.ok(models);
}
private LoginUser currentLoginUser() {
return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}
}

View File

@ -0,0 +1,644 @@
package com.imeeting.controller.android.legacy;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.dto.android.legacy.LegacyApiResponse;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.legacy.LegacyMeetingAccessPasswordRequest;
import com.imeeting.dto.android.legacy.LegacyMeetingAccessPasswordResponse;
import com.imeeting.dto.android.legacy.LegacyMeetingAttendeeResponse;
import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest;
import com.imeeting.dto.android.legacy.LegacyMeetingCreateResponse;
import com.imeeting.dto.android.legacy.LegacyMeetingItemResponse;
import com.imeeting.dto.android.legacy.LegacyMeetingListResponse;
import com.imeeting.dto.android.legacy.LegacyMeetingPreviewDataResponse;
import com.imeeting.dto.android.legacy.LegacyMeetingPreviewResult;
import com.imeeting.dto.android.legacy.LegacyMeetingProcessingStatusResponse;
import com.imeeting.dto.android.legacy.LegacyMeetingTagResponse;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.entity.biz.PromptTemplate;
import com.imeeting.enums.MeetingStatusEnum;
import com.imeeting.service.android.legacy.LegacyMeetingAdapterService;
import com.imeeting.support.AndroidRequestLogHelper;
import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.MeetingAccessService;
import com.imeeting.service.biz.MeetingCommandService;
import com.imeeting.service.biz.MeetingProgressService;
import com.imeeting.service.biz.MeetingQueryService;
import com.imeeting.service.biz.MeetingService;
import com.imeeting.service.biz.PromptTemplateService;
import com.unisbase.common.annotation.Log;
import com.unisbase.dto.PageResult;
import com.unisbase.entity.SysUser;
import com.unisbase.mapper.SysUserMapper;
import com.unisbase.security.LoginUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@Tag(name = "兼容会议接口")
@RestController
@RequestMapping("/api/meetings")
@Slf4j
public class LegacyMeetingController {
private static final String STAGE_DATA_INITIALIZATION = "data_initialization";
private static final String STAGE_AUDIO_TRANSCRIPTION = "audio_transcription";
private static final String STAGE_SUMMARY_GENERATION = "summary_generation";
private static final String STAGE_COMPLETED = "completed";
private final LegacyMeetingAdapterService legacyMeetingAdapterService;
private final MeetingQueryService meetingQueryService;
private final MeetingAccessService meetingAccessService;
private final MeetingCommandService meetingCommandService;
private final MeetingService meetingService;
private final AiTaskService aiTaskService;
private final PromptTemplateService promptTemplateService;
private final MeetingTranscriptMapper meetingTranscriptMapper;
private final SysUserMapper sysUserMapper;
private final MeetingProgressService meetingProgressService;
private final ObjectMapper objectMapper;
public LegacyMeetingController(LegacyMeetingAdapterService legacyMeetingAdapterService,
MeetingQueryService meetingQueryService,
MeetingAccessService meetingAccessService,
MeetingCommandService meetingCommandService,
MeetingService meetingService,
AiTaskService aiTaskService,
PromptTemplateService promptTemplateService,
MeetingTranscriptMapper meetingTranscriptMapper,
SysUserMapper sysUserMapper) {
this(legacyMeetingAdapterService,
meetingQueryService,
meetingAccessService,
meetingCommandService,
meetingService,
aiTaskService,
promptTemplateService,
meetingTranscriptMapper,
sysUserMapper,
(MeetingProgressService) null,
new ObjectMapper());
}
@Autowired
public LegacyMeetingController(LegacyMeetingAdapterService legacyMeetingAdapterService,
MeetingQueryService meetingQueryService,
MeetingAccessService meetingAccessService,
MeetingCommandService meetingCommandService,
MeetingService meetingService,
AiTaskService aiTaskService,
PromptTemplateService promptTemplateService,
MeetingTranscriptMapper meetingTranscriptMapper,
SysUserMapper sysUserMapper,
MeetingProgressService meetingProgressService,
ObjectMapper objectMapper) {
this.legacyMeetingAdapterService = legacyMeetingAdapterService;
this.meetingQueryService = meetingQueryService;
this.meetingAccessService = meetingAccessService;
this.meetingCommandService = meetingCommandService;
this.meetingService = meetingService;
this.aiTaskService = aiTaskService;
this.promptTemplateService = promptTemplateService;
this.meetingTranscriptMapper = meetingTranscriptMapper;
this.sysUserMapper = sysUserMapper;
this.meetingProgressService = meetingProgressService;
this.objectMapper = objectMapper;
}
@Operation(summary = "兼容创建会议")
@PostMapping
@PreAuthorize("isAuthenticated()")
@Log(value = "新增兼容会议", type = "兼容会议管理")
public LegacyApiResponse<LegacyMeetingCreateResponse> create(@RequestBody LegacyMeetingCreateRequest request) {
AndroidRequestLogHelper.logRequest(log, "兼容会议", "创建会议接口", "request", request);
MeetingVO meeting = legacyMeetingAdapterService.createMeeting(request, buildLegacyAuthContext(), currentLoginUser());
return LegacyApiResponse.ok(new LegacyMeetingCreateResponse(meeting.getId()));
}
@Operation(summary = "兼容上传会议音频")
@PostMapping("/upload-audio")
@PreAuthorize("isAuthenticated()")
public LegacyApiResponse<Void> uploadAudio(@RequestParam("meeting_id") Long meetingId,
@RequestParam(value = "prompt_id", required = false) Long promptId,
@RequestParam(value = "model_code", required = false) String modelCode,
@RequestParam(value = "force_replace", defaultValue = "false") boolean forceReplace,
@RequestParam("audio_file") MultipartFile audioFile) throws IOException {
AndroidRequestLogHelper.logRequest(log, "兼容会议", "上传会议音频接口",
"meetingId", meetingId,
"promptId", promptId,
"modelCode", modelCode,
"forceReplace", forceReplace,
"audioFile", audioFile);
legacyMeetingAdapterService.uploadAndTriggerOfflineProcess(
meetingId,
promptId,
modelCode,
forceReplace,
audioFile,
buildLegacyAuthContext(),
currentLoginUser()
);
return LegacyApiResponse.ok("上传成功", null);
}
@Operation(summary = "兼容分页查询会议")
@GetMapping
@PreAuthorize("isAuthenticated()")
public LegacyApiResponse<LegacyMeetingListResponse> list(@RequestParam(value = "user_id", required = false) Long ignoredUserId,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(value = "page_size", defaultValue = "10") Integer pageSize,
@RequestParam(required = false) String title) {
AndroidRequestLogHelper.logRequest(log, "兼容会议", "分页查询会议接口",
"userId", ignoredUserId,
"page", page,
"pageSize", pageSize,
"title", title);
LoginUser loginUser = currentLoginUser();
boolean isAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin());
PageResult<List<MeetingVO>> result = meetingQueryService.pageMeetings(
page,
pageSize,
title,
loginUser.getTenantId(),
loginUser.getUserId(),
resolveCreatorName(loginUser),
"all",
null,
isAdmin
);
LegacyMeetingListResponse data = new LegacyMeetingListResponse();
data.setPage(page);
data.setPageSize(pageSize);
data.setTotal(result.getTotal());
data.setTotalPages(pageSize == null || pageSize <= 0 ? 0 : (result.getTotal() + pageSize - 1) / pageSize);
data.setHasMore(page != null && page < data.getTotalPages());
data.setMeetings(result.getRecords() == null
? List.of()
: result.getRecords().stream().map(this::buildListItem).toList());
return LegacyApiResponse.ok(data);
}
@Operation(summary = "兼容查询会议预览数据")
@GetMapping("/{meetingId}/preview-data")
public LegacyApiResponse<?> previewData(@PathVariable Long meetingId) {
AndroidRequestLogHelper.logRequest(log, "兼容会议", "查询会议预览数据接口", "meetingId", meetingId);
LegacyMeetingPreviewResult result = buildPreviewResult(meetingId);
return new LegacyApiResponse<>(result.getCode(), result.getMessage(), result.getData());
}
@Operation(summary = "兼容更新会议访问密码")
@PutMapping("/{meetingId}/access-password")
@PreAuthorize("isAuthenticated()")
@Log(value = "修改兼容会议访问密码", type = "兼容会议管理")
public LegacyApiResponse<LegacyMeetingAccessPasswordResponse> updateAccessPassword(@PathVariable Long meetingId,
@RequestBody(required = false) LegacyMeetingAccessPasswordRequest request) {
AndroidRequestLogHelper.logRequest(log, "兼容会议", "更新会议访问密码接口",
"meetingId", meetingId,
"request", request);
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(meetingId);
if (!Objects.equals(meeting.getCreatorId(), loginUser.getUserId())) {
return LegacyApiResponse.error("403", "仅会议创建人可设置访问密码");
}
String password = normalizePassword(request == null ? null : request.getPassword());
meetingService.update(new LambdaUpdateWrapper<Meeting>()
.eq(Meeting::getId,meeting.getId())
.set(Meeting::getAccessPassword, password));
return LegacyApiResponse.ok(new LegacyMeetingAccessPasswordResponse(password));
}
@Operation(summary = "兼容删除会议")
@DeleteMapping("/{meetingId}")
@PreAuthorize("isAuthenticated()")
@Log(value = "删除兼容会议", type = "兼容会议管理")
public LegacyApiResponse<Void> delete(@PathVariable Long meetingId) {
AndroidRequestLogHelper.logRequest(log, "兼容会议", "删除会议接口", "meetingId", meetingId);
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(meetingId);
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
meetingCommandService.deleteMeeting(meetingId);
return LegacyApiResponse.ok("删除成功", null);
}
private LegacyMeetingPreviewResult buildPreviewResult(Long meetingId) {
Meeting meeting = meetingService.getById(meetingId);
if (meeting == null) {
return new LegacyMeetingPreviewResult("404", "会议不存在", null);
}
AiTask asrTask = findLatestTask(meetingId, "ASR");
AiTask summaryTask = findLatestTask(meetingId, "SUMMARY");
boolean summaryCompleted = summaryTask != null && Integer.valueOf(2).equals(summaryTask.getStatus());
MeetingVO detail = (MeetingStatusEnum.isCode(meeting.getStatus(), MeetingStatusEnum.COMPLETED) || summaryCompleted)
? meetingQueryService.getDetail(meetingId)
: null;
boolean hasSummary = detail != null && detail.getSummaryContent() != null && !detail.getSummaryContent().isBlank();
if (hasSummary) {
return new LegacyMeetingPreviewResult("200", "success", buildCompletedPreview(meeting, detail, summaryTask));
}
if (summaryCompleted) {
return new LegacyMeetingPreviewResult("200", "success", buildCompletedPreview(meeting, detail, summaryTask));
}
// if (summaryCompleted) {
// return new LegacyMeetingPreviewResult(
// "504",
// "处理已完成,但摘要尚未同步,请稍后重试",
// buildProcessingPreview(meeting, summaryTask, processingStatus("摘要已生成,可扫码查看", 100, STAGE_COMPLETED))
// );
// }
if (isFailed(asrTask)) {
return new LegacyMeetingPreviewResult(
"503",
buildFailureMessage(asrTask, "转译"),
buildProcessingPreview(meeting, summaryTask, processingStatus("转译或总结失败", 50, STAGE_AUDIO_TRANSCRIPTION))
);
}
if (isFailed(summaryTask)) {
return new LegacyMeetingPreviewResult(
"503",
buildFailureMessage(summaryTask, "总结"),
buildProcessingPreview(meeting, summaryTask, processingStatus("转译或总结失败", 75, STAGE_SUMMARY_GENERATION))
);
}
Integer realtimeProgress = resolveRealtimeProgress(meetingId);
if (asrTask != null && Integer.valueOf(0).equals(asrTask.getStatus()) && realtimeProgress != null && realtimeProgress <= 0) {
return new LegacyMeetingPreviewResult(
"400",
"会议正在处理中",
buildProcessingPreview(meeting, summaryTask, processingStatus("会议数据准备中", 25, STAGE_DATA_INITIALIZATION))
);
}
if (realtimeProgress != null) {
if (realtimeProgress >= 100) {
MeetingVO completedDetail = detail != null ? detail : meetingQueryService.getDetail(meetingId);
boolean completedHasSummary = completedDetail != null
&& completedDetail.getSummaryContent() != null
&& !completedDetail.getSummaryContent().isBlank();
if (completedHasSummary) {
return new LegacyMeetingPreviewResult("200", "success", buildCompletedPreview(meeting, completedDetail, summaryTask));
}
return new LegacyMeetingPreviewResult(
"504",
"处理已完成,但摘要尚未同步,请稍后重试",
buildProcessingPreview(meeting, summaryTask, processingStatus("摘要已生成,可扫码查看", 100, STAGE_COMPLETED))
);
}
if (realtimeProgress < 90) {
return new LegacyMeetingPreviewResult(
"400",
"会议正在处理中",
buildProcessingPreview(meeting, summaryTask, processingStatus("姝e湪杞瘧闊抽", 50, STAGE_AUDIO_TRANSCRIPTION))
);
}
if (realtimeProgress >= 90) {
return new LegacyMeetingPreviewResult(
"400",
"会议正在处理中",
buildProcessingPreview(meeting, summaryTask, processingStatus("姝e湪鐢熸垚鎬荤粨", 75, STAGE_SUMMARY_GENERATION))
);
}
}
boolean isSummaryStage = isSummaryStage(meeting.getStatus(), summaryTask);
boolean isAsrStage = isAsrStage(meeting.getStatus(), asrTask, hasAudio(meeting), isSummaryStage);
if (!isAsrStage && !isSummaryStage) {
return new LegacyMeetingPreviewResult(
"400",
"会议正在处理中",
buildProcessingPreview(meeting, summaryTask, processingStatus("会议数据准备中", 25, STAGE_DATA_INITIALIZATION))
);
}
if (!isSummaryStage) {
return new LegacyMeetingPreviewResult(
"400",
"会议正在处理中",
buildProcessingPreview(meeting, summaryTask, processingStatus("正在转译音频", 50, STAGE_AUDIO_TRANSCRIPTION))
);
}
return new LegacyMeetingPreviewResult(
"400",
"会议正在处理中",
buildProcessingPreview(meeting, summaryTask, processingStatus("正在生成总结", 75, STAGE_SUMMARY_GENERATION))
);
}
private LegacyMeetingPreviewDataResponse buildCompletedPreview(Meeting meeting, MeetingVO detail, AiTask summaryTask) {
LegacyMeetingPreviewDataResponse data = new LegacyMeetingPreviewDataResponse();
data.setMeetingId(meeting.getId());
data.setTitle(meeting.getTitle());
data.setMeetingTime(formatDateTime(meeting.getMeetingTime()));
data.setSummary(detail.getSummaryContent());
data.setCreatorUsername(resolveCreatorDisplayName(meeting.getCreatorId(), meeting.getCreatorName()));
Long promptId = resolvePromptId(summaryTask);
data.setPromptId(promptId);
data.setPromptName(resolvePromptName(promptId));
List<LegacyMeetingAttendeeResponse> attendees = buildAttendees(meeting.getParticipants());
data.setAttendees(attendees);
data.setAttendeesCount(attendees.size());
data.setHasPassword(meeting.getAccessPassword() != null && !meeting.getAccessPassword().isBlank());
data.setProcessingStatus(processingStatus("摘要已生成,可扫码查看", 100, STAGE_COMPLETED));
return data;
}
private LegacyMeetingItemResponse buildListItem(MeetingVO meeting) {
LegacyMeetingItemResponse item = new LegacyMeetingItemResponse();
item.setMeetingId(meeting.getId());
item.setTitle(meeting.getTitle());
item.setMeetingTime(formatDateTime(meeting.getMeetingTime()));
item.setCreatedAt(formatDateTime(meeting.getCreatedAt()));
item.setCreatorId(meeting.getCreatorId());
item.setCreatorUsername(resolveCreatorDisplayName(meeting.getCreatorId(), meeting.getCreatorName()));
item.setAudioFilePath(meeting.getAudioUrl());
item.setAudioDuration(meeting.getDuration());
item.setAccessPassword(resolveAccessPassword(meeting.getId()));
List<Long> attendeeIds = meeting.getParticipantIds() == null ? List.of() : meeting.getParticipantIds();
item.setAttendeeIds(attendeeIds);
item.setAttendees(buildAttendees(attendeeIds));
item.setTags(buildTags(meeting.getTags()));
item.setSummary(resolveListSummary(meeting.getId()));
LegacyMeetingProcessingStatusResponse status = buildListStatus(meeting);
item.setOverallStatus(status.getOverallStatus());
item.setOverallProgress(status.getOverallProgress());
item.setCurrentStage(translateListStage(status.getCurrentStage()));
return item;
}
private LegacyMeetingPreviewDataResponse buildProcessingPreview(Meeting meeting,
AiTask summaryTask,
LegacyMeetingProcessingStatusResponse status) {
LegacyMeetingPreviewDataResponse data = new LegacyMeetingPreviewDataResponse();
data.setMeetingId(meeting.getId());
data.setTitle(meeting.getTitle());
data.setMeetingTime(formatDateTime(meeting.getMeetingTime()));
data.setCreatorUsername(resolveCreatorDisplayName(meeting.getCreatorId(), meeting.getCreatorName()));
Long promptId = resolvePromptId(summaryTask);
data.setPromptId(promptId);
data.setPromptName(resolvePromptName(promptId));
data.setHasPassword(meeting.getAccessPassword() != null && !meeting.getAccessPassword().isBlank());
data.setProcessingStatus(status);
return data;
}
private LegacyMeetingProcessingStatusResponse processingStatus(String overallStatus, int overallProgress, String currentStage) {
return new LegacyMeetingProcessingStatusResponse(overallStatus, overallProgress, currentStage);
}
private Integer resolveRealtimeProgress(Long meetingId) {
if (meetingProgressService == null) {
return null;
}
return meetingProgressService.resolvePercent(meetingId);
}
private LegacyMeetingProcessingStatusResponse buildListStatus(MeetingVO meeting) {
Long meetingId = meeting.getId();
AiTask asrTask = findLatestTask(meetingId, "ASR");
AiTask summaryTask = findLatestTask(meetingId, "SUMMARY");
boolean summaryCompleted = summaryTask != null && Integer.valueOf(2).equals(summaryTask.getStatus());
if (MeetingStatusEnum.isCode(meeting.getStatus(), MeetingStatusEnum.COMPLETED) || summaryCompleted) {
return new LegacyMeetingProcessingStatusResponse("completed", 100, STAGE_COMPLETED);
}
if (isFailed(asrTask)) {
return new LegacyMeetingProcessingStatusResponse("failed", 50, STAGE_AUDIO_TRANSCRIPTION);
}
if (isFailed(summaryTask)) {
return new LegacyMeetingProcessingStatusResponse("failed", 75, STAGE_SUMMARY_GENERATION);
}
boolean isSummaryStage = isSummaryStage(meeting.getStatus(), summaryTask);
boolean isAsrStage = isAsrStage(meeting.getStatus(), asrTask, hasAudio(meeting), isSummaryStage);
if (!isAsrStage && !isSummaryStage) {
return new LegacyMeetingProcessingStatusResponse("pending", 0, STAGE_DATA_INITIALIZATION);
}
if (isSummaryStage) {
return new LegacyMeetingProcessingStatusResponse("summarizing", 75, STAGE_SUMMARY_GENERATION);
}
return new LegacyMeetingProcessingStatusResponse("transcribing", 50, STAGE_AUDIO_TRANSCRIPTION);
}
private String buildFailureMessage(AiTask failedTask, String stageName) {
String error = failedTask == null || failedTask.getErrorMsg() == null || failedTask.getErrorMsg().isBlank()
? "处理失败"
: failedTask.getErrorMsg();
return "会议" + stageName + "失败: " + error;
}
private boolean isRunningAsr(AiTask task) {
return task != null && Integer.valueOf(1).equals(task.getStatus());
}
private boolean isRunningSummary(AiTask task) {
return task != null && Integer.valueOf(1).equals(task.getStatus());
}
private boolean isFailed(AiTask task) {
return task != null && Integer.valueOf(3).equals(task.getStatus());
}
private AiTask findLatestTask(Long meetingId, String taskType) {
return aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meetingId)
.eq(AiTask::getTaskType, taskType)
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
}
private Long resolvePromptId(AiTask summaryTask) {
if (summaryTask == null || summaryTask.getTaskConfig() == null) {
return null;
}
Object rawPromptId = summaryTask.getTaskConfig().get("promptId");
if (rawPromptId == null) {
return null;
}
if (rawPromptId instanceof Number number) {
return number.longValue();
}
String value = String.valueOf(rawPromptId).trim();
if (value.isEmpty()) {
return null;
}
try {
return Long.parseLong(value);
} catch (NumberFormatException ignored) {
return null;
}
}
private String resolvePromptName(Long promptId) {
if (promptId == null) {
return null;
}
PromptTemplate template = promptTemplateService.getById(promptId);
return template == null ? null : template.getTemplateName();
}
private List<LegacyMeetingAttendeeResponse> buildAttendees(String participants) {
return buildAttendees(parseParticipantIds(participants));
}
private List<LegacyMeetingAttendeeResponse> buildAttendees(List<Long> participantIds) {
if (participantIds == null || participantIds.isEmpty()) {
return List.of();
}
Map<Long, SysUser> userMap = sysUserMapper.selectBatchIds(participantIds).stream()
.collect(Collectors.toMap(SysUser::getUserId, user -> user, (left, right) -> left, LinkedHashMap::new));
return participantIds.stream()
.map(userId -> {
SysUser user = userMap.get(userId);
String caption = user == null
? String.valueOf(userId)
: (user.getDisplayName() != null ? user.getDisplayName() : user.getUsername());
String username = user == null ? null : user.getUsername();
return new LegacyMeetingAttendeeResponse(userId, username, caption);
})
.toList();
}
private List<LegacyMeetingTagResponse> buildTags(String rawTags) {
if (rawTags == null || rawTags.isBlank()) {
return List.of();
}
return Arrays.stream(rawTags.split(","))
.map(String::trim)
.filter(value -> !value.isEmpty())
.map(value -> new LegacyMeetingTagResponse(null, value))
.toList();
}
private List<Long> parseParticipantIds(String participants) {
if (participants == null || participants.isBlank()) {
return List.of();
}
return Arrays.stream(participants.split(","))
.map(String::trim)
.filter(value -> !value.isEmpty())
.map(value -> {
try {
return Long.parseLong(value);
} catch (NumberFormatException ignored) {
return null;
}
})
.filter(Objects::nonNull)
.toList();
}
private String normalizePassword(String password) {
if (password == null) {
return null;
}
String normalized = password.trim();
return normalized.isEmpty() ? null : normalized;
}
private String resolveListSummary(Long meetingId) {
MeetingVO detail = meetingQueryService.getDetail(meetingId);
if (detail == null || detail.getSummaryContent() == null || detail.getSummaryContent().isBlank()) {
return null;
}
String summary = detail.getSummaryContent().trim();
return summary.length() <= 240 ? summary : summary.substring(0, 240);
}
private String resolveAccessPassword(Long meetingId) {
Meeting meeting = meetingService.getById(meetingId);
return meeting == null ? null : normalizePassword(meeting.getAccessPassword());
}
private String resolveCreatorDisplayName(Long creatorId, String fallbackName) {
if (creatorId == null) {
return fallbackName;
}
SysUser creator = sysUserMapper.selectById(creatorId);
if (creator == null) {
return fallbackName;
}
if (creator.getDisplayName() != null && !creator.getDisplayName().isBlank()) {
return creator.getDisplayName();
}
if (creator.getUsername() != null && !creator.getUsername().isBlank()) {
return creator.getUsername();
}
return fallbackName;
}
private boolean hasAudio(Meeting meeting) {
return meeting.getAudioUrl() != null && !meeting.getAudioUrl().isBlank();
}
private boolean hasAudio(MeetingVO meeting) {
return meeting.getAudioUrl() != null && !meeting.getAudioUrl().isBlank();
}
private boolean isSummaryStage(Integer meetingStatus, AiTask summaryTask) {
return Integer.valueOf(2).equals(meetingStatus) || isRunningSummary(summaryTask);
}
private boolean isAsrStage(Integer meetingStatus, AiTask asrTask, boolean hasAudio, boolean isSummaryStage) {
return (Integer.valueOf(1).equals(meetingStatus) && (asrTask == null || !Integer.valueOf(0).equals(asrTask.getStatus())))
|| isRunningAsr(asrTask)
|| (asrTask == null && hasAudio && !isSummaryStage);
}
private String formatDateTime(LocalDateTime value) {
return value == null ? null : value.toString();
}
private String translateListStage(String stage) {
if (STAGE_SUMMARY_GENERATION.equals(stage)) {
return "llm";
}
if (STAGE_COMPLETED.equals(stage)) {
return "completed";
}
return "transcription";
}
private LoginUser currentLoginUser() {
return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}
private String resolveCreatorName(LoginUser loginUser) {
return loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername();
}
private AndroidAuthContext buildLegacyAuthContext() {
return new AndroidAuthContext();
}
}

View File

@ -0,0 +1,71 @@
package com.imeeting.controller.android.legacy;
import com.imeeting.dto.android.legacy.LegacyApiResponse;
import com.imeeting.dto.android.legacy.LegacyPromptItemResponse;
import com.imeeting.dto.android.legacy.LegacyPromptListResponse;
import com.imeeting.dto.biz.PromptTemplateVO;
import com.imeeting.service.biz.PromptTemplateService;
import com.imeeting.support.AndroidRequestLogHelper;
import com.unisbase.dto.PageResult;
import com.unisbase.security.LoginUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Objects;
@Tag(name = "兼容提示词接口")
@RestController
@RequestMapping("/api/prompts")
@RequiredArgsConstructor
@Slf4j
public class LegacyPromptController {
private static final String LEGACY_MEETING_SCENE = "MEETING_TASK";
private final PromptTemplateService promptTemplateService;
@Operation(summary = "查询场景提示词")
@GetMapping("/active/{scene}")
@PreAuthorize("isAuthenticated()")
public LegacyApiResponse<LegacyPromptListResponse> activePrompts(@PathVariable String scene) {
AndroidRequestLogHelper.logRequest(log, "兼容提示词", "查询场景提示词接口", "scene", scene);
if (!LEGACY_MEETING_SCENE.equals(scene)) {
return LegacyApiResponse.error("400", "scene 仅支持 MEETING_TASK");
}
LoginUser loginUser = currentLoginUser();
PageResult<List<PromptTemplateVO>> result = promptTemplateService.pageTemplates(
1,
1000,
null,
null,
loginUser.getTenantId(),
loginUser.getUserId(),
loginUser.getIsPlatformAdmin(),
loginUser.getIsTenantAdmin()
);
List<PromptTemplateVO> enabledTemplates = result.getRecords() == null
? List.of()
: result.getRecords().stream()
.filter(item -> Integer.valueOf(1).equals(item.getStatus()))
.toList();
Long defaultTemplateId = enabledTemplates.isEmpty() ? null : enabledTemplates.get(0).getId();
List<LegacyPromptItemResponse> prompts = enabledTemplates.stream()
.map(item -> LegacyPromptItemResponse.from(item, Objects.equals(item.getId(), defaultTemplateId)))
.toList();
return LegacyApiResponse.ok(new LegacyPromptListResponse(prompts));
}
private LoginUser currentLoginUser() {
return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}
}

View File

@ -0,0 +1,103 @@
package com.imeeting.controller.android.legacy;
import com.imeeting.dto.android.legacy.LegacyApiResponse;
import com.imeeting.dto.android.legacy.LegacyScreenSaverCatalogResponse;
import com.imeeting.service.android.legacy.LegacyScreenSaverAdapterService;
import com.imeeting.support.AndroidRequestLogHelper;
import com.imeeting.support.TaskSecurityContextRunner;
import com.unisbase.dto.InternalAuthCheckResponse;
import com.unisbase.security.LoginUser;
import com.unisbase.service.TokenValidationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "兼容屏保接口")
@RestController
@RequestMapping("/api/screensavers")
@RequiredArgsConstructor
@Slf4j
public class LegacyScreenSaverController {
private static final String HEADER_AUTHORIZATION = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
private final LegacyScreenSaverAdapterService legacyScreenSaverAdapterService;
private final TokenValidationService tokenValidationService;
private final TaskSecurityContextRunner taskSecurityContextRunner;
@Operation(summary = "查询启用的屏保列表")
@GetMapping("/active")
public LegacyApiResponse<LegacyScreenSaverCatalogResponse> active(HttpServletRequest request) {
AndroidRequestLogHelper.logRequest(log, "兼容屏保", "查询启用屏保列表接口");
LoginUser loginUser = resolveLoginUser(request);
return LegacyApiResponse.ok(queryActive(loginUser));
}
private LegacyScreenSaverCatalogResponse queryActive(LoginUser loginUser) {
if (loginUser == null || loginUser.getUserId() == null || loginUser.getTenantId() == null) {
return legacyScreenSaverAdapterService.getActiveScreenSavers(null);
}
return taskSecurityContextRunner.callAsTenantUser(
loginUser.getTenantId(),
loginUser.getUserId(),
() -> legacyScreenSaverAdapterService.getActiveScreenSavers(loginUser.getUserId())
);
}
private LoginUser resolveLoginUser(HttpServletRequest request) {
LoginUser loginUser = currentLoginUserFromContext();
if (loginUser != null) {
return loginUser;
}
String token = resolveBearerToken(request);
if (!StringUtils.hasText(token)) {
return null;
}
InternalAuthCheckResponse authResult = tokenValidationService.validateAccessToken(token);
if (authResult == null || !authResult.isValid()
|| authResult.getUserId() == null || authResult.getTenantId() == null) {
return null;
}
LoginUser resolved = new LoginUser(
authResult.getUserId(),
authResult.getTenantId(),
authResult.getUsername(),
authResult.getPlatformAdmin(),
authResult.getTenantAdmin(),
authResult.getPermissions()
);
resolved.setDisplayName(authResult.getDisplayName());
return resolved;
}
private LoginUser currentLoginUserFromContext() {
if (SecurityContextHolder.getContext().getAuthentication() == null
|| !(SecurityContextHolder.getContext().getAuthentication().getPrincipal() instanceof LoginUser loginUser)) {
return null;
}
if (loginUser.getUserId() == null || loginUser.getTenantId() == null) {
return null;
}
return loginUser;
}
private String resolveBearerToken(HttpServletRequest request) {
String authorization = request.getHeader(HEADER_AUTHORIZATION);
if (!StringUtils.hasText(authorization) || !authorization.startsWith(BEARER_PREFIX)) {
return null;
}
String token = authorization.substring(BEARER_PREFIX.length()).trim();
return token.isEmpty() ? null : token;
}
}

View File

@ -0,0 +1,219 @@
package com.imeeting.controller.biz;
import com.imeeting.dto.biz.AiLocalProfileVO;
import com.imeeting.dto.biz.AiModelDTO;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.dto.biz.PlatformAsrStatusUpdateCommand;
import com.imeeting.dto.biz.TenantModelDefaultCommand;
import com.imeeting.enums.ModelProviderEnum;
import com.imeeting.service.biz.AiModelService;
import com.unisbase.common.ApiResponse;
import com.unisbase.common.annotation.Log;
import com.unisbase.dto.PageResult;
import com.unisbase.security.LoginUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "AI模型管理")
@RestController
@RequestMapping("/api/biz/aimodel")
public class AiModelController {
private final AiModelService aiModelService;
public AiModelController(AiModelService aiModelService) {
this.aiModelService = aiModelService;
}
@Operation(summary = "新增AI模型")
@PostMapping
@PreAuthorize("isAuthenticated()")
@Log(value = "新增AI模型", type = "AI模型管理")
public ApiResponse<AiModelVO> save(@RequestBody AiModelDTO dto) {
return ApiResponse.ok(aiModelService.saveModel(dto));
}
@Operation(summary = "更新AI模型")
@PutMapping
@PreAuthorize("isAuthenticated()")
@Log(value = "修改AI模型", type = "AI模型管理")
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();
AiModelVO existing = aiModelService.getModelById(dto.getId(), dto.getModelType());
if (existing == null) {
return ApiResponse.error("模型不存在");
}
if (Long.valueOf(0L).equals(existing.getTenantId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
return ApiResponse.error("无权修改系统级模型");
}
return ApiResponse.ok(aiModelService.updateModel(dto));
}
@Operation(summary = "删除AI模型")
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
@Log(value = "删除AI模型", type = "AI模型管理")
public ApiResponse<Boolean> delete(@PathVariable Long id, @RequestParam String type) {
if (type == null || type.isBlank()) {
return ApiResponse.error("模型类型不能为空");
}
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
AiModelVO existing = aiModelService.getModelById(id, type);
if (existing == null) {
return ApiResponse.ok(true);
}
if (Long.valueOf(0L).equals(existing.getTenantId()) && !Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
return ApiResponse.error("无权删除系统级模型");
}
return ApiResponse.ok(aiModelService.removeModelById(id, type));
}
@Operation(summary = "分页查询AI模型")
@GetMapping("/page")
@PreAuthorize("isAuthenticated()")
public ApiResponse<PageResult<List<AiModelVO>>> page(
@RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String name,
@RequestParam(required = false) String type,
@RequestParam(required = false, defaultValue = "false") Boolean tenantEnabledOnly) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
boolean platformAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin());
return ApiResponse.ok(aiModelService.pageModels(current, size, name, type, loginUser.getTenantId(), platformAdmin, Boolean.TRUE.equals(tenantEnabledOnly)));
}
@Operation(summary = "拉取远程模型列表")
@GetMapping("/remote-list")
@PreAuthorize("isAuthenticated()")
public ApiResponse<List<String>> remoteList(
@RequestParam String provider,
@RequestParam String baseUrl,
@RequestParam(required = false) String apiKey) {
return ApiResponse.ok(aiModelService.fetchRemoteModels(provider, baseUrl, apiKey));
}
@Operation(summary = "测试本地模型连通性")
@PostMapping("/local-connectivity-test")
@PreAuthorize("isAuthenticated()")
public ApiResponse<AiLocalProfileVO> testLocalConnectivity(@RequestBody AiModelDTO dto) {
if (dto.getBaseUrl() == null || dto.getBaseUrl().isBlank()) {
return ApiResponse.error("基础地址不能为空");
}
// if (dto.getApiKey() == null || dto.getApiKey().isBlank()) {
// return ApiResponse.error("API 密钥不能为空");
// }
return ApiResponse.ok(aiModelService.testLocalConnectivity(dto.getBaseUrl(), dto.getApiKey()));
}
@Operation(summary = "测试LLM模型连通性")
@PostMapping("/llm-connectivity-test")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> testLlmConnectivity(@RequestBody AiModelDTO dto) {
if (ModelProviderEnum.LOCAL.getCode().equalsIgnoreCase(dto.getProvider()) && (dto.getBaseUrl() == null || dto.getBaseUrl().isBlank())) {
return ApiResponse.error("基础地址不能为空");
}
if (dto.getModelCode() == null || dto.getModelCode().isBlank()) {
return ApiResponse.error("模型名称不能为空");
}
aiModelService.testLlmConnectivity(dto);
return ApiResponse.ok(Boolean.TRUE);
}
@Operation(summary = "获取默认AI模型")
@GetMapping("/default")
@PreAuthorize("isAuthenticated()")
public ApiResponse<AiModelVO> getDefault(@RequestParam String type) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return ApiResponse.ok(aiModelService.getDefaultModel(type, loginUser.getTenantId()));
}
@Operation(summary = "租户启用 ASR")
@PostMapping("/{id}/tenant-enable")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> tenantEnable(@PathVariable Long id,
@RequestParam(required = false, defaultValue = "ASR") String type) {
LoginUser loginUser = getLoginUser();
if (loginUser == null || loginUser.getTenantId() == null) {
return ApiResponse.error("未获取到用户信息");
}
aiModelService.enableModelForTenant(type, loginUser.getTenantId(), id);
return ApiResponse.ok(Boolean.TRUE);
}
@Operation(summary = "租户关闭 ASR")
@PostMapping("/{id}/tenant-disable")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> tenantDisable(@PathVariable Long id,
@RequestParam(required = false, defaultValue = "ASR") String type) {
LoginUser loginUser = getLoginUser();
if (loginUser == null || loginUser.getTenantId() == null) {
return ApiResponse.error("未获取到用户信息");
}
aiModelService.disableModelForTenant(type, loginUser.getTenantId(), id);
return ApiResponse.ok(Boolean.TRUE);
}
@Operation(summary = "平台更新 ASR 状态")
@PostMapping("/{id}/platform-status")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> updatePlatformStatus(@PathVariable Long id,
@RequestBody PlatformAsrStatusUpdateCommand command,
@RequestParam(required = false, defaultValue = "ASR") String type) {
LoginUser loginUser = getLoginUser();
if (loginUser == null) {
return ApiResponse.error("未获取到用户信息");
}
aiModelService.updatePlatformModelStatus(type, id, command.getStatus(), Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()));
return ApiResponse.ok(Boolean.TRUE);
}
@Operation(summary = "同步当前 ASR 声纹")
@PostMapping("/current/sync-speakers")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> syncCurrentSpeakers() {
LoginUser loginUser = getLoginUser();
if (loginUser == null || loginUser.getTenantId() == null) {
return ApiResponse.error("未获取到用户信息");
}
aiModelService.syncCurrentTenantActiveAsrSpeakers(loginUser.getTenantId());
return ApiResponse.ok(Boolean.TRUE);
}
@Operation(summary = "绉熸埛璁剧疆榛樿妯″瀷")
@PostMapping("/{id}/tenant-default")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> setTenantDefault(@PathVariable Long id,
@RequestBody TenantModelDefaultCommand command) {
LoginUser loginUser = getLoginUser();
if (loginUser == null || loginUser.getTenantId() == null) {
return ApiResponse.error("鏈幏鍙栧埌鐢ㄦ埛淇℃伅");
}
aiModelService.setDefaultModelForTenant(command.getModelType(), loginUser.getTenantId(), id);
return ApiResponse.ok(Boolean.TRUE);
}
private LoginUser getLoginUser() {
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principal instanceof LoginUser loginUser) {
return loginUser;
}
return null;
}
}

View File

@ -0,0 +1,90 @@
package com.imeeting.controller.biz;
import com.imeeting.dto.biz.ClientDownloadDTO;
import com.imeeting.entity.biz.ClientDownload;
import com.imeeting.service.biz.ClientDownloadService;
import com.unisbase.common.ApiResponse;
import com.unisbase.common.annotation.Log;
import com.unisbase.security.LoginUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Tag(name = "客户端下载管理")
@RestController
@RequestMapping("/api/clients")
@RequiredArgsConstructor
public class ClientDownloadController {
private final ClientDownloadService clientDownloadService;
@Operation(summary = "查询客户端下载包列表")
@GetMapping
@PreAuthorize("isAuthenticated()")
public ApiResponse<Map<String, Object>> list(@RequestParam(value = "platformCode", required = false) String platformCode,
@RequestParam(value = "status", required = false) Integer status,
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "size", defaultValue = "50") Integer size) {
List<ClientDownload> clients = clientDownloadService.listForAdmin(currentLoginUser(), platformCode, status);
Map<String, Object> data = new HashMap<>();
data.put("clients", clients);
data.put("total", clients.size());
data.put("page", page);
data.put("size", size);
return ApiResponse.ok(data);
}
@Operation(summary = "新增客户端下载包")
@PostMapping
@PreAuthorize("isAuthenticated()")
@Log(value = "新增客户端下载包", type = "客户端下载管理")
public ApiResponse<ClientDownload> create(@RequestBody ClientDownloadDTO dto) {
return ApiResponse.ok(clientDownloadService.create(dto, currentLoginUser()));
}
@Operation(summary = "修改客户端下载包")
@PutMapping("/{id}")
@PreAuthorize("isAuthenticated()")
@Log(value = "修改客户端下载包", type = "客户端下载管理")
public ApiResponse<ClientDownload> update(@PathVariable Long id, @RequestBody ClientDownloadDTO dto) {
return ApiResponse.ok(clientDownloadService.update(id, dto, currentLoginUser()));
}
@Operation(summary = "删除客户端下载包")
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
@Log(value = "删除客户端下载包", type = "客户端下载管理")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
clientDownloadService.removeClient(id, currentLoginUser());
return ApiResponse.ok(true);
}
@Operation(summary = "上传客户端安装包")
@PostMapping("/upload")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Map<String, Object>> upload(@RequestParam("platformCode") String platformCode,
@RequestParam("file") MultipartFile file) throws IOException {
return ApiResponse.ok(clientDownloadService.uploadPackage(platformCode, file));
}
private LoginUser currentLoginUser() {
return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}
}

View File

@ -0,0 +1,45 @@
package com.imeeting.controller.biz;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.service.biz.MeetingQueryService;
import com.unisbase.common.ApiResponse;
import com.unisbase.security.LoginUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@Tag(name = "工作台")
@RestController
@RequestMapping("/api/biz/dashboard")
public class DashboardController {
private final MeetingQueryService meetingQueryService;
public DashboardController(MeetingQueryService meetingQueryService) {
this.meetingQueryService = meetingQueryService;
}
@Operation(summary = "获取工作台统计数据")
@GetMapping("/stats")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Map<String, Object>> getStats() {
LoginUser user = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
boolean isAdmin = Boolean.TRUE.equals(user.getIsPlatformAdmin()) || Boolean.TRUE.equals(user.getIsTenantAdmin());
return ApiResponse.ok(meetingQueryService.getDashboardStats(user.getTenantId(), user.getUserId(), isAdmin));
}
@Operation(summary = "获取最近会议列表")
@GetMapping("/recent")
@PreAuthorize("isAuthenticated()")
public ApiResponse<List<MeetingVO>> getRecent() {
LoginUser user = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
boolean isAdmin = Boolean.TRUE.equals(user.getIsPlatformAdmin()) || Boolean.TRUE.equals(user.getIsTenantAdmin());
return ApiResponse.ok(meetingQueryService.getRecentMeetings(user.getTenantId(), user.getUserId(), isAdmin, 10));
}
}

View File

@ -0,0 +1,77 @@
package com.imeeting.controller.biz;
import com.imeeting.dto.biz.DeviceAdminUpdateCommand;
import com.imeeting.dto.biz.DeviceOnlineAdminVO;
import com.imeeting.service.biz.DeviceOnlineManagementService;
import com.unisbase.common.ApiResponse;
import com.unisbase.common.annotation.Log;
import com.unisbase.security.LoginUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "设备在线管理")
@RestController
@RequestMapping("/api/admin/devices")
@RequiredArgsConstructor
public class DeviceManagementController {
private final DeviceOnlineManagementService deviceOnlineManagementService;
@Operation(summary = "查询设备在线管理列表")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "返回设备在线管理列表",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = DeviceOnlineAdminVO.class)))
)
})
@GetMapping
public ApiResponse<List<DeviceOnlineAdminVO>> list() {
return ApiResponse.ok(deviceOnlineManagementService.listForAdmin(currentLoginUser()));
}
@Operation(summary = "更新设备管理信息")
@PutMapping("/{id}")
@Log(value = "修改设备管理信息", type = "设备在线管理")
public ApiResponse<DeviceOnlineAdminVO> update(@PathVariable Long id, @RequestBody DeviceAdminUpdateCommand command) {
return ApiResponse.ok(deviceOnlineManagementService.update(id, command, currentLoginUser()));
}
@Operation(summary = "踢下线设备")
@PostMapping("/{id}/kick")
public ApiResponse<Boolean> kick(@PathVariable Long id) {
return ApiResponse.ok(deviceOnlineManagementService.kick(id, currentLoginUser()));
}
@Operation(summary = "删除设备并解绑授权")
@DeleteMapping("/{id}")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
return ApiResponse.ok(deviceOnlineManagementService.delete(id, currentLoginUser()));
}
@Operation(summary = "重置设备首页统计")
@PostMapping("/{id}/reset")
public ApiResponse<Boolean> reset(@PathVariable Long id) {
return ApiResponse.ok(deviceOnlineManagementService.resetStats(id, currentLoginUser()));
}
private LoginUser currentLoginUser() {
return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}
}

View File

@ -0,0 +1,87 @@
package com.imeeting.controller.biz;
import com.imeeting.dto.biz.ExternalAppDTO;
import com.imeeting.entity.biz.ExternalApp;
import com.imeeting.service.biz.ExternalAppService;
import com.unisbase.common.ApiResponse;
import com.unisbase.common.annotation.Log;
import com.unisbase.security.LoginUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
import java.util.Map;
@Tag(name = "外部应用管理")
@RestController
@RequestMapping("/api/external-apps")
@RequiredArgsConstructor
public class ExternalAppController {
private final ExternalAppService externalAppService;
@Operation(summary = "查询外部应用列表")
@GetMapping
@PreAuthorize("isAuthenticated()")
public ApiResponse<List<Map<String, Object>>> list(@RequestParam(value = "appType", required = false) String appType,
@RequestParam(value = "status", required = false) Integer status) {
return ApiResponse.ok(externalAppService.listForAdmin(currentLoginUser(), appType, status));
}
@Operation(summary = "新增外部应用")
@PostMapping
@PreAuthorize("isAuthenticated()")
@Log(value = "新增外部应用", type = "外部应用管理")
public ApiResponse<ExternalApp> create(@RequestBody ExternalAppDTO dto) {
return ApiResponse.ok(externalAppService.create(dto, currentLoginUser()));
}
@Operation(summary = "修改外部应用")
@PutMapping("/{id}")
@PreAuthorize("isAuthenticated()")
@Log(value = "修改外部应用", type = "外部应用管理")
public ApiResponse<ExternalApp> update(@PathVariable Long id, @RequestBody ExternalAppDTO dto) {
return ApiResponse.ok(externalAppService.update(id, dto, currentLoginUser()));
}
@Operation(summary = "删除外部应用")
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
@Log(value = "删除外部应用", type = "外部应用管理")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
externalAppService.removeApp(id, currentLoginUser());
return ApiResponse.ok(true);
}
@Operation(summary = "上传外部应用APK")
@PostMapping("/upload-apk")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Map<String, Object>> uploadApk(@RequestParam("apkFile") MultipartFile apkFile) throws IOException {
return ApiResponse.ok(externalAppService.uploadApk(apkFile));
}
@Operation(summary = "上传外部应用图标")
@PostMapping("/upload-icon")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Map<String, Object>> uploadIcon(@RequestParam("iconFile") MultipartFile iconFile) throws IOException {
return ApiResponse.ok(externalAppService.uploadIcon(iconFile));
}
private LoginUser currentLoginUser() {
return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}
}

View File

@ -0,0 +1,164 @@
package com.imeeting.controller.biz;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.imeeting.dto.biz.HotWordBatchCreateDTO;
import com.imeeting.dto.biz.HotWordBatchCreateResultVO;
import com.imeeting.dto.biz.HotWordBatchGroupDTO;
import com.imeeting.dto.biz.HotWordDTO;
import com.imeeting.dto.biz.HotWordVO;
import com.imeeting.entity.biz.HotWord;
import com.imeeting.entity.biz.HotWordGroup;
import com.imeeting.service.biz.HotWordGroupService;
import com.imeeting.service.biz.HotWordService;
import com.unisbase.common.ApiResponse;
import com.unisbase.common.annotation.Log;
import com.unisbase.dto.PageResult;
import com.unisbase.security.LoginUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@Tag(name = "热词管理")
@RestController
@RequestMapping("/api/biz/hotword")
public class HotWordController {
private final HotWordService hotWordService;
private final HotWordGroupService hotWordGroupService;
public HotWordController(HotWordService hotWordService, HotWordGroupService hotWordGroupService) {
this.hotWordService = hotWordService;
this.hotWordGroupService = hotWordGroupService;
}
private Long resolveTargetTenantId(LoginUser loginUser, Long tenantId) {
if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && Long.valueOf(0L).equals(tenantId)) {
return 0L;
}
return loginUser.getTenantId();
}
@Operation(summary = "新增热词")
@PostMapping
@PreAuthorize("isAuthenticated()")
@Log(value = "新增热词", type = "热词管理")
public ApiResponse<HotWordVO> save(@RequestBody HotWordDTO hotWordDTO) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = resolveTargetTenantId(loginUser, hotWordDTO.getTenantId());
return ApiResponse.ok(hotWordService.saveHotWord(hotWordDTO, loginUser.getUserId(), targetTenantId));
}
@Operation(summary = "批量新增热词")
@PostMapping("/batch")
@PreAuthorize("isAuthenticated()")
@Log(value = "批量新增热词", type = "热词管理")
public ApiResponse<HotWordBatchCreateResultVO> saveBatch(@RequestBody HotWordBatchCreateDTO dto) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = resolveTargetTenantId(loginUser, dto.getTenantId());
boolean platformAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin());
return ApiResponse.ok(
hotWordService.saveHotWordsBatch(dto, loginUser.getUserId(), targetTenantId, platformAdmin));
}
@Operation(summary = "修改热词")
@PutMapping
@PreAuthorize("isAuthenticated()")
@Log(value = "修改热词", type = "热词管理")
public ApiResponse<HotWordVO> update(@RequestBody HotWordDTO hotWordDTO) {
HotWord existing = hotWordService.getById(hotWordDTO.getId());
if (existing == null) {
return ApiResponse.error("热词不存在");
}
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return ApiResponse.ok(hotWordService.updateHotWord(hotWordDTO, loginUser.getUserId(), existing.getTenantId()));
}
@Operation(summary = "批量修改热词分组")
@PutMapping("/group/batch")
@PreAuthorize("isAuthenticated()")
@Log(value = "批量修改热词分组", type = "热词管理")
public ApiResponse<Integer> updateGroupBatch(@RequestBody HotWordBatchGroupDTO dto) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = resolveTargetTenantId(loginUser, dto.getTenantId());
return ApiResponse.ok(hotWordService.updateHotWordGroupBatch(dto.getIds(), dto.getHotWordGroupId(), targetTenantId));
}
@Operation(summary = "删除热词")
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
@Log(value = "删除热词", type = "热词管理")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
HotWord existing = hotWordService.getById(id);
if (existing == null) {
return ApiResponse.ok(true);
}
return ApiResponse.ok(hotWordService.removeById(id));
}
@Operation(summary = "分页查询热词")
@GetMapping("/page")
@PreAuthorize("isAuthenticated()")
public ApiResponse<PageResult<List<HotWordVO>>> page(
@RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String word,
@RequestParam(required = false) String category,
@RequestParam(required = false) Long hotWordGroupId,
@RequestParam(required = false) Boolean ungrouped,
@RequestParam(required = false) Long tenantId) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) ? tenantId : null;
LambdaQueryWrapper<HotWord> wrapper = new LambdaQueryWrapper<HotWord>()
.like(word != null && !word.isEmpty(), HotWord::getWord, word)
.eq(category != null && !category.isEmpty(), HotWord::getCategory, category)
.eq(hotWordGroupId != null, HotWord::getHotWordGroupId, hotWordGroupId)
.isNull(Boolean.TRUE.equals(ungrouped), HotWord::getHotWordGroupId)
.orderByDesc(HotWord::getCreatedAt);
wrapper.eq(targetTenantId != null, HotWord::getTenantId, targetTenantId);
Page<HotWord> page = hotWordService.page(new Page<>(current, size), wrapper);
List<HotWordVO> vos = page.getRecords().stream().map(this::toVO).collect(Collectors.toList());
PageResult<List<HotWordVO>> result = new PageResult<>();
result.setTotal(page.getTotal());
result.setRecords(vos);
return ApiResponse.ok(result);
}
@Operation(summary = "生成热词拼音")
@GetMapping("/pinyin")
@PreAuthorize("isAuthenticated()")
public ApiResponse<List<String>> getPinyin(@RequestParam String word) {
return ApiResponse.ok(hotWordService.generatePinyin(word));
}
private HotWordVO toVO(HotWord entity) {
HotWordVO vo = new HotWordVO();
vo.setId(entity.getId());
vo.setWord(entity.getWord());
vo.setPinyinList(entity.getPinyinList());
vo.setMatchStrategy(entity.getMatchStrategy());
vo.setCategory(entity.getCategory());
vo.setHotWordGroupId(entity.getHotWordGroupId());
vo.setWeight(entity.getWeight());
vo.setStatus(entity.getStatus());
vo.setIsPublic(1);
vo.setCreatorId(entity.getCreatorId());
vo.setIsSynced(entity.getIsSynced());
vo.setRemark(entity.getRemark());
vo.setCreatedAt(entity.getCreatedAt());
vo.setUpdatedAt(entity.getUpdatedAt());
if (entity.getHotWordGroupId() != null) {
HotWordGroup group = hotWordGroupService.getById(entity.getHotWordGroupId());
vo.setHotWordGroupName(group == null ? null : group.getGroupName());
}
return vo;
}
}

View File

@ -0,0 +1,91 @@
package com.imeeting.controller.biz;
import com.imeeting.dto.biz.HotWordGroupDTO;
import com.imeeting.dto.biz.HotWordGroupVO;
import com.imeeting.service.biz.HotWordGroupService;
import com.unisbase.common.ApiResponse;
import com.unisbase.common.annotation.Log;
import com.unisbase.dto.PageResult;
import com.unisbase.security.LoginUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "热词组管理")
@RestController
@RequestMapping("/api/biz/hotword-group")
public class HotWordGroupController {
private final HotWordGroupService hotWordGroupService;
public HotWordGroupController(HotWordGroupService hotWordGroupService) {
this.hotWordGroupService = hotWordGroupService;
}
private Long resolveTargetTenantId(LoginUser loginUser) {
return loginUser.getTenantId();
}
@Operation(summary = "新增热词组")
@PostMapping
@PreAuthorize("isAuthenticated()")
@Log(value = "新增热词组", type = "热词组管理")
public ApiResponse<HotWordGroupVO> save(@RequestBody HotWordGroupDTO dto) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = resolveTargetTenantId(loginUser);
return ApiResponse.ok(hotWordGroupService.saveGroup(dto, loginUser.getUserId(), targetTenantId));
}
@Operation(summary = "修改热词组")
@PutMapping
@PreAuthorize("isAuthenticated()")
@Log(value = "修改热词组", type = "热词组管理")
public ApiResponse<HotWordGroupVO> update(@RequestBody HotWordGroupDTO dto) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = resolveTargetTenantId(loginUser);
HotWordGroupVO existing = hotWordGroupService.listVisibleOptions(targetTenantId).stream()
.filter(item -> item.getId().equals(dto.getId()))
.findFirst()
.orElse(null);
if (existing == null) {
return ApiResponse.error("热词组不存在");
}
return ApiResponse.ok(hotWordGroupService.updateGroup(dto));
}
@Operation(summary = "删除热词组")
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
@Log(value = "删除热词组", type = "热词组管理")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = resolveTargetTenantId(loginUser);
return ApiResponse.ok(hotWordGroupService.removeGroupById(id, targetTenantId));
}
@Operation(summary = "分页查询热词组")
@GetMapping("/page")
@PreAuthorize("isAuthenticated()")
public ApiResponse<PageResult<List<HotWordGroupVO>>> page(
@RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String name,
@RequestParam(required = false) Integer status) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = resolveTargetTenantId(loginUser);
return ApiResponse.ok(hotWordGroupService.pageGroups(current, size, name, status, targetTenantId));
}
@Operation(summary = "查询热词组选项")
@GetMapping("/options")
@PreAuthorize("isAuthenticated()")
public ApiResponse<List<HotWordGroupVO>> options() {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = resolveTargetTenantId(loginUser);
return ApiResponse.ok(hotWordGroupService.listVisibleOptions(targetTenantId));
}
}

View File

@ -0,0 +1,45 @@
package com.imeeting.controller.biz;
import com.imeeting.dto.biz.LicenseImportResultVO;
import com.imeeting.dto.biz.LicenseVO;
import com.imeeting.service.biz.LicenseService;
import com.unisbase.common.ApiResponse;
import com.unisbase.security.LoginUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
@Tag(name = "租户授权管理")
@RestController
@RequestMapping("/api/admin/licenses")
@RequiredArgsConstructor
public class LicenseManagementController {
private final LicenseService licenseService;
@Operation(summary = "查询当前租户授权列表")
@GetMapping
public ApiResponse<List<LicenseVO>> list() {
return ApiResponse.ok(licenseService.listCurrentTenantLicenses(currentLoginUser()));
}
@Operation(summary = "导入当前租户正式授权")
@PostMapping("/import")
public ApiResponse<LicenseImportResultVO> importLicenses(@RequestParam("file") MultipartFile file) throws IOException {
return ApiResponse.ok(licenseService.importFormalLicenses(file, currentLoginUser()));
}
private LoginUser currentLoginUser() {
return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}
}

View File

@ -0,0 +1,649 @@
package com.imeeting.controller.biz;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.common.MeetingConstants;
import com.imeeting.common.SysParamKeys;
import com.imeeting.dto.biz.CreateMeetingCommand;
import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
import com.imeeting.dto.biz.MeetingCreateConfigVO;
import com.imeeting.dto.biz.MeetingResummaryDTO;
import com.imeeting.dto.biz.MeetingSummaryOrchestrationTriggerResultVO;
import com.imeeting.dto.biz.MeetingSpeakerUpdateDTO;
import com.imeeting.dto.biz.MeetingSummaryExportResult;
import com.imeeting.dto.biz.MeetingTranscriptExportResult;
import com.imeeting.dto.biz.MeetingTranscriptVO;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.OpenRealtimeSocketSessionCommand;
import com.imeeting.dto.biz.RealtimeMeetingCompleteDTO;
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
import com.imeeting.dto.biz.RealtimeSocketSessionVO;
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
import com.imeeting.dto.biz.UpdateMeetingBasicCommand;
import com.imeeting.dto.biz.UpdateMeetingParticipantsCommand;
import com.imeeting.dto.biz.UpdateMeetingSummaryCommand;
import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.enums.MeetingTerminalEnum;
import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.MeetingAccessService;
import com.imeeting.service.biz.MeetingCommandService;
import com.imeeting.service.biz.MeetingExportService;
import com.imeeting.service.biz.MeetingProgressService;
import com.imeeting.service.biz.MeetingQueryService;
import com.imeeting.service.biz.MeetingTranscriptFileService;
import com.imeeting.service.biz.MeetingUnifiedStatusService;
import com.imeeting.service.biz.PromptTemplateService;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.service.biz.RealtimeMeetingSocketSessionService;
import com.imeeting.service.biz.impl.MeetingAudioUploadSupport;
import com.unisbase.common.ApiResponse;
import com.unisbase.common.annotation.Log;
import com.unisbase.dto.PageResult;
import com.unisbase.security.LoginUser;
import com.unisbase.service.SysParamService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Tag(name = "会议管理")
@RestController
@RequestMapping("/api/biz/meeting")
public class MeetingController {
private final MeetingQueryService meetingQueryService;
private final MeetingCommandService meetingCommandService;
private final MeetingAccessService meetingAccessService;
private final MeetingExportService meetingExportService;
private final MeetingTranscriptFileService meetingTranscriptFileService;
private final PromptTemplateService promptTemplateService;
private final RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService;
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
private final MeetingAudioUploadSupport meetingAudioUploadSupport;
private final MeetingProgressService meetingProgressService;
private final SysParamService sysParamService;
private final AiTaskService aiTaskService;
private final MeetingUnifiedStatusService meetingUnifiedStatusService;
@Value("${imeeting.h5.base-url:}")
private String h5BaseUrl;
@Autowired
public MeetingController(MeetingQueryService meetingQueryService,
MeetingCommandService meetingCommandService,
MeetingAccessService meetingAccessService,
MeetingExportService meetingExportService,
MeetingTranscriptFileService meetingTranscriptFileService,
PromptTemplateService promptTemplateService,
RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService,
RealtimeMeetingSessionStateService realtimeMeetingSessionStateService,
MeetingAudioUploadSupport meetingAudioUploadSupport,
MeetingProgressService meetingProgressService,
SysParamService sysParamService,
AiTaskService aiTaskService,
MeetingUnifiedStatusService meetingUnifiedStatusService) {
this.meetingQueryService = meetingQueryService;
this.meetingCommandService = meetingCommandService;
this.meetingAccessService = meetingAccessService;
this.meetingExportService = meetingExportService;
this.meetingTranscriptFileService = meetingTranscriptFileService;
this.promptTemplateService = promptTemplateService;
this.realtimeMeetingSocketSessionService = realtimeMeetingSocketSessionService;
this.realtimeMeetingSessionStateService = realtimeMeetingSessionStateService;
this.meetingAudioUploadSupport = meetingAudioUploadSupport;
this.meetingProgressService = meetingProgressService;
this.sysParamService = sysParamService;
this.aiTaskService = aiTaskService;
this.meetingUnifiedStatusService = meetingUnifiedStatusService;
}
@Operation(summary = "查询会议处理进度")
@GetMapping("/{id}/progress")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Map<String, Object>> getProgress(@PathVariable Long id) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanViewMeeting(meeting, loginUser);
Map<String, Object> progress = meetingProgressService.getProgressMap(id);
if ("Waiting...".equals(progress.get("message"))) {
AiTask asrTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, id)
.eq(AiTask::getTaskType, "ASR")
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
if (asrTask != null && Integer.valueOf(0).equals(asrTask.getStatus())) {
return ApiResponse.ok(Map.of("percent", 0, "message", "排队中,等待 ASR 执行名额..."));
}
if (asrTask != null && Integer.valueOf(1).equals(asrTask.getStatus())) {
return ApiResponse.ok(Map.of("percent", 5, "message", "识别中,等待进度刷新..."));
}
}
Map<String, Object> payload = new LinkedHashMap<>(progress);
payload.put("unifiedStatus", meetingUnifiedStatusService.resolve(id));
return ApiResponse.ok(payload);
}
@Operation(summary = "批量查询会议处理进度")
@PostMapping("/progress/batch")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Map<Long, Map<String, Object>>> getProgressBatch(@RequestBody List<Long> ids) {
LoginUser loginUser = currentLoginUser();
Map<Long, Map<String, Object>> result = new LinkedHashMap<>();
if (ids == null || ids.isEmpty()) {
return ApiResponse.ok(result);
}
for (Long id : ids) {
if (id == null) {
continue;
}
try {
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanViewMeeting(meeting, loginUser);
Map<String, Object> progress = meetingProgressService.getProgressMap(id);
if ("Waiting...".equals(progress.get("message"))) {
AiTask asrTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, id)
.eq(AiTask::getTaskType, "ASR")
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
if (asrTask != null && Integer.valueOf(0).equals(asrTask.getStatus())) {
progress = Map.of("percent", 0, "message", "排队中,等待 ASR 执行名额...");
} else if (asrTask != null && Integer.valueOf(1).equals(asrTask.getStatus())) {
progress = Map.of("percent", 5, "message", "识别中,等待进度刷新...");
}
}
Map<String, Object> payload = new LinkedHashMap<>(progress);
payload.put("unifiedStatus", meetingUnifiedStatusService.resolve(id));
result.put(id, payload);
} catch (RuntimeException ignored) {
// Ignore inaccessible meetings in batch mode.
}
}
return ApiResponse.ok(result);
}
@Operation(summary = "重新调度排队中的会议 ASR 任务")
@PostMapping("/{id}/retry-schedule")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> retrySchedule(@PathVariable Long id) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
return ApiResponse.ok(aiTaskService.retryScheduleMeeting(id));
}
@Operation(summary = "上传会议音频")
@PostMapping("/upload")
@PreAuthorize("isAuthenticated()")
public ApiResponse<String> upload(@RequestParam("file") MultipartFile file) throws IOException {
return ApiResponse.ok(meetingAudioUploadSupport.storeUploadedAudio(file));
}
@Operation(summary = "获取会议创建配置")
@GetMapping("/create-config")
@PreAuthorize("isAuthenticated()")
public ApiResponse<MeetingCreateConfigVO> getCreateConfig() {
MeetingCreateConfigVO vo = new MeetingCreateConfigVO();
vo.setOfflineEnabled(resolveBooleanParam(SysParamKeys.MEETING_CREATE_OFFLINE_ENABLED, true));
vo.setRealtimeEnabled(resolveBooleanParam(SysParamKeys.MEETING_CREATE_REALTIME_ENABLED, true));
vo.setAiCatalogEnabled(resolveBooleanParam(SysParamKeys.MEETING_AI_CATALOG_ENABLED, false));
vo.setOfflineAudioMaxSizeMb(resolveLongParam(SysParamKeys.MEETING_OFFLINE_AUDIO_MAX_SIZE_MB, 1024L));
return ApiResponse.ok(vo);
}
@Operation(summary = "获取会议分享配置")
@GetMapping("/share-config")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Map<String, String>> getShareConfig(@RequestParam(name = "meetingId", required = false) Long meetingId) {
String baseUrl = StringUtils.hasText(h5BaseUrl) ? h5BaseUrl.trim() : "";
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
}
Map<String, String> result = new HashMap<>();
result.put("h5BaseUrl", baseUrl);
if (meetingId != null) {
result.put("h5PreviewUrl", baseUrl + StrUtil.format("/meetings/{}/preview", meetingId));
}
return ApiResponse.ok(result);
}
@Operation(summary = "创建离线会议")
@PostMapping
@PreAuthorize("isAuthenticated()")
@Log(value = "新增离线会议", type = "会议管理")
public ApiResponse<MeetingVO> create(@Valid @RequestBody CreateMeetingCommand command) {
LoginUser loginUser = currentLoginUser();
assertPromptAvailable(command.getPromptId(), loginUser);
return ApiResponse.ok(meetingCommandService.createMeeting(
command,
loginUser.getTenantId(),
loginUser.getUserId(),
resolveCreatorName(loginUser),
MeetingTerminalEnum.WEB.getCode()
));
}
@Operation(summary = "创建实时会议")
@PostMapping("/realtime/start")
@PreAuthorize("isAuthenticated()")
@Log(value = "新增实时会议", type = "会议管理")
public ApiResponse<MeetingVO> createRealtime(@Valid @RequestBody CreateRealtimeMeetingCommand command) {
LoginUser loginUser = currentLoginUser();
assertPromptAvailable(command.getPromptId(), loginUser);
return ApiResponse.ok(meetingCommandService.createRealtimeMeeting(
command,
loginUser.getTenantId(),
loginUser.getUserId(),
resolveCreatorName(loginUser),
MeetingTerminalEnum.WEB.getCode()
));
}
@Operation(summary = "分页查询会议")
@GetMapping("/page")
@PreAuthorize("isAuthenticated()")
public ApiResponse<PageResult<List<MeetingVO>>> page(
@RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String title,
@RequestParam(defaultValue = "all") String viewType,
@RequestParam(required = false) Integer status) {
LoginUser loginUser = currentLoginUser();
boolean isAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin());
return ApiResponse.ok(meetingQueryService.pageMeetings(
current,
size,
title,
loginUser.getTenantId(),
loginUser.getUserId(),
resolveCreatorName(loginUser),
viewType,
status,
isAdmin
));
}
@Operation(summary = "查询会议详情")
@GetMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<MeetingVO> getDetail(@PathVariable Long id) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanViewMeeting(meeting, loginUser);
return ApiResponse.ok(meetingQueryService.getDetail(id));
}
@Operation(summary = "导出会议摘要")
@GetMapping("/{id}/summary/export")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<byte[]> exportSummary(@PathVariable Long id, @RequestParam(defaultValue = "pdf") String format) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanExportMeeting(meeting, loginUser);
MeetingVO meetingDetail = meetingQueryService.getDetail(id);
if (meetingDetail == null) {
throw new RuntimeException("会议不存在");
}
MeetingSummaryExportResult exportResult = meetingExportService.exportSummary(meeting, meetingDetail, format, loginUser);
String encodedFilename = URLEncoder.encode(exportResult.getFileName(), StandardCharsets.UTF_8).replace("+", "%20");
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedFilename)
.contentType(MediaType.parseMediaType(exportResult.getContentType()))
.body(exportResult.getContent());
}
@Operation(summary = "查询会议转写记录")
@GetMapping("/{id}/transcripts")
@PreAuthorize("isAuthenticated()")
public ApiResponse<List<MeetingTranscriptVO>> getTranscripts(@PathVariable Long id) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanViewMeeting(meeting, loginUser);
return ApiResponse.ok(meetingQueryService.getTranscripts(id));
}
@Operation(summary = "查询会议章节")
@GetMapping("/{id}/chapters")
@PreAuthorize("isAuthenticated()")
public ApiResponse<List<Map<String, Object>>> getChapters(@PathVariable Long id) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanViewMeeting(meeting, loginUser);
return ApiResponse.ok(meetingQueryService.getChapters(id));
}
@Operation(summary = "下载会议转录 Markdown")
@GetMapping("/{id}/transcripts/export")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<byte[]> exportTranscripts(@PathVariable Long id) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanExportMeeting(meeting, loginUser);
MeetingVO meetingDetail = meetingQueryService.getDetail(id);
if (meetingDetail == null) {
throw new RuntimeException("会议不存在");
}
MeetingTranscriptExportResult exportResult = meetingTranscriptFileService.exportTranscript(meeting, meetingDetail);
String encodedFilename = URLEncoder.encode(exportResult.getFileName(), StandardCharsets.UTF_8).replace("+", "%20");
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedFilename)
.contentType(MediaType.parseMediaType(exportResult.getContentType()))
.body(exportResult.getContent());
}
@Operation(summary = "查询实时会议状态")
@GetMapping("/{id}/realtime/session-status")
@PreAuthorize("isAuthenticated()")
public ApiResponse<RealtimeMeetingSessionStatusVO> getRealtimeSessionStatus(@PathVariable Long id) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanManageRealtimeMeeting(meeting, loginUser);
return ApiResponse.ok(realtimeMeetingSessionStateService.getStatus(id));
}
@Operation(summary = "批量查询实时会议状态")
@PostMapping("/realtime/session-status/batch")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Map<Long, RealtimeMeetingSessionStatusVO>> getRealtimeSessionStatuses(@RequestBody List<Long> ids) {
LoginUser loginUser = currentLoginUser();
Map<Long, RealtimeMeetingSessionStatusVO> result = new LinkedHashMap<>();
if (ids == null || ids.isEmpty()) {
return ApiResponse.ok(result);
}
Map<Long, RealtimeMeetingSessionStatusVO> statuses = realtimeMeetingSessionStateService.getStatuses(ids);
for (Long id : ids) {
if (id == null) {
continue;
}
try {
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanManageRealtimeMeeting(meeting, loginUser);
RealtimeMeetingSessionStatusVO status = statuses.get(id);
if (status != null) {
result.put(id, status);
}
} catch (RuntimeException ignored) {
// Preserve previous per-item fallback behavior for inaccessible meetings.
}
}
return ApiResponse.ok(result);
}
@Operation(summary = "暂停实时会议")
@PostMapping("/{id}/realtime/pause")
@PreAuthorize("isAuthenticated()")
public ApiResponse<RealtimeMeetingSessionStatusVO> pauseRealtimeMeeting(@PathVariable Long id) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingTerminalEnum.WEB.getCode());
return ApiResponse.ok(realtimeMeetingSessionStateService.pause(id));
}
@Operation(summary = "打开实时会议Socket会话")
@PostMapping("/{id}/realtime/socket-session")
@PreAuthorize("isAuthenticated()")
public ApiResponse<RealtimeSocketSessionVO> openRealtimeSocketSession(@PathVariable Long id,
@RequestBody OpenRealtimeSocketSessionCommand command) {
LoginUser loginUser = currentLoginUser();
return ApiResponse.ok(realtimeMeetingSocketSessionService.createSession(
id,
command.getAsrModelId(),
command.getMode(),
command.getLanguage(),
command.getUseSpkId(),
command.getEnablePunctuation(),
command.getEnableItn(),
command.getEnableTextRefine(),
command.getSaveAudio(),
command.getHotWordGroupId(),
loginUser
));
}
@Operation(summary = "完成实时会议")
@PostMapping("/{id}/realtime/complete")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> completeRealtimeMeeting(@PathVariable Long id, @RequestBody(required = false) RealtimeMeetingCompleteDTO dto) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingTerminalEnum.WEB.getCode());
meetingCommandService.completeRealtimeMeeting(
id,
dto != null ? dto.getAudioUrl() : null,
dto != null && Boolean.TRUE.equals(dto.getOverwriteAudio())
);
return ApiResponse.ok(true);
}
@Operation(summary = "更新会议讲话人")
@PutMapping("/speaker")
@PreAuthorize("isAuthenticated()")
@Log(value = "修改会议讲话人", type = "会议管理")
public ApiResponse<Boolean> updateSpeaker(@RequestBody MeetingSpeakerUpdateDTO dto) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(dto.getMeetingId());
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
meetingCommandService.updateSpeakerInfo(dto.getMeetingId(), dto.getSpeakerId(), dto.getNewName(), dto.getLabel());
return ApiResponse.ok(true);
}
@Operation(summary = "更新会议转写")
@PutMapping("/{id}/transcripts/{transcriptId}")
@PreAuthorize("isAuthenticated()")
@Log(value = "修改会议转写", type = "会议管理")
public ApiResponse<Boolean> updateTranscript(@PathVariable Long id,
@PathVariable Long transcriptId,
@RequestBody UpdateMeetingTranscriptCommand command) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
command.setMeetingId(id);
command.setTranscriptId(transcriptId);
meetingCommandService.updateMeetingTranscript(command);
return ApiResponse.ok(true);
}
@Operation(summary = "更新参会人员")
@PutMapping("/{id}/participants")
@PreAuthorize("isAuthenticated()")
@Log(value = "修改会议参会人员", type = "会议管理")
public ApiResponse<Boolean> updateParticipants(@PathVariable Long id,
@RequestBody UpdateMeetingParticipantsCommand command) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
command.setMeetingId(id);
meetingCommandService.updateMeetingParticipants(command.getMeetingId(), command.getParticipants());
return ApiResponse.ok(true);
}
@Operation(summary = "重新生成会议摘要")
@PostMapping("/{id}/summary/regenerate")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> reSummary(@PathVariable Long id, @Valid @RequestBody MeetingResummaryDTO dto) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
dto.setMeetingId(id);
assertPromptAvailable(dto.getPromptId(), loginUser);
meetingCommandService.reSummary(
dto.getMeetingId(),
dto.getSummaryModelId(),
dto.getChapterModelId(),
dto.getPromptId(),
dto.getUserPrompt(),
dto.getSummaryDetailLevel()
);
return ApiResponse.ok(true);
}
@Operation(summary = "手动触发外部 n8n 总结编排")
@PostMapping("/{id}/summary/orchestration/trigger")
@PreAuthorize("isAuthenticated()")
public ApiResponse<MeetingSummaryOrchestrationTriggerResultVO> triggerExternalSummaryOrchestration(@PathVariable Long id,
@RequestParam(defaultValue = "false") boolean force) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
return ApiResponse.ok(meetingCommandService.triggerExternalSummaryOrchestration(id, force));
}
@Operation(summary = "重试音频转写")
@PostMapping("/{id}/transcripts/regenerate")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> retryTranscription(@PathVariable Long id) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
meetingCommandService.retryTranscription(id);
return ApiResponse.ok(true);
}
@Operation(summary = "重试 AI 目录")
@PostMapping("/{id}/chapters/retry")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> retryChapter(@PathVariable Long id) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
meetingCommandService.retryChapter(id);
return ApiResponse.ok(true);
}
@Operation(summary = "重试会议总结")
@PostMapping("/{id}/summary/retry")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> retrySummary(@PathVariable Long id) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
meetingCommandService.retrySummary(id);
return ApiResponse.ok(true);
}
@Operation(summary = "更新会议基础信息")
@PutMapping("/{id}/basic")
@PreAuthorize("isAuthenticated()")
@Log(value = "修改会议基础信息", type = "会议管理")
public ApiResponse<Boolean> updateBasic(@PathVariable Long id, @RequestBody UpdateMeetingBasicCommand command) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
assertPromptAvailable(command.getPromptId(), loginUser);
command.setMeetingId(id);
meetingCommandService.updateMeetingBasic(command);
return ApiResponse.ok(true);
}
@Operation(summary = "更新会议摘要")
@PutMapping("/{id}/summary")
@PreAuthorize("isAuthenticated()")
@Log(value = "修改会议摘要", type = "会议管理")
public ApiResponse<Boolean> updateSummary(@PathVariable Long id, @RequestBody UpdateMeetingSummaryCommand command) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
command.setMeetingId(id);
meetingCommandService.updateSummaryContent(command.getMeetingId(), command.getSummaryContent());
return ApiResponse.ok(true);
}
@Operation(summary = "删除会议")
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
@Log(value = "删除会议", type = "会议管理")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
meetingCommandService.deleteMeeting(id);
return ApiResponse.ok(true);
}
private LoginUser currentLoginUser() {
return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}
private void assertPromptAvailable(Long promptId, LoginUser loginUser) {
if (promptId == null) {
return;
}
boolean enabled = promptTemplateService.isTemplateEnabledForUser(
promptId,
loginUser.getTenantId(),
loginUser.getUserId(),
loginUser.getIsPlatformAdmin(),
loginUser.getIsTenantAdmin()
);
if (!enabled) {
throw new RuntimeException("总结模板不可用");
}
}
private String resolveCreatorName(LoginUser loginUser) {
return loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername();
}
private boolean resolveBooleanParam(String key, boolean defaultValue) {
String rawValue = sysParamService.getCachedParamValue(key, String.valueOf(defaultValue));
if (rawValue == null || rawValue.isBlank()) {
return defaultValue;
}
String normalized = rawValue.trim().toLowerCase();
if ("1".equals(normalized) || "true".equals(normalized) || "yes".equals(normalized) || "on".equals(normalized)) {
return true;
}
if ("0".equals(normalized) || "false".equals(normalized) || "no".equals(normalized) || "off".equals(normalized)) {
return false;
}
return defaultValue;
}
private long resolveLongParam(String key, long defaultValue) {
String rawValue = sysParamService.getCachedParamValue(key, String.valueOf(defaultValue));
if (rawValue == null || rawValue.isBlank()) {
return defaultValue;
}
try {
long parsed = Long.parseLong(rawValue.trim());
return parsed > 0 ? parsed : defaultValue;
} catch (NumberFormatException ex) {
return defaultValue;
}
}
}

View File

@ -0,0 +1,155 @@
package com.imeeting.controller.biz;
import com.imeeting.dto.android.AndroidGrpcConnectionSnapshotVO;
import com.imeeting.dto.biz.MeetingExternalWorkflowFailureDTO;
import com.imeeting.dto.biz.MeetingSummaryFinalizeDTO;
import com.imeeting.dto.biz.MeetingSummaryPromptContextRequestDTO;
import com.imeeting.dto.biz.MeetingSummaryPromptContextVO;
import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO;
import com.imeeting.dto.biz.MeetingTranscriptChapterImportResultVO;
import com.imeeting.dto.biz.MeetingTranscriptSourceVO;
import com.imeeting.service.android.AndroidGatewayPushService;
import com.imeeting.service.biz.MeetingCommandService;
import com.imeeting.service.biz.MeetingQueryService;
import com.unisbase.common.ApiResponse;
import com.unisbase.config.properties.UnisBaseProperties;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "会议内部编排接口")
@RestController
@RequestMapping("/sys/internal/meetings")
public class MeetingInternalWorkflowController {
private final MeetingCommandService meetingCommandService;
private final MeetingQueryService meetingQueryService;
private final AndroidGatewayPushService androidGatewayPushService;
private final UnisBaseProperties unisBaseProperties;
@Value("${imeeting.summary-orchestration.mode:INTERNAL_BUILTIN}")
private String summaryOrchestrationMode;
public MeetingInternalWorkflowController(MeetingCommandService meetingCommandService,
MeetingQueryService meetingQueryService,
AndroidGatewayPushService androidGatewayPushService,
UnisBaseProperties unisBaseProperties) {
this.meetingCommandService = meetingCommandService;
this.meetingQueryService = meetingQueryService;
this.androidGatewayPushService = androidGatewayPushService;
this.unisBaseProperties = unisBaseProperties;
}
@Operation(summary = "导入会议章节")
@PostMapping("/{meetingId}/chapters/import")
public ApiResponse<MeetingTranscriptChapterImportResultVO> importChapters(HttpServletRequest request,
@PathVariable Long meetingId,
@Valid @RequestBody MeetingTranscriptChapterImportDTO command) {
if (!isExternalModeEnabled()) {
return ApiResponse.error("External n8n summary orchestration is disabled");
}
if (!isInternalSecretValid(request)) {
return ApiResponse.error("Invalid internal secret");
}
command.setMeetingId(meetingId);
return ApiResponse.ok(meetingCommandService.importTranscriptChapters(command));
}
@Operation(summary = "获取会议原始转录源")
@GetMapping("/{meetingId}/transcript-source")
public ApiResponse<MeetingTranscriptSourceVO> getTranscriptSource(HttpServletRequest request, @PathVariable Long meetingId) {
if (!isExternalModeEnabled()) {
return ApiResponse.error("External n8n summary orchestration is disabled");
}
if (!isInternalSecretValid(request)) {
return ApiResponse.error("Invalid internal secret");
}
return ApiResponse.ok(meetingQueryService.getTranscriptSource(meetingId));
}
@Operation(summary = "获取会议总结提示词上下文")
@PostMapping("/{meetingId}/summary-prompt-context")
public ApiResponse<MeetingSummaryPromptContextVO> getSummaryPromptContext(HttpServletRequest request,
@PathVariable Long meetingId,
@RequestBody(required = false) MeetingSummaryPromptContextRequestDTO requestDTO) {
if (!isExternalModeEnabled()) {
return ApiResponse.error("External n8n summary orchestration is disabled");
}
if (!isInternalSecretValid(request)) {
return ApiResponse.error("Invalid internal secret");
}
return ApiResponse.ok(meetingQueryService.buildSummaryPromptContext(
meetingId,
requestDTO == null ? new MeetingSummaryPromptContextRequestDTO() : requestDTO
));
}
@Operation(summary = "回填会议总结")
@PostMapping("/{meetingId}/summary/finalize")
public ApiResponse<Boolean> finalizeSummary(HttpServletRequest request,
@PathVariable Long meetingId,
@Valid @RequestBody MeetingSummaryFinalizeDTO command) {
if (!isExternalModeEnabled()) {
return ApiResponse.error("External n8n summary orchestration is disabled");
}
if (!isInternalSecretValid(request)) {
return ApiResponse.error("Invalid internal secret");
}
command.setMeetingId(meetingId);
meetingCommandService.finalizeSummary(command);
return ApiResponse.ok(true);
}
@Operation(summary = "回写外部编排失败")
@PostMapping("/{meetingId}/summary/fail")
public ApiResponse<Boolean> failSummary(HttpServletRequest request,
@PathVariable Long meetingId,
@Valid @RequestBody MeetingExternalWorkflowFailureDTO command) {
if (!isExternalModeEnabled()) {
return ApiResponse.error("External n8n summary orchestration is disabled");
}
if (!isInternalSecretValid(request)) {
return ApiResponse.error("Invalid internal secret");
}
command.setMeetingId(meetingId);
meetingCommandService.markExternalSummaryOrchestrationFailed(command);
return ApiResponse.ok(true);
}
@Operation(summary = "查询 Android gRPC 连接详情")
@GetMapping("/grpc/connections")
public ApiResponse<AndroidGrpcConnectionSnapshotVO> listGrpcConnections(HttpServletRequest request) {
if (!isInternalSecretValid(request)) {
return ApiResponse.error("Invalid internal secret");
}
return ApiResponse.ok(androidGatewayPushService.snapshotConnections());
}
private boolean isExternalModeEnabled() {
return "EXTERNAL_N8N".equalsIgnoreCase(summaryOrchestrationMode);
}
private boolean isInternalSecretValid(HttpServletRequest request) {
if (request == null || unisBaseProperties == null || unisBaseProperties.getInternalAuth() == null) {
return false;
}
if (!unisBaseProperties.getInternalAuth().isEnabled()) {
return false;
}
String headerName = unisBaseProperties.getInternalAuth().getHeaderName();
String expectedSecret = unisBaseProperties.getInternalAuth().getSecret();
if (headerName == null || headerName.isBlank() || expectedSecret == null || expectedSecret.isBlank()) {
return false;
}
String actual = request.getHeader(headerName);
return expectedSecret.equals(actual);
}
}

View File

@ -0,0 +1,74 @@
package com.imeeting.controller.biz;
import com.imeeting.dto.biz.MeetingPointsBalanceVO;
import com.imeeting.dto.biz.MeetingPointsTransferRequest;
import com.imeeting.service.biz.MeetingPointsService;
import com.unisbase.common.ApiResponse;
import com.unisbase.security.LoginUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "会议积分")
@RestController
@RequestMapping("/api/biz/meeting-points")
public class MeetingPointsController {
private final MeetingPointsService meetingPointsService;
public MeetingPointsController(MeetingPointsService meetingPointsService) {
this.meetingPointsService = meetingPointsService;
}
@Operation(summary = "查看会议积分余额")
@GetMapping("/balance")
@PreAuthorize("isAuthenticated()")
public ApiResponse<MeetingPointsBalanceVO> getBalance(@RequestParam(value = "userId", required = false) Long userId) {
LoginUser loginUser = currentLoginUser();
Long targetUserId = resolveTargetUserId(loginUser, userId);
return ApiResponse.ok(meetingPointsService.getBalanceView(loginUser.getTenantId(), targetUserId));
}
@Operation(summary = "从公共账户分配积分给个人账户")
@PostMapping("/transfer")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> transferPublicPoints(@RequestBody MeetingPointsTransferRequest request) {
LoginUser loginUser = currentLoginUser();
ensureAdmin(loginUser);
if (request == null) {
throw new RuntimeException("分配请求不能为空");
}
meetingPointsService.transferPublicPointsToUser(
loginUser.getTenantId(),
request.getTargetUserId(),
request.getPoints(),
request.getRemark()
);
return ApiResponse.ok(Boolean.TRUE);
}
private Long resolveTargetUserId(LoginUser loginUser, Long requestedUserId) {
if (requestedUserId == null || requestedUserId.equals(loginUser.getUserId())) {
return loginUser.getUserId();
}
ensureAdmin(loginUser);
return requestedUserId;
}
private void ensureAdmin(LoginUser loginUser) {
boolean isAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin());
if (!isAdmin) {
throw new RuntimeException("无权限执行该操作");
}
}
private LoginUser currentLoginUser() {
return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}
}

View File

@ -0,0 +1,62 @@
package com.imeeting.controller.biz;
import com.imeeting.dto.biz.MeetingPointsLedgerDetailVO;
import com.imeeting.dto.biz.MeetingPointsLedgerListItemVO;
import com.imeeting.dto.biz.MeetingPointsOverviewVO;
import com.imeeting.service.biz.MeetingPointsQueryService;
import com.unisbase.common.ApiResponse;
import com.unisbase.dto.PageResult;
import com.unisbase.security.LoginUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "积分管理")
@RestController
@RequestMapping("/api/biz/meeting-points/management")
public class MeetingPointsManagementController {
private final MeetingPointsQueryService meetingPointsQueryService;
public MeetingPointsManagementController(MeetingPointsQueryService meetingPointsQueryService) {
this.meetingPointsQueryService = meetingPointsQueryService;
}
@Operation(summary = "获取积分管理总览")
@GetMapping("/overview")
@PreAuthorize("isAuthenticated()")
public ApiResponse<MeetingPointsOverviewVO> getOverview() {
LoginUser loginUser = currentLoginUser();
boolean isAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin());
return ApiResponse.ok(meetingPointsQueryService.getOverview(loginUser.getTenantId(), loginUser.getUserId(), isAdmin));
}
@Operation(summary = "分页查询积分消耗流水")
@GetMapping("/ledgers")
@PreAuthorize("isAuthenticated()")
public ApiResponse<PageResult<List<MeetingPointsLedgerListItemVO>>> pageLedgers(
@RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "20") Integer size,
@RequestParam(required = false) String username,
@RequestParam(required = false) String pointsType) {
return ApiResponse.ok(meetingPointsQueryService.pageLedgers(currentLoginUser().getTenantId(), current, size, username, pointsType));
}
@Operation(summary = "查看积分消耗流水详情")
@GetMapping("/ledgers/{ledgerId}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<MeetingPointsLedgerDetailVO> getLedgerDetail(@PathVariable Long ledgerId) {
return ApiResponse.ok(meetingPointsQueryService.getLedgerDetail(currentLoginUser().getTenantId(), ledgerId));
}
private LoginUser currentLoginUser() {
return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}
}

View File

@ -0,0 +1,62 @@
package com.imeeting.controller.biz;
import com.imeeting.dto.biz.MeetingPreviewAccessVO;
import com.imeeting.dto.biz.PublicMeetingPreviewVO;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.service.biz.MeetingAccessService;
import com.imeeting.service.biz.MeetingQueryService;
import com.unisbase.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "会议公开预览")
@RestController
@RequestMapping("/api/public/meetings")
public class MeetingPublicPreviewController {
private final MeetingQueryService meetingQueryService;
private final MeetingAccessService meetingAccessService;
public MeetingPublicPreviewController(MeetingQueryService meetingQueryService,
MeetingAccessService meetingAccessService) {
this.meetingQueryService = meetingQueryService;
this.meetingAccessService = meetingAccessService;
}
@Operation(summary = "查询会议预览访问要求")
@GetMapping("/{id}/preview/access")
public ApiResponse<MeetingPreviewAccessVO> getPreviewAccess(@PathVariable Long id) {
try {
Meeting meeting = meetingAccessService.requireMeetingIgnoreTenant(id);
return ApiResponse.ok(new MeetingPreviewAccessVO(meetingAccessService.isPreviewPasswordRequired(meeting)));
} catch (RuntimeException ex) {
return ApiResponse.error(ex.getMessage());
}
}
@Operation(summary = "获取会议公开预览内容")
@GetMapping("/{id}/preview")
public ApiResponse<PublicMeetingPreviewVO> getPreview(@PathVariable Long id,
@RequestParam(required = false) String accessPassword) {
try {
Meeting meeting = meetingAccessService.requireMeetingIgnoreTenant(id);
meetingAccessService.assertCanPreviewMeeting(meeting, accessPassword);
PublicMeetingPreviewVO data = new PublicMeetingPreviewVO();
data.setMeeting(meetingQueryService.getDetailIgnoreTenant(id));
if (data.getMeeting() != null) {
data.getMeeting().setAccessPassword(null);
}
data.setTranscripts(meetingQueryService.getTranscripts(id));
data.setChapters(meetingQueryService.getChaptersIgnoreTenant(id));
return ApiResponse.ok(data);
} catch (RuntimeException ex) {
return ApiResponse.error(ex.getMessage());
}
}
}

View File

@ -0,0 +1,216 @@
package com.imeeting.controller.biz;
import com.imeeting.dto.biz.PromptTemplateDTO;
import com.imeeting.dto.biz.PromptTemplateVO;
import com.imeeting.entity.biz.PromptTemplate;
import com.imeeting.service.biz.PromptTemplateService;
import com.unisbase.common.ApiResponse;
import com.unisbase.common.annotation.Log;
import com.unisbase.dto.PageResult;
import com.unisbase.security.LoginUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "提示词模板管理")
@RestController
@RequestMapping("/api/biz/prompt")
public class PromptTemplateController {
private final PromptTemplateService promptTemplateService;
public PromptTemplateController(PromptTemplateService promptTemplateService) {
this.promptTemplateService = promptTemplateService;
}
@Operation(summary = "新增提示词模板")
@PostMapping
@PreAuthorize("isAuthenticated()")
@Log(value = "新增提示词模板", type = "提示词模板管理")
public ApiResponse<PromptTemplateVO> save(@RequestBody PromptTemplateDTO dto) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (Integer.valueOf(1).equals(dto.getIsSystem())) {
if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && !Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) {
return ApiResponse.error("无权创建公共模板");
}
if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
dto.setTenantId(loginUser.getTenantId());
} else if (dto.getTenantId() == null) {
dto.setTenantId(0L);
}
} else {
dto.setTenantId(loginUser.getTenantId());
}
return ApiResponse.ok(promptTemplateService.saveTemplate(dto, loginUser.getUserId(), loginUser.getTenantId()));
}
@Operation(summary = "修改提示词模板")
@PutMapping
@PreAuthorize("isAuthenticated()")
@Log(value = "修改提示词模板", type = "提示词模板管理")
public ApiResponse<PromptTemplateVO> update(@RequestBody PromptTemplateDTO dto) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
PromptTemplate existing = promptTemplateService.getById(dto.getId());
if (existing == null) {
return ApiResponse.error("模板不存在");
}
boolean canModify = false;
if (Integer.valueOf(0).equals(existing.getIsSystem())) {
canModify = existing.getCreatorId().equals(loginUser.getUserId());
} else {
if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
canModify = existing.getTenantId() == 0L;
} else if (Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) {
canModify = existing.getTenantId().equals(loginUser.getTenantId());
}
}
if (!canModify) {
return ApiResponse.error("无权修改该模板");
}
return ApiResponse.ok(promptTemplateService.updateTemplate(dto, loginUser.getUserId(), loginUser.getTenantId()));
}
@Operation(summary = "更新提示词模板状态")
@PutMapping("/{id}/status")
@PreAuthorize("isAuthenticated()")
@Log(value = "修改提示词模板状态", type = "提示词模板管理")
public ApiResponse<Boolean> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
PromptTemplate existing = promptTemplateService.getById(id);
if (existing == null) {
return ApiResponse.error("模板不存在");
}
boolean canGlobalModify = false;
if (Integer.valueOf(1).equals(existing.getIsSystem())) {
if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && Long.valueOf(0L).equals(existing.getTenantId())) {
canGlobalModify = true;
} else if (Boolean.TRUE.equals(loginUser.getIsTenantAdmin()) && existing.getTenantId().equals(loginUser.getTenantId())) {
canGlobalModify = true;
}
}
if (canGlobalModify) {
existing.setStatus(status);
return ApiResponse.ok(promptTemplateService.updateById(existing));
}
boolean success = promptTemplateService.updateUserTemplateStatus(
id,
status,
loginUser.getTenantId(),
loginUser.getUserId(),
loginUser.getIsPlatformAdmin(),
loginUser.getIsTenantAdmin()
);
if (!success) {
return ApiResponse.error("模板不存在或无权限访问");
}
return ApiResponse.ok(true);
}
@Operation(summary = "设置默认提示词模板")
@PutMapping("/{id}/default")
@PreAuthorize("isAuthenticated()")
@Log(value = "设置默认提示词模板", type = "提示词模板管理")
public ApiResponse<Boolean> setDefault(@PathVariable Long id) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
boolean success = promptTemplateService.setUserDefaultTemplate(
id,
loginUser.getTenantId(),
loginUser.getUserId(),
loginUser.getIsPlatformAdmin(),
loginUser.getIsTenantAdmin()
);
return success ? ApiResponse.ok(true) : ApiResponse.error("模板不存在、不可用或无权限访问");
}
@Operation(summary = "取消默认提示词模板")
@DeleteMapping("/{id}/default")
@PreAuthorize("isAuthenticated()")
@Log(value = "取消默认提示词模板", type = "提示词模板管理")
public ApiResponse<Boolean> clearDefault(@PathVariable Long id) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return ApiResponse.ok(promptTemplateService.clearUserDefaultTemplate(
id,
loginUser.getTenantId(),
loginUser.getUserId(),
loginUser.getIsPlatformAdmin(),
loginUser.getIsTenantAdmin()
));
}
@Operation(summary = "删除提示词模板")
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
@Log(value = "删除提示词模板", type = "提示词模板管理")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
PromptTemplate existing = promptTemplateService.getById(id);
if (existing == null) {
return ApiResponse.ok(true);
}
boolean canModify = false;
if (Integer.valueOf(0).equals(existing.getIsSystem())) {
canModify = existing.getCreatorId().equals(loginUser.getUserId());
} else {
if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
canModify = existing.getTenantId() == 0L;
} else if (Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) {
canModify = existing.getTenantId().equals(loginUser.getTenantId());
}
}
if (!canModify) {
return ApiResponse.error("无权删除该模板");
}
return ApiResponse.ok(promptTemplateService.removeById(id));
}
@Operation(summary = "查询提示词模板详情")
@GetMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<PromptTemplateVO> detail(@PathVariable Long id) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return ApiResponse.ok(promptTemplateService.getTemplateDetail(
id,
loginUser.getTenantId(),
loginUser.getUserId(),
loginUser.getIsPlatformAdmin(),
loginUser.getIsTenantAdmin()
));
}
@Operation(summary = "分页查询提示词模板")
@GetMapping("/page")
@PreAuthorize("isAuthenticated()")
public ApiResponse<PageResult<List<PromptTemplateVO>>> page(
@RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String name,
@RequestParam(required = false) String category) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return ApiResponse.ok(promptTemplateService.pageTemplates(
current,
size,
name,
category,
loginUser.getTenantId(),
loginUser.getUserId(),
loginUser.getIsPlatformAdmin(),
loginUser.getIsTenantAdmin()));
}
}

View File

@ -0,0 +1,63 @@
package com.imeeting.controller.biz;
import com.imeeting.dto.android.AndroidPublicLoginConfirmPayload;
import com.imeeting.dto.android.AndroidPublicMeetingSessionState;
import com.imeeting.service.android.AndroidMeetingPushService;
import com.imeeting.service.android.AndroidPublicMeetingSessionService;
import com.unisbase.common.ApiResponse;
import com.unisbase.security.LoginUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "公有设备扫码建会接口")
@RestController
@RequestMapping("/api/biz/public-device-meetings")
public class PublicDeviceMeetingController {
private final AndroidPublicMeetingSessionService androidPublicMeetingSessionService;
private final AndroidMeetingPushService androidMeetingPushService;
public PublicDeviceMeetingController(AndroidPublicMeetingSessionService androidPublicMeetingSessionService,
AndroidMeetingPushService androidMeetingPushService) {
this.androidPublicMeetingSessionService = androidPublicMeetingSessionService;
this.androidMeetingPushService = androidMeetingPushService;
}
@Operation(summary = "H5扫码确认公有设备登录")
@io.swagger.v3.oas.annotations.responses.ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "确认成功返回 true并向设备推送扫码用户信息",
content = @Content(schema = @Schema(implementation = Boolean.class))
)
})
@PostMapping("/sessions/{sessionId}/create")
public ApiResponse<Boolean> createBySession(@PathVariable String sessionId) {
LoginUser loginUser = currentLoginUser();
AndroidPublicMeetingSessionState session = androidPublicMeetingSessionService.require(sessionId);
AndroidPublicLoginConfirmPayload payload = new AndroidPublicLoginConfirmPayload();
payload.setSessionId(sessionId);
payload.setTenantId(loginUser.getTenantId());
payload.setUserId(loginUser.getUserId());
payload.setUsername(loginUser.getUsername());
payload.setDisplayName(loginUser.getDisplayName());
androidMeetingPushService.pushPublicLoginConfirm(session.getDeviceId(), payload);
androidPublicMeetingSessionService.invalidate(sessionId);
return ApiResponse.ok(true);
}
private LoginUser currentLoginUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser loginUser)) {
throw new RuntimeException("未获取到登录用户");
}
return loginUser;
}
}

View File

@ -0,0 +1,112 @@
package com.imeeting.controller.biz;
import com.imeeting.dto.biz.ScreenSaverAdminVO;
import com.imeeting.dto.biz.ScreenSaverDTO;
import com.imeeting.dto.biz.ScreenSaverImageUploadVO;
import com.imeeting.dto.biz.ScreenSaverUserSettingsDTO;
import com.imeeting.dto.biz.ScreenSaverUserSettingsVO;
import com.imeeting.entity.biz.ScreenSaver;
import com.imeeting.service.biz.ScreenSaverService;
import com.unisbase.common.ApiResponse;
import com.unisbase.common.annotation.Log;
import com.unisbase.security.LoginUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
@Tag(name = "屏保管理")
@RestController
@RequestMapping("/api/screen-savers")
@RequiredArgsConstructor
public class ScreenSaverController {
private final ScreenSaverService screenSaverService;
@Operation(summary = "查询屏保列表")
@GetMapping
@PreAuthorize("isAuthenticated()")
public ApiResponse<List<ScreenSaverAdminVO>> list(@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "status", required = false) Integer status,
@RequestParam(value = "scopeType", required = false) String scopeType,
@RequestParam(value = "ownerUserId", required = false) Long ownerUserId) {
return ApiResponse.ok(screenSaverService.listForAdmin(currentLoginUser(), keyword, status, scopeType, ownerUserId));
}
@Operation(summary = "查询当前用户屏保播放设置")
@GetMapping("/my-settings")
@PreAuthorize("isAuthenticated()")
public ApiResponse<ScreenSaverUserSettingsVO> getMySettings() {
return ApiResponse.ok(screenSaverService.getMySettings(currentLoginUser()));
}
@Operation(summary = "更新当前用户屏保播放设置")
@PutMapping("/my-settings")
@PreAuthorize("isAuthenticated()")
@Log(value = "修改个人屏保设置", type = "屏保管理")
public ApiResponse<ScreenSaverUserSettingsVO> updateMySettings(@RequestBody ScreenSaverUserSettingsDTO dto) {
return ApiResponse.ok(screenSaverService.updateMySettings(dto, currentLoginUser()));
}
@Operation(summary = "新增屏保")
@PostMapping
@PreAuthorize("isAuthenticated()")
@Log(value = "新增屏保", type = "屏保管理")
public ApiResponse<ScreenSaver> create(@RequestBody ScreenSaverDTO dto) {
return ApiResponse.ok(screenSaverService.create(dto, currentLoginUser()));
}
@Operation(summary = "修改屏保")
@PutMapping("/{id}")
@PreAuthorize("isAuthenticated()")
@Log(value = "修改屏保", type = "屏保管理")
public ApiResponse<ScreenSaver> update(@PathVariable Long id, @RequestBody ScreenSaverDTO dto) {
return ApiResponse.ok(screenSaverService.update(id, dto, currentLoginUser()));
}
@Operation(summary = "更新屏保状态")
@PutMapping("/{id}/status")
@PreAuthorize("isAuthenticated()")
@Log(value = "修改屏保状态", type = "屏保管理")
public ApiResponse<Boolean> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
boolean success = screenSaverService.updateStatus(id, status, currentLoginUser());
if (!success) {
return ApiResponse.error("屏保不存在或无权限访问");
}
return ApiResponse.ok(true);
}
@Operation(summary = "删除屏保")
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
@Log(value = "删除屏保", type = "屏保管理")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
screenSaverService.removeScreenSaver(id, currentLoginUser());
return ApiResponse.ok(true);
}
@Operation(summary = "上传屏保图片")
@PostMapping("/upload-image")
@PreAuthorize("isAuthenticated()")
public ApiResponse<ScreenSaverImageUploadVO> uploadImage(@RequestParam("imageFile") MultipartFile imageFile) throws IOException {
return ApiResponse.ok(screenSaverService.uploadImage(imageFile));
}
private LoginUser currentLoginUser() {
return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}
}

View File

@ -0,0 +1,106 @@
package com.imeeting.controller.biz;
import com.imeeting.dto.biz.SpeakerRegisterDTO;
import com.imeeting.dto.biz.SpeakerVO;
import com.imeeting.service.biz.SpeakerService;
import com.unisbase.common.ApiResponse;
import com.unisbase.common.annotation.Log;
import com.unisbase.dto.PageResult;
import com.unisbase.security.LoginUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "讲话人管理")
@RestController
@RequestMapping("/api/biz/speaker")
public class SpeakerController {
private final SpeakerService speakerService;
public SpeakerController(SpeakerService speakerService) {
this.speakerService = speakerService;
}
@Operation(summary = "注册讲话人样本")
@PostMapping("/register")
@PreAuthorize("isAuthenticated()")
@Log(value = "新增讲话人样本", type = "讲话人管理")
public ApiResponse<SpeakerVO> register(@ModelAttribute SpeakerRegisterDTO registerDTO) {
LoginUser loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserId() == null) {
return ApiResponse.error("未获取到用户信息");
}
registerDTO.setCreatorId(loginUser.getUserId());
return ApiResponse.ok(speakerService.register(registerDTO, loginUser));
}
@Operation(summary = "分页查询讲话人")
@GetMapping("/page")
@PreAuthorize("isAuthenticated()")
public ApiResponse<PageResult<List<SpeakerVO>>> page(
@RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "8") Integer size,
@RequestParam(required = false) String name) {
LoginUser loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserId() == null) {
return ApiResponse.error("未获取到用户信息");
}
return ApiResponse.ok(speakerService.pageVisible(current, size, name, loginUser));
}
@Operation(summary = "查询可见讲话人")
@GetMapping("/list")
@PreAuthorize("isAuthenticated()")
public ApiResponse<List<SpeakerVO>> list() {
LoginUser loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserId() == null) {
return ApiResponse.error("未获取到用户信息");
}
return ApiResponse.ok(speakerService.listVisible(loginUser));
}
@Operation(summary = "同步当前 ASR 声纹")
@PostMapping("/{id}/sync")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> sync(@PathVariable Long id) {
LoginUser loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserId() == null) {
return ApiResponse.error("未获取到用户信息");
}
speakerService.syncCurrentAsr(id, loginUser);
return ApiResponse.ok(Boolean.TRUE);
}
@Operation(summary = "删除讲话人")
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
@Log(value = "删除讲话人", type = "讲话人管理")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
LoginUser loginUser = getLoginUser();
if (loginUser == null || loginUser.getUserId() == null) {
return ApiResponse.error("未获取到用户信息");
}
speakerService.deleteSpeaker(id, loginUser);
return ApiResponse.ok(Boolean.TRUE);
}
private LoginUser getLoginUser() {
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principal instanceof LoginUser loginUser) {
return loginUser;
}
return null;
}
}

View File

@ -0,0 +1,103 @@
package com.imeeting.controller.biz;
import com.imeeting.dto.biz.TenantMeetingPointsSettingVO;
import com.imeeting.dto.biz.UpdateTenantMeetingPointsBalanceCheckCommand;
import com.imeeting.service.biz.TenantMeetingPointsManagementService;
import com.unisbase.common.ApiResponse;
import com.unisbase.dto.PageResult;
import com.unisbase.security.LoginUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "租户积分余额校验")
@RestController
@RequestMapping("/api/biz/tenant-meeting-points/settings")
public class TenantMeetingPointsSettingController {
private final TenantMeetingPointsManagementService tenantMeetingPointsManagementService;
public TenantMeetingPointsSettingController(TenantMeetingPointsManagementService tenantMeetingPointsManagementService) {
this.tenantMeetingPointsManagementService = tenantMeetingPointsManagementService;
}
@Operation(summary = "分页查询租户积分余额校验配置")
@GetMapping
@PreAuthorize("isAuthenticated()")
public ApiResponse<PageResult<List<TenantMeetingPointsSettingVO>>> pageSettings(
@RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "20") Integer size,
@RequestParam(required = false) String tenantName,
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) Boolean balanceCheckEnabled) {
LoginUser loginUser = currentLoginUser();
ensurePlatformAdmin(loginUser);
return ApiResponse.ok(tenantMeetingPointsManagementService.pageSettings(current, size, tenantName, tenantCode, balanceCheckEnabled));
}
@Operation(summary = "获取当前租户积分余额校验配置")
@GetMapping("/current")
@PreAuthorize("isAuthenticated()")
public ApiResponse<TenantMeetingPointsSettingVO> getCurrentSetting() {
LoginUser loginUser = currentLoginUser();
ensureAdmin(loginUser);
return ApiResponse.ok(tenantMeetingPointsManagementService.getCurrentTenantSetting(loginUser.getTenantId()));
}
@Operation(summary = "更新租户积分余额校验开关")
@PutMapping("/{tenantId}/balance-check")
@PreAuthorize("isAuthenticated()")
public ApiResponse<TenantMeetingPointsSettingVO> updateBalanceCheck(@PathVariable Long tenantId,
@Valid @RequestBody UpdateTenantMeetingPointsBalanceCheckCommand command) {
LoginUser loginUser = currentLoginUser();
ensureCanManageTenant(loginUser, tenantId);
if (command == null || command.getBalanceCheckEnabled() == null) {
throw new RuntimeException("余额校验开关不能为空");
}
return ApiResponse.ok(tenantMeetingPointsManagementService.updateBalanceCheck(
tenantId,
command.getBalanceCheckEnabled(),
command.getRemark(),
loginUser.getUserId(),
loginUser.getDisplayName()
));
}
private void ensureCanManageTenant(LoginUser loginUser, Long tenantId) {
if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
return;
}
if (!Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) {
throw new RuntimeException("无权限执行该操作");
}
if (tenantId == null || !tenantId.equals(loginUser.getTenantId())) {
throw new RuntimeException("租户管理员只能操作当前租户");
}
}
private void ensureAdmin(LoginUser loginUser) {
if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && !Boolean.TRUE.equals(loginUser.getIsTenantAdmin())) {
throw new RuntimeException("无权限执行该操作");
}
}
private void ensurePlatformAdmin(LoginUser loginUser) {
if (!Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())) {
throw new RuntimeException("仅平台管理员可查看租户列表");
}
}
private LoginUser currentLoginUser() {
return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}
}

View File

@ -0,0 +1,96 @@
package com.imeeting.controller.qt;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.common.MeetingConstants;
import com.imeeting.dto.biz.CreateMeetingCommand;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.enums.MeetingTerminalEnum;
import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.biz.MeetingAuthorizationService;
import com.imeeting.service.biz.MeetingCommandService;
import com.imeeting.service.biz.PromptTemplateService;
import com.imeeting.support.AndroidRequestLogHelper;
import com.unisbase.annotation.Anonymous;
import com.unisbase.common.ApiResponse;
import com.unisbase.common.annotation.Log;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "Qt会议管理")
@RestController
@RequestMapping("/api/qt/meetings")
@RequiredArgsConstructor
@Slf4j
public class QtMeetingController {
private final AndroidAuthService androidAuthService;
private final MeetingAuthorizationService meetingAuthorizationService;
private final MeetingCommandService meetingCommandService;
private final PromptTemplateService promptTemplateService;
@Operation(summary = "创建 Qt 离线会议")
@PostMapping
@Anonymous
@Log(value = "新增 Qt 离线会议", type = "Qt会议管理")
public ApiResponse<MeetingVO> createMeeting(HttpServletRequest request,
@Valid @RequestBody CreateMeetingCommand command) {
AndroidRequestLogHelper.logRequest(log, "Qt会议", "创建离线会议接口", "request", command);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
meetingAuthorizationService.assertCanCreateMeeting(authContext);
assertPromptAvailable(command.getPromptId(), authContext);
MeetingVO meeting = meetingCommandService.createMeeting(
command,
authContext.getTenantId(),
authContext.getUserId(),
resolveCreatorName(authContext),
MeetingTerminalEnum.resolve(authContext.getPlatform()).getCode(),
authContext.getDeviceId(),
resolveSourceDeviceMode(authContext)
);
return ApiResponse.ok(meeting);
}
private String resolveCreatorName(AndroidAuthContext authContext) {
if (hasText(authContext.getDisplayName())) {
return authContext.getDisplayName().trim();
}
if (hasText(authContext.getUsername())) {
return authContext.getUsername().trim();
}
return hasText(authContext.getDeviceId()) ? "qt:" + authContext.getDeviceId().trim() : "qt";
}
private void assertPromptAvailable(Long promptId, AndroidAuthContext authContext) {
if (promptId == null) {
return;
}
boolean enabled = promptTemplateService.isTemplateEnabledForUser(
promptId,
authContext.getTenantId(),
authContext.getUserId(),
authContext.getPlatformAdmin(),
authContext.getTenantAdmin()
);
if (!enabled) {
throw new RuntimeException("总结模板不可用");
}
}
private String resolveSourceDeviceMode(AndroidAuthContext authContext) {
return authContext.isAnonymous()
? MeetingConstants.DEVICE_MODE_PUBLIC
: MeetingConstants.DEVICE_MODE_PRIVATE;
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
}

View File

@ -1,14 +0,0 @@
package com.imeeting.dto;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class CreateTenantDTO {
private String tenantCode;
private String tenantName;
private String contactName;
private String contactPhone;
private String remark;
private LocalDateTime expireTime;
}

View File

@ -1,9 +0,0 @@
package com.imeeting.dto;
import lombok.Data;
@Data
public class PasswordUpdateDTO {
private String oldPassword;
private String newPassword;
}

View File

@ -1,25 +0,0 @@
package com.imeeting.dto;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class PermissionNode {
private Long permId;
private Long parentId;
private String name;
private String code;
private String permType;
private Integer level;
private String path;
private String component;
private String icon;
private Integer sortOrder;
private Integer isVisible;
private Integer status;
private String description;
private String meta;
private List<PermissionNode> children = new ArrayList<>();
}

View File

@ -1,15 +0,0 @@
package com.imeeting.dto;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class PlatformConfigVO {
private String projectName;
private String logoUrl;
private String iconUrl;
private String loginBgUrl;
private String icpInfo;
private String copyrightInfo;
private String systemDescription;
}

View File

@ -1,12 +0,0 @@
package com.imeeting.dto;
import lombok.Data;
@Data
public class SysParamQueryDTO {
private String paramKey;
private String paramType;
private String description;
private Integer pageNum = 1;
private Integer pageSize = 10;
}

View File

@ -1,17 +0,0 @@
package com.imeeting.dto;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class SysParamVO {
private Long paramId;
private String paramKey;
private String paramValue;
private String paramType;
private Integer isSystem;
private String description;
private Integer status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@ -1,19 +0,0 @@
package com.imeeting.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
public class UserProfile {
private Long userId;
private String username;
private String displayName;
private String email;
private String phone;
private Integer status;
@JsonProperty("isAdmin")
private boolean isAdmin;
private Boolean isPlatformAdmin;
private Boolean isTenantAdmin;
private Integer pwdResetRequired;
}

View File

@ -0,0 +1,24 @@
package com.imeeting.dto.android;
import lombok.Data;
import java.util.Set;
@Data
public class AndroidAuthContext {
private String authMode;
private String deviceId;
private Long tenantId;
private String tenantCode;
private Long userId;
private String username;
private String displayName;
private Boolean platformAdmin;
private Boolean tenantAdmin;
private Set<String> permissions;
private String appId;
private String appVersion;
private String platform;
private String accessToken;
private boolean anonymous;
}

View File

@ -0,0 +1,20 @@
package com.imeeting.dto.android;
import lombok.Data;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
@Data
public class AndroidChunkUploadSessionState {
private Long meetingId;
private String deviceId;
private Integer totalChunks;
private String fileName;
private String contentType;
private Set<Integer> receivedChunks = new TreeSet<>();
private Set<String> uploadedChunkFileNames = new TreeSet<>();
private Map<Integer, String> chunkFileNames = new TreeMap<>();
}

View File

@ -0,0 +1,35 @@
package com.imeeting.dto.android;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
public class AndroidCreateRealtimeMeetingCommand {
private String title;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime meetingTime;
private String participants;
private String tags;
private Long hostUserId;
private String hostName;
private Long asrModelId;
private Long summaryModelId;
private Long promptId;
private Long hotWordGroupId;
@Schema(description = "总结详细程度DETAILED=详细STANDARD=标准BRIEF=简洁")
private String summaryDetailLevel;
private String mode;
private String language;
private Integer useSpkId;
private Boolean enablePunctuation;
private Boolean enableItn;
private Boolean enableTextRefine;
private Boolean saveAudio;
private List<String> hotWords;
}

Some files were not shown because too many files have changed in this diff Show More