# 健身房管理系统详细设计文档 - 签到模块(LLD) > 文档编号: GYM-LLD-003 > 版本: v1.0 > 日期: 2026-02-28 > 作者: 张翔 > 状态: 初稿 > **归属版本**: 基础版 **说明**:本文档为健身房管理系统**基础版**的签到模块详细设计文档,描述扫码签到模块的数据库设计、API设计、业务逻辑实现等技术细节。 --- ## 文档修订历史 | 版本 | 日期 | 作者 | 修订内容 | | ---- | ---------- | ---- | -------- | | v1.0 | 2026-02-28 | 张翔 | 初稿 | --- ## 参考文档 - 《健身房管理系统产品设计文档》 GYM-PRD-001 - 《健身房管理系统业务概要设计文档》 GYM-HLD-001 - 《健身房管理系统详细设计文档》 GYM-LLD-000 - Spring Boot 3 官方文档 - R2DBC 规范文档 - PostgreSQL 官方文档 --- ## 一、模块概述 ### 1.1 模块定位 签到模块是健身房管理系统的核心业务模块,负责管理会员的入场签到和课程签到,支持多种签到方式: - **二维码签到**:会员出示二维码,扫码签到 - **人脸识别签到**:通过人脸识别设备自动签到 - **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) ```sql 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) ```sql 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) ```sql 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) ```sql 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) ```sql 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 值对象设计 ```java 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 warnings ) {} ``` ### 3.3 领域服务设计 ```java public interface CheckinDomainService { Mono processCheckin(CheckinRequest request); Mono cancelCheckin(Long checkinId, String reason); Mono validateCheckinEligibility(Long memberId, CheckinType type, Long bookingId); Mono getCheckinRecord(Long checkinId); Flux getMemberCheckinHistory(Long memberId, LocalDate startDate, LocalDate endDate); } public interface FaceRecognitionService { Mono registerFace(Long memberId, byte[] faceImage); Mono matchFace(byte[] faceFeature, Long tenantId); Mono updateFace(Long memberId, byte[] faceImage); Mono deleteFace(Long memberId); } public interface CheckinStatisticsService { Mono generateDailyStatistics(Long tenantId, Long storeId, LocalDate date); Mono getDailyStatistics(Long tenantId, Long storeId, LocalDate date); Mono> 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": } 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": } 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 签到领域服务实现 ```java @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 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 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 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 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 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 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 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 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 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 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 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 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 人脸识别服务实现 ```java @Slf4j @Service @RequiredArgsConstructor public class FaceRecognitionServiceImpl implements FaceRecognitionService { private final MemberFaceRepository faceRepository; private final MemberRepository memberRepository; private final FaceFeatureExtractor featureExtractor; private final Cache faceFeatureCache; private final TransactionalOperator rxtx; private static final float MATCH_THRESHOLD = 0.85f; private static final float QUALITY_THRESHOLD = 60.0f; @Override public Mono 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 matchFace(byte[] faceFeature, Long tenantId) { return Mono.fromCallable(() -> { List 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 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 deleteFace(Long memberId) { return faceRepository.deleteByMemberId(memberId) .doOnSuccess(v -> faceFeatureCache.invalidate(memberId)) .then(); } } ``` ### 6.3 签到网关实现 ```java @Slf4j @Service @RequiredArgsConstructor public class CheckinGateway { private final CheckinDomainService checkinService; private final MemberRepository memberRepository; private final QRCodeValidator qrCodeValidator; private final NFCService nfcService; public Mono 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 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 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 签到统计服务实现 ```java @Slf4j @Service @RequiredArgsConstructor public class CheckinStatisticsServiceImpl implements CheckinStatisticsService { private final CheckinRecordRepository checkinRepository; private final CheckinStatisticsRepository statisticsRepository; private final MemberRepository memberRepository; @Override public Mono generateDailyStatistics(Long tenantId, Long storeId, LocalDate date) { return Mono.defer(() -> { LocalDateTime startOfDay = date.atStartOfDay(); LocalDateTime endOfDay = startOfDay.plusDays(1); Mono totalCount = checkinRepository.countByTenantIdAndStoreIdAndCheckinAtBetween( tenantId, storeId, startOfDay, endOfDay ); Mono entryCount = checkinRepository.countByTenantIdAndStoreIdAndTypeAndCheckinAtBetween( tenantId, storeId, CheckinType.ENTRY, startOfDay, endOfDay ); Mono courseCount = checkinRepository.countByTenantIdAndStoreIdAndTypeAndCheckinAtBetween( tenantId, storeId, CheckinType.COURSE, startOfDay, endOfDay ); Mono privateCount = checkinRepository.countByTenantIdAndStoreIdAndTypeAndCheckinAtBetween( tenantId, storeId, CheckinType.PRIVATE, startOfDay, endOfDay ); Mono activityCount = checkinRepository.countByTenantIdAndStoreIdAndTypeAndCheckinAtBetween( tenantId, storeId, CheckinType.ACTIVITY, startOfDay, endOfDay ); Mono activeMemberCount = checkinRepository.countDistinctMemberByTenantIdAndStoreIdAndCheckinAtBetween( tenantId, storeId, startOfDay, endOfDay ); Mono> 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 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 getDailyStatistics(Long tenantId, Long storeId, LocalDate date) { return statisticsRepository.findByTenantIdAndStoreIdAndStatDateAndStatType( tenantId, storeId, date, StatType.DAILY ); } @Override public Mono> getMemberCheckinStats(Long memberId, LocalDate startDate, LocalDate endDate) { LocalDateTime start = startDate.atStartOfDay(); LocalDateTime end = endDate.plusDays(1).atStartOfDay(); Mono totalCount = checkinRepository.countByMemberIdAndCheckinAtBetween( memberId, start, end ); Mono> typeDistribution = checkinRepository .countByMemberIdGroupByType(memberId, start, end) .collectMap(CheckinTypeStats::getType, CheckinTypeStats::getCount); Mono> checkinDates = checkinRepository .findDistinctCheckinDatesByMemberId(memberId, start, end) .collectList(); return Mono.zip(totalCount, typeDistribution, checkinDates) .map(tuple -> { Map 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 签到限流设计 ```java @Slf4j @Component @RequiredArgsConstructor public class CheckinRateLimiter { private final Cache rateLimitCache = Caffeine.newBuilder() .expireAfterWrite(Duration.ofSeconds(1)) .build(); private static final int MAX_REQUESTS_PER_SECOND = 100; public Mono 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 withRateLimit(Long tenantId, Long storeId, Mono action) { return allowRequest(tenantId, storeId) .flatMap(allowed -> { if (allowed) { return action; } return Mono.error(new CheckinException( CheckinException.RATE_LIMIT_EXCEEDED, "签到请求过于频繁,请稍后重试" )); }); } } ``` ### 7.3 人脸特征缓存设计 ```java @Slf4j @Component public class FaceFeatureCacheManager { private final Cache 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 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 异步签到处理 ```java @Slf4j @Service @RequiredArgsConstructor public class AsyncCheckinProcessor { private final CheckinRecordRepository checkinRepository; private final ApplicationEventPublisher eventPublisher; private final Sinks.Many checkinSink; @PostConstruct public void init() { checkinSink.asFlux() .flatMap(this::processAsync, 10) .subscribe( v -> {}, e -> log.error("异步签到处理错误", e) ); } public Mono submitAsync(CheckinTask task) { return Mono.fromCallable(() -> { checkinSink.tryEmitNext(task); return task.getTaskId(); }); } private Mono 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 processCheckin(CheckinTask task) { // 签到处理逻辑 } private Mono 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 缓存配置 ````java @Configuration public class CheckinCacheConfig { @Bean public Cache memberCache() { return Caffeine.newBuilder() .maximumSize(5000) .expireAfterWrite(Duration.ofMinutes(30)) .recordStats() .build(); } @Bean public Cache faceFeatureCache() { return Caffeine.newBuilder() .maximumSize(10000) .expireAfterAccess(Duration.ofHours(24)) .recordStats() .build(); } @Bean public Cache 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 设备心跳检测 ```java @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 异常定义 ```java 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 全局异常处理 ```java @Slf4j @RestControllerAdvice public class CheckinExceptionHandler { @ExceptionHandler(CheckinException.class) public ResponseEntity> handleCheckinException(CheckinException e) { log.warn("签到异常: {}", e.getMessage()); return ResponseEntity.badRequest() .body(ApiResponse.error(e.getCode(), e.getMessage())); } @ExceptionHandler(FaceException.class) public ResponseEntity> 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 | 张翔 | 初稿 |