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
-4
@@ -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 质量、数据与上线 | 已建 | 已冻结 | 压测与埋点 下一批 |
|
||||
@@ -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>
|
||||
|
||||
+59
@@ -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();
|
||||
}
|
||||
}
|
||||
+69
@@ -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()));
|
||||
}
|
||||
|
||||
}
|
||||
+42
@@ -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));
|
||||
}
|
||||
}
|
||||
+58
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+63
@@ -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", "")));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
+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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
-- 第一阶段新增的白名单数据(当前阶段都是免费内容,先把演示商品占位好)
|
||||
|
||||
Reference in new issue
Block a user