From c374a3ccae66aa5b9f14347712906a2152f0b80d Mon Sep 17 00:00:00 2001 From: liwentao Date: Thu, 18 Jun 2026 10:34:37 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8A=9F=E8=83=BD=EF=BC=9A?= =?UTF-8?q?=E5=9B=A2=E8=AF=BE=E7=AD=BE=E5=88=B0=E5=89=8D=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E6=98=AF=E5=90=A6=E5=88=B0=E5=BA=97=E7=AD=BE=E5=88=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gym-manage-api/docs/groupcourse-api.md | 45 ++++++++-- .../service/impl/GroupCourseService.java | 89 +++++++++++++++---- 2 files changed, 111 insertions(+), 23 deletions(-) diff --git a/gym-manage-api/docs/groupcourse-api.md b/gym-manage-api/docs/groupcourse-api.md index 124c171..98e1417 100644 --- a/gym-manage-api/docs/groupcourse-api.md +++ b/gym-manage-api/docs/groupcourse-api.md @@ -604,26 +604,26 @@ | 属性 | 值 | |------|-----| | **HTTP方法** | POST | -| **接口路径** | `/api/groupCourse/{courseId}/signin` | +| **接口路径** | `/api/groupCourse/signin/{memberId}` | | **所属文件** | `GroupCourseHandler.java` | **路径参数**: | 参数名 | 类型 | 必填 | 说明 | |--------|------|------|------| -| courseId | Long | 是 | 团课ID | +| memberId | Long | 是 | 会员ID | **请求体**: ```json { - "memberId": 1001 + "courseId": 1 } ``` | 参数名 | 类型 | 必填 | 说明 | |--------|------|------|------| -| memberId | Long | **是** | 会员ID | +| courseId | Long | **是** | 团课ID | **成功响应** (200 OK): @@ -638,12 +638,36 @@ } ``` -**失败响应** (400 Bad Request): +**失败响应** (400 Bad Request) —— 校验规则及对应错误信息: + +| 序号 | 校验规则 | 错误信息 | +|------|----------|----------| +| 1 | 团课不存在或已被删除 | `团课不存在或已删除` | +| 2 | 团课状态为"已取消" | `团课已取消,无法签到` | +| 3 | 团课状态为"已结束" | `团课已结束,无法签到` | +| 4 | 当前时间早于开课前2小时 | `未到签到时间,请在开课前2小时内签到` | +| 5 | 当前时间晚于团课结束时间 | `团课已结束,无法签到` | +| 6 | 用户今日未到店签到 | `请先完成到店签到` | +| 7 | 课程当前人数已达上限 | `课程已满员,无法签到` | +| 8 | 用户未预约此课程 | `您未预约此课程,无法签到` | +| - | 请求体为空 | `请求体不能为空` | +| - | 请求体缺少courseId | `courseId不能为空` | + +> **说明**: 校验按上表顺序依次执行,命中第一个失败条件即返回对应错误信息。其中第6项「到店签到」基于 `sign_in_record` 表验证用户今日是否存在 `sign_in_status = 'SUCCESS'` 的记录,与 `gym-dataCount` 模块共用同一数据源。 + +**失败响应示例**: ```json { "success": false, - "message": "课程已满员" + "message": "请先完成到店签到" +} +``` + +```json +{ + "success": false, + "message": "您未预约此课程,无法签到" } ``` @@ -1637,7 +1661,14 @@ ### 团课管理 1. **创建团课**:课程名称为必填项 2. **取消团课**:需提前24小时通知,否则拒绝操作 -3. **团课签到**:验证课程状态必须为正常,且未达最大人数限制 +3. **团课签到**:严格按以下顺序校验,任一不通过即拒绝签到: + - 团课是否存在且未被删除 + - 团课状态不为"已取消" + - 团课状态不为"已结束"(含已过结束时间) + - 当前时间在开课前2小时内(签到时间窗口) + - 用户今日已完成到店签到(基于 `sign_in_record` 表验证) + - 课程当前人数未达到最大人数上限 + - 用户已预约该课程(有效预约) 4. **删除团课**:采用软删除机制,数据保留可恢复 ### 团课预约 diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseService.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseService.java index 037fe44..4fac4ba 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseService.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseService.java @@ -27,11 +27,15 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.r2dbc.core.DatabaseClient; import org.springframework.stereotype.Service; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; @Service public class GroupCourseService implements IGroupCourseService { @@ -46,6 +50,7 @@ public class GroupCourseService implements IGroupCourseService { private final RedisUtil redisUtil; private final ObjectMapper objectMapper; private final GroupCourseStateMachine stateMachine; + private final DatabaseClient databaseClient; private static final String CACHE_KEY_PREFIX = "group_course:page:"; private static final String CACHE_KEY_ID_PREFIX = "group_course:id:"; @@ -62,7 +67,8 @@ public class GroupCourseService implements IGroupCourseService { MemberCardRepository memberCardRepository, RedisUtil redisUtil, ObjectMapper objectMapper, - GroupCourseStateMachine stateMachine){ + GroupCourseStateMachine stateMachine, + DatabaseClient databaseClient){ this.groupCourseRepository = groupCourseRepository; this.bookingRepository = bookingRepository; this.groupCourseTypeRepository = groupCourseTypeRepository; @@ -72,6 +78,7 @@ public class GroupCourseService implements IGroupCourseService { this.redisUtil = redisUtil; this.objectMapper = objectMapper; this.stateMachine = stateMachine; + this.databaseClient = databaseClient; } @Override @@ -441,26 +448,51 @@ public class GroupCourseService implements IGroupCourseService { @Override public Mono signIn(Long courseId, Long memberId) { return groupCourseRepository.findByIdAndDeletedAtIsNull(courseId) - .switchIfEmpty(Mono.error(new RuntimeException("团课不存在"))) + .switchIfEmpty(Mono.error(new RuntimeException("团课不存在或已删除"))) .flatMap(course -> { - if (course.getStatus() != 0L) { - return Mono.error(new RuntimeException("课程状态不允许签到")); + // 校验1:团课已取消 + if (course.getStatus().equals(CourseStatus.CANCELLED.getValue())) { + return Mono.error(new RuntimeException("团课已取消,无法签到")); } - if (course.getCurrentMembers() >= course.getMaxMembers()) { - return Mono.error(new RuntimeException("课程已满员")); + // 校验2:团课已结束 + if (course.getStatus().equals(CourseStatus.ENDED.getValue())) { + return Mono.error(new RuntimeException("团课已结束,无法签到")); } - // 检查会员是否已预约此课程 - return bookingRepository.findValidBooking(courseId, memberId) - .switchIfEmpty(Mono.error(new RuntimeException("会员未预约此课程"))) - .flatMap(booking -> { - // 更新课程当前人数 - return groupCourseRepository.updateCurrentMembers(courseId, 1) - .flatMap(updatedCourse -> { - // 更新预约状态为已出席 - return bookingRepository.updateStatus(booking.getId(), "2") - .thenReturn(updatedCourse); + // 校验3:签到时间校验(开课前2小时内才能签到) + LocalDateTime now = LocalDateTime.now(); + LocalDateTime signInStart = course.getStartTime().minusHours(2); + if (now.isBefore(signInStart)) { + return Mono.error(new RuntimeException("未到签到时间,请在开课前2小时内签到")); + } + if (now.isAfter(course.getEndTime())) { + return Mono.error(new RuntimeException("团课已结束,无法签到")); + } + + // 校验4:用户是否已到店签到(今日有成功签到记录) + return checkStoreCheckIn(memberId) + .flatMap(storeCheckedIn -> { + if (!storeCheckedIn) { + return Mono.error(new RuntimeException("请先完成到店签到")); + } + + // 校验5:课程已满员 + if (course.getCurrentMembers() >= course.getMaxMembers()) { + return Mono.error(new RuntimeException("课程已满员,无法签到")); + } + + // 校验6:用户已预约此课程(有效预约,状态为0-已预约) + return bookingRepository.findValidBooking(courseId, memberId) + .switchIfEmpty(Mono.error(new RuntimeException("您未预约此课程,无法签到"))) + .flatMap(booking -> { + // 更新课程当前人数 + return groupCourseRepository.updateCurrentMembers(courseId, 1) + .flatMap(updatedCourse -> { + // 更新预约状态为已出席 + return bookingRepository.updateStatus(booking.getId(), "2") + .thenReturn(updatedCourse); + }); }); }); }) @@ -468,6 +500,31 @@ public class GroupCourseService implements IGroupCourseService { .flatMap(course -> clearCache().thenReturn(course)) .doOnError(error -> logger.error("团课签到失败 - courseId={}, memberId={}, error: {}", courseId, memberId, error.getMessage())); } + + /** + * 检查用户今日是否有成功到店签到记录 + * 基于 sign_in_record 表查询,与 gym-dataCount 模块使用相同数据源 + */ + private Mono checkStoreCheckIn(Long memberId) { + LocalDate today = LocalDate.now(); + LocalDateTime startOfDay = today.atStartOfDay(); + LocalDateTime endOfDay = today.plusDays(1).atStartOfDay(); + + return databaseClient.sql( + "SELECT COUNT(*) FROM sign_in_record " + + "WHERE member_id = :memberId " + + "AND sign_in_time >= :startTime " + + "AND sign_in_time < :endTime " + + "AND sign_in_status = 'SUCCESS' " + + "AND is_delete = false") + .bind("memberId", memberId) + .bind("startTime", startOfDay) + .bind("endTime", endOfDay) + .map(row -> row.get(0, Long.class)) + .one() + .map(count -> count > 0) + .defaultIfEmpty(false); + } @Override public Mono delete(Long id) {