From b2e0aa9f28040be338a2e02abe8006dccebbb01c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Fri, 15 Aug 2025 00:35:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E4=BC=98=E6=83=A0=E5=88=B8):=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E4=BC=98=E6=83=A0=E5=88=B8=E7=8A=B6=E6=80=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增优惠券状态管理相关实体类字段和方法 - 实现优惠券状态自动更新和手动更新功能- 添加优惠券适用范围验证逻辑 - 新增优惠券状态查询和统计接口 - 优化数据库索引和查询性能 --- docs/COUPON_STATUS_FIX_SUMMARY.md | 173 ++++++++++ docs/COUPON_STATUS_MANAGEMENT.md | 281 +++++++++++++++ .../controller/CouponStatusController.java | 189 ++++++++++ .../controller/ShopUserCouponController.java | 59 +++- .../shop/entity/ShopCouponApplyItem.java | 8 + .../gxwebsoft/shop/entity/ShopUserCoupon.java | 84 +++++ .../shop/service/CouponStatusService.java | 154 +++++++++ .../service/impl/CouponStatusServiceImpl.java | 325 ++++++++++++++++++ .../service/impl/ShopOrderServiceImpl.java | 12 +- .../gxwebsoft/shop/task/CouponExpireTask.java | 86 +++++ src/main/resources/application.yml | 17 + .../sql/coupon_status_optimization.sql | 194 +++++++++++ .../shop/service/CouponStatusServiceTest.java | 113 ++++++ 13 files changed, 1681 insertions(+), 14 deletions(-) create mode 100644 docs/COUPON_STATUS_FIX_SUMMARY.md create mode 100644 docs/COUPON_STATUS_MANAGEMENT.md create mode 100644 src/main/java/com/gxwebsoft/shop/controller/CouponStatusController.java create mode 100644 src/main/java/com/gxwebsoft/shop/service/CouponStatusService.java create mode 100644 src/main/java/com/gxwebsoft/shop/service/impl/CouponStatusServiceImpl.java create mode 100644 src/main/java/com/gxwebsoft/shop/task/CouponExpireTask.java create mode 100644 src/main/resources/sql/coupon_status_optimization.sql create mode 100644 src/test/java/com/gxwebsoft/shop/service/CouponStatusServiceTest.java diff --git a/docs/COUPON_STATUS_FIX_SUMMARY.md b/docs/COUPON_STATUS_FIX_SUMMARY.md new file mode 100644 index 0000000..f216ad8 --- /dev/null +++ b/docs/COUPON_STATUS_FIX_SUMMARY.md @@ -0,0 +1,173 @@ +# 优惠券状态管理页面错误修复总结 + +## 🐛 发现的问题 + +### 1. 代码结构错误 +**位置**: `ShopUserCouponController.java` 第70-84行 +**问题**: for循环结构不完整,缺少循环体的闭合大括号 +```java +// 错误的代码结构 +for (ShopUserCoupon userCoupon : userCouponList) { + couponStatusService.checkAndUpdateCouponStatus(userCoupon); +} + ShopCoupon coupon = couponService.getById(userCoupon.getCouponId()); // 这行代码在循环外 +``` + +### 2. 实体类字段缺失 +**位置**: `ShopCouponApplyItem.java` +**问题**: 缺少 `goodsId` 和 `categoryId` 字段,导致优惠券适用范围验证失败 + +### 3. 服务依赖注入缺失 +**位置**: `ShopUserCouponController.java` +**问题**: 缺少 `CouponStatusService` 的注入 + +## ✅ 修复内容 + +### 1. 修复控制器代码结构 +```java +// 修复后的正确代码 +for (ShopUserCoupon userCoupon : userCouponList) { + // 使用新的状态管理服务检查和更新状态 + couponStatusService.checkAndUpdateCouponStatus(userCoupon); + + ShopCoupon coupon = couponService.getById(userCoupon.getCouponId()); + coupon.setCouponApplyCateList(couponApplyCateService.list( + new LambdaQueryWrapper() + .eq(ShopCouponApplyCate::getCouponId, userCoupon.getCouponId()) + )); + coupon.setCouponApplyItemList(couponApplyItemService.list( + new LambdaQueryWrapper() + .eq(ShopCouponApplyItem::getCouponId, userCoupon.getCouponId()) + )); + userCoupon.setCouponItem(coupon); +} +``` + +### 2. 完善实体类字段 +在 `ShopCouponApplyItem.java` 中添加了必要的字段: +```java +@Schema(description = "优惠券ID") +private Integer couponId; + +@Schema(description = "商品ID") +private Integer goodsId; + +@Schema(description = "分类ID") +private Integer categoryId; + +@Schema(description = "类型(1商品 2分类)") +private Integer type; +``` + +### 3. 添加服务依赖注入 +在 `ShopUserCouponController.java` 中添加: +```java +@Resource +private CouponStatusService couponStatusService; +``` + +### 4. 优化适用范围验证逻辑 +在 `CouponStatusServiceImpl.java` 中改进了验证逻辑: +```java +private boolean validateApplyRange(ShopUserCoupon userCoupon, List goodsIds) { + if (userCoupon.getApplyRange() == null || userCoupon.getApplyRange() == ShopUserCoupon.APPLY_ALL) { + return true; // 全部商品适用 + } + + if (userCoupon.getApplyRange() == ShopUserCoupon.APPLY_GOODS) { + // 指定商品适用 + List applyItems = shopCouponApplyItemService.list( + new LambdaQueryWrapper() + .eq(ShopCouponApplyItem::getCouponId, userCoupon.getCouponId()) + .eq(ShopCouponApplyItem::getType, 1) // 类型1表示商品 + .isNotNull(ShopCouponApplyItem::getGoodsId) + ); + + List applicableGoodsIds = applyItems.stream() + .map(ShopCouponApplyItem::getGoodsId) + .filter(goodsId -> goodsId != null) + .collect(Collectors.toList()); + + return goodsIds.stream().anyMatch(applicableGoodsIds::contains); + } + + if (userCoupon.getApplyRange() == ShopUserCoupon.APPLY_CATEGORY) { + // 指定分类适用 - 暂时返回true,实际项目中需要实现商品分类查询逻辑 + log.debug("分类适用范围验证暂未实现,默认通过"); + return true; + } + + return true; +} +``` + +## 🔧 修复的文件列表 + +1. **src/main/java/com/gxwebsoft/shop/controller/ShopUserCouponController.java** + - 修复了for循环结构错误 + - 添加了CouponStatusService依赖注入 + - 集成了新的状态管理功能 + +2. **src/main/java/com/gxwebsoft/shop/entity/ShopCouponApplyItem.java** + - 添加了goodsId字段 + - 添加了categoryId字段 + - 完善了字段注释 + +3. **src/main/java/com/gxwebsoft/shop/service/impl/CouponStatusServiceImpl.java** + - 优化了适用范围验证逻辑 + - 添加了空值检查 + - 改进了错误处理 + +## 🧪 测试验证 + +创建了测试类 `CouponStatusServiceTest.java` 来验证: +- 优惠券状态常量定义 +- 状态判断方法 +- 状态更新方法 +- 批量过期处理功能 + +## 📋 后续建议 + +### 1. 数据库字段同步 +确保数据库表 `shop_coupon_apply_item` 包含以下字段: +```sql +ALTER TABLE shop_coupon_apply_item +ADD COLUMN goods_id INT COMMENT '商品ID', +ADD COLUMN category_id INT COMMENT '分类ID'; +``` + +### 2. 完善分类适用范围验证 +需要实现商品分类查询逻辑,建议: +- 创建商品分类查询服务 +- 根据商品ID查询所属分类 +- 验证分类是否在优惠券适用范围内 + +### 3. 添加单元测试 +- 为所有新增的方法添加单元测试 +- 测试各种边界情况 +- 确保异常处理正确 + +### 4. 性能优化 +- 考虑添加缓存减少数据库查询 +- 批量处理大量优惠券状态更新 +- 优化查询条件和索引 + +## ✅ 修复验证 + +修复完成后,以下功能应该正常工作: + +1. **优惠券列表查询** - 不再出现编译错误 +2. **状态自动更新** - 过期优惠券自动标记 +3. **适用范围验证** - 商品范围验证正常 +4. **API接口调用** - 所有新增接口可正常访问 +5. **定时任务执行** - 过期处理任务正常运行 + +## 🚀 部署建议 + +1. **备份数据库** - 在部署前备份现有数据 +2. **执行SQL脚本** - 运行数据库优化脚本 +3. **重启应用** - 确保所有新功能生效 +4. **监控日志** - 观察定时任务和API调用日志 +5. **功能测试** - 验证所有优惠券功能正常 + +修复完成!现在优惠券状态管理功能应该可以正常使用了。 diff --git a/docs/COUPON_STATUS_MANAGEMENT.md b/docs/COUPON_STATUS_MANAGEMENT.md new file mode 100644 index 0000000..7f0939c --- /dev/null +++ b/docs/COUPON_STATUS_MANAGEMENT.md @@ -0,0 +1,281 @@ +# 优惠券状态管理功能说明 + +## 📋 功能概述 + +本功能实现了完整的优惠券状态管理系统,包括可用、已使用、过期三种状态的自动管理和API接口。 + +## 🎯 核心功能 + +### 1. 状态管理 +- **可用状态 (STATUS_UNUSED = 0)**: 优惠券未使用且未过期 +- **已使用状态 (STATUS_USED = 1)**: 优惠券已在订单中使用 +- **已过期状态 (STATUS_EXPIRED = 2)**: 优惠券已过期 + +### 2. 自动状态更新 +- 定时任务自动检测和更新过期优惠券 +- 查询时实时检查优惠券状态 +- 订单使用时自动更新状态 + +### 3. 状态验证 +- 订单使用前验证优惠券可用性 +- 检查最低消费金额限制 +- 验证适用商品范围 + +## 🔧 API接口 + +### 用户优惠券查询 + +#### 获取可用优惠券 +```http +GET /api/shop/user-coupon/my/available +``` + +#### 获取已使用优惠券 +```http +GET /api/shop/user-coupon/my/used +``` + +#### 获取已过期优惠券 +```http +GET /api/shop/user-coupon/my/expired +``` + +#### 获取优惠券统计 +```http +GET /api/shop/user-coupon/my/statistics +``` + +### 优惠券状态管理 + +#### 验证优惠券可用性 +```http +POST /api/shop/coupon-status/validate +Content-Type: application/json + +{ + "userCouponId": 1, + "totalAmount": 150.00, + "goodsIds": [1, 2, 3] +} +``` + +#### 使用优惠券 +```http +POST /api/shop/coupon-status/use +Content-Type: application/x-www-form-urlencoded + +userCouponId=1&orderId=123&orderNo=ORDER123456 +``` + +#### 退还优惠券 +```http +POST /api/shop/coupon-status/return/123 +``` + +## 💻 代码使用示例 + +### 1. 检查优惠券状态 +```java +@Autowired +private CouponStatusService couponStatusService; + +// 获取用户可用优惠券 +List availableCoupons = couponStatusService.getAvailableCoupons(userId); + +// 检查优惠券是否可用 +ShopUserCoupon coupon = shopUserCouponService.getById(couponId); +if (coupon.isAvailable()) { + // 优惠券可用 +} +``` + +### 2. 使用优惠券 +```java +// 验证优惠券 +CouponValidationResult result = couponStatusService.validateCouponForOrder( + userCouponId, totalAmount, goodsIds); + +if (result.isValid()) { + // 使用优惠券 + boolean success = couponStatusService.useCoupon(userCouponId, orderId, orderNo); +} +``` + +### 3. 实体类便捷方法 +```java +ShopUserCoupon userCoupon = shopUserCouponService.getById(id); + +// 判断状态 +boolean available = userCoupon.isAvailable(); // 是否可用 +boolean used = userCoupon.isUsed(); // 是否已使用 +boolean expired = userCoupon.isExpired(); // 是否已过期 + +// 获取状态描述 +String statusDesc = userCoupon.getStatusDesc(); // "可使用"、"已使用"、"已过期" + +// 标记为已使用 +userCoupon.markAsUsed(orderId, orderNo); + +// 标记为已过期 +userCoupon.markAsExpired(); +``` + +## ⏰ 定时任务 + +### 过期优惠券处理 +- **执行时间**: 每天凌晨2点(生产环境) +- **功能**: 自动将过期的优惠券状态更新为已过期 +- **配置**: `coupon.expire.cron` + +### 每小时状态检查(可选) +- **执行时间**: 每小时整点 +- **功能**: 及时发现和处理刚过期的优惠券 +- **环境**: 仅生产环境执行 + +## 🗄️ 数据库优化 + +### 索引优化 +```sql +-- 用户优惠券状态查询索引 +CREATE INDEX idx_user_coupon_status ON shop_user_coupon(user_id, status, expire_time); + +-- 过期优惠券查询索引 +CREATE INDEX idx_user_coupon_expire ON shop_user_coupon(expire_time) WHERE status = 0; + +-- 订单优惠券查询索引 +CREATE INDEX idx_user_coupon_order ON shop_user_coupon(order_id) WHERE status = 1; +``` + +### 视图简化查询 +```sql +-- 用户可用优惠券视图 +CREATE VIEW v_user_available_coupons AS +SELECT uc.*, c.name as coupon_name +FROM shop_user_coupon uc +LEFT JOIN shop_coupon c ON uc.coupon_id = c.id +WHERE uc.deleted = 0; +``` + +## 📊 状态统计 + +### 优惠券状态分布 +```sql +SELECT + status, + CASE + WHEN status = 0 THEN '未使用' + WHEN status = 1 THEN '已使用' + WHEN status = 2 THEN '已过期' + END as status_name, + COUNT(*) as count +FROM shop_user_coupon +WHERE deleted = 0 +GROUP BY status; +``` + +### 使用率统计 +```sql +SELECT + DATE(create_time) as date, + COUNT(*) as issued_count, + SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) as used_count, + ROUND(SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 2) as usage_rate +FROM shop_user_coupon +WHERE deleted = 0 +GROUP BY DATE(create_time); +``` + +## 🔧 配置说明 + +### application.yml 配置 +```yaml +# 优惠券配置 +coupon: + expire: + # 定时任务执行时间 + cron: "0 0 2 * * ?" # 每天凌晨2点 + status: + # 是否启用自动状态更新 + auto-update: true + # 批量处理大小 + batch-size: 1000 +``` + +## 🚀 部署步骤 + +### 1. 执行数据库脚本 +```bash +mysql -u root -p < src/main/resources/sql/coupon_status_optimization.sql +``` + +### 2. 更新应用配置 +确保 `application.yml` 中包含优惠券相关配置。 + +### 3. 重启应用 +重启应用以加载新的功能和定时任务。 + +### 4. 验证功能 +- 访问 API 文档: `http://localhost:9200/doc.html` +- 测试优惠券状态查询接口 +- 检查定时任务日志 + +## 🐛 故障排查 + +### 常见问题 + +1. **定时任务不执行** + - 检查 `@EnableScheduling` 注解 + - 确认 cron 表达式正确 + - 查看应用日志 + +2. **状态更新不及时** + - 检查数据库索引 + - 确认事务配置 + - 查看错误日志 + +3. **性能问题** + - 检查数据库索引是否生效 + - 优化查询条件 + - 考虑分页查询 + +### 日志监控 +```bash +# 查看定时任务日志 +grep "过期优惠券处理" logs/application.log + +# 查看状态更新日志 +grep "更新优惠券状态" logs/application.log +``` + +## 📈 性能优化建议 + +1. **数据库层面** + - 添加必要的索引 + - 定期清理过期数据 + - 使用分区表(大数据量时) + +2. **应用层面** + - 使用缓存减少数据库查询 + - 批量处理状态更新 + - 异步处理非关键操作 + +3. **监控告警** + - 监控优惠券使用率 + - 设置过期优惠券数量告警 + - 监控定时任务执行状态 + +## 🔄 后续扩展 + +1. **消息通知** + - 优惠券即将过期提醒 + - 优惠券使用成功通知 + +2. **数据分析** + - 优惠券使用趋势分析 + - 用户行为分析 + - ROI 计算 + +3. **高级功能** + - 优惠券组合使用 + - 动态优惠券推荐 + - A/B 测试支持 diff --git a/src/main/java/com/gxwebsoft/shop/controller/CouponStatusController.java b/src/main/java/com/gxwebsoft/shop/controller/CouponStatusController.java new file mode 100644 index 0000000..ded942f --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/controller/CouponStatusController.java @@ -0,0 +1,189 @@ +package com.gxwebsoft.shop.controller; + +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.shop.entity.ShopUserCoupon; +import com.gxwebsoft.shop.service.CouponStatusService; +import com.gxwebsoft.shop.service.CouponStatusService.CouponStatusResult; +import com.gxwebsoft.shop.service.CouponStatusService.CouponValidationResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 优惠券状态管理控制器 + * + * @author WebSoft + * @since 2025-01-15 + */ +@Slf4j +@Tag(name = "优惠券状态管理") +@RestController +@RequestMapping("/api/shop/coupon-status") +public class CouponStatusController extends BaseController { + + @Autowired + private CouponStatusService couponStatusService; + + @Operation(summary = "获取当前用户可用优惠券") + @GetMapping("/available") + public ApiResult> getAvailableCoupons() { + try { + List coupons = couponStatusService.getAvailableCoupons(getLoginUserId()); + return success("获取成功", coupons); + } catch (Exception e) { + log.error("获取可用优惠券失败", e); + return fail("获取失败",null); + } + } + + @Operation(summary = "获取当前用户已使用优惠券") + @GetMapping("/used") + public ApiResult> getUsedCoupons() { + try { + List coupons = couponStatusService.getUsedCoupons(getLoginUserId()); + return success("获取成功", coupons); + } catch (Exception e) { + log.error("获取已使用优惠券失败", e); + return fail("获取失败",null); + } + } + + @Operation(summary = "获取当前用户已过期优惠券") + @GetMapping("/expired") + public ApiResult> getExpiredCoupons() { + try { + List coupons = couponStatusService.getExpiredCoupons(getLoginUserId()); + return success("获取成功", coupons); + } catch (Exception e) { + log.error("获取已过期优惠券失败", e); + return fail("获取失败",null); + } + } + + @Operation(summary = "获取当前用户所有优惠券(按状态分类)") + @GetMapping("/all-grouped") + public ApiResult getAllCouponsGrouped() { + try { + CouponStatusResult result = couponStatusService.getUserCouponsGroupByStatus(getLoginUserId()); + return success("获取成功", result); + } catch (Exception e) { + log.error("获取优惠券分类失败", e); + return fail("获取失败",null); + } + } + + @Operation(summary = "验证优惠券是否可用于订单") + @PostMapping("/validate") + public ApiResult validateCouponForOrder( + @Parameter(description = "用户优惠券ID") @RequestParam Long userCouponId, + @Parameter(description = "订单总金额") @RequestParam BigDecimal totalAmount, + @Parameter(description = "商品ID列表") @RequestBody List goodsIds) { + try { + CouponValidationResult result = couponStatusService.validateCouponForOrder( + userCouponId, totalAmount, goodsIds); + return success(result.getMessage(), result); + } catch (Exception e) { + log.error("验证优惠券失败", e); + return fail("验证失败",null); + } + } + + @Operation(summary = "使用优惠券") + @PostMapping("/use") + public ApiResult useCoupon( + @Parameter(description = "用户优惠券ID") @RequestParam Long userCouponId, + @Parameter(description = "订单ID") @RequestParam Integer orderId, + @Parameter(description = "订单号") @RequestParam String orderNo) { + try { + boolean success = couponStatusService.useCoupon(userCouponId, orderId, orderNo); + if (success) { + return success("使用成功"); + } else { + return fail("使用失败"); + } + } catch (Exception e) { + log.error("使用优惠券失败", e); + return fail("使用失败"); + } + } + + @Operation(summary = "退还优惠券(订单取消时)") + @PostMapping("/return/{orderId}") + public ApiResult returnCoupon( + @Parameter(description = "订单ID") @PathVariable Integer orderId) { + try { + boolean success = couponStatusService.returnCoupon(orderId); + if (success) { + return success("退还成功"); + } else { + return fail("退还失败"); + } + } catch (Exception e) { + log.error("退还优惠券失败", e); + return fail("退还失败"); + } + } + + @PreAuthorize("hasAuthority('shop:coupon:manage')") + @Operation(summary = "批量更新过期优惠券状态(管理员)") + @PostMapping("/update-expired") + public ApiResult updateExpiredCoupons() { + try { + int updatedCount = couponStatusService.updateExpiredCoupons(); + return success("更新完成,共更新 " + updatedCount + " 张优惠券"); + } catch (Exception e) { + log.error("批量更新过期优惠券失败", e); + return fail("更新失败"); + } + } + + @Operation(summary = "获取优惠券状态统计") + @GetMapping("/statistics") + public ApiResult getCouponStatistics() { + try { + CouponStatusResult result = couponStatusService.getUserCouponsGroupByStatus(getLoginUserId()); + + CouponStatistics statistics = new CouponStatistics(); + statistics.setAvailableCount(result.getAvailableCoupons().size()); + statistics.setUsedCount(result.getUsedCoupons().size()); + statistics.setExpiredCount(result.getExpiredCoupons().size()); + statistics.setTotalCount(result.getTotalCount()); + + return success("获取成功", statistics); + } catch (Exception e) { + log.error("获取优惠券统计失败", e); + return fail("获取失败",null); + } + } + + /** + * 优惠券统计信息 + */ + public static class CouponStatistics { + private int availableCount; // 可用数量 + private int usedCount; // 已使用数量 + private int expiredCount; // 已过期数量 + private int totalCount; // 总数量 + + // Getters and Setters + public int getAvailableCount() { return availableCount; } + public void setAvailableCount(int availableCount) { this.availableCount = availableCount; } + + public int getUsedCount() { return usedCount; } + public void setUsedCount(int usedCount) { this.usedCount = usedCount; } + + public int getExpiredCount() { return expiredCount; } + public void setExpiredCount(int expiredCount) { this.expiredCount = expiredCount; } + + public int getTotalCount() { return totalCount; } + public void setTotalCount(int totalCount) { this.totalCount = totalCount; } + } +} diff --git a/src/main/java/com/gxwebsoft/shop/controller/ShopUserCouponController.java b/src/main/java/com/gxwebsoft/shop/controller/ShopUserCouponController.java index 2bbb1f7..9d7e1c2 100644 --- a/src/main/java/com/gxwebsoft/shop/controller/ShopUserCouponController.java +++ b/src/main/java/com/gxwebsoft/shop/controller/ShopUserCouponController.java @@ -1,6 +1,5 @@ package com.gxwebsoft.shop.controller; -import cn.hutool.core.date.DateUnit; import cn.hutool.core.date.LocalDateTimeUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.github.yulichang.wrapper.MPJLambdaWrapper; @@ -12,11 +11,11 @@ import com.gxwebsoft.shop.service.ShopCouponApplyCateService; import com.gxwebsoft.shop.service.ShopCouponApplyItemService; import com.gxwebsoft.shop.service.ShopCouponService; import com.gxwebsoft.shop.service.ShopUserCouponService; +import com.gxwebsoft.shop.service.CouponStatusService; import com.gxwebsoft.shop.entity.ShopUserCoupon; import com.gxwebsoft.shop.param.ShopUserCouponParam; import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.PageResult; -import com.gxwebsoft.common.core.web.PageParam; import com.gxwebsoft.common.core.web.BatchParam; import com.gxwebsoft.common.core.annotation.OperationLog; import com.gxwebsoft.common.system.entity.User; @@ -27,10 +26,8 @@ import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; import java.text.ParseException; -import java.text.SimpleDateFormat; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; -import java.util.Calendar; import java.util.List; /** @@ -51,6 +48,8 @@ public class ShopUserCouponController extends BaseController { private ShopCouponApplyCateService couponApplyCateService; @Resource private ShopCouponApplyItemService couponApplyItemService; + @Resource + private CouponStatusService couponStatusService; @Operation(summary = "用户优惠券列表") @PostMapping("/list") @@ -65,10 +64,9 @@ public class ShopUserCouponController extends BaseController { if (userCouponParam.getIsUse() != null) queryWrapper.eq(ShopUserCoupon::getIsUse, userCouponParam.getIsUse()); List userCouponList = shopUserCouponService.list(queryWrapper); for (ShopUserCoupon userCoupon : userCouponList) { - if (userCoupon.getEndTime().isBefore(LocalDateTime.now())) { - userCoupon.setIsExpire(1); - shopUserCouponService.updateById(userCoupon); - } + // 使用新的状态管理服务检查和更新状态 + couponStatusService.checkAndUpdateCouponStatus(userCoupon); + ShopCoupon coupon = couponService.getById(userCoupon.getCouponId()); coupon.setCouponApplyCateList(couponApplyCateService.list( new LambdaQueryWrapper() @@ -216,4 +214,49 @@ public class ShopUserCouponController extends BaseController { return fail("删除失败"); } + @Operation(summary = "获取我的可用优惠券") + @GetMapping("/my/available") + public ApiResult> getMyAvailableCoupons() { + try { + List coupons = couponStatusService.getAvailableCoupons(getLoginUserId()); + return success("获取成功", coupons); + } catch (Exception e) { + return fail("获取失败",null); + } + } + + @Operation(summary = "获取我的已使用优惠券") + @GetMapping("/my/used") + public ApiResult> getMyUsedCoupons() { + try { + List coupons = couponStatusService.getUsedCoupons(getLoginUserId()); + return success("获取成功", coupons); + } catch (Exception e) { + return fail("获取失败",null); + } + } + + @Operation(summary = "获取我的已过期优惠券") + @GetMapping("/my/expired") + public ApiResult> getMyExpiredCoupons() { + try { + List coupons = couponStatusService.getExpiredCoupons(getLoginUserId()); + return success("获取成功", coupons); + } catch (Exception e) { + return fail("获取失败",null); + } + } + + @Operation(summary = "获取我的优惠券统计") + @GetMapping("/my/statistics") + public ApiResult getMyCouponStatistics() { + try { + CouponStatusService.CouponStatusResult result = + couponStatusService.getUserCouponsGroupByStatus(getLoginUserId()); + return success("获取成功", result); + } catch (Exception e) { + return fail("获取失败",null); + } + } + } diff --git a/src/main/java/com/gxwebsoft/shop/entity/ShopCouponApplyItem.java b/src/main/java/com/gxwebsoft/shop/entity/ShopCouponApplyItem.java index 156bcd2..504d9d7 100644 --- a/src/main/java/com/gxwebsoft/shop/entity/ShopCouponApplyItem.java +++ b/src/main/java/com/gxwebsoft/shop/entity/ShopCouponApplyItem.java @@ -25,8 +25,16 @@ public class ShopCouponApplyItem implements Serializable { @TableId(value = "id", type = IdType.AUTO) private Integer id; + @Schema(description = "优惠券ID") private Integer couponId; + @Schema(description = "商品ID") + private Integer goodsId; + + @Schema(description = "分类ID") + private Integer categoryId; + + @Schema(description = "类型(1商品 2分类)") private Integer type; @Schema(description = "0服务1需求2闲置") diff --git a/src/main/java/com/gxwebsoft/shop/entity/ShopUserCoupon.java b/src/main/java/com/gxwebsoft/shop/entity/ShopUserCoupon.java index ea93e5a..cb3c12f 100644 --- a/src/main/java/com/gxwebsoft/shop/entity/ShopUserCoupon.java +++ b/src/main/java/com/gxwebsoft/shop/entity/ShopUserCoupon.java @@ -24,6 +24,26 @@ import lombok.EqualsAndHashCode; public class ShopUserCoupon implements Serializable { private static final long serialVersionUID = 1L; + // 优惠券类型常量 + public static final int TYPE_REDUCE = 10; // 满减券 + public static final int TYPE_DISCOUNT = 20; // 折扣券 + public static final int TYPE_FREE = 30; // 免费券 + + // 使用状态常量 + public static final int STATUS_UNUSED = 0; // 未使用 + public static final int STATUS_USED = 1; // 已使用 + public static final int STATUS_EXPIRED = 2; // 已过期 + + // 获取方式常量 + public static final int OBTAIN_ACTIVE = 10; // 主动领取 + public static final int OBTAIN_SYSTEM = 20; // 系统发放 + public static final int OBTAIN_ACTIVITY = 30; // 活动赠送 + + // 适用范围常量 + public static final int APPLY_ALL = 10; // 全部商品 + public static final int APPLY_GOODS = 20; // 指定商品 + public static final int APPLY_CATEGORY = 30; // 指定分类 + @Schema(description = "id") @TableId(value = "id", type = IdType.AUTO) private Long id; @@ -115,4 +135,68 @@ public class ShopUserCoupon implements Serializable { @TableField(exist = false) private ShopCoupon couponItem; + /** + * 判断优惠券是否可用 + * @return true-可用,false-不可用 + */ + public boolean isAvailable() { + return this.status != null && this.status == STATUS_UNUSED && !isExpired(); + } + + /** + * 判断优惠券是否已使用 + * @return true-已使用,false-未使用 + */ + public boolean isUsed() { + return this.status != null && this.status == STATUS_USED; + } + + /** + * 判断优惠券是否已过期 + * @return true-已过期,false-未过期 + */ + public boolean isExpired() { + if (this.status != null && this.status == STATUS_EXPIRED) { + return true; + } + return this.endTime != null && this.endTime.isBefore(LocalDateTime.now()); + } + + /** + * 获取优惠券状态描述 + * @return 状态描述 + */ + public String getStatusDesc() { + if (isExpired()) { + return "已过期"; + } else if (isUsed()) { + return "已使用"; + } else if (isAvailable()) { + return "可使用"; + } else { + return "未知状态"; + } + } + + /** + * 更新优惠券状态为已使用 + * @param orderId 订单ID + * @param orderNo 订单号 + */ + public void markAsUsed(Integer orderId, String orderNo) { + this.status = STATUS_USED; + this.isUse = 1; + this.useTime = LocalDateTime.now(); + this.orderId = orderId; + this.orderNo = orderNo; + } + + /** + * 更新优惠券状态为已过期 + */ + public void markAsExpired() { + this.status = STATUS_EXPIRED; + this.isExpire = 1; + } + } diff --git a/src/main/java/com/gxwebsoft/shop/service/CouponStatusService.java b/src/main/java/com/gxwebsoft/shop/service/CouponStatusService.java new file mode 100644 index 0000000..0bfad4a --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/service/CouponStatusService.java @@ -0,0 +1,154 @@ +package com.gxwebsoft.shop.service; + +import com.gxwebsoft.shop.entity.ShopUserCoupon; + +import java.util.List; + +/** + * 优惠券状态管理服务 + * + * @author WebSoft + * @since 2025-01-15 + */ +public interface CouponStatusService { + + /** + * 获取用户可用的优惠券列表 + * + * @param userId 用户ID + * @return 可用优惠券列表 + */ + List getAvailableCoupons(Integer userId); + + /** + * 获取用户已使用的优惠券列表 + * + * @param userId 用户ID + * @return 已使用优惠券列表 + */ + List getUsedCoupons(Integer userId); + + /** + * 获取用户已过期的优惠券列表 + * + * @param userId 用户ID + * @return 已过期优惠券列表 + */ + List getExpiredCoupons(Integer userId); + + /** + * 获取用户所有优惠券并按状态分类 + * + * @param userId 用户ID + * @return 分类后的优惠券列表 + */ + CouponStatusResult getUserCouponsGroupByStatus(Integer userId); + + /** + * 使用优惠券 + * + * @param userCouponId 用户优惠券ID + * @param orderId 订单ID + * @param orderNo 订单号 + * @return 是否成功 + */ + boolean useCoupon(Long userCouponId, Integer orderId, String orderNo); + + /** + * 退还优惠券(订单取消时) + * + * @param orderId 订单ID + * @return 是否成功 + */ + boolean returnCoupon(Integer orderId); + + /** + * 批量更新过期优惠券状态 + * + * @return 更新的数量 + */ + int updateExpiredCoupons(); + + /** + * 检查并更新单个优惠券状态 + * + * @param userCoupon 用户优惠券 + * @return 是否状态发生变化 + */ + boolean checkAndUpdateCouponStatus(ShopUserCoupon userCoupon); + + /** + * 验证优惠券是否可用于指定订单 + * + * @param userCouponId 用户优惠券ID + * @param totalAmount 订单总金额 + * @param goodsIds 商品ID列表 + * @return 验证结果 + */ + CouponValidationResult validateCouponForOrder(Long userCouponId, + java.math.BigDecimal totalAmount, + List goodsIds); + + /** + * 优惠券状态分类结果 + */ + class CouponStatusResult { + private List availableCoupons; // 可用优惠券 + private List usedCoupons; // 已使用优惠券 + private List expiredCoupons; // 已过期优惠券 + private int totalCount; // 总数量 + + // 构造函数 + public CouponStatusResult(List availableCoupons, + List usedCoupons, + List expiredCoupons) { + this.availableCoupons = availableCoupons; + this.usedCoupons = usedCoupons; + this.expiredCoupons = expiredCoupons; + this.totalCount = availableCoupons.size() + usedCoupons.size() + expiredCoupons.size(); + } + + // Getters and Setters + public List getAvailableCoupons() { return availableCoupons; } + public void setAvailableCoupons(List availableCoupons) { this.availableCoupons = availableCoupons; } + + public List getUsedCoupons() { return usedCoupons; } + public void setUsedCoupons(List usedCoupons) { this.usedCoupons = usedCoupons; } + + public List getExpiredCoupons() { return expiredCoupons; } + public void setExpiredCoupons(List expiredCoupons) { this.expiredCoupons = expiredCoupons; } + + public int getTotalCount() { return totalCount; } + public void setTotalCount(int totalCount) { this.totalCount = totalCount; } + } + + /** + * 优惠券验证结果 + */ + class CouponValidationResult { + private boolean valid; // 是否有效 + private String message; // 验证消息 + private java.math.BigDecimal discountAmount; // 优惠金额 + + public CouponValidationResult(boolean valid, String message) { + this.valid = valid; + this.message = message; + } + + public CouponValidationResult(boolean valid, String message, java.math.BigDecimal discountAmount) { + this.valid = valid; + this.message = message; + this.discountAmount = discountAmount; + } + + // Getters and Setters + public boolean isValid() { return valid; } + public void setValid(boolean valid) { this.valid = valid; } + + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + + public java.math.BigDecimal getDiscountAmount() { return discountAmount; } + public void setDiscountAmount(java.math.BigDecimal discountAmount) { this.discountAmount = discountAmount; } + } +} diff --git a/src/main/java/com/gxwebsoft/shop/service/impl/CouponStatusServiceImpl.java b/src/main/java/com/gxwebsoft/shop/service/impl/CouponStatusServiceImpl.java new file mode 100644 index 0000000..ceb68ab --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/service/impl/CouponStatusServiceImpl.java @@ -0,0 +1,325 @@ +package com.gxwebsoft.shop.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.gxwebsoft.shop.entity.ShopUserCoupon; +import com.gxwebsoft.shop.entity.ShopCoupon; +import com.gxwebsoft.shop.entity.ShopCouponApplyItem; +import com.gxwebsoft.shop.service.CouponStatusService; +import com.gxwebsoft.shop.service.ShopUserCouponService; +import com.gxwebsoft.shop.service.ShopCouponService; +import com.gxwebsoft.shop.service.ShopCouponApplyItemService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 优惠券状态管理服务实现 + * + * @author WebSoft + * @since 2025-01-15 + */ +@Slf4j +@Service +public class CouponStatusServiceImpl implements CouponStatusService { + + @Autowired + private ShopUserCouponService shopUserCouponService; + + @Autowired + private ShopCouponService shopCouponService; + + @Autowired + private ShopCouponApplyItemService shopCouponApplyItemService; + + @Override + public List getAvailableCoupons(Integer userId) { + List allCoupons = getUserCoupons(userId); + return allCoupons.stream() + .filter(ShopUserCoupon::isAvailable) + .collect(Collectors.toList()); + } + + @Override + public List getUsedCoupons(Integer userId) { + return shopUserCouponService.list( + new LambdaQueryWrapper() + .eq(ShopUserCoupon::getUserId, userId) + .eq(ShopUserCoupon::getStatus, ShopUserCoupon.STATUS_USED) + .orderByDesc(ShopUserCoupon::getUseTime) + ); + } + + @Override + public List getExpiredCoupons(Integer userId) { + // 先更新过期状态 + updateExpiredCouponsForUser(userId); + + return shopUserCouponService.list( + new LambdaQueryWrapper() + .eq(ShopUserCoupon::getUserId, userId) + .eq(ShopUserCoupon::getStatus, ShopUserCoupon.STATUS_EXPIRED) + .orderByDesc(ShopUserCoupon::getEndTime) + ); + } + + @Override + public CouponStatusResult getUserCouponsGroupByStatus(Integer userId) { + List availableCoupons = getAvailableCoupons(userId); + List usedCoupons = getUsedCoupons(userId); + List expiredCoupons = getExpiredCoupons(userId); + + return new CouponStatusResult(availableCoupons, usedCoupons, expiredCoupons); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean useCoupon(Long userCouponId, Integer orderId, String orderNo) { + try { + ShopUserCoupon userCoupon = shopUserCouponService.getById(userCouponId); + if (userCoupon == null) { + log.warn("优惠券不存在: {}", userCouponId); + return false; + } + + if (!userCoupon.isAvailable()) { + log.warn("优惠券不可用: {}, 状态: {}", userCouponId, userCoupon.getStatusDesc()); + return false; + } + + // 标记为已使用 + userCoupon.markAsUsed(orderId, orderNo); + + return shopUserCouponService.updateById(userCoupon); + } catch (Exception e) { + log.error("使用优惠券失败: {}", userCouponId, e); + return false; + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean returnCoupon(Integer orderId) { + try { + ShopUserCoupon userCoupon = shopUserCouponService.getOne( + new LambdaQueryWrapper() + .eq(ShopUserCoupon::getOrderId, orderId) + .eq(ShopUserCoupon::getStatus, ShopUserCoupon.STATUS_USED) + ); + + if (userCoupon == null) { + log.info("订单没有使用优惠券: {}", orderId); + return true; + } + + // 检查是否已过期 + if (userCoupon.isExpired()) { + log.warn("优惠券已过期,无法退还: {}", userCoupon.getId()); + return false; + } + + // 恢复为未使用状态 + userCoupon.setStatus(ShopUserCoupon.STATUS_UNUSED); + userCoupon.setIsUse(0); + userCoupon.setUseTime(null); + userCoupon.setOrderId(null); + userCoupon.setOrderNo(null); + + return shopUserCouponService.updateById(userCoupon); + } catch (Exception e) { + log.error("退还优惠券失败, 订单ID: {}", orderId, e); + return false; + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int updateExpiredCoupons() { + try { + // 查询所有未使用且已过期的优惠券 + List expiredCoupons = shopUserCouponService.list( + new LambdaQueryWrapper() + .eq(ShopUserCoupon::getStatus, ShopUserCoupon.STATUS_UNUSED) + .lt(ShopUserCoupon::getEndTime, LocalDateTime.now()) + ); + + if (expiredCoupons.isEmpty()) { + return 0; + } + + // 批量更新状态 + List expiredIds = expiredCoupons.stream() + .map(ShopUserCoupon::getId) + .collect(Collectors.toList()); + + boolean success = shopUserCouponService.update( + new LambdaUpdateWrapper() + .in(ShopUserCoupon::getId, expiredIds) + .set(ShopUserCoupon::getStatus, ShopUserCoupon.STATUS_EXPIRED) + .set(ShopUserCoupon::getIsExpire, 1) + ); + + int updatedCount = success ? expiredIds.size() : 0; + log.info("批量更新过期优惠券状态完成,更新数量: {}", updatedCount); + return updatedCount; + } catch (Exception e) { + log.error("批量更新过期优惠券状态失败", e); + return 0; + } + } + + @Override + public boolean checkAndUpdateCouponStatus(ShopUserCoupon userCoupon) { + if (userCoupon == null) { + return false; + } + + boolean statusChanged = false; + + // 检查是否过期 + if (userCoupon.getStatus() == ShopUserCoupon.STATUS_UNUSED && userCoupon.isExpired()) { + userCoupon.markAsExpired(); + statusChanged = true; + } + + // 如果状态发生变化,更新数据库 + if (statusChanged) { + try { + shopUserCouponService.updateById(userCoupon); + log.debug("更新优惠券状态: {} -> {}", userCoupon.getId(), userCoupon.getStatusDesc()); + } catch (Exception e) { + log.error("更新优惠券状态失败: {}", userCoupon.getId(), e); + return false; + } + } + + return statusChanged; + } + + @Override + public CouponValidationResult validateCouponForOrder(Long userCouponId, + BigDecimal totalAmount, + List goodsIds) { + try { + ShopUserCoupon userCoupon = shopUserCouponService.getById(userCouponId); + if (userCoupon == null) { + return new CouponValidationResult(false, "优惠券不存在"); + } + + // 检查优惠券状态 + if (!userCoupon.isAvailable()) { + return new CouponValidationResult(false, "优惠券" + userCoupon.getStatusDesc()); + } + + // 检查最低消费金额 + if (userCoupon.getMinPrice() != null && + totalAmount.compareTo(userCoupon.getMinPrice()) < 0) { + return new CouponValidationResult(false, + String.format("订单金额不满足最低消费要求,需满%s元", userCoupon.getMinPrice())); + } + + // 检查适用范围 + if (!validateApplyRange(userCoupon, goodsIds)) { + return new CouponValidationResult(false, "优惠券不适用于当前商品"); + } + + // 计算优惠金额 + BigDecimal discountAmount = calculateDiscountAmount(userCoupon, totalAmount); + + return new CouponValidationResult(true, "优惠券可用", discountAmount); + } catch (Exception e) { + log.error("验证优惠券失败: {}", userCouponId, e); + return new CouponValidationResult(false, "验证优惠券时发生错误"); + } + } + + /** + * 获取用户所有优惠券 + */ + private List getUserCoupons(Integer userId) { + List coupons = shopUserCouponService.list( + new LambdaQueryWrapper() + .eq(ShopUserCoupon::getUserId, userId) + .orderByAsc(ShopUserCoupon::getEndTime) + ); + + // 检查并更新状态 + coupons.forEach(this::checkAndUpdateCouponStatus); + + return coupons; + } + + /** + * 更新指定用户的过期优惠券 + */ + private void updateExpiredCouponsForUser(Integer userId) { + try { + shopUserCouponService.update( + new LambdaUpdateWrapper() + .eq(ShopUserCoupon::getUserId, userId) + .eq(ShopUserCoupon::getStatus, ShopUserCoupon.STATUS_UNUSED) + .lt(ShopUserCoupon::getEndTime, LocalDateTime.now()) + .set(ShopUserCoupon::getStatus, ShopUserCoupon.STATUS_EXPIRED) + .set(ShopUserCoupon::getIsExpire, 1) + ); + } catch (Exception e) { + log.error("更新用户过期优惠券失败, userId: {}", userId, e); + } + } + + /** + * 验证优惠券适用范围 + */ + private boolean validateApplyRange(ShopUserCoupon userCoupon, List goodsIds) { + if (userCoupon.getApplyRange() == null || userCoupon.getApplyRange() == ShopUserCoupon.APPLY_ALL) { + return true; // 全部商品适用 + } + + if (userCoupon.getApplyRange() == ShopUserCoupon.APPLY_GOODS) { + // 指定商品适用 + List applyItems = shopCouponApplyItemService.list( + new LambdaQueryWrapper() + .eq(ShopCouponApplyItem::getCouponId, userCoupon.getCouponId()) + .eq(ShopCouponApplyItem::getType, 1) // 类型1表示商品 + .isNotNull(ShopCouponApplyItem::getGoodsId) + ); + + List applicableGoodsIds = applyItems.stream() + .map(ShopCouponApplyItem::getGoodsId) + .filter(goodsId -> goodsId != null) + .collect(Collectors.toList()); + + return goodsIds.stream().anyMatch(applicableGoodsIds::contains); + } + + if (userCoupon.getApplyRange() == ShopUserCoupon.APPLY_CATEGORY) { + // 指定分类适用 - 这里需要根据商品ID查询分类ID,然后验证 + // 暂时返回true,实际项目中需要实现商品分类查询逻辑 + log.debug("分类适用范围验证暂未实现,默认通过"); + return true; + } + + return true; + } + + /** + * 计算优惠金额 + */ + private BigDecimal calculateDiscountAmount(ShopUserCoupon userCoupon, BigDecimal totalAmount) { + if (userCoupon.getType() == ShopUserCoupon.TYPE_REDUCE) { + // 满减券 + return userCoupon.getReducePrice(); + } else if (userCoupon.getType() == ShopUserCoupon.TYPE_DISCOUNT) { + // 折扣券 + BigDecimal discountRate = BigDecimal.valueOf(userCoupon.getDiscount()).divide(BigDecimal.valueOf(100)); + return totalAmount.multiply(BigDecimal.ONE.subtract(discountRate)); + } + return BigDecimal.ZERO; + } +} diff --git a/src/main/java/com/gxwebsoft/shop/service/impl/ShopOrderServiceImpl.java b/src/main/java/com/gxwebsoft/shop/service/impl/ShopOrderServiceImpl.java index daed37c..294cc9b 100644 --- a/src/main/java/com/gxwebsoft/shop/service/impl/ShopOrderServiceImpl.java +++ b/src/main/java/com/gxwebsoft/shop/service/impl/ShopOrderServiceImpl.java @@ -296,15 +296,15 @@ public class ShopOrderServiceImpl extends ServiceImpl 0) { + // 注入 CouponStatusService 并使用其 useCoupon 方法 + // couponStatusService.useCoupon(order.getCouponId().longValue(), order.getOrderId(), order.getOrderNo()); + + // 临时保持原有逻辑,建议后续重构 ShopUserCoupon coupon = shopUserCouponService.getById(order.getCouponId()); if (coupon != null) { - coupon.setStatus(1); - coupon.setIsUse(1); - coupon.setUseTime(LocalDateTime.now()); - coupon.setOrderId(order.getOrderId()); - coupon.setOrderNo(order.getOrderNo()); + coupon.markAsUsed(order.getOrderId(), order.getOrderNo()); shopUserCouponService.updateById(coupon); } } diff --git a/src/main/java/com/gxwebsoft/shop/task/CouponExpireTask.java b/src/main/java/com/gxwebsoft/shop/task/CouponExpireTask.java new file mode 100644 index 0000000..9781bd9 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/task/CouponExpireTask.java @@ -0,0 +1,86 @@ +package com.gxwebsoft.shop.task; + +import com.gxwebsoft.shop.service.CouponStatusService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 优惠券过期处理定时任务 + * + * @author WebSoft + * @since 2025-01-15 + */ +@Slf4j +@Component +public class CouponExpireTask { + + @Autowired + private CouponStatusService couponStatusService; + + @Value("${spring.profiles.active:dev}") + private String activeProfile; + + /** + * 每天凌晨2点执行过期优惠券处理 + * 生产环境:每天凌晨2点执行 + * 开发环境:每10分钟执行一次(用于测试) + */ + @Scheduled(cron = "${coupon.expire.cron:0 0 2 * * ?}") + public void processExpiredCoupons() { + log.info("开始执行过期优惠券处理任务..."); + + try { + long startTime = System.currentTimeMillis(); + + // 批量更新过期优惠券状态 + int updatedCount = couponStatusService.updateExpiredCoupons(); + + long endTime = System.currentTimeMillis(); + long duration = endTime - startTime; + + log.info("过期优惠券处理任务完成,更新数量: {},耗时: {}ms", updatedCount, duration); + + // 如果是开发环境,输出更详细的日志 + if ("dev".equals(activeProfile)) { + log.debug("开发环境 - 过期优惠券处理详情: 更新{}张优惠券", updatedCount); + } + + } catch (Exception e) { + log.error("过期优惠券处理任务执行失败", e); + } + } + + /** + * 每小时执行一次优惠券状态检查(可选) + * 用于及时发现和处理刚过期的优惠券 + */ + @Scheduled(cron = "0 0 * * * ?") + public void hourlyExpiredCouponsCheck() { + // 只在生产环境执行 + if (!"prod".equals(activeProfile)) { + return; + } + + log.debug("执行每小时优惠券状态检查..."); + + try { + int updatedCount = couponStatusService.updateExpiredCoupons(); + if (updatedCount > 0) { + log.info("每小时检查发现并更新了 {} 张过期优惠券", updatedCount); + } + } catch (Exception e) { + log.error("每小时优惠券状态检查失败", e); + } + } + + /** + * 手动触发过期优惠券处理(用于测试) + */ + public void manualProcessExpiredCoupons() { + log.info("手动触发过期优惠券处理任务..."); + processExpiredCoupons(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3938505..7bc480c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -170,3 +170,20 @@ springdoc: # 启用 Knife4j knife4j: enable: true + +# 优惠券配置 +coupon: + # 过期处理定时任务配置 + expire: + # 定时任务执行时间(cron表达式) + # 生产环境:每天凌晨2点执行 + # 开发环境:每10分钟执行一次 + cron: "0 0 2 * * ?" + # 开发环境可以设置为: "0 */10 * * * ?" + + # 状态管理配置 + status: + # 是否启用自动状态更新 + auto-update: true + # 批量处理大小 + batch-size: 1000 diff --git a/src/main/resources/sql/coupon_status_optimization.sql b/src/main/resources/sql/coupon_status_optimization.sql new file mode 100644 index 0000000..6a737b8 --- /dev/null +++ b/src/main/resources/sql/coupon_status_optimization.sql @@ -0,0 +1,194 @@ +-- 优惠券状态管理优化SQL脚本 +-- 作者: WebSoft +-- 日期: 2025-01-15 +-- 说明: 优化优惠券查询性能,添加必要的索引 + +-- ======================================== +-- 1. 添加索引优化查询性能 +-- ======================================== + +-- 用户优惠券表索引优化 +CREATE INDEX IF NOT EXISTS idx_user_coupon_status ON shop_user_coupon(user_id, status, expire_time); +CREATE INDEX IF NOT EXISTS idx_user_coupon_expire ON shop_user_coupon(expire_time) WHERE status = 0; +CREATE INDEX IF NOT EXISTS idx_user_coupon_order ON shop_user_coupon(order_id) WHERE status = 1; + +-- 优惠券模板表索引优化 +CREATE INDEX IF NOT EXISTS idx_coupon_status_expire ON shop_coupon(status, expire_type, end_time); + +-- ======================================== +-- 2. 统一状态字段(如果需要数据迁移) +-- ======================================== + +-- 检查现有数据的状态一致性 +SELECT + '状态一致性检查' as check_item, + COUNT(*) as total_count, + SUM(CASE WHEN status = 0 AND is_use = 0 AND is_expire = 0 THEN 1 ELSE 0 END) as available_count, + SUM(CASE WHEN status = 1 AND is_use = 1 THEN 1 ELSE 0 END) as used_count, + SUM(CASE WHEN status = 2 OR is_expire = 1 THEN 1 ELSE 0 END) as expired_count, + SUM(CASE WHEN + (status = 0 AND (is_use = 1 OR is_expire = 1)) OR + (status = 1 AND is_use = 0) OR + (status = 2 AND is_expire = 0) + THEN 1 ELSE 0 END) as inconsistent_count +FROM shop_user_coupon; + +-- 修复状态不一致的数据 +UPDATE shop_user_coupon +SET status = 1, is_use = 1 +WHERE status = 0 AND is_use = 1 AND is_expire = 0; + +UPDATE shop_user_coupon +SET status = 2, is_expire = 1 +WHERE status = 0 AND is_expire = 1; + +UPDATE shop_user_coupon +SET status = 2, is_expire = 1 +WHERE status IN (0, 1) AND end_time < NOW(); + +-- ======================================== +-- 3. 添加触发器自动更新过期状态(可选) +-- ======================================== + +DELIMITER $$ + +-- 创建触发器:在查询时自动检查过期状态 +CREATE TRIGGER IF NOT EXISTS tr_check_coupon_expire +BEFORE UPDATE ON shop_user_coupon +FOR EACH ROW +BEGIN + -- 如果是未使用状态且已过期,自动更新为过期状态 + IF NEW.status = 0 AND NEW.end_time < NOW() THEN + SET NEW.status = 2; + SET NEW.is_expire = 1; + END IF; +END$$ + +DELIMITER ; + +-- ======================================== +-- 4. 创建视图简化查询 +-- ======================================== + +-- 创建用户可用优惠券视图 +CREATE OR REPLACE VIEW v_user_available_coupons AS +SELECT + uc.*, + c.name as coupon_name, + c.description as coupon_description, + c.apply_range, + c.apply_range_config, + CASE + WHEN uc.end_time < NOW() THEN '已过期' + WHEN uc.status = 1 THEN '已使用' + WHEN uc.status = 0 THEN '可使用' + ELSE '未知状态' + END as status_desc +FROM shop_user_coupon uc +LEFT JOIN shop_coupon c ON uc.coupon_id = c.id +WHERE uc.deleted = 0; + +-- 创建优惠券统计视图 +CREATE OR REPLACE VIEW v_coupon_statistics AS +SELECT + user_id, + COUNT(*) as total_count, + SUM(CASE WHEN status = 0 AND end_time >= NOW() THEN 1 ELSE 0 END) as available_count, + SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) as used_count, + SUM(CASE WHEN status = 2 OR end_time < NOW() THEN 1 ELSE 0 END) as expired_count +FROM shop_user_coupon +WHERE deleted = 0 +GROUP BY user_id; + +-- ======================================== +-- 5. 存储过程:批量处理过期优惠券 +-- ======================================== + +DELIMITER $$ + +CREATE PROCEDURE IF NOT EXISTS sp_update_expired_coupons() +BEGIN + DECLARE done INT DEFAULT FALSE; + DECLARE update_count INT DEFAULT 0; + + -- 声明异常处理 + DECLARE CONTINUE HANDLER FOR SQLEXCEPTION + BEGIN + ROLLBACK; + RESIGNAL; + END; + + START TRANSACTION; + + -- 批量更新过期优惠券 + UPDATE shop_user_coupon + SET status = 2, is_expire = 1 + WHERE status = 0 + AND end_time < NOW() + AND deleted = 0; + + -- 获取更新数量 + SET update_count = ROW_COUNT(); + + COMMIT; + + -- 返回更新数量 + SELECT update_count as updated_count; + +END$$ + +DELIMITER ; + +-- ======================================== +-- 6. 性能监控查询 +-- ======================================== + +-- 查看优惠券状态分布 +SELECT + status, + CASE + WHEN status = 0 THEN '未使用' + WHEN status = 1 THEN '已使用' + WHEN status = 2 THEN '已过期' + ELSE '未知' + END as status_name, + COUNT(*) as count, + ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM shop_user_coupon WHERE deleted = 0), 2) as percentage +FROM shop_user_coupon +WHERE deleted = 0 +GROUP BY status +ORDER BY status; + +-- 查看即将过期的优惠券(7天内) +SELECT + COUNT(*) as expiring_soon_count, + MIN(end_time) as earliest_expire_time, + MAX(end_time) as latest_expire_time +FROM shop_user_coupon +WHERE status = 0 + AND end_time BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 7 DAY) + AND deleted = 0; + +-- 查看优惠券使用率统计 +SELECT + DATE(create_time) as date, + COUNT(*) as issued_count, + SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) as used_count, + ROUND(SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 2) as usage_rate +FROM shop_user_coupon +WHERE deleted = 0 + AND create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY) +GROUP BY DATE(create_time) +ORDER BY date DESC; + +-- ======================================== +-- 7. 清理和维护 +-- ======================================== + +-- 清理过期很久的优惠券记录(可选,谨慎使用) +-- DELETE FROM shop_user_coupon +-- WHERE status = 2 +-- AND end_time < DATE_SUB(NOW(), INTERVAL 1 YEAR) +-- AND deleted = 0; + +COMMIT; diff --git a/src/test/java/com/gxwebsoft/shop/service/CouponStatusServiceTest.java b/src/test/java/com/gxwebsoft/shop/service/CouponStatusServiceTest.java new file mode 100644 index 0000000..3d68842 --- /dev/null +++ b/src/test/java/com/gxwebsoft/shop/service/CouponStatusServiceTest.java @@ -0,0 +1,113 @@ +package com.gxwebsoft.shop.service; + +import com.gxwebsoft.shop.entity.ShopUserCoupon; +import com.gxwebsoft.shop.service.CouponStatusService.CouponStatusResult; +import com.gxwebsoft.shop.service.CouponStatusService.CouponValidationResult; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 优惠券状态管理服务测试 + * + * @author WebSoft + * @since 2025-01-15 + */ +@SpringBootTest +@ActiveProfiles("dev") +public class CouponStatusServiceTest { + + @Autowired + private CouponStatusService couponStatusService; + + @Test + public void testCouponStatusConstants() { + // 测试状态常量 + assertEquals(0, ShopUserCoupon.STATUS_UNUSED); + assertEquals(1, ShopUserCoupon.STATUS_USED); + assertEquals(2, ShopUserCoupon.STATUS_EXPIRED); + + // 测试类型常量 + assertEquals(10, ShopUserCoupon.TYPE_REDUCE); + assertEquals(20, ShopUserCoupon.TYPE_DISCOUNT); + assertEquals(30, ShopUserCoupon.TYPE_FREE); + + // 测试适用范围常量 + assertEquals(10, ShopUserCoupon.APPLY_ALL); + assertEquals(20, ShopUserCoupon.APPLY_GOODS); + assertEquals(30, ShopUserCoupon.APPLY_CATEGORY); + } + + @Test + public void testCouponStatusMethods() { + // 创建测试优惠券 + ShopUserCoupon coupon = new ShopUserCoupon(); + coupon.setStatus(ShopUserCoupon.STATUS_UNUSED); + coupon.setEndTime(LocalDateTime.now().plusDays(7)); + + // 测试可用状态 + assertTrue(coupon.isAvailable()); + assertFalse(coupon.isUsed()); + assertFalse(coupon.isExpired()); + assertEquals("可使用", coupon.getStatusDesc()); + + // 测试已使用状态 + coupon.markAsUsed(123, "ORDER123"); + assertTrue(coupon.isUsed()); + assertFalse(coupon.isAvailable()); + assertEquals("已使用", coupon.getStatusDesc()); + assertEquals(Integer.valueOf(123), coupon.getOrderId()); + assertEquals("ORDER123", coupon.getOrderNo()); + + // 测试过期状态 + coupon.setStatus(ShopUserCoupon.STATUS_UNUSED); + coupon.setEndTime(LocalDateTime.now().minusDays(1)); + assertTrue(coupon.isExpired()); + assertFalse(coupon.isAvailable()); + assertEquals("已过期", coupon.getStatusDesc()); + } + + @Test + public void testValidateCouponForOrder() { + // 这个测试需要数据库中有实际的优惠券数据 + // 这里只是演示测试结构 + + List goodsIds = Arrays.asList(1, 2, 3); + BigDecimal totalAmount = new BigDecimal("150.00"); + + // 注意:这个测试需要实际的优惠券ID,在真实环境中需要先创建测试数据 + // CouponValidationResult result = couponStatusService.validateCouponForOrder(1L, totalAmount, goodsIds); + // assertNotNull(result); + + System.out.println("优惠券验证测试需要实际的数据库数据"); + } + + @Test + public void testGetUserCouponsGroupByStatus() { + // 这个测试需要数据库中有实际的用户和优惠券数据 + // 这里只是演示测试结构 + + // 注意:这个测试需要实际的用户ID,在真实环境中需要先创建测试数据 + // CouponStatusResult result = couponStatusService.getUserCouponsGroupByStatus(1); + // assertNotNull(result); + // assertTrue(result.getTotalCount() >= 0); + + System.out.println("用户优惠券分组测试需要实际的数据库数据"); + } + + @Test + public void testUpdateExpiredCoupons() { + // 测试批量更新过期优惠券 + int updatedCount = couponStatusService.updateExpiredCoupons(); + assertTrue(updatedCount >= 0); + System.out.println("更新了 " + updatedCount + " 张过期优惠券"); + } +}