This commit is contained in:
Guoqs
2026-03-04 16:06:55 +08:00
parent 100ca67ad6
commit c231c322f8
21 changed files with 1766 additions and 0 deletions

View File

@@ -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 1Domain 实体类
**状态**: ✅ 完成
### 文件清单
| 文件 | 路径 |
|------|------|
| `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 2Mapper 接口和 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 3Service 层
**状态**: ✅ 完成
### 文件清单
| 文件 | 路径 |
|------|------|
| `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 6Quartz 过期归档任务
**状态**: ✅ 完成
方法 `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. **二维码安全**:券详情接口返回短时签名 tokenHMAC-SHA2565分钟有效核销接口接受 token 或 couponNo 两种方式

View File

@@ -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<CouponTemplate> 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<MemberCoupon> 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<String, Object> 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")));
}
}

View File

@@ -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<MemberCoupon> 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("功能待实现");
}
}

View File

@@ -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<CouponTemplate> 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());
}
}
}

View File

@@ -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<CouponTemplateScope> scopeList;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
/** 关联会员IDvarchar与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;
}

View File

@@ -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<CouponTemplate> 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);
}

View File

@@ -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<CouponTemplateScope> selectByTemplateId(@Param("templateId") Long templateId);
/**
* 按 (scopeType, scopeId) 反查可用模板ID列表走 idx_scope_lookup
*/
List<Long> selectTemplateIdsByScopeId(@Param("scopeType") Integer scopeType,
@Param("scopeId") Long scopeId);
int insertBatch(@Param("list") List<CouponTemplateScope> list);
int deleteByTemplateId(@Param("templateId") Long templateId);
}

View File

@@ -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);
}

View File

@@ -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<MemberCoupon> selectByMemberId(@Param("memberId") String memberId,
@Param("status") Integer status);
/**
* 后台兑换记录查询
*/
List<MemberCoupon> 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);
}

View File

@@ -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<CouponTemplate> 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);
}

View File

@@ -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<CouponTemplate> 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<MemberCoupon> 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<MemberCoupon> listRecordForAdmin(MemberCoupon query);
/**
* 批量归档过期券Quartz 调用)
*/
int batchExpire();
}

View File

@@ -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<CouponTemplate> 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<CouponTemplateScope> scopeList) {
if (!CollectionUtils.isEmpty(scopeList)) {
for (CouponTemplateScope scope : scopeList) {
scope.setTemplateId(templateId);
}
couponTemplateScopeMapper.insertBatch(scopeList);
}
}
}

View File

@@ -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<CouponTemplate> listAvailableTemplates(String memberId, Long stationId) {
// 查询所有上架且在兑换期内的模板
CouponTemplate query = new CouponTemplate();
query.setStatus(1);
List<CouponTemplate> 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<MemberCoupon> 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<MemberCoupon> 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<Long> 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);
}
}
}

View File

@@ -0,0 +1,123 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.jsowell.pile.mapper.CouponTemplateMapper">
<resultMap id="BaseResultMap" type="com.jsowell.pile.domain.CouponTemplate">
<id column="id" property="id"/>
<result column="name" property="name"/>
<result column="type" property="type"/>
<result column="creator_type" property="creatorType"/>
<result column="creator_merchant_id" property="creatorMerchantId"/>
<result column="scope_type" property="scopeType"/>
<result column="points_cost" property="pointsCost"/>
<result column="discount_rate" property="discountRate"/>
<result column="min_charge_amount" property="minChargeAmount"/>
<result column="max_discount_amount" property="maxDiscountAmount"/>
<result column="exchange_start_time" property="exchangeStartTime"/>
<result column="exchange_end_time" property="exchangeEndTime"/>
<result column="stock_total" property="stockTotal"/>
<result column="stock_remain" property="stockRemain"/>
<result column="validity_type" property="validityType"/>
<result column="valid_start_time" property="validStartTime"/>
<result column="valid_end_time" property="validEndTime"/>
<result column="valid_days" property="validDays"/>
<result column="daily_limit" property="dailyLimit"/>
<result column="monthly_limit" property="monthlyLimit"/>
<result column="total_limit" property="totalLimit"/>
<result column="status" property="status"/>
<result column="description" property="description"/>
<result column="update_by" property="updateBy"/>
<result column="create_by" property="createBy"/>
<result column="create_time" property="createTime"/>
<result column="update_time" property="updateTime"/>
<result column="del_flag" property="delFlag"/>
</resultMap>
<sql id="Base_Column_List">
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
</sql>
<select id="selectById" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM coupon_template
WHERE id = #{id} AND del_flag = '0'
</select>
<select id="selectList" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM coupon_template
WHERE del_flag = '0'
<if test="creatorMerchantId != null">
AND creator_merchant_id = #{creatorMerchantId}
</if>
<if test="status != null">
AND status = #{status}
</if>
<if test="type != null">
AND type = #{type}
</if>
<if test="name != null and name != ''">
AND name LIKE CONCAT('%', #{name}, '%')
</if>
ORDER BY create_time DESC
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
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'
)
</insert>
<update id="updateById">
UPDATE coupon_template
<set>
<if test="name != null">name = #{name},</if>
<if test="status != null">status = #{status},</if>
<if test="stockTotal != null">stock_total = #{stockTotal},</if>
<if test="dailyLimit != null">daily_limit = #{dailyLimit},</if>
<if test="monthlyLimit != null">monthly_limit = #{monthlyLimit},</if>
<if test="totalLimit != null">total_limit = #{totalLimit},</if>
<if test="description != null">description = #{description},</if>
<if test="updateBy != null">update_by = #{updateBy},</if>
update_time = NOW()
</set>
WHERE id = #{id} AND del_flag = '0'
</update>
<!-- 原子扣减库存stock_remain=-1(不限) 时跳过扣减直接返回1 -->
<update id="deductStock">
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)
</update>
<!-- 检查是否已有兑换记录(用于冻结字段校验) -->
<select id="countExchangeRecord" resultType="int">
SELECT COUNT(1) FROM member_coupon
WHERE template_id = #{templateId} AND del_flag = '0'
</select>
</mapper>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.jsowell.pile.mapper.CouponTemplateScopeMapper">
<resultMap id="BaseResultMap" type="com.jsowell.pile.domain.CouponTemplateScope">
<id column="id" property="id"/>
<result column="template_id" property="templateId"/>
<result column="scope_type" property="scopeType"/>
<result column="scope_id" property="scopeId"/>
</resultMap>
<select id="selectByTemplateId" resultMap="BaseResultMap">
SELECT id, template_id, scope_type, scope_id
FROM coupon_template_scope
WHERE template_id = #{templateId}
</select>
<!-- 反向查询:按 (scopeType, scopeId) 查可用的模板ID列表走 idx_scope_lookup -->
<select id="selectTemplateIdsByScopeId" resultType="java.lang.Long">
SELECT template_id
FROM coupon_template_scope
WHERE scope_type = #{scopeType}
AND scope_id = #{scopeId}
</select>
<insert id="insertBatch">
INSERT INTO coupon_template_scope (template_id, scope_type, scope_id)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.templateId}, #{item.scopeType}, #{item.scopeId})
</foreach>
</insert>
<delete id="deleteByTemplateId">
DELETE FROM coupon_template_scope WHERE template_id = #{templateId}
</delete>
</mapper>

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.jsowell.pile.mapper.CouponVerifyRecordMapper">
<resultMap id="BaseResultMap" type="com.jsowell.pile.domain.CouponVerifyRecord">
<id column="id" property="id"/>
<result column="coupon_no" property="couponNo"/>
<result column="request_id" property="requestId"/>
<result column="store_id" property="storeId"/>
<result column="operator_id" property="operatorId"/>
<result column="result" property="result"/>
<result column="fail_reason" property="failReason"/>
<result column="create_time" property="createTime"/>
</resultMap>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
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()
)
</insert>
<select id="selectByRequestId" resultMap="BaseResultMap">
SELECT id, coupon_no, request_id, store_id, operator_id,
result, fail_reason, create_time
FROM coupon_verify_record
WHERE request_id = #{requestId}
LIMIT 1
</select>
</mapper>

View File

@@ -0,0 +1,142 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.jsowell.pile.mapper.MemberCouponMapper">
<resultMap id="BaseResultMap" type="com.jsowell.pile.domain.MemberCoupon">
<id column="id" property="id"/>
<result column="coupon_no" property="couponNo"/>
<result column="exchange_request_id" property="exchangeRequestId"/>
<result column="template_id" property="templateId"/>
<result column="member_id" property="memberId"/>
<result column="coupon_type" property="couponType"/>
<result column="points_cost" property="pointsCost"/>
<result column="discount_rate" property="discountRate"/>
<result column="status" property="status"/>
<result column="source" property="source"/>
<result column="exchange_time" property="exchangeTime"/>
<result column="expire_time" property="expireTime"/>
<result column="use_time" property="useTime"/>
<result column="use_store_id" property="useStoreId"/>
<result column="use_operator" property="useOperator"/>
<result column="create_time" property="createTime"/>
<result column="del_flag" property="delFlag"/>
</resultMap>
<sql id="Base_Column_List">
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
</sql>
<select id="selectByCouponNo" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM member_coupon
WHERE coupon_no = #{couponNo} AND del_flag = '0'
</select>
<select id="selectByMemberId" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM member_coupon
WHERE member_id = #{memberId}
AND del_flag = '0'
<if test="status != null">
AND (
<choose>
<when test="status == 2">
status = 2 OR (status = 0 AND expire_time &lt;= NOW())
</when>
<when test="status == 0">
status = 0 AND expire_time &gt; NOW()
</when>
<otherwise>
status = #{status}
</otherwise>
</choose>
)
</if>
ORDER BY create_time DESC
</select>
<select id="selectRecordList" resultMap="BaseResultMap">
SELECT <include refid="Base_Column_List"/>
FROM member_coupon
WHERE del_flag = '0'
<if test="memberId != null and memberId != ''">
AND member_id = #{memberId}
</if>
<if test="templateId != null">
AND template_id = #{templateId}
</if>
<if test="status != null">
AND status = #{status}
</if>
ORDER BY create_time DESC
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
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'
)
</insert>
<!-- 原子核销status=0 且未过期时才更新 -->
<update id="verify">
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>
<!-- 批量归档过期券Quartz 定时任务) -->
<update id="batchExpire">
UPDATE member_coupon
SET status = 2
WHERE status = 0
AND expire_time &lt;= #{now}
AND del_flag = '0'
</update>
<!-- 累计兑换数total_limit 校验) -->
<select id="countByMemberAndTemplate" resultType="int">
SELECT COUNT(1) FROM member_coupon
WHERE member_id = #{memberId}
AND template_id = #{templateId}
AND del_flag = '0'
</select>
<!-- 今日兑换数daily_limit 校验) -->
<select id="countTodayByMemberAndTemplate" resultType="int">
SELECT COUNT(1) FROM member_coupon
WHERE member_id = #{memberId}
AND template_id = #{templateId}
AND del_flag = '0'
AND exchange_time >= #{dayStart}
AND exchange_time &lt; #{dayEnd}
</select>
<!-- 本月兑换数monthly_limit 校验) -->
<select id="countMonthByMemberAndTemplate" resultType="int">
SELECT COUNT(1) FROM member_coupon
WHERE member_id = #{memberId}
AND template_id = #{templateId}
AND del_flag = '0'
AND exchange_time >= #{monthStart}
AND exchange_time &lt; #{monthEnd}
</select>
</mapper>

View File

@@ -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";