Files
kra-new/internal/data/payment.go
T
2026-08-19 18:06:03 +08:00

411 lines
14 KiB
Go

package data
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"kra/internal/biz"
datapayment "kra/internal/data/payment"
"gorm.io/gorm"
)
type paymentRepo struct{ data *Data }
func NewPaymentRepo(data *Data) biz.PaymentRepo { return &paymentRepo{data: data} }
func ensurePaymentIntegrationConfigs(db *gorm.DB) error {
for _, provider := range biz.SupportedPaymentProviders {
var count int64
if err := db.Model(&integrationConfigPO{}).Where("kind = ? AND provider = ?", integrationKindPayment, provider).Count(&count).Error; err != nil {
return err
}
if count == 0 {
if err := db.Create(&integrationConfigPO{Kind: integrationKindPayment, Provider: provider, Enabled: false, Config: "{}"}).Error; err != nil {
return err
}
}
}
return nil
}
func (r *paymentRepo) row(ctx context.Context, provider string) (*integrationConfigPO, map[string]any, error) {
var row integrationConfigPO
if err := r.data.gormDB.WithContext(ctx).Where("kind = ? AND provider = ?", integrationKindPayment, provider).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, biz.ErrPaymentProviderNotFound
}
return nil, nil, err
}
if !row.Enabled {
return nil, nil, fmt.Errorf("支付渠道 %s 未启用", provider)
}
values := map[string]any{}
if err := json.Unmarshal([]byte(row.Config), &values); err != nil {
return nil, nil, fmt.Errorf("支付配置格式错误: %w", err)
}
return &row, values, nil
}
func (r *paymentRepo) ListConfigs(ctx context.Context) ([]*biz.PaymentConfig, error) {
var rows []integrationConfigPO
if err := r.data.gormDB.WithContext(ctx).Where("kind = ?", integrationKindPayment).Order("provider ASC").Find(&rows).Error; err != nil {
return nil, err
}
out := make([]*biz.PaymentConfig, 0, len(rows))
for _, row := range rows {
values := map[string]any{}
if json.Unmarshal([]byte(row.Config), &values) == nil {
maskPaymentSecrets(values)
masked, _ := json.Marshal(values)
out = append(out, &biz.PaymentConfig{Provider: row.Provider, Enabled: row.Enabled, Values: masked})
continue
}
out = append(out, &biz.PaymentConfig{Provider: row.Provider, Enabled: row.Enabled, Values: json.RawMessage("{}")})
}
return out, nil
}
func (r *paymentRepo) SaveConfig(ctx context.Context, config *biz.PaymentConfig) error {
if config == nil || !contains(biz.SupportedPaymentProviders, config.Provider) {
return errors.New("不支持的支付渠道")
}
if !json.Valid(config.Values) {
return errors.New("支付配置必须是 JSON")
}
values := map[string]any{}
if err := json.Unmarshal(config.Values, &values); err != nil {
return errors.New("支付配置必须是 JSON 对象")
}
db := r.data.gormDB.WithContext(ctx)
var row integrationConfigPO
err := db.Where("kind = ? AND provider = ?", integrationKindPayment, config.Provider).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
if config.Enabled {
if err = validatePaymentConfig(config.Provider, values); err != nil {
return err
}
}
encoded, _ := json.Marshal(values)
return db.Create(&integrationConfigPO{Kind: integrationKindPayment, Provider: config.Provider, Enabled: config.Enabled, Config: string(encoded)}).Error
}
if err != nil {
return err
}
old := map[string]any{}
_ = json.Unmarshal([]byte(row.Config), &old)
mergePaymentSecrets(values, old)
if config.Enabled {
if err = validatePaymentConfig(config.Provider, values); err != nil {
return err
}
}
encoded, _ := json.Marshal(values)
return db.Model(&row).Updates(map[string]any{"enabled": config.Enabled, "config": string(encoded)}).Error
}
func (r *paymentRepo) adapter(ctx context.Context, provider string) (datapayment.Adapter, map[string]any, error) {
_, values, err := r.row(ctx, provider)
if err != nil {
return nil, nil, err
}
adapter, err := datapayment.New(provider)
return adapter, values, err
}
func (r *paymentRepo) Create(ctx context.Context, req *biz.PaymentRequest) (*biz.PaymentResult, error) {
a, c, err := r.adapter(ctx, req.Provider)
if err != nil {
return nil, err
}
effective := *req
effective.NotifyURL = strings.TrimSpace(text(c, "notify_url"))
effective.ReturnURL = strings.TrimSpace(text(c, "return_url"))
if req.Provider != biz.PaymentApple && effective.NotifyURL == "" {
return nil, fmt.Errorf("支付渠道 %s 未配置服务端 notify_url", req.Provider)
}
return a.Create(ctx, &effective, c)
}
func (r *paymentRepo) Query(ctx context.Context, provider, tradeNo string) (*biz.PaymentResult, error) {
a, c, err := r.adapter(ctx, provider)
if err != nil {
return nil, err
}
return a.Query(ctx, tradeNo, c)
}
func (r *paymentRepo) Refund(ctx context.Context, req *biz.PaymentRefundRequest) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("支付退款请求为空")
}
a, c, err := r.adapter(ctx, req.Provider)
if err != nil {
return nil, err
}
return a.Refund(ctx, req, c)
}
func (r *paymentRepo) HandleCallback(ctx context.Context, callback *biz.PaymentCallback) (*biz.PaymentResult, error) {
if callback == nil {
return nil, errors.New("支付回调为空")
}
a, c, err := r.adapter(ctx, callback.Provider)
if err != nil {
return nil, err
}
result, err := a.Callback(ctx, callback, c)
if err != nil {
return nil, &biz.PaymentCallbackError{Cause: err, Ack: paymentCallbackAck(callback.Provider, c, false)}
}
if result == nil {
err = errors.New("支付回调解析结果为空")
return nil, &biz.PaymentCallbackError{Cause: err, Ack: paymentCallbackAck(callback.Provider, c, false)}
}
result.SuccessAck = paymentCallbackAck(callback.Provider, c, true)
result.FailureAck = paymentCallbackAck(callback.Provider, c, false)
if result.Provider != callback.Provider {
err = errors.New("支付回调渠道不匹配")
return nil, &biz.PaymentCallbackError{Cause: err, Ack: result.FailureAck}
}
fields := callbackFields(callback)
eventID := first(fields, "event_id", "notify_id", "notificationUUID", "id")
if eventID == "" {
hash := sha256.Sum256(append([]byte(callback.Provider+"\x00"), callback.Body...))
eventID = hex.EncodeToString(hash[:])
}
result.EventID = eventID
return result, nil
}
func paymentCallbackAck(provider string, values map[string]any, success bool) biz.PaymentCallbackAck {
ack := biz.DefaultPaymentCallbackAck(provider, success)
prefix := "callback_success_"
if !success {
prefix = "callback_failure_"
}
if configured := strings.TrimSpace(text(values, prefix+"status")); configured != "" {
if status, err := strconv.Atoi(configured); err == nil && status >= 200 && status <= 599 {
ack.StatusCode = status
}
}
if contentType := strings.TrimSpace(text(values, prefix+"content_type")); contentType != "" {
ack.ContentType = contentType
}
if body, exists := values[prefix+"body"]; exists {
ack.Body = []byte(fmt.Sprint(body))
}
return ack
}
func callbackFields(callback *biz.PaymentCallback) map[string]string {
fields := map[string]string{}
for key, value := range callback.Query {
fields[key] = value
}
contentType := strings.ToLower(callback.Headers["Content-Type"])
if strings.Contains(contentType, "application/x-www-form-urlencoded") {
if values, err := url.ParseQuery(string(callback.Body)); err == nil {
for key, value := range values {
if len(value) > 0 {
fields[key] = value[0]
}
}
}
}
var object map[string]any
if json.Unmarshal(callback.Body, &object) == nil {
for key, value := range object {
if text, ok := value.(string); ok {
fields[key] = text
}
}
}
return fields
}
func first(values map[string]string, keys ...string) string {
for _, key := range keys {
if values[key] != "" {
return values[key]
}
}
return ""
}
var paymentSecretKeys = map[string]bool{
"key": true, "secret": true, "secret_key": true, "private_key": true, "privatekey": true,
"client_key": true, "client_secret": true, "api_key": true, "api_v3_key": true,
"mch_key": true, "merchant_key": true, "signing_secret": true, "certificate": true,
"cert": true, "root_cert": true, "platform_cert": true, "public_cert": true,
"public_key": true, "client_cert": true, "app_secret": true, "appsecret": true,
"access_token": true, "app_auth_token": true, "credential_code": true, "cus_id": true, "p12": true,
"pkcs12": true, "merchant_private_key": true, "platform_key": true,
}
func maskPaymentSecrets(values map[string]any) {
for key, value := range values {
normalized := strings.ToLower(strings.ReplaceAll(key, "-", "_"))
if paymentSecretKeys[normalized] || strings.Contains(normalized, "private") || strings.Contains(normalized, "secret") {
if text, ok := value.(string); ok && text != "" {
values[key] = "******"
}
continue
}
if nested, ok := value.(map[string]any); ok {
maskPaymentSecrets(nested)
}
}
}
func mergePaymentSecrets(values, old map[string]any) {
for key, value := range values {
normalized := strings.ToLower(strings.ReplaceAll(key, "-", "_"))
if paymentSecretKeys[normalized] || strings.Contains(normalized, "private") || strings.Contains(normalized, "secret") {
if text, ok := value.(string); ok && text == "******" {
if prior, exists := old[key]; exists {
values[key] = prior
}
}
continue
}
if nested, ok := value.(map[string]any); ok {
if prior, ok := old[key].(map[string]any); ok {
mergePaymentSecrets(nested, prior)
}
}
}
}
func contains(values []string, value string) bool {
for _, item := range values {
if item == value {
return true
}
}
return false
}
func validatePaymentConfig(provider string, values map[string]any) error {
required := []string{}
switch provider {
case biz.PaymentAlipay:
required = []string{"app_id", "private_key", "public_key"}
case biz.PaymentWechatV2:
required = []string{"app_id", "merchant_id", "mch_key"}
case biz.PaymentWechatV3:
required = []string{"app_id", "merchant_id", "serial_no", "private_key", "api_v3_key", "platform_cert"}
case biz.PaymentApple:
required = []string{"issuer_id", "key_id", "bundle_id", "private_key"}
if configuredInt64(values, "price_divisor", 0) <= 0 {
if _, ok := values["price_divisors"].(map[string]any); !ok {
return fmt.Errorf("%s 缺少配置字段 price_divisor 或 price_divisors", provider)
}
}
case biz.PaymentDouyin:
required = []string{"merchant_id", "serial_no", "api_key", "private_key", "platform_cert", "platform_serial_no"}
case biz.PaymentQQ:
if firstAny(values, "mch_id", "merchant_id") == "" {
return fmt.Errorf("%s 缺少配置字段 mch_id", provider)
}
required = []string{"api_key"}
case biz.PaymentAllinPay:
required = []string{"cus_id", "app_id", "private_key", "public_key"}
case biz.PaymentLakala:
required = []string{"partner_code", "credential_code"}
case biz.PaymentPayPal:
required = []string{"client_id", "client_secret"}
case biz.PaymentSaobei:
required = []string{"inst_no", "key", "merchant_no", "terminal_id", "access_token"}
default:
required = []string{
"protocol_version", "create_url", "query_url", "query_status_field", "query_success_values",
"query_trade_no_field", "query_provider_trade_no_field", "query_amount_field",
"query_currency_field", "query_amount_scale", "callback_status_field",
"callback_success_values", "callback_trade_no_field", "callback_provider_trade_no_field",
}
if firstAny(values, "app_key", "merchant_key", "signing_secret", "token") == "" {
return fmt.Errorf("%s 缺少签名密钥", provider)
}
if err := validateConfiguredBreakdownFields(values); err != nil {
return fmt.Errorf("%s 金额拆分配置无效: %w", provider, err)
}
}
for _, key := range required {
if text(values, key) == "" {
return fmt.Errorf("%s 缺少配置字段 %s", provider, key)
}
}
if provider == biz.PaymentDouyin && firstAny(values, "platform_serial_no", "platform_cert_serial") == "" {
return fmt.Errorf("%s 缺少配置字段 platform_serial_no", provider)
}
if environment := strings.ToLower(strings.TrimSpace(text(values, "environment"))); environment != "" {
switch provider {
case biz.PaymentQQ, biz.PaymentDouyin, biz.PaymentLakala:
if environment != "production" && environment != "prod" {
return fmt.Errorf("%s 仅支持 production 环境", provider)
}
case biz.PaymentPayPal:
if environment != "production" && environment != "prod" && environment != "sandbox" {
return fmt.Errorf("%s environment 必须是 production 或 sandbox", provider)
}
default:
if environment != "production" && environment != "prod" && environment != "sandbox" {
return fmt.Errorf("%s environment 配置无效", provider)
}
}
}
if provider == biz.PaymentQQ {
signType := strings.ToUpper(strings.TrimSpace(text(values, "sign_type")))
if signType != "" && signType != "MD5" && signType != "HMAC-SHA256" {
return fmt.Errorf("%s sign_type 必须是 MD5 或 HMAC-SHA256", provider)
}
}
if err := validatePaymentCallbackAckConfig(values); err != nil {
return fmt.Errorf("%s 回调响应配置无效: %w", provider, err)
}
return nil
}
func validateConfiguredBreakdownFields(values map[string]any) error {
for _, key := range []string{
"query_payer_paid_amount_scale", "query_cash_paid_amount_scale", "query_point_paid_amount_scale",
"query_discount_amount_scale", "query_provider_discount_amount_scale", "query_merchant_discount_amount_scale",
"query_settlement_amount_scale",
} {
if _, exists := values[key]; exists && configuredInt64(values, key, 0) <= 0 {
return fmt.Errorf("%s 必须是正整数", key)
}
}
for _, key := range []string{
"query_payer_paid_amount_field", "query_cash_paid_amount_field", "query_point_paid_amount_field",
"query_discount_amount_field", "query_provider_discount_amount_field", "query_merchant_discount_amount_field",
"query_settlement_amount_field", "query_payer_currency_field",
} {
if value, exists := values[key]; exists && strings.TrimSpace(fmt.Sprint(value)) == "" {
return fmt.Errorf("%s 不能为空", key)
}
}
return nil
}
func validatePaymentCallbackAckConfig(values map[string]any) error {
for _, prefix := range []string{"callback_success_", "callback_failure_"} {
if value := strings.TrimSpace(text(values, prefix+"status")); value != "" {
status, err := strconv.Atoi(value)
if err != nil || status < 200 || status > 599 {
return fmt.Errorf("%sstatus 必须是 200-599", prefix)
}
}
if contentType := text(values, prefix+"content_type"); strings.ContainsAny(contentType, "\r\n") || len(contentType) > 200 {
return fmt.Errorf("%scontent_type 非法", prefix)
}
if body, exists := values[prefix+"body"]; exists && len(fmt.Sprint(body)) > 64<<10 {
return fmt.Errorf("%sbody 超过 64KiB", prefix)
}
}
return nil
}