diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml deleted file mode 100644 index cef0d8bf..00000000 --- a/docker-compose.dev.yml +++ /dev/null @@ -1,263 +0,0 @@ -version: '3.8' - -# 开发环境完整部署配置 -services: - - # ================== 基础服务 ================== - - # PostgreSQL 数据库 - postgres: - image: postgres:16-alpine - container_name: ul-postgres - environment: - POSTGRES_DB: urban_lifeline - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres123 - TZ: Asia/Shanghai - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - - ./urbanLifelineServ/.bin/database/postgres/sql:/docker-entrypoint-initdb.d - networks: - - ul-network - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 10s - timeout: 5s - retries: 5 - - # Redis - redis: - image: redis:7-alpine - container_name: ul-redis - ports: - - "6379:6379" - volumes: - - redis_data:/data - networks: - - ul-network - command: redis-server --appendonly yes - - # Nacos 注册中心 - nacos: - image: nacos/nacos-server:v2.3.0 - container_name: ul-nacos - environment: - MODE: standalone - SPRING_DATASOURCE_PLATFORM: mysql - PREFER_HOST_MODE: hostname - JVM_XMS: 512m - JVM_XMX: 512m - JVM_XMN: 256m - ports: - - "8848:8848" - - "9848:9848" - volumes: - - nacos_data:/home/nacos/data - networks: - - ul-network - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8848/nacos"] - interval: 10s - timeout: 5s - retries: 10 - - # ================== 后端服务 ================== - - # Gateway 网关服务 - gateway: - build: - context: ./urbanLifelineServ/gateway - dockerfile: Dockerfile.dev - container_name: ul-gateway - environment: - SPRING_PROFILES_ACTIVE: dev - NACOS_SERVER_ADDR: nacos:8848 - POSTGRES_HOST: postgres - POSTGRES_PORT: 5432 - REDIS_HOST: redis - REDIS_PORT: 6379 - ports: - - "8080:8080" - volumes: - - ./urbanLifelineServ/gateway:/app - - maven_cache:/root/.m2 - networks: - - ul-network - depends_on: - postgres: - condition: service_healthy - nacos: - condition: service_healthy - command: mvn spring-boot:run - - # 认证服务 - auth-service: - build: - context: ./urbanLifelineServ/auth - dockerfile: Dockerfile.dev - container_name: ul-auth - environment: - SPRING_PROFILES_ACTIVE: dev - NACOS_SERVER_ADDR: nacos:8848 - POSTGRES_HOST: postgres - REDIS_HOST: redis - volumes: - - ./urbanLifelineServ/auth:/app - - maven_cache:/root/.m2 - networks: - - ul-network - depends_on: - - postgres - - nacos - - # 系统服务 - system-service: - build: - context: ./urbanLifelineServ/system - dockerfile: Dockerfile.dev - container_name: ul-system - environment: - SPRING_PROFILES_ACTIVE: dev - NACOS_SERVER_ADDR: nacos:8848 - POSTGRES_HOST: postgres - volumes: - - ./urbanLifelineServ/system:/app - - maven_cache:/root/.m2 - networks: - - ul-network - depends_on: - - postgres - - nacos - - # ================== 前端服务 ================== - - # 共享包服务(Module Federation Remote) - shared: - build: - context: ./urbanLifelineWeb - dockerfile: packages/shared/Dockerfile.dev - container_name: ul-shared - ports: - - "5000:5000" - volumes: - - ./urbanLifelineWeb/packages/shared:/app - - /app/node_modules - - pnpm_store:/root/.local/share/pnpm/store - networks: - - ul-network - environment: - - VITE_PORT=5000 - - CHOKIDAR_USEPOLLING=true # 支持 Docker 内文件监听 - command: pnpm dev - - # 主应用(Portal) - portal: - build: - context: ./urbanLifelineWeb - dockerfile: packages/portal/Dockerfile.dev - container_name: ul-portal - ports: - - "3000:3000" - volumes: - - ./urbanLifelineWeb/packages/portal:/app - - ./urbanLifelineWeb/packages/shared:/shared - - /app/node_modules - - pnpm_store:/root/.local/share/pnpm/store - networks: - - ul-network - environment: - - VITE_PORT=3000 - - VITE_API_BASE_URL=http://nginx/api - - VITE_SHARED_REMOTE=http://nginx/shared - - CHOKIDAR_USEPOLLING=true - depends_on: - - shared - command: pnpm dev - - # 招投标应用 - app-bidding: - build: - context: ./urbanLifelineWeb - dockerfile: packages/app-bidding/Dockerfile.dev - container_name: ul-app-bidding - ports: - - "3001:3001" - volumes: - - ./urbanLifelineWeb/packages/app-bidding:/app - - ./urbanLifelineWeb/packages/shared:/shared - - /app/node_modules - - pnpm_store:/root/.local/share/pnpm/store - networks: - - ul-network - environment: - - VITE_PORT=3001 - - VITE_API_BASE_URL=http://nginx/api - - VITE_SHARED_REMOTE=http://nginx/shared - - CHOKIDAR_USEPOLLING=true - depends_on: - - shared - command: pnpm dev - - # 智能客服应用 - app-customer-service: - build: - context: ./urbanLifelineWeb - dockerfile: packages/app-customer-service/Dockerfile.dev - container_name: ul-app-cs - ports: - - "3002:3002" - volumes: - - ./urbanLifelineWeb/packages/app-customer-service:/app - - ./urbanLifelineWeb/packages/shared:/shared - - /app/node_modules - - pnpm_store:/root/.local/share/pnpm/store - networks: - - ul-network - environment: - - VITE_PORT=3002 - - VITE_API_BASE_URL=http://nginx/api - - VITE_SHARED_REMOTE=http://nginx/shared - - CHOKIDAR_USEPOLLING=true - depends_on: - - shared - command: pnpm dev - - # ================== 统一网关 ================== - - # Nginx 统一入口 - nginx: - image: nginx:alpine - container_name: ul-nginx - ports: - - "80:80" - - "443:443" - volumes: - - ./docker/nginx/nginx.dev.conf:/etc/nginx/nginx.conf:ro - - ./docker/nginx/conf.d:/etc/nginx/conf.d:ro - - ./docker/nginx/ssl:/etc/nginx/ssl:ro - networks: - - ul-network - depends_on: - - gateway - - portal - - app-bidding - - app-customer-service - - shared - healthcheck: - test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"] - interval: 10s - timeout: 5s - retries: 3 - -networks: - ul-network: - driver: bridge - -volumes: - postgres_data: - redis_data: - nacos_data: - maven_cache: - pnpm_store: diff --git a/docs/Jitsi-Meet-Docker部署指南.md b/docs/Jitsi-Meet-Docker部署指南.md new file mode 100644 index 00000000..1faccf92 --- /dev/null +++ b/docs/Jitsi-Meet-Docker部署指南.md @@ -0,0 +1,393 @@ +# 🎥 Jitsi Meet Docker 部署与使用指南 + +## 📋 架构说明 + +本项目已将Jitsi Meet完整集成到Docker Compose中,包含以下4个服务: + +| 服务 | 容器名 | 端口 | 说明 | +|------|--------|------|------| +| **jitsi-web** | urban-lifeline-jitsi-web | 8280 (HTTP)8443 (HTTPS) | Web前端服务 | +| **jitsi-prosody** | urban-lifeline-jitsi-prosody | 5222/5347/5280 (内部) | XMPP信令服务器 | +| **jitsi-jicofo** | urban-lifeline-jitsi-jicofo | 无需暴露 | 会议控制服务 | +| **jitsi-jvb** | urban-lifeline-jitsi-jvb | 10000/udp4443/tcp | 视频桥接服务 | + +**数据持久化目录**: +``` +F:\Project\urbanLifeline\.data\docker\jitsi\ +├── web/ # Web配置 +├── prosody/ # XMPP配置 +├── jicofo/ # Jicofo配置 +├── jvb/ # JVB配置 +└── transcripts/ # 转录文件 +``` + +--- + +## 🚀 快速开始 + +### 步骤1:启动Jitsi Meet服务 + +```bash +# 进入Docker Compose目录 +cd F:\Project\urbanLifeline\urbanLifelineServ\.bin\docker\urbanlifeline + +# 启动Jitsi Meet(仅启动jitsi相关服务) +docker-compose up -d jitsi-web jitsi-prosody jitsi-jicofo jitsi-jvb + +# 查看服务状态 +docker-compose ps + +# 查看日志(排查问题用) +docker-compose logs -f jitsi-web +``` + +**预期输出**: +``` +NAME STATUS PORTS +urban-lifeline-jitsi-web Up (healthy) 0.0.0.0:8280->80/tcp, 0.0.0.0:8443->443/tcp +urban-lifeline-jitsi-prosody Up (healthy) 5222/tcp, 5347/tcp, 5280/tcp +urban-lifeline-jitsi-jicofo Up (healthy) +urban-lifeline-jitsi-jvb Up (healthy) 0.0.0.0:10000->10000/udp, 0.0.0.0:4443->4443/tcp +``` + +### 步骤2:验证Jitsi Meet运行 + +在浏览器打开: +- **主页**: http://localhost:8280/ +- **测试房间**: http://localhost:8280/test-meeting + +如果能看到Jitsi Meet界面,说明部署成功! + +--- + +## 🔧 配置说明 + +### 1. JWT认证配置(已配置) + +**Docker配置**(docker-compose.yml): +```yaml +jitsi-web: + environment: + JWT_APP_ID: urbanLifeline # 应用ID + JWT_APP_SECRET: your-secret-key-change-in-production # JWT密钥 +``` + +**Java后端配置**(application-dev.yml): +```yaml +jitsi: + app: + id: urbanLifeline # 必须与Docker一致 + secret: your-secret-key-change-in-production # 必须与Docker一致 + server: + url: http://localhost:8280 # Jitsi服务地址 + token: + expiration: 7200000 # Token有效期2小时 +``` + +⚠️ **重要**: +- `JWT_APP_ID` 和 `JWT_APP_SECRET` 在Docker和Java后端**必须完全一致** +- 生产环境请修改 `JWT_APP_SECRET` 为强随机字符串 +- 建议使用密钥管理工具(如Vault)管理密钥 + +### 2. 修改JWT密钥(生产环境必须) + +#### 方法A:修改Docker配置 +```bash +# 1. 编辑 docker-compose.yml +# 将所有 JWT_APP_SECRET 改为你的密钥 +JWT_APP_SECRET: "A8sF9dK2mP5nX7qW3tY6uZ1vB4cE0hG" + +# 2. 同步修改 application-dev.yml +jitsi: + app: + secret: "A8sF9dK2mP5nX7qW3tY6uZ1vB4cE0hG" + +# 3. 重启服务 +docker-compose restart jitsi-web jitsi-prosody +``` + +#### 方法B:使用环境变量(推荐生产环境) +```bash +# 创建 .env 文件 +echo "JITSI_JWT_SECRET=your-strong-secret-key" > .env + +# 修改 docker-compose.yml 使用环境变量 +JWT_APP_SECRET: ${JITSI_JWT_SECRET} +``` + +--- + +## 🧪 测试JWT验证 + +### 1. 测试无Token访问(应被拒绝) + +在浏览器打开:http://localhost:8280/test-room + +**预期结果**:显示"会议需要密码"或"需要登录" + +### 2. 测试带JWT Token访问(应成功) + +使用你的Java后端创建会议,后端会自动生成JWT Token并返回iframe URL: + +```bash +# 调用创建会议接口 +POST http://localhost:8180/urban-lifeline/workcase/chat/meeting/create +Content-Type: application/json + +{ + "roomId": "test-room-123", + "workcaseId": "WC001", + "meetingName": "测试会议", + "maxParticipants": 10 +} + +# 响应包含带JWT的URL +{ + "code": 0, + "data": { + "meetingId": "xxx", + "iframeUrl": "http://localhost:8280/workcase_WC001_1234567890?jwt=eyJhbGc..." + } +} +``` + +在浏览器打开响应中的 `iframeUrl`,应该能正常进入会议。 + +--- + +## 📊 服务管理命令 + +```bash +# 查看服务状态 +docker-compose ps + +# 查看日志 +docker-compose logs -f jitsi-web # Web服务日志 +docker-compose logs -f jitsi-prosody # XMPP日志 +docker-compose logs -f jitsi-jicofo # Jicofo日志 +docker-compose logs -f jitsi-jvb # JVB日志 + +# 重启服务 +docker-compose restart jitsi-web + +# 停止服务 +docker-compose stop jitsi-web jitsi-prosody jitsi-jicofo jitsi-jvb + +# 删除服务(保留数据) +docker-compose down + +# 删除服务和数据 +docker-compose down -v +rm -rf F:\Project\urbanLifeline\.data\docker\jitsi +``` + +--- + +## 🔍 常见问题排查 + +### 问题1:服务启动失败 + +**症状**: +```bash +docker-compose ps +# 显示 Exit (1) 或 Restarting +``` + +**解决方案**: +```bash +# 查看详细日志 +docker-compose logs jitsi-web + +# 检查端口占用 +netstat -ano | findstr "8280" + +# 删除容器重新启动 +docker-compose down +docker-compose up -d jitsi-web jitsi-prosody jitsi-jicofo jitsi-jvb +``` + +### 问题2:JWT验证失败 + +**症状**:进入会议后立即被踢出,或显示"认证失败" + +**原因**:JWT密钥不匹配 + +**解决方案**: +```bash +# 1. 检查Docker配置 +docker-compose exec jitsi-prosody cat /config/prosody.cfg.lua | grep jwt + +# 2. 检查Java配置 +# 确保 application-dev.yml 中的 jitsi.app.secret 与Docker一致 + +# 3. 重启服务 +docker-compose restart jitsi-prosody +``` + +### 问题3:视频无法连接 + +**症状**:音频正常,但视频黑屏或连接失败 + +**原因**:UDP端口10000被防火墙阻止 + +**解决方案**: +```bash +# Windows防火墙添加规则 +netsh advfirewall firewall add rule name="Jitsi JVB UDP" dir=in action=allow protocol=UDP localport=10000 + +# 或关闭防火墙测试(不推荐生产环境) +``` + +### 问题4:健康检查失败 + +**症状**: +```bash +docker-compose ps +# 显示 (unhealthy) +``` + +**解决方案**: +```bash +# 检查健康检查端点 +curl http://localhost:8280/ +curl http://localhost:8888/about/health # Jicofo +curl http://localhost:8080/about/health # JVB + +# 增加启动等待时间 +# 编辑 docker-compose.yml,修改 start_period: 120s +``` + +--- + +## 🌐 公网部署(可选) + +如果需要从公网访问,需要额外配置: + +### 1. 配置域名和SSL + +```yaml +jitsi-web: + environment: + PUBLIC_URL: https://your-domain.com + ENABLE_LETSENCRYPT: 1 + LETSENCRYPT_DOMAIN: your-domain.com + LETSENCRYPT_EMAIL: your-email@example.com + DISABLE_HTTPS: 0 +``` + +### 2. 配置JVB公网IP + +```yaml +jitsi-jvb: + environment: + DOCKER_HOST_ADDRESS: your-public-ip +``` + +### 3. 开放防火墙端口 + +- **TCP 443**: HTTPS访问 +- **UDP 10000**: WebRTC媒体流 +- **TCP 4443**: WebRTC Fallback(可选) + +--- + +## 🎯 集成到项目 + +你的Java后端已经完全配置好,可以直接使用: + +### 前端Vue调用示例 + +```typescript +import { createVideoMeeting } from '@/api/workcase/meeting' + +// 创建会议 +const res = await createVideoMeeting({ + roomId: 'room-123', + workcaseId: 'WC001', + meetingName: '技术支持会议', + maxParticipants: 10 +}) + +// 在iframe中显示 +const iframeUrl = res.data.iframeUrl +``` + +### 前端UniApp调用示例 + +```typescript +import { workcaseChatAPI } from '@/api/workcase' + +// 创建会议 +const res = await workcaseChatAPI.createVideoMeeting({ + roomId: roomId.value, + workcaseId: workcaseId.value, + meetingName: '工单技术支持', + maxParticipants: 10 +}) + +// 跳转到会议页面 +uni.navigateTo({ + url: `/pages/meeting/MeetingView/MeetingView?meetingUrl=${encodeURIComponent(res.data.iframeUrl)}` +}) +``` + +--- + +## 📈 性能优化(可选) + +### 1. 限制CPU和内存 + +```yaml +jitsi-jvb: + deploy: + resources: + limits: + cpus: '2' + memory: 2G + reservations: + cpus: '1' + memory: 1G +``` + +### 2. 配置视频质量 + +编辑 `.data/docker/jitsi/web/config/config.js`: + +```javascript +var config = { + resolution: 720, + constraints: { + video: { + height: { ideal: 720, max: 720, min: 180 } + } + } +}; +``` + +--- + +## ✅ 检查清单 + +启动前确认: +- [x] Docker Desktop已安装并运行 +- [x] 端口8280、8443、10000未被占用 +- [x] application-dev.yml配置正确 +- [x] JWT密钥在Docker和Java后端一致 + +启动后确认: +- [ ] 4个容器都是Up状态 +- [ ] 访问http://localhost:8280能看到Jitsi界面 +- [ ] Java后端能成功创建会议并生成JWT +- [ ] 前端能正常嵌入iframe并进入会议 + +--- + +## 🆘 获取帮助 + +如遇问题,收集以下信息: +1. Docker服务状态:`docker-compose ps` +2. 服务日志:`docker-compose logs jitsi-web` +3. 健康检查:`curl http://localhost:8280/` +4. Java后端日志中的JWT Token生成情况 + +**祝部署顺利!** 🚀 diff --git a/docs/qrcode.jpg b/docs/qrcode.jpg new file mode 100644 index 00000000..d9a83d61 Binary files /dev/null and b/docs/qrcode.jpg differ diff --git a/docs/qrcode.png b/docs/qrcode.png new file mode 100644 index 00000000..3f5205bb Binary files /dev/null and b/docs/qrcode.png differ diff --git a/docs/代码重构-视频会议API规范化.md b/docs/代码重构-视频会议API规范化.md new file mode 100644 index 00000000..cb9dfe58 --- /dev/null +++ b/docs/代码重构-视频会议API规范化.md @@ -0,0 +1,177 @@ +# 代码重构:视频会议 API 规范化 + +## 重构日期 +2025-12-26 + +## 重构原因 +原代码将类型定义和 API 调用混在一个独立的 `meeting.ts` 文件中,不符合项目规范。需要按照以下规范重构: + +1. **类型定义规范**:所有 DTO/VO 类型应放在 `types/workcase/` 目录下 +2. **API 调用规范**:使用 `shared/api` 的 `api` 对象发送请求 +3. **API 组织规范**:按业务模块组织成对象形式(如 `workcaseAPI`、`workcaseChatAPI`) +4. **代码复用规范**:避免重复定义,视频会议属于聊天室模块 + +## 重构内容 + +### 1. 类型定义迁移 + +**原位置**:`api/workcase/meeting.ts`(已删除) + +**新位置**:`types/workcase/chatRoom.ts` + +类型定义已经存在于 `chatRoom.ts` 中,无需创建新文件: +- `TbVideoMeetingDTO` (line 68-88) +- `VideoMeetingVO` (line 220-241) +- `CreateMeetingParam` (line 279-285) + +### 2. API 方法整合 + +**原文件**:`api/workcase/meeting.ts`(已删除) +- 独立的函数式 API 调用 +- 使用 `http.post/get` 发送请求 +- 类型定义和 API 混在一起 + +**新文件**:`api/workcase/workcaseChat.ts`(已更新) + +新增 6 个视频会议方法到 `workcaseChatAPI` 对象(line 227-276): + +```typescript +// ====================== 视频会议管理(Jitsi Meet) ====================== + +async createVideoMeeting(meeting: TbVideoMeetingDTO): Promise> +async getVideoMeetingInfo(meetingId: string): Promise> +async getActiveMeeting(roomId: string): Promise> +async joinVideoMeeting(meetingId: string): Promise> +async startVideoMeeting(meetingId: string): Promise> +async endVideoMeeting(meetingId: string): Promise> +``` + +### 3. 导入语句更新 + +**文件**:`api/workcase/workcaseChat.ts` (line 3-15) + +新增导入: +```typescript +import type { + // ... 现有导入 + TbVideoMeetingDTO, // 新增 + VideoMeetingVO // 新增 +} from '@/types/workcase' +``` + +### 4. 导出配置更新 + +**文件**:`api/workcase/index.ts` + +```typescript +// 移除 +- export * from './meeting' + +// 保留 +export * from './workcase' +export * from './workcaseChat' +``` + +### 5. Vue 组件引用更新 + +**文件**:`components/chatRoom/chatRoom/ChatRoom.vue` + +**修改前**: +```typescript +import { createVideoMeeting, getActiveMeeting, endVideoMeeting } from '@/api/workcase/meeting' + +// 使用 +await createVideoMeeting({ ... }) +await getActiveMeeting(props.roomId) +await endVideoMeeting(currentMeetingId.value) +``` + +**修改后**: +```typescript +import { workcaseChatAPI } from '@/api/workcase' + +// 使用 +await workcaseChatAPI.createVideoMeeting({ ... }) +await workcaseChatAPI.getActiveMeeting(props.roomId) +await workcaseChatAPI.endVideoMeeting(currentMeetingId.value) +``` + +## 重构优势 + +### 1. 符合项目规范 +- ✅ 类型定义集中在 `types/` 目录 +- ✅ API 调用使用 `shared/api` +- ✅ API 按业务模块组织 + +### 2. 避免代码重复 +- ✅ 复用已有的类型定义 +- ✅ 统一的 API 调用风格 + +### 3. 更好的可维护性 +- ✅ 代码组织清晰,职责分明 +- ✅ 类型定义和 API 调用分离 +- ✅ 按业务模块聚合,易于查找 + +### 4. 统一的开发体验 +- ✅ 与其他 API 调用方式一致 +- ✅ 自动类型推断 +- ✅ 统一的错误处理 + +## 受影响的文件 + +### 删除的文件 +- `packages/workcase/src/api/workcase/meeting.ts` + +### 修改的文件 +1. `packages/workcase/src/api/workcase/workcaseChat.ts` - 新增 6 个视频会议方法 +2. `packages/workcase/src/api/workcase/index.ts` - 移除 meeting 导出 +3. `packages/workcase/src/components/chatRoom/chatRoom/ChatRoom.vue` - 更新 API 调用 + +### 保持不变的文件 +- `packages/workcase/src/types/workcase/chatRoom.ts` - 类型定义已存在 +- `packages/workcase_wechat/api/workcase/workcaseChat.ts` - UniApp 端已符合规范 + +## 测试建议 + +1. **Vue Web 端测试** + - 测试创建视频会议功能 + - 测试加入已有会议功能 + - 测试结束会议功能 + - 测试自动检测活跃会议 + +2. **UniApp 端测试** + - 测试 MeetingView 页面导航 + - 测试视频会议页面显示 + - 测试结束会议返回聊天室 + +3. **类型检查** + ```bash + # 运行 TypeScript 类型检查 + npm run type-check + ``` + +## 后续优化建议 + +1. **错误处理增强** + - 添加统一的错误提示 + - 添加会议状态校验 + +2. **用户体验优化** + - 添加会议加载状态提示 + - 添加会议连接失败重试 + +3. **性能优化** + - 会议状态使用 WebSocket 实时同步 + - 离开页面自动结束会议 + +## 参考文档 + +- 项目 API 规范:参考 `workcase.ts` 和 `workcaseChat.ts` +- 类型定义规范:参考 `types/workcase/` 目录 +- Vue 组件规范:参考现有聊天室组件 + +--- + +**重构人员**:Claude Code +**审核状态**:待审核 +**版本**:v1.0 diff --git a/docs/功能实现-会议通知消息.md b/docs/功能实现-会议通知消息.md new file mode 100644 index 00000000..9d73f1d8 --- /dev/null +++ b/docs/功能实现-会议通知消息.md @@ -0,0 +1,310 @@ +# 功能实现:视频会议预约模式(Reservation Model) + +## 实现日期 +2025-12-26 + +## 功能概述 +实现视频会议"预约+按需创建"架构:用户创建会议预约时不立即创建Jitsi会议室,仅在首个用户在允许入会时间窗口内加入时,通过Redis双检锁机制创建Jitsi会议室。 + +## 架构模型 + +### 预约模式 (Reservation Model) +``` +用户创建会议 → 保存预约信息(scheduled)→ 发送会议通知消息(meetingId) + ↓ +首个用户加入 → 时间窗口校验 → Redis分布式锁 → 创建Jitsi会议室 → 更新状态(ongoing) + ↓ +后续用户加入 → 直接获取已创建的会议室URL +``` + +### 时间窗口规则 +- **提前入会时间**: `start_time - advance` 分钟 +- **允许入会窗口**: `[提前入会时间, end_time]` +- **默认advance**: 5分钟 + +## 实现文件 + +### 后端修改 + +**VideoMeetingServiceImpl.java** +- 位置:`urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/service/` +- 修改内容: + 1. 新增导入:`TbChatRoomMessageDTO`、`ChatRoomService`、`JSONObject` + 2. 注入依赖:`ChatRoomService` + 3. 修改 `createMeeting()` 方法:插入数据库成功后调用 `sendMeetingNotification()` + 4. 新增私有方法:`sendMeetingNotification()` + +## 详细实现 + +### 1. 依赖注入 + +```java +@Autowired +private ChatRoomService chatRoomService; +``` + +### 2. 调用时机 + +在 `createMeeting()` 方法中,会议记录插入数据库成功后: + +```java +// 8. 插入数据库 +int rows = videoMeetingMapper.insertVideoMeeting(meetingDTO); +if (rows > 0) { + logger.info("视频会议创建成功: meetingId={}, jitsiRoomName={}", + meetingId, jitsiRoomName); + + // 9. 发送会议通知消息到聊天室 + sendMeetingNotification(meetingDTO, userName); + + // 10. 返回VO + VideoMeetingVO meetingVO = new VideoMeetingVO(); + BeanUtils.copyProperties(meetingDTO, meetingVO); + return ResultDomain.success("创建会议成功", meetingVO); +} +``` + +### 3. 消息发送实现 + +**sendMeetingNotification() 方法 (VideoMeetingServiceImpl.java:396-442)** + +```java +/** + * 发送会议通知消息到聊天室 + * @param meetingDTO 会议信息 + * @param creatorName 创建者名称 + */ +private void sendMeetingNotification(TbVideoMeetingDTO meetingDTO, String creatorName) { + try { + logger.info("发送会议通知消息: roomId={}, meetingId={}", + meetingDTO.getRoomId(), meetingDTO.getMeetingId()); + + // 构建消息内容 + TbChatRoomMessageDTO message = new TbChatRoomMessageDTO(); + message.setMessageId(IdUtil.generateUUID()); + message.setRoomId(meetingDTO.getRoomId()); + message.setSenderId(meetingDTO.getCreator()); + message.setSenderType(meetingDTO.getCreatorType()); + message.setSenderName(creatorName); + message.setMessageType("meet"); // 会议类型消息 + message.setContent(meetingDTO.getIframeUrl()); // 会议URL作为内容 + message.setStatus("sent"); + message.setReadCount(0); + message.setSendTime(new Date()); + + // 构建扩展信息(会议详情) + JSONObject contentExtra = new JSONObject(); + contentExtra.put("meetingId", meetingDTO.getMeetingId()); + contentExtra.put("meetingName", meetingDTO.getMeetingName()); + contentExtra.put("jitsiRoomName", meetingDTO.getJitsiRoomName()); + contentExtra.put("iframeUrl", meetingDTO.getIframeUrl()); + contentExtra.put("maxParticipants", meetingDTO.getMaxParticipants()); + contentExtra.put("creatorName", creatorName); + contentExtra.put("workcaseId", meetingDTO.getWorkcaseId()); + message.setContentExtra(contentExtra); + + // 发送消息 + ResultDomain sendResult = chatRoomService.sendMessage(message); + if (sendResult.getSuccess()) { + logger.info("会议通知消息发送成功: messageId={}", message.getMessageId()); + } else { + logger.warn("会议通知消息发送失败: {}", sendResult.getMessage()); + } + } catch (Exception e) { + // 消息发送失败不影响会议创建 + logger.error("发送会议通知消息异常: roomId={}, error={}", + meetingDTO.getRoomId(), e.getMessage(), e); + } +} +``` + +## 消息数据结构 + +### 消息字段 + +| 字段 | 类型 | 说明 | +|-----|------|------| +| messageId | String | 消息ID(UUID) | +| roomId | String | 聊天室ID | +| senderId | String | 发送者ID(会议创建者) | +| senderType | String | 发送者类型(guest/agent) | +| senderName | String | 发送者名称 | +| **messageType** | **String** | **"meet"(会议消息类型)** | +| **content** | **String** | **会议iframe URL** | +| contentExtra | JSONObject | 会议详细信息(见下表) | +| status | String | "sent" | +| readCount | Integer | 0 | +| sendTime | Date | 发送时间 | + +### contentExtra 详细信息 + +```json +{ + "meetingId": "会议ID", + "meetingName": "会议名称", + "jitsiRoomName": "Jitsi房间名", + "iframeUrl": "会议URL", + "maxParticipants": 10, + "creatorName": "创建者名称", + "workcaseId": "工单ID" +} +``` + +## 前端渲染建议 + +### Vue Web 端 + +在 `ChatRoom.vue` 中添加会议消息卡片渲染: + +```vue + + + + + {{ message.contentExtra.meetingName }} + + + 发起人:{{ message.contentExtra.creatorName }} + 最多参与人数:{{ message.contentExtra.maxParticipants }} + + + + + + +``` + +### UniApp 小程序端 + +在 `chatRoom.uvue` 中添加会议消息卡片: + +```vue + + + + 📹 + {{ message.contentExtra.meetingName }} + + + 发起人:{{ message.contentExtra.creatorName }} + 最多 {{ message.contentExtra.maxParticipants }} 人 + + + 加入会议 + + + + + +``` + +## 设计考虑 + +### 1. 异步发送 +消息发送在独立的 try-catch 块中,失败不影响会议创建流程 + +### 2. 完整信息 +contentExtra 包含会议所有关键信息,前端可灵活使用 + +### 3. 类型明确 +messageType 使用 "meet" 标识会议消息,方便前端过滤和渲染 + +### 4. URL 即内容 +content 字段直接存储会议URL,方便快速访问 + +### 5. 日志追踪 +完整的日志记录,便于问题排查 + +## 测试要点 + +### 1. 会议创建测试 +```bash +POST /urban-lifeline/workcase/chat/meeting/create +{ + "roomId": "test-room-123", + "workcaseId": "WC001", + "meetingName": "技术支持会议", + "maxParticipants": 10 +} +``` + +**预期结果**: +- ✅ 返回会议创建成功 +- ✅ 数据库 tb_video_meeting 表插入会议记录 +- ✅ 数据库 tb_chat_room_message 表插入类型为 "meet" 的消息 +- ✅ 消息的 content 字段包含会议URL +- ✅ 消息的 contentExtra 包含会议详细信息 + +### 2. 前端卡片渲染测试 +- ✅ 聊天消息列表中显示会议卡片 +- ✅ 卡片展示会议名称、发起人、参与人数等信息 +- ✅ 点击"加入会议"按钮能正确跳转 + +### 3. 多人加入测试 +- ✅ 创建者加入会议(主持人权限) +- ✅ 其他成员通过卡片加入会议(普通权限) +- ✅ 非聊天室成员无法加入 + +### 4. 异常情况测试 +- ✅ 消息发送失败不影响会议创建 +- ✅ 会议创建失败不会发送消息 + +## 注意事项 + +1. **消息类型**:messageType 为 "meet",而非 "meeting"(根据用户需求) + +2. **权限控制**: + - 只有聊天室成员才能创建会议 + - 只有聊天室成员才能加入会议 + +3. **事务处理**: + - 会议创建在事务中(@Transactional) + - 消息发送在独立的 try-catch,失败不回滚会议创建 + +4. **前端适配**: + - Web端和小程序端需分别实现会议卡片渲染 + - 建议使用统一的样式和交互 + +5. **扩展性**: + - contentExtra 可根据需要添加更多字段 + - 建议前端做好字段缺失的容错处理 + +## 相关文档 + +- [Jitsi Meet 视频会议集成总结](./项目总结-Jitsi-Meet视频会议功能.md) +- [Docker 部署指南](./Jitsi-Meet-Docker部署指南.md) +- [代码重构 - 视频会议API规范化](./代码重构-视频会议API规范化.md) + +--- + +**实现人员**:Claude Code +**审核状态**:待审核 +**版本**:v1.0 diff --git a/docs/项目总结-Jitsi-Meet视频会议功能.md b/docs/项目总结-Jitsi-Meet视频会议功能.md new file mode 100644 index 00000000..fc32bce4 --- /dev/null +++ b/docs/项目总结-Jitsi-Meet视频会议功能.md @@ -0,0 +1,475 @@ +# 🎉 Jitsi Meet 视频会议功能 - 完整实现总结 + +## ✅ 已完成的工作 + +### 一、后端开发(Java Spring Boot) + +#### 1. Service接口层(2个接口) +- ✅ `VideoMeetingService.java` - 视频会议业务接口 +- ✅ `JitsiTokenService.java` - JWT Token服务接口 + +#### 2. Service实现层(2个实现) +- ✅ `VideoMeetingServiceImpl.java` - 核心业务逻辑(约400行) + - 会议创建(验证权限、生成JWT、构建iframe URL) + - 会议权限验证(检查聊天室成员身份) + - 用户专属URL生成(每个用户独立JWT Token) + - 会议状态管理(开始/结束) + - 活跃会议检测 + +- ✅ `JitsiTokenServiceImpl.java` - JWT Token生成服务 + - 符合Jitsi Meet标准的JWT生成 + - iframe URL构建(带配置参数) + - 房间名生成规则 + +#### 3. Controller层(6个REST API) +``` +POST /workcase/chat/meeting/create - 创建会议 +GET /workcase/chat/meeting/{meetingId} - 获取会议信息 +POST /workcase/chat/meeting/{meetingId}/join - 加入会议 +POST /workcase/chat/meeting/{meetingId}/start - 开始会议 +POST /workcase/chat/meeting/{meetingId}/end - 结束会议 +GET /workcase/chat/meeting/room/{roomId}/active - 获取活跃会议 +``` + +--- + +### 二、前端Vue开发 + +#### 1. API封装 +- ✅ `meeting.ts` - 完整的会议API封装(6个方法) + +#### 2. ChatRoom.vue组件增强 +- ✅ 添加roomId、workcaseId Props +- ✅ 实现会议创建逻辑(handleStartMeeting) +- ✅ 实现会议结束逻辑(handleEndMeeting) +- ✅ 实现活跃会议检测(checkActiveMeeting) +- ✅ 会议iframe显示(带头部控制栏) +- ✅ 按钮状态管理(loading、disabled) + +#### 3. 样式优化 +- ✅ 会议容器样式(渐变头部) +- ✅ 关闭按钮样式(半透明效果) +- ✅ 按钮禁用状态 + +--- + +### 三、前端UniApp开发 + +#### 1. MeetingView.uvue页面 +- ✅ 自定义导航栏 +- ✅ web-view全屏显示 +- ✅ 结束会议功能 +- ✅ 退出确认弹窗 + +#### 2. chatRoom.uvue修改 +- ✅ 更新startMeeting函数(调用API创建会议) +- ✅ 跳转到MeetingView页面 + +#### 3. workcaseChat.ts API +- ✅ 添加6个会议相关API方法 + +--- + +### 四、Docker部署配置 + +#### 1. docker-compose.yml +- ✅ 添加4个Jitsi服务(web、prosody、jicofo、jvb) +- ✅ 配置JWT认证 +- ✅ 端口映射(8280、8443、10000/udp) +- ✅ 数据持久化(.data/docker/jitsi) +- ✅ 健康检查配置 +- ✅ 服务依赖关系 + +#### 2. application-dev.yml +- ✅ 添加jitsi配置节 +- ✅ JWT密钥配置 +- ✅ 服务器地址配置 +- ✅ Token有效期配置 + +--- + +### 五、文档和脚本 + +#### 1. 文档 +- ✅ `Jitsi-Meet本地部署指南.md` - npm start方式(开发测试) +- ✅ `Jitsi-Meet-Docker部署指南.md` - Docker方式(生产推荐) +- ✅ `项目总结.md` - 本文档 + +#### 2. 启动脚本 +- ✅ `启动Jitsi视频会议服务.bat` - 一键启动 +- ✅ `停止Jitsi视频会议服务.bat` - 一键停止 + +--- + +## 📂 修改的文件清单 + +### 后端(6个文件) +``` +✅ VideoMeetingService.java (新建) + F:\Project\urbanLifeline\urbanLifelineServ\apis\api-workcase\src\main\java\org\xyzh\api\workcase\service\ + +✅ JitsiTokenService.java (新建) + F:\Project\urbanLifeline\urbanLifelineServ\apis\api-workcase\src\main\java\org\xyzh\api\workcase\service\ + +✅ VideoMeetingServiceImpl.java (新建) + F:\Project\urbanLifeline\urbanLifelineServ\workcase\src\main\java\org\xyzh\workcase\service\ + +✅ JitsiTokenServiceImpl.java (新建) + F:\Project\urbanLifeline\urbanLifelineServ\workcase\src\main\java\org\xyzh\workcase\service\ + +✅ WorkcaseChatContorller.java (修改:添加会议接口) + F:\Project\urbanLifeline\urbanLifelineServ\workcase\src\main\java\org\xyzh\workcase\controller\ + +✅ application-dev.yml (修改:添加jitsi配置) + F:\Project\urbanLifeline\urbanLifelineServ\workcase\src\main\resources\ +``` + +### 前端Vue(3个文件) +``` +✅ meeting.ts (新建) + F:\Project\urbanLifeline\urbanLifelineWeb\packages\workcase\src\api\workcase\ + +✅ ChatRoom.vue (修改:添加会议功能) + F:\Project\urbanLifeline\urbanLifelineWeb\packages\workcase\src\components\chatRoom\chatRoom\ + +✅ ChatRoom.scss (修改:添加会议样式) + F:\Project\urbanLifeline\urbanLifelineWeb\packages\workcase\src\components\chatRoom\chatRoom\ +``` + +### 前端UniApp(3个文件) +``` +✅ MeetingView.uvue (新建) + F:\Project\urbanLifeline\urbanLifelineWeb\packages\workcase_wechat\pages\meeting\MeetingView\ + +✅ chatRoom.uvue (修改:更新startMeeting) + F:\Project\urbanLifeline\urbanLifelineWeb\packages\workcase_wechat\pages\chatRoom\chatRoom\ + +✅ workcaseChat.ts (修改:添加会议API) + F:\Project\urbanLifeline\urbanLifelineWeb\packages\workcase_wechat\api\workcase\ +``` + +### Docker配置(2个文件) +``` +✅ docker-compose.yml (修改:添加4个Jitsi服务) + F:\Project\urbanLifeline\urbanLifelineServ\.bin\docker\urbanlifeline\ + +✅ application-dev.yml (修改:添加jitsi配置) + F:\Project\urbanLifeline\urbanLifelineServ\workcase\src\main\resources\ +``` + +### 文档和脚本(5个文件) +``` +✅ Jitsi-Meet本地部署指南.md +✅ Jitsi-Meet-Docker部署指南.md +✅ 项目总结.md +✅ 启动Jitsi视频会议服务.bat +✅ 停止Jitsi视频会议服务.bat +``` + +**总计:19个文件(12个新建,7个修改)** + +--- + +## 🚀 快速开始(3步搞定) + +### 步骤1:启动Jitsi Meet服务 + +**方法A:使用启动脚本(推荐)** +```bash +# 双击运行 +F:\Project\urbanLifeline\启动Jitsi视频会议服务.bat +``` + +**方法B:手动启动** +```bash +cd F:\Project\urbanLifeline\urbanLifelineServ\.bin\docker\urbanlifeline +docker-compose up -d jitsi-web jitsi-prosody jitsi-jicofo jitsi-jvb +``` + +**验证**:浏览器打开 http://localhost:8280/ + +--- + +### 步骤2:启动Java后端 + +```bash +# 确保配置正确 +# application-dev.yml 中的 jitsi 配置已自动添加 + +# 启动后端服务 +# 使用你的IDE或命令行启动 workcase-service +``` + +**验证**:访问 http://localhost:8180/swagger-ui.html 查看会议接口 + +--- + +### 步骤3:启动前端并测试 + +#### Vue前端测试 +```bash +# 启动Vue前端 +npm run dev + +# 在聊天室页面点击"发起会议"按钮 +# 应该能看到Jitsi Meet会议界面 +``` + +#### UniApp测试 +```bash +# 使用HBuilderX打开项目 +# 运行到小程序/App +# 在聊天室点击"发起会议" +# 跳转到全屏会议页面 +``` + +--- + +## 🔑 核心特性 + +### 1. 安全性 +- ✅ JWT Token身份验证(每个用户独立Token) +- ✅ 聊天室成员权限校验 +- ✅ Token有效期控制(2小时) +- ✅ 主持人权限区分(客服=主持人) + +### 2. 用户体验 +- ✅ 页面刷新后会议不丢失(活跃会议检测) +- ✅ 会议创建loading状态 +- ✅ 按钮禁用状态(会议进行中不可重复创建) +- ✅ 会议头部控制栏(显示状态+关闭按钮) +- ✅ UniApp独立会议页面(可切换聊天/会议) + +### 3. 扩展性 +- ✅ 支持主持人权限 +- ✅ 支持Jitsi配置项扩展 +- ✅ 预留会议参与者记录功能 +- ✅ Docker部署,易于扩展 + +--- + +## ⚙️ 重要配置说明 + +### JWT密钥配置 + +**Docker配置**(docker-compose.yml): +```yaml +JWT_APP_ID: urbanLifeline +JWT_APP_SECRET: your-secret-key-change-in-production +``` + +**Java后端配置**(application-dev.yml): +```yaml +jitsi: + app: + id: urbanLifeline # 必须与Docker一致 + secret: your-secret-key-change-in-production # 必须与Docker一致 +``` + +⚠️ **生产环境必须修改密钥**: +1. 生成强随机字符串(至少32位) +2. 同时修改Docker和Java配置 +3. 重启Jitsi服务和Java后端 + +--- + +## 🧪 测试步骤 + +### 1. 测试Jitsi服务 +```bash +# 访问Jitsi主页 +http://localhost:8280/ + +# 测试创建房间(应被拦截,需要JWT) +http://localhost:8280/test-room +``` + +### 2. 测试后端接口 +```bash +# 使用Postman或curl测试 +POST http://localhost:8180/urban-lifeline/workcase/chat/meeting/create +Content-Type: application/json +Authorization: Bearer + +{ + "roomId": "test-room-123", + "workcaseId": "WC001", + "meetingName": "测试会议", + "maxParticipants": 10 +} + +# 响应应包含带JWT的iframeUrl +{ + "code": 0, + "data": { + "meetingId": "xxx", + "iframeUrl": "http://localhost:8280/workcase_WC001_xxx?jwt=eyJhbGc..." + } +} +``` + +### 3. 测试前端集成 +- Vue: 在聊天室点击"发起会议" +- UniApp: 在聊天室点击"发起会议" +- 验证会议iframe能正常显示 +- 验证多人能同时加入会议 + +--- + +## 🔧 常见问题 + +### Q1: Docker启动失败? +**A**: 检查端口占用和Docker状态 +```bash +# 检查Docker +docker info + +# 检查端口 +netstat -ano | findstr "8280" +netstat -ano | findstr "10000" + +# 查看日志 +docker-compose logs jitsi-web +``` + +### Q2: JWT验证失败? +**A**: 确保密钥一致 +```bash +# 检查Docker配置 +docker-compose exec jitsi-prosody cat /config/prosody.cfg.lua | grep jwt + +# 检查Java配置 +cat application-dev.yml | grep -A 5 "jitsi:" + +# 密钥必须完全一致 +``` + +### Q3: 视频连接不上? +**A**: 检查UDP端口10000 +```bash +# Windows防火墙 +netsh advfirewall firewall add rule name="Jitsi JVB" dir=in action=allow protocol=UDP localport=10000 + +# 或临时关闭防火墙测试 +``` + +### Q4: 前端iframe显示空白? +**A**: 检查跨域和URL +```bash +# 浏览器控制台查看错误 +# 确保iframeUrl是http://localhost:8280开头 +# 检查JWT Token是否正确生成 +``` + +--- + +## 📊 项目结构 + +``` +urbanLifeline/ +├── .data/docker/jitsi/ # Jitsi数据目录 +│ ├── web/ +│ ├── prosody/ +│ ├── jicofo/ +│ └── jvb/ +├── docs/ # 文档目录 +│ ├── Jitsi-Meet本地部署指南.md +│ ├── Jitsi-Meet-Docker部署指南.md +│ └── 项目总结.md +├── urbanLifelineServ/ +│ ├── .bin/docker/urbanlifeline/ +│ │ └── docker-compose.yml # 包含Jitsi配置 +│ ├── apis/api-workcase/ +│ │ └── src/main/java/org/xyzh/api/workcase/ +│ │ └── service/ +│ │ ├── VideoMeetingService.java +│ │ └── JitsiTokenService.java +│ └── workcase/ +│ ├── src/main/java/org/xyzh/workcase/ +│ │ ├── controller/WorkcaseChatContorller.java +│ │ └── service/ +│ │ ├── VideoMeetingServiceImpl.java +│ │ └── JitsiTokenServiceImpl.java +│ └── src/main/resources/ +│ └── application-dev.yml +├── urbanLifelineWeb/ +│ └── packages/ +│ ├── workcase/ +│ │ └── src/ +│ │ ├── api/workcase/meeting.ts +│ │ └── components/chatRoom/chatRoom/ +│ │ ├── ChatRoom.vue +│ │ └── ChatRoom.scss +│ └── workcase_wechat/ +│ ├── api/workcase/workcaseChat.ts +│ └── pages/ +│ ├── chatRoom/chatRoom/chatRoom.uvue +│ └── meeting/MeetingView/MeetingView.uvue +├── 启动Jitsi视频会议服务.bat +└── 停止Jitsi视频会议服务.bat +``` + +--- + +## 🎯 下一步建议 + +### 短期优化 +1. **会议录制功能** + - 配置Jibri录制组件 + - 存储录制文件到MinIO + - 提供录制回放功能 + +2. **会议统计** + - 记录参与者加入/离开时间 + - 生成会议时长报告 + - 导出会议数据 + +3. **界面优化** + - 自定义Jitsi Meet界面主题 + - 添加品牌Logo + - 优化移动端体验 + +### 长期规划 +1. **生产部署** + - 配置域名和SSL证书 + - 配置公网IP和防火墙 + - 负载均衡和高可用 + +2. **功能增强** + - 屏幕共享优化 + - 虚拟背景 + - 会议白板功能 + - AI转录字幕 + +3. **监控告警** + - Prometheus监控 + - Grafana仪表板 + - 告警通知 + +--- + +## ✨ 总结 + +### 已实现功能 +✅ 完整的Jitsi Meet视频会议功能 +✅ JWT认证和权限控制 +✅ Docker一键部署 +✅ 前后端完整集成 +✅ Vue和UniApp双端支持 +✅ 详细的文档和脚本 + +### 技术亮点 +🌟 每个用户独立JWT Token(安全性高) +🌟 活跃会议检测(用户体验好) +🌟 Docker部署(易于维护) +🌟 微服务架构(易于扩展) + +### 项目价值 +💡 真正的企业级视频会议解决方案 +💡 完全自主可控,无第三方依赖 +💡 可扩展性强,支持二次开发 +💡 开发效率高,5分钟即可部署 + +--- + +**开发完成!祝使用愉快!** 🎉 diff --git a/jitsi-meet b/jitsi-meet deleted file mode 160000 index 6549d472..00000000 --- a/jitsi-meet +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 6549d47233326f5755b32c77b8e8801838021a0a diff --git a/urbanLifelineServ/.bin/database/postgres/sql/createTableWorkcase.sql b/urbanLifelineServ/.bin/database/postgres/sql/createTableWorkcase.sql index 53ab72e9..82198517 100644 --- a/urbanLifelineServ/.bin/database/postgres/sql/createTableWorkcase.sql +++ b/urbanLifelineServ/.bin/database/postgres/sql/createTableWorkcase.sql @@ -121,17 +121,20 @@ CREATE TABLE workcase.tb_video_meeting( workcase_id VARCHAR(50) NOT NULL, -- 关联工单ID meeting_name VARCHAR(200) NOT NULL, -- 会议名称 meeting_password VARCHAR(50) DEFAULT NULL, -- 会议密码(可选) + description VARCHAR(500) DEFAULT NULL, -- 会议模式 jwt_token TEXT DEFAULT NULL, -- JWT Token(用于身份验证) jitsi_room_name VARCHAR(200) NOT NULL, -- Jitsi房间名(格式:workcase_{workcase_id}_{timestamp}) jitsi_server_url VARCHAR(500) NOT NULL DEFAULT 'https://meet.jit.si', -- Jitsi服务器地址 status VARCHAR(20) NOT NULL DEFAULT 'scheduled', -- 状态:scheduled-已安排 ongoing-进行中 ended-已结束 cancelled-已取消 - creator_id VARCHAR(50) NOT NULL, -- 创建者ID creator_type VARCHAR(20) NOT NULL, -- 创建者类型:guest-来客 agent-客服 creator_name VARCHAR(100) NOT NULL, -- 创建者名称 participant_count INTEGER NOT NULL DEFAULT 0, -- 参与人数 max_participants INTEGER DEFAULT 10, -- 最大参与人数 - start_time TIMESTAMPTZ DEFAULT NULL, -- 实际开始时间 - end_time TIMESTAMPTZ DEFAULT NULL, -- 实际结束时间 + start_time TIMESTAMPTZ NOT NULL, -- 定义会议开始时间 + end_time TIMESTAMPTZ NOT NULL, -- 定义会议结束时间 + advance INTEGER DEFAULT 5, -- 提前入会时间(分钟) + actual_start_time TIMESTAMPTZ DEFAULT NULL, -- 真正会议开始时间 + actual_end_time TIMESTAMPTZ DEFAULT NULL, -- 真正会议结束时间 duration_seconds INTEGER DEFAULT 0, -- 会议时长(秒) iframe_url TEXT DEFAULT NULL, -- iframe嵌入URL(生成后存储) config JSONB DEFAULT NULL, -- Jitsi配置项(自定义配置) diff --git a/urbanLifelineServ/.bin/database/postgres/sql/initDataPermission.sql b/urbanLifelineServ/.bin/database/postgres/sql/initDataPermission.sql index f2b62e7a..260ffc86 100644 --- a/urbanLifelineServ/.bin/database/postgres/sql/initDataPermission.sql +++ b/urbanLifelineServ/.bin/database/postgres/sql/initDataPermission.sql @@ -47,7 +47,8 @@ INSERT INTO sys.tb_sys_module ( ('MODULE-0008', 'module_agent', '智能体', '智能体管理', 'system', NULL, now(), false), ('MODULE-0005', 'module_knowledge', '知识库', '知识文档管理', 'system', NULL, now(), false), ('MODULE-0006', 'module_bidding', '招投标', '招投标业务管理', 'system', NULL, now(), false), -('MODULE-0007', 'module_workcase', '智能客服', '客服工单管理', 'system', NULL, now(), false); +('MODULE-0007', 'module_workcase', '智能客服', '客服工单管理', 'system', NULL, now(), false), +('MODULE-0009', 'module_meeting', '视频会议', 'Jitsi Meet视频会议管理', 'system', NULL, now(), false); -- ============================= -- 4. 初始化系统权限 @@ -161,7 +162,14 @@ INSERT INTO sys.tb_sys_permission ( ('PERM-0722', 'perm_workcase_ticket_update', '工单更新', 'workcase:ticket:update', '更新工单', 'module_workcase', true, 'system', NULL, now(), false), ('PERM-0723', 'perm_workcase_ticket_view', '工单查看', 'workcase:ticket:view', '查看工单详情和列表', 'module_workcase', true, 'system', NULL, now(), false), ('PERM-0724', 'perm_workcase_ticket_process', '工单处理', 'workcase:ticket:process', '工单处理过程管理', 'module_workcase', true, 'system', NULL, now(), false), -('PERM-0725', 'perm_workcase_ticket_device', '工单设备', 'workcase:ticket:device', '工单设备管理', 'module_workcase', true, 'system', NULL, now(), false); +('PERM-0725', 'perm_workcase_ticket_device', '工单设备', 'workcase:ticket:device', '工单设备管理', 'module_workcase', true, 'system', NULL, now(), false), + +-- 视频会议模块权限(Jitsi Meet) +('PERM-0730', 'perm_meeting_create', '创建会议', 'meeting:create:own', '创建视频会议', 'module_meeting', true, 'system', NULL, now(), false), +('PERM-0731', 'perm_meeting_join', '加入会议', 'meeting:join:any', '加入视频会议', 'module_meeting', true, 'system', NULL, now(), false), +('PERM-0732', 'perm_meeting_url', '获取会议链接', 'meeting:url:any', '获取会议加入链接', 'module_meeting', true, 'system', NULL, now(), false), +('PERM-0733', 'perm_meeting_token', '获取会议令牌', 'meeting:token:any', '获取会议参与令牌', 'module_meeting', true, 'system', NULL, now(), false); + -- ============================= -- 5. 初始化视图(菜单) -- ============================= @@ -332,7 +340,12 @@ INSERT INTO sys.tb_sys_role_permission ( ('RP-U-0013', 'role_user', 'perm_file_upload', 'system', NULL, now(), false), ('RP-U-0014', 'role_user', 'perm_file_download', 'system', NULL, now(), false), ('RP-U-0015', 'role_user', 'perm_message_view', 'system', NULL, now(), false), -('RP-U-0016', 'role_user', 'perm_config_view', 'system', NULL, now(), false); +('RP-U-0016', 'role_user', 'perm_config_view', 'system', NULL, now(), false), +--- 视频会议权限 +('RP-U-0050', 'role_user', 'perm_meeting_create', 'system', NULL, now(), false), +('RP-U-0051', 'role_user', 'perm_meeting_join', 'system', NULL, now(), false), +('RP-U-0052', 'role_user', 'perm_meeting_url', 'system', NULL, now(), false), +('RP-U-0053', 'role_user', 'perm_meeting_token', 'system', NULL, now(), false); -- 访客权限(基础菜单 + workcase聊天和工单全部接口权限) INSERT INTO sys.tb_sys_role_permission ( @@ -366,7 +379,12 @@ INSERT INTO sys.tb_sys_role_permission ( ('RP-G-0042', 'role_guest', 'perm_workcase_ticket_update', 'system', NULL, now(), false), ('RP-G-0043', 'role_guest', 'perm_workcase_ticket_view', 'system', NULL, now(), false), ('RP-G-0044', 'role_guest', 'perm_workcase_ticket_process', 'system', NULL, now(), false), -('RP-G-0045', 'role_guest', 'perm_workcase_ticket_device', 'system', NULL, now(), false); +('RP-G-0045', 'role_guest', 'perm_workcase_ticket_device', 'system', NULL, now(), false), +--- 视频会议权限 +('RP-G-0050', 'role_guest', 'perm_meeting_create', 'system', NULL, now(), false), +('RP-G-0051', 'role_guest', 'perm_meeting_join', 'system', NULL, now(), false), +('RP-G-0052', 'role_guest', 'perm_meeting_url', 'system', NULL, now(), false), +('RP-G-0053', 'role_guest', 'perm_meeting_token', 'system', NULL, now(), false); -- ============================= -- 7. 视图权限关联 diff --git a/urbanLifelineServ/.bin/docker/urbanlifeline/docker-compose.yml b/urbanLifelineServ/.bin/docker/urbanlifeline/docker-compose.yml index 65515535..16b9cff5 100644 --- a/urbanLifelineServ/.bin/docker/urbanlifeline/docker-compose.yml +++ b/urbanLifelineServ/.bin/docker/urbanlifeline/docker-compose.yml @@ -74,24 +74,249 @@ services: # 管理员账户配置 MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin123 - + # Console 地址配置 MINIO_CONSOLE_ADDRESS: ":9001" MINIO_ADDRESS: ":9000" - + # 时区设置 TZ: Asia/Shanghai - + volumes: # 数据持久化到主机目录 - ../../../.data/docker/minio/data:/data - ../../../.data/docker/minio/config:/root/.minio - + command: server /data --console-address ":9001" - + healthcheck: test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] interval: 30s timeout: 20s retries: 3 start_period: 30s + + # ====================== Jitsi Meet 视频会议服务 ====================== + + jitsi-web: + image: jitsi/web:stable-9584 + container_name: urban-lifeline-jitsi-web + restart: unless-stopped + networks: + - urban-lifeline + ports: + - "8280:80" # 保留原 HTTP 端口 + - "8443:443" # 保留原 HTTPS 端口(仅保留映射,实际禁用 HTTPS) + environment: + # 基础配置(局域网访问) + TZ: Asia/Shanghai + PUBLIC_URL: http://192.168.0.253:8280 + + # 核心修复:解决 WebSocket 协议/URL 错误(局域网 IP) + BOSH_URL_BASE: http://jitsi-prosody:5280 + WEBSOCKET_URL: ws://192.168.0.253:8280/xmpp-websocket + + # XMPP 配置(完全保留原设置) + XMPP_DOMAIN: meet.jitsi + XMPP_AUTH_DOMAIN: auth.meet.jitsi + XMPP_BOSH_URL_BASE: http://jitsi-prosody:5280 + XMPP_MUC_DOMAIN: muc.meet.jitsi + XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi + XMPP_GUEST_DOMAIN: guest.meet.jitsi + + # Jicofo 配置(完全保留原设置) + JICOFO_COMPONENT_SECRET: jicofo-secret + JICOFO_AUTH_USER: focus + + # JVB 配置(完全保留原设置) + JVB_AUTH_USER: jvb + JVB_AUTH_PASSWORD: jvb-password + + # JWT 认证配置(完全保留原设置) + ENABLE_AUTH: 1 + ENABLE_GUESTS: 1 + AUTH_TYPE: jwt + JWT_APP_ID: urbanLifeline + JWT_APP_SECRET: urbanLifelinejitsi + JWT_ACCEPTED_ISSUERS: urbanLifeline + JWT_ACCEPTED_AUDIENCES: jitsi + JWT_ASAP_KEYSERVER: https://192.168.0.253:8280/ + JWT_ALLOW_EMPTY: 0 + JWT_AUTH_TYPE: token + JWT_TOKEN_AUTH_MODULE: token_verification + + # 界面/功能配置(完全保留原设置) + ENABLE_RECORDING: 0 + ENABLE_TRANSCRIPTIONS: 0 + ENABLE_SUBDOMAINS: 0 + ENABLE_XMPP_WEBSOCKET: 1 + ENABLE_SCTP: 1 + DISABLE_HTTPS: 1 + + # 日志/HTTPS 配置 + ENABLE_LETSENCRYPT: 0 + LETSENCRYPT_DOMAIN: 192.168.0.253 + + volumes: + - ../../../.data/docker/jitsi/web:/config + - ../../../.data/docker/jitsi/web/crontabs:/var/spool/cron/crontabs + - ../../../.data/docker/jitsi/transcripts:/usr/share/jitsi-meet/transcripts + depends_on: + - jitsi-prosody + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # XMPP 服务(Prosody)- 完全保留原配置 + jitsi-prosody: + image: jitsi/prosody:stable-9584 + container_name: urban-lifeline-jitsi-prosody + restart: unless-stopped + networks: + - urban-lifeline + expose: + - "5222" # XMPP客户端连接(内部) + - "5347" # XMPP组件连接(内部) + - "5280" # BOSH/WebSocket(内部) + environment: + TZ: Asia/Shanghai + + # XMPP域配置(完全保留) + XMPP_DOMAIN: meet.jitsi + XMPP_AUTH_DOMAIN: auth.meet.jitsi + XMPP_MUC_DOMAIN: muc.meet.jitsi + XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi + XMPP_GUEST_DOMAIN: guest.meet.jitsi + + # Jicofo组件密钥(完全保留) + JICOFO_COMPONENT_SECRET: jicofo-secret + JICOFO_AUTH_USER: focus + JICOFO_AUTH_PASSWORD: focus-password + + # JVB认证(完全保留) + JVB_AUTH_USER: jvb + JVB_AUTH_PASSWORD: jvb-password + + # JWT认证(完全保留) + ENABLE_AUTH: 1 + ENABLE_GUESTS: 1 + AUTH_TYPE: jwt + JWT_APP_ID: urbanLifeline + JWT_APP_SECRET: urbanLifelinejitsi + JWT_ACCEPTED_ISSUERS: urbanLifeline + JWT_ACCEPTED_AUDIENCES: jitsi + JWT_ALLOW_EMPTY: 0 + JWT_AUTH_TYPE: token + JWT_TOKEN_AUTH_MODULE: token_verification + + # 日志配置(完全保留) + LOG_LEVEL: info + + # 公共URL(局域网访问) + PUBLIC_URL: http://192.168.0.253:8280 + + volumes: + - ../../../.data/docker/jitsi/prosody/config:/config + - ../../../.data/docker/jitsi/prosody/prosody-plugins-custom:/prosody-plugins-custom + healthcheck: + test: ["CMD", "prosodyctl", "status"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 90s + + # 会议焦点控制器(Jicofo)- 完全保留原配置 + jitsi-jicofo: + image: jitsi/jicofo:stable-9584 + container_name: urban-lifeline-jitsi-jicofo + restart: unless-stopped + networks: + - urban-lifeline + environment: + TZ: Asia/Shanghai + + # XMPP配置(完全保留) + XMPP_DOMAIN: meet.jitsi + XMPP_AUTH_DOMAIN: auth.meet.jitsi + XMPP_MUC_DOMAIN: muc.meet.jitsi + XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi + XMPP_SERVER: jitsi-prosody + + # Jicofo认证(完全保留) + JICOFO_COMPONENT_SECRET: jicofo-secret + JICOFO_AUTH_USER: focus + JICOFO_AUTH_PASSWORD: focus-password + + # JWT配置(完全保留) + AUTH_TYPE: jwt + + # JVB配置(完全保留) + JVB_BREWERY_MUC: jvbbrewery + + # 日志级别(完全保留) + JICOFO_ENABLE_HEALTH_CHECKS: "true" + + volumes: + - ../../../.data/docker/jitsi/jicofo:/config + depends_on: + - jitsi-prosody + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8888/about/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 90s + + # 视频桥接服务(JVB)- 仅修复 WebSocket 相关,保留IP/端口 + jitsi-jvb: + image: jitsi/jvb:stable-9584 + container_name: urban-lifeline-jitsi-jvb + restart: unless-stopped + networks: + - urban-lifeline + ports: + - "10000:10000/udp" # 保留原 UDP 端口 + - "4443:4443/tcp" # 保留原 TCP 端口 + environment: + TZ: Asia/Shanghai + + # XMPP配置(完全保留) + XMPP_DOMAIN: meet.jitsi + XMPP_AUTH_DOMAIN: auth.meet.jitsi + XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi + XMPP_SERVER: jitsi-prosody + + # JVB认证(完全保留) + JVB_AUTH_USER: jvb + JVB_AUTH_PASSWORD: jvb-password + + # JVB配置(完全保留) + JVB_BREWERY_MUC: jvbbrewery + JVB_PORT: 10000 + JVB_STUN_SERVERS: stun.l.google.com:19302,stun1.l.google.com:19302 + + # 本地IP配置(局域网IP - 关键配置!) + DOCKER_HOST_ADDRESS: 192.168.0.253 + JVB_ADVERTISE_IPS: 192.168.0.253 + + # 启用统计(完全保留) + JVB_ENABLE_APIS: rest,colibri + + # 性能优化(完全保留) + JVB_TCP_HARVESTER_DISABLED: "false" + JVB_TCP_PORT: 4443 + JVB_TCP_MAPPED_PORT: 4443 + + volumes: + - ../../../.data/docker/jitsi/jvb:/config + depends_on: + - jitsi-prosody + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/about/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 90s diff --git a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbVideoMeetingDTO.java b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbVideoMeetingDTO.java index acc57b87..13d91dc8 100644 --- a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbVideoMeetingDTO.java +++ b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbVideoMeetingDTO.java @@ -34,6 +34,9 @@ public class TbVideoMeetingDTO extends BaseDTO { @Schema(description = "会议密码") private String meetingPassword; + @Schema(description = "会议模式") + private String description; + @Schema(description = "JWT Token") private String jwtToken; @@ -46,9 +49,6 @@ public class TbVideoMeetingDTO extends BaseDTO { @Schema(description = "状态:scheduled-已安排 ongoing-进行中 ended-已结束 cancelled-已取消") private String status; - @Schema(description = "创建者ID") - private String creatorId; - @Schema(description = "创建者类型:guest-来客 agent-客服") private String creatorType; @@ -61,6 +61,15 @@ public class TbVideoMeetingDTO extends BaseDTO { @Schema(description = "最大参与人数") private Integer maxParticipants; + @Schema(description = "定义会议开始时间") + private Date startTime; + + @Schema(description = "定义会议结束时间") + private Date endTime; + + @Schema(description = "提前入会时间(分钟)") + private Integer advance; + @Schema(description = "实际开始时间") private Date actualStartTime; diff --git a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/service/MeetService.java b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/service/MeetService.java deleted file mode 100644 index 0e486b41..00000000 --- a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/service/MeetService.java +++ /dev/null @@ -1,186 +0,0 @@ -package org.xyzh.api.workcase.service; - -import org.xyzh.api.workcase.dto.TbVideoMeetingDTO; -import org.xyzh.api.workcase.dto.TbMeetingParticipantDTO; -import org.xyzh.api.workcase.dto.TbMeetingTranscriptionDTO; -import org.xyzh.api.workcase.vo.VideoMeetingVO; -import org.xyzh.api.workcase.vo.MeetingParticipantVO; -import org.xyzh.api.workcase.vo.MeetingTranscriptionVO; -import org.xyzh.common.core.domain.ResultDomain; -import org.xyzh.common.core.page.PageRequest; - -/** - * @description 视频会议服务接口,管理Jitsi Meet会议、参与者和转录 - * @filename MeetService.java - * @author cascade - * @copyright xyzh - * @since 2025-12-22 - */ -public interface MeetService { - - // ========================= 会议管理 ========================== - - /** - * @description 创建视频会议 - * @param meeting 会议信息 - * @author cascade - * @since 2025-12-22 - */ - ResultDomain createMeeting(TbVideoMeetingDTO meeting); - - /** - * @description 更新会议信息 - * @param meeting 会议信息 - * @author cascade - * @since 2025-12-22 - */ - ResultDomain updateMeeting(TbVideoMeetingDTO meeting); - - /** - * @description 开始会议 - * @param meetingId 会议ID - * @author cascade - * @since 2025-12-22 - */ - ResultDomain startMeeting(String meetingId); - - /** - * @description 结束会议 - * @param meetingId 会议ID - * @author cascade - * @since 2025-12-22 - */ - ResultDomain endMeeting(String meetingId); - - /** - * @description 删除会议 - * @param meetingId 会议ID - * @author cascade - * @since 2025-12-22 - */ - ResultDomain deleteMeeting(String meetingId); - - /** - * @description 根据ID获取会议 - * @param meetingId 会议ID - * @author cascade - * @since 2025-12-22 - */ - ResultDomain getMeetingById(String meetingId); - - /** - * @description 获取会议列表/分页 - * @param pageRequest 分页请求 - * @author cascade - * @since 2025-12-22 - */ - ResultDomain getMeetingPage(PageRequest pageRequest); - - /** - * @description 生成会议加入链接/iframe URL - * @param meetingId 会议ID - * @param userId 用户ID - * @param userName 用户名称 - * @author cascade - * @since 2025-12-22 - */ - ResultDomain generateMeetingJoinUrl(String meetingId, String userId, String userName); - - /** - * @description 生成会议JWT Token - * @param meetingId 会议ID - * @param userId 用户ID - * @param isModerator 是否主持人 - * @author cascade - * @since 2025-12-22 - */ - ResultDomain generateMeetingToken(String meetingId, String userId, boolean isModerator); - - // ========================= 参与者管理 ========================== - - /** - * @description 参与者加入会议 - * @param participant 参与者信息 - * @author cascade - * @since 2025-12-22 - */ - ResultDomain joinMeeting(TbMeetingParticipantDTO participant); - - /** - * @description 参与者离开会议 - * @param participantId 参与者ID - * @author cascade - * @since 2025-12-22 - */ - ResultDomain leaveMeeting(String participantId); - - /** - * @description 获取会议参与者列表 - * @param meetingId 会议ID - * @author cascade - * @since 2025-12-22 - */ - ResultDomain getMeetingParticipantList(String meetingId); - - /** - * @description 更新参与者信息 - * @param participant 参与者信息 - * @author cascade - * @since 2025-12-22 - */ - ResultDomain updateParticipant(TbMeetingParticipantDTO participant); - - /** - * @description 设置参与者为主持人 - * @param participantId 参与者ID - * @param isModerator 是否主持人 - * @author cascade - * @since 2025-12-22 - */ - ResultDomain setModerator(String participantId, boolean isModerator); - - // ========================= 转录管理 ========================== - - /** - * @description 添加转录记录 - * @param transcription 转录内容 - * @author cascade - * @since 2025-12-22 - */ - ResultDomain addTranscription(TbMeetingTranscriptionDTO transcription); - - /** - * @description 获取会议转录列表/分页 - * @param pageRequest 分页请求 - * @author cascade - * @since 2025-12-22 - */ - ResultDomain getTranscriptionPage(PageRequest pageRequest); - - /** - * @description 获取会议完整转录文本 - * @param meetingId 会议ID - * @author cascade - * @since 2025-12-22 - */ - ResultDomain getFullTranscriptionText(String meetingId); - - /** - * @description 删除转录记录 - * @param transcriptionId 转录ID - * @author cascade - * @since 2025-12-22 - */ - ResultDomain deleteTranscription(String transcriptionId); - - // ========================= 会议统计 ========================== - - /** - * @description 获取会议统计信息(参与人数、时长等) - * @param meetingId 会议ID - * @author cascade - * @since 2025-12-22 - */ - ResultDomain getMeetingStatistics(String meetingId); - -} diff --git a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/VideoMeetingVO.java b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/VideoMeetingVO.java index 9cc570c4..8d075d19 100644 --- a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/VideoMeetingVO.java +++ b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/VideoMeetingVO.java @@ -33,6 +33,10 @@ public class VideoMeetingVO extends BaseVO { @Schema(description = "会议密码") private String meetingPassword; + @Schema(description = "会议模式") + private String description; + + @Schema(description = "JWT Token(用于身份验证)") private String jwtToken; @@ -45,9 +49,6 @@ public class VideoMeetingVO extends BaseVO { @Schema(description = "状态:scheduled-已安排 ongoing-进行中 ended-已结束 cancelled-已取消") private String status; - @Schema(description = "创建者ID") - private String creatorId; - @Schema(description = "创建者类型:guest-来客 agent-客服") private String creatorType; @@ -59,6 +60,17 @@ public class VideoMeetingVO extends BaseVO { @Schema(description = "最大参与人数") private Integer maxParticipants; + + @Schema(description = "定义会议开始时间", format = "date-time") + @JSONField(format = "yyyy-MM-dd HH:mm:ss") + private Date startTime; + + @Schema(description = "定义会议结束时间", format = "date-time") + @JSONField(format = "yyyy-MM-dd HH:mm:ss") + private Date endTime; + + @Schema(description = "提前入会时间(分钟)") + private Integer advance; @Schema(description = "实际开始时间", format = "date-time") @JSONField(format = "yyyy-MM-dd HH:mm:ss") diff --git a/urbanLifelineServ/common/common-exception/pom.xml b/urbanLifelineServ/common/common-exception/pom.xml index 1d2f28e9..3f49f094 100644 --- a/urbanLifelineServ/common/common-exception/pom.xml +++ b/urbanLifelineServ/common/common-exception/pom.xml @@ -33,6 +33,11 @@ org.springframework.boot spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-security + + org.projectlombok lombok diff --git a/urbanLifelineServ/common/common-exception/src/main/java/org/xyzh/common/exception/handler/GlobalExceptionHandler.java b/urbanLifelineServ/common/common-exception/src/main/java/org/xyzh/common/exception/handler/GlobalExceptionHandler.java index 60d4b1b6..17636708 100644 --- a/urbanLifelineServ/common/common-exception/src/main/java/org/xyzh/common/exception/handler/GlobalExceptionHandler.java +++ b/urbanLifelineServ/common/common-exception/src/main/java/org/xyzh/common/exception/handler/GlobalExceptionHandler.java @@ -2,8 +2,11 @@ package org.xyzh.common.exception.handler; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; -import lombok.extern.slf4j.Slf4j; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; +import org.springframework.security.authorization.AuthorizationDeniedException; import org.springframework.validation.BindException; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -26,17 +29,16 @@ import java.util.stream.Collectors; * @copyright yslg * @since 2025-12-17 */ -@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { - + private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); /** * 业务异常 */ @ExceptionHandler(BusinessException.class) @ResponseStatus(HttpStatus.OK) public ResultDomain> handleBusinessException(BusinessException e) { - log.warn("业务异常: {}", e.getMessage()); + logger.warn("业务异常: {}", e.getMessage()); return ResultDomain.failure(e.getCode(), e.getMessage()); } @@ -49,7 +51,7 @@ public class GlobalExceptionHandler { String message = e.getBindingResult().getFieldErrors().stream() .map(FieldError::getDefaultMessage) .collect(Collectors.joining("; ")); - log.warn("参数校验失败: {}", message); + logger.warn("参数校验失败: {}", message); return ResultDomain.failure(HttpStatus.BAD_REQUEST.value(), message); } @@ -63,7 +65,7 @@ public class GlobalExceptionHandler { String message = violations.stream() .map(ConstraintViolation::getMessage) .collect(Collectors.joining("; ")); - log.warn("参数校验失败: {}", message); + logger.warn("参数校验失败: {}", message); return ResultDomain.failure(HttpStatus.BAD_REQUEST.value(), message); } @@ -76,7 +78,7 @@ public class GlobalExceptionHandler { String message = e.getFieldErrors().stream() .map(FieldError::getDefaultMessage) .collect(Collectors.joining("; ")); - log.warn("参数绑定失败: {}", message); + logger.warn("参数绑定失败: {}", message); return ResultDomain.failure(HttpStatus.BAD_REQUEST.value(), message); } @@ -87,7 +89,7 @@ public class GlobalExceptionHandler { @ResponseStatus(HttpStatus.OK) public ResultDomain> handleMissingServletRequestParameterException(MissingServletRequestParameterException e) { String message = "缺少必要参数: " + e.getParameterName(); - log.warn(message); + logger.warn(message); return ResultDomain.failure(HttpStatus.BAD_REQUEST.value(), message); } @@ -98,7 +100,7 @@ public class GlobalExceptionHandler { @ResponseStatus(HttpStatus.OK) public ResultDomain> handleMissingServletRequestPartException(MissingServletRequestPartException e) { String message = "缺少必要参数: " + e.getRequestPartName(); - log.warn(message); + logger.warn(message); return ResultDomain.failure(HttpStatus.BAD_REQUEST.value(), message); } @@ -108,17 +110,27 @@ public class GlobalExceptionHandler { @ExceptionHandler(MaxUploadSizeExceededException.class) @ResponseStatus(HttpStatus.OK) public ResultDomain> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) { - log.warn("文件上传大小超限: {}", e.getMessage()); + logger.warn("文件上传大小超限: {}", e.getMessage()); return ResultDomain.failure(HttpStatus.BAD_REQUEST.value(), "上传文件大小超过限制"); } + /** + * 权限不足异常 + */ + @ExceptionHandler(AuthorizationDeniedException.class) + @ResponseStatus(HttpStatus.OK) + public ResultDomain> handleAuthorizationDeniedException(AuthorizationDeniedException e) { + logger.warn("权限不足: {}", e.getMessage()); + return ResultDomain.failure(HttpStatus.FORBIDDEN.value(), "权限不足"); + } + /** * 其他未捕获异常 */ @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.OK) public ResultDomain> handleException(Exception e) { - log.error("系统异常: ", e); + logger.error("系统异常: ", e); return ResultDomain.failure(HttpStatus.INTERNAL_SERVER_ERROR.value(), "系统异常,请联系管理员"); } } diff --git a/urbanLifelineServ/common/common-utils/src/main/java/org/xyzh/common/utils/validation/ValidationUtils.java b/urbanLifelineServ/common/common-utils/src/main/java/org/xyzh/common/utils/validation/ValidationUtils.java index 4d666f17..24329840 100644 --- a/urbanLifelineServ/common/common-utils/src/main/java/org/xyzh/common/utils/validation/ValidationUtils.java +++ b/urbanLifelineServ/common/common-utils/src/main/java/org/xyzh/common/utils/validation/ValidationUtils.java @@ -1,5 +1,6 @@ package org.xyzh.common.utils.validation; +import org.xyzh.common.utils.validation.method.FieldCompareValidateMethod; import org.xyzh.common.utils.validation.method.InSetValidateMethod; import org.xyzh.common.utils.validation.method.MinFieldsValidateMethod; import org.xyzh.common.utils.validation.method.ObjectValidateMethod; @@ -10,6 +11,7 @@ import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.function.BiFunction; import java.util.regex.Pattern; /** @@ -380,4 +382,32 @@ public class ValidationUtils { .validateMethod(new InSetValidateMethod(fieldLabel, allowedValues)) .build(); } + + /** + * @description 创建字段比较校验参数(比较对象中的两个字段) + * @param field1Name 第一个字段名称 + * @param field2Name 第二个字段名称 + * @param fieldLabel 字段标签 + * @param compareFunction 比较函数:(field1Value, field2Value) -> Boolean,返回true表示通过 + * @param errorMessage 自定义错误消息 + * @return ValidationParam + */ + public static ValidationParam fieldCompare(String field1Name, + String field2Name, + String fieldLabel, + BiFunction compareFunction, + String errorMessage) { + return ValidationParam.builder() + .fieldName(field1Name) + .fieldLabel(fieldLabel) + .required(false) + .validateMethod(new FieldCompareValidateMethod( + field1Name, + field2Name, + fieldLabel, + compareFunction, + errorMessage + )) + .build(); + } } diff --git a/urbanLifelineServ/common/common-utils/src/main/java/org/xyzh/common/utils/validation/method/FieldCompareValidateMethod.java b/urbanLifelineServ/common/common-utils/src/main/java/org/xyzh/common/utils/validation/method/FieldCompareValidateMethod.java new file mode 100644 index 00000000..9b78d2f5 --- /dev/null +++ b/urbanLifelineServ/common/common-utils/src/main/java/org/xyzh/common/utils/validation/method/FieldCompareValidateMethod.java @@ -0,0 +1,156 @@ +package org.xyzh.common.utils.validation.method; + +import java.lang.reflect.Field; +import java.util.function.BiFunction; + +/** + * @description 字段比较校验方法,用于比较对象中的两个字段 + * @filename FieldCompareValidateMethod.java + * @author Claude Code + * @copyright xyzh + * @since 2025-12-26 + */ +public class FieldCompareValidateMethod implements ObjectValidateMethod { + + /** + * 第一个字段名称 + */ + private final String field1Name; + + /** + * 第二个字段名称 + */ + private final String field2Name; + + /** + * 字段标签(用于错误消息) + */ + private final String fieldLabel; + + /** + * 比较函数:(field1Value, field2Value) -> Boolean + * 返回 true 表示校验通过,false 表示校验失败 + */ + private final BiFunction compareFunction; + + /** + * 自定义错误消息 + */ + private final String customErrorMessage; + + /** + * 构造函数 + * + * @param field1Name 第一个字段名称 + * @param field2Name 第二个字段名称 + * @param fieldLabel 字段标签 + * @param compareFunction 比较函数 + * @param customErrorMessage 自定义错误消息 + */ + public FieldCompareValidateMethod(String field1Name, + String field2Name, + String fieldLabel, + BiFunction compareFunction, + String customErrorMessage) { + this.field1Name = field1Name; + this.field2Name = field2Name; + this.fieldLabel = fieldLabel; + this.compareFunction = compareFunction; + this.customErrorMessage = customErrorMessage; + } + + @Override + public Boolean validate(Object targetObject) { + if (targetObject == null) { + return true; + } + + try { + // 获取第一个字段的值 + Object field1Value = getFieldValue(targetObject, field1Name); + + // 获取第二个字段的值 + Object field2Value = getFieldValue(targetObject, field2Name); + + // 如果任意字段为null,跳过校验 + if (field1Value == null || field2Value == null) { + return true; + } + + // 执行比较函数 + return compareFunction.apply(field1Value, field2Value); + + } catch (Exception e) { + throw new RuntimeException("字段比较校验失败: " + e.getMessage(), e); + } + } + + @Override + public String getErrorMessage() { + if (customErrorMessage != null && !customErrorMessage.trim().isEmpty()) { + return customErrorMessage; + } + return fieldLabel + "校验失败"; + } + + @Override + public String getName() { + return "字段比较校验"; + } + + /** + * 获取字段值(支持嵌套字段) + * + * @param obj 对象 + * @param fieldName 字段名称(支持 "field" 或 "nested.field") + * @return 字段值 + */ + private Object getFieldValue(Object obj, String fieldName) throws Exception { + if (fieldName == null || fieldName.trim().isEmpty()) { + return null; + } + + // 支持嵌套字段访问(如 "user.name") + String[] fieldParts = fieldName.split("\\."); + Object currentObj = obj; + + for (String part : fieldParts) { + if (currentObj == null) { + return null; + } + + // 获取字段 + Field field = findField(currentObj.getClass(), part); + if (field == null) { + throw new NoSuchFieldException("字段不存在: " + part); + } + + // 设置可访问 + field.setAccessible(true); + + // 获取字段值 + currentObj = field.get(currentObj); + } + + return currentObj; + } + + /** + * 查找字段(包括父类) + * + * @param clazz 类 + * @param fieldName 字段名称 + * @return 字段 + */ + private Field findField(Class> clazz, String fieldName) { + Class> currentClass = clazz; + while (currentClass != null) { + try { + return currentClass.getDeclaredField(fieldName); + } catch (NoSuchFieldException e) { + currentClass = currentClass.getSuperclass(); + } + } + return null; + } +} diff --git a/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/controller/WorkcaseChatContorller.java b/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/controller/WorkcaseChatContorller.java index e5a540d2..58c66500 100644 --- a/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/controller/WorkcaseChatContorller.java +++ b/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/controller/WorkcaseChatContorller.java @@ -17,6 +17,7 @@ import org.xyzh.api.workcase.dto.TbChatRoomDTO; import org.xyzh.api.workcase.dto.TbChatRoomMemberDTO; import org.xyzh.api.workcase.dto.TbChatRoomMessageDTO; import org.xyzh.api.workcase.dto.TbCustomerServiceDTO; +import org.xyzh.api.workcase.dto.TbVideoMeetingDTO; import org.xyzh.api.workcase.dto.TbWordCloudDTO; import org.xyzh.api.workcase.service.ChatRoomService; import org.xyzh.api.workcase.service.WorkcaseChatService; @@ -24,16 +25,20 @@ import org.xyzh.api.workcase.vo.ChatMemberVO; import org.xyzh.api.workcase.vo.ChatRoomMessageVO; import org.xyzh.api.workcase.vo.ChatRoomVO; import org.xyzh.api.workcase.vo.CustomerServiceVO; +import org.xyzh.api.workcase.vo.VideoMeetingVO; import org.xyzh.common.auth.utils.JwtTokenUtil; import org.xyzh.common.auth.utils.LoginUtil; import org.xyzh.common.core.domain.ResultDomain; import org.xyzh.common.core.page.PageRequest; +import org.xyzh.common.utils.validation.ValidationParam; import org.xyzh.common.utils.validation.ValidationResult; import org.xyzh.common.utils.validation.ValidationUtils; import io.swagger.v3.oas.annotations.Operation; import java.util.Arrays; +import java.util.Date; + import io.swagger.v3.oas.annotations.tags.Tag; /** @@ -274,12 +279,38 @@ public class WorkcaseChatContorller { private org.xyzh.api.workcase.service.VideoMeetingService videoMeetingService; @Operation(summary = "创建视频会议") - @PreAuthorize("hasAuthority('workcase:room:meeting')") + @PreAuthorize("hasAuthority('meeting:create:own')") @PostMapping("/meeting/create") - public ResultDomain createVideoMeeting( - @RequestBody org.xyzh.api.workcase.dto.TbVideoMeetingDTO meetingDTO) { + public ResultDomain createVideoMeeting( + @RequestBody TbVideoMeetingDTO meetingDTO) { ValidationResult vr = ValidationUtils.validate(meetingDTO, Arrays.asList( - ValidationUtils.requiredString("roomId", "聊天室ID") + ValidationUtils.requiredString("roomId", "聊天室ID"), + ValidationUtils.requiredString("meetingName", "会议名称"), + // 校验开始时间不为空 + ValidationParam.builder() + .fieldName("startTime") + .fieldLabel("会议开始时间") + .required() + .build(), + // 校验结束时间不为空 + ValidationParam.builder() + .fieldName("endTime") + .fieldLabel("会议结束时间") + .required() + .build(), + // 校验开始时间小于结束时间(使用 fieldCompare 比较两个字段) + ValidationUtils.fieldCompare( + "startTime", + "endTime", + "会议时间", + (startTime, endTime) -> { + if (startTime instanceof Date && endTime instanceof Date) { + return ((Date) startTime).before((Date) endTime); + } + return true; + }, + "会议开始时间不能晚于结束时间" + ) )); if (!vr.isValid()) { return ResultDomain.failure(vr.getAllErrors()); @@ -293,9 +324,9 @@ public class WorkcaseChatContorller { } @Operation(summary = "获取会议信息") - @PreAuthorize("hasAuthority('workcase:room:meeting')") + @PreAuthorize("hasAuthority('meeting:url:any')") @GetMapping("/meeting/{meetingId}") - public ResultDomain getMeetingInfo( + public ResultDomain getMeetingInfo( @PathVariable(value = "meetingId") String meetingId) { String userId = LoginUtil.getCurrentUserId(); @@ -307,9 +338,9 @@ public class WorkcaseChatContorller { } @Operation(summary = "加入会议(生成用户专属JWT)") - @PreAuthorize("hasAuthority('workcase:room:meeting')") + @PreAuthorize("hasAuthority('meeting:join:any')") @PostMapping("/meeting/{meetingId}/join") - public ResultDomain joinMeeting( + public ResultDomain joinMeeting( @PathVariable(value = "meetingId") String meetingId) { String userId = LoginUtil.getCurrentUserId(); @@ -328,7 +359,7 @@ public class WorkcaseChatContorller { } @Operation(summary = "开始会议") - @PreAuthorize("hasAuthority('workcase:room:meeting')") + @PreAuthorize("hasAuthority('meeting:create:own')") @PostMapping("/meeting/{meetingId}/start") public ResultDomain startMeeting(@PathVariable(value = "meetingId") String meetingId) { try { @@ -339,9 +370,9 @@ public class WorkcaseChatContorller { } @Operation(summary = "结束会议") - @PreAuthorize("hasAuthority('workcase:room:meeting')") + @PreAuthorize("hasAuthority('meeting:create:own')") @PostMapping("/meeting/{meetingId}/end") - public ResultDomain endMeeting( + public ResultDomain endMeeting( @PathVariable(value = "meetingId") String meetingId) { try { return videoMeetingService.endMeeting(meetingId); @@ -351,9 +382,9 @@ public class WorkcaseChatContorller { } @Operation(summary = "获取聊天室当前活跃会议") - @PreAuthorize("hasAuthority('workcase:room:meeting')") + @PreAuthorize("hasAuthority('meeting:url:any')") @GetMapping("/meeting/room/{roomId}/active") - public ResultDomain getActiveMeetingByRoom( + public ResultDomain getActiveMeetingByRoom( @PathVariable(value = "roomId") String roomId) { try { return videoMeetingService.getActiveMeetingByRoom(roomId); diff --git a/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/service/MeetServiceImpl.java b/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/service/MeetServiceImpl.java deleted file mode 100644 index 08c4cde3..00000000 --- a/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/service/MeetServiceImpl.java +++ /dev/null @@ -1,450 +0,0 @@ -package org.xyzh.workcase.service; - -import java.util.Date; -import java.util.List; - -import org.apache.dubbo.config.annotation.DubboService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.transaction.annotation.Transactional; -import org.xyzh.api.workcase.dto.TbVideoMeetingDTO; -import org.xyzh.api.workcase.dto.TbMeetingParticipantDTO; -import org.xyzh.api.workcase.dto.TbMeetingTranscriptionDTO; -import org.xyzh.api.workcase.service.MeetService; -import org.xyzh.api.workcase.vo.VideoMeetingVO; -import org.xyzh.api.workcase.vo.MeetingParticipantVO; -import org.xyzh.api.workcase.vo.MeetingTranscriptionVO; -import org.xyzh.common.core.domain.ResultDomain; -import org.xyzh.common.core.page.PageDomain; -import org.xyzh.common.core.page.PageParam; -import org.xyzh.common.core.page.PageRequest; -import org.xyzh.common.utils.id.IdUtil; -import org.xyzh.workcase.mapper.TbVideoMeetingMapper; -import org.xyzh.workcase.mapper.TbMeetingParticipantMapper; -import org.xyzh.workcase.mapper.TbMeetingTranscriptionMapper; - -/** - * @description 视频会议服务实现类(伪代码) - * @filename MeetServiceImpl.java - * @author cascade - * @copyright xyzh - * @since 2025-12-22 - */ -@DubboService(version = "1.0.0", group = "workcase", timeout = 30000, retries = 0) -public class MeetServiceImpl implements MeetService { - private static final Logger logger = LoggerFactory.getLogger(MeetServiceImpl.class); - - @Autowired - private TbVideoMeetingMapper videoMeetingMapper; - - @Autowired - private TbMeetingParticipantMapper meetingParticipantMapper; - - @Autowired - private TbMeetingTranscriptionMapper meetingTranscriptionMapper; - - // TODO: 注入Jitsi配置和JWT工具类 - // @Autowired - // private JitsiConfig jitsiConfig; - // @Autowired - // private JwtTokenUtil jwtTokenUtil; - - // ========================= 会议管理 ========================== - - @Override - @Transactional - public ResultDomain createMeeting(TbVideoMeetingDTO meeting) { - logger.info("创建会议: roomId={}, meetingName={}", meeting.getRoomId(), meeting.getMeetingName()); - - // TODO: 生成唯一的Jitsi房间名 - // String jitsiRoomName = "meet_" + IdUtil.generateUUID().replace("-", ""); - - if (meeting.getMeetingId() == null || meeting.getMeetingId().isEmpty()) { - meeting.setMeetingId(IdUtil.generateUUID()); - } - if (meeting.getOptsn() == null || meeting.getOptsn().isEmpty()) { - meeting.setOptsn(IdUtil.getOptsn()); - } - if (meeting.getStatus() == null || meeting.getStatus().isEmpty()) { - meeting.setStatus("scheduled"); - } - // TODO: 设置Jitsi相关配置 - // meeting.setJitsiRoomName(jitsiRoomName); - // meeting.setJitsiServerUrl(jitsiConfig.getServerUrl()); - - int rows = videoMeetingMapper.insertVideoMeeting(meeting); - if (rows > 0) { - logger.info("会议创建成功: meetingId={}", meeting.getMeetingId()); - return ResultDomain.success("创建成功", meeting); - } - return ResultDomain.failure("创建失败"); - } - - @Override - public ResultDomain updateMeeting(TbVideoMeetingDTO meeting) { - logger.info("更新会议: meetingId={}", meeting.getMeetingId()); - - TbVideoMeetingDTO existing = videoMeetingMapper.selectVideoMeetingById(meeting.getMeetingId()); - if (existing == null) { - return ResultDomain.failure("会议不存在"); - } - - int rows = videoMeetingMapper.updateVideoMeeting(meeting); - if (rows > 0) { - TbVideoMeetingDTO updated = videoMeetingMapper.selectVideoMeetingById(meeting.getMeetingId()); - return ResultDomain.success("更新成功", updated); - } - return ResultDomain.failure("更新失败"); - } - - @Override - public ResultDomain startMeeting(String meetingId) { - logger.info("开始会议: meetingId={}", meetingId); - - TbVideoMeetingDTO existing = videoMeetingMapper.selectVideoMeetingById(meetingId); - if (existing == null) { - return ResultDomain.failure("会议不存在"); - } - if ("ongoing".equals(existing.getStatus())) { - return ResultDomain.failure("会议已在进行中"); - } - - TbVideoMeetingDTO meeting = new TbVideoMeetingDTO(); - meeting.setMeetingId(meetingId); - meeting.setStatus("ongoing"); - meeting.setActualStartTime(new Date()); - - int rows = videoMeetingMapper.updateVideoMeeting(meeting); - if (rows > 0) { - TbVideoMeetingDTO updated = videoMeetingMapper.selectVideoMeetingById(meetingId); - return ResultDomain.success("会议已开始", updated); - } - return ResultDomain.failure("开始会议失败"); - } - - @Override - public ResultDomain endMeeting(String meetingId) { - logger.info("结束会议: meetingId={}", meetingId); - - TbVideoMeetingDTO existing = videoMeetingMapper.selectVideoMeetingById(meetingId); - if (existing == null) { - return ResultDomain.failure("会议不存在"); - } - - TbVideoMeetingDTO meeting = new TbVideoMeetingDTO(); - meeting.setMeetingId(meetingId); - meeting.setStatus("ended"); - meeting.setActualEndTime(new Date()); - - // TODO: 计算会议时长 - // if (existing.getActualStartTime() != null) { - // long durationMs = new Date().getTime() - existing.getActualStartTime().getTime(); - // meeting.setDurationSeconds((int)(durationMs / 1000)); - // } - - int rows = videoMeetingMapper.updateVideoMeeting(meeting); - if (rows > 0) { - // TODO: 更新所有参与者离开时间 - // updateAllParticipantsLeaveTime(meetingId); - - TbVideoMeetingDTO updated = videoMeetingMapper.selectVideoMeetingById(meetingId); - return ResultDomain.success("会议已结束", updated); - } - return ResultDomain.failure("结束会议失败"); - } - - @Override - public ResultDomain deleteMeeting(String meetingId) { - logger.info("删除会议: meetingId={}", meetingId); - - TbVideoMeetingDTO existing = videoMeetingMapper.selectVideoMeetingById(meetingId); - if (existing == null) { - return ResultDomain.failure("会议不存在"); - } - - TbVideoMeetingDTO meeting = new TbVideoMeetingDTO(); - meeting.setMeetingId(meetingId); - int rows = videoMeetingMapper.deleteVideoMeeting(meeting); - if (rows > 0) { - return ResultDomain.success("删除成功", true); - } - return ResultDomain.failure("删除失败"); - } - - @Override - public ResultDomain getMeetingById(String meetingId) { - TbVideoMeetingDTO meeting = videoMeetingMapper.selectVideoMeetingById(meetingId); - if (meeting != null) { - return ResultDomain.success("查询成功", meeting); - } - return ResultDomain.failure("会议不存在"); - } - - @Override - public ResultDomain getMeetingPage(PageRequest pageRequest) { - TbVideoMeetingDTO filter = pageRequest.getFilter(); - if (filter == null) { - filter = new TbVideoMeetingDTO(); - } - - PageParam pageParam = pageRequest.getPageParam(); - List list = videoMeetingMapper.selectVideoMeetingPage(filter, pageParam); - long total = videoMeetingMapper.countVideoMeetings(filter); - pageParam.setTotal((int) total); - - PageDomain pageDomain = new PageDomain<>(pageParam, list); - return ResultDomain.success("查询成功", pageDomain); - } - - @Override - public ResultDomain generateMeetingJoinUrl(String meetingId, String userId, String userName) { - logger.info("生成会议加入链接: meetingId={}, userId={}", meetingId, userId); - - TbVideoMeetingDTO meeting = videoMeetingMapper.selectVideoMeetingById(meetingId); - if (meeting == null) { - return ResultDomain.failure("会议不存在"); - } - - // TODO: 生成Jitsi iframe URL - // String jwtToken = generateMeetingToken(meetingId, userId, false).getData(); - // String baseUrl = meeting.getJitsiServerUrl(); - // String roomName = meeting.getJitsiRoomName(); - // String iframeUrl = String.format("%s/%s?jwt=%s#userInfo.displayName=%s", - // baseUrl, roomName, jwtToken, URLEncoder.encode(userName, "UTF-8")); - - String iframeUrl = "TODO: 生成Jitsi iframe URL"; - return ResultDomain.success("生成成功", iframeUrl); - } - - @Override - public ResultDomain generateMeetingToken(String meetingId, String userId, boolean isModerator) { - logger.info("生成会议JWT: meetingId={}, userId={}, isModerator={}", meetingId, userId, isModerator); - - TbVideoMeetingDTO meeting = videoMeetingMapper.selectVideoMeetingById(meetingId); - if (meeting == null) { - return ResultDomain.failure("会议不存在"); - } - - // TODO: 使用Jitsi JWT规范生成Token - // JitsiTokenPayload payload = new JitsiTokenPayload(); - // payload.setRoom(meeting.getJitsiRoomName()); - // payload.setModerator(isModerator); - // payload.setUserId(userId); - // String token = jwtTokenUtil.generateJitsiToken(payload); - - String token = "TODO: 生成Jitsi JWT Token"; - return ResultDomain.success("生成成功", token); - } - - // ========================= 参与者管理 ========================== - - @Override - public ResultDomain joinMeeting(TbMeetingParticipantDTO participant) { - logger.info("参与者加入会议: meetingId={}, userId={}", participant.getMeetingId(), participant.getUserId()); - - // 检查会议是否存在 - TbVideoMeetingDTO meeting = videoMeetingMapper.selectVideoMeetingById(participant.getMeetingId()); - if (meeting == null) { - return ResultDomain.failure("会议不存在"); - } - - if (participant.getParticipantId() == null || participant.getParticipantId().isEmpty()) { - participant.setParticipantId(IdUtil.generateUUID()); - } - if (participant.getOptsn() == null || participant.getOptsn().isEmpty()) { - participant.setOptsn(IdUtil.getOptsn()); - } - participant.setJoinTime(new Date()); - - int rows = meetingParticipantMapper.insertMeetingParticipant(participant); - if (rows > 0) { - // 更新会议参与人数 - TbVideoMeetingDTO updateMeeting = new TbVideoMeetingDTO(); - updateMeeting.setMeetingId(participant.getMeetingId()); - updateMeeting.setParticipantCount(meeting.getParticipantCount() != null ? meeting.getParticipantCount() + 1 : 1); - videoMeetingMapper.updateVideoMeeting(updateMeeting); - - return ResultDomain.success("加入成功", participant); - } - return ResultDomain.failure("加入失败"); - } - - @Override - public ResultDomain leaveMeeting(String participantId) { - logger.info("参与者离开会议: participantId={}", participantId); - - TbMeetingParticipantDTO existing = meetingParticipantMapper.selectMeetingParticipantById(participantId); - if (existing == null) { - return ResultDomain.failure("参与者不存在"); - } - - TbMeetingParticipantDTO participant = new TbMeetingParticipantDTO(); - participant.setParticipantId(participantId); - participant.setLeaveTime(new Date()); - - // TODO: 计算参与时长 - // if (existing.getJoinTime() != null) { - // long durationMs = new Date().getTime() - existing.getJoinTime().getTime(); - // participant.setDurationSeconds((int)(durationMs / 1000)); - // } - - int rows = meetingParticipantMapper.updateMeetingParticipant(participant); - if (rows > 0) { - return ResultDomain.success("离开成功", true); - } - return ResultDomain.failure("离开失败"); - } - - @Override - public ResultDomain getMeetingParticipantList(String meetingId) { - TbMeetingParticipantDTO filter = new TbMeetingParticipantDTO(); - filter.setMeetingId(meetingId); - List list = meetingParticipantMapper.selectMeetingParticipantList(filter); - return ResultDomain.success("查询成功", list); - } - - @Override - public ResultDomain updateParticipant(TbMeetingParticipantDTO participant) { - logger.info("更新参与者: participantId={}", participant.getParticipantId()); - - TbMeetingParticipantDTO existing = meetingParticipantMapper.selectMeetingParticipantById(participant.getParticipantId()); - if (existing == null) { - return ResultDomain.failure("参与者不存在"); - } - - int rows = meetingParticipantMapper.updateMeetingParticipant(participant); - if (rows > 0) { - TbMeetingParticipantDTO updated = meetingParticipantMapper.selectMeetingParticipantById(participant.getParticipantId()); - return ResultDomain.success("更新成功", updated); - } - return ResultDomain.failure("更新失败"); - } - - @Override - public ResultDomain setModerator(String participantId, boolean isModerator) { - logger.info("设置主持人: participantId={}, isModerator={}", participantId, isModerator); - - TbMeetingParticipantDTO participant = new TbMeetingParticipantDTO(); - participant.setParticipantId(participantId); - participant.setIsModerator(isModerator); - - int rows = meetingParticipantMapper.updateMeetingParticipant(participant); - if (rows > 0) { - return ResultDomain.success("设置成功", true); - } - return ResultDomain.failure("设置失败"); - } - - // ========================= 转录管理 ========================== - - @Override - public ResultDomain addTranscription(TbMeetingTranscriptionDTO transcription) { - logger.info("添加转录记录: meetingId={}, speakerId={}", transcription.getMeetingId(), transcription.getSpeakerId()); - - if (transcription.getTranscriptionId() == null || transcription.getTranscriptionId().isEmpty()) { - transcription.setTranscriptionId(IdUtil.generateUUID()); - } - if (transcription.getOptsn() == null || transcription.getOptsn().isEmpty()) { - transcription.setOptsn(IdUtil.getOptsn()); - } - - int rows = meetingTranscriptionMapper.insertMeetingTranscription(transcription); - if (rows > 0) { - return ResultDomain.success("添加成功", transcription); - } - return ResultDomain.failure("添加失败"); - } - - @Override - public ResultDomain getTranscriptionPage(PageRequest pageRequest) { - TbMeetingTranscriptionDTO filter = pageRequest.getFilter(); - if (filter == null) { - filter = new TbMeetingTranscriptionDTO(); - } - - PageParam pageParam = pageRequest.getPageParam(); - List list = meetingTranscriptionMapper.selectMeetingTranscriptionPage(filter, pageParam); - long total = meetingTranscriptionMapper.countMeetingTranscriptions(filter); - pageParam.setTotal((int) total); - - PageDomain pageDomain = new PageDomain<>(pageParam, list); - return ResultDomain.success("查询成功", pageDomain); - } - - @Override - public ResultDomain getFullTranscriptionText(String meetingId) { - logger.info("获取完整转录文本: meetingId={}", meetingId); - - TbMeetingTranscriptionDTO filter = new TbMeetingTranscriptionDTO(); - filter.setMeetingId(meetingId); - filter.setIsFinal(true); - List list = meetingTranscriptionMapper.selectMeetingTranscriptionList(filter); - - // TODO: 拼接转录文本 - StringBuilder sb = new StringBuilder(); - for (MeetingTranscriptionVO transcription : list) { - // 格式:[说话人名称] 内容 - sb.append("[").append(transcription.getSpeakerName()).append("] "); - sb.append(transcription.getContent()).append("\n"); - } - - return ResultDomain.success("查询成功", sb.toString()); - } - - @Override - public ResultDomain deleteTranscription(String transcriptionId) { - logger.info("删除转录记录: transcriptionId={}", transcriptionId); - - TbMeetingTranscriptionDTO existing = meetingTranscriptionMapper.selectMeetingTranscriptionById(transcriptionId); - if (existing == null) { - return ResultDomain.failure("转录记录不存在"); - } - - TbMeetingTranscriptionDTO transcription = new TbMeetingTranscriptionDTO(); - transcription.setTranscriptionId(transcriptionId); - int rows = meetingTranscriptionMapper.deleteMeetingTranscription(transcription); - if (rows > 0) { - return ResultDomain.success("删除成功", true); - } - return ResultDomain.failure("删除失败"); - } - - // ========================= 会议统计 ========================== - - @Override - public ResultDomain getMeetingStatistics(String meetingId) { - logger.info("获取会议统计: meetingId={}", meetingId); - - TbVideoMeetingDTO meeting = videoMeetingMapper.selectVideoMeetingById(meetingId); - if (meeting == null) { - return ResultDomain.failure("会议不存在"); - } - - // TODO: 查询并组装统计信息 - // - 参与人数 - // - 会议时长 - // - 转录记录数 - // - 各参与者参与时长等 - - TbMeetingParticipantDTO participantFilter = new TbMeetingParticipantDTO(); - participantFilter.setMeetingId(meetingId); - List participants = meetingParticipantMapper.selectMeetingParticipantList(participantFilter); - - VideoMeetingVO vo = new VideoMeetingVO(); - vo.setMeetingId(meeting.getMeetingId()); - vo.setMeetingName(meeting.getMeetingName()); - vo.setStatus(meeting.getStatus()); - vo.setParticipantCount(participants.size()); - vo.setActualStartTime(meeting.getActualStartTime()); - vo.setActualEndTime(meeting.getActualEndTime()); - vo.setDurationSeconds(meeting.getDurationSeconds()); - - // TODO: 格式化时长 - // if (meeting.getDurationSeconds() != null) { - // vo.setDurationFormatted(formatDuration(meeting.getDurationSeconds())); - // } - - return ResultDomain.success("查询成功", vo); - } -} diff --git a/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/service/VideoMeetingServiceImpl.java b/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/service/VideoMeetingServiceImpl.java index 39860acd..20793b9f 100644 --- a/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/service/VideoMeetingServiceImpl.java +++ b/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/service/VideoMeetingServiceImpl.java @@ -1,5 +1,6 @@ package org.xyzh.workcase.service; +import com.alibaba.fastjson2.JSONObject; import org.apache.dubbo.config.annotation.DubboService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -7,19 +8,25 @@ import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional; import org.xyzh.api.workcase.dto.TbChatRoomMemberDTO; +import org.xyzh.api.workcase.dto.TbChatRoomMessageDTO; import org.xyzh.api.workcase.dto.TbVideoMeetingDTO; +import org.xyzh.api.workcase.service.ChatRoomService; import org.xyzh.api.workcase.service.JitsiTokenService; import org.xyzh.api.workcase.service.VideoMeetingService; import org.xyzh.api.workcase.vo.ChatMemberVO; import org.xyzh.api.workcase.vo.VideoMeetingVO; import org.xyzh.common.auth.utils.LoginUtil; +import org.xyzh.common.core.domain.LoginDomain; import org.xyzh.common.core.domain.ResultDomain; import org.xyzh.common.utils.id.IdUtil; import org.xyzh.workcase.mapper.TbChatRoomMemberMapper; import org.xyzh.workcase.mapper.TbVideoMeetingMapper; +import java.util.Calendar; import java.util.Date; import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; /** * @description 视频会议服务实现类 @@ -41,13 +48,21 @@ public class VideoMeetingServiceImpl implements VideoMeetingService { @Autowired private JitsiTokenService jitsiTokenService; + @Autowired + private ChatRoomService chatRoomService; + + // 会议创建锁映射表:每个meetingId对应一个ReentrantLock + private final ConcurrentHashMap meetingLocks = new ConcurrentHashMap<>(); + @Override @Transactional public ResultDomain createMeeting(TbVideoMeetingDTO meetingDTO) { // 获取当前用户ID - String userId = LoginUtil.getCurrentUserId(); - logger.info("创建视频会议: roomId={}, workcaseId={}, userId={}", - meetingDTO.getRoomId(), meetingDTO.getWorkcaseId(), userId); + LoginDomain loginDomain = LoginUtil.getCurrentLogin(); + String userId = loginDomain.getUser().getUserId(); + logger.info("创建视频会议预约: roomId={}, workcaseId={}, userId={}, startTime={}, endTime={}", + meetingDTO.getRoomId(), meetingDTO.getWorkcaseId(), userId, + meetingDTO.getStartTime(), meetingDTO.getEndTime()); try { // 1. 验证用户是否为聊天室成员 @@ -57,18 +72,25 @@ public class VideoMeetingServiceImpl implements VideoMeetingService { return ResultDomain.failure("您不是聊天室成员,无法创建会议"); } - // 2. 检查聊天室是否已有进行中的会议 - TbVideoMeetingDTO existingMeetingFilter = new TbVideoMeetingDTO(); - existingMeetingFilter.setRoomId(meetingDTO.getRoomId()); - existingMeetingFilter.setStatus("ongoing"); - List existingMeetings = videoMeetingMapper.selectVideoMeetingList(existingMeetingFilter); + // 2. 检查聊天室是否已有时间冲突的会议 + TbVideoMeetingDTO conflictFilter = new TbVideoMeetingDTO(); + conflictFilter.setRoomId(meetingDTO.getRoomId()); + conflictFilter.setStatus("scheduled"); // 只检查已安排的会议 + List existingMeetings = videoMeetingMapper.selectVideoMeetingList(conflictFilter); if (existingMeetings != null && !existingMeetings.isEmpty()) { - logger.warn("聊天室已有进行中的会议: roomId={}", meetingDTO.getRoomId()); - return ResultDomain.failure("聊天室已有进行中的会议,请稍后再试"); + for (VideoMeetingVO existing : existingMeetings) { + // 检查时间是否冲突 + if (isTimeConflict(meetingDTO.getStartTime(), meetingDTO.getEndTime(), + existing.getStartTime(), existing.getEndTime())) { + logger.warn("会议时间冲突: roomId={}, existingMeetingId={}", + meetingDTO.getRoomId(), existing.getMeetingId()); + return ResultDomain.failure("该时间段已有会议安排,请选择其他时间"); + } + } } - // 3. 生成会议ID和房间名 + // 3. 生成会议ID和房间名(房间名暂时生成,真正创建Jitsi时会重新生成) String meetingId = IdUtil.generateUUID(); String jitsiRoomName = jitsiTokenService.generateRoomName(meetingDTO.getWorkcaseId()); @@ -78,32 +100,21 @@ public class VideoMeetingServiceImpl implements VideoMeetingService { memberFilter.setUserId(userId); List members = chatRoomMemberMapper.selectChatRoomMemberList(memberFilter); - String userName = "用户"; - String userType = "guest"; + String userName = loginDomain.getUserInfo().getUsername(); + String userType = "guest".equals(loginDomain.getUser().getStatus())?"guest":"user"; if (members != null && !members.isEmpty()) { ChatMemberVO member = members.get(0); userName = member.getUserName(); userType = member.getUserType(); } - // 5. 生成创建者的JWT Token(创建者默认为主持人) - String jwtToken = jitsiTokenService.generateJwtToken( - jitsiRoomName, - userId, - userName, - true // 创建者为主持人 - ); - - // 6. 构建iframe URL - String iframeUrl = jitsiTokenService.buildIframeUrl(jitsiRoomName, jwtToken, meetingDTO.getConfig()); - - // 7. 填充会议信息 + // 5. 填充会议预约信息(不生成JWT Token和iframe URL) meetingDTO.setMeetingId(meetingId); meetingDTO.setJitsiRoomName(jitsiRoomName); - meetingDTO.setJwtToken(jwtToken); // 存储创建者的token(可选) - meetingDTO.setIframeUrl(iframeUrl); - meetingDTO.setStatus("scheduled"); - meetingDTO.setCreatorId(userId); + meetingDTO.setJwtToken(null); // 预约阶段不生成token + meetingDTO.setIframeUrl(null); // 预约阶段不生成URL + meetingDTO.setStatus("scheduled"); // 状态为已安排 + meetingDTO.setCreator(userId); meetingDTO.setCreatorType(userType); meetingDTO.setCreatorName(userName); meetingDTO.setParticipantCount(0); @@ -113,24 +124,31 @@ public class VideoMeetingServiceImpl implements VideoMeetingService { meetingDTO.setMaxParticipants(10); } - // 8. 插入数据库 + if (meetingDTO.getAdvance() == null) { + meetingDTO.setAdvance(5); // 默认提前5分钟可入会 + } + + // 6. 插入数据库 int rows = videoMeetingMapper.insertVideoMeeting(meetingDTO); if (rows > 0) { - logger.info("视频会议创建成功: meetingId={}, jitsiRoomName={}", - meetingId, jitsiRoomName); + logger.info("视频会议预约创建成功: meetingId={}, jitsiRoomName={}, startTime={}, endTime={}", + meetingId, jitsiRoomName, meetingDTO.getStartTime(), meetingDTO.getEndTime()); - // 9. 返回VO + // 7. 发送会议通知消息到聊天室(内容为meetingId而非URL) + sendMeetingNotification(meetingDTO, userName); + + // 8. 返回VO VideoMeetingVO meetingVO = new VideoMeetingVO(); BeanUtils.copyProperties(meetingDTO, meetingVO); - return ResultDomain.success("创建会议成功", meetingVO); + return ResultDomain.success("创建会议预约成功", meetingVO); } else { logger.error("插入会议记录失败: meetingId={}", meetingId); - return ResultDomain.failure("创建会议失败"); + return ResultDomain.failure("创建会议预约失败"); } } catch (Exception e) { - logger.error("创建视频会议异常: roomId={}, error={}", + logger.error("创建视频会议预约异常: roomId={}, error={}", meetingDTO.getRoomId(), e.getMessage(), e); - return ResultDomain.failure("创建会议失败: " + e.getMessage()); + return ResultDomain.failure("创建会议预约失败: " + e.getMessage()); } } @@ -195,6 +213,7 @@ public class VideoMeetingServiceImpl implements VideoMeetingService { } @Override + @Transactional public ResultDomain generateUserMeetingUrl(String meetingId, String userId) { logger.info("生成用户专属会议URL: meetingId={}, userId={}", meetingId, userId); @@ -218,23 +237,96 @@ public class VideoMeetingServiceImpl implements VideoMeetingService { return ResultDomain.failure("您无权访问此会议"); } - // 3. 获取用户信息 + // 3. 检查会议时间窗口(仅对scheduled状态的会议检查) + if ("scheduled".equals(meeting.getStatus())) { + Date now = new Date(); + + // 计算提前入会时间点(开始时间 - advance 分钟) + Calendar calendar = Calendar.getInstance(); + calendar.setTime(meeting.getStartTime()); + calendar.add(Calendar.MINUTE, -meeting.getAdvance()); + Date advanceTime = calendar.getTime(); + + // 检查当前时间是否在允许入会的时间窗口内 + if (now.before(advanceTime)) { + logger.warn("会议未到入会时间: meetingId={}, 当前时间={}, 提前入会时间={}", + meetingId, now, advanceTime); + return ResultDomain.failure("会议未到入会时间,请在 " + advanceTime + " 之后加入"); + } + + if (now.after(meeting.getEndTime())) { + logger.warn("会议已结束: meetingId={}, 当前时间={}, 结束时间={}", + meetingId, now, meeting.getEndTime()); + return ResultDomain.failure("会议已结束"); + } + + // 4. 使用ReentrantLock进行双检锁:首次用户入会时创建Jitsi会议室 + // 获取或创建该会议的锁对象 + ReentrantLock lock = meetingLocks.computeIfAbsent(meetingId, k -> new ReentrantLock()); + + logger.info("尝试获取会议创建锁: meetingId={}", meetingId); + lock.lock(); // 阻塞等待获取锁 + try { + logger.info("成功获取会议创建锁: meetingId={}, userId={}", meetingId, userId); + + // 双重检查:再次查询数据库确认会议状态(防止其他线程已创建) + List recheck = videoMeetingMapper.selectVideoMeetingList(filter); + if (recheck != null && !recheck.isEmpty()) { + VideoMeetingVO recheckMeeting = recheck.get(0); + + if ("scheduled".equals(recheckMeeting.getStatus())) { + logger.info("首次创建Jitsi会议室: meetingId={}", meetingId); + + // 更新会议状态为进行中 + TbVideoMeetingDTO updateDTO = new TbVideoMeetingDTO(); + updateDTO.setMeetingId(meetingId); + updateDTO.setStatus("ongoing"); + updateDTO.setActualStartTime(new Date()); + + int rows = videoMeetingMapper.updateVideoMeeting(updateDTO); + if (rows > 0) { + logger.info("Jitsi会议室创建成功,会议状态已更新: meetingId={}", meetingId); + meeting.setStatus("ongoing"); + meeting.setActualStartTime(new Date()); + } else { + logger.error("更新会议状态失败: meetingId={}", meetingId); + return ResultDomain.failure("创建会议失败"); + } + } else { + logger.info("会议已被其他用户创建: meetingId={}, status={}", + meetingId, recheckMeeting.getStatus()); + meeting.setStatus(recheckMeeting.getStatus()); + meeting.setActualStartTime(recheckMeeting.getActualStartTime()); + } + } + } finally { + lock.unlock(); + logger.info("释放会议创建锁: meetingId={}", meetingId); + + // 清理锁对象:如果没有其他线程在等待,则移除锁 + if (!lock.hasQueuedThreads()) { + meetingLocks.remove(meetingId); + logger.debug("清理会议锁对象: meetingId={}", meetingId); + } + } + } + + // 5. 获取用户信息 TbChatRoomMemberDTO memberFilter = new TbChatRoomMemberDTO(); memberFilter.setRoomId(meeting.getRoomId()); memberFilter.setUserId(userId); List members = chatRoomMemberMapper.selectChatRoomMemberList(memberFilter); String userName = "用户"; - boolean isModerator = false; + // 会议创建人才是主持人 + boolean isModerator = userId.equals(meeting.getCreator()); if (members != null && !members.isEmpty()) { ChatMemberVO member = members.get(0); userName = member.getUserName(); - // 客服人员设为主持人 - isModerator = "agent".equals(member.getUserType()); } - // 4. 生成用户专属JWT Token + // 6. 生成用户专属JWT Token String userJwtToken = jitsiTokenService.generateJwtToken( meeting.getJitsiRoomName(), userId, @@ -242,18 +334,19 @@ public class VideoMeetingServiceImpl implements VideoMeetingService { isModerator ); - // 5. 构建用户专属iframe URL + // 7. 构建用户专属iframe URL String userIframeUrl = jitsiTokenService.buildIframeUrl( meeting.getJitsiRoomName(), userJwtToken, meeting.getConfig() ); - // 6. 更新VO + // 8. 更新VO meeting.setJwtToken(userJwtToken); meeting.setIframeUrl(userIframeUrl); - logger.info("生成用户专属会议URL成功: meetingId={}, userId={}", meetingId, userId); + logger.info("生成用户专属会议URL成功: meetingId={}, userId={}, status={}", + meetingId, userId, meeting.getStatus()); return ResultDomain.success("生成用户专属会议URL成功", meeting); } catch (Exception e) { logger.error("生成用户专属会议URL异常: meetingId={}, error={}", meetingId, e.getMessage(), e); @@ -383,4 +476,62 @@ public class VideoMeetingServiceImpl implements VideoMeetingService { return false; } } + + /** + * 发送会议通知消息到聊天室 + * @param meetingDTO 会议信息 + * @param creatorName 创建者名称 + */ + private void sendMeetingNotification(TbVideoMeetingDTO meetingDTO, String creatorName) { + logger.info("发送会议通知消息: roomId={}, meetingId={}", + meetingDTO.getRoomId(), meetingDTO.getMeetingId()); + + // 构建消息内容 + TbChatRoomMessageDTO message = new TbChatRoomMessageDTO(); + message.setMessageId(IdUtil.generateUUID()); + message.setRoomId(meetingDTO.getRoomId()); + message.setSenderId(meetingDTO.getCreator()); + message.setSenderType(meetingDTO.getCreatorType()); + message.setSenderName(creatorName); + message.setMessageType("meet"); // 会议类型消息 + message.setContent(meetingDTO.getMeetingId()); // 会议ID作为内容(前端根据ID查询会议详情) + message.setStatus("sent"); + message.setReadCount(0); + message.setSendTime(new Date()); + + // 构建扩展信息(会议详情) + JSONObject contentExtra = new JSONObject(); + contentExtra.put("meetingId", meetingDTO.getMeetingId()); + contentExtra.put("meetingName", meetingDTO.getMeetingName()); + contentExtra.put("jitsiRoomName", meetingDTO.getJitsiRoomName()); + contentExtra.put("startTime", meetingDTO.getStartTime()); + contentExtra.put("endTime", meetingDTO.getEndTime()); + contentExtra.put("advance", meetingDTO.getAdvance()); + contentExtra.put("maxParticipants", meetingDTO.getMaxParticipants()); + contentExtra.put("creatorName", creatorName); + contentExtra.put("workcaseId", meetingDTO.getWorkcaseId()); + contentExtra.put("status", meetingDTO.getStatus()); + message.setContentExtra(contentExtra); + + // 发送消息 + ResultDomain sendResult = chatRoomService.sendMessage(message); + if (sendResult.getSuccess()) { + logger.info("会议通知消息发送成功: messageId={}", message.getMessageId()); + } else { + logger.warn("会议通知消息发送失败: {}", sendResult.getMessage()); + } + } + + /** + * 检查时间是否冲突 + * @param start1 时间段1开始时间 + * @param end1 时间段1结束时间 + * @param start2 时间段2开始时间 + * @param end2 时间段2结束时间 + * @return 是否冲突 + */ + private boolean isTimeConflict(Date start1, Date end1, Date start2, Date end2) { + // 时间段1的结束时间 > 时间段2的开始时间 AND 时间段1的开始时间 < 时间段2的结束时间 + return end1.after(start2) && start1.before(end2); + } } diff --git a/urbanLifelineServ/workcase/src/main/resources/application-dev.yml b/urbanLifelineServ/workcase/src/main/resources/application-dev.yml index 8e568254..3768f88f 100644 --- a/urbanLifelineServ/workcase/src/main/resources/application-dev.yml +++ b/urbanLifelineServ/workcase/src/main/resources/application-dev.yml @@ -92,4 +92,20 @@ logging: console: UTF-8 file: UTF-8 level: - org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping: TRACE \ No newline at end of file + org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping: TRACE + +# ================== Jitsi Meet 视频会议配置 ================== +jitsi: + app: + # 应用ID(必须与Docker配置中的JWT_APP_ID一致) + id: urbanLifeline + # JWT密钥(必须与Docker配置中的JWT_APP_SECRET一致) + # 警告:生产环境请修改为强随机字符串,并妥善保管 + # 注意:HS256算法要求密钥长度至少32字节(256 bits) + secret: urbanLifeline-jitsi-secret-key-2025-production-safe-hs256 + server: + # Jitsi Meet服务器地址(Docker部署在本地8280端口) + url: http://192.168.0.253:8280 + token: + # JWT Token有效期(毫秒)- 默认2小时 + expiration: 7200000 \ No newline at end of file diff --git a/urbanLifelineServ/workcase/src/main/resources/mapper/TbChatMessageMapper.xml b/urbanLifelineServ/workcase/src/main/resources/mapper/TbChatMessageMapper.xml index 8e91cd1a..dd2ced13 100644 --- a/urbanLifelineServ/workcase/src/main/resources/mapper/TbChatMessageMapper.xml +++ b/urbanLifelineServ/workcase/src/main/resources/mapper/TbChatMessageMapper.xml @@ -12,7 +12,7 @@ - + @@ -34,7 +34,7 @@ - + @@ -66,7 +66,7 @@ #{optsn}, #{messageId}, #{roomId}, #{senderId}, #{senderType}, #{senderName}, #{content}, #{creator} , #{messageType} , #{files, typeHandler=org.xyzh.common.jdbc.handler.StringArrayTypeHandler} - , #{contentExtra, typeHandler=com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler} + , #{contentExtra, typeHandler=org.xyzh.common.jdbc.handler.FastJson2TypeHandler}::jsonb , #{replyToMsgId} , #{isAiMessage} , #{aiMessageId} diff --git a/urbanLifelineServ/workcase/src/main/resources/mapper/TbVideoMeetingMapper.xml b/urbanLifelineServ/workcase/src/main/resources/mapper/TbVideoMeetingMapper.xml index 4861b0b8..1d3a6f72 100644 --- a/urbanLifelineServ/workcase/src/main/resources/mapper/TbVideoMeetingMapper.xml +++ b/urbanLifelineServ/workcase/src/main/resources/mapper/TbVideoMeetingMapper.xml @@ -9,21 +9,24 @@ + - + - - + + + + + - @@ -37,17 +40,20 @@ + - - - + + + + + @@ -59,29 +65,38 @@ - meeting_id, optsn, room_id, workcase_id, meeting_name, meeting_password, jwt_token, - jitsi_room_name, jitsi_server_url, status, creator_id, creator_type, creator_name, - participant_count, max_participants, start_time, end_time, duration_seconds, iframe_url, + meeting_id, optsn, room_id, workcase_id, meeting_name, meeting_password, description, jwt_token, + jitsi_room_name, jitsi_server_url, status, creator_type, creator_name, + participant_count, max_participants, start_time, end_time, advance, + actual_start_time, actual_end_time, duration_seconds, iframe_url, config, creator, create_time, update_time, delete_time, deleted INSERT INTO workcase.tb_video_meeting ( - optsn, meeting_id, room_id, workcase_id, meeting_name, jitsi_room_name, creator_id, creator_type, creator_name, creator + optsn, meeting_id, room_id, workcase_id, meeting_name, jitsi_room_name, creator_type, creator_name, creator , meeting_password + , description , jwt_token , jitsi_server_url , status , max_participants + , start_time + , end_time + , advance , iframe_url , config ) VALUES ( - #{optsn}, #{meetingId}, #{roomId}, #{workcaseId}, #{meetingName}, #{jitsiRoomName}, #{creatorId}, #{creatorType}, #{creatorName}, #{creator} + #{optsn}, #{meetingId}, #{roomId}, #{workcaseId}, #{meetingName}, #{jitsiRoomName}, #{creatorType}, #{creatorName}, #{creator} , #{meetingPassword} + , #{description} , #{jwtToken} , #{jitsiServerUrl} , #{status} , #{maxParticipants} + , #{startTime} + , #{endTime} + , #{advance} , #{iframeUrl} , #{config, typeHandler=com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler} ) @@ -92,11 +107,15 @@ meeting_name = #{meetingName}, meeting_password = #{meetingPassword}, + description = #{description}, jwt_token = #{jwtToken}, status = #{status}, participant_count = #{participantCount}, - start_time = #{actualStartTime}, - end_time = #{actualEndTime}, + start_time = #{startTime}, + end_time = #{endTime}, + advance = #{advance}, + actual_start_time = #{actualStartTime}, + actual_end_time = #{actualEndTime}, duration_seconds = #{durationSeconds}, iframe_url = #{iframeUrl}, config = #{config, typeHandler=com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler}, @@ -126,7 +145,7 @@ AND workcase_id = #{filter.workcaseId} AND meeting_name LIKE CONCAT('%', #{filter.meetingName}, '%') AND status = #{filter.status} - AND creator_id = #{filter.creatorId} + AND creator = #{filter.creator} AND creator_type = #{filter.creatorType} AND deleted = false @@ -142,7 +161,7 @@ AND workcase_id = #{filter.workcaseId} AND meeting_name LIKE CONCAT('%', #{filter.meetingName}, '%') AND status = #{filter.status} - AND creator_id = #{filter.creatorId} + AND creator = #{filter.creator} AND creator_type = #{filter.creatorType} AND deleted = false @@ -159,7 +178,7 @@ AND workcase_id = #{filter.workcaseId} AND meeting_name LIKE CONCAT('%', #{filter.meetingName}, '%') AND status = #{filter.status} - AND creator_id = #{filter.creatorId} + AND creator = #{filter.creator} AND creator_type = #{filter.creatorType} AND deleted = false diff --git a/urbanLifelineServ/workcase/工单+Jitsi Meet技术方案.md b/urbanLifelineServ/workcase/工单+Jitsi Meet技术方案.md index f9ba0f82..7c558ae2 100644 --- a/urbanLifelineServ/workcase/工单+Jitsi Meet技术方案.md +++ b/urbanLifelineServ/workcase/工单+Jitsi Meet技术方案.md @@ -154,7 +154,6 @@ jwt_token -- JWT Token(身份验证) jitsi_room_name -- Jitsi房间名(格式:workcase_{workcase_id}_{timestamp}) jitsi_server_url -- Jitsi服务器地址(默认:https://meet.jit.si) status -- 状态:scheduled ongoing ended cancelled -creator_id -- 创建者ID creator_type -- 创建者类型:guest-来客 agent-客服 creator_name -- 创建者名称 participant_count -- 参与人数 diff --git a/urbanLifelineWeb/packages/workcase/src/api/workcase/meeting.ts b/urbanLifelineWeb/packages/workcase/src/api/workcase/meeting.ts deleted file mode 100644 index c207277d..00000000 --- a/urbanLifelineWeb/packages/workcase/src/api/workcase/meeting.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { http } from '@/utils/http' - -/** - * 创建视频会议参数 - */ -export interface CreateMeetingParams { - roomId: string - workcaseId?: string - meetingName: string - maxParticipants?: number -} - -/** - * 视频会议VO - */ -export interface VideoMeetingVO { - meetingId: string - roomId: string - workcaseId?: string - meetingName: string - meetingPassword?: string - jwtToken: string - jitsiRoomName: string - jitsiServerUrl: string - status: string - creatorId: string - creatorType: string - creatorName: string - participantCount: number - maxParticipants: number - actualStartTime?: string - actualEndTime?: string - durationSeconds?: number - durationFormatted?: string - iframeUrl: string - config?: any -} - -/** - * 创建视频会议 - */ -export const createVideoMeeting = (params: CreateMeetingParams) => { - return http.post('/workcase/chat/meeting/create', params) -} - -/** - * 获取会议信息 - */ -export const getMeetingInfo = (meetingId: string) => { - return http.get(`/workcase/chat/meeting/${meetingId}`) -} - -/** - * 获取聊天室活跃会议 - */ -export const getActiveMeeting = (roomId: string) => { - return http.get(`/workcase/chat/meeting/room/${roomId}/active`) -} - -/** - * 加入会议(生成用户专属JWT) - */ -export const joinMeeting = (meetingId: string) => { - return http.post(`/workcase/chat/meeting/${meetingId}/join`) -} - -/** - * 开始会议 - */ -export const startVideoMeeting = (meetingId: string) => { - return http.post(`/workcase/chat/meeting/${meetingId}/start`) -} - -/** - * 结束会议 - */ -export const endVideoMeeting = (meetingId: string) => { - return http.post(`/workcase/chat/meeting/${meetingId}/end`) -} diff --git a/urbanLifelineWeb/packages/workcase/src/api/workcase/workcaseChat.ts b/urbanLifelineWeb/packages/workcase/src/api/workcase/workcaseChat.ts index 3947a02c..7c358bf3 100644 --- a/urbanLifelineWeb/packages/workcase/src/api/workcase/workcaseChat.ts +++ b/urbanLifelineWeb/packages/workcase/src/api/workcase/workcaseChat.ts @@ -6,10 +6,12 @@ import type { TbChatRoomMessageDTO, TbCustomerServiceDTO, TbWordCloudDTO, + TbVideoMeetingDTO, ChatRoomVO, ChatMemberVO, ChatRoomMessageVO, - CustomerServiceVO + CustomerServiceVO, + VideoMeetingVO } from '@/types/workcase' /** @@ -220,5 +222,55 @@ export const workcaseChatAPI = { async getWordCloudPage(pageRequest: PageRequest): Promise> { const response = await api.post(`${this.baseUrl}/wordcloud/page`, pageRequest) return response.data + }, + + // ====================== 视频会议管理(Jitsi Meet) ====================== + + /** + * 创建视频会议 + */ + async createVideoMeeting(meeting: TbVideoMeetingDTO): Promise> { + const response = await api.post(`${this.baseUrl}/meeting/create`, meeting) + return response.data + }, + + /** + * 获取会议信息 + */ + async getVideoMeetingInfo(meetingId: string): Promise> { + const response = await api.get(`${this.baseUrl}/meeting/${meetingId}`) + return response.data + }, + + /** + * 获取聊天室活跃会议 + */ + async getActiveMeeting(roomId: string): Promise> { + const response = await api.get(`${this.baseUrl}/meeting/room/${roomId}/active`) + return response.data + }, + + /** + * 加入会议(生成用户专属JWT) + */ + async joinVideoMeeting(meetingId: string): Promise> { + const response = await api.post(`${this.baseUrl}/meeting/${meetingId}/join`) + return response.data + }, + + /** + * 开始会议 + */ + async startVideoMeeting(meetingId: string): Promise> { + const response = await api.post(`${this.baseUrl}/meeting/${meetingId}/start`) + return response.data + }, + + /** + * 结束会议 + */ + async endVideoMeeting(meetingId: string): Promise> { + const response = await api.post(`${this.baseUrl}/meeting/${meetingId}/end`) + return response.data } } diff --git a/urbanLifelineWeb/packages/workcase/src/components/chatRoom/index.ts b/urbanLifelineWeb/packages/workcase/src/components/chatRoom/index.ts deleted file mode 100644 index cf0eb282..00000000 --- a/urbanLifelineWeb/packages/workcase/src/components/chatRoom/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as ChatRoom } from './chatRoom/ChatRoom.vue'; \ No newline at end of file diff --git a/urbanLifelineWeb/packages/workcase/src/components/index.ts b/urbanLifelineWeb/packages/workcase/src/components/index.ts index 766cd7ca..e69de29b 100644 --- a/urbanLifelineWeb/packages/workcase/src/components/index.ts +++ b/urbanLifelineWeb/packages/workcase/src/components/index.ts @@ -1 +0,0 @@ -export * from './chatRoom' \ No newline at end of file diff --git a/urbanLifelineWeb/packages/workcase/src/types/workcase/chatRoom.ts b/urbanLifelineWeb/packages/workcase/src/types/workcase/chatRoom.ts index 6d179a76..07fcee7e 100644 --- a/urbanLifelineWeb/packages/workcase/src/types/workcase/chatRoom.ts +++ b/urbanLifelineWeb/packages/workcase/src/types/workcase/chatRoom.ts @@ -71,6 +71,7 @@ export interface TbVideoMeetingDTO extends BaseDTO { workcaseId?: string meetingName?: string meetingPassword?: string + description?: string jwtToken?: string jitsiRoomName?: string jitsiServerUrl?: string @@ -80,6 +81,12 @@ export interface TbVideoMeetingDTO extends BaseDTO { creatorName?: string participantCount?: number maxParticipants?: number + /** 预定开始时间 */ + startTime?: string + /** 预定结束时间 */ + endTime?: string + /** 提前入会时间(分钟) */ + advance?: number actualStartTime?: string actualEndTime?: string durationSeconds?: number @@ -223,17 +230,23 @@ export interface VideoMeetingVO extends BaseVO { workcaseId?: string meetingName?: string meetingPassword?: string + description?: string jwtToken?: string jitsiRoomName?: string jitsiServerUrl?: string status?: string - creatorId?: string creatorType?: string creatorName?: string participantCount?: number maxParticipants?: number - startTime?: string + // 预定开始时间 + startTime?: string + // 预定结束时间 endTime?: string + // 提前入会时间(分钟) + advance?: number + actualStartTime?: string + actualEndTime?: string durationSeconds?: number durationFormatted?: string iframeUrl?: string @@ -279,6 +292,9 @@ export interface SendMessageParam { export interface CreateMeetingParam { roomId: string workcaseId: string + startTime: string + endTime: string + advance?: number meetingName?: string meetingPassword?: string maxParticipants?: number diff --git a/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/ChatRoomView.vue b/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/ChatRoomView.vue index b57dc7cc..51be90d6 100644 --- a/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/ChatRoomView.vue +++ b/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/ChatRoomView.vue @@ -89,6 +89,8 @@ ref="chatRoomRef" :messages="messages" :current-user-id="loginDomain.user.userId" + :room-id="currentRoomId" + :workcase-id="currentWorkcaseId" :room-name="currentRoom?.roomName" :meeting-url="currentMeetingUrl" :show-meeting="showMeetingIframe" @@ -163,7 +165,7 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue' import { ElButton, ElInput, ElDialog, ElMessage } from 'element-plus' import { Search, FileText, MessageSquare, MessageCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next' -import { ChatRoom } from '@/components/chatRoom' +import ChatRoom from './chatRoom/ChatRoom.vue' import WorkcaseDetail from '@/views/public/workcase/WorkcaseDetail/WorkcaseDetail.vue' import { workcaseChatAPI } from '@/api/workcase' import { fileAPI } from 'shared/api/file' @@ -248,6 +250,7 @@ const showWorkcaseCreator = ref(false) // Jitsi Meet会议相关 const currentMeetingUrl = ref('') const showMeetingIframe = ref(false) +const currentMeetingId = ref(null) // ChatRoom组件引用 const chatRoomRef = ref | null>(null) @@ -512,10 +515,50 @@ const onWorkcaseCreated = (workcaseId: string) => { const startMeeting = async () => { if (!currentRoomId.value) return - // TODO: 调用后端API创建Jitsi会议 - const meetingId = 'meeting-' + currentRoomId.value + '-' + Date.now() - currentMeetingUrl.value = `https://meet.jit.si/${meetingId}` - showMeetingIframe.value = true + try { + // 先检查是否有活跃会议 + const activeResult = await workcaseChatAPI.getActiveMeeting(currentRoomId.value) + if (activeResult.success && activeResult.data) { + // 已有活跃会议,直接加入 + currentMeetingId.value = activeResult.data.meetingId! + const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId.value!) + if (joinResult.success && joinResult.data?.iframeUrl) { + currentMeetingUrl.value = joinResult.data.iframeUrl + showMeetingIframe.value = true + } else { + ElMessage.error(joinResult.message || '加入会议失败') + } + return + } + + // 没有活跃会议,创建新会议 + const createResult = await workcaseChatAPI.createVideoMeeting({ + roomId: currentRoomId.value, + meetingName: currentRoom.value?.roomName || '视频会议' + }) + + if (createResult.success && createResult.data) { + currentMeetingId.value = createResult.data.meetingId! + + // 开始会议 + await workcaseChatAPI.startVideoMeeting(currentMeetingId.value!) + + // 加入会议获取iframe URL + const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId.value!) + if (joinResult.success && joinResult.data?.iframeUrl) { + currentMeetingUrl.value = joinResult.data.iframeUrl + showMeetingIframe.value = true + ElMessage.success('会议已创建') + } else { + ElMessage.error(joinResult.message || '获取会议链接失败') + } + } else { + ElMessage.error(createResult.message || '创建会议失败') + } + } catch (error) { + console.error('发起会议失败:', error) + ElMessage.error('发起会议失败') + } } // 滚动聊天消息到底部 diff --git a/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/MeetingCard/MeetingCard.scss b/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/MeetingCard/MeetingCard.scss new file mode 100644 index 00000000..9554ea19 --- /dev/null +++ b/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/MeetingCard/MeetingCard.scss @@ -0,0 +1,100 @@ +.meeting-card { + padding: 12px 16px; + border: 1px solid #e4e7ed; + border-radius: 8px; + background-color: #fff; + transition: all 0.3s; + + &:hover { + box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); + } + + &--scheduled { + border-left: 4px solid #409eff; + } + + &--ongoing { + border-left: 4px solid #67c23a; + } + + &--ended { + border-left: 4px solid #909399; + background-color: #f5f7fa; + } + + .meeting-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + + .meeting-card-title { + font-size: 16px; + font-weight: 600; + color: #303133; + flex: 1; + } + + .meeting-card-status { + .status-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + } + + .status-scheduled { + background-color: #ecf5ff; + color: #409eff; + } + + .status-ongoing { + background-color: #f0f9ff; + color: #67c23a; + } + + .status-ended { + background-color: #f4f4f5; + color: #909399; + } + } + } + + .meeting-card-time { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 12px; + font-size: 14px; + color: #606266; + + div { + line-height: 1.5; + } + } + + .meeting-card-content { + margin-bottom: 12px; + + .meeting-card-desc { + font-size: 14px; + color: #909399; + line-height: 1.5; + } + } + + .meeting-card-action { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 12px; + border-top: 1px solid #ebeef5; + + .meeting-card-countdown { + font-size: 14px; + color: #409eff; + font-weight: 500; + } + } +} diff --git a/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/MeetingCard/MeetingCard.vue b/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/MeetingCard/MeetingCard.vue new file mode 100644 index 00000000..05bd99e7 --- /dev/null +++ b/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/MeetingCard/MeetingCard.vue @@ -0,0 +1,195 @@ + + + + + {{ meeting.meetingName }} + + 预定 + 进行中 + 已结束 + + + + 开始时间:{{ formatDateTime(meeting.startTime) }} + 结束时间:{{ formatDateTime(meeting.endTime) }} + 提前入会:{{ meeting.advance }}分钟 + + + {{ meeting.description }} + + + {{ countdownText }} + + {{ buttonText }} + + + + + + + \ No newline at end of file diff --git a/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/MeetingCreate/MeetingCreate.vue b/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/MeetingCreate/MeetingCreate.vue new file mode 100644 index 00000000..ac9102a3 --- /dev/null +++ b/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/MeetingCreate/MeetingCreate.vue @@ -0,0 +1,239 @@ + + + + + + + + + + + + + + + + + + 用户可在会议开始前N分钟加入 + + + + + + + + + + + + + 取消 + + 创建会议 + + + + + + + + diff --git a/urbanLifelineWeb/packages/workcase/src/components/chatRoom/chatRoom/ChatRoom.scss b/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/chatRoom/ChatRoom.scss similarity index 78% rename from urbanLifelineWeb/packages/workcase/src/components/chatRoom/chatRoom/ChatRoom.scss rename to urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/chatRoom/ChatRoom.scss index 7b761249..6673bd2d 100644 --- a/urbanLifelineWeb/packages/workcase/src/components/chatRoom/chatRoom/ChatRoom.scss +++ b/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/chatRoom/ChatRoom.scss @@ -469,3 +469,136 @@ $brand-color-hover: #004488; line-height: 1.6; } } + + +// ==================== 视频会议弹窗 ==================== +.meeting-modal-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); +} + +.meeting-modal { + width: 90vw; + max-width: 1200px; + height: 80vh; + background: #fff; + border-radius: 16px; + overflow: hidden; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; + animation: modalFadeIn 0.3s ease; +} + +@keyframes modalFadeIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.meeting-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + flex-shrink: 0; +} + +.meeting-modal-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 16px; + font-weight: 600; +} + +.meeting-modal-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.minimize-btn, +.meeting-modal .close-meeting-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: rgba(255, 255, 255, 0.2); + border: none; + border-radius: 8px; + color: white; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: rgba(255, 255, 255, 0.3); + } +} + +.meeting-modal .close-meeting-btn:hover { + background: rgba(239, 68, 68, 0.8); +} + +.meeting-modal-body { + flex: 1; + overflow: hidden; + + .meeting-iframe { + width: 100%; + height: 100%; + border: none; + } +} + +// ==================== 最小化悬浮按钮 ==================== +.meeting-float-btn { + position: fixed; + bottom: 24px; + right: 24px; + display: flex; + align-items: center; + gap: 8px; + padding: 12px 20px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-radius: 50px; + box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4); + cursor: pointer; + z-index: 999; + font-size: 14px; + font-weight: 500; + transition: all 0.3s; + animation: floatPulse 2s infinite; + + &:hover { + transform: scale(1.05); + box-shadow: 0 6px 24px rgba(102, 126, 234, 0.5); + } +} + +@keyframes floatPulse { + 0%, 100% { + box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4); + } + 50% { + box-shadow: 0 4px 30px rgba(102, 126, 234, 0.6); + } +} diff --git a/urbanLifelineWeb/packages/workcase/src/components/chatRoom/chatRoom/ChatRoom.vue b/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/chatRoom/ChatRoom.vue similarity index 61% rename from urbanLifelineWeb/packages/workcase/src/components/chatRoom/chatRoom/ChatRoom.vue rename to urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/chatRoom/ChatRoom.vue index 7f65cb91..ac308ce5 100644 --- a/urbanLifelineWeb/packages/workcase/src/components/chatRoom/chatRoom/ChatRoom.vue +++ b/urbanLifelineWeb/packages/workcase/src/views/public/ChatRoom/chatRoom/ChatRoom.vue @@ -15,18 +15,6 @@ 加载中... 没有更多消息了 - - - - 视频会议进行中 - - - 结束会议 - - - - - - - + + + + {{ formatTime(message.sendTime) }} + - - + + + - - - - - 附件 + class="message-text" + v-html="renderMarkdown(message.content || '')" + > + + + + + + + + + 附件 + - - {{ formatTime(message.sendTime) }} + {{ formatTime(message.sendTime) }} + @@ -132,15 +129,56 @@ + + + + + + + + + + + + 视频会议进行中 + + + + + + + + + + + + + + + + + + + + + 返回会议 + + + diff --git a/urbanLifelineWeb/packages/workcase_wechat/pages/meeting/meetingCard/MeetingCard.scss b/urbanLifelineWeb/packages/workcase_wechat/pages/meeting/meetingCard/MeetingCard.scss new file mode 100644 index 00000000..fcd152a1 --- /dev/null +++ b/urbanLifelineWeb/packages/workcase_wechat/pages/meeting/meetingCard/MeetingCard.scss @@ -0,0 +1,119 @@ +/* UniApp +/SCSSLW(Us7 */ + +.meeting-card { + padding: 24rpx 32rpx; + border: 2rpx solid #e4e7ed; + border-radius: 16rpx; + background-color: #fff; + width: 100%; + box-sizing: border-box; +} + +.meeting-card--scheduled { + border-left: 8rpx solid #409eff; +} + +.meeting-card--ongoing { + border-left: 8rpx solid #67c23a; +} + +.meeting-card--ended { + border-left: 8rpx solid #909399; + background-color: #f5f7fa; +} + +.meeting-card-header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + margin-bottom: 24rpx; +} + +.meeting-card-title { + font-size: 32rpx; + font-weight: 600; + color: #303133; + flex: 1; +} + +.meeting-card-status { + margin-left: 16rpx; +} + +.status-badge { + display: inline-block; + padding: 4rpx 16rpx; + border-radius: 8rpx; + font-size: 24rpx; + font-weight: 500; +} + +.status-scheduled { + background-color: #ecf5ff; + color: #409eff; +} + +.status-ongoing { + background-color: #f0f9ff; + color: #67c23a; +} + +.status-ended { + background-color: #f4f4f5; + color: #909399; +} + +.meeting-card-time { + display: flex; + flex-direction: column; + margin-bottom: 24rpx; + font-size: 28rpx; + color: #606266; +} + +.meeting-card-time text { + line-height: 1.5; + margin-bottom: 8rpx; +} + +.meeting-card-content { + margin-bottom: 24rpx; +} + +.meeting-card-desc { + font-size: 28rpx; + color: #909399; + line-height: 1.5; +} + +.meeting-card-action { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding-top: 24rpx; + border-top: 2rpx solid #ebeef5; +} + +.meeting-card-countdown { + font-size: 28rpx; + color: #409eff; + font-weight: 500; +} + +.meeting-card-action button { + padding: 8rpx 24rpx; + font-size: 28rpx; + min-width: 160rpx; + border-radius: 8rpx; +} + +.meeting-card-action button[disabled] { + opacity: 0.6; +} + +.meeting-card-action button text { + font-size: 28rpx; +} diff --git a/urbanLifelineWeb/packages/workcase_wechat/pages/meeting/meetingCard/MeetingCard.uvue b/urbanLifelineWeb/packages/workcase_wechat/pages/meeting/meetingCard/MeetingCard.uvue new file mode 100644 index 00000000..ccf84253 --- /dev/null +++ b/urbanLifelineWeb/packages/workcase_wechat/pages/meeting/meetingCard/MeetingCard.uvue @@ -0,0 +1,214 @@ + + + + + {{ meeting.meetingName || '未命名会议' }} + + 预定 + 进行中 + 已结束 + + + + 开始时间:{{ formatDateTime(meeting.startTime) }} + 结束时间:{{ formatDateTime(meeting.endTime) }} + 提前入会:{{ meeting.advance }}分钟 + + + {{ meeting.description }} + + + {{ countdownText }} + + {{ buttonText }} + + + + + + \ No newline at end of file diff --git a/urbanLifelineWeb/packages/workcase_wechat/types/workcase/chatRoom.ts b/urbanLifelineWeb/packages/workcase_wechat/types/workcase/chatRoom.ts index 0c1aba02..fc1270cb 100644 --- a/urbanLifelineWeb/packages/workcase_wechat/types/workcase/chatRoom.ts +++ b/urbanLifelineWeb/packages/workcase_wechat/types/workcase/chatRoom.ts @@ -72,6 +72,7 @@ export interface TbVideoMeetingDTO extends BaseDTO { workcaseId?: string meetingName?: string meetingPassword?: string + description?: string jwtToken?: string jitsiRoomName?: string jitsiServerUrl?: string @@ -81,6 +82,12 @@ export interface TbVideoMeetingDTO extends BaseDTO { creatorName?: string participantCount?: number maxParticipants?: number + /** 预定开始时间 */ + startTime?: string + /** 预定结束时间 */ + endTime?: string + /** 提前入会时间(分钟) */ + advance?: number actualStartTime?: string actualEndTime?: string durationSeconds?: number @@ -214,6 +221,10 @@ export interface ChatMemberVO extends BaseVO { leaveTime?: string } +/** + * 视频会议VO + * 用于前端展示Jitsi Meet会议信息 + */ /** * 视频会议VO * 用于前端展示Jitsi Meet会议信息 @@ -224,17 +235,23 @@ export interface VideoMeetingVO extends BaseVO { workcaseId?: string meetingName?: string meetingPassword?: string + description?: string jwtToken?: string jitsiRoomName?: string jitsiServerUrl?: string status?: string - creatorId?: string creatorType?: string creatorName?: string participantCount?: number maxParticipants?: number - startTime?: string + // 预定开始时间 + startTime?: string + // 预定结束时间 endTime?: string + // 提前入会时间(分钟) + advance?: number + actualStartTime?: string + actualEndTime?: string durationSeconds?: number durationFormatted?: string iframeUrl?: string @@ -280,6 +297,9 @@ export interface SendMessageParam { export interface CreateMeetingParam { roomId: string workcaseId: string + startTime: string + endTime: string + advance?: number meetingName?: string meetingPassword?: string maxParticipants?: number
发起人:{{ message.contentExtra.creatorName }}
最多参与人数:{{ message.contentExtra.maxParticipants }}