97c5c08513
为提高兼容性,避免Mermaid版本兼容问题,将所有设计文档中的 Mermaid图表转换为ASCII格式。 修改文件: - HLD-系统概要设计.md • 业务范围图 (mindmap → ASCII) • 总体架构图 (graph → ASCII) • 技术架构图 (graph → ASCII) • 部署架构图 (graph → ASCII) • 模块划分图 (graph → ASCII) • 模块交互图 (sequenceDiagram → ASCII) • 接口分组图 (graph → ASCII) - LLD-会员模块详细设计.md • 模块边界图 (graph → ASCII) • 实体关系图 (erDiagram → ASCII) - LLD-预约模块详细设计.md • 模块边界图 (graph → ASCII) • 实体关系图 (erDiagram → ASCII) - LLD-签到模块详细设计.md • 模块边界图 (graph → ASCII) • 实体关系图 (erDiagram → ASCII) 所有ASCII图表采用统一的边框样式,左右两侧对齐,提高可读性。~
88 KiB
88 KiB
健身房管理系统详细设计文档 - 签到模块(LLD)
文档编号: GYM-LLD-003
版本: v1.0
日期: 2026-02-28
作者: 张翔
状态: 初稿
文档修订历史
| 版本 | 日期 | 作者 | 修订内容 |
|---|---|---|---|
| v1.0 | 2026-02-28 | 张翔 | 初稿 |
参考文档
- 《健身房管理系统产品设计文档》 GYM-PRD-001
- 《健身房管理系统概要设计文档》 GYM-HLD-001
- 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 | 张翔 | 初稿 |