1816 lines
83 KiB
Markdown
1816 lines
83 KiB
Markdown
# 健身房管理系统详细设计文档 - 会员模块(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)
|
|
|
|
```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 领域模型类图
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────────────┐
|
|
│ 会员领域模型 │
|
|
├─────────────────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ ┌───────────────────────────────────────────────────────────────────┐ │
|
|
│ │ <<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 会员实体
|
|
|
|
```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<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 会员权益实体
|
|
|
|
```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<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 会员仓储
|
|
|
|
```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<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 缓存配置
|
|
|
|
```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<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 异常定义
|
|
|
|
```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<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 单元测试
|
|
|
|
```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<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 枚举定义
|
|
|
|
```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 | 张翔 | 初稿 |
|
|
|
|
---
|
|
|
|
_文档结束_
|