Skip to content

基于业务生命周期的 Meeting Status 重新设计

一、真实的 Speaker Program 生命周期

在制药行业,一场 Speaker Program 的生命周期天然地包含了渐进式锁定的过程:

时间线 ──────────────────────────────────────────────────────────→

创建项目    开放注册    注册截止    活动当天    核查完成    财务结算    归档
  │          │          │          │          │          │         │
  ▼          ▼          ▼          ▼          ▼          ▼         ▼
┌─────┐  ┌──────┐  ┌──────┐  ┌──────┐  ┌──────┐  ┌──────┐  ┌──────┐
│DRAFT│→ │OPEN  │→ │CLOSED│→ │EVENT │→ │RECON │→ │SETTLE│→ │LOCKED│
│     │  │ REG  │  │ REG  │  │ DAY  │  │CILED │  │MENT  │  │      │
└─────┘  └──────┘  └──────┘  └──────┘  └──────┘  └──────┘  └──────┘

可编辑:   可编辑:   可编辑:   可编辑:    可编辑:   可编辑:   全部
项目信息   项目信息   项目信息   签到       HCP核查   预算      锁定
预算      预算      预算      HCP核查    预算      TOV
注册站    注册站    参会者    参会者     参会者
          参会者    HCP核查
                   预算

不可改:   不可改:   不可改:    不可改:    不可改:    不可改:   不可改:
(无)      (无)     新注册     新注册     新注册     新注册    所有
                              新参会者   新参会者   参会者
                                        签到状态   HCP核查
                                                   签到状态

核心洞察:每进入下一个阶段,可编辑的范围就缩小一层。这就是"渐进式锁定"。

二、当前实现 vs 业务需求对比

业务需求当前实现差距
注册截止后自动停止接受新注册policy_registration_deadline 仅用于显示,不校验完全缺失
活动结束后不接受新参会者仅前端 checkProgramCloseOutStatus()后端无保护
参会者列表关闭后不能修改签到状态前端 attendeeListStatus==0 时弹提示,可绕过后端无保护
核查完成后 HCP 信息锁定无此机制完全缺失
财务结算后预算锁定Budget API 无任何状态检查完全缺失
项目关闭后全部锁定updateProgram()saveSurveyResponse() 检查大量漏洞
Reopen 可以解锁REOPENED 状态存在,但解锁范围不明确缺乏细粒度

三、重新设计 Meeting Status

3.1 新的状态体系

将 Meeting Status 重新设计为一个真正反映生命周期阶段的状态机,每个阶段天然包含"什么可以做、什么不可以做"的权限语义:

Code状态含义Budget Version阶段
0DRAFT草稿/初始创建SOW准备期
1WAITLISTED等待预算SOW准备期
2PENDING_APPROVAL等待审批SOW准备期
3DENIED审批拒绝终止
4PLANNING策划中,注册站可配置EST策划期
5REGISTRATION_OPEN注册开放EST注册期
6REGISTRATION_CLOSED注册关闭,活动即将开始BILL执行期
7EVENT_COMPLETE活动结束,签到/核查中BILL核查期
8RECONCILED核查完成,财务结算中ACT结算期
9CLOSED全部锁定,归档ACT归档
10CANCELLED已取消BILL/CXL终止
11POSTPONED已推迟CXL暂停
12VOID作废终止
13REOPENED管理员解锁ACT特殊

3.2 关键变化:吸收 Reg Site Status 和 Attendee List Status

Registration Site Status 不再需要独立存在。

之前分析说 Reg Site Status 是"独立生命周期"——但从业务角度看,这种独立性其实是一个 bug,不是 feature:

  • 项目还在 DRAFT 阶段,注册站就不应该能被激活
  • 项目进入 PLANNING 阶段,注册站可以配置但不应该对外开放
  • 只有到了 REGISTRATION_OPEN 阶段,HCP 才能实际注册
  • 项目进入 REGISTRATION_CLOSED 后,注册站自动停止接受新注册

