Compare commits

...

249 Commits

Author SHA1 Message Date
chenhao d5e1616c01 fix:修复热词组分组 从数据字典获取 2026-08-11 09:17:29 +08:00
chenhao 22aefae6cf refactor(service): 修复音频无法播放问题 2026-07-21 15:11:08 +08:00
chenhao e3c688b9ce refactor(service): 清理导入并优化代码格式
- 移除无用导入语句,精简依赖引用
- AndroidAuthController 添加 UnAuthException 异常类导入
- 统一代码格式规范,调整空行与缩进
2026-07-20 16:21:48 +08:00
puz 1e1ca71939 feat: 添加忽略文件 2026-07-20 11:22:55 +08:00
puz 093438e73a feat: 新增热词组分组筛选编辑 2026-07-16 16:05:16 +08:00
puz 85ed5e37fd feat: 参数修改 2026-07-16 13:51:00 +08:00
puz 3df2e753c5 feat: 新增max_tokens和调整 2026-07-16 11:02:00 +08:00
puz 8703b8bdc7 feat: PDF文档调整1 2026-07-14 16:50:02 +08:00
puz c375390d52 feat:H5md渲染优化 2026-07-14 16:49:16 +08:00
puz 5df4f3d840 Merge remote-tracking branch 'origin/dev_na' into dev_na 2026-07-14 14:32:02 +08:00
puz 66f84af96c feat:调整md显示渲染 2026-07-14 14:29:25 +08:00
puz c8df0d9604 feat: PDF文档调整 2026-07-14 14:15:23 +08:00
puz cd4b5365d6 feat: word文档调整暂存2 2026-07-14 11:24:15 +08:00
puz 58674642e7 feat: word文档调整暂存 2026-07-13 18:07:57 +08:00
chenhao 0aaa3a1cde fix:ASR排队优化 全租户排队改为按模型前缀URL进行排队 2026-07-10 10:01:08 +08:00
puz 9e12e03481 feat: 说话人修改调整 2026-07-08 18:11:25 +08:00
puz e39f1405b3 feat: 界面优化2 2026-07-08 18:05:27 +08:00
puz 7671f94fcd feat: 界面优化 2026-07-08 16:31:28 +08:00
puz 77879f4e0e feat: 会议中心界面优化 2026-07-07 17:10:28 +08:00
chenhao 6476b23bb6 fix:总结返回结构变化 2026-07-07 17:00:10 +08:00
chenhao 788a98f28f fix:提示词修改 2026-07-07 15:47:25 +08:00
puz c0b4c0d319 feat: 优化响应布局 2026-07-07 09:13:19 +08:00
chenhao b06b7585d8 refactor(realtime): 重构实时会议会话状态以支持恢复配置
在 LocalRealtimeAsrChannel 中增加恢复配置处理逻辑,
并在 RealtimeMeetingSessionStateService 中新增
rememberResumeConfig 方法,以完善实时会议恢复
配置的保存与传递,确保会议恢复时能正确应用配置。
2026-07-07 09:04:38 +08:00
puz 9d0edbfa00 feat:响应式布局调整 2026-07-06 11:08:36 +08:00
chenhao 93900308e0 feat(LLM任务):总结任务添加超时时间 防止llm死循环任务一直不结束 2026-07-06 10:44:33 +08:00
AlanPaine ec8bb466da refactor(frontend): 重构声纹管理页面UI 优化AI模型页面布局
- 重写声纹注册页面的整体UI与代码逻辑,拆分我的声纹、他人声纹视图,优化录音上传与管理的交互体验
- 调整AI模型页面的样式结构,移除冗余样式并新增内容容器样式,修复代码缩进问题
- 统一前端页面的代码规范与样式命名规则,提升项目可维护性
2026-07-03 16:25:42 +08:00
chenhao 76410071e7 Merge remote-tracking branch '个人/dev_na' into dev_na 2026-07-03 15:52:38 +08:00
chenhao ae75ea4e2c feat(permiss添加icons): 增加表格行拖拽排序功能 2026-07-03 15:52:17 +08:00
puz 2bc0b55e60 feat:调整会议详情 2026-07-03 15:41:45 +08:00
puz 37696b13a9 feat: 页面重构优化 2026-07-03 09:57:59 +08:00
puz 0d6acd1056 feat: 设备管理页面优化 2026-07-02 17:49:27 +08:00
chenhao 31d7a8a59f Merge remote-tracking branch '个人/dev_na' into dev_na 2026-07-02 17:43:30 +08:00
chenhao 91ab0eb7f0 refactor(meeting): 优化会议模块代码结构与导入语句
- 整理并优化后端会议相关服务、控制器及DTO的import语句
- 补充AndroidMeetingConfigVo等类的Swagger注解及必要字段
- 调整前端API与类型定义文件的代码格式
- 提升代码可读性与规范性,不涉及业务逻辑变更
2026-07-02 17:43:14 +08:00
puz ba52e21437 Merge remote-tracking branch 'origin/dev_na' into dev_na 2026-07-02 17:34:21 +08:00
puz cc43fe2e01 feat: 界面优化1 2026-07-02 17:29:39 +08:00
chenhao 17dbeefdf8 feat(core): 新增租户模型激活、ASR同步与会议导出功能
- 实现租户级AI模型激活与默认配置管理
- 新增发言人ASR状态网关及数据同步机制
- 完善会议导出服务的具体实现
- 补充AI模型与会议运行时的单元测试
- 适配前端AI模型管理与发言人注册页面
2026-07-02 15:19:11 +08:00
puz f5a6a22eb1 feat: 界面优化 2026-07-02 10:04:15 +08:00
puz e954e775c0 feat:还原配置 2026-07-02 09:39:17 +08:00
puz 3337285135 feat: 界面优化 2026-07-02 09:30:48 +08:00
puz b0a3dedb99 feat:前端整体调整 2026-07-02 08:36:49 +08:00
puz 90b1f60955 Merge branch 'dev_na' of http://111.10.202.195:3000/chenh/imeeting into dev_ymcg 2026-07-01 22:02:04 +08:00
puz 0a33ff8e56 feat:页面调整2 2026-07-01 22:00:19 +08:00
chenhao 4097e719a9 style(SpeakerReg): 统一文件换行符格式 2026-07-01 15:16:47 +08:00
puz 4bc3f65a10 Merge branch 'dev_na' of http://111.10.202.195:3000/chenh/imeeting into dev_ymcg
# Conflicts:
#	frontend/src/pages/business/MeetingDetail.tsx
2026-07-01 11:06:49 +08:00
puz cfb5aeff71 feat:调整优化 2026-07-01 11:01:38 +08:00
chenhao 69e6eff9d9 feat: 添加热词组支持并优化相关逻辑
- 在 `RealtimeMeetingResumeConfig` 和 `MeetingVO` 中添加 `hotWordGroupId` 和 `hotWordGroupName` 字段
- 更新 `MeetingCommandServiceImpl` 以设置 `hotWordGroupId` 到 `resumeConfig`
- 在 `MeetingDomainSupport` 中添加方法以解析和填充有效的热词组信息
- 更新前端 `meeting.ts` 和 `MeetingDetail.tsx` 以支持热词组显示
2026-07-01 09:42:31 +08:00
puz 92c8b126f5 Merge branch 'dev_na' of http://111.10.202.195:3000/chenh/imeeting into dev_ymcg 2026-07-01 09:01:44 +08:00
chenhao 202ce057fd feat: 更新模板服务逻辑以支持系统模板状态检查和用户权限控制 2026-06-30 17:27:57 +08:00
chenhao c89e508091 feat: 添加 max_tokens 参数以增强 LLM 请求的灵活性 2026-06-30 16:46:10 +08:00
puz 1ab6e37fe1 feat:首页调整 2026-06-30 15:25:51 +08:00
chenhao 2a95eca102 refactor:优化 MeetingSummaryPromptAssembler 和相关逻辑
- 更新 `MeetingSummaryPromptAssembler`,引入新的模板和占位符
- 在 `MeetingSummaryFileServiceImpl` 中重构代码以简化解析逻辑
- 更新 `SysParamKeys`,添加新的系统参数键
- 调整前端 `AiModels` 页面的本地模型验证逻辑
- 注释掉登录页面中的示例账户信息
2026-06-30 15:17:18 +08:00
puz d36beec479 Merge branch 'dev_na' of http://111.10.202.195:3000/chenh/imeeting into dev_ymcg 2026-06-30 13:40:30 +08:00
puz d5525496ea feat:前端项目页面重构 2026-06-30 13:37:33 +08:00
chenhao 065708497a refactor: 优化代码结构和样式
- 在 `AiTaskServiceImplTest` 中注释掉 `isRetryableAsrQueryExceptionShouldTreatHttpTimeoutAsRetryable` 测试方法
- 在 `MeetingSummaryFileServiceImpl` 中重构代码以简化解析逻辑,并添加 `unwrapCodeFence` 方法
- 更新 `vite.config.ts` 中的代理配置,使用新的 IP 地址
- 调整 `MeetingPreviewView.css` 中的样式,包括宽度、背景色、边框颜色和阴影
- 增加 `audio-range` 的 Webkit 和 Firefox 样式定义
- 在 `MeetingPreviewView.tsx` 中添加 `useEffect` 以重置音频状态和同步会议时长
2026-06-29 17:46:40 +08:00
chenhao fc034b8326 feat: 注释掉 API 密钥验证逻辑
- 在 `AiModelController` 中注释掉对 `dto.getApiKey()` 的验证逻辑
2026-06-29 14:39:21 +08:00
chenhao 0593e0c23d feat: 添加重试机制以增强 ASR 任务的稳定性
- 在 `AiTaskServiceImpl` 中引入 `RetryExecutor` 和 `RetryOptions`,以处理 ASR 任务提交和查询的超时及连接异常
- 添加多个单元测试以验证 ASR 任务重试逻辑
2026-06-29 14:25:32 +08:00
puz 6c970536b2 feat: 积分、算力、模型页面重构 2026-06-29 10:17:30 +08:00
puz 08d9024401 Merge remote-tracking branch 'origin/dev_na' into dev_ymcg 2026-06-29 10:14:13 +08:00
chenhao 57f343ddfd feat: 添加腾讯离线和实时 ASR 模型配置并优化相关逻辑
- 在 `AiModelServiceImpl` 和 `AiTaskServiceImpl` 中添加处理腾讯离线和实时 ASR 模型的逻辑
- 更新前端 `AiModels` 页面,支持腾讯 ASR 配置表单字段
- 在 `RealtimeMeetingSocketSessionServiceImpl` 中添加解析腾讯实时模型代码的方法
- 更新单元测试以验证腾讯 ASR 模型保存逻辑
2026-06-29 09:00:12 +08:00
chenhao 982425e21e Merge remote-tracking branch '个人/dev_na' into dev_na 2026-06-26 16:57:59 +08:00
chenhao 06c4f5d74e feat: 添加腾讯离线 ASR 支持并优化相关逻辑
- 在 `AiTaskServiceImpl` 中添加处理腾讯离线 ASR 任务的逻辑
- 更新 `AiModelServiceImpl` 中的腾讯 ASR 模型配置验证
- 在 `pom.xml` 中添加腾讯 ASR SDK 依赖
- 添加单元测试以验证腾讯离线 ASR 处理逻辑
2026-06-26 16:57:39 +08:00
chenhao 9c98e670e1 feat: 添加 WebSocket 心跳保活和资源清理逻辑
- 在 `RealtimeMeetingWebSocketConfig` 中添加配置,关闭 Tomcat 内置的 WebSocket keepalive 检测
- 在 `RealtimeAsrSession` 组件中添加心跳定时器和资源清理逻辑,防止资源泄漏
- 在 `RealtimeMeetingProxyWebSocketHandler` 中过滤前端心跳消息,避免转发给上游 ASR 服务
2026-06-26 16:56:58 +08:00
chenhao c0cc4b1c27 feat: 添加上游断开时的会议暂停处理逻辑并优化实时转录显示
- 在 `RealtimeAsrSession` 组件中添加 `handleUpstreamPauseError` 方法,处理上游断开时的会议暂停
- 更新 `LocalRealtimeAsrChannel` 类,移除不必要的回调调用
- 优化 `RealtimeMeetingTranscriptCacheServiceImpl` 中的说话人解析逻辑
- 移除 `RealtimeAsrSession` 组件中部分未使用的实时转录显示字段
2026-06-26 14:22:12 +08:00
puz 255e268138 Merge branch 'dev_na' of http://111.10.202.195:3000/chenh/imeeting into dev_ymcg
同步 dev_na 分支的最新更新,确保 dev_ymcg 包含远程修复的功能模块和代码变更,避免后续冲突。
2026-06-26 14:14:38 +08:00
puz 21d356bb58 初次重构 2026-06-26 14:13:40 +08:00
puz bf40b13383 界面优化 2026-06-26 14:04:24 +08:00
chenhao 77fe6d4e77 feat: 添加腾讯实时 ASR 支持并优化相关逻辑
- 在 `AiModelServiceImpl` 中添加腾讯 ASR 配置验证和处理逻辑
- 更新前端 `RealtimeAsrSession` 组件,支持 `sentenceKey` 和腾讯 ASR 配置
- 在 `RealtimeMeetingProxyWebSocketHandler` 中添加对腾讯 ASR 会话的处理
- 添加 `TencentRealtimeAsrChannel` 类,实现腾讯实时 ASR 通道逻辑
- 更新 `RealtimeMeetingSocketSessionServiceImpl` 和 `RealtimeMeetingSessionStateServiceImpl`,支持腾讯 ASR 会话状态管理
- 在 `RealtimeSocketSessionData` 中添加 `modelCode` 和 `mediaConfig` 字段
- 更新 `RealtimeMeetingTranscriptCacheItem`,添加 `sentenceGroupKey` 字段
- 在 `AiModels` 页面中添加腾讯 ASR 配置表单字段
- 添加 `TENCENT_PROVIDER` 常量,并在 `ModelProviderEnum` 中添加腾讯云枚举值
- 添加单元测试以验证腾讯 ASR 模型保存逻辑
2026-06-26 11:11:58 +08:00
chenhao ba62c9e0c0 Merge branch 'refs/heads/dev_na' into dev_asr_local 2026-06-26 11:10:37 +08:00
chenhao 033ffaacc9 feat: 优化会议状态解析和章节边界识别逻辑
- 在 `MeetingUnifiedStatusServiceImpl` 中添加 `MeetingUnifiedStageContext` 参数,优化阶段解析逻辑
- 在 `MeetingTranscriptChapterServiceImpl` 中更新章节边界识别的系统提示,使其更清晰
- 在 `AiTaskServiceImpl` 和 `MeetingProgressServiceImpl` 中增加对 AI 目录启用状态的检查,优化任务调度逻辑
2026-06-26 11:09:52 +08:00
chenhao a036c14673 feat: 添加异步分片合并和优化音频处理逻辑
- 在 `AndroidChunkUploadServiceImpl` 中添加 `completeUploadAsync` 方法,实现异步分片合并和上传
- 优化 `MeetingAudioUploadSupport` 中的音频文件存储和验证逻辑
- 更新 `AndroidMeetingController` 和 `AndroidMeetingChunkUploadController` 中的响应构建和日志记录逻辑
- 在 `MeetingQueryServiceImpl` 中更新 `getDetailIgnoreTenant` 方法,支持是否包含音频的参数
- 在 `LegacyMeetingAdapterServiceImpl` 中调用 `prewarmPlaybackAudioAfterCommit` 方法进行预热处理
2026-06-25 18:59:47 +08:00
chenhao 31a4c5c767 refactor: 使用 ModelProviderEnum 替代硬编码字符串并优化实时会议 WebSocket 处理逻辑
- 在 `AiModelServiceImpl`、`AiModelController` 和 `MeetingCommandServiceImpl` 中使用 `ModelProviderEnum` 替代硬编码的 "custom" 字符串
- 移除 `appendRealtimeTranscripts` 方法,并在 `RealtimeMeetingProxyWebSocketHandler` 中添加对 `RealtimeAsrChannel` 的支持
- 优化前端 `RealtimeAsrSession` 组件,移除不必要的方法并更新状态管理逻辑
- 添加 `RealtimeMeetingTranscriptCacheService` 和 `RealtimeMeetingTranscriptCacheState` 类,用于缓存和处理实时转录数据
2026-06-25 10:17:51 +08:00
chenhao 2bab042ca0 feat: 添加 AI 目录功能和相关逻辑
- 在 `MeetingUnifiedStatusServiceImpl`、`MeetingCommandServiceImpl` 和 `MeetingDomainSupport` 中添加 `resolveAiCatalogEnabled` 方法,用于检查 AI 目录是否启用
- 更新 `MeetingVO` 和 `MeetingCreateConfigVO`,添加 `aiCatalogEnabled` 字段
- 在 `MeetingController` 中添加 `aiCatalogEnabled` 参数,并更新响应构建逻辑
- 在前端页面中添加对 `aiCatalogEnabled` 的处理,包括 `MeetingDetail`、`MeetingPreview` 和 `MeetingPreviewView` 页面
- 在 `sys-params/index.tsx` 中添加 `MEETING_AI_CATALOG_ENABLED` 系统参数配置
- 更新 `AndroidPushGrpcService` 中的平台枚举,增加新的平台类型
- 优化 `AiTaskServiceImpl` 中的任务调度逻辑,支持并行和串行模式
2026-06-25 10:08:32 +08:00
chenhao 97065c68a6 feat: 添加离线会议转录失败处理逻辑和优化分片上传
- 在 `MeetingUnifiedStatusServiceImpl` 中添加 `isAndroidOfflineEmptyUploadFailure` 方法,处理安卓离线会议空上传失败情况
- 在 `MeetingCommandService` 和 `MeetingCommandServiceImpl` 中添加 `failOfflineTranscription` 方法,处理离线会议转录失败
- 优化 `AndroidChunkUploadServiceImpl` 中的分片上传和合并逻辑,增加对空文件的处理
- 更新 `AndroidMeetingController` 中的分片上传结果处理逻辑
2026-06-24 16:59:33 +08:00
chenhao d38acf5ccc refactor: 使用 `authenticateHttpIgnoreToken` 方法并优化响应构建逻辑
- 在 `AndroidMeetingChunkUploadController` 和 `AndroidMeetingController` 中使用 `authenticateHttpIgnoreToken` 方法进行认证
- 优化 `AndroidMeetingController` 中的响应构建逻辑,添加日志记录
2026-06-24 09:52:17 +08:00
puz 95f7f766c5 feat: 日志管理界面按钮优化 2026-06-23 16:14:43 +08:00
puz 11b726017d 数据字典管理分页调整 2026-06-23 15:45:33 +08:00
puz 5ff2ddc388 feat: 全局分页参数:滚动条修复,前端其他异常警告修复 2026-06-23 15:23:46 +08:00
chenhao b34efa5eb0 refactor: 优化设备和客户端管理页面样式和滚动逻辑
- 在 `devices/index.less` 中取消注释并应用必要的 CSS 规则
- 更新 `devices/index.tsx` 和 `business/ClientManagement.tsx` 中的滚动配置和样式
- 在 `backend/lombok.config` 中添加 `lombok.copyableAnnotations` 配置
2026-06-22 20:11:23 +08:00
chenhao 33caa1201f refactor: 优化 ListTable 组件滚动逻辑和样式
- 在 `ListTable` 组件中合并默认滚动配置
- 更新 `TenantMeetingPointsSettings`、`MeetingPointsManagement` 和 `logs/index` 页面的滚动设置
- 优化 `ListTable` 和相关页面的 CSS 样式
- 更新 `fetchLogs` 请求参数处理逻辑
2026-06-22 17:45:24 +08:00
puz 4499e6265b 修复会议管理的列表分页和声纹注册优化 2026-06-22 17:07:29 +08:00
puz 9585033303 Merge branch 'dev_na' of http://111.10.202.195:3000/chenh/imeeting into dev_na 2026-06-22 14:29:34 +08:00
chenhao 97ee737f8c refactor: 优化音频文件上传和分片处理逻辑
- 在 `MeetingAudioUploadSupport` 和 `LegacyMeetingAdapterServiceImpl` 中简化文件空值检查
- 在 `AndroidChunkUploadServiceImpl` 中重构分片状态重建和合并逻辑,增加对未完成分片的处理
- 更新 `AndroidMeetingController` 和 `AndroidChunkUploadServiceImpl` 中的文件头验证和存储逻辑
- 注释掉 `AndroidMeetingController` 中的部分权限检查代码
2026-06-22 14:29:46 +08:00
puz 7b0e100f6c 会议管理状态显示优化 2026-06-22 14:28:46 +08:00
puz d5738ca35d 合并冲突 2026-06-22 10:23:02 +08:00
puz 8c771abbb8 参数管理优化 2026-06-22 10:09:26 +08:00
chenhao bdc91f4f8b refactor: 优化字典和系统参数页面的表单处理和抽屉关闭逻辑
- 在 `dictionaries/index.tsx` 中添加 `closeTypeDrawer` 和 `closeItemDrawer` 函数,统一处理抽屉关闭和表单重置
- 在 `sys-params/index.tsx` 中添加 `closeDrawer` 函数,统一处理抽屉关闭和表单重置
- 更新 `dictionaries/index.tsx` 和 `sys-params/index.tsx` 中的相关方法,使用新的关闭函数
- 移除 `PageContainer` 组件,改用 `div` 和 `PageHeader` 组件进行页面布局
2026-06-22 09:41:03 +08:00
chenhao 194a05cbe0 feat: 添加密码找回页面和更新认证逻辑
- 新增 `forgot-password/index.tsx` 页面,实现密码找回功能
- 更新 `AndroidAuthServiceImpl`,添加 `authenticateHttpIgnoreToken` 方法,并在 `authenticateHttp` 方法中增加 `ignoreTokenValidation` 参数
- 更新 `AndroidAuthService` 接口,添加 `authenticateHttpIgnoreToken` 方法
- 更新 `AndroidDeviceController`,使用 `authenticateHttpIgnoreToken` 方法进行认证
- 优化 `AndroidDeviceRegistrationServiceImpl` 中的异常信息
- 更新 `.gitignore`,忽略不必要的文件和目录
2026-06-18 14:52:13 +08:00
chenhao ee1e75eda2 feat: 添加密码找回功能和相关接口
- 在 `auth.ts` 中添加 `PasswordPolicyPublic`、`PasswordRecoverySendCodePayload` 和 `PasswordRecoveryResetPayload` 接口
- 添加 `fetchPublicPasswordPolicy`、`sendPasswordRecoveryCode` 和 `resetPasswordByRecovery` API 函数
- 在 `index.ts` 中添加 `resetUserPassword` API 函数
- 更新 `http.ts` 中的 `AUTH_WHITELIST`,添加新的密码找回相关路径
- 在 `App.tsx` 中添加 `ForgotPasswordPage` 路由
- 在 `zh-CN.json` 和 `en-US.json` 中添加密码规则相关的翻译
- 在 `AndroidPushGrpcService.java` 中添加日志记录
- 添加 `LettuceRedisConfig` 配置类
- 在 `login/index.tsx` 中添加忘记密码链接
- 新增 `forgot-password/index.tsx` 页面,实现密码找回功能
- 更新 `global.css` 和 `reset-password/index.tsx` 的样式和逻辑
- 更新 `application.yml`,添加邮件配置
- 更新 `tsconfig.tsbuildinfo` 文件
2026-06-18 13:43:27 +08:00
chenhao 7233f13598 refactor:优化设备列表样式和更新 Redis 支持
- 在 `devices/index.less` 中注释掉不必要的 CSS 规则
- 更新 `AndroidAuthServiceImpl` 和 `AndroidDeviceRegistrationServiceImpl` 中的异常信息和方法简化
- 在 `MeetingCreateDrawer.tsx` 中启用文本精炼功能
- 在 `devices/index.tsx` 中使用通用成功消息
- 在 `DeviceOnlineManagementServiceImpl` 中添加对终端类型的映射
- 更新 `ClientManagement.tsx` 中的平台类型选项
- 在 `MeetingPointsManagement.tsx` 中注释掉当前可用额度显示
- 在 `scan-confirm/index.tsx` 中更新登录确认消息
- 更新 `RedisSupport` 以使用 Lettuce 库并调整相关方法
2026-06-17 15:16:08 +08:00
chenhao f787f867bb feat: 添加 ASR 任务许可缓存和优化任务调度逻辑
- 在 `AiTaskServiceImpl` 中添加 `MeetingAsrPermitCache` 依赖,用于管理 ASR 任务的许可
- 更新 `claimQueuedAsrTaskForScheduling` 方法,使用 `MeetingAsrPermitCache` 进行许可检查
- 在 `dispatchTasks` 方法中更新并发任务数量计算,考虑许可缓存中的许可数量
- 在 `requeueAsrTask` 和 `finally` 块中移除许可,确保资源释放
- 在 `RedisSupport` 中添加 `addToSetQuietly`、`isSetMemberQuietly` 和 `getSetSizeQuietly` 方法,支持许可缓存操作
2026-06-16 19:23:28 +08:00
chenhao 8d4a31e043 feat: 添加最小会议时长配置和优化设备列表样式
- 在 `AndroidMeetingController` 和 `AndroidMeetingConfigVo` 中添加最小会议时长配置
- 优化 `ListTable` 组件的滚动样式和逻辑
- 更新 `devices/index.less` 和 `devices/index.tsx`,改进设备列表单元格样式和内容展示
2026-06-16 16:35:17 +08:00
chenhao 443e067b30 feat: 添加设备更新接口和优化设备注册逻辑
- 在 `AndroidDeviceServiceImpl` 中添加 `updateDevice` 方法,支持更新设备信息
- 优化 `AndroidDeviceRegistrationServiceImpl` 中的设备名称设置逻辑
- 更新 `AndroidDeviceController`,添加新的 `update` 接口以支持设备信息更新
- 调整 `DeviceInfoMapper` 中的 `updateBaseInfoByIdIgnoreTenant` 方法,移除不必要的字段更新
2026-06-15 17:23:17 +08:00
chenhao 3c299aaf3b feat: 添加平台管理员权限和优化会议状态处理逻辑
- 在 `usePermission` 钩子中添加 `isPlatformAdmin` 权限检查
- 在 `TenantMeetingPointsSettings` 页面中根据权限控制按钮显示
- 优化 `MeetingCommandServiceImpl` 和 `MeetingUnifiedStatusServiceImpl` 中的会议状态处理逻辑
- 更新 `Meetings.tsx` 中的会议状态判断逻辑
- 在 `AiTaskServiceImpl` 中添加 `AndroidMeetingPushService` 依赖,并在任务完成或失败时推送会议状态变化
2026-06-15 16:15:36 +08:00
chenhao 4572d15bea feat: 添加会议状态推送和优化任务调度逻辑
- 在 `AiTaskServiceImpl` 中添加对轮询锁的检查,防止提前 claim 队列任务
- 在 `AndroidMeetingController` 中添加 `AndroidMeetingPushService` 依赖,并在 `retryTranscription` 和 `retrySummary` 方法中推送会议状态变化
- 移除 `AndroidMeetingController` 中未使用的代码块和方法
2026-06-15 13:47:37 +08:00
chenhao c0d2dcce3d refactor: 优化参与者列表生成逻辑
- 在 `MeetingPreviewView.tsx` 中更新 `participants` 的生成逻辑,当 `meeting?.participants` 为空时,从 `transcripts` 中提取唯一发言人并生成列表
2026-06-12 17:45:04 +08:00
chenhao 236176d5ff refactor: 优化会议预览页面的分享设置样式和功能
- 更新 `MeetingPreviewView.css`,移除未使用的类并优化样式
- 在 `MeetingPreviewView.tsx` 中添加密码修改模态框,支持访问密码的设置和修改
- 优化分享设置的显示逻辑,新增紧凑模式和密码显示功能
2026-06-12 17:36:52 +08:00
chenhao 877a4a0654 feat: 添加会议预览页面和样式
- 新增 `MeetingPreviewView` 组件,用于展示会议预览页面
- 添加 `MeetingPreviewView.css` 样式文件,定义会议预览页面的样式
- 实现会议基本信息、AI 纪要、AI 目录和转录原文的展示
- 支持分享设置、复制链接和章节跳转功能
2026-06-12 17:22:25 +08:00
chenhao fee7f2a0b3 refactor: 优化 ASR 和总结任务调度逻辑并移除未使用的线程池配置
- 移除 `@Async` 注解和未使用的 `asrDispatchExecutor` 配置
- 更新 `dispatchTasks`、`dispatchChapterTask` 和 `dispatchSummaryTask` 方法,添加日志记录和线程池检查
- 在 `doDispatchTasks`、`doDispatchChapterTask` 和 `doDispatchSummaryTask` 方法中添加详细的日志记录和异常处理
- 调整 `MeetingAsyncExecutorConfig` 中的线程池配置,移除 `asrDispatchExecutor` 和 `summaryDispatchExecutor`,更新 `asrTaskExecutor` 和 `summaryTaskExecutor` 的配置
2026-06-12 16:34:15 +08:00
chenhao c64c8b5690 refactor: 移除 `ExistingOfflineMeetingException` 并更新相关逻辑
- 移除 `ExistingOfflineMeetingException` 类
- 更新 `AndroidMeetingController` 中的异常处理,使用 `BusinessException` 和 `BusinessErrorCodeEnum`
- 优化 `AndroidDeviceHomeServiceImpl`,添加 `TenantMeetingPointsSettingService` 依赖并更新积分校验逻辑
- 将 `AboutPage` 页面移至 `ProfilePage` 的模态框中
- 更新 `MeetingUnifiedStatusServiceImpl` 的代码格式和逻辑顺序
2026-06-12 14:00:45 +08:00
chenhao fd9ef5c885 feat: 添加租户业务数据逻辑删除和优化会议状态处理
- 在多个 Mapper 中添加 `logicalDeleteByTenantId` 方法,支持按租户 ID 逻辑删除数据
- 优化 `MeetingUnifiedStatusServiceImpl` 中的会议状态处理逻辑,调整 `isAndroidOfflineMeetingWaitingUpload` 的调用位置
- 更新 `Meetings.tsx` 中的会议状态判断逻辑,新增 `isUnifiedTerminalProgress` 方法
- 优化 `AndroidChunkUploadServiceImpl` 中的文件上传逻辑,移除不必要的 `try-finally` 块
- 在 `TenantManagementServicePrimaryImpl` 中添加逻辑删除租户业务数据的方法,并更新相关依赖注入
2026-06-12 09:09:44 +08:00
chenhao 146b31b809 feat: 添加租户积分校验设置页面和优化 Android 上传服务
- 新增 `TenantMeetingPointsSettings` 页面,用于管理租户积分校验设置
- 优化 `AndroidChunkUploadServiceImpl`,新增 `normalizeChunkSourceFileName` 方法以处理文件名规范化
2026-06-11 17:22:57 +08:00
chenhao 6e4d10427a feat: 添加租户积分校验设置和前端页面优化
- 在 `MeetingPointsServiceImpl` 中新增租户积分校验逻辑,支持无限余额模式
- 更新 `MeetingPointsManagement` 页面,移除不必要的组件和样式,优化统计卡片和表格布局
- 新增 `TenantMeetingPointsSettings` 页面,用于管理租户积分校验设置
2026-06-11 17:10:10 +08:00
chenhao e330edc965 refactor: 更新异常处理和前端页面布局
- 在 `AndroidAuthServiceImpl` 中将 `RuntimeException` 替换为 `BusinessException`,并使用 `ErrorCodeEnum.UNAUTHORIZED`
- 优化前端 `MeetingPointsManagement` 页面布局,移除不必要的组件和样式
- 添加个人账户分页功能和新的统计卡片样式
- 调整积分管理页面的表格和标签样式,提升用户体验
2026-06-11 14:38:07 +08:00
chenhao 2e05a25e63 feat: 增强会议总结和积分管理功能
- 添加 `MeetingSummaryPromptAssembler` 依赖并更新相关方法
- 优化 `resolveHostName` 方法为 `resolveMeetingUserName`,并调整相关调用
- 更新 `updateMeetingBasic` 方法,支持更新摘要模型 ID 和提示 ID
- 在 `retrySummary` 和 `retryChapter` 方法中添加对摘要模型 ID 和提示 ID 的处理
- 优化前端积分管理页面,新增个人账户余额画廊和统计卡片
- 调整积分管理页面的布局和样式,提升用户体验
2026-06-10 20:43:35 +08:00
chenhao 178b920581 refactor: 移除会议完成推送逻辑并更新积分校验处理
- 移除 `AndroidMeetingPushService` 中的 `pushMeetingCompleted` 方法及相关调用
- 更新 `MeetingCommandServiceImpl` 和 `AiTaskServiceImpl`,移除对 `AndroidMeetingPushService` 的依赖
- 新增 `assertSufficientPointsBeforeAsrSubmit` 和 `assertSufficientPointsBeforeSummarySubmit` 方法到 `MeetingPointsService`,并在 `AiTaskServiceImpl` 中调用这些方法以确保在提交 ASR 和总结任务前有足够的积分
- 更新前端表单占位符和多语言文件中的文本
2026-06-10 18:23:04 +08:00
chenhao b07bbe90a0 feat: 添加 Android 会议相关 DTO 和积分处理 DTO
- 新增 `AndroidMeetingListItemVO` 和 `AndroidMeetingCreateResponse` 用于 Android 会议列表和创建响应
- 新增 `MeetingPointsTransferRequest` 用于公共积分分配给个人请求
- 新增 `MeetingPointsChargeItemVO` 用于会议积分扣费明细视图
2026-06-10 16:05:09 +08:00
chenhao 1e7a8ad83c feat: 更新 Android 会议控制器,添加 ASR 重试和总结重试功能
- 新增 `retryTranscription` 和 `retrySummary` 方法,支持重试 ASR 识别和会议总结
- 更新 `create` 方法,返回 `AndroidMeetingCreateResponse` 对象
- 更新 `list` 方法,返回 `AndroidMeetingListItemVO` 列表
- 添加辅助方法 `buildAndroidMeetingCreateResponse` 和 `buildAndroidMeetingListPage` 用于构建响应对象
- 引入 `h5BaseUrl` 配置项,用于生成会议预览 URL
2026-06-10 16:00:54 +08:00
chenhao 11c3ee1b09 refactor: 移除会议用户统计相关代码并更新设备认证逻辑
- 移除 `MeetingUserStats` 相关实体、服务和映射器
- 更新 `AndroidPushGrpcService` 和 `AndroidAuthServiceImpl`,添加 `userId` 和 `tenantId` 参数
- 优化 `AndroidDeviceHomeServiceImpl` 中的积分计算逻辑
- 在 `MeetingPointsController` 中新增 `transfer` 方法,支持从公共账户分配积分给个人账户
- 更新 `MeetingPointsLedgerListItemVO` 中的 `pointsType` 描述
- 优化 `AndroidDeviceBindingServiceImpl` 中的设备登录态验证逻辑
2026-06-10 15:25:58 +08:00
chenhao 1813b90c6a feat: 引入会议状态枚举并更新相关逻辑
- 新增 `MeetingStatusEnum` 枚举类,定义会议状态及其相关方法
- 更新多个服务和控制器中的会议状态处理逻辑,使用 `MeetingStatusEnum` 代替硬编码的状态值
- 优化会议状态判断和设置的相关代码,提高可读性和维护性
2026-06-10 09:51:53 +08:00
chenhao 82dc485dcf feat: 添加 H5 基础 URL 和匿名访问支持
- 在 `AndroidScreenSaverCatalogVO` 中新增 `h5BaseUrl` 字段
- 更新 `vite.config.ts`,设置基础路径为 `/H5/`
- 更新 `main.tsx`,设置 `BrowserRouter` 的 `basename` 为 `/H5`
- 在 `AndroidMeetingController` 的 `config` 方法上添加 `@Anonymous` 注解
- 在 `AndroidScreen
2026-06-09 18:09:17 +08:00
chenhao 1cce0aeabb feat: 添加授权码管理和设备自注册功能
- 新增 `AndroidTenantProviderConfig` 配置类,提供租户解析逻辑
- 更新 `AndroidAuthService` 接口,添加 `authenticateHttp` 方法的可选参数
- 新增前端授权码管理页面 `LicenseManagement`,支持授权码列表展示和导入
- 新增 `AndroidDeviceRegistrationServiceImpl` 的 `register` 方法,支持设备自注册并验证租户代码
- 更新 `AndroidAuthServiceImpl`,在认证过程中应用授权信息
- 更新相关 DTO 和接口,支持新的授权和设备注册逻辑
2026-06-09 17:09:45 +08:00
chenhao d47a66febd fix: 修正 ffmpeg 路径和音频处理逻辑
- 更新 `application-dev.yml` 中的 `ffmpeg-path` 配置
- 在 `SpeakerServiceImpl` 中添加 `StandardCharsets.UTF_8` 编码
- 优化 `LegacyMeetingAdapterServiceImpl` 和 `MeetingDomainSupport` 中的音频处理逻辑
2026-06-09 09:22:09 +08:00
chenhao e1e321a86d feat: 添加会议进度通知和离线会议冲突处理
- 在 `MeetingProgressServiceImpl` 中添加 `notifyUnifiedStatusChangedIfNeeded` 方法,用于通知会议状态变更
- 新增 `ExistingOfflineMeetingException` 和 `AndroidOfflineMeetingConflictVO` 类,用于处理离线会议冲突
- 更新前端 `Meeting.ts` 和 `MeetingPreview.tsx`,添加统一会议状态字段和访问校验逻辑
- 新增 `AndroidPublicLoginConfirmPayload` 和 `AndroidUnifiedMeetingStatusRequest` DTO 类,用于公有设备登录确认和统一会议状态查询
2026-06-08 16:19:40 +08:00
chenhao 30414bd41f refactor: 注释掉 ASR 本地模型配置和前端声纹模型相关代码
- 在 `AiModelServiceImpl` 中注释掉 ASR 本地模型配置保存逻辑
- 在 `AiModels.tsx` 中注释掉声纹模型表单字段
2026-06-08 10:19:28 +08:00
chenhao 1b41693597 refactor: 更新 ASR 本地模型连接测试和分片上传逻辑
- 修改 `AiModelServiceImpl` 中的 `fetchLocalProfile` 方法,更新目标 URL 和解析逻辑
- 添加新的 `extractStringArray` 方法以提取字符串数组
- 更新 `AndroidChunkUploadService` 接口,移除 `totalChunks` 参数
- 修改 `LegacyMeetingAdapterServiceImpl` 和 `AndroidChunkUploadServiceImpl` 中的分片上传逻辑,支持合并音频文件
- 更新 `AndroidMeetingController` 中的 `finishOfflineMeeting` 方法,处理分片上传完成后的逻辑
- 优化 `MeetingServiceImpl` 中的 `getById` 方法,忽略租户信息
- 更新前端 `AiModels.tsx` 中的表单验证规则和隐藏部分字段
2026-06-05 18:31:44 +08:00
chenhao 2e20799b4b 安卓会议重构 2026-06-05 15:07:45 +08:00
chenhao 40bf049a0e feat: 添加会议积分管理功能
- 新增 `MeetingPointsController` 用于查看会议积分余额
- 新增 `MeetingPointsBalanceVO` DTO 类,表示会议积分余额视图
- 新增前端页面 `MeetingPointsManagement`,展示会议积分余额和消耗流水
- 新增 `MeetingSummaryChargeRecordServiceImpl` 服务实现类
2026-06-04 14:18:45 +08:00
chenhao e7659b1e31 feat: 添加设备自注册接口并优化设备在线管理逻辑
- 新增 `AndroidDeviceController` 用于设备自注册
- 优化 `DeviceOnlineManagementServiceImpl` 和 `AndroidDeviceBindingServiceImpl` 中的设备在线管理逻辑
- 更新 `DeviceInfoMapper` 添加 `updateBaseInfoByIdIgnoreTenant` 方法
- 在 `AndroidPublicMeetingController` 中添加设备未注册时的异常处理
- 更新 `AndroidAuthService` 和 `AndroidAuthServiceImpl` 支持设备是否需要注册的验证
2026-06-02 19:35:17 +08:00
chenhao 8716608afa feat: 添加公共设备会议创建页面
- 新增 `PublicDeviceMeetingCreate` 组件,用于创建公共设备会议
- 支持选择 ASR 模型、总结模型、总结模板等配置
- 提供参会人员、主持人、会议标签、访问密码等字段
- 实现表单验证和提交功能,支持推送到设备
2026-06-02 17:20:25 +08:00
chenhao 7c3b65624e refactor: 重构会议进度管理和Android设备绑定服务
- 移除 `RedisOnlyMeetingProgressServiceAdapter` 和 `RedisValueSupport` 类
- 更新 `MeetingProgressServiceImpl` 使用新的 `MeetingProgressCache`
- 重构 `MeetingTaskRecoveryListener` 使用 `MeetingLockCache` 和 `MeetingAsrPermitCache`
- 添加 `AndroidDeviceBindingService` 和 `AndroidPushMessageService` 接口及其实现类
- 新增 `AndroidPublicMeetingSessionRequest` 和 `AndroidPublicMeetingSessionVO` DTO 类
- 更新 `AndroidMeetingPushService` 及其实现类,添加推送待处理会议功能
2026-06-02 17:19:40 +08:00
chenhao fb5c4b545e Merge branch 'dev_zy' into dev_na 2026-06-01 17:50:41 +08:00
chenhao 3860ba3e00 feat: 添加请求日志记录和 gRPC 连接快照功能
- 引入 `AndroidRequestLogHelper` 以记录请求日志
- 新增 `AndroidGrpcConnectionSnapshotVO` 和 `AndroidGrpcConnectionDetailVO` 类,用于表示 gRPC 连接快照和详情
2026-06-01 17:49:15 +08:00
chenhao 8a082732df feat: 添加 ASR 任务重新调度功能并优化代理配置
- 在 `AiTaskService` 中添加 `triggerQueuedAsrScheduling` 和 `retryScheduleMeeting` 方法
- 更新多个服务实现类以使用新的 ASR 任务调度方法
- 在前端 `MeetingDetail.tsx` 和 `Meetings.tsx` 中添加重新调度按钮和相关逻辑
2026-06-01 13:42:50 +08:00
chenhao 92a12c4c81 feat: 添加会议完成推送功能并优化相关服务
- 引入 `AndroidMeetingPushService` 用于推送会议完成通知
- 在 `MeetingCommandServiceImpl` 和 `AiTaskServiceImpl` 中添加 `pushMeetingCompletedAfterCommitIfNeeded` 方法,确保事务提交后触发推送
- 更新 `MeetingInternalWorkflowController` 以支持手动触发会议完成推送和查询 gRPC 连接详情
- 新增 `MeetingPushTypeEnum` 枚举类,定义推送类型
- 优化 `AndroidGatewayPushService` 接口,添加用户级别的推送方法和连接快照功能
- 更新 `AndroidPushGrpcService` 和 `AndroidGatewayPushServiceImpl` 以支持新的注册参数和推送逻辑
2026-05-28 16:18:41 +08:00
chenhao 7f9c080bf7 feat: 添加请求日志记录并优化前端样式
- 在多个 Android 控制器中引入 `AndroidRequestLogHelper` 以记录请求日志
- 优化 `index.less` 和 `index.tsx` 文件,确保表格滚动和高度限制生效
2026-05-28 14:11:20 +08:00
chenhao 47ebeade20 refactor: 优化 `AndroidMeetingController` 中的包丢失率处理逻辑
- 引入 `RoundingMode` 以确保 `packetLossRate` 精度为两位小数
- 更新 `setPacketLossRate` 方法,使用 `setScale` 方法进行四舍五入处理
2026-05-27 17:56:48 +08:00
chenhao 384494d9ff feat: 添加总结和章节任务重试功能并优化会议处理逻辑
- 在 `MeetingCommandServiceImpl` 中添加 `retrySummary` 和 `retryChapter` 方法,支持总结和章节任务的重试
- 更新 `MeetingCommandService` 接口以包含新的重试方法
- 优化 `executeSummaryFlow` 和 `doDispatchChapterTask` 方法,简化任务执行逻辑
- 更新 `finalizeSummary` 方法,移除不必要的章节版本检查
- 调整 `updateMeetingProgress` 的进度值和消息,更准确地反映任务状态
- 在前端 `MeetingDetail.tsx` 中添加重试按钮和相关逻辑,支持用户手动重试失败的任务
2026-05-27 17:44:15 +08:00
chenhao 892275bc65 refactor:优化 LegacyMeetingController 测试用例
- 重构 `LegacyMeetingControllerTest` 类,提升测试覆盖率和代码可读性
- 更新测试方法以验证不同场景下的响应数据和状态码
- 确保测试用例覆盖会议预览、列表和密码更新等功能
2026-05-27 10:22:38 +08:00
chenhao a046ecf05b refactor: 删除 MeetingTranscriptRevisionServiceImpl 类
- 移除 `MeetingTranscriptRevisionServiceImpl` 类及其相关方法和逻辑
- 该类涉及会议转录修订的生成、解析和更新等功能
2026-05-22 17:28:59 +08:00
chenhao 188809305e feat: 添加 Redis 支持和优化会议进度管理
- 新增 `RedisValueSupport` 类,提供 Redis 操作支持
- 实现 `MeetingProgressServiceImpl` 服务,管理会议进度的读写操作
- 更新 `MeetingMcpToolService` 和 `MeetingCommandServiceImpl` 以使用新的进度服务
- 优化会议进度解析和状态更新逻辑
2026-05-19 17:49:41 +08:00
chenhao 7989b6aa11 refactor: 优化会议创建按钮逻辑和生成进度显示
- 简化 `Meetings.tsx` 中的会议创建按钮逻辑
- 在 `MeetingDetail.tsx` 中添加 `MeetingStateNotice` 类型,并更新生成进度显示
- 优化生成失败提示和展示逻辑,增加对历史内容的支持
- 更新相关组件以支持新的生成进度和状态显示
2026-05-14 09:20:11 +08:00
chenhao 7d08234919 feat: 添加分页和优化会议任务处理逻辑
- 在前端 `Dashboard` 页面中添加分页功能
- 优化 `AiTaskServiceImpl` 中的 ASR 任务处理逻辑,支持任务恢复和失败处理
- 更新相关服务和组件以支持新的分页和任务处理逻辑
2026-05-13 18:12:25 +08:00
chenhao f7480df565 feat: 添加总结详细程度选项和优化用户认证逻辑
- 在 `MeetingCommandService` 中添加 `summaryDetailLevel` 参数
- 更新 `MeetingSummaryPromptAssembler` 以支持不同详细程度的总结
- 在前端 `useAuth` 钩子中添加 `profileVersion` 状态,同步用户资料更新
- 优化密码重置流程,保持登录状态并更新用户资料
- 在 `Meeting` 实体中添加 `summaryDetailLevel` 字段
- 更新相关服务和控制器以处理新的总结详细程度选项
2026-05-13 14:31:48 +08:00
chenhao 51190f330c feat: 添加外部总结编排触发和失败处理功能
- 新增 `MeetingSummaryOrchestrationTriggerResultVO` 数据传输对象
- 实现 `MeetingExternalSummaryWebhookTrigger` 服务,用于触发外部 n8n 总结编排
- 在 `MeetingCommandServiceImpl` 中添加 `triggerExternalSummaryOrchestration` 和 `markExternalSummaryOrchestrationFailed` 方法
- 更新 `MeetingCommandService` 接口以支持新的方法
- 在 `AiTaskServiceImpl` 中添加 `triggerExternalSummaryWebhook` 方法
- 在 `MeetingController` 中添加手动触发外部 n8n 总结编排的 API
- 新增 `MeetingMarkdownBundleMcpToolProvider` 以提供会议 Markdown 包工具
2026-05-12 16:44:09 +08:00
chenhao 3469884bca refactor: 优化会议预览页面的密码输入界面和样式
- 更新密码提示和占位符文本
- 重构密码输入界面布局和样式
- 添加背景动画和视觉效果
- 优化移动端显示
2026-05-11 20:54:51 +08:00
chenhao a49888587f feat: 添加设备分页和搜索功能
- 引入 `ListTable` 和 `AppPagination` 组件
- 添加分页状态和处理函数
- 更新搜索逻辑以重置分页
- 重构表格列定义和数据加载逻辑
2026-05-11 20:14:37 +08:00
chenhao b9ec41095f feat: 添加会议MCP工具服务和优化章节查询
- 新增 `MeetingMcpToolService` 以提供会议列表、预览、详情和Markdown包
- 在 `MeetingQueryService` 中添加忽略租户的章节查询方法
- 更新 `MeetingPublicPreviewController` 以使用新的章节查询方法
2026-05-11 18:09:31 +08:00
chenhao ccb408ade5 feat:优化会议转录和章节功能
- 更新 `MeetingTranscriptFileServiceImpl`,使用 Markdown 格式导出会议转录
- 在 `MeetingQueryServiceImpl` 中添加会议存在性检查,并调用 `listDisplayChapterAnalysis`
- 在 `MeetingCommandServiceImpl` 中更新会议进度信息
- 在前端 `http.ts` 和 `meeting.ts` 中添加 `suppressErrorToast` 选项
- 在 `MeetingVO` 中添加最近一次总结和章节尝试的任务状态及错误信息
- 更新 `MeetingDetail.tsx` 和 `Meetings.tsx`,处理生成失败的提示和展示逻辑
- 在 `MeetingTranscriptChapterService` 和 `MeetingTranscriptChapterServiceImpl` 中添加加载当前章节 Markdown 的方法
- 优化 `PageContainer` 和 `Meetings` 页面布局,改善滚动和内容展示
2026-05-11 10:54:33 +08:00
alanpaine 1877c64cc2 fix(系统参数): 移除表格的onChange和totalCount属性并添加滚动条
表格配置中移除不再使用的onChange和totalCount属性,同时添加垂直滚动条以改善长列表的浏览体验
2026-05-11 10:53:18 +08:00
chenhao beab0edabe feat: 添加会议章节和目录功能
- 在 `PublicMeetingPreviewVO` 中添加 `chapters` 字段
- 更新 `MeetingPreview` 组件以支持章节和目录展示
- 添加新的 `pageCatalog` 选项卡,展示 AI 生成的目录
- 实现章节与转录内容的关联和跳转功能
- 优化关键词和总结内容的展示布局
2026-05-09 17:33:00 +08:00
chenhao 38edf9dad6 refactor: 优化会议总结和关键词展示布局
- 移除 `linkifySummary` 和 `MarkdownSummary` 组件
- 优化关键词和讨论点的展示逻辑
- 重构会议总结编辑和导出功能的交互
- 更新样式以改善整体视觉效果
2026-05-09 15:19:49 +08:00
chenhao a34885111c feat: 添加会议章节导入和总结功能
- 在 `MeetingCommandService` 中添加 `importTranscriptChapters` 和 `finalizeSummary` 方法
- 更新 `MeetingSummaryPromptAssembler` 以支持章节模型和摘要源
- 在 `MeetingQueryService` 中添加获取章节和转录源的方法
- 新增 `MeetingSummaryFinalizeDTO` 和 `MeetingSummaryPromptContextVO` 数据传输对象
- 在 `MeetingCommandServiceImpl` 中实现章节导入和总结任务创建逻辑
- 更新前端 `meeting.ts` 以支持获取章节信息
2026-05-09 13:48:09 +08:00
alanpaine 9b63a1ec4e feat: 引入PageContainer组件并重构页面布局
refactor: 使用PageContainer统一管理页面布局结构

style: 优化页面布局样式和响应式设计

chore: 添加批量导入和重构脚本

build: 新增PageContainer组件及相关依赖

docs: 更新页面布局相关文档

perf: 提升页面渲染性能和布局一致性
2026-05-09 10:17:46 +08:00
chenhao eba6bf105e feat: 添加平台运行时信息和多租户支持
- 在 `auth.ts` 和 `platform.ts` 中添加获取平台运行时信息的 API
- 在 `AppLayout.tsx` 和 `login/index.tsx` 中处理平台运行时信息,并更新用户界面
- 在 `DeviceInfoMapper.java` 中添加忽略租户的更新方法
- 更新日志清理和模块获取的 API 以支持多租户
- 在 `tenants/index.tsx` 和 `logs/index.tsx` 中添加多租户相关的逻辑和 UI 改进
2026-05-07 09:46:22 +08:00
chenhao c0e973e5a9 refactor: 优化 Android gRPC 推送服务日志记录和错误处理
- 在 `AndroidPushGrpcService` 中添加详细的日志记录,包括连接、心跳、ACK 和错误处理
- 更新 `sendError` 方法以包含设备信息,并在日志中记录错误响应
- 在 `LegacyAuthController` 中添加租户 ID 设置逻辑
- 更新 `MeetingSummaryPromptAssembler` 中的提示词,明确关键词基于会议转写生成
- 移除 `AndroidAuthServiceImplTest` 测试类
2026-05-06 16:38:05 +08:00
chenhao 5b4304a4b2 feat: 添加日志注解并更新相关操作描述
- 在多个控制器中添加 `@Log` 注解,记录新增、修改和删除操作
- 更新前端 `MeetingDetail.tsx` 中的按钮文本和注释
- 优化会议管理相关接口的日志记录
2026-05-06 10:31:06 +08:00
chenhao aed87e8ad3 feat: 添加关键词高亮和跳转功能
- 在 `MeetingDetail.tsx` 中添加 `linkifySummary` 和 `MarkdownSummary` 组件,支持关键词高亮和虚拟链接
- 更新 `ActiveTranscriptRow` 组件以支持关键词高亮和自动滚动到匹配项
- 增加 `handleKeywordClick` 回调函数,处理关键词点击事件并跳转到相应位置
- 优化样式,添加高亮文本的动画效果和样式调整
2026-05-06 10:09:07 +08:00
chenhao 6445d429f8 refactor: 优化会议总结和详情页面布局
- 移除 `MeetingSummaryPromptAssembler` 中未使用的分析字段
- 更新前端 `MeetingDetail.tsx` 布局,移除不必要的组件并优化界面显示
- 重构会议详情页头部,引入 `PageHeader` 组件
- 优化关键词和讨论点的展示逻辑
- 简化会议总结编辑和导出功能的交互
2026-05-06 09:41:07 +08:00
chenhao f20be9bcc6 refactor: 移除 Android gRPC 服务和相关测试代码
- 删除 `AndroidGatewayGrpcService` 及其依赖的 DTO 和接口
- 删除 `AndroidRealtimeGrpcManualTest` 手动测试类
- 删除与 Android 实时会议 gRPC 会话相关的数据传输对象和业务逻辑类
2026-04-30 16:47:30 +08:00
chenhao 4904526e09 feat: 添加会议转录文件服务
- 新增 `MeetingTranscriptFileServiceImpl` 实现会议转录文件的初始化和导出功能
- 定义 `MeetingTranscriptExportResult` 数据传输对象,用于封装导出结果
- 定义 `MeetingTranscriptFileService` 接口,提供初始化和导出会议转录文件的方法
2026-04-30 09:18:11 +08:00
chenhao a8b93a46f8 feat: 添加会议转录文件服务
- 新增 `MeetingTranscriptFileServiceImpl` 实现会议转录文件的初始化和导出功能
- 定义 `MeetingTranscriptExportResult` 数据传输对象,用于封装导出结果
- 定义 `MeetingTranscriptFileService` 接口,提供初始化和导出会议转录文件的方法
2026-04-28 15:51:39 +08:00
chenhao 35698287de feat: 添加会议转录文件初始化和下载功能
- 在 `MeetingCommandServiceImpl` 和 `AiTaskServiceImpl` 中添加 `initializeTranscriptFileIfAbsent` 方法调用
- 在 `MeetingController` 中添加 `exportTranscripts` 接口,支持下载会议转录 Markdown 文件
- 更新前端 `meeting.ts` 和 `MeetingDetail.tsx` 以支持会议转录文件的下载
- 在相关测试类中添加对 `MeetingTranscriptFileService` 的 mock
2026-04-28 10:34:15 +08:00
chenhao aaa2624fe2 feat: 添加音频预处理和播放支持
- 在 `application-dev.yml` 中添加 FFmpeg 路径配置
- 在 `MeetingCommandServiceImpl` 和 `MeetingQueryServiceImpl` 中更新 `fillMeetingVO` 方法签名,并在适当位置调用 `prewarmPlaybackAudioAfterCommit`
- 新增 `MeetingPlaybackAudioResolver` 类,用于处理音频文件的浏览器兼容性转换
- 在前端 `MeetingPreview.tsx` 和 `MeetingDetail.tsx` 中更新音频 URL 处理逻辑,使用新的 `resolveMeetingPlaybackAudioUrl` 方法
2026-04-27 15:16:08 +08:00
chenhao 6600d37757 feat: 添加 M4A 文件验证和音频播放错误处理
- 在 `MeetingAudioUploadSupport` 中添加 M4A 文件验证逻辑,确保文件可播放
- 更新前端 `MeetingPreview.tsx` 和 `MeetingDetail.tsx` 以处理音频播放错误,并显示相应的警告信息
- 在 `WebMvcConfig` 中配置 M4A 媒
2026-04-27 10:39:34 +08:00
chenhao 5aefcf8d7d feat: 更新会议管理逻辑和界面
- 重构 `Meetings.tsx` 中的 `canManageRealtimeMeeting` 为 `canManageMeeting`,并更新相关调用
- 在会议卡片中添加创建人信息,并优化会议时间显示
- 增加权限检查,确保只有会议创建人或管理员可以编辑参会人
2026-04-24 17:30:26 +08:00
chenhao e6580beaa8 feat: 添加音频文件大小验证和系统参数配置
- 在 `MeetingAudioUploadSupport` 中添加 `validateFileSize` 方法,验证上传的音频文件大小
- 引入 `SysParamService` 以获取系统参数配置的最大上传大小
- 在前端 `MeetingCreateDrawer.tsx` 中添加文件大小验证逻辑,并显示系统配置的最大大小
2026-04-24 15:47:52 +08:00
chenhao 2f80c6c55e refactor: 优化错误消息为中文
- 将多个服务和控制器中的英文错误消息替换为中文
- 更新测试用例以匹配新的错误消息
2026-04-24 14:27:28 +08:00
chenhao a295a3b15b refactor: 优化会议访问密码更新逻辑和屏保实体字段策略
- 在 `LegacyMeetingController` 和 `AndroidMeetingController` 中使用 `LambdaUpdateWrapper` 更新会议访问密码
- 更新 `ScreenSaver` 实体的 `ownerUserId` 字段策略为 `ALWAYS`
- 添加相关测试用例以验证屏保实体字段策略和更新逻辑
- 优化 `AiModels.tsx` 中的提供商基础 URL 和表单占位符
2026-04-24 13:44:29 +08:00
chenhao f6ffaddae1 refactor: 注释掉登录页的示例账户信息和相关测试代码
- 在 `login/index.tsx` 中注释掉示例账户信息
- 在 `LegacyMeetingAdapterServiceImplTest.java` 和 `MeetingCommandServiceImplTest.java` 中注释掉相关测试代码
2026-04-24 10:42:22 +08:00
chenhao 99f5fd1cbd feat: 添加热词组筛选和分页功能
- 在 `HotWords.tsx` 中添加热词组筛选选项和分页逻辑
- 更新 `hotwordGroup.ts` 和后端相关控制器及服务以支持新的筛选参数
- 优化前端热词组列表的展示和交互逻辑
2026-04-24 09:04:16 +08:00
chenhao 0b8014d1af feat: 添加会议来源平台控制和实时会议状态处理
- 在 `MeetingAccessService` 和 `MeetingCommandService` 中添加 `assertCanControlRealtimeMeeting` 方法,支持不同平台的实时会议控制
- 更新 `createMeeting` 和 `createRealtimeMeeting` 方法,以包含 `meetingSource` 参数
- 在前端 `Meetings.tsx` 和 `RealtimeAsrSession.tsx` 中添加对跨平台实时会议的控制逻辑
- 更新数据库表结构和文档,添加 `meeting_type` 和 `meeting_source` 字段
- 更新相关测试类以验证新的控制逻辑
2026-04-23 17:53:12 +08:00
chenhao 8cdac8ad9f refactor: 注释掉 `MeetingAudioUploadSupport` 中的 `validateContentType` 方法调用 2026-04-23 16:41:48 +08:00
chenhao 4e38580258 feat: 添加实时会议状态处理和转录内容检查
- 在 `AiTaskServiceImpl` 中添加 `buildTranscriptText` 和 `failPendingSummaryTask` 方法,用于构建转录文本和处理失败的摘要任务
- 更新 `doDispatchSummaryTask` 和 `dispatchTasks` 方法,以在转录内容为空时处理失败情况
- 在前端 `Meetings.tsx` 中添加实时会议状态处理逻辑,支持实时会议的暂停、进行中和待开始状态
- 更新测试类 `AiTaskServiceImplTest` 以包含新的测试用例,验证转录内容为空时的任务处理逻辑
2026-04-23 15:47:29 +08:00
chenhao 8d0ef246f3 feat: 添加实时会议状态处理和转录内容检查
- 在 `AiTaskServiceImpl` 中添加 `buildTranscriptText` 和 `failPendingSummaryTask` 方法,用于构建转录文本和处理失败的摘要任务
- 更新 `doDispatchSummaryTask` 和 `dispatchTasks` 方法,以在转录内容为空时处理失败情况
- 在前端 `Meetings.tsx` 中添加实时会议状态处理逻辑,支持实时会议的暂停、进行中和待开始状态
- 更新测试类 `AiTaskServiceImplTest` 以包含新的测试用例,验证转录内容为空时的任务处理逻辑
2026-04-22 17:54:58 +08:00
chenhao 29551dfbe2 feat: 添加会议音频上传支持和热词组管理功能
- 添加 `MeetingAudioUploadSupport` 类,支持音频文件上传和验证
- 在 `MeetingCommandServiceImpl` 中添加删除会议时清理相关数据和工件的逻辑
- 添加热词组相关的实体、服务、控制器和映射器
- 更新测试类以包含新的依赖和测试用例
2026-04-22 16:38:45 +08:00
chenhao b36a08adc7 feat: 添加热词组筛选功能并更新相关API和前端逻辑
- 在 `HotWords.tsx` 中添加热词组筛选选项,并更新 `useEffect` 依赖
- 更新 `hotword.ts` 和 `HotWordController.java` 以支持 `hotWordGroupId` 参数
- 在 `MeetingDetail.tsx` 中添加 `getPinyinSuggestion` API 调用,优化热词创建逻辑
2026-04-22 16:06:50 +08:00
chenhao 2d788bac75 feat: 添加热词组管理功能并优化热词控制器逻辑
- 在 `HotWordController` 中添加 `HotWordGroupService` 依赖,并更新相关方法以支持租户ID
- 重构权限校验逻辑,移除 `isCurrentUserAdmin` 方法,改为使用 `resolveTargetTenantId` 方法
2026-04-22 15:28:06 +08:00
chenhao 324e283f41 refactor: 优化模型配置验证和测试逻辑
- 更新 `AiModelServiceImpl` 中的验证逻辑,改进错误信息
- 在 `pushAsrConfig` 方法中添加对空白 `apiKey` 的处理
- 添加单元测试以验证自定义 LLM 和 ASR 模型在没有 `apiKey` 时的行为
- 更新前端 `AiModels.tsx` 中的表单验证逻辑,移除 `apiKey` 的必填规则并添加警告提示
2026-04-22 09:53:34 +08:00
chenhao 6a08fb1a3b feat: 添加LLM模型连通性测试功能
- 在 `AiModelServiceImpl` 中添加 `testLlmConnectivity` 方法,支持不同提供商的连通性测试
- 在 `AiModelController` 中添加 `/llm-connectivity-test` API 端点,用于测试 LLM 模型连通性
- 更新 `AiModelService` 接口以包含新的 `testLlmConnectivity` 方法
- 添加相关单元测试以验证连通性测试功能的正确性
2026-04-22 09:40:15 +08:00
chenhao 940cc8a939 feat: 添加屏保用户播放设置和相关功能
- 在 `AndroidScreenSaverCatalogVO` 中添加 `displayDurationSec` 字段
- 移除 `ScreenSaver` 和 `AndroidScreenSaverItemVO` 中的 `displayDurationSec` 字段
- 更新 `ScreenSaverServiceImpl` 以支持用户播放设置
- 添加 `ScreenSaverUserSettings` 实体类和 `ScreenSaverUserSettingsMapper` 映射器
- 更新 `ScreenSaverSelectionResult` 以包含 `displayDurationSec`
- 更新数据库表结构以支持新的字段和表
- 更新单元测试以验证新功能的正确性
2026-04-21 14:17:41 +08:00
chenhao ce4743c5ea refactor: 更新用户页面组件和分页逻辑
- 优化 `index.tsx` 中的导入和表单字段
- 使用 `Table` 组件替换 `ListTable` 和 `AppPagination`
- 添加密码确认字段及其验证规则
- 调整工具栏和表格布局以提高一致性
2026-04-21 10:00:47 +08:00
chenhao 900f092d5e feat: 添加屏保用户配置和相关功能
- 在 `ScreenSaverServiceImpl` 中添加用户状态配置逻辑
- 添加 `ScreenSaverUserConfig` 实体类和 `ScreenSaverUserConfigMapper` 映射器
- 更新 `LegacyMeetingAttendeeResponse`, `LegacyMeetingPreviewDataResponse`, 和 `LegacyMeetingProcessingStatusResponse` 以包含 Swagger 注解
- 添加 `ScreenSaverServiceImplTest` 单元测试
- 更新 `AndroidCreateRealtimeMeetingVO` 以包含 Swagger 注解
- 在 `AndroidAuthServiceImplTest` 中添加匿名认证测试
- 添加 `AndroidExternalAppController` 控制器
2026-04-21 09:22:32 +08:00
chenhao 6107e611f4 feat: 添加屏保管理页面和相关功能
- 在前端添加 `ScreenSaverManagement` 页面,支持屏保的创建、编辑、删除和状态切换
- 在 `AiModelController` 中添加 Swagger 注解以描述 API 操作
- 在 `pom.xml` 中添加 `springdoc-openapi-starter-webmvc-ui` 依赖
- 更新 `role-permission` 和 `tenants` 页面的分页逻辑
- 在 `sys-params` 页面中使用 `ListTable` 组件并优化分页显示
2026-04-20 11:30:26 +08:00
alanpaine b1fd9de87d 更新分享页面基础信息显示与隐藏 2026-04-17 11:30:22 +08:00
alanpaine 9480d0e4bf feat(会议详情): 改进音频播放和转写交互体验
- 将会议预览页面的基本信息卡片合并到会议概览卡片,优化布局
- 为转写列表添加当前播放高亮和自动滚动功能
- 实现浮动音频播放器,随滚动保持可见
- 调整转写列表的响应式网格布局
2026-04-17 11:02:03 +08:00
chenhao ddd97e0514 补充提交 2026-04-17 10:09:18 +08:00
chenhao 27ae0a3def feat: 添加用户提示支持和优化会议访问逻辑
- 在 `MeetingCommandService` 和 `MeetingDomainSupport` 中添加 `userPrompt` 参数
- 在 `MeetingAccessService` 和 `MeetingQueryService` 中添加忽略租户的会议查询方法
- 更新前端API和组件,支持用户提示功能
- 优化会议访问逻辑,包括预览密码验证和角色管理页面
- 添加相关单元测试以验证新功能的正确性
2026-04-17 10:08:40 +08:00
chenhao d4424a157b feat: 增加会议上传超时配置并优化分页选项
- 在 `meeting.ts` 中添加 `MEETING_UPLOAD_FLOW_TIMEOUT` 配置,并应用于 `createMeeting` 和 `uploadMeeting` 请求
- 在 `AppPagination` 组件中增加 `8` 作为分页选项之一
- 优化 `Meetings.tsx` 和 `MeetingDetail.tsx` 的导入和状态初始化逻辑
2026-04-16 14:18:48 +08:00
alanpaine 0960e625f9 feat(ui): 优化页面标题组件和业务管理页面的视觉设计
重构 PageHeader 组件,移除冗余的 Space 导入,使用内联样式替代 Tailwind 类以提供更灵活的布局控制。同时,为外部应用管理和客户端管理页面引入全新的卡片设计,包括圆角、渐变背景、毛玻璃效果和自定义间距,以提升视觉层次感和现代感。调整按钮样式,统一圆角和高度,增强整体 UI 的一致性。
2026-04-16 14:17:12 +08:00
alanpaine 48dd7d1150 style(MeetingPreview): 调整分析标签页布局为弹性等宽
将标签页容器设为 display: flex 并移除滚动条,为每个标签页添加 flex: 1 以实现等宽分布,同时优化内边距和文本对齐
2026-04-16 13:42:43 +08:00
alanpaine 29a4be912d style(ui): 替换分析标签组件为自定义样式实现
移除对 Ant Design Segmented 组件的依赖,改用自定义 div 实现标签切换。这提供了更精细的样式控制,优化了视觉外观和交互反馈,并消除了不必要的第三方组件依赖。
2026-04-16 13:39:25 +08:00
alanpaine 4194b9afe6 style(frontend): 统一会议中心页面样式与布局
调整会议中心页面的卡片与布局结构,使用一致的样式类名并优化间距。
移除 List.Item 的底部边距,使卡片间距完全由网格控制。
将分页组件移至内容卡片内部,提升整体布局一致性。
2026-04-16 13:32:00 +08:00
alanpaine 276f9aa188 style(layout): 优化页脚样式,移除冗余装饰元素
- 调整页脚内边距,移除背景透明设置,使用卡片背景色并添加顶部边框
- 简化页脚内容容器样式,移除圆角、边框和毛玻璃效果
- 调整文字颜色为次要文本色,减少间距,优化视觉层次
- 更新分隔符颜色以匹配边框颜色变量
2026-04-16 13:15:39 +08:00
alanpaine 5a5f71d465 feat(会议预览): 增强分享弹窗样式并添加音频播放器
- 为会议分享弹窗添加渐变背景和卡片样式
- 在会议预览页面添加自定义音频播放器,支持播放/暂停、进度控制和倍速切换
- 优化转录文本的显示布局,将发言人头像和时间信息整合到内容区域
- 更新免责声明文本,明确AI生成内容的性质
- 调整会议详情页的关键词选择交互,使用勾选图标替代复选框
- 统一多个页面的配色方案,使用主色调变量
2026-04-16 13:05:50 +08:00
alanpaine 7c64cdf7a2 更新加载动画 2026-04-16 11:26:21 +08:00
chenhao 712d31d911 feat: 添加会议访问密码功能和相关UI组件
- 在 `MeetingCommandServiceImpl` 中添加 `normalizeAccessPassword` 方法,处理访问密码
- 在 `MeetingVO` 和 `UpdateMeetingBasicCommand` 中添加 `accessPassword` 字段
- 在前端 `MeetingPreview.tsx` 和 `MeetingDetail.tsx` 中添加访问密码输入和预览链接生成逻辑
- 在 `MeetingPublicPreviewController` 中移除预览响应中的访问密码
- 在 `LegacyAuthController` 中添加异常处理,返回错误信息
- 更新相关前端API和组件,支持访问密码功能
2026-04-16 10:55:10 +08:00
alanpaine caf2e22df0 chore: 更新代理配置并替换分页组件
- 更新 Vite 代理配置中的后端服务器地址和端口
- 将 ExternalAppManagement 和 ClientManagement 页面中的自定义分页逻辑替换为统一的 AppPagination 组件
- 移除表格内嵌的滚动样式,使分页组件独立于表格布局
2026-04-16 09:52:29 +08:00
chenhao 017e1d2ded feat: 添加旧版Android API支持和会议预览功能
- 添加 `LegacyLoginResponse`, `LegacyRefreshTokenResponse`, `LegacyLoginUserResponse` 和 `LegacyMeetingTagResponse` DTO
- 添加 `LegacyAuthController` 以处理旧版登录和刷新令牌请求
- 更新 `MeetingAccessServiceImpl` 中的异常信息为英文
- 在前端 `MeetingPreview.tsx` 中添加多语言文本和分享功能
2026-04-16 09:41:22 +08:00
chenhao db310fc803 feat: 添加会议预览功能和相关UI组件
- 在 `MeetingAccessService` 中添加 `isPreviewPasswordRequired` 和 `assertCanPreviewMeeting` 方法
- 在 `PromptTemplateVO` 和 `PromptTemplateDTO` 中添加 `description` 字段
- 在前端添加 `meetingAnalysis.ts` 以处理会议分析数据
- 在 `PromptTemplates.tsx` 中显示模板描述
- 添加 `MeetingPreview.tsx` 组件,支持会议预览和密码验证
2026-04-15 17:55:57 +08:00
alanpaine 2b30744d2e feat(ui): 统一全局分页组件并优化布局样式
- 新增 AppPagination 组件,统一分页样式与行为
- 将多个页面的自定义分页替换为标准分页组件
- 修复布局容器高度计算问题,确保内容区域正确滚动
- 调整表格容器样式,支持响应式布局和水平滚动
- 优化国际化配置,支持 Ant Design 组件多语言
- 统一分页边距和边框样式,提升视觉一致性
2026-04-15 17:52:19 +08:00
chenhao 6d46998abe feat: 增强旧版会议控制器功能和相关测试
- 添加 `StringRedisTemplate` 和 `ObjectMapper` 依赖
- 更新 `buildListItem` 方法以构建会议列表项
- 增加实时进度解析和处理逻辑
- 优化会议预览数据响应构建
- 添加并更新相关单元测试以验证新功能的正确性
2026-04-15 15:21:00 +08:00
alanpaine 658b7e6b59 新增拖拉排序 2026-04-15 13:49:50 +08:00
alanpaine 5972182519 新增拖拉排序 2026-04-15 13:49:30 +08:00
chenhao b2430abe73 refactor: 统一使用全局租户ID并优化客户端和外部应用管理页面
- 在 `ClientDownloadServiceImpl` 和 `ExternalAppServiceImpl` 中使用 `GLOBAL_TENANT_ID`
- 移除 `requireOwned` 方法中的租户ID检查,更新为 `requireExisting`
- 更新前端 `ClientManagement` 和 `ExternalAppManagement` 页面,优化平台选项和状态过滤
- 添加数据字典驱动的平台分组和选项加载逻辑
- 修复前端组件中的字段名称和代理配置问题
2026-04-14 11:17:25 +08:00
chenhao 3b7ba2c47a feat: 添加旧版Android API支持和相关测试
- 添加 `LegacyMeetingAttendeeResponse`, `LegacyMeetingPreviewDataResponse`, `LegacyExternalAppItemResponse`, `LegacyClientDownloadResponse` 等DTO
- 添加 `LegacyCatalogAdapterService` 接口及其实现
- 添加 `MeetingAuthorizationServiceImplTest`, `RealtimeMeetingSessionStateServiceImplTest`, `RealtimeMeetingGrpcServiceTest`, `LegacyMeetingAdapterServiceImplTest` 单元测试
- 添加 `ClientDownload` 和 `ExternalApp` 实体类
- 添加 `ApkManifestParser` 工具类
2026-04-13 20:21:08 +08:00
chenhao dffd33206a feat: 添加旧版Android API支持和日志配置
- 添加 `ApiResponseSuccessCodeAdvice` 以处理旧版成功代码
- 添加 `LegacyMeetingCreateRequest`, `LegacyApiResponse` 和相关控制器
- 添加 `GrpcExceptionLoggingInterceptor` 以增强gRPC异常日志记录
- 更新 `application.yml`,移除 `/api/android/**` 的安全配置
- 更新前端API和组件,修复字段名称和代理配置
- 添加日志配置文件 `logback-spring.xml` 以支持日志滚动和格式化
2026-04-13 10:32:56 +08:00
alanpaine 5f895bfe26 feat: 增加音频上传超时和进度显示
为音频文件上传接口增加超时设置,防止大文件上传时请求超时。在 speaker 注册接口设置 2 分钟超时,在会议音频上传接口设置 5 分钟超时。同时为上传音频功能添加真实的进度回调,替换模拟进度,提升用户体验。

在首页点击创建会议按钮时,改为直接打开创建抽屉,而不是跳转到会议页面再打开,简化用户操作流程。
2026-04-10 17:38:23 +08:00
chenhao 53ff2292a8 feat: 添加重新识别会议转录功能
- 在 `MeetingCommandServiceImpl` 中添加 `retryTranscription` 方法,用于重新提交转录任务
- 更新 `MeetingCommandService` 接口,添加 `retryTranscription` 方法
- 在 `MeetingController` 中添加 `retryTranscription` API 端点
- 更新前端API和组件,支持重新识别会议转录
- 添加相关单元测试以验证新功能的正确性
2026-04-10 15:18:05 +08:00
chenhao b9593324a5 feat: 增强会议配置解析和日志级别设置
- 在 `MeetingCommandServiceImpl` 中引入 `MeetingRuntimeProfileResolver`,解析并应用运行时配置
- 更新 `application-dev.yml` 和 `application.yml`,添加日志级别配置和新的数据库表
2026-04-10 09:14:00 +08:00
chenhao 1c82365e97 feat: 增强实时会议gRPC服务和会话状态管理
- 在 `MeetingCommandServiceImpl` 中更新 `saveRealtimeTranscriptSnapshot` 方法,仅保存最终结果
- 在 `GrpcServerLifecycle` 中添加 `GrpcExceptionLoggingInterceptor`
- 在 `RealtimeMeetingSessionStateServiceImpl` 中添加终端状态处理逻辑
- 在 `RealtimeMeetingGrpcService` 中增强错误处理和流关闭逻辑
- 添加 `saveRealtimeTranscriptSnapshotShouldIgnoreNonFinalTranscript` 测试用例
- 在 `MeetingAuthorizationServiceImpl` 中添加匿名访问支持
- 在 `RealtimeMeetingGrpcSessionServiceImpl` 中添加异常处理和清理逻辑
2026-04-08 19:45:50 +08:00
chenhao b2e2f2c46a feat: 增强实时会议gRPC服务和会话状态管理
- 在 `MeetingCommandServiceImpl` 中更新 `saveRealtimeTranscriptSnapshot` 方法,仅保存最终结果
- 在 `GrpcServerLifecycle` 中添加 `GrpcExceptionLoggingInterceptor`
- 在 `RealtimeMeetingSessionStateServiceImpl` 中添加终端状态处理逻辑
- 在 `RealtimeMeetingGrpcService` 中增强错误处理和流关闭逻辑
- 添加 `saveRealtimeTranscriptSnapshotShouldIgnoreNonFinalTranscript` 测试用例
- 在 `MeetingAuthorizationServiceImpl` 中添加匿名访问支持
- 在 `RealtimeMeetingGrpcSessionServiceImpl` 中添加异常处理和清理逻辑
2026-04-08 19:15:03 +08:00
alanpaine e83a0ece32 feat(首页): 优化最近会议布局并调整创建会议表单
- 将最近会议网格布局从3列改为4列,并添加响应式设计
- 调整首页标题为"最近会议",优化查看全部按钮样式和布局
- 移除热词选择功能,简化创建会议表单
- 默认启用声纹区分功能,优化高级设置面板视觉样式
- 修复创建会议时表单初始化逻辑
2026-04-08 17:30:31 +08:00
alanpaine c802f63ada fix(frontend): 修复多个抽屉和模态框组件渲染问题
为多个抽屉和模态框组件添加 forceRender 属性,确保表单字段在隐藏后重新显示时能正确渲染
修复会议详情页转录时间线显示问题,移除多余的时间戳元素
优化实时ASR会话页面的UI布局和交互设计
2026-04-08 16:16:21 +08:00
alanpaine 21c38355c3 feat(frontend): 添加Antd App组件和配置更新
- 将全局message替换为App.useApp以支持Antd 5.x静态方法
- 更新vite代理配置指向新后端地址
- 添加baseUrl到tsconfig.json支持路径别名
- 统一Card组件使用variant="borderless"替代bordered={false}
- 移除AppLayout中的菜单loading属性
- 优化热词表格显示,添加文本省略
- 更新Drawer组件的destroyOnClose为destroyOnHidden
- 添加前端.gitignore文件
- 更新分页组件size配置为default
- 修复会议详情页面总结更新参数传递问题
- 移除实时识别创建页面路由,整合到会议列表
- 添加生产环境配置文件到target目录
- 更新字体文件到资源目录
更新首页样式
2026-04-08 15:13:53 +08:00
chenhao 135203b9f6 feat: 添加实时会议创建和验证测试
- 添加 `MeetingCreateCommandValidationTest` 以验证创建会议命令的必填字段
- 添加 `MeetingRuntimeProfileResolverImplTest` 以测试运行时配置解析
- 添加 `MeetingCommandServiceImplTest` 以测试会议命令服务的逻辑
- 添加 `AndroidAuthServiceImplTest` 以测试 Android 认证服务
- 更新 `MeetingCommandService` 接口,添加 `saveRealtimeTranscriptSnapshot` 和更新 `completeRealtimeMeeting` 方法
- 在 `AndroidMeetingRealtimeController` 中添加创建实时会议的 API 端点
- 定义 `AndroidCreateRealtimeMeetingCommand` 和 `AndroidCreateRealtimeMeetingVO` 数据传输对象
2026-04-08 09:15:26 +08:00
chenhao 24c3835b79 feat: 添加实时会议配置选项和会话状态批量查询功能
- 在 `CreateRealtimeMeetingCommand` 中添加 `mode`, `language`, `enablePunctuation`, `enableItn`, `enableTextRefine`, 和 `saveAudio` 字段
- 更新 `MeetingCommandServiceImpl` 以支持新的实时会议配置选项
- 添加 `getRealtimeSessionStatuses` 接口,支持批量查询实时会议会话状态
- 更新前端API和组件,支持新的配置选项和批量查询功能
2026-04-03 14:38:36 +08:00
chenhao ff47c34349 refactor: 优化首页布局和样式
- 更新 `RightVisual.less` 和 `index.less`,调整响应式布局和样式
- 改进背景视觉元素的自适应性和动画效果
- 调整颜色和字体以提升UI/UX体验
2026-04-03 10:58:20 +08:00
chenhao 3cd1c48bce refactor: 优化首页布局和样式
- 简化首页布局,移除不必要的视觉元素
- 更新 `RightVisual.less` 和 `index.less`,调整样式和动画
- 在 `index.html` 中添加 Google Fonts 链接
- 更新 `index.tsx`,简化组件结构并优化内容展示
2026-04-03 10:40:34 +08:00
chenhao d780278da4 refactor: 移除Android API和WebSocket安全配置,更新应用配置
- 删除 `AndroidApiSecurityConfig` 和 `WebSocketSecurityConfig` 类
- 更新 `application.yml`,添加 `/api/android/**` 和 `/ws/**` 到 `permit-all-urls`
2026-04-02 17:05:41 +08:00
chenhao 4f42fb50ad feat: 添加分页查询和前端搜索功能
- 在 `SpeakerController` 中添加分页查询接口
- 在 `SpeakerServiceImpl` 中实现分页查询逻辑
- 更新前端API和组件,支持分页查询和按名称搜索声纹记录
2026-04-02 15:52:54 +08:00
chenhao 5da9a97d55 feat: 添加声纹库管理功能和相关API
- 在 `Speaker` 实体中添加 `creatorId` 和 `externalSpeakerId` 字段
- 更新数据库表 `biz_speakers`,添加 `creator_id` 和 `external_speaker_id` 字段,并创建相应索引
- 在 `SpeakerService` 中添加 `listByCreator` 和 `deleteSpeaker` 方法
- 更新前端API和组件,支持声纹注册、删除和列表查询
- 优化声纹注册逻辑,支持第三方声纹服务的调用和状态更新
2026-04-01 17:20:42 +08:00
chenhao 578359a0d3 feat: 添加声纹库管理功能和相关API
- 在 `Speaker` 实体中添加 `creatorId` 和 `externalSpeakerId` 字段
- 更新数据库表 `biz_speakers`,添加 `creator_id` 和 `external_speaker_id` 字段,并创建相应索引
- 在 `SpeakerService` 中添加 `listByCreator` 和 `deleteSpeaker` 方法
- 更新前端API和组件,支持声纹注册、删除和列表查询
- 优化声纹注册逻辑,支持第三方声纹服务的调用和状态更新
2026-04-01 14:31:12 +08:00
chenhao f0d63c97a3 feat: 添加Android实时会议gRPC服务和相关实现
- 定义 `common.proto` 和 `realtime_meeting.proto`,包含客户端认证、错误事件和实时会议相关的gRPC消息
- 实现 `AndroidGatewayPushServiceImpl`,处理设备注册、注销和消息推送
- 实现 `AndroidDeviceSessionServiceImpl`,管理设备会话状态和心跳更新
- 实现 `AsrUpstreamBridgeServiceImpl`,桥接ASR上游服务并处理音频流
- 实现 `AndroidRealtimeSessionTicketServiceImpl`,创建和获取实时会议会话票据
- 定义 `gateway.proto`,包含网关服务的消息定义
2026-04-01 09:27:30 +08:00
chenhao 3a7baa0341 feat: 添加实时会议会话状态管理和WebSocket控制功能
- 创建 `RealtimeMeetingSessionStateService` 及其实现类,管理实时会议会话状态
- 添加 `RealtimeMeetingSessionExpirationListener` 监听器,处理会话过期事件
- 更新前端API和组件,支持获取和控制实时会议会话状态
- 在 `RealtimeAsrSession` 组件中添加暂停和恢复识别的功能
2026-03-31 14:44:36 +08:00
chenhao a611ac2b61 feat: 添加文本修正功能和相关配置选项
- 在 `CreateMeetingCommand` 和 `meeting.ts` 中添加 `enableTextRefine` 字段
- 更新 `AiTaskServiceImpl` 和 `MeetingCommandServiceImpl` 以支持文本修正配置
- 在 `Meetings.tsx` 中添加文本修正的表单选项和默认值
2026-03-31 10:11:56 +08:00
chenhao 552e2255bd feat: 添加文本修正功能和相关配置选项
- 在 `CreateMeetingCommand` 和 `meeting.ts` 中添加 `enableTextRefine` 字段
- 更新 `AiTaskServiceImpl` 和 `MeetingCommandServiceImpl` 以支持文本修正配置
- 在 `Meetings.tsx` 中添加文本修正的表单选项和默认值
2026-03-31 10:02:42 +08:00
chenhao f9c0d31b87 feat: 添加实时会议WebSocket支持和相关服务
- 创建 `RealtimeMeetingSocketSessionService` 及其实现类,用于创建和获取实时会议会话
- 添加 `WebSocketSecurityConfig` 以配置WebSocket安全
- 创建 `RealtimeMeetingProxyWebSocketHandler` 处理WebSocket消息代理
- 配置 `RealtimeMeetingWebSocketConfig` 注
2026-03-31 09:54:08 +08:00
chenhao 9d1a8710af feat: 增加实时会议配置选项和WebSocket支持
- 在 `RealtimeAsr` 组件中添加语言、标点、ITN、文本修正和音频保存等配置选项
- 添加构建WebSocket URL的函数 `buildRealtimeProxyPreviewUrl`
- 更新 `meeting.ts` API,增加 `openRealtimeMeetingSocketSession` 接口
- 更新 `vite.config.ts`,添加WebSocket代理配置
- 优化 `RealtimeAsrSession` 组件,处理WebSocket消息并支持新的配置选项
2026-03-30 17:56:30 +08:00
chenhao 60754bbd26 feat: 添加会议转录编辑组件和优化关键词处理
- 在 `MeetingDetail` 页面中添加 `ActiveTranscriptRow` 组件,支持会议转录的编辑和保存
- 优化关键词处理逻辑,移除重复代码并
2026-03-27 14:33:32 +08:00
chenhao ffc19fa572 feat: 添加关键词管理功能和会议转录编辑功能
- 在 `MeetingDetail` 页面中添加关键词选择和一键加入热词的功能
- 增加会议转录的编辑和保存功能
- 更新后端接口和相关服务,支持更新会议转录内容
2026-03-27 13:50:01 +08:00
chenhao 8dbed4c8e6 refactor: 优化MeetingDetail页面和后端时间处理逻辑
- 优化 `MeetingDetail` 页面的待办事项和侧边栏样式
- 更新 `AiTaskServiceImpl` 和 `MeetingSummaryFileServiceImpl`
2026-03-27 10:52:01 +08:00
chenhao 12c79cdf26 refactor: 重构MeetingDetail页面,优化会议详情和智能分析展示
- 重构 `MeetingDetail` 组件,优化会议详情和智能分析的展示
- 增加关键词、全文概要、章节速览、发言总结、要点回顾和待办事项的展示逻辑
- 优化音频播放器和进度条功能
- 更新表单验证和数据处理逻辑
- 修复部分样式和布局问题
2026-03-27 10:30:48 +08:00
chenhao 4ee7a620b9 refactor: 删除MeetingCreate页面并更新主页和AI模型服务
- 删除 `MeetingCreate` 页面及其相关代码
- 更新主页组件,替换静态视觉元素为动态 `RightVisual` 组件
- 在 `AiModelService` 和 `AiModelController` 中添加本地连通性测试功能
- 重构 `AiModelServiceImpl`,增加验证和配置更新逻辑
2026-03-26 17:42:29 +08:00
chenhao 92e6b9fd4d feat: 添加角色数据权限管理功能
- 在 `zh-CN.json` 中新增与Bot凭证相关的国际化字符串
- 在 `index.tsx` 中添加数据权限管理标签页,支持自定义部门选择
- 更新API接口,新增获取和保存角色数据权限的方法
- 重构角色选择逻辑,加载角色时同时获取权限和数据权限信息
- 优化用户绑定和权限保存操作的提示信息
2026-03-26 13:44:57 +08:00
chenhao 653a9f7ef4 feat: 添加实时会议和主页组件
- 新增 `RealtimeAsr` 组件,用于创建和配置实时会议
- 新增 `HomePage` 组件,展示最近的会议记录和快速入口
- 新增 `RealtimeAsrSession` 组件,用于实时会议的会中识别和转录
2026-03-26 11:18:44 +08:00
chenhao 2b1d7b8a2e feat: 集成jar 集成前端 2026-03-17 15:31:09 +08:00
chenhao af5c658bd9 feat: 修改文件 2026-03-17 10:24:50 +08:00
chenhao d8bfdb21fa feat: 增加实时会议功能并优化数据库和API
- 增加实时会议的创建、追加转录和完成接口
- 更新 `MeetingDTO` 和 `MeetingVO`,添加可选字段 `summaryModelId`, `useSpkId`, `hotWords`
- 增加 `RealtimeTranscriptItemDTO` 接口
- 延长HTTP客户端连接超时时间至300秒
- 优化数据库表结构,新增 `biz_prompt_template_user_config` 表
- 更新系统参数和权限配置
2026-03-12 20:39:49 +08:00
chenhao eaed89c9ec feat: 修改页面 2026-03-10 17:43:33 +08:00
chenhao 364e49b3df feat: 重构AI模型控制器和服务,支持ASR和LLM模型类型
- 更新 `AiModelController` 中的保存、更新和删除方法,增加模型类型校验
- 重构 `AiModelServiceImpl`,分离ASR和LLM模型处理逻辑
- 更新数据库表结构,新增 `biz_asr_models` 和 `biz_llm_models` 表
- 添加前端API接口,支持按类型删除AI模型
2026-03-09 16:10:48 +08:00
chenhao 86b3616786 feat: 重构会议和任务模型,优化ASR和总结任务处理逻辑
- 移除 `Meeting` 实体中的 `asrModelId`, `summaryModelId`, `promptContent`, `useSpkId`, `hotWords`, `summaryContent` 字段,新增 `latestSummaryTaskId` 字段
- 更新数据库表结构,移除相关字段并添加 `latest_summary_task_id` 字段
- 在 `AiTask` 实体中新增 `task_config` 和 `result_file_path` 字段
- 重构 `AiTaskServiceImpl` 中的 ASR 和总结任务处理逻辑,支持任务配置和结果文件路径
- 重构 `MeetingServiceImpl` 中的会议创建和更新逻辑,分离 ASR 和总结任务
- 优化 `PromptTemplateServiceImpl`,新增用户模板状态管理功能
2026-03-09 14:44:24 +08:00
chenhao d554849e8e feat: 添加声纹注册路由并优化用户菜单项 2026-03-06 15:27:08 +08:00
chenhao c60db64578 feat:导出pdf优化 2026-03-06 13:45:56 +08:00
chenhao 430459c331 feat:导出pdf优化 2026-03-06 09:59:29 +08:00
chenhao 61da050438 feat:导出pdf 2026-03-05 17:52:08 +08:00
chenhao 0ccf0aa87d feat: 优化ASR任务接管逻辑和前端多语言支持
- 在后端实现ASR任务接管逻辑,支持恢复未完成的任务
- 添加前端多语言支持,引入 `react-i18next` 库
-
2026-03-05 09:36:41 +08:00
chenhao 11ab76f2ed feat: 优化会议创建表单和卡片显示
- 重构会议创建表单,增加录音上传、AI分析配置和参会人员选择
- 更新会议卡片组件,集成进度背景和状态标签,优化样式和交互体验
- 增加分页功能和多语言支持
- 修复和优化多处代码逻辑和样式问题
2026-03-04 20:59:49 +08:00
chenhao 423327c61d feat: 优化会议创建表单和卡片显示
- 重构会议创建表单,增加录音上传、AI分析配置和参会人员选择
- 更新会议卡片组件,集成进度背景和状态标签,优化样式和交互体验
- 增加分页功能和多语言支持
- 修复和优化多处代码逻辑和样式问题
2026-03-04 20:49:32 +08:00
chenhao 5c47b07ec6 feat:sql修改 2026-03-04 19:28:07 +08:00
chenhao afff8a8d07 feat: 添加会议总结测试用例和优化前端会议卡片显示
- 在后端添加 `SummaryTest` 测试类,实现会议总结功能的分步测试
- 重构前端会议卡片组件,集成进度背景和状态标签
- 优化会议卡片样式和交互体验,增加呼吸灯效果和详细信息展示
2026-03-04 19:25:21 +08:00
chenhao 35396104a0 Merge branch 'refs/heads/master' into dev_na
# Conflicts:
#	backend/src/main/java/com/imeeting/mapper/SysUserRoleMapper.java
2026-03-04 18:57:43 +08:00
chenhao 37025d3f02 feat: 添加会议进度显示和优化任务处理逻辑
- 在前端添加会议进度显示组件
- 优化后端任务调度逻辑,增加轮询锁防止并发执行
- 更新ASR和LLM任务处理流程,同步进度到Redis
- 重构会议详情页,展示AI分析进度和状态
- 修复和优化多处代码逻辑和样式问题
2026-03-04 17:19:41 +08:00
chenhao 80a4682757 feat: 添加声纹识别开关和优化模板管理权限
- 在会议创建页面添加声纹识别开关
- 优化提示模板的权限管理,区分平台级、租户级和个人级
- 更新后端逻辑处理不同层级的模板权限
- 重构前端模板选择界面,增强用户体验
- 添加热词权重显示并更新数据库表结构
- 修复和优化多处代码逻辑和样式问题
2026-03-04 15:19:40 +08:00
chenhao eaadc4ee51 feat: 添加会议创建事件和任务调度监听器
- 新增 `MeetingCreatedEvent` 事件类
- 实现 `MeetingTaskDispatchListener` 监听器,处理会议创建后的任务调度
- 更新 `MeetingServiceImpl` 发布会议创建事件
- 新增 `DashboardController` 提供仪表板统计和最近会议接口
- 更新 `SpeakerController` 和 `SpeakerServiceImpl` 支持声纹注册调用外部接口
- 添加测试配置文件 `application-test.yml`
- 优化 `WebConfig` 配置上传路径
- 更新前端 API 封装 `dashboard.ts`
2026-03-03 15:51:17 +08:00
chenhao 5e4a2aa2d1 feat(layout): 添加声纹注册菜单项并重构仪表板页面
- 在应用布局的导航菜单中添加声纹注册功能入口
- 重构仪表板页面实现动态数据加载和实时任务状态显示
- 实现会议任务的分步处理进度可视化展示
- 优化声纹注册页面的用户体验和交互流程
- 完善会议管理的权限控制逻辑
- 实现仪表板统计数据的实时轮询更新
2026-03-03 11:25:03 +08:00
chenhao 70e739818a feat(hotword): 实现热词权限管理和搜索功能
- 添加管理员权限判断方法支持平台管理员和租户管理员
- 实现公开热词仅管理员可创建、修改、删除的权限控制
- 支持私有热词本人或管理员可操作的权限验证
- 添加前端搜索类型筛选功能支持公开/私有分类
- 更新前端权限检查逻辑统一使用管理员身份判断
- 修复后端查询构造器逻辑确保权限过滤正确执行
- 添加租户管理员身份到JWT认证信息中
- 配置数据访问层忽略租户隔离注解优化查询性能
2026-03-03 10:08:07 +08:00
chenhao f6ba04ebd8 Merge branch 'refs/heads/master' into dev_na
# Conflicts:
#	backend/src/main/java/com/imeeting/auth/JwtAuthenticationFilter.java
#	backend/src/main/java/com/imeeting/controller/DictItemController.java
#	backend/src/main/java/com/imeeting/service/impl/AuthServiceImpl.java
2026-03-03 09:23:39 +08:00
chenhao 21b3ab3afc feat(business): 添加AI模型配置功能实现
- 新增AiModel实体类定义数据库表结构
- 实现AI模型的增删改查REST API接口
- 添加前端AI模型管理页面支持配置展示
- 实现ASR和LLM两种模型类型的区分管理
- 添加模型远程列表获取和验证功能
- 实现默认模型设置和租户权限控制
- 新增AiTask实体用于AI任务调度管理
- 实现AI任务异步处理服务逻辑
- 添加会议转录和总结的完整处理流程
2026-03-02 19:59:47 +08:00
chenhao 1a392d96b9 feat(layout): 添加音频、热词和提示词菜单项并优化布局结构
- 在AppLayout中添加AudioOutlined、TagsOutlined和BulbOutlined图标
- 为audio、hotword和prompt路由配置对应的菜单图标映射
- 重构菜单项生成逻辑,使用useMemo优化性能并修复TDZ错误
- 在菜单树查找函数中添加数组验证防止运行时错误
- 添加新业务模块数据库表:声纹发言人表、热词管理表和提示词模板表
- 更新租户ID字段的自动填充逻辑和权限过滤配置
- 在认证流程中添加displayName和pwdResetRequired字段支持
- 添加React Markdown依赖用于内容渲染功能
2026-03-02 09:09:53 +08:00
788 changed files with 95832 additions and 17536 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,223 +1,480 @@
# 数据库结构文档PostgreSQL # 鏁版嵁搴撶粨鏋勬枃妗紙PostgreSQL锛?
鏈枃妗f牴鎹?`backend/design/db_schema_pgsql.sql` 鐢熸垚锛屾弿杩板綋鍓嶆牳蹇冭〃缁撴瀯銆佸瓧娈点€佺害鏉熶笌绱㈠紩銆?
本文档根据 `backend/design/db_schema_pgsql.sql` 生成,描述当前核心表结构、字段、约束与索引。 ## 0. 绉熸埛涓庣粍缁?
### 0.1 `sys_tenant`锛堢鎴疯〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
## 0. 租户与组织
### 0.1 `sys_tenant`(租户表)
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| id | BIGSERIAL | PK | 租户ID | | id | BIGSERIAL | PK | 绉熸埛ID |
| tenant_code | VARCHAR(64) | NOT NULL, UNIQUE | 租户编码 | | tenant_code | VARCHAR(64) | NOT NULL, UNIQUE | 绉熸埛缂栫爜 |
| tenant_name | VARCHAR(128) | NOT NULL | 租户名称 | | tenant_name | VARCHAR(128) | NOT NULL | 绉熸埛鍚嶇О |
| status | SMALLINT | NOT NULL, DEFAULT 1 | 状态 | | status | SMALLINT | NOT NULL, DEFAULT 1 | 鐘舵€?|
| expire_time | TIMESTAMP(6) | | 过期时间 | | expire_time | TIMESTAMP(6) | | 杩囨湡鏃堕棿 |
| contact_name | VARCHAR(64) | | 联系人 | | contact_name | VARCHAR(64) | | 鑱旂郴浜?|
| contact_phone | VARCHAR(32) | | 联系电话 | | contact_phone | VARCHAR(32) | | 鑱旂郴鐢佃瘽 |
| remark | VARCHAR(255) | | 备注 | | remark | VARCHAR(255) | | 澶囨敞 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 创建时间 | | created_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 更新时间 | | updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 鏇存柊鏃堕棿 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 | | is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
索引: 绱㈠紩锛?- `uk_tenant_code`锛歚UNIQUE (tenant_code) WHERE is_deleted = FALSE`
- `uk_tenant_code``UNIQUE (tenant_code) WHERE is_deleted = FALSE`
### 0.2 `sys_org`(组织架构表) ### 0.2 `sys_org`锛堢粍缁囨灦鏋勮〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| id | BIGSERIAL | PK | 组织ID | | id | BIGSERIAL | PK | 缁勭粐ID |
| tenant_id | BIGINT | NOT NULL | 租户ID | | tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
| parent_id | BIGINT | | 父级组织ID | | parent_id | BIGINT | | 鐖剁骇缁勭粐ID |
| org_name | VARCHAR(128) | NOT NULL | 组织名称 | | org_name | VARCHAR(128) | NOT NULL | 缁勭粐鍚嶇О |
| org_code | VARCHAR(64) | | 组织编码 | | org_code | VARCHAR(64) | | 缁勭粐缂栫爜 |
| org_path | VARCHAR(512) | | 组织路径 | | org_path | VARCHAR(512) | | 缁勭粐璺緞 |
| sort_order | INTEGER | DEFAULT 0 | 排序 | | sort_order | INTEGER | DEFAULT 0 | 鎺掑簭 |
| status | SMALLINT | DEFAULT 1 | 状态 | | status | SMALLINT | DEFAULT 1 | 鐘舵€?|
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 创建时间 | | created_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 更新时间 | | updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 鏇存柊鏃堕棿 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 | | is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
外键: 澶栭敭锛?- `fk_org_parent`锛歚parent_id -> sys_org(id)`
- `fk_org_parent``parent_id -> sys_org(id)` - `fk_org_tenant`锛歚tenant_id -> sys_tenant(id)`
- `fk_org_tenant``tenant_id -> sys_tenant(id)`
索引: 绱㈠紩锛?- `idx_org_tenant`锛歚(tenant_id)`
- `idx_org_tenant``(tenant_id)`
## 1. 用户与角色 ## 1. 鐢ㄦ埛涓庤鑹?
### 1.1 `sys_user`锛堢敤鎴疯〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
### 1.1 `sys_user`(用户表)
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| user_id | BIGSERIAL | PK | 用户ID | | user_id | BIGSERIAL | PK | 鐢ㄦ埛ID |
| username | VARCHAR(50) | NOT NULL, UNIQUE | 登录名 | | username | VARCHAR(50) | NOT NULL, UNIQUE | 鐧诲綍鍚?|
| display_name | VARCHAR(50) | NOT NULL | 显示名 | | display_name | VARCHAR(50) | NOT NULL | 鏄剧ず鍚?|
| email | VARCHAR(100) | | 邮箱 | | email | VARCHAR(100) | | 閭 |
| phone | VARCHAR(30) | UNIQUE | 手机号 | | phone | VARCHAR(30) | UNIQUE | 鎵嬫満鍙?|
| password_hash | VARCHAR(255) | NOT NULL | 密码哈希 | | password_hash | VARCHAR(255) | NOT NULL | 瀵嗙爜鍝堝笇 |
| status | SMALLINT | NOT NULL, DEFAULT 1 | 状态 | | status | SMALLINT | NOT NULL, DEFAULT 1 | 鐘舵€?|
| pwd_reset_required | SMALLINT | DEFAULT 1 | 首次登录是否需改密 | | pwd_reset_required | SMALLINT | DEFAULT 1 | 棣栨鐧诲綍鏄惁闇€鏀瑰瘑 |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 逻辑删除标记 | | is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 | | created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 | | updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
| is_platform_admin | BOOLEAN | DEFAULT false | 是否平台管理员 | | is_platform_admin | BOOLEAN | DEFAULT false | 鏄惁骞冲彴绠$悊鍛?|
索引: 绱㈠紩锛?- `uk_user_username`锛歚UNIQUE (username) WHERE is_deleted = FALSE`
- `uk_user_username``UNIQUE (username) WHERE is_deleted = FALSE`
### 1.2 `sys_role`(角色表) ### 1.2 `sys_role`锛堣鑹茶〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| role_id | BIGSERIAL | PK | 角色ID | | role_id | BIGSERIAL | PK | 瑙掕壊ID |
| tenant_id | BIGINT | NOT NULL | 租户ID | | tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
| role_code | VARCHAR(50) | NOT NULL | 角色编码(租户内唯一) | | role_code | VARCHAR(50) | NOT NULL | 瑙掕壊缂栫爜锛堢鎴峰唴鍞竴锛?|
| role_name | VARCHAR(50) | NOT NULL | 角色名称 | | role_name | VARCHAR(50) | NOT NULL | 瑙掕壊鍚嶇О |
| status | SMALLINT | NOT NULL, DEFAULT 1 | 状态 | | status | SMALLINT | NOT NULL, DEFAULT 1 | 鐘舵€?|
| remark | TEXT | | 备注 | | remark | TEXT | | 澶囨敞 |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 逻辑删除标记 | | is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 | | created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 | | updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
索引: 绱㈠紩锛?- `idx_sys_role_tenant`锛歚(tenant_id)`
- `idx_sys_role_tenant``(tenant_id)` - `uk_role_code`锛歚UNIQUE (tenant_id, role_code) WHERE is_deleted = FALSE`
- `uk_role_code``UNIQUE (tenant_id, role_code) WHERE is_deleted = FALSE`
### 1.3 `sys_user_role`(用户-角色关联表,租户强约束) ### 1.3 `sys_user_role`锛堢敤鎴?瑙掕壊鍏宠仈琛紝绉熸埛寮虹害鏉燂級
| 字段 | 类型 | 约束 | 说明 | | 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| id | BIGSERIAL | PK | 关联ID | | id | BIGSERIAL | PK | 鍏宠仈ID |
| tenant_id | BIGINT | NOT NULL | 租户ID | | tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
| user_id | BIGINT | NOT NULL | 用户ID | | user_id | BIGINT | NOT NULL | 鐢ㄦ埛ID |
| role_id | BIGINT | NOT NULL | 角色ID | | role_id | BIGINT | NOT NULL | 瑙掕壊ID |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 逻辑删除标记 | | is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 | | created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 | | updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
唯一约束: 鍞竴绾︽潫锛?- `UNIQUE (tenant_id, user_id, role_id) WHERE is_deleted = 0`
- `UNIQUE (tenant_id, user_id, role_id) WHERE is_deleted = 0`
### 1.4 `sys_tenant_user`(租户成员关联表) ### 1.4 `sys_tenant_user`锛堢鎴锋垚鍛樺叧鑱旇〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| id | BIGSERIAL | PK | 关联ID | | id | BIGSERIAL | PK | 鍏宠仈ID |
| user_id | BIGINT | NOT NULL | 用户ID | | user_id | BIGINT | NOT NULL | 鐢ㄦ埛ID |
| tenant_id | BIGINT | NOT NULL | 租户ID | | tenant_id | BIGINT | NOT NULL | 绉熸埛ID |
| org_id | BIGINT | | 组织ID | | org_id | BIGINT | | 缁勭粐ID |
| status | SMALLINT | DEFAULT 1 | 状态 | | status | SMALLINT | DEFAULT 1 | 鐘舵€?|
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 | | is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 | | created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 | | updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
索引: 绱㈠紩锛?- `uk_tenant_user`锛歚UNIQUE (user_id, tenant_id) WHERE is_deleted = 0`
- `uk_tenant_user``UNIQUE (user_id, tenant_id) WHERE is_deleted = 0`
## 2. 权限/字典/参数(全局共享) ## 2. 鏉冮檺/瀛楀吀/鍙傛暟锛堝叏灞€鍏变韩锛?
### 2.1 `sys_permission`锛堟潈闄愯〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
### 2.1 `sys_permission`(权限表)
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| perm_id | BIGSERIAL | PK | 权限ID | | perm_id | BIGSERIAL | PK | 鏉冮檺ID |
| parent_id | BIGINT | | 父级权限ID | | parent_id | BIGINT | | 鐖剁骇鏉冮檺ID |
| name | VARCHAR(100) | NOT NULL | 权限名称 | | name | VARCHAR(100) | NOT NULL | 鏉冮檺鍚嶇О |
| code | VARCHAR(100) | NOT NULL, UNIQUE | 权限编码 | | code | VARCHAR(100) | NOT NULL, UNIQUE | 鏉冮檺缂栫爜 |
| perm_type | VARCHAR(20) | NOT NULL | 权限类型 | | perm_type | VARCHAR(20) | NOT NULL | 鏉冮檺绫诲瀷 |
| level | INTEGER | NOT NULL | 层级 | | level | INTEGER | NOT NULL | 灞傜骇 |
| path | VARCHAR(255) | | 路径 | | path | VARCHAR(255) | | 璺緞 |
| component | VARCHAR(255) | | 组件 | | component | VARCHAR(255) | | 缁勪欢 |
| icon | VARCHAR(100) | | 图标 | | icon | VARCHAR(100) | | 鍥炬爣 |
| sort_order | INTEGER | NOT NULL, DEFAULT 0 | 排序 | | sort_order | INTEGER | NOT NULL, DEFAULT 0 | 鎺掑簭 |
| is_visible | SMALLINT | NOT NULL, DEFAULT 1 | 是否可见 | | is_visible | SMALLINT | NOT NULL, DEFAULT 1 | 鏄惁鍙 |
| status | SMALLINT | NOT NULL, DEFAULT 1 | 状态 | | status | SMALLINT | NOT NULL, DEFAULT 1 | 鐘舵€?|
| description | TEXT | | 描述 | | description | TEXT | | 鎻忚堪 |
| meta | JSONB | | 扩展信息 | | meta | JSONB | | 鎵╁睍淇℃伅 |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 逻辑删除标记 | | is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
| created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 创建时间 | | created_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 | | updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 鏇存柊鏃堕棿 |
### 2.2 `sys_dict_type`(字典类型表) ### 2.2 `sys_dict_type`锛堝瓧鍏哥被鍨嬭〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| dict_type_id | BIGSERIAL | PK | 类型ID | | dict_type_id | BIGSERIAL | PK | 绫诲瀷ID |
| type_code | VARCHAR(50) | NOT NULL, UNIQUE | 类型编码 | | type_code | VARCHAR(50) | NOT NULL, UNIQUE | 绫诲瀷缂栫爜 |
| type_name | VARCHAR(50) | NOT NULL | 类型名称 | | type_name | VARCHAR(50) | NOT NULL | 绫诲瀷鍚嶇О |
| status | SMALLINT | DEFAULT 1 | 状态 | | status | SMALLINT | DEFAULT 1 | 鐘舵€?|
| remark | TEXT | | 备注 | | remark | TEXT | | 澶囨敞 |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 | | created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 更新时间 | | updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鏇存柊鏃堕棿 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 | | is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
**初始化数据:** **鍒濆鍖栨暟鎹細**
- `sys_common_status`: 通用状态 (启用/禁用) - `sys_common_status`: 閫氱敤鐘舵€?(鍚敤/绂佺敤)
- `sys_permission_type`: 权限类型 (目录/菜单/按钮) - `sys_permission_type`: 鏉冮檺绫诲瀷 (鐩綍/鑿滃崟/鎸夐挳)
- `sys_common_visibility`: 可见性 (显示/隐藏) - `sys_common_visibility`: 鍙鎬?(鏄剧ず/闅愯棌)
- `sys_permission_level`: 权限层级 (1, 2, 3) - `sys_permission_level`: 鏉冮檺灞傜骇 (1, 2, 3)
- `sys_log_type`: 日志类型 (LOGIN/OPERATION) - `sys_log_type`: 鏃ュ織绫诲瀷 (LOGIN/OPERATION)
- `sys_param_type`: 参数类型 (String/Number/Boolean/JSON) - `sys_param_type`: 鍙傛暟绫诲瀷 (String/Number/Boolean/JSON)
- `sys_log_status`: 操作状态 (成功/失败) - `sys_log_status`: 鎿嶄綔鐘舵€?(鎴愬姛/澶辫触)
### 2.3 `sys_dict_item`(字典项表) ### 2.3 `sys_dict_item`锛堝瓧鍏搁」琛級
| 字段 | 类型 | 约束 | 说明 | | 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| dict_item_id | BIGSERIAL | PK | 字典项ID | | dict_item_id | BIGSERIAL | PK | 瀛楀吀椤笽D |
| type_code | VARCHAR(50) | NOT NULL | 字典类型编码 | | type_code | VARCHAR(50) | NOT NULL | 瀛楀吀绫诲瀷缂栫爜 |
| item_label | VARCHAR(100) | NOT NULL | 展示文本 | | item_label | VARCHAR(100) | NOT NULL | 灞曠ず鏂囨湰 |
| item_value | VARCHAR(100) | NOT NULL | 存储值 | | item_value | VARCHAR(100) | NOT NULL | 瀛樺偍鍊?|
| sort_order | INT | DEFAULT 0 | 排序 | | sort_order | INT | DEFAULT 0 | 鎺掑簭 |
| status | SMALLINT | DEFAULT 1 | 状态 | | status | SMALLINT | DEFAULT 1 | 鐘舵€?|
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 | | created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 更新时间 | | updated_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鏇存柊鏃堕棿 |
| is_deleted | SMALLINT | DEFAULT 0 | 逻辑删除标记 | | is_deleted | SMALLINT | DEFAULT 0 | 閫昏緫鍒犻櫎鏍囪 |
索引: 绱㈠紩锛?- `idx_dict_item_type`锛歚(type_code)`
- `idx_dict_item_type``(type_code)` - `uk_dict_item_value`锛歚UNIQUE (type_code, item_value)`
- `uk_dict_item_value``UNIQUE (type_code, item_value)`
### 2.4 `sys_param`(系统参数表) ### 2.4 `sys_param`锛堢郴缁熷弬鏁拌〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| id | BIGSERIAL | PK | 参数ID | | id | BIGSERIAL | PK | 鍙傛暟ID |
| param_key | VARCHAR(100) | NOT NULL, UNIQUE | 参数键 | | param_key | VARCHAR(100) | NOT NULL, UNIQUE | 鍙傛暟閿?|
| param_value | TEXT | NOT NULL | 参数值 | | param_value | TEXT | NOT NULL | 鍙傛暟鍊?|
| param_type | VARCHAR(20) | NOT NULL | 参数类型 | | param_type | VARCHAR(20) | NOT NULL | 鍙傛暟绫诲瀷 |
| is_system | SMALLINT | DEFAULT 0 | 是否系统内置 | | is_system | SMALLINT | DEFAULT 0 | 鏄惁绯荤粺鍐呯疆 |
| status | SMALLINT | DEFAULT 1 | 状态 | | status | SMALLINT | DEFAULT 1 | 鐘舵€?|
| description | TEXT | | 描述 | | description | TEXT | | 鎻忚堪 |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 | | created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鍒涘缓鏃堕棿 |
## 3. 日志(租户隔离) ## 3. 鏃ュ織锛堢鎴烽殧绂伙級
### 3.1 `sys_log`(系统日志表) ### 3.1 `sys_log`锛堢郴缁熸棩蹇楄〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| id | BIGSERIAL | PK | 日志ID | | id | BIGSERIAL | PK | 鏃ュ織ID |
| tenant_id | BIGINT | NOT NULL, DEFAULT 0 | 租户ID | | tenant_id | BIGINT | NOT NULL, DEFAULT 0 | 绉熸埛ID |
| user_id | BIGINT | | 用户ID | | user_id | BIGINT | | 鐢ㄦ埛ID |
| username | VARCHAR(50) | | 用户名 | | username | VARCHAR(50) | | 鐢ㄦ埛鍚?|
| log_type | VARCHAR(20) | | 日志类型(如 LOGIN、OPERATION | | log_type | VARCHAR(20) | | 鏃ュ織绫诲瀷锛堝 LOGIN銆丱PERATION锛?|
| operation | VARCHAR(100) | NOT NULL | 操作描述 | | operation | VARCHAR(100) | NOT NULL | 鎿嶄綔鎻忚堪 |
| method | VARCHAR(200) | | 方法 | | method | VARCHAR(200) | | 鏂规硶 |
| params | TEXT | | 请求参数 | | params | TEXT | | 璇锋眰鍙傛暟 |
| status | SMALLINT | DEFAULT 1 | 状态 | | status | SMALLINT | DEFAULT 1 | 鐘舵€?|
| ip | VARCHAR(50) | | IP | | ip | VARCHAR(50) | | IP |
| duration | BIGINT | | 耗时ms | | duration | BIGINT | | 鑰楁椂锛坢s锛?|
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 | | created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 鍒涘缓鏃堕棿 |
索引: 绱㈠紩锛?- `idx_log_tenant_type`锛歚(tenant_id, log_type, created_at)`
- `idx_log_tenant_type``(tenant_id, log_type, created_at)`
## 4. 平台配置 ## 4. 骞冲彴閰嶇疆
### 4.1 `sys_platform_config`(平台管理表) ### 4.1 `sys_platform_config`锛堝钩鍙扮鐞嗚〃锛?| 瀛楁 | 绫诲瀷 | 绾︽潫 | 璇存槑 |
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| id | BIGINT | PK | 固定为 1 | | id | BIGINT | PK | 鍥哄畾涓?1 |
| project_name | VARCHAR(128) | NOT NULL | 项目名称 | | project_name | VARCHAR(128) | NOT NULL | 椤圭洰鍚嶇О |
| logo_url | VARCHAR(512) | | Logo URL | | logo_url | VARCHAR(512) | | Logo URL |
| icon_url | VARCHAR(512) | | Icon URL | | icon_url | VARCHAR(512) | | Icon URL |
| login_bg_url | VARCHAR(512) | | 登录页背景 | | login_bg_url | VARCHAR(512) | | 鐧诲綍椤佃儗鏅?|
| icp_info | VARCHAR(128) | | 备案信息 | | icp_info | VARCHAR(128) | | 澶囨淇℃伅 |
| copyright_info | VARCHAR(255) | | 版权信息 | | copyright_info | VARCHAR(255) | | 鐗堟潈淇℃伅 |
| system_description | TEXT | | 系统描述 | | system_description | TEXT | | 绯荤粺鎻忚堪 |
| created_at | TIMESTAMP | NOT NULL | 创建时间 | | created_at | TIMESTAMP | NOT NULL | 鍒涘缓鏃堕棿 |
| updated_at | TIMESTAMP | NOT NULL | 更新时间 | | updated_at | TIMESTAMP | NOT NULL | 鏇存柊鏃堕棿 |
## 5. 涓氬姟妯″潡
### 5.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 为系统预留租户 ID
-- ---------------------------- -- ----------------------------
-- 0. 租户与组织 -- 0. 租户与组织
-- ---------------------------- -- ----------------------------
CREATE EXTENSION IF NOT EXISTS vector;
-- 租户表 -- 租户表
CREATE TABLE sys_tenant ( CREATE TABLE sys_tenant (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
@ -19,7 +19,7 @@ CREATE TABLE sys_tenant (
updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_deleted SMALLINT DEFAULT 0 is_deleted SMALLINT DEFAULT 0
); );
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; DROP TABLE IF EXISTS sys_org CASCADE;
@ -55,6 +55,7 @@ CREATE TABLE sys_user (
user_id BIGSERIAL PRIMARY KEY, user_id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE, username VARCHAR(50) NOT NULL UNIQUE,
display_name VARCHAR(50) NOT NULL, display_name VARCHAR(50) NOT NULL,
avatar_url VARCHAR(500),
email VARCHAR(100), email VARCHAR(100),
phone VARCHAR(30) UNIQUE, phone VARCHAR(30) UNIQUE,
password_hash VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL,
@ -75,6 +76,7 @@ CREATE TABLE sys_role (
tenant_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL,
role_code VARCHAR(50) NOT NULL, role_code VARCHAR(50) NOT NULL,
role_name 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, status SMALLINT NOT NULL DEFAULT 1,
remark TEXT, remark TEXT,
is_deleted SMALLINT NOT NULL DEFAULT 0, 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 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 强约束,避免跨租户角色污染) -- 用户-角色关联表 (按 tenant_id 强约束,避免跨租户角色污染)
DROP TABLE IF EXISTS sys_user_role CASCADE; DROP TABLE IF EXISTS sys_user_role CASCADE;
@ -109,7 +124,7 @@ CREATE TABLE sys_permission (
perm_id BIGSERIAL PRIMARY KEY, perm_id BIGSERIAL PRIMARY KEY,
parent_id BIGINT, parent_id BIGINT,
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
code VARCHAR(100) NOT NULL UNIQUE, code VARCHAR(100) NOT NULL ,
perm_type VARCHAR(20) NOT NULL, perm_type VARCHAR(20) NOT NULL,
level INTEGER NOT NULL, level INTEGER NOT NULL,
path VARCHAR(255), path VARCHAR(255),
@ -138,7 +153,7 @@ CREATE TABLE sys_tenant_user (
updated_at TIMESTAMP(6) NOT NULL DEFAULT now() 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) ON sys_tenant_user (user_id, tenant_id)
WHERE is_deleted = 0; WHERE is_deleted = 0;
CREATE TABLE sys_dict_type ( CREATE TABLE sys_dict_type (
@ -161,13 +176,14 @@ CREATE TABLE sys_dict_item (
status SMALLINT DEFAULT 1, status SMALLINT DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT NOW(), created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
is_deleted SMALLINT DEFAULT 0 is_deleted SMALLINT DEFAULT 0,
remark varchar(255)
); );
CREATE INDEX idx_dict_item_type ON sys_dict_item (type_code); 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 ( CREATE TABLE sys_param (
id BIGSERIAL PRIMARY KEY, param_id BIGSERIAL PRIMARY KEY,
param_key VARCHAR(100) UNIQUE NOT NULL, param_key VARCHAR(100) UNIQUE NOT NULL,
param_value TEXT NOT NULL, param_value TEXT NOT NULL,
param_type VARCHAR(20) NOT NULL, param_type VARCHAR(20) NOT NULL,
@ -179,6 +195,36 @@ CREATE TABLE sys_param (
is_deleted SMALLINT DEFAULT 0 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. 日志 (租户隔离) -- 3. 日志 (租户隔离)
-- ---------------------------- -- ----------------------------
@ -188,7 +234,10 @@ CREATE TABLE sys_log (
tenant_id BIGINT NOT NULL DEFAULT 0, tenant_id BIGINT NOT NULL DEFAULT 0,
user_id BIGINT, user_id BIGINT,
username VARCHAR(50), username VARCHAR(50),
source_system VARCHAR(64),
log_type VARCHAR(20), -- LOGIN, OPERATION log_type VARCHAR(20), -- LOGIN, OPERATION
module_name VARCHAR(100),
action_name VARCHAR(100),
operation VARCHAR(100) NOT NULL, operation VARCHAR(100) NOT NULL,
method VARCHAR(200), method VARCHAR(200),
params TEXT, params TEXT,
@ -198,6 +247,9 @@ CREATE TABLE sys_log (
created_at TIMESTAMP NOT NULL DEFAULT NOW() 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 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. 平台配置 (系统品牌化) -- 4. 平台配置 (系统品牌化)
@ -217,49 +269,478 @@ CREATE TABLE sys_platform_config (
is_deleted SMALLINT DEFAULT 0 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. 基础初始化数据 -- 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=按钮'); -- 6. 屏保模块
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);
-- sys_common_visibility CREATE TABLE biz_screen_savers (
INSERT INTO sys_dict_type (type_code, type_name, remark) VALUES ('sys_common_visibility', '可见性', '0=隐藏, 1=显示'); id BIGSERIAL PRIMARY KEY,
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order) VALUES ('sys_common_visibility', '显示', '1', 1); tenant_id BIGINT NOT NULL DEFAULT 0,
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order) VALUES ('sys_common_visibility', '隐藏', '0', 2); 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 CREATE INDEX idx_screen_savers_status_sort
INSERT INTO sys_dict_type (type_code, type_name, remark) VALUES ('sys_permission_level', '权限层级', '1=一级入口, 2=二级子项, 3=三级按钮'); ON biz_screen_savers (status, sort_order);
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);
-- sys_log_type CREATE INDEX idx_screen_savers_scope_owner_status_sort
INSERT INTO sys_dict_type (type_code, type_name, remark) VALUES ('sys_log_type', '日志类型', 'LOGIN=登录, OPERATION=操作'); ON biz_screen_savers (scope_type, owner_user_id, status, sort_order);
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);
-- sys_param_type CREATE TABLE biz_screen_saver_user_config (
INSERT INTO sys_dict_type (type_code, type_name, remark) VALUES ('sys_param_type', '参数类型', 'String, Number, Boolean, JSON'); id BIGSERIAL PRIMARY KEY,
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order) VALUES ('sys_param_type', 'String', 'String', 1); tenant_id BIGINT NOT NULL DEFAULT 0,
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order) VALUES ('sys_param_type', 'Number', 'Number', 2); user_id BIGINT NOT NULL,
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order) VALUES ('sys_param_type', 'Boolean', 'Boolean', 3); screen_saver_id BIGINT NOT NULL,
INSERT INTO sys_dict_item (type_code, item_label, item_value, sort_order) VALUES ('sys_param_type', 'JSON', 'JSON', 4); 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"> 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> <modelVersion>4.0.0</modelVersion>
@ -20,6 +20,10 @@
<mybatis-plus.version>3.5.6</mybatis-plus.version> <mybatis-plus.version>3.5.6</mybatis-plus.version>
<jjwt.version>0.11.5</jjwt.version> <jjwt.version>0.11.5</jjwt.version>
<easycaptcha.version>1.6.2</easycaptcha.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>
</properties> </properties>
<dependencies> <dependencies>
@ -27,6 +31,10 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId> <artifactId>spring-boot-starter-security</artifactId>
@ -47,6 +55,10 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId> <artifactId>spring-boot-starter-aop</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency> <dependency>
<groupId>com.baomidou</groupId> <groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
@ -79,6 +91,38 @@
<artifactId>easy-captcha</artifactId> <artifactId>easy-captcha</artifactId>
<version>${easycaptcha.version}</version> <version>${easycaptcha.version}</version>
</dependency> </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> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>
@ -89,10 +133,90 @@
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </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>0.1.0</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>
</dependencies> </dependencies>
<build> <build>
<extensions>
<extension>
<groupId>kr.motd.maven</groupId>
<artifactId>os-maven-plugin</artifactId>
<version>${os.maven.plugin.version}</version>
</extension>
</extensions>
<plugins> <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> <plugin>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId> <artifactId>spring-boot-maven-plugin</artifactId>

View File

@ -3,9 +3,11 @@ package com.imeeting;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableAsync @EnableAsync
@EnableScheduling
public class ImeetingApplication { public class ImeetingApplication {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(ImeetingApplication.class, 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,29 @@
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 SOURCE_WEB = "WEB";
public static final String SOURCE_ANDROID = "ANDROID";
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"; 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 CACHE_EMPTY_MARKER = "EMPTY_MARKER";
public static final String SYS_PARAM_FIELD_VALUE = "value"; public static final String SYS_PARAM_FIELD_VALUE = "value";
public static final String SYS_PARAM_FIELD_TYPE = "type"; public static final String SYS_PARAM_FIELD_TYPE = "type";

View File

@ -1,7 +1,71 @@
package com.imeeting.common; package com.imeeting.common;
/**
* Key
*/
public final class SysParamKeys { public final class SysParamKeys {
private SysParamKeys() {} private SysParamKeys() {
}
public static final String CAPTCHA_ENABLED = "security.captcha.enabled"; 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,35 @@
package com.imeeting.config;
import com.unisbase.common.ApiResponse;
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.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
@RestControllerAdvice
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;
}
}

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,46 @@
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.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 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");
}
});
}
}

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.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration; 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.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@ -10,12 +12,17 @@ import java.io.File;
@Configuration @Configuration
public class WebMvcConfig implements WebMvcConfigurer { public class WebMvcConfig implements WebMvcConfigurer {
@Value("${app.upload-path}") @Value("${unisbase.app.upload-path}")
private String uploadPath; private String uploadPath;
@Value("${app.resource-prefix}") @Value("${unisbase.app.resource-prefix}")
private String resourcePrefix; private String resourcePrefix;
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer.mediaType("m4a", MediaType.parseMediaType("audio/mp4"));
}
@Override @Override
public void addResourceHandlers(ResourceHandlerRegistry registry) { 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(e.getMessage());
}
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,585 @@
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.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.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.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),
"all",
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 = "删除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()))
.toList();
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,275 @@
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.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(),
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),
MeetingConstants.SOURCE_ANDROID
);
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, MeetingConstants.SOURCE_ANDROID);
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, MeetingConstants.SOURCE_ANDROID);
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,150 @@
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.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 = "修改热词")
@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,96 @@
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, Long tenantId) {
if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) && Long.valueOf(0L).equals(tenantId)) {
return 0L;
}
return null;
}
@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, dto.getTenantId());
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, dto.getTenantId());
HotWordGroupVO existing = hotWordGroupService.listVisibleOptions(targetTenantId).stream()
.filter(item -> item.getId().equals(dto.getId()))
.findFirst()
.orElse(null);
if (existing == null) {
return ApiResponse.error("热词组不存在");
}
dto.setTenantId(targetTenantId);
return ApiResponse.ok(hotWordGroupService.updateGroup(dto));
}
@Operation(summary = "删除热词组")
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
@Log(value = "删除热词组", type = "热词组管理")
public ApiResponse<Boolean> delete(@PathVariable Long id, @RequestParam(required = false) Long tenantId) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = resolveTargetTenantId(loginUser, tenantId);
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,
@RequestParam(required = false) Long tenantId) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = resolveTargetTenantId(loginUser, tenantId);
return ApiResponse.ok(hotWordGroupService.pageGroups(current, size, name, status, targetTenantId));
}
@Operation(summary = "查询热词组选项")
@GetMapping("/options")
@PreAuthorize("isAuthenticated()")
public ApiResponse<List<HotWordGroupVO>> options(@RequestParam(required = false) Long tenantId) {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long targetTenantId = resolveTargetTenantId(loginUser, tenantId);
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,644 @@
package com.imeeting.controller.biz;
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.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() {
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);
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),
MeetingConstants.SOURCE_WEB
));
}
@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),
MeetingConstants.SOURCE_WEB
));
}
@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, MeetingConstants.SOURCE_WEB);
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.getHotwords(),
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, MeetingConstants.SOURCE_WEB);
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,185 @@
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 = "删除提示词模板")
@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

