feat(adapter): add sub2api platform adapter stack
This commit is contained in:
@@ -43,6 +43,7 @@
|
||||
- `prd/PRODUCTION_CHECKLIST.md`
|
||||
- `docs/CONFIG_CONTRACT_BASELINE.md`
|
||||
- `docs/P0_P1_P2_RECTIFICATION_EXECUTION_BOARD.md`
|
||||
- `docs/RUNBOOK_PLATFORM_CALLBACKS.md`
|
||||
|
||||
### 1.3 本轮已执行验证
|
||||
```bash
|
||||
@@ -53,6 +54,10 @@ AI_CS_RUNTIME_ENV=production ... scripts/verify_preprod_gate_b.sh
|
||||
AI_CS_RUNTIME_ENV=production ... scripts/verify_gate_c_rollback.sh
|
||||
```
|
||||
|
||||
适配层新增实测:
|
||||
- `go test ./test/integration ./test/e2e -count=1`
|
||||
- 覆盖 `Sub2API` 平台入口、outbox、callback 成功投递、callback 死信路径
|
||||
|
||||
### 1.4 关键事实校准
|
||||
- 当前仓库实测结论:**全量 Go 测试与 `go vet` 已通过**
|
||||
- prod fallback / runtime env / readiness 相关代码阻断:**已落地并有测试覆盖**
|
||||
@@ -76,6 +81,7 @@ AI_CS_RUNTIME_ENV=production ... scripts/verify_gate_c_rollback.sh
|
||||
|
||||
### 2.1 已通过项
|
||||
- webhook / dialog / handoff / ticket 主链已落地
|
||||
- `Sub2API` 平台适配入口、outbox、callback worker、死信链路已落地并有自动化覆盖
|
||||
- feedback / handoff / stats 等 Phase 1 核心接口已具备
|
||||
- Webhook HMAC / timestamp / dedup / body limit / rate limit 已存在
|
||||
- Postgres 持久化链路已接通
|
||||
|
||||
266
test/e2e/sub2api_callback_flow_test.go
Normal file
266
test/e2e/sub2api_callback_flow_test.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bridge/ai-customer-service/internal/app"
|
||||
"github.com/bridge/ai-customer-service/internal/config"
|
||||
"github.com/bridge/ai-customer-service/internal/domain/platformevent"
|
||||
"github.com/bridge/ai-customer-service/internal/http/handlers"
|
||||
"github.com/bridge/ai-customer-service/internal/platform/logging"
|
||||
pgstore "github.com/bridge/ai-customer-service/internal/store/postgres"
|
||||
)
|
||||
|
||||
func e2ePlatformDSN() string {
|
||||
return "host=localhost port=5434 user=ai_cs password=ai_cs_secret dbname=ai_customer_service sslmode=disable"
|
||||
}
|
||||
|
||||
func openE2EPlatformDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := pgstore.Open(pgstore.Config{
|
||||
DSN: e2ePlatformDSN(),
|
||||
MaxOpenConns: 5,
|
||||
MaxIdleConns: 2,
|
||||
ConnMaxLifetime: 30 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open postgres failed: %v", err)
|
||||
}
|
||||
if err := pgstore.RunMigrations(db, "../../db/migration"); err != nil {
|
||||
_ = db.Close()
|
||||
t.Fatalf("run migrations failed: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func newSub2APIE2EApp(t *testing.T, callbackURL string, callbackSecret string, maxRetries int) *app.App {
|
||||
t.Helper()
|
||||
cfg := &config.Config{}
|
||||
cfg.HTTP.Addr = ":0"
|
||||
cfg.HTTP.ReadHeaderTimeout = 5
|
||||
cfg.HTTP.ReadTimeout = 10
|
||||
cfg.HTTP.WriteTimeout = 15
|
||||
cfg.HTTP.IdleTimeout = 60
|
||||
cfg.HTTP.MaxHeaderBytes = 1 << 20
|
||||
cfg.HTTP.MaxBodyBytes = 1 << 20
|
||||
cfg.Runtime.Env = "test"
|
||||
cfg.Postgres.Enabled = true
|
||||
cfg.Postgres.DSN = e2ePlatformDSN()
|
||||
cfg.Postgres.MigrationDir = "../../db/migration"
|
||||
cfg.Postgres.MaxOpenConns = 5
|
||||
cfg.Postgres.MaxIdleConns = 2
|
||||
cfg.Postgres.ConnMaxLifetime = 30
|
||||
cfg.Webhook.Secret = "default-webhook-secret"
|
||||
cfg.Webhook.TimestampHeader = "X-CS-Timestamp"
|
||||
cfg.Webhook.SignatureHeader = "X-CS-Signature"
|
||||
cfg.Webhook.MaxSkewSeconds = 300
|
||||
cfg.PlatformAdapters.Enabled = true
|
||||
cfg.PlatformAdapters.Sub2API.Enabled = true
|
||||
cfg.PlatformAdapters.Sub2API.IngressSecret = "sub2api-ingress-secret"
|
||||
cfg.PlatformAdapters.Sub2API.CallbackBaseURL = callbackURL
|
||||
cfg.PlatformAdapters.Sub2API.CallbackSecret = callbackSecret
|
||||
cfg.PlatformAdapters.Sub2API.CallbackTimeoutMS = 2000
|
||||
cfg.PlatformAdapters.Sub2API.CallbackMaxRetries = maxRetries
|
||||
|
||||
application, err := app.New(cfg, logging.New())
|
||||
if err != nil {
|
||||
t.Fatalf("app.New() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = application.Shutdown(context.Background())
|
||||
})
|
||||
return application
|
||||
}
|
||||
|
||||
func eventually(t *testing.T, timeout time.Duration, fn func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if fn() {
|
||||
return
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("condition not satisfied before timeout")
|
||||
}
|
||||
|
||||
func TestSub2APICallbackFlow_ShouldDeliverOrderedEventsWithStableEventIDs(t *testing.T) {
|
||||
db := openE2EPlatformDB(t)
|
||||
defer db.Close()
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
received []platformevent.Event
|
||||
)
|
||||
callbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
var event platformevent.Event
|
||||
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
|
||||
t.Fatalf("decode callback body failed: %v", err)
|
||||
}
|
||||
received = append(received, event)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer callbackServer.Close()
|
||||
|
||||
application := newSub2APIE2EApp(t, callbackServer.URL, "sub2api-callback-secret", 3)
|
||||
server := httptest.NewServer(application.Server.Handler)
|
||||
defer server.Close()
|
||||
|
||||
openID := "sub2api-e2e-" + time.Now().UTC().Format("150405.000000000")
|
||||
payload := map[string]any{
|
||||
"message_id": "m-e2e-" + time.Now().UTC().Format("150405.000000000"),
|
||||
"channel": "sub2api",
|
||||
"open_id": openID,
|
||||
"content": "我要退款",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
timestamp, signature, err := handlers.SignWebhookRequest("sub2api-ingress-secret", time.Now().Unix(), body)
|
||||
if err != nil {
|
||||
t.Fatalf("SignWebhookRequest() error = %v", err)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/customer-service/platforms/sub2api/webhook", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("new request error = %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-CS-Timestamp", timestamp)
|
||||
req.Header.Set("X-CS-Signature", signature)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do request error = %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var ack map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ack); err != nil {
|
||||
t.Fatalf("decode ack failed: %v", err)
|
||||
}
|
||||
sessionID, _ := ack["session_id"].(string)
|
||||
if sessionID == "" {
|
||||
t.Fatalf("ack session_id = %v, want non-empty", ack["session_id"])
|
||||
}
|
||||
|
||||
eventually(t, 8*time.Second, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
count := 0
|
||||
for _, event := range received {
|
||||
if event.SessionID == sessionID {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count == 6
|
||||
})
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
filtered := make([]platformevent.Event, 0, 6)
|
||||
for _, event := range received {
|
||||
if event.SessionID == sessionID {
|
||||
filtered = append(filtered, event)
|
||||
}
|
||||
}
|
||||
wantTypes := []string{
|
||||
platformevent.TypeMessageReceived,
|
||||
platformevent.TypeMessageProcessing,
|
||||
platformevent.TypeIntentResolved,
|
||||
platformevent.TypeHandoffTriggered,
|
||||
platformevent.TypeTicketCreated,
|
||||
platformevent.TypeReplyGenerated,
|
||||
}
|
||||
seenIDs := make(map[string]struct{}, len(filtered))
|
||||
for i, event := range filtered {
|
||||
if event.EventType != wantTypes[i] {
|
||||
t.Fatalf("event[%d].type = %s, want %s", i, event.EventType, wantTypes[i])
|
||||
}
|
||||
if event.ID == "" {
|
||||
t.Fatalf("event[%d] id is empty", i)
|
||||
}
|
||||
if _, exists := seenIDs[event.ID]; exists {
|
||||
t.Fatalf("duplicate event id: %s", event.ID)
|
||||
}
|
||||
seenIDs[event.ID] = struct{}{}
|
||||
}
|
||||
|
||||
var deliveredCount int
|
||||
if err := db.QueryRowContext(context.Background(), `
|
||||
SELECT COUNT(1)
|
||||
FROM cs_platform_event_outbox
|
||||
WHERE platform = 'sub2api' AND status = 'delivered' AND session_id IN (
|
||||
SELECT id FROM cs_sessions WHERE channel = 'sub2api' AND open_id = $1
|
||||
)
|
||||
`, openID).Scan(&deliveredCount); err != nil {
|
||||
t.Fatalf("query delivered count failed: %v", err)
|
||||
}
|
||||
if deliveredCount != 6 {
|
||||
t.Fatalf("delivered count = %d, want 6", deliveredCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSub2APICallbackFlow_ShouldDeadLetterAfterMaxRetries(t *testing.T) {
|
||||
db := openE2EPlatformDB(t)
|
||||
defer db.Close()
|
||||
|
||||
callbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte(`{"error":"upstream"}`))
|
||||
}))
|
||||
defer callbackServer.Close()
|
||||
|
||||
application := newSub2APIE2EApp(t, callbackServer.URL, "sub2api-callback-secret", 1)
|
||||
server := httptest.NewServer(application.Server.Handler)
|
||||
defer server.Close()
|
||||
|
||||
openID := "sub2api-dead-" + time.Now().UTC().Format("150405.000000000")
|
||||
payload := map[string]any{
|
||||
"message_id": "m-dead-" + time.Now().UTC().Format("150405.000000000"),
|
||||
"channel": "sub2api",
|
||||
"open_id": openID,
|
||||
"content": "晚上好",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
timestamp, signature, err := handlers.SignWebhookRequest("sub2api-ingress-secret", time.Now().Unix(), body)
|
||||
if err != nil {
|
||||
t.Fatalf("SignWebhookRequest() error = %v", err)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/customer-service/platforms/sub2api/webhook", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("new request error = %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-CS-Timestamp", timestamp)
|
||||
req.Header.Set("X-CS-Signature", signature)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do request error = %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
eventually(t, 8*time.Second, func() bool {
|
||||
var deadCount int
|
||||
err := db.QueryRowContext(context.Background(), `
|
||||
SELECT COUNT(1)
|
||||
FROM cs_platform_event_dead_letters dl
|
||||
JOIN cs_platform_event_outbox o ON o.id = dl.event_id
|
||||
WHERE o.platform = 'sub2api' AND o.session_id IN (
|
||||
SELECT id FROM cs_sessions WHERE channel = 'sub2api' AND open_id = $1
|
||||
)
|
||||
`, openID).Scan(&deadCount)
|
||||
return err == nil && deadCount == 4
|
||||
})
|
||||
}
|
||||
162
test/integration/sub2api_webhook_flow_test.go
Normal file
162
test/integration/sub2api_webhook_flow_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bridge/ai-customer-service/internal/app"
|
||||
"github.com/bridge/ai-customer-service/internal/config"
|
||||
"github.com/bridge/ai-customer-service/internal/http/handlers"
|
||||
"github.com/bridge/ai-customer-service/internal/platform/logging"
|
||||
pgstore "github.com/bridge/ai-customer-service/internal/store/postgres"
|
||||
)
|
||||
|
||||
func platformTestDSN() string {
|
||||
return "host=localhost port=5434 user=ai_cs password=ai_cs_secret dbname=ai_customer_service sslmode=disable"
|
||||
}
|
||||
|
||||
func openPlatformTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := pgstore.Open(pgstore.Config{
|
||||
DSN: platformTestDSN(),
|
||||
MaxOpenConns: 5,
|
||||
MaxIdleConns: 2,
|
||||
ConnMaxLifetime: 30 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open postgres failed: %v", err)
|
||||
}
|
||||
if err := pgstore.RunMigrations(db, "../../db/migration"); err != nil {
|
||||
_ = db.Close()
|
||||
t.Fatalf("run migrations failed: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func newSub2APIIntegrationApp(t *testing.T) *app.App {
|
||||
t.Helper()
|
||||
cfg := &config.Config{}
|
||||
cfg.HTTP.Addr = ":0"
|
||||
cfg.HTTP.ReadHeaderTimeout = 5
|
||||
cfg.HTTP.ReadTimeout = 10
|
||||
cfg.HTTP.WriteTimeout = 15
|
||||
cfg.HTTP.IdleTimeout = 60
|
||||
cfg.HTTP.MaxHeaderBytes = 1 << 20
|
||||
cfg.HTTP.MaxBodyBytes = 1 << 20
|
||||
cfg.Runtime.Env = "test"
|
||||
cfg.Postgres.Enabled = true
|
||||
cfg.Postgres.DSN = platformTestDSN()
|
||||
cfg.Postgres.MigrationDir = "../../db/migration"
|
||||
cfg.Postgres.MaxOpenConns = 5
|
||||
cfg.Postgres.MaxIdleConns = 2
|
||||
cfg.Postgres.ConnMaxLifetime = 30
|
||||
cfg.Webhook.Secret = "default-webhook-secret"
|
||||
cfg.Webhook.TimestampHeader = "X-CS-Timestamp"
|
||||
cfg.Webhook.SignatureHeader = "X-CS-Signature"
|
||||
cfg.Webhook.MaxSkewSeconds = 300
|
||||
cfg.PlatformAdapters.Enabled = true
|
||||
cfg.PlatformAdapters.Sub2API.Enabled = true
|
||||
cfg.PlatformAdapters.Sub2API.IngressSecret = "sub2api-ingress-secret"
|
||||
|
||||
application, err := app.New(cfg, logging.New())
|
||||
if err != nil {
|
||||
t.Fatalf("app.New() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = application.Shutdown(context.Background())
|
||||
})
|
||||
return application
|
||||
}
|
||||
|
||||
func TestSub2APIWebhookFlow_ShouldCreateSessionTicketAndOutboxEvents(t *testing.T) {
|
||||
db := openPlatformTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
application := newSub2APIIntegrationApp(t)
|
||||
server := httptest.NewServer(application.Server.Handler)
|
||||
defer server.Close()
|
||||
|
||||
openID := "sub2api-intg-" + time.Now().UTC().Format("150405.000000000")
|
||||
payload := map[string]any{
|
||||
"message_id": "m-intg-" + time.Now().UTC().Format("150405.000000000"),
|
||||
"channel": "sub2api",
|
||||
"open_id": openID,
|
||||
"content": "我要退款",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
timestamp, signature, err := handlers.SignWebhookRequest("sub2api-ingress-secret", time.Now().Unix(), body)
|
||||
if err != nil {
|
||||
t.Fatalf("SignWebhookRequest() error = %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/customer-service/platforms/sub2api/webhook", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("new request error = %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-CS-Timestamp", timestamp)
|
||||
req.Header.Set("X-CS-Signature", signature)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do request error = %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
var ack map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ack); err != nil {
|
||||
t.Fatalf("decode ack error = %v", err)
|
||||
}
|
||||
sessionID, _ := ack["session_id"].(string)
|
||||
ticketID, _ := ack["ticket_id"].(string)
|
||||
if sessionID == "" || ticketID == "" {
|
||||
t.Fatalf("ack session_id=%v ticket_id=%v, want both non-empty", ack["session_id"], ack["ticket_id"])
|
||||
}
|
||||
|
||||
var storedSessionID string
|
||||
if err := db.QueryRowContext(context.Background(), `
|
||||
SELECT id
|
||||
FROM cs_sessions
|
||||
WHERE channel = 'sub2api' AND open_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, openID).Scan(&storedSessionID); err != nil {
|
||||
t.Fatalf("query session failed: %v", err)
|
||||
}
|
||||
if storedSessionID != sessionID {
|
||||
t.Fatalf("stored session id = %s, want %s", storedSessionID, sessionID)
|
||||
}
|
||||
|
||||
var storedTicketID string
|
||||
if err := db.QueryRowContext(context.Background(), `
|
||||
SELECT id
|
||||
FROM cs_tickets
|
||||
WHERE id = $1 AND session_id = $2
|
||||
`, ticketID, sessionID).Scan(&storedTicketID); err != nil {
|
||||
t.Fatalf("query ticket failed: %v", err)
|
||||
}
|
||||
if storedTicketID != ticketID {
|
||||
t.Fatalf("stored ticket id = %s, want %s", storedTicketID, ticketID)
|
||||
}
|
||||
|
||||
var outboxCount int
|
||||
if err := db.QueryRowContext(context.Background(), `
|
||||
SELECT COUNT(1)
|
||||
FROM cs_platform_event_outbox
|
||||
WHERE session_id = $1 AND platform = 'sub2api'
|
||||
`, sessionID).Scan(&outboxCount); err != nil {
|
||||
t.Fatalf("query outbox count failed: %v", err)
|
||||
}
|
||||
if outboxCount != 6 {
|
||||
t.Fatalf("outbox count = %d, want 6", outboxCount)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user