diff --git a/_config/sql/table.sql b/_config/sql/table.sql index 241ff2c9b..d5b546fcc 100644 --- a/_config/sql/table.sql +++ b/_config/sql/table.sql @@ -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 '最后修改人'; diff --git a/_config/sql/update-tables.sql b/_config/sql/update-tables.sql index f4f885dfe..e69de29bb 100644 --- a/_config/sql/update-tables.sql +++ b/_config/sql/update-tables.sql @@ -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 '同一通道商户仅一条转账配置(部分唯一索引)'; diff --git a/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/controller/trade/TransferAdminController.java b/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/controller/trade/TransferAdminController.java index b4c56d378..35b1fb468 100644 --- a/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/controller/trade/TransferAdminController.java +++ b/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/controller/trade/TransferAdminController.java @@ -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 wechatCreate(@Valid @RequestBody TransferParam param) { - transferAdminService.create("wechat", param); - return Res.ok(); + public Result 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) diff --git a/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/service/trade/TransferAdminService.java b/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/service/trade/TransferAdminService.java index bbe198e4f..d09e1e0d9 100644 --- a/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/service/trade/TransferAdminService.java +++ b/daxpay-payment/daxpay-payment-admin/src/main/java/cn/daxpay/open/payment/admin/service/trade/TransferAdminService.java @@ -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); } /// 同步转账状态 diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/transfer/TransferStrategyContext.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/transfer/TransferStrategyContext.java index 787fae9e3..af0f78b54 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/transfer/TransferStrategyContext.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/strategy/transfer/TransferStrategyContext.java @@ -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 reportInfos; + + /// 用户收款感知(抖音专用, 收款人在抖音中看到的文案) + private String userRecvPerception; } diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/bo/TransferResultBo.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/bo/TransferResultBo.java index f3037940b..813444507 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/bo/TransferResultBo.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/bo/TransferResultBo.java @@ -25,6 +25,9 @@ public class TransferResultBo { /// 通道转账单号(微信 paymentNo / 支付宝 order_id / 抖音 orderId) private String outTransferNo; + /// 支付宝资金流水号(支付宝特有 pay_fund_order_id, 财务对账用; 其余通道空) + private String payFundOrderId; + /// 实际上送通道的商户转账号(特殊通道变形后回写; 普通通道可空, 结算沿用建单默认值) private String relationNo; diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/entity/AlipayTransferOrder.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/entity/AlipayTransferOrder.java index 26c50271b..2bdc69b47 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/entity/AlipayTransferOrder.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/entity/AlipayTransferOrder.java @@ -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; + // ===== 商户出站 / 审计 ===== /// 异步通知地址(出站商户通知用) diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/entity/DouyinTransferOrder.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/entity/DouyinTransferOrder.java index ab2507eb8..33921d372 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/entity/DouyinTransferOrder.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/entity/DouyinTransferOrder.java @@ -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; + // ===== 商户出站 / 审计 ===== /// 异步通知地址(出站商户通知用) diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/param/TransferParam.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/param/TransferParam.java index f1007949a..d1ae8da82 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/param/TransferParam.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/param/TransferParam.java @@ -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 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; } diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/param/TransferReportInfo.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/param/TransferReportInfo.java new file mode 100644 index 000000000..99bb3be71 --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/param/TransferReportInfo.java @@ -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; +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/result/TransferCreateResult.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/result/TransferCreateResult.java new file mode 100644 index 000000000..175a8a85d --- /dev/null +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/result/TransferCreateResult.java @@ -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; +} diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/runtime/service/TransferAssistService.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/runtime/service/TransferAssistService.java index 28c1511a4..2642b8d34 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/runtime/service/TransferAssistService.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/runtime/service/TransferAssistService.java @@ -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 reportInfos) { + if (reportInfos == null || reportInfos.isEmpty()) { + return null; + } + return JSONUtil.toJsonStr(reportInfos); + } + + /// 反序列化报备信息(从容器恢复) + private List deserializeReportInfos(String json) { + if (StrUtil.isBlank(json)) { + return null; + } + return JSONUtil.toList(json, TransferReportInfo.class); } } diff --git a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/runtime/service/TransferStartService.java b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/runtime/service/TransferStartService.java index 8f3609786..95f9e7b39 100644 --- a/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/runtime/service/TransferStartService.java +++ b/daxpay-payment/daxpay-payment-core/src/main/java/cn/daxpay/open/payment/trade/transfer/runtime/service/TransferStartService.java @@ -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(); } /// 建单(事务内: 通道参数校验 + 容器/凭证双写) diff --git a/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/controller/trade/MchTransferController.java b/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/controller/trade/MchTransferController.java index 2edc883e4..8ae69042d 100644 --- a/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/controller/trade/MchTransferController.java +++ b/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/controller/trade/MchTransferController.java @@ -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 wechatCreate(@Valid @RequestBody TransferParam param) { - mchTransferService.create("wechat", param); - return Res.ok(); + public Result 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); } // ===== 支付宝转账 ===== diff --git a/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/service/trade/MchTransferService.java b/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/service/trade/MchTransferService.java index 46711740a..6d608d1c4 100644 --- a/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/service/trade/MchTransferService.java +++ b/daxpay-payment/daxpay-payment-merchant/src/main/java/cn/daxpay/open/payment/merchant/service/trade/MchTransferService.java @@ -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); } /// 同步转账状态