feat(transfer): 转账申请链路扩展(报备/场景/收款感知透传+容器持久化)与微信确认收款链接, 发起返回单号

This commit is contained in:
daxpay
2026-08-07 16:38:22 +08:00
parent 80810851c3
commit be248dec17
15 changed files with 269 additions and 63 deletions

View File

@@ -308,6 +308,44 @@ COMMENT ON COLUMN "public"."alipay_direct_channel_merchant"."deleted" IS '逻辑
COMMENT ON COLUMN "public"."alipay_direct_channel_merchant"."sandbox" IS '是否沙箱环境商户';
COMMENT ON TABLE "public"."alipay_direct_channel_merchant" IS '支付宝直连通道商户绑定';
-- ----------------------------
-- Table structure for alipay_transfer_scene_config
-- ----------------------------
DROP TABLE IF EXISTS "public"."alipay_transfer_scene_config";
CREATE TABLE "public"."alipay_transfer_scene_config" (
"id" int8 NOT NULL,
"mch_no" varchar(32) COLLATE "pg_catalog"."default" NOT NULL,
"channel_mch_no" varchar(64) COLLATE "pg_catalog"."default" NOT NULL,
"scene_name" varchar(64) COLLATE "pg_catalog"."default" NOT NULL,
"is_default" bool NOT NULL DEFAULT false,
"remark" varchar(200) COLLATE "pg_catalog"."default",
"creator" int8,
"create_time" timestamptz(6),
"last_modifier" int8,
"last_modified_time" timestamptz(6),
"version" int4 NOT NULL DEFAULT 0,
"deleted" bool NOT NULL DEFAULT false
)
;
COMMENT ON COLUMN "public"."alipay_transfer_scene_config"."id" IS '主键';
COMMENT ON COLUMN "public"."alipay_transfer_scene_config"."mch_no" IS '商户号';
COMMENT ON COLUMN "public"."alipay_transfer_scene_config"."channel_mch_no" IS '通道商户号';
COMMENT ON COLUMN "public"."alipay_transfer_scene_config"."scene_name" IS '转账场景名称(2026新商户必填,枚举:现金营销/企业退款/佣金报酬/业务结算/二手回收/公益补助/行政补贴和退款/保险理赔)';
COMMENT ON COLUMN "public"."alipay_transfer_scene_config"."is_default" IS '是否默认场景(一个通道商户最多一个默认)';
COMMENT ON COLUMN "public"."alipay_transfer_scene_config"."remark" IS '备注';
COMMENT ON COLUMN "public"."alipay_transfer_scene_config"."creator" IS '创建人';
COMMENT ON COLUMN "public"."alipay_transfer_scene_config"."create_time" IS '创建时间';
COMMENT ON COLUMN "public"."alipay_transfer_scene_config"."last_modifier" IS '最后修改人';
COMMENT ON COLUMN "public"."alipay_transfer_scene_config"."last_modified_time" IS '最后修改时间';
COMMENT ON COLUMN "public"."alipay_transfer_scene_config"."version" IS '乐观锁版本号';
COMMENT ON COLUMN "public"."alipay_transfer_scene_config"."deleted" IS '逻辑删除标志';
COMMENT ON TABLE "public"."alipay_transfer_scene_config" IS '支付宝转账场景配置(一对多,按通道商户维度管理,2026新商户转账必配)';
CREATE INDEX "idx_alipay_transfer_scene_config_mch" ON "public"."alipay_transfer_scene_config" USING btree ("channel_mch_no", "deleted");
COMMENT ON INDEX "public"."idx_alipay_transfer_scene_config_mch" IS '按通道商户号查询转账场景列表';
CREATE UNIQUE INDEX "uk_alipay_transfer_scene_config_default" ON "public"."alipay_transfer_scene_config" ("channel_mch_no") WHERE is_default = true AND deleted = false;
COMMENT ON INDEX "public"."uk_alipay_transfer_scene_config_default" IS '同一通道商户最多一个默认转账场景(部分唯一索引)';
ALTER TABLE "public"."alipay_transfer_scene_config" ADD CONSTRAINT "pk_alipay_transfer_scene_config" PRIMARY KEY ("id");
-- ----------------------------
-- Table structure for alipay_isv_app
-- ----------------------------
@@ -651,6 +689,46 @@ COMMENT ON COLUMN "public"."douyin_direct_channel_merchant"."deleted" IS '逻辑
COMMENT ON COLUMN "public"."douyin_direct_channel_merchant"."transfer_scene" IS '转账场景ID(商家转账, 未配置时发起转账报错)';
COMMENT ON TABLE "public"."douyin_direct_channel_merchant" IS '抖音直连通道商户绑定';
-- ----------------------------
-- Table structure for douyin_transfer_scene_config
-- ----------------------------
DROP TABLE IF EXISTS "public"."douyin_transfer_scene_config";
CREATE TABLE "public"."douyin_transfer_scene_config" (
"id" int8 NOT NULL,
"mch_no" varchar(32) COLLATE "pg_catalog"."default" NOT NULL,
"channel_mch_no" varchar(64) COLLATE "pg_catalog"."default" NOT NULL,
"scene_id" varchar(16) COLLATE "pg_catalog"."default" NOT NULL,
"is_default" bool NOT NULL DEFAULT false,
"remark" varchar(200) COLLATE "pg_catalog"."default",
"creator" int8,
"create_time" timestamptz(6),
"last_modifier" int8,
"last_modified_time" timestamptz(6),
"version" int4 NOT NULL DEFAULT 0,
"deleted" bool NOT NULL DEFAULT false
)
;
COMMENT ON COLUMN "public"."douyin_transfer_scene_config"."id" IS '主键';
COMMENT ON COLUMN "public"."douyin_transfer_scene_config"."mch_no" IS '商户号';
COMMENT ON COLUMN "public"."douyin_transfer_scene_config"."channel_mch_no" IS '通道商户号';
COMMENT ON COLUMN "public"."douyin_transfer_scene_config"."scene_id" IS '转账场景ID(固定枚举:1001现金营销/1002企业赔付/1003佣金报酬/1004采购货款/1005二手回收/1006公益补助/1007行政补贴)';
COMMENT ON COLUMN "public"."douyin_transfer_scene_config"."is_default" IS '是否默认场景(一个通道商户最多一个默认)';
COMMENT ON COLUMN "public"."douyin_transfer_scene_config"."remark" IS '备注';
COMMENT ON COLUMN "public"."douyin_transfer_scene_config"."creator" IS '创建人ID';
COMMENT ON COLUMN "public"."douyin_transfer_scene_config"."create_time" IS '创建时间';
COMMENT ON COLUMN "public"."douyin_transfer_scene_config"."last_modifier" IS '最后修改人ID';
COMMENT ON COLUMN "public"."douyin_transfer_scene_config"."last_modified_time" IS '最后修改时间';
COMMENT ON COLUMN "public"."douyin_transfer_scene_config"."version" IS '乐观锁版本号';
COMMENT ON COLUMN "public"."douyin_transfer_scene_config"."deleted" IS '逻辑删除标志';
COMMENT ON TABLE "public"."douyin_transfer_scene_config" IS '抖音转账场景配置(一对多, 按通道商户维度管理, 场景ID为固定枚举1001-1007)';
CREATE INDEX "idx_douyin_transfer_scene_config_mch" ON "public"."douyin_transfer_scene_config" USING btree ("channel_mch_no", "deleted");
COMMENT ON INDEX "public"."idx_douyin_transfer_scene_config_mch" IS '按通道商户号查询转账场景列表';
CREATE UNIQUE INDEX "uk_douyin_transfer_scene_config_default" ON "public"."douyin_transfer_scene_config" ("channel_mch_no") WHERE is_default = true AND deleted = false;
COMMENT ON INDEX "public"."uk_douyin_transfer_scene_config_default" IS '同一通道商户最多一个默认转账场景(部分唯一索引)';
CREATE UNIQUE INDEX "uk_douyin_transfer_scene_config_scene" ON "public"."douyin_transfer_scene_config" ("channel_mch_no", "scene_id") WHERE deleted = false;
COMMENT ON INDEX "public"."uk_douyin_transfer_scene_config_scene" IS '同一通道商户同一转账场景不可重复(部分唯一索引)';
ALTER TABLE "public"."douyin_transfer_scene_config" ADD CONSTRAINT "pk_douyin_transfer_scene_config" PRIMARY KEY ("id");
-- ----------------------------
-- Table structure for douyin_direct_key_config
-- ----------------------------
@@ -3548,6 +3626,7 @@ CREATE TABLE "public"."pay_transfer_order_alipay" (
"payee_type" varchar(32) COLLATE "pg_catalog"."default" NOT NULL,
"payee_account" varchar(100) COLLATE "pg_catalog"."default" NOT NULL,
"payee_name" varchar(100) COLLATE "pg_catalog"."default",
"pay_fund_order_id" varchar(64) COLLATE "pg_catalog"."default",
"creator" int8,
"create_time" timestamptz(6),
"last_modifier" int8,
@@ -3575,6 +3654,7 @@ COMMENT ON COLUMN "public"."pay_transfer_order_alipay"."error_msg" IS '错误信
COMMENT ON COLUMN "public"."pay_transfer_order_alipay"."payee_type" IS '收款人账号类型(user_id/open_id/login_name)';
COMMENT ON COLUMN "public"."pay_transfer_order_alipay"."payee_account" IS '收款人账号';
COMMENT ON COLUMN "public"."pay_transfer_order_alipay"."payee_name" IS '收款人姓名';
COMMENT ON COLUMN "public"."pay_transfer_order_alipay"."pay_fund_order_id" IS '支付宝资金流水号(财务对账,区别于 out_transfer_no)';
COMMENT ON COLUMN "public"."pay_transfer_order_alipay"."creator" IS '创建人';
COMMENT ON COLUMN "public"."pay_transfer_order_alipay"."create_time" IS '创建时间';
COMMENT ON COLUMN "public"."pay_transfer_order_alipay"."last_modifier" IS '最后修改人';

View File

@@ -1,43 +0,0 @@
-- ============================================================
-- 抖音转账配置表(转账发起应用绑定, 发起转账时决定转出主体与收款人openId来源)
-- 对齐微信 wechat_transfer_config 范式; 转账场景为主数据枚举无需落库, 本表仅存发起应用
-- ============================================================
DROP TABLE IF EXISTS "public"."douyin_transfer_config";
CREATE TABLE "public"."douyin_transfer_config" (
"id" int8 NOT NULL,
"creator" int8,
"create_time" timestamptz(6),
"last_modifier" int8,
"last_modified_time" timestamptz(6),
"version" int4 NOT NULL DEFAULT 0,
"deleted" bool NOT NULL DEFAULT false,
"mch_no" varchar(32) COLLATE "pg_catalog"."default" NOT NULL,
"channel_mch_no" varchar(64) COLLATE "pg_catalog"."default" NOT NULL,
"transfer_app_ref_id" int8
);
COMMENT ON COLUMN "public"."douyin_transfer_config"."id" IS '主键';
COMMENT ON COLUMN "public"."douyin_transfer_config"."creator" IS '创建者ID';
COMMENT ON COLUMN "public"."douyin_transfer_config"."create_time" IS '创建时间';
COMMENT ON COLUMN "public"."douyin_transfer_config"."last_modifier" IS '最后修改ID';
COMMENT ON COLUMN "public"."douyin_transfer_config"."last_modified_time" IS '最后修改时间';
COMMENT ON COLUMN "public"."douyin_transfer_config"."version" IS '版本号';
COMMENT ON COLUMN "public"."douyin_transfer_config"."deleted" IS '删除标志';
COMMENT ON COLUMN "public"."douyin_transfer_config"."mch_no" IS '商户号';
COMMENT ON COLUMN "public"."douyin_transfer_config"."channel_mch_no" IS '通道商户号';
COMMENT ON COLUMN "public"."douyin_transfer_config"."transfer_app_ref_id" IS '转账发起应用引用(dy_mch_app 主键, 须为网站应用 web_app, 支持手机H5获取OpenId)';
COMMENT ON TABLE "public"."douyin_transfer_config" IS '抖音转账配置(一对一, 指定转账发起应用, 决定转出主体与收款人openId来源)';
-- 主键约束 先删后增
ALTER TABLE "public"."douyin_transfer_config" DROP CONSTRAINT IF EXISTS "pk_douyin_transfer_config";
ALTER TABLE "public"."douyin_transfer_config" ADD CONSTRAINT "pk_douyin_transfer_config" PRIMARY KEY ("id");
-- 通道商户查询索引 先删后增
DROP INDEX IF EXISTS "public"."idx_douyin_transfer_config_mch";
CREATE INDEX "idx_douyin_transfer_config_mch" ON "public"."douyin_transfer_config" USING btree ("channel_mch_no", "deleted");
COMMENT ON INDEX "public"."idx_douyin_transfer_config_mch" IS '按通道商户号查询转账配置';
-- 通道商户唯一索引(一个通道商户一条转账配置) 先删后增
DROP INDEX IF EXISTS "public"."uk_douyin_transfer_config_mch";
CREATE UNIQUE INDEX "uk_douyin_transfer_config_mch" ON "public"."douyin_transfer_config" ("channel_mch_no") WHERE deleted = false;
COMMENT ON INDEX "public"."uk_douyin_transfer_config_mch" IS '同一通道商户仅一条转账配置(部分唯一索引)';

View File

@@ -14,8 +14,11 @@ import cn.daxpay.open.payment.trade.transfer.param.TransferTradeQuery;
import cn.daxpay.open.payment.trade.transfer.param.WechatTransferOrderQuery;
import cn.daxpay.open.payment.trade.transfer.result.AlipayTransferOrderResult;
import cn.daxpay.open.payment.trade.transfer.result.DouyinTransferOrderResult;
import cn.daxpay.open.payment.trade.transfer.result.TransferCreateResult;
import cn.daxpay.open.payment.trade.transfer.result.TransferTradeResult;
import cn.daxpay.open.payment.trade.transfer.result.WechatTransferOrderResult;
import cn.daxpay.open.platform.system.service.config.infra.PlatformUrlConfigService;
import cn.hutool.core.util.StrUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
@@ -40,6 +43,7 @@ import org.springframework.web.bind.annotation.RestController;
public class TransferAdminController {
private final TransferAdminService transferAdminService;
private final PlatformUrlConfigService platformUrlConfigService;
// ===== 微信转账 =====
@@ -97,9 +101,20 @@ public class TransferAdminController {
@PermCode(code = PermCodes.Action.MANAGE)
@Operation(summary = "发起微信转账(运营端代发, 传 mchNo; FAIL 单复用原单号即重试)")
@PostMapping("/wechat/create")
public Result<Void> wechatCreate(@Valid @RequestBody TransferParam param) {
transferAdminService.create("wechat", param);
return Res.ok();
public Result<TransferCreateResult> wechatCreate(@Valid @RequestBody TransferParam param) {
String transferNo = transferAdminService.create("wechat", param);
// 生成确认收款链接(供商户发给收款人在微信内打开)
String confirmUrl = buildConfirmUrl(transferNo);
return Res.ok(new TransferCreateResult().setTransferNo(transferNo).setConfirmUrl(confirmUrl));
}
/// 生成微信转账确认收款链接
private String buildConfirmUrl(String transferNo) {
String base = platformUrlConfigService.getUrlConfig().getPaymentGatewayBaseUrl();
if (StrUtil.isBlank(base)) {
return null;
}
return StrUtil.format("{}/transfer-confirm/{}", base, transferNo);
}
@PermCode(code = PermCodes.Action.MANAGE)

View File

@@ -109,8 +109,10 @@ public class TransferAdminService {
// ===== 公共操作 =====
/// 发起转账(运营端代发, mchNo 必传; FAIL 单复用原单号即重试)
public void create(String channel, TransferParam param) {
transferStartService.start(channel, param);
///
/// @return 平台转账单号
public String create(String channel, TransferParam param) {
return transferStartService.start(channel, param);
}
/// 同步转账状态

View File

@@ -1,11 +1,13 @@
package cn.daxpay.open.payment.strategy.transfer;
import cn.daxpay.open.payment.trade.transfer.entity.TransferTrade;
import cn.daxpay.open.payment.trade.transfer.param.TransferReportInfo;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
import java.time.OffsetDateTime;
import java.util.List;
/// # 转账策略执行上下文(请求级,显式传参)
///
@@ -86,4 +88,13 @@ public class TransferStrategyContext {
/// 收款人姓名(支付宝/抖音)
private String payeeName;
/// 转账场景配置ID(支付宝专用,发起时指定用哪个场景; 空则用通道商户默认)
private String transferSceneConfigId;
/// 转账场景报备信息(微信/抖音转账必填, 各场景要求不同)
private List<TransferReportInfo> reportInfos;
/// 用户收款感知(抖音专用, 收款人在抖音中看到的文案)
private String userRecvPerception;
}

View File

@@ -25,6 +25,9 @@ public class TransferResultBo {
/// 通道转账单号(微信 paymentNo / 支付宝 order_id / 抖音 orderId)
private String outTransferNo;
/// 支付宝资金流水号(支付宝特有 pay_fund_order_id, 财务对账用; 其余通道空)
private String payFundOrderId;
/// 实际上送通道的商户转账号(特殊通道变形后回写; 普通通道可空, 结算沿用建单默认值)
private String relationNo;

View File

@@ -43,6 +43,9 @@ public class AlipayTransferOrder extends MchBaseEntity {
/// 通道转账单号(支付宝 order_id)
private String outTransferNo;
/// 支付宝资金流水号(pay_fund_order_id, 财务对账, 区别于 outTransferNo)
private String payFundOrderId;
// ===== 转账要素 =====
/// 转账金额(最小货币单位, 分)
@@ -80,6 +83,12 @@ public class AlipayTransferOrder extends MchBaseEntity {
/// 收款人姓名
private String payeeName;
/// 转账场景配置ID(支付宝专用, FAIL重试时恢复场景用)
private String transferSceneConfigId;
/// 转账场景报备信息(JSON序列化, FAIL重试时恢复报备用)
private String reportInfos;
// ===== 商户出站 / 审计 =====
/// 异步通知地址(出站商户通知用)

View File

@@ -79,9 +79,15 @@ public class DouyinTransferOrder extends MchBaseEntity {
/// 收款人姓名
private String payeeName;
/// 转账场景ID(冗余自通道商户配置)
/// 转账场景ID(发起转账时由前端选择的主数据枚举, 如1001-1007)
private String transferScene;
/// 用户收款感知(收款人在抖音中看到的文案)
private String userRecvPerception;
/// 转账场景报备信息(JSON序列化, FAIL重试时恢复报备用)
private String reportInfos;
// ===== 商户出站 / 审计 =====
/// 异步通知地址(出站商户通知用)

View File

@@ -10,6 +10,7 @@ import lombok.Data;
import lombok.experimental.Accessors;
import java.math.BigDecimal;
import java.util.List;
/// # 转账发起参数(管理端/商户端共用)
///
@@ -86,4 +87,23 @@ public class TransferParam {
@Size(max = 200, message = "回调通知地址不可超过200位")
@Schema(description = "回调通知地址")
private String notifyUrl;
/// 转账场景报备信息(微信转账必填, 各场景要求不同, 留空由通道兜底)
@Schema(description = "转账场景报备信息")
private List<TransferReportInfo> reportInfos;
/// 转账场景配置ID(支付宝专用,可选; 不传则用通道商户的默认转账场景配置)
@Size(max = 32, message = "转账场景配置ID不可超过32位")
@Schema(description = "转账场景配置ID(支付宝专用,不传用默认)")
private String transferSceneConfigId;
/// 转账场景ID(抖音专用, 主数据枚举如1001-1007, 发起转账时由前端选择传入)
@Size(max = 16, message = "转账场景ID不可超过16位")
@Schema(description = "转账场景ID(抖音专用,主数据枚举)")
private String transferScene;
/// 用户收款感知(抖音专用, 收款人在抖音中看到的文案, 按场景枚举选项)
@Size(max = 64, message = "用户收款感知不可超过64位")
@Schema(description = "用户收款感知(抖音专用)")
private String userRecvPerception;
}

View File

@@ -0,0 +1,21 @@
package cn.daxpay.open.payment.trade.transfer.param;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 转账场景报备信息项
///
/// 通用结构(微信转账场景报备用), [infoType] 为微信协议固定的信息类型(如"活动名称"),
/// [infoContent] 为商户填写的信息内容(如"新会员有礼")。
@Data
@Accessors(chain = true)
@Schema(title = "转账场景报备信息项")
public class TransferReportInfo {
@Schema(description = "信息类型(微信协议固定中文, 如: 活动名称)")
private String infoType;
@Schema(description = "信息内容(商户自定义填写)")
private String infoContent;
}

View File

@@ -0,0 +1,20 @@
package cn.daxpay.open.payment.trade.transfer.result;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 转账发起结果
///
/// 返回平台转账单号与(微信)确认收款链接, 供商户前端展示给收款人。
@Data
@Accessors(chain = true)
@Schema(title = "转账发起结果")
public class TransferCreateResult {
@Schema(description = "平台转账单号")
private String transferNo;
@Schema(description = "确认收款链接(微信转账, 收款人在微信内打开确认)")
private String confirmUrl;
}

View File

@@ -11,16 +11,20 @@ import cn.daxpay.open.payment.trade.transfer.entity.DouyinTransferOrder;
import cn.daxpay.open.payment.trade.transfer.entity.TransferTrade;
import cn.daxpay.open.payment.trade.transfer.entity.WechatTransferOrder;
import cn.daxpay.open.payment.trade.transfer.param.TransferParam;
import cn.daxpay.open.payment.trade.transfer.param.TransferReportInfo;
import cn.daxpay.open.payment.trade.util.CurrencyAmountUtil;
import cn.daxpay.open.platform.core.enums.pay.channel.CurrencyEnum;
import cn.daxpay.open.platform.core.enums.pay.notice.NoticeEventEnum;
import cn.daxpay.open.platform.core.util.TradeNoGenerateUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
@@ -133,7 +137,8 @@ public class TransferAssistService {
wechatTransferOrderManager.save(order);
TransferTrade trade = this.buildTrade(order, channel, transferNo);
transferTradeManager.save(trade);
return buildWechatContext(order).setChannel(channel).setTrade(trade);
return buildWechatContext(order).setChannel(channel).setTrade(trade)
.setReportInfos(param.getReportInfos());
}
case "alipay" -> {
AlipayTransferOrder order = new AlipayTransferOrder()
@@ -154,16 +159,22 @@ public class TransferAssistService {
.setReqTime(OffsetDateTime.now());
// 商户号独立赋值(父类 setter 返回 MchBaseEntity, 禁止链式)
order.setMchNo(mchNo);
// 持久化场景配置ID与报备信息(FAIL重试时恢复)
order.setTransferSceneConfigId(param.getTransferSceneConfigId());
order.setReportInfos(serializeReportInfos(param.getReportInfos()));
alipayTransferOrderManager.save(order);
TransferTrade trade = this.buildTrade(order, channel, transferNo);
transferTradeManager.save(trade);
return buildAlipayContext(order).setChannel(channel).setTrade(trade);
return buildAlipayContext(order).setChannel(channel).setTrade(trade)
.setTransferSceneConfigId(param.getTransferSceneConfigId())
.setReportInfos(param.getReportInfos());
}
case "douyin" -> {
DouyinTransferOrder order = new DouyinTransferOrder()
.setPayeeType(param.getPayeeType())
.setPayeeAccount(param.getPayeeAccount())
.setPayeeName(param.getPayeeName())
.setTransferScene(param.getTransferScene())
.setTransferNo(transferNo)
.setBizTransferNo(param.getBizTransferNo())
.setChannelMchNo(param.getChannelMchNo())
@@ -178,10 +189,16 @@ public class TransferAssistService {
.setReqTime(OffsetDateTime.now());
// 商户号独立赋值(父类 setter 返回 MchBaseEntity, 禁止链式)
order.setMchNo(mchNo);
// 持久化收款感知与报备信息(FAIL重试时恢复)
order.setUserRecvPerception(param.getUserRecvPerception());
order.setReportInfos(serializeReportInfos(param.getReportInfos()));
douyinTransferOrderManager.save(order);
TransferTrade trade = this.buildTrade(order, channel, transferNo);
transferTradeManager.save(trade);
return buildDouyinContext(order).setChannel(channel).setTrade(trade);
return buildDouyinContext(order).setChannel(channel).setTrade(trade)
.setTransferScene(param.getTransferScene())
.setUserRecvPerception(param.getUserRecvPerception())
.setReportInfos(param.getReportInfos());
}
default -> throw new IllegalArgumentException("未知转账通道: " + channel);
}
@@ -465,7 +482,10 @@ public class TransferAssistService {
.setFinishTime(order.getFinishTime())
.setPayeeType(order.getPayeeType())
.setPayeeAccount(order.getPayeeAccount())
.setPayeeName(order.getPayeeName());
.setPayeeName(order.getPayeeName())
// 恢复场景配置ID与报备信息(FAIL重试时使用)
.setTransferSceneConfigId(order.getTransferSceneConfigId())
.setReportInfos(deserializeReportInfos(order.getReportInfos()));
}
private TransferStrategyContext buildDouyinContext(DouyinTransferOrder order) {
@@ -485,6 +505,27 @@ public class TransferAssistService {
.setPayeeType(order.getPayeeType())
.setPayeeAccount(order.getPayeeAccount())
.setPayeeName(order.getPayeeName())
.setTransferScene(order.getTransferScene());
.setTransferScene(order.getTransferScene())
// 恢复收款感知与报备信息(FAIL重试时使用)
.setUserRecvPerception(order.getUserRecvPerception())
.setReportInfos(deserializeReportInfos(order.getReportInfos()));
}
// ===== 报备信息序列化(容器持久化/重试恢复) =====
/// 序列化报备信息为JSON字符串(支付宝容器持久化, FAIL重试时恢复)
private String serializeReportInfos(List<TransferReportInfo> reportInfos) {
if (reportInfos == null || reportInfos.isEmpty()) {
return null;
}
return JSONUtil.toJsonStr(reportInfos);
}
/// 反序列化报备信息(从容器恢复)
private List<TransferReportInfo> deserializeReportInfos(String json) {
if (StrUtil.isBlank(json)) {
return null;
}
return JSONUtil.toList(json, TransferReportInfo.class);
}
}

View File

@@ -54,9 +54,10 @@ public class TransferStartService {
///
/// @param channel 通道编码(wechat/alipay/douyin)
/// @param param 转账参数
public void start(String channel, TransferParam param) {
/// @return 平台转账单号(transferNo)
public String start(String channel, TransferParam param) {
// 锁租期 60s 覆盖通道 HTTP 超时(40s), 等待 3s 让并发同号请求排队而非立即失败
lockExecutor.run(
return lockExecutor.execute(
"payment:transfer:" + param.getBizTransferNo(),
60000, 3000,
() -> this.startHandle(channel, param),
@@ -65,7 +66,9 @@ public class TransferStartService {
}
/// 发起转账编排(锁内, 无事务)
public void startHandle(String channel, TransferParam param) {
///
/// @return 平台转账单号(transferNo)
public String startHandle(String channel, TransferParam param) {
// 商户身份装载: 商户端强制当前登录商户, 运营端按传入 mchNo 代发
merchantContextLoader.initMch(param.getMchNo());
// 应用解析: 空则取商户默认应用, 校验启用与归属
@@ -83,7 +86,7 @@ public class TransferStartService {
TransferStrategyContext context = assistService.loadContext(channel, containerId)
.orElseThrow(() -> new BizInfoException(CommonCode.FAIL_CODE, "pay.error.transfer.notFound"));
this.transfer(channel, context);
return;
return context.getTransferNo();
}
// 该商户转账号已存在,请勿重复转账
throw new BizInfoException(CommonCode.FAIL_CODE, "pay.error.transfer.noDuplicate");
@@ -91,6 +94,7 @@ public class TransferStartService {
// 新单: 建单(独立事务)后发起
TransferStrategyContext context = self.createOrder(channel, param, mchApp.getAppId());
this.transfer(channel, context);
return context.getTransferNo();
}
/// 建单(事务内: 通道参数校验 + 容器/凭证双写)

View File

@@ -14,8 +14,11 @@ import cn.daxpay.open.payment.trade.transfer.param.TransferTradeQuery;
import cn.daxpay.open.payment.trade.transfer.param.WechatTransferOrderQuery;
import cn.daxpay.open.payment.trade.transfer.result.AlipayTransferOrderResult;
import cn.daxpay.open.payment.trade.transfer.result.DouyinTransferOrderResult;
import cn.daxpay.open.payment.trade.transfer.result.TransferCreateResult;
import cn.daxpay.open.payment.trade.transfer.result.TransferTradeResult;
import cn.daxpay.open.payment.trade.transfer.result.WechatTransferOrderResult;
import cn.daxpay.open.platform.system.service.config.infra.PlatformUrlConfigService;
import cn.hutool.core.util.StrUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
@@ -40,6 +43,7 @@ import org.springframework.web.bind.annotation.RestController;
public class MchTransferController {
private final MchTransferService mchTransferService;
private final PlatformUrlConfigService platformUrlConfigService;
// ===== 微信转账 =====
@@ -61,9 +65,20 @@ public class MchTransferController {
@PermCode(code = PermCodes.Action.MANAGE)
@Operation(summary = "发起微信转账(FAIL 单复用原单号即重试)")
@PostMapping("/wechat/create")
public Result<Void> wechatCreate(@Valid @RequestBody TransferParam param) {
mchTransferService.create("wechat", param);
return Res.ok();
public Result<TransferCreateResult> wechatCreate(@Valid @RequestBody TransferParam param) {
String transferNo = mchTransferService.create("wechat", param);
// 生成确认收款链接(供商户发给收款人在微信内打开)
String confirmUrl = buildConfirmUrl(transferNo);
return Res.ok(new TransferCreateResult().setTransferNo(transferNo).setConfirmUrl(confirmUrl));
}
/// 生成微信转账确认收款链接
private String buildConfirmUrl(String transferNo) {
String base = platformUrlConfigService.getUrlConfig().getPaymentGatewayBaseUrl();
if (StrUtil.isBlank(base)) {
return null;
}
return StrUtil.format("{}/transfer-confirm/{}", base, transferNo);
}
// ===== 支付宝转账 =====

View File

@@ -100,8 +100,10 @@ public class MchTransferService {
}
/// 发起转账(商户号由登录上下文强制)
public void create(String channel, TransferParam param) {
transferStartService.start(channel, param);
///
/// @return 平台转账单号
public String create(String channel, TransferParam param) {
return transferStartService.start(channel, param);
}
/// 同步转账状态