新增团课多条件查询

This commit was merged in pull request #28.
This commit is contained in:
2026-06-15 14:09:19 +08:00
parent 7a94145819
commit b5c8a087dd
9 changed files with 511 additions and 2 deletions
+144
View File
@@ -14,6 +14,7 @@
3. [团课管理接口](#团课管理接口)
- [获取所有团课](#获取所有团课)
- [分页获取团课](#分页获取团课)
- [多条件查询团课](#多条件查询团课)
- [根据ID获取团课详情](#根据ID获取团课详情)
- [创建团课](#创建团课)
- [更新团课](#更新团课)
@@ -154,6 +155,149 @@
---
### 多条件查询团课
| 属性 | 值 |
|------|-----|
| **HTTP方法** | POST |
| **接口路径** | `/api/groupCourse/search` |
| **所属文件** | `GroupCourseHandler.java` |
**功能说明**: 支持团课名称模糊查询、类型筛选、日期范围、时间段、价格排序、剩余名额排序等多条件组合查询,默认不查询不可预约的团课(已取消或已结束)。
**请求体**:
```json
{
"courseName": "瑜伽",
"courseType": 1,
"startDate": "2026-06-01T00:00:00",
"endDate": "2026-06-30T23:59:59",
"timePeriod": "morning",
"priceSort": "asc",
"remainingMost": true,
"page": 0,
"size": 10
}
```
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| courseName | String | 否 | - | 课程名称(模糊查询,不区分大小写) |
| courseType | Long | 否 | - | 课程类型ID |
| startDate | LocalDateTime | 否 | - | 查询开始日期 |
| endDate | LocalDateTime | 否 | - | 查询结束日期 |
| timePeriod | String | 否 | - | 时间段:`morning`(6:00-12:00)、`afternoon`(12:00-18:00)、`evening`(18:00-24:00) |
| priceSort | String | 否 | - | 价格排序:`asc`(从低到高)、`desc`(从高到低) |
| remainingMost | Boolean | 否 | false | 是否按剩余名额最多排序 |
| page | Integer | 否 | 0 | 页码,从0开始 |
| size | Integer | 否 | 10 | 每页数量,最大100 |
**查询条件优先级说明**:
| 优先级 | 条件类型 | 说明 |
|--------|----------|------|
| 1 | 默认过滤 | 自动过滤已删除和不可预约的团课(status != 0 |
| 2 | 基础筛选 | courseName、courseType、startDate、endDate、timePeriod |
| 3 | 排序规则 | remainingMost优先,其次priceSort,默认按startTime升序 |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "查询成功",
"data": {
"content": [
{
"id": 1,
"courseName": "瑜伽入门",
"coachId": 1,
"courseType": 1,
"startTime": "2026-06-15T09:00:00",
"endTime": "2026-06-15T10:00:00",
"maxMembers": 20,
"currentMembers": 5,
"status": 0,
"location": "健身房A区",
"coverImage": "https://example.com/yoga.jpg",
"description": "适合初学者的瑜伽课程",
"pointCardAmount": 1,
"storedValueAmount": 50.00,
"createdAt": "2026-06-01T10:00:00",
"updatedAt": "2026-06-01T10:00:00"
}
],
"totalPages": 3,
"totalElements": 25,
"currentPage": 0,
"pageSize": 10,
"first": true,
"last": false
}
}
```
**失败响应** (400 Bad Request):
```json
{
"success": false,
"message": "查询失败的原因"
}
```
**使用示例**:
1. **查询瑜伽课程**
```json
{
"courseName": "瑜伽"
}
```
2. **查询特定类型的早晨课程**
```json
{
"courseType": 1,
"timePeriod": "morning"
}
```
3. **查询下周的课程,按价格从低到高排序**
```json
{
"startDate": "2026-06-16T00:00:00",
"endDate": "2026-06-22T23:59:59",
"priceSort": "asc"
}
```
4. **查询剩余名额最多的晚间课程**
```json
{
"timePeriod": "evening",
"remainingMost": true
}
```
5. **多条件组合查询**
```json
{
"courseName": "瑜伽",
"courseType": 1,
"startDate": "2026-06-01T00:00:00",
"endDate": "2026-06-30T23:59:59",
"timePeriod": "morning",
"priceSort": "asc",
"remainingMost": true,
"page": 0,
"size": 10
}
```
---
### 根据ID获取团课详情
| 属性 | 值 |
@@ -1,16 +1,19 @@
package cn.novalon.gym.manage.groupcourse.dao;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.repository.Modifying;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
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;
import java.util.ArrayList;
import java.util.List;
@Repository
public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long> {
@@ -38,4 +41,184 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
Flux<GroupCourseEntity> findByCourseTypeAndDeletedAtIsNull(Long courseType);
/**
* 多条件查询团课(使用 DatabaseClient 构建动态 SQL
*/
default Flux<GroupCourseEntity> searchGroupCourses(DatabaseClient databaseClient, GroupCourseQueryDto query) {
StringBuilder sql = new StringBuilder("SELECT * FROM group_course WHERE deleted_at IS NULL");
List<String> conditions = new ArrayList<>();
// 默认不查询不可预约的团课(仅查询 status = '0'
conditions.add("status = '0'");
// 1. 团课名称模糊查询
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
conditions.add("course_name ILIKE :courseName");
}
// 2. 基于团课类型查询
if (query.getCourseType() != null) {
conditions.add("course_type = :courseType");
}
// 3. 基于日期时间段查询
if (query.getStartDate() != null) {
conditions.add("start_time >= :startDate");
}
if (query.getEndDate() != null) {
conditions.add("start_time <= :endDate");
}
// 4. 基于早晨/下午/夜晚时间段查询
if (query.getTimePeriod() != null && !query.getTimePeriod().isEmpty()) {
switch (query.getTimePeriod().toLowerCase()) {
case "morning":
conditions.add("EXTRACT(HOUR FROM start_time) >= 6 AND EXTRACT(HOUR FROM start_time) < 12");
break;
case "afternoon":
conditions.add("EXTRACT(HOUR FROM start_time) >= 12 AND EXTRACT(HOUR FROM start_time) < 18");
break;
case "evening":
conditions.add("EXTRACT(HOUR FROM start_time) >= 18 AND EXTRACT(HOUR FROM start_time) < 24");
break;
default:
break;
}
}
sql.append(" AND ").append(String.join(" AND ", conditions));
// 5. 价格排序 / 6. 剩余名额最多排序
boolean hasPriceSort = query.getPriceSort() != null && !query.getPriceSort().isEmpty();
boolean hasRemainingMost = query.getRemainingMost() != null && query.getRemainingMost();
if (hasPriceSort || hasRemainingMost) {
sql.append(" ORDER BY");
List<String> orderClauses = new ArrayList<>();
if (hasRemainingMost) {
orderClauses.add(" (max_members - current_members) DESC");
}
if (hasPriceSort) {
if ("asc".equalsIgnoreCase(query.getPriceSort())) {
orderClauses.add(" stored_value_amount ASC");
} else if ("desc".equalsIgnoreCase(query.getPriceSort())) {
orderClauses.add(" stored_value_amount DESC");
}
}
sql.append(String.join(",", orderClauses));
} else {
sql.append(" ORDER BY start_time ASC");
}
// 分页
int page = query.getPage() != null ? query.getPage() : 0;
int size = query.getSize() != null ? query.getSize() : 10;
if (size < 1) size = 10;
if (size > 100) size = 100;
int offset = page * size;
sql.append(" LIMIT :limit OFFSET :offset");
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
spec = spec.bind("courseName", "%" + query.getCourseName() + "%");
}
if (query.getCourseType() != null) {
spec = spec.bind("courseType", query.getCourseType());
}
if (query.getStartDate() != null) {
spec = spec.bind("startDate", query.getStartDate());
}
if (query.getEndDate() != null) {
spec = spec.bind("endDate", query.getEndDate());
}
spec = spec.bind("limit", size);
spec = spec.bind("offset", offset);
return spec.map((row, meta) -> {
GroupCourseEntity entity = new GroupCourseEntity();
entity.setId(row.get("id", Long.class));
entity.setCourseName(row.get("course_name", String.class));
entity.setCoachId(row.get("coach_id", Long.class));
entity.setCourseType(row.get("course_type", Long.class));
entity.setStartTime(row.get("start_time", LocalDateTime.class));
entity.setEndTime(row.get("end_time", LocalDateTime.class));
entity.setMaxMembers(row.get("max_members", Integer.class));
entity.setCurrentMembers(row.get("current_members", Integer.class));
String statusStr = row.get("status", String.class);
entity.setStatus(statusStr != null ? Long.parseLong(statusStr) : null);
entity.setLocation(row.get("location", String.class));
entity.setCoverImage(row.get("cover_image", String.class));
entity.setDescription(row.get("description", String.class));
entity.setPointCardAmount(row.get("point_card_amount", Integer.class));
entity.setStoredValueAmount(row.get("stored_value_amount", java.math.BigDecimal.class));
entity.setCreateBy(row.get("create_by", String.class));
entity.setUpdateBy(row.get("update_by", String.class));
entity.setCreatedAt(row.get("created_at", LocalDateTime.class));
entity.setUpdatedAt(row.get("updated_at", LocalDateTime.class));
entity.setDeletedAt(row.get("deleted_at", LocalDateTime.class));
return entity;
}).all();
}
/**
* 多条件查询团课总数
*/
default Mono<Long> countSearchGroupCourses(DatabaseClient databaseClient, GroupCourseQueryDto query) {
StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM group_course WHERE deleted_at IS NULL");
List<String> conditions = new ArrayList<>();
conditions.add("status = '0'");
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
conditions.add("course_name ILIKE :courseName");
}
if (query.getCourseType() != null) {
conditions.add("course_type = :courseType");
}
if (query.getStartDate() != null) {
conditions.add("start_time >= :startDate");
}
if (query.getEndDate() != null) {
conditions.add("start_time <= :endDate");
}
if (query.getTimePeriod() != null && !query.getTimePeriod().isEmpty()) {
switch (query.getTimePeriod().toLowerCase()) {
case "morning":
conditions.add("EXTRACT(HOUR FROM start_time) >= 6 AND EXTRACT(HOUR FROM start_time) < 12");
break;
case "afternoon":
conditions.add("EXTRACT(HOUR FROM start_time) >= 12 AND EXTRACT(HOUR FROM start_time) < 18");
break;
case "evening":
conditions.add("EXTRACT(HOUR FROM start_time) >= 18 AND EXTRACT(HOUR FROM start_time) < 24");
break;
default:
break;
}
}
sql.append(" AND ").append(String.join(" AND ", conditions));
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
spec = spec.bind("courseName", "%" + query.getCourseName() + "%");
}
if (query.getCourseType() != null) {
spec = spec.bind("courseType", query.getCourseType());
}
if (query.getStartDate() != null) {
spec = spec.bind("startDate", query.getStartDate());
}
if (query.getEndDate() != null) {
spec = spec.bind("endDate", query.getEndDate());
}
return spec.map((row, meta) -> row.get(0, Long.class)).one();
}
}
@@ -0,0 +1,116 @@
package cn.novalon.gym.manage.groupcourse.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
/**
* 团课多条件查询请求DTO
*
* @author 张翔
* @date 2026-06-14
*/
@Schema(description = "团课多条件查询参数")
public class GroupCourseQueryDto {
@Schema(description = "课程名称(模糊查询)", example = "瑜伽")
private String courseName;
@Schema(description = "课程类型ID", example = "1")
private Long courseType;
@Schema(description = "查询开始日期", example = "2026-06-01T00:00:00")
private LocalDateTime startDate;
@Schema(description = "查询结束日期", example = "2026-06-30T23:59:59")
private LocalDateTime endDate;
@Schema(description = "时间段:morning-早晨(6:00-12:00), afternoon-下午(12:00-18:00), evening-夜晚(18:00-24:00)", example = "morning")
private String timePeriod;
@Schema(description = "价格排序:asc-从低到高, desc-从高到低", example = "asc")
private String priceSort;
@Schema(description = "按剩余名额最多排序", example = "true")
private Boolean remainingMost;
@Schema(description = "页码", example = "0")
private Integer page = 0;
@Schema(description = "每页大小", example = "10")
private Integer size = 10;
// ===== Getters and Setters =====
public String getCourseName() {
return courseName;
}
public void setCourseName(String courseName) {
this.courseName = courseName;
}
public Long getCourseType() {
return courseType;
}
public void setCourseType(Long courseType) {
this.courseType = courseType;
}
public LocalDateTime getStartDate() {
return startDate;
}
public void setStartDate(LocalDateTime startDate) {
this.startDate = startDate;
}
public LocalDateTime getEndDate() {
return endDate;
}
public void setEndDate(LocalDateTime endDate) {
this.endDate = endDate;
}
public String getTimePeriod() {
return timePeriod;
}
public void setTimePeriod(String timePeriod) {
this.timePeriod = timePeriod;
}
public String getPriceSort() {
return priceSort;
}
public void setPriceSort(String priceSort) {
this.priceSort = priceSort;
}
public Boolean getRemainingMost() {
return remainingMost;
}
public void setRemainingMost(Boolean remainingMost) {
this.remainingMost = remainingMost;
}
public Integer getPage() {
return page;
}
public void setPage(Integer page) {
this.page = page;
}
public Integer getSize() {
return size;
}
public void setSize(Integer size) {
this.size = size;
}
}
@@ -5,6 +5,7 @@ import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
@@ -215,6 +216,27 @@ public class GroupCourseHandler {
});
}
@Operation(summary = "多条件查询团课", description = "支持团课名称模糊查询、类型筛选、日期范围、时间段、价格排序、剩余名额排序等多条件组合查询")
public Mono<ServerResponse> searchGroupCourses(ServerRequest request) {
return request.bodyToMono(GroupCourseQueryDto.class)
.flatMap(query -> {
return groupCourseService.searchGroupCourses(query)
.flatMap(response -> {
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("message", "查询成功");
result.put("data", response);
return ServerResponse.ok().bodyValue(result);
});
})
.onErrorResume(error -> {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false);
errorResponse.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(errorResponse);
});
}
@Operation(summary = "测试-根据Key获取Redis缓存", description = "测试接口:根据传入的key值获取Redis中缓存的数据")
public Mono<ServerResponse> getCacheByKey(ServerRequest request) {
return request.bodyToMono(Map.class)
@@ -4,6 +4,7 @@ package cn.novalon.gym.manage.groupcourse.repository;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import org.springframework.data.domain.Sort;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@@ -29,4 +30,6 @@ public interface IGroupCourseRepository {
Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta);
Flux<GroupCourse> findByCourseType(Long courseType);
Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query);
}
@@ -6,6 +6,7 @@ import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import org.springframework.data.domain.Sort;
@@ -184,4 +185,27 @@ public class GroupCourseRepository implements IGroupCourseRepository {
return groupCourseDao.findByCourseTypeAndDeletedAtIsNull(courseType)
.map(groupCourseConverter::toDomain);
}
@Override
public Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query) {
return groupCourseDao.countSearchGroupCourses(r2dbcEntityTemplate.getDatabaseClient(), query)
.flatMap(total -> {
if (total == 0) {
return Mono.just(new PageResponse<>(
List.of(), 0, 0L,
query.getPage() != null ? query.getPage() : 0,
query.getSize() != null ? query.getSize() : 10));
}
return groupCourseDao.searchGroupCourses(r2dbcEntityTemplate.getDatabaseClient(), query)
.map(groupCourseConverter::toDomain)
.collectList()
.map(courseList -> {
int size = query.getSize() != null ? query.getSize() : 10;
int totalPages = (int) Math.ceil((double) total / size);
return new PageResponse<>(
courseList, totalPages, total,
query.getPage() != null ? query.getPage() : 0, size);
});
});
}
}
@@ -5,6 +5,7 @@ import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@@ -25,4 +26,6 @@ public interface IGroupCourseService {
Mono<GroupCourse> signIn(Long courseId, Long memberId);
Mono<Void> delete(Long id);
Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query);
}
@@ -9,6 +9,7 @@ import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.enums.CourseEvent;
import cn.novalon.gym.manage.groupcourse.enums.CourseStatus;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseStateMachine;
@@ -488,6 +489,18 @@ public class GroupCourseService implements IGroupCourseService {
});
}
@Override
public Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query) {
logger.info("多条件查询团课 - courseName={}, courseType={}, startDate={}, endDate={}, timePeriod={}, priceSort={}, remainingMost={}",
query.getCourseName(), query.getCourseType(), query.getStartDate(), query.getEndDate(),
query.getTimePeriod(), query.getPriceSort(), query.getRemainingMost());
return groupCourseRepository.searchGroupCourses(query)
.doOnSuccess(result -> logger.info("多条件查询结果 - total={}, page={}, size={}",
result.getTotalElements(), result.getCurrentPage(), result.getPageSize()))
.doOnError(error -> logger.error("多条件查询失败 - error: {}", error.getMessage()));
}
private Mono<Void> clearCache() {
return redisUtil.deleteByPattern(CACHE_KEY_PREFIX + "*")
.then(redisUtil.deleteByPattern(CACHE_KEY_ID_PREFIX + "*"))
@@ -307,6 +307,7 @@ public class SystemRouter {
.DELETE("/api/groupCourse/{id}", groupCourseHandler::deleteGroupCourse)
.POST("/api/groupCourse/{id}/cancel", groupCourseHandler::cancelGroupCourse)
.POST("/api/groupCourse/{courseId}/signin", groupCourseHandler::signIn)
.POST("/api/groupCourse/search", groupCourseHandler::searchGroupCourses)
// ========= 签到模块路由 ==========
// ===== 签到核心功能 =====