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

+29 -4
View File
@@ -190,11 +190,22 @@ POST /app/auth/register
**可见性**:匿名与其他人只看得到 `published`;自己待审的评论也会返回,便于展示"审核中"。
评论列表禁止重复提交(同一剧集每分钟 5 条,可配)。
### 1.8 交易、创作者(下一批)
### 1.8 金币与权益(已实现)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/app/wallet` | 金币余额 + 最近流水 |
| POST | `/app/episodes/{id}/unlock` | 金币解锁分集;幂等键 `unlock:{userId}:{episodeId}`,重复调用不重复扣币 |
| GET | `/app/orders` | 我的订单 |
| POST | `/app/orders/verify` | 提交 IAP / Google Play 票据。**本轮只留端口不接商店**,会返回 `200003 尚未配置` |
解锁后播放入口与下载入口会同时放行 —— 权益判定由交易模块的 `EntitlementPort` 实现提供,
播放域与下载域都不需要改代码。金币变动一律带幂等键并写 `coin_ledger`,钱包余额与流水同事务。
### 1.9 创作者(下一批)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| POST | `/app/orders/verify` | 提交 IAP / Google Play 票据,服务端二次校验后发权益(本轮只留接口,不接真实商店) |
| GET | `/app/orders` | 我的订单 |
| GET | `/app/wallet` | 金币余额与流水 |
| POST | `/app/episodes/{id}/unlock` | 金币解锁分集 |
@@ -251,7 +262,21 @@ POST /app/auth/register
| GET | `/admin/sensitive-words/page` | `sensitive-word:list` | 敏感词检索 |
| POST/PUT/DELETE | `/admin/sensitive-words[/{id}]` | `sensitive-word:update` | 维护词库,改动即时生效(服务端缓存失效重建) |
### 2.6 订单、创作者审核、数据看板(下一批)
### 2.6 订单与权益(已实现)
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/orders/page` `/admin/orders/{orderNo}` `/{orderNo}/events` | `order:list` | 订单检索、详情、状态流转流水 |
| POST | `/admin/orders/{orderNo}/compensate` | `order:update` | 补发:已支付未交付的订单重新交付,已交付幂等返回 |
| POST | `/admin/orders/{orderNo}/refund` | `order:update` | 退款/撤销:回收权益 + 扣回金币(余额不足时留人工跟进备注) |
| GET | `/admin/entitlements/page` | `entitlement:list` | 权益检索 |
| POST | `/admin/entitlements` | `entitlement:update` | 人工补发权益(客服补偿) |
| PUT | `/admin/entitlements/{id}/revoke` | `entitlement:update` | 回收权益,播放判定实时生效 |
| GET | `/admin/wallets/{userId}/ledger` | `order:list` | 金币流水 |
| POST | `/admin/wallets/coins` | `wallet:update` | 人工补币/扣回,必须带幂等键,先校验用户存在 |
| POST | `/admin/reconcile/run` · GET `/admin/reconcile/page` | `reconcile:run` / `order:list` | 手动对账与历史(默认每天 04:20 自动跑前一天) |
### 2.7 创作者审核、数据看板(下一批)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
@@ -284,6 +309,6 @@ POST /app/auth/register
| 05 记录、收藏与下载 | 已建 | 已冻结 | **已实现**:进度、观看流水、收藏、离线下载授权与任务 |
| 06 评论、点赞与分享 | 已建 | 已冻结 | **已实现**:评论(游标翻页/审核/删除)、点赞、举报、敏感词;分享埋点在下一批 |
| 07 创作者中心 | 已建 | 已冻结 | 下一批(转码任务与产物记账已实现) |
| 08 平台运营后台 | 已建 | 用户管理已实现 | 治理/订单/看板 下一批 |
| 08 平台运营后台 | 已建 | 已冻结 | 用户管理、内容治理、**订单与权益已实现**;创作者审核与数据看板下一批 |
| 09 分集收费与支付 | 已建 | 已冻结 | 下一批(权益判定端口已预留,接上即生效) |
| 10 质量、数据与上线 | 已建 | 已冻结 | 压测与埋点 下一批 |
+4
View File
@@ -39,6 +39,10 @@
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-interaction</artifactId>
</dependency>
<dependency>
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-commerce</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
@@ -0,0 +1,59 @@
package com.fruitshorts.admin.controller;
import com.fruitshorts.commerce.dal.entity.Entitlement;
import com.fruitshorts.commerce.service.EntitlementService;
import com.fruitshorts.commerce.vo.EntitlementGrantReqVO;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.log.OperationLog;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 后台权益:检索、人工补发、回收。 */
@RestController
@RequestMapping("/admin/entitlements")
@RequiredArgsConstructor
public class EntitlementAdminController {
private final EntitlementService entitlementService;
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('entitlement:list')")
public Result<PageResult<Entitlement>> page(@RequestParam(required = false) Long userId,
@RequestParam(required = false) String status,
@RequestParam(required = false) String type,
@RequestParam(defaultValue = "1") Integer pageIndex,
@RequestParam(defaultValue = "10") Integer pageSize) {
return Result.ok(entitlementService.page(userId, status, type, pageIndex, pageSize));
}
/** 客服补偿:直接给用户某集/整剧的播放权益。 */
@PostMapping
@PreAuthorize("@ss.hasPermission('entitlement:update')")
@OperationLog(module = "订单管理", action = "人工补发权益")
public Result<Entitlement> grant(@Valid @RequestBody EntitlementGrantReqVO req) {
return Result.ok(entitlementService.grant(req.getUserId(), req.getDramaId(),
req.getEpisodeId() == null ? 0L : req.getEpisodeId(),
req.getType() == null ? EntitlementService.TYPE_EPISODE : req.getType(),
"ADMIN", ""));
}
/** 回收权益:退款、违规处置后用。 */
@PutMapping("/{id}/revoke")
@PreAuthorize("@ss.hasPermission('entitlement:update')")
@OperationLog(module = "订单管理", action = "回收权益")
public Result<Void> revoke(@PathVariable Long id,
@RequestParam(required = false) String reason) {
entitlementService.revoke(id, reason);
return Result.ok();
}
}
@@ -0,0 +1,69 @@
package com.fruitshorts.admin.controller;
import com.fruitshorts.commerce.dal.entity.OrderEvent;
import com.fruitshorts.commerce.dal.entity.Orders;
import com.fruitshorts.commerce.service.OrderService;
import com.fruitshorts.commerce.vo.OrderQueryReqVO;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.framework.security.SecurityUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/** 后台订单:检索、补发、退款回收。 */
@RestController
@RequestMapping("/admin/orders")
@RequiredArgsConstructor
public class OrderAdminController {
private final OrderService orderService;
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('order:list')")
public Result<PageResult<Orders>> page(OrderQueryReqVO req) {
return Result.ok(orderService.page(req));
}
@GetMapping("/{orderNo}")
@PreAuthorize("@ss.hasPermission('order:list')")
public Result<Orders> detail(@PathVariable String orderNo) {
return Result.ok(orderService.getExisting(orderNo));
}
/** 订单状态流转流水:支付争议时的证据链。 */
@GetMapping("/{orderNo}/events")
@PreAuthorize("@ss.hasPermission('order:list')")
public Result<List<OrderEvent>> events(@PathVariable String orderNo) {
return Result.ok(orderService.events(orderNo));
}
/** 补发:已支付未交付的订单重新交付;已交付时幂等返回。 */
@PostMapping("/{orderNo}/compensate")
@PreAuthorize("@ss.hasPermission('order:update')")
@OperationLog(module = "订单管理", action = "补发订单")
public Result<Orders> compensate(@PathVariable String orderNo) {
return Result.ok(orderService.compensate(orderNo, SecurityUtils.getUserId()));
}
/** 退款/撤销:回收权益并扣回金币。 */
@PostMapping("/{orderNo}/refund")
@PreAuthorize("@ss.hasPermission('order:update')")
@OperationLog(module = "订单管理", action = "订单退款")
public Result<Orders> refund(@PathVariable String orderNo,
@RequestBody(required = false) Map<String, String> body) {
String reason = body == null ? "" : body.getOrDefault("reason", "");
return Result.ok(orderService.refund(orderNo, reason, SecurityUtils.getUserId()));
}
}
@@ -0,0 +1,42 @@
package com.fruitshorts.admin.controller;
import com.fruitshorts.commerce.dal.entity.ReconcileTask;
import com.fruitshorts.commerce.service.ReconcileService;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.log.OperationLog;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
/** 后台对账:手动触发与历史查看。 */
@RestController
@RequestMapping("/admin/reconcile")
@RequiredArgsConstructor
public class ReconcileAdminController {
private final ReconcileService reconcileService;
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('order:list')")
public Result<PageResult<ReconcileTask>> page(@RequestParam(defaultValue = "1") Integer pageIndex,
@RequestParam(defaultValue = "10") Integer pageSize) {
return Result.ok(reconcileService.page(pageIndex, pageSize));
}
/** 手动对账:默认前一天,可指定日期与渠道。 */
@PostMapping("/run")
@PreAuthorize("@ss.hasPermission('reconcile:run')")
@OperationLog(module = "订单管理", action = "手动对账")
public Result<ReconcileTask> run(@RequestParam(required = false) String bizDate,
@RequestParam(required = false) String platform) {
LocalDate date = bizDate == null || bizDate.isBlank() ? null : LocalDate.parse(bizDate);
return Result.ok(reconcileService.run(date, platform));
}
}
@@ -0,0 +1,58 @@
package com.fruitshorts.admin.controller;
import com.fruitshorts.commerce.dal.entity.CoinLedger;
import com.fruitshorts.commerce.service.WalletService;
import com.fruitshorts.commerce.vo.CoinAdjustReqVO;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.user.dal.mapper.AppUserMapper;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 后台钱包:人工补币/扣回,全部进流水且必须带幂等键。 */
@RestController
@RequestMapping("/admin/wallets")
@RequiredArgsConstructor
public class WalletAdminController {
private final WalletService walletService;
private final AppUserMapper appUserMapper;
@GetMapping("/{userId}/ledger")
@PreAuthorize("@ss.hasPermission('order:list')")
public Result<List<CoinLedger>> ledger(@PathVariable Long userId,
@RequestParam(defaultValue = "20") int limit) {
return Result.ok(walletService.recentLedger(userId, limit));
}
@PostMapping("/coins")
@PreAuthorize("@ss.hasPermission('wallet:update')")
@OperationLog(module = "订单管理", action = "人工补币")
public Result<CoinLedger> adjust(@Valid @RequestBody CoinAdjustReqVO req) {
// 必须先确认用户存在:否则会凭空给一个不存在的 userId 建钱包,
// 留下对不上账的脏数据(自测时确实踩到了这个坑)。
if (appUserMapper.selectById(req.getUserId()) == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "用户不存在,无法补币");
}
String key = req.getIdempotentKey() == null || req.getIdempotentKey().isBlank()
? "admin:" + System.currentTimeMillis() + ":" + req.getUserId()
: req.getIdempotentKey();
return Result.ok(walletService.change(req.getUserId(), req.getAmount(),
WalletService.BIZ_ADMIN, "", key,
req.getRemark() == null ? "人工调整" : req.getRemark(),
false, SecurityUtils.getUserId()));
}
}
+4
View File
@@ -35,6 +35,10 @@
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-interaction</artifactId>
</dependency>
<dependency>
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-commerce</artifactId>
</dependency>
</dependencies>
<build>
@@ -0,0 +1,63 @@
package com.fruitshorts.app.controller;
import com.fruitshorts.commerce.dal.entity.Orders;
import com.fruitshorts.commerce.service.OrderService;
import com.fruitshorts.commerce.service.UnlockService;
import com.fruitshorts.commerce.service.WalletService;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.security.SecurityUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 客户端金币与订阅。
*
* 金币解锁是第一阶段真实的收费方式;真实商店购买走 {@code /app/orders/verify},
* 本轮只预留接口(商店未接入时返回明确业务提示,不会产生假订单)。
*/
@RestController
@RequestMapping("/app")
@RequiredArgsConstructor
public class AppCommerceController {
private final WalletService walletService;
private final UnlockService unlockService;
private final OrderService orderService;
/** 我的钱包:余额 + 最近流水,客户端一处渲染。 */
@GetMapping("/wallet")
public Result<Map<String, Object>> wallet() {
Long userId = SecurityUtils.getAppUserId();
Map<String, Object> data = new LinkedHashMap<>();
data.put("wallet", walletService.getOrCreate(userId));
data.put("ledger", walletService.recentLedger(userId, 20));
return Result.ok(data);
}
/** 金币解锁分集:幂等,重复点击不会重复扣币。 */
@PostMapping("/episodes/{episodeId}/unlock")
public Result<Map<String, Object>> unlock(@PathVariable Long episodeId) {
return Result.ok(unlockService.unlock(SecurityUtils.getAppUserId(), episodeId));
}
@GetMapping("/orders")
public Result<List<Orders>> orders() {
return Result.ok(orderService.listByUser(SecurityUtils.getAppUserId()));
}
/** 提交商店票据。第一阶段商店未接入,会返回明确的"尚未配置"。 */
@PostMapping("/orders/verify")
public Result<Orders> verify(@RequestBody Map<String, String> body) {
return Result.ok(orderService.verifyAndDeliver(SecurityUtils.getAppUserId(),
body.getOrDefault("platform", ""), body.getOrDefault("receipt", "")));
}
}
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>fruitshorts-module-commerce</artifactId>
<name>fruitshorts-module-commerce</name>
<description>交易域:钱包、金币、订单、权益、对账</description>
<dependencies>
<dependency>
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-framework</artifactId>
</dependency>
<dependency>
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-content</artifactId>
</dependency>
<dependency>
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-user</artifactId>
</dependency>
<!-- 权益判定端口定义在播放域,这里提供实现(@Primary) -->
<dependency>
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-playback</artifactId>
</dependency>
</dependencies>
</project>
@@ -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;
}
+6
View File
@@ -27,6 +27,7 @@
<module>fruitshorts-module-user</module>
<module>fruitshorts-module-playback</module>
<module>fruitshorts-module-interaction</module>
<module>fruitshorts-module-commerce</module>
<module>fruitshorts-admin-api</module>
<module>fruitshorts-app-api</module>
</modules>
@@ -84,6 +85,11 @@
<artifactId>fruitshorts-module-interaction</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-commerce</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
+12 -1
View File
@@ -46,6 +46,15 @@ INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES
('sensitive-word:update','维护敏感词', '内容治理', 'BUTTON', 116)
ON CONFLICT (code) DO NOTHING;
INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES
('order:list', '查看订单', '订单与权益', 'BUTTON', 121),
('order:update', '补发/退款', '订单与权益', 'BUTTON', 122),
('entitlement:list', '查看权益', '订单与权益', 'BUTTON', 123),
('entitlement:update', '补发/回收权益', '订单与权益', 'BUTTON', 124),
('wallet:update', '人工补币', '订单与权益', 'BUTTON', 125),
('reconcile:run', '手动对账', '订单与权益', 'BUTTON', 126)
ON CONFLICT (code) DO NOTHING;
-- 超管默认拥有全部权限点
INSERT INTO sys_role_permission (role_id, permission_id)
SELECT r.id, p.id
@@ -54,7 +63,9 @@ CROSS JOIN sys_permission p
WHERE r.code = 'super_admin'
AND p.code IN ('app-user:list', 'app-user:update', 'download:list', 'download:revoke',
'comment:list', 'comment:audit', 'report:list', 'report:handle',
'sensitive-word:list', 'sensitive-word:update')
'sensitive-word:list', 'sensitive-word:update',
'order:list', 'order:update', 'entitlement:list', 'entitlement:update',
'wallet:update', 'reconcile:run')
ON CONFLICT DO NOTHING;
-- 第一阶段新增的白名单数据(当前阶段都是免费内容,先把演示商品占位好)