From cf7e2560b571d48a3a6fbbfe669944124de32135 Mon Sep 17 00:00:00 2001 From: liwentao Date: Mon, 29 Jun 2026 07:35:07 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=A4=9A=E4=B8=AA=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gym/manage/groupcourse/dao/BannerDao.java | 34 ++ .../groupcourse/dao/GroupCourseDao.java | 20 + .../gym/manage/groupcourse/domain/Banner.java | 43 ++ .../groupcourse/domain/GroupCourse.java | 12 + .../groupcourse/dto/GroupCourseQueryDto.java | 11 + .../groupcourse/entity/BannerEntity.java | 45 ++ .../groupcourse/entity/GroupCourseEntity.java | 12 + .../groupcourse/handler/BannerHandler.java | 224 ++++++++ .../handler/GroupCourseBookingHandler.java | 27 + .../handler/GroupCourseHandler.java | 47 ++ .../repository/IBannerRepository.java | 24 + .../repository/impl/BannerRepository.java | 113 ++++ .../impl/GroupCourseRepository.java | 2 + .../GroupCourseRecurringScheduler.java | 48 ++ .../groupcourse/service/IBannerService.java | 26 + .../service/IGroupCourseBookingService.java | 10 + .../service/impl/BannerService.java | 99 ++++ .../impl/GroupCourseBookingService.java | 78 ++- .../service/impl/GroupCourseService.java | 3 + .../payment/dto/PaymentRecordResponse.java | 54 ++ .../manage/payment/dto/RevenueStatistics.java | 47 ++ .../handler/PaymentRevenueHandler.java | 74 +++ .../repository/PaymentOrderRepository.java | 21 + .../payment/service/PaymentService.java | 8 + .../service/impl/PaymentServiceImpl.java | 74 +++ .../gym/manage/app/config/SystemRouter.java | 20 + .../src/main/resources/application-dev.yml | 2 +- .../V28__Add_is_recurring_to_group_course.sql | 7 + .../db/migration/V29__Create_Banner_table.sql | 36 ++ gym-manage-cuit/src/api/modules/banner.ts | 34 ++ gym-manage-cuit/src/api/modules/config.ts | 22 + .../src/api/modules/groupCourse.ts | 4 + gym-manage-cuit/src/api/modules/payment.ts | 9 + gym-manage-cuit/src/router/routes/index.ts | 12 + gym-manage-cuit/src/stores/permission.ts | 10 + gym-manage-cuit/src/views/banner/index.vue | 496 ++++++++++++++++++ gym-manage-cuit/src/views/dashboard/index.vue | 52 +- gym-manage-cuit/src/views/datacount/index.vue | 174 +++++- .../src/views/groupCourse/index.vue | 121 ++++- .../src/views/memberCard/index.vue | 77 +++ .../src/views/payment/revenue/index.vue | 193 +++++++ 41 files changed, 2388 insertions(+), 37 deletions(-) create mode 100644 gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/dao/BannerDao.java create mode 100644 gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/domain/Banner.java create mode 100644 gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/entity/BannerEntity.java create mode 100644 gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/BannerHandler.java create mode 100644 gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/IBannerRepository.java create mode 100644 gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/impl/BannerRepository.java create mode 100644 gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/scheduler/GroupCourseRecurringScheduler.java create mode 100644 gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/IBannerService.java create mode 100644 gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/BannerService.java create mode 100644 gym-manage-api/gym-payment/src/main/java/cn/novalon/gym/manage/payment/dto/PaymentRecordResponse.java create mode 100644 gym-manage-api/gym-payment/src/main/java/cn/novalon/gym/manage/payment/dto/RevenueStatistics.java create mode 100644 gym-manage-api/gym-payment/src/main/java/cn/novalon/gym/manage/payment/handler/PaymentRevenueHandler.java create mode 100644 gym-manage-api/manage-db/src/main/resources/db/migration/V28__Add_is_recurring_to_group_course.sql create mode 100644 gym-manage-api/manage-db/src/main/resources/db/migration/V29__Create_Banner_table.sql create mode 100644 gym-manage-cuit/src/api/modules/banner.ts create mode 100644 gym-manage-cuit/src/api/modules/config.ts create mode 100644 gym-manage-cuit/src/api/modules/payment.ts create mode 100644 gym-manage-cuit/src/views/banner/index.vue create mode 100644 gym-manage-cuit/src/views/payment/revenue/index.vue diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/dao/BannerDao.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/dao/BannerDao.java new file mode 100644 index 0000000..cb50780 --- /dev/null +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/dao/BannerDao.java @@ -0,0 +1,34 @@ +package cn.novalon.gym.manage.groupcourse.dao; + +import cn.novalon.gym.manage.groupcourse.entity.BannerEntity; +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.stereotype.Repository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +@Repository +public interface BannerDao extends R2dbcRepository { + + Mono findByIdAndDeletedAtIsNull(Long id); + + Flux findAllByDeletedAtIsNull(); + + Flux findAllByDeletedAtIsNull(Sort sort); + + Flux findByIsActiveTrueAndDeletedAtIsNull(); + + Flux findByIsActiveTrueAndDeletedAtIsNull(Sort sort); + + @Modifying + @Query("UPDATE banner SET is_active = :isActive, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL") + Mono updateActiveStatus(Long id, Boolean isActive, LocalDateTime updatedAt); + + @Modifying + @Query("UPDATE banner SET deleted_at = :deletedAt WHERE id = :id") + Mono softDelete(Long id, LocalDateTime deletedAt); +} diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/dao/GroupCourseDao.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/dao/GroupCourseDao.java index a2a098f..cd13ac4 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/dao/GroupCourseDao.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/dao/GroupCourseDao.java @@ -48,6 +48,10 @@ public interface GroupCourseDao extends R2dbcRepository @Query("UPDATE group_course SET status = '2', updated_at = :updatedAt WHERE status = '0' AND end_time <= NOW() AND deleted_at IS NULL") Mono completeExpiredCourses(LocalDateTime updatedAt); + @Modifying + @Query("UPDATE group_course SET status = '0', current_members = 0, start_time = start_time + INTERVAL '7 days', end_time = end_time + INTERVAL '7 days', updated_at = :updatedAt WHERE is_recurring = TRUE AND status = '2' AND end_time <= NOW() AND deleted_at IS NULL") + Mono renewRecurringCourses(LocalDateTime updatedAt); + Flux findByCourseTypeAndDeletedAtIsNull(Long courseType); /** @@ -96,6 +100,11 @@ public interface GroupCourseDao extends R2dbcRepository } } + // 5. 常态化团课筛选 + if (query.getIsRecurring() != null) { + conditions.add("is_recurring = :isRecurring"); + } + sql.append(" AND ").append(String.join(" AND ", conditions)); // 5. 价格排序 / 6. 剩余名额最多排序 @@ -145,6 +154,9 @@ public interface GroupCourseDao extends R2dbcRepository if (query.getEndDate() != null) { spec = spec.bind("endDate", query.getEndDate()); } + if (query.getIsRecurring() != null) { + spec = spec.bind("isRecurring", query.getIsRecurring()); + } spec = spec.bind("limit", size); spec = spec.bind("offset", offset); @@ -169,6 +181,7 @@ public interface GroupCourseDao extends R2dbcRepository entity.setCreatedAt(row.get("created_at", LocalDateTime.class)); entity.setUpdatedAt(row.get("updated_at", LocalDateTime.class)); entity.setDeletedAt(row.get("deleted_at", LocalDateTime.class)); + entity.setIsRecurring(row.get("is_recurring", Boolean.class)); return entity; }).all(); } @@ -211,6 +224,10 @@ public interface GroupCourseDao extends R2dbcRepository } } + if (query.getIsRecurring() != null) { + conditions.add("is_recurring = :isRecurring"); + } + sql.append(" AND ").append(String.join(" AND ", conditions)); DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString()); @@ -227,6 +244,9 @@ public interface GroupCourseDao extends R2dbcRepository if (query.getEndDate() != null) { spec = spec.bind("endDate", query.getEndDate()); } + if (query.getIsRecurring() != null) { + spec = spec.bind("isRecurring", query.getIsRecurring()); + } return spec.map((row, meta) -> row.get(0, Long.class)).one(); } diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/domain/Banner.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/domain/Banner.java new file mode 100644 index 0000000..22e8b02 --- /dev/null +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/domain/Banner.java @@ -0,0 +1,43 @@ +package cn.novalon.gym.manage.groupcourse.domain; + +import cn.novalon.gym.manage.sys.core.domain.BaseDomain; +import io.swagger.v3.oas.annotations.media.Schema; + +public class Banner extends BaseDomain { + + @Schema(description = "背景图URL", example = "https://example.com/banner.jpg") + private String imageUrl; + + @Schema(description = "主标题", example = "突破自我") + private String title; + + @Schema(description = "副标题", example = "超越极限") + private String subtitle; + + @Schema(description = "简介", example = "科学训练 · 遇见更好的自己") + private String description; + + @Schema(description = "排序(数值越大越靠前)", example = "10") + private Integer sortOrder; + + @Schema(description = "是否启用", example = "true") + private Boolean isActive; + + public String getImageUrl() { return imageUrl; } + public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public String getSubtitle() { return subtitle; } + public void setSubtitle(String subtitle) { this.subtitle = subtitle; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public Integer getSortOrder() { return sortOrder; } + public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; } + + public Boolean getIsActive() { return isActive; } + public void setIsActive(Boolean isActive) { this.isActive = isActive; } +} diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/domain/GroupCourse.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/domain/GroupCourse.java index 2369e2d..60b6218 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/domain/GroupCourse.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/domain/GroupCourse.java @@ -60,6 +60,10 @@ public class GroupCourse extends BaseDomain{ @Schema(description = "二维码路径", example = "D:\\Games\\exmp\\image\\abc123_20260618120000.png") private String qrCodePath; + //是否常态化团课 + @Schema(description = "是否常态化团课", example = "true") + private Boolean isRecurring; + public String getCourseName() { return courseName; } @@ -163,4 +167,12 @@ public class GroupCourse extends BaseDomain{ public void setQrCodePath(String qrCodePath) { this.qrCodePath = qrCodePath; } + + public Boolean getIsRecurring() { + return isRecurring; + } + + public void setIsRecurring(Boolean isRecurring) { + this.isRecurring = isRecurring; + } } diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/dto/GroupCourseQueryDto.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/dto/GroupCourseQueryDto.java index c38b800..982a560 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/dto/GroupCourseQueryDto.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/dto/GroupCourseQueryDto.java @@ -40,6 +40,9 @@ public class GroupCourseQueryDto { @Schema(description = "每页大小", example = "10") private Integer size = 10; + @Schema(description = "是否常态化团课筛选:null-不过滤, true-仅常态化, false-仅非常态化", example = "true") + private Boolean isRecurring; + // ===== Getters and Setters ===== public String getCourseName() { @@ -113,4 +116,12 @@ public class GroupCourseQueryDto { public void setSize(Integer size) { this.size = size; } + + public Boolean getIsRecurring() { + return isRecurring; + } + + public void setIsRecurring(Boolean isRecurring) { + this.isRecurring = isRecurring; + } } \ No newline at end of file diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/entity/BannerEntity.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/entity/BannerEntity.java new file mode 100644 index 0000000..7dba7ee --- /dev/null +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/entity/BannerEntity.java @@ -0,0 +1,45 @@ +package cn.novalon.gym.manage.groupcourse.entity; + +import cn.novalon.gym.manage.db.entity.BaseEntity; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.Table; + +@Table("banner") +public class BannerEntity extends BaseEntity { + + @Column("image_url") + private String imageUrl; + + @Column("title") + private String title; + + @Column("subtitle") + private String subtitle; + + @Column("description") + private String description; + + @Column("sort_order") + private Integer sortOrder; + + @Column("is_active") + private Boolean isActive; + + public String getImageUrl() { return imageUrl; } + public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public String getSubtitle() { return subtitle; } + public void setSubtitle(String subtitle) { this.subtitle = subtitle; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public Integer getSortOrder() { return sortOrder; } + public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; } + + public Boolean getIsActive() { return isActive; } + public void setIsActive(Boolean isActive) { this.isActive = isActive; } +} diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/entity/GroupCourseEntity.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/entity/GroupCourseEntity.java index 5ef3fa7..1982410 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/entity/GroupCourseEntity.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/entity/GroupCourseEntity.java @@ -62,6 +62,10 @@ public class GroupCourseEntity extends BaseEntity { @Column("qr_code_path") private String qrCodePath; + //是否常态化团课 + @Column("is_recurring") + private Boolean isRecurring; + public String getCourseName() { return courseName; } @@ -165,4 +169,12 @@ public class GroupCourseEntity extends BaseEntity { public void setQrCodePath(String qrCodePath) { this.qrCodePath = qrCodePath; } + + public Boolean getIsRecurring() { + return isRecurring; + } + + public void setIsRecurring(Boolean isRecurring) { + this.isRecurring = isRecurring; + } } diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/BannerHandler.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/BannerHandler.java new file mode 100644 index 0000000..5fabbea --- /dev/null +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/BannerHandler.java @@ -0,0 +1,224 @@ +package cn.novalon.gym.manage.groupcourse.handler; + +import cn.novalon.gym.manage.groupcourse.domain.Banner; +import cn.novalon.gym.manage.groupcourse.service.IBannerService; +import cn.novalon.gym.manage.sys.core.service.ISysUserService; +import cn.novalon.gym.manage.sys.util.AuthUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Mono; + +import java.util.HashMap; +import java.util.Map; + +@Component +@Tag(name = "轮播图管理", description = "轮播图相关操作") +public class BannerHandler { + + private final IBannerService bannerService; + private final ISysUserService sysUserService; + private final AuthUtil authUtil; + + public BannerHandler(IBannerService bannerService, + ISysUserService sysUserService, + AuthUtil authUtil) { + this.bannerService = bannerService; + this.sysUserService = sysUserService; + this.authUtil = authUtil; + } + + @Operation(summary = "获取所有轮播图", description = "获取系统中所有轮播图列表,支持按排序字段排序") + public Mono getAllBanners(ServerRequest request) { + String sortBy = request.queryParam("sortBy").orElse("sortOrder"); + String sortOrder = request.queryParam("sortOrder").orElse("desc"); + + return ServerResponse.ok() + .body(bannerService.findAll(sortBy, sortOrder), Banner.class); + } + + @Operation(summary = "获取所有启用的轮播图", description = "获取系统中所有已启用的轮播图列表") + public Mono getAllActiveBanners(ServerRequest request) { + return ServerResponse.ok() + .body(bannerService.findAllActive(), Banner.class); + } + + @Operation(summary = "根据ID获取轮播图", description = "根据ID获取轮播图详情") + public Mono getBannerById(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return bannerService.findById(id) + .flatMap(banner -> ServerResponse.ok().bodyValue(banner)) + .switchIfEmpty(ServerResponse.notFound().build()); + } + + @Operation(summary = "创建轮播图", description = "创建新的轮播图记录") + public Mono createBanner(ServerRequest request) { + return request.bodyToMono(Banner.class) + .flatMap(banner -> { + if (banner.getImageUrl() == null || banner.getImageUrl().isBlank()) { + Map error = new HashMap<>(); + error.put("success", false); + error.put("message", "背景图不能为空"); + return ServerResponse.badRequest().bodyValue(error); + } + if (banner.getTitle() == null || banner.getTitle().isBlank()) { + Map error = new HashMap<>(); + error.put("success", false); + error.put("message", "主标题不能为空"); + return ServerResponse.badRequest().bodyValue(error); + } + + return bannerService.create(banner) + .flatMap(r -> { + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "轮播图创建成功"); + response.put("data", r); + return ServerResponse.ok().bodyValue(response); + }) + .onErrorResume(error -> { + Map response = new HashMap<>(); + response.put("success", false); + response.put("message", error.getMessage()); + return ServerResponse.badRequest().bodyValue(response); + }); + }); + } + + @Operation(summary = "更新轮播图", description = "更新指定轮播图信息,需验证管理员密码") + public Mono updateBanner(ServerRequest request) { + Long adminId = authUtil.getMemberIdOrThrow(request); + Long id = Long.valueOf(request.pathVariable("id")); + String adminPassword = request.queryParam("adminPassword").orElse(null); + + if (adminPassword == null || adminPassword.isBlank()) { + Map error = new HashMap<>(); + error.put("success", false); + error.put("message", "管理员密码不能为空"); + return ServerResponse.badRequest().bodyValue(error); + } + + return sysUserService.verifyPassword(adminId, adminPassword) + .flatMap(valid -> { + if (!valid) { + Map error = new HashMap<>(); + error.put("success", false); + error.put("message", "管理员密码错误"); + return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error); + } + return request.bodyToMono(Banner.class) + .flatMap(banner -> bannerService.update(id, banner) + .flatMap(r -> { + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "轮播图更新成功"); + response.put("data", r); + return ServerResponse.ok().bodyValue(response); + }) + .onErrorResume(error -> { + Map response = new HashMap<>(); + response.put("success", false); + response.put("message", error.getMessage()); + return ServerResponse.badRequest().bodyValue(response); + })); + }); + } + + @Operation(summary = "删除轮播图", description = "删除指定轮播图(软删除),需验证管理员密码") + public Mono deleteBanner(ServerRequest request) { + Long adminId = authUtil.getMemberIdOrThrow(request); + Long id = Long.valueOf(request.pathVariable("id")); + String adminPassword = request.queryParam("adminPassword").orElse(null); + + if (adminPassword == null || adminPassword.isBlank()) { + Map error = new HashMap<>(); + error.put("success", false); + error.put("message", "管理员密码不能为空"); + return ServerResponse.badRequest().bodyValue(error); + } + + return sysUserService.verifyPassword(adminId, adminPassword) + .flatMap(valid -> { + if (!valid) { + Map error = new HashMap<>(); + error.put("success", false); + error.put("message", "管理员密码错误"); + return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error); + } + return bannerService.delete(id) + .then(Mono.defer(() -> { + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "轮播图删除成功"); + return ServerResponse.ok().bodyValue(response); + })) + .onErrorResume(error -> { + Map response = new HashMap<>(); + response.put("success", false); + response.put("message", error.getMessage()); + return ServerResponse.badRequest().bodyValue(response); + }); + }); + } + + @Operation(summary = "启用轮播图", description = "启用指定轮播图") + public Mono enableBanner(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + + return bannerService.enable(id) + .flatMap(r -> { + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "轮播图启用成功"); + response.put("data", r); + return ServerResponse.ok().bodyValue(response); + }) + .onErrorResume(error -> { + Map response = new HashMap<>(); + response.put("success", false); + response.put("message", error.getMessage()); + return ServerResponse.badRequest().bodyValue(response); + }); + } + + @Operation(summary = "禁用轮播图", description = "禁用指定轮播图,需验证管理员密码") + public Mono disableBanner(ServerRequest request) { + Long adminId = authUtil.getMemberIdOrThrow(request); + Long id = Long.valueOf(request.pathVariable("id")); + String adminPassword = request.queryParam("adminPassword").orElse(null); + + if (adminPassword == null || adminPassword.isBlank()) { + Map error = new HashMap<>(); + error.put("success", false); + error.put("message", "管理员密码不能为空"); + return ServerResponse.badRequest().bodyValue(error); + } + + return sysUserService.verifyPassword(adminId, adminPassword) + .flatMap(valid -> { + if (!valid) { + Map error = new HashMap<>(); + error.put("success", false); + error.put("message", "管理员密码错误"); + return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error); + } + return bannerService.disable(id) + .flatMap(r -> { + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "轮播图禁用成功"); + response.put("data", r); + return ServerResponse.ok().bodyValue(response); + }) + .onErrorResume(error -> { + Map response = new HashMap<>(); + response.put("success", false); + response.put("message", error.getMessage()); + return ServerResponse.badRequest().bodyValue(response); + }); + }); + } +} diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/GroupCourseBookingHandler.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/GroupCourseBookingHandler.java index 954d773..6aa58dd 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/GroupCourseBookingHandler.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/GroupCourseBookingHandler.java @@ -177,4 +177,31 @@ public class GroupCourseBookingHandler { response.put("message", message); return ServerResponse.badRequest().bodyValue(response); } + + /** + * 扫码签到(二维码扫一扫签到) + * 用户扫描团课签到二维码后,更新预约记录状态为 2(已出席) + */ + @Operation(summary = "扫码签到", description = "用户扫描团课二维码签到,更新预约状态为已出席") + public Mono qrSignIn(ServerRequest request) { + Long courseId = Long.valueOf(request.pathVariable("courseId")); + + return request.bodyToMono(Map.class) + .flatMap(body -> { + if (body.get("memberId") == null) { + return buildErrorResponse("请提供会员ID"); + } + Long memberId = toLong(body.get("memberId"), "memberId"); + + return bookingService.qrSignIn(courseId, memberId) + .flatMap(booking -> { + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "签到成功"); + response.put("data", booking); + return ServerResponse.ok().bodyValue(response); + }) + .onErrorResume(error -> buildErrorResponse(error.getMessage())); + }); + } } \ No newline at end of file diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/GroupCourseHandler.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/GroupCourseHandler.java index 81daebb..3e44af5 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/GroupCourseHandler.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/handler/GroupCourseHandler.java @@ -10,6 +10,10 @@ import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService; import cn.novalon.gym.manage.sys.core.service.ISysUserService; import cn.novalon.gym.manage.sys.util.AuthUtil; import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.zxing.BarcodeFormat; +import com.google.zxing.client.j2se.MatrixToImageWriter; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.QRCodeWriter; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Validator; @@ -18,7 +22,10 @@ import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; +import java.io.ByteArrayOutputStream; +import java.util.Base64; import java.util.HashMap; import java.util.Map; @@ -372,4 +379,44 @@ public class GroupCourseHandler { }); }); } + + @Operation(summary = "获取团课签到二维码", description = "根据团课ID生成签到二维码(base64)") + public Mono getCourseQRCode(ServerRequest request) { + Long courseId = Long.valueOf(request.pathVariable("id")); + + return groupCourseService.findById(courseId) + .flatMap(course -> { + return Mono.fromCallable(() -> { + String qrContent = "{\"courseId\":" + course.getId() + + ",\"courseName\":\"" + escapeJson(course.getCourseName()) + "\"}"; + + QRCodeWriter qrCodeWriter = new QRCodeWriter(); + BitMatrix bitMatrix = qrCodeWriter.encode(qrContent, BarcodeFormat.QR_CODE, 300, 300); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + MatrixToImageWriter.writeToStream(bitMatrix, "PNG", outputStream); + byte[] pngBytes = outputStream.toByteArray(); + String base64 = Base64.getEncoder().encodeToString(pngBytes); + + Map result = new HashMap<>(); + result.put("base64", "data:image/png;base64," + base64); + result.put("courseId", course.getId()); + result.put("courseName", course.getCourseName()); + result.put("width", 300); + result.put("height", 300); + return result; + }).subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic()); + }) + .flatMap(result -> ServerResponse.ok().bodyValue(result)) + .switchIfEmpty(ServerResponse.notFound().build()); + } + + private String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } } diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/IBannerRepository.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/IBannerRepository.java new file mode 100644 index 0000000..afa51e0 --- /dev/null +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/IBannerRepository.java @@ -0,0 +1,24 @@ +package cn.novalon.gym.manage.groupcourse.repository; + +import cn.novalon.gym.manage.groupcourse.domain.Banner; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface IBannerRepository { + + Mono findById(Long id); + + Flux findAll(); + + Flux findAll(String sortBy, String sortOrder); + + Flux findAllActive(); + + Mono save(Banner banner); + + Mono update(Banner banner); + + Mono deleteById(Long id); + + Mono updateActiveStatus(Long id, Boolean isActive); +} diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/impl/BannerRepository.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/impl/BannerRepository.java new file mode 100644 index 0000000..b6489e6 --- /dev/null +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/impl/BannerRepository.java @@ -0,0 +1,113 @@ +package cn.novalon.gym.manage.groupcourse.repository.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.novalon.gym.manage.groupcourse.dao.BannerDao; +import cn.novalon.gym.manage.groupcourse.domain.Banner; +import cn.novalon.gym.manage.groupcourse.entity.BannerEntity; +import cn.novalon.gym.manage.groupcourse.repository.IBannerRepository; +import org.springframework.data.domain.Sort; +import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +@Repository +public class BannerRepository implements IBannerRepository { + + private final BannerDao bannerDao; + private final R2dbcEntityTemplate r2dbcEntityTemplate; + + public BannerRepository(BannerDao bannerDao, R2dbcEntityTemplate r2dbcEntityTemplate) { + this.bannerDao = bannerDao; + this.r2dbcEntityTemplate = r2dbcEntityTemplate; + } + + @Override + public Mono findById(Long id) { + return bannerDao.findByIdAndDeletedAtIsNull(id) + .map(this::toDomain); + } + + @Override + public Flux findAll() { + return bannerDao.findAllByDeletedAtIsNull() + .map(this::toDomain); + } + + @Override + public Flux findAll(String sortBy, String sortOrder) { + Sort.Direction direction = "asc".equalsIgnoreCase(sortOrder) + ? Sort.Direction.ASC + : Sort.Direction.DESC; + Sort sort = Sort.by(direction, sortBy); + return bannerDao.findAllByDeletedAtIsNull(sort) + .map(this::toDomain); + } + + @Override + public Flux findAllActive() { + return bannerDao.findByIsActiveTrueAndDeletedAtIsNull(Sort.by(Sort.Direction.DESC, "sortOrder")) + .map(this::toDomain); + } + + @Override + public Mono save(Banner banner) { + BannerEntity entity = toEntity(banner); + entity.setCreatedAt(LocalDateTime.now()); + entity.setUpdatedAt(LocalDateTime.now()); + if (entity.getSortOrder() == null) { + entity.setSortOrder(0); + } + if (entity.getIsActive() == null) { + entity.setIsActive(true); + } + + return bannerDao.save(entity) + .map(this::toDomain); + } + + @Override + public Mono update(Banner banner) { + BannerEntity entity = toEntity(banner); + entity.setUpdatedAt(LocalDateTime.now()); + + return r2dbcEntityTemplate.update(entity) + .then(findById(banner.getId())); + } + + @Override + public Mono deleteById(Long id) { + return bannerDao.softDelete(id, LocalDateTime.now()) + .then(); + } + + @Override + public Mono updateActiveStatus(Long id, Boolean isActive) { + return bannerDao.updateActiveStatus(id, isActive, LocalDateTime.now()) + .flatMap(updated -> { + if (updated > 0) { + return findById(id); + } + return Mono.empty(); + }); + } + + private Banner toDomain(BannerEntity entity) { + if (entity == null) return null; + Banner banner = new Banner(); + BeanUtil.copyProperties(entity, banner); + return banner; + } + + private BannerEntity toEntity(Banner domain) { + if (domain == null) return null; + BannerEntity entity = new BannerEntity(); + BeanUtil.copyProperties(domain, entity); + if (domain.getId() != null) { + entity.markNotNew(); + } + return entity; + } +} diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/impl/GroupCourseRepository.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/impl/GroupCourseRepository.java index 8cf4198..4634238 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/impl/GroupCourseRepository.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/repository/impl/GroupCourseRepository.java @@ -138,6 +138,7 @@ public class GroupCourseRepository implements IGroupCourseRepository { entity.setUpdatedAt(LocalDateTime.now()); entity.setStatus(0L); entity.setCurrentMembers(0); + entity.setIsRecurring(groupCourse.getIsRecurring() != null ? groupCourse.getIsRecurring() : false); return groupCourseDao.save(entity) .map(groupCourseConverter::toDomain); @@ -147,6 +148,7 @@ public class GroupCourseRepository implements IGroupCourseRepository { public Mono update(GroupCourse groupCourse) { GroupCourseEntity entity = groupCourseConverter.toEntity(groupCourse); entity.setUpdatedAt(LocalDateTime.now()); + entity.setIsRecurring(groupCourse.getIsRecurring() != null ? groupCourse.getIsRecurring() : false); return r2dbcEntityTemplate.update(entity) .then(findByIdAndDeletedAtIsNull(groupCourse.getId())); diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/scheduler/GroupCourseRecurringScheduler.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/scheduler/GroupCourseRecurringScheduler.java new file mode 100644 index 0000000..024835e --- /dev/null +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/scheduler/GroupCourseRecurringScheduler.java @@ -0,0 +1,48 @@ +package cn.novalon.gym.manage.groupcourse.scheduler; + +import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * 常态化团课自动续期定时任务 + * + * 功能:定期检查已结束的常态化团课(is_recurring = TRUE 且 status = '2'), + * 自动重置状态为正常(status = '0'),并将课程时间推迟一周,重新开始。 + * + * @date 2026-06-29 + */ +@Component +public class GroupCourseRecurringScheduler { + + private static final Logger logger = LoggerFactory.getLogger(GroupCourseRecurringScheduler.class); + + private final GroupCourseDao groupCourseDao; + + public GroupCourseRecurringScheduler(GroupCourseDao groupCourseDao) { + this.groupCourseDao = groupCourseDao; + } + + /** + * 每分钟检查一次,将已结束的常态化团课重置为正常状态, + * 并将上课时间/下课时间各推迟一周 + */ + @Scheduled(fixedRate = 60000) + public void renewRecurringCourses() { + logger.debug("定时任务开始检查常态化团课,续期已结束的课程"); + + groupCourseDao.renewRecurringCourses(LocalDateTime.now()) + .subscribe( + count -> { + if (count > 0) { + logger.info("常态化团课续期完成,更新了 {} 条课程", count); + } + }, + error -> logger.error("常态化团课续期定时任务执行失败:{}", error.getMessage(), error) + ); + } +} diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/IBannerService.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/IBannerService.java new file mode 100644 index 0000000..a1ad66d --- /dev/null +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/IBannerService.java @@ -0,0 +1,26 @@ +package cn.novalon.gym.manage.groupcourse.service; + +import cn.novalon.gym.manage.groupcourse.domain.Banner; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface IBannerService { + + Mono findById(Long id); + + Flux findAll(); + + Flux findAll(String sortBy, String sortOrder); + + Flux findAllActive(); + + Mono create(Banner banner); + + Mono update(Long id, Banner banner); + + Mono delete(Long id); + + Mono enable(Long id); + + Mono disable(Long id); +} diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/IGroupCourseBookingService.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/IGroupCourseBookingService.java index ce1d068..f08f1c0 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/IGroupCourseBookingService.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/IGroupCourseBookingService.java @@ -62,4 +62,14 @@ public interface IGroupCourseBookingService { * @return 处理的记录数 */ Mono processAbsentMembers(); + + /** + * 扫码签到 + * 用户扫描团课二维码签到,将预约状态更新为已出席(2) + * + * @param courseId 团课ID + * @param memberId 会员ID + * @return 更新后的预约记录 + */ + Mono qrSignIn(Long courseId, Long memberId); } \ No newline at end of file diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/BannerService.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/BannerService.java new file mode 100644 index 0000000..a3460ba --- /dev/null +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/BannerService.java @@ -0,0 +1,99 @@ +package cn.novalon.gym.manage.groupcourse.service.impl; + +import cn.novalon.gym.manage.groupcourse.domain.Banner; +import cn.novalon.gym.manage.groupcourse.repository.IBannerRepository; +import cn.novalon.gym.manage.groupcourse.service.IBannerService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@Service +public class BannerService implements IBannerService { + + private static final Logger logger = LoggerFactory.getLogger(BannerService.class); + + private final IBannerRepository bannerRepository; + + public BannerService(IBannerRepository bannerRepository) { + this.bannerRepository = bannerRepository; + } + + @Override + public Mono findById(Long id) { + return bannerRepository.findById(id); + } + + @Override + public Flux findAll() { + return bannerRepository.findAll(); + } + + @Override + public Flux findAll(String sortBy, String sortOrder) { + return bannerRepository.findAll(sortBy, sortOrder); + } + + @Override + public Flux findAllActive() { + return bannerRepository.findAllActive(); + } + + @Override + public Mono create(Banner banner) { + if (banner.getImageUrl() == null || banner.getImageUrl().isBlank()) { + return Mono.error(new RuntimeException("背景图不能为空")); + } + if (banner.getTitle() == null || banner.getTitle().isBlank()) { + return Mono.error(new RuntimeException("主标题不能为空")); + } + + return bannerRepository.save(banner) + .doOnSuccess(r -> logger.info("轮播图创建成功 - id={}, title={}", r.getId(), r.getTitle())) + .doOnError(error -> logger.error("轮播图创建失败 - error: {}", error.getMessage())); + } + + @Override + public Mono update(Long id, Banner banner) { + return bannerRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("轮播图不存在"))) + .flatMap(existing -> { + if (banner.getImageUrl() != null) existing.setImageUrl(banner.getImageUrl()); + if (banner.getTitle() != null) existing.setTitle(banner.getTitle()); + if (banner.getSubtitle() != null) existing.setSubtitle(banner.getSubtitle()); + if (banner.getDescription() != null) existing.setDescription(banner.getDescription()); + if (banner.getSortOrder() != null) existing.setSortOrder(banner.getSortOrder()); + if (banner.getIsActive() != null) existing.setIsActive(banner.getIsActive()); + + return bannerRepository.update(existing); + }) + .doOnSuccess(r -> logger.info("轮播图更新成功 - id={}", id)) + .doOnError(error -> logger.error("轮播图更新失败 - id={}, error: {}", id, error.getMessage())); + } + + @Override + public Mono delete(Long id) { + return bannerRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("轮播图不存在"))) + .flatMap(banner -> bannerRepository.deleteById(id) + .doOnSuccess(v -> logger.info("轮播图删除成功 - id={}", id)) + .doOnError(error -> logger.error("轮播图删除失败 - id={}, error: {}", id, error.getMessage()))); + } + + @Override + public Mono enable(Long id) { + return bannerRepository.updateActiveStatus(id, true) + .switchIfEmpty(Mono.error(new RuntimeException("轮播图不存在"))) + .doOnSuccess(r -> logger.info("轮播图启用成功 - id={}", id)) + .doOnError(error -> logger.error("轮播图启用失败 - id={}, error: {}", id, error.getMessage())); + } + + @Override + public Mono disable(Long id) { + return bannerRepository.updateActiveStatus(id, false) + .switchIfEmpty(Mono.error(new RuntimeException("轮播图不存在"))) + .doOnSuccess(r -> logger.info("轮播图禁用成功 - id={}", id)) + .doOnError(error -> logger.error("轮播图禁用失败 - id={}, error: {}", id, error.getMessage())); + } +} diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseBookingService.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseBookingService.java index d8cb0f7..3bc185e 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseBookingService.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseBookingService.java @@ -9,6 +9,7 @@ import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService; 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; @@ -46,6 +47,7 @@ public class GroupCourseBookingService implements IGroupCourseBookingService { private final GroupCourseRedisService redisService; private final BookingReminderEventPublisher bookingReminderEventPublisher; private final BookingSagaHandler bookingSagaHandler; + private final DatabaseClient databaseClient; // 预约提前时间限制(分钟) private static final long BOOKING_MIN_ADVANCE_MINUTES = 30; @@ -56,12 +58,14 @@ public class GroupCourseBookingService implements IGroupCourseBookingService { IGroupCourseRepository courseRepository, GroupCourseRedisService redisService, BookingReminderEventPublisher bookingReminderEventPublisher, - BookingSagaHandler bookingSagaHandler) { + BookingSagaHandler bookingSagaHandler, + DatabaseClient databaseClient) { this.bookingRepository = bookingRepository; this.courseRepository = courseRepository; this.redisService = redisService; this.bookingReminderEventPublisher = bookingReminderEventPublisher; this.bookingSagaHandler = bookingSagaHandler; + this.databaseClient = databaseClient; } @Override @@ -347,6 +351,78 @@ public class GroupCourseBookingService implements IGroupCourseBookingService { .doOnComplete(() -> logger.debug("查询完成:courseId={}", courseId)); } + @Override + public Mono qrSignIn(Long courseId, Long memberId) { + logger.info("扫码签到:courseId={}, memberId={}", courseId, memberId); + + return courseRepository.findByIdAndDeletedAtIsNull(courseId) + .switchIfEmpty(Mono.error(new RuntimeException("团课不存在或已删除"))) + .flatMap(course -> { + // 校验1:团课状态必须为 0(正常) + Long status = course.getStatus(); + if (status == null || status != 0L) { + String msg; + if (status == null) msg = "课程状态异常"; + else if (status == 1L) msg = "课程已取消,无法签到"; + else if (status == 2L) msg = "课程已结束,无法签到"; + else msg = "课程状态不可签到"; + return Mono.error(new RuntimeException(msg)); + } + + // 校验2:用户是否预约了该课程(状态为 0-已预约) + return bookingRepository.findValidBooking(courseId, memberId) + .switchIfEmpty(Mono.error(new RuntimeException("您未预约此课程,无法签到"))) + .flatMap(booking -> { + // 校验3:预约状态必须为 0 + if (!"0".equals(booking.getStatus())) { + String msg; + if ("1".equals(booking.getStatus())) msg = "预约已取消"; + else if ("2".equals(booking.getStatus())) msg = "已签到,无需重复签到"; + else msg = "预约状态异常"; + return Mono.error(new RuntimeException(msg)); + } + + // 更新预约状态为 2(已出席) + return bookingRepository.updateStatus(booking.getId(), "2") + .then(Mono.defer(() -> { + // 同步写入签到记录,供仪表盘统计 + LocalDateTime now = LocalDateTime.now(); + LocalDateTime todayStart = now.toLocalDate().atStartOfDay(); + LocalDateTime todayEnd = todayStart.plusDays(1); + + // 先检查今天是否已有成功签到记录,避免重复 + return databaseClient.sql( + "SELECT sign_in_status FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time < :endTime AND is_delete = false AND sign_in_status = 'SUCCESS' ORDER BY sign_in_time DESC LIMIT 1") + .bind("memberId", memberId) + .bind("startTime", todayStart) + .bind("endTime", todayEnd) + .map(row -> row.get("sign_in_status", String.class)) + .one() + .flatMap(existing -> { + // 已有签到记录,不重复插入 + return bookingRepository.findById(booking.getId()); + }) + .switchIfEmpty( + // 无今日签到记录,插入一条 + databaseClient.sql( + "INSERT INTO sign_in_record (member_id, member_card_id, sign_in_time, sign_in_type, sign_in_status, source, created_at, updated_at, is_delete) " + + "VALUES (:memberId, :memberCardId, :signInTime, 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', NOW(), NOW(), false)") + .bind("memberId", memberId) + .bind("memberCardId", booking.getMemberCardRecordId()) + .bind("signInTime", now) + .fetch() + .rowsUpdated() + .then(bookingRepository.findById(booking.getId())) + ); + })); + }); + }) + .doOnSuccess(booking -> logger.info("扫码签到成功:bookingId={}, courseId={}, memberId={}", + booking.getId(), courseId, memberId)) + .doOnError(error -> logger.error("扫码签到失败:courseId={}, memberId={}, error={}", + courseId, memberId, error.getMessage())); + } + @Override public Mono processAbsentMembers() { logger.info("开始处理已开始课程但未到场会员的预约记录"); 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 5854561..4d1143f 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 @@ -347,6 +347,9 @@ public class GroupCourseService implements IGroupCourseService { if (groupCourse.getQrCodePath() != null) { existing.setQrCodePath(groupCourse.getQrCodePath()); } + if (groupCourse.getIsRecurring() != null) { + existing.setIsRecurring(groupCourse.getIsRecurring()); + } return groupCourseRepository.update(existing); }) .doOnSuccess(course -> logger.info("团课更新成功 - id={}", id)) diff --git a/gym-manage-api/gym-payment/src/main/java/cn/novalon/gym/manage/payment/dto/PaymentRecordResponse.java b/gym-manage-api/gym-payment/src/main/java/cn/novalon/gym/manage/payment/dto/PaymentRecordResponse.java new file mode 100644 index 0000000..75a0e7a --- /dev/null +++ b/gym-manage-api/gym-payment/src/main/java/cn/novalon/gym/manage/payment/dto/PaymentRecordResponse.java @@ -0,0 +1,54 @@ +package cn.novalon.gym.manage.payment.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 支付记录响应DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "支付记录") +public class PaymentRecordResponse { + + @Schema(description = "订单ID") + private Long id; + + @Schema(description = "订单号") + private String orderNo; + + @Schema(description = "用户编号(会员ID)") + private Long memberId; + + @Schema(description = "支付方式(ALIPAY/WECHAT)") + private String tradeType; + + @Schema(description = "购买内容(商品描述)") + private String goodsDesc; + + @Schema(description = "订单类型(MEMBER_CARD/GROUP_COURSE/GOODS)") + private String orderType; + + @Schema(description = "支付金额(元)") + private BigDecimal transAmt; + + @Schema(description = "支付状态(PENDING/SUCCESS/FAIL/CLOSED)") + private String payStatus; + + @Schema(description = "支付时间") + private String payTime; + + @Schema(description = "汇付流水号") + private String hfSeqId; + + @Schema(description = "创建时间") + private String createdAt; +} diff --git a/gym-manage-api/gym-payment/src/main/java/cn/novalon/gym/manage/payment/dto/RevenueStatistics.java b/gym-manage-api/gym-payment/src/main/java/cn/novalon/gym/manage/payment/dto/RevenueStatistics.java new file mode 100644 index 0000000..1eaed92 --- /dev/null +++ b/gym-manage-api/gym-payment/src/main/java/cn/novalon/gym/manage/payment/dto/RevenueStatistics.java @@ -0,0 +1,47 @@ +package cn.novalon.gym.manage.payment.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** + * 营业额统计数据 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "营业额统计数据") +public class RevenueStatistics { + + @Schema(description = "今日收入(元)") + private BigDecimal todayIncome; + + @Schema(description = "今日退款(元)") + private BigDecimal todayRefund; + + @Schema(description = "今日订单数") + private Long todayOrderCount; + + @Schema(description = "当月收入(元)") + private BigDecimal monthIncome; + + @Schema(description = "当月退款(元)") + private BigDecimal monthRefund; + + @Schema(description = "当月订单数") + private Long monthOrderCount; + + @Schema(description = "当年收入(元)") + private BigDecimal yearIncome; + + @Schema(description = "当年退款(元)") + private BigDecimal yearRefund; + + @Schema(description = "当年订单数") + private Long yearOrderCount; +} diff --git a/gym-manage-api/gym-payment/src/main/java/cn/novalon/gym/manage/payment/handler/PaymentRevenueHandler.java b/gym-manage-api/gym-payment/src/main/java/cn/novalon/gym/manage/payment/handler/PaymentRevenueHandler.java new file mode 100644 index 0000000..7ad77f5 --- /dev/null +++ b/gym-manage-api/gym-payment/src/main/java/cn/novalon/gym/manage/payment/handler/PaymentRevenueHandler.java @@ -0,0 +1,74 @@ +package cn.novalon.gym.manage.payment.handler; + +import cn.novalon.gym.manage.payment.dto.ApiResponse; +import cn.novalon.gym.manage.payment.service.PaymentService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Mono; + +/** + * 支付营业数据 Handler + * + * @author system + * @date 2026-06-29 + */ +@Slf4j +@Component +@Tag(name = "支付营业数据", description = "营业额统计与支付记录查询") +public class PaymentRevenueHandler { + + private final PaymentService paymentService; + + public PaymentRevenueHandler(PaymentService paymentService) { + this.paymentService = paymentService; + } + + @Operation(summary = "获取营业额统计", description = "获取今日、当月、当年的收入和退款情况") + public Mono getRevenueStatistics(ServerRequest request) { + log.info("[Revenue] 查询营业额统计"); + + return paymentService.getRevenueStatistics() + .flatMap(stats -> ServerResponse.ok() + .bodyValue(ApiResponse.success(stats))) + .onErrorResume(e -> { + log.error("[Revenue] 查询营业额统计失败", e); + return ServerResponse.ok() + .bodyValue(ApiResponse.error("查询营业额统计失败: " + e.getMessage())); + }); + } + + @Operation(summary = "查询支付记录", description = "分页查询用户支付记录,支持按会员ID、支付状态、支付方式筛选") + public Mono getPaymentRecords(ServerRequest request) { + String memberIdStr = request.queryParam("memberId").orElse(null); + Long memberId = null; + if (memberIdStr != null && !memberIdStr.isEmpty()) { + try { + memberId = Long.parseLong(memberIdStr); + } catch (NumberFormatException e) { + return ServerResponse.ok() + .bodyValue(ApiResponse.error("会员ID格式不正确")); + } + } + + String payStatus = request.queryParam("payStatus").orElse(null); + String tradeType = request.queryParam("tradeType").orElse(null); + int page = Integer.parseInt(request.queryParam("page").orElse("1")); + int pageSize = Integer.parseInt(request.queryParam("pageSize").orElse("20")); + + log.info("[Revenue] 查询支付记录: memberId={}, payStatus={}, tradeType={}, page={}, pageSize={}", + memberId, payStatus, tradeType, page, pageSize); + + return paymentService.getPaymentRecords(memberId, payStatus, tradeType, page, pageSize) + .flatMap(result -> ServerResponse.ok() + .bodyValue(ApiResponse.success(result))) + .onErrorResume(e -> { + log.error("[Revenue] 查询支付记录失败", e); + return ServerResponse.ok() + .bodyValue(ApiResponse.error("查询支付记录失败: " + e.getMessage())); + }); + } +} diff --git a/gym-manage-api/gym-payment/src/main/java/cn/novalon/gym/manage/payment/repository/PaymentOrderRepository.java b/gym-manage-api/gym-payment/src/main/java/cn/novalon/gym/manage/payment/repository/PaymentOrderRepository.java index fa1e4f4..8b59652 100644 --- a/gym-manage-api/gym-payment/src/main/java/cn/novalon/gym/manage/payment/repository/PaymentOrderRepository.java +++ b/gym-manage-api/gym-payment/src/main/java/cn/novalon/gym/manage/payment/repository/PaymentOrderRepository.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Repository; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import java.math.BigDecimal; import java.time.LocalDateTime; @Repository @@ -38,4 +39,24 @@ public interface PaymentOrderRepository extends R2dbcRepository findAllByDeletedAtIsNull(); Flux findByMemberIdAndDeletedAtIsNull(Long memberId); + + // ===== 营业额统计 ===== + + /** 统计指定时间范围内成功支付的金额总和 */ + @Query("SELECT COALESCE(SUM(trans_amt), 0) FROM payment_order WHERE pay_status = 'SUCCESS' AND pay_time >= :startTime AND pay_time < :endTime AND deleted_at IS NULL") + Mono sumSuccessAmount(LocalDateTime startTime, LocalDateTime endTime); + + /** 统计指定时间范围内的成功订单数 */ + @Query("SELECT COUNT(*) FROM payment_order WHERE pay_status = 'SUCCESS' AND pay_time >= :startTime AND pay_time < :endTime AND deleted_at IS NULL") + Mono countSuccessOrders(LocalDateTime startTime, LocalDateTime endTime); + + // ===== 支付记录分页查询 ===== + + /** 按条件分页查询支付记录 */ + @Query("SELECT * FROM payment_order WHERE (:memberId IS NULL OR member_id = :memberId) AND (:payStatus IS NULL OR pay_status = :payStatus) AND (:tradeType IS NULL OR trade_type = :tradeType) AND deleted_at IS NULL ORDER BY created_at DESC LIMIT :limit OFFSET :offset") + Flux findPaymentRecords(Long memberId, String payStatus, String tradeType, int limit, int offset); + + /** 按条件统计支付记录总数 */ + @Query("SELECT COUNT(*) FROM payment_order WHERE (:memberId IS NULL OR member_id = :memberId) AND (:payStatus IS NULL OR pay_status = :payStatus) AND (:tradeType IS NULL OR trade_type = :tradeType) AND deleted_at IS NULL") + Mono countPaymentRecords(Long memberId, String payStatus, String tradeType); } diff --git a/gym-manage-api/gym-payment/src/main/java/cn/novalon/gym/manage/payment/service/PaymentService.java b/gym-manage-api/gym-payment/src/main/java/cn/novalon/gym/manage/payment/service/PaymentService.java index 5e54cd8..f4759de 100644 --- a/gym-manage-api/gym-payment/src/main/java/cn/novalon/gym/manage/payment/service/PaymentService.java +++ b/gym-manage-api/gym-payment/src/main/java/cn/novalon/gym/manage/payment/service/PaymentService.java @@ -1,7 +1,9 @@ package cn.novalon.gym.manage.payment.service; import cn.novalon.gym.manage.payment.dto.CreatePaymentRequest; +import cn.novalon.gym.manage.payment.dto.PaymentRecordResponse; import cn.novalon.gym.manage.payment.dto.PaymentResponse; +import cn.novalon.gym.manage.payment.dto.RevenueStatistics; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -30,4 +32,10 @@ public interface PaymentService { Mono getPendingOrder(Long memberId, String orderType); Mono closeOrder(Long memberId, String orderId); + + /** 获取营业额统计(今日/当月/当年) */ + Mono getRevenueStatistics(); + + /** 分页查询支付记录 */ + Mono> getPaymentRecords(Long memberId, String payStatus, String tradeType, int page, int pageSize); } \ No newline at end of file diff --git a/gym-manage-api/gym-payment/src/main/java/cn/novalon/gym/manage/payment/service/impl/PaymentServiceImpl.java b/gym-manage-api/gym-payment/src/main/java/cn/novalon/gym/manage/payment/service/impl/PaymentServiceImpl.java index b9445b9..58f3c5b 100644 --- a/gym-manage-api/gym-payment/src/main/java/cn/novalon/gym/manage/payment/service/impl/PaymentServiceImpl.java +++ b/gym-manage-api/gym-payment/src/main/java/cn/novalon/gym/manage/payment/service/impl/PaymentServiceImpl.java @@ -3,7 +3,9 @@ package cn.novalon.gym.manage.payment.service.impl; import cn.novalon.gym.manage.common.util.RedisUtil; import cn.novalon.gym.manage.payment.config.HuifuProperties; import cn.novalon.gym.manage.payment.dto.CreatePaymentRequest; +import cn.novalon.gym.manage.payment.dto.PaymentRecordResponse; import cn.novalon.gym.manage.payment.dto.PaymentResponse; +import cn.novalon.gym.manage.payment.dto.RevenueStatistics; import cn.novalon.gym.manage.payment.entity.PaymentOrder; import cn.novalon.gym.manage.payment.repository.PaymentOrderRepository; import cn.novalon.gym.manage.payment.service.PaymentNotifyService; @@ -934,4 +936,76 @@ public class PaymentServiceImpl implements PaymentService { }) .switchIfEmpty(Mono.just(false)); } + + @Override + public Mono getRevenueStatistics() { + LocalDateTime now = LocalDateTime.now(); + // 今日 + LocalDateTime todayStart = now.toLocalDate().atStartOfDay(); + LocalDateTime todayEnd = todayStart.plusDays(1); + // 当月 + LocalDateTime monthStart = now.toLocalDate().withDayOfMonth(1).atStartOfDay(); + LocalDateTime monthEnd = monthStart.plusMonths(1); + // 当年 + LocalDateTime yearStart = now.toLocalDate().withDayOfYear(1).atStartOfDay(); + LocalDateTime yearEnd = yearStart.plusYears(1); + + Mono todayIncomeMono = paymentOrderRepository.sumSuccessAmount(todayStart, todayEnd); + Mono todayCountMono = paymentOrderRepository.countSuccessOrders(todayStart, todayEnd); + Mono monthIncomeMono = paymentOrderRepository.sumSuccessAmount(monthStart, monthEnd); + Mono monthCountMono = paymentOrderRepository.countSuccessOrders(monthStart, monthEnd); + Mono yearIncomeMono = paymentOrderRepository.sumSuccessAmount(yearStart, yearEnd); + Mono yearCountMono = paymentOrderRepository.countSuccessOrders(yearStart, yearEnd); + + return Mono.zip(todayIncomeMono, todayCountMono, monthIncomeMono, monthCountMono, yearIncomeMono, yearCountMono) + .map(tuple -> RevenueStatistics.builder() + .todayIncome(tuple.getT1() != null ? tuple.getT1() : BigDecimal.ZERO) + .todayRefund(BigDecimal.ZERO) // 退款功能暂未实现 + .todayOrderCount(tuple.getT2() != null ? tuple.getT2() : 0L) + .monthIncome(tuple.getT3() != null ? tuple.getT3() : BigDecimal.ZERO) + .monthRefund(BigDecimal.ZERO) // 退款功能暂未实现 + .monthOrderCount(tuple.getT4() != null ? tuple.getT4() : 0L) + .yearIncome(tuple.getT5() != null ? tuple.getT5() : BigDecimal.ZERO) + .yearRefund(BigDecimal.ZERO) // 退款功能暂未实现 + .yearOrderCount(tuple.getT6() != null ? tuple.getT6() : 0L) + .build()); + } + + @Override + public Mono> getPaymentRecords(Long memberId, String payStatus, String tradeType, int page, int pageSize) { + int offset = (page - 1) * pageSize; + + Mono> recordsMono = paymentOrderRepository + .findPaymentRecords(memberId, payStatus, tradeType, pageSize, offset) + .map(order -> PaymentRecordResponse.builder() + .id(order.getId()) + .orderNo(order.getOrderNo()) + .memberId(order.getMemberId()) + .tradeType(order.getTradeType()) + .goodsDesc(order.getGoodsDesc()) + .orderType(order.getOrderType()) + .transAmt(order.getTransAmt()) + .payStatus(order.getPayStatus()) + .payTime(order.getPayTime() != null + ? order.getPayTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + : null) + .hfSeqId(order.getHfSeqId()) + .createdAt(order.getCreatedAt() != null + ? order.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + : null) + .build()) + .collectList(); + + Mono totalMono = paymentOrderRepository.countPaymentRecords(memberId, payStatus, tradeType); + + return Mono.zip(recordsMono, totalMono) + .map(tuple -> { + Map result = new HashMap<>(); + result.put("records", tuple.getT1()); + result.put("total", tuple.getT2()); + result.put("page", page); + result.put("pageSize", pageSize); + return result; + }); + } } \ No newline at end of file diff --git a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java index edd345c..b4d04ef 100644 --- a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java +++ b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java @@ -7,6 +7,7 @@ import cn.novalon.gym.manage.file.handler.SysFileHandler; import cn.novalon.gym.manage.auth.handler.PhoneAuthHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseBookingHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseHandler; +import cn.novalon.gym.manage.groupcourse.handler.BannerHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseRecommendHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseTypeHandler; import cn.novalon.gym.manage.groupcourse.handler.CourseLabelHandler; @@ -20,6 +21,7 @@ import cn.novalon.gym.manage.member.handler.WechatAuthHandler; import cn.novalon.gym.manage.notify.handler.SysNoticeHandler; import cn.novalon.gym.manage.notify.handler.SysUserMessageHandler; import cn.novalon.gym.manage.payment.handler.PaymentHandler; +import cn.novalon.gym.manage.payment.handler.PaymentRevenueHandler; import cn.novalon.gym.manage.sys.handler.auth.PasswordDiagnosticHandler; import cn.novalon.gym.manage.sys.handler.auth.SysAuthHandler; import cn.novalon.gym.manage.sys.handler.config.SysConfigHandler; @@ -80,10 +82,12 @@ public class SystemRouter { GroupCourseRecommendHandler groupCourseRecommendHandler, GroupCourseTypeHandler groupCourseTypeHandler, CourseLabelHandler courseLabelHandler, + BannerHandler bannerHandler, CheckInHandler checkInHandler, DataStatisticsHandler dataStatisticsHandler, PhoneAuthHandler phoneAuthHandler, PaymentHandler paymentHandler, + PaymentRevenueHandler paymentRevenueHandler, CommonUploadHandler commonUploadHandler) { return route() @@ -348,7 +352,19 @@ public class SystemRouter { .POST("/api/groupCourse/recommend/{id}/enable", groupCourseRecommendHandler::enableRecommendation) .POST("/api/groupCourse/recommend/{id}/disable", groupCourseRecommendHandler::disableRecommendation) + // ========== 轮播图路由 ========== + .GET("/api/banner/list", bannerHandler::getAllBanners) + .GET("/api/banner/active", bannerHandler::getAllActiveBanners) + .GET("/api/banner/{id}", bannerHandler::getBannerById) + .POST("/api/banner", bannerHandler::createBanner) + .PUT("/api/banner/{id}", bannerHandler::updateBanner) + .DELETE("/api/banner/{id}", bannerHandler::deleteBanner) + .POST("/api/banner/{id}/enable", bannerHandler::enableBanner) + .POST("/api/banner/{id}/disable", bannerHandler::disableBanner) + // ===== 团课课程管理(需要放在具体路由之后)===== + .GET("/api/groupCourse/{id}/qrcode", groupCourseHandler::getCourseQRCode) + .POST("/api/groupCourse/{courseId}/qrsignin", groupCourseBookingHandler::qrSignIn) .GET("/api/groupCourse/{id}", groupCourseHandler::getGroupCourseById) .GET("/api/groupCourse/{id}/detail", groupCourseHandler::getGroupCourseDetailById) .POST("/api/groupCourse", groupCourseHandler::createGroupCourse) @@ -404,6 +420,10 @@ public class SystemRouter { .POST("/api/payment/{orderId}/refund", paymentHandler::refundPayment) .POST("/api/payment/{orderId}/close", paymentHandler::closeOrder) + // ===== 支付营业数据 ===== + .GET("/api/payment/revenue/statistics", paymentRevenueHandler::getRevenueStatistics) + .GET("/api/payment/revenue/records", paymentRevenueHandler::getPaymentRecords) + .build(); } } diff --git a/gym-manage-api/manage-app/src/main/resources/application-dev.yml b/gym-manage-api/manage-app/src/main/resources/application-dev.yml index 5f83ff0..8d78df9 100644 --- a/gym-manage-api/manage-app/src/main/resources/application-dev.yml +++ b/gym-manage-api/manage-app/src/main/resources/application-dev.yml @@ -15,7 +15,7 @@ spring: url: jdbc:postgresql://localhost:55432/manage_system user: novalon password: novalon123 - enabled: false + enabled: true locations: classpath:db/migration baseline-on-migrate: true validate-on-migrate: false diff --git a/gym-manage-api/manage-db/src/main/resources/db/migration/V28__Add_is_recurring_to_group_course.sql b/gym-manage-api/manage-db/src/main/resources/db/migration/V28__Add_is_recurring_to_group_course.sql new file mode 100644 index 0000000..1e5e964 --- /dev/null +++ b/gym-manage-api/manage-db/src/main/resources/db/migration/V28__Add_is_recurring_to_group_course.sql @@ -0,0 +1,7 @@ +-- ============================================ +-- 团课新增常态化标记 +-- ============================================ + +ALTER TABLE group_course ADD COLUMN IF NOT EXISTS is_recurring BOOLEAN DEFAULT FALSE; + +COMMENT ON COLUMN group_course.is_recurring IS '是否常态化团课:TRUE-是,FALSE-否(默认)'; diff --git a/gym-manage-api/manage-db/src/main/resources/db/migration/V29__Create_Banner_table.sql b/gym-manage-api/manage-db/src/main/resources/db/migration/V29__Create_Banner_table.sql new file mode 100644 index 0000000..830ded0 --- /dev/null +++ b/gym-manage-api/manage-db/src/main/resources/db/migration/V29__Create_Banner_table.sql @@ -0,0 +1,36 @@ +-- ============================================ +-- 轮播图表 +-- ============================================ + +CREATE TABLE IF NOT EXISTS banner ( + id BIGSERIAL PRIMARY KEY, + image_url TEXT NOT NULL, + title VARCHAR(100) NOT NULL, + subtitle VARCHAR(100), + description TEXT, + sort_order INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT TRUE, + create_by VARCHAR(50), + update_by VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_banner_sort_order ON banner(sort_order); +CREATE INDEX IF NOT EXISTS idx_banner_is_active ON banner(is_active); +CREATE INDEX IF NOT EXISTS idx_banner_deleted_at ON banner(deleted_at); + +COMMENT ON TABLE banner IS '轮播图表'; +COMMENT ON COLUMN banner.id IS '主键ID'; +COMMENT ON COLUMN banner.image_url IS '背景图URL'; +COMMENT ON COLUMN banner.title IS '主标题'; +COMMENT ON COLUMN banner.subtitle IS '副标题'; +COMMENT ON COLUMN banner.description IS '简介'; +COMMENT ON COLUMN banner.sort_order IS '排序(数值越大越靠前)'; +COMMENT ON COLUMN banner.is_active IS '是否启用'; +COMMENT ON COLUMN banner.create_by IS '创建人'; +COMMENT ON COLUMN banner.update_by IS '更新人'; +COMMENT ON COLUMN banner.created_at IS '创建时间'; +COMMENT ON COLUMN banner.updated_at IS '更新时间'; +COMMENT ON COLUMN banner.deleted_at IS '删除时间(软删除)'; diff --git a/gym-manage-cuit/src/api/modules/banner.ts b/gym-manage-cuit/src/api/modules/banner.ts new file mode 100644 index 0000000..d3e4284 --- /dev/null +++ b/gym-manage-cuit/src/api/modules/banner.ts @@ -0,0 +1,34 @@ +import { get, post, put, del } from '@/api/request' +import type { AxiosRequestConfig } from 'axios' + +export function getAllBanners(params?: any) { + return get('/api/banner/list', params) +} + +export function getActiveBanners() { + return get('/api/banner/active') +} + +export function getBannerById(id: number) { + return get(`/api/banner/${id}`) +} + +export function createBanner(data: any) { + return post('/api/banner', data) +} + +export function updateBanner(id: number, data: any, config?: AxiosRequestConfig) { + return put(`/api/banner/${id}`, data, config) +} + +export function deleteBanner(id: number, config?: AxiosRequestConfig) { + return del(`/api/banner/${id}`, config) +} + +export function enableBanner(id: number) { + return post(`/api/banner/${id}/enable`) +} + +export function disableBanner(id: number, config?: AxiosRequestConfig) { + return post(`/api/banner/${id}/disable`, null, config) +} diff --git a/gym-manage-cuit/src/api/modules/config.ts b/gym-manage-cuit/src/api/modules/config.ts new file mode 100644 index 0000000..9b3f8ac --- /dev/null +++ b/gym-manage-cuit/src/api/modules/config.ts @@ -0,0 +1,22 @@ +import { get, put } from '@/api/request' + +/** + * 获取所有配置列表 + */ +export function getAllConfigs(params?: any) { + return get('/api/config', params) +} + +/** + * 根据配置键获取配置 + */ +export function getConfigByKey(configKey: string) { + return get(`/api/config/key/${configKey}`) +} + +/** + * 更新配置 + */ +export function updateConfig(id: number, data: any) { + return put(`/api/config/${id}`, data) +} diff --git a/gym-manage-cuit/src/api/modules/groupCourse.ts b/gym-manage-cuit/src/api/modules/groupCourse.ts index 4bae236..2f75a9f 100644 --- a/gym-manage-cuit/src/api/modules/groupCourse.ts +++ b/gym-manage-cuit/src/api/modules/groupCourse.ts @@ -53,3 +53,7 @@ export function cancelGroupCourse(id: number) { export function searchGroupCourses(data: any) { return post('/api/groupCourse/search', data) } + +export function getGroupCourseQRCode(id: number) { + return get(`/api/groupCourse/${id}/qrcode`) +} diff --git a/gym-manage-cuit/src/api/modules/payment.ts b/gym-manage-cuit/src/api/modules/payment.ts new file mode 100644 index 0000000..0474f69 --- /dev/null +++ b/gym-manage-cuit/src/api/modules/payment.ts @@ -0,0 +1,9 @@ +import { get } from '@/api/request' + +export function getRevenueStatistics() { + return get('/api/payment/revenue/statistics') +} + +export function getPaymentRecords(params?: any) { + return get('/api/payment/revenue/records', params) +} diff --git a/gym-manage-cuit/src/router/routes/index.ts b/gym-manage-cuit/src/router/routes/index.ts index f40a364..19d80c6 100644 --- a/gym-manage-cuit/src/router/routes/index.ts +++ b/gym-manage-cuit/src/router/routes/index.ts @@ -55,12 +55,24 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/groupCourseRecommend/index.vue'), meta: { title: '推荐团课', icon: 'Star' }, }, + { + path: 'banner', + name: 'Banner', + component: () => import('@/views/banner/index.vue'), + meta: { title: '轮播图', icon: 'Picture' }, + }, { path: 'datacount', name: 'DataCount', component: () => import('@/views/datacount/index.vue'), meta: { title: '数据报表', icon: 'DataAnalysis' }, }, + { + path: 'payment/revenue', + name: 'PaymentRevenue', + component: () => import('@/views/payment/revenue/index.vue'), + meta: { title: '营收详情', icon: 'Money' }, + }, { path: 'operationLog', name: 'OperationLog', diff --git a/gym-manage-cuit/src/stores/permission.ts b/gym-manage-cuit/src/stores/permission.ts index 1195c65..cc75c69 100644 --- a/gym-manage-cuit/src/stores/permission.ts +++ b/gym-manage-cuit/src/stores/permission.ts @@ -39,6 +39,11 @@ const allMenus: MenuItem[] = [ { path: '/groupCourse/recommend', name: 'GroupCourseRecommend', meta: { title: '推荐管理', icon: 'Star', permission: 'business:groupCourseRecommend:view' } }, ], }, + { + path: '/banner', + name: 'Banner', + meta: { title: '轮播图', icon: 'Picture' }, + }, { path: '/checkIn', name: 'CheckIn', @@ -49,6 +54,11 @@ const allMenus: MenuItem[] = [ name: 'DataCount', meta: { title: '数据报表', icon: 'DataAnalysis', permission: 'business:dataCount:view' }, }, + { + path: '/payment/revenue', + name: 'PaymentRevenue', + meta: { title: '营收详情', icon: 'Money', permission: 'business:payment:view' }, + }, { path: '/operationLog', name: 'OperationLog', diff --git a/gym-manage-cuit/src/views/banner/index.vue b/gym-manage-cuit/src/views/banner/index.vue new file mode 100644 index 0000000..4b1be00 --- /dev/null +++ b/gym-manage-cuit/src/views/banner/index.vue @@ -0,0 +1,496 @@ + + + + + diff --git a/gym-manage-cuit/src/views/dashboard/index.vue b/gym-manage-cuit/src/views/dashboard/index.vue index 0b855a2..f98571d 100644 --- a/gym-manage-cuit/src/views/dashboard/index.vue +++ b/gym-manage-cuit/src/views/dashboard/index.vue @@ -6,12 +6,14 @@ import { getBookingStatistics, getSignInStatistics, } from '@/api/modules/datacount' +import { getRevenueStatistics } from '@/api/modules/payment' import dayjs from 'dayjs' interface StatCard { label: string value: number | string color: string + suffix?: string } const loading = ref(false) @@ -20,6 +22,7 @@ const statCards = ref([ { label: '今日新增', value: 0, color: '#52c41a' }, { label: '今日签到', value: 0, color: '#722ed1' }, { label: '今日预约', value: 0, color: '#faad14' }, + { label: '今日营业额', value: 0, color: '#e6a23c', suffix: '元' }, ]) const memberChartRef = ref() @@ -92,7 +95,7 @@ async function fetchData() { }).catch(() => {}), getBookingStatistics(range).then((res: any) => { - bookings[i] = res?.newBookings ?? 0 + bookingData[i] = res?.newBookings ?? 0 cancelData[i] = res?.cancelBookings ?? 0 if (i === todayIdx) { statCards.value[3].value = res?.newBookings ?? 0 @@ -107,6 +110,17 @@ async function fetchData() { }).catch(() => {}), ) } + + // 营业额统计 + promises.push( + getRevenueStatistics().then((res: any) => { + const data = res?.data + if (data) { + statCards.value[4].value = (data.todayIncome ?? 0).toFixed(2) + } + }).catch(() => {}), + ) + await Promise.allSettled(promises) } catch { // fallback: empty data @@ -192,19 +206,24 @@ onMounted(() => {

仪表盘

- - - -
-
+ +
+ +
+
+ +
- - - +
{{ card.label }}
+
+
+
+ @@ -227,3 +246,14 @@ onMounted(() => {
+ + diff --git a/gym-manage-cuit/src/views/datacount/index.vue b/gym-manage-cuit/src/views/datacount/index.vue index 984ccbc..6a475ba 100644 --- a/gym-manage-cuit/src/views/datacount/index.vue +++ b/gym-manage-cuit/src/views/datacount/index.vue @@ -7,6 +7,7 @@ import { getSignInStatistics, getHistoricalStatistics, } from '@/api/modules/datacount' +import { getPaymentRecords } from '@/api/modules/payment' import { download } from '@/api/request' import { ElMessage } from 'element-plus' import dayjs from 'dayjs' @@ -27,6 +28,88 @@ const filters = reactive({ periodType: 'DAY', }) +// ===== 支付记录 ===== +const paymentLoading = ref(false) +const paymentList = ref([]) +const paymentTotal = ref(0) +const paymentPage = ref(1) +const paymentPageSize = ref(10) +const paymentFilters = reactive({ + memberId: '', + payStatus: '', + tradeType: '', +}) + +function tradeTypeLabel(type: string) { + const map: Record = { ALIPAY: '支付宝', WECHAT: '微信', QRCODE: '扫码支付' } + return map[type] || type || '-' +} + +function payStatusLabel(status: string) { + const map: Record = { PENDING: '待支付', SUCCESS: '成功', FAIL: '失败', CLOSED: '已关闭' } + return map[status] || status || '-' +} + +function payStatusTagType(status: string) { + const map: Record = { PENDING: 'warning', SUCCESS: 'success', FAIL: 'danger', CLOSED: 'info' } + return map[status] || 'info' +} + +function orderTypeLabel(type: string) { + const map: Record = { + MEMBER_CARD: '会员卡', + GROUP_COURSE: '团课', + GOODS: '商品', + STORED_CARD_RECHARGE: '储值卡充值', + } + return map[type] || type || '-' +} + +async function fetchPaymentData() { + paymentLoading.value = true + try { + const params: any = { page: paymentPage.value, pageSize: paymentPageSize.value } + if (paymentFilters.memberId) params.memberId = paymentFilters.memberId + if (paymentFilters.payStatus) params.payStatus = paymentFilters.payStatus + if (paymentFilters.tradeType) params.tradeType = paymentFilters.tradeType + const res: any = await getPaymentRecords(params) + if (res?.data) { + paymentList.value = res.data.records || [] + paymentTotal.value = res.data.total || 0 + } + } catch { + paymentList.value = [] + paymentTotal.value = 0 + } finally { + paymentLoading.value = false + } +} + +function handlePaymentSearch() { + paymentPage.value = 1 + fetchPaymentData() +} + +function handlePaymentReset() { + paymentFilters.memberId = '' + paymentFilters.payStatus = '' + paymentFilters.tradeType = '' + paymentPage.value = 1 + fetchPaymentData() +} + +function handlePaymentPageChange(page: number) { + paymentPage.value = page + fetchPaymentData() +} + +function handlePaymentSizeChange(size: number) { + paymentPageSize.value = size + paymentPage.value = 1 + fetchPaymentData() +} + +// ===== 数据报表 ===== async function fetchData() { loading.value = true try { @@ -124,6 +207,7 @@ function dateLabel(d: string | null) { onMounted(() => { fetchData() + fetchPaymentData() }) @@ -256,7 +340,7 @@ onMounted(() => { - + @@ -281,6 +365,88 @@ onMounted(() => { 暂无历史统计数据
+ + + + + + + + + + + + + + + + + + + + + + + + + + 查询 + 重置 + + + + + + + + + + + + + + + + + + + + + + + + +
+ 暂无支付记录 +
+ +
+ +
+
@@ -288,4 +454,10 @@ onMounted(() => { .search-card { margin-bottom: 16px; } +.empty-tip { + color: #909399; + font-size: 13px; + text-align: center; + padding: 40px; +} diff --git a/gym-manage-cuit/src/views/groupCourse/index.vue b/gym-manage-cuit/src/views/groupCourse/index.vue index 09a7958..56bf7c4 100644 --- a/gym-manage-cuit/src/views/groupCourse/index.vue +++ b/gym-manage-cuit/src/views/groupCourse/index.vue @@ -9,6 +9,7 @@ import { restoreGroupCourse, uploadImage, getPresignedUrl, + getGroupCourseQRCode, } from '@/api/modules/groupCourse' import { getAllGroupCourseTypes } from '@/api/modules/groupCourseType' import { formatDate } from '@/utils/format' @@ -27,6 +28,7 @@ interface CourseItem { coverImage: string | null description: string storedValueAmount: number + isRecurring: boolean | null createdAt: string | null updatedAt: string | null deletedAt: string | null @@ -72,6 +74,12 @@ const saving = ref(false) const adminPassword = ref('') const pendingCoverFile = ref(null) const coverPreviewUrl = ref('') +const recurringEnabled = ref(false) + +// ===== 二维码弹窗 ===== +const qrVisible = ref(false) +const qrLoading = ref(false) +const qrData = ref<{ base64: string; courseId: number; courseName: string } | null>(null) const form = reactive({ id: 0, @@ -79,9 +87,8 @@ const form = reactive({ coachId: null as number | null, courseType: null as number | null, startTime: '', - endTime: '', + durationMinutes: 60, maxMembers: 20, - currentMembers: 0, status: 0, location: '', coverImage: '', @@ -301,15 +308,15 @@ function openCreate() { adminPassword.value = '' pendingCoverFile.value = null coverPreviewUrl.value = '' + recurringEnabled.value = false Object.assign(form, { id: 0, courseName: '', coachId: null, courseType: null, startTime: '', - endTime: '', + durationMinutes: 60, maxMembers: 20, - currentMembers: 0, status: 0, location: '', coverImage: '', @@ -329,14 +336,20 @@ function openEdit(row: CourseItem) { form.coachId = row.coachId ?? null form.courseType = toNum(row.courseType) form.maxMembers = row.maxMembers ?? 20 - form.currentMembers = row.currentMembers ?? 0 form.status = toNum(row.status) form.location = row.location || '' form.coverImage = row.coverImage || '' form.description = row.description || '' form.storedValueAmount = row.storedValueAmount ?? 0 form.startTime = row.startTime ? formatDate(row.startTime, 'YYYY-MM-DD HH:mm:ss') : '' - form.endTime = row.endTime ? formatDate(row.endTime, 'YYYY-MM-DD HH:mm:ss') : '' + // 根据 startTime/endTime 反算课程时长 + if (row.startTime && row.endTime) { + const diff = new Date(row.endTime).getTime() - new Date(row.startTime).getTime() + form.durationMinutes = Math.max(1, Math.round(diff / 60000)) + } else { + form.durationMinutes = 60 + } + recurringEnabled.value = row.isRecurring === true dialogVisible.value = true // 如果有已有封面,获取预签名URL用于预览 if (form.coverImage) { @@ -365,7 +378,7 @@ function handleImgError(e: Event) { } function handleDialogClosed() { - Object.assign(form, { startTime: '', endTime: '' }) + Object.assign(form, { startTime: '', durationMinutes: 60 }) adminPassword.value = '' if (coverPreviewUrl.value) { URL.revokeObjectURL(coverPreviewUrl.value) @@ -379,8 +392,15 @@ async function handleSave() { try { const body: any = { ...form } delete body.id - if (body.startTime) body.startTime = new Date(body.startTime).toISOString() - if (body.endTime) body.endTime = new Date(body.endTime).toISOString() + delete body.durationMinutes + body.isRecurring = recurringEnabled.value + if (body.startTime) { + const start = new Date(body.startTime) + body.startTime = start.toISOString() + // 根据上课时间 + 课程时长计算下课时间 + const end = new Date(start.getTime() + (form.durationMinutes || 60) * 60000) + body.endTime = end.toISOString() + } if (isEdit.value) { if (!adminPassword.value) { @@ -461,6 +481,32 @@ async function handleRestore(row: CourseItem) { } } +// ===== 二维码 ===== +async function showQRCode(row: CourseItem) { + qrVisible.value = true + qrLoading.value = true + qrData.value = null + try { + const res: any = await getGroupCourseQRCode(row.id) + if (res) { + qrData.value = res + } + } catch { + ElMessage.error('获取二维码失败') + qrVisible.value = false + } finally { + qrLoading.value = false + } +} + +function downloadQRCode() { + if (!qrData.value?.base64) return + const a = document.createElement('a') + a.href = qrData.value.base64 + a.download = `签到二维码_${qrData.value.courseName}_${qrData.value.courseId}.png` + a.click() +} + onMounted(() => { fetchTypes() loadData() @@ -624,15 +670,23 @@ onMounted(() => { + + + + - + + + + +
+ +
+ 暂无数据 +
+
+ +
diff --git a/gym-manage-cuit/src/views/memberCard/index.vue b/gym-manage-cuit/src/views/memberCard/index.vue index 33a5f0e..9df3ac3 100644 --- a/gym-manage-cuit/src/views/memberCard/index.vue +++ b/gym-manage-cuit/src/views/memberCard/index.vue @@ -7,6 +7,7 @@ import { updateMemberCard, deleteMemberCard, } from '@/api/modules/memberCard' +import { getConfigByKey, updateConfig } from '@/api/modules/config' import { formatDate } from '@/utils/format' interface CardItem { @@ -24,6 +25,49 @@ interface CardItem { updatedAt: string | null } +// ---- 团课免费取消次数配置 ---- +const cancelFreeLimit = ref(3) +const cancelFreeLimitConfigId = ref(null) +const cancelFreeLimitLoading = ref(false) +const cancelFreeLimitSaving = ref(false) + +async function fetchCancelFreeLimit() { + cancelFreeLimitLoading.value = true + try { + const res: any = await getConfigByKey('group_course.cancel_free_limit') + if (res) { + cancelFreeLimitConfigId.value = res.id || res.configId || null + cancelFreeLimit.value = parseInt(res.configValue || res.value) || 3 + } + } catch { + // ignore, use default + } finally { + cancelFreeLimitLoading.value = false + } +} + +async function saveCancelFreeLimit() { + if (!cancelFreeLimitConfigId.value) { + ElMessage.error('配置记录不存在,无法保存') + return + } + cancelFreeLimitSaving.value = true + try { + await updateConfig(cancelFreeLimitConfigId.value, { + configName: '团课取消预约免手续费次数', + configKey: 'group_course.cancel_free_limit', + configValue: String(cancelFreeLimit.value), + configType: 'Y', + }) + ElMessage.success(`免费取消次数已更新为 ${cancelFreeLimit.value} 次。所有会员将立即应用新规则,后台实时生效。`) + await fetchCancelFreeLimit() + } catch { + ElMessage.error('保存失败') + } finally { + cancelFreeLimitSaving.value = false + } +} + // ---- State ---- const loading = ref(false) const list = ref([]) @@ -217,6 +261,7 @@ async function doDelete(row: CardItem) { onMounted(() => { fetchData() + fetchCancelFreeLimit() }) @@ -352,6 +397,38 @@ onMounted(() => { + + + +
+ 会员前 + + 次取消预约免收手续费,超出后扣除 10% 手续费 + + 保存 + +
+
+ 当前生效值:{{ cancelFreeLimit }} 次。修改后实时生效,对所有会员立即应用新规则。 +
+
+ diff --git a/gym-manage-cuit/src/views/payment/revenue/index.vue b/gym-manage-cuit/src/views/payment/revenue/index.vue new file mode 100644 index 0000000..7e41fe5 --- /dev/null +++ b/gym-manage-cuit/src/views/payment/revenue/index.vue @@ -0,0 +1,193 @@ + + + + +