c1d7660aac
- 修复HLD-系统概要设计.md中所有ASCII图表的右侧边框对齐 - 修复LLD-签到模块详细设计.md中ASCII图表的右侧边框对齐 - 修复LLD-会员模块详细设计.md中ASCII图表的右侧边框对齐 - 修复LLD-预约模块详细设计.md中ASCII图表的右侧边框对齐 - 确保所有ASCII图表的右侧边框纵向靠右对齐
66 KiB
66 KiB
健身房管理系统详细设计文档 - 预约模块(LLD)
文档编号: GYM-LLD-002
版本: v1.0
日期: 2026-02-28
作者: 张翔
状态: 初稿
文档修订历史
| 版本 | 日期 | 作者 | 修订内容 |
|---|---|---|---|
| v1.0 | 2026-02-28 | 张翔 | 初稿 |
参考文档
- 《健身房管理系统产品设计文档》 GYM-PRD-001
- 《健身房管理系统概要设计文档》 GYM-HLD-001
- 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;
@Transactional
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);
}
@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 | 张翔 | 初稿 |
文档结束