feat: 08 订单与权益落地(金币解锁 / 权益判定生效 / 补发回收 / 对账)
为什么: 1) 第一阶段要能真正收到钱,而收钱方式是金币解锁分集。本提交把钱包、金币流水、 权益、订单四样打通,客户端可以解锁并立刻播放,不再只是展示。 2) 权益判定此前只是端口占位(一律视为未解锁)。交易模块用 @Primary 提供实现后, 播放入口与下载入口的付费判定同时生效 —— 两条链路一行没改。 3) 钱的事必须可追溯、可幂等:所有金币变动都带幂等键并写 coin_ledger, 余额与流水同事务;订单每次状态流转都写 order_event(月分区),支付争议可回溯。 4) 后台要能兜住线上问题:补发(重复提交幂等)、退款(回收权益 + 扣回金币, 余额不足时留人工跟进备注)、人工补币(先校验用户存在,避免给不存在的用户建钱包)、 手动对账(默认每天 04:20 自动跑前一天)。 5) 09 商店支付按约束只做端口预留:StoreVerifier 未接入时明确报"尚未配置", 宁可不收钱也不产生假订单。
This commit is contained in:
1 parent
af1dc48494
commit
7e730fbbca
37 files changed
+1467
-5
No files matched your search
@@ -39,6 +39,10 @@
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-module-interaction</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-module-commerce</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.fruitshorts.admin.controller;
|
||||
|
||||
import com.fruitshorts.commerce.dal.entity.Entitlement;
|
||||
import com.fruitshorts.commerce.service.EntitlementService;
|
||||
import com.fruitshorts.commerce.vo.EntitlementGrantReqVO;
|
||||
import com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.framework.log.OperationLog;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** 后台权益:检索、人工补发、回收。 */
|
||||
@RestController
|
||||
@RequestMapping("/admin/entitlements")
|
||||
@RequiredArgsConstructor
|
||||
public class EntitlementAdminController {
|
||||
|
||||
private final EntitlementService entitlementService;
|
||||
|
||||
@GetMapping("/page")
|
||||
@PreAuthorize("@ss.hasPermission('entitlement:list')")
|
||||
public Result<PageResult<Entitlement>> page(@RequestParam(required = false) Long userId,
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false) String type,
|
||||
@RequestParam(defaultValue = "1") Integer pageIndex,
|
||||
@RequestParam(defaultValue = "10") Integer pageSize) {
|
||||
return Result.ok(entitlementService.page(userId, status, type, pageIndex, pageSize));
|
||||
}
|
||||
|
||||
/** 客服补偿:直接给用户某集/整剧的播放权益。 */
|
||||
@PostMapping
|
||||
@PreAuthorize("@ss.hasPermission('entitlement:update')")
|
||||
@OperationLog(module = "订单管理", action = "人工补发权益")
|
||||
public Result<Entitlement> grant(@Valid @RequestBody EntitlementGrantReqVO req) {
|
||||
return Result.ok(entitlementService.grant(req.getUserId(), req.getDramaId(),
|
||||
req.getEpisodeId() == null ? 0L : req.getEpisodeId(),
|
||||
req.getType() == null ? EntitlementService.TYPE_EPISODE : req.getType(),
|
||||
"ADMIN", ""));
|
||||
}
|
||||
|
||||
/** 回收权益:退款、违规处置后用。 */
|
||||
@PutMapping("/{id}/revoke")
|
||||
@PreAuthorize("@ss.hasPermission('entitlement:update')")
|
||||
@OperationLog(module = "订单管理", action = "回收权益")
|
||||
public Result<Void> revoke(@PathVariable Long id,
|
||||
@RequestParam(required = false) String reason) {
|
||||
entitlementService.revoke(id, reason);
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.fruitshorts.admin.controller;
|
||||
|
||||
import com.fruitshorts.commerce.dal.entity.OrderEvent;
|
||||
import com.fruitshorts.commerce.dal.entity.Orders;
|
||||
import com.fruitshorts.commerce.service.OrderService;
|
||||
import com.fruitshorts.commerce.vo.OrderQueryReqVO;
|
||||
import com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.framework.log.OperationLog;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 后台订单:检索、补发、退款回收。 */
|
||||
@RestController
|
||||
@RequestMapping("/admin/orders")
|
||||
@RequiredArgsConstructor
|
||||
public class OrderAdminController {
|
||||
|
||||
private final OrderService orderService;
|
||||
|
||||
@GetMapping("/page")
|
||||
@PreAuthorize("@ss.hasPermission('order:list')")
|
||||
public Result<PageResult<Orders>> page(OrderQueryReqVO req) {
|
||||
return Result.ok(orderService.page(req));
|
||||
}
|
||||
|
||||
@GetMapping("/{orderNo}")
|
||||
@PreAuthorize("@ss.hasPermission('order:list')")
|
||||
public Result<Orders> detail(@PathVariable String orderNo) {
|
||||
return Result.ok(orderService.getExisting(orderNo));
|
||||
}
|
||||
|
||||
/** 订单状态流转流水:支付争议时的证据链。 */
|
||||
@GetMapping("/{orderNo}/events")
|
||||
@PreAuthorize("@ss.hasPermission('order:list')")
|
||||
public Result<List<OrderEvent>> events(@PathVariable String orderNo) {
|
||||
return Result.ok(orderService.events(orderNo));
|
||||
}
|
||||
|
||||
/** 补发:已支付未交付的订单重新交付;已交付时幂等返回。 */
|
||||
@PostMapping("/{orderNo}/compensate")
|
||||
@PreAuthorize("@ss.hasPermission('order:update')")
|
||||
@OperationLog(module = "订单管理", action = "补发订单")
|
||||
public Result<Orders> compensate(@PathVariable String orderNo) {
|
||||
return Result.ok(orderService.compensate(orderNo, SecurityUtils.getUserId()));
|
||||
}
|
||||
|
||||
/** 退款/撤销:回收权益并扣回金币。 */
|
||||
@PostMapping("/{orderNo}/refund")
|
||||
@PreAuthorize("@ss.hasPermission('order:update')")
|
||||
@OperationLog(module = "订单管理", action = "订单退款")
|
||||
public Result<Orders> refund(@PathVariable String orderNo,
|
||||
@RequestBody(required = false) Map<String, String> body) {
|
||||
String reason = body == null ? "" : body.getOrDefault("reason", "");
|
||||
return Result.ok(orderService.refund(orderNo, reason, SecurityUtils.getUserId()));
|
||||
}
|
||||
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.fruitshorts.admin.controller;
|
||||
|
||||
import com.fruitshorts.commerce.dal.entity.ReconcileTask;
|
||||
import com.fruitshorts.commerce.service.ReconcileService;
|
||||
import com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.framework.log.OperationLog;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** 后台对账:手动触发与历史查看。 */
|
||||
@RestController
|
||||
@RequestMapping("/admin/reconcile")
|
||||
@RequiredArgsConstructor
|
||||
public class ReconcileAdminController {
|
||||
|
||||
private final ReconcileService reconcileService;
|
||||
|
||||
@GetMapping("/page")
|
||||
@PreAuthorize("@ss.hasPermission('order:list')")
|
||||
public Result<PageResult<ReconcileTask>> page(@RequestParam(defaultValue = "1") Integer pageIndex,
|
||||
@RequestParam(defaultValue = "10") Integer pageSize) {
|
||||
return Result.ok(reconcileService.page(pageIndex, pageSize));
|
||||
}
|
||||
|
||||
/** 手动对账:默认前一天,可指定日期与渠道。 */
|
||||
@PostMapping("/run")
|
||||
@PreAuthorize("@ss.hasPermission('reconcile:run')")
|
||||
@OperationLog(module = "订单管理", action = "手动对账")
|
||||
public Result<ReconcileTask> run(@RequestParam(required = false) String bizDate,
|
||||
@RequestParam(required = false) String platform) {
|
||||
LocalDate date = bizDate == null || bizDate.isBlank() ? null : LocalDate.parse(bizDate);
|
||||
return Result.ok(reconcileService.run(date, platform));
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.fruitshorts.admin.controller;
|
||||
|
||||
import com.fruitshorts.commerce.dal.entity.CoinLedger;
|
||||
import com.fruitshorts.commerce.service.WalletService;
|
||||
import com.fruitshorts.commerce.vo.CoinAdjustReqVO;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.framework.log.OperationLog;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.user.dal.mapper.AppUserMapper;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 后台钱包:人工补币/扣回,全部进流水且必须带幂等键。 */
|
||||
@RestController
|
||||
@RequestMapping("/admin/wallets")
|
||||
@RequiredArgsConstructor
|
||||
public class WalletAdminController {
|
||||
|
||||
private final WalletService walletService;
|
||||
private final AppUserMapper appUserMapper;
|
||||
|
||||
@GetMapping("/{userId}/ledger")
|
||||
@PreAuthorize("@ss.hasPermission('order:list')")
|
||||
public Result<List<CoinLedger>> ledger(@PathVariable Long userId,
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
return Result.ok(walletService.recentLedger(userId, limit));
|
||||
}
|
||||
|
||||
@PostMapping("/coins")
|
||||
@PreAuthorize("@ss.hasPermission('wallet:update')")
|
||||
@OperationLog(module = "订单管理", action = "人工补币")
|
||||
public Result<CoinLedger> adjust(@Valid @RequestBody CoinAdjustReqVO req) {
|
||||
// 必须先确认用户存在:否则会凭空给一个不存在的 userId 建钱包,
|
||||
// 留下对不上账的脏数据(自测时确实踩到了这个坑)。
|
||||
if (appUserMapper.selectById(req.getUserId()) == null) {
|
||||
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "用户不存在,无法补币");
|
||||
}
|
||||
String key = req.getIdempotentKey() == null || req.getIdempotentKey().isBlank()
|
||||
? "admin:" + System.currentTimeMillis() + ":" + req.getUserId()
|
||||
: req.getIdempotentKey();
|
||||
return Result.ok(walletService.change(req.getUserId(), req.getAmount(),
|
||||
WalletService.BIZ_ADMIN, "", key,
|
||||
req.getRemark() == null ? "人工调整" : req.getRemark(),
|
||||
false, SecurityUtils.getUserId()));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user