c1d7660aac
- 修复HLD-系统概要设计.md中所有ASCII图表的右侧边框对齐 - 修复LLD-签到模块详细设计.md中ASCII图表的右侧边框对齐 - 修复LLD-会员模块详细设计.md中ASCII图表的右侧边框对齐 - 修复LLD-预约模块详细设计.md中ASCII图表的右侧边框对齐 - 确保所有ASCII图表的右侧边框纵向靠右对齐
1970 lines
88 KiB
Markdown
1970 lines
88 KiB
Markdown
# 健身房管理系统详细设计文档 - 签到模块(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)
|
|
|
|
```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<String> warnings
|
|
) {}
|
|
```
|
|
|
|
### 3.3 领域服务设计
|
|
|
|
```java
|
|
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 签到领域服务实现
|
|
|
|
```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<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 人脸识别服务实现
|
|
|
|
```java
|
|
@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 签到网关实现
|
|
|
|
```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<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 签到统计服务实现
|
|
|
|
```java
|
|
@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 签到限流设计
|
|
|
|
```java
|
|
@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 人脸特征缓存设计
|
|
|
|
```java
|
|
@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 异步签到处理
|
|
|
|
```java
|
|
@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 缓存配置
|
|
|
|
````java
|
|
@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 设备心跳检测
|
|
|
|
```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<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 | 张翔 | 初稿 |
|