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:
jianghaifeng committed 2026-10-16 16:33:15 +08:00
1 parent 7513ac06bc
commit 8d19a0cc0a
24 files changed
+1761 -48

No files matched your search

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