From 7e730fbbcacf56343b71b23ec48941a09676eb19 Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Thu, 15 Oct 2026 12:26:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=2008=20=E8=AE=A2=E5=8D=95=E4=B8=8E?= =?UTF-8?q?=E6=9D=83=E7=9B=8A=E8=90=BD=E5=9C=B0=EF=BC=88=E9=87=91=E5=B8=81?= =?UTF-8?q?=E8=A7=A3=E9=94=81=20/=20=E6=9D=83=E7=9B=8A=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E7=94=9F=E6=95=88=20/=20=E8=A1=A5=E5=8F=91=E5=9B=9E=E6=94=B6?= =?UTF-8?q?=20/=20=E5=AF=B9=E8=B4=A6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么: 1) 第一阶段要能真正收到钱,而收钱方式是金币解锁分集。本提交把钱包、金币流水、 权益、订单四样打通,客户端可以解锁并立刻播放,不再只是展示。 2) 权益判定此前只是端口占位(一律视为未解锁)。交易模块用 @Primary 提供实现后, 播放入口与下载入口的付费判定同时生效 —— 两条链路一行没改。 3) 钱的事必须可追溯、可幂等:所有金币变动都带幂等键并写 coin_ledger, 余额与流水同事务;订单每次状态流转都写 order_event(月分区),支付争议可回溯。 4) 后台要能兜住线上问题:补发(重复提交幂等)、退款(回收权益 + 扣回金币, 余额不足时留人工跟进备注)、人工补币(先校验用户存在,避免给不存在的用户建钱包)、 手动对账(默认每天 04:20 自动跑前一天)。 5) 09 商店支付按约束只做端口预留:StoreVerifier 未接入时明确报"尚未配置", 宁可不收钱也不产生假订单。 --- docs/api-phase1.md | 33 +++- fruitshorts-admin-api/pom.xml | 4 + .../EntitlementAdminController.java | 59 ++++++ .../controller/OrderAdminController.java | 69 +++++++ .../controller/ReconcileAdminController.java | 42 +++++ .../controller/WalletAdminController.java | 58 ++++++ fruitshorts-app-api/pom.xml | 4 + .../app/controller/AppCommerceController.java | 63 +++++++ fruitshorts-module-commerce/pom.xml | 36 ++++ .../commerce/dal/entity/CoinLedger.java | 29 +++ .../commerce/dal/entity/Entitlement.java | 41 ++++ .../commerce/dal/entity/OrderEvent.java | 26 +++ .../commerce/dal/entity/Orders.java | 55 ++++++ .../commerce/dal/entity/Product.java | 44 +++++ .../commerce/dal/entity/ReconcileTask.java | 32 ++++ .../commerce/dal/entity/StoreReceipt.java | 38 ++++ .../commerce/dal/entity/Wallet.java | 34 ++++ .../commerce/dal/mapper/CoinLedgerMapper.java | 7 + .../dal/mapper/EntitlementMapper.java | 7 + .../commerce/dal/mapper/OrderEventMapper.java | 7 + .../commerce/dal/mapper/OrdersMapper.java | 7 + .../commerce/dal/mapper/ProductMapper.java | 7 + .../dal/mapper/ReconcileTaskMapper.java | 7 + .../dal/mapper/StoreReceiptMapper.java | 7 + .../commerce/dal/mapper/WalletMapper.java | 21 +++ .../service/DisabledStoreVerifier.java | 19 ++ .../commerce/service/EntitlementService.java | 137 ++++++++++++++ .../commerce/service/OrderService.java | 177 ++++++++++++++++++ .../commerce/service/ReconcileService.java | 98 ++++++++++ .../commerce/service/StoreVerifier.java | 21 +++ .../commerce/service/UnlockService.java | 80 ++++++++ .../commerce/service/WalletService.java | 124 ++++++++++++ .../commerce/vo/CoinAdjustReqVO.java | 21 +++ .../commerce/vo/EntitlementGrantReqVO.java | 23 +++ .../commerce/vo/OrderQueryReqVO.java | 16 ++ pom.xml | 6 + sql/data-phase1.sql | 13 +- 37 files changed, 1467 insertions(+), 5 deletions(-) create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/EntitlementAdminController.java create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/OrderAdminController.java create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/ReconcileAdminController.java create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/WalletAdminController.java create mode 100644 fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommerceController.java create mode 100644 fruitshorts-module-commerce/pom.xml create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/CoinLedger.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/Entitlement.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/OrderEvent.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/Orders.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/Product.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/ReconcileTask.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/StoreReceipt.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/Wallet.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/CoinLedgerMapper.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/EntitlementMapper.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/OrderEventMapper.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/OrdersMapper.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/ProductMapper.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/ReconcileTaskMapper.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/StoreReceiptMapper.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/WalletMapper.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/DisabledStoreVerifier.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/EntitlementService.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/OrderService.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/ReconcileService.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/StoreVerifier.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/UnlockService.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/WalletService.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/CoinAdjustReqVO.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/EntitlementGrantReqVO.java create mode 100644 fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/OrderQueryReqVO.java diff --git a/docs/api-phase1.md b/docs/api-phase1.md index a2af271..8b051b0 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -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 质量、数据与上线 | 已建 | 已冻结 | 压测与埋点 下一批 | diff --git a/fruitshorts-admin-api/pom.xml b/fruitshorts-admin-api/pom.xml index 2ba4278..e09c587 100644 --- a/fruitshorts-admin-api/pom.xml +++ b/fruitshorts-admin-api/pom.xml @@ -39,6 +39,10 @@ com.fruitshorts fruitshorts-module-interaction + + com.fruitshorts + fruitshorts-module-commerce + org.springframework.boot spring-boot-starter-test diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/EntitlementAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/EntitlementAdminController.java new file mode 100644 index 0000000..5130079 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/EntitlementAdminController.java @@ -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> 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 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 revoke(@PathVariable Long id, + @RequestParam(required = false) String reason) { + entitlementService.revoke(id, reason); + return Result.ok(); + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/OrderAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/OrderAdminController.java new file mode 100644 index 0000000..703a698 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/OrderAdminController.java @@ -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> page(OrderQueryReqVO req) { + return Result.ok(orderService.page(req)); + } + + @GetMapping("/{orderNo}") + @PreAuthorize("@ss.hasPermission('order:list')") + public Result detail(@PathVariable String orderNo) { + return Result.ok(orderService.getExisting(orderNo)); + } + + /** 订单状态流转流水:支付争议时的证据链。 */ + @GetMapping("/{orderNo}/events") + @PreAuthorize("@ss.hasPermission('order:list')") + public Result> events(@PathVariable String orderNo) { + return Result.ok(orderService.events(orderNo)); + } + + /** 补发:已支付未交付的订单重新交付;已交付时幂等返回。 */ + @PostMapping("/{orderNo}/compensate") + @PreAuthorize("@ss.hasPermission('order:update')") + @OperationLog(module = "订单管理", action = "补发订单") + public Result 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 refund(@PathVariable String orderNo, + @RequestBody(required = false) Map body) { + String reason = body == null ? "" : body.getOrDefault("reason", ""); + return Result.ok(orderService.refund(orderNo, reason, SecurityUtils.getUserId())); + } + +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/ReconcileAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/ReconcileAdminController.java new file mode 100644 index 0000000..19b0404 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/ReconcileAdminController.java @@ -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> 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 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)); + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/WalletAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/WalletAdminController.java new file mode 100644 index 0000000..0e3d6ce --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/WalletAdminController.java @@ -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> 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 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())); + } +} diff --git a/fruitshorts-app-api/pom.xml b/fruitshorts-app-api/pom.xml index 24471f2..16659f5 100644 --- a/fruitshorts-app-api/pom.xml +++ b/fruitshorts-app-api/pom.xml @@ -35,6 +35,10 @@ com.fruitshorts fruitshorts-module-interaction + + com.fruitshorts + fruitshorts-module-commerce + diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommerceController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommerceController.java new file mode 100644 index 0000000..0d1df14 --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommerceController.java @@ -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> wallet() { + Long userId = SecurityUtils.getAppUserId(); + Map 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> unlock(@PathVariable Long episodeId) { + return Result.ok(unlockService.unlock(SecurityUtils.getAppUserId(), episodeId)); + } + + @GetMapping("/orders") + public Result> orders() { + return Result.ok(orderService.listByUser(SecurityUtils.getAppUserId())); + } + + /** 提交商店票据。第一阶段商店未接入,会返回明确的"尚未配置"。 */ + @PostMapping("/orders/verify") + public Result verify(@RequestBody Map body) { + return Result.ok(orderService.verifyAndDeliver(SecurityUtils.getAppUserId(), + body.getOrDefault("platform", ""), body.getOrDefault("receipt", ""))); + } +} diff --git a/fruitshorts-module-commerce/pom.xml b/fruitshorts-module-commerce/pom.xml new file mode 100644 index 0000000..60bec3f --- /dev/null +++ b/fruitshorts-module-commerce/pom.xml @@ -0,0 +1,36 @@ + + + 4.0.0 + + + com.fruitshorts + fruitshorts + 1.0.0-SNAPSHOT + + + fruitshorts-module-commerce + fruitshorts-module-commerce + 交易域:钱包、金币、订单、权益、对账 + + + + com.fruitshorts + fruitshorts-framework + + + com.fruitshorts + fruitshorts-module-content + + + com.fruitshorts + fruitshorts-module-user + + + + com.fruitshorts + fruitshorts-module-playback + + + diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/CoinLedger.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/CoinLedger.java new file mode 100644 index 0000000..3d2729e --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/CoinLedger.java @@ -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; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/Entitlement.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/Entitlement.java new file mode 100644 index 0000000..7c13a39 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/Entitlement.java @@ -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; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/OrderEvent.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/OrderEvent.java new file mode 100644 index 0000000..36e72a6 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/OrderEvent.java @@ -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; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/Orders.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/Orders.java new file mode 100644 index 0000000..625e894 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/Orders.java @@ -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; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/Product.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/Product.java new file mode 100644 index 0000000..11a9474 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/Product.java @@ -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; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/ReconcileTask.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/ReconcileTask.java new file mode 100644 index 0000000..e3268d9 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/ReconcileTask.java @@ -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; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/StoreReceipt.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/StoreReceipt.java new file mode 100644 index 0000000..daadf5e --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/StoreReceipt.java @@ -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; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/Wallet.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/Wallet.java new file mode 100644 index 0000000..4d28780 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/entity/Wallet.java @@ -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; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/CoinLedgerMapper.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/CoinLedgerMapper.java new file mode 100644 index 0000000..0929dde --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/CoinLedgerMapper.java @@ -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 { +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/EntitlementMapper.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/EntitlementMapper.java new file mode 100644 index 0000000..433217e --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/EntitlementMapper.java @@ -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 { +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/OrderEventMapper.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/OrderEventMapper.java new file mode 100644 index 0000000..5e2524b --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/OrderEventMapper.java @@ -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 { +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/OrdersMapper.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/OrdersMapper.java new file mode 100644 index 0000000..632e314 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/OrdersMapper.java @@ -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 { +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/ProductMapper.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/ProductMapper.java new file mode 100644 index 0000000..af9e10d --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/ProductMapper.java @@ -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 { +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/ReconcileTaskMapper.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/ReconcileTaskMapper.java new file mode 100644 index 0000000..853265d --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/ReconcileTaskMapper.java @@ -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 { +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/StoreReceiptMapper.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/StoreReceiptMapper.java new file mode 100644 index 0000000..90abf6c --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/StoreReceiptMapper.java @@ -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 { +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/WalletMapper.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/WalletMapper.java new file mode 100644 index 0000000..a4669fe --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/dal/mapper/WalletMapper.java @@ -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 { + + /** + * 原子增减余额。扣款带 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); +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/DisabledStoreVerifier.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/DisabledStoreVerifier.java new file mode 100644 index 0000000..2bd6cbd --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/DisabledStoreVerifier.java @@ -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 商店校验尚未配置,第一阶段仅预留接口"); + } +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/EntitlementService.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/EntitlementService.java new file mode 100644 index 0000000..092d0cf --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/EntitlementService.java @@ -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.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.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 list = entitlementMapper.selectList(Wrappers.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 listByUser(Long userId) { + return entitlementMapper.selectList(Wrappers.lambdaQuery() + .eq(Entitlement::getUserId, userId) + .orderByDesc(Entitlement::getId)); + } + + public PageResult page(Long userId, String status, String type, + int pageIndex, int pageSize) { + Page page = new Page<>(pageIndex < 1 ? 1 : pageIndex, pageSize < 1 ? 10 : pageSize); + IPage result = entitlementMapper.selectPage(page, + Wrappers.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()); + } +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/OrderService.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/OrderService.java new file mode 100644 index 0000000..ec228a1 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/OrderService.java @@ -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 page(OrderQueryReqVO req) { + Page page = new Page<>( + req.getPageIndex() == null || req.getPageIndex() < 1 ? 1 : req.getPageIndex(), + req.getPageSize() == null || req.getPageSize() < 1 ? 10 : req.getPageSize()); + IPage result = ordersMapper.selectPage(page, Wrappers.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 listByUser(Long userId) { + return ordersMapper.selectList(Wrappers.lambdaQuery() + .eq(Orders::getUserId, userId) + .orderByDesc(Orders::getId)); + } + + public Orders getExisting(String orderNo) { + Orders order = StringUtils.hasText(orderNo) ? ordersMapper.selectOne( + Wrappers.lambdaQuery().eq(Orders::getOrderNo, orderNo).last("limit 1")) : null; + if (order == null) { + throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "订单不存在"); + } + return order; + } + + public List events(String orderNo) { + return orderEventMapper.selectList(Wrappers.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.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); + } +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/ReconcileService.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/ReconcileService.java new file mode 100644 index 0000000..7d0bb11 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/ReconcileService.java @@ -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 = ordersMapper.selectList(Wrappers.lambdaQuery() + .ge(Orders::getCreatedAt, from) + .lt(Orders::getCreatedAt, to) + .eq(!"ALL".equals(scope), Orders::getPlatform, scope)); + List receipts = storeReceiptMapper.selectList(Wrappers.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 page(int pageIndex, int pageSize) { + Page page = new Page<>(pageIndex < 1 ? 1 : pageIndex, pageSize < 1 ? 10 : pageSize); + IPage result = reconcileTaskMapper.selectPage(page, + Wrappers.lambdaQuery().orderByDesc(ReconcileTask::getId)); + return new PageResult<>(result.getTotal(), result.getRecords()); + } +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/StoreVerifier.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/StoreVerifier.java new file mode 100644 index 0000000..7d11a51 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/StoreVerifier.java @@ -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); + } + } +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/UnlockService.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/UnlockService.java new file mode 100644 index 0000000..dfea136 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/UnlockService.java @@ -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 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 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(); + } +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/WalletService.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/WalletService.java new file mode 100644 index 0000000..e5fbdbf --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/service/WalletService.java @@ -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.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.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 recentLedger(Long userId, int limit) { + return ledgerMapper.selectList(Wrappers.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.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; + } +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/CoinAdjustReqVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/CoinAdjustReqVO.java new file mode 100644 index 0000000..47250fa --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/CoinAdjustReqVO.java @@ -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; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/EntitlementGrantReqVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/EntitlementGrantReqVO.java new file mode 100644 index 0000000..97681ff --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/EntitlementGrantReqVO.java @@ -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; +} diff --git a/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/OrderQueryReqVO.java b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/OrderQueryReqVO.java new file mode 100644 index 0000000..a2fe363 --- /dev/null +++ b/fruitshorts-module-commerce/src/main/java/com/fruitshorts/commerce/vo/OrderQueryReqVO.java @@ -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; +} diff --git a/pom.xml b/pom.xml index 6688ab0..1c92ae2 100644 --- a/pom.xml +++ b/pom.xml @@ -27,6 +27,7 @@ fruitshorts-module-user fruitshorts-module-playback fruitshorts-module-interaction + fruitshorts-module-commerce fruitshorts-admin-api fruitshorts-app-api @@ -84,6 +85,11 @@ fruitshorts-module-interaction ${project.version} + + com.fruitshorts + fruitshorts-module-commerce + ${project.version} + com.baomidou diff --git a/sql/data-phase1.sql b/sql/data-phase1.sql index 3b6fe84..0ada34b 100644 --- a/sql/data-phase1.sql +++ b/sql/data-phase1.sql @@ -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; -- 第一阶段新增的白名单数据(当前阶段都是免费内容,先把演示商品占位好)