优化结构
This commit is contained in:
@@ -5,7 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
paymentutil "kra/pkg/paymentkit"
|
||||
bizpayment "kra/internal/biz/payment"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -316,7 +316,7 @@ func validatePaymentIntegrationConfig(provider string, values map[string]any) er
|
||||
}
|
||||
}
|
||||
switch provider {
|
||||
case paymentutil.ProviderAlipayV3:
|
||||
case bizpayment.PaymentAlipayV3:
|
||||
for _, group := range []struct {
|
||||
label string
|
||||
keys []string
|
||||
@@ -330,21 +330,21 @@ func validatePaymentIntegrationConfig(provider string, values map[string]any) er
|
||||
return fmt.Errorf("%s 缺少配置字段 %s", provider, group.label)
|
||||
}
|
||||
}
|
||||
case paymentutil.ProviderWechatV2:
|
||||
case bizpayment.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 paymentutil.ProviderApple:
|
||||
case bizpayment.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 paymentutil.ProviderDouyin:
|
||||
case bizpayment.PaymentDouyin:
|
||||
if integrationFirst(values, "platform_serial_no", "platform_cert_serial") == "" {
|
||||
return fmt.Errorf("%s 缺少配置字段 platform_serial_no", provider)
|
||||
}
|
||||
case paymentutil.ProviderQQ:
|
||||
case bizpayment.PaymentQQ:
|
||||
if integrationFirst(values, "mch_id", "merchant_id") == "" {
|
||||
return fmt.Errorf("%s 缺少配置字段 mch_id", provider)
|
||||
}
|
||||
@@ -355,12 +355,12 @@ func validatePaymentIntegrationConfig(provider string, values map[string]any) er
|
||||
if signType != "" && signType != "MD5" && signType != "HMAC-SHA256" {
|
||||
return fmt.Errorf("%s sign_type 必须是 MD5 或 HMAC-SHA256", provider)
|
||||
}
|
||||
case paymentutil.ProviderAllinPay:
|
||||
case bizpayment.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 paymentutil.ProviderChinaums, paymentutil.ProviderSFT, paymentutil.ProviderSuperPay, paymentutil.ProviderWechatGame, paymentutil.ProviderDouyinGame:
|
||||
case bizpayment.PaymentChinaums, bizpayment.PaymentSFT, bizpayment.PaymentSuperPay, bizpayment.PaymentWechatGame, bizpayment.PaymentDouyinGame:
|
||||
if integrationFirst(values, "app_key", "merchant_key", "signing_secret", "token") == "" {
|
||||
return fmt.Errorf("%s 缺少签名密钥", provider)
|
||||
}
|
||||
@@ -369,7 +369,7 @@ func validatePaymentIntegrationConfig(provider string, values map[string]any) er
|
||||
}
|
||||
}
|
||||
if environment := strings.ToLower(integrationText(values, "environment")); environment != "" {
|
||||
allowedSandbox := provider != paymentutil.ProviderQQ && provider != paymentutil.ProviderDouyin && provider != paymentutil.ProviderLakala
|
||||
allowedSandbox := provider != bizpayment.PaymentQQ && provider != bizpayment.PaymentDouyin && provider != bizpayment.PaymentLakala
|
||||
if environment != "production" && environment != "prod" && (!allowedSandbox || environment != "sandbox") {
|
||||
return fmt.Errorf("%s environment 配置无效", provider)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package integration
|
||||
|
||||
import paymentutil "kra/pkg/paymentkit"
|
||||
import bizpayment "kra/internal/biz/payment"
|
||||
|
||||
func integrationField(key, label string, required, secret bool, fieldType string) IntegrationConfigField {
|
||||
if fieldType == "" {
|
||||
@@ -150,35 +150,35 @@ var integrationDefinitions = map[string][]IntegrationConfigDefinition{
|
||||
},
|
||||
},
|
||||
IntegrationKindPayment: {
|
||||
paymentDefinition(paymentutil.ProviderAlipay, "支付宝", "支付宝 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"},
|
||||
paymentDefinition(bizpayment.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(paymentutil.ProviderAlipayV3, "支付宝 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"},
|
||||
paymentDefinition(bizpayment.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(paymentutil.ProviderWechatV2, "微信支付 V2", "微信支付 V2,含退款双向证书", map[string]any{"app_id": "", "merchant_id": "", "mch_key": "", "sign_type": "MD5", "trade_type": "NATIVE", "client_cert": "", "client_key": ""},
|
||||
paymentDefinition(bizpayment.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(paymentutil.ProviderWechatV3, "微信支付 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"},
|
||||
paymentDefinition(bizpayment.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(paymentutil.ProviderApple, "Apple IAP", "Apple App Store Server API", map[string]any{"issuer_id": "", "key_id": "", "bundle_id": "", "private_key": "", "price_divisor": 10, "environment": "production", "test_product_id": "", "test_transaction_id": ""},
|
||||
paymentDefinition(bizpayment.PaymentApple, "Apple IAP", "Apple App Store Server API", map[string]any{"issuer_id": "", "key_id": "", "bundle_id": "", "private_key": "", "price_divisor": 10, "environment": "production", "test_product_id": "", "test_transaction_id": ""},
|
||||
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"), integrationField("test_product_id", "测试商品 ID", false, false, "text"), integrationField("test_transaction_id", "沙箱交易 ID", false, false, "text")),
|
||||
paymentDefinition(paymentutil.ProviderDouyin, "抖音支付", "抖音开放平台支付", map[string]any{"app_id": "", "merchant_id": "", "serial_no": "", "api_key": "", "private_key": "", "platform_cert": "", "platform_serial_no": "", "trade_type": "jsapi", "environment": "production"},
|
||||
paymentDefinition(bizpayment.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(paymentutil.ProviderQQ, "QQ 钱包", "QQ 钱包支付", map[string]any{"mch_id": "", "api_key": "", "sign_type": "MD5", "trade_type": "NATIVE", "cert_file": "", "key_file": "", "environment": "production"},
|
||||
paymentDefinition(bizpayment.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(paymentutil.ProviderAllinPay, "通联支付", "通联收银宝支付", map[string]any{"cus_id": "", "app_id": "", "private_key": "", "public_key": "", "org_id": "", "pay_type": "W02", "query_order_type": "reqsn", "currency": "CNY", "environment": "production"},
|
||||
paymentDefinition(bizpayment.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(paymentutil.ProviderLakala, "拉卡拉", "拉卡拉聚合支付", map[string]any{"partner_code": "", "credential_code": "", "channel": "Wechat", "method": "jsapi", "currency": "CNY", "environment": "production"},
|
||||
paymentDefinition(bizpayment.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(paymentutil.ProviderPayPal, "PayPal", "PayPal Checkout", map[string]any{"client_id": "", "client_secret": "", "webhook_id": "", "environment": "sandbox", "return_url": "", "cancel_url": "", "auto_capture": true, "test_currency": "USD"},
|
||||
paymentDefinition(bizpayment.PaymentPayPal, "PayPal", "PayPal Checkout", map[string]any{"client_id": "", "client_secret": "", "webhook_id": "", "environment": "sandbox", "return_url": "", "cancel_url": "", "auto_capture": true, "test_currency": "USD"},
|
||||
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(paymentutil.ProviderSaobei, "扫呗", "扫呗聚合支付", map[string]any{"inst_no": "", "key": "", "merchant_no": "", "terminal_id": "", "access_token": "", "pay_type": "010", "currency": "CNY", "environment": "production"},
|
||||
paymentDefinition(bizpayment.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(paymentutil.ProviderChinaums, "银联商务", "按商户协议配置的银联商务适配器"),
|
||||
genericPaymentDefinition(paymentutil.ProviderSFT, "商福通", "按商户协议配置的商福通适配器"),
|
||||
genericPaymentDefinition(paymentutil.ProviderSuperPay, "Supper Pay", "按商户协议配置的 Supper Pay 适配器"),
|
||||
genericPaymentDefinition(paymentutil.ProviderWechatGame, "微信小游戏支付", "微信小游戏虚拟支付配置驱动适配器"),
|
||||
genericPaymentDefinition(paymentutil.ProviderDouyinGame, "抖音小游戏支付", "抖音小游戏支付配置驱动适配器"),
|
||||
genericPaymentDefinition(bizpayment.PaymentChinaums, "银联商务", "按商户协议配置的银联商务适配器"),
|
||||
genericPaymentDefinition(bizpayment.PaymentSFT, "商福通", "按商户协议配置的商福通适配器"),
|
||||
genericPaymentDefinition(bizpayment.PaymentSuperPay, "Supper Pay", "按商户协议配置的 Supper Pay 适配器"),
|
||||
genericPaymentDefinition(bizpayment.PaymentWechatGame, "微信小游戏支付", "微信小游戏虚拟支付配置驱动适配器"),
|
||||
genericPaymentDefinition(bizpayment.PaymentDouyinGame, "抖音小游戏支付", "抖音小游戏支付配置驱动适配器"),
|
||||
},
|
||||
}
|
||||
|
||||
+130
-44
@@ -12,37 +12,39 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"kra/pkg/paymentkit"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
PaymentAlipay = paymentutil.ProviderAlipay
|
||||
PaymentAlipayV3 = paymentutil.ProviderAlipayV3
|
||||
PaymentWechatV2 = paymentutil.ProviderWechatV2
|
||||
PaymentWechatV3 = paymentutil.ProviderWechatV3
|
||||
PaymentApple = paymentutil.ProviderApple
|
||||
PaymentChinaums = paymentutil.ProviderChinaums
|
||||
PaymentSFT = paymentutil.ProviderSFT
|
||||
PaymentSuperPay = paymentutil.ProviderSuperPay
|
||||
PaymentWechatGame = paymentutil.ProviderWechatGame
|
||||
PaymentDouyinGame = paymentutil.ProviderDouyinGame
|
||||
PaymentAlipay = "alipay"
|
||||
PaymentAlipayV3 = "alipay-v3"
|
||||
PaymentWechatV2 = "wechat-v2"
|
||||
PaymentWechatV3 = "wechat-v3"
|
||||
PaymentApple = "apple-iap"
|
||||
PaymentChinaums = "chinaums"
|
||||
PaymentSFT = "sft"
|
||||
PaymentSuperPay = "supper-pay"
|
||||
PaymentWechatGame = "wechat-game-pay"
|
||||
PaymentDouyinGame = "douyin-game-pay"
|
||||
// GoPay-backed providers. These are kept separate from the existing
|
||||
// configuration-driven game/aggregator protocols above.
|
||||
PaymentDouyin = paymentutil.ProviderDouyin
|
||||
PaymentQQ = paymentutil.ProviderQQ
|
||||
PaymentAllinPay = paymentutil.ProviderAllinPay
|
||||
PaymentLakala = paymentutil.ProviderLakala
|
||||
PaymentPayPal = paymentutil.ProviderPayPal
|
||||
PaymentSaobei = paymentutil.ProviderSaobei
|
||||
PaymentInternal = paymentutil.ProviderInternal
|
||||
PaymentDouyin = "douyin"
|
||||
PaymentQQ = "qq"
|
||||
PaymentAllinPay = "allinpay"
|
||||
PaymentLakala = "lakala"
|
||||
PaymentPayPal = "paypal"
|
||||
PaymentSaobei = "saobei"
|
||||
PaymentInternal = "internal"
|
||||
|
||||
PaymentModeExternal = paymentutil.ModeExternal
|
||||
PaymentModeInternal = paymentutil.ModeInternal
|
||||
PaymentModeExternal = "external"
|
||||
PaymentModeInternal = "internal"
|
||||
)
|
||||
|
||||
var SupportedPaymentProviders = paymentutil.SupportedProviders()
|
||||
var SupportedPaymentProviders = []string{
|
||||
PaymentAlipay, PaymentAlipayV3, PaymentWechatV2, PaymentWechatV3, PaymentApple,
|
||||
PaymentChinaums, PaymentSFT, PaymentSuperPay, PaymentWechatGame, PaymentDouyinGame,
|
||||
PaymentDouyin, PaymentQQ, PaymentAllinPay, PaymentLakala, PaymentPayPal, PaymentSaobei,
|
||||
}
|
||||
|
||||
var (
|
||||
ErrPaymentProviderNotFound = errors.New("支付渠道未配置")
|
||||
@@ -203,15 +205,74 @@ type PaymentTestResult struct {
|
||||
Result *PaymentResult `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
type PaymentCallbackAck = paymentutil.CallbackAck
|
||||
type PaymentCallbackError = paymentutil.CallbackError
|
||||
type PaymentCallbackAck struct {
|
||||
StatusCode int
|
||||
ContentType string
|
||||
Body []byte
|
||||
}
|
||||
|
||||
type PaymentCallbackError struct {
|
||||
Cause error
|
||||
Ack PaymentCallbackAck
|
||||
}
|
||||
|
||||
func (e *PaymentCallbackError) Error() string {
|
||||
if e == nil || e.Cause == nil {
|
||||
return "支付回调处理失败"
|
||||
}
|
||||
return e.Cause.Error()
|
||||
}
|
||||
|
||||
func (e *PaymentCallbackError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
func CallbackFailure(err error, fallback PaymentCallbackAck) PaymentCallbackAck {
|
||||
return paymentutil.CallbackFailure(err, fallback)
|
||||
var callbackErr *PaymentCallbackError
|
||||
if errors.As(err, &callbackErr) && callbackErr.Ack.StatusCode != 0 {
|
||||
return callbackErr.Ack
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func DefaultPaymentCallbackAck(provider string, success bool) PaymentCallbackAck {
|
||||
return paymentutil.DefaultCallbackAck(provider, success)
|
||||
status := 200
|
||||
if !success {
|
||||
status = 500
|
||||
}
|
||||
switch provider {
|
||||
case PaymentAlipay, PaymentAlipayV3:
|
||||
body := "success"
|
||||
if !success {
|
||||
body = "failure"
|
||||
}
|
||||
return PaymentCallbackAck{StatusCode: status, ContentType: "text/plain; charset=utf-8", Body: []byte(body)}
|
||||
case PaymentWechatV2:
|
||||
code, message := "SUCCESS", "OK"
|
||||
if !success {
|
||||
code, message = "FAIL", "FAIL"
|
||||
}
|
||||
body := fmt.Sprintf("<xml><return_code><![CDATA[%s]]></return_code><return_msg><![CDATA[%s]]></return_msg></xml>", code, message)
|
||||
return PaymentCallbackAck{StatusCode: status, ContentType: "application/xml; charset=utf-8", Body: []byte(body)}
|
||||
case PaymentWechatV3:
|
||||
code, message := "SUCCESS", "成功"
|
||||
if !success {
|
||||
code, message = "FAIL", "失败"
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"code": code, "message": message})
|
||||
return PaymentCallbackAck{StatusCode: status, ContentType: "application/json; charset=utf-8", Body: body}
|
||||
case PaymentApple:
|
||||
return PaymentCallbackAck{StatusCode: status}
|
||||
default:
|
||||
body := "success"
|
||||
if !success {
|
||||
body = "failure"
|
||||
}
|
||||
return PaymentCallbackAck{StatusCode: status, ContentType: "text/plain; charset=utf-8", Body: []byte(body)}
|
||||
}
|
||||
}
|
||||
|
||||
type PaymentCallback struct {
|
||||
@@ -228,6 +289,19 @@ type PaymentRepo interface {
|
||||
HandleCallback(context.Context, *PaymentCallback) (*PaymentResult, error)
|
||||
}
|
||||
|
||||
// PaymentAdapter is the integration boundary for provider protocols. SDK
|
||||
// clients and wire formats stay outside the business and data packages.
|
||||
type PaymentAdapter interface {
|
||||
Create(context.Context, *PaymentRequest, map[string]any) (*PaymentResult, error)
|
||||
Query(context.Context, string, map[string]any) (*PaymentResult, error)
|
||||
Refund(context.Context, *PaymentRefundRequest, map[string]any) (*PaymentResult, error)
|
||||
Callback(context.Context, *PaymentCallback, map[string]any) (*PaymentResult, error)
|
||||
}
|
||||
|
||||
type PaymentAdapterFactory interface {
|
||||
New(string) (PaymentAdapter, error)
|
||||
}
|
||||
|
||||
type PaymentProviderTester interface {
|
||||
TestProvider(context.Context, string) (*PaymentTestResult, error)
|
||||
}
|
||||
@@ -240,6 +314,8 @@ type NoopPaymentHooks struct{}
|
||||
|
||||
func (NoopPaymentHooks) BeforeCreate(context.Context, *PaymentRequest) error { return nil }
|
||||
|
||||
func NewPaymentHooks() PaymentHooks { return NoopPaymentHooks{} }
|
||||
|
||||
type PaymentFulfillmentHandler interface {
|
||||
Type() string
|
||||
Fulfill(context.Context, *PaymentConfirmation) error
|
||||
@@ -273,6 +349,15 @@ func (r *PaymentFulfillmentRegistry) Handler(kind string) PaymentFulfillmentHand
|
||||
return r.handlers[kind]
|
||||
}
|
||||
|
||||
func (r *PaymentFulfillmentRegistry) Len() int {
|
||||
if r == nil {
|
||||
return 0
|
||||
}
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return len(r.handlers)
|
||||
}
|
||||
|
||||
type PaymentUsecase struct {
|
||||
repo PaymentRepo
|
||||
orders PaymentOrderRepo
|
||||
@@ -282,33 +367,34 @@ type PaymentUsecase struct {
|
||||
logger PaymentLogger
|
||||
}
|
||||
|
||||
func NewPaymentUsecase(repo PaymentRepo, orders PaymentOrderRepo, appLogger *slog.Logger) *PaymentUsecase {
|
||||
func NewPaymentUsecase(repo PaymentRepo, orders PaymentOrderRepo, hooks PaymentHooks, sources *PaymentOrderSourceRegistry, fulfillments *PaymentFulfillmentRegistry, appLogger *slog.Logger) *PaymentUsecase {
|
||||
if hooks == nil {
|
||||
hooks = NoopPaymentHooks{}
|
||||
}
|
||||
if sources == nil {
|
||||
sources = NewPaymentOrderSourceRegistry()
|
||||
}
|
||||
if fulfillments == nil {
|
||||
fulfillments = NewPaymentFulfillmentRegistry()
|
||||
}
|
||||
return &PaymentUsecase{
|
||||
repo: repo,
|
||||
orders: orders,
|
||||
hooks: NoopPaymentHooks{},
|
||||
sources: NewPaymentOrderSourceRegistry(),
|
||||
fulfillments: NewPaymentFulfillmentRegistry(),
|
||||
hooks: hooks,
|
||||
sources: sources,
|
||||
fulfillments: fulfillments,
|
||||
logger: NewPaymentLogger(appLogger),
|
||||
}
|
||||
}
|
||||
|
||||
func (uc *PaymentUsecase) SetHooks(hooks PaymentHooks) {
|
||||
if hooks != nil {
|
||||
uc.hooks = hooks
|
||||
func NewConfiguredPaymentUsecase(repo PaymentRepo, orders PaymentOrderRepo, hooks PaymentHooks, sources *PaymentOrderSourceRegistry, fulfillments *PaymentFulfillmentRegistry, appLogger *slog.Logger) (*PaymentUsecase, error) {
|
||||
if sources == nil || sources.Len() == 0 {
|
||||
return nil, errors.New("支付业务订单来源未注册,无法启动支付服务")
|
||||
}
|
||||
}
|
||||
|
||||
func (uc *PaymentUsecase) SetOrderSourceRegistry(registry *PaymentOrderSourceRegistry) {
|
||||
if registry != nil {
|
||||
uc.sources = registry
|
||||
}
|
||||
}
|
||||
|
||||
func (uc *PaymentUsecase) SetFulfillmentRegistry(registry *PaymentFulfillmentRegistry) {
|
||||
if registry != nil {
|
||||
uc.fulfillments = registry
|
||||
if fulfillments == nil || fulfillments.Len() == 0 {
|
||||
return nil, errors.New("支付发货 handler 未注册,无法启动支付服务")
|
||||
}
|
||||
return NewPaymentUsecase(repo, orders, hooks, sources, fulfillments, appLogger), nil
|
||||
}
|
||||
|
||||
func (uc *PaymentUsecase) RegisterBusinessModule(module PaymentBusinessModule) error {
|
||||
|
||||
@@ -182,6 +182,15 @@ func (r *PaymentOrderSourceRegistry) Source(kind string) PaymentOrderSource {
|
||||
return r.sources[kind]
|
||||
}
|
||||
|
||||
func (r *PaymentOrderSourceRegistry) Len() int {
|
||||
if r == nil {
|
||||
return 0
|
||||
}
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return len(r.sources)
|
||||
}
|
||||
|
||||
func (r *PaymentOrderSourceRegistry) Unregister(kind string) {
|
||||
if r == nil {
|
||||
return
|
||||
|
||||
@@ -135,7 +135,12 @@ func (h *paymentFulfillmentStub) Fulfill(_ context.Context, confirmation *Paymen
|
||||
|
||||
func newPaymentUsecaseForTest(repo PaymentRepo) *PaymentUsecase {
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return NewPaymentUsecase(repo, &paymentOrderRepoStub{order: testBizPaymentOrder()}, logger)
|
||||
return NewPaymentUsecase(repo, &paymentOrderRepoStub{order: testBizPaymentOrder()}, nil, nil, nil, logger)
|
||||
}
|
||||
|
||||
func newPaymentUsecaseWithFulfillmentRegistry(repo PaymentRepo, registry *PaymentFulfillmentRegistry) *PaymentUsecase {
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return NewPaymentUsecase(repo, &paymentOrderRepoStub{order: testBizPaymentOrder()}, nil, nil, registry, logger)
|
||||
}
|
||||
|
||||
func testBizPaymentOrder() *PaymentOrder {
|
||||
@@ -186,7 +191,7 @@ func TestPaymentCallbackPendingDoesNotFulfill(t *testing.T) {
|
||||
if err := registry.Register(handler); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uc.SetFulfillmentRegistry(registry)
|
||||
uc = newPaymentUsecaseWithFulfillmentRegistry(repo, registry)
|
||||
result, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -200,10 +205,10 @@ func TestPaymentCallbackRejectsAmountMismatch(t *testing.T) {
|
||||
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
|
||||
handler := &paymentFulfillmentStub{}
|
||||
uc := newPaymentUsecaseForTest(repo)
|
||||
uc.orders.(*paymentOrderRepoStub).order.Amount = 101
|
||||
registry := NewPaymentFulfillmentRegistry()
|
||||
_ = registry.Register(handler)
|
||||
uc.SetFulfillmentRegistry(registry)
|
||||
uc = newPaymentUsecaseWithFulfillmentRegistry(repo, registry)
|
||||
uc.orders.(*paymentOrderRepoStub).order.Amount = 101
|
||||
if _, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}); err == nil {
|
||||
t.Fatal("amount mismatch was accepted")
|
||||
}
|
||||
@@ -226,7 +231,7 @@ func TestPaymentCallbackDuplicateDoesNotFulfillTwice(t *testing.T) {
|
||||
uc := newPaymentUsecaseForTest(repo)
|
||||
registry := NewPaymentFulfillmentRegistry()
|
||||
_ = registry.Register(handler)
|
||||
uc.SetFulfillmentRegistry(registry)
|
||||
uc = newPaymentUsecaseWithFulfillmentRegistry(repo, registry)
|
||||
callback := &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}
|
||||
if _, err := uc.Callback(context.Background(), callback); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -246,7 +251,7 @@ func TestPaymentCallbackFulfillmentFailureCanRetry(t *testing.T) {
|
||||
uc := newPaymentUsecaseForTest(repo)
|
||||
registry := NewPaymentFulfillmentRegistry()
|
||||
_ = registry.Register(handler)
|
||||
uc.SetFulfillmentRegistry(registry)
|
||||
uc = newPaymentUsecaseWithFulfillmentRegistry(repo, registry)
|
||||
callback := &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}
|
||||
if _, err := uc.Callback(context.Background(), callback); err == nil {
|
||||
t.Fatal("first fulfillment failure was accepted")
|
||||
@@ -290,12 +295,11 @@ func TestPaymentCallbackErrorCarriesSafeAck(t *testing.T) {
|
||||
func TestPersistentPaymentCreateUsesBusinessCanonicalAmount(t *testing.T) {
|
||||
repo := &paymentRepoStub{create: &PaymentResult{Provider: PaymentAlipay, Status: "created", TradeNo: "order-1", Payload: []byte(`{"prepay":"x"}`)}}
|
||||
orders := &paymentOrderRepoStub{}
|
||||
uc := NewPaymentUsecase(repo, orders, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
sources := NewPaymentOrderSourceRegistry()
|
||||
if err := sources.Register(&paymentSourceStub{intent: &PaymentIntent{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "canonical item", Amount: 100, Currency: "CNY", Extra: []byte(`{"product_id":"canonical"}`)}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uc.SetOrderSourceRegistry(sources)
|
||||
uc := NewPaymentUsecase(repo, orders, nil, sources, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
result, err := uc.Create(context.Background(), &PaymentRequest{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "forged", Amount: 1, Currency: "USD", Extra: map[string]any{"product_id": "forged"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -309,12 +313,11 @@ func TestPersistentPaymentCallbackFulfillsAndPersistsDuplicate(t *testing.T) {
|
||||
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
|
||||
orders := &paymentOrderRepoStub{order: &PaymentOrder{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "item", Amount: 100, Currency: "CNY", PaymentStatus: PaymentStatusPending, FulfillmentStatus: FulfillmentStatusPending, RefundStatus: RefundStatusNone, ConfirmationID: "11111111-1111-1111-1111-111111111111"}}
|
||||
handler := &paymentFulfillmentStub{}
|
||||
uc := NewPaymentUsecase(repo, orders, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
registry := NewPaymentFulfillmentRegistry()
|
||||
if err := registry.Register(handler); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uc.SetFulfillmentRegistry(registry)
|
||||
uc := NewPaymentUsecase(repo, orders, nil, nil, registry, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
first, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -330,13 +333,12 @@ func TestPersistentPaymentCallbackFulfillsAndPersistsDuplicate(t *testing.T) {
|
||||
|
||||
func TestPaymentFulfillRetriesFailedOrder(t *testing.T) {
|
||||
orders := &paymentOrderRepoStub{order: &PaymentOrder{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "item", Amount: 100, Currency: "CNY", PaymentStatus: PaymentStatusPaid, FulfillmentStatus: FulfillmentStatusFailed, RefundStatus: RefundStatusNone, ConfirmationID: "11111111-1111-1111-1111-111111111111"}}
|
||||
uc := NewPaymentUsecase(&paymentRepoStub{}, orders, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
handler := &paymentFulfillmentStub{}
|
||||
registry := NewPaymentFulfillmentRegistry()
|
||||
if err := registry.Register(handler); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uc.SetFulfillmentRegistry(registry)
|
||||
uc := NewPaymentUsecase(&paymentRepoStub{}, orders, nil, nil, registry, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
result, err := uc.Fulfill(context.Background(), PaymentAlipay, "order-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -5,4 +5,9 @@ import "github.com/google/wire"
|
||||
// ProviderSet wires the payment domain usecase independently from other
|
||||
// business domains. Persistence and transport layers consume the payment
|
||||
// interfaces without depending on the system package.
|
||||
var ProviderSet = wire.NewSet(NewPaymentUsecase)
|
||||
var ProviderSet = wire.NewSet(
|
||||
NewPaymentHooks,
|
||||
NewPaymentOrderSourceRegistry,
|
||||
NewPaymentFulfillmentRegistry,
|
||||
NewConfiguredPaymentUsecase,
|
||||
)
|
||||
|
||||
@@ -23,7 +23,6 @@ type APITokenRepo interface {
|
||||
UserHasAuthority(context.Context, uint, uint) (*User, bool, error)
|
||||
CreateAPIToken(context.Context, *APIToken) error
|
||||
ListAPITokens(context.Context, int, int, uint, *bool) ([]*APIToken, int64, error)
|
||||
DisableAPIToken(context.Context, uint) (string, error)
|
||||
DisableAndBlacklistAPIToken(context.Context, uint) error
|
||||
BlacklistToken(context.Context, string) error
|
||||
IsTokenDisabled(context.Context, string) (bool, error)
|
||||
|
||||
@@ -40,8 +40,11 @@ type LoginLog struct {
|
||||
}
|
||||
|
||||
type LoginLogFilter struct {
|
||||
Username string
|
||||
Status *bool
|
||||
Username string
|
||||
IP string
|
||||
Status *bool
|
||||
StartCreatedAt *time.Time
|
||||
EndCreatedAt *time.Time
|
||||
}
|
||||
|
||||
type DataAccessLog struct {
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
type EmailRepo interface {
|
||||
Send(context.Context, []string, string, string) error
|
||||
DefaultRecipients() []string
|
||||
Enabled() bool
|
||||
}
|
||||
|
||||
type EmailUsecase struct{ repo EmailRepo }
|
||||
@@ -27,22 +26,3 @@ func (uc *EmailUsecase) Test(ctx context.Context) error {
|
||||
}
|
||||
return uc.repo.Send(ctx, recipients, "test", "test")
|
||||
}
|
||||
|
||||
func (uc *EmailUsecase) Alert(ctx context.Context, subject, body string) error {
|
||||
if !uc.repo.Enabled() {
|
||||
return nil
|
||||
}
|
||||
// ErrorToEmail splits the configured comma-separated recipient
|
||||
// string, while its explicit test endpoint deliberately sends the whole
|
||||
// value as one recipient. Keep those two contracts separate.
|
||||
configured := uc.repo.DefaultRecipients()
|
||||
recipients := make([]string, 0, len(configured))
|
||||
for _, value := range configured {
|
||||
parts := strings.Split(value, ",")
|
||||
if len(parts) > 1 && parts[len(parts)-1] == "" {
|
||||
parts = parts[:len(parts)-1]
|
||||
}
|
||||
recipients = append(recipients, parts...)
|
||||
}
|
||||
return uc.repo.Send(ctx, recipients, subject, body)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@ type AttachmentCategory struct {
|
||||
type MediaMetadataRepo interface {
|
||||
CreateMedia(context.Context, *MediaFile) error
|
||||
FindMedia(context.Context, uint) (*MediaFile, error)
|
||||
FindMediaByHash(context.Context, uint, string) (*MediaFile, error)
|
||||
ListMedia(context.Context, MediaFilter) ([]*MediaFile, int64, error)
|
||||
UpdateMediaName(context.Context, uint, string) error
|
||||
DeleteMedia(context.Context, uint) error
|
||||
|
||||
@@ -58,7 +58,6 @@ type MenuRepo interface {
|
||||
Find(context.Context, uint) (*Menu, error)
|
||||
List(context.Context) ([]*Menu, error)
|
||||
SetAuthorityMenus(context.Context, uint, []uint) error
|
||||
AuthorityMenuIDs(context.Context, uint) ([]uint, error)
|
||||
// ListAuthorityMenus returns the base menus assigned to the target
|
||||
// authority. Unlike List, it must not apply the current actor's strict
|
||||
// menu filter: the administration endpoint is explicitly querying another
|
||||
@@ -73,10 +72,6 @@ type MenuUsecase struct{ repo MenuRepo }
|
||||
|
||||
func NewMenuUsecase(repo MenuRepo) *MenuUsecase { return &MenuUsecase{repo: repo} }
|
||||
|
||||
func (uc *MenuUsecase) List(ctx context.Context) ([]*Menu, error) {
|
||||
return uc.repo.List(ctx)
|
||||
}
|
||||
|
||||
func (uc *MenuUsecase) Tree(ctx context.Context) ([]*Menu, error) {
|
||||
items, err := uc.repo.List(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,9 +3,6 @@ package system
|
||||
import "context"
|
||||
|
||||
type PermissionRepo interface {
|
||||
Buttons(context.Context, uint) ([]*MenuButton, error)
|
||||
SetAuthorityButtons(context.Context, uint, map[uint][]uint) error
|
||||
AuthorityButtonIDs(context.Context, uint) ([]uint, error)
|
||||
SelectedButtons(context.Context, uint, uint) ([]uint, error)
|
||||
SetSelectedButtons(context.Context, uint, uint, []uint) error
|
||||
CanRemoveButton(context.Context, string) (bool, error)
|
||||
|
||||
@@ -233,14 +233,6 @@ func (uc *SecurityUsecase) ActiveToken(ctx context.Context, username string) (st
|
||||
return value, err
|
||||
}
|
||||
|
||||
func (uc *SecurityUsecase) ActiveTokenMatches(ctx context.Context, username, token string) (bool, error) {
|
||||
if !uc.settings.UseMultipoint() {
|
||||
return true, nil
|
||||
}
|
||||
active, ok, err := uc.cache.Get(ctx, activeTokenKey(username))
|
||||
return ok && active == token, err
|
||||
}
|
||||
|
||||
func (uc *SecurityUsecase) RotateActiveToken(ctx context.Context, username, oldToken, newToken string, expiration time.Duration) error {
|
||||
if !uc.settings.UseMultipoint() {
|
||||
return nil
|
||||
|
||||
@@ -42,11 +42,9 @@ type UserRepo interface {
|
||||
HasAuthorityMenu(context.Context, uint, string) (bool, error)
|
||||
MenusByAuthority(context.Context, uint) ([]*Menu, error)
|
||||
ListUsers(context.Context, int, int, *UserListFilter) ([]*User, int64, error)
|
||||
CreateUser(context.Context, *User) (*User, error)
|
||||
CreateUserWithAuthorities(context.Context, *User, []uint) (*User, error)
|
||||
UpdateUser(context.Context, *User) error
|
||||
UpdateSelfUser(context.Context, *User) error
|
||||
UpdateUserWithAuthorities(context.Context, *User, []uint) error
|
||||
DeleteUser(context.Context, uint) error
|
||||
UpdatePassword(context.Context, uint, string, bool) error
|
||||
SetUserAuthorities(context.Context, uint, []uint) error
|
||||
|
||||
@@ -50,7 +50,7 @@ type VersionRepo interface {
|
||||
FindVersion(context.Context, uint) (*Version, error)
|
||||
ListVersions(context.Context, int, int, string, string, []*time.Time) ([]*Version, int64, error)
|
||||
BuildVersionBundle(context.Context, []uint, []uint, []uint) (*VersionBundle, error)
|
||||
ImportVersionBundle(context.Context, *VersionBundle) error
|
||||
ImportVersionBundle(context.Context, *VersionBundle, *Version) error
|
||||
}
|
||||
type VersionUsecase struct{ VersionRepo }
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ var ProviderSet = wire.NewSet(
|
||||
wire.Bind(new(datasystem.Provider), new(*Data)),
|
||||
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
|
||||
wire.Bind(new(dataintegration.Provider), new(*Data)),
|
||||
wire.Bind(new(datatask.Provider), new(*Data)),
|
||||
wire.Bind(new(datapayment.Provider), new(*Data)),
|
||||
datasystem.ProviderSet,
|
||||
dataintegration.ProviderSet,
|
||||
@@ -235,8 +234,9 @@ func NewData(runtime *config.Store, appLogger *slog.Logger, storageManager *stor
|
||||
usingFallback = true
|
||||
}
|
||||
d.databaseReady.Store(!usingFallback)
|
||||
d.gormDB = newReloadableDB(db, d.enqueueDataScopeAudit)
|
||||
d.gormDB = newReloadableDB(db)
|
||||
d.auditLog = newDataScopeAuditWriter(d, appLogger)
|
||||
registerDataScopeCallbacks(db, d.enqueueDataScopeAudit)
|
||||
d.dbList, err = openDatabaseList(c.DatabaseList, appLogger)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"kra/internal/logging"
|
||||
"kra/pkg/database/gormkit"
|
||||
"kra/pkg/logging"
|
||||
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
@@ -14,18 +14,17 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
datapayment "kra/internal/integration/payment"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type paymentRepo struct {
|
||||
data Provider
|
||||
config integrationbiz.PaymentConfigReader
|
||||
data Provider
|
||||
config integrationbiz.PaymentConfigReader
|
||||
factory bizpayment.PaymentAdapterFactory
|
||||
}
|
||||
|
||||
func NewPaymentRepo(data Provider, config integrationbiz.PaymentConfigReader) bizpayment.PaymentRepo {
|
||||
return &paymentRepo{data: data, config: config}
|
||||
func NewPaymentRepo(data Provider, config integrationbiz.PaymentConfigReader, factory bizpayment.PaymentAdapterFactory) bizpayment.PaymentRepo {
|
||||
return &paymentRepo{data: data, config: config, factory: factory}
|
||||
}
|
||||
|
||||
func (r *paymentRepo) values(ctx context.Context, provider string) (map[string]any, error) {
|
||||
@@ -49,12 +48,15 @@ func (r *paymentRepo) values(ctx context.Context, provider string) (map[string]a
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (r *paymentRepo) adapter(ctx context.Context, provider string) (datapayment.Adapter, map[string]any, error) {
|
||||
func (r *paymentRepo) adapter(ctx context.Context, provider string) (bizpayment.PaymentAdapter, map[string]any, error) {
|
||||
values, err := r.values(ctx, provider)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
adapter, err := datapayment.New(provider)
|
||||
if r.factory == nil {
|
||||
return nil, nil, errors.New("支付渠道适配器未接入")
|
||||
}
|
||||
adapter, err := r.factory.New(provider)
|
||||
return adapter, values, err
|
||||
}
|
||||
|
||||
@@ -81,7 +83,13 @@ func (r *paymentRepo) TestProvider(ctx context.Context, provider string) (*bizpa
|
||||
return test, err
|
||||
}
|
||||
|
||||
adapter, err := datapayment.New(provider)
|
||||
if r.factory == nil {
|
||||
err = errors.New("支付渠道适配器未接入")
|
||||
}
|
||||
var adapter bizpayment.PaymentAdapter
|
||||
if err == nil {
|
||||
adapter, err = r.factory.New(provider)
|
||||
}
|
||||
if err != nil {
|
||||
add("adapter", "failed", err.Error(), "", time.Now())
|
||||
return test, err
|
||||
@@ -237,7 +245,7 @@ func validatePaymentTestResult(provider, tradeNo string, result *bizpayment.Paym
|
||||
return nil
|
||||
}
|
||||
|
||||
func queryPaymentTest(ctx context.Context, adapter datapayment.Adapter, queryID string, values map[string]any) (*bizpayment.PaymentResult, error) {
|
||||
func queryPaymentTest(ctx context.Context, adapter bizpayment.PaymentAdapter, queryID string, values map[string]any) (*bizpayment.PaymentResult, error) {
|
||||
var result *bizpayment.PaymentResult
|
||||
var err error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
@@ -547,7 +555,3 @@ func contains(values []string, value string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validatePaymentConfig(provider string, values map[string]any) error {
|
||||
return integrationbiz.ValidateIntegrationConfig(integrationbiz.IntegrationKindPayment, provider, values)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestValidatePaymentConfigRequiresDouyinAppIDWhenEnabled(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidatePaymentConfigAcceptsProviderAliases(t *testing.T) {
|
||||
err := validatePaymentConfig(bizpayment.PaymentDouyin, map[string]any{
|
||||
err := integrationbiz.ValidateIntegrationConfig(integrationbiz.IntegrationKindPayment, bizpayment.PaymentDouyin, map[string]any{
|
||||
"app_id": "douyin-app", "merchant_id": "merchant-douyin", "serial_no": "merchant-serial", "api_key": "01234567890123456789012345678901",
|
||||
"private_key": "merchant-private-key", "platform_cert": "platform-public-key", "platform_cert_serial": "platform-serial",
|
||||
})
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
package payment
|
||||
|
||||
import "kra/pkg/paymentkit"
|
||||
import "kra/internal/paymentkit"
|
||||
|
||||
func text(values map[string]any, key string) string {
|
||||
return paymentutil.Text(values, key)
|
||||
return paymentkit.Text(values, key)
|
||||
}
|
||||
|
||||
func firstAny(values map[string]any, keys ...string) string {
|
||||
return paymentutil.FirstText(values, keys...)
|
||||
return paymentkit.FirstText(values, keys...)
|
||||
}
|
||||
|
||||
// configuredInt64 keeps repository-side validation independent from the
|
||||
// provider adapter package while sharing the canonical payment parser.
|
||||
func configuredInt64(values map[string]any, key string, fallback int64) int64 {
|
||||
return paymentutil.ConfiguredInt64(values, key, fallback)
|
||||
return paymentkit.ConfiguredInt64(values, key, fallback)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestPaymentDefinitionsProvideNonEmptyDefaults(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPaymentValidationRejectsUnknownProvider(t *testing.T) {
|
||||
err := validatePaymentConfig("unknown-provider", map[string]any{})
|
||||
err := integrationbiz.ValidateIntegrationConfig(integrationbiz.IntegrationKindPayment, "unknown-provider", map[string]any{})
|
||||
if err == nil || !strings.Contains(err.Error(), "不支持") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
|
||||
@@ -163,10 +163,9 @@ func (r *rollback) run() {
|
||||
}
|
||||
}
|
||||
|
||||
// newReloadableDB attaches the row-level data scope callbacks the pool needs
|
||||
// before it becomes readable. Data.replacePrimaryDB is the reload counterpart.
|
||||
func newReloadableDB(db *gorm.DB, enqueue dataScopeAuditEnqueue) *reloadable[*gorm.DB] {
|
||||
registerDataScopeCallbacks(db, enqueue)
|
||||
// newReloadableDB owns only the hot-swappable database handle. Domain-specific
|
||||
// callbacks are attached by Data when a pool becomes active.
|
||||
func newReloadableDB(db *gorm.DB, _ ...dataScopeAuditEnqueue) *reloadable[*gorm.DB] {
|
||||
return newReloadable(db, closeGormDB)
|
||||
}
|
||||
|
||||
|
||||
@@ -109,13 +109,6 @@ func (r *apiTokenRepo) ListAPITokens(ctx context.Context, page, size int, userID
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
func (r *apiTokenRepo) DisableAPIToken(ctx context.Context, id uint) (string, error) {
|
||||
var po apiTokenPO
|
||||
if err := r.data.DB().WithContext(ctx).First(&po, id).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return po.Token, r.data.DB().WithContext(ctx).Model(&po).Update("status", false).Error
|
||||
}
|
||||
func (r *apiTokenRepo) DisableAndBlacklistAPIToken(ctx context.Context, id uint) error {
|
||||
// Persist the blacklist row first, then mark the
|
||||
// API-token record disabled. The two writes are intentionally separate in
|
||||
|
||||
@@ -191,12 +191,103 @@ func (r *exportRepo) QueryExport(ctx context.Context, t *system.ExportTemplate,
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
var rows []map[string]any
|
||||
preview := query.ToSQL(func(tx *gorm.DB) *gorm.DB { return tx.Find(&[]map[string]any{}) })
|
||||
err = query.Find(&rows).Error
|
||||
rows, err := queryExportRows(query)
|
||||
return rows, preview, err
|
||||
}
|
||||
|
||||
func queryExportRows(query *gorm.DB) ([]map[string]any, error) {
|
||||
rows, err := query.Rows()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columnTypes, err := rows.ColumnTypes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]map[string]any, 0)
|
||||
for rows.Next() {
|
||||
values := make([]any, len(columns))
|
||||
destinations := make([]any, len(values))
|
||||
for index := range values {
|
||||
destinations[index] = &values[index]
|
||||
}
|
||||
if err := rows.Scan(destinations...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := make(map[string]any, len(columns))
|
||||
for index, column := range columns {
|
||||
typeName := ""
|
||||
if index < len(columnTypes) {
|
||||
typeName = columnTypes[index].DatabaseTypeName()
|
||||
}
|
||||
item[column] = exportQueryValue(values[index], typeName)
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func exportQueryValue(value any, databaseType string) any {
|
||||
bytesValue, ok := value.([]byte)
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
raw := string(bytesValue)
|
||||
typeName := strings.ToUpper(databaseType)
|
||||
if strings.Contains(typeName, "INT") || strings.Contains(typeName, "SERIAL") {
|
||||
if integer, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
return integer
|
||||
}
|
||||
}
|
||||
if strings.Contains(typeName, "DECIMAL") || strings.Contains(typeName, "NUMERIC") || strings.Contains(typeName, "NUMBER") || strings.Contains(typeName, "REAL") || strings.Contains(typeName, "DOUBLE") || strings.Contains(typeName, "FLOAT") {
|
||||
if decimal, ok := exactExportFloat(raw); ok {
|
||||
return decimal
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func exactExportFloat(value string) (float64, bool) {
|
||||
decimal, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return decimal, normalizeDecimal(value) == normalizeDecimal(strconv.FormatFloat(decimal, 'f', -1, 64))
|
||||
}
|
||||
|
||||
func normalizeDecimal(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
negative := strings.HasPrefix(value, "-")
|
||||
value = strings.TrimPrefix(value, "+")
|
||||
value = strings.TrimPrefix(value, "-")
|
||||
parts := strings.SplitN(value, ".", 2)
|
||||
whole := strings.TrimLeft(parts[0], "0")
|
||||
if whole == "" {
|
||||
whole = "0"
|
||||
}
|
||||
fraction := ""
|
||||
if len(parts) == 2 {
|
||||
fraction = strings.TrimRight(parts[1], "0")
|
||||
}
|
||||
result := whole
|
||||
if fraction != "" {
|
||||
result += "." + fraction
|
||||
}
|
||||
if negative && result != "0" {
|
||||
result = "-" + result
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func exportColumns(raw string) ([]string, error) {
|
||||
columns := make([]string, 0)
|
||||
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||
@@ -338,17 +429,6 @@ func (r *exportRepo) PreviewExport(ctx context.Context, t *system.ExportTemplate
|
||||
}
|
||||
return query.ToSQL(func(tx *gorm.DB) *gorm.DB { return tx.Find(&[]map[string]any{}) }), nil
|
||||
}
|
||||
func parseTemplateColumns(raw string) map[string]string {
|
||||
out := map[string]string{}
|
||||
parts := strings.Split(strings.Trim(strings.TrimSpace(raw), "{}"), ",")
|
||||
for _, part := range parts {
|
||||
pair := strings.SplitN(part, ":", 2)
|
||||
if len(pair) == 2 {
|
||||
out[strings.Trim(strings.TrimSpace(pair[0]), `"`)] = strings.Trim(strings.TrimSpace(pair[1]), `"`)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (r *exportRepo) ImportExportRows(ctx context.Context, t *system.ExportTemplate, rows []map[string]any) error {
|
||||
if err := system.ValidateExportTemplate(t); err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package system
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExportQueryValueUsesDatabaseType(t *testing.T) {
|
||||
if value := exportQueryValue([]byte("42"), "VARCHAR"); value != "42" {
|
||||
t.Fatalf("text value = %#v, want string 42", value)
|
||||
}
|
||||
if value, ok := exportQueryValue([]byte("42"), "BIGINT").(int64); !ok || value != 42 {
|
||||
t.Fatalf("integer value = %#v", exportQueryValue([]byte("42"), "BIGINT"))
|
||||
}
|
||||
if value, ok := exportQueryValue([]byte("12.5"), "DECIMAL").(float64); !ok || value != 12.5 {
|
||||
t.Fatalf("decimal value = %#v", exportQueryValue([]byte("12.5"), "DECIMAL"))
|
||||
}
|
||||
if value, ok := exportQueryValue([]byte("1234567890123456.78"), "DECIMAL").(string); !ok || value != "1234567890123456.78" {
|
||||
t.Fatalf("high-precision decimal value = %#v", exportQueryValue([]byte("1234567890123456.78"), "DECIMAL"))
|
||||
}
|
||||
}
|
||||
@@ -40,9 +40,18 @@ func (r *auditQueryRepo) ListLogins(ctx context.Context, page, size int, q *syst
|
||||
if q.Username != "" {
|
||||
db = db.Where("username LIKE ?", "%"+q.Username+"%")
|
||||
}
|
||||
if q.IP != "" {
|
||||
db = db.Where("ip LIKE ?", "%"+q.IP+"%")
|
||||
}
|
||||
if q.Status != nil {
|
||||
db = db.Where("status = ?", *q.Status)
|
||||
}
|
||||
if q.StartCreatedAt != nil {
|
||||
db = db.Where("created_at >= ?", *q.StartCreatedAt)
|
||||
}
|
||||
if q.EndCreatedAt != nil {
|
||||
db = db.Where("created_at <= ?", *q.EndCreatedAt)
|
||||
}
|
||||
}
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
|
||||
@@ -64,13 +64,6 @@ func (r *mediaRepo) FindMedia(ctx context.Context, id uint) (*system.MediaFile,
|
||||
}
|
||||
return mediaFromPO(po), nil
|
||||
}
|
||||
func (r *mediaRepo) FindMediaByHash(ctx context.Context, userID uint, hash string) (*system.MediaFile, error) {
|
||||
var po mediaPO
|
||||
if err := r.data.DB().WithContext(ctx).Where("user_id = ? AND md5 = ?", userID, hash).First(&po).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mediaFromPO(po), nil
|
||||
}
|
||||
func (r *mediaRepo) ListMedia(ctx context.Context, filter system.MediaFilter) ([]*system.MediaFile, int64, error) {
|
||||
db := r.data.DB().WithContext(ctx).Model(&mediaPO{})
|
||||
if filter.Keyword != "" {
|
||||
|
||||
@@ -281,12 +281,6 @@ func (r *menuRepo) SetAuthorityMenus(ctx context.Context, id uint, ids []uint) e
|
||||
})
|
||||
}
|
||||
|
||||
func (r *menuRepo) AuthorityMenuIDs(ctx context.Context, id uint) ([]uint, error) {
|
||||
var ids []uint
|
||||
err := r.data.DB().WithContext(ctx).Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ?", id).Pluck("sys_base_menu_id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// ListAuthorityMenus reads the authority-menu query. It reads the
|
||||
// target authority's relation rows first and then fetches only those base
|
||||
// menus. In particular, it deliberately does not call List: List applies the
|
||||
|
||||
@@ -33,40 +33,6 @@ type authorityButtonPO struct {
|
||||
|
||||
func (authorityButtonPO) TableName() string { return "sys_authority_btns" }
|
||||
|
||||
func (r *permissionRepo) Buttons(ctx context.Context, menuID uint) ([]*system.MenuButton, error) {
|
||||
var pos []menuButtonPO
|
||||
if err := r.data.DB().WithContext(ctx).Where("sys_base_menu_id = ?", menuID).Find(&pos).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*system.MenuButton, 0, len(pos))
|
||||
for _, po := range pos {
|
||||
out = append(out, menuButtonFromPO(po))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (r *permissionRepo) SetAuthorityButtons(ctx context.Context, aid uint, buttons map[uint][]uint) error {
|
||||
if err := (&authorityAccessRepo{data: r.data}).checkAuthorityIDAuth(ctx, aid); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.checkButtonAssignmentAuth(ctx, buttons); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("authority_id = ?", aid).Delete(&authorityButtonPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var links []authorityButtonPO
|
||||
for menuID, ids := range buttons {
|
||||
for _, id := range ids {
|
||||
links = append(links, authorityButtonPO{AuthorityID: aid, MenuID: menuID, ButtonID: id})
|
||||
}
|
||||
}
|
||||
if len(links) > 0 {
|
||||
return tx.Create(&links).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
func (r *permissionRepo) SelectedButtons(ctx context.Context, aid, menuID uint) ([]uint, error) {
|
||||
var rows []authorityButtonPO
|
||||
if err := r.data.DB().WithContext(ctx).Where("authority_id = ? AND sys_menu_id = ?", aid, menuID).Find(&rows).Error; err != nil {
|
||||
@@ -110,12 +76,6 @@ func (r *permissionRepo) CanRemoveButton(ctx context.Context, id string) (bool,
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
func (r *permissionRepo) AuthorityButtonIDs(ctx context.Context, aid uint) ([]uint, error) {
|
||||
var ids []uint
|
||||
err := r.data.DB().WithContext(ctx).Model(&authorityButtonPO{}).Where("authority_id = ?", aid).Pluck("sys_base_menu_btn_id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
|
||||
func (r *permissionRepo) checkButtonAssignmentAuth(ctx context.Context, buttons map[uint][]uint) error {
|
||||
actor, allowedAuthorities, strict, err := (&authorityAccessRepo{data: r.data}).strictAuthorityAccess(ctx)
|
||||
if err != nil || !strict {
|
||||
|
||||
@@ -24,7 +24,7 @@ func newTransactionTestData(t *testing.T) *Data {
|
||||
return data
|
||||
}
|
||||
|
||||
func TestUserAuthorityWritesAreAtomic(t *testing.T) {
|
||||
func TestUserAuthorityWrites(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
ctx := context.Background()
|
||||
if err := data.gormDB.WithContext(ctx).Create(&[]authorityPO{{AuthorityID: 888, AuthorityName: "admin"}, {AuthorityID: 999, AuthorityName: "operator"}}).Error; err != nil {
|
||||
@@ -44,7 +44,10 @@ func TestUserAuthorityWritesAreAtomic(t *testing.T) {
|
||||
// With strict authority checks disabled, the reference implementation writes the supplied join rows
|
||||
// without first requiring every role record to exist.
|
||||
created.NickName = "compatible update"
|
||||
if err = repo.UpdateUserWithAuthorities(ctx, created, []uint{888, 123456}); err != nil {
|
||||
if err = repo.UpdateUser(ctx, created); err != nil {
|
||||
t.Fatalf("user update failed: %v", err)
|
||||
}
|
||||
if err = repo.SetUserAuthorities(ctx, created.ID, []uint{888, 123456}); err != nil {
|
||||
t.Fatalf("missing authority ID was rejected in non-strict mode: %v", err)
|
||||
}
|
||||
var persisted userPO
|
||||
|
||||
@@ -329,10 +329,6 @@ func (r *userRepo) ListUsers(ctx context.Context, page, pageSize int, filter *sy
|
||||
return users, total, err
|
||||
}
|
||||
|
||||
func (r *userRepo) CreateUser(ctx context.Context, user *system.User) (*system.User, error) {
|
||||
return r.CreateUserWithAuthorities(ctx, user, nil)
|
||||
}
|
||||
|
||||
func (r *userRepo) CreateUserWithAuthorities(ctx context.Context, user *system.User, authorityIDs []uint) (*system.User, error) {
|
||||
requestedAuthorities := append([]uint{user.AuthorityID}, authorityIDs...)
|
||||
if err := (&authorityAccessRepo{data: r.data}).checkAuthorityIDsAuth(ctx, requestedAuthorities); err != nil {
|
||||
@@ -489,24 +485,6 @@ func (r *userRepo) updateUser(tx *gorm.DB, user *system.User) error {
|
||||
return tx.Model(&userPO{}).Where("id = ?", user.ID).Updates(updates).Error
|
||||
}
|
||||
|
||||
func (r *userRepo) UpdateUserWithAuthorities(ctx context.Context, user *system.User, authorityIDs []uint) error {
|
||||
access := &authorityAccessRepo{data: r.data}
|
||||
if err := r.checkUserUpdateAuth(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := access.checkAuthorityIDsAuth(ctx, authorityIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := r.updateUser(tx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(authorityIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return setUserAuthorities(tx, user.ID, authorityIDs)
|
||||
})
|
||||
}
|
||||
func (r *userRepo) DeleteUser(ctx context.Context, id uint) error {
|
||||
if err := (&authorityAccessRepo{data: r.data}).checkUserIDAuth(ctx, id, false); err != nil {
|
||||
return err
|
||||
@@ -561,7 +539,7 @@ func (r *userRepo) SetUserAuthorities(ctx context.Context, id uint, authorityIDs
|
||||
|
||||
func setUserAuthorities(tx *gorm.DB, id uint, authorityIDs []uint) error {
|
||||
if len(authorityIDs) == 0 {
|
||||
return errors.New("角色不能为空")
|
||||
return system.ErrAuthoritiesRequired
|
||||
}
|
||||
if err := tx.Where("sys_user_id = ?", id).Delete(&userAuthorityPO{}).Error; err != nil {
|
||||
return err
|
||||
|
||||
@@ -80,10 +80,6 @@ func TestStrictUserUpdateRejectsPrimaryAuthorityOutsideManagedTree(t *testing.T)
|
||||
if err := repo.UpdateUser(ctx, &system.User{ID: users[1].ID, NickName: "changed", AuthorityID: siblingID}); err == nil {
|
||||
t.Fatal("UpdateUser() accepted a primary authority outside the managed tree")
|
||||
}
|
||||
if err := repo.UpdateUserWithAuthorities(ctx, &system.User{ID: users[1].ID, NickName: "changed", AuthorityID: siblingID}, []uint{childID}); err == nil {
|
||||
t.Fatal("UpdateUserWithAuthorities() accepted a primary authority outside the managed tree")
|
||||
}
|
||||
|
||||
var stored userPO
|
||||
if err := data.gormDB.WithContext(context.Background()).First(&stored, users[1].ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -179,24 +179,32 @@ func (r *versionRepo) BuildVersionBundle(ctx context.Context, menuIDs, apiIDs, d
|
||||
}
|
||||
return bundle, nil
|
||||
}
|
||||
func (r *versionRepo) ImportVersionBundle(ctx context.Context, b *system.VersionBundle) error {
|
||||
db := r.data.DB().WithContext(ctx)
|
||||
if len(b.Menus) > 0 {
|
||||
if err := db.Transaction(func(tx *gorm.DB) error { return importVersionMenus(tx, b.Menus, 0) }); err != nil {
|
||||
return &system.VersionStageError{Stage: system.VersionStageMenus, Err: err}
|
||||
func (r *versionRepo) ImportVersionBundle(ctx context.Context, b *system.VersionBundle, audit *system.Version) error {
|
||||
return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if len(b.Menus) > 0 {
|
||||
if err := importVersionMenus(tx, b.Menus, 0); err != nil {
|
||||
return &system.VersionStageError{Stage: system.VersionStageMenus, Err: err}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(b.APIs) > 0 {
|
||||
if err := db.Transaction(func(tx *gorm.DB) error { return importVersionAPIs(tx, b.APIs) }); err != nil {
|
||||
return &system.VersionStageError{Stage: system.VersionStageAPIs, Err: err}
|
||||
if len(b.APIs) > 0 {
|
||||
if err := importVersionAPIs(tx, b.APIs); err != nil {
|
||||
return &system.VersionStageError{Stage: system.VersionStageAPIs, Err: err}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(b.Dictionaries) > 0 {
|
||||
if err := db.Transaction(func(tx *gorm.DB) error { return importVersionDictionaries(tx, b.Dictionaries) }); err != nil {
|
||||
return &system.VersionStageError{Stage: system.VersionStageDictionaries, Err: err}
|
||||
if len(b.Dictionaries) > 0 {
|
||||
if err := importVersionDictionaries(tx, b.Dictionaries); err != nil {
|
||||
return &system.VersionStageError{Stage: system.VersionStageDictionaries, Err: err}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
if audit != nil {
|
||||
po := versionPO{Name: audit.Name, Code: audit.Code, Description: audit.Description, Data: audit.Data}
|
||||
if err := tx.Create(&po).Error; err != nil {
|
||||
return &system.VersionStageError{Stage: system.VersionStageSave, Err: err}
|
||||
}
|
||||
audit.ID = po.ID
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func importVersionMenus(tx *gorm.DB, items []*system.Menu, parentID uint) error {
|
||||
|
||||
@@ -33,10 +33,6 @@ func (r *emailRepo) email() *config.Email {
|
||||
return config.Email
|
||||
}
|
||||
|
||||
func (r *emailRepo) Enabled() bool {
|
||||
return emailEnabled(r.email())
|
||||
}
|
||||
|
||||
func (r *emailRepo) DefaultRecipients() []string {
|
||||
config := r.email()
|
||||
if config == nil || strings.TrimSpace(config.To) == "" {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
bizpayment "kra/internal/biz/payment"
|
||||
"strings"
|
||||
@@ -9,12 +8,11 @@ import (
|
||||
|
||||
// Adapter is the provider boundary used by the payment repository. Provider
|
||||
// SDK types stay in this package and are normalized to biz/payment results.
|
||||
type Adapter interface {
|
||||
Create(context.Context, *bizpayment.PaymentRequest, map[string]any) (*bizpayment.PaymentResult, error)
|
||||
Query(context.Context, string, map[string]any) (*bizpayment.PaymentResult, error)
|
||||
Refund(context.Context, *bizpayment.PaymentRefundRequest, map[string]any) (*bizpayment.PaymentResult, error)
|
||||
Callback(context.Context, *bizpayment.PaymentCallback, map[string]any) (*bizpayment.PaymentResult, error)
|
||||
}
|
||||
type Adapter = bizpayment.PaymentAdapter
|
||||
|
||||
type Factory struct{}
|
||||
|
||||
func NewFactory() *Factory { return &Factory{} }
|
||||
|
||||
type adapterFactory func() Adapter
|
||||
|
||||
@@ -51,3 +49,7 @@ func New(provider string) (Adapter, error) {
|
||||
}
|
||||
return factory(), nil
|
||||
}
|
||||
|
||||
func (*Factory) New(provider string) (bizpayment.PaymentAdapter, error) {
|
||||
return New(provider)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
bizpayment "kra/internal/biz/payment"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -569,21 +568,6 @@ func verifyAlipayResponse(raw []byte, method string, c map[string]any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func alipaySignContent(v url.Values) string {
|
||||
keys := make([]string, 0, len(v))
|
||||
for key := range v {
|
||||
if key != "sign" && v.Get(key) != "" {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, key+"="+v.Get(key))
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
func verifyAlipay(fields map[string]string, c map[string]any) error {
|
||||
if text(c, "app_id") == "" || text(c, "public_key") == "" || fields["sign"] == "" {
|
||||
return errors.New("支付宝回调验签配置或 sign 缺失")
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kra/pkg/paymentkit"
|
||||
"kra/internal/paymentkit"
|
||||
|
||||
gopayAlipay "github.com/go-pay/gopay/alipay"
|
||||
gopayDouyin "github.com/go-pay/gopay/douyin"
|
||||
@@ -177,7 +177,7 @@ func TestWechatV2GoPayCallbackVerificationAndSandboxAmount(t *testing.T) {
|
||||
t.Errorf("read request: %v", err)
|
||||
return
|
||||
}
|
||||
requestValues, err = paymentutil.XMLValues(body)
|
||||
requestValues, err = paymentkit.XMLValues(body)
|
||||
if err != nil {
|
||||
t.Errorf("parse request: %v", err)
|
||||
return
|
||||
@@ -190,7 +190,7 @@ func TestWechatV2GoPayCallbackVerificationAndSandboxAmount(t *testing.T) {
|
||||
}
|
||||
response := map[string]string{"return_code": "SUCCESS", "return_msg": "OK", "result_code": "SUCCESS", "prepay_id": "prepay"}
|
||||
response["sign"] = wechatV2Sign(response, key, "MD5")
|
||||
_, _ = w.Write(paymentutil.XMLEncode(response))
|
||||
_, _ = w.Write(paymentkit.XMLEncode(response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
@@ -219,7 +219,7 @@ func TestWechatV2GoPayCallbackVerificationAndSandboxAmount(t *testing.T) {
|
||||
if callbackValues["sign"] != originalSign {
|
||||
t.Fatal("verification mutated source values")
|
||||
}
|
||||
callbackBody := paymentutil.XMLEncode(callbackValues)
|
||||
callbackBody := paymentkit.XMLEncode(callbackValues)
|
||||
callbackResult, err := (&wechatV2Adapter{}).Callback(context.Background(), &bizpayment.PaymentCallback{Body: callbackBody}, map[string]any{
|
||||
"app_id": "wx-test", "merchant_id": "mch-test", "mch_key": key,
|
||||
})
|
||||
@@ -243,7 +243,7 @@ func TestWechatV2MicropayUsesGoPaySDKAndValidatesIdentities(t *testing.T) {
|
||||
t.Errorf("read request: %v", err)
|
||||
return
|
||||
}
|
||||
requestValues, err = paymentutil.XMLValues(body)
|
||||
requestValues, err = paymentkit.XMLValues(body)
|
||||
if err != nil {
|
||||
t.Errorf("parse request: %v", err)
|
||||
return
|
||||
@@ -263,7 +263,7 @@ func TestWechatV2MicropayUsesGoPaySDKAndValidatesIdentities(t *testing.T) {
|
||||
"transaction_id": "WX-TRANSACTION-1", "total_fee": "123", "cash_fee": "123", "fee_type": "CNY",
|
||||
}
|
||||
response["sign"] = wechatV2Sign(response, key, "MD5")
|
||||
_, _ = w.Write(paymentutil.XMLEncode(response))
|
||||
_, _ = w.Write(paymentkit.XMLEncode(response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
@@ -344,7 +344,7 @@ func TestWechatV2MicropayRejectsInvalidSuccessResponse(t *testing.T) {
|
||||
}
|
||||
tc.mutate(response)
|
||||
response["sign"] = wechatV2Sign(response, key, "MD5")
|
||||
_, _ = w.Write(paymentutil.XMLEncode(response))
|
||||
_, _ = w.Write(paymentkit.XMLEncode(response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
|
||||
@@ -6,10 +6,39 @@ import (
|
||||
"encoding/base64"
|
||||
bizpayment "kra/internal/biz/payment"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
gopayWechat "github.com/go-pay/gopay/wechat"
|
||||
gopayWechatV3 "github.com/go-pay/gopay/wechat/v3"
|
||||
)
|
||||
|
||||
func wechatV2Sign(values map[string]string, key, signType string) string {
|
||||
body := toGoPayBodyMap(values)
|
||||
body.Remove("sign")
|
||||
return gopayWechat.GetReleaseSign(key, wechatV2SignType(signType), body)
|
||||
}
|
||||
|
||||
func decryptWechatV3(ciphertext, nonce, associatedData, key string) ([]byte, error) {
|
||||
return gopayWechatV3.V3DecryptNotifyCipherTextToBytes(ciphertext, nonce, associatedData, key)
|
||||
}
|
||||
|
||||
func alipaySignContent(values url.Values) string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
if key != "sign" && values.Get(key) != "" {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, key+"="+values.Get(key))
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
func TestWechatV2SignOfficialStyle(t *testing.T) {
|
||||
values := map[string]string{
|
||||
"appid": "wxd930ea5d5a258f4f", "mch_id": "10000100", "device_info": "1000",
|
||||
|
||||
@@ -385,11 +385,6 @@ func paypalOrderResult(order *paypal.OrderDetail, queryID, fallbackTradeNo strin
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func paypalOrderTradeNo(order *paypal.OrderDetail) string {
|
||||
tradeNo, _ := paypalOrderIdentity(order)
|
||||
return tradeNo
|
||||
}
|
||||
|
||||
func paypalOrderIdentity(order *paypal.OrderDetail) (string, error) {
|
||||
if order == nil {
|
||||
return "", nil
|
||||
@@ -648,17 +643,6 @@ func paypalAmountScale(currency string, c map[string]any) (int64, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePayPalOrderState(state string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(state)) {
|
||||
case "COMPLETED":
|
||||
return "success"
|
||||
case "VOIDED":
|
||||
return "failed"
|
||||
default:
|
||||
return "pending"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePayPalCaptureState(state string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(state)) {
|
||||
case "COMPLETED", "PARTIALLY_REFUNDED", "REFUNDED":
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"kra/pkg/paymentkit"
|
||||
"kra/internal/paymentkit"
|
||||
|
||||
"github.com/go-pay/gopay"
|
||||
gopayQQ "github.com/go-pay/gopay/qq"
|
||||
@@ -55,7 +55,7 @@ func TestQQCreateKeepsClientPaymentDataOutOfDurableIdentities(t *testing.T) {
|
||||
http.Error(w, "invalid XML", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
values, err := paymentutil.XMLValues(body)
|
||||
values, err := paymentkit.XMLValues(body)
|
||||
if err != nil {
|
||||
t.Errorf("parse request XML: %v", err)
|
||||
http.Error(w, "invalid XML", http.StatusBadRequest)
|
||||
@@ -81,7 +81,7 @@ func TestQQCreateKeepsClientPaymentDataOutOfDurableIdentities(t *testing.T) {
|
||||
for key, value := range response {
|
||||
encoded[key] = fmt.Sprint(value)
|
||||
}
|
||||
_, _ = w.Write(paymentutil.XMLEncode(encoded))
|
||||
_, _ = w.Write(paymentkit.XMLEncode(encoded))
|
||||
}))
|
||||
|
||||
result, err := (&qqAdapter{}).Create(context.Background(), &bizpayment.PaymentRequest{
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"kra/pkg/paymentkit"
|
||||
"kra/internal/paymentkit"
|
||||
)
|
||||
|
||||
func callbackFields(callback *bizpayment.PaymentCallback) map[string]string {
|
||||
@@ -56,57 +56,53 @@ func first(values map[string]string, keys ...string) string {
|
||||
}
|
||||
|
||||
func text(values map[string]any, key string) string {
|
||||
return paymentutil.Text(values, key)
|
||||
return paymentkit.Text(values, key)
|
||||
}
|
||||
|
||||
func firstAny(values map[string]any, keys ...string) string {
|
||||
return paymentutil.FirstText(values, keys...)
|
||||
return paymentkit.FirstText(values, keys...)
|
||||
}
|
||||
|
||||
// Compatibility shims keep channel adapters focused on protocol and
|
||||
// persistence concerns while stateless parsing rules live in paymentutil.
|
||||
// persistence concerns while stateless parsing rules live in paymentkit.
|
||||
func normalizePaymentStatus(value, fallback string) string {
|
||||
return paymentutil.NormalizeStatus(value, fallback)
|
||||
return paymentkit.NormalizeStatus(value, fallback)
|
||||
}
|
||||
|
||||
func parseIntegerAmount(value string) (int64, error) {
|
||||
return paymentutil.ParseIntegerAmount(value)
|
||||
return paymentkit.ParseIntegerAmount(value)
|
||||
}
|
||||
|
||||
func parseDecimalAmount(value string, scale int64) (int64, error) {
|
||||
return paymentutil.ParseDecimalAmount(value, scale)
|
||||
return paymentkit.ParseDecimalAmount(value, scale)
|
||||
}
|
||||
|
||||
func formatDecimalAmount(amount, scale int64) (string, error) {
|
||||
return paymentutil.FormatDecimalAmount(amount, scale)
|
||||
return paymentkit.FormatDecimalAmount(amount, scale)
|
||||
}
|
||||
|
||||
func configuredInt64(values map[string]any, key string, fallback int64) int64 {
|
||||
return paymentutil.ConfiguredInt64(values, key, fallback)
|
||||
return paymentkit.ConfiguredInt64(values, key, fallback)
|
||||
}
|
||||
|
||||
func jsonObject(raw []byte) map[string]any {
|
||||
return paymentutil.JSONObject(raw)
|
||||
}
|
||||
|
||||
func nestedString(value any, keys ...string) string {
|
||||
return paymentutil.NestedString(value, keys...)
|
||||
return paymentkit.JSONObject(raw)
|
||||
}
|
||||
|
||||
func valueAtPath(value any, path string) any {
|
||||
return paymentutil.ValueAtPath(value, path)
|
||||
return paymentkit.ValueAtPath(value, path)
|
||||
}
|
||||
|
||||
func stringAtPath(value any, path string) string {
|
||||
return paymentutil.StringAtPath(value, path)
|
||||
return paymentkit.StringAtPath(value, path)
|
||||
}
|
||||
|
||||
func configuredValues(values map[string]any, key string) []string {
|
||||
return paymentutil.ConfiguredValues(values, key)
|
||||
return paymentkit.ConfiguredValues(values, key)
|
||||
}
|
||||
|
||||
func containsFold(values []string, value string) bool {
|
||||
return paymentutil.ContainsFold(values, value)
|
||||
return paymentkit.ContainsFold(values, value)
|
||||
}
|
||||
|
||||
// parseConfiguredAmount reads a provider amount field using an explicit unit
|
||||
@@ -138,12 +134,3 @@ func parseConfiguredAmount(object any, config map[string]any, fieldKey, scaleKey
|
||||
}
|
||||
return amount, true, nil
|
||||
}
|
||||
|
||||
func amountFromDecimalField(values map[string]any, key string, fallback int64) (int64, bool, error) {
|
||||
value := strings.TrimSpace(fmt.Sprint(values[key]))
|
||||
if value == "" || value == "<nil>" {
|
||||
return 0, false, nil
|
||||
}
|
||||
amount, err := parseDecimalAmount(value, fallback)
|
||||
return amount, true, err
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/pkg/paymentkit"
|
||||
"kra/internal/paymentkit"
|
||||
)
|
||||
|
||||
type vendorProfile string
|
||||
@@ -133,8 +133,13 @@ func (a *vendorPaymentAdapter) call(ctx context.Context, endpointKey string, pay
|
||||
}
|
||||
result := &bizpayment.PaymentResult{Provider: a.provider, Status: "created", TradeNo: tradeNo, Payload: ensureJSON(body)}
|
||||
if endpointKey != "query_url" {
|
||||
if object := jsonObject(body); object != nil {
|
||||
operation := strings.TrimSuffix(endpointKey, "_url")
|
||||
operation := strings.TrimSuffix(endpointKey, "_url")
|
||||
object := jsonObject(body)
|
||||
if object == nil {
|
||||
if operation == "refund" {
|
||||
return nil, fmt.Errorf("%s 退款响应不是 JSON 对象", a.provider)
|
||||
}
|
||||
} else {
|
||||
statusField := text(c, operation+"_status_field")
|
||||
if statusField == "" {
|
||||
statusField = text(c, "query_status_field")
|
||||
@@ -158,8 +163,20 @@ func (a *vendorPaymentAdapter) call(ctx context.Context, endpointKey string, pay
|
||||
case containsFold(configuredValues(c, operation+"_success_values"), state):
|
||||
result.Status = "created"
|
||||
default:
|
||||
result.Status = normalizePaymentStatus(state, "created")
|
||||
if operation == "refund" {
|
||||
normalized := normalizePaymentStatus(state, "")
|
||||
switch normalized {
|
||||
case "success", "pending", "failed":
|
||||
result.Status = normalized
|
||||
default:
|
||||
return nil, fmt.Errorf("%s 退款响应状态无法识别: %s", a.provider, state)
|
||||
}
|
||||
} else {
|
||||
result.Status = normalizePaymentStatus(state, "created")
|
||||
}
|
||||
}
|
||||
} else if operation == "refund" {
|
||||
return nil, fmt.Errorf("%s 退款响应缺少业务状态", a.provider)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
@@ -273,18 +290,18 @@ func (a *vendorPaymentAdapter) signRequest(request *http.Request, payload map[st
|
||||
digest := sha256.Sum256([]byte(appID + timestamp + nonceValue + string(raw) + secret))
|
||||
request.Header.Set("Authorization", "OPEN-BODY-SIG AppId="+appID+", Timestamp="+timestamp+", Nonce="+nonceValue+", Signature="+hex.EncodeToString(digest[:]))
|
||||
case vendorSFT:
|
||||
request.Header.Set("X-SFT-Sign", paymentutil.MD5Canonical(payload, secret))
|
||||
request.Header.Set("X-SFT-Sign", paymentkit.MD5Canonical(payload, secret))
|
||||
case vendorWechatGame:
|
||||
request.Header.Set("X-Wechat-Game-Sign", paymentutil.HMACSHA256Hex(raw, secret, false))
|
||||
request.Header.Set("X-Wechat-Game-Sign", paymentkit.HMACSHA256Hex(raw, secret, false))
|
||||
if token := text(c, "access_token"); token != "" {
|
||||
query := request.URL.Query()
|
||||
query.Set("access_token", token)
|
||||
request.URL.RawQuery = query.Encode()
|
||||
}
|
||||
case vendorDouyinGame:
|
||||
request.Header.Set("X-TT-Pay-Sign", paymentutil.MD5Canonical(payload, secret))
|
||||
request.Header.Set("X-TT-Pay-Sign", paymentkit.MD5Canonical(payload, secret))
|
||||
default:
|
||||
request.Header.Set("X-Payment-Sign", paymentutil.HMACSHA256Hex(raw, secret, false))
|
||||
request.Header.Set("X-Payment-Sign", paymentkit.HMACSHA256Hex(raw, secret, false))
|
||||
}
|
||||
}
|
||||
func (a *vendorPaymentAdapter) verify(fields map[string]string, raw []byte, headers map[string]string, c map[string]any) error {
|
||||
@@ -327,9 +344,9 @@ func (a *vendorPaymentAdapter) verify(fields map[string]string, raw []byte, head
|
||||
values[key] = value
|
||||
}
|
||||
}
|
||||
actual = paymentutil.MD5Canonical(values, secret)
|
||||
actual = paymentkit.MD5Canonical(values, secret)
|
||||
default:
|
||||
actual = paymentutil.HMACSHA256Hex(raw, secret, false)
|
||||
actual = paymentkit.HMACSHA256Hex(raw, secret, false)
|
||||
}
|
||||
if !hmac.Equal([]byte(strings.ToLower(expected)), []byte(strings.ToLower(actual))) {
|
||||
return errors.New("支付回调签名校验失败")
|
||||
|
||||
@@ -56,3 +56,23 @@ func TestVendorRefundMapsBusinessFailureStatus(t *testing.T) {
|
||||
t.Fatalf("Refund() result = %#v, want failed status", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVendorRefundRejectsUnknownBusinessStatus(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"UNKNOWN"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
adapter := &vendorPaymentAdapter{provider: "vendor", profile: vendorSFT}
|
||||
result, err := adapter.Refund(context.Background(), &bizpayment.PaymentRefundRequest{TradeNo: "trade-1", RefundNo: "refund-1", Amount: 100, TotalAmount: 100, Currency: "CNY"}, map[string]any{
|
||||
"refund_url": server.URL,
|
||||
"app_key": "secret",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "状态无法识别") {
|
||||
t.Fatalf("Refund() error = %v, want unknown-status error", err)
|
||||
}
|
||||
if result != nil {
|
||||
t.Fatalf("Refund() result = %#v, want nil", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,12 +44,6 @@ func wechatV2SignType(value string) string {
|
||||
return gopayWechat.SignType_MD5
|
||||
}
|
||||
|
||||
func wechatV2Sign(values map[string]string, key, signType string) string {
|
||||
body := toGoPayBodyMap(values)
|
||||
body.Remove("sign")
|
||||
return gopayWechat.GetReleaseSign(key, wechatV2SignType(signType), body)
|
||||
}
|
||||
|
||||
func (a *wechatV2Adapter) Create(ctx context.Context, req *bizpayment.PaymentRequest, c map[string]any) (*bizpayment.PaymentResult, error) {
|
||||
if req == nil {
|
||||
return nil, errors.New("微信支付 v2 下单请求为空")
|
||||
@@ -581,12 +575,6 @@ func nonce() string {
|
||||
}
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
func stringOr(extra map[string]any, key, fallback string) string {
|
||||
if value, ok := extra[key].(string); ok {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
func stringMap(values map[string]any) map[string]string {
|
||||
result := map[string]string{}
|
||||
for key, value := range values {
|
||||
|
||||
@@ -596,10 +596,3 @@ func parseJSONInteger(object map[string]any, key string) (int64, bool, error) {
|
||||
amount, err := parseIntegerAmount(fmt.Sprint(raw))
|
||||
return amount, true, err
|
||||
}
|
||||
|
||||
func decryptWechatV3(ciphertext, nonce, associatedData, key string) ([]byte, error) {
|
||||
if len(key) != 32 {
|
||||
return nil, errors.New("微信支付 v3 api_v3_key 必须为 32 字节")
|
||||
}
|
||||
return gopayWechatV3.V3DecryptNotifyCipherTextToBytes(ciphertext, nonce, associatedData, key)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ package integration
|
||||
|
||||
import (
|
||||
integrationbiz "kra/internal/biz/integration"
|
||||
bizpayment "kra/internal/biz/payment"
|
||||
"kra/internal/biz/system"
|
||||
"kra/internal/integration/cache"
|
||||
"kra/internal/integration/email"
|
||||
mqintegration "kra/internal/integration/mq"
|
||||
paymentintegration "kra/internal/integration/payment"
|
||||
"kra/internal/integration/storage"
|
||||
websocketintegration "kra/internal/integration/websocket"
|
||||
"kra/pkg/mq"
|
||||
@@ -20,6 +22,8 @@ import (
|
||||
var ProviderSet = wire.NewSet(
|
||||
cache.New,
|
||||
email.NewEmailRepo,
|
||||
paymentintegration.NewFactory,
|
||||
wire.Bind(new(bizpayment.PaymentAdapterFactory), new(*paymentintegration.Factory)),
|
||||
storage.NewFileStorage,
|
||||
NewConnectivityTester,
|
||||
wire.Bind(new(integrationbiz.IntegrationConnectionTester), new(*ConnectivityTester)),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type contextFieldsKey struct{}
|
||||
|
||||
// ContextFields contains request metadata attached to logs emitted with a
|
||||
// request context.
|
||||
type ContextFields struct {
|
||||
RequestID string
|
||||
TraceID string
|
||||
SpanID string
|
||||
ParentSpanID string
|
||||
DeviceID string
|
||||
ClientIP string
|
||||
HTTPMethod string
|
||||
HTTPPath string
|
||||
}
|
||||
|
||||
func WithContextFields(ctx context.Context, fields *ContextFields) context.Context {
|
||||
return context.WithValue(ctx, contextFieldsKey{}, fields)
|
||||
}
|
||||
|
||||
func ContextFieldsFrom(ctx context.Context) *ContextFields {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
fields, _ := ctx.Value(contextFieldsKey{}).(*ContextFields)
|
||||
return fields
|
||||
}
|
||||
|
||||
type contextHandler struct{ handler slog.Handler }
|
||||
|
||||
func (h *contextHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
return h.handler.Enabled(ctx, level)
|
||||
}
|
||||
|
||||
func (h *contextHandler) Handle(ctx context.Context, record slog.Record) error {
|
||||
if fields := ContextFieldsFrom(ctx); fields != nil {
|
||||
record.AddAttrs(
|
||||
slog.String("request_id", fields.RequestID),
|
||||
slog.String("trace_id", fields.TraceID),
|
||||
slog.String("device_id", fields.DeviceID),
|
||||
slog.String("client_ip", fields.ClientIP),
|
||||
slog.String("http_method", fields.HTTPMethod),
|
||||
slog.String("http_path", fields.HTTPPath),
|
||||
)
|
||||
if fields.SpanID != "" {
|
||||
record.AddAttrs(slog.String("span_id", fields.SpanID))
|
||||
}
|
||||
if fields.ParentSpanID != "" {
|
||||
record.AddAttrs(slog.String("parent_span_id", fields.ParentSpanID))
|
||||
}
|
||||
}
|
||||
return h.handler.Handle(ctx, record)
|
||||
}
|
||||
|
||||
func (h *contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return &contextHandler{handler: h.handler.WithAttrs(attrs)}
|
||||
}
|
||||
|
||||
func (h *contextHandler) WithGroup(name string) slog.Handler {
|
||||
return &contextHandler{handler: h.handler.WithGroup(name)}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DailyWriter writes log entries to <root>/<yyyy-mm-dd>/<name>. It keeps the
|
||||
// file handle open for the active day and rotates on the first write after
|
||||
// midnight, matching the directory layout consumed by the log viewer.
|
||||
type DailyWriter struct {
|
||||
mu sync.Mutex
|
||||
root string
|
||||
name string
|
||||
date string
|
||||
file *os.File
|
||||
retentionDay int
|
||||
}
|
||||
|
||||
func NewDailyWriter(root, name string, retentionDay int) *DailyWriter {
|
||||
w := &DailyWriter{root: root, name: name, retentionDay: retentionDay}
|
||||
w.removeExpired()
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *DailyWriter) removeExpired() {
|
||||
if w.retentionDay <= 0 {
|
||||
return
|
||||
}
|
||||
entries, err := os.ReadDir(w.root)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().AddDate(0, 0, -w.retentionDay)
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
date, err := time.Parse("2006-01-02", entry.Name())
|
||||
if err == nil && date.Before(cutoff) {
|
||||
_ = os.RemoveAll(filepath.Join(w.root, entry.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *DailyWriter) Write(value []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
date := time.Now().Format("2006-01-02")
|
||||
if w.file == nil || w.date != date {
|
||||
if w.file != nil {
|
||||
_ = w.file.Close()
|
||||
}
|
||||
filePath := filepath.Join(w.root, date, filepath.Clean(w.name))
|
||||
directory := filepath.Dir(filePath)
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
w.file, w.date = file, date
|
||||
}
|
||||
return w.file.Write(value)
|
||||
}
|
||||
|
||||
func (w *DailyWriter) Sync() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.file == nil {
|
||||
return nil
|
||||
}
|
||||
return w.file.Sync()
|
||||
}
|
||||
|
||||
func (w *DailyWriter) Close() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.file == nil {
|
||||
return nil
|
||||
}
|
||||
err := w.file.Close()
|
||||
w.file = nil
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type stackFrame struct {
|
||||
File string
|
||||
Line int
|
||||
Func string
|
||||
}
|
||||
|
||||
var stackFileLinePattern = regexp.MustCompile(`\s*(.+\.go):(\d+)\s*$`)
|
||||
|
||||
func finalApplicationCaller(stack string) (stackFrame, bool) {
|
||||
if stack == "" {
|
||||
return stackFrame{}, false
|
||||
}
|
||||
functionName := ""
|
||||
for _, raw := range strings.Split(stack, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
matches := stackFileLinePattern.FindStringSubmatch(line)
|
||||
if matches == nil {
|
||||
functionName = line
|
||||
continue
|
||||
}
|
||||
lineNumber, _ := strconv.Atoi(matches[2])
|
||||
if skipStackFile(matches[1]) {
|
||||
functionName = ""
|
||||
continue
|
||||
}
|
||||
return stackFrame{File: matches[1], Line: lineNumber, Func: functionName}, true
|
||||
}
|
||||
return stackFrame{}, false
|
||||
}
|
||||
|
||||
func skipStackFile(filename string) bool {
|
||||
normalized := strings.ReplaceAll(filename, "\\", "/")
|
||||
for _, marker := range []string{
|
||||
"/go/pkg/mod/",
|
||||
"/go.uber.org/",
|
||||
"/gorm.io/",
|
||||
"/pkg/logging/",
|
||||
"/internal/transport/middleware/",
|
||||
"/internal/transport/router/",
|
||||
"/internal/server/handler/",
|
||||
"/internal/server/middleware/",
|
||||
"/internal/server/router/",
|
||||
"/internal/server/httpx/",
|
||||
"/internal/server/middleware_",
|
||||
"/internal/server/route_",
|
||||
} {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.Contains(normalized, "/src/") &&
|
||||
(strings.Contains(normalized, "/go/go") || strings.Contains(normalized, "/go/src/"))
|
||||
}
|
||||
|
||||
func functionSourceAt(filename string, line int) (name, source string, startLine, endLine int, err error) {
|
||||
content, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return "", "", 0, 0, fmt.Errorf("read file failed: %w", err)
|
||||
}
|
||||
files := token.NewFileSet()
|
||||
parsed, err := parser.ParseFile(files, filename, content, parser.ParseComments)
|
||||
if err != nil {
|
||||
return "", "", 0, 0, fmt.Errorf("parse file failed: %w", err)
|
||||
}
|
||||
var target *ast.FuncDecl
|
||||
ast.Inspect(parsed, func(node ast.Node) bool {
|
||||
declaration, ok := node.(*ast.FuncDecl)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
start := files.Position(declaration.Pos()).Line
|
||||
end := files.Position(declaration.End()).Line
|
||||
if line >= start && line <= end {
|
||||
target, startLine, endLine = declaration, start, end
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if target == nil {
|
||||
return "", "", 0, 0, fmt.Errorf("no function encloses line %d in %s", line, filename)
|
||||
}
|
||||
start := files.Position(target.Pos()).Offset
|
||||
end := files.Position(target.End()).Offset
|
||||
if start < 0 || end > len(content) || start >= end {
|
||||
return "", "", 0, 0, fmt.Errorf("invalid offsets for function: start=%d end=%d len=%d", start, end, len(content))
|
||||
}
|
||||
return target.Name.Name, string(content[start:end]), startLine, endLine, nil
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-kratos/kratos/contrib/otel/v3/tracing"
|
||||
kratoslog "github.com/go-kratos/kratos/v3/log"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/exp/zapslog"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Level, Format, EncodeLevel, Prefix, StacktraceKey string
|
||||
LogInConsole, ShowLine bool
|
||||
RetentionDay int
|
||||
FileOnlyModules []string
|
||||
}
|
||||
|
||||
// ErrorEntry is the storage-neutral representation of an Error-level log.
|
||||
// Keeping it in pkg/logging lets the log core report failures without taking a
|
||||
// dependency on the application service or persistence layers.
|
||||
type ErrorEntry struct {
|
||||
Form, Info, Level, RequestID, TraceID string
|
||||
}
|
||||
|
||||
// ErrorSink receives Error-level log entries. The sink must not log failures
|
||||
// through the same logger, otherwise a storage failure could recurse forever.
|
||||
type ErrorSink interface {
|
||||
RecordLogError(context.Context, ErrorEntry) error
|
||||
}
|
||||
|
||||
type ErrorSinkFunc func(context.Context, ErrorEntry) error
|
||||
|
||||
func (f ErrorSinkFunc) RecordLogError(ctx context.Context, entry ErrorEntry) error {
|
||||
return f(ctx, entry)
|
||||
}
|
||||
|
||||
type errorSinkState struct {
|
||||
mu sync.RWMutex
|
||||
sink ErrorSink
|
||||
}
|
||||
|
||||
func (s *errorSinkState) record(entry ErrorEntry) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.RLock()
|
||||
sink := s.sink
|
||||
s.mu.RUnlock()
|
||||
if sink != nil {
|
||||
_ = sink.RecordLogError(context.Background(), entry)
|
||||
}
|
||||
}
|
||||
|
||||
type handlerOperation struct {
|
||||
attrs []slog.Attr
|
||||
group string
|
||||
}
|
||||
|
||||
type reloadableHandlerState struct {
|
||||
mu sync.RWMutex
|
||||
handler slog.Handler
|
||||
cleanup func()
|
||||
}
|
||||
|
||||
type reloadableHandler struct {
|
||||
state *reloadableHandlerState
|
||||
ops []handlerOperation
|
||||
}
|
||||
|
||||
func (h *reloadableHandler) resolved() slog.Handler {
|
||||
current := h.state.handler
|
||||
for _, operation := range h.ops {
|
||||
if operation.group != "" {
|
||||
current = current.WithGroup(operation.group)
|
||||
} else {
|
||||
current = current.WithAttrs(operation.attrs)
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func (h *reloadableHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
h.state.mu.RLock()
|
||||
defer h.state.mu.RUnlock()
|
||||
return h.resolved().Enabled(ctx, level)
|
||||
}
|
||||
|
||||
func (h *reloadableHandler) Handle(ctx context.Context, record slog.Record) error {
|
||||
h.state.mu.RLock()
|
||||
defer h.state.mu.RUnlock()
|
||||
return h.resolved().Handle(ctx, record)
|
||||
}
|
||||
|
||||
func (h *reloadableHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
next := append([]handlerOperation(nil), h.ops...)
|
||||
next = append(next, handlerOperation{attrs: append([]slog.Attr(nil), attrs...)})
|
||||
return &reloadableHandler{state: h.state, ops: next}
|
||||
}
|
||||
|
||||
func (h *reloadableHandler) WithGroup(name string) slog.Handler {
|
||||
next := append([]handlerOperation(nil), h.ops...)
|
||||
next = append(next, handlerOperation{group: name})
|
||||
return &reloadableHandler{state: h.state, ops: next}
|
||||
}
|
||||
|
||||
type ReloadableLogger struct {
|
||||
state *reloadableHandlerState
|
||||
filename string
|
||||
errorSink *errorSinkState
|
||||
}
|
||||
|
||||
func (l *ReloadableLogger) Reload(root string, options Options) {
|
||||
handler, cleanup := newZapHandler(root, l.filename, options, l.errorSink)
|
||||
l.state.mu.Lock()
|
||||
previous := l.state.cleanup
|
||||
l.state.handler = handler
|
||||
l.state.cleanup = cleanup
|
||||
l.state.mu.Unlock()
|
||||
if previous != nil {
|
||||
previous()
|
||||
}
|
||||
}
|
||||
|
||||
// SetErrorSink changes the database/audit target while keeping the current
|
||||
// logger and its hot-reload state intact.
|
||||
func (l *ReloadableLogger) SetErrorSink(sink ErrorSink) {
|
||||
if l == nil || l.errorSink == nil {
|
||||
return
|
||||
}
|
||||
l.errorSink.mu.Lock()
|
||||
l.errorSink.sink = sink
|
||||
l.errorSink.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *ReloadableLogger) Close() {
|
||||
l.state.mu.Lock()
|
||||
cleanup := l.state.cleanup
|
||||
l.state.cleanup = nil
|
||||
l.state.mu.Unlock()
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
type consoleSummaryCore struct {
|
||||
level zapcore.LevelEnabler
|
||||
output zapcore.WriteSyncer
|
||||
prefix string
|
||||
fileOnly map[string]struct{}
|
||||
fields []zapcore.Field
|
||||
}
|
||||
|
||||
func (c *consoleSummaryCore) Enabled(level zapcore.Level) bool { return c.level.Enabled(level) }
|
||||
|
||||
func (c *consoleSummaryCore) With(fields []zapcore.Field) zapcore.Core {
|
||||
inherited := append([]zapcore.Field(nil), c.fields...)
|
||||
inherited = append(inherited, fields...)
|
||||
return &consoleSummaryCore{level: c.level, output: c.output, prefix: c.prefix, fileOnly: c.fileOnly, fields: inherited}
|
||||
}
|
||||
|
||||
func (c *consoleSummaryCore) Check(entry zapcore.Entry, checked *zapcore.CheckedEntry) *zapcore.CheckedEntry {
|
||||
if c.Enabled(entry.Level) {
|
||||
return checked.AddCore(entry, c)
|
||||
}
|
||||
return checked
|
||||
}
|
||||
|
||||
func (c *consoleSummaryCore) Write(entry zapcore.Entry, fields []zapcore.Field) error {
|
||||
allFields := append([]zapcore.Field(nil), c.fields...)
|
||||
allFields = append(allFields, fields...)
|
||||
module := moduleField(allFields)
|
||||
if _, excluded := c.fileOnly[module]; excluded {
|
||||
return nil
|
||||
}
|
||||
_, err := c.output.Write([]byte(formatConsoleEntry(entry, module, allFields, c.prefix)))
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *consoleSummaryCore) Sync() error { return c.output.Sync() }
|
||||
|
||||
var hiddenConsoleFields = map[string]struct{}{
|
||||
"mod": {}, "service.id": {}, "service.name": {}, "service.version": {},
|
||||
"node": {}, "app_id": {}, "env": {}, "trace_id": {}, "span_id": {},
|
||||
}
|
||||
|
||||
func formatConsoleEntry(entry zapcore.Entry, module string, fields []zapcore.Field, prefix string) string {
|
||||
values := zapcore.NewMapObjectEncoder()
|
||||
for _, field := range fields {
|
||||
field.AddTo(values)
|
||||
}
|
||||
keys := make([]string, 0, len(values.Fields))
|
||||
for key, value := range values.Fields {
|
||||
if _, hidden := hiddenConsoleFields[key]; hidden || consoleValueEmpty(value) {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
caller := ""
|
||||
if entry.Caller.Defined {
|
||||
caller = filepath.Base(entry.Caller.File) + fmt.Sprintf(":%d", entry.Caller.Line)
|
||||
}
|
||||
if module == "" {
|
||||
module = "app"
|
||||
}
|
||||
parts := []string{
|
||||
prefix + entry.Time.Format("15:04:05.000"),
|
||||
consoleLevel(entry.Level),
|
||||
fmt.Sprintf("%-10s", module),
|
||||
}
|
||||
if caller != "" {
|
||||
parts = append(parts, fmt.Sprintf("%-24s", caller))
|
||||
}
|
||||
parts = append(parts, entry.Message)
|
||||
for _, key := range keys {
|
||||
parts = append(parts, key+"="+formatConsoleValue(values.Fields[key]))
|
||||
}
|
||||
return strings.Join(parts, " ") + "\n"
|
||||
}
|
||||
|
||||
func consoleLevel(level zapcore.Level) string {
|
||||
label := strings.ToUpper(level.String())
|
||||
color := "\x1b[36m"
|
||||
switch {
|
||||
case level >= zapcore.ErrorLevel:
|
||||
color = "\x1b[31m"
|
||||
case level == zapcore.WarnLevel:
|
||||
color = "\x1b[33m"
|
||||
case level == zapcore.InfoLevel:
|
||||
color = "\x1b[32m"
|
||||
}
|
||||
return color + fmt.Sprintf("%-5s", label) + "\x1b[0m"
|
||||
}
|
||||
|
||||
func consoleValueEmpty(value any) bool {
|
||||
return value == nil || value == ""
|
||||
}
|
||||
|
||||
func formatConsoleValue(value any) string {
|
||||
text := fmt.Sprint(value)
|
||||
text = strings.Join(strings.Fields(text), " ")
|
||||
if len(text) > 180 {
|
||||
text = text[:177] + "..."
|
||||
}
|
||||
if strings.ContainsAny(text, " \t\r\n") {
|
||||
return fmt.Sprintf("%q", text)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func moduleField(fields []zapcore.Field) string {
|
||||
for _, field := range fields {
|
||||
if field.Key == "mod" {
|
||||
if field.String != "" {
|
||||
return field.String
|
||||
}
|
||||
if field.Interface != nil {
|
||||
return fmt.Sprint(field.Interface)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type routedFileState struct {
|
||||
mu sync.Mutex
|
||||
writers map[string]*DailyWriter
|
||||
}
|
||||
|
||||
type routedFileCore struct {
|
||||
base zapcore.Core
|
||||
encoder zapcore.Encoder
|
||||
level zapcore.LevelEnabler
|
||||
root string
|
||||
retentionDay int
|
||||
state *routedFileState
|
||||
fields []zapcore.Field
|
||||
errorSink *errorSinkState
|
||||
}
|
||||
|
||||
func (c *routedFileCore) Enabled(level zapcore.Level) bool { return c.level.Enabled(level) }
|
||||
|
||||
func (c *routedFileCore) With(fields []zapcore.Field) zapcore.Core {
|
||||
inherited := append([]zapcore.Field(nil), c.fields...)
|
||||
inherited = append(inherited, fields...)
|
||||
return &routedFileCore{base: c.base.With(fields), encoder: c.encoder, level: c.level, root: c.root, retentionDay: c.retentionDay, state: c.state, fields: inherited, errorSink: c.errorSink}
|
||||
}
|
||||
|
||||
func (c *routedFileCore) Check(entry zapcore.Entry, checked *zapcore.CheckedEntry) *zapcore.CheckedEntry {
|
||||
if c.Enabled(entry.Level) {
|
||||
return checked.AddCore(entry, c)
|
||||
}
|
||||
return checked
|
||||
}
|
||||
|
||||
func (c *routedFileCore) Write(entry zapcore.Entry, fields []zapcore.Field) error {
|
||||
baseErr := c.base.Write(entry, fields)
|
||||
allFields := append([]zapcore.Field(nil), c.fields...)
|
||||
allFields = append(allFields, fields...)
|
||||
if entry.Level >= zapcore.ErrorLevel && !isGORMLoggerEntry(entry.Caller.File, allFields) {
|
||||
c.errorSink.record(errorEntryFromZap(entry, allFields))
|
||||
}
|
||||
paths := routedLogPaths(moduleField(allFields), entry.Level)
|
||||
if len(paths) == 0 {
|
||||
return baseErr
|
||||
}
|
||||
buffer, err := c.encoder.Clone().EncodeEntry(entry, allFields)
|
||||
if err != nil {
|
||||
if baseErr != nil {
|
||||
return baseErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer buffer.Free()
|
||||
for _, name := range paths {
|
||||
writer := c.writer(name)
|
||||
if _, writeErr := writer.Write(buffer.Bytes()); writeErr != nil && baseErr == nil {
|
||||
baseErr = writeErr
|
||||
}
|
||||
}
|
||||
return baseErr
|
||||
}
|
||||
|
||||
func isGORMLoggerEntry(filename string, fields []zapcore.Field) bool {
|
||||
for _, field := range fields {
|
||||
if field.Key == "gorm_logger" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
normalized := strings.ReplaceAll(filename, "\\", "/")
|
||||
return strings.HasSuffix(normalized, "/gorm_logger_writer.go") ||
|
||||
strings.HasSuffix(normalized, "/pkg/database/gormkit/logger.go")
|
||||
}
|
||||
|
||||
func errorEntryFromZap(entry zapcore.Entry, fields []zapcore.Field) ErrorEntry {
|
||||
requestID, traceID, errorText := "", "", ""
|
||||
for _, field := range fields {
|
||||
switch field.Key {
|
||||
case "request_id":
|
||||
if requestID == "" {
|
||||
requestID = zapFieldString(field)
|
||||
}
|
||||
case "trace_id":
|
||||
if traceID == "" {
|
||||
traceID = zapFieldString(field)
|
||||
}
|
||||
case "error", "err":
|
||||
if errorText == "" {
|
||||
errorText = zapFieldString(field)
|
||||
}
|
||||
}
|
||||
}
|
||||
info := entry.Message
|
||||
if errorText != "" {
|
||||
info += " | 错误: " + errorText
|
||||
}
|
||||
if entry.Caller.File != "" {
|
||||
info += fmt.Sprintf(" \n 源文件:%s:%d", entry.Caller.File, entry.Caller.Line)
|
||||
}
|
||||
if entry.Stack != "" {
|
||||
info += " \n 调用栈:" + entry.Stack
|
||||
if frame, ok := finalApplicationCaller(entry.Stack); ok {
|
||||
functionName, source, startLine, endLine, err := functionSourceAt(frame.File, frame.Line)
|
||||
if err == nil {
|
||||
info += fmt.Sprintf(" \n 最终调用方法:%s:%d (%s lines %d-%d)\n----- 产生日志的方法代码如下 -----\n%s", frame.File, frame.Line, functionName, startLine, endLine, source)
|
||||
} else {
|
||||
info += fmt.Sprintf(" \n 最终调用方法:%s:%d (%s) | extract_err=%v", frame.File, frame.Line, functionName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ErrorEntry{Form: "后端", Info: info, Level: entry.Level.String(), RequestID: requestID, TraceID: traceID}
|
||||
}
|
||||
|
||||
func zapFieldString(field zapcore.Field) string {
|
||||
if field.String != "" {
|
||||
return field.String
|
||||
}
|
||||
if field.Interface != nil {
|
||||
return fmt.Sprint(field.Interface)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *routedFileCore) Sync() error {
|
||||
result := c.base.Sync()
|
||||
c.state.mu.Lock()
|
||||
defer c.state.mu.Unlock()
|
||||
for _, writer := range c.state.writers {
|
||||
if err := writer.Sync(); err != nil && result == nil {
|
||||
result = err
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *routedFileCore) writer(name string) *DailyWriter {
|
||||
c.state.mu.Lock()
|
||||
defer c.state.mu.Unlock()
|
||||
writer := c.state.writers[name]
|
||||
if writer == nil {
|
||||
writer = NewDailyWriter(c.root, name, c.retentionDay)
|
||||
c.state.writers[name] = writer
|
||||
}
|
||||
return writer
|
||||
}
|
||||
|
||||
func (c *routedFileCore) Close() {
|
||||
c.state.mu.Lock()
|
||||
defer c.state.mu.Unlock()
|
||||
for name, writer := range c.state.writers {
|
||||
_ = writer.Close()
|
||||
delete(c.state.writers, name)
|
||||
}
|
||||
}
|
||||
|
||||
func routedLogPaths(module string, level zapcore.Level) []string {
|
||||
paths := make([]string, 0, 2)
|
||||
if module = safeModuleName(module); module != "" {
|
||||
switch module {
|
||||
case "http":
|
||||
paths = append(paths, filepath.Join("http", "access.log"))
|
||||
case "timedTask":
|
||||
paths = append(paths, filepath.Join("timedTask", "task.log"))
|
||||
case "error":
|
||||
paths = append(paths, filepath.Join("error", "error.log"))
|
||||
default:
|
||||
paths = append(paths, filepath.Join(module, "application.log"))
|
||||
}
|
||||
}
|
||||
if level >= zapcore.ErrorLevel {
|
||||
errorPath := filepath.Join("error", "error.log")
|
||||
if len(paths) == 0 || paths[len(paths)-1] != errorPath {
|
||||
paths = append(paths, errorPath)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func safeModuleName(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' {
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, value)
|
||||
}
|
||||
|
||||
// NewZapLogger adapts a Zap core to the slog logger used by Kratos v3.
|
||||
// The file layout remains compatible with the administration log viewer.
|
||||
func newZapHandler(root, filename string, options Options, errorSink *errorSinkState) (slog.Handler, func()) {
|
||||
file := NewDailyWriter(root, filename, options.RetentionDay)
|
||||
encoder := zap.NewProductionEncoderConfig()
|
||||
encoder.EncodeTime = zapcore.RFC3339NanoTimeEncoder
|
||||
if options.StacktraceKey != "" {
|
||||
encoder.StacktraceKey = options.StacktraceKey
|
||||
}
|
||||
switch options.EncodeLevel {
|
||||
case "CapitalLevelEncoder":
|
||||
encoder.EncodeLevel = zapcore.CapitalLevelEncoder
|
||||
case "CapitalColorLevelEncoder":
|
||||
encoder.EncodeLevel = zapcore.CapitalColorLevelEncoder
|
||||
case "LowercaseColorLevelEncoder":
|
||||
encoder.EncodeLevel = zapcore.LowercaseColorLevelEncoder
|
||||
default:
|
||||
encoder.EncodeLevel = zapcore.LowercaseLevelEncoder
|
||||
}
|
||||
var outputEncoder zapcore.Encoder = zapcore.NewJSONEncoder(encoder)
|
||||
if options.Format != "" && options.Format != "json" {
|
||||
if options.Prefix != "" {
|
||||
encoder.EncodeTime = func(value time.Time, output zapcore.PrimitiveArrayEncoder) {
|
||||
output.AppendString(options.Prefix + value.Format("2006-01-02 15:04:05.000"))
|
||||
}
|
||||
}
|
||||
outputEncoder = zapcore.NewConsoleEncoder(encoder)
|
||||
}
|
||||
level := zap.DebugLevel
|
||||
// zapcore.Level.Set reports an error but does not make the intended
|
||||
// configuration handling obvious here. Parse the configured value directly
|
||||
// so debug/warn/error are actually applied after startup and hot reload.
|
||||
if value := strings.TrimSpace(strings.ToLower(options.Level)); value != "" {
|
||||
if err := level.UnmarshalText([]byte(value)); err != nil {
|
||||
level = zap.DebugLevel
|
||||
}
|
||||
}
|
||||
levelEnabler := zap.NewAtomicLevelAt(level)
|
||||
fileCore := zapcore.NewCore(
|
||||
outputEncoder.Clone(),
|
||||
zapcore.AddSync(file),
|
||||
levelEnabler,
|
||||
)
|
||||
core := zapcore.Core(fileCore)
|
||||
if options.LogInConsole {
|
||||
fileOnly := make(map[string]struct{}, len(options.FileOnlyModules))
|
||||
for _, module := range options.FileOnlyModules {
|
||||
fileOnly[module] = struct{}{}
|
||||
}
|
||||
consoleCore := &consoleSummaryCore{level: levelEnabler, output: zapcore.AddSync(os.Stdout), prefix: options.Prefix, fileOnly: fileOnly}
|
||||
core = zapcore.NewTee(fileCore, consoleCore)
|
||||
}
|
||||
routed := &routedFileCore{base: core, encoder: outputEncoder.Clone(), level: levelEnabler, root: root, retentionDay: options.RetentionDay, state: &routedFileState{writers: map[string]*DailyWriter{}}, errorSink: errorSink}
|
||||
zapLogger := zap.New(routed)
|
||||
handlerOptions := []zapslog.HandlerOption{zapslog.AddStacktraceAt(slog.LevelError)}
|
||||
if options.ShowLine {
|
||||
handlerOptions = append(handlerOptions, zapslog.WithCaller(true))
|
||||
}
|
||||
handler := zapslog.NewHandler(zapLogger.Core(), handlerOptions...)
|
||||
cleanup := func() {
|
||||
_ = zapLogger.Sync()
|
||||
routed.Close()
|
||||
_ = file.Close()
|
||||
}
|
||||
return handler, cleanup
|
||||
}
|
||||
|
||||
// NewReloadableZapLogger keeps the slog/Kratos adapter stable while replacing
|
||||
// the underlying Zap core when the runtime configuration changes.
|
||||
func NewReloadableZapLogger(root, filename string, options Options, attrs ...any) (*slog.Logger, *ReloadableLogger) {
|
||||
errorSink := &errorSinkState{}
|
||||
baseHandler, cleanup := newZapHandler(root, filename, options, errorSink)
|
||||
state := &reloadableHandlerState{handler: baseHandler, cleanup: cleanup}
|
||||
control := &ReloadableLogger{state: state, filename: filename, errorSink: errorSink}
|
||||
handler := &contextHandler{handler: &reloadableHandler{state: state}}
|
||||
logger := kratoslog.NewLogger(handler, kratoslog.WithExtractor(tracing.TraceAttrs)).With(attrs...)
|
||||
return logger, control
|
||||
}
|
||||
|
||||
func NewZapLogger(root, filename string, options Options, attrs ...any) (*slog.Logger, func()) {
|
||||
logger, control := NewReloadableZapLogger(root, filename, options, attrs...)
|
||||
return logger, control.Close
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
func TestZapHandlerHonorsConfiguredLevel(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
handler, cleanup := newZapHandler(root, "application.log", Options{Level: "error", Format: "json"}, nil)
|
||||
defer cleanup()
|
||||
|
||||
if handler.Enabled(context.Background(), slog.LevelInfo) {
|
||||
t.Fatal("info should be disabled when level is error")
|
||||
}
|
||||
if !handler.Enabled(context.Background(), slog.LevelError) {
|
||||
t.Fatal("error should be enabled when level is error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZapHandlerUsesDebugFallbackForInvalidLevel(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
handler, cleanup := newZapHandler(root, "application.log", Options{Level: "not-a-level", Format: "json"}, nil)
|
||||
defer cleanup()
|
||||
|
||||
if !handler.Enabled(context.Background(), slog.LevelDebug) {
|
||||
t.Fatal("invalid levels should use the reference debug fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZapHandlerRoutesHTTPAndErrorLogs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
handler, cleanup := newZapHandler(root, "application.log", Options{Level: "info", Format: "json"}, nil)
|
||||
logger := slog.New(handler)
|
||||
logger.Info("request", "mod", "http", "request_id", "req-1")
|
||||
logger.Error("failed", "mod", "users")
|
||||
cleanup()
|
||||
|
||||
dateEntries, err := os.ReadDir(root)
|
||||
if err != nil || len(dateEntries) != 1 {
|
||||
t.Fatalf("expected one daily log directory, entries=%v err=%v", len(dateEntries), err)
|
||||
}
|
||||
date := dateEntries[0].Name()
|
||||
paths := []string{
|
||||
filepath.Join(root, date, "application.log"),
|
||||
filepath.Join(root, date, "http", "access.log"),
|
||||
filepath.Join(root, date, "users", "application.log"),
|
||||
filepath.Join(root, date, "error", "error.log"),
|
||||
}
|
||||
for _, path := range paths {
|
||||
if info, err := os.Stat(path); err != nil || info.Size() == 0 {
|
||||
t.Fatalf("expected non-empty routed log %s: info=%v err=%v", path, info, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleEncoderDoesNotChangeJSONFileEncoder(t *testing.T) {
|
||||
config := zap.NewProductionEncoderConfig()
|
||||
config.EncodeTime = zapcore.RFC3339NanoTimeEncoder
|
||||
fileEncoder := zapcore.NewJSONEncoder(config)
|
||||
entry := zapcore.Entry{Level: zapcore.InfoLevel, Time: time.Date(2026, 8, 21, 16, 30, 0, 0, time.Local), Message: "started"}
|
||||
buffer, err := fileEncoder.EncodeEntry(entry, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer buffer.Free()
|
||||
if !strings.HasPrefix(buffer.String(), "{") || !strings.Contains(buffer.String(), `"msg":"started"`) {
|
||||
t.Fatalf("file encoder is no longer JSON: %s", buffer.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleSummaryHidesInfrastructureNoise(t *testing.T) {
|
||||
entry := zapcore.Entry{
|
||||
Level: zapcore.InfoLevel,
|
||||
Time: time.Date(2026, 8, 21, 16, 50, 53, 145000000, time.Local),
|
||||
Message: "register swagger handler",
|
||||
Caller: zapcore.NewEntryCaller(0, `D:\workspace\kra\internal\server\swagger.go`, 55, true),
|
||||
}
|
||||
line := formatConsoleEntry(entry, "system", []zapcore.Field{
|
||||
zap.String("service.id", "DESKTOP"), zap.String("app_id", "kra"),
|
||||
zap.String("trace_id", ""), zap.String("path", "/swagger/*any"),
|
||||
}, "[kra] ")
|
||||
for _, want := range []string{"[kra] 16:50:53.145", "INFO", "system", "swagger.go:55", "register swagger handler", "path=/swagger/*any"} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Fatalf("console line %q does not contain %q", line, want)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"service.id", "DESKTOP", "app_id", "trace_id", `D:\workspace`} {
|
||||
if strings.Contains(line, unwanted) {
|
||||
t.Fatalf("console line still contains %q: %s", unwanted, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZapHandlerRecordsEveryErrorThroughSink(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var entries []ErrorEntry
|
||||
logger, control := NewReloadableZapLogger(root, "application.log", Options{Level: "info", Format: "json"})
|
||||
defer control.Close()
|
||||
control.SetErrorSink(ErrorSinkFunc(func(_ context.Context, entry ErrorEntry) error {
|
||||
entries = append(entries, entry)
|
||||
return nil
|
||||
}))
|
||||
logger.Error("task execution failed", "mod", "timedTask", "request_id", "request-1", "trace_id", "trace-1", "error", os.ErrPermission)
|
||||
control.Reload(t.TempDir(), Options{Level: "info", Format: "json"})
|
||||
logger.Error("reloaded logger failure", "mod", "system")
|
||||
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("expected two error entries across reload, got %d", len(entries))
|
||||
}
|
||||
entry := entries[0]
|
||||
if entry.Form != "后端" || entry.Level != "error" || entry.RequestID != "request-1" || entry.TraceID != "trace-1" || !strings.Contains(entry.Info, "task execution failed") || !strings.Contains(entry.Info, os.ErrPermission.Error()) {
|
||||
t.Fatalf("unexpected error entry: %+v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorEntryIncludesFinalApplicationSource(t *testing.T) {
|
||||
filename := filepath.Join(t.TempDir(), "worker.go")
|
||||
content := "package sample\n\nfunc execute() {\n\tprintln(\"failed\")\n}\n"
|
||||
if err := os.WriteFile(filename, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := errorEntryFromZap(zapcore.Entry{Message: "task failed", Level: zapcore.ErrorLevel, Stack: "kra/internal/worker.execute\n" + filename + ":4"}, nil)
|
||||
if !strings.Contains(entry.Info, "最终调用方法:"+filename+":4 (execute lines 3-5)") || !strings.Contains(entry.Info, "func execute()") {
|
||||
t.Fatalf("expected final caller source in error entry: %s", entry.Info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipStackFileRecognizesServerTransportPackages(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{name: "handler", path: `D:\workspace\app\system\internal\server\handler\user.go`, want: true},
|
||||
{name: "middleware", path: `D:\workspace\app\system\internal\server\middleware\auth.go`, want: true},
|
||||
{name: "router", path: `/workspace/internal/server/router/user.go`, want: true},
|
||||
{name: "http helper", path: `/workspace/internal/server/httpx/response.go`, want: true},
|
||||
{name: "legacy flattened middleware", path: `D:\workspace\app\system\internal\server\middleware_auth.go`, want: true},
|
||||
{name: "legacy flattened route", path: `/workspace/internal/server/route_user.go`, want: true},
|
||||
{name: "handler remains application boundary", path: `/workspace/internal/server/handler_user.go`, want: false},
|
||||
{name: "legacy middleware", path: `/workspace/internal/transport/middleware/auth.go`, want: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := skipStackFile(test.path); got != test.want {
|
||||
t.Fatalf("skipStackFile(%q) = %v, want %v", test.path, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorSinkSkipsGORMBridge(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var entries []ErrorEntry
|
||||
state := &errorSinkState{sink: ErrorSinkFunc(func(_ context.Context, entry ErrorEntry) error {
|
||||
entries = append(entries, entry)
|
||||
return nil
|
||||
})}
|
||||
base := zapcore.NewNopCore()
|
||||
core := &routedFileCore{base: base, encoder: zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()), level: zapcore.ErrorLevel, root: root, state: &routedFileState{writers: map[string]*DailyWriter{}}, errorSink: state}
|
||||
defer core.Close()
|
||||
for _, filename := range []string{"/tmp/gorm_logger_writer.go", "/workspace/pkg/database/gormkit/logger.go"} {
|
||||
if err := core.Write(zapcore.Entry{Level: zapcore.ErrorLevel, Message: "database failed", Caller: zapcore.EntryCaller{Defined: true, File: filename, Line: 10}}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := core.Write(zapcore.Entry{Level: zapcore.ErrorLevel, Message: "database failed"}, []zapcore.Field{zap.Bool("gorm_logger", true)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("gorm bridge error must not recurse into sys_error: %+v", entries)
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,13 @@ func Definition() module.Definition {
|
||||
return module.Definition{
|
||||
Name: "task",
|
||||
Migrations: datatask.Migrations(),
|
||||
TimedTasks: []module.TimedTask{
|
||||
{Name: "ClearDB", Description: "定时清理数据库过期日志(操作记录/JWT黑名单/定时任务执行日志)", Spec: "@daily", MethodName: "ClearDB", Enabled: true},
|
||||
{Name: "CleanStaleUploads", Description: "定时清理过期大文件上传会话", Spec: "@hourly", MethodName: "CleanStaleUploads", Enabled: true},
|
||||
},
|
||||
TimedTasks: TimedTasks(),
|
||||
}
|
||||
}
|
||||
|
||||
func TimedTasks() []module.TimedTask {
|
||||
return []module.TimedTask{
|
||||
{Name: "ClearDB", Description: "定时清理数据库过期日志(操作记录/JWT黑名单/定时任务执行日志)", Spec: "@daily", MethodName: "ClearDB", Enabled: true},
|
||||
{Name: "CleanStaleUploads", Description: "定时清理过期大文件上传会话", Spec: "@hourly", MethodName: "CleanStaleUploads", Enabled: true},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Payment Utilities
|
||||
|
||||
Stateless payment protocol helpers live here: amount conversion, status
|
||||
normalization, JSON/XML extraction, and request signing. Provider adapters and
|
||||
their SDK clients remain under `internal/integration/payment`.
|
||||
@@ -0,0 +1,97 @@
|
||||
package paymentkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseIntegerAmount parses an integer amount expressed in the smallest
|
||||
// currency unit. Floating point is intentionally not involved.
|
||||
func ParseIntegerAmount(value string) (int64, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0, errors.New("支付金额为空")
|
||||
}
|
||||
return strconv.ParseInt(value, 10, 64)
|
||||
}
|
||||
|
||||
// ParseDecimalAmount converts a major-unit decimal string to an integer amount
|
||||
// using a power-of-ten scale, without using floating point.
|
||||
func ParseDecimalAmount(value string, scale int64) (int64, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || scale <= 0 {
|
||||
return 0, errors.New("支付金额或换算比例无效")
|
||||
}
|
||||
if strings.HasPrefix(value, "-") {
|
||||
return 0, errors.New("支付金额不能为负数")
|
||||
}
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) > 2 || parts[0] == "" {
|
||||
return 0, fmt.Errorf("支付金额格式错误: %s", value)
|
||||
}
|
||||
digits, err := scaleDigits(scale)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
whole, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
fraction := ""
|
||||
if len(parts) == 2 {
|
||||
fraction = parts[1]
|
||||
}
|
||||
if len(fraction) > digits {
|
||||
return 0, fmt.Errorf("支付金额精度超过 %d 位", digits)
|
||||
}
|
||||
fraction += strings.Repeat("0", digits-len(fraction))
|
||||
fractionValue := int64(0)
|
||||
if fraction != "" {
|
||||
fractionValue, err = strconv.ParseInt(fraction, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if whole > (int64(^uint64(0)>>1)-fractionValue)/scale {
|
||||
return 0, errors.New("支付金额超出范围")
|
||||
}
|
||||
return whole*scale + fractionValue, nil
|
||||
}
|
||||
|
||||
func FormatDecimalAmount(amount, scale int64) (string, error) {
|
||||
if amount <= 0 || scale <= 0 {
|
||||
return "", errors.New("支付金额或换算比例无效")
|
||||
}
|
||||
digits, err := scaleDigits(scale)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if digits == 0 {
|
||||
return strconv.FormatInt(amount, 10), nil
|
||||
}
|
||||
return fmt.Sprintf("%d.%0*d", amount/scale, digits, amount%scale), nil
|
||||
}
|
||||
|
||||
func scaleDigits(scale int64) (int, error) {
|
||||
digits := 0
|
||||
for n := scale; n > 1 && n%10 == 0; n /= 10 {
|
||||
digits++
|
||||
}
|
||||
if scale <= 0 || scale != pow10(digits) {
|
||||
return 0, errors.New("支付金额换算比例必须是 10 的幂")
|
||||
}
|
||||
return digits, nil
|
||||
}
|
||||
|
||||
func pow10(digits int) int64 {
|
||||
value := int64(1)
|
||||
for i := 0; i < digits; i++ {
|
||||
if value > (int64(^uint64(0)>>1))/10 {
|
||||
return 0
|
||||
}
|
||||
value *= 10
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package paymentkit
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDecimalAmountUsesIntegerMath(t *testing.T) {
|
||||
got, err := ParseDecimalAmount("12.34", 100)
|
||||
if err != nil || got != 1234 {
|
||||
t.Fatalf("ParseDecimalAmount = %d, %v", got, err)
|
||||
}
|
||||
formatted, err := FormatDecimalAmount(got, 100)
|
||||
if err != nil || formatted != "12.34" {
|
||||
t.Fatalf("FormatDecimalAmount = %q, %v", formatted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecimalAmountRejectsUnsafeInput(t *testing.T) {
|
||||
for _, value := range []string{"-1.00", "1.001", "1.2.3"} {
|
||||
if _, err := ParseDecimalAmount(value, 100); err == nil {
|
||||
t.Fatalf("ParseDecimalAmount(%q) accepted unsafe input", value)
|
||||
}
|
||||
}
|
||||
if _, err := ParseDecimalAmount("1", 3); err == nil {
|
||||
t.Fatal("non power-of-ten scale accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package paymentkit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func JSONObject(raw []byte) map[string]any {
|
||||
var value any
|
||||
if json.Unmarshal(raw, &value) != nil {
|
||||
return nil
|
||||
}
|
||||
object, _ := value.(map[string]any)
|
||||
return object
|
||||
}
|
||||
|
||||
func NestedString(value any, keys ...string) string {
|
||||
found := nestedValue(value, keys...)
|
||||
if found == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(found))
|
||||
}
|
||||
|
||||
func StringAtPath(value any, path string) string {
|
||||
found := ValueAtPath(value, path)
|
||||
if found == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(found))
|
||||
}
|
||||
|
||||
func ValueAtPath(value any, path string) any {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
current := value
|
||||
for _, part := range strings.Split(path, ".") {
|
||||
object, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
current = object[part]
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func nestedValue(value any, keys ...string) any {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, key := range keys {
|
||||
if found, exists := object[key]; exists && found != nil && fmt.Sprint(found) != "" {
|
||||
return found
|
||||
}
|
||||
}
|
||||
for _, nestedKey := range []string{"data", "result", "order", "transaction", "response"} {
|
||||
if found := nestedValue(object[nestedKey], keys...); found != nil {
|
||||
return found
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package paymentkit
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNestedAndPathValues(t *testing.T) {
|
||||
object := JSONObject([]byte(`{"data":{"status":"SUCCESS","amount":100}}`))
|
||||
if got := NestedString(object, "status"); got != "SUCCESS" {
|
||||
t.Fatalf("NestedString = %q", got)
|
||||
}
|
||||
if got := StringAtPath(object, "data.amount"); got != "100" {
|
||||
t.Fatalf("StringAtPath = %q", got)
|
||||
}
|
||||
if got := StringAtPath(object, "data.missing"); got != "" {
|
||||
t.Fatalf("missing path = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package paymentkit
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func MD5Canonical(values map[string]any, secret string) string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key, value := range values {
|
||||
if key != "sign" && value != nil && fmt.Sprint(value) != "" {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
query := url.Values{}
|
||||
for _, key := range keys {
|
||||
query.Set(key, fmt.Sprint(values[key]))
|
||||
}
|
||||
sum := md5.Sum([]byte(query.Encode() + "&key=" + secret))
|
||||
return strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||
}
|
||||
|
||||
func HMACSHA256Hex(raw []byte, secret string, upper bool) string {
|
||||
h := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = h.Write(raw)
|
||||
result := hex.EncodeToString(h.Sum(nil))
|
||||
if upper {
|
||||
return strings.ToUpper(result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package paymentkit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func NormalizeStatus(value, fallback string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(value)) {
|
||||
case "SUCCESS", "OK", "PAID", "TRADE_SUCCESS", "TRADE_FINISHED", "COMPLETED", "PAY_SUCCESS", "REFUND_SUCCESS", "REFUNDED":
|
||||
return "success"
|
||||
case "WAIT_BUYER_PAY", "USERPAYING", "NOTPAY", "PROCESSING", "PENDING", "CREATED", "ACCEPT", "ACCEPTED", "PAYING":
|
||||
return "pending"
|
||||
case "CLOSED", "TRADE_CLOSED", "CANCELLED", "CANCELED", "REVOKED", "REFUND", "FAILED", "FAIL", "REJECTED", "DENIED", "DECLINED", "ERROR", "PAYERROR":
|
||||
return "failed"
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func ConfiguredInt64(values map[string]any, key string, fallback int64) int64 {
|
||||
value, ok := values[key]
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := ParseIntegerAmount(strings.TrimSpace(toString(value)))
|
||||
if err != nil || parsed <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func ConfiguredValues(values map[string]any, key string) []string {
|
||||
result := []string{}
|
||||
for _, item := range strings.Split(toString(values[key]), ",") {
|
||||
item = strings.ToUpper(strings.TrimSpace(item))
|
||||
if item != "" {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func ContainsFold(values []string, value string) bool {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
for _, item := range values {
|
||||
if item == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func Text(values map[string]any, key string) string {
|
||||
value, _ := values[key].(string)
|
||||
return value
|
||||
}
|
||||
|
||||
func FirstText(values map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := Text(values, key); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func toString(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.ReplaceAll(strings.TrimSpace(stringify(value)), "<nil>", ""))
|
||||
}
|
||||
|
||||
func stringify(value any) string {
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package paymentkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type xmlValue struct {
|
||||
XMLName xml.Name
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
func XMLValues(raw []byte) (map[string]string, error) {
|
||||
decoder := xml.NewDecoder(bytes.NewReader(raw))
|
||||
result := map[string]string{}
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
return result, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
start, ok := token.(xml.StartElement)
|
||||
if !ok || start.Name.Local == "xml" {
|
||||
continue
|
||||
}
|
||||
var value xmlValue
|
||||
if err = decoder.DecodeElement(&value, &start); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value.Value != "" {
|
||||
result[start.Name.Local] = value.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func XMLEncode(values map[string]string) []byte {
|
||||
var b strings.Builder
|
||||
b.WriteString("<xml>")
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
b.WriteString("<")
|
||||
b.WriteString(key)
|
||||
b.WriteString("><![CDATA[")
|
||||
b.WriteString(values[key])
|
||||
b.WriteString("]]></")
|
||||
b.WriteString(key)
|
||||
b.WriteString(">")
|
||||
}
|
||||
b.WriteString("</xml>")
|
||||
return []byte(b.String())
|
||||
}
|
||||
@@ -8,23 +8,17 @@ import (
|
||||
|
||||
"kra/internal/config"
|
||||
websocket "kra/internal/integration/websocket"
|
||||
"kra/internal/server/handler"
|
||||
"kra/internal/server/httpx"
|
||||
"kra/internal/server/middleware"
|
||||
"kra/internal/server/router"
|
||||
"kra/internal/server/staticfiles"
|
||||
"kra/internal/service"
|
||||
"kra/pkg/httpx"
|
||||
systemservice "kra/internal/service/system"
|
||||
platformmodule "kra/pkg/module"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
kratoshttp "github.com/go-kratos/kratos/v3/transport/http"
|
||||
)
|
||||
|
||||
func NewGinEngine(runtime *config.Store, access *service.AccessControlService, handlers *handler.Set, auth *service.AuthService, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string) *gin.Engine {
|
||||
return NewGinEngineWithRuntime(runtime, access, auth, security, audit, logger, version, router.NewRoutes(handlers), nil)
|
||||
}
|
||||
|
||||
func NewGinEngineWithRuntime(runtime *config.Store, access *service.AccessControlService, auth middleware.TokenAuthenticator, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string, routes platformmodule.RouteRegistrar, ws *websocket.Server) *gin.Engine {
|
||||
func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.AccessControlService, auth middleware.TokenAuthenticator, security *systemservice.SecurityService, audit *systemservice.AuditRecorder, logger *slog.Logger, version string, routes platformmodule.RouteRegistrar, ws *websocket.Server) *gin.Engine {
|
||||
if runtime == nil {
|
||||
runtime = config.NewStore(nil)
|
||||
}
|
||||
|
||||
@@ -14,10 +14,16 @@ import (
|
||||
"kra/internal/config"
|
||||
"kra/internal/routecatalog"
|
||||
"kra/internal/server/handler"
|
||||
"kra/internal/server/router"
|
||||
systemservice "kra/internal/service/system"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func NewGinEngine(runtime *config.Store, access *systemservice.AccessControlService, handlers *handler.Set, auth *systemservice.AuthService, security *systemservice.SecurityService, audit *systemservice.AuditRecorder, logger *slog.Logger, version string) *gin.Engine {
|
||||
return NewGinEngineWithRuntime(runtime, access, auth, security, audit, logger, version, router.NewRoutes(handlers), nil)
|
||||
}
|
||||
|
||||
func emptyHandlers() *handler.Set {
|
||||
return &handler.Set{
|
||||
Authority: &handler.Authority{}, Menu: &handler.Menu{}, API: &handler.API{},
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
systemservice "kra/internal/service/system"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Announcement struct{ service *service.AnnouncementService }
|
||||
type Announcement struct {
|
||||
service *systemservice.AnnouncementService
|
||||
}
|
||||
|
||||
func NewAnnouncement(service *service.AnnouncementService) *Announcement {
|
||||
func NewAnnouncement(service *systemservice.AnnouncementService) *Announcement {
|
||||
return &Announcement{service: service}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
systemservice "kra/internal/service/system"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type API struct{ service *service.APIService }
|
||||
type API struct{ service *systemservice.APIService }
|
||||
|
||||
func NewAPI(service *service.APIService) *API { return &API{service: service} }
|
||||
func NewAPI(service *systemservice.APIService) *API { return &API{service: service} }
|
||||
|
||||
func (h *API) List(c *gin.Context) {
|
||||
var req dto.APIListRequest
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
systemservice "kra/internal/service/system"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type APIToken struct {
|
||||
service *service.TokenService
|
||||
service *systemservice.TokenService
|
||||
}
|
||||
|
||||
func NewAPIToken(service *service.TokenService) *APIToken {
|
||||
func NewAPIToken(service *systemservice.TokenService) *APIToken {
|
||||
return &APIToken{service: service}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,20 +7,20 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
systemservice "kra/internal/service/system"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Audit struct {
|
||||
service *service.AuditService
|
||||
recorder *service.AuditRecorder
|
||||
logs *service.LogViewerService
|
||||
service *systemservice.AuditService
|
||||
recorder *systemservice.AuditRecorder
|
||||
logs *systemservice.LogViewerService
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewAudit(service *service.AuditService, recorder *service.AuditRecorder, logs *service.LogViewerService, logger *slog.Logger) *Audit {
|
||||
func NewAudit(service *systemservice.AuditService, recorder *systemservice.AuditRecorder, logs *systemservice.LogViewerService, logger *slog.Logger) *Audit {
|
||||
return &Audit{service: service, recorder: recorder, logs: logs, logger: logger}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ func (h *Audit) Logins(c *gin.Context) {
|
||||
Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
items, total, err := h.service.LoginsFilter(c.Request.Context(), req.Page, req.PageSize, req.Username, req.Status)
|
||||
items, total, err := h.service.LoginsFilter(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
Fail(c, "获取失败")
|
||||
return
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
systemservice "kra/internal/service/system"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Authority struct{ service *service.AuthorityService }
|
||||
type Authority struct {
|
||||
service *systemservice.AuthorityService
|
||||
}
|
||||
|
||||
func NewAuthority(service *service.AuthorityService) *Authority { return &Authority{service: service} }
|
||||
func NewAuthority(service *systemservice.AuthorityService) *Authority {
|
||||
return &Authority{service: service}
|
||||
}
|
||||
|
||||
func (h *Authority) List(c *gin.Context) {
|
||||
items, err := h.service.Authorities(c.Request.Context())
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
systemservice "kra/internal/service/system"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Dictionary struct{ service *service.DictionaryService }
|
||||
type Dictionary struct {
|
||||
service *systemservice.DictionaryService
|
||||
}
|
||||
|
||||
func NewDictionary(service *service.DictionaryService) *Dictionary {
|
||||
func NewDictionary(service *systemservice.DictionaryService) *Dictionary {
|
||||
return &Dictionary{service: service}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
systemservice "kra/internal/service/system"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Email struct{ service *service.EmailService }
|
||||
type Email struct{ service *systemservice.EmailService }
|
||||
|
||||
func NewEmail(service *service.EmailService) *Email { return &Email{service: service} }
|
||||
func NewEmail(service *systemservice.EmailService) *Email { return &Email{service: service} }
|
||||
|
||||
func (h *Email) Test(c *gin.Context) {
|
||||
if err := h.service.Test(c.Request.Context()); err != nil {
|
||||
|
||||
@@ -2,8 +2,8 @@ package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
systemservice "kra/internal/service/system"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -13,10 +13,10 @@ import (
|
||||
)
|
||||
|
||||
type Export struct {
|
||||
service *service.ExportService
|
||||
service *systemservice.ExportService
|
||||
}
|
||||
|
||||
func NewExport(service *service.ExportService) *Export {
|
||||
func NewExport(service *systemservice.ExportService) *Export {
|
||||
return &Export{service: service}
|
||||
}
|
||||
|
||||
@@ -214,9 +214,9 @@ func (h *Export) Download(expectBlank bool) gin.HandlerFunc {
|
||||
value, err := h.service.ConsumeToken(c.Request.Context(), token, expectBlank)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrExportTokenMalformed):
|
||||
case errors.Is(err, systemservice.ErrExportTokenMalformed):
|
||||
Fail(c, "解析导出参数失败")
|
||||
case errors.Is(err, service.ErrExportTokenType):
|
||||
case errors.Is(err, systemservice.ErrExportTokenType):
|
||||
Fail(c, "token类型错误")
|
||||
default:
|
||||
Fail(c, "导出token无效或已过期")
|
||||
|
||||
@@ -5,8 +5,8 @@ package handler
|
||||
|
||||
import (
|
||||
"kra/internal/biz/system"
|
||||
"kra/internal/server/httpx"
|
||||
"kra/internal/server/middleware"
|
||||
"kra/pkg/httpx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
integrationservice "kra/internal/service/integration"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type IntegrationConfig struct {
|
||||
service *service.IntegrationConfigService
|
||||
service *integrationservice.IntegrationConfigService
|
||||
}
|
||||
|
||||
func NewIntegrationConfig(service *service.IntegrationConfigService) *IntegrationConfig {
|
||||
func NewIntegrationConfig(service *integrationservice.IntegrationConfigService) *IntegrationConfig {
|
||||
return &IntegrationConfig{service: service}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,17 +2,17 @@ package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
systemservice "kra/internal/service/system"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Media struct{ service *service.MediaService }
|
||||
type Media struct{ service *systemservice.MediaService }
|
||||
|
||||
func NewMedia(service *service.MediaService) *Media { return &Media{service: service} }
|
||||
func NewMedia(service *systemservice.MediaService) *Media { return &Media{service: service} }
|
||||
|
||||
func (h *Media) limitMultipartBody(c *gin.Context) int64 {
|
||||
limit := h.service.MediaConfig().EffectiveMaxFileSize()
|
||||
|
||||
@@ -2,15 +2,15 @@ package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
systemservice "kra/internal/service/system"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Menu struct{ service *service.MenuService }
|
||||
type Menu struct{ service *systemservice.MenuService }
|
||||
|
||||
func NewMenu(service *service.MenuService) *Menu { return &Menu{service: service} }
|
||||
func NewMenu(service *systemservice.MenuService) *Menu { return &Menu{service: service} }
|
||||
|
||||
func validateMenuRequest(req *dto.MenuRequest) error {
|
||||
if req == nil {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"kra/internal/service"
|
||||
systemservice "kra/internal/service/system"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Navigation struct{ service *service.UserService }
|
||||
type Navigation struct{ service *systemservice.UserService }
|
||||
|
||||
func NewNavigation(service *service.UserService) *Navigation { return &Navigation{service: service} }
|
||||
func NewNavigation(service *systemservice.UserService) *Navigation {
|
||||
return &Navigation{service: service}
|
||||
}
|
||||
func (h *Navigation) Menu(c *gin.Context) {
|
||||
claims := Claims(c)
|
||||
if claims == nil {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
systemservice "kra/internal/service/system"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Organization struct {
|
||||
departments *service.DepartmentService
|
||||
positions *service.PositionService
|
||||
departments *systemservice.DepartmentService
|
||||
positions *systemservice.PositionService
|
||||
}
|
||||
|
||||
func NewOrganization(departments *service.DepartmentService, positions *service.PositionService) *Organization {
|
||||
func NewOrganization(departments *systemservice.DepartmentService, positions *systemservice.PositionService) *Organization {
|
||||
return &Organization{departments: departments, positions: positions}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
systemservice "kra/internal/service/system"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Parameter struct {
|
||||
service *service.ParameterService
|
||||
service *systemservice.ParameterService
|
||||
}
|
||||
|
||||
func NewParameter(service *service.ParameterService) *Parameter {
|
||||
func NewParameter(service *systemservice.ParameterService) *Parameter {
|
||||
return &Parameter{service: service}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,16 +2,18 @@ package handler
|
||||
|
||||
import (
|
||||
"io"
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
paymentservice "kra/internal/service/payment"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Payment struct{ service *service.PaymentService }
|
||||
type Payment struct {
|
||||
service *paymentservice.PaymentService
|
||||
}
|
||||
|
||||
func NewPayment(service *service.PaymentService) *Payment { return &Payment{service: service} }
|
||||
func NewPayment(service *paymentservice.PaymentService) *Payment { return &Payment{service: service} }
|
||||
func (h *Payment) Order(c *gin.Context) {
|
||||
var req dto.PaymentQueryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -148,7 +150,8 @@ func (h *Payment) Callback(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 8<<20)
|
||||
raw, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
writePaymentCallbackAck(c, service.DefaultPaymentCallbackAck(c.Param("provider"), false))
|
||||
_ = c.Error(err)
|
||||
writePaymentCallbackAck(c, paymentservice.DefaultPaymentCallbackAck(c.Param("provider"), false))
|
||||
return
|
||||
}
|
||||
headers := map[string]string{}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
systemservice "kra/internal/service/system"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Permission struct{ service *service.PermissionService }
|
||||
type Permission struct {
|
||||
service *systemservice.PermissionService
|
||||
}
|
||||
|
||||
func NewPermission(service *service.PermissionService) *Permission {
|
||||
func NewPermission(service *systemservice.PermissionService) *Permission {
|
||||
return &Permission{service: service}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,21 +9,21 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
systemservice "kra/internal/service/system"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mojocn/base64Captcha"
|
||||
)
|
||||
|
||||
type Public struct {
|
||||
auth *service.AuthService
|
||||
system *service.SystemConfigService
|
||||
settings *service.SecurityService
|
||||
auth *systemservice.AuthService
|
||||
system *systemservice.SystemConfigService
|
||||
settings *systemservice.SecurityService
|
||||
store *captchaStore
|
||||
}
|
||||
|
||||
func NewPublic(auth *service.AuthService, system *service.SystemConfigService, settings *service.SecurityService) *Public {
|
||||
func NewPublic(auth *systemservice.AuthService, system *systemservice.SystemConfigService, settings *systemservice.SecurityService) *Public {
|
||||
return &Public{auth: auth, system: system, settings: settings, store: &captchaStore{service: settings}}
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ func (h *Public) InitializeDatabase(engine *gin.Engine) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
type captchaStore struct {
|
||||
service *service.SecurityService
|
||||
service *systemservice.SecurityService
|
||||
}
|
||||
|
||||
func (s *captchaStore) Set(id, value string) error {
|
||||
|
||||
@@ -2,14 +2,14 @@ package handler
|
||||
|
||||
import (
|
||||
"kra/internal/server/middleware"
|
||||
"kra/internal/service"
|
||||
systemservice "kra/internal/service/system"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Session struct{ tokens *service.TokenService }
|
||||
type Session struct{ tokens *systemservice.TokenService }
|
||||
|
||||
func NewSession(tokens *service.TokenService) *Session { return &Session{tokens: tokens} }
|
||||
func NewSession(tokens *systemservice.TokenService) *Session { return &Session{tokens: tokens} }
|
||||
func (h *Session) Logout(c *gin.Context) {
|
||||
token := middleware.RequestToken(c, false)
|
||||
if token == "" {
|
||||
|
||||
@@ -3,19 +3,19 @@ package handler
|
||||
import (
|
||||
"errors"
|
||||
"kra/internal/biz/system"
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
systemservice "kra/internal/service/system"
|
||||
"log/slog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type SystemConfig struct {
|
||||
system *service.SystemConfigService
|
||||
settings *service.SecurityService
|
||||
system *systemservice.SystemConfigService
|
||||
settings *systemservice.SecurityService
|
||||
}
|
||||
|
||||
func NewSystemConfig(system *service.SystemConfigService, settings *service.SecurityService) *SystemConfig {
|
||||
func NewSystemConfig(system *systemservice.SystemConfigService, settings *systemservice.SecurityService) *SystemConfig {
|
||||
return &SystemConfig{system: system, settings: settings}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
taskservice "kra/internal/service/task"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
)
|
||||
|
||||
type Task struct {
|
||||
service *service.TaskService
|
||||
service *taskservice.TaskService
|
||||
}
|
||||
|
||||
func NewTask(service *service.TaskService) *Task {
|
||||
func NewTask(service *taskservice.TaskService) *Task {
|
||||
return &Task{service: service}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"kra/internal/service"
|
||||
"errors"
|
||||
"kra/internal/biz/system"
|
||||
"kra/internal/service/dto"
|
||||
systemservice "kra/internal/service/system"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -10,11 +12,11 @@ import (
|
||||
)
|
||||
|
||||
type User struct {
|
||||
service *service.UserService
|
||||
auth *service.AuthService
|
||||
service *systemservice.UserService
|
||||
auth *systemservice.AuthService
|
||||
}
|
||||
|
||||
func NewUser(user *service.UserService, auth *service.AuthService) *User {
|
||||
func NewUser(user *systemservice.UserService, auth *systemservice.AuthService) *User {
|
||||
return &User{service: user, auth: auth}
|
||||
}
|
||||
|
||||
@@ -64,7 +66,7 @@ func (h *User) Create(c *gin.Context) {
|
||||
}
|
||||
result, err := h.service.CreateUserRequest(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
if service.IsPasswordPolicyError(err) {
|
||||
if systemservice.IsPasswordPolicyError(err) {
|
||||
Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -141,7 +143,7 @@ func (h *User) ResetPassword(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if err := h.service.ResetPassword(c.Request.Context(), req.ID, req.Password); err != nil {
|
||||
if service.IsPasswordPolicyError(err) {
|
||||
if systemservice.IsPasswordPolicyError(err) {
|
||||
Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -171,7 +173,7 @@ func (h *User) ChangePassword(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if err := h.service.ChangePassword(c.Request.Context(), claims.ID, req.Password, req.NewPassword); err != nil {
|
||||
if service.IsPasswordPolicyError(err) {
|
||||
if systemservice.IsPasswordPolicyError(err) {
|
||||
Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -206,7 +208,11 @@ func (h *User) SetAuthorities(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if err := h.service.SetUserAuthorities(c.Request.Context(), req.ID, req.AuthorityIDs); err != nil {
|
||||
Fail(c, "修改失败")
|
||||
if errors.Is(err, system.ErrAuthoritiesRequired) {
|
||||
Fail(c, err.Error())
|
||||
} else {
|
||||
Fail(c, "修改失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
Write(c, CodeSuccess, gin.H{}, "修改成功")
|
||||
|
||||
@@ -8,15 +8,15 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
systemservice "kra/internal/service/system"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Version struct{ service *service.VersionService }
|
||||
type Version struct{ service *systemservice.VersionService }
|
||||
|
||||
func NewVersion(service *service.VersionService) *Version { return &Version{service: service} }
|
||||
func NewVersion(service *systemservice.VersionService) *Version { return &Version{service: service} }
|
||||
|
||||
func versionStage(err error) (system.VersionStage, bool) {
|
||||
var stageErr *system.VersionStageError
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Package httpx contains the stable JSON response contract shared by HTTP
|
||||
// modules. It intentionally keeps the existing Gin adapter so callers can
|
||||
// migrate without changing their handler flow.
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
CodeSuccess = 0
|
||||
CodeError = 7
|
||||
CodePasswordChangeRequired = 10001
|
||||
)
|
||||
|
||||
type Response struct {
|
||||
Code int `json:"code"`
|
||||
Data any `json:"data"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
type PageResult struct {
|
||||
List any `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
func Write(c *gin.Context, code int, data any, message string) {
|
||||
c.JSON(http.StatusOK, Response{Code: code, Data: data, Msg: message})
|
||||
}
|
||||
|
||||
func OK(c *gin.Context) { Write(c, CodeSuccess, gin.H{}, "操作成功") }
|
||||
|
||||
func OKWithData(c *gin.Context, data any) { Write(c, CodeSuccess, data, "成功") }
|
||||
|
||||
func Fail(c *gin.Context, message string) { Write(c, CodeError, gin.H{}, message) }
|
||||
|
||||
func NoAuth(c *gin.Context, message string) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, Response{Code: CodeError, Data: nil, Msg: message})
|
||||
}
|
||||
|
||||
func requestUsesHTTPS(request *http.Request) bool {
|
||||
if request != nil && request.TLS != nil {
|
||||
return true
|
||||
}
|
||||
if request == nil {
|
||||
return false
|
||||
}
|
||||
forwarded := strings.SplitN(request.Header.Get("X-Forwarded-Proto"), ",", 2)[0]
|
||||
return strings.EqualFold(strings.TrimSpace(forwarded), "https")
|
||||
}
|
||||
|
||||
// SetTokenCookie is the shared transport-level cookie policy. It has no KRA
|
||||
// business dependency, so handlers and middleware can use pkg/httpx directly.
|
||||
func SetTokenCookie(c *gin.Context, value string, maxAge int) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
cookie := &http.Cookie{Name: "x-token", Value: value, Path: "/", MaxAge: maxAge, Secure: requestUsesHTTPS(c.Request), HttpOnly: true, SameSite: http.SameSiteStrictMode}
|
||||
if maxAge < 0 {
|
||||
cookie.Expires = time.Unix(1, 0)
|
||||
}
|
||||
http.SetCookie(c.Writer, cookie)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestResponseHelpers(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/ok", OK)
|
||||
r.GET("/fail", func(c *gin.Context) { Fail(c, "bad") })
|
||||
r.GET("/auth", func(c *gin.Context) { NoAuth(c, "login") })
|
||||
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
statusCode int
|
||||
body string
|
||||
}{
|
||||
{"/ok", 200, `{"code":0,"data":{},"msg":"操作成功"}`},
|
||||
{"/fail", 200, `{"code":7,"data":{},"msg":"bad"}`},
|
||||
{"/auth", 401, `{"code":7,"data":null,"msg":"login"}`},
|
||||
} {
|
||||
req := httptest.NewRequest("GET", tc.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != tc.statusCode || w.Body.String() != tc.body {
|
||||
t.Fatalf("%s: status=%d body=%q", tc.path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package middleware
|
||||
import (
|
||||
"context"
|
||||
"kra/internal/biz/system"
|
||||
httpx "kra/pkg/httpx"
|
||||
httpx "kra/internal/server/httpx"
|
||||
|
||||
"kra/internal/config"
|
||||
"kra/internal/utils/routepath"
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"kra/internal/biz/system"
|
||||
httpx "kra/pkg/httpx"
|
||||
httpx "kra/internal/server/httpx"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net/http"
|
||||
|
||||
@@ -13,15 +13,15 @@ import (
|
||||
|
||||
"kra/internal/config"
|
||||
"kra/internal/routecatalog"
|
||||
"kra/internal/service"
|
||||
"kra/internal/service/dto"
|
||||
systemservice "kra/internal/service/system"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const ctxOperationAuditPersistFailedKey = "operation_audit_persist_failed"
|
||||
|
||||
func OperationAudit(runtime *config.Store, recorder *service.AuditRecorder) gin.HandlerFunc {
|
||||
func OperationAudit(runtime *config.Store, recorder *systemservice.AuditRecorder) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if runtime == nil || recorder == nil {
|
||||
c.Next()
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"kra/internal/biz/system"
|
||||
httpx "kra/pkg/httpx"
|
||||
httpx "kra/internal/server/httpx"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -68,7 +68,3 @@ func redactJSON(raw []byte, contentType string, limit int) string {
|
||||
}
|
||||
|
||||
func stringValue(value any) string { text, _ := value.(string); return text }
|
||||
|
||||
func isBootstrapPath(path string) bool {
|
||||
return strings.HasSuffix(path, "/health") || strings.Contains(path, "/init/")
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package middleware
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
httpx "kra/pkg/httpx"
|
||||
httpx "kra/internal/server/httpx"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"kra/internal/service"
|
||||
httpx "kra/pkg/httpx"
|
||||
httpx "kra/internal/server/httpx"
|
||||
systemservice "kra/internal/service/system"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SecurityRateLimit(settings *service.SecurityService) gin.HandlerFunc {
|
||||
func SecurityRateLimit(settings *systemservice.SecurityService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := strings.TrimSuffix(c.Request.URL.Path, "/")
|
||||
if !strings.HasSuffix(path, "/base/login") && !strings.HasSuffix(path, "/base/captcha") {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
|
||||
"kra/pkg/logging"
|
||||
"kra/internal/logging"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
@@ -18,17 +18,6 @@ type OperationRecordRequest struct {
|
||||
DeviceID string
|
||||
}
|
||||
|
||||
type DataAccessRecordRequest struct {
|
||||
EventType string
|
||||
Operation string
|
||||
UserID uint
|
||||
AuthorityID uint
|
||||
RequestID string
|
||||
Method string
|
||||
Path string
|
||||
Detail string
|
||||
}
|
||||
|
||||
type DataAccessListRequest struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
@@ -45,10 +34,13 @@ type OperationRecordSearchRequest struct {
|
||||
}
|
||||
|
||||
type LoginLogSearchRequest struct {
|
||||
Page int `form:"page"`
|
||||
PageSize int `form:"pageSize"`
|
||||
Username string `form:"username"`
|
||||
Status *bool `form:"status"`
|
||||
Page int `form:"page"`
|
||||
PageSize int `form:"pageSize"`
|
||||
Username string `form:"username"`
|
||||
IP string `form:"ip"`
|
||||
Status *bool `form:"status"`
|
||||
StartCreatedAt *time.Time `form:"startCreatedAt" time_format:"2006-01-02 15:04:05"`
|
||||
EndCreatedAt *time.Time `form:"endCreatedAt" time_format:"2006-01-02 15:04:05"`
|
||||
}
|
||||
|
||||
type AuditIDQuery struct {
|
||||
|
||||
@@ -65,15 +65,6 @@ type SwitchAuthorityRequest struct {
|
||||
AuthorityID uint `json:"authorityId"`
|
||||
}
|
||||
|
||||
type LoginLogRequest struct {
|
||||
Username string
|
||||
IP string
|
||||
Status bool
|
||||
ErrorMessage string
|
||||
Agent string
|
||||
UserID uint
|
||||
}
|
||||
|
||||
type UserResponse struct {
|
||||
ID uint `json:"ID"`
|
||||
CreatedAt time.Time `json:"CreatedAt"`
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
package service
|
||||
|
||||
// This package is the transport layer's entry point into the service tier.
|
||||
// Concrete implementations live under service/system, service/payment,
|
||||
// service/integration, and service/task; the aliases here give handlers one
|
||||
// import instead of four, and Wire composes the module provider sets directly.
|
||||
|
||||
import (
|
||||
"kra/internal/service/dto"
|
||||
integrationservice "kra/internal/service/integration"
|
||||
paymentservice "kra/internal/service/payment"
|
||||
systemservice "kra/internal/service/system"
|
||||
@@ -15,41 +9,6 @@ import (
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
type AccessControlService = systemservice.AccessControlService
|
||||
type AnnouncementService = systemservice.AnnouncementService
|
||||
type APIService = systemservice.APIService
|
||||
type AuditService = systemservice.AuditService
|
||||
type AuditRecorder = systemservice.AuditRecorder
|
||||
type AuthService = systemservice.AuthService
|
||||
type AuthorityService = systemservice.AuthorityService
|
||||
type DepartmentService = systemservice.DepartmentService
|
||||
type DictionaryService = systemservice.DictionaryService
|
||||
type EmailService = systemservice.EmailService
|
||||
type ExportService = systemservice.ExportService
|
||||
type LogViewerService = systemservice.LogViewerService
|
||||
type MediaService = systemservice.MediaService
|
||||
type MenuService = systemservice.MenuService
|
||||
type ParameterService = systemservice.ParameterService
|
||||
type PermissionService = systemservice.PermissionService
|
||||
type PositionService = systemservice.PositionService
|
||||
type SecurityService = systemservice.SecurityService
|
||||
type SystemConfigService = systemservice.SystemConfigService
|
||||
type TokenService = systemservice.TokenService
|
||||
type VersionService = systemservice.VersionService
|
||||
type PaymentService = paymentservice.PaymentService
|
||||
type IntegrationConfigService = integrationservice.IntegrationConfigService
|
||||
type TaskService = taskservice.TaskService
|
||||
type UserService = systemservice.UserService
|
||||
|
||||
var ErrExportTokenMalformed = systemservice.ErrExportTokenMalformed
|
||||
var ErrExportTokenType = systemservice.ErrExportTokenType
|
||||
|
||||
func IsPasswordPolicyError(err error) bool { return systemservice.IsPasswordPolicyError(err) }
|
||||
|
||||
func DefaultPaymentCallbackAck(provider string, success bool) dto.PaymentCallbackAck {
|
||||
return paymentservice.DefaultPaymentCallbackAck(provider, success)
|
||||
}
|
||||
|
||||
// ProviderSet aggregates all service modules for Wire.
|
||||
var ProviderSet = wire.NewSet(
|
||||
systemservice.ProviderSet,
|
||||
|
||||
@@ -57,6 +57,3 @@ func (s *TokenService) DisableAPIToken(ctx context.Context, id uint) error {
|
||||
func (s *TokenService) BlacklistToken(ctx context.Context, token string) error {
|
||||
return s.uc.BlacklistToken(ctx, token)
|
||||
}
|
||||
func (s *TokenService) IsTokenDisabled(ctx context.Context, token string) (bool, error) {
|
||||
return s.uc.IsTokenDisabled(ctx, token)
|
||||
}
|
||||
|
||||
@@ -53,11 +53,11 @@ func (s *AuditService) DeleteOperations(ctx context.Context, ids []int) error {
|
||||
return s.uc.DeleteOperations(ctx, ids)
|
||||
}
|
||||
|
||||
func (s *AuditRecorder) RecordLogin(ctx context.Context, v *system.LoginLog) error {
|
||||
return s.uc.RecordLogin(ctx, v)
|
||||
}
|
||||
func (s *AuditService) LoginsFilter(ctx context.Context, page, size int, username string, status *bool) ([]*dto.LoginLogResponse, int64, error) {
|
||||
return s.Logins(ctx, page, size, &system.LoginLogFilter{Username: username, Status: status})
|
||||
func (s *AuditService) LoginsFilter(ctx context.Context, request dto.LoginLogSearchRequest) ([]*dto.LoginLogResponse, int64, error) {
|
||||
return s.Logins(ctx, request.Page, request.PageSize, &system.LoginLogFilter{
|
||||
Username: request.Username, IP: request.IP, Status: request.Status,
|
||||
StartCreatedAt: request.StartCreatedAt, EndCreatedAt: request.EndCreatedAt,
|
||||
})
|
||||
}
|
||||
func loginDTO(v *system.LoginLog) *dto.LoginLogResponse {
|
||||
var user any = map[string]any{}
|
||||
@@ -88,9 +88,6 @@ func (s *AuditService) DeleteLogins(ctx context.Context, ids []int) error {
|
||||
return s.uc.DeleteLogins(ctx, ids)
|
||||
}
|
||||
|
||||
func (s *AuditRecorder) RecordDataAccess(ctx context.Context, v *system.DataAccessLog) error {
|
||||
return s.uc.RecordDataAccess(ctx, v)
|
||||
}
|
||||
func (s *AuditService) DataAccessRequest(ctx context.Context, req *dto.DataAccessListRequest) ([]*dto.DataAccessLogResponse, int64, error) {
|
||||
return s.DataAccess(ctx, req.Page, req.PageSize, &system.DataAccessLog{EventType: req.EventType, TargetTable: req.TargetTable})
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user