You've already forked FrameTour-BE
feat(coupon): 添加优惠券领取后有效期配置功能
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 在数据库插入和更新操作中添加 valid_days_after_claim 字段支持 - 在使用优惠券时增加对优惠券全局有效性和领取记录过期时间的验证逻辑 - 添加对已过期领取记录的筛选和错误提示处理 - 新增优惠券使用请求、状态枚举和异常类的测试用例 - 实现优惠券配置的完整有效期验证流程
This commit is contained in:
@@ -249,6 +249,9 @@ public class OrderBiz {
|
||||
case 13: // AI微单
|
||||
sourceRepository.setUserIsBuyItem(order.getMemberId(), item.getGoodsType(), item.getGoodsId(), order.getId());
|
||||
break;
|
||||
case 14: // 单张照片
|
||||
sourceRepository.setUserIsBuyItemBySourceId(order.getMemberId(), item.getGoodsId(), order.getFaceId(), order.getId());
|
||||
break;
|
||||
case 3:
|
||||
printerService.setUserIsBuyItem(order.getMemberId(), item.getGoodsId(), order.getId());
|
||||
break;
|
||||
@@ -287,6 +290,9 @@ public class OrderBiz {
|
||||
case 2: // 照片原素材
|
||||
sourceRepository.setUserNotBuyItem(order.getMemberId(), item.getGoodsType(), item.getGoodsId());
|
||||
break;
|
||||
case 14: // 单张照片
|
||||
sourceRepository.setUserNotBuyItemBySourceId(order.getMemberId(), item.getGoodsId(), order.getFaceId());
|
||||
break;
|
||||
}
|
||||
});
|
||||
orderRepository.clearOrderCache(orderId); // 更新完了,清理下
|
||||
@@ -311,6 +317,9 @@ public class OrderBiz {
|
||||
case 2: // 照片原素材
|
||||
sourceRepository.setUserNotBuyItem(order.getMemberId(), item.getGoodsType(), item.getGoodsId());
|
||||
break;
|
||||
case 14: // 单张照片
|
||||
sourceRepository.setUserNotBuyItemBySourceId(order.getMemberId(), item.getGoodsId(), order.getFaceId());
|
||||
break;
|
||||
}
|
||||
});
|
||||
orderRepository.clearOrderCache(orderId); // 更新完了,清理下
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.github.pagehelper.PageInfo;
|
||||
import com.ycwl.basic.constant.BaseContextHandler;
|
||||
import com.ycwl.basic.mapper.SourceMapper;
|
||||
import com.ycwl.basic.mapper.VideoMapper;
|
||||
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
|
||||
import com.ycwl.basic.model.pc.source.req.SourceReqQuery;
|
||||
import com.ycwl.basic.model.pc.task.entity.TaskEntity;
|
||||
import com.ycwl.basic.model.pc.video.entity.MemberVideoEntity;
|
||||
@@ -95,6 +96,12 @@ public class AppOrderV2Controller {
|
||||
request.setFaceId(video.getFaceId());
|
||||
}
|
||||
case RECORDING_SET, PHOTO_SET, AI_CAM_PHOTO_SET -> request.setFaceId(Long.valueOf(productItem.getProductId()));
|
||||
case PHOTO -> {
|
||||
MemberSourceEntity ms = sourceMapper.getMemberSourceByMemberAndSourceId(currentUserId, Long.valueOf(productItem.getProductId()));
|
||||
if (ms != null) {
|
||||
request.setFaceId(ms.getFaceId());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +148,9 @@ public class AppOrderV2Controller {
|
||||
Integer _count = sourceMapper.countUser(aiPhotoSetReqQuery);
|
||||
product.setQuantity(_count);
|
||||
break;
|
||||
case PHOTO:
|
||||
product.setQuantity(1);
|
||||
break;
|
||||
default:
|
||||
log.warn("未知的商品类型,跳过重复购买检查: productType={}", product.getProductType());
|
||||
break;
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.ycwl.basic.model.pc.source.entity.SourceWatermarkEntity;
|
||||
import com.ycwl.basic.model.pc.source.req.SourceReqQuery;
|
||||
import com.ycwl.basic.model.pc.source.resp.SourceRespVO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
@@ -223,4 +224,19 @@ public interface SourceMapper {
|
||||
* @return 有数据的时间桶列表
|
||||
*/
|
||||
List<DeviceSourceTimelineVO> getDeviceSourceTimeline(Long deviceId, Date startTime, Date endTime);
|
||||
|
||||
/**
|
||||
* 根据会员ID和素材ID查询 member_source 关联记录
|
||||
* @param memberId 会员ID
|
||||
* @param sourceId 素材ID
|
||||
* @return 关联记录(含 faceId 等信息)
|
||||
*/
|
||||
MemberSourceEntity getMemberSourceByMemberAndSourceId(@Param("memberId") Long memberId, @Param("sourceId") Long sourceId);
|
||||
|
||||
/**
|
||||
* 根据会员ID和素材ID更新 member_source 关联记录的购买状态
|
||||
* @param memberSourceEntity 包含 memberId、sourceId、isBuy、orderId
|
||||
* @return 影响行数
|
||||
*/
|
||||
int updateRelationBySourceId(MemberSourceEntity memberSourceEntity);
|
||||
}
|
||||
|
||||
@@ -61,11 +61,11 @@ public interface PriceCouponConfigMapper extends BaseMapper<PriceCouponConfig> {
|
||||
*/
|
||||
@Insert("INSERT INTO price_coupon_config (coupon_name, coupon_type, discount_value, min_amount, " +
|
||||
"max_discount, applicable_products, required_attribute_keys, total_quantity, used_quantity, " +
|
||||
"claimed_quantity, user_claim_limit, valid_from, valid_until, " +
|
||||
"claimed_quantity, user_claim_limit, valid_from, valid_until, valid_days_after_claim, " +
|
||||
"is_active, scenic_id, create_time, update_time) VALUES " +
|
||||
"(#{couponName}, #{couponType}, #{discountValue}, #{minAmount}, #{maxDiscount}, " +
|
||||
"#{applicableProducts}, #{requiredAttributeKeys}, #{totalQuantity}, #{usedQuantity}, " +
|
||||
"#{claimedQuantity}, #{userClaimLimit}, #{validFrom}, #{validUntil}, " +
|
||||
"#{claimedQuantity}, #{userClaimLimit}, #{validFrom}, #{validUntil}, #{validDaysAfterClaim}, " +
|
||||
"#{isActive}, #{scenicId}, NOW(), NOW())")
|
||||
int insertCoupon(PriceCouponConfig coupon);
|
||||
|
||||
@@ -76,7 +76,8 @@ public interface PriceCouponConfigMapper extends BaseMapper<PriceCouponConfig> {
|
||||
"discount_value = #{discountValue}, min_amount = #{minAmount}, max_discount = #{maxDiscount}, " +
|
||||
"applicable_products = #{applicableProducts}, required_attribute_keys = #{requiredAttributeKeys}, " +
|
||||
"total_quantity = #{totalQuantity}, user_claim_limit = #{userClaimLimit}, " +
|
||||
"valid_from = #{validFrom}, valid_until = #{validUntil}, is_active = #{isActive}, " +
|
||||
"valid_from = #{validFrom}, valid_until = #{validUntil}, valid_days_after_claim = #{validDaysAfterClaim}, " +
|
||||
"is_active = #{isActive}, " +
|
||||
"scenic_id = #{scenicId}, update_time = NOW() WHERE id = #{id}")
|
||||
int updateCoupon(PriceCouponConfig coupon);
|
||||
|
||||
|
||||
@@ -224,6 +224,18 @@ public class CouponServiceImpl implements ICouponService {
|
||||
@Override
|
||||
@Transactional
|
||||
public CouponUseResult useCoupon(CouponUseRequest request) {
|
||||
Date now = new Date();
|
||||
PriceCouponConfig coupon = couponConfigMapper.selectById(request.getCouponId());
|
||||
if (coupon == null || coupon.getDeleted() == 1 || !Boolean.TRUE.equals(coupon.getIsActive())) {
|
||||
throw new CouponInvalidException("优惠券不存在或已失效");
|
||||
}
|
||||
if (coupon.getValidFrom() != null && now.before(coupon.getValidFrom())) {
|
||||
throw new CouponInvalidException("优惠券尚未生效");
|
||||
}
|
||||
if (coupon.getValidUntil() != null && !now.before(coupon.getValidUntil())) {
|
||||
throw new CouponInvalidException("优惠券已过期");
|
||||
}
|
||||
|
||||
List<PriceCouponClaimRecord> records = couponClaimRecordMapper.selectUserCouponRecords(
|
||||
request.getUserId(), request.getCouponId());
|
||||
|
||||
@@ -234,10 +246,18 @@ public class CouponServiceImpl implements ICouponService {
|
||||
// 查找一张可用的优惠券(状态为CLAIMED)
|
||||
PriceCouponClaimRecord record = records.stream()
|
||||
.filter(r -> r.getStatus() == CouponStatus.CLAIMED)
|
||||
.filter(r -> r.getExpireTime() == null || r.getExpireTime().after(now))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
if (record == null) {
|
||||
boolean hasClaimedButExpired = records.stream()
|
||||
.anyMatch(r -> r.getStatus() == CouponStatus.CLAIMED
|
||||
&& r.getExpireTime() != null
|
||||
&& !r.getExpireTime().after(now));
|
||||
if (hasClaimedButExpired) {
|
||||
throw new CouponInvalidException("优惠券已过期");
|
||||
}
|
||||
// 如果没有可用的,抛出异常。为了错误信息准确,可以检查最后一张的状态
|
||||
CouponStatus lastStatus = records.getFirst().getStatus();
|
||||
throw new CouponInvalidException("优惠券状态无效: " + lastStatus);
|
||||
|
||||
@@ -76,27 +76,39 @@ public class ProductConfigServiceImpl implements IProductConfigService {
|
||||
return getProductConfig(productType, productId);
|
||||
}
|
||||
|
||||
String scenicIdStr = scenicId.toString();
|
||||
|
||||
// 查询优先级:
|
||||
// 1. 景区+商品ID
|
||||
PriceProductConfig config = productConfigMapper.selectByProductTypeIdAndScenic(
|
||||
productType, productId, scenicId.toString());
|
||||
productType, productId, scenicIdStr);
|
||||
if (config != null) {
|
||||
log.debug("使用景区特定商品配置: productType={}, productId={}, scenicId={}",
|
||||
productType, productId, scenicId);
|
||||
return config;
|
||||
}
|
||||
|
||||
// 2. 景区+默认
|
||||
// 2. 景区+景区ID作为商品ID(productId未命中时回退)
|
||||
if (!scenicIdStr.equals(productId)) {
|
||||
config = productConfigMapper.selectByProductTypeIdAndScenic(
|
||||
productType, scenicIdStr, scenicIdStr);
|
||||
if (config != null) {
|
||||
log.debug("使用景区ID作为商品ID的配置: productType={}, scenicId={}", productType, scenicId);
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 景区+默认
|
||||
if (!"default".equals(productId)) {
|
||||
config = productConfigMapper.selectByProductTypeIdAndScenic(
|
||||
productType, "default", scenicId.toString());
|
||||
productType, "default", scenicIdStr);
|
||||
if (config != null) {
|
||||
log.debug("使用景区默认配置: productType={}, scenicId={}", productType, scenicId);
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 全局+商品ID (兜底)
|
||||
// 4. 全局+商品ID (兜底)
|
||||
try {
|
||||
config = productConfigMapper.selectGlobalByProductTypeAndId(productType, productId);
|
||||
if (config != null) {
|
||||
@@ -107,7 +119,7 @@ public class ProductConfigServiceImpl implements IProductConfigService {
|
||||
log.debug("全局商品配置未找到: productType={}, productId={}", productType, productId);
|
||||
}
|
||||
|
||||
// 4. 全局+默认 (最后兜底)
|
||||
// 5. 全局+默认 (最后兜底)
|
||||
config = productConfigMapper.selectGlobalByProductTypeAndId(productType, "default");
|
||||
if (config != null) {
|
||||
log.debug("使用全局默认配置: productType={}", productType);
|
||||
@@ -130,20 +142,33 @@ public class ProductConfigServiceImpl implements IProductConfigService {
|
||||
return getTierConfig(productType, productId, quantity);
|
||||
}
|
||||
|
||||
String scenicIdStr = scenicId.toString();
|
||||
|
||||
// 查询优先级:
|
||||
// 1. 景区+商品ID
|
||||
PriceTierConfig config = tierConfigMapper.selectByProductTypeQuantityAndScenic(
|
||||
productType, productId, quantity, scenicId.toString());
|
||||
productType, productId, quantity, scenicIdStr);
|
||||
if (config != null) {
|
||||
log.debug("使用景区特定阶梯定价: productType={}, productId={}, quantity={}, scenicId={}",
|
||||
productType, productId, quantity, scenicId);
|
||||
return config;
|
||||
}
|
||||
|
||||
// 2. 景区+默认
|
||||
// 2. 景区+景区ID作为商品ID(productId未命中时回退)
|
||||
if (!scenicIdStr.equals(productId)) {
|
||||
config = tierConfigMapper.selectByProductTypeQuantityAndScenic(
|
||||
productType, scenicIdStr, quantity, scenicIdStr);
|
||||
if (config != null) {
|
||||
log.debug("使用景区ID作为商品ID的阶梯定价: productType={}, quantity={}, scenicId={}",
|
||||
productType, quantity, scenicId);
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 景区+默认
|
||||
if (!"default".equals(productId)) {
|
||||
config = tierConfigMapper.selectByProductTypeQuantityAndScenic(
|
||||
productType, "default", quantity, scenicId.toString());
|
||||
productType, "default", quantity, scenicIdStr);
|
||||
if (config != null) {
|
||||
log.debug("使用景区默认阶梯定价: productType={}, quantity={}, scenicId={}",
|
||||
productType, quantity, scenicId);
|
||||
@@ -151,7 +176,7 @@ public class ProductConfigServiceImpl implements IProductConfigService {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 全局+商品ID (兜底)
|
||||
// 4. 全局+商品ID (兜底)
|
||||
config = tierConfigMapper.selectGlobalByProductTypeAndQuantity(productType, productId, quantity);
|
||||
if (config != null) {
|
||||
log.debug("使用全局阶梯定价: productType={}, productId={}, quantity={}",
|
||||
@@ -159,7 +184,7 @@ public class ProductConfigServiceImpl implements IProductConfigService {
|
||||
return config;
|
||||
}
|
||||
|
||||
// 4. 全局+默认 (最后兜底)
|
||||
// 5. 全局+默认 (最后兜底)
|
||||
config = tierConfigMapper.selectGlobalByProductTypeAndQuantity(productType, "default", quantity);
|
||||
if (config != null) {
|
||||
log.debug("使用全局默认阶梯定价: productType={}, quantity={}", productType, quantity);
|
||||
|
||||
@@ -235,6 +235,28 @@ public class SourceRepository {
|
||||
faceStatusManager.invalidatePuzzleSourceVersion(faceId);
|
||||
}
|
||||
|
||||
public void setUserIsBuyItemBySourceId(Long memberId, Long sourceId, Long faceId, Long orderId) {
|
||||
MemberSourceEntity memberSource = new MemberSourceEntity();
|
||||
memberSource.setMemberId(memberId);
|
||||
memberSource.setSourceId(sourceId);
|
||||
memberSource.setOrderId(orderId);
|
||||
memberSource.setIsBuy(1);
|
||||
sourceMapper.updateRelationBySourceId(memberSource);
|
||||
memberRelationRepository.clearSCacheByFace(faceId);
|
||||
faceStatusManager.invalidatePuzzleSourceVersion(faceId);
|
||||
}
|
||||
|
||||
public void setUserNotBuyItemBySourceId(Long memberId, Long sourceId, Long faceId) {
|
||||
MemberSourceEntity memberSource = new MemberSourceEntity();
|
||||
memberSource.setMemberId(memberId);
|
||||
memberSource.setSourceId(sourceId);
|
||||
memberSource.setOrderId(null);
|
||||
memberSource.setIsBuy(0);
|
||||
sourceMapper.updateRelationBySourceId(memberSource);
|
||||
memberRelationRepository.clearSCacheByFace(faceId);
|
||||
faceStatusManager.invalidatePuzzleSourceVersion(faceId);
|
||||
}
|
||||
|
||||
public SourceEntity getSource(Long id) {
|
||||
return sourceMapper.getEntity(id);
|
||||
}
|
||||
|
||||
@@ -930,6 +930,7 @@ public class OrderServiceImpl implements OrderService {
|
||||
Integer type = switch (productItem.getProductType()) {
|
||||
case PHOTO_LOG -> 5;
|
||||
case PHOTO_SET -> 2;
|
||||
case PHOTO -> 14;
|
||||
case VLOG_VIDEO -> 0;
|
||||
case RECORDING_SET -> 1;
|
||||
case AI_CAM_PHOTO_SET -> 13;
|
||||
@@ -937,6 +938,7 @@ public class OrderServiceImpl implements OrderService {
|
||||
};
|
||||
Long goodsId = switch (productItem.getProductType()) {
|
||||
case PHOTO_LOG -> Long.valueOf(productItem.getProductId());
|
||||
case PHOTO -> Long.valueOf(productItem.getProductId());
|
||||
case PHOTO_SET, RECORDING_SET -> face.getId();
|
||||
case AI_CAM_PHOTO_SET -> face.getId();
|
||||
case VLOG_VIDEO -> {
|
||||
|
||||
@@ -595,4 +595,15 @@
|
||||
GROUP BY FLOOR(UNIX_TIMESTAMP(create_time) / 300)
|
||||
ORDER BY time
|
||||
</select>
|
||||
<select id="getMemberSourceByMemberAndSourceId" resultType="com.ycwl.basic.model.pc.source.entity.MemberSourceEntity">
|
||||
SELECT * FROM member_source WHERE member_id = #{memberId} AND source_id = #{sourceId} AND deleted = 0 LIMIT 1
|
||||
</select>
|
||||
<update id="updateRelationBySourceId">
|
||||
update member_source
|
||||
<set>
|
||||
<if test="isBuy!=null">is_buy = #{isBuy}, </if>
|
||||
<if test="orderId!=null">order_id = #{orderId}, </if>
|
||||
</set>
|
||||
where member_id = #{memberId} and source_id = #{sourceId}
|
||||
</update>
|
||||
</mapper>
|
||||
|
||||
@@ -3,8 +3,11 @@ package com.ycwl.basic.pricing.service;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ycwl.basic.pricing.dto.CouponClaimRequest;
|
||||
import com.ycwl.basic.pricing.dto.CouponClaimResult;
|
||||
import com.ycwl.basic.pricing.dto.CouponUseRequest;
|
||||
import com.ycwl.basic.pricing.entity.PriceCouponClaimRecord;
|
||||
import com.ycwl.basic.pricing.entity.PriceCouponConfig;
|
||||
import com.ycwl.basic.pricing.enums.CouponStatus;
|
||||
import com.ycwl.basic.pricing.exception.CouponInvalidException;
|
||||
import com.ycwl.basic.pricing.mapper.PriceCouponClaimRecordMapper;
|
||||
import com.ycwl.basic.pricing.mapper.PriceCouponConfigMapper;
|
||||
import com.ycwl.basic.pricing.service.impl.CouponServiceImpl;
|
||||
@@ -96,6 +99,61 @@ class CouponServiceImplTest {
|
||||
assertEquals(CouponClaimResult.ERROR_COUPON_OUT_OF_STOCK, result.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailToUseCouponWhenClaimRecordExpiredByClaimWindow() {
|
||||
PriceCouponConfig coupon = baseCoupon();
|
||||
when(couponConfigMapper.selectById(1L)).thenReturn(coupon);
|
||||
|
||||
PriceCouponClaimRecord expiredRecord = new PriceCouponClaimRecord();
|
||||
expiredRecord.setId(99L);
|
||||
expiredRecord.setStatus(CouponStatus.CLAIMED);
|
||||
expiredRecord.setExpireTime(new Date(System.currentTimeMillis() - 1_000L));
|
||||
when(couponClaimRecordMapper.selectUserCouponRecords(10L, 1L)).thenReturn(java.util.List.of(expiredRecord));
|
||||
|
||||
CouponUseRequest request = buildUseRequest();
|
||||
CouponInvalidException exception = assertThrows(CouponInvalidException.class, () -> couponService.useCoupon(request));
|
||||
|
||||
assertEquals("优惠券已过期", exception.getMessage());
|
||||
verify(couponConfigMapper, never()).incrementUsedQuantity(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailToUseCouponWhenCouponGlobalValidityExpired() {
|
||||
PriceCouponConfig coupon = baseCoupon();
|
||||
coupon.setValidUntil(new Date(System.currentTimeMillis() - 1_000L));
|
||||
when(couponConfigMapper.selectById(1L)).thenReturn(coupon);
|
||||
|
||||
CouponUseRequest request = buildUseRequest();
|
||||
CouponInvalidException exception = assertThrows(CouponInvalidException.class, () -> couponService.useCoupon(request));
|
||||
|
||||
assertEquals("优惠券已过期", exception.getMessage());
|
||||
verify(couponConfigMapper, never()).incrementUsedQuantity(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUseCouponWhenClaimRecordAndGlobalWindowAreValid() {
|
||||
PriceCouponConfig coupon = baseCoupon();
|
||||
when(couponConfigMapper.selectById(1L)).thenReturn(coupon);
|
||||
when(couponConfigMapper.incrementUsedQuantity(1L)).thenReturn(1);
|
||||
|
||||
PriceCouponClaimRecord validClaimRecord = new PriceCouponClaimRecord();
|
||||
validClaimRecord.setId(99L);
|
||||
validClaimRecord.setStatus(CouponStatus.CLAIMED);
|
||||
validClaimRecord.setExpireTime(new Date(System.currentTimeMillis() + 10_000L));
|
||||
when(couponClaimRecordMapper.selectUserCouponRecords(10L, 1L)).thenReturn(java.util.List.of(validClaimRecord));
|
||||
|
||||
CouponUseRequest request = buildUseRequest();
|
||||
couponService.useCoupon(request);
|
||||
|
||||
verify(couponConfigMapper).incrementUsedQuantity(1L);
|
||||
verify(couponClaimRecordMapper).updateCouponStatus(
|
||||
eq(99L),
|
||||
eq(CouponStatus.USED),
|
||||
any(Date.class),
|
||||
eq("ORDER-1"),
|
||||
eq("SCENIC-1"));
|
||||
}
|
||||
|
||||
private CouponClaimRequest buildRequest() {
|
||||
CouponClaimRequest request = new CouponClaimRequest();
|
||||
request.setUserId(10L);
|
||||
@@ -104,6 +162,15 @@ class CouponServiceImplTest {
|
||||
return request;
|
||||
}
|
||||
|
||||
private CouponUseRequest buildUseRequest() {
|
||||
CouponUseRequest request = new CouponUseRequest();
|
||||
request.setUserId(10L);
|
||||
request.setCouponId(1L);
|
||||
request.setOrderId("ORDER-1");
|
||||
request.setScenicId("SCENIC-1");
|
||||
return request;
|
||||
}
|
||||
|
||||
private PriceCouponConfig baseCoupon() {
|
||||
PriceCouponConfig coupon = new PriceCouponConfig();
|
||||
coupon.setId(1L);
|
||||
|
||||
Reference in New Issue
Block a user