CouponServiceImpl.java 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  1. package com.zsElectric.boot.business.service.impl;
  2. import cn.hutool.core.util.ObjectUtil;
  3. import com.baomidou.mybatisplus.core.toolkit.Wrappers;
  4. import com.zsElectric.boot.business.model.form.applet.AppGainCouponForm;
  5. import com.zsElectric.boot.business.model.query.applet.AppCouponQuery;
  6. import com.zsElectric.boot.business.model.vo.applet.AppCouponStatusNumVO;
  7. import com.zsElectric.boot.business.service.CouponTemplateService;
  8. import com.zsElectric.boot.core.exception.BusinessException;
  9. import com.zsElectric.boot.core.exception.CouponException;
  10. import com.zsElectric.boot.business.model.entity.CouponTemplate;
  11. import com.zsElectric.boot.security.util.SecurityUtils;
  12. import jakarta.annotation.Resource;
  13. import lombok.RequiredArgsConstructor;
  14. import lombok.extern.slf4j.Slf4j;
  15. import org.redisson.api.RLock;
  16. import org.redisson.api.RedissonClient;
  17. import org.springframework.beans.factory.annotation.Autowired;
  18. import org.springframework.stereotype.Service;
  19. import com.baomidou.mybatisplus.core.metadata.IPage;
  20. import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
  21. import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
  22. import com.zsElectric.boot.business.mapper.CouponMapper;
  23. import com.zsElectric.boot.business.service.CouponService;
  24. import com.zsElectric.boot.business.model.entity.Coupon;
  25. import com.zsElectric.boot.business.model.form.CouponForm;
  26. import com.zsElectric.boot.business.model.query.CouponQuery;
  27. import com.zsElectric.boot.business.model.vo.CouponVO;
  28. import com.zsElectric.boot.business.converter.CouponConverter;
  29. import org.springframework.data.redis.core.RedisTemplate;
  30. import java.math.BigDecimal;
  31. import java.time.LocalDateTime;
  32. import java.util.Arrays;
  33. import java.util.List;
  34. import java.util.concurrent.TimeUnit;
  35. import cn.hutool.core.lang.Assert;
  36. import cn.hutool.core.util.StrUtil;
  37. import org.springframework.transaction.annotation.Transactional;
  38. /**
  39. * 优惠劵服务实现类
  40. *
  41. * @author zsElectric
  42. * @since 2025-12-19 09:58
  43. */
  44. @Service
  45. @Slf4j
  46. @RequiredArgsConstructor
  47. public class CouponServiceImpl extends ServiceImpl<CouponMapper, Coupon> implements CouponService {
  48. private final CouponConverter couponConverter;
  49. private final CouponTemplateService couponTemplateService;
  50. /**
  51. * 获取优惠劵分页列表
  52. *
  53. * @param queryParams 查询参数
  54. * @return {@link IPage<CouponVO>} 优惠劵分页列表
  55. */
  56. @Override
  57. public IPage<CouponVO> getCouponPage(CouponQuery queryParams) {
  58. Page<CouponVO> pageVO = this.baseMapper.getCouponPage(
  59. new Page<>(queryParams.getPageNum(), queryParams.getPageSize()),
  60. queryParams
  61. );
  62. return pageVO;
  63. }
  64. /**
  65. * 获取优惠劵表单数据
  66. *
  67. * @param id 优惠劵ID
  68. * @return 优惠劵表单数据
  69. */
  70. @Override
  71. public CouponForm getCouponFormData(Long id) {
  72. Coupon entity = this.getById(id);
  73. return couponConverter.toForm(entity);
  74. }
  75. /**
  76. * 新增优惠劵
  77. *
  78. * @param formData 优惠劵表单对象
  79. * @return 是否新增成功
  80. */
  81. @Override
  82. public boolean saveCoupon(CouponForm formData) {
  83. Coupon entity = couponConverter.toEntity(formData);
  84. return this.save(entity);
  85. }
  86. /**
  87. * 更新优惠劵
  88. *
  89. * @param id 优惠劵ID
  90. * @param formData 优惠劵表单对象
  91. * @return 是否修改成功
  92. */
  93. @Override
  94. public boolean updateCoupon(Long id, CouponForm formData) {
  95. Coupon entity = couponConverter.toEntity(formData);
  96. return this.updateById(entity);
  97. }
  98. /**
  99. * 删除优惠劵
  100. *
  101. * @param ids 优惠劵ID,多个以英文逗号(,)分割
  102. * @return 是否删除成功
  103. */
  104. @Override
  105. public boolean deleteCoupons(String ids) {
  106. Assert.isTrue(StrUtil.isNotBlank(ids), "删除的优惠劵数据为空");
  107. // 逻辑删除
  108. List<Long> idList = Arrays.stream(ids.split(","))
  109. .map(Long::parseLong)
  110. .toList();
  111. return this.removeByIds(idList);
  112. }
  113. /**
  114. * 领取优惠券
  115. *
  116. * @param templateId 优惠券模板ID
  117. * @param userId 用户ID
  118. * @param takeType 领取类型(1-用户领取,2-后台发放)
  119. * @return 优惠券ID
  120. */
  121. @Transactional(rollbackFor = Exception.class)
  122. public Long takeCoupon(Long templateId, Long userId, Integer takeType) {
  123. // 获取优惠券模板
  124. CouponTemplate template = couponTemplateService.getById(templateId);
  125. if (template == null) {
  126. throw new CouponException("优惠券模板不存在");
  127. }
  128. // 检查模板状态(1-上线,2-下线)
  129. if (template.getStatus() != 1) {
  130. throw new CouponException("优惠券模板已下线");
  131. }
  132. // 检查发放数量限制
  133. if (template.getTotalCount() != -1) { // -1表示不限制
  134. if (template.getTotalCountAll() != null && template.getTotalCountAll() >= template.getTotalCount()) {
  135. throw new CouponException("优惠券已领完");
  136. }
  137. }
  138. // 创建优惠券
  139. Coupon coupon = new Coupon();
  140. coupon.setTemplateId(templateId);
  141. coupon.setName(template.getName());
  142. coupon.setUserId(userId);
  143. coupon.setTakeType(takeType);
  144. coupon.setTakeTime(LocalDateTime.now());
  145. coupon.setStatus(1); // 未使用
  146. // 设置过期时间
  147. if (template.getFailureTime() != null && template.getFailureTime() > 0) {
  148. LocalDateTime expireTime = LocalDateTime.now().plusDays(template.getFailureTime());
  149. coupon.setExpireTime(expireTime);
  150. }
  151. // 保存优惠券
  152. this.save(coupon);
  153. // 更新模板已发放数量
  154. template.setTotalCountAll(template.getTotalCountAll() != null ? template.getTotalCountAll() + 1 : 1);
  155. couponTemplateService.updateById(template);
  156. return coupon.getId();
  157. }
  158. /**
  159. * 计算优惠金额
  160. *
  161. * @param coupon 优惠券
  162. * @param orderAmount 订单金额
  163. * @return 优惠金额
  164. */
  165. public BigDecimal calculateDiscount(Coupon coupon, BigDecimal orderAmount) {
  166. // 检查优惠券状态
  167. if (!isValidCoupon(coupon.getId())) {
  168. return BigDecimal.ZERO;
  169. }
  170. // 获取优惠券模板
  171. CouponTemplate template = couponTemplateService.getById(coupon.getTemplateId());
  172. if (template == null) {
  173. return BigDecimal.ZERO;
  174. }
  175. // 根据优惠券类型计算优惠金额
  176. switch (template.getType()) {
  177. case 1: // 折扣券
  178. return calculateDiscountCoupon(template, orderAmount);
  179. case 2: // 满减券
  180. return calculateReductionCoupon(template, orderAmount);
  181. case 3: // 无门槛券
  182. return calculateNoThresholdCoupon(template);
  183. default:
  184. return BigDecimal.ZERO;
  185. }
  186. }
  187. /**
  188. * 计算折扣券优惠金额
  189. *
  190. * @param template 优惠券模板
  191. * @param orderAmount 订单金额
  192. * @return 优惠金额
  193. */
  194. private BigDecimal calculateDiscountCoupon(CouponTemplate template, BigDecimal orderAmount) {
  195. // 检查是否满足使用条件
  196. if (template.getUsePrice() != null && orderAmount.compareTo(template.getUsePrice()) < 0) {
  197. return BigDecimal.ZERO;
  198. }
  199. // 计算折扣金额 = 订单金额 × 折扣率
  200. if (template.getDiscountPrice() != null) {
  201. return orderAmount.multiply(template.getDiscountPrice()).divide(BigDecimal.valueOf(100));
  202. }
  203. return BigDecimal.ZERO;
  204. }
  205. /**
  206. * 计算满减券优惠金额
  207. *
  208. * @param template 优惠券模板
  209. * @param orderAmount 订单金额
  210. * @return 优惠金额
  211. */
  212. private BigDecimal calculateReductionCoupon(CouponTemplate template, BigDecimal orderAmount) {
  213. // 检查是否满足使用条件
  214. if (template.getUsePrice() != null && orderAmount.compareTo(template.getUsePrice()) < 0) {
  215. return BigDecimal.ZERO;
  216. }
  217. // 返回减免金额
  218. return template.getDiscountPrice() != null ? template.getDiscountPrice() : BigDecimal.ZERO;
  219. }
  220. /**
  221. * 计算无门槛券优惠金额
  222. *
  223. * @param template 优惠券模板
  224. * @return 优惠金额
  225. */
  226. private BigDecimal calculateNoThresholdCoupon(CouponTemplate template) {
  227. // 直接返回减免金额
  228. return template.getDiscountPrice() != null ? template.getDiscountPrice() : BigDecimal.ZERO;
  229. }
  230. /**
  231. * 检查优惠券是否有效
  232. *
  233. * @param couponId 优惠券ID
  234. * @return 是否有效
  235. */
  236. @Override
  237. public boolean isValidCoupon(Long couponId) {
  238. Coupon coupon = this.getById(couponId);
  239. if (coupon == null) {
  240. return false;
  241. }
  242. // 检查优惠券状态(1-未使用,2-已使用,3-已过期)
  243. if (coupon.getStatus() != 1) {
  244. return false;
  245. }
  246. // 检查是否过期
  247. if (coupon.getExpireTime().isBefore(LocalDateTime.now())) {
  248. return false;
  249. }
  250. return true;
  251. }
  252. /**
  253. * 检查优惠券是否可以使用
  254. *
  255. * @param couponId 优惠券ID
  256. * @param orderAmount 订单金额
  257. * @return 是否可以使用
  258. */
  259. public boolean canUseCoupon(Long couponId, BigDecimal orderAmount) {
  260. // 获取优惠券
  261. Coupon coupon = this.getById(couponId);
  262. if (coupon == null) {
  263. return false;
  264. }
  265. // 检查优惠券状态
  266. if (coupon.getStatus() != 1) {
  267. return false;
  268. }
  269. // 检查是否过期
  270. if (coupon.getExpireTime().isBefore(LocalDateTime.now())) {
  271. return false;
  272. }
  273. // 获取优惠券模板
  274. CouponTemplate template = couponTemplateService.getById(coupon.getTemplateId());
  275. if (template == null) {
  276. return false;
  277. }
  278. // 根据优惠券类型检查使用条件
  279. switch (template.getType()) {
  280. case 1: // 折扣券
  281. // 检查是否满足使用条件
  282. if (template.getUsePrice() != null && orderAmount.compareTo(template.getUsePrice()) < 0) {
  283. return false;
  284. }
  285. return true;
  286. case 2: // 满减券
  287. // 检查是否满足使用条件
  288. if (template.getUsePrice() != null && orderAmount.compareTo(template.getUsePrice()) < 0) {
  289. return false;
  290. }
  291. return true;
  292. case 3: // 无门槛券
  293. return true;
  294. default:
  295. return false;
  296. }
  297. }
  298. /**
  299. * 使用优惠券
  300. *
  301. * @param couponId 优惠券ID
  302. * @param orderId 订单ID
  303. * @param orderAmount 订单金额
  304. * @return 实际优惠金额
  305. */
  306. @Transactional(rollbackFor = Exception.class)
  307. public BigDecimal useCoupon(Long couponId, Long orderId, BigDecimal orderAmount) {
  308. // 获取优惠券
  309. Coupon coupon = this.getById(couponId);
  310. if (coupon == null) {
  311. throw new CouponException("优惠券不存在");
  312. }
  313. // 检查优惠券状态
  314. if (coupon.getStatus() != 1) {
  315. throw new CouponException("优惠券状态不正确");
  316. }
  317. // 检查是否过期
  318. if (coupon.getExpireTime().isBefore(LocalDateTime.now())) {
  319. throw new CouponException("优惠券已过期");
  320. }
  321. // 计算优惠金额
  322. BigDecimal discountAmount = calculateDiscount(coupon, orderAmount);
  323. // 更新优惠券状态为已使用
  324. coupon.setStatus(2); // 已使用
  325. coupon.setUseOrderId(orderId);
  326. coupon.setUseTime(LocalDateTime.now());
  327. this.updateById(coupon);
  328. return discountAmount;
  329. }
  330. /**
  331. * 过期优惠券处理
  332. *
  333. * @param couponId 优惠券ID
  334. * @return 是否处理成功
  335. */
  336. @Override
  337. public boolean expireCoupon(Long couponId) {
  338. Coupon coupon = this.getById(couponId);
  339. if (coupon == null) {
  340. return false;
  341. }
  342. // 检查优惠券状态
  343. if (coupon.getStatus() != 1) {
  344. return false;
  345. }
  346. // 检查是否过期
  347. if (coupon.getExpireTime().isAfter(LocalDateTime.now())) {
  348. return false;
  349. }
  350. // 更新优惠券状态为已过期
  351. coupon.setStatus(3); // 已过期
  352. return this.updateById(coupon);
  353. }
  354. /**
  355. * 获取用户指定状态的优惠券列表
  356. *
  357. * @param userId 用户ID
  358. * @param status 优惠券状态
  359. * @return 优惠券列表
  360. */
  361. @Override
  362. public List<Coupon> getUserCouponsByStatus(Long userId, Integer status) {
  363. return this.list(Wrappers.lambdaQuery(Coupon.class)
  364. .eq(Coupon::getUserId, userId)
  365. .eq(Coupon::getStatus, status));
  366. }
  367. /**
  368. * 根据模板ID和用户ID获取用户持有的优惠券
  369. *
  370. * @param templateId 模板ID
  371. * @param userId 用户ID
  372. * @return 优惠券列表
  373. */
  374. @Override
  375. public List<Coupon> getCouponsByTemplateAndUser(Long templateId, Long userId) {
  376. return this.list(Wrappers.lambdaQuery(Coupon.class)
  377. .eq(Coupon::getTemplateId, templateId)
  378. .eq(Coupon::getUserId, userId));
  379. }
  380. @Override
  381. public IPage<CouponVO> getUserCouponPage(AppCouponQuery queryParams) {
  382. queryParams.setUserId(SecurityUtils.getUserId());
  383. Page<CouponVO> pageVO = this.baseMapper.getUserCouponPage(
  384. new Page<>(queryParams.getPageNum(), queryParams.getPageSize()),
  385. queryParams
  386. );
  387. return pageVO;
  388. }
  389. @Override
  390. public AppCouponStatusNumVO getCouponStatusNum(Long userId) {
  391. return this.baseMapper.getCouponStatusNum(userId);
  392. }
  393. @Resource
  394. private RedissonClient redissonClient;
  395. // 获取分布式锁
  396. private RLock getLock(String couponCode) {
  397. return redissonClient.getLock("lock:coupon:" + couponCode);
  398. }
  399. @Autowired
  400. private RedisTemplate<String, Object> redisTemplate;
  401. @Transactional(rollbackFor = Exception.class)
  402. @Override
  403. public Boolean gainCoupon(AppGainCouponForm formData) {
  404. RLock lock = getLock(formData.getCouponCode());
  405. try {
  406. // 尝试获取锁(等待3秒,自动续期10秒)
  407. boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
  408. if (!locked) {
  409. throw new BusinessException("系统繁忙,请稍后重试!");
  410. }
  411. formData.setUserId(SecurityUtils.getUserId());
  412. // 查询优惠券模板是否存在且可用
  413. CouponTemplate template = couponTemplateService.getOne(Wrappers.lambdaQuery(CouponTemplate.class)
  414. .eq(CouponTemplate::getCode, formData.getCouponCode()));
  415. if (ObjectUtil.isEmpty(template)) {
  416. throw new CouponException("优惠券不存在");
  417. }
  418. if (!couponTemplateService.isValidTemplate(template.getId())) {
  419. throw new CouponException("优惠券已失效");
  420. }
  421. // 判断当前用户是否已经领取过该优惠券
  422. Coupon existCoupon = this.getOne(Wrappers.lambdaQuery(Coupon.class)
  423. .eq(Coupon::getUserId, formData.getUserId())
  424. .eq(Coupon::getCouponCode, formData.getCouponCode())
  425. );
  426. if (ObjectUtil.isNotEmpty(existCoupon)) {
  427. throw new CouponException("当前用户已经领取过该优惠券");
  428. }
  429. // Redis和MySQL库存扣减
  430. String stockKey = "coupon:stock:" + template.getId();
  431. // 初始化库存到Redis(如果不存在)
  432. initStockInRedis(stockKey, template);
  433. // 使用Redis的decr命令进行原子性扣减库存操作
  434. Long stock = redisTemplate.opsForValue().decrement(stockKey);
  435. if (stock == null || stock < 0) {
  436. // 库存不足,回滚操作
  437. if (stock != null && stock < 0) {
  438. redisTemplate.opsForValue().increment(stockKey);
  439. }
  440. throw new CouponException("优惠券库存不足");
  441. }
  442. // 同步更新MySQL中的库存(使用乐观锁)
  443. boolean mysqlUpdated = updateMysqlStock(template.getId());
  444. if (!mysqlUpdated) {
  445. // MySQL库存更新失败,回滚Redis库存
  446. redisTemplate.opsForValue().increment(stockKey);
  447. throw new CouponException("优惠券库存不足");
  448. }
  449. // 创建用户优惠券
  450. Coupon userCoupon = new Coupon();
  451. userCoupon.setTemplateId(template.getId());
  452. userCoupon.setName(template.getName());
  453. userCoupon.setCouponCode(template.getCode());
  454. userCoupon.setStatus(1);
  455. userCoupon.setDescription(template.getDescription());
  456. userCoupon.setUserId(formData.getUserId());
  457. userCoupon.setTakeType(1);
  458. LocalDateTime now = LocalDateTime.now();
  459. userCoupon.setTakeTime(now);
  460. userCoupon.setExpireTime(now.plusDays(template.getFailureTime()));
  461. return this.save(userCoupon);
  462. } catch (InterruptedException e) {
  463. Thread.currentThread().interrupt();
  464. throw new BusinessException("系统繁忙,请稍后重试!");
  465. } catch (CouponException e) {
  466. // 优惠券相关异常直接抛出
  467. throw e;
  468. } catch (BusinessException e) {
  469. // 业务异常直接抛出
  470. throw e;
  471. } catch (Exception e) {
  472. // 其他异常记录日志后抛出
  473. log.error("优惠券领取异常,用户ID:{}, 优惠券码:{}", formData.getUserId(), formData.getCouponCode(), e);
  474. throw new BusinessException("优惠券领取失败,请稍后重试!");
  475. } finally {
  476. if (lock.isHeldByCurrentThread()) {
  477. lock.unlock();
  478. }
  479. }
  480. }
  481. /**
  482. * 初始化优惠券库存到Redis
  483. * @param stockKey 库存键
  484. * @param template 优惠券模板
  485. */
  486. private void initStockInRedis(String stockKey, CouponTemplate template) {
  487. // 使用Redis的SETNX命令,只有当key不存在时才设置,保证原子性
  488. Boolean success = redisTemplate.opsForValue().setIfAbsent(stockKey, 0);
  489. if (Boolean.TRUE.equals(success)) {
  490. // 首次初始化,计算剩余库存
  491. int totalCount = template.getTotalCount();
  492. int usedCount = template.getTotalCountAll() != null ? template.getTotalCountAll() : 0;
  493. // 如果是无限库存(-1)则设置一个大数值
  494. int stock = totalCount == -1 ? 999999 : Math.max(0, totalCount - usedCount);
  495. redisTemplate.opsForValue().set(stockKey, stock);
  496. // 设置过期时间,防止Redis数据永久存在(可选,根据业务需求调整)
  497. redisTemplate.expire(stockKey, 7, TimeUnit.DAYS);
  498. }
  499. }
  500. /**
  501. * 更新MySQL中的库存数量
  502. * @param templateId 模板ID
  503. * @return 是否更新成功
  504. */
  505. private boolean updateMysqlStock(Long templateId) {
  506. // 使用乐观锁更新已发放数量
  507. CouponTemplate template = couponTemplateService.getById(templateId);
  508. if (template == null) {
  509. return false;
  510. }
  511. // 检查是否有限制发放数量且已达到上限
  512. if (template.getTotalCount() != -1) { // -1表示不限制
  513. int currentCount = template.getTotalCountAll() != null ? template.getTotalCountAll() : 0;
  514. if (currentCount >= template.getTotalCount()) {
  515. return false;
  516. }
  517. }
  518. // 使用乐观锁更新已发放数量(version字段由MyBatis-Plus自动处理)
  519. return couponTemplateService.update(
  520. Wrappers.<CouponTemplate>lambdaUpdate()
  521. .setSql("total_count_all = total_count_all + 1")
  522. .eq(CouponTemplate::getId, templateId)
  523. .eq(CouponTemplate::getVersion, template.getVersion())
  524. );
  525. }
  526. }