优化结构
This commit is contained in:
@@ -1,416 +0,0 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
IntegrationKindPayment = "payment"
|
||||
IntegrationKindMQ = "mq"
|
||||
IntegrationKindWebSocket = "websocket"
|
||||
)
|
||||
|
||||
type IntegrationConfig struct {
|
||||
Kind string
|
||||
Provider string
|
||||
Enabled bool
|
||||
Values json.RawMessage
|
||||
}
|
||||
|
||||
type IntegrationConfigField struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type"`
|
||||
Required bool `json:"required"`
|
||||
Secret bool `json:"secret"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Options []IntegrationConfigOption `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
type IntegrationConfigOption struct {
|
||||
Label string `json:"label"`
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
type IntegrationConfigDefinition struct {
|
||||
Kind string `json:"kind"`
|
||||
Provider string `json:"provider"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Fields []IntegrationConfigField `json:"fields"`
|
||||
Defaults map[string]any `json:"defaults"`
|
||||
}
|
||||
|
||||
type IntegrationConfigRepo interface {
|
||||
ListIntegrationConfigs(context.Context, string) ([]*IntegrationConfig, error)
|
||||
FindIntegrationConfig(context.Context, string, string) (*IntegrationConfig, error)
|
||||
SaveIntegrationConfig(context.Context, *IntegrationConfig) error
|
||||
DeleteIntegrationConfig(context.Context, string, string) error
|
||||
}
|
||||
|
||||
type IntegrationConnectionTester interface {
|
||||
TestIntegration(context.Context, *IntegrationConfig) error
|
||||
}
|
||||
|
||||
type IntegrationConfigUsecase struct {
|
||||
repo IntegrationConfigRepo
|
||||
tester IntegrationConnectionTester
|
||||
}
|
||||
|
||||
func NewIntegrationConfigUsecase(repo IntegrationConfigRepo, tester IntegrationConnectionTester) *IntegrationConfigUsecase {
|
||||
return &IntegrationConfigUsecase{repo: repo, tester: tester}
|
||||
}
|
||||
|
||||
func (uc *IntegrationConfigUsecase) List(ctx context.Context, kind string) ([]*IntegrationConfig, error) {
|
||||
kind = normalizeIntegrationPart(kind)
|
||||
if kind == "" {
|
||||
return nil, errors.New("集成配置 kind 不能为空")
|
||||
}
|
||||
return uc.repo.ListIntegrationConfigs(ctx, kind)
|
||||
}
|
||||
|
||||
func (uc *IntegrationConfigUsecase) Find(ctx context.Context, kind, provider string) (*IntegrationConfig, error) {
|
||||
kind, provider = normalizeIntegrationPart(kind), normalizeIntegrationPart(provider)
|
||||
if kind == "" || provider == "" {
|
||||
return nil, errors.New("集成配置 kind 和 provider 不能为空")
|
||||
}
|
||||
return uc.repo.FindIntegrationConfig(ctx, kind, provider)
|
||||
}
|
||||
|
||||
func (uc *IntegrationConfigUsecase) Save(ctx context.Context, config *IntegrationConfig) error {
|
||||
if config == nil {
|
||||
return errors.New("集成配置请求为空")
|
||||
}
|
||||
config.Kind = normalizeIntegrationPart(config.Kind)
|
||||
config.Provider = normalizeIntegrationPart(config.Provider)
|
||||
if config.Kind == "" || config.Provider == "" || len(config.Kind) > 32 || len(config.Provider) > 64 {
|
||||
return errors.New("集成配置 kind 或 provider 无效")
|
||||
}
|
||||
if !json.Valid(config.Values) {
|
||||
return errors.New("集成配置必须是合法 JSON")
|
||||
}
|
||||
values := map[string]any{}
|
||||
if err := json.Unmarshal(config.Values, &values); err != nil {
|
||||
return errors.New("集成配置必须是 JSON 对象")
|
||||
}
|
||||
if definition, ok := IntegrationDefinition(config.Kind, config.Provider); ok {
|
||||
values = mergeIntegrationDefaults(definition.Defaults, values)
|
||||
if config.Enabled {
|
||||
if err := ValidateIntegrationConfig(config.Kind, config.Provider, values); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
config.Values, _ = json.Marshal(values)
|
||||
}
|
||||
return uc.repo.SaveIntegrationConfig(ctx, config)
|
||||
}
|
||||
|
||||
// Test validates and probes a candidate configuration without persisting it.
|
||||
// The adapter may resolve masked secret values from the active runtime store.
|
||||
func (uc *IntegrationConfigUsecase) Test(ctx context.Context, config *IntegrationConfig) error {
|
||||
if config == nil {
|
||||
return errors.New("集成配置请求为空")
|
||||
}
|
||||
config.Kind = normalizeIntegrationPart(config.Kind)
|
||||
config.Provider = normalizeIntegrationPart(config.Provider)
|
||||
if config.Kind != IntegrationKindMQ && config.Kind != IntegrationKindWebSocket {
|
||||
return errors.New("仅支持测试消息队列和 WebSocket 集成")
|
||||
}
|
||||
if config.Kind == "" || config.Provider == "" || len(config.Kind) > 32 || len(config.Provider) > 64 {
|
||||
return errors.New("集成配置 kind 或 provider 无效")
|
||||
}
|
||||
if !json.Valid(config.Values) {
|
||||
return errors.New("集成配置必须是合法 JSON")
|
||||
}
|
||||
values := map[string]any{}
|
||||
if err := json.Unmarshal(config.Values, &values); err != nil {
|
||||
return errors.New("集成配置必须是 JSON 对象")
|
||||
}
|
||||
if definition, ok := IntegrationDefinition(config.Kind, config.Provider); ok {
|
||||
values = mergeIntegrationDefaults(definition.Defaults, values)
|
||||
}
|
||||
if err := ValidateIntegrationConfig(config.Kind, config.Provider, values); err != nil {
|
||||
return err
|
||||
}
|
||||
encoded, _ := json.Marshal(values)
|
||||
config.Enabled = true
|
||||
config.Values = encoded
|
||||
if uc.tester == nil {
|
||||
return errors.New("集成连接测试器未初始化")
|
||||
}
|
||||
return uc.tester.TestIntegration(ctx, config)
|
||||
}
|
||||
|
||||
func (uc *IntegrationConfigUsecase) Delete(ctx context.Context, kind, provider string) error {
|
||||
kind, provider = normalizeIntegrationPart(kind), normalizeIntegrationPart(provider)
|
||||
if kind == "" || provider == "" {
|
||||
return errors.New("集成配置 kind 和 provider 不能为空")
|
||||
}
|
||||
return uc.repo.DeleteIntegrationConfig(ctx, kind, provider)
|
||||
}
|
||||
|
||||
func normalizeIntegrationPart(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func IntegrationDefinitions(kind string) []IntegrationConfigDefinition {
|
||||
kind = normalizeIntegrationPart(kind)
|
||||
definitions := integrationDefinitions[kind]
|
||||
out := append([]IntegrationConfigDefinition(nil), definitions...)
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Provider < out[j].Provider })
|
||||
return out
|
||||
}
|
||||
|
||||
func IntegrationDefinition(kind, provider string) (IntegrationConfigDefinition, bool) {
|
||||
for _, definition := range integrationDefinitions[normalizeIntegrationPart(kind)] {
|
||||
if definition.Provider == normalizeIntegrationPart(provider) {
|
||||
return definition, true
|
||||
}
|
||||
}
|
||||
return IntegrationConfigDefinition{}, false
|
||||
}
|
||||
|
||||
func DefaultIntegrationConfig(kind, provider string) map[string]any {
|
||||
definition, ok := IntegrationDefinition(kind, provider)
|
||||
if !ok {
|
||||
return map[string]any{}
|
||||
}
|
||||
return mergeIntegrationDefaults(definition.Defaults, nil)
|
||||
}
|
||||
|
||||
func mergeIntegrationDefaults(defaults, values map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(defaults)+len(values))
|
||||
for key, value := range defaults {
|
||||
out[key] = value
|
||||
}
|
||||
for key, value := range values {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ValidateIntegrationConfig(kind, provider string, values map[string]any) error {
|
||||
kind = normalizeIntegrationPart(kind)
|
||||
provider = normalizeIntegrationPart(provider)
|
||||
switch kind {
|
||||
case IntegrationKindPayment:
|
||||
return validatePaymentIntegrationConfig(provider, values)
|
||||
case IntegrationKindMQ, IntegrationKindWebSocket:
|
||||
return validateCommunicationIntegrationConfig(kind, provider, values)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateCommunicationIntegrationConfig(kind, provider string, values map[string]any) error {
|
||||
definition, ok := IntegrationDefinition(kind, provider)
|
||||
if !ok {
|
||||
return errors.New("不支持的通信集成")
|
||||
}
|
||||
for _, field := range definition.Fields {
|
||||
if field.Required && integrationText(values, field.Key) == "" {
|
||||
return fmt.Errorf("%s 缺少配置字段 %s", provider, field.Key)
|
||||
}
|
||||
}
|
||||
|
||||
switch kind + "/" + provider {
|
||||
case IntegrationKindMQ + "/emqx":
|
||||
broker := strings.ToLower(integrationText(values, "broker"))
|
||||
if !strings.HasPrefix(broker, "tcp://") && !strings.HasPrefix(broker, "ssl://") && !strings.HasPrefix(broker, "ws://") && !strings.HasPrefix(broker, "wss://") && !strings.HasPrefix(broker, "mqtt://") {
|
||||
return errors.New("emqx broker 必须使用 tcp、ssl、ws、wss 或 mqtt 协议")
|
||||
}
|
||||
if keepAlive := integrationInt64(values, "keep_alive", 0); keepAlive <= 0 {
|
||||
return errors.New("emqx keep_alive 必须大于 0")
|
||||
}
|
||||
if timeout := integrationInt64(values, "connect_timeout", 0); timeout <= 0 {
|
||||
return errors.New("emqx connect_timeout 必须大于 0")
|
||||
}
|
||||
if interval := integrationInt64(values, "reconnect_interval", 0); interval <= 0 {
|
||||
return errors.New("emqx reconnect_interval 必须大于 0")
|
||||
}
|
||||
case IntegrationKindMQ + "/rabbitmq":
|
||||
port := integrationInt64(values, "port", 0)
|
||||
if port < 1 || port > 65535 {
|
||||
return errors.New("rabbitmq port 必须在 1-65535 之间")
|
||||
}
|
||||
exchangeType := strings.ToLower(integrationText(values, "exchange_type"))
|
||||
if exchangeType != "direct" && exchangeType != "fanout" && exchangeType != "topic" {
|
||||
return errors.New("rabbitmq exchange_type 必须是 direct、fanout 或 topic")
|
||||
}
|
||||
if integrationInt64(values, "prefetch_count", -1) < 0 {
|
||||
return errors.New("rabbitmq prefetch_count 不能小于 0")
|
||||
}
|
||||
if integrationInt64(values, "heartbeat", -1) < 0 {
|
||||
return errors.New("rabbitmq heartbeat 不能小于 0")
|
||||
}
|
||||
if integrationInt64(values, "connect_timeout", 0) <= 0 {
|
||||
return errors.New("rabbitmq connect_timeout 必须大于 0")
|
||||
}
|
||||
if integrationInt64(values, "reconnect_interval", 0) <= 0 {
|
||||
return errors.New("rabbitmq reconnect_interval 必须大于 0")
|
||||
}
|
||||
case IntegrationKindWebSocket + "/melody":
|
||||
path := integrationText(values, "path")
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
return errors.New("websocket path 必须以 / 开头")
|
||||
}
|
||||
for _, key := range []string{"write_wait", "pong_wait", "ping_period"} {
|
||||
value := integrationText(values, key)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
duration, err := time.ParseDuration(value)
|
||||
if err != nil || duration <= 0 {
|
||||
return fmt.Errorf("websocket %s 必须是大于 0 的时长", key)
|
||||
}
|
||||
}
|
||||
if integrationInt64(values, "max_message_size", -1) < 0 || integrationInt64(values, "message_buffer_size", -1) < 0 {
|
||||
return errors.New("websocket 消息大小和缓冲区不能小于 0")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePaymentIntegrationConfig(provider string, values map[string]any) error {
|
||||
definition, ok := IntegrationDefinition(IntegrationKindPayment, provider)
|
||||
if !ok {
|
||||
return errors.New("不支持的支付渠道")
|
||||
}
|
||||
for _, field := range definition.Fields {
|
||||
if field.Required && integrationText(values, field.Key) == "" {
|
||||
return fmt.Errorf("%s 缺少配置字段 %s", provider, field.Key)
|
||||
}
|
||||
}
|
||||
switch provider {
|
||||
case PaymentAlipayV3:
|
||||
for _, group := range []struct {
|
||||
label string
|
||||
keys []string
|
||||
}{
|
||||
{"private_key", []string{"private_key", "private_key_content", "private_key_path"}},
|
||||
{"app_cert", []string{"app_cert", "app_cert_content", "app_cert_path", "app_public_cert", "app_public_cert_content", "app_public_cert_path"}},
|
||||
{"root_cert", []string{"root_cert", "root_cert_content", "root_cert_path", "alipay_root_cert", "alipay_root_cert_content", "alipay_root_cert_path"}},
|
||||
{"public_cert", []string{"public_cert", "public_cert_content", "public_cert_path", "alipay_public_cert", "alipay_public_cert_content", "alipay_public_cert_path"}},
|
||||
} {
|
||||
if integrationFirst(values, group.keys...) == "" {
|
||||
return fmt.Errorf("%s 缺少配置字段 %s", provider, group.label)
|
||||
}
|
||||
}
|
||||
case PaymentWechatV2:
|
||||
if integrationFirst(values, "client_cert", "cert_pem", "apiclient_cert") == "" || integrationFirst(values, "client_key", "key_pem", "apiclient_key") == "" {
|
||||
return fmt.Errorf("%s 退款要求同时配置 client_cert 和 client_key", provider)
|
||||
}
|
||||
case PaymentApple:
|
||||
if integrationInt64(values, "price_divisor", 0) <= 0 {
|
||||
if _, exists := values["price_divisors"].(map[string]any); !exists {
|
||||
return fmt.Errorf("%s 缺少配置字段 price_divisor 或 price_divisors", provider)
|
||||
}
|
||||
}
|
||||
case PaymentDouyin:
|
||||
if integrationFirst(values, "platform_serial_no", "platform_cert_serial") == "" {
|
||||
return fmt.Errorf("%s 缺少配置字段 platform_serial_no", provider)
|
||||
}
|
||||
case PaymentQQ:
|
||||
if integrationFirst(values, "mch_id", "merchant_id") == "" {
|
||||
return fmt.Errorf("%s 缺少配置字段 mch_id", provider)
|
||||
}
|
||||
if !hasQQIntegrationCertificate(values) {
|
||||
return fmt.Errorf("%s 退款要求配置 cert_file + key_file、pkcs12_file 或对应的 *_content", provider)
|
||||
}
|
||||
signType := strings.ToUpper(integrationText(values, "sign_type"))
|
||||
if signType != "" && signType != "MD5" && signType != "HMAC-SHA256" {
|
||||
return fmt.Errorf("%s sign_type 必须是 MD5 或 HMAC-SHA256", provider)
|
||||
}
|
||||
case PaymentAllinPay:
|
||||
orderType := strings.ToLower(integrationFirst(values, "query_order_type", "order_type"))
|
||||
if orderType != "" && orderType != "reqsn" && orderType != "trxid" {
|
||||
return fmt.Errorf("%s query_order_type 必须是 reqsn 或 trxid", provider)
|
||||
}
|
||||
case PaymentChinaums, PaymentSFT, PaymentSuperPay, PaymentWechatGame, PaymentDouyinGame:
|
||||
if integrationFirst(values, "app_key", "merchant_key", "signing_secret", "token") == "" {
|
||||
return fmt.Errorf("%s 缺少签名密钥", provider)
|
||||
}
|
||||
if !validIntegrationScale(integrationInt64(values, "query_amount_scale", 0)) {
|
||||
return fmt.Errorf("%s query_amount_scale 必须是正的 10 的幂", provider)
|
||||
}
|
||||
}
|
||||
if environment := strings.ToLower(integrationText(values, "environment")); environment != "" {
|
||||
allowedSandbox := provider != PaymentQQ && provider != PaymentDouyin && provider != PaymentLakala
|
||||
if environment != "production" && environment != "prod" && (!allowedSandbox || environment != "sandbox") {
|
||||
return fmt.Errorf("%s environment 配置无效", provider)
|
||||
}
|
||||
}
|
||||
return validateIntegrationCallbackAck(values)
|
||||
}
|
||||
|
||||
func integrationText(values map[string]any, key string) string {
|
||||
value, exists := values[key]
|
||||
if !exists || value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
|
||||
func integrationFirst(values map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := integrationText(values, key); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func integrationInt64(values map[string]any, key string, fallback int64) int64 {
|
||||
value := integrationText(values, key)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func hasQQIntegrationCertificate(values map[string]any) bool {
|
||||
if integrationFirst(values, "pkcs12_file", "p12_file", "pkcs12_content") != "" {
|
||||
return true
|
||||
}
|
||||
if integrationFirst(values, "cert_file", "cert_path") != "" && integrationFirst(values, "key_file", "key_path") != "" {
|
||||
return true
|
||||
}
|
||||
return integrationFirst(values, "cert_content") != "" && integrationFirst(values, "key_content") != ""
|
||||
}
|
||||
|
||||
func validIntegrationScale(scale int64) bool {
|
||||
if scale <= 0 {
|
||||
return false
|
||||
}
|
||||
for scale%10 == 0 {
|
||||
scale /= 10
|
||||
}
|
||||
return scale == 1
|
||||
}
|
||||
|
||||
func validateIntegrationCallbackAck(values map[string]any) error {
|
||||
for _, prefix := range []string{"callback_success_", "callback_failure_"} {
|
||||
if value := integrationText(values, prefix+"status"); value != "" {
|
||||
status, err := strconv.Atoi(value)
|
||||
if err != nil || status < 200 || status > 599 {
|
||||
return fmt.Errorf("%sstatus 必须是 200-599", prefix)
|
||||
}
|
||||
}
|
||||
if contentType := integrationText(values, prefix+"content_type"); strings.ContainsAny(contentType, "\r\n") || len(contentType) > 200 {
|
||||
return fmt.Errorf("%scontent_type 非法", prefix)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type integrationConfigRepoTestDouble struct {
|
||||
saves int
|
||||
}
|
||||
|
||||
func (*integrationConfigRepoTestDouble) ListIntegrationConfigs(context.Context, string) ([]*IntegrationConfig, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*integrationConfigRepoTestDouble) FindIntegrationConfig(context.Context, string, string) (*IntegrationConfig, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *integrationConfigRepoTestDouble) SaveIntegrationConfig(context.Context, *IntegrationConfig) error {
|
||||
r.saves++
|
||||
return nil
|
||||
}
|
||||
func (*integrationConfigRepoTestDouble) DeleteIntegrationConfig(context.Context, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type integrationConnectionTesterDouble struct {
|
||||
calls int
|
||||
config *IntegrationConfig
|
||||
}
|
||||
|
||||
func (t *integrationConnectionTesterDouble) TestIntegration(_ context.Context, config *IntegrationConfig) error {
|
||||
t.calls++
|
||||
t.config = config
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCommunicationIntegrationDefinitionsAndValidation(t *testing.T) {
|
||||
for _, target := range []struct{ kind, provider string }{
|
||||
{IntegrationKindMQ, "emqx"},
|
||||
{IntegrationKindMQ, "rabbitmq"},
|
||||
{IntegrationKindWebSocket, "melody"},
|
||||
} {
|
||||
values := DefaultIntegrationConfig(target.kind, target.provider)
|
||||
if len(values) == 0 {
|
||||
t.Fatalf("default config missing for %s/%s", target.kind, target.provider)
|
||||
}
|
||||
if err := ValidateIntegrationConfig(target.kind, target.provider, values); err != nil {
|
||||
t.Fatalf("default config invalid for %s/%s: %v", target.kind, target.provider, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommunicationIntegrationValidationRejectsInvalidValues(t *testing.T) {
|
||||
rabbit := DefaultIntegrationConfig(IntegrationKindMQ, "rabbitmq")
|
||||
rabbit["port"] = 0
|
||||
if err := ValidateIntegrationConfig(IntegrationKindMQ, "rabbitmq", rabbit); err == nil {
|
||||
t.Fatal("invalid rabbitmq port was accepted")
|
||||
}
|
||||
websocket := DefaultIntegrationConfig(IntegrationKindWebSocket, "melody")
|
||||
websocket["path"] = "ws"
|
||||
if err := ValidateIntegrationConfig(IntegrationKindWebSocket, "melody", websocket); err == nil {
|
||||
t.Fatal("invalid websocket path was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationConfigTestDoesNotPersistCandidate(t *testing.T) {
|
||||
repo := &integrationConfigRepoTestDouble{}
|
||||
tester := &integrationConnectionTesterDouble{}
|
||||
usecase := NewIntegrationConfigUsecase(repo, tester)
|
||||
raw, _ := json.Marshal(map[string]any{"path": "/candidate"})
|
||||
|
||||
err := usecase.Test(context.Background(), &IntegrationConfig{
|
||||
Kind: " WebSocket ",
|
||||
Provider: " Melody ",
|
||||
Enabled: false,
|
||||
Values: raw,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repo.saves != 0 {
|
||||
t.Fatalf("candidate config was persisted %d times", repo.saves)
|
||||
}
|
||||
if tester.calls != 1 || tester.config == nil {
|
||||
t.Fatalf("connection tester calls = %d, config = %#v", tester.calls, tester.config)
|
||||
}
|
||||
if tester.config.Kind != IntegrationKindWebSocket || tester.config.Provider != "melody" || !tester.config.Enabled {
|
||||
t.Fatalf("tested config = %#v", tester.config)
|
||||
}
|
||||
values := map[string]any{}
|
||||
if err = json.Unmarshal(tester.config.Values, &values); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if values["path"] != "/candidate" || values["write_wait"] != "10s" {
|
||||
t.Fatalf("tested values = %#v", values)
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
package system
|
||||
|
||||
func integrationField(key, label string, required, secret bool, fieldType string) IntegrationConfigField {
|
||||
if fieldType == "" {
|
||||
fieldType = "text"
|
||||
}
|
||||
return IntegrationConfigField{Key: key, Label: label, Required: required, Secret: secret, Type: fieldType}
|
||||
}
|
||||
|
||||
func integrationSelect(key, label string, required bool, values ...string) IntegrationConfigField {
|
||||
options := make([]IntegrationConfigOption, 0, len(values))
|
||||
for _, value := range values {
|
||||
options = append(options, IntegrationConfigOption{Label: value, Value: value})
|
||||
}
|
||||
return IntegrationConfigField{Key: key, Label: label, Required: required, Type: "select", Options: options}
|
||||
}
|
||||
|
||||
func paymentDefinition(provider, name, description string, defaults map[string]any, fields ...IntegrationConfigField) IntegrationConfigDefinition {
|
||||
common := []IntegrationConfigField{
|
||||
integrationField("notify_url", "支付回调地址", false, false, "url"),
|
||||
integrationField("return_url", "同步跳转地址", false, false, "url"),
|
||||
integrationField("test_amount", "测试金额(最小货币单位)", false, false, "number"),
|
||||
integrationField("test_currency", "测试币种", false, false, "text"),
|
||||
integrationField("test_extra", "测试下单扩展参数(JSON)", false, false, "textarea"),
|
||||
integrationField("test_mode", "允许执行渠道测试", false, false, "switch"),
|
||||
}
|
||||
if _, exists := defaults["test_amount"]; !exists {
|
||||
defaults["test_amount"] = 1
|
||||
}
|
||||
if _, exists := defaults["test_currency"]; !exists {
|
||||
defaults["test_currency"] = "CNY"
|
||||
}
|
||||
if _, exists := defaults["test_extra"]; !exists {
|
||||
defaults["test_extra"] = "{}"
|
||||
}
|
||||
if _, exists := defaults["test_mode"]; !exists {
|
||||
defaults["test_mode"] = false
|
||||
}
|
||||
fields = append(fields, common...)
|
||||
for _, field := range fields {
|
||||
if _, exists := defaults[field.Key]; exists {
|
||||
continue
|
||||
}
|
||||
switch field.Type {
|
||||
case "number":
|
||||
defaults[field.Key] = 0
|
||||
case "switch":
|
||||
defaults[field.Key] = false
|
||||
default:
|
||||
defaults[field.Key] = ""
|
||||
}
|
||||
}
|
||||
return IntegrationConfigDefinition{Kind: IntegrationKindPayment, Provider: provider, Name: name, Description: description, Defaults: defaults, Fields: fields}
|
||||
}
|
||||
|
||||
var genericPaymentFields = []IntegrationConfigField{
|
||||
integrationField("protocol_version", "协议版本", true, false, "text"),
|
||||
integrationField("app_id", "应用 ID", true, false, "text"),
|
||||
integrationField("merchant_id", "商户 ID", true, false, "text"),
|
||||
integrationField("create_url", "下单接口", true, false, "url"),
|
||||
integrationField("query_url", "查单接口", true, false, "url"),
|
||||
integrationField("refund_url", "退款接口", true, false, "url"),
|
||||
integrationField("app_key", "签名密钥", true, true, "password"),
|
||||
integrationField("query_status_field", "查单状态字段", true, false, "text"),
|
||||
integrationField("query_success_values", "查单成功值", true, false, "text"),
|
||||
integrationField("query_trade_no_field", "商户单号字段", true, false, "text"),
|
||||
integrationField("query_provider_trade_no_field", "平台单号字段", true, false, "text"),
|
||||
integrationField("query_amount_field", "金额字段", true, false, "text"),
|
||||
integrationField("query_currency_field", "币种字段", true, false, "text"),
|
||||
integrationField("query_amount_scale", "金额倍率", true, false, "number"),
|
||||
integrationField("callback_status_field", "回调状态字段", true, false, "text"),
|
||||
integrationField("callback_success_values", "回调成功值", true, false, "text"),
|
||||
integrationField("callback_trade_no_field", "回调商户单号字段", true, false, "text"),
|
||||
integrationField("callback_provider_trade_no_field", "回调平台单号字段", true, false, "text"),
|
||||
}
|
||||
|
||||
var genericPaymentDefaults = map[string]any{
|
||||
"protocol_version": "v1", "app_id": "", "merchant_id": "", "create_url": "", "query_url": "", "refund_url": "", "app_key": "",
|
||||
"query_status_field": "", "query_success_values": "", "query_trade_no_field": "",
|
||||
"query_provider_trade_no_field": "", "query_amount_field": "", "query_currency_field": "", "query_amount_scale": 1,
|
||||
"callback_status_field": "", "callback_success_values": "", "callback_trade_no_field": "",
|
||||
"callback_provider_trade_no_field": "", "notify_url": "", "return_url": "",
|
||||
}
|
||||
|
||||
func genericPaymentDefinition(provider, name, description string) IntegrationConfigDefinition {
|
||||
defaults := mergeIntegrationDefaults(genericPaymentDefaults, nil)
|
||||
fields := append([]IntegrationConfigField(nil), genericPaymentFields...)
|
||||
return paymentDefinition(provider, name, description, defaults, fields...)
|
||||
}
|
||||
|
||||
var integrationDefinitions = map[string][]IntegrationConfigDefinition{
|
||||
IntegrationKindMQ: {
|
||||
{
|
||||
Kind: IntegrationKindMQ, Provider: "emqx", Name: "EMQX", Description: "EMQX MQTT 消息服务",
|
||||
Defaults: map[string]any{"broker": "tcp://127.0.0.1:1883", "client_id": "kra", "username": "", "password": "", "keep_alive": 30, "clean_session": true, "connect_timeout": 10, "reconnect_interval": 5},
|
||||
Fields: []IntegrationConfigField{
|
||||
{Key: "broker", Label: "Broker 地址", Type: "text", Required: true, Placeholder: "tcp://127.0.0.1:1883"},
|
||||
{Key: "client_id", Label: "客户端 ID", Type: "text", Required: true, Placeholder: "kra"},
|
||||
{Key: "username", Label: "用户名", Type: "text"},
|
||||
{Key: "password", Label: "密码", Type: "password", Secret: true},
|
||||
{Key: "keep_alive", Label: "心跳间隔(秒)", Type: "number", Required: true},
|
||||
{Key: "clean_session", Label: "清理会话", Type: "switch", Description: "连接时不恢复 Broker 端保存的旧会话。"},
|
||||
{Key: "connect_timeout", Label: "连接超时(秒)", Type: "number", Required: true},
|
||||
{Key: "reconnect_interval", Label: "重连退避上限(秒)", Type: "number", Required: true, Description: "网络中断后自动重连的最大退避间隔。"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Kind: IntegrationKindMQ, Provider: "rabbitmq", Name: "RabbitMQ", Description: "RabbitMQ AMQP 消息队列",
|
||||
Defaults: map[string]any{"host": "127.0.0.1", "port": 5672, "username": "guest", "password": "guest", "vhost": "/", "exchange": "kra", "exchange_type": "topic", "queue": "kra", "routing_key": "#", "durable": true, "auto_delete": false, "prefetch_count": 10, "heartbeat": 10, "connect_timeout": 10, "reconnect_interval": 5, "tls": false},
|
||||
Fields: []IntegrationConfigField{
|
||||
{Key: "host", Label: "主机", Type: "text", Required: true, Placeholder: "127.0.0.1"},
|
||||
{Key: "port", Label: "端口", Type: "number", Required: true},
|
||||
{Key: "username", Label: "用户名", Type: "text", Required: true},
|
||||
{Key: "password", Label: "密码", Type: "password", Required: true, Secret: true},
|
||||
{Key: "vhost", Label: "Virtual Host", Type: "text", Required: true, Placeholder: "/"},
|
||||
{Key: "exchange", Label: "Exchange", Type: "text", Required: true, Placeholder: "kra"},
|
||||
{Key: "exchange_type", Label: "Exchange 类型", Type: "select", Required: true, Options: []IntegrationConfigOption{{Label: "topic", Value: "topic"}, {Label: "direct", Value: "direct"}, {Label: "fanout", Value: "fanout"}}},
|
||||
{Key: "queue", Label: "Queue", Type: "text", Required: true, Placeholder: "kra"},
|
||||
{Key: "routing_key", Label: "默认 Routing Key", Type: "text", Required: true, Placeholder: "#", Description: "业务未指定订阅键时使用;topic 类型支持 * 和 #。"},
|
||||
{Key: "durable", Label: "持久化", Type: "switch"},
|
||||
{Key: "auto_delete", Label: "自动删除", Type: "switch"},
|
||||
{Key: "prefetch_count", Label: "预取数量", Type: "number"},
|
||||
{Key: "heartbeat", Label: "心跳间隔(秒)", Type: "number"},
|
||||
{Key: "connect_timeout", Label: "连接超时(秒)", Type: "number", Required: true},
|
||||
{Key: "reconnect_interval", Label: "重连退避上限(秒)", Type: "number", Required: true, Description: "网络中断后自动重连的最大退避间隔。"},
|
||||
{Key: "tls", Label: "启用 TLS", Type: "switch"},
|
||||
},
|
||||
},
|
||||
},
|
||||
IntegrationKindWebSocket: {
|
||||
{
|
||||
Kind: IntegrationKindWebSocket, Provider: "melody", Name: "WebSocket", Description: "WebSocket 实时连接服务",
|
||||
Defaults: map[string]any{"path": "/ws", "allow_origins": []string{}, "max_message_size": 0, "write_wait": "10s", "pong_wait": "60s", "ping_period": "54s", "message_buffer_size": 0, "concurrent_message_handling": false},
|
||||
Fields: []IntegrationConfigField{
|
||||
{Key: "path", Label: "访问路径", Type: "text", Required: true, Placeholder: "/ws"},
|
||||
{Key: "allow_origins", Label: "允许的来源", Type: "string-list", Placeholder: "https://admin.example.com", Description: "每行一个 Origin;留空时沿用 WebSocket 组件默认策略。"},
|
||||
{Key: "max_message_size", Label: "最大消息字节数", Type: "number", Description: "0 表示使用组件默认值。"},
|
||||
{Key: "write_wait", Label: "写入超时", Type: "text", Required: true, Placeholder: "10s"},
|
||||
{Key: "pong_wait", Label: "Pong 等待时间", Type: "text", Required: true, Placeholder: "60s"},
|
||||
{Key: "ping_period", Label: "Ping 间隔", Type: "text", Required: true, Placeholder: "54s"},
|
||||
{Key: "message_buffer_size", Label: "消息缓冲区", Type: "number", Description: "0 表示不额外缓冲。"},
|
||||
{Key: "concurrent_message_handling", Label: "并发处理消息", Type: "switch"},
|
||||
},
|
||||
},
|
||||
},
|
||||
IntegrationKindPayment: {
|
||||
paymentDefinition(PaymentAlipay, "支付宝", "支付宝 OpenAPI RSA2 支付", map[string]any{"app_id": "", "private_key": "", "public_key": "", "environment": "production", "sign_type": "RSA2", "gateway_url": "https://openapi.alipay.com/gateway.do", "method": "alipay.trade.create"},
|
||||
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("private_key", "应用私钥", true, true, "textarea"), integrationField("public_key", "支付宝公钥", true, true, "textarea"),
|
||||
integrationSelect("environment", "环境", false, "production", "sandbox"), integrationSelect("sign_type", "签名算法", false, "RSA2", "RSA"), integrationField("gateway_url", "网关地址", false, false, "url"),
|
||||
integrationSelect("method", "默认支付方式", false, "alipay.trade.create", "alipay.trade.pay", "alipay.trade.precreate", "alipay.trade.app.pay", "alipay.trade.page.pay", "alipay.trade.wap.pay")),
|
||||
paymentDefinition(PaymentAlipayV3, "支付宝 V3", "支付宝证书模式 V3 接口", map[string]any{"app_id": "", "private_key": "", "app_cert": "", "root_cert": "", "public_cert": "", "environment": "production", "api_base_url": "https://openapi.alipay.com", "gateway_url": "https://openapi.alipay.com/gateway.do", "method": "alipay.trade.create"},
|
||||
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("private_key", "应用私钥", false, true, "textarea"), integrationField("app_cert", "应用公钥证书", false, true, "textarea"), integrationField("root_cert", "支付宝根证书", false, true, "textarea"), integrationField("public_cert", "支付宝公钥证书", false, true, "textarea"),
|
||||
integrationSelect("environment", "环境", false, "production", "sandbox"), integrationField("api_base_url", "API 地址", false, false, "url"), integrationField("gateway_url", "网关地址", false, false, "url"), integrationSelect("method", "默认支付方式", false, "alipay.trade.create", "alipay.trade.pay", "alipay.trade.precreate", "alipay.trade.app.pay", "alipay.trade.page.pay", "alipay.trade.wap.pay")),
|
||||
paymentDefinition(PaymentWechatV2, "微信支付 V2", "微信支付 V2,含退款双向证书", map[string]any{"app_id": "", "merchant_id": "", "mch_key": "", "sign_type": "MD5", "trade_type": "NATIVE", "client_cert": "", "client_key": ""},
|
||||
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("merchant_id", "商户号", true, false, "text"), integrationField("mch_key", "API 密钥", true, true, "password"), integrationSelect("sign_type", "签名算法", false, "MD5", "HMAC-SHA256"), integrationSelect("trade_type", "默认交易类型", false, "JSAPI", "APP", "NATIVE", "MWEB", "MICROPAY"), integrationField("client_cert", "商户证书", false, true, "textarea"), integrationField("client_key", "证书私钥", false, true, "textarea")),
|
||||
paymentDefinition(PaymentWechatV3, "微信支付 V3", "微信支付 API v3", map[string]any{"app_id": "", "merchant_id": "", "serial_no": "", "private_key": "", "api_v3_key": "", "platform_cert": "", "platform_serial_no": "", "trade_type": "jsapi"},
|
||||
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("merchant_id", "商户号", true, false, "text"), integrationField("serial_no", "商户证书序列号", true, false, "text"), integrationField("private_key", "商户私钥", true, true, "textarea"), integrationField("api_v3_key", "API v3 密钥", true, true, "password"), integrationField("platform_cert", "平台证书", true, true, "textarea"), integrationField("platform_serial_no", "平台证书序列号", false, false, "text"), integrationSelect("trade_type", "默认交易类型", false, "jsapi", "app", "native", "h5", "codepay")),
|
||||
paymentDefinition(PaymentApple, "Apple IAP", "Apple App Store Server API", map[string]any{"issuer_id": "", "key_id": "", "bundle_id": "", "private_key": "", "price_divisor": 10, "environment": "production", "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(PaymentDouyin, "抖音支付", "抖音开放平台支付", map[string]any{"app_id": "", "merchant_id": "", "serial_no": "", "api_key": "", "private_key": "", "platform_cert": "", "platform_serial_no": "", "trade_type": "jsapi", "environment": "production"},
|
||||
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("merchant_id", "商户号", true, false, "text"), integrationField("serial_no", "商户证书序列号", true, false, "text"), integrationField("api_key", "API 密钥", true, true, "password"), integrationField("private_key", "商户私钥", true, true, "textarea"), integrationField("platform_cert", "平台证书", true, true, "textarea"), integrationField("platform_serial_no", "平台证书序列号", false, false, "text"), integrationSelect("trade_type", "默认交易类型", false, "app", "jsapi", "h5", "native"), integrationSelect("environment", "环境", false, "production")),
|
||||
paymentDefinition(PaymentQQ, "QQ 钱包", "QQ 钱包支付", map[string]any{"mch_id": "", "api_key": "", "sign_type": "MD5", "trade_type": "NATIVE", "cert_file": "", "key_file": "", "environment": "production"},
|
||||
integrationField("mch_id", "商户号", true, false, "text"), integrationField("api_key", "API 密钥", true, true, "password"), integrationSelect("sign_type", "签名算法", false, "MD5", "HMAC-SHA256"), integrationSelect("trade_type", "默认交易类型", false, "JSAPI", "NATIVE", "APP", "MICROPAY"), integrationField("cert_file", "退款证书路径", false, false, "text"), integrationField("key_file", "退款私钥路径", false, true, "text"), integrationSelect("environment", "环境", false, "production")),
|
||||
paymentDefinition(PaymentAllinPay, "通联支付", "通联收银宝支付", map[string]any{"cus_id": "", "app_id": "", "private_key": "", "public_key": "", "org_id": "", "pay_type": "W02", "query_order_type": "reqsn", "currency": "CNY", "environment": "production"},
|
||||
integrationField("cus_id", "商户号", true, false, "text"), integrationField("app_id", "应用 ID", true, false, "text"), integrationField("private_key", "商户私钥", true, true, "textarea"), integrationField("public_key", "平台公钥", true, true, "textarea"), integrationField("org_id", "机构号", false, false, "text"), integrationField("pay_type", "支付类型", false, false, "text"), integrationSelect("query_order_type", "查单标识", false, "reqsn", "trxid"), integrationField("currency", "币种", false, false, "text"), integrationSelect("environment", "环境", false, "production", "sandbox")),
|
||||
paymentDefinition(PaymentLakala, "拉卡拉", "拉卡拉聚合支付", map[string]any{"partner_code": "", "credential_code": "", "channel": "Wechat", "method": "jsapi", "currency": "CNY", "environment": "production"},
|
||||
integrationField("partner_code", "合作方编号", true, false, "text"), integrationField("credential_code", "凭证码", true, true, "password"), integrationSelect("channel", "支付渠道", false, "Wechat", "Alipay", "UnionPay"), integrationSelect("method", "默认支付方式", false, "jsapi", "h5", "mini", "native", "qrcode", "native_jsapi", "sdk", "web", "retail", "retail_qrcode"), integrationField("currency", "币种", false, false, "text"), integrationSelect("environment", "环境", false, "production")),
|
||||
paymentDefinition(PaymentPayPal, "PayPal", "PayPal Checkout", map[string]any{"client_id": "", "client_secret": "", "webhook_id": "", "environment": "sandbox", "return_url": "", "cancel_url": "", "auto_capture": true, "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(PaymentSaobei, "扫呗", "扫呗聚合支付", map[string]any{"inst_no": "", "key": "", "merchant_no": "", "terminal_id": "", "access_token": "", "pay_type": "010", "currency": "CNY", "environment": "production"},
|
||||
integrationField("inst_no", "机构号", true, false, "text"), integrationField("key", "机构密钥", true, true, "password"), integrationField("merchant_no", "商户号", true, false, "text"), integrationField("terminal_id", "终端号", true, false, "text"), integrationField("access_token", "访问令牌", true, true, "password"), integrationField("pay_type", "支付类型", false, false, "text"), integrationField("currency", "币种", false, false, "text"), integrationSelect("environment", "环境", false, "production", "sandbox")),
|
||||
genericPaymentDefinition(PaymentChinaums, "银联商务", "按商户协议配置的银联商务适配器"),
|
||||
genericPaymentDefinition(PaymentSFT, "商福通", "按商户协议配置的商福通适配器"),
|
||||
genericPaymentDefinition(PaymentSuperPay, "Supper Pay", "按商户协议配置的 Supper Pay 适配器"),
|
||||
genericPaymentDefinition(PaymentWechatGame, "微信小游戏支付", "微信小游戏虚拟支付配置驱动适配器"),
|
||||
genericPaymentDefinition(PaymentDouyinGame, "抖音小游戏支付", "抖音小游戏支付配置驱动适配器"),
|
||||
},
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,32 +0,0 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// PaymentLogger isolates payment audit logging from the payment workflow and
|
||||
// lets an application replace slog with its own structured logger.
|
||||
type PaymentLogger interface {
|
||||
Info(context.Context, string, ...any)
|
||||
Error(context.Context, string, ...any)
|
||||
}
|
||||
|
||||
type slogPaymentLogger struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewPaymentLogger(logger *slog.Logger) PaymentLogger {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &slogPaymentLogger{logger: logger.With("mod", "payment")}
|
||||
}
|
||||
|
||||
func (l *slogPaymentLogger) Info(ctx context.Context, message string, args ...any) {
|
||||
l.logger.InfoContext(ctx, message, args...)
|
||||
}
|
||||
|
||||
func (l *slogPaymentLogger) Error(ctx context.Context, message string, args ...any) {
|
||||
l.logger.ErrorContext(ctx, message, args...)
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
PaymentStatusInitialized = "initialized"
|
||||
PaymentStatusPending = "pending"
|
||||
PaymentStatusPaid = "paid"
|
||||
PaymentStatusFailed = "failed"
|
||||
PaymentStatusClosed = "closed"
|
||||
PaymentStatusPartiallyRefunded = "partially_refunded"
|
||||
PaymentStatusRefunded = "refunded"
|
||||
|
||||
FulfillmentStatusPending = "pending"
|
||||
FulfillmentStatusProcessing = "processing"
|
||||
FulfillmentStatusSucceeded = "succeeded"
|
||||
FulfillmentStatusFailed = "failed"
|
||||
|
||||
RefundStatusNone = "none"
|
||||
RefundStatusProcessing = "processing"
|
||||
RefundStatusPending = "pending"
|
||||
RefundStatusPartial = "partial"
|
||||
RefundStatusSucceeded = "succeeded"
|
||||
RefundStatusFailed = "failed"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPaymentOrderNotFound = errors.New("支付订单不存在")
|
||||
ErrPaymentOrderConflict = errors.New("支付订单参数冲突")
|
||||
ErrPaymentOrderBusy = errors.New("支付订单正在处理中")
|
||||
ErrPaymentOrderState = errors.New("支付订单状态不允许当前操作")
|
||||
ErrPaymentProviderConflict = errors.New("支付平台交易号冲突")
|
||||
)
|
||||
|
||||
// PaymentOrder is one payment attempt. Business modules own their domain
|
||||
// orders and link to this record through BusinessType and BusinessID.
|
||||
type PaymentOrder struct {
|
||||
ID uint64
|
||||
TradeNo string
|
||||
Provider string
|
||||
ProviderTradeNo string
|
||||
QueryID string
|
||||
BusinessType string
|
||||
BusinessID string
|
||||
Subject string
|
||||
PaymentMode string
|
||||
OriginalAmount int64
|
||||
Amount int64
|
||||
PaidAmount int64
|
||||
PayerPaidAmount int64
|
||||
CashPaidAmount int64
|
||||
PointPaidAmount int64
|
||||
DiscountAmount int64
|
||||
ProviderDiscountAmount int64
|
||||
MerchantDiscountAmount int64
|
||||
SettlementAmount int64
|
||||
Currency string
|
||||
PayerCurrency string
|
||||
AmountBreakdownKnown bool
|
||||
PaymentStatus string
|
||||
ProviderStatus string
|
||||
FulfillmentStatus string
|
||||
RefundStatus string
|
||||
RefundedAmount int64
|
||||
RefundRequestedAmount int64
|
||||
RefundNo string
|
||||
ConfirmationID string
|
||||
RequestFingerprint string
|
||||
CreatePayload json.RawMessage
|
||||
Extra json.RawMessage
|
||||
LastEventID string
|
||||
LastPayloadHash string
|
||||
LastError string
|
||||
FulfillmentToken string
|
||||
RefundToken string
|
||||
Version uint64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
PaidAt *time.Time
|
||||
FulfilledAt *time.Time
|
||||
RefundedAt *time.Time
|
||||
FulfillmentLeaseUntil *time.Time
|
||||
RefundLeaseUntil *time.Time
|
||||
}
|
||||
|
||||
type PaymentProviderUpdate struct {
|
||||
Status string
|
||||
ProviderStatus string
|
||||
ProviderTradeNo string
|
||||
QueryID string
|
||||
Amount int64
|
||||
PayerPaidAmount int64
|
||||
CashPaidAmount int64
|
||||
PointPaidAmount int64
|
||||
DiscountAmount int64
|
||||
ProviderDiscountAmount int64
|
||||
MerchantDiscountAmount int64
|
||||
SettlementAmount int64
|
||||
Currency string
|
||||
PayerCurrency string
|
||||
AmountBreakdownKnown bool
|
||||
EventID string
|
||||
PayloadHash string
|
||||
CreatePayload json.RawMessage
|
||||
}
|
||||
|
||||
type PaymentOrderFilter struct {
|
||||
Provider string
|
||||
TradeNo string
|
||||
BusinessType string
|
||||
BusinessID string
|
||||
PaymentStatus string
|
||||
RefundStatus string
|
||||
}
|
||||
|
||||
type PaymentOrderRepo interface {
|
||||
CreatePaymentOrder(context.Context, *PaymentOrder) (order *PaymentOrder, created bool, err error)
|
||||
FindPaymentOrder(context.Context, string, string) (*PaymentOrder, error)
|
||||
ListPaymentOrders(context.Context, int, int, PaymentOrderFilter) ([]*PaymentOrder, int64, error)
|
||||
RecordPaymentCreate(context.Context, string, string, *PaymentProviderUpdate) (*PaymentOrder, error)
|
||||
ApplyPaymentResult(context.Context, string, string, *PaymentProviderUpdate) (*PaymentOrder, error)
|
||||
BeginPaymentFulfillment(context.Context, string, string, time.Duration) (order *PaymentOrder, token string, duplicate bool, err error)
|
||||
CompletePaymentFulfillment(context.Context, string, string, string, bool, string) (*PaymentOrder, error)
|
||||
BeginPaymentRefund(context.Context, string, string, int64, time.Duration) (order *PaymentOrder, token string, err error)
|
||||
CompletePaymentRefundRequest(context.Context, string, string, string, bool, string) (*PaymentOrder, error)
|
||||
ConfirmPaymentRefund(context.Context, string, string, string, int64, bool, string) (*PaymentOrder, error)
|
||||
}
|
||||
|
||||
// PaymentOrderSource is implemented by each business module. It returns the
|
||||
// canonical order facts; client-provided amount, currency and subject are not
|
||||
// trusted once the persistent payment-order flow is enabled.
|
||||
type PaymentOrderSource interface {
|
||||
Type() string
|
||||
PreparePayment(context.Context, string, string, string) (*PaymentIntent, error)
|
||||
}
|
||||
|
||||
type PaymentBusinessModule interface {
|
||||
PaymentOrderSource
|
||||
PaymentFulfillmentHandler
|
||||
PaymentRefundAuthorizer
|
||||
}
|
||||
|
||||
type PaymentRefundAuthorizer interface {
|
||||
AuthorizeRefund(context.Context, *PaymentOrder, int64) error
|
||||
}
|
||||
|
||||
type PaymentOrderSourceRegistry struct {
|
||||
mu sync.RWMutex
|
||||
sources map[string]PaymentOrderSource
|
||||
}
|
||||
|
||||
func NewPaymentOrderSourceRegistry() *PaymentOrderSourceRegistry {
|
||||
return &PaymentOrderSourceRegistry{sources: map[string]PaymentOrderSource{}}
|
||||
}
|
||||
|
||||
func (r *PaymentOrderSourceRegistry) Register(source PaymentOrderSource) error {
|
||||
if source == nil || strings.TrimSpace(source.Type()) == "" {
|
||||
return errors.New("支付业务订单来源无效")
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, exists := r.sources[source.Type()]; exists {
|
||||
return errors.New("支付业务订单来源已注册: " + source.Type())
|
||||
}
|
||||
r.sources[source.Type()] = source
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PaymentOrderSourceRegistry) Source(kind string) PaymentOrderSource {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.sources[kind]
|
||||
}
|
||||
|
||||
func (r *PaymentOrderSourceRegistry) Unregister(kind string) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.sources, kind)
|
||||
}
|
||||
|
||||
func (o *PaymentOrder) Intent() *PaymentIntent {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
return &PaymentIntent{
|
||||
Provider: o.Provider,
|
||||
TradeNo: o.TradeNo,
|
||||
BusinessType: o.BusinessType,
|
||||
BusinessID: o.BusinessID,
|
||||
Subject: o.Subject,
|
||||
Amount: o.Amount,
|
||||
Currency: o.Currency,
|
||||
Extra: append(json.RawMessage(nil), o.Extra...),
|
||||
PaymentMode: defaultPaymentMode(o.Provider, o.PaymentMode),
|
||||
OriginalAmount: o.OriginalAmount,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultPaymentMode(provider, mode string) string {
|
||||
if strings.TrimSpace(mode) != "" {
|
||||
return mode
|
||||
}
|
||||
if provider == PaymentInternal {
|
||||
return PaymentModeInternal
|
||||
}
|
||||
return PaymentModeExternal
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type paymentRepoStub struct {
|
||||
create *PaymentResult
|
||||
createdReq *PaymentRequest
|
||||
refund *PaymentResult
|
||||
refundErr error
|
||||
refundedReq *PaymentRefundRequest
|
||||
callback *PaymentResult
|
||||
query *PaymentResult
|
||||
queryCalls int
|
||||
callbackErr error
|
||||
}
|
||||
|
||||
func (r *paymentRepoStub) Create(_ context.Context, req *PaymentRequest) (*PaymentResult, error) {
|
||||
copyReq := *req
|
||||
r.createdReq = ©Req
|
||||
return r.create, nil
|
||||
}
|
||||
|
||||
type paymentOrderRepoStub struct {
|
||||
order *PaymentOrder
|
||||
}
|
||||
|
||||
func (r *paymentOrderRepoStub) CreatePaymentOrder(_ context.Context, order *PaymentOrder) (*PaymentOrder, bool, error) {
|
||||
if r.order != nil {
|
||||
return r.order, false, nil
|
||||
}
|
||||
copyOrder := *order
|
||||
r.order = ©Order
|
||||
return r.order, true, nil
|
||||
}
|
||||
func (r *paymentOrderRepoStub) FindPaymentOrder(context.Context, string, string) (*PaymentOrder, error) {
|
||||
if r.order == nil {
|
||||
return nil, ErrPaymentOrderNotFound
|
||||
}
|
||||
return r.order, nil
|
||||
}
|
||||
func (r *paymentOrderRepoStub) ListPaymentOrders(context.Context, int, int, PaymentOrderFilter) ([]*PaymentOrder, int64, error) {
|
||||
if r.order == nil {
|
||||
return []*PaymentOrder{}, 0, nil
|
||||
}
|
||||
return []*PaymentOrder{r.order}, 1, nil
|
||||
}
|
||||
func (r *paymentOrderRepoStub) RecordPaymentCreate(_ context.Context, _, _ string, update *PaymentProviderUpdate) (*PaymentOrder, error) {
|
||||
r.order.PaymentStatus = PaymentStatusPending
|
||||
r.order.CreatePayload = append(r.order.CreatePayload[:0], update.CreatePayload...)
|
||||
return r.order, nil
|
||||
}
|
||||
func (r *paymentOrderRepoStub) ApplyPaymentResult(_ context.Context, _, _ string, update *PaymentProviderUpdate) (*PaymentOrder, error) {
|
||||
if update.Status == "success" {
|
||||
r.order.PaymentStatus = PaymentStatusPaid
|
||||
r.order.PaidAmount = r.order.Amount
|
||||
r.order.ProviderTradeNo = update.ProviderTradeNo
|
||||
}
|
||||
return r.order, nil
|
||||
}
|
||||
func (r *paymentOrderRepoStub) BeginPaymentFulfillment(context.Context, string, string, time.Duration) (*PaymentOrder, string, bool, error) {
|
||||
if r.order.FulfillmentStatus == FulfillmentStatusSucceeded {
|
||||
return r.order, "", true, nil
|
||||
}
|
||||
r.order.FulfillmentStatus = FulfillmentStatusProcessing
|
||||
return r.order, "token", false, nil
|
||||
}
|
||||
func (r *paymentOrderRepoStub) CompletePaymentFulfillment(_ context.Context, _, _, _ string, success bool, _ string) (*PaymentOrder, error) {
|
||||
if success {
|
||||
r.order.FulfillmentStatus = FulfillmentStatusSucceeded
|
||||
} else {
|
||||
r.order.FulfillmentStatus = FulfillmentStatusFailed
|
||||
}
|
||||
return r.order, nil
|
||||
}
|
||||
func (r *paymentOrderRepoStub) BeginPaymentRefund(context.Context, string, string, int64, time.Duration) (*PaymentOrder, string, error) {
|
||||
r.order.RefundNo = "refund-1"
|
||||
r.order.RefundStatus = RefundStatusProcessing
|
||||
return r.order, "refund-token", nil
|
||||
}
|
||||
func (r *paymentOrderRepoStub) CompletePaymentRefundRequest(context.Context, string, string, string, bool, string) (*PaymentOrder, error) {
|
||||
r.order.RefundStatus = RefundStatusPending
|
||||
return r.order, nil
|
||||
}
|
||||
func (r *paymentOrderRepoStub) ConfirmPaymentRefund(context.Context, string, string, string, int64, bool, string) (*PaymentOrder, error) {
|
||||
return r.order, nil
|
||||
}
|
||||
|
||||
type paymentSourceStub struct{ intent *PaymentIntent }
|
||||
|
||||
func (s *paymentSourceStub) Type() string { return s.intent.BusinessType }
|
||||
func (s *paymentSourceStub) PreparePayment(context.Context, string, string, string) (*PaymentIntent, error) {
|
||||
return s.intent, nil
|
||||
}
|
||||
func (r *paymentRepoStub) Query(context.Context, string, string) (*PaymentResult, error) {
|
||||
r.queryCalls++
|
||||
return r.query, nil
|
||||
}
|
||||
func (r *paymentRepoStub) Refund(_ context.Context, req *PaymentRefundRequest) (*PaymentResult, error) {
|
||||
if req != nil {
|
||||
copyReq := *req
|
||||
r.refundedReq = ©Req
|
||||
}
|
||||
return r.refund, r.refundErr
|
||||
}
|
||||
func (r *paymentRepoStub) HandleCallback(context.Context, *PaymentCallback) (*PaymentResult, error) {
|
||||
return r.callback, r.callbackErr
|
||||
}
|
||||
|
||||
type paymentFulfillmentStub struct {
|
||||
calls int
|
||||
failFirst bool
|
||||
confirmIDs []string
|
||||
}
|
||||
|
||||
func (h *paymentFulfillmentStub) Type() string { return "game_item" }
|
||||
func (h *paymentFulfillmentStub) AuthorizeRefund(context.Context, *PaymentOrder, int64) error {
|
||||
return nil
|
||||
}
|
||||
func (h *paymentFulfillmentStub) Fulfill(_ context.Context, confirmation *PaymentConfirmation) error {
|
||||
h.calls++
|
||||
h.confirmIDs = append(h.confirmIDs, confirmation.ID)
|
||||
if h.failFirst && h.calls == 1 {
|
||||
return errors.New("temporary fulfillment failure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newPaymentUsecaseForTest(repo PaymentRepo) *PaymentUsecase {
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return NewPaymentUsecase(repo, &paymentOrderRepoStub{order: testBizPaymentOrder()}, logger)
|
||||
}
|
||||
|
||||
func testBizPaymentOrder() *PaymentOrder {
|
||||
return &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"}
|
||||
}
|
||||
|
||||
func successPaymentResult() *PaymentResult {
|
||||
return &PaymentResult{
|
||||
Provider: PaymentAlipay,
|
||||
Status: "success",
|
||||
TradeNo: "order-1",
|
||||
ProviderTradeNo: "ali-1",
|
||||
Amount: 100,
|
||||
Currency: "CNY",
|
||||
}
|
||||
}
|
||||
|
||||
func successIntent() *PaymentIntent {
|
||||
return &PaymentIntent{
|
||||
Provider: PaymentAlipay,
|
||||
TradeNo: "order-1",
|
||||
BusinessType: "game_item",
|
||||
BusinessID: "item-1",
|
||||
Amount: 100,
|
||||
Currency: "CNY",
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentCallbackQueriesBeforeFulfillment(t *testing.T) {
|
||||
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
|
||||
uc := newPaymentUsecaseForTest(repo)
|
||||
_, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
|
||||
if err == nil {
|
||||
t.Fatal("callback succeeded without fulfillment handler")
|
||||
}
|
||||
if repo.queryCalls != 1 {
|
||||
t.Fatalf("query calls = %d, want 1", repo.queryCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentCallbackPendingDoesNotFulfill(t *testing.T) {
|
||||
pending := successPaymentResult()
|
||||
pending.Status = "pending"
|
||||
repo := &paymentRepoStub{callback: successPaymentResult(), query: pending}
|
||||
handler := &paymentFulfillmentStub{}
|
||||
uc := newPaymentUsecaseForTest(repo)
|
||||
registry := NewPaymentFulfillmentRegistry()
|
||||
if err := registry.Register(handler); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uc.SetFulfillmentRegistry(registry)
|
||||
result, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status != "pending" || handler.calls != 0 {
|
||||
t.Fatalf("result=%+v handler=%d", result, handler.calls)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if _, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}); err == nil {
|
||||
t.Fatal("amount mismatch was accepted")
|
||||
}
|
||||
if handler.calls != 0 {
|
||||
t.Fatal("handler ran after amount mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentCallbackRequiresFulfillmentHandler(t *testing.T) {
|
||||
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
|
||||
uc := newPaymentUsecaseForTest(repo)
|
||||
if _, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}); err == nil {
|
||||
t.Fatal("callback succeeded without fulfillment handler")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentCallbackDuplicateDoesNotFulfillTwice(t *testing.T) {
|
||||
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
|
||||
handler := &paymentFulfillmentStub{}
|
||||
uc := newPaymentUsecaseForTest(repo)
|
||||
registry := NewPaymentFulfillmentRegistry()
|
||||
_ = registry.Register(handler)
|
||||
uc.SetFulfillmentRegistry(registry)
|
||||
callback := &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}
|
||||
if _, err := uc.Callback(context.Background(), callback); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := uc.Callback(context.Background(), callback)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !second.Duplicate || handler.calls != 1 || repo.queryCalls != 2 {
|
||||
t.Fatalf("duplicate=%v handler=%d query=%d", second.Duplicate, handler.calls, repo.queryCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentCallbackFulfillmentFailureCanRetry(t *testing.T) {
|
||||
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
|
||||
handler := &paymentFulfillmentStub{failFirst: true}
|
||||
uc := newPaymentUsecaseForTest(repo)
|
||||
registry := NewPaymentFulfillmentRegistry()
|
||||
_ = registry.Register(handler)
|
||||
uc.SetFulfillmentRegistry(registry)
|
||||
callback := &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}
|
||||
if _, err := uc.Callback(context.Background(), callback); err == nil {
|
||||
t.Fatal("first fulfillment failure was accepted")
|
||||
}
|
||||
result, err := uc.Callback(context.Background(), callback)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status != "fulfilled" || handler.calls != 2 || handler.confirmIDs[0] != handler.confirmIDs[1] {
|
||||
t.Fatalf("result=%+v handler=%d ids=%v", result, handler.calls, handler.confirmIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentQueryRejectsIncompleteSuccess(t *testing.T) {
|
||||
result := successPaymentResult()
|
||||
result.ProviderTradeNo = ""
|
||||
uc := newPaymentUsecaseForTest(&paymentRepoStub{query: result})
|
||||
if _, err := uc.Query(context.Background(), PaymentAlipay, "order-1"); err == nil {
|
||||
t.Fatal("incomplete successful query was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentQueryRejectsUnknownStatus(t *testing.T) {
|
||||
result := successPaymentResult()
|
||||
result.Status = "maybe"
|
||||
uc := newPaymentUsecaseForTest(&paymentRepoStub{query: result})
|
||||
if _, err := uc.Query(context.Background(), PaymentAlipay, "order-1"); err == nil {
|
||||
t.Fatal("unknown payment status was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentCallbackErrorCarriesSafeAck(t *testing.T) {
|
||||
ack := DefaultPaymentCallbackAck(PaymentAlipay, false)
|
||||
err := callbackResultError(&PaymentResult{FailureAck: ack}, errors.New("internal order resolver details"))
|
||||
callbackErr, ok := err.(*PaymentCallbackError)
|
||||
if !ok || string(callbackErr.Ack.Body) != "failure" {
|
||||
t.Fatalf("callback error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
if repo.createdReq.Amount != 100 || repo.createdReq.Currency != "CNY" || repo.createdReq.Subject != "canonical item" || repo.createdReq.Extra["product_id"] != "canonical" || orders.order.Amount != 100 || result.OrderStatus != PaymentStatusPending {
|
||||
t.Fatalf("canonical order was not used: request=%+v order=%+v result=%+v", repo.createdReq, orders.order, result)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
first, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Status != "fulfilled" || second.Status != "fulfilled" || !second.Duplicate || handler.calls != 1 || orders.order.FulfillmentStatus != FulfillmentStatusSucceeded {
|
||||
t.Fatalf("persistent callback flow failed: first=%+v second=%+v handler=%d order=%+v", first, second, handler.calls, orders.order)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
result, err := uc.Fulfill(context.Background(), PaymentAlipay, "order-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status != "fulfilled" || handler.calls != 1 || orders.order.FulfillmentStatus != FulfillmentStatusSucceeded {
|
||||
t.Fatalf("fulfillment retry failed: result=%+v calls=%d status=%s", result, handler.calls, orders.order.FulfillmentStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentConfirmationAcceptsProviderDiscount(t *testing.T) {
|
||||
intent := &PaymentIntent{Provider: PaymentAlipay, TradeNo: "T1", Amount: 1000, Currency: "CNY", BusinessType: "item", BusinessID: "I1"}
|
||||
result := &PaymentResult{Provider: PaymentAlipay, TradeNo: "T1", ProviderTradeNo: "P1", Status: "success", Amount: 1000, PayerPaidAmount: 800, CashPaidAmount: 800, DiscountAmount: 200, ProviderDiscountAmount: 150, MerchantDiscountAmount: 50, SettlementAmount: 950, Currency: "CNY", AmountBreakdownKnown: true}
|
||||
if err := validatePaymentConfirmation(intent, PaymentAlipay, "T1", result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentConfirmationRejectsOverpayment(t *testing.T) {
|
||||
intent := &PaymentIntent{Provider: PaymentWechatV3, TradeNo: "T1", Amount: 1000, Currency: "CNY", BusinessType: "item", BusinessID: "I1"}
|
||||
result := &PaymentResult{Provider: PaymentWechatV3, TradeNo: "T1", ProviderTradeNo: "P1", Status: "success", Amount: 1000, PayerPaidAmount: 1001, CashPaidAmount: 1001, Currency: "CNY", AmountBreakdownKnown: true}
|
||||
if err := validatePaymentConfirmation(intent, PaymentWechatV3, "T1", result); err == nil {
|
||||
t.Fatal("overpayment was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentConfirmationRejectsBrokenDiscountConservation(t *testing.T) {
|
||||
intent := &PaymentIntent{Provider: PaymentWechatV2, TradeNo: "T1", Amount: 1000, Currency: "CNY", BusinessType: "item", BusinessID: "I1"}
|
||||
result := &PaymentResult{Provider: PaymentWechatV2, TradeNo: "T1", ProviderTradeNo: "P1", Status: "success", Amount: 1000, PayerPaidAmount: 800, CashPaidAmount: 800, DiscountAmount: 100, Currency: "CNY", AmountBreakdownKnown: true}
|
||||
if err := validatePaymentConfirmation(intent, PaymentWechatV2, "T1", result); err == nil {
|
||||
t.Fatal("broken discount conservation was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentConfirmationBindsAppleProductID(t *testing.T) {
|
||||
intent := &PaymentIntent{
|
||||
Provider: PaymentApple, TradeNo: "123e4567-e89b-12d3-a456-426614174000",
|
||||
Amount: 100, Currency: "USD", BusinessType: "item", BusinessID: "I1",
|
||||
Extra: json.RawMessage(`{"product_id":"com.example.coin100"}`),
|
||||
}
|
||||
result := &PaymentResult{
|
||||
Provider: PaymentApple, TradeNo: intent.TradeNo, ProviderTradeNo: "APPLE-TX-1",
|
||||
Status: "success", Amount: 100, Currency: "USD",
|
||||
Payload: json.RawMessage(`{"productId":"com.example.coin100"}`),
|
||||
}
|
||||
if err := validatePaymentConfirmation(intent, PaymentApple, intent.TradeNo, result); err != nil {
|
||||
t.Fatalf("matching productId rejected: %v", err)
|
||||
}
|
||||
result.Payload = json.RawMessage(`{"productId":"com.example.coin200"}`)
|
||||
if err := validatePaymentConfirmation(intent, PaymentApple, intent.TradeNo, result); err == nil {
|
||||
t.Fatal("mismatched Apple productId was accepted")
|
||||
}
|
||||
}
|
||||
@@ -24,11 +24,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewAuditUsecase,
|
||||
NewAuditRecorderUsecase,
|
||||
NewLogViewerUsecase,
|
||||
NewTaskUsecaseWithRegistry,
|
||||
NewTaskApplicationUsecase,
|
||||
NewMediaUsecase,
|
||||
NewAnnouncementUsecase,
|
||||
NewEmailUsecase,
|
||||
NewPaymentUsecase,
|
||||
NewIntegrationConfigUsecase,
|
||||
)
|
||||
|
||||
@@ -30,6 +30,8 @@ type InitializationRepo interface {
|
||||
DiskMountPoints() []string
|
||||
}
|
||||
|
||||
// TaskReloader is the narrow scheduler boundary needed after configuration
|
||||
// changes. The consumer owns this interface; worker supplies the implementation.
|
||||
type TaskReloader interface {
|
||||
Reload(context.Context) error
|
||||
}
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
type TimedTask struct {
|
||||
ID uint
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Name, Description, Spec string
|
||||
WithSeconds bool
|
||||
ExecutorType, MethodName string
|
||||
Params []byte
|
||||
HTTPURL, HTTPMethod string
|
||||
HTTPHeader []byte
|
||||
HTTPBody string
|
||||
HTTPAllowPrivate, Enabled bool
|
||||
EnabledFilter *bool
|
||||
}
|
||||
type TimedTaskLog struct {
|
||||
ID uint
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
TaskID uint
|
||||
TaskName, TriggerType string
|
||||
StartedAt, FinishedAt time.Time
|
||||
DurationMS int64
|
||||
Status, ErrorMsg, Output string
|
||||
}
|
||||
|
||||
const (
|
||||
TaskExecutorMethod = "method"
|
||||
TaskExecutorHTTP = "http"
|
||||
TaskMethodClearDB = "ClearDB"
|
||||
TaskMethodUploads = "CleanStaleUploads"
|
||||
)
|
||||
|
||||
var ErrTaskSchedule = errors.New("task schedule failed")
|
||||
|
||||
type TaskScheduleError struct{ Err error }
|
||||
|
||||
func (e *TaskScheduleError) Error() string { return e.Err.Error() }
|
||||
func (e *TaskScheduleError) Is(target error) bool { return target == ErrTaskSchedule }
|
||||
|
||||
type TaskRepo interface {
|
||||
CreateTask(context.Context, *TimedTask) error
|
||||
UpdateTask(context.Context, *TimedTask) error
|
||||
DeleteTask(context.Context, uint) error
|
||||
FindTask(context.Context, uint) (*TimedTask, error)
|
||||
ListTasks(context.Context, int, int, *TimedTask) ([]*TimedTask, int64, error)
|
||||
ToggleTask(context.Context, uint, bool) error
|
||||
RecordTaskLog(context.Context, *TimedTaskLog) error
|
||||
ListTaskLogs(context.Context, int, int, uint, string) ([]*TimedTaskLog, int64, error)
|
||||
CleanupLogs(context.Context) error
|
||||
TaskNameExists(context.Context, string, uint) (bool, error)
|
||||
}
|
||||
|
||||
type TaskRuntime interface {
|
||||
ScheduleID(context.Context, uint) error
|
||||
Remove(uint)
|
||||
TriggerID(context.Context, uint) error
|
||||
NextRuns() map[uint]time.Time
|
||||
Reload(context.Context) error
|
||||
Subscribe(uint) chan []byte
|
||||
Unsubscribe(uint, chan []byte)
|
||||
}
|
||||
|
||||
type TaskUsecase struct {
|
||||
TaskRepo
|
||||
methods TaskMethodRegistry
|
||||
}
|
||||
|
||||
func NewTaskUsecase(repo TaskRepo) *TaskUsecase {
|
||||
return NewTaskUsecaseWithRegistry(repo, DefaultTaskMethodRegistry())
|
||||
}
|
||||
|
||||
func NewTaskUsecaseWithRegistry(repo TaskRepo, methods TaskMethodRegistry) *TaskUsecase {
|
||||
if methods == nil {
|
||||
methods = DefaultTaskMethodRegistry()
|
||||
}
|
||||
return &TaskUsecase{TaskRepo: repo, methods: methods}
|
||||
}
|
||||
|
||||
func (uc *TaskUsecase) RegisteredMethods() []TaskMethod { return uc.methods.List() }
|
||||
|
||||
func (uc *TaskUsecase) Validate(value *TimedTask) error {
|
||||
if value.Name == "" {
|
||||
return errors.New("任务名不能为空")
|
||||
}
|
||||
var err error
|
||||
if value.WithSeconds {
|
||||
_, err = cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor).Parse(value.Spec)
|
||||
} else {
|
||||
_, err = cron.ParseStandard(value.Spec)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("cron 表达式非法: %w", err)
|
||||
}
|
||||
switch value.ExecutorType {
|
||||
case TaskExecutorMethod:
|
||||
if _, ok := uc.methods.Lookup(value.MethodName); !ok {
|
||||
return fmt.Errorf("方法 %s 未注册", value.MethodName)
|
||||
}
|
||||
if len(value.Params) > 0 && !json.Valid(value.Params) {
|
||||
return errors.New("params 必须是合法 JSON")
|
||||
}
|
||||
case TaskExecutorHTTP:
|
||||
parsed, parseErr := url.Parse(value.HTTPURL)
|
||||
if parseErr != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||
return errors.New("httpUrl 必须是合法的 http/https 地址")
|
||||
}
|
||||
if len(value.HTTPHeader) > 0 {
|
||||
headers := map[string]string{}
|
||||
if json.Unmarshal(value.HTTPHeader, &headers) != nil {
|
||||
return errors.New(`httpHeader 必须是 {"Key":"Value"} 形式的 JSON 对象`)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return errors.New("executorType 必须为 method 或 http")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (uc *TaskUsecase) Create(ctx context.Context, value *TimedTask) error {
|
||||
if err := uc.Validate(value); err != nil {
|
||||
return err
|
||||
}
|
||||
exists, err := uc.TaskNameExists(ctx, value.Name, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return fmt.Errorf("任务名 %s 已存在", value.Name)
|
||||
}
|
||||
return uc.CreateTask(ctx, value)
|
||||
}
|
||||
|
||||
func (uc *TaskUsecase) Update(ctx context.Context, value *TimedTask) error {
|
||||
if value.ID == 0 {
|
||||
return errors.New("缺少任务 ID")
|
||||
}
|
||||
if err := uc.Validate(value); err != nil {
|
||||
return err
|
||||
}
|
||||
exists, err := uc.TaskNameExists(ctx, value.Name, value.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return fmt.Errorf("任务名 %s 已存在", value.Name)
|
||||
}
|
||||
return uc.UpdateTask(ctx, value)
|
||||
}
|
||||
|
||||
type TaskApplicationUsecase struct {
|
||||
tasks *TaskUsecase
|
||||
runtime TaskRuntime
|
||||
}
|
||||
|
||||
func NewTaskApplicationUsecase(tasks *TaskUsecase, runtime TaskRuntime) *TaskApplicationUsecase {
|
||||
return &TaskApplicationUsecase{tasks: tasks, runtime: runtime}
|
||||
}
|
||||
|
||||
func (uc *TaskApplicationUsecase) RegisteredMethods() []TaskMethod {
|
||||
return uc.tasks.RegisteredMethods()
|
||||
}
|
||||
|
||||
func (uc *TaskApplicationUsecase) syncRuntime(ctx context.Context, id uint) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := uc.runtime.ScheduleID(ctx, id); err != nil {
|
||||
repairCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer cancel()
|
||||
if reloadErr := uc.runtime.Reload(repairCtx); reloadErr != nil {
|
||||
return &TaskScheduleError{Err: errors.Join(err, fmt.Errorf("重载任务运行时失败: %w", reloadErr))}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (uc *TaskApplicationUsecase) Create(ctx context.Context, value *TimedTask) error {
|
||||
if err := uc.tasks.Create(ctx, value); err != nil {
|
||||
return err
|
||||
}
|
||||
return uc.syncRuntime(ctx, value.ID)
|
||||
}
|
||||
|
||||
func (uc *TaskApplicationUsecase) Update(ctx context.Context, value *TimedTask) error {
|
||||
if err := uc.tasks.Update(ctx, value); err != nil {
|
||||
return err
|
||||
}
|
||||
return uc.syncRuntime(ctx, value.ID)
|
||||
}
|
||||
|
||||
func (uc *TaskApplicationUsecase) Delete(ctx context.Context, id uint) error {
|
||||
if err := uc.tasks.DeleteTask(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
uc.runtime.Remove(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (uc *TaskApplicationUsecase) Toggle(ctx context.Context, id uint, enabled bool) error {
|
||||
if err := uc.tasks.ToggleTask(ctx, id, enabled); err != nil {
|
||||
return err
|
||||
}
|
||||
return uc.syncRuntime(ctx, id)
|
||||
}
|
||||
|
||||
func (uc *TaskApplicationUsecase) Trigger(ctx context.Context, id uint) error {
|
||||
return uc.runtime.TriggerID(ctx, id)
|
||||
}
|
||||
|
||||
func (uc *TaskApplicationUsecase) List(ctx context.Context, page, size int, filter *TimedTask) ([]*TimedTask, int64, map[uint]time.Time, error) {
|
||||
items, total, err := uc.tasks.ListTasks(ctx, page, size, filter)
|
||||
return items, total, uc.runtime.NextRuns(), err
|
||||
}
|
||||
|
||||
func (uc *TaskApplicationUsecase) Logs(ctx context.Context, page, size int, taskID uint, status string) ([]*TimedTaskLog, int64, error) {
|
||||
return uc.tasks.ListTaskLogs(ctx, page, size, taskID, status)
|
||||
}
|
||||
|
||||
func (uc *TaskApplicationUsecase) Reload(ctx context.Context) error {
|
||||
return uc.runtime.Reload(ctx)
|
||||
}
|
||||
|
||||
func (uc *TaskApplicationUsecase) Subscribe(userID uint) chan []byte {
|
||||
return uc.runtime.Subscribe(userID)
|
||||
}
|
||||
func (uc *TaskApplicationUsecase) Unsubscribe(userID uint, events chan []byte) {
|
||||
uc.runtime.Unsubscribe(userID, events)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package system
|
||||
|
||||
import platformtask "kra/pkg/task"
|
||||
|
||||
type TaskMethodFunc = platformtask.MethodFunc
|
||||
type TaskMethod = platformtask.Method
|
||||
|
||||
type TaskMethodRegistry interface {
|
||||
Register(platformtask.Method)
|
||||
Lookup(string) (TaskMethodFunc, bool)
|
||||
List() []TaskMethod
|
||||
}
|
||||
|
||||
var defaultTaskMethods = platformtask.NewRegistry()
|
||||
|
||||
// RegisterTaskMethod remains as a compatibility helper for tests and callers
|
||||
// that have not moved to constructor injection yet.
|
||||
func RegisterTaskMethod(name, description string, fn TaskMethodFunc) {
|
||||
defaultTaskMethods.Register(platformtask.Method{Name: name, Description: description, Run: fn})
|
||||
}
|
||||
|
||||
func TaskMethodByName(name string) (TaskMethodFunc, bool) {
|
||||
return defaultTaskMethods.Lookup(name)
|
||||
}
|
||||
|
||||
func RegisteredTaskMethods() []TaskMethod { return defaultTaskMethods.List() }
|
||||
|
||||
func DefaultTaskMethodRegistry() TaskMethodRegistry { return defaultTaskMethods }
|
||||
@@ -1,197 +0,0 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type applicationTaskRepo struct {
|
||||
mu sync.Mutex
|
||||
deleted bool
|
||||
deleteErr error
|
||||
createdID uint
|
||||
toggleCalls int
|
||||
updateCalls int
|
||||
createdCalls int
|
||||
nameExists bool
|
||||
nameExistsErr error
|
||||
deleteObserved chan struct{}
|
||||
}
|
||||
|
||||
func (r *applicationTaskRepo) CreateTask(_ context.Context, value *TimedTask) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.createdCalls++
|
||||
if value.ID == 0 {
|
||||
value.ID = r.createdID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *applicationTaskRepo) UpdateTask(context.Context, *TimedTask) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.updateCalls++
|
||||
return nil
|
||||
}
|
||||
func (r *applicationTaskRepo) DeleteTask(context.Context, uint) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.deleteErr != nil {
|
||||
return r.deleteErr
|
||||
}
|
||||
r.deleted = true
|
||||
if r.deleteObserved != nil {
|
||||
close(r.deleteObserved)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *applicationTaskRepo) FindTask(context.Context, uint) (*TimedTask, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (r *applicationTaskRepo) ListTasks(context.Context, int, int, *TimedTask) ([]*TimedTask, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (r *applicationTaskRepo) ToggleTask(context.Context, uint, bool) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.toggleCalls++
|
||||
return nil
|
||||
}
|
||||
func (r *applicationTaskRepo) RecordTaskLog(context.Context, *TimedTaskLog) error { return nil }
|
||||
func (r *applicationTaskRepo) ListTaskLogs(context.Context, int, int, uint, string) ([]*TimedTaskLog, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (r *applicationTaskRepo) CleanupLogs(context.Context) error { return nil }
|
||||
func (r *applicationTaskRepo) TaskNameExists(context.Context, string, uint) (bool, error) {
|
||||
return r.nameExists, r.nameExistsErr
|
||||
}
|
||||
|
||||
type applicationTaskRuntime struct {
|
||||
mu sync.Mutex
|
||||
scheduleErr error
|
||||
reloadErr error
|
||||
scheduleCalls int
|
||||
reloadCalls int
|
||||
removeCalls int
|
||||
removeBeforeDelete bool
|
||||
repo *applicationTaskRepo
|
||||
reloadContextActive bool
|
||||
}
|
||||
|
||||
func (r *applicationTaskRuntime) ScheduleID(context.Context, uint) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.scheduleCalls++
|
||||
return r.scheduleErr
|
||||
}
|
||||
func (r *applicationTaskRuntime) Remove(uint) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.removeCalls++
|
||||
if r.repo != nil {
|
||||
r.repo.mu.Lock()
|
||||
r.removeBeforeDelete = !r.repo.deleted
|
||||
r.repo.mu.Unlock()
|
||||
}
|
||||
}
|
||||
func (r *applicationTaskRuntime) TriggerID(context.Context, uint) error { return nil }
|
||||
func (r *applicationTaskRuntime) NextRuns() map[uint]time.Time { return nil }
|
||||
func (r *applicationTaskRuntime) Reload(ctx context.Context) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.reloadCalls++
|
||||
r.reloadContextActive = ctx.Err() == nil
|
||||
return r.reloadErr
|
||||
}
|
||||
func (r *applicationTaskRuntime) Subscribe(uint) chan []byte { return make(chan []byte) }
|
||||
func (r *applicationTaskRuntime) Unsubscribe(uint, chan []byte) {}
|
||||
|
||||
func validApplicationTask(id uint) *TimedTask {
|
||||
const methodName = "biz-test-application-task"
|
||||
RegisterTaskMethod(methodName, "test", func(context.Context, json.RawMessage) error { return nil })
|
||||
return &TimedTask{ID: id, Name: "test", Spec: "0 0 * * *", ExecutorType: TaskExecutorMethod, MethodName: methodName, Enabled: true}
|
||||
}
|
||||
|
||||
func TestTaskApplicationRepairsRuntimeAfterScheduleFailure(t *testing.T) {
|
||||
repo := &applicationTaskRepo{createdID: 41}
|
||||
runtime := &applicationTaskRuntime{scheduleErr: errors.New("schedule failed")}
|
||||
uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := uc.Create(ctx, validApplicationTask(0)); err != nil {
|
||||
t.Fatalf("Create() error = %v, want successful reload compensation", err)
|
||||
}
|
||||
if runtime.scheduleCalls != 1 || runtime.reloadCalls != 1 {
|
||||
t.Fatalf("runtime calls = schedule:%d reload:%d, want 1 and 1", runtime.scheduleCalls, runtime.reloadCalls)
|
||||
}
|
||||
if !runtime.reloadContextActive {
|
||||
t.Fatal("reload compensation inherited the canceled request context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskApplicationReturnsScheduleErrorWhenRepairFails(t *testing.T) {
|
||||
repo := &applicationTaskRepo{createdID: 42}
|
||||
runtime := &applicationTaskRuntime{scheduleErr: errors.New("schedule failed"), reloadErr: errors.New("reload failed")}
|
||||
uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime)
|
||||
|
||||
err := uc.Create(context.Background(), validApplicationTask(0))
|
||||
if !errors.Is(err, ErrTaskSchedule) {
|
||||
t.Fatalf("Create() error = %v, want ErrTaskSchedule", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskApplicationUpdateAndToggleUseRuntimeRepair(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
run func(*TaskApplicationUsecase) error
|
||||
}{
|
||||
{name: "update", run: func(uc *TaskApplicationUsecase) error {
|
||||
return uc.Update(context.Background(), validApplicationTask(7))
|
||||
}},
|
||||
{name: "toggle", run: func(uc *TaskApplicationUsecase) error { return uc.Toggle(context.Background(), 7, true) }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
repo := &applicationTaskRepo{}
|
||||
runtime := &applicationTaskRuntime{scheduleErr: errors.New("schedule failed")}
|
||||
uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime)
|
||||
if err := test.run(uc); err != nil {
|
||||
t.Fatalf("operation error = %v", err)
|
||||
}
|
||||
if runtime.scheduleCalls != 1 || runtime.reloadCalls != 1 {
|
||||
t.Fatalf("runtime calls = schedule:%d reload:%d, want 1 and 1", runtime.scheduleCalls, runtime.reloadCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskApplicationDeleteRemovesRuntimeAfterDatabase(t *testing.T) {
|
||||
repo := &applicationTaskRepo{}
|
||||
runtime := &applicationTaskRuntime{repo: repo}
|
||||
uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime)
|
||||
if err := uc.Delete(context.Background(), 8); err != nil {
|
||||
t.Fatalf("Delete() error = %v", err)
|
||||
}
|
||||
if runtime.removeBeforeDelete {
|
||||
t.Fatal("runtime entry was removed before the database row")
|
||||
}
|
||||
if runtime.removeCalls != 1 {
|
||||
t.Fatalf("Remove() calls = %d, want 1", runtime.removeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskApplicationDeleteKeepsRuntimeWhenDatabaseFails(t *testing.T) {
|
||||
repo := &applicationTaskRepo{deleteErr: errors.New("delete failed")}
|
||||
runtime := &applicationTaskRuntime{repo: repo}
|
||||
uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime)
|
||||
if err := uc.Delete(context.Background(), 8); err == nil {
|
||||
t.Fatal("Delete() error = nil, want database error")
|
||||
}
|
||||
if runtime.removeCalls != 0 {
|
||||
t.Fatalf("Remove() calls = %d, want 0", runtime.removeCalls)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user