From ca9ce99b6637e4a7bceeba55a53c7169431d32be Mon Sep 17 00:00:00 2001 From: Yvan <8574526@qq,com> Date: Wed, 4 Nov 2026 21:18:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E5=85=B6=E4=BB=96=E8=AE=BE=E7=BD=AE=E8=8F=9C=E5=8D=95=E5=8F=8A?= =?UTF-8?q?=E6=9C=89=E6=95=88=E7=AB=AF=E5=8F=82=E6=95=B0=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/other_settings_http_test.go | 90 ++++++++++++++ cmd/wire_gen.go | 5 + internal/biz/biz.go | 2 +- internal/biz/business/other_settings.go | 78 ++++++++++++ internal/biz/business/other_settings_test.go | 57 +++++++++ internal/data/business/menu_icons.go | 1 + internal/data/business/other_settings.go | 91 ++++++++++++++ internal/data/business/other_settings_test.go | 81 +++++++++++++ internal/data/data.go | 1 + internal/data/migrations_test.go | 4 +- internal/modules/business/definition.go | 47 +++++++- internal/modules/business/definition_test.go | 66 +++++++++++ internal/modules/catalog_test.go | 8 +- internal/routecatalog/catalog.go | 2 + internal/server/gin_test.go | 3 +- internal/server/handler/other_settings.go | 39 ++++++ internal/server/handler/provider.go | 1 + internal/server/handler/set.go | 1 + internal/server/router/other_settings.go | 11 ++ internal/server/router/routes.go | 1 + internal/service/business/other_settings.go | 52 ++++++++ .../service/business/other_settings_test.go | 38 ++++++ internal/service/dto/other_settings.go | 17 +++ internal/service/service.go | 1 + web/src/api/otherSettings.js | 4 + web/src/pathInfo.json | 1 + web/src/view/sw/OtherSettings.vue | 112 ++++++++++++++++++ 27 files changed, 805 insertions(+), 9 deletions(-) create mode 100644 cmd/other_settings_http_test.go create mode 100644 internal/biz/business/other_settings.go create mode 100644 internal/biz/business/other_settings_test.go create mode 100644 internal/data/business/other_settings.go create mode 100644 internal/data/business/other_settings_test.go create mode 100644 internal/modules/business/definition_test.go create mode 100644 internal/server/handler/other_settings.go create mode 100644 internal/server/router/other_settings.go create mode 100644 internal/service/business/other_settings.go create mode 100644 internal/service/business/other_settings_test.go create mode 100644 internal/service/dto/other_settings.go create mode 100644 web/src/api/otherSettings.js create mode 100644 web/src/view/sw/OtherSettings.vue diff --git a/cmd/other_settings_http_test.go b/cmd/other_settings_http_test.go new file mode 100644 index 0000000..83bafba --- /dev/null +++ b/cmd/other_settings_http_test.go @@ -0,0 +1,90 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "gorm.io/gorm" + businessbiz "kra/internal/biz/business" + "kra/internal/biz/system" + businessdata "kra/internal/data/business" + systemdata "kra/internal/data/system" + "kra/internal/server/handler" + "kra/internal/server/router" + businessservice "kra/internal/service/business" +) + +func TestOtherSettingsHTTPPersistenceAndAccess(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "settings.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + pool, _ := db.DB() + t.Cleanup(func() { _ = pool.Close() }) + for _, step := range systemdata.Migrations() { + if err := step.Migrate(db); err != nil { + t.Fatal(err) + } + } + uc := businessbiz.NewOtherSettingsUsecase(businessdata.NewOtherSettingsRepo(businessHTTPData{db})) + engine := gin.New() + engine.Use(func(c *gin.Context) { + role, _ := strconv.ParseUint(c.GetHeader("X-Test-Role"), 10, 64) + if role != 0 { + c.Request = c.Request.WithContext(system.NewActorContext(c.Request.Context(), system.Actor{UserID: 1, AuthorityID: uint(role)})) + } + c.Next() + }) + router.RegisterOtherSettings(engine.Group(""), handler.NewOtherSettings(businessservice.NewOtherSettingsService(uc))) + call := func(role int, method, body string) map[string]any { + t.Helper() + req := httptest.NewRequest(method, "/sw/settings/effectiveness", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Test-Role", strconv.Itoa(role)) + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, req) + var result map[string]any + if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + return result + } + for _, role := range []int{0, 901, 902, 903} { + if call(role, http.MethodGet, "")["code"] == float64(0) { + t.Fatal("非超管不得读取设置") + } + if call(role, http.MethodPut, `{"amount":10,"days":10}`)["code"] == float64(0) { + t.Fatal("非超管不得写入设置") + } + } + value := call(888, http.MethodGet, "")["data"].(map[string]any) + if value["amount"] != float64(3000) || value["days"] != float64(30) { + t.Fatal("首次访问默认值错误") + } + for _, body := range []string{`{}`, `{"amount":0}`, `{"amount":-1,"days":30}`, `{"amount":3.5,"days":30}`, `{"amount":0,"days":0}`, `{"amount":"3000","days":30}`} { + if call(888, http.MethodPut, body)["code"] == float64(0) { + t.Fatalf("非法输入被接受: %s", body) + } + } + if call(888, http.MethodPut, `{"amount":0,"days":14}`)["code"] != float64(0) { + t.Fatal("合法设置保存失败") + } + value = call(888, http.MethodGet, "")["data"].(map[string]any) + if value["amount"] != float64(0) || value["days"] != float64(14) || value["updatedAt"] == "" { + t.Fatal("读取未回显已保存值") + } + var raw string + if err := db.Table("sys_params").Select("value").Where(map[string]any{"key": "sw.channel_effectiveness"}).Scan(&raw).Error; err != nil || raw != `{"amount":0,"days":14}` { + t.Fatalf("应使用参数表的单条 JSON: %s %v", raw, err) + } + if call(888, http.MethodPut, `{"amount":3000,"days":30}`)["code"] != float64(0) { + t.Fatal("默认值无法重新保存") + } +} diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 2bed735..39f340a 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -82,6 +82,10 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg auditRecorderUsecase := system2.NewAuditRecorderUsecase(auditRecordRepo) auditRecorder := system3.NewAuditRecorder(auditRecorderUsecase) registry := taskRegistry() + otherSettingsRepo := business.NewOtherSettingsRepo(dataData) + otherSettingsUsecase := business2.NewOtherSettingsUsecase(otherSettingsRepo) + otherSettingsService := business3.NewOtherSettingsService(otherSettingsUsecase) + otherSettings := handler.NewOtherSettings(otherSettingsService) rankingRepo := business.NewRankingRepo(dataData) accountRepo := business.NewAccountRepo(dataData) accountUsecase := business2.NewAccountUsecase(accountRepo) @@ -247,6 +251,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg integrationConfigService := integration2.NewIntegrationConfigService(integrationConfigUsecase) integrationConfig := handler.NewIntegrationConfig(integrationConfigService) set := &handler.Set{ + OtherSettings: otherSettings, Ranking: ranking, Workbench: workbench, ServerAnalysis: serverAnalysis, diff --git a/internal/biz/biz.go b/internal/biz/biz.go index a7c26a0..a4fa9d7 100644 --- a/internal/biz/biz.go +++ b/internal/biz/biz.go @@ -11,4 +11,4 @@ import ( ) // ProviderSet is biz providers. -var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase, business.NewReportBuildUsecase, business.NewAlertTriggerUsecase) +var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewOtherSettingsUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase, business.NewReportBuildUsecase, business.NewAlertTriggerUsecase) diff --git a/internal/biz/business/other_settings.go b/internal/biz/business/other_settings.go new file mode 100644 index 0000000..764398a --- /dev/null +++ b/internal/biz/business/other_settings.go @@ -0,0 +1,78 @@ +package business + +import ( + "context" + "time" + + "github.com/go-kratos/kratos/v3/errors" + "kra/internal/biz/system" +) + +var ( + ErrOtherSettingsScope = errors.Forbidden("SW_OTHER_SETTINGS_SCOPE", "仅超级管理员可以查看和修改其他设置") + ErrOtherSettingsInput = errors.BadRequest("SW_OTHER_SETTINGS_INPUT", "达标金额须为不小于0的整数,判定窗口须为不小于1的整数,且不能超过安全整数范围") + ErrOtherSettingsStored = errors.BadRequest("SW_OTHER_SETTINGS_STORED", "已保存的设置格式不正确,请恢复默认后重新保存") +) + +const MaxSettingsInteger int64 = 9007199254740991 + +type OtherSettings struct { + Amount, Days int64 + UpdatedAt time.Time +} + +func DefaultOtherSettings() OtherSettings { return OtherSettings{Amount: 3000, Days: 30} } + +func ValidateOtherSettings(value OtherSettings) error { + if value.Amount < 0 || value.Amount > MaxSettingsInteger || value.Days < 1 || value.Days > MaxSettingsInteger { + return ErrOtherSettingsInput + } + return nil +} + +type OtherSettingsRepo interface { + Find(context.Context) (*OtherSettings, error) + Save(context.Context, OtherSettings) (*OtherSettings, error) +} + +type OtherSettingsUsecase struct{ repo OtherSettingsRepo } + +func NewOtherSettingsUsecase(repo OtherSettingsRepo) *OtherSettingsUsecase { + return &OtherSettingsUsecase{repo: repo} +} + +func requireSettingsAdmin(ctx context.Context) error { + actor, ok := system.ActorFromContext(ctx) + if !ok || actor.UserID == 0 { + return ErrUnauthenticated + } + if actor.AuthorityID != system.SuperAdminAuthorityID { + return ErrOtherSettingsScope + } + return nil +} + +func (uc *OtherSettingsUsecase) Get(ctx context.Context) (*OtherSettings, error) { + if err := requireSettingsAdmin(ctx); err != nil { + return nil, err + } + value, err := uc.repo.Find(ctx) + if err != nil { + return nil, err + } + if value == nil { + defaults := DefaultOtherSettings() + return &defaults, nil + } + return value, nil +} + +func (uc *OtherSettingsUsecase) Save(ctx context.Context, value OtherSettings) (*OtherSettings, error) { + if err := requireSettingsAdmin(ctx); err != nil { + return nil, err + } + if err := ValidateOtherSettings(value); err != nil { + return nil, err + } + return uc.repo.Save(ctx, value) +} diff --git a/internal/biz/business/other_settings_test.go b/internal/biz/business/other_settings_test.go new file mode 100644 index 0000000..4370de9 --- /dev/null +++ b/internal/biz/business/other_settings_test.go @@ -0,0 +1,57 @@ +package business + +import ( + "context" + "testing" +) + +type otherSettingsFake struct { + value *OtherSettings + calls int +} + +func (r *otherSettingsFake) Find(context.Context) (*OtherSettings, error) { + r.calls++ + return r.value, nil +} +func (r *otherSettingsFake) Save(_ context.Context, value OtherSettings) (*OtherSettings, error) { + r.calls++ + r.value = &value + return r.value, nil +} + +func TestOtherSettingsOnlySuperAdminAndDefaults(t *testing.T) { + r := &otherSettingsFake{} + uc := NewOtherSettingsUsecase(r) + for _, ctx := range []context.Context{context.Background(), accountContext(7, 901), accountContext(8, 902), accountContext(9, 903)} { + if _, err := uc.Get(ctx); err == nil { + t.Fatal("非超管不能读取设置") + } + if _, err := uc.Save(ctx, DefaultOtherSettings()); err == nil { + t.Fatal("非超管不能保存设置") + } + } + if r.calls != 0 { + t.Fatal("无权限请求不得访问存储") + } + value, err := uc.Get(accountContext(1, 888)) + if err != nil || *value != DefaultOtherSettings() { + t.Fatalf("首次读取应返回默认值: %+v %v", value, err) + } + if _, err := uc.Save(accountContext(1, 888), OtherSettings{Amount: 0, Days: 1}); err != nil { + t.Fatal(err) + } + value, err = uc.Get(accountContext(2, 888)) + if err != nil || value.Amount != 0 || value.Days != 1 { + t.Fatal("参数应为全站共享,不能按用户各存一份") + } +} + +func TestOtherSettingsRejectInvalidRange(t *testing.T) { + uc := NewOtherSettingsUsecase(&otherSettingsFake{}) + for _, value := range []OtherSettings{{Amount: -1, Days: 30}, {Amount: 3000, Days: 0}, {Amount: MaxSettingsInteger + 1, Days: 30}, {Amount: 3000, Days: MaxSettingsInteger + 1}} { + if _, err := uc.Save(accountContext(1, 888), value); err != ErrOtherSettingsInput { + t.Fatalf("不应保存非法值: %+v %v", value, err) + } + } +} diff --git a/internal/data/business/menu_icons.go b/internal/data/business/menu_icons.go index dbe8d22..d91b3c6 100644 --- a/internal/data/business/menu_icons.go +++ b/internal/data/business/menu_icons.go @@ -4,6 +4,7 @@ import "gorm.io/gorm" // 使用 Kra 全局注册的 Element Plus 组件名;不可直接沿用 RuoYi 的 SVG 文件名。 var businessMenuIcons = map[string]string{ + "sw_OtherSettings": "Setting", "sw_BusMgmt": "Briefcase", "sw_BusinessList": "UserFilled", "sw_ChannelList": "Connection", diff --git a/internal/data/business/other_settings.go b/internal/data/business/other_settings.go new file mode 100644 index 0000000..9af342a --- /dev/null +++ b/internal/data/business/other_settings.go @@ -0,0 +1,91 @@ +package business + +import ( + "context" + "encoding/json" + "errors" + "sync" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "kra/internal/biz/business" +) + +const otherSettingsKey = "sw.channel_effectiveness" + +// Projection of the existing parameter-management table; no new settings table. +type otherSettingsPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt, UpdatedAt time.Time + DeletedAt gorm.DeletedAt + Name, Key, Value, Desc string +} + +func (otherSettingsPO) TableName() string { return "sys_params" } + +type effectivenessJSON struct { + Amount *int64 `json:"amount"` + Days *int64 `json:"days"` +} + +func newOtherSettings(value business.OtherSettings) otherSettingsPO { + encoded, _ := json.Marshal(effectivenessJSON{Amount: &value.Amount, Days: &value.Days}) + return otherSettingsPO{Name: "渠道有效端判定", Key: otherSettingsKey, Value: string(encoded), Desc: "其他设置:达标金额(元)和判定窗口(天)"} +} + +func toBizOtherSettings(po otherSettingsPO) (*business.OtherSettings, error) { + var value effectivenessJSON + if err := json.Unmarshal([]byte(po.Value), &value); err != nil || value.Amount == nil || value.Days == nil { + return nil, business.ErrOtherSettingsStored + } + result := &business.OtherSettings{Amount: *value.Amount, Days: *value.Days, UpdatedAt: po.UpdatedAt} + if err := business.ValidateOtherSettings(*result); err != nil { + return nil, business.ErrOtherSettingsStored + } + return result, nil +} + +type otherSettingsRepo struct { + data Provider + // Serialize first creation as sys_params has no unique index on key. + mu sync.Mutex +} + +func NewOtherSettingsRepo(data Provider) business.OtherSettingsRepo { + return &otherSettingsRepo{data: data} +} + +func (r *otherSettingsRepo) Find(ctx context.Context) (*business.OtherSettings, error) { + var po otherSettingsPO + err := r.data.DB().WithContext(ctx).Where(map[string]any{"key": otherSettingsKey}).First(&po).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, business.ErrStorage + } + return toBizOtherSettings(po) +} + +func (r *otherSettingsRepo) Save(ctx context.Context, value business.OtherSettings) (*business.OtherSettings, error) { + r.mu.Lock() + defer r.mu.Unlock() + po := newOtherSettings(value) + err := r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var existing otherSettingsPO + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where(map[string]any{"key": otherSettingsKey}).First(&existing).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return tx.Create(&po).Error + } + if err != nil { + return err + } + po.ID, po.CreatedAt, po.UpdatedAt = existing.ID, existing.CreatedAt, time.Now() + return tx.Model(&existing).Updates(map[string]any{"value": po.Value, "updated_at": po.UpdatedAt}).Error + }) + if err != nil { + return nil, business.ErrStorage + } + return toBizOtherSettings(po) +} diff --git a/internal/data/business/other_settings_test.go b/internal/data/business/other_settings_test.go new file mode 100644 index 0000000..15c81f7 --- /dev/null +++ b/internal/data/business/other_settings_test.go @@ -0,0 +1,81 @@ +package business + +import ( + "context" + "sync" + "testing" + + "kra/internal/biz/business" +) + +func TestOtherSettingsReuseParametersAndSaveAtomically(t *testing.T) { + db := openTestDB(t) + if err := db.AutoMigrate(&otherSettingsPO{}); err != nil { + t.Fatal(err) + } + if err := db.Create(&otherSettingsPO{Name: "other", Key: "unrelated", Value: "keep"}).Error; err != nil { + t.Fatal(err) + } + r := NewOtherSettingsRepo(testData{db}) + ctx := context.Background() + if value, err := r.Find(ctx); err != nil || value != nil { + t.Fatal("缺省记录应由用例提供默认值") + } + var wg sync.WaitGroup + for i := int64(1); i <= 8; i++ { + wg.Add(1) + go func(value int64) { + defer wg.Done() + if _, err := r.Save(ctx, business.OtherSettings{Amount: value * 100, Days: value}); err != nil { + t.Error(err) + } + }(i) + } + wg.Wait() + value, err := NewOtherSettingsRepo(testData{db}).Find(ctx) + if err != nil || value.Amount != value.Days*100 || value.UpdatedAt.IsZero() { + t.Fatalf("重建仓储后应回显完整持久化参数: %+v %v", value, err) + } + var count int64 + if err := db.Model(&otherSettingsPO{}).Where(map[string]any{"key": otherSettingsKey}).Count(&count).Error; err != nil || count != 1 { + t.Fatal("反复保存不能新增多份参数") + } + var other otherSettingsPO + if err := db.Where(map[string]any{"key": "unrelated"}).First(&other).Error; err != nil || other.Value != "keep" { + t.Fatal("保存不得覆盖其他参数") + } + if _, err := r.Save(ctx, business.OtherSettings{Amount: 0, Days: 1}); err != nil { + t.Fatal(err) + } + value, _ = r.Find(ctx) + if value.Amount != 0 || value.Days != 1 { + t.Fatal("零金额必须持久化") + } +} + +func TestOtherSettingsMalformedStoredValueCanBeRepaired(t *testing.T) { + db := openTestDB(t) + if err := db.AutoMigrate(&otherSettingsPO{}); err != nil { + t.Fatal(err) + } + r := NewOtherSettingsRepo(testData{db}) + ctx := context.Background() + if _, err := r.Save(ctx, business.DefaultOtherSettings()); err != nil { + t.Fatal(err) + } + for _, raw := range []string{`{"amount":3000}`, `{"amount":3000,"days":0}`, `{"amount":1.5,"days":30}`, `invalid`} { + if err := db.Model(&otherSettingsPO{}).Where(map[string]any{"key": otherSettingsKey}).Update("value", raw).Error; err != nil { + t.Fatal(err) + } + if _, err := r.Find(ctx); err != business.ErrOtherSettingsStored { + t.Fatalf("错误参数不能静默当成默认值: %v", err) + } + } + if _, err := r.Save(ctx, business.DefaultOtherSettings()); err != nil { + t.Fatal(err) + } + value, err := r.Find(ctx) + if err != nil || value.Amount != 3000 || value.Days != 30 { + t.Fatal("恢复默认保存应修复格式错误的参数") + } +} diff --git a/internal/data/data.go b/internal/data/data.go index e5a7587..d632dad 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -29,6 +29,7 @@ var ProviderSet = wire.NewSet( wire.Bind(new(databusiness.Provider), new(*Data)), wire.Bind(new(databusiness.PlatformProvider), new(*Data)), databusiness.NewUserPreferenceRepo, + databusiness.NewOtherSettingsRepo, databusiness.NewAccountRepo, databusiness.NewBindingRepo, databusiness.NewPlatformAccountRepo, diff --git a/internal/data/migrations_test.go b/internal/data/migrations_test.go index 3c2aff2..7461aba 100644 --- a/internal/data/migrations_test.go +++ b/internal/data/migrations_test.go @@ -34,8 +34,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) { if err = db.Table(migration.TableName).Count(&versions).Error; err != nil { t.Fatal(err) } - if versions != 28 { - t.Fatalf("migration versions = %d, want 28", versions) + if expected := int64(len(testCatalog().MigrationSteps())); versions != expected { + t.Fatalf("migration versions = %d, want %d", versions, expected) } var communicationRows []dataintegration.ConfigPO if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil { diff --git a/internal/modules/business/definition.go b/internal/modules/business/definition.go index 7fcb34d..38da59d 100644 --- a/internal/modules/business/definition.go +++ b/internal/modules/business/definition.go @@ -1,14 +1,59 @@ package business import ( + "gorm.io/gorm" databusiness "kra/internal/data/business" + datasystem "kra/internal/data/system" "kra/internal/modules/surface" + "kra/pkg/database/migration" "kra/pkg/module" ) func Definition() module.Definition { - return module.Definition{Name: "business", Migrations: databusiness.Migrations(), Surface: module.Surface{ + settingsMenu := module.Menu{Name: "sw_OtherSettings", Path: "OtherSettings", Component: "view/sw/OtherSettings.vue", Title: "其他设置", Icon: databusiness.MenuIcon("sw_OtherSettings"), Sort: 5} + migrations := append(databusiness.Migrations(), migration.Step{ID: "202611040016_business_other_settings_surface", Migrate: func(db *gorm.DB) error { + // First-install menus are created by the normal seed flow. + if !db.Migrator().HasTable("sys_authorities") { + return nil + } + var admins int64 + if err := db.Table("sys_authorities").Where("authority_id=888 AND deleted_at IS NULL").Count(&admins).Error; err != nil { + return err + } + if admins == 0 { + return nil + } + return db.Transaction(func(tx *gorm.DB) error { + menu := settingsMenu + var alert struct{ Sort int } + result := tx.Table("sys_base_menus").Select("sort").Where("name=? AND parent_id=0 AND deleted_at IS NULL", "sw_AlertCenter").Limit(1).Find(&alert) + if result.Error != nil { + return result.Error + } + if result.RowsAffected > 0 { + menu.Sort = alert.Sort + 1 + } + var collisions int64 + if err := tx.Table("sys_base_menus").Where("parent_id=0 AND deleted_at IS NULL AND name<>? AND sort=?", menu.Name, menu.Sort).Count(&collisions).Error; err != nil { + return err + } + if collisions > 0 { + if err := tx.Table("sys_base_menus").Where("parent_id=0 AND deleted_at IS NULL AND name<>? AND sort>=?", menu.Name, menu.Sort).Update("sort", gorm.Expr("sort + 1")).Error; err != nil { + return err + } + } + return datasystem.EnsureAdminSurface(tx, module.Surface{ + Menus: []module.Menu{menu}, + APIs: []module.API{ + {Path: "/sw/settings/effectiveness", Method: "GET", Group: "其他设置", Description: "获取有效端判定参数"}, + {Path: "/sw/settings/effectiveness", Method: "PUT", Group: "其他设置", Description: "保存有效端判定参数"}, + }, + }, 888) + }) + }}) + return module.Definition{Name: "business", Migrations: migrations, Surface: module.Surface{ Menus: []module.Menu{ + settingsMenu, {Name: "sw_RankingAnalysis", Path: "RankingAnalysis", ParentName: "sw_DataAnalysis", Component: "view/sw/RankingAnalysis.vue", Title: "排行榜", Icon: databusiness.MenuIcon("sw_RankingAnalysis"), Sort: 6}, {Name: "sw_ServerAnalysis", Path: "ServerAnalysis", ParentName: "sw_DataAnalysis", Component: "view/sw/ServerAnalysis.vue", Title: "区服分析", Icon: databusiness.MenuIcon("sw_ServerAnalysis"), Sort: 5}, {Name: "sw_ChannelDataStatistics", Path: "ChannelDataStatistics", ParentName: "sw_DataAnalysis", Component: "view/sw/ChannelDataStatistics.vue", Title: "渠道数据统计", Icon: databusiness.MenuIcon("sw_ChannelDataStatistics"), Sort: 1}, diff --git a/internal/modules/business/definition_test.go b/internal/modules/business/definition_test.go new file mode 100644 index 0000000..1b275c0 --- /dev/null +++ b/internal/modules/business/definition_test.go @@ -0,0 +1,66 @@ +package business + +import ( + "path/filepath" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + datasystem "kra/internal/data/system" +) + +func TestOtherSettingsUpgradeAddsStandaloneMenuForSuperAdmin(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "settings.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + pool, _ := db.DB() + t.Cleanup(func() { _ = pool.Close() }) + for _, step := range datasystem.Migrations() { + if err := step.Migrate(db); err != nil { + t.Fatal(err) + } + } + if err := db.Exec("INSERT INTO sys_authorities(authority_id,authority_name,parent_id) VALUES(888,'admin',0),(901,'business',888)").Error; err != nil { + t.Fatal(err) + } + if err := db.Exec("INSERT INTO sys_base_menus(name,title,parent_id,sort) VALUES('sw_AlertCenter','预警中心',0,4),('permission','权限管理',0,5)").Error; err != nil { + t.Fatal(err) + } + for attempt := 0; attempt < 2; attempt++ { + for _, step := range Definition().Migrations { + if err := step.Migrate(db); err != nil { + t.Fatal(err) + } + } + } + var menu struct { + ID, ParentID uint + Sort int + Title, Component string + } + if err := db.Table("sys_base_menus").Where("name=?", "sw_OtherSettings").Take(&menu).Error; err != nil { + t.Fatal(err) + } + if menu.ParentID != 0 || menu.Sort != 5 || menu.Title != "其他设置" || menu.Component != "view/sw/OtherSettings.vue" { + t.Fatalf("必须是独立菜单: %+v", menu) + } + var permissionSort int + if err := db.Table("sys_base_menus").Select("sort").Where("name='permission'").Scan(&permissionSort).Error; err != nil || permissionSort != 6 { + t.Fatal("其他设置必须紧接预警中心,后续菜单只应顺延一次") + } + var roles []uint + if err := db.Table("sys_authority_menus").Where("sys_base_menu_id=?", menu.ID).Pluck("sys_authority_authority_id", &roles).Error; err != nil { + t.Fatal(err) + } + if len(roles) != 1 || roles[0] != 888 { + t.Fatalf("只有超管应收到菜单,重复升级不能重复授权: %v", roles) + } + var count int64 + if err := db.Table("casbin_rule").Where("ptype='p' AND v1=? AND v0='888'", "/sw/settings/effectiveness").Count(&count).Error; err != nil || count != 2 { + t.Fatal("超管读写 API 授权缺失或重复") + } + if err := db.Table("casbin_rule").Where("ptype='p' AND v1=? AND v0<>'888'", "/sw/settings/effectiveness").Count(&count).Error; err != nil || count != 0 { + t.Fatal("不应向商务账号开放设置 API") + } +} diff --git a/internal/modules/catalog_test.go b/internal/modules/catalog_test.go index 6518730..1960a7c 100644 --- a/internal/modules/catalog_test.go +++ b/internal/modules/catalog_test.go @@ -25,16 +25,16 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) { t.Fatalf("definition[%d] = %q, want %q", index, got, name) } } - if got := catalog.MigrationSteps(); len(got) != 28 { - t.Fatalf("module migrations = %d, want 28", len(got)) + if got := catalog.MigrationSteps(); len(got) != 30 { + t.Fatalf("module migrations = %d, want 30", len(got)) } var menus, apis int for _, definition := range catalog.Definitions { menus += len(definition.Surface.Menus) apis += len(definition.Surface.APIs) } - if menus != 18 || apis != 69 { - t.Fatalf("admin surface = %d menus/%d APIs, want 18/69", menus, apis) + if menus != 19 || apis != 71 { + t.Fatalf("admin surface = %d menus/%d APIs, want 19/71", menus, apis) } if got := catalog.DefaultTimedTasks(); len(got) != 2 { t.Fatalf("default timed tasks = %d, want 2", len(got)) diff --git a/internal/routecatalog/catalog.go b/internal/routecatalog/catalog.go index 6e39cc7..400a742 100644 --- a/internal/routecatalog/catalog.go +++ b/internal/routecatalog/catalog.go @@ -95,6 +95,8 @@ var routes = map[string]routeValue{ "POST /sw/business": {group: "商务管理", description: "添加子商务", audit: true}, "PUT /sw/business": {group: "商务管理", description: "编辑商务资料和审核状态", audit: true}, "GET /sw/user/preference": {group: "商务偏好", description: "获取当前用户商务偏好"}, + "GET /sw/settings/effectiveness": {group: "其他设置", description: "获取有效端判定参数"}, + "PUT /sw/settings/effectiveness": {group: "其他设置", description: "保存有效端判定参数", audit: true}, "PUT /sw/user/preference": {group: "商务偏好", description: "更新当前用户商务偏好", audit: true}, "DELETE /api/deleteApisByIds": {group: "api", description: "批量删除api", audit: true}, "DELETE /dataAccessLog/deleteDataAccessLogByIds": {group: "数据权限审计", description: "批量删除数据权限审计日志", audit: true}, diff --git a/internal/server/gin_test.go b/internal/server/gin_test.go index 47c4b1b..50c6b60 100644 --- a/internal/server/gin_test.go +++ b/internal/server/gin_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "testing" @@ -136,7 +137,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) { if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want { t.Fatalf("registered route log count = %d, want %d", got, want) } - if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":252`) { + if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":`+strconv.Itoa(len(engine.Routes()))) { t.Fatalf("startup route summary is missing: %s", text) } } diff --git a/internal/server/handler/other_settings.go b/internal/server/handler/other_settings.go new file mode 100644 index 0000000..e1c7166 --- /dev/null +++ b/internal/server/handler/other_settings.go @@ -0,0 +1,39 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + "github.com/go-kratos/kratos/v3/errors" + "kra/internal/service/business" + "kra/internal/service/dto" +) + +type OtherSettings struct { + service *business.OtherSettingsService +} + +func NewOtherSettings(service *business.OtherSettingsService) *OtherSettings { + return &OtherSettings{service: service} +} + +func (h *OtherSettings) Get(c *gin.Context) { + value, err := h.service.Get(c.Request.Context()) + if err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OKWithData(c, value) +} + +func (h *OtherSettings) Save(c *gin.Context) { + var req dto.OtherSettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + Fail(c, "请输入完整的整数金额和天数") + return + } + value, err := h.service.Save(c.Request.Context(), &req) + if err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OKWithData(c, value) +} diff --git a/internal/server/handler/provider.go b/internal/server/handler/provider.go index 43a5add..1a2b5e2 100644 --- a/internal/server/handler/provider.go +++ b/internal/server/handler/provider.go @@ -5,6 +5,7 @@ import "github.com/google/wire" // ProviderSet wires the system HTTP handlers. Set itself is filled field by // field so adding a handler only means adding its constructor here. var ProviderSet = wire.NewSet( + NewOtherSettings, NewBusiness, NewBusinessPassword, NewBusinessPromote, diff --git a/internal/server/handler/set.go b/internal/server/handler/set.go index 5ab5f44..6bf296f 100644 --- a/internal/server/handler/set.go +++ b/internal/server/handler/set.go @@ -1,6 +1,7 @@ package handler type Set struct { + OtherSettings *OtherSettings Ranking *Ranking Workbench *Workbench ServerAnalysis *ServerAnalysis diff --git a/internal/server/router/other_settings.go b/internal/server/router/other_settings.go new file mode 100644 index 0000000..177876e --- /dev/null +++ b/internal/server/router/other_settings.go @@ -0,0 +1,11 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "kra/internal/server/handler" +) + +func RegisterOtherSettings(private *gin.RouterGroup, h *handler.OtherSettings) { + private.GET("/sw/settings/effectiveness", h.Get) + private.PUT("/sw/settings/effectiveness", h.Save) +} diff --git a/internal/server/router/routes.go b/internal/server/router/routes.go index 76d6ee4..6bc51ab 100644 --- a/internal/server/router/routes.go +++ b/internal/server/router/routes.go @@ -20,6 +20,7 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En return } registrations := []func(){ + func() { RegisterOtherSettings(private, r.handlers.OtherSettings) }, func() { private.GET("/sw/rank/channel", r.handlers.Ranking.Channel) private.GET("/sw/rank/business", r.handlers.Ranking.Business) diff --git a/internal/service/business/other_settings.go b/internal/service/business/other_settings.go new file mode 100644 index 0000000..701e6a7 --- /dev/null +++ b/internal/service/business/other_settings.go @@ -0,0 +1,52 @@ +package business + +import ( + "context" + "time" + + "kra/internal/biz/business" + "kra/internal/service/dto" +) + +type OtherSettingsUsecase interface { + Get(context.Context) (*business.OtherSettings, error) + Save(context.Context, business.OtherSettings) (*business.OtherSettings, error) +} + +type OtherSettingsService struct{ uc OtherSettingsUsecase } + +func NewOtherSettingsService(uc *business.OtherSettingsUsecase) *OtherSettingsService { + return &OtherSettingsService{uc: uc} +} + +func otherSettingsResponse(value *business.OtherSettings) *dto.OtherSettingsResponse { + defaults := business.DefaultOtherSettings() + result := &dto.OtherSettingsResponse{OtherSettingsValues: dto.OtherSettingsValues{Amount: value.Amount, Days: value.Days}, Defaults: dto.OtherSettingsValues{Amount: defaults.Amount, Days: defaults.Days}} + if !value.UpdatedAt.IsZero() { + result.UpdatedAt = value.UpdatedAt.Format(time.RFC3339) + } + return result +} + +func (s *OtherSettingsService) Get(ctx context.Context) (*dto.OtherSettingsResponse, error) { + value, err := s.uc.Get(ctx) + if err != nil { + return nil, err + } + return otherSettingsResponse(value), nil +} + +func (s *OtherSettingsService) Save(ctx context.Context, req *dto.OtherSettingsRequest) (*dto.OtherSettingsResponse, error) { + if req == nil || req.Amount == nil || req.Days == nil { + return nil, business.ErrOtherSettingsInput + } + value := business.OtherSettings{Amount: *req.Amount, Days: *req.Days} + if err := business.ValidateOtherSettings(value); err != nil { + return nil, err + } + saved, err := s.uc.Save(ctx, value) + if err != nil { + return nil, err + } + return otherSettingsResponse(saved), nil +} diff --git a/internal/service/business/other_settings_test.go b/internal/service/business/other_settings_test.go new file mode 100644 index 0000000..84a1d0b --- /dev/null +++ b/internal/service/business/other_settings_test.go @@ -0,0 +1,38 @@ +package business + +import ( + "context" + "testing" + + "kra/internal/biz/business" + "kra/internal/service/dto" +) + +type settingsUsecaseFake struct{ calls int } + +func (f *settingsUsecaseFake) Get(context.Context) (*business.OtherSettings, error) { + value := business.DefaultOtherSettings() + return &value, nil +} +func (f *settingsUsecaseFake) Save(_ context.Context, value business.OtherSettings) (*business.OtherSettings, error) { + f.calls++ + return &value, nil +} + +func TestOtherSettingsServiceRequiresBothValidFields(t *testing.T) { + f := &settingsUsecaseFake{} + s := &OtherSettingsService{uc: f} + zero, days, negative := int64(0), int64(30), int64(-1) + for _, req := range []*dto.OtherSettingsRequest{nil, {}, {Amount: &zero}, {Days: &days}, {Amount: &negative, Days: &days}, {Amount: &zero, Days: &zero}} { + if _, err := s.Save(context.Background(), req); err != business.ErrOtherSettingsInput { + t.Fatalf("边界校验错误: %v", err) + } + } + if f.calls != 0 { + t.Fatal("无效输入不得进入用例") + } + res, err := s.Save(context.Background(), &dto.OtherSettingsRequest{Amount: &zero, Days: &days}) + if err != nil || res.Amount != 0 || res.Days != 30 || res.Defaults.Amount != 3000 || f.calls != 1 { + t.Fatalf("DTO 转换或零值错误: %+v %v", res, err) + } +} diff --git a/internal/service/dto/other_settings.go b/internal/service/dto/other_settings.go new file mode 100644 index 0000000..438b569 --- /dev/null +++ b/internal/service/dto/other_settings.go @@ -0,0 +1,17 @@ +package dto + +type OtherSettingsRequest struct { + Amount *int64 `json:"amount"` + Days *int64 `json:"days"` +} + +type OtherSettingsValues struct { + Amount int64 `json:"amount"` + Days int64 `json:"days"` +} + +type OtherSettingsResponse struct { + OtherSettingsValues + Defaults OtherSettingsValues `json:"defaults"` + UpdatedAt string `json:"updatedAt"` +} diff --git a/internal/service/service.go b/internal/service/service.go index 7d9ce92..24b5017 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -13,6 +13,7 @@ import ( // ProviderSet aggregates all service modules for Wire. var ProviderSet = wire.NewSet( businessservice.NewUserPreferenceService, + businessservice.NewOtherSettingsService, businessservice.NewAccountService, businessservice.NewBindingService, businessservice.NewPasswordResetService, diff --git a/web/src/api/otherSettings.js b/web/src/api/otherSettings.js new file mode 100644 index 0000000..c758159 --- /dev/null +++ b/web/src/api/otherSettings.js @@ -0,0 +1,4 @@ +import service from '@/utils/request' + +export const getOtherSettings = () => service({ url: '/sw/settings/effectiveness', method: 'get', donNotShowLoading: true }) +export const saveOtherSettings = data => service({ url: '/sw/settings/effectiveness', method: 'put', data, donNotShowLoading: true }) diff --git a/web/src/pathInfo.json b/web/src/pathInfo.json index 9911dc5..858a5a7 100644 --- a/web/src/pathInfo.json +++ b/web/src/pathInfo.json @@ -59,6 +59,7 @@ "/src/view/sw/ChannelDataStatistics.vue": "SwChannelDataStatistics", "/src/view/sw/ChannelList.vue": "SwChannelList", "/src/view/sw/GameAnalytics.vue": "SwGameAnalytics", + "/src/view/sw/OtherSettings.vue": "SwOtherSettings", "/src/view/sw/RankingAnalysis.vue": "RankingAnalysis", "/src/view/sw/RechargeDetails.vue": "SwRechargeDetails", "/src/view/sw/RegistrationDetails.vue": "SwRegistrationDetails", diff --git a/web/src/view/sw/OtherSettings.vue b/web/src/view/sw/OtherSettings.vue new file mode 100644 index 0000000..dafb9ba --- /dev/null +++ b/web/src/view/sw/OtherSettings.vue @@ -0,0 +1,112 @@ + + + + +