Skip to content

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 modulemodules/v1/virtualProgram/虚拟会议核心业务模块
controllervirtualProgram/controller/VirtualProgramController.java虚拟会议 REST API
controllervirtualProgram/controller/ZoomMeetingWebhookController.javaZoom Webhook 处理
servicevirtualProgram/service/VirtualProgramServiceFactory.java服务工厂(策略模式)
servicevirtualProgram/service/VirtualProgramServiceInterface.java服务接口定义
servicevirtualProgram/service/ZoomAbstractService.javaZoom 通用基类
servicevirtualProgram/service/ZoomVirtualMeetingService.javaZoom Meeting 实现
servicevirtualProgram/service/ZoomWebinarService.javaZoom Webinar 实现
servicevirtualProgram/service/StubVirtualMeetingService.java第三方虚拟会议(空实现)
modelvirtualProgram/model/VirtualServiceType.java服务类型枚举
modelvirtualProgram/model/ZoomAccessTokenManager.javaToken 缓存管理
entitycommon/persistence/entity/ProgramServiceType.java项目服务类型实体

2. Data Model Analysis

2.1 Entity Overview Table

VirtualServiceType (枚举) - 虚拟服务类型

枚举值Code说明
zoomVirtualMeeting1Zoom Meeting
zoomWebinar2Zoom Webinar
thirdPartyVirtualMeeting3第三方虚拟会议

注意:PVM (code=4) 已被移除。

ProgramServiceType (t_meeting_program_service_type) - 项目服务类型

字段类型说明
program_service_idInteger (PK, auto, seq: s_service_type)服务类型ID
program_service_type_nameString服务类型名称
product_idInteger产品ID
program_type_idInteger项目类型ID
descriptionString描述
management_feeBigDecimal管理费
creditInteger积分
program_categoryString项目类别
statusInteger状态
sequenceInteger排序
access_levelInteger访问级别: 0=所有用户, 1=仅UM
meeting_id_prefixString会议ID前缀
project_codeString项目代码
updated_atDate更新时间
virtual_service_type_idInteger虚拟服务类型ID (关联 VirtualServiceType 枚举)
space_configObject (JSONB)空间配置
second_speaker_requiredBoolean是否需要第二演讲者
virtual_program_infoObject (JSONB)虚拟会议信息
hybridBoolean是否混合模式(线上+线下)

VirtualProgramResponse (非数据库实体) - 虚拟会议信息

字段类型说明
idLongZoom Meeting/Webinar ID
hostEmailString主持人邮箱
topicString会议主题
startTimeDate开始时间
durationInteger时长(分钟)
timezoneString时区
createdAtDate创建时间
agendaString会议议程
startUrlString主持人开始链接
joinUrlString参会者加入链接
passwordString会议密码

VirtualProgramAttendeeResponse (非数据库实体) - 虚拟参会者信息

字段类型说明
meetingIdInteger会议ID
emailString参会者邮箱
joinUrlString个人加入链接
registrantIdStringZoom 注册人ID
startTimeString开始时间
attendeeActivitiesList<AttendeeActivity>参会活动记录列表

AttendeeActivity (非数据库实体) - 参会活动

字段类型说明
attendeeJoinedZonedDateTime加入时间
attendeeLeftZonedDateTime离开时间
activityTimelong活动时长

ZoomAuthResponse (非数据库实体) - Zoom OAuth 响应

字段类型说明
accessTokenString访问令牌
tokenTypeString令牌类型
expiresInLong过期时间(秒)
scopeString权限范围

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 为空,发布 AttendeeEndMeetingEvent

3.2 Validation Rules

规则位置描述
Email 格式验证VirtualProgramServiceFactory.java:107,148添加参会者前验证邮箱格式
Email 不能为空VirtualProgramServiceFactory.java:143参会者邮箱不能为空
Attendee 必须存在VirtualProgramServiceFactory.java:139参会者必须在系统中存在
虚拟会议必须存在ZoomVirtualMeetingService.java:288添加参会者时会议必须有虚拟会议信息
Webinar 权限校验ZoomAbstractService.java:114-116Webinar 需要 Zoom 用户有 Webinar 权限
Webhook 签名验证ZoomMeetingWebhookController.java:124-151HMAC-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)

MethodPath描述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)

MethodPath描述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/joinWebinar 参会者加入73
POST/v1/webhooks/zoom/webinar/participant/leaveWebinar 参会者离开81
POST/v1/webhooks/zoom/webinar/endedWebinar 结束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 (虚拟会议配置)

