diff --git a/src/main/java/com/ycwl/basic/biz/OrderBiz.java b/src/main/java/com/ycwl/basic/biz/OrderBiz.java index e4f604cd..1ffab8af 100644 --- a/src/main/java/com/ycwl/basic/biz/OrderBiz.java +++ b/src/main/java/com/ycwl/basic/biz/OrderBiz.java @@ -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); // 更新完了,清理下 diff --git a/src/main/java/com/ycwl/basic/controller/mobile/AppOrderV2Controller.java b/src/main/java/com/ycwl/basic/controller/mobile/AppOrderV2Controller.java index 8e3372db..98c824ba 100644 --- a/src/main/java/com/ycwl/basic/controller/mobile/AppOrderV2Controller.java +++ b/src/main/java/com/ycwl/basic/controller/mobile/AppOrderV2Controller.java @@ -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; diff --git a/src/main/java/com/ycwl/basic/mapper/SourceMapper.java b/src/main/java/com/ycwl/basic/mapper/SourceMapper.java index c9210752..5411b9d0 100644 --- a/src/main/java/com/ycwl/basic/mapper/SourceMapper.java +++ b/src/main/java/com/ycwl/basic/mapper/SourceMapper.java @@ -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 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); } diff --git a/src/main/java/com/ycwl/basic/pricing/mapper/PriceCouponConfigMapper.java b/src/main/java/com/ycwl/basic/pricing/mapper/PriceCouponConfigMapper.java index 00de5da6..9f93f44c 100644 --- a/src/main/java/com/ycwl/basic/pricing/mapper/PriceCouponConfigMapper.java +++ b/src/main/java/com/ycwl/basic/pricing/mapper/PriceCouponConfigMapper.java @@ -61,11 +61,11 @@ public interface PriceCouponConfigMapper extends BaseMapper { */ @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 { "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); diff --git a/src/main/java/com/ycwl/basic/pricing/service/impl/CouponServiceImpl.java b/src/main/java/com/ycwl/basic/pricing/service/impl/CouponServiceImpl.java index a5deda7f..cfaf3ca4 100644 --- a/src/main/java/com/ycwl/basic/pricing/service/impl/CouponServiceImpl.java +++ b/src/main/java/com/ycwl/basic/pricing/service/impl/CouponServiceImpl.java @@ -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 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); diff --git a/src/main/java/com/ycwl/basic/pricing/service/impl/ProductConfigServiceImpl.java b/src/main/java/com/ycwl/basic/pricing/service/impl/ProductConfigServiceImpl.java index d0b186c8..c20eb541 100644 --- a/src/main/java/com/ycwl/basic/pricing/service/impl/ProductConfigServiceImpl.java +++ b/src/main/java/com/ycwl/basic/pricing/service/impl/ProductConfigServiceImpl.java @@ -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); diff --git a/src/main/java/com/ycwl/basic/repository/SourceRepository.java b/src/main/java/com/ycwl/basic/repository/SourceRepository.java index 8a0d0698..ca12b867 100644 --- a/src/main/java/com/ycwl/basic/repository/SourceRepository.java +++ b/src/main/java/com/ycwl/basic/repository/SourceRepository.java @@ -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); } diff --git a/src/main/java/com/ycwl/basic/service/pc/impl/OrderServiceImpl.java b/src/main/java/com/ycwl/basic/service/pc/impl/OrderServiceImpl.java index 40e60b8f..144697ea 100644 --- a/src/main/java/com/ycwl/basic/service/pc/impl/OrderServiceImpl.java +++ b/src/main/java/com/ycwl/basic/service/pc/impl/OrderServiceImpl.java @@ -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 -> { diff --git a/src/main/resources/mapper/SourceMapper.xml b/src/main/resources/mapper/SourceMapper.xml index 5a46eab5..572f9e0f 100644 --- a/src/main/resources/mapper/SourceMapper.xml +++ b/src/main/resources/mapper/SourceMapper.xml @@ -595,4 +595,15 @@ GROUP BY FLOOR(UNIX_TIMESTAMP(create_time) / 300) ORDER BY time + + + update member_source + + is_buy = #{isBuy}, + order_id = #{orderId}, + + where member_id = #{memberId} and source_id = #{sourceId} + diff --git a/src/test/java/com/ycwl/basic/pricing/service/CouponServiceImplTest.java b/src/test/java/com/ycwl/basic/pricing/service/CouponServiceImplTest.java index 1acd9f70..8044bb1f 100644 --- a/src/test/java/com/ycwl/basic/pricing/service/CouponServiceImplTest.java +++ b/src/test/java/com/ycwl/basic/pricing/service/CouponServiceImplTest.java @@ -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);