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

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

No files matched your search

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