@ -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;
}

View File

@ -0,0 +1,41 @@
package com.imeeting.dto.android;
import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "Android 实时会议创建结果")
@Data
public class AndroidCreateRealtimeMeetingVO {
@Schema(description = "会议 ID")
private Long meetingId;
@Schema(description = "会议标题")
private String title;
@Schema(description = "主持人用户 ID")
private Long hostUserId;
@Schema(description = "主持人名称")
private String hostName;
@Schema(description = "实时音频采样率")
private Integer sampleRate;
@Schema(description = "音频通道数")
private Integer channels;
@Schema(description = "音频编码格式")
private String encoding;
@Schema(description = "最终生效的 ASR 模型 ID")
private Long resolvedAsrModelId;
@Schema(description = "最终生效的 ASR 模型名称")
private String resolvedAsrModelName;
@Schema(description = "最终生效的总结模型 ID")
private Long resolvedSummaryModelId;
@Schema(description = "最终生效的总结模型名称")
private String resolvedSummaryModelName;
@Schema(description = "最终生效的提示词模板 ID")
private Long resolvedPromptId;
@Schema(description = "最终生效的提示词模板名称")
private String resolvedPromptName;
@Schema(description = "恢复会议时使用的运行时参数")
private RealtimeMeetingResumeConfig resumeConfig;
@Schema(description = "当前实时会议状态")
private RealtimeMeetingSessionStatusVO status;
}

View File

@ -0,0 +1,38 @@
package com.imeeting.dto.android;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "Android 设备首页统计响应")
public class AndroidDeviceHomeStatsVO {
@Schema(description = "设备名称")
private String deviceName;
@Schema(description = "授权类型1-临时2-正式")
private Integer licenseType;
@Schema(description = "剩余分钟数")
private Long remainingMinutes;
@Schema(description = "会议数量")
private Long meetingCount;
@Schema(description = "会议总时长,单位分钟")
private Long meetingDurationMinutes;
@Schema(description = "登录用户数")
private Long loginUserCount;
@Schema(description = "H5 地址")
private String h5BaseUrl;
@Schema(description = "天气信息")
private AndroidDeviceHomeWeatherVO weather;
@Schema(description = "是否已登录")
private Boolean loggedIn;
@Schema(description = "是否开启余额校验")
private Boolean balanceCheckEnabled;
}

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