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