Attendee List Status 也不再需要独立存在。

  • REGISTRATION_OPEN 阶段 = 参会者列表开放
  • REGISTRATION_CLOSED 阶段 = 不再接受新注册,但可以修改已有参会者
  • EVENT_COMPLETE 阶段 = 不再接受新参会者,只能核查和修改签到
  • RECONCILED 阶段 = 参会者信息冻结,只能做财务操作
  • CLOSED 阶段 = 全部冻结

3.3 渐进式权限矩阵

这是整个设计的核心——每个 Meeting Status 阶段对应明确的操作权限:

操作 ╲ 状态         DRAFT  PLAN  REG    REG     EVENT   RECON  CLOSED  RE-
                           NING  OPEN   CLOSED  COMP.   CILED          OPENED
─────────────────────────────────────────────────────────────────────────────
修改项目基本信息      ✅     ✅    ✅     ✅      ❌      ❌     ❌      ✅
修改预算 (Budget)     ✅     ✅    ✅     ✅      ✅      ✅     ❌      ✅
配置注册站            ✅     ✅    ✅     ❌      ❌      ❌     ❌      ❌
HCP 自助注册          ❌     ❌    ✅     ❌      ❌      ❌     ❌      ❌
Planner 手动添加参会者 ✅     ✅    ✅     ✅      ❌      ❌     ❌      ✅
修改参会者信息        ✅     ✅    ✅     ✅      ✅      ❌     ❌      ✅
删除参会者            ✅     ✅    ✅     ✅      ❌      ❌     ❌      ✅
修改签到状态          ❌     ❌    ❌     ✅      ✅      ❌     ❌      ✅
HCP 核查 (Reconcile)  ❌     ❌    ✅     ✅      ✅      ❌     ❌      ✅
TOV 计算              ❌     ❌    ❌     ❌      ❌      ✅     ❌      ✅
分配 Speaker          ✅     ✅    ✅     ✅      ❌      ❌     ❌      ✅
发送邀请邮件          ❌     ❌    ✅     ✅      ❌      ❌     ❌      ❌
记录 Expense          ❌     ❌    ❌     ✅      ✅      ✅     ❌      ✅

3.4 完整状态机

                          ┌─────────┐
                          │  DRAFT  │ (SOW)
                          │   (0)   │
                          └────┬────┘

                 ┌─────────────┼──────────────┐
                 │             │              │
                 ▼             ▼              ▼
          ┌────────────┐ ┌──────────┐  ┌──────────┐
          │ WAITLISTED │ │ PENDING  │  │   VOID   │
          │    (1)     │ │ APPROVAL │  │   (12)   │
          │   (SOW)    │ │   (2)    │  └──────────┘
          └─────┬──────┘ │  (SOW)   │
                │        └──┬───┬───┘
                │     Approve   Deny
                │           │   │
                │           │   ▼
                │           │ ┌──────────┐
                │           │ │  DENIED  │
                │           │ │   (3)    │
                │           │ └──────────┘
                ▼           ▼
          ┌──────────────────────┐
          │      PLANNING        │ (EST)
          │        (4)           │
          │                      │
          │  可以: 配置注册站     │
          │  可以: 准备预算       │
          │  可以: 分配 Speaker   │
          │  不可: 开放注册       │
          └──────────┬───────────┘

                     │  (注册站配置完成,手动或自动开放)

          ┌──────────────────────┐
          │  REGISTRATION_OPEN   │ (EST)
          │        (5)           │
          │                      │
          │  可以: HCP 自助注册   │
          │  可以: 发邀请邮件     │
          │  可以: 手动加参会者   │
          │  可以: HCP 核查       │
          └──────────┬───────────┘

                     │  (注册截止 — 手动/定时/活动前自动)

          ┌──────────────────────┐
          │ REGISTRATION_CLOSED  │ (BILL)
          │        (6)           │
          │                      │
          │  不可: 新注册         │
          │  可以: 修改已有参会者 │
          │  可以: 签到           │
          │  可以: HCP 核查       │
          │  可以: 修改预算       │
          └──────────┬───────────┘

                     │  (活动日结束)

          ┌──────────────────────┐
          │   EVENT_COMPLETE     │ (BILL)
          │        (7)           │
          │                      │
          │  不可: 新参会者       │
          │  不可: 删除参会者     │
          │  可以: 修改签到状态   │
          │  可以: HCP 核查       │
          │  可以: 修改预算       │
          │  可以: 记录 Expense   │
          └──────────┬───────────┘

                     │  (所有参会者已核查完毕)

          ┌──────────────────────┐
          │     RECONCILED       │ (ACT)
          │        (8)           │
          │                      │
          │  不可: 修改参会者     │
          │  不可: 修改签到       │
          │  不可: 修改 HCP 核查  │
          │  可以: 修改预算/费用  │
          │  可以: TOV 计算       │
          └──────────┬───────────┘

                     │  (TOV 完成 + 预算确认)

          ┌──────────────────────┐
          │      CLOSED          │ (ACT)
          │       (9)            │
          │                      │
          │  全部锁定             │
          │  不可: 任何修改       │
          └──────────┬───────────┘

                     │  (管理员解锁)

          ┌──────────────────────┐
          │     REOPENED         │ (ACT)
          │      (13)            │
          │                      │
          │  恢复到 RECONCILED    │
          │  级别的编辑权限       │
          └──────────────────────┘


   ── 任意非终态 ──→ CANCELLED(10) / POSTPONED(11) / VOID(12)

