Files
gym-manage/docs/design/LLD-签到模块详细设计.md
T
张翔 349b0a754f 重构HLD和LLD文档,明确业务概要设计与详细设计的职责分工
主要变更:
- 重构HLD-系统概要设计.md为业务概要设计文档
  * 聚焦于业务范围、业务流程、业务规则、业务场景
  * 移除技术架构、模块设计、接口设计等技术内容
  * 新增核心业务流程图(会员注册、课程预约、签到、会员卡购买)
  * 新增详细业务规则(会员管理、预约管理、签到管理、财务管理、数据分析)
  * 新增典型业务场景和特殊业务场景描述
  * 新增业务约束和业务指标

- 新增LLD-系统详细设计.md作为系统技术详细设计文档
  * 整合从HLD移出的技术架构设计(总体架构、技术架构、部署架构)
  * 整合模块设计(模块划分、模块职责、模块交互)
  * 整合接口设计(接口规范、接口分组、接口版本管理)
  * 整合安全设计(认证机制、权限控制、数据安全、接口安全)
  * 整合性能设计(性能目标、性能优化策略、高并发场景处理)
  * 整合可扩展性设计(水平扩展、功能扩展)
  * 整合监控与运维(监控体系、日志规范)

- 更新所有LLD模块文档的参考文档
  * 更新HLD文档引用为业务概要设计文档
  * 新增LLD-系统详细设计.md作为参考文档

- 删除docs/api/API接口文档.md(接口设计已整合到LLD-系统详细设计.md)

文档职责分工:
- PRD:产品需求文档,描述产品需求和功能
- HLD:业务概要设计文档,描述业务范围、流程、规则、场景
- LLD:详细设计文档,描述技术架构、模块设计、接口设计等实现细节~
2026-03-04 11:43:21 +08:00

88 KiB

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

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


文档修订历史

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

参考文档

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

一、模块概述

1.1 模块定位

签到模块是健身房管理系统的核心业务模块,负责管理会员的入场签到和课程签到,支持多种签到方式:

  • 二维码签到:会员出示二维码,扫码签到
  • 人脸识别签到:通过人脸识别设备自动签到
  • NFC签到:会员卡或手机NFC感应签到
  • 教练代签:教练手动为会员签到

1.2 模块边界

┌─────────────────────────────────────────────────────────────────────────┐
│                          签到模块边界                                   │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │ 签到模块内部                                         │   │
│  ├─────────────────────────────────────────────────────────────────┤   │
│  │ • 签到网关  • 签到验证  • 签到记录  • 签到统计               │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                         │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │ 外部依赖                                         │   │
│  ├─────────────────────────────────────────────────────────────────┤   │
│  │ • 会员模块 (查询会员信息、验证会员状态)                       │   │
│  │ • 权益模块 (验证权益有效性、扣减权益)                         │   │
│  │ • 预约模块 (查询预约信息、验证签到资格)                       │   │
│  │ • 设备模块 (人脸识别设备、NFC读卡器)                         │   │
│  │ • 消息模块 (发送签到通知)                                    │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                         │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │ 被依赖                                               │   │
│  ├─────────────────────────────────────────────────────────────────┤   │
│  │ • 财务模块 (签到消费记录)                                   │   │
│  │ • 数据模块 (签到数据分析、会员活跃度统计)                     │   │
│  │ • 考勤模块 (教练考勤统计)                                   │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

1.3 签到类型

签到类型 说明 触发条件 验证规则
入场签到 会员进入健身房 扫码/人脸/NFC 验证会员卡有效性
课程签到 会员参加预约课程 扫码/教练代签 验证预约记录、时间窗口
私教签到 会员上私教课 教练代签 验证私教预约、教练身份
活动签到 会员参加活动 扫码 验证活动报名

二、数据模型设计

2.1 实体关系图

┌─────────────────────────────────────────────────────────────────────────┐
│                          实体关系图                                     │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌──────────────┐  ┌──────────────────┐  ┌──────────────┐              │
│  │   member    │  │ booking_record  │  │   device    │              │
│  │   (会员)    │  │   (预约记录)     │  │   (设备)    │              │
│  └──────┬───────┘  └────────┬─────────┘  └──────┬───────┘              │
│         │ 1:N                │ 1:N                 │ 1:N                 │
│         │                   │                     │                     │
│         └───────────────────┴─────────────────────┘                     │
│                             │ 1:N                                      │
│                             ▼                                          │
│                    ┌──────────────────┐                                 │
│                    │ checkin_record  │                                 │
│                    │   (签到记录)     │                                 │
│                    └──────────────────┘                                 │
│                                                                         │
│  ┌──────────────┐                                                      │
│  │   member    │                                                      │
│  │   (会员)    │                                                      │
│  └──────┬───────┘                                                      │
│         │ 1:N                                                           │
│         ▼                                                                │
│  ┌──────────────────┐                                                   │
│  │  member_face    │                                                   │
│  │   (会员人脸)     │                                                   │
│  └──────────────────┘                                                   │
│                                                                         │
│  关系说明:                                                              │
│  • member (1) ─── (N) checkin_record    : 一个会员有多个签到记录       │
│  • booking_record (1) ─── (N) checkin_record : 一个预约有多个签到记录   │
│  • device (1) ─── (N) checkin_record    : 一个设备有多个签到记录       │
│  • member (1) ─── (N) member_face      : 一个会员有多个人脸特征       │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

2.2 数据表设计

2.2.1 签到记录表 (checkin_record)

CREATE TABLE checkin_record (
    id              BIGSERIAL       PRIMARY KEY,
    tenant_id       BIGINT          NOT NULL,
    store_id        BIGINT          NOT NULL,
    member_id       BIGINT          NOT NULL,
    booking_id      BIGINT,                             -- 关联预约记录ID
    type            SMALLINT        NOT NULL,           -- 1:入场 2:课程 3:私教 4:活动
    method          SMALLINT        NOT NULL,           -- 1:二维码 2:人脸 3:NFC 4:教练代签
    device_id       BIGINT,                             -- 签到设备ID
    device_name     VARCHAR(64),                        -- 设备名称
    operator_id     BIGINT,                             -- 操作人ID(教练代签时)
    operator_name   VARCHAR(64),                        -- 操作人姓名
    status          SMALLINT        DEFAULT 1,          -- 1:成功 2:失败 3:已取消
    checkin_at      TIMESTAMP       NOT NULL,           -- 签到时间
    checkin_date    DATE            NOT NULL,           -- 签到日期(便于统计)
    location        VARCHAR(128),                       -- 签到位置
    latitude        DECIMAL(10,7),                      -- 纬度
    longitude       DECIMAL(10,7),                      -- 经度
    fail_reason     VARCHAR(256),                       -- 失败原因
    benefit_id      BIGINT,                             -- 扣减的权益ID
    benefit_type    SMALLINT,                           -- 权益类型
    benefit_value   DECIMAL(10,2),                      -- 扣减值
    extra_data      JSONB,                              -- 扩展数据
    created_at      TIMESTAMP       DEFAULT NOW(),
    updated_at      TIMESTAMP       DEFAULT NOW(),
    deleted_at      TIMESTAMP       DEFAULT NULL,

    CONSTRAINT fk_checkin_member FOREIGN KEY (member_id) REFERENCES member(id),
    CONSTRAINT fk_checkin_booking FOREIGN KEY (booking_id) REFERENCES booking_record(id)
);

