feat(acceptance): 建立同构验收与弹性容量体系
实现本地三节点 K3s 同构环境、脱敏生产快照、Gemini 图片和多参考图视频协议模拟、统一验收报告及故障注入。\n\n新增 Worker 容量控制器、资源与连接预算、任务恢复保护,并将生产验收拆分为 validation 执行和人工 CAS 放量。\n\n验证包括 Go 全量测试、PostgreSQL HTTP 集成测试、go vet、OpenAPI、ShellCheck、前端检查、迁移及发布脚本测试。
This commit is contained in:
@@ -37,18 +37,20 @@ type Config struct {
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
now func() time.Time
|
||||
httpClient *http.Client
|
||||
wait func(context.Context, time.Duration) error
|
||||
mu sync.RWMutex
|
||||
tasks map[string]videoTask
|
||||
fixtures map[string]fixture
|
||||
nextID atomic.Uint64
|
||||
report Report
|
||||
callbacks map[string]map[int64]int
|
||||
pngSmall string
|
||||
pngLarge string
|
||||
pngPeak string
|
||||
now func() time.Time
|
||||
httpClient *http.Client
|
||||
wait func(context.Context, time.Duration) error
|
||||
mu sync.RWMutex
|
||||
tasks map[string]videoTask
|
||||
fixtures map[string]fixture
|
||||
nextID atomic.Uint64
|
||||
report Report
|
||||
callbacks map[string]map[int64]int
|
||||
videoByIdempotency map[string]string
|
||||
geminiIdempotency map[string]struct{}
|
||||
pngSmall string
|
||||
pngLarge string
|
||||
pngPeak string
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
@@ -67,6 +69,8 @@ type Report struct {
|
||||
VerifiedConversions int64 `json:"verifiedConversions"`
|
||||
CallbackEvents int64 `json:"callbackEvents"`
|
||||
DuplicateCallbacks int64 `json:"duplicateCallbacks"`
|
||||
MissingIdempotency int64 `json:"missingIdempotencyKeys"`
|
||||
DuplicateSubmissions int64 `json:"duplicateSubmissionAttempts"`
|
||||
}
|
||||
|
||||
type videoTask struct {
|
||||
@@ -105,7 +109,9 @@ func New(config Config) *Server {
|
||||
}
|
||||
return &Server{
|
||||
now: now, httpClient: client, wait: waiter, tasks: map[string]videoTask{}, fixtures: buildFixtures(),
|
||||
callbacks: map[string]map[int64]int{},
|
||||
callbacks: map[string]map[int64]int{},
|
||||
videoByIdempotency: map[string]string{},
|
||||
geminiIdempotency: map[string]struct{}{},
|
||||
report: Report{
|
||||
VideoReferenceCounts: map[string]int64{},
|
||||
VideoRoleCounts: map[string]int64{},
|
||||
@@ -140,6 +146,14 @@ func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
idempotencyKey := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
||||
if idempotencyKey == "" {
|
||||
s.mu.Lock()
|
||||
s.report.MissingIdempotency++
|
||||
s.mu.Unlock()
|
||||
writeProtocolError(w, http.StatusBadRequest, "Idempotency-Key is required")
|
||||
return
|
||||
}
|
||||
var body map[string]any
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
s.recordGeminiInvalid()
|
||||
@@ -157,6 +171,11 @@ func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
if _, exists := s.geminiIdempotency[idempotencyKey]; exists {
|
||||
s.report.DuplicateSubmissions++
|
||||
} else {
|
||||
s.geminiIdempotency[idempotencyKey] = struct{}{}
|
||||
}
|
||||
s.report.GeminiRequests++
|
||||
s.report.GeminiInputBytes += int64(inputBytes)
|
||||
s.report.GeminiOutputBytes += int64(outputBytes)
|
||||
@@ -195,6 +214,14 @@ func (s *Server) geminiProfile(inputBytes int) (int, string, time.Duration) {
|
||||
}
|
||||
|
||||
func (s *Server) submitVideo(w http.ResponseWriter, r *http.Request) {
|
||||
idempotencyKey := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
||||
if idempotencyKey == "" {
|
||||
s.mu.Lock()
|
||||
s.report.MissingIdempotency++
|
||||
s.mu.Unlock()
|
||||
writeProtocolError(w, http.StatusBadRequest, "Idempotency-Key is required")
|
||||
return
|
||||
}
|
||||
var body map[string]any
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
s.recordVideoInvalid()
|
||||
@@ -214,7 +241,19 @@ func (s *Server) submitVideo(w http.ResponseWriter, r *http.Request) {
|
||||
CreatedAt: s.now(), ReadyAt: s.now().Add(delay), ImageRefs: refs,
|
||||
}
|
||||
s.mu.Lock()
|
||||
if existingID := s.videoByIdempotency[idempotencyKey]; existingID != "" {
|
||||
task = s.tasks[existingID]
|
||||
s.report.DuplicateSubmissions++
|
||||
s.mu.Unlock()
|
||||
w.Header().Set("X-Request-ID", task.ID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": task.ID, "model": task.Model, "status": "queued",
|
||||
"created_at": task.CreatedAt.Unix(),
|
||||
})
|
||||
return
|
||||
}
|
||||
s.tasks[id] = task
|
||||
s.videoByIdempotency[idempotencyKey] = id
|
||||
s.report.VideoSubmissions++
|
||||
s.report.VideoReferenceCounts[strconv.Itoa(len(refs))]++
|
||||
if forceConversion {
|
||||
|
||||
@@ -40,7 +40,11 @@ func TestGeminiAndVolcesProtocolEmulation(t *testing.T) {
|
||||
}}},
|
||||
"generationConfig": map[string]any{"responseModalities": []any{"IMAGE"}},
|
||||
})
|
||||
response, err := http.Post(httpServer.URL+"/v1beta/models/gemini-test:generateContent", "application/json", bytes.NewReader(geminiBody))
|
||||
response, err := postIdempotent(
|
||||
httpServer.URL+"/v1beta/models/gemini-test:generateContent",
|
||||
geminiBody,
|
||||
"gemini-task",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Gemini request: %v", err)
|
||||
}
|
||||
@@ -76,7 +80,11 @@ func TestGeminiAndVolcesProtocolEmulation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
videoBody, _ := json.Marshal(map[string]any{"model": "seedance-test", "content": content, "seed": 1})
|
||||
response, err = http.Post(httpServer.URL+"/contents/generations/tasks", "application/json", bytes.NewReader(videoBody))
|
||||
response, err = postIdempotent(
|
||||
httpServer.URL+"/contents/generations/tasks",
|
||||
videoBody,
|
||||
"video-task",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("submit video: %v", err)
|
||||
}
|
||||
@@ -159,7 +167,11 @@ func TestVolcesProtocolAcceptsThreeSixAndNineReferenceImages(t *testing.T) {
|
||||
})
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"model": "seedance-test", "content": content, "seed": imageCount})
|
||||
response, err := http.Post(httpServer.URL+"/contents/generations/tasks", "application/json", bytes.NewReader(body))
|
||||
response, err := postIdempotent(
|
||||
httpServer.URL+"/contents/generations/tasks",
|
||||
body,
|
||||
fmt.Sprintf("video-%d", imageCount),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("%d-image submit: %v", imageCount, err)
|
||||
}
|
||||
@@ -184,3 +196,13 @@ func TestVolcesProtocolAcceptsThreeSixAndNineReferenceImages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func postIdempotent(url string, body []byte, key string) (*http.Response, error) {
|
||||
request, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Idempotency-Key", key)
|
||||
return http.DefaultClient.Do(request)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user