feat(*): 功能开放与页面布局 - 新增运维开关并优化后台及创作界面空间利用

This commit is contained in:
ddCat
2026-08-04 16:24:18 +08:00
parent 8f7c492b70
commit f439d600ba
36 changed files with 723 additions and 86 deletions

View File

@@ -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")

View File

@@ -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))

View 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()
}
}

View File

@@ -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}).

View File

@@ -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

View 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
}

View 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
}

View File

@@ -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()
}

View File

@@ -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}

View File

@@ -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{