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:
parent
617abbb589
commit
d32d5965fb
@ -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, "充值渠道不可用,请稍后重试");
|
||||
|
||||
}
|
||||
|
||||
@ -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.* 自动添加。鉴权:需用户 Token,userId 由 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));
|
||||
}
|
||||
|
||||
}
|
||||
@ -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 VO(P0 骨架)
|
||||
*
|
||||
* @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;
|
||||
|
||||
}
|
||||
@ -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 VO(P0 骨架)
|
||||
*
|
||||
* @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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
/** 充值用户 ID(DataPermission:用户只见自己的充值单) */
|
||||
private Long userId;
|
||||
/** 充值金额(单位:分,>0) */
|
||||
private Long amount;
|
||||
/** 充值获得积分(单位:点,>0) */
|
||||
private Long points;
|
||||
/** 充值渠道码(Nacos trade.charge-channel:mock/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;
|
||||
|
||||
}
|
||||
@ -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 0→10 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 0→10,恰一次入账去重):仅当当前为 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);
|
||||
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
package com.wanxiang.huijing.game.module.trade.framework.charge;
|
||||
|
||||
/**
|
||||
* 充值收单渠道 SPI(ChargeClient):统一形态屏蔽 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);
|
||||
|
||||
}
|
||||
@ -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() 建索引。渠道选择走 Nacos:trade.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);
|
||||
|
||||
/**
|
||||
* 当前充值渠道走 Nacos:trade.charge-channel,默认 mock(MVP)。
|
||||
* @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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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 0→10(已支付)+ 积分入账,保持与异步渠道一致的状态机路径(不抄近路直接置 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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
package com.wanxiang.huijing.game.module.trade.service.charge;
|
||||
|
||||
import com.wanxiang.huijing.game.module.trade.dal.dataobject.ChargeOrderDO;
|
||||
|
||||
/**
|
||||
* 积分充值 Service(P0 骨架,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);
|
||||
|
||||
}
|
||||
@ -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 渠道同步直通:创建后本进程直接驱动 0→10(CAS)。
|
||||
* 资金安全与可对账同 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/alipay,MVP 未接入):仅发起、落 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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
* R2「charge 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 骨架 SQL(insert 待支付单 /
|
||||
* markPaidCas 0→10 入账 / 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 SOCKSEnable,127.0.0.1:7897)——MySQL 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.markPaidCas:status 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
-- =============================================================================
|
||||
-- 契约 #2 DB 迁移 | 模块:trade(积分充值 P0 骨架)| owner:WS5
|
||||
-- 文件:V28.0.0__create_game_trade_charge_order.sql(Flyway,只新增;已合入禁止修改,回滚写新补偿迁移)
|
||||
-- 内容:积分充值单表 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 '充值用户 ID(DataPermission:用户只见自己的充值单)',
|
||||
`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-channel:mock/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 '租户 ID(Yudao 多租户兼容,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 受日历闸门)';
|
||||
Loading…
x
Reference in New Issue
Block a user