feat: 08 订单与权益落地(金币解锁 / 权益判定生效 / 补发回收 / 对账)

为什么:
1) 第一阶段要能真正收到钱,而收钱方式是金币解锁分集。本提交把钱包、金币流水、
   权益、订单四样打通,客户端可以解锁并立刻播放,不再只是展示。
2) 权益判定此前只是端口占位(一律视为未解锁)。交易模块用 @Primary 提供实现后,
   播放入口与下载入口的付费判定同时生效 —— 两条链路一行没改。
3) 钱的事必须可追溯、可幂等:所有金币变动都带幂等键并写 coin_ledger,
   余额与流水同事务;订单每次状态流转都写 order_event(月分区),支付争议可回溯。
4) 后台要能兜住线上问题:补发(重复提交幂等)、退款(回收权益 + 扣回金币,
   余额不足时留人工跟进备注)、人工补币(先校验用户存在,避免给不存在的用户建钱包)、
   手动对账(默认每天 04:20 自动跑前一天)。
5) 09 商店支付按约束只做端口预留:StoreVerifier 未接入时明确报"尚未配置",
   宁可不收钱也不产生假订单。
This commit is contained in:
Vicky committed 2026-10-15 12:26:27 +08:00
1 parent af1dc48494
commit 7e730fbbca
37 files changed
+1467 -5

No files matched your search

@@ -0,0 +1,29 @@
package com.fruitshorts.commerce.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** 金币流水。钱包余额的唯一事实来源,每笔变动都要有。 */
@Data
@TableName("coin_ledger")
public class CoinLedger {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
/** 正数入账、负数出账 */
private Long changeAmount;
private Long balanceAfter;
/** RECHARGE/UNLOCK/REFUND/REWARD/ADMIN */
private String bizType;
private String bizId;
/** 幂等键:重复请求直接复用,不重复扣发 */
private String idempotentKey;
private String remark;
private Long operatorId;
private LocalDateTime createdAt;
}
@@ -0,0 +1,41 @@
package com.fruitshorts.commerce.dal.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** 权益:用户对某集(或整剧)的播放权利。播放与下载的付费判定读这里。 */
@Data
@TableName("entitlement")
public class Entitlement {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private Long dramaId;
/** 0 表示整剧权益 */
private Long episodeId;
/** EPISODE/DRAMA/VIP(VIP 为第二阶段预留) */
private String type;
/** ORDER/ACTIVITY/ADMIN/TRIAL */
private String source;
/** ACTIVE/EXPIRED/REVOKED */
private String status;
private String orderNo;
private LocalDateTime startAt;
private LocalDateTime expireAt;
private String revokeReason;
private LocalDateTime createdAt;
@TableField(updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,26 @@
package com.fruitshorts.commerce.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** 订单状态流转流水(月分区),支付争议时的证据链。 */
@Data
@TableName("order_event")
public class OrderEvent {
@TableId(type = IdType.AUTO)
private Long id;
private String orderNo;
private String event;
private String fromStatus;
private String toStatus;
/** SERVER/STORE/ADMIN/COMPENSATE */
private String source;
private String payload;
private Long operatorId;
private LocalDateTime createdAt;
}
@@ -0,0 +1,55 @@
package com.fruitshorts.commerce.dal.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 订单。表名用 orders(order 是 SQL 关键字)。 */
@Data
@TableName("orders")
public class Orders {
@TableId(type = IdType.AUTO)
private Long id;
private String orderNo;
private Long userId;
private Long productId;
private String productCode;
private String productName;
private String type;
/** APPLE/GOOGLE */
private String platform;
private String storeProductId;
private String storeTransactionId;
private String originalTransactionId;
private BigDecimal amount;
private String currency;
private Long coinAmount;
/** CREATED/PAID/DELIVERED/FAILED/REFUNDING/REFUNDED/CLOSED */
private String status;
private Integer quantity;
private String regionCode;
private String channel;
private String experimentId;
private String receipt;
private LocalDateTime verifyAt;
private LocalDateTime paidAt;
private LocalDateTime deliveredAt;
private String failReason;
private LocalDateTime refundAt;
private String refundReason;
private LocalDateTime createdAt;
@TableField(updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,44 @@
package com.fruitshorts.commerce.dal.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 商品配置:金币包;会员与活动价为后续阶段预留。 */
@Data
@TableName("product")
public class Product {
@TableId(type = IdType.AUTO)
private Long id;
private String code;
private String name;
private String type;
private String platform;
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;
private LocalDateTime createdAt;
@TableField(updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,32 @@
package com.fruitshorts.commerce.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** 每日对账任务:与商店账单比对,差异进人工处理。 */
@Data
@TableName("reconcile_task")
public class ReconcileTask {
@TableId(type = IdType.AUTO)
private Long id;
private LocalDate bizDate;
private String platform;
private Integer totalCount;
private Integer matchedCount;
private Integer diffCount;
private BigDecimal amountSum;
/** RUNNING/SUCCESS/FAILED */
private String status;
private String detail;
private LocalDateTime startedAt;
private LocalDateTime finishedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,38 @@
package com.fruitshorts.commerce.dal.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** 商店票据去重与校验留证(重放防护)。 */
@Data
@TableName("store_receipt")
public class StoreReceipt {
@TableId(type = IdType.AUTO)
private Long id;
private String platform;
private String transactionId;
private String originalTransactionId;
private String orderNo;
private Long userId;
private String productId;
private LocalDateTime purchaseAt;
private String rawPayload;
private String verifyStatus;
private String verifyResponse;
private LocalDateTime verifyAt;
private LocalDateTime createdAt;
@TableField(updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,34 @@
package com.fruitshorts.commerce.dal.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** 金币钱包。余额变动必须同时写 coin_ledger,两者在同一个事务里。 */
@Data
@TableName("wallet")
public class Wallet {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private Long coinBalance;
private Long totalRechargedCoin;
private Long totalConsumedCoin;
private Long totalRefundedCoin;
/** 乐观锁版本号 */
private Integer version;
private LocalDateTime createdAt;
@TableField(updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,7 @@
package com.fruitshorts.commerce.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.commerce.dal.entity.CoinLedger;
public interface CoinLedgerMapper extends BaseMapper<CoinLedger> {
}
@@ -0,0 +1,7 @@
package com.fruitshorts.commerce.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.commerce.dal.entity.Entitlement;
public interface EntitlementMapper extends BaseMapper<Entitlement> {
}
@@ -0,0 +1,7 @@
package com.fruitshorts.commerce.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.commerce.dal.entity.OrderEvent;
public interface OrderEventMapper extends BaseMapper<OrderEvent> {
}
@@ -0,0 +1,7 @@
package com.fruitshorts.commerce.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.commerce.dal.entity.Orders;
public interface OrdersMapper extends BaseMapper<Orders> {
}
@@ -0,0 +1,7 @@
package com.fruitshorts.commerce.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.commerce.dal.entity.Product;
public interface ProductMapper extends BaseMapper<Product> {
}
@@ -0,0 +1,7 @@
package com.fruitshorts.commerce.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.commerce.dal.entity.ReconcileTask;
public interface ReconcileTaskMapper extends BaseMapper<ReconcileTask> {
}
@@ -0,0 +1,7 @@
package com.fruitshorts.commerce.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.commerce.dal.entity.StoreReceipt;
public interface StoreReceiptMapper extends BaseMapper<StoreReceipt> {
}
@@ -0,0 +1,21 @@
package com.fruitshorts.commerce.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.commerce.dal.entity.Wallet;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
public interface WalletMapper extends BaseMapper<Wallet> {
/**
* 原子增减余额。扣款带 coin_balance + amount >= 0 守卫,
* 并发下不会把余额扣成负数,也不会像读改写那样丢更新;返回 0 表示余额不足。
*/
@Update("UPDATE wallet SET coin_balance = coin_balance + #{amount}, "
+ "total_consumed_coin = total_consumed_coin + (CASE WHEN #{amount} < 0 THEN -#{amount} ELSE 0 END), "
+ "total_recharged_coin = total_recharged_coin + (CASE WHEN #{amount} > 0 AND #{countAsRecharge} THEN #{amount} ELSE 0 END), "
+ "version = version + 1 "
+ "WHERE user_id = #{userId} AND deleted = 0 AND coin_balance + #{amount} >= 0")
int adjustBalance(@Param("userId") Long userId, @Param("amount") long amount,
@Param("countAsRecharge") boolean countAsRecharge);
}
@@ -0,0 +1,19 @@
package com.fruitshorts.commerce.service;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import org.springframework.stereotype.Component;
/**
* 未接入商店时的默认实现:明确报"未配置",而不是静默放行或假装成功。
* 这样客户端拿到的是可理解的业务提示,也不会产生假订单。
*/
@Component
public class DisabledStoreVerifier implements StoreVerifier {
@Override
public StoreReceiptResult verify(String platform, String receipt) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"Apple / Google 商店校验尚未配置,第一阶段仅预留接口");
}
}
@@ -0,0 +1,137 @@
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.Entitlement;
import com.fruitshorts.commerce.dal.mapper.EntitlementMapper;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.playback.service.EntitlementPort;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.List;
/**
* 权益:播放与下载的付费判定读这里。
*
* 标注 @Primary 覆盖播放域里"一律视为未解锁"的默认实现 ——
* 交易模块一落地,播放与下载的付费判定立即生效,两条链路都不用改代码。
*/
@Primary
@Service
@RequiredArgsConstructor
public class EntitlementService implements EntitlementPort {
public static final String STATUS_ACTIVE = "ACTIVE";
public static final String STATUS_REVOKED = "REVOKED";
public static final String STATUS_EXPIRED = "EXPIRED";
public static final String TYPE_EPISODE = "EPISODE";
public static final String TYPE_DRAMA = "DRAMA";
private final EntitlementMapper entitlementMapper;
@Override
public boolean isUnlocked(Long userId, Long dramaId, Long episodeId) {
if (userId == null) {
return false;
}
LocalDateTime now = LocalDateTime.now();
Long count = entitlementMapper.selectCount(Wrappers.<Entitlement>lambdaQuery()
.eq(Entitlement::getUserId, userId)
.eq(Entitlement::getStatus, STATUS_ACTIVE)
.and(w -> w.eq(episodeId != null, Entitlement::getEpisodeId, episodeId)
.or()
.eq(dramaId != null, Entitlement::getType, TYPE_DRAMA)
.eq(dramaId != null, Entitlement::getDramaId, dramaId))
.and(w -> w.isNull(Entitlement::getExpireAt)
.or().gt(Entitlement::getExpireAt, now)));
return count != null && count > 0;
}
/** 发放权益。同一目标重复发放复用既有记录(唯一索引保证只有一条)。 */
@Transactional(rollbackFor = Exception.class)
public Entitlement grant(Long userId, Long dramaId, Long episodeId, String type,
String source, String orderNo) {
Entitlement existing = entitlementMapper.selectOne(Wrappers.<Entitlement>lambdaQuery()
.eq(Entitlement::getUserId, userId)
.eq(Entitlement::getType, type)
.eq(Entitlement::getDramaId, dramaId == null ? 0L : dramaId)
.eq(Entitlement::getEpisodeId, episodeId == null ? 0L : episodeId)
.last("limit 1"));
if (existing != null) {
if (!STATUS_ACTIVE.equals(existing.getStatus())) {
existing.setStatus(STATUS_ACTIVE);
existing.setRevokeReason("");
existing.setExpireAt(null);
entitlementMapper.updateById(existing);
}
return existing;
}
Entitlement entitlement = new Entitlement();
entitlement.setUserId(userId);
entitlement.setDramaId(dramaId == null ? 0L : dramaId);
entitlement.setEpisodeId(episodeId == null ? 0L : episodeId);
entitlement.setType(type);
entitlement.setSource(source == null ? "ORDER" : source);
entitlement.setStatus(STATUS_ACTIVE);
entitlement.setOrderNo(orderNo == null ? "" : orderNo);
entitlement.setStartAt(LocalDateTime.now());
entitlement.setRevokeReason("");
entitlementMapper.insert(entitlement);
return entitlement;
}
@Transactional(rollbackFor = Exception.class)
public void revoke(Long id, String reason) {
Entitlement entitlement = id == null ? null : entitlementMapper.selectById(id);
if (entitlement == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "权益不存在");
}
if (STATUS_REVOKED.equals(entitlement.getStatus())) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "该权益已回收");
}
entitlement.setStatus(STATUS_REVOKED);
entitlement.setRevokeReason(reason == null ? "" : reason);
entitlementMapper.updateById(entitlement);
}
/** 按订单回收权益(退款/撤销链路)。 */
@Transactional(rollbackFor = Exception.class)
public int revokeByOrderNo(String orderNo, String reason) {
List<Entitlement> list = entitlementMapper.selectList(Wrappers.<Entitlement>lambdaQuery()
.eq(Entitlement::getOrderNo, orderNo)
.eq(Entitlement::getStatus, STATUS_ACTIVE));
for (Entitlement entitlement : list) {
entitlement.setStatus(STATUS_REVOKED);
entitlement.setRevokeReason(reason == null ? "" : reason);
entitlementMapper.updateById(entitlement);
}
return list.size();
}
public List<Entitlement> listByUser(Long userId) {
return entitlementMapper.selectList(Wrappers.<Entitlement>lambdaQuery()
.eq(Entitlement::getUserId, userId)
.orderByDesc(Entitlement::getId));
}
public PageResult<Entitlement> page(Long userId, String status, String type,
int pageIndex, int pageSize) {
Page<Entitlement> page = new Page<>(pageIndex < 1 ? 1 : pageIndex, pageSize < 1 ? 10 : pageSize);
IPage<Entitlement> result = entitlementMapper.selectPage(page,
Wrappers.<Entitlement>lambdaQuery()
.eq(userId != null, Entitlement::getUserId, userId)
.eq(StringUtils.hasText(status), Entitlement::getStatus, status)
.eq(StringUtils.hasText(type), Entitlement::getType, type)
.orderByDesc(Entitlement::getId));
return new PageResult<>(result.getTotal(), result.getRecords());
}
}
@@ -0,0 +1,177 @@
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.OrderEvent;
import com.fruitshorts.commerce.dal.entity.Orders;
import com.fruitshorts.commerce.dal.mapper.OrderEventMapper;
import com.fruitshorts.commerce.dal.mapper.OrdersMapper;
import com.fruitshorts.commerce.vo.OrderQueryReqVO;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.List;
/**
* 订单:检索、补发、退款回收。
*
* 每个状态流转都写 order_event(月分区流水),支付争议时能回答"谁在什么时候改了什么"。
* 商店票据校验走 StoreVerifier 端口,第一阶段只预留、不接真实商店。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {
public static final String STATUS_CREATED = "CREATED";
public static final String STATUS_PAID = "PAID";
public static final String STATUS_DELIVERED = "DELIVERED";
public static final String STATUS_REFUNDED = "REFUNDED";
public static final String STATUS_CLOSED = "CLOSED";
private final OrdersMapper ordersMapper;
private final OrderEventMapper orderEventMapper;
private final StoreVerifier storeVerifier;
private final WalletService walletService;
private final EntitlementService entitlementService;
public PageResult<Orders> page(OrderQueryReqVO req) {
Page<Orders> page = new Page<>(
req.getPageIndex() == null || req.getPageIndex() < 1 ? 1 : req.getPageIndex(),
req.getPageSize() == null || req.getPageSize() < 1 ? 10 : req.getPageSize());
IPage<Orders> result = ordersMapper.selectPage(page, Wrappers.<Orders>lambdaQuery()
.eq(req.getUserId() != null, Orders::getUserId, req.getUserId())
.eq(StringUtils.hasText(req.getStatus()), Orders::getStatus, req.getStatus())
.eq(StringUtils.hasText(req.getPlatform()), Orders::getPlatform, req.getPlatform())
.eq(StringUtils.hasText(req.getOrderNo()), Orders::getOrderNo, req.getOrderNo())
.orderByDesc(Orders::getId));
return new PageResult<>(result.getTotal(), result.getRecords());
}
public List<Orders> listByUser(Long userId) {
return ordersMapper.selectList(Wrappers.<Orders>lambdaQuery()
.eq(Orders::getUserId, userId)
.orderByDesc(Orders::getId));
}
public Orders getExisting(String orderNo) {
Orders order = StringUtils.hasText(orderNo) ? ordersMapper.selectOne(
Wrappers.<Orders>lambdaQuery().eq(Orders::getOrderNo, orderNo).last("limit 1")) : null;
if (order == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "订单不存在");
}
return order;
}
public List<OrderEvent> events(String orderNo) {
return orderEventMapper.selectList(Wrappers.<OrderEvent>lambdaQuery()
.eq(OrderEvent::getOrderNo, orderNo)
.orderByDesc(OrderEvent::getId));
}
/**
* 客户端提交商店票据。
* 商店未接入时 StoreVerifier 会抛出明确异常,不会产生假订单 —— 这是刻意的:
* 宁可不收钱,也不能在支付链路上给出"看起来成功"的假象。
*/
@Transactional(rollbackFor = Exception.class)
public Orders verifyAndDeliver(Long userId, String platform, String receipt) {
StoreVerifier.StoreReceiptResult result = storeVerifier.verify(platform, receipt);
if (!result.ok()) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, result.message());
}
Orders order = ordersMapper.selectOne(Wrappers.<Orders>lambdaQuery()
.eq(Orders::getPlatform, platform)
.eq(Orders::getStoreTransactionId, result.transactionId())
.last("limit 1"));
if (order == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "找不到对应的订单");
}
return deliver(order, "STORE", null, "票据校验通过");
}
/** 后台补发:把已支付未交付的订单重新交付;已交付时幂等返回。 */
@Transactional(rollbackFor = Exception.class)
public Orders compensate(String orderNo, Long operatorId) {
Orders order = getExisting(orderNo);
if (STATUS_DELIVERED.equals(order.getStatus())) {
return order;
}
if (!STATUS_PAID.equals(order.getStatus())) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"只有已支付未交付的订单可以补发,当前状态:" + order.getStatus());
}
return deliver(order, "COMPENSATE", operatorId, "后台补发");
}
/** 交付:发金币 + 置为已交付 + 记录流水。幂等键按订单固定,重复调用不会重复发币。 */
private Orders deliver(Orders order, String source, Long operatorId, String remark) {
String from = order.getStatus();
if (order.getCoinAmount() != null && order.getCoinAmount() > 0) {
walletService.change(order.getUserId(), order.getCoinAmount(), WalletService.BIZ_RECHARGE,
order.getOrderNo(), "order:" + order.getOrderNo() + ":coins",
remark + ":" + order.getProductName(), true, operatorId);
}
order.setStatus(STATUS_DELIVERED);
order.setDeliveredAt(LocalDateTime.now());
order.setFailReason("");
ordersMapper.updateById(order);
recordEvent(order, "DELIVER", from, STATUS_DELIVERED, source, operatorId, remark);
return order;
}
/** 退款/撤销:回收权益 + 扣回金币,并留流水。 */
@Transactional(rollbackFor = Exception.class)
public Orders refund(String orderNo, String reason, Long operatorId) {
Orders order = getExisting(orderNo);
if (STATUS_REFUNDED.equals(order.getStatus())) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "该订单已退款");
}
if (!STATUS_PAID.equals(order.getStatus()) && !STATUS_DELIVERED.equals(order.getStatus())) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"只有已支付或已交付的订单可以退款,当前状态:" + order.getStatus());
}
String from = order.getStatus();
int revoked = entitlementService.revokeByOrderNo(orderNo, reason);
String clawback = "未涉及金币";
if (order.getCoinAmount() != null && order.getCoinAmount() > 0) {
try {
walletService.change(order.getUserId(), -order.getCoinAmount(), WalletService.BIZ_REFUND,
orderNo, "order:" + orderNo + ":refund", "退款扣回金币", false, operatorId);
clawback = "已扣回 " + order.getCoinAmount() + " 金币";
} catch (ServiceException ex) {
// 余额已被消耗:标记欠款,交给客服人工跟进,不能因此阻断退款
clawback = "金币余额不足,未能扣回,需人工跟进";
log.warn("订单 {} 退款时金币扣回失败:{}", orderNo, ex.getMessage());
}
}
order.setStatus(STATUS_REFUNDED);
order.setRefundAt(LocalDateTime.now());
order.setRefundReason(reason == null ? "" : reason);
ordersMapper.updateById(order);
recordEvent(order, "REFUND", from, STATUS_REFUNDED, "ADMIN", operatorId,
"回收权益 " + revoked + " 条;" + clawback);
return order;
}
private void recordEvent(Orders order, String event, String from, String to, String source,
Long operatorId, String payload) {
OrderEvent record = new OrderEvent();
record.setOrderNo(order.getOrderNo());
record.setEvent(event);
record.setFromStatus(from == null ? "" : from);
record.setToStatus(to);
record.setSource(source);
record.setPayload(payload == null ? "" : payload);
record.setOperatorId(operatorId == null ? 0L : operatorId);
orderEventMapper.insert(record);
}
}
@@ -0,0 +1,98 @@
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.Orders;
import com.fruitshorts.commerce.dal.entity.ReconcileTask;
import com.fruitshorts.commerce.dal.entity.StoreReceipt;
import com.fruitshorts.commerce.dal.mapper.OrdersMapper;
import com.fruitshorts.commerce.dal.mapper.ReconcileTaskMapper;
import com.fruitshorts.commerce.dal.mapper.StoreReceiptMapper;
import com.fruitshorts.common.pojo.PageResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
* 对账:把本地订单与商店票据比对,差异进人工处理队列。
*
* 第一阶段商店未接入,票据表是空的,所以差异必然等于订单数 ——
* 这正是要暴露的:接入商店后这个数字必须归零,否则就是在错账。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ReconcileService {
public static final String STATUS_SUCCESS = "SUCCESS";
public static final String STATUS_FAILED = "FAILED";
private final ReconcileTaskMapper reconcileTaskMapper;
private final OrdersMapper ordersMapper;
private final StoreReceiptMapper storeReceiptMapper;
@Transactional(rollbackFor = Exception.class)
public ReconcileTask run(LocalDate bizDate, String platform) {
LocalDate date = bizDate == null ? LocalDate.now().minusDays(1) : bizDate;
String scope = platform == null || platform.isBlank() ? "ALL" : platform;
LocalDateTime from = date.atStartOfDay();
LocalDateTime to = date.plusDays(1).atStartOfDay();
List<Orders> orders = ordersMapper.selectList(Wrappers.<Orders>lambdaQuery()
.ge(Orders::getCreatedAt, from)
.lt(Orders::getCreatedAt, to)
.eq(!"ALL".equals(scope), Orders::getPlatform, scope));
List<StoreReceipt> receipts = storeReceiptMapper.selectList(Wrappers.<StoreReceipt>lambdaQuery()
.ge(StoreReceipt::getCreatedAt, from)
.lt(StoreReceipt::getCreatedAt, to)
.eq(!"ALL".equals(scope), StoreReceipt::getPlatform, scope));
BigDecimal amountSum = BigDecimal.ZERO;
int paidCount = 0;
for (Orders order : orders) {
if (order.getAmount() != null && !OrderService.STATUS_CREATED.equals(order.getStatus())
&& !OrderService.STATUS_CLOSED.equals(order.getStatus())) {
amountSum = amountSum.add(order.getAmount());
paidCount++;
}
}
int diff = Math.abs(paidCount - receipts.size());
ReconcileTask task = new ReconcileTask();
task.setBizDate(date);
task.setPlatform(scope);
task.setTotalCount(paidCount);
task.setMatchedCount(Math.min(paidCount, receipts.size()));
task.setDiffCount(diff);
task.setAmountSum(amountSum);
task.setStatus(STATUS_SUCCESS);
task.setDetail("本地有效订单 " + paidCount + " 笔 / 商店票据 " + receipts.size()
+ " 张,差异 " + diff + " 笔。"
+ (diff > 0 ? "商店尚未接入时差异属预期,接入后必须归零。" : ""));
task.setStartedAt(LocalDateTime.now());
task.setFinishedAt(LocalDateTime.now());
reconcileTaskMapper.insert(task);
log.info("对账完成 {} {}:订单 {} 票据 {} 差异 {}", scope, date, paidCount, receipts.size(), diff);
return task;
}
/** 每天 04:20 自动对前一天,避免人工忘记。 */
@Scheduled(cron = "${fruitshorts.commerce.reconcile-cron:0 20 4 * * *}")
public void scheduled() {
run(LocalDate.now().minusDays(1), "ALL");
}
public PageResult<ReconcileTask> page(int pageIndex, int pageSize) {
Page<ReconcileTask> page = new Page<>(pageIndex < 1 ? 1 : pageIndex, pageSize < 1 ? 10 : pageSize);
IPage<ReconcileTask> result = reconcileTaskMapper.selectPage(page,
Wrappers.<ReconcileTask>lambdaQuery().orderByDesc(ReconcileTask::getId));
return new PageResult<>(result.getTotal(), result.getRecords());
}
}
@@ -0,0 +1,21 @@
package com.fruitshorts.commerce.service;
/**
* 商店票据校验端口。
*
* 第一阶段**只预留端口,不接真实 Apple / Google 商店**:接入时新增一个实现
* (Apple App Store Server API / Google Play Developer API)并替换 DisabledStoreVerifier 即可,
* 订单与权益链路不需要改动。
*/
public interface StoreVerifier {
StoreReceiptResult verify(String platform, String receipt);
/** 校验结果。ok=false 时 message 说明原因,用于人工排查。 */
record StoreReceiptResult(boolean ok, String transactionId, String originalTransactionId,
String productId, String message) {
public static StoreReceiptResult failed(String message) {
return new StoreReceiptResult(false, "", "", "", message);
}
}
}
@@ -0,0 +1,80 @@
package com.fruitshorts.commerce.service;
import com.fruitshorts.commerce.dal.entity.CoinLedger;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 金币解锁分集。
*
* 幂等键固定为 unlock:{userId}:{episodeId}:重复点击、弱网重试、跨端并发
* 都只会扣一次币、发一次权益。真实商店购买走 StoreVerifier 端口,本轮不接。
*/
@Service
@RequiredArgsConstructor
public class UnlockService {
private final WalletService walletService;
private final EntitlementService entitlementService;
private final DramaService dramaService;
private final EpisodeService episodeService;
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> unlock(Long userId, Long episodeId) {
DramaEpisode episode = episodeService.getPublished(episodeId);
Drama drama = dramaService.getPublished(episode.getDramaId());
boolean free = episode.getIsFree() != null && episode.getIsFree() == 1;
Map<String, Object> data = new LinkedHashMap<>();
data.put("episodeId", episodeId);
data.put("dramaId", drama.getId());
if (free) {
data.put("unlocked", true);
data.put("accessType", "FREE");
data.put("coinCost", 0);
data.put("coinBalance", walletService.getOrCreate(userId).getCoinBalance());
return data;
}
if (entitlementService.isUnlocked(userId, drama.getId(), episode.getId())) {
data.put("unlocked", true);
data.put("accessType", "ENTITLED");
data.put("coinCost", 0);
data.put("coinBalance", walletService.getOrCreate(userId).getCoinBalance());
return data;
}
int coinCost = resolveCoinCost(episode, drama);
if (coinCost <= 0) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "该剧集未配置解锁价格");
}
CoinLedger ledger = walletService.change(userId, -coinCost, WalletService.BIZ_UNLOCK,
String.valueOf(episode.getId()), "unlock:" + userId + ":" + episodeId,
"解锁 " + drama.getTitle() + " 第 " + episode.getEpNo() + " 集", false, null);
entitlementService.grant(userId, drama.getId(), episode.getId(),
EntitlementService.TYPE_EPISODE, "ORDER", "");
data.put("unlocked", true);
data.put("accessType", "UNLOCKED");
data.put("coinCost", coinCost);
data.put("coinBalance", ledger.getBalanceAfter());
return data;
}
/** 单集价格优先,其次整剧价格,与详情接口下发的 unlockCoin 口径一致。 */
private int resolveCoinCost(DramaEpisode episode, Drama drama) {
if (episode.getUnlockCoin() != null && episode.getUnlockCoin() > 0) {
return episode.getUnlockCoin();
}
return drama.getUnlockCoin() == null ? 0 : drama.getUnlockCoin();
}
}
@@ -0,0 +1,124 @@
package com.fruitshorts.commerce.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.commerce.dal.entity.CoinLedger;
import com.fruitshorts.commerce.dal.entity.Wallet;
import com.fruitshorts.commerce.dal.mapper.CoinLedgerMapper;
import com.fruitshorts.commerce.dal.mapper.WalletMapper;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.List;
/**
* 钱包与金币流水。
*
* 两条硬规则:
* 1. 余额变动必须同时写流水(同一事务),流水是唯一事实来源;
* 2. 所有变动都要带幂等键,重复请求直接复用既有流水,绝不重复扣发 ——
* 支付回调、补发、对账补偿都会重放同一笔。
*/
@Service
@RequiredArgsConstructor
public class WalletService {
public static final String BIZ_RECHARGE = "RECHARGE";
public static final String BIZ_UNLOCK = "UNLOCK";
public static final String BIZ_REFUND = "REFUND";
public static final String BIZ_ADMIN = "ADMIN";
private final WalletMapper walletMapper;
private final CoinLedgerMapper ledgerMapper;
public Wallet getExisting(Long userId) {
Wallet wallet = walletMapper.selectOne(Wrappers.<Wallet>lambdaQuery()
.eq(Wallet::getUserId, userId)
.last("limit 1"));
if (wallet == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "钱包不存在");
}
return wallet;
}
/** 没有钱包就建一个(余额 0),避免首次解锁时报错。 */
@Transactional(rollbackFor = Exception.class)
public Wallet getOrCreate(Long userId) {
Wallet wallet = walletMapper.selectOne(Wrappers.<Wallet>lambdaQuery()
.eq(Wallet::getUserId, userId)
.last("limit 1"));
if (wallet != null) {
return wallet;
}
wallet = new Wallet();
wallet.setUserId(userId);
wallet.setCoinBalance(0L);
wallet.setTotalRechargedCoin(0L);
wallet.setTotalConsumedCoin(0L);
wallet.setTotalRefundedCoin(0L);
wallet.setVersion(0);
try {
walletMapper.insert(wallet);
} catch (DuplicateKeyException ex) {
return getExisting(userId);
}
return wallet;
}
public List<CoinLedger> recentLedger(Long userId, int limit) {
return ledgerMapper.selectList(Wrappers.<CoinLedger>lambdaQuery()
.eq(CoinLedger::getUserId, userId)
.orderByDesc(CoinLedger::getId)
.last("limit " + (limit <= 0 || limit > 100 ? 20 : limit)));
}
/**
* 余额变动。
*
* @param idempotentKey 幂等键,重复调用只生效一次
* @param countAsRecharge 是否计入累计充值(人工补币与活动奖励不计入)
*/
@Transactional(rollbackFor = Exception.class)
public CoinLedger change(Long userId, long amount, String bizType, String bizId,
String idempotentKey, String remark, boolean countAsRecharge,
Long operatorId) {
if (amount == 0) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "变动金额不能为 0");
}
if (!StringUtils.hasText(idempotentKey)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "缺少幂等键");
}
CoinLedger existed = ledgerMapper.selectOne(Wrappers.<CoinLedger>lambdaQuery()
.eq(CoinLedger::getIdempotentKey, idempotentKey)
.last("limit 1"));
if (existed != null) {
return existed;
}
getOrCreate(userId);
int updated = walletMapper.adjustBalance(userId, amount, countAsRecharge);
if (updated == 0) {
throw new ServiceException(ErrorCode.COIN_NOT_ENOUGH);
}
Wallet wallet = getExisting(userId);
CoinLedger ledger = new CoinLedger();
ledger.setUserId(userId);
ledger.setChangeAmount(amount);
ledger.setBalanceAfter(wallet.getCoinBalance());
ledger.setBizType(bizType);
ledger.setBizId(bizId == null ? "" : bizId);
ledger.setIdempotentKey(idempotentKey);
ledger.setRemark(remark == null ? "" : remark);
ledger.setOperatorId(operatorId == null ? 0L : operatorId);
try {
ledgerMapper.insert(ledger);
} catch (DuplicateKeyException ex) {
// 并发同键:对方已记账,本事务回滚,保持余额与流水一致
throw new ServiceException(ErrorCode.DATA_EXISTS, "该笔金币变动已处理");
}
return ledger;
}
}
@@ -0,0 +1,21 @@
package com.fruitshorts.commerce.vo;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/** 人工补币(可正可负),必须带幂等键与备注,全部进流水。 */
@Data
public class CoinAdjustReqVO {
@NotNull(message = "缺少 userId")
private Long userId;
/** 正数补发、负数扣回 */
@NotNull(message = "缺少变动金额")
private Long amount;
private String remark;
/** 幂等键:同一个工单重复提交只生效一次 */
private String idempotentKey;
}
@@ -0,0 +1,23 @@
package com.fruitshorts.commerce.vo;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/** 后台人工补权益(客服补偿用)。 */
@Data
public class EntitlementGrantReqVO {
@NotNull(message = "缺少 userId")
private Long userId;
@NotNull(message = "缺少 dramaId")
private Long dramaId;
/** 传 0 表示整剧权益 */
private Long episodeId;
/** EPISODE/DRAMA */
private String type = "EPISODE";
private String reason;
}
@@ -0,0 +1,16 @@
package com.fruitshorts.commerce.vo;
import lombok.Data;
/** 后台订单检索条件。 */
@Data
public class OrderQueryReqVO {
private Long userId;
private String orderNo;
/** CREATED/PAID/DELIVERED/REFUNDED/CLOSED */
private String status;
private String platform;
private Integer pageIndex = 1;
private Integer pageSize = 10;
}