From 9d4a595465d505aa1f47d7e8d9412b959208d5e0 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Thu, 9 Apr 2026 18:28:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(region-sync):=20Master=E2=86=92Slave=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=90=8C=E6=AD=A5=E3=80=81=E7=83=AD=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E4=B8=8E=E5=89=8D=E7=AB=AF=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 GET /api/internal/sync/config 接口,Master 下发 MinBalanceThreshold、MaxRetryCount、SyncBatchSize、SyncIntervalSeconds - Slave 每 5 分钟从 Master 拉取配置并持久化到本地数据库,确保重启不丢失 - 统一 startWorker 支持动态间隔,同步间隔前端修改后无需重启立即生效 - 主节点地址从 TagInput 改为 Form.Input,修复 JSON 数组格式问题 - stringToArray 兼容 JSON 数组和换行分隔两种格式 - 清理根目录测试脚本(已迁移到 test-scripts/) Co-Authored-By: Claude --- controller/region_sync.go | 14 + router/api-router.go | 1 + service/region_sync/sync_client.go | 12 + service/region_sync/sync_manager.go | 84 ++- service/region_sync/sync_manager_test.go | 2 + service/region_sync/sync_types.go | 10 + test_channel_pricing.sh | 182 ------ test_region_sync.sh | 153 ----- test_region_sync_concurrent.sh | 156 ----- test_region_sync_e2e.sh | 533 ------------------ .../Setting/Operation/SettingsRegionSync.jsx | 44 +- 11 files changed, 126 insertions(+), 1065 deletions(-) delete mode 100644 test_channel_pricing.sh delete mode 100644 test_region_sync.sh delete mode 100644 test_region_sync_concurrent.sh delete mode 100644 test_region_sync_e2e.sh diff --git a/controller/region_sync.go b/controller/region_sync.go index 14d93e4..80bd904 100644 --- a/controller/region_sync.go +++ b/controller/region_sync.go @@ -8,6 +8,7 @@ import ( "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service/region_sync" + "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -271,3 +272,16 @@ func recordSyncLog(c *gin.Context, userId, remoteUserId int, syncType, requestId logger.LogError(c.Request.Context(), "failed to create sync log: "+err.Error()) } } + + +// GetSyncConfig Master 节点返回同步配置给 Slave +func GetSyncConfig(c *gin.Context) { + settings := system_setting.GetRegionSyncSettings() + c.JSON(http.StatusOK, region_sync.SyncConfigResponse{ + Success: true, + MinBalanceThreshold: settings.MinBalanceThreshold, + MaxRetryCount: settings.MaxRetryCount, + SyncBatchSize: settings.SyncBatchSize, + SyncIntervalSeconds: settings.SyncIntervalSeconds, + }) +} diff --git a/router/api-router.go b/router/api-router.go index 80421dc..5b80d5c 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -397,6 +397,7 @@ func SetApiRouter(router *gin.Engine) { syncRoute.POST("/quota/update", controller.ReceiveQuotaUpdate) syncRoute.POST("/quota/query", controller.QueryUserQuota) syncRoute.POST("/quota/batch-deduct", controller.BatchDeductQuota) + syncRoute.GET("/config", controller.GetSyncConfig) } } } diff --git a/service/region_sync/sync_client.go b/service/region_sync/sync_client.go index aeb9ddc..8af24ce 100644 --- a/service/region_sync/sync_client.go +++ b/service/region_sync/sync_client.go @@ -130,3 +130,15 @@ func (c *SyncClient) UpdateQuota(remoteUserId, quota int) (*UpdateQuotaResponse, } return &resp, nil } + +func (c *SyncClient) FetchConfig() (*SyncConfigResponse, error) { + data, err := c.doRequest("GET", "/api/internal/sync/config", nil) + if err != nil { + return nil, err + } + var resp SyncConfigResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("unmarshal response: %w", err) + } + return &resp, nil +} diff --git a/service/region_sync/sync_manager.go b/service/region_sync/sync_manager.go index 623daeb..b2e6ae9 100644 --- a/service/region_sync/sync_manager.go +++ b/service/region_sync/sync_manager.go @@ -15,10 +15,11 @@ import ( // SyncManager 管理跨区域同步的后台任务 type SyncManager struct { - client *SyncClient - stopped atomic.Bool - lastEndpoint string - lastApiKey string + client *SyncClient + stopped atomic.Bool + lastEndpoint string + lastApiKey string + lastConfigFetch atomic.Int64 // unix timestamp of last config fetch } var ( @@ -59,6 +60,17 @@ func (m *SyncManager) RunBatchSync() int { return 0 } + // Slave 节点每 5 分钟从 Master 拉取最新配置并持久化到本地数据库 + if !settings.IsMaster && m.client != nil { + now := time.Now().Unix() + if now-m.lastConfigFetch.Load() >= 300 { + m.lastConfigFetch.Store(now) + if config, err := m.client.FetchConfig(); err == nil && config.Success { + applyRemoteConfig(config) + } + } + } + records := model.GetPendingRecordsForSync(settings.SyncBatchSize, settings.MaxRetryCount) if len(records) == 0 { return 0 @@ -181,18 +193,20 @@ func (m *SyncManager) StartSyncWorkers() { return } - interval := time.Duration(settings.SyncIntervalSeconds) * time.Second - - startWorker := func(tickInterval time.Duration, fn func() int, label string) { + // startWorker 启动后台工作器,支持动态间隔(前端热更新) + startWorker := func(getInterval func() time.Duration, fn func() int, label string) { gopool.Go(func() { - ticker := time.NewTicker(tickInterval) - defer ticker.Stop() - + var lastInterval time.Duration for { if m.stopped.Load() { return } - <-ticker.C + interval := getInterval() + if lastInterval > 0 && interval != lastInterval { + common.SysLog(fmt.Sprintf("[SyncManager] %s interval changed: %s -> %s", label, lastInterval, interval)) + } + lastInterval = interval + time.Sleep(interval) if m.stopped.Load() { return } @@ -203,12 +217,20 @@ func (m *SyncManager) StartSyncWorkers() { }) } - startWorker(interval, m.RunBatchSync, "batch sync completed") - quotaSyncInterval := time.Duration(settings.QuotaSyncIntervalSeconds) * time.Second - startWorker(quotaSyncInterval, m.RunQuotaSync, "quota sync completed") - startWorker(time.Hour, m.RunCleanup, "cleanup completed") + startWorker(func() time.Duration { + s := system_setting.GetRegionSyncSettings() + return time.Duration(s.SyncIntervalSeconds) * time.Second + }, m.RunBatchSync, "batch sync completed") + + startWorker(func() time.Duration { + s := system_setting.GetRegionSyncSettings() + return time.Duration(s.QuotaSyncIntervalSeconds) * time.Second + }, m.RunQuotaSync, "quota sync completed") + + startWorker(func() time.Duration { return time.Hour }, m.RunCleanup, "cleanup completed") + // 清理超时的 pending 记录(请求崩溃的情况,5分钟超时) - startWorker(5*time.Minute, func() int { + startWorker(func() time.Duration { return 5 * time.Minute }, func() int { return int(model.ArchiveStalePendingRecords(300)) }, "stale pending records archived") } @@ -217,3 +239,33 @@ func (m *SyncManager) StartSyncWorkers() { func (m *SyncManager) Stop() { m.stopped.Store(true) } + +// applyRemoteConfig 将 Master 下发的配置更新到内存并持久化到本地数据库 +func applyRemoteConfig(config *SyncConfigResponse) { + s := system_setting.GetRegionSyncSettings() + + type fieldUpdate struct { + key string + old int + new int + field *int + } + + updates := []fieldUpdate{ + {"region_sync.min_balance_threshold", s.MinBalanceThreshold, config.MinBalanceThreshold, &s.MinBalanceThreshold}, + {"region_sync.max_retry_count", s.MaxRetryCount, config.MaxRetryCount, &s.MaxRetryCount}, + {"region_sync.sync_batch_size", s.SyncBatchSize, config.SyncBatchSize, &s.SyncBatchSize}, + {"region_sync.sync_interval_seconds", s.SyncIntervalSeconds, config.SyncIntervalSeconds, &s.SyncIntervalSeconds}, + } + + for _, u := range updates { + if u.old != u.new { + *u.field = u.new + if err := model.UpdateOption(u.key, fmt.Sprintf("%d", u.new)); err != nil { + common.SysError(fmt.Sprintf("[SyncManager] failed to persist remote config %s: %v", u.key, err)) + } else { + common.SysLog(fmt.Sprintf("[SyncManager] config synced from master: %s %d -> %d", u.key, u.old, u.new)) + } + } + } +} diff --git a/service/region_sync/sync_manager_test.go b/service/region_sync/sync_manager_test.go index 4c5c55c..b2b9ee3 100644 --- a/service/region_sync/sync_manager_test.go +++ b/service/region_sync/sync_manager_test.go @@ -198,6 +198,7 @@ func TestRunBatchSync_WithMockServer(t *testing.T) { }() settings.Enabled = true + settings.IsMaster = true // 跳过 FetchConfig settings.SyncBatchSize = 100 settings.MaxRetryCount = 3 @@ -310,6 +311,7 @@ func TestRunBatchSync_RetryExhaustion(t *testing.T) { settings.MaxRetryCount = origMaxRetry }() settings.Enabled = true + settings.IsMaster = true settings.SyncBatchSize = 100 settings.MaxRetryCount = 3 diff --git a/service/region_sync/sync_types.go b/service/region_sync/sync_types.go index ba094b3..d60a13b 100644 --- a/service/region_sync/sync_types.go +++ b/service/region_sync/sync_types.go @@ -71,3 +71,13 @@ type UpdateQuotaResponse struct { Success bool `json:"success"` Error string `json:"error,omitempty"` } + +// SyncConfigResponse Master 下发给 Slave 的配置 +type SyncConfigResponse struct { + Success bool `json:"success"` + MinBalanceThreshold int `json:"min_balance_threshold"` + MaxRetryCount int `json:"max_retry_count"` + SyncBatchSize int `json:"sync_batch_size"` + SyncIntervalSeconds int `json:"sync_interval_seconds"` + Error string `json:"error,omitempty"` +} diff --git a/test_channel_pricing.sh b/test_channel_pricing.sh deleted file mode 100644 index 0bf80ee..0000000 --- a/test_channel_pricing.sh +++ /dev/null @@ -1,182 +0,0 @@ -#!/bin/bash - -# 渠道定价功能端到端测试脚本 -# 使用方法: ./test_channel_pricing.sh - -BASE_URL="http://localhost:3000" -ADMIN_TOKEN="${1:-}" - -if [ -z "$ADMIN_TOKEN" ]; then - echo "错误: 请提供管理员 token" - echo "使用方法: ./test_channel_pricing.sh " - exit 1 -fi - -# 颜色输出 -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -pass_count=0 -fail_count=0 - -check_success() { - local result="$1" - local test_name="$2" - if echo "$result" | grep -q '"success":true'; then - echo -e "${GREEN}✓ $test_name${NC}" - ((pass_count++)) - return 0 - else - echo -e "${RED}✗ $test_name${NC}" - echo " 响应: $result" - ((fail_count++)) - return 1 - fi -} - -check_failure() { - local result="$1" - local test_name="$2" - if echo "$result" | grep -q '"success":false'; then - echo -e "${GREEN}✓ $test_name (预期失败)${NC}" - ((pass_count++)) - return 0 - else - echo -e "${RED}✗ $test_name (应该失败但成功了)${NC}" - ((fail_count++)) - return 1 - fi -} - -echo "======================================" -echo " 渠道定价功能端到端测试" -echo "======================================" -echo "" - -# ========== 模块 1: 定价标签 ========== -echo -e "${YELLOW}【模块 1: 定价标签 API】${NC}" - -echo "" -echo "TC-1.1 创建定价标签..." -RESULT=$(curl -s -X POST "$BASE_URL/api/pricing_tag/" \ - -H "Authorization: Bearer $ADMIN_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"name":"E2E测试标签","color":"#52c41a","description":"端到端测试","sort_order":1}') -check_success "$RESULT" "创建定价标签" -TAG_ID=$(echo "$RESULT" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*') - -echo "" -echo "TC-1.2 获取所有标签..." -RESULT=$(curl -s "$BASE_URL/api/pricing_tag/" -H "Authorization: Bearer $ADMIN_TOKEN") -check_success "$RESULT" "获取所有标签" - -echo "" -echo "TC-1.3 创建重复名称标签 (应失败)..." -RESULT=$(curl -s -X POST "$BASE_URL/api/pricing_tag/" \ - -H "Authorization: Bearer $ADMIN_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"name":"E2E测试标签","color":"#ff0000"}') -check_failure "$RESULT" "创建重复名称标签" - -if [ -n "$TAG_ID" ]; then - echo "" - echo "TC-1.4 更新标签..." - RESULT=$(curl -s -X PUT "$BASE_URL/api/pricing_tag/$TAG_ID" \ - -H "Authorization: Bearer $ADMIN_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"name":"E2E测试标签-更新","color":"#1890ff","description":"更新后"}') - check_success "$RESULT" "更新标签" -fi - -# ========== 模块 2: 渠道定价 ========== -echo "" -echo -e "${YELLOW}【模块 2: 渠道定价 API】${NC}" - -echo "" -echo "TC-2.1 创建渠道定价 (按量计费)..." -RESULT=$(curl -s -X POST "$BASE_URL/api/channel_pricing/" \ - -H "Authorization: Bearer $ADMIN_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"model_name":"e2e-test-model","channel_id":1,"quota_type":0,"model_ratio":10,"completion_ratio":1.5,"model_price":0}') -check_success "$RESULT" "创建渠道定价(按量)" -PRICING_ID=$(echo "$RESULT" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*') - -echo "" -echo "TC-2.2 创建渠道定价 (按次计费)..." -RESULT=$(curl -s -X POST "$BASE_URL/api/channel_pricing/" \ - -H "Authorization: Bearer $ADMIN_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"model_name":"e2e-test-model-call","channel_id":1,"quota_type":1,"model_ratio":0,"completion_ratio":0,"model_price":0.05}') -check_success "$RESULT" "创建渠道定价(按次)" - -echo "" -echo "TC-2.3 获取所有渠道定价..." -RESULT=$(curl -s "$BASE_URL/api/channel_pricing/?p=1&page_size=10" -H "Authorization: Bearer $ADMIN_TOKEN") -check_success "$RESULT" "获取所有渠道定价" - -echo "" -echo "TC-2.4 获取指定模型的渠道定价..." -RESULT=$(curl -s "$BASE_URL/api/channel_pricing/model/e2e-test-model" -H "Authorization: Bearer $ADMIN_TOKEN") -check_success "$RESULT" "获取指定模型的渠道定价" - -echo "" -echo "TC-2.5 获取带标签的渠道定价..." -RESULT=$(curl -s "$BASE_URL/api/channel_pricing/with_tags?p=1&page_size=10" -H "Authorization: Bearer $ADMIN_TOKEN") -check_success "$RESULT" "获取带标签的渠道定价" - -echo "" -echo "TC-2.6 批量创建渠道定价..." -RESULT=$(curl -s -X POST "$BASE_URL/api/channel_pricing/batch" \ - -H "Authorization: Bearer $ADMIN_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"items":[{"model_name":"batch-model-1","channel_id":1,"quota_type":0,"model_ratio":5},{"model_name":"batch-model-2","channel_id":1,"quota_type":0,"model_ratio":8}]}') -check_success "$RESULT" "批量创建渠道定价" - -# ========== 模块 3: 清理测试数据 ========== -echo "" -echo -e "${YELLOW}【清理测试数据】${NC}" - -if [ -n "$TAG_ID" ]; then - echo "" - echo "删除测试标签..." - RESULT=$(curl -s -X DELETE "$BASE_URL/api/pricing_tag/$TAG_ID" -H "Authorization: Bearer $ADMIN_TOKEN") - check_success "$RESULT" "删除测试标签" -fi - -if [ -n "$PRICING_ID" ]; then - echo "" - echo "删除测试渠道定价..." - RESULT=$(curl -s -X DELETE "$BASE_URL/api/channel_pricing/$PRICING_ID" -H "Authorization: Bearer $ADMIN_TOKEN") - check_success "$RESULT" "删除测试渠道定价" -fi - -# 删除其他测试数据 -echo "" -echo "清理批量创建的测试数据..." -for model in "batch-model-1" "batch-model-2" "e2e-test-model-call"; do - RESULT=$(curl -s "$BASE_URL/api/channel_pricing/model/$model" -H "Authorization: Bearer $ADMIN_TOKEN") - IDS=$(echo "$RESULT" | grep -o '"id":[0-9]*' | grep -o '[0-9]*') - for id in $IDS; do - curl -s -X DELETE "$BASE_URL/api/channel_pricing/$id" -H "Authorization: Bearer $ADMIN_TOKEN" > /dev/null - done -done -echo -e "${GREEN}✓ 清理完成${NC}" - -# ========== 测试总结 ========== -echo "" -echo "======================================" -echo " 测试结果汇总" -echo "======================================" -echo -e "通过: ${GREEN}$pass_count${NC}" -echo -e "失败: ${RED}$fail_count${NC}" -echo "" - -if [ $fail_count -eq 0 ]; then - echo -e "${GREEN}所有测试通过! ✓${NC}" - exit 0 -else - echo -e "${RED}有 $fail_count 个测试失败${NC}" - exit 1 -fi diff --git a/test_region_sync.sh b/test_region_sync.sh deleted file mode 100644 index 511c711..0000000 --- a/test_region_sync.sh +++ /dev/null @@ -1,153 +0,0 @@ -#!/bin/bash - -# Master-Slave 余额同步测试脚本 -# 用法: ./test_region_sync.sh [USER_ID] -# 示例: ./test_region_sync.sh 1 - -MASTER="http://172.22.57.85:23000" -SLAVE="http://172.22.57.85:13000" -API_KEY="123456789" -USER_ID=${1:-1} # 默认用户ID为1,可通过参数传入 - -echo "==========================================" -echo " Master-Slave 余额同步测试" -echo "==========================================" -echo "Master: $MASTER" -echo "Slave: $SLAVE" -echo "API Key: $API_KEY" -echo "测试用户ID: $USER_ID" -echo "==========================================" -echo "" - -# 测试7:完整消费流程测试 -echo "=== 测试7: 完整消费流程测试 ===" -echo "" - -echo "[步骤1] 查询Master当前余额..." -RESULT=$(curl -s -X POST "$MASTER/api/internal/sync/quota/query" \ - -H "Content-Type: application/json" \ - -H "X-Sync-API-Key: $API_KEY" \ - -H "X-Sync-Node: $SLAVE" \ - -d "{\"user_id\": $USER_ID}") -echo "结果: $RESULT" -BEFORE_QUOTA=$(echo $RESULT | grep -o '"quota":[0-9]*' | grep -o '[0-9]*') -echo "当前余额: $BEFORE_QUOTA" -echo "" - -echo "[步骤2] 执行扣费500..." -RESULT=$(curl -s -X POST "$MASTER/api/internal/sync/quota/batch-deduct" \ - -H "Content-Type: application/json" \ - -H "X-Sync-API-Key: $API_KEY" \ - -H "X-Sync-Node: $SLAVE" \ - -d "{\"records\": [{\"user_id\": $USER_ID, \"request_id\": \"req-test-$(date +%s)\", \"quota\": 500}]}") -echo "结果: $RESULT" -echo "" - -echo "[步骤3] 再次查询Master余额验证扣费成功..." -RESULT=$(curl -s -X POST "$MASTER/api/internal/sync/quota/query" \ - -H "Content-Type: application/json" \ - -H "X-Sync-API-Key: $API_KEY" \ - -H "X-Sync-Node: $SLAVE" \ - -d "{\"user_id\": $USER_ID}") -echo "结果: $RESULT" -AFTER_QUOTA=$(echo $RESULT | grep -o '"quota":[0-9]*' | grep -o '[0-9]*') -echo "扣费后余额: $AFTER_QUOTA" - -if [ "$BEFORE_QUOTA" != "" ] && [ "$AFTER_QUOTA" != "" ]; then - DIFF=$((BEFORE_QUOTA - AFTER_QUOTA)) - if [ "$DIFF" -eq 500 ]; then - echo "✅ 测试7通过: 余额正确扣除了500" - else - echo "❌ 测试7失败: 余额变化为$DIFF,期望为500" - fi -fi -echo "" - -# 测试8:并发扣费测试 -echo "=== 测试8: 并发扣费测试 ===" -echo "" -echo "[步骤1] 查询当前余额..." -RESULT=$(curl -s -X POST "$MASTER/api/internal/sync/quota/query" \ - -H "Content-Type: application/json" \ - -H "X-Sync-API-Key: $API_KEY" \ - -H "X-Sync-Node: $SLAVE" \ - -d "{\"user_id\": $USER_ID}") -BEFORE_QUOTA=$(echo $RESULT | grep -o '"quota":[0-9]*' | grep -o '[0-9]*') -echo "当前余额: $BEFORE_QUOTA" -echo "" - -echo "[步骤2] 并发发送10个扣费请求(每个100)..." -for i in {1..10}; do - curl -s -X POST "$MASTER/api/internal/sync/quota/batch-deduct" \ - -H "Content-Type: application/json" \ - -H "X-Sync-API-Key: $API_KEY" \ - -H "X-Sync-Node: $SLAVE" \ - -d "{\"records\": [{\"user_id\": $USER_ID, \"request_id\": \"req-concurrent-$i-$(date +%s%N)\", \"quota\": 100}]}" > /dev/null & -done -wait -echo "并发请求发送完成" -echo "" - -echo "[步骤3] 查询最终余额..." -sleep 1 -RESULT=$(curl -s -X POST "$MASTER/api/internal/sync/quota/query" \ - -H "Content-Type: application/json" \ - -H "X-Sync-API-Key: $API_KEY" \ - -H "X-Sync-Node: $SLAVE" \ - -d "{\"user_id\": $USER_ID}") -AFTER_QUOTA=$(echo $RESULT | grep -o '"quota":[0-9]*' | grep -o '[0-9]*') -echo "最终余额: $AFTER_QUOTA" - -if [ "$AFTER_QUOTA" != "" ] && [ "$AFTER_QUOTA" -ge 0 ]; then - echo "✅ 测试8通过: 余额不为负数" -else - echo "❌ 测试8失败: 余额为负数或查询失败" -fi -echo "" - -# 测试10:幂等性测试 -echo "=== 测试10: 重复请求幂等性测试 ===" -echo "" -REQUEST_ID="req-idempotent-$(date +%s)" - -echo "[步骤1] 发送扣费请求 request_id=$REQUEST_ID..." -RESULT=$(curl -s -X POST "$MASTER/api/internal/sync/quota/batch-deduct" \ - -H "Content-Type: application/json" \ - -H "X-Sync-API-Key: $API_KEY" \ - -H "X-Sync-Node: $SLAVE" \ - -d "{\"records\": [{\"user_id\": $USER_ID, \"request_id\": \"$REQUEST_ID\", \"quota\": 100}]}") -echo "结果: $RESULT" -AFTER_FIRST=$(curl -s -X POST "$MASTER/api/internal/sync/quota/query" \ - -H "Content-Type: application/json" \ - -H "X-Sync-API-Key: $API_KEY" \ - -H "X-Sync-Node: $SLAVE" \ - -d "{\"user_id\": $USER_ID}" | grep -o '"quota":[0-9]*' | grep -o '[0-9]*') -echo "第一次扣费后余额: $AFTER_FIRST" -echo "" - -echo "[步骤2] 再次发送相同request_id的请求..." -RESULT=$(curl -s -X POST "$MASTER/api/internal/sync/quota/batch-deduct" \ - -H "Content-Type: application/json" \ - -H "X-Sync-API-Key: $API_KEY" \ - -H "X-Sync-Node: $SLAVE" \ - -d "{\"records\": [{\"user_id\": $USER_ID, \"request_id\": \"$REQUEST_ID\", \"quota\": 100}]}") -echo "结果: $RESULT" -AFTER_SECOND=$(curl -s -X POST "$MASTER/api/internal/sync/quota/query" \ - -H "Content-Type: application/json" \ - -H "X-Sync-API-Key: $API_KEY" \ - -H "X-Sync-Node: $SLAVE" \ - -d "{\"user_id\": $USER_ID}" | grep -o '"quota":[0-9]*' | grep -o '[0-9]*') -echo "第二次扣费后余额: $AFTER_SECOND" -echo "" - -if [ "$AFTER_FIRST" == "$AFTER_SECOND" ]; then - echo "✅ 测试10通过: 重复请求没有重复扣费" -else - echo "⚠️ 测试10警告: 余额发生了变化,当前实现可能不支持幂等性" - echo " (第一次: $AFTER_FIRST, 第二次: $AFTER_SECOND)" -fi -echo "" - -echo "==========================================" -echo " 测试完成" -echo "==========================================" diff --git a/test_region_sync_concurrent.sh b/test_region_sync_concurrent.sh deleted file mode 100644 index ef63c85..0000000 --- a/test_region_sync_concurrent.sh +++ /dev/null @@ -1,156 +0,0 @@ -#!/bin/bash - -# Master + Slave 并发扣费测试脚本 -# 测试场景:Master本地扣费 和 Slave同步扣费 同时对同一用户扣费 -# 用法: ./test_region_sync_concurrent.sh [USER_ID] - -MASTER="http://172.22.57.85:23000" -SLAVE="http://172.22.57.85:13000" -API_KEY="123456789" -USER_ID=${1:-1} - -echo "==========================================" -echo " Master/Slave 并发扣费冲突测试" -echo "==========================================" -echo "Master: $MASTER" -echo "Slave: $SLAVE" -echo "测试用户ID: $USER_ID" -echo "==========================================" -echo "" - -# 先查询当前余额 -echo "[1] 查询初始余额..." -INITIAL=$(curl -s -X POST "$MASTER/api/internal/sync/quota/query" \ - -H "Content-Type: application/json" \ - -H "X-Sync-API-Key: $API_KEY" \ - -H "X-Sync-Node: $SLAVE" \ - -d "{\"user_id\": $USER_ID}") -INITIAL_QUOTA=$(echo $INITIAL | grep -o '"quota":[0-9]*' | grep -o '[0-9]*') -echo "初始余额: $INITIAL_QUOTA" -echo "" - -# 定义扣费金额 -MASTER_DEDUCT=1000 # Master端扣费 1000 -SLAVE_DEDUCT=1000 # Slave端扣费 1000 -EXPECTED_FINAL=$((INITIAL_QUOTA - MASTER_DEDUCT - SLAVE_DEDUCT)) - -echo "[2] 并发执行 Master本地扣费($MASTER_DEDUCT) + Slave同步扣费($SLAVE_DEDUCT)..." -echo " 期望最终余额: $INITIAL_QUOTA - $MASTER_DEDUCT - $SLAVE_DEDUCT = $EXPECTED_FINAL" -echo "" - -# 并发发送两个请求 -# 请求1: 模拟 Slave 同步扣费(调用 Master 的 batch-deduct 接口) -curl -s -X POST "$MASTER/api/internal/sync/quota/batch-deduct" \ - -H "Content-Type: application/json" \ - -H "X-Sync-API-Key: $API_KEY" \ - -H "X-Sync-Node: $SLAVE" \ - -d "{\"records\": [{\"user_id\": $USER_ID, \"request_id\": \"req-slave-concurrent-$(date +%s)\", \"quota\": $SLAVE_DEDUCT}]}" > /tmp/slave_result.json & - -# 请求2: 模拟 Master 本地扣费(通过正常API调用,这里用 DecreaseUserQuota 对应的管理接口) -# 直接用 batch-deduct 的不同 request_id 模拟第二个并发扣费 -curl -s -X POST "$MASTER/api/internal/sync/quota/batch-deduct" \ - -H "Content-Type: application/json" \ - -H "X-Sync-API-Key: $API_KEY" \ - -H "X-Sync-Node: $SLAVE" \ - -d "{\"records\": [{\"user_id\": $USER_ID, \"request_id\": \"req-master-concurrent-$(date +%s)\", \"quota\": $MASTER_DEDUCT}]}" > /tmp/master_result.json & - -wait -echo "Slave扣费结果: $(cat /tmp/slave_result.json)" -echo "Master扣费结果: $(cat /tmp/master_result.json)" -echo "" - -# 查询最终余额 -echo "[3] 查询最终余额..." -FINAL=$(curl -s -X POST "$MASTER/api/internal/sync/quota/query" \ - -H "Content-Type: application/json" \ - -H "X-Sync-API-Key: $API_KEY" \ - -H "X-Sync-Node: $SLAVE" \ - -d "{\"user_id\": $USER_ID}") -FINAL_QUOTA=$(echo $FINAL | grep -o '"quota":[0-9]*' | grep -o '[0-9]*') -echo "最终余额: $FINAL_QUOTA" -echo "" - -# 分析结果 -echo "==========================================" -echo " 结果分析" -echo "==========================================" -echo "初始余额: $INITIAL_QUOTA" -echo "Master扣费: -$MASTER_DEDUCT" -echo "Slave扣费: -$SLAVE_DEDUCT" -echo "期望最终余额: $EXPECTED_FINAL" -echo "实际最终余额: $FINAL_QUOTA" -echo "" - -ACTUAL_DEDUCTED=$((INITIAL_QUOTA - FINAL_QUOTA)) -echo "实际总共扣除: $ACTUAL_DEDUCTED" -echo "期望总共扣除: $((MASTER_DEDUCT + SLAVE_DEDUCT))" -echo "" - -if [ "$FINAL_QUOTA" -eq "$EXPECTED_FINAL" ]; then - echo "✅ 测试通过: 余额变化正确,并发扣费无丢失" -elif [ "$ACTUAL_DEDUCTED" -lt "$((MASTER_DEDUCT + SLAVE_DEDUCT))" ]; then - LOST=$((MASTER_DEDUCT + SLAVE_DEDUCT - ACTUAL_DEDUCTED)) - echo "❌ 测试失败: 扣费丢失 $LOST,并发扣费存在覆盖问题" - echo " 可能原因: BatchDeductQuota 使用 FOR UPDATE + 设定新值," - echo " 而 DecreaseUserQuota 使用 gorm.Expr 原子减,两者混用会互相覆盖" -else - echo "❌ 测试失败: 余额异常(扣多了或变成负数)" -fi -echo "" - -# 高并发版本: 多次并发测试 -echo "==========================================" -echo " 高并发压力测试 (10轮)" -echo "==========================================" -echo "" - -echo "[4] 查询当前余额..." -BEFORE=$(curl -s -X POST "$MASTER/api/internal/sync/quota/query" \ - -H "Content-Type: application/json" \ - -H "X-Sync-API-Key: $API_KEY" \ - -H "X-Sync-Node: $SLAVE" \ - -d "{\"user_id\": $USER_ID}") -BEFORE_QUOTA=$(echo $BEFORE | grep -o '"quota":[0-9]*' | grep -o '[0-9]*') -echo "当前余额: $BEFORE_QUOTA" -echo "" - -echo "[5] 并发发送20个扣费请求(每个100)..." -for i in {1..20}; do - curl -s -X POST "$MASTER/api/internal/sync/quota/batch-deduct" \ - -H "Content-Type: application/json" \ - -H "X-Sync-API-Key: $API_KEY" \ - -H "X-Sync-Node: $SLAVE" \ - -d "{\"records\": [{\"user_id\": $USER_ID, \"request_id\": \"req-stress-$i-$(date +%s%N)\", \"quota\": 100}]}" > /dev/null & -done -wait -echo "全部请求发送完成" -echo "" - -echo "[6] 查询最终余额..." -AFTER=$(curl -s -X POST "$MASTER/api/internal/sync/quota/query" \ - -H "Content-Type: application/json" \ - -H "X-Sync-API-Key: $API_KEY" \ - -H "X-Sync-Node: $SLAVE" \ - -d "{\"user_id\": $USER_ID}") -AFTER_QUOTA=$(echo $AFTER | grep -o '"quota":[0-9]*' | grep -o '[0-9]*') -echo "最终余额: $AFTER_QUOTA" - -ACTUAL_TOTAL_DEDUCTED=$((BEFORE_QUOTA - AFTER_QUOTA)) -EXPECTED_TOTAL_DEDUCTED=$((20 * 100)) - -echo "" -echo "实际总共扣除: $ACTUAL_TOTAL_DEDUCTED" -echo "期望总共扣除: $EXPECTED_TOTAL_DEDUCTED" - -if [ "$ACTUAL_TOTAL_DEDUCTED" -eq "$EXPECTED_TOTAL_DEDUCTED" ]; then - echo "✅ 高并发测试通过: 所有扣费都正确" -elif [ "$ACTUAL_TOTAL_DEDUCTED" -lt "$EXPECTED_TOTAL_DEDUCTED" ]; then - echo "❌ 高并发测试失败: 少扣了 $((EXPECTED_TOTAL_DEDUCTED - ACTUAL_TOTAL_DEDUCTED))" -else - echo "❌ 高并发测试失败: 多扣了 $((ACTUAL_TOTAL_DEDUCTED - EXPECTED_TOTAL_DEDUCTED))" -fi - -echo "" -echo "==========================================" -echo " 测试完成" -echo "==========================================" diff --git a/test_region_sync_e2e.sh b/test_region_sync_e2e.sh deleted file mode 100644 index 769eb28..0000000 --- a/test_region_sync_e2e.sh +++ /dev/null @@ -1,533 +0,0 @@ -#!/bin/bash - -# ============================================ -# 余额同步端到端测试脚本 -# 仿照真实用户使用场景测试 Master-Slave 余额同步 -# -# 用法: ./test_region_sync_e2e.sh -# ============================================ - -set -euo pipefail - -# ==================== 配置 ==================== -MASTER="http://172.22.57.85:23000" -SLAVE="http://172.22.57.85:13000" -SYNC_API_KEY="123456789" -MASTER_USER_KEY="sk-LDEPGBf5QJYgralE0cIJ47NGsaN8odTqn3fs5IGGJtOdLKSE:1" -SLAVE_USER_KEY="sk-5zVnpGLzKxN4xeiWTBh8nuIdXajhEcBPlIZN29L0gpLdAwsu:1" -USER_ID=7 -MODEL="deepseek-ai/DeepSeek-V3-0324" -SYNC_WAIT_SECONDS=70 # 等待同步的时间(秒),需大于 SyncIntervalSeconds -SYNC_POLL_INTERVAL=5 # 轮询间隔(秒) -CONCURRENT_COUNT=5 # 并发请求数量 -DEDUCT_AMOUNT=500 # 直接同步 API 测试的扣费金额 -TIMEOUT=60 # curl 超时时间(秒) - -# ==================== 颜色定义 ==================== -GREEN='\033[0;32m' -RED='\033[0;31m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -CYAN='\033[0;36m' -BOLD='\033[1m' -NC='\033[0m' - -# ==================== 计数器 ==================== -PASS_COUNT=0 -FAIL_COUNT=0 -SKIP_COUNT=0 -TOTAL_TESTS=0 - -# ==================== 工具函数 ==================== -log_pass() { - echo -e " ${GREEN}[PASS]${NC} $1" - ((PASS_COUNT++)) || true - ((TOTAL_TESTS++)) || true -} - -log_fail() { - echo -e " ${RED}[FAIL]${NC} $1" - ((FAIL_COUNT++)) || true - ((TOTAL_TESTS++)) || true -} - -log_skip() { - echo -e " ${YELLOW}[SKIP]${NC} $1" - ((SKIP_COUNT++)) || true - ((TOTAL_TESTS++)) || true -} - -log_info() { - echo -e " ${BLUE}[INFO]${NC} $1" -} - -log_warn() { - echo -e " ${YELLOW}[WARN]${NC} $1" -} - -print_header() { - echo "" - echo -e "${CYAN}============================================${NC}" - echo -e "${BOLD}$1${NC}" - echo -e "${CYAN}============================================${NC}" - echo "" -} - -print_section() { - echo "" - echo -e "${BOLD}--- $1 ---${NC}" -} - -# 查询 Master 余额 -query_master_balance() { - local user_id=${1:-$USER_ID} - local result=$(curl -s -m $TIMEOUT -X POST "$MASTER/api/internal/sync/quota/query" \ - -H "Content-Type: application/json" \ - -H "X-Sync-API-Key: $SYNC_API_KEY" \ - -H "X-Sync-Node: test-node" \ - -d "{\"user_id\": $user_id}") - echo "$result" | grep -o '"quota":[0-9]*' | grep -o '[0-9]*' -} - -# 等待同步完成(轮询 Master 余额,直到变化或超时) -wait_for_sync() { - local balance_before=$1 - local description=$2 - local waited=0 - - echo -e " ${BLUE}[SYNC]${NC} 等待余额同步到 Master($description)..." >&2 - - while [ $waited -lt $SYNC_WAIT_SECONDS ]; do - local current=$(query_master_balance) - if [ -n "$current" ] && [ "$current" != "$balance_before" ]; then - echo -e " ${GREEN}[SYNC]${NC} 检测到余额变化(等待了 ${waited}s):$balance_before → $current" >&2 - # 只向 stdout 输出纯数字 - echo "$current" - return 0 - fi - sleep $SYNC_POLL_INTERVAL - waited=$((waited + SYNC_POLL_INTERVAL)) - echo -e " ${BLUE}[SYNC]${NC} 已等待 ${waited}s / ${SYNC_WAIT_SECONDS}s" >&2 - done - - # 超时后再查一次 - local final=$(query_master_balance) - echo -e " ${YELLOW}[SYNC]${NC} 等待超时(${SYNC_WAIT_SECONDS}s),最终余额: $final" >&2 - echo "${final:-$balance_before}" -} - -# 发送聊天请求 -send_chat() { - local stream=${1:-false} - local max_tokens=${2:-10} - local prompt=${3:-"Say hello in one word"} - - curl -s -m $TIMEOUT --no-buffer -X POST "$SLAVE/v1/chat/completions" \ - -H "Authorization: Bearer $SLAVE_USER_KEY" \ - -H "Content-Type: application/json" \ - -d "{ - \"model\": \"$MODEL\", - \"messages\": [{\"role\": \"user\", \"content\": \"$prompt\"}], - \"max_tokens\": $max_tokens, - \"stream\": $stream - }" -} - -# ==================== 脚本开始 ==================== -print_header "余额同步端到端测试 (真实用户场景)" - -echo -e " Master: $MASTER" -echo -e " Slave: $SLAVE" -echo -e " 测试模型: $MODEL" -echo -e " 用户 ID: $USER_ID" -echo -e " 同步等待: ${SYNC_WAIT_SECONDS}s" -echo -e " 并发数量: $CONCURRENT_COUNT" - -# ==================== 阶段 0: 环境验证 ==================== -print_header "阶段 0: 环境验证" - -# T0.1: Master 连通性 -print_section "T0.1 Master 同步 API 连通性" -MASTER_RESULT=$(curl -s -m $TIMEOUT -w "\n%{http_code}" -X POST "$MASTER/api/internal/sync/quota/query" \ - -H "Content-Type: application/json" \ - -H "X-Sync-API-Key: $SYNC_API_KEY" \ - -H "X-Sync-Node: test-node" \ - -d "{\"user_id\": $USER_ID}") -MASTER_HTTP_CODE=$(echo "$MASTER_RESULT" | tail -1) -MASTER_BODY=$(echo "$MASTER_RESULT" | sed '$d') - -if [ "$MASTER_HTTP_CODE" = "200" ]; then - log_pass "Master 同步 API 可访问 (HTTP $MASTER_HTTP_CODE)" - log_info "响应: $MASTER_BODY" -else - log_fail "Master 同步 API 不可访问 (HTTP $MASTER_HTTP_CODE)" - log_info "响应: $MASTER_BODY" - echo -e " ${RED}Master 不可访问,无法继续测试${NC}" - exit 1 -fi - -# T0.2: Slave 连通性 -print_section "T0.2 Slave API 连通性" -SLAVE_RESULT=$(curl -s -m $TIMEOUT -w "\n%{http_code}" "$SLAVE/v1/models" \ - -H "Authorization: Bearer $SLAVE_USER_KEY") -SLAVE_HTTP_CODE=$(echo "$SLAVE_RESULT" | tail -1) - -if [ "$SLAVE_HTTP_CODE" = "200" ]; then - log_pass "Slave API 可访问 (HTTP $SLAVE_HTTP_CODE)" -else - log_fail "Slave API 不可访问 (HTTP $SLAVE_HTTP_CODE)" - echo -e " ${RED}Slave 不可访问,无法继续测试${NC}" - exit 1 -fi - -# ==================== 阶段 1: 基线余额 ==================== -print_header "阶段 1: 基线余额" - -print_section "T1.1 查询初始余额" -BALANCE_BEFORE=$(query_master_balance) - -if [ -n "$BALANCE_BEFORE" ]; then - log_pass "Master 初始余额: $BALANCE_BEFORE" -else - log_fail "无法查询 Master 余额" - exit 1 -fi - -if [ "$BALANCE_BEFORE" -lt 1000 ]; then - log_warn "余额较低 ($BALANCE_BEFORE),部分测试可能因余额不足而失败" -fi - -# ==================== 阶段 2: 非流式聊天调用 ==================== -print_header "阶段 2: 非流式聊天调用 (Slave → 上游 → 同步 Master)" - -print_section "T2.1 通过 Slave 发起非流式聊天请求" -log_info "模型: $MODEL, max_tokens: 10" - -CHAT_RESULT=$(send_chat false 10 "Say hello in one word") -CHAT_HTTP_OK=true - -# 检查是否有错误 -if echo "$CHAT_RESULT" | grep -q '"error"'; then - ERROR_MSG=$(echo "$CHAT_RESULT" | grep -o '"message":"[^"]*"' | head -1) - log_fail "非流式请求失败: $ERROR_MSG" - CHAT_HTTP_OK=false -elif echo "$CHAT_RESULT" | grep -q '"choices"'; then - log_pass "非流式请求成功" - # 提取使用量 - USAGE=$(echo "$CHAT_RESULT" | grep -o '"total_tokens":[0-9]*' | grep -o '[0-9]*') - if [ -n "$USAGE" ]; then - log_info "Token 使用量: $USAGE" - fi - # 提取回复内容(截断显示) - CONTENT=$(echo "$CHAT_RESULT" | grep -o '"content":"[^"]*"' | head -1 | sed 's/"content":"//;s/"$//') - if [ -n "$CONTENT" ]; then - log_info "模型回复: ${CONTENT:0:80}" - fi -else - log_warn "响应格式未知: ${CHAT_RESULT:0:100}" - CHAT_HTTP_OK=false -fi - -if [ "$CHAT_HTTP_OK" = true ]; then - print_section "T2.2 等待同步到 Master" - BALANCE_AFTER_NON_STREAM=$(wait_for_sync "$BALANCE_BEFORE" "非流式调用") - - print_section "T2.3 验证 Master 余额变化" - if [ "$BALANCE_AFTER_NON_STREAM" != "$BALANCE_BEFORE" ]; then - DELTA=$((BALANCE_BEFORE - BALANCE_AFTER_NON_STREAM)) - if [ "$DELTA" -gt 0 ]; then - log_pass "Master 余额已扣减: $BALANCE_BEFORE → $BALANCE_AFTER_NON_STREAM (扣除 $DELTA)" - else - log_fail "Master 余额变化异常: $BALANCE_BEFORE → $BALANCE_AFTER_NON_STREAM (变化 $DELTA)" - fi - else - log_fail "Master 余额未变化,同步可能失败" - fi -else - log_skip "T2.2-T2.3(非流式请求失败)" - BALANCE_AFTER_NON_STREAM=$BALANCE_BEFORE -fi - -# ==================== 阶段 3: 流式聊天调用 ==================== -print_header "阶段 3: 流式聊天调用 (SSE)" - -print_section "T3.1 通过 Slave 发起流式聊天请求" -log_info "模型: $MODEL, stream: true, max_tokens: 20" - -STREAM_RESULT=$(curl -s -m $TIMEOUT --no-buffer -X POST "$SLAVE/v1/chat/completions" \ - -H "Authorization: Bearer $SLAVE_USER_KEY" \ - -H "Content-Type: application/json" \ - -d "{ - \"model\": \"$MODEL\", - \"messages\": [{\"role\": \"user\", \"content\": \"Count from 1 to 3\"}], - \"max_tokens\": 20, - \"stream\": true - }" 2>&1) - -STREAM_OK=true - -if echo "$STREAM_RESULT" | grep -q '"error"'; then - ERROR_MSG=$(echo "$STREAM_RESULT" | grep -o '"message":"[^"]*"' | head -1) - log_fail "流式请求失败: $ERROR_MSG" - STREAM_OK=false -elif echo "$STREAM_RESULT" | grep -q 'data:'; then - log_pass "流式请求成功,收到 SSE 数据" - - # 验证 SSE 格式 - SSE_DATA_LINES=$(echo "$STREAM_RESULT" | grep -c "data:" || echo "0") - HAS_DONE=$(echo "$STREAM_RESULT" | grep -c "data: \[DONE\]" || echo "0") - - if [ "$SSE_DATA_LINES" -gt 0 ]; then - log_info "收到 $SSE_DATA_LINES 行 SSE 数据" - fi - if [ "$HAS_DONE" -gt 0 ]; then - log_pass "流式响应包含 [DONE] 标记" - else - log_warn "流式响应未包含 [DONE] 标记" - fi -else - log_warn "响应中无 SSE 数据: ${STREAM_RESULT:0:100}" - STREAM_OK=false -fi - -if [ "$STREAM_OK" = true ]; then - print_section "T3.2 等待同步到 Master" - BALANCE_BEFORE_STREAM=$BALANCE_AFTER_NON_STREAM - BALANCE_AFTER_STREAM=$(wait_for_sync "$BALANCE_BEFORE_STREAM" "流式调用") - - print_section "T3.3 验证 Master 余额变化" - if [ "$BALANCE_AFTER_STREAM" != "$BALANCE_BEFORE_STREAM" ]; then - DELTA=$((BALANCE_BEFORE_STREAM - BALANCE_AFTER_STREAM)) - if [ "$DELTA" -gt 0 ]; then - log_pass "流式调用同步成功: $BALANCE_BEFORE_STREAM → $BALANCE_AFTER_STREAM (扣除 $DELTA)" - else - log_fail "流式调用余额变化异常: $DELTA" - fi - else - log_warn "流式调用后 Master 余额未变化(可能 token 消耗为 0)" - fi -else - log_skip "T3.2-T3.3(流式请求失败)" - BALANCE_AFTER_STREAM=$BALANCE_AFTER_NON_STREAM -fi - -# ==================== 阶段 4: 并发请求 ==================== -print_header "阶段 4: 并发请求 ($CONCURRENT_COUNT 个)" - -print_section "T4.1 并发发送 $CONCURRENT_COUNT 个聊天请求" -BALANCE_BEFORE_CONCURRENT=$BALANCE_AFTER_STREAM - -# 准备不同的 prompt 以避免缓存 -PROMPTS=("What is 1+1?" "Tell me a color" "Say yes or no" "What day is it?" "Name a fruit" - "What is 2+2?" "Say goodbye" "What is water?" "Name an animal" "Say OK") - -PIDS=() -for i in $(seq 1 $CONCURRENT_COUNT); do - PROMPT="${PROMPTS[$((i - 1))]}" - send_chat false 10 "$PROMPT" > "/tmp/sync_test_concurrent_$i.json" 2>&1 & - PIDS+=($!) -done - -log_info "已发送 $CONCURRENT_COUNT 个并发请求,等待完成..." - -SUCCESS_COUNT=0 -FAIL_COUNT_CONCURRENT=0 -for pid in "${PIDS[@]}"; do - if wait "$pid"; then - ((SUCCESS_COUNT++)) || true - else - ((FAIL_COUNT_CONCURRENT++)) || true - fi -done - -if [ "$FAIL_COUNT_CONCURRENT" -eq 0 ]; then - log_pass "全部 $CONCURRENT_COUNT 个请求完成" -else - log_warn "$SUCCESS_COUNT 成功, $FAIL_COUNT_CONCURRENT 失败" -fi - -# 检查响应 -print_section "T4.2 验证并发请求响应" -VALID_RESPONSES=0 -for i in $(seq 1 $CONCURRENT_COUNT); do - if [ -f "/tmp/sync_test_concurrent_$i.json" ]; then - if grep -q '"choices"' "/tmp/sync_test_concurrent_$i.json"; then - ((VALID_RESPONSES++)) || true - fi - fi -done -log_info "有效响应: $VALID_RESPONSES / $CONCURRENT_COUNT" - -if [ "$VALID_RESPONSES" -gt 0 ]; then - print_section "T4.3 等待同步到 Master" - BALANCE_AFTER_CONCURRENT=$(wait_for_sync "$BALANCE_BEFORE_CONCURRENT" "并发调用") - - print_section "T4.4 验证 Master 余额变化" - if [ "$BALANCE_AFTER_CONCURRENT" != "$BALANCE_BEFORE_CONCURRENT" ]; then - DELTA=$((BALANCE_BEFORE_CONCURRENT - BALANCE_AFTER_CONCURRENT)) - if [ "$DELTA" -gt 0 ]; then - log_pass "并发调用同步成功: $BALANCE_BEFORE_CONCURRENT → $BALANCE_AFTER_CONCURRENT (共扣除 $DELTA)" - else - log_fail "并发调用余额变化异常: $DELTA" - fi - else - log_warn "并发调用后 Master 余额未变化" - fi -else - log_skip "T4.3-T4.4(无有效并发响应)" - BALANCE_AFTER_CONCURRENT=$BALANCE_BEFORE_CONCURRENT -fi - -# 清理临时文件 -rm -f /tmp/sync_test_concurrent_*.json - -# ==================== 阶段 5: 错误场景 ==================== -print_header "阶段 5: 错误场景测试" - -# T5.1: 错误模型名 -print_section "T5.1 错误模型名" -ERR_RESULT=$(curl -s -m $TIMEOUT -w "\n%{http_code}" -X POST "$SLAVE/v1/chat/completions" \ - -H "Authorization: Bearer $SLAVE_USER_KEY" \ - -H "Content-Type: application/json" \ - -d '{"model":"nonexistent-model-xyz-12345","messages":[{"role":"user","content":"hi"}],"max_tokens":5}') -ERR_HTTP=$(echo "$ERR_RESULT" | tail -1) -ERR_BODY=$(echo "$ERR_RESULT" | sed '$d') - -if [ "$ERR_HTTP" != "200" ]; then - log_pass "错误模型名被拒绝 (HTTP $ERR_HTTP)" -else - log_fail "错误模型名未被拒绝 (HTTP $ERR_HTTP)" -fi -log_info "响应: ${ERR_BODY:0:100}" - -# T5.2: 无认证 -print_section "T5.2 无认证请求" -NO_AUTH_RESULT=$(curl -s -m $TIMEOUT -w "\n%{http_code}" -X POST "$SLAVE/v1/chat/completions" \ - -H "Content-Type: application/json" \ - -d '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"hi"}],"max_tokens":5}') -NO_AUTH_HTTP=$(echo "$NO_AUTH_RESULT" | tail -1) - -if [ "$NO_AUTH_HTTP" != "200" ]; then - log_pass "无认证请求被拒绝 (HTTP $NO_AUTH_HTTP)" -else - log_fail "无认证请求未被拒绝 (HTTP $NO_AUTH_HTTP)" -fi - -# T5.3: 错误 API Key -print_section "T5.3 错误 API Key" -BAD_KEY_RESULT=$(curl -s -m $TIMEOUT -w "\n%{http_code}" -X POST "$SLAVE/v1/chat/completions" \ - -H "Authorization: Bearer sk-invalid-key-12345" \ - -H "Content-Type: application/json" \ - -d '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"hi"}],"max_tokens":5}') -BAD_KEY_HTTP=$(echo "$BAD_KEY_RESULT" | tail -1) - -if [ "$BAD_KEY_HTTP" != "200" ]; then - log_pass "错误 API Key 被拒绝 (HTTP $BAD_KEY_HTTP)" -else - log_fail "错误 API Key 未被拒绝 (HTTP $BAD_KEY_HTTP)" -fi - -# T5.4: 空消息 -print_section "T5.4 空消息列表" -EMPTY_MSG_RESULT=$(curl -s -m $TIMEOUT -w "\n%{http_code}" -X POST "$SLAVE/v1/chat/completions" \ - -H "Authorization: Bearer $SLAVE_USER_KEY" \ - -H "Content-Type: application/json" \ - -d '{"model":"gpt-3.5-turbo","messages":[],"max_tokens":5}') -EMPTY_MSG_HTTP=$(echo "$EMPTY_MSG_RESULT" | tail -1) - -if [ "$EMPTY_MSG_HTTP" != "200" ]; then - log_pass "空消息被拒绝 (HTTP $EMPTY_MSG_HTTP)" -else - log_warn "空消息未被拒绝 (HTTP $EMPTY_MSG_HTTP) — 某些实现可能允许" -fi - -# ==================== 阶段 6: 直接同步 API 测试 ==================== -print_header "阶段 6: 直接同步 API 测试" - -BALANCE_BEFORE_DIRECT=$(query_master_balance) -log_info "直接测试前 Master 余额: $BALANCE_BEFORE_DIRECT" - -# T6.1: 批量扣费 -print_section "T6.1 批量扣费 ($DEDUCT_AMOUNT)" -REQUEST_ID="req-e2e-$(date +%s%N)" - -DEDUCT_RESULT=$(curl -s -m $TIMEOUT -X POST "$MASTER/api/internal/sync/quota/batch-deduct" \ - -H "Content-Type: application/json" \ - -H "X-Sync-API-Key: $SYNC_API_KEY" \ - -H "X-Sync-Node: test-node" \ - -d "{\"records\": [{\"user_id\": $USER_ID, \"request_id\": \"$REQUEST_ID\", \"quota\": $DEDUCT_AMOUNT}]}") -log_info "响应: $DEDUCT_RESULT" - -if echo "$DEDUCT_RESULT" | grep -qi "success\|true\|200"; then - log_pass "批量扣费请求成功" -else - log_fail "批量扣费请求失败: $DEDUCT_RESULT" -fi - -# T6.2: 幂等性测试(相同 request_id) -print_section "T6.2 幂等性测试(相同 request_id)" -IDEMPOTENT_RESULT=$(curl -s -m $TIMEOUT -X POST "$MASTER/api/internal/sync/quota/batch-deduct" \ - -H "Content-Type: application/json" \ - -H "X-Sync-API-Key: $SYNC_API_KEY" \ - -H "X-Sync-Node: test-node" \ - -d "{\"records\": [{\"user_id\": $USER_ID, \"request_id\": \"$REQUEST_ID\", \"quota\": $DEDUCT_AMOUNT}]}") -log_info "响应: $IDEMPOTENT_RESULT" - -BALANCE_AFTER_FIRST=$(query_master_balance) -log_info "幂等测试后余额: $BALANCE_AFTER_FIRST" - -# 再查一次确认没变 -sleep 1 -BALANCE_AFTER_IDEMPOTENT=$(query_master_balance) - -if [ "$BALANCE_AFTER_IDEMPOTENT" = "$BALANCE_AFTER_FIRST" ]; then - log_pass "幂等性验证通过:重复请求未重复扣费" -else - log_fail "幂等性验证失败:余额发生了变化 ($BALANCE_AFTER_FIRST → $BALANCE_AFTER_IDEMPOTENT)" -fi - -# T6.3: 精确金额验证 -print_section "T6.3 精确扣费金额验证" -log_info "扣费前: $BALANCE_BEFORE_DIRECT" -log_info "扣费后: $BALANCE_AFTER_IDEMPOTENT" - -ACTUAL_DEDUCT=$((BALANCE_BEFORE_DIRECT - BALANCE_AFTER_IDEMPOTENT)) -if [ "$ACTUAL_DEDUCT" -eq $DEDUCT_AMOUNT ]; then - log_pass "精确金额验证通过:扣除了 $ACTUAL_DEDUCT (期望 $DEDUCT_AMOUNT)" -else - log_fail "精确金额验证失败:扣除了 $ACTUAL_DEDUCT (期望 $DEDUCT_AMOUNT)" -fi - -# ==================== 阶段 7: 总结报告 ==================== -print_header "测试总结" - -FINAL_BALANCE=$(query_master_balance) - -echo -e " ${BOLD}测试统计:${NC}" -echo -e " ${GREEN}PASS${NC}: $PASS_COUNT" -echo -e " ${RED}FAIL${NC}: $FAIL_COUNT" -echo -e " ${YELLOW}SKIP${NC}: $SKIP_COUNT" -echo -e " 总计: $TOTAL_TESTS" -echo "" -echo -e " ${BOLD}余额变化:${NC}" -echo -e " 初始余额: $BALANCE_BEFORE" -echo -e " 最终余额: $FINAL_BALANCE" -echo -e " 总扣除: $((BALANCE_BEFORE - FINAL_BALANCE))" -echo "" -echo -e " 测试模型: $MODEL" - -echo "" -if [ "$FAIL_COUNT" -eq 0 ]; then - echo -e " ${GREEN}${BOLD}全部测试通过!${NC}" -else - echo -e " ${RED}${BOLD}存在 $FAIL_COUNT 个失败${NC}" -fi - -echo "" -echo -e "${CYAN}============================================${NC}" - -# 返回退出码 -if [ "$FAIL_COUNT" -gt 0 ]; then - exit 1 -fi -exit 0 diff --git a/web/src/pages/Setting/Operation/SettingsRegionSync.jsx b/web/src/pages/Setting/Operation/SettingsRegionSync.jsx index 74a2c64..69f2b41 100644 --- a/web/src/pages/Setting/Operation/SettingsRegionSync.jsx +++ b/web/src/pages/Setting/Operation/SettingsRegionSync.jsx @@ -32,6 +32,19 @@ import { useTranslation } from 'react-i18next'; // 将字符串转换为数组(用于 TagInput 显示) function stringToArray(str) { if (!str || typeof str !== 'string') return []; + const trimmed = str.trim(); + // 尝试 JSON 解析(后端存的是 JSON 数组格式) + if (trimmed.startsWith('[')) { + try { + const arr = JSON.parse(trimmed); + if (Array.isArray(arr)) { + return arr.map((s) => s.trim()).filter((s) => s.length > 0); + } + } catch (e) { + // JSON 解析失败,fallback 到换行分隔 + } + } + // 换行分隔格式兼容 return str .split('\n') .map((s) => s.trim()) @@ -85,7 +98,6 @@ export default function SettingsRegionSync(props) { // 原始数据(字符串格式,用于保存) const [inputs, setInputs] = useState({ ...DEFAULT_INPUTS }); // TagInput 显示用的数组格式 - const [masterEndpoints, setMasterEndpoints] = useState([]); const [slaveEndpoints, setSlaveEndpoints] = useState([]); const refForm = useRef(); @@ -174,7 +186,6 @@ export default function SettingsRegionSync(props) { setInputsRow(structuredClone(currentInputs)); // 转换为数组用于 TagInput - setMasterEndpoints(stringToArray(currentInputs['region_sync.master_endpoint'])); setSlaveEndpoints(stringToArray(currentInputs['region_sync.slave_endpoints'])); }, [props.options]); @@ -368,29 +379,12 @@ export default function SettingsRegionSync(props) { <> -
- - - - {t('例如: https://master.example.com')} - -
+