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