组件路径描述
VirtualProgramSetupcomponents/VirtualProgramSetup/index.js虚拟会议创建/配置入口
VirtualProgramSetup/actionscomponents/VirtualProgramSetup/actions.js创建 Zoom 会议 action
VirtualProgramSetup/sagascomponents/VirtualProgramSetup/sagas.jsZoom 会议创建 saga
VirtualProgramURLscontainers/MeetingDetail/VirtualProgramURLs.js虚拟会议链接显示
MeetingDetailcontainers/MeetingDetail/MeetingWrapper.js会议详情中的虚拟会议信息
RegReportcomponents/RegReport/index.js注册报告中的虚拟会议信息

Salesview (虚拟会议参与)

组件路径描述
Program Informationpages/Programs/Profile/Information/program.js项目信息中显示虚拟会议
Program Attendeespages/Programs/Profile/Attendees/index.js参会者列表中的虚拟会议状态
Registration/ViewRegistrationpages/Programs/Modal/Registration/ViewRegistration.js注册视图中的虚拟会议链接
ProgramInvitationFormpages/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: Object

5.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)

  1. VirtualProgramServiceFactory 职责混乱 - 同时承担工厂模式和代理模式的职责,包含自己的业务逻辑,应拆分为纯工厂和独立的编排服务
  2. 虚拟会议信息无独立持久化 - 完全依赖 JSONB 字段和 Zoom API 即时查询,数据可靠性差
  3. Webhook 产品ID获取方式不安全 - 无法从 Zoom Webhook 请求中正确获取产品ID

6.2 Design Defects (should improve)

  1. Token 缓存不支持多产品 - ZoomAccessTokenManager 使用全局单一 key,多产品场景会冲突
  2. 遗留的 JWT 签名方法 - generateZoomSignaturegenerateSignature 两个方法并存,前者已被 Zoom 弃用
  3. StubVirtualMeetingService 空实现 - 第三方虚拟会议完全无功能,需要明确是否保留此类型
  4. Zoom Host 选择逻辑 - 多次调用 Zoom API 匹配用户,应缓存用户列表
  5. resend 使用 GET - 发送邮件操作应使用 POST 方法
  6. Controller API 标签错误 - 标记为 "User Management" 而非 "Virtual Programs"

6.3 Technical Debt (nice to have)

  1. Zoom Model 类过多 - model/zoom/ 下有20+个类用于 Zoom API 交互,部分可以合并或简化
  2. ZoomAbstractService.restCall 异常处理 - catch Exception 后再 throw RuntimeException,丢失原始异常链
  3. getRandomPassword 安全性 - 使用 Random 而非 SecureRandom 生成密码
  4. ZoomAccessTokenManager 使用 Guava Cache - 可以简化为 AtomicReference + 过期时间
  5. Webhook 端点可合并 - 6个端点可以合并为按事件类型分发的单一端点

7. Rewrite Recommendations

  1. 拆分 VirtualProgramServiceFactory:

    • 纯工厂类: VirtualProgramServiceFactory 只负责根据 serviceType 返回正确的 Service 实例
    • 编排服务: VirtualProgramOrchestrationService 负责 addProgramAttendee(attendeeId), getJoinUrlForUser, sendInvite 等编排逻辑
    • 接口实现: ZoomVirtualMeetingService, ZoomWebinarService 各自独立实现
  2. 独立的虚拟会议数据表:

    sql
    CREATE 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
    );
  3. 改进 Token 管理:

    • 按 productId 缓存不同的 access token
    • 使用 ConcurrentHashMap<Integer, CachedToken> 替代全局单一缓存
    • 添加 token 刷新的并发控制
  4. 简化 Webhook 处理:

    • 合并为两个端点: /v1/webhooks/zoom/meeting/v1/webhooks/zoom/webinar
    • 通过 event 字段区分 join/leave/ended
    • 或进一步合并为单一端点: /v1/webhooks/zoom
  5. 第三方虚拟会议决策:

    • 如果保留: 实现基本的第三方会议管理(手动输入 join URL、密码等)
    • 如果移除: 从 VirtualServiceType 枚举中删除 thirdPartyVirtualMeeting
  6. 清理遗留代码:

    • 删除 generateZoomSignature 方法(旧 JWT 方式)
    • 使用 SecureRandom 替代 Random 生成密码
    • 修复 @Api(tags = "User Management") 为正确的标签
    • 修复 resend 端点使用 POST 方法