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