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,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>