feat(coupon): 添加优惠券领取后有效期配置功能
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good

- 在数据库插入和更新操作中添加 valid_days_after_claim 字段支持
- 在使用优惠券时增加对优惠券全局有效性和领取记录过期时间的验证逻辑
- 添加对已过期领取记录的筛选和错误提示处理
- 新增优惠券使用请求、状态枚举和异常类的测试用例
- 实现优惠券配置的完整有效期验证流程
This commit is contained in:
2026-02-14 14:59:10 +08:00
parent 143185926c
commit 09d142aa98
10 changed files with 196 additions and 13 deletions

View File

@@ -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); // 更新完了,清理下

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 -> {

View File

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

View File

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