- 新增 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 <noreply@anthropic.com>feat/alipay-payment
| @@ -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, | |||
| }) | |||
| } | |||
| @@ -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) | |||
| } | |||
| } | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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)) | |||
| } | |||
| } | |||
| } | |||
| } | |||
| @@ -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 | |||
| @@ -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"` | |||
| } | |||
| @@ -1,182 +0,0 @@ | |||
| #!/bin/bash | |||
| # 渠道定价功能端到端测试脚本 | |||
| # 使用方法: ./test_channel_pricing.sh <admin_token> | |||
| BASE_URL="http://localhost:3000" | |||
| ADMIN_TOKEN="${1:-}" | |||
| if [ -z "$ADMIN_TOKEN" ]; then | |||
| echo "错误: 请提供管理员 token" | |||
| echo "使用方法: ./test_channel_pricing.sh <admin_token>" | |||
| 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 | |||
| @@ -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 "==========================================" | |||
| @@ -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 "==========================================" | |||
| @@ -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 | |||
| @@ -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) { | |||
| <> | |||
| <Row gutter={16} style={{ marginTop: 16 }}> | |||
| <Col xs={24} sm={24} md={12} lg={12} xl={12}> | |||
| <div className='semi-form-field'> | |||
| <label className='semi-form-field-label'> | |||
| {t('主节点地址')} | |||
| </label> | |||
| <TagInput | |||
| value={masterEndpoints} | |||
| onChange={handleTagInputChange( | |||
| 'region_sync.master_endpoint', | |||
| setMasterEndpoints | |||
| )} | |||
| placeholder={t('输入地址后按回车添加')} | |||
| separator=',' | |||
| addOnBlur | |||
| style={{ marginTop: 4 }} | |||
| /> | |||
| <Typography.Text | |||
| type='tertiary' | |||
| size='small' | |||
| style={{ marginTop: 4, display: 'block' }} | |||
| > | |||
| {t('例如: https://master.example.com')} | |||
| </Typography.Text> | |||
| </div> | |||
| <Form.Input | |||
| field={'region_sync.master_endpoint'} | |||
| label={t('主节点地址')} | |||
| placeholder={t('例如: https://master.example.com')} | |||
| onChange={handleFieldChange('region_sync.master_endpoint')} | |||
| /> | |||
| </Col> | |||
| </Row> | |||
| <Row gutter={16} style={{ marginTop: 16 }}> | |||