3.5 被吸收的状态字段

原独立字段新设计中如何处理
budget_version_id从 Meeting Status 派生(见 3.1 表格)
budget_status从 Meeting Status 派生
reg_site_status不再需要。PLANNING 以前 = 不可配置;PLANNING = 可配置未开放;REGISTRATION_OPEN = 已开放;REGISTRATION_CLOSED 以后 = 已关闭
attendee_list_status不再需要。REGISTRATION_OPEN/CLOSED = 列表开放;EVENT_COMPLETE 以后 = 不接受新参会者;RECONCILED 以后 = 全部冻结
attendee_list_closed_time记录为 Changelog(进入 EVENT_COMPLETE 的时间)

四、后端权限执行层设计

4.1 统一拦截器(核心改动)

当前最大的问题是后端不执行任何基于状态的权限检查。新设计必须在后端强制执行:

java
/**
 * 基于 Meeting Status 的操作权限检查器。
 * 在每个需要校验的 Service 方法入口调用。
 */
public class MeetingPhaseGuard {

    /**
     * 操作类型枚举 — 每个业务操作映射到一个 Operation
     */
    public enum Operation {
        EDIT_PROGRAM_INFO,        // 修改项目基本信息
        EDIT_BUDGET,              // 修改预算
        CONFIGURE_REG_SITE,       // 配置注册站
        ACCEPT_REGISTRATION,      // 接受新注册(HCP自助)
        ADD_ATTENDEE,             // Planner手动添加参会者
        EDIT_ATTENDEE,            // 修改参会者信息
        DELETE_ATTENDEE,          // 删除参会者
        EDIT_SIGN_IN_STATUS,      // 修改签到状态
        RECONCILE_HCP,            // HCP 核查
        CALCULATE_TOV,            // TOV 计算
        ASSIGN_SPEAKER,           // 分配 Speaker
        SEND_INVITATION,          // 发送邀请
        RECORD_EXPENSE,           // 记录费用
    }

    // 每个状态允许的操作集合
    private static final Map<MeetingStatus, Set<Operation>> ALLOWED_OPERATIONS = Map.ofEntries(

        entry(DRAFT, Set.of(
            EDIT_PROGRAM_INFO, EDIT_BUDGET, CONFIGURE_REG_SITE,
            ADD_ATTENDEE, EDIT_ATTENDEE, DELETE_ATTENDEE, ASSIGN_SPEAKER
        )),

        entry(PLANNING, Set.of(
            EDIT_PROGRAM_INFO, EDIT_BUDGET, CONFIGURE_REG_SITE,
            ADD_ATTENDEE, EDIT_ATTENDEE, DELETE_ATTENDEE, ASSIGN_SPEAKER
        )),

        entry(REGISTRATION_OPEN, Set.of(
            EDIT_PROGRAM_INFO, EDIT_BUDGET, CONFIGURE_REG_SITE,
            ACCEPT_REGISTRATION, ADD_ATTENDEE, EDIT_ATTENDEE, DELETE_ATTENDEE,
            ASSIGN_SPEAKER, SEND_INVITATION, RECONCILE_HCP
        )),

        entry(REGISTRATION_CLOSED, Set.of(
            EDIT_PROGRAM_INFO, EDIT_BUDGET,
            ADD_ATTENDEE, EDIT_ATTENDEE, DELETE_ATTENDEE,
            EDIT_SIGN_IN_STATUS, RECONCILE_HCP,
            ASSIGN_SPEAKER, RECORD_EXPENSE
        )),

        entry(EVENT_COMPLETE, Set.of(
            EDIT_BUDGET, EDIT_ATTENDEE,
            EDIT_SIGN_IN_STATUS, RECONCILE_HCP, RECORD_EXPENSE
        )),

        entry(RECONCILED, Set.of(
            EDIT_BUDGET, CALCULATE_TOV, RECORD_EXPENSE
        )),

        entry(CLOSED, Set.of(
            // 空集 — 全部锁定
        )),

        entry(REOPENED, Set.of(
            EDIT_BUDGET, EDIT_ATTENDEE,
            EDIT_SIGN_IN_STATUS, RECONCILE_HCP,
            CALCULATE_TOV, RECORD_EXPENSE,
            ADD_ATTENDEE, DELETE_ATTENDEE, ASSIGN_SPEAKER
        ))
    );

    /**
     * 核心校验方法 — 每个 Service 方法入口调用
     */
    public void checkPermission(Integer meetingRequestId, Operation operation) {
        MeetingRequest mr = meetingRequestMapper.selectByPrimaryKey(meetingRequestId);
        MeetingStatus status = MeetingStatus.valueOfCode(mr.getMeetingStatus());

        Set<Operation> allowed = ALLOWED_OPERATIONS.getOrDefault(status, Set.of());
        if (!allowed.contains(operation)) {
            throw new ServiceException(
                String.format("Operation [%s] is not allowed in status [%s]",
                    operation, status.getName()),
                ErrorCode.FORBIDDEN
            );
        }
    }
}

4.2 Service 层改造示例

java
// AttendeeService.java — 改造前 vs 改造后

// ---- 改造前(无任何检查)----
public void updateAttendee(String id, AttendeeDTO dto) {
    Attendee attendee = this.selectByPrimaryKey(id);
    BeanUtil.copyPropertiesIgnoreNull(dto, attendee);
    this.updateByPrimaryKeySelective(attendee);
}

// ---- 改造后(加入阶段权限检查)----
public void updateAttendee(String id, AttendeeDTO dto) {
    Attendee attendee = this.selectByPrimaryKey(id);
    MeetingRequest mr = getMeetingRequestByMeetingId(attendee.getMeetingId());

    // 后端强制校验
    phaseGuard.checkPermission(mr.getMeetingRequestId(), Operation.EDIT_ATTENDEE);

    BeanUtil.copyPropertiesIgnoreNull(dto, attendee);
    this.updateByPrimaryKeySelective(attendee);
}

需要改造的 Service 方法清单:

Service方法操作类型
AttendeeServiceupdateAttendee()EDIT_ATTENDEE
AttendeeServicedelete() / deleteAttendees()DELETE_ATTENDEE
AttendeeServicesaveRegistration()ACCEPT_REGISTRATION
AttendeeServiceupdateSignInStatus()EDIT_SIGN_IN_STATUS
AttendeeServicereconcile() / reconcileAttendee()RECONCILE_HCP
BudgetItemServicesaveBudgetItem() / updateBudgetItem()EDIT_BUDGET
BudgetItemServicedeleteBudgetItem()EDIT_BUDGET
ProgramServiceupdateProgram()EDIT_PROGRAM_INFO
SiteServiceactivateRegSiteStatus()CONFIGURE_REG_SITE
ExpenseServiceCRUD operationsRECORD_EXPENSE
AttendeeTovServiceallocateTov()CALCULATE_TOV

五、自动化状态转换

5.1 定时驱动的转换

某些状态转换应该可以自动触发,减少 Planner 的手动操作:

java
/**
 * 定时检查并自动转换状态的 Cron Job
 * 建议频率:每小时执行一次
 */
@Scheduled(cron = "0 0 * * * *")
public void autoAdvanceMeetingStatus() {
    LocalDate today = LocalDate.now();

    // 1. 注册截止:到达 registration_deadline 的 REGISTRATION_OPEN 项目 → REGISTRATION_CLOSED
    List<MeetingRequest> regOpenMeetings = findByStatus(REGISTRATION_OPEN);
    for (MeetingRequest mr : regOpenMeetings) {
        Meeting meeting = meetingMapper.selectByPrimaryKey(mr.getMeetingId());
        if (meeting.getPolicyRegistrationDeadline() != null
            && today.isAfter(toLocalDate(meeting.getPolicyRegistrationDeadline()))) {
            advancePhase(mr.getMeetingRequestId(), REGISTRATION_CLOSED);
            logAutoTransition(mr, REGISTRATION_OPEN, REGISTRATION_CLOSED, "Registration deadline reached");
        }
    }

    // 2. 活动结束:活动 end_date 已过的 REGISTRATION_CLOSED 项目 → EVENT_COMPLETE
    List<MeetingRequest> regClosedMeetings = findByStatus(REGISTRATION_CLOSED);
    for (MeetingRequest mr : regClosedMeetings) {
        Meeting meeting = meetingMapper.selectByPrimaryKey(mr.getMeetingId());
        if (meeting.getEndDate() != null
            && today.isAfter(toLocalDate(meeting.getEndDate()))) {
            advancePhase(mr.getMeetingRequestId(), EVENT_COMPLETE);
            logAutoTransition(mr, REGISTRATION_CLOSED, EVENT_COMPLETE, "Event end date passed");
        }
    }
}

5.2 条件驱动的转换建议

这些转换不自动执行,而是在条件满足时提示 Planner

EVENT_COMPLETE → RECONCILED:
  触发条件: 所有 hcp_status != NOT_RECONCILED (1) 的参会者
  行为: 发通知给 Planner:"All attendees have been reconciled.
         Ready to advance to Settlement phase?"

RECONCILED → CLOSED:
  触发条件: TOV 计算完成 + 预算已在 ACT 版本
  行为: 发通知给 Planner:"All close-out conditions met.
         Ready to close the program?"

5.3 注册截止的多种触发方式

注册关闭触发方式(可配置,不互斥):

1. 手动关闭:  Planner 点击 "Close Registration" 按钮
2. 定时关闭:  到达 policy_registration_deadline 日期时自动关闭
3. 活动前关闭: 活动开始前 N 小时自动关闭(可配置 N)
4. 人数满关闭: 达到 expected_attendees 上限时自动关闭

六、Changelog 统一设计

所有状态转换和关键数据变更都记录在统一的 Changelog 中:

6.1 增强 t_meeting_change_log

sql
-- 在已有的 t_meeting_change_log 基础上,增加结构化字段
ALTER TABLE t_meeting_change_log ADD COLUMN change_type VARCHAR(50);
ALTER TABLE t_meeting_change_log ADD COLUMN change_detail JSONB;

-- change_type 枚举值:
-- STATUS_CHANGE        : 状态变更
-- BUDGET_PHASE_CHANGE  : 预算版本变更(由状态变更驱动)
-- ATTENDEE_LOCKED      : 参会者列表锁定
-- REGISTRATION_CLOSED  : 注册关闭
-- HCP_RECONCILED       : HCP核查完成
-- TOV_CALCULATED       : TOV计算完成
-- PROGRAM_CLOSED       : 项目关闭
-- PROGRAM_REOPENED     : 项目解锁

6.2 自动记录示例

java
// 状态转换时自动记录
public void advancePhase(Integer meetingRequestId, MeetingStatus target) {
    // ... 状态转换逻辑 ...

    // 记录状态变更
    changeLogService.log(MeetingChangeLog.builder()
        .meetingRequestId(meetingRequestId)
        .changeType("STATUS_CHANGE")
        .fieldName("Meeting Status")
        .oldValue(oldStatus.getName())
        .newValue(target.getName())
        .createdBy(currentUserId())
        .build());

    // 如果涉及 budget version 变更,额外记录
    if (!oldBV.equals(newBV)) {
        changeLogService.log(MeetingChangeLog.builder()
            .meetingRequestId(meetingRequestId)
            .changeType("BUDGET_PHASE_CHANGE")
            .fieldName("Budget Version")
            .oldValue(oldBV.name())
            .newValue(newBV.name())
            .changeDetail(budgetSnapshot)  // JSON: 各 category 金额快照
            .createdBy(currentUserId())
            .build());
    }

    // 如果进入 REGISTRATION_CLOSED,记录注册关闭
    if (target == REGISTRATION_CLOSED) {
        changeLogService.log(MeetingChangeLog.builder()
            .meetingRequestId(meetingRequestId)
            .changeType("REGISTRATION_CLOSED")
            .fieldName("Registration")
            .oldValue("Open")
            .newValue("Closed")
            .changeDetail(attendeeCountSnapshot)  // JSON: 各状态参会者数量
            .createdBy(currentUserId())
            .build());
    }

    // 如果进入 RECONCILED,记录参会者冻结
    if (target == RECONCILED) {
        changeLogService.log(MeetingChangeLog.builder()
            .meetingRequestId(meetingRequestId)
            .changeType("ATTENDEE_LOCKED")
            .fieldName("Attendee List")
            .oldValue("Editable")
            .newValue("Locked")
            .changeDetail(reconciliationSummary)  // JSON: 核查统计
            .createdBy(currentUserId())
            .build());
    }
}

七、与前一版设计的对比

维度前一版设计(docs/meeting-status-unification-design.md)本版设计
Reg Site Status保持独立,不合并合并。用 Meeting Status 阶段替代
Attendee List Status保持为独立 flag合并。用 Meeting Status 阶段替代
Budget Version合并,派生合并,派生(不变)
状态数量12 个14 个(多了 REGISTRATION_OPEN, REGISTRATION_CLOSED, EVENT_COMPLETE, RECONCILED)
锁定粒度仅区分"关闭"和"未关闭"渐进式锁定,6 级权限收窄
后端执行未详细设计MeetingPhaseGuard 统一拦截
自动化未涉及Cron Job 自动推进状态

为什么改变了对 Reg Site Status 的判断?

前一版从技术角度分析,认为 Reg Site Status 有"独立生命周期"。但从业务角度重新审视后发现:

  • 注册站在项目还是 DRAFT 时就能被激活 → 这不是"灵活性",这是缺乏控制
  • Planner 需要手动管理注册站的开关 → 这不是"独立性",这是自动化缺失
  • 同一个 Meeting Status 下 Reg Site 可以处于任何状态 → 这不是"正交性",这是状态不一致

真正的业务需求是:注册站的状态应该跟随项目的生命周期阶段,而不是由 Planner 独立控制。

八、迁移策略

Phase 1:后端加固(不改状态体系,先堵漏洞)

优先级最高,风险最低。 在现有状态体系上增加 MeetingPhaseGuard

1. 创建 MeetingPhaseGuard 类
2. 在 AttendeeService 的所有写操作中加入权限检查
3. 在 BudgetItemService 的所有写操作中加入权限检查
4. 在 ExpenseService 的所有写操作中加入权限检查
5. 使用现有的 isMeetingClosed() 判断,先覆盖最基本的"关闭后锁定"

Phase 2:注册截止自动化(小范围改动)

1. 添加 Cron Job:检查 registration_deadline,自动将 reg_site_status 改为 Completed
2. 在 saveRegistration() 中增加后端校验:检查 reg_site_status 和 registration_deadline
3. 这一步不改变状态体系,只是让现有字段真正生效

Phase 3:状态体系重构(大改动)

1. 引入新的 MeetingStatus 枚举(14 个状态)
2. 迁移数据:现有状态 → 新状态映射
3. 删除 budget_version_id, budget_status, reg_site_status, attendee_list_status
4. 前端适配新状态值
5. 全面测试

数据迁移映射

sql
-- 现有状态 → 新状态映射
-- 需要结合 reg_site_status 和 attendee_list_status 来判断目标状态

UPDATE t_meeting_request mr SET meeting_status =
  CASE
    -- 终止态直接映射
    WHEN mr.meeting_status = 10 THEN 12  -- VOID → VOID
    WHEN mr.meeting_status = 13 THEN 3   -- DENIED → DENIED
    WHEN mr.meeting_status = 12 THEN 2   -- PENDING_APPROVAL → PENDING_APPROVAL
    WHEN mr.meeting_status = 11 THEN 1   -- WAITLISTED → WAITLISTED

    -- 关闭态
    WHEN mr.meeting_status IN (4, 3, 8) THEN 9  -- CLOSED/CANCELLED_CLOSED/POSTPONED_CLOSED → CLOSED
    WHEN mr.meeting_status = 2 THEN 10  -- CANCELLED → CANCELLED
    WHEN mr.meeting_status = 7 THEN 11  -- POSTPONED → POSTPONED
    WHEN mr.meeting_status = 14 THEN 13 -- REOPENED → REOPENED

    -- 活跃态需要根据子状态判断
    WHEN mr.meeting_status IN (0, 5, 9) THEN 0  -- Assigned/Estimate/Registered → DRAFT
    WHEN mr.meeting_status = 6 THEN
      CASE
        WHEN m.reg_site_status = 1 THEN 5  -- Active → REGISTRATION_OPEN
        WHEN m.reg_site_status = 3 AND m.attendee_list_status = 0 THEN 7  -- Completed + List Closed → EVENT_COMPLETE
        WHEN m.reg_site_status = 3 THEN 6  -- Completed → REGISTRATION_CLOSED
        ELSE 4  -- Draft/Suspend → PLANNING
      END
    WHEN mr.meeting_status = 1 THEN 6  -- Billing → REGISTRATION_CLOSED (或 EVENT_COMPLETE)
  END
FROM ooto.t_meeting m
WHERE m.meeting_request_id = mr.meeting_request_id;

九、总结

从业务角度重新审视后,Meeting Status 应该是项目生命周期的唯一驱动力,它天然地包含了:

  1. 财务阶段(Budget Version)— 已在前一版确认可合并
  2. 注册阶段(Registration Site Status)— 本版确认应该合并,因为注册开放/关闭是生命周期的自然阶段
  3. 参会者锁定(Attendee List Status)— 本版确认应该合并,因为"冻结参会者信息"是结算阶段的自然特征
  4. 渐进式权限— 这是前版完全缺失的,本版通过 MeetingPhaseGuard + 权限矩阵实现

最关键的改进不是状态合并本身,而是引入后端强制执行的 MeetingPhaseGuard —— 即使不做状态体系重构,仅这一项改动就能解决当前最严重的合规性漏洞。