Virtual Programs Domain - Deep Dive Analysis
1. Domain Overview
1.1 领域职责描述
Virtual Programs 领域负责管理药品演讲项目的虚拟会议功能(线上会议),包括:
- 虚拟会议创建:通过 Zoom API 创建 Meeting 或 Webinar
- 虚拟参会者管理:注册、更新、取消虚拟会议的参会者
- 会议邀请发送:向参会者发送虚拟会议链接
- 加入链接管理:为主持人、参会者提供不同的加入 URL
- Zoom Webhook 处理:处理参会者加入、离开和会议结束事件
- Zoom Web SDK 集成:生成 Web SDK 签名用于内嵌 Zoom 会议
- 第三方虚拟会议支持:支持非 Zoom 的第三方虚拟会议(Stub 实现)
注意:PVM(Pharmagin Virtual Meeting,基于 Agora 的视频会议,type=4)已被移除。当前仅支持 Zoom Virtual Meeting、Zoom Webinar 和 Third-Party Virtual Meeting。
1.2 涉及的后端模块和包
| 模块/包 | 路径 | 描述 |
|---|---|---|
| virtualProgram module | modules/v1/virtualProgram/ | 虚拟会议核心业务模块 |
| controller | virtualProgram/controller/VirtualProgramController.java | 虚拟会议 REST API |
| controller | virtualProgram/controller/ZoomMeetingWebhookController.java | Zoom Webhook 处理 |
| service | virtualProgram/service/VirtualProgramServiceFactory.java | 服务工厂(策略模式) |
| service | virtualProgram/service/VirtualProgramServiceInterface.java | 服务接口定义 |
| service | virtualProgram/service/ZoomAbstractService.java | Zoom 通用基类 |
| service | virtualProgram/service/ZoomVirtualMeetingService.java | Zoom Meeting 实现 |
| service | virtualProgram/service/ZoomWebinarService.java | Zoom Webinar 实现 |
| service | virtualProgram/service/StubVirtualMeetingService.java | 第三方虚拟会议(空实现) |
| model | virtualProgram/model/VirtualServiceType.java | 服务类型枚举 |
| model | virtualProgram/model/ZoomAccessTokenManager.java | Token 缓存管理 |
| entity | common/persistence/entity/ProgramServiceType.java | 项目服务类型实体 |
2. Data Model Analysis
2.1 Entity Overview Table
VirtualServiceType (枚举) - 虚拟服务类型
| 枚举值 | Code | 说明 |
|---|---|---|
| zoomVirtualMeeting | 1 | Zoom Meeting |
| zoomWebinar | 2 | Zoom Webinar |
| thirdPartyVirtualMeeting | 3 | 第三方虚拟会议 |
注意:PVM (code=4) 已被移除。
ProgramServiceType (t_meeting_program_service_type) - 项目服务类型
| 字段 | 类型 | 说明 |
|---|---|---|
| program_service_id | Integer (PK, auto, seq: s_service_type) | 服务类型ID |
| program_service_type_name | String | 服务类型名称 |
| product_id | Integer | 产品ID |
| program_type_id | Integer | 项目类型ID |
| description | String | 描述 |
| management_fee | BigDecimal | 管理费 |
| credit | Integer | 积分 |
| program_category | String | 项目类别 |
| status | Integer | 状态 |
| sequence | Integer | 排序 |
| access_level | Integer | 访问级别: 0=所有用户, 1=仅UM |
| meeting_id_prefix | String | 会议ID前缀 |
| project_code | String | 项目代码 |
| updated_at | Date | 更新时间 |
| virtual_service_type_id | Integer | 虚拟服务类型ID (关联 VirtualServiceType 枚举) |
| space_config | Object (JSONB) | 空间配置 |
| second_speaker_required | Boolean | 是否需要第二演讲者 |
| virtual_program_info | Object (JSONB) | 虚拟会议信息 |
| hybrid | Boolean | 是否混合模式(线上+线下) |
VirtualProgramResponse (非数据库实体) - 虚拟会议信息
| 字段 | 类型 | 说明 |
|---|---|---|
| id | Long | Zoom Meeting/Webinar ID |
| hostEmail | String | 主持人邮箱 |
| topic | String | 会议主题 |
| startTime | Date | 开始时间 |
| duration | Integer | 时长(分钟) |
| timezone | String | 时区 |
| createdAt | Date | 创建时间 |
| agenda | String | 会议议程 |
| startUrl | String | 主持人开始链接 |
| joinUrl | String | 参会者加入链接 |
| password | String | 会议密码 |
VirtualProgramAttendeeResponse (非数据库实体) - 虚拟参会者信息
| 字段 | 类型 | 说明 |
|---|---|---|
| meetingId | Integer | 会议ID |
| String | 参会者邮箱 | |
| joinUrl | String | 个人加入链接 |
| registrantId | String | Zoom 注册人ID |
| startTime | String | 开始时间 |
| attendeeActivities | List<AttendeeActivity> | 参会活动记录列表 |
AttendeeActivity (非数据库实体) - 参会活动
| 字段 | 类型 | 说明 |
|---|---|---|
| attendeeJoined | ZonedDateTime | 加入时间 |
| attendeeLeft | ZonedDateTime | 离开时间 |
| activityTime | long | 活动时长 |
ZoomAuthResponse (非数据库实体) - Zoom OAuth 响应
| 字段 | 类型 | 说明 |
|---|---|---|
| accessToken | String | 访问令牌 |
| tokenType | String | 令牌类型 |
| expiresIn | Long | 过期时间(秒) |
| scope | String | 权限范围 |
2.2 Table Relationships (ER Diagram - ASCII)
t_meeting_program_service_type (项目服务类型)
|-- program_service_id (PK)
|-- product_id --> t_product.product_id
|-- program_type_id --> t_meeting_program_type.program_type_id
|-- virtual_service_type_id --> VirtualServiceType 枚举 (1/2/3)
|
|-- Referenced by: t_meeting_request.service_type
| (通过 service_type 字段确定虚拟会议类型)
t_meeting_request (会议请求)
|-- meeting_request_id (PK)
|-- service_type --> t_meeting_program_service_type.program_service_id
|-- virtual_program_info (JSONB) --> 存储 VirtualProgramResponse
|-- zoom_program_info (JSONB) --> 存储 Zoom 配置信息
|
|-- 1:N --> t_attendee
|-- virtual_program_attendee_info (JSONB)
| --> 存储 VirtualProgramAttendeeResponse
Configuration (Spring Cloud Config):
product-{env}.yml
|-- virtualPrograms:
|-- zoomVirtualMeeting:
|-- url: Zoom API base URL
|-- s2sClientId: Server-to-Server OAuth Client ID
|-- s2sClientSecret: Server-to-Server OAuth Secret
|-- s2sAccountId: Zoom Account ID
|-- s2sOAuthTokenUrl: Token URL
|-- meetingSDKClientId: Web SDK Client ID
|-- meetingSDKClientSecret: Web SDK Secret
|-- eventSecretToken: Webhook Secret Token
|-- users: [HostConfig list]
|-- programSettings: MeetingSettings
|-- polls: [Poll list]2.3 Data Model Issues
DM-1: 虚拟会议信息存储在 JSONB 中
- 文件:
MeetingRequest.virtualProgramInfo,Attendee.virtualProgramAttendeeInfo - 虚拟会议和参会者信息以 JSONB 形式嵌入 MeetingRequest 和 Attendee 实体
- 每次读取都需要
BeanUtil.convertMapToEntity转换,类型不安全 - 无法直接通过 SQL 查询虚拟会议相关数据
DM-2: ProgramServiceType 承载过多职责
- 文件:
common/persistence/entity/ProgramServiceType.java - 该实体同时包含服务类型定义、管理费、积分、访问级别、项目代码、虚拟会议配置等
- 应拆分为独立的服务类型配置和虚拟会议配置
DM-3: 没有独立的虚拟会议数据库表
- 虚拟会议没有自己的持久化表,完全依赖 MeetingRequest 的 JSONB 字段和 Zoom API 的即时查询
- 如果 Zoom API 不可用,无法查询历史虚拟会议信息
3. Business Flow Analysis
3.1 Core Business Flows (ASCII Flow Diagrams)
虚拟会议创建流程
Planner (plannerview - VirtualProgramSetup)
|
v
[选择 Zoom 用户] --> POST /v1/virtualPrograms/meetings/{meetingRequestId}
| body: { zoomUser, conflictTime }
| |
| v
| VirtualProgramController.createZoomMeeting()
| 1. 获取 MeetingRequest 和 ProgramResponse
| 2. programService.setZoomProgramInfo() -- 保存 Zoom 配置
| 3. virtualProgramService.createProgram()
| |
| v
| VirtualProgramServiceFactory.getVirtualProgramService()
| 根据 serviceType -> ProgramServiceType -> virtualServiceTypeId
| |
| +-- code=1 --> ZoomVirtualMeetingService
| +-- code=2 --> ZoomWebinarService
| +-- code=3 --> StubVirtualMeetingService
| |
| v
| ZoomVirtualMeetingService.createProgram()
| 1. 获取 VirtualProgramConfig (从 Spring Cloud Config)
| 2. getUserForProgram() -- 选择 Zoom 主持人
| 3. 构建 MeetingCreate 请求
| 4. REST POST /users/{userId}/meetings --> Zoom API
| 5. 获取 meetingId, 创建 Polls (如配置)
| 6. 返回 VirtualProgramResponse
| |
| v
| 4. meetingRequest.setVirtualProgramInfo(response)
| 5. programService.updateMeetingRequest() -- 保存到 JSONB参会者注册虚拟会议流程
System (注册参会者时自动触发)
|
v
[添加参会者] --> PUT /v1/virtualPrograms/attendees/{attendeeId}
| |
| v
| VirtualProgramServiceFactory.addProgramAttendee(attendeeId)
| 1. 查找 Attendee
| 2. 验证 email 格式
| 3. 获取 MeetingRequest
| |
| v
| ZoomVirtualMeetingService.addProgramAttendee()
| 1. 获取 VirtualProgramResponse (from JSONB)
| 2. REST POST /meetings/{id}/registrants --> Zoom API
| body: { email, firstName, lastName }
| 3. 获取 registrantId
| 4. 如果注册状态需要 approve/cancel:
| REST PUT /meetings/{id}/registrants/status --> Zoom API
| 5. 返回 VirtualProgramAttendeeResponse
| |
| v
| 6. attendee.setVirtualProgramAttendeeInfo(response) -- 保存到 JSONB
| 7. sendProgramInvitation() -- 发送确认邮件Zoom Webhook 事件处理流程
Zoom Server --> POST /v1/webhooks/zoom/meeting/participant/join
| |
| v
| ZoomMeetingWebhookController.processWebhook()
| 1. 解析 ZoomWebhookEvent
| 2. 如果是 endpoint.url_validation:
| 返回 { plainToken, encryptedToken }
| 3. 验证签名 (HMAC-SHA256)
| message = "v0:{timestamp}:{body}"
| signature = "v0=" + hex(HMAC-SHA256(secret, message))
| 4. 验证时间戳 (5分钟容差)
| |
| v
| ZoomVirtualMeetingService.handleParticipantJoin()
| 1. 获取 MeetingEvent 中的 participantEvent
| 2. 通过虚拟会议ID和邮箱查找 Attendee
| 3. 转换时区
| 4. 发布 AttendeeJoinedEvent (Spring Event Bus)
|
Zoom Server --> POST /v1/webhooks/zoom/meeting/ended
| |
| v
| ZoomVirtualMeetingService.handleMeetingEnd()
| 1. 获取虚拟会议ID
| 2. 查找关联的 Program
| 3. 获取 MeetingReport (REST GET /report/meetings/{id}/participants)
| 4. 遍历所有 Attendee
| 5. 如果 attendeeActivities 为空,发布 AttendeeEndMeetingEvent3.2 Validation Rules
| 规则 | 位置 | 描述 |
|---|---|---|
| Email 格式验证 | VirtualProgramServiceFactory.java:107,148 | 添加参会者前验证邮箱格式 |
| Email 不能为空 | VirtualProgramServiceFactory.java:143 | 参会者邮箱不能为空 |
| Attendee 必须存在 | VirtualProgramServiceFactory.java:139 | 参会者必须在系统中存在 |
| 虚拟会议必须存在 | ZoomVirtualMeetingService.java:288 | 添加参会者时会议必须有虚拟会议信息 |
| Webinar 权限校验 | ZoomAbstractService.java:114-116 | Webinar 需要 Zoom 用户有 Webinar 权限 |
| Webhook 签名验证 | ZoomMeetingWebhookController.java:124-151 | HMAC-SHA256 签名 + 5分钟时间戳容差 |
3.3 Business Logic Issues
BL-1: VirtualProgramServiceFactory 同时是工厂和代理
- 文件:
virtualProgram/service/VirtualProgramServiceFactory.java VirtualProgramServiceFactory实现了VirtualProgramServiceInterface,同时又是工厂类- 它既作为策略选择器,又包含自己的业务逻辑(如
addProgramAttendee(String attendeeId)、getJoinUrlForUser) - 违反了单一职责原则
BL-2: StubVirtualMeetingService 是空实现
- 文件:
virtualProgram/service/StubVirtualMeetingService.java - 第三方虚拟会议的实现完全为空,所有方法返回 null 或空对象
sendProgramInvitation有 TODO 注释但未实现
BL-3: Zoom Host 选择逻辑复杂且脆弱
- 文件:
ZoomAbstractService.java:68-89 - 主持人选择顺序:指定的 zoomUser > Sales Rep email > Planner email > 默认配置
- 需要逐个查询 Zoom API 的用户列表匹配邮箱
- 如果都匹配不到,默认使用配置中的第一个用户
BL-4: Token 缓存不支持多产品
- 文件:
virtualProgram/model/ZoomAccessTokenManager.java ZoomAccessTokenManager使用全局单一缓存 key"accessToken"- 如果不同产品使用不同的 Zoom 账号,Token 会互相覆盖
- 当前架构是单租户部署,所以问题不严重,但在多租户场景下会有问题
BL-5: 遗留的 Zoom JWT 签名方法
- 文件:
ZoomAbstractService.java:221-243 generateZoomSignature方法使用旧的 JWT 签名方式(已被 Zoom 弃用)generateSignature是新的实现,两者并存
BL-6: Webhook 产品ID获取方式不安全
- 文件:
ZoomMeetingWebhookController.java:194-197 getZoomWebhookSecretToken通过RequestUtil.getProductId()获取产品ID- Webhook 是 Zoom 发起的请求,不携带产品ID信息,这里的获取方式需要通过其他方式(如 Nginx header)注入
4. API Inventory
4.1 REST Endpoints Table
VirtualProgramController (v1/virtualPrograms)
| Method | Path | 描述 | Controller 行号 |
|---|---|---|---|
| GET | /v1/virtualPrograms/attendees/{attendeeId}/resend | 重发虚拟会议邀请 | 53 |
| PUT | /v1/virtualPrograms/attendees/{attendeeId} | 添加参会者到虚拟会议 | 59 |
| POST | /v1/virtualPrograms/meetings/{meetingRequestId} | 创建 Zoom 会议 | 64 |
| GET | /v1/virtualPrograms/meetings/{meetingRequestId}/url | 获取当前用户加入链接 | 82 |
| GET | /v1/virtualPrograms/meetings/{meetingRequestId}/zoom | 获取 Zoom Web SDK 信息 | 87 |
ZoomMeetingWebhookController (v1/webhooks/zoom)
| Method | Path | 描述 | Controller 行号 |
|---|---|---|---|
| POST | /v1/webhooks/zoom/meeting/participant/join | 会议参会者加入 | 49 |
| POST | /v1/webhooks/zoom/meeting/participant/leave | 会议参会者离开 | 57 |
| POST | /v1/webhooks/zoom/meeting/ended | 会议结束 | 65 |
| POST | /v1/webhooks/zoom/webinar/participant/join | Webinar 参会者加入 | 73 |
| POST | /v1/webhooks/zoom/webinar/participant/leave | Webinar 参会者离开 | 81 |
| POST | /v1/webhooks/zoom/webinar/ended | Webinar 结束 | 89 |
4.2 API Design Issues
API-1: resend 操作使用 GET 方法
GET /v1/virtualPrograms/attendees/{attendeeId}/resend执行写操作(发送邮件),应使用 POST
API-2: Controller @Api 标签错误
- 文件:
VirtualProgramController.java:41 @Api(tags = "User Management")标签为 "User Management",但这是虚拟会议控制器
API-3: Webhook 端点分离不必要
- 6个 Webhook 端点(meeting/webinar x join/leave/ended)可以合并为2个
- Zoom Webhook 可以通过 event type 字段区分事件类型
API-4: 缺少虚拟会议的完整 CRUD
- 没有更新虚拟会议的端点(更新通过 Program 更新时自动触发)
- 没有删除虚拟会议的端点
- 没有查询虚拟会议详情的端点
5. Frontend Analysis
5.1 Pages & Components
Plannerview (虚拟会议配置)
| 组件 | 路径 | 描述 |
|---|---|---|
| VirtualProgramSetup | components/VirtualProgramSetup/index.js | 虚拟会议创建/配置入口 |
| VirtualProgramSetup/actions | components/VirtualProgramSetup/actions.js | 创建 Zoom 会议 action |
| VirtualProgramSetup/sagas | components/VirtualProgramSetup/sagas.js | Zoom 会议创建 saga |
| VirtualProgramURLs | containers/MeetingDetail/VirtualProgramURLs.js | 虚拟会议链接显示 |
| MeetingDetail | containers/MeetingDetail/MeetingWrapper.js | 会议详情中的虚拟会议信息 |
| RegReport | components/RegReport/index.js | 注册报告中的虚拟会议信息 |
Salesview (虚拟会议参与)
| 组件 | 路径 | 描述 |
|---|---|---|
| Program Information | pages/Programs/Profile/Information/program.js | 项目信息中显示虚拟会议 |
| Program Attendees | pages/Programs/Profile/Attendees/index.js | 参会者列表中的虚拟会议状态 |
| Registration/ViewRegistration | pages/Programs/Modal/Registration/ViewRegistration.js | 注册视图中的虚拟会议链接 |
| ProgramInvitationForm | pages/Programs/Modal/ProgramInvitationForm.js | 项目邀请表单 |
5.2 Redux State Structure
Plannerview
// VirtualProgramSetup 使用 redux-saga-routines
state.virtualProgramSetup:
- createZoomMeeting: { loading, data, error } // 创建 Zoom 会议结果
// MeetingDetail 中
state.meetingDetail:
- program.virtualProgramInfo: Object // 虚拟会议信息 (JSONB)
- program.zoomProgramInfo: Object // Zoom 配置信息Salesview
// 嵌入在 Programs Profile 状态中
state.programProfile:
- program.virtualProgramInfo: Object
- program.zoomProgramInfo: Object5.3 Frontend Issues
FE-1: 虚拟会议配置入口分散
- Zoom 会议创建在
VirtualProgramSetup中 - 虚拟会议链接在
VirtualProgramURLs中 - 会议详情在
MeetingWrapper中 - 缺乏统一的虚拟会议管理页面
FE-2: 第三方虚拟会议无前端支持
StubVirtualMeetingService后端为空实现- 前端也没有第三方虚拟会议的配置界面
- 如果选择第三方类型,无法在 UI 中管理
FE-3: Speakerview 中缺少虚拟会议入口
- Speaker 没有直接的虚拟会议入口组件
- 需要通过会议详情间接获取加入链接
6. Problem Summary
6.1 Critical Issues (must fix in rewrite)
- VirtualProgramServiceFactory 职责混乱 - 同时承担工厂模式和代理模式的职责,包含自己的业务逻辑,应拆分为纯工厂和独立的编排服务
- 虚拟会议信息无独立持久化 - 完全依赖 JSONB 字段和 Zoom API 即时查询,数据可靠性差
- Webhook 产品ID获取方式不安全 - 无法从 Zoom Webhook 请求中正确获取产品ID
6.2 Design Defects (should improve)
- Token 缓存不支持多产品 -
ZoomAccessTokenManager使用全局单一 key,多产品场景会冲突 - 遗留的 JWT 签名方法 -
generateZoomSignature和generateSignature两个方法并存,前者已被 Zoom 弃用 - StubVirtualMeetingService 空实现 - 第三方虚拟会议完全无功能,需要明确是否保留此类型
- Zoom Host 选择逻辑 - 多次调用 Zoom API 匹配用户,应缓存用户列表
- resend 使用 GET - 发送邮件操作应使用 POST 方法
- Controller API 标签错误 - 标记为 "User Management" 而非 "Virtual Programs"
6.3 Technical Debt (nice to have)
- Zoom Model 类过多 -
model/zoom/下有20+个类用于 Zoom API 交互,部分可以合并或简化 ZoomAbstractService.restCall异常处理 - catch Exception 后再 throw RuntimeException,丢失原始异常链getRandomPassword安全性 - 使用Random而非SecureRandom生成密码ZoomAccessTokenManager使用 Guava Cache - 可以简化为AtomicReference+ 过期时间- Webhook 端点可合并 - 6个端点可以合并为按事件类型分发的单一端点
7. Rewrite Recommendations
拆分 VirtualProgramServiceFactory:
- 纯工厂类:
VirtualProgramServiceFactory只负责根据 serviceType 返回正确的 Service 实例 - 编排服务:
VirtualProgramOrchestrationService负责addProgramAttendee(attendeeId),getJoinUrlForUser,sendInvite等编排逻辑 - 接口实现:
ZoomVirtualMeetingService,ZoomWebinarService各自独立实现
- 纯工厂类:
独立的虚拟会议数据表:
sqlCREATE TABLE t_virtual_meeting ( virtual_meeting_id SERIAL PRIMARY KEY, meeting_request_id INTEGER REFERENCES t_meeting_request, service_type INTEGER NOT NULL, -- 1=Zoom Meeting, 2=Zoom Webinar, 3=Third Party external_meeting_id VARCHAR(255), -- Zoom Meeting ID host_email VARCHAR(255), join_url TEXT, start_url TEXT, password VARCHAR(50), topic VARCHAR(500), status INTEGER DEFAULT 0, created_at TIMESTAMP, updated_at TIMESTAMP ); CREATE TABLE t_virtual_meeting_attendee ( id SERIAL PRIMARY KEY, virtual_meeting_id INTEGER REFERENCES t_virtual_meeting, attendee_id VARCHAR(255) REFERENCES t_attendee, registrant_id VARCHAR(255), join_url TEXT, status INTEGER, join_time TIMESTAMP WITH TIME ZONE, leave_time TIMESTAMP WITH TIME ZONE, duration_minutes INTEGER );改进 Token 管理:
- 按 productId 缓存不同的 access token
- 使用
ConcurrentHashMap<Integer, CachedToken>替代全局单一缓存 - 添加 token 刷新的并发控制
简化 Webhook 处理:
- 合并为两个端点:
/v1/webhooks/zoom/meeting和/v1/webhooks/zoom/webinar - 通过
event字段区分 join/leave/ended - 或进一步合并为单一端点:
/v1/webhooks/zoom
- 合并为两个端点:
第三方虚拟会议决策:
- 如果保留: 实现基本的第三方会议管理(手动输入 join URL、密码等)
- 如果移除: 从
VirtualServiceType枚举中删除thirdPartyVirtualMeeting
清理遗留代码:
- 删除
generateZoomSignature方法(旧 JWT 方式) - 使用
SecureRandom替代Random生成密码 - 修复
@Api(tags = "User Management")为正确的标签 - 修复
resend端点使用 POST 方法
- 删除