Files
gym-manage/docs/design/LLD-会员模块详细设计.md
T
张翔 c1d7660aac 修复所有设计文档中ASCII图表右侧边框对齐问题
- 修复HLD-系统概要设计.md中所有ASCII图表的右侧边框对齐
- 修复LLD-签到模块详细设计.md中ASCII图表的右侧边框对齐
- 修复LLD-会员模块详细设计.md中ASCII图表的右侧边框对齐
- 修复LLD-预约模块详细设计.md中ASCII图表的右侧边框对齐
- 确保所有ASCII图表的右侧边框纵向靠右对齐
2026-03-04 11:20:36 +08:00

82 KiB

健身房管理系统详细设计文档 - 会员模块(LLD)

文档编号: GYM-LLD-001
版本: v1.0
日期: 2026-02-28
作者: 张翔
状态: 初稿


文档修订历史

版本 日期 作者 修订内容
v1.0 2026-02-28 张翔 初稿

参考文档

  • 《健身房管理系统产品设计文档》 GYM-PRD-001
  • 《健身房管理系统概要设计文档》 GYM-HLD-001
  • Spring Boot 3 官方文档
  • R2DBC 规范文档
  • PostgreSQL 官方文档

一、模块概述

1.1 模块定位

会员模块是健身房管理系统的核心基础模块,负责管理会员全生命周期,包括:

  • 会员注册与信息管理
  • 会员卡购买与管理
  • 会员权益(时长/次数/储值/等级)管理
  • 会员等级体系与积分管理

1.2 模块边界

┌─────────────────────────────────────────────────────────────────────────┐
│                          会员模块边界                                   │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │ 会员模块内部                                         │   │
│  ├─────────────────────────────────────────────────────────────────┤   │
│  │ • 会员管理  • 会员卡管理  • 权益管理  • 等级管理            │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                         │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │ 外部依赖                                         │   │
│  ├─────────────────────────────────────────────────────────────────┤   │
│  │ • 租户模块 (获取租户信息)                                      │   │
│  │ • 门店模块 (获取门店信息)                                      │   │
│  │ • 认证模块 (用户登录认证)                                      │   │
│  │ • 消息模块 (发送短信验证码)                                    │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                         │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │ 被依赖                                               │   │
│  ├─────────────────────────────────────────────────────────────────┤   │
│  │ • 预约模块 (查询会员权益、扣减权益)                           │   │
│  │ • 签到模块 (查询会员信息、扣减权益)                           │   │
│  │ • 财务模块 (查询会员消费记录)                                 │   │
│  │ • 数据模块 (会员数据分析)                                      │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

二、数据模型设计

2.1 实体关系图

┌─────────────────────────────────────────────────────────────────────────┐
│                          实体关系图                                     │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌──────────────┐                                                      │
│  │   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 张翔 初稿

文档结束