83 KiB
83 KiB
健身房管理系统详细设计文档 - 会员模块(LLD)
文档编号: GYM-LLD-001
版本: 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 实体关系图
┌─────────────────────────────────────────────────────────────────────────┐
│ 实体关系图 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ │
│ │ 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)
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)
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)
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)
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)
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)
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)
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 领域模型类图
┌─────────────────────────────────────────────────────────────────────────┐
│ 会员领域模型 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ <<Entity>> │ │
│ │ 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<MemberCard> │ │
│ │ - benefits: List<MemberBenefit> │ │
│ ├───────────────────────────────────────────────────────────────────┤ │
│ │ + activate(): void │ │
│ │ + freeze(reason: String): void │ │
│ │ + unfreeze(): void │ │
│ │ + addExp(exp: Integer): void │ │
│ │ + canLevelUp(): Boolean │ │
│ │ + levelUp(): void │ │
│ │ + getValidBenefits(): List<MemberBenefit> │ │
│ │ + getUsableBenefit(type: BenefitType): MemberBenefit │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ 1:N │
│ ▼ │
│ ┌────────────────────────────┐ ┌────────────────────────────┐ │
│ │ <<Entity>> │ │ <<Entity>> │ │
│ │ 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 │ │
│ └────────────────────────────┘ └────────────────────────────┘ │
│ │
│ ┌────────────────────────────┐ ┌────────────────────────────┐ │
│ │ <<ValueObject>> │ │ <<ValueObject>> │ │
│ │ MemberStatus │ │ BenefitType │ │
│ ├────────────────────────────┤ ├────────────────────────────┤ │
│ │ NORMAL(1, "正常") │ │ DURATION(1, "时长") │ │
│ │ FROZEN(2, "冻结") │ │ TIMES(2, "次数") │ │
│ │ CANCELLED(3, "注销") │ │ STORED_VALUE(3, "储值") │ │
│ └────────────────────────────┘ │ LEVEL(4, "等级") │ │
│ └────────────────────────────┘ │
│ │
│ ┌────────────────────────────┐ ┌────────────────────────────┐ │
│ │ <<ValueObject>> │ │ <<ValueObject>> │ │
│ │ CardStatus │ │ BenefitCategory │ │
│ ├────────────────────────────┤ ├────────────────────────────┤ │
│ │ INACTIVE(1, "未激活") │ │ GROUP_CLASS(1, "团课") │ │
│ │ ACTIVE(2, "有效") │ │ PRIVATE(2, "私教") │ │
│ │ EXPIRED(3, "已过期") │ │ GENERAL(3, "通用") │ │
│ │ USED_UP(4, "已用完") │ └────────────────────────────┘ │
│ │ FROZEN(5, "已冻结") │ │
│ └────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
3.2 领域服务
┌─────────────────────────────────────────────────────────────────────────┐
│ 领域服务设计 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ <<DomainService>> │ │
│ │ MemberDomainService │ │
│ ├───────────────────────────────────────────────────────────────────┤ │
│ │ + registerMember(command: RegisterMemberCommand): Member │ │
│ │ + updateMemberInfo(memberId: Long, command: UpdateMemberCommand) │ │
│ │ + freezeMember(memberId: Long, reason: String): void │ │
│ │ + unfreezeMember(memberId: Long): void │ │
│ │ + calculateLevel(memberId: Long): Integer │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ <<DomainService>> │ │
│ │ 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 │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ <<DomainService>> │ │
│ │ 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 会员实体
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<MemberCard> cards = new ArrayList<>();
private List<MemberBenefit> 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<MemberBenefit> getValidBenefits() {
return benefits.stream()
.filter(MemberBenefit::isUsable)
.toList();
}
public List<MemberBenefit> 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 会员权益实体
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 权益服务
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<MemberBenefit> 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<Void> 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<MemberBenefit> 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 会员仓储
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<Member> findById(Long id) {
return r2dbcRepository.findByIdAndDeletedAtIsNull(id);
}
public Mono<Member> findByPhone(Long tenantId, String phone) {
return r2dbcRepository.findByTenantIdAndPhoneAndDeletedAtIsNull(tenantId, phone);
}
public Mono<Member> findByMemberNo(Long tenantId, String memberNo) {
return r2dbcRepository.findByTenantIdAndMemberNoAndDeletedAtIsNull(tenantId, memberNo);
}
public Flux<Member> findByStoreId(Long storeId) {
return r2dbcRepository.findByStoreIdAndDeletedAtIsNull(storeId);
}
public Mono<Member> 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<Void> 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<Boolean> existsByPhone(Long tenantId, String phone) {
return r2dbcRepository.existsByTenantIdAndPhoneAndDeletedAtIsNull(tenantId, phone);
}
public Mono<Long> countByStoreId(Long storeId) {
return r2dbcRepository.countByStoreIdAndDeletedAtIsNull(storeId);
}
public Mono<String> 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<MemberBenefit> JSON │
│ ├── TTL: 10分钟 │
│ ├── 更新策略: 写穿透 │
│ └── 失效策略: 权益变更时删除 │
│ │
│ 3. 会员卡类型缓存 │
│ ├── Key: card-types:tenant:{tenantId} │
│ ├── Value: List<CardType> JSON │
│ ├── TTL: 1小时 │
│ ├── 更新策略: 定时刷新 │
│ └── 失效策略: 卡种变更时删除 │
│ │
│ 4. 等级规则缓存 │
│ ├── Key: level-rules:tenant:{tenantId} │
│ ├── Value: List<LevelRule> JSON │
│ ├── TTL: 1天 │
│ ├── 更新策略: 定时刷新 │
│ └── 失效策略: 规则变更时删除 │
│ │
│ 5. 会员编号生成锁 │
│ ├── Key: member:no:lock:{tenantId} │
│ ├── Value: 1 │
│ ├── TTL: 5秒 │
│ └── 用途: 防止并发生成重复编号 │
│ │
└─────────────────────────────────────────────────────────────────────────┘
7.2 缓存配置
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<String, Object> memberCache() {
return Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(30, TimeUnit.MINUTES)
.recordStats()
.build();
}
@Bean
public Cache<String, Object> benefitCache() {
return Caffeine.newBuilder()
.maximumSize(20000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.recordStats()
.build();
}
@Bean
public Cache<String, Object> cardTypeCache() {
return Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(1, TimeUnit.HOURS)
.recordStats()
.build();
}
@Bean
public Cache<String, Object> levelRuleCache() {
return Caffeine.newBuilder()
.maximumSize(500)
.expireAfterWrite(1, TimeUnit.DAYS)
.recordStats()
.build();
}
}
八、异常处理
8.1 异常定义
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 异常处理
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<ApiResponse<Void>> 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<ApiResponse<Void>> 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<ApiResponse<Void>> 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<ApiResponse<Void>> handleException(Exception e) {
log.error("系统异常", e);
return Mono.just(ApiResponse.error(50001, "系统异常,请稍后重试"));
}
}
九、测试设计
9.1 单元测试
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 集成测试
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<Void> 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 枚举定义
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 配置项
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 | 张翔 | 初稿 |
文档结束