CREATE INDEX idx_checkin_tenant ON checkin_record(tenant_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_checkin_store ON checkin_record(store_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_checkin_member ON checkin_record(member_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_checkin_date ON checkin_record(checkin_date) WHERE deleted_at IS NULL;
CREATE INDEX idx_checkin_type ON checkin_record(type) WHERE deleted_at IS NULL;
CREATE INDEX idx_checkin_status ON checkin_record(status) WHERE deleted_at IS NULL;
CREATE INDEX idx_checkin_time ON checkin_record(checkin_at) WHERE deleted_at IS NULL;

2.2.2 会员人脸信息表 (member_face)

CREATE TABLE member_face (
    id              BIGSERIAL       PRIMARY KEY,
    tenant_id       BIGINT          NOT NULL,
    member_id       BIGINT          NOT NULL,
    face_feature    BYTEA           NOT NULL,           -- 人脸特征值(加密存储)
    face_image      VARCHAR(512),                       -- 人脸照片URL
    feature_version VARCHAR(32),                        -- 特征版本
    quality_score   DECIMAL(5,2),                       -- 质量分数
    status          SMALLINT        DEFAULT 1,          -- 1:正常 2:待更新 3:已禁用
    last_match_at   TIMESTAMP,                          -- 最后匹配时间
    match_count     INT             DEFAULT 0,          -- 匹配次数
    created_at      TIMESTAMP       DEFAULT NOW(),
    updated_at      TIMESTAMP       DEFAULT NOW(),
    deleted_at      TIMESTAMP       DEFAULT NULL,

    CONSTRAINT fk_face_member FOREIGN KEY (member_id) REFERENCES member(id),
    CONSTRAINT uk_face_member UNIQUE (member_id)
);

CREATE INDEX idx_face_tenant ON member_face(tenant_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_face_status ON member_face(status) WHERE deleted_at IS NULL;

2.2.3 签到设备表 (checkin_device)

CREATE TABLE checkin_device (
    id              BIGSERIAL       PRIMARY KEY,
    tenant_id       BIGINT          NOT NULL,
    store_id        BIGINT          NOT NULL,
    name            VARCHAR(64)     NOT NULL,           -- 设备名称
    code            VARCHAR(32)     NOT NULL,           -- 设备编码
    type            SMALLINT        NOT NULL,           -- 1:人脸识别机 2:NFC读卡器 3:扫码枪 4:一体机
    sn              VARCHAR(64),                        -- 序列号
    location        VARCHAR(128),                       -- 安装位置
    ip_address      VARCHAR(64),                        -- IP地址
    mac_address     VARCHAR(32),                        -- MAC地址
    status          SMALLINT        DEFAULT 1,          -- 1:在线 2:离线 3:维护中
    last_heartbeat  TIMESTAMP,                          -- 最后心跳时间
    config          JSONB,                              -- 设备配置
    created_at      TIMESTAMP       DEFAULT NOW(),
    updated_at      TIMESTAMP       DEFAULT NOW(),
    deleted_at      TIMESTAMP       DEFAULT NULL,

    CONSTRAINT fk_device_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id),
    CONSTRAINT fk_device_store FOREIGN KEY (store_id) REFERENCES store(id),
    CONSTRAINT uk_device_code UNIQUE (tenant_id, code)
);

CREATE INDEX idx_device_tenant ON checkin_device(tenant_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_device_store ON checkin_device(store_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_device_status ON checkin_device(status) WHERE deleted_at IS NULL;

2.2.4 签到统计表 (checkin_statistics)

CREATE TABLE checkin_statistics (
    id              BIGSERIAL       PRIMARY KEY,
    tenant_id       BIGINT          NOT NULL,
    store_id        BIGINT          NOT NULL,
    stat_date       DATE            NOT NULL,           -- 统计日期
    stat_type       SMALLINT        NOT NULL,           -- 1:日统计 2:周统计 3:月统计
    total_count     INT             DEFAULT 0,          -- 总签到次数
    entry_count     INT             DEFAULT 0,          -- 入场签到次数
    course_count    INT             DEFAULT 0,          -- 课程签到次数
    private_count   INT             DEFAULT 0,          -- 私教签到次数
    activity_count  INT             DEFAULT 0,          -- 活动签到次数
    new_member_count INT            DEFAULT 0,          -- 新会员签到数
    active_member_count INT         DEFAULT 0,          -- 活跃会员数
    peak_hour       SMALLINT,                           -- 高峰时段
    peak_count      INT,                                -- 高峰人数
    avg_duration    INT,                                -- 平均停留时长(分钟)
    created_at      TIMESTAMP       DEFAULT NOW(),
    updated_at      TIMESTAMP       DEFAULT NOW(),
    deleted_at      TIMESTAMP       DEFAULT NULL,

    CONSTRAINT uk_stat_date UNIQUE (tenant_id, store_id, stat_date, stat_type)
);

CREATE INDEX idx_stat_tenant ON checkin_statistics(tenant_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_stat_store ON checkin_statistics(store_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_stat_date ON checkin_statistics(stat_date) WHERE deleted_at IS NULL;

2.2.5 签到规则表 (checkin_rule)

CREATE TABLE checkin_rule (
    id              BIGSERIAL       PRIMARY KEY,
    tenant_id       BIGINT          NOT NULL,
    store_id        BIGINT,                             -- NULL表示全局规则
    rule_type       SMALLINT        NOT NULL,           -- 1:入场规则 2:课程规则 3:私教规则
    name            VARCHAR(64)     NOT NULL,           -- 规则名称
    description     VARCHAR(256),                       -- 规则描述
    time_before     INT             DEFAULT 30,         -- 提前签到时间(分钟)
    time_after      INT             DEFAULT 15,         -- 迟到允许时间(分钟)
    late_penalty    DECIMAL(3,2)    DEFAULT 0.00,       -- 迟到扣款比例
    absent_penalty  DECIMAL(3,2)    DEFAULT 1.00,       -- 缺席扣款比例
    allow_late      BOOLEAN         DEFAULT TRUE,       -- 是否允许迟到签到
    allow_absent    BOOLEAN         DEFAULT FALSE,      -- 是否允许缺席
    max_daily_entry INT             DEFAULT 1,          -- 每日最大入场次数
    interval_minutes INT            DEFAULT 0,          -- 签到间隔(分钟)
    status          SMALLINT        DEFAULT 1,          -- 1:启用 2:禁用
    priority        INT             DEFAULT 0,          -- 优先级
    created_at      TIMESTAMP       DEFAULT NOW(),
    updated_at      TIMESTAMP       DEFAULT NOW(),
    created_by      BIGINT,
    updated_by      BIGINT,
    deleted_at      TIMESTAMP       DEFAULT NULL,

    CONSTRAINT fk_rule_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id)
);

CREATE INDEX idx_rule_tenant ON checkin_rule(tenant_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_rule_store ON checkin_rule(store_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_rule_type ON checkin_rule(rule_type) WHERE deleted_at IS NULL;

三、领域模型设计

3.1 聚合设计

┌─────────────────────────────────────────────────────────────────────────┐
│                          签到聚合设计                                    │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌───────────────────────────────────────────────────────────────────┐  │
│  │                      CheckinRecord (聚合根)                        │  │
│  ├───────────────────────────────────────────────────────────────────┤  │
│  │  - id: Long                                                        │  │
│  │  - tenantId: Long                                                  │  │
│  │  - storeId: Long                                                   │  │
│  │  - memberId: Long                                                  │  │
│  │  - bookingId: Long?                                                │  │
│  │  - type: CheckinType                                               │  │
│  │  - method: CheckinMethod                                           │  │
│  │  - device: DeviceInfo?                                             │  │
│  │  - operator: OperatorInfo?                                         │  │
│  │  - status: CheckinStatus                                           │  │
│  │  - checkinAt: LocalDateTime                                        │  │
│  │  - benefit: BenefitDeduction?                                      │  │
│  │                                                                     │  │
│  │  行为:                                                              │  │
│  │  + checkin(): void                                                 │  │
│  │  + cancel(reason: String): void                                    │  │
│  │  + isLate(): Boolean                                               │  │
│  │  + getDuration(): Duration                                         │  │
│  └───────────────────────────────────────────────────────────────────┘  │
│                                                                         │
│  ┌───────────────────────┐   ┌───────────────────────┐                 │
│  │   CheckinGateway      │   │   CheckinValidator    │                 │
│  │   (签到网关)          │   │   (签到验证器)        │                 │
│  ├───────────────────────┤   ├───────────────────────┤                 │
│  │  + processQRCode()    │   │  + validateMember()   │                 │
│  │  + processFace()      │   │  + validateBooking()  │                 │
│  │  + processNFC()       │   │  + validateBenefit()  │                 │
│  │  + processManual()    │   │  + validateRule()     │                 │
│  └───────────────────────┘   └───────────────────────┘                 │
│                                                                         │
│  ┌───────────────────────┐   ┌───────────────────────┐                 │
│  │   CheckinStatistics   │   │   FaceRecognition     │                 │
│  │   (签到统计)          │   │   (人脸识别)          │                 │
│  ├───────────────────────┤   ├───────────────────────┤                 │
│  │  + dailyStats()       │   │  + register()         │                 │
│  │  + weeklyStats()      │   │  + match()            │                 │
│  │  + monthlyStats()     │   │  + update()           │                 │
│  │  + memberStats()      │   │  + delete()           │                 │
│  └───────────────────────┘   └───────────────────────┘                 │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

3.2 值对象设计

public enum CheckinType {
    ENTRY(1, "入场签到"),
    COURSE(2, "课程签到"),
    PRIVATE(3, "私教签到"),
    ACTIVITY(4, "活动签到");

    private final int code;
    private final String desc;
}

public enum CheckinMethod {
    QRCODE(1, "二维码"),
    FACE(2, "人脸识别"),
    NFC(3, "NFC"),
    MANUAL(4, "教练代签");

    private final int code;
    private final String desc;
}

public enum CheckinStatus {
    SUCCESS(1, "成功"),
    FAILED(2, "失败"),
    CANCELLED(3, "已取消");

    private final int code;
    private final String desc;
}

public record DeviceInfo(
    Long deviceId,
    String deviceName,
    String location
) {}

public record OperatorInfo(
    Long operatorId,
    String operatorName,
    String operatorRole
) {}

public record BenefitDeduction(
    Long benefitId,
    Integer benefitType,
    BigDecimal benefitValue
) {}

public record CheckinResult(
    boolean success,
    String message,
    CheckinRecord record,
    List<String> warnings
) {}

3.3 领域服务设计

public interface CheckinDomainService {

    Mono<CheckinResult> processCheckin(CheckinRequest request);

    Mono<Void> cancelCheckin(Long checkinId, String reason);

    Mono<Boolean> validateCheckinEligibility(Long memberId, CheckinType type, Long bookingId);

    Mono<CheckinRecord> getCheckinRecord(Long checkinId);

    Flux<CheckinRecord> getMemberCheckinHistory(Long memberId, LocalDate startDate, LocalDate endDate);
}

public interface FaceRecognitionService {

    Mono<Boolean> registerFace(Long memberId, byte[] faceImage);

    Mono<Long> matchFace(byte[] faceFeature, Long tenantId);

    Mono<Boolean> updateFace(Long memberId, byte[] faceImage);

    Mono<Void> deleteFace(Long memberId);
}

public interface CheckinStatisticsService {

    Mono<Void> generateDailyStatistics(Long tenantId, Long storeId, LocalDate date);

    Mono<CheckinStatistics> getDailyStatistics(Long tenantId, Long storeId, LocalDate date);

    Mono<Map<String, Object>> getMemberCheckinStats(Long memberId, LocalDate startDate, LocalDate endDate);
}

四、业务流程设计

4.1 入场签到流程

┌─────────────────────────────────────────────────────────────────────────┐
│                          入场签到流程                                    │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌───────┐     ┌───────┐     ┌───────┐     ┌───────┐     ┌───────┐    │
│  │ 会员  │     │ 签到  │     │ 签到  │     │ 权益  │     │ 签到  │    │
│  │       │     │ 网关  │     │ 验证  │     │ 服务  │     │ 记录  │    │
│  └───┬───┘     └───┬───┘     └───┬───┘     └───┬───┘     └───┬───┘    │
│      │             │             │             │             │          │
│      │  出示二维码  │             │             │             │          │
│      │────────────▶│             │             │             │          │
│      │             │             │             │             │          │
│      │             │  解析二维码  │             │             │          │
│      │             │────────────▶│             │             │          │
│      │             │             │             │             │          │
│      │             │             │  查询会员   │             │          │
│      │             │             │────────────▶│             │          │
│      │             │             │             │             │          │
│      │             │             │  会员信息   │             │          │
│      │             │             │◀────────────│             │          │
│      │             │             │             │             │          │
│      │             │             │  验证会员卡 │             │          │
│      │             │             │────────────▶│             │          │
│      │             │             │             │             │          │
│      │             │             │  权益状态   │             │          │
│      │             │             │◀────────────│             │          │
│      │             │             │             │             │          │
│      │             │             │  检查签到规则│             │          │
│      │             │             │─────────────┼────────────▶│          │
│      │             │             │             │             │          │
│      │             │             │  规则验证结果│             │          │
│      │             │             │◀────────────┼─────────────│          │
│      │             │             │             │             │          │
│      │             │  验证结果   │             │             │          │
│      │             │◀────────────│             │             │          │
│      │             │             │             │             │          │
│      │             │  创建签到记录│             │             │          │
│      │             │─────────────┼─────────────┼────────────▶│          │
│      │             │             │             │             │          │
│      │             │  签到成功   │             │             │          │
│      │             │◀────────────┼─────────────┼─────────────│          │
│      │             │             │             │             │          │
│      │  签到成功   │             │             │             │          │
│      │◀────────────│             │             │             │          │
│      │             │             │             │             │          │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

4.2 课程签到流程

┌─────────────────────────────────────────────────────────────────────────┐
│                          课程签到流程                                    │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌───────┐     ┌───────┐     ┌───────┐     ┌───────┐     ┌───────┐    │
│  │ 会员  │     │ 签到  │     │ 预约  │     │ 权益  │     │ 签到  │    │
│  │       │     │ 网关  │     │ 服务  │     │ 服务  │     │ 记录  │    │
│  └───┬───┘     └───┬───┘     └───┬───┘     └───┬───┘     └───┬───┘    │
│      │             │             │             │             │          │
│      │  扫码签到   │             │             │             │          │
│      │────────────▶│             │             │             │          │
│      │             │             │             │             │          │
│      │             │  查询预约   │             │             │          │
│      │             │────────────▶│             │             │          │
│      │             │             │             │             │          │
│      │             │  预约信息   │             │             │          │
│      │             │◀────────────│             │             │          │
│      │             │             │             │             │          │
│      │             │  验证签到时间窗口          │             │          │
│      │             │─────────────┼────────────▶│             │          │
│      │             │             │             │             │          │
│      │             │  时间窗口验证结果          │             │          │
│      │             │◀────────────┼─────────────│             │          │
│      │             │             │             │             │          │
│      │             │  验证权益   │             │             │          │
│      │             │─────────────┼────────────▶│             │          │
│      │             │             │             │             │          │
│      │             │  权益状态   │             │             │          │
│      │             │◀────────────┼─────────────│             │          │
│      │             │             │             │             │          │
│      │             │  扣减权益   │             │             │          │
│      │             │─────────────┼────────────▶│             │          │
│      │             │             │             │             │          │
│      │             │  扣减结果   │             │             │          │
│      │             │◀────────────┼─────────────│             │          │
│      │             │             │             │             │          │
│      │             │  创建签到记录│             │             │          │
│      │             │─────────────┼─────────────┼────────────▶│          │
│      │             │             │             │             │          │
│      │             │  更新预约签到状态          │             │          │
│      │             │────────────▶│             │             │          │
│      │             │             │             │             │          │
│      │  签到成功   │             │             │             │          │
│      │◀────────────│             │             │             │          │
│      │             │             │             │             │          │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

4.3 人脸识别签到流程

┌─────────────────────────────────────────────────────────────────────────┐
│                        人脸识别签到流程                                  │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌───────┐     ┌───────┐     ┌───────┐     ┌───────┐     ┌───────┐    │
│  │ 会员  │     │ 人脸  │     │ 人脸  │     │ 签到  │     │ 签到  │    │
│  │       │     │ 设备  │     │ 服务  │     │ 验证  │     │ 记录  │    │
│  └───┬───┘     └───┬───┘     └───┬───┘     └───┬───┘     └───┬───┘    │
│      │             │             │             │             │          │
│      │  人脸识别   │             │             │             │          │
│      │────────────▶│             │             │             │          │
│      │             │             │             │             │          │
│      │             │  提取特征值 │             │             │          │
│      │             │────────────▶│             │             │          │
│      │             │             │             │             │          │
│      │             │             │  匹配会员   │             │          │
│      │             │             │─────────────┼────────────▶│          │
│      │             │             │             │             │          │
│      │             │             │  匹配结果   │             │          │
│      │             │             │◀────────────┼─────────────│          │
│      │             │             │             │             │          │
│      │             │  会员ID     │             │             │          │
│      │             │◀────────────│             │             │          │
│      │             │             │             │             │          │
│      │             │  执行签到流程│             │             │          │
│      │             │─────────────┼────────────▶│             │          │
│      │             │             │             │             │          │
│      │             │  签到结果   │             │             │          │
│      │             │◀────────────┼─────────────┼─────────────│          │
│      │             │             │             │             │          │
│      │  签到成功   │             │             │             │          │
│      │◀────────────│             │             │             │          │
│      │             │             │             │             │          │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

4.4 教练代签流程

┌─────────────────────────────────────────────────────────────────────────┐
│                          教练代签流程                                    │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌───────┐     ┌───────┐     ┌───────┐     ┌───────┐     ┌───────┐    │
│  │ 教练  │     │ 签到  │     │ 预约  │     │ 权益  │     │ 签到  │    │
│  │       │     │ 网关  │     │ 服务  │     │ 服务  │     │ 记录  │    │
│  └───┬───┘     └───┬───┘     └───┬───┘     └───┬───┘     └───┬───┘    │
│      │             │             │             │             │          │
│      │  选择会员   │             │             │             │          │
│      │────────────▶│             │             │             │          │
│      │             │             │             │             │          │
│      │             │  验证教练身份│             │             │          │
│      │             │─────────────┼─────────────┼────────────▶│          │
│      │             │             │             │             │          │
│      │             │  身份验证结果│             │             │          │
│      │             │◀────────────┼─────────────┼─────────────│          │
│      │             │             │             │             │          │
│      │             │  查询会员预约│             │             │          │
│      │             │────────────▶│             │             │          │
│      │             │             │             │             │          │
│      │             │  预约列表   │             │             │          │
│      │             │◀────────────│             │             │          │
│      │             │             │             │             │          │
│      │  选择预约   │             │             │             │          │
│      │────────────▶│             │             │             │          │
│      │             │             │             │             │          │
│      │             │  验证签到资格│             │             │          │
│      │             │─────────────┼────────────▶│             │          │
│      │             │             │             │             │          │
│      │             │  扣减权益   │             │             │          │
│      │             │─────────────┼────────────▶│             │          │
│      │             │             │             │             │          │
│      │             │  创建签到记录│             │             │          │
│      │             │─────────────┼─────────────┼────────────▶│          │
│      │             │             │             │             │          │
│      │  代签成功   │             │             │             │          │
│      │◀────────────│             │             │             │          │
│      │             │             │             │             │          │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

五、接口设计

5.1 签到网关接口

5.1.1 二维码签到

POST /api/v1/checkin/qrcode
Content-Type: application/json

Request:
{
    "tenantId": 1,
    "storeId": 1,
    "qrcode": "MEMBER_123456_TIMESTAMP",
    "deviceId": 1,
    "type": 1,              // 1:入场 2:课程
    "bookingId": null       // 课程签到时必填
}

Response:
{
    "code": 0,
    "message": "签到成功",
    "data": {
        "checkinId": 1001,
        "memberId": 123456,
        "memberName": "张三",
        "memberPhone": "138****8888",
        "memberLevel": "VIP",
        "checkinType": "入场签到",
        "checkinTime": "2026-02-28 10:30:00",
        "benefitDeducted": {
            "type": "时长权益",
            "value": "年卡有效期至2026-12-31"
        },
        "warnings": []
    }
}

5.1.2 人脸识别签到

POST /api/v1/checkin/face
Content-Type: application/json

Request:
{
    "tenantId": 1,
    "storeId": 1,
    "faceFeature": "base64_encoded_feature",
    "deviceId": 1,
    "type": 1,
    "bookingId": null
}

Response:
{
    "code": 0,
    "message": "签到成功",
    "data": {
        "checkinId": 1002,
        "memberId": 123456,
        "memberName": "张三",
        "memberPhone": "138****8888",
        "memberLevel": "VIP",
        "checkinType": "入场签到",
        "checkinTime": "2026-02-28 10:31:00",
        "benefitDeducted": {
            "type": "时长权益",
            "value": "年卡有效期至2026-12-31"
        },
        "warnings": []
    }
}

5.1.3 NFC签到

POST /api/v1/checkin/nfc
Content-Type: application/json

Request:
{
    "tenantId": 1,
    "storeId": 1,
    "nfcId": "NFC_CARD_123456",
    "deviceId": 1,
    "type": 1,
    "bookingId": null
}

Response:
{
    "code": 0,
    "message": "签到成功",
    "data": {
        "checkinId": 1003,
        "memberId": 123456,
        "memberName": "张三",
        "memberPhone": "138****8888",
        "memberLevel": "VIP",
        "checkinType": "入场签到",
        "checkinTime": "2026-02-28 10:32:00",
        "benefitDeducted": {
            "type": "时长权益",
            "value": "年卡有效期至2026-12-31"
        },
        "warnings": []
    }
}

5.1.4 教练代签

POST /api/v1/checkin/manual
Content-Type: application/json

Request:
{
    "tenantId": 1,
    "storeId": 1,
    "memberId": 123456,
    "bookingId": 2001,
    "operatorId": 100,      // 教练ID
    "operatorName": "李教练",
    "remark": "会员已到场"
}

Response:
{
    "code": 0,
    "message": "代签成功",
    "data": {
        "checkinId": 1004,
        "memberId": 123456,
        "memberName": "张三",
        "checkinType": "私教签到",
        "checkinTime": "2026-02-28 10:33:00",
        "operatorName": "李教练"
    }
}

5.2 人脸管理接口

5.2.1 注册人脸

POST /api/v1/face/register
Content-Type: multipart/form-data

Request:
{
    "memberId": 123456,
    "faceImage": <binary>
}

Response:
{
    "code": 0,
    "message": "人脸注册成功",
    "data": {
        "faceId": 1,
        "qualityScore": 95.5,
        "status": "正常"
    }
}

5.2.2 更新人脸

PUT /api/v1/face/{memberId}
Content-Type: multipart/form-data

Request:
{
    "faceImage": <binary>
}

Response:
{
    "code": 0,
    "message": "人脸更新成功",
    "data": {
        "faceId": 1,
        "qualityScore": 96.2,
        "status": "正常"
    }
}

5.2.3 删除人脸

DELETE /api/v1/face/{memberId}

Response:
{
    "code": 0,
    "message": "人脸删除成功"
}

5.3 签到记录接口

5.3.1 查询签到记录

GET /api/v1/checkin/records?memberId=123456&startDate=2026-02-01&endDate=2026-02-28&page=1&size=20

Response:
{
    "code": 0,
    "message": "success",
    "data": {
        "total": 25,
        "list": [
            {
                "checkinId": 1001,
                "type": "入场签到",
                "method": "二维码",
                "checkinTime": "2026-02-28 10:30:00",
                "storeName": "中关村店",
                "status": "成功"
            },
            {
                "checkinId": 1002,
                "type": "课程签到",
                "method": "人脸识别",
                "checkinTime": "2026-02-27 19:00:00",
                "courseName": "瑜伽课",
                "coachName": "王教练",
                "status": "成功"
            }
        ]
    }
}

5.3.2 查询签到统计

GET /api/v1/checkin/statistics?tenantId=1&storeId=1&startDate=2026-02-01&endDate=2026-02-28

Response:
{
    "code": 0,
    "message": "success",
    "data": {
        "totalCount": 1500,
        "entryCount": 800,
        "courseCount": 500,
        "privateCount": 150,
        "activityCount": 50,
        "activeMemberCount": 350,
        "newMemberCount": 25,
        "peakHour": 19,
        "peakCount": 120,
        "avgDuration": 90,
        "dailyTrend": [
            {"date": "2026-02-01", "count": 50},
            {"date": "2026-02-02", "count": 55}
        ]
    }
}

5.4 设备管理接口

5.4.1 设备心跳

POST /api/v1/device/heartbeat
Content-Type: application/json

Request:
{
    "deviceId": 1,
    "deviceCode": "DEVICE_001",
    "status": 1,
    "timestamp": "2026-02-28T10:30:00"
}

Response:
{
    "code": 0,
    "message": "success"
}

5.4.2 设备列表

GET /api/v1/device/list?tenantId=1&storeId=1

Response:
{
    "code": 0,
    "message": "success",
    "data": [
        {
            "deviceId": 1,
            "name": "前台人脸机",
            "code": "DEVICE_001",
            "type": "人脸识别机",
            "location": "前台入口",
            "status": "在线",
            "lastHeartbeat": "2026-02-28 10:30:00"
        }
    ]
}

六、核心代码设计

6.1 签到领域服务实现

@Slf4j
@Service
@RequiredArgsConstructor
public class CheckinDomainServiceImpl implements CheckinDomainService {

    private final CheckinRecordRepository checkinRepository;
    private final MemberRepository memberRepository;
    private final BookingRecordRepository bookingRepository;
    private final BenefitDomainService benefitService;
    private final CheckinRuleRepository ruleRepository;
    private final TransactionalOperator rxtx;
    private final ApplicationEventPublisher eventPublisher;

    @Override
    public Mono<CheckinResult> processCheckin(CheckinRequest request) {
        return Mono.defer(() ->
            validateMember(request.getTenantId(), request.getMemberId())
                .flatMap(member -> validateCheckinRule(member, request))
                .flatMap(member -> processCheckinByType(member, request))
        ).as(rxtx::transactional);
    }

    private Mono<Member> validateMember(Long tenantId, Long memberId) {
        return memberRepository.findByIdAndTenantId(memberId, tenantId)
            .switchIfEmpty(Mono.error(new CheckinException(CheckinException.MEMBER_NOT_FOUND)))
            .flatMap(member -> {
                if (member.getStatus() != MemberStatus.ACTIVE) {
                    return Mono.error(new CheckinException(CheckinException.MEMBER_INACTIVE));
                }
                return Mono.just(member);
            });
    }

    private Mono<Member> validateCheckinRule(Member member, CheckinRequest request) {
        return ruleRepository.findByTenantIdAndRuleType(
            member.getTenantId(),
            request.getType()
        )
        .flatMap(rule -> {
            if (request.getType() == CheckinType.ENTRY) {
                return validateEntryRule(member, rule, request);
            }
            return Mono.just(member);
        })
        .switchIfEmpty(Mono.just(member));
    }

    private Mono<Member> validateEntryRule(Member member, CheckinRule rule, CheckinRequest request) {
        LocalDateTime todayStart = LocalDate.now().atStartOfDay();
        LocalDateTime todayEnd = todayStart.plusDays(1);

        return checkinRepository.countByMemberIdAndTypeAndCheckinAtBetween(
            member.getId(),
            CheckinType.ENTRY,
            todayStart,
            todayEnd
        ).flatMap(count -> {
            if (count >= rule.getMaxDailyEntry()) {
                return Mono.error(new CheckinException(
                    CheckinException.DAILY_LIMIT_EXCEEDED,
                    "今日入场次数已达上限"
                ));
            }

            if (rule.getIntervalMinutes() > 0) {
                return validateCheckinInterval(member, rule);
            }

            return Mono.just(member);
        });
    }

    private Mono<Member> validateCheckinInterval(Member member, CheckinRule rule) {
        return checkinRepository.findFirstByMemberIdOrderByCheckinAtDesc(member.getId())
            .flatMap(lastCheckin -> {
                long minutes = Duration.between(
                    lastCheckin.getCheckinAt(),
                    LocalDateTime.now()
                ).toMinutes();

                if (minutes < rule.getIntervalMinutes()) {
                    return Mono.error(new CheckinException(
                        CheckinException.INTERVAL_NOT_MET,
                        "签到间隔不足" + rule.getIntervalMinutes() + "分钟"
                    ));
                }
                return Mono.just(member);
            })
            .switchIfEmpty(Mono.just(member));
    }

    private Mono<CheckinResult> processCheckinByType(Member member, CheckinRequest request) {
        return switch (request.getType()) {
            case ENTRY -> processEntryCheckin(member, request);
            case COURSE -> processCourseCheckin(member, request);
            case PRIVATE -> processPrivateCheckin(member, request);
            case ACTIVITY -> processActivityCheckin(member, request);
        };
    }

    private Mono<CheckinResult> processEntryCheckin(Member member, CheckinRequest request) {
        return benefitService.validateAndDeduct(
            member.getId(),
            BenefitType.TIME,
            null,
            "入场签到"
        ).flatMap(benefitDeduction -> {
            CheckinRecord record = buildCheckinRecord(member, request, benefitDeduction);
            record.setType(CheckinType.ENTRY);

            return checkinRepository.save(record)
                .doOnNext(saved -> eventPublisher.publishEvent(
                    new CheckinSuccessEvent(saved)
                ))
                .map(saved -> CheckinResult.success(saved));
        }).onErrorResume(e -> {
            if (e instanceof BenefitException) {
                return Mono.just(CheckinResult.failure("权益不足,请充值或续费"));
            }
            return Mono.error(e);
        });
    }

    private Mono<CheckinResult> processCourseCheckin(Member member, CheckinRequest request) {
        return bookingRepository.findById(request.getBookingId())
            .switchIfEmpty(Mono.error(new CheckinException(CheckinException.BOOKING_NOT_FOUND)))
            .flatMap(booking -> validateBookingForCheckin(booking, member))
            .flatMap(booking -> {
                LocalDateTime now = LocalDateTime.now();
                LocalDateTime courseStart = booking.getSlot().getStartTime();
                long minutesBefore = Duration.between(now, courseStart).toMinutes();

                CheckinRecord record = buildCheckinRecord(member, request, null);
                record.setType(CheckinType.COURSE);
                record.setBookingId(booking.getId());

                if (minutesBefore < 0) {
                    record.setLate(true);
                    record.setLateMinutes((int) Math.abs(minutesBefore));
                }

                return checkinRepository.save(record)
                    .flatMap(saved -> updateBookingCheckinStatus(booking, saved))
                    .doOnNext(saved -> eventPublisher.publishEvent(
                        new CheckinSuccessEvent(saved)
                    ))
                    .map(saved -> CheckinResult.success(saved));
            });
    }

    private Mono<BookingRecord> validateBookingForCheckin(BookingRecord booking, Member member) {
        if (!booking.getMemberId().equals(member.getId())) {
            return Mono.error(new CheckinException(CheckinException.BOOKING_NOT_MATCH));
        }

        if (booking.getStatus() != BookingStatus.CONFIRMED) {
            return Mono.error(new CheckinException(CheckinException.BOOKING_NOT_CONFIRMED));
        }

        if (booking.getCheckinStatus() == CheckinStatus.CHECKED) {
            return Mono.error(new CheckinException(CheckinException.ALREADY_CHECKED));
        }

        LocalDateTime now = LocalDateTime.now();
        LocalDateTime courseStart = booking.getSlot().getStartTime();
        LocalDateTime courseEnd = booking.getSlot().getEndTime();

        if (now.isAfter(courseEnd)) {
            return Mono.error(new CheckinException(CheckinException.COURSE_ENDED));
        }

        return Mono.just(booking);
    }

    private Mono<CheckinRecord> updateBookingCheckinStatus(BookingRecord booking, CheckinRecord checkin) {
        booking.setCheckinStatus(checkin.isLate() ? CheckinStatus.LATE : CheckinStatus.CHECKED);
        booking.setCheckinAt(checkin.getCheckinAt());
        booking.setCheckinBy(checkin.getOperatorId());

        return bookingRepository.save(booking).thenReturn(checkin);
    }

    private CheckinRecord buildCheckinRecord(Member member, CheckinRequest request,
                                              BenefitDeduction deduction) {
        CheckinRecord record = new CheckinRecord();
        record.setTenantId(member.getTenantId());
        record.setStoreId(request.getStoreId());
        record.setMemberId(member.getId());
        record.setMethod(request.getMethod());
        record.setDeviceId(request.getDeviceId());
        record.setOperatorId(request.getOperatorId());
        record.setOperatorName(request.getOperatorName());
        record.setStatus(CheckinStatus.SUCCESS);
        record.setCheckinAt(LocalDateTime.now());
        record.setCheckinDate(LocalDate.now());

        if (deduction != null) {
            record.setBenefitId(deduction.benefitId());
            record.setBenefitType(deduction.benefitType());
            record.setBenefitValue(deduction.benefitValue());
        }

        return record;
    }

    @Override
    public Mono<Void> cancelCheckin(Long checkinId, String reason) {
        return checkinRepository.findById(checkinId)
            .switchIfEmpty(Mono.error(new CheckinException(CheckinException.CHECKIN_NOT_FOUND)))
            .flatMap(record -> {
                if (record.getStatus() == CheckinStatus.CANCELLED) {
                    return Mono.error(new CheckinException(CheckinException.ALREADY_CANCELLED));
                }

                record.setStatus(CheckinStatus.CANCELLED);
                record.setFailReason(reason);

                return checkinRepository.save(record)
                    .flatMap(saved -> {
                        if (saved.getBenefitId() != null) {
                            return benefitService.refund(
                                saved.getBenefitId(),
                                saved.getBenefitValue(),
                                "取消签到退款"
                            );
                        }
                        return Mono.empty();
                    });
            })
            .then();
    }

    @Override
    public Mono<Boolean> validateCheckinEligibility(Long memberId, CheckinType type, Long bookingId) {
        return memberRepository.findById(memberId)
            .flatMap(member -> {
                if (member.getStatus() != MemberStatus.ACTIVE) {
                    return Mono.just(false);
                }

                if (type == CheckinType.ENTRY) {
                    return benefitService.hasValidBenefit(memberId, BenefitType.TIME);
                }

                if (bookingId != null) {
                    return bookingRepository.findById(bookingId)
                        .map(booking -> booking.getStatus() == BookingStatus.CONFIRMED
                            && booking.getCheckinStatus() == CheckinStatus.NOT_CHECKED);
                }

                return Mono.just(true);
            })
            .switchIfEmpty(Mono.just(false));
    }
}

6.2 人脸识别服务实现

@Slf4j
@Service
@RequiredArgsConstructor
public class FaceRecognitionServiceImpl implements FaceRecognitionService {

    private final MemberFaceRepository faceRepository;
    private final MemberRepository memberRepository;
    private final FaceFeatureExtractor featureExtractor;
    private final Cache<Long, byte[]> faceFeatureCache;
    private final TransactionalOperator rxtx;

    private static final float MATCH_THRESHOLD = 0.85f;
    private static final float QUALITY_THRESHOLD = 60.0f;

    @Override
    public Mono<Boolean> registerFace(Long memberId, byte[] faceImage) {
        return Mono.fromCallable(() -> featureExtractor.extractFeature(faceImage))
            .subscribeOn(Schedulers.boundedElastic())
            .flatMap(featureResult -> {
                if (featureResult.qualityScore() < QUALITY_THRESHOLD) {
                    return Mono.error(new FaceException(
                        FaceException.QUALITY_TOO_LOW,
                        "人脸质量分数过低: " + featureResult.qualityScore()
                    ));
                }

                return faceRepository.existsByMemberId(memberId)
                    .flatMap(exists -> {
                        if (exists) {
                            return Mono.error(new FaceException(
                                FaceException.FACE_ALREADY_REGISTERED
                            ));
                        }

                        MemberFace face = new MemberFace();
                        face.setMemberId(memberId);
                        face.setFaceFeature(featureResult.feature());
                        face.setQualityScore(featureResult.qualityScore());
                        face.setFeatureVersion("v1.0");
                        face.setStatus(FaceStatus.ACTIVE);

                        return faceRepository.save(face)
                            .doOnNext(saved -> faceFeatureCache.put(memberId, saved.getFaceFeature()))
                            .thenReturn(true);
                    });
            })
            .as(rxtx::transactional);
    }

    @Override
    public Mono<Long> matchFace(byte[] faceFeature, Long tenantId) {
        return Mono.fromCallable(() -> {
                List<MemberFace> faces = faceRepository.findAllByTenantIdAndStatus(
                    tenantId,
                    FaceStatus.ACTIVE
                );

                float maxSimilarity = 0;
                Long matchedMemberId = null;

                for (MemberFace face : faces) {
                    byte[] cachedFeature = faceFeatureCache.getIfPresent(face.getMemberId());
                    byte[] targetFeature = cachedFeature != null ? cachedFeature : face.getFaceFeature();

                    float similarity = featureExtractor.compareFeature(faceFeature, targetFeature);

                    if (similarity > maxSimilarity && similarity >= MATCH_THRESHOLD) {
                        maxSimilarity = similarity;
                        matchedMemberId = face.getMemberId();
                    }
                }

                return matchedMemberId;
            })
            .subscribeOn(Schedulers.boundedElastic())
            .flatMap(memberId -> {
                if (memberId == null) {
                    return Mono.error(new FaceException(FaceException.FACE_NOT_MATCHED));
                }

                return faceRepository.updateMatchInfo(memberId, LocalDateTime.now())
                    .thenReturn(memberId);
            });
    }

    @Override
    public Mono<Boolean> updateFace(Long memberId, byte[] faceImage) {
        return Mono.fromCallable(() -> featureExtractor.extractFeature(faceImage))
            .subscribeOn(Schedulers.boundedElastic())
            .flatMap(featureResult -> {
                if (featureResult.qualityScore() < QUALITY_THRESHOLD) {
                    return Mono.error(new FaceException(
                        FaceException.QUALITY_TOO_LOW,
                        "人脸质量分数过低: " + featureResult.qualityScore()
                    ));
                }

                return faceRepository.findByMemberId(memberId)
                    .switchIfEmpty(Mono.error(new FaceException(FaceException.FACE_NOT_FOUND)))
                    .flatMap(face -> {
                        face.setFaceFeature(featureResult.feature());
                        face.setQualityScore(featureResult.qualityScore());
                        face.setStatus(FaceStatus.ACTIVE);

                        return faceRepository.save(face)
                            .doOnNext(saved -> faceFeatureCache.put(memberId, saved.getFaceFeature()))
                            .thenReturn(true);
                    });
            })
            .as(rxtx::transactional);
    }

    @Override
    public Mono<Void> deleteFace(Long memberId) {
        return faceRepository.deleteByMemberId(memberId)
            .doOnSuccess(v -> faceFeatureCache.invalidate(memberId))
            .then();
    }
}

6.3 签到网关实现

@Slf4j
@Service
@RequiredArgsConstructor
public class CheckinGateway {

    private final CheckinDomainService checkinService;
    private final MemberRepository memberRepository;
    private final QRCodeValidator qrCodeValidator;
    private final NFCService nfcService;

    public Mono<CheckinResult> processQRCode(CheckinQRCodeRequest request) {
        return Mono.defer(() -> {
            QRCodeInfo qrInfo = qrCodeValidator.parseAndValidate(request.getQrcode());

            if (qrInfo.isExpired()) {
                return Mono.just(CheckinResult.failure("二维码已过期,请刷新"));
            }

            CheckinRequest checkinRequest = new CheckinRequest();
            checkinRequest.setTenantId(request.getTenantId());
            checkinRequest.setStoreId(request.getStoreId());
            checkinRequest.setMemberId(qrInfo.getMemberId());
            checkinRequest.setMethod(CheckinMethod.QRCODE);
            checkinRequest.setDeviceId(request.getDeviceId());
            checkinRequest.setType(CheckinType.fromCode(request.getType()));
            checkinRequest.setBookingId(request.getBookingId());

            return checkinService.processCheckin(checkinRequest);
        });
    }

    public Mono<CheckinResult> processNFC(CheckinNFCRequest request) {
        return nfcService.getMemberByNFC(request.getNfcId())
            .flatMap(member -> {
                CheckinRequest checkinRequest = new CheckinRequest();
                checkinRequest.setTenantId(request.getTenantId());
                checkinRequest.setStoreId(request.getStoreId());
                checkinRequest.setMemberId(member.getId());
                checkinRequest.setMethod(CheckinMethod.NFC);
                checkinRequest.setDeviceId(request.getDeviceId());
                checkinRequest.setType(CheckinType.fromCode(request.getType()));
                checkinRequest.setBookingId(request.getBookingId());

                return checkinService.processCheckin(checkinRequest);
            })
            .onErrorResume(e -> {
                if (e instanceof NFCException) {
                    return Mono.just(CheckinResult.failure("NFC卡未绑定会员"));
                }
                return Mono.error(e);
            });
    }

    public Mono<CheckinResult> processManual(CheckinManualRequest request) {
        return Mono.defer(() -> {
            CheckinRequest checkinRequest = new CheckinRequest();
            checkinRequest.setTenantId(request.getTenantId());
            checkinRequest.setStoreId(request.getStoreId());
            checkinRequest.setMemberId(request.getMemberId());
            checkinRequest.setMethod(CheckinMethod.MANUAL);
            checkinRequest.setType(CheckinType.PRIVATE);
            checkinRequest.setBookingId(request.getBookingId());
            checkinRequest.setOperatorId(request.getOperatorId());
            checkinRequest.setOperatorName(request.getOperatorName());

            return checkinService.processCheckin(checkinRequest);
        });
    }
}

6.4 签到统计服务实现

@Slf4j
@Service
@RequiredArgsConstructor
public class CheckinStatisticsServiceImpl implements CheckinStatisticsService {

    private final CheckinRecordRepository checkinRepository;
    private final CheckinStatisticsRepository statisticsRepository;
    private final MemberRepository memberRepository;

    @Override
    public Mono<Void> generateDailyStatistics(Long tenantId, Long storeId, LocalDate date) {
        return Mono.defer(() -> {
            LocalDateTime startOfDay = date.atStartOfDay();
            LocalDateTime endOfDay = startOfDay.plusDays(1);

            Mono<Long> totalCount = checkinRepository.countByTenantIdAndStoreIdAndCheckinAtBetween(
                tenantId, storeId, startOfDay, endOfDay
            );

            Mono<Long> entryCount = checkinRepository.countByTenantIdAndStoreIdAndTypeAndCheckinAtBetween(
                tenantId, storeId, CheckinType.ENTRY, startOfDay, endOfDay
            );

            Mono<Long> courseCount = checkinRepository.countByTenantIdAndStoreIdAndTypeAndCheckinAtBetween(
                tenantId, storeId, CheckinType.COURSE, startOfDay, endOfDay
            );

            Mono<Long> privateCount = checkinRepository.countByTenantIdAndStoreIdAndTypeAndCheckinAtBetween(
                tenantId, storeId, CheckinType.PRIVATE, startOfDay, endOfDay
            );

            Mono<Long> activityCount = checkinRepository.countByTenantIdAndStoreIdAndTypeAndCheckinAtBetween(
                tenantId, storeId, CheckinType.ACTIVITY, startOfDay, endOfDay
            );

            Mono<Integer> activeMemberCount = checkinRepository.countDistinctMemberByTenantIdAndStoreIdAndCheckinAtBetween(
                tenantId, storeId, startOfDay, endOfDay
            );

            Mono<Map<Integer, Long>> hourlyDistribution = checkinRepository
                .findHourlyDistribution(tenantId, storeId, startOfDay, endOfDay)
                .collectMap(CheckinHourlyStats::getHour, CheckinHourlyStats::getCount);

            return Mono.zip(totalCount, entryCount, courseCount, privateCount,
                           activityCount, activeMemberCount, hourlyDistribution)
                .flatMap(tuple -> {
                    CheckinStatistics stats = new CheckinStatistics();
                    stats.setTenantId(tenantId);
                    stats.setStoreId(storeId);
                    stats.setStatDate(date);
                    stats.setStatType(StatType.DAILY);
                    stats.setTotalCount(tuple.getT1().intValue());
                    stats.setEntryCount(tuple.getT2().intValue());
                    stats.setCourseCount(tuple.getT3().intValue());
                    stats.setPrivateCount(tuple.getT4().intValue());
                    stats.setActivityCount(tuple.getT5().intValue());
                    stats.setActiveMemberCount(tuple.getT6());

                    Map<Integer, Long> hourly = tuple.getT7();
                    if (!hourly.isEmpty()) {
                        stats.setPeakHour(
                            hourly.entrySet().stream()
                                .max(Map.Entry.comparingByValue())
                                .map(Map.Entry::getKey)
                                .orElse(null)
                        );
                        stats.setPeakCount(
                            hourly.values().stream()
                                .max(Long::compare)
                                .map(Long::intValue)
                                .orElse(0)
                        );
                    }

                    return statisticsRepository.save(stats);
                })
                .then();
        });
    }

    @Override
    public Mono<CheckinStatistics> getDailyStatistics(Long tenantId, Long storeId, LocalDate date) {
        return statisticsRepository.findByTenantIdAndStoreIdAndStatDateAndStatType(
            tenantId, storeId, date, StatType.DAILY
        );
    }

    @Override
    public Mono<Map<String, Object>> getMemberCheckinStats(Long memberId,
            LocalDate startDate, LocalDate endDate) {
        LocalDateTime start = startDate.atStartOfDay();
        LocalDateTime end = endDate.plusDays(1).atStartOfDay();

        Mono<Long> totalCount = checkinRepository.countByMemberIdAndCheckinAtBetween(
            memberId, start, end
        );

        Mono<Map<CheckinType, Long>> typeDistribution = checkinRepository
            .countByMemberIdGroupByType(memberId, start, end)
            .collectMap(CheckinTypeStats::getType, CheckinTypeStats::getCount);

        Mono<List<LocalDate>> checkinDates = checkinRepository
            .findDistinctCheckinDatesByMemberId(memberId, start, end)
            .collectList();

        return Mono.zip(totalCount, typeDistribution, checkinDates)
            .map(tuple -> {
                Map<String, Object> result = new HashMap<>();
                result.put("totalCount", tuple.getT1());
                result.put("typeDistribution", tuple.getT2());
                result.put("checkinDays", tuple.getT3().size());
                result.put("checkinDates", tuple.getT3());
                return result;
            });
    }
}

七、高并发处理

7.1 签到并发场景分析

场景 并发特点 处理策略
早高峰入场 短时间内大量签到请求 本地缓存+异步处理
课程签到窗口 集中签到时段 预加载+限流
人脸识别匹配 计算密集型 特征缓存+批量匹配
统计计算 数据量大 异步任务+增量计算

7.2 签到限流设计

@Slf4j
@Component
@RequiredArgsConstructor
public class CheckinRateLimiter {

    private final Cache<String, AtomicInteger> rateLimitCache = Caffeine.newBuilder()
        .expireAfterWrite(Duration.ofSeconds(1))
        .build();

    private static final int MAX_REQUESTS_PER_SECOND = 100;

    public Mono<Boolean> allowRequest(Long tenantId, Long storeId) {
        String key = tenantId + ":" + storeId + ":" + System.currentTimeMillis() / 1000;

        return Mono.fromCallable(() -> {
            AtomicInteger counter = rateLimitCache.get(key, k -> new AtomicInteger(0));
            return counter.incrementAndGet() <= MAX_REQUESTS_PER_SECOND;
        });
    }

    public Mono<T> withRateLimit(Long tenantId, Long storeId, Mono<T> action) {
        return allowRequest(tenantId, storeId)
            .flatMap(allowed -> {
                if (allowed) {
                    return action;
                }
                return Mono.error(new CheckinException(
                    CheckinException.RATE_LIMIT_EXCEEDED,
                    "签到请求过于频繁,请稍后重试"
                ));
            });
    }
}

7.3 人脸特征缓存设计

@Slf4j
@Component
public class FaceFeatureCacheManager {

    private final Cache<Long, byte[]> featureCache = Caffeine.newBuilder()
        .maximumSize(10000)
        .expireAfterAccess(Duration.ofHours(24))
        .recordStats()
        .build();

    private final MemberFaceRepository faceRepository;

    @Scheduled(fixedRate = 300000)
    public void preloadFeatures() {
        log.info("开始预加载人脸特征...");

        faceRepository.findAllByStatus(FaceStatus.ACTIVE)
            .doOnNext(face -> featureCache.put(face.getMemberId(), face.getFaceFeature()))
            .then()
            .subscribe(
                v -> log.info("人脸特征预加载完成"),
                e -> log.error("人脸特征预加载失败", e)
            );
    }

    public Optional<byte[]> getFeature(Long memberId) {
        return Optional.ofNullable(featureCache.getIfPresent(memberId));
    }

    public void putFeature(Long memberId, byte[] feature) {
        featureCache.put(memberId, feature);
    }

    public void invalidate(Long memberId) {
        featureCache.invalidate(memberId);
    }

    public CacheStats getStats() {
        return featureCache.stats();
    }
}

7.4 异步签到处理

@Slf4j
@Service
@RequiredArgsConstructor
public class AsyncCheckinProcessor {

    private final CheckinRecordRepository checkinRepository;
    private final ApplicationEventPublisher eventPublisher;
    private final Sinks.Many<CheckinTask> checkinSink;

    @PostConstruct
    public void init() {
        checkinSink.asFlux()
            .flatMap(this::processAsync, 10)
            .subscribe(
                v -> {},
                e -> log.error("异步签到处理错误", e)
            );
    }

    public Mono<Long> submitAsync(CheckinTask task) {
        return Mono.fromCallable(() -> {
            checkinSink.tryEmitNext(task);
            return task.getTaskId();
        });
    }

    private Mono<Void> processAsync(CheckinTask task) {
        return processCheckin(task)
            .flatMap(record -> {
                eventPublisher.publishEvent(new CheckinSuccessEvent(record));
                return Mono.empty();
            })
            .onErrorResume(e -> {
                log.error("异步签到处理失败: taskId={}", task.getTaskId(), e);
                return saveFailedRecord(task, e);
            })
            .then();
    }

    private Mono<CheckinRecord> processCheckin(CheckinTask task) {
        // 签到处理逻辑
    }

    private Mono<Void> saveFailedRecord(CheckinTask task, Throwable e) {
        CheckinRecord record = new CheckinRecord();
        record.setStatus(CheckinStatus.FAILED);
        record.setFailReason(e.getMessage());
        // 设置其他字段...

        return checkinRepository.save(record).then();
    }
}

八、缓存设计

8.1 缓存策略

数据类型 缓存位置 过期时间 更新策略
会员信息 本地缓存 30分钟 写时更新
人脸特征 本地缓存 24小时 定时刷新
签到规则 本地缓存 1小时 写时更新
签到统计 本地缓存 5分钟 定时计算
设备状态 本地缓存 1分钟 心跳更新

8.2 缓存配置

@Configuration
public class CheckinCacheConfig {

    @Bean
    public Cache<Long, MemberInfo> memberCache() {
        return Caffeine.newBuilder()
            .maximumSize(5000)
            .expireAfterWrite(Duration.ofMinutes(30))
            .recordStats()
            .build();
    }

    @Bean
    public Cache<Long, byte[]> faceFeatureCache() {
        return Caffeine.newBuilder()
            .maximumSize(10000)
            .expireAfterAccess(Duration.ofHours(24))
            .recordStats()
            .build();
    }

    @Bean
    public Cache<Long, CheckinRule> ruleCache() {
        return Caffeine.newBuilder()
            .maximumSize(100)
            .expireAfterWrite(Duration.ofHours(1))
            .build();
    }
}

---

## 定时任务设计

### 9.1 统计任务

```java
@Slf4j
@Component
@RequiredArgsConstructor
public class CheckinStatisticsScheduler {

    private final CheckinStatisticsService statisticsService;

    @Scheduled(cron = "0 5 0 * * ?")
    public void generateYesterdayStatistics() {
        LocalDate yesterday = LocalDate.now().minusDays(1);
        statisticsService.generateDailyStatistics(null, null, yesterday)
            .subscribe(
                v -> log.info("昨日签到统计生成完成: {}", yesterday),
                e -> log.error("昨日签到统计生成失败", e)
            );
    }

    @Scheduled(cron = "0 0 */1 * * ?")
    public void generateTodayStatistics() {
        LocalDate today = LocalDate.now();
        statisticsService.generateDailyStatistics(null, null, today)
            .subscribe(
                v -> log.info("今日签到统计更新完成: {}", today),
                e -> log.error("今日签到统计更新失败", e)
            );
    }
}

9.2 设备心跳检测

@Slf4j
@Component
@RequiredArgsConstructor
public class DeviceHeartbeatScheduler {

    private final CheckinDeviceRepository deviceRepository;

    @Scheduled(fixedRate = 60000)
    public void checkDeviceStatus() {
        LocalDateTime threshold = LocalDateTime.now().minusMinutes(5);

        deviceRepository.findAllByStatus(DeviceStatus.ONLINE)
            .filter(device -> device.getLastHeartbeat().isBefore(threshold))
            .flatMap(device -> {
                device.setStatus(DeviceStatus.OFFLINE);
                return deviceRepository.save(device);
            })
            .subscribe(
                device -> log.warn("设备离线: {}", device.getName()),
                e -> log.error("设备状态检测失败", e)
            );
    }
}

十、异常处理

10.1 异常定义

public class CheckinException extends RuntimeException {

    public static final String MEMBER_NOT_FOUND = "MEMBER_NOT_FOUND";
    public static final String MEMBER_INACTIVE = "MEMBER_INACTIVE";
    public static final String BOOKING_NOT_FOUND = "BOOKING_NOT_FOUND";
    public static final String BOOKING_NOT_MATCH = "BOOKING_NOT_MATCH";
    public static final String BOOKING_NOT_CONFIRMED = "BOOKING_NOT_CONFIRMED";
    public static final String ALREADY_CHECKED = "ALREADY_CHECKED";
    public static final String COURSE_ENDED = "COURSE_ENDED";
    public static final String DAILY_LIMIT_EXCEEDED = "DAILY_LIMIT_EXCEEDED";
    public static final String INTERVAL_NOT_MET = "INTERVAL_NOT_MET";
    public static final String RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED";
    public static final String CHECKIN_NOT_FOUND = "CHECKIN_NOT_FOUND";
    public static final String ALREADY_CANCELLED = "ALREADY_CANCELLED";

    private final String code;

    public CheckinException(String code) {
        super(getMessage(code));
        this.code = code;
    }

    public CheckinException(String code, String message) {
        super(message);
        this.code = code;
    }

    private static String getMessage(String code) {
        return switch (code) {
            case MEMBER_NOT_FOUND -> "会员不存在";
            case MEMBER_INACTIVE -> "会员状态异常";
            case BOOKING_NOT_FOUND -> "预约记录不存在";
            case BOOKING_NOT_MATCH -> "预约信息不匹配";
            case BOOKING_NOT_CONFIRMED -> "预约未确认";
            case ALREADY_CHECKED -> "已签到";
            case COURSE_ENDED -> "课程已结束";
            case DAILY_LIMIT_EXCEEDED -> "签到次数超限";
            case INTERVAL_NOT_MET -> "签到间隔不足";
            case RATE_LIMIT_EXCEEDED -> "请求过于频繁";
            case CHECKIN_NOT_FOUND -> "签到记录不存在";
            case ALREADY_CANCELLED -> "签到已取消";
            default -> "签到异常";
        };
    }
}

public class FaceException extends RuntimeException {

    public static final String QUALITY_TOO_LOW = "QUALITY_TOO_LOW";
    public static final String FACE_ALREADY_REGISTERED = "FACE_ALREADY_REGISTERED";
    public static final String FACE_NOT_FOUND = "FACE_NOT_FOUND";
    public static final String FACE_NOT_MATCHED = "FACE_NOT_MATCHED";

    private final String code;

    public FaceException(String code) {
        super(getMessage(code));
        this.code = code;
    }

    public FaceException(String code, String message) {
        super(message);
        this.code = code;
    }

    private static String getMessage(String code) {
        return switch (code) {
            case QUALITY_TOO_LOW -> "人脸质量分数过低";
            case FACE_ALREADY_REGISTERED -> "人脸已注册";
            case FACE_NOT_FOUND -> "人脸信息不存在";
            case FACE_NOT_MATCHED -> "人脸匹配失败";
            default -> "人脸识别异常";
        };
    }
}

10.2 全局异常处理

@Slf4j
@RestControllerAdvice
public class CheckinExceptionHandler {

    @ExceptionHandler(CheckinException.class)
    public ResponseEntity<ApiResponse<Void>> handleCheckinException(CheckinException e) {
        log.warn("签到异常: {}", e.getMessage());
        return ResponseEntity.badRequest()
            .body(ApiResponse.error(e.getCode(), e.getMessage()));
    }

    @ExceptionHandler(FaceException.class)
    public ResponseEntity<ApiResponse<Void>> handleFaceException(FaceException e) {
        log.warn("人脸识别异常: {}", e.getMessage());
        return ResponseEntity.badRequest()
            .body(ApiResponse.error(e.getCode(), e.getMessage()));
    }
}

十一、附录

11.1 枚举定义

枚举类型 说明
CheckinType 1 入场签到
CheckinType 2 课程签到
CheckinType 3 私教签到
CheckinType 4 活动签到
CheckinMethod 1 二维码
CheckinMethod 2 人脸识别
CheckinMethod 3 NFC
CheckinMethod 4 教练代签
CheckinStatus 1 成功
CheckinStatus 2 失败
CheckinStatus 3 已取消
DeviceType 1 人脸识别机
DeviceType 2 NFC读卡器
DeviceType 3 扫码枪
DeviceType 4 一体机
DeviceStatus 1 在线
DeviceStatus 2 离线
DeviceStatus 3 维护中
FaceStatus 1 正常
FaceStatus 2 待更新
FaceStatus 3 已禁用

11.2 错误码定义

错误码 说明 处理建议
MEMBER_NOT_FOUND 会员不存在 检查会员ID
MEMBER_INACTIVE 会员状态异常 联系工作人员
BOOKING_NOT_FOUND 预约不存在 检查预约ID
BOOKING_NOT_MATCH 预约不匹配 确认预约信息
BOOKING_NOT_CONFIRMED 预约未确认 等待确认
ALREADY_CHECKED 已签到 无需重复签到
COURSE_ENDED 课程已结束 无法签到
DAILY_LIMIT_EXCEEDED 签到次数超限 明日再来
INTERVAL_NOT_MET 签到间隔不足 稍后重试
RATE_LIMIT_EXCEEDED 请求过于频繁 稍后重试
QUALITY_TOO_LOW 人脸质量低 重新拍照
FACE_NOT_MATCHED 人脸匹配失败 重新注册或使用其他方式

十二、版本历史

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