接入顺成支付并优化配置列表
This commit is contained in:
@@ -5,9 +5,11 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/integrations/payment/lakala"
|
||||
"hfb_sys/backend/internal/integrations/payment/leshua"
|
||||
"hfb_sys/backend/internal/integrations/payment/shuncheng"
|
||||
"hfb_sys/backend/internal/modules/paymentconfig"
|
||||
)
|
||||
|
||||
@@ -91,6 +93,7 @@ type channelQueryRefundResponse struct {
|
||||
Status string
|
||||
RefundAmount string
|
||||
RefundTime string
|
||||
RawRequest map[string]string
|
||||
Raw map[string]string
|
||||
}
|
||||
|
||||
@@ -168,7 +171,12 @@ func (c leshuaChannel) CreateRefund(ctx context.Context, req channelCreateRefund
|
||||
Attach: req.Attach,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if rawReq == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &channelCreateRefundResponse{
|
||||
RawRequest: rawReq,
|
||||
}, err
|
||||
}
|
||||
return &channelCreateRefundResponse{
|
||||
OK: resp.RespCode == "0" && resp.ResultCode == "0",
|
||||
@@ -285,7 +293,12 @@ func (c lakalaChannel) CreateRefund(ctx context.Context, req channelCreateRefund
|
||||
ClientIP: req.ClientIP,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if resp == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &channelCreateRefundResponse{
|
||||
RawRequest: resp.RawRequest,
|
||||
}, err
|
||||
}
|
||||
return &channelCreateRefundResponse{
|
||||
OK: resp.OK,
|
||||
@@ -305,6 +318,129 @@ func (c lakalaChannel) QueryRefund(ctx context.Context, req channelQueryRefundRe
|
||||
MerchantRefundID: req.MerchantRefundID,
|
||||
ProviderRefundID: req.ProviderRefundID,
|
||||
})
|
||||
if err != nil {
|
||||
if resp == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &channelQueryRefundResponse{
|
||||
RawRequest: resp.RawRequest,
|
||||
}, err
|
||||
}
|
||||
return &channelQueryRefundResponse{
|
||||
OK: resp.OK,
|
||||
ErrorMessage: resp.ErrorMessage,
|
||||
ProviderRefundID: resp.ProviderRefundID,
|
||||
Status: resp.Status,
|
||||
RefundAmount: resp.RefundAmount,
|
||||
RefundTime: resp.RefundTime,
|
||||
RawRequest: resp.RawRequest,
|
||||
Raw: resp.Raw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c lakalaChannel) VerifyNotify(params map[string]string, rawPayload string, contentType string, authorization string) (channelVerifyNotifyResult, error) {
|
||||
verify := c.client.VerifyNotifyDetail(rawPayload, authorization)
|
||||
result := channelVerifyNotifyResult{
|
||||
OK: verify.OK,
|
||||
MatchedKey: verify.MatchedKey,
|
||||
Got: verify.Got,
|
||||
Expected: verify.Expected,
|
||||
BaseString: verify.BaseString,
|
||||
ParamKeys: verify.ParamKeys,
|
||||
}
|
||||
if !result.OK {
|
||||
return result, ErrPaymentVerifyFailed
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type shunchengChannel struct {
|
||||
client *shuncheng.Client
|
||||
}
|
||||
|
||||
func newShunchengChannel(cfg shuncheng.Config) channelClient {
|
||||
return shunchengChannel{client: shuncheng.NewClient(cfg)}
|
||||
}
|
||||
|
||||
func (c shunchengChannel) CreatePayment(ctx context.Context, req channelCreatePaymentRequest) (*channelCreatePaymentResponse, error) {
|
||||
resp, err := c.client.CreatePayment(ctx, shuncheng.CreatePaymentRequest{
|
||||
ThirdOrderID: req.ThirdOrderID,
|
||||
AmountCent: req.AmountCent,
|
||||
PayWay: req.PayWay,
|
||||
JSPayFlag: req.JSPayFlag,
|
||||
NotifyURL: req.NotifyURL,
|
||||
JumpURL: req.JumpURL,
|
||||
ClientIP: req.ClientIP,
|
||||
Body: req.Body,
|
||||
Attach: req.Attach,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &channelCreatePaymentResponse{
|
||||
OK: resp.OK,
|
||||
ErrorMessage: resp.ErrorMessage,
|
||||
ProviderOrderID: resp.ProviderOrderID,
|
||||
PayWay: resp.PayWay,
|
||||
Status: resp.Status,
|
||||
PayTime: resp.PayTime,
|
||||
TDCode: resp.TDCode,
|
||||
JSPayURL: resp.JSPayURL,
|
||||
JSPayInfo: resp.JSPayInfo,
|
||||
RawRequest: resp.RawRequest,
|
||||
Raw: resp.Raw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c shunchengChannel) QueryPayment(ctx context.Context, thirdOrderID string, providerOrderID string) (*channelQueryPaymentResponse, error) {
|
||||
resp, err := c.client.QueryPayment(ctx, thirdOrderID, providerOrderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &channelQueryPaymentResponse{
|
||||
OK: resp.OK,
|
||||
ErrorMessage: resp.ErrorMessage,
|
||||
ProviderOrderID: resp.ProviderOrderID,
|
||||
Status: resp.Status,
|
||||
Amount: resp.Amount,
|
||||
PayWay: resp.PayWay,
|
||||
PayTime: resp.PayTime,
|
||||
Raw: resp.Raw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c shunchengChannel) CreateRefund(ctx context.Context, req channelCreateRefundRequest) (*channelCreateRefundResponse, error) {
|
||||
resp, err := c.client.CreateRefund(ctx, shuncheng.CreateRefundRequest{
|
||||
ThirdOrderID: req.ThirdOrderID,
|
||||
ProviderOrderID: req.ProviderOrderID,
|
||||
MerchantRefundID: req.MerchantRefundID,
|
||||
RefundAmountCent: req.RefundAmountCent,
|
||||
NotifyURL: req.NotifyURL,
|
||||
Attach: req.Attach,
|
||||
RefundReason: req.Remark,
|
||||
ClientIP: req.ClientIP,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &channelCreateRefundResponse{
|
||||
OK: resp.OK,
|
||||
ErrorMessage: resp.ErrorMessage,
|
||||
ProviderRefundID: resp.ProviderRefundID,
|
||||
Status: resp.Status,
|
||||
RefundAmount: resp.RefundAmount,
|
||||
RawRequest: resp.RawRequest,
|
||||
Raw: resp.Raw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c shunchengChannel) QueryRefund(ctx context.Context, req channelQueryRefundRequest) (*channelQueryRefundResponse, error) {
|
||||
resp, err := c.client.QueryRefund(ctx, shuncheng.QueryRefundRequest{
|
||||
ThirdOrderID: req.ThirdOrderID,
|
||||
ProviderOrderID: req.ProviderOrderID,
|
||||
MerchantRefundID: req.MerchantRefundID,
|
||||
ProviderRefundID: req.ProviderRefundID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -319,8 +455,8 @@ func (c lakalaChannel) QueryRefund(ctx context.Context, req channelQueryRefundRe
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c lakalaChannel) VerifyNotify(params map[string]string, rawPayload string, contentType string, authorization string) (channelVerifyNotifyResult, error) {
|
||||
verify := c.client.VerifyNotifyDetail(rawPayload, authorization)
|
||||
func (c shunchengChannel) VerifyNotify(params map[string]string, rawPayload string, contentType string, authorization string) (channelVerifyNotifyResult, error) {
|
||||
verify := c.client.VerifyNotifyDetail(params)
|
||||
result := channelVerifyNotifyResult{
|
||||
OK: verify.OK,
|
||||
MatchedKey: verify.MatchedKey,
|
||||
@@ -366,6 +502,22 @@ func buildChannelClient(dto *paymentconfig.ConfigDTO) (channelClient, error) {
|
||||
PayMode: extraString(dto.ExtraConfig, "pay_mode"),
|
||||
OrderExpireMinutes: extraInt(dto.ExtraConfig, "order_expire_minutes"),
|
||||
})), nil
|
||||
case "shuncheng":
|
||||
return withChannelBreaker("shuncheng", newShunchengChannel(shuncheng.Config{
|
||||
GatewayURL: dto.GatewayURL,
|
||||
MerchantID: dto.MerchantID,
|
||||
SecretID: extraString(dto.ExtraConfig, "secret_id"),
|
||||
SecretKey: dto.SignKey,
|
||||
NotifyKey: dto.NotifyKey,
|
||||
NotifyURL: dto.NotifyURL,
|
||||
JumpURL: dto.JumpURL,
|
||||
PayWay: firstNonEmpty(dto.PayWay, "ZFBZF"),
|
||||
JSPayFlag: firstNonEmpty(dto.JSPayFlag, "2"),
|
||||
SignType: firstNonEmpty(dto.SignType, "MD5"),
|
||||
ShopNo: extraString(dto.ExtraConfig, "shop_no"),
|
||||
RefundType: extraString(dto.ExtraConfig, "refund_type"),
|
||||
TokenMargin: time.Duration(extraInt(dto.ExtraConfig, "token_refresh_margin_seconds")) * time.Second,
|
||||
})), nil
|
||||
case "mock":
|
||||
return nil, nil
|
||||
default:
|
||||
@@ -398,7 +550,7 @@ func normalizeLeshuaRefundStatus(status string) string {
|
||||
}
|
||||
|
||||
func normalizeNotifyPaymentStatus(provider string, status string) string {
|
||||
if provider == "leshua" {
|
||||
if provider == "leshua" || provider == "shuncheng" {
|
||||
return normalizeLeshuaPaymentStatus(status)
|
||||
}
|
||||
switch status {
|
||||
@@ -410,7 +562,7 @@ func normalizeNotifyPaymentStatus(provider string, status string) string {
|
||||
}
|
||||
|
||||
func normalizeNotifyRefundStatus(provider string, status string) string {
|
||||
if provider == "leshua" {
|
||||
if provider == "leshua" || provider == "shuncheng" {
|
||||
return normalizeLeshuaRefundStatus(status)
|
||||
}
|
||||
switch status {
|
||||
|
||||
@@ -63,7 +63,7 @@ func (r *Repository) confirmPaid(ctx context.Context, payment *model.PaymentOrde
|
||||
}
|
||||
updates := map[string]any{
|
||||
"status": "paid",
|
||||
"provider_order_id": firstNonEmpty(raw["provider_order_id"], raw["leshua_order_id"], raw["pay_order_no"], raw["trade_no"], latest.ProviderOrderID),
|
||||
"provider_order_id": firstNonEmpty(raw["provider_order_id"], raw["schc_order_id"], raw["leshua_order_id"], raw["pay_order_no"], raw["trade_no"], latest.ProviderOrderID),
|
||||
"raw_response": jsonMap(withRawSource(raw, source)),
|
||||
"paid_at": paidAt,
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"hfb_sys/backend/internal/integrations/payment/lakala"
|
||||
"hfb_sys/backend/internal/integrations/payment/leshua"
|
||||
"hfb_sys/backend/internal/integrations/payment/shuncheng"
|
||||
"hfb_sys/backend/internal/middleware"
|
||||
"hfb_sys/backend/internal/modules/order"
|
||||
"hfb_sys/backend/pkg/response"
|
||||
@@ -154,6 +155,40 @@ func (h *Handler) LakalaNotify(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "执行成功"})
|
||||
}
|
||||
|
||||
func (h *Handler) ShunchengNotify(c *gin.Context) {
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
||||
if err != nil {
|
||||
c.String(http.StatusOK, "FAIL")
|
||||
return
|
||||
}
|
||||
params, err := shuncheng.ParsePayload(body)
|
||||
if err != nil {
|
||||
c.String(http.StatusOK, "FAIL")
|
||||
return
|
||||
}
|
||||
rawPayload := string(body)
|
||||
contentType := c.GetHeader("Content-Type")
|
||||
authorization := c.GetHeader("Authorization")
|
||||
log.Printf(
|
||||
"[payment] shuncheng notify received third_order_id=%s schc_order_id=%s schc_refund_id=%s status=%s amount=%s content_type=%s raw_payload=%s",
|
||||
params["third_order_id"],
|
||||
params["schc_order_id"],
|
||||
params["schc_refund_id"],
|
||||
params["status"],
|
||||
params["amount"],
|
||||
contentType,
|
||||
rawPayload,
|
||||
)
|
||||
result, err := h.service.HandleNotify(c.Request.Context(), "shuncheng", params, rawPayload, contentType, authorization)
|
||||
if err != nil || result == nil || !result.OK {
|
||||
log.Printf("[payment] shuncheng notify failed third_order_id=%s err=%v", params["third_order_id"], err)
|
||||
c.String(http.StatusOK, "FAIL")
|
||||
return
|
||||
}
|
||||
log.Printf("[payment] shuncheng notify processed third_order_id=%s status=%s", params["third_order_id"], params["status"])
|
||||
c.String(http.StatusOK, result.Message)
|
||||
}
|
||||
|
||||
func currentUserID(c *gin.Context) (uint64, bool) {
|
||||
value, ok := c.Get(middleware.ContextUserID)
|
||||
if !ok {
|
||||
|
||||
@@ -106,14 +106,13 @@ func (r *Repository) runtimeConfigForPayment(ctx context.Context, payment *model
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if provider != "leshua" {
|
||||
if provider == "lakala" && r.configRepo != nil {
|
||||
dto, err := r.configRepo.FindDefaultByProviderPayWay(ctx, provider, payWay, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return runtimeConfigFromDTO(dto), nil
|
||||
}
|
||||
if provider == "mock" {
|
||||
return &runtimePaymentConfig{
|
||||
Provider: provider,
|
||||
MerchantID: merchantID,
|
||||
}, nil
|
||||
}
|
||||
if provider != "leshua" && provider != "lakala" && provider != "shuncheng" {
|
||||
return &runtimePaymentConfig{
|
||||
Provider: provider,
|
||||
MerchantID: merchantID,
|
||||
|
||||
@@ -24,6 +24,7 @@ var (
|
||||
ErrAppIDRequired = errors.New("extra_config.app_id is required for lakala provider")
|
||||
ErrSerialNoRequired = errors.New("extra_config.serial_no is required for lakala provider")
|
||||
ErrTermNoRequired = errors.New("extra_config.term_no is required for lakala provider")
|
||||
ErrSecretIDRequired = errors.New("extra_config.secret_id is required for shuncheng provider")
|
||||
)
|
||||
|
||||
// DTO 数据传输对象
|
||||
@@ -57,7 +58,7 @@ type ConfigDTO struct {
|
||||
// CreateRequest 创建支付配置请求
|
||||
type CreateRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Provider string `json:"provider" binding:"required,oneof=leshua lakala mock"`
|
||||
Provider string `json:"provider" binding:"required,oneof=leshua lakala shuncheng mock"`
|
||||
MerchantID string `json:"merchant_id" binding:"required"`
|
||||
GatewayURL string `json:"gateway_url"`
|
||||
SignKey string `json:"sign_key"`
|
||||
|
||||
@@ -48,6 +48,28 @@ func TestImportCreateRequestPreservesTestingConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCreateRequestRequiresShunchengSecretID(t *testing.T) {
|
||||
repo := &Repository{}
|
||||
req := CreateRequest{
|
||||
Name: "顺成生产商户",
|
||||
Provider: "shuncheng",
|
||||
MerchantID: "0000000038",
|
||||
GatewayURL: "http://jyzxapi.ydxnhb.com",
|
||||
SignKey: "secret-key",
|
||||
NotifyKey: "notify-key",
|
||||
NotifyURL: "https://example.com/api/payments/shuncheng/notify",
|
||||
PayWay: "ZFBZF",
|
||||
}
|
||||
if err := repo.validateCreateRequest(req); err != ErrSecretIDRequired {
|
||||
t.Fatalf("validateCreateRequest() error = %v, want ErrSecretIDRequired", err)
|
||||
}
|
||||
|
||||
req.ExtraConfig = map[string]any{"secret_id": "SEC-1"}
|
||||
if err := repo.validateCreateRequest(req); err != nil {
|
||||
t.Fatalf("validateCreateRequest() error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListUsesSuccessfulPaymentStats 验证配置页只统计成功支付,不把打开二维码或取消订单算作成交。
|
||||
func TestListUsesSuccessfulPaymentStats(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
|
||||
@@ -62,12 +62,29 @@ func (r *Repository) validateCreateRequest(req CreateRequest) error {
|
||||
return ErrTermNoRequired
|
||||
}
|
||||
}
|
||||
if req.Provider == "shuncheng" {
|
||||
if req.GatewayURL == "" {
|
||||
return ErrGatewayURLRequired
|
||||
}
|
||||
if req.SignKey == "" {
|
||||
return ErrSignKeyRequired
|
||||
}
|
||||
if req.NotifyKey == "" {
|
||||
return ErrNotifyKeyRequired
|
||||
}
|
||||
if req.NotifyURL == "" {
|
||||
return ErrNotifyURLRequired
|
||||
}
|
||||
if extraString(req.ExtraConfig, "secret_id") == "" {
|
||||
return ErrSecretIDRequired
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isValidProvider(value string) bool {
|
||||
return value == "leshua" || value == "lakala" || value == "mock"
|
||||
return value == "leshua" || value == "lakala" || value == "shuncheng" || value == "mock"
|
||||
}
|
||||
|
||||
func isValidSignType(value string) bool {
|
||||
|
||||
Reference in New Issue
Block a user