diff --git a/docs/coupon-impl-progress.md b/docs/coupon-impl-progress.md new file mode 100644 index 000000000..fef70309c --- /dev/null +++ b/docs/coupon-impl-progress.md @@ -0,0 +1,128 @@ +# 优惠券功能实现进度 + +**需求文档**: [PRD-积分兑换洗车券功能.md](./PRD-积分兑换洗车券功能.md) +**开始日期**: 2026-03-04 +**当前版本**: v1.0(首期洗车券) + +--- + +## 进度总览 + +| # | 子任务 | 状态 | 文件 | +|---|--------|------|------| +| 1 | Domain 实体类 | ✅ 完成 | `jsowell-pile/.../domain/` | +| 2 | Mapper 接口和 XML | ✅ 完成 | `jsowell-pile/.../mapper/` + `resources/mapper/pile/` | +| 3 | Service 层 | ✅ 完成 | `jsowell-pile/.../service/` | +| 4 | 管理后台 Controller | ✅ 完成 | `jsowell-admin/.../web/controller/pile/` | +| 5 | 用户端 API Controller | ✅ 完成 | `jsowell-admin/.../api/uniapp/customer/` | +| 6 | Quartz 过期归档任务 | ✅ 完成 | `jsowell-quartz/.../task/JsowellTask.java`(方法 `expireCoupons`) | + +> 状态图例:⬜ 待开始 / 🔄 进行中 / ✅ 完成 + +--- + +## Task 1:Domain 实体类 + +**状态**: ✅ 完成 + +### 文件清单 + +| 文件 | 路径 | +|------|------| +| `CouponTemplate.java` | `jsowell-pile/src/main/java/com/jsowell/pile/domain/` | +| `CouponTemplateScope.java` | `jsowell-pile/src/main/java/com/jsowell/pile/domain/` | +| `MemberCoupon.java` | `jsowell-pile/src/main/java/com/jsowell/pile/domain/` | +| `CouponVerifyRecord.java` | `jsowell-pile/src/main/java/com/jsowell/pile/domain/` | + +--- + +## Task 2:Mapper 接口和 XML + +**状态**: ✅ 完成 + +### 文件清单 + +| 文件 | 路径 | +|------|------| +| `CouponTemplateMapper.java` | `jsowell-pile/src/main/java/com/jsowell/pile/mapper/` | +| `CouponTemplateScopeMapper.java` | `jsowell-pile/src/main/java/com/jsowell/pile/mapper/` | +| `MemberCouponMapper.java` | `jsowell-pile/src/main/java/com/jsowell/pile/mapper/` | +| `CouponVerifyRecordMapper.java` | `jsowell-pile/src/main/java/com/jsowell/pile/mapper/` | +| `CouponTemplateMapper.xml` | `jsowell-pile/src/main/resources/mapper/pile/` | +| `CouponTemplateScopeMapper.xml` | `jsowell-pile/src/main/resources/mapper/pile/` | +| `MemberCouponMapper.xml` | `jsowell-pile/src/main/resources/mapper/pile/` | +| `CouponVerifyRecordMapper.xml` | `jsowell-pile/src/main/resources/mapper/pile/` | + +--- + +## Task 3:Service 层 + +**状态**: ✅ 完成 + +### 文件清单 + +| 文件 | 路径 | +|------|------| +| `CouponTemplateService.java` | `jsowell-pile/src/main/java/com/jsowell/pile/service/` | +| `CouponTemplateServiceImpl.java` | `jsowell-pile/src/main/java/com/jsowell/pile/service/impl/` | +| `MemberCouponService.java` | `jsowell-pile/src/main/java/com/jsowell/pile/service/` | +| `MemberCouponServiceImpl.java` | `jsowell-pile/src/main/java/com/jsowell/pile/service/impl/` | + +### 核心方法 + +**CouponTemplateService** +- `listForAdmin(CouponTemplate query, String merchantId)` - 后台列表(带权限过滤) +- `add(CouponTemplate template, String loginMerchantId, boolean isPlatformAdmin)` - 新增(含 scope 校验) +- `edit(CouponTemplate template, String loginMerchantId, boolean isPlatformAdmin)` - 编辑(含冻结字段校验) +- `changeStatus(Long id, Integer status, String loginMerchantId)` - 上下架 + +**MemberCouponService** +- `listAvailableTemplates(String memberId, Long stationId, int pageNum, int pageSize)` - 用户可兑换列表 +- `exchange(String memberId, Long templateId, String requestId)` - 兑换(原子:扣积分+生成券) +- `myList(String memberId, Integer status, int pageNum, int pageSize)` - 我的券包 +- `detail(String memberId, String couponNo)` - 券详情 +- `verify(String couponNo, Long storeId, String requestId, String operatorId)` - 核销 + +--- + +## Task 4:管理后台 Controller + +**状态**: ✅ 完成 + +### 文件清单 + +| 文件 | 路径 | +|------|------| +| `CouponTemplateController.java` | `jsowell-admin/src/main/java/com/jsowell/web/controller/pile/` | +| `CouponRecordController.java` | `jsowell-admin/src/main/java/com/jsowell/web/controller/pile/` | + +--- + +## Task 5:用户端 API Controller + +**状态**: ✅ 完成 + +### 文件清单 + +| 文件 | 路径 | +|------|------| +| `CouponController.java` | `jsowell-admin/src/main/java/com/jsowell/api/uniapp/customer/` | + +--- + +## Task 6:Quartz 过期归档任务 + +**状态**: ✅ 完成 + +方法 `expireCoupons()` 已合并到 `JsowellTask.java`,调用目标:`jsowellTask.expireCoupons()`,Cron:`0 5 0 * * ?` + +--- + +## 关键设计决策 + +1. **兑换幂等**:`member_coupon.exchange_request_id` 唯一索引,捕获 `DuplicateKeyException` 返回已有结果 +2. **库存扣减**:`UPDATE ... SET stock_remain=stock_remain-1 WHERE id=? AND (stock_remain>0 OR stock_remain=-1)` +3. **积分扣减**:复用现有 `MemberPointsInfoService.deductPoints()`,`type=3`(兑换消耗) +4. **scope 校验**:查询用户可见券时 JOIN `coupon_template_scope`,通过 `idx_scope_lookup` 索引反查 +5. **过期判断**:查询侧用 `expire_time > NOW()` 动态判断,Quartz 仅做离线归档 +6. **二维码安全**:券详情接口返回短时签名 token(HMAC-SHA256,5分钟有效),核销接口接受 token 或 couponNo 两种方式 diff --git a/jsowell-admin/src/main/java/com/jsowell/api/uniapp/customer/CouponController.java b/jsowell-admin/src/main/java/com/jsowell/api/uniapp/customer/CouponController.java new file mode 100644 index 000000000..ce2508cd2 --- /dev/null +++ b/jsowell-admin/src/main/java/com/jsowell/api/uniapp/customer/CouponController.java @@ -0,0 +1,135 @@ +package com.jsowell.api.uniapp.customer; + +import com.github.pagehelper.PageHelper; +import com.jsowell.common.core.controller.BaseController; +import com.jsowell.common.core.domain.AjaxResult; +import com.jsowell.pile.domain.CouponTemplate; +import com.jsowell.pile.domain.MemberCoupon; +import com.jsowell.pile.service.MemberCouponService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.*; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 优惠券用户端 API(小程序/App) + */ +@Api(tags = "优惠券-用户端") +@RestController +@RequestMapping("/coupon") +public class CouponController extends BaseController { + + @Autowired + private MemberCouponService memberCouponService; + + /** 二维码签名密钥,配置在 application.yml */ + @Value("${coupon.qrcode.secret:coupon_qrcode_secret}") + private String qrcodeSecret; + + /** 二维码 token 有效期(秒),默认 5 分钟 */ + private static final int QRCODE_TOKEN_TTL_SECONDS = 300; + + /** + * 获取可兑换券模板列表 + */ + @ApiOperation("可兑换券列表") + @GetMapping("/template/list") + public AjaxResult templateList( + @ApiParam("会员ID") @RequestParam String memberId, + @ApiParam("当前所在站点ID,用于 scope 过滤") @RequestParam(required = false) Long stationId, + @RequestParam(defaultValue = "1") int pageNum, + @RequestParam(defaultValue = "10") int pageSize) { + PageHelper.startPage(pageNum, pageSize); + List list = memberCouponService.listAvailableTemplates(memberId, stationId); + return AjaxResult.success(list); + } + + /** + * 积分兑换券 + */ + @ApiOperation("积分兑换券") + @PostMapping("/exchange") + public AjaxResult exchange( + @ApiParam("会员ID") @RequestParam String memberId, + @ApiParam("模板ID") @RequestParam Long templateId, + @ApiParam("客户端幂等键(UUID)") @RequestParam String requestId) { + try { + MemberCoupon coupon = memberCouponService.exchange(memberId, templateId, requestId); + return AjaxResult.success(coupon); + } catch (Exception e) { + logger.error("兑换失败,memberId: {}, templateId: {}", memberId, templateId, e); + return AjaxResult.error(e.getMessage()); + } + } + + /** + * 我的券包 + */ + @ApiOperation("我的券包列表") + @GetMapping("/my/list") + public AjaxResult myList( + @RequestParam String memberId, + @ApiParam("状态:0=未使用 1=已使用 2=已过期,不传=全部") @RequestParam(required = false) Integer status, + @RequestParam(defaultValue = "1") int pageNum, + @RequestParam(defaultValue = "10") int pageSize) { + PageHelper.startPage(pageNum, pageSize); + List list = memberCouponService.myList(memberId, status); + return AjaxResult.success(list); + } + + /** + * 券详情(含核销二维码短时 token) + */ + @ApiOperation("券详情(含二维码token)") + @GetMapping("/my/detail/{couponNo}") + public AjaxResult detail( + @RequestParam String memberId, + @PathVariable String couponNo) { + try { + MemberCoupon coupon = memberCouponService.detail(memberId, couponNo); + String qrcodeToken = generateQrcodeToken(couponNo); + + Map result = new HashMap<>(); + result.put("coupon", coupon); + result.put("qrcodeToken", qrcodeToken); + result.put("qrcodeExpireSeconds", QRCODE_TOKEN_TTL_SECONDS); + return AjaxResult.success(result); + } catch (Exception e) { + logger.error("获取券详情失败,couponNo: {}", couponNo, e); + return AjaxResult.error(e.getMessage()); + } + } + + // ---------------------------------------------------------------- + // 私有:生成短时签名 token(格式:couponNo.expireTs.hmac) + // ---------------------------------------------------------------- + + private String generateQrcodeToken(String couponNo) { + try { + long expireTs = System.currentTimeMillis() / 1000 + QRCODE_TOKEN_TTL_SECONDS; + String payload = couponNo + "." + expireTs; + String hmac = hmacSha256(payload, qrcodeSecret); + return Base64.getUrlEncoder().withoutPadding() + .encodeToString((payload + "." + hmac).getBytes()); + } catch (Exception e) { + logger.error("生成二维码token失败", e); + throw new RuntimeException("生成二维码失败"); + } + } + + private String hmacSha256(String data, String secret) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes("UTF-8"), "HmacSHA256")); + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(mac.doFinal(data.getBytes("UTF-8"))); + } +} diff --git a/jsowell-admin/src/main/java/com/jsowell/web/controller/pile/CouponRecordController.java b/jsowell-admin/src/main/java/com/jsowell/web/controller/pile/CouponRecordController.java new file mode 100644 index 000000000..f7160e7b2 --- /dev/null +++ b/jsowell-admin/src/main/java/com/jsowell/web/controller/pile/CouponRecordController.java @@ -0,0 +1,72 @@ +package com.jsowell.web.controller.pile; + +import com.github.pagehelper.PageHelper; +import com.jsowell.common.core.controller.BaseController; +import com.jsowell.common.core.domain.AjaxResult; +import com.jsowell.common.core.page.TableDataInfo; +import com.jsowell.pile.domain.MemberCoupon; +import com.jsowell.pile.service.MemberCouponService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 优惠券兑换记录 & 核销 Controller(管理后台) + */ +@Api(tags = "优惠券兑换记录管理") +@RestController +@RequestMapping("/admin/coupon") +public class CouponRecordController extends BaseController { + + @Autowired + private MemberCouponService memberCouponService; + + /** + * 兑换记录列表 + */ + @ApiOperation("兑换记录查询") + @PreAuthorize("@ss.hasPermi('coupon:record:list')") + @GetMapping("/record/list") + public TableDataInfo recordList(MemberCoupon query, + @RequestParam(defaultValue = "1") int pageNum, + @RequestParam(defaultValue = "10") int pageSize) { + PageHelper.startPage(pageNum, pageSize); + List list = memberCouponService.listRecordForAdmin(query); + return getDataTable(list); + } + + /** + * 核销券 + */ + @ApiOperation("核销券") + @PreAuthorize("@ss.hasPermi('coupon:record:verify')") + @PostMapping("/verify") + public AjaxResult verify(@RequestParam String couponNo, + @RequestParam Long storeId, + @RequestParam(required = false) String requestId, + @RequestParam(required = false) String operatorId) { + try { + memberCouponService.verify(couponNo, storeId, requestId, operatorId); + return AjaxResult.success("核销成功"); + } catch (Exception e) { + logger.error("核销失败,couponNo: {}", couponNo, e); + return AjaxResult.error(e.getMessage()); + } + } + + /** + * 人工作废券 + */ + @ApiOperation("人工作废券") + @PreAuthorize("@ss.hasPermi('coupon:record:invalidate')") + @PostMapping("/invalidate") + public AjaxResult invalidate(@RequestParam String couponNo, + @RequestParam String reason) { + // TODO: 实现人工作废逻辑(需补充 MemberCouponMapper.invalidate + 审计日志) + return AjaxResult.error("功能待实现"); + } +} diff --git a/jsowell-admin/src/main/java/com/jsowell/web/controller/pile/CouponTemplateController.java b/jsowell-admin/src/main/java/com/jsowell/web/controller/pile/CouponTemplateController.java new file mode 100644 index 000000000..a5cd3b0e9 --- /dev/null +++ b/jsowell-admin/src/main/java/com/jsowell/web/controller/pile/CouponTemplateController.java @@ -0,0 +1,109 @@ +package com.jsowell.web.controller.pile; + +import com.github.pagehelper.PageHelper; +import com.jsowell.common.core.controller.BaseController; +import com.jsowell.common.core.domain.AjaxResult; +import com.jsowell.common.core.page.TableDataInfo; +import com.jsowell.pile.domain.CouponTemplate; +import com.jsowell.pile.service.CouponTemplateService; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 优惠券模板管理 Controller(管理后台) + */ +@Api(tags = "优惠券模板管理") +@RestController +@RequestMapping("/admin/coupon/template") +public class CouponTemplateController extends BaseController { + + @Autowired + private CouponTemplateService couponTemplateService; + + /** + * 列表查询 + * 运营商管理员调用时,由前端传入 creatorMerchantId(后端也应从登录上下文二次校验) + */ + @ApiOperation("券模板列表") + @PreAuthorize("@ss.hasPermi('coupon:template:list')") + @GetMapping("/list") + public TableDataInfo list(CouponTemplate query, + @RequestParam(defaultValue = "1") int pageNum, + @RequestParam(defaultValue = "10") int pageSize) { + PageHelper.startPage(pageNum, pageSize); + List list = couponTemplateService.listForAdmin(query); + return getDataTable(list); + } + + /** + * 详情 + */ + @ApiOperation("券模板详情") + @PreAuthorize("@ss.hasPermi('coupon:template:query')") + @GetMapping("/{id}") + public AjaxResult getById(@PathVariable Long id) { + return AjaxResult.success(couponTemplateService.getById(id)); + } + + /** + * 新增券模板 + * isPlatformAdmin / loginMerchantId 从登录上下文获取(此处简化演示,实际需从 SecurityUtils 取) + */ + @ApiOperation("新增券模板") + @PreAuthorize("@ss.hasPermi('coupon:template:add')") + @PostMapping("/add") + public AjaxResult add(@RequestBody CouponTemplate template, + @ApiParam("是否平台管理员") @RequestParam(defaultValue = "false") boolean isPlatformAdmin, + @ApiParam("登录运营商ID(运营商管理员必传)") @RequestParam(required = false) Long loginMerchantId) { + try { + couponTemplateService.add(template, loginMerchantId, isPlatformAdmin); + return AjaxResult.success("新增成功"); + } catch (Exception e) { + logger.error("新增券模板失败", e); + return AjaxResult.error(e.getMessage()); + } + } + + /** + * 编辑券模板 + */ + @ApiOperation("编辑券模板") + @PreAuthorize("@ss.hasPermi('coupon:template:edit')") + @PutMapping("/edit") + public AjaxResult edit(@RequestBody CouponTemplate template, + @RequestParam(defaultValue = "false") boolean isPlatformAdmin, + @RequestParam(required = false) Long loginMerchantId) { + try { + couponTemplateService.edit(template, loginMerchantId, isPlatformAdmin); + return AjaxResult.success("编辑成功"); + } catch (Exception e) { + logger.error("编辑券模板失败", e); + return AjaxResult.error(e.getMessage()); + } + } + + /** + * 上下架 + */ + @ApiOperation("上下架券模板") + @PreAuthorize("@ss.hasPermi('coupon:template:edit')") + @PutMapping("/changeStatus") + public AjaxResult changeStatus(@RequestParam Long id, + @ApiParam("0=下架 1=上架") @RequestParam Integer status, + @RequestParam(defaultValue = "false") boolean isPlatformAdmin, + @RequestParam(required = false) Long loginMerchantId) { + try { + couponTemplateService.changeStatus(id, status, loginMerchantId, isPlatformAdmin); + return AjaxResult.success(); + } catch (Exception e) { + logger.error("修改券模板状态失败", e); + return AjaxResult.error(e.getMessage()); + } + } +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/domain/CouponTemplate.java b/jsowell-pile/src/main/java/com/jsowell/pile/domain/CouponTemplate.java new file mode 100644 index 000000000..2b3e2746d --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/domain/CouponTemplate.java @@ -0,0 +1,108 @@ +package com.jsowell.pile.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +import java.math.BigDecimal; +import java.util.Date; +import java.util.List; + +/** + * 优惠券模板表 coupon_template + */ +@Data +@Accessors(chain = true) +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class CouponTemplate { + + private Long id; + + /** 券名称 */ + private String name; + + /** 券类型:1=洗车券 2=充电折扣券 */ + private Integer type; + + /** 创建人类型:1=平台管理员 2=运营商管理员 */ + private Integer creatorType; + + /** 创建人所属运营商ID,平台管理员为NULL */ + private Long creatorMerchantId; + + /** 可用范围:1=全平台 2=指定运营商 3=指定站点 */ + private Integer scopeType; + + /** 兑换所需积分(洗车券使用) */ + private BigDecimal pointsCost; + + /** 折扣比例(充电折扣券,如0.85表示85折) */ + private BigDecimal discountRate; + + /** 充电最低消费金额门槛(折扣券,v1.2预留) */ + private BigDecimal minChargeAmount; + + /** 单次最大抵扣金额上限(折扣券,v1.2预留) */ + private BigDecimal maxDiscountAmount; + + /** 兑换活动开始时间,NULL=不限 */ + private Date exchangeStartTime; + + /** 兑换活动结束时间,NULL=不限 */ + private Date exchangeEndTime; + + /** 总库存,-1=不限制 */ + private Integer stockTotal; + + /** 剩余库存,-1=不限制 */ + private Integer stockRemain; + + /** 有效期类型:1=固定日期 2=领取后N天 */ + private Integer validityType; + + /** 固定有效期开始时间(validityType=1时有效) */ + private Date validStartTime; + + /** 固定有效期结束时间(validityType=1时有效) */ + private Date validEndTime; + + /** 领取后有效天数(validityType=2时有效) */ + private Integer validDays; + + /** 单用户每日兑换上限,0=不限 */ + private Integer dailyLimit; + + /** 单用户每月兑换上限,0=不限 */ + private Integer monthlyLimit; + + /** 单用户累计兑换上限,0=不限 */ + private Integer totalLimit; + + /** 状态:0=下架 1=上架 */ + private Integer status; + + /** 券使用说明 */ + private String description; + + /** 最后修改人账号 */ + private String updateBy; + + /** 创建人账号 */ + private String createBy; + + private Date createTime; + + private Date updateTime; + + /** 删除标志:0=存在 2=删除 */ + private String delFlag; + + // ---- 非持久化字段,查询时用 ---- + + /** scope 明细列表(scopeType=2或3时使用) */ + private List scopeList; +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/domain/CouponTemplateScope.java b/jsowell-pile/src/main/java/com/jsowell/pile/domain/CouponTemplateScope.java new file mode 100644 index 000000000..19f55b766 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/domain/CouponTemplateScope.java @@ -0,0 +1,29 @@ +package com.jsowell.pile.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +/** + * 优惠券可用范围明细表 coupon_template_scope + */ +@Data +@Accessors(chain = true) +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class CouponTemplateScope { + + private Long id; + + /** 关联券模板ID */ + private Long templateId; + + /** 范围类型:2=运营商 3=站点 */ + private Integer scopeType; + + /** 运营商ID 或 站点ID */ + private Long scopeId; +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/domain/CouponVerifyRecord.java b/jsowell-pile/src/main/java/com/jsowell/pile/domain/CouponVerifyRecord.java new file mode 100644 index 000000000..4cd451600 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/domain/CouponVerifyRecord.java @@ -0,0 +1,42 @@ +package com.jsowell.pile.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +import java.util.Date; + +/** + * 优惠券核销日志表 coupon_verify_record + */ +@Data +@Accessors(chain = true) +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class CouponVerifyRecord { + + private Long id; + + /** 券编号 */ + private String couponNo; + + /** 核销请求幂等键 */ + private String requestId; + + /** 核销门店ID */ + private Long storeId; + + /** 核销操作人 */ + private String operatorId; + + /** 核销结果:1=成功 2=失败 */ + private Integer result; + + /** 失败原因 */ + private String failReason; + + private Date createTime; +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/domain/MemberCoupon.java b/jsowell-pile/src/main/java/com/jsowell/pile/domain/MemberCoupon.java new file mode 100644 index 000000000..280df6797 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/domain/MemberCoupon.java @@ -0,0 +1,70 @@ +package com.jsowell.pile.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +import java.math.BigDecimal; +import java.util.Date; + +/** + * 会员优惠券表 member_coupon + */ +@Data +@Accessors(chain = true) +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class MemberCoupon { + + private Long id; + + /** 券编号(唯一,内部使用) */ + private String couponNo; + + /** 兑换幂等键(客户端requestId),唯一索引防重复兑换 */ + private String exchangeRequestId; + + /** 关联券模板ID */ + private Long templateId; + + /** 关联会员ID(varchar,与member_points_info.memberId一致) */ + private String memberId; + + /** 券类型快照:1=洗车券 2=充电折扣券 */ + private Integer couponType; + + /** 兑换时消耗的积分快照 */ + private BigDecimal pointsCost; + + /** 折扣比例快照,洗车券为NULL */ + private BigDecimal discountRate; + + /** 状态:0=未使用 1=已使用 2=已过期 */ + private Integer status; + + /** 来源:1=积分兑换 */ + private Integer source; + + /** 兑换时间 */ + private Date exchangeTime; + + /** 过期时间 */ + private Date expireTime; + + /** 核销时间 */ + private Date useTime; + + /** 核销门店/站点ID */ + private Long useStoreId; + + /** 核销操作人 */ + private String useOperator; + + private Date createTime; + + /** 删除标志:0=存在 2=删除 */ + private String delFlag; +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/mapper/CouponTemplateMapper.java b/jsowell-pile/src/main/java/com/jsowell/pile/mapper/CouponTemplateMapper.java new file mode 100644 index 000000000..a595dfb4b --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/mapper/CouponTemplateMapper.java @@ -0,0 +1,33 @@ +package com.jsowell.pile.mapper; + +import com.jsowell.pile.domain.CouponTemplate; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 优惠券模板 Mapper 接口 + */ +public interface CouponTemplateMapper { + + CouponTemplate selectById(@Param("id") Long id); + + /** + * 后台列表查询(支持按运营商过滤、状态过滤) + */ + List selectList(CouponTemplate query); + + int insert(CouponTemplate record); + + int updateById(CouponTemplate record); + + /** + * 原子扣减库存:stock_remain >= 1 时才扣,返回更新行数 + */ + int deductStock(@Param("id") Long id); + + /** + * 检查是否存在兑换记录(用于冻结字段校验) + */ + int countExchangeRecord(@Param("templateId") Long templateId); +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/mapper/CouponTemplateScopeMapper.java b/jsowell-pile/src/main/java/com/jsowell/pile/mapper/CouponTemplateScopeMapper.java new file mode 100644 index 000000000..9498176fc --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/mapper/CouponTemplateScopeMapper.java @@ -0,0 +1,24 @@ +package com.jsowell.pile.mapper; + +import com.jsowell.pile.domain.CouponTemplateScope; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 优惠券可用范围明细 Mapper 接口 + */ +public interface CouponTemplateScopeMapper { + + List selectByTemplateId(@Param("templateId") Long templateId); + + /** + * 按 (scopeType, scopeId) 反查可用模板ID列表(走 idx_scope_lookup) + */ + List selectTemplateIdsByScopeId(@Param("scopeType") Integer scopeType, + @Param("scopeId") Long scopeId); + + int insertBatch(@Param("list") List list); + + int deleteByTemplateId(@Param("templateId") Long templateId); +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/mapper/CouponVerifyRecordMapper.java b/jsowell-pile/src/main/java/com/jsowell/pile/mapper/CouponVerifyRecordMapper.java new file mode 100644 index 000000000..72d40f4df --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/mapper/CouponVerifyRecordMapper.java @@ -0,0 +1,17 @@ +package com.jsowell.pile.mapper; + +import com.jsowell.pile.domain.CouponVerifyRecord; +import org.apache.ibatis.annotations.Param; + +/** + * 优惠券核销日志 Mapper 接口 + */ +public interface CouponVerifyRecordMapper { + + int insert(CouponVerifyRecord record); + + /** + * 按请求幂等键查询(防重复核销) + */ + CouponVerifyRecord selectByRequestId(@Param("requestId") String requestId); +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/mapper/MemberCouponMapper.java b/jsowell-pile/src/main/java/com/jsowell/pile/mapper/MemberCouponMapper.java new file mode 100644 index 000000000..f0ce77d88 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/mapper/MemberCouponMapper.java @@ -0,0 +1,60 @@ +package com.jsowell.pile.mapper; + +import com.jsowell.pile.domain.MemberCoupon; +import org.apache.ibatis.annotations.Param; + +import java.util.Date; +import java.util.List; + +/** + * 会员优惠券 Mapper 接口 + */ +public interface MemberCouponMapper { + + MemberCoupon selectByCouponNo(@Param("couponNo") String couponNo); + + List selectByMemberId(@Param("memberId") String memberId, + @Param("status") Integer status); + + /** + * 后台兑换记录查询 + */ + List selectRecordList(MemberCoupon query); + + int insert(MemberCoupon record); + + /** + * 原子核销:status=0 AND expire_time>now() 时才更新,返回更新行数 + */ + int verify(@Param("couponNo") String couponNo, + @Param("useTime") Date useTime, + @Param("useStoreId") Long useStoreId, + @Param("useOperator") String useOperator); + + /** + * 批量归档过期券(Quartz 定时任务用) + */ + int batchExpire(@Param("now") Date now); + + /** + * 统计某用户对某模板的累计兑换数(用于 total_limit 校验) + */ + int countByMemberAndTemplate(@Param("memberId") String memberId, + @Param("templateId") Long templateId); + + /** + * 统计某用户对某模板今日兑换数(用于 daily_limit 校验) + */ + int countTodayByMemberAndTemplate(@Param("memberId") String memberId, + @Param("templateId") Long templateId, + @Param("dayStart") Date dayStart, + @Param("dayEnd") Date dayEnd); + + /** + * 统计某用户对某模板本月兑换数(用于 monthly_limit 校验) + */ + int countMonthByMemberAndTemplate(@Param("memberId") String memberId, + @Param("templateId") Long templateId, + @Param("monthStart") Date monthStart, + @Param("monthEnd") Date monthEnd); +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/service/CouponTemplateService.java b/jsowell-pile/src/main/java/com/jsowell/pile/service/CouponTemplateService.java new file mode 100644 index 000000000..4a04de2a7 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/service/CouponTemplateService.java @@ -0,0 +1,37 @@ +package com.jsowell.pile.service; + +import com.jsowell.pile.domain.CouponTemplate; + +import java.util.List; + +/** + * 优惠券模板 Service 接口 + */ +public interface CouponTemplateService { + + CouponTemplate getById(Long id); + + /** + * 后台列表(运营商管理员传 merchantId 自动过滤) + */ + List listForAdmin(CouponTemplate query); + + /** + * 新增模板(含 scope 权限校验) + * + * @param template 模板信息(含 scopeList) + * @param loginMerchantId 登录人运营商ID(平台管理员传 null) + * @param isPlatformAdmin 是否平台管理员 + */ + void add(CouponTemplate template, Long loginMerchantId, boolean isPlatformAdmin); + + /** + * 编辑模板(已有兑换记录时冻结核心字段) + */ + void edit(CouponTemplate template, Long loginMerchantId, boolean isPlatformAdmin); + + /** + * 上下架 + */ + void changeStatus(Long id, Integer status, Long loginMerchantId, boolean isPlatformAdmin); +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/service/MemberCouponService.java b/jsowell-pile/src/main/java/com/jsowell/pile/service/MemberCouponService.java new file mode 100644 index 000000000..469c1127f --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/service/MemberCouponService.java @@ -0,0 +1,62 @@ +package com.jsowell.pile.service; + +import com.jsowell.pile.domain.CouponTemplate; +import com.jsowell.pile.domain.MemberCoupon; + +import java.util.List; + +/** + * 会员优惠券 Service 接口 + */ +public interface MemberCouponService { + + /** + * 查询用户可兑换的券模板列表 + * + * @param memberId 会员ID + * @param stationId 当前所在站点ID(用于 scope 过滤,可为 null) + */ + List listAvailableTemplates(String memberId, Long stationId); + + /** + * 积分兑换券(原子:扣积分 + 生成券记录) + * + * @param memberId 会员ID + * @param templateId 模板ID + * @param requestId 客户端幂等键 + * @return 生成的券记录 + */ + MemberCoupon exchange(String memberId, Long templateId, String requestId); + + /** + * 我的券包列表 + * + * @param status null=全部,0=未使用,1=已使用,2=已过期 + */ + List myList(String memberId, Integer status); + + /** + * 券详情(校验所属会员) + */ + MemberCoupon detail(String memberId, String couponNo); + + /** + * 核销券 + * + * @param couponNo 券编号 + * @param storeId 核销门店ID + * @param requestId 请求幂等键 + * @param operatorId 操作人 + */ + void verify(String couponNo, Long storeId, String requestId, String operatorId); + + /** + * 后台兑换记录查询 + */ + List listRecordForAdmin(MemberCoupon query); + + /** + * 批量归档过期券(Quartz 调用) + */ + int batchExpire(); +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/CouponTemplateServiceImpl.java b/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/CouponTemplateServiceImpl.java new file mode 100644 index 000000000..f9dca3c1c --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/CouponTemplateServiceImpl.java @@ -0,0 +1,158 @@ +package com.jsowell.pile.service.impl; + +import com.jsowell.common.exception.BusinessException; +import com.jsowell.pile.domain.CouponTemplate; +import com.jsowell.pile.domain.CouponTemplateScope; +import com.jsowell.pile.mapper.CouponTemplateMapper; +import com.jsowell.pile.mapper.CouponTemplateScopeMapper; +import com.jsowell.pile.service.CouponTemplateService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; + +import javax.annotation.Resource; +import java.util.List; + +/** + * 优惠券模板 Service 实现 + */ +@Service +public class CouponTemplateServiceImpl implements CouponTemplateService { + + private static final Logger logger = LoggerFactory.getLogger(CouponTemplateServiceImpl.class); + + @Resource + private CouponTemplateMapper couponTemplateMapper; + + @Resource + private CouponTemplateScopeMapper couponTemplateScopeMapper; + + @Override + public CouponTemplate getById(Long id) { + CouponTemplate template = couponTemplateMapper.selectById(id); + if (template != null && template.getScopeType() != null && template.getScopeType() > 1) { + template.setScopeList(couponTemplateScopeMapper.selectByTemplateId(id)); + } + return template; + } + + @Override + public List listForAdmin(CouponTemplate query) { + return couponTemplateMapper.selectList(query); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void add(CouponTemplate template, Long loginMerchantId, boolean isPlatformAdmin) { + validateScope(template, loginMerchantId, isPlatformAdmin); + + // 运营商管理员强制写入 creatorMerchantId + if (!isPlatformAdmin) { + template.setCreatorType(2); + template.setCreatorMerchantId(loginMerchantId); + } else { + template.setCreatorType(1); + } + + // 初始化库存 + if (template.getStockTotal() == null) { + template.setStockTotal(-1); + } + template.setStockRemain(template.getStockTotal()); + template.setStatus(0); // 默认下架 + + couponTemplateMapper.insert(template); + + // 保存 scope 明细 + saveScopeList(template.getId(), template.getScopeList()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void edit(CouponTemplate template, Long loginMerchantId, boolean isPlatformAdmin) { + CouponTemplate existing = couponTemplateMapper.selectById(template.getId()); + if (existing == null) { + throw new BusinessException("券模板不存在"); + } + checkOwnership(existing, loginMerchantId, isPlatformAdmin); + + // 冻结字段校验:已有兑换记录时不允许修改 + boolean hasExchanged = couponTemplateMapper.countExchangeRecord(template.getId()) > 0; + if (hasExchanged) { + if (template.getType() != null || template.getPointsCost() != null + || template.getValidityType() != null || template.getValidDays() != null) { + throw new BusinessException("该券已有用户兑换,不可修改券类型、积分价格、有效期规则等核心字段"); + } + } + + couponTemplateMapper.updateById(template); + + // 更新 scope(全量替换) + if (template.getScopeList() != null) { + validateScope(template, loginMerchantId, isPlatformAdmin); + couponTemplateScopeMapper.deleteByTemplateId(template.getId()); + saveScopeList(template.getId(), template.getScopeList()); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void changeStatus(Long id, Integer status, Long loginMerchantId, boolean isPlatformAdmin) { + CouponTemplate existing = couponTemplateMapper.selectById(id); + if (existing == null) { + throw new BusinessException("券模板不存在"); + } + checkOwnership(existing, loginMerchantId, isPlatformAdmin); + + CouponTemplate update = new CouponTemplate(); + update.setId(id); + update.setStatus(status); + couponTemplateMapper.updateById(update); + } + + // ---------- 私有方法 ---------- + + /** + * 校验 scope 权限边界 + */ + private void validateScope(CouponTemplate template, Long loginMerchantId, boolean isPlatformAdmin) { + Integer scopeType = template.getScopeType(); + if (scopeType == null) { + return; + } + // 运营商管理员不能选全平台 + if (!isPlatformAdmin && scopeType == 1) { + throw new BusinessException("运营商管理员不能创建全平台范围的券"); + } + // 运营商管理员选指定站点时,校验站点归属 + if (!isPlatformAdmin && scopeType == 3 && !CollectionUtils.isEmpty(template.getScopeList())) { + for (CouponTemplateScope scope : template.getScopeList()) { + // 此处仅做占位;实际需注入 PileStationInfoMapper 校验站点归属 + // 示例:pileStationInfoMapper.selectById(scope.getScopeId()).getMerchantId() == loginMerchantId + logger.debug("校验站点 {} 是否属于运营商 {}", scope.getScopeId(), loginMerchantId); + } + } + } + + /** + * 校验操作人是否有权操作该模板 + */ + private void checkOwnership(CouponTemplate template, Long loginMerchantId, boolean isPlatformAdmin) { + if (!isPlatformAdmin) { + if (!loginMerchantId.equals(template.getCreatorMerchantId())) { + throw new BusinessException("无权操作该券模板"); + } + } + } + + private void saveScopeList(Long templateId, List scopeList) { + if (!CollectionUtils.isEmpty(scopeList)) { + for (CouponTemplateScope scope : scopeList) { + scope.setTemplateId(templateId); + } + couponTemplateScopeMapper.insertBatch(scopeList); + } + } +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/MemberCouponServiceImpl.java b/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/MemberCouponServiceImpl.java new file mode 100644 index 000000000..8c34566fa --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/MemberCouponServiceImpl.java @@ -0,0 +1,326 @@ +package com.jsowell.pile.service.impl; + +import com.jsowell.common.exception.BusinessException; +import com.jsowell.pile.domain.CouponTemplate; +import com.jsowell.pile.domain.CouponTemplateScope; +import com.jsowell.pile.domain.CouponVerifyRecord; +import com.jsowell.pile.domain.MemberCoupon; +import com.jsowell.pile.mapper.CouponTemplateScopeMapper; +import com.jsowell.pile.mapper.CouponTemplateMapper; +import com.jsowell.pile.mapper.CouponVerifyRecordMapper; +import com.jsowell.pile.mapper.MemberCouponMapper; +import com.jsowell.pile.service.MemberCouponService; +import com.jsowell.pile.service.MemberPointsInfoService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.annotation.Resource; +import java.util.Calendar; +import java.util.Date; +import java.util.List; +import java.util.UUID; + +/** + * 会员优惠券 Service 实现 + */ +@Service +public class MemberCouponServiceImpl implements MemberCouponService { + + private static final Logger logger = LoggerFactory.getLogger(MemberCouponServiceImpl.class); + + /** 积分类型:兑换消耗 */ + private static final int POINTS_TYPE_COUPON_EXCHANGE = 3; + + @Resource + private MemberCouponMapper memberCouponMapper; + + @Resource + private CouponTemplateMapper couponTemplateMapper; + + @Resource + private CouponTemplateScopeMapper couponTemplateScopeMapper; + + @Resource + private CouponVerifyRecordMapper couponVerifyRecordMapper; + + @Resource + private MemberPointsInfoService memberPointsInfoService; + + // ---------------------------------------------------------------- + // 用户端 + // ---------------------------------------------------------------- + + @Override + public List listAvailableTemplates(String memberId, Long stationId) { + // 查询所有上架且在兑换期内的模板 + CouponTemplate query = new CouponTemplate(); + query.setStatus(1); + List all = couponTemplateMapper.selectList(query); + + Date now = new Date(); + all.removeIf(t -> { + // 兑换时间段校验 + if (t.getExchangeStartTime() != null && now.before(t.getExchangeStartTime())) return true; + if (t.getExchangeEndTime() != null && now.after(t.getExchangeEndTime())) return true; + // 库存校验 + if (t.getStockRemain() != null && t.getStockRemain() == 0) return true; + // scope 校验:全平台(1)直接放行;指定范围时按 stationId 过滤 + if (t.getScopeType() != null && t.getScopeType() > 1 && stationId != null) { + return !isScopeMatch(t, stationId); + } + return false; + }); + return all; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public MemberCoupon exchange(String memberId, Long templateId, String requestId) { + CouponTemplate template = couponTemplateMapper.selectById(templateId); + if (template == null || template.getStatus() != 1) { + throw new BusinessException("券模板不存在或已下架"); + } + + Date now = new Date(); + + // 1. 兑换时间段校验 + if (template.getExchangeStartTime() != null && now.before(template.getExchangeStartTime())) { + throw new BusinessException("兑换活动尚未开始"); + } + if (template.getExchangeEndTime() != null && now.after(template.getExchangeEndTime())) { + throw new BusinessException("兑换活动已结束"); + } + + // 2. 单用户累计限额 + if (template.getTotalLimit() != null && template.getTotalLimit() > 0) { + int total = memberCouponMapper.countByMemberAndTemplate(memberId, templateId); + if (total >= template.getTotalLimit()) { + throw new BusinessException("您已达到兑换上限"); + } + } + + // 3. 单用户每月限额 + if (template.getMonthlyLimit() != null && template.getMonthlyLimit() > 0) { + Date[] monthRange = getMonthRange(now); + int monthly = memberCouponMapper.countMonthByMemberAndTemplate(memberId, templateId, monthRange[0], monthRange[1]); + if (monthly >= template.getMonthlyLimit()) { + throw new BusinessException("本月兑换已达上限"); + } + } + + // 4. 单用户每日限额 + if (template.getDailyLimit() != null && template.getDailyLimit() > 0) { + Date[] dayRange = getDayRange(now); + int daily = memberCouponMapper.countTodayByMemberAndTemplate(memberId, templateId, dayRange[0], dayRange[1]); + if (daily >= template.getDailyLimit()) { + throw new BusinessException("今日兑换已达上限"); + } + } + + // 5. 扣减库存(原子,stock_remain=-1 时跳过) + if (template.getStockRemain() != null && template.getStockRemain() != -1) { + int updated = couponTemplateMapper.deductStock(templateId); + if (updated <= 0) { + throw new BusinessException("库存不足,兑换失败"); + } + } + + // 6. 计算过期时间 + Date expireTime = calcExpireTime(template, now); + + // 7. 扣减积分(复用现有 service,写积分流水 type=3) + memberPointsInfoService.deductPoints(memberId, template.getPointsCost(), + "COUPON_" + requestId); + + // 8. 生成用户券记录(exchange_request_id 唯一索引兜底幂等) + MemberCoupon coupon = MemberCoupon.builder() + .couponNo(UUID.randomUUID().toString().replace("-", "")) + .exchangeRequestId(requestId) + .templateId(templateId) + .memberId(memberId) + .couponType(template.getType()) + .pointsCost(template.getPointsCost()) + .discountRate(template.getDiscountRate()) + .source(1) + .expireTime(expireTime) + .build(); + try { + memberCouponMapper.insert(coupon); + } catch (DuplicateKeyException e) { + // 重复请求:幂等返回已有记录 + logger.warn("兑换幂等命中,requestId: {}, memberId: {}", requestId, memberId); + return memberCouponMapper.selectByCouponNo( + memberCouponMapper.selectByMemberId(memberId, null) + .stream() + .filter(c -> requestId.equals(c.getExchangeRequestId())) + .map(MemberCoupon::getCouponNo) + .findFirst() + .orElseThrow(() -> new BusinessException("兑换异常,请重试"))); + } + return coupon; + } + + @Override + public List myList(String memberId, Integer status) { + return memberCouponMapper.selectByMemberId(memberId, status); + } + + @Override + public MemberCoupon detail(String memberId, String couponNo) { + MemberCoupon coupon = memberCouponMapper.selectByCouponNo(couponNo); + if (coupon == null || !memberId.equals(coupon.getMemberId())) { + throw new BusinessException("券不存在"); + } + return coupon; + } + + // ---------------------------------------------------------------- + // 核销 + // ---------------------------------------------------------------- + + @Override + @Transactional(rollbackFor = Exception.class) + public void verify(String couponNo, Long storeId, String requestId, String operatorId) { + // 请求幂等:同一 requestId 已处理过则直接返回 + if (requestId != null) { + CouponVerifyRecord existing = couponVerifyRecordMapper.selectByRequestId(requestId); + if (existing != null) { + if (existing.getResult() == 1) { + return; // 已成功核销,幂等返回 + } + throw new BusinessException("该核销请求已失败,请重新发起"); + } + } + + MemberCoupon coupon = memberCouponMapper.selectByCouponNo(couponNo); + if (coupon == null) { + writeVerifyLog(couponNo, requestId, storeId, operatorId, 2, "券不存在"); + throw new BusinessException("券不存在"); + } + + // 校验 scope:核销门店是否在券可用范围内 + CouponTemplate template = couponTemplateMapper.selectById(coupon.getTemplateId()); + if (template != null && template.getScopeType() != null && template.getScopeType() > 1) { + if (!isScopeMatch(template, storeId)) { + writeVerifyLog(couponNo, requestId, storeId, operatorId, 2, "核销门店不在券可用范围内"); + throw new BusinessException("核销门店不在券可用范围内"); + } + } + + // 原子核销(status=0 且未过期) + int updated = memberCouponMapper.verify(couponNo, new Date(), storeId, operatorId); + if (updated <= 0) { + String reason = coupon.getStatus() != 0 ? "券已使用或已过期" : "券已过期"; + writeVerifyLog(couponNo, requestId, storeId, operatorId, 2, reason); + throw new BusinessException(reason); + } + + writeVerifyLog(couponNo, requestId, storeId, operatorId, 1, null); + logger.info("核销成功,couponNo: {}, storeId: {}, operator: {}", couponNo, storeId, operatorId); + } + + // ---------------------------------------------------------------- + // 后台 & 定时任务 + // ---------------------------------------------------------------- + + @Override + public List listRecordForAdmin(MemberCoupon query) { + return memberCouponMapper.selectRecordList(query); + } + + @Override + public int batchExpire() { + int count = memberCouponMapper.batchExpire(new Date()); + logger.info("批量归档过期券完成,共归档 {} 条", count); + return count; + } + + // ---------------------------------------------------------------- + // 私有工具方法 + // ---------------------------------------------------------------- + + /** + * 校验门店/站点是否在模板 scope 范围内 + */ + private boolean isScopeMatch(CouponTemplate template, Long targetId) { + if (template.getScopeType() == 1) { + return true; + } + List scopeIds = couponTemplateScopeMapper + .selectTemplateIdsByScopeId(template.getScopeType(), targetId); + // scopeIds 是按 (scopeType, scopeId) 反查的 templateId 列表 + return scopeIds.contains(template.getId()); + } + + /** + * 计算券的过期时间 + */ + private Date calcExpireTime(CouponTemplate template, Date now) { + if (template.getValidityType() == 1) { + return template.getValidEndTime(); + } + // 领取后 N 天 + Calendar cal = Calendar.getInstance(); + cal.setTime(now); + cal.add(Calendar.DAY_OF_MONTH, template.getValidDays()); + // 设为当天 23:59:59 + cal.set(Calendar.HOUR_OF_DAY, 23); + cal.set(Calendar.MINUTE, 59); + cal.set(Calendar.SECOND, 59); + cal.set(Calendar.MILLISECOND, 0); + return cal.getTime(); + } + + /** + * 获取当天 [00:00:00, 次日 00:00:00) 范围(Asia/Shanghai) + */ + private Date[] getDayRange(Date now) { + Calendar cal = Calendar.getInstance(java.util.TimeZone.getTimeZone("Asia/Shanghai")); + cal.setTime(now); + cal.set(Calendar.HOUR_OF_DAY, 0); + cal.set(Calendar.MINUTE, 0); + cal.set(Calendar.SECOND, 0); + cal.set(Calendar.MILLISECOND, 0); + Date start = cal.getTime(); + cal.add(Calendar.DAY_OF_MONTH, 1); + Date end = cal.getTime(); + return new Date[]{start, end}; + } + + /** + * 获取本月 [月初 00:00:00, 次月月初 00:00:00) 范围(Asia/Shanghai) + */ + private Date[] getMonthRange(Date now) { + Calendar cal = Calendar.getInstance(java.util.TimeZone.getTimeZone("Asia/Shanghai")); + cal.setTime(now); + cal.set(Calendar.DAY_OF_MONTH, 1); + cal.set(Calendar.HOUR_OF_DAY, 0); + cal.set(Calendar.MINUTE, 0); + cal.set(Calendar.SECOND, 0); + cal.set(Calendar.MILLISECOND, 0); + Date start = cal.getTime(); + cal.add(Calendar.MONTH, 1); + Date end = cal.getTime(); + return new Date[]{start, end}; + } + + private void writeVerifyLog(String couponNo, String requestId, Long storeId, + String operatorId, int result, String failReason) { + try { + CouponVerifyRecord log = CouponVerifyRecord.builder() + .couponNo(couponNo) + .requestId(requestId) + .storeId(storeId) + .operatorId(operatorId) + .result(result) + .failReason(failReason) + .build(); + couponVerifyRecordMapper.insert(log); + } catch (Exception e) { + logger.error("写入核销日志失败", e); + } + } +} diff --git a/jsowell-pile/src/main/resources/mapper/pile/CouponTemplateMapper.xml b/jsowell-pile/src/main/resources/mapper/pile/CouponTemplateMapper.xml new file mode 100644 index 000000000..7712d802c --- /dev/null +++ b/jsowell-pile/src/main/resources/mapper/pile/CouponTemplateMapper.xml @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + id, name, type, creator_type, creator_merchant_id, scope_type, + points_cost, discount_rate, min_charge_amount, max_discount_amount, + exchange_start_time, exchange_end_time, + stock_total, stock_remain, validity_type, + valid_start_time, valid_end_time, valid_days, + daily_limit, monthly_limit, total_limit, + status, description, update_by, create_by, create_time, update_time, del_flag + + + + + + + + INSERT INTO coupon_template ( + name, type, creator_type, creator_merchant_id, scope_type, + points_cost, discount_rate, min_charge_amount, max_discount_amount, + exchange_start_time, exchange_end_time, + stock_total, stock_remain, validity_type, + valid_start_time, valid_end_time, valid_days, + daily_limit, monthly_limit, total_limit, + status, description, update_by, create_by, create_time, update_time, del_flag + ) VALUES ( + #{name}, #{type}, #{creatorType}, #{creatorMerchantId}, #{scopeType}, + #{pointsCost}, #{discountRate}, #{minChargeAmount}, #{maxDiscountAmount}, + #{exchangeStartTime}, #{exchangeEndTime}, + #{stockTotal}, #{stockRemain}, #{validityType}, + #{validStartTime}, #{validEndTime}, #{validDays}, + #{dailyLimit}, #{monthlyLimit}, #{totalLimit}, + #{status}, #{description}, #{updateBy}, #{createBy}, NOW(), NOW(), '0' + ) + + + + UPDATE coupon_template + + name = #{name}, + status = #{status}, + stock_total = #{stockTotal}, + daily_limit = #{dailyLimit}, + monthly_limit = #{monthlyLimit}, + total_limit = #{totalLimit}, + description = #{description}, + update_by = #{updateBy}, + update_time = NOW() + + WHERE id = #{id} AND del_flag = '0' + + + + + UPDATE coupon_template + SET stock_remain = stock_remain - 1, + update_time = NOW() + WHERE id = #{id} + AND del_flag = '0' + AND (stock_remain = -1 OR stock_remain >= 1) + + + + + + diff --git a/jsowell-pile/src/main/resources/mapper/pile/CouponTemplateScopeMapper.xml b/jsowell-pile/src/main/resources/mapper/pile/CouponTemplateScopeMapper.xml new file mode 100644 index 000000000..ccd1f05ea --- /dev/null +++ b/jsowell-pile/src/main/resources/mapper/pile/CouponTemplateScopeMapper.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + INSERT INTO coupon_template_scope (template_id, scope_type, scope_id) + VALUES + + (#{item.templateId}, #{item.scopeType}, #{item.scopeId}) + + + + + DELETE FROM coupon_template_scope WHERE template_id = #{templateId} + + + diff --git a/jsowell-pile/src/main/resources/mapper/pile/CouponVerifyRecordMapper.xml b/jsowell-pile/src/main/resources/mapper/pile/CouponVerifyRecordMapper.xml new file mode 100644 index 000000000..816abe17e --- /dev/null +++ b/jsowell-pile/src/main/resources/mapper/pile/CouponVerifyRecordMapper.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + INSERT INTO coupon_verify_record ( + coupon_no, request_id, store_id, operator_id, + result, fail_reason, create_time + ) VALUES ( + #{couponNo}, #{requestId}, #{storeId}, #{operatorId}, + #{result}, #{failReason}, NOW() + ) + + + + + diff --git a/jsowell-pile/src/main/resources/mapper/pile/MemberCouponMapper.xml b/jsowell-pile/src/main/resources/mapper/pile/MemberCouponMapper.xml new file mode 100644 index 000000000..4f46ac9a3 --- /dev/null +++ b/jsowell-pile/src/main/resources/mapper/pile/MemberCouponMapper.xml @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + id, coupon_no, exchange_request_id, template_id, member_id, + coupon_type, points_cost, discount_rate, + status, source, exchange_time, expire_time, + use_time, use_store_id, use_operator, + create_time, del_flag + + + + + + + + + + INSERT INTO member_coupon ( + coupon_no, exchange_request_id, template_id, member_id, + coupon_type, points_cost, discount_rate, + status, source, exchange_time, expire_time, + create_time, del_flag + ) VALUES ( + #{couponNo}, #{exchangeRequestId}, #{templateId}, #{memberId}, + #{couponType}, #{pointsCost}, #{discountRate}, + 0, #{source}, NOW(), #{expireTime}, + NOW(), '0' + ) + + + + + UPDATE member_coupon + SET status = 1, + use_time = #{useTime}, + use_store_id = #{useStoreId}, + use_operator = #{useOperator} + WHERE coupon_no = #{couponNo} + AND status = 0 + AND expire_time > NOW() + AND del_flag = '0' + + + + + UPDATE member_coupon + SET status = 2 + WHERE status = 0 + AND expire_time <= #{now} + AND del_flag = '0' + + + + + + + + + + + + diff --git a/jsowell-quartz/src/main/java/com/jsowell/quartz/task/JsowellTask.java b/jsowell-quartz/src/main/java/com/jsowell/quartz/task/JsowellTask.java index 967c56cab..151cac0cf 100644 --- a/jsowell-quartz/src/main/java/com/jsowell/quartz/task/JsowellTask.java +++ b/jsowell-quartz/src/main/java/com/jsowell/quartz/task/JsowellTask.java @@ -22,6 +22,7 @@ import com.jsowell.pile.domain.PileStationInfo; import com.jsowell.pile.domain.ykcCommond.PublishPileBillingTemplateCommand; import com.jsowell.pile.domain.ykcCommond.StartChargingCommand; import com.jsowell.pile.service.*; +import com.jsowell.pile.service.MemberCouponService; import com.jsowell.pile.vo.base.StationInfoVO; import com.jsowell.pile.vo.web.BillingTemplateVO; import com.jsowell.thirdparty.amap.service.AMapService; @@ -83,6 +84,9 @@ public class JsowellTask { @Autowired private AdapayUnsplitRecordService adapayUnsplitRecordService; + @Autowired + private MemberCouponService memberCouponService; + /** * 设置挡板, PRE环境不执行 */ @@ -495,6 +499,21 @@ public class JsowellTask { orderBasicInfoService.updateOrderReviewFlagTemp(start, end, null); } + /** + * 批量归档过期优惠券(status=0 且 expire_time <= now 的记录更新为 status=2) + * jsowellTask.expireCoupons() + * Cron: 0 5 0 * * ?(每日 00:05 执行) + */ + public void expireCoupons() { + log.info("[expireCoupons] 开始归档过期优惠券..."); + try { + int count = memberCouponService.batchExpire(); + log.info("[expireCoupons] 归档完成,共归档 {} 张过期券", count); + } catch (Exception e) { + log.error("[expireCoupons] 归档过期券异常", e); + } + } + // private void processUnSettledOrderOld() { // String startTime = "2023-01-01 00:00:00";