diff --git a/docs/api/API接口文档.md b/docs/api/API接口文档.md new file mode 100644 index 0000000..747af04 --- /dev/null +++ b/docs/api/API接口文档.md @@ -0,0 +1,1382 @@ +# 健身房管理系统 API 接口文档 + +> 文档编号: GYM-API-001 +> 版本: v1.0 +> 日期: 2026-02-28 +> 作者: 张翔 +> 状态: 初稿 + +--- + +## 文档修订历史 + +| 版本 | 日期 | 作者 | 修订内容 | +|------|------|------|---------| +| v1.0 | 2026-02-28 | 张翔 | 初稿 | + +--- + +## 一、接口规范 + +### 1.1 基础信息 + +| 项目 | 说明 | +|------|------| +| 协议 | HTTPS | +| 请求方式 | GET / POST / PUT / DELETE | +| 数据格式 | JSON | +| 字符编码 | UTF-8 | +| 时间格式 | yyyy-MM-dd HH:mm:ss | +| 时区 | Asia/Shanghai | + +### 1.2 请求头 + +``` +Content-Type: application/json +Authorization: Bearer {token} +X-Tenant-Id: {tenantId} +X-Store-Id: {storeId} +X-Request-Id: {uuid} +``` + +### 1.3 响应格式 + +```json +{ + "code": 0, + "message": "success", + "data": {}, + "timestamp": 1709123456789 +} +``` + +### 1.4 错误码定义 + +| 错误码 | 说明 | +|-------|------| +| 0 | 成功 | +| 400 | 请求参数错误 | +| 401 | 未授权 | +| 403 | 无权限 | +| 404 | 资源不存在 | +| 409 | 资源冲突 | +| 429 | 请求过于频繁 | +| 500 | 服务器内部错误 | + +--- + +## 二、会员模块 API + +### 2.1 会员信息 + +#### 2.1.1 获取会员信息 + +``` +GET /api/v1/members/{memberId} +``` + +**请求参数** + +| 参数名 | 类型 | 必填 | 说明 | +|-------|------|------|------| +| memberId | Long | 是 | 会员ID | + +**响应示例** + +```json +{ + "code": 0, + "message": "success", + "data": { + "memberId": 1001, + "memberNo": "M20260001", + "name": "张三", + "phone": "138****8888", + "avatar": "https://xxx.com/avatar.jpg", + "gender": 1, + "birthday": "1990-01-15", + "level": 2, + "levelName": "VIP会员", + "exp": 1500, + "status": 1, + "createdAt": "2026-01-01 10:00:00" + } +} +``` + +#### 2.1.2 获取会员卡列表 + +``` +GET /api/v1/members/{memberId}/cards +``` + +**响应示例** + +```json +{ + "code": 0, + "message": "success", + "data": [ + { + "cardId": 1, + "cardName": "年卡", + "cardType": 1, + "status": 1, + "validFrom": "2026-01-01", + "validTo": "2026-12-31", + "remainDays": 306, + "benefits": [ + { + "benefitId": 1, + "benefitType": 1, + "benefitName": "时长权益", + "remainValue": "306天" + } + ] + } + ] +} +``` + +#### 2.1.3 获取会员权益 + +``` +GET /api/v1/members/{memberId}/benefits +``` + +**响应示例** + +```json +{ + "code": 0, + "message": "success", + "data": { + "timeBenefits": [ + { + "benefitId": 1, + "name": "年卡", + "validFrom": "2026-01-01", + "validTo": "2026-12-31", + "remainDays": 306 + } + ], + "countBenefits": [ + { + "benefitId": 2, + "name": "私教课时包", + "totalCount": 20, + "usedCount": 5, + "remainCount": 15 + } + ], + "balanceBenefits": [ + { + "benefitId": 3, + "name": "储值账户", + "balance": 1000.00 + } + ] + } +} +``` + +### 2.2 会员二维码 + +#### 2.2.1 获取签到二维码 + +``` +GET /api/v1/members/{memberId}/qrcode +``` + +**响应示例** + +```json +{ + "code": 0, + "message": "success", + "data": { + "qrcode": "MEMBER_1001_1709123456", + "expireAt": "2026-02-28 10:35:00", + "refreshInterval": 60 + } +} +``` + +--- + +## 三、预约模块 API + +### 3.1 课程查询 + +#### 3.1.1 获取课程列表 + +``` +GET /api/v1/courses +``` + +**请求参数** + +| 参数名 | 类型 | 必填 | 说明 | +|-------|------|------|------| +| tenantId | Long | 是 | 租户ID | +| storeId | Long | 否 | 门店ID | +| type | Integer | 否 | 课程类型:1-团课 2-私教 3-线上 | +| category | String | 否 | 课程分类 | +| status | Integer | 否 | 状态:1-上架 | +| page | Integer | 否 | 页码,默认1 | +| size | Integer | 否 | 每页数量,默认20 | + +**响应示例** + +```json +{ + "code": 0, + "message": "success", + "data": { + "total": 50, + "list": [ + { + "courseId": 1, + "name": "瑜伽基础课", + "type": 1, + "category": "瑜伽", + "duration": 60, + "capacity": 20, + "difficulty": 1, + "calories": 200, + "coverImage": "https://xxx.com/course.jpg", + "price": 1, + "priceValue": 1, + "status": 1 + } + ] + } +} +``` + +#### 3.1.2 获取课程详情 + +``` +GET /api/v1/courses/{courseId} +``` + +**响应示例** + +```json +{ + "code": 0, + "message": "success", + "data": { + "courseId": 1, + "name": "瑜伽基础课", + "type": 1, + "category": "瑜伽", + "description": "适合初学者的瑜伽课程", + "duration": 60, + "capacity": 20, + "minCapacity": 5, + "difficulty": 1, + "calories": 200, + "equipment": "瑜伽垫", + "coverImage": "https://xxx.com/course.jpg", + "price": 1, + "priceType": 1, + "priceValue": 1, + "advanceDays": 7, + "cancelHours": 2, + "status": 1 + } +} +``` + +### 3.2 预约时段 + +#### 3.2.1 获取可预约时段 + +``` +GET /api/v1/slots +``` + +**请求参数** + +| 参数名 | 类型 | 必填 | 说明 | +|-------|------|------|------| +| tenantId | Long | 是 | 租户ID | +| storeId | Long | 是 | 门店ID | +| resourceType | Integer | 是 | 资源类型:1-团课 2-私教 3-场地 | +| resourceId | Long | 否 | 资源ID | +| date | Date | 是 | 日期 | +| page | Integer | 否 | 页码 | +| size | Integer | 否 | 每页数量 | + +**响应示例** + +```json +{ + "code": 0, + "message": "success", + "data": { + "total": 10, + "list": [ + { + "slotId": 1, + "resourceType": 1, + "resourceId": 1, + "resourceName": "瑜伽基础课", + "coachId": 100, + "coachName": "王教练", + "venueId": 1, + "venueName": "瑜伽室A", + "startTime": "2026-02-28 10:00:00", + "endTime": "2026-02-28 11:00:00", + "capacity": 20, + "bookedCount": 15, + "remainCount": 5, + "status": 1, + "price": 1, + "priceType": 1, + "priceValue": 1, + "bookingStart": "2026-02-21 10:00:00", + "bookingEnd": "2026-02-28 08:00:00", + "cancelDeadline": "2026-02-28 08:00:00" + } + ] + } +} +``` + +#### 3.2.2 获取时段详情 + +``` +GET /api/v1/slots/{slotId} +``` + +**响应示例** + +```json +{ + "code": 0, + "message": "success", + "data": { + "slotId": 1, + "resourceType": 1, + "resourceId": 1, + "resourceName": "瑜伽基础课", + "resourceImage": "https://xxx.com/course.jpg", + "coachId": 100, + "coachName": "王教练", + "coachAvatar": "https://xxx.com/coach.jpg", + "venueId": 1, + "venueName": "瑜伽室A", + "startTime": "2026-02-28 10:00:00", + "endTime": "2026-02-28 11:00:00", + "capacity": 20, + "bookedCount": 15, + "remainCount": 5, + "waitlistCount": 3, + "minCapacity": 5, + "status": 1, + "price": 1, + "priceType": 1, + "priceValue": 1, + "canBook": true, + "canCancel": true, + "bookingStart": "2026-02-21 10:00:00", + "bookingEnd": "2026-02-28 08:00:00", + "cancelDeadline": "2026-02-28 08:00:00" + } +} +``` + +### 3.3 预约操作 + +#### 3.3.1 创建预约 + +``` +POST /api/v1/bookings +``` + +**请求参数** + +```json +{ + "tenantId": 1, + "storeId": 1, + "memberId": 1001, + "slotId": 1, + "source": "app" +} +``` + +**响应示例** + +```json +{ + "code": 0, + "message": "预约成功", + "data": { + "bookingId": 2001, + "bookingNo": "B20260228001", + "slotId": 1, + "resourceName": "瑜伽基础课", + "coachName": "王教练", + "venueName": "瑜伽室A", + "startTime": "2026-02-28 10:00:00", + "endTime": "2026-02-28 11:00:00", + "status": 1, + "priceType": 1, + "priceValue": 1, + "benefitDeducted": { + "benefitId": 1, + "benefitType": 1, + "benefitName": "年卡", + "deductedValue": "1次" + }, + "cancelDeadline": "2026-02-28 08:00:00" + } +} +``` + +#### 3.3.2 取消预约 + +``` +POST /api/v1/bookings/{bookingId}/cancel +``` + +**请求参数** + +```json +{ + "reason": "临时有事" +} +``` + +**响应示例** + +```json +{ + "code": 0, + "message": "取消成功", + "data": { + "bookingId": 2001, + "status": 2, + "refundAmount": 0, + "refundBenefit": { + "benefitId": 1, + "refundValue": "1次" + } + } +} +``` + +#### 3.3.3 获取预约列表 + +``` +GET /api/v1/bookings +``` + +**请求参数** + +| 参数名 | 类型 | 必填 | 说明 | +|-------|------|------|------| +| memberId | Long | 是 | 会员ID | +| status | Integer | 否 | 状态:1-已预约 2-已取消 3-已完成 | +| startDate | Date | 否 | 开始日期 | +| endDate | Date | 否 | 结束日期 | +| page | Integer | 否 | 页码 | +| size | Integer | 否 | 每页数量 | + +**响应示例** + +```json +{ + "code": 0, + "message": "success", + "data": { + "total": 25, + "list": [ + { + "bookingId": 2001, + "bookingNo": "B20260228001", + "resourceType": 1, + "resourceName": "瑜伽基础课", + "coachName": "王教练", + "venueName": "瑜伽室A", + "startTime": "2026-02-28 10:00:00", + "endTime": "2026-02-28 11:00:00", + "status": 1, + "checkinStatus": 0, + "canCancel": true, + "cancelDeadline": "2026-02-28 08:00:00" + } + ] + } +} +``` + +#### 3.3.4 获取预约详情 + +``` +GET /api/v1/bookings/{bookingId} +``` + +**响应示例** + +```json +{ + "code": 0, + "message": "success", + "data": { + "bookingId": 2001, + "bookingNo": "B20260228001", + "memberId": 1001, + "memberName": "张三", + "memberPhone": "138****8888", + "slotId": 1, + "resourceType": 1, + "resourceId": 1, + "resourceName": "瑜伽基础课", + "resourceImage": "https://xxx.com/course.jpg", + "coachId": 100, + "coachName": "王教练", + "coachAvatar": "https://xxx.com/coach.jpg", + "venueId": 1, + "venueName": "瑜伽室A", + "startTime": "2026-02-28 10:00:00", + "endTime": "2026-02-28 11:00:00", + "status": 1, + "checkinStatus": 0, + "checkinAt": null, + "priceType": 1, + "priceValue": 1, + "benefitId": 1, + "source": "app", + "createdAt": "2026-02-25 10:00:00", + "cancelDeadline": "2026-02-28 08:00:00", + "canCancel": true, + "canCheckin": true + } +} +``` + +### 3.4 候补队列 + +#### 3.4.1 加入候补 + +``` +POST /api/v1/waitlist +``` + +**请求参数** + +```json +{ + "tenantId": 1, + "memberId": 1001, + "slotId": 1 +} +``` + +**响应示例** + +```json +{ + "code": 0, + "message": "加入候补成功", + "data": { + "waitlistId": 1, + "slotId": 1, + "queueNo": 4, + "estimatedWaitTime": "约30分钟" + } +} +``` + +#### 3.4.2 取消候补 + +``` +DELETE /api/v1/waitlist/{waitlistId} +``` + +**响应示例** + +```json +{ + "code": 0, + "message": "取消候补成功" +} +``` + +--- + +## 四、签到模块 API + +### 4.1 签到操作 + +#### 4.1.1 二维码签到 + +``` +POST /api/v1/checkin/qrcode +``` + +**请求参数** + +```json +{ + "tenantId": 1, + "storeId": 1, + "qrcode": "MEMBER_1001_1709123456", + "deviceId": 1, + "type": 1, + "bookingId": null +} +``` + +**响应示例** + +```json +{ + "code": 0, + "message": "签到成功", + "data": { + "checkinId": 1001, + "memberId": 1001, + "memberName": "张三", + "memberPhone": "138****8888", + "memberLevel": "VIP会员", + "checkinType": "入场签到", + "checkinTime": "2026-02-28 10:30:00", + "benefitDeducted": { + "type": "时长权益", + "value": "年卡有效期至2026-12-31" + }, + "warnings": [] + } +} +``` + +#### 4.1.2 人脸识别签到 + +``` +POST /api/v1/checkin/face +``` + +**请求参数** + +```json +{ + "tenantId": 1, + "storeId": 1, + "faceFeature": "base64_encoded_feature", + "deviceId": 1, + "type": 1, + "bookingId": null +} +``` + +**响应示例** + +```json +{ + "code": 0, + "message": "签到成功", + "data": { + "checkinId": 1002, + "memberId": 1001, + "memberName": "张三", + "memberPhone": "138****8888", + "memberLevel": "VIP会员", + "checkinType": "入场签到", + "checkinTime": "2026-02-28 10:31:00", + "benefitDeducted": { + "type": "时长权益", + "value": "年卡有效期至2026-12-31" + }, + "warnings": [] + } +} +``` + +#### 4.1.3 NFC签到 + +``` +POST /api/v1/checkin/nfc +``` + +**请求参数** + +```json +{ + "tenantId": 1, + "storeId": 1, + "nfcId": "NFC_CARD_1001", + "deviceId": 1, + "type": 1, + "bookingId": null +} +``` + +**响应示例** + +```json +{ + "code": 0, + "message": "签到成功", + "data": { + "checkinId": 1003, + "memberId": 1001, + "memberName": "张三", + "memberPhone": "138****8888", + "memberLevel": "VIP会员", + "checkinType": "入场签到", + "checkinTime": "2026-02-28 10:32:00", + "benefitDeducted": { + "type": "时长权益", + "value": "年卡有效期至2026-12-31" + }, + "warnings": [] + } +} +``` + +#### 4.1.4 教练代签 + +``` +POST /api/v1/checkin/manual +``` + +**请求参数** + +```json +{ + "tenantId": 1, + "storeId": 1, + "memberId": 1001, + "bookingId": 2001, + "operatorId": 100, + "operatorName": "李教练", + "remark": "会员已到场" +} +``` + +**响应示例** + +```json +{ + "code": 0, + "message": "代签成功", + "data": { + "checkinId": 1004, + "memberId": 1001, + "memberName": "张三", + "checkinType": "私教签到", + "checkinTime": "2026-02-28 10:33:00", + "operatorName": "李教练" + } +} +``` + +### 4.2 人脸管理 + +#### 4.2.1 注册人脸 + +``` +POST /api/v1/face/register +Content-Type: multipart/form-data +``` + +**请求参数** + +| 参数名 | 类型 | 必填 | 说明 | +|-------|------|------|------| +| memberId | Long | 是 | 会员ID | +| faceImage | File | 是 | 人脸照片 | + +**响应示例** + +```json +{ + "code": 0, + "message": "人脸注册成功", + "data": { + "faceId": 1, + "qualityScore": 95.5, + "status": "正常" + } +} +``` + +#### 4.2.2 更新人脸 + +``` +PUT /api/v1/face/{memberId} +Content-Type: multipart/form-data +``` + +**请求参数** + +| 参数名 | 类型 | 必填 | 说明 | +|-------|------|------|------| +| faceImage | File | 是 | 人脸照片 | + +**响应示例** + +```json +{ + "code": 0, + "message": "人脸更新成功", + "data": { + "faceId": 1, + "qualityScore": 96.2, + "status": "正常" + } +} +``` + +#### 4.2.3 删除人脸 + +``` +DELETE /api/v1/face/{memberId} +``` + +**响应示例** + +```json +{ + "code": 0, + "message": "人脸删除成功" +} +``` + +### 4.3 签到记录 + +#### 4.3.1 查询签到记录 + +``` +GET /api/v1/checkin/records +``` + +**请求参数** + +| 参数名 | 类型 | 必填 | 说明 | +|-------|------|------|------| +| memberId | Long | 是 | 会员ID | +| startDate | Date | 否 | 开始日期 | +| endDate | Date | 否 | 结束日期 | +| type | Integer | 否 | 签到类型 | +| page | Integer | 否 | 页码 | +| size | Integer | 否 | 每页数量 | + +**响应示例** + +```json +{ + "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": "成功" + } + ] + } +} +``` + +#### 4.3.2 查询签到统计 + +``` +GET /api/v1/checkin/statistics +``` + +**请求参数** + +| 参数名 | 类型 | 必填 | 说明 | +|-------|------|------|------| +| tenantId | Long | 是 | 租户ID | +| storeId | Long | 否 | 门店ID | +| startDate | Date | 是 | 开始日期 | +| endDate | Date | 是 | 结束日期 | + +**响应示例** + +```json +{ + "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} + ] + } +} +``` + +### 4.4 设备管理 + +#### 4.4.1 设备心跳 + +``` +POST /api/v1/device/heartbeat +``` + +**请求参数** + +```json +{ + "deviceId": 1, + "deviceCode": "DEVICE_001", + "status": 1, + "timestamp": "2026-02-28T10:30:00" +} +``` + +**响应示例** + +```json +{ + "code": 0, + "message": "success" +} +``` + +#### 4.4.2 设备列表 + +``` +GET /api/v1/device/list +``` + +**请求参数** + +| 参数名 | 类型 | 必填 | 说明 | +|-------|------|------|------| +| tenantId | Long | 是 | 租户ID | +| storeId | Long | 否 | 门店ID | + +**响应示例** + +```json +{ + "code": 0, + "message": "success", + "data": [ + { + "deviceId": 1, + "name": "前台人脸机", + "code": "DEVICE_001", + "type": "人脸识别机", + "location": "前台入口", + "status": "在线", + "lastHeartbeat": "2026-02-28 10:30:00" + } + ] +} +``` + +--- + +## 五、场地模块 API + +### 5.1 场地查询 + +#### 5.1.1 获取场地列表 + +``` +GET /api/v1/venues +``` + +**请求参数** + +| 参数名 | 类型 | 必填 | 说明 | +|-------|------|------|------| +| tenantId | Long | 是 | 租户ID | +| storeId | Long | 是 | 门店ID | +| type | Integer | 否 | 场地类型 | +| status | Integer | 否 | 状态 | + +**响应示例** + +```json +{ + "code": 0, + "message": "success", + "data": { + "total": 10, + "list": [ + { + "venueId": 1, + "name": "瑜伽室A", + "type": 1, + "typeName": "瑜伽室", + "area": 100.5, + "capacity": 25, + "openTime": "07:00", + "closeTime": "22:00", + "pricePerHour": 100.00, + "status": 1, + "images": [ + "https://xxx.com/venue1.jpg", + "https://xxx.com/venue2.jpg" + ] + } + ] + } +} +``` + +#### 5.1.2 获取场地可用时段 + +``` +GET /api/v1/venues/{venueId}/slots +``` + +**请求参数** + +| 参数名 | 类型 | 必填 | 说明 | +|-------|------|------|------| +| date | Date | 是 | 日期 | + +**响应示例** + +```json +{ + "code": 0, + "message": "success", + "data": [ + { + "slotId": 1, + "startTime": "2026-02-28 10:00:00", + "endTime": "2026-02-28 11:00:00", + "status": 1, + "price": 100.00 + } + ] +} +``` + +--- + +## 六、教练模块 API + +### 6.1 教练查询 + +#### 6.1.1 获取教练列表 + +``` +GET /api/v1/coaches +``` + +**请求参数** + +| 参数名 | 类型 | 必填 | 说明 | +|-------|------|------|------| +| tenantId | Long | 是 | 租户ID | +| storeId | Long | 否 | 门店ID | +| specialty | String | 否 | 专长 | +| status | Integer | 否 | 状态 | + +**响应示例** + +```json +{ + "code": 0, + "message": "success", + "data": { + "total": 20, + "list": [ + { + "coachId": 100, + "name": "王教练", + "avatar": "https://xxx.com/coach.jpg", + "gender": 1, + "specialty": "瑜伽,普拉提", + "experience": 5, + "rating": 4.8, + "courseCount": 120, + "status": 1 + } + ] + } +} +``` + +#### 6.1.2 获取教练可预约时段 + +``` +GET /api/v1/coaches/{coachId}/slots +``` + +**请求参数** + +| 参数名 | 类型 | 必填 | 说明 | +|-------|------|------|------| +| date | Date | 是 | 日期 | + +**响应示例** + +```json +{ + "code": 0, + "message": "success", + "data": [ + { + "slotId": 1, + "startTime": "2026-02-28 10:00:00", + "endTime": "2026-02-28 11:00:00", + "status": 1, + "price": 300.00 + } + ] +} +``` + +--- + +## 七、数据统计 API + +### 7.1 运营数据 + +#### 7.1.1 获取运营概览 + +``` +GET /api/v1/statistics/overview +``` + +**请求参数** + +| 参数名 | 类型 | 必填 | 说明 | +|-------|------|------|------| +| tenantId | Long | 是 | 租户ID | +| storeId | Long | 否 | 门店ID | +| date | Date | 是 | 日期 | + +**响应示例** + +```json +{ + "code": 0, + "message": "success", + "data": { + "date": "2026-02-28", + "memberStats": { + "totalMembers": 1500, + "newMembers": 25, + "activeMembers": 350, + "activeRate": 23.3 + }, + "checkinStats": { + "totalCount": 500, + "entryCount": 300, + "courseCount": 150, + "privateCount": 50 + }, + "bookingStats": { + "totalCount": 200, + "successCount": 180, + "cancelCount": 20, + "successRate": 90.0 + }, + "revenueStats": { + "totalRevenue": 50000.00, + "courseRevenue": 30000.00, + "privateRevenue": 15000.00, + "otherRevenue": 5000.00 + } + } +} +``` + +#### 7.1.2 获取趋势数据 + +``` +GET /api/v1/statistics/trend +``` + +**请求参数** + +| 参数名 | 类型 | 必填 | 说明 | +|-------|------|------|------| +| tenantId | Long | 是 | 租户ID | +| storeId | Long | 否 | 门店ID | +| startDate | Date | 是 | 开始日期 | +| endDate | Date | 是 | 结束日期 | +| metric | String | 是 | 指标:checkin/booking/revenue | + +**响应示例** + +```json +{ + "code": 0, + "message": "success", + "data": { + "metric": "checkin", + "trend": [ + {"date": "2026-02-01", "value": 450}, + {"date": "2026-02-02", "value": 480}, + {"date": "2026-02-03", "value": 520} + ], + "total": 1450, + "average": 483.3, + "max": 520, + "min": 450 + } +} +``` + +--- + +## 八、消息通知 API + +### 8.1 通知管理 + +#### 8.1.1 获取通知列表 + +``` +GET /api/v1/notifications +``` + +**请求参数** + +| 参数名 | 类型 | 必填 | 说明 | +|-------|------|------|------| +| memberId | Long | 是 | 会员ID | +| type | Integer | 否 | 通知类型 | +| isRead | Boolean | 否 | 是否已读 | +| page | Integer | 否 | 页码 | +| size | Integer | 否 | 每页数量 | + +**响应示例** + +```json +{ + "code": 0, + "message": "success", + "data": { + "total": 50, + "unreadCount": 10, + "list": [ + { + "notificationId": 1, + "type": 1, + "typeName": "预约提醒", + "title": "课程预约成功", + "content": "您已成功预约瑜伽基础课,时间为2026-02-28 10:00-11:00", + "isRead": false, + "createdAt": "2026-02-25 10:00:00" + } + ] + } +} +``` + +#### 8.1.2 标记通知已读 + +``` +PUT /api/v1/notifications/{notificationId}/read +``` + +**响应示例** + +```json +{ + "code": 0, + "message": "success" +} +``` + +#### 8.1.3 全部标记已读 + +``` +PUT /api/v1/notifications/read-all +``` + +**请求参数** + +```json +{ + "memberId": 1001 +} +``` + +**响应示例** + +```json +{ + "code": 0, + "message": "success" +} +``` + +--- + +## 九、错误响应示例 + +### 9.1 参数错误 + +```json +{ + "code": 400, + "message": "参数错误: memberId不能为空", + "data": null, + "timestamp": 1709123456789 +} +``` + +### 9.2 未授权 + +```json +{ + "code": 401, + "message": "未授权,请先登录", + "data": null, + "timestamp": 1709123456789 +} +``` + +### 9.3 业务异常 + +```json +{ + "code": 400, + "message": "预约失败:课程已满", + "data": { + "errorCode": "SLOT_FULL", + "errorDetail": "当前时段已预约满,请选择其他时段" + }, + "timestamp": 1709123456789 +} +``` + +--- + +## 十、版本历史 + +| 版本 | 日期 | 作者 | 变更内容 | +|------|------|------|---------| +| v1.0 | 2026-02-28 | 张翔 | 初稿 | diff --git a/docs/design/HLD-系统概要设计.md b/docs/design/HLD-系统概要设计.md new file mode 100644 index 0000000..d30b0be --- /dev/null +++ b/docs/design/HLD-系统概要设计.md @@ -0,0 +1,925 @@ +# 健身房管理系统概要设计文档(HLD) + +> 文档编号: GYM-HLD-001 +> 版本: v1.0 +> 日期: 2026-02-28 +> 作者: 张翔 +> 状态: 初稿 + +--- + +## 文档修订历史 + +| 版本 | 日期 | 作者 | 修订内容 | +| ---- | ---------- | ---- | -------- | +| v1.0 | 2026-02-28 | 张翔 | 初稿 | + +--- + +## 一、引言 + +### 1.1 编写目的 + +本文档为健身房管理系统的概要设计文档(High-Level Design),旨在: + +1. 从总体上描述系统的技术架构、模块划分、接口设计 +2. 为详细设计提供指导和约束 +3. 作为开发人员、测试人员、运维人员的技术参考 + +### 1.2 项目背景 + +健身房管理系统是一款面向综合型健身俱乐部、精品工作室、连锁品牌的全场景管理平台,核心解决: + +- 会员端:一站式查看个人所有信息,便捷预约签到 +- 管理后台:全维度数据整理与分析,支撑运营决策 +- 多业态支持:灵活适配不同规模和类型的健身场所 + +### 1.3 术语定义 + +| 术语 | 定义 | +| ----------------------------- | ------------------------------------------------ | +| 租户(Tenant) | 系统的多租户架构中的独立业务实体,如一个连锁品牌 | +| 门店(Store) | 租户下的具体经营场所 | +| 会员(Member) | 在门店注册的用户 | +| 权益(Benefit) | 会员卡包含的时长、次数、储值、等级等权益 | +| 可预约资源(Bookable Resource) | 团课、私教、场地、线上课程等可被预约的对象 | +| 时段(Slot) | 资源的可预约时间窗口 | + +### 1.4 参考文档 + +- 《健身房管理系统产品设计文档》 GYM-PRD-001 +- Spring Boot 3 官方文档 +- R2DBC 规范文档 +- PostgreSQL 官方文档 + +--- + +## 二、系统概述 + +### 2.1 系统目标 + +| 目标 | 描述 | 度量标准 | +| ------ | -------------------------- | ---------------- | +| 高可用 | 系统稳定运行,故障快速恢复 | SLA ≥ 99.9% | +| 高并发 | 支撑热门课程抢课场景 | QPS ≥ 1000 | +| 可扩展 | 支持功能模块渐进式扩展 | 模块化设计 | +| 易维护 | 代码规范,文档完善 | 代码覆盖率 ≥ 80% | + +### 2.2 用户角色 + +| 角色 | 描述 | 主要功能 | +| ---------- | -------------- | ---------------------------- | +| 会员 | 健身房注册用户 | 预约课程、签到、查看个人信息 | +| 教练 | 健身房教练 | 排课、私教预约确认、学员签到 | +| 前台 | 门店前台人员 | 会员接待、签到辅助、会员管理 | +| 店长 | 门店管理者 | 单店全功能管理、数据查看 | +| 运营管理员 | 平台运营人员 | 营销活动配置、数据分析 | +| 财务专员 | 财务人员 | 账单管理、财务报表 | +| 超级管理员 | 平台最高权限 | 全平台管理、系统配置 | + +### 2.3 业务范围 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 业务范围 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ 会员管理 │ │ 预约管理 │ │ 签到管理 │ │ +│ │ 会员注册 │ │ 团课预约 │ │ 扫码签到 │ │ +│ │ 会员卡管理 │ │ 私教预约 │ │ 刷脸签到 │ │ +│ │ 权益管理 │ │ 场地预约 │ │ NFC签到 │ │ +│ │ 等级管理 │ │ 线上课程 │ │ 教练代签 │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ 课程管理 │ │ 教练管理 │ │ 财务管理 │ │ +│ │ 课程类型 │ │ 教练信息 │ │ 营收统计 │ │ +│ │ 课程排期 │ │ 排班管理 │ │ 账单管理 │ │ +│ │ 场地管理 │ │ 课时统计 │ │ 退款管理 │ │ +│ │ 价格配置 │ │ 评价管理 │ │ 对账管理 │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ 计划中心 │ │ 数据分析 │ │ 系统管理 │ │ +│ │ 训练计划 │ │ 会员分析 │ │ 租户管理 │ │ +│ │ 课程排期 │ │ 课程分析 │ │ 门店管理 │ │ +│ │ 会员目标 │ │ 财务分析 │ │ 权限管理 │ │ +│ │ 教练排班 │ │ 运营分析 │ │ 系统配置 │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 三、系统架构设计 + +### 3.1 总体架构 + +采用分层架构 + 微服务思想的模块化设计: + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 客户端层 │ +├──────────────────┬──────────────────┬──────────────────┬───────────────┤ +│ 会员小程序 │ 教练端App │ 管理后台PC │ 硬件设备 │ +│ (uniapp+Vue3) │ (uniapp+Vue3) │ (Vue3+Vite) │ (人脸/NFC) │ +└────────┬─────────┴────────┬─────────┴────────┬─────────┴───────┬───────┘ + │ │ │ │ + └──────────────────┴────────┬─────────┴─────────────────┘ + │ + ┌────────▼────────┐ + │ API Gateway │ + │ (统一网关) │ + │ - 路由转发 │ + │ - 认证鉴权 │ + │ - 限流熔断 │ + │ - 日志追踪 │ + └────────┬────────┘ + │ + ┌───────────────────────────┼───────────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ 会员服务 │ │ 预约服务 │ │ 数据服务 │ +│ Member Service │ │ Booking Service │ │ Data Service │ +├─────────────────┤ ├─────────────────┤ ├─────────────────┤ +│ - 会员管理 │ │ - 课程管理 │ │ - 数据统计 │ +│ - 会员卡管理 │ │ - 预约管理 │ │ - 报表生成 │ +│ - 权益管理 │ │ - 签到管理 │ │ - 数据导出 │ +│ - 等级管理 │ │ - 库存管理 │ │ - 数据分析 │ +└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ + │ │ │ + └──────────────────────────┼──────────────────────────┘ + │ + ┌───────────────────────────┼───────────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ 公共服务层 │ │ 基础设施层 │ │ 外部服务 │ +├─────────────────┤ ├─────────────────┤ ├─────────────────┤ +│ - 认证服务 │ │ - PostgreSQL │ │ - 微信开放平台 │ +│ - 消息服务 │ │ - R2DBC │ │ - 短信服务 │ +│ - 文件服务 │ │ - Caffeine │ │ - 支付服务 │ +│ - 缓存服务 │ │ - Redis(可选) │ │ - OSS存储 │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +### 3.2 技术架构 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 技术架构分层 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ 表现层 (Presentation) │ │ +│ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │ +│ │ │ 会员端 uniapp │ │ 教练端 uniapp │ │ 管理后台 Vue3 │ │ │ +│ │ │ - Vue3 + TS │ │ - Vue3 + TS │ │ - Vue3 + TS │ │ │ +│ │ │ - Pinia │ │ - Pinia │ │ - Pinia │ │ │ +│ │ │ - uni-ui │ │ - uni-ui │ │ - Element Plus│ │ │ +│ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ 网关层 (Gateway) │ │ +│ │ ┌─────────────────────────────────────────────────────────────┐ │ │ +│ │ │ Spring Cloud Gateway │ │ │ +│ │ │ - 路由转发 - 认证鉴权 - 限流熔断 - 日志追踪 - 灰度发布 │ │ │ +│ │ └─────────────────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ 业务层 (Business) │ │ +│ │ ┌─────────────────────────────────────────────────────────────┐ │ │ +│ │ │ Spring Boot 3 + WebFlux + JDK 21 │ │ │ +│ │ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │ │ +│ │ │ │ Controller│ │ Service │ │ Repository│ │ Model │ │ │ │ +│ │ │ │ (API) │ │ (业务逻辑) │ │ (数据访问) │ │ (领域模型) │ │ │ │ +│ │ │ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │ │ │ +│ │ └─────────────────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ 数据层 (Data) │ │ +│ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │ +│ │ │ PostgreSQL │ │ Caffeine │ │ Redis(可选) │ │ │ +│ │ │ - R2DBC │ │ - 本地缓存 │ │ - 分布式缓存 │ │ │ +│ │ │ - Flyway │ │ - 热点数据 │ │ - 分布式锁 │ │ │ +│ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 3.3 部署架构 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 部署架构 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────┐ │ +│ │ 负载均衡器 │ │ +│ │ (Nginx/ALB) │ │ +│ └────────┬────────┘ │ +│ │ │ +│ ┌───────────────────┼───────────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ +│ │ API Gateway │ │ API Gateway │ │ API Gateway │ │ +│ │ (实例1) │ │ (实例2) │ │ (实例N) │ │ +│ └───────┬────────┘ └───────┬────────┘ └───────┬────────┘ │ +│ │ │ │ │ +│ └───────────────────┼───────────────────┘ │ +│ │ │ +│ ┌──────────────────┼──────────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ +│ │ Application │ │ Application │ │ Application │ │ +│ │ Server (Pod1) │ │ Server (Pod2) │ │ Server (PodN) │ │ +│ └───────┬────────┘ └───────┬────────┘ └ +``` +───────┬────────┘ │ +│ │ │ │ │ +│ └───────────────────┼───────────────────┘ │ +│ │ │ +│ ┌───────────────────────────┼───────────────────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ PostgreSQL │ │ Redis │ │ OSS │ │ +│ │ (主从复制) │ │ (哨兵模式) │ │ (对象存储) │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 四、模块设计 + +### 4.1 模块划分 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 模块划分 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ gym-manage-server (父工程) │ +│ │ │ +│ ├── gym-common (公共模块) │ +│ │ ├── gym-common-core # 核心工具类、常量、枚举 │ +│ │ ├── gym-common-redis # Redis配置(可选) │ +│ │ ├── gym-common-security # 安全认证公共组件 │ +│ │ └── gym-common-log # 日志公共组件 │ +│ │ │ +│ ├── gym-api (API网关模块) │ +│ │ ├── controller # HTTP接口 │ +│ │ ├── dto # 数据传输对象 │ +│ │ ├── vo # 视图对象 │ +│ │ └── config # API配置 │ +│ │ │ +│ ├── gym-service (业务服务模块) │ +│ │ ├── gym-service-member # 会员服务 │ +│ │ ├── gym-service-booking # 预约服务 │ +│ │ ├── gym-service-checkin # 签到服务 │ +│ │ ├── gym-service-course # 课程服务 │ +│ │ ├── gym-service-coach # 教练服务 │ +│ │ ├── gym-service-finance # 财务服务 │ +│ │ └── gym-service-data # 数据服务 │ +│ │ │ +│ ├── gym-domain (领域模型模块) │ +│ │ ├── model # 领域模型 │ +│ │ ├── event # 领域事件 │ +│ │ └── service # 领域服务 │ +│ │ │ +│ ├── gym-infrastructure (基础设施模块) │ +│ │ ├── repository # 数据仓储 │ +│ │ ├── cache # 缓存配置 │ +│ │ ├── external # 外部服务集成 │ +│ │ └── config # 基础配置 │ +│ │ │ +│ └── gym-starter (启动模块) │ +│ └── gym-admin # 管理后台启动器 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.2 模块职责 + +| 模块 | 职责 | 依赖 | +|------|------|------| +| gym-common-core | 提供通用工具类、常量定义、异常处理 | 无 | +| gym-common-security | 提供JWT认证、权限校验 | gym-common-core | +| gym-common-log | 提供统一日志处理 | gym-common-core | +| gym-domain | 定义领域模型、领域事件、领域服务 | gym-common-core | +| gym-infrastructure | 提供数据访问、缓存、外部服务集成 | gym-domain | +| gym-service-member | 会员、会员卡、权益管理 | gym-domain, gym-infrastructure | +| gym-service-booking | 课程预约、库存管理 | gym-domain, gym-infrastructure | +| gym-service-checkin | 签到处理、权益扣减 | gym-domain, gym-infrastructure | +| gym-api | HTTP接口定义、参数校验 | gym-service-* | +| gym-starter | 应用启动、配置加载 | gym-api | + +### 4.3 模块交互 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 模块交互流程 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ HTTP Request │ +│ │ │ +│ ▼ │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ API │───▶│ Service │───▶│ Domain │───▶│ Repo │ │ +│ │ Layer │ │ Layer │ │ Layer │ │ Layer │ │ +│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ +│ │ │ │ │ │ +│ │ │ │ │ │ +│ │ ▼ ▼ ▼ │ +│ │ ┌─────────────────────────────────────┐ │ +│ │ │ Infrastructure │ │ +│ │ │ ┌─────────┐ ┌─────────┐ ┌─────┐ │ │ +│ │ │ │ Cache │ │ MQ │ │ DB │ │ │ +│ │ │ └─────────┘ └─────────┘ └─────┘ │ │ +│ │ └─────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ HTTP Response │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 五、接口设计 + +### 5.1 接口规范 + +#### 5.1.1 RESTful API 规范 + +``` +基础URL: https://api.gym-manage.com/v1 + +资源命名规范: +- 使用名词复数形式: /members, /bookings, /courses +- 使用小写字母和连字符: /member-cards, /booking-slots +- 避免动词: /members (正确) vs /getMembers (错误) + +HTTP方法语义: +- GET: 查询资源 +- POST: 创建资源 +- PUT: 全量更新资源 +- PATCH: 部分更新资源 +- DELETE: 删除资源 +``` + +#### 5.1.2 请求响应格式 + +```json +// 统一响应格式 +{ + "code": 0, // 业务状态码,0表示成功 + "message": "success", // 响应消息 + "data": {}, // 响应数据 + "timestamp": 1709123456789 // 时间戳 +} + +// 分页响应格式 +{ + "code": 0, + "message": "success", + "data": { + "list": [], // 数据列表 + "total": 100, // 总记录数 + "page": 1, // 当前页码 + "pageSize": 20 // 每页大小 + }, + "timestamp": 1709123456789 +} + +// 错误响应格式 +{ + "code": 40001, // 错误码 + "message": "参数校验失败", // 错误消息 + "data": { + "errors": [ // 详细错误信息 + {"field": "phone", "message": "手机号格式不正确"} + ] + }, + "timestamp": 1709123456789 +} +``` + +#### 5.1.3 状态码定义 + +| 状态码 | 含义 | 说明 | +|--------|------|------| +| 0 | 成功 | 请求处理成功 | +| 40001 | 参数错误 | 请求参数校验失败 | +| 40002 | 资源不存在 | 请求的资源不存在 | +| 40003 | 资源已存在 | 创建的资源已存在 | +| 40101 | 未登录 | 用户未登录或Token过期 | +| 40102 | 无权限 | 用户无访问权限 | +| 40301 | 业务异常 | 业务逻辑校验失败 | +| 50001 | 系统异常 | 系统内部错误 | + +### 5.2 接口分组 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ API 接口分组 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ /v1/auth # 认证接口 │ +│ ├── POST /login # 登录 │ +│ ├── POST /logout # 登出 │ +│ ├── POST /refresh # 刷新Token │ +│ └── POST /wechat-login # 微信登录 │ +│ │ +│ /v1/members # 会员接口 │ +│ ├── GET / # 会员列表 │ +│ ├── GET /{id} # 会员详情 │ +│ ├── POST / # 创建会员 │ +│ ├── PUT /{id} # 更新会员 │ +│ ├── GET /{id}/cards # 会员卡列表 │ +│ ├── GET /{id}/benefits # 权益列表 │ +│ └── GET /{id}/bookings # 预约记录 │ +│ │ +│ /v1/courses # 课程接口 │ +│ ├── GET / # 课程列表 │ +│ ├── GET /{id} # 课程详情 │ +│ ├── POST / # 创建课程 │ +│ ├── PUT /{id} # 更新课程 │ +│ └── GET /{id}/slots # 可预约时段 │ +│ │ +│ /v1/bookings # 预约接口 │ +│ ├── GET / # 预约列表 │ +│ ├── GET /{id} # 预约详情 │ +│ ├── POST / # 创建预约 │ +│ ├── POST /{id}/cancel # 取消预约 │ +│ └── GET /my # 我的预约 │ +│ │ +│ /v1/checkins # 签到接口 │ +│ ├── GET / # 签到列表 │ +│ ├── POST /scan # 扫码签到 │ +│ ├── POST /manual # 手动签到(教练代签) │ +│ └── GET /my # 我的签到 │ +│ │ +│ /v1/coaches # 教练接口 │ +│ ├── GET / # 教练列表 │ +│ ├── GET /{id} # 教练详情 │ +│ ├── GET /{id}/schedule # 教练排班 │ +│ └── GET /{id}/slots # 可预约时段 │ +│ │ +│ /v1/dashboard # 数据看板 │ +│ ├── GET /overview # 今日概览 │ +│ ├── GET /trends # 趋势数据 │ +│ └── GET /rankings # 排行数据 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 5.3 接口版本管理 + +``` +版本策略: +1. URL路径版本: /v1/members, /v2/members +2. 向后兼容原则: 新版本接口保留旧版本功能 +3. 废弃通知: 旧版本接口返回 Warning 头,提前3个月通知 + +版本生命周期: +- 开发中: 内部测试,不对外发布 +- 当前版本: 正式发布,完全支持 +- 已废弃: 仍可使用,但建议迁移 +- 已下线: 不再可用 +``` + +--- + +## 六、安全设计 + +### 6.1 认证机制 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 认证机制 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ JWT Token 认证流程 │ │ +│ │ │ │ +│ │ 1. 用户登录 │ │ +│ │ POST /v1/auth/login │ │ +│ │ { phone, code } │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ 2. 服务端验证 │ │ +│ │ - 验证手机号和验证码 │ │ +│ │ - 查询用户信息 │ │ +│ │ - 生成 JWT Token │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ 3. 返回 Token │ │ +│ │ { accessToken, refreshToken, expiresIn } │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ 4. 后续请求携带 Token │ │ +│ │ Authorization: Bearer {accessToken} │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ 5. 服务端验证 Token │ │ +│ │ - 解析 Token │ │ +│ │ - 验证签名和有效期 │ │ +│ │ - 提取用户信息 │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Token 结构: │ +│ { │ +│ "sub": "member:12345", // 用户标识 │ +│ "tenant": "tenant:001", // 租户标识 │ +│ "store": "store:001", // 门店标识 │ +│ "roles": ["MEMBER"], // 角色列表 │ +│ "exp": 1709123456, // 过期时间 │ +│ "iat": 1709120000 // 签发时间 │ +│ } │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 6.2 权限控制 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ RBAC 权限模型 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────┐ M:N ┌─────────┐ M:N ┌─────────┐ │ +│ │ 用户 │─────────────▶│ 角色 │─────────────▶│ 权限 │ │ +│ │ User │ │ Role │ │Permission│ │ +│ └─────────┘ └─────────┘ └─────────┘ │ +│ │ +│ 角色定义: │ +│ ┌────────────────┬────────────────────────────────────────────────┐ │ +│ │ 角色 │ 权限范围 │ │ +│ ├────────────────┼────────────────────────────────────────────────┤ │ +│ │ ROLE_MEMBER │ 会员端功能:预约、签到、个人信息 │ │ +│ │ ROLE_COACH │ 教练端功能:排课、签到、学员管理 │ │ +│ │ ROLE_RECEPTION │ 前台功能:接待、签到、会员查询 │ │ +│ │ ROLE_STORE_MGR │ 店长功能:单店全功能管理 │ │ +│ │ ROLE_OPERATOR │ 运营功能:营销活动、数据分析 │ │ +│ │ ROLE_FINANCE │ 财务功能:账单、报表 │ │ +│ │ ROLE_ADMIN │ 超管功能:全平台管理 │ │ +│ └────────────────┴────────────────────────────────────────────────┘ │ +│ │ +│ 权限标识: │ +│ 格式: {模块}:{资源}:{操作} │ +│ 示例: │ +│ - member:user:read # 查看会员信息 │ +│ - member:user:write # 编辑会员信息 │ +│ - booking:course:create # 创建课程预约 │ +│ - booking:course:cancel # 取消课程预约 │ +│ - checkin:scan:execute # 执行扫码签到 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 6.3 数据安全 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 数据安全措施 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. 敏感数据加密 │ +│ ├── 手机号: 存储时AES加密,展示时脱敏(138****1234) │ +│ ├── 身份证: 存储时AES加密,不对外展示 │ +│ ├── 密码: 使用BCrypt加密存储 │ +│ └── 支付信息: 不存储,仅保留支付渠道token │ +│ │ +│ 2. 传输安全 │ +│ ├── 全站HTTPS │ +│ ├── 敏感接口签名验证 │ +│ └── 防重放攻击(timestamp + nonce) │ +│ │ +│ 3. SQL注入防护 │ +│ ├── 使用R2DBC参数化查询 │ +│ ├── 禁止拼接SQL │ +│ └── 输入参数校验 │ +│ │ +│ 4. XSS防护 │ +│ ├── 输入过滤 │ +│ ├── 输出编码 │ +│ └── Content-Security-Policy头 │ +│ │ +│ 5. CSRF防护 │ +│ ├── CSRF Token验证 │ +│ └── SameSite Cookie │ +│ │ +│ 6. 多租户数据隔离 │ +│ ├── tenant_id 强制过滤 │ +│ ├── 数据库行级安全策略(RLS) │ +│ └── API层租户上下文自动注入 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 6.4 接口安全 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 接口安全措施 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. 限流策略 │ +│ ├── 全局限流: 10000 QPS │ +│ ├── 单IP限流: 100 QPS │ +│ ├── 单用户限流: 50 QPS │ +│ └── 敏感接口限流: 登录10次/分钟,短信1次/分钟 │ +│ │ +│ 2. 熔断降级 │ +│ ├── 错误率阈值: 50% │ +│ ├── 熔断时间窗口: 30秒 │ +│ └── 降级策略: 返回默认值或友好提示 │ +│ │ +│ 3. 黑白名单 │ +│ ├── IP白名单: 管理后台接口 │ +│ ├── IP黑名单: 恶意攻击IP │ +│ └── 用户黑名单: 违规用户 │ +│ │ +│ 4. 日志审计 │ +│ ├── 操作日志: 记录所有写操作 │ +│ ├── 访问日志: 记录所有API请求 │ +│ └── 异常日志: 记录系统异常 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 七、性能设计 + +### 7.1 性能目标 + +| 指标 | 目标值 | 说明 | +|------|--------|------| +| 响应时间 | P99 < 200ms | 99%的请求响应时间小于200ms | +| 吞吐量 | QPS ≥ 1000 | 系统每秒处理请求数 | +| 并发用户 | ≥ 5000 | 同时在线用户数 | +| 可用性 | SLA ≥ 99.9% | 年度可用性 | + +### 7.2 性能优化策略 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 性能优化策略 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. 数据库优化 │ +│ ├── 索引优化: 合理创建索引,避免全表扫描 │ +│ ├── 连接池: R2DBC连接池,max-size=20 │ +│ ├── 读写分离: 主库写入,从库读取(后期扩展) │ +│ └── 分库分表: 按租户分库(后期扩展) │ +│ │ +│ 2. 缓存优化 │ +│ ├── L1缓存: Caffeine本地缓存,热点数据 │ +│ ├── L2缓存: Redis分布式缓存(可选扩展) │ +│ ├── 缓存策略: 写穿透 + 延迟双删 │ +│ └── 缓存预热: 系统启动时预加载热点数据 │ +│ │ +│ 3. 代码优化 │ +│ ├── 响应式编程: WebFlux非阻塞IO │ +│ ├── 异步处理: 非核心流程异步化 │ +│ ├── 批量操作: 减少数据库往返 │ +│ └── 虚拟线程: JDK 21虚拟线程优化阻塞操作 │ +│ │ +│ 4. 网络优化 │ +│ ├── CDN加速: 静态资源CDN分发 │ +│ ├── GZIP压缩: 响应数据压缩 │ +│ ├── HTTP/2: 多路复用 │ +│ └── Keep-Alive: 连接复用 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 7.3 高并发场景处理 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 高并发预约场景处理 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 场景: 热门课程开抢,大量用户同时预约 │ +│ │ +│ 解决方案: │ +│ │ +│ 1. 库存预热 │ +│ └── 开抢前将课程库存加载到Caffeine缓存 │ +│ │ +│ 2. 原子操作 │ +│ └── 使用PostgreSQL原子更新: │ +│ UPDATE booking_slot │ +│ SET booked_count = booked_count + 1 │ +│ WHERE id = ? AND booked_count < capacity │ +│ │ +│ 3. 乐观锁 │ +│ └── 版本号控制,冲突时重试 │ +│ │ +│ 4. 排队机制 │ +│ └── 请求进入队列,异步处理结果 │ +│ │ +│ 5. 限流保护 │ +│ └── 令牌桶限流,防止系统过载 │ +│ │ +│ 6. 候补机制 │ +│ └── 满员后自动进入候补队列,有人取消时自动补位 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 八、可扩展性设计 + +### 8.1 水平扩展 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 水平扩展方案 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. 无状态设计 │ +│ ├── Session外置到Redis │ +│ ├── 本地不存储用户状态 │ +│ └── 任意实例可处理任意请求 │ +│ │ +│ 2. 负载均衡 │ +│ ├── 轮询: 默认策略 │ +│ ├── 加权轮询: 根据服务器性能分配权重 │ +│ └── 最少连接: 请求分配给连接数最少的服务器 │ +│ │ +│ 3. 服务拆分(后期) │ +│ ├── 会员服务独立部署 │ +│ ├── 预约服务独立部署 │ +│ └── 数据服务独立部署 │ +│ │ +│ 4. 数据库扩展(后期) │ +│ ├── 读写分离 │ +│ ├── 分库分表 │ +│ └── 多活架构 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 8.2 功能扩展 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 功能扩展点 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. 支付扩展 │ +│ ├── 预留支付接口抽象 │ +│ ├── 支持微信支付、支付宝、银联等 │ +│ └── 可扩展其他支付渠道 │ +│ │ +│ 2. 硬件扩展 │ +│ ├── 签到网关抽象设计 │ +│ ├── 支持多种签到设备 │ +│ └── 可扩展智能硬件 │ +│ │ +│ 3. 消息扩展 │ +│ ├── 消息模板可配置 │ +│ ├── 支持多渠道推送 │ +│ └── 可扩展新的消息渠道 │ +│ │ +│ 4. 报表扩展 │ +│ ├── 报表模板可配置 │ +│ ├── 支持自定义报表 │ +│ └── 可扩展BI工具对接 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 九、监控与运维 + +### 9.1 监控体系 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 监控体系 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. 基础监控 │ +│ ├── CPU使用率 │ +│ ├── 内存使用率 │ +│ ├── 磁盘使用率 │ +│ ├── 网络IO │ +│ └── 进程状态 │ +│ │ +│ 2. 应用监控 │ +│ ├── JVM监控(GC、堆内存、线程) │ +│ ├── 请求QPS/响应时间 │ +│ ├── 错误率/异常数 │ +│ └── 数据库连接池状态 │ +│ │ +│ 3. 业务监控 │ +│ ├── 登录数/在线用户数 │ +│ ├── 预约成功率 │ +│ ├── 签到成功率 │ +│ └── 支付成功率 │ +│ │ +│ 4. 告警策略 │ +│ ├── CPU > 80% 告警 │ +│ ├── 内存 > 85% 告警 │ +│ ├── 错误率 > 1% 告警 │ +│ └── 响应时间 P99 > 500ms 告警 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 9.2 日志规范 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 日志规范 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. 日志级别 │ +│ ├── ERROR: 错误日志,需要立即处理 │ +│ ├── WARN: 警告日志,需要关注 │ +│ ├── INFO: 重要业务日志 │ +│ └── DEBUG: 调试日志(生产环境关闭) │ +│ │ +│ 2. 日志格式 │ +│ { │ +│ "timestamp": "2024-02-28T10:00:00.000+08:00", │ +│ "level": "INFO", │ +│ "traceId": "abc123", │ +│ "spanId": "def456", │ +│ "tenantId": "tenant:001", │ +│ "userId": "member:12345", │ +│ "class": "MemberService", │ +│ "method": "createMember", │ +│ "message": "创建会员成功", │ +│ "args": {"phone": "138****1234"}, │ +│ "result": {"memberId": 12345}, │ +│ "duration": 50 │ +│ } │ +│ │ +│ 3. 日志存储 │ +│ ├── 本地文件: 保留7天 │ +│ ├── ELK: 集中存储和分析 │ +│ └── 归档: 超过30天归档到OSS │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 十、附录 + +### 10.1 技术选型清单 + +| 分类 | 技术 | 版本 | 说明 | +|------|------|------|------| +| 后端框架 | Spring Boot | 3.x | 主框架 | +| 响应式 | Spring WebFlux | 3.x | 响应式Web框架 | +| JDK | OpenJDK | 21+ | 虚拟线程支持 | +| 数据库 | PostgreSQL | 15+ | 主数据库 | +| 数据访问 | R2DBC | 1.x | 响应式数据库访问 | +| 数据库迁移 | Flyway | 9.x | 数据库版本管理 | +| 缓存 | Caffeine | 3.x | 本地缓存 | +| 缓存(可选) | Redis | 7.x | 分布式缓存 | +| 前端框架 | Vue | 3.x | 前端框架 | +| 跨端框架 | uniapp | 3.x | 跨端开发框架 | +| 状态管理 | Pinia | 2.x | 状态管理 | +| UI组件 | Element Plus | 2.x | 管理后台UI | +| 构建工具 | Vite | 5.x | 前端构建工具 | +| 容器 | Docker | 24.x | 容器化部署 | + +### 10.2 术语表 + +| 术语 | 英文 | 定义 | +|------|------|------| +| 高可用 | High Availability | 系统持续提供服务的能力 | +| 响应式编程 | Reactive Programming | 基于数据流和变化传播的编程范式 | +| 多租户 | Multi-tenancy | 单个实例服务多个租户的架构 | +| 软删除 | Soft Delete | 通过标记删除时间而非物理删除数据 | +| 乐观锁 | Optimistic Lock | 假设冲突较少,通过版本号控制并发 | +| 悲观锁 | Pessimistic Lock | 假设冲突较多,通过锁机制控制并发 | + +--- + +*文档结束* diff --git a/docs/design/LLD-会员模块详细设计.md b/docs/design/LLD-会员模块详细设计.md new file mode 100644 index 0000000..33bb08f --- /dev/null +++ b/docs/design/LLD-会员模块详细设计.md @@ -0,0 +1,1783 @@ +# 健身房管理系统详细设计文档 - 会员模块(LLD) + +> 文档编号: GYM-LLD-001 +> 版本: v1.0 +> 日期: 2026-02-28 +> 作者: 张翔 +> 状态: 初稿 + +--- + +## 文档修订历史 + +| 版本 | 日期 | 作者 | 修订内容 | +|------|------|------|---------| +| v1.0 | 2026-02-28 | 张翔 | 初稿 | + +--- + +## 一、模块概述 + +### 1.1 模块定位 + +会员模块是健身房管理系统的核心基础模块,负责管理会员全生命周期,包括: + +- 会员注册与信息管理 +- 会员卡购买与管理 +- 会员权益(时长/次数/储值/等级)管理 +- 会员等级体系与积分管理 + +### 1.2 模块边界 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 会员模块边界 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 会员模块内部 │ │ +│ │ │ │ +│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌──────────┐ │ │ +│ │ │ 会员管理 │ │ 会员卡管理 │ │ 权益管理 │ │ 等级管理 │ │ │ +│ │ └───────────┘ └───────────┘ └───────────┘ └──────────┘ │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 外部依赖: │ +│ ├── 租户模块: 获取租户信息 │ +│ ├── 门店模块: 获取门店信息 │ +│ ├── 认证模块: 用户登录认证 │ +│ └── 消息模块: 发送短信验证码 │ +│ │ +│ 被依赖: │ +│ ├── 预约模块: 查询会员权益、扣减权益 │ +│ ├── 签到模块: 查询会员信息、扣减权益 │ +│ ├── 财务模块: 查询会员消费记录 │ +│ └── 数据模块: 会员数据分析 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 二、数据模型设计 + +### 2.1 实体关系图 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 会员模块ER图 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ tenant │ │ store │ │ member │ │ +│ │─────────────│ │─────────────│ │─────────────│ │ +│ │ id │◀──┐ │ id │◀──┐ │ id │ │ +│ │ name │ │ │ tenant_id │───┘ │ tenant_id │───┐ │ +│ │ code │ │ │ name │ │ store_id │───┼──┐ │ +│ │ status │ │ │ address │ │ member_no │ │ │ │ +│ └─────────────┘ │ │ status │ │ name │ │ │ │ +│ │ └─────────────┘ │ phone │ │ │ │ +│ │ │ gender │ │ │ │ +│ │ │ level │ │ │ │ +│ │ │ exp │ │ │ │ +│ │ │ status │ │ │ │ +│ │ └─────────────┘ │ │ │ +│ │ │ │ │ │ +│ │ │ │ │ │ +│ │ ▼ │ │ │ +│ │ ┌─────────────┐ ┌─────────────┐ │ │ │ +│ │ │ member_card │◀──────│ member_bene │ │ │ │ +│ │ │─────────────│ │ fit │ │ │ │ +│ │ │ id │ │─────────────│ │ │ │ +│ │ │ member_id │──────▶│ id │ │ │ │ +│ │ │ card_type_id│ │ member_id │───┘ │ │ +│ │ │ card_no │ │ card_id │ │ │ +│ │ │ status │ │ type │ │ │ +│ │ │ start_date │ │ value │ │ │ +│ │ │ end_date │ │ used_value │ │ │ +│ │ └─────────────┘ │ expire_date │ │ │ +│ │ └─────────────┘ │ │ +│ │ │ │ +│ │ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ card_type │ │ level_rule │ │ │ +│ │ │─────────────│ │─────────────│ │ │ +│ └───│ tenant_id │ │ tenant_id │◀─────┘ │ +│ │ name │ │ level │ │ +│ │ type │ │ name │ │ +│ │ price │ │ min_exp │ │ +│ │ duration │ │ max_exp │ │ +│ └─────────────┘ │ discount │ │ +│ └─────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 数据表设计 + +#### 2.2.1 会员表 (member) + +```sql +CREATE TABLE member ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + store_id BIGINT NOT NULL, + member_no VARCHAR(32) NOT NULL, + name VARCHAR(64), + phone VARCHAR(64) NOT NULL, -- AES加密存储 + phone_mask VARCHAR(20), -- 脱敏手机号 + avatar VARCHAR(512), + gender SMALLINT DEFAULT 0, -- 0:未知 1:男 2:女 + birthday DATE, + id_card VARCHAR(128), -- AES加密存储 + emergency_contact VARCHAR(64), + emergency_phone VARCHAR(64), + level SMALLINT DEFAULT 0, -- 会员等级 + exp INT DEFAULT 0, -- 经验值 + total_exp INT DEFAULT 0, -- 累计经验值 + status SMALLINT DEFAULT 1, -- 1:正常 2:冻结 3:注销 + register_source VARCHAR(32), -- 注册来源 + last_login_at TIMESTAMP, + last_login_ip VARCHAR(64), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + created_by BIGINT, + updated_by BIGINT, + deleted_at TIMESTAMP DEFAULT NULL, + + CONSTRAINT uk_member_no UNIQUE (tenant_id, member_no), + CONSTRAINT uk_member_phone UNIQUE (tenant_id, phone), + CONSTRAINT fk_member_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id), + CONSTRAINT fk_member_store FOREIGN KEY (store_id) REFERENCES store(id) +); + +CREATE INDEX idx_member_tenant ON member(tenant_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_member_store ON member(store_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_member_phone ON member(phone) WHERE deleted_at IS NULL; +CREATE INDEX idx_member_level ON member(level) WHERE deleted_at IS NULL; +CREATE INDEX idx_member_status ON member(status) WHERE deleted_at IS NULL; +``` + +#### 2.2.2 会员卡类型表 (card_type) + +```sql +CREATE TABLE card_type ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + name VARCHAR(64) NOT NULL, + code VARCHAR(32) NOT NULL, + type SMALLINT NOT NULL, -- 1:时长卡 2:次卡 3:储值卡 4:等级卡 + category SMALLINT DEFAULT 1, -- 1:团课卡 2:私教卡 3:通用卡 + price DECIMAL(10,2) NOT NULL, + original_price DECIMAL(10,2), + duration_days INT, -- 时长卡有效期(天) + total_times INT, -- 次卡总次数 + stored_value DECIMAL(10,2), -- 储值卡金额 + level SMALLINT, -- 等级卡等级 + discount DECIMAL(3,2) DEFAULT 1.00, -- 折扣率 + description TEXT, + benefits JSONB, -- 权益配置 + valid_days INT DEFAULT 365, -- 激活后有效天数 + transferable BOOLEAN DEFAULT FALSE, -- 是否可转让 + refundable BOOLEAN DEFAULT FALSE, -- 是否可退款 + status SMALLINT DEFAULT 1, -- 1:上架 2:下架 + sort_order INT DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + created_by BIGINT, + updated_by BIGINT, + deleted_at TIMESTAMP DEFAULT NULL, + + CONSTRAINT uk_card_type_code UNIQUE (tenant_id, code), + CONSTRAINT fk_card_type_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id) +); + +CREATE INDEX idx_card_type_tenant ON card_type(tenant_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_card_type_status ON card_type(status) WHERE deleted_at IS NULL; +``` + +#### 2.2.3 会员卡表 (member_card) + +```sql +CREATE TABLE member_card ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + card_type_id BIGINT NOT NULL, + card_no VARCHAR(32) NOT NULL, + status SMALLINT DEFAULT 1, -- 1:未激活 2:有效 3:已过期 4:已用完 5:已冻结 + price DECIMAL(10,2) NOT NULL, -- 购买价格 + paid_amount DECIMAL(10,2) NOT NULL, -- 实付金额 + start_date DATE, -- 生效日期 + end_date DATE, -- 到期日期 + freeze_at TIMESTAMP, -- 冻结时间 + freeze_reason VARCHAR(256), -- 冻结原因 + transfer_from BIGINT, -- 转让来源会员ID + transfer_to BIGINT, -- 转让目标会员ID + order_id BIGINT, -- 关联订单ID + remark VARCHAR(256), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + created_by BIGINT, + updated_by BIGINT, + deleted_at TIMESTAMP DEFAULT NULL, + + CONSTRAINT uk_member_card_no UNIQUE (tenant_id, card_no), + CONSTRAINT fk_member_card_member FOREIGN KEY (member_id) REFERENCES member(id), + CONSTRAINT fk_member_card_type FOREIGN KEY (card_type_id) REFERENCES card_type(id) +); + +CREATE INDEX idx_member_card_member ON member_card(member_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_member_card_status ON member_card(status) WHERE deleted_at IS NULL; +CREATE INDEX idx_member_card_end_date ON member_card(end_date) WHERE deleted_at IS NULL; +``` + +#### 2.2.4 会员权益表 (member_benefit) + +```sql +CREATE TABLE member_benefit ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + card_id BIGINT, -- 关联会员卡ID + type SMALLINT NOT NULL, -- 1:时长 2:次数 3:储值 4:等级 + category SMALLINT DEFAULT 1, -- 1:团课 2:私教 3:通用 + name VARCHAR(64) NOT NULL, -- 权益名称 + value DECIMAL(12,2) NOT NULL, -- 权益值(天数/次数/金额) + used_value DECIMAL(12,2) DEFAULT 0, -- 已使用值 + remain_value DECIMAL(12,2) NOT NULL, -- 剩余值 + unit VARCHAR(16), -- 单位: 天/次/元 + expire_date DATE, -- 过期日期 + status SMALLINT DEFAULT 1, -- 1:有效 2:已过期 3:已用完 + source VARCHAR(32), -- 来源: purchase/reward/activity + source_id BIGINT, -- 来源ID + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + deleted_at TIMESTAMP DEFAULT NULL, + + CONSTRAINT fk_benefit_member FOREIGN KEY (member_id) REFERENCES member(id), + CONSTRAINT fk_benefit_card FOREIGN KEY (card_id) REFERENCES member_card(id) +); + +CREATE INDEX idx_benefit_member ON member_benefit(member_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_benefit_type ON member_benefit(type, category) WHERE deleted_at IS NULL; +CREATE INDEX idx_benefit_status ON member_benefit(status) WHERE deleted_at IS NULL; +CREATE INDEX idx_benefit_expire ON member_benefit(expire_date) WHERE deleted_at IS NULL; +``` + +#### 2.2.5 权益变更记录表 (benefit_log) + +```sql +CREATE TABLE benefit_log ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + benefit_id BIGINT NOT NULL, + type SMALLINT NOT NULL, -- 1:增加 2:扣减 3:过期 4:冻结 5:解冻 + before_value DECIMAL(12,2) NOT NULL, -- 变更前值 + change_value DECIMAL(12,2) NOT NULL, -- 变更值 + after_value DECIMAL(12,2) NOT NULL, -- 变更后值 + reason VARCHAR(256), -- 变更原因 + biz_type VARCHAR(32), -- 业务类型: booking/checkin/reward/refund + biz_id BIGINT, -- 业务ID + operator_id BIGINT, -- 操作人ID + operator_type VARCHAR(32), -- 操作人类型: member/staff/system + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_benefit_log_member ON benefit_log(member_id); +CREATE INDEX idx_benefit_log_benefit ON benefit_log(benefit_id); +CREATE INDEX idx_benefit_log_biz ON benefit_log(biz_type, biz_id); +CREATE INDEX idx_benefit_log_created ON benefit_log(created_at); +``` + +#### 2.2.6 会员等级规则表 (level_rule) + +```sql +CREATE TABLE level_rule ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + level SMALLINT NOT NULL, -- 等级 + name VARCHAR(32) NOT NULL, -- 等级名称 + icon VARCHAR(256), -- 等级图标 + min_exp INT NOT NULL, -- 最低经验值 + max_exp INT, -- 最高经验值(为空表示无上限) + discount DECIMAL(3,2) DEFAULT 1.00, -- 折扣率 + benefits JSONB, -- 等级权益 + upgrade_reward INT DEFAULT 0, -- 升级奖励经验值 + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + deleted_at TIMESTAMP DEFAULT NULL, + + CONSTRAINT uk_level_rule UNIQUE (tenant_id, level), + CONSTRAINT fk_level_rule_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id) +); + +CREATE INDEX idx_level_rule_tenant ON level_rule(tenant_id) WHERE deleted_at IS NULL; +``` + +#### 2.2.7 经验值记录表 (exp_log) + +```sql +CREATE TABLE exp_log ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + type SMALLINT NOT NULL, -- 1:获得 2:消耗 + change_exp INT NOT NULL, -- 变更经验值 + before_exp INT NOT NULL, -- 变更前经验值 + after_exp INT NOT NULL, -- 变更后经验值 + before_level SMALLINT NOT NULL, -- 变更前等级 + after_level SMALLINT NOT NULL, -- 变更后等级 + source VARCHAR(32), -- 来源: checkin/booking/purchase/reward + source_id BIGINT, -- 来源ID + remark VARCHAR(256), + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_exp_log_member ON exp_log(member_id); +CREATE INDEX idx_exp_log_created ON exp_log(created_at); +``` + +--- + +## 三、领域模型设计 + +### 3.1 领域模型类图 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 会员领域模型 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ <> │ │ +│ │ Member │ │ +│ ├───────────────────────────────────────────────────────────────────┤ │ +│ │ - id: Long │ │ +│ │ - tenantId: Long │ │ +│ │ - storeId: Long │ │ +│ │ - memberNo: String │ │ +│ │ - name: String │ │ +│ │ - phone: String │ │ +│ │ - avatar: String │ │ +│ │ - gender: Gender │ │ +│ │ - birthday: LocalDate │ │ +│ │ - level: Integer │ │ +│ │ - exp: Integer │ │ +│ │ - status: MemberStatus │ │ +│ │ - cards: List │ │ +│ │ - benefits: List │ │ +│ ├───────────────────────────────────────────────────────────────────┤ │ +│ │ + activate(): void │ │ +│ │ + freeze(reason: String): void │ │ +│ │ + unfreeze(): void │ │ +│ │ + addExp(exp: Integer): void │ │ +│ │ + canLevelUp(): Boolean │ │ +│ │ + levelUp(): void │ │ +│ │ + getValidBenefits(): List │ │ +│ │ + getUsableBenefit(type: BenefitType): MemberBenefit │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ │ 1:N │ +│ ▼ │ +│ ┌────────────────────────────┐ ┌────────────────────────────┐ │ +│ │ <> │ │ <> │ │ +│ │ MemberCard │ │ MemberBenefit │ │ +│ ├────────────────────────────┤ ├────────────────────────────┤ │ +│ │ - id: Long │ │ - id: Long │ │ +│ │ - memberId: Long │ │ - memberId: Long │ │ +│ │ - cardTypeId: Long │ │ - cardId: Long │ │ +│ │ - cardNo: String │ │ - type: BenefitType │ │ +│ │ - status: CardStatus │ │ - category: BenefitCategory│ │ +│ │ - startDate: LocalDate │ │ - value: BigDecimal │ │ +│ │ - endDate: LocalDate │ │ - usedValue: BigDecimal │ │ +│ │ - price: BigDecimal │ │ - remainValue: BigDecimal │ │ +│ ├────────────────────────────┤ │ - expireDate: LocalDate │ │ +│ │ + activate(): void │ │ - status: BenefitStatus │ │ +│ │ + freeze(): void │ ├────────────────────────────┤ │ +│ │ + unfreeze(): void │ │ + deduct(value): void │ │ +│ │ + isExpired(): Boolean │ │ + add(value): void │ │ +│ │ + isUsable(): Boolean │ │ + isExpired(): Boolean │ │ +│ │ + getRemainDays(): Integer │ │ + isUsable(): Boolean │ │ +│ └────────────────────────────┘ └────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────┐ ┌────────────────────────────┐ │ +│ │ <> │ │ <> │ │ +│ │ MemberStatus │ │ BenefitType │ │ +│ ├────────────────────────────┤ ├────────────────────────────┤ │ +│ │ NORMAL(1, "正常") │ │ DURATION(1, "时长") │ │ +│ │ FROZEN(2, "冻结") │ │ TIMES(2, "次数") │ │ +│ │ CANCELLED(3, "注销") │ │ STORED_VALUE(3, "储值") │ │ +│ └────────────────────────────┘ │ LEVEL(4, "等级") │ │ +│ └────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────┐ ┌────────────────────────────┐ │ +│ │ <> │ │ <> │ │ +│ │ CardStatus │ │ BenefitCategory │ │ +│ ├────────────────────────────┤ ├────────────────────────────┤ │ +│ │ INACTIVE(1, "未激活") │ │ GROUP_CLASS(1, "团课") │ │ +│ │ ACTIVE(2, "有效") │ │ PRIVATE(2, "私教") │ │ +│ │ EXPIRED(3, "已过期") │ │ GENERAL(3, "通用") │ │ +│ │ USED_UP(4, "已用完") │ └────────────────────────────┘ │ +│ │ FROZEN(5, "已冻结") │ │ +│ └────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 3.2 领域服务 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 领域服务设计 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ <> │ │ +│ │ MemberDomainService │ │ +│ ├───────────────────────────────────────────────────────────────────┤ │ +│ │ + registerMember(command: RegisterMemberCommand): Member │ │ +│ │ + updateMemberInfo(memberId: Long, command: UpdateMemberCommand) │ │ +│ │ + freezeMember(memberId: Long, reason: String): void │ │ +│ │ + unfreezeMember(memberId: Long): void │ │ +│ │ + calculateLevel(memberId: Long): Integer │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ <> │ │ +│ │ BenefitDomainService │ │ +│ ├───────────────────────────────────────────────────────────────────┤ │ +│ │ + purchaseCard(command: PurchaseCardCommand): MemberCard │ │ +│ │ + activateCard(cardId: Long): void │ │ +│ │ + deductBenefit(memberId: Long, request: DeductRequest): void │ │ +│ │ + refundBenefit(memberId: Long, request: RefundRequest): void │ │ +│ │ + expireBenefits(): void │ │ +│ │ + getUsableBenefits(memberId: Long, type: BenefitType): List │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ <> │ │ +│ │ LevelDomainService │ │ +│ ├───────────────────────────────────────────────────────────────────┤ │ +│ │ + addExp(memberId: Long, exp: Integer, source: String): void │ │ +│ │ + calculateLevel(tenantId: Long, exp: Integer): Integer │ │ +│ │ + getLevelBenefits(tenantId: Long, level: Integer): LevelBenefit │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 四、业务流程设计 + +### 4.1 会员注册流程 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 会员注册流程 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 会员端 API层 Service层 数据层 │ +│ │ │ │ │ │ +│ │ 1.输入手机号 │ │ │ │ +│ │─────────────────▶│ │ │ │ +│ │ │ 2.发送验证码 │ │ │ +│ │ │───────────────────▶│ │ │ +│ │ │ │ 3.调用短信服务 │ │ +│ │ │ │─────────────────▶│ │ +│ │ │ │◀─────────────────│ │ +│ │ │◀───────────────────│ │ │ +│ │◀─────────────────│ 返回验证码ID │ │ │ +│ │ │ │ │ │ +│ │ 4.提交注册信息 │ │ │ │ +│ │─────────────────▶│ │ │ │ +│ │ │ 5.验证验证码 │ │ │ +│ │ │───────────────────▶│ │ │ +│ │ │ │ 6.查询手机号 │ │ +│ │ │ │─────────────────▶│ │ +│ │ │ │◀─────────────────│ │ +│ │ │ │ 7.检查是否已注册 │ │ +│ │ │ │ │ │ +│ │ │ │ 8.生成会员编号 │ │ +│ │ │ │ 9.创建会员 │ │ +│ │ │ │─────────────────▶│ │ +│ │ │ │◀─────────────────│ │ +│ │ │ │ 10.生成JWT Token │ │ +│ │ │◀───────────────────│ │ │ +│ │◀─────────────────│ 返回Token和会员信息 │ │ │ +│ │ │ │ │ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.2 会员卡购买流程 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 会员卡购买流程 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 会员端 API层 OrderService MemberService PaymentService │ +│ │ │ │ │ │ │ +│ │ 1.选择卡种│ │ │ │ │ +│ │─────────▶│ │ │ │ │ +│ │ │ 2.创建订单 │ │ │ │ +│ │ │───────────▶│ │ │ │ +│ │ │ │ 3.校验卡种 │ │ │ +│ │ │ │─────────────▶│ │ │ +│ │ │ │◀─────────────│ │ │ +│ │ │ │ 4.创建支付单 │ │ │ +│ │ │ │─────────────────────────────▶│ │ +│ │ │ │◀─────────────────────────────│ │ +│ │◀────────│ 返回支付参数│ │ │ │ +│ │ │ │ │ │ │ +│ │ 5.完成支付│ │ │ │ │ +│ │──────────────────────────────────────────────────▶│ │ +│ │ │ │ │ 6.支付回调 │ │ +│ │ │ │◀─────────────────────────────│ │ +│ │ │ │ 7.更新订单状态│ │ │ +│ │ │ │─────────────▶│ │ │ +│ │ │ │ │ 8.创建会员卡 │ │ +│ │ │ │ │─────────────▶ │ │ +│ │ │ │ │ 9.创建权益 │ │ +│ │ │ │ │─────────────▶ │ │ +│ │ │ │ │ 10.增加经验值 │ │ +│ │ │ │ │─────────────▶ │ │ +│ │ │ │◀─────────────│ │ │ +│ │◀────────│ 购买成功通知│ │ │ │ +│ │ │ │ │ │ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.3 权益扣减流程 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 权益扣减流程 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 调用方 BenefitService Repository │ +│ │ │ │ │ +│ │ 1.请求扣减权益 │ │ │ +│ │─────────────────▶│ │ │ +│ │ │ 2.查询可用权益 │ │ +│ │ │────────────────────▶│ │ +│ │ │◀────────────────────│ │ +│ │ │ │ │ +│ │ │ 3.按优先级排序 │ │ +│ │ │ (即将过期优先) │ │ +│ │ │ │ │ +│ │ │ 4.校验余额充足 │ │ +│ │ │ │ │ +│ │ │ 5.执行扣减(事务) │ │ +│ │ │────────────────────▶│ │ +│ │ │ UPDATE member_benefit │ +│ │ │ SET remain_value = remain_value - ? │ +│ │ │ used_value = used_value + ? │ +│ │ │ WHERE id = ? AND remain_value >= ? │ +│ │ │ │ │ +│ │ │ 6.记录变更日志 │ │ +│ │ │────────────────────▶│ │ +│ │ │ │ │ +│ │ │ 7.检查是否用完 │ │ +│ │ │ 更新状态 │ │ +│ │ │────────────────────▶│ │ +│ │ │ │ │ +│ │◀─────────────────│ 返回扣减结果 │ │ +│ │ │ │ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.4 等级升级流程 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 等级升级流程 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 触发源 LevelService Member LevelRule │ +│ │ │ │ │ │ +│ │ 1.增加经验值 │ │ │ │ +│ │────────────────▶│ │ │ │ +│ │ │ 2.查询当前会员 │ │ │ +│ │ │───────────────────▶│ │ │ +│ │ │◀───────────────────│ │ │ +│ │ │ │ │ │ +│ │ │ 3.计算新等级 │ │ │ +│ │ │─────────────────────────────────────▶│ │ +│ │ │◀─────────────────────────────────────│ │ +│ │ │ │ │ │ +│ │ │ 4.比较是否升级 │ │ │ +│ │ │ │ │ │ +│ │ │ [如果升级] │ │ │ +│ │ │ 5.更新会员等级 │ │ │ +│ │ │───────────────────▶│ │ │ +│ │ │ │ │ │ +│ │ │ 6.发放升级奖励 │ │ │ +│ │ │ (经验值/优惠券) │ │ │ +│ │ │───────────────────▶│ │ │ +│ │ │ │ │ │ +│ │ │ 7.记录升级日志 │ │ │ +│ │ │───────────────────▶│ │ │ +│ │ │ │ │ │ +│ │ │ 8.发送升级通知 │ │ │ +│ │ │ │ │ │ +│ │◀────────────────│ 返回升级结果 │ │ │ +│ │ │ │ │ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 五、接口设计 + +### 5.1 会员接口 + +#### 5.1.1 会员注册 + +``` +POST /v1/members/register + +Request: +{ + "storeId": 1, + "phone": "13800138000", + "verifyCode": "123456", + "verifyCodeId": "uuid-xxx", + "name": "张三", + "gender": 1, + "birthday": "1990-01-01" +} + +Response: +{ + "code": 0, + "message": "success", + "data": { + "memberId": 10001, + "memberNo": "M202602280001", + "name": "张三", + "phone": "138****8000", + "level": 0, + "levelName": "普通会员", + "accessToken": "eyJhbGciOiJIUzI1NiIs...", + "refreshToken": "eyJhbGciOiJIUzI1NiIs...", + "expiresIn": 7200 + } +} +``` + +#### 5.1.2 获取会员信息 + +``` +GET /v1/members/{id} + +Response: +{ + "code": 0, + "message": "success", + "data": { + "id": 10001, + "memberNo": "M202602280001", + "name": "张三", + "phone": "138****8000", + "avatar": "https://xxx.com/avatar.jpg", + "gender": 1, + "genderName": "男", + "birthday": "1990-01-01", + "level": 2, + "levelName": "银卡会员", + "exp": 1500, + "totalExp": 1500, + "nextLevelExp": 3000, + "cards": [ + { + "id": 1, + "cardNo": "C202602280001", + "cardTypeName": "年卡", + "status": 2, + "statusName": "有效", + "startDate": "2026-02-28", + "endDate": "2027-02-27", + "remainDays": 365 + } + ], + "benefits": [ + { + "id": 1, + "type": 1, + "typeName": "时长", + "name": "团课时长", + "remainValue": 30, + "unit": "天", + "expireDate": "2027-02-27" + } + ], + "createdAt": "2026-02-28T10:00:00" + } +} +``` + +#### 5.1.3 更新会员信息 + +``` +PUT /v1/members/{id} + +Request: +{ + "name": "张三", + "avatar": "https://xxx.com/new-avatar.jpg", + "gender": 1, + "birthday": "1990-01-01", + "emergencyContact": "李四", + "emergencyPhone": "13900139000" +} + +Response: +{ + "code": 0, + "message": "success", + "data": { + "id": 10001, + "name": "张三", + "avatar": "https://xxx.com/new-avatar.jpg", + "updatedAt": "2026-02-28T11:00:00" + } +} +``` + +### 5.2 会员卡接口 + +#### 5.2.1 获取可购买卡种列表 + +``` +GET /v1/card-types?storeId=1&status=1 + +Response: +{ + "code": 0, + "message": "success", + "data": { + "list": [ + { + "id": 1, + "name": "月卡", + "code": "MONTH_CARD", + "type": 1, + "typeName": "时长卡", + "category": 3, + "categoryName": "通用卡", + "price": 299.00, + "originalPrice": 399.00, + "durationDays": 30, + "validDays": 365, + "description": "30天无限次使用", + "benefits": { + "groupClass": true, + "privateClass": false, + "locker": true + } + } + ], + "total": 5 + } +} +``` + +#### 5.2.2 购买会员卡 + +``` +POST /v1/member-cards/purchase + +Request: +{ + "memberId": 10001, + "cardTypeId": 1, + "quantity": 1, + "couponId": null, + "remark": "首次购卡" +} + +Response: +{ + "code": 0, + "message": "success", + "data": { + "orderId": "O202602280001", + "paymentUrl": "weixin://wxpay/bizpayurl?...", + "amount": 299.00 + } +} +``` + +#### 5.2.3 获取会员卡列表 + +``` +GET /v1/members/{memberId}/cards + +Response: +{ + "code": 0, + "message": "success", + "data": { + "list": [ + { + "id": 1, + "cardNo": "C202602280001", + "cardTypeName": "年卡", + "type": 1, + "typeName": "时长卡", + "status": 2, + "statusName": "有效", + "price": 1999.00, + "paidAmount": 1799.10, + "startDate": "2026-02-28", + "endDate": "2027-02-27", + "remainDays": 365, + "benefits": [ + { + "type": 1, + "typeName": "时长", + "remainValue": 365, + "unit": "天" + } + ] + } + ], + "total": 1 + } +} +``` + +### 5.3 权益接口 + +#### 5.3.1 获取会员权益 + +``` +GET /v1/members/{memberId}/benefits?type=1&status=1 + +Response: +{ + "code": 0, + "message": "success", + "data": { + "list": [ + { + "id": 1, + "type": 1, + "typeName": "时长", + "category": 3, + "categoryName": "通用", + "name": "年卡时长", + "value": 365, + "usedValue": 0, + "remainValue": 365, + "unit": "天", + "expireDate": "2027-02-27", + "status": 1, + "statusName": "有效", + "cardId": 1, + "cardNo": "C202602280001" + } + ], + "total": 1, + "summary": { + "totalDuration": 365, + "totalTimes": 50, + "totalStoredValue": 1000.00 + } + } +} +``` + +#### 5.3.2 获取权益变更记录 + +``` +GET /v1/members/{memberId}/benefit-logs?startDate=2026-01-01&endDate=2026-02-28 + +Response: +{ + "code": 0, + "message": "success", + "data": { + "list": [ + { + "id": 1, + "benefitId": 1, + "benefitName": "年卡时长", + "type": 1, + "typeName": "增加", + "beforeValue": 0, + "changeValue": 365, + "afterValue": 365, + "reason": "购买年卡", + "bizType": "purchase", + "bizId": "O202602280001", + "createdAt": "2026-02-28T10:00:00" + }, + { + "id": 2, + "benefitId": 2, + "benefitName": "团课次数", + "type": 2, + "typeName": "扣减", + "beforeValue": 10, + "changeValue": 1, + "afterValue": 9, + "reason": "预约团课: 瑜伽课", + "bizType": "booking", + "bizId": "B202602280001", + "createdAt": "2026-02-28T14:00:00" + } + ], + "total": 2, + "page": 1, + "pageSize": 20 + } +} +``` + +--- + +## 六、核心代码设计 + +### 6.1 会员实体 + +```java +package com.gym.domain.model.member; + +import com.gym.domain.model.base.BaseEntity; +import com.gym.domain.model.base.AggregateRoot; +import lombok.Getter; +import lombok.Setter; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +public class Member extends BaseEntity implements AggregateRoot { + + private Long tenantId; + private Long storeId; + private String memberNo; + private String name; + private String phone; + private String phoneMask; + private String avatar; + private Gender gender; + private LocalDate birthday; + private String idCard; + private String emergencyContact; + private String emergencyPhone; + private Integer level; + private Integer exp; + private Integer totalExp; + private MemberStatus status; + private String registerSource; + private LocalDateTime lastLoginAt; + private String lastLoginIp; + + private List cards = new ArrayList<>(); + private List benefits = new ArrayList<>(); + + public boolean isNormal() { + return MemberStatus.NORMAL.equals(this.status); + } + + public boolean isFrozen() { + return MemberStatus.FROZEN.equals(this.status); + } + + public void freeze(String reason) { + if (!isNormal()) { + throw new BusinessException("会员状态异常,无法冻结"); + } + this.status = MemberStatus.FROZEN; + this.updatedAt = LocalDateTime.now(); + } + + public void unfreeze() { + if (!isFrozen()) { + throw new BusinessException("会员未冻结"); + } + this.status = MemberStatus.NORMAL; + this.updatedAt = LocalDateTime.now(); + } + + public void addExp(Integer exp) { + if (exp <= 0) { + return; + } + this.exp += exp; + this.totalExp += exp; + this.updatedAt = LocalDateTime.now(); + } + + public void updateLevel(Integer newLevel) { + if (newLevel > this.level) { + this.level = newLevel; + this.updatedAt = LocalDateTime.now(); + } + } + + public List getValidBenefits() { + return benefits.stream() + .filter(MemberBenefit::isUsable) + .toList(); + } + + public List getUsableBenefits(BenefitType type, BenefitCategory category) { + return benefits.stream() + .filter(b -> b.getType().equals(type)) + .filter(b -> category == null || b.getCategory().equals(category)) + .filter(MemberBenefit::isUsable) + .sorted((a, b) -> { + if (a.getExpireDate() == null && b.getExpireDate() == null) return 0; + if (a.getExpireDate() == null) return 1; + if (b.getExpireDate() == null) return -1; + return a.getExpireDate().compareTo(b.getExpireDate()); + }) + .toList(); + } +} +``` + +### 6.2 会员权益实体 + +```java +package com.gym.domain.model.member; + +import com.gym.domain.model.base.BaseEntity; +import lombok.Getter; +import lombok.Setter; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +@Setter +public class MemberBenefit extends BaseEntity { + + private Long tenantId; + private Long memberId; + private Long cardId; + private BenefitType type; + private BenefitCategory category; + private String name; + private BigDecimal value; + private BigDecimal usedValue; + private BigDecimal remainValue; + private String unit; + private LocalDate expireDate; + private BenefitStatus status; + private String source; + private Long sourceId; + + public boolean isUsable() { + if (!BenefitStatus.VALID.equals(status)) { + return false; + } + if (remainValue.compareTo(BigDecimal.ZERO) <= 0) { + return false; + } + if (expireDate != null && expireDate.isBefore(LocalDate.now())) { + return false; + } + return true; + } + + public boolean isExpired() { + return expireDate != null && expireDate.isBefore(LocalDate.now()); + } + + public boolean canDeduct(BigDecimal amount) { + return remainValue.compareTo(amount) >= 0; + } + + public void deduct(BigDecimal amount) { + if (!canDeduct(amount)) { + throw new BusinessException("权益余额不足"); + } + this.usedValue = this.usedValue.add(amount); + this.remainValue = this.remainValue.subtract(amount); + this.updatedAt = LocalDateTime.now(); + + if (this.remainValue.compareTo(BigDecimal.ZERO) == 0) { + this.status = BenefitStatus.USED_UP; + } + } + + public void add(BigDecimal amount) { + this.value = this.value.add(amount); + this.remainValue = this.remainValue.add(amount); + this.updatedAt = LocalDateTime.now(); + } + + public void expire() { + this.status = BenefitStatus.EXPIRED; + this.updatedAt = LocalDateTime.now(); + } +} +``` + +### 6.3 权益服务 + +```java +package com.gym.domain.service; + +import com.gym.domain.model.member.*; +import com.gym.domain.repository.MemberBenefitRepository; +import com.gym.domain.repository.BenefitLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class BenefitDomainService { + + private final MemberBenefitRepository benefitRepository; + private final BenefitLogRepository benefitLogRepository; + + public Flux getUsableBenefits(Long memberId, BenefitType type, BenefitCategory category) { + return benefitRepository.findUsableByMemberId(memberId, type, category) + .sort((a, b) -> { + if (a.getExpireDate() == null && b.getExpireDate() == null) return 0; + if (a.getExpireDate() == null) return 1; + if (b.getExpireDate() == null) return -1; + return a.getExpireDate().compareTo(b.getExpireDate()); + }); + } + + @Transactional + public Mono deductBenefit(Long memberId, BenefitType type, BenefitCategory category, + BigDecimal amount, String bizType, Long bizId, String reason) { + return getUsableBenefits(memberId, type, category) + .collectList() + .flatMap(benefits -> { + BigDecimal remaining = amount; + + for (MemberBenefit benefit : benefits) { + if (remaining.compareTo(BigDecimal.ZERO) <= 0) break; + + BigDecimal deductAmount = benefit.getRemainValue().min(remaining); + BigDecimal beforeValue = benefit.getRemainValue(); + + benefit.deduct(deductAmount); + remaining = remaining.subtract(deductAmount); + + BenefitLog log = BenefitLog.builder() + .tenantId(benefit.getTenantId()) + .memberId(memberId) + .benefitId(benefit.getId()) + .type(BenefitLogType.DEDUCT) + .beforeValue(beforeValue) + .changeValue(deductAmount) + .afterValue(benefit.getRemainValue()) + .reason(reason) + .bizType(bizType) + .bizId(bizId) + .build(); + + benefitLogRepository.save(log).subscribe(); + } + + if (remaining.compareTo(BigDecimal.ZERO) > 0) { + return Mono.error(new BusinessException("权益余额不足")); + } + + return Mono.when(benefits.stream() + .map(benefitRepository::save) + .toArray(Mono[]::new)); + }); + } + + @Transactional + public Mono addBenefit(Long memberId, Long cardId, BenefitType type, + BenefitCategory category, String name, BigDecimal value, + String unit, LocalDate expireDate, String source, Long sourceId) { + MemberBenefit benefit = new MemberBenefit(); + benefit.setMemberId(memberId); + benefit.setCardId(cardId); + benefit.setType(type); + benefit.setCategory(category); + benefit.setName(name); + benefit.setValue(value); + benefit.setUsedValue(BigDecimal.ZERO); + benefit.setRemainValue(value); + benefit.setUnit(unit); + benefit.setExpireDate(expireDate); + benefit.setStatus(BenefitStatus.VALID); + benefit.setSource(source); + benefit.setSourceId(sourceId); + + return benefitRepository.save(benefit) + .doOnNext(saved -> { + BenefitLog log = BenefitLog.builder() + .tenantId(saved.getTenantId()) + .memberId(memberId) + .benefitId(saved.getId()) + .type(BenefitLogType.ADD) + .beforeValue(BigDecimal.ZERO) + .changeValue(value) + .afterValue(value) + .reason("购买会员卡") + .bizType("purchase") + .bizId(sourceId) + .build(); + + benefitLogRepository.save(log).subscribe(); + }); + } +} +``` + +### 6.4 会员仓储 + +```java +package com.gym.infrastructure.repository; + +import com.gym.domain.model.member.Member; +import com.gym.infrastructure.r2dbc.MemberR2dbcRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.r2dbc.core.DatabaseClient; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import java.time.LocalDateTime; + +@Repository +@RequiredArgsConstructor +public class MemberRepository { + + private final MemberR2dbcRepository r2dbcRepository; + private final DatabaseClient databaseClient; + + public Mono findById(Long id) { + return r2dbcRepository.findByIdAndDeletedAtIsNull(id); + } + + public Mono findByPhone(Long tenantId, String phone) { + return r2dbcRepository.findByTenantIdAndPhoneAndDeletedAtIsNull(tenantId, phone); + } + + public Mono findByMemberNo(Long tenantId, String memberNo) { + return r2dbcRepository.findByTenantIdAndMemberNoAndDeletedAtIsNull(tenantId, memberNo); + } + + public Flux findByStoreId(Long storeId) { + return r2dbcRepository.findByStoreIdAndDeletedAtIsNull(storeId); + } + + public Mono save(Member member) { + member.setUpdatedAt(LocalDateTime.now()); + if (member.getId() == null) { + member.setCreatedAt(LocalDateTime.now()); + return r2dbcRepository.save(member); + } + return r2dbcRepository.save(member); + } + + public Mono softDelete(Long id, Long operatorId) { + return databaseClient.sql(""" + UPDATE member + SET deleted_at = NOW(), updated_at = NOW(), updated_by = :operatorId + WHERE id = :id AND deleted_at IS NULL + """) + .bind("id", id) + .bind("operatorId", operatorId) + .fetch() + .rowsUpdated() + .then(); + } + + public Mono existsByPhone(Long tenantId, String phone) { + return r2dbcRepository.existsByTenantIdAndPhoneAndDeletedAtIsNull(tenantId, phone); + } + + public Mono countByStoreId(Long storeId) { + return r2dbcRepository.countByStoreIdAndDeletedAtIsNull(storeId); + } + + public Mono generateMemberNo(Long tenantId) { + String prefix = "M" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + + return databaseClient.sql(""" + SELECT COALESCE(MAX(CAST(SUBSTRING(member_no, 10) AS BIGINT)), 0) + 1 as next_no + FROM member + WHERE tenant_id = :tenantId + AND member_no LIKE :prefix + AND deleted_at IS NULL + """) + .bind("tenantId", tenantId) + .bind("prefix", prefix + "%") + .map(row -> row.get("next_no", Long.class)) + .first() + .map(nextNo -> prefix + String.format("%04d", nextNo)); + } +} +``` + +--- + +## 七、缓存设计 + +### 7.1 缓存策略 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 会员模块缓存策略 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. 会员信息缓存 │ +│ ├── Key: member:info:{memberId} │ +│ ├── Value: Member JSON │ +│ ├── TTL: 30分钟 │ +│ ├── 更新策略: 写穿透(Write-Through) │ +│ └── 失效策略: 更新时删除 │ +│ │ +│ 2. 会员权益缓存 │ +│ ├── Key: member:benefits:{memberId} │ +│ ├── Value: List JSON │ +│ ├── TTL: 10分钟 │ +│ ├── 更新策略: 写穿透 │ +│ └── 失效策略: 权益变更时删除 │ +│ │ +│ 3. 会员卡类型缓存 │ +│ ├── Key: card-types:tenant:{tenantId} │ +│ ├── Value: List JSON │ +│ ├── TTL: 1小时 │ +│ ├── 更新策略: 定时刷新 │ +│ └── 失效策略: 卡种变更时删除 │ +│ │ +│ 4. 等级规则缓存 │ +│ ├── Key: level-rules:tenant:{tenantId} │ +│ ├── Value: List JSON │ +│ ├── TTL: 1天 │ +│ ├── 更新策略: 定时刷新 │ +│ └── 失效策略: 规则变更时删除 │ +│ │ +│ 5. 会员编号生成锁 │ +│ ├── Key: member:no:lock:{tenantId} │ +│ ├── Value: 1 │ +│ ├── TTL: 5秒 │ +│ └── 用途: 防止并发生成重复编号 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 7.2 缓存配置 + +```java +package com.gym.infrastructure.cache; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import java.util.concurrent.TimeUnit; + +@Configuration +public class CacheConfig { + + @Bean + public Cache memberCache() { + return Caffeine.newBuilder() + .maximumSize(10000) + .expireAfterWrite(30, TimeUnit.MINUTES) + .recordStats() + .build(); + } + + @Bean + public Cache benefitCache() { + return Caffeine.newBuilder() + .maximumSize(20000) + .expireAfterWrite(10, TimeUnit.MINUTES) + .recordStats() + .build(); + } + + @Bean + public Cache cardTypeCache() { + return Caffeine.newBuilder() + .maximumSize(1000) + .expireAfterWrite(1, TimeUnit.HOURS) + .recordStats() + .build(); + } + + @Bean + public Cache levelRuleCache() { + return Caffeine.newBuilder() + .maximumSize(500) + .expireAfterWrite(1, TimeUnit.DAYS) + .recordStats() + .build(); + } +} +``` + +--- + +## 八、异常处理 + +### 8.1 异常定义 + +```java +package com.gym.domain.exception; + +public class MemberException extends BusinessException { + + public static final MemberException MEMBER_NOT_FOUND = + new MemberException(40001, "会员不存在"); + + public static final MemberException MEMBER_ALREADY_EXISTS = + new MemberException(40002, "会员已存在"); + + public static final MemberException MEMBER_FROZEN = + new MemberException(40003, "会员已冻结"); + + public static final MemberException MEMBER_CANCELLED = + new MemberException(40004, "会员已注销"); + + public static final MemberException PHONE_ALREADY_REGISTERED = + new MemberException(40005, "手机号已注册"); + + public static final MemberException VERIFY_CODE_ERROR = + new MemberException(40006, "验证码错误"); + + public static final MemberException VERIFY_CODE_EXPIRED = + new MemberException(40007, "验证码已过期"); + + public MemberException(int code, String message) { + super(code, message); + } +} + +public class BenefitException extends BusinessException { + + public static final BenefitException BENEFIT_NOT_FOUND = + new BenefitException(40101, "权益不存在"); + + public static final BenefitException BENEFIT_INSUFFICIENT = + new BenefitException(40102, "权益余额不足"); + + public static final BenefitException BENEFIT_EXPIRED = + new BenefitException(40103, "权益已过期"); + + public static final BenefitException BENEFIT_USED_UP = + new BenefitException(40104, "权益已用完"); + + public static final BenefitException CARD_NOT_FOUND = + new BenefitException(40105, "会员卡不存在"); + + public static final BenefitException CARD_EXPIRED = + new BenefitException(40106, "会员卡已过期"); + + public static final BenefitException CARD_FROZEN = + new BenefitException(40107, "会员卡已冻结"); + + public BenefitException(int code, String message) { + super(code, message); + } +} +``` + +### 8.2 异常处理 + +```java +package com.gym.api.exception; + +import com.gym.domain.exception.*; +import com.gym.api.dto.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import reactor.core.publisher.Mono; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MemberException.class) + public Mono> handleMemberException(MemberException e) { + log.warn("会员业务异常: code={}, message={}", e.getCode(), e.getMessage()); + return Mono.just(ApiResponse.error(e.getCode(), e.getMessage())); + } + + @ExceptionHandler(BenefitException.class) + public Mono> handleBenefitException(BenefitException e) { + log.warn("权益业务异常: code={}, message={}", e.getCode(), e.getMessage()); + return Mono.just(ApiResponse.error(e.getCode(), e.getMessage())); + } + + @ExceptionHandler(BusinessException.class) + public Mono> handleBusinessException(BusinessException e) { + log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage()); + return Mono.just(ApiResponse.error(e.getCode(), e.getMessage())); + } + + @ExceptionHandler(Exception.class) + public Mono> handleException(Exception e) { + log.error("系统异常", e); + return Mono.just(ApiResponse.error(50001, "系统异常,请稍后重试")); + } +} +``` + +--- + +## 九、测试设计 + +### 9.1 单元测试 + +```java +package com.gym.domain.model.member; + +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.LocalDate; +import static org.junit.jupiter.api.Assertions.*; + +class MemberBenefitTest { + + @Test + void testIsUsable_WhenValid_ShouldReturnTrue() { + MemberBenefit benefit = new MemberBenefit(); + benefit.setStatus(BenefitStatus.VALID); + benefit.setRemainValue(BigDecimal.TEN); + benefit.setExpireDate(LocalDate.now().plusDays(10)); + + assertTrue(benefit.isUsable()); + } + + @Test + void testIsUsable_WhenExpired_ShouldReturnFalse() { + MemberBenefit benefit = new MemberBenefit(); + benefit.setStatus(BenefitStatus.VALID); + benefit.setRemainValue(BigDecimal.TEN); + benefit.setExpireDate(LocalDate.now().minusDays(1)); + + assertFalse(benefit.isUsable()); + } + + @Test + void testIsUsable_WhenUsedUp_ShouldReturnFalse() { + MemberBenefit benefit = new MemberBenefit(); + benefit.setStatus(BenefitStatus.VALID); + benefit.setRemainValue(BigDecimal.ZERO); + benefit.setExpireDate(LocalDate.now().plusDays(10)); + + assertFalse(benefit.isUsable()); + } + + @Test + void testDeduct_WhenSufficient_ShouldSuccess() { + MemberBenefit benefit = new MemberBenefit(); + benefit.setStatus(BenefitStatus.VALID); + benefit.setValue(BigDecimal.TEN); + benefit.setUsedValue(BigDecimal.ZERO); + benefit.setRemainValue(BigDecimal.TEN); + + benefit.deduct(BigDecimal.valueOf(3)); + + assertEquals(BigDecimal.valueOf(7), benefit.getRemainValue()); + assertEquals(BigDecimal.valueOf(3), benefit.getUsedValue()); + } + + @Test + void testDeduct_WhenInsufficient_ShouldThrowException() { + MemberBenefit benefit = new MemberBenefit(); + benefit.setStatus(BenefitStatus.VALID); + benefit.setRemainValue(BigDecimal.ONE); + + assertThrows(BusinessException.class, () -> benefit.deduct(BigDecimal.TEN)); + } + + @Test + void testDeduct_WhenFullyUsed_ShouldUpdateStatus() { + MemberBenefit benefit = new MemberBenefit(); + benefit.setStatus(BenefitStatus.VALID); + benefit.setValue(BigDecimal.TEN); + benefit.setUsedValue(BigDecimal.ZERO); + benefit.setRemainValue(BigDecimal.TEN); + + benefit.deduct(BigDecimal.TEN); + + assertEquals(BenefitStatus.USED_UP, benefit.getStatus()); + } +} +``` + +### 9.2 集成测试 + +```java +package com.gym.domain.service; + +import com.gym.domain.model.member.*; +import com.gym.domain.repository.MemberBenefitRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.reactive.TransactionalOperator; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import java.math.BigDecimal; +import java.time.LocalDate; + +@SpringBootTest +class BenefitDomainServiceIntegrationTest { + + @Autowired + private BenefitDomainService benefitService; + + @Autowired + private MemberBenefitRepository benefitRepository; + + @Autowired + private TransactionalOperator rxtx; + + @Test + void testDeductBenefit_ShouldSuccess() { + Long memberId = 1L; + Long benefitId = 1L; + + Mono result = benefitRepository.findById(benefitId) + .flatMap(benefit -> { + BigDecimal beforeValue = benefit.getRemainValue(); + + return benefitService.deductBenefit( + memberId, + BenefitType.TIMES, + BenefitCategory.GROUP_CLASS, + BigDecimal.ONE, + "booking", + 1L, + "预约扣减" + ).then(Mono.just(beforeValue)); + }) + .flatMap(beforeValue -> benefitRepository.findById(benefitId) + .map(benefit -> { + assertEquals(beforeValue.subtract(BigDecimal.ONE), benefit.getRemainValue()); + return true; + })) + .as(rxtx::transactional) + .then(); + + StepVerifier.create(result) + .verifyComplete(); + } +} +``` + +--- + +## 十、附录 + +### 10.1 枚举定义 + +```java +public enum MemberStatus { + NORMAL(1, "正常"), + FROZEN(2, "冻结"), + CANCELLED(3, "注销"); + + private final int code; + private final String name; +} + +public enum CardStatus { + INACTIVE(1, "未激活"), + ACTIVE(2, "有效"), + EXPIRED(3, "已过期"), + USED_UP(4, "已用完"), + FROZEN(5, "已冻结"); + + private final int code; + private final String name; +} + +public enum BenefitType { + DURATION(1, "时长"), + TIMES(2, "次数"), + STORED_VALUE(3, "储值"), + LEVEL(4, "等级"); + + private final int code; + private final String name; +} + +public enum BenefitCategory { + GROUP_CLASS(1, "团课"), + PRIVATE(2, "私教"), + GENERAL(3, "通用"); + + private final int code; + private final String name; +} + +public enum BenefitStatus { + VALID(1, "有效"), + EXPIRED(2, "已过期"), + USED_UP(3, "已用完"); + + private final int code; + private final String name; +} + +public enum BenefitLogType { + ADD(1, "增加"), + DEDUCT(2, "扣减"), + EXPIRE(3, "过期"), + FREEZE(4, "冻结"), + UNFREEZE(5, "解冻"); + + private final int code; + private final String name; +} + +public enum Gender { + UNKNOWN(0, "未知"), + MALE(1, "男"), + FEMALE(2, "女"); + + private final int code; + private final String name; +} +``` + +### 10.2 配置项 + +```yaml +member: + register: + default-level: 0 + default-exp: 0 + member-no-prefix: "M" + + benefit: + expire-notice-days: 7 + max-benefits-per-member: 100 + + level: + exp-rules: + checkin: 10 + booking: 20 + purchase: 100 +``` + +--- + +*文档结束* diff --git a/docs/design/LLD-签到模块详细设计.md b/docs/design/LLD-签到模块详细设计.md new file mode 100644 index 0000000..2c360f1 --- /dev/null +++ b/docs/design/LLD-签到模块详细设计.md @@ -0,0 +1,1964 @@ +# 健身房管理系统详细设计文档 - 签到模块(LLD) + +> 文档编号: GYM-LLD-003 +> 版本: v1.0 +> 日期: 2026-02-28 +> 作者: 张翔 +> 状态: 初稿 + +--- + +## 文档修订历史 + +| 版本 | 日期 | 作者 | 修订内容 | +|------|------|------|---------| +| v1.0 | 2026-02-28 | 张翔 | 初稿 | + +--- + +## 一、模块概述 + +### 1.1 模块定位 + +签到模块是健身房管理系统的核心业务模块,负责管理会员的入场签到和课程签到,支持多种签到方式: + +- **二维码签到**:会员出示二维码,扫码签到 +- **人脸识别签到**:通过人脸识别设备自动签到 +- **NFC签到**:会员卡或手机NFC感应签到 +- **教练代签**:教练手动为会员签到 + +### 1.2 模块边界 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 签到模块边界 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 签到模块内部 │ │ +│ │ │ │ +│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌──────────┐ │ │ +│ │ │ 签到网关 │ │ 签到验证 │ │ 签到记录 │ │ 签到统计 │ │ │ +│ │ └───────────┘ └───────────┘ └───────────┘ └──────────┘ │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 外部依赖: │ +│ ├── 会员模块: 查询会员信息、验证会员状态 │ +│ ├── 权益模块: 验证权益有效性、扣减权益 │ +│ ├── 预约模块: 查询预约信息、验证签到资格 │ +│ ├── 设备模块: 人脸识别设备、NFC读卡器 │ +│ └── 消息模块: 发送签到通知 │ +│ │ +│ 被依赖: │ +│ ├── 财务模块: 签到消费记录 │ +│ ├── 数据模块: 签到数据分析、会员活跃度统计 │ +│ └── 考勤模块: 教练考勤统计 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 1.3 签到类型 + +| 签到类型 | 说明 | 触发条件 | 验证规则 | +|---------|------|---------|---------| +| **入场签到** | 会员进入健身房 | 扫码/人脸/NFC | 验证会员卡有效性 | +| **课程签到** | 会员参加预约课程 | 扫码/教练代签 | 验证预约记录、时间窗口 | +| **私教签到** | 会员上私教课 | 教练代签 | 验证私教预约、教练身份 | +| **活动签到** | 会员参加活动 | 扫码 | 验证活动报名 | + +--- + +## 二、数据模型设计 + +### 2.1 实体关系图 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 签到模块ER图 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ member │ │booking_record│ │ device │ │ +│ │─────────────│ │─────────────│ │─────────────│ │ +│ │ id │ │ id │ │ id │ │ +│ │ name │ │ member_id │ │ name │ │ +│ │ phone │ │ slot_id │ │ type │ │ +│ │ status │ │ status │ │ location │ │ +│ └──────┬──────┘ │ checkin_ │ │ status │ │ +│ │ │ status │ └──────┬──────┘ │ +│ │ └──────┬──────┘ │ │ +│ │ │ │ │ +│ │ ┌────────────────┼─────────────────────┘ │ +│ │ │ │ │ +│ │ ▼ ▼ │ +│ │ ┌─────────────────────────────┐ │ +│ │ │ checkin_record │ │ +│ │ │─────────────────────────────│ │ +│ │ │ id │ │ +│ │ │ tenant_id │ │ +│ │ │ member_id ◀────────────┘ │ +│ │ │ booking_id │ │ +│ │ │ device_id │ │ +│ │ │ type │ │ +│ │ │ method │ │ +│ │ │ status │ │ +│ │ │ checkin_at │ │ +│ │ └─────────────────────────────┘ │ +│ │ │ +│ │ ┌─────────────────────────────┐ │ +│ │ │ member_face │ │ +│ │ │─────────────────────────────│ │ +│ │ │ id │ │ +│ │ │ member_id ◀────────────┘ │ +│ │ │ face_feature │ │ +│ │ │ status │ │ +│ │ └─────────────────────────────┘ │ +│ │ │ +│ └────────────────────────────────────────────────────────────┘ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 数据表设计 + +#### 2.2.1 签到记录表 (checkin_record) + +```sql +CREATE TABLE checkin_record ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + store_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + booking_id BIGINT, -- 关联预约记录ID + type SMALLINT NOT NULL, -- 1:入场 2:课程 3:私教 4:活动 + method SMALLINT NOT NULL, -- 1:二维码 2:人脸 3:NFC 4:教练代签 + device_id BIGINT, -- 签到设备ID + device_name VARCHAR(64), -- 设备名称 + operator_id BIGINT, -- 操作人ID(教练代签时) + operator_name VARCHAR(64), -- 操作人姓名 + status SMALLINT DEFAULT 1, -- 1:成功 2:失败 3:已取消 + checkin_at TIMESTAMP NOT NULL, -- 签到时间 + checkin_date DATE NOT NULL, -- 签到日期(便于统计) + location VARCHAR(128), -- 签到位置 + latitude DECIMAL(10,7), -- 纬度 + longitude DECIMAL(10,7), -- 经度 + fail_reason VARCHAR(256), -- 失败原因 + benefit_id BIGINT, -- 扣减的权益ID + benefit_type SMALLINT, -- 权益类型 + benefit_value DECIMAL(10,2), -- 扣减值 + extra_data JSONB, -- 扩展数据 + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + deleted_at TIMESTAMP DEFAULT NULL, + + CONSTRAINT fk_checkin_member FOREIGN KEY (member_id) REFERENCES member(id), + CONSTRAINT fk_checkin_booking FOREIGN KEY (booking_id) REFERENCES booking_record(id) +); + +CREATE INDEX idx_checkin_tenant ON checkin_record(tenant_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_checkin_store ON checkin_record(store_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_checkin_member ON checkin_record(member_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_checkin_date ON checkin_record(checkin_date) WHERE deleted_at IS NULL; +CREATE INDEX idx_checkin_type ON checkin_record(type) WHERE deleted_at IS NULL; +CREATE INDEX idx_checkin_status ON checkin_record(status) WHERE deleted_at IS NULL; +CREATE INDEX idx_checkin_time ON checkin_record(checkin_at) WHERE deleted_at IS NULL; +``` + +#### 2.2.2 会员人脸信息表 (member_face) + +```sql +CREATE TABLE member_face ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + face_feature BYTEA NOT NULL, -- 人脸特征值(加密存储) + face_image VARCHAR(512), -- 人脸照片URL + feature_version VARCHAR(32), -- 特征版本 + quality_score DECIMAL(5,2), -- 质量分数 + status SMALLINT DEFAULT 1, -- 1:正常 2:待更新 3:已禁用 + last_match_at TIMESTAMP, -- 最后匹配时间 + match_count INT DEFAULT 0, -- 匹配次数 + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + deleted_at TIMESTAMP DEFAULT NULL, + + CONSTRAINT fk_face_member FOREIGN KEY (member_id) REFERENCES member(id), + CONSTRAINT uk_face_member UNIQUE (member_id) +); + +CREATE INDEX idx_face_tenant ON member_face(tenant_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_face_status ON member_face(status) WHERE deleted_at IS NULL; +``` + +#### 2.2.3 签到设备表 (checkin_device) + +```sql +CREATE TABLE checkin_device ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + store_id BIGINT NOT NULL, + name VARCHAR(64) NOT NULL, -- 设备名称 + code VARCHAR(32) NOT NULL, -- 设备编码 + type SMALLINT NOT NULL, -- 1:人脸识别机 2:NFC读卡器 3:扫码枪 4:一体机 + sn VARCHAR(64), -- 序列号 + location VARCHAR(128), -- 安装位置 + ip_address VARCHAR(64), -- IP地址 + mac_address VARCHAR(32), -- MAC地址 + status SMALLINT DEFAULT 1, -- 1:在线 2:离线 3:维护中 + last_heartbeat TIMESTAMP, -- 最后心跳时间 + config JSONB, -- 设备配置 + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + deleted_at TIMESTAMP DEFAULT NULL, + + CONSTRAINT fk_device_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id), + CONSTRAINT fk_device_store FOREIGN KEY (store_id) REFERENCES store(id), + CONSTRAINT uk_device_code UNIQUE (tenant_id, code) +); + +CREATE INDEX idx_device_tenant ON checkin_device(tenant_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_device_store ON checkin_device(store_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_device_status ON checkin_device(status) WHERE deleted_at IS NULL; +``` + +#### 2.2.4 签到统计表 (checkin_statistics) + +```sql +CREATE TABLE checkin_statistics ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + store_id BIGINT NOT NULL, + stat_date DATE NOT NULL, -- 统计日期 + stat_type SMALLINT NOT NULL, -- 1:日统计 2:周统计 3:月统计 + total_count INT DEFAULT 0, -- 总签到次数 + entry_count INT DEFAULT 0, -- 入场签到次数 + course_count INT DEFAULT 0, -- 课程签到次数 + private_count INT DEFAULT 0, -- 私教签到次数 + activity_count INT DEFAULT 0, -- 活动签到次数 + new_member_count INT DEFAULT 0, -- 新会员签到数 + active_member_count INT DEFAULT 0, -- 活跃会员数 + peak_hour SMALLINT, -- 高峰时段 + peak_count INT, -- 高峰人数 + avg_duration INT, -- 平均停留时长(分钟) + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + deleted_at TIMESTAMP DEFAULT NULL, + + CONSTRAINT uk_stat_date UNIQUE (tenant_id, store_id, stat_date, stat_type) +); + +CREATE INDEX idx_stat_tenant ON checkin_statistics(tenant_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_stat_store ON checkin_statistics(store_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_stat_date ON checkin_statistics(stat_date) WHERE deleted_at IS NULL; +``` + +#### 2.2.5 签到规则表 (checkin_rule) + +```sql +CREATE TABLE checkin_rule ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + store_id BIGINT, -- NULL表示全局规则 + rule_type SMALLINT NOT NULL, -- 1:入场规则 2:课程规则 3:私教规则 + name VARCHAR(64) NOT NULL, -- 规则名称 + description VARCHAR(256), -- 规则描述 + time_before INT DEFAULT 30, -- 提前签到时间(分钟) + time_after INT DEFAULT 15, -- 迟到允许时间(分钟) + late_penalty DECIMAL(3,2) DEFAULT 0.00, -- 迟到扣款比例 + absent_penalty DECIMAL(3,2) DEFAULT 1.00, -- 缺席扣款比例 + allow_late BOOLEAN DEFAULT TRUE, -- 是否允许迟到签到 + allow_absent BOOLEAN DEFAULT FALSE, -- 是否允许缺席 + max_daily_entry INT DEFAULT 1, -- 每日最大入场次数 + interval_minutes INT DEFAULT 0, -- 签到间隔(分钟) + status SMALLINT DEFAULT 1, -- 1:启用 2:禁用 + priority INT DEFAULT 0, -- 优先级 + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + created_by BIGINT, + updated_by BIGINT, + deleted_at TIMESTAMP DEFAULT NULL, + + CONSTRAINT fk_rule_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id) +); + +CREATE INDEX idx_rule_tenant ON checkin_rule(tenant_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_rule_store ON checkin_rule(store_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_rule_type ON checkin_rule(rule_type) WHERE deleted_at IS NULL; +``` + +--- + +## 三、领域模型设计 + +### 3.1 聚合设计 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 签到聚合设计 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ CheckinRecord (聚合根) │ │ +│ ├───────────────────────────────────────────────────────────────────┤ │ +│ │ - id: Long │ │ +│ │ - tenantId: Long │ │ +│ │ - storeId: Long │ │ +│ │ - memberId: Long │ │ +│ │ - bookingId: Long? │ │ +│ │ - type: CheckinType │ │ +│ │ - method: CheckinMethod │ │ +│ │ - device: DeviceInfo? │ │ +│ │ - operator: OperatorInfo? │ │ +│ │ - status: CheckinStatus │ │ +│ │ - checkinAt: LocalDateTime │ │ +│ │ - benefit: BenefitDeduction? │ │ +│ │ │ │ +│ │ 行为: │ │ +│ │ + checkin(): void │ │ +│ │ + cancel(reason: String): void │ │ +│ │ + isLate(): Boolean │ │ +│ │ + getDuration(): Duration │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────┐ ┌───────────────────────┐ │ +│ │ CheckinGateway │ │ CheckinValidator │ │ +│ │ (签到网关) │ │ (签到验证器) │ │ +│ ├───────────────────────┤ ├───────────────────────┤ │ +│ │ + processQRCode() │ │ + validateMember() │ │ +│ │ + processFace() │ │ + validateBooking() │ │ +│ │ + processNFC() │ │ + validateBenefit() │ │ +│ │ + processManual() │ │ + validateRule() │ │ +│ └───────────────────────┘ └───────────────────────┘ │ +│ │ +│ ┌───────────────────────┐ ┌───────────────────────┐ │ +│ │ CheckinStatistics │ │ FaceRecognition │ │ +│ │ (签到统计) │ │ (人脸识别) │ │ +│ ├───────────────────────┤ ├───────────────────────┤ │ +│ │ + dailyStats() │ │ + register() │ │ +│ │ + weeklyStats() │ │ + match() │ │ +│ │ + monthlyStats() │ │ + update() │ │ +│ │ + memberStats() │ │ + delete() │ │ +│ └───────────────────────┘ └───────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 3.2 值对象设计 + +```java +public enum CheckinType { + ENTRY(1, "入场签到"), + COURSE(2, "课程签到"), + PRIVATE(3, "私教签到"), + ACTIVITY(4, "活动签到"); + + private final int code; + private final String desc; +} + +public enum CheckinMethod { + QRCODE(1, "二维码"), + FACE(2, "人脸识别"), + NFC(3, "NFC"), + MANUAL(4, "教练代签"); + + private final int code; + private final String desc; +} + +public enum CheckinStatus { + SUCCESS(1, "成功"), + FAILED(2, "失败"), + CANCELLED(3, "已取消"); + + private final int code; + private final String desc; +} + +public record DeviceInfo( + Long deviceId, + String deviceName, + String location +) {} + +public record OperatorInfo( + Long operatorId, + String operatorName, + String operatorRole +) {} + +public record BenefitDeduction( + Long benefitId, + Integer benefitType, + BigDecimal benefitValue +) {} + +public record CheckinResult( + boolean success, + String message, + CheckinRecord record, + List warnings +) {} +``` + +### 3.3 领域服务设计 + +```java +public interface CheckinDomainService { + + Mono processCheckin(CheckinRequest request); + + Mono cancelCheckin(Long checkinId, String reason); + + Mono validateCheckinEligibility(Long memberId, CheckinType type, Long bookingId); + + Mono getCheckinRecord(Long checkinId); + + Flux getMemberCheckinHistory(Long memberId, LocalDate startDate, LocalDate endDate); +} + +public interface FaceRecognitionService { + + Mono registerFace(Long memberId, byte[] faceImage); + + Mono matchFace(byte[] faceFeature, Long tenantId); + + Mono updateFace(Long memberId, byte[] faceImage); + + Mono deleteFace(Long memberId); +} + +public interface CheckinStatisticsService { + + Mono generateDailyStatistics(Long tenantId, Long storeId, LocalDate date); + + Mono getDailyStatistics(Long tenantId, Long storeId, LocalDate date); + + Mono> getMemberCheckinStats(Long memberId, LocalDate startDate, LocalDate endDate); +} +``` + +--- + +## 四、业务流程设计 + +### 4.1 入场签到流程 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 入场签到流程 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ │ +│ │ 会员 │ │ 签到 │ │ 签到 │ │ 权益 │ │ 签到 │ │ +│ │ │ │ 网关 │ │ 验证 │ │ 服务 │ │ 记录 │ │ +│ └───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘ │ +│ │ │ │ │ │ │ +│ │ 出示二维码 │ │ │ │ │ +│ │────────────▶│ │ │ │ │ +│ │ │ │ │ │ │ +│ │ │ 解析二维码 │ │ │ │ +│ │ │────────────▶│ │ │ │ +│ │ │ │ │ │ │ +│ │ │ │ 查询会员 │ │ │ +│ │ │ │────────────▶│ │ │ +│ │ │ │ │ │ │ +│ │ │ │ 会员信息 │ │ │ +│ │ │ │◀────────────│ │ │ +│ │ │ │ │ │ │ +│ │ │ │ 验证会员卡 │ │ │ +│ │ │ │────────────▶│ │ │ +│ │ │ │ │ │ │ +│ │ │ │ 权益状态 │ │ │ +│ │ │ │◀────────────│ │ │ +│ │ │ │ │ │ │ +│ │ │ │ 检查签到规则│ │ │ +│ │ │ │─────────────┼────────────▶│ │ +│ │ │ │ │ │ │ +│ │ │ │ 规则验证结果│ │ │ +│ │ │ │◀────────────┼─────────────│ │ +│ │ │ │ │ │ │ +│ │ │ 验证结果 │ │ │ │ +│ │ │◀────────────│ │ │ │ +│ │ │ │ │ │ │ +│ │ │ 创建签到记录│ │ │ │ +│ │ │─────────────┼─────────────┼────────────▶│ │ +│ │ │ │ │ │ │ +│ │ │ 签到成功 │ │ │ │ +│ │ │◀────────────┼─────────────┼─────────────│ │ +│ │ │ │ │ │ │ +│ │ 签到成功 │ │ │ │ │ +│ │◀────────────│ │ │ │ │ +│ │ │ │ │ │ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.2 课程签到流程 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 课程签到流程 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ │ +│ │ 会员 │ │ 签到 │ │ 预约 │ │ 权益 │ │ 签到 │ │ +│ │ │ │ 网关 │ │ 服务 │ │ 服务 │ │ 记录 │ │ +│ └───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘ │ +│ │ │ │ │ │ │ +│ │ 扫码签到 │ │ │ │ │ +│ │────────────▶│ │ │ │ │ +│ │ │ │ │ │ │ +│ │ │ 查询预约 │ │ │ │ +│ │ │────────────▶│ │ │ │ +│ │ │ │ │ │ │ +│ │ │ 预约信息 │ │ │ │ +│ │ │◀────────────│ │ │ │ +│ │ │ │ │ │ │ +│ │ │ 验证签到时间窗口 │ │ │ +│ │ │─────────────┼────────────▶│ │ │ +│ │ │ │ │ │ │ +│ │ │ 时间窗口验证结果 │ │ │ +│ │ │◀────────────┼─────────────│ │ │ +│ │ │ │ │ │ │ +│ │ │ 验证权益 │ │ │ │ +│ │ │─────────────┼────────────▶│ │ │ +│ │ │ │ │ │ │ +│ │ │ 权益状态 │ │ │ │ +│ │ │◀────────────┼─────────────│ │ │ +│ │ │ │ │ │ │ +│ │ │ 扣减权益 │ │ │ │ +│ │ │─────────────┼────────────▶│ │ │ +│ │ │ │ │ │ │ +│ │ │ 扣减结果 │ │ │ │ +│ │ │◀────────────┼─────────────│ │ │ +│ │ │ │ │ │ │ +│ │ │ 创建签到记录│ │ │ │ +│ │ │─────────────┼─────────────┼────────────▶│ │ +│ │ │ │ │ │ │ +│ │ │ 更新预约签到状态 │ │ │ +│ │ │────────────▶│ │ │ │ +│ │ │ │ │ │ │ +│ │ 签到成功 │ │ │ │ │ +│ │◀────────────│ │ │ │ │ +│ │ │ │ │ │ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.3 人脸识别签到流程 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 人脸识别签到流程 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ │ +│ │ 会员 │ │ 人脸 │ │ 人脸 │ │ 签到 │ │ 签到 │ │ +│ │ │ │ 设备 │ │ 服务 │ │ 验证 │ │ 记录 │ │ +│ └───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘ │ +│ │ │ │ │ │ │ +│ │ 人脸识别 │ │ │ │ │ +│ │────────────▶│ │ │ │ │ +│ │ │ │ │ │ │ +│ │ │ 提取特征值 │ │ │ │ +│ │ │────────────▶│ │ │ │ +│ │ │ │ │ │ │ +│ │ │ │ 匹配会员 │ │ │ +│ │ │ │─────────────┼────────────▶│ │ +│ │ │ │ │ │ │ +│ │ │ │ 匹配结果 │ │ │ +│ │ │ │◀────────────┼─────────────│ │ +│ │ │ │ │ │ │ +│ │ │ 会员ID │ │ │ │ +│ │ │◀────────────│ │ │ │ +│ │ │ │ │ │ │ +│ │ │ 执行签到流程│ │ │ │ +│ │ │─────────────┼────────────▶│ │ │ +│ │ │ │ │ │ │ +│ │ │ 签到结果 │ │ │ │ +│ │ │◀────────────┼─────────────┼─────────────│ │ +│ │ │ │ │ │ │ +│ │ 签到成功 │ │ │ │ │ +│ │◀────────────│ │ │ │ │ +│ │ │ │ │ │ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.4 教练代签流程 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 教练代签流程 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ │ +│ │ 教练 │ │ 签到 │ │ 预约 │ │ 权益 │ │ 签到 │ │ +│ │ │ │ 网关 │ │ 服务 │ │ 服务 │ │ 记录 │ │ +│ └───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘ └───┬───┘ │ +│ │ │ │ │ │ │ +│ │ 选择会员 │ │ │ │ │ +│ │────────────▶│ │ │ │ │ +│ │ │ │ │ │ │ +│ │ │ 验证教练身份│ │ │ │ +│ │ │─────────────┼─────────────┼────────────▶│ │ +│ │ │ │ │ │ │ +│ │ │ 身份验证结果│ │ │ │ +│ │ │◀────────────┼─────────────┼─────────────│ │ +│ │ │ │ │ │ │ +│ │ │ 查询会员预约│ │ │ │ +│ │ │────────────▶│ │ │ │ +│ │ │ │ │ │ │ +│ │ │ 预约列表 │ │ │ │ +│ │ │◀────────────│ │ │ │ +│ │ │ │ │ │ │ +│ │ 选择预约 │ │ │ │ │ +│ │────────────▶│ │ │ │ │ +│ │ │ │ │ │ │ +│ │ │ 验证签到资格│ │ │ │ +│ │ │─────────────┼────────────▶│ │ │ +│ │ │ │ │ │ │ +│ │ │ 扣减权益 │ │ │ │ +│ │ │─────────────┼────────────▶│ │ │ +│ │ │ │ │ │ │ +│ │ │ 创建签到记录│ │ │ │ +│ │ │─────────────┼─────────────┼────────────▶│ │ +│ │ │ │ │ │ │ +│ │ 代签成功 │ │ │ │ │ +│ │◀────────────│ │ │ │ │ +│ │ │ │ │ │ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 五、接口设计 + +### 5.1 签到网关接口 + +#### 5.1.1 二维码签到 + +``` +POST /api/v1/checkin/qrcode +Content-Type: application/json + +Request: +{ + "tenantId": 1, + "storeId": 1, + "qrcode": "MEMBER_123456_TIMESTAMP", + "deviceId": 1, + "type": 1, // 1:入场 2:课程 + "bookingId": null // 课程签到时必填 +} + +Response: +{ + "code": 0, + "message": "签到成功", + "data": { + "checkinId": 1001, + "memberId": 123456, + "memberName": "张三", + "memberPhone": "138****8888", + "memberLevel": "VIP", + "checkinType": "入场签到", + "checkinTime": "2026-02-28 10:30:00", + "benefitDeducted": { + "type": "时长权益", + "value": "年卡有效期至2026-12-31" + }, + "warnings": [] + } +} +``` + +#### 5.1.2 人脸识别签到 + +``` +POST /api/v1/checkin/face +Content-Type: application/json + +Request: +{ + "tenantId": 1, + "storeId": 1, + "faceFeature": "base64_encoded_feature", + "deviceId": 1, + "type": 1, + "bookingId": null +} + +Response: +{ + "code": 0, + "message": "签到成功", + "data": { + "checkinId": 1002, + "memberId": 123456, + "memberName": "张三", + "memberPhone": "138****8888", + "memberLevel": "VIP", + "checkinType": "入场签到", + "checkinTime": "2026-02-28 10:31:00", + "benefitDeducted": { + "type": "时长权益", + "value": "年卡有效期至2026-12-31" + }, + "warnings": [] + } +} +``` + +#### 5.1.3 NFC签到 + +``` +POST /api/v1/checkin/nfc +Content-Type: application/json + +Request: +{ + "tenantId": 1, + "storeId": 1, + "nfcId": "NFC_CARD_123456", + "deviceId": 1, + "type": 1, + "bookingId": null +} + +Response: +{ + "code": 0, + "message": "签到成功", + "data": { + "checkinId": 1003, + "memberId": 123456, + "memberName": "张三", + "memberPhone": "138****8888", + "memberLevel": "VIP", + "checkinType": "入场签到", + "checkinTime": "2026-02-28 10:32:00", + "benefitDeducted": { + "type": "时长权益", + "value": "年卡有效期至2026-12-31" + }, + "warnings": [] + } +} +``` + +#### 5.1.4 教练代签 + +``` +POST /api/v1/checkin/manual +Content-Type: application/json + +Request: +{ + "tenantId": 1, + "storeId": 1, + "memberId": 123456, + "bookingId": 2001, + "operatorId": 100, // 教练ID + "operatorName": "李教练", + "remark": "会员已到场" +} + +Response: +{ + "code": 0, + "message": "代签成功", + "data": { + "checkinId": 1004, + "memberId": 123456, + "memberName": "张三", + "checkinType": "私教签到", + "checkinTime": "2026-02-28 10:33:00", + "operatorName": "李教练" + } +} +``` + +### 5.2 人脸管理接口 + +#### 5.2.1 注册人脸 + +``` +POST /api/v1/face/register +Content-Type: multipart/form-data + +Request: +{ + "memberId": 123456, + "faceImage": +} + +Response: +{ + "code": 0, + "message": "人脸注册成功", + "data": { + "faceId": 1, + "qualityScore": 95.5, + "status": "正常" + } +} +``` + +#### 5.2.2 更新人脸 + +``` +PUT /api/v1/face/{memberId} +Content-Type: multipart/form-data + +Request: +{ + "faceImage": +} + +Response: +{ + "code": 0, + "message": "人脸更新成功", + "data": { + "faceId": 1, + "qualityScore": 96.2, + "status": "正常" + } +} +``` + +#### 5.2.3 删除人脸 + +``` +DELETE /api/v1/face/{memberId} + +Response: +{ + "code": 0, + "message": "人脸删除成功" +} +``` + +### 5.3 签到记录接口 + +#### 5.3.1 查询签到记录 + +``` +GET /api/v1/checkin/records?memberId=123456&startDate=2026-02-01&endDate=2026-02-28&page=1&size=20 + +Response: +{ + "code": 0, + "message": "success", + "data": { + "total": 25, + "list": [ + { + "checkinId": 1001, + "type": "入场签到", + "method": "二维码", + "checkinTime": "2026-02-28 10:30:00", + "storeName": "中关村店", + "status": "成功" + }, + { + "checkinId": 1002, + "type": "课程签到", + "method": "人脸识别", + "checkinTime": "2026-02-27 19:00:00", + "courseName": "瑜伽课", + "coachName": "王教练", + "status": "成功" + } + ] + } +} +``` + +#### 5.3.2 查询签到统计 + +``` +GET /api/v1/checkin/statistics?tenantId=1&storeId=1&startDate=2026-02-01&endDate=2026-02-28 + +Response: +{ + "code": 0, + "message": "success", + "data": { + "totalCount": 1500, + "entryCount": 800, + "courseCount": 500, + "privateCount": 150, + "activityCount": 50, + "activeMemberCount": 350, + "newMemberCount": 25, + "peakHour": 19, + "peakCount": 120, + "avgDuration": 90, + "dailyTrend": [ + {"date": "2026-02-01", "count": 50}, + {"date": "2026-02-02", "count": 55} + ] + } +} +``` + +### 5.4 设备管理接口 + +#### 5.4.1 设备心跳 + +``` +POST /api/v1/device/heartbeat +Content-Type: application/json + +Request: +{ + "deviceId": 1, + "deviceCode": "DEVICE_001", + "status": 1, + "timestamp": "2026-02-28T10:30:00" +} + +Response: +{ + "code": 0, + "message": "success" +} +``` + +#### 5.4.2 设备列表 + +``` +GET /api/v1/device/list?tenantId=1&storeId=1 + +Response: +{ + "code": 0, + "message": "success", + "data": [ + { + "deviceId": 1, + "name": "前台人脸机", + "code": "DEVICE_001", + "type": "人脸识别机", + "location": "前台入口", + "status": "在线", + "lastHeartbeat": "2026-02-28 10:30:00" + } + ] +} +``` + +--- + +## 六、核心代码设计 + +### 6.1 签到领域服务实现 + +```java +@Slf4j +@Service +@RequiredArgsConstructor +public class CheckinDomainServiceImpl implements CheckinDomainService { + + private final CheckinRecordRepository checkinRepository; + private final MemberRepository memberRepository; + private final BookingRecordRepository bookingRepository; + private final BenefitDomainService benefitService; + private final CheckinRuleRepository ruleRepository; + private final TransactionalOperator rxtx; + private final ApplicationEventPublisher eventPublisher; + + @Override + public Mono processCheckin(CheckinRequest request) { + return Mono.defer(() -> + validateMember(request.getTenantId(), request.getMemberId()) + .flatMap(member -> validateCheckinRule(member, request)) + .flatMap(member -> processCheckinByType(member, request)) + ).as(rxtx::transactional); + } + + private Mono validateMember(Long tenantId, Long memberId) { + return memberRepository.findByIdAndTenantId(memberId, tenantId) + .switchIfEmpty(Mono.error(new CheckinException(CheckinException.MEMBER_NOT_FOUND))) + .flatMap(member -> { + if (member.getStatus() != MemberStatus.ACTIVE) { + return Mono.error(new CheckinException(CheckinException.MEMBER_INACTIVE)); + } + return Mono.just(member); + }); + } + + private Mono validateCheckinRule(Member member, CheckinRequest request) { + return ruleRepository.findByTenantIdAndRuleType( + member.getTenantId(), + request.getType() + ) + .flatMap(rule -> { + if (request.getType() == CheckinType.ENTRY) { + return validateEntryRule(member, rule, request); + } + return Mono.just(member); + }) + .switchIfEmpty(Mono.just(member)); + } + + private Mono validateEntryRule(Member member, CheckinRule rule, CheckinRequest request) { + LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + LocalDateTime todayEnd = todayStart.plusDays(1); + + return checkinRepository.countByMemberIdAndTypeAndCheckinAtBetween( + member.getId(), + CheckinType.ENTRY, + todayStart, + todayEnd + ).flatMap(count -> { + if (count >= rule.getMaxDailyEntry()) { + return Mono.error(new CheckinException( + CheckinException.DAILY_LIMIT_EXCEEDED, + "今日入场次数已达上限" + )); + } + + if (rule.getIntervalMinutes() > 0) { + return validateCheckinInterval(member, rule); + } + + return Mono.just(member); + }); + } + + private Mono validateCheckinInterval(Member member, CheckinRule rule) { + return checkinRepository.findFirstByMemberIdOrderByCheckinAtDesc(member.getId()) + .flatMap(lastCheckin -> { + long minutes = Duration.between( + lastCheckin.getCheckinAt(), + LocalDateTime.now() + ).toMinutes(); + + if (minutes < rule.getIntervalMinutes()) { + return Mono.error(new CheckinException( + CheckinException.INTERVAL_NOT_MET, + "签到间隔不足" + rule.getIntervalMinutes() + "分钟" + )); + } + return Mono.just(member); + }) + .switchIfEmpty(Mono.just(member)); + } + + private Mono processCheckinByType(Member member, CheckinRequest request) { + return switch (request.getType()) { + case ENTRY -> processEntryCheckin(member, request); + case COURSE -> processCourseCheckin(member, request); + case PRIVATE -> processPrivateCheckin(member, request); + case ACTIVITY -> processActivityCheckin(member, request); + }; + } + + private Mono processEntryCheckin(Member member, CheckinRequest request) { + return benefitService.validateAndDeduct( + member.getId(), + BenefitType.TIME, + null, + "入场签到" + ).flatMap(benefitDeduction -> { + CheckinRecord record = buildCheckinRecord(member, request, benefitDeduction); + record.setType(CheckinType.ENTRY); + + return checkinRepository.save(record) + .doOnNext(saved -> eventPublisher.publishEvent( + new CheckinSuccessEvent(saved) + )) + .map(saved -> CheckinResult.success(saved)); + }).onErrorResume(e -> { + if (e instanceof BenefitException) { + return Mono.just(CheckinResult.failure("权益不足,请充值或续费")); + } + return Mono.error(e); + }); + } + + private Mono processCourseCheckin(Member member, CheckinRequest request) { + return bookingRepository.findById(request.getBookingId()) + .switchIfEmpty(Mono.error(new CheckinException(CheckinException.BOOKING_NOT_FOUND))) + .flatMap(booking -> validateBookingForCheckin(booking, member)) + .flatMap(booking -> { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime courseStart = booking.getSlot().getStartTime(); + long minutesBefore = Duration.between(now, courseStart).toMinutes(); + + CheckinRecord record = buildCheckinRecord(member, request, null); + record.setType(CheckinType.COURSE); + record.setBookingId(booking.getId()); + + if (minutesBefore < 0) { + record.setLate(true); + record.setLateMinutes((int) Math.abs(minutesBefore)); + } + + return checkinRepository.save(record) + .flatMap(saved -> updateBookingCheckinStatus(booking, saved)) + .doOnNext(saved -> eventPublisher.publishEvent( + new CheckinSuccessEvent(saved) + )) + .map(saved -> CheckinResult.success(saved)); + }); + } + + private Mono validateBookingForCheckin(BookingRecord booking, Member member) { + if (!booking.getMemberId().equals(member.getId())) { + return Mono.error(new CheckinException(CheckinException.BOOKING_NOT_MATCH)); + } + + if (booking.getStatus() != BookingStatus.CONFIRMED) { + return Mono.error(new CheckinException(CheckinException.BOOKING_NOT_CONFIRMED)); + } + + if (booking.getCheckinStatus() == CheckinStatus.CHECKED) { + return Mono.error(new CheckinException(CheckinException.ALREADY_CHECKED)); + } + + LocalDateTime now = LocalDateTime.now(); + LocalDateTime courseStart = booking.getSlot().getStartTime(); + LocalDateTime courseEnd = booking.getSlot().getEndTime(); + + if (now.isAfter(courseEnd)) { + return Mono.error(new CheckinException(CheckinException.COURSE_ENDED)); + } + + return Mono.just(booking); + } + + private Mono updateBookingCheckinStatus(BookingRecord booking, CheckinRecord checkin) { + booking.setCheckinStatus(checkin.isLate() ? CheckinStatus.LATE : CheckinStatus.CHECKED); + booking.setCheckinAt(checkin.getCheckinAt()); + booking.setCheckinBy(checkin.getOperatorId()); + + return bookingRepository.save(booking).thenReturn(checkin); + } + + private CheckinRecord buildCheckinRecord(Member member, CheckinRequest request, + BenefitDeduction deduction) { + CheckinRecord record = new CheckinRecord(); + record.setTenantId(member.getTenantId()); + record.setStoreId(request.getStoreId()); + record.setMemberId(member.getId()); + record.setMethod(request.getMethod()); + record.setDeviceId(request.getDeviceId()); + record.setOperatorId(request.getOperatorId()); + record.setOperatorName(request.getOperatorName()); + record.setStatus(CheckinStatus.SUCCESS); + record.setCheckinAt(LocalDateTime.now()); + record.setCheckinDate(LocalDate.now()); + + if (deduction != null) { + record.setBenefitId(deduction.benefitId()); + record.setBenefitType(deduction.benefitType()); + record.setBenefitValue(deduction.benefitValue()); + } + + return record; + } + + @Override + public Mono cancelCheckin(Long checkinId, String reason) { + return checkinRepository.findById(checkinId) + .switchIfEmpty(Mono.error(new CheckinException(CheckinException.CHECKIN_NOT_FOUND))) + .flatMap(record -> { + if (record.getStatus() == CheckinStatus.CANCELLED) { + return Mono.error(new CheckinException(CheckinException.ALREADY_CANCELLED)); + } + + record.setStatus(CheckinStatus.CANCELLED); + record.setFailReason(reason); + + return checkinRepository.save(record) + .flatMap(saved -> { + if (saved.getBenefitId() != null) { + return benefitService.refund( + saved.getBenefitId(), + saved.getBenefitValue(), + "取消签到退款" + ); + } + return Mono.empty(); + }); + }) + .then(); + } + + @Override + public Mono validateCheckinEligibility(Long memberId, CheckinType type, Long bookingId) { + return memberRepository.findById(memberId) + .flatMap(member -> { + if (member.getStatus() != MemberStatus.ACTIVE) { + return Mono.just(false); + } + + if (type == CheckinType.ENTRY) { + return benefitService.hasValidBenefit(memberId, BenefitType.TIME); + } + + if (bookingId != null) { + return bookingRepository.findById(bookingId) + .map(booking -> booking.getStatus() == BookingStatus.CONFIRMED + && booking.getCheckinStatus() == CheckinStatus.NOT_CHECKED); + } + + return Mono.just(true); + }) + .switchIfEmpty(Mono.just(false)); + } +} +``` + +### 6.2 人脸识别服务实现 + +```java +@Slf4j +@Service +@RequiredArgsConstructor +public class FaceRecognitionServiceImpl implements FaceRecognitionService { + + private final MemberFaceRepository faceRepository; + private final MemberRepository memberRepository; + private final FaceFeatureExtractor featureExtractor; + private final Cache faceFeatureCache; + private final TransactionalOperator rxtx; + + private static final float MATCH_THRESHOLD = 0.85f; + private static final float QUALITY_THRESHOLD = 60.0f; + + @Override + public Mono registerFace(Long memberId, byte[] faceImage) { + return Mono.fromCallable(() -> featureExtractor.extractFeature(faceImage)) + .subscribeOn(Schedulers.boundedElastic()) + .flatMap(featureResult -> { + if (featureResult.qualityScore() < QUALITY_THRESHOLD) { + return Mono.error(new FaceException( + FaceException.QUALITY_TOO_LOW, + "人脸质量分数过低: " + featureResult.qualityScore() + )); + } + + return faceRepository.existsByMemberId(memberId) + .flatMap(exists -> { + if (exists) { + return Mono.error(new FaceException( + FaceException.FACE_ALREADY_REGISTERED + )); + } + + MemberFace face = new MemberFace(); + face.setMemberId(memberId); + face.setFaceFeature(featureResult.feature()); + face.setQualityScore(featureResult.qualityScore()); + face.setFeatureVersion("v1.0"); + face.setStatus(FaceStatus.ACTIVE); + + return faceRepository.save(face) + .doOnNext(saved -> faceFeatureCache.put(memberId, saved.getFaceFeature())) + .thenReturn(true); + }); + }) + .as(rxtx::transactional); + } + + @Override + public Mono matchFace(byte[] faceFeature, Long tenantId) { + return Mono.fromCallable(() -> { + List faces = faceRepository.findAllByTenantIdAndStatus( + tenantId, + FaceStatus.ACTIVE + ); + + float maxSimilarity = 0; + Long matchedMemberId = null; + + for (MemberFace face : faces) { + byte[] cachedFeature = faceFeatureCache.getIfPresent(face.getMemberId()); + byte[] targetFeature = cachedFeature != null ? cachedFeature : face.getFaceFeature(); + + float similarity = featureExtractor.compareFeature(faceFeature, targetFeature); + + if (similarity > maxSimilarity && similarity >= MATCH_THRESHOLD) { + maxSimilarity = similarity; + matchedMemberId = face.getMemberId(); + } + } + + return matchedMemberId; + }) + .subscribeOn(Schedulers.boundedElastic()) + .flatMap(memberId -> { + if (memberId == null) { + return Mono.error(new FaceException(FaceException.FACE_NOT_MATCHED)); + } + + return faceRepository.updateMatchInfo(memberId, LocalDateTime.now()) + .thenReturn(memberId); + }); + } + + @Override + public Mono updateFace(Long memberId, byte[] faceImage) { + return Mono.fromCallable(() -> featureExtractor.extractFeature(faceImage)) + .subscribeOn(Schedulers.boundedElastic()) + .flatMap(featureResult -> { + if (featureResult.qualityScore() < QUALITY_THRESHOLD) { + return Mono.error(new FaceException( + FaceException.QUALITY_TOO_LOW, + "人脸质量分数过低: " + featureResult.qualityScore() + )); + } + + return faceRepository.findByMemberId(memberId) + .switchIfEmpty(Mono.error(new FaceException(FaceException.FACE_NOT_FOUND))) + .flatMap(face -> { + face.setFaceFeature(featureResult.feature()); + face.setQualityScore(featureResult.qualityScore()); + face.setStatus(FaceStatus.ACTIVE); + + return faceRepository.save(face) + .doOnNext(saved -> faceFeatureCache.put(memberId, saved.getFaceFeature())) + .thenReturn(true); + }); + }) + .as(rxtx::transactional); + } + + @Override + public Mono deleteFace(Long memberId) { + return faceRepository.deleteByMemberId(memberId) + .doOnSuccess(v -> faceFeatureCache.invalidate(memberId)) + .then(); + } +} +``` + +### 6.3 签到网关实现 + +```java +@Slf4j +@Service +@RequiredArgsConstructor +public class CheckinGateway { + + private final CheckinDomainService checkinService; + private final MemberRepository memberRepository; + private final QRCodeValidator qrCodeValidator; + private final NFCService nfcService; + + public Mono processQRCode(CheckinQRCodeRequest request) { + return Mono.defer(() -> { + QRCodeInfo qrInfo = qrCodeValidator.parseAndValidate(request.getQrcode()); + + if (qrInfo.isExpired()) { + return Mono.just(CheckinResult.failure("二维码已过期,请刷新")); + } + + CheckinRequest checkinRequest = new CheckinRequest(); + checkinRequest.setTenantId(request.getTenantId()); + checkinRequest.setStoreId(request.getStoreId()); + checkinRequest.setMemberId(qrInfo.getMemberId()); + checkinRequest.setMethod(CheckinMethod.QRCODE); + checkinRequest.setDeviceId(request.getDeviceId()); + checkinRequest.setType(CheckinType.fromCode(request.getType())); + checkinRequest.setBookingId(request.getBookingId()); + + return checkinService.processCheckin(checkinRequest); + }); + } + + public Mono processNFC(CheckinNFCRequest request) { + return nfcService.getMemberByNFC(request.getNfcId()) + .flatMap(member -> { + CheckinRequest checkinRequest = new CheckinRequest(); + checkinRequest.setTenantId(request.getTenantId()); + checkinRequest.setStoreId(request.getStoreId()); + checkinRequest.setMemberId(member.getId()); + checkinRequest.setMethod(CheckinMethod.NFC); + checkinRequest.setDeviceId(request.getDeviceId()); + checkinRequest.setType(CheckinType.fromCode(request.getType())); + checkinRequest.setBookingId(request.getBookingId()); + + return checkinService.processCheckin(checkinRequest); + }) + .onErrorResume(e -> { + if (e instanceof NFCException) { + return Mono.just(CheckinResult.failure("NFC卡未绑定会员")); + } + return Mono.error(e); + }); + } + + public Mono processManual(CheckinManualRequest request) { + return Mono.defer(() -> { + CheckinRequest checkinRequest = new CheckinRequest(); + checkinRequest.setTenantId(request.getTenantId()); + checkinRequest.setStoreId(request.getStoreId()); + checkinRequest.setMemberId(request.getMemberId()); + checkinRequest.setMethod(CheckinMethod.MANUAL); + checkinRequest.setType(CheckinType.PRIVATE); + checkinRequest.setBookingId(request.getBookingId()); + checkinRequest.setOperatorId(request.getOperatorId()); + checkinRequest.setOperatorName(request.getOperatorName()); + + return checkinService.processCheckin(checkinRequest); + }); + } +} +``` + +### 6.4 签到统计服务实现 + +```java +@Slf4j +@Service +@RequiredArgsConstructor +public class CheckinStatisticsServiceImpl implements CheckinStatisticsService { + + private final CheckinRecordRepository checkinRepository; + private final CheckinStatisticsRepository statisticsRepository; + private final MemberRepository memberRepository; + + @Override + public Mono generateDailyStatistics(Long tenantId, Long storeId, LocalDate date) { + return Mono.defer(() -> { + LocalDateTime startOfDay = date.atStartOfDay(); + LocalDateTime endOfDay = startOfDay.plusDays(1); + + Mono totalCount = checkinRepository.countByTenantIdAndStoreIdAndCheckinAtBetween( + tenantId, storeId, startOfDay, endOfDay + ); + + Mono entryCount = checkinRepository.countByTenantIdAndStoreIdAndTypeAndCheckinAtBetween( + tenantId, storeId, CheckinType.ENTRY, startOfDay, endOfDay + ); + + Mono courseCount = checkinRepository.countByTenantIdAndStoreIdAndTypeAndCheckinAtBetween( + tenantId, storeId, CheckinType.COURSE, startOfDay, endOfDay + ); + + Mono privateCount = checkinRepository.countByTenantIdAndStoreIdAndTypeAndCheckinAtBetween( + tenantId, storeId, CheckinType.PRIVATE, startOfDay, endOfDay + ); + + Mono activityCount = checkinRepository.countByTenantIdAndStoreIdAndTypeAndCheckinAtBetween( + tenantId, storeId, CheckinType.ACTIVITY, startOfDay, endOfDay + ); + + Mono activeMemberCount = checkinRepository.countDistinctMemberByTenantIdAndStoreIdAndCheckinAtBetween( + tenantId, storeId, startOfDay, endOfDay + ); + + Mono> hourlyDistribution = checkinRepository + .findHourlyDistribution(tenantId, storeId, startOfDay, endOfDay) + .collectMap(CheckinHourlyStats::getHour, CheckinHourlyStats::getCount); + + return Mono.zip(totalCount, entryCount, courseCount, privateCount, + activityCount, activeMemberCount, hourlyDistribution) + .flatMap(tuple -> { + CheckinStatistics stats = new CheckinStatistics(); + stats.setTenantId(tenantId); + stats.setStoreId(storeId); + stats.setStatDate(date); + stats.setStatType(StatType.DAILY); + stats.setTotalCount(tuple.getT1().intValue()); + stats.setEntryCount(tuple.getT2().intValue()); + stats.setCourseCount(tuple.getT3().intValue()); + stats.setPrivateCount(tuple.getT4().intValue()); + stats.setActivityCount(tuple.getT5().intValue()); + stats.setActiveMemberCount(tuple.getT6()); + + Map hourly = tuple.getT7(); + if (!hourly.isEmpty()) { + stats.setPeakHour( + hourly.entrySet().stream() + .max(Map.Entry.comparingByValue()) + .map(Map.Entry::getKey) + .orElse(null) + ); + stats.setPeakCount( + hourly.values().stream() + .max(Long::compare) + .map(Long::intValue) + .orElse(0) + ); + } + + return statisticsRepository.save(stats); + }) + .then(); + }); + } + + @Override + public Mono getDailyStatistics(Long tenantId, Long storeId, LocalDate date) { + return statisticsRepository.findByTenantIdAndStoreIdAndStatDateAndStatType( + tenantId, storeId, date, StatType.DAILY + ); + } + + @Override + public Mono> getMemberCheckinStats(Long memberId, + LocalDate startDate, LocalDate endDate) { + LocalDateTime start = startDate.atStartOfDay(); + LocalDateTime end = endDate.plusDays(1).atStartOfDay(); + + Mono totalCount = checkinRepository.countByMemberIdAndCheckinAtBetween( + memberId, start, end + ); + + Mono> typeDistribution = checkinRepository + .countByMemberIdGroupByType(memberId, start, end) + .collectMap(CheckinTypeStats::getType, CheckinTypeStats::getCount); + + Mono> checkinDates = checkinRepository + .findDistinctCheckinDatesByMemberId(memberId, start, end) + .collectList(); + + return Mono.zip(totalCount, typeDistribution, checkinDates) + .map(tuple -> { + Map result = new HashMap<>(); + result.put("totalCount", tuple.getT1()); + result.put("typeDistribution", tuple.getT2()); + result.put("checkinDays", tuple.getT3().size()); + result.put("checkinDates", tuple.getT3()); + return result; + }); + } +} +``` + +--- + +## 七、高并发处理 + +### 7.1 签到并发场景分析 + +| 场景 | 并发特点 | 处理策略 | +|------|---------|---------| +| 早高峰入场 | 短时间内大量签到请求 | 本地缓存+异步处理 | +| 课程签到窗口 | 集中签到时段 | 预加载+限流 | +| 人脸识别匹配 | 计算密集型 | 特征缓存+批量匹配 | +| 统计计算 | 数据量大 | 异步任务+增量计算 | + +### 7.2 签到限流设计 + +```java +@Slf4j +@Component +@RequiredArgsConstructor +public class CheckinRateLimiter { + + private final Cache rateLimitCache = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofSeconds(1)) + .build(); + + private static final int MAX_REQUESTS_PER_SECOND = 100; + + public Mono allowRequest(Long tenantId, Long storeId) { + String key = tenantId + ":" + storeId + ":" + System.currentTimeMillis() / 1000; + + return Mono.fromCallable(() -> { + AtomicInteger counter = rateLimitCache.get(key, k -> new AtomicInteger(0)); + return counter.incrementAndGet() <= MAX_REQUESTS_PER_SECOND; + }); + } + + public Mono withRateLimit(Long tenantId, Long storeId, Mono action) { + return allowRequest(tenantId, storeId) + .flatMap(allowed -> { + if (allowed) { + return action; + } + return Mono.error(new CheckinException( + CheckinException.RATE_LIMIT_EXCEEDED, + "签到请求过于频繁,请稍后重试" + )); + }); + } +} +``` + +### 7.3 人脸特征缓存设计 + +```java +@Slf4j +@Component +public class FaceFeatureCacheManager { + + private final Cache featureCache = Caffeine.newBuilder() + .maximumSize(10000) + .expireAfterAccess(Duration.ofHours(24)) + .recordStats() + .build(); + + private final MemberFaceRepository faceRepository; + + @Scheduled(fixedRate = 300000) + public void preloadFeatures() { + log.info("开始预加载人脸特征..."); + + faceRepository.findAllByStatus(FaceStatus.ACTIVE) + .doOnNext(face -> featureCache.put(face.getMemberId(), face.getFaceFeature())) + .then() + .subscribe( + v -> log.info("人脸特征预加载完成"), + e -> log.error("人脸特征预加载失败", e) + ); + } + + public Optional getFeature(Long memberId) { + return Optional.ofNullable(featureCache.getIfPresent(memberId)); + } + + public void putFeature(Long memberId, byte[] feature) { + featureCache.put(memberId, feature); + } + + public void invalidate(Long memberId) { + featureCache.invalidate(memberId); + } + + public CacheStats getStats() { + return featureCache.stats(); + } +} +``` + +### 7.4 异步签到处理 + +```java +@Slf4j +@Service +@RequiredArgsConstructor +public class AsyncCheckinProcessor { + + private final CheckinRecordRepository checkinRepository; + private final ApplicationEventPublisher eventPublisher; + private final Sinks.Many checkinSink; + + @PostConstruct + public void init() { + checkinSink.asFlux() + .flatMap(this::processAsync, 10) + .subscribe( + v -> {}, + e -> log.error("异步签到处理错误", e) + ); + } + + public Mono submitAsync(CheckinTask task) { + return Mono.fromCallable(() -> { + checkinSink.tryEmitNext(task); + return task.getTaskId(); + }); + } + + private Mono processAsync(CheckinTask task) { + return processCheckin(task) + .flatMap(record -> { + eventPublisher.publishEvent(new CheckinSuccessEvent(record)); + return Mono.empty(); + }) + .onErrorResume(e -> { + log.error("异步签到处理失败: taskId={}", task.getTaskId(), e); + return saveFailedRecord(task, e); + }) + .then(); + } + + private Mono processCheckin(CheckinTask task) { + // 签到处理逻辑 + } + + private Mono saveFailedRecord(CheckinTask task, Throwable e) { + CheckinRecord record = new CheckinRecord(); + record.setStatus(CheckinStatus.FAILED); + record.setFailReason(e.getMessage()); + // 设置其他字段... + + return checkinRepository.save(record).then(); + } +} +``` + +--- + +## 八、缓存设计 + +### 8.1 缓存策略 + +| 数据类型 | 缓存位置 | 过期时间 | 更新策略 | +|---------|---------|---------|---------| +| 会员信息 | 本地缓存 | 30分钟 | 写时更新 | +| 人脸特征 | 本地缓存 | 24小时 | 定时刷新 | +| 签到规则 | 本地缓存 | 1小时 | 写时更新 | +| 签到统计 | 本地缓存 | 5分钟 | 定时计算 | +| 设备状态 | 本地缓存 | 1分钟 | 心跳更新 | + +### 8.2 缓存配置 + +```java +@Configuration +public class CheckinCacheConfig { + + @Bean + public Cache memberCache() { + return Caffeine.newBuilder() + .maximumSize(5000) + .expireAfterWrite(Duration.ofMinutes(30)) + .recordStats() + .build(); + } + + @Bean + public Cache faceFeatureCache() { + return Caffeine.newBuilder() + .maximumSize(10000) + .expireAfterAccess(Duration.ofHours(24)) + .recordStats() + .build(); + } + + @Bean + public Cache ruleCache() { + return Caffeine.newBuilder() + .maximumSize(100) + .expireAfterWrite(Duration.ofHours(1)) + .build(); + } +} + +--- + +## 九、定时任务设计 + +### 9.1 统计任务 + +```java +@Slf4j +@Component +@RequiredArgsConstructor +public class CheckinStatisticsScheduler { + + private final CheckinStatisticsService statisticsService; + + @Scheduled(cron = "0 5 0 * * ?") + public void generateYesterdayStatistics() { + LocalDate yesterday = LocalDate.now().minusDays(1); + statisticsService.generateDailyStatistics(null, null, yesterday) + .subscribe( + v -> log.info("昨日签到统计生成完成: {}", yesterday), + e -> log.error("昨日签到统计生成失败", e) + ); + } + + @Scheduled(cron = "0 0 */1 * * ?") + public void generateTodayStatistics() { + LocalDate today = LocalDate.now(); + statisticsService.generateDailyStatistics(null, null, today) + .subscribe( + v -> log.info("今日签到统计更新完成: {}", today), + e -> log.error("今日签到统计更新失败", e) + ); + } +} +``` + +### 9.2 设备心跳检测 + +```java +@Slf4j +@Component +@RequiredArgsConstructor +public class DeviceHeartbeatScheduler { + + private final CheckinDeviceRepository deviceRepository; + + @Scheduled(fixedRate = 60000) + public void checkDeviceStatus() { + LocalDateTime threshold = LocalDateTime.now().minusMinutes(5); + + deviceRepository.findAllByStatus(DeviceStatus.ONLINE) + .filter(device -> device.getLastHeartbeat().isBefore(threshold)) + .flatMap(device -> { + device.setStatus(DeviceStatus.OFFLINE); + return deviceRepository.save(device); + }) + .subscribe( + device -> log.warn("设备离线: {}", device.getName()), + e -> log.error("设备状态检测失败", e) + ); + } +} +``` + +--- + +## 十、异常处理 + +### 10.1 异常定义 + +```java +public class CheckinException extends RuntimeException { + + public static final String MEMBER_NOT_FOUND = "MEMBER_NOT_FOUND"; + public static final String MEMBER_INACTIVE = "MEMBER_INACTIVE"; + public static final String BOOKING_NOT_FOUND = "BOOKING_NOT_FOUND"; + public static final String BOOKING_NOT_MATCH = "BOOKING_NOT_MATCH"; + public static final String BOOKING_NOT_CONFIRMED = "BOOKING_NOT_CONFIRMED"; + public static final String ALREADY_CHECKED = "ALREADY_CHECKED"; + public static final String COURSE_ENDED = "COURSE_ENDED"; + public static final String DAILY_LIMIT_EXCEEDED = "DAILY_LIMIT_EXCEEDED"; + public static final String INTERVAL_NOT_MET = "INTERVAL_NOT_MET"; + public static final String RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED"; + public static final String CHECKIN_NOT_FOUND = "CHECKIN_NOT_FOUND"; + public static final String ALREADY_CANCELLED = "ALREADY_CANCELLED"; + + private final String code; + + public CheckinException(String code) { + super(getMessage(code)); + this.code = code; + } + + public CheckinException(String code, String message) { + super(message); + this.code = code; + } + + private static String getMessage(String code) { + return switch (code) { + case MEMBER_NOT_FOUND -> "会员不存在"; + case MEMBER_INACTIVE -> "会员状态异常"; + case BOOKING_NOT_FOUND -> "预约记录不存在"; + case BOOKING_NOT_MATCH -> "预约信息不匹配"; + case BOOKING_NOT_CONFIRMED -> "预约未确认"; + case ALREADY_CHECKED -> "已签到"; + case COURSE_ENDED -> "课程已结束"; + case DAILY_LIMIT_EXCEEDED -> "签到次数超限"; + case INTERVAL_NOT_MET -> "签到间隔不足"; + case RATE_LIMIT_EXCEEDED -> "请求过于频繁"; + case CHECKIN_NOT_FOUND -> "签到记录不存在"; + case ALREADY_CANCELLED -> "签到已取消"; + default -> "签到异常"; + }; + } +} + +public class FaceException extends RuntimeException { + + public static final String QUALITY_TOO_LOW = "QUALITY_TOO_LOW"; + public static final String FACE_ALREADY_REGISTERED = "FACE_ALREADY_REGISTERED"; + public static final String FACE_NOT_FOUND = "FACE_NOT_FOUND"; + public static final String FACE_NOT_MATCHED = "FACE_NOT_MATCHED"; + + private final String code; + + public FaceException(String code) { + super(getMessage(code)); + this.code = code; + } + + public FaceException(String code, String message) { + super(message); + this.code = code; + } + + private static String getMessage(String code) { + return switch (code) { + case QUALITY_TOO_LOW -> "人脸质量分数过低"; + case FACE_ALREADY_REGISTERED -> "人脸已注册"; + case FACE_NOT_FOUND -> "人脸信息不存在"; + case FACE_NOT_MATCHED -> "人脸匹配失败"; + default -> "人脸识别异常"; + }; + } +} +``` + +### 10.2 全局异常处理 + +```java +@Slf4j +@RestControllerAdvice +public class CheckinExceptionHandler { + + @ExceptionHandler(CheckinException.class) + public ResponseEntity> handleCheckinException(CheckinException e) { + log.warn("签到异常: {}", e.getMessage()); + return ResponseEntity.badRequest() + .body(ApiResponse.error(e.getCode(), e.getMessage())); + } + + @ExceptionHandler(FaceException.class) + public ResponseEntity> handleFaceException(FaceException e) { + log.warn("人脸识别异常: {}", e.getMessage()); + return ResponseEntity.badRequest() + .body(ApiResponse.error(e.getCode(), e.getMessage())); + } +} +``` + +--- + +## 十一、附录 + +### 11.1 枚举定义 + +| 枚举类型 | 值 | 说明 | +|---------|---|------| +| CheckinType | 1 | 入场签到 | +| CheckinType | 2 | 课程签到 | +| CheckinType | 3 | 私教签到 | +| CheckinType | 4 | 活动签到 | +| CheckinMethod | 1 | 二维码 | +| CheckinMethod | 2 | 人脸识别 | +| CheckinMethod | 3 | NFC | +| CheckinMethod | 4 | 教练代签 | +| CheckinStatus | 1 | 成功 | +| CheckinStatus | 2 | 失败 | +| CheckinStatus | 3 | 已取消 | +| DeviceType | 1 | 人脸识别机 | +| DeviceType | 2 | NFC读卡器 | +| DeviceType | 3 | 扫码枪 | +| DeviceType | 4 | 一体机 | +| DeviceStatus | 1 | 在线 | +| DeviceStatus | 2 | 离线 | +| DeviceStatus | 3 | 维护中 | +| FaceStatus | 1 | 正常 | +| FaceStatus | 2 | 待更新 | +| FaceStatus | 3 | 已禁用 | + +### 11.2 错误码定义 + +| 错误码 | 说明 | 处理建议 | +|-------|------|---------| +| MEMBER_NOT_FOUND | 会员不存在 | 检查会员ID | +| MEMBER_INACTIVE | 会员状态异常 | 联系工作人员 | +| BOOKING_NOT_FOUND | 预约不存在 | 检查预约ID | +| BOOKING_NOT_MATCH | 预约不匹配 | 确认预约信息 | +| BOOKING_NOT_CONFIRMED | 预约未确认 | 等待确认 | +| ALREADY_CHECKED | 已签到 | 无需重复签到 | +| COURSE_ENDED | 课程已结束 | 无法签到 | +| DAILY_LIMIT_EXCEEDED | 签到次数超限 | 明日再来 | +| INTERVAL_NOT_MET | 签到间隔不足 | 稍后重试 | +| RATE_LIMIT_EXCEEDED | 请求过于频繁 | 稍后重试 | +| QUALITY_TOO_LOW | 人脸质量低 | 重新拍照 | +| FACE_NOT_MATCHED | 人脸匹配失败 | 重新注册或使用其他方式 | + +--- + +## 十二、版本历史 + +| 版本 | 日期 | 作者 | 变更内容 | +|------|------|------|---------| +| v1.0 | 2026-02-28 | 张翔 | 初稿 | diff --git a/docs/design/LLD-预约模块详细设计.md b/docs/design/LLD-预约模块详细设计.md new file mode 100644 index 0000000..ce89422 --- /dev/null +++ b/docs/design/LLD-预约模块详细设计.md @@ -0,0 +1,1236 @@ +# 健身房管理系统详细设计文档 - 预约模块(LLD) + +> 文档编号: GYM-LLD-002 +> 版本: v1.0 +> 日期: 2026-02-28 +> 作者: 张翔 +> 状态: 初稿 + +--- + +## 文档修订历史 + +| 版本 | 日期 | 作者 | 修订内容 | +|------|------|------|---------| +| v1.0 | 2026-02-28 | 张翔 | 初稿 | + +--- + +## 一、模块概述 + +### 1.1 模块定位 + +预约模块是健身房管理系统的核心业务模块,负责管理各类资源的预约,包括: + +- 团课预约:会员预约团体课程 +- 私教预约:会员预约私教课程 +- 场地预约:会员预约运动场地 +- 线上课程预约:会员预约线上直播课程 + +### 1.2 模块边界 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 预约模块边界 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 预约模块内部 │ │ +│ │ │ │ +│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌──────────┐ │ │ +│ │ │ 课程管理 │ │ 时段管理 │ │ 预约管理 │ │ 库存管理 │ │ │ +│ │ └───────────┘ └───────────┘ └───────────┘ └──────────┘ │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 外部依赖: │ +│ ├── 会员模块: 查询会员权益、扣减权益 │ +│ ├── 教练模块: 查询教练信息、排班 │ +│ ├── 场地模块: 查询场地信息、可用性 │ +│ └── 消息模块: 发送预约通知 │ +│ │ +│ 被依赖: │ +│ ├── 签到模块: 查询预约信息、验证签到资格 │ +│ ├── 财务模块: 查询预约消费记录 │ +│ └── 数据模块: 预约数据分析 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 二、数据模型设计 + +### 2.1 实体关系图 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 预约模块ER图 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ coach │ │ course │ │ venue │ │ +│ │─────────────│ │─────────────│ │─────────────│ │ +│ │ id │ │ id │ │ id │ │ +│ │ name │ │ tenant_id │ │ tenant_id │ │ +│ │ specialty │ │ name │ │ name │ │ +│ │ status │ │ type │ │ type │ │ +│ └──────┬──────┘ │ category │ │ capacity │ │ +│ │ │ duration │ │ status │ │ +│ │ │ capacity │ └──────┬──────┘ │ +│ │ │ status │ │ │ +│ │ └──────┬──────┘ │ │ +│ │ │ │ │ +│ │ ┌────────────────┼─────────────────────┘ │ +│ │ │ │ │ +│ │ ▼ ▼ │ +│ │ ┌─────────────────────────────┐ │ +│ │ │ booking_slot │ │ +│ │ │─────────────────────────────│ │ +│ │ │ id │ │ +│ │ │ tenant_id │ │ +│ │ │ resource_type (course/venue)│ │ +│ │ │ resource_id │ │ +│ │ │ coach_id │◀──────────┐ │ +│ │ │ venue_id │ │ │ +│ │ │ start_time │ │ │ +│ │ │ end_time │ │ │ +│ │ │ capacity │ │ │ +│ │ │ booked_count │ │ │ +│ │ │ status │ │ │ +│ │ └─────────────┬───────────────┘ │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ ┌─────────────────────────────┐ │ │ +│ │ │ booking_record │ │ │ +│ │ │─────────────────────────────│ │ │ +│ │ │ id │ │ │ +│ │ │ tenant_id │ │ │ +│ │ │ member_id │ │ │ +│ │ │ slot_id │───────────┘ │ +│ │ │ coach_id │ │ +│ │ │ status │ │ +│ │ │ price │ │ +│ │ │ checkin_status │ │ +│ │ └─────────────────────────────┘ │ +│ │ │ +│ └────────────────────────────────────────────────────────────┘ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 数据表设计 + +#### 2.2.1 课程表 (course) + +```sql +CREATE TABLE course ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + name VARCHAR(128) NOT NULL, + code VARCHAR(32), + type SMALLINT NOT NULL, -- 1:团课 2:私教 3:线上 + category VARCHAR(64), -- 课程分类 + description TEXT, + cover_image VARCHAR(512), + duration INT NOT NULL, -- 课程时长(分钟) + capacity INT DEFAULT 20, -- 最大人数 + min_capacity INT DEFAULT 1, -- 最少开课人数 + difficulty SMALLINT DEFAULT 1, -- 1:入门 2:初级 3:中级 4:高级 + calories INT, -- 消耗卡路里 + equipment VARCHAR(256), -- 所需器材 + benefits JSONB, -- 课程收益 + price DECIMAL(10,2), -- 单次价格 + price_type SMALLINT DEFAULT 1, -- 1:扣次 2:扣时长 3:扣金额 + price_value DECIMAL(10,2), -- 扣减值 + advance_days INT DEFAULT 7, -- 可提前预约天数 + cancel_hours INT DEFAULT 2, -- 可取消小时数 + cancel_penalty DECIMAL(3,2) DEFAULT 0.00, -- 取消扣款比例 + status SMALLINT DEFAULT 1, -- 1:上架 2:下架 + sort_order INT DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + created_by BIGINT, + updated_by BIGINT, + deleted_at TIMESTAMP DEFAULT NULL, + + CONSTRAINT fk_course_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id) +); + +CREATE INDEX idx_course_tenant ON course(tenant_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_course_type ON course(type) WHERE deleted_at IS NULL; +CREATE INDEX idx_course_status ON course(status) WHERE deleted_at IS NULL; +``` + +#### 2.2.2 场地表 (venue) + +```sql +CREATE TABLE venue ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + store_id BIGINT NOT NULL, + name VARCHAR(128) NOT NULL, + code VARCHAR(32), + type SMALLINT NOT NULL, -- 1:瑜伽室 2:动感单车 3:力量区 4:游泳池 + area DECIMAL(8,2), -- 面积(平方米) + capacity INT NOT NULL, -- 最大容量 + facilities JSONB, -- 设施配置 + open_time TIME, -- 开放时间 + close_time TIME, -- 关闭时间 + price_per_hour DECIMAL(10,2), -- 每小时价格 + status SMALLINT DEFAULT 1, -- 1:可用 2:维护中 3:已停用 + images JSONB, -- 场地图片 + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + created_by BIGINT, + updated_by BIGINT, + deleted_at TIMESTAMP DEFAULT NULL, + + CONSTRAINT fk_venue_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id), + CONSTRAINT fk_venue_store FOREIGN KEY (store_id) REFERENCES store(id) +); + +CREATE INDEX idx_venue_tenant ON venue(tenant_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_venue_store ON venue(store_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_venue_status ON venue(status) WHERE deleted_at IS NULL; +``` + +#### 2.2.3 预约时段表 (booking_slot) + +```sql +CREATE TABLE booking_slot ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + store_id BIGINT NOT NULL, + resource_type SMALLINT NOT NULL, -- 1:团课 2:私教 3:场地 4:线上 + resource_id BIGINT NOT NULL, -- 课程ID或场地ID + coach_id BIGINT, -- 教练ID(私教必填) + venue_id BIGINT, -- 场地ID + title VARCHAR(128), -- 时段标题 + start_time TIMESTAMP NOT NULL, -- 开始时间 + end_time TIMESTAMP NOT NULL, -- 结束时间 + capacity INT NOT NULL, -- 容量 + booked_count INT DEFAULT 0, -- 已预约人数 + waitlist_count INT DEFAULT 0, -- 候补人数 + min_capacity INT DEFAULT 1, -- 最少开课人数 + status SMALLINT DEFAULT 1, -- 1:可预约 2:已满 3:已取消 4:已结束 + price DECIMAL(10,2), -- 价格 + price_type SMALLINT DEFAULT 1, -- 1:扣次 2:扣时长 3:扣金额 + price_value DECIMAL(10,2), -- 扣减值 + booking_start TIMESTAMP, -- 开放预约时间 + booking_end TIMESTAMP, -- 截止预约时间 + cancel_deadline TIMESTAMP, -- 取消截止时间 + version INT DEFAULT 0, -- 乐观锁版本号 + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + created_by BIGINT, + deleted_at TIMESTAMP DEFAULT NULL, + + CONSTRAINT fk_slot_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id), + CONSTRAINT fk_slot_store FOREIGN KEY (store_id) REFERENCES store(id) +); + +CREATE INDEX idx_slot_tenant ON booking_slot(tenant_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_slot_store ON booking_slot(store_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_slot_resource ON booking_slot(resource_type, resource_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_slot_coach ON booking_slot(coach_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_slot_time ON booking_slot(start_time, end_time) WHERE deleted_at IS NULL; +CREATE INDEX idx_slot_status ON booking_slot(status) WHERE deleted_at IS NULL; +``` + +#### 2.2.4 预约记录表 (booking_record) + +```sql +CREATE TABLE booking_record ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + store_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + slot_id BIGINT NOT NULL, + resource_type SMALLINT NOT NULL, + resource_id BIGINT NOT NULL, + coach_id BIGINT, + booking_no VARCHAR(32) NOT NULL, -- 预约编号 + status SMALLINT DEFAULT 1, -- 1:已预约 2:已取消 3:已完成 4:已过期 + price DECIMAL(10,2), -- 价格 + price_type SMALLINT, -- 价格类型 + price_value DECIMAL(10,2), -- 扣减值 + benefit_id BIGINT, -- 扣减的权益ID + source VARCHAR(32), -- 来源: app/miniprogram/staff + cancel_reason VARCHAR(256), -- 取消原因 + cancel_by BIGINT, -- 取消人 + cancel_at TIMESTAMP, -- 取消时间 + checkin_status SMALLINT DEFAULT 0, -- 0:未签到 1:已签到 2:迟到 3:缺席 + checkin_at TIMESTAMP, -- 签到时间 + checkin_by BIGINT, -- 签到操作人 + rating SMALLINT, -- 评分 1-5 + comment TEXT, -- 评价内容 + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + deleted_at TIMESTAMP DEFAULT NULL, + + CONSTRAINT uk_booking_no UNIQUE (tenant_id, booking_no), + CONSTRAINT fk_booking_member FOREIGN KEY (member_id) REFERENCES member(id), + CONSTRAINT fk_booking_slot FOREIGN KEY (slot_id) REFERENCES booking_slot(id) +); + +CREATE INDEX idx_booking_member ON booking_record(member_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_booking_slot ON booking_record(slot_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_booking_coach ON booking_record(coach_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_booking_status ON booking_record(status) WHERE deleted_at IS NULL; +CREATE INDEX idx_booking_time ON booking_record(created_at) WHERE deleted_at IS NULL; +CREATE INDEX idx_booking_checkin ON booking_record(checkin_status) WHERE deleted_at IS NULL; +``` + +#### 2.2.5 预约候补表 (booking_waitlist) + +```sql +CREATE TABLE booking_waitlist ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + slot_id BIGINT NOT NULL, + queue_no INT NOT NULL, -- 排队序号 + status SMALLINT DEFAULT 1, -- 1:排队中 2:已转正 3:已取消 + expire_at TIMESTAMP, -- 转正过期时间 + notified_at TIMESTAMP, -- 通知时间 + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + deleted_at TIMESTAMP DEFAULT NULL, + + CONSTRAINT fk_waitlist_member FOREIGN KEY (member_id) REFERENCES member(id), + CONSTRAINT fk_waitlist_slot FOREIGN KEY (slot_id) REFERENCES booking_slot(id) +); + +CREATE INDEX idx_waitlist_slot ON booking_waitlist(slot_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_waitlist_member ON booking_waitlist(member_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_waitlist_status ON booking_waitlist(status) WHERE deleted_at IS NULL; +``` + +#### 2.2.6 教练排班表 (coach_schedule) + +```sql +CREATE TABLE coach_schedule ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + store_id BIGINT NOT NULL, + coach_id BIGINT NOT NULL, + schedule_date DATE NOT NULL, -- 排班日期 + start_time TIME NOT NULL, -- 开始时间 + end_time TIME NOT NULL, -- 结束时间 + status SMALLINT DEFAULT 1, -- 1:上班 2:休息 3:请假 + remark VARCHAR(256), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + deleted_at TIMESTAMP DEFAULT NULL, + + CONSTRAINT fk_schedule_coach FOREIGN KEY (coach_id) REFERENCES coach(id), + CONSTRAINT uk_schedule UNIQUE (coach_id, schedule_date, start_time) +); + +CREATE INDEX idx_schedule_coach ON coach_schedule(coach_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_schedule_date ON coach_schedule(schedule_date) WHERE deleted_at IS NULL; +``` + +--- + +## 三、领域模型设计 + +### 3.1 领域模型类图 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 预约领域模型 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ <> │ │ +│ │ BookingSlot │ │ +│ ├───────────────────────────────────────────────────────────────────┤ │ +│ │ - id: Long │ │ +│ │ - tenantId: Long │ │ +│ │ - storeId: Long │ │ +│ │ - resourceType: ResourceType │ │ +│ │ - resourceId: Long │ │ +│ │ - coachId: Long │ │ +│ │ - venueId: Long │ │ +│ │ - startTime: LocalDateTime │ │ +│ │ - endTime: LocalDateTime │ │ +│ │ - capacity: Integer │ │ +│ │ - bookedCount: Integer │ │ +│ │ - waitlistCount: Integer │ │ +│ │ - status: SlotStatus │ │ +│ │ - version: Integer │ │ +│ ├───────────────────────────────────────────────────────────────────┤ │ +│ │ + hasCapacity(): Boolean │ │ +│ │ + getRemainCapacity(): Integer │ │ +│ │ + canBook(): Boolean │ │ +│ │ + book(): void │ │ +│ │ + cancel(): void │ │ +│ │ + isExpired(): Boolean │ │ +│ │ + isFull(): Boolean │ │ +│ │ + addToWaitlist(): void │ │ +│ │ + removeFromWaitlist(): void │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ │ 1:N │ +│ ▼ │ +│ ┌────────────────────────────┐ ┌────────────────────────────┐ │ +│ │ <> │ │ <> │ │ +│ │ BookingRecord │ │ BookingWaitlist │ │ +│ ├────────────────────────────┤ ├────────────────────────────┤ │ +│ │ - id: Long │ │ - id: Long │ │ +│ │ - memberId: Long │ │ - memberId: Long │ │ +│ │ - slotId: Long │ │ - slotId: Long │ │ +│ │ - bookingNo: String │ │ - queueNo: Integer │ │ +│ │ - status: BookingStatus │ │ - status: WaitlistStatus │ │ +│ │ - priceType: PriceType │ │ - expireAt: LocalDateTime │ │ +│ │ - priceValue: BigDecimal │ ├────────────────────────────┤ │ +│ │ - checkinStatus: CheckinSt │ │ + isExpired(): Boolean │ │ +│ ├────────────────────────────┤ │ + convert(): void │ │ +│ │ + canCancel(): Boolean │ └────────────────────────────┘ │ +│ │ + cancel(): void │ │ +│ │ + checkin(): void │ │ +│ │ + isCheckinable(): Boolean │ │ +│ └────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────┐ ┌────────────────────────────┐ │ +│ │ <> │ │ <> │ │ +│ │ ResourceType │ │ SlotStatus │ │ +│ ├────────────────────────────┤ ├────────────────────────────┤ │ +│ │ GROUP_CLASS(1, "团课") │ │ AVAILABLE(1, "可预约") │ │ +│ │ PRIVATE(2, "私教") │ │ FULL(2, "已满") │ │ +│ │ VENUE(3, "场地") │ │ CANCELLED(3, "已取消") │ │ +│ │ ONLINE(4, "线上") │ │ ENDED(4, "已结束") │ │ +│ └────────────────────────────┘ └────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────┐ ┌────────────────────────────┐ │ +│ │ <> │ │ <> │ │ +│ │ BookingStatus │ │ PriceType │ │ +│ ├────────────────────────────┤ ├────────────────────────────┤ │ +│ │ BOOKED(1, "已预约") │ │ TIMES(1, "扣次") │ │ +│ │ CANCELLED(2, "已取消") │ │ DURATION(2, "扣时长") │ │ +│ │ COMPLETED(3, "已完成") │ │ AMOUNT(3, "扣金额") │ │ +│ │ EXPIRED(4, "已过期") │ └────────────────────────────┘ │ +│ └────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 3.2 领域服务 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 领域服务设计 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ <> │ │ +│ │ BookingDomainService │ │ +│ ├───────────────────────────────────────────────────────────────────┤ │ +│ │ + createBooking(command: CreateBookingCommand): BookingRecord │ │ +│ │ + cancelBooking(bookingId: Long, reason: String): void │ │ +│ │ + checkin(bookingId: Long): void │ │ +│ │ + addToWaitlist(memberId: Long, slotId: Long): void │ │ +│ │ + processWaitlist(slotId: Long): void │ │ +│ │ + validateBooking(memberId: Long, slotId: Long): void │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ <> │ │ +│ │ SlotDomainService │ │ +│ ├───────────────────────────────────────────────────────────────────┤ │ +│ │ + createSlot(command: CreateSlotCommand): BookingSlot │ │ +│ │ + batchCreateSlots(command: BatchSlotCommand): List │ │ +│ │ + updateSlot(slotId: Long, command: UpdateSlotCommand): void │ │ +│ │ + cancelSlot(slotId: Long, reason: String): void │ │ +│ │ + getAvailableSlots(query: SlotQuery): List │ │ +│ │ + incrementBookedCount(slotId: Long): Boolean │ │ +│ │ + decrementBookedCount(slotId: Long): void │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ <> │ │ +│ │ InventoryDomainService │ │ +│ ├───────────────────────────────────────────────────────────────────┤ │ +│ │ + checkInventory(slotId: Long): Boolean │ │ +│ │ + reserveInventory(slotId: Long, count: Integer): Boolean │ │ +│ │ + releaseInventory(slotId: Long, count: Integer): void │ │ +│ │ + preloadInventory(slotIds: List): void │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 四、业务流程设计 + +### 4.1 团课预约流程 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 团课预约流程 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 会员端 API层 BookingService BenefitService 数据层 │ +│ │ │ │ │ │ │ +│ │ 1.选择课程 │ │ │ │ │ +│ │─────────▶│ │ │ │ │ +│ │ │ 2.查询时段 │ │ │ │ +│ │ │─────────────▶│ │ │ │ +│ │ │ │ 3.查询可预约时段 │ │ │ +│ │ │ │───────────────────────────────▶│ │ +│ │ │ │◀───────────────────────────────│ │ +│ │◀─────────│ 返回时段列表 │ │ │ │ +│ │ │ │ │ │ │ +│ │ 4.提交预约 │ │ │ │ │ +│ │─────────▶│ │ │ │ │ +│ │ │ 5.创建预约 │ │ │ │ +│ │ │─────────────▶│ │ │ │ +│ │ │ │ 6.校验时段 │ │ │ +│ │ │ │───────────────────────────────▶│ │ +│ │ │ │◀───────────────────────────────│ │ +│ │ │ │ │ │ │ +│ │ │ │ 7.校验会员状态 │ │ │ +│ │ │ │───────────────────────────────▶│ │ +│ │ │ │◀───────────────────────────────│ │ +│ │ │ │ │ │ │ +│ │ │ │ 8.检查库存(原子)│ │ │ +│ │ │ │───────────────────────────────▶│ │ +│ │ │ │◀───────────────────────────────│ │ +│ │ │ │ │ │ │ +│ │ │ │ 9.扣减权益 │ │ │ +│ │ │ │────────────────▶│ │ │ +│ │ │ │ │─────────────▶│ │ +│ │ │ │ │◀─────────────│ │ +│ │ │ │◀────────────────│ │ │ +│ │ │ │ │ │ │ +│ │ │ │ 10.创建预约记录 │ │ │ +│ │ │ │───────────────────────────────▶│ │ +│ │ │ │ │ │ │ +│ │ │ │ 11.增加预约人数 │ │ │ +│ │ │ │───────────────────────────────▶│ │ +│ │ │ │◀───────────────────────────────│ │ +│ │ │ │ │ │ │ +│ │ │ │ 12.发送预约通知 │ │ │ +│ │◀─────────│◀─────────────│ │ │ │ +│ │ 返回预约成功│ │ │ │ │ +│ │ │ │ │ │ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.2 取消预约流程 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 取消预约流程 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 会员端 API层 BookingService BenefitService 数据层 │ +│ │ │ │ │ │ │ +│ │ 1.请求取消 │ │ │ │ │ +│ │─────────▶│ │ │ │ │ +│ │ │ 2.查询预约 │ │ │ │ +│ │ │─────────────▶│ │ │ │ +│ │ │ │───────────────────────────────▶│ │ +│ │ │ │◀───────────────────────────────│ │ +│ │ │ │ │ │ │ +│ │ │ │ 3.校验可取消 │ │ │ +│ │ │ │ - 状态检查 │ │ │ +│ │ │ │ - 时间检查 │ │ │ +│ │ │ │ │ │ │ +│ │ │ │ 4.计算退款金额 │ │ │ +│ │ │ │ │ │ │ +│ │ │ │ 5.退还权益 │ │ │ +│ │ │ │────────────────▶│ │ │ +│ │ │ │ │─────────────▶│ │ +│ │ │ │ │◀─────────────│ │ +│ │ │ │◀────────────────│ │ │ +│ │ │ │ │ │ │ +│ │ │ │ 6.更新预约状态 │ │ │ +│ │ │ │───────────────────────────────▶│ │ +│ │ │ │ │ │ │ +│ │ │ │ 7.减少预约人数 │ │ │ +│ │ │ │───────────────────────────────▶│ │ +│ │ │ │ │ │ │ +│ │ │ │ 8.处理候补队列 │ │ │ +│ │ │ │───────────────────────────────▶│ │ +│ │ │ │◀───────────────────────────────│ │ +│ │ │ │ │ │ │ +│ │ │ │ 9.发送取消通知 │ │ │ +│ │◀─────────│◀─────────────│ │ │ │ +│ │ 返回取消成功│ │ │ │ │ +│ │ │ │ │ │ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 五、接口设计 + +### 5.1 课程接口 + +#### 5.1.1 获取课程列表 + +``` +GET /v1/courses?storeId=1&type=1&status=1 + +Response: +{ + "code": 0, + "message": "success", + "data": { + "list": [ + { + "id": 1, + "name": "瑜伽基础课", + "type": 1, + "typeName": "团课", + "category": "瑜伽", + "coverImage": "https://xxx.com/yoga.jpg", + "duration": 60, + "capacity": 20, + "difficulty": 1, + "difficultyName": "入门", + "calories": 200, + "priceType": 1, + "priceValue": 1, + "priceTypeName": "扣1次" + } + ], + "total": 10 + } +} +``` + +### 5.2 预约接口 + +#### 5.2.1 创建预约 + +``` +POST /v1/bookings + +Request: +{ + "slotId": 1, + "memberId": 10001, + "source": "miniprogram" +} + +Response: +{ + "code": 0, + "message": "success", + "data": { + "bookingId": 1, + "bookingNo": "B202602280001", + "courseName": "瑜伽基础课", + "coachName": "王教练", + "venueName": "瑜伽室A", + "startTime": "2026-03-01T09:00:00", + "endTime": "2026-03-01T10:00:00", + "priceType": 1, + "priceValue": 1, + "status": 1, + "statusName": "已预约", + "createdAt": "2026-02-28T10:00:00" + } +} +``` + +#### 5.2.2 取消预约 + +``` +POST /v1/bookings/{id}/cancel + +Request: +{ + "reason": "临时有事" +} + +Response: +{ + "code": 0, + "message": "success", + "data": { + "bookingId": 1, + "status": 2, + "statusName": "已取消", + "refundAmount": 1, + "refundTypeName": "退还1次" + } +} +``` + +#### 5.2.3 获取我的预约 + +``` +GET /v1/bookings/my?memberId=10001&status=1&page=1&pageSize=20 + +Response: +{ + "code": 0, + "message": "success", + "data": { + "list": [ + { + "id": 1, + "bookingNo": "B202602280001", + "resourceType": 1, + "resourceTypeName": "团课", + "courseName": "瑜伽基础课", + "coachName": "王教练", + "coachAvatar": "https://xxx.com/coach.jpg", + "venueName": "瑜伽室A", + "startTime": "2026-03-01T09:00:00", + "endTime": "2026-03-01T10:00:00", + "status": 1, + "statusName": "已预约", + "checkinStatus": 0, + "checkinStatusName": "未签到", + "canCancel": true, + "cancelDeadline": "2026-03-01T07:00:00" + } + ], + "total": 5, + "page": 1, + "pageSize": 20 + } +} +``` + +--- + +## 六、核心代码设计 + +### 6.1 预约时段实体 + +```java +package com.gym.domain.model.booking; + +import com.gym.domain.model.base.BaseEntity; +import com.gym.domain.model.base.AggregateRoot; +import lombok.Getter; +import lombok.Setter; +import java.time.LocalDateTime; + +@Getter +@Setter +public class BookingSlot extends BaseEntity implements AggregateRoot { + + private Long tenantId; + private Long storeId; + private ResourceType resourceType; + private Long resourceId; + private Long coachId; + private Long venueId; + private String title; + private LocalDateTime startTime; + private LocalDateTime endTime; + private Integer capacity; + private Integer bookedCount; + private Integer waitlistCount; + private Integer minCapacity; + private SlotStatus status; + private BigDecimal price; + private PriceType priceType; + private BigDecimal priceValue; + private LocalDateTime bookingStart; + private LocalDateTime bookingEnd; + private LocalDateTime cancelDeadline; + private Integer version; + + public boolean hasCapacity() { + return bookedCount < capacity; + } + + public Integer getRemainCapacity() { + return Math.max(0, capacity - bookedCount); + } + + public boolean canBook() { + if (!SlotStatus.AVAILABLE.equals(status)) { + return false; + } + if (!hasCapacity()) { + return false; + } + LocalDateTime now = LocalDateTime.now(); + if (bookingStart != null && now.isBefore(bookingStart)) { + return false; + } + if (bookingEnd != null && now.isAfter(bookingEnd)) { + return false; + } + return true; + } + + public boolean isExpired() { + return LocalDateTime.now().isAfter(endTime); + } + + public boolean isFull() { + return bookedCount >= capacity; + } + + public void book() { + if (!canBook()) { + throw new BookingException(BookingException.SLOT_NOT_AVAILABLE); + } + this.bookedCount++; + this.version++; + this.updatedAt = LocalDateTime.now(); + + if (isFull()) { + this.status = SlotStatus.FULL; + } + } + + public void cancel() { + if (this.bookedCount > 0) { + this.bookedCount--; + } + this.version++; + this.updatedAt = LocalDateTime.now(); + + if (SlotStatus.FULL.equals(status) && !isFull()) { + this.status = SlotStatus.AVAILABLE; + } + } +} +``` + +### 6.2 预约服务 + +```java +package com.gym.domain.service; + +import com.gym.domain.model.booking.*; +import com.gym.domain.repository.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.reactive.TransactionalOperator; +import reactor.core.publisher.Mono; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@Service +@RequiredArgsConstructor +public class BookingDomainService { + + private final BookingSlotRepository slotRepository; + private final BookingRecordRepository recordRepository; + private final BookingWaitlistRepository waitlistRepository; + private final BenefitDomainService benefitService; + private final TransactionalOperator rxtx; + + @Transactional + public Mono createBooking(Long memberId, Long slotId, String source) { + return Mono.defer(() -> + slotRepository.findById(slotId) + .switchIfEmpty(Mono.error(new BookingException(BookingException.SLOT_NOT_FOUND))) + .flatMap(slot -> { + if (!slot.canBook()) { + return Mono.error(new BookingException(BookingException.SLOT_NOT_AVAILABLE)); + } + + return recordRepository.existsByMemberIdAndSlotIdAndStatus( + memberId, slotId, BookingStatus.BOOKED + ).flatMap(exists -> { + if (exists) { + return Mono.error(new BookingException(BookingException.ALREADY_BOOKED)); + } + + BookingRecord record = new BookingRecord(); + record.setTenantId(slot.getTenantId()); + record.setStoreId(slot.getStoreId()); + record.setMemberId(memberId); + record.setSlotId(slotId); + record.setResourceType(slot.getResourceType()); + record.setResourceId(slot.getResourceId()); + record.setCoachId(slot.getCoachId()); + record.setBookingNo(generateBookingNo(slot.getTenantId())); + record.setStatus(BookingStatus.BOOKED); + record.setPrice(slot.getPrice()); + record.setPriceType(slot.getPriceType()); + record.setPriceValue(slot.getPriceValue()); + record.setSource(source); + record.setCheckinStatus(CheckinStatus.NOT_CHECKED); + + return deductBenefit(memberId, slot) + .flatMap(benefitId -> { + record.setBenefitId(benefitId); + slot.book(); + + return Mono.when( + recordRepository.save(record), + slotRepository.save(slot) + ).thenReturn(record); + }); + }); + }) + ).as(rxtx::transactional); + } + + @Transactional + public Mono cancelBooking(Long bookingId, String reason, Long operatorId) { + return Mono.defer(() -> + recordRepository.findById(bookingId) + .switchIfEmpty(Mono.error(new BookingException(BookingException.BOOKING_NOT_FOUND))) + .flatMap(record -> { + if (!record.canCancel()) { + return Mono.error(new BookingException(BookingException.CANNOT_CANCEL)); + } + + return slotRepository.findById(record.getSlotId()) + .flatMap(slot -> refundBenefit(record) + .flatMap(v -> { + record.cancel(reason, operatorId); + slot.cancel(); + + return Mono.when( + recordRepository.save(record), + slotRepository.save(slot) + ).then(processWaitlist(slot.getId())); + })); + }) + ).as(rxtx::transactional); + } + + private Mono deductBenefit(Long memberId, BookingSlot slot) { + if (slot.getPriceType() == null || slot.getPriceValue() == null) { + return Mono.just(null); + } + + return benefitService.deductBenefit( + memberId, + mapPriceTypeToBenefitType(slot.getPriceType()), + null, + slot.getPriceValue(), + "booking", + slot.getId(), + "预约: " + slot.getTitle() + ).then(Mono.just(1L)); + } + + private Mono refundBenefit(BookingRecord record) { + if (record.getPriceType() == null || record.getPriceValue() == null) { + return Mono.empty(); + } + + return benefitService.addBenefit( + record.getMemberId(), + record.getBenefitId(), + mapPriceTypeToBenefitType(record.getPriceType()), + null, + "取消预约退还", + record.getPriceValue(), + null, + null, + "refund", + record.getId() + ).then(); + } + + private Mono processWaitlist(Long slotId) { + return waitlistRepository.findFirstBySlotIdOrderByQueueNo(slotId) + .flatMap(waitlist -> { + waitlist.convert(); + return waitlistRepository.save(waitlist) + .then(createBooking(waitlist.getMemberId(), slotId, "waitlist")) + .then(); + }) + .switchIfEmpty(Mono.empty()); + } + + private String generateBookingNo(Long tenantId) { + String prefix = "B" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); + return prefix + String.format("%04d", (int)(Math.random() * 10000)); + } + + private BenefitType mapPriceTypeToBenefitType(PriceType priceType) { + return switch (priceType) { + case TIMES -> BenefitType.TIMES; + case DURATION -> BenefitType.DURATION; + case AMOUNT -> BenefitType.STORED_VALUE; + }; + } +} +``` + +### 6.3 库存服务 + +```java +package com.gym.domain.service; + +import com.github.benmanes.caffeine.cache.Cache; +import com.gym.domain.repository.BookingSlotRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.r2dbc.core.DatabaseClient; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +@Slf4j +@Service +@RequiredArgsConstructor +public class InventoryDomainService { + + private final BookingSlotRepository slotRepository; + private final DatabaseClient databaseClient; + private final Cache inventoryCache; + + public Mono checkInventory(Long slotId) { + return getRemainCapacity(slotId) + .map(remain -> remain > 0); + } + + public Mono reserveInventory(Long slotId) { + return databaseClient.sql(""" + UPDATE booking_slot + SET booked_count = booked_count + 1, + version = version + 1, + updated_at = NOW() + WHERE id = :slotId + AND deleted_at IS NULL + AND booked_count < capacity + """) + .bind("slotId", slotId) + .fetch() + .rowsUpdated() + .map(rows -> rows > 0) + .doOnNext(success -> { + if (success) { + invalidateCache(slotId); + } + }); + } + + public Mono releaseInventory(Long slotId) { + return databaseClient.sql(""" + UPDATE booking_slot + SET booked_count = GREATEST(booked_count - 1, 0), + version = version + 1, + updated_at = NOW() + WHERE id = :slotId + AND deleted_at IS NULL + """) + .bind("slotId", slotId) + .fetch() + .rowsUpdated() + .then() + .doOnSuccess(v -> invalidateCache(slotId)); + } + + public void preloadInventory(Long slotId) { + slotRepository.findById(slotId) + .subscribe(slot -> { + int remain = slot.getCapacity() - slot.getBookedCount(); + inventoryCache.put(slotId, remain); + log.debug("预加载库存: slotId={}, remain={}", slotId, remain); + }); + } + + private Mono getRemainCapacity(Long slotId) { + Integer cached = inventoryCache.getIfPresent(slotId); + if (cached != null) { + return Mono.just(cached); + } + + return slotRepository.findById(slotId) + .map(slot -> { + int remain = slot.getCapacity() - slot.getBookedCount(); + inventoryCache.put(slotId, remain); + return remain; + }); + } + + private void invalidateCache(Long slotId) { + inventoryCache.invalidate(slotId); + } +} +``` + +--- + +## 七、高并发处理 + +### 7.1 预约并发控制 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 高并发预约处理方案 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. 库存预热 │ +│ ├── 热门课程开抢前5分钟预热库存到Caffeine缓存 │ +│ ├── 定时任务扫描即将开始的课程时段 │ +│ └── 预热数据: slotId -> remainCapacity │ +│ │ +│ 2. 原子操作 │ +│ └── 使用PostgreSQL原子更新保证库存一致性: │ +│ UPDATE booking_slot │ +│ SET booked_count = booked_count + 1 │ +│ WHERE id = ? AND booked_count < capacity │ +│ RETURNING booked_count │ +│ │ +│ 3. 乐观锁 │ +│ └── 使用version字段防止并发更新冲突: │ +│ UPDATE booking_slot │ +│ SET booked_count = ?, version = version + 1 │ +│ WHERE id = ? AND version = ? │ +│ │ +│ 4. 请求排队 │ +│ ├── 使用信号量控制并发请求数 │ +│ ├── 超出限制的请求进入等待队列 │ +│ └── 避免数据库连接池耗尽 │ +│ │ +│ 5. 限流保护 │ +│ ├── 接口限流: 单用户10次/秒 │ +│ ├── 热门课程限流: 令牌桶算法 │ +│ └── 超出限流返回"系统繁忙,请稍后重试" │ +│ │ +│ 6. 降级策略 │ +│ ├── 库存查询降级: 优先返回缓存数据 │ +│ ├── 预约失败降级: 返回排队页面 │ +│ └── 数据库压力过大时: 暂停预约功能 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 八、缓存设计 + +### 8.1 缓存策略 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 预约模块缓存策略 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. 时段库存缓存 │ +│ ├── Key: slot:inventory:{slotId} │ +│ ├── Value: Integer (剩余容量) │ +│ ├── TTL: 10分钟 │ +│ ├── 更新策略: 预约/取消时更新 │ +│ └── 预热: 热门课程开抢前预热 │ +│ │ +│ 2. 时段详情缓存 │ +│ ├── Key: slot:detail:{slotId} │ +│ ├── Value: BookingSlot JSON │ +│ ├── TTL: 30分钟 │ +│ └── 更新策略: 时段变更时删除 │ +│ │ +│ 3. 课程信息缓存 │ +│ ├── Key: course:info:{courseId} │ +│ ├── Value: Course JSON │ +│ ├── TTL: 1小时 │ +│ └── 更新策略: 课程变更时删除 │ +│ │ +│ 4. 教练排班缓存 │ +│ ├── Key: coach:schedule:{coachId}:{date} │ +│ ├── Value: List JSON │ +│ ├── TTL: 1天 │ +│ └── 更新策略: 排班变更时删除 │ +│ │ +│ 5. 用户预约锁 │ +│ ├── Key: booking:lock:{memberId}:{slotId} │ +│ ├── Value: 1 │ +│ ├── TTL: 5秒 │ +│ └── 用途: 防止重复预约 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 九、定时任务 + +### 9.1 定时任务列表 + +| 任务名称 | 执行频率 | 功能描述 | +|---------|---------|---------| +| SlotStatusTask | 每分钟 | 更新已结束时段状态 | +| WaitlistExpireTask | 每分钟 | 处理候补转正超时 | +| BookingRemindTask | 每小时 | 发送预约提醒通知 | +| SlotEndTask | 每小时 | 标记缺席会员 | +| InventoryPreloadTask | 开抢前5分钟 | 预热热门课程库存 | + +--- + +## 十、附录 + +### 10.1 枚举定义 + +```java +public enum ResourceType { + GROUP_CLASS(1, "团课"), + PRIVATE(2, "私教"), + VENUE(3, "场地"), + ONLINE(4, "线上"); +} + +public enum SlotStatus { + AVAILABLE(1, "可预约"), + FULL(2, "已满"), + CANCELLED(3, "已取消"), + ENDED(4, "已结束"); +} + +public enum BookingStatus { + BOOKED(1, "已预约"), + CANCELLED(2, "已取消"), + COMPLETED(3, "已完成"), + EXPIRED(4, "已过期"); +} + +public enum CheckinStatus { + NOT_CHECKED(0, "未签到"), + CHECKED(1, "已签到"), + LATE(2, "迟到"), + ABSENT(3, "缺席"); +} + +public enum PriceType { + TIMES(1, "扣次"), + DURATION(2, "扣时长"), + AMOUNT(3, "扣金额"); +} +``` + +### 10.2 异常定义 + +```java +public class BookingException extends BusinessException { + + public static final BookingException SLOT_NOT_FOUND = + new BookingException(40201, "时段不存在"); + + public static final BookingException SLOT_NOT_AVAILABLE = + new BookingException(40202, "时段不可预约"); + + public static final BookingException SLOT_FULL = + new BookingException(40203, "课程已满"); + + public static final BookingException ALREADY_BOOKED = + new BookingException(40204, "已预约该课程"); + + public static final BookingException BOOKING_NOT_FOUND = + new BookingException(40205, "预约记录不存在"); + + public static final BookingException CANNOT_CANCEL = + new BookingException(40206, "无法取消预约"); + + public static final BookingException CANNOT_CHECKIN = + new BookingException(40207, "无法签到"); +} +``` + +--- + +*文档结束*