# 健身房管理系统详细设计文档 - 会员模块(LLD) > 文档编号: GYM-LLD-001 > 版本: v1.0 > 日期: 2026-02-28 > 作者: 张翔 > 状态: 初稿 --- ## 文档修订历史 | 版本 | 日期 | 作者 | 修订内容 | | ---- | ---------- | ---- | -------- | | 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 实体关系图 ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ 实体关系图 │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ │ │ │ tenant │ │ │ │ (租户表) │ │ │ └──────┬───────┘ │ │ │ 1:N │ │ ┌────┴────┐ │ │ ▼ ▼ │ │ ┌──────┐ ┌──────┐ │ │ │store │ │member│ │ │ │(门店)│ │(会员)│ │ │ └──┬───┘ └──┬───┘ │ │ │ 1:N │ 1:N │ │ │ │ │ │ │ └─────────────┐ │ │ │ │ │ │ │ ▼ │ │ │ ┌──────────────────┐ │ │ │ │ member_card │ │ │ │ │ (会员卡) │ │ │ │ └────────┬─────────┘ │ │ │ │ N:1 │ │ │ ▼ │ │ │ ┌──────────────────┐ │ │ │ │ card_type │ │ │ │ │ (卡类型) │ │ │ │ └────────┬─────────┘ │ │ │ │ 1:N │ │ │ ▼ │ │ │ ┌──────────────────┐ │ │ │ │ level_rule │ │ │ │ │ (等级规则) │ │ │ │ └──────────────────┘ │ │ │ │ │ │ ┌──────────────────┐ │ │ └───────────────────────┤ member_benefit │ │ │ │ (会员权益) │ │ │ └──────────────────┘ │ │ │ │ 关系说明: │ │ • tenant (1) ─── (N) store : 一个租户有多个门店 │ │ • tenant (1) ─── (N) member : 一个租户有多个会员 │ │ • store (1) ─── (N) member : 一个门店有多个会员 │ │ • member (1) ─── (N) member_card : 一个会员有多张卡 │ │ • member (1) ─── (N) member_benefit : 一个会员有多个权益 │ │ • member_card (N) ─── (1) card_type : 卡属于一种类型 │ │ • member_benefit (N) ─── (1) card_type : 权益属于一种卡类型 │ │ • card_type (1) ─── (N) level_rule : 卡类型有多个等级规则 │ │ │ └─────────────────────────────────────────────────────────────────────────┘ ``` ### 2.2 数据表设计 #### 2.2.1 会员表 (member) ```sql CREATE TABLE member ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL, store_id BIGINT NOT NULL, member_no VARCHAR(32) NOT NULL, name VARCHAR(64), phone VARCHAR(64) NOT NULL, -- AES加密存储 phone_mask VARCHAR(20), -- 脱敏手机号 avatar VARCHAR(512), gender SMALLINT DEFAULT 0, -- 0:未知 1:男 2:女 birthday DATE, id_card VARCHAR(128), -- AES加密存储 emergency_contact VARCHAR(64), emergency_phone VARCHAR(64), level SMALLINT DEFAULT 0, -- 会员等级 exp INT DEFAULT 0, -- 经验值 total_exp INT DEFAULT 0, -- 累计经验值 status SMALLINT DEFAULT 1, -- 1:正常 2:冻结 3:注销 register_source VARCHAR(32), -- 注册来源 last_login_at TIMESTAMP, last_login_ip VARCHAR(64), created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), created_by BIGINT, updated_by BIGINT, deleted_at TIMESTAMP DEFAULT NULL, CONSTRAINT uk_member_no UNIQUE (tenant_id, member_no), CONSTRAINT uk_member_phone UNIQUE (tenant_id, phone), CONSTRAINT fk_member_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id), CONSTRAINT fk_member_store FOREIGN KEY (store_id) REFERENCES store(id) ); CREATE INDEX idx_member_tenant ON member(tenant_id) WHERE deleted_at IS NULL; CREATE INDEX idx_member_store ON member(store_id) WHERE deleted_at IS NULL; CREATE INDEX idx_member_phone ON member(phone) WHERE deleted_at IS NULL; CREATE INDEX idx_member_level ON member(level) WHERE deleted_at IS NULL; CREATE INDEX idx_member_status ON member(status) WHERE deleted_at IS NULL; ``` #### 2.2.2 会员卡类型表 (card_type) ```sql CREATE TABLE card_type ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL, name VARCHAR(64) NOT NULL, code VARCHAR(32) NOT NULL, type SMALLINT NOT NULL, -- 1:时长卡 2:次卡 3:储值卡 4:等级卡 category SMALLINT DEFAULT 1, -- 1:团课卡 2:私教卡 3:通用卡 price DECIMAL(10,2) NOT NULL, original_price DECIMAL(10,2), duration_days INT, -- 时长卡有效期(天) total_times INT, -- 次卡总次数 stored_value DECIMAL(10,2), -- 储值卡金额 level SMALLINT, -- 等级卡等级 discount DECIMAL(3,2) DEFAULT 1.00, -- 折扣率 description TEXT, benefits JSONB, -- 权益配置 valid_days INT DEFAULT 365, -- 激活后有效天数 transferable BOOLEAN DEFAULT FALSE, -- 是否可转让 refundable BOOLEAN DEFAULT FALSE, -- 是否可退款 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 uk_card_type_code UNIQUE (tenant_id, code), CONSTRAINT fk_card_type_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id) ); CREATE INDEX idx_card_type_tenant ON card_type(tenant_id) WHERE deleted_at IS NULL; CREATE INDEX idx_card_type_status ON card_type(status) WHERE deleted_at IS NULL; ``` #### 2.2.3 会员卡表 (member_card) ```sql CREATE TABLE member_card ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL, member_id BIGINT NOT NULL, card_type_id BIGINT NOT NULL, card_no VARCHAR(32) NOT NULL, status SMALLINT DEFAULT 1, -- 1:未激活 2:有效 3:已过期 4:已用完 5:已冻结 price DECIMAL(10,2) NOT NULL, -- 购买价格 paid_amount DECIMAL(10,2) NOT NULL, -- 实付金额 start_date DATE, -- 生效日期 end_date DATE, -- 到期日期 freeze_at TIMESTAMP, -- 冻结时间 freeze_reason VARCHAR(256), -- 冻结原因 transfer_from BIGINT, -- 转让来源会员ID transfer_to BIGINT, -- 转让目标会员ID order_id BIGINT, -- 关联订单ID remark VARCHAR(256), created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), created_by BIGINT, updated_by BIGINT, deleted_at TIMESTAMP DEFAULT NULL, CONSTRAINT uk_member_card_no UNIQUE (tenant_id, card_no), CONSTRAINT fk_member_card_member FOREIGN KEY (member_id) REFERENCES member(id), CONSTRAINT fk_member_card_type FOREIGN KEY (card_type_id) REFERENCES card_type(id) ); CREATE INDEX idx_member_card_member ON member_card(member_id) WHERE deleted_at IS NULL; CREATE INDEX idx_member_card_status ON member_card(status) WHERE deleted_at IS NULL; CREATE INDEX idx_member_card_end_date ON member_card(end_date) WHERE deleted_at IS NULL; ``` #### 2.2.4 会员权益表 (member_benefit) ```sql CREATE TABLE member_benefit ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL, member_id BIGINT NOT NULL, card_id BIGINT, -- 关联会员卡ID type SMALLINT NOT NULL, -- 1:时长 2:次数 3:储值 4:等级 category SMALLINT DEFAULT 1, -- 1:团课 2:私教 3:通用 name VARCHAR(64) NOT NULL, -- 权益名称 value DECIMAL(12,2) NOT NULL, -- 权益值(天数/次数/金额) used_value DECIMAL(12,2) DEFAULT 0, -- 已使用值 remain_value DECIMAL(12,2) NOT NULL, -- 剩余值 unit VARCHAR(16), -- 单位: 天/次/元 expire_date DATE, -- 过期日期 status SMALLINT DEFAULT 1, -- 1:有效 2:已过期 3:已用完 source VARCHAR(32), -- 来源: purchase/reward/activity source_id BIGINT, -- 来源ID created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), deleted_at TIMESTAMP DEFAULT NULL, CONSTRAINT fk_benefit_member FOREIGN KEY (member_id) REFERENCES member(id), CONSTRAINT fk_benefit_card FOREIGN KEY (card_id) REFERENCES member_card(id) ); CREATE INDEX idx_benefit_member ON member_benefit(member_id) WHERE deleted_at IS NULL; CREATE INDEX idx_benefit_type ON member_benefit(type, category) WHERE deleted_at IS NULL; CREATE INDEX idx_benefit_status ON member_benefit(status) WHERE deleted_at IS NULL; CREATE INDEX idx_benefit_expire ON member_benefit(expire_date) WHERE deleted_at IS NULL; ``` #### 2.2.5 权益变更记录表 (benefit_log) ```sql CREATE TABLE benefit_log ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL, member_id BIGINT NOT NULL, benefit_id BIGINT NOT NULL, type SMALLINT NOT NULL, -- 1:增加 2:扣减 3:过期 4:冻结 5:解冻 before_value DECIMAL(12,2) NOT NULL, -- 变更前值 change_value DECIMAL(12,2) NOT NULL, -- 变更值 after_value DECIMAL(12,2) NOT NULL, -- 变更后值 reason VARCHAR(256), -- 变更原因 biz_type VARCHAR(32), -- 业务类型: booking/checkin/reward/refund biz_id BIGINT, -- 业务ID operator_id BIGINT, -- 操作人ID operator_type VARCHAR(32), -- 操作人类型: member/staff/system created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_benefit_log_member ON benefit_log(member_id); CREATE INDEX idx_benefit_log_benefit ON benefit_log(benefit_id); CREATE INDEX idx_benefit_log_biz ON benefit_log(biz_type, biz_id); CREATE INDEX idx_benefit_log_created ON benefit_log(created_at); ``` #### 2.2.6 会员等级规则表 (level_rule) ```sql CREATE TABLE level_rule ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL, level SMALLINT NOT NULL, -- 等级 name VARCHAR(32) NOT NULL, -- 等级名称 icon VARCHAR(256), -- 等级图标 min_exp INT NOT NULL, -- 最低经验值 max_exp INT, -- 最高经验值(为空表示无上限) discount DECIMAL(3,2) DEFAULT 1.00, -- 折扣率 benefits JSONB, -- 等级权益 upgrade_reward INT DEFAULT 0, -- 升级奖励经验值 created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), deleted_at TIMESTAMP DEFAULT NULL, CONSTRAINT uk_level_rule UNIQUE (tenant_id, level), CONSTRAINT fk_level_rule_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id) ); CREATE INDEX idx_level_rule_tenant ON level_rule(tenant_id) WHERE deleted_at IS NULL; ``` #### 2.2.7 经验值记录表 (exp_log) ```sql CREATE TABLE exp_log ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL, member_id BIGINT NOT NULL, type SMALLINT NOT NULL, -- 1:获得 2:消耗 change_exp INT NOT NULL, -- 变更经验值 before_exp INT NOT NULL, -- 变更前经验值 after_exp INT NOT NULL, -- 变更后经验值 before_level SMALLINT NOT NULL, -- 变更前等级 after_level SMALLINT NOT NULL, -- 变更后等级 source VARCHAR(32), -- 来源: checkin/booking/purchase/reward source_id BIGINT, -- 来源ID remark VARCHAR(256), created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_exp_log_member ON exp_log(member_id); CREATE INDEX idx_exp_log_created ON exp_log(created_at); ``` --- ## 三、领域模型设计 ### 3.1 领域模型类图 ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ 会员领域模型 │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌───────────────────────────────────────────────────────────────────┐ │ │ │ <> │ │ │ │ Member │ │ │ ├───────────────────────────────────────────────────────────────────┤ │ │ │ - id: Long │ │ │ │ - tenantId: Long │ │ │ │ - storeId: Long │ │ │ │ - memberNo: String │ │ │ │ - name: String │ │ │ │ - phone: String │ │ │ │ - avatar: String │ │ │ │ - gender: Gender │ │ │ │ - birthday: LocalDate │ │ │ │ - level: Integer │ │ │ │ - exp: Integer │ │ │ │ - status: MemberStatus │ │ │ │ - cards: List │ │ │ │ - benefits: List │ │ │ ├───────────────────────────────────────────────────────────────────┤ │ │ │ + activate(): void │ │ │ │ + freeze(reason: String): void │ │ │ │ + unfreeze(): void │ │ │ │ + addExp(exp: Integer): void │ │ │ │ + canLevelUp(): Boolean │ │ │ │ + levelUp(): void │ │ │ │ + getValidBenefits(): List │ │ │ │ + getUsableBenefit(type: BenefitType): MemberBenefit │ │ │ └───────────────────────────────────────────────────────────────────┘ │ │ │ │ │ │ 1:N │ │ ▼ │ │ ┌────────────────────────────┐ ┌────────────────────────────┐ │ │ │ <> │ │ <> │ │ │ │ MemberCard │ │ MemberBenefit │ │ │ ├────────────────────────────┤ ├────────────────────────────┤ │ │ │ - id: Long │ │ - id: Long │ │ │ │ - memberId: Long │ │ - memberId: Long │ │ │ │ - cardTypeId: Long │ │ - cardId: Long │ │ │ │ - cardNo: String │ │ - type: BenefitType │ │ │ │ - status: CardStatus │ │ - category: BenefitCategory│ │ │ │ - startDate: LocalDate │ │ - value: BigDecimal │ │ │ │ - endDate: LocalDate │ │ - usedValue: BigDecimal │ │ │ │ - price: BigDecimal │ │ - remainValue: BigDecimal │ │ │ ├────────────────────────────┤ │ - expireDate: LocalDate │ │ │ │ + activate(): void │ │ - status: BenefitStatus │ │ │ │ + freeze(): void │ ├────────────────────────────┤ │ │ │ + unfreeze(): void │ │ + deduct(value): void │ │ │ │ + isExpired(): Boolean │ │ + add(value): void │ │ │ │ + isUsable(): Boolean │ │ + isExpired(): Boolean │ │ │ │ + getRemainDays(): Integer │ │ + isUsable(): Boolean │ │ │ └────────────────────────────┘ └────────────────────────────┘ │ │ │ │ ┌────────────────────────────┐ ┌────────────────────────────┐ │ │ │ <> │ │ <> │ │ │ │ MemberStatus │ │ BenefitType │ │ │ ├────────────────────────────┤ ├────────────────────────────┤ │ │ │ NORMAL(1, "正常") │ │ DURATION(1, "时长") │ │ │ │ FROZEN(2, "冻结") │ │ TIMES(2, "次数") │ │ │ │ CANCELLED(3, "注销") │ │ STORED_VALUE(3, "储值") │ │ │ └────────────────────────────┘ │ LEVEL(4, "等级") │ │ │ └────────────────────────────┘ │ │ │ │ ┌────────────────────────────┐ ┌────────────────────────────┐ │ │ │ <> │ │ <> │ │ │ │ CardStatus │ │ BenefitCategory │ │ │ ├────────────────────────────┤ ├────────────────────────────┤ │ │ │ INACTIVE(1, "未激活") │ │ GROUP_CLASS(1, "团课") │ │ │ │ ACTIVE(2, "有效") │ │ PRIVATE(2, "私教") │ │ │ │ EXPIRED(3, "已过期") │ │ GENERAL(3, "通用") │ │ │ │ USED_UP(4, "已用完") │ └────────────────────────────┘ │ │ │ FROZEN(5, "已冻结") │ │ │ └────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────┘ ``` ### 3.2 领域服务 ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ 领域服务设计 │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌───────────────────────────────────────────────────────────────────┐ │ │ │ <> │ │ │ │ MemberDomainService │ │ │ ├───────────────────────────────────────────────────────────────────┤ │ │ │ + registerMember(command: RegisterMemberCommand): Member │ │ │ │ + updateMemberInfo(memberId: Long, command: UpdateMemberCommand) │ │ │ │ + freezeMember(memberId: Long, reason: String): void │ │ │ │ + unfreezeMember(memberId: Long): void │ │ │ │ + calculateLevel(memberId: Long): Integer │ │ │ └───────────────────────────────────────────────────────────────────┘ │ │ │ │ ┌───────────────────────────────────────────────────────────────────┐ │ │ │ <> │ │ │ │ BenefitDomainService │ │ │ ├───────────────────────────────────────────────────────────────────┤ │ │ │ + purchaseCard(command: PurchaseCardCommand): MemberCard │ │ │ │ + activateCard(cardId: Long): void │ │ │ │ + deductBenefit(memberId: Long, request: DeductRequest): void │ │ │ │ + refundBenefit(memberId: Long, request: RefundRequest): void │ │ │ │ + expireBenefits(): void │ │ │ │ + getUsableBenefits(memberId: Long, type: BenefitType): List │ │ │ └───────────────────────────────────────────────────────────────────┘ │ │ │ │ ┌───────────────────────────────────────────────────────────────────┐ │ │ │ <> │ │ │ │ LevelDomainService │ │ │ ├───────────────────────────────────────────────────────────────────┤ │ │ │ + addExp(memberId: Long, exp: Integer, source: String): void │ │ │ │ + calculateLevel(tenantId: Long, exp: Integer): Integer │ │ │ │ + getLevelBenefits(tenantId: Long, level: Integer): LevelBenefit │ │ │ └───────────────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────┘ ``` --- ## 四、业务流程设计 ### 4.1 会员注册流程 ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ 会员注册流程 │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ 会员端 API层 Service层 数据层 │ │ │ │ │ │ │ │ │ 1.输入手机号 │ │ │ │ │ │─────────────────▶│ │ │ │ │ │ │ 2.发送验证码 │ │ │ │ │ │───────────────────▶│ │ │ │ │ │ │ 3.调用短信服务 │ │ │ │ │ │─────────────────▶│ │ │ │ │ │◀─────────────────│ │ │ │ │◀───────────────────│ │ │ │ │◀─────────────────│ 返回验证码ID │ │ │ │ │ │ │ │ │ │ │ 4.提交注册信息 │ │ │ │ │ │─────────────────▶│ │ │ │ │ │ │ 5.验证验证码 │ │ │ │ │ │───────────────────▶│ │ │ │ │ │ │ 6.查询手机号 │ │ │ │ │ │─────────────────▶│ │ │ │ │ │◀─────────────────│ │ │ │ │ │ 7.检查是否已注册 │ │ │ │ │ │ │ │ │ │ │ │ 8.生成会员编号 │ │ │ │ │ │ 9.创建会员 │ │ │ │ │ │─────────────────▶│ │ │ │ │ │◀─────────────────│ │ │ │ │ │ 10.生成JWT Token │ │ │ │ │◀───────────────────│ │ │ │ │◀─────────────────│ 返回Token和会员信息 │ │ │ │ │ │ │ │ │ │ │ └─────────────────────────────────────────────────────────────────────────┘ ``` ### 4.2 会员卡购买流程 ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ 会员卡购买流程 │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ 会员端 API层 OrderService MemberService PaymentService │ │ │ │ │ │ │ │ │ │ 1.选择卡种│ │ │ │ │ │ │─────────▶│ │ │ │ │ │ │ │ 2.创建订单 │ │ │ │ │ │ │───────────▶│ │ │ │ │ │ │ │ 3.校验卡种 │ │ │ │ │ │ │─────────────▶│ │ │ │ │ │ │◀─────────────│ │ │ │ │ │ │ 4.创建支付单 │ │ │ │ │ │ │─────────────────────────────▶│ │ │ │ │ │◀─────────────────────────────│ │ │ │◀────────│ 返回支付参数│ │ │ │ │ │ │ │ │ │ │ │ │ 5.完成支付│ │ │ │ │ │ │──────────────────────────────────────────────────▶│ │ │ │ │ │ │ 6.支付回调 │ │ │ │ │ │◀─────────────────────────────│ │ │ │ │ │ 7.更新订单状态│ │ │ │ │ │ │─────────────▶│ │ │ │ │ │ │ │ 8.创建会员卡 │ │ │ │ │ │ │─────────────▶ │ │ │ │ │ │ │ 9.创建权益 │ │ │ │ │ │ │─────────────▶ │ │ │ │ │ │ │ 10.增加经验值 │ │ │ │ │ │ │─────────────▶ │ │ │ │ │ │◀─────────────│ │ │ │ │◀────────│ 购买成功通知│ │ │ │ │ │ │ │ │ │ │ │ │ └─────────────────────────────────────────────────────────────────────────┘ ``` ### 4.3 权益扣减流程 ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ 权益扣减流程 │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ 调用方 BenefitService Repository │ │ │ │ │ │ │ │ 1.请求扣减权益 │ │ │ │ │─────────────────▶│ │ │ │ │ │ 2.查询可用权益 │ │ │ │ │────────────────────▶│ │ │ │ │◀────────────────────│ │ │ │ │ │ │ │ │ │ 3.按优先级排序 │ │ │ │ │ (即将过期优先) │ │ │ │ │ │ │ │ │ │ 4.校验余额充足 │ │ │ │ │ │ │ │ │ │ 5.执行扣减(事务) │ │ │ │ │────────────────────▶│ │ │ │ │ UPDATE member_benefit │ │ │ │ SET remain_value = remain_value - ? │ │ │ │ used_value = used_value + ? │ │ │ │ WHERE id = ? AND remain_value >= ? │ │ │ │ │ │ │ │ │ 6.记录变更日志 │ │ │ │ │────────────────────▶│ │ │ │ │ │ │ │ │ │ 7.检查是否用完 │ │ │ │ │ 更新状态 │ │ │ │ │────────────────────▶│ │ │ │ │ │ │ │ │◀─────────────────│ 返回扣减结果 │ │ │ │ │ │ │ │ │ └─────────────────────────────────────────────────────────────────────────┘ ``` ### 4.4 等级升级流程 ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ 等级升级流程 │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ 触发源 LevelService Member LevelRule │ │ │ │ │ │ │ │ │ 1.增加经验值 │ │ │ │ │ │────────────────▶│ │ │ │ │ │ │ 2.查询当前会员 │ │ │ │ │ │───────────────────▶│ │ │ │ │ │◀───────────────────│ │ │ │ │ │ │ │ │ │ │ │ 3.计算新等级 │ │ │ │ │ │─────────────────────────────────────▶│ │ │ │ │◀─────────────────────────────────────│ │ │ │ │ │ │ │ │ │ │ 4.比较是否升级 │ │ │ │ │ │ │ │ │ │ │ │ [如果升级] │ │ │ │ │ │ 5.更新会员等级 │ │ │ │ │ │───────────────────▶│ │ │ │ │ │ │ │ │ │ │ │ 6.发放升级奖励 │ │ │ │ │ │ (经验值/优惠券) │ │ │ │ │ │───────────────────▶│ │ │ │ │ │ │ │ │ │ │ │ 7.记录升级日志 │ │ │ │ │ │───────────────────▶│ │ │ │ │ │ │ │ │ │ │ │ 8.发送升级通知 │ │ │ │ │ │ │ │ │ │ │◀────────────────│ 返回升级结果 │ │ │ │ │ │ │ │ │ │ │ └─────────────────────────────────────────────────────────────────────────┘ ``` --- ## 五、接口设计 ### 5.1 会员接口 #### 5.1.1 会员注册 ``` POST /v1/members/register Request: { "storeId": 1, "phone": "13800138000", "verifyCode": "123456", "verifyCodeId": "uuid-xxx", "name": "张三", "gender": 1, "birthday": "1990-01-01" } Response: { "code": 0, "message": "success", "data": { "memberId": 10001, "memberNo": "M202602280001", "name": "张三", "phone": "138****8000", "level": 0, "levelName": "普通会员", "accessToken": "eyJhbGciOiJIUzI1NiIs...", "refreshToken": "eyJhbGciOiJIUzI1NiIs...", "expiresIn": 7200 } } ``` #### 5.1.2 获取会员信息 ``` GET /v1/members/{id} Response: { "code": 0, "message": "success", "data": { "id": 10001, "memberNo": "M202602280001", "name": "张三", "phone": "138****8000", "avatar": "https://xxx.com/avatar.jpg", "gender": 1, "genderName": "男", "birthday": "1990-01-01", "level": 2, "levelName": "银卡会员", "exp": 1500, "totalExp": 1500, "nextLevelExp": 3000, "cards": [ { "id": 1, "cardNo": "C202602280001", "cardTypeName": "年卡", "status": 2, "statusName": "有效", "startDate": "2026-02-28", "endDate": "2027-02-27", "remainDays": 365 } ], "benefits": [ { "id": 1, "type": 1, "typeName": "时长", "name": "团课时长", "remainValue": 30, "unit": "天", "expireDate": "2027-02-27" } ], "createdAt": "2026-02-28T10:00:00" } } ``` #### 5.1.3 更新会员信息 ``` PUT /v1/members/{id} Request: { "name": "张三", "avatar": "https://xxx.com/new-avatar.jpg", "gender": 1, "birthday": "1990-01-01", "emergencyContact": "李四", "emergencyPhone": "13900139000" } Response: { "code": 0, "message": "success", "data": { "id": 10001, "name": "张三", "avatar": "https://xxx.com/new-avatar.jpg", "updatedAt": "2026-02-28T11:00:00" } } ``` ### 5.2 会员卡接口 #### 5.2.1 获取可购买卡种列表 ``` GET /v1/card-types?storeId=1&status=1 Response: { "code": 0, "message": "success", "data": { "list": [ { "id": 1, "name": "月卡", "code": "MONTH_CARD", "type": 1, "typeName": "时长卡", "category": 3, "categoryName": "通用卡", "price": 299.00, "originalPrice": 399.00, "durationDays": 30, "validDays": 365, "description": "30天无限次使用", "benefits": { "groupClass": true, "privateClass": false, "locker": true } } ], "total": 5 } } ``` #### 5.2.2 购买会员卡 ``` POST /v1/member-cards/purchase Request: { "memberId": 10001, "cardTypeId": 1, "quantity": 1, "couponId": null, "remark": "首次购卡" } Response: { "code": 0, "message": "success", "data": { "orderId": "O202602280001", "paymentUrl": "weixin://wxpay/bizpayurl?...", "amount": 299.00 } } ``` #### 5.2.3 获取会员卡列表 ``` GET /v1/members/{memberId}/cards Response: { "code": 0, "message": "success", "data": { "list": [ { "id": 1, "cardNo": "C202602280001", "cardTypeName": "年卡", "type": 1, "typeName": "时长卡", "status": 2, "statusName": "有效", "price": 1999.00, "paidAmount": 1799.10, "startDate": "2026-02-28", "endDate": "2027-02-27", "remainDays": 365, "benefits": [ { "type": 1, "typeName": "时长", "remainValue": 365, "unit": "天" } ] } ], "total": 1 } } ``` ### 5.3 权益接口 #### 5.3.1 获取会员权益 ``` GET /v1/members/{memberId}/benefits?type=1&status=1 Response: { "code": 0, "message": "success", "data": { "list": [ { "id": 1, "type": 1, "typeName": "时长", "category": 3, "categoryName": "通用", "name": "年卡时长", "value": 365, "usedValue": 0, "remainValue": 365, "unit": "天", "expireDate": "2027-02-27", "status": 1, "statusName": "有效", "cardId": 1, "cardNo": "C202602280001" } ], "total": 1, "summary": { "totalDuration": 365, "totalTimes": 50, "totalStoredValue": 1000.00 } } } ``` #### 5.3.2 获取权益变更记录 ``` GET /v1/members/{memberId}/benefit-logs?startDate=2026-01-01&endDate=2026-02-28 Response: { "code": 0, "message": "success", "data": { "list": [ { "id": 1, "benefitId": 1, "benefitName": "年卡时长", "type": 1, "typeName": "增加", "beforeValue": 0, "changeValue": 365, "afterValue": 365, "reason": "购买年卡", "bizType": "purchase", "bizId": "O202602280001", "createdAt": "2026-02-28T10:00:00" }, { "id": 2, "benefitId": 2, "benefitName": "团课次数", "type": 2, "typeName": "扣减", "beforeValue": 10, "changeValue": 1, "afterValue": 9, "reason": "预约团课: 瑜伽课", "bizType": "booking", "bizId": "B202602280001", "createdAt": "2026-02-28T14:00:00" } ], "total": 2, "page": 1, "pageSize": 20 } } ``` --- ## 六、核心代码设计 ### 6.1 会员实体 ```java package com.gym.domain.model.member; import com.gym.domain.model.base.BaseEntity; import com.gym.domain.model.base.AggregateRoot; import lombok.Getter; import lombok.Setter; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @Getter @Setter public class Member extends BaseEntity implements AggregateRoot { private Long tenantId; private Long storeId; private String memberNo; private String name; private String phone; private String phoneMask; private String avatar; private Gender gender; private LocalDate birthday; private String idCard; private String emergencyContact; private String emergencyPhone; private Integer level; private Integer exp; private Integer totalExp; private MemberStatus status; private String registerSource; private LocalDateTime lastLoginAt; private String lastLoginIp; private List cards = new ArrayList<>(); private List benefits = new ArrayList<>(); public boolean isNormal() { return MemberStatus.NORMAL.equals(this.status); } public boolean isFrozen() { return MemberStatus.FROZEN.equals(this.status); } public void freeze(String reason) { if (!isNormal()) { throw new BusinessException("会员状态异常,无法冻结"); } this.status = MemberStatus.FROZEN; this.updatedAt = LocalDateTime.now(); } public void unfreeze() { if (!isFrozen()) { throw new BusinessException("会员未冻结"); } this.status = MemberStatus.NORMAL; this.updatedAt = LocalDateTime.now(); } public void addExp(Integer exp) { if (exp <= 0) { return; } this.exp += exp; this.totalExp += exp; this.updatedAt = LocalDateTime.now(); } public void updateLevel(Integer newLevel) { if (newLevel > this.level) { this.level = newLevel; this.updatedAt = LocalDateTime.now(); } } public List getValidBenefits() { return benefits.stream() .filter(MemberBenefit::isUsable) .toList(); } public List getUsableBenefits(BenefitType type, BenefitCategory category) { return benefits.stream() .filter(b -> b.getType().equals(type)) .filter(b -> category == null || b.getCategory().equals(category)) .filter(MemberBenefit::isUsable) .sorted((a, b) -> { if (a.getExpireDate() == null && b.getExpireDate() == null) return 0; if (a.getExpireDate() == null) return 1; if (b.getExpireDate() == null) return -1; return a.getExpireDate().compareTo(b.getExpireDate()); }) .toList(); } } ``` ### 6.2 会员权益实体 ```java package com.gym.domain.model.member; import com.gym.domain.model.base.BaseEntity; import lombok.Getter; import lombok.Setter; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; @Getter @Setter public class MemberBenefit extends BaseEntity { private Long tenantId; private Long memberId; private Long cardId; private BenefitType type; private BenefitCategory category; private String name; private BigDecimal value; private BigDecimal usedValue; private BigDecimal remainValue; private String unit; private LocalDate expireDate; private BenefitStatus status; private String source; private Long sourceId; public boolean isUsable() { if (!BenefitStatus.VALID.equals(status)) { return false; } if (remainValue.compareTo(BigDecimal.ZERO) <= 0) { return false; } if (expireDate != null && expireDate.isBefore(LocalDate.now())) { return false; } return true; } public boolean isExpired() { return expireDate != null && expireDate.isBefore(LocalDate.now()); } public boolean canDeduct(BigDecimal amount) { return remainValue.compareTo(amount) >= 0; } public void deduct(BigDecimal amount) { if (!canDeduct(amount)) { throw new BusinessException("权益余额不足"); } this.usedValue = this.usedValue.add(amount); this.remainValue = this.remainValue.subtract(amount); this.updatedAt = LocalDateTime.now(); if (this.remainValue.compareTo(BigDecimal.ZERO) == 0) { this.status = BenefitStatus.USED_UP; } } public void add(BigDecimal amount) { this.value = this.value.add(amount); this.remainValue = this.remainValue.add(amount); this.updatedAt = LocalDateTime.now(); } public void expire() { this.status = BenefitStatus.EXPIRED; this.updatedAt = LocalDateTime.now(); } } ``` ### 6.3 权益服务 ```java package com.gym.domain.service; import com.gym.domain.model.member.*; import com.gym.domain.repository.MemberBenefitRepository; import com.gym.domain.repository.BenefitLogRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.math.BigDecimal; import java.time.LocalDateTime; @Service @RequiredArgsConstructor public class BenefitDomainService { private final MemberBenefitRepository benefitRepository; private final BenefitLogRepository benefitLogRepository; public Flux getUsableBenefits(Long memberId, BenefitType type, BenefitCategory category) { return benefitRepository.findUsableByMemberId(memberId, type, category) .sort((a, b) -> { if (a.getExpireDate() == null && b.getExpireDate() == null) return 0; if (a.getExpireDate() == null) return 1; if (b.getExpireDate() == null) return -1; return a.getExpireDate().compareTo(b.getExpireDate()); }); } @Transactional public Mono deductBenefit(Long memberId, BenefitType type, BenefitCategory category, BigDecimal amount, String bizType, Long bizId, String reason) { return getUsableBenefits(memberId, type, category) .collectList() .flatMap(benefits -> { BigDecimal remaining = amount; for (MemberBenefit benefit : benefits) { if (remaining.compareTo(BigDecimal.ZERO) <= 0) break; BigDecimal deductAmount = benefit.getRemainValue().min(remaining); BigDecimal beforeValue = benefit.getRemainValue(); benefit.deduct(deductAmount); remaining = remaining.subtract(deductAmount); BenefitLog log = BenefitLog.builder() .tenantId(benefit.getTenantId()) .memberId(memberId) .benefitId(benefit.getId()) .type(BenefitLogType.DEDUCT) .beforeValue(beforeValue) .changeValue(deductAmount) .afterValue(benefit.getRemainValue()) .reason(reason) .bizType(bizType) .bizId(bizId) .build(); benefitLogRepository.save(log).subscribe(); } if (remaining.compareTo(BigDecimal.ZERO) > 0) { return Mono.error(new BusinessException("权益余额不足")); } return Mono.when(benefits.stream() .map(benefitRepository::save) .toArray(Mono[]::new)); }); } @Transactional public Mono addBenefit(Long memberId, Long cardId, BenefitType type, BenefitCategory category, String name, BigDecimal value, String unit, LocalDate expireDate, String source, Long sourceId) { MemberBenefit benefit = new MemberBenefit(); benefit.setMemberId(memberId); benefit.setCardId(cardId); benefit.setType(type); benefit.setCategory(category); benefit.setName(name); benefit.setValue(value); benefit.setUsedValue(BigDecimal.ZERO); benefit.setRemainValue(value); benefit.setUnit(unit); benefit.setExpireDate(expireDate); benefit.setStatus(BenefitStatus.VALID); benefit.setSource(source); benefit.setSourceId(sourceId); return benefitRepository.save(benefit) .doOnNext(saved -> { BenefitLog log = BenefitLog.builder() .tenantId(saved.getTenantId()) .memberId(memberId) .benefitId(saved.getId()) .type(BenefitLogType.ADD) .beforeValue(BigDecimal.ZERO) .changeValue(value) .afterValue(value) .reason("购买会员卡") .bizType("purchase") .bizId(sourceId) .build(); benefitLogRepository.save(log).subscribe(); }); } } ``` ### 6.4 会员仓储 ```java package com.gym.infrastructure.repository; import com.gym.domain.model.member.Member; import com.gym.infrastructure.r2dbc.MemberR2dbcRepository; import lombok.RequiredArgsConstructor; import org.springframework.r2dbc.core.DatabaseClient; import org.springframework.stereotype.Repository; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.time.LocalDateTime; @Repository @RequiredArgsConstructor public class MemberRepository { private final MemberR2dbcRepository r2dbcRepository; private final DatabaseClient databaseClient; public Mono findById(Long id) { return r2dbcRepository.findByIdAndDeletedAtIsNull(id); } public Mono findByPhone(Long tenantId, String phone) { return r2dbcRepository.findByTenantIdAndPhoneAndDeletedAtIsNull(tenantId, phone); } public Mono findByMemberNo(Long tenantId, String memberNo) { return r2dbcRepository.findByTenantIdAndMemberNoAndDeletedAtIsNull(tenantId, memberNo); } public Flux findByStoreId(Long storeId) { return r2dbcRepository.findByStoreIdAndDeletedAtIsNull(storeId); } public Mono save(Member member) { member.setUpdatedAt(LocalDateTime.now()); if (member.getId() == null) { member.setCreatedAt(LocalDateTime.now()); return r2dbcRepository.save(member); } return r2dbcRepository.save(member); } public Mono softDelete(Long id, Long operatorId) { return databaseClient.sql(""" UPDATE member SET deleted_at = NOW(), updated_at = NOW(), updated_by = :operatorId WHERE id = :id AND deleted_at IS NULL """) .bind("id", id) .bind("operatorId", operatorId) .fetch() .rowsUpdated() .then(); } public Mono existsByPhone(Long tenantId, String phone) { return r2dbcRepository.existsByTenantIdAndPhoneAndDeletedAtIsNull(tenantId, phone); } public Mono countByStoreId(Long storeId) { return r2dbcRepository.countByStoreIdAndDeletedAtIsNull(storeId); } public Mono generateMemberNo(Long tenantId) { String prefix = "M" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); return databaseClient.sql(""" SELECT COALESCE(MAX(CAST(SUBSTRING(member_no, 10) AS BIGINT)), 0) + 1 as next_no FROM member WHERE tenant_id = :tenantId AND member_no LIKE :prefix AND deleted_at IS NULL """) .bind("tenantId", tenantId) .bind("prefix", prefix + "%") .map(row -> row.get("next_no", Long.class)) .first() .map(nextNo -> prefix + String.format("%04d", nextNo)); } } ``` --- ## 七、缓存设计 ### 7.1 缓存策略 ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ 会员模块缓存策略 │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ 1. 会员信息缓存 │ │ ├── Key: member:info:{memberId} │ │ ├── Value: Member JSON │ │ ├── TTL: 30分钟 │ │ ├── 更新策略: 写穿透(Write-Through) │ │ └── 失效策略: 更新时删除 │ │ │ │ 2. 会员权益缓存 │ │ ├── Key: member:benefits:{memberId} │ │ ├── Value: List JSON │ │ ├── TTL: 10分钟 │ │ ├── 更新策略: 写穿透 │ │ └── 失效策略: 权益变更时删除 │ │ │ │ 3. 会员卡类型缓存 │ │ ├── Key: card-types:tenant:{tenantId} │ │ ├── Value: List JSON │ │ ├── TTL: 1小时 │ │ ├── 更新策略: 定时刷新 │ │ └── 失效策略: 卡种变更时删除 │ │ │ │ 4. 等级规则缓存 │ │ ├── Key: level-rules:tenant:{tenantId} │ │ ├── Value: List JSON │ │ ├── TTL: 1天 │ │ ├── 更新策略: 定时刷新 │ │ └── 失效策略: 规则变更时删除 │ │ │ │ 5. 会员编号生成锁 │ │ ├── Key: member:no:lock:{tenantId} │ │ ├── Value: 1 │ │ ├── TTL: 5秒 │ │ └── 用途: 防止并发生成重复编号 │ │ │ └─────────────────────────────────────────────────────────────────────────┘ ``` ### 7.2 缓存配置 ```java package com.gym.infrastructure.cache; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.concurrent.TimeUnit; @Configuration public class CacheConfig { @Bean public Cache memberCache() { return Caffeine.newBuilder() .maximumSize(10000) .expireAfterWrite(30, TimeUnit.MINUTES) .recordStats() .build(); } @Bean public Cache benefitCache() { return Caffeine.newBuilder() .maximumSize(20000) .expireAfterWrite(10, TimeUnit.MINUTES) .recordStats() .build(); } @Bean public Cache cardTypeCache() { return Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(1, TimeUnit.HOURS) .recordStats() .build(); } @Bean public Cache levelRuleCache() { return Caffeine.newBuilder() .maximumSize(500) .expireAfterWrite(1, TimeUnit.DAYS) .recordStats() .build(); } } ``` --- ## 八、异常处理 ### 8.1 异常定义 ```java package com.gym.domain.exception; public class MemberException extends BusinessException { public static final MemberException MEMBER_NOT_FOUND = new MemberException(40001, "会员不存在"); public static final MemberException MEMBER_ALREADY_EXISTS = new MemberException(40002, "会员已存在"); public static final MemberException MEMBER_FROZEN = new MemberException(40003, "会员已冻结"); public static final MemberException MEMBER_CANCELLED = new MemberException(40004, "会员已注销"); public static final MemberException PHONE_ALREADY_REGISTERED = new MemberException(40005, "手机号已注册"); public static final MemberException VERIFY_CODE_ERROR = new MemberException(40006, "验证码错误"); public static final MemberException VERIFY_CODE_EXPIRED = new MemberException(40007, "验证码已过期"); public MemberException(int code, String message) { super(code, message); } } public class BenefitException extends BusinessException { public static final BenefitException BENEFIT_NOT_FOUND = new BenefitException(40101, "权益不存在"); public static final BenefitException BENEFIT_INSUFFICIENT = new BenefitException(40102, "权益余额不足"); public static final BenefitException BENEFIT_EXPIRED = new BenefitException(40103, "权益已过期"); public static final BenefitException BENEFIT_USED_UP = new BenefitException(40104, "权益已用完"); public static final BenefitException CARD_NOT_FOUND = new BenefitException(40105, "会员卡不存在"); public static final BenefitException CARD_EXPIRED = new BenefitException(40106, "会员卡已过期"); public static final BenefitException CARD_FROZEN = new BenefitException(40107, "会员卡已冻结"); public BenefitException(int code, String message) { super(code, message); } } ``` ### 8.2 异常处理 ```java package com.gym.api.exception; import com.gym.domain.exception.*; import com.gym.api.dto.ApiResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import reactor.core.publisher.Mono; @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MemberException.class) public Mono> handleMemberException(MemberException e) { log.warn("会员业务异常: code={}, message={}", e.getCode(), e.getMessage()); return Mono.just(ApiResponse.error(e.getCode(), e.getMessage())); } @ExceptionHandler(BenefitException.class) public Mono> handleBenefitException(BenefitException e) { log.warn("权益业务异常: code={}, message={}", e.getCode(), e.getMessage()); return Mono.just(ApiResponse.error(e.getCode(), e.getMessage())); } @ExceptionHandler(BusinessException.class) public Mono> handleBusinessException(BusinessException e) { log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage()); return Mono.just(ApiResponse.error(e.getCode(), e.getMessage())); } @ExceptionHandler(Exception.class) public Mono> handleException(Exception e) { log.error("系统异常", e); return Mono.just(ApiResponse.error(50001, "系统异常,请稍后重试")); } } ``` --- ## 九、测试设计 ### 9.1 单元测试 ```java package com.gym.domain.model.member; import org.junit.jupiter.api.Test; import java.math.BigDecimal; import java.time.LocalDate; import static org.junit.jupiter.api.Assertions.*; class MemberBenefitTest { @Test void testIsUsable_WhenValid_ShouldReturnTrue() { MemberBenefit benefit = new MemberBenefit(); benefit.setStatus(BenefitStatus.VALID); benefit.setRemainValue(BigDecimal.TEN); benefit.setExpireDate(LocalDate.now().plusDays(10)); assertTrue(benefit.isUsable()); } @Test void testIsUsable_WhenExpired_ShouldReturnFalse() { MemberBenefit benefit = new MemberBenefit(); benefit.setStatus(BenefitStatus.VALID); benefit.setRemainValue(BigDecimal.TEN); benefit.setExpireDate(LocalDate.now().minusDays(1)); assertFalse(benefit.isUsable()); } @Test void testIsUsable_WhenUsedUp_ShouldReturnFalse() { MemberBenefit benefit = new MemberBenefit(); benefit.setStatus(BenefitStatus.VALID); benefit.setRemainValue(BigDecimal.ZERO); benefit.setExpireDate(LocalDate.now().plusDays(10)); assertFalse(benefit.isUsable()); } @Test void testDeduct_WhenSufficient_ShouldSuccess() { MemberBenefit benefit = new MemberBenefit(); benefit.setStatus(BenefitStatus.VALID); benefit.setValue(BigDecimal.TEN); benefit.setUsedValue(BigDecimal.ZERO); benefit.setRemainValue(BigDecimal.TEN); benefit.deduct(BigDecimal.valueOf(3)); assertEquals(BigDecimal.valueOf(7), benefit.getRemainValue()); assertEquals(BigDecimal.valueOf(3), benefit.getUsedValue()); } @Test void testDeduct_WhenInsufficient_ShouldThrowException() { MemberBenefit benefit = new MemberBenefit(); benefit.setStatus(BenefitStatus.VALID); benefit.setRemainValue(BigDecimal.ONE); assertThrows(BusinessException.class, () -> benefit.deduct(BigDecimal.TEN)); } @Test void testDeduct_WhenFullyUsed_ShouldUpdateStatus() { MemberBenefit benefit = new MemberBenefit(); benefit.setStatus(BenefitStatus.VALID); benefit.setValue(BigDecimal.TEN); benefit.setUsedValue(BigDecimal.ZERO); benefit.setRemainValue(BigDecimal.TEN); benefit.deduct(BigDecimal.TEN); assertEquals(BenefitStatus.USED_UP, benefit.getStatus()); } } ``` ### 9.2 集成测试 ```java package com.gym.domain.service; import com.gym.domain.model.member.*; import com.gym.domain.repository.MemberBenefitRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.transaction.reactive.TransactionalOperator; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import java.math.BigDecimal; import java.time.LocalDate; @SpringBootTest class BenefitDomainServiceIntegrationTest { @Autowired private BenefitDomainService benefitService; @Autowired private MemberBenefitRepository benefitRepository; @Autowired private TransactionalOperator rxtx; @Test void testDeductBenefit_ShouldSuccess() { Long memberId = 1L; Long benefitId = 1L; Mono result = benefitRepository.findById(benefitId) .flatMap(benefit -> { BigDecimal beforeValue = benefit.getRemainValue(); return benefitService.deductBenefit( memberId, BenefitType.TIMES, BenefitCategory.GROUP_CLASS, BigDecimal.ONE, "booking", 1L, "预约扣减" ).then(Mono.just(beforeValue)); }) .flatMap(beforeValue -> benefitRepository.findById(benefitId) .map(benefit -> { assertEquals(beforeValue.subtract(BigDecimal.ONE), benefit.getRemainValue()); return true; })) .as(rxtx::transactional) .then(); StepVerifier.create(result) .verifyComplete(); } } ``` --- ## 十、附录 ### 10.1 枚举定义 ```java public enum MemberStatus { NORMAL(1, "正常"), FROZEN(2, "冻结"), CANCELLED(3, "注销"); private final int code; private final String name; } public enum CardStatus { INACTIVE(1, "未激活"), ACTIVE(2, "有效"), EXPIRED(3, "已过期"), USED_UP(4, "已用完"), FROZEN(5, "已冻结"); private final int code; private final String name; } public enum BenefitType { DURATION(1, "时长"), TIMES(2, "次数"), STORED_VALUE(3, "储值"), LEVEL(4, "等级"); private final int code; private final String name; } public enum BenefitCategory { GROUP_CLASS(1, "团课"), PRIVATE(2, "私教"), GENERAL(3, "通用"); private final int code; private final String name; } public enum BenefitStatus { VALID(1, "有效"), EXPIRED(2, "已过期"), USED_UP(3, "已用完"); private final int code; private final String name; } public enum BenefitLogType { ADD(1, "增加"), DEDUCT(2, "扣减"), EXPIRE(3, "过期"), FREEZE(4, "冻结"), UNFREEZE(5, "解冻"); private final int code; private final String name; } public enum Gender { UNKNOWN(0, "未知"), MALE(1, "男"), FEMALE(2, "女"); private final int code; private final String name; } ``` ### 10.2 配置项 ```yaml member: register: default-level: 0 default-exp: 0 member-no-prefix: "M" benefit: expire-notice-days: 7 max-benefits-per-member: 100 level: exp-rules: checkin: 10 booking: 20 purchase: 100 ``` --- ## 十一、版本历史 | 版本 | 日期 | 作者 | 变更内容 | | ---- | ---------- | ---- | -------- | | v1.0 | 2026-02-28 | 张翔 | 初稿 | --- _文档结束_