富友支付支持

This commit is contained in:
2026-08-18 17:13:20 +08:00
parent 46c8e819df
commit 656a921ff0
17 changed files with 622 additions and 16 deletions

View File

@@ -444,6 +444,7 @@ func registerAgentRechargeRecoveryTask(mux *asynq.ServeMux, runtime *workerRunti
runtime.db,
paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(runtime.redisClient), integration, appLogger),
paymentInfra.NewAlipayWapAdapter(integration, appLogger),
paymentInfra.NewFuiouScanAdapter(integration, appLogger),
confirm,
runtime.workerResult.Services.PaymentAudit,
)

View File

@@ -49,17 +49,18 @@ type OnlineCreationService struct {
db *gorm.DB
wechat OnlinePaymentPort
alipay OnlinePaymentPort
fuiou OnlinePaymentPort
audit PaymentAuditWriter
}
// NewOnlineCreationService 创建代理在线充值用例并以结构体字段注入个渠道 Adapter。
func NewOnlineCreationService(db *gorm.DB, wechat, alipay OnlinePaymentPort, audit PaymentAuditWriter) *OnlineCreationService {
return &OnlineCreationService{db: db, wechat: wechat, alipay: alipay, audit: audit}
// NewOnlineCreationService 创建代理在线充值用例并以结构体字段注入个渠道 Adapter。
func NewOnlineCreationService(db *gorm.DB, wechat, alipay, fuiou OnlinePaymentPort, audit PaymentAuditWriter) *OnlineCreationService {
return &OnlineCreationService{db: db, wechat: wechat, alipay: alipay, fuiou: fuiou, audit: audit}
}
// Execute 以短事务建单,事务外生成支付链接,再条件保存链接或关闭失败订单。
func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlineCommand) (*CreateOnlineResult, error) {
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.audit == nil {
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.audit == nil {
return nil, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值能力未配置")
}
command.PaymentMethod = strings.TrimSpace(command.PaymentMethod)
@@ -139,7 +140,7 @@ func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, use
}
return result, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询生效支付配置失败")
}
if s.wechat.Available(&config) {
if s.wechat.Available(&config) || s.fuiou.Available(&config) {
result.Methods = append(result.Methods, constants.RechargeMethodWechat)
}
if s.alipay.Available(&config) {
@@ -180,7 +181,7 @@ func (s *OnlineCreationService) loadCreationFacts(
}
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询生效支付配置失败")
}
adapter := s.adapter(command.PaymentMethod)
adapter := s.adapter(command.PaymentMethod, &config)
if adapter == nil || !adapter.Available(&config) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeNoPaymentConfig)
}
@@ -209,7 +210,7 @@ func (s *OnlineCreationService) createLocalFacts(
expireMinutes = model.DefaultAliPayExpireMinutes
}
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
channel, requestID := command.PaymentMethod, command.RequestID
channel, requestID := paymentChannel(command.PaymentMethod, config), command.RequestID
record := &model.AgentRechargeRecord{
UserID: account.ID, AgentWalletID: wallet.ID, ShopID: shop.ID, RechargeNo: rechargeNo,
Amount: command.Amount, PaymentMethod: command.PaymentMethod, PaymentChannel: &channel,
@@ -247,6 +248,9 @@ func paymentMerchantIdentity(paymentMethod string, config *model.WechatConfig) s
return ""
}
if paymentMethod == constants.RechargeMethodWechat {
if config.ProviderType == model.ProviderTypeFuiou {
return config.FyMchntCd
}
return config.WxMchID
}
if paymentMethod == constants.RechargeMethodAlipay {
@@ -255,6 +259,14 @@ func paymentMerchantIdentity(paymentMethod string, config *model.WechatConfig) s
return ""
}
// paymentChannel 返回实际支付渠道:富友配置下微信业务方式落库为 fuiou其余与业务方式一致。
func paymentChannel(paymentMethod string, config *model.WechatConfig) string {
if paymentMethod == constants.RechargeMethodWechat && config != nil && config.ProviderType == model.ProviderTypeFuiou {
return model.ProviderTypeFuiou
}
return paymentMethod
}
func (s *OnlineCreationService) loadReplay(
ctx context.Context,
command CreateOnlineCommand,
@@ -316,8 +328,11 @@ func (s *OnlineCreationService) closeFailedCreation(ctx context.Context, result
})
}
func (s *OnlineCreationService) adapter(paymentMethod string) OnlinePaymentPort {
func (s *OnlineCreationService) adapter(paymentMethod string, config *model.WechatConfig) OnlinePaymentPort {
if paymentMethod == constants.RechargeMethodWechat {
if config != nil && config.ProviderType == model.ProviderTypeFuiou {
return s.fuiou
}
return s.wechat
}
if paymentMethod == constants.RechargeMethodAlipay {

View File

@@ -16,19 +16,20 @@ type RecoverOnlinePaymentService struct {
db *gorm.DB
wechat OnlinePaymentPort
alipay OnlinePaymentPort
fuiou OnlinePaymentPort
confirm *ConfirmOnlinePaymentService
audit PaymentAuditWriter
now func() time.Time
}
// NewRecoverOnlinePaymentService 创建代理在线充值支付恢复用例。
func NewRecoverOnlinePaymentService(db *gorm.DB, wechat, alipay OnlinePaymentPort, confirm *ConfirmOnlinePaymentService, audit PaymentAuditWriter) *RecoverOnlinePaymentService {
return &RecoverOnlinePaymentService{db: db, wechat: wechat, alipay: alipay, confirm: confirm, audit: audit, now: time.Now}
func NewRecoverOnlinePaymentService(db *gorm.DB, wechat, alipay, fuiou OnlinePaymentPort, confirm *ConfirmOnlinePaymentService, audit PaymentAuditWriter) *RecoverOnlinePaymentService {
return &RecoverOnlinePaymentService{db: db, wechat: wechat, alipay: alipay, fuiou: fuiou, confirm: confirm, audit: audit, now: time.Now}
}
// ProcessBatch 按固定批次读取本地待处理事实并调用对应渠道收敛状态。
func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, error) {
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.confirm == nil || s.audit == nil {
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.confirm == nil || s.audit == nil {
return 0, errors.New(errors.CodeServiceUnavailable, "代理在线充值支付恢复能力未配置")
}
now := s.now().UTC()
@@ -70,7 +71,7 @@ func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, er
}
func (s *RecoverOnlinePaymentService) recoverOne(ctx context.Context, payment *model.Payment, recharge *model.AgentRechargeRecord, config *model.WechatConfig, now time.Time) error {
adapter := s.adapter(payment.PaymentMethod)
adapter := s.adapter(payment.PaymentMethod, config)
if adapter == nil {
return errors.New(errors.CodeNoPaymentConfig, "代理充值创建时支付配置不可用")
}
@@ -200,8 +201,11 @@ func (s *RecoverOnlinePaymentService) closePending(ctx context.Context, payment
})
}
func (s *RecoverOnlinePaymentService) adapter(paymentMethod string) OnlinePaymentPort {
func (s *RecoverOnlinePaymentService) adapter(paymentMethod string, config *model.WechatConfig) OnlinePaymentPort {
if paymentMethod == constants.RechargeMethodWechat {
if config != nil && config.ProviderType == model.ProviderTypeFuiou {
return s.fuiou
}
return s.wechat
}
if paymentMethod == constants.RechargeMethodAlipay {

View File

@@ -288,6 +288,7 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.DB,
paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(deps.Redis), paymentIntegration, deps.Logger),
paymentInfra.NewAlipayWapAdapter(paymentIntegration, deps.Logger),
paymentInfra.NewFuiouScanAdapter(paymentIntegration, deps.Logger),
auditWriter,
)
agentRechargePaymentConfirm := agentrechargeApp.NewConfirmOnlinePaymentService(

View File

@@ -3,6 +3,7 @@ package agentrecharge
import (
"strings"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
@@ -51,8 +52,11 @@ func ValidatePaymentConfirmation(facts PaymentConfirmationFacts) (bool, error) {
return false, errors.New(errors.CodeConflict, "支付单与代理充值单关联不一致")
}
method := strings.TrimSpace(facts.PaymentMethod)
if method == "" || method != strings.TrimSpace(facts.RechargePaymentMethod) ||
method != strings.TrimSpace(facts.RechargePaymentChannel) {
if method == "" || method != strings.TrimSpace(facts.RechargePaymentMethod) {
return false, errors.New(errors.CodeConflict, "支付渠道与代理充值单不一致")
}
channel := strings.TrimSpace(facts.RechargePaymentChannel)
if channel != method && !(method == constants.RechargeMethodWechat && channel == model.ProviderTypeFuiou) {
return false, errors.New(errors.CodeConflict, "支付渠道与代理充值单不一致")
}
identity := strings.TrimSpace(facts.MerchantIdentity)

View File

@@ -283,8 +283,13 @@ func (h *PaymentHandler) confirmAgentRechargePayment(ctx context.Context, callba
correlationID := callback.PaymentNo
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: correlationID})
linkage := auditcontext.From(ctx)
// 富友本质是微信支付上游通道,回调渠道归一化为业务方式 wechat 后再进入确认用例。
paymentMethod := callback.PaymentMethod
if paymentMethod == model.ProviderTypeFuiou {
paymentMethod = constants.RechargeMethodWechat
}
result, confirmErr := h.agentPaymentConfirm.Execute(ctx, agentrechargeApp.ConfirmOnlinePaymentCommand{
PaymentNo: callback.PaymentNo, PaymentMethod: callback.PaymentMethod, ConfigID: callback.ConfigID,
PaymentNo: callback.PaymentNo, PaymentMethod: paymentMethod, ConfigID: callback.ConfigID,
MerchantIdentity: callback.MerchantIdentity, ThirdPartyTradeNo: callback.TransactionID,
Amount: callback.Amount, PaidAt: callback.PaidAt, RequestID: linkage.RequestID,
CorrelationID: correlationID, ParentEventID: linkage.ParentEventID,

View File

@@ -0,0 +1,172 @@
package payment
import (
"context"
"strconv"
"strings"
"time"
"go.uber.org/zap"
agentrecharge "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/fuiou"
)
// FuiouScanAdapter 按富友主扫统一下单生成微信扫码支付链接并主动查单。
type FuiouScanAdapter struct {
integration *integrationlog.Repository
logger *zap.Logger
}
// NewFuiouScanAdapter 创建富友扫码支付适配器。
func NewFuiouScanAdapter(integration *integrationlog.Repository, logger *zap.Logger) *FuiouScanAdapter {
return &FuiouScanAdapter{integration: integration, logger: logger}
}
// Available 判断富友主扫下单、验签与查单所需的配置是否完整。
func (a *FuiouScanAdapter) Available(config *model.WechatConfig) bool {
return fuiouConfigComplete(config, true)
}
// CreatePaymentURL 调用富友主扫统一下单并返回二维码链接。
func (a *FuiouScanAdapter) CreatePaymentURL(ctx context.Context, request agentrecharge.OnlinePaymentRequest) (agentrecharge.OnlinePaymentResult, error) {
attempt, err := a.startAttempt(ctx, request, constants.IntegrationOperationPaymentPreCreate, request.Config.ID, request.Amount)
if err != nil {
return agentrecharge.OnlinePaymentResult{}, err
}
startedAt := time.Now()
client, err := a.newClient(request.Config)
if err != nil {
return agentrecharge.OnlinePaymentResult{}, a.completeUnknown(ctx, attempt.IntegrationID, startedAt, err)
}
expireMinutes := int(time.Until(request.ExpireAt).Minutes())
if expireMinutes < 1 {
expireMinutes = 1
}
resp, callErr := client.PreCreate(
request.PaymentNo, strconv.FormatInt(request.Amount, 10), request.Description,
fuiou.GetServerIP(), fuiou.OrderTypeWechat, strconv.Itoa(expireMinutes),
)
if callErr != nil {
return agentrecharge.OnlinePaymentResult{}, a.completeUnknown(ctx, attempt.IntegrationID, startedAt, callErr)
}
if strings.TrimSpace(resp.QrCode) == "" {
return agentrecharge.OnlinePaymentResult{}, a.completeFailed(ctx, attempt.IntegrationID, startedAt, "empty_qr_code", "富友主扫下单未返回二维码链接")
}
if _, err = a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, ResponseSummary: map[string]any{"success": true},
DurationMS: time.Since(startedAt).Milliseconds(), StateChanged: true,
}); err != nil {
return agentrecharge.OnlinePaymentResult{}, err
}
return agentrecharge.OnlinePaymentResult{QRContent: resp.QrCode}, nil
}
// Query 调用富友订单查询并按 trans_stat 返回统一查询结果。
func (a *FuiouScanAdapter) Query(ctx context.Context, request agentrecharge.OnlinePaymentRequest) (agentrecharge.OnlinePaymentQueryResult, error) {
attempt, err := a.startAttempt(ctx, request, constants.IntegrationOperationPaymentQuery, request.Config.ID, 0)
if err != nil {
return agentrecharge.OnlinePaymentQueryResult{}, err
}
startedAt := time.Now()
client, err := a.newClient(request.Config)
if err != nil {
return agentrecharge.OnlinePaymentQueryResult{}, a.completeUnknown(ctx, attempt.IntegrationID, startedAt, err)
}
resp, callErr := client.CommonQuery(request.PaymentNo, fuiou.OrderTypeWechat)
if callErr != nil {
return agentrecharge.OnlinePaymentQueryResult{}, a.completeUnknown(ctx, attempt.IntegrationID, startedAt, callErr)
}
result := agentrecharge.OnlinePaymentQueryResult{State: mapFuiouTransStat(resp.TransStat)}
if result.State == agentrecharge.OnlinePaymentStatePaid {
result.ThirdPartyTradeNo = strings.TrimSpace(resp.TransactionId)
result.Amount, _ = strconv.ParseInt(strings.TrimSpace(resp.OrderAmt), 10, 64)
if paidAt, ok := parseWechatPaidAt(strings.TrimSpace(resp.ReservedTxnFinTs)); ok {
result.PaidAt = &paidAt
}
}
providerCode := resp.TransStat
if strings.TrimSpace(providerCode) == "" {
providerCode = "unknown"
}
if _, err = a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, ProviderCode: providerCode,
ResponseSummary: map[string]any{"state": result.State, "has_trade_no": result.ThirdPartyTradeNo != ""},
DurationMS: time.Since(startedAt).Milliseconds(),
}); err != nil {
return agentrecharge.OnlinePaymentQueryResult{}, err
}
return result, nil
}
func fuiouConfigComplete(config *model.WechatConfig, requireActive bool) bool {
return config != nil && (!requireActive || config.IsActive) && config.ProviderType == model.ProviderTypeFuiou &&
config.FyInsCd != "" && config.FyMchntCd != "" && config.FyTermID != "" &&
config.FyPrivateKey != "" && config.FyPublicKey != "" && config.FyAPIURL != "" && config.FyNotifyURL != ""
}
func (a *FuiouScanAdapter) newClient(config *model.WechatConfig) (*fuiou.Client, error) {
if !fuiouConfigComplete(config, false) {
return nil, apperrors.New(apperrors.CodeNoPaymentConfig, "富友扫码支付配置不可用")
}
return fuiou.NewClient(
config.FyInsCd, config.FyMchntCd, config.FyTermID, config.FyAPIURL, config.FyNotifyURL,
config.FyPrivateKey, config.FyPublicKey, a.logger,
)
}
func mapFuiouTransStat(transStat string) string {
switch transStat {
case "SUCCESS":
return agentrecharge.OnlinePaymentStatePaid
case "PAYERROR", "CLOSED", "REVOKED":
return agentrecharge.OnlinePaymentStateClosed
case "USERPAYING", "NOTPAY":
return agentrecharge.OnlinePaymentStatePending
default:
return agentrecharge.OnlinePaymentStateUnknown
}
}
func (a *FuiouScanAdapter) startAttempt(ctx context.Context, request agentrecharge.OnlinePaymentRequest, operation string, configID uint, amount int64) (*model.IntegrationLog, error) {
resourceID, resourceKey := strconv.FormatUint(uint64(request.PaymentID), 10), request.PaymentNo
series := "agent-recharge-payment:" + resourceID + ":" + operation
correlationID := request.CorrelationID
return a.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderFuiou, Direction: constants.IntegrationDirectionOutbound,
Operation: operation, ResourceType: constants.IntegrationResourceTypeAgentRechargePayment,
ResourceID: &resourceID, ResourceKey: &resourceKey, ExternalID: &resourceKey,
TriggerSeries: &series, CorrelationID: &correlationID,
RequestSummary: map[string]any{"payment_config_id": configID, "amount": amount},
})
}
func (a *FuiouScanAdapter) completeUnknown(ctx context.Context, integrationID string, startedAt time.Time, cause error) error {
if a.logger != nil {
a.logger.Warn("富友支付请求结果未知", zap.String("integration_id", integrationID), zap.Error(cause))
}
_, err := a.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", SafeProviderMessage: "富友支付请求结果未知",
ResponseSummary: map[string]any{"success": false}, DurationMS: time.Since(startedAt).Milliseconds(),
RecoveryStrategy: "使用原支付单号主动查单,确认不存在或关闭后才允许关闭本地支付单",
})
if err != nil {
return err
}
return apperrors.Wrap(apperrors.CodeTimeout, cause, "富友支付请求结果未知")
}
func (a *FuiouScanAdapter) completeFailed(ctx context.Context, integrationID string, startedAt time.Time, providerCode, providerMessage string) error {
_, err := a.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, ProviderCode: providerCode, SafeProviderMessage: providerMessage,
ResponseSummary: map[string]any{"success": false}, DurationMS: time.Since(startedAt).Milliseconds(),
})
if err != nil {
return err
}
return apperrors.New(apperrors.CodeServiceUnavailable, providerMessage)
}

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-18

View File

@@ -0,0 +1,47 @@
## Context
代理在线充值现有 `OnlinePaymentPort` 抽象只有微信直连 H5/MWEB 与支付宝 WAP 两个 Adapter`payment-methods` 只调用 `wechat.Available``alipay.Available`。当前生效配置为富友时,微信 Adapter 因 `provider_type=fuiou` 判定不可用,导致只返回 `alipay`。富友本质是微信支付上游通道,`pkg/fuiou` 已具备 XML/GBK/双重 URL 编码/RSA 签名验签与回调解析能力,但缺少主扫下单与订单查询。参见 proposal.md - Why。
## Goals / Non-Goals
**Goals:**
- 对外支付方式枚举保持 `wechat` / `alipay`,不暴露 `fuiou`
- 富友配置下 `wechat` 走富友主扫统一下单,复用现有 `qr_content` 契约。
- 富友通道下主动查单通过 `/commonQuery` 收敛状态,不重建支付链接。
- 回调与确认校验接受 `channel=fuiou` 对应业务方式 `wechat`
**Non-Goals:**
- 不新增对外 `fuiou` 支付方式,不改前端枚举。
- 不接入富友退款、撤销、条码支付(商户扫用户)等其它交易类型。
- 不改支付宝通道(仍走直接支付宝 WAP
## Decisions
### 新增富友扫码 Adapter 而非扩展微信 Adapter
新增 `FuiouScanAdapter` 实现 `OnlinePaymentPort``Available` 判定 `provider_type==fuiou` 且富友字段完整;`CreatePaymentURL` 调主扫下单返回 `qr_code``Query` 调订单查询映射 `trans_stat`。备选方案是在 `WechatWebAdapter` 内部分支,但会混淆微信直连与富友的日志提供方、错误语义与恢复策略,故放弃。
### Adapter 选择改为配置感知
`OnlineCreationService``RecoverOnlinePaymentService``adapter()` 增加 `config` 参数:`wechat` 方法 + `provider_type==fuiou` 返回富友 Adapter否则返回微信 Adapter。恢复阶段富友与微信一致只查单不重建链接。
### 业务方式与渠道分离存储
`Payment.PaymentMethod` 与充值记录 `PaymentMethod` 保持 `wechat`(业务语义),充值记录 `PaymentChannel``fuiou`(实际渠道);`paymentMerchantIdentity` 在富友下返回 `FyMchntCd`。回调 `PaymentMethod=fuiou` 在确认入口归一化为业务方式 `wechat`,领域校验允许 `channel=fuiou` 映射到 `method=wechat`
### 复用现有富友回调
异步通知复用 `FuiouPayCallback``VerifyNotify``NotifyRequest` 字段与主扫通知报文一致,不做改动。
## Risks / Trade-offs
- [富友主扫 `mchnt_order_no` 必须全局唯一,重复会被拒绝] → 复用本地 `payment_no` 作为商户订单号,且恢复阶段只查单不重建链接。
- [富友查询 `trans_stat``9999`/空/`1010` 时状态未知] → 映射为 unknown保持待恢复继续查不确认收款也不关闭。
- [富友 `reserved_*` 字段不参与签名且渠道会新增] → 复用 `pkg/fuiou` 现有 `structToMap` 排除 reserved 前缀的签名规则。
- [回调无支付时间或金额不一致] → 现有确认用例已校验金额、配置身份与支付时间,富友金额用 `order_amt`(分)、时间用 `reserved_txn_fin_ts`
## Migration Plan
无数据库迁移、无新外部依赖。代码上线后,将生效支付配置切为富友即可使代理在线充值展示微信扫码;回滚为恢复生效配置为微信直连或回退代码,不改变既有数据语义。

View File

@@ -0,0 +1,31 @@
## Why
当前生效支付配置为富友(`provider_type=fuiou`)时,代理在线充值可用支付方式接口只返回 `alipay`,不返回 `wechat`。原因是现有微信 Adapter 只支持微信直连(`wechat`/`wechat_v2`),而富友虽然本质是微信支付上游通道,却未接入代理在线充值链路。本变更让富友主扫下单成为内部 `wechat` 通道,使代理在线充值在富友配置下也能展示并完成微信扫码支付。
## What Changes
- 代理在线充值 `payment-methods` 在富友配置完整时把 `wechat` 列为可用支付方式(对外枚举仍只有 `wechat``alipay`,不新增 `fuiou`)。
- `POST /api/admin/agent-recharges` 使用 `wechat` 创建时,若生效配置为富友,则调用富友主扫统一下单,返回 `qr_code` 作为支付链接。
- 新增富友主扫下单(`/preCreate`)与主动查单(`/commonQuery`)客户端能力;支付结果继续复用现有富友异步通知回调。
- 代理在线充值支付恢复(主动查单)在富友通道下通过 `commonQuery` 收敛状态,不重建链接。
- 支付确认校验允许富友渠道(`channel=fuiou`)对应业务支付方式 `wechat`
## Capabilities
### New Capabilities
(无)
### Modified Capabilities
- `agent-funds-commission`: 代理在线充值可用支付方式与创建行为在富友配置下按微信通道处理。
- `external-integration`: 富友主扫统一下单与订单查询的调用、状态映射与失败边界。
## Impact
- `internal/application/agentrecharge``OnlineCreationService``RecoverOnlinePaymentService`、确认用例渠道校验)
- `internal/domain/agentrecharge`(支付确认渠道一致性校验)
- `internal/infrastructure/payment`(新增富友扫码 Adapter
- `pkg/fuiou`(新增主扫下单与订单查询请求/响应)
- `internal/handler/callback`(富友回调支付方式归一化为业务方式 `wechat`
- 无数据库迁移、无新外部依赖;对外支付方式枚举不变。

View File

@@ -0,0 +1,29 @@
## ADDED Requirements
### Requirement: 代理在线充值可用支付方式按支付配置判定
系统 SHALL 按当前生效支付配置判定代理在线充值可用支付方式:微信直连(`wechat``wechat_v2`)配置完整,或富友(`fuiou`)配置完整时,返回 `wechat`;支付宝字段完整时返回 `alipay`。对外支付方式枚举 MUST 固定为 `wechat``alipay`MUST NOT 返回 `fuiou`。代理账号以 `wechat` 创建在线充值单时,若生效配置为富友,系统 MUST 使用富友主扫统一下单并将返回的二维码链接作为支付链接。
#### Scenario: 富友配置完整时微信可用
- **GIVEN** 当前生效支付配置 `provider_type=fuiou` 且富友机构号、商户号、终端号、私钥、公钥、API 地址、通知地址均非空
- **WHEN** 代理账号查询可用支付方式
- **THEN** 系统返回包含 `wechat` 的方式列表且不包含 `fuiou`
#### Scenario: 微信直连配置完整时微信可用
- **GIVEN** 当前生效支付配置为微信直连且对应字段完整
- **WHEN** 代理账号查询可用支付方式
- **THEN** 系统返回包含 `wechat` 的方式列表
#### Scenario: 支付宝字段完整时支付宝可用
- **GIVEN** 当前生效支付配置的支付宝应用 ID、应用私钥、支付宝公钥、通知地址均非空
- **WHEN** 代理账号查询可用支付方式
- **THEN** 系统返回包含 `alipay` 的方式列表
#### Scenario: 富友配置下微信创建走主扫下单
- **GIVEN** 当前生效支付配置为富友且字段完整
- **WHEN** 代理账号以 `wechat` 创建在线充值单
- **THEN** 系统调用富友主扫统一下单并返回二维码链接作为支付链接,本地充值单支付方式为 `wechat`、支付渠道为 `fuiou`

View File

@@ -0,0 +1,36 @@
## ADDED Requirements
### Requirement: 富友主扫统一下单与订单查询
系统 SHALL 通过富友主扫统一下单创建微信二维码支付并返回 `qr_code` 二维码链接;系统 SHALL 通过富友订单查询按 `trans_stat` 将状态映射为已支付、已关闭、待支付或未知,未知状态 MUST 保持待恢复。下单与查询失败 MUST 映射为项目稳定错误,已接入外部交互日志的调用保留脱敏结果。
#### Scenario: 主扫下单成功返回二维码链接
- **GIVEN** 富友支付配置完整
- **WHEN** 系统发起主扫统一下单且渠道返回成功
- **THEN** 系统返回 `qr_code` 作为支付链接,业务以该链接生成二维码
#### Scenario: 查询映射支付成功
- **WHEN** 富友订单查询返回 `trans_stat=SUCCESS`
- **THEN** 系统将状态映射为已支付并取得渠道交易号、金额与支付时间
#### Scenario: 查询映射已关闭
- **WHEN** 富友订单查询返回 `trans_stat``PAYERROR``CLOSED``REVOKED`
- **THEN** 系统将状态映射为已关闭
#### Scenario: 查询映射待支付
- **WHEN** 富友订单查询返回 `trans_stat``USERPAYING``NOTPAY`
- **THEN** 系统将状态映射为待支付
#### Scenario: 查询状态未知保持待恢复
- **WHEN** 富友订单查询返回系统错误、找不到交易或无法识别的 `trans_stat`
- **THEN** 系统将状态映射为未知并保持本地支付单待恢复,不得据此确认收款或关闭订单
#### Scenario: 下单失败返回稳定错误
- **WHEN** 富友主扫统一下单返回失败或请求结果未知
- **THEN** 系统返回项目稳定错误且已接入外部交互日志的调用记录脱敏结果

View File

@@ -0,0 +1,33 @@
## 1. 富友主扫下单与查询客户端
- [x] 1.1 在 `pkg/fuiou` 新增主扫统一下单请求/响应结构(`/preCreate`,含 `order_type``notify_url``reserved_expire_minute`,响应含 `qr_code`
- [x] 1.2 在 `pkg/fuiou` 新增主扫下单方法,复用 `Client.Sign``DoRequest`
- [x] 1.3 在 `pkg/fuiou` 新增订单查询请求/响应结构(`/commonQuery`,响应含 `trans_stat``order_amt``transaction_id``reserved_txn_fin_ts`
- [x] 1.4 在 `pkg/fuiou` 新增订单查询方法,复用 `Client.Sign``DoRequest`
- [x] 1.5 补充 `trans_stat` 到统一支付状态的映射SUCCESS→已支付PAYERROR/CLOSED/REVOKED→已关闭USERPAYING/NOTPAY→待支付其余→未知
## 2. 富友扫码 Adapter
- [x] 2.1 新增 `internal/infrastructure/payment/fuiou_scan.go``FuiouScanAdapter`,实现 `OnlinePaymentPort`
- [x] 2.2 `Available` 判定 `provider_type==fuiou` 且富友机构号/商户号/终端号/私钥/公钥/API 地址/通知地址完整
- [x] 2.3 `CreatePaymentURL` 调主扫下单并以 `qr_code` 作为 `QRContent`,接入外部交互日志
- [x] 2.4 `Query` 调订单查询并按映射返回统一查询结果
## 3. 代理在线充值 Adapter 选择与渠道事实
- [x] 3.1 `OnlineCreationService` 注入富友 Adapter`adapter()` 增加配置参数并按 `provider_type==fuiou` 分流
- [x] 3.2 `RecoverOnlinePaymentService` 同样注入并按配置分流,恢复阶段富友只查单不重建链接
- [x] 3.3 `paymentMerchantIdentity` 在富友下返回 `FyMchntCd`
- [x] 3.4 创建本地事实时 `PaymentChannel``fuiou``PaymentMethod` 仍存 `wechat`
## 4. 回调与确认校验
- [x] 4.1 `confirmAgentRechargePayment` 将回调 `PaymentMethod==fuiou` 归一化为业务方式 `wechat` 后再进入确认用例
- [x] 4.2 `domain.ValidatePaymentConfirmation` 允许 `channel=fuiou` 对应 `method=wechat`,保留其余一致性校验
## 5. 装配与验证
- [x] 5.1 `bootstrap/services.go` 注入富友扫码 Adapter 到在线创建与恢复服务
- [x] 5.2 `gofmt -w` 变更文件,`go build ./cmd/api ./cmd/worker` 通过
- [x] 5.3 `go run cmd/gendocs/main.go` 重新生成文档(如路由/DTO 有变化)
- [x] 5.4 `openspec validate --all` 通过

View File

@@ -122,6 +122,34 @@
- **WHEN** 充值申请原创建账号不可用,或企业微信线下充值审批场景不可用
- **THEN** 系统返回相应错误,充值申请保持未关联审批实例,修复条件后可再次发起
### Requirement: 代理在线充值可用支付方式按支付配置判定
系统 SHALL 按当前生效支付配置判定代理在线充值可用支付方式:微信直连(`wechat``wechat_v2`)配置完整,或富友(`fuiou`)配置完整时,返回 `wechat`;支付宝字段完整时返回 `alipay`。对外支付方式枚举 MUST 固定为 `wechat``alipay`MUST NOT 返回 `fuiou`。代理账号以 `wechat` 创建在线充值单时,若生效配置为富友,系统 MUST 使用富友主扫统一下单并将返回的二维码链接作为支付链接。
#### Scenario: 富友配置完整时微信可用
- **GIVEN** 当前生效支付配置 `provider_type=fuiou` 且富友机构号、商户号、终端号、私钥、公钥、API 地址、通知地址均非空
- **WHEN** 代理账号查询可用支付方式
- **THEN** 系统返回包含 `wechat` 的方式列表且不包含 `fuiou`
#### Scenario: 微信直连配置完整时微信可用
- **GIVEN** 当前生效支付配置为微信直连且对应字段完整
- **WHEN** 代理账号查询可用支付方式
- **THEN** 系统返回包含 `wechat` 的方式列表
#### Scenario: 支付宝字段完整时支付宝可用
- **GIVEN** 当前生效支付配置的支付宝应用 ID、应用私钥、支付宝公钥、通知地址均非空
- **WHEN** 代理账号查询可用支付方式
- **THEN** 系统返回包含 `alipay` 的方式列表
#### Scenario: 富友配置下微信创建走主扫下单
- **GIVEN** 当前生效支付配置为富友且字段完整
- **WHEN** 代理账号以 `wechat` 创建在线充值单
- **THEN** 系统调用富友主扫统一下单并返回二维码链接作为支付链接,本地充值单支付方式为 `wechat`、支付渠道为 `fuiou`
## 可达操作索引
本节只用于入口导航,不是行为 Requirement业务义务以上述 Requirements 为准。

View File

@@ -66,6 +66,41 @@
- **WHEN** 渠道再次发送相同业务事实
- **THEN** 系统返回渠道可接受响应且不重复推进卡状态
### Requirement: 富友主扫统一下单与订单查询
系统 SHALL 通过富友主扫统一下单创建微信二维码支付并返回 `qr_code` 二维码链接;系统 SHALL 通过富友订单查询按 `trans_stat` 将状态映射为已支付、已关闭、待支付或未知,未知状态 MUST 保持待恢复。下单与查询失败 MUST 映射为项目稳定错误,已接入外部交互日志的调用保留脱敏结果。
#### Scenario: 主扫下单成功返回二维码链接
- **GIVEN** 富友支付配置完整
- **WHEN** 系统发起主扫统一下单且渠道返回成功
- **THEN** 系统返回 `qr_code` 作为支付链接,业务以该链接生成二维码
#### Scenario: 查询映射支付成功
- **WHEN** 富友订单查询返回 `trans_stat=SUCCESS`
- **THEN** 系统将状态映射为已支付并取得渠道交易号、金额与支付时间
#### Scenario: 查询映射已关闭
- **WHEN** 富友订单查询返回 `trans_stat``PAYERROR``CLOSED``REVOKED`
- **THEN** 系统将状态映射为已关闭
#### Scenario: 查询映射待支付
- **WHEN** 富友订单查询返回 `trans_stat``USERPAYING``NOTPAY`
- **THEN** 系统将状态映射为待支付
#### Scenario: 查询状态未知保持待恢复
- **WHEN** 富友订单查询返回系统错误、找不到交易或无法识别的 `trans_stat`
- **THEN** 系统将状态映射为未知并保持本地支付单待恢复,不得据此确认收款或关闭订单
#### Scenario: 下单失败返回稳定错误
- **WHEN** 富友主扫统一下单返回失败或请求结果未知
- **THEN** 系统返回项目稳定错误且已接入外部交互日志的调用记录脱敏结果
## 可达操作索引
本节只用于入口导航,不是行为 Requirement业务义务以上述 Requirements 为准。

92
pkg/fuiou/scan.go Normal file
View File

@@ -0,0 +1,92 @@
package fuiou
import (
"fmt"
"time"
"go.uber.org/zap"
)
// PreCreate 主扫统一下单C扫B生成用户扫码支付的二维码
// orderType: 订单类型WECHAT=微信主扫、ALIPAY=支付宝主扫。
// reservedExpireMinute: 订单有效期分钟reserved 字段,不参与签名)。
func (c *Client) PreCreate(orderNo, amount, goodsDesc, termIP, orderType, reservedExpireMinute string) (*PreCreateResponse, error) {
req := &PreCreateRequest{
Version: "1.0",
InsCd: c.InsCd,
MchntCd: c.MchntCd,
TermId: c.TermId,
RandomStr: generateRandomStr(),
OrderType: orderType,
MchntOrderNo: orderNo,
OrderAmt: amount,
GoodsDesc: goodsDesc,
TermIp: termIP,
TxnBeginTs: time.Now().Format("20060102150405"),
NotifyUrl: c.NotifyURL,
ReservedExpireMinute: reservedExpireMinute,
}
sign, err := c.Sign(req)
if err != nil {
return nil, fmt.Errorf("签名失败: %w", err)
}
req.Sign = sign
var resp PreCreateResponse
if err := c.DoRequest("/preCreate", req, &resp); err != nil {
return nil, fmt.Errorf("请求富友失败: %w", err)
}
if resp.ResultCode != "000000" {
c.logger.Error("富友主扫下单失败",
zap.String("order_no", orderNo),
zap.String("result_code", resp.ResultCode),
zap.String("result_msg", resp.ResultMsg),
)
return nil, fmt.Errorf("富友主扫下单失败: %s", resp.ResultMsg)
}
c.logger.Info("富友主扫下单成功",
zap.String("order_no", orderNo),
zap.String("order_type", orderType),
)
return &resp, nil
}
// CommonQuery 主动查询订单状态。
// orderType: 订单类型,须与下单时一致。
func (c *Client) CommonQuery(orderNo, orderType string) (*CommonQueryResponse, error) {
req := &CommonQueryRequest{
Version: "1.0",
InsCd: c.InsCd,
MchntCd: c.MchntCd,
TermId: c.TermId,
RandomStr: generateRandomStr(),
OrderType: orderType,
MchntOrderNo: orderNo,
}
sign, err := c.Sign(req)
if err != nil {
return nil, fmt.Errorf("签名失败: %w", err)
}
req.Sign = sign
var resp CommonQueryResponse
if err := c.DoRequest("/commonQuery", req, &resp); err != nil {
return nil, fmt.Errorf("请求富友失败: %w", err)
}
if resp.ResultCode != "000000" {
c.logger.Error("富友订单查询失败",
zap.String("order_no", orderNo),
zap.String("result_code", resp.ResultCode),
zap.String("result_msg", resp.ResultMsg),
)
return nil, fmt.Errorf("富友订单查询失败: %s", resp.ResultMsg)
}
return &resp, nil
}

View File

@@ -4,6 +4,12 @@ package fuiou
import "encoding/xml"
// 主扫统一下单与订单查询的订单类型常量
const (
OrderTypeWechat = "WECHAT" // 微信主扫
OrderTypeAlipay = "ALIPAY" // 支付宝主扫
)
// WxPreCreateRequest wxPreCreate 下单请求3.3接口)
// 所有非 reserved 字段必须出现在 XML 中(含空值),否则富友验签不通过
type WxPreCreateRequest struct {
@@ -77,3 +83,68 @@ type NotifyResponse struct {
ResultCode string `xml:"result_code"` // 结果码
ResultMsg string `xml:"result_msg"` // 结果消息
}
// PreCreateRequest 主扫统一下单请求C扫B
// 所有非 reserved 字段必须出现在 XML 中(含空值),否则富友验签不通过
type PreCreateRequest struct {
XMLName xml.Name `xml:"xml"`
Version string `xml:"version"` // 版本号: 1.0
InsCd string `xml:"ins_cd"` // 机构号
MchntCd string `xml:"mchnt_cd"` // 商户号
TermId string `xml:"term_id"` // 终端号
RandomStr string `xml:"random_str"` // 随机字符串
Sign string `xml:"sign"` // 签名
OrderType string `xml:"order_type"` // 订单类型: WECHAT=微信主扫
MchntOrderNo string `xml:"mchnt_order_no"` // 商户订单号
CurrType string `xml:"curr_type"` // 货币类型(可选)
OrderAmt string `xml:"order_amt"` // 订单金额(分)
TermIp string `xml:"term_ip"` // 终端IP
TxnBeginTs string `xml:"txn_begin_ts"` // 交易起始时间,格式 yyyyMMddHHmmss
NotifyUrl string `xml:"notify_url"` // 回调地址
GoodsDesc string `xml:"goods_des"` // 商品描述
GoodsDetail string `xml:"goods_detail"` // 商品详情(可选)
AddnInf string `xml:"addn_inf"` // 附加数据(可选)
ReservedExpireMinute string `xml:"reserved_expire_minute"` // 订单有效期分钟reserved不参与签名
}
// PreCreateResponse 主扫统一下单响应
type PreCreateResponse struct {
ResultCode string `xml:"result_code"` // 结果码: 000000=成功
ResultMsg string `xml:"result_msg"` // 结果消息
InsCd string `xml:"ins_cd"` // 机构号
MchntCd string `xml:"mchnt_cd"` // 商户号
RandomStr string `xml:"random_str"` // 随机字符串
Sign string `xml:"sign"` // 签名
QrCode string `xml:"qr_code"` // 二维码内容
ReservedFyTraceNo string `xml:"reserved_fy_trace_no"` // 富友流水号
}
// CommonQueryRequest 订单查询请求
type CommonQueryRequest struct {
XMLName xml.Name `xml:"xml"`
Version string `xml:"version"` // 版本号: 1.0
InsCd string `xml:"ins_cd"` // 机构号
MchntCd string `xml:"mchnt_cd"` // 商户号
TermId string `xml:"term_id"` // 终端号
RandomStr string `xml:"random_str"` // 随机字符串
Sign string `xml:"sign"` // 签名
OrderType string `xml:"order_type"` // 订单类型
MchntOrderNo string `xml:"mchnt_order_no"` // 商户订单号
}
// CommonQueryResponse 订单查询响应
type CommonQueryResponse struct {
ResultCode string `xml:"result_code"` // 结果码: 000000=成功
ResultMsg string `xml:"result_msg"` // 结果消息
InsCd string `xml:"ins_cd"` // 机构号
MchntCd string `xml:"mchnt_cd"` // 商户号
RandomStr string `xml:"random_str"` // 随机字符串
Sign string `xml:"sign"` // 签名
OrderType string `xml:"order_type"` // 订单类型
MchntOrderNo string `xml:"mchnt_order_no"` // 商户订单号
TransStat string `xml:"trans_stat"` // 交易状态
OrderAmt string `xml:"order_amt"` // 订单金额(分)
TransactionId string `xml:"transaction_id"` // 交易流水号
ReservedTxnFinTs string `xml:"reserved_txn_fin_ts"` // 交易完成时间reserved
ReservedFyTraceNo string `xml:"reserved_fy_trace_no"` // 富友流水号reserved
}