feat(*): 功能开放与页面布局 - 新增运维开关并优化后台及创作界面空间利用
This commit is contained in:
@@ -73,6 +73,7 @@ func main() {
|
||||
})
|
||||
handler.RegisterOAuthCallbackRoutes(r, svc)
|
||||
handler.RegisterAuthRoutes(api, svc)
|
||||
handler.RegisterFeatureAvailabilityRoutes(api, svc)
|
||||
handler.RegisterAdminRoutes(api, svc)
|
||||
handler.RegisterAdminAnalyticsRoutes(api, svc)
|
||||
handler.RegisterAnnouncementRoutes(api, svc)
|
||||
@@ -85,7 +86,9 @@ func main() {
|
||||
handler.RegisterSessionRoutes(api, svc)
|
||||
handler.RegisterSkillRoutes(api, svc)
|
||||
handler.RegisterUserDataRoutes(api, svc)
|
||||
handler.RegisterProjectRoutes(api, svc)
|
||||
projectAPI := api.Group("")
|
||||
projectAPI.Use(handler.RequireFeature(svc, service.FeatureShortDrama))
|
||||
handler.RegisterProjectRoutes(projectAPI, svc)
|
||||
handler.RegisterCanvasShareRoutes(api, svc)
|
||||
|
||||
addr := env("CANVAS_BACKEND_ADDR", ":8080")
|
||||
|
||||
@@ -127,7 +127,12 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"user": publicUser, "systemChannels": channels, "runtimeLimits": limits, "drawingEngine": drawingEngine})
|
||||
features, err := svc.FeatureAvailability()
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"user": publicUser, "systemChannels": channels, "runtimeLimits": limits, "drawingEngine": drawingEngine, "features": features})
|
||||
})
|
||||
r.GET("/channels/system", func(c *gin.Context) {
|
||||
if _, err := currentUser(c, svc); err != nil {
|
||||
@@ -750,11 +755,13 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
billingOrderID = order.ID
|
||||
if err := svc.MarkBillingRunning(billingOrderID); err != nil {
|
||||
_ = svc.RefundBilling(billingOrderID, "系统渠道请求尚未发出")
|
||||
failService(c, err)
|
||||
return
|
||||
if order != nil {
|
||||
billingOrderID = order.ID
|
||||
if err := svc.MarkBillingRunning(billingOrderID); err != nil {
|
||||
_ = svc.RefundBilling(billingOrderID, "系统渠道请求尚未发出")
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
upstreamReq, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, target, bytes.NewReader(body))
|
||||
|
||||
75
backend/internal/handler/feature_availability.go
Normal file
75
backend/internal/handler/feature_availability.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"infinite-canvas/backend/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterFeatureAvailabilityRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
r.GET("/features", func(c *gin.Context) {
|
||||
if _, err := currentUser(c, svc); err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
setting, err := svc.FeatureAvailability()
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"features": setting})
|
||||
})
|
||||
|
||||
r.GET("/admin/settings/features", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
setting, err := svc.AdminFeatureAvailability(user)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"features": setting})
|
||||
})
|
||||
|
||||
r.PATCH("/admin/settings/features", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
|
||||
var req service.FeatureAvailability
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
setting, err := svc.UpdateFeatureAvailability(user, req)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"features": setting})
|
||||
})
|
||||
}
|
||||
|
||||
// 功能守卫先校验登录态再判断开放状态,避免关闭功能时改变未登录请求的认证语义。
|
||||
func RequireFeature(svc *service.Service, feature string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if _, err := currentUser(c, svc); err != nil {
|
||||
failService(c, err)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if err := svc.RequireFeature(feature); err != nil {
|
||||
failService(c, err)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,9 @@ func (r *Repository) RetryTaskWithBilling(userID string, taskID string, order *m
|
||||
}
|
||||
if order != nil {
|
||||
updates["billing_order_id"] = order.ID
|
||||
} else {
|
||||
// 免费模式重试必须解除旧订单,避免任务执行阶段复用上一次的计费状态。
|
||||
updates["billing_order_id"] = ""
|
||||
}
|
||||
updated := tx.Model(&model.Task{}).
|
||||
Where("id = ? AND user_id = ? AND status IN ?", taskID, userID, []model.TaskStatus{model.TaskStatusFailed, model.TaskStatusCancelled}).
|
||||
|
||||
@@ -108,6 +108,10 @@ func (s *Service) UpdateCreditPolicy(actor *model.User, policy CreditPolicy) (Cr
|
||||
}
|
||||
|
||||
func (s *Service) ensureSignupBonus(userID string) error {
|
||||
enabled, err := s.FeatureEnabled(FeatureCredits)
|
||||
if err != nil || !enabled {
|
||||
return err
|
||||
}
|
||||
policy, err := s.creditPolicy()
|
||||
if err != nil || policy.SignupBonusMicrocredits == 0 {
|
||||
return err
|
||||
@@ -120,6 +124,9 @@ func (s *Service) CheckinCredits(user *model.User) (*model.CreditAccount, bool,
|
||||
if user == nil {
|
||||
return nil, false, Unauthorized("请先登录")
|
||||
}
|
||||
if err := s.RequireFeature(FeatureCredits); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
policy, err := s.creditPolicy()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
|
||||
139
backend/internal/service/feature_availability.go
Normal file
139
backend/internal/service/feature_availability.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const featureAvailabilitySettingKey = "feature_availability"
|
||||
|
||||
const (
|
||||
FeatureShortDrama = "shortDrama"
|
||||
FeatureTaskCenter = "taskCenter"
|
||||
FeatureCredits = "credits"
|
||||
)
|
||||
|
||||
type FeatureAvailability struct {
|
||||
ShortDramaEnabled bool `json:"shortDramaEnabled"`
|
||||
TaskCenterEnabled bool `json:"taskCenterEnabled"`
|
||||
CreditsEnabled bool `json:"creditsEnabled"`
|
||||
}
|
||||
|
||||
type PublicFeatureAvailability struct {
|
||||
FeatureAvailability
|
||||
Configured bool `json:"configured"`
|
||||
UpdatedBy string `json:"updatedBy,omitempty"`
|
||||
UpdatedAt time.Time `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
func defaultFeatureAvailability() FeatureAvailability {
|
||||
// 缺少配置代表尚未由运维接管,默认保持现有功能全部开放。
|
||||
return FeatureAvailability{ShortDramaEnabled: true, TaskCenterEnabled: true, CreditsEnabled: true}
|
||||
}
|
||||
|
||||
func (s *Service) FeatureAvailability() (*PublicFeatureAvailability, error) {
|
||||
setting, value, err := s.readFeatureAvailability()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return publicFeatureAvailability(setting, value), nil
|
||||
}
|
||||
|
||||
func (s *Service) AdminFeatureAvailability(actor *model.User) (*PublicFeatureAvailability, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.FeatureAvailability()
|
||||
}
|
||||
|
||||
func (s *Service) UpdateFeatureAvailability(actor *model.User, value FeatureAvailability) (*PublicFeatureAvailability, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, before, err := s.readFeatureAvailability()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setting := model.SystemSetting{Key: featureAvailabilitySettingKey, ValueJSON: string(encoded), UpdatedBy: actor.ID}
|
||||
if current != nil {
|
||||
setting.CreatedAt = current.CreatedAt
|
||||
}
|
||||
if err := s.repo.SaveSystemSetting(&setting); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.appendAdminAudit(actor, "feature_availability.update", "system_setting", featureAvailabilitySettingKey, "更新功能开放配置", map[string]any{"before": before, "after": value}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return publicFeatureAvailability(&setting, value), nil
|
||||
}
|
||||
|
||||
func (s *Service) FeatureEnabled(feature string) (bool, error) {
|
||||
_, value, err := s.readFeatureAvailability()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch feature {
|
||||
case FeatureShortDrama:
|
||||
return value.ShortDramaEnabled, nil
|
||||
case FeatureTaskCenter:
|
||||
return value.TaskCenterEnabled, nil
|
||||
case FeatureCredits:
|
||||
return value.CreditsEnabled, nil
|
||||
default:
|
||||
return false, errors.New("未知功能开放配置")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) RequireFeature(feature string) error {
|
||||
enabled, err := s.FeatureEnabled(feature)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if enabled {
|
||||
return nil
|
||||
}
|
||||
switch feature {
|
||||
case FeatureShortDrama:
|
||||
return Forbidden("短剧创作暂未开放")
|
||||
case FeatureTaskCenter:
|
||||
return Forbidden("任务中心暂未开放")
|
||||
case FeatureCredits:
|
||||
return Forbidden("积分功能暂未开放")
|
||||
default:
|
||||
return Forbidden("该功能暂未开放")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) readFeatureAvailability() (*model.SystemSetting, FeatureAvailability, error) {
|
||||
setting, err := s.repo.SystemSetting(featureAvailabilitySettingKey)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, defaultFeatureAvailability(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, FeatureAvailability{}, err
|
||||
}
|
||||
value := FeatureAvailability{}
|
||||
if strings.TrimSpace(setting.ValueJSON) == "" || json.Unmarshal([]byte(setting.ValueJSON), &value) != nil {
|
||||
return nil, FeatureAvailability{}, errors.New("功能开放配置格式无效")
|
||||
}
|
||||
return setting, value, nil
|
||||
}
|
||||
|
||||
func publicFeatureAvailability(setting *model.SystemSetting, value FeatureAvailability) *PublicFeatureAvailability {
|
||||
result := &PublicFeatureAvailability{FeatureAvailability: value, Configured: setting != nil}
|
||||
if setting != nil {
|
||||
result.UpdatedBy = setting.UpdatedBy
|
||||
result.UpdatedAt = setting.UpdatedAt
|
||||
}
|
||||
return result
|
||||
}
|
||||
84
backend/internal/service/feature_availability_test.go
Normal file
84
backend/internal/service/feature_availability_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestFeatureAvailabilityDefaultsToEnabled(t *testing.T) {
|
||||
svc, _ := newFeatureAvailabilityTestService(t)
|
||||
|
||||
setting, err := svc.FeatureAvailability()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if setting.Configured || !setting.ShortDramaEnabled || !setting.TaskCenterEnabled || !setting.CreditsEnabled {
|
||||
t.Fatalf("FeatureAvailability() = %#v", setting)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeatureAvailabilityPersistsAndAudits(t *testing.T) {
|
||||
svc, db := newFeatureAvailabilityTestService(t)
|
||||
actor := &model.User{ID: "admin-1", Role: model.UserRoleAdmin}
|
||||
want := FeatureAvailability{ShortDramaEnabled: false, TaskCenterEnabled: true, CreditsEnabled: false}
|
||||
|
||||
setting, err := svc.UpdateFeatureAvailability(actor, want)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !setting.Configured || setting.ShortDramaEnabled || !setting.TaskCenterEnabled || setting.CreditsEnabled {
|
||||
t.Fatalf("UpdateFeatureAvailability() = %#v", setting)
|
||||
}
|
||||
if err := svc.RequireFeature(FeatureShortDrama); err == nil {
|
||||
t.Fatal("RequireFeature(shortDrama) error = nil")
|
||||
} else {
|
||||
var authErr *AuthError
|
||||
if !errors.As(err, &authErr) || authErr.Status != 403 {
|
||||
t.Fatalf("RequireFeature(shortDrama) error = %#v", err)
|
||||
}
|
||||
}
|
||||
var auditCount int64
|
||||
if err := db.Model(&model.AdminAuditEvent{}).Where("action = ?", "feature_availability.update").Count(&auditCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if auditCount != 1 {
|
||||
t.Fatalf("audit count = %d, want 1", auditCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskBillingOrderSkipsPricingWhenCreditsDisabled(t *testing.T) {
|
||||
svc, _ := newFeatureAvailabilityTestService(t)
|
||||
actor := &model.User{ID: "admin-1", Role: model.UserRoleAdmin}
|
||||
if _, err := svc.UpdateFeatureAvailability(actor, FeatureAvailability{ShortDramaEnabled: true, TaskCenterEnabled: true, CreditsEnabled: false}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
order, err := svc.taskBillingOrder("user-1", &model.Task{ID: "task-1"}, map[string]any{"config": map[string]any{"channelId": "missing", "model": "missing"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if order != nil {
|
||||
t.Fatalf("taskBillingOrder() = %#v, want nil", order)
|
||||
}
|
||||
}
|
||||
|
||||
func newFeatureAvailabilityTestService(t *testing.T) (*Service, *gorm.DB) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.SystemSetting{}, &model.AdminAuditEvent{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return New(repository.New(db), t.TempDir()), db
|
||||
}
|
||||
@@ -107,6 +107,9 @@ func (s *Service) Wallet(user *model.User, entryType string, page int, limit int
|
||||
if user == nil {
|
||||
return nil, Unauthorized("请先登录")
|
||||
}
|
||||
if err := s.RequireFeature(FeatureCredits); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
@@ -132,6 +135,9 @@ func (s *Service) RedeemCredits(user *model.User, code string, redeemedIP string
|
||||
if user == nil {
|
||||
return nil, Unauthorized("请先登录")
|
||||
}
|
||||
if err := s.RequireFeature(FeatureCredits); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code = strings.ToLower(strings.TrimSpace(code))
|
||||
if len(code) != 32 {
|
||||
return nil, BadAuthRequest("兑换码无效或已使用")
|
||||
@@ -414,6 +420,13 @@ func (s *Service) AdminDisableRedeemCode(actor *model.User, batchID string, code
|
||||
}
|
||||
|
||||
func (s *Service) taskBillingOrder(userID string, task *model.Task, input map[string]any) (*model.BillingOrder, error) {
|
||||
enabled, err := s.FeatureEnabled(FeatureCredits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !enabled {
|
||||
return nil, nil
|
||||
}
|
||||
config, _ := input["config"].(map[string]any)
|
||||
if config == nil {
|
||||
return nil, nil
|
||||
@@ -435,6 +448,13 @@ func (s *Service) taskBillingOrder(userID string, task *model.Task, input map[st
|
||||
}
|
||||
|
||||
func (s *Service) ReserveProxyBilling(userID string, channelID string, modelKey string, capability string, scene string, idempotencyKey string, quantity int64) (*model.BillingOrder, error) {
|
||||
enabled, err := s.FeatureEnabled(FeatureCredits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !enabled {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.TrimSpace(idempotencyKey) == "" {
|
||||
idempotencyKey = newID()
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ func TestRedeemBatchCanBeReviewedAndRecordsAuditIP(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.CreditAccount{}, &model.CreditLedgerEntry{}, &model.RedeemBatch{}, &model.RedeemCode{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.SystemSetting{}, &model.User{}, &model.CreditAccount{}, &model.CreditLedgerEntry{}, &model.RedeemBatch{}, &model.RedeemCode{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := &model.User{ID: "admin-1", Username: "admin", DisplayName: "管理员", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestNormalizeTaskInputMakesTypedProviderConfigBillable(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.ChannelModel{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.SystemSetting{}, &model.ChannelModel{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channelModel := model.ChannelModel{
|
||||
|
||||
Reference in New Issue
Block a user