# 健身房管理系统详细设计文档 - 预约模块(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) ```sql 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) ```sql 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) ```sql 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) ```sql 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) ```sql 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) ```sql 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 领域模型类图 ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ 预约领域模型 │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌───────────────────────────────────────────────────────────────────┐ │ │ │ <> │ │ │ │ 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 │ │ ▼ │ │ ┌────────────────────────────┐ ┌────────────────────────────┐ │ │ │ <> │ │ <> │ │ │ │ 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 │ │ │ └────────────────────────────┘ │ │ │ │ ┌────────────────────────────┐ ┌────────────────────────────┐ │ │ │ <> │ │ <> │ │ │ │ ResourceType │ │ SlotStatus │ │ │ ├────────────────────────────┤ ├────────────────────────────┤ │ │ │ GROUP_CLASS(1, "团课") │ │ AVAILABLE(1, "可预约") │ │ │ │ PRIVATE(2, "私教") │ │ FULL(2, "已满") │ │ │ │ VENUE(3, "场地") │ │ CANCELLED(3, "已取消") │ │ │ │ ONLINE(4, "线上") │ │ ENDED(4, "已结束") │ │ │ └────────────────────────────┘ └────────────────────────────┘ │ │ │ │ ┌────────────────────────────┐ ┌────────────────────────────┐ │ │ │ <> │ │ <> │ │ │ │ BookingStatus │ │ PriceType │ │ │ ├────────────────────────────┤ ├────────────────────────────┤ │ │ │ BOOKED(1, "已预约") │ │ TIMES(1, "扣次") │ │ │ │ CANCELLED(2, "已取消") │ │ DURATION(2, "扣时长") │ │ │ │ COMPLETED(3, "已完成") │ │ AMOUNT(3, "扣金额") │ │ │ │ EXPIRED(4, "已过期") │ └────────────────────────────┘ │ │ └────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────┘ ``` ### 3.2 领域服务 ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ 领域服务设计 │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌───────────────────────────────────────────────────────────────────┐ │ │ │ <> │ │ │ │ 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 │ │ │ └───────────────────────────────────────────────────────────────────┘ │ │ │ │ ┌───────────────────────────────────────────────────────────────────┐ │ │ │ <> │ │ │ │ SlotDomainService │ │ │ ├───────────────────────────────────────────────────────────────────┤ │ │ │ + createSlot(command: CreateSlotCommand): BookingSlot │ │ │ │ + batchCreateSlots(command: BatchSlotCommand): List │ │ │ │ + updateSlot(slotId: Long, command: UpdateSlotCommand): void │ │ │ │ + cancelSlot(slotId: Long, reason: String): void │ │ │ │ + getAvailableSlots(query: SlotQuery): List │ │ │ │ + incrementBookedCount(slotId: Long): Boolean │ │ │ │ + decrementBookedCount(slotId: Long): void │ │ │ └───────────────────────────────────────────────────────────────────┘ │ │ │ │ ┌───────────────────────────────────────────────────────────────────┐ │ │ │ <> │ │ │ │ InventoryDomainService │ │ │ ├───────────────────────────────────────────────────────────────────┤ │ │ │ + checkInventory(slotId: Long): Boolean │ │ │ │ + reserveInventory(slotId: Long, count: Integer): Boolean │ │ │ │ + releaseInventory(slotId: Long, count: Integer): void │ │ │ │ + preloadInventory(slotIds: List): 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 预约时段实体 ```java 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 预约服务 ```java 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 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 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 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 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 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 库存服务 ```java 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 inventoryCache; public Mono checkInventory(Long slotId) { return getRemainCapacity(slotId) .map(remain -> remain > 0); } public Mono 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 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 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 JSON │ │ ├── TTL: 1天 │ │ └── 更新策略: 排班变更时删除 │ │ │ │ 5. 用户预约锁 │ │ ├── Key: booking:lock:{memberId}:{slotId} │ │ ├── Value: 1 │ │ ├── TTL: 5秒 │ │ └── 用途: 防止重复预约 │ │ │ └─────────────────────────────────────────────────────────────────────────┘ ``` --- ## 九、定时任务 ### 9.1 定时任务列表 | 任务名称 | 执行频率 | 功能描述 | |---------|---------|---------| | SlotStatusTask | 每分钟 | 更新已结束时段状态 | | WaitlistExpireTask | 每分钟 | 处理候补转正超时 | | BookingRemindTask | 每小时 | 发送预约提醒通知 | | SlotEndTask | 每小时 | 标记缺席会员 | | InventoryPreloadTask | 开抢前5分钟 | 预热热门课程库存 | --- ## 十、附录 ### 10.1 枚举定义 ```java 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 异常定义 ```java 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 | 张翔 | 初稿 | --- *文档结束*