mirror of
https://codeup.aliyun.com/67c68d4e484ca2f0a13ac3c1/ydc/jsowell-charger-web.git
synced 2026-06-14 12:20:04 +08:00
update
This commit is contained in:
128
docs/coupon-impl-progress.md
Normal file
128
docs/coupon-impl-progress.md
Normal 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 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 两种方式
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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("功能待实现");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 <= NOW())
|
||||
</when>
|
||||
<when test="status == 0">
|
||||
status = 0 AND expire_time > 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 <= #{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 < #{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 < #{monthEnd}
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user