feat(trade): 积分充值 P0 骨架(复用 payout mock-gated 渠道 SPI 范式·真支付收单 P1 受日历闸门) (W-REAL R2)

补 55 P0 最后一块结构缺口。MVP 不接真实支付收单(trade.yaml:支付收单=P1、归 pay 日历闸门)——充值渠道复用
game-module-trade 既有 payout 那套「mock-gated 渠道 SPI」范式,而非接入 12 表重 huijing-module-pay-server
(无 game 消费者、无 Flyway schema、Alipay/微信 SDK 重、P1 缓)。

- 充值渠道 SPI(镜像 PayoutClient):ChargeClient/MockChargeClient/ChargeClientFactory 按 Nacos trade.charge-channel
  路由(默认 mock),mock 同步直通即支付成功入账;真实 wxpay/alipay 未注册时工厂 fail-fast(TRADE_CHARGE_CHANNEL_UNAVAILABLE),
  绝不静默降级 mock 收假单(与 payout「假打款吞真钱」红线同构)。
- V28 game_trade_charge_order:充值单(status 0待支付→10已支付 CAS 入账),uk_biz_no 幂等,账本自包含
  (积分余额=Σ 已支付单 points),不污染创作者收益账户(game_trade_account)不变式。
- ChargeService:创建幂等(bizNo 去重 + uk 兜底并发)→ 发起收单 → mock 同步 CAS 置已支付 + 积分入账;整单同事务。
- app-api 薄骨架:POST /trade/charge/create + GET /trade/charge/points-balance(userId 由 token 解析防越权)。

验证:trade 模块单测绿(新增 ChargeChannelTest 3[mock契约+工厂路由+fail-fast红线] + ChargeServiceImplTest 5
[mock流程+幂等+校验+余额];既有 Account16/Withdraw18/Subscription13 等全不变);连真 MySQL charge mock e2e IT 绿
(V28 DDL+创建→CAS入账→积分余额150→bizNo幂等拒绝);Flyway 全绿(28/28 迁移含 V27+V28 按版本序真库全应用,61 语句);
huijing-server 单体编译 BUILD SUCCESS。Flyway 版本核对 V27→取 V28。

范围说明:「pay 模块接单体」按项目既有模式(game 模块自带 mock-gated 渠道 SPI、huijing pay 保持解耦)解读为复用 pay
模式而非全量接入重 pay-server;若需真接 12 表 pay-server 请复核(见交付报告)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-02 20:46:54 -07:00
parent 617abbb589
commit d32d5965fb
17 changed files with 958 additions and 0 deletions

View File

@ -73,4 +73,12 @@ public interface ErrorCodeConstants {
/** 订阅赋值幂等冲突(同一 bizNo 并发提交,唯一键 uk_biz_no 冲突且回查不到——非幂等的并发异常) */
ErrorCode TRADE_SUBSCRIPTION_BIZ_NO_CONFLICT = new ErrorCode(1_106_006_002, "订阅赋值提交冲突,请重试");
// ========== 积分充值 1-106-007-***P0 骨架mock-gated 收单真实支付收单 P1 受日历闸门==========
/** 充值金额/积分非法amount ≤ 0 或 points ≤ 0充值只增不减必须为正 */
ErrorCode TRADE_CHARGE_AMOUNT_INVALID = new ErrorCode(1_106_007_000, "充值金额与积分必须大于 0");
/** 充值幂等冲突(同一 bizNo 并发提交,唯一键 uk_biz_no 冲突且回查不到——非幂等的并发异常) */
ErrorCode TRADE_CHARGE_BIZ_NO_CONFLICT = new ErrorCode(1_106_007_001, "充值提交冲突,请重试");
/** 充值渠道不可用Nacos trade.charge-channel 配的渠道未注册实现,如真实 wxpay/alipay 未接入fail-fast 受日历闸门,绝不静默降级 mock 发假单) */
ErrorCode TRADE_CHARGE_CHANNEL_UNAVAILABLE = new ErrorCode(1_106_007_002, "充值渠道不可用,请稍后重试");
}

View File

@ -0,0 +1,58 @@
package com.wanxiang.huijing.game.module.trade.controller.app;
import com.wanxiang.huijing.game.module.trade.controller.app.vo.ChargeCreateReqVO;
import com.wanxiang.huijing.game.module.trade.controller.app.vo.ChargeOrderRespVO;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.ChargeOrderDO;
import com.wanxiang.huijing.game.module.trade.service.charge.ChargeService;
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
import com.wanxiang.huijing.framework.common.util.object.BeanUtils;
import com.wanxiang.huijing.framework.security.core.util.SecurityFrameworkUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
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 static com.wanxiang.huijing.framework.common.pojo.CommonResult.success;
/**
* 产品端game-studio- 积分充值控制器P0 骨架
*
* <p>端前缀 /app-api 由框架按包名 controller.app.* 自动添加鉴权需用户 TokenuserId token 解析
* 用户只能给自己充值只见自己积分数据边界在 Service 强制真实支付收单 P1 受日历闸门
* MVP mock 渠道同步直通即入账Nacos trade.charge-channel=mock真实渠道未接入时 Service 侧工厂 fail-fast
*
* @author 绘境AI
*/
@Tag(name = "用户 App - 积分充值")
@RestController
@RequestMapping("/trade/charge")
@Validated
public class AppChargeController {
@Resource
private ChargeService chargeService;
@PostMapping("/create")
@Operation(summary = "创建积分充值单", description = "mock 渠道同步直通:创建即支付成功并积分入账;幂等以 bizNo 去重")
public CommonResult<ChargeOrderRespVO> createCharge(@Valid @RequestBody ChargeCreateReqVO reqVO) {
// userId token 解析用户只能给自己充值不接受前端传入 userId防越权
Long userId = SecurityFrameworkUtils.getLoginUserId();
ChargeOrderDO order = chargeService.createCharge(
userId, reqVO.getAmount(), reqVO.getPoints(), reqVO.getBizNo(), reqVO.getRemark());
return success(BeanUtils.toBean(order, ChargeOrderRespVO.class));
}
@GetMapping("/points-balance")
@Operation(summary = "查我的积分余额", description = "= Σ 本人已支付充值单的 points账本自包含消耗留后续")
public CommonResult<Long> getPointsBalance() {
Long userId = SecurityFrameworkUtils.getLoginUserId();
return success(chargeService.getPointsBalance(userId));
}
}

View File

@ -0,0 +1,35 @@
package com.wanxiang.huijing.game.module.trade.controller.app.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Data;
/**
* 积分充值创建 Request VOP0 骨架
*
* @author 绘境AI
*/
@Schema(description = "产品端 - 积分充值创建 Request VO")
@Data
public class ChargeCreateReqVO {
@Schema(description = "充值金额(单位:分,>0", requiredMode = Schema.RequiredMode.REQUIRED, example = "1000")
@NotNull(message = "充值金额不能为空")
@Positive(message = "充值金额必须大于 0")
private Long amount;
@Schema(description = "充值获得积分(单位:点,>0", requiredMode = Schema.RequiredMode.REQUIRED, example = "100")
@NotNull(message = "充值积分不能为空")
@Positive(message = "充值积分必须大于 0")
private Long points;
@Schema(description = "充值业务单号 = 幂等键(客户端生成唯一)", requiredMode = Schema.RequiredMode.REQUIRED, example = "charge-20260702-abc")
@NotBlank(message = "充值业务单号不能为空")
private String bizNo;
@Schema(description = "备注(充值说明,可空)", example = "充值 100 积分")
private String remark;
}

View File

@ -0,0 +1,38 @@
package com.wanxiang.huijing.game.module.trade.controller.app.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 积分充值单 Response VOP0 骨架
*
* @author 绘境AI
*/
@Schema(description = "产品端 - 积分充值单 Response VO")
@Data
public class ChargeOrderRespVO {
@Schema(description = "充值单 ID", example = "1024")
private Long id;
@Schema(description = "充值金额(单位:分)", example = "1000")
private Long amount;
@Schema(description = "充值获得积分(单位:点)", example = "100")
private Long points;
@Schema(description = "充值渠道码mock/wxpay/alipay", example = "mock")
private String channel;
@Schema(description = "状态0待支付 10已支付 20已取消/失败", example = "10")
private Integer status;
@Schema(description = "渠道支付单号(供对账)", example = "mock-charge-1024-charge-20260702-abc")
private String payRef;
@Schema(description = "支付成功时间(已支付时有值)")
private LocalDateTime payTime;
}

View File

@ -0,0 +1,47 @@
package com.wanxiang.huijing.game.module.trade.dal.dataobject;
import com.wanxiang.huijing.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 积分充值单 DO对应表 game_trade_charge_order积分充值 P0 骨架
*
* <p>继承 {@link TenantBaseDO} 自动携带 creator/create_time/updater/update_time/deleted + tenant_id
* 状态机0待支付 10已支付(积分入账) / 20已取消或失败金额/积分一律/BIGINT 禁浮点
* 幂等uk_biz_no用户积分余额 = Σ 本人 status=10 points账本自包含不污染 game_trade_account 不变式
*
* @author 绘境AI
*/
@TableName("game_trade_charge_order")
@KeySequence("game_trade_charge_order_seq")
@Data
@EqualsAndHashCode(callSuper = true)
public class ChargeOrderDO extends TenantBaseDO {
/** 充值单 ID */
private Long id;
/** 充值用户 IDDataPermission用户只见自己的充值单 */
private Long userId;
/** 充值金额(单位:分,>0 */
private Long amount;
/** 充值获得积分(单位:点,>0 */
private Long points;
/** 充值渠道码Nacos trade.charge-channelmock/wxpay/alipay */
private String channel;
/** 状态机0待支付 10已支付(积分入账) 20已取消/失败 */
private Integer status;
/** 充值业务单号 = 幂等键uk_biz_no */
private String bizNo;
/** 渠道支付单号mock 为本地 mock 单号;真实渠道为收单单号,供对账) */
private String payRef;
/** 支付成功时间status 置 10 时写入) */
private LocalDateTime payTime;
/** 备注(充值说明,可空) */
private String remark;
}

View File

@ -0,0 +1,60 @@
package com.wanxiang.huijing.game.module.trade.dal.mysql;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.ChargeOrderDO;
import com.wanxiang.huijing.framework.mybatis.core.mapper.BaseMapperX;
import com.wanxiang.huijing.framework.mybatis.core.query.LambdaQueryWrapperX;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
/**
* 积分充值单 Mapper收单骨架
*
* <p>幂等靠 {@link #selectByBizNo}uk_biz_no 回查已支付置位靠 {@link #markPaidCas}status 010 CAS恰一次入账
* 用户积分余额靠 {@link #sumPaidPoints}Σ status=10 points账本自包含
*
* @author 绘境AI
*/
@Mapper
public interface ChargeOrderMapper extends BaseMapperX<ChargeOrderDO> {
/**
* 按业务单号取充值单幂等回查同一 bizNo 命中即返回已有单不重复收单
*
* @param bizNo 充值业务单号
* @return 充值单不存在返回 null
*/
default ChargeOrderDO selectByBizNo(String bizNo) {
return selectOne(new LambdaQueryWrapperX<ChargeOrderDO>()
.eq(ChargeOrderDO::getBizNo, bizNo));
}
/**
* CAS 置已支付status 010恰一次入账去重仅当当前为 0待支付 才置 10已支付 + pay_ref/pay_time
*
* <p>返回受影响行数保证并发/重放下同一单只入账一次1=本次置位成功调用方据此视为入账完成
* 0=已被置位或非待支付态幂等跳过
*
* @param id 充值单 ID
* @param payRef 渠道支付单号
* @param payTime 支付成功时间
* @return 受影响行数1=首次置位0=已置位/非待支付
*/
@Update("UPDATE game_trade_charge_order SET status = 10, pay_ref = #{payRef}, pay_time = #{payTime} "
+ "WHERE id = #{id} AND status = 0")
int markPaidCas(@Param("id") Long id, @Param("payRef") String payRef, @Param("payTime") LocalDateTime payTime);
/**
* 用户积分余额 = Σ 本人已支付(status=10)充值单的 points账本自包含不含消耗消耗留后续
*
* @param userId 用户 ID
* @return 积分余额无已支付单返回 0
*/
@Select("SELECT IFNULL(SUM(points), 0) FROM game_trade_charge_order "
+ "WHERE user_id = #{userId} AND status = 10 AND deleted = 0")
long sumPaidPoints(@Param("userId") Long userId);
}

View File

@ -0,0 +1,43 @@
package com.wanxiang.huijing.game.module.trade.framework.charge;
/**
* 充值收单渠道 SPIChargeClient统一形态屏蔽 mock/wxpay/alipay 差异业务码只认渠道码不认具体收单钱包
*
* <p>对应 payout {@code PayoutClient} 的镜像范式复用 huijing/yudao pay 模式最小改动
* ChargeService 只调本 SPI {@link #initiateCharge} 发起收单不在业务层散落微信/支付宝分支
* 渠道由 {@link ChargeClientFactory} Nacos {@code trade.charge-channel} 选择默认 mock切渠道=改配置业务码不变
*
* <p><b>mock-gated受日历闸门</b>MVP 不接真实支付收单trade.yaml支付收单=P1
* mock 渠道 = 同步直通发起即支付成功本进程直接入账真实 wxpay/alipay 渠道未接入时工厂 fail-fast
* {@code TRADE_CHARGE_CHANNEL_UNAVAILABLE}绝不静默降级 mock 收假单 payout假打款吞真钱红线同构收单侧=收假钱
*
* @author 绘境AI
*/
public interface ChargeClient {
/**
* 本实现对应的渠道码mock/wxpay/alipay Nacos trade.charge-channel 取值一致
*
* @return 渠道码
*/
String getChannel();
/**
* 是否同步直通渠道发起即支付成功本进程直接入账
*
* <p>mock=true同步直通无外部异步回调wxpay/alipay=false等收单 notify 回调驱动终态
* ChargeService 据此决定同步渠道发起后本进程直接置已支付 + 入账异步渠道仅发起等外部回调MVP 不涉及
*
* @return true=同步直通false=异步回调
*/
boolean isSynchronous();
/**
* 发起收单同步渠道发起即成功异步渠道仅发起终态等回调
*
* @param req 收单发起请求充值单 id / 金额 / 积分 / 用户 / 业务单号
* @return 发起受理结果含渠道支付单号 payRef发起失败抛 ServiceException调用方 fail-fast不静默降级
*/
ChargeResult initiateCharge(ChargeRequest req);
}

View File

@ -0,0 +1,74 @@
package com.wanxiang.huijing.game.module.trade.framework.charge;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import static com.wanxiang.huijing.game.module.trade.enums.ErrorCodeConstants.TRADE_CHARGE_CHANNEL_UNAVAILABLE;
import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception;
/**
* ChargeClient 工厂 Nacos trade.charge-channel 路由到对应充值收单渠道 SPI 实现镜像 PayoutClientFactory
*
* <p>Spring 注入全部 {@link ChargeClient} 实现 getChannel() 建索引渠道选择走 Nacostrade.charge-channel默认 mock
* ChargeService 只问工厂要当前渠道客户端不认具体收单钱包切渠道=改配置业务码零分支
*
* <p><b>降级红线 payout 同构</b>配置渠道未命中实现时 <b>fail-fast 抛错挂起</b>
* {@code TRADE_CHARGE_CHANNEL_UNAVAILABLE}<b>严禁静默兜底到 mock</b>真实 wxpay/alipay 未接入时收假单
* = 收假钱/给假积分必须挂起受日历闸门支付资质下证前真实渠道不注册mock {@link MockChargeClient} 提供MVP 默认
*
* @author 绘境AI
*/
@Component
public class ChargeClientFactory {
private static final Logger log = LoggerFactory.getLogger(ChargeClientFactory.class);
/**
* 当前充值渠道走 Nacostrade.charge-channel默认 mockMVP
* @Value 占位符默认值仅为Nacos 未配置时的兜底线上以 Nacos 为准真实化时改为 wxpay/alipay 即切业务码零分支
*/
@Value("${trade.charge-channel:mock}")
private String chargeChannel;
/** 渠道码 → 实现,应用启动时一次性构建 */
private final Map<String, ChargeClient> clientMap;
public ChargeClientFactory(List<ChargeClient> clients) {
this.clientMap = clients.stream()
.collect(Collectors.toMap(ChargeClient::getChannel, Function.identity()));
}
/**
* 取当前 Nacos 配置渠道的充值收单客户端
*
* <p>红线配置渠道未注册实现 fail-fast {@code TRADE_CHARGE_CHANNEL_UNAVAILABLE}严禁静默降级 mock 收假单
*
* @return 当前渠道 ChargeClient保证非 null否则已抛错
*/
public ChargeClient current() {
ChargeClient client = clientMap.get(chargeChannel);
if (client == null) {
log.error("[ChargeClientFactory] 充值渠道不可用 channel={} 已注册渠道={}fail-fast不降级 mock",
chargeChannel, clientMap.keySet());
throw exception(TRADE_CHARGE_CHANNEL_UNAVAILABLE);
}
return client;
}
/**
* 当前配置的渠道码 charge_order.channel
*
* @return 渠道码来自 Nacos trade.charge-channel
*/
public String currentChannel() {
return chargeChannel;
}
}

