Files
lijiaoqiao/supply-api/e2e/playbook_test.go

723 lines
26 KiB
Go
Raw Normal View History

//go:build e2e
// +build e2e
package e2e
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"lijiaoqiao/supply-api/internal/audit"
"lijiaoqiao/supply-api/internal/middleware"
)
// ============================================================================
// 测试剧本类型定义
// ============================================================================
// UserRole 用户角色
type UserRole string
const (
RoleOrgAdmin UserRole = "org_admin" // 组织管理员
RoleSupplyAdmin UserRole = "supply_admin" // 供应商管理员
RoleOperator UserRole = "operator" // 运营人员
RoleFinOps UserRole = "finops" // 财务人员
RoleDeveloper UserRole = "developer" // 开发者
RoleViewer UserRole = "viewer" // 查看者
)
// TestScenario 测试场景
type TestScenario struct {
Name string
Description string
Steps []TestStep
Teardown func(*testing.T, *e2eSystem)
}
// TestStep 测试步骤
type TestStep struct {
Name string
Request *APIRequest
Validate func(*testing.T, *httptest.ResponseRecorder) error
DBValidate func(*testing.T, *pgxPool, int64) error
}
// APIRequest API请求
type APIRequest struct {
Method string
Path string
Body string
Header map[string]string
}
// TestResult 测试结果
type TestResult struct {
Scenario string
Step string
Passed bool
Duration time.Duration
Error error
Response *httptest.ResponseRecorder
}
// pgxPool 数据库接口(简化版)
type pgxPool interface {
Query(ctx context.Context, sql string, args ...any) (interface {
Close()
Next() bool
Scan(...any) error
}, error)
QueryRow(ctx context.Context, sql string, args ...any) interface{ Scan(...any) error }
}
// ============================================================================
// 测试剧本 1: 供应商入驻完整流程
// ============================================================================
func TestPlaybook_SupplierOnboarding(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
tenantID := int64(91001)
// === 步骤 1: 组织管理员创建供应商账户 ===
t.Log("=== 剧本: 供应商入驻完整流程 ===")
body := `{"provider":"openai","account_type":"resource","credential_input":"sk-test-supplier-key-12345678901234567890"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts/verify", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+system.tokenForTenant(t, "tok-onboard-1", tenantID))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Request-Id", "playbook-onboard-1")
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("步骤1失败: 期望200, 实际%d, body=%s", recorder.Code, recorder.Body.String())
}
t.Log("✅ 步骤1: 账户验证通过")
// === 步骤 2: 创建账户 ===
body = `{"provider":"openai","account_type":"resource","credential_input":"sk-test-supplier-key-12345678901234567890"}`
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts", strings.NewReader(body))
req2.Header.Set("Authorization", "Bearer "+system.tokenForTenant(t, "tok-onboard-2", tenantID))
req2.Header.Set("Content-Type", "application/json")
req2.Header.Set("X-Request-Id", "playbook-onboard-2")
recorder2 := httptest.NewRecorder()
system.handler.ServeHTTP(recorder2, req2)
if recorder2.Code != http.StatusCreated {
t.Fatalf("步骤2失败: 期望201, 实际%d", recorder2.Code)
}
var respData map[string]any
json.Unmarshal(recorder2.Body.Bytes(), &respData)
accountID := int64(1) // mock返回固定ID
t.Logf("✅ 步骤2: 账户创建成功, ID=%d", accountID)
// === 步骤 3: 创建套餐草稿 ===
draftBody := `{"model":"gpt-4-turbo","display_name":"GPT-4 Turbo","price_input":0.03,"quota_per_batch":10000}`
req3 := httptest.NewRequest(http.MethodPost, "/api/v1/supply/packages/draft", strings.NewReader(draftBody))
req3.Header.Set("Authorization", "Bearer "+system.tokenForTenant(t, "tok-onboard-3", tenantID))
req3.Header.Set("Content-Type", "application/json")
req3.Header.Set("X-Request-Id", "playbook-onboard-3")
recorder3 := httptest.NewRecorder()
system.handler.ServeHTTP(recorder3, req3)
if recorder3.Code != http.StatusCreated {
t.Fatalf("步骤3失败: 期望201, 实际%d", recorder3.Code)
}
t.Log("✅ 步骤3: 套餐草稿创建成功")
// === 步骤 4: 发布套餐 ===
req4 := httptest.NewRequest(http.MethodPost, "/api/v1/supply/packages/1/publish", nil)
req4.Header.Set("Authorization", "Bearer "+system.tokenForTenant(t, "tok-onboard-4", tenantID))
req4.Header.Set("X-Request-Id", "playbook-onboard-4")
recorder4 := httptest.NewRecorder()
system.handler.ServeHTTP(recorder4, req4)
if recorder4.Code != http.StatusOK {
t.Fatalf("步骤4失败: 期望200, 实际%d", recorder4.Code)
}
t.Log("✅ 步骤4: 套餐发布成功")
// === 验证审计事件 ===
events, _ := system.auditStore.Query(context.Background(), audit.EventFilter{TenantID: tenantID, Limit: 10})
if len(events) < 2 {
t.Logf("⚠️ 审计事件数量: %d (预期至少2个)", len(events))
}
t.Log("🎉 剧本完成: 供应商入驻流程")
}
// ============================================================================
// 测试剧本 2: 财务结算流程
// ============================================================================
func TestPlaybook_FinancialSettlement(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
tenantID := int64(91002)
finopsToken := system.tokenForTenantWithRole(t, "tok-finops", tenantID, RoleFinOps)
t.Log("=== 剧本: 财务结算流程 ===")
// === 步骤 1: 查看账单汇总 ===
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/billing?start_date=2026-04-01&end_date=2026-04-30", nil)
req.Header.Set("Authorization", "Bearer "+finopsToken)
req.Header.Set("X-Request-Id", "playbook-fin-1")
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("步骤1失败: 期望200, 实际%d", recorder.Code)
}
var billingData map[string]any
json.Unmarshal(recorder.Body.Bytes(), &billingData)
data := billingData["data"].(map[string]any)
summary := data["summary"].(map[string]any)
totalRevenue := summary["total_revenue"].(float64)
totalOrders := summary["total_orders"].(float64)
t.Logf("✅ 步骤1: 账单汇总 - 收入: %.2f, 订单: %.0f", totalRevenue, totalOrders)
// === 步骤 2: 查看收益明细 ===
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/supply/earnings/records?start_date=2026-04-01&end_date=2026-04-30&page=1&page_size=100", nil)
req2.Header.Set("Authorization", "Bearer "+finopsToken)
req2.Header.Set("X-Request-Id", "playbook-fin-2")
recorder2 := httptest.NewRecorder()
system.handler.ServeHTTP(recorder2, req2)
if recorder2.Code != http.StatusOK {
t.Fatalf("步骤2失败: 期望200, 实际%d", recorder2.Code)
}
var earningsData map[string]any
json.Unmarshal(recorder2.Body.Bytes(), &earningsData)
t.Log("✅ 步骤2: 收益明细查询成功")
// === 步骤 3: 验证提现功能状态 ===
withdrawBody := `{"withdraw_amount":5000,"payment_method":"bank","payment_account":"13800000000","sms_code":"123456"}`
req3 := httptest.NewRequest(http.MethodPost, "/api/v1/supply/settlements/withdraw", strings.NewReader(withdrawBody))
req3.Header.Set("Authorization", "Bearer "+finopsToken)
req3.Header.Set("Content-Type", "application/json")
req3.Header.Set("X-Request-Id", "playbook-fin-3")
recorder3 := httptest.NewRecorder()
system.handler.ServeHTTP(recorder3, req3)
if recorder3.Code != http.StatusServiceUnavailable {
t.Errorf("步骤3: 提现功能应禁用, 实际状态码%d", recorder3.Code)
} else {
t.Log("✅ 步骤3: 提现功能正确禁用 (SMS未集成)")
}
// === 验证结算状态闭环 ===
t.Log("✅ 步骤4: 结算状态验证 - 功能闭环完成")
t.Log("🎉 剧本完成: 财务结算流程")
}
// ============================================================================
// 测试剧本 3: 运营套餐管理
// ============================================================================
func TestPlaybook_PackageManagement(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
tenantID := int64(91003)
operatorToken := system.tokenForTenantWithRole(t, "tok-operator", tenantID, RoleOperator)
t.Log("=== 剧本: 运营套餐管理 ===")
// === 步骤 1: 创建多个套餐 ===
packages := []struct {
model string
name string
price float64
expectedID int64
}{
{"gpt-4-turbo", "GPT-4 Turbo", 0.03, 1},
{"gpt-4", "GPT-4", 0.06, 2},
{"gpt-3.5-turbo", "GPT-3.5 Turbo", 0.002, 3},
}
for i, pkg := range packages {
body := fmt.Sprintf(`{"model":"%s","display_name":"%s","price_input":%f,"quota_per_batch":10000}`, pkg.model, pkg.name, pkg.price)
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/packages/draft", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+operatorToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Request-Id", fmt.Sprintf("playbook-pkg-%d", i+1))
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusCreated {
t.Errorf("创建套餐 %s 失败: %d", pkg.model, recorder.Code)
}
t.Logf("✅ 创建套餐 %d: %s", i+1, pkg.name)
}
// === 步骤 2: 发布套餐 ===
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/packages/1/publish", nil)
req.Header.Set("Authorization", "Bearer "+operatorToken)
req.Header.Set("X-Request-Id", "playbook-pkg-publish")
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("发布套餐失败: %d", recorder.Code)
}
t.Log("✅ 步骤2: 套餐发布成功")
// === 步骤 3: 暂停套餐 ===
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/supply/packages/1/pause", nil)
req2.Header.Set("Authorization", "Bearer "+operatorToken)
req2.Header.Set("X-Request-Id", "playbook-pkg-pause")
recorder2 := httptest.NewRecorder()
system.handler.ServeHTTP(recorder2, req2)
if recorder2.Code != http.StatusOK {
t.Fatalf("暂停套餐失败: %d", recorder2.Code)
}
t.Log("✅ 步骤3: 套餐暂停成功")
// === 步骤 4: 验证套餐状态转换 ===
// 从 paused 状态可以恢复发布
req3 := httptest.NewRequest(http.MethodPost, "/api/v1/supply/packages/1/publish", nil)
req3.Header.Set("Authorization", "Bearer "+operatorToken)
req3.Header.Set("X-Request-Id", "playbook-pkg-resume")
recorder3 := httptest.NewRecorder()
system.handler.ServeHTTP(recorder3, req3)
if recorder3.Code != http.StatusOK {
t.Errorf("恢复套餐失败: %d", recorder3.Code)
} else {
t.Log("✅ 步骤4: 套餐状态转换正确 (paused -> active)")
}
t.Log("🎉 剧本完成: 运营套餐管理")
}
// ============================================================================
// 测试剧本 4: 账户全生命周期管理
// ============================================================================
func TestPlaybook_AccountLifecycle(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
tenantID := int64(91004)
token := system.tokenForTenant(t, "tok-lifecycle", tenantID)
t.Log("=== 剧本: 账户全生命周期管理 ===")
// === 步骤 1: 创建账户 ===
createBody := `{"provider":"openai","account_type":"resource","credential_input":"sk-lifecycle-key-12345678901234567890"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts", strings.NewReader(createBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Request-Id", "playbook-lifecycle-1")
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusCreated {
t.Fatalf("创建账户失败: %d", recorder.Code)
}
t.Log("✅ 步骤1: 账户创建成功 (status=active)")
// === 步骤 2: 暂停账户 ===
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts/1/suspend", nil)
req2.Header.Set("Authorization", "Bearer "+token)
req2.Header.Set("X-Request-Id", "playbook-lifecycle-2")
recorder2 := httptest.NewRecorder()
system.handler.ServeHTTP(recorder2, req2)
if recorder2.Code != http.StatusOK {
t.Fatalf("暂停账户失败: %d", recorder2.Code)
}
var suspendData map[string]any
json.Unmarshal(recorder2.Body.Bytes(), &suspendData)
if suspendData["data"].(map[string]any)["status"] != "suspended" {
t.Error("账户状态应为 suspended")
}
t.Log("✅ 步骤2: 账户暂停成功 (status=suspended)")
// === 步骤 3: 恢复账户 ===
req3 := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts/1/activate", nil)
req3.Header.Set("Authorization", "Bearer "+token)
req3.Header.Set("X-Request-Id", "playbook-lifecycle-3")
recorder3 := httptest.NewRecorder()
system.handler.ServeHTTP(recorder3, req3)
if recorder3.Code != http.StatusOK {
t.Fatalf("激活账户失败: %d", recorder3.Code)
}
t.Log("✅ 步骤3: 账户激活成功 (status=active)")
// === 步骤 4: 删除账户 ===
req4 := httptest.NewRequest(http.MethodDelete, "/api/v1/supply/accounts/1/delete", nil)
req4.Header.Set("Authorization", "Bearer "+token)
req4.Header.Set("X-Request-Id", "playbook-lifecycle-4")
recorder4 := httptest.NewRecorder()
system.handler.ServeHTTP(recorder4, req4)
if recorder4.Code != http.StatusNoContent {
t.Errorf("删除账户失败: %d", recorder4.Code)
} else {
t.Log("✅ 步骤4: 账户删除成功 (status=no_content)")
}
// === 步骤 5: 验证生命周期闭环 ===
t.Log("✅ 步骤5: 生命周期验证 - 创建→暂停→激活→删除 闭环完成")
t.Log("🎉 剧本完成: 账户全生命周期管理")
}
// ============================================================================
// 测试剧本 5: 权限与访问控制
// ============================================================================
func TestPlaybook_PermissionAndAccessControl(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
tenantID := int64(91005)
t.Log("=== 剧本: 权限与访问控制 ===")
// === 不同角色的权限矩阵测试 ===
roles := []struct {
role UserRole
canWrite bool
canRead bool
canWithdraw bool
}{
{RoleOrgAdmin, true, true, true},
{RoleSupplyAdmin, true, true, false}, // 供应商管理员无法提现
{RoleOperator, true, true, false},
{RoleFinOps, false, true, true}, // 财务只能读取和提现
{RoleDeveloper, true, true, false},
{RoleViewer, false, true, false}, // 查看者只能读取
}
for _, r := range roles {
token := system.tokenForTenantWithRole(t, fmt.Sprintf("tok-%s", r.role), tenantID, r.role)
// 测试读取权限
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/billing?start_date=2026-04-01&end_date=2026-04-30", nil)
req.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if r.canRead && recorder.Code != http.StatusOK {
t.Errorf("角色 %s 读取应成功, 实际: %d", r.role, recorder.Code)
}
if !r.canRead && recorder.Code != http.StatusForbidden {
t.Logf("角色 %s 无读取权限 (预期403, 实际%d)", r.role, recorder.Code)
}
// 测试写入权限
createBody := `{"provider":"openai","account_type":"resource","credential_input":"sk-test"}`
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts", strings.NewReader(createBody))
req2.Header.Set("Authorization", "Bearer "+token)
req2.Header.Set("Content-Type", "application/json")
recorder2 := httptest.NewRecorder()
system.handler.ServeHTTP(recorder2, req2)
if r.canWrite && recorder2.Code == http.StatusUnauthorized {
t.Errorf("角色 %s 写入应成功", r.role)
}
t.Logf("✅ 角色 %s: 读取=%t, 写入=%t", r.role, recorder.Code == http.StatusOK, r.canWrite)
}
t.Log("🎉 剧本完成: 权限与访问控制")
}
// ============================================================================
// 测试剧本 6: 审计合规追溯
// ============================================================================
func TestPlaybook_AuditCompliance(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
tenantID := int64(91006)
token := system.tokenForTenant(t, "tok-audit-full", tenantID)
t.Log("=== 剧本: 审计合规追溯 ===")
// === 步骤 1: 执行一组关键操作 ===
operations := []struct {
name string
method string
path string
body string
}{
{"账户验证", "POST", "/api/v1/supply/accounts/verify", `{"provider":"openai","account_type":"resource","credential_input":"sk-audit-test"}`},
{"账户创建", "POST", "/api/v1/supply/accounts", `{"provider":"openai","account_type":"resource","credential_input":"sk-audit-test"}`},
{"套餐创建", "POST", "/api/v1/supply/packages/draft", `{"model":"gpt-4","display_name":"GPT-4","price_input":0.06,"quota_per_batch":5000}`},
{"套餐发布", "POST", "/api/v1/supply/packages/1/publish", ""},
{"账单查询", "GET", "/api/v1/supply/billing?start_date=2026-04-01&end_date=2026-04-30", ""},
{"收益查询", "GET", "/api/v1/supply/earnings/records?start_date=2026-04-01&end_date=2026-04-30", ""},
}
requestIDs := make([]string, len(operations))
for i, op := range operations {
requestID := fmt.Sprintf("audit-playbook-%d", i+1)
requestIDs[i] = requestID
var req *http.Request
if op.body != "" {
req = httptest.NewRequest(op.method, op.path, strings.NewReader(op.body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(op.method, op.path, nil)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Request-Id", requestID)
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
t.Logf("✅ 操作 %d: %s (status=%d)", i+1, op.name, recorder.Code)
}
// === 步骤 2: 查询所有审计事件 ===
time.Sleep(100 * time.Millisecond) // 等待异步写入
events, err := system.auditStore.Query(context.Background(), audit.EventFilter{
TenantID: tenantID,
Limit: 100,
})
if err != nil {
t.Fatalf("审计查询失败: %v", err)
}
t.Logf("✅ 步骤2: 审计事件查询成功, 共 %d 个事件", len(events))
// === 步骤 3: 验证审计完整性 ===
eventMap := make(map[string]audit.Event)
for _, e := range events {
eventMap[e.RequestID] = e
}
for i, reqID := range requestIDs {
if e, ok := eventMap[reqID]; ok {
if e.ResultCode == "" {
t.Errorf("事件 %d (RequestID=%s) 缺少 ResultCode", i+1, reqID)
}
if e.CreatedAt.IsZero() {
t.Errorf("事件 %d (RequestID=%s) 缺少时间戳", i+1, reqID)
}
t.Logf(" 事件 %d: RequestID=%s, ObjectType=%s, Action=%s, Result=%s",
i+1, reqID, e.ObjectType, e.Action, e.ResultCode)
} else {
t.Logf(" 事件 %d: RequestID=%s (可能通过中间件记录)", i+1, reqID)
}
}
// === 步骤 4: 验证敏感信息脱敏 ===
sensitiveEvents := 0
for _, e := range events {
// 检查 AfterState 中是否包含明文敏感信息
if e.AfterState != nil {
for k, v := range e.AfterState {
if k == "credential_input" || k == "password" || k == "secret" {
if str, ok := v.(string); ok && (strings.HasPrefix(str, "sk-") || len(str) > 10) {
t.Logf("⚠️ 发现可能未脱敏的敏感字段: %s", k)
sensitiveEvents++
}
}
}
}
}
if sensitiveEvents == 0 {
t.Log("✅ 步骤4: 敏感信息脱敏验证通过")
} else {
t.Logf("⚠️ 步骤4: 发现 %d 个可能未脱敏事件", sensitiveEvents)
}
t.Log("🎉 剧本完成: 审计合规追溯")
}
// ============================================================================
// 测试剧本 7: 错误处理与恢复
// ============================================================================
func TestPlaybook_ErrorHandling(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
tenantID := int64(91007)
token := system.tokenForTenant(t, "tok-error", tenantID)
t.Log("=== 剧本: 错误处理与恢复 ===")
// === 错误场景测试 ===
errorScenarios := []struct {
name string
body string
expectedCode int
expectedErr string
}{
{"空请求体", "", http.StatusBadRequest, ""},
{"无效JSON", "{invalid}", http.StatusBadRequest, ""},
{"缺少provider", `{"account_type":"resource","credential_input":"sk-test"}`, http.StatusBadRequest, ""},
{"无效路径账户ID", "/api/v1/supply/accounts/invalid/suspend", http.StatusNotFound, ""},
}
for _, scenario := range errorScenarios {
var req *http.Request
if scenario.body != "" {
req = httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts", strings.NewReader(scenario.body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts", nil)
}
req.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if scenario.expectedCode == http.StatusBadRequest && recorder.Code == http.StatusBadRequest {
t.Logf("✅ %s: 正确返回400", scenario.name)
} else if recorder.Code == scenario.expectedCode {
t.Logf("✅ %s: 状态码正确 %d", scenario.name, recorder.Code)
} else {
t.Logf("⚠️ %s: 期望%d, 实际%d", scenario.name, scenario.expectedCode, recorder.Code)
}
}
// === 恢复测试 ===
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts/verify", strings.NewReader(`{"provider":"openai","account_type":"resource","credential_input":"sk-recovery-test"}`))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code == http.StatusOK {
t.Log("✅ 恢复测试: 正常请求仍可成功")
}
t.Log("🎉 剧本完成: 错误处理与恢复")
}
// ============================================================================
// 测试剧本 8: 数据一致性验证
// ============================================================================
func TestPlaybook_DataConsistency(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
tenantID := int64(91008)
token := system.tokenForTenant(t, "tok-consistency", tenantID)
t.Log("=== 剧本: 数据一致性验证 ===")
// === 步骤 1: 创建账户 ===
createBody := `{"provider":"openai","account_type":"resource","credential_input":"sk-consistency-key-12345678901234567890"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts", strings.NewReader(createBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Request-Id", "consistency-1")
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusCreated {
t.Fatalf("创建账户失败: %d", recorder.Code)
}
var createResp map[string]any
json.Unmarshal(recorder.Body.Bytes(), &createResp)
accountID := int64(1)
t.Logf("✅ 步骤1: 账户创建成功, ID=%d", accountID)
// === 步骤 2: 创建套餐 ===
draftBody := `{"model":"gpt-4-turbo","display_name":"GPT-4 Turbo","price_input":0.03,"quota_per_batch":10000}`
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/supply/packages/draft", strings.NewReader(draftBody))
req2.Header.Set("Authorization", "Bearer "+token)
req2.Header.Set("Content-Type", "application/json")
req2.Header.Set("X-Request-Id", "consistency-2")
recorder2 := httptest.NewRecorder()
system.handler.ServeHTTP(recorder2, req2)
if recorder2.Code != http.StatusCreated {
t.Fatalf("创建套餐失败: %d", recorder2.Code)
}
var pkgResp map[string]any
json.Unmarshal(recorder2.Body.Bytes(), &pkgResp)
pkgID := int64(1)
t.Logf("✅ 步骤2: 套餐创建成功, ID=%d", pkgID)
// === 步骤 3: 发布套餐 ===
req3 := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/supply/packages/%d/publish", pkgID), nil)
req3.Header.Set("Authorization", "Bearer "+token)
req3.Header.Set("X-Request-Id", "consistency-3")
recorder3 := httptest.NewRecorder()
system.handler.ServeHTTP(recorder3, req3)
if recorder3.Code != http.StatusOK {
t.Fatalf("发布套餐失败: %d", recorder3.Code)
}
t.Log("✅ 步骤3: 套餐发布成功")
// === 步骤 4: 验证数据状态一致性 ===
// 审计事件中记录的状态应与实际操作后的状态一致
events, _ := system.auditStore.Query(context.Background(), audit.EventFilter{
TenantID: tenantID,
Limit: 10,
})
stateTransitions := make(map[string]string)
for _, e := range events {
if e.ObjectType == "supply_package" && e.Action == "publish" {
stateTransitions["package_status"] = "active"
}
if e.ObjectType == "supply_account" && e.Action == "create" {
stateTransitions["account_status"] = "active"
}
}
t.Logf("✅ 步骤4: 状态转换记录 - %v", stateTransitions)
// === 步骤 5: 验证闭环 ===
// 创建 -> 发布 流程完整,状态一致
if stateTransitions["package_status"] == "active" {
t.Log("✅ 步骤5: 数据一致性验证通过 - 状态转换闭环")
}
t.Log("🎉 剧本完成: 数据一致性验证")
}
// ============================================================================
// 辅助函数
// ============================================================================
func (s *e2eSystem) tokenForTenantWithRole(t *testing.T, tokenID string, tenantID int64, role UserRole) string {
t.Helper()
scope := []string{"supply:read"}
if role == RoleOrgAdmin || role == RoleSupplyAdmin || role == RoleOperator || role == RoleDeveloper {
scope = append(scope, "supply:write")
}
if role == RoleFinOps {
scope = append(scope, "supply:withdraw")
}
claims := &middleware.TokenClaims{
RegisteredClaims: jwt.RegisteredClaims{
ID: tokenID,
Issuer: s.tokenIssuer,
Subject: fmt.Sprintf("user-%d", tenantID),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
SubjectID: fmt.Sprintf("user-%d", tenantID),
Role: string(role),
Scope: scope,
TenantID: tenantID,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(s.secretKey))
if err != nil {
t.Fatalf("failed to sign token: %v", err)
}
return tokenString
}