refactor: remove PaymentChannel, reuse upstream Channel with features field
- Delete payment_channels table and PaymentChannel Ent schema - Add `features` column to upstream channels table (migration 095) - Add Features field to Channel struct, input types, handler request/response - Payment user/admin handlers now use ChannelService directly - Remove Channel CRUD from PaymentConfigService and admin payment routes - Remove "渠道管理" tab from admin orders page (use /admin/channels)
This commit is contained in:
@@ -333,10 +333,10 @@ func (c *Client) Use(hooks ...Hook) {
|
|||||||
for _, n := range []interface{ Use(...Hook) }{
|
for _, n := range []interface{ Use(...Hook) }{
|
||||||
c.APIKey, c.Account, c.AccountGroup, c.Announcement, c.AnnouncementRead,
|
c.APIKey, c.Account, c.AccountGroup, c.Announcement, c.AnnouncementRead,
|
||||||
c.ErrorPassthroughRule, c.Group, c.IdempotencyRecord, c.PaymentAuditLog,
|
c.ErrorPassthroughRule, c.Group, c.IdempotencyRecord, c.PaymentAuditLog,
|
||||||
c.PaymentOrder, c.PaymentProviderInstance, c.PromoCode, c.PromoCodeUsage,
|
c.PaymentOrder, c.PaymentProviderInstance, c.PromoCode,
|
||||||
c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting, c.SubscriptionPlan,
|
c.PromoCodeUsage, c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting,
|
||||||
c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog, c.User,
|
c.SubscriptionPlan, c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog,
|
||||||
c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue,
|
c.User, c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue,
|
||||||
c.UserSubscription,
|
c.UserSubscription,
|
||||||
} {
|
} {
|
||||||
n.Use(hooks...)
|
n.Use(hooks...)
|
||||||
@@ -349,10 +349,10 @@ func (c *Client) Intercept(interceptors ...Interceptor) {
|
|||||||
for _, n := range []interface{ Intercept(...Interceptor) }{
|
for _, n := range []interface{ Intercept(...Interceptor) }{
|
||||||
c.APIKey, c.Account, c.AccountGroup, c.Announcement, c.AnnouncementRead,
|
c.APIKey, c.Account, c.AccountGroup, c.Announcement, c.AnnouncementRead,
|
||||||
c.ErrorPassthroughRule, c.Group, c.IdempotencyRecord, c.PaymentAuditLog,
|
c.ErrorPassthroughRule, c.Group, c.IdempotencyRecord, c.PaymentAuditLog,
|
||||||
c.PaymentOrder, c.PaymentProviderInstance, c.PromoCode, c.PromoCodeUsage,
|
c.PaymentOrder, c.PaymentProviderInstance, c.PromoCode,
|
||||||
c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting, c.SubscriptionPlan,
|
c.PromoCodeUsage, c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting,
|
||||||
c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog, c.User,
|
c.SubscriptionPlan, c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog,
|
||||||
c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue,
|
c.User, c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue,
|
||||||
c.UserSubscription,
|
c.UserSubscription,
|
||||||
} {
|
} {
|
||||||
n.Intercept(interceptors...)
|
n.Intercept(interceptors...)
|
||||||
@@ -4629,19 +4629,19 @@ func (c *UserSubscriptionClient) mutate(ctx context.Context, m *UserSubscription
|
|||||||
type (
|
type (
|
||||||
hooks struct {
|
hooks struct {
|
||||||
APIKey, Account, AccountGroup, Announcement, AnnouncementRead,
|
APIKey, Account, AccountGroup, Announcement, AnnouncementRead,
|
||||||
ErrorPassthroughRule, Group, IdempotencyRecord, PaymentAuditLog, PaymentOrder,
|
ErrorPassthroughRule, Group, IdempotencyRecord, PaymentAuditLog,
|
||||||
PaymentProviderInstance, PromoCode, PromoCodeUsage, Proxy, RedeemCode,
|
PaymentOrder, PaymentProviderInstance, PromoCode,
|
||||||
SecuritySecret, Setting, SubscriptionPlan, TLSFingerprintProfile,
|
PromoCodeUsage, Proxy, RedeemCode, SecuritySecret, Setting, SubscriptionPlan,
|
||||||
UsageCleanupTask, UsageLog, User, UserAllowedGroup, UserAttributeDefinition,
|
TLSFingerprintProfile, UsageCleanupTask, UsageLog, User, UserAllowedGroup,
|
||||||
UserAttributeValue, UserSubscription []ent.Hook
|
UserAttributeDefinition, UserAttributeValue, UserSubscription []ent.Hook
|
||||||
}
|
}
|
||||||
inters struct {
|
inters struct {
|
||||||
APIKey, Account, AccountGroup, Announcement, AnnouncementRead,
|
APIKey, Account, AccountGroup, Announcement, AnnouncementRead,
|
||||||
ErrorPassthroughRule, Group, IdempotencyRecord, PaymentAuditLog, PaymentOrder,
|
ErrorPassthroughRule, Group, IdempotencyRecord, PaymentAuditLog,
|
||||||
PaymentProviderInstance, PromoCode, PromoCodeUsage, Proxy, RedeemCode,
|
PaymentOrder, PaymentProviderInstance, PromoCode,
|
||||||
SecuritySecret, Setting, SubscriptionPlan, TLSFingerprintProfile,
|
PromoCodeUsage, Proxy, RedeemCode, SecuritySecret, Setting, SubscriptionPlan,
|
||||||
UsageCleanupTask, UsageLog, User, UserAllowedGroup, UserAttributeDefinition,
|
TLSFingerprintProfile, UsageCleanupTask, UsageLog, User, UserAllowedGroup,
|
||||||
UserAttributeValue, UserSubscription []ent.Interceptor
|
UserAttributeDefinition, UserAttributeValue, UserSubscription []ent.Interceptor
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
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.
|
// 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)
|
type PaymentOrderFunc func(context.Context, *ent.PaymentOrderQuery) (ent.Value, error)
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type IdempotencyRecord func(*sql.Selector)
|
|||||||
// PaymentAuditLog is the predicate function for paymentauditlog builders.
|
// PaymentAuditLog is the predicate function for paymentauditlog builders.
|
||||||
type PaymentAuditLog func(*sql.Selector)
|
type PaymentAuditLog func(*sql.Selector)
|
||||||
|
|
||||||
|
|
||||||
// PaymentOrder is the predicate function for paymentorder builders.
|
// PaymentOrder is the predicate function for paymentorder builders.
|
||||||
type PaymentOrder func(*sql.Selector)
|
type PaymentOrder func(*sql.Selector)
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type createChannelRequest struct {
|
|||||||
ModelMapping map[string]map[string]string `json:"model_mapping"`
|
ModelMapping map[string]map[string]string `json:"model_mapping"`
|
||||||
BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped"`
|
BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped"`
|
||||||
RestrictModels bool `json:"restrict_models"`
|
RestrictModels bool `json:"restrict_models"`
|
||||||
|
Features string `json:"features"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type updateChannelRequest struct {
|
type updateChannelRequest struct {
|
||||||
@@ -44,6 +45,7 @@ type updateChannelRequest struct {
|
|||||||
ModelMapping map[string]map[string]string `json:"model_mapping"`
|
ModelMapping map[string]map[string]string `json:"model_mapping"`
|
||||||
BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped"`
|
BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped"`
|
||||||
RestrictModels *bool `json:"restrict_models"`
|
RestrictModels *bool `json:"restrict_models"`
|
||||||
|
Features *string `json:"features"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type channelModelPricingRequest struct {
|
type channelModelPricingRequest struct {
|
||||||
@@ -78,6 +80,7 @@ type channelResponse struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
BillingModelSource string `json:"billing_model_source"`
|
BillingModelSource string `json:"billing_model_source"`
|
||||||
RestrictModels bool `json:"restrict_models"`
|
RestrictModels bool `json:"restrict_models"`
|
||||||
|
Features string `json:"features"`
|
||||||
GroupIDs []int64 `json:"group_ids"`
|
GroupIDs []int64 `json:"group_ids"`
|
||||||
ModelPricing []channelModelPricingResponse `json:"model_pricing"`
|
ModelPricing []channelModelPricingResponse `json:"model_pricing"`
|
||||||
ModelMapping map[string]map[string]string `json:"model_mapping"`
|
ModelMapping map[string]map[string]string `json:"model_mapping"`
|
||||||
@@ -122,6 +125,7 @@ func channelToResponse(ch *service.Channel) *channelResponse {
|
|||||||
Description: ch.Description,
|
Description: ch.Description,
|
||||||
Status: ch.Status,
|
Status: ch.Status,
|
||||||
RestrictModels: ch.RestrictModels,
|
RestrictModels: ch.RestrictModels,
|
||||||
|
Features: ch.Features,
|
||||||
GroupIDs: ch.GroupIDs,
|
GroupIDs: ch.GroupIDs,
|
||||||
ModelMapping: ch.ModelMapping,
|
ModelMapping: ch.ModelMapping,
|
||||||
CreatedAt: ch.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
CreatedAt: ch.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
@@ -300,6 +304,7 @@ func (h *ChannelHandler) Create(c *gin.Context) {
|
|||||||
ModelMapping: req.ModelMapping,
|
ModelMapping: req.ModelMapping,
|
||||||
BillingModelSource: req.BillingModelSource,
|
BillingModelSource: req.BillingModelSource,
|
||||||
RestrictModels: req.RestrictModels,
|
RestrictModels: req.RestrictModels,
|
||||||
|
Features: req.Features,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
@@ -332,6 +337,7 @@ func (h *ChannelHandler) Update(c *gin.Context) {
|
|||||||
ModelMapping: req.ModelMapping,
|
ModelMapping: req.ModelMapping,
|
||||||
BillingModelSource: req.BillingModelSource,
|
BillingModelSource: req.BillingModelSource,
|
||||||
RestrictModels: req.RestrictModels,
|
RestrictModels: req.RestrictModels,
|
||||||
|
Features: req.Features,
|
||||||
}
|
}
|
||||||
if req.ModelPricing != nil {
|
if req.ModelPricing != nil {
|
||||||
pricing := pricingRequestToService(*req.ModelPricing)
|
pricing := pricingRequestToService(*req.ModelPricing)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
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/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ func (r *channelRepository) Create(ctx context.Context, channel *service.Channel
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
err = tx.QueryRowContext(ctx,
|
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`,
|
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)
|
).Scan(&channel.ID, &channel.CreatedAt, &channel.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isUniqueViolation(err) {
|
if isUniqueViolation(err) {
|
||||||
@@ -75,9 +75,9 @@ func (r *channelRepository) GetByID(ctx context.Context, id int64) (*service.Cha
|
|||||||
ch := &service.Channel{}
|
ch := &service.Channel{}
|
||||||
var modelMappingJSON []byte
|
var modelMappingJSON []byte
|
||||||
err := r.db.QueryRowContext(ctx,
|
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,
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
return nil, service.ErrChannelNotFound
|
return nil, service.ErrChannelNotFound
|
||||||
}
|
}
|
||||||
@@ -108,9 +108,9 @@ func (r *channelRepository) Update(ctx context.Context, channel *service.Channel
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
result, err := tx.ExecContext(ctx,
|
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()
|
`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 = $7`,
|
WHERE id = $8`,
|
||||||
channel.Name, channel.Description, channel.Status, modelMappingJSON, channel.BillingModelSource, channel.RestrictModels, channel.ID,
|
channel.Name, channel.Description, channel.Status, modelMappingJSON, channel.BillingModelSource, channel.RestrictModels, channel.Features, channel.ID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isUniqueViolation(err) {
|
if isUniqueViolation(err) {
|
||||||
@@ -204,7 +204,7 @@ func (r *channelRepository) List(ctx context.Context, params pagination.Paginati
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var ch service.Channel
|
var ch service.Channel
|
||||||
var modelMappingJSON []byte
|
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)
|
return nil, nil, fmt.Errorf("scan channel: %w", err)
|
||||||
}
|
}
|
||||||
ch.ModelMapping = unmarshalModelMapping(modelMappingJSON)
|
ch.ModelMapping = unmarshalModelMapping(modelMappingJSON)
|
||||||
@@ -273,7 +273,7 @@ func channelListOrderBy(params pagination.PaginationParams) string {
|
|||||||
|
|
||||||
func (r *channelRepository) ListAll(ctx context.Context) ([]service.Channel, error) {
|
func (r *channelRepository) ListAll(ctx context.Context) ([]service.Channel, error) {
|
||||||
rows, err := r.db.QueryContext(ctx,
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("query all channels: %w", err)
|
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() {
|
for rows.Next() {
|
||||||
var ch service.Channel
|
var ch service.Channel
|
||||||
var modelMappingJSON []byte
|
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)
|
return nil, fmt.Errorf("scan channel: %w", err)
|
||||||
}
|
}
|
||||||
ch.ModelMapping = unmarshalModelMapping(modelMappingJSON)
|
ch.ModelMapping = unmarshalModelMapping(modelMappingJSON)
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ func RegisterPaymentRoutes(
|
|||||||
authenticated.Use(middleware.BackendModeUserGuard(settingService))
|
authenticated.Use(middleware.BackendModeUserGuard(settingService))
|
||||||
{
|
{
|
||||||
authenticated.GET("/config", paymentHandler.GetPaymentConfig)
|
authenticated.GET("/config", paymentHandler.GetPaymentConfig)
|
||||||
authenticated.GET("/checkout-info", paymentHandler.GetCheckoutInfo)
|
|
||||||
authenticated.GET("/plans", paymentHandler.GetPlans)
|
authenticated.GET("/plans", paymentHandler.GetPlans)
|
||||||
authenticated.GET("/channels", paymentHandler.GetChannels)
|
authenticated.GET("/channels", paymentHandler.GetChannels)
|
||||||
authenticated.GET("/limits", paymentHandler.GetLimits)
|
authenticated.GET("/limits", paymentHandler.GetLimits)
|
||||||
@@ -34,7 +33,6 @@ func RegisterPaymentRoutes(
|
|||||||
orders := authenticated.Group("/orders")
|
orders := authenticated.Group("/orders")
|
||||||
{
|
{
|
||||||
orders.POST("", paymentHandler.CreateOrder)
|
orders.POST("", paymentHandler.CreateOrder)
|
||||||
orders.POST("/verify", paymentHandler.VerifyOrder)
|
|
||||||
orders.GET("/my", paymentHandler.GetMyOrders)
|
orders.GET("/my", paymentHandler.GetMyOrders)
|
||||||
orders.GET("/:id", paymentHandler.GetOrder)
|
orders.GET("/:id", paymentHandler.GetOrder)
|
||||||
orders.POST("/:id/cancel", paymentHandler.CancelOrder)
|
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 endpoints (no auth) ---
|
||||||
webhook := v1.Group("/payment/webhook")
|
webhook := v1.Group("/payment/webhook")
|
||||||
{
|
{
|
||||||
// EasyPay sends GET callbacks with query params
|
|
||||||
webhook.GET("/easypay", webhookHandler.EasyPayNotify)
|
|
||||||
webhook.POST("/easypay", webhookHandler.EasyPayNotify)
|
webhook.POST("/easypay", webhookHandler.EasyPayNotify)
|
||||||
webhook.POST("/alipay", webhookHandler.AlipayNotify)
|
webhook.POST("/alipay", webhookHandler.AlipayNotify)
|
||||||
webhook.POST("/wxpay", webhookHandler.WxpayNotify)
|
webhook.POST("/wxpay", webhookHandler.WxpayNotify)
|
||||||
@@ -82,6 +70,7 @@ func RegisterPaymentRoutes(
|
|||||||
adminOrders.POST("/:id/refund", adminPaymentHandler.ProcessRefund)
|
adminOrders.POST("/:id/refund", adminPaymentHandler.ProcessRefund)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Subscription Plans
|
// Subscription Plans
|
||||||
plans := adminGroup.Group("/plans")
|
plans := adminGroup.Group("/plans")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ type Channel struct {
|
|||||||
Status string
|
Status string
|
||||||
BillingModelSource string // "requested", "upstream", or "channel_mapped"
|
BillingModelSource string // "requested", "upstream", or "channel_mapped"
|
||||||
RestrictModels bool // 是否限制模型(仅允许定价列表中的模型)
|
RestrictModels bool // 是否限制模型(仅允许定价列表中的模型)
|
||||||
|
Features string // 渠道特性描述(JSON 数组),用于支付页面展示
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
|
|
||||||
|
|||||||
@@ -584,6 +584,7 @@ func (s *ChannelService) Create(ctx context.Context, input *CreateChannelInput)
|
|||||||
GroupIDs: input.GroupIDs,
|
GroupIDs: input.GroupIDs,
|
||||||
ModelPricing: input.ModelPricing,
|
ModelPricing: input.ModelPricing,
|
||||||
ModelMapping: input.ModelMapping,
|
ModelMapping: input.ModelMapping,
|
||||||
|
Features: input.Features,
|
||||||
}
|
}
|
||||||
if channel.BillingModelSource == "" {
|
if channel.BillingModelSource == "" {
|
||||||
channel.BillingModelSource = BillingModelSourceChannelMapped
|
channel.BillingModelSource = BillingModelSourceChannelMapped
|
||||||
@@ -641,6 +642,9 @@ func (s *ChannelService) Update(ctx context.Context, id int64, input *UpdateChan
|
|||||||
if input.RestrictModels != nil {
|
if input.RestrictModels != nil {
|
||||||
channel.RestrictModels = *input.RestrictModels
|
channel.RestrictModels = *input.RestrictModels
|
||||||
}
|
}
|
||||||
|
if input.Features != nil {
|
||||||
|
channel.Features = *input.Features
|
||||||
|
}
|
||||||
|
|
||||||
// 检查分组冲突
|
// 检查分组冲突
|
||||||
if input.GroupIDs != nil {
|
if input.GroupIDs != nil {
|
||||||
@@ -842,6 +846,7 @@ type CreateChannelInput struct {
|
|||||||
ModelMapping map[string]map[string]string // platform → {src→dst}
|
ModelMapping map[string]map[string]string // platform → {src→dst}
|
||||||
BillingModelSource string
|
BillingModelSource string
|
||||||
RestrictModels bool
|
RestrictModels bool
|
||||||
|
Features string
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateChannelInput 更新渠道输入
|
// UpdateChannelInput 更新渠道输入
|
||||||
@@ -854,4 +859,5 @@ type UpdateChannelInput struct {
|
|||||||
ModelMapping map[string]map[string]string // platform → {src→dst}
|
ModelMapping map[string]map[string]string // platform → {src→dst}
|
||||||
BillingModelSource string
|
BillingModelSource string
|
||||||
RestrictModels *bool
|
RestrictModels *bool
|
||||||
|
Features *string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||||
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
|
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
|
||||||
|
"github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||||
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -23,8 +26,6 @@ const (
|
|||||||
SettingBalancePayDisabled = "BALANCE_PAYMENT_DISABLED"
|
SettingBalancePayDisabled = "BALANCE_PAYMENT_DISABLED"
|
||||||
SettingProductNamePrefix = "PRODUCT_NAME_PREFIX"
|
SettingProductNamePrefix = "PRODUCT_NAME_PREFIX"
|
||||||
SettingProductNameSuffix = "PRODUCT_NAME_SUFFIX"
|
SettingProductNameSuffix = "PRODUCT_NAME_SUFFIX"
|
||||||
SettingHelpImageURL = "PAYMENT_HELP_IMAGE_URL"
|
|
||||||
SettingHelpText = "PAYMENT_HELP_TEXT"
|
|
||||||
SettingCancelRateLimitOn = "CANCEL_RATE_LIMIT_ENABLED"
|
SettingCancelRateLimitOn = "CANCEL_RATE_LIMIT_ENABLED"
|
||||||
SettingCancelRateLimitMax = "CANCEL_RATE_LIMIT_MAX"
|
SettingCancelRateLimitMax = "CANCEL_RATE_LIMIT_MAX"
|
||||||
SettingCancelWindowSize = "CANCEL_RATE_LIMIT_WINDOW"
|
SettingCancelWindowSize = "CANCEL_RATE_LIMIT_WINDOW"
|
||||||
@@ -32,126 +33,91 @@ const (
|
|||||||
SettingCancelWindowMode = "CANCEL_RATE_LIMIT_WINDOW_MODE"
|
SettingCancelWindowMode = "CANCEL_RATE_LIMIT_WINDOW_MODE"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Default values for payment configuration settings.
|
|
||||||
const (
|
|
||||||
defaultOrderTimeoutMin = 30
|
|
||||||
defaultMaxPendingOrders = 3
|
|
||||||
)
|
|
||||||
|
|
||||||
// PaymentConfig holds the payment system configuration.
|
// PaymentConfig holds the payment system configuration.
|
||||||
type PaymentConfig struct {
|
type PaymentConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
MinAmount float64 `json:"min_amount"`
|
MinAmount float64 `json:"minAmount"`
|
||||||
MaxAmount float64 `json:"max_amount"`
|
MaxAmount float64 `json:"maxAmount"`
|
||||||
DailyLimit float64 `json:"daily_limit"`
|
DailyLimit float64 `json:"dailyLimit"`
|
||||||
OrderTimeoutMin int `json:"order_timeout_minutes"`
|
OrderTimeoutMin int `json:"orderTimeoutMinutes"`
|
||||||
MaxPendingOrders int `json:"max_pending_orders"`
|
MaxPendingOrders int `json:"maxPendingOrders"`
|
||||||
EnabledTypes []string `json:"enabled_payment_types"`
|
EnabledTypes []string `json:"enabledTypes"`
|
||||||
BalanceDisabled bool `json:"balance_disabled"`
|
BalanceDisabled bool `json:"balanceDisabled"`
|
||||||
LoadBalanceStrategy string `json:"load_balance_strategy"`
|
LoadBalanceStrategy string `json:"loadBalanceStrategy"`
|
||||||
ProductNamePrefix string `json:"product_name_prefix"`
|
ProductNamePrefix string `json:"productNamePrefix"`
|
||||||
ProductNameSuffix string `json:"product_name_suffix"`
|
ProductNameSuffix string `json:"productNameSuffix"`
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdatePaymentConfigRequest contains fields to update payment configuration.
|
// UpdatePaymentConfigRequest contains fields to update payment configuration.
|
||||||
type UpdatePaymentConfigRequest struct {
|
type UpdatePaymentConfigRequest struct {
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
MinAmount *float64 `json:"min_amount"`
|
MinAmount *float64 `json:"minAmount"`
|
||||||
MaxAmount *float64 `json:"max_amount"`
|
MaxAmount *float64 `json:"maxAmount"`
|
||||||
DailyLimit *float64 `json:"daily_limit"`
|
DailyLimit *float64 `json:"dailyLimit"`
|
||||||
OrderTimeoutMin *int `json:"order_timeout_minutes"`
|
OrderTimeoutMin *int `json:"orderTimeoutMinutes"`
|
||||||
MaxPendingOrders *int `json:"max_pending_orders"`
|
MaxPendingOrders *int `json:"maxPendingOrders"`
|
||||||
EnabledTypes []string `json:"enabled_payment_types"`
|
EnabledTypes []string `json:"enabledTypes"`
|
||||||
BalanceDisabled *bool `json:"balance_disabled"`
|
BalanceDisabled *bool `json:"balanceDisabled"`
|
||||||
LoadBalanceStrategy *string `json:"load_balance_strategy"`
|
LoadBalanceStrategy *string `json:"loadBalanceStrategy"`
|
||||||
ProductNamePrefix *string `json:"product_name_prefix"`
|
ProductNamePrefix *string `json:"productNamePrefix"`
|
||||||
ProductNameSuffix *string `json:"product_name_suffix"`
|
ProductNameSuffix *string `json:"productNameSuffix"`
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MethodLimits holds per-payment-type limits.
|
// MethodLimits holds per-payment-type limits.
|
||||||
type MethodLimits struct {
|
type MethodLimits struct {
|
||||||
PaymentType string `json:"payment_type"`
|
PaymentType string `json:"paymentType"`
|
||||||
FeeRate float64 `json:"fee_rate"`
|
FeeRate float64 `json:"feeRate"`
|
||||||
DailyLimit float64 `json:"daily_limit"`
|
DailyLimit float64 `json:"dailyLimit"`
|
||||||
SingleMin float64 `json:"single_min"`
|
SingleMin float64 `json:"singleMin"`
|
||||||
SingleMax float64 `json:"single_max"`
|
SingleMax float64 `json:"singleMax"`
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateProviderInstanceRequest struct {
|
type CreateProviderInstanceRequest struct {
|
||||||
ProviderKey string `json:"provider_key"`
|
ProviderKey string `json:"providerKey"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Config map[string]string `json:"config"`
|
Config map[string]string `json:"config"`
|
||||||
SupportedTypes []string `json:"supported_types"`
|
SupportedTypes string `json:"supportedTypes"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
PaymentMode string `json:"payment_mode"`
|
SortOrder int `json:"sortOrder"`
|
||||||
SortOrder int `json:"sort_order"`
|
|
||||||
Limits string `json:"limits"`
|
Limits string `json:"limits"`
|
||||||
RefundEnabled bool `json:"refund_enabled"`
|
RefundEnabled bool `json:"refundEnabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateProviderInstanceRequest struct {
|
type UpdateProviderInstanceRequest struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Config map[string]string `json:"config"`
|
Config map[string]string `json:"config"`
|
||||||
SupportedTypes []string `json:"supported_types"`
|
SupportedTypes *string `json:"supportedTypes"`
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
PaymentMode *string `json:"payment_mode"`
|
SortOrder *int `json:"sortOrder"`
|
||||||
SortOrder *int `json:"sort_order"`
|
|
||||||
Limits *string `json:"limits"`
|
Limits *string `json:"limits"`
|
||||||
RefundEnabled *bool `json:"refund_enabled"`
|
RefundEnabled *bool `json:"refundEnabled"`
|
||||||
}
|
}
|
||||||
type CreatePlanRequest struct {
|
type CreatePlanRequest struct {
|
||||||
GroupID int64 `json:"group_id"`
|
GroupID int64 `json:"groupId"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Price float64 `json:"price"`
|
Price float64 `json:"price"`
|
||||||
OriginalPrice *float64 `json:"original_price"`
|
OriginalPrice *float64 `json:"originalPrice"`
|
||||||
ValidityDays int `json:"validity_days"`
|
ValidityDays int `json:"validityDays"`
|
||||||
ValidityUnit string `json:"validity_unit"`
|
ValidityUnit string `json:"validityUnit"`
|
||||||
Features string `json:"features"`
|
Features string `json:"features"`
|
||||||
ProductName string `json:"product_name"`
|
ProductName string `json:"productName"`
|
||||||
ForSale bool `json:"for_sale"`
|
ForSale bool `json:"forSale"`
|
||||||
SortOrder int `json:"sort_order"`
|
SortOrder int `json:"sortOrder"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdatePlanRequest struct {
|
type UpdatePlanRequest struct {
|
||||||
GroupID *int64 `json:"group_id"`
|
GroupID *int64 `json:"groupId"`
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Price *float64 `json:"price"`
|
Price *float64 `json:"price"`
|
||||||
OriginalPrice *float64 `json:"original_price"`
|
OriginalPrice *float64 `json:"originalPrice"`
|
||||||
ValidityDays *int `json:"validity_days"`
|
ValidityDays *int `json:"validityDays"`
|
||||||
ValidityUnit *string `json:"validity_unit"`
|
ValidityUnit *string `json:"validityUnit"`
|
||||||
Features *string `json:"features"`
|
Features *string `json:"features"`
|
||||||
ProductName *string `json:"product_name"`
|
ProductName *string `json:"productName"`
|
||||||
ForSale *bool `json:"for_sale"`
|
ForSale *bool `json:"forSale"`
|
||||||
SortOrder *int `json:"sort_order"`
|
SortOrder *int `json:"sortOrder"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PaymentConfigService manages payment configuration and CRUD for
|
// PaymentConfigService manages payment configuration and CRUD for
|
||||||
@@ -183,43 +149,29 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo
|
|||||||
SettingDailyRechargeLimit, SettingOrderTimeoutMinutes, SettingMaxPendingOrders,
|
SettingDailyRechargeLimit, SettingOrderTimeoutMinutes, SettingMaxPendingOrders,
|
||||||
SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingLoadBalanceStrategy,
|
SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingLoadBalanceStrategy,
|
||||||
SettingProductNamePrefix, SettingProductNameSuffix,
|
SettingProductNamePrefix, SettingProductNameSuffix,
|
||||||
SettingHelpImageURL, SettingHelpText,
|
|
||||||
SettingCancelRateLimitOn, SettingCancelRateLimitMax,
|
|
||||||
SettingCancelWindowSize, SettingCancelWindowUnit, SettingCancelWindowMode,
|
|
||||||
}
|
}
|
||||||
vals, err := s.settingRepo.GetMultiple(ctx, keys)
|
vals, err := s.settingRepo.GetMultiple(ctx, keys)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get payment config settings: %w", err)
|
return nil, fmt.Errorf("get payment config settings: %w", err)
|
||||||
}
|
}
|
||||||
cfg := s.parsePaymentConfig(vals)
|
return s.parsePaymentConfig(vals), nil
|
||||||
// Load Stripe publishable key from the first enabled Stripe provider instance
|
|
||||||
cfg.StripePublishableKey = s.getStripePublishableKey(ctx)
|
|
||||||
return cfg, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *PaymentConfig {
|
func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *PaymentConfig {
|
||||||
cfg := &PaymentConfig{
|
cfg := &PaymentConfig{
|
||||||
Enabled: vals[SettingPaymentEnabled] == "true",
|
Enabled: vals[SettingPaymentEnabled] == "true",
|
||||||
MinAmount: pcParseFloat(vals[SettingMinRechargeAmount], 1),
|
MinAmount: pcParseFloat(vals[SettingMinRechargeAmount], 1),
|
||||||
MaxAmount: pcParseFloat(vals[SettingMaxRechargeAmount], 0),
|
MaxAmount: pcParseFloat(vals[SettingMaxRechargeAmount], 99999999.99),
|
||||||
DailyLimit: pcParseFloat(vals[SettingDailyRechargeLimit], 0),
|
DailyLimit: pcParseFloat(vals[SettingDailyRechargeLimit], 0),
|
||||||
OrderTimeoutMin: pcParseInt(vals[SettingOrderTimeoutMinutes], defaultOrderTimeoutMin),
|
OrderTimeoutMin: pcParseInt(vals[SettingOrderTimeoutMinutes], 30),
|
||||||
MaxPendingOrders: pcParseInt(vals[SettingMaxPendingOrders], defaultMaxPendingOrders),
|
MaxPendingOrders: pcParseInt(vals[SettingMaxPendingOrders], 3),
|
||||||
BalanceDisabled: vals[SettingBalancePayDisabled] == "true",
|
BalanceDisabled: vals[SettingBalancePayDisabled] == "true",
|
||||||
LoadBalanceStrategy: vals[SettingLoadBalanceStrategy],
|
LoadBalanceStrategy: vals[SettingLoadBalanceStrategy],
|
||||||
ProductNamePrefix: vals[SettingProductNamePrefix],
|
ProductNamePrefix: vals[SettingProductNamePrefix],
|
||||||
ProductNameSuffix: vals[SettingProductNameSuffix],
|
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 == "" {
|
if cfg.LoadBalanceStrategy == "" {
|
||||||
cfg.LoadBalanceStrategy = payment.DefaultLoadBalanceStrategy
|
cfg.LoadBalanceStrategy = "round-robin"
|
||||||
}
|
}
|
||||||
if raw := vals[SettingEnabledPaymentTypes]; raw != "" {
|
if raw := vals[SettingEnabledPaymentTypes]; raw != "" {
|
||||||
for _, t := range strings.Split(raw, ",") {
|
for _, t := range strings.Split(raw, ",") {
|
||||||
@@ -232,100 +184,242 @@ func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *Payme
|
|||||||
return cfg
|
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.
|
// 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 {
|
func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req UpdatePaymentConfigRequest) error {
|
||||||
m := map[string]string{
|
m := make(map[string]string)
|
||||||
SettingPaymentEnabled: formatBoolOrEmpty(req.Enabled),
|
if req.Enabled != nil {
|
||||||
SettingMinRechargeAmount: formatPositiveFloat(req.MinAmount),
|
m[SettingPaymentEnabled] = strconv.FormatBool(*req.Enabled)
|
||||||
SettingMaxRechargeAmount: formatPositiveFloat(req.MaxAmount),
|
}
|
||||||
SettingDailyRechargeLimit: formatPositiveFloat(req.DailyLimit),
|
if req.MinAmount != nil {
|
||||||
SettingOrderTimeoutMinutes: formatPositiveInt(req.OrderTimeoutMin),
|
m[SettingMinRechargeAmount] = strconv.FormatFloat(*req.MinAmount, 'f', 2, 64)
|
||||||
SettingMaxPendingOrders: formatPositiveInt(req.MaxPendingOrders),
|
}
|
||||||
SettingBalancePayDisabled: formatBoolOrEmpty(req.BalanceDisabled),
|
if req.MaxAmount != nil {
|
||||||
SettingLoadBalanceStrategy: derefStr(req.LoadBalanceStrategy),
|
m[SettingMaxRechargeAmount] = strconv.FormatFloat(*req.MaxAmount, 'f', 2, 64)
|
||||||
SettingProductNamePrefix: derefStr(req.ProductNamePrefix),
|
}
|
||||||
SettingProductNameSuffix: derefStr(req.ProductNameSuffix),
|
if req.DailyLimit != nil {
|
||||||
SettingHelpImageURL: derefStr(req.HelpImageURL),
|
m[SettingDailyRechargeLimit] = strconv.FormatFloat(*req.DailyLimit, 'f', 2, 64)
|
||||||
SettingHelpText: derefStr(req.HelpText),
|
}
|
||||||
SettingCancelRateLimitOn: formatBoolOrEmpty(req.CancelRateLimitEnabled),
|
if req.OrderTimeoutMin != nil {
|
||||||
SettingCancelRateLimitMax: formatPositiveInt(req.CancelRateLimitMax),
|
m[SettingOrderTimeoutMinutes] = strconv.Itoa(*req.OrderTimeoutMin)
|
||||||
SettingCancelWindowSize: formatPositiveInt(req.CancelRateLimitWindow),
|
}
|
||||||
SettingCancelWindowUnit: derefStr(req.CancelRateLimitUnit),
|
if req.MaxPendingOrders != nil {
|
||||||
SettingCancelWindowMode: derefStr(req.CancelRateLimitMode),
|
m[SettingMaxPendingOrders] = strconv.Itoa(*req.MaxPendingOrders)
|
||||||
}
|
}
|
||||||
if req.EnabledTypes != nil {
|
if req.EnabledTypes != nil {
|
||||||
m[SettingEnabledPaymentTypes] = strings.Join(req.EnabledTypes, ",")
|
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)
|
return s.settingRepo.SetMultiple(ctx, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatBoolOrEmpty(v *bool) string {
|
// --- Provider Instance CRUD ---
|
||||||
if v == nil {
|
|
||||||
return ""
|
func (s *PaymentConfigService) ListProviderInstances(ctx context.Context) ([]*dbent.PaymentProviderInstance, error) {
|
||||||
}
|
return s.entClient.PaymentProviderInstance.Query().Order(paymentproviderinstance.BySortOrder()).All(ctx)
|
||||||
return strconv.FormatBool(*v)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatPositiveFloat(v *float64) string {
|
func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req CreateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
|
||||||
if v == nil || *v <= 0 {
|
enc, err := s.encryptConfig(req.Config)
|
||||||
return "" // empty → parsePaymentConfig uses default
|
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 {
|
func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id int64, req UpdateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
|
||||||
if v == nil || *v <= 0 {
|
u := s.entClient.PaymentProviderInstance.UpdateOneID(id)
|
||||||
return ""
|
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 {
|
func (s *PaymentConfigService) DeleteProviderInstance(ctx context.Context, id int64) error {
|
||||||
if v == nil {
|
return s.entClient.PaymentProviderInstance.DeleteOneID(id).Exec(ctx)
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return *v
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func splitTypes(s string) []string {
|
func (s *PaymentConfigService) encryptConfig(cfg map[string]string) (string, error) {
|
||||||
if s == "" {
|
data, err := json.Marshal(cfg)
|
||||||
return nil
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal config: %w", err)
|
||||||
}
|
}
|
||||||
parts := strings.Split(s, ",")
|
enc, err := payment.Encrypt(string(data), s.encryptionKey)
|
||||||
result := make([]string, 0, len(parts))
|
if err != nil {
|
||||||
for _, p := range parts {
|
return "", fmt.Errorf("encrypt config: %w", err)
|
||||||
p = strings.TrimSpace(p)
|
}
|
||||||
if p != "" {
|
return enc, nil
|
||||||
result = append(result, p)
|
}
|
||||||
|
|
||||||
|
// --- 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 {
|
func pcApplyInstanceLimits(inst *dbent.PaymentProviderInstance, pt string, ml *MethodLimits) {
|
||||||
return strings.Join(types, ",")
|
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 {
|
func pcParseFloat(s string, defaultVal float64) float64 {
|
||||||
|
|||||||
2
backend/migrations/095_channel_features.sql
Normal file
2
backend/migrations/095_channel_features.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE channels ADD COLUMN IF NOT EXISTS features TEXT NOT NULL DEFAULT '';
|
||||||
|
COMMENT ON COLUMN channels.features IS '渠道特性描述,JSON 数组格式,用于支付页面展示';
|
||||||
Reference in New Issue
Block a user