优化结构
This commit is contained in:
parent
80e62acf7d
commit
5a548e0725
File diff suppressed because it is too large
Load Diff
2
Makefile
2
Makefile
|
|
@ -26,7 +26,7 @@ build:
|
||||||
.PHONY: generate
|
.PHONY: generate
|
||||||
# generate
|
# generate
|
||||||
generate:
|
generate:
|
||||||
go generate ./app/system/cmd
|
go generate ./cmd
|
||||||
go mod tidy
|
go mod tidy
|
||||||
|
|
||||||
.PHONY: all
|
.PHONY: all
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,11 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger
|
||||||
user := handler.NewUser(userService, authService)
|
user := handler.NewUser(userService, authService)
|
||||||
navigation := handler.NewNavigation(userService)
|
navigation := handler.NewNavigation(userService)
|
||||||
session := handler.NewSession(tokenService)
|
session := handler.NewSession(tokenService)
|
||||||
v := handler.NewSet(authority, menu, api, permission, organization, announcement, handlerEmail, handlerPayment, task, media, audit, export, version, dictionary, parameter, apiToken, systemConfig, public, user, navigation, session)
|
integrationConfigRepo := system.NewIntegrationConfigRepo(dataData)
|
||||||
|
integrationConfigUsecase := biz.NewIntegrationConfigUsecase(integrationConfigRepo)
|
||||||
|
integrationConfigService := service.NewIntegrationConfigService(integrationConfigUsecase)
|
||||||
|
integrationConfig := handler.NewIntegrationConfig(integrationConfigService)
|
||||||
|
v := handler.NewSet(authority, menu, api, permission, organization, announcement, handlerEmail, handlerPayment, task, media, audit, export, version, dictionary, parameter, apiToken, systemConfig, public, user, navigation, session, integrationConfig)
|
||||||
routes := router.NewRoutes(v)
|
routes := router.NewRoutes(v)
|
||||||
taskMethods := worker.NewTaskMethods(taskUsecase, mediaUsecase, runtime)
|
taskMethods := worker.NewTaskMethods(taskUsecase, mediaUsecase, runtime)
|
||||||
moduleRuntime := app.Runtime(routes, taskMethods, registry)
|
moduleRuntime := app.Runtime(routes, taskMethods, registry)
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,4 @@ package biz
|
||||||
import "github.com/google/wire"
|
import "github.com/google/wire"
|
||||||
|
|
||||||
// ProviderSet is biz providers.
|
// ProviderSet is biz providers.
|
||||||
var ProviderSet = wire.NewSet(NewUserUsecase, NewAuthenticationUsecase, NewSystemConfigUsecase, NewAuthorityUsecase, NewAPIUsecase, NewPermissionUsecase, NewAccessControlUsecase, NewMenuUsecase, NewDepartmentUsecase, NewPositionUsecase, NewDictionaryUsecase, NewParameterUsecase, NewTokenUsecase, NewSecurityUsecase, NewVersionUsecase, NewExportUsecase, NewAuditUsecase, NewAuditRecorderUsecase, NewLogViewerUsecase, NewTaskUsecaseWithRegistry, NewTaskApplicationUsecase, NewMediaUsecase, NewAnnouncementUsecase, NewEmailUsecase, NewPaymentUsecase)
|
var ProviderSet = wire.NewSet(NewUserUsecase, NewAuthenticationUsecase, NewSystemConfigUsecase, NewAuthorityUsecase, NewAPIUsecase, NewPermissionUsecase, NewAccessControlUsecase, NewMenuUsecase, NewDepartmentUsecase, NewPositionUsecase, NewDictionaryUsecase, NewParameterUsecase, NewTokenUsecase, NewSecurityUsecase, NewVersionUsecase, NewExportUsecase, NewAuditUsecase, NewAuditRecorderUsecase, NewLogViewerUsecase, NewTaskUsecaseWithRegistry, NewTaskApplicationUsecase, NewMediaUsecase, NewAnnouncementUsecase, NewEmailUsecase, NewPaymentUsecase, NewIntegrationConfigUsecase)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,293 @@
|
||||||
|
package biz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const IntegrationKindPayment = "payment"
|
||||||
|
|
||||||
|
type IntegrationConfig struct {
|
||||||
|
Kind string
|
||||||
|
Provider string
|
||||||
|
Enabled bool
|
||||||
|
Values json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
type IntegrationConfigField struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Required bool `json:"required"`
|
||||||
|
Secret bool `json:"secret"`
|
||||||
|
Placeholder string `json:"placeholder,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Options []IntegrationConfigOption `json:"options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IntegrationConfigOption struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Value any `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IntegrationConfigDefinition struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Fields []IntegrationConfigField `json:"fields"`
|
||||||
|
Defaults map[string]any `json:"defaults"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IntegrationConfigRepo interface {
|
||||||
|
ListIntegrationConfigs(context.Context, string) ([]*IntegrationConfig, error)
|
||||||
|
FindIntegrationConfig(context.Context, string, string) (*IntegrationConfig, error)
|
||||||
|
SaveIntegrationConfig(context.Context, *IntegrationConfig) error
|
||||||
|
DeleteIntegrationConfig(context.Context, string, string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type IntegrationConfigUsecase struct{ repo IntegrationConfigRepo }
|
||||||
|
|
||||||
|
func NewIntegrationConfigUsecase(repo IntegrationConfigRepo) *IntegrationConfigUsecase {
|
||||||
|
return &IntegrationConfigUsecase{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *IntegrationConfigUsecase) List(ctx context.Context, kind string) ([]*IntegrationConfig, error) {
|
||||||
|
kind = normalizeIntegrationPart(kind)
|
||||||
|
if kind == "" {
|
||||||
|
return nil, errors.New("集成配置 kind 不能为空")
|
||||||
|
}
|
||||||
|
return uc.repo.ListIntegrationConfigs(ctx, kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *IntegrationConfigUsecase) Find(ctx context.Context, kind, provider string) (*IntegrationConfig, error) {
|
||||||
|
kind, provider = normalizeIntegrationPart(kind), normalizeIntegrationPart(provider)
|
||||||
|
if kind == "" || provider == "" {
|
||||||
|
return nil, errors.New("集成配置 kind 和 provider 不能为空")
|
||||||
|
}
|
||||||
|
return uc.repo.FindIntegrationConfig(ctx, kind, provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *IntegrationConfigUsecase) Save(ctx context.Context, config *IntegrationConfig) error {
|
||||||
|
if config == nil {
|
||||||
|
return errors.New("集成配置请求为空")
|
||||||
|
}
|
||||||
|
config.Kind = normalizeIntegrationPart(config.Kind)
|
||||||
|
config.Provider = normalizeIntegrationPart(config.Provider)
|
||||||
|
if config.Kind == "" || config.Provider == "" || len(config.Kind) > 32 || len(config.Provider) > 64 {
|
||||||
|
return errors.New("集成配置 kind 或 provider 无效")
|
||||||
|
}
|
||||||
|
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 对象")
|
||||||
|
}
|
||||||
|
if definition, ok := IntegrationDefinition(config.Kind, config.Provider); ok {
|
||||||
|
values = mergeIntegrationDefaults(definition.Defaults, values)
|
||||||
|
if config.Enabled {
|
||||||
|
if err := ValidateIntegrationConfig(config.Kind, config.Provider, values); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.Values, _ = json.Marshal(values)
|
||||||
|
}
|
||||||
|
return uc.repo.SaveIntegrationConfig(ctx, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *IntegrationConfigUsecase) Delete(ctx context.Context, kind, provider string) error {
|
||||||
|
kind, provider = normalizeIntegrationPart(kind), normalizeIntegrationPart(provider)
|
||||||
|
if kind == "" || provider == "" {
|
||||||
|
return errors.New("集成配置 kind 和 provider 不能为空")
|
||||||
|
}
|
||||||
|
return uc.repo.DeleteIntegrationConfig(ctx, kind, provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeIntegrationPart(value string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func IntegrationDefinitions(kind string) []IntegrationConfigDefinition {
|
||||||
|
kind = normalizeIntegrationPart(kind)
|
||||||
|
definitions := integrationDefinitions[kind]
|
||||||
|
out := append([]IntegrationConfigDefinition(nil), definitions...)
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Provider < out[j].Provider })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func IntegrationDefinition(kind, provider string) (IntegrationConfigDefinition, bool) {
|
||||||
|
for _, definition := range integrationDefinitions[normalizeIntegrationPart(kind)] {
|
||||||
|
if definition.Provider == normalizeIntegrationPart(provider) {
|
||||||
|
return definition, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return IntegrationConfigDefinition{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultIntegrationConfig(kind, provider string) map[string]any {
|
||||||
|
definition, ok := IntegrationDefinition(kind, provider)
|
||||||
|
if !ok {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
return mergeIntegrationDefaults(definition.Defaults, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeIntegrationDefaults(defaults, values map[string]any) map[string]any {
|
||||||
|
out := make(map[string]any, len(defaults)+len(values))
|
||||||
|
for key, value := range defaults {
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
for key, value := range values {
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateIntegrationConfig(kind, provider string, values map[string]any) error {
|
||||||
|
if normalizeIntegrationPart(kind) != IntegrationKindPayment {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return validatePaymentIntegrationConfig(normalizeIntegrationPart(provider), values)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePaymentIntegrationConfig(provider string, values map[string]any) error {
|
||||||
|
definition, ok := IntegrationDefinition(IntegrationKindPayment, provider)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("不支持的支付渠道")
|
||||||
|
}
|
||||||
|
for _, field := range definition.Fields {
|
||||||
|
if field.Required && integrationText(values, field.Key) == "" {
|
||||||
|
return fmt.Errorf("%s 缺少配置字段 %s", provider, field.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch provider {
|
||||||
|
case PaymentAlipayV3:
|
||||||
|
for _, group := range []struct {
|
||||||
|
label string
|
||||||
|
keys []string
|
||||||
|
}{
|
||||||
|
{"private_key", []string{"private_key", "private_key_content", "private_key_path"}},
|
||||||
|
{"app_cert", []string{"app_cert", "app_cert_content", "app_cert_path", "app_public_cert", "app_public_cert_content", "app_public_cert_path"}},
|
||||||
|
{"root_cert", []string{"root_cert", "root_cert_content", "root_cert_path", "alipay_root_cert", "alipay_root_cert_content", "alipay_root_cert_path"}},
|
||||||
|
{"public_cert", []string{"public_cert", "public_cert_content", "public_cert_path", "alipay_public_cert", "alipay_public_cert_content", "alipay_public_cert_path"}},
|
||||||
|
} {
|
||||||
|
if integrationFirst(values, group.keys...) == "" {
|
||||||
|
return fmt.Errorf("%s 缺少配置字段 %s", provider, group.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case PaymentWechatV2:
|
||||||
|
if integrationFirst(values, "client_cert", "cert_pem", "apiclient_cert") == "" || integrationFirst(values, "client_key", "key_pem", "apiclient_key") == "" {
|
||||||
|
return fmt.Errorf("%s 退款要求同时配置 client_cert 和 client_key", provider)
|
||||||
|
}
|
||||||
|
case PaymentApple:
|
||||||
|
if integrationInt64(values, "price_divisor", 0) <= 0 {
|
||||||
|
if _, exists := values["price_divisors"].(map[string]any); !exists {
|
||||||
|
return fmt.Errorf("%s 缺少配置字段 price_divisor 或 price_divisors", provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case PaymentDouyin:
|
||||||
|
if integrationFirst(values, "platform_serial_no", "platform_cert_serial") == "" {
|
||||||
|
return fmt.Errorf("%s 缺少配置字段 platform_serial_no", provider)
|
||||||
|
}
|
||||||
|
case PaymentQQ:
|
||||||
|
if integrationFirst(values, "mch_id", "merchant_id") == "" {
|
||||||
|
return fmt.Errorf("%s 缺少配置字段 mch_id", provider)
|
||||||
|
}
|
||||||
|
if !hasQQIntegrationCertificate(values) {
|
||||||
|
return fmt.Errorf("%s 退款要求配置 cert_file + key_file、pkcs12_file 或对应的 *_content", provider)
|
||||||
|
}
|
||||||
|
signType := strings.ToUpper(integrationText(values, "sign_type"))
|
||||||
|
if signType != "" && signType != "MD5" && signType != "HMAC-SHA256" {
|
||||||
|
return fmt.Errorf("%s sign_type 必须是 MD5 或 HMAC-SHA256", provider)
|
||||||
|
}
|
||||||
|
case PaymentAllinPay:
|
||||||
|
orderType := strings.ToLower(integrationFirst(values, "query_order_type", "order_type"))
|
||||||
|
if orderType != "" && orderType != "reqsn" && orderType != "trxid" {
|
||||||
|
return fmt.Errorf("%s query_order_type 必须是 reqsn 或 trxid", provider)
|
||||||
|
}
|
||||||
|
case PaymentChinaums, PaymentSFT, PaymentSuperPay, PaymentWechatGame, PaymentDouyinGame:
|
||||||
|
if integrationFirst(values, "app_key", "merchant_key", "signing_secret", "token") == "" {
|
||||||
|
return fmt.Errorf("%s 缺少签名密钥", provider)
|
||||||
|
}
|
||||||
|
if !validIntegrationScale(integrationInt64(values, "query_amount_scale", 0)) {
|
||||||
|
return fmt.Errorf("%s query_amount_scale 必须是正的 10 的幂", provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if environment := strings.ToLower(integrationText(values, "environment")); environment != "" {
|
||||||
|
allowedSandbox := provider != PaymentQQ && provider != PaymentDouyin && provider != PaymentLakala
|
||||||
|
if environment != "production" && environment != "prod" && (!allowedSandbox || environment != "sandbox") {
|
||||||
|
return fmt.Errorf("%s environment 配置无效", provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return validateIntegrationCallbackAck(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
func integrationText(values map[string]any, key string) string {
|
||||||
|
value, exists := values[key]
|
||||||
|
if !exists || value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(fmt.Sprint(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func integrationFirst(values map[string]any, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value := integrationText(values, key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func integrationInt64(values map[string]any, key string, fallback int64) int64 {
|
||||||
|
value := integrationText(values, key)
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasQQIntegrationCertificate(values map[string]any) bool {
|
||||||
|
if integrationFirst(values, "pkcs12_file", "p12_file", "pkcs12_content") != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if integrationFirst(values, "cert_file", "cert_path") != "" && integrationFirst(values, "key_file", "key_path") != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return integrationFirst(values, "cert_content") != "" && integrationFirst(values, "key_content") != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func validIntegrationScale(scale int64) bool {
|
||||||
|
if scale <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for scale%10 == 0 {
|
||||||
|
scale /= 10
|
||||||
|
}
|
||||||
|
return scale == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateIntegrationCallbackAck(values map[string]any) error {
|
||||||
|
for _, prefix := range []string{"callback_success_", "callback_failure_"} {
|
||||||
|
if value := integrationText(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 := integrationText(values, prefix+"content_type"); strings.ContainsAny(contentType, "\r\n") || len(contentType) > 200 {
|
||||||
|
return fmt.Errorf("%scontent_type 非法", prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
package biz
|
||||||
|
|
||||||
|
func integrationField(key, label string, required, secret bool, fieldType string) IntegrationConfigField {
|
||||||
|
if fieldType == "" {
|
||||||
|
fieldType = "text"
|
||||||
|
}
|
||||||
|
return IntegrationConfigField{Key: key, Label: label, Required: required, Secret: secret, Type: fieldType}
|
||||||
|
}
|
||||||
|
|
||||||
|
func integrationSelect(key, label string, required bool, values ...string) IntegrationConfigField {
|
||||||
|
options := make([]IntegrationConfigOption, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
options = append(options, IntegrationConfigOption{Label: value, Value: value})
|
||||||
|
}
|
||||||
|
return IntegrationConfigField{Key: key, Label: label, Required: required, Type: "select", Options: options}
|
||||||
|
}
|
||||||
|
|
||||||
|
func paymentDefinition(provider, name, description string, defaults map[string]any, fields ...IntegrationConfigField) IntegrationConfigDefinition {
|
||||||
|
common := []IntegrationConfigField{
|
||||||
|
integrationField("notify_url", "支付回调地址", false, false, "url"),
|
||||||
|
integrationField("return_url", "同步跳转地址", false, false, "url"),
|
||||||
|
}
|
||||||
|
fields = append(fields, common...)
|
||||||
|
for _, field := range fields {
|
||||||
|
if _, exists := defaults[field.Key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch field.Type {
|
||||||
|
case "number":
|
||||||
|
defaults[field.Key] = 0
|
||||||
|
case "switch":
|
||||||
|
defaults[field.Key] = false
|
||||||
|
default:
|
||||||
|
defaults[field.Key] = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return IntegrationConfigDefinition{Kind: IntegrationKindPayment, Provider: provider, Name: name, Description: description, Defaults: defaults, Fields: fields}
|
||||||
|
}
|
||||||
|
|
||||||
|
var genericPaymentFields = []IntegrationConfigField{
|
||||||
|
integrationField("protocol_version", "协议版本", true, false, "text"),
|
||||||
|
integrationField("app_id", "应用 ID", true, false, "text"),
|
||||||
|
integrationField("merchant_id", "商户 ID", true, false, "text"),
|
||||||
|
integrationField("create_url", "下单接口", true, false, "url"),
|
||||||
|
integrationField("query_url", "查单接口", true, false, "url"),
|
||||||
|
integrationField("refund_url", "退款接口", true, false, "url"),
|
||||||
|
integrationField("app_key", "签名密钥", true, true, "password"),
|
||||||
|
integrationField("query_status_field", "查单状态字段", true, false, "text"),
|
||||||
|
integrationField("query_success_values", "查单成功值", true, false, "text"),
|
||||||
|
integrationField("query_trade_no_field", "商户单号字段", true, false, "text"),
|
||||||
|
integrationField("query_provider_trade_no_field", "平台单号字段", true, false, "text"),
|
||||||
|
integrationField("query_amount_field", "金额字段", true, false, "text"),
|
||||||
|
integrationField("query_currency_field", "币种字段", true, false, "text"),
|
||||||
|
integrationField("query_amount_scale", "金额倍率", true, false, "number"),
|
||||||
|
integrationField("callback_status_field", "回调状态字段", true, false, "text"),
|
||||||
|
integrationField("callback_success_values", "回调成功值", true, false, "text"),
|
||||||
|
integrationField("callback_trade_no_field", "回调商户单号字段", true, false, "text"),
|
||||||
|
integrationField("callback_provider_trade_no_field", "回调平台单号字段", true, false, "text"),
|
||||||
|
}
|
||||||
|
|
||||||
|
var genericPaymentDefaults = map[string]any{
|
||||||
|
"protocol_version": "v1", "app_id": "", "merchant_id": "", "create_url": "", "query_url": "", "refund_url": "", "app_key": "",
|
||||||
|
"query_status_field": "", "query_success_values": "", "query_trade_no_field": "",
|
||||||
|
"query_provider_trade_no_field": "", "query_amount_field": "", "query_currency_field": "", "query_amount_scale": 1,
|
||||||
|
"callback_status_field": "", "callback_success_values": "", "callback_trade_no_field": "",
|
||||||
|
"callback_provider_trade_no_field": "", "notify_url": "", "return_url": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
func genericPaymentDefinition(provider, name, description string) IntegrationConfigDefinition {
|
||||||
|
defaults := mergeIntegrationDefaults(genericPaymentDefaults, nil)
|
||||||
|
fields := append([]IntegrationConfigField(nil), genericPaymentFields...)
|
||||||
|
return paymentDefinition(provider, name, description, defaults, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
var integrationDefinitions = map[string][]IntegrationConfigDefinition{
|
||||||
|
IntegrationKindPayment: {
|
||||||
|
paymentDefinition(PaymentAlipay, "支付宝", "支付宝 OpenAPI RSA2 支付", map[string]any{"app_id": "", "private_key": "", "public_key": "", "environment": "production", "sign_type": "RSA2", "gateway_url": "https://openapi.alipay.com/gateway.do", "method": "alipay.trade.create"},
|
||||||
|
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("private_key", "应用私钥", true, true, "textarea"), integrationField("public_key", "支付宝公钥", true, true, "textarea"),
|
||||||
|
integrationSelect("environment", "环境", false, "production", "sandbox"), integrationSelect("sign_type", "签名算法", false, "RSA2", "RSA"), integrationField("gateway_url", "网关地址", false, false, "url"),
|
||||||
|
integrationSelect("method", "默认支付方式", false, "alipay.trade.create", "alipay.trade.pay", "alipay.trade.precreate", "alipay.trade.app.pay", "alipay.trade.page.pay", "alipay.trade.wap.pay")),
|
||||||
|
paymentDefinition(PaymentAlipayV3, "支付宝 V3", "支付宝证书模式 V3 接口", map[string]any{"app_id": "", "private_key": "", "app_cert": "", "root_cert": "", "public_cert": "", "environment": "production", "api_base_url": "https://openapi.alipay.com", "gateway_url": "https://openapi.alipay.com/gateway.do", "method": "alipay.trade.create"},
|
||||||
|
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("private_key", "应用私钥", false, true, "textarea"), integrationField("app_cert", "应用公钥证书", false, true, "textarea"), integrationField("root_cert", "支付宝根证书", false, true, "textarea"), integrationField("public_cert", "支付宝公钥证书", false, true, "textarea"),
|
||||||
|
integrationSelect("environment", "环境", false, "production", "sandbox"), integrationField("api_base_url", "API 地址", false, false, "url"), integrationField("gateway_url", "网关地址", false, false, "url"), integrationSelect("method", "默认支付方式", false, "alipay.trade.create", "alipay.trade.pay", "alipay.trade.precreate", "alipay.trade.app.pay", "alipay.trade.page.pay", "alipay.trade.wap.pay")),
|
||||||
|
paymentDefinition(PaymentWechatV2, "微信支付 V2", "微信支付 V2,含退款双向证书", map[string]any{"app_id": "", "merchant_id": "", "mch_key": "", "sign_type": "MD5", "trade_type": "NATIVE", "client_cert": "", "client_key": ""},
|
||||||
|
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("merchant_id", "商户号", true, false, "text"), integrationField("mch_key", "API 密钥", true, true, "password"), integrationSelect("sign_type", "签名算法", false, "MD5", "HMAC-SHA256"), integrationSelect("trade_type", "默认交易类型", false, "JSAPI", "APP", "NATIVE", "MWEB", "MICROPAY"), integrationField("client_cert", "商户证书", false, true, "textarea"), integrationField("client_key", "证书私钥", false, true, "textarea")),
|
||||||
|
paymentDefinition(PaymentWechatV3, "微信支付 V3", "微信支付 API v3", map[string]any{"app_id": "", "merchant_id": "", "serial_no": "", "private_key": "", "api_v3_key": "", "platform_cert": "", "platform_serial_no": "", "trade_type": "jsapi"},
|
||||||
|
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("merchant_id", "商户号", true, false, "text"), integrationField("serial_no", "商户证书序列号", true, false, "text"), integrationField("private_key", "商户私钥", true, true, "textarea"), integrationField("api_v3_key", "API v3 密钥", true, true, "password"), integrationField("platform_cert", "平台证书", true, true, "textarea"), integrationField("platform_serial_no", "平台证书序列号", false, false, "text"), integrationSelect("trade_type", "默认交易类型", false, "jsapi", "app", "native", "h5", "codepay")),
|
||||||
|
paymentDefinition(PaymentApple, "Apple IAP", "Apple App Store Server API", map[string]any{"issuer_id": "", "key_id": "", "bundle_id": "", "private_key": "", "price_divisor": 10, "environment": "production"},
|
||||||
|
integrationField("issuer_id", "Issuer ID", true, false, "text"), integrationField("key_id", "Key ID", true, false, "text"), integrationField("bundle_id", "Bundle ID", true, false, "text"), integrationField("private_key", "P8 私钥", true, true, "textarea"), integrationField("price_divisor", "价格除数", false, false, "number"), integrationSelect("environment", "环境", false, "production", "sandbox")),
|
||||||
|
paymentDefinition(PaymentDouyin, "抖音支付", "抖音开放平台支付", map[string]any{"app_id": "", "merchant_id": "", "serial_no": "", "api_key": "", "private_key": "", "platform_cert": "", "platform_serial_no": "", "trade_type": "jsapi", "environment": "production"},
|
||||||
|
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("merchant_id", "商户号", true, false, "text"), integrationField("serial_no", "商户证书序列号", true, false, "text"), integrationField("api_key", "API 密钥", true, true, "password"), integrationField("private_key", "商户私钥", true, true, "textarea"), integrationField("platform_cert", "平台证书", true, true, "textarea"), integrationField("platform_serial_no", "平台证书序列号", false, false, "text"), integrationSelect("trade_type", "默认交易类型", false, "app", "jsapi", "h5", "native"), integrationSelect("environment", "环境", false, "production")),
|
||||||
|
paymentDefinition(PaymentQQ, "QQ 钱包", "QQ 钱包支付", map[string]any{"mch_id": "", "api_key": "", "sign_type": "MD5", "trade_type": "NATIVE", "cert_file": "", "key_file": "", "environment": "production"},
|
||||||
|
integrationField("mch_id", "商户号", true, false, "text"), integrationField("api_key", "API 密钥", true, true, "password"), integrationSelect("sign_type", "签名算法", false, "MD5", "HMAC-SHA256"), integrationSelect("trade_type", "默认交易类型", false, "JSAPI", "NATIVE", "APP", "MICROPAY"), integrationField("cert_file", "退款证书路径", false, false, "text"), integrationField("key_file", "退款私钥路径", false, true, "text"), integrationSelect("environment", "环境", false, "production")),
|
||||||
|
paymentDefinition(PaymentAllinPay, "通联支付", "通联收银宝支付", map[string]any{"cus_id": "", "app_id": "", "private_key": "", "public_key": "", "org_id": "", "pay_type": "W02", "query_order_type": "reqsn", "currency": "CNY", "environment": "production"},
|
||||||
|
integrationField("cus_id", "商户号", true, false, "text"), integrationField("app_id", "应用 ID", true, false, "text"), integrationField("private_key", "商户私钥", true, true, "textarea"), integrationField("public_key", "平台公钥", true, true, "textarea"), integrationField("org_id", "机构号", false, false, "text"), integrationField("pay_type", "支付类型", false, false, "text"), integrationSelect("query_order_type", "查单标识", false, "reqsn", "trxid"), integrationField("currency", "币种", false, false, "text"), integrationSelect("environment", "环境", false, "production", "sandbox")),
|
||||||
|
paymentDefinition(PaymentLakala, "拉卡拉", "拉卡拉聚合支付", map[string]any{"partner_code": "", "credential_code": "", "channel": "Wechat", "method": "jsapi", "currency": "CNY", "environment": "production"},
|
||||||
|
integrationField("partner_code", "合作方编号", true, false, "text"), integrationField("credential_code", "凭证码", true, true, "password"), integrationSelect("channel", "支付渠道", false, "Wechat", "Alipay", "UnionPay"), integrationSelect("method", "默认支付方式", false, "jsapi", "h5", "mini", "native", "qrcode", "native_jsapi", "sdk", "web", "retail", "retail_qrcode"), integrationField("currency", "币种", false, false, "text"), integrationSelect("environment", "环境", false, "production")),
|
||||||
|
paymentDefinition(PaymentPayPal, "PayPal", "PayPal Checkout", map[string]any{"client_id": "", "client_secret": "", "webhook_id": "", "environment": "sandbox", "return_url": "", "cancel_url": "", "auto_capture": true},
|
||||||
|
integrationField("client_id", "Client ID", true, false, "text"), integrationField("client_secret", "Client Secret", true, true, "password"), integrationField("webhook_id", "Webhook ID", true, false, "text"), integrationSelect("environment", "环境", false, "sandbox", "production"), integrationField("cancel_url", "取消跳转地址", false, false, "url"), integrationField("auto_capture", "自动捕获", false, false, "switch")),
|
||||||
|
paymentDefinition(PaymentSaobei, "扫呗", "扫呗聚合支付", map[string]any{"inst_no": "", "key": "", "merchant_no": "", "terminal_id": "", "access_token": "", "pay_type": "010", "currency": "CNY", "environment": "production"},
|
||||||
|
integrationField("inst_no", "机构号", true, false, "text"), integrationField("key", "机构密钥", true, true, "password"), integrationField("merchant_no", "商户号", true, false, "text"), integrationField("terminal_id", "终端号", true, false, "text"), integrationField("access_token", "访问令牌", true, true, "password"), integrationField("pay_type", "支付类型", false, false, "text"), integrationField("currency", "币种", false, false, "text"), integrationSelect("environment", "环境", false, "production", "sandbox")),
|
||||||
|
genericPaymentDefinition(PaymentChinaums, "银联商务", "按商户协议配置的银联商务适配器"),
|
||||||
|
genericPaymentDefinition(PaymentSFT, "商福通", "按商户协议配置的商福通适配器"),
|
||||||
|
genericPaymentDefinition(PaymentSuperPay, "Supper Pay", "按商户协议配置的 Supper Pay 适配器"),
|
||||||
|
genericPaymentDefinition(PaymentWechatGame, "微信小游戏支付", "微信小游戏虚拟支付配置驱动适配器"),
|
||||||
|
genericPaymentDefinition(PaymentDouyinGame, "抖音小游戏支付", "抖音小游戏支付配置驱动适配器"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -56,12 +56,6 @@ func PaymentRejected(err error) error {
|
||||||
return fmt.Errorf("%w: %v", ErrPaymentOperationRejected, err)
|
return fmt.Errorf("%w: %v", ErrPaymentOperationRejected, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
type PaymentConfig struct {
|
|
||||||
Provider string
|
|
||||||
Enabled bool
|
|
||||||
Values json.RawMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
type PaymentRequest struct {
|
type PaymentRequest struct {
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
TradeNo string `json:"tradeNo"`
|
TradeNo string `json:"tradeNo"`
|
||||||
|
|
@ -207,8 +201,6 @@ type PaymentCallback struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type PaymentRepo interface {
|
type PaymentRepo interface {
|
||||||
ListConfigs(context.Context) ([]*PaymentConfig, error)
|
|
||||||
SaveConfig(context.Context, *PaymentConfig) error
|
|
||||||
Create(context.Context, *PaymentRequest) (*PaymentResult, error)
|
Create(context.Context, *PaymentRequest) (*PaymentResult, error)
|
||||||
Query(context.Context, string, string) (*PaymentResult, error)
|
Query(context.Context, string, string) (*PaymentResult, error)
|
||||||
Refund(context.Context, *PaymentRefundRequest) (*PaymentResult, error)
|
Refund(context.Context, *PaymentRefundRequest) (*PaymentResult, error)
|
||||||
|
|
@ -308,14 +300,6 @@ func (uc *PaymentUsecase) RegisterBusinessModule(module PaymentBusinessModule) e
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uc *PaymentUsecase) Configs(ctx context.Context) ([]*PaymentConfig, error) {
|
|
||||||
return uc.repo.ListConfigs(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (uc *PaymentUsecase) SaveConfig(ctx context.Context, config *PaymentConfig) error {
|
|
||||||
return uc.repo.SaveConfig(ctx, config)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (uc *PaymentUsecase) Order(ctx context.Context, provider, tradeNo string) (*PaymentOrder, error) {
|
func (uc *PaymentUsecase) Order(ctx context.Context, provider, tradeNo string) (*PaymentOrder, error) {
|
||||||
if uc.orders == nil {
|
if uc.orders == nil {
|
||||||
return nil, errors.New("支付订单仓储未接入")
|
return nil, errors.New("支付订单仓储未接入")
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,6 @@ type paymentRepoStub struct {
|
||||||
callbackErr error
|
callbackErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *paymentRepoStub) ListConfigs(context.Context) ([]*PaymentConfig, error) { return nil, nil }
|
|
||||||
func (r *paymentRepoStub) SaveConfig(context.Context, *PaymentConfig) error { return nil }
|
|
||||||
func (r *paymentRepoStub) Create(_ context.Context, req *PaymentRequest) (*PaymentResult, error) {
|
func (r *paymentRepoStub) Create(_ context.Context, req *PaymentRequest) (*PaymentResult, error) {
|
||||||
copyReq := *req
|
copyReq := *req
|
||||||
r.createdReq = ©Req
|
r.createdReq = ©Req
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ package kratos.api;
|
||||||
|
|
||||||
import "google/protobuf/duration.proto";
|
import "google/protobuf/duration.proto";
|
||||||
|
|
||||||
option go_package = "kra/app/system/internal/conf;conf";
|
option go_package = "kra/internal/conf;conf";
|
||||||
|
|
||||||
message Bootstrap {
|
message Bootstrap {
|
||||||
Server server = 1;
|
Server server = 1;
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ var ProviderSet = wire.NewSet(
|
||||||
datasystem.NewSecurityRepo,
|
datasystem.NewSecurityRepo,
|
||||||
datasystem.NewVersionRepo, datasystem.NewExportRepo, datasystem.NewAuditRepo, datasystem.NewAuditRecorderRepo, datasystem.NewLogFileRepo, datasystem.NewTaskRepo,
|
datasystem.NewVersionRepo, datasystem.NewExportRepo, datasystem.NewAuditRepo, datasystem.NewAuditRecorderRepo, datasystem.NewLogFileRepo, datasystem.NewTaskRepo,
|
||||||
datasystem.NewMediaRepo, datasystem.NewAnnouncementRepo, datapayment.NewPaymentRepo, datapayment.NewPaymentOrderRepo,
|
datasystem.NewMediaRepo, datasystem.NewAnnouncementRepo, datapayment.NewPaymentRepo, datapayment.NewPaymentOrderRepo,
|
||||||
|
datasystem.NewIntegrationConfigRepo,
|
||||||
)
|
)
|
||||||
|
|
||||||
type Data struct {
|
type Data struct {
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,10 @@ func AdminSurface() platformmodule.Surface {
|
||||||
{Name: "paymentConfig", Path: "paymentConfig", ParentName: "extensions", Component: "view/systemTools/payment/config.vue", Title: "支付配置", Icon: "credit-card", Sort: 7},
|
{Name: "paymentConfig", Path: "paymentConfig", ParentName: "extensions", Component: "view/systemTools/payment/config.vue", Title: "支付配置", Icon: "credit-card", Sort: 7},
|
||||||
},
|
},
|
||||||
APIs: []platformmodule.API{
|
APIs: []platformmodule.API{
|
||||||
{Path: "/payment/configs", Method: "GET", Group: "支付", Description: "获取支付渠道配置"},
|
{Path: "/integration/configs/:kind", Method: "GET", Group: "集成配置", Description: "按类型获取集成配置"},
|
||||||
{Path: "/payment/config", Method: "POST", Group: "支付", Description: "保存支付渠道配置"},
|
{Path: "/integration/configs/:kind/:provider", Method: "GET", Group: "集成配置", Description: "获取指定集成配置"},
|
||||||
|
{Path: "/integration/configs/:kind/:provider", Method: "PUT", Group: "集成配置", Description: "保存集成配置"},
|
||||||
|
{Path: "/integration/configs/:kind/:provider", Method: "DELETE", Group: "集成配置", Description: "删除集成配置"},
|
||||||
{Path: "/payment/orders", Method: "GET", Group: "支付", Description: "分页查询支付订单"},
|
{Path: "/payment/orders", Method: "GET", Group: "支付", Description: "分页查询支付订单"},
|
||||||
{Path: "/payment/order", Method: "POST", Group: "支付", Description: "查询支付订单"},
|
{Path: "/payment/order", Method: "POST", Group: "支付", Description: "查询支付订单"},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,31 @@ func NewPaymentRepo(data Provider) biz.PaymentRepo { return &paymentRepo{data: d
|
||||||
|
|
||||||
func ensurePaymentIntegrationConfigs(db *gorm.DB) error {
|
func ensurePaymentIntegrationConfigs(db *gorm.DB) error {
|
||||||
for _, provider := range biz.SupportedPaymentProviders {
|
for _, provider := range biz.SupportedPaymentProviders {
|
||||||
var count int64
|
var row integrationConfigPO
|
||||||
if err := db.Model(&integrationConfigPO{}).Where("kind = ? AND provider = ?", integrationKindPayment, provider).Count(&count).Error; err != nil {
|
err := db.Where("kind = ? AND provider = ?", integrationKindPayment, provider).First(&row).Error
|
||||||
|
defaults := biz.DefaultIntegrationConfig(integrationKindPayment, provider)
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
encoded, _ := json.Marshal(defaults)
|
||||||
|
if err := db.Create(&integrationConfigPO{Kind: integrationKindPayment, Provider: provider, Enabled: false, Config: string(encoded)}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if count == 0 {
|
values := map[string]any{}
|
||||||
if err := db.Create(&integrationConfigPO{Kind: integrationKindPayment, Provider: provider, Enabled: false, Config: "{}"}).Error; err != nil {
|
_ = json.Unmarshal([]byte(row.Config), &values)
|
||||||
|
changed := false
|
||||||
|
for key, value := range defaults {
|
||||||
|
if _, exists := values[key]; !exists {
|
||||||
|
values[key] = value
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
encoded, _ := json.Marshal(values)
|
||||||
|
if err := db.Model(&row).Update("config", string(encoded)).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -54,63 +73,6 @@ func (r *paymentRepo) row(ctx context.Context, provider string) (*integrationCon
|
||||||
return &row, values, nil
|
return &row, values, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *paymentRepo) ListConfigs(ctx context.Context) ([]*biz.PaymentConfig, error) {
|
|
||||||
var rows []integrationConfigPO
|
|
||||||
if err := r.data.DB().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.DB().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) {
|
func (r *paymentRepo) adapter(ctx context.Context, provider string) (datapayment.Adapter, map[string]any, error) {
|
||||||
_, values, err := r.row(ctx, provider)
|
_, values, err := r.row(ctx, provider)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -302,50 +264,6 @@ func first(values map[string]string, keys ...string) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
var paymentSecretKeys = map[string]bool{
|
|
||||||
"key": true, "secret": true, "secret_key": true, "private_key": true, "privatekey": true,
|
|
||||||
"app_key": true, "client_key": true, "client_secret": true, "api_key": true,
|
|
||||||
"api_v3_key": true, "api_v3key": true, "apiv3_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, "key_content": true, "key_pem": true, "apiclient_key": true,
|
|
||||||
"app_secret": true, "appsecret": true, "token": true, "access_token": true, "app_auth_token": true,
|
|
||||||
"credential_code": true, "cus_id": true, "op_user_passwd": true, "p12": true, "pkcs12": true,
|
|
||||||
"pkcs12_content": 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 {
|
func contains(values []string, value string) bool {
|
||||||
for _, item := range values {
|
for _, item := range values {
|
||||||
if item == value {
|
if item == value {
|
||||||
|
|
@ -356,180 +274,5 @@ func contains(values []string, value string) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func validatePaymentConfig(provider string, values map[string]any) error {
|
func validatePaymentConfig(provider string, values map[string]any) error {
|
||||||
required := []string{}
|
return biz.ValidateIntegrationConfig(biz.IntegrationKindPayment, provider, values)
|
||||||
configDriven := false
|
|
||||||
switch provider {
|
|
||||||
case biz.PaymentAlipay:
|
|
||||||
required = []string{"app_id", "private_key", "public_key"}
|
|
||||||
case biz.PaymentAlipayV3:
|
|
||||||
required = []string{"app_id"}
|
|
||||||
for _, group := range []struct {
|
|
||||||
label string
|
|
||||||
keys []string
|
|
||||||
}{
|
|
||||||
{label: "private_key", keys: []string{"private_key", "private_key_content", "private_key_path"}},
|
|
||||||
{label: "app_cert", keys: []string{"app_cert", "app_cert_content", "app_cert_path", "app_public_cert", "app_public_cert_content", "app_public_cert_path"}},
|
|
||||||
{label: "root_cert", keys: []string{"root_cert", "root_cert_content", "root_cert_path", "alipay_root_cert", "alipay_root_cert_content", "alipay_root_cert_path"}},
|
|
||||||
{label: "public_cert", keys: []string{"public_cert", "public_cert_content", "public_cert_path", "alipay_public_cert", "alipay_public_cert_content", "alipay_public_cert_path"}},
|
|
||||||
} {
|
|
||||||
if firstAny(values, group.keys...) == "" {
|
|
||||||
return fmt.Errorf("%s 缺少配置字段 %s", provider, group.label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case biz.PaymentWechatV2:
|
|
||||||
required = []string{"app_id", "merchant_id", "mch_key"}
|
|
||||||
if firstAny(values, "client_cert", "cert_pem", "apiclient_cert") == "" || firstAny(values, "client_key", "key_pem", "apiclient_key") == "" {
|
|
||||||
return fmt.Errorf("%s 退款要求同时配置 client_cert 和 client_key", provider)
|
|
||||||
}
|
|
||||||
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{"app_id", "merchant_id", "serial_no", "api_key", "private_key", "platform_cert"}
|
|
||||||
case biz.PaymentQQ:
|
|
||||||
if firstAny(values, "mch_id", "merchant_id") == "" {
|
|
||||||
return fmt.Errorf("%s 缺少配置字段 mch_id", provider)
|
|
||||||
}
|
|
||||||
required = []string{"api_key"}
|
|
||||||
if !hasQQRefundCertificate(values) {
|
|
||||||
return fmt.Errorf("%s 退款要求配置 cert_file + key_file、pkcs12_file 或对应的 *_content", provider)
|
|
||||||
}
|
|
||||||
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", "webhook_id"}
|
|
||||||
case biz.PaymentSaobei:
|
|
||||||
required = []string{"inst_no", "key", "merchant_no", "terminal_id", "access_token"}
|
|
||||||
default:
|
|
||||||
configDriven = true
|
|
||||||
required = []string{
|
|
||||||
"protocol_version", "app_id", "merchant_id", "create_url", "query_url", "refund_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 configDriven && key == "query_amount_scale" {
|
|
||||||
if _, exists := values[key]; !exists {
|
|
||||||
return fmt.Errorf("%s 缺少配置字段 %s", provider, key)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(text(values, key)) == "" {
|
|
||||||
return fmt.Errorf("%s 缺少配置字段 %s", provider, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if configDriven && !validPaymentAmountScale(configuredInt64(values, "query_amount_scale", 0)) {
|
|
||||||
return fmt.Errorf("%s query_amount_scale 必须是正的 10 的幂", provider)
|
|
||||||
}
|
|
||||||
if provider == biz.PaymentDouyin && firstAny(values, "platform_serial_no", "platform_cert_serial") == "" {
|
|
||||||
return fmt.Errorf("%s 缺少配置字段 platform_serial_no", provider)
|
|
||||||
}
|
|
||||||
if provider == biz.PaymentAllinPay {
|
|
||||||
orderType := strings.ToLower(strings.TrimSpace(firstAny(values, "query_order_type", "order_type")))
|
|
||||||
if orderType != "" && orderType != "reqsn" && orderType != "trxid" {
|
|
||||||
return fmt.Errorf("%s query_order_type 必须是 reqsn 或 trxid", 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 hasQQRefundCertificate(values map[string]any) bool {
|
|
||||||
if firstAny(values, "pkcs12_file", "p12_file", "pkcs12_content") != "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if firstAny(values, "cert_file", "cert_path") != "" && firstAny(values, "key_file", "key_path") != "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return firstAny(values, "cert_content") != "" && firstAny(values, "key_content") != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func validPaymentAmountScale(scale int64) bool {
|
|
||||||
if scale <= 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for scale%10 == 0 {
|
|
||||||
scale /= 10
|
|
||||||
}
|
|
||||||
return scale == 1
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,300 +1,64 @@
|
||||||
package payment
|
package payment
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"kra/app/system/internal/biz"
|
"kra/app/system/internal/biz"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSavePaymentConfigRequiresDouyinAppIDWhenEnabled(t *testing.T) {
|
func TestValidatePaymentConfigRequiresDouyinAppIDWhenEnabled(t *testing.T) {
|
||||||
db := openIntegrationConfigTestDB(t)
|
|
||||||
repo := &paymentRepo{data: &Data{gormDB: newReloadableDB(db, nil)}}
|
|
||||||
values := map[string]any{
|
values := map[string]any{
|
||||||
"merchant_id": "merchant-douyin",
|
"merchant_id": "merchant-douyin", "serial_no": "merchant-serial", "api_key": "01234567890123456789012345678901",
|
||||||
"serial_no": "merchant-serial",
|
"private_key": "merchant-private-key", "platform_cert": "platform-public-key", "platform_serial_no": "platform-serial",
|
||||||
"api_key": "01234567890123456789012345678901",
|
|
||||||
"private_key": "merchant-private-key",
|
|
||||||
"platform_cert": "platform-public-key",
|
|
||||||
"platform_serial_no": "platform-serial",
|
|
||||||
}
|
}
|
||||||
encode := func() json.RawMessage {
|
err := biz.ValidateIntegrationConfig(biz.IntegrationKindPayment, biz.PaymentDouyin, values)
|
||||||
raw, err := json.Marshal(values)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
|
|
||||||
err := repo.SaveConfig(context.Background(), &biz.PaymentConfig{
|
|
||||||
Provider: biz.PaymentDouyin,
|
|
||||||
Enabled: true,
|
|
||||||
Values: encode(),
|
|
||||||
})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "app_id") {
|
if err == nil || !strings.Contains(err.Error(), "app_id") {
|
||||||
t.Fatalf("missing app_id error = %v", err)
|
t.Fatalf("missing app_id error = %v", err)
|
||||||
}
|
}
|
||||||
var count int64
|
|
||||||
if err = db.Model(&integrationConfigPO{}).Where("kind = ? AND provider = ?", integrationKindPayment, biz.PaymentDouyin).Count(&count).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if count != 0 {
|
|
||||||
t.Fatalf("invalid enabled configuration was persisted: count=%d", count)
|
|
||||||
}
|
|
||||||
|
|
||||||
values["app_id"] = "douyin-app"
|
values["app_id"] = "douyin-app"
|
||||||
if err = repo.SaveConfig(context.Background(), &biz.PaymentConfig{
|
if err = biz.ValidateIntegrationConfig(biz.IntegrationKindPayment, biz.PaymentDouyin, values); err != nil {
|
||||||
Provider: biz.PaymentDouyin,
|
t.Fatalf("valid Douyin configuration rejected: %v", err)
|
||||||
Enabled: true,
|
|
||||||
Values: encode(),
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("save complete Douyin configuration: %v", err)
|
|
||||||
}
|
|
||||||
var stored integrationConfigPO
|
|
||||||
if err = db.Where("kind = ? AND provider = ?", integrationKindPayment, biz.PaymentDouyin).First(&stored).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !stored.Enabled || !strings.Contains(stored.Config, `"app_id":"douyin-app"`) {
|
|
||||||
t.Fatalf("stored configuration = %+v", stored)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateDouyinPaymentConfigAcceptsPlatformCertificateSerialAlias(t *testing.T) {
|
func TestValidatePaymentConfigAcceptsProviderAliases(t *testing.T) {
|
||||||
err := validatePaymentConfig(biz.PaymentDouyin, map[string]any{
|
err := validatePaymentConfig(biz.PaymentDouyin, map[string]any{
|
||||||
"app_id": "douyin-app",
|
"app_id": "douyin-app", "merchant_id": "merchant-douyin", "serial_no": "merchant-serial", "api_key": "01234567890123456789012345678901",
|
||||||
"merchant_id": "merchant-douyin",
|
"private_key": "merchant-private-key", "platform_cert": "platform-public-key", "platform_cert_serial": "platform-serial",
|
||||||
"serial_no": "merchant-serial",
|
|
||||||
"api_key": "01234567890123456789012345678901",
|
|
||||||
"private_key": "merchant-private-key",
|
|
||||||
"platform_cert": "platform-public-key",
|
|
||||||
"platform_cert_serial": "platform-serial",
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("platform_cert_serial alias rejected: %v", err)
|
t.Fatalf("platform_cert_serial alias rejected: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateAllinPayPaymentConfigRejectsUnknownQueryOrderType(t *testing.T) {
|
func TestValidatePaymentConfigProviderRules(t *testing.T) {
|
||||||
err := validatePaymentConfig(biz.PaymentAllinPay, map[string]any{
|
tests := []struct {
|
||||||
"cus_id": "customer",
|
name, provider, want string
|
||||||
"app_id": "app",
|
values map[string]any
|
||||||
"private_key": "private-key",
|
|
||||||
"public_key": "public-key",
|
|
||||||
"query_order_type": "payinfo",
|
|
||||||
})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "reqsn") || !strings.Contains(err.Error(), "trxid") {
|
|
||||||
t.Fatalf("unknown query_order_type error = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateAlipayV3PaymentConfigRequiresCertificates(t *testing.T) {
|
|
||||||
values := map[string]any{
|
|
||||||
"app_id": "alipay-v3-app",
|
|
||||||
"private_key": "private-key",
|
|
||||||
}
|
|
||||||
if err := validatePaymentConfig(biz.PaymentAlipayV3, values); err == nil || !strings.Contains(err.Error(), "app_cert") {
|
|
||||||
t.Fatalf("missing app certificate error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
values["app_cert_content"] = "app-certificate"
|
|
||||||
if err := validatePaymentConfig(biz.PaymentAlipayV3, values); err == nil || !strings.Contains(err.Error(), "root_cert") {
|
|
||||||
t.Fatalf("missing root certificate error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
values["alipay_root_cert_path"] = "alipayRootCert.crt"
|
|
||||||
if err := validatePaymentConfig(biz.PaymentAlipayV3, values); err == nil || !strings.Contains(err.Error(), "public_cert") {
|
|
||||||
t.Fatalf("missing public certificate error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
values["alipay_public_cert_content"] = "alipay-public-certificate"
|
|
||||||
if err := validatePaymentConfig(biz.PaymentAlipayV3, values); err != nil {
|
|
||||||
t.Fatalf("valid Alipay V3 configuration rejected: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidatePayPalPaymentConfigRequiresWebhookID(t *testing.T) {
|
|
||||||
values := map[string]any{
|
|
||||||
"client_id": "client-id",
|
|
||||||
"client_secret": "client-secret",
|
|
||||||
}
|
|
||||||
if err := validatePaymentConfig(biz.PaymentPayPal, values); err == nil || !strings.Contains(err.Error(), "webhook_id") {
|
|
||||||
t.Fatalf("missing webhook_id error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
values["webhook_id"] = "webhook-1"
|
|
||||||
if err := validatePaymentConfig(biz.PaymentPayPal, values); err != nil {
|
|
||||||
t.Fatalf("valid PayPal configuration rejected: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateWechatV2PaymentConfigRequiresRefundCertificatePair(t *testing.T) {
|
|
||||||
base := map[string]any{
|
|
||||||
"app_id": "wechat-app",
|
|
||||||
"merchant_id": "wechat-merchant",
|
|
||||||
"mch_key": "merchant-key",
|
|
||||||
}
|
|
||||||
if err := validatePaymentConfig(biz.PaymentWechatV2, base); err == nil || !strings.Contains(err.Error(), "client_cert") {
|
|
||||||
t.Fatalf("missing refund certificate error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
base["client_cert"] = "certificate-pem"
|
|
||||||
if err := validatePaymentConfig(biz.PaymentWechatV2, base); err == nil || !strings.Contains(err.Error(), "client_key") {
|
|
||||||
t.Fatalf("missing refund private key error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
base["client_key"] = "private-key-pem"
|
|
||||||
if err := validatePaymentConfig(biz.PaymentWechatV2, base); err != nil {
|
|
||||||
t.Fatalf("valid WeChat v2 configuration rejected: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateQQPaymentConfigRequiresRefundCertificate(t *testing.T) {
|
|
||||||
base := map[string]any{
|
|
||||||
"mch_id": "qq-merchant",
|
|
||||||
"api_key": "merchant-key",
|
|
||||||
}
|
|
||||||
if err := validatePaymentConfig(biz.PaymentQQ, base); err == nil || !strings.Contains(err.Error(), "cert_file") {
|
|
||||||
t.Fatalf("missing refund certificate error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
values map[string]any
|
|
||||||
}{
|
}{
|
||||||
{name: "certificate files", values: map[string]any{"cert_file": "cert.pem", "key_file": "key.pem"}},
|
{"allinpay order type", biz.PaymentAllinPay, "reqsn", map[string]any{"cus_id": "customer", "app_id": "app", "private_key": "private-key", "public_key": "public-key", "query_order_type": "payinfo"}},
|
||||||
{name: "pkcs12 file", values: map[string]any{"pkcs12_file": "merchant.p12"}},
|
{"paypal webhook", biz.PaymentPayPal, "webhook_id", map[string]any{"client_id": "client-id", "client_secret": "client-secret"}},
|
||||||
{name: "certificate content", values: map[string]any{"cert_content": "certificate-pem", "key_content": "private-key-pem"}},
|
{"wechat v2 refund cert", biz.PaymentWechatV2, "client_cert", map[string]any{"app_id": "app", "merchant_id": "merchant", "mch_key": "key"}},
|
||||||
{name: "pkcs12 content", values: map[string]any{"pkcs12_content": "base64-pkcs12"}},
|
}
|
||||||
} {
|
for _, test := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
values := map[string]any{}
|
err := biz.ValidateIntegrationConfig(biz.IntegrationKindPayment, test.provider, test.values)
|
||||||
for key, value := range base {
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
values[key] = value
|
t.Fatalf("error = %v, want %q", err, test.want)
|
||||||
}
|
|
||||||
for key, value := range tc.values {
|
|
||||||
values[key] = value
|
|
||||||
}
|
|
||||||
if err := validatePaymentConfig(biz.PaymentQQ, values); err != nil {
|
|
||||||
t.Fatalf("valid QQ configuration rejected: %v", err)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPaymentProviderNotifyURLRequirementMatchesCallbackSupport(t *testing.T) {
|
func TestValidatePaymentConfigGenericRequiresRuntimeFields(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
values := biz.DefaultIntegrationConfig(biz.IntegrationKindPayment, biz.PaymentChinaums)
|
||||||
provider string
|
if err := biz.ValidateIntegrationConfig(biz.IntegrationKindPayment, biz.PaymentChinaums, values); err == nil {
|
||||||
want bool
|
t.Fatal("empty generic payment config unexpectedly accepted")
|
||||||
}{
|
}
|
||||||
{provider: biz.PaymentApple, want: false},
|
values["app_id"], values["merchant_id"] = "app", "merchant"
|
||||||
{provider: biz.PaymentAllinPay, want: false},
|
values["create_url"], values["query_url"], values["refund_url"], values["app_key"] = "https://pay.test/create", "https://pay.test/query", "https://pay.test/refund", "secret"
|
||||||
{provider: biz.PaymentSaobei, want: false},
|
if err := biz.ValidateIntegrationConfig(biz.IntegrationKindPayment, biz.PaymentChinaums, values); err == nil {
|
||||||
{provider: biz.PaymentAlipay, want: true},
|
t.Fatal("generic config with only identity/endpoints unexpectedly accepted")
|
||||||
{provider: biz.PaymentAlipayV3, want: true},
|
|
||||||
{provider: biz.PaymentWechatV3, want: true},
|
|
||||||
} {
|
|
||||||
t.Run(tc.provider, func(t *testing.T) {
|
|
||||||
if got := paymentProviderRequiresNotifyURL(tc.provider); got != tc.want {
|
|
||||||
t.Fatalf("requires notify_url = %v, want %v", got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPaymentCreateNotifyURLRequirementMatchesSynchronousMethods(t *testing.T) {
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
provider string
|
|
||||||
extra map[string]any
|
|
||||||
config map[string]any
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{name: "paypal create", provider: biz.PaymentPayPal, want: false},
|
|
||||||
{name: "alipay barcode", provider: biz.PaymentAlipay, extra: map[string]any{"method": "barcode"}, want: false},
|
|
||||||
{name: "alipay v3 barcode", provider: biz.PaymentAlipayV3, extra: map[string]any{"method": "barcode"}, want: false},
|
|
||||||
{name: "alipay ignores unsupported pay type selector", provider: biz.PaymentAlipay, extra: map[string]any{"pay_type": "barcode"}, want: true},
|
|
||||||
{name: "wechat v2 micropay", provider: biz.PaymentWechatV2, config: map[string]any{"trade_type": "MICROPAY"}, want: false},
|
|
||||||
{name: "wechat v3 codepay", provider: biz.PaymentWechatV3, extra: map[string]any{"method": "codepay"}, want: false},
|
|
||||||
{name: "qq micropay", provider: biz.PaymentQQ, config: map[string]any{"trade_type": "MICROPAY"}, want: false},
|
|
||||||
{name: "lakala retail", provider: biz.PaymentLakala, extra: map[string]any{"method": "retail"}, want: false},
|
|
||||||
{name: "alipay native", provider: biz.PaymentAlipay, config: map[string]any{"method": "native"}, want: true},
|
|
||||||
{name: "wechat v3 jsapi", provider: biz.PaymentWechatV3, extra: map[string]any{"trade_type": "jsapi"}, want: true},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
if got := paymentCreateRequiresNotifyURL(tc.provider, tc.extra, tc.config); got != tc.want {
|
|
||||||
t.Fatalf("paymentCreateRequiresNotifyURL() = %v, want %v", got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPaymentCallbackEventIDPreservesVerifiedAdapterIdentity(t *testing.T) {
|
|
||||||
callback := &biz.PaymentCallback{
|
|
||||||
Provider: biz.PaymentDouyin,
|
|
||||||
Body: []byte(`{"id":"untrusted-flat-id","data":{"event_id":"nested-id"}}`),
|
|
||||||
}
|
|
||||||
result := &biz.PaymentResult{EventID: " verified-sdk-event "}
|
|
||||||
if got := paymentCallbackEventID(callback, result); got != "verified-sdk-event" {
|
|
||||||
t.Fatalf("paymentCallbackEventID() = %q, want verified adapter ID", got)
|
|
||||||
}
|
|
||||||
result.EventID = ""
|
|
||||||
if got := paymentCallbackEventID(callback, result); got != "untrusted-flat-id" {
|
|
||||||
t.Fatalf("paymentCallbackEventID() fallback = %q, want flat callback ID", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateGenericPaymentConfigRequiresRuntimeEndpointsAndIdentity(t *testing.T) {
|
|
||||||
base := map[string]any{
|
|
||||||
"protocol_version": "v1",
|
|
||||||
"app_id": "app",
|
|
||||||
"merchant_id": "merchant",
|
|
||||||
"create_url": "https://pay.test/create",
|
|
||||||
"query_url": "https://pay.test/query",
|
|
||||||
"refund_url": "https://pay.test/refund",
|
|
||||||
"app_key": "secret",
|
|
||||||
"query_status_field": "data.status",
|
|
||||||
"query_success_values": "SUCCESS",
|
|
||||||
"query_trade_no_field": "data.trade_no",
|
|
||||||
"query_provider_trade_no_field": "data.provider_trade_no",
|
|
||||||
"query_amount_field": "data.amount",
|
|
||||||
"query_currency_field": "data.currency",
|
|
||||||
"query_amount_scale": "100",
|
|
||||||
"callback_status_field": "data.status",
|
|
||||||
"callback_success_values": "SUCCESS",
|
|
||||||
"callback_trade_no_field": "data.trade_no",
|
|
||||||
"callback_provider_trade_no_field": "data.provider_trade_no",
|
|
||||||
}
|
|
||||||
if err := validatePaymentConfig(biz.PaymentChinaums, base); err != nil {
|
|
||||||
t.Fatalf("valid generic config rejected: %v", err)
|
|
||||||
}
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
key string
|
|
||||||
value any
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{name: "missing app id", key: "app_id", want: "app_id"},
|
|
||||||
{name: "missing merchant id", key: "merchant_id", want: "merchant_id"},
|
|
||||||
{name: "missing refund endpoint", key: "refund_url", want: "refund_url"},
|
|
||||||
{name: "zero amount scale", key: "query_amount_scale", value: "0", want: "query_amount_scale"},
|
|
||||||
{name: "non power amount scale", key: "query_amount_scale", value: "3", want: "query_amount_scale"},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
values := map[string]any{}
|
|
||||||
for key, value := range base {
|
|
||||||
values[key] = value
|
|
||||||
}
|
|
||||||
if tc.value == nil {
|
|
||||||
values[tc.key] = ""
|
|
||||||
} else {
|
|
||||||
values[tc.key] = tc.value
|
|
||||||
}
|
|
||||||
err := validatePaymentConfig(biz.PaymentChinaums, values)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
||||||
t.Fatalf("validation error = %v, want %q", err, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,119 +1,30 @@
|
||||||
package payment
|
package payment
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"strings"
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"kra/app/system/internal/biz"
|
"kra/app/system/internal/biz"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMigrateSeedsPaymentProviders(t *testing.T) {
|
func TestPaymentDefinitionsProvideNonEmptyDefaults(t *testing.T) {
|
||||||
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
definitions := biz.IntegrationDefinitions(biz.IntegrationKindPayment)
|
||||||
if err != nil {
|
if len(definitions) != len(biz.SupportedPaymentProviders) {
|
||||||
t.Fatal(err)
|
t.Fatalf("payment definitions = %d, want %d", len(definitions), len(biz.SupportedPaymentProviders))
|
||||||
}
|
}
|
||||||
if err = migrateAll(db); err != nil {
|
for _, definition := range definitions {
|
||||||
t.Fatal(err)
|
if definition.Provider == "" || definition.Name == "" || len(definition.Fields) == 0 {
|
||||||
}
|
t.Fatalf("incomplete payment definition: %+v", definition)
|
||||||
var count int64
|
}
|
||||||
if err = db.Model(&integrationConfigPO{}).Where("kind = ?", integrationKindPayment).Count(&count).Error; err != nil {
|
if len(definition.Defaults) == 0 {
|
||||||
t.Fatal(err)
|
t.Fatalf("payment provider %s still has empty defaults", definition.Provider)
|
||||||
}
|
|
||||||
if count != int64(len(biz.SupportedPaymentProviders)) {
|
|
||||||
t.Fatalf("payment providers = %d", count)
|
|
||||||
}
|
|
||||||
if !db.Migrator().HasTable("pay_orders") {
|
|
||||||
t.Fatal("payment migration must create pay_orders")
|
|
||||||
}
|
|
||||||
if db.Migrator().HasTable("pay_callback_events") {
|
|
||||||
t.Fatal("payment migration must not create pay_callback_events")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPaymentSecretMaskAndMerge(t *testing.T) {
|
|
||||||
values := map[string]any{"app_id": "a", "private_key": "private", "nested": map[string]any{"api_v3_key": "key"}}
|
|
||||||
maskPaymentSecrets(values)
|
|
||||||
if values["private_key"] != "******" || values["nested"].(map[string]any)["api_v3_key"] != "******" {
|
|
||||||
t.Fatalf("not masked: %#v", values)
|
|
||||||
}
|
|
||||||
mergePaymentSecrets(values, map[string]any{"private_key": "private", "nested": map[string]any{"api_v3_key": "key"}})
|
|
||||||
if values["private_key"] != "private" || values["nested"].(map[string]any)["api_v3_key"] != "key" {
|
|
||||||
t.Fatalf("not merged: %#v", values)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPaymentConfigListMasksAndSavePreservesSecretAliases(t *testing.T) {
|
|
||||||
db := openIntegrationConfigTestDB(t)
|
|
||||||
repo := &paymentRepo{data: &Data{gormDB: newReloadableDB(db, nil)}}
|
|
||||||
secrets := map[string]string{
|
|
||||||
"app_key": "app-key-secret",
|
|
||||||
"token": "signing-token",
|
|
||||||
"key_content": "private-key-content",
|
|
||||||
"pkcs12_content": "pkcs12-content",
|
|
||||||
"op_user_passwd": "operator-password",
|
|
||||||
"api_v3key": "api-v3-key-alias",
|
|
||||||
"apiv3_key": "api-v3-key-compact-alias",
|
|
||||||
"key_pem": "key-pem-content",
|
|
||||||
"apiclient_key": "api-client-key-content",
|
|
||||||
}
|
|
||||||
storedValues := map[string]any{"app_id": "public-app-id"}
|
|
||||||
for key, value := range secrets {
|
|
||||||
storedValues[key] = value
|
|
||||||
}
|
|
||||||
storedValues["nested"] = map[string]any{"token": "nested-token"}
|
|
||||||
raw, err := json.Marshal(storedValues)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err = db.Create(&integrationConfigPO{
|
|
||||||
Kind: integrationKindPayment, Provider: biz.PaymentQQ, Config: string(raw),
|
|
||||||
}).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
configs, err := repo.ListConfigs(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(configs) != 1 {
|
|
||||||
t.Fatalf("payment configs = %d, want 1", len(configs))
|
|
||||||
}
|
|
||||||
masked := map[string]any{}
|
|
||||||
if err = json.Unmarshal(configs[0].Values, &masked); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if masked["app_id"] != "public-app-id" {
|
|
||||||
t.Fatalf("non-secret app_id = %#v, want public-app-id", masked["app_id"])
|
|
||||||
}
|
|
||||||
for key := range secrets {
|
|
||||||
if masked[key] != "******" {
|
|
||||||
t.Errorf("secret %s = %#v, want masked value", key, masked[key])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if nested, ok := masked["nested"].(map[string]any); !ok || nested["token"] != "******" {
|
}
|
||||||
t.Errorf("nested token was not masked: %#v", masked["nested"])
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = repo.SaveConfig(context.Background(), &biz.PaymentConfig{
|
func TestPaymentValidationRejectsUnknownProvider(t *testing.T) {
|
||||||
Provider: biz.PaymentQQ, Values: configs[0].Values,
|
err := validatePaymentConfig("unknown-provider", map[string]any{})
|
||||||
}); err != nil {
|
if err == nil || !strings.Contains(err.Error(), "不支持") {
|
||||||
t.Fatal(err)
|
t.Fatalf("error = %v", err)
|
||||||
}
|
|
||||||
var row integrationConfigPO
|
|
||||||
if err = db.Where("kind = ? AND provider = ?", integrationKindPayment, biz.PaymentQQ).First(&row).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
preserved := map[string]any{}
|
|
||||||
if err = json.Unmarshal([]byte(row.Config), &preserved); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for key, want := range secrets {
|
|
||||||
if preserved[key] != want {
|
|
||||||
t.Errorf("preserved secret %s = %#v, want %q", key, preserved[key], want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if nested, ok := preserved["nested"].(map[string]any); !ok || nested["token"] != "nested-token" {
|
|
||||||
t.Errorf("nested token was not preserved: %#v", preserved["nested"])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
package system
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"kra/app/system/internal/biz"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type integrationConfigPO struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
Kind string `gorm:"size:32;not null;uniqueIndex:idx_integration_kind_provider"`
|
||||||
|
Provider string `gorm:"size:64;not null;uniqueIndex:idx_integration_kind_provider"`
|
||||||
|
Enabled bool
|
||||||
|
Config string `gorm:"type:text;not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (integrationConfigPO) TableName() string { return "sys_integration_configs" }
|
||||||
|
|
||||||
|
type integrationConfigRepo struct{ data Provider }
|
||||||
|
|
||||||
|
func NewIntegrationConfigRepo(data Provider) biz.IntegrationConfigRepo {
|
||||||
|
return &integrationConfigRepo{data: data}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *integrationConfigRepo) ListIntegrationConfigs(ctx context.Context, kind string) ([]*biz.IntegrationConfig, error) {
|
||||||
|
var rows []integrationConfigPO
|
||||||
|
if err := r.data.DB().WithContext(ctx).Where("kind = ?", kind).Order("provider ASC").Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]*biz.IntegrationConfig, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
result = append(result, integrationConfigFromPO(row))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *integrationConfigRepo) FindIntegrationConfig(ctx context.Context, kind, provider string) (*biz.IntegrationConfig, error) {
|
||||||
|
var row integrationConfigPO
|
||||||
|
if err := r.data.DB().WithContext(ctx).Where("kind = ? AND provider = ?", kind, provider).First(&row).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("集成配置不存在")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return integrationConfigFromPO(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *integrationConfigRepo) SaveIntegrationConfig(ctx context.Context, config *biz.IntegrationConfig) error {
|
||||||
|
db := r.data.DB().WithContext(ctx)
|
||||||
|
var row integrationConfigPO
|
||||||
|
err := db.Where("kind = ? AND provider = ?", config.Kind, config.Provider).First(&row).Error
|
||||||
|
values := integrationObject(config.Values)
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
if config.Enabled {
|
||||||
|
if err = biz.ValidateIntegrationConfig(config.Kind, config.Provider, values); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encoded, _ := json.Marshal(values)
|
||||||
|
return db.Create(&integrationConfigPO{Kind: config.Kind, Provider: config.Provider, Enabled: config.Enabled, Config: string(encoded)}).Error
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mergeIntegrationSecrets(config.Kind, config.Provider, values, integrationObject(json.RawMessage(row.Config)))
|
||||||
|
if config.Enabled {
|
||||||
|
if err = biz.ValidateIntegrationConfig(config.Kind, 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 *integrationConfigRepo) DeleteIntegrationConfig(ctx context.Context, kind, provider string) error {
|
||||||
|
return r.data.DB().WithContext(ctx).Where("kind = ? AND provider = ?", kind, provider).Delete(&integrationConfigPO{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func integrationConfigFromPO(row integrationConfigPO) *biz.IntegrationConfig {
|
||||||
|
values := integrationObject(json.RawMessage(row.Config))
|
||||||
|
maskIntegrationSecrets(row.Kind, row.Provider, values)
|
||||||
|
encoded, _ := json.Marshal(values)
|
||||||
|
return &biz.IntegrationConfig{Kind: row.Kind, Provider: row.Provider, Enabled: row.Enabled, Values: encoded}
|
||||||
|
}
|
||||||
|
|
||||||
|
func integrationObject(raw json.RawMessage) map[string]any {
|
||||||
|
values := map[string]any{}
|
||||||
|
_ = json.Unmarshal(raw, &values)
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskIntegrationSecrets(kind, provider string, values map[string]any) {
|
||||||
|
secretFields := integrationSecretFields(kind, provider)
|
||||||
|
for key, value := range values {
|
||||||
|
if secretFields[key] || likelyIntegrationSecret(key) {
|
||||||
|
if text, ok := value.(string); ok && text != "" {
|
||||||
|
values[key] = "******"
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if nested, ok := value.(map[string]any); ok {
|
||||||
|
maskIntegrationSecrets(kind, provider, nested)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeIntegrationSecrets(kind, provider string, values, old map[string]any) {
|
||||||
|
secretFields := integrationSecretFields(kind, provider)
|
||||||
|
for key, value := range values {
|
||||||
|
if secretFields[key] || likelyIntegrationSecret(key) {
|
||||||
|
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 {
|
||||||
|
mergeIntegrationSecrets(kind, provider, nested, prior)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func integrationSecretFields(kind, provider string) map[string]bool {
|
||||||
|
result := map[string]bool{}
|
||||||
|
if definition, ok := biz.IntegrationDefinition(kind, provider); ok {
|
||||||
|
for _, field := range definition.Fields {
|
||||||
|
if field.Secret {
|
||||||
|
result[field.Key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func likelyIntegrationSecret(key string) bool {
|
||||||
|
normalized := strings.ToLower(strings.ReplaceAll(key, "-", "_"))
|
||||||
|
if strings.Contains(normalized, "secret") || strings.Contains(normalized, "private") || strings.Contains(normalized, "password") || strings.Contains(normalized, "credential") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, item := range []string{"key", "token", "access_token", "api_key", "mch_key", "client_key", "certificate", "cert", "p12", "pkcs12", "public_key", "platform_cert", "root_cert"} {
|
||||||
|
if normalized == item || strings.HasSuffix(normalized, "_"+item) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ func emptyHandlers() *handler.Set {
|
||||||
Version: &handler.Version{}, Dictionary: &handler.Dictionary{}, Parameter: &handler.Parameter{},
|
Version: &handler.Version{}, Dictionary: &handler.Dictionary{}, Parameter: &handler.Parameter{},
|
||||||
APIToken: &handler.APIToken{}, SystemConfig: &handler.SystemConfig{}, Public: &handler.Public{},
|
APIToken: &handler.APIToken{}, SystemConfig: &handler.SystemConfig{}, Public: &handler.Public{},
|
||||||
User: &handler.User{}, Navigation: &handler.Navigation{}, Session: &handler.Session{},
|
User: &handler.User{}, Navigation: &handler.Navigation{}, Session: &handler.Session{},
|
||||||
|
IntegrationConfig: &handler.IntegrationConfig{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,8 +44,21 @@ func TestGinRouteContract(t *testing.T) {
|
||||||
actual = append(actual, key)
|
actual = append(actual, key)
|
||||||
}
|
}
|
||||||
sort.Strings(actual)
|
sort.Strings(actual)
|
||||||
if value := strings.Join(actual, "\n"); value != expectedGinRouteContract {
|
value := strings.Join(actual, "\n")
|
||||||
t.Fatalf("route contract changed:\n%s", value)
|
for _, route := range []string{
|
||||||
|
"GET /integration/configs/:kind",
|
||||||
|
"GET /integration/configs/:kind/:provider",
|
||||||
|
"PUT /integration/configs/:kind/:provider",
|
||||||
|
"DELETE /integration/configs/:kind/:provider",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(value, route) {
|
||||||
|
t.Fatalf("route contract missing %s:\n%s", route, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, removed := range []string{"GET /payment/configs", "POST /payment/config"} {
|
||||||
|
if strings.Contains(value, removed) {
|
||||||
|
t.Fatalf("legacy payment config route still registered: %s", removed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,7 +70,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
|
||||||
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
|
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
|
||||||
t.Fatalf("registered route log count = %d, want %d", got, want)
|
t.Fatalf("registered route log count = %d, want %d", got, want)
|
||||||
}
|
}
|
||||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":190`) {
|
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":192`) {
|
||||||
t.Fatalf("startup route summary is missing: %s", text)
|
t.Fatalf("startup route summary is missing: %s", text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -256,7 +270,8 @@ GET /logViewer/content
|
||||||
GET /logViewer/dates
|
GET /logViewer/dates
|
||||||
GET /logViewer/files
|
GET /logViewer/files
|
||||||
GET /menu/getMenuRoles
|
GET /menu/getMenuRoles
|
||||||
GET /payment/configs
|
GET /integration/configs/:kind
|
||||||
|
GET /integration/configs/:kind/:provider
|
||||||
GET /payment/orders
|
GET /payment/orders
|
||||||
GET /payment/orders/:provider/:tradeNo
|
GET /payment/orders/:provider/:tradeNo
|
||||||
GET /position/findPosition
|
GET /position/findPosition
|
||||||
|
|
@ -352,7 +367,6 @@ POST /menu/getMenuList
|
||||||
POST /menu/setMenuRoles
|
POST /menu/setMenuRoles
|
||||||
POST /menu/updateBaseMenu
|
POST /menu/updateBaseMenu
|
||||||
POST /payment/callback/:provider
|
POST /payment/callback/:provider
|
||||||
POST /payment/config
|
|
||||||
POST /payment/create
|
POST /payment/create
|
||||||
POST /payment/fulfill
|
POST /payment/fulfill
|
||||||
POST /payment/order
|
POST /payment/order
|
||||||
|
|
@ -394,6 +408,7 @@ POST /user/setUserPositions
|
||||||
PUT /authority/updateAuthority
|
PUT /authority/updateAuthority
|
||||||
PUT /department/updateDepartment
|
PUT /department/updateDepartment
|
||||||
PUT /info/updateInfo
|
PUT /info/updateInfo
|
||||||
|
PUT /integration/configs/:kind/:provider
|
||||||
PUT /position/updatePosition
|
PUT /position/updatePosition
|
||||||
PUT /sysDictionary/updateSysDictionary
|
PUT /sysDictionary/updateSysDictionary
|
||||||
PUT /sysDictionaryDetail/updateSysDictionaryDetail
|
PUT /sysDictionaryDetail/updateSysDictionaryDetail
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"kra/app/system/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IntegrationConfig struct {
|
||||||
|
service *service.IntegrationConfigService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIntegrationConfig(service *service.IntegrationConfigService) *IntegrationConfig {
|
||||||
|
return &IntegrationConfig{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *IntegrationConfig) List(c *gin.Context) {
|
||||||
|
configs, err := h.service.List(c.Request.Context(), c.Param("kind"))
|
||||||
|
if err != nil {
|
||||||
|
Fail(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
OKWithData(c, configs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *IntegrationConfig) Find(c *gin.Context) {
|
||||||
|
config, err := h.service.Find(c.Request.Context(), c.Param("kind"), c.Param("provider"))
|
||||||
|
if err != nil {
|
||||||
|
Fail(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
OKWithData(c, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *IntegrationConfig) Save(c *gin.Context) {
|
||||||
|
var req service.IntegrationConfigRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
Fail(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.Save(c.Request.Context(), c.Param("kind"), c.Param("provider"), &req); err != nil {
|
||||||
|
Fail(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
OK(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *IntegrationConfig) Delete(c *gin.Context) {
|
||||||
|
if err := h.service.Delete(c.Request.Context(), c.Param("kind"), c.Param("provider")); err != nil {
|
||||||
|
Fail(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
OK(c)
|
||||||
|
}
|
||||||
|
|
@ -11,26 +11,6 @@ import (
|
||||||
type Payment struct{ service *service.PaymentService }
|
type Payment struct{ service *service.PaymentService }
|
||||||
|
|
||||||
func NewPayment(service *service.PaymentService) *Payment { return &Payment{service: service} }
|
func NewPayment(service *service.PaymentService) *Payment { return &Payment{service: service} }
|
||||||
func (h *Payment) Configs(c *gin.Context) {
|
|
||||||
v, err := h.service.Configs(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
Fail(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
OKWithData(c, v)
|
|
||||||
}
|
|
||||||
func (h *Payment) SaveConfig(c *gin.Context) {
|
|
||||||
var req service.PaymentConfigRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
Fail(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.service.SaveConfig(c.Request.Context(), &req); err != nil {
|
|
||||||
Fail(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
OK(c)
|
|
||||||
}
|
|
||||||
func (h *Payment) Order(c *gin.Context) {
|
func (h *Payment) Order(c *gin.Context) {
|
||||||
var req service.PaymentQueryRequest
|
var req service.PaymentQueryRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -8,4 +8,5 @@ var ProviderSet = wire.NewSet(
|
||||||
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
|
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
|
||||||
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
|
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
|
||||||
NewSystemConfig, NewPublic, NewUser, NewNavigation, NewSession, NewSet,
|
NewSystemConfig, NewPublic, NewUser, NewNavigation, NewSession, NewSet,
|
||||||
|
NewIntegrationConfig,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,30 @@
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
type Set struct {
|
type Set struct {
|
||||||
Authority *Authority
|
Authority *Authority
|
||||||
Menu *Menu
|
Menu *Menu
|
||||||
API *API
|
API *API
|
||||||
Permission *Permission
|
Permission *Permission
|
||||||
Organization *Organization
|
Organization *Organization
|
||||||
Announcement *Announcement
|
Announcement *Announcement
|
||||||
Email *Email
|
Email *Email
|
||||||
Payment *Payment
|
Payment *Payment
|
||||||
Task *Task
|
Task *Task
|
||||||
Media *Media
|
Media *Media
|
||||||
Audit *Audit
|
Audit *Audit
|
||||||
Export *Export
|
Export *Export
|
||||||
Version *Version
|
Version *Version
|
||||||
Dictionary *Dictionary
|
Dictionary *Dictionary
|
||||||
Parameter *Parameter
|
Parameter *Parameter
|
||||||
APIToken *APIToken
|
APIToken *APIToken
|
||||||
SystemConfig *SystemConfig
|
SystemConfig *SystemConfig
|
||||||
Public *Public
|
Public *Public
|
||||||
User *User
|
User *User
|
||||||
Navigation *Navigation
|
Navigation *Navigation
|
||||||
Session *Session
|
Session *Session
|
||||||
|
IntegrationConfig *IntegrationConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSet(authority *Authority, menu *Menu, api *API, permission *Permission, organization *Organization, announcement *Announcement, email *Email, payment *Payment, task *Task, media *Media, audit *Audit, export *Export, version *Version, dictionary *Dictionary, parameter *Parameter, apiToken *APIToken, systemConfig *SystemConfig, public *Public, user *User, navigation *Navigation, session *Session) *Set {
|
func NewSet(authority *Authority, menu *Menu, api *API, permission *Permission, organization *Organization, announcement *Announcement, email *Email, payment *Payment, task *Task, media *Media, audit *Audit, export *Export, version *Version, dictionary *Dictionary, parameter *Parameter, apiToken *APIToken, systemConfig *SystemConfig, public *Public, user *User, navigation *Navigation, session *Session, integrationConfig *IntegrationConfig) *Set {
|
||||||
return &Set{Authority: authority, Menu: menu, API: api, Permission: permission, Organization: organization, Announcement: announcement, Email: email, Payment: payment, Task: task, Media: media, Audit: audit, Export: export, Version: version, Dictionary: dictionary, Parameter: parameter, APIToken: apiToken, SystemConfig: systemConfig, Public: public, User: user, Navigation: navigation, Session: session}
|
return &Set{Authority: authority, Menu: menu, API: api, Permission: permission, Organization: organization, Announcement: announcement, Email: email, Payment: payment, Task: task, Media: media, Audit: audit, Export: export, Version: version, Dictionary: dictionary, Parameter: parameter, APIToken: apiToken, SystemConfig: systemConfig, Public: public, User: user, Navigation: navigation, Session: session, IntegrationConfig: integrationConfig}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,3 +24,4 @@ type SystemConfig = handler.SystemConfig
|
||||||
type Task = handler.Task
|
type Task = handler.Task
|
||||||
type User = handler.User
|
type User = handler.User
|
||||||
type Version = handler.Version
|
type Version = handler.Version
|
||||||
|
type IntegrationConfig = handler.IntegrationConfig
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package router
|
||||||
|
|
||||||
|
import "github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
func RegisterIntegrationConfig(group *gin.RouterGroup, handler *IntegrationConfig) {
|
||||||
|
configs := group.Group("/integration/configs")
|
||||||
|
configs.GET("/:kind", handler.List)
|
||||||
|
configs.GET("/:kind/:provider", handler.Find)
|
||||||
|
configs.PUT("/:kind/:provider", handler.Save)
|
||||||
|
configs.DELETE("/:kind/:provider", handler.Delete)
|
||||||
|
}
|
||||||
|
|
@ -6,8 +6,6 @@ import (
|
||||||
|
|
||||||
func RegisterPayment(group, public *gin.RouterGroup, h *Payment) {
|
func RegisterPayment(group, public *gin.RouterGroup, h *Payment) {
|
||||||
payment := group.Group("/payment")
|
payment := group.Group("/payment")
|
||||||
payment.GET("/configs", h.Configs)
|
|
||||||
payment.POST("/config", h.SaveConfig)
|
|
||||||
payment.GET("/orders", h.Orders)
|
payment.GET("/orders", h.Orders)
|
||||||
payment.POST("/order", h.Order)
|
payment.POST("/order", h.Order)
|
||||||
payment.GET("/orders/:provider/:tradeNo", h.OrderByPath)
|
payment.GET("/orders/:provider/:tradeNo", h.OrderByPath)
|
||||||
|
|
|
||||||
|
|
@ -38,5 +38,6 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
|
||||||
RegisterMedia(private, r.handlers.Media)
|
RegisterMedia(private, r.handlers.Media)
|
||||||
RegisterAnnouncement(private, public, r.handlers.Announcement)
|
RegisterAnnouncement(private, public, r.handlers.Announcement)
|
||||||
RegisterEmail(private, r.handlers.Email)
|
RegisterEmail(private, r.handlers.Email)
|
||||||
|
RegisterIntegrationConfig(private, r.handlers.IntegrationConfig)
|
||||||
RegisterPayment(private, public, r.handlers.Payment)
|
RegisterPayment(private, public, r.handlers.Payment)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"kra/app/system/internal/biz"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IntegrationConfigRequest struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Config json.RawMessage `json:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IntegrationConfigResponse struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Configured bool `json:"configured"`
|
||||||
|
Config json.RawMessage `json:"config"`
|
||||||
|
Fields []biz.IntegrationConfigField `json:"fields"`
|
||||||
|
}
|
||||||
|
|
@ -5,21 +5,6 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PaymentConfigRequest struct {
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
Config json.RawMessage `json:"config"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PaymentConfigResponse is the transport representation returned by the
|
|
||||||
// payment configuration endpoint. Values are already masked by the usecase's
|
|
||||||
// repository boundary.
|
|
||||||
type PaymentConfigResponse struct {
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
Config json.RawMessage `json:"config"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PaymentOrderListRequest struct {
|
type PaymentOrderListRequest struct {
|
||||||
Page int `form:"page"`
|
Page int `form:"page"`
|
||||||
PageSize int `form:"pageSize"`
|
PageSize int `form:"pageSize"`
|
||||||
|
|
|
||||||
|
|
@ -96,8 +96,8 @@ type OperationRecordResponse = dto.OperationRecordResponse
|
||||||
type OperationRecordSearchRequest = dto.OperationRecordSearchRequest
|
type OperationRecordSearchRequest = dto.OperationRecordSearchRequest
|
||||||
type PaymentCallbackAck = dto.PaymentCallbackAck
|
type PaymentCallbackAck = dto.PaymentCallbackAck
|
||||||
type PaymentCallbackRequest = dto.PaymentCallbackRequest
|
type PaymentCallbackRequest = dto.PaymentCallbackRequest
|
||||||
type PaymentConfigRequest = dto.PaymentConfigRequest
|
type IntegrationConfigRequest = dto.IntegrationConfigRequest
|
||||||
type PaymentConfigResponse = dto.PaymentConfigResponse
|
type IntegrationConfigResponse = dto.IntegrationConfigResponse
|
||||||
type PaymentFulfillRequest = dto.PaymentFulfillRequest
|
type PaymentFulfillRequest = dto.PaymentFulfillRequest
|
||||||
type PaymentOrderListRequest = dto.PaymentOrderListRequest
|
type PaymentOrderListRequest = dto.PaymentOrderListRequest
|
||||||
type PaymentOrderResponse = dto.PaymentOrderResponse
|
type PaymentOrderResponse = dto.PaymentOrderResponse
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"kra/app/system/internal/biz"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IntegrationConfigService struct{ uc *biz.IntegrationConfigUsecase }
|
||||||
|
|
||||||
|
func NewIntegrationConfigService(uc *biz.IntegrationConfigUsecase) *IntegrationConfigService {
|
||||||
|
return &IntegrationConfigService{uc: uc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *IntegrationConfigService) List(ctx context.Context, kind string) ([]*IntegrationConfigResponse, error) {
|
||||||
|
configs, err := s.uc.List(ctx, kind)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
byProvider := make(map[string]*biz.IntegrationConfig, len(configs))
|
||||||
|
for _, config := range configs {
|
||||||
|
if config != nil {
|
||||||
|
byProvider[config.Provider] = config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
definitions := biz.IntegrationDefinitions(kind)
|
||||||
|
result := make([]*IntegrationConfigResponse, 0, len(definitions)+len(configs))
|
||||||
|
for _, definition := range definitions {
|
||||||
|
config, configured := byProvider[definition.Provider]
|
||||||
|
values := mergeConfigJSON(definition.Defaults, nil)
|
||||||
|
enabled := false
|
||||||
|
if configured {
|
||||||
|
values = mergeConfigJSON(definition.Defaults, config.Values)
|
||||||
|
enabled = config.Enabled
|
||||||
|
delete(byProvider, definition.Provider)
|
||||||
|
}
|
||||||
|
result = append(result, &IntegrationConfigResponse{Kind: definition.Kind, Provider: definition.Provider, Name: definition.Name, Description: definition.Description, Enabled: enabled, Configured: configured, Config: values, Fields: definition.Fields})
|
||||||
|
}
|
||||||
|
for _, config := range configs {
|
||||||
|
if _, exists := byProvider[config.Provider]; !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, &IntegrationConfigResponse{Kind: config.Kind, Provider: config.Provider, Name: config.Provider, Enabled: config.Enabled, Configured: true, Config: config.Values})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *IntegrationConfigService) Find(ctx context.Context, kind, provider string) (*IntegrationConfigResponse, error) {
|
||||||
|
config, err := s.uc.Find(ctx, kind, provider)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
definition, found := biz.IntegrationDefinition(config.Kind, config.Provider)
|
||||||
|
response := &IntegrationConfigResponse{Kind: config.Kind, Provider: config.Provider, Name: config.Provider, Enabled: config.Enabled, Configured: true, Config: config.Values}
|
||||||
|
if found {
|
||||||
|
response.Name = definition.Name
|
||||||
|
response.Description = definition.Description
|
||||||
|
response.Fields = definition.Fields
|
||||||
|
response.Config = mergeConfigJSON(definition.Defaults, config.Values)
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *IntegrationConfigService) Save(ctx context.Context, kind, provider string, req *IntegrationConfigRequest) error {
|
||||||
|
if req == nil {
|
||||||
|
return s.uc.Save(ctx, nil)
|
||||||
|
}
|
||||||
|
return s.uc.Save(ctx, &biz.IntegrationConfig{Kind: kind, Provider: provider, Enabled: req.Enabled, Values: req.Config})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *IntegrationConfigService) Delete(ctx context.Context, kind, provider string) error {
|
||||||
|
return s.uc.Delete(ctx, kind, provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeConfigJSON(defaults map[string]any, raw json.RawMessage) json.RawMessage {
|
||||||
|
values := make(map[string]any, len(defaults))
|
||||||
|
for key, value := range defaults {
|
||||||
|
values[key] = value
|
||||||
|
}
|
||||||
|
stored := map[string]any{}
|
||||||
|
_ = json.Unmarshal(raw, &stored)
|
||||||
|
for key, value := range stored {
|
||||||
|
values[key] = value
|
||||||
|
}
|
||||||
|
encoded, _ := json.Marshal(values)
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
@ -10,26 +10,6 @@ import (
|
||||||
type PaymentService struct{ uc *biz.PaymentUsecase }
|
type PaymentService struct{ uc *biz.PaymentUsecase }
|
||||||
|
|
||||||
func NewPaymentService(uc *biz.PaymentUsecase) *PaymentService { return &PaymentService{uc: uc} }
|
func NewPaymentService(uc *biz.PaymentUsecase) *PaymentService { return &PaymentService{uc: uc} }
|
||||||
func (s *PaymentService) Configs(ctx context.Context) ([]*PaymentConfigResponse, error) {
|
|
||||||
configs, err := s.uc.Configs(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result := make([]*PaymentConfigResponse, 0, len(configs))
|
|
||||||
for _, config := range configs {
|
|
||||||
if config == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result = append(result, &PaymentConfigResponse{Provider: config.Provider, Enabled: config.Enabled, Config: config.Values})
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
func (s *PaymentService) SaveConfig(ctx context.Context, req *PaymentConfigRequest) error {
|
|
||||||
if req == nil {
|
|
||||||
return errors.New("支付配置请求为空")
|
|
||||||
}
|
|
||||||
return s.uc.SaveConfig(ctx, &biz.PaymentConfig{Provider: req.Provider, Enabled: req.Enabled, Values: req.Config})
|
|
||||||
}
|
|
||||||
func (s *PaymentService) Order(ctx context.Context, provider, tradeNo string) (*PaymentOrderResponse, error) {
|
func (s *PaymentService) Order(ctx context.Context, provider, tradeNo string) (*PaymentOrderResponse, error) {
|
||||||
order, err := s.uc.Order(ctx, provider, tradeNo)
|
order, err := s.uc.Order(ctx, provider, tradeNo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,8 @@ var apiMetadata = map[string]apiMetadataValue{
|
||||||
"GET /menu/getMenuRoles": {group: "菜单", description: "获取菜单关联角色列表"},
|
"GET /menu/getMenuRoles": {group: "菜单", description: "获取菜单关联角色列表"},
|
||||||
"GET /position/findPosition": {group: "岗位", description: "根据ID获取岗位"},
|
"GET /position/findPosition": {group: "岗位", description: "根据ID获取岗位"},
|
||||||
"GET /position/getPositionUsers": {group: "岗位", description: "获取岗位成员ID列表"},
|
"GET /position/getPositionUsers": {group: "岗位", description: "获取岗位成员ID列表"},
|
||||||
"GET /payment/configs": {group: "支付", description: "获取支付渠道配置"},
|
"GET /integration/configs/:kind": {group: "集成配置", description: "按类型获取集成配置"},
|
||||||
|
"GET /integration/configs/:kind/:provider": {group: "集成配置", description: "获取指定集成配置"},
|
||||||
"GET /payment/orders": {group: "支付", description: "分页查询支付订单"},
|
"GET /payment/orders": {group: "支付", description: "分页查询支付订单"},
|
||||||
"GET /securityConfig/getSecurityConfig": {group: "安全配置", description: "获取安全配置"},
|
"GET /securityConfig/getSecurityConfig": {group: "安全配置", description: "获取安全配置"},
|
||||||
"GET /sysDictionary/exportSysDictionary": {group: "系统字典", description: "导出字典JSON"},
|
"GET /sysDictionary/exportSysDictionary": {group: "系统字典", description: "导出字典JSON"},
|
||||||
|
|
@ -127,7 +128,8 @@ var apiMetadata = map[string]apiMetadataValue{
|
||||||
"POST /menu/getMenuList": {group: "菜单", description: "分页获取基础menu列表"},
|
"POST /menu/getMenuList": {group: "菜单", description: "分页获取基础menu列表"},
|
||||||
"POST /menu/setMenuRoles": {group: "菜单", description: "全量覆盖菜单关联角色列表"},
|
"POST /menu/setMenuRoles": {group: "菜单", description: "全量覆盖菜单关联角色列表"},
|
||||||
"POST /menu/updateBaseMenu": {group: "菜单", description: "更新菜单"},
|
"POST /menu/updateBaseMenu": {group: "菜单", description: "更新菜单"},
|
||||||
"POST /payment/config": {group: "支付", description: "保存支付渠道配置"},
|
"PUT /integration/configs/:kind/:provider": {group: "集成配置", description: "保存集成配置"},
|
||||||
|
"DELETE /integration/configs/:kind/:provider": {group: "集成配置", description: "删除集成配置"},
|
||||||
"POST /payment/order": {group: "支付", description: "查询支付订单"},
|
"POST /payment/order": {group: "支付", description: "查询支付订单"},
|
||||||
"POST /position/createPosition": {group: "岗位", description: "创建岗位"},
|
"POST /position/createPosition": {group: "岗位", description: "创建岗位"},
|
||||||
"POST /position/getPositionList": {group: "岗位", description: "获取岗位列表"},
|
"POST /position/getPositionList": {group: "岗位", description: "获取岗位列表"},
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,4 @@ package service
|
||||||
import "github.com/google/wire"
|
import "github.com/google/wire"
|
||||||
|
|
||||||
// ProviderSet is service providers.
|
// ProviderSet is service providers.
|
||||||
var ProviderSet = wire.NewSet(NewAuthService, NewUserService, NewSystemConfigService, NewAuthorityService, NewPermissionService, NewAccessControlService, NewAPIService, NewMenuService, NewDepartmentService, NewPositionService, NewDictionaryService, NewParameterService, NewTokenService, NewSecurityService, NewVersionService, NewExportService, NewAuditService, NewAuditRecorder, NewLogViewerService, NewTaskService, NewMediaService, NewAnnouncementService, NewEmailService, NewPaymentService)
|
var ProviderSet = wire.NewSet(NewAuthService, NewUserService, NewSystemConfigService, NewAuthorityService, NewPermissionService, NewAccessControlService, NewAPIService, NewMenuService, NewDepartmentService, NewPositionService, NewDictionaryService, NewParameterService, NewTokenService, NewSecurityService, NewVersionService, NewExportService, NewAuditService, NewAuditRecorder, NewLogViewerService, NewTaskService, NewMediaService, NewAnnouncementService, NewEmailService, NewPaymentService, NewIntegrationConfigService)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
version: v2
|
version: v2
|
||||||
inputs:
|
inputs:
|
||||||
- directory: app/system/internal
|
- directory: internal
|
||||||
plugins:
|
plugins:
|
||||||
- local: ["go", "run", "google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11"]
|
- local: ["go", "run", "google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11"]
|
||||||
out: app/system/internal
|
out: internal
|
||||||
opt: paths=source_relative
|
opt: paths=source_relative
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -391,8 +391,10 @@ Chinaums、SFT、Supper Pay、微信小游戏和抖音小游戏没有在代码
|
||||||
|
|
||||||
## HTTP 接口
|
## HTTP 接口
|
||||||
|
|
||||||
- `GET /payment/configs`
|
- `GET /integration/configs/payment`
|
||||||
- `POST /payment/config`
|
- `GET /integration/configs/payment/:provider`
|
||||||
|
- `PUT /integration/configs/payment/:provider`
|
||||||
|
- `DELETE /integration/configs/payment/:provider`
|
||||||
- `POST /payment/order`
|
- `POST /payment/order`
|
||||||
- `POST /payment/create`
|
- `POST /payment/create`
|
||||||
- `POST /payment/query`
|
- `POST /payment/query`
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import service from '@/utils/request'
|
||||||
|
|
||||||
|
export const getIntegrationConfigs = (kind) => service({
|
||||||
|
url: `/integration/configs/${encodeURIComponent(kind)}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
|
||||||
|
export const saveIntegrationConfig = (kind, provider, data) => service({
|
||||||
|
url: `/integration/configs/${encodeURIComponent(kind)}/${encodeURIComponent(provider)}`,
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
export const deleteIntegrationConfig = (kind, provider) => service({
|
||||||
|
url: `/integration/configs/${encodeURIComponent(kind)}/${encodeURIComponent(provider)}`,
|
||||||
|
method: 'delete'
|
||||||
|
})
|
||||||
|
|
@ -1,13 +1,5 @@
|
||||||
import service from '@/utils/request'
|
import service from '@/utils/request'
|
||||||
|
|
||||||
export const getPaymentConfigs = () => service({ url: '/payment/configs', method: 'get' })
|
|
||||||
|
|
||||||
export const savePaymentConfig = (data) => service({
|
|
||||||
url: '/payment/config',
|
|
||||||
method: 'post',
|
|
||||||
data
|
|
||||||
})
|
|
||||||
|
|
||||||
export const getPaymentOrders = (params) => service({
|
export const getPaymentOrders = (params) => service({
|
||||||
url: '/payment/orders',
|
url: '/payment/orders',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,87 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="payment-config">
|
<div class="integration-config-page">
|
||||||
<el-alert title="密钥字段会由服务端脱敏。编辑时保留 ****** 可继续使用原密钥;保存前请先校验 JSON。" type="warning" :closable="false" class="mb-4" />
|
<div class="page-heading">
|
||||||
<div class="kra-table-box">
|
<div><h2>支付渠道配置</h2><p>统一管理支付渠道凭证、接口地址和默认交易参数</p></div>
|
||||||
<div class="config-toolbar"><span>已配置 {{ configs.length }} 个支付渠道</span><el-button :loading="loading" icon="refresh" @click="load">刷新</el-button></div>
|
<el-button :loading="loading" :icon="Refresh" @click="load">刷新</el-button>
|
||||||
<el-table v-loading="loading" :data="configs" row-key="provider" stripe>
|
</div>
|
||||||
<el-table-column prop="provider" label="渠道" width="170" />
|
<div v-loading="loading" class="config-layout">
|
||||||
<el-table-column label="启用" width="100"><template #default="scope"><el-switch v-model="scope.row.enabled" @change="save(scope.row)" /></template></el-table-column>
|
<aside class="provider-panel">
|
||||||
<el-table-column label="配置 JSON" min-width="560"><template #default="scope"><el-input v-model="scope.row.editor" type="textarea" :rows="5" spellcheck="false" /></template></el-table-column>
|
<div class="panel-title">渠道 <span>{{ configs.length }}</span></div>
|
||||||
<el-table-column label="校验" width="100"><template #default="scope"><el-tag :type="scope.row.valid === false ? 'danger' : 'success'">{{ scope.row.valid === false ? '格式错误' : '可保存' }}</el-tag></template></el-table-column>
|
<button v-for="item in configs" :key="item.provider" type="button" class="provider-item" :class="{ active: selected?.provider === item.provider }" @click="select(item)">
|
||||||
<el-table-column label="操作" width="130"><template #default="scope"><el-button type="primary" link icon="check" @click="save(scope.row)">保存</el-button><el-button link @click="pretty(scope.row)">格式化</el-button></template></el-table-column>
|
<span class="provider-copy"><strong>{{ item.name || item.provider }}</strong><small>{{ item.provider }}</small></span>
|
||||||
</el-table>
|
<el-tag :type="item.enabled ? 'success' : 'info'" size="small">{{ item.enabled ? '启用' : '停用' }}</el-tag>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
<section v-if="selected" class="editor-panel">
|
||||||
|
<div class="editor-heading">
|
||||||
|
<div><div class="editor-title">{{ selected.name || selected.provider }}</div><div class="editor-subtitle">{{ selected.description || `provider: ${selected.provider}` }}</div></div>
|
||||||
|
<el-switch v-model="selected.enabled" active-text="启用渠道" @change="save" />
|
||||||
|
</div>
|
||||||
|
<el-alert v-if="selected.configured" title="密钥字段已脱敏,保留 ****** 表示继续使用当前密钥。" type="info" :closable="false" class="editor-alert" />
|
||||||
|
<el-alert v-else title="该渠道尚未保存,填写字段后保存即可创建配置。" type="warning" :closable="false" class="editor-alert" />
|
||||||
|
<el-form label-position="top" class="config-form">
|
||||||
|
<div class="field-grid">
|
||||||
|
<el-form-item v-for="field in selected.fields" :key="field.key" :label="field.label" :required="field.required">
|
||||||
|
<template #label><span>{{ field.label }}</span><span class="field-key">{{ field.key }}</span></template>
|
||||||
|
<el-select v-if="field.type === 'select'" v-model="selected.config[field.key]" class="field-control" filterable>
|
||||||
|
<el-option v-for="option in field.options" :key="String(option.value)" :label="option.label" :value="option.value" />
|
||||||
|
</el-select>
|
||||||
|
<el-switch v-else-if="field.type === 'switch'" v-model="selected.config[field.key]" />
|
||||||
|
<el-input v-else-if="field.type === 'textarea'" v-model="selected.config[field.key]" class="field-control" type="textarea" :rows="4" :show-password="field.secret" spellcheck="false" />
|
||||||
|
<el-input v-else v-model="selected.config[field.key]" class="field-control" :type="field.secret ? 'password' : field.type === 'number' ? 'number' : 'text'" :show-password="field.secret" spellcheck="false" />
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
<div class="editor-actions">
|
||||||
|
<el-button type="primary" :loading="saving" :icon="Check" @click="save">保存配置</el-button>
|
||||||
|
<el-button v-if="selected.configured" type="danger" plain :icon="Delete" @click="remove">删除配置</el-button>
|
||||||
|
<el-button text :icon="DocumentCopy" @click="copyConfig">复制 JSON</el-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<el-empty v-else description="暂无支付渠道" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { getPaymentConfigs, savePaymentConfig } from '@/api/payment'
|
import { Check, Delete, DocumentCopy, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { deleteIntegrationConfig, getIntegrationConfigs, saveIntegrationConfig } from '@/api/integration'
|
||||||
|
|
||||||
const configs = ref([]); const loading = ref(false)
|
const configs = ref([]); const selectedProvider = ref(''); const loading = ref(false); const saving = ref(false)
|
||||||
const parseConfig = (row, notify = true) => { try { JSON.parse(row.editor || '{}'); row.valid = true; return true } catch { row.valid = false; if (notify) ElMessage.error(`${row.provider} 配置必须是合法 JSON`); return false } }
|
const selected = computed(() => configs.value.find((item) => item.provider === selectedProvider.value) || configs.value[0])
|
||||||
const load = async () => { loading.value = true; try { const res = await getPaymentConfigs(); if (res.code === 0) configs.value = (res.data || []).map((item) => ({ ...item, editor: JSON.stringify(item.config || {}, null, 2), valid: true })) } finally { loading.value = false } }
|
const select = (item) => { selectedProvider.value = item.provider }
|
||||||
const pretty = (row) => { try { row.editor = JSON.stringify(JSON.parse(row.editor || '{}'), null, 2); row.valid = true } catch { parseConfig(row) } }
|
const load = async () => { loading.value = true; try { const res = await getIntegrationConfigs('payment'); if (res.code === 0) { configs.value = (res.data || []).map((item) => ({ ...item, config: { ...(item.config || {}) } })); if (!configs.value.some((item) => item.provider === selectedProvider.value)) selectedProvider.value = configs.value[0]?.provider || '' } } finally { loading.value = false } }
|
||||||
const save = async (row) => { if (!parseConfig(row)) return; const config = JSON.parse(row.editor || '{}'); const res = await savePaymentConfig({ provider: row.provider, enabled: row.enabled, config }); if (res.code === 0) { ElMessage.success(`${row.provider} 配置保存成功`); await load() } }
|
const save = async () => { if (!selected.value) return; saving.value = true; try { const res = await saveIntegrationConfig('payment', selected.value.provider, { enabled: selected.value.enabled, config: selected.value.config }); if (res.code === 0) { ElMessage.success('支付渠道配置已保存'); await load() } } finally { saving.value = false } }
|
||||||
|
const remove = async () => { if (!selected.value) return; await ElMessageBox.confirm(`确认删除 ${selected.value.name || selected.value.provider} 配置吗?`, '删除配置', { type: 'warning' }); const res = await deleteIntegrationConfig('payment', selected.value.provider); if (res.code === 0) { ElMessage.success('配置已删除'); await load() } }
|
||||||
|
const copyConfig = async () => { if (!selected.value) return; await navigator.clipboard.writeText(JSON.stringify(selected.value.config || {}, null, 2)); ElMessage.success('JSON 已复制') }
|
||||||
load()
|
load()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.config-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; color: var(--el-text-color-secondary); }
|
.integration-config-page { padding: 4px 0 24px; }
|
||||||
.payment-config { min-width: 900px; }
|
.page-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
|
||||||
|
.page-heading h2 { margin: 0; color: var(--el-text-color-primary); font-size: 20px; font-weight: 600; }
|
||||||
|
.page-heading p { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||||
|
.config-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); min-height: 620px; border: 1px solid var(--el-border-color-lighter); background: var(--el-bg-color); }
|
||||||
|
.provider-panel { border-right: 1px solid var(--el-border-color-lighter); padding: 14px 10px; }
|
||||||
|
.panel-title { display: flex; justify-content: space-between; padding: 2px 10px 12px; color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; }
|
||||||
|
.panel-title span { color: var(--el-text-color-secondary); font-weight: 400; }
|
||||||
|
.provider-item { display: flex; align-items: center; justify-content: space-between; width: 100%; min-height: 54px; padding: 9px 10px; border: 0; border-left: 3px solid transparent; background: transparent; color: inherit; text-align: left; cursor: pointer; }
|
||||||
|
.provider-item:hover { background: var(--el-fill-color-light); }
|
||||||
|
.provider-item.active { border-left-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
||||||
|
.provider-copy { display: grid; gap: 3px; min-width: 0; }
|
||||||
|
.provider-copy strong { overflow: hidden; color: var(--el-text-color-primary); font-size: 14px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.provider-copy small { color: var(--el-text-color-secondary); font-size: 11px; }
|
||||||
|
.editor-panel { min-width: 0; padding: 22px 28px 24px; }
|
||||||
|
.editor-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding-bottom: 18px; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||||
|
.editor-title { color: var(--el-text-color-primary); font-size: 18px; font-weight: 600; }
|
||||||
|
.editor-subtitle { margin-top: 5px; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||||
|
.editor-alert { margin: 18px 0; }
|
||||||
|
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 22px; }
|
||||||
|
.field-key { margin-left: 8px; color: var(--el-text-color-placeholder); font-size: 11px; font-weight: 400; }
|
||||||
|
.field-control { width: 100%; }
|
||||||
|
.editor-actions { display: flex; align-items: center; gap: 10px; padding-top: 8px; border-top: 1px solid var(--el-border-color-lighter); }
|
||||||
|
@media (max-width: 900px) { .config-layout { grid-template-columns: 1fr; } .provider-panel { border-right: 0; border-bottom: 1px solid var(--el-border-color-lighter); max-height: 260px; overflow-y: auto; } .field-grid { grid-template-columns: 1fr; } .editor-panel { padding: 18px; } }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue