From e0856a1b9c25322b3fdee050875e4d81681f17eb Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Tue, 6 Jan 2026 18:30:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(pricing):=20=E6=B7=BB=E5=8A=A0=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E4=BC=98=E6=83=A0=E5=88=B8=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建场景优惠券领取控制器,提供前端优惠券领取接口 - 创建场景优惠券配置管理控制器,提供后台管理端配置接口 - 定义场景优惠券领取和配置相关的请求响应DTO - 创建场景优惠券配置实体和数据库表结构 - 实现场景优惠券配置的数据访问和业务逻辑处理 - 实现场景优惠券领取功能,支持景区隔离和默认配置回退 - 添加优惠券领取状态检查和用户限制验证逻辑 - 实现分页查询和配置管理功能 --- .../SceneCouponClaimController.java | 92 +++++ .../SceneCouponConfigController.java | 116 ++++++ .../pricing/dto/req/SceneCouponClaimReq.java | 25 ++ .../dto/req/SceneCouponConfigPageReq.java | 33 ++ .../dto/req/SceneCouponConfigSaveReq.java | 40 ++ .../dto/resp/SceneCouponAvailableResp.java | 78 ++++ .../dto/resp/SceneCouponConfigResp.java | 64 ++++ .../entity/PriceSceneCouponConfig.java | 61 +++ .../mapper/PriceSceneCouponConfigMapper.java | 99 +++++ .../pricing/service/ISceneCouponService.java | 88 +++++ .../service/impl/SceneCouponServiceImpl.java | 360 ++++++++++++++++++ 11 files changed, 1056 insertions(+) create mode 100644 src/main/java/com/ycwl/basic/pricing/controller/SceneCouponClaimController.java create mode 100644 src/main/java/com/ycwl/basic/pricing/controller/SceneCouponConfigController.java create mode 100644 src/main/java/com/ycwl/basic/pricing/dto/req/SceneCouponClaimReq.java create mode 100644 src/main/java/com/ycwl/basic/pricing/dto/req/SceneCouponConfigPageReq.java create mode 100644 src/main/java/com/ycwl/basic/pricing/dto/req/SceneCouponConfigSaveReq.java create mode 100644 src/main/java/com/ycwl/basic/pricing/dto/resp/SceneCouponAvailableResp.java create mode 100644 src/main/java/com/ycwl/basic/pricing/dto/resp/SceneCouponConfigResp.java create mode 100644 src/main/java/com/ycwl/basic/pricing/entity/PriceSceneCouponConfig.java create mode 100644 src/main/java/com/ycwl/basic/pricing/mapper/PriceSceneCouponConfigMapper.java create mode 100644 src/main/java/com/ycwl/basic/pricing/service/ISceneCouponService.java create mode 100644 src/main/java/com/ycwl/basic/pricing/service/impl/SceneCouponServiceImpl.java diff --git a/src/main/java/com/ycwl/basic/pricing/controller/SceneCouponClaimController.java b/src/main/java/com/ycwl/basic/pricing/controller/SceneCouponClaimController.java new file mode 100644 index 00000000..0cc2333c --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/controller/SceneCouponClaimController.java @@ -0,0 +1,92 @@ +package com.ycwl.basic.pricing.controller; + +import com.ycwl.basic.constant.BaseContextHandler; +import com.ycwl.basic.pricing.dto.CouponClaimResult; +import com.ycwl.basic.pricing.dto.req.SceneCouponClaimReq; +import com.ycwl.basic.pricing.dto.resp.SceneCouponAvailableResp; +import com.ycwl.basic.pricing.service.ISceneCouponService; +import com.ycwl.basic.utils.ApiResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 场景优惠券领取控制器(前端/移动端) + */ +@Slf4j +@RestController +@RequestMapping("/api/pricing/scene-coupon") +@RequiredArgsConstructor +public class SceneCouponClaimController { + + private final ISceneCouponService sceneCouponService; + + /** + * 查询场景下可领取的优惠券列表 + * + * @param sceneKey 场景标识 + * @param scenicId 景区ID + * @return 可领取优惠券列表(包含用户领取状态) + */ + @GetMapping("/available") + public ApiResponse> getAvailableCoupons( + @RequestParam String sceneKey, + @RequestParam Long scenicId) { + try { + Long userId = getUserId(); + List list = sceneCouponService.getAvailableCoupons(sceneKey, scenicId, userId); + return ApiResponse.success(list); + } catch (Exception e) { + log.error("场景优惠券|可领取列表查询失败 sceneKey={}, scenicId={}", sceneKey, scenicId, e); + return ApiResponse.fail("查询失败: " + e.getMessage()); + } + } + + /** + * 领取场景优惠券 + * + * @param req 领取请求 + * @return 领取结果列表 + */ + @PostMapping("/claim") + public ApiResponse> claimCoupons(@RequestBody SceneCouponClaimReq req) { + try { + Long userId = getUserId(); + if (userId == null) { + return ApiResponse.fail("用户未登录"); + } + + List results = sceneCouponService.claimCoupons(req, userId); + + // 判断整体结果 + boolean hasSuccess = results.stream().anyMatch(CouponClaimResult::isSuccess); + if (!hasSuccess && !results.isEmpty()) { + // 全部失败,返回第一个错误信息 + return ApiResponse.fail(results.get(0).getErrorMessage()); + } + + return ApiResponse.success(results); + } catch (Exception e) { + log.error("场景优惠券|领取失败 req={}", req, e); + return ApiResponse.fail("领取失败: " + e.getMessage()); + } + } + + /** + * 获取当前登录用户ID + */ + private Long getUserId() { + try { + String userIdStr = BaseContextHandler.getUserId(); + if (userIdStr == null || userIdStr.isEmpty()) { + return null; + } + return Long.valueOf(userIdStr); + } catch (NumberFormatException e) { + log.warn("无法解析用户ID: {}", BaseContextHandler.getUserId()); + return null; + } + } +} diff --git a/src/main/java/com/ycwl/basic/pricing/controller/SceneCouponConfigController.java b/src/main/java/com/ycwl/basic/pricing/controller/SceneCouponConfigController.java new file mode 100644 index 00000000..22072a2c --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/controller/SceneCouponConfigController.java @@ -0,0 +1,116 @@ +package com.ycwl.basic.pricing.controller; + +import com.github.pagehelper.PageInfo; +import com.ycwl.basic.pricing.dto.req.SceneCouponConfigPageReq; +import com.ycwl.basic.pricing.dto.req.SceneCouponConfigSaveReq; +import com.ycwl.basic.pricing.dto.resp.SceneCouponConfigResp; +import com.ycwl.basic.pricing.service.ISceneCouponService; +import com.ycwl.basic.utils.ApiResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 场景优惠券配置管理控制器(后台管理端) + */ +@Slf4j +@RestController +@RequestMapping("/api/pricing/admin/scene-coupon") +@RequiredArgsConstructor +public class SceneCouponConfigController { + + private final ISceneCouponService sceneCouponService; + + /** + * 分页查询场景优惠券配置 + */ + @PostMapping("/page") + public ApiResponse> page(@RequestBody SceneCouponConfigPageReq req) { + try { + PageInfo pageInfo = sceneCouponService.pageConfig(req); + return ApiResponse.success(pageInfo); + } catch (Exception e) { + log.error("场景优惠券|分页查询失败", e); + return ApiResponse.fail("分页查询失败: " + e.getMessage()); + } + } + + /** + * 获取配置详情 + */ + @GetMapping("/detail/{id}") + public ApiResponse getDetail(@PathVariable("id") Long id) { + try { + SceneCouponConfigResp detail = sceneCouponService.getConfigDetail(id); + if (detail == null) { + return ApiResponse.fail("记录不存在"); + } + return ApiResponse.success(detail); + } catch (Exception e) { + log.error("场景优惠券|详情查询失败 id={}", id, e); + return ApiResponse.fail("详情查询失败: " + e.getMessage()); + } + } + + /** + * 保存配置(新增/更新) + */ + @PostMapping("/save") + public ApiResponse save(@RequestBody SceneCouponConfigSaveReq req) { + try { + boolean success = sceneCouponService.saveConfig(req); + return ApiResponse.success(success); + } catch (IllegalArgumentException e) { + return ApiResponse.fail(e.getMessage()); + } catch (Exception e) { + log.error("场景优惠券|保存失败", e); + return ApiResponse.fail("保存失败: " + e.getMessage()); + } + } + + /** + * 删除配置 + */ + @DeleteMapping("/delete/{id}") + public ApiResponse delete(@PathVariable("id") Long id) { + try { + boolean success = sceneCouponService.deleteConfig(id); + return ApiResponse.success(success); + } catch (Exception e) { + log.error("场景优惠券|删除失败 id={}", id, e); + return ApiResponse.fail("删除失败: " + e.getMessage()); + } + } + + /** + * 获取所有已配置的场景列表(用于前端下拉选择) + */ + @GetMapping("/scenes") + public ApiResponse> listSceneKeys() { + try { + List sceneKeys = sceneCouponService.listSceneKeys(); + return ApiResponse.success(sceneKeys); + } catch (Exception e) { + log.error("场景优惠券|场景列表查询失败", e); + return ApiResponse.fail("场景列表查询失败: " + e.getMessage()); + } + } + + /** + * 根据场景和景区查询配置列表 + */ + @GetMapping("/by-scene") + public ApiResponse> listByScene( + @RequestParam String sceneKey, + @RequestParam Long scenicId) { + try { + List list = sceneCouponService.listBySceneKeyAndScenicId(sceneKey, scenicId); + return ApiResponse.success(list); + } catch (Exception e) { + log.error("场景优惠券|按场景查询失败 sceneKey={}, scenicId={}", sceneKey, scenicId, e); + return ApiResponse.fail("按场景查询失败: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/ycwl/basic/pricing/dto/req/SceneCouponClaimReq.java b/src/main/java/com/ycwl/basic/pricing/dto/req/SceneCouponClaimReq.java new file mode 100644 index 00000000..5e735575 --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/dto/req/SceneCouponClaimReq.java @@ -0,0 +1,25 @@ +package com.ycwl.basic.pricing.dto.req; + +import lombok.Data; + +/** + * 场景优惠券领取请求 + */ +@Data +public class SceneCouponClaimReq { + + /** + * 场景标识符(必填) + */ + private String sceneKey; + + /** + * 景区ID(必填) + */ + private Long scenicId; + + /** + * 指定领取的优惠券ID(可选,不传则领取场景下所有可领取的优惠券) + */ + private Long couponId; +} diff --git a/src/main/java/com/ycwl/basic/pricing/dto/req/SceneCouponConfigPageReq.java b/src/main/java/com/ycwl/basic/pricing/dto/req/SceneCouponConfigPageReq.java new file mode 100644 index 00000000..83151e0c --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/dto/req/SceneCouponConfigPageReq.java @@ -0,0 +1,33 @@ +package com.ycwl.basic.pricing.dto.req; + +import com.ycwl.basic.model.common.BaseQueryParameterReq; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 场景优惠券配置分页查询请求 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class SceneCouponConfigPageReq extends BaseQueryParameterReq { + + /** + * 场景标识(模糊匹配) + */ + private String sceneKey; + + /** + * 景区ID;为空表示不筛选 + */ + private Long scenicId; + + /** + * 优惠券ID + */ + private Long couponId; + + /** + * 是否启用:1启用 0禁用 + */ + private Integer enabled; +} diff --git a/src/main/java/com/ycwl/basic/pricing/dto/req/SceneCouponConfigSaveReq.java b/src/main/java/com/ycwl/basic/pricing/dto/req/SceneCouponConfigSaveReq.java new file mode 100644 index 00000000..436e7fb0 --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/dto/req/SceneCouponConfigSaveReq.java @@ -0,0 +1,40 @@ +package com.ycwl.basic.pricing.dto.req; + +import lombok.Data; + +/** + * 场景优惠券配置保存请求(新增/修改) + */ +@Data +public class SceneCouponConfigSaveReq { + + /** + * 主键ID(为空表示新增;不为空表示更新) + */ + private Long id; + + /** + * 场景标识符 + */ + private String sceneKey; + + /** + * 关联的优惠券ID + */ + private Long couponId; + + /** + * 景区ID;0=默认配置 + */ + private Long scenicId; + + /** + * 是否启用:1启用 0禁用 + */ + private Integer enabled; + + /** + * 排序顺序(越小越靠前) + */ + private Integer sortOrder; +} diff --git a/src/main/java/com/ycwl/basic/pricing/dto/resp/SceneCouponAvailableResp.java b/src/main/java/com/ycwl/basic/pricing/dto/resp/SceneCouponAvailableResp.java new file mode 100644 index 00000000..ac35fee1 --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/dto/resp/SceneCouponAvailableResp.java @@ -0,0 +1,78 @@ +package com.ycwl.basic.pricing.dto.resp; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 场景下可领取优惠券响应 + */ +@Data +public class SceneCouponAvailableResp { + + /** + * 优惠券ID + */ + private Long couponId; + + /** + * 优惠券名称 + */ + private String couponName; + + /** + * 优惠券类型 (PERCENTAGE/FIXED_AMOUNT) + */ + private String couponType; + + /** + * 优惠值 + */ + private BigDecimal discountValue; + + /** + * 最小使用金额 + */ + private BigDecimal minAmount; + + /** + * 最大优惠金额 + */ + private BigDecimal maxDiscount; + + /** + * 有效期起始 + */ + private LocalDateTime validFrom; + + /** + * 有效期结束 + */ + private LocalDateTime validUntil; + + /** + * 用户可领取数量限制(每人最多可领,null或0表示无限制) + */ + private Integer userClaimLimit; + + /** + * 用户已领取数量 + */ + private Integer userClaimedCount; + + /** + * 用户剩余可领取数量(-1表示无限制,0表示已达上限) + */ + private Integer userRemaining; + + /** + * 是否可领取(综合判断:库存、用户限制、有效期等) + */ + private Boolean canClaim; + + /** + * 不可领取原因(当 canClaim=false 时) + */ + private String cannotClaimReason; +} diff --git a/src/main/java/com/ycwl/basic/pricing/dto/resp/SceneCouponConfigResp.java b/src/main/java/com/ycwl/basic/pricing/dto/resp/SceneCouponConfigResp.java new file mode 100644 index 00000000..2b32f833 --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/dto/resp/SceneCouponConfigResp.java @@ -0,0 +1,64 @@ +package com.ycwl.basic.pricing.dto.resp; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Date; + +/** + * 场景优惠券配置响应(包含优惠券详情) + */ +@Data +public class SceneCouponConfigResp { + + // ========== 配置信息 ========== + + private Long id; + + private String sceneKey; + + private Long couponId; + + private Long scenicId; + + private Integer enabled; + + private Integer sortOrder; + + private Date createTime; + + private Date updateTime; + + // ========== 关联的优惠券信息 ========== + + /** + * 优惠券名称 + */ + private String couponName; + + /** + * 优惠券类型 (PERCENTAGE/FIXED_AMOUNT) + */ + private String couponType; + + /** + * 优惠值 + */ + private BigDecimal discountValue; + + /** + * 优惠券是否启用 + */ + private Boolean couponActive; + + /** + * 优惠券有效期起始 + */ + private LocalDateTime couponValidFrom; + + /** + * 优惠券有效期结束 + */ + private LocalDateTime couponValidUntil; +} diff --git a/src/main/java/com/ycwl/basic/pricing/entity/PriceSceneCouponConfig.java b/src/main/java/com/ycwl/basic/pricing/entity/PriceSceneCouponConfig.java new file mode 100644 index 00000000..80dd7b26 --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/entity/PriceSceneCouponConfig.java @@ -0,0 +1,61 @@ +package com.ycwl.basic.pricing.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.util.Date; + +/** + * 场景-优惠券关联配置实体 + *

+ * 用于配置不同场景下可领取的优惠券,支持分景区配置。 + * scenicId=0 表示默认配置(所有景区通用),具体景区配置优先级高于默认配置。 + *

+ */ +@Data +@TableName("price_scene_coupon_config") +public class PriceSceneCouponConfig { + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 场景标识符(如 home_banner, checkout_page, new_user_popup) + */ + private String sceneKey; + + /** + * 关联的优惠券ID (price_coupon_config.id) + */ + private Long couponId; + + /** + * 景区ID;0=默认配置(所有景区通用) + */ + private Long scenicId; + + /** + * 是否启用:1启用 0禁用 + */ + private Integer enabled; + + /** + * 排序顺序(越小越靠前) + */ + private Integer sortOrder; + + /** + * 创建时间 + */ + private Date createTime; + + /** + * 更新时间 + */ + private Date updateTime; +} diff --git a/src/main/java/com/ycwl/basic/pricing/mapper/PriceSceneCouponConfigMapper.java b/src/main/java/com/ycwl/basic/pricing/mapper/PriceSceneCouponConfigMapper.java new file mode 100644 index 00000000..4001bb34 --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/mapper/PriceSceneCouponConfigMapper.java @@ -0,0 +1,99 @@ +package com.ycwl.basic.pricing.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.ycwl.basic.pricing.dto.resp.SceneCouponConfigResp; +import com.ycwl.basic.pricing.entity.PriceSceneCouponConfig; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 场景优惠券配置Mapper + */ +@Mapper +public interface PriceSceneCouponConfigMapper extends BaseMapper { + + /** + * 查询场景下已启用的优惠券配置(带优惠券详情) + * 用于前端领取接口 + * + * @param sceneKey 场景标识 + * @param scenicId 景区ID + * @return 配置列表(带优惠券信息) + */ + @Select("SELECT scc.id, scc.scene_key, scc.coupon_id, scc.scenic_id, scc.enabled, " + + "scc.sort_order, scc.create_time, scc.update_time, " + + "c.coupon_name, c.coupon_type, c.discount_value, " + + "c.is_active AS coupon_active, c.valid_from AS coupon_valid_from, c.valid_until AS coupon_valid_until " + + "FROM price_scene_coupon_config scc " + + "JOIN price_coupon_config c ON scc.coupon_id = c.id AND c.deleted = 0 " + + "WHERE scc.scene_key = #{sceneKey} AND scc.scenic_id = #{scenicId} " + + "AND scc.enabled = 1 AND c.is_active = 1 " + + "AND (c.valid_from IS NULL OR c.valid_from <= NOW()) " + + "AND (c.valid_until IS NULL OR c.valid_until > NOW()) " + + "ORDER BY scc.sort_order ASC, scc.id ASC") + List selectEnabledBySceneKeyAndScenicId( + @Param("sceneKey") String sceneKey, + @Param("scenicId") Long scenicId); + + /** + * 检查场景下是否存在已启用的配置(用于判断是否需要回退到默认) + * + * @param sceneKey 场景标识 + * @param scenicId 景区ID + * @return 配置数量 + */ + @Select("SELECT COUNT(*) FROM price_scene_coupon_config " + + "WHERE scene_key = #{sceneKey} AND scenic_id = #{scenicId} AND enabled = 1") + int countEnabledBySceneKeyAndScenicId( + @Param("sceneKey") String sceneKey, + @Param("scenicId") Long scenicId); + + /** + * 查询所有已配置的场景列表(去重) + * + * @return 场景标识列表 + */ + @Select("SELECT DISTINCT scene_key FROM price_scene_coupon_config ORDER BY scene_key") + List selectDistinctSceneKeys(); + + /** + * 管理端:带优惠券信息的分页查询 + * + * @param sceneKey 场景标识(模糊匹配,可为null) + * @param scenicId 景区ID(精确匹配,可为null) + * @param couponId 优惠券ID(精确匹配,可为null) + * @param enabled 启用状态(精确匹配,可为null) + * @return 配置列表(带优惠券信息) + */ + @Select("") + List selectPageWithCouponInfo( + @Param("sceneKey") String sceneKey, + @Param("scenicId") Long scenicId, + @Param("couponId") Long couponId, + @Param("enabled") Integer enabled); +} diff --git a/src/main/java/com/ycwl/basic/pricing/service/ISceneCouponService.java b/src/main/java/com/ycwl/basic/pricing/service/ISceneCouponService.java new file mode 100644 index 00000000..9690307b --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/service/ISceneCouponService.java @@ -0,0 +1,88 @@ +package com.ycwl.basic.pricing.service; + +import com.github.pagehelper.PageInfo; +import com.ycwl.basic.pricing.dto.CouponClaimResult; +import com.ycwl.basic.pricing.dto.req.SceneCouponClaimReq; +import com.ycwl.basic.pricing.dto.req.SceneCouponConfigPageReq; +import com.ycwl.basic.pricing.dto.req.SceneCouponConfigSaveReq; +import com.ycwl.basic.pricing.dto.resp.SceneCouponAvailableResp; +import com.ycwl.basic.pricing.dto.resp.SceneCouponConfigResp; + +import java.util.List; + +/** + * 场景优惠券服务接口 + */ +public interface ISceneCouponService { + + // ==================== 后台管理接口 ==================== + + /** + * 分页查询场景优惠券配置 + * + * @param req 查询请求 + * @return 分页结果 + */ + PageInfo pageConfig(SceneCouponConfigPageReq req); + + /** + * 获取配置详情 + * + * @param id 配置ID + * @return 配置详情 + */ + SceneCouponConfigResp getConfigDetail(Long id); + + /** + * 保存配置(新增/更新) + * + * @param req 保存请求 + * @return 是否成功 + */ + boolean saveConfig(SceneCouponConfigSaveReq req); + + /** + * 删除配置 + * + * @param id 配置ID + * @return 是否成功 + */ + boolean deleteConfig(Long id); + + /** + * 获取所有已配置的场景列表 + * + * @return 场景标识列表 + */ + List listSceneKeys(); + + /** + * 根据场景和景区查询配置列表 + * + * @param sceneKey 场景标识 + * @param scenicId 景区ID + * @return 配置列表 + */ + List listBySceneKeyAndScenicId(String sceneKey, Long scenicId); + + // ==================== 前端领取接口 ==================== + + /** + * 查询场景下可领取的优惠券列表 + * + * @param sceneKey 场景标识 + * @param scenicId 景区ID + * @param userId 用户ID(用于判断用户已领取数量) + * @return 可领取优惠券列表 + */ + List getAvailableCoupons(String sceneKey, Long scenicId, Long userId); + + /** + * 领取场景优惠券 + * + * @param req 领取请求 + * @param userId 用户ID + * @return 领取结果列表 + */ + List claimCoupons(SceneCouponClaimReq req, Long userId); +} diff --git a/src/main/java/com/ycwl/basic/pricing/service/impl/SceneCouponServiceImpl.java b/src/main/java/com/ycwl/basic/pricing/service/impl/SceneCouponServiceImpl.java new file mode 100644 index 00000000..e304b205 --- /dev/null +++ b/src/main/java/com/ycwl/basic/pricing/service/impl/SceneCouponServiceImpl.java @@ -0,0 +1,360 @@ +package com.ycwl.basic.pricing.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.github.pagehelper.PageHelper; +import com.github.pagehelper.PageInfo; +import com.ycwl.basic.model.common.BaseQueryParameterReq; +import com.ycwl.basic.pricing.dto.CouponClaimRequest; +import com.ycwl.basic.pricing.dto.CouponClaimResult; +import com.ycwl.basic.pricing.dto.req.SceneCouponClaimReq; +import com.ycwl.basic.pricing.dto.req.SceneCouponConfigPageReq; +import com.ycwl.basic.pricing.dto.req.SceneCouponConfigSaveReq; +import com.ycwl.basic.pricing.dto.resp.SceneCouponAvailableResp; +import com.ycwl.basic.pricing.dto.resp.SceneCouponConfigResp; +import com.ycwl.basic.pricing.entity.PriceCouponConfig; +import com.ycwl.basic.pricing.entity.PriceSceneCouponConfig; +import com.ycwl.basic.pricing.mapper.PriceCouponClaimRecordMapper; +import com.ycwl.basic.pricing.mapper.PriceCouponConfigMapper; +import com.ycwl.basic.pricing.mapper.PriceSceneCouponConfigMapper; +import com.ycwl.basic.pricing.service.ICouponService; +import com.ycwl.basic.pricing.service.ISceneCouponService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Objects; + +/** + * 场景优惠券服务实现 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SceneCouponServiceImpl implements ISceneCouponService { + + private static final int MAX_PAGE_SIZE = 200; + private static final Long DEFAULT_SCENIC_ID = 0L; + + private final PriceSceneCouponConfigMapper sceneCouponConfigMapper; + private final PriceCouponConfigMapper couponConfigMapper; + private final PriceCouponClaimRecordMapper claimRecordMapper; + private final ICouponService couponService; + + // ==================== 后台管理接口实现 ==================== + + @Override + public PageInfo pageConfig(SceneCouponConfigPageReq req) { + if (req == null) { + req = new SceneCouponConfigPageReq(); + } + sanitizePage(req); + + PageHelper.startPage(req.getPageNum(), req.getPageSize()); + List list = sceneCouponConfigMapper.selectPageWithCouponInfo( + req.getSceneKey(), req.getScenicId(), req.getCouponId(), req.getEnabled()); + return new PageInfo<>(list); + } + + @Override + public SceneCouponConfigResp getConfigDetail(Long id) { + if (id == null) { + return null; + } + PriceSceneCouponConfig config = sceneCouponConfigMapper.selectById(id); + if (config == null) { + return null; + } + + SceneCouponConfigResp resp = new SceneCouponConfigResp(); + resp.setId(config.getId()); + resp.setSceneKey(config.getSceneKey()); + resp.setCouponId(config.getCouponId()); + resp.setScenicId(config.getScenicId()); + resp.setEnabled(config.getEnabled()); + resp.setSortOrder(config.getSortOrder()); + resp.setCreateTime(config.getCreateTime()); + resp.setUpdateTime(config.getUpdateTime()); + + // 填充优惠券信息 + PriceCouponConfig coupon = couponConfigMapper.selectById(config.getCouponId()); + if (coupon != null) { + resp.setCouponName(coupon.getCouponName()); + resp.setCouponType(coupon.getCouponType() != null ? coupon.getCouponType().name() : null); + resp.setDiscountValue(coupon.getDiscountValue()); + resp.setCouponActive(coupon.getIsActive()); + resp.setCouponValidFrom(coupon.getValidFrom()); + resp.setCouponValidUntil(coupon.getValidUntil()); + } + + return resp; + } + + @Override + public boolean saveConfig(SceneCouponConfigSaveReq req) { + String err = validateSaveReq(req); + if (err != null) { + throw new IllegalArgumentException(err); + } + + PriceSceneCouponConfig entity = new PriceSceneCouponConfig(); + entity.setSceneKey(req.getSceneKey().trim()); + entity.setCouponId(req.getCouponId()); + entity.setScenicId(req.getScenicId()); + entity.setEnabled(req.getEnabled()); + entity.setSortOrder(Objects.requireNonNullElse(req.getSortOrder(), 0)); + entity.setUpdateTime(new Date()); + + try { + if (req.getId() != null) { + // 更新 + PriceSceneCouponConfig existing = sceneCouponConfigMapper.selectById(req.getId()); + if (existing == null) { + throw new IllegalArgumentException("记录不存在"); + } + entity.setId(req.getId()); + return sceneCouponConfigMapper.updateById(entity) > 0; + } + + // 新增前检查唯一性 + PriceSceneCouponConfig existing = sceneCouponConfigMapper.selectOne( + new QueryWrapper() + .eq("scene_key", entity.getSceneKey()) + .eq("coupon_id", entity.getCouponId()) + .eq("scenic_id", entity.getScenicId())); + if (existing != null) { + // 已存在则更新 + entity.setId(existing.getId()); + return sceneCouponConfigMapper.updateById(entity) > 0; + } + + entity.setCreateTime(new Date()); + return sceneCouponConfigMapper.insert(entity) > 0; + + } catch (DuplicateKeyException e) { + throw new IllegalArgumentException("保存失败:配置已存在(sceneKey+couponId+scenicId重复)"); + } + } + + @Override + public boolean deleteConfig(Long id) { + if (id == null) { + return false; + } + return sceneCouponConfigMapper.deleteById(id) > 0; + } + + @Override + public List listSceneKeys() { + return sceneCouponConfigMapper.selectDistinctSceneKeys(); + } + + @Override + public List listBySceneKeyAndScenicId(String sceneKey, Long scenicId) { + return sceneCouponConfigMapper.selectPageWithCouponInfo(sceneKey, scenicId, null, null); + } + + // ==================== 前端领取接口实现 ==================== + + @Override + public List getAvailableCoupons(String sceneKey, Long scenicId, Long userId) { + if (sceneKey == null || sceneKey.isBlank() || scenicId == null) { + return new ArrayList<>(); + } + + // 景区隔离查询:先查具体景区,为空则回退到默认配置 + List configs = getConfigsWithFallback(sceneKey, scenicId); + if (configs.isEmpty()) { + return new ArrayList<>(); + } + + List result = new ArrayList<>(); + for (SceneCouponConfigResp config : configs) { + SceneCouponAvailableResp resp = buildAvailableResp(config, userId); + result.add(resp); + } + + return result; + } + + @Override + @Transactional + public List claimCoupons(SceneCouponClaimReq req, Long userId) { + if (req.getSceneKey() == null || req.getSceneKey().isBlank() || req.getScenicId() == null) { + return List.of(CouponClaimResult.failure(CouponClaimResult.ERROR_INVALID_PARAMS, "sceneKey和scenicId不能为空")); + } + + // 获取场景下的优惠券配置 + List configs = getConfigsWithFallback(req.getSceneKey(), req.getScenicId()); + if (configs.isEmpty()) { + return List.of(CouponClaimResult.failure("SCENE_NOT_FOUND", "该场景暂无可领取的优惠券")); + } + + // 如果指定了couponId,只领取指定的 + if (req.getCouponId() != null) { + configs = configs.stream() + .filter(c -> req.getCouponId().equals(c.getCouponId())) + .toList(); + if (configs.isEmpty()) { + return List.of(CouponClaimResult.failure("COUPON_NOT_IN_SCENE", "指定的优惠券不在该场景中")); + } + } + + // 调用现有的领取服务 + List results = new ArrayList<>(); + for (SceneCouponConfigResp config : configs) { + CouponClaimRequest claimReq = new CouponClaimRequest(); + claimReq.setUserId(userId); + claimReq.setCouponId(config.getCouponId()); + claimReq.setScenicId(String.valueOf(req.getScenicId())); + claimReq.setClaimSource("scene:" + req.getSceneKey()); + + CouponClaimResult claimResult = couponService.claimCoupon(claimReq); + results.add(claimResult); + } + + return results; + } + + // ==================== 私有方法 ==================== + + /** + * 带景区回退的配置查询 + * 如果景区有配置(哪怕只有一条),则使用景区的配置; + * 如果景区没有配置,则使用默认景区(scenicId=0)的配置。 + */ + private List getConfigsWithFallback(String sceneKey, Long scenicId) { + // 1. 先查具体景区是否有配置 + int count = sceneCouponConfigMapper.countEnabledBySceneKeyAndScenicId(sceneKey, scenicId); + if (count > 0) { + // 景区有配置,使用景区的配置 + return sceneCouponConfigMapper.selectEnabledBySceneKeyAndScenicId(sceneKey, scenicId); + } + + // 2. 景区没有配置,回退到默认配置 (scenicId=0) + return sceneCouponConfigMapper.selectEnabledBySceneKeyAndScenicId(sceneKey, DEFAULT_SCENIC_ID); + } + + /** + * 构建可领取优惠券响应 + */ + private SceneCouponAvailableResp buildAvailableResp(SceneCouponConfigResp config, Long userId) { + SceneCouponAvailableResp resp = new SceneCouponAvailableResp(); + + // 查询优惠券详情 + PriceCouponConfig coupon = couponConfigMapper.selectById(config.getCouponId()); + if (coupon == null) { + resp.setCouponId(config.getCouponId()); + resp.setCanClaim(false); + resp.setCannotClaimReason("优惠券不存在"); + return resp; + } + + resp.setCouponId(coupon.getId()); + resp.setCouponName(coupon.getCouponName()); + resp.setCouponType(coupon.getCouponType() != null ? coupon.getCouponType().name() : null); + resp.setDiscountValue(coupon.getDiscountValue()); + resp.setMinAmount(coupon.getMinAmount()); + resp.setMaxDiscount(coupon.getMaxDiscount()); + resp.setValidFrom(coupon.getValidFrom()); + resp.setValidUntil(coupon.getValidUntil()); + resp.setUserClaimLimit(coupon.getUserClaimLimit()); + + // 查询用户已领取数量 + int userClaimedCount = 0; + if (userId != null) { + userClaimedCount = claimRecordMapper.countUserCouponClaims(userId, coupon.getId()); + } + resp.setUserClaimedCount(userClaimedCount); + + // 计算剩余可领取数量 + int userRemaining; + if (coupon.getUserClaimLimit() == null || coupon.getUserClaimLimit() <= 0) { + userRemaining = -1; // -1表示无限制 + } else { + userRemaining = Math.max(0, coupon.getUserClaimLimit() - userClaimedCount); + } + resp.setUserRemaining(userRemaining); + + // 综合判断是否可领取 + String cannotClaimReason = checkCanClaim(coupon, userClaimedCount); + resp.setCanClaim(cannotClaimReason == null); + resp.setCannotClaimReason(cannotClaimReason); + + return resp; + } + + /** + * 检查是否可领取 + * + * @return null表示可领取,否则返回不可领取原因 + */ + private String checkCanClaim(PriceCouponConfig coupon, int userClaimedCount) { + // 1. 检查启用状态 + if (!Boolean.TRUE.equals(coupon.getIsActive())) { + return "优惠券已停用"; + } + + // 2. 检查有效期 + LocalDateTime now = LocalDateTime.now(); + if (coupon.getValidFrom() != null && now.isBefore(coupon.getValidFrom())) { + return "优惠券尚未生效"; + } + if (coupon.getValidUntil() != null && now.isAfter(coupon.getValidUntil())) { + return "优惠券已过期"; + } + + // 3. 检查库存 + if (coupon.getTotalQuantity() != null && coupon.getTotalQuantity() > 0) { + int claimed = coupon.getClaimedQuantity() == null ? 0 : coupon.getClaimedQuantity(); + if (claimed >= coupon.getTotalQuantity()) { + return "优惠券已领完"; + } + } + + // 4. 检查用户领取限制 + if (coupon.getUserClaimLimit() != null && coupon.getUserClaimLimit() > 0) { + if (userClaimedCount >= coupon.getUserClaimLimit()) { + return "您已达到领取上限"; + } + } + + return null; // 可领取 + } + + private static void sanitizePage(BaseQueryParameterReq req) { + if (req.getPageNum() == null || req.getPageNum() < 1) { + req.setPageNum(1); + } + if (req.getPageSize() == null || req.getPageSize() < 1) { + req.setPageSize(10); + } + if (req.getPageSize() > MAX_PAGE_SIZE) { + req.setPageSize(MAX_PAGE_SIZE); + } + } + + private static String validateSaveReq(SceneCouponConfigSaveReq req) { + if (req == null) { + return "请求体不能为空"; + } + if (req.getSceneKey() == null || req.getSceneKey().isBlank()) { + return "sceneKey不能为空"; + } + if (req.getCouponId() == null) { + return "couponId不能为空"; + } + if (req.getScenicId() == null) { + return "scenicId不能为空"; + } + if (req.getEnabled() == null || (req.getEnabled() != 0 && req.getEnabled() != 1)) { + return "enabled必须为0或1"; + } + return null; + } +}