浏览代码

feat(region-sync): Master→Slave 配置同步、热更新与前端修复

- 新增 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
fengsilin 4 周前
父节点
当前提交
9d4a595465
共有 11 个文件被更改,包括 126 次插入1065 次删除
  1. +14
    -0
      controller/region_sync.go
  2. +1
    -0
      router/api-router.go
  3. +12
    -0
      service/region_sync/sync_client.go
  4. +68
    -16
      service/region_sync/sync_manager.go
  5. +2
    -0
      service/region_sync/sync_manager_test.go
  6. +10
    -0
      service/region_sync/sync_types.go
  7. +0
    -182
      test_channel_pricing.sh
  8. +0
    -153
      test_region_sync.sh
  9. +0
    -156
      test_region_sync_concurrent.sh
  10. +0
    -533
      test_region_sync_e2e.sh
  11. +19
    -25
      web/src/pages/Setting/Operation/SettingsRegionSync.jsx

+ 14
- 0
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,
})
}

+ 1
- 0
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)
}
}
}

+ 12
- 0
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
}

+ 68
- 16
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))
}
}
}
}

+ 2
- 0
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



+ 10
- 0
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"`
}

+ 0
- 182
test_channel_pricing.sh 查看文件

@@ -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

+ 0
- 153
test_region_sync.sh 查看文件

@@ -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 "=========================================="

+ 0
- 156
test_region_sync_concurrent.sh 查看文件

@@ -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 "=========================================="

+ 0
- 533
test_region_sync_e2e.sh 查看文件

@@ -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

+ 19
- 25
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) {
<>
<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 }}>


正在加载...
取消
保存