mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 23:56:37 +08:00
[功能] 添加批量更新选项接口,支持一次性更新多个配置项,更新相关逻辑和测试
This commit is contained in:
@@ -22,6 +22,10 @@ var (
|
|||||||
openRestyDurationTokenPattern = regexp.MustCompile(`^\d+[smhdwSMHDW]$`)
|
openRestyDurationTokenPattern = regexp.MustCompile(`^\d+[smhdwSMHDW]$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type optionBatchPayload struct {
|
||||||
|
Options []model.Option `json:"options"`
|
||||||
|
}
|
||||||
|
|
||||||
func validateRateLimitOption(key string, value string) error {
|
func validateRateLimitOption(key string, value string) error {
|
||||||
maxDurationSeconds := int(common.RateLimitKeyExpirationDuration.Seconds())
|
maxDurationSeconds := int(common.RateLimitKeyExpirationDuration.Seconds())
|
||||||
|
|
||||||
@@ -193,6 +197,69 @@ func validateOpenRestyOption(key string, value string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildOptionValidationState(options []model.Option) map[string]string {
|
||||||
|
common.OptionMapRWMutex.RLock()
|
||||||
|
state := make(map[string]string, len(common.OptionMap)+len(options))
|
||||||
|
for key, value := range common.OptionMap {
|
||||||
|
state[key] = value
|
||||||
|
}
|
||||||
|
common.OptionMapRWMutex.RUnlock()
|
||||||
|
|
||||||
|
for _, option := range options {
|
||||||
|
state[option.Key] = option.Value
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateOptionWithState(option model.Option, state map[string]string) error {
|
||||||
|
switch option.Key {
|
||||||
|
case "GitHubOAuthEnabled":
|
||||||
|
if option.Value == "true" && strings.TrimSpace(state["GitHubClientId"]) == "" {
|
||||||
|
return fmt.Errorf("鏃犳硶鍚敤 GitHub OAuth锛岃鍏堝~鍏?GitHub Client ID 浠ュ強 GitHub Client Secret锛?")
|
||||||
|
}
|
||||||
|
case "WeChatAuthEnabled":
|
||||||
|
if option.Value == "true" && strings.TrimSpace(state["WeChatServerAddress"]) == "" {
|
||||||
|
return fmt.Errorf("鏃犳硶鍚敤寰俊鐧诲綍锛岃鍏堝~鍏ュ井淇$櫥褰曠浉鍏抽厤缃俊鎭紒")
|
||||||
|
}
|
||||||
|
case "TurnstileCheckEnabled":
|
||||||
|
if option.Value == "true" && strings.TrimSpace(state["TurnstileSiteKey"]) == "" {
|
||||||
|
return fmt.Errorf("鏃犳硶鍚敤 Turnstile 鏍¢獙锛岃鍏堝~鍏?Turnstile 鏍¢獙鐩稿叧閰嶇疆淇℃伅锛?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateRateLimitOption(option.Key, option.Value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateOpenRestyOption(option.Key, option.Value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateGeoIPOption(option.Key, option.Value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateDatabaseCleanupOption(option.Key, option.Value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateOptions(options []model.Option) error {
|
||||||
|
if len(options) == 0 {
|
||||||
|
return fmt.Errorf("鏃犳晥鐨勫弬鏁?")
|
||||||
|
}
|
||||||
|
|
||||||
|
state := buildOptionValidationState(options)
|
||||||
|
for _, option := range options {
|
||||||
|
if strings.TrimSpace(option.Key) == "" {
|
||||||
|
return fmt.Errorf("鏃犳晥鐨勫弬鏁?")
|
||||||
|
}
|
||||||
|
if err := validateOptionWithState(option, state); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.UpdateOptions(options)
|
||||||
|
}
|
||||||
|
|
||||||
// GetOptions godoc
|
// GetOptions godoc
|
||||||
// @Summary List editable options
|
// @Summary List editable options
|
||||||
// @Tags Options
|
// @Tags Options
|
||||||
@@ -307,3 +374,36 @@ func UpdateOption(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateOptionsBatch godoc
|
||||||
|
// @Summary Batch update options
|
||||||
|
// @Tags Options
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param payload body optionBatchPayload true "Batch option payload"
|
||||||
|
// @Success 200 {object} map[string]interface{}
|
||||||
|
// @Failure 400 {object} map[string]interface{}
|
||||||
|
// @Router /api/option/update-batch [post]
|
||||||
|
func UpdateOptionsBatch(c *gin.Context) {
|
||||||
|
var payload optionBatchPayload
|
||||||
|
if err := json.NewDecoder(c.Request.Body).Decode(&payload); err != nil || len(payload.Options) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "鏃犳晥鐨勫弬鏁?",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := updateOptions(payload.Options); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Option struct {
|
type Option struct {
|
||||||
@@ -110,19 +112,38 @@ func InitOptionMap() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func UpdateOption(key string, value string) error {
|
func UpdateOption(key string, value string) error {
|
||||||
// Save to database first
|
return UpdateOptions([]Option{{
|
||||||
option := Option{
|
Key: key,
|
||||||
Key: key,
|
Value: value,
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateOptions(options []Option) error {
|
||||||
|
if len(options) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
for _, item := range options {
|
||||||
|
option := Option{
|
||||||
|
Key: item.Key,
|
||||||
|
}
|
||||||
|
if err := tx.FirstOrCreate(&option, Option{Key: item.Key}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
option.Value = item.Value
|
||||||
|
if err := tx.Save(&option).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range options {
|
||||||
|
updateOptionMap(item.Key, item.Value)
|
||||||
}
|
}
|
||||||
// https://gorm.io/docs/update.html#Save-All-Fields
|
|
||||||
DB.FirstOrCreate(&option, Option{Key: key})
|
|
||||||
option.Value = value
|
|
||||||
// Save is a combination function.
|
|
||||||
// If save value does not contain primary key, it will execute Create,
|
|
||||||
// otherwise it will execute Update (with all fields).
|
|
||||||
DB.Save(&option)
|
|
||||||
// Update OptionMap
|
|
||||||
updateOptionMap(key, value)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
{
|
{
|
||||||
optionRoute.GET("/", controller.GetOptions)
|
optionRoute.GET("/", controller.GetOptions)
|
||||||
optionRoute.POST("/update", controller.UpdateOption)
|
optionRoute.POST("/update", controller.UpdateOption)
|
||||||
|
optionRoute.POST("/update-batch", controller.UpdateOptionsBatch)
|
||||||
optionRoute.POST("/geoip/lookup", controller.LookupGeoIP)
|
optionRoute.POST("/geoip/lookup", controller.LookupGeoIP)
|
||||||
optionRoute.POST("/database/cleanup", controller.CleanupDatabaseObservability)
|
optionRoute.POST("/database/cleanup", controller.CleanupDatabaseObservability)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,21 +41,25 @@ func TestPhase2RateLimitOptionsHotReload(t *testing.T) {
|
|||||||
|
|
||||||
loginCookie := loginAsRoot(t, engine)
|
loginCookie := loginAsRoot(t, engine)
|
||||||
|
|
||||||
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/option/update", map[string]any{
|
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/option/update-batch", map[string]any{
|
||||||
"key": "GlobalApiRateLimitNum",
|
"options": []map[string]any{
|
||||||
"value": "450",
|
{
|
||||||
})
|
"key": "GlobalApiRateLimitNum",
|
||||||
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/option/update", map[string]any{
|
"value": "450",
|
||||||
"key": "GlobalApiRateLimitDuration",
|
},
|
||||||
"value": "240",
|
{
|
||||||
})
|
"key": "GlobalApiRateLimitDuration",
|
||||||
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/option/update", map[string]any{
|
"value": "240",
|
||||||
"key": "CriticalRateLimitNum",
|
},
|
||||||
"value": "150",
|
{
|
||||||
})
|
"key": "CriticalRateLimitNum",
|
||||||
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/option/update", map[string]any{
|
"value": "150",
|
||||||
"key": "CriticalRateLimitDuration",
|
},
|
||||||
"value": "900",
|
{
|
||||||
|
"key": "CriticalRateLimitDuration",
|
||||||
|
"value": "900",
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if common.GlobalApiRateLimitNum != 450 {
|
if common.GlobalApiRateLimitNum != 450 {
|
||||||
@@ -88,6 +92,115 @@ func TestPhase2RateLimitOptionsHotReload(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPhase2BatchOptionUpdateIsAtomic(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
common.RedisEnabled = false
|
||||||
|
setupTestDB(t)
|
||||||
|
model.InitOptionMap()
|
||||||
|
|
||||||
|
oldGlobalAPI := common.GlobalApiRateLimitNum
|
||||||
|
t.Cleanup(func() {
|
||||||
|
common.GlobalApiRateLimitNum = oldGlobalAPI
|
||||||
|
})
|
||||||
|
|
||||||
|
engine := gin.New()
|
||||||
|
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
|
||||||
|
router.SetApiRouter(engine)
|
||||||
|
|
||||||
|
loginCookie := loginAsRoot(t, engine)
|
||||||
|
|
||||||
|
payload, err := json.Marshal(map[string]any{
|
||||||
|
"options": []map[string]any{
|
||||||
|
{
|
||||||
|
"key": "GlobalApiRateLimitNum",
|
||||||
|
"value": "451",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "CriticalRateLimitDuration",
|
||||||
|
"value": "1800",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to marshal batch payload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/option/update-batch", bytes.NewReader(payload))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.AddCookie(loginCookie)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
engine.ServeHTTP(recorder, req)
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("unexpected status %d: %s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp apiResponse
|
||||||
|
if err = json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Success {
|
||||||
|
t.Fatal("expected invalid batch update to fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
if common.GlobalApiRateLimitNum != oldGlobalAPI {
|
||||||
|
t.Fatalf("expected GlobalApiRateLimitNum to remain %d after failed batch, got %d", oldGlobalAPI, common.GlobalApiRateLimitNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = performSessionJSONRequest(t, engine, loginCookie, http.MethodGet, "/api/option/", nil)
|
||||||
|
var options []model.Option
|
||||||
|
decodeResponseData(t, resp, &options)
|
||||||
|
|
||||||
|
optionMap := make(map[string]string, len(options))
|
||||||
|
for _, option := range options {
|
||||||
|
optionMap[option.Key] = option.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
if optionMap["GlobalApiRateLimitNum"] == "451" {
|
||||||
|
t.Fatal("expected failed batch update to avoid persisting partial values")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhase2BatchOptionUpdateValidatesMergedState(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
common.RedisEnabled = false
|
||||||
|
setupTestDB(t)
|
||||||
|
model.InitOptionMap()
|
||||||
|
|
||||||
|
oldGitHubClientID := common.GitHubClientId
|
||||||
|
oldGitHubOAuthEnabled := common.GitHubOAuthEnabled
|
||||||
|
t.Cleanup(func() {
|
||||||
|
common.GitHubClientId = oldGitHubClientID
|
||||||
|
common.GitHubOAuthEnabled = oldGitHubOAuthEnabled
|
||||||
|
})
|
||||||
|
|
||||||
|
engine := gin.New()
|
||||||
|
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
|
||||||
|
router.SetApiRouter(engine)
|
||||||
|
|
||||||
|
loginCookie := loginAsRoot(t, engine)
|
||||||
|
|
||||||
|
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/option/update-batch", map[string]any{
|
||||||
|
"options": []map[string]any{
|
||||||
|
{
|
||||||
|
"key": "GitHubClientId",
|
||||||
|
"value": "client-id-from-batch",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "GitHubOAuthEnabled",
|
||||||
|
"value": "true",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if common.GitHubClientId != "client-id-from-batch" {
|
||||||
|
t.Fatalf("expected GitHubClientId to be updated from batch, got %q", common.GitHubClientId)
|
||||||
|
}
|
||||||
|
if !common.GitHubOAuthEnabled {
|
||||||
|
t.Fatal("expected GitHubOAuthEnabled to be enabled by merged batch state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func loginAsRoot(t *testing.T, engine http.Handler) *http.Cookie {
|
func loginAsRoot(t *testing.T, engine http.Handler) *http.Cookie {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
payload, err := json.Marshal(map[string]any{
|
payload, err := json.Marshal(map[string]any{
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {useAuth} from '@/components/providers/auth-provider';
|
|||||||
import {AppCard} from '@/components/ui/app-card';
|
import {AppCard} from '@/components/ui/app-card';
|
||||||
import {StatusBadge} from '@/components/ui/status-badge';
|
import {StatusBadge} from '@/components/ui/status-badge';
|
||||||
import {getConfigVersionPreview} from '@/features/config-versions/api/config-versions';
|
import {getConfigVersionPreview} from '@/features/config-versions/api/config-versions';
|
||||||
import {getOptions, updateOption} from '@/features/settings/api/settings';
|
import {getOptions, updateOptions} from '@/features/settings/api/settings';
|
||||||
import type {OptionItem} from '@/features/settings/types';
|
import type {OptionItem} from '@/features/settings/types';
|
||||||
import {
|
import {
|
||||||
CodeBlock,
|
CodeBlock,
|
||||||
@@ -277,9 +277,7 @@ export function PerformancePage() {
|
|||||||
entries: Array<[string, string]>,
|
entries: Array<[string, string]>,
|
||||||
successMessage: string,
|
successMessage: string,
|
||||||
) => {
|
) => {
|
||||||
for (const [key, value] of entries) {
|
await updateOptions(entries.map(([key, value]) => ({key, value})));
|
||||||
await updateOption(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({queryKey: settingsQueryKey}),
|
queryClient.invalidateQueries({queryKey: settingsQueryKey}),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
DatabaseCleanupPayload,
|
DatabaseCleanupPayload,
|
||||||
DatabaseCleanupResult,
|
DatabaseCleanupResult,
|
||||||
GeoIPLookupResult,
|
GeoIPLookupResult,
|
||||||
|
OptionBatchPayload,
|
||||||
OptionItem,
|
OptionItem,
|
||||||
SettingsProfile,
|
SettingsProfile,
|
||||||
UpdateSelfPayload,
|
UpdateSelfPayload,
|
||||||
@@ -21,6 +22,13 @@ export function updateOption(key: string, value: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateOptions(options: OptionBatchPayload['options']) {
|
||||||
|
return apiRequest<void>('/option/update-batch', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ options }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function lookupGeoIP(provider: string, ip: string) {
|
export function lookupGeoIP(provider: string, ip: string) {
|
||||||
return apiRequest<GeoIPLookupResult>('/option/geoip/lookup', {
|
return apiRequest<GeoIPLookupResult>('/option/geoip/lookup', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
getSettingsProfile,
|
getSettingsProfile,
|
||||||
lookupGeoIP,
|
lookupGeoIP,
|
||||||
rotateBootstrapToken,
|
rotateBootstrapToken,
|
||||||
updateOption,
|
updateOptions,
|
||||||
updateSelf,
|
updateSelf,
|
||||||
} from '@/features/settings/api/settings';
|
} from '@/features/settings/api/settings';
|
||||||
import type {
|
import type {
|
||||||
@@ -558,9 +558,7 @@ export function SettingsPage() {
|
|||||||
entries: Array<[string, string]>,
|
entries: Array<[string, string]>,
|
||||||
successMessage: string,
|
successMessage: string,
|
||||||
) => {
|
) => {
|
||||||
for (const [key, value] of entries) {
|
await updateOptions(entries.map(([key, value]) => ({ key, value })));
|
||||||
await updateOption(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
await queryClient.invalidateQueries({ queryKey: settingsQueryKey });
|
await queryClient.invalidateQueries({ queryKey: settingsQueryKey });
|
||||||
await queryClient.invalidateQueries({ queryKey: ['public-status'] });
|
await queryClient.invalidateQueries({ queryKey: ['public-status'] });
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ export interface OptionItem {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OptionBatchPayload {
|
||||||
|
options: OptionItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface BootstrapTokenPayload {
|
export interface BootstrapTokenPayload {
|
||||||
discovery_token: string;
|
discovery_token: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user