From 7835283f0fb00d74e65ce7e72b57aeda3da5d91c Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Mon, 17 Nov 2025 00:26:15 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(pricing):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=BC=98=E6=83=A0=E5=88=B8=E7=94=A8=E6=88=B7=E9=A2=86=E5=8F=96?= =?UTF-8?q?=E6=95=B0=E9=87=8F=E9=99=90=E5=88=B6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增用户领取数量限制字段 userClaimLimit - 区分已领取数量 claimedQuantity 和已使用数量 usedQuantity - 添加用户领取次数统计方法 countUserCouponClaims - 实现领取上限检查逻辑和错误码 CLAIM_LIMIT_REACHED - 更新数据库表结构和索引优化建议 - 完善文档说明和版本更新记录 --- .../java/com/ycwl/basic/pricing/CLAUDE.md | 94 ++++++++++++++++++- .../basic/pricing/dto/CouponClaimResult.java | 1 + .../ycwl/basic/pricing/dto/CouponInfo.java | 7 +- .../pricing/entity/PriceCouponConfig.java | 12 ++- .../mapper/PriceCouponClaimRecordMapper.java | 11 ++- .../service/impl/CouponServiceImpl.java | 46 +++++---- 6 files changed, 148 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/ycwl/basic/pricing/CLAUDE.md b/src/main/java/com/ycwl/basic/pricing/CLAUDE.md index 158caa9a..383380bf 100644 --- a/src/main/java/com/ycwl/basic/pricing/CLAUDE.md +++ b/src/main/java/com/ycwl/basic/pricing/CLAUDE.md @@ -122,8 +122,54 @@ public enum CouponStatus { CLAIMED("claimed", ...), USED("used", ...), EXPIRED(" - 商品类型限制:通过 JSON 字段(结合 `ProductTypeListTypeHandler`)控制适用商品 - 消费限制:支持最小消费金额、最大折扣限制 - 时效性:基于时间的有效期控制 +- **用户领取数量限制**:通过 `userClaimLimit` 字段控制单个用户可领取优惠券的最大数量(v1.0.0新增) +- **库存精细化管理**:区分 `claimedQuantity`(已领取数量) 和 `usedQuantity`(已使用数量) - 统计分析:完整的使用统计与分析能力 +#### 优惠券数量管理机制 (v1.0.0更新) +```java +// PriceCouponConfig 实体新增字段 +private Integer userClaimLimit; // 每个用户可领取数量限制(NULL表示不限制) +private Integer claimedQuantity; // 已领取数量(区分于已使用数量) +private Integer usedQuantity; // 已使用数量(实际消耗时更新) + +// 领取流程检查顺序 +1. 检查优惠券是否存在且启用 +2. 检查有效期 +3. 检查总量库存: claimedQuantity < totalQuantity +4. 检查用户领取限制: countUserClaims(userId, couponId) < userClaimLimit +5. 创建领取记录并更新 claimedQuantity + 1 +``` + +#### 用户领取限制配置示例 +```java +// 场景1: 新人专享券,每人限领1张 +{ + "couponName": "新人专享券", + "userClaimLimit": 1, + "totalQuantity": 1000 +} + +// 场景2: 活动券,每人限领3张 +{ + "couponName": "618促销券", + "userClaimLimit": 3, + "totalQuantity": 5000 +} + +// 场景3: 不限制领取次数 +{ + "couponName": "会员专享券", + "userClaimLimit": null, // 或 0 + "totalQuantity": 10000 +} +``` + +#### 错误码扩展 +- `CLAIM_LIMIT_REACHED`: 用户已达到该优惠券的领取上限 +- `COUPON_OUT_OF_STOCK`: 优惠券库存不足(基于claimedQuantity检查) +- `ALREADY_CLAIMED`: 用户已领取过(兼容旧逻辑) + ### 3. 商品配置管理 #### API端点(摘) @@ -550,9 +596,22 @@ public class PriceCalculationResult { - `price_product_config`: 商品价格基础配置(包含 `can_use_coupon`、`can_use_voucher`、`can_use_one_price` 优惠控制字段) - `price_tier_config`: 分层定价配置 - `price_bundle_config`: 套餐配置 -- `price_coupon_config`: 优惠券配置 +- `price_coupon_config`: 优惠券配置(**v1.0.0新增**: `user_claim_limit` 用户领取限制, `claimed_quantity` 已领取数量) - `price_coupon_claim_record`: 优惠券领取记录 +### price_coupon_config 表字段说明 (v1.0.0更新) +```sql +-- 核心数量管理字段 +total_quantity INT -- 发行总量 +claimed_quantity INT -- 已领取数量(v1.0.0新增,领取时+1) +used_quantity INT -- 已使用数量(实际使用时+1) +user_claim_limit INT -- 每个用户可领取数量限制(v1.0.0新增,NULL表示不限制) + +-- 字段关系 +-- claimed_quantity >= used_quantity (已领取 >= 已使用) +-- claimed_quantity <= total_quantity (已领取 <= 总量) +``` + ### 新增表结构 - `price_voucher_batch_config`: 券码批次配置表 - `price_voucher_code`: 券码表 @@ -562,11 +621,15 @@ public class PriceCalculationResult { ### 索引优化(示例) ```sql +-- 优惠券领取记录查询优化 (v1.0.0新增) +CREATE INDEX idx_user_coupon ON price_coupon_claim_record(user_id, coupon_id); +CREATE INDEX idx_coupon_status ON price_coupon_claim_record(coupon_id, status); + -- 券码查询优化 CREATE INDEX idx_voucher_code ON price_voucher_code(code); CREATE INDEX idx_face_scenic ON price_voucher_code(face_id, scenic_id); --- 批次查询优化 +-- 批次查询优化 CREATE INDEX idx_scenic_broker ON price_voucher_batch_config(scenic_id, broker_id); -- 使用记录与打印记录查询优化(示例) @@ -579,10 +642,37 @@ CREATE INDEX idx_print_face_scenic ON voucher_print_record(face_id, scenic_id); - 券码表可能数据量较大,考虑按景区维度分表或归档 - 定期清理已删除的过期数据 - 使用数据完整性检查 SQL 验证统计数据准确性 +- **优惠券领取记录表查询优化** (v1.0.0): 为 `(user_id, coupon_id)` 添加复合索引以加速用户领取次数统计 ## 兼容性与注意事项 - 本模块使用 PageHelper(优惠券相关)与 MyBatis‑Plus(券码/一口价等)并存,请根据对应 Service/Mapper 选择分页与查询方式。 - 优惠优先级及叠加规则以各 Provider 与业务配置为准,避免在外层重复实现优先级判断逻辑。 - 若扩展新的优惠类型,务必实现 `IDiscountProvider` 并在 `IDiscountDetectionService` 中完成注册(当前实现通过组件扫描自动注册并排序)。 +- **优惠券数量管理** (v1.0.0): 现有代码已调整为领取时更新 `claimedQuantity`,使用时更新 `usedQuantity`。如业务需求不同,请调整 `CouponServiceImpl.claimCoupon()` 和 `CouponServiceImpl.useCoupon()` 逻辑。 + +## 版本更新记录 + +### v1.0.0 (2025-11-16) +**优惠券用户领取数量限制功能** + +新增特性: +- ✅ 支持为每个优惠券配置用户领取数量限制 (`userClaimLimit`) +- ✅ 区分已领取数量 (`claimedQuantity`) 和已使用数量 (`usedQuantity`) +- ✅ 新增错误码 `CLAIM_LIMIT_REACHED` 处理超限场景 +- ✅ 新增统计方法 `PriceCouponClaimRecordMapper.countUserCouponClaims()` + +数据库变更: +- 表 `price_coupon_config` 新增字段 `user_claim_limit INT` +- 表 `price_coupon_config` 新增字段 `claimed_quantity INT` +- 建议索引 `idx_user_coupon ON price_coupon_claim_record(user_id, coupon_id)` + +受影响文件: +- `entity/PriceCouponConfig.java` +- `mapper/PriceCouponClaimRecordMapper.java` +- `service/impl/CouponServiceImpl.java` +- `dto/CouponClaimResult.java` +- `dto/CouponInfo.java` + +迁移指南: 详见 `docs/coupon-user-claim-limit-guide.md` diff --git a/src/main/java/com/ycwl/basic/pricing/dto/CouponClaimResult.java b/src/main/java/com/ycwl/basic/pricing/dto/CouponClaimResult.java index b36c51b7..2a8f66c1 100644 --- a/src/main/java/com/ycwl/basic/pricing/dto/CouponClaimResult.java +++ b/src/main/java/com/ycwl/basic/pricing/dto/CouponClaimResult.java @@ -98,6 +98,7 @@ public class CouponClaimResult { public static final String ERROR_COUPON_INACTIVE = "COUPON_INACTIVE"; public static final String ERROR_COUPON_OUT_OF_STOCK = "COUPON_OUT_OF_STOCK"; public static final String ERROR_ALREADY_CLAIMED = "ALREADY_CLAIMED"; + public static final String ERROR_CLAIM_LIMIT_REACHED = "CLAIM_LIMIT_REACHED"; public static final String ERROR_INVALID_PARAMS = "INVALID_PARAMS"; public static final String ERROR_SYSTEM_ERROR = "SYSTEM_ERROR"; } \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/dto/CouponInfo.java b/src/main/java/com/ycwl/basic/pricing/dto/CouponInfo.java index 6d315b77..e3d85639 100644 --- a/src/main/java/com/ycwl/basic/pricing/dto/CouponInfo.java +++ b/src/main/java/com/ycwl/basic/pricing/dto/CouponInfo.java @@ -30,9 +30,14 @@ public class CouponInfo { * 优惠值 */ private BigDecimal discountValue; - + /** * 实际优惠金额 */ private BigDecimal actualDiscountAmount; + + /** + * 每个用户可领取数量限制(NULL表示不限制) + */ + private Integer userClaimLimit; } \ No newline at end of file diff --git a/src/main/java/com/ycwl/basic/pricing/entity/PriceCouponConfig.java b/src/main/java/com/ycwl/basic/pricing/entity/PriceCouponConfig.java index 06be0ce9..824bc248 100644 --- a/src/main/java/com/ycwl/basic/pricing/entity/PriceCouponConfig.java +++ b/src/main/java/com/ycwl/basic/pricing/entity/PriceCouponConfig.java @@ -60,7 +60,17 @@ public class PriceCouponConfig { * 已使用数量 */ private Integer usedQuantity; - + + /** + * 已领取数量(区分于已使用数量) + */ + private Integer claimedQuantity; + + /** + * 每个用户可领取数量限制(NULL表示不限制) + */ + private Integer userClaimLimit; + /** * 生效时间 */ diff --git a/src/main/java/com/ycwl/basic/pricing/mapper/PriceCouponClaimRecordMapper.java b/src/main/java/com/ycwl/basic/pricing/mapper/PriceCouponClaimRecordMapper.java index 7b30e035..f08241ec 100644 --- a/src/main/java/com/ycwl/basic/pricing/mapper/PriceCouponClaimRecordMapper.java +++ b/src/main/java/com/ycwl/basic/pricing/mapper/PriceCouponClaimRecordMapper.java @@ -33,9 +33,16 @@ public interface PriceCouponClaimRecordMapper extends BaseMapper= coupon.getTotalQuantity()) { + if (coupon.getTotalQuantity() != null && coupon.getClaimedQuantity() != null) { + if (coupon.getClaimedQuantity() >= coupon.getTotalQuantity()) { return CouponClaimResult.failure(CouponClaimResult.ERROR_COUPON_OUT_OF_STOCK, "优惠券已领完"); } } - - // 6. 检查用户是否已经领取过该优惠券 + + // 6. 检查用户领取数量限制 + if (coupon.getUserClaimLimit() != null && coupon.getUserClaimLimit() > 0) { + int userClaimCount = couponClaimRecordMapper.countUserCouponClaims( + request.getUserId(), request.getCouponId()); + if (userClaimCount >= coupon.getUserClaimLimit()) { + return CouponClaimResult.failure( + CouponClaimResult.ERROR_CLAIM_LIMIT_REACHED, + "您已达到该优惠券的领取上限(" + coupon.getUserClaimLimit() + "张)"); + } + } + + // 7. 检查用户是否已经领取过该优惠券(兼容旧逻辑,双重保护) PriceCouponClaimRecord existingRecord = couponClaimRecordMapper.selectUserCouponRecord( request.getUserId(), request.getCouponId()); if (existingRecord != null) { return CouponClaimResult.failure(CouponClaimResult.ERROR_ALREADY_CLAIMED, "您已经领取过该优惠券"); } - - // 7. 创建领取记录 + + // 8. 创建领取记录 Date claimTime = new Date(); PriceCouponClaimRecord claimRecord = new PriceCouponClaimRecord(); claimRecord.setCouponId(request.getCouponId()); @@ -254,26 +266,26 @@ public class CouponServiceImpl implements ICouponService { claimRecord.setCreateTime(claimTime); claimRecord.setUpdateTime(claimTime); claimRecord.setDeleted(0); - - // 8. 插入领取记录 + + // 9. 插入领取记录 int insertResult = couponClaimRecordMapper.insert(claimRecord); if (insertResult <= 0) { - log.error("插入优惠券领取记录失败: userId={}, couponId={}", + log.error("插入优惠券领取记录失败: userId={}, couponId={}", request.getUserId(), request.getCouponId()); return CouponClaimResult.failure(CouponClaimResult.ERROR_SYSTEM_ERROR, "领取失败,请稍后重试"); } - - // 9. 更新优惠券已使用数量(如果有总量限制) + + // 10. 更新优惠券已领取数量(区分于已使用数量) if (coupon.getTotalQuantity() != null) { - int updatedUsedQuantity = (coupon.getUsedQuantity() == null ? 0 : coupon.getUsedQuantity()) + 1; - coupon.setUsedQuantity(updatedUsedQuantity); + int updatedClaimedQuantity = (coupon.getClaimedQuantity() == null ? 0 : coupon.getClaimedQuantity()) + 1; + coupon.setClaimedQuantity(updatedClaimedQuantity); couponConfigMapper.updateById(coupon); } - - log.info("优惠券领取成功: userId={}, couponId={}, claimRecordId={}", + + log.info("优惠券领取成功: userId={}, couponId={}, claimRecordId={}", request.getUserId(), request.getCouponId(), claimRecord.getId()); - - // 10. 返回成功结果 + + // 11. 返回成功结果 return CouponClaimResult.success(claimRecord, coupon); } catch (Exception e) { From 88ad6d6b6f3e13263045997c1633385057ca4468 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Mon, 17 Nov 2025 00:30:58 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(pricing):=20=E4=BF=AE=E5=A4=8D=E4=BC=98?= =?UTF-8?q?=E6=83=A0=E5=88=B8=E5=BA=93=E5=AD=98=E6=A3=80=E6=9F=A5=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修正totalQuantity为NULL或0时不限制总量的判断逻辑 - 优化claimedQuantity为空时的默认值处理 - 仅在totalQuantity大于0时更新已领取数量 - 完善MD文档中字段语义描述和配置示例 - 更新SQL表字段说明及典型配置组合示例 --- .../java/com/ycwl/basic/pricing/CLAUDE.md | 46 +++++++++++++------ .../service/impl/CouponServiceImpl.java | 9 ++-- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/ycwl/basic/pricing/CLAUDE.md b/src/main/java/com/ycwl/basic/pricing/CLAUDE.md index 383380bf..0742dcdb 100644 --- a/src/main/java/com/ycwl/basic/pricing/CLAUDE.md +++ b/src/main/java/com/ycwl/basic/pricing/CLAUDE.md @@ -128,40 +128,54 @@ public enum CouponStatus { CLAIMED("claimed", ...), USED("used", ...), EXPIRED(" #### 优惠券数量管理机制 (v1.0.0更新) ```java -// PriceCouponConfig 实体新增字段 -private Integer userClaimLimit; // 每个用户可领取数量限制(NULL表示不限制) +// PriceCouponConfig 实体字段 +private Integer totalQuantity; // 发行总量(NULL或0表示不限制) +private Integer userClaimLimit; // 每个用户可领取数量限制(NULL或0表示不限制) private Integer claimedQuantity; // 已领取数量(区分于已使用数量) private Integer usedQuantity; // 已使用数量(实际消耗时更新) +// 字段语义 +totalQuantity: NULL/0=不限总量, >0=限制总量 +userClaimLimit: NULL/0=不限用户, >0=限制单用户 +claimedQuantity: 仅在totalQuantity>0时更新 +usedQuantity: 实际使用时更新 + // 领取流程检查顺序 1. 检查优惠券是否存在且启用 2. 检查有效期 -3. 检查总量库存: claimedQuantity < totalQuantity -4. 检查用户领取限制: countUserClaims(userId, couponId) < userClaimLimit -5. 创建领取记录并更新 claimedQuantity + 1 +3. 检查总量库存: totalQuantity>0 时,检查 claimedQuantity < totalQuantity +4. 检查用户领取限制: userClaimLimit>0 时,检查 countUserClaims(userId, couponId) < userClaimLimit +5. 创建领取记录并更新 claimedQuantity (仅当totalQuantity>0时) ``` #### 用户领取限制配置示例 ```java -// 场景1: 新人专享券,每人限领1张 +// 场景1: 新人专享券,每人限领1张,总量1000张 { "couponName": "新人专享券", "userClaimLimit": 1, "totalQuantity": 1000 } -// 场景2: 活动券,每人限领3张 +// 场景2: 活动券,每人限领3张,总量5000张 { "couponName": "618促销券", "userClaimLimit": 3, "totalQuantity": 5000 } -// 场景3: 不限制领取次数 +// 场景3: 不限制领取次数,不限制总量 { "couponName": "会员专享券", "userClaimLimit": null, // 或 0 - "totalQuantity": 10000 + "totalQuantity": null // 或 0,表示无限量 +} + +// 场景4: 不限用户次数,但限制总量 +{ + "couponName": "限量抢购券", + "userClaimLimit": null, // 不限单用户 + "totalQuantity": 500 // 总共500张 } ``` @@ -602,14 +616,20 @@ public class PriceCalculationResult { ### price_coupon_config 表字段说明 (v1.0.0更新) ```sql -- 核心数量管理字段 -total_quantity INT -- 发行总量 -claimed_quantity INT -- 已领取数量(v1.0.0新增,领取时+1) +total_quantity INT -- 发行总量(NULL或0表示不限制,>0表示限量) +claimed_quantity INT -- 已领取数量(v1.0.0新增,领取时+1,仅在total_quantity>0时更新) used_quantity INT -- 已使用数量(实际使用时+1) -user_claim_limit INT -- 每个用户可领取数量限制(v1.0.0新增,NULL表示不限制) +user_claim_limit INT -- 每个用户可领取数量限制(v1.0.0新增,NULL或0表示不限制,>0表示限制) -- 字段关系 -- claimed_quantity >= used_quantity (已领取 >= 已使用) --- claimed_quantity <= total_quantity (已领取 <= 总量) +-- claimed_quantity <= total_quantity (已领取 <= 总量,仅当total_quantity>0时适用) + +-- 典型配置组合 +-- 1. 无限量发行: total_quantity=NULL/0, user_claim_limit=NULL/0 +-- 2. 限量限人: total_quantity=1000, user_claim_limit=1 +-- 3. 限量不限人: total_quantity=500, user_claim_limit=NULL/0 +-- 4. 不限量但限人: total_quantity=NULL/0, user_claim_limit=3 ``` ### 新增表结构 diff --git a/src/main/java/com/ycwl/basic/pricing/service/impl/CouponServiceImpl.java b/src/main/java/com/ycwl/basic/pricing/service/impl/CouponServiceImpl.java index 6deaa80d..904e1b10 100644 --- a/src/main/java/com/ycwl/basic/pricing/service/impl/CouponServiceImpl.java +++ b/src/main/java/com/ycwl/basic/pricing/service/impl/CouponServiceImpl.java @@ -231,8 +231,10 @@ public class CouponServiceImpl implements ICouponService { } // 5. 检查库存(如果有总量限制) - if (coupon.getTotalQuantity() != null && coupon.getClaimedQuantity() != null) { - if (coupon.getClaimedQuantity() >= coupon.getTotalQuantity()) { + // totalQuantity为NULL或0表示不限制总量 + if (coupon.getTotalQuantity() != null && coupon.getTotalQuantity() > 0) { + int currentClaimed = (coupon.getClaimedQuantity() == null ? 0 : coupon.getClaimedQuantity()); + if (currentClaimed >= coupon.getTotalQuantity()) { return CouponClaimResult.failure(CouponClaimResult.ERROR_COUPON_OUT_OF_STOCK, "优惠券已领完"); } } @@ -276,7 +278,8 @@ public class CouponServiceImpl implements ICouponService { } // 10. 更新优惠券已领取数量(区分于已使用数量) - if (coupon.getTotalQuantity() != null) { + // 仅在有总量限制时才更新claimedQuantity(totalQuantity为正整数) + if (coupon.getTotalQuantity() != null && coupon.getTotalQuantity() > 0) { int updatedClaimedQuantity = (coupon.getClaimedQuantity() == null ? 0 : coupon.getClaimedQuantity()) + 1; coupon.setClaimedQuantity(updatedClaimedQuantity); couponConfigMapper.updateById(coupon); From 7c906d5529ffee31318a4a9ad6f26fa3f914ec78 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Mon, 17 Nov 2025 00:53:48 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(pricing):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=BC=98=E6=83=A0=E5=88=B8=E9=A2=86=E5=8F=96=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E4=B8=8E=E5=B9=B6=E5=8F=91=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为 CouponInvalidException 添加错误码支持 - 在 countUserCouponClaims 查询中添加 FOR UPDATE 锁 - 新增 incrementClaimedQuantityIfAvailable 方法用于原子性增加已领取数量 - 移除重复的用户优惠券领取检查逻辑 - 调整领取流程步骤编号并优化事务回滚处理 - 增加对优惠券库存耗尽情况的业务异常处理 - 使用 --- .../exception/CouponInvalidException.java | 25 ++++++++++--- .../mapper/PriceCouponClaimRecordMapper.java | 4 +- .../mapper/PriceCouponConfigMapper.java | 10 ++++- .../service/impl/CouponServiceImpl.java | 37 ++++++++++++------- 4 files changed, 54 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/ycwl/basic/pricing/exception/CouponInvalidException.java b/src/main/java/com/ycwl/basic/pricing/exception/CouponInvalidException.java index 73a6fa40..e4be45da 100644 --- a/src/main/java/com/ycwl/basic/pricing/exception/CouponInvalidException.java +++ b/src/main/java/com/ycwl/basic/pricing/exception/CouponInvalidException.java @@ -4,12 +4,27 @@ package com.ycwl.basic.pricing.exception; * 优惠券无效异常 */ public class CouponInvalidException extends RuntimeException { - + + private final String errorCode; + public CouponInvalidException(String message) { - super(message); + this(null, message, null); } - + public CouponInvalidException(String message, Throwable cause) { - super(message, cause); + this(null, message, cause); } -} \ No newline at end of file + + public CouponInvalidException(String errorCode, String message) { + this(errorCode, message, null); + } + + public CouponInvalidException(String errorCode, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + } + + public String getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/com/ycwl/basic/pricing/mapper/PriceCouponClaimRecordMapper.java b/src/main/java/com/ycwl/basic/pricing/mapper/PriceCouponClaimRecordMapper.java index f08241ec..107ba60b 100644 --- a/src/main/java/com/ycwl/basic/pricing/mapper/PriceCouponClaimRecordMapper.java +++ b/src/main/java/com/ycwl/basic/pricing/mapper/PriceCouponClaimRecordMapper.java @@ -40,7 +40,7 @@ public interface PriceCouponClaimRecordMapper extends BaseMapper selectScenicCouponUsageStats(@Param("scenicId") String scenicId); -} \ No newline at end of file +} diff --git a/src/main/java/com/ycwl/basic/pricing/mapper/PriceCouponConfigMapper.java b/src/main/java/com/ycwl/basic/pricing/mapper/PriceCouponConfigMapper.java index 077e470d..71496c2e 100644 --- a/src/main/java/com/ycwl/basic/pricing/mapper/PriceCouponConfigMapper.java +++ b/src/main/java/com/ycwl/basic/pricing/mapper/PriceCouponConfigMapper.java @@ -38,6 +38,14 @@ public interface PriceCouponConfigMapper extends BaseMapper { @Update("UPDATE price_coupon_config SET used_quantity = used_quantity + 1, " + "update_time = NOW() WHERE id = #{couponId} AND used_quantity < total_quantity") int incrementUsedQuantity(Long couponId); + + /** + * 原子性增加已领取数量(仅对有限库存的优惠券生效) + */ + @Update("UPDATE price_coupon_config SET claimed_quantity = COALESCE(claimed_quantity, 0) + 1, " + + "update_time = NOW() WHERE id = #{couponId} AND total_quantity IS NOT NULL AND total_quantity > 0 " + + "AND COALESCE(claimed_quantity, 0) < total_quantity") + int incrementClaimedQuantityIfAvailable(@Param("couponId") Long couponId); /** * 插入优惠券配置 @@ -133,4 +141,4 @@ public interface PriceCouponConfigMapper extends BaseMapper { "SUM(used_quantity) as used_quantity " + "FROM price_coupon_config WHERE scenic_id = #{scenicId}") java.util.Map selectScenicCouponConfigStats(@Param("scenicId") String scenicId); -} \ No newline at end of file +} diff --git a/src/main/java/com/ycwl/basic/pricing/service/impl/CouponServiceImpl.java b/src/main/java/com/ycwl/basic/pricing/service/impl/CouponServiceImpl.java index 904e1b10..e9d0e15f 100644 --- a/src/main/java/com/ycwl/basic/pricing/service/impl/CouponServiceImpl.java +++ b/src/main/java/com/ycwl/basic/pricing/service/impl/CouponServiceImpl.java @@ -18,6 +18,7 @@ import java.util.Date; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.interceptor.TransactionAspectSupport; import java.math.BigDecimal; import java.math.RoundingMode; @@ -243,6 +244,7 @@ public class CouponServiceImpl implements ICouponService { if (coupon.getUserClaimLimit() != null && coupon.getUserClaimLimit() > 0) { int userClaimCount = couponClaimRecordMapper.countUserCouponClaims( request.getUserId(), request.getCouponId()); + // countUserCouponClaims 使用 FOR UPDATE + 复合索引,确保并发下的计数准确 if (userClaimCount >= coupon.getUserClaimLimit()) { return CouponClaimResult.failure( CouponClaimResult.ERROR_CLAIM_LIMIT_REACHED, @@ -250,14 +252,7 @@ public class CouponServiceImpl implements ICouponService { } } - // 7. 检查用户是否已经领取过该优惠券(兼容旧逻辑,双重保护) - PriceCouponClaimRecord existingRecord = couponClaimRecordMapper.selectUserCouponRecord( - request.getUserId(), request.getCouponId()); - if (existingRecord != null) { - return CouponClaimResult.failure(CouponClaimResult.ERROR_ALREADY_CLAIMED, "您已经领取过该优惠券"); - } - - // 8. 创建领取记录 + // 7. 创建领取记录 Date claimTime = new Date(); PriceCouponClaimRecord claimRecord = new PriceCouponClaimRecord(); claimRecord.setCouponId(request.getCouponId()); @@ -269,7 +264,7 @@ public class CouponServiceImpl implements ICouponService { claimRecord.setUpdateTime(claimTime); claimRecord.setDeleted(0); - // 9. 插入领取记录 + // 8. 插入领取记录 int insertResult = couponClaimRecordMapper.insert(claimRecord); if (insertResult <= 0) { log.error("插入优惠券领取记录失败: userId={}, couponId={}", @@ -277,24 +272,38 @@ public class CouponServiceImpl implements ICouponService { return CouponClaimResult.failure(CouponClaimResult.ERROR_SYSTEM_ERROR, "领取失败,请稍后重试"); } - // 10. 更新优惠券已领取数量(区分于已使用数量) + // 9. 更新优惠券已领取数量(区分于已使用数量) // 仅在有总量限制时才更新claimedQuantity(totalQuantity为正整数) if (coupon.getTotalQuantity() != null && coupon.getTotalQuantity() > 0) { + int affected = couponConfigMapper.incrementClaimedQuantityIfAvailable(coupon.getId()); + if (affected == 0) { + throw new CouponInvalidException( + CouponClaimResult.ERROR_COUPON_OUT_OF_STOCK, + "优惠券已被领取完,请稍后重试"); + } int updatedClaimedQuantity = (coupon.getClaimedQuantity() == null ? 0 : coupon.getClaimedQuantity()) + 1; coupon.setClaimedQuantity(updatedClaimedQuantity); - couponConfigMapper.updateById(coupon); } log.info("优惠券领取成功: userId={}, couponId={}, claimRecordId={}", request.getUserId(), request.getCouponId(), claimRecord.getId()); - // 11. 返回成功结果 + // 10. 返回成功结果 return CouponClaimResult.success(claimRecord, coupon); + } catch (CouponInvalidException e) { + TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); + log.warn("领取优惠券失败(业务校验不通过): userId={}, couponId={}, reason={}", + request.getUserId(), request.getCouponId(), e.getMessage()); + String errorCode = e.getErrorCode() == null + ? CouponClaimResult.ERROR_SYSTEM_ERROR + : e.getErrorCode(); + return CouponClaimResult.failure(errorCode, e.getMessage()); } catch (Exception e) { - log.error("领取优惠券失败: userId={}, couponId={}", + TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); + log.error("领取优惠券失败: userId={}, couponId={}", request.getUserId(), request.getCouponId(), e); return CouponClaimResult.failure(CouponClaimResult.ERROR_SYSTEM_ERROR, "系统错误,领取失败:" + e.getMessage()); } } -} \ No newline at end of file +} From 8efd16ba56c794d36ca512cf720b6d8af90eeaaa Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Mon, 17 Nov 2025 08:52:00 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(coupon):=20=E4=BC=98=E5=8C=96=E4=BA=8B?= =?UTF-8?q?=E5=8A=A1=E5=9B=9E=E6=BB=9A=E6=A0=87=E8=AE=B0=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加对无事务上下文情况的处理 - 避免在非事务环境下抛出异常 - 提高优惠券领取失败时的系统稳定性 --- .../pricing/service/impl/CouponServiceImpl.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/ycwl/basic/pricing/service/impl/CouponServiceImpl.java b/src/main/java/com/ycwl/basic/pricing/service/impl/CouponServiceImpl.java index e9d0e15f..1130f302 100644 --- a/src/main/java/com/ycwl/basic/pricing/service/impl/CouponServiceImpl.java +++ b/src/main/java/com/ycwl/basic/pricing/service/impl/CouponServiceImpl.java @@ -17,6 +17,7 @@ import lombok.RequiredArgsConstructor; import java.util.Date; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.NoTransactionException; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.interceptor.TransactionAspectSupport; @@ -292,7 +293,7 @@ public class CouponServiceImpl implements ICouponService { return CouponClaimResult.success(claimRecord, coupon); } catch (CouponInvalidException e) { - TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); + markRollbackOnly(); log.warn("领取优惠券失败(业务校验不通过): userId={}, couponId={}, reason={}", request.getUserId(), request.getCouponId(), e.getMessage()); String errorCode = e.getErrorCode() == null @@ -300,10 +301,18 @@ public class CouponServiceImpl implements ICouponService { : e.getErrorCode(); return CouponClaimResult.failure(errorCode, e.getMessage()); } catch (Exception e) { - TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); + markRollbackOnly(); log.error("领取优惠券失败: userId={}, couponId={}", request.getUserId(), request.getCouponId(), e); return CouponClaimResult.failure(CouponClaimResult.ERROR_SYSTEM_ERROR, "系统错误,领取失败:" + e.getMessage()); } } + + private void markRollbackOnly() { + try { + TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); + } catch (NoTransactionException ex) { + log.debug("未检测到Spring事务上下文,跳过回滚标记"); + } + } }