feat(admin): 商品配置与系统公告管理接口,id 以字符串下发
- /admin/products 六个接口 + 校验(code 唯一、store_product_id 同平台唯一、 COIN_PACK 需 coin_amount>0、VIP 需 vip_days>0、VIP 只建不启) - /admin/notices 六个接口,与 C 端 /app/notices 共用 OpsNoticeService.pageForApp 一套筛选口径 - 权限点 product:list/product:update/notice:list/notice:update + 菜单 31/32/33(sql/data-phase1.sql 幂等) - 雪花 id 超过 JS 安全整数范围:id 以字符串下发,避免浏览器四舍五入导致回查 200002 - C 端商品查询收敛到 ProductService.findForStore,OrderService 不再自带一份 - 测试:module-commerce 33 / admin-api 16 / app-api 49 / worker 12,共 110 条全过
This commit is contained in:
1 parent
7513ac06bc
commit
8d19a0cc0a
24 files changed
+1761
-48
No files matched your search
+2
-15
@@ -11,7 +11,6 @@ import com.fruitshorts.commerce.dal.entity.Product;
|
||||
import com.fruitshorts.commerce.dal.entity.StoreReceipt;
|
||||
import com.fruitshorts.commerce.dal.mapper.OrderEventMapper;
|
||||
import com.fruitshorts.commerce.dal.mapper.OrdersMapper;
|
||||
import com.fruitshorts.commerce.dal.mapper.ProductMapper;
|
||||
import com.fruitshorts.commerce.dal.mapper.StoreReceiptMapper;
|
||||
import com.fruitshorts.commerce.service.store.StorePlatforms;
|
||||
import com.fruitshorts.commerce.vo.OrderQueryReqVO;
|
||||
@@ -54,7 +53,7 @@ public class OrderService {
|
||||
|
||||
private final OrdersMapper ordersMapper;
|
||||
private final OrderEventMapper orderEventMapper;
|
||||
private final ProductMapper productMapper;
|
||||
private final ProductService productService;
|
||||
private final StoreReceiptMapper storeReceiptMapper;
|
||||
private final StoreVerifier storeVerifier;
|
||||
private final WalletService walletService;
|
||||
@@ -130,7 +129,7 @@ public class OrderService {
|
||||
return deliver(existing, "STORE", null, "票据重复提交,返回既有订单");
|
||||
}
|
||||
|
||||
Product product = findProduct(store, result.productId());
|
||||
Product product = productService.findForStore(store, result.productId());
|
||||
if (product == null) {
|
||||
throw new ServiceException(ErrorCode.DATA_NOT_FOUND,
|
||||
"该商品未在后台配置,请联系客服:" + result.productId());
|
||||
@@ -180,18 +179,6 @@ public class OrderService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 商品按 `(平台, 商店商品 id)` 找;`platform=ALL` 的通用商品也认。 */
|
||||
private Product findProduct(String platform, String storeProductId) {
|
||||
if (!StringUtils.hasText(storeProductId)) {
|
||||
return null;
|
||||
}
|
||||
List<Product> products = productMapper.selectList(Wrappers.<Product>lambdaQuery()
|
||||
.eq(Product::getStoreProductId, storeProductId)
|
||||
.in(Product::getPlatform, List.of(platform, "ALL"))
|
||||
.eq(Product::getStatus, 1));
|
||||
return products.isEmpty() ? null : products.get(0);
|
||||
}
|
||||
|
||||
/** 从校验结果建单:状态先记 PAID(商店已收款),交付由 deliver 推进到 DELIVERED。 */
|
||||
private Orders buildOrder(Long userId, String platform, Product product,
|
||||
StoreVerifier.StoreReceiptResult result, String receipt) {
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.commerce.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.fruitshorts.commerce.dal.entity.Product;
|
||||
import com.fruitshorts.commerce.dal.mapper.ProductMapper;
|
||||
import com.fruitshorts.commerce.vo.ProductQueryReqVO;
|
||||
import com.fruitshorts.commerce.vo.ProductRespVO;
|
||||
import com.fruitshorts.commerce.vo.ProductSaveReqVO;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.common.pojo.PageResult;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 商品(金币包)配置:后台 CRUD。
|
||||
*
|
||||
* <p>几条硬校验(对不上会直接影响发币口径,所以放在服务端而不是只靠前端):
|
||||
* <ul>
|
||||
* <li>`code` 全局唯一;</li>
|
||||
* <li>`store_product_id` 在**同一 `platform` 下唯一** —— 否则同一商店商品被配置成两个商品,
|
||||
* 客户端 verify 时"按 storeProductId 找到第一个"会随机发其中一个的金币;</li>
|
||||
* <li>`type=COIN_PACK` 要求 `coin_amount > 0`;`type=VIP` 要求 `vip_days > 0`;</li>
|
||||
* <li>**VIP 允许建档、不允许启用**(与 B1-1「VIP 订阅本轮不做」一致)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ProductService {
|
||||
|
||||
public static final String TYPE_COIN = "COIN_PACK";
|
||||
public static final String TYPE_VIP = "VIP";
|
||||
public static final String PLATFORM_ALL = "ALL";
|
||||
|
||||
private static final List<String> PLATFORMS = List.of(PLATFORM_ALL, "GOOGLE", "APPLE");
|
||||
|
||||
private final ProductMapper productMapper;
|
||||
|
||||
public PageResult<ProductRespVO> page(ProductQueryReqVO req) {
|
||||
Page<Product> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
|
||||
IPage<Product> result = productMapper.selectPage(page, Wrappers.<Product>lambdaQuery()
|
||||
.eq(StringUtils.hasText(req.getType()), Product::getType, req.getType())
|
||||
.eq(req.getStatus() != null, Product::getStatus, req.getStatus())
|
||||
.eq(StringUtils.hasText(req.getPlatform()), Product::getPlatform, req.getPlatform())
|
||||
.and(StringUtils.hasText(req.getKeyword()), w -> w
|
||||
.like(Product::getCode, req.getKeyword())
|
||||
.or().like(Product::getName, req.getKeyword()))
|
||||
.orderByAsc(Product::getSort)
|
||||
.orderByDesc(Product::getId));
|
||||
return new PageResult<>(result.getTotal(),
|
||||
result.getRecords().stream().map(ProductRespVO::of).toList());
|
||||
}
|
||||
|
||||
public ProductRespVO detail(Long id) {
|
||||
return ProductRespVO.of(getExisting(id));
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long create(ProductSaveReqVO req) {
|
||||
String code = req.getCode().trim();
|
||||
String platform = normalizePlatform(req.getPlatform());
|
||||
validateShape(req);
|
||||
requireCodeFree(code, null);
|
||||
requireStoreProductFree(platform, req.getStoreProductId(), null);
|
||||
|
||||
Product product = new Product();
|
||||
product.setCode(code);
|
||||
applyFields(product, req, platform);
|
||||
// 新建默认下架:上架是显式动作,避免半配置的商品立刻对客户端可见
|
||||
product.setStatus(req.getStatus() == null ? 0 : req.getStatus());
|
||||
requireVipNotEnabled(product);
|
||||
productMapper.insert(product);
|
||||
return product.getId();
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void update(Long id, ProductSaveReqVO req) {
|
||||
Product product = getExisting(id);
|
||||
String code = req.getCode().trim();
|
||||
String platform = normalizePlatform(req.getPlatform());
|
||||
validateShape(req);
|
||||
requireCodeFree(code, id);
|
||||
requireStoreProductFree(platform, req.getStoreProductId(), id);
|
||||
|
||||
product.setCode(code);
|
||||
applyFields(product, req, platform);
|
||||
if (req.getStatus() != null) {
|
||||
product.setStatus(req.getStatus());
|
||||
}
|
||||
requireVipNotEnabled(product);
|
||||
productMapper.updateById(product);
|
||||
}
|
||||
|
||||
/** 上架 / 下架;VIP 商品只允许下架(第二阶段订阅上线后才放开)。 */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateStatus(Long id, Integer status) {
|
||||
if (status == null || (status != 0 && status != 1)) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "状态只能是 0(下架)或 1(上架)");
|
||||
}
|
||||
Product product = getExisting(id);
|
||||
product.setStatus(status);
|
||||
requireVipNotEnabled(product);
|
||||
productMapper.updateById(product);
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void delete(Long id) {
|
||||
getExisting(id);
|
||||
productMapper.deleteById(id);
|
||||
}
|
||||
|
||||
/** 供 C 端校验链路复用(按 store_product_id + 平台找商品)。 */
|
||||
public Product findForStore(String platform, String storeProductId) {
|
||||
if (!StringUtils.hasText(storeProductId)) {
|
||||
return null;
|
||||
}
|
||||
List<Product> products = productMapper.selectList(Wrappers.<Product>lambdaQuery()
|
||||
.eq(Product::getStoreProductId, storeProductId)
|
||||
.in(Product::getPlatform, List.of(normalizePlatform(platform), PLATFORM_ALL))
|
||||
.eq(Product::getStatus, 1));
|
||||
return products.isEmpty() ? null : products.get(0);
|
||||
}
|
||||
|
||||
private void applyFields(Product product, ProductSaveReqVO req, String platform) {
|
||||
product.setName(req.getName().trim());
|
||||
product.setType(normalizeType(req.getType()));
|
||||
product.setPlatform(platform);
|
||||
product.setStoreProductId(req.getStoreProductId() == null ? "" : req.getStoreProductId().trim());
|
||||
product.setCoinAmount(req.getCoinAmount() == null ? 0L : req.getCoinAmount());
|
||||
product.setBonusCoin(req.getBonusCoin() == null ? 0L : req.getBonusCoin());
|
||||
product.setPriceAmount(req.getPriceAmount() == null ? BigDecimal.ZERO : req.getPriceAmount());
|
||||
product.setCurrency(StringUtils.hasText(req.getCurrency()) ? req.getCurrency() : "IDR");
|
||||
product.setDisplayPrice(req.getDisplayPrice() == null ? "" : req.getDisplayPrice());
|
||||
product.setRegionCodes(req.getRegionCodes() == null ? "" : req.getRegionCodes());
|
||||
product.setVipDays(req.getVipDays() == null ? 0 : req.getVipDays());
|
||||
product.setSort(req.getSort() == null ? 0 : req.getSort());
|
||||
product.setEffectiveAt(req.getEffectiveAt());
|
||||
product.setExpireAt(req.getExpireAt());
|
||||
}
|
||||
|
||||
/** type 与金额/天数的匹配校验。 */
|
||||
private void validateShape(ProductSaveReqVO req) {
|
||||
String type = normalizeType(req.getType());
|
||||
if (TYPE_VIP.equals(type)) {
|
||||
if (req.getVipDays() == null || req.getVipDays() <= 0) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "会员商品的会员天数必须大于 0");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (req.getCoinAmount() == null || req.getCoinAmount() <= 0) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "金币包的金币数量必须大于 0");
|
||||
}
|
||||
}
|
||||
|
||||
/** VIP 商品在订阅功能上线前不能启用(允许建档)。 */
|
||||
private void requireVipNotEnabled(Product product) {
|
||||
boolean enabled = product.getStatus() != null && product.getStatus() == 1;
|
||||
if (enabled && TYPE_VIP.equals(product.getType())) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST,
|
||||
"会员商品在订阅功能上线前不能启用(可先建档,上线后再上下架)");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireCodeFree(String code, Long excludeId) {
|
||||
Long count = productMapper.selectCount(Wrappers.<Product>lambdaQuery()
|
||||
.eq(Product::getCode, code)
|
||||
.ne(excludeId != null, Product::getId, excludeId));
|
||||
if (count != null && count > 0) {
|
||||
throw new ServiceException(ErrorCode.DATA_EXISTS, "商品编码已存在:" + code);
|
||||
}
|
||||
}
|
||||
|
||||
/** 同一平台下 store_product_id 必须唯一(ALL 视作"所有平台的通用商品",一并纳入比较)。 */
|
||||
private void requireStoreProductFree(String platform, String storeProductId, Long excludeId) {
|
||||
if (!StringUtils.hasText(storeProductId)) {
|
||||
return;
|
||||
}
|
||||
String id = storeProductId.trim();
|
||||
List<String> scope = PLATFORM_ALL.equals(platform)
|
||||
? PLATFORMS
|
||||
: List.of(platform, PLATFORM_ALL);
|
||||
Long count = productMapper.selectCount(Wrappers.<Product>lambdaQuery()
|
||||
.eq(Product::getStoreProductId, id)
|
||||
.in(Product::getPlatform, scope)
|
||||
.ne(excludeId != null, Product::getId, excludeId));
|
||||
if (count != null && count > 0) {
|
||||
throw new ServiceException(ErrorCode.DATA_EXISTS,
|
||||
"该平台下商店商品 id 已被占用:" + id);
|
||||
}
|
||||
}
|
||||
|
||||
private Product getExisting(Long id) {
|
||||
Product product = id == null ? null : productMapper.selectById(id);
|
||||
if (product == null) {
|
||||
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "商品不存在");
|
||||
}
|
||||
return product;
|
||||
}
|
||||
|
||||
private String normalizeType(String type) {
|
||||
return StringUtils.hasText(type) ? type.trim().toUpperCase() : TYPE_COIN;
|
||||
}
|
||||
|
||||
private String normalizePlatform(String platform) {
|
||||
if (!StringUtils.hasText(platform)) {
|
||||
return PLATFORM_ALL;
|
||||
}
|
||||
String value = platform.trim().toUpperCase();
|
||||
if (!PLATFORMS.contains(value)) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "平台取值只能是 ALL / GOOGLE / APPLE");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.commerce.vo;
|
||||
|
||||
import com.fruitshorts.common.pojo.PageParam;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/** 后台商品检索条件。 */
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class ProductQueryReqVO extends PageParam {
|
||||
|
||||
/** COIN_PACK / VIP。 */
|
||||
private String type;
|
||||
/** 1 上架 / 0 下架。 */
|
||||
private Integer status;
|
||||
/** ALL / GOOGLE / APPLE。 */
|
||||
private String platform;
|
||||
/** 模糊匹配 code 或 name。 */
|
||||
private String keyword;
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.commerce.vo;
|
||||
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
||||
import com.fruitshorts.commerce.dal.entity.Product;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 后台商品响应:实体字段 + **中文文案**(`statusText` / `typeText`)。
|
||||
*
|
||||
* <p>文案由服务端给:前端不再写死中文,避免"同一状态两种叫法"。
|
||||
*/
|
||||
@Data
|
||||
public class ProductRespVO {
|
||||
|
||||
/**
|
||||
* 雪花 id 超过 JS 的安全整数范围(2^53-1),直接下发数字会被浏览器四舍五入 ——
|
||||
* 后台页面再拿这个 id 回查就会"商品不存在"。所以 id 一律以**字符串**下发,
|
||||
* 前端原样拼进 URL 即可(详见 api-phase1.md §2.11)。
|
||||
*/
|
||||
@JsonSerialize(using = ToStringSerializer.class)
|
||||
private Long id;
|
||||
private String code;
|
||||
private String name;
|
||||
private String type;
|
||||
private String typeText;
|
||||
private String platform;
|
||||
private String storeProductId;
|
||||
private Long coinAmount;
|
||||
private Long bonusCoin;
|
||||
/** 实际到账金币 = coinAmount + bonusCoin(展示用,避免前端各算一遍)。 */
|
||||
private Long totalCoin;
|
||||
private BigDecimal priceAmount;
|
||||
private String currency;
|
||||
private String displayPrice;
|
||||
private String regionCodes;
|
||||
private Integer vipDays;
|
||||
private Integer sort;
|
||||
private Integer status;
|
||||
private String statusText;
|
||||
private LocalDateTime effectiveAt;
|
||||
private LocalDateTime expireAt;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
public static ProductRespVO of(Product product) {
|
||||
ProductRespVO vo = new ProductRespVO();
|
||||
vo.setId(product.getId());
|
||||
vo.setCode(product.getCode());
|
||||
vo.setName(product.getName());
|
||||
vo.setType(product.getType());
|
||||
vo.setTypeText(typeText(product.getType()));
|
||||
vo.setPlatform(product.getPlatform());
|
||||
vo.setStoreProductId(product.getStoreProductId());
|
||||
vo.setCoinAmount(product.getCoinAmount());
|
||||
vo.setBonusCoin(product.getBonusCoin());
|
||||
long coins = (product.getCoinAmount() == null ? 0L : product.getCoinAmount())
|
||||
+ (product.getBonusCoin() == null ? 0L : product.getBonusCoin());
|
||||
vo.setTotalCoin(coins);
|
||||
vo.setPriceAmount(product.getPriceAmount());
|
||||
vo.setCurrency(product.getCurrency());
|
||||
vo.setDisplayPrice(product.getDisplayPrice());
|
||||
vo.setRegionCodes(product.getRegionCodes());
|
||||
vo.setVipDays(product.getVipDays());
|
||||
vo.setSort(product.getSort());
|
||||
vo.setStatus(product.getStatus());
|
||||
vo.setStatusText(isEnabled(product.getStatus()) ? "已上架" : "已下架");
|
||||
vo.setEffectiveAt(product.getEffectiveAt());
|
||||
vo.setExpireAt(product.getExpireAt());
|
||||
vo.setCreatedAt(product.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private static boolean isEnabled(Integer status) {
|
||||
return status != null && status == 1;
|
||||
}
|
||||
|
||||
private static String typeText(String type) {
|
||||
if (type == null) {
|
||||
return "-";
|
||||
}
|
||||
return switch (type) {
|
||||
case "COIN_PACK" -> "金币包";
|
||||
case "VIP" -> "会员(第二阶段)";
|
||||
default -> type;
|
||||
};
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.commerce.vo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 后台商品新增/编辑。
|
||||
*
|
||||
* <p>字段与 `product` 表一致(不改表结构);业务校验在 `ProductService` 里做
|
||||
* (code 唯一、store_product_id 同平台唯一、type 与金额/天数匹配、VIP 不允许启用)。
|
||||
*/
|
||||
@Data
|
||||
public class ProductSaveReqVO {
|
||||
|
||||
@NotBlank(message = "请填写商品编码")
|
||||
@Size(max = 64, message = "商品编码过长")
|
||||
private String code;
|
||||
|
||||
@NotBlank(message = "请填写商品名称")
|
||||
@Size(max = 128, message = "商品名称过长")
|
||||
private String name;
|
||||
|
||||
/** COIN_PACK(默认)/ VIP。 */
|
||||
private String type;
|
||||
|
||||
/** ALL(默认)/ GOOGLE / APPLE。 */
|
||||
private String platform;
|
||||
|
||||
@Size(max = 128, message = "商店商品 id 过长")
|
||||
private String storeProductId;
|
||||
|
||||
private Long coinAmount;
|
||||
private Long bonusCoin;
|
||||
private BigDecimal priceAmount;
|
||||
private String currency;
|
||||
private String displayPrice;
|
||||
private String regionCodes;
|
||||
private Integer vipDays;
|
||||
private Integer sort;
|
||||
private Integer status;
|
||||
private LocalDateTime effectiveAt;
|
||||
private LocalDateTime expireAt;
|
||||
}
|
||||
+3
-3
@@ -46,9 +46,9 @@ class OrderServiceRefundTest {
|
||||
orderEventMapper = mock(OrderEventMapper.class);
|
||||
walletService = mock(WalletService.class);
|
||||
entitlementService = mock(EntitlementService.class);
|
||||
orderService = new OrderService(ordersMapper, orderEventMapper, mock(ProductMapper.class),
|
||||
mock(StoreReceiptMapper.class), mock(StoreVerifier.class), walletService,
|
||||
entitlementService);
|
||||
orderService = new OrderService(ordersMapper, orderEventMapper,
|
||||
new ProductService(mock(ProductMapper.class)), mock(StoreReceiptMapper.class),
|
||||
mock(StoreVerifier.class), walletService, entitlementService);
|
||||
given(entitlementService.revokeByOrderNo(anyString(), any())).willReturn(1);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ class OrderServiceVerifyTest {
|
||||
verifier = new StubStoreVerifier();
|
||||
walletService = mock(WalletService.class);
|
||||
entitlementService = mock(EntitlementService.class);
|
||||
orderService = new OrderService(ordersMapper, orderEventMapper, productMapper,
|
||||
orderService = new OrderService(ordersMapper, orderEventMapper, new ProductService(productMapper),
|
||||
storeReceiptMapper, verifier, walletService, entitlementService);
|
||||
}
|
||||
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.commerce.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.fruitshorts.commerce.dal.entity.Product;
|
||||
import com.fruitshorts.commerce.dal.mapper.ProductMapper;
|
||||
import com.fruitshorts.commerce.vo.ProductSaveReqVO;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* 商品配置的业务校验。
|
||||
*
|
||||
* <p>这几条直接决定 C 端发币口径,必须在服务端拦住(前端校验只是体验):
|
||||
* `code` 唯一、`store_product_id` 同平台唯一、type 与金额/天数匹配、VIP 不允许启用。
|
||||
*/
|
||||
class ProductServiceTest {
|
||||
|
||||
private ProductMapper productMapper;
|
||||
private ProductService productService;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfoCache() {
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new MybatisConfiguration(), ""), Product.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
productMapper = mock(ProductMapper.class);
|
||||
productService = new ProductService(productMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_when_code_already_exists() {
|
||||
given(productMapper.selectCount(any())).willReturn(1L);
|
||||
|
||||
assertThatThrownBy(() -> productService.create(coinPack("coin_100", "coin_100")))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.DATA_EXISTS.getCode()))
|
||||
.hasMessageContaining("商品编码已存在");
|
||||
verify(productMapper, never()).insert(any(Product.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_when_store_product_id_is_taken_on_the_same_platform() {
|
||||
// 第一次 selectCount 是 code 检查(通过),第二次是 store_product_id 检查(命中)
|
||||
given(productMapper.selectCount(any())).willReturn(0L, 1L);
|
||||
|
||||
assertThatThrownBy(() -> productService.create(coinPack("coin_550_new", "coin_550")))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.DATA_EXISTS.getCode()))
|
||||
.hasMessageContaining("商店商品 id 已被占用");
|
||||
verify(productMapper, never()).insert(any(Product.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_when_coin_pack_has_no_coins() {
|
||||
ProductSaveReqVO req = coinPack("coin_zero", "coin_zero");
|
||||
req.setCoinAmount(0L);
|
||||
|
||||
assertThatThrownBy(() -> productService.create(req))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.satisfies(ex -> assertThat(((ServiceException) ex).getCode())
|
||||
.isEqualTo(ErrorCode.BAD_REQUEST.getCode()))
|
||||
.hasMessageContaining("金币数量必须大于 0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_when_vip_has_no_days() {
|
||||
ProductSaveReqVO req = coinPack("vip_month", "vip_month");
|
||||
req.setType("VIP");
|
||||
req.setVipDays(0);
|
||||
|
||||
assertThatThrownBy(() -> productService.create(req))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.hasMessageContaining("会员天数必须大于 0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_allow_creating_vip_but_reject_enabling_it() {
|
||||
given(productMapper.selectCount(any())).willReturn(0L);
|
||||
AtomicReference<Product> saved = captureInsert();
|
||||
ProductSaveReqVO req = coinPack("vip_year", "vip_year");
|
||||
req.setType("VIP");
|
||||
req.setVipDays(365);
|
||||
req.setStatus(0);
|
||||
|
||||
// 允许建档(下架状态)
|
||||
productService.create(req);
|
||||
assertThat(saved.get().getType()).isEqualTo("VIP");
|
||||
assertThat(saved.get().getStatus()).isZero();
|
||||
|
||||
// 但不允许启用
|
||||
Product existing = saved.get();
|
||||
given(productMapper.selectById(any())).willReturn(existing);
|
||||
assertThatThrownBy(() -> productService.updateStatus(existing.getId(), 1))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.hasMessageContaining("会员商品在订阅功能上线前不能启用");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_create_coin_pack_as_offline_when_status_is_missing() {
|
||||
given(productMapper.selectCount(any())).willReturn(0L);
|
||||
AtomicReference<Product> saved = captureInsert();
|
||||
|
||||
productService.create(coinPack("coin_999", "coin_999"));
|
||||
|
||||
// 新建默认下架:上架是显式动作,避免半配置商品立刻对客户端可见
|
||||
assertThat(saved.get().getStatus()).isZero();
|
||||
assertThat(saved.get().getCoinAmount()).isEqualTo(500L);
|
||||
}
|
||||
|
||||
private AtomicReference<Product> captureInsert() {
|
||||
AtomicReference<Product> saved = new AtomicReference<>();
|
||||
given(productMapper.insert(any(Product.class))).willAnswer(invocation -> {
|
||||
Product product = invocation.getArgument(0);
|
||||
product.setId(9L);
|
||||
saved.set(product);
|
||||
return 1;
|
||||
});
|
||||
return saved;
|
||||
}
|
||||
|
||||
private ProductSaveReqVO coinPack(String code, String storeProductId) {
|
||||
ProductSaveReqVO req = new ProductSaveReqVO();
|
||||
req.setCode(code);
|
||||
req.setName("500 金币");
|
||||
req.setType("COIN_PACK");
|
||||
req.setPlatform("GOOGLE");
|
||||
req.setStoreProductId(storeProductId);
|
||||
req.setCoinAmount(500L);
|
||||
req.setBonusCoin(50L);
|
||||
req.setCurrency("IDR");
|
||||
return req;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user