Files
gym-manage/docs/design/modules/LLD-预约模块详细设计.md
T
2026-03-05 13:48:13 +08:00

67 KiB

健身房管理系统详细设计文档 - 预约模块(LLD)

文档编号: GYM-LLD-002
版本: v1.0
日期: 2026-02-28
作者: 张翔
状态: 初稿
归属版本: 基础版

说明:本文档为健身房管理系统基础版的预约模块详细设计文档,描述团课预约模块的数据库设计、API设计、业务逻辑实现等技术细节。


文档修订历史

版本 日期 作者 修订内容
v1.0 2026-02-28 张翔 初稿

参考文档

  • 《健身房管理系统产品设计文档》 GYM-PRD-001
  • 《健身房管理系统业务概要设计文档》 GYM-HLD-001
  • 《健身房管理系统详细设计文档》 GYM-LLD-000
  • Spring Boot 3 官方文档
  • R2DBC 规范文档
  • PostgreSQL 官方文档

一、模块概述

1.1 模块定位

预约模块是健身房管理系统的核心业务模块,负责管理各类资源的预约,包括:

  • 团课预约:会员预约团体课程
  • 私教预约:会员预约私教课程
  • 场地预约:会员预约运动场地
  • 线上课程预约:会员预约线上直播课程

1.2 模块边界

┌─────────────────────────────────────────────────────────────────────────┐
│                          预约模块边界                                   │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │ 预约模块内部                                         │   │
│  ├─────────────────────────────────────────────────────────────────┤   │
│  │ • 课程管理  • 时段管理  • 预约管理  • 库存管理               │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                         │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │ 外部依赖                                         │   │
│  ├─────────────────────────────────────────────────────────────────┤   │
│  │ • 会员模块 (查询会员权益、扣减权益)                           │   │
│  │ • 教练模块 (查询教练信息、排班)                               │   │
│  │ • 场地模块 (查询场地信息、可用性)                             │   │
│  │ • 消息模块 (发送预约通知)                                    │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                         │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │ 被依赖                                               │   │
│  ├─────────────────────────────────────────────────────────────────┤   │
│  │ • 签到模块 (查询预约信息、验证签到资格)                       │   │
│  │ • 财务模块 (查询预约消费记录)                               │   │
│  │ • 数据模块 (预约数据分析)                                    │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

二、数据模型设计

2.1 实体关系图

┌─────────────────────────────────────────────────────────────────────────┐
│                          实体关系图                                     │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐                            │
│  │  coach   │  │  course  │  │  venue   │                            │
│  │  (教练)  │  │  (课程)  │  │  (场地)  │                            │
│  └────┬─────┘  └────┬─────┘  └────┬─────┘                            │
│       │ 1:N          │ 1:N          │ 1:N                             │
│       │              │              │                                  │
│       └──────────────┴──────────────┘                                  │
│                      │ 1:N                                              │
│                      ▼                                                  │
│              ┌──────────────────┐                                        │
│              │  booking_slot   │                                        │
│              │   (预约时段)     │                                        │
│              └────────┬─────────┘                                        │
│                       │ 1:N                                             │
│                       ▼                                                 │
│              ┌──────────────────┐                                        │
│              │ booking_record  │                                        │
│              │   (预约记录)     │                                        │
│              └──────────────────┘                                        │
│                                                                         │
│  关系说明:                                                              │
│  • coach (1) ─── (N) booking_slot   : 一个教练有多个时段              │
│  • course (1) ─── (N) booking_slot  : 一个课程有多个时段              │
│  • venue (1) ─── (N) booking_slot   : 一个场地有多个时段              │
│  • booking_slot (1) ─── (N) booking_record : 一个时段有多个预约记录      │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

2.2 数据表设计

2.2.1 课程表 (course)

CREATE TABLE course (
    id              BIGSERIAL       PRIMARY KEY,
    tenant_id       BIGINT          NOT NULL,
    name            VARCHAR(128)    NOT NULL,
    code            VARCHAR(32),
    type            SMALLINT        NOT NULL,           -- 1:团课 2:私教 3:线上
    category        VARCHAR(64),                        -- 课程分类
    description     TEXT,
    cover_image     VARCHAR(512),
    duration        INT             NOT NULL,           -- 课程时长(分钟)
    capacity        INT             DEFAULT 20,         -- 最大人数
    min_capacity    INT             DEFAULT 1,          -- 最少开课人数
    difficulty      SMALLINT        DEFAULT 1,          -- 1:入门 2:初级 3:中级 4:高级
    calories        INT,                                -- 消耗卡路里
    equipment       VARCHAR(256),                       -- 所需器材
    benefits        JSONB,                              -- 课程收益
    price           DECIMAL(10,2),                      -- 单次价格
    price_type      SMALLINT        DEFAULT 1,          -- 1:扣次 2:扣时长 3:扣金额
    price_value     DECIMAL(10,2),                      -- 扣减值
    advance_days    INT             DEFAULT 7,          -- 可提前预约天数
    cancel_hours    INT             DEFAULT 2,          -- 可取消小时数
    cancel_penalty  DECIMAL(3,2)    DEFAULT 0.00,       -- 取消扣款比例
    status          SMALLINT        DEFAULT 1,          -- 1:上架 2:下架
    sort_order      INT             DEFAULT 0,
    created_at      TIMESTAMP       DEFAULT NOW(),
    updated_at      TIMESTAMP       DEFAULT NOW(),
    created_by      BIGINT,
    updated_by      BIGINT,
    deleted_at      TIMESTAMP       DEFAULT NULL,
    
    CONSTRAINT fk_course_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id)
);

CREATE INDEX idx_course_tenant ON course(tenant_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_course_type ON course(type) WHERE deleted_at IS NULL;
CREATE INDEX idx_course_status ON course(status) WHERE deleted_at IS NULL;

2.2.2 场地表 (venue)

CREATE TABLE venue (
    id              BIGSERIAL       PRIMARY KEY,
    tenant_id       BIGINT          NOT NULL,
    store_id        BIGINT          NOT NULL,
    name            VARCHAR(128)    NOT NULL,
    code            VARCHAR(32),
    type            SMALLINT        NOT NULL,           -- 1:瑜伽室 2:动感单车 3:力量区 4:游泳池
    area            DECIMAL(8,2),                       -- 面积(平方米)
    capacity        INT             NOT NULL,           -- 最大容量
    facilities      JSONB,                              -- 设施配置
    open_time       TIME,                               -- 开放时间
    close_time      TIME,                               -- 关闭时间
    price_per_hour  DECIMAL(10,2),                      -- 每小时价格
    status          SMALLINT        DEFAULT 1,          -- 1:可用 2:维护中 3:已停用
    images          JSONB,                              -- 场地图片
    created_at      TIMESTAMP       DEFAULT NOW(),
    updated_at      TIMESTAMP       DEFAULT NOW(),
    created_by      BIGINT,
    updated_by      BIGINT,
    deleted_at      TIMESTAMP       DEFAULT NULL,
    
    CONSTRAINT fk_venue_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id),
    CONSTRAINT fk_venue_store FOREIGN KEY (store_id) REFERENCES store(id)
);

CREATE INDEX idx_venue_tenant ON venue(tenant_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_venue_store ON venue(store_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_venue_status ON venue(status) WHERE deleted_at IS NULL;

2.2.3 预约时段表 (booking_slot)

CREATE TABLE booking_slot (
    id              BIGSERIAL       PRIMARY KEY,
    tenant_id       BIGINT          NOT NULL,
    store_id        BIGINT          NOT NULL,
    resource_type   SMALLINT        NOT NULL,           -- 1:团课 2:私教 3:场地 4:线上
    resource_id     BIGINT          NOT NULL,           -- 课程ID或场地ID
    coach_id        BIGINT,                             -- 教练ID(私教必填)
    venue_id        BIGINT,                             -- 场地ID
    title           VARCHAR(128),                       -- 时段标题
    start_time      TIMESTAMP       NOT NULL,           -- 开始时间
    end_time        TIMESTAMP       NOT NULL,           -- 结束时间
    capacity        INT             NOT NULL,           -- 容量
    booked_count    INT             DEFAULT 0,          -- 已预约人数
    waitlist_count  INT             DEFAULT 0,          -- 候补人数
    min_capacity    INT             DEFAULT 1,          -- 最少开课人数
    status          SMALLINT        DEFAULT 1,          -- 1:可预约 2:已满 3:已取消 4:已结束
    price           DECIMAL(10,2),                      -- 价格
    price_type      SMALLINT        DEFAULT 1,          -- 1:扣次 2:扣时长 3:扣金额
    price_value     DECIMAL(10,2),                      -- 扣减值
    booking_start   TIMESTAMP,                          -- 开放预约时间
    booking_end     TIMESTAMP,                          -- 截止预约时间
    cancel_deadline TIMESTAMP,                          -- 取消截止时间
    version         INT             DEFAULT 0,          -- 乐观锁版本号
    created_at      TIMESTAMP       DEFAULT NOW(),
    updated_at      TIMESTAMP       DEFAULT NOW(),
    created_by      BIGINT,
    deleted_at      TIMESTAMP       DEFAULT NULL,
    
    CONSTRAINT fk_slot_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id),
    CONSTRAINT fk_slot_store FOREIGN KEY (store_id) REFERENCES store(id)
);

CREATE INDEX idx_slot_tenant ON booking_slot(tenant_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_slot_store ON booking_slot(store_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_slot_resource ON booking_slot(resource_type, resource_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_slot_coach ON booking_slot(coach_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_slot_time ON booking_slot(start_time, end_time) WHERE deleted_at IS NULL;
CREATE INDEX idx_slot_status ON booking_slot(status) WHERE deleted_at IS NULL;

2.2.4 预约记录表 (booking_record)

CREATE TABLE booking_record (
    id              BIGSERIAL       PRIMARY KEY,
    tenant_id       BIGINT          NOT NULL,
    store_id        BIGINT          NOT NULL,
    member_id       BIGINT          NOT NULL,
    slot_id         BIGINT          NOT NULL,
    resource_type   SMALLINT        NOT NULL,
    resource_id     BIGINT          NOT NULL,
    coach_id        BIGINT,
    booking_no      VARCHAR(32)     NOT NULL,           -- 预约编号
    status          SMALLINT        DEFAULT 1,          -- 1:已预约 2:已取消 3:已完成 4:已过期
    price           DECIMAL(10,2),                      -- 价格
    price_type      SMALLINT,                           -- 价格类型
    price_value     DECIMAL(10,2),                      -- 扣减值
    benefit_id      BIGINT,                             -- 扣减的权益ID
    source          VARCHAR(32),                        -- 来源: app/miniprogram/staff
    cancel_reason   VARCHAR(256),                       -- 取消原因
    cancel_by       BIGINT,                             -- 取消人
    cancel_at       TIMESTAMP,                          -- 取消时间
    checkin_status  SMALLINT        DEFAULT 0,          -- 0:未签到 1:已签到 2:迟到 3:缺席
    checkin_at      TIMESTAMP,                          -- 签到时间
    checkin_by      BIGINT,                             -- 签到操作人
    rating          SMALLINT,                           -- 评分 1-5
    comment         TEXT,                               -- 评价内容
    created_at      TIMESTAMP       DEFAULT NOW(),
    updated_at      TIMESTAMP       DEFAULT NOW(),
    deleted_at      TIMESTAMP       DEFAULT NULL,
    
    CONSTRAINT uk_booking_no UNIQUE (tenant_id, booking_no),
    CONSTRAINT fk_booking_member FOREIGN KEY (member_id) REFERENCES member(id),
    CONSTRAINT fk_booking_slot FOREIGN KEY (slot_id) REFERENCES booking_slot(id)
);

CREATE INDEX idx_booking_member ON booking_record(member_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_booking_slot ON booking_record(slot_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_booking_coach ON booking_record(coach_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_booking_status ON booking_record(status) WHERE deleted_at IS NULL;
CREATE INDEX idx_booking_time ON booking_record(created_at) WHERE deleted_at IS NULL;
CREATE INDEX idx_booking_checkin ON booking_record(checkin_status) WHERE deleted_at IS NULL;

2.2.5 预约候补表 (booking_waitlist)

CREATE TABLE booking_waitlist (
    id              BIGSERIAL       PRIMARY KEY,
    tenant_id       BIGINT          NOT NULL,
    member_id       BIGINT          NOT NULL,
    slot_id         BIGINT          NOT NULL,
    queue_no        INT             NOT NULL,           -- 排队序号
    status          SMALLINT        DEFAULT 1,          -- 1:排队中 2:已转正 3:已取消
    expire_at       TIMESTAMP,                          -- 转正过期时间
    notified_at     TIMESTAMP,                          -- 通知时间
    created_at      TIMESTAMP       DEFAULT NOW(),
    updated_at      TIMESTAMP       DEFAULT NOW(),
    deleted_at      TIMESTAMP       DEFAULT NULL,
    
    CONSTRAINT fk_waitlist_member FOREIGN KEY (member_id) REFERENCES member(id),
    CONSTRAINT fk_waitlist_slot FOREIGN KEY (slot_id) REFERENCES booking_slot(id)
);

CREATE INDEX idx_waitlist_slot ON booking_waitlist(slot_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_waitlist_member ON booking_waitlist(member_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_waitlist_status ON booking_waitlist(status) WHERE deleted_at IS NULL;

2.2.6 教练排班表 (coach_schedule)

CREATE TABLE coach_schedule (
    id              BIGSERIAL       PRIMARY KEY,
    tenant_id       BIGINT          NOT NULL,
    store_id        BIGINT          NOT NULL,
    coach_id        BIGINT          NOT NULL,
    schedule_date   DATE            NOT NULL,           -- 排班日期
    start_time      TIME            NOT NULL,           -- 开始时间
    end_time        TIME            NOT NULL,           -- 结束时间
    status          SMALLINT        DEFAULT 1,          -- 1:上班 2:休息 3:请假
    remark          VARCHAR(256),
    created_at      TIMESTAMP       DEFAULT NOW(),
    updated_at      TIMESTAMP       DEFAULT NOW(),
    deleted_at      TIMESTAMP       DEFAULT NULL,
    
    CONSTRAINT fk_schedule_coach FOREIGN KEY (coach_id) REFERENCES coach(id),
    CONSTRAINT uk_schedule UNIQUE (coach_id, schedule_date, start_time)
);

CREATE INDEX idx_schedule_coach ON coach_schedule(coach_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_schedule_date ON coach_schedule(schedule_date) WHERE deleted_at IS NULL;

三、领域模型设计

3.1 领域模型类图

┌─────────────────────────────────────────────────────────────────────────┐
│                          预约领域模型                                    │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌───────────────────────────────────────────────────────────────────┐  │
│  │                        <<Entity>>                                  │  │
│  │                       BookingSlot                                  │  │
│  ├───────────────────────────────────────────────────────────────────┤  │
│  │ - id: Long                                                        │  │
│  │ - tenantId: Long                                                  │  │
│  │ - storeId: Long                                                   │  │
│  │ - resourceType: ResourceType                                      │  │
│  │ - resourceId: Long                                                │  │
│  │ - coachId: Long                                                   │  │
│  │ - venueId: Long                                                   │  │
│  │ - startTime: LocalDateTime                                        │  │
│  │ - endTime: LocalDateTime                                          │  │
│  │ - capacity: Integer                                               │  │
│  │ - bookedCount: Integer                                            │  │
│  │ - waitlistCount: Integer                                          │  │
│  │ - status: SlotStatus                                              │  │
│  │ - version: Integer                                                │  │
│  ├───────────────────────────────────────────────────────────────────┤  │
│  │ + hasCapacity(): Boolean                                          │  │
│  │ + getRemainCapacity(): Integer                                    │  │
│  │ + canBook(): Boolean                                              │  │
│  │ + book(): void                                                    │  │
│  │ + cancel(): void                                                  │  │
│  │ + isExpired(): Boolean                                            │  │
│  │ + isFull(): Boolean                                               │  │
│  │ + addToWaitlist(): void                                           │  │
│  │ + removeFromWaitlist(): void                                      │  │
│  └───────────────────────────────────────────────────────────────────┘  │
│                                    │                                     │
│                                    │ 1:N                                 │
│                                    ▼                                     │
│  ┌────────────────────────────┐   ┌────────────────────────────┐       │
│  │      <<Entity>>            │   │      <<Entity>>            │       │
│  │     BookingRecord          │   │    BookingWaitlist         │       │
│  ├────────────────────────────┤   ├────────────────────────────┤       │
│  │ - id: Long                 │   │ - id: Long                 │       │
│  │ - memberId: Long           │   │ - memberId: Long           │       │
│  │ - slotId: Long             │   │ - slotId: Long             │       │
│  │ - bookingNo: String        │   │ - queueNo: Integer         │       │
│  │ - status: BookingStatus    │   │ - status: WaitlistStatus   │       │
│  │ - priceType: PriceType     │   │ - expireAt: LocalDateTime  │       │
│  │ - priceValue: BigDecimal   │   ├────────────────────────────┤       │
│  │ - checkinStatus: CheckinSt │   │ + isExpired(): Boolean     │       │
│  ├────────────────────────────┤   │ + convert(): void          │       │
│  │ + canCancel(): Boolean     │   └────────────────────────────┘       │
│  │ + cancel(): void           │                                        │
│  │ + checkin(): void          │                                        │
│  │ + isCheckinable(): Boolean │                                        │
│  └────────────────────────────┘                                        │
│                                                                         │
│  ┌────────────────────────────┐   ┌────────────────────────────┐       │
│  │      <<ValueObject>>       │   │      <<ValueObject>>       │       │
│  │      ResourceType          │   │       SlotStatus           │       │
│  ├────────────────────────────┤   ├────────────────────────────┤       │
│  │ GROUP_CLASS(1, "团课")     │   │ AVAILABLE(1, "可预约")     │       │
│  │ PRIVATE(2, "私教")         │   │ FULL(2, "已满")            │       │
│  │ VENUE(3, "场地")           │   │ CANCELLED(3, "已取消")     │       │
│  │ ONLINE(4, "线上")          │   │ ENDED(4, "已结束")         │       │
│  └────────────────────────────┘   └────────────────────────────┘       │
│                                                                         │
│  ┌────────────────────────────┐   ┌────────────────────────────┐       │
│  │      <<ValueObject>>       │   │      <<ValueObject>>       │       │
│  │      BookingStatus         │   │      PriceType             │       │
│  ├────────────────────────────┤   ├────────────────────────────┤       │
│  │ BOOKED(1, "已预约")        │   │ TIMES(1, "扣次")           │       │
│  │ CANCELLED(2, "已取消")     │   │ DURATION(2, "扣时长")      │       │
│  │ COMPLETED(3, "已完成")     │   │ AMOUNT(3, "扣金额")        │       │
│  │ EXPIRED(4, "已过期")       │   └────────────────────────────┘       │
│  └────────────────────────────┘                                        │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

3.2 领域服务

┌─────────────────────────────────────────────────────────────────────────┐
│                          领域服务设计                                    │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌───────────────────────────────────────────────────────────────────┐  │
│  │                    <<DomainService>>                               │  │
│  │                    BookingDomainService                            │  │
│  ├───────────────────────────────────────────────────────────────────┤  │
│  │ + createBooking(command: CreateBookingCommand): BookingRecord     │  │
│  │ + cancelBooking(bookingId: Long, reason: String): void            │  │
│  │ + checkin(bookingId: Long): void                                  │  │
│  │ + addToWaitlist(memberId: Long, slotId: Long): void               │  │
│  │ + processWaitlist(slotId: Long): void                             │  │
│  │ + validateBooking(memberId: Long, slotId: Long): void             │  │
│  └───────────────────────────────────────────────────────────────────┘  │
│                                                                         │
│  ┌───────────────────────────────────────────────────────────────────┐  │
│  │                    <<DomainService>>                               │  │
│  │                    SlotDomainService                               │  │
│  ├───────────────────────────────────────────────────────────────────┤  │
│  │ + createSlot(command: CreateSlotCommand): BookingSlot             │  │
│  │ + batchCreateSlots(command: BatchSlotCommand): List<Slot>         │  │
│  │ + updateSlot(slotId: Long, command: UpdateSlotCommand): void      │  │
│  │ + cancelSlot(slotId: Long, reason: String): void                  │  │
│  │ + getAvailableSlots(query: SlotQuery): List<BookingSlot>          │  │
│  │ + incrementBookedCount(slotId: Long): Boolean                     │  │
│  │ + decrementBookedCount(slotId: Long): void                        │  │
│  └───────────────────────────────────────────────────────────────────┘  │
│                                                                         │
│  ┌───────────────────────────────────────────────────────────────────┐  │
│  │                    <<DomainService>>                               │  │
│  │                    InventoryDomainService                          │  │
│  ├───────────────────────────────────────────────────────────────────┤  │
│  │ + checkInventory(slotId: Long): Boolean                           │  │
│  │ + reserveInventory(slotId: Long, count: Integer): Boolean         │  │
│  │ + releaseInventory(slotId: Long, count: Integer): void            │  │
│  │ + preloadInventory(slotIds: List<Long>): void                     │  │
│  └───────────────────────────────────────────────────────────────────┘  │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

四、业务流程设计

4.1 团课预约流程

┌─────────────────────────────────────────────────────────────────────────┐
│                          团课预约流程                                    │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  会员端      API层      BookingService    BenefitService    数据层       │
│    │          │              │                 │              │          │
│    │ 1.选择课程 │              │                 │              │          │
│    │─────────▶│              │                 │              │          │
│    │          │ 2.查询时段    │                 │              │          │
│    │          │─────────────▶│                 │              │          │
│    │          │              │ 3.查询可预约时段 │              │          │
│    │          │              │───────────────────────────────▶│          │
│    │          │              │◀───────────────────────────────│          │
│    │◀─────────│ 返回时段列表  │                 │              │          │
│    │          │              │                 │              │          │
│    │ 4.提交预约 │              │                 │              │          │
│    │─────────▶│              │                 │              │          │
│    │          │ 5.创建预约    │                 │              │          │
│    │          │─────────────▶│                 │              │          │
│    │          │              │ 6.校验时段      │              │          │
│    │          │              │───────────────────────────────▶│          │
│    │          │              │◀───────────────────────────────│          │
│    │          │              │                 │              │          │
│    │          │              │ 7.校验会员状态  │              │          │
│    │          │              │───────────────────────────────▶│          │
│    │          │              │◀───────────────────────────────│          │
│    │          │              │                 │              │          │
│    │          │              │ 8.检查库存(原子)│              │          │
│    │          │              │───────────────────────────────▶│          │
│    │          │              │◀───────────────────────────────│          │
│    │          │              │                 │              │          │
│    │          │              │ 9.扣减权益      │              │          │
│    │          │              │────────────────▶│              │          │
│    │          │              │                 │─────────────▶│          │
│    │          │              │                 │◀─────────────│          │
│    │          │              │◀────────────────│              │          │
│    │          │              │                 │              │          │
│    │          │              │ 10.创建预约记录 │              │          │
│    │          │              │───────────────────────────────▶│          │
│    │          │              │                 │              │          │
│    │          │              │ 11.增加预约人数 │              │          │
│    │          │              │───────────────────────────────▶│          │
│    │          │              │◀───────────────────────────────│          │
│    │          │              │                 │              │          │
│    │          │              │ 12.发送预约通知 │              │          │
│    │◀─────────│◀─────────────│                 │              │          │
│    │ 返回预约成功│            │                 │              │          │
│    │          │              │                 │              │          │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

4.2 取消预约流程

┌─────────────────────────────────────────────────────────────────────────┐
│                          取消预约流程                                    │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  会员端      API层      BookingService    BenefitService    数据层       │
│    │          │              │                 │              │          │
│    │ 1.请求取消 │              │                 │              │          │
│    │─────────▶│              │                 │              │          │
│    │          │ 2.查询预约    │                 │              │          │
│    │          │─────────────▶│                 │              │          │
│    │          │              │───────────────────────────────▶│          │
│    │          │              │◀───────────────────────────────│          │
│    │          │              │                 │              │          │
│    │          │              │ 3.校验可取消    │              │          │
│    │          │              │  - 状态检查     │              │          │
│    │          │              │  - 时间检查     │              │          │
│    │          │              │                 │              │          │
│    │          │              │ 4.计算退款金额  │              │          │
│    │          │              │                 │              │          │
│    │          │              │ 5.退还权益      │              │          │
│    │          │              │────────────────▶│              │          │
│    │          │              │                 │─────────────▶│          │
│    │          │              │                 │◀─────────────│          │
│    │          │              │◀────────────────│              │          │
│    │          │              │                 │              │          │
│    │          │              │ 6.更新预约状态  │              │          │
│    │          │              │───────────────────────────────▶│          │
│    │          │              │                 │              │          │
│    │          │              │ 7.减少预约人数  │              │          │
│    │          │              │───────────────────────────────▶│          │
│    │          │              │                 │              │          │
│    │          │              │ 8.处理候补队列  │              │          │
│    │          │              │───────────────────────────────▶│          │
│    │          │              │◀───────────────────────────────│          │
│    │          │              │                 │              │          │
│    │          │              │ 9.发送取消通知  │              │          │
│    │◀─────────│◀─────────────│                 │              │          │
│    │ 返回取消成功│            │                 │              │          │
│    │          │              │                 │              │          │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

五、接口设计

5.1 课程接口

5.1.1 获取课程列表

GET /v1/courses?storeId=1&type=1&status=1

Response:
{
  "code": 0,
  "message": "success",
  "data": {
    "list": [
      {
        "id": 1,
        "name": "瑜伽基础课",
        "type": 1,
        "typeName": "团课",
        "category": "瑜伽",
        "coverImage": "https://xxx.com/yoga.jpg",
        "duration": 60,
        "capacity": 20,
        "difficulty": 1,
        "difficultyName": "入门",
        "calories": 200,
        "priceType": 1,
        "priceValue": 1,
        "priceTypeName": "扣1次"
      }
    ],
    "total": 10
  }
}

5.2 预约接口

5.2.1 创建预约

POST /v1/bookings

Request:
{
  "slotId": 1,
  "memberId": 10001,
  "source": "miniprogram"
}

Response:
{
  "code": 0,
  "message": "success",
  "data": {
    "bookingId": 1,
    "bookingNo": "B202602280001",
    "courseName": "瑜伽基础课",
    "coachName": "王教练",
    "venueName": "瑜伽室A",
    "startTime": "2026-03-01T09:00:00",
    "endTime": "2026-03-01T10:00:00",
    "priceType": 1,
    "priceValue": 1,
    "status": 1,
    "statusName": "已预约",
    "createdAt": "2026-02-28T10:00:00"
  }
}

5.2.2 取消预约

POST /v1/bookings/{id}/cancel

Request:
{
  "reason": "临时有事"
}

Response:
{
  "code": 0,
  "message": "success",
  "data": {
    "bookingId": 1,
    "status": 2,
    "statusName": "已取消",
    "refundAmount": 1,
    "refundTypeName": "退还1次"
  }
}

5.2.3 获取我的预约

GET /v1/bookings/my?memberId=10001&status=1&page=1&pageSize=20

Response:
{
  "code": 0,
  "message": "success",
  "data": {
    "list": [
      {
        "id": 1,
        "bookingNo": "B202602280001",
        "resourceType": 1,
        "resourceTypeName": "团课",
        "courseName": "瑜伽基础课",
        "coachName": "王教练",
        "coachAvatar": "https://xxx.com/coach.jpg",
        "venueName": "瑜伽室A",
        "startTime": "2026-03-01T09:00:00",
        "endTime": "2026-03-01T10:00:00",
        "status": 1,
        "statusName": "已预约",
        "checkinStatus": 0,
        "checkinStatusName": "未签到",
        "canCancel": true,
        "cancelDeadline": "2026-03-01T07:00:00"
      }
    ],
    "total": 5,
    "page": 1,
    "pageSize": 20
  }
}

六、核心代码设计

6.1 预约时段实体

package com.gym.domain.model.booking;

import com.gym.domain.model.base.BaseEntity;
import com.gym.domain.model.base.AggregateRoot;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;

@Getter
@Setter
public class BookingSlot extends BaseEntity implements AggregateRoot {
    
    private Long tenantId;
    private Long storeId;
    private ResourceType resourceType;
    private Long resourceId;
    private Long coachId;
    private Long venueId;
    private String title;
    private LocalDateTime startTime;
    private LocalDateTime endTime;
    private Integer capacity;
    private Integer bookedCount;
    private Integer waitlistCount;
    private Integer minCapacity;
    private SlotStatus status;
    private BigDecimal price;
    private PriceType priceType;
    private BigDecimal priceValue;
    private LocalDateTime bookingStart;
    private LocalDateTime bookingEnd;
    private LocalDateTime cancelDeadline;
    private Integer version;
    
    public boolean hasCapacity() {
        return bookedCount < capacity;
    }
    
    public Integer getRemainCapacity() {
        return Math.max(0, capacity - bookedCount);
    }
    
    public boolean canBook() {
        if (!SlotStatus.AVAILABLE.equals(status)) {
            return false;
        }
        if (!hasCapacity()) {
            return false;
        }
        LocalDateTime now = LocalDateTime.now();
        if (bookingStart != null && now.isBefore(bookingStart)) {
            return false;
        }
        if (bookingEnd != null && now.isAfter(bookingEnd)) {
            return false;
        }
        return true;
    }
    
    public boolean isExpired() {
        return LocalDateTime.now().isAfter(endTime);
    }
    
    public boolean isFull() {
        return bookedCount >= capacity;
    }
    
    public void book() {
        if (!canBook()) {
            throw new BookingException(BookingException.SLOT_NOT_AVAILABLE);
        }
        this.bookedCount++;
        this.version++;
        this.updatedAt = LocalDateTime.now();
        
        if (isFull()) {
            this.status = SlotStatus.FULL;
        }
    }
    
    public void cancel() {
        if (this.bookedCount > 0) {
            this.bookedCount--;
        }
        this.version++;
        this.updatedAt = LocalDateTime.now();
        
        if (SlotStatus.FULL.equals(status) && !isFull()) {
            this.status = SlotStatus.AVAILABLE;
        }
    }
}

6.2 预约服务

package com.gym.domain.service;

import com.gym.domain.model.booking.*;
import com.gym.domain.repository.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.reactive.TransactionalOperator;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

@Service
@RequiredArgsConstructor
public class BookingDomainService {
    
    private final BookingSlotRepository slotRepository;
    private final BookingRecordRepository recordRepository;
    private final BookingWaitlistRepository waitlistRepository;
    private final BenefitDomainService benefitService;
    private final TransactionalOperator rxtx;
    
    public Mono<BookingRecord> createBooking(Long memberId, Long slotId, String source) {
        return Mono.defer(() -> 
            slotRepository.findById(slotId)
                .switchIfEmpty(Mono.error(new BookingException(BookingException.SLOT_NOT_FOUND)))
                .flatMap(slot -> {
                    if (!slot.canBook()) {
                        return Mono.error(new BookingException(BookingException.SLOT_NOT_AVAILABLE));
                    }
                    
                    return recordRepository.existsByMemberIdAndSlotIdAndStatus(
                        memberId, slotId, BookingStatus.BOOKED
                    ).flatMap(exists -> {
                        if (exists) {
                            return Mono.error(new BookingException(BookingException.ALREADY_BOOKED));
                        }
                        
                        BookingRecord record = new BookingRecord();
                        record.setTenantId(slot.getTenantId());
                        record.setStoreId(slot.getStoreId());
                        record.setMemberId(memberId);
                        record.setSlotId(slotId);
                        record.setResourceType(slot.getResourceType());
                        record.setResourceId(slot.getResourceId());
                        record.setCoachId(slot.getCoachId());
                        record.setBookingNo(generateBookingNo(slot.getTenantId()));
                        record.setStatus(BookingStatus.BOOKED);
                        record.setPrice(slot.getPrice());
                        record.setPriceType(slot.getPriceType());
                        record.setPriceValue(slot.getPriceValue());
                        record.setSource(source);
                        record.setCheckinStatus(CheckinStatus.NOT_CHECKED);
                        
                        return deductBenefit(memberId, slot)
                            .flatMap(benefitId -> {
                                record.setBenefitId(benefitId);
                                slot.book();
                                
                                return Mono.when(
                                    recordRepository.save(record),
                                    slotRepository.save(slot)
                                ).thenReturn(record);
                            });
                    });
                })
        ).as(rxtx::transactional);
    }
    
    public Mono<Void> cancelBooking(Long bookingId, String reason, Long operatorId) {
        return Mono.defer(() ->
            recordRepository.findById(bookingId)
                .switchIfEmpty(Mono.error(new BookingException(BookingException.BOOKING_NOT_FOUND)))
                .flatMap(record -> {
                    if (!record.canCancel()) {
                        return Mono.error(new BookingException(BookingException.CANNOT_CANCEL));
                    }
                    
                    return slotRepository.findById(record.getSlotId())
                        .flatMap(slot -> refundBenefit(record)
                            .flatMap(v -> {
                                record.cancel(reason, operatorId);
                                slot.cancel();
                                
                                return Mono.when(
                                    recordRepository.save(record),
                                    slotRepository.save(slot)
                                ).then(processWaitlist(slot.getId()));
                            }));
                })
        ).as(rxtx::transactional);
    }
    
    private Mono<Long> deductBenefit(Long memberId, BookingSlot slot) {
        if (slot.getPriceType() == null || slot.getPriceValue() == null) {
            return Mono.just(null);
        }
        
        return benefitService.deductBenefit(
            memberId,
            mapPriceTypeToBenefitType(slot.getPriceType()),
            null,
            slot.getPriceValue(),
            "booking",
            slot.getId(),
            "预约: " + slot.getTitle()
        ).then(Mono.just(1L));
    }
    
    private Mono<Void> refundBenefit(BookingRecord record) {
        if (record.getPriceType() == null || record.getPriceValue() == null) {
            return Mono.empty();
        }
        
        return benefitService.addBenefit(
            record.getMemberId(),
            record.getBenefitId(),
            mapPriceTypeToBenefitType(record.getPriceType()),
            null,
            "取消预约退还",
            record.getPriceValue(),
            null,
            null,
            "refund",
            record.getId()
        ).then();
    }
    
    private Mono<Void> processWaitlist(Long slotId) {
        return waitlistRepository.findFirstBySlotIdOrderByQueueNo(slotId)
            .flatMap(waitlist -> {
                waitlist.convert();
                return waitlistRepository.save(waitlist)
                    .then(createBooking(waitlist.getMemberId(), slotId, "waitlist"))
                    .then();
            })
            .switchIfEmpty(Mono.empty());
    }
    
    private String generateBookingNo(Long tenantId) {
        String prefix = "B" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
        return prefix + String.format("%04d", (int)(Math.random() * 10000));
    }
    
    private BenefitType mapPriceTypeToBenefitType(PriceType priceType) {
        return switch (priceType) {
            case TIMES -> BenefitType.TIMES;
            case DURATION -> BenefitType.DURATION;
            case AMOUNT -> BenefitType.STORED_VALUE;
        };
    }
}

6.3 库存服务

package com.gym.domain.service;

import com.github.benmanes.caffeine.cache.Cache;
import com.gym.domain.repository.BookingSlotRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;

@Slf4j
@Service
@RequiredArgsConstructor
public class InventoryDomainService {
    
    private final BookingSlotRepository slotRepository;
    private final DatabaseClient databaseClient;
    private final Cache<Long, Integer> inventoryCache;
    
    public Mono<Boolean> checkInventory(Long slotId) {
        return getRemainCapacity(slotId)
            .map(remain -> remain > 0);
    }
    
    public Mono<Boolean> reserveInventory(Long slotId) {
        return databaseClient.sql("""
            UPDATE booking_slot 
            SET booked_count = booked_count + 1,
                version = version + 1,
                updated_at = NOW()
            WHERE id = :slotId 
            AND deleted_at IS NULL
            AND booked_count < capacity
            """)
            .bind("slotId", slotId)
            .fetch()
            .rowsUpdated()
            .map(rows -> rows > 0)
            .doOnNext(success -> {
                if (success) {
                    invalidateCache(slotId);
                }
            });
    }
    
    public Mono<Void> releaseInventory(Long slotId) {
        return databaseClient.sql("""
            UPDATE booking_slot 
            SET booked_count = GREATEST(booked_count - 1, 0),
                version = version + 1,
                updated_at = NOW()
            WHERE id = :slotId 
            AND deleted_at IS NULL
            """)
            .bind("slotId", slotId)
            .fetch()
            .rowsUpdated()
            .then()
            .doOnSuccess(v -> invalidateCache(slotId));
    }
    
    public void preloadInventory(Long slotId) {
        slotRepository.findById(slotId)
            .subscribe(slot -> {
                int remain = slot.getCapacity() - slot.getBookedCount();
                inventoryCache.put(slotId, remain);
                log.debug("预加载库存: slotId={}, remain={}", slotId, remain);
            });
    }
    
    private Mono<Integer> getRemainCapacity(Long slotId) {
        Integer cached = inventoryCache.getIfPresent(slotId);
        if (cached != null) {
            return Mono.just(cached);
        }
        
        return slotRepository.findById(slotId)
            .map(slot -> {
                int remain = slot.getCapacity() - slot.getBookedCount();
                inventoryCache.put(slotId, remain);
                return remain;
            });
    }
    
    private void invalidateCache(Long slotId) {
        inventoryCache.invalidate(slotId);
    }
}

七、高并发处理

7.1 预约并发控制

┌─────────────────────────────────────────────────────────────────────────┐
│                        高并发预约处理方案                                │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  1. 库存预热                                                             │
│     ├── 热门课程开抢前5分钟预热库存到Caffeine缓存                        │
│     ├── 定时任务扫描即将开始的课程时段                                   │
│     └── 预热数据: slotId -> remainCapacity                              │
│                                                                         │
│  2. 原子操作                                                             │
│     └── 使用PostgreSQL原子更新保证库存一致性:                            │
│         UPDATE booking_slot                                             │
│         SET booked_count = booked_count + 1                             │
│         WHERE id = ? AND booked_count < capacity                        │
│         RETURNING booked_count                                          │
│                                                                         │
│  3. 乐观锁                                                               │
│     └── 使用version字段防止并发更新冲突:                                 │
│         UPDATE booking_slot                                             │
│         SET booked_count = ?, version = version + 1                     │
│         WHERE id = ? AND version = ?                                    │
│                                                                         │
│  4. 请求排队                                                             │
│     ├── 使用信号量控制并发请求数                                         │
│     ├── 超出限制的请求进入等待队列                                       │
│     └── 避免数据库连接池耗尽                                            │
│                                                                         │
│  5. 限流保护                                                             │
│     ├── 接口限流: 单用户10次/秒                                         │
│     ├── 热门课程限流: 令牌桶算法                                         │
│     └── 超出限流返回"系统繁忙,请稍后重试"                               │
│                                                                         │
│  6. 降级策略                                                             │
│     ├── 库存查询降级: 优先返回缓存数据                                   │
│     ├── 预约失败降级: 返回排队页面                                       │
│     └── 数据库压力过大时: 暂停预约功能                                   │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

八、缓存设计

8.1 缓存策略

┌─────────────────────────────────────────────────────────────────────────┐
│                          预约模块缓存策略                                │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  1. 时段库存缓存                                                         │
│     ├── Key: slot:inventory:{slotId}                                    │
│     ├── Value: Integer (剩余容量)                                       │
│     ├── TTL: 10分钟                                                     │
│     ├── 更新策略: 预约/取消时更新                                        │
│     └── 预热: 热门课程开抢前预热                                         │
│                                                                         │
│  2. 时段详情缓存                                                         │
│     ├── Key: slot:detail:{slotId}                                       │
│     ├── Value: BookingSlot JSON                                         │
│     ├── TTL: 30分钟                                                     │
│     └── 更新策略: 时段变更时删除                                         │
│                                                                         │
│  3. 课程信息缓存                                                         │
│     ├── Key: course:info:{courseId}                                     │
│     ├── Value: Course JSON                                              │
│     ├── TTL: 1小时                                                      │
│     └── 更新策略: 课程变更时删除                                         │
│                                                                         │
│  4. 教练排班缓存                                                         │
│     ├── Key: coach:schedule:{coachId}:{date}                            │
│     ├── Value: List<Schedule> JSON                                      │
│     ├── TTL: 1天                                                        │
│     └── 更新策略: 排班变更时删除                                         │
│                                                                         │
│  5. 用户预约锁                                                           │
│     ├── Key: booking:lock:{memberId}:{slotId}                           │
│     ├── Value: 1                                                        │
│     ├── TTL: 5秒                                                        │
│     └── 用途: 防止重复预约                                               │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

九、定时任务

9.1 定时任务列表

任务名称 执行频率 功能描述
SlotStatusTask 每分钟 更新已结束时段状态
WaitlistExpireTask 每分钟 处理候补转正超时
BookingRemindTask 每小时 发送预约提醒通知
SlotEndTask 每小时 标记缺席会员
InventoryPreloadTask 开抢前5分钟 预热热门课程库存

十、附录

10.1 枚举定义

public enum ResourceType {
    GROUP_CLASS(1, "团课"),
    PRIVATE(2, "私教"),
    VENUE(3, "场地"),
    ONLINE(4, "线上");
}

public enum SlotStatus {
    AVAILABLE(1, "可预约"),
    FULL(2, "已满"),
    CANCELLED(3, "已取消"),
    ENDED(4, "已结束");
}

public enum BookingStatus {
    BOOKED(1, "已预约"),
    CANCELLED(2, "已取消"),
    COMPLETED(3, "已完成"),
    EXPIRED(4, "已过期");
}

public enum CheckinStatus {
    NOT_CHECKED(0, "未签到"),
    CHECKED(1, "已签到"),
    LATE(2, "迟到"),
    ABSENT(3, "缺席");
}

public enum PriceType {
    TIMES(1, "扣次"),
    DURATION(2, "扣时长"),
    AMOUNT(3, "扣金额");
}

10.2 异常定义

public class BookingException extends BusinessException {
    
    public static final BookingException SLOT_NOT_FOUND = 
        new BookingException(40201, "时段不存在");
    
    public static final BookingException SLOT_NOT_AVAILABLE = 
        new BookingException(40202, "时段不可预约");
    
    public static final BookingException SLOT_FULL = 
        new BookingException(40203, "课程已满");
    
    public static final BookingException ALREADY_BOOKED = 
        new BookingException(40204, "已预约该课程");
    
    public static final BookingException BOOKING_NOT_FOUND = 
        new BookingException(40205, "预约记录不存在");
    
    public static final BookingException CANNOT_CANCEL = 
        new BookingException(40206, "无法取消预约");
    
    public static final BookingException CANNOT_CHECKIN = 
        new BookingException(40207, "无法签到");
}

十一、版本历史

版本 日期 作者 变更内容
v1.0 2026-02-28 张翔 初稿

文档结束