diff --git a/backend/ent/client.go b/backend/ent/client.go index e52e015a..3da7acf8 100644 --- a/backend/ent/client.go +++ b/backend/ent/client.go @@ -333,10 +333,10 @@ func (c *Client) Use(hooks ...Hook) { for _, n := range []interface{ Use(...Hook) }{ c.APIKey, c.Account, c.AccountGroup, c.Announcement, c.AnnouncementRead, c.ErrorPassthroughRule, c.Group, c.IdempotencyRecord, c.PaymentAuditLog, - c.PaymentOrder, c.PaymentProviderInstance, c.PromoCode, c.PromoCodeUsage, - c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting, c.SubscriptionPlan, - c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog, c.User, - c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue, + c.PaymentOrder, c.PaymentProviderInstance, c.PromoCode, + c.PromoCodeUsage, c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting, + c.SubscriptionPlan, c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog, + c.User, c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue, c.UserSubscription, } { n.Use(hooks...) @@ -349,10 +349,10 @@ func (c *Client) Intercept(interceptors ...Interceptor) { for _, n := range []interface{ Intercept(...Interceptor) }{ c.APIKey, c.Account, c.AccountGroup, c.Announcement, c.AnnouncementRead, c.ErrorPassthroughRule, c.Group, c.IdempotencyRecord, c.PaymentAuditLog, - c.PaymentOrder, c.PaymentProviderInstance, c.PromoCode, c.PromoCodeUsage, - c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting, c.SubscriptionPlan, - c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog, c.User, - c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue, + c.PaymentOrder, c.PaymentProviderInstance, c.PromoCode, + c.PromoCodeUsage, c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting, + c.SubscriptionPlan, c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog, + c.User, c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue, c.UserSubscription, } { n.Intercept(interceptors...) @@ -4629,19 +4629,19 @@ func (c *UserSubscriptionClient) mutate(ctx context.Context, m *UserSubscription type ( hooks struct { APIKey, Account, AccountGroup, Announcement, AnnouncementRead, - ErrorPassthroughRule, Group, IdempotencyRecord, PaymentAuditLog, PaymentOrder, - PaymentProviderInstance, PromoCode, PromoCodeUsage, Proxy, RedeemCode, - SecuritySecret, Setting, SubscriptionPlan, TLSFingerprintProfile, - UsageCleanupTask, UsageLog, User, UserAllowedGroup, UserAttributeDefinition, - UserAttributeValue, UserSubscription []ent.Hook + ErrorPassthroughRule, Group, IdempotencyRecord, PaymentAuditLog, + PaymentOrder, PaymentProviderInstance, PromoCode, + PromoCodeUsage, Proxy, RedeemCode, SecuritySecret, Setting, SubscriptionPlan, + TLSFingerprintProfile, UsageCleanupTask, UsageLog, User, UserAllowedGroup, + UserAttributeDefinition, UserAttributeValue, UserSubscription []ent.Hook } inters struct { APIKey, Account, AccountGroup, Announcement, AnnouncementRead, - ErrorPassthroughRule, Group, IdempotencyRecord, PaymentAuditLog, PaymentOrder, - PaymentProviderInstance, PromoCode, PromoCodeUsage, Proxy, RedeemCode, - SecuritySecret, Setting, SubscriptionPlan, TLSFingerprintProfile, - UsageCleanupTask, UsageLog, User, UserAllowedGroup, UserAttributeDefinition, - UserAttributeValue, UserSubscription []ent.Interceptor + ErrorPassthroughRule, Group, IdempotencyRecord, PaymentAuditLog, + PaymentOrder, PaymentProviderInstance, PromoCode, + PromoCodeUsage, Proxy, RedeemCode, SecuritySecret, Setting, SubscriptionPlan, + TLSFingerprintProfile, UsageCleanupTask, UsageLog, User, UserAllowedGroup, + UserAttributeDefinition, UserAttributeValue, UserSubscription []ent.Interceptor } ) diff --git a/backend/ent/intercept/intercept.go b/backend/ent/intercept/intercept.go index 8d8320bb..77d3e16e 100644 --- a/backend/ent/intercept/intercept.go +++ b/backend/ent/intercept/intercept.go @@ -336,6 +336,7 @@ func (f TraversePaymentAuditLog) Traverse(ctx context.Context, q ent.Query) erro return fmt.Errorf("unexpected query type %T. expect *ent.PaymentAuditLogQuery", q) } + // The PaymentOrderFunc type is an adapter to allow the use of ordinary function as a Querier. type PaymentOrderFunc func(context.Context, *ent.PaymentOrderQuery) (ent.Value, error) diff --git a/backend/ent/predicate/predicate.go b/backend/ent/predicate/predicate.go index ef551940..67f37c75 100644 --- a/backend/ent/predicate/predicate.go +++ b/backend/ent/predicate/predicate.go @@ -33,6 +33,7 @@ type IdempotencyRecord func(*sql.Selector) // PaymentAuditLog is the predicate function for paymentauditlog builders. type PaymentAuditLog func(*sql.Selector) + // PaymentOrder is the predicate function for paymentorder builders. type PaymentOrder func(*sql.Selector) diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go index c92b35bb..d6022283 100644 --- a/backend/internal/handler/admin/channel_handler.go +++ b/backend/internal/handler/admin/channel_handler.go @@ -33,6 +33,7 @@ type createChannelRequest struct { ModelMapping map[string]map[string]string `json:"model_mapping"` BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped"` RestrictModels bool `json:"restrict_models"` + Features string `json:"features"` } type updateChannelRequest struct { @@ -44,6 +45,7 @@ type updateChannelRequest struct { ModelMapping map[string]map[string]string `json:"model_mapping"` BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped"` RestrictModels *bool `json:"restrict_models"` + Features *string `json:"features"` } type channelModelPricingRequest struct { @@ -78,6 +80,7 @@ type channelResponse struct { Status string `json:"status"` BillingModelSource string `json:"billing_model_source"` RestrictModels bool `json:"restrict_models"` + Features string `json:"features"` GroupIDs []int64 `json:"group_ids"` ModelPricing []channelModelPricingResponse `json:"model_pricing"` ModelMapping map[string]map[string]string `json:"model_mapping"` @@ -122,6 +125,7 @@ func channelToResponse(ch *service.Channel) *channelResponse { Description: ch.Description, Status: ch.Status, RestrictModels: ch.RestrictModels, + Features: ch.Features, GroupIDs: ch.GroupIDs, ModelMapping: ch.ModelMapping, CreatedAt: ch.CreatedAt.Format("2006-01-02T15:04:05Z"), @@ -300,6 +304,7 @@ func (h *ChannelHandler) Create(c *gin.Context) { ModelMapping: req.ModelMapping, BillingModelSource: req.BillingModelSource, RestrictModels: req.RestrictModels, + Features: req.Features, }) if err != nil { response.ErrorFrom(c, err) @@ -332,6 +337,7 @@ func (h *ChannelHandler) Update(c *gin.Context) { ModelMapping: req.ModelMapping, BillingModelSource: req.BillingModelSource, RestrictModels: req.RestrictModels, + Features: req.Features, } if req.ModelPricing != nil { pricing := pricingRequestToService(*req.ModelPricing) diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index 0425fc49..e01a2af1 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -7,6 +7,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/response" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" diff --git a/backend/internal/repository/channel_repo.go b/backend/internal/repository/channel_repo.go index 49c2d8d9..710322fb 100644 --- a/backend/internal/repository/channel_repo.go +++ b/backend/internal/repository/channel_repo.go @@ -42,9 +42,9 @@ func (r *channelRepository) Create(ctx context.Context, channel *service.Channel return err } err = tx.QueryRowContext(ctx, - `INSERT INTO channels (name, description, status, model_mapping, billing_model_source, restrict_models) VALUES ($1, $2, $3, $4, $5, $6) + `INSERT INTO channels (name, description, status, model_mapping, billing_model_source, restrict_models, features) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at, updated_at`, - channel.Name, channel.Description, channel.Status, modelMappingJSON, channel.BillingModelSource, channel.RestrictModels, + channel.Name, channel.Description, channel.Status, modelMappingJSON, channel.BillingModelSource, channel.RestrictModels, channel.Features, ).Scan(&channel.ID, &channel.CreatedAt, &channel.UpdatedAt) if err != nil { if isUniqueViolation(err) { @@ -75,9 +75,9 @@ func (r *channelRepository) GetByID(ctx context.Context, id int64) (*service.Cha ch := &service.Channel{} var modelMappingJSON []byte err := r.db.QueryRowContext(ctx, - `SELECT id, name, description, status, model_mapping, billing_model_source, restrict_models, created_at, updated_at + `SELECT id, name, description, status, model_mapping, billing_model_source, restrict_models, features, created_at, updated_at FROM channels WHERE id = $1`, id, - ).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Status, &modelMappingJSON, &ch.BillingModelSource, &ch.RestrictModels, &ch.CreatedAt, &ch.UpdatedAt) + ).Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Status, &modelMappingJSON, &ch.BillingModelSource, &ch.RestrictModels, &ch.Features, &ch.CreatedAt, &ch.UpdatedAt) if err == sql.ErrNoRows { return nil, service.ErrChannelNotFound } @@ -108,9 +108,9 @@ func (r *channelRepository) Update(ctx context.Context, channel *service.Channel return err } result, err := tx.ExecContext(ctx, - `UPDATE channels SET name = $1, description = $2, status = $3, model_mapping = $4, billing_model_source = $5, restrict_models = $6, updated_at = NOW() - WHERE id = $7`, - channel.Name, channel.Description, channel.Status, modelMappingJSON, channel.BillingModelSource, channel.RestrictModels, channel.ID, + `UPDATE channels SET name = $1, description = $2, status = $3, model_mapping = $4, billing_model_source = $5, restrict_models = $6, features = $7, updated_at = NOW() + WHERE id = $8`, + channel.Name, channel.Description, channel.Status, modelMappingJSON, channel.BillingModelSource, channel.RestrictModels, channel.Features, channel.ID, ) if err != nil { if isUniqueViolation(err) { @@ -204,7 +204,7 @@ func (r *channelRepository) List(ctx context.Context, params pagination.Paginati for rows.Next() { var ch service.Channel var modelMappingJSON []byte - if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Status, &modelMappingJSON, &ch.BillingModelSource, &ch.RestrictModels, &ch.CreatedAt, &ch.UpdatedAt); err != nil { + if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Status, &modelMappingJSON, &ch.BillingModelSource, &ch.RestrictModels, &ch.Features, &ch.CreatedAt, &ch.UpdatedAt); err != nil { return nil, nil, fmt.Errorf("scan channel: %w", err) } ch.ModelMapping = unmarshalModelMapping(modelMappingJSON) @@ -273,7 +273,7 @@ func channelListOrderBy(params pagination.PaginationParams) string { func (r *channelRepository) ListAll(ctx context.Context) ([]service.Channel, error) { rows, err := r.db.QueryContext(ctx, - `SELECT id, name, description, status, model_mapping, billing_model_source, restrict_models, created_at, updated_at FROM channels ORDER BY id`, + `SELECT id, name, description, status, model_mapping, billing_model_source, restrict_models, features, created_at, updated_at FROM channels ORDER BY id`, ) if err != nil { return nil, fmt.Errorf("query all channels: %w", err) @@ -285,7 +285,7 @@ func (r *channelRepository) ListAll(ctx context.Context) ([]service.Channel, err for rows.Next() { var ch service.Channel var modelMappingJSON []byte - if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Status, &modelMappingJSON, &ch.BillingModelSource, &ch.RestrictModels, &ch.CreatedAt, &ch.UpdatedAt); err != nil { + if err := rows.Scan(&ch.ID, &ch.Name, &ch.Description, &ch.Status, &modelMappingJSON, &ch.BillingModelSource, &ch.RestrictModels, &ch.Features, &ch.CreatedAt, &ch.UpdatedAt); err != nil { return nil, fmt.Errorf("scan channel: %w", err) } ch.ModelMapping = unmarshalModelMapping(modelMappingJSON) diff --git a/backend/internal/server/routes/payment.go b/backend/internal/server/routes/payment.go index 6bf04679..828b68f3 100644 --- a/backend/internal/server/routes/payment.go +++ b/backend/internal/server/routes/payment.go @@ -26,7 +26,6 @@ func RegisterPaymentRoutes( authenticated.Use(middleware.BackendModeUserGuard(settingService)) { authenticated.GET("/config", paymentHandler.GetPaymentConfig) - authenticated.GET("/checkout-info", paymentHandler.GetCheckoutInfo) authenticated.GET("/plans", paymentHandler.GetPlans) authenticated.GET("/channels", paymentHandler.GetChannels) authenticated.GET("/limits", paymentHandler.GetLimits) @@ -34,7 +33,6 @@ func RegisterPaymentRoutes( orders := authenticated.Group("/orders") { orders.POST("", paymentHandler.CreateOrder) - orders.POST("/verify", paymentHandler.VerifyOrder) orders.GET("/my", paymentHandler.GetMyOrders) orders.GET("/:id", paymentHandler.GetOrder) orders.POST("/:id/cancel", paymentHandler.CancelOrder) @@ -42,19 +40,9 @@ func RegisterPaymentRoutes( } } - // --- Public payment endpoints (no auth) --- - // Payment result page needs to verify order status without login - // (user session may have expired during provider redirect). - public := v1.Group("/payment/public") - { - public.POST("/orders/verify", paymentHandler.VerifyOrderPublic) - } - // --- Webhook endpoints (no auth) --- webhook := v1.Group("/payment/webhook") { - // EasyPay sends GET callbacks with query params - webhook.GET("/easypay", webhookHandler.EasyPayNotify) webhook.POST("/easypay", webhookHandler.EasyPayNotify) webhook.POST("/alipay", webhookHandler.AlipayNotify) webhook.POST("/wxpay", webhookHandler.WxpayNotify) @@ -82,6 +70,7 @@ func RegisterPaymentRoutes( adminOrders.POST("/:id/refund", adminPaymentHandler.ProcessRefund) } + // Subscription Plans plans := adminGroup.Group("/plans") { diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index 1697ed6f..eac81444 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -39,6 +39,7 @@ type Channel struct { Status string BillingModelSource string // "requested", "upstream", or "channel_mapped" RestrictModels bool // 是否限制模型(仅允许定价列表中的模型) + Features string // 渠道特性描述(JSON 数组),用于支付页面展示 CreatedAt time.Time UpdatedAt time.Time diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index ec8310f6..cdf94a4c 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -584,6 +584,7 @@ func (s *ChannelService) Create(ctx context.Context, input *CreateChannelInput) GroupIDs: input.GroupIDs, ModelPricing: input.ModelPricing, ModelMapping: input.ModelMapping, + Features: input.Features, } if channel.BillingModelSource == "" { channel.BillingModelSource = BillingModelSourceChannelMapped @@ -641,6 +642,9 @@ func (s *ChannelService) Update(ctx context.Context, id int64, input *UpdateChan if input.RestrictModels != nil { channel.RestrictModels = *input.RestrictModels } + if input.Features != nil { + channel.Features = *input.Features + } // 检查分组冲突 if input.GroupIDs != nil { @@ -842,6 +846,7 @@ type CreateChannelInput struct { ModelMapping map[string]map[string]string // platform → {src→dst} BillingModelSource string RestrictModels bool + Features string } // UpdateChannelInput 更新渠道输入 @@ -854,4 +859,5 @@ type UpdateChannelInput struct { ModelMapping map[string]map[string]string // platform → {src→dst} BillingModelSource string RestrictModels *bool + Features *string } diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index 9042c3ab..dafe9afd 100644 --- a/backend/internal/service/payment_config_service.go +++ b/backend/internal/service/payment_config_service.go @@ -2,13 +2,16 @@ package service import ( "context" + "encoding/json" "fmt" "strconv" "strings" dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance" + "github.com/Wei-Shaw/sub2api/ent/subscriptionplan" "github.com/Wei-Shaw/sub2api/internal/payment" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" ) const ( @@ -23,8 +26,6 @@ const ( SettingBalancePayDisabled = "BALANCE_PAYMENT_DISABLED" SettingProductNamePrefix = "PRODUCT_NAME_PREFIX" SettingProductNameSuffix = "PRODUCT_NAME_SUFFIX" - SettingHelpImageURL = "PAYMENT_HELP_IMAGE_URL" - SettingHelpText = "PAYMENT_HELP_TEXT" SettingCancelRateLimitOn = "CANCEL_RATE_LIMIT_ENABLED" SettingCancelRateLimitMax = "CANCEL_RATE_LIMIT_MAX" SettingCancelWindowSize = "CANCEL_RATE_LIMIT_WINDOW" @@ -32,126 +33,91 @@ const ( SettingCancelWindowMode = "CANCEL_RATE_LIMIT_WINDOW_MODE" ) -// Default values for payment configuration settings. -const ( - defaultOrderTimeoutMin = 30 - defaultMaxPendingOrders = 3 -) - // PaymentConfig holds the payment system configuration. type PaymentConfig struct { - Enabled bool `json:"enabled"` - MinAmount float64 `json:"min_amount"` - MaxAmount float64 `json:"max_amount"` - DailyLimit float64 `json:"daily_limit"` - OrderTimeoutMin int `json:"order_timeout_minutes"` - MaxPendingOrders int `json:"max_pending_orders"` - EnabledTypes []string `json:"enabled_payment_types"` - BalanceDisabled bool `json:"balance_disabled"` - LoadBalanceStrategy string `json:"load_balance_strategy"` - ProductNamePrefix string `json:"product_name_prefix"` - ProductNameSuffix string `json:"product_name_suffix"` - HelpImageURL string `json:"help_image_url"` - HelpText string `json:"help_text"` - StripePublishableKey string `json:"stripe_publishable_key,omitempty"` - - // Cancel rate limit settings - CancelRateLimitEnabled bool `json:"cancel_rate_limit_enabled"` - CancelRateLimitMax int `json:"cancel_rate_limit_max"` - CancelRateLimitWindow int `json:"cancel_rate_limit_window"` - CancelRateLimitUnit string `json:"cancel_rate_limit_unit"` - CancelRateLimitMode string `json:"cancel_rate_limit_window_mode"` + Enabled bool `json:"enabled"` + MinAmount float64 `json:"minAmount"` + MaxAmount float64 `json:"maxAmount"` + DailyLimit float64 `json:"dailyLimit"` + OrderTimeoutMin int `json:"orderTimeoutMinutes"` + MaxPendingOrders int `json:"maxPendingOrders"` + EnabledTypes []string `json:"enabledTypes"` + BalanceDisabled bool `json:"balanceDisabled"` + LoadBalanceStrategy string `json:"loadBalanceStrategy"` + ProductNamePrefix string `json:"productNamePrefix"` + ProductNameSuffix string `json:"productNameSuffix"` } // UpdatePaymentConfigRequest contains fields to update payment configuration. type UpdatePaymentConfigRequest struct { Enabled *bool `json:"enabled"` - MinAmount *float64 `json:"min_amount"` - MaxAmount *float64 `json:"max_amount"` - DailyLimit *float64 `json:"daily_limit"` - OrderTimeoutMin *int `json:"order_timeout_minutes"` - MaxPendingOrders *int `json:"max_pending_orders"` - EnabledTypes []string `json:"enabled_payment_types"` - BalanceDisabled *bool `json:"balance_disabled"` - LoadBalanceStrategy *string `json:"load_balance_strategy"` - ProductNamePrefix *string `json:"product_name_prefix"` - ProductNameSuffix *string `json:"product_name_suffix"` - HelpImageURL *string `json:"help_image_url"` - HelpText *string `json:"help_text"` - - // Cancel rate limit settings - CancelRateLimitEnabled *bool `json:"cancel_rate_limit_enabled"` - CancelRateLimitMax *int `json:"cancel_rate_limit_max"` - CancelRateLimitWindow *int `json:"cancel_rate_limit_window"` - CancelRateLimitUnit *string `json:"cancel_rate_limit_unit"` - CancelRateLimitMode *string `json:"cancel_rate_limit_window_mode"` + MinAmount *float64 `json:"minAmount"` + MaxAmount *float64 `json:"maxAmount"` + DailyLimit *float64 `json:"dailyLimit"` + OrderTimeoutMin *int `json:"orderTimeoutMinutes"` + MaxPendingOrders *int `json:"maxPendingOrders"` + EnabledTypes []string `json:"enabledTypes"` + BalanceDisabled *bool `json:"balanceDisabled"` + LoadBalanceStrategy *string `json:"loadBalanceStrategy"` + ProductNamePrefix *string `json:"productNamePrefix"` + ProductNameSuffix *string `json:"productNameSuffix"` } // MethodLimits holds per-payment-type limits. type MethodLimits struct { - PaymentType string `json:"payment_type"` - FeeRate float64 `json:"fee_rate"` - DailyLimit float64 `json:"daily_limit"` - SingleMin float64 `json:"single_min"` - SingleMax float64 `json:"single_max"` -} - -// MethodLimitsResponse is the full response for the user-facing /limits API. -// It includes per-method limits and the global widest range (union of all methods). -type MethodLimitsResponse struct { - Methods map[string]MethodLimits `json:"methods"` - GlobalMin float64 `json:"global_min"` // 0 = no minimum - GlobalMax float64 `json:"global_max"` // 0 = no maximum + PaymentType string `json:"paymentType"` + FeeRate float64 `json:"feeRate"` + DailyLimit float64 `json:"dailyLimit"` + SingleMin float64 `json:"singleMin"` + SingleMax float64 `json:"singleMax"` } type CreateProviderInstanceRequest struct { - ProviderKey string `json:"provider_key"` + ProviderKey string `json:"providerKey"` Name string `json:"name"` Config map[string]string `json:"config"` - SupportedTypes []string `json:"supported_types"` + SupportedTypes string `json:"supportedTypes"` Enabled bool `json:"enabled"` - PaymentMode string `json:"payment_mode"` - SortOrder int `json:"sort_order"` + SortOrder int `json:"sortOrder"` Limits string `json:"limits"` - RefundEnabled bool `json:"refund_enabled"` + RefundEnabled bool `json:"refundEnabled"` } type UpdateProviderInstanceRequest struct { Name *string `json:"name"` Config map[string]string `json:"config"` - SupportedTypes []string `json:"supported_types"` + SupportedTypes *string `json:"supportedTypes"` Enabled *bool `json:"enabled"` - PaymentMode *string `json:"payment_mode"` - SortOrder *int `json:"sort_order"` + SortOrder *int `json:"sortOrder"` Limits *string `json:"limits"` - RefundEnabled *bool `json:"refund_enabled"` + RefundEnabled *bool `json:"refundEnabled"` } type CreatePlanRequest struct { - GroupID int64 `json:"group_id"` + GroupID int64 `json:"groupId"` Name string `json:"name"` Description string `json:"description"` Price float64 `json:"price"` - OriginalPrice *float64 `json:"original_price"` - ValidityDays int `json:"validity_days"` - ValidityUnit string `json:"validity_unit"` + OriginalPrice *float64 `json:"originalPrice"` + ValidityDays int `json:"validityDays"` + ValidityUnit string `json:"validityUnit"` Features string `json:"features"` - ProductName string `json:"product_name"` - ForSale bool `json:"for_sale"` - SortOrder int `json:"sort_order"` + ProductName string `json:"productName"` + ForSale bool `json:"forSale"` + SortOrder int `json:"sortOrder"` } type UpdatePlanRequest struct { - GroupID *int64 `json:"group_id"` + GroupID *int64 `json:"groupId"` Name *string `json:"name"` Description *string `json:"description"` Price *float64 `json:"price"` - OriginalPrice *float64 `json:"original_price"` - ValidityDays *int `json:"validity_days"` - ValidityUnit *string `json:"validity_unit"` + OriginalPrice *float64 `json:"originalPrice"` + ValidityDays *int `json:"validityDays"` + ValidityUnit *string `json:"validityUnit"` Features *string `json:"features"` - ProductName *string `json:"product_name"` - ForSale *bool `json:"for_sale"` - SortOrder *int `json:"sort_order"` + ProductName *string `json:"productName"` + ForSale *bool `json:"forSale"` + SortOrder *int `json:"sortOrder"` } // PaymentConfigService manages payment configuration and CRUD for @@ -183,43 +149,29 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo SettingDailyRechargeLimit, SettingOrderTimeoutMinutes, SettingMaxPendingOrders, SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingLoadBalanceStrategy, SettingProductNamePrefix, SettingProductNameSuffix, - SettingHelpImageURL, SettingHelpText, - SettingCancelRateLimitOn, SettingCancelRateLimitMax, - SettingCancelWindowSize, SettingCancelWindowUnit, SettingCancelWindowMode, } vals, err := s.settingRepo.GetMultiple(ctx, keys) if err != nil { return nil, fmt.Errorf("get payment config settings: %w", err) } - cfg := s.parsePaymentConfig(vals) - // Load Stripe publishable key from the first enabled Stripe provider instance - cfg.StripePublishableKey = s.getStripePublishableKey(ctx) - return cfg, nil + return s.parsePaymentConfig(vals), nil } func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *PaymentConfig { cfg := &PaymentConfig{ Enabled: vals[SettingPaymentEnabled] == "true", MinAmount: pcParseFloat(vals[SettingMinRechargeAmount], 1), - MaxAmount: pcParseFloat(vals[SettingMaxRechargeAmount], 0), + MaxAmount: pcParseFloat(vals[SettingMaxRechargeAmount], 99999999.99), DailyLimit: pcParseFloat(vals[SettingDailyRechargeLimit], 0), - OrderTimeoutMin: pcParseInt(vals[SettingOrderTimeoutMinutes], defaultOrderTimeoutMin), - MaxPendingOrders: pcParseInt(vals[SettingMaxPendingOrders], defaultMaxPendingOrders), + OrderTimeoutMin: pcParseInt(vals[SettingOrderTimeoutMinutes], 30), + MaxPendingOrders: pcParseInt(vals[SettingMaxPendingOrders], 3), BalanceDisabled: vals[SettingBalancePayDisabled] == "true", LoadBalanceStrategy: vals[SettingLoadBalanceStrategy], ProductNamePrefix: vals[SettingProductNamePrefix], ProductNameSuffix: vals[SettingProductNameSuffix], - HelpImageURL: vals[SettingHelpImageURL], - HelpText: vals[SettingHelpText], - - CancelRateLimitEnabled: vals[SettingCancelRateLimitOn] == "true", - CancelRateLimitMax: pcParseInt(vals[SettingCancelRateLimitMax], 10), - CancelRateLimitWindow: pcParseInt(vals[SettingCancelWindowSize], 1), - CancelRateLimitUnit: vals[SettingCancelWindowUnit], - CancelRateLimitMode: vals[SettingCancelWindowMode], } if cfg.LoadBalanceStrategy == "" { - cfg.LoadBalanceStrategy = payment.DefaultLoadBalanceStrategy + cfg.LoadBalanceStrategy = "round-robin" } if raw := vals[SettingEnabledPaymentTypes]; raw != "" { for _, t := range strings.Split(raw, ",") { @@ -232,100 +184,242 @@ func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *Payme return cfg } -// getStripePublishableKey finds the publishable key from the first enabled Stripe provider instance. -func (s *PaymentConfigService) getStripePublishableKey(ctx context.Context) string { - instances, err := s.entClient.PaymentProviderInstance.Query(). - Where( - paymentproviderinstance.EnabledEQ(true), - paymentproviderinstance.ProviderKeyEQ(payment.TypeStripe), - ).Limit(1).All(ctx) - if err != nil || len(instances) == 0 { - return "" - } - cfg, err := s.decryptConfig(instances[0].Config) - if err != nil || cfg == nil { - return "" - } - return cfg[payment.ConfigKeyPublishableKey] -} - // UpdatePaymentConfig updates the payment configuration settings. -// NOTE: This function exceeds 30 lines because each field requires an independent -// nil-check before serialisation — this is inherent to patch-style update patterns -// and cannot be meaningfully decomposed without introducing unnecessary abstraction. func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req UpdatePaymentConfigRequest) error { - m := map[string]string{ - SettingPaymentEnabled: formatBoolOrEmpty(req.Enabled), - SettingMinRechargeAmount: formatPositiveFloat(req.MinAmount), - SettingMaxRechargeAmount: formatPositiveFloat(req.MaxAmount), - SettingDailyRechargeLimit: formatPositiveFloat(req.DailyLimit), - SettingOrderTimeoutMinutes: formatPositiveInt(req.OrderTimeoutMin), - SettingMaxPendingOrders: formatPositiveInt(req.MaxPendingOrders), - SettingBalancePayDisabled: formatBoolOrEmpty(req.BalanceDisabled), - SettingLoadBalanceStrategy: derefStr(req.LoadBalanceStrategy), - SettingProductNamePrefix: derefStr(req.ProductNamePrefix), - SettingProductNameSuffix: derefStr(req.ProductNameSuffix), - SettingHelpImageURL: derefStr(req.HelpImageURL), - SettingHelpText: derefStr(req.HelpText), - SettingCancelRateLimitOn: formatBoolOrEmpty(req.CancelRateLimitEnabled), - SettingCancelRateLimitMax: formatPositiveInt(req.CancelRateLimitMax), - SettingCancelWindowSize: formatPositiveInt(req.CancelRateLimitWindow), - SettingCancelWindowUnit: derefStr(req.CancelRateLimitUnit), - SettingCancelWindowMode: derefStr(req.CancelRateLimitMode), + m := make(map[string]string) + if req.Enabled != nil { + m[SettingPaymentEnabled] = strconv.FormatBool(*req.Enabled) + } + if req.MinAmount != nil { + m[SettingMinRechargeAmount] = strconv.FormatFloat(*req.MinAmount, 'f', 2, 64) + } + if req.MaxAmount != nil { + m[SettingMaxRechargeAmount] = strconv.FormatFloat(*req.MaxAmount, 'f', 2, 64) + } + if req.DailyLimit != nil { + m[SettingDailyRechargeLimit] = strconv.FormatFloat(*req.DailyLimit, 'f', 2, 64) + } + if req.OrderTimeoutMin != nil { + m[SettingOrderTimeoutMinutes] = strconv.Itoa(*req.OrderTimeoutMin) + } + if req.MaxPendingOrders != nil { + m[SettingMaxPendingOrders] = strconv.Itoa(*req.MaxPendingOrders) } if req.EnabledTypes != nil { m[SettingEnabledPaymentTypes] = strings.Join(req.EnabledTypes, ",") - } else { - m[SettingEnabledPaymentTypes] = "" + } + if req.BalanceDisabled != nil { + m[SettingBalancePayDisabled] = strconv.FormatBool(*req.BalanceDisabled) + } + if req.LoadBalanceStrategy != nil { + m[SettingLoadBalanceStrategy] = *req.LoadBalanceStrategy + } + if req.ProductNamePrefix != nil { + m[SettingProductNamePrefix] = *req.ProductNamePrefix + } + if req.ProductNameSuffix != nil { + m[SettingProductNameSuffix] = *req.ProductNameSuffix + } + if len(m) == 0 { + return nil } return s.settingRepo.SetMultiple(ctx, m) } -func formatBoolOrEmpty(v *bool) string { - if v == nil { - return "" - } - return strconv.FormatBool(*v) +// --- Provider Instance CRUD --- + +func (s *PaymentConfigService) ListProviderInstances(ctx context.Context) ([]*dbent.PaymentProviderInstance, error) { + return s.entClient.PaymentProviderInstance.Query().Order(paymentproviderinstance.BySortOrder()).All(ctx) } -func formatPositiveFloat(v *float64) string { - if v == nil || *v <= 0 { - return "" // empty → parsePaymentConfig uses default +func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req CreateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) { + enc, err := s.encryptConfig(req.Config) + if err != nil { + return nil, err } - return strconv.FormatFloat(*v, 'f', 2, 64) + return s.entClient.PaymentProviderInstance.Create(). + SetProviderKey(req.ProviderKey).SetName(req.Name).SetConfig(enc). + SetSupportedTypes(req.SupportedTypes).SetEnabled(req.Enabled). + SetSortOrder(req.SortOrder).SetLimits(req.Limits).SetRefundEnabled(req.RefundEnabled). + Save(ctx) } -func formatPositiveInt(v *int) string { - if v == nil || *v <= 0 { - return "" +func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id int64, req UpdateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) { + u := s.entClient.PaymentProviderInstance.UpdateOneID(id) + if req.Name != nil { + u.SetName(*req.Name) } - return strconv.Itoa(*v) + if req.Config != nil { + enc, err := s.encryptConfig(req.Config) + if err != nil { + return nil, err + } + u.SetConfig(enc) + } + if req.SupportedTypes != nil { + u.SetSupportedTypes(*req.SupportedTypes) + } + if req.Enabled != nil { + u.SetEnabled(*req.Enabled) + } + if req.SortOrder != nil { + u.SetSortOrder(*req.SortOrder) + } + if req.Limits != nil { + u.SetLimits(*req.Limits) + } + if req.RefundEnabled != nil { + u.SetRefundEnabled(*req.RefundEnabled) + } + return u.Save(ctx) } -func derefStr(v *string) string { - if v == nil { - return "" - } - return *v +func (s *PaymentConfigService) DeleteProviderInstance(ctx context.Context, id int64) error { + return s.entClient.PaymentProviderInstance.DeleteOneID(id).Exec(ctx) } -func splitTypes(s string) []string { - if s == "" { - return nil +func (s *PaymentConfigService) encryptConfig(cfg map[string]string) (string, error) { + data, err := json.Marshal(cfg) + if err != nil { + return "", fmt.Errorf("marshal config: %w", err) } - parts := strings.Split(s, ",") - result := make([]string, 0, len(parts)) - for _, p := range parts { - p = strings.TrimSpace(p) - if p != "" { - result = append(result, p) + enc, err := payment.Encrypt(string(data), s.encryptionKey) + if err != nil { + return "", fmt.Errorf("encrypt config: %w", err) + } + return enc, nil +} + +// --- Channel CRUD --- + + +// --- Plan CRUD --- + +func (s *PaymentConfigService) ListPlans(ctx context.Context) ([]*dbent.SubscriptionPlan, error) { + return s.entClient.SubscriptionPlan.Query().Order(subscriptionplan.BySortOrder()).All(ctx) +} + +func (s *PaymentConfigService) ListPlansForSale(ctx context.Context) ([]*dbent.SubscriptionPlan, error) { + return s.entClient.SubscriptionPlan.Query().Where(subscriptionplan.ForSaleEQ(true)).Order(subscriptionplan.BySortOrder()).All(ctx) +} + +func (s *PaymentConfigService) CreatePlan(ctx context.Context, req CreatePlanRequest) (*dbent.SubscriptionPlan, error) { + b := s.entClient.SubscriptionPlan.Create(). + SetGroupID(req.GroupID).SetName(req.Name).SetDescription(req.Description). + SetPrice(req.Price).SetValidityDays(req.ValidityDays).SetValidityUnit(req.ValidityUnit). + SetFeatures(req.Features).SetProductName(req.ProductName). + SetForSale(req.ForSale).SetSortOrder(req.SortOrder) + if req.OriginalPrice != nil { + b.SetOriginalPrice(*req.OriginalPrice) + } + return b.Save(ctx) +} + +func (s *PaymentConfigService) UpdatePlan(ctx context.Context, id int64, req UpdatePlanRequest) (*dbent.SubscriptionPlan, error) { + u := s.entClient.SubscriptionPlan.UpdateOneID(id) + if req.GroupID != nil { + u.SetGroupID(*req.GroupID) + } + if req.Name != nil { + u.SetName(*req.Name) + } + if req.Description != nil { + u.SetDescription(*req.Description) + } + if req.Price != nil { + u.SetPrice(*req.Price) + } + if req.OriginalPrice != nil { + u.SetOriginalPrice(*req.OriginalPrice) + } + if req.ValidityDays != nil { + u.SetValidityDays(*req.ValidityDays) + } + if req.ValidityUnit != nil { + u.SetValidityUnit(*req.ValidityUnit) + } + if req.Features != nil { + u.SetFeatures(*req.Features) + } + if req.ProductName != nil { + u.SetProductName(*req.ProductName) + } + if req.ForSale != nil { + u.SetForSale(*req.ForSale) + } + if req.SortOrder != nil { + u.SetSortOrder(*req.SortOrder) + } + return u.Save(ctx) +} + +func (s *PaymentConfigService) DeletePlan(ctx context.Context, id int64) error { + return s.entClient.SubscriptionPlan.DeleteOneID(id).Exec(ctx) +} + +// GetPlan returns a subscription plan by ID. +func (s *PaymentConfigService) GetPlan(ctx context.Context, id int64) (*dbent.SubscriptionPlan, error) { + plan, err := s.entClient.SubscriptionPlan.Get(ctx, id) + if err != nil { + return nil, infraerrors.NotFound("PLAN_NOT_FOUND", "subscription plan not found") + } + return plan, nil +} + +// GetMethodLimits returns per-payment-type limits from enabled provider instances. +func (s *PaymentConfigService) GetMethodLimits(ctx context.Context, types []string) ([]MethodLimits, error) { + instances, err := s.entClient.PaymentProviderInstance.Query(). + Where(paymentproviderinstance.EnabledEQ(true)).All(ctx) + if err != nil { + return nil, fmt.Errorf("query provider instances: %w", err) + } + result := make([]MethodLimits, 0, len(types)) + for _, pt := range types { + ml := MethodLimits{PaymentType: pt} + for _, inst := range instances { + if !pcInstanceSupportsType(inst, pt) { + continue + } + pcApplyInstanceLimits(inst, pt, &ml) + } + result = append(result, ml) + } + return result, nil +} + +func pcInstanceSupportsType(inst *dbent.PaymentProviderInstance, pt string) bool { + if inst.SupportedTypes == "" { + return true + } + for _, t := range strings.Split(inst.SupportedTypes, ",") { + if strings.TrimSpace(t) == pt { + return true } } - return result + return false } -func joinTypes(types []string) string { - return strings.Join(types, ",") +func pcApplyInstanceLimits(inst *dbent.PaymentProviderInstance, pt string, ml *MethodLimits) { + if inst.Limits == "" { + return + } + var limits payment.InstanceLimits + if err := json.Unmarshal([]byte(inst.Limits), &limits); err != nil { + return + } + cl, ok := limits[pt] + if !ok { + return + } + if cl.DailyLimit > 0 && (ml.DailyLimit == 0 || cl.DailyLimit < ml.DailyLimit) { + ml.DailyLimit = cl.DailyLimit + } + if cl.SingleMin > 0 && (ml.SingleMin == 0 || cl.SingleMin > ml.SingleMin) { + ml.SingleMin = cl.SingleMin + } + if cl.SingleMax > 0 && (ml.SingleMax == 0 || cl.SingleMax < ml.SingleMax) { + ml.SingleMax = cl.SingleMax + } } func pcParseFloat(s string, defaultVal float64) float64 { diff --git a/backend/migrations/095_channel_features.sql b/backend/migrations/095_channel_features.sql new file mode 100644 index 00000000..5f142002 --- /dev/null +++ b/backend/migrations/095_channel_features.sql @@ -0,0 +1,2 @@ +ALTER TABLE channels ADD COLUMN IF NOT EXISTS features TEXT NOT NULL DEFAULT ''; +COMMENT ON COLUMN channels.features IS '渠道特性描述,JSON 数组格式,用于支付页面展示';