From af2422c1145b77d04aa6735020fcf738036464a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=97=B6=E8=88=9F=E5=B9=B4?= <3147056268@qq.com> Date: Mon, 15 Jun 2026 09:14:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(coupon):=20=E6=96=B0=E5=A2=9E=E8=90=A5?= =?UTF-8?q?=E9=94=80=E6=A8=A1=E5=9D=97=20gym-coupon=EF=BC=8C=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=20PRD=202.5=20=E4=BC=98=E6=83=A0=E5=88=B8/=E6=8B=BC?= =?UTF-8?q?=E5=9B=A2/=E7=A7=92=E6=9D=80/=E8=90=A5=E9=94=80/=E7=A7=AF?= =?UTF-8?q?=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- gym-manage-api/gym-coupon/pom.xml | 90 ++++ .../coupon/converter/CouponConverter.java | 75 ++++ .../manage/coupon/dao/CouponTemplateDao.java | 43 ++ .../manage/coupon/dao/MemberCouponDao.java | 28 ++ .../coupon/domain/ClaimCouponRequest.java | 29 ++ .../coupon/domain/CouponStatistics.java | 86 ++++ .../manage/coupon/domain/CouponTemplate.java | 209 ++++++++++ .../domain/DistributeCouponRequest.java | 32 ++ .../coupon/domain/DistributeCouponResult.java | 49 +++ .../manage/coupon/domain/MemberCoupon.java | 109 +++++ .../coupon/entity/CouponTemplateEntity.java | 210 ++++++++++ .../coupon/entity/MemberCouponEntity.java | 110 +++++ .../gym/manage/coupon/enums/ApplyScope.java | 11 + .../coupon/enums/CouponTemplateStatus.java | 15 + .../gym/manage/coupon/enums/CouponType.java | 15 + .../manage/coupon/enums/DistributeType.java | 15 + .../coupon/enums/MemberCouponStatus.java | 15 + .../gym/manage/coupon/enums/ValidityType.java | 11 + .../converter/FlashSaleConverter.java | 77 ++++ .../flashsale/dao/FlashSaleActivityDao.java | 31 ++ .../flashsale/dao/FlashSaleItemDao.java | 31 ++ .../flashsale/dao/FlashSaleOrderDao.java | 38 ++ .../flashsale/domain/FlashSaleActivity.java | 74 ++++ .../flashsale/domain/FlashSaleItem.java | 92 +++++ .../flashsale/domain/FlashSaleOrder.java | 84 ++++ .../flashsale/domain/FlashSaleStatistics.java | 82 ++++ .../coupon/flashsale/domain/GrabRequest.java | 35 ++ .../entity/FlashSaleActivityEntity.java | 88 ++++ .../flashsale/entity/FlashSaleItemEntity.java | 110 +++++ .../entity/FlashSaleOrderEntity.java | 100 +++++ .../enums/FlashSaleActivityStatus.java | 11 + .../flashsale/enums/FlashSaleOrderStatus.java | 11 + .../flashsale/handler/FlashSaleHandler.java | 219 ++++++++++ .../IFlashSaleActivityRepository.java | 26 ++ .../repository/IFlashSaleItemRepository.java | 22 + .../repository/IFlashSaleOrderRepository.java | 26 ++ .../impl/FlashSaleActivityRepository.java | 109 +++++ .../impl/FlashSaleItemRepository.java | 90 ++++ .../impl/FlashSaleOrderRepository.java | 68 +++ .../FlashSaleOrderExpireScheduler.java | 35 ++ .../service/IFlashSaleActivityService.java | 50 +++ .../impl/FlashSaleActivityService.java | 390 ++++++++++++++++++ .../groupbuy/converter/GroupBuyConverter.java | 77 ++++ .../groupbuy/dao/GroupBuyActivityDao.java | 35 ++ .../groupbuy/dao/GroupBuyParticipantDao.java | 35 ++ .../coupon/groupbuy/dao/GroupBuyTeamDao.java | 38 ++ .../groupbuy/domain/CreateTeamRequest.java | 26 ++ .../groupbuy/domain/GroupBuyActivity.java | 138 +++++++ .../groupbuy/domain/GroupBuyParticipant.java | 65 +++ .../groupbuy/domain/GroupBuyStatistics.java | 91 ++++ .../coupon/groupbuy/domain/GroupBuyTeam.java | 74 ++++ .../groupbuy/domain/JoinTeamRequest.java | 17 + .../entity/GroupBuyActivityEntity.java | 166 ++++++++ .../entity/GroupBuyParticipantEntity.java | 77 ++++ .../groupbuy/entity/GroupBuyTeamEntity.java | 88 ++++ .../enums/GroupBuyActivityStatus.java | 11 + .../enums/GroupBuyParticipantStatus.java | 9 + .../groupbuy/enums/GroupBuyTeamStatus.java | 11 + .../coupon/groupbuy/enums/ProductType.java | 10 + .../groupbuy/handler/GroupBuyHandler.java | 220 ++++++++++ .../IGroupBuyActivityRepository.java | 28 ++ .../IGroupBuyParticipantRepository.java | 20 + .../repository/IGroupBuyTeamRepository.java | 24 ++ .../impl/GroupBuyActivityRepository.java | 133 ++++++ .../impl/GroupBuyParticipantRepository.java | 61 +++ .../impl/GroupBuyTeamRepository.java | 70 ++++ .../GroupBuyTeamExpireScheduler.java | 35 ++ .../service/IGroupBuyActivityService.java | 31 ++ .../service/IGroupBuyTeamService.java | 25 ++ .../service/impl/GroupBuyActivityService.java | 226 ++++++++++ .../service/impl/GroupBuyTeamService.java | 200 +++++++++ .../manage/coupon/handler/CouponHandler.java | 227 ++++++++++ .../coupon/handler/MemberCouponHandler.java | 67 +++ .../converter/MarketingConverter.java | 35 ++ .../marketing/dao/MarketingActivityDao.java | 33 ++ .../marketing/domain/MarketingActivity.java | 176 ++++++++ .../domain/MarketingActivityStatistics.java | 75 ++++ .../entity/MarketingActivityEntity.java | 177 ++++++++ .../enums/MarketingActivityStatus.java | 15 + .../enums/MarketingActivityType.java | 19 + .../handler/MarketingActivityHandler.java | 197 +++++++++ .../IMarketingActivityRepository.java | 30 ++ .../impl/MarketingActivityRepository.java | 164 ++++++++ .../service/IMarketingActivityService.java | 31 ++ .../impl/MarketingActivityService.java | 192 +++++++++ .../points/converter/PointsConverter.java | 102 +++++ .../coupon/points/dao/MemberPointsDao.java | 27 ++ .../points/dao/PointsMallProductDao.java | 35 ++ .../coupon/points/dao/PointsRecordDao.java | 27 ++ .../coupon/points/dao/PointsRuleDao.java | 27 ++ .../points/domain/EarnPointsRequest.java | 51 +++ .../points/domain/ExchangePointsRequest.java | 29 ++ .../coupon/points/domain/MemberPoints.java | 52 +++ .../points/domain/PointsMallProduct.java | 107 +++++ .../coupon/points/domain/PointsRecord.java | 85 ++++ .../coupon/points/domain/PointsRule.java | 76 ++++ .../points/domain/PointsStatistics.java | 95 +++++ .../points/entity/MemberPointsEntity.java | 53 +++ .../entity/PointsMallProductEntity.java | 108 +++++ .../points/entity/PointsRecordEntity.java | 86 ++++ .../points/entity/PointsRuleEntity.java | 77 ++++ .../coupon/points/enums/PointsChangeType.java | 13 + .../points/enums/PointsProductStatus.java | 13 + .../points/enums/PointsProductType.java | 15 + .../coupon/points/enums/PointsRuleStatus.java | 11 + .../coupon/points/enums/PointsRuleType.java | 15 + .../coupon/points/handler/PointsHandler.java | 192 +++++++++ .../repository/IMemberPointsRepository.java | 20 + .../IPointsMallProductRepository.java | 30 ++ .../repository/IPointsRecordRepository.java | 22 + .../repository/IPointsRuleRepository.java | 24 ++ .../impl/MemberPointsRepository.java | 62 +++ .../impl/PointsMallProductRepository.java | 122 ++++++ .../impl/PointsRecordRepository.java | 65 +++ .../repository/impl/PointsRuleRepository.java | 99 +++++ .../coupon/points/service/IPointsService.java | 45 ++ .../points/service/impl/PointsService.java | 373 +++++++++++++++++ .../repository/ICouponTemplateRepository.java | 34 ++ .../repository/IMemberCouponRepository.java | 22 + .../impl/CouponTemplateRepository.java | 192 +++++++++ .../impl/MemberCouponRepository.java | 58 +++ .../scheduler/CouponExpireScheduler.java | 29 ++ .../service/ICouponTemplateService.java | 43 ++ .../coupon/service/IMemberCouponService.java | 16 + .../service/impl/CouponTemplateService.java | 369 +++++++++++++++++ .../service/impl/MemberCouponService.java | 117 ++++++ gym-manage-api/manage-app/pom.xml | 5 + .../gym/manage/app/ManageApplication.java | 7 +- .../gym/manage/app/config/SystemRouter.java | 105 ++++- .../migration/V17__Create_Coupon_tables.sql | 74 ++++ .../V18__Create_Marketing_Module_tables.sql | 219 ++++++++++ gym-manage-api/pom.xml | 1 + 132 files changed, 9977 insertions(+), 2 deletions(-) create mode 100644 gym-manage-api/gym-coupon/pom.xml create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/converter/CouponConverter.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/dao/CouponTemplateDao.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/dao/MemberCouponDao.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/domain/ClaimCouponRequest.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/domain/CouponStatistics.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/domain/CouponTemplate.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/domain/DistributeCouponRequest.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/domain/DistributeCouponResult.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/domain/MemberCoupon.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/entity/CouponTemplateEntity.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/entity/MemberCouponEntity.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/enums/ApplyScope.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/enums/CouponTemplateStatus.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/enums/CouponType.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/enums/DistributeType.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/enums/MemberCouponStatus.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/enums/ValidityType.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/converter/FlashSaleConverter.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/dao/FlashSaleActivityDao.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/dao/FlashSaleItemDao.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/dao/FlashSaleOrderDao.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/domain/FlashSaleActivity.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/domain/FlashSaleItem.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/domain/FlashSaleOrder.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/domain/FlashSaleStatistics.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/domain/GrabRequest.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/entity/FlashSaleActivityEntity.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/entity/FlashSaleItemEntity.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/entity/FlashSaleOrderEntity.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/enums/FlashSaleActivityStatus.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/enums/FlashSaleOrderStatus.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/handler/FlashSaleHandler.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/repository/IFlashSaleActivityRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/repository/IFlashSaleItemRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/repository/IFlashSaleOrderRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/repository/impl/FlashSaleActivityRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/repository/impl/FlashSaleItemRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/repository/impl/FlashSaleOrderRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/scheduler/FlashSaleOrderExpireScheduler.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/service/IFlashSaleActivityService.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/service/impl/FlashSaleActivityService.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/converter/GroupBuyConverter.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/dao/GroupBuyActivityDao.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/dao/GroupBuyParticipantDao.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/dao/GroupBuyTeamDao.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/domain/CreateTeamRequest.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/domain/GroupBuyActivity.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/domain/GroupBuyParticipant.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/domain/GroupBuyStatistics.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/domain/GroupBuyTeam.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/domain/JoinTeamRequest.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/entity/GroupBuyActivityEntity.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/entity/GroupBuyParticipantEntity.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/entity/GroupBuyTeamEntity.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/enums/GroupBuyActivityStatus.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/enums/GroupBuyParticipantStatus.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/enums/GroupBuyTeamStatus.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/enums/ProductType.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/handler/GroupBuyHandler.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/repository/IGroupBuyActivityRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/repository/IGroupBuyParticipantRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/repository/IGroupBuyTeamRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/repository/impl/GroupBuyActivityRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/repository/impl/GroupBuyParticipantRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/repository/impl/GroupBuyTeamRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/scheduler/GroupBuyTeamExpireScheduler.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/service/IGroupBuyActivityService.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/service/IGroupBuyTeamService.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/service/impl/GroupBuyActivityService.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/service/impl/GroupBuyTeamService.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/handler/CouponHandler.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/handler/MemberCouponHandler.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/converter/MarketingConverter.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/dao/MarketingActivityDao.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/domain/MarketingActivity.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/domain/MarketingActivityStatistics.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/entity/MarketingActivityEntity.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/enums/MarketingActivityStatus.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/enums/MarketingActivityType.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/handler/MarketingActivityHandler.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/repository/IMarketingActivityRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/repository/impl/MarketingActivityRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/service/IMarketingActivityService.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/service/impl/MarketingActivityService.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/converter/PointsConverter.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/dao/MemberPointsDao.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/dao/PointsMallProductDao.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/dao/PointsRecordDao.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/dao/PointsRuleDao.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/EarnPointsRequest.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/ExchangePointsRequest.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/MemberPoints.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/PointsMallProduct.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/PointsRecord.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/PointsRule.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/PointsStatistics.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/entity/MemberPointsEntity.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/entity/PointsMallProductEntity.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/entity/PointsRecordEntity.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/entity/PointsRuleEntity.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/enums/PointsChangeType.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/enums/PointsProductStatus.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/enums/PointsProductType.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/enums/PointsRuleStatus.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/enums/PointsRuleType.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/handler/PointsHandler.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/IMemberPointsRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/IPointsMallProductRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/IPointsRecordRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/IPointsRuleRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/impl/MemberPointsRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/impl/PointsMallProductRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/impl/PointsRecordRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/impl/PointsRuleRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/service/IPointsService.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/service/impl/PointsService.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/repository/ICouponTemplateRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/repository/IMemberCouponRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/repository/impl/CouponTemplateRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/repository/impl/MemberCouponRepository.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/scheduler/CouponExpireScheduler.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/service/ICouponTemplateService.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/service/IMemberCouponService.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/service/impl/CouponTemplateService.java create mode 100644 gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/service/impl/MemberCouponService.java create mode 100644 gym-manage-api/manage-db/src/main/resources/db/migration/V17__Create_Coupon_tables.sql create mode 100644 gym-manage-api/manage-db/src/main/resources/db/migration/V18__Create_Marketing_Module_tables.sql diff --git a/gym-manage-api/gym-coupon/pom.xml b/gym-manage-api/gym-coupon/pom.xml new file mode 100644 index 0000000..ef9eab0 --- /dev/null +++ b/gym-manage-api/gym-coupon/pom.xml @@ -0,0 +1,90 @@ + + + 4.0.0 + + cn.novalon.gym.manage + gym-manage-api + 1.0.0 + ../pom.xml + + cn.novalon.gym.manage + gym-coupon + 1.0.0 + gym-coupon + Coupon Management Module + + + + + + + + + + + + + + + 21 + + + + cn.novalon.gym.manage + manage-common + ${project.version} + + + cn.novalon.gym.manage + manage-sys + ${project.version} + + + cn.novalon.gym.manage + manage-db + ${project.version} + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework.boot + spring-boot-starter-data-r2dbc + + + org.springframework.boot + spring-boot-starter-validation + + + org.springdoc + springdoc-openapi-starter-webflux-ui + + + org.springframework.boot + spring-boot-starter-test + test + + + io.swagger.core.v3 + swagger-annotations-jakarta + 2.2.43 + compile + + + cn.novalon.gym.manage + gym-member + ${project.version} + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/converter/CouponConverter.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/converter/CouponConverter.java new file mode 100644 index 0000000..76a86ee --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/converter/CouponConverter.java @@ -0,0 +1,75 @@ +package cn.novalon.gym.manage.coupon.converter; + +import cn.hutool.core.bean.BeanUtil; +import cn.novalon.gym.manage.coupon.domain.CouponTemplate; +import cn.novalon.gym.manage.coupon.domain.MemberCoupon; +import cn.novalon.gym.manage.coupon.entity.CouponTemplateEntity; +import cn.novalon.gym.manage.coupon.entity.MemberCouponEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 优惠券相关转换器 + */ +@Component +@Slf4j +public class CouponConverter { + + /** + * 将优惠券模板实体转换为领域模型 + */ + public CouponTemplate toCouponTemplate(CouponTemplateEntity entity) { + if (entity == null) { + return null; + } + CouponTemplate couponTemplate = new CouponTemplate(); + BeanUtil.copyProperties(entity, couponTemplate); + log.debug("转换优惠券模板实体到领域模型:couponId={}", entity.getId()); + return couponTemplate; + } + + /** + * 将优惠券模板领域模型转换为实体 + */ + public CouponTemplateEntity toCouponTemplateEntity(CouponTemplate domain) { + if (domain == null) { + return null; + } + CouponTemplateEntity entity = new CouponTemplateEntity(); + BeanUtil.copyProperties(domain, entity); + if (domain.getId() != null) { + entity.markNotNew(); + } + log.debug("转换优惠券模板领域模型到实体:couponId={}", domain.getId()); + return entity; + } + + /** + * 将会员优惠券实体转换为领域模型 + */ + public MemberCoupon toMemberCoupon(MemberCouponEntity entity) { + if (entity == null) { + return null; + } + MemberCoupon memberCoupon = new MemberCoupon(); + BeanUtil.copyProperties(entity, memberCoupon); + log.debug("转换会员优惠券实体到领域模型:memberCouponId={}", entity.getId()); + return memberCoupon; + } + + /** + * 将会员优惠券领域模型转换为实体 + */ + public MemberCouponEntity toMemberCouponEntity(MemberCoupon domain) { + if (domain == null) { + return null; + } + MemberCouponEntity entity = new MemberCouponEntity(); + BeanUtil.copyProperties(domain, entity); + if (domain.getId() != null) { + entity.markNotNew(); + } + log.debug("转换会员优惠券领域模型到实体:memberCouponId={}", domain.getId()); + return entity; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/dao/CouponTemplateDao.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/dao/CouponTemplateDao.java new file mode 100644 index 0000000..65e32f7 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/dao/CouponTemplateDao.java @@ -0,0 +1,43 @@ +package cn.novalon.gym.manage.coupon.dao; + +import cn.novalon.gym.manage.coupon.entity.CouponTemplateEntity; +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 CouponTemplateDao extends R2dbcRepository { + + Mono findByIdIsAndDeletedAtIsNull(Long id); + + Flux findAllByDeletedAtIsNull(); + + Flux findByNameContainingAndDeletedAtIsNull(String name); + + Flux findByCouponTypeAndDeletedAtIsNull(String couponType); + + Flux findByStatusAndDeletedAtIsNull(String status); + + Mono findByClaimCodeAndDeletedAtIsNull(String claimCode); + + @Modifying + @Query("UPDATE coupon_template SET deleted_at = :deletedAt WHERE id = :id") + Mono softDelete(Long id, LocalDateTime deletedAt); + + @Modifying + @Query("UPDATE coupon_template SET status = :status, updated_at = :updatedAt WHERE id = :id") + Mono updateStatus(Long id, String status, LocalDateTime updatedAt); + + @Modifying + @Query("UPDATE coupon_template SET issued_count = issued_count + :count, updated_at = :updatedAt WHERE id = :id") + Mono incrementIssuedCount(Long id, int count, LocalDateTime updatedAt); + + @Modifying + @Query("UPDATE coupon_template SET used_count = used_count + :count, updated_at = :updatedAt WHERE id = :id") + Mono incrementUsedCount(Long id, int count, LocalDateTime updatedAt); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/dao/MemberCouponDao.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/dao/MemberCouponDao.java new file mode 100644 index 0000000..e3cf996 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/dao/MemberCouponDao.java @@ -0,0 +1,28 @@ +package cn.novalon.gym.manage.coupon.dao; + +import cn.novalon.gym.manage.coupon.entity.MemberCouponEntity; +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; + +@Repository +public interface MemberCouponDao extends R2dbcRepository { + + Flux findByTemplateIdAndDeletedAtIsNull(Long templateId); + + Flux findByMemberIdAndDeletedAtIsNull(Long memberId); + + Mono countByTemplateIdAndMemberIdAndDeletedAtIsNull(Long templateId, Long memberId); + + @Query("SELECT COUNT(*) FROM member_coupon WHERE template_id = :templateId AND status = :status AND deleted_at IS NULL") + Mono countByTemplateIdAndStatus(Long templateId, String status); + + Mono findByCouponCodeAndDeletedAtIsNull(String couponCode); + + @Modifying + @Query("UPDATE member_coupon SET status = 'EXPIRED', updated_at = :now WHERE status = 'AVAILABLE' AND expire_at < :now AND deleted_at IS NULL") + Mono expireAvailableCoupons(java.time.LocalDateTime now); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/domain/ClaimCouponRequest.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/domain/ClaimCouponRequest.java new file mode 100644 index 0000000..fda6050 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/domain/ClaimCouponRequest.java @@ -0,0 +1,29 @@ +package cn.novalon.gym.manage.coupon.domain; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "领取码兑换优惠券请求") +public class ClaimCouponRequest { + + @Schema(description = "会员ID", requiredMode = Schema.RequiredMode.REQUIRED) + private Long memberId; + + @Schema(description = "领取码", requiredMode = Schema.RequiredMode.REQUIRED) + private String claimCode; + + public Long getMemberId() { + return memberId; + } + + public void setMemberId(Long memberId) { + this.memberId = memberId; + } + + public String getClaimCode() { + return claimCode; + } + + public void setClaimCode(String claimCode) { + this.claimCode = claimCode; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/domain/CouponStatistics.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/domain/CouponStatistics.java new file mode 100644 index 0000000..59d2d6c --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/domain/CouponStatistics.java @@ -0,0 +1,86 @@ +package cn.novalon.gym.manage.coupon.domain; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; + +@Schema(description = "优惠券统计数据") +public class CouponStatistics { + + @Schema(description = "优惠券模板ID") + private Long templateId; + + @Schema(description = "已发放数量") + private long issuedCount; + + @Schema(description = "已使用数量") + private long usedCount; + + @Schema(description = "可用数量") + private long availableCount; + + @Schema(description = "已过期数量") + private long expiredCount; + + @Schema(description = "核销率(百分比)") + private BigDecimal redemptionRate; + + @Schema(description = "累计优惠金额") + private BigDecimal totalDiscountAmount; + + public Long getTemplateId() { + return templateId; + } + + public void setTemplateId(Long templateId) { + this.templateId = templateId; + } + + public long getIssuedCount() { + return issuedCount; + } + + public void setIssuedCount(long issuedCount) { + this.issuedCount = issuedCount; + } + + public long getUsedCount() { + return usedCount; + } + + public void setUsedCount(long usedCount) { + this.usedCount = usedCount; + } + + public long getAvailableCount() { + return availableCount; + } + + public void setAvailableCount(long availableCount) { + this.availableCount = availableCount; + } + + public long getExpiredCount() { + return expiredCount; + } + + public void setExpiredCount(long expiredCount) { + this.expiredCount = expiredCount; + } + + public BigDecimal getRedemptionRate() { + return redemptionRate; + } + + public void setRedemptionRate(BigDecimal redemptionRate) { + this.redemptionRate = redemptionRate; + } + + public BigDecimal getTotalDiscountAmount() { + return totalDiscountAmount; + } + + public void setTotalDiscountAmount(BigDecimal totalDiscountAmount) { + this.totalDiscountAmount = totalDiscountAmount; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/domain/CouponTemplate.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/domain/CouponTemplate.java new file mode 100644 index 0000000..9bf309b --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/domain/CouponTemplate.java @@ -0,0 +1,209 @@ +package cn.novalon.gym.manage.coupon.domain; + +import cn.novalon.gym.manage.sys.core.domain.BaseDomain; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Schema(description = "优惠券模板") +public class CouponTemplate extends BaseDomain { + + //优惠券名称 + private String name; + + @Schema(description = "优惠券描述") + private String description; + + @Schema(description = "优惠券类型:CASH/DISCOUNT/COURSE/EXPERIENCE", example = "CASH") + private String couponType; + + @Schema(description = "优惠值:满减/课程/体验为金额,折扣券为比例(0.9=9折)", example = "20.00") + private BigDecimal discountValue; + + @Schema(description = "使用门槛金额", example = "100.00") + private BigDecimal thresholdAmount; + + @Schema(description = "有效期类型:FIXED_DATE/DAYS_AFTER_CLAIM", example = "FIXED_DATE") + private String validityType; + + @Schema(description = "固定有效期开始时间") + private LocalDateTime startTime; + + @Schema(description = "固定有效期结束时间") + private LocalDateTime endTime; + + @Schema(description = "领取后有效天数") + private Integer validDays; + + @Schema(description = "适用商品范围:ALL/SPECIFIC", example = "ALL") + private String applyScope; + + @Schema(description = "指定商品ID列表(JSON数组字符串)") + private String applyProductIds; + + @Schema(description = "发放总量,-1表示不限量", example = "1000") + private Integer totalQuantity; + + @Schema(description = "每人限领数量", example = "1") + private Integer perUserLimit; + + @Schema(description = "是否可叠加使用", example = "false") + private Boolean stackable; + + @Schema(description = "领取码") + private String claimCode; + + @Schema(description = "状态:DRAFT/ACTIVE/TERMINATED/EXPIRED", example = "DRAFT") + private String status; + + @Schema(description = "已发放数量") + private Integer issuedCount; + + @Schema(description = "已使用数量") + private Integer usedCount; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getCouponType() { + return couponType; + } + + public void setCouponType(String couponType) { + this.couponType = couponType; + } + + public BigDecimal getDiscountValue() { + return discountValue; + } + + public void setDiscountValue(BigDecimal discountValue) { + this.discountValue = discountValue; + } + + public BigDecimal getThresholdAmount() { + return thresholdAmount; + } + + public void setThresholdAmount(BigDecimal thresholdAmount) { + this.thresholdAmount = thresholdAmount; + } + + public String getValidityType() { + return validityType; + } + + public void setValidityType(String validityType) { + this.validityType = validityType; + } + + public LocalDateTime getStartTime() { + return startTime; + } + + public void setStartTime(LocalDateTime startTime) { + this.startTime = startTime; + } + + public LocalDateTime getEndTime() { + return endTime; + } + + public void setEndTime(LocalDateTime endTime) { + this.endTime = endTime; + } + + public Integer getValidDays() { + return validDays; + } + + public void setValidDays(Integer validDays) { + this.validDays = validDays; + } + + public String getApplyScope() { + return applyScope; + } + + public void setApplyScope(String applyScope) { + this.applyScope = applyScope; + } + + public String getApplyProductIds() { + return applyProductIds; + } + + public void setApplyProductIds(String applyProductIds) { + this.applyProductIds = applyProductIds; + } + + public Integer getTotalQuantity() { + return totalQuantity; + } + + public void setTotalQuantity(Integer totalQuantity) { + this.totalQuantity = totalQuantity; + } + + public Integer getPerUserLimit() { + return perUserLimit; + } + + public void setPerUserLimit(Integer perUserLimit) { + this.perUserLimit = perUserLimit; + } + + public Boolean getStackable() { + return stackable; + } + + public void setStackable(Boolean stackable) { + this.stackable = stackable; + } + + public String getClaimCode() { + return claimCode; + } + + public void setClaimCode(String claimCode) { + this.claimCode = claimCode; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public Integer getIssuedCount() { + return issuedCount; + } + + public void setIssuedCount(Integer issuedCount) { + this.issuedCount = issuedCount; + } + + public Integer getUsedCount() { + return usedCount; + } + + public void setUsedCount(Integer usedCount) { + this.usedCount = usedCount; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/domain/DistributeCouponRequest.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/domain/DistributeCouponRequest.java new file mode 100644 index 0000000..ebf0fcf --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/domain/DistributeCouponRequest.java @@ -0,0 +1,32 @@ +package cn.novalon.gym.manage.coupon.domain; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; +import java.util.List; + +@Schema(description = "优惠券发放请求") +public class DistributeCouponRequest { + + @Schema(description = "目标会员ID列表", requiredMode = Schema.RequiredMode.REQUIRED) + private List memberIds; + + @Schema(description = "发放方式:MANUAL/BATCH,默认MANUAL", example = "MANUAL") + private String distributeType; + + public List getMemberIds() { + return memberIds; + } + + public void setMemberIds(List memberIds) { + this.memberIds = memberIds; + } + + public String getDistributeType() { + return distributeType; + } + + public void setDistributeType(String distributeType) { + this.distributeType = distributeType; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/domain/DistributeCouponResult.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/domain/DistributeCouponResult.java new file mode 100644 index 0000000..2519013 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/domain/DistributeCouponResult.java @@ -0,0 +1,49 @@ +package cn.novalon.gym.manage.coupon.domain; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "优惠券发放结果") +public class DistributeCouponResult { + + @Schema(description = "成功发放数量") + private int successCount; + + @Schema(description = "失败数量") + private int failCount; + + @Schema(description = "提示信息") + private String message; + + public DistributeCouponResult() { + } + + public DistributeCouponResult(int successCount, int failCount, String message) { + this.successCount = successCount; + this.failCount = failCount; + this.message = message; + } + + public int getSuccessCount() { + return successCount; + } + + public void setSuccessCount(int successCount) { + this.successCount = successCount; + } + + public int getFailCount() { + return failCount; + } + + public void setFailCount(int failCount) { + this.failCount = failCount; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/domain/MemberCoupon.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/domain/MemberCoupon.java new file mode 100644 index 0000000..788cfd0 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/domain/MemberCoupon.java @@ -0,0 +1,109 @@ +package cn.novalon.gym.manage.coupon.domain; + +import cn.novalon.gym.manage.sys.core.domain.BaseDomain; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +@Schema(description = "会员优惠券") +public class MemberCoupon extends BaseDomain { + + @Schema(description = "优惠券模板ID") + private Long templateId; + + @Schema(description = "会员ID") + private Long memberId; + + @Schema(description = "优惠券码") + private String couponCode; + + @Schema(description = "状态:AVAILABLE/USED/EXPIRED/INVALID") + private String status; + + @Schema(description = "发放方式:MANUAL/BATCH/AUTO/CLAIM") + private String distributeType; + + @Schema(description = "领取时间") + private LocalDateTime receivedAt; + + @Schema(description = "过期时间") + private LocalDateTime expireAt; + + @Schema(description = "使用时间") + private LocalDateTime usedAt; + + @Schema(description = "关联订单ID") + private Long orderId; + + public Long getTemplateId() { + return templateId; + } + + public void setTemplateId(Long templateId) { + this.templateId = templateId; + } + + public Long getMemberId() { + return memberId; + } + + public void setMemberId(Long memberId) { + this.memberId = memberId; + } + + public String getCouponCode() { + return couponCode; + } + + public void setCouponCode(String couponCode) { + this.couponCode = couponCode; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getDistributeType() { + return distributeType; + } + + public void setDistributeType(String distributeType) { + this.distributeType = distributeType; + } + + public LocalDateTime getReceivedAt() { + return receivedAt; + } + + public void setReceivedAt(LocalDateTime receivedAt) { + this.receivedAt = receivedAt; + } + + public LocalDateTime getExpireAt() { + return expireAt; + } + + public void setExpireAt(LocalDateTime expireAt) { + this.expireAt = expireAt; + } + + public LocalDateTime getUsedAt() { + return usedAt; + } + + public void setUsedAt(LocalDateTime usedAt) { + this.usedAt = usedAt; + } + + public Long getOrderId() { + return orderId; + } + + public void setOrderId(Long orderId) { + this.orderId = orderId; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/entity/CouponTemplateEntity.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/entity/CouponTemplateEntity.java new file mode 100644 index 0000000..62c3b8d --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/entity/CouponTemplateEntity.java @@ -0,0 +1,210 @@ +package cn.novalon.gym.manage.coupon.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; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Table("coupon_template") +public class CouponTemplateEntity extends BaseEntity { + + @Column("name") + private String name; + + @Column("description") + private String description; + + @Column("coupon_type") + private String couponType; + + @Column("discount_value") + private BigDecimal discountValue; + + @Column("threshold_amount") + private BigDecimal thresholdAmount; + + @Column("validity_type") + private String validityType; + + @Column("start_time") + private LocalDateTime startTime; + + @Column("end_time") + private LocalDateTime endTime; + + @Column("valid_days") + private Integer validDays; + + @Column("apply_scope") + private String applyScope; + + @Column("apply_product_ids") + private String applyProductIds; + + @Column("total_quantity") + private Integer totalQuantity; + + @Column("per_user_limit") + private Integer perUserLimit; + + @Column("stackable") + private Boolean stackable; + + @Column("claim_code") + private String claimCode; + + @Column("status") + private String status; + + @Column("issued_count") + private Integer issuedCount; + + @Column("used_count") + private Integer usedCount; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getCouponType() { + return couponType; + } + + public void setCouponType(String couponType) { + this.couponType = couponType; + } + + public BigDecimal getDiscountValue() { + return discountValue; + } + + public void setDiscountValue(BigDecimal discountValue) { + this.discountValue = discountValue; + } + + public BigDecimal getThresholdAmount() { + return thresholdAmount; + } + + public void setThresholdAmount(BigDecimal thresholdAmount) { + this.thresholdAmount = thresholdAmount; + } + + public String getValidityType() { + return validityType; + } + + public void setValidityType(String validityType) { + this.validityType = validityType; + } + + public LocalDateTime getStartTime() { + return startTime; + } + + public void setStartTime(LocalDateTime startTime) { + this.startTime = startTime; + } + + public LocalDateTime getEndTime() { + return endTime; + } + + public void setEndTime(LocalDateTime endTime) { + this.endTime = endTime; + } + + public Integer getValidDays() { + return validDays; + } + + public void setValidDays(Integer validDays) { + this.validDays = validDays; + } + + public String getApplyScope() { + return applyScope; + } + + public void setApplyScope(String applyScope) { + this.applyScope = applyScope; + } + + public String getApplyProductIds() { + return applyProductIds; + } + + public void setApplyProductIds(String applyProductIds) { + this.applyProductIds = applyProductIds; + } + + public Integer getTotalQuantity() { + return totalQuantity; + } + + public void setTotalQuantity(Integer totalQuantity) { + this.totalQuantity = totalQuantity; + } + + public Integer getPerUserLimit() { + return perUserLimit; + } + + public void setPerUserLimit(Integer perUserLimit) { + this.perUserLimit = perUserLimit; + } + + public Boolean getStackable() { + return stackable; + } + + public void setStackable(Boolean stackable) { + this.stackable = stackable; + } + + public String getClaimCode() { + return claimCode; + } + + public void setClaimCode(String claimCode) { + this.claimCode = claimCode; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public Integer getIssuedCount() { + return issuedCount; + } + + public void setIssuedCount(Integer issuedCount) { + this.issuedCount = issuedCount; + } + + public Integer getUsedCount() { + return usedCount; + } + + public void setUsedCount(Integer usedCount) { + this.usedCount = usedCount; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/entity/MemberCouponEntity.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/entity/MemberCouponEntity.java new file mode 100644 index 0000000..545b076 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/entity/MemberCouponEntity.java @@ -0,0 +1,110 @@ +package cn.novalon.gym.manage.coupon.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; + +import java.time.LocalDateTime; + +@Table("member_coupon") +public class MemberCouponEntity extends BaseEntity { + + @Column("template_id") + private Long templateId; + + @Column("member_id") + private Long memberId; + + @Column("coupon_code") + private String couponCode; + + @Column("status") + private String status; + + @Column("distribute_type") + private String distributeType; + + @Column("received_at") + private LocalDateTime receivedAt; + + @Column("expire_at") + private LocalDateTime expireAt; + + @Column("used_at") + private LocalDateTime usedAt; + + @Column("order_id") + private Long orderId; + + public Long getTemplateId() { + return templateId; + } + + public void setTemplateId(Long templateId) { + this.templateId = templateId; + } + + public Long getMemberId() { + return memberId; + } + + public void setMemberId(Long memberId) { + this.memberId = memberId; + } + + public String getCouponCode() { + return couponCode; + } + + public void setCouponCode(String couponCode) { + this.couponCode = couponCode; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getDistributeType() { + return distributeType; + } + + public void setDistributeType(String distributeType) { + this.distributeType = distributeType; + } + + public LocalDateTime getReceivedAt() { + return receivedAt; + } + + public void setReceivedAt(LocalDateTime receivedAt) { + this.receivedAt = receivedAt; + } + + public LocalDateTime getExpireAt() { + return expireAt; + } + + public void setExpireAt(LocalDateTime expireAt) { + this.expireAt = expireAt; + } + + public LocalDateTime getUsedAt() { + return usedAt; + } + + public void setUsedAt(LocalDateTime usedAt) { + this.usedAt = usedAt; + } + + public Long getOrderId() { + return orderId; + } + + public void setOrderId(Long orderId) { + this.orderId = orderId; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/enums/ApplyScope.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/enums/ApplyScope.java new file mode 100644 index 0000000..b33e9ca --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/enums/ApplyScope.java @@ -0,0 +1,11 @@ +package cn.novalon.gym.manage.coupon.enums; + +/** + * 优惠券适用商品范围 + */ +public enum ApplyScope { + /** 全场通用 */ + ALL, + /** 指定商品 */ + SPECIFIC +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/enums/CouponTemplateStatus.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/enums/CouponTemplateStatus.java new file mode 100644 index 0000000..1c5f302 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/enums/CouponTemplateStatus.java @@ -0,0 +1,15 @@ +package cn.novalon.gym.manage.coupon.enums; + +/** + * 优惠券模板状态 + */ +public enum CouponTemplateStatus { + /** 草稿 */ + DRAFT, + /** 进行中 */ + ACTIVE, + /** 已终止 */ + TERMINATED, + /** 已过期 */ + EXPIRED +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/enums/CouponType.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/enums/CouponType.java new file mode 100644 index 0000000..3c54205 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/enums/CouponType.java @@ -0,0 +1,15 @@ +package cn.novalon.gym.manage.coupon.enums; + +/** + * 优惠券类型 + */ +public enum CouponType { + /** 满减券 */ + CASH, + /** 折扣券 */ + DISCOUNT, + /** 课程券 */ + COURSE, + /** 体验券(会员体验专用) */ + EXPERIENCE +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/enums/DistributeType.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/enums/DistributeType.java new file mode 100644 index 0000000..99a49ee --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/enums/DistributeType.java @@ -0,0 +1,15 @@ +package cn.novalon.gym.manage.coupon.enums; + +/** + * 优惠券发放方式 + */ +public enum DistributeType { + /** 手动发放(指定会员) */ + MANUAL, + /** 批量发放(按会员分组) */ + BATCH, + /** 自动发放(触发规则) */ + AUTO, + /** 领取码/二维码领取 */ + CLAIM +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/enums/MemberCouponStatus.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/enums/MemberCouponStatus.java new file mode 100644 index 0000000..972225c --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/enums/MemberCouponStatus.java @@ -0,0 +1,15 @@ +package cn.novalon.gym.manage.coupon.enums; + +/** + * 会员优惠券状态 + */ +public enum MemberCouponStatus { + /** 可用 */ + AVAILABLE, + /** 已使用 */ + USED, + /** 已过期 */ + EXPIRED, + /** 已作废 */ + INVALID +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/enums/ValidityType.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/enums/ValidityType.java new file mode 100644 index 0000000..b680c90 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/enums/ValidityType.java @@ -0,0 +1,11 @@ +package cn.novalon.gym.manage.coupon.enums; + +/** + * 优惠券有效期类型 + */ +public enum ValidityType { + /** 固定日期 */ + FIXED_DATE, + /** 领取后X天 */ + DAYS_AFTER_CLAIM +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/converter/FlashSaleConverter.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/converter/FlashSaleConverter.java new file mode 100644 index 0000000..1069d3c --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/converter/FlashSaleConverter.java @@ -0,0 +1,77 @@ +package cn.novalon.gym.manage.coupon.flashsale.converter; + +import cn.hutool.core.bean.BeanUtil; +import cn.novalon.gym.manage.coupon.flashsale.domain.FlashSaleActivity; +import cn.novalon.gym.manage.coupon.flashsale.domain.FlashSaleItem; +import cn.novalon.gym.manage.coupon.flashsale.domain.FlashSaleOrder; +import cn.novalon.gym.manage.coupon.flashsale.entity.FlashSaleActivityEntity; +import cn.novalon.gym.manage.coupon.flashsale.entity.FlashSaleItemEntity; +import cn.novalon.gym.manage.coupon.flashsale.entity.FlashSaleOrderEntity; +import org.springframework.stereotype.Component; + +@Component +public class FlashSaleConverter { + + public FlashSaleActivity toActivity(FlashSaleActivityEntity entity) { + if (entity == null) { + return null; + } + FlashSaleActivity domain = new FlashSaleActivity(); + BeanUtil.copyProperties(entity, domain); + return domain; + } + + public FlashSaleActivityEntity toActivityEntity(FlashSaleActivity domain) { + if (domain == null) { + return null; + } + FlashSaleActivityEntity entity = new FlashSaleActivityEntity(); + BeanUtil.copyProperties(domain, entity); + if (domain.getId() != null) { + entity.markNotNew(); + } + return entity; + } + + public FlashSaleItem toItem(FlashSaleItemEntity entity) { + if (entity == null) { + return null; + } + FlashSaleItem domain = new FlashSaleItem(); + BeanUtil.copyProperties(entity, domain); + return domain; + } + + public FlashSaleItemEntity toItemEntity(FlashSaleItem domain) { + if (domain == null) { + return null; + } + FlashSaleItemEntity entity = new FlashSaleItemEntity(); + BeanUtil.copyProperties(domain, entity); + if (domain.getId() != null) { + entity.markNotNew(); + } + return entity; + } + + public FlashSaleOrder toOrder(FlashSaleOrderEntity entity) { + if (entity == null) { + return null; + } + FlashSaleOrder domain = new FlashSaleOrder(); + BeanUtil.copyProperties(entity, domain); + return domain; + } + + public FlashSaleOrderEntity toOrderEntity(FlashSaleOrder domain) { + if (domain == null) { + return null; + } + FlashSaleOrderEntity entity = new FlashSaleOrderEntity(); + BeanUtil.copyProperties(domain, entity); + if (domain.getId() != null) { + entity.markNotNew(); + } + return entity; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/dao/FlashSaleActivityDao.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/dao/FlashSaleActivityDao.java new file mode 100644 index 0000000..807ab13 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/dao/FlashSaleActivityDao.java @@ -0,0 +1,31 @@ +package cn.novalon.gym.manage.coupon.flashsale.dao; + +import cn.novalon.gym.manage.coupon.flashsale.entity.FlashSaleActivityEntity; +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 FlashSaleActivityDao extends R2dbcRepository { + + Mono findByIdIsAndDeletedAtIsNull(Long id); + + Flux findAllByDeletedAtIsNull(); + + Flux findByNameContainingAndDeletedAtIsNull(String name); + + Flux findByStatusAndDeletedAtIsNull(String status); + + @Modifying + @Query("UPDATE flash_sale_activity SET deleted_at = :deletedAt WHERE id = :id") + Mono softDelete(Long id, LocalDateTime deletedAt); + + @Modifying + @Query("UPDATE flash_sale_activity SET status = :status, updated_at = :updatedAt WHERE id = :id") + Mono updateStatus(Long id, String status, LocalDateTime updatedAt); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/dao/FlashSaleItemDao.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/dao/FlashSaleItemDao.java new file mode 100644 index 0000000..a48ea6f --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/dao/FlashSaleItemDao.java @@ -0,0 +1,31 @@ +package cn.novalon.gym.manage.coupon.flashsale.dao; + +import cn.novalon.gym.manage.coupon.flashsale.entity.FlashSaleItemEntity; +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 FlashSaleItemDao extends R2dbcRepository { + + Mono findByIdIsAndDeletedAtIsNull(Long id); + + Flux findByActivityIdAndDeletedAtIsNull(Long activityId); + + @Modifying + @Query("UPDATE flash_sale_item SET stock = stock - :quantity, updated_at = :updatedAt WHERE id = :id AND stock >= :quantity") + Mono deductStock(Long id, int quantity, LocalDateTime updatedAt); + + @Modifying + @Query("UPDATE flash_sale_item SET stock = stock + :quantity, updated_at = :updatedAt WHERE id = :id") + Mono restoreStock(Long id, int quantity, LocalDateTime updatedAt); + + @Modifying + @Query("UPDATE flash_sale_item SET sold_count = sold_count + :count, updated_at = :updatedAt WHERE id = :id") + Mono incrementSoldCount(Long id, int count, LocalDateTime updatedAt); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/dao/FlashSaleOrderDao.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/dao/FlashSaleOrderDao.java new file mode 100644 index 0000000..7fb8d7e --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/dao/FlashSaleOrderDao.java @@ -0,0 +1,38 @@ +package cn.novalon.gym.manage.coupon.flashsale.dao; + +import cn.novalon.gym.manage.coupon.flashsale.entity.FlashSaleOrderEntity; +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 FlashSaleOrderDao extends R2dbcRepository { + + Mono findByIdIsAndDeletedAtIsNull(Long id); + + Flux findByMemberIdAndDeletedAtIsNull(Long memberId); + + Flux findByActivityIdAndDeletedAtIsNull(Long activityId); + + Mono countByActivityIdAndMemberIdAndStatusAndDeletedAtIsNull( + Long activityId, Long memberId, String status); + + Mono countByItemIdAndMemberIdAndStatusInAndDeletedAtIsNull( + Long itemId, Long memberId, java.util.Collection statuses); + + @Query("SELECT * FROM flash_sale_order WHERE status = :status AND expire_at < :now AND deleted_at IS NULL") + Flux findExpiredPendingOrders(String status, LocalDateTime now); + + @Modifying + @Query("UPDATE flash_sale_order SET status = :status, updated_at = :updatedAt WHERE id = :id") + Mono updateStatus(Long id, String status, LocalDateTime updatedAt); + + @Modifying + @Query("UPDATE flash_sale_order SET status = :status, pay_at = :payAt, updated_at = :updatedAt WHERE id = :id") + Mono markPaid(Long id, String status, LocalDateTime payAt, LocalDateTime updatedAt); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/domain/FlashSaleActivity.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/domain/FlashSaleActivity.java new file mode 100644 index 0000000..5aaec01 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/domain/FlashSaleActivity.java @@ -0,0 +1,74 @@ +package cn.novalon.gym.manage.coupon.flashsale.domain; + +import cn.novalon.gym.manage.sys.core.domain.BaseDomain; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +@Schema(description = "秒杀活动") +public class FlashSaleActivity extends BaseDomain { + + private String name; + private String description; + private LocalDateTime startTime; + private LocalDateTime endTime; + private Integer payTimeoutMinutes; + private Integer perUserLimit; + private String status; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public LocalDateTime getStartTime() { + return startTime; + } + + public void setStartTime(LocalDateTime startTime) { + this.startTime = startTime; + } + + public LocalDateTime getEndTime() { + return endTime; + } + + public void setEndTime(LocalDateTime endTime) { + this.endTime = endTime; + } + + public Integer getPayTimeoutMinutes() { + return payTimeoutMinutes; + } + + public void setPayTimeoutMinutes(Integer payTimeoutMinutes) { + this.payTimeoutMinutes = payTimeoutMinutes; + } + + public Integer getPerUserLimit() { + return perUserLimit; + } + + public void setPerUserLimit(Integer perUserLimit) { + this.perUserLimit = perUserLimit; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/domain/FlashSaleItem.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/domain/FlashSaleItem.java new file mode 100644 index 0000000..e5bbeae --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/domain/FlashSaleItem.java @@ -0,0 +1,92 @@ +package cn.novalon.gym.manage.coupon.flashsale.domain; + +import cn.novalon.gym.manage.sys.core.domain.BaseDomain; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; + +@Schema(description = "秒杀商品") +public class FlashSaleItem extends BaseDomain { + + private Long activityId; + private String productType; + private Long productId; + private String productName; + private BigDecimal originalPrice; + private BigDecimal seckillPrice; + private Integer stock; + private Integer soldCount; + private Integer perUserLimit; + + public Long getActivityId() { + return activityId; + } + + public void setActivityId(Long activityId) { + this.activityId = activityId; + } + + public String getProductType() { + return productType; + } + + public void setProductType(String productType) { + this.productType = productType; + } + + public Long getProductId() { + return productId; + } + + public void setProductId(Long productId) { + this.productId = productId; + } + + public String getProductName() { + return productName; + } + + public void setProductName(String productName) { + this.productName = productName; + } + + public BigDecimal getOriginalPrice() { + return originalPrice; + } + + public void setOriginalPrice(BigDecimal originalPrice) { + this.originalPrice = originalPrice; + } + + public BigDecimal getSeckillPrice() { + return seckillPrice; + } + + public void setSeckillPrice(BigDecimal seckillPrice) { + this.seckillPrice = seckillPrice; + } + + public Integer getStock() { + return stock; + } + + public void setStock(Integer stock) { + this.stock = stock; + } + + public Integer getSoldCount() { + return soldCount; + } + + public void setSoldCount(Integer soldCount) { + this.soldCount = soldCount; + } + + public Integer getPerUserLimit() { + return perUserLimit; + } + + public void setPerUserLimit(Integer perUserLimit) { + this.perUserLimit = perUserLimit; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/domain/FlashSaleOrder.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/domain/FlashSaleOrder.java new file mode 100644 index 0000000..af37089 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/domain/FlashSaleOrder.java @@ -0,0 +1,84 @@ +package cn.novalon.gym.manage.coupon.flashsale.domain; + +import cn.novalon.gym.manage.sys.core.domain.BaseDomain; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Schema(description = "秒杀订单") +public class FlashSaleOrder extends BaseDomain { + + private Long activityId; + private Long itemId; + private Long memberId; + private Integer quantity; + private BigDecimal payAmount; + private String status; + private LocalDateTime expireAt; + private LocalDateTime payAt; + + public Long getActivityId() { + return activityId; + } + + public void setActivityId(Long activityId) { + this.activityId = activityId; + } + + public Long getItemId() { + return itemId; + } + + public void setItemId(Long itemId) { + this.itemId = itemId; + } + + public Long getMemberId() { + return memberId; + } + + public void setMemberId(Long memberId) { + this.memberId = memberId; + } + + public Integer getQuantity() { + return quantity; + } + + public void setQuantity(Integer quantity) { + this.quantity = quantity; + } + + public BigDecimal getPayAmount() { + return payAmount; + } + + public void setPayAmount(BigDecimal payAmount) { + this.payAmount = payAmount; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public LocalDateTime getExpireAt() { + return expireAt; + } + + public void setExpireAt(LocalDateTime expireAt) { + this.expireAt = expireAt; + } + + public LocalDateTime getPayAt() { + return payAt; + } + + public void setPayAt(LocalDateTime payAt) { + this.payAt = payAt; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/domain/FlashSaleStatistics.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/domain/FlashSaleStatistics.java new file mode 100644 index 0000000..cce9433 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/domain/FlashSaleStatistics.java @@ -0,0 +1,82 @@ +package cn.novalon.gym.manage.coupon.flashsale.domain; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; + +@Schema(description = "秒杀统计数据") +public class FlashSaleStatistics { + + private Long activityId; + private long totalOrders; + private long pendingOrders; + private long paidOrders; + private long cancelledOrders; + private long expiredOrders; + private long totalSoldQuantity; + private BigDecimal totalRevenue; + + public Long getActivityId() { + return activityId; + } + + public void setActivityId(Long activityId) { + this.activityId = activityId; + } + + public long getTotalOrders() { + return totalOrders; + } + + public void setTotalOrders(long totalOrders) { + this.totalOrders = totalOrders; + } + + public long getPendingOrders() { + return pendingOrders; + } + + public void setPendingOrders(long pendingOrders) { + this.pendingOrders = pendingOrders; + } + + public long getPaidOrders() { + return paidOrders; + } + + public void setPaidOrders(long paidOrders) { + this.paidOrders = paidOrders; + } + + public long getCancelledOrders() { + return cancelledOrders; + } + + public void setCancelledOrders(long cancelledOrders) { + this.cancelledOrders = cancelledOrders; + } + + public long getExpiredOrders() { + return expiredOrders; + } + + public void setExpiredOrders(long expiredOrders) { + this.expiredOrders = expiredOrders; + } + + public long getTotalSoldQuantity() { + return totalSoldQuantity; + } + + public void setTotalSoldQuantity(long totalSoldQuantity) { + this.totalSoldQuantity = totalSoldQuantity; + } + + public BigDecimal getTotalRevenue() { + return totalRevenue; + } + + public void setTotalRevenue(BigDecimal totalRevenue) { + this.totalRevenue = totalRevenue; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/domain/GrabRequest.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/domain/GrabRequest.java new file mode 100644 index 0000000..cbb594a --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/domain/GrabRequest.java @@ -0,0 +1,35 @@ +package cn.novalon.gym.manage.coupon.flashsale.domain; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "秒杀抢购请求") +public class GrabRequest { + + private Long itemId; + private Long memberId; + private Integer quantity; + + public Long getItemId() { + return itemId; + } + + public void setItemId(Long itemId) { + this.itemId = itemId; + } + + public Long getMemberId() { + return memberId; + } + + public void setMemberId(Long memberId) { + this.memberId = memberId; + } + + public Integer getQuantity() { + return quantity; + } + + public void setQuantity(Integer quantity) { + this.quantity = quantity; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/entity/FlashSaleActivityEntity.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/entity/FlashSaleActivityEntity.java new file mode 100644 index 0000000..9a043db --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/entity/FlashSaleActivityEntity.java @@ -0,0 +1,88 @@ +package cn.novalon.gym.manage.coupon.flashsale.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; + +import java.time.LocalDateTime; + +@Table("flash_sale_activity") +public class FlashSaleActivityEntity extends BaseEntity { + + @Column("name") + private String name; + + @Column("description") + private String description; + + @Column("start_time") + private LocalDateTime startTime; + + @Column("end_time") + private LocalDateTime endTime; + + @Column("pay_timeout_minutes") + private Integer payTimeoutMinutes; + + @Column("per_user_limit") + private Integer perUserLimit; + + @Column("status") + private String status; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public LocalDateTime getStartTime() { + return startTime; + } + + public void setStartTime(LocalDateTime startTime) { + this.startTime = startTime; + } + + public LocalDateTime getEndTime() { + return endTime; + } + + public void setEndTime(LocalDateTime endTime) { + this.endTime = endTime; + } + + public Integer getPayTimeoutMinutes() { + return payTimeoutMinutes; + } + + public void setPayTimeoutMinutes(Integer payTimeoutMinutes) { + this.payTimeoutMinutes = payTimeoutMinutes; + } + + public Integer getPerUserLimit() { + return perUserLimit; + } + + public void setPerUserLimit(Integer perUserLimit) { + this.perUserLimit = perUserLimit; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/entity/FlashSaleItemEntity.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/entity/FlashSaleItemEntity.java new file mode 100644 index 0000000..00c1e0f --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/entity/FlashSaleItemEntity.java @@ -0,0 +1,110 @@ +package cn.novalon.gym.manage.coupon.flashsale.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; + +import java.math.BigDecimal; + +@Table("flash_sale_item") +public class FlashSaleItemEntity extends BaseEntity { + + @Column("activity_id") + private Long activityId; + + @Column("product_type") + private String productType; + + @Column("product_id") + private Long productId; + + @Column("product_name") + private String productName; + + @Column("original_price") + private BigDecimal originalPrice; + + @Column("seckill_price") + private BigDecimal seckillPrice; + + @Column("stock") + private Integer stock; + + @Column("sold_count") + private Integer soldCount; + + @Column("per_user_limit") + private Integer perUserLimit; + + public Long getActivityId() { + return activityId; + } + + public void setActivityId(Long activityId) { + this.activityId = activityId; + } + + public String getProductType() { + return productType; + } + + public void setProductType(String productType) { + this.productType = productType; + } + + public Long getProductId() { + return productId; + } + + public void setProductId(Long productId) { + this.productId = productId; + } + + public String getProductName() { + return productName; + } + + public void setProductName(String productName) { + this.productName = productName; + } + + public BigDecimal getOriginalPrice() { + return originalPrice; + } + + public void setOriginalPrice(BigDecimal originalPrice) { + this.originalPrice = originalPrice; + } + + public BigDecimal getSeckillPrice() { + return seckillPrice; + } + + public void setSeckillPrice(BigDecimal seckillPrice) { + this.seckillPrice = seckillPrice; + } + + public Integer getStock() { + return stock; + } + + public void setStock(Integer stock) { + this.stock = stock; + } + + public Integer getSoldCount() { + return soldCount; + } + + public void setSoldCount(Integer soldCount) { + this.soldCount = soldCount; + } + + public Integer getPerUserLimit() { + return perUserLimit; + } + + public void setPerUserLimit(Integer perUserLimit) { + this.perUserLimit = perUserLimit; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/entity/FlashSaleOrderEntity.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/entity/FlashSaleOrderEntity.java new file mode 100644 index 0000000..11d801c --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/entity/FlashSaleOrderEntity.java @@ -0,0 +1,100 @@ +package cn.novalon.gym.manage.coupon.flashsale.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; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Table("flash_sale_order") +public class FlashSaleOrderEntity extends BaseEntity { + + @Column("activity_id") + private Long activityId; + + @Column("item_id") + private Long itemId; + + @Column("member_id") + private Long memberId; + + @Column("quantity") + private Integer quantity; + + @Column("pay_amount") + private BigDecimal payAmount; + + @Column("status") + private String status; + + @Column("expire_at") + private LocalDateTime expireAt; + + @Column("pay_at") + private LocalDateTime payAt; + + public Long getActivityId() { + return activityId; + } + + public void setActivityId(Long activityId) { + this.activityId = activityId; + } + + public Long getItemId() { + return itemId; + } + + public void setItemId(Long itemId) { + this.itemId = itemId; + } + + public Long getMemberId() { + return memberId; + } + + public void setMemberId(Long memberId) { + this.memberId = memberId; + } + + public Integer getQuantity() { + return quantity; + } + + public void setQuantity(Integer quantity) { + this.quantity = quantity; + } + + public BigDecimal getPayAmount() { + return payAmount; + } + + public void setPayAmount(BigDecimal payAmount) { + this.payAmount = payAmount; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public LocalDateTime getExpireAt() { + return expireAt; + } + + public void setExpireAt(LocalDateTime expireAt) { + this.expireAt = expireAt; + } + + public LocalDateTime getPayAt() { + return payAt; + } + + public void setPayAt(LocalDateTime payAt) { + this.payAt = payAt; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/enums/FlashSaleActivityStatus.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/enums/FlashSaleActivityStatus.java new file mode 100644 index 0000000..0703763 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/enums/FlashSaleActivityStatus.java @@ -0,0 +1,11 @@ +package cn.novalon.gym.manage.coupon.flashsale.enums; + +/** + * 秒杀活动状态 + */ +public enum FlashSaleActivityStatus { + DRAFT, + ACTIVE, + TERMINATED, + EXPIRED +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/enums/FlashSaleOrderStatus.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/enums/FlashSaleOrderStatus.java new file mode 100644 index 0000000..33a991e --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/enums/FlashSaleOrderStatus.java @@ -0,0 +1,11 @@ +package cn.novalon.gym.manage.coupon.flashsale.enums; + +/** + * 秒杀订单状态 + */ +public enum FlashSaleOrderStatus { + PENDING, + PAID, + CANCELLED, + EXPIRED +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/handler/FlashSaleHandler.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/handler/FlashSaleHandler.java new file mode 100644 index 0000000..5c35bd5 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/handler/FlashSaleHandler.java @@ -0,0 +1,219 @@ +package cn.novalon.gym.manage.coupon.flashsale.handler; + +import cn.novalon.gym.manage.common.dto.PageRequest; +import cn.novalon.gym.manage.coupon.flashsale.domain.FlashSaleActivity; +import cn.novalon.gym.manage.coupon.flashsale.domain.FlashSaleItem; +import cn.novalon.gym.manage.coupon.flashsale.domain.FlashSaleOrder; +import cn.novalon.gym.manage.coupon.flashsale.domain.GrabRequest; +import cn.novalon.gym.manage.coupon.flashsale.service.IFlashSaleActivityService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +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 FlashSaleHandler { + + private final IFlashSaleActivityService flashSaleService; + + public FlashSaleHandler(IFlashSaleActivityService flashSaleService) { + this.flashSaleService = flashSaleService; + } + + @Operation(summary = "获取所有秒杀活动") + public Mono getAllActivities(ServerRequest request) { + boolean includeDeleted = Boolean.parseBoolean(request.queryParam("includeDeleted").orElse("false")); + return ServerResponse.ok() + .body(flashSaleService.findAll(includeDeleted), FlashSaleActivity.class); + } + + @Operation(summary = "分页获取秒杀活动") + public Mono getActivitiesByPage(ServerRequest request) { + return request.bodyToMono(PageRequest.class) + .flatMap(pageRequest -> { + String status = request.queryParam("status").orElse(null); + normalizePageRequest(pageRequest); + return flashSaleService.findByPage(pageRequest, status) + .flatMap(response -> ServerResponse.ok().bodyValue(response)); + }); + } + + @Operation(summary = "搜索秒杀活动") + public Mono searchActivities(ServerRequest request) { + String keyword = request.queryParam("keyword").orElse(""); + String status = request.queryParam("status").orElse(null); + return ServerResponse.ok() + .body(flashSaleService.findByKeywordAndStatus(keyword, status), FlashSaleActivity.class); + } + + @Operation(summary = "根据ID获取秒杀活动") + public Mono getActivityById(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return flashSaleService.findById(id) + .flatMap(activity -> ServerResponse.ok().bodyValue(activity)) + .switchIfEmpty(ServerResponse.notFound().build()); + } + + @Operation(summary = "创建秒杀活动") + public Mono createActivity(ServerRequest request) { + return request.bodyToMono(FlashSaleActivity.class) + .flatMap(activity -> { + if (activity.getName() == null || activity.getName().isEmpty()) { + return badRequest("活动名称不能为空"); + } + return flashSaleService.create(activity) + .flatMap(created -> successResponse("秒杀活动创建成功", created)) + .onErrorResume(this::errorResponse); + }); + } + + @Operation(summary = "更新秒杀活动") + public Mono updateActivity(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return request.bodyToMono(FlashSaleActivity.class) + .flatMap(activity -> flashSaleService.update(id, activity) + .flatMap(updated -> successResponse("秒杀活动更新成功", updated)) + .onErrorResume(this::errorResponse)); + } + + @Operation(summary = "删除秒杀活动") + public Mono deleteActivity(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return flashSaleService.delete(id) + .then(Mono.defer(() -> successResponse("秒杀活动删除成功", null))) + .onErrorResume(this::errorResponse); + } + + @Operation(summary = "发布秒杀活动") + public Mono publishActivity(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return flashSaleService.publish(id) + .flatMap(activity -> successResponse("秒杀活动发布成功", activity)) + .onErrorResume(this::errorResponse); + } + + @Operation(summary = "终止秒杀活动") + public Mono terminateActivity(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return flashSaleService.terminate(id) + .flatMap(activity -> successResponse("秒杀活动已终止", activity)) + .onErrorResume(this::errorResponse); + } + + @Operation(summary = "获取秒杀统计") + public Mono getStatistics(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return flashSaleService.getStatistics(id) + .flatMap(stats -> ServerResponse.ok().bodyValue(stats)) + .onErrorResume(this::errorResponse); + } + + @Operation(summary = "获取秒杀商品列表") + public Mono getItems(ServerRequest request) { + String activityIdStr = request.queryParam("activityId").orElse(null); + if (activityIdStr == null) { + return badRequest("activityId不能为空"); + } + Long activityId = Long.valueOf(activityIdStr); + return ServerResponse.ok() + .body(flashSaleService.findItemsByActivityId(activityId), FlashSaleItem.class); + } + + @Operation(summary = "创建秒杀商品") + public Mono createItem(ServerRequest request) { + return request.bodyToMono(FlashSaleItem.class) + .flatMap(item -> flashSaleService.createItem(item) + .flatMap(created -> successResponse("秒杀商品创建成功", created)) + .onErrorResume(this::errorResponse)); + } + + @Operation(summary = "更新秒杀商品") + public Mono updateItem(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return request.bodyToMono(FlashSaleItem.class) + .flatMap(item -> flashSaleService.updateItem(id, item) + .flatMap(updated -> successResponse("秒杀商品更新成功", updated)) + .onErrorResume(this::errorResponse)); + } + + @Operation(summary = "秒杀抢购") + public Mono grab(ServerRequest request) { + return request.bodyToMono(GrabRequest.class) + .flatMap(body -> { + if (body.getItemId() == null || body.getMemberId() == null) { + return badRequest("itemId和memberId不能为空"); + } + return flashSaleService.grab(body) + .flatMap(order -> successResponse("抢购成功,请尽快支付", order)) + .onErrorResume(this::errorResponse); + }); + } + + @Operation(summary = "支付秒杀订单") + public Mono payOrder(ServerRequest request) { + Long orderId = Long.valueOf(request.pathVariable("orderId")); + return flashSaleService.payOrder(orderId) + .flatMap(order -> successResponse("支付成功", order)) + .onErrorResume(this::errorResponse); + } + + @Operation(summary = "取消秒杀订单") + public Mono cancelOrder(ServerRequest request) { + Long orderId = Long.valueOf(request.pathVariable("orderId")); + return flashSaleService.cancelOrder(orderId) + .flatMap(order -> successResponse("订单已取消", order)) + .onErrorResume(this::errorResponse); + } + + @Operation(summary = "获取会员秒杀订单") + public Mono getOrdersByMember(ServerRequest request) { + Long memberId = Long.valueOf(request.pathVariable("memberId")); + return ServerResponse.ok() + .body(flashSaleService.findOrdersByMemberId(memberId), FlashSaleOrder.class); + } + + private void normalizePageRequest(PageRequest pageRequest) { + if (pageRequest.getPage() < 0) { + pageRequest.setPage(0); + } + if (pageRequest.getSize() <= 0 || pageRequest.getSize() > 100) { + pageRequest.setSize(10); + } + if (pageRequest.getSort() == null || pageRequest.getSort().isEmpty()) { + pageRequest.setSort("id"); + } + if (pageRequest.getOrder() == null || pageRequest.getOrder().isEmpty()) { + pageRequest.setOrder("desc"); + } + } + + private Mono successResponse(String message, Object data) { + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", message); + if (data != null) { + response.put("data", data); + } + return ServerResponse.ok().bodyValue(response); + } + + private Mono badRequest(String message) { + Map error = new HashMap<>(); + error.put("success", false); + error.put("message", message); + return ServerResponse.badRequest().bodyValue(error); + } + + private Mono errorResponse(Throwable 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-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/repository/IFlashSaleActivityRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/repository/IFlashSaleActivityRepository.java new file mode 100644 index 0000000..c66cbea --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/repository/IFlashSaleActivityRepository.java @@ -0,0 +1,26 @@ +package cn.novalon.gym.manage.coupon.flashsale.repository; + +import cn.novalon.gym.manage.coupon.flashsale.domain.FlashSaleActivity; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface IFlashSaleActivityRepository { + + Mono findById(Long id); + + Flux findAll(boolean includeDeleted); + + Flux findByKeyword(String keyword); + + Flux findByStatus(String status); + + Flux findByKeywordAndStatus(String keyword, String status); + + Mono save(FlashSaleActivity activity); + + Mono update(FlashSaleActivity activity); + + Mono deleteById(Long id); + + Mono updateStatus(Long id, String status); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/repository/IFlashSaleItemRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/repository/IFlashSaleItemRepository.java new file mode 100644 index 0000000..6afa37b --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/repository/IFlashSaleItemRepository.java @@ -0,0 +1,22 @@ +package cn.novalon.gym.manage.coupon.flashsale.repository; + +import cn.novalon.gym.manage.coupon.flashsale.domain.FlashSaleItem; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface IFlashSaleItemRepository { + + Mono findById(Long id); + + Flux findByActivityId(Long activityId); + + Mono save(FlashSaleItem item); + + Mono update(FlashSaleItem item); + + Mono deductStock(Long id, int quantity); + + Mono restoreStock(Long id, int quantity); + + Mono incrementSoldCount(Long id, int count); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/repository/IFlashSaleOrderRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/repository/IFlashSaleOrderRepository.java new file mode 100644 index 0000000..0bae51c --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/repository/IFlashSaleOrderRepository.java @@ -0,0 +1,26 @@ +package cn.novalon.gym.manage.coupon.flashsale.repository; + +import cn.novalon.gym.manage.coupon.flashsale.domain.FlashSaleOrder; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Collection; + +public interface IFlashSaleOrderRepository { + + Mono findById(Long id); + + Flux findByMemberId(Long memberId); + + Flux findByActivityId(Long activityId); + + Flux findExpiredPendingOrders(); + + Mono countByItemIdAndMemberIdAndStatusIn(Long itemId, Long memberId, Collection statuses); + + Mono save(FlashSaleOrder order); + + Mono updateStatus(Long id, String status); + + Mono markPaid(Long id, String status); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/repository/impl/FlashSaleActivityRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/repository/impl/FlashSaleActivityRepository.java new file mode 100644 index 0000000..1dc999a --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/repository/impl/FlashSaleActivityRepository.java @@ -0,0 +1,109 @@ +package cn.novalon.gym.manage.coupon.flashsale.repository.impl; + +import cn.novalon.gym.manage.coupon.flashsale.converter.FlashSaleConverter; +import cn.novalon.gym.manage.coupon.flashsale.dao.FlashSaleActivityDao; +import cn.novalon.gym.manage.coupon.flashsale.domain.FlashSaleActivity; +import cn.novalon.gym.manage.coupon.flashsale.entity.FlashSaleActivityEntity; +import cn.novalon.gym.manage.coupon.flashsale.repository.IFlashSaleActivityRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +@Repository +@Transactional +public class FlashSaleActivityRepository implements IFlashSaleActivityRepository { + + private final FlashSaleActivityDao activityDao; + private final FlashSaleConverter converter; + + public FlashSaleActivityRepository(FlashSaleActivityDao activityDao, FlashSaleConverter converter) { + this.activityDao = activityDao; + this.converter = converter; + } + + @Override + public Mono findById(Long id) { + return activityDao.findByIdIsAndDeletedAtIsNull(id).map(converter::toActivity); + } + + @Override + public Flux findAll(boolean includeDeleted) { + if (includeDeleted) { + return activityDao.findAll().map(converter::toActivity); + } + return activityDao.findAllByDeletedAtIsNull().map(converter::toActivity); + } + + @Override + public Flux findByKeyword(String keyword) { + if (keyword == null || keyword.isEmpty()) { + return findAll(false); + } + return activityDao.findByNameContainingAndDeletedAtIsNull(keyword).map(converter::toActivity); + } + + @Override + public Flux findByStatus(String status) { + if (status == null || status.isEmpty()) { + return findAll(false); + } + return activityDao.findByStatusAndDeletedAtIsNull(status).map(converter::toActivity); + } + + @Override + public Flux findByKeywordAndStatus(String keyword, String status) { + Flux result = findByKeyword(keyword); + if (status != null && !status.isEmpty()) { + result = result.filter(a -> status.equals(a.getStatus())); + } + return result; + } + + @Override + public Mono save(FlashSaleActivity activity) { + return activityDao.save(converter.toActivityEntity(activity)).map(converter::toActivity); + } + + @Override + public Mono update(FlashSaleActivity activity) { + return activityDao.findByIdIsAndDeletedAtIsNull(activity.getId()) + .switchIfEmpty(Mono.error(new RuntimeException("秒杀活动不存在"))) + .flatMap(existing -> { + existing.markNotNew(); + if (activity.getName() != null) { + existing.setName(activity.getName()); + } + if (activity.getDescription() != null) { + existing.setDescription(activity.getDescription()); + } + if (activity.getStartTime() != null) { + existing.setStartTime(activity.getStartTime()); + } + if (activity.getEndTime() != null) { + existing.setEndTime(activity.getEndTime()); + } + if (activity.getPayTimeoutMinutes() != null) { + existing.setPayTimeoutMinutes(activity.getPayTimeoutMinutes()); + } + if (activity.getPerUserLimit() != null) { + existing.setPerUserLimit(activity.getPerUserLimit()); + } + existing.setUpdatedAt(LocalDateTime.now()); + return activityDao.save(existing); + }) + .map(converter::toActivity); + } + + @Override + public Mono deleteById(Long id) { + return activityDao.softDelete(id, LocalDateTime.now()).then(); + } + + @Override + public Mono updateStatus(Long id, String status) { + return activityDao.updateStatus(id, status, LocalDateTime.now()).then(); + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/repository/impl/FlashSaleItemRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/repository/impl/FlashSaleItemRepository.java new file mode 100644 index 0000000..64d8db1 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/repository/impl/FlashSaleItemRepository.java @@ -0,0 +1,90 @@ +package cn.novalon.gym.manage.coupon.flashsale.repository.impl; + +import cn.novalon.gym.manage.coupon.flashsale.converter.FlashSaleConverter; +import cn.novalon.gym.manage.coupon.flashsale.dao.FlashSaleItemDao; +import cn.novalon.gym.manage.coupon.flashsale.domain.FlashSaleItem; +import cn.novalon.gym.manage.coupon.flashsale.entity.FlashSaleItemEntity; +import cn.novalon.gym.manage.coupon.flashsale.repository.IFlashSaleItemRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +@Repository +@Transactional +public class FlashSaleItemRepository implements IFlashSaleItemRepository { + + private final FlashSaleItemDao itemDao; + private final FlashSaleConverter converter; + + public FlashSaleItemRepository(FlashSaleItemDao itemDao, FlashSaleConverter converter) { + this.itemDao = itemDao; + this.converter = converter; + } + + @Override + public Mono findById(Long id) { + return itemDao.findByIdIsAndDeletedAtIsNull(id).map(converter::toItem); + } + + @Override + public Flux findByActivityId(Long activityId) { + return itemDao.findByActivityIdAndDeletedAtIsNull(activityId).map(converter::toItem); + } + + @Override + public Mono save(FlashSaleItem item) { + return itemDao.save(converter.toItemEntity(item)).map(converter::toItem); + } + + @Override + public Mono update(FlashSaleItem item) { + return itemDao.findByIdIsAndDeletedAtIsNull(item.getId()) + .switchIfEmpty(Mono.error(new RuntimeException("秒杀商品不存在"))) + .flatMap(existing -> { + existing.markNotNew(); + if (item.getProductType() != null) { + existing.setProductType(item.getProductType()); + } + if (item.getProductId() != null) { + existing.setProductId(item.getProductId()); + } + if (item.getProductName() != null) { + existing.setProductName(item.getProductName()); + } + if (item.getOriginalPrice() != null) { + existing.setOriginalPrice(item.getOriginalPrice()); + } + if (item.getSeckillPrice() != null) { + existing.setSeckillPrice(item.getSeckillPrice()); + } + if (item.getStock() != null) { + existing.setStock(item.getStock()); + } + if (item.getPerUserLimit() != null) { + existing.setPerUserLimit(item.getPerUserLimit()); + } + existing.setUpdatedAt(LocalDateTime.now()); + return itemDao.save(existing); + }) + .map(converter::toItem); + } + + @Override + public Mono deductStock(Long id, int quantity) { + return itemDao.deductStock(id, quantity, LocalDateTime.now()) + .map(rows -> rows != null && rows > 0); + } + + @Override + public Mono restoreStock(Long id, int quantity) { + return itemDao.restoreStock(id, quantity, LocalDateTime.now()).then(); + } + + @Override + public Mono incrementSoldCount(Long id, int count) { + return itemDao.incrementSoldCount(id, count, LocalDateTime.now()).then(); + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/repository/impl/FlashSaleOrderRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/repository/impl/FlashSaleOrderRepository.java new file mode 100644 index 0000000..842c679 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/repository/impl/FlashSaleOrderRepository.java @@ -0,0 +1,68 @@ +package cn.novalon.gym.manage.coupon.flashsale.repository.impl; + +import cn.novalon.gym.manage.coupon.flashsale.converter.FlashSaleConverter; +import cn.novalon.gym.manage.coupon.flashsale.dao.FlashSaleOrderDao; +import cn.novalon.gym.manage.coupon.flashsale.domain.FlashSaleOrder; +import cn.novalon.gym.manage.coupon.flashsale.enums.FlashSaleOrderStatus; +import cn.novalon.gym.manage.coupon.flashsale.repository.IFlashSaleOrderRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; +import java.util.Collection; + +@Repository +@Transactional +public class FlashSaleOrderRepository implements IFlashSaleOrderRepository { + + private final FlashSaleOrderDao orderDao; + private final FlashSaleConverter converter; + + public FlashSaleOrderRepository(FlashSaleOrderDao orderDao, FlashSaleConverter converter) { + this.orderDao = orderDao; + this.converter = converter; + } + + @Override + public Mono findById(Long id) { + return orderDao.findByIdIsAndDeletedAtIsNull(id).map(converter::toOrder); + } + + @Override + public Flux findByMemberId(Long memberId) { + return orderDao.findByMemberIdAndDeletedAtIsNull(memberId).map(converter::toOrder); + } + + @Override + public Flux findByActivityId(Long activityId) { + return orderDao.findByActivityIdAndDeletedAtIsNull(activityId).map(converter::toOrder); + } + + @Override + public Flux findExpiredPendingOrders() { + return orderDao.findExpiredPendingOrders( + FlashSaleOrderStatus.PENDING.name(), LocalDateTime.now()).map(converter::toOrder); + } + + @Override + public Mono countByItemIdAndMemberIdAndStatusIn(Long itemId, Long memberId, Collection statuses) { + return orderDao.countByItemIdAndMemberIdAndStatusInAndDeletedAtIsNull(itemId, memberId, statuses); + } + + @Override + public Mono save(FlashSaleOrder order) { + return orderDao.save(converter.toOrderEntity(order)).map(converter::toOrder); + } + + @Override + public Mono updateStatus(Long id, String status) { + return orderDao.updateStatus(id, status, LocalDateTime.now()).then(); + } + + @Override + public Mono markPaid(Long id, String status) { + return orderDao.markPaid(id, status, LocalDateTime.now(), LocalDateTime.now()).then(); + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/scheduler/FlashSaleOrderExpireScheduler.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/scheduler/FlashSaleOrderExpireScheduler.java new file mode 100644 index 0000000..ce581c1 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/scheduler/FlashSaleOrderExpireScheduler.java @@ -0,0 +1,35 @@ +package cn.novalon.gym.manage.coupon.flashsale.scheduler; + +import cn.novalon.gym.manage.coupon.flashsale.service.IFlashSaleActivityService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 秒杀订单过期定时任务 + * + * 功能:定期检查已过期的待支付订单,取消订单并恢复库存 + */ +@Component +public class FlashSaleOrderExpireScheduler { + + private static final Logger logger = LoggerFactory.getLogger(FlashSaleOrderExpireScheduler.class); + + private final IFlashSaleActivityService flashSaleService; + + public FlashSaleOrderExpireScheduler(IFlashSaleActivityService flashSaleService) { + this.flashSaleService = flashSaleService; + } + + @Scheduled(fixedRate = 60000) + public void processExpiredOrders() { + logger.debug("定时任务开始检查过期秒杀订单"); + + flashSaleService.processExpiredOrders() + .subscribe( + count -> logger.debug("定时任务完成,处理了 {} 个过期秒杀订单", count), + error -> logger.error("秒杀订单过期定时任务执行失败:{}", error.getMessage(), error) + ); + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/service/IFlashSaleActivityService.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/service/IFlashSaleActivityService.java new file mode 100644 index 0000000..d008e34 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/service/IFlashSaleActivityService.java @@ -0,0 +1,50 @@ +package cn.novalon.gym.manage.coupon.flashsale.service; + +import cn.novalon.gym.manage.common.dto.PageRequest; +import cn.novalon.gym.manage.common.dto.PageResponse; +import cn.novalon.gym.manage.coupon.flashsale.domain.FlashSaleActivity; +import cn.novalon.gym.manage.coupon.flashsale.domain.FlashSaleItem; +import cn.novalon.gym.manage.coupon.flashsale.domain.FlashSaleOrder; +import cn.novalon.gym.manage.coupon.flashsale.domain.FlashSaleStatistics; +import cn.novalon.gym.manage.coupon.flashsale.domain.GrabRequest; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface IFlashSaleActivityService { + + Mono findById(Long id); + + Flux findAll(boolean includeDeleted); + + Flux findByKeywordAndStatus(String keyword, String status); + + Mono> findByPage(PageRequest pageRequest, String status); + + Mono create(FlashSaleActivity activity); + + Mono update(Long id, FlashSaleActivity activity); + + Mono delete(Long id); + + Mono publish(Long id); + + Mono terminate(Long id); + + Flux findItemsByActivityId(Long activityId); + + Mono createItem(FlashSaleItem item); + + Mono updateItem(Long id, FlashSaleItem item); + + Mono grab(GrabRequest request); + + Mono payOrder(Long orderId); + + Mono cancelOrder(Long orderId); + + Flux findOrdersByMemberId(Long memberId); + + Mono getStatistics(Long id); + + Mono processExpiredOrders(); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/service/impl/FlashSaleActivityService.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/service/impl/FlashSaleActivityService.java new file mode 100644 index 0000000..05d1618 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/flashsale/service/impl/FlashSaleActivityService.java @@ -0,0 +1,390 @@ +package cn.novalon.gym.manage.coupon.flashsale.service.impl; + +import cn.novalon.gym.manage.common.dto.PageRequest; +import cn.novalon.gym.manage.common.dto.PageResponse; +import cn.novalon.gym.manage.common.util.SnowflakeId; +import cn.novalon.gym.manage.coupon.flashsale.domain.FlashSaleActivity; +import cn.novalon.gym.manage.coupon.flashsale.domain.FlashSaleItem; +import cn.novalon.gym.manage.coupon.flashsale.domain.FlashSaleOrder; +import cn.novalon.gym.manage.coupon.flashsale.domain.FlashSaleStatistics; +import cn.novalon.gym.manage.coupon.flashsale.domain.GrabRequest; +import cn.novalon.gym.manage.coupon.flashsale.enums.FlashSaleActivityStatus; +import cn.novalon.gym.manage.coupon.flashsale.enums.FlashSaleOrderStatus; +import cn.novalon.gym.manage.coupon.flashsale.repository.IFlashSaleActivityRepository; +import cn.novalon.gym.manage.coupon.flashsale.repository.IFlashSaleItemRepository; +import cn.novalon.gym.manage.coupon.flashsale.repository.IFlashSaleOrderRepository; +import cn.novalon.gym.manage.coupon.flashsale.service.IFlashSaleActivityService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; + +@Service +public class FlashSaleActivityService implements IFlashSaleActivityService { + + private static final Logger logger = LoggerFactory.getLogger(FlashSaleActivityService.class); + + private final IFlashSaleActivityRepository activityRepository; + private final IFlashSaleItemRepository itemRepository; + private final IFlashSaleOrderRepository orderRepository; + + public FlashSaleActivityService(IFlashSaleActivityRepository activityRepository, + IFlashSaleItemRepository itemRepository, + IFlashSaleOrderRepository orderRepository) { + this.activityRepository = activityRepository; + this.itemRepository = itemRepository; + this.orderRepository = orderRepository; + } + + @Override + public Mono findById(Long id) { + return activityRepository.findById(id); + } + + @Override + public Flux findAll(boolean includeDeleted) { + return activityRepository.findAll(includeDeleted); + } + + @Override + public Flux findByKeywordAndStatus(String keyword, String status) { + return activityRepository.findByKeywordAndStatus(keyword, status); + } + + @Override + public Mono> findByPage(PageRequest pageRequest, String status) { + int page = Math.max(pageRequest.getPage(), 0); + int size = pageRequest.getSize() <= 0 || pageRequest.getSize() > 100 ? 10 : pageRequest.getSize(); + String keyword = pageRequest.getKeyword(); + + return activityRepository.findByKeywordAndStatus(keyword, status) + .sort(Comparator.comparing(FlashSaleActivity::getCreatedAt, + Comparator.nullsLast(Comparator.reverseOrder()))) + .collectList() + .map(list -> { + long total = list.size(); + int fromIndex = Math.min(page * size, list.size()); + int toIndex = Math.min(fromIndex + size, list.size()); + List content = list.subList(fromIndex, toIndex); + int totalPages = size == 0 ? 0 : (int) Math.ceil((double) total / size); + return new PageResponse<>(content, totalPages, total, page, size); + }); + } + + @Override + public Mono create(FlashSaleActivity activity) { + return validateActivity(activity) + .flatMap(validated -> { + validated.generateId(); + validated.setStatus(FlashSaleActivityStatus.DRAFT.name()); + applyDefaults(validated); + return activityRepository.save(validated); + }) + .doOnSuccess(a -> logger.info("秒杀活动创建成功 - id={}, name={}", a.getId(), a.getName())); + } + + @Override + public Mono update(Long id, FlashSaleActivity activity) { + return activityRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("秒杀活动不存在"))) + .flatMap(existing -> { + if (!FlashSaleActivityStatus.DRAFT.name().equals(existing.getStatus())) { + return Mono.error(new RuntimeException("仅草稿状态的秒杀活动可编辑")); + } + activity.setId(id); + return validateActivity(activity) + .flatMap(activityRepository::update); + }) + .doOnSuccess(a -> logger.info("秒杀活动更新成功 - id={}", id)); + } + + @Override + public Mono delete(Long id) { + return activityRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("秒杀活动不存在"))) + .flatMap(existing -> { + if (!FlashSaleActivityStatus.DRAFT.name().equals(existing.getStatus())) { + return Mono.error(new RuntimeException("仅草稿状态的秒杀活动可删除")); + } + return activityRepository.deleteById(id); + }); + } + + @Override + public Mono publish(Long id) { + return activityRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("秒杀活动不存在"))) + .flatMap(existing -> { + if (!FlashSaleActivityStatus.DRAFT.name().equals(existing.getStatus())) { + return Mono.error(new RuntimeException("仅草稿状态的秒杀活动可发布")); + } + return validateActivity(existing) + .flatMap(validated -> activityRepository.updateStatus(id, FlashSaleActivityStatus.ACTIVE.name()) + .then(activityRepository.findById(id))); + }) + .doOnSuccess(a -> logger.info("秒杀活动发布成功 - id={}", id)); + } + + @Override + public Mono terminate(Long id) { + return activityRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("秒杀活动不存在"))) + .flatMap(existing -> { + if (!FlashSaleActivityStatus.ACTIVE.name().equals(existing.getStatus())) { + return Mono.error(new RuntimeException("仅进行中的秒杀活动可终止")); + } + return activityRepository.updateStatus(id, FlashSaleActivityStatus.TERMINATED.name()) + .then(activityRepository.findById(id)); + }) + .doOnSuccess(a -> logger.info("秒杀活动已终止 - id={}", id)); + } + + @Override + public Flux findItemsByActivityId(Long activityId) { + return itemRepository.findByActivityId(activityId); + } + + @Override + public Mono createItem(FlashSaleItem item) { + return validateItem(item) + .flatMap(validated -> activityRepository.findById(validated.getActivityId()) + .switchIfEmpty(Mono.error(new RuntimeException("秒杀活动不存在"))) + .flatMap(activity -> { + if (!FlashSaleActivityStatus.DRAFT.name().equals(activity.getStatus())) { + return Mono.error(new RuntimeException("仅草稿状态的秒杀活动可添加商品")); + } + validated.setId(SnowflakeId.nextId()); + validated.setSoldCount(0); + if (validated.getPerUserLimit() == null) { + validated.setPerUserLimit(activity.getPerUserLimit() != null ? activity.getPerUserLimit() : 1); + } + return itemRepository.save(validated); + })); + } + + @Override + public Mono updateItem(Long id, FlashSaleItem item) { + return itemRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("秒杀商品不存在"))) + .flatMap(existing -> activityRepository.findById(existing.getActivityId()) + .flatMap(activity -> { + if (!FlashSaleActivityStatus.DRAFT.name().equals(activity.getStatus())) { + return Mono.error(new RuntimeException("活动已发布,不可修改商品")); + } + item.setId(id); + return validateItem(item) + .flatMap(itemRepository::update); + })); + } + + @Override + public Mono grab(GrabRequest request) { + if (request.getItemId() == null || request.getMemberId() == null) { + return Mono.error(new RuntimeException("itemId和memberId不能为空")); + } + int quantity = request.getQuantity() != null && request.getQuantity() > 0 ? request.getQuantity() : 1; + + return itemRepository.findById(request.getItemId()) + .switchIfEmpty(Mono.error(new RuntimeException("秒杀商品不存在"))) + .flatMap(item -> activityRepository.findById(item.getActivityId()) + .switchIfEmpty(Mono.error(new RuntimeException("秒杀活动不存在"))) + .flatMap(activity -> validateActivityForGrab(activity) + .then(checkUserLimit(item, activity, request.getMemberId(), quantity)) + .then(itemRepository.deductStock(item.getId(), quantity)) + .flatMap(success -> { + if (!success) { + return Mono.error(new RuntimeException("库存不足")); + } + return createOrder(item, activity, request.getMemberId(), quantity) + .onErrorResume(e -> itemRepository.restoreStock(item.getId(), quantity) + .then(Mono.error(e))); + }))); + } + + @Override + public Mono payOrder(Long orderId) { + return orderRepository.findById(orderId) + .switchIfEmpty(Mono.error(new RuntimeException("秒杀订单不存在"))) + .flatMap(order -> { + if (!FlashSaleOrderStatus.PENDING.name().equals(order.getStatus())) { + return Mono.error(new RuntimeException("订单状态不允许支付")); + } + if (order.getExpireAt() != null && order.getExpireAt().isBefore(LocalDateTime.now())) { + return Mono.error(new RuntimeException("订单已过期")); + } + return orderRepository.markPaid(orderId, FlashSaleOrderStatus.PAID.name()) + .then(itemRepository.incrementSoldCount(order.getItemId(), order.getQuantity())) + .then(orderRepository.findById(orderId)); + }) + .doOnSuccess(o -> logger.info("秒杀订单支付成功 - orderId={}", orderId)); + } + + @Override + public Mono cancelOrder(Long orderId) { + return orderRepository.findById(orderId) + .switchIfEmpty(Mono.error(new RuntimeException("秒杀订单不存在"))) + .flatMap(order -> { + if (!FlashSaleOrderStatus.PENDING.name().equals(order.getStatus())) { + return Mono.error(new RuntimeException("仅待支付订单可取消")); + } + return orderRepository.updateStatus(orderId, FlashSaleOrderStatus.CANCELLED.name()) + .then(itemRepository.restoreStock(order.getItemId(), order.getQuantity())) + .then(orderRepository.findById(orderId)); + }) + .doOnSuccess(o -> logger.info("秒杀订单已取消 - orderId={}", orderId)); + } + + @Override + public Flux findOrdersByMemberId(Long memberId) { + return orderRepository.findByMemberId(memberId); + } + + @Override + public Mono getStatistics(Long id) { + return activityRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("秒杀活动不存在"))) + .flatMap(activity -> orderRepository.findByActivityId(id) + .collectList() + .map(orders -> { + FlashSaleStatistics stats = new FlashSaleStatistics(); + stats.setActivityId(id); + stats.setTotalOrders(orders.size()); + stats.setPendingOrders(countByStatus(orders, FlashSaleOrderStatus.PENDING)); + stats.setPaidOrders(countByStatus(orders, FlashSaleOrderStatus.PAID)); + stats.setCancelledOrders(countByStatus(orders, FlashSaleOrderStatus.CANCELLED)); + stats.setExpiredOrders(countByStatus(orders, FlashSaleOrderStatus.EXPIRED)); + + long soldQty = orders.stream() + .filter(o -> FlashSaleOrderStatus.PAID.name().equals(o.getStatus())) + .mapToLong(o -> o.getQuantity() != null ? o.getQuantity() : 0) + .sum(); + stats.setTotalSoldQuantity(soldQty); + + BigDecimal revenue = orders.stream() + .filter(o -> FlashSaleOrderStatus.PAID.name().equals(o.getStatus())) + .map(o -> o.getPayAmount() != null ? o.getPayAmount() : BigDecimal.ZERO) + .reduce(BigDecimal.ZERO, BigDecimal::add); + stats.setTotalRevenue(revenue); + + return stats; + })); + } + + @Override + public Mono processExpiredOrders() { + return orderRepository.findExpiredPendingOrders() + .flatMap(order -> orderRepository.updateStatus(order.getId(), FlashSaleOrderStatus.EXPIRED.name()) + .then(itemRepository.restoreStock(order.getItemId(), order.getQuantity())) + .thenReturn(1L)) + .reduce(0L, Long::sum) + .doOnSuccess(count -> { + if (count > 0) { + logger.info("已处理 {} 个过期秒杀订单", count); + } + }); + } + + private Mono createOrder(FlashSaleItem item, FlashSaleActivity activity, + Long memberId, int quantity) { + int timeoutMinutes = activity.getPayTimeoutMinutes() != null ? activity.getPayTimeoutMinutes() : 5; + BigDecimal payAmount = item.getSeckillPrice().multiply(BigDecimal.valueOf(quantity)); + + FlashSaleOrder order = new FlashSaleOrder(); + order.setId(SnowflakeId.nextId()); + order.setActivityId(activity.getId()); + order.setItemId(item.getId()); + order.setMemberId(memberId); + order.setQuantity(quantity); + order.setPayAmount(payAmount); + order.setStatus(FlashSaleOrderStatus.PENDING.name()); + order.setExpireAt(LocalDateTime.now().plusMinutes(timeoutMinutes)); + + return orderRepository.save(order); + } + + private Mono checkUserLimit(FlashSaleItem item, FlashSaleActivity activity, + Long memberId, int quantity) { + int itemLimit = item.getPerUserLimit() != null ? item.getPerUserLimit() : 1; + int activityLimit = activity.getPerUserLimit() != null ? activity.getPerUserLimit() : 1; + int effectiveLimit = Math.min(itemLimit, activityLimit); + + return orderRepository.countByItemIdAndMemberIdAndStatusIn( + item.getId(), memberId, + Arrays.asList(FlashSaleOrderStatus.PENDING.name(), FlashSaleOrderStatus.PAID.name())) + .flatMap(count -> { + if (count + quantity > effectiveLimit) { + return Mono.error(new RuntimeException("超出每人限购数量")); + } + return Mono.empty(); + }); + } + + private Mono validateActivityForGrab(FlashSaleActivity activity) { + if (!FlashSaleActivityStatus.ACTIVE.name().equals(activity.getStatus())) { + return Mono.error(new RuntimeException("秒杀活动未开始或已结束")); + } + LocalDateTime now = LocalDateTime.now(); + if (activity.getStartTime() != null && now.isBefore(activity.getStartTime())) { + return Mono.error(new RuntimeException("秒杀活动尚未开始")); + } + if (activity.getEndTime() != null && now.isAfter(activity.getEndTime())) { + return Mono.error(new RuntimeException("秒杀活动已结束")); + } + return Mono.empty(); + } + + private Mono validateActivity(FlashSaleActivity activity) { + if (activity.getName() == null || activity.getName().isBlank()) { + return Mono.error(new RuntimeException("活动名称不能为空")); + } + if (activity.getStartTime() == null || activity.getEndTime() == null) { + return Mono.error(new RuntimeException("活动开始和结束时间不能为空")); + } + if (activity.getEndTime().isBefore(activity.getStartTime())) { + return Mono.error(new RuntimeException("结束时间不能早于开始时间")); + } + return Mono.just(activity); + } + + private Mono validateItem(FlashSaleItem item) { + if (item.getActivityId() == null) { + return Mono.error(new RuntimeException("活动ID不能为空")); + } + if (item.getProductType() == null || item.getProductName() == null) { + return Mono.error(new RuntimeException("商品类型和名称不能为空")); + } + if (item.getProductId() == null) { + return Mono.error(new RuntimeException("商品ID不能为空")); + } + if (item.getOriginalPrice() == null || item.getSeckillPrice() == null) { + return Mono.error(new RuntimeException("原价和秒杀价不能为空")); + } + if (item.getSeckillPrice().compareTo(item.getOriginalPrice()) >= 0) { + return Mono.error(new RuntimeException("秒杀价必须低于原价")); + } + if (item.getStock() == null || item.getStock() < 0) { + return Mono.error(new RuntimeException("库存不能为空且不能为负数")); + } + return Mono.just(item); + } + + private void applyDefaults(FlashSaleActivity activity) { + if (activity.getPayTimeoutMinutes() == null) { + activity.setPayTimeoutMinutes(5); + } + if (activity.getPerUserLimit() == null) { + activity.setPerUserLimit(1); + } + } + + private long countByStatus(List orders, FlashSaleOrderStatus status) { + return orders.stream().filter(o -> status.name().equals(o.getStatus())).count(); + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/converter/GroupBuyConverter.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/converter/GroupBuyConverter.java new file mode 100644 index 0000000..2abf7b8 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/converter/GroupBuyConverter.java @@ -0,0 +1,77 @@ +package cn.novalon.gym.manage.coupon.groupbuy.converter; + +import cn.hutool.core.bean.BeanUtil; +import cn.novalon.gym.manage.coupon.groupbuy.domain.GroupBuyActivity; +import cn.novalon.gym.manage.coupon.groupbuy.domain.GroupBuyParticipant; +import cn.novalon.gym.manage.coupon.groupbuy.domain.GroupBuyTeam; +import cn.novalon.gym.manage.coupon.groupbuy.entity.GroupBuyActivityEntity; +import cn.novalon.gym.manage.coupon.groupbuy.entity.GroupBuyParticipantEntity; +import cn.novalon.gym.manage.coupon.groupbuy.entity.GroupBuyTeamEntity; +import org.springframework.stereotype.Component; + +@Component +public class GroupBuyConverter { + + public GroupBuyActivity toActivity(GroupBuyActivityEntity entity) { + if (entity == null) { + return null; + } + GroupBuyActivity domain = new GroupBuyActivity(); + BeanUtil.copyProperties(entity, domain); + return domain; + } + + public GroupBuyActivityEntity toActivityEntity(GroupBuyActivity domain) { + if (domain == null) { + return null; + } + GroupBuyActivityEntity entity = new GroupBuyActivityEntity(); + BeanUtil.copyProperties(domain, entity); + if (domain.getId() != null) { + entity.markNotNew(); + } + return entity; + } + + public GroupBuyTeam toTeam(GroupBuyTeamEntity entity) { + if (entity == null) { + return null; + } + GroupBuyTeam domain = new GroupBuyTeam(); + BeanUtil.copyProperties(entity, domain); + return domain; + } + + public GroupBuyTeamEntity toTeamEntity(GroupBuyTeam domain) { + if (domain == null) { + return null; + } + GroupBuyTeamEntity entity = new GroupBuyTeamEntity(); + BeanUtil.copyProperties(domain, entity); + if (domain.getId() != null) { + entity.markNotNew(); + } + return entity; + } + + public GroupBuyParticipant toParticipant(GroupBuyParticipantEntity entity) { + if (entity == null) { + return null; + } + GroupBuyParticipant domain = new GroupBuyParticipant(); + BeanUtil.copyProperties(entity, domain); + return domain; + } + + public GroupBuyParticipantEntity toParticipantEntity(GroupBuyParticipant domain) { + if (domain == null) { + return null; + } + GroupBuyParticipantEntity entity = new GroupBuyParticipantEntity(); + BeanUtil.copyProperties(domain, entity); + if (domain.getId() != null) { + entity.markNotNew(); + } + return entity; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/dao/GroupBuyActivityDao.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/dao/GroupBuyActivityDao.java new file mode 100644 index 0000000..1a1bb07 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/dao/GroupBuyActivityDao.java @@ -0,0 +1,35 @@ +package cn.novalon.gym.manage.coupon.groupbuy.dao; + +import cn.novalon.gym.manage.coupon.groupbuy.entity.GroupBuyActivityEntity; +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 GroupBuyActivityDao extends R2dbcRepository { + + Mono findByIdIsAndDeletedAtIsNull(Long id); + + Flux findAllByDeletedAtIsNull(); + + Flux findByNameContainingAndDeletedAtIsNull(String name); + + Flux findByStatusAndDeletedAtIsNull(String status); + + @Modifying + @Query("UPDATE group_buy_activity SET deleted_at = :deletedAt WHERE id = :id") + Mono softDelete(Long id, LocalDateTime deletedAt); + + @Modifying + @Query("UPDATE group_buy_activity SET status = :status, updated_at = :updatedAt WHERE id = :id") + Mono updateStatus(Long id, String status, LocalDateTime updatedAt); + + @Modifying + @Query("UPDATE group_buy_activity SET sold_count = sold_count + :count, updated_at = :updatedAt WHERE id = :id") + Mono incrementSoldCount(Long id, int count, LocalDateTime updatedAt); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/dao/GroupBuyParticipantDao.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/dao/GroupBuyParticipantDao.java new file mode 100644 index 0000000..2632c09 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/dao/GroupBuyParticipantDao.java @@ -0,0 +1,35 @@ +package cn.novalon.gym.manage.coupon.groupbuy.dao; + +import cn.novalon.gym.manage.coupon.groupbuy.entity.GroupBuyParticipantEntity; +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 GroupBuyParticipantDao extends R2dbcRepository { + + Flux findByTeamIdAndDeletedAtIsNull(Long teamId); + + Flux findByActivityIdAndDeletedAtIsNull(Long activityId); + + @Query(""" + SELECT p.* FROM group_buy_participant p + INNER JOIN group_buy_team t ON p.team_id = t.id + WHERE p.activity_id = :activityId AND p.member_id = :memberId + AND p.status = :participantStatus AND t.status = :teamStatus + AND p.deleted_at IS NULL AND t.deleted_at IS NULL + """) + Flux findActiveParticipantInFormingTeam( + Long activityId, Long memberId, String participantStatus, String teamStatus); + + Mono countByActivityIdAndDeletedAtIsNull(Long activityId); + + @Modifying + @Query("UPDATE group_buy_participant SET status = :status, updated_at = :updatedAt WHERE team_id = :teamId") + Mono updateStatusByTeamId(Long teamId, String status, LocalDateTime updatedAt); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/dao/GroupBuyTeamDao.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/dao/GroupBuyTeamDao.java new file mode 100644 index 0000000..044d83f --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/dao/GroupBuyTeamDao.java @@ -0,0 +1,38 @@ +package cn.novalon.gym.manage.coupon.groupbuy.dao; + +import cn.novalon.gym.manage.coupon.groupbuy.entity.GroupBuyTeamEntity; +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 GroupBuyTeamDao extends R2dbcRepository { + + Mono findByIdIsAndDeletedAtIsNull(Long id); + + Flux findByActivityIdAndDeletedAtIsNull(Long activityId); + + Flux findByActivityIdAndStatusAndDeletedAtIsNull(Long activityId, String status); + + Flux findByStatusAndDeletedAtIsNull(String status); + + @Query("SELECT * FROM group_buy_team WHERE status = :status AND expire_at < :now AND deleted_at IS NULL") + Flux findExpiredFormingTeams(String status, LocalDateTime now); + + @Modifying + @Query("UPDATE group_buy_team SET status = :status, updated_at = :updatedAt WHERE id = :id") + Mono updateStatus(Long id, String status, LocalDateTime updatedAt); + + @Modifying + @Query("UPDATE group_buy_team SET current_members = current_members + :count, updated_at = :updatedAt WHERE id = :id") + Mono incrementCurrentMembers(Long id, int count, LocalDateTime updatedAt); + + @Modifying + @Query("UPDATE group_buy_team SET status = :status, success_at = :successAt, updated_at = :updatedAt WHERE id = :id") + Mono markSuccess(Long id, String status, LocalDateTime successAt, LocalDateTime updatedAt); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/domain/CreateTeamRequest.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/domain/CreateTeamRequest.java new file mode 100644 index 0000000..dbc49d0 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/domain/CreateTeamRequest.java @@ -0,0 +1,26 @@ +package cn.novalon.gym.manage.coupon.groupbuy.domain; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "创建拼团团队请求") +public class CreateTeamRequest { + + private Long activityId; + private Long memberId; + + public Long getActivityId() { + return activityId; + } + + public void setActivityId(Long activityId) { + this.activityId = activityId; + } + + public Long getMemberId() { + return memberId; + } + + public void setMemberId(Long memberId) { + this.memberId = memberId; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/domain/GroupBuyActivity.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/domain/GroupBuyActivity.java new file mode 100644 index 0000000..f674043 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/domain/GroupBuyActivity.java @@ -0,0 +1,138 @@ +package cn.novalon.gym.manage.coupon.groupbuy.domain; + +import cn.novalon.gym.manage.sys.core.domain.BaseDomain; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Schema(description = "拼团活动") +public class GroupBuyActivity extends BaseDomain { + + private String name; + private String description; + private String productType; + private Long productId; + private String productName; + private BigDecimal originalPrice; + private BigDecimal groupPrice; + private Integer requiredMembers; + private Integer validHours; + private LocalDateTime startTime; + private LocalDateTime endTime; + private Integer stock; + private Integer soldCount; + private String status; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getProductType() { + return productType; + } + + public void setProductType(String productType) { + this.productType = productType; + } + + public Long getProductId() { + return productId; + } + + public void setProductId(Long productId) { + this.productId = productId; + } + + public String getProductName() { + return productName; + } + + public void setProductName(String productName) { + this.productName = productName; + } + + public BigDecimal getOriginalPrice() { + return originalPrice; + } + + public void setOriginalPrice(BigDecimal originalPrice) { + this.originalPrice = originalPrice; + } + + public BigDecimal getGroupPrice() { + return groupPrice; + } + + public void setGroupPrice(BigDecimal groupPrice) { + this.groupPrice = groupPrice; + } + + public Integer getRequiredMembers() { + return requiredMembers; + } + + public void setRequiredMembers(Integer requiredMembers) { + this.requiredMembers = requiredMembers; + } + + public Integer getValidHours() { + return validHours; + } + + public void setValidHours(Integer validHours) { + this.validHours = validHours; + } + + public LocalDateTime getStartTime() { + return startTime; + } + + public void setStartTime(LocalDateTime startTime) { + this.startTime = startTime; + } + + public LocalDateTime getEndTime() { + return endTime; + } + + public void setEndTime(LocalDateTime endTime) { + this.endTime = endTime; + } + + public Integer getStock() { + return stock; + } + + public void setStock(Integer stock) { + this.stock = stock; + } + + public Integer getSoldCount() { + return soldCount; + } + + public void setSoldCount(Integer soldCount) { + this.soldCount = soldCount; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/domain/GroupBuyParticipant.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/domain/GroupBuyParticipant.java new file mode 100644 index 0000000..ef1f6e7 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/domain/GroupBuyParticipant.java @@ -0,0 +1,65 @@ +package cn.novalon.gym.manage.coupon.groupbuy.domain; + +import cn.novalon.gym.manage.sys.core.domain.BaseDomain; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +@Schema(description = "拼团参与人") +public class GroupBuyParticipant extends BaseDomain { + + private Long teamId; + private Long activityId; + private Long memberId; + private Boolean isLeader; + private String status; + private LocalDateTime joinAt; + + public Long getTeamId() { + return teamId; + } + + public void setTeamId(Long teamId) { + this.teamId = teamId; + } + + public Long getActivityId() { + return activityId; + } + + public void setActivityId(Long activityId) { + this.activityId = activityId; + } + + public Long getMemberId() { + return memberId; + } + + public void setMemberId(Long memberId) { + this.memberId = memberId; + } + + public Boolean getIsLeader() { + return isLeader; + } + + public void setIsLeader(Boolean isLeader) { + this.isLeader = isLeader; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public LocalDateTime getJoinAt() { + return joinAt; + } + + public void setJoinAt(LocalDateTime joinAt) { + this.joinAt = joinAt; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/domain/GroupBuyStatistics.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/domain/GroupBuyStatistics.java new file mode 100644 index 0000000..83b8e9e --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/domain/GroupBuyStatistics.java @@ -0,0 +1,91 @@ +package cn.novalon.gym.manage.coupon.groupbuy.domain; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; + +@Schema(description = "拼团统计数据") +public class GroupBuyStatistics { + + private Long activityId; + private long totalTeams; + private long formingTeams; + private long successTeams; + private long failedTeams; + private long cancelledTeams; + private long totalParticipants; + private long soldCount; + private BigDecimal totalRevenue; + + public Long getActivityId() { + return activityId; + } + + public void setActivityId(Long activityId) { + this.activityId = activityId; + } + + public long getTotalTeams() { + return totalTeams; + } + + public void setTotalTeams(long totalTeams) { + this.totalTeams = totalTeams; + } + + public long getFormingTeams() { + return formingTeams; + } + + public void setFormingTeams(long formingTeams) { + this.formingTeams = formingTeams; + } + + public long getSuccessTeams() { + return successTeams; + } + + public void setSuccessTeams(long successTeams) { + this.successTeams = successTeams; + } + + public long getFailedTeams() { + return failedTeams; + } + + public void setFailedTeams(long failedTeams) { + this.failedTeams = failedTeams; + } + + public long getCancelledTeams() { + return cancelledTeams; + } + + public void setCancelledTeams(long cancelledTeams) { + this.cancelledTeams = cancelledTeams; + } + + public long getTotalParticipants() { + return totalParticipants; + } + + public void setTotalParticipants(long totalParticipants) { + this.totalParticipants = totalParticipants; + } + + public long getSoldCount() { + return soldCount; + } + + public void setSoldCount(long soldCount) { + this.soldCount = soldCount; + } + + public BigDecimal getTotalRevenue() { + return totalRevenue; + } + + public void setTotalRevenue(BigDecimal totalRevenue) { + this.totalRevenue = totalRevenue; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/domain/GroupBuyTeam.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/domain/GroupBuyTeam.java new file mode 100644 index 0000000..27eaba7 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/domain/GroupBuyTeam.java @@ -0,0 +1,74 @@ +package cn.novalon.gym.manage.coupon.groupbuy.domain; + +import cn.novalon.gym.manage.sys.core.domain.BaseDomain; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +@Schema(description = "拼团团队") +public class GroupBuyTeam extends BaseDomain { + + private Long activityId; + private Long leaderMemberId; + private Integer requiredMembers; + private Integer currentMembers; + private String status; + private LocalDateTime expireAt; + private LocalDateTime successAt; + + public Long getActivityId() { + return activityId; + } + + public void setActivityId(Long activityId) { + this.activityId = activityId; + } + + public Long getLeaderMemberId() { + return leaderMemberId; + } + + public void setLeaderMemberId(Long leaderMemberId) { + this.leaderMemberId = leaderMemberId; + } + + public Integer getRequiredMembers() { + return requiredMembers; + } + + public void setRequiredMembers(Integer requiredMembers) { + this.requiredMembers = requiredMembers; + } + + public Integer getCurrentMembers() { + return currentMembers; + } + + public void setCurrentMembers(Integer currentMembers) { + this.currentMembers = currentMembers; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public LocalDateTime getExpireAt() { + return expireAt; + } + + public void setExpireAt(LocalDateTime expireAt) { + this.expireAt = expireAt; + } + + public LocalDateTime getSuccessAt() { + return successAt; + } + + public void setSuccessAt(LocalDateTime successAt) { + this.successAt = successAt; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/domain/JoinTeamRequest.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/domain/JoinTeamRequest.java new file mode 100644 index 0000000..8e49ef0 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/domain/JoinTeamRequest.java @@ -0,0 +1,17 @@ +package cn.novalon.gym.manage.coupon.groupbuy.domain; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "加入拼团团队请求") +public class JoinTeamRequest { + + private Long memberId; + + public Long getMemberId() { + return memberId; + } + + public void setMemberId(Long memberId) { + this.memberId = memberId; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/entity/GroupBuyActivityEntity.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/entity/GroupBuyActivityEntity.java new file mode 100644 index 0000000..643ae14 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/entity/GroupBuyActivityEntity.java @@ -0,0 +1,166 @@ +package cn.novalon.gym.manage.coupon.groupbuy.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; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Table("group_buy_activity") +public class GroupBuyActivityEntity extends BaseEntity { + + @Column("name") + private String name; + + @Column("description") + private String description; + + @Column("product_type") + private String productType; + + @Column("product_id") + private Long productId; + + @Column("product_name") + private String productName; + + @Column("original_price") + private BigDecimal originalPrice; + + @Column("group_price") + private BigDecimal groupPrice; + + @Column("required_members") + private Integer requiredMembers; + + @Column("valid_hours") + private Integer validHours; + + @Column("start_time") + private LocalDateTime startTime; + + @Column("end_time") + private LocalDateTime endTime; + + @Column("stock") + private Integer stock; + + @Column("sold_count") + private Integer soldCount; + + @Column("status") + private String status; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getProductType() { + return productType; + } + + public void setProductType(String productType) { + this.productType = productType; + } + + public Long getProductId() { + return productId; + } + + public void setProductId(Long productId) { + this.productId = productId; + } + + public String getProductName() { + return productName; + } + + public void setProductName(String productName) { + this.productName = productName; + } + + public BigDecimal getOriginalPrice() { + return originalPrice; + } + + public void setOriginalPrice(BigDecimal originalPrice) { + this.originalPrice = originalPrice; + } + + public BigDecimal getGroupPrice() { + return groupPrice; + } + + public void setGroupPrice(BigDecimal groupPrice) { + this.groupPrice = groupPrice; + } + + public Integer getRequiredMembers() { + return requiredMembers; + } + + public void setRequiredMembers(Integer requiredMembers) { + this.requiredMembers = requiredMembers; + } + + public Integer getValidHours() { + return validHours; + } + + public void setValidHours(Integer validHours) { + this.validHours = validHours; + } + + public LocalDateTime getStartTime() { + return startTime; + } + + public void setStartTime(LocalDateTime startTime) { + this.startTime = startTime; + } + + public LocalDateTime getEndTime() { + return endTime; + } + + public void setEndTime(LocalDateTime endTime) { + this.endTime = endTime; + } + + public Integer getStock() { + return stock; + } + + public void setStock(Integer stock) { + this.stock = stock; + } + + public Integer getSoldCount() { + return soldCount; + } + + public void setSoldCount(Integer soldCount) { + this.soldCount = soldCount; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/entity/GroupBuyParticipantEntity.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/entity/GroupBuyParticipantEntity.java new file mode 100644 index 0000000..b3e178a --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/entity/GroupBuyParticipantEntity.java @@ -0,0 +1,77 @@ +package cn.novalon.gym.manage.coupon.groupbuy.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; + +import java.time.LocalDateTime; + +@Table("group_buy_participant") +public class GroupBuyParticipantEntity extends BaseEntity { + + @Column("team_id") + private Long teamId; + + @Column("activity_id") + private Long activityId; + + @Column("member_id") + private Long memberId; + + @Column("is_leader") + private Boolean isLeader; + + @Column("status") + private String status; + + @Column("join_at") + private LocalDateTime joinAt; + + public Long getTeamId() { + return teamId; + } + + public void setTeamId(Long teamId) { + this.teamId = teamId; + } + + public Long getActivityId() { + return activityId; + } + + public void setActivityId(Long activityId) { + this.activityId = activityId; + } + + public Long getMemberId() { + return memberId; + } + + public void setMemberId(Long memberId) { + this.memberId = memberId; + } + + public Boolean getIsLeader() { + return isLeader; + } + + public void setIsLeader(Boolean isLeader) { + this.isLeader = isLeader; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public LocalDateTime getJoinAt() { + return joinAt; + } + + public void setJoinAt(LocalDateTime joinAt) { + this.joinAt = joinAt; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/entity/GroupBuyTeamEntity.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/entity/GroupBuyTeamEntity.java new file mode 100644 index 0000000..f1ce386 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/entity/GroupBuyTeamEntity.java @@ -0,0 +1,88 @@ +package cn.novalon.gym.manage.coupon.groupbuy.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; + +import java.time.LocalDateTime; + +@Table("group_buy_team") +public class GroupBuyTeamEntity extends BaseEntity { + + @Column("activity_id") + private Long activityId; + + @Column("leader_member_id") + private Long leaderMemberId; + + @Column("required_members") + private Integer requiredMembers; + + @Column("current_members") + private Integer currentMembers; + + @Column("status") + private String status; + + @Column("expire_at") + private LocalDateTime expireAt; + + @Column("success_at") + private LocalDateTime successAt; + + public Long getActivityId() { + return activityId; + } + + public void setActivityId(Long activityId) { + this.activityId = activityId; + } + + public Long getLeaderMemberId() { + return leaderMemberId; + } + + public void setLeaderMemberId(Long leaderMemberId) { + this.leaderMemberId = leaderMemberId; + } + + public Integer getRequiredMembers() { + return requiredMembers; + } + + public void setRequiredMembers(Integer requiredMembers) { + this.requiredMembers = requiredMembers; + } + + public Integer getCurrentMembers() { + return currentMembers; + } + + public void setCurrentMembers(Integer currentMembers) { + this.currentMembers = currentMembers; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public LocalDateTime getExpireAt() { + return expireAt; + } + + public void setExpireAt(LocalDateTime expireAt) { + this.expireAt = expireAt; + } + + public LocalDateTime getSuccessAt() { + return successAt; + } + + public void setSuccessAt(LocalDateTime successAt) { + this.successAt = successAt; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/enums/GroupBuyActivityStatus.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/enums/GroupBuyActivityStatus.java new file mode 100644 index 0000000..11d1e41 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/enums/GroupBuyActivityStatus.java @@ -0,0 +1,11 @@ +package cn.novalon.gym.manage.coupon.groupbuy.enums; + +/** + * 拼团活动状态 + */ +public enum GroupBuyActivityStatus { + DRAFT, + ACTIVE, + TERMINATED, + EXPIRED +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/enums/GroupBuyParticipantStatus.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/enums/GroupBuyParticipantStatus.java new file mode 100644 index 0000000..3328ef9 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/enums/GroupBuyParticipantStatus.java @@ -0,0 +1,9 @@ +package cn.novalon.gym.manage.coupon.groupbuy.enums; + +/** + * 拼团参与人状态 + */ +public enum GroupBuyParticipantStatus { + JOINED, + CANCELLED +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/enums/GroupBuyTeamStatus.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/enums/GroupBuyTeamStatus.java new file mode 100644 index 0000000..32e2a25 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/enums/GroupBuyTeamStatus.java @@ -0,0 +1,11 @@ +package cn.novalon.gym.manage.coupon.groupbuy.enums; + +/** + * 拼团团队状态 + */ +public enum GroupBuyTeamStatus { + FORMING, + SUCCESS, + FAILED, + CANCELLED +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/enums/ProductType.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/enums/ProductType.java new file mode 100644 index 0000000..e624775 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/enums/ProductType.java @@ -0,0 +1,10 @@ +package cn.novalon.gym.manage.coupon.groupbuy.enums; + +/** + * 商品类型 + */ +public enum ProductType { + COURSE, + MEMBER_CARD, + PRODUCT +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/handler/GroupBuyHandler.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/handler/GroupBuyHandler.java new file mode 100644 index 0000000..d6b59e5 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/handler/GroupBuyHandler.java @@ -0,0 +1,220 @@ +package cn.novalon.gym.manage.coupon.groupbuy.handler; + +import cn.novalon.gym.manage.common.dto.PageRequest; +import cn.novalon.gym.manage.coupon.groupbuy.domain.CreateTeamRequest; +import cn.novalon.gym.manage.coupon.groupbuy.domain.GroupBuyActivity; +import cn.novalon.gym.manage.coupon.groupbuy.domain.GroupBuyTeam; +import cn.novalon.gym.manage.coupon.groupbuy.domain.JoinTeamRequest; +import cn.novalon.gym.manage.coupon.groupbuy.service.IGroupBuyActivityService; +import cn.novalon.gym.manage.coupon.groupbuy.service.IGroupBuyTeamService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +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 GroupBuyHandler { + + private final IGroupBuyActivityService activityService; + private final IGroupBuyTeamService teamService; + + public GroupBuyHandler(IGroupBuyActivityService activityService, IGroupBuyTeamService teamService) { + this.activityService = activityService; + this.teamService = teamService; + } + + @Operation(summary = "获取所有拼团活动") + public Mono getAllActivities(ServerRequest request) { + boolean includeDeleted = Boolean.parseBoolean(request.queryParam("includeDeleted").orElse("false")); + return ServerResponse.ok() + .body(activityService.findAll(includeDeleted), GroupBuyActivity.class); + } + + @Operation(summary = "分页获取拼团活动") + public Mono getActivitiesByPage(ServerRequest request) { + return request.bodyToMono(PageRequest.class) + .flatMap(pageRequest -> { + String status = request.queryParam("status").orElse(null); + normalizePageRequest(pageRequest); + return activityService.findByPage(pageRequest, status) + .flatMap(response -> ServerResponse.ok().bodyValue(response)); + }); + } + + @Operation(summary = "搜索拼团活动") + public Mono searchActivities(ServerRequest request) { + String keyword = request.queryParam("keyword").orElse(""); + String status = request.queryParam("status").orElse(null); + return ServerResponse.ok() + .body(activityService.findByKeywordAndStatus(keyword, status), GroupBuyActivity.class); + } + + @Operation(summary = "根据ID获取拼团活动") + public Mono getActivityById(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return activityService.findById(id) + .flatMap(activity -> ServerResponse.ok().bodyValue(activity)) + .switchIfEmpty(ServerResponse.notFound().build()); + } + + @Operation(summary = "创建拼团活动") + public Mono createActivity(ServerRequest request) { + return request.bodyToMono(GroupBuyActivity.class) + .flatMap(activity -> { + if (activity.getName() == null || activity.getName().isEmpty()) { + return badRequest("活动名称不能为空"); + } + return activityService.create(activity) + .flatMap(created -> successResponse("拼团活动创建成功", created)) + .onErrorResume(this::errorResponse); + }); + } + + @Operation(summary = "更新拼团活动") + public Mono updateActivity(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return request.bodyToMono(GroupBuyActivity.class) + .flatMap(activity -> activityService.update(id, activity) + .flatMap(updated -> successResponse("拼团活动更新成功", updated)) + .onErrorResume(this::errorResponse)); + } + + @Operation(summary = "删除拼团活动") + public Mono deleteActivity(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return activityService.delete(id) + .then(Mono.defer(() -> successResponse("拼团活动删除成功", null))) + .onErrorResume(this::errorResponse); + } + + @Operation(summary = "发布拼团活动") + public Mono publishActivity(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return activityService.publish(id) + .flatMap(activity -> successResponse("拼团活动发布成功", activity)) + .onErrorResume(this::errorResponse); + } + + @Operation(summary = "终止拼团活动") + public Mono terminateActivity(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return activityService.terminate(id) + .flatMap(activity -> successResponse("拼团活动已终止", activity)) + .onErrorResume(this::errorResponse); + } + + @Operation(summary = "获取拼团统计") + public Mono getStatistics(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return activityService.getStatistics(id) + .flatMap(stats -> ServerResponse.ok().bodyValue(stats)) + .onErrorResume(this::errorResponse); + } + + @Operation(summary = "创建拼团团队") + public Mono createTeam(ServerRequest request) { + return request.bodyToMono(CreateTeamRequest.class) + .flatMap(body -> { + if (body.getActivityId() == null || body.getMemberId() == null) { + return badRequest("activityId和memberId不能为空"); + } + return teamService.createTeam(body) + .flatMap(team -> successResponse("拼团团队创建成功", team)) + .onErrorResume(this::errorResponse); + }); + } + + @Operation(summary = "加入拼团团队") + public Mono joinTeam(ServerRequest request) { + Long teamId = Long.valueOf(request.pathVariable("teamId")); + return request.bodyToMono(JoinTeamRequest.class) + .flatMap(body -> { + if (body.getMemberId() == null) { + return badRequest("memberId不能为空"); + } + return teamService.joinTeam(teamId, body) + .flatMap(team -> successResponse("加入拼团成功", team)) + .onErrorResume(this::errorResponse); + }); + } + + @Operation(summary = "取消拼团团队") + public Mono cancelTeam(ServerRequest request) { + Long teamId = Long.valueOf(request.pathVariable("teamId")); + return teamService.cancelTeam(teamId) + .flatMap(team -> successResponse("拼团团队已取消", team)) + .onErrorResume(this::errorResponse); + } + + @Operation(summary = "查询拼团团队列表") + public Mono getTeams(ServerRequest request) { + String activityIdStr = request.queryParam("activityId").orElse(null); + if (activityIdStr == null) { + return badRequest("activityId不能为空"); + } + Long activityId = Long.valueOf(activityIdStr); + String status = request.queryParam("status").orElse(null); + return ServerResponse.ok() + .body(teamService.findTeams(activityId, status), GroupBuyTeam.class); + } + + @Operation(summary = "获取拼团团队详情") + public Mono getTeamById(ServerRequest request) { + Long teamId = Long.valueOf(request.pathVariable("teamId")); + return teamService.findTeamById(teamId) + .flatMap(team -> teamService.findParticipantsByTeamId(teamId) + .collectList() + .flatMap(participants -> { + Map result = new HashMap<>(); + result.put("team", team); + result.put("participants", participants); + return ServerResponse.ok().bodyValue(result); + })) + .switchIfEmpty(ServerResponse.notFound().build()); + } + + private void normalizePageRequest(PageRequest pageRequest) { + if (pageRequest.getPage() < 0) { + pageRequest.setPage(0); + } + if (pageRequest.getSize() <= 0 || pageRequest.getSize() > 100) { + pageRequest.setSize(10); + } + if (pageRequest.getSort() == null || pageRequest.getSort().isEmpty()) { + pageRequest.setSort("id"); + } + if (pageRequest.getOrder() == null || pageRequest.getOrder().isEmpty()) { + pageRequest.setOrder("desc"); + } + } + + private Mono successResponse(String message, Object data) { + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", message); + if (data != null) { + response.put("data", data); + } + return ServerResponse.ok().bodyValue(response); + } + + private Mono badRequest(String message) { + Map error = new HashMap<>(); + error.put("success", false); + error.put("message", message); + return ServerResponse.badRequest().bodyValue(error); + } + + private Mono errorResponse(Throwable 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-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/repository/IGroupBuyActivityRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/repository/IGroupBuyActivityRepository.java new file mode 100644 index 0000000..a69e411 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/repository/IGroupBuyActivityRepository.java @@ -0,0 +1,28 @@ +package cn.novalon.gym.manage.coupon.groupbuy.repository; + +import cn.novalon.gym.manage.coupon.groupbuy.domain.GroupBuyActivity; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface IGroupBuyActivityRepository { + + Mono findById(Long id); + + Flux findAll(boolean includeDeleted); + + Flux findByKeyword(String keyword); + + Flux findByStatus(String status); + + Flux findByKeywordAndStatus(String keyword, String status); + + Mono save(GroupBuyActivity activity); + + Mono update(GroupBuyActivity activity); + + Mono deleteById(Long id); + + Mono updateStatus(Long id, String status); + + Mono incrementSoldCount(Long id, int count); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/repository/IGroupBuyParticipantRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/repository/IGroupBuyParticipantRepository.java new file mode 100644 index 0000000..44dbf4c --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/repository/IGroupBuyParticipantRepository.java @@ -0,0 +1,20 @@ +package cn.novalon.gym.manage.coupon.groupbuy.repository; + +import cn.novalon.gym.manage.coupon.groupbuy.domain.GroupBuyParticipant; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface IGroupBuyParticipantRepository { + + Flux findByTeamId(Long teamId); + + Flux findByActivityId(Long activityId); + + Mono existsInFormingTeam(Long activityId, Long memberId); + + Mono countByActivityId(Long activityId); + + Mono save(GroupBuyParticipant participant); + + Mono updateStatusByTeamId(Long teamId, String status); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/repository/IGroupBuyTeamRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/repository/IGroupBuyTeamRepository.java new file mode 100644 index 0000000..3438570 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/repository/IGroupBuyTeamRepository.java @@ -0,0 +1,24 @@ +package cn.novalon.gym.manage.coupon.groupbuy.repository; + +import cn.novalon.gym.manage.coupon.groupbuy.domain.GroupBuyTeam; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface IGroupBuyTeamRepository { + + Mono findById(Long id); + + Flux findByActivityId(Long activityId); + + Flux findByActivityIdAndStatus(Long activityId, String status); + + Flux findExpiredFormingTeams(); + + Mono save(GroupBuyTeam team); + + Mono updateStatus(Long id, String status); + + Mono incrementCurrentMembers(Long id, int count); + + Mono markSuccess(Long id, String status); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/repository/impl/GroupBuyActivityRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/repository/impl/GroupBuyActivityRepository.java new file mode 100644 index 0000000..c731a42 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/repository/impl/GroupBuyActivityRepository.java @@ -0,0 +1,133 @@ +package cn.novalon.gym.manage.coupon.groupbuy.repository.impl; + +import cn.novalon.gym.manage.coupon.groupbuy.converter.GroupBuyConverter; +import cn.novalon.gym.manage.coupon.groupbuy.dao.GroupBuyActivityDao; +import cn.novalon.gym.manage.coupon.groupbuy.domain.GroupBuyActivity; +import cn.novalon.gym.manage.coupon.groupbuy.entity.GroupBuyActivityEntity; +import cn.novalon.gym.manage.coupon.groupbuy.repository.IGroupBuyActivityRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +@Repository +@Transactional +public class GroupBuyActivityRepository implements IGroupBuyActivityRepository { + + private final GroupBuyActivityDao activityDao; + private final GroupBuyConverter converter; + + public GroupBuyActivityRepository(GroupBuyActivityDao activityDao, GroupBuyConverter converter) { + this.activityDao = activityDao; + this.converter = converter; + } + + @Override + public Mono findById(Long id) { + return activityDao.findByIdIsAndDeletedAtIsNull(id).map(converter::toActivity); + } + + @Override + public Flux findAll(boolean includeDeleted) { + if (includeDeleted) { + return activityDao.findAll().map(converter::toActivity); + } + return activityDao.findAllByDeletedAtIsNull().map(converter::toActivity); + } + + @Override + public Flux findByKeyword(String keyword) { + if (keyword == null || keyword.isEmpty()) { + return findAll(false); + } + return activityDao.findByNameContainingAndDeletedAtIsNull(keyword).map(converter::toActivity); + } + + @Override + public Flux findByStatus(String status) { + if (status == null || status.isEmpty()) { + return findAll(false); + } + return activityDao.findByStatusAndDeletedAtIsNull(status).map(converter::toActivity); + } + + @Override + public Flux findByKeywordAndStatus(String keyword, String status) { + Flux result = findByKeyword(keyword); + if (status != null && !status.isEmpty()) { + result = result.filter(a -> status.equals(a.getStatus())); + } + return result; + } + + @Override + public Mono save(GroupBuyActivity activity) { + GroupBuyActivityEntity entity = converter.toActivityEntity(activity); + return activityDao.save(entity).map(converter::toActivity); + } + + @Override + public Mono update(GroupBuyActivity activity) { + return activityDao.findByIdIsAndDeletedAtIsNull(activity.getId()) + .switchIfEmpty(Mono.error(new RuntimeException("拼团活动不存在"))) + .flatMap(existing -> { + existing.markNotNew(); + if (activity.getName() != null) { + existing.setName(activity.getName()); + } + if (activity.getDescription() != null) { + existing.setDescription(activity.getDescription()); + } + if (activity.getProductType() != null) { + existing.setProductType(activity.getProductType()); + } + if (activity.getProductId() != null) { + existing.setProductId(activity.getProductId()); + } + if (activity.getProductName() != null) { + existing.setProductName(activity.getProductName()); + } + if (activity.getOriginalPrice() != null) { + existing.setOriginalPrice(activity.getOriginalPrice()); + } + if (activity.getGroupPrice() != null) { + existing.setGroupPrice(activity.getGroupPrice()); + } + if (activity.getRequiredMembers() != null) { + existing.setRequiredMembers(activity.getRequiredMembers()); + } + if (activity.getValidHours() != null) { + existing.setValidHours(activity.getValidHours()); + } + if (activity.getStartTime() != null) { + existing.setStartTime(activity.getStartTime()); + } + if (activity.getEndTime() != null) { + existing.setEndTime(activity.getEndTime()); + } + if (activity.getStock() != null) { + existing.setStock(activity.getStock()); + } + existing.setUpdatedAt(LocalDateTime.now()); + return activityDao.save(existing); + }) + .map(converter::toActivity); + } + + @Override + public Mono deleteById(Long id) { + return activityDao.softDelete(id, LocalDateTime.now()).then(); + } + + @Override + public Mono updateStatus(Long id, String status) { + return activityDao.updateStatus(id, status, LocalDateTime.now()).then(); + } + + @Override + public Mono incrementSoldCount(Long id, int count) { + return activityDao.incrementSoldCount(id, count, LocalDateTime.now()).then(); + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/repository/impl/GroupBuyParticipantRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/repository/impl/GroupBuyParticipantRepository.java new file mode 100644 index 0000000..1403367 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/repository/impl/GroupBuyParticipantRepository.java @@ -0,0 +1,61 @@ +package cn.novalon.gym.manage.coupon.groupbuy.repository.impl; + +import cn.novalon.gym.manage.coupon.groupbuy.converter.GroupBuyConverter; +import cn.novalon.gym.manage.coupon.groupbuy.dao.GroupBuyParticipantDao; +import cn.novalon.gym.manage.coupon.groupbuy.domain.GroupBuyParticipant; +import cn.novalon.gym.manage.coupon.groupbuy.enums.GroupBuyParticipantStatus; +import cn.novalon.gym.manage.coupon.groupbuy.enums.GroupBuyTeamStatus; +import cn.novalon.gym.manage.coupon.groupbuy.repository.IGroupBuyParticipantRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +@Repository +@Transactional +public class GroupBuyParticipantRepository implements IGroupBuyParticipantRepository { + + private final GroupBuyParticipantDao participantDao; + private final GroupBuyConverter converter; + + public GroupBuyParticipantRepository(GroupBuyParticipantDao participantDao, GroupBuyConverter converter) { + this.participantDao = participantDao; + this.converter = converter; + } + + @Override + public Flux findByTeamId(Long teamId) { + return participantDao.findByTeamIdAndDeletedAtIsNull(teamId).map(converter::toParticipant); + } + + @Override + public Flux findByActivityId(Long activityId) { + return participantDao.findByActivityIdAndDeletedAtIsNull(activityId).map(converter::toParticipant); + } + + @Override + public Mono existsInFormingTeam(Long activityId, Long memberId) { + return participantDao.findActiveParticipantInFormingTeam( + activityId, memberId, + GroupBuyParticipantStatus.JOINED.name(), + GroupBuyTeamStatus.FORMING.name()) + .hasElements(); + } + + @Override + public Mono countByActivityId(Long activityId) { + return participantDao.countByActivityIdAndDeletedAtIsNull(activityId); + } + + @Override + public Mono save(GroupBuyParticipant participant) { + return participantDao.save(converter.toParticipantEntity(participant)).map(converter::toParticipant); + } + + @Override + public Mono updateStatusByTeamId(Long teamId, String status) { + return participantDao.updateStatusByTeamId(teamId, status, LocalDateTime.now()).then(); + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/repository/impl/GroupBuyTeamRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/repository/impl/GroupBuyTeamRepository.java new file mode 100644 index 0000000..8f88500 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/repository/impl/GroupBuyTeamRepository.java @@ -0,0 +1,70 @@ +package cn.novalon.gym.manage.coupon.groupbuy.repository.impl; + +import cn.novalon.gym.manage.coupon.groupbuy.converter.GroupBuyConverter; +import cn.novalon.gym.manage.coupon.groupbuy.dao.GroupBuyTeamDao; +import cn.novalon.gym.manage.coupon.groupbuy.domain.GroupBuyTeam; +import cn.novalon.gym.manage.coupon.groupbuy.enums.GroupBuyTeamStatus; +import cn.novalon.gym.manage.coupon.groupbuy.repository.IGroupBuyTeamRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +@Repository +@Transactional +public class GroupBuyTeamRepository implements IGroupBuyTeamRepository { + + private final GroupBuyTeamDao teamDao; + private final GroupBuyConverter converter; + + public GroupBuyTeamRepository(GroupBuyTeamDao teamDao, GroupBuyConverter converter) { + this.teamDao = teamDao; + this.converter = converter; + } + + @Override + public Mono findById(Long id) { + return teamDao.findByIdIsAndDeletedAtIsNull(id).map(converter::toTeam); + } + + @Override + public Flux findByActivityId(Long activityId) { + return teamDao.findByActivityIdAndDeletedAtIsNull(activityId).map(converter::toTeam); + } + + @Override + public Flux findByActivityIdAndStatus(Long activityId, String status) { + if (status == null || status.isEmpty()) { + return findByActivityId(activityId); + } + return teamDao.findByActivityIdAndStatusAndDeletedAtIsNull(activityId, status).map(converter::toTeam); + } + + @Override + public Flux findExpiredFormingTeams() { + return teamDao.findExpiredFormingTeams( + GroupBuyTeamStatus.FORMING.name(), LocalDateTime.now()).map(converter::toTeam); + } + + @Override + public Mono save(GroupBuyTeam team) { + return teamDao.save(converter.toTeamEntity(team)).map(converter::toTeam); + } + + @Override + public Mono updateStatus(Long id, String status) { + return teamDao.updateStatus(id, status, LocalDateTime.now()).then(); + } + + @Override + public Mono incrementCurrentMembers(Long id, int count) { + return teamDao.incrementCurrentMembers(id, count, LocalDateTime.now()).then(); + } + + @Override + public Mono markSuccess(Long id, String status) { + return teamDao.markSuccess(id, status, LocalDateTime.now(), LocalDateTime.now()).then(); + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/scheduler/GroupBuyTeamExpireScheduler.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/scheduler/GroupBuyTeamExpireScheduler.java new file mode 100644 index 0000000..caed7c5 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/scheduler/GroupBuyTeamExpireScheduler.java @@ -0,0 +1,35 @@ +package cn.novalon.gym.manage.coupon.groupbuy.scheduler; + +import cn.novalon.gym.manage.coupon.groupbuy.service.IGroupBuyTeamService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 拼团过期团队定时任务 + * + * 功能:定期检查已过期的拼团中团队,标记为失败 + */ +@Component +public class GroupBuyTeamExpireScheduler { + + private static final Logger logger = LoggerFactory.getLogger(GroupBuyTeamExpireScheduler.class); + + private final IGroupBuyTeamService teamService; + + public GroupBuyTeamExpireScheduler(IGroupBuyTeamService teamService) { + this.teamService = teamService; + } + + @Scheduled(fixedRate = 60000) + public void processExpiredTeams() { + logger.debug("定时任务开始检查过期拼团团队"); + + teamService.processExpiredTeams() + .subscribe( + count -> logger.debug("定时任务完成,处理了 {} 个过期拼团团队", count), + error -> logger.error("拼团过期定时任务执行失败:{}", error.getMessage(), error) + ); + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/service/IGroupBuyActivityService.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/service/IGroupBuyActivityService.java new file mode 100644 index 0000000..ccf3b90 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/service/IGroupBuyActivityService.java @@ -0,0 +1,31 @@ +package cn.novalon.gym.manage.coupon.groupbuy.service; + +import cn.novalon.gym.manage.common.dto.PageRequest; +import cn.novalon.gym.manage.common.dto.PageResponse; +import cn.novalon.gym.manage.coupon.groupbuy.domain.GroupBuyActivity; +import cn.novalon.gym.manage.coupon.groupbuy.domain.GroupBuyStatistics; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface IGroupBuyActivityService { + + Mono findById(Long id); + + Flux findAll(boolean includeDeleted); + + Flux findByKeywordAndStatus(String keyword, String status); + + Mono> findByPage(PageRequest pageRequest, String status); + + Mono create(GroupBuyActivity activity); + + Mono update(Long id, GroupBuyActivity activity); + + Mono delete(Long id); + + Mono publish(Long id); + + Mono terminate(Long id); + + Mono getStatistics(Long id); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/service/IGroupBuyTeamService.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/service/IGroupBuyTeamService.java new file mode 100644 index 0000000..fcd74e4 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/service/IGroupBuyTeamService.java @@ -0,0 +1,25 @@ +package cn.novalon.gym.manage.coupon.groupbuy.service; + +import cn.novalon.gym.manage.coupon.groupbuy.domain.CreateTeamRequest; +import cn.novalon.gym.manage.coupon.groupbuy.domain.GroupBuyParticipant; +import cn.novalon.gym.manage.coupon.groupbuy.domain.GroupBuyTeam; +import cn.novalon.gym.manage.coupon.groupbuy.domain.JoinTeamRequest; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface IGroupBuyTeamService { + + Mono createTeam(CreateTeamRequest request); + + Mono joinTeam(Long teamId, JoinTeamRequest request); + + Mono cancelTeam(Long teamId); + + Flux findTeams(Long activityId, String status); + + Mono findTeamById(Long teamId); + + Flux findParticipantsByTeamId(Long teamId); + + Mono processExpiredTeams(); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/service/impl/GroupBuyActivityService.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/service/impl/GroupBuyActivityService.java new file mode 100644 index 0000000..f0b0ff1 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/service/impl/GroupBuyActivityService.java @@ -0,0 +1,226 @@ +package cn.novalon.gym.manage.coupon.groupbuy.service.impl; + +import cn.novalon.gym.manage.common.dto.PageRequest; +import cn.novalon.gym.manage.common.dto.PageResponse; +import cn.novalon.gym.manage.coupon.groupbuy.domain.GroupBuyActivity; +import cn.novalon.gym.manage.coupon.groupbuy.domain.GroupBuyStatistics; +import cn.novalon.gym.manage.coupon.groupbuy.domain.GroupBuyTeam; +import cn.novalon.gym.manage.coupon.groupbuy.enums.GroupBuyActivityStatus; +import cn.novalon.gym.manage.coupon.groupbuy.enums.GroupBuyTeamStatus; +import cn.novalon.gym.manage.coupon.groupbuy.repository.IGroupBuyActivityRepository; +import cn.novalon.gym.manage.coupon.groupbuy.repository.IGroupBuyParticipantRepository; +import cn.novalon.gym.manage.coupon.groupbuy.repository.IGroupBuyTeamRepository; +import cn.novalon.gym.manage.coupon.groupbuy.service.IGroupBuyActivityService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.math.BigDecimal; +import java.util.Comparator; +import java.util.List; + +@Service +public class GroupBuyActivityService implements IGroupBuyActivityService { + + private static final Logger logger = LoggerFactory.getLogger(GroupBuyActivityService.class); + + private final IGroupBuyActivityRepository activityRepository; + private final IGroupBuyTeamRepository teamRepository; + private final IGroupBuyParticipantRepository participantRepository; + + public GroupBuyActivityService(IGroupBuyActivityRepository activityRepository, + IGroupBuyTeamRepository teamRepository, + IGroupBuyParticipantRepository participantRepository) { + this.activityRepository = activityRepository; + this.teamRepository = teamRepository; + this.participantRepository = participantRepository; + } + + @Override + public Mono findById(Long id) { + return activityRepository.findById(id); + } + + @Override + public Flux findAll(boolean includeDeleted) { + return activityRepository.findAll(includeDeleted); + } + + @Override + public Flux findByKeywordAndStatus(String keyword, String status) { + return activityRepository.findByKeywordAndStatus(keyword, status); + } + + @Override + public Mono> findByPage(PageRequest pageRequest, String status) { + int page = Math.max(pageRequest.getPage(), 0); + int size = pageRequest.getSize() <= 0 || pageRequest.getSize() > 100 ? 10 : pageRequest.getSize(); + String keyword = pageRequest.getKeyword(); + + return activityRepository.findByKeywordAndStatus(keyword, status) + .sort(Comparator.comparing(GroupBuyActivity::getCreatedAt, + Comparator.nullsLast(Comparator.reverseOrder()))) + .collectList() + .map(list -> { + long total = list.size(); + int fromIndex = Math.min(page * size, list.size()); + int toIndex = Math.min(fromIndex + size, list.size()); + List content = list.subList(fromIndex, toIndex); + int totalPages = size == 0 ? 0 : (int) Math.ceil((double) total / size); + return new PageResponse<>(content, totalPages, total, page, size); + }); + } + + @Override + public Mono create(GroupBuyActivity activity) { + return validateActivity(activity) + .flatMap(validated -> { + validated.generateId(); + validated.setStatus(GroupBuyActivityStatus.DRAFT.name()); + validated.setSoldCount(0); + applyDefaults(validated); + return activityRepository.save(validated); + }) + .doOnSuccess(a -> logger.info("拼团活动创建成功 - id={}, name={}", a.getId(), a.getName())) + .doOnError(error -> logger.error("拼团活动创建失败 - error: {}", error.getMessage())); + } + + @Override + public Mono update(Long id, GroupBuyActivity activity) { + return activityRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("拼团活动不存在"))) + .flatMap(existing -> { + if (!GroupBuyActivityStatus.DRAFT.name().equals(existing.getStatus())) { + return Mono.error(new RuntimeException("仅草稿状态的拼团活动可编辑")); + } + activity.setId(id); + return validateActivity(activity) + .flatMap(activityRepository::update); + }) + .doOnSuccess(a -> logger.info("拼团活动更新成功 - id={}", id)) + .doOnError(error -> logger.error("拼团活动更新失败 - id={}, error: {}", id, error.getMessage())); + } + + @Override + public Mono delete(Long id) { + return activityRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("拼团活动不存在"))) + .flatMap(existing -> { + if (!GroupBuyActivityStatus.DRAFT.name().equals(existing.getStatus())) { + return Mono.error(new RuntimeException("仅草稿状态的拼团活动可删除")); + } + return activityRepository.deleteById(id); + }) + .doOnSuccess(v -> logger.info("拼团活动删除成功 - id={}", id)) + .doOnError(error -> logger.error("拼团活动删除失败 - id={}, error: {}", id, error.getMessage())); + } + + @Override + public Mono publish(Long id) { + return activityRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("拼团活动不存在"))) + .flatMap(existing -> { + if (!GroupBuyActivityStatus.DRAFT.name().equals(existing.getStatus())) { + return Mono.error(new RuntimeException("仅草稿状态的拼团活动可发布")); + } + return validateActivity(existing) + .flatMap(validated -> activityRepository.updateStatus(id, GroupBuyActivityStatus.ACTIVE.name()) + .then(activityRepository.findById(id))); + }) + .doOnSuccess(a -> logger.info("拼团活动发布成功 - id={}", id)) + .doOnError(error -> logger.error("拼团活动发布失败 - id={}, error: {}", id, error.getMessage())); + } + + @Override + public Mono terminate(Long id) { + return activityRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("拼团活动不存在"))) + .flatMap(existing -> { + if (!GroupBuyActivityStatus.ACTIVE.name().equals(existing.getStatus())) { + return Mono.error(new RuntimeException("仅进行中的拼团活动可终止")); + } + return activityRepository.updateStatus(id, GroupBuyActivityStatus.TERMINATED.name()) + .then(activityRepository.findById(id)); + }) + .doOnSuccess(a -> logger.info("拼团活动已终止 - id={}", id)) + .doOnError(error -> logger.error("拼团活动终止失败 - id={}, error: {}", id, error.getMessage())); + } + + @Override + public Mono getStatistics(Long id) { + return activityRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("拼团活动不存在"))) + .flatMap(activity -> teamRepository.findByActivityId(id) + .collectList() + .zipWith(participantRepository.countByActivityId(id)) + .map(tuple -> { + List teams = tuple.getT1(); + long participants = tuple.getT2(); + + GroupBuyStatistics stats = new GroupBuyStatistics(); + stats.setActivityId(id); + stats.setTotalTeams(teams.size()); + stats.setFormingTeams(teams.stream() + .filter(t -> GroupBuyTeamStatus.FORMING.name().equals(t.getStatus())).count()); + stats.setSuccessTeams(teams.stream() + .filter(t -> GroupBuyTeamStatus.SUCCESS.name().equals(t.getStatus())).count()); + stats.setFailedTeams(teams.stream() + .filter(t -> GroupBuyTeamStatus.FAILED.name().equals(t.getStatus())).count()); + stats.setCancelledTeams(teams.stream() + .filter(t -> GroupBuyTeamStatus.CANCELLED.name().equals(t.getStatus())).count()); + stats.setTotalParticipants(participants); + stats.setSoldCount(activity.getSoldCount() != null ? activity.getSoldCount() : 0); + + BigDecimal groupPrice = activity.getGroupPrice() != null ? activity.getGroupPrice() : BigDecimal.ZERO; + stats.setTotalRevenue(groupPrice.multiply( + BigDecimal.valueOf(stats.getSuccessTeams() * activity.getRequiredMembers()))); + + return stats; + })); + } + + private Mono validateActivity(GroupBuyActivity activity) { + if (activity.getName() == null || activity.getName().isBlank()) { + return Mono.error(new RuntimeException("活动名称不能为空")); + } + if (activity.getProductType() == null || activity.getProductType().isBlank()) { + return Mono.error(new RuntimeException("商品类型不能为空")); + } + if (activity.getProductId() == null) { + return Mono.error(new RuntimeException("商品ID不能为空")); + } + if (activity.getOriginalPrice() == null || activity.getGroupPrice() == null) { + return Mono.error(new RuntimeException("原价和拼团价不能为空")); + } + if (activity.getGroupPrice().compareTo(activity.getOriginalPrice()) >= 0) { + return Mono.error(new RuntimeException("拼团价必须低于原价")); + } + if (activity.getRequiredMembers() == null || activity.getRequiredMembers() < 2) { + return Mono.error(new RuntimeException("成团人数至少为2人")); + } + if (activity.getValidHours() == null || activity.getValidHours() <= 0) { + return Mono.error(new RuntimeException("拼团有效时长必须大于0")); + } + if (activity.getStartTime() == null || activity.getEndTime() == null) { + return Mono.error(new RuntimeException("活动开始和结束时间不能为空")); + } + if (activity.getEndTime().isBefore(activity.getStartTime())) { + return Mono.error(new RuntimeException("结束时间不能早于开始时间")); + } + return Mono.just(activity); + } + + private void applyDefaults(GroupBuyActivity activity) { + if (activity.getRequiredMembers() == null) { + activity.setRequiredMembers(2); + } + if (activity.getValidHours() == null) { + activity.setValidHours(24); + } + if (activity.getStock() == null) { + activity.setStock(-1); + } + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/service/impl/GroupBuyTeamService.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/service/impl/GroupBuyTeamService.java new file mode 100644 index 0000000..52b393e --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/groupbuy/service/impl/GroupBuyTeamService.java @@ -0,0 +1,200 @@ +package cn.novalon.gym.manage.coupon.groupbuy.service.impl; + +import cn.novalon.gym.manage.common.util.SnowflakeId; +import cn.novalon.gym.manage.coupon.groupbuy.domain.CreateTeamRequest; +import cn.novalon.gym.manage.coupon.groupbuy.domain.GroupBuyActivity; +import cn.novalon.gym.manage.coupon.groupbuy.domain.GroupBuyParticipant; +import cn.novalon.gym.manage.coupon.groupbuy.domain.GroupBuyTeam; +import cn.novalon.gym.manage.coupon.groupbuy.domain.JoinTeamRequest; +import cn.novalon.gym.manage.coupon.groupbuy.enums.GroupBuyActivityStatus; +import cn.novalon.gym.manage.coupon.groupbuy.enums.GroupBuyParticipantStatus; +import cn.novalon.gym.manage.coupon.groupbuy.enums.GroupBuyTeamStatus; +import cn.novalon.gym.manage.coupon.groupbuy.repository.IGroupBuyActivityRepository; +import cn.novalon.gym.manage.coupon.groupbuy.repository.IGroupBuyParticipantRepository; +import cn.novalon.gym.manage.coupon.groupbuy.repository.IGroupBuyTeamRepository; +import cn.novalon.gym.manage.coupon.groupbuy.service.IGroupBuyTeamService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +@Service +public class GroupBuyTeamService implements IGroupBuyTeamService { + + private static final Logger logger = LoggerFactory.getLogger(GroupBuyTeamService.class); + + private final IGroupBuyActivityRepository activityRepository; + private final IGroupBuyTeamRepository teamRepository; + private final IGroupBuyParticipantRepository participantRepository; + + public GroupBuyTeamService(IGroupBuyActivityRepository activityRepository, + IGroupBuyTeamRepository teamRepository, + IGroupBuyParticipantRepository participantRepository) { + this.activityRepository = activityRepository; + this.teamRepository = teamRepository; + this.participantRepository = participantRepository; + } + + @Override + public Mono createTeam(CreateTeamRequest request) { + if (request.getActivityId() == null || request.getMemberId() == null) { + return Mono.error(new RuntimeException("activityId和memberId不能为空")); + } + + return activityRepository.findById(request.getActivityId()) + .switchIfEmpty(Mono.error(new RuntimeException("拼团活动不存在"))) + .flatMap(activity -> validateActivityForTeam(activity) + .then(checkMemberNotInFormingTeam(activity.getId(), request.getMemberId())) + .then(createTeamInternal(activity, request.getMemberId()))); + } + + @Override + public Mono joinTeam(Long teamId, JoinTeamRequest request) { + if (request.getMemberId() == null) { + return Mono.error(new RuntimeException("memberId不能为空")); + } + + return teamRepository.findById(teamId) + .switchIfEmpty(Mono.error(new RuntimeException("拼团团队不存在"))) + .flatMap(team -> { + if (!GroupBuyTeamStatus.FORMING.name().equals(team.getStatus())) { + return Mono.error(new RuntimeException("该团不在拼团中,无法加入")); + } + if (team.getExpireAt() != null && team.getExpireAt().isBefore(LocalDateTime.now())) { + return Mono.error(new RuntimeException("该团已过期")); + } + return activityRepository.findById(team.getActivityId()) + .switchIfEmpty(Mono.error(new RuntimeException("拼团活动不存在"))) + .flatMap(activity -> validateActivityForTeam(activity) + .then(checkMemberNotInFormingTeam(activity.getId(), request.getMemberId())) + .then(addParticipant(team, activity, request.getMemberId(), false)) + .flatMap(updatedTeam -> checkAndCompleteTeam(updatedTeam, activity))); + }); + } + + @Override + public Mono cancelTeam(Long teamId) { + return teamRepository.findById(teamId) + .switchIfEmpty(Mono.error(new RuntimeException("拼团团队不存在"))) + .flatMap(team -> { + if (!GroupBuyTeamStatus.FORMING.name().equals(team.getStatus())) { + return Mono.error(new RuntimeException("仅拼团中的团队可取消")); + } + return teamRepository.updateStatus(teamId, GroupBuyTeamStatus.CANCELLED.name()) + .then(participantRepository.updateStatusByTeamId( + teamId, GroupBuyParticipantStatus.CANCELLED.name())) + .then(teamRepository.findById(teamId)); + }) + .doOnSuccess(t -> logger.info("拼团团队已取消 - teamId={}", teamId)); + } + + @Override + public Flux findTeams(Long activityId, String status) { + if (activityId == null) { + return Flux.error(new RuntimeException("activityId不能为空")); + } + return teamRepository.findByActivityIdAndStatus(activityId, status); + } + + @Override + public Mono findTeamById(Long teamId) { + return teamRepository.findById(teamId); + } + + @Override + public Flux findParticipantsByTeamId(Long teamId) { + return participantRepository.findByTeamId(teamId); + } + + @Override + public Mono processExpiredTeams() { + return teamRepository.findExpiredFormingTeams() + .flatMap(team -> teamRepository.updateStatus(team.getId(), GroupBuyTeamStatus.FAILED.name()) + .then(participantRepository.updateStatusByTeamId( + team.getId(), GroupBuyParticipantStatus.CANCELLED.name())) + .thenReturn(1L)) + .reduce(0L, Long::sum) + .doOnSuccess(count -> { + if (count > 0) { + logger.info("已处理 {} 个过期拼团团队", count); + } + }); + } + + private Mono createTeamInternal(GroupBuyActivity activity, Long leaderMemberId) { + int validHours = activity.getValidHours() != null ? activity.getValidHours() : 24; + + GroupBuyTeam team = new GroupBuyTeam(); + team.setId(SnowflakeId.nextId()); + team.setActivityId(activity.getId()); + team.setLeaderMemberId(leaderMemberId); + team.setRequiredMembers(activity.getRequiredMembers()); + team.setCurrentMembers(1); + team.setStatus(GroupBuyTeamStatus.FORMING.name()); + team.setExpireAt(LocalDateTime.now().plusHours(validHours)); + + return teamRepository.save(team) + .flatMap(saved -> addParticipant(saved, activity, leaderMemberId, true)); + } + + private Mono addParticipant(GroupBuyTeam team, GroupBuyActivity activity, + Long memberId, boolean isLeader) { + GroupBuyParticipant participant = new GroupBuyParticipant(); + participant.setId(SnowflakeId.nextId()); + participant.setTeamId(team.getId()); + participant.setActivityId(activity.getId()); + participant.setMemberId(memberId); + participant.setIsLeader(isLeader); + participant.setStatus(GroupBuyParticipantStatus.JOINED.name()); + participant.setJoinAt(LocalDateTime.now()); + + Mono teamUpdate = isLeader + ? Mono.just(team) + : teamRepository.incrementCurrentMembers(team.getId(), 1) + .then(teamRepository.findById(team.getId())); + + return participantRepository.save(participant) + .then(teamUpdate); + } + + private Mono checkAndCompleteTeam(GroupBuyTeam team, GroupBuyActivity activity) { + return teamRepository.findById(team.getId()) + .flatMap(updated -> { + if (updated.getCurrentMembers() >= updated.getRequiredMembers()) { + return teamRepository.markSuccess(updated.getId(), GroupBuyTeamStatus.SUCCESS.name()) + .then(activityRepository.incrementSoldCount(activity.getId(), updated.getRequiredMembers())) + .then(teamRepository.findById(updated.getId())); + } + return Mono.just(updated); + }); + } + + private Mono validateActivityForTeam(GroupBuyActivity activity) { + if (!GroupBuyActivityStatus.ACTIVE.name().equals(activity.getStatus())) { + return Mono.error(new RuntimeException("拼团活动未开始或已结束")); + } + LocalDateTime now = LocalDateTime.now(); + if (activity.getStartTime() != null && now.isBefore(activity.getStartTime())) { + return Mono.error(new RuntimeException("拼团活动尚未开始")); + } + if (activity.getEndTime() != null && now.isAfter(activity.getEndTime())) { + return Mono.error(new RuntimeException("拼团活动已结束")); + } + int stock = activity.getStock() != null ? activity.getStock() : -1; + int sold = activity.getSoldCount() != null ? activity.getSoldCount() : 0; + if (stock >= 0 && sold >= stock) { + return Mono.error(new RuntimeException("拼团活动库存不足")); + } + return Mono.empty(); + } + + private Mono checkMemberNotInFormingTeam(Long activityId, Long memberId) { + return participantRepository.existsInFormingTeam(activityId, memberId) + .flatMap(exists -> exists + ? Mono.error(new RuntimeException("您已在该活动的拼团中,不能重复参团")) + : Mono.empty()); + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/handler/CouponHandler.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/handler/CouponHandler.java new file mode 100644 index 0000000..a3c2ad0 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/handler/CouponHandler.java @@ -0,0 +1,227 @@ +package cn.novalon.gym.manage.coupon.handler; + +import cn.novalon.gym.manage.common.dto.PageRequest; +import cn.novalon.gym.manage.coupon.domain.CouponTemplate; +import cn.novalon.gym.manage.coupon.domain.DistributeCouponRequest; +import cn.novalon.gym.manage.coupon.service.ICouponTemplateService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +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 CouponHandler { + + private final ICouponTemplateService couponTemplateService; + + public CouponHandler(ICouponTemplateService couponTemplateService) { + this.couponTemplateService = couponTemplateService; + } + + @Operation(summary = "获取所有优惠券", description = "获取系统中所有优惠券列表") + public Mono getAllCoupons(ServerRequest request) { + boolean includeDeleted = Boolean.parseBoolean(request.queryParam("includeDeleted").orElse("false")); + return ServerResponse.ok() + .body(couponTemplateService.findAll(includeDeleted), CouponTemplate.class); + } + + @Operation(summary = "分页获取优惠券", description = "根据分页参数获取优惠券列表") + public Mono getCouponsByPage(ServerRequest request) { + return request.bodyToMono(PageRequest.class) + .flatMap(pageRequest -> { + String couponType = request.queryParam("couponType").orElse(null); + String status = request.queryParam("status").orElse(null); + + if (pageRequest.getPage() < 0) { + pageRequest.setPage(0); + } + if (pageRequest.getSize() <= 0 || pageRequest.getSize() > 100) { + pageRequest.setSize(10); + } + if (pageRequest.getSort() == null || pageRequest.getSort().isEmpty()) { + pageRequest.setSort("id"); + } + if (pageRequest.getOrder() == null || pageRequest.getOrder().isEmpty()) { + pageRequest.setOrder("desc"); + } + + return couponTemplateService.findByPage(pageRequest, couponType, status) + .flatMap(response -> ServerResponse.ok().bodyValue(response)); + }); + } + + @Operation(summary = "搜索优惠券", description = "根据关键词、类型、状态搜索优惠券") + public Mono searchCoupons(ServerRequest request) { + String keyword = request.queryParam("keyword").orElse(""); + String couponType = request.queryParam("couponType").orElse(null); + String status = request.queryParam("status").orElse(null); + return ServerResponse.ok() + .body(couponTemplateService.findByCouponTypeAndKeyword(couponType, keyword, status), CouponTemplate.class); + } + + @Operation(summary = "根据ID获取优惠券", description = "根据ID获取优惠券详情") + public Mono getCouponById(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return couponTemplateService.findById(id) + .flatMap(coupon -> ServerResponse.ok().bodyValue(coupon)) + .switchIfEmpty(ServerResponse.notFound().build()); + } + + @Operation(summary = "创建优惠券", description = "创建新的优惠券") + public Mono createCoupon(ServerRequest request) { + return request.bodyToMono(CouponTemplate.class) + .flatMap(couponTemplate -> { + if (couponTemplate.getName() == null || couponTemplate.getName().isEmpty()) { + Map error = new HashMap<>(); + error.put("success", false); + error.put("message", "优惠券名称不能为空"); + return ServerResponse.badRequest().bodyValue(error); + } + + return couponTemplateService.create(couponTemplate) + .flatMap(coupon -> { + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "优惠券创建成功"); + response.put("data", coupon); + 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 updateCoupon(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + + return request.bodyToMono(CouponTemplate.class) + .flatMap(couponTemplate -> couponTemplateService.update(id, couponTemplate) + .flatMap(coupon -> { + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "优惠券更新成功"); + response.put("data", coupon); + 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 deleteCoupon(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + + return couponTemplateService.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 publishCoupon(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + + return couponTemplateService.publish(id) + .flatMap(coupon -> { + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "优惠券发布成功"); + response.put("data", coupon); + 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 terminateCoupon(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + + return couponTemplateService.terminate(id) + .flatMap(coupon -> { + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "优惠券已终止"); + response.put("data", coupon); + 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 distributeCoupon(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + + return request.bodyToMono(DistributeCouponRequest.class) + .flatMap(body -> { + if (body.getMemberIds() == null || body.getMemberIds().isEmpty()) { + Map error = new HashMap<>(); + error.put("success", false); + error.put("message", "memberIds不能为空"); + return ServerResponse.badRequest().bodyValue(error); + } + + return couponTemplateService.distribute(id, body) + .flatMap(result -> { + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", result.getMessage()); + response.put("data", result); + 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 getCouponStatistics(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + + return couponTemplateService.getStatistics(id) + .flatMap(stats -> ServerResponse.ok().bodyValue(stats)) + .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-coupon/src/main/java/cn/novalon/gym/manage/coupon/handler/MemberCouponHandler.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/handler/MemberCouponHandler.java new file mode 100644 index 0000000..5eb2890 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/handler/MemberCouponHandler.java @@ -0,0 +1,67 @@ +package cn.novalon.gym.manage.coupon.handler; + +import cn.novalon.gym.manage.coupon.domain.ClaimCouponRequest; +import cn.novalon.gym.manage.coupon.domain.MemberCoupon; +import cn.novalon.gym.manage.coupon.service.IMemberCouponService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +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 MemberCouponHandler { + + private final IMemberCouponService memberCouponService; + + public MemberCouponHandler(IMemberCouponService memberCouponService) { + this.memberCouponService = memberCouponService; + } + + @Operation(summary = "查询会员优惠券", description = "查询指定会员的优惠券列表") + public Mono getMemberCoupons(ServerRequest request) { + Long memberId = Long.valueOf(request.pathVariable("memberId")); + String status = request.queryParam("status").orElse(null); + return ServerResponse.ok() + .body(memberCouponService.findByMemberIdAndStatus(memberId, status), MemberCoupon.class); + } + + @Operation(summary = "领取码兑换优惠券", description = "会员通过领取码/二维码兑换优惠券") + public Mono claimCoupon(ServerRequest request) { + return request.bodyToMono(ClaimCouponRequest.class) + .flatMap(body -> { + if (body.getMemberId() == null) { + Map error = new HashMap<>(); + error.put("success", false); + error.put("message", "memberId不能为空"); + return ServerResponse.badRequest().bodyValue(error); + } + if (body.getClaimCode() == null || body.getClaimCode().isBlank()) { + Map error = new HashMap<>(); + error.put("success", false); + error.put("message", "claimCode不能为空"); + return ServerResponse.badRequest().bodyValue(error); + } + + return memberCouponService.claimByCode(body.getMemberId(), body.getClaimCode()) + .flatMap(coupon -> { + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "优惠券领取成功"); + response.put("data", coupon); + 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-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/converter/MarketingConverter.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/converter/MarketingConverter.java new file mode 100644 index 0000000..159e176 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/converter/MarketingConverter.java @@ -0,0 +1,35 @@ +package cn.novalon.gym.manage.coupon.marketing.converter; + +import cn.hutool.core.bean.BeanUtil; +import cn.novalon.gym.manage.coupon.marketing.domain.MarketingActivity; +import cn.novalon.gym.manage.coupon.marketing.entity.MarketingActivityEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +public class MarketingConverter { + + public MarketingActivity toMarketingActivity(MarketingActivityEntity entity) { + if (entity == null) { + return null; + } + MarketingActivity domain = new MarketingActivity(); + BeanUtil.copyProperties(entity, domain); + log.debug("转换营销活动实体到领域模型:activityId={}", entity.getId()); + return domain; + } + + public MarketingActivityEntity toMarketingActivityEntity(MarketingActivity domain) { + if (domain == null) { + return null; + } + MarketingActivityEntity entity = new MarketingActivityEntity(); + BeanUtil.copyProperties(domain, entity); + if (domain.getId() != null) { + entity.markNotNew(); + } + log.debug("转换营销活动领域模型到实体:activityId={}", domain.getId()); + return entity; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/dao/MarketingActivityDao.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/dao/MarketingActivityDao.java new file mode 100644 index 0000000..703bdc0 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/dao/MarketingActivityDao.java @@ -0,0 +1,33 @@ +package cn.novalon.gym.manage.coupon.marketing.dao; + +import cn.novalon.gym.manage.coupon.marketing.entity.MarketingActivityEntity; +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 MarketingActivityDao extends R2dbcRepository { + + Mono findByIdIsAndDeletedAtIsNull(Long id); + + Flux findAllByDeletedAtIsNull(); + + Flux findByNameContainingAndDeletedAtIsNull(String name); + + Flux findByActivityTypeAndDeletedAtIsNull(String activityType); + + Flux findByStatusAndDeletedAtIsNull(String status); + + @Modifying + @Query("UPDATE marketing_activity SET deleted_at = :deletedAt WHERE id = :id") + Mono softDelete(Long id, LocalDateTime deletedAt); + + @Modifying + @Query("UPDATE marketing_activity SET status = :status, updated_at = :updatedAt WHERE id = :id") + Mono updateStatus(Long id, String status, LocalDateTime updatedAt); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/domain/MarketingActivity.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/domain/MarketingActivity.java new file mode 100644 index 0000000..ae19008 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/domain/MarketingActivity.java @@ -0,0 +1,176 @@ +package cn.novalon.gym.manage.coupon.marketing.domain; + +import cn.novalon.gym.manage.sys.core.domain.BaseDomain; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Schema(description = "会员营销活动") +public class MarketingActivity extends BaseDomain { + + @Schema(description = "活动名称") + private String name; + + @Schema(description = "活动描述") + private String description; + + @Schema(description = "活动类型:GIFT/TIME_DISCOUNT/TIERED_DISCOUNT/GROUP_DISCOUNT/LIMITED_OFFER/RECOMMEND_REWARD") + private String activityType; + + @Schema(description = "开始时间") + private LocalDateTime startTime; + + @Schema(description = "结束时间") + private LocalDateTime endTime; + + @Schema(description = "折扣值") + private BigDecimal discountValue; + + @Schema(description = "门槛金额") + private BigDecimal thresholdAmount; + + @Schema(description = "赠品描述") + private String giftDescription; + + @Schema(description = "适用范围:ALL/SPECIFIC") + private String applyScope; + + @Schema(description = "指定商品ID列表(JSON数组字符串)") + private String applyProductIds; + + @Schema(description = "活动规则JSON配置") + private String rulesJson; + + @Schema(description = "参与人数") + private Integer participantCount; + + @Schema(description = "订单数") + private Integer orderCount; + + @Schema(description = "累计优惠金额") + private BigDecimal totalDiscountAmount; + + @Schema(description = "状态:DRAFT/ACTIVE/TERMINATED/EXPIRED") + private String status; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getActivityType() { + return activityType; + } + + public void setActivityType(String activityType) { + this.activityType = activityType; + } + + public LocalDateTime getStartTime() { + return startTime; + } + + public void setStartTime(LocalDateTime startTime) { + this.startTime = startTime; + } + + public LocalDateTime getEndTime() { + return endTime; + } + + public void setEndTime(LocalDateTime endTime) { + this.endTime = endTime; + } + + public BigDecimal getDiscountValue() { + return discountValue; + } + + public void setDiscountValue(BigDecimal discountValue) { + this.discountValue = discountValue; + } + + public BigDecimal getThresholdAmount() { + return thresholdAmount; + } + + public void setThresholdAmount(BigDecimal thresholdAmount) { + this.thresholdAmount = thresholdAmount; + } + + public String getGiftDescription() { + return giftDescription; + } + + public void setGiftDescription(String giftDescription) { + this.giftDescription = giftDescription; + } + + public String getApplyScope() { + return applyScope; + } + + public void setApplyScope(String applyScope) { + this.applyScope = applyScope; + } + + public String getApplyProductIds() { + return applyProductIds; + } + + public void setApplyProductIds(String applyProductIds) { + this.applyProductIds = applyProductIds; + } + + public String getRulesJson() { + return rulesJson; + } + + public void setRulesJson(String rulesJson) { + this.rulesJson = rulesJson; + } + + public Integer getParticipantCount() { + return participantCount; + } + + public void setParticipantCount(Integer participantCount) { + this.participantCount = participantCount; + } + + public Integer getOrderCount() { + return orderCount; + } + + public void setOrderCount(Integer orderCount) { + this.orderCount = orderCount; + } + + public BigDecimal getTotalDiscountAmount() { + return totalDiscountAmount; + } + + public void setTotalDiscountAmount(BigDecimal totalDiscountAmount) { + this.totalDiscountAmount = totalDiscountAmount; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/domain/MarketingActivityStatistics.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/domain/MarketingActivityStatistics.java new file mode 100644 index 0000000..59fafc8 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/domain/MarketingActivityStatistics.java @@ -0,0 +1,75 @@ +package cn.novalon.gym.manage.coupon.marketing.domain; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; + +@Schema(description = "营销活动统计数据") +public class MarketingActivityStatistics { + + @Schema(description = "活动ID") + private Long activityId; + + @Schema(description = "活动名称") + private String activityName; + + @Schema(description = "参与人数") + private Integer participantCount; + + @Schema(description = "订单数") + private Integer orderCount; + + @Schema(description = "累计优惠金额") + private BigDecimal totalDiscountAmount; + + @Schema(description = "活动状态") + private String status; + + public Long getActivityId() { + return activityId; + } + + public void setActivityId(Long activityId) { + this.activityId = activityId; + } + + public String getActivityName() { + return activityName; + } + + public void setActivityName(String activityName) { + this.activityName = activityName; + } + + public Integer getParticipantCount() { + return participantCount; + } + + public void setParticipantCount(Integer participantCount) { + this.participantCount = participantCount; + } + + public Integer getOrderCount() { + return orderCount; + } + + public void setOrderCount(Integer orderCount) { + this.orderCount = orderCount; + } + + public BigDecimal getTotalDiscountAmount() { + return totalDiscountAmount; + } + + public void setTotalDiscountAmount(BigDecimal totalDiscountAmount) { + this.totalDiscountAmount = totalDiscountAmount; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/entity/MarketingActivityEntity.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/entity/MarketingActivityEntity.java new file mode 100644 index 0000000..51c9c44 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/entity/MarketingActivityEntity.java @@ -0,0 +1,177 @@ +package cn.novalon.gym.manage.coupon.marketing.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; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Table("marketing_activity") +public class MarketingActivityEntity extends BaseEntity { + + @Column("name") + private String name; + + @Column("description") + private String description; + + @Column("activity_type") + private String activityType; + + @Column("start_time") + private LocalDateTime startTime; + + @Column("end_time") + private LocalDateTime endTime; + + @Column("discount_value") + private BigDecimal discountValue; + + @Column("threshold_amount") + private BigDecimal thresholdAmount; + + @Column("gift_description") + private String giftDescription; + + @Column("apply_scope") + private String applyScope; + + @Column("apply_product_ids") + private String applyProductIds; + + @Column("rules_json") + private String rulesJson; + + @Column("participant_count") + private Integer participantCount; + + @Column("order_count") + private Integer orderCount; + + @Column("total_discount_amount") + private BigDecimal totalDiscountAmount; + + @Column("status") + private String status; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getActivityType() { + return activityType; + } + + public void setActivityType(String activityType) { + this.activityType = activityType; + } + + public LocalDateTime getStartTime() { + return startTime; + } + + public void setStartTime(LocalDateTime startTime) { + this.startTime = startTime; + } + + public LocalDateTime getEndTime() { + return endTime; + } + + public void setEndTime(LocalDateTime endTime) { + this.endTime = endTime; + } + + public BigDecimal getDiscountValue() { + return discountValue; + } + + public void setDiscountValue(BigDecimal discountValue) { + this.discountValue = discountValue; + } + + public BigDecimal getThresholdAmount() { + return thresholdAmount; + } + + public void setThresholdAmount(BigDecimal thresholdAmount) { + this.thresholdAmount = thresholdAmount; + } + + public String getGiftDescription() { + return giftDescription; + } + + public void setGiftDescription(String giftDescription) { + this.giftDescription = giftDescription; + } + + public String getApplyScope() { + return applyScope; + } + + public void setApplyScope(String applyScope) { + this.applyScope = applyScope; + } + + public String getApplyProductIds() { + return applyProductIds; + } + + public void setApplyProductIds(String applyProductIds) { + this.applyProductIds = applyProductIds; + } + + public String getRulesJson() { + return rulesJson; + } + + public void setRulesJson(String rulesJson) { + this.rulesJson = rulesJson; + } + + public Integer getParticipantCount() { + return participantCount; + } + + public void setParticipantCount(Integer participantCount) { + this.participantCount = participantCount; + } + + public Integer getOrderCount() { + return orderCount; + } + + public void setOrderCount(Integer orderCount) { + this.orderCount = orderCount; + } + + public BigDecimal getTotalDiscountAmount() { + return totalDiscountAmount; + } + + public void setTotalDiscountAmount(BigDecimal totalDiscountAmount) { + this.totalDiscountAmount = totalDiscountAmount; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/enums/MarketingActivityStatus.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/enums/MarketingActivityStatus.java new file mode 100644 index 0000000..0fe84ce --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/enums/MarketingActivityStatus.java @@ -0,0 +1,15 @@ +package cn.novalon.gym.manage.coupon.marketing.enums; + +/** + * 营销活动状态 + */ +public enum MarketingActivityStatus { + /** 草稿 */ + DRAFT, + /** 进行中 */ + ACTIVE, + /** 已终止 */ + TERMINATED, + /** 已过期 */ + EXPIRED +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/enums/MarketingActivityType.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/enums/MarketingActivityType.java new file mode 100644 index 0000000..569550e --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/enums/MarketingActivityType.java @@ -0,0 +1,19 @@ +package cn.novalon.gym.manage.coupon.marketing.enums; + +/** + * 会员营销活动类型 + */ +public enum MarketingActivityType { + /** 赠品活动 */ + GIFT, + /** 限时折扣 */ + TIME_DISCOUNT, + /** 阶梯折扣 */ + TIERED_DISCOUNT, + /** 团购折扣 */ + GROUP_DISCOUNT, + /** 限量特惠 */ + LIMITED_OFFER, + /** 推荐奖励 */ + RECOMMEND_REWARD +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/handler/MarketingActivityHandler.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/handler/MarketingActivityHandler.java new file mode 100644 index 0000000..f358204 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/handler/MarketingActivityHandler.java @@ -0,0 +1,197 @@ +package cn.novalon.gym.manage.coupon.marketing.handler; + +import cn.novalon.gym.manage.common.dto.PageRequest; +import cn.novalon.gym.manage.coupon.marketing.domain.MarketingActivity; +import cn.novalon.gym.manage.coupon.marketing.service.IMarketingActivityService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +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 MarketingActivityHandler { + + private final IMarketingActivityService marketingActivityService; + + public MarketingActivityHandler(IMarketingActivityService marketingActivityService) { + this.marketingActivityService = marketingActivityService; + } + + @Operation(summary = "获取所有营销活动", description = "获取系统中所有营销活动列表") + public Mono getAllActivities(ServerRequest request) { + boolean includeDeleted = Boolean.parseBoolean(request.queryParam("includeDeleted").orElse("false")); + return ServerResponse.ok() + .body(marketingActivityService.findAll(includeDeleted), MarketingActivity.class); + } + + @Operation(summary = "分页获取营销活动", description = "根据分页参数获取营销活动列表") + public Mono getActivitiesByPage(ServerRequest request) { + return request.bodyToMono(PageRequest.class) + .flatMap(pageRequest -> { + String activityType = request.queryParam("activityType").orElse(null); + String status = request.queryParam("status").orElse(null); + + if (pageRequest.getPage() < 0) { + pageRequest.setPage(0); + } + if (pageRequest.getSize() <= 0 || pageRequest.getSize() > 100) { + pageRequest.setSize(10); + } + if (pageRequest.getSort() == null || pageRequest.getSort().isEmpty()) { + pageRequest.setSort("id"); + } + if (pageRequest.getOrder() == null || pageRequest.getOrder().isEmpty()) { + pageRequest.setOrder("desc"); + } + + return marketingActivityService.findByPage(pageRequest, activityType, status) + .flatMap(response -> ServerResponse.ok().bodyValue(response)); + }); + } + + @Operation(summary = "搜索营销活动", description = "根据关键词、类型、状态搜索营销活动") + public Mono searchActivities(ServerRequest request) { + String keyword = request.queryParam("keyword").orElse(""); + String activityType = request.queryParam("activityType").orElse(null); + String status = request.queryParam("status").orElse(null); + return ServerResponse.ok() + .body(marketingActivityService.findByActivityTypeAndKeyword(activityType, keyword, status), + MarketingActivity.class); + } + + @Operation(summary = "根据ID获取营销活动", description = "根据ID获取营销活动详情") + public Mono getActivityById(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return marketingActivityService.findById(id) + .flatMap(activity -> ServerResponse.ok().bodyValue(activity)) + .switchIfEmpty(ServerResponse.notFound().build()); + } + + @Operation(summary = "创建营销活动", description = "创建新的营销活动") + public Mono createActivity(ServerRequest request) { + return request.bodyToMono(MarketingActivity.class) + .flatMap(activity -> { + if (activity.getName() == null || activity.getName().isEmpty()) { + Map error = new HashMap<>(); + error.put("success", false); + error.put("message", "活动名称不能为空"); + return ServerResponse.badRequest().bodyValue(error); + } + + return marketingActivityService.create(activity) + .flatMap(created -> { + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "营销活动创建成功"); + response.put("data", created); + 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 updateActivity(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + + return request.bodyToMono(MarketingActivity.class) + .flatMap(activity -> marketingActivityService.update(id, activity) + .flatMap(updated -> { + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "营销活动更新成功"); + response.put("data", updated); + 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 deleteActivity(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + + return marketingActivityService.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 publishActivity(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + + return marketingActivityService.publish(id) + .flatMap(activity -> { + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "营销活动发布成功"); + response.put("data", activity); + 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 terminateActivity(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + + return marketingActivityService.terminate(id) + .flatMap(activity -> { + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "营销活动已终止"); + response.put("data", activity); + 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 getActivityStatistics(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + + return marketingActivityService.getStatistics(id) + .flatMap(stats -> ServerResponse.ok().bodyValue(stats)) + .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-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/repository/IMarketingActivityRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/repository/IMarketingActivityRepository.java new file mode 100644 index 0000000..22cfb72 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/repository/IMarketingActivityRepository.java @@ -0,0 +1,30 @@ +package cn.novalon.gym.manage.coupon.marketing.repository; + +import cn.novalon.gym.manage.coupon.marketing.domain.MarketingActivity; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface IMarketingActivityRepository { + + Mono findById(Long id); + + Flux findAll(); + + Flux findAll(boolean includeDeleted); + + Flux findByKeyword(String keyword); + + Flux findByActivityType(String activityType); + + Flux findByStatus(String status); + + Flux findByActivityTypeAndKeyword(String activityType, String keyword, String status); + + Mono save(MarketingActivity activity); + + Mono update(MarketingActivity activity); + + Mono deleteById(Long id); + + Mono updateStatus(Long id, String status); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/repository/impl/MarketingActivityRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/repository/impl/MarketingActivityRepository.java new file mode 100644 index 0000000..33a4a07 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/repository/impl/MarketingActivityRepository.java @@ -0,0 +1,164 @@ +package cn.novalon.gym.manage.coupon.marketing.repository.impl; + +import cn.novalon.gym.manage.coupon.marketing.converter.MarketingConverter; +import cn.novalon.gym.manage.coupon.marketing.dao.MarketingActivityDao; +import cn.novalon.gym.manage.coupon.marketing.domain.MarketingActivity; +import cn.novalon.gym.manage.coupon.marketing.entity.MarketingActivityEntity; +import cn.novalon.gym.manage.coupon.marketing.repository.IMarketingActivityRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +@Repository +@Transactional +public class MarketingActivityRepository implements IMarketingActivityRepository { + + private final MarketingActivityDao marketingActivityDao; + private final MarketingConverter converter; + + public MarketingActivityRepository(MarketingActivityDao marketingActivityDao, MarketingConverter converter) { + this.marketingActivityDao = marketingActivityDao; + this.converter = converter; + } + + @Override + public Mono findById(Long id) { + return marketingActivityDao.findByIdIsAndDeletedAtIsNull(id) + .map(converter::toMarketingActivity); + } + + @Override + public Flux findAll() { + return marketingActivityDao.findAllByDeletedAtIsNull() + .map(converter::toMarketingActivity); + } + + @Override + public Flux findAll(boolean includeDeleted) { + if (includeDeleted) { + return marketingActivityDao.findAll() + .map(converter::toMarketingActivity); + } + return findAll(); + } + + @Override + public Flux findByKeyword(String keyword) { + if (keyword == null || keyword.isEmpty()) { + return findAll(false); + } + return marketingActivityDao.findByNameContainingAndDeletedAtIsNull(keyword) + .map(converter::toMarketingActivity); + } + + @Override + public Flux findByActivityType(String activityType) { + if (activityType == null || activityType.isEmpty()) { + return findAll(false); + } + return marketingActivityDao.findByActivityTypeAndDeletedAtIsNull(activityType) + .map(converter::toMarketingActivity); + } + + @Override + public Flux findByStatus(String status) { + if (status == null || status.isEmpty()) { + return findAll(false); + } + return marketingActivityDao.findByStatusAndDeletedAtIsNull(status) + .map(converter::toMarketingActivity); + } + + @Override + public Flux findByActivityTypeAndKeyword(String activityType, String keyword, String status) { + Flux result; + + if (activityType != null && !activityType.isEmpty()) { + result = findByActivityType(activityType); + } else if (status != null && !status.isEmpty()) { + result = findByStatus(status); + } else { + result = findAll(false); + } + + if (keyword != null && !keyword.isEmpty()) { + result = result.filter(activity -> activity.getName() != null + && activity.getName().toLowerCase().contains(keyword.toLowerCase())); + } + + if (status != null && !status.isEmpty()) { + result = result.filter(activity -> status.equals(activity.getStatus())); + } + + if (activityType != null && !activityType.isEmpty()) { + result = result.filter(activity -> activityType.equals(activity.getActivityType())); + } + + return result; + } + + @Override + public Mono save(MarketingActivity activity) { + MarketingActivityEntity entity = converter.toMarketingActivityEntity(activity); + return marketingActivityDao.save(entity) + .map(converter::toMarketingActivity); + } + + @Override + public Mono update(MarketingActivity activity) { + return marketingActivityDao.findByIdIsAndDeletedAtIsNull(activity.getId()) + .switchIfEmpty(Mono.error(new RuntimeException("营销活动不存在"))) + .flatMap(existing -> { + existing.markNotNew(); + if (activity.getName() != null) { + existing.setName(activity.getName()); + } + if (activity.getDescription() != null) { + existing.setDescription(activity.getDescription()); + } + if (activity.getActivityType() != null) { + existing.setActivityType(activity.getActivityType()); + } + if (activity.getStartTime() != null) { + existing.setStartTime(activity.getStartTime()); + } + if (activity.getEndTime() != null) { + existing.setEndTime(activity.getEndTime()); + } + if (activity.getDiscountValue() != null) { + existing.setDiscountValue(activity.getDiscountValue()); + } + if (activity.getThresholdAmount() != null) { + existing.setThresholdAmount(activity.getThresholdAmount()); + } + if (activity.getGiftDescription() != null) { + existing.setGiftDescription(activity.getGiftDescription()); + } + if (activity.getApplyScope() != null) { + existing.setApplyScope(activity.getApplyScope()); + } + if (activity.getApplyProductIds() != null) { + existing.setApplyProductIds(activity.getApplyProductIds()); + } + if (activity.getRulesJson() != null) { + existing.setRulesJson(activity.getRulesJson()); + } + existing.setUpdatedAt(LocalDateTime.now()); + return marketingActivityDao.save(existing); + }) + .map(converter::toMarketingActivity); + } + + @Override + public Mono deleteById(Long id) { + return marketingActivityDao.softDelete(id, LocalDateTime.now()).then(); + } + + @Override + public Mono updateStatus(Long id, String status) { + return marketingActivityDao.updateStatus(id, status, LocalDateTime.now()).then(); + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/service/IMarketingActivityService.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/service/IMarketingActivityService.java new file mode 100644 index 0000000..fb7a923 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/service/IMarketingActivityService.java @@ -0,0 +1,31 @@ +package cn.novalon.gym.manage.coupon.marketing.service; + +import cn.novalon.gym.manage.common.dto.PageRequest; +import cn.novalon.gym.manage.common.dto.PageResponse; +import cn.novalon.gym.manage.coupon.marketing.domain.MarketingActivity; +import cn.novalon.gym.manage.coupon.marketing.domain.MarketingActivityStatistics; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface IMarketingActivityService { + + Mono findById(Long id); + + Flux findAll(boolean includeDeleted); + + Flux findByActivityTypeAndKeyword(String activityType, String keyword, String status); + + Mono> findByPage(PageRequest pageRequest, String activityType, String status); + + Mono create(MarketingActivity activity); + + Mono update(Long id, MarketingActivity activity); + + Mono delete(Long id); + + Mono publish(Long id); + + Mono terminate(Long id); + + Mono getStatistics(Long id); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/service/impl/MarketingActivityService.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/service/impl/MarketingActivityService.java new file mode 100644 index 0000000..45ac592 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/marketing/service/impl/MarketingActivityService.java @@ -0,0 +1,192 @@ +package cn.novalon.gym.manage.coupon.marketing.service.impl; + +import cn.novalon.gym.manage.common.dto.PageRequest; +import cn.novalon.gym.manage.common.dto.PageResponse; +import cn.novalon.gym.manage.coupon.enums.ApplyScope; +import cn.novalon.gym.manage.coupon.marketing.domain.MarketingActivity; +import cn.novalon.gym.manage.coupon.marketing.domain.MarketingActivityStatistics; +import cn.novalon.gym.manage.coupon.marketing.enums.MarketingActivityStatus; +import cn.novalon.gym.manage.coupon.marketing.enums.MarketingActivityType; +import cn.novalon.gym.manage.coupon.marketing.repository.IMarketingActivityRepository; +import cn.novalon.gym.manage.coupon.marketing.service.IMarketingActivityService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.math.BigDecimal; +import java.util.Comparator; +import java.util.List; + +@Service +public class MarketingActivityService implements IMarketingActivityService { + + private static final Logger logger = LoggerFactory.getLogger(MarketingActivityService.class); + + private final IMarketingActivityRepository marketingActivityRepository; + + public MarketingActivityService(IMarketingActivityRepository marketingActivityRepository) { + this.marketingActivityRepository = marketingActivityRepository; + } + + @Override + public Mono findById(Long id) { + return marketingActivityRepository.findById(id); + } + + @Override + public Flux findAll(boolean includeDeleted) { + return marketingActivityRepository.findAll(includeDeleted); + } + + @Override + public Flux findByActivityTypeAndKeyword(String activityType, String keyword, String status) { + return marketingActivityRepository.findByActivityTypeAndKeyword(activityType, keyword, status); + } + + @Override + public Mono> findByPage(PageRequest pageRequest, String activityType, String status) { + int page = Math.max(pageRequest.getPage(), 0); + int size = pageRequest.getSize() <= 0 || pageRequest.getSize() > 100 ? 10 : pageRequest.getSize(); + String keyword = pageRequest.getKeyword(); + + return marketingActivityRepository.findByActivityTypeAndKeyword(activityType, keyword, status) + .sort(Comparator.comparing(MarketingActivity::getCreatedAt, + Comparator.nullsLast(Comparator.reverseOrder()))) + .collectList() + .map(list -> { + long total = list.size(); + int fromIndex = Math.min(page * size, list.size()); + int toIndex = Math.min(fromIndex + size, list.size()); + List content = list.subList(fromIndex, toIndex); + int totalPages = size == 0 ? 0 : (int) Math.ceil((double) total / size); + return new PageResponse<>(content, totalPages, total, page, size); + }); + } + + @Override + public Mono create(MarketingActivity activity) { + return validateActivity(activity) + .flatMap(validated -> { + validated.generateId(); + validated.setStatus(MarketingActivityStatus.DRAFT.name()); + validated.setParticipantCount(0); + validated.setOrderCount(0); + validated.setTotalDiscountAmount(BigDecimal.ZERO); + applyDefaults(validated); + return marketingActivityRepository.save(validated); + }) + .doOnSuccess(a -> logger.info("营销活动创建成功 - id={}, name={}", a.getId(), a.getName())) + .doOnError(error -> logger.error("营销活动创建失败 - error: {}", error.getMessage())); + } + + @Override + public Mono update(Long id, MarketingActivity activity) { + return marketingActivityRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("营销活动不存在"))) + .flatMap(existing -> { + if (!MarketingActivityStatus.DRAFT.name().equals(existing.getStatus())) { + return Mono.error(new RuntimeException("活动已发布,不可编辑")); + } + activity.setId(id); + return validateActivity(activity) + .flatMap(marketingActivityRepository::update); + }) + .doOnSuccess(a -> logger.info("营销活动更新成功 - id={}", id)) + .doOnError(error -> logger.error("营销活动更新失败 - id={}, error: {}", id, error.getMessage())); + } + + @Override + public Mono delete(Long id) { + return marketingActivityRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("营销活动不存在"))) + .flatMap(existing -> { + if (!MarketingActivityStatus.DRAFT.name().equals(existing.getStatus())) { + return Mono.error(new RuntimeException("仅草稿状态的活动可删除")); + } + return marketingActivityRepository.deleteById(id); + }) + .doOnSuccess(v -> logger.info("营销活动删除成功 - id={}", id)) + .doOnError(error -> logger.error("营销活动删除失败 - id={}, error: {}", id, error.getMessage())); + } + + @Override + public Mono publish(Long id) { + return marketingActivityRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("营销活动不存在"))) + .flatMap(existing -> { + if (!MarketingActivityStatus.DRAFT.name().equals(existing.getStatus()) + && !MarketingActivityStatus.TERMINATED.name().equals(existing.getStatus())) { + return Mono.error(new RuntimeException("当前状态不允许发布")); + } + return validateActivity(existing) + .flatMap(validated -> marketingActivityRepository.updateStatus(id, MarketingActivityStatus.ACTIVE.name()) + .then(marketingActivityRepository.findById(id))); + }) + .doOnSuccess(a -> logger.info("营销活动发布成功 - id={}", id)) + .doOnError(error -> logger.error("营销活动发布失败 - id={}, error: {}", id, error.getMessage())); + } + + @Override + public Mono terminate(Long id) { + return marketingActivityRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("营销活动不存在"))) + .flatMap(existing -> { + if (!MarketingActivityStatus.ACTIVE.name().equals(existing.getStatus())) { + return Mono.error(new RuntimeException("仅进行中的活动可终止")); + } + return marketingActivityRepository.updateStatus(id, MarketingActivityStatus.TERMINATED.name()) + .then(marketingActivityRepository.findById(id)); + }) + .doOnSuccess(a -> logger.info("营销活动已终止 - id={}", id)) + .doOnError(error -> logger.error("营销活动终止失败 - id={}, error: {}", id, error.getMessage())); + } + + @Override + public Mono getStatistics(Long id) { + return marketingActivityRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("营销活动不存在"))) + .map(activity -> { + MarketingActivityStatistics stats = new MarketingActivityStatistics(); + stats.setActivityId(id); + stats.setActivityName(activity.getName()); + stats.setParticipantCount(activity.getParticipantCount() != null ? activity.getParticipantCount() : 0); + stats.setOrderCount(activity.getOrderCount() != null ? activity.getOrderCount() : 0); + stats.setTotalDiscountAmount(activity.getTotalDiscountAmount() != null + ? activity.getTotalDiscountAmount() : BigDecimal.ZERO); + stats.setStatus(activity.getStatus()); + return stats; + }); + } + + private Mono validateActivity(MarketingActivity activity) { + if (activity.getName() == null || activity.getName().isBlank()) { + return Mono.error(new RuntimeException("活动名称不能为空")); + } + if (activity.getActivityType() == null || activity.getActivityType().isBlank()) { + return Mono.error(new RuntimeException("活动类型不能为空")); + } + try { + MarketingActivityType.valueOf(activity.getActivityType()); + } catch (IllegalArgumentException e) { + return Mono.error(new RuntimeException("无效的活动类型")); + } + if (activity.getStartTime() == null || activity.getEndTime() == null) { + return Mono.error(new RuntimeException("活动开始和结束时间不能为空")); + } + if (activity.getEndTime().isBefore(activity.getStartTime())) { + return Mono.error(new RuntimeException("结束时间不能早于开始时间")); + } + return Mono.just(activity); + } + + private void applyDefaults(MarketingActivity activity) { + if (activity.getThresholdAmount() == null) { + activity.setThresholdAmount(BigDecimal.ZERO); + } + if (activity.getApplyScope() == null) { + activity.setApplyScope(ApplyScope.ALL.name()); + } + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/converter/PointsConverter.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/converter/PointsConverter.java new file mode 100644 index 0000000..c746800 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/converter/PointsConverter.java @@ -0,0 +1,102 @@ +package cn.novalon.gym.manage.coupon.points.converter; + +import cn.hutool.core.bean.BeanUtil; +import cn.novalon.gym.manage.coupon.points.domain.MemberPoints; +import cn.novalon.gym.manage.coupon.points.domain.PointsMallProduct; +import cn.novalon.gym.manage.coupon.points.domain.PointsRecord; +import cn.novalon.gym.manage.coupon.points.domain.PointsRule; +import cn.novalon.gym.manage.coupon.points.entity.MemberPointsEntity; +import cn.novalon.gym.manage.coupon.points.entity.PointsMallProductEntity; +import cn.novalon.gym.manage.coupon.points.entity.PointsRecordEntity; +import cn.novalon.gym.manage.coupon.points.entity.PointsRuleEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +public class PointsConverter { + + public PointsRule toPointsRule(PointsRuleEntity entity) { + if (entity == null) { + return null; + } + PointsRule domain = new PointsRule(); + BeanUtil.copyProperties(entity, domain); + return domain; + } + + public PointsRuleEntity toPointsRuleEntity(PointsRule domain) { + if (domain == null) { + return null; + } + PointsRuleEntity entity = new PointsRuleEntity(); + BeanUtil.copyProperties(domain, entity); + if (domain.getId() != null) { + entity.markNotNew(); + } + return entity; + } + + public PointsMallProduct toPointsMallProduct(PointsMallProductEntity entity) { + if (entity == null) { + return null; + } + PointsMallProduct domain = new PointsMallProduct(); + BeanUtil.copyProperties(entity, domain); + return domain; + } + + public PointsMallProductEntity toPointsMallProductEntity(PointsMallProduct domain) { + if (domain == null) { + return null; + } + PointsMallProductEntity entity = new PointsMallProductEntity(); + BeanUtil.copyProperties(domain, entity); + if (domain.getId() != null) { + entity.markNotNew(); + } + return entity; + } + + public MemberPoints toMemberPoints(MemberPointsEntity entity) { + if (entity == null) { + return null; + } + MemberPoints domain = new MemberPoints(); + BeanUtil.copyProperties(entity, domain); + return domain; + } + + public MemberPointsEntity toMemberPointsEntity(MemberPoints domain) { + if (domain == null) { + return null; + } + MemberPointsEntity entity = new MemberPointsEntity(); + BeanUtil.copyProperties(domain, entity); + if (domain.getId() != null) { + entity.markNotNew(); + } + return entity; + } + + public PointsRecord toPointsRecord(PointsRecordEntity entity) { + if (entity == null) { + return null; + } + PointsRecord domain = new PointsRecord(); + BeanUtil.copyProperties(entity, domain); + return domain; + } + + public PointsRecordEntity toPointsRecordEntity(PointsRecord domain) { + if (domain == null) { + return null; + } + PointsRecordEntity entity = new PointsRecordEntity(); + BeanUtil.copyProperties(domain, entity); + if (domain.getId() != null) { + entity.markNotNew(); + } + return entity; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/dao/MemberPointsDao.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/dao/MemberPointsDao.java new file mode 100644 index 0000000..3fa36a8 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/dao/MemberPointsDao.java @@ -0,0 +1,27 @@ +package cn.novalon.gym.manage.coupon.points.dao; + +import cn.novalon.gym.manage.coupon.points.entity.MemberPointsEntity; +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 MemberPointsDao extends R2dbcRepository { + + Mono findByMemberIdAndDeletedAtIsNull(Long memberId); + + Flux findAllByDeletedAtIsNull(); + + @Modifying + @Query("UPDATE member_points SET total_points = total_points + :points, available_points = available_points + :points, updated_at = :updatedAt WHERE member_id = :memberId AND deleted_at IS NULL") + Mono addPoints(Long memberId, int points, LocalDateTime updatedAt); + + @Modifying + @Query("UPDATE member_points SET available_points = available_points - :points, used_points = used_points + :points, updated_at = :updatedAt WHERE member_id = :memberId AND available_points >= :points AND deleted_at IS NULL") + Mono deductPoints(Long memberId, int points, LocalDateTime updatedAt); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/dao/PointsMallProductDao.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/dao/PointsMallProductDao.java new file mode 100644 index 0000000..61e7879 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/dao/PointsMallProductDao.java @@ -0,0 +1,35 @@ +package cn.novalon.gym.manage.coupon.points.dao; + +import cn.novalon.gym.manage.coupon.points.entity.PointsMallProductEntity; +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 PointsMallProductDao extends R2dbcRepository { + + Mono findByIdIsAndDeletedAtIsNull(Long id); + + Flux findAllByDeletedAtIsNull(); + + Flux findByProductNameContainingAndDeletedAtIsNull(String productName); + + Flux findByStatusAndDeletedAtIsNull(String status); + + @Modifying + @Query("UPDATE points_mall_product SET deleted_at = :deletedAt WHERE id = :id") + Mono softDelete(Long id, LocalDateTime deletedAt); + + @Modifying + @Query("UPDATE points_mall_product SET status = :status, updated_at = :updatedAt WHERE id = :id") + Mono updateStatus(Long id, String status, LocalDateTime updatedAt); + + @Modifying + @Query("UPDATE points_mall_product SET sold_count = sold_count + :count, stock = CASE WHEN stock = -1 THEN -1 ELSE stock - :count END, updated_at = :updatedAt WHERE id = :id AND (stock = -1 OR stock >= :count)") + Mono decrementStockAndIncrementSold(Long id, int count, LocalDateTime updatedAt); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/dao/PointsRecordDao.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/dao/PointsRecordDao.java new file mode 100644 index 0000000..ebdfac5 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/dao/PointsRecordDao.java @@ -0,0 +1,27 @@ +package cn.novalon.gym.manage.coupon.points.dao; + +import cn.novalon.gym.manage.coupon.points.entity.PointsRecordEntity; +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 PointsRecordDao extends R2dbcRepository { + + Flux findByMemberIdAndDeletedAtIsNullOrderByCreatedAtDesc(Long memberId); + + @Query("SELECT COUNT(*) FROM points_record WHERE member_id = :memberId AND source = :source AND change_type = :changeType AND created_at >= :startOfDay AND deleted_at IS NULL") + Mono countTodayByMemberAndSource(Long memberId, String source, String changeType, LocalDateTime startOfDay); + + @Query("SELECT COALESCE(SUM(points), 0) FROM points_record WHERE change_type = :changeType AND points > 0 AND deleted_at IS NULL") + Mono sumPositivePointsByChangeType(String changeType); + + @Query("SELECT COALESCE(SUM(ABS(points)), 0) FROM points_record WHERE change_type IN ('SPEND', 'EXCHANGE') AND deleted_at IS NULL") + Mono sumSpentPoints(); + + Mono countByChangeTypeAndDeletedAtIsNull(String changeType); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/dao/PointsRuleDao.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/dao/PointsRuleDao.java new file mode 100644 index 0000000..a35cdf7 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/dao/PointsRuleDao.java @@ -0,0 +1,27 @@ +package cn.novalon.gym.manage.coupon.points.dao; + +import cn.novalon.gym.manage.coupon.points.entity.PointsRuleEntity; +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 PointsRuleDao extends R2dbcRepository { + + Mono findByIdIsAndDeletedAtIsNull(Long id); + + Flux findAllByDeletedAtIsNull(); + + Flux findByRuleTypeAndStatusAndDeletedAtIsNull(String ruleType, String status); + + Mono findFirstByRuleTypeAndStatusAndDeletedAtIsNull(String ruleType, String status); + + @Modifying + @Query("UPDATE points_rule SET deleted_at = :deletedAt WHERE id = :id") + Mono softDelete(Long id, LocalDateTime deletedAt); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/EarnPointsRequest.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/EarnPointsRequest.java new file mode 100644 index 0000000..a22836e --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/EarnPointsRequest.java @@ -0,0 +1,51 @@ +package cn.novalon.gym.manage.coupon.points.domain; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "积分获取请求") +public class EarnPointsRequest { + + @Schema(description = "会员ID") + private Long memberId; + + @Schema(description = "积分数量") + private Integer points; + + @Schema(description = "来源") + private String source; + + @Schema(description = "备注") + private String remark; + + public Long getMemberId() { + return memberId; + } + + public void setMemberId(Long memberId) { + this.memberId = memberId; + } + + public Integer getPoints() { + return points; + } + + public void setPoints(Integer points) { + this.points = points; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/ExchangePointsRequest.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/ExchangePointsRequest.java new file mode 100644 index 0000000..e78af87 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/ExchangePointsRequest.java @@ -0,0 +1,29 @@ +package cn.novalon.gym.manage.coupon.points.domain; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "积分兑换请求") +public class ExchangePointsRequest { + + @Schema(description = "会员ID") + private Long memberId; + + @Schema(description = "商品ID") + private Long productId; + + public Long getMemberId() { + return memberId; + } + + public void setMemberId(Long memberId) { + this.memberId = memberId; + } + + public Long getProductId() { + return productId; + } + + public void setProductId(Long productId) { + this.productId = productId; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/MemberPoints.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/MemberPoints.java new file mode 100644 index 0000000..36e3bc8 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/MemberPoints.java @@ -0,0 +1,52 @@ +package cn.novalon.gym.manage.coupon.points.domain; + +import cn.novalon.gym.manage.sys.core.domain.BaseDomain; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "会员积分账户") +public class MemberPoints extends BaseDomain { + + @Schema(description = "会员ID") + private Long memberId; + + @Schema(description = "累计积分") + private Integer totalPoints; + + @Schema(description = "可用积分") + private Integer availablePoints; + + @Schema(description = "已使用积分") + private Integer usedPoints; + + public Long getMemberId() { + return memberId; + } + + public void setMemberId(Long memberId) { + this.memberId = memberId; + } + + public Integer getTotalPoints() { + return totalPoints; + } + + public void setTotalPoints(Integer totalPoints) { + this.totalPoints = totalPoints; + } + + public Integer getAvailablePoints() { + return availablePoints; + } + + public void setAvailablePoints(Integer availablePoints) { + this.availablePoints = availablePoints; + } + + public Integer getUsedPoints() { + return usedPoints; + } + + public void setUsedPoints(Integer usedPoints) { + this.usedPoints = usedPoints; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/PointsMallProduct.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/PointsMallProduct.java new file mode 100644 index 0000000..fbc9695 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/PointsMallProduct.java @@ -0,0 +1,107 @@ +package cn.novalon.gym.manage.coupon.points.domain; + +import cn.novalon.gym.manage.sys.core.domain.BaseDomain; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "积分商城商品") +public class PointsMallProduct extends BaseDomain { + + @Schema(description = "商品名称") + private String productName; + + @Schema(description = "商品描述") + private String description; + + @Schema(description = "商品类型:COUPON/COURSE/GOODS/OTHER") + private String productType; + + @Schema(description = "关联业务ID") + private Long relatedId; + + @Schema(description = "兑换所需积分") + private Integer pointsCost; + + @Schema(description = "库存,-1表示不限") + private Integer stock; + + @Schema(description = "已兑换数量") + private Integer soldCount; + + @Schema(description = "商品图片URL") + private String imageUrl; + + @Schema(description = "状态:DRAFT/ACTIVE/OFFLINE") + private String status; + + public String getProductName() { + return productName; + } + + public void setProductName(String productName) { + this.productName = productName; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getProductType() { + return productType; + } + + public void setProductType(String productType) { + this.productType = productType; + } + + public Long getRelatedId() { + return relatedId; + } + + public void setRelatedId(Long relatedId) { + this.relatedId = relatedId; + } + + public Integer getPointsCost() { + return pointsCost; + } + + public void setPointsCost(Integer pointsCost) { + this.pointsCost = pointsCost; + } + + public Integer getStock() { + return stock; + } + + public void setStock(Integer stock) { + this.stock = stock; + } + + public Integer getSoldCount() { + return soldCount; + } + + public void setSoldCount(Integer soldCount) { + this.soldCount = soldCount; + } + + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/PointsRecord.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/PointsRecord.java new file mode 100644 index 0000000..0261ddd --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/PointsRecord.java @@ -0,0 +1,85 @@ +package cn.novalon.gym.manage.coupon.points.domain; + +import cn.novalon.gym.manage.sys.core.domain.BaseDomain; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "积分变动记录") +public class PointsRecord extends BaseDomain { + + @Schema(description = "会员ID") + private Long memberId; + + @Schema(description = "变动类型:EARN/SPEND/EXCHANGE") + private String changeType; + + @Schema(description = "变动积分(正数为增加,负数为减少)") + private Integer points; + + @Schema(description = "变动后余额") + private Integer balanceAfter; + + @Schema(description = "来源") + private String source; + + @Schema(description = "关联业务ID") + private Long relatedId; + + @Schema(description = "备注") + private String remark; + + public Long getMemberId() { + return memberId; + } + + public void setMemberId(Long memberId) { + this.memberId = memberId; + } + + public String getChangeType() { + return changeType; + } + + public void setChangeType(String changeType) { + this.changeType = changeType; + } + + public Integer getPoints() { + return points; + } + + public void setPoints(Integer points) { + this.points = points; + } + + public Integer getBalanceAfter() { + return balanceAfter; + } + + public void setBalanceAfter(Integer balanceAfter) { + this.balanceAfter = balanceAfter; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public Long getRelatedId() { + return relatedId; + } + + public void setRelatedId(Long relatedId) { + this.relatedId = relatedId; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/PointsRule.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/PointsRule.java new file mode 100644 index 0000000..d55bb25 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/PointsRule.java @@ -0,0 +1,76 @@ +package cn.novalon.gym.manage.coupon.points.domain; + +import cn.novalon.gym.manage.sys.core.domain.BaseDomain; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; + +@Schema(description = "积分规则") +public class PointsRule extends BaseDomain { + + @Schema(description = "规则名称") + private String ruleName; + + @Schema(description = "规则类型:SIGN_IN/CONSUME/REFERRAL/MANUAL") + private String ruleType; + + @Schema(description = "积分值") + private Integer pointsValue; + + @Schema(description = "消费积分比例") + private BigDecimal ratio; + + @Schema(description = "规则描述") + private String description; + + @Schema(description = "状态:ACTIVE/INACTIVE") + private String status; + + public String getRuleName() { + return ruleName; + } + + public void setRuleName(String ruleName) { + this.ruleName = ruleName; + } + + public String getRuleType() { + return ruleType; + } + + public void setRuleType(String ruleType) { + this.ruleType = ruleType; + } + + public Integer getPointsValue() { + return pointsValue; + } + + public void setPointsValue(Integer pointsValue) { + this.pointsValue = pointsValue; + } + + public BigDecimal getRatio() { + return ratio; + } + + public void setRatio(BigDecimal ratio) { + this.ratio = ratio; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/PointsStatistics.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/PointsStatistics.java new file mode 100644 index 0000000..bf0f1fc --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/domain/PointsStatistics.java @@ -0,0 +1,95 @@ +package cn.novalon.gym.manage.coupon.points.domain; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "积分商城统计数据") +public class PointsStatistics { + + @Schema(description = "积分规则总数") + private long totalRules; + + @Schema(description = "启用规则数") + private long activeRules; + + @Schema(description = "商品总数") + private long totalProducts; + + @Schema(description = "上架商品数") + private long activeProducts; + + @Schema(description = "会员积分账户总数") + private long totalMemberAccounts; + + @Schema(description = "累计发放积分") + private long totalPointsIssued; + + @Schema(description = "累计消耗积分") + private long totalPointsUsed; + + @Schema(description = "累计兑换次数") + private long totalExchanges; + + public long getTotalRules() { + return totalRules; + } + + public void setTotalRules(long totalRules) { + this.totalRules = totalRules; + } + + public long getActiveRules() { + return activeRules; + } + + public void setActiveRules(long activeRules) { + this.activeRules = activeRules; + } + + public long getTotalProducts() { + return totalProducts; + } + + public void setTotalProducts(long totalProducts) { + this.totalProducts = totalProducts; + } + + public long getActiveProducts() { + return activeProducts; + } + + public void setActiveProducts(long activeProducts) { + this.activeProducts = activeProducts; + } + + public long getTotalMemberAccounts() { + return totalMemberAccounts; + } + + public void setTotalMemberAccounts(long totalMemberAccounts) { + this.totalMemberAccounts = totalMemberAccounts; + } + + public long getTotalPointsIssued() { + return totalPointsIssued; + } + + public void setTotalPointsIssued(long totalPointsIssued) { + this.totalPointsIssued = totalPointsIssued; + } + + public long getTotalPointsUsed() { + return totalPointsUsed; + } + + public void setTotalPointsUsed(long totalPointsUsed) { + this.totalPointsUsed = totalPointsUsed; + } + + public long getTotalExchanges() { + return totalExchanges; + } + + public void setTotalExchanges(long totalExchanges) { + this.totalExchanges = totalExchanges; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/entity/MemberPointsEntity.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/entity/MemberPointsEntity.java new file mode 100644 index 0000000..48bba83 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/entity/MemberPointsEntity.java @@ -0,0 +1,53 @@ +package cn.novalon.gym.manage.coupon.points.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("member_points") +public class MemberPointsEntity extends BaseEntity { + + @Column("member_id") + private Long memberId; + + @Column("total_points") + private Integer totalPoints; + + @Column("available_points") + private Integer availablePoints; + + @Column("used_points") + private Integer usedPoints; + + public Long getMemberId() { + return memberId; + } + + public void setMemberId(Long memberId) { + this.memberId = memberId; + } + + public Integer getTotalPoints() { + return totalPoints; + } + + public void setTotalPoints(Integer totalPoints) { + this.totalPoints = totalPoints; + } + + public Integer getAvailablePoints() { + return availablePoints; + } + + public void setAvailablePoints(Integer availablePoints) { + this.availablePoints = availablePoints; + } + + public Integer getUsedPoints() { + return usedPoints; + } + + public void setUsedPoints(Integer usedPoints) { + this.usedPoints = usedPoints; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/entity/PointsMallProductEntity.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/entity/PointsMallProductEntity.java new file mode 100644 index 0000000..3731b69 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/entity/PointsMallProductEntity.java @@ -0,0 +1,108 @@ +package cn.novalon.gym.manage.coupon.points.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("points_mall_product") +public class PointsMallProductEntity extends BaseEntity { + + @Column("product_name") + private String productName; + + @Column("description") + private String description; + + @Column("product_type") + private String productType; + + @Column("related_id") + private Long relatedId; + + @Column("points_cost") + private Integer pointsCost; + + @Column("stock") + private Integer stock; + + @Column("sold_count") + private Integer soldCount; + + @Column("image_url") + private String imageUrl; + + @Column("status") + private String status; + + public String getProductName() { + return productName; + } + + public void setProductName(String productName) { + this.productName = productName; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getProductType() { + return productType; + } + + public void setProductType(String productType) { + this.productType = productType; + } + + public Long getRelatedId() { + return relatedId; + } + + public void setRelatedId(Long relatedId) { + this.relatedId = relatedId; + } + + public Integer getPointsCost() { + return pointsCost; + } + + public void setPointsCost(Integer pointsCost) { + this.pointsCost = pointsCost; + } + + public Integer getStock() { + return stock; + } + + public void setStock(Integer stock) { + this.stock = stock; + } + + public Integer getSoldCount() { + return soldCount; + } + + public void setSoldCount(Integer soldCount) { + this.soldCount = soldCount; + } + + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/entity/PointsRecordEntity.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/entity/PointsRecordEntity.java new file mode 100644 index 0000000..d5cd0be --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/entity/PointsRecordEntity.java @@ -0,0 +1,86 @@ +package cn.novalon.gym.manage.coupon.points.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("points_record") +public class PointsRecordEntity extends BaseEntity { + + @Column("member_id") + private Long memberId; + + @Column("change_type") + private String changeType; + + @Column("points") + private Integer points; + + @Column("balance_after") + private Integer balanceAfter; + + @Column("source") + private String source; + + @Column("related_id") + private Long relatedId; + + @Column("remark") + private String remark; + + public Long getMemberId() { + return memberId; + } + + public void setMemberId(Long memberId) { + this.memberId = memberId; + } + + public String getChangeType() { + return changeType; + } + + public void setChangeType(String changeType) { + this.changeType = changeType; + } + + public Integer getPoints() { + return points; + } + + public void setPoints(Integer points) { + this.points = points; + } + + public Integer getBalanceAfter() { + return balanceAfter; + } + + public void setBalanceAfter(Integer balanceAfter) { + this.balanceAfter = balanceAfter; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public Long getRelatedId() { + return relatedId; + } + + public void setRelatedId(Long relatedId) { + this.relatedId = relatedId; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/entity/PointsRuleEntity.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/entity/PointsRuleEntity.java new file mode 100644 index 0000000..5d45342 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/entity/PointsRuleEntity.java @@ -0,0 +1,77 @@ +package cn.novalon.gym.manage.coupon.points.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; + +import java.math.BigDecimal; + +@Table("points_rule") +public class PointsRuleEntity extends BaseEntity { + + @Column("rule_name") + private String ruleName; + + @Column("rule_type") + private String ruleType; + + @Column("points_value") + private Integer pointsValue; + + @Column("ratio") + private BigDecimal ratio; + + @Column("description") + private String description; + + @Column("status") + private String status; + + public String getRuleName() { + return ruleName; + } + + public void setRuleName(String ruleName) { + this.ruleName = ruleName; + } + + public String getRuleType() { + return ruleType; + } + + public void setRuleType(String ruleType) { + this.ruleType = ruleType; + } + + public Integer getPointsValue() { + return pointsValue; + } + + public void setPointsValue(Integer pointsValue) { + this.pointsValue = pointsValue; + } + + public BigDecimal getRatio() { + return ratio; + } + + public void setRatio(BigDecimal ratio) { + this.ratio = ratio; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/enums/PointsChangeType.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/enums/PointsChangeType.java new file mode 100644 index 0000000..d7f5350 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/enums/PointsChangeType.java @@ -0,0 +1,13 @@ +package cn.novalon.gym.manage.coupon.points.enums; + +/** + * 积分变动类型 + */ +public enum PointsChangeType { + /** 获得积分 */ + EARN, + /** 消耗积分 */ + SPEND, + /** 兑换 */ + EXCHANGE +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/enums/PointsProductStatus.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/enums/PointsProductStatus.java new file mode 100644 index 0000000..c84aec3 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/enums/PointsProductStatus.java @@ -0,0 +1,13 @@ +package cn.novalon.gym.manage.coupon.points.enums; + +/** + * 积分商城商品状态 + */ +public enum PointsProductStatus { + /** 草稿 */ + DRAFT, + /** 上架 */ + ACTIVE, + /** 下架 */ + OFFLINE +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/enums/PointsProductType.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/enums/PointsProductType.java new file mode 100644 index 0000000..8b51b51 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/enums/PointsProductType.java @@ -0,0 +1,15 @@ +package cn.novalon.gym.manage.coupon.points.enums; + +/** + * 积分商城商品类型 + */ +public enum PointsProductType { + /** 优惠券 */ + COUPON, + /** 课程 */ + COURSE, + /** 实物商品 */ + GOODS, + /** 其他 */ + OTHER +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/enums/PointsRuleStatus.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/enums/PointsRuleStatus.java new file mode 100644 index 0000000..821557f --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/enums/PointsRuleStatus.java @@ -0,0 +1,11 @@ +package cn.novalon.gym.manage.coupon.points.enums; + +/** + * 积分规则状态 + */ +public enum PointsRuleStatus { + /** 启用 */ + ACTIVE, + /** 停用 */ + INACTIVE +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/enums/PointsRuleType.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/enums/PointsRuleType.java new file mode 100644 index 0000000..a23467d --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/enums/PointsRuleType.java @@ -0,0 +1,15 @@ +package cn.novalon.gym.manage.coupon.points.enums; + +/** + * 积分规则类型 + */ +public enum PointsRuleType { + /** 签到 */ + SIGN_IN, + /** 消费 */ + CONSUME, + /** 推荐 */ + REFERRAL, + /** 手动发放 */ + MANUAL +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/handler/PointsHandler.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/handler/PointsHandler.java new file mode 100644 index 0000000..0cddafc --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/handler/PointsHandler.java @@ -0,0 +1,192 @@ +package cn.novalon.gym.manage.coupon.points.handler; + +import cn.novalon.gym.manage.coupon.points.domain.*; +import cn.novalon.gym.manage.coupon.points.service.IPointsService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +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 PointsHandler { + + private final IPointsService pointsService; + + public PointsHandler(IPointsService pointsService) { + this.pointsService = pointsService; + } + + // ========== Rules ========== + + @Operation(summary = "获取所有积分规则", description = "获取系统中所有积分规则列表") + public Mono getAllRules(ServerRequest request) { + return ServerResponse.ok() + .body(pointsService.findAllRules(), PointsRule.class); + } + + @Operation(summary = "创建积分规则", description = "创建新的积分规则") + public Mono createRule(ServerRequest request) { + return request.bodyToMono(PointsRule.class) + .flatMap(rule -> pointsService.createRule(rule) + .flatMap(created -> successResponse("积分规则创建成功", created)) + .onErrorResume(this::errorResponse)); + } + + @Operation(summary = "更新积分规则", description = "更新指定积分规则") + public Mono updateRule(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return request.bodyToMono(PointsRule.class) + .flatMap(rule -> pointsService.updateRule(id, rule) + .flatMap(updated -> successResponse("积分规则更新成功", updated)) + .onErrorResume(this::errorResponse)); + } + + @Operation(summary = "删除积分规则", description = "删除指定积分规则(软删除)") + public Mono deleteRule(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return pointsService.deleteRule(id) + .then(Mono.defer(() -> successResponse("积分规则删除成功", null))) + .onErrorResume(this::errorResponse); + } + + // ========== Products ========== + + @Operation(summary = "获取所有积分商品", description = "获取积分商城所有商品列表") + public Mono getAllProducts(ServerRequest request) { + return ServerResponse.ok() + .body(pointsService.findAllProducts(), PointsMallProduct.class); + } + + @Operation(summary = "搜索积分商品", description = "根据关键词和状态搜索积分商品") + public Mono searchProducts(ServerRequest request) { + String keyword = request.queryParam("keyword").orElse(""); + String status = request.queryParam("status").orElse(null); + return ServerResponse.ok() + .body(pointsService.searchProducts(keyword, status), PointsMallProduct.class); + } + + @Operation(summary = "创建积分商品", description = "创建新的积分商城商品") + public Mono createProduct(ServerRequest request) { + return request.bodyToMono(PointsMallProduct.class) + .flatMap(product -> pointsService.createProduct(product) + .flatMap(created -> successResponse("积分商品创建成功", created)) + .onErrorResume(this::errorResponse)); + } + + @Operation(summary = "更新积分商品", description = "更新指定积分商品") + public Mono updateProduct(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return request.bodyToMono(PointsMallProduct.class) + .flatMap(product -> pointsService.updateProduct(id, product) + .flatMap(updated -> successResponse("积分商品更新成功", updated)) + .onErrorResume(this::errorResponse)); + } + + @Operation(summary = "删除积分商品", description = "删除指定积分商品(软删除)") + public Mono deleteProduct(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return pointsService.deleteProduct(id) + .then(Mono.defer(() -> successResponse("积分商品删除成功", null))) + .onErrorResume(this::errorResponse); + } + + @Operation(summary = "上架积分商品", description = "发布积分商品使其可兑换") + public Mono publishProduct(ServerRequest request) { + Long id = Long.valueOf(request.pathVariable("id")); + return pointsService.publishProduct(id) + .flatMap(product -> successResponse("积分商品上架成功", product)) + .onErrorResume(this::errorResponse); + } + + // ========== Member ========== + + @Operation(summary = "查询会员积分余额", description = "根据会员ID查询积分余额") + public Mono getBalance(ServerRequest request) { + Long memberId = Long.valueOf(request.pathVariable("memberId")); + return pointsService.getBalance(memberId) + .flatMap(balance -> ServerResponse.ok().bodyValue(balance)) + .onErrorResume(this::errorResponse); + } + + @Operation(summary = "发放积分", description = "手动为会员发放积分") + public Mono earnPoints(ServerRequest request) { + return request.bodyToMono(EarnPointsRequest.class) + .flatMap(body -> { + if (body.getMemberId() == null) { + return errorResponse(new RuntimeException("memberId不能为空")); + } + return pointsService.earnPoints(body) + .flatMap(record -> successResponse("积分发放成功", record)) + .onErrorResume(this::errorResponse); + }); + } + + @Operation(summary = "每日签到", description = "会员每日签到获取积分") + public Mono signIn(ServerRequest request) { + return request.bodyToMono(Map.class) + .flatMap(body -> { + Object memberIdObj = body.get("memberId"); + if (memberIdObj == null) { + return errorResponse(new RuntimeException("memberId不能为空")); + } + Long memberId = ((Number) memberIdObj).longValue(); + return pointsService.signIn(memberId) + .flatMap(record -> successResponse("签到成功", record)) + .onErrorResume(this::errorResponse); + }); + } + + @Operation(summary = "积分兑换", description = "会员使用积分兑换商品") + public Mono exchange(ServerRequest request) { + return request.bodyToMono(ExchangePointsRequest.class) + .flatMap(body -> { + if (body.getMemberId() == null || body.getProductId() == null) { + return errorResponse(new RuntimeException("memberId和productId不能为空")); + } + return pointsService.exchange(body) + .flatMap(record -> successResponse("兑换成功", record)) + .onErrorResume(this::errorResponse); + }); + } + + // ========== Records ========== + + @Operation(summary = "查询会员积分记录", description = "根据会员ID查询积分变动记录") + public Mono getRecordsByMember(ServerRequest request) { + Long memberId = Long.valueOf(request.pathVariable("memberId")); + return ServerResponse.ok() + .body(pointsService.findRecordsByMember(memberId), PointsRecord.class); + } + + // ========== Statistics ========== + + @Operation(summary = "积分商城统计", description = "获取积分商城整体统计数据") + public Mono getStatistics(ServerRequest request) { + return pointsService.getStatistics() + .flatMap(stats -> ServerResponse.ok().bodyValue(stats)) + .onErrorResume(this::errorResponse); + } + + private Mono successResponse(String message, Object data) { + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", message); + if (data != null) { + response.put("data", data); + } + return ServerResponse.ok().bodyValue(response); + } + + private Mono errorResponse(Throwable 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-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/IMemberPointsRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/IMemberPointsRepository.java new file mode 100644 index 0000000..4f88b5e --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/IMemberPointsRepository.java @@ -0,0 +1,20 @@ +package cn.novalon.gym.manage.coupon.points.repository; + +import cn.novalon.gym.manage.coupon.points.domain.MemberPoints; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface IMemberPointsRepository { + + Mono findByMemberId(Long memberId); + + Flux findAll(); + + Mono save(MemberPoints memberPoints); + + Mono addPoints(Long memberId, int points); + + Mono deductPoints(Long memberId, int points); + + Mono countAll(); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/IPointsMallProductRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/IPointsMallProductRepository.java new file mode 100644 index 0000000..3745cf9 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/IPointsMallProductRepository.java @@ -0,0 +1,30 @@ +package cn.novalon.gym.manage.coupon.points.repository; + +import cn.novalon.gym.manage.coupon.points.domain.PointsMallProduct; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface IPointsMallProductRepository { + + Mono findById(Long id); + + Flux findAll(); + + Flux findByKeyword(String keyword); + + Flux findByStatus(String status); + + Mono save(PointsMallProduct product); + + Mono update(PointsMallProduct product); + + Mono deleteById(Long id); + + Mono updateStatus(Long id, String status); + + Mono decrementStock(Long id, int count); + + Mono countAll(); + + Mono countByStatus(String status); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/IPointsRecordRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/IPointsRecordRepository.java new file mode 100644 index 0000000..ffd429b --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/IPointsRecordRepository.java @@ -0,0 +1,22 @@ +package cn.novalon.gym.manage.coupon.points.repository; + +import cn.novalon.gym.manage.coupon.points.domain.PointsRecord; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +public interface IPointsRecordRepository { + + Mono save(PointsRecord record); + + Flux findByMemberId(Long memberId); + + Mono countTodaySignIn(Long memberId, LocalDateTime startOfDay); + + Mono sumEarnedPoints(); + + Mono sumSpentPoints(); + + Mono countExchanges(); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/IPointsRuleRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/IPointsRuleRepository.java new file mode 100644 index 0000000..ad7e243 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/IPointsRuleRepository.java @@ -0,0 +1,24 @@ +package cn.novalon.gym.manage.coupon.points.repository; + +import cn.novalon.gym.manage.coupon.points.domain.PointsRule; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface IPointsRuleRepository { + + Mono findById(Long id); + + Flux findAll(); + + Mono findActiveByRuleType(String ruleType); + + Mono save(PointsRule rule); + + Mono update(PointsRule rule); + + Mono deleteById(Long id); + + Mono countAll(); + + Mono countByStatus(String status); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/impl/MemberPointsRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/impl/MemberPointsRepository.java new file mode 100644 index 0000000..5218585 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/impl/MemberPointsRepository.java @@ -0,0 +1,62 @@ +package cn.novalon.gym.manage.coupon.points.repository.impl; + +import cn.novalon.gym.manage.coupon.points.converter.PointsConverter; +import cn.novalon.gym.manage.coupon.points.dao.MemberPointsDao; +import cn.novalon.gym.manage.coupon.points.domain.MemberPoints; +import cn.novalon.gym.manage.coupon.points.entity.MemberPointsEntity; +import cn.novalon.gym.manage.coupon.points.repository.IMemberPointsRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +@Repository +@Transactional +public class MemberPointsRepository implements IMemberPointsRepository { + + private final MemberPointsDao memberPointsDao; + private final PointsConverter converter; + + public MemberPointsRepository(MemberPointsDao memberPointsDao, PointsConverter converter) { + this.memberPointsDao = memberPointsDao; + this.converter = converter; + } + + @Override + public Mono findByMemberId(Long memberId) { + return memberPointsDao.findByMemberIdAndDeletedAtIsNull(memberId) + .map(converter::toMemberPoints); + } + + @Override + public Flux findAll() { + return memberPointsDao.findAllByDeletedAtIsNull() + .map(converter::toMemberPoints); + } + + @Override + public Mono save(MemberPoints memberPoints) { + MemberPointsEntity entity = converter.toMemberPointsEntity(memberPoints); + return memberPointsDao.save(entity) + .map(converter::toMemberPoints); + } + + @Override + public Mono addPoints(Long memberId, int points) { + return memberPointsDao.addPoints(memberId, points, LocalDateTime.now()) + .map(rows -> rows != null && rows > 0); + } + + @Override + public Mono deductPoints(Long memberId, int points) { + return memberPointsDao.deductPoints(memberId, points, LocalDateTime.now()) + .map(rows -> rows != null && rows > 0); + } + + @Override + public Mono countAll() { + return memberPointsDao.findAllByDeletedAtIsNull().count(); + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/impl/PointsMallProductRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/impl/PointsMallProductRepository.java new file mode 100644 index 0000000..30caf5e --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/impl/PointsMallProductRepository.java @@ -0,0 +1,122 @@ +package cn.novalon.gym.manage.coupon.points.repository.impl; + +import cn.novalon.gym.manage.coupon.points.converter.PointsConverter; +import cn.novalon.gym.manage.coupon.points.dao.PointsMallProductDao; +import cn.novalon.gym.manage.coupon.points.domain.PointsMallProduct; +import cn.novalon.gym.manage.coupon.points.entity.PointsMallProductEntity; +import cn.novalon.gym.manage.coupon.points.repository.IPointsMallProductRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +@Repository +@Transactional +public class PointsMallProductRepository implements IPointsMallProductRepository { + + private final PointsMallProductDao pointsMallProductDao; + private final PointsConverter converter; + + public PointsMallProductRepository(PointsMallProductDao pointsMallProductDao, PointsConverter converter) { + this.pointsMallProductDao = pointsMallProductDao; + this.converter = converter; + } + + @Override + public Mono findById(Long id) { + return pointsMallProductDao.findByIdIsAndDeletedAtIsNull(id) + .map(converter::toPointsMallProduct); + } + + @Override + public Flux findAll() { + return pointsMallProductDao.findAllByDeletedAtIsNull() + .map(converter::toPointsMallProduct); + } + + @Override + public Flux findByKeyword(String keyword) { + if (keyword == null || keyword.isEmpty()) { + return findAll(); + } + return pointsMallProductDao.findByProductNameContainingAndDeletedAtIsNull(keyword) + .map(converter::toPointsMallProduct); + } + + @Override + public Flux findByStatus(String status) { + if (status == null || status.isEmpty()) { + return findAll(); + } + return pointsMallProductDao.findByStatusAndDeletedAtIsNull(status) + .map(converter::toPointsMallProduct); + } + + @Override + public Mono save(PointsMallProduct product) { + PointsMallProductEntity entity = converter.toPointsMallProductEntity(product); + return pointsMallProductDao.save(entity) + .map(converter::toPointsMallProduct); + } + + @Override + public Mono update(PointsMallProduct product) { + return pointsMallProductDao.findByIdIsAndDeletedAtIsNull(product.getId()) + .switchIfEmpty(Mono.error(new RuntimeException("积分商品不存在"))) + .flatMap(existing -> { + existing.markNotNew(); + if (product.getProductName() != null) { + existing.setProductName(product.getProductName()); + } + if (product.getDescription() != null) { + existing.setDescription(product.getDescription()); + } + if (product.getProductType() != null) { + existing.setProductType(product.getProductType()); + } + if (product.getRelatedId() != null) { + existing.setRelatedId(product.getRelatedId()); + } + if (product.getPointsCost() != null) { + existing.setPointsCost(product.getPointsCost()); + } + if (product.getStock() != null) { + existing.setStock(product.getStock()); + } + if (product.getImageUrl() != null) { + existing.setImageUrl(product.getImageUrl()); + } + existing.setUpdatedAt(LocalDateTime.now()); + return pointsMallProductDao.save(existing); + }) + .map(converter::toPointsMallProduct); + } + + @Override + public Mono deleteById(Long id) { + return pointsMallProductDao.softDelete(id, LocalDateTime.now()).then(); + } + + @Override + public Mono updateStatus(Long id, String status) { + return pointsMallProductDao.updateStatus(id, status, LocalDateTime.now()).then(); + } + + @Override + public Mono decrementStock(Long id, int count) { + return pointsMallProductDao.decrementStockAndIncrementSold(id, count, LocalDateTime.now()) + .map(rows -> rows != null && rows > 0); + } + + @Override + public Mono countAll() { + return pointsMallProductDao.findAllByDeletedAtIsNull().count(); + } + + @Override + public Mono countByStatus(String status) { + return pointsMallProductDao.findByStatusAndDeletedAtIsNull(status).count(); + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/impl/PointsRecordRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/impl/PointsRecordRepository.java new file mode 100644 index 0000000..0291f3c --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/impl/PointsRecordRepository.java @@ -0,0 +1,65 @@ +package cn.novalon.gym.manage.coupon.points.repository.impl; + +import cn.novalon.gym.manage.coupon.points.converter.PointsConverter; +import cn.novalon.gym.manage.coupon.points.dao.PointsRecordDao; +import cn.novalon.gym.manage.coupon.points.domain.PointsRecord; +import cn.novalon.gym.manage.coupon.points.entity.PointsRecordEntity; +import cn.novalon.gym.manage.coupon.points.enums.PointsChangeType; +import cn.novalon.gym.manage.coupon.points.enums.PointsRuleType; +import cn.novalon.gym.manage.coupon.points.repository.IPointsRecordRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +@Repository +@Transactional +public class PointsRecordRepository implements IPointsRecordRepository { + + private final PointsRecordDao pointsRecordDao; + private final PointsConverter converter; + + public PointsRecordRepository(PointsRecordDao pointsRecordDao, PointsConverter converter) { + this.pointsRecordDao = pointsRecordDao; + this.converter = converter; + } + + @Override + public Mono save(PointsRecord record) { + PointsRecordEntity entity = converter.toPointsRecordEntity(record); + return pointsRecordDao.save(entity) + .map(converter::toPointsRecord); + } + + @Override + public Flux findByMemberId(Long memberId) { + return pointsRecordDao.findByMemberIdAndDeletedAtIsNullOrderByCreatedAtDesc(memberId) + .map(converter::toPointsRecord); + } + + @Override + public Mono countTodaySignIn(Long memberId, LocalDateTime startOfDay) { + return pointsRecordDao.countTodayByMemberAndSource( + memberId, + PointsRuleType.SIGN_IN.name(), + PointsChangeType.EARN.name(), + startOfDay); + } + + @Override + public Mono sumEarnedPoints() { + return pointsRecordDao.sumPositivePointsByChangeType(PointsChangeType.EARN.name()); + } + + @Override + public Mono sumSpentPoints() { + return pointsRecordDao.sumSpentPoints(); + } + + @Override + public Mono countExchanges() { + return pointsRecordDao.countByChangeTypeAndDeletedAtIsNull(PointsChangeType.EXCHANGE.name()); + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/impl/PointsRuleRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/impl/PointsRuleRepository.java new file mode 100644 index 0000000..68bb3c8 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/repository/impl/PointsRuleRepository.java @@ -0,0 +1,99 @@ +package cn.novalon.gym.manage.coupon.points.repository.impl; + +import cn.novalon.gym.manage.coupon.points.converter.PointsConverter; +import cn.novalon.gym.manage.coupon.points.dao.PointsRuleDao; +import cn.novalon.gym.manage.coupon.points.domain.PointsRule; +import cn.novalon.gym.manage.coupon.points.entity.PointsRuleEntity; +import cn.novalon.gym.manage.coupon.points.enums.PointsRuleStatus; +import cn.novalon.gym.manage.coupon.points.repository.IPointsRuleRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +@Repository +@Transactional +public class PointsRuleRepository implements IPointsRuleRepository { + + private final PointsRuleDao pointsRuleDao; + private final PointsConverter converter; + + public PointsRuleRepository(PointsRuleDao pointsRuleDao, PointsConverter converter) { + this.pointsRuleDao = pointsRuleDao; + this.converter = converter; + } + + @Override + public Mono findById(Long id) { + return pointsRuleDao.findByIdIsAndDeletedAtIsNull(id) + .map(converter::toPointsRule); + } + + @Override + public Flux findAll() { + return pointsRuleDao.findAllByDeletedAtIsNull() + .map(converter::toPointsRule); + } + + @Override + public Mono findActiveByRuleType(String ruleType) { + return pointsRuleDao.findFirstByRuleTypeAndStatusAndDeletedAtIsNull(ruleType, PointsRuleStatus.ACTIVE.name()) + .map(converter::toPointsRule); + } + + @Override + public Mono save(PointsRule rule) { + PointsRuleEntity entity = converter.toPointsRuleEntity(rule); + return pointsRuleDao.save(entity) + .map(converter::toPointsRule); + } + + @Override + public Mono update(PointsRule rule) { + return pointsRuleDao.findByIdIsAndDeletedAtIsNull(rule.getId()) + .switchIfEmpty(Mono.error(new RuntimeException("积分规则不存在"))) + .flatMap(existing -> { + existing.markNotNew(); + if (rule.getRuleName() != null) { + existing.setRuleName(rule.getRuleName()); + } + if (rule.getRuleType() != null) { + existing.setRuleType(rule.getRuleType()); + } + if (rule.getPointsValue() != null) { + existing.setPointsValue(rule.getPointsValue()); + } + if (rule.getRatio() != null) { + existing.setRatio(rule.getRatio()); + } + if (rule.getDescription() != null) { + existing.setDescription(rule.getDescription()); + } + if (rule.getStatus() != null) { + existing.setStatus(rule.getStatus()); + } + existing.setUpdatedAt(LocalDateTime.now()); + return pointsRuleDao.save(existing); + }) + .map(converter::toPointsRule); + } + + @Override + public Mono deleteById(Long id) { + return pointsRuleDao.softDelete(id, LocalDateTime.now()).then(); + } + + @Override + public Mono countAll() { + return pointsRuleDao.findAllByDeletedAtIsNull().count(); + } + + @Override + public Mono countByStatus(String status) { + return pointsRuleDao.findAllByDeletedAtIsNull() + .filter(rule -> status.equals(rule.getStatus())) + .count(); + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/service/IPointsService.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/service/IPointsService.java new file mode 100644 index 0000000..4f229c0 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/service/IPointsService.java @@ -0,0 +1,45 @@ +package cn.novalon.gym.manage.coupon.points.service; + +import cn.novalon.gym.manage.coupon.points.domain.*; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface IPointsService { + + // Rules + Flux findAllRules(); + + Mono createRule(PointsRule rule); + + Mono updateRule(Long id, PointsRule rule); + + Mono deleteRule(Long id); + + // Products + Flux findAllProducts(); + + Flux searchProducts(String keyword, String status); + + Mono createProduct(PointsMallProduct product); + + Mono updateProduct(Long id, PointsMallProduct product); + + Mono deleteProduct(Long id); + + Mono publishProduct(Long id); + + // Member points + Mono getBalance(Long memberId); + + Mono earnPoints(EarnPointsRequest request); + + Mono signIn(Long memberId); + + Mono exchange(ExchangePointsRequest request); + + // Records + Flux findRecordsByMember(Long memberId); + + // Statistics + Mono getStatistics(); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/service/impl/PointsService.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/service/impl/PointsService.java new file mode 100644 index 0000000..52e1968 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/points/service/impl/PointsService.java @@ -0,0 +1,373 @@ +package cn.novalon.gym.manage.coupon.points.service.impl; + +import cn.novalon.gym.manage.common.util.SnowflakeId; +import cn.novalon.gym.manage.coupon.points.domain.*; +import cn.novalon.gym.manage.coupon.points.enums.*; +import cn.novalon.gym.manage.coupon.points.repository.IMemberPointsRepository; +import cn.novalon.gym.manage.coupon.points.repository.IPointsMallProductRepository; +import cn.novalon.gym.manage.coupon.points.repository.IPointsRecordRepository; +import cn.novalon.gym.manage.coupon.points.repository.IPointsRuleRepository; +import cn.novalon.gym.manage.coupon.points.service.IPointsService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Service +public class PointsService implements IPointsService { + + private static final Logger logger = LoggerFactory.getLogger(PointsService.class); + + private final IPointsRuleRepository pointsRuleRepository; + private final IPointsMallProductRepository pointsMallProductRepository; + private final IMemberPointsRepository memberPointsRepository; + private final IPointsRecordRepository pointsRecordRepository; + + public PointsService(IPointsRuleRepository pointsRuleRepository, + IPointsMallProductRepository pointsMallProductRepository, + IMemberPointsRepository memberPointsRepository, + IPointsRecordRepository pointsRecordRepository) { + this.pointsRuleRepository = pointsRuleRepository; + this.pointsMallProductRepository = pointsMallProductRepository; + this.memberPointsRepository = memberPointsRepository; + this.pointsRecordRepository = pointsRecordRepository; + } + + @Override + public Flux findAllRules() { + return pointsRuleRepository.findAll(); + } + + @Override + public Mono createRule(PointsRule rule) { + return validateRule(rule) + .flatMap(validated -> { + validated.generateId(); + if (validated.getStatus() == null) { + validated.setStatus(PointsRuleStatus.ACTIVE.name()); + } + return pointsRuleRepository.save(validated); + }) + .doOnSuccess(r -> logger.info("积分规则创建成功 - id={}, name={}", r.getId(), r.getRuleName())); + } + + @Override + public Mono updateRule(Long id, PointsRule rule) { + return pointsRuleRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("积分规则不存在"))) + .flatMap(existing -> { + rule.setId(id); + return validateRule(rule) + .flatMap(pointsRuleRepository::update); + }) + .doOnSuccess(r -> logger.info("积分规则更新成功 - id={}", id)); + } + + @Override + public Mono deleteRule(Long id) { + return pointsRuleRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("积分规则不存在"))) + .flatMap(existing -> pointsRuleRepository.deleteById(id)) + .doOnSuccess(v -> logger.info("积分规则删除成功 - id={}", id)); + } + + @Override + public Flux findAllProducts() { + return pointsMallProductRepository.findAll(); + } + + @Override + public Flux searchProducts(String keyword, String status) { + Flux result = pointsMallProductRepository.findByKeyword(keyword); + if (status != null && !status.isEmpty()) { + result = result.filter(p -> status.equals(p.getStatus())); + } + return result; + } + + @Override + public Mono createProduct(PointsMallProduct product) { + return validateProduct(product) + .flatMap(validated -> { + validated.generateId(); + validated.setStatus(PointsProductStatus.DRAFT.name()); + validated.setSoldCount(0); + if (validated.getStock() == null) { + validated.setStock(-1); + } + return pointsMallProductRepository.save(validated); + }) + .doOnSuccess(p -> logger.info("积分商品创建成功 - id={}, name={}", p.getId(), p.getProductName())); + } + + @Override + public Mono updateProduct(Long id, PointsMallProduct product) { + return pointsMallProductRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("积分商品不存在"))) + .flatMap(existing -> { + if (!PointsProductStatus.DRAFT.name().equals(existing.getStatus())) { + return Mono.error(new RuntimeException("仅草稿状态的商品可编辑")); + } + product.setId(id); + return validateProduct(product) + .flatMap(pointsMallProductRepository::update); + }) + .doOnSuccess(p -> logger.info("积分商品更新成功 - id={}", id)); + } + + @Override + public Mono deleteProduct(Long id) { + return pointsMallProductRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("积分商品不存在"))) + .flatMap(existing -> { + if (!PointsProductStatus.DRAFT.name().equals(existing.getStatus())) { + return Mono.error(new RuntimeException("仅草稿状态的商品可删除")); + } + return pointsMallProductRepository.deleteById(id); + }) + .doOnSuccess(v -> logger.info("积分商品删除成功 - id={}", id)); + } + + @Override + public Mono publishProduct(Long id) { + return pointsMallProductRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("积分商品不存在"))) + .flatMap(existing -> { + if (!PointsProductStatus.DRAFT.name().equals(existing.getStatus()) + && !PointsProductStatus.OFFLINE.name().equals(existing.getStatus())) { + return Mono.error(new RuntimeException("当前状态不允许上架")); + } + return validateProduct(existing) + .flatMap(validated -> pointsMallProductRepository.updateStatus(id, PointsProductStatus.ACTIVE.name()) + .then(pointsMallProductRepository.findById(id))); + }) + .doOnSuccess(p -> logger.info("积分商品上架成功 - id={}", id)); + } + + @Override + public Mono getBalance(Long memberId) { + return memberPointsRepository.findByMemberId(memberId) + .switchIfEmpty(Mono.defer(() -> { + MemberPoints account = new MemberPoints(); + account.generateId(); + account.setMemberId(memberId); + account.setTotalPoints(0); + account.setAvailablePoints(0); + account.setUsedPoints(0); + return memberPointsRepository.save(account); + })); + } + + @Override + public Mono earnPoints(EarnPointsRequest request) { + if (request.getMemberId() == null) { + return Mono.error(new RuntimeException("会员ID不能为空")); + } + if (request.getPoints() == null || request.getPoints() <= 0) { + return Mono.error(new RuntimeException("积分数量必须大于0")); + } + + return ensureMemberAccount(request.getMemberId()) + .flatMap(account -> memberPointsRepository.addPoints(request.getMemberId(), request.getPoints()) + .flatMap(success -> { + if (!success) { + return Mono.error(new RuntimeException("积分发放失败")); + } + return memberPointsRepository.findByMemberId(request.getMemberId()); + }) + .flatMap(updated -> createRecord( + request.getMemberId(), + PointsChangeType.EARN.name(), + request.getPoints(), + updated.getAvailablePoints(), + request.getSource() != null ? request.getSource() : PointsRuleType.MANUAL.name(), + null, + request.getRemark()))); + } + + @Override + public Mono signIn(Long memberId) { + if (memberId == null) { + return Mono.error(new RuntimeException("会员ID不能为空")); + } + + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + + return pointsRecordRepository.countTodaySignIn(memberId, startOfDay) + .flatMap(count -> { + if (count > 0) { + return Mono.error(new RuntimeException("今日已签到")); + } + return pointsRuleRepository.findActiveByRuleType(PointsRuleType.SIGN_IN.name()) + .switchIfEmpty(Mono.error(new RuntimeException("签到积分规则未配置"))); + }) + .flatMap(rule -> { + int points = rule.getPointsValue() != null ? rule.getPointsValue() : 0; + if (points <= 0) { + return Mono.error(new RuntimeException("签到积分规则无效")); + } + EarnPointsRequest request = new EarnPointsRequest(); + request.setMemberId(memberId); + request.setPoints(points); + request.setSource(PointsRuleType.SIGN_IN.name()); + request.setRemark("每日签到"); + return earnPoints(request); + }); + } + + @Override + public Mono exchange(ExchangePointsRequest request) { + if (request.getMemberId() == null || request.getProductId() == null) { + return Mono.error(new RuntimeException("会员ID和商品ID不能为空")); + } + + return pointsMallProductRepository.findById(request.getProductId()) + .switchIfEmpty(Mono.error(new RuntimeException("积分商品不存在"))) + .flatMap(product -> { + if (!PointsProductStatus.ACTIVE.name().equals(product.getStatus())) { + return Mono.error(new RuntimeException("商品未上架")); + } + int cost = product.getPointsCost() != null ? product.getPointsCost() : 0; + if (cost <= 0) { + return Mono.error(new RuntimeException("商品积分价格无效")); + } + int stock = product.getStock() != null ? product.getStock() : -1; + if (stock == 0) { + return Mono.error(new RuntimeException("商品库存不足")); + } + + return ensureMemberAccount(request.getMemberId()) + .flatMap(account -> { + int available = account.getAvailablePoints() != null ? account.getAvailablePoints() : 0; + if (available < cost) { + return Mono.error(new RuntimeException("积分不足")); + } + return memberPointsRepository.deductPoints(request.getMemberId(), cost) + .flatMap(success -> { + if (!success) { + return Mono.error(new RuntimeException("积分扣减失败")); + } + return pointsMallProductRepository.decrementStock(request.getProductId(), 1) + .flatMap(stockUpdated -> { + if (!stockUpdated) { + return Mono.error(new RuntimeException("库存扣减失败")); + } + return memberPointsRepository.findByMemberId(request.getMemberId()); + }); + }) + .flatMap(updated -> createRecord( + request.getMemberId(), + PointsChangeType.EXCHANGE.name(), + -cost, + updated.getAvailablePoints(), + PointsChangeType.EXCHANGE.name(), + request.getProductId(), + "兑换商品:" + product.getProductName())); + }); + }) + .doOnSuccess(r -> logger.info("积分兑换成功 - memberId={}, productId={}", request.getMemberId(), request.getProductId())); + } + + @Override + public Flux findRecordsByMember(Long memberId) { + return pointsRecordRepository.findByMemberId(memberId); + } + + @Override + public Mono getStatistics() { + Mono totalRules = pointsRuleRepository.countAll(); + Mono activeRules = pointsRuleRepository.countByStatus(PointsRuleStatus.ACTIVE.name()); + Mono totalProducts = pointsMallProductRepository.countAll(); + Mono activeProducts = pointsMallProductRepository.countByStatus(PointsProductStatus.ACTIVE.name()); + Mono totalMemberAccounts = memberPointsRepository.countAll(); + Mono totalPointsIssued = pointsRecordRepository.sumEarnedPoints(); + Mono totalPointsUsed = pointsRecordRepository.sumSpentPoints(); + Mono totalExchanges = pointsRecordRepository.countExchanges(); + + return Mono.zip(totalRules, activeRules, totalProducts, activeProducts, + totalMemberAccounts, totalPointsIssued, totalPointsUsed, totalExchanges) + .map(tuple -> { + PointsStatistics stats = new PointsStatistics(); + stats.setTotalRules(tuple.getT1()); + stats.setActiveRules(tuple.getT2()); + stats.setTotalProducts(tuple.getT3()); + stats.setActiveProducts(tuple.getT4()); + stats.setTotalMemberAccounts(tuple.getT5()); + stats.setTotalPointsIssued(tuple.getT6()); + stats.setTotalPointsUsed(tuple.getT7()); + stats.setTotalExchanges(tuple.getT8()); + return stats; + }); + } + + private Mono ensureMemberAccount(Long memberId) { + return memberPointsRepository.findByMemberId(memberId) + .switchIfEmpty(Mono.defer(() -> { + MemberPoints account = new MemberPoints(); + account.generateId(); + account.setMemberId(memberId); + account.setTotalPoints(0); + account.setAvailablePoints(0); + account.setUsedPoints(0); + return memberPointsRepository.save(account); + })); + } + + private Mono createRecord(Long memberId, String changeType, int points, + int balanceAfter, String source, Long relatedId, String remark) { + PointsRecord record = new PointsRecord(); + record.setId(SnowflakeId.nextId()); + record.setMemberId(memberId); + record.setChangeType(changeType); + record.setPoints(points); + record.setBalanceAfter(balanceAfter); + record.setSource(source); + record.setRelatedId(relatedId); + record.setRemark(remark); + return pointsRecordRepository.save(record); + } + + private Mono validateRule(PointsRule rule) { + if (rule.getRuleName() == null || rule.getRuleName().isBlank()) { + return Mono.error(new RuntimeException("规则名称不能为空")); + } + if (rule.getRuleType() == null || rule.getRuleType().isBlank()) { + return Mono.error(new RuntimeException("规则类型不能为空")); + } + try { + PointsRuleType.valueOf(rule.getRuleType()); + } catch (IllegalArgumentException e) { + return Mono.error(new RuntimeException("无效的规则类型")); + } + if (rule.getStatus() != null) { + try { + PointsRuleStatus.valueOf(rule.getStatus()); + } catch (IllegalArgumentException e) { + return Mono.error(new RuntimeException("无效的规则状态")); + } + } + return Mono.just(rule); + } + + private Mono validateProduct(PointsMallProduct product) { + if (product.getProductName() == null || product.getProductName().isBlank()) { + return Mono.error(new RuntimeException("商品名称不能为空")); + } + if (product.getProductType() == null || product.getProductType().isBlank()) { + return Mono.error(new RuntimeException("商品类型不能为空")); + } + try { + PointsProductType.valueOf(product.getProductType()); + } catch (IllegalArgumentException e) { + return Mono.error(new RuntimeException("无效的商品类型")); + } + if (product.getPointsCost() == null || product.getPointsCost() <= 0) { + return Mono.error(new RuntimeException("兑换积分必须大于0")); + } + return Mono.just(product); + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/repository/ICouponTemplateRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/repository/ICouponTemplateRepository.java new file mode 100644 index 0000000..9e8b048 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/repository/ICouponTemplateRepository.java @@ -0,0 +1,34 @@ +package cn.novalon.gym.manage.coupon.repository; + +import cn.novalon.gym.manage.coupon.domain.CouponTemplate; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface ICouponTemplateRepository { + + Mono findById(Long id); + + Flux findAll(); + + Flux findAll(boolean includeDeleted); + + Flux findByKeyword(String keyword); + + Flux findByCouponType(String couponType); + + Flux findByStatus(String status); + + Flux findByCouponTypeAndKeyword(String couponType, String keyword, String status); + + Mono findByClaimCode(String claimCode); + + Mono save(CouponTemplate couponTemplate); + + Mono update(CouponTemplate couponTemplate); + + Mono deleteById(Long id); + + Mono updateStatus(Long id, String status); + + Mono incrementIssuedCount(Long id, int count); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/repository/IMemberCouponRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/repository/IMemberCouponRepository.java new file mode 100644 index 0000000..d9e2d9c --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/repository/IMemberCouponRepository.java @@ -0,0 +1,22 @@ +package cn.novalon.gym.manage.coupon.repository; + +import cn.novalon.gym.manage.coupon.domain.MemberCoupon; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +public interface IMemberCouponRepository { + + Mono save(MemberCoupon memberCoupon); + + Flux findByTemplateId(Long templateId); + + Flux findByMemberId(Long memberId); + + Mono countByTemplateIdAndMemberId(Long templateId, Long memberId); + + Mono countByTemplateIdAndStatus(Long templateId, String status); + + Mono expireAvailableCoupons(LocalDateTime now); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/repository/impl/CouponTemplateRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/repository/impl/CouponTemplateRepository.java new file mode 100644 index 0000000..9ae5d3a --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/repository/impl/CouponTemplateRepository.java @@ -0,0 +1,192 @@ +package cn.novalon.gym.manage.coupon.repository.impl; + +import cn.novalon.gym.manage.coupon.converter.CouponConverter; +import cn.novalon.gym.manage.coupon.dao.CouponTemplateDao; +import cn.novalon.gym.manage.coupon.domain.CouponTemplate; +import cn.novalon.gym.manage.coupon.entity.CouponTemplateEntity; +import cn.novalon.gym.manage.coupon.repository.ICouponTemplateRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +@Repository +@Transactional +public class CouponTemplateRepository implements ICouponTemplateRepository { + + private static final Logger logger = LoggerFactory.getLogger(CouponTemplateRepository.class); + + private final CouponTemplateDao couponTemplateDao; + private final CouponConverter converter; + + public CouponTemplateRepository(CouponTemplateDao couponTemplateDao, CouponConverter converter) { + this.couponTemplateDao = couponTemplateDao; + this.converter = converter; + } + + @Override + public Mono findById(Long id) { + return couponTemplateDao.findByIdIsAndDeletedAtIsNull(id) + .map(converter::toCouponTemplate); + } + + @Override + public Flux findAll() { + return couponTemplateDao.findAllByDeletedAtIsNull() + .map(converter::toCouponTemplate); + } + + @Override + public Flux findAll(boolean includeDeleted) { + if (includeDeleted) { + return couponTemplateDao.findAll() + .map(converter::toCouponTemplate); + } + return findAll(); + } + + @Override + public Flux findByKeyword(String keyword) { + if (keyword == null || keyword.isEmpty()) { + return findAll(false); + } + return couponTemplateDao.findByNameContainingAndDeletedAtIsNull(keyword) + .map(converter::toCouponTemplate); + } + + @Override + public Flux findByCouponType(String couponType) { + if (couponType == null || couponType.isEmpty()) { + return findAll(false); + } + return couponTemplateDao.findByCouponTypeAndDeletedAtIsNull(couponType) + .map(converter::toCouponTemplate); + } + + @Override + public Flux findByStatus(String status) { + if (status == null || status.isEmpty()) { + return findAll(false); + } + return couponTemplateDao.findByStatusAndDeletedAtIsNull(status) + .map(converter::toCouponTemplate); + } + + @Override + public Flux findByCouponTypeAndKeyword(String couponType, String keyword, String status) { + Flux result; + + if (couponType != null && !couponType.isEmpty()) { + result = findByCouponType(couponType); + } else if (status != null && !status.isEmpty()) { + result = findByStatus(status); + } else { + result = findAll(false); + } + + if (keyword != null && !keyword.isEmpty()) { + result = result.filter(coupon -> coupon.getName() != null + && coupon.getName().toLowerCase().contains(keyword.toLowerCase())); + } + + if (status != null && !status.isEmpty()) { + result = result.filter(coupon -> status.equals(coupon.getStatus())); + } + + if (couponType != null && !couponType.isEmpty()) { + result = result.filter(coupon -> couponType.equals(coupon.getCouponType())); + } + + return result; + } + + @Override + public Mono findByClaimCode(String claimCode) { + return couponTemplateDao.findByClaimCodeAndDeletedAtIsNull(claimCode) + .map(converter::toCouponTemplate); + } + + @Override + public Mono save(CouponTemplate couponTemplate) { + CouponTemplateEntity entity = converter.toCouponTemplateEntity(couponTemplate); + return couponTemplateDao.save(entity) + .map(converter::toCouponTemplate); + } + + @Override + public Mono update(CouponTemplate couponTemplate) { + return couponTemplateDao.findByIdIsAndDeletedAtIsNull(couponTemplate.getId()) + .switchIfEmpty(Mono.error(new RuntimeException("优惠券不存在"))) + .flatMap(existing -> { + existing.markNotNew(); + if (couponTemplate.getName() != null) { + existing.setName(couponTemplate.getName()); + } + if (couponTemplate.getDescription() != null) { + existing.setDescription(couponTemplate.getDescription()); + } + if (couponTemplate.getCouponType() != null) { + existing.setCouponType(couponTemplate.getCouponType()); + } + if (couponTemplate.getDiscountValue() != null) { + existing.setDiscountValue(couponTemplate.getDiscountValue()); + } + if (couponTemplate.getThresholdAmount() != null) { + existing.setThresholdAmount(couponTemplate.getThresholdAmount()); + } + if (couponTemplate.getValidityType() != null) { + existing.setValidityType(couponTemplate.getValidityType()); + } + if (couponTemplate.getStartTime() != null) { + existing.setStartTime(couponTemplate.getStartTime()); + } + if (couponTemplate.getEndTime() != null) { + existing.setEndTime(couponTemplate.getEndTime()); + } + if (couponTemplate.getValidDays() != null) { + existing.setValidDays(couponTemplate.getValidDays()); + } + if (couponTemplate.getApplyScope() != null) { + existing.setApplyScope(couponTemplate.getApplyScope()); + } + if (couponTemplate.getApplyProductIds() != null) { + existing.setApplyProductIds(couponTemplate.getApplyProductIds()); + } + if (couponTemplate.getTotalQuantity() != null) { + existing.setTotalQuantity(couponTemplate.getTotalQuantity()); + } + if (couponTemplate.getPerUserLimit() != null) { + existing.setPerUserLimit(couponTemplate.getPerUserLimit()); + } + if (couponTemplate.getStackable() != null) { + existing.setStackable(couponTemplate.getStackable()); + } + if (couponTemplate.getClaimCode() != null) { + existing.setClaimCode(couponTemplate.getClaimCode()); + } + existing.setUpdatedAt(LocalDateTime.now()); + return couponTemplateDao.save(existing); + }) + .map(converter::toCouponTemplate); + } + + @Override + public Mono deleteById(Long id) { + return couponTemplateDao.softDelete(id, LocalDateTime.now()) + .then(); + } + + @Override + public Mono updateStatus(Long id, String status) { + return couponTemplateDao.updateStatus(id, status, LocalDateTime.now()).then(); + } + + @Override + public Mono incrementIssuedCount(Long id, int count) { + return couponTemplateDao.incrementIssuedCount(id, count, LocalDateTime.now()).then(); + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/repository/impl/MemberCouponRepository.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/repository/impl/MemberCouponRepository.java new file mode 100644 index 0000000..10f4239 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/repository/impl/MemberCouponRepository.java @@ -0,0 +1,58 @@ +package cn.novalon.gym.manage.coupon.repository.impl; + +import cn.novalon.gym.manage.coupon.converter.CouponConverter; +import cn.novalon.gym.manage.coupon.dao.MemberCouponDao; +import cn.novalon.gym.manage.coupon.domain.MemberCoupon; +import cn.novalon.gym.manage.coupon.repository.IMemberCouponRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +@Repository +@Transactional +public class MemberCouponRepository implements IMemberCouponRepository { + + private final MemberCouponDao memberCouponDao; + private final CouponConverter converter; + + public MemberCouponRepository(MemberCouponDao memberCouponDao, CouponConverter converter) { + this.memberCouponDao = memberCouponDao; + this.converter = converter; + } + + @Override + public Mono save(MemberCoupon memberCoupon) { + return memberCouponDao.save(converter.toMemberCouponEntity(memberCoupon)) + .map(converter::toMemberCoupon); + } + + @Override + public Flux findByTemplateId(Long templateId) { + return memberCouponDao.findByTemplateIdAndDeletedAtIsNull(templateId) + .map(converter::toMemberCoupon); + } + + @Override + public Flux findByMemberId(Long memberId) { + return memberCouponDao.findByMemberIdAndDeletedAtIsNull(memberId) + .map(converter::toMemberCoupon); + } + + @Override + public Mono countByTemplateIdAndMemberId(Long templateId, Long memberId) { + return memberCouponDao.countByTemplateIdAndMemberIdAndDeletedAtIsNull(templateId, memberId); + } + + @Override + public Mono countByTemplateIdAndStatus(Long templateId, String status) { + return memberCouponDao.countByTemplateIdAndStatus(templateId, status); + } + + @Override + public Mono expireAvailableCoupons(LocalDateTime now) { + return memberCouponDao.expireAvailableCoupons(now); + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/scheduler/CouponExpireScheduler.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/scheduler/CouponExpireScheduler.java new file mode 100644 index 0000000..66f32ba --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/scheduler/CouponExpireScheduler.java @@ -0,0 +1,29 @@ +package cn.novalon.gym.manage.coupon.scheduler; + +import cn.novalon.gym.manage.coupon.service.IMemberCouponService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +public class CouponExpireScheduler { + + private static final Logger logger = LoggerFactory.getLogger(CouponExpireScheduler.class); + + private final IMemberCouponService memberCouponService; + + public CouponExpireScheduler(IMemberCouponService memberCouponService) { + this.memberCouponService = memberCouponService; + } + + @Scheduled(fixedRate = 60000) + public void expireCoupons() { + logger.debug("定时任务开始处理过期优惠券"); + memberCouponService.expireAvailableCoupons() + .subscribe( + count -> logger.debug("优惠券过期任务完成,更新 {} 张", count), + error -> logger.error("优惠券过期任务失败:{}", error.getMessage(), error) + ); + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/service/ICouponTemplateService.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/service/ICouponTemplateService.java new file mode 100644 index 0000000..8b6803e --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/service/ICouponTemplateService.java @@ -0,0 +1,43 @@ +package cn.novalon.gym.manage.coupon.service; + +import cn.novalon.gym.manage.common.dto.PageRequest; +import cn.novalon.gym.manage.common.dto.PageResponse; +import cn.novalon.gym.manage.coupon.domain.CouponStatistics; +import cn.novalon.gym.manage.coupon.domain.CouponTemplate; +import cn.novalon.gym.manage.coupon.domain.DistributeCouponRequest; +import cn.novalon.gym.manage.coupon.domain.DistributeCouponResult; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface ICouponTemplateService { + + Mono findById(Long id); + + Flux findAll(); + + Flux findAll(boolean includeDeleted); + + Flux findByKeyword(String keyword); + + Flux findByCouponType(String couponType); + + Flux findByStatus(String status); + + Flux findByCouponTypeAndKeyword(String couponType, String keyword, String status); + + Mono> findByPage(PageRequest pageRequest, String couponType, String status); + + Mono create(CouponTemplate couponTemplate); + + Mono update(Long id, CouponTemplate couponTemplate); + + Mono delete(Long id); + + Mono publish(Long id); + + Mono terminate(Long id); + + Mono distribute(Long id, DistributeCouponRequest request); + + Mono getStatistics(Long id); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/service/IMemberCouponService.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/service/IMemberCouponService.java new file mode 100644 index 0000000..e4a0bca --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/service/IMemberCouponService.java @@ -0,0 +1,16 @@ +package cn.novalon.gym.manage.coupon.service; + +import cn.novalon.gym.manage.coupon.domain.MemberCoupon; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface IMemberCouponService { + + Flux findByMemberId(Long memberId); + + Flux findByMemberIdAndStatus(Long memberId, String status); + + Mono claimByCode(Long memberId, String claimCode); + + Mono expireAvailableCoupons(); +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/service/impl/CouponTemplateService.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/service/impl/CouponTemplateService.java new file mode 100644 index 0000000..8542c66 --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/service/impl/CouponTemplateService.java @@ -0,0 +1,369 @@ +package cn.novalon.gym.manage.coupon.service.impl; + +import cn.novalon.gym.manage.common.dto.PageRequest; +import cn.novalon.gym.manage.common.dto.PageResponse; +import cn.novalon.gym.manage.common.util.SnowflakeId; +import cn.novalon.gym.manage.coupon.domain.CouponStatistics; +import cn.novalon.gym.manage.coupon.domain.CouponTemplate; +import cn.novalon.gym.manage.coupon.domain.DistributeCouponRequest; +import cn.novalon.gym.manage.coupon.domain.DistributeCouponResult; +import cn.novalon.gym.manage.coupon.domain.MemberCoupon; +import cn.novalon.gym.manage.coupon.enums.ApplyScope; +import cn.novalon.gym.manage.coupon.enums.CouponTemplateStatus; +import cn.novalon.gym.manage.coupon.enums.CouponType; +import cn.novalon.gym.manage.coupon.enums.DistributeType; +import cn.novalon.gym.manage.coupon.enums.MemberCouponStatus; +import cn.novalon.gym.manage.coupon.enums.ValidityType; +import cn.novalon.gym.manage.coupon.repository.ICouponTemplateRepository; +import cn.novalon.gym.manage.coupon.repository.IMemberCouponRepository; +import cn.novalon.gym.manage.coupon.service.ICouponTemplateService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; + +@Service +public class CouponTemplateService implements ICouponTemplateService { + + private static final Logger logger = LoggerFactory.getLogger(CouponTemplateService.class); + + private final ICouponTemplateRepository couponTemplateRepository; + private final IMemberCouponRepository memberCouponRepository; + + public CouponTemplateService(ICouponTemplateRepository couponTemplateRepository, + IMemberCouponRepository memberCouponRepository) { + this.couponTemplateRepository = couponTemplateRepository; + this.memberCouponRepository = memberCouponRepository; + } + + @Override + public Mono findById(Long id) { + return couponTemplateRepository.findById(id); + } + + @Override + public Flux findAll() { + return couponTemplateRepository.findAll(false); + } + + @Override + public Flux findAll(boolean includeDeleted) { + return couponTemplateRepository.findAll(includeDeleted); + } + + @Override + public Flux findByKeyword(String keyword) { + return couponTemplateRepository.findByKeyword(keyword); + } + + @Override + public Flux findByCouponType(String couponType) { + return couponTemplateRepository.findByCouponType(couponType); + } + + @Override + public Flux findByStatus(String status) { + return couponTemplateRepository.findByStatus(status); + } + + @Override + public Flux findByCouponTypeAndKeyword(String couponType, String keyword, String status) { + return couponTemplateRepository.findByCouponTypeAndKeyword(couponType, keyword, status); + } + + @Override + public Mono> findByPage(PageRequest pageRequest, String couponType, String status) { + int page = Math.max(pageRequest.getPage(), 0); + int size = pageRequest.getSize() <= 0 || pageRequest.getSize() > 100 ? 10 : pageRequest.getSize(); + String keyword = pageRequest.getKeyword(); + + return couponTemplateRepository.findByCouponTypeAndKeyword(couponType, keyword, status) + .sort(Comparator.comparing(CouponTemplate::getCreatedAt, + Comparator.nullsLast(Comparator.reverseOrder()))) + .collectList() + .map(list -> { + long total = list.size(); + int fromIndex = Math.min(page * size, list.size()); + int toIndex = Math.min(fromIndex + size, list.size()); + List content = list.subList(fromIndex, toIndex); + int totalPages = size == 0 ? 0 : (int) Math.ceil((double) total / size); + return new PageResponse<>(content, totalPages, total, page, size); + }); + } + + @Override + public Mono create(CouponTemplate couponTemplate) { + return validateTemplate(couponTemplate, true) + .flatMap(validated -> { + validated.generateId(); + validated.setStatus(CouponTemplateStatus.DRAFT.name()); + validated.setIssuedCount(0); + validated.setUsedCount(0); + applyDefaults(validated); + return couponTemplateRepository.save(validated); + }) + .doOnSuccess(coupon -> logger.info("优惠券创建成功 - id={}, name={}", coupon.getId(), coupon.getName())) + .doOnError(error -> logger.error("优惠券创建失败 - error: {}", error.getMessage())); + } + + @Override + public Mono update(Long id, CouponTemplate couponTemplate) { + return couponTemplateRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("优惠券不存在"))) + .flatMap(existing -> { + if (existing.getIssuedCount() != null && existing.getIssuedCount() > 0) { + return Mono.error(new RuntimeException("优惠券已发放,不可修改规则,如需调整请提前终止活动")); + } + if (!CouponTemplateStatus.DRAFT.name().equals(existing.getStatus())) { + return Mono.error(new RuntimeException("仅草稿状态的优惠券可编辑")); + } + couponTemplate.setId(id); + return validateTemplate(couponTemplate, false) + .flatMap(couponTemplateRepository::update); + }) + .doOnSuccess(coupon -> logger.info("优惠券更新成功 - id={}", id)) + .doOnError(error -> logger.error("优惠券更新失败 - id={}, error: {}", id, error.getMessage())); + } + + @Override + public Mono delete(Long id) { + return couponTemplateRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("优惠券不存在"))) + .flatMap(existing -> { + if (existing.getIssuedCount() != null && existing.getIssuedCount() > 0) { + return Mono.error(new RuntimeException("优惠券已发放,不可删除")); + } + return couponTemplateRepository.deleteById(id); + }) + .doOnSuccess(v -> logger.info("优惠券删除成功 - id={}", id)) + .doOnError(error -> logger.error("优惠券删除失败 - id={}, error: {}", id, error.getMessage())); + } + + @Override + public Mono publish(Long id) { + return couponTemplateRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("优惠券不存在"))) + .flatMap(existing -> { + if (!CouponTemplateStatus.DRAFT.name().equals(existing.getStatus()) + && !CouponTemplateStatus.TERMINATED.name().equals(existing.getStatus())) { + return Mono.error(new RuntimeException("当前状态不允许发布")); + } + return validateTemplate(existing, false) + .flatMap(validated -> couponTemplateRepository.updateStatus(id, CouponTemplateStatus.ACTIVE.name()) + .then(couponTemplateRepository.findById(id))); + }) + .doOnSuccess(coupon -> logger.info("优惠券发布成功 - id={}", id)) + .doOnError(error -> logger.error("优惠券发布失败 - id={}, error: {}", id, error.getMessage())); + } + + @Override + public Mono terminate(Long id) { + return couponTemplateRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("优惠券不存在"))) + .flatMap(existing -> { + if (!CouponTemplateStatus.ACTIVE.name().equals(existing.getStatus())) { + return Mono.error(new RuntimeException("仅进行中的优惠券可终止")); + } + return couponTemplateRepository.updateStatus(id, CouponTemplateStatus.TERMINATED.name()) + .then(couponTemplateRepository.findById(id)); + }) + .doOnSuccess(coupon -> logger.info("优惠券已终止 - id={}", id)) + .doOnError(error -> logger.error("优惠券终止失败 - id={}, error: {}", id, error.getMessage())); + } + + @Override + public Mono distribute(Long id, DistributeCouponRequest request) { + if (request.getMemberIds() == null || request.getMemberIds().isEmpty()) { + return Mono.error(new RuntimeException("请指定发放会员")); + } + + String distributeType = request.getDistributeType() != null + ? request.getDistributeType() : DistributeType.MANUAL.name(); + + return couponTemplateRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("优惠券不存在"))) + .flatMap(template -> { + if (!CouponTemplateStatus.ACTIVE.name().equals(template.getStatus())) { + return Mono.error(new RuntimeException("仅进行中的优惠券可发放")); + } + return Flux.fromIterable(request.getMemberIds()) + .flatMap(memberId -> distributeToMember(template, memberId, distributeType) + .thenReturn(1) + .onErrorResume(error -> { + logger.warn("向会员 {} 发放优惠券失败: {}", memberId, error.getMessage()); + return Mono.just(0); + })) + .reduce(0, Integer::sum) + .map(successCount -> { + int failCount = request.getMemberIds().size() - successCount; + String message = String.format("成功发放 %d 张,失败 %d 张", successCount, failCount); + return new DistributeCouponResult(successCount, failCount, message); + }); + }); + } + + @Override + public Mono getStatistics(Long id) { + return couponTemplateRepository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("优惠券不存在"))) + .flatMap(template -> { + Mono usedCount = memberCouponRepository.countByTemplateIdAndStatus(id, MemberCouponStatus.USED.name()); + Mono availableCount = memberCouponRepository.countByTemplateIdAndStatus(id, MemberCouponStatus.AVAILABLE.name()); + Mono expiredCount = memberCouponRepository.countByTemplateIdAndStatus(id, MemberCouponStatus.EXPIRED.name()); + + return Mono.zip(usedCount, availableCount, expiredCount) + .map(tuple -> { + long used = tuple.getT1(); + long available = tuple.getT2(); + long expired = tuple.getT3(); + long issued = template.getIssuedCount() != null ? template.getIssuedCount() : 0; + + CouponStatistics stats = new CouponStatistics(); + stats.setTemplateId(id); + stats.setIssuedCount(issued); + stats.setUsedCount(used); + stats.setAvailableCount(available); + stats.setExpiredCount(expired); + + if (issued > 0) { + stats.setRedemptionRate(BigDecimal.valueOf(used * 100.0 / issued) + .setScale(2, RoundingMode.HALF_UP)); + } else { + stats.setRedemptionRate(BigDecimal.ZERO); + } + + BigDecimal discountPerCoupon = calculateDiscountAmount(template); + stats.setTotalDiscountAmount(discountPerCoupon.multiply(BigDecimal.valueOf(used))); + + return stats; + }); + }); + } + + private Mono distributeToMember(CouponTemplate template, Long memberId, String distributeType) { + return memberCouponRepository.countByTemplateIdAndMemberId(template.getId(), memberId) + .flatMap(count -> { + int perUserLimit = template.getPerUserLimit() != null ? template.getPerUserLimit() : 1; + if (count >= perUserLimit) { + return Mono.error(new RuntimeException("会员已达领取上限")); + } + + int issued = template.getIssuedCount() != null ? template.getIssuedCount() : 0; + int total = template.getTotalQuantity() != null ? template.getTotalQuantity() : -1; + if (total >= 0 && issued >= total) { + return Mono.error(new RuntimeException("优惠券已发完")); + } + + MemberCoupon memberCoupon = new MemberCoupon(); + memberCoupon.setId(SnowflakeId.nextId()); + memberCoupon.setTemplateId(template.getId()); + memberCoupon.setMemberId(memberId); + memberCoupon.setCouponCode(generateCouponCode()); + memberCoupon.setStatus(MemberCouponStatus.AVAILABLE.name()); + memberCoupon.setDistributeType(distributeType); + memberCoupon.setReceivedAt(LocalDateTime.now()); + memberCoupon.setExpireAt(calculateExpireAt(template)); + + return memberCouponRepository.save(memberCoupon) + .then(couponTemplateRepository.incrementIssuedCount(template.getId(), 1)); + }); + } + + private LocalDateTime calculateExpireAt(CouponTemplate template) { + if (ValidityType.FIXED_DATE.name().equals(template.getValidityType())) { + return template.getEndTime() != null ? template.getEndTime() : LocalDateTime.now().plusDays(30); + } + int days = template.getValidDays() != null ? template.getValidDays() : 7; + return LocalDateTime.now().plusDays(days); + } + + private BigDecimal calculateDiscountAmount(CouponTemplate template) { + if (CouponType.DISCOUNT.name().equals(template.getCouponType())) { + return template.getThresholdAmount() != null + ? template.getThresholdAmount().multiply(BigDecimal.ONE.subtract(template.getDiscountValue())) + : BigDecimal.ZERO; + } + return template.getDiscountValue() != null ? template.getDiscountValue() : BigDecimal.ZERO; + } + + private String generateCouponCode() { + return "CPN" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(); + } + + private Mono validateTemplate(CouponTemplate template, boolean isCreate) { + if (template.getName() == null || template.getName().isBlank()) { + return Mono.error(new RuntimeException("优惠券名称不能为空")); + } + if (template.getCouponType() == null || template.getCouponType().isBlank()) { + return Mono.error(new RuntimeException("优惠券类型不能为空")); + } + try { + CouponType.valueOf(template.getCouponType()); + } catch (IllegalArgumentException e) { + return Mono.error(new RuntimeException("无效的优惠券类型")); + } + if (template.getDiscountValue() == null || template.getDiscountValue().compareTo(BigDecimal.ZERO) <= 0) { + return Mono.error(new RuntimeException("优惠值必须大于0")); + } + if (CouponType.DISCOUNT.name().equals(template.getCouponType()) + && template.getDiscountValue().compareTo(BigDecimal.ONE) >= 0) { + return Mono.error(new RuntimeException("折扣券优惠比例必须在0到1之间")); + } + if (template.getValidityType() == null || template.getValidityType().isBlank()) { + return Mono.error(new RuntimeException("有效期类型不能为空")); + } + try { + ValidityType.valueOf(template.getValidityType()); + } catch (IllegalArgumentException e) { + return Mono.error(new RuntimeException("无效的有效期类型")); + } + if (ValidityType.FIXED_DATE.name().equals(template.getValidityType())) { + if (template.getStartTime() == null || template.getEndTime() == null) { + return Mono.error(new RuntimeException("固定日期类型需设置开始和结束时间")); + } + if (template.getEndTime().isBefore(template.getStartTime())) { + return Mono.error(new RuntimeException("结束时间不能早于开始时间")); + } + } else if (template.getValidDays() == null || template.getValidDays() <= 0) { + return Mono.error(new RuntimeException("领取后有效天数必须大于0")); + } + + if (template.getClaimCode() != null && !template.getClaimCode().isBlank()) { + return couponTemplateRepository.findByClaimCode(template.getClaimCode()) + .flatMap(existing -> { + if (!isCreate && existing.getId().equals(template.getId())) { + return Mono.just(template); + } + return Mono.error(new RuntimeException("领取码已存在")); + }) + .switchIfEmpty(Mono.just(template)); + } + return Mono.just(template); + } + + private void applyDefaults(CouponTemplate template) { + if (template.getThresholdAmount() == null) { + template.setThresholdAmount(BigDecimal.ZERO); + } + if (template.getApplyScope() == null) { + template.setApplyScope(ApplyScope.ALL.name()); + } + if (template.getTotalQuantity() == null) { + template.setTotalQuantity(-1); + } + if (template.getPerUserLimit() == null) { + template.setPerUserLimit(1); + } + if (template.getStackable() == null) { + template.setStackable(false); + } + } +} diff --git a/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/service/impl/MemberCouponService.java b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/service/impl/MemberCouponService.java new file mode 100644 index 0000000..7bab12c --- /dev/null +++ b/gym-manage-api/gym-coupon/src/main/java/cn/novalon/gym/manage/coupon/service/impl/MemberCouponService.java @@ -0,0 +1,117 @@ +package cn.novalon.gym.manage.coupon.service.impl; + +import cn.novalon.gym.manage.common.util.SnowflakeId; +import cn.novalon.gym.manage.coupon.domain.CouponTemplate; +import cn.novalon.gym.manage.coupon.domain.MemberCoupon; +import cn.novalon.gym.manage.coupon.enums.CouponTemplateStatus; +import cn.novalon.gym.manage.coupon.enums.DistributeType; +import cn.novalon.gym.manage.coupon.enums.MemberCouponStatus; +import cn.novalon.gym.manage.coupon.enums.ValidityType; +import cn.novalon.gym.manage.coupon.repository.ICouponTemplateRepository; +import cn.novalon.gym.manage.coupon.repository.IMemberCouponRepository; +import cn.novalon.gym.manage.coupon.service.IMemberCouponService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Service +public class MemberCouponService implements IMemberCouponService { + + private static final Logger logger = LoggerFactory.getLogger(MemberCouponService.class); + + private final IMemberCouponRepository memberCouponRepository; + private final ICouponTemplateRepository couponTemplateRepository; + + public MemberCouponService(IMemberCouponRepository memberCouponRepository, + ICouponTemplateRepository couponTemplateRepository) { + this.memberCouponRepository = memberCouponRepository; + this.couponTemplateRepository = couponTemplateRepository; + } + + @Override + public Flux findByMemberId(Long memberId) { + return memberCouponRepository.findByMemberId(memberId); + } + + @Override + public Flux findByMemberIdAndStatus(Long memberId, String status) { + return memberCouponRepository.findByMemberId(memberId) + .filter(coupon -> status == null || status.isBlank() || status.equals(coupon.getStatus())); + } + + @Override + public Mono claimByCode(Long memberId, String claimCode) { + if (memberId == null) { + return Mono.error(new RuntimeException("会员ID不能为空")); + } + if (claimCode == null || claimCode.isBlank()) { + return Mono.error(new RuntimeException("领取码不能为空")); + } + + return couponTemplateRepository.findByClaimCode(claimCode.trim()) + .switchIfEmpty(Mono.error(new RuntimeException("领取码无效"))) + .flatMap(template -> issueCoupon(template, memberId, DistributeType.CLAIM.name())) + .doOnSuccess(c -> logger.info("领取码兑换成功 - memberId={}, couponId={}", memberId, c.getId())); + } + + @Override + public Mono expireAvailableCoupons() { + return memberCouponRepository.expireAvailableCoupons(LocalDateTime.now()) + .doOnSuccess(count -> { + if (count != null && count > 0) { + logger.info("优惠券过期处理完成,更新 {} 张", count); + } + }); + } + + private Mono issueCoupon(CouponTemplate template, Long memberId, String distributeType) { + if (!CouponTemplateStatus.ACTIVE.name().equals(template.getStatus())) { + return Mono.error(new RuntimeException("优惠券活动未进行中")); + } + + return memberCouponRepository.countByTemplateIdAndMemberId(template.getId(), memberId) + .flatMap(count -> { + int perUserLimit = template.getPerUserLimit() != null ? template.getPerUserLimit() : 1; + if (count >= perUserLimit) { + return Mono.error(new RuntimeException("已达领取上限")); + } + + int issued = template.getIssuedCount() != null ? template.getIssuedCount() : 0; + int total = template.getTotalQuantity() != null ? template.getTotalQuantity() : -1; + if (total >= 0 && issued >= total) { + return Mono.error(new RuntimeException("优惠券已领完")); + } + + MemberCoupon memberCoupon = new MemberCoupon(); + memberCoupon.setId(SnowflakeId.nextId()); + memberCoupon.setTemplateId(template.getId()); + memberCoupon.setMemberId(memberId); + memberCoupon.setCouponCode(generateCouponCode()); + memberCoupon.setStatus(MemberCouponStatus.AVAILABLE.name()); + memberCoupon.setDistributeType(distributeType); + memberCoupon.setReceivedAt(LocalDateTime.now()); + memberCoupon.setExpireAt(calculateExpireAt(template)); + + return memberCouponRepository.save(memberCoupon) + .flatMap(saved -> couponTemplateRepository.incrementIssuedCount(template.getId(), 1) + .thenReturn(saved)); + }); + } + + private LocalDateTime calculateExpireAt(CouponTemplate template) { + if (ValidityType.FIXED_DATE.name().equals(template.getValidityType())) { + return template.getEndTime() != null ? template.getEndTime() : LocalDateTime.now().plusDays(30); + } + int days = template.getValidDays() != null ? template.getValidDays() : 7; + return LocalDateTime.now().plusDays(days); + } + + private String generateCouponCode() { + return "CPN" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(); + } +} diff --git a/gym-manage-api/manage-app/pom.xml b/gym-manage-api/manage-app/pom.xml index 5682d05..6d34aed 100644 --- a/gym-manage-api/manage-app/pom.xml +++ b/gym-manage-api/manage-app/pom.xml @@ -159,6 +159,11 @@ 1.0.0 compile + + cn.novalon.gym.manage + gym-coupon + ${project.version} + diff --git a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/ManageApplication.java b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/ManageApplication.java index 7d11ca4..bce318f 100644 --- a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/ManageApplication.java +++ b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/ManageApplication.java @@ -24,7 +24,12 @@ import java.util.List; "cn.novalon.gym.manage.gymmembercard.dao", "cn.novalon.gym.manage.member.repository", "cn.novalon.gym.manage.groupcourse.dao", - "cn.novalon.gym.manage.checkIn.repository" + "cn.novalon.gym.manage.checkIn.repository", + "cn.novalon.gym.manage.coupon.dao", + "cn.novalon.gym.manage.coupon.groupbuy.dao", + "cn.novalon.gym.manage.coupon.flashsale.dao", + "cn.novalon.gym.manage.coupon.marketing.dao", + "cn.novalon.gym.manage.coupon.points.dao" }) @EnableReactiveElasticsearchRepositories(basePackages = "cn.novalon.gym.manage.member.es.repository") public class ManageApplication { 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 84512fa..175db5a 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 @@ -1,6 +1,12 @@ package cn.novalon.gym.manage.app.config; +import cn.novalon.gym.manage.coupon.flashsale.handler.FlashSaleHandler; +import cn.novalon.gym.manage.coupon.groupbuy.handler.GroupBuyHandler; +import cn.novalon.gym.manage.coupon.handler.CouponHandler; +import cn.novalon.gym.manage.coupon.handler.MemberCouponHandler; +import cn.novalon.gym.manage.coupon.marketing.handler.MarketingActivityHandler; +import cn.novalon.gym.manage.coupon.points.handler.PointsHandler; import cn.novalon.gym.manage.checkIn.handler.CheckInHandler; import cn.novalon.gym.manage.datacount.handler.DataStatisticsHandler; import cn.novalon.gym.manage.file.handler.SysFileHandler; @@ -74,7 +80,13 @@ public class SystemRouter { GroupCourseTypeHandler groupCourseTypeHandler, CourseLabelHandler courseLabelHandler, CheckInHandler checkInHandler, - DataStatisticsHandler dataStatisticsHandler) { + DataStatisticsHandler dataStatisticsHandler, + CouponHandler couponHandler, + MemberCouponHandler memberCouponHandler, + GroupBuyHandler groupBuyHandler, + FlashSaleHandler flashSaleHandler, + MarketingActivityHandler marketingActivityHandler, + PointsHandler pointsHandler) { return route() // ========== 诊断路由 ========== @@ -335,6 +347,97 @@ public class SystemRouter { .GET("/api/datacount/signin", dataStatisticsHandler::getSignInStatistics) .GET("/api/datacount/history", dataStatisticsHandler::queryHistoricalStatistics) .GET("/api/datacount/export", dataStatisticsHandler::exportStatistics) + + // ======================================== + // ========== 优惠券模块路由 =============== + // ======================================== + .GET("/api/coupon/list", couponHandler::getAllCoupons) + .POST("/api/coupon/page", couponHandler::getCouponsByPage) + .GET("/api/coupon/search", couponHandler::searchCoupons) + .GET("/api/coupon/member/{memberId}/coupons", memberCouponHandler::getMemberCoupons) + .POST("/api/coupon/claim", memberCouponHandler::claimCoupon) + .GET("/api/coupon/{id}/statistics", couponHandler::getCouponStatistics) + .POST("/api/coupon/{id}/publish", couponHandler::publishCoupon) + .POST("/api/coupon/{id}/terminate", couponHandler::terminateCoupon) + .POST("/api/coupon/{id}/distribute", couponHandler::distributeCoupon) + .GET("/api/coupon/{id}", couponHandler::getCouponById) + .POST("/api/coupon", couponHandler::createCoupon) + .PUT("/api/coupon/{id}", couponHandler::updateCoupon) + .DELETE("/api/coupon/{id}", couponHandler::deleteCoupon) + + // ======================================== + // ========== 拼团模块路由 ================= + // ======================================== + .GET("/api/coupon/groupBuy/list", groupBuyHandler::getAllActivities) + .POST("/api/coupon/groupBuy/page", groupBuyHandler::getActivitiesByPage) + .GET("/api/coupon/groupBuy/search", groupBuyHandler::searchActivities) + .POST("/api/coupon/groupBuy/teams", groupBuyHandler::createTeam) + .GET("/api/coupon/groupBuy/teams", groupBuyHandler::getTeams) + .POST("/api/coupon/groupBuy/teams/{teamId}/join", groupBuyHandler::joinTeam) + .POST("/api/coupon/groupBuy/teams/{teamId}/cancel", groupBuyHandler::cancelTeam) + .GET("/api/coupon/groupBuy/teams/{teamId}", groupBuyHandler::getTeamById) + .GET("/api/coupon/groupBuy/{id}/statistics", groupBuyHandler::getStatistics) + .POST("/api/coupon/groupBuy/{id}/publish", groupBuyHandler::publishActivity) + .POST("/api/coupon/groupBuy/{id}/terminate", groupBuyHandler::terminateActivity) + .GET("/api/coupon/groupBuy/{id}", groupBuyHandler::getActivityById) + .POST("/api/coupon/groupBuy", groupBuyHandler::createActivity) + .PUT("/api/coupon/groupBuy/{id}", groupBuyHandler::updateActivity) + .DELETE("/api/coupon/groupBuy/{id}", groupBuyHandler::deleteActivity) + + // ======================================== + // ========== 秒杀模块路由 ================= + // ======================================== + .GET("/api/coupon/flashSale/list", flashSaleHandler::getAllActivities) + .POST("/api/coupon/flashSale/page", flashSaleHandler::getActivitiesByPage) + .GET("/api/coupon/flashSale/search", flashSaleHandler::searchActivities) + .GET("/api/coupon/flashSale/items", flashSaleHandler::getItems) + .POST("/api/coupon/flashSale/items", flashSaleHandler::createItem) + .PUT("/api/coupon/flashSale/items/{id}", flashSaleHandler::updateItem) + .POST("/api/coupon/flashSale/grab", flashSaleHandler::grab) + .POST("/api/coupon/flashSale/orders/{orderId}/pay", flashSaleHandler::payOrder) + .POST("/api/coupon/flashSale/orders/{orderId}/cancel", flashSaleHandler::cancelOrder) + .GET("/api/coupon/flashSale/orders/member/{memberId}", flashSaleHandler::getOrdersByMember) + .GET("/api/coupon/flashSale/{id}/statistics", flashSaleHandler::getStatistics) + .POST("/api/coupon/flashSale/{id}/publish", flashSaleHandler::publishActivity) + .POST("/api/coupon/flashSale/{id}/terminate", flashSaleHandler::terminateActivity) + .GET("/api/coupon/flashSale/{id}", flashSaleHandler::getActivityById) + .POST("/api/coupon/flashSale", flashSaleHandler::createActivity) + .PUT("/api/coupon/flashSale/{id}", flashSaleHandler::updateActivity) + .DELETE("/api/coupon/flashSale/{id}", flashSaleHandler::deleteActivity) + + // ======================================== + // ========== 会员营销活动路由 ============= + // ======================================== + .GET("/api/coupon/marketing/list", marketingActivityHandler::getAllActivities) + .POST("/api/coupon/marketing/page", marketingActivityHandler::getActivitiesByPage) + .GET("/api/coupon/marketing/search", marketingActivityHandler::searchActivities) + .GET("/api/coupon/marketing/{id}/statistics", marketingActivityHandler::getActivityStatistics) + .POST("/api/coupon/marketing/{id}/publish", marketingActivityHandler::publishActivity) + .POST("/api/coupon/marketing/{id}/terminate", marketingActivityHandler::terminateActivity) + .GET("/api/coupon/marketing/{id}", marketingActivityHandler::getActivityById) + .POST("/api/coupon/marketing", marketingActivityHandler::createActivity) + .PUT("/api/coupon/marketing/{id}", marketingActivityHandler::updateActivity) + .DELETE("/api/coupon/marketing/{id}", marketingActivityHandler::deleteActivity) + + // ======================================== + // ========== 积分商城路由 ================= + // ======================================== + .GET("/api/coupon/points/rules/list", pointsHandler::getAllRules) + .POST("/api/coupon/points/rules", pointsHandler::createRule) + .PUT("/api/coupon/points/rules/{id}", pointsHandler::updateRule) + .DELETE("/api/coupon/points/rules/{id}", pointsHandler::deleteRule) + .GET("/api/coupon/points/products/list", pointsHandler::getAllProducts) + .GET("/api/coupon/points/products/search", pointsHandler::searchProducts) + .POST("/api/coupon/points/products", pointsHandler::createProduct) + .PUT("/api/coupon/points/products/{id}", pointsHandler::updateProduct) + .DELETE("/api/coupon/points/products/{id}", pointsHandler::deleteProduct) + .POST("/api/coupon/points/products/{id}/publish", pointsHandler::publishProduct) + .GET("/api/coupon/points/balance/{memberId}", pointsHandler::getBalance) + .POST("/api/coupon/points/earn", pointsHandler::earnPoints) + .POST("/api/coupon/points/signIn", pointsHandler::signIn) + .POST("/api/coupon/points/exchange", pointsHandler::exchange) + .GET("/api/coupon/points/records/member/{memberId}", pointsHandler::getRecordsByMember) + .GET("/api/coupon/points/statistics", pointsHandler::getStatistics) .build(); } } diff --git a/gym-manage-api/manage-db/src/main/resources/db/migration/V17__Create_Coupon_tables.sql b/gym-manage-api/manage-db/src/main/resources/db/migration/V17__Create_Coupon_tables.sql new file mode 100644 index 0000000..0dfbbef --- /dev/null +++ b/gym-manage-api/manage-db/src/main/resources/db/migration/V17__Create_Coupon_tables.sql @@ -0,0 +1,74 @@ +-- ============================================ +-- 优惠券模块表结构(模块5.1 优惠券管理) +-- ============================================ + +-- 优惠券模板表 +CREATE TABLE IF NOT EXISTS coupon_template ( + id BIGINT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + coupon_type VARCHAR(20) NOT NULL, + discount_value DECIMAL(10, 2) NOT NULL, + threshold_amount DECIMAL(10, 2) DEFAULT 0, + validity_type VARCHAR(20) NOT NULL, + start_time TIMESTAMP, + end_time TIMESTAMP, + valid_days INTEGER, + apply_scope VARCHAR(20) NOT NULL DEFAULT 'ALL', + apply_product_ids TEXT, + total_quantity INTEGER DEFAULT -1, + per_user_limit INTEGER DEFAULT 1, + stackable BOOLEAN DEFAULT FALSE, + claim_code VARCHAR(50), + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + issued_count INTEGER DEFAULT 0, + used_count INTEGER DEFAULT 0, + create_by VARCHAR(50), + update_by VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP +); + +COMMENT ON TABLE coupon_template IS '优惠券模板表'; +COMMENT ON COLUMN coupon_template.coupon_type IS '类型:CASH-满减券, DISCOUNT-折扣券, COURSE-课程券, EXPERIENCE-体验券'; +COMMENT ON COLUMN coupon_template.discount_value IS '优惠值:满减/课程/体验为金额,折扣券为折扣比例(如0.9表示9折)'; +COMMENT ON COLUMN coupon_template.threshold_amount IS '使用门槛金额(满X元可用)'; +COMMENT ON COLUMN coupon_template.validity_type IS '有效期类型:FIXED_DATE-固定日期, DAYS_AFTER_CLAIM-领取后X天'; +COMMENT ON COLUMN coupon_template.apply_scope IS '适用商品范围:ALL-全场通用, SPECIFIC-指定商品'; +COMMENT ON COLUMN coupon_template.total_quantity IS '发放总量,-1表示不限量'; +COMMENT ON COLUMN coupon_template.status IS '状态:DRAFT-草稿, ACTIVE-进行中, TERMINATED-已终止, EXPIRED-已过期'; + +CREATE INDEX idx_coupon_template_status ON coupon_template(status); +CREATE INDEX idx_coupon_template_type ON coupon_template(coupon_type); +CREATE INDEX idx_coupon_template_name ON coupon_template(name); +CREATE UNIQUE INDEX idx_coupon_template_claim_code ON coupon_template(claim_code) WHERE claim_code IS NOT NULL AND deleted_at IS NULL; + +-- 会员优惠券表 +CREATE TABLE IF NOT EXISTS member_coupon ( + id BIGINT PRIMARY KEY, + template_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + coupon_code VARCHAR(64) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE', + distribute_type VARCHAR(20) NOT NULL DEFAULT 'MANUAL', + received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expire_at TIMESTAMP NOT NULL, + used_at TIMESTAMP, + order_id BIGINT, + create_by VARCHAR(50), + update_by VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + CONSTRAINT fk_member_coupon_template FOREIGN KEY (template_id) REFERENCES coupon_template(id) +); + +COMMENT ON TABLE member_coupon IS '会员优惠券表'; +COMMENT ON COLUMN member_coupon.status IS '状态:AVAILABLE-可用, USED-已使用, EXPIRED-已过期, INVALID-已作废'; +COMMENT ON COLUMN member_coupon.distribute_type IS '发放方式:MANUAL-手动, BATCH-批量, AUTO-自动, CLAIM-领取码'; + +CREATE INDEX idx_member_coupon_template_id ON member_coupon(template_id); +CREATE INDEX idx_member_coupon_member_id ON member_coupon(member_id); +CREATE INDEX idx_member_coupon_status ON member_coupon(status); +CREATE UNIQUE INDEX idx_member_coupon_code ON member_coupon(coupon_code); diff --git a/gym-manage-api/manage-db/src/main/resources/db/migration/V18__Create_Marketing_Module_tables.sql b/gym-manage-api/manage-db/src/main/resources/db/migration/V18__Create_Marketing_Module_tables.sql new file mode 100644 index 0000000..7bf2ead --- /dev/null +++ b/gym-manage-api/manage-db/src/main/resources/db/migration/V18__Create_Marketing_Module_tables.sql @@ -0,0 +1,219 @@ +-- ============================================ +-- 营销模块 5.2~5.5 表结构 +-- ============================================ + +-- 5.2 拼团活动 +CREATE TABLE IF NOT EXISTS group_buy_activity ( + id BIGINT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + product_type VARCHAR(20) NOT NULL, + product_id BIGINT NOT NULL, + product_name VARCHAR(200), + original_price DECIMAL(10, 2) NOT NULL, + group_price DECIMAL(10, 2) NOT NULL, + required_members INTEGER NOT NULL DEFAULT 2, + valid_hours INTEGER NOT NULL DEFAULT 24, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NOT NULL, + stock INTEGER DEFAULT -1, + sold_count INTEGER DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + create_by VARCHAR(50), + update_by VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS group_buy_team ( + id BIGINT PRIMARY KEY, + activity_id BIGINT NOT NULL, + leader_member_id BIGINT NOT NULL, + required_members INTEGER NOT NULL, + current_members INTEGER NOT NULL DEFAULT 1, + status VARCHAR(20) NOT NULL DEFAULT 'FORMING', + expire_at TIMESTAMP NOT NULL, + success_at TIMESTAMP, + create_by VARCHAR(50), + update_by VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + CONSTRAINT fk_group_buy_team_activity FOREIGN KEY (activity_id) REFERENCES group_buy_activity(id) +); + +CREATE TABLE IF NOT EXISTS group_buy_participant ( + id BIGINT PRIMARY KEY, + team_id BIGINT NOT NULL, + activity_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + is_leader BOOLEAN DEFAULT FALSE, + status VARCHAR(20) NOT NULL DEFAULT 'JOINED', + join_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + create_by VARCHAR(50), + update_by VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + CONSTRAINT fk_group_buy_participant_team FOREIGN KEY (team_id) REFERENCES group_buy_team(id) +); + +CREATE INDEX idx_group_buy_activity_status ON group_buy_activity(status); +CREATE INDEX idx_group_buy_team_activity ON group_buy_team(activity_id); +CREATE INDEX idx_group_buy_team_status ON group_buy_team(status); +CREATE INDEX idx_group_buy_participant_member ON group_buy_participant(member_id); + +-- 5.3 秒杀活动 +CREATE TABLE IF NOT EXISTS flash_sale_activity ( + id BIGINT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NOT NULL, + pay_timeout_minutes INTEGER DEFAULT 5, + per_user_limit INTEGER DEFAULT 1, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + create_by VARCHAR(50), + update_by VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS flash_sale_item ( + id BIGINT PRIMARY KEY, + activity_id BIGINT NOT NULL, + product_type VARCHAR(20) NOT NULL, + product_id BIGINT NOT NULL, + product_name VARCHAR(200) NOT NULL, + original_price DECIMAL(10, 2) NOT NULL, + seckill_price DECIMAL(10, 2) NOT NULL, + stock INTEGER NOT NULL DEFAULT 0, + sold_count INTEGER DEFAULT 0, + per_user_limit INTEGER DEFAULT 1, + create_by VARCHAR(50), + update_by VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + CONSTRAINT fk_flash_sale_item_activity FOREIGN KEY (activity_id) REFERENCES flash_sale_activity(id) +); + +CREATE TABLE IF NOT EXISTS flash_sale_order ( + id BIGINT PRIMARY KEY, + activity_id BIGINT NOT NULL, + item_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + pay_amount DECIMAL(10, 2) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + expire_at TIMESTAMP NOT NULL, + pay_at TIMESTAMP, + create_by VARCHAR(50), + update_by VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP, + CONSTRAINT fk_flash_sale_order_item FOREIGN KEY (item_id) REFERENCES flash_sale_item(id) +); + +CREATE INDEX idx_flash_sale_activity_status ON flash_sale_activity(status); +CREATE INDEX idx_flash_sale_item_activity ON flash_sale_item(activity_id); +CREATE INDEX idx_flash_sale_order_member ON flash_sale_order(member_id); +CREATE INDEX idx_flash_sale_order_status ON flash_sale_order(status); + +-- 5.4 会员营销活动 +CREATE TABLE IF NOT EXISTS marketing_activity ( + id BIGINT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + activity_type VARCHAR(30) NOT NULL, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NOT NULL, + discount_value DECIMAL(10, 2), + threshold_amount DECIMAL(10, 2) DEFAULT 0, + gift_description TEXT, + apply_scope VARCHAR(20) DEFAULT 'ALL', + apply_product_ids TEXT, + rules_json TEXT, + participant_count INTEGER DEFAULT 0, + order_count INTEGER DEFAULT 0, + total_discount_amount DECIMAL(12, 2) DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + 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 idx_marketing_activity_type ON marketing_activity(activity_type); +CREATE INDEX idx_marketing_activity_status ON marketing_activity(status); + +-- 5.5 积分商城 +CREATE TABLE IF NOT EXISTS points_rule ( + id BIGINT PRIMARY KEY, + rule_name VARCHAR(100) NOT NULL, + rule_type VARCHAR(30) NOT NULL, + points_value INTEGER DEFAULT 0, + ratio DECIMAL(10, 4), + description TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + create_by VARCHAR(50), + update_by VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS points_mall_product ( + id BIGINT PRIMARY KEY, + product_name VARCHAR(100) NOT NULL, + description TEXT, + product_type VARCHAR(20) NOT NULL, + related_id BIGINT, + points_cost INTEGER NOT NULL, + stock INTEGER DEFAULT -1, + sold_count INTEGER DEFAULT 0, + image_url VARCHAR(500), + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + create_by VARCHAR(50), + update_by VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS member_points ( + id BIGINT PRIMARY KEY, + member_id BIGINT NOT NULL UNIQUE, + total_points INTEGER DEFAULT 0, + available_points INTEGER DEFAULT 0, + used_points INTEGER DEFAULT 0, + create_by VARCHAR(50), + update_by VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS points_record ( + id BIGINT PRIMARY KEY, + member_id BIGINT NOT NULL, + change_type VARCHAR(20) NOT NULL, + points INTEGER NOT NULL, + balance_after INTEGER NOT NULL, + source VARCHAR(50), + related_id BIGINT, + remark VARCHAR(500), + 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 idx_points_mall_product_status ON points_mall_product(status); +CREATE INDEX idx_member_points_member ON member_points(member_id); +CREATE INDEX idx_points_record_member ON points_record(member_id); diff --git a/gym-manage-api/pom.xml b/gym-manage-api/pom.xml index 91a2e5a..e8ad35c 100644 --- a/gym-manage-api/pom.xml +++ b/gym-manage-api/pom.xml @@ -46,6 +46,7 @@ gym-groupCourse gym-checkIn gym-dataCount + gym-coupon