feat: 添加独立其他设置菜单及有效端参数保存
This commit is contained in:
@@ -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("默认值无法重新保存")
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+5
@@ -82,6 +82,10 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
|||||||
auditRecorderUsecase := system2.NewAuditRecorderUsecase(auditRecordRepo)
|
auditRecorderUsecase := system2.NewAuditRecorderUsecase(auditRecordRepo)
|
||||||
auditRecorder := system3.NewAuditRecorder(auditRecorderUsecase)
|
auditRecorder := system3.NewAuditRecorder(auditRecorderUsecase)
|
||||||
registry := taskRegistry()
|
registry := taskRegistry()
|
||||||
|
otherSettingsRepo := business.NewOtherSettingsRepo(dataData)
|
||||||
|
otherSettingsUsecase := business2.NewOtherSettingsUsecase(otherSettingsRepo)
|
||||||
|
otherSettingsService := business3.NewOtherSettingsService(otherSettingsUsecase)
|
||||||
|
otherSettings := handler.NewOtherSettings(otherSettingsService)
|
||||||
rankingRepo := business.NewRankingRepo(dataData)
|
rankingRepo := business.NewRankingRepo(dataData)
|
||||||
accountRepo := business.NewAccountRepo(dataData)
|
accountRepo := business.NewAccountRepo(dataData)
|
||||||
accountUsecase := business2.NewAccountUsecase(accountRepo)
|
accountUsecase := business2.NewAccountUsecase(accountRepo)
|
||||||
@@ -247,6 +251,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
|||||||
integrationConfigService := integration2.NewIntegrationConfigService(integrationConfigUsecase)
|
integrationConfigService := integration2.NewIntegrationConfigService(integrationConfigUsecase)
|
||||||
integrationConfig := handler.NewIntegrationConfig(integrationConfigService)
|
integrationConfig := handler.NewIntegrationConfig(integrationConfigService)
|
||||||
set := &handler.Set{
|
set := &handler.Set{
|
||||||
|
OtherSettings: otherSettings,
|
||||||
Ranking: ranking,
|
Ranking: ranking,
|
||||||
Workbench: workbench,
|
Workbench: workbench,
|
||||||
ServerAnalysis: serverAnalysis,
|
ServerAnalysis: serverAnalysis,
|
||||||
|
|||||||
+1
-1
@@ -11,4 +11,4 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ProviderSet is biz providers.
|
// 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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import "gorm.io/gorm"
|
|||||||
|
|
||||||
// 使用 Kra 全局注册的 Element Plus 组件名;不可直接沿用 RuoYi 的 SVG 文件名。
|
// 使用 Kra 全局注册的 Element Plus 组件名;不可直接沿用 RuoYi 的 SVG 文件名。
|
||||||
var businessMenuIcons = map[string]string{
|
var businessMenuIcons = map[string]string{
|
||||||
|
"sw_OtherSettings": "Setting",
|
||||||
"sw_BusMgmt": "Briefcase",
|
"sw_BusMgmt": "Briefcase",
|
||||||
"sw_BusinessList": "UserFilled",
|
"sw_BusinessList": "UserFilled",
|
||||||
"sw_ChannelList": "Connection",
|
"sw_ChannelList": "Connection",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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("恢复默认保存应修复格式错误的参数")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ var ProviderSet = wire.NewSet(
|
|||||||
wire.Bind(new(databusiness.Provider), new(*Data)),
|
wire.Bind(new(databusiness.Provider), new(*Data)),
|
||||||
wire.Bind(new(databusiness.PlatformProvider), new(*Data)),
|
wire.Bind(new(databusiness.PlatformProvider), new(*Data)),
|
||||||
databusiness.NewUserPreferenceRepo,
|
databusiness.NewUserPreferenceRepo,
|
||||||
|
databusiness.NewOtherSettingsRepo,
|
||||||
databusiness.NewAccountRepo,
|
databusiness.NewAccountRepo,
|
||||||
databusiness.NewBindingRepo,
|
databusiness.NewBindingRepo,
|
||||||
databusiness.NewPlatformAccountRepo,
|
databusiness.NewPlatformAccountRepo,
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
|
|||||||
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
|
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if versions != 28 {
|
if expected := int64(len(testCatalog().MigrationSteps())); versions != expected {
|
||||||
t.Fatalf("migration versions = %d, want 28", versions)
|
t.Fatalf("migration versions = %d, want %d", versions, expected)
|
||||||
}
|
}
|
||||||
var communicationRows []dataintegration.ConfigPO
|
var communicationRows []dataintegration.ConfigPO
|
||||||
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {
|
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {
|
||||||
|
|||||||
@@ -1,14 +1,59 @@
|
|||||||
package business
|
package business
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"gorm.io/gorm"
|
||||||
databusiness "kra/internal/data/business"
|
databusiness "kra/internal/data/business"
|
||||||
|
datasystem "kra/internal/data/system"
|
||||||
"kra/internal/modules/surface"
|
"kra/internal/modules/surface"
|
||||||
|
"kra/pkg/database/migration"
|
||||||
"kra/pkg/module"
|
"kra/pkg/module"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Definition() module.Definition {
|
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{
|
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_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_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},
|
{Name: "sw_ChannelDataStatistics", Path: "ChannelDataStatistics", ParentName: "sw_DataAnalysis", Component: "view/sw/ChannelDataStatistics.vue", Title: "渠道数据统计", Icon: databusiness.MenuIcon("sw_ChannelDataStatistics"), Sort: 1},
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,16 +25,16 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
|
|||||||
t.Fatalf("definition[%d] = %q, want %q", index, got, name)
|
t.Fatalf("definition[%d] = %q, want %q", index, got, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if got := catalog.MigrationSteps(); len(got) != 28 {
|
if got := catalog.MigrationSteps(); len(got) != 30 {
|
||||||
t.Fatalf("module migrations = %d, want 28", len(got))
|
t.Fatalf("module migrations = %d, want 30", len(got))
|
||||||
}
|
}
|
||||||
var menus, apis int
|
var menus, apis int
|
||||||
for _, definition := range catalog.Definitions {
|
for _, definition := range catalog.Definitions {
|
||||||
menus += len(definition.Surface.Menus)
|
menus += len(definition.Surface.Menus)
|
||||||
apis += len(definition.Surface.APIs)
|
apis += len(definition.Surface.APIs)
|
||||||
}
|
}
|
||||||
if menus != 18 || apis != 69 {
|
if menus != 19 || apis != 71 {
|
||||||
t.Fatalf("admin surface = %d menus/%d APIs, want 18/69", menus, apis)
|
t.Fatalf("admin surface = %d menus/%d APIs, want 19/71", menus, apis)
|
||||||
}
|
}
|
||||||
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
|
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
|
||||||
t.Fatalf("default timed tasks = %d, want 2", len(got))
|
t.Fatalf("default timed tasks = %d, want 2", len(got))
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ var routes = map[string]routeValue{
|
|||||||
"POST /sw/business": {group: "商务管理", description: "添加子商务", audit: true},
|
"POST /sw/business": {group: "商务管理", description: "添加子商务", audit: true},
|
||||||
"PUT /sw/business": {group: "商务管理", description: "编辑商务资料和审核状态", audit: true},
|
"PUT /sw/business": {group: "商务管理", description: "编辑商务资料和审核状态", audit: true},
|
||||||
"GET /sw/user/preference": {group: "商务偏好", description: "获取当前用户商务偏好"},
|
"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},
|
"PUT /sw/user/preference": {group: "商务偏好", description: "更新当前用户商务偏好", audit: true},
|
||||||
"DELETE /api/deleteApisByIds": {group: "api", description: "批量删除api", audit: true},
|
"DELETE /api/deleteApisByIds": {group: "api", description: "批量删除api", audit: true},
|
||||||
"DELETE /dataAccessLog/deleteDataAccessLogByIds": {group: "数据权限审计", description: "批量删除数据权限审计日志", audit: true},
|
"DELETE /dataAccessLog/deleteDataAccessLogByIds": {group: "数据权限审计", description: "批量删除数据权限审计日志", audit: true},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -136,7 +137,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
|
|||||||
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
|
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)
|
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)
|
t.Fatalf("startup route summary is missing: %s", text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import "github.com/google/wire"
|
|||||||
// ProviderSet wires the system HTTP handlers. Set itself is filled field by
|
// ProviderSet wires the system HTTP handlers. Set itself is filled field by
|
||||||
// field so adding a handler only means adding its constructor here.
|
// field so adding a handler only means adding its constructor here.
|
||||||
var ProviderSet = wire.NewSet(
|
var ProviderSet = wire.NewSet(
|
||||||
|
NewOtherSettings,
|
||||||
NewBusiness,
|
NewBusiness,
|
||||||
NewBusinessPassword,
|
NewBusinessPassword,
|
||||||
NewBusinessPromote,
|
NewBusinessPromote,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
type Set struct {
|
type Set struct {
|
||||||
|
OtherSettings *OtherSettings
|
||||||
Ranking *Ranking
|
Ranking *Ranking
|
||||||
Workbench *Workbench
|
Workbench *Workbench
|
||||||
ServerAnalysis *ServerAnalysis
|
ServerAnalysis *ServerAnalysis
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
registrations := []func(){
|
registrations := []func(){
|
||||||
|
func() { RegisterOtherSettings(private, r.handlers.OtherSettings) },
|
||||||
func() {
|
func() {
|
||||||
private.GET("/sw/rank/channel", r.handlers.Ranking.Channel)
|
private.GET("/sw/rank/channel", r.handlers.Ranking.Channel)
|
||||||
private.GET("/sw/rank/business", r.handlers.Ranking.Business)
|
private.GET("/sw/rank/business", r.handlers.Ranking.Business)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
// ProviderSet aggregates all service modules for Wire.
|
// ProviderSet aggregates all service modules for Wire.
|
||||||
var ProviderSet = wire.NewSet(
|
var ProviderSet = wire.NewSet(
|
||||||
businessservice.NewUserPreferenceService,
|
businessservice.NewUserPreferenceService,
|
||||||
|
businessservice.NewOtherSettingsService,
|
||||||
businessservice.NewAccountService,
|
businessservice.NewAccountService,
|
||||||
businessservice.NewBindingService,
|
businessservice.NewBindingService,
|
||||||
businessservice.NewPasswordResetService,
|
businessservice.NewPasswordResetService,
|
||||||
|
|||||||
@@ -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 })
|
||||||
@@ -59,6 +59,7 @@
|
|||||||
"/src/view/sw/ChannelDataStatistics.vue": "SwChannelDataStatistics",
|
"/src/view/sw/ChannelDataStatistics.vue": "SwChannelDataStatistics",
|
||||||
"/src/view/sw/ChannelList.vue": "SwChannelList",
|
"/src/view/sw/ChannelList.vue": "SwChannelList",
|
||||||
"/src/view/sw/GameAnalytics.vue": "SwGameAnalytics",
|
"/src/view/sw/GameAnalytics.vue": "SwGameAnalytics",
|
||||||
|
"/src/view/sw/OtherSettings.vue": "SwOtherSettings",
|
||||||
"/src/view/sw/RankingAnalysis.vue": "RankingAnalysis",
|
"/src/view/sw/RankingAnalysis.vue": "RankingAnalysis",
|
||||||
"/src/view/sw/RechargeDetails.vue": "SwRechargeDetails",
|
"/src/view/sw/RechargeDetails.vue": "SwRechargeDetails",
|
||||||
"/src/view/sw/RegistrationDetails.vue": "SwRegistrationDetails",
|
"/src/view/sw/RegistrationDetails.vue": "SwRegistrationDetails",
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<el-result v-if="!admin" icon="warning" title="无权访问" sub-title="其他设置仅超级管理员可见、可修改。" />
|
||||||
|
<div v-else class="other-settings">
|
||||||
|
<el-text class="settings-intro" type="info">集中配置渠道有效端的判定参数,仅超级管理员可修改。</el-text>
|
||||||
|
<el-card shadow="never" v-loading="loading">
|
||||||
|
<el-tabs v-model="tab">
|
||||||
|
<el-tab-pane label="有效端" name="effectiveness">
|
||||||
|
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon class="settings-error" />
|
||||||
|
<div class="settings-heading">
|
||||||
|
<h3>有效端判定参数</h3>
|
||||||
|
<el-text type="info">渠道注册后,在判定窗口内累计充值达到金额门槛,即视为有效端。</el-text>
|
||||||
|
</div>
|
||||||
|
<el-form v-if="loaded" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="loading || saving" @submit.prevent="save">
|
||||||
|
<el-form-item label="达标金额(元)" prop="amount">
|
||||||
|
<el-input-number v-model="form.amount" :min="0" :max="Number.MAX_SAFE_INTEGER" :step="100" :precision="0" controls-position="right" />
|
||||||
|
<el-text class="field-help" type="info">默认 ¥3,000,支持 0 及以上整数。</el-text>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="判定窗口(天)" prop="days">
|
||||||
|
<el-input-number v-model="form.days" :min="1" :max="Number.MAX_SAFE_INTEGER" :precision="0" controls-position="right" />
|
||||||
|
<el-text class="field-help" type="info">从渠道注册时间起算的连续天数,默认 30 天。</el-text>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div class="rule-preview">
|
||||||
|
<strong>判定口径说明</strong>
|
||||||
|
<ol>
|
||||||
|
<li>渠道注册后 <b>{{ form.days ?? '—' }} 天</b>内累计充值流水满 <b>{{ amountText }}</b>,即为有效端。</li>
|
||||||
|
<li>归属月份为<b>达标当天所在的月份</b>,该渠道计入当月有效端。</li>
|
||||||
|
<li>注册满 <b>{{ form.days ?? '—' }} 天</b>仍未达标,不算有效端,之后流水上涨也不补记。</li>
|
||||||
|
<li>一旦达标,有效端标记保持,不因后续流水回落而取消。</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<div class="settings-actions">
|
||||||
|
<el-button type="primary" :loading="saving" :disabled="loading || !loaded" @click="save">保存</el-button>
|
||||||
|
<el-button :disabled="loading || saving" @click="restoreDefaults">恢复默认</el-button>
|
||||||
|
<el-button v-if="error" link :disabled="loading || saving" @click="load">重新加载</el-button>
|
||||||
|
<el-text type="info" role="status">{{ status }}</el-text>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { useUserStore } from '@/pinia/modules/user'
|
||||||
|
import { getOtherSettings, saveOtherSettings } from '@/api/otherSettings'
|
||||||
|
|
||||||
|
defineOptions({ name: 'SwOtherSettings' })
|
||||||
|
const user = useUserStore()
|
||||||
|
const admin = computed(() => Number(user.userInfo.authorityId) === 888)
|
||||||
|
const tab = ref('effectiveness'), formRef = ref()
|
||||||
|
const defaults = ref({ amount: 3000, days: 30 })
|
||||||
|
const form = reactive({ ...defaults.value })
|
||||||
|
const loading = ref(false), saving = ref(false), loaded = ref(false), error = ref(''), status = ref('')
|
||||||
|
const amountText = computed(() => Number.isSafeInteger(form.amount) ? `¥${form.amount.toLocaleString('zh-CN')}` : '—')
|
||||||
|
const validateInteger = minimum => (_rule, value, callback) => callback(Number.isSafeInteger(value) && value >= minimum ? undefined : new Error(`请输入不小于 ${minimum} 的有效整数`))
|
||||||
|
const rules = {
|
||||||
|
amount: [{ validator: validateInteger(0), trigger: ['blur', 'change'] }],
|
||||||
|
days: [{ validator: validateInteger(1), trigger: ['blur', 'change'] }]
|
||||||
|
}
|
||||||
|
const apply = data => {
|
||||||
|
Object.assign(form, { amount: data.amount, days: data.days })
|
||||||
|
defaults.value = data.defaults
|
||||||
|
loaded.value = true
|
||||||
|
}
|
||||||
|
const load = async () => {
|
||||||
|
if (!admin.value || loading.value || saving.value) return
|
||||||
|
loading.value = true; loaded.value = false; error.value = ''; status.value = ''
|
||||||
|
try {
|
||||||
|
const res = await getOtherSettings()
|
||||||
|
if (res.code !== 0) { error.value = res.msg || '设置读取失败,请重试'; return }
|
||||||
|
apply(res.data)
|
||||||
|
} catch { error.value = '设置读取失败,请重试' } finally { loading.value = false }
|
||||||
|
}
|
||||||
|
const restoreDefaults = () => {
|
||||||
|
Object.assign(form, defaults.value)
|
||||||
|
loaded.value = true; error.value = ''; status.value = '已恢复默认值,点击「保存」后生效'
|
||||||
|
formRef.value?.clearValidate()
|
||||||
|
}
|
||||||
|
const save = async () => {
|
||||||
|
if (!admin.value || saving.value || loading.value || !loaded.value) return
|
||||||
|
if (!await formRef.value.validate().catch(() => false)) return
|
||||||
|
if (saving.value) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认保存有效端参数?达标金额 ${amountText.value},判定窗口 ${form.days} 天。`, '保存设置', { type: 'warning', confirmButtonText: '确认保存', cancelButtonText: '取消' })
|
||||||
|
} catch { saving.value = false; return }
|
||||||
|
try {
|
||||||
|
const res = await saveOtherSettings({ amount: form.amount, days: form.days })
|
||||||
|
if (res.code !== 0) { error.value = res.msg || '保存失败,请重试'; return }
|
||||||
|
apply(res.data); error.value = ''; status.value = '参数已保存'
|
||||||
|
ElMessage.success('参数已保存')
|
||||||
|
} catch { error.value = '保存失败,请重试' } finally { saving.value = false }
|
||||||
|
}
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.other-settings { max-width: 860px; }
|
||||||
|
.settings-intro { display: block; margin-bottom: 16px; }
|
||||||
|
.settings-heading { margin: 4px 0 24px; }
|
||||||
|
.settings-heading h3 { margin: 0 0 8px; font-size: 17px; }
|
||||||
|
.settings-error { margin-bottom: 16px; }
|
||||||
|
.field-help { margin-left: 16px; }
|
||||||
|
.rule-preview { padding: 16px; border-radius: 6px; background: var(--el-fill-color-light); line-height: 1.9; color: var(--el-text-color-regular); }
|
||||||
|
.rule-preview ol { margin: 8px 0 0; padding-left: 24px; }
|
||||||
|
.settings-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin: 24px 0 4px; }
|
||||||
|
.settings-actions .el-button + .el-button { margin-left: 0; }
|
||||||
|
@media (max-width: 600px) { .field-help { display: block; margin: 8px 0 0; width: 100%; } }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user