feat(backend): 补齐 C 端商业化与账号缺口(金币充值/会员/搜索/语言筛选/Google登录/短信/商店可配置接入)

This commit is contained in:
Zhou Weidong committed 2026-10-20 04:20:42 +08:00
1 parent 212834d59d
commit 63b086998e
25 files changed
+859 -29

No files matched your search

@@ -37,6 +37,7 @@ public class EntitlementService implements EntitlementPort {
public static final String TYPE_EPISODE = "EPISODE";
public static final String TYPE_DRAMA = "DRAMA";
public static final String TYPE_VIP = "VIP";
private final EntitlementMapper entitlementMapper;
@@ -46,6 +47,15 @@ public class EntitlementService implements EntitlementPort {
return false;
}
LocalDateTime now = LocalDateTime.now();
Long vipCount = entitlementMapper.selectCount(Wrappers.<Entitlement>lambdaQuery()
.eq(Entitlement::getUserId, userId)
.eq(Entitlement::getStatus, STATUS_ACTIVE)
.eq(Entitlement::getType, TYPE_VIP)
.and(w -> w.isNull(Entitlement::getExpireAt)
.or().gt(Entitlement::getExpireAt, now)));
if (vipCount != null && vipCount > 0) {
return true;
}
Long count = entitlementMapper.selectCount(Wrappers.<Entitlement>lambdaQuery()
.eq(Entitlement::getUserId, userId)
.eq(Entitlement::getStatus, STATUS_ACTIVE)
@@ -93,6 +93,49 @@ public class OrderService {
.orderByDesc(OrderEvent::getId));
}
/**
* 客户端创建充值订单:按商品配置生成一笔 CREATED 订单,等待后续支付。
*
* <p>商品必须处于上架状态;金币数量按 coinAmount + bonusCoin 计算,
* 与商店票据链路 {@link #buildOrder} 的口径一致,交付时一次性到账。
*/
@Transactional(rollbackFor = Exception.class)
public Orders createOrder(Long userId, Long productId) {
if (userId == null) {
throw new ServiceException(ErrorCode.UNAUTHORIZED);
}
Product product = productService.getExisting(productId);
if (product.getStatus() == null || product.getStatus() != 1) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "商品已下架");
}
LocalDateTime now = LocalDateTime.now();
Orders order = new Orders();
order.setOrderNo(newOrderNo());
order.setUserId(userId);
order.setProductId(product.getId());
order.setProductCode(product.getCode());
order.setProductName(product.getName());
order.setType(StringUtils.hasText(product.getType()) ? product.getType() : ProductService.TYPE_COIN);
order.setPlatform(product.getPlatform());
order.setStoreProductId(product.getStoreProductId());
long coins = (product.getCoinAmount() == null ? 0L : product.getCoinAmount())
+ (product.getBonusCoin() == null ? 0L : product.getBonusCoin());
order.setCoinAmount(coins);
order.setAmount(product.getPriceAmount() == null ? BigDecimal.ZERO : product.getPriceAmount());
order.setCurrency(StringUtils.hasText(product.getCurrency()) ? product.getCurrency() : "IDR");
order.setQuantity(1);
order.setStatus(STATUS_CREATED);
order.setRegionCode("ID");
order.setChannel("IAP");
order.setExperimentId("");
order.setReceipt("");
order.setFailReason("");
order.setRefundReason("");
order.setCreatedAt(now);
ordersMapper.insert(order);
return order;
}
/**
* 客户端提交商店票据:**校验 → 建单 → 发权益**,一步都不能省。
*
@@ -273,6 +316,10 @@ public class OrderService {
order.getOrderNo(), "order:" + order.getOrderNo() + ":coins",
remark + ":" + order.getProductName(), true, operatorId);
}
if (ProductService.TYPE_VIP.equals(order.getType())) {
entitlementService.grant(order.getUserId(), 0L, 0L,
EntitlementService.TYPE_VIP, "ORDER", order.getOrderNo());
}
order.setStatus(STATUS_DELIVERED);
order.setDeliveredAt(LocalDateTime.now());
order.setFailReason("");
@@ -30,7 +30,7 @@ import java.util.List;
* <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>
* <li>**VIP 允许建档、允许启用**(会员商品随金币包一起上架售卖)。</li>
* </ul>
*/
@Service
@@ -64,6 +64,17 @@ public class ProductService {
return ProductRespVO.of(getExisting(id));
}
/** C 端商品列表:只返回上架商品,type 可选过滤(自动归一化为大写)。 */
public List<ProductRespVO> listForApp(String type) {
String normalized = StringUtils.hasText(type) ? type.trim().toUpperCase() : null;
List<Product> records = productMapper.selectList(Wrappers.<Product>lambdaQuery()
.eq(Product::getStatus, 1)
.eq(normalized != null, Product::getType, normalized)
.orderByAsc(Product::getSort)
.orderByDesc(Product::getId));
return records.stream().map(ProductRespVO::of).toList();
}
@Transactional(rollbackFor = Exception.class)
public Long create(ProductSaveReqVO req) {
String code = req.getCode().trim();
@@ -77,7 +88,6 @@ public class ProductService {
applyFields(product, req, platform);
// 新建默认下架:上架是显式动作,避免半配置的商品立刻对客户端可见
product.setStatus(req.getStatus() == null ? 0 : req.getStatus());
requireVipNotEnabled(product);
productMapper.insert(product);
return product.getId();
}
@@ -96,11 +106,10 @@ public class ProductService {
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)) {
@@ -108,7 +117,6 @@ public class ProductService {
}
Product product = getExisting(id);
product.setStatus(status);
requireVipNotEnabled(product);
productMapper.updateById(product);
}
@@ -161,15 +169,6 @@ public class ProductService {
}
}
/** 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)
@@ -198,7 +197,8 @@ public class ProductService {
}
}
private Product getExisting(Long id) {
/** 按 id 取商品,不存在抛业务异常(供订单创建链路复用)。 */
public Product getExisting(Long id) {
Product product = id == null ? null : productMapper.selectById(id);
if (product == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "商品不存在");