Speaker Management Domain - Deep Dive Analysis
1. Domain Overview
1.1 领域职责描述
Speaker Management 是整个平台最核心的业务领域,负责管理制药行业演讲者(Speaker)的完整生命周期。主要职责包括:
- Speaker Profile 管理:演讲者基本信息、联系方式、医疗执照、NPI 注册信息、W9 税务信息、助理信息等
- 合同管理 (Contract):演讲者服务合同的创建、签署、续签、报酬上限管理
- 培训管理 (Training):演讲者 Topic 培训状态跟踪与合规检查
- 内容/演示文稿管理 (Content/Presentation):Core 演示文稿、Module/Case Study 的管理,以及 Speaker 的个人演示文稿组合
- 费用报销 (Expense):演讲者参加会议的费用申报、审批流程
- Speaker Group 管理:按用户组对演讲者进行分组,用于内容可见性控制和筛选
- 提名管理 (Nomination):演讲者候选人的提名与审核流程
- Topic 管理:演讲主题的 CRUD 及与演讲者、内容的关联
- Document 管理:演讲者相关文档(W9 表格、合同附件等)的上传与管理
- Alert 通知:培训、演示文稿下载、费用、调查问卷等事件的通知
- Speaker 登录/账户管理:演讲者账户的创建、密码重置、自助注册
1.2 涉及的后端模块和包
核心模块路径:
pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/modules/v1/speaker/- Speaker 模块主目录controller/- 12 个 Controller(SpeakerController, SpeakerContractController, SecureSpeakerContractController, SpeakerPresentationController, SpeakerMeetingController, ContentController, TopicController, SpeakerGroupController, ExpenseController, ExpenseItemController, ExpenseCategoryController, AlertController, DocumentController, TrainingController)service/- 16 个 Servicemodel/- 约 70 个 DTO/Model 类query/- 9 个 Query 类request/- 7 个 Request 类response/- 若干 Response 类constant/- 10 个枚举常量类manager/- SpeakerManager
Entity 包路径:
pharmagin-api/pharmagin-web/src/main/java/com/pharmagin/common/persistence/entity/
2. Data Model Analysis
2.1 Entity Overview Table
2.1.1 Speaker (t_speaker) - 主表,88 个字段
| 字段名 | 类型 | 注解 | 说明 |
|---|---|---|---|
| speakerId | Integer | @Id, @GeneratedValue | 主键,自增序列 s_speaker |
| firstName | String | 名 | |
| lastName | String | 姓 | |
| speakerName | String | 全名(冗余) | |
| middleName | String | 中间名 | |
| suffix | String | 后缀(如 MD, PhD) | |
| title | String | 职称 | |
| resume | String | 简历 | |
| imagePath | String | 头像文件路径 | |
| videoPath | String | 视频文件路径 | |
| emailAddress | String | 电子邮箱 | |
| phone | String | 办公电话 | |
| mobilePhone | String | 手机 | |
| fax | String | 传真 | |
| street1 | String | 办公地址1 | |
| street2 | String | 办公地址2 | |
| city | String | 城市 | |
| state | String | 州 | |
| zip | String | 邮编 | |
| homeAddress1 | String | 家庭地址1 | |
| homeAddress2 | String | 家庭地址2 | |
| homeCity | String | 家庭城市 | |
| homeState | String | 家庭州 | |
| homeZip | String | 家庭邮编 | |
| w9Address1 | String | W9 税务地址1 | |
| w9Address2 | String | W9 税务地址2 | |
| w9City | String | W9 城市 | |
| w9State | String | W9 州 | |
| w9ZipCode | String | W9 邮编 | |
| npiRegistryAddress1 | String | NPI 注册地址1 | |
| npiRegistryAddress2 | String | NPI 注册地址2 | |
| npiRegistryCity | String | NPI 注册城市 | |
| npiRegistryState | String | NPI 注册州 | |
| npiRegistryZip | String | NPI 注册邮编 | |
| honorariaCap | BigDecimal | 报酬上限(已移至合同,冗余) | |
| travelFee | BigDecimal | 差旅费 | |
| webcastHonoraria | BigDecimal | 网络研讨会报酬 | |
| localHonoraria | BigDecimal | 本地报酬 | |
| contractStartDate | Date | 合同开始日期(冗余,已移至合同) | |
| contractExpirationDate | Date | 合同到期日期(冗余) | |
| contractTerm | String | 合同期限 | |
| contractHistory | String | 合同历史 | |
| adminContract | String | 管理员合同 | |
| status | Integer | 状态:0=Inactive, 1=Active, 2=W9 Pending, 3=Contract Creation Pending, 4=Contract Signature Pending, 5=Expired | |
| approved | Integer | 审批状态 | |
| deleted | Integer | 软删除标记 | |
| speakerType | Integer | 演讲者类型 | |
| speakerLevel | String | 演讲者级别 | |
| speakerOrder | Integer | 排序号 | |
| payment | String | 支付方式 | |
| companyId | Integer | 所属公司 ID | |
| company | String | 公司名(冗余) | |
| regionId | Integer | 区域 ID | |
| districtId | Integer | 地区 ID | |
| npi | String | NPI 号码 | |
| ssnTaxId | String | SSN/Tax ID | |
| w9Year | String | W9 年份 | |
| bie | String | BIE 标识 | |
| usNumber | String | US 编号 | |
| stateMedicalLicense | String | 州医疗执照 | |
| additionalMedicalLicense | String | 附加医疗执照 | |
| stateOfMedicalLicense | String | 执照所在州(与 licenseState 冗余) | |
| licenseState | String | 执照州 | |
| stateLicenseExpDate | String | 执照到期日期(类型应为 Date) | |
| boardCertification | String | 董事会认证 | |
| expertise | String | 专业领域 | |
| specialty | String | 专科 | |
| institutionAffiliation | String | 机构附属关系 | |
| affiliationListing | String | 附属列表 | |
| businessName | String | 企业名称 | |
| website | String | 个人网站 | |
| publications | String | 出版物 | |
| travelPreference | String | 差旅偏好 | |
| globalEntry | String | Global Entry 编号 | |
| classification | String | 分类 | |
| audienceType | String | 受众类型 | |
| notes | String | 备注 | |
| me | String | 用途不明 | |
| wk | String | 用途不明 | |
| wdea | String | 用途不明 | |
| other | String | 其他信息 | |
| nominationStatus | Integer | 提名状态 | |
| nominationNote | String | 提名备注(枚举值存在 String 中) | |
| createdBy | Integer | 创建者 | |
| password | String | 密码(明文存储,已废弃) | |
| passwordQuestion | String | 密码提示问题(已废弃) | |
| passwordAnswer | String | 密码提示答案(已废弃) | |
| resetPassword | String | 重置密码 token | |
| resetTime | Date | 重置密码时间 | |
| speakername | String | 用户名(与 speakerName 冗余,小写命名) | |
| hideFromSpeakerSearch | Boolean | 是否在搜索中隐藏 | |
| ambassadorType | String | 大使类型 | |
| birthday | Date | 生日 | |
| maritalStatus | String | 婚姻状态 | |
| employmentStatus | String | 就业状态 | |
| primaryLanguage | String | 主要语言 | |
| otherLanguage | String | 其他语言 | |
| category | String | 分类(PATIENT/CAREGIVER) | |
| caregiver | String | 护理者信息 | |
| physicianCare | String | 医生护理信息 | |
| programSource | String | 项目来源 | |
| travelCompanionRequired | Integer | 是否需要旅伴(1/0 应为 Boolean) | |
| handicappedAccessible | Integer | 是否需要无障碍设施(1/0 应为 Boolean) | |
| connectedToSource | Integer | 是否连接到来源(1/0 应为 Boolean) | |
| nurseName | String | 护士姓名 | |
| homeMedical | String | 家庭医疗信息 | |
| diagnosisDate | Date | 诊断日期 | |
| treatmentStartDate | Date | 治疗开始日期 | |
| indicationType | String | 适应症类型 | |
| internalAreaContact | String | 内部区域联系人 | |
| requiredNotice | String | 所需通知 | |
| unavailableDays | String | 不可用天数 | |
| assistantName | String | 助理姓名 | |
| assistantEmail | String | 助理邮箱 | |
| assistantPhone | String | 助理电话 | |
| geog | String | 地理坐标(PostGIS geography 类型) |
2.1.2 SpeakerContract (t_speaker_contract) - 合同表,26 个字段
| 字段名 | 类型 | 说明 |
|---|---|---|
| contractId | Integer | 主键 |
| contractNumber | String | 合同编号 |
| startDate | Date | 开始日期 |
| endDate | Date | 结束日期 |
| description | String | 描述 |
| honorariaCap | BigDecimal | 报酬上限 |
| travelHonoraria | BigDecimal | 差旅报酬 |
| webcastHonoraria | BigDecimal | 网络研讨会报酬 |
| localHonoraria | BigDecimal | 本地报酬 |
| speakerId | Integer | 关联 Speaker |
| status | Integer | 状态:0=未签署, 1=已签署 |
| deleted | Integer | 软删除 |
| extendedTravelHonoraria | BigDecimal | 长途差旅报酬 |
| localAdvisoryBoardHonoraria | BigDecimal | 本地顾问委员会报酬 |
| localTravelHonoraria | BigDecimal | 本地差旅报酬 |
| dinnerMeetingHonoraria | BigDecimal | 晚宴会议报酬 |
| localAdBoardHonoraria | BigDecimal | 本地咨询委员会报酬(与上面冗余) |
| localAdBoardTravelHonoraria | BigDecimal | 本地咨询委员会差旅报酬 |
| localAdBoardExtTravelHonoraria | BigDecimal | 本地咨询委员会长途差旅报酬 |
| dinnerMtgHonoraria | BigDecimal | 晚餐会议报酬(与 dinnerMeetingHonoraria 冗余) |
| dinnerMtgTravelHonoraria | BigDecimal | 晚餐会议差旅报酬 |
| dinnerMtgTravelExtHonoraria | BigDecimal | 晚餐会议长途差旅报酬 |
| localLunchHonoraria | BigDecimal | 本地午餐报酬 |
| travelLunchHonoraria | BigDecimal | 差旅午餐报酬 |
| localDinnerHonoraria | BigDecimal | 本地晚餐报酬 |
| travelDinnerHonoraria | BigDecimal | 差旅晚餐报酬 |
| peerToPeerPrograms | String | Peer-to-Peer 项目 |
| contractType | Integer | 合同类型:0=Speaker Program, 1=Consulting |
| level | String | 级别 |
| templateId | Integer | 合同模板 ID |
| signedDate | Date | 签署日期 |
| customFields | Object | 自定义字段(JSONB) |
| createdAt | Date | 创建时间 |
| includeTeInHonorariaCalculation | Boolean | T&E 是否计入报酬计算 |
| enableProgramQuantityLimit | Boolean | 是否启用项目数量限制 |
| programQuantityLimit | Integer | 项目数量上限 |
| includedProgramTypeIds | Object | 包含的项目类型 ID(JSONB) |
2.1.3 SpeakerTraining (t_speaker_training) - 培训记录表,8 个字段
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | Integer | 主键 |
| speakerId | Integer | 关联 Speaker |
| contentId | Integer | 关联 Content (培训模块) |
| status | Integer | 0=未完成, 1=已完成, 2=豁免 |
| trainedDate | Date | 培训日期 |
| expiredDate | Date | 过期日期 |
| trainingMethod | Integer | 0=Live, 1=Online/On Demand |
| createdAt | Date | 创建时间 |
| updatedAt | Date | 更新时间 |
2.1.4 Content (t_content) - 内容库表,14 个字段
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | Integer | 主键 |
| contentName | String | 内容名称 |
| type | Integer | 0=Presentation, 1=Training, 2=Other, 3=Video |
| subType | Integer | 0=Core, 1=Module, 2=Case Study, 3=iSpring, 4=Video, 5=Other |
| productId | Integer | 关联产品 |
| description | String | 描述 |
| fileId | Integer | 关联文件 |
| delFlag | Integer | 软删除 |
| createdAt | Date | 创建时间 |
| updatedAt | Date | 更新时间 |
| moduleMaximum | Integer | Module 最大数量 |
| caseStudyMaximum | Integer | Case Study 最大数量 |
| companyId | Integer | 公司 ID |
| expirationDate | Date | 过期日期 |
| comment | String | 备注 |
| url | String | 外部 URL |
2.1.5 SpeakerSlide (t_speaker_slide) - Speaker 个人演示文稿表,8 个字段
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | Integer | 主键 |
| name | String | 演示文稿名称 |
| contentId | Integer | 关联 Core Presentation (t_content.id) |
| contentName | String | 来源名称(冗余) |
| speakerId | Integer | 关联 Speaker |
| fileId | Integer | 生成的文件 ID |
| delFlag | Integer | 软删除 |
| createdAt | Date | 创建时间 |
| updatedAt | Date | 更新时间 |
2.1.6 SlideDetail (t_slide_detail) - 演示文稿页面详情表,8 个字段
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | Integer | 主键 |
| contentId | Integer | 关联 Content |
| title | String | 页面标题 |
| type | Integer | 0=普通页面, 1=插入点 |
| sequence | Integer | 顺序号 |
| min | Integer | 最小插入数 |
| max | Integer | 最大插入数 |
| imageUrl | String | 预览图 URL |
| createdAt | Date | 创建时间 |
2.1.7 SlideInsertion (t_slide_insertion) - 演示文稿插入点表,5 个字段
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | Integer | 主键(同时作为排序序号) |
| speakerSlideId | Integer | 关联 SpeakerSlide |
| detailId | Integer | 关联 SlideDetail(插入点) |
| contentId | Integer | 被插入的 Content ID |
| delFlag | Integer | 软删除 |
2.1.8 SpeakerGroup (t_speaker_group) - 演讲者组表,6 个字段
| 字段名 | 类型 | 说明 |
|---|---|---|
| groupId | Integer | 主键 |
| groupName | String | 组名 |
| description | String | 描述 |
| delFlag | Integer | 软删除 |
| filterEnabled | Integer | 是否在搜索筛选中启用 |
| kclEmail | String | KCL 邮箱(逗号分隔多个) |
| companyId | Integer | 公司 ID |
2.1.9 Topic (t_topic) - 主题表,9 个字段
| 字段名 | 类型 | 说明 |
|---|---|---|
| topicId | Integer | 主键 |
| topicName | String | 主题名称 |
| topicRef | String | 主题引用 |
| topicDescription | String | 描述 |
| productId | Integer | 关联产品 |
| meetingType | Integer | 会议类型 |
| serviceType | Integer | 服务类型 |
| status | Integer | 状态 |
| topicCode | String | 主题代码 |
| shortName | String | 简称 |
2.1.10 Expense (t_expense) - 费用报销表,12 个字段
| 字段名 | 类型 | 说明 |
|---|---|---|
| expenseId | Integer | 主键 |
| submitter | Integer | 提交者 ID |
| submitDate | Date | 提交日期 |
| meetingRequestId | Integer | 关联会议 |
| amount | BigDecimal | 金额 |
| expenseStatus | Integer | 0=Draft, 1=Approved, 2=Rejected, 3=Pending |
| rejectReason | String | 拒绝原因 |
| approveAmount | BigDecimal | 批准金额 |
| updateDate | Date | 更新日期 |
| pdfReceipts | String | PDF 收据路径 |
| fieldId | Integer | 字段 ID |
| checkNumber | String | 支票号码 |
| issueDate | Date | 签发日期 |
| pvExtUserId | String | PV 外部用户 ID |
2.1.11 Alert (t_alert) - 通知表,6 个字段
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | Integer | 主键 |
| name | String | 通知名称 |
| type | Integer | 0=Training Module, 1=Presentation, 2=Expense, 3=Survey, 5=Speaker Survey, 6=Video |
| objectId | Integer | 关联对象 ID |
| speakerId | Integer | 关联 Speaker |
| createdAt | Date | 创建时间 |
| companyId | Integer | 公司 ID |
2.1.12 Mapping Tables(关联表)
MappingTopicSpeaker (t_mapping_topic_speaker):Topic 与 Speaker 的多对多关系及培训状态
| 字段名 | 类型 | 说明 |
|---|---|---|
| topicId | Integer | @Id, 关联 Topic |
| speakerId | Integer | @Id, 关联 Speaker |
| status | Integer | 培训状态 |
| trainnedDate | Date | 培训日期(注意拼写错误:trainned) |
| expiredDate | Date | 过期日期 |
MappingSpeakerGroup (t_mapping_speaker_group):Speaker 与 Group 的多对多
| 字段名 | 类型 | 说明 |
|---|---|---|
| speakerId | Integer | @Id |
| groupId | Integer | @Id |
MappingSpeakerDistrict (t_mapping_speaker_district):Speaker 与 District 的多对多
| 字段名 | 类型 | 说明 |
|---|---|---|
| speakerId | Integer | @Id |
| districtId | Integer | @Id |
MappingSpeakerPlace (t_mapping_speaker_place):Speaker 与 Territory 的关联
| 字段名 | 类型 | 说明 |
|---|---|---|
| speakerTerritoryId | Integer | @Id, 主键 |
| speakerId | Integer | Speaker ID |
| refId | Integer | Territory 引用 ID |
MappingPortalUserSpeaker (t_mapping_portal_user_speaker):Speaker 与 UnifiedUser 的关联
| 字段名 | 类型 | 说明 |
|---|---|---|
| portalUserId | Integer | 历史字段 |
| speakerId | Integer | Speaker ID |
| userId | Integer | UnifiedUser ID |
MappingContractDocument (t_mapping_contract_document):Contract 与 Document 关联
| 字段名 | 类型 | 说明 |
|---|---|---|
| contractId | Integer | @Id |
| documentId | Integer | @Id |
MappingContractProductProgramType (t_mapping_contract_product_program_type):合同的产品+项目类型关联
| 字段名 | 类型 | 说明 |
|---|---|---|
| contractId | Integer | @Id |
| productId | Integer | @Id |
| programTypeId | Integer | @Id |
| serviceTypeId | Integer | 服务类型 ID |
2.2 Table Relationships (ER Diagram - ASCII)
+-------------------+
| t_product |
| productId (PK) |
+-------------------+
|
+----------------------+----------------------+
| | |
v v v
+-------------------+ +-------------------+ +-------------------+
| t_speaker | | t_topic | | t_content |
| speakerId (PK) | | topicId (PK) | | id (PK) |
| companyId (FK) | | productId (FK) | | productId (FK) |
| regionId (FK) | +-------------------+ | companyId (FK) |
| districtId (FK) | | | | fileId (FK) |
+-------------------+ | | +-------------------+
| | | | | | | | |
| | | | | | | | |
| | | | | +------+ +-------+ | |
| | | | | | | | |
| | | | v v v v v
| | | | +------------------------+ +-----------+ +-----------+
| | | | |t_mapping_topic_speaker | |t_content | |t_slide |
| | | | | topicId + speakerId (PK)| | _topic | | _detail |
| | | | | status, trainnedDate | +-----------+ | contentId |
| | | | +------------------------+ +-----------+
| | | | |
| | | v |
| | | +-------------------+ |
| | | |t_speaker_contract | +--------------------------+
| | | | contractId (PK) | |
| | | | speakerId (FK) | v
| | | | templateId (FK) | +--------------------+
| | | +-------------------+ | t_speaker_slide |
| | | | | | id (PK) |
| | | | | | contentId (FK) |
| | | v v | speakerId (FK) |
| | | +--------+ +--------+ | fileId (FK) |
| | | |mapping | |mapping | +--------------------+
| | | |contract | |contract| |
| | | |document | |product | v
| | | +--------+ |progtype| +-------------------+
| | | +--------+ | t_slide_insertion |
| | | | speakerSlideId FK |
| | v | detailId FK |
| | +-------------------+ | contentId FK |
| | |t_mapping_speaker | +-------------------+
| | | _group |
| | | speakerId+groupId |
| | +-------------------+
| | |
| | v
| | +-------------------+
| | | t_speaker_group |
| | | groupId (PK) |
| | +-------------------+
| |
| v
| +------------------------+
| |t_mapping_speaker_place |
| | speakerTerritoryId (PK)|
| | speakerId (FK) |
| +------------------------+
|
v
+----------------------------+ +-------------------+
|t_mapping_portal_user_speaker| | t_expense |
| speakerId, userId |-------->| expenseId (PK) |
+----------------------------+ | meetingRequestId |
+-------------------+
|
+------+------+
| |
v v
+-----------+ +-----------+
|t_expense | |t_expense |
| _item | | _history |
+-----------+ +-----------+
+-------------------+
| t_alert |
| id (PK) |
| speakerId (FK) |
| companyId (FK) |
+-------------------+
+-------------------+
|t_speaker_training |
| id (PK) |
| speakerId (FK) |
| contentId (FK) |
+-------------------+2.3 Data Model Issues
DM-1: Speaker 表严重膨胀(88 个字段)
- 文件:
Speaker.java:24-397 - 问题:Speaker 表包含了至少 5 组应独立的数据:
- 基本信息(姓名、联系方式)
- 办公地址、家庭地址、W9 地址、NPI 注册地址(4 组地址,20+ 个字段)
- 合同相关字段(contractStartDate, honorariaCap 等 - 已由 SpeakerContract 管理但未从 Speaker 中移除)
- 提名相关字段(nominationStatus, nominationNote, category, caregiver 等)
- 密码/认证字段(password, passwordQuestion, passwordAnswer - 已废弃但未移除)
- 助理信息(assistantName, assistantEmail, assistantPhone)
DM-2: 字段冗余严重
speakerName与speakername两个字段(Speaker.java:36,186),只有大小写命名差异stateOfMedicalLicense与licenseState语义重复(Speaker.java:159,255)honorariaCap,contractStartDate,contractExpirationDate在 Speaker 和 SpeakerContract 中均存在- SpeakerContract 中
dinnerMeetingHonoraria与dinnerMtgHonoraria是相同概念的两个字段(SpeakerContract.java:71-84) localAdvisoryBoardHonoraria与localAdBoardHonoraria也是冗余(SpeakerContract.java:65-76)
DM-3: 布尔值使用 Integer 而非 Boolean
travelCompanionRequired,handicappedAccessible,connectedToSource等使用 Integer(0/1) 而非 Boolean 类型(Speaker.java:313,319,331)- SpeakerSlide, SpeakerGroup 的
delFlag也是 Integer 而非 Boolean
DM-4: MappingTopicSpeaker 拼写错误
trainnedDate字段拼写错误,应为trainedDate(MappingTopicSpeaker.java:33)
DM-5: 培训状态数据存储在两个地方
t_speaker_training表存储 Speaker 对 Content(Module)的培训状态t_mapping_topic_speaker表存储 Speaker 对 Topic 的培训状态- 两者概念上有重叠但结构不一致
DM-6: 密码字段残留
- Speaker 实体仍保留
password,passwordQuestion,passwordAnswer字段(Speaker.java:177-184),但实际认证已迁移到 UnifiedUser 系统 - 这些是敏感数据字段,应该被移除
DM-7: MappingPortalUserSpeaker 缺少主键定义
MappingPortalUserSpeaker.java实体没有 @Id 注解(MappingPortalUserSpeaker.java:7-16),无明确主键
DM-8: 日期类型字段存为 String
stateLicenseExpDate使用 String 类型而非 Date(Speaker.java:258)
3. Business Flow Analysis
3.1 Core Business Flows
3.1.1 Speaker Onboarding/Certification Flow
+------------------+ +-----------------+ +------------------+
| Sales Rep | | Admin/Planner | | Speaker |
| Nominates |---->| Reviews |---->| Account Created |
| Speaker | | Nomination | | via Portal |
+------------------+ +-----------------+ +------------------+
| |
v |
+-----------------------+ |
| Status: Nomination | |
| (nominationStatus) | |
+-----------------------+ |
| |
| Approve |
v |
+------------------------+ |
| Status: Contract | |
| Creation Pending (3) | |
+------------------------+ |
| |
Contract Created |
| |
v |
+------------------------+ |
| Status: Contract | |
| Signature Pending (4) | |
+------------------------+ |
| |
Speaker Signs Contract <----------+
|
v
+------------------------+
| Status: W9 Pending (2) |
+------------------------+
|
W9 Submitted
|
v
+------------------------+
| Status: Active (1) |
+------------------------+
|
+----------+----------+
| |
Topic Training Content Training
| |
v v
+----------------+ +--------------------+
| MappingTopic | | SpeakerTraining |
| Speaker | | (Content modules) |
+----------------+ +--------------------+
| |
+----------+----------+
|
Training Complete
|
v
+------------------------+
| Speaker Certified |
| (can be selected for |
| meetings) |
+------------------------+3.1.2 Contract Lifecycle
+-----------+ +------------------+ +-----------------+
| Create |---->| Not Signed (0) |---->| Speaker Signs |
| Contract | | (via Planner) | | (signContract) |
+-----------+ +------------------+ +-----------------+
| |
+------+------+ |
| | v
Download Update +-----------+
Contract Contract | Signed(1) |
(docx/pdf) (PUT) +-----------+
|
Download Signed
PDF (saved copy)
|
+-----v------+
| Resign |
| (re-save |
| PDF only) |
+------------+
Contract Structure:
+--------------------------------------------------+
| SpeakerContract |
| - Multiple Honoraria Fields (15+ BigDecimal) |
| - customFields (JSONB for dynamic honoraria) |
| - programQuantityLimit (optional cap) |
| - includeTeInHonorariaCalculation |
+--------------------------------------------------+
| |
v v
+-------------------+ +---------------------------+
| MappingContract | | MappingContractProduct |
| Document | | ProgramType |
| (attached docs) | | (eligible program types) |
+-------------------+ +---------------------------+3.1.3 Presentation Approval & Building Flow
Admin/Planner Side:
+------------------+ +-------------------+ +------------------+
| Upload PPT File |---->| Create Content |---->| Set Slide Detail |
| (via FileService)| | (type=0,sub=0) | | + Insertion Pts |
+------------------+ | = Core Present. | +------------------+
+-------------------+ |
| |
Upload Modules Set Allowed
(type=0,sub=1/2) Modules/Cases
| |
v v
+-------------------+ +----------------+
| Module/CaseStudy | | MappingCore |
| Content records | | Module |
+-------------------+ +----------------+
|
Assign to Speaker Groups |
Assign Topics |
Set Expiration Date |
|
Speaker Side: |
+-------------------+ +-------------------+ |
| View Available |<----| Content filtered |<-----------+
| Presentations | | by SpeakerGroup |
+-------------------+ +-------------------+
|
v
+-------------------+ +-------------------+
| Create Personal |---->| Select Modules |
| Presentation | | for Insertion Pts |
| (SpeakerSlide) | +-------------------+
+-------------------+ |
| |
v v
+-------------------+ +-------------------+
| Save SlideInser- | | Download PPTX |
| tion records | | (Aspose Slides) |
+-------------------+ | Merge field subst.|
| Write protection |
+-------------------+3.2 Validation Rules
Speaker 创建/更新验证:
SpeakerService.java:414- 邮箱唯一性检查(ILIKE 忽略大小写,scoped by companyId)SpeakerService.java:460-463- 更新时检查新邮箱是否已存在SpeakerService.java:830-834- Speaker 角色用户只能访问自己的数据(checkSpeakerPermission)
合同验证:
SpeakerContractService.java:186- 签署合同时检查 Speaker 身份所有权(checkOwnerPermission)SpeakerContractService.java:165-169- 如果未启用 programQuantityLimit,清空相关字段
费用验证:
- 费用状态流转:Draft(0) -> Pending(3) -> Approved(1) / Rejected(2)
演示文稿验证:
ContentService.java:871-885- 下载时检查被插入的 Module 是否仍可用(delFlag 检查)ContentService.java:685-688- 检查 Speaker 对演示文稿的权限
Speaker 搜索验证:
SpeakerService.java:176-178- 根据 Product 配置决定是否启用 Training 检查SpeakerService.java:247-255- programQuantityLimit 超限时将 balance 设为 -1 禁用 Speaker
3.3 Business Logic Issues
BL-1: 报酬计算逻辑分散且复杂
- 文件:
SpeakerService.java:227-258 - 问题:在 findSpeakers 中直接计算每个 Speaker 的 balance,涉及 honorariaCap、meetingHonoraria、speakerExpense、includeTeInHonorariaCalculation 等多个维度,逻辑分散在 Controller 调用的 Service 中,无独立的报酬计算领域服务
BL-2: 密码重置使用 Guava Cache
- 文件:
SpeakerService.java:133-134,136-137 - 问题:密码重置 token 和激活码存储在 JVM 内存 Cache 中,在多实例部署或服务重启时会丢失
BL-3: 签署合同时存在两套 API
SpeakerContractController.java:93-96路径v1/contracts/{id}/sign(管理员用)SecureSpeakerContractController.java:40-43路径v1/speakers/{speakerId}/contracts/{id}/sign(Speaker 用,带 @PreAuthorize)- 两个 API 调用同一个
signContract()方法,但权限检查逻辑不一致:SecureSpeakerContractController 有 @PreAuthorize 注解但 SpeakerContractController 没有
BL-4: 演示文稿生成直接操作文件系统
- 文件:
ContentService.java:896-957 - 问题:使用 Aspose Slides 直接读写文件系统来合并 PPT,没有异步处理或缓存策略,可能在高并发下阻塞线程
BL-5: Speaker 删除是硬删除
- 文件:
SpeakerService.java:503 - 问题:
deleteSpeaker调用deleteByPrimaryKey执行硬删除,而不是软删除(Speaker 表有 deleted 字段但未使用)。关联的 User 也会被删除角色和公司绑定
BL-6: 合同报酬字段硬编码
- 文件:
SpeakerContract.java:41-102 - 问题:SpeakerContract 有 15+ 个固定的 honoraria 字段(localHonoraria, travelHonoraria, dinnerMeetingHonoraria 等),当客户需要不同的报酬结构时只能通过
customFieldsJSONB 扩展,导致部分数据在固定列、部分在 JSONB 中
4. API Inventory
4.1 REST Endpoints Table
4.1.1 SpeakerController (v1/speakers)
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | /v1/speakers | SpeakerQuery (speakerName, groupId, specialty, state, status, credential, speakerLevel) | - | PageResult<SpeakersResponse> | 获取 Speaker 列表 |
| GET | /v1/speakers/nominations | NominationQuery | - | PageResult<NominationSpeakersResponse> | 获取提名 Speaker 列表 |
| GET | /v1/speakers/search | FindSpeakerQuery (productId, topicId, meetingDate 等) | - | PageResult<FindSpeakersResponse> | 搜索 Speaker(含报酬余额计算) |
| GET | /v1/speakers/download | SpeakerQuery | - | Excel 下载 | 下载 Speaker 列表 Excel |
| POST | /v1/speakers | - | SpeakerProfile | void (201) | 创建 Speaker |
| PUT | /v1/speakers/ | id | SpeakerProfile | SpeakerProfile | 更新 Speaker |
| GET | /v1/speakers/ | id | - | SpeakerProfile | 获取 Speaker 详情 |
| DELETE | /v1/speakers/ | id | - | void | 删除 Speaker(硬删除) |
| GET | /v1/speakers/{id}/regiondistrict | id | - | RegionDistrictsDetail | 获取 Speaker 区域地区信息 |
| PUT | /v1/speakers/nominations/{id}/status | id, status | - | void | 更新提名状态 |
| PUT | /v1/speakers/{id}/status | id, status | - | void | 更新 Speaker 状态 |
| POST | /v1/speakers/login | - | SpeakerLoginRequest | AuthInfo<SpeakerInfo> | Speaker 登录 |
| PUT | /v1/speakers/{speakerId}/account | speakerId | SetSpeakerAccountRequest | User | 设置 Speaker 账户 |
| POST | /v1/speakers/{id}/upload | id, uploadFile | MultipartFile | GetFileResponse | 上传头像 |
| POST | /v1/speakers/{id}/video | id, uploadFile | MultipartFile | GetFileResponse | 上传视频 |
| GET | /v1/speakers/forget-password | userId | - | void | 忘记密码(发送邮件) |
| PUT | /v1/speakers/reset-password | - | ResetSpeakerPasswordRequest | void | 重置密码 |
| POST | /v1/speakers/upload | uploadFile | MultipartFile | void | 批量上传 Speaker(Excel) |
| GET | /v1/speakers/activation-code | - | void | 获取激活码 | |
| POST | /v1/speakers/activation-code/validation | - | ValidateActivationCodeRequest | void | 验证激活码 |
| POST | /v1/speakers/signup | - | SignUpRequest | void | Speaker 自助注册 |
| GET | /v1/speakers/{speakerId}/image/download | speakerId | - | ResponseEntity (file) | 下载 Speaker 头像 |
4.1.2 SpeakerContractController (v1/contracts) - 管理端
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | /v1/contracts | SpeakerContractQuery | - | PageResult<ListContractResponse> | 合同列表 |
| POST | /v1/contracts | - | SpeakerContractRequest | SpeakerContractResponse | 创建合同 |
| PUT | /v1/contracts/ | id | SpeakerContractRequest | SpeakerContractResponse | 更新合同 |
| GET | /v1/contracts/ | id | - | SpeakerContractResponse | 获取合同详情 |
| DELETE | /v1/contracts/ | id | - | void | 删除合同 |
| PUT | /v1/contracts/regions | - | ContractRegionsDTO | Speaker | 更新合同区域和地区 |
| GET | /v1/contracts/download | SpeakerContractQuery | - | Excel 下载 | 下载合同列表 |
| PUT | /v1/contracts/{id}/sign | id | - | SpeakerContractResponse | 签署合同 |
| GET | /v1/contracts/{id}/file | id, fileFormat | - | docx/pdf 下载 | 获取合同文件 |
| GET | /v1/contracts/{id}/signedFile | id | - | ResponseEntity (PDF) | 获取已签署合同 |
| GET | /v1/contracts/resign | contractIds (逗号分隔) | - | void | 批量重新签署 |
4.1.3 SecureSpeakerContractController (v1/speakers/{speakerId}/contracts) - Speaker 端
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| PUT | /v1/speakers/{speakerId}/contracts/{id}/sign | speakerId, id | - | SpeakerContractResponse | Speaker 签署合同 |
| GET | /v1/speakers/{speakerId}/contracts/{id}/file | id, fileFormat | - | 文件下载 | 获取合同文件 |
| GET | /v1/speakers/{speakerId}/contracts/{id}/signedFile | id | - | ResponseEntity (PDF) | 获取已签署合同 |
4.1.4 SpeakerPresentationController (v1/speakers/{speakerId}/presentations)
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | /v1/speakers/{speakerId}/presentations | speakerId | - | List<PersonalPresentationList> | 个人演示文稿列表 |
| GET | /v1/speakers/{speakerId}/presentations/modules | speakerId | - | List<ListContentsResponse> | 可用 Module 列表 |
| GET | /v1/speakers/{speakerId}/presentations/ | id | - | PresentationProfile | 个人演示文稿详情 |
| DELETE | /v1/speakers/{speakerId}/presentations/ | id | - | void (204) | 删除个人演示文稿 |
| GET | /v1/speakers/{speakerId}/presentations/{id}/download | id, meetingRequestId | - | 文件下载 | 下载演示文稿 |
| GET | /v1/speakers/{speakerId}/presentations/download | meetingRequestId, fileId | - | 文件下载 | 下载会议演示文稿 |
| POST | /v1/speakers/{speakerId}/presentations | speakerId | PresentationSlide | PresentationProfile | 创建个人演示文稿 |
4.1.5 ContentController (v1/contents) - 内容库管理
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | /v1/contents | ContentQuery | - | PageResult<ListContentsResponse> | 内容列表 |
| GET | /v1/contents/modules | - | - | List<ListContentsResponse> | Module 列表 |
| POST | /v1/contents | - | CreateContentRequest | GetContentResponse (201) | 创建内容 |
| PUT | /v1/contents/{id}/maximums | id | PresentMaximumRequest | void | 设置演示文稿最大值 |
| GET | /v1/contents/ | id | - | GetContentResponse | 获取内容详情 |
| GET | /v1/contents/presentations/ | id | - | PresentationProfile | 获取演示文稿 |
| GET | /v1/contents/presentations/{id}/download | id | - | 文件下载 | 下载演示文稿 |
| DELETE | /v1/contents/ | id | - | void (204) | 删除内容(软删除) |
| PUT | /v1/contents/{id}/description | id | UpdateContentRequest | GetContentResponse | 更新描述 |
| PUT | /v1/contents/{id}/url | id | UpdateURLRequest | GetContentResponse | 更新 URL |
| PUT | /v1/contents/{id}/topics | id | UpdateContentRequest | GetContentResponse | 设置 Topic |
| PUT | /v1/contents/{id}/expiration | id | ContentExpirationRequest | void | 设置过期日期 |
| PUT | /v1/contents/groups | - | SetContentGroupRequest | void | 设置内容用户组 |
| DELETE | /v1/contents/groups | - | SetContentGroupRequest | void | 移除内容用户组 |
| POST | /v1/contents/{id}/modules | id | PresentationDTO | PresentationProfile | 设置允许的 Module |
| GET | /v1/contents/{id}/modules | id | - | List<SlideDTO> | 获取 Module 列表 |
| PUT | /v1/contents/{id}/conversion | id | ConvertModuleRequest | PresentationProfile | 类型转换 |
| PUT | /v1/contents/{id}/insertions | id | PresentationSlide | PresentationProfile | 保存插入点 |
| GET | /v1/contents/presentations/slide/ | id | - | SpeakerSlide | 获取 Slide 信息 |
4.1.6 TopicController (v1/topics 和 v1/speaker/topics)
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | /v1/topics | TopicQuery | - | PageResult<ListTopicsResponse> | Topic 列表 |
| GET | /v1/speaker/topics | TopicQuery | - | PageResult<SpeakerTopicDetail> | Speaker 的 Topic 列表 |
| PUT | /v1/speaker/{id}/topics | id | SpeakerTopicDTO | MappingTopicSpeaker | 保存/更新 Speaker-Topic 关系 |
| POST | /v1/topics | - | TopicProfile (201) | void | 创建 Topic |
| PUT | /v1/topics/ | id | TopicProfile | Topic | 更新 Topic |
| GET | /v1/topics/ | id | - | TopicInfo | 获取 Topic 详情 |
| PUT | /v1/topics/{id}/active | id | - | void | 激活 Topic |
| PUT | /v1/topics/{id}/deactive | id | - | void | 停用 Topic |
| GET | /v1/topics/{id}/presentations | id | - | List<TopicPresentation> | 获取 Topic 下的演示文稿 |
4.1.7 SpeakerGroupController (v1/speakergroups)
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | /v1/speakergroups | - | - | List<SpeakerGroupResponse> | 获取所有组 |
| POST | /v1/speakergroups | - | SpeakerGroupRequest (201) | SpeakerGroupResponse | 创建组 |
| PUT | /v1/speakergroups/ | id | SpeakerGroupRequest | SpeakerGroupResponse | 更新组 |
| DELETE | /v1/speakergroups/ | id | - | void (204) | 删除组 |
| POST | /v1/speakergroups/users | - | SpeakerToGroupRequest | void | 添加 Speaker 到组 |
| DELETE | /v1/speakergroups/users | - | SpeakerToGroupRequest | void | 从组移除 Speaker |
| PUT | /v1/speakergroups/filters | - | List<Integer> groupIds | void | 添加到搜索筛选 |
| DELETE | /v1/speakergroups/filters | - | List<Integer> groupIds | void (204) | 从搜索筛选移除 |
4.1.8 TrainingController (v1/training)
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | /v1/training | TrainingQuery | - | PageResult<ListTrainingResponse> | 培训列表 |
| POST | /v1/training/status | - | SetTrainingStatusRequest | void | 设置培训状态 |
| GET | /v1/training/topics/download | TrainingQuery | - | Excel 下载 | 下载 Topic 培训报告 |
4.1.9 SpeakerMeetingController (v1/speakers/meetings)
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | /v1/speakers/meetings | SpeakerMeetingQuery | - | PageResult<MeetingsResponse> | Speaker 会议列表 |
| GET | /v1/speakers/meetings/ | meetingRequestId | - | SpeakerMeetingResponse | 获取会议详情 |
| GET | /v1/speakers/meetings/survey/download | - | - | Excel 下载 | 下载调查问卷 |
| GET | /v1/speakers/meetings/{meetingRequestId}/survey | meetingRequestId | - | SpeakerSurvey | 获取调查问卷 |
| PUT | /v1/speakers/meetings/{meetingRequestId}/survey | meetingRequestId | SpeakerSurveyRequest | MeetingRequest | 提交调查问卷 |
4.1.10 ExpenseController (v1/expenses)
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | /v1/expenses | ExpenseQuery | - | PageResult<ListExpensesResponse> | 费用列表 |
| GET | /v1/expenses/ | expenseId | - | GetExpenseResponse | 费用详情 |
| POST | /v1/expenses | meetingRequestId | - (201) | Expense | 创建费用 |
| PUT | /v1/expenses/ | expenseId | UpdateExpenseRequest | void | 更新费用 |
| DELETE | /v1/expenses/ | expenseId | - | void | 删除费用 |
| PUT | /v1/expenses/{expenseId}/approval | expenseId | ApproveExpenseRequest | void | 批准费用 |
| PUT | /v1/expenses/{expenseId}/rejection | expenseId, rejectReason | - | void | 拒绝费用 |
| GET | /v1/expenses/{expenseId}/histories | expenseId, status | - | List<ExpenseHistory> | 费用历史 |
| PUT | /v1/expenses/{expenseId}/submit | expenseId, amount | - | void | 提交费用 |
| POST | /v1/expenses/{expenseId}/receipts | expenseId | ExpenseReceiptRequest | void | 上传收据 |
4.1.11 ExpenseItemController (v1/expenses/items)
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | /v1/expenses/items | expenseId | - | List<ListExpenseItemsResponse> | 费用明细列表 |
| GET | /v1/expenses/items/ | itemId | - | GetExpenseItemResponse | 费用明细详情 |
| POST | /v1/expenses/items | - | CreateExpenseItemRequest (201) | void | 创建费用明细 |
| PUT | /v1/expenses/items/ | itemId | CreateExpenseItemRequest | GetExpenseItemResponse | 更新费用明细 |
| DELETE | /v1/expenses/items/ | itemId | - | void (204) | 删除费用明细 |
| POST | /v1/expenses/items/{expenseItemId}/receipts | expenseItemId | ExpenseReceiptRequest | void | 上传明细收据 |
4.1.12 ExpenseCategoryController (v1/expenses/categories)
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | /v1/expenses/categories | - | - | List<ExpenseCategory> | 费用类别列表 |
| GET | /v1/expenses/categories/ | categoryId | - | List<ExpenseCategory> | 子类别列表 |
4.1.13 AlertController (v1/alerts)
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | /v1/alerts | - | - | List<Alert> | 通知列表 |
| DELETE | /v1/alerts | - | AlertRemoveDTO | void | 删除通知 |
4.1.14 DocumentController (v1/documents)
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | /v1/documents | DocumentQuery | - | PageResult<DocumentsResponse> | 文档列表 |
| DELETE | /v1/documents/ | id, contractId | - | void | 删除文档 |
| POST | /v1/documents | - | DocumentDTO (201) | void | 创建文档 |
| PUT | /v1/documents/ | id | DocumentDTO | void | 更新文档 |
4.2 API Design Issues
API-1: 同一资源两套 Controller
SpeakerContractController(v1/contracts) 和SecureSpeakerContractController(v1/speakers/{speakerId}/contracts) 管理同一资源- 功能大量重复:签署合同、获取合同文件均有两份实现
- 应统一为一个 Controller,通过角色权限控制访问
API-2: RESTful 设计不一致
PUT /v1/topics/{id}/active和PUT /v1/topics/{id}/deactive应为PATCH /v1/topics/{id}+ 状态字段GET /v1/contracts/resign?contractIds=1,2,3使用 GET 执行修改操作(重新签署),违反 REST 语义PUT /v1/speakers/nominations/{id}/status?status=1状态通过 Query Param 传递而非 Request BodyDELETE /v1/contents/groups使用 DELETE + RequestBody,部分 HTTP 客户端不支持
API-3: 缺少版本化和统一错误处理
- Topic 的路径混用
/v1/topics和/v1/speaker/topics,没有统一的资源命名 - Alert 的 DELETE 使用 RequestBody 传 ID 列表而非路径参数
API-4: 安全问题
SpeakerContractController.java:93-96的signContract接口没有 @PreAuthorize 注解,任何认证用户都可以签署任意合同ContentController.java:178-180直接暴露 Mapper 查询结果(speakerSlideMapper.selectByPrimaryKey),无权限检查- Speaker 登录接口(
POST /v1/speakers/login)直接返回 token,而 companyId 来自 HTTP Header(Pharmagin-Company-ID),存在伪造风险
API-5: 返回值不统一
- 部分创建接口返回 void(SpeakerController.save),部分返回实体(SpeakerContractController.createContract)
- 部分列表接口返回 PageResult,部分返回 List
5. Frontend Analysis
5.1 Pages & Components
5.1.1 Speakerview (pharmagin-speakerview/legacy/src/)
这是面向 Speaker 和 Speaker Admin 的前端应用。路由文件:routes.js
核心页面路由:
| 路由 | 页面名称 | Container/Component | 说明 |
|---|---|---|---|
/speakers | Speaker 列表 | containers/Speakers | Speaker 管理列表页 |
/speakers/:speakerId/profile | Speaker 详情 | containers/SpeakerProfile | Speaker 个人资料页 |
/speakers/:speakerId/documents | Speaker 文档 | containers/SpeakerDocuments | 文档管理页 |
/speakers/:speakerId/contracts | Speaker 合同 | containers/SpeakerContracts | 合同列表和详情 |
/speakers/:speakerId/training | Speaker 培训 | containers/SpeakerModules | 培训模块状态 |
/speakers/:speakerId/scheduled-programs | 已安排的项目 | containers/SpeakerScheduledMeetings | 即将参加的会议 |
/speakers/:speakerId/completed-programs | 已完成的项目 | containers/SpeakerCompletedMeetings | 已完成的会议 |
/speakers/:speakerId/expenses | 费用报告 | containers/SpeakerExpenseReport | 费用管理 |
/contracts | 合同管理 | containers/Contracts | 合同管理页(Admin) |
/content | 内容库 | containers/ContentLibrary | 内容/演示文稿管理 |
/presentation/:slideId/:accessToken | 演示文稿详情 | containers/Presentation | 演示文稿查看/编辑 |
/topics | Topic 管理 | containers/Topics | 主题管理(Admin) |
/expenses(/:expenseId) | 费用报告 | containers/ExpenseReports | 费用管理(Admin) |
/nominations | 提名管理 | containers/Nominations | 提名列表(Admin) |
/nominationDetail/:id | 提名详情 | containers/NominationDetail | 提名查看 |
/training | 培训管理 | containers/Training | 培训状态管理(Admin) |
/reports | 报告 | containers/Report | 报表 |
/admin | 管理页 | containers/Admin | 系统管理 |
/forgotPwd/:uuid | 忘记密码 | containers/ForgotPwd | 密码重置 |
核心 Components(约 40+ 个组件):
| Component | 说明 |
|---|---|
| Login | 登录页,含 ForgotPwd, SignUp 子组件 |
| ContractForm | 合同表单 |
| ContractProfile | 合同详情(含 actions, reducer, sagas) |
| ContractDistrict | 合同区域管理 |
| SpeakerDocuments | Speaker 文档上传/管理 |
| SpeakerExpenseReport | Speaker 费用报告 |
| SpeakerMeetingDetail | 会议详情 |
| SpeakerPresentations | Speaker 演示文稿选择 |
| SpeakerSurvey | 调查问卷 |
| Presentations | 演示文稿管理(Admin)含 Description, SetExpiration, SettingTopics, SettingUserGroup 子模态框 |
| Slide | 幻灯片管理,含 DragBlock, DropBlock, SlideDetail 子组件 |
| OtherContents | 其他内容管理 |
| TopicForm | Topic 表单 |
| TopicPresentation | Topic 演示文稿关联 |
| TrainingTopics | 培训 Topic 管理 |
| TrainingStatusForm | 培训状态表单 |
| ExpenseAddForm | 费用添加 |
| ExpenseApproveForm | 费用审批 |
| ExpenseRejectForm | 费用拒绝 |
| ExpenseItem | 费用明细 |
| ExpenseMileageReimbursement | 里程报销 |
| ExpenseReportItems | 费用报告明细(含打印功能) |
| NominationForm | 提名表单 |
| NominationInfo | 提名信息 |
| NominationProfile | 提名详情 |
| UserGroupForm | 用户组表单 |
| UserGroupList | 用户组列表 |
| UserGroupSelect | 用户组选择 |
| Templates | 合同模板管理 |
| AdministerUserAccount | 用户账户管理 |
| Alert | 通知列表 |
| ReportTrainingStatus | 培训状态报表 |
| ReportVideoViews | 视频观看报表 |
5.1.2 Plannerview (pharmagin-plannerview/legacy/src/)
Speaker 相关组件主要出现在会议创建和管理流程中:
| Component/Container | 说明 |
|---|---|
| MTProfile/meetingDetail.js | 会议详情中显示已选 Speaker 信息 |
| MTProfile/meetingForm.js | 会议创建表单中的 Speaker 选择 |
| MTProfile/SpeakerCriteriaEditForm.js | Speaker 选择标准编辑 |
| MTProfile/SpeakerRandomizationForm.js | Speaker 随机化表单 |
| Common/actions.js, sagas.js | 共享的 Speaker 数据获取 actions |
| Compliance/AggregateSpendByAttendeesProfile.js | 合规 - Speaker 支出汇总 |
5.1.3 Salesview (pharmagin-salesview/src/)
Speaker 相关功能主要用于 Sales Rep 查看和选择 Speaker:
| Page/Component | 说明 |
|---|---|
| pages/Speakers/index.js | Speaker 列表页 |
| pages/Speakers/filters.js | Speaker 搜索筛选 |
| pages/Speakers/constants.js | Speaker 相关常量 |
| pages/Speakers/reducer.js | Speaker Redux 状态 |
| pages/Speakers/saga.js | Speaker 数据获取 |
| pages/Speakers/selectors.js | Speaker 状态选择器 |
| pages/Programs/Form/SelectSpeakers/ | 会议创建中的 Speaker 选择 |
| pages/Programs/SpeakerCandidates/ | Speaker 候选人列表 |
| pages/Programs/Modal/SpeakerCriteriaModal.js | Speaker 选择标准模态框 |
5.2 Redux State Structure
Speakerview 的 Redux Store 结构:
{
app: {
loginStatus: '',
accessToken: '',
user: {}, // SpeakerInfo
roleId: '',
alertList: [],
alertLoading: true,
expenseInfo: {},
surveyInfo: {},
module: {},
presentation: {},
removeAlertsSuccess: false,
meetingInfo: {},
surveySuccess: false,
config: {}, // CompanyConfiguration
authorities: [],
products: [],
},
speakers: { ... }, // Speaker 列表页状态
speakerDetail: { ... }, // Speaker 详情页状态(共享 reducer)
contracts: { ... }, // 合同管理状态
contractInfo: { ... }, // 合同详情状态
content: { ... }, // 内容库状态
topics: { ... }, // Topic 管理状态
training: { ... }, // 培训管理状态
expenseReports: { ... }, // 费用管理状态
nominations: { ... }, // 提名管理状态
nominationDetail: { ... }, // 提名详情状态
forgotPwd: { ... }, // 密码重置状态
reports: { ... }, // 报表状态
admin: { ... }, // 管理页状态
}5.3 Frontend Issues
FE-1: 过时的技术栈
- 使用 React Router 3.x(
System.import动态加载,已过时的路由 API) - Ant Design 3.x(已停止维护)
- Redux 使用
Object.assign而非 immer 或 toolkit - 路由使用
getComponent(nextState, cb)回调模式而非现代 hooks
FE-2: 代码分割和 Reducer 注入不一致
routes.js:110-139中 SpeakerContracts 路由同时注入了 4 个 reducer(speakerDetail, contracts, admin, contractInfo),加载了不必要的模块- 部分 Container 使用 sagas 注入,部分不注入(如 SpeakerProfile 页面没有注入 sagas)
FE-3: Speaker 详情页面共享同一个 reducer
speakerDetailreducer 被 profile, documents, contracts, training, meetings, expenses 等 6 个页面共享- 导致页面间状态可能互相污染
FE-4: 认证流程安全性问题
- 登录成功后将 accessToken 存储在 Redux state 中
- CompanyId 通过 HTTP Header
Pharmagin-Company-ID传递,前端可被篡改 - 密码重置使用 UUID 作为 token,通过 URL 明文传递
FE-5: XSS 风险
App/index.js:215使用dangerouslySetInnerHTML渲染config.headerText,如果配置数据被篡改可能导致 XSS
6. Problem Summary
6.1 Critical Issues (must fix in rewrite)
| ID | 问题 | 影响 | 文件位置 |
|---|---|---|---|
| C-1 | Speaker 表 88 个字段,严重违反单一职责 | 维护困难,性能差,查询效率低 | Speaker.java |
| C-2 | Speaker 实体存储密码明文字段 | 安全风险 | Speaker.java:177-184 |
| C-3 | 密码重置 token 存在 JVM 内存 | 多实例不可用,重启丢失 | SpeakerService.java:133-137 |
| C-4 | 合同签署 API 缺少权限控制 | 任何认证用户可签署任意合同 | SpeakerContractController.java:93-96 |
| C-5 | Speaker 删除使用硬删除 | 数据丢失不可恢复 | SpeakerService.java:503 |
| C-6 | 合同报酬字段硬编码 15+ 个 | 扩展性差,大量冗余字段 | SpeakerContract.java:41-102 |
| C-7 | 过时的前端技术栈 | 维护成本高,安全隐患 | speakerview 整体 |
| C-8 | dangerouslySetInnerHTML XSS 风险 | 安全漏洞 | App/index.js:215 |
| C-9 | 两套 Contract Controller 功能重叠 | 代码重复,权限模型混乱 | SpeakerContractController + SecureSpeakerContractController |
6.2 Design Defects (should improve)
| ID | 问题 | 影响 | 文件位置 |
|---|---|---|---|
| D-1 | 培训状态存储在两个不同的表中 | 数据不一致风险 | SpeakerTraining.java vs MappingTopicSpeaker.java |
| D-2 | 报酬计算逻辑分散在 findSpeakers 方法中 | 难以测试和维护 | SpeakerService.java:165-284 |
| D-3 | 演示文稿生成同步阻塞 | 高并发下性能问题 | ContentService.java:896-957 |
| D-4 | API 设计不符合 REST 规范 | 客户端使用困难 | 多处 Controller |
| D-5 | 字段冗余(speakerName/speakername、多个重复报酬字段) | 数据一致性问题 | Speaker.java:36,186; SpeakerContract.java:71-84 |
| D-6 | speakerDetail reducer 被 6 个页面共享 | 状态污染 | speakerview routes.js |
| D-7 | Boolean 字段使用 Integer 表示 | 代码可读性差 | Speaker.java:313,319,331 |
| D-8 | 缺少审计日志和变更追踪 | 合规风险 | Speaker, Contract 相关 Service |
| D-9 | Content/Presentation 领域模型混淆 | Content 既是培训材料也是演示文稿,由 type 字段区分 | Content.java, ContentService.java |
| D-10 | 邮箱唯一性仅限 company 范围内 | 跨 company 数据不隔离 | SpeakerManager.java:112-119 |
6.3 Technical Debt (nice to have)
| ID | 问题 | 影响 | 文件位置 |
|---|---|---|---|
| T-1 | SpeakerSlide 未使用 Lombok | 代码风格不统一 | SpeakerSlide.java |
| T-2 | SpeakerGroup 未使用 Lombok | 代码风格不统一 | SpeakerGroup.java |
| T-3 | MappingTopicSpeaker 中 trainnedDate 拼写错误 | 代码质量 | MappingTopicSpeaker.java:33 |
| T-4 | stateLicenseExpDate 使用 String 而非 Date | 类型安全 | Speaker.java:258 |
| T-5 | 合同下载使用 GET 请求传逗号分隔 ID | API 设计 | SpeakerContractController.java:117-123 |
| T-6 | ContentService 中硬编码 Aspose 密码逻辑 | 安全性 | ContentService.java:136,949 |
| T-7 | AlertType 枚举中 type=4 缺失 | 枚举值不连续 | AlertType.java |
| T-8 | SpeakerService 方法过多(约 30 个公开方法) | 类职责过重 | SpeakerService.java |
| T-9 | 前端使用 System.import 而非标准 dynamic import | 过时语法 | routes.js |
| T-10 | Controller 层直接注入 Mapper(绕过 Service) | 违反分层架构 | ExpenseCategoryController.java:23, ContentController.java:58 |
7. Rewrite Recommendations
7.1 数据模型重构
拆分 Speaker 表:
speakers- 核心身份信息(姓名、后缀、NPI、类型、级别、状态)speaker_addresses- 地址信息(类型枚举:Office/Home/W9/NPI),一对多speaker_contacts- 联系方式(电话、邮箱、传真)speaker_credentials- 医疗执照和认证信息speaker_preferences- 偏好设置(差旅、语言、可用性)speaker_nominations- 提名信息独立表(包含 patient/caregiver 相关字段)- 移除密码相关字段,统一使用 UnifiedUser 认证
重构合同报酬模型:
- 使用
contract_honoraria_items表替代 15+ 个硬编码字段 - 结构:
{contractId, honorariaType, amount} honorariaType使用枚举或字典表管理- 移除
customFieldsJSONB,统一走结构化存储
- 使用
统一培训状态模型:
- 合并
t_speaker_training和t_mapping_topic_speaker为统一的培训记录表 - 结构:
{speakerId, trainingType(TOPIC/MODULE), referenceId, status, trainedDate, expiredDate}
- 合并
7.2 API 重构
- 统一 Contract API 为
v1/speakers/{speakerId}/contracts,通过角色权限控制 Admin 和 Speaker 的访问级别 - 遵循 REST 规范:状态变更使用 PATCH,避免 GET 执行修改操作
- 引入 API 版本化策略(v2 命名空间)
- 统一返回值格式:所有创建操作返回创建的资源,所有列表使用统一的分页结构
7.3 业务逻辑重构
- 抽取报酬计算服务
HonorariaCalculationService,统一管理 balance 计算逻辑 - 演示文稿生成异步化:使用消息队列或异步任务处理 PPT 合并
- 密码重置 token 存储到数据库或 Redis,支持多实例部署
- Speaker 删除改为软删除,保留数据审计能力
- 引入事件驱动:Speaker 状态变更、合同签署等关键操作发布领域事件
7.4 前端重构
- 升级技术栈:React 18+、React Router 6+、Ant Design 5.x、Redux Toolkit / Zustand
- 拆分 speakerDetail reducer:每个页面使用独立的状态切片
- 移除 dangerouslySetInnerHTML:使用 sanitize-html 或 DOMPurify
- 现代化代码分割:使用 React.lazy + Suspense 替代 System.import
- 类型安全:引入 TypeScript
7.5 安全改进
- 为所有写操作添加基于角色的访问控制(RBAC)
- 移除 Speaker 实体中的密码相关字段
- CompanyId 从 JWT token 中提取而非 HTTP Header
- 合同签署流程增加电子签名验证
- 敏感字段(SSN/Tax ID)加密存储