View File

@ -0,0 +1,26 @@
package com.wanxiang.huijing.game.module.trade.framework.charge;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* 充值收单发起请求充值渠道 SPI 入参屏蔽 mock/wxpay/alipay 差异
*
* @author 绘境AI
*/
@Data
@AllArgsConstructor
public class ChargeRequest {
/** 充值单 ID已落库 game_trade_charge_order.id */
private Long chargeId;
/** 充值业务单号(幂等键,落 pay_ref 拼接可对账) */
private String bizNo;
/** 充值用户 ID */
private Long userId;
/** 充值金额(单位:分,>0 */
private Long amount;
/** 充值获得积分(单位:点,>0 */
private Long points;
}

View File

@ -0,0 +1,23 @@
package com.wanxiang.huijing.game.module.trade.framework.charge;
import lombok.Data;
/**
* 充值收单发起受理结果充值渠道 SPI 出参
*
* <p>发起受理结果含渠道支付单号 {@code payRef}mock 同步直通渠道发起即支付成功
* 真实异步渠道wxpay/alipay发起后终态由 notify 回调驱动MVP 未接入受日历闸门
*
* @author 绘境AI
*/
@Data
public class ChargeResult {
/** 渠道支付单号mock 为本地 mock 单号;真实渠道为收单单号,落 pay_ref 供对账) */
private final String payRef;
public ChargeResult(String payRef) {
this.payRef = payRef;
}
}

View File

@ -0,0 +1,45 @@
package com.wanxiang.huijing.game.module.trade.framework.charge;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/**
* mock 充值收单渠道实现MVP 默认 trade.charge-channel=mock
*
* <p>同步直通发起收单即受理成功返回本地 mock 支付单号终态不在本类置 ChargeService 在发起后本进程
* 同步驱动 status 010已支付+ 积分入账保持与异步渠道一致的状态机路径不抄近路直接置 10
*
* <p>真实渠道wxpay/alipay走支付资质下证 + pay 进件后由独立 ChargeClient 实现注入本类不变工厂按 Nacos
* trade.charge-channel 路由mock 为默认兜底真实渠道缺失 fail-fast不静默降级 mock 收假单
*
* @author 绘境AI
*/
@Component
public class MockChargeClient implements ChargeClient {
private static final Logger log = LoggerFactory.getLogger(MockChargeClient.class);
/** 渠道码mock */
public static final String CHANNEL = "mock";
@Override
public String getChannel() {
return CHANNEL;
}
@Override
public boolean isSynchronous() {
return true; // mock 同步直通发起后本进程直接驱动已支付 + 入账
}
@Override
public ChargeResult initiateCharge(ChargeRequest req) {
// mock 支付单号以充值单 id + bizNo 拼接保证同一充值单稳定可对账非真实渠道单号
String mockRef = "mock-charge-" + req.getChargeId() + "-" + req.getBizNo();
log.info("[MockChargeClient] mock 发起收单同步直通chargeId={} bizNo={} amount={} points={} payRef={}",
req.getChargeId(), req.getBizNo(), req.getAmount(), req.getPoints(), mockRef);
return new ChargeResult(mockRef);
}
}

View File

@ -0,0 +1,36 @@
package com.wanxiang.huijing.game.module.trade.service.charge;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.ChargeOrderDO;
/**
* 积分充值 ServiceP0 骨架mock-gated 收单
*
* <p>创建充值单 经充值渠道 SPI{@code ChargeClientFactory}Nacos trade.charge-channel默认 mock发起收单
* mock 同步直通即置已支付 + 积分入账真实 wxpay/alipay 渠道未接入时工厂 fail-fast受日历闸门不收假单
* 幂等 bizNo 命中 uk_biz_no 去重用户积分余额 = Σ 本人已支付充值单 points账本自包含
*
* @author 绘境AI
*/
public interface ChargeService {
/**
* 创建并支付充值单幂等mock 渠道同步直通创建即支付成功 + 积分入账
*
* @param userId 充值用户 ID
* @param amount 充值金额单位>0
* @param points 充值获得积分单位>0
* @param bizNo 充值业务单号 = 幂等键调用端生成唯一
* @param remark 备注可空
* @return 充值单mock 同步渠道下为已支付态幂等命中返回已有单
*/
ChargeOrderDO createCharge(Long userId, Long amount, Long points, String bizNo, String remark);
/**
* 用户积分余额 = Σ 本人已支付(status=10)充值单 points账本自包含不含消耗消耗留后续
*
* @param userId 用户 ID
* @return 积分余额无已支付单返回 0
*/
long getPointsBalance(Long userId);
}

View File

@ -0,0 +1,119 @@
package com.wanxiang.huijing.game.module.trade.service.charge;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.ChargeOrderDO;
import com.wanxiang.huijing.game.module.trade.dal.mysql.ChargeOrderMapper;
import com.wanxiang.huijing.game.module.trade.framework.charge.ChargeClient;
import com.wanxiang.huijing.game.module.trade.framework.charge.ChargeClientFactory;
import com.wanxiang.huijing.game.module.trade.framework.charge.ChargeRequest;
import com.wanxiang.huijing.game.module.trade.framework.charge.ChargeResult;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import static com.wanxiang.huijing.game.module.trade.enums.ErrorCodeConstants.TRADE_CHARGE_AMOUNT_INVALID;
import static com.wanxiang.huijing.game.module.trade.enums.ErrorCodeConstants.TRADE_CHARGE_BIZ_NO_CONFLICT;
import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception;
/**
* 积分充值 Service 实现P0 骨架mock-gated 收单
*
* <p>状态机常量0待支付 10已支付(入账) / 20已取消mock 渠道同步直通创建后本进程直接驱动 010CAS
* 资金安全与可对账同 payout 侧红线真实渠道未接入时工厂 fail-fast不静默降级 mock 收假单幂等以 bizNo 去重
*
* @author 绘境AI
*/
@Slf4j
@Service
public class ChargeServiceImpl implements ChargeService {
/** 状态:待支付(创建初值) */
private static final int STATUS_CREATED = 0;
/** 状态:已支付(积分入账) */
static final int STATUS_PAID = 10;
@Resource
private ChargeOrderMapper chargeOrderMapper;
@Resource
private ChargeClientFactory chargeClientFactory;
@Override
@Transactional(rollbackFor = Exception.class) // 创建+发起收单+同步入账在同一本地事务任一步失败整单回滚不留脏单
public ChargeOrderDO createCharge(Long userId, Long amount, Long points, String bizNo, String remark) {
// 参数校验充值只增不减金额与积分必须 > 0
if (amount == null || amount <= 0 || points == null || points <= 0) {
throw exception(TRADE_CHARGE_AMOUNT_INVALID);
}
// 幂等回查同一 bizNo 命中即返回已有单不重复收单不重复入账
ChargeOrderDO existing = chargeOrderMapper.selectByBizNo(bizNo);
if (existing != null) {
log.info("[charge] 幂等命中,返回已有充值单 bizNo={} chargeId={} status={}", bizNo, existing.getId(), existing.getStatus());
return existing;
}
// 取当前渠道客户端Nacos trade.charge-channel默认 mock真实渠道未注册 fail-fast绝不静默降级 mock 收假单
ChargeClient client = chargeClientFactory.current();
String channel = chargeClientFactory.currentChannel();
// 创建充值单status=待支付uk_biz_no 兜底并发冲突则回查返回已有单保证幂等
ChargeOrderDO order = new ChargeOrderDO();
order.setUserId(userId);
order.setAmount(amount);
order.setPoints(points);
order.setChannel(channel);
order.setStatus(STATUS_CREATED);
order.setBizNo(bizNo);
order.setPayRef("");
order.setRemark(StringUtils.hasText(remark) ? remark : "");
try {
chargeOrderMapper.insert(order);
} catch (DuplicateKeyException dup) {
// 并发同 bizNo另一线程已插入 回查返回已有单幂等回查不到则为异常并发抛冲突
ChargeOrderDO concurrent = chargeOrderMapper.selectByBizNo(bizNo);
if (concurrent != null) {
log.info("[charge] 并发同 bizNo返回已插入充值单 bizNo={} chargeId={}", bizNo, concurrent.getId());
return concurrent;
}
log.error("[charge] 充值单 uk_biz_no 冲突但回查不到 bizNo={}", bizNo, dup);
throw exception(TRADE_CHARGE_BIZ_NO_CONFLICT);
}
// 发起收单外部交互留痕mock 同步直通返回 mock 支付单号
ChargeResult result = client.initiateCharge(new ChargeRequest(order.getId(), bizNo, userId, amount, points));
// 同步渠道mock发起即支付成功 CAS 置已支付 + 积分入账账本自包含已支付单即入账凭据
if (client.isSynchronous()) {
LocalDateTime payTime = LocalDateTime.now();
int paid = chargeOrderMapper.markPaidCas(order.getId(), result.getPayRef(), payTime);
if (paid > 0) {
order.setStatus(STATUS_PAID);
order.setPayRef(result.getPayRef());
order.setPayTime(payTime);
log.info("[charge] mock 同步充值成功入账 chargeId={} userId={} amount={} points={} payRef={}",
order.getId(), userId, amount, points, result.getPayRef());
} else {
// 极端并发CAS 未命中已被置位回读当前态返回不重复入账
ChargeOrderDO refreshed = chargeOrderMapper.selectById(order.getId());
if (refreshed != null) {
return refreshed;
}
}
} else {
// 异步渠道wxpay/alipayMVP 未接入仅发起 pay_ref终态等收单 notify 回调驱动本骨架不涉及
order.setPayRef(result.getPayRef());
log.info("[charge] 异步渠道发起收单,待 notify 驱动终态 chargeId={} channel={} payRef={}",
order.getId(), channel, result.getPayRef());
}
return order;
}
@Override
public long getPointsBalance(Long userId) {
return chargeOrderMapper.sumPaidPoints(userId);
}
}

View File

@ -0,0 +1,52 @@
package com.wanxiang.huijing.game.module.trade.framework.charge;
import com.wanxiang.huijing.framework.common.exception.ServiceException;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static com.wanxiang.huijing.game.module.trade.enums.ErrorCodeConstants.TRADE_CHARGE_CHANNEL_UNAVAILABLE;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 充值渠道 SPI 单元测试MockChargeClient 契约 + ChargeClientFactory 路由与 fail-fast 红线镜像 payout
*
* @author 绘境AI
*/
class ChargeChannelTest {
/** MockChargeClient 契约channel=mock、同步直通、payRef 稳定可对账(含 chargeId+bizNo。 */
@Test
void testMockChargeClient_contract() {
MockChargeClient client = new MockChargeClient();
assertEquals("mock", client.getChannel());
assertTrue(client.isSynchronous(), "mock 应同步直通");
ChargeResult r = client.initiateCharge(new ChargeRequest(1024L, "biz-1", 100L, 1000L, 100L));
assertEquals("mock-charge-1024-biz-1", r.getPayRef(), "mock 支付单号应含 chargeId+bizNo 稳定可对账");
}
/** 工厂路由Nacos trade.charge-channel=mock → 命中 MockChargeClient。 */
@Test
void testFactory_current_routesToMock() {
MockChargeClient mock = new MockChargeClient();
ChargeClientFactory factory = new ChargeClientFactory(List.of(mock));
ReflectionTestUtils.setField(factory, "chargeChannel", "mock");
assertSame(mock, factory.current());
assertEquals("mock", factory.currentChannel());
}
/** fail-fast 红线:配置真实渠道(wxpay)但未注册实现 → 抛 TRADE_CHARGE_CHANNEL_UNAVAILABLE绝不静默降级 mock 收假单。 */
@Test
void testFactory_unregisteredChannel_failFast() {
ChargeClientFactory factory = new ChargeClientFactory(List.of(new MockChargeClient()));
ReflectionTestUtils.setField(factory, "chargeChannel", "wxpay"); // 真实渠道未接入受日历闸门
ServiceException ex = assertThrows(ServiceException.class, factory::current);
assertEquals(TRADE_CHARGE_CHANNEL_UNAVAILABLE.getCode(), ex.getCode(), "真实渠道缺失应 fail-fast 不降级 mock");
}
}

View File

@ -0,0 +1,150 @@
package com.wanxiang.huijing.game.module.trade.service.charge;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.SQLIntegrityConstraintViolationException;
import java.sql.Statement;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* R2charge mock e2e集成证据 mini-infra MySQL{@code 100.64.0.8:3306}·Tailscale 直连·绕系统 SOCKS 代理
* throwaway 库里跑<b> V28 迁移文件的 DDL</b> + 积分充值 mock 收单流程 SQL验入账/幂等/积分余额正确
*
* <p><b>覆盖边界</b> MySQL V28 DDL 可跑双验 Flyway DDL 合法性 charge 骨架 SQLinsert 待支付单 /
* markPaidCas 010 入账 / sumPaidPoints 汇总积分余额 / uk_biz_no 幂等去重行为正确ChargeService 分支逻辑
* 校验/幂等/mock 同步入账 {@link ChargeServiceImplTest} 单测坐实 IT 用真 SQL 复演其收单序列
* 全程 throwaway {@code trade_r2_charge_e2e} /隔离不碰真库真表不与 Flyway 冲突
*
* <p><b>默认跳过</b>{@code @EnabledIfSystemProperty(trade.charge.e2e=1)}常规 {@code mvn test} 不连 MySQL
* 出证据时 {@code mvn test -Dtrade.charge.e2e=1 -Dtest=ChargeOrderRealDbIT}
*
* @author 绘境AI
*/
@EnabledIfSystemProperty(named = "trade.charge.e2e", matches = "1")
class ChargeOrderRealDbIT {
private static final String HOST = System.getProperty("trade.charge.dbhost", "100.64.0.8");
private static final String USER = System.getProperty("trade.charge.dbuser", "root");
// 内网 MVP 凭据铁律授权入仓 docs/内网凭据与端点.md可经 -D 覆盖
private static final String PASS = System.getProperty("trade.charge.dbpass", "ZRH3jwYLOrntBcTAw29MW9BP");
private static final String DB = "trade_r2_charge_e2e";
private static final long UID = 999_999_002L;
private String baseUrl() {
return "jdbc:mysql://" + HOST + ":3306/?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC";
}
@Test
void v28Ddl_and_chargeMockFlow_onRealMysql() throws Exception {
// 内网直连绕系统 SOCKS 代理macOS SOCKSEnable127.0.0.1:7897MySQL Connector/J 默认走 JVM socks须显式禁
System.setProperty("socksProxyHost", "");
System.setProperty("java.net.useSystemProxies", "false");
try (Connection conn = DriverManager.getConnection(baseUrl(), USER, PASS)) {
try (Statement st = conn.createStatement()) {
st.execute("DROP DATABASE IF EXISTS " + DB);
st.execute("CREATE DATABASE " + DB + " DEFAULT CHARACTER SET utf8mb4");
st.execute("USE " + DB);
}
runV28Ddl(conn);
// 充值单 1创建待支付 mock 同步置已支付入账points=100
long id1 = insertCreated(conn, "biz-e2e-1", 1000L, 100L);
int paid1 = markPaidCas(conn, id1, "mock-charge-" + id1 + "-biz-e2e-1");
assertEquals(1, paid1, "首次 CAS 0→10 应成功");
// 重复入账幂等 markPaidCas 同单 0 已非待支付
assertEquals(0, markPaidCas(conn, id1, "dup-ref"), "已支付单重复 CAS 应 0 行(不重复入账)");
// 充值单 2另一 bizNo入账 points=50
long id2 = insertCreated(conn, "biz-e2e-2", 500L, 50L);
assertEquals(1, markPaidCas(conn, id2, "mock-charge-" + id2 + "-biz-e2e-2"));
// 积分余额 = Σ 已支付单 points = 100 + 50 = 150
assertEquals(150L, sumPaidPoints(conn), "积分余额应为已支付单 points 之和");
// bizNo 幂等重复插入同 biz-e2e-1 uk_biz_no 唯一键冲突收单不重复
assertThrows(SQLIntegrityConstraintViolationException.class,
() -> insertCreated(conn, "biz-e2e-1", 1000L, 100L), "同 bizNo 应被 uk_biz_no 拒绝");
} finally {
try (Connection conn = DriverManager.getConnection(baseUrl(), USER, PASS);
Statement st = conn.createStatement()) {
st.execute("DROP DATABASE IF EXISTS " + DB);
}
}
}
private void runV28Ddl(Connection conn) throws Exception {
Path migration = Paths.get(System.getProperty("user.dir"), "..", "..",
"huijing-server", "src", "main", "resources", "db", "migration",
"V28.0.0__create_game_trade_charge_order.sql");
assertTrue(Files.exists(migration), "应找到 V28 迁移文件:" + migration.toAbsolutePath().normalize());
String sql = Files.readString(migration);
StringBuilder cleaned = new StringBuilder();
for (String line : sql.split("\n")) {
if (line.trim().startsWith("--")) {
continue;
}
cleaned.append(line).append('\n');
}
try (Statement st = conn.createStatement()) {
for (String stmt : cleaned.toString().split(";")) {
if (!stmt.trim().isEmpty()) {
st.execute(stmt);
}
}
}
}
/** 复演 ChargeService插入待支付单status=0。返回自增 id。uk_biz_no 冲突抛 SQLIntegrityConstraintViolationException。 */
private long insertCreated(Connection conn, String bizNo, long amount, long points) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO game_trade_charge_order (user_id, amount, points, channel, status, biz_no, pay_ref) "
+ "VALUES (?,?,?, 'mock', 0, ?, '')", Statement.RETURN_GENERATED_KEYS)) {
ps.setLong(1, UID);
ps.setLong(2, amount);
ps.setLong(3, points);
ps.setString(4, bizNo);
ps.executeUpdate();
try (ResultSet rs = ps.getGeneratedKeys()) {
rs.next();
return rs.getLong(1);
}
}
}
/** 复演 ChargeOrderMapper.markPaidCasstatus 0→10 CAS + 落 pay_ref/pay_time。返回受影响行数。 */
private int markPaidCas(Connection conn, long id, String payRef) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement(
"UPDATE game_trade_charge_order SET status = 10, pay_ref = ?, pay_time = ? WHERE id = ? AND status = 0")) {
ps.setString(1, payRef);
ps.setTimestamp(2, Timestamp.valueOf(LocalDateTime.now()));
ps.setLong(3, id);
return ps.executeUpdate();
}
}
/** 复演 ChargeOrderMapper.sumPaidPointsΣ status=10 的 points。 */
private long sumPaidPoints(Connection conn) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement(
"SELECT IFNULL(SUM(points), 0) FROM game_trade_charge_order WHERE user_id = ? AND status = 10 AND deleted = 0")) {
ps.setLong(1, UID);
try (ResultSet rs = ps.executeQuery()) {
rs.next();
return rs.getLong(1);
}
}
}
}

View File

@ -0,0 +1,106 @@
package com.wanxiang.huijing.game.module.trade.service.charge;
import com.wanxiang.huijing.game.module.trade.dal.dataobject.ChargeOrderDO;
import com.wanxiang.huijing.game.module.trade.dal.mysql.ChargeOrderMapper;
import com.wanxiang.huijing.game.module.trade.framework.charge.ChargeClient;
import com.wanxiang.huijing.game.module.trade.framework.charge.ChargeClientFactory;
import com.wanxiang.huijing.game.module.trade.framework.charge.ChargeRequest;
import com.wanxiang.huijing.game.module.trade.framework.charge.ChargeResult;
import com.wanxiang.huijing.framework.common.exception.ServiceException;
import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.time.LocalDateTime;
import static com.wanxiang.huijing.game.module.trade.enums.ErrorCodeConstants.TRADE_CHARGE_AMOUNT_INVALID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link ChargeServiceImpl} 单元测试 Mockito把守积分充值 mock-gated 收单流程幂等参数校验
*
* @author 绘境AI
*/
class ChargeServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private ChargeServiceImpl chargeService;
@Mock
private ChargeOrderMapper chargeOrderMapper;
@Mock
private ChargeClientFactory chargeClientFactory;
@Mock
private ChargeClient mockChargeClient;
/** mock 同步充值创建→发起收单→CAS 置已支付+积分入账单据落已支付态、payRef 落库。 */
@Test
void testCreateCharge_mockSynchronous_paidAndCredited() {
when(chargeOrderMapper.selectByBizNo("biz-1")).thenReturn(null); // 非幂等命中
when(chargeClientFactory.current()).thenReturn(mockChargeClient);
when(chargeClientFactory.currentChannel()).thenReturn("mock");
when(mockChargeClient.isSynchronous()).thenReturn(true);
when(mockChargeClient.initiateCharge(any(ChargeRequest.class))).thenReturn(new ChargeResult("mock-ref-1"));
when(chargeOrderMapper.markPaidCas(any(), eq("mock-ref-1"), any(LocalDateTime.class))).thenReturn(1);
ChargeOrderDO order = chargeService.createCharge(100L, 1000L, 100L, "biz-1", "充值 100 积分");
// 落库为待支付初值 CAS 置已支付
verify(chargeOrderMapper).insert(any(ChargeOrderDO.class));
verify(mockChargeClient).initiateCharge(any(ChargeRequest.class));
verify(chargeOrderMapper).markPaidCas(any(), eq("mock-ref-1"), any(LocalDateTime.class));
assertEquals(ChargeServiceImpl.STATUS_PAID, order.getStatus(), "mock 同步应置已支付");
assertEquals("mock-ref-1", order.getPayRef());
assertEquals("mock", order.getChannel());
assertEquals(100L, order.getPoints());
}
/** 幂等:同 bizNo 命中已有单 → 返回已有单,不重复插入/不重复发起收单/不重复入账。 */
@Test
void testCreateCharge_idempotentOnBizNo() {
ChargeOrderDO existing = new ChargeOrderDO();
existing.setId(7L);
existing.setBizNo("biz-1");
existing.setStatus(ChargeServiceImpl.STATUS_PAID);
when(chargeOrderMapper.selectByBizNo("biz-1")).thenReturn(existing);
ChargeOrderDO result = chargeService.createCharge(100L, 1000L, 100L, "biz-1", null);
assertEquals(7L, result.getId());
verify(chargeOrderMapper, never()).insert(any(ChargeOrderDO.class));
verify(chargeClientFactory, never()).current();
}
/** 参数校验:金额 ≤ 0 → 抛 TRADE_CHARGE_AMOUNT_INVALID不落库、不发起收单。 */
@Test
void testCreateCharge_invalidAmount_throws() {
ServiceException ex = assertThrows(ServiceException.class,
() -> chargeService.createCharge(100L, 0L, 100L, "biz-1", null));
assertEquals(TRADE_CHARGE_AMOUNT_INVALID.getCode(), ex.getCode());
verify(chargeOrderMapper, never()).insert(any(ChargeOrderDO.class));
}
/** 参数校验:积分 ≤ 0 → 抛 TRADE_CHARGE_AMOUNT_INVALID。 */
@Test
void testCreateCharge_invalidPoints_throws() {
ServiceException ex = assertThrows(ServiceException.class,
() -> chargeService.createCharge(100L, 1000L, 0L, "biz-1", null));
assertEquals(TRADE_CHARGE_AMOUNT_INVALID.getCode(), ex.getCode());
}
/** 积分余额 = Σ 本人已支付单 points委托 mapper.sumPaidPoints。 */
@Test
void testGetPointsBalance_sumsPaidOrders() {
when(chargeOrderMapper.sumPaidPoints(100L)).thenReturn(250L);
assertEquals(250L, chargeService.getPointsBalance(100L));
verify(chargeOrderMapper).sumPaidPoints(anyLong());
}
}

View File

@ -0,0 +1,38 @@
-- =============================================================================
-- 契约 #2 DB 迁移 | 模块trade积分充值 P0 骨架)| ownerWS5
-- 文件V28.0.0__create_game_trade_charge_order.sqlFlyway只新增已合入禁止修改回滚写新补偿迁移
-- 内容:积分充值单表 game_trade_charge_order —— 用户充值获得积分的收单骨架
-- 背景:补 55 P0 最后一块结构缺口。MVP 不接真实支付收单trade.yaml会员订阅/支付收单=P1、归 pay 日历闸门)——
-- 充值渠道复用 trade 既有 payout 那套「mock-gated 渠道 SPI」范式Nacos trade.charge-channel默认 mock路由
-- mock 同步直通即支付成功入账、真实 wxpay/alipay 渠道未接入时工厂 fail-fast受日历闸门绝不静默发假单
-- 账本自包含:用户积分余额 = Σ 本人已支付(status=10)充值单的 points不污染创作者收益账户(game_trade_account)不变式。
-- 约定InnoDB + utf8mb4金额/积分一律「分/点」BIGINT 禁浮点;含 Yudao 审计列 + 租户列。
-- 错误码段trade = 1-106-***-***charge 用 007 子段)
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 表game_trade_charge_order —— 积分充值单(收单骨架)
-- 状态机0待支付 → 10已支付(积分入账) / 20已取消或失败。mock 渠道同步直通:创建后本进程直接驱动 0→10。
-- 幂等uk_biz_no —— 同一 biz_no 重复提交返回已有单(不重复收单、不重复入账)。
-- -----------------------------------------------------------------------------
CREATE TABLE `game_trade_charge_order` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '充值单 ID',
`user_id` BIGINT NOT NULL COMMENT '充值用户 IDDataPermission用户只见自己的充值单',
`amount` BIGINT NOT NULL COMMENT '充值金额单位BIGINT 禁浮点,>0',
`points` BIGINT NOT NULL COMMENT '充值获得积分(单位:点,>0积分余额 = Σ 本人已支付单 points',
`channel` VARCHAR(16) NOT NULL DEFAULT 'mock' COMMENT '充值渠道码Nacos trade.charge-channelmock/wxpay/alipay',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态机0待支付 10已支付(积分入账) 20已取消/失败',
`biz_no` VARCHAR(64) NOT NULL COMMENT '充值业务单号 = 幂等键(调用端生成唯一)',
`pay_ref` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '渠道支付单号mock 为本地 mock 单号;真实渠道为收单单号,供对账)',
`pay_time` DATETIME NULL COMMENT '支付成功时间status 置 10 时写入)',
`remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注(充值说明,可空)',
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者Yudao 审计列)',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除0未删 1已删',
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 IDYudao 多租户兼容MVP 单租户=0',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_biz_no` (`biz_no`) COMMENT '充值幂等键:同一 biz_no 只收单一次',
KEY `idx_user_status` (`user_id`, `status`) COMMENT '按用户取充值单 / 汇总积分余额(Σ status=10 的 points'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '积分充值单收单骨架mock-gated真实支付收单 P1 受日历闸门)';