Attendee & Registration Domain - Deep Dive Analysis
1. Domain Overview
1.1 领域职责描述
Attendee & Registration 领域是制药行业 Speaker Program 平台的核心业务域,负责管理参会人员(Attendee)从添加、邀请、注册、签到到合规审查的完整生命周期。该领域涵盖以下核心职责:
- 参会人员管理(Attendee Management):创建、编辑、删除参会人员记录,支持 Excel/CSV 批量导入、跨 Meeting 复制参会人员
- 注册工作流(Registration Workflow):管理从 Pending -> Invited -> Accepted -> Registered -> Declined/Canceled 的状态流转
- HCP 合规调和(Reconciliation):将参会人员与 NPI 数据库、Target 列表或 Salesforce CRM 进行身份匹配,确保 HCP(Healthcare Professional)的合规追踪
- 出席频率检查(Frequency Check):控制 HCP 在特定时间范围内参加 Speaker Program 的次数,防止合规风险
- 签到与签名(Check-In & Signature):现场签到、电子签名采集、地理位置记录
- 住宿管理(Sleeping Room Management):酒店房间预订、房型库存管理、入住/退房日期管理
- 注册站点(Registration Site / Site Builder):通过可视化模板配置面向公众的注册页面,包含自定义字段、页面布局和品牌样式
- Transfer of Value(TOV):追踪和分配对参会人员的价值转移金额
- 邀请通知(Invitation & Notification):邮件邀请发送、邀请状态跟踪(已读、已访问)
- 问卷调查(Attendee Survey):参会人员满意度调查的创建和响应收集
1.2 涉及的后端模块和包
| 模块路径 | 说明 |
|---|---|
modules/v1/site/ | 核心模块:Attendee CRUD、AttendeeType、SiteBuilder、SleepingRoom、Contact、TOV、RegistrationWorkflow、ChangeLog |
modules/v1/registration/ | 注册流程模块:注册表单获取与提交、批量注册、邮件邀请、频率检查、接受/拒绝注册 |
modules/v1/npi/ | NPI 数据查询模块:从 npi schema 查询 NPPES 国家医疗人员标识数据 |
modules/v1/target/ | Target 列表模块:客户上传的 HCP 目标列表查询 |
common/persistence/entity/ | 15 个核心 Entity |
plus/resolver/ | FrequencyCheckResolver - Plus 包中的频率检查解析器 |
modules/v1/survey/ | 问卷调查模块(AttendeeSurvey 相关) |
2. Data Model Analysis
2.1 Entity Overview Table
2.1.1 Attendee (t_attendee)
主表,每条记录代表一个 Meeting 中的一个参会人员。
| 字段名 | Java 类型 | 数据库列名 | 注解 | 说明 |
|---|---|---|---|---|
| attendeeId | String | attendee_id | @Id, @GeneratedValue(generator="JDBC") | UUID 主键 |
| meetingId | Integer | meeting_id | @Column | 关联 Meeting(注册站点) |
| attendeeTypeId | Integer | attendee_type_id | @Column | 关联 AttendeeType |
| firstName | String | first_name | @Column | 名 |
| lastName | String | last_name | @Column | 姓 |
| middleName | String | middle_name | @Column | 中间名 |
| String | @Column | 邮箱 | ||
| hcpStatus | Integer | hcp_status | @Column | HCP 状态:1=Not Reconciled, 2=Reconciled, 3=Not HCP |
| phone | String | phone | @Column | 电话 |
| officeAddress1 | String | office_address1 | @Column | 办公地址1 |
| officeAddress2 | String | office_address2 | @Column | 办公地址2 |
| city | String | city | @Column | 城市 |
| state | String | state | @Column | 州 |
| zipCode | String | zip_code | @Column | 邮编 |
| specialty | String | specialty | @Column | 专科 |
| affiliation | String | affiliation | @Column | 从属机构 |
| licenseNumber | String | license_number | @Column | 执照号 |
| licenseState | String | license_state | @Column | 执照所在州 |
| npi | String | npi | @Column | NPI 号码 |
| reconciliationType | Integer | reconciliation_type | @Column | 调和类型:1=NPI, 2=Target, 3=Salesforce |
| meNumber | String | me_number | @Column | ME 号码 |
| occupation | String | occupation | @Column | 职业 / attendee type |
| credentials | String | credentials | @Column | 资质证书 |
| governmentEmployee | String | government_employee | @Column | 是否政府雇员 |
| vermontLicense | String | vermont_license | @Column | 佛蒙特州执照 |
| vermontLicenseNumber | String | vermont_license_number | @Column | 佛蒙特州执照号 |
| mealRequirements | String | meal_requirements | @Column | 餐饮要求 |
| allergies | String | allergies | @Column | 过敏信息 |
| note | String | note | @Column | 备注 |
| attendeeCategory | String | attendee_category | @Column | 参会人员分类(如 HCP, Pharma, Speaker) |
| salesforceContactWhoId | String | salesforce_contact_who_id | @Column | Salesforce 联系人 ID |
| salesforceEventId | String | salesforce_event_id | @Column | Salesforce 事件 ID |
| registrationStatus | Integer | registration_status | @Column | 注册状态:0=Pending, 10=Invited, 20=Accepted, 40=Registered, 50=Canceled, 60=Declined, 70=Rep Invited |
| signInStatus | Integer | sign_in_status | @Column | 签到状态:0=Default, 1=Signed In, 2=No Show, 3=Checked In |
| createdAt | Date | created_at | @Column | 创建时间(注册日期) |
| updatedAt | Date | updated_at | @Column | 最后更新时间 |
| reconciliationId | String | reconciliation_id | @Column | 调和 ID(NPI 号 / Target externalId / SF contactId) |
| declineReason | String | decline_reason | @Column | 拒绝原因 |
| transferOfValue | BigDecimal | transfer_of_value | @Column | 价值转移金额 |
| tovChangeReason | String | tov_change_reason | @Column | TOV 变更原因 |
| draftAttendeeUuid | String | draft_attendee_uuid | @Column | 草稿 UUID |
| externalId | Long | external_id | @Column | 外部 ID |
| attendeeType2 | String | attendee_type2 | @Column | 第二参会人类型 |
| reconciliationSubType | Integer | reconciliation_sub_type | @Column | 调和子类型:0=t_target, 1=t_secondary_target |
| parentId | String | parent_id | @Column | 父级 attendee_id(Guest 关联) |
| ccEmail | String | cc_email | @Column | 抄送邮箱 |
| signInTime | Date | sign_in_time | @Column | 签到时间 |
| pvExtUserId | String | pv_ext_user_id | @Column | PV 外部用户 ID(遗留字段) |
| primaryHcpId | String | primary_hcp_id | @Column | 主要 HCP ID |
| primaryHcpName | String | primary_hcp_name | @Column | 主要 HCP 姓名 |
| virtualProgramAttendeeInfo | Object (JSON) | virtual_program_attendee_info | @ColumnType(JdbcType.OTHER) | 虚拟会议参会信息(JSON) |
| registrationSource | Object (JSON) | registration_source | @ColumnType(JdbcType.OTHER) | 注册来源(JSON:{source, createdBy}) |
| createdBy | String | created_by | @Column | 创建者 |
| attendanceFormat | String | attendance_format | @Column | 参会格式 |
2.1.2 AttendeeType (t_attendee_type)
定义 Meeting 下的参会人员类别(如 HCP、Pharma、Speaker)。
| 字段名 | Java 类型 | 数据库列名 | 说明 |
|---|---|---|---|
| attendeeTypeId | Integer | attendee_type_id | 自增主键 (s_attendee_type) |
| meetingId | Integer | meeting_id | 关联 Meeting |
| attendeeTypeName | String | attendee_type_name | 类型名称 |
| status | Integer | status | 状态 |
| isDefault | Integer | is_default | 是否默认类型 |
| siteTemplate | String | site_template | 站点模板 |
| pageNumber | Integer | page_number | 页面数量 |
| completeContactInfoStatus | Integer | complete_contact_info_status | 联系信息完成状态 |
| completeActivityStatus | Integer | complete_activity_status | 活动完成状态 |
| completeQaStatus | Integer | complete_qa_status | QA 完成状态 |
| completeSleepingRoomStatus | Integer | complete_sleeping_room_status | 住宿完成状态 |
| attendeeManagementStatus | Integer | attendee_management_status | 管理状态 |
| siteTitle | String | site_title | 站点标题 |
| createdByWizard | Integer | created_by_wizard | 是否由向导创建 |
| isModified | Integer | is_modified | 是否已修改 |
| attendeeInfoType | Integer | attendee_info_type | 参会信息类型 |
| attendeeType | Integer | attendee_type | 参会类型(数值) |
| sequence | Integer | sequence | 排序序号 |
| attendeeCategory | String | attendee_category | 参会分类(HCP/Pharma/Speaker 等) |
| lookupEnabled | Integer | lookup_enabled | 是否启用查找 |
| addInRegistrationStatus | Integer | add_in_registration_status | 添加时的注册状态 |
| type | Short | type | 0=Primary, 1=Guest |
| externalLookupRule | Integer | external_lookup_rule | 外部查找规则:0=No lookup, 1=Primary registrant, 2=Secondary registrant |
2.1.3 AttendeeResponse (t_attendee_response)
存储参会人员对注册表单中每个字段的响应值。
| 字段名 | Java 类型 | 数据库列名 | 说明 |
|---|---|---|---|
| attendeeResponseId | Integer | attendee_response_id | 自增主键 (s_attendee_response) |
| attendeeId | String | attendee_id | 关联 Attendee |
| siteTemplateFieldId | Integer | site_template_field_id | 关联 SiteTemplateField |
| response | String | response | 响应值 |
| meetingId | Integer | meeting_id | 关联 Meeting |
| attendeeTypeId | Integer | attendee_type_id | 关联 AttendeeType |
| reportFieldId | Integer | report_field_id | 报表字段 ID |
| reportFieldLabel | String | report_field_label | 报表字段标签 |
| subResponse | String | sub_response | 子响应值(用于 Other 选项等) |
2.1.4 AttendeeSignature (t_attendee_signature)
参会人员签名记录。
| 字段名 | Java 类型 | 数据库列名 | 说明 |
|---|---|---|---|
| id | Integer | id | 自增主键 (s_attendee_signature) |
| meetingId | Integer | meeting_id | 关联 Meeting |
| signTime | Date | sign_time | 签名时间 |
| signature | String | signature | 签名字符串 |
| willPayMeal | Boolean | will_pay_meal | 是否自付餐费 |
| attendeeId | String | attendee_id | 关联 Attendee |
| longitude | Double | longitude | 经度 |
| latitude | Double | latitude | 纬度 |
| signatureData | Object (JSON) | signature_data | 签名原始数据(JSON) |
| base64String | String | base64_string | 签名 Base64 图片 |
2.1.5 AttendeeChangeLog (t_attendee_change_log)
参会人员信息变更审计日志。
| 字段名 | Java 类型 | 数据库列名 | 说明 |
|---|---|---|---|
| id | Integer | id | 自增主键 (s_attendee_change_log) |
| attendeeId | String | attendee_id | 关联 Attendee |
| fieldId | Integer | field_id | 变更字段 ID(关联 SiteTemplateField) |
| oldValue | String | old_value | 旧值 |
| newValue | String | new_value | 新值 |
| createdAt | Date | created_at | 变更时间 |
2.1.6 AttendeeComplianceQA (无 @Table 注解)
参会人员合规问答。
| 字段名 | Java 类型 | 数据库列名 | 说明 |
|---|---|---|---|
| complianceId | Integer | compliance_id | 主键 |
| attendeeId | String | attendee_id | 关联 Attendee |
| question | String | question | 合规问题 |
| answer | String | answer | 回答 |
| createdAt | Date | created_at | 创建时间 |
2.1.7 AttendeeInfoField (t_attendee_info_field)
参会信息字段定义(元数据)。
| 字段名 | Java 类型 | 数据库列名 | 说明 |
|---|---|---|---|
| fieldName | String | field_name | 字段名(主键) |
| fieldLabel | String | field_label | 字段标签 |
| sequence | Integer | sequence | 排序序号 |
2.1.8 AttendeeSurvey (t_attendee_survey)
参会人员问卷模板。
| 字段名 | Java 类型 | 数据库列名 | 说明 |
|---|---|---|---|
| id | Integer | id | 自增主键 |
| name | String | name | 问卷名称 |
| content | Object (JSON) | content | 问卷内容(JSON) |
| createdAt | Date | created_at | 创建时间 |
| createdBy | String | created_by | 创建者 |
| updatedAt | Date | updated_at | 更新时间 |
| updatedBy | String | updated_by | 更新者 |
| status | Integer | status | 状态:0=inactive, 1=active |
| delFlag | Integer | del_flag | 删除标记:0=未删除, 1=已删除 |
| activatedAt | Date | activated_at | 激活时间 |
| type | Integer | type | 类型:0=Attendee Survey, 1=Speaker Survey, 2=Sales Rep Survey |
| products | Object (JSON) | products | 适用产品/项目类型 |
2.1.9 AttendeeSurveyResponse (t_attendee_survey_response)
问卷填写响应。
| 字段名 | Java 类型 | 数据库列名 | 说明 |
|---|---|---|---|
| surveyId | Integer | survey_id | 关联 AttendeeSurvey |
| attendeeId | String | attendee_id | 关联 Attendee |
| meetingRequestId | Integer | meeting_request_id | 关联 MeetingRequest |
| response | Object (JSON) | response | 响应数据(JSON) |
| createdAt | Date | created_at | 创建时间 |
| userId | Integer | user_id | 填写用户 ID |
2.1.10 RegistrationStatus (t_registration_status)
注册状态字典表。
| 字段名 | Java 类型 | 数据库列名 | 说明 |
|---|---|---|---|
| id | Integer | id | 自增主键 |
| code | Integer | code | 状态码 |
| name | String | name | 状态名称 |
2.1.11 RegistrationWorkflow (t_registration_workflow)
注册状态转换规则矩阵。
| 字段名 | Java 类型 | 数据库列名 | 说明 |
|---|---|---|---|
| id | Integer | id | 自增主键 |
| productId | Integer | product_id | 关联 Product |
| registrationStatus | Integer | registration_status | 当前注册状态 |
| pending | Integer | pending | 是否可转为 Pending |
| invited | Integer | invited | 是否可转为 Invited |
| accepted | Integer | accepted | 是否可转为 Accepted |
| registered | Integer | registered | 是否可转为 Registered |
| declined | Integer | declined | 是否可转为 Declined |
| cancelled | Integer | cancelled | 是否可转为 Cancelled |
2.1.12 SleepingRoom (t_sleeping_room)
住宿房间信息。
| 字段名 | Java 类型 | 数据库列名 | 说明 |
|---|---|---|---|
| sleepingRoomId | Integer | sleeping_room_id | 自增主键 |
| meetingId | Integer | meeting_id | 关联 Meeting |
| status | Integer | status | 状态 |
| hotelName | String | hotel_name | 酒店名称 |
| hotelDescription | String | hotel_description | 酒店描述 |
| hotelDescriptionHtml | String | hotel_description_html | 酒店描述(HTML) |
| checkInDateFrom | Date | check_in_date_from | 入住日期起 |
| checkInDateTo | Date | check_in_date_to | 入住日期止 |
| checkOutDateFrom | Date | check_out_date_from | 退房日期起 |
| checkOutDateTo | Date | check_out_date_to | 退房日期止 |
| hasSmokingPreference | Integer | has_smoking_preference | 是否有吸烟偏好 |
| hasSpecialRequirement | Integer | has_special_requirement | 是否有特殊要求 |
| isRunOfHouse | Integer | is_run_of_house | 是否为 Run of House |
| createdAt | Date | created_at | 创建时间 |
| updatedAt | Date | updated_at | 更新时间 |
| askForExplanation | Integer | ask_for_explanation | 是否要求解释:0=no, 1=yes |
2.1.13 SleepingRoomInventory (t_sleeping_room_inventory)
按日期和房型的房间库存。
| 字段名 | Java 类型 | 数据库列名 | 说明 |
|---|---|---|---|
| sleepingRoomInventoryId | Integer | sleeping_room_inventory_id | 自增主键 |
| sleepingRoomId | Integer | sleeping_room_id | 关联 SleepingRoom |
| roomCount | Integer | room_count | 总房间数 |
| registeredCount | Integer | registered_count | 已预订数 |
| roomTypeId | Integer | room_type_id | 关联 RoomType |
| inventoryDate | Date | inventory_date | 库存日期 |
| meetingId | Integer | meeting_id | 关联 Meeting(冗余) |
2.1.14 SiteTemplate (t_site_template)
注册站点模板样式配置。
| 字段名 | Java 类型 | 数据库列名 | 说明 |
|---|---|---|---|
| attendeeTypeId | Integer | attendee_type_id | 主键(与 AttendeeType 1:1) |
| meetingId | Integer | meeting_id | 关联 Meeting |
| navLayout | String | nav_layout | 导航布局 |
| navDirection | String | nav_direction | 导航方向 |
| navBgColor | String | nav_bg_color | 导航背景色 |
| navTextColor | String | nav_text_color | 导航文字色 |
| bannerImg | String | banner_img | Banner 图片 |
| filedPosition | String | filed_position | 字段位置 |
| fieldLabelColor | String | field_label_color | 字段标签颜色 |
| pageBgColor | String | page_bg_color | 页面背景色 |
| containerBgColor | String | container_bg_color | 容器背景色 |
| headerBgColor | String | header_bg_color | 头部背景色 |
| headerTextColor | String | header_text_color | 头部文字色 |
| tabStyle | String | tab_style | Tab 样式 |
| status | Integer | status | 状态:0=Draft, 1=Active, 2=Suspend, 3=Completed |
| footerImg | String | footer_img | Footer 图片 |
2.1.15 SiteTemplateField (t_site_template_field)
注册表单字段定义。
| 字段名 | Java 类型 | 数据库列名 | 说明 |
|---|---|---|---|
| fieldId | Integer | field_id | 自增主键 |
| clientId | String | client_id | 客户端 ID |
| pageId | Integer | page_id | 关联 SiteTemplatePage |
| containerId | String | container_id | 容器 ID(attendeeInfo 等) |
| meetingId | Integer | meeting_id | 关联 Meeting |
| attendeeTypeId | Integer | attendee_type_id | 关联 AttendeeType |
| parentId | Integer | parent_id | 父字段 ID |
| fieldLabel | String | field_label | 字段标签 |
| fieldName | String | field_name | 字段名(映射到 Attendee 实体字段) |
| fieldType | String | field_type | 字段类型(inputBox/radioButton/checkBoxButton/datePicker/dropDownList/image/attendeeInfo/boxHeader/text/sleepingRoom/radioButtonCondition/conditionItem) |
| fieldValue | String | field_value | 字段默认值 |
| fieldOrder | Integer | field_order | 排序序号 |
| items | String | items | 选项列表(用 @-@ 分隔) |
| imgUrl | String | img_url | 图片 URL |
| imgWidth | String | img_width | 图片宽度 |
| imgPosition | String | img_position | 图片位置 |
| verticality | Boolean | verticality | 是否纵向布局 |
| required | Boolean | required | 是否必填 |
| format | String | format | 格式 |
| range | Boolean | range | 是否有范围 |
| min | String | min | 最小值 |
| max | String | max | 最大值 |
| fieldRestrict | String | field_restrict | 字段限制 |
| regexp | String | regexp | 正则验证 |
| reportFieldId | Integer | report_field_id | 报表字段 ID |
| reportFieldLabel | String | report_field_label | 报表字段标签 |
| ipadRequired | Boolean | ipad_required | iPad 端是否必填 |
| registrationSiteEnabled | Boolean | registration_site_enabled | 是否在注册站点启用 |
| salesRepRequired | Boolean | sales_rep_required | Sales Rep 端是否必填 |
| fieldSubType | String | field_sub_type | 字段子类型 |
2.1.16 SiteTemplatePage (t_site_template_page)
注册表单页面定义(多步骤表单)。
| 字段名 | Java 类型 | 数据库列名 | 说明 |
|---|---|---|---|
| pageId | Integer | page_id | 自增主键 |
| clientId | String | client_id | 客户端 ID |
| meetingId | Integer | meeting_id | 关联 Meeting |
| attendeeTypeId | Integer | attendee_type_id | 关联 AttendeeType |
| title | String | title | 页面标题 |
| layout | String | layout | 布局 |
| pageOrder | Integer | page_order | 页面排序 |
2.1.17 Target (t_target)
客户上传的 HCP 目标列表。数据存储在 target schema 中。
| 字段名 | Java 类型 | 数据库列名 | 说明 |
|---|---|---|---|
| targetId | Long | target_id | 自增主键 (s_target) |
| externalCode | String | external_code | 外部代码(硬编码 100) |
| externalId | String | external_id | 外部 ID(调和用的关键标识) |
| attendeeTypeCode | String | attendee_type_code | 参会类型代码 |
| currencyCode | String | currency_code | 币种代码 |
| lastName | String | last_name | 姓 |
| firstName | String | first_name | 名 |
| company | String | company | 公司 |
| title | String | title | 头衔/职称 |
| inactive | String | inactive | 是否无效 |
| ytdTotal | String | ytd_total | 年初至今总额 |
| middleName | String | middle_name | 中间名 |
| suffixName | String | suffix_name | 后缀名 |
| stateLicenseNumber | String | state_license_number | 州执照号 |
| deaNumber | String | dea_number | DEA 号码 |
| licenseState | String | license_state | 执照州 |
| typeTitle | String | type_title | 专科描述 |
| addressLine1 | String | address_line_1 | 地址1 |
| addressLine2 | String | address_line_2 | 地址2 |
| city | String | city | 城市 |
| state | String | state | 州 |
| zipCode | String | zip_code | 邮编 |
| npiNumber | String | npi_number | NPI 号码 |
| meNumber | String | me_number | ME 号码 |
| companyId | Integer | company_id | 关联公司(制药公司) |
| custom1-custom20 | String | custom1-custom20 | 20 个自定义字段 |
| productId | Integer | product_id | 关联 Product |
| status | Integer | status | 状态:0=inactive, 1=active |
| updatedAt | Date | updated_at | 更新时间 |
| String | 邮箱 | ||
| affiliation | String | affiliation | 从属机构 |
2.2 Table Relationships (ER Diagram - ASCII)
+-------------------+
| t_meeting |
| (Meeting/Site) |
+--------+----------+
| 1
|
+----------+------------+---------+-----------+
| | | | |
| N | N | N | N | N
+--------+---+ +----+------+ +--+-------+ +--+------+ +-----+------+
|t_attendee | |t_attendee | |t_sleeping| |t_site | |t_site |
|_type | | | |_room | |_template| |_template |
+-----+------+ +-----+----+ +----+-----+ +---------+ |_page |
| 1 | 1 | 1 +-----+------+
| | | |
| N | N | N | N
+-----+-------+ +----+------+ +--+---------+ +-----+------+
|t_site | |t_attendee | |t_sleeping | |t_site |
|_template | |_response | |_room | |_template |
|_field | +-----------+ |_inventory | |_field |
+------+------+ +------------+ +------+-----+
| |
| 1 |
| |
+------+------+ +------+-----+
|t_attendee | |t_attendee |
|_change_log | |_response |
+-------------+ +------------+
t_attendee 关系:
+--------------+ +-------------------+
| t_attendee |----->| t_attendee | (parentId: Guest->Primary)
| (Self-Ref) | | _signature |
+--------------+ +-------------------+
|
|-----> t_attendee_response (1:N via attendeeId)
|-----> t_attendee_change_log (1:N via attendeeId)
|-----> t_attendee_survey_response (1:N via attendeeId)
|-----> t_attendee_compliance_qa (1:N via attendeeId)
外部关联:
t_attendee.reconciliationId ----> NpiData.npi (when reconciliationType=1)
t_attendee.reconciliationId ----> t_target.externalId (when reconciliationType=2)
t_attendee.reconciliationId ----> Salesforce Contact (when reconciliationType=3)
t_registration_workflow ----> t_product (via productId)
t_registration_status: 独立字典表
t_attendee_info_field: 独立元数据表
t_attendee_survey: 独立配置表2.3 Data Model Issues
DM-1: Attendee 主键使用 String UUID 而非自增整数
- 文件:
Attendee.java:27—@GeneratedValue(generator = "JDBC")实际由 Java 端IdGenerator.getUUIDString()生成 - 影响:索引效率低、JOIN 性能差、外键约束复杂
DM-2: 状态码使用魔数整数而非枚举列
registrationStatus使用 0/10/20/40/50/60/70 的非连续整数hcpStatus使用 1/2/3signInStatus使用 0/1/2/3- 这些值仅在
Constants.java中定义,数据库层面无约束
DM-3: JSON 字段滥用
Attendee.virtualProgramAttendeeInfo— JSON 存储虚拟会议参会信息Attendee.registrationSource— JSON 存储{source, createdBy},应该是两个独立列AttendeeSurvey.content— JSON 存储问卷内容AttendeeSurveyResponse.response— JSON 存储响应数据
DM-4: AttendeeComplianceQA 缺少 @Table 注解
- 文件:
AttendeeComplianceQA.java:8— 没有@Table注解,表名约定不明确
DM-5: SleepingRoomInventory 中 meetingId 冗余
- 文件:
SleepingRoomInventory.java:29-30— meetingId 已在 SleepingRoom 中存在,此处冗余
DM-6: AttendeeResponse 数据模型设计问题
AttendeeResponse将参会人员的表单填写值作为 EAV(Entity-Attribute-Value)模式存储- 导致查询需要多表关联和 pivot,性能差
Attendee实体本身也存储了部分相同数据(firstName, lastName 等),存在数据重复
DM-7: Target 实体有 20 个 custom 字段(custom1-custom20)
- 典型的 schema-less 反模式,无法进行类型检查和约束
DM-8: 编码风格不一致
Attendee.java使用 Lombok (@Data, @Builder),AttendeeChangeLog.java使用手写 getter/setter- 部分实体使用
GenerationType.IDENTITY,部分使用generator = "JDBC"
3. Business Flow Analysis
3.1 Core Business Flows
3.1.1 NPI Lookup and Validation Flow
┌─────────────────┐
│ 前端发起 NPI 查询│
│ GET v1/npis │
└────────┬────────┘
│ NpiQuery(npi, firstName, lastName, city, state, zip)
v
┌────────────────────────┐
│ NpiService.listNpis() │
│ 查询 npi schema 的 │
│ NpiData 表(NPPES 数据)│
└────────┬───────────────┘
│ 返回分页 Npi 列表
v
┌────────────────────────────────┐
│ 前端选择 NPI 记录进行调和 │
│ PUT v1/attendees/{id}/reconcile│
└────────┬───────────────────────┘
│ ReconcileNpiRequest {npi, mergeFields}
v
┌─────────────────────────────┐
│ AttendeeService │
│ .reconcileAttendee() │
│ 1. 查找 attendee │
│ 2. 设置 reconciliationId │
│ 3. mergeNpiData(): │
│ - 合并 NPI/License/ │
│ Address/Specialty/ │
│ Credential 等字段 │
│ 4. hcpStatus = Reconciled(2) │
│ 5. reconciliationType = NPI │
│ 6. 更新 Attendee │
│ 7. 同步更新 AttendeeResponse │
└─────────────────────────────┘3.1.2 Target Reconciliation Flow (3 remaining types)
调和类型由 Product.npiTarget 字段决定,当前有 3 种(QIMS type=5 和 MedPro type=6 已移除):
Product.npiTarget 决定调和类型
│
├── 1 (NPI): 查询 npi schema -> NpiData 表
│ ContactService.getNpis()
│
├── 2 (TARGET): 查询 target schema -> t_target 表
│ ContactService.getTargets()
│ 按 companyId + productId 过滤
│
└── 3 (SALESFORCE): 调用 Salesforce Force API
ContactService.getSalesForceResult()
通过 ReconcileResolver -> SF ContactResponse
调和操作流程(SalesView 端):
┌──────────────────────────────┐
│ PUT v1/attendees/{id}/ │
│ reconciliation │
│ ReconcileAttendeeRequest: │
│ { reconciliationId } │
└──────────┬───────────────────┘
v
┌──────────────────────────────┐
│ AttendeeService.reconcile() │
│ 1. 获取 productConfiguration │
│ 2. 根据 reconciliationType: │
│ TARGET: 从 Target 表获取 │
│ Contact 信息并合并到 │
│ Attendee (firstName, │
│ lastName, city, state, │
│ zipCode, address, phone, │
│ email, meNumber, npi, │
│ specialty, credentials, │
│ licenseNumber, etc.) │
│ SALESFORCE: 设置 │
│ salesforceContactWhoId │
│ 3. hcpStatus = Reconciled │
│ 4. 更新 Attendee + Response │
└──────────────────────────────┘
批量自动调和:
┌──────────────────────────────┐
│ PUT v1/attendees/reconcile │
│ ReconcileAttendeesRequest: │
│ { meetingId, │
│ attendeeTypeIds, │
│ mergeFields } │
└──────────┬───────────────────┘
v
┌──────────────────────────────┐
│ reconcileAttendees() │
│ 遍历所有未调和 attendee │
│ 通过 NPI+Name+City+State │
│ 搜索 NPI 数据库 │
│ 仅当精确匹配到 1 条时 │
│ 自动合并 │
└──────────────────────────────┘3.1.3 Registration Workflow and Status Transitions
注册状态值定义 (Constants.AttendeeRegistrationStatus):
0 = Pending (Not Yet Invited)
10 = Invited (No Response)
20 = Accepted
40 = Registered
50 = Canceled
60 = Declined
70 = Rep Invited
RegistrationWorkflow 矩阵(按 Product 配置):
每行表示一个当前状态,列表示可转换到的目标状态(0/1 标志)
状态流转逻辑 (getNextRegistrationStatus):
┌─────────────┐ ┌────────────┐
│ Pending(0) │──邀请邮件──> │ Invited(10)│
│ │ 或 Rep 邀请-->│ RepInvited │
└─────────────┘ │ (70) │
└─────┬──────┘
┌──────────────┼──────────────┐
v v v
┌───────────┐ ┌────────────┐ ┌──────────┐
│Accepted(20)│ │Declined(60)│ │Canceled │
│(按 Product │ │(含 decline │ │ (50) │
│ 配置决定) │ │ reason) │ └──────────┘
└──────┬─────┘ └────────────┘
v
┌──────────────┐
│Registered(40)│
│(完成注册) │
└──────┬───────┘
v
┌──────────────┐
│ Check-In │
│ signInStatus │
│ 0->1 or 3 │
└──────────────┘
注册来源 (RegistrationSource):
1=Open Reg, 2=Email Invitation, 3=Sales Rep(Quick Reg),
4=Planner(Contact Import), 5=Pharmagin Connect(已移除),
6=Walk-In(Sign-In Attendees), 7=Copy Attendees,
8=Auto Reg, 9=Sales Rep(Batch Reg), 10=Set Registration Status3.1.4 Frequency Check Logic
频率检查入口:
GET v1/registration/frequency-check
FrequencyCheckRequest: {reconciliationId, meetingRequestId,
firstName, lastName, state, attendeeId, attendeeTypeId}
┌─────────────────────────────────┐
│ RegistrationManager │
│ .frequencyCheck() │
│ │
│ 1. 检查 bypassedFrequencyCheck │
│ Categories(如 attendee │
│ category 在白名单中则跳过) │
│ │
│ 2. 有 reconciliationId: │
│ FrequencyCheckResolver │
│ .getFrequencyCheckResponse() │
│ (Plus 包提供,按 productId │
│ 和 meetingRequestId 检查 │
│ AttendanceConfiguration) │
│ │
│ 3. 无 reconciliationId: │
│ FrequencyCheckResolver │
│ .getAttendanceFrequency │
│ CheckResponse() │
│ (按 firstName+lastName+ │
│ state 匹配历史记录) │
│ │
│ 4. 返回 message + 历史记录 │
│ message 非空 = 触发频率限制 │
│ │
│ 5. 发送通知邮件给 Planner │
│ (FrequencyCheckReminder 配置)│
└─────────────────────────────────┘
Noven 特殊频率检查 (NovenAttendanceFrequencyCheckService):
- 独立于 Plus 包的实现
- 按 AttendanceConfig 配置:
- frequencyPeriodType=0: 按月数范围
- frequencyPeriodType=1: 按 FiscalYear 范围
- 支持按 Topic 过滤 (topicEnabled)
- 查询 t_noven_attendee_history 表
- 发送 noven-frequency-check-notification 邮件模板3.1.5 Sleeping Room Management
┌─────────────────────┐ ┌──────────────────────┐
│ SleepingRoom CRUD │ │ SleepingRoomInventory │
│ v1/sleepingrooms │ │ (按日期+房型的库存) │
│ │ │ │
│ - hotelName │--->│ - roomCount │
│ - checkIn/Out dates │ │ - registeredCount │
│ - smokingPref │ │ - roomTypeId │
│ - specialReq │ │ - inventoryDate │
└────────┬────────────┘ └───────────────────────┘
│
│ 注册时选择住宿
v
┌────────────────────────────────────┐
│ saveAttendeeSleepingRoomResponse() │
│ 1. 遍历 SleepingRoom fields │
│ 2. 保存 MappingSleepingRoomContact│
│ (attendeeId + sleepingRoomId + │
│ checkIn/Out + roomType + │
│ smoking + specialReq) │
│ 3. 更新 SleepingRoomInventory │
│ registeredCount + 1 │
│ 4. 取消时 registeredCount - 1 │
└────────────────────────────────────┘
下载报表:
Sheet1: Rooming List (按入住信息列表)
Sheet2: Pickup Report (按日期汇总)
Sheet3: Attendees No Reservation (未预订的参会人员)3.2 Validation Rules
| 验证规则 | 位置 | 说明 |
|---|---|---|
| 最大参会人数 | AttendeeService.java:585-611 | meeting.expectedAttendees 非空时,已注册(Accepted + Registered)人数 + guest 数不能超过上限 |
| 注册条件检查 | AttendeeService.java:567-583 | Meeting、AttendeeType、SiteTemplate、Fields 必须都存在 |
| 导入查重 | AttendeeService.java:1226-1229 | 按 firstName.toUpperCase() + lastName.toUpperCase() + email 判断是否重复 |
| 频率检查 | RegistrationService.java:234-254 | 新注册时调用 frequencyCheck(),超频则抛出 FORBIDDEN |
| 频率检查可跳过 | RegistrationManager.java:75-88 | attendeeCategory 在 bypassedFrequencyCheckCategories 白名单中时跳过 |
| FirstName/LastName 必填 | AttendeeService.java:1222-1224 | 导入联系人时必须提供 |
| Attendee Type 列必填 | AttendeeService.java:1004-1005 | Excel 上传时 "Attendee Type" 列必须存在 |
| 邮件取消订阅检查 | RegistrationService.java:390-394 | 发送邀请前检查邮箱是否在 unsubscribed 列表中 |
3.3 Business Logic Issues
BL-1: AttendeeService 文件超过 2100 行(God Class)
- 文件:
AttendeeService.java— 集中了注册、调和、导入、签到、TOV 等所有逻辑 - 违反单一职责原则
BL-2: 注册逻辑分散在两个模块中
modules/v1/site/controller/AttendeeController.java中有POST v1/attendees/registration和PUT v1/attendees/registration/{id}modules/v1/registration/controller/RegistrationController.java中有POST v1/registration/{attendeeTypeId}- 两条路径最终都调用
AttendeeService.saveRegistration(),但入参封装和验证逻辑不同 - SalesView 使用 registration 模块,PlannView 的 OpenReg 使用 site 模块
BL-3: reconcile 方法有 3 个不同入口,逻辑重复
reconcileAttendee()(line 1692) — PlannView NPI 调和reconcile()(line 1703) — SalesView Target/SF 调和reconcileAttendees()(line 1552) — 批量自动调和- 三者的数据合并逻辑各自独立实现
BL-4: NpiService.getReconciliationNpiMap 有 BUG
- 文件:
NpiService.java:69— 过滤条件使用attendee.getAttendeeTypeId()而不是attendee.getReconciliationType() TargetService.getReconciliationTargetMap也有同样的 bug(line 61)- 这导致只有 attendeeTypeId 恰好等于 ReconciliationType 值时才能匹配
BL-5: getNextRegistrationStatus 逻辑复杂且分散
- 文件:
AttendeeService.java:614-637 - 依赖 Product 配置来决定 Invited 状态的下一状态
- 缺少状态机的清晰定义
BL-6: saveAttendeeResponse 每次先删除再全量插入
- 文件:
AttendeeService.java:725-746 - 先
delete所有 response 再循环insertSelective,无事务保护 - 性能差且有数据不一致风险
BL-7: RegistrationSource.PHARMAGIN_CONNECT (value=5) 仍在枚举中
- 文件:
RegistrationSource.java:14— PharmaginConnect 已被移除但枚举值保留 - 应标记为 @Deprecated 或删除
BL-8: 频率检查有两套独立实现
NovenAttendanceFrequencyCheckService— Noven 客户特定实现,查询t_noven_attendee_historyFrequencyCheckResolver(Plus 包) — 通用实现- 没有统一的接口抽象
4. API Inventory
4.1 REST Endpoints Table
4.1.1 AttendeeController (v1/attendees)
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | /v1/attendees | AttendeeQuery(meetingId, keyword, etc.) | - | PageResult<AttendeesResponse> | 参会人员分页列表 |
| GET | /v1/attendees/{attendeeId} | attendeeId (path) | - | Attendee | 获取单个参会人员 |
| GET | /v1/attendees/registrations | AttendeeRegistrationQuery | - | PageResult<AttendeeRegistration> | 参会人员注册列表 |
| PUT | /v1/attendees/{id} | id (path) | AttendeeDTO | AttendeeDTO | 更新参会人员 |
| DELETE | /v1/attendees/{id} | id (path) | - | void (204) | 删除单个参会人员 |
| DELETE | /v1/attendees | - | DeleteAttendeesRequest | void (204) | 批量删除参会人员 |
| PUT | /v1/attendees/signinstatus | - | UpdateSignInStatusRequest | void | 批量签到/取消签到 |
| PUT | /v1/attendees/attendance-format | - | UpdateAttendanceFormatRequest | void | 更新参会格式 |
| PUT | /v1/attendees/categories | - | UpdateCategoryRequest | void | 批量标记分类 |
| GET | /v1/attendees/registration | meetingId, attendeeTypeId, openRegistration?, attendeeId? | - | SiteTemplateDTO | 获取注册表单模板 |
| GET | /v1/attendees/confirmation | attendeeId, openRegistration? | - | SiteTemplateDTO | 获取确认页信息 |
| POST | /v1/attendees/registration | - | Registration (201) | Registration | 保存注册 |
| PUT | /v1/attendees/registration/{id} | id (path) | Registration | Object | 更新注册 |
| DELETE | /v1/attendees/registration/{id} | id (path), sendMail (query) | - | void (204) | 取消注册 |
| GET | /v1/attendees/registration/{id}/decline | id (path) | - | DeclinedRegistration | 获取拒绝信息 |
| PUT | /v1/attendees/registration/{id}/decline | id (path) | DeclineRegistrationRequest | void | 拒绝注册 |
| POST | /v1/attendees/upload | file (multipart), meetingId | - | UploadAttendeeResponse | 上传 Excel/CSV |
| POST | /v1/attendees/import | - | ImportAttendeeRequest | ImportAttendeeResponse | 导入联系人 |
| PUT | /v1/attendees/{attendeeId}/reconcile | attendeeId (path) | ReconcileNpiRequest | void | NPI 调和 |
| PUT | /v1/attendees/{attendeeId}/merge-npi-data | attendeeId (path) | MergeNpiDataRequest | void | 合并 NPI 数据 |
| PUT | /v1/attendees/{attendeeId}/reconciliation | attendeeId (path) | ReconcileAttendeeRequest | void | SalesView 调和 |
| PUT | /v1/attendees/{attendeeId}/unreconcile | attendeeId (path) | - | void | 取消调和 |
| PUT | /v1/attendees/{attendeeId}/registration-status | attendeeId (path) | SetRegistrationStatusRequest | void | 设置注册状态 |
| PUT | /v1/attendees/reconcile | - | ReconcileAttendeesRequest | Integer | 批量自动调和 |
| PUT | /v1/attendees/foodbeverage | - | FoodAndBeverageRequest | void | 批量更新餐饮 |
| PUT | /v1/attendees/{id}/foodbeverage | id (path) | FoodAndBeverageRequest | void | 更新单个餐饮 |
| GET | /v1/attendees/summary | meetingId (query) | - | AttendeeReport | 汇总报告 |
| POST | /v1/attendees/copy | - | CopyAttendeesRequest | void | 跨 Meeting 复制参会人员 |
| GET | /v1/attendees/unsynchronized-attendees | meetingId (query) | - | List<UnsynchronizedAttendeesResponse> | 未同步到 SF 的参会人员 |
| PUT | /v1/attendees/update-registration-in-salesforce | - | List<String> (attendeeIds) | void | 同步注册到 SF |
| POST | /v1/attendees/{attendeeId}/add-to-salesforce | attendeeId (path) | - | void | 添加到 SF |
4.1.2 AttendeeSignatureController (v1/attendees)
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | /v1/attendees/signature/declaimer/{programTypeId} | programTypeId (path) | - | Map<String, String> | 获取签名免责声明 |
| GET | /v1/attendees/{attendeeId}/signature | attendeeId (path) | - | AttendeeSignature | 获取签名 |
| POST | /v1/attendees/{attendeeId}/signature | attendeeId (path) | SignatureDTO | void | 保存签名 |
| POST | /v1/attendees/{attendeeId}/checkedIn | attendeeId (path) | SignatureDTO | void | Check-In |
| POST | /v1/attendees/{attendeeId}/noShow | attendeeId (path) | - | void | 标记 No Show |
4.1.3 AttendeeTovController (v1/attendees)
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | /v1/attendees/tov | AttendeeTovQuery | - | AttendeeTovResponse | TOV 列表 |
| GET | /v1/attendees/tov/download | meetingRequestId (query) | - | Excel 下载 | 下载 TOV |
| PUT | /v1/attendees/tov/allocation | - | AllocateTovRequest | AttendeeTovResponse | TOV 分配 |
| PUT | /v1/attendees/tov | - | UpdateAttendeeTovRequest | AttendeeTovResponse | 批量更新 TOV |
| PUT | /v1/attendees/{attendeeId}/tov | attendeeId (path) | UpdateAttendeeTovRequest | void | 更新单个 TOV |
| POST | /v1/attendees/{attendeeId}/transfer-of-value | attendeeId (path) | List<AddTransferOfValueRequest> | void | 添加 TOV |
4.1.4 AttendeeTypeController (v1/attendeetypes)
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | /v1/attendeetypes | meetingId (query) | - | List<AttendeeTypeDTO> | AttendeeType 列表 |
| POST | /v1/attendeetypes | - | AttendeeTypeDTO | MeetingDTO | 创建 AttendeeType |
| PUT | /v1/attendeetypes/{id} | id (path) | AttendeeType | void | 更新 AttendeeType |
| PUT | /v1/attendeetypes/{attendeeTypeId}/external-lookup-rule | attendeeTypeId (path) | SetExternalLookupRuleRequest | void | 设置外部查找规则 |
| DELETE | /v1/attendeetypes/{id} | id (path) | - | void (204) | 删除 AttendeeType |
| PUT | /v1/attendeetypes/sequences | - | List<AttendeeTypeSequence> | void | 排序 |
4.1.5 RegistrationController (v1/registration)
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | /v1/registration/{attendeeTypeId} | attendeeTypeId (path), attendeeId? (query) | - | RegistrationResponse | 获取注册表单 |
| POST | /v1/registration/{attendeeTypeId} | attendeeTypeId (path) | SaveRegistrationRequest | Registration | SalesView 保存注册 |
| POST | /v1/registration/{attendeeTypeId}/batch-registration | attendeeTypeId (path) | SaveBatchRegistrationRequest | SaveBatchRegistrationResponse | 批量注册 |
| GET | /v1/registration/attendees | meetingRequestId, lastName, state | - | List<Attendee> | 查询参会人员(频率检查用) |
| GET | /v1/registration/attendees/{attendeeId}/program-invitation | attendeeId (path) | - | ProgramInvitationResponse | 获取邀请内容 |
| POST | /v1/registration/attendees/{attendeeId}/program-invitation | attendeeId (path) | SendProgramInvitationRequest | void | 发送邀请 |
| GET | /v1/registration/attendees/{attendeeId}/email-invitation | attendeeId (path) | - | EmailInvitationResponse | 获取邮件邀请 |
| POST | /v1/registration/attendees/{attendeeId}/email-invitation | attendeeId (path) | SendEmailInvitationRequest | void | 发送邮件邀请 |
| POST | /v1/registration/attendees/email-invitation | - | SendMultipleEmailInvitationRequest | SendMultipleEmailInvitationResponse | 批量发送邮件邀请 |
| GET | /v1/registration/attendees/{attendeeId}/email-invitation/{id}/open | attendeeId, id (path) | - | void | 邀请打开跟踪 |
| GET | /v1/registration/attendees/{attendeeId}/email-invitation/{id}/access | attendeeId, id (path) | - | void | 邀请访问跟踪 |
| GET | /v1/registration/attendees/{attendeeId}/accept | attendeeId (path) | - | String | 接受注册 |
| GET | /v1/registration/attendees/{attendeeId}/decline | attendeeId (path) | - | String | 拒绝注册 |
| GET | /v1/registration/frequency-check | FrequencyCheckRequest | - | AttendanceFrequencyCheckResponse | 频率检查 |
| GET | /v1/registration/quick-email-invitation | meetingRequestId, attendeeId? | - | EmailInvitationResponse | 快速邮件邀请 |
| POST | /v1/registration/quick-email-invitation | - | SendQuickEmailInvitationRequest | void | 发送快速邮件邀请 |
4.1.6 SiteController (v1/sites)
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | /v1/sites | SiteQuery | - | PageResult<ListSitesResponse> | 注册站点列表 |
| POST | /v1/sites | - | MeetingDTO (201) | MeetingDTO | 创建站点 |
| GET | /v1/sites/{id} | id (path) | - | MeetingDTO | 获取站点 |
| PUT | /v1/sites/{id} | id (path) | MeetingDTO | MeetingDTO | 更新站点 |
| DELETE | /v1/sites/{id} | id (path) | - | void (204) | 删除站点 |
| POST | /v1/sites/{id}/copy | id (path), meetingName (query) | - | MeetingDTO (201) | 复制站点 |
| GET | /v1/sites/{meetingId}/templates | meetingId (path) | - | List<Integer> | 获取模板 ProgramType |
| PUT | /v1/sites/{id}/templates | id (path), programTypeIds (query) | - | void | 设置模板 |
| GET | /v1/sites/{meetingId}/url | meetingId (path) | - | SiteUrlDTO | 获取站点 URL |
| PUT | /v1/sites/{meetingId}/url | meetingId (path) | SiteUrlDTO | SiteUrlDTO | 更新站点 URL |
| PUT | /v1/sites/{meetingId}/policy | meetingId (path) | Policy | Policy | 更新策略 |
| GET | /v1/sites/{meetingId}/policy | meetingId (path) | - | Policy | 获取策略 |
| GET | /v1/sites/reg/{url} | url (path) | - | MeetingDTO | 通过自定义 URL 获取站点 |
| GET | /v1/sites/{meetingId}/shared-users | meetingId (path) | - | List<Integer> | 获取共享用户 |
| POST | /v1/sites/{meetingId}/shared-users | meetingId (path) | List<Integer> | void | 添加共享用户 |
| PUT | /v1/sites/{meetingId}/activate | meetingId (path) | ActivateRequest | MeetingDTO | 激活注册站点 |
| PUT | /v1/sites/{meetingId}/suspend | meetingId (path) | - | MeetingDTO | 暂停注册站点 |
4.1.7 SleepingRoomController (v1/sleepingrooms)
| Method | Path | Parameters | Request Body | Response | Description |
|---|---|---|---|---|---|
| GET | /v1/sleepingrooms | meetingId (query) | - | List<ListSleepingRoomsResponse> | 住宿列表 |
| GET | /v1/sleepingrooms/{sleepingRoomId} | sleepingRoomId (path) | - | GetSleepingRoomResponse | 获取住宿详情 |
| POST | /v1/sleepingrooms | - | CreateSleepingRoomRequest (201) | GetSleepingRoomResponse | 创建住宿 |
| PUT | /v1/sleepingrooms/{sleepingRoomId} | sleepingRoomId (path) | CreateSleepingRoomRequest | GetSleepingRoomResponse | 更新住宿 |
| DELETE | /v1/sleepingrooms/{sleepingRoomId} | sleepingRoomId (path) | - | void (204) | 删除住宿 |
| GET | /v1/sleepingrooms/{sleepingRoomId}/download | sleepingRoomId (path) | - | Excel | 下载住宿报表 |
4.1.8 其他相关 Controller
| Method | Path | Controller | Description |
|---|---|---|---|
| GET | /v1/npis | NpiController | NPI 查询 |
| GET | /v1/targets | TargetController | Target 列表查询 |
| GET | /v1/contacts | ContactController | 统一联系人查询(根据 reconciliationType 路由到 NPI/Target/SF) |
| GET | /v1/contacts/npis | ContactController | NPI 联系人查询 |
| GET | /v1/public/contacts | ContactController | 公共联系人查询(无需认证) |
| GET | /v1/registrationworkflows | RegistrationWorkflowController | 获取注册工作流配置 |
| PUT | /v1/registrationworkflows | RegistrationWorkflowController | 更新注册工作流配置 |
| GET | /v1/attendees/changelog | AttendeeChangeLogController | 变更日志 |
| GET | /v1/attendees/download | AttendeeDownloadController | 下载参会列表 |
4.2 API Design Issues
API-1: URL 路径命名不一致
/v1/attendees/registration(site 模块) vs/v1/registration/{attendeeTypeId}(registration 模块) — 两个功能重叠的注册端点/v1/attendees/{id}/reconcilevs/v1/attendees/{id}/reconciliation— 相似操作不同命名
API-2: HTTP 方法使用不当
GET /v1/registration/attendees/{attendeeId}/accept— 接受注册应该使用 POST/PUT 而非 GET(有副作用)GET /v1/registration/attendees/{attendeeId}/decline— 同上
API-3: AttendeeSignatureController 和 AttendeeTovController 共用 v1/attendees 路径
- 三个 Controller 映射到同一前缀
v1/attendees,容易冲突 - 应拆分为独立的 URL namespace
API-4: SiteController 中存在 SQL 注入风险
- 文件:
SiteController.java:168—andCondition("lower(customized_sub_domain) = '" + url.toLowerCase() + "'") - 直接拼接用户输入到 SQL 条件中
API-5: DELETE /v1/attendees 使用 RequestBody
- HTTP DELETE 请求带 body 在部分 HTTP 客户端中不被支持
- 应改为 query parameter 或使用 POST
API-6: 缺少版本化和统一的错误响应格式
- 虽然使用了
v1前缀,但没有其他版本 - 错误通过
ServiceException抛出,但缺少统一的错误响应 DTO
5. Frontend Analysis
5.1 Pages & Components
Plannerview (pharmagin-plannerview/legacy/src/)
| 路径/组件 | 说明 |
|---|---|
containers/Sitebuilder/index.js | Site Builder 主容器 — 注册站点可视化配置 |
containers/Sitebuilder/registration.js | 注册表单渲染 |
containers/Sitebuilder/confirmation.js | 注册确认页 |
containers/Sitebuilder/openRegistration.js | 公开注册入口 |
containers/Sitebuilder/invitation.js | 邀请注册入口 |
containers/Sitebuilder/declineForm.js | 拒绝注册表单 |
containers/Sitebuilder/guestRegistration.js | Guest 注册 |
containers/Sitebuilder/LookupRegistrant.js | 注册人查找 |
containers/Sitebuilder/unavailable.js | 注册站点不可用页 |
containers/Sitebuilder/reducer.js | Sitebuilder Redux reducer |
containers/Sitebuilder/sagas.js | Sitebuilder Redux sagas |
components/RegReport/index.js | 参会人员报告主组件 |
components/RegReport/LookUpNPI.js | NPI 查找对话框 |
components/RegReport/ReconcileNPI.js | NPI 调和对话框 |
components/RegReport/ReconcileAll.js | 批量自动调和 |
components/RegReport/MergeNPIDataForm.js | NPI 数据合并表单 |
components/RegReport/TargetList.js | Target 列表查找 |
components/RegReport/TagSelect.js | 标签选择(分类) |
components/RegReport/AddTransferOfValueModal.js | TOV 添加弹窗 |
components/RegReport/Download.js | 下载参会列表 |
components/RegReport/EditNote.js | 编辑备注 |
components/RegReport/InvitationTrackReport.js | 邀请跟踪报告 |
components/RegReport/SendSurvey.js | 发送调查 |
components/RegSiteBuilder/index.js | 注册站点配置组件 |
components/RegAttendeeType/index.js | AttendeeType 管理组件 |
components/RegAttendeeType/AttendeeCategorySelect.js | 分类选择器 |
components/RegistrationWorkflow/index.js | 注册工作流配置 |
components/RegMeetingForm/index.js | Meeting 表单 |
路由 /registrations | 注册列表页 |
路由 /registrations/:id(/:type) | 注册详情页 |
路由 /sitebuilder | Site Builder 配置页 |
路由 /confirmation/:attendeeId(/:planner) | 确认页 |
路由 /invitations/:attendeeId(/:planner) | 邀请页 |
路由 /decline/:attendeeId | 拒绝页 |
路由 /unsubscribe/:attendeeId | 退订页 |
路由 /resubscribe/:attendeeId | 重新订阅页 |
Salesview (pharmagin-salesview/src/)
| 路径/组件 | 说明 |
|---|---|
pages/Programs/Profile/Attendees/index.js | 项目详情中的参会人员 Tab |
pages/Programs/Profile/Attendees/Filters.js | 参会人员筛选器 |
pages/Programs/Profile/Attendees/InvitationTrackReport.js | 邀请跟踪 |
pages/Programs/Profile/Attendees/surveyType.js | 调查类型选择 |
pages/Programs/Profile/actions.js | 包含参会人员相关 Redux actions |
pages/Programs/Profile/saga.js | 包含参会人员相关 sagas |
pages/Programs/Profile/reducer.js | 包含参会人员相关 state |
pages/Reports/Survey/ | 调查报告 |
Speakerview (pharmagin-speakerview/legacy/src/)
| 路径/组件 | 说明 |
|---|---|
containers/SpeakerDetail/sagas.js | 包含少量参会人员相关的数据获取逻辑(Speaker 查看自己参会的 meeting 信息) |
5.2 Redux State Structure
Plannerview:
state = {
sitebuilder: { // containers/Sitebuilder/reducer.js
template: {}, // SiteTemplateDTO
loading: false,
registration: {}, // 注册表单数据
confirmation: {}, // 确认页数据
registrationStatus: null,
signInStatus: null,
...
},
registrations: {}, // 注册列表
registrationsDetail: {}, // 注册详情(Meeting + Attendees)
siteBuilderConfig: {}, // components/RegSiteBuilder/reducer.js
attendeeTypes: {}, // components/RegAttendeeType/reducer.js
registrationWorkflow: {}, // components/RegistrationWorkflow/reducer.js
}Salesview:
state = {
programProfile: { // pages/Programs/Profile/reducer.js
attendees: [], // 参会人员列表
attendeeSummary: {}, // 参会人员汇总
registrationConfig: {}, // 注册配置
...
}
}5.3 Frontend Issues
FE-1: Sitebuilder 组件使用 React Router v3 和 Redux 旧模式
containers/Sitebuilder/使用injectReducer动态注入,代码分割方式过时- React 15.x/16.x 混用
FE-2: 前端注册逻辑与后端强耦合
- Sitebuilder 的 registration.js 直接映射后端 SiteTemplateDTO 结构
- 字段验证逻辑在前端重新实现一遍
- 表单渲染完全依赖后端返回的字段定义
FE-3: Plannerview 和 Salesview 的参会人员管理界面代码重复
- 两个前端应用都实现了参会人员列表、NPI 查找、调和等功能
- 没有共享组件库
FE-4: Speakerview 几乎没有参会人员相关界面
- Speaker 端无法查看自己的注册状态详情
- 仅在 sagas 中有少量数据获取逻辑
6. Problem Summary
6.1 Critical Issues (must fix in rewrite)
| ID | 问题 | 影响 | 文件位置 |
|---|---|---|---|
| C-1 | AttendeeService 超过 2100 行 God Class | 可维护性极差,难以测试和修改 | site/service/AttendeeService.java |
| C-2 | 注册逻辑双入口重复 | site 模块和 registration 模块有重叠的注册功能,维护困难 | site/controller/AttendeeController.java:178 vs registration/controller/RegistrationController.java:51 |
| C-3 | SQL 注入漏洞 | SiteController 直接拼接用户输入到 SQL | site/controller/SiteController.java:168 |
| C-4 | NpiService/TargetService 过滤条件 BUG | 使用 attendeeTypeId 代替 reconciliationType 进行过滤 | NpiService.java:69, TargetService.java:61 |
| C-5 | saveAttendeeResponse 先全删再插入无事务保护 | 并发操作可能导致数据丢失 | AttendeeService.java:725-746 |
| C-6 | Accept/Decline 使用 GET 方法 | 违反 HTTP 语义,GET 不应有副作用,且可被爬虫/预加载触发 | RegistrationController.java:103-109 |
6.2 Design Defects (should improve)
| ID | 问题 | 影响 | 文件位置 |
|---|---|---|---|
| D-1 | EAV 模式存储表单响应 | AttendeeResponse 表成为数据瓶颈,查询需要 pivot | entity/AttendeeResponse.java |
| D-2 | 三套独立的 reconcile 实现 | 代码重复、合并逻辑不一致 | AttendeeService.java:1552,1692,1703 |
| D-3 | 频率检查双实现 | Noven 特殊实现 + Plus 包通用实现,缺少统一接口 | NovenAttendanceFrequencyCheckService.java + Plus FrequencyCheckResolver |
| D-4 | 状态码使用魔数 | registrationStatus(0/10/20/40/50/60/70) 无数据库约束 | Constants.java:69-148 |
| D-5 | 三个 Controller 共用 /v1/attendees 路径 | URL 冲突风险 | AttendeeController, AttendeeSignatureController, AttendeeTovController |
| D-6 | JSON 字段滥用 | registrationSource 应为结构化列,virtualProgramAttendeeInfo 应为独立表 | Attendee.java:207-215 |
| D-7 | RegistrationWorkflow 矩阵设计 | 状态转换规则存储为扁平列(pending/invited/accepted/...),不易扩展 | entity/RegistrationWorkflow.java |
| D-8 | Target 表 20 个 custom 字段 | schema-less 反模式,无类型安全 | entity/Target.java:169-227 |
| D-9 | Attendee 与 AttendeeResponse 数据冗余 | firstName/lastName/email 等在 Attendee 表和 AttendeeResponse 中都存储 | AttendeeService.java:1628-1681 |
6.3 Technical Debt (nice to have)
| ID | 问题 | 影响 | 文件位置 |
|---|---|---|---|
| T-1 | 编码风格不一致 | 部分 Entity 用 Lombok,部分手写 getter/setter | AttendeeChangeLog.java vs Attendee.java |
| T-2 | AttendeeComplianceQA 缺少 @Table 注解 | 表名映射不明确 | AttendeeComplianceQA.java:8 |
| T-3 | RegistrationSource.PHARMAGIN_CONNECT 遗留 | 已移除功能的枚举值未清理 | RegistrationSource.java:14 |
| T-4 | pvExtUserId 遗留字段 | Attendee 中的 PV 外部用户 ID,PharmaginConnect 已移除 | Attendee.java:195 |
| T-5 | UUID 主键性能问题 | Attendee 使用 String UUID 作为主键 | Attendee.java:27 |
| T-6 | Excel 解析逻辑嵌入 Service 层 | parseExcel 方法应提取为独立工具类 | AttendeeService.java:958-1063 |
| T-7 | 前端 React 版本过旧 | React 15.x-16.x,应升级到 18.x | pharmagin-plannerview/legacy/ |
| T-8 | 前端无共享组件库 | PlannerView 和 SalesView 的参会人员组件独立开发 | 两个前端项目 |
| T-9 | 硬编码邮件主题 | "Repeat Attendee Eligibility Verification Against 2024 Noven SP Attendees" | NovenAttendanceFrequencyCheckService.java:98 |
| T-10 | DELETE 带 RequestBody | HTTP DELETE 带 body 不符合部分 HTTP 规范 | AttendeeController.java:112-115 |
7. Rewrite Recommendations
7.1 Domain 拆分建议
将当前的 God Class AttendeeService 拆分为以下清晰的服务:
| 新服务 | 职责 | 当前代码来源 |
|---|---|---|
AttendeeService | Attendee CRUD、查询、分页 | 保留核心 CRUD |
RegistrationService | 注册表单获取、提交、状态流转 | 合并 site + registration 模块 |
ReconciliationService | NPI/Target/SF 调和统一入口 | 3 个 reconcile 方法合并 |
FrequencyCheckService | 频率检查统一接口 | Noven 实现 + Plus 实现统一 |
CheckInService | 签到、签名、No Show | check-in 相关逻辑 |
AttendeeImportService | Excel/CSV 解析和导入 | parseExcel + importAttendee |
TransferOfValueService | TOV 管理(已独立为 AttendeeTovService) | 保持 |
SleepingRoomService | 住宿管理(已独立) | 保持 |
7.2 数据模型改进
- Attendee 主键改为 Long 自增,保留 UUID 作为公开标识符(externalId)
- registrationStatus/hcpStatus/signInStatus 改为 PostgreSQL ENUM 类型,增加数据库层约束
- registrationSource 拆分为
registration_source_type(ENUM) +registered_by(VARCHAR) 两个结构化列 - 评估是否保留 EAV 模式:考虑将核心字段固化到 Attendee 表,仅自定义字段保留 EAV
- 引入状态机框架(如 Spring Statemachine)管理注册状态转换
- Target 的 custom 字段改为 JSONB,利用 PostgreSQL 的 JSONB 索引能力
7.3 API 改进
- 统一注册入口:合并
v1/attendees/registration和v1/registration/{attendeeTypeId}为一个端点 - Accept/Decline 改为 POST/PUT 方法
- 拆分 Controller URL namespace:签名 ->
v1/signatures, TOV ->v1/transfer-of-value - 修复 SQL 注入:使用参数化查询
- 添加 API 版本策略:为 v2 重写准备
- 统一错误响应格式:定义标准的 ErrorResponse DTO
7.4 前端改进
- 建立共享组件库:NPI 查找、调和、参会人员列表等通用组件
- 升级 React 到 18.x,使用 hooks 替代 class 组件
- 用 React Query 或 SWR 替代 Redux-Saga 进行数据获取
- 统一前端表单验证:与后端共享验证规则 schema
- SpeakerView 增加参会信息查看功能:让 Speaker 可以查看注册确认详情