openai_live_lifecycle_test.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "sync"
  11. "testing"
  12. "time"
  13. "github.com/Wei-Shaw/sub2api/internal/config"
  14. coderws "github.com/coder/websocket"
  15. "github.com/stretchr/testify/require"
  16. )
  17. type liveTestFrame struct {
  18. messageType coderws.MessageType
  19. payload []byte
  20. err error
  21. }
  22. type liveTestFrameConn struct {
  23. reads chan liveTestFrame
  24. writes chan liveTestFrame
  25. closed chan struct{}
  26. closeOnce sync.Once
  27. }
  28. func newLiveTestFrameConn() *liveTestFrameConn {
  29. return &liveTestFrameConn{
  30. reads: make(chan liveTestFrame, 8),
  31. writes: make(chan liveTestFrame, 8),
  32. closed: make(chan struct{}),
  33. }
  34. }
  35. func (c *liveTestFrameConn) ReadFrame(ctx context.Context) (coderws.MessageType, []byte, error) {
  36. select {
  37. case frame := <-c.reads:
  38. return frame.messageType, frame.payload, frame.err
  39. case <-c.closed:
  40. return coderws.MessageText, nil, coderws.CloseError{Code: coderws.StatusNormalClosure}
  41. case <-ctx.Done():
  42. return coderws.MessageText, nil, context.Cause(ctx)
  43. }
  44. }
  45. func (c *liveTestFrameConn) WriteFrame(ctx context.Context, messageType coderws.MessageType, payload []byte) error {
  46. frame := liveTestFrame{messageType: messageType, payload: append([]byte(nil), payload...)}
  47. select {
  48. case c.writes <- frame:
  49. return nil
  50. case <-c.closed:
  51. return errors.New("connection closed")
  52. case <-ctx.Done():
  53. return context.Cause(ctx)
  54. }
  55. }
  56. func (c *liveTestFrameConn) WriteJSON(ctx context.Context, value any) error {
  57. payload, err := json.Marshal(value)
  58. if err != nil {
  59. return err
  60. }
  61. return c.WriteFrame(ctx, coderws.MessageText, payload)
  62. }
  63. func (c *liveTestFrameConn) ReadMessage(ctx context.Context) ([]byte, error) {
  64. _, payload, err := c.ReadFrame(ctx)
  65. return payload, err
  66. }
  67. func (c *liveTestFrameConn) Ping(context.Context) error { return nil }
  68. func (c *liveTestFrameConn) Close() error {
  69. c.closeOnce.Do(func() { close(c.closed) })
  70. return nil
  71. }
  72. type liveTestDialer struct {
  73. conn *liveTestFrameConn
  74. url string
  75. headers http.Header
  76. }
  77. func (d *liveTestDialer) Dial(
  78. _ context.Context,
  79. wsURL string,
  80. headers http.Header,
  81. _ string,
  82. ) (openAIWSClientConn, int, http.Header, error) {
  83. d.url = wsURL
  84. d.headers = headers.Clone()
  85. return d.conn, http.StatusSwitchingProtocols, nil, nil
  86. }
  87. type liveTestAccountRepo struct {
  88. AccountRepository
  89. account *Account
  90. }
  91. func (r *liveTestAccountRepo) GetByID(context.Context, int64) (*Account, error) {
  92. return r.account, nil
  93. }
  94. type liveTestStore struct {
  95. GatewayCache
  96. mu sync.Mutex
  97. record *LiveCallRecord
  98. // 注入 store 故障(模拟 Redis 抖动),区别于 ErrLiveCallNotFound。
  99. claimErr error
  100. getCallErr error
  101. getControllerErr error
  102. }
  103. func (s *liveTestStore) SaveLiveCall(_ context.Context, record *LiveCallRecord, _ time.Duration) error {
  104. s.mu.Lock()
  105. defer s.mu.Unlock()
  106. copy := *record
  107. s.record = &copy
  108. return nil
  109. }
  110. func (s *liveTestStore) GetLiveCall(_ context.Context, callHash string) (*LiveCallRecord, error) {
  111. s.mu.Lock()
  112. defer s.mu.Unlock()
  113. if s.getCallErr != nil {
  114. return nil, s.getCallErr
  115. }
  116. if s.record == nil || s.record.CallHash != callHash {
  117. return nil, ErrLiveCallNotFound
  118. }
  119. copy := *s.record
  120. return &copy, nil
  121. }
  122. func (s *liveTestStore) ClaimLiveController(_ context.Context, callHash, controller, owner string) (bool, error) {
  123. s.mu.Lock()
  124. defer s.mu.Unlock()
  125. if s.claimErr != nil {
  126. return false, s.claimErr
  127. }
  128. if s.record == nil || s.record.CallHash != callHash || s.record.Controller == LiveControllerClosed {
  129. return false, nil
  130. }
  131. if controller == LiveControllerObserver && s.record.Controller != LiveControllerPending {
  132. return false, nil
  133. }
  134. if controller == LiveControllerProxy && s.record.Controller != LiveControllerPending && s.record.Controller != LiveControllerObserver {
  135. return false, nil
  136. }
  137. s.record.Controller = controller
  138. s.record.ControllerOwner = owner
  139. return true, nil
  140. }
  141. func (s *liveTestStore) ReleaseLiveController(_ context.Context, callHash, owner string) (bool, error) {
  142. s.mu.Lock()
  143. defer s.mu.Unlock()
  144. if s.record == nil || s.record.CallHash != callHash || s.record.ControllerOwner != owner {
  145. return false, nil
  146. }
  147. s.record.Controller = LiveControllerPending
  148. s.record.ControllerOwner = ""
  149. return true, nil
  150. }
  151. func (s *liveTestStore) GetLiveController(_ context.Context, callHash string) (string, error) {
  152. s.mu.Lock()
  153. defer s.mu.Unlock()
  154. if s.getControllerErr != nil {
  155. return "", s.getControllerErr
  156. }
  157. if s.record == nil || s.record.CallHash != callHash {
  158. return "", ErrLiveCallNotFound
  159. }
  160. return s.record.Controller, nil
  161. }
  162. func (s *liveTestStore) MarkLiveCallClosed(_ context.Context, callHash string, _ time.Duration) (bool, error) {
  163. s.mu.Lock()
  164. defer s.mu.Unlock()
  165. if s.record == nil || s.record.CallHash != callHash || s.record.Controller == LiveControllerClosed {
  166. return false, nil
  167. }
  168. s.record.Controller = LiveControllerClosed
  169. s.record.ControllerOwner = ""
  170. return true, nil
  171. }
  172. type liveTestConcurrencyCache struct {
  173. ConcurrencyCache
  174. mu sync.Mutex
  175. releases int
  176. }
  177. func (c *liveTestConcurrencyCache) AcquireLiveLease(
  178. context.Context,
  179. int64,
  180. int,
  181. int64,
  182. int,
  183. int64,
  184. string,
  185. bool,
  186. ) (bool, error) {
  187. return true, nil
  188. }
  189. func (c *liveTestConcurrencyCache) RefreshLiveLease(
  190. context.Context,
  191. int64,
  192. int64,
  193. int64,
  194. string,
  195. ) (bool, error) {
  196. return true, nil
  197. }
  198. func (c *liveTestConcurrencyCache) ReleaseLiveLease(
  199. context.Context,
  200. int64,
  201. int64,
  202. int64,
  203. string,
  204. ) error {
  205. c.mu.Lock()
  206. c.releases++
  207. c.mu.Unlock()
  208. return nil
  209. }
  210. type liveTestUsageRepo struct {
  211. UsageLogRepository
  212. mu sync.Mutex
  213. logs []*UsageLog
  214. }
  215. func (r *liveTestUsageRepo) Create(_ context.Context, log *UsageLog) (bool, error) {
  216. r.mu.Lock()
  217. defer r.mu.Unlock()
  218. copy := *log
  219. r.logs = append(r.logs, &copy)
  220. return true, nil
  221. }
  222. func TestRunLiveControllerClosesExpiredSession(t *testing.T) {
  223. upstream := newLiveTestFrameConn()
  224. record := &LiveCallRecord{ExpiresAt: time.Now().Add(20 * time.Millisecond)}
  225. service := &OpenAIGatewayService{}
  226. err := service.runLiveController(context.Background(), record, upstream, make(chan error))
  227. require.ErrorIs(t, err, context.DeadlineExceeded)
  228. select {
  229. case frame := <-upstream.writes:
  230. require.Equal(t, coderws.MessageText, frame.messageType)
  231. require.JSONEq(t, `{"type":"session.close"}`, string(frame.payload))
  232. case <-time.After(time.Second):
  233. t.Fatal("没有向上游发送 session.close")
  234. }
  235. }
  236. func TestFinalizeLiveCallIsIdempotentAndWritesZeroUsage(t *testing.T) {
  237. record := &LiveCallRecord{
  238. CallID: "call_secret",
  239. CallHash: hashLiveCallID("call_secret"),
  240. AccountID: 11,
  241. APIKeyID: 22,
  242. UserID: 33,
  243. GroupID: 44,
  244. LeaseID: "lease-1",
  245. Model: "gpt-live-test",
  246. CreatedAt: time.Now().Add(-time.Second),
  247. ExpiresAt: time.Now().Add(time.Hour),
  248. Controller: LiveControllerPending,
  249. InboundEndpoint: "/v1/live",
  250. }
  251. store := &liveTestStore{}
  252. require.NoError(t, store.SaveLiveCall(context.Background(), record, time.Hour))
  253. concurrencyCache := &liveTestConcurrencyCache{}
  254. usageRepo := &liveTestUsageRepo{}
  255. service := &OpenAIGatewayService{
  256. cache: store,
  257. concurrencyService: NewConcurrencyService(concurrencyCache),
  258. usageLogRepo: usageRepo,
  259. }
  260. service.finalizeLiveCall(record)
  261. service.finalizeLiveCall(record)
  262. concurrencyCache.mu.Lock()
  263. require.Equal(t, 1, concurrencyCache.releases)
  264. concurrencyCache.mu.Unlock()
  265. usageRepo.mu.Lock()
  266. require.Len(t, usageRepo.logs, 1)
  267. log := usageRepo.logs[0]
  268. usageRepo.mu.Unlock()
  269. require.Equal(t, RequestTypeLive, log.RequestType)
  270. require.Equal(t, record.CallHash, log.RequestID)
  271. require.NotEqual(t, record.CallID, log.RequestID)
  272. require.NotNil(t, log.DurationMs)
  273. require.Zero(t, log.InputTokens)
  274. require.Zero(t, log.OutputTokens)
  275. require.Zero(t, log.TotalCost)
  276. require.Zero(t, log.ActualCost)
  277. }
  278. func TestGetLiveCallForIdentityRejectsMismatchedCaller(t *testing.T) {
  279. groupID := int64(44)
  280. record := &LiveCallRecord{
  281. CallID: "call_identity",
  282. CallHash: hashLiveCallID("call_identity"),
  283. APIKeyID: 22,
  284. UserID: 33,
  285. GroupID: groupID,
  286. Controller: LiveControllerPending,
  287. }
  288. store := &liveTestStore{}
  289. require.NoError(t, store.SaveLiveCall(context.Background(), record, time.Hour))
  290. service := &OpenAIGatewayService{cache: store}
  291. _, err := service.GetLiveCallForIdentity(context.Background(), record.CallID, LiveCallIdentity{
  292. APIKeyID: 99,
  293. UserID: record.UserID,
  294. GroupID: &groupID,
  295. })
  296. require.ErrorIs(t, err, ErrLiveIdentityMismatch)
  297. loaded, err := service.GetLiveCallForIdentity(context.Background(), record.CallID, LiveCallIdentity{
  298. APIKeyID: record.APIKeyID,
  299. UserID: record.UserID,
  300. GroupID: &groupID,
  301. })
  302. require.NoError(t, err)
  303. require.Equal(t, record.AccountID, loaded.AccountID)
  304. }
  305. func TestProxyLiveSidebandForwardsTextAndBinary(t *testing.T) {
  306. account := &Account{
  307. ID: 11,
  308. Platform: PlatformOpenAI,
  309. Type: AccountTypeOAuth,
  310. Concurrency: 2,
  311. Credentials: map[string]any{
  312. "access_token": "test-access-token",
  313. "chatgpt_account_id": "acct_test",
  314. },
  315. }
  316. record := &LiveCallRecord{
  317. CallID: "call_proxy",
  318. CallHash: hashLiveCallID("call_proxy"),
  319. AccountID: account.ID,
  320. APIKeyID: 22,
  321. UserID: 33,
  322. LeaseID: "lease-1",
  323. CreatedAt: time.Now(),
  324. ExpiresAt: time.Now().Add(time.Minute),
  325. Controller: LiveControllerPending,
  326. }
  327. attestationCipher := newLiveAttestationCipher(&config.Config{
  328. JWT: config.JWTConfig{Secret: "live-sideband-test-secret"},
  329. })
  330. var err error
  331. record.AttestationCiphertext, err = attestationCipher.Encrypt(`{"v":1,"s":0,"t":"v1.sideband"}`)
  332. require.NoError(t, err)
  333. store := &liveTestStore{}
  334. require.NoError(t, store.SaveLiveCall(context.Background(), record, time.Hour))
  335. upstream := newLiveTestFrameConn()
  336. dialer := &liveTestDialer{conn: upstream}
  337. service := &OpenAIGatewayService{
  338. accountRepo: &liveTestAccountRepo{account: account},
  339. cache: store,
  340. openaiWSPassthroughDialer: dialer,
  341. liveAttestationCipher: attestationCipher,
  342. }
  343. proxyResult := make(chan error, 1)
  344. server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
  345. downstream, err := coderws.Accept(writer, request, nil)
  346. if err != nil {
  347. proxyResult <- err
  348. return
  349. }
  350. defer func() { _ = downstream.CloseNow() }()
  351. proxyResult <- service.ProxyLiveSideband(request.Context(), record, downstream)
  352. }))
  353. defer server.Close()
  354. client, _, err := coderws.Dial(
  355. context.Background(),
  356. "ws"+strings.TrimPrefix(server.URL, "http"),
  357. nil,
  358. )
  359. require.NoError(t, err)
  360. defer func() { _ = client.CloseNow() }()
  361. ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
  362. defer cancel()
  363. require.NoError(t, client.Write(ctx, coderws.MessageText, []byte(`{"type":"client.text"}`)))
  364. clientText := <-upstream.writes
  365. require.Equal(t, coderws.MessageText, clientText.messageType)
  366. require.JSONEq(t, `{"type":"client.text"}`, string(clientText.payload))
  367. require.NoError(t, client.Write(ctx, coderws.MessageBinary, []byte{1, 2, 3}))
  368. clientBinary := <-upstream.writes
  369. require.Equal(t, coderws.MessageBinary, clientBinary.messageType)
  370. require.Equal(t, []byte{1, 2, 3}, clientBinary.payload)
  371. upstream.reads <- liveTestFrame{messageType: coderws.MessageText, payload: []byte(`{"type":"server.text"}`)}
  372. messageType, payload, err := client.Read(ctx)
  373. require.NoError(t, err)
  374. require.Equal(t, coderws.MessageText, messageType)
  375. require.JSONEq(t, `{"type":"server.text"}`, string(payload))
  376. upstream.reads <- liveTestFrame{messageType: coderws.MessageBinary, payload: []byte{4, 5, 6}}
  377. messageType, payload, err = client.Read(ctx)
  378. require.NoError(t, err)
  379. require.Equal(t, coderws.MessageBinary, messageType)
  380. require.Equal(t, []byte{4, 5, 6}, payload)
  381. require.Equal(t, "wss://chatgpt.com/backend-api/codex/call_proxy", dialer.url)
  382. require.Equal(t, "Bearer test-access-token", dialer.headers.Get("Authorization"))
  383. require.Equal(t, "acct_test", dialer.headers.Get("Chatgpt-Account-Id"))
  384. require.Equal(t, `{"v":1,"s":0,"t":"v1.sideband"}`, dialer.headers.Get(liveAttestationHeader))
  385. upstream.reads <- liveTestFrame{err: coderws.CloseError{Code: coderws.StatusNormalClosure}}
  386. require.ErrorIs(t, <-proxyResult, ErrLiveCallNotFound)
  387. }
  388. // TestLiveSessionEndedTreatsLeaseLossAsTerminal 锁定:租约续租失败(ErrLiveUnavailable)
  389. // 必须判为会话终结。RefreshLiveLease 的 Lua 在 leaseID 被 GC 后不会重新写入,若把它
  390. // 当临时错误交给 observer 重连,会话会空转到 ExpiresAt 且不计入任何并发限制。
  391. func TestLiveSessionEndedTreatsLeaseLossAsTerminal(t *testing.T) {
  392. cases := []struct {
  393. name string
  394. err error
  395. want bool
  396. }{
  397. {"租约丢失", ErrLiveUnavailable, true},
  398. {"租约丢失(被包装)", fmt.Errorf("refresh live lease: %w", ErrLiveUnavailable), true},
  399. {"上游报告会话已关闭", ErrLiveCallNotFound, true},
  400. {"到达会话时长上限", context.DeadlineExceeded, true},
  401. {"控制权被他人接管", ErrLiveControllerChanged, false},
  402. {"临时读错误", errors.New("unexpected EOF"), false},
  403. {"无错误", nil, false},
  404. }
  405. for _, tc := range cases {
  406. t.Run(tc.name, func(t *testing.T) {
  407. require.Equal(t, tc.want, liveSessionEnded(tc.err))
  408. })
  409. }
  410. }
  411. // TestWaitForLiveObserverRetryLeavesExpiryToLoopFinalize 锁定:已过期但控制权仍在
  412. // observer 手上时返回 true,让调用方回到 observeLiveCall 循环顶部的过期分支去
  413. // finalize(写 usage log + 释放租约)。在此处直接返回 false 会让会话静默结束、不留记录。
  414. func TestWaitForLiveObserverRetryLeavesExpiryToLoopFinalize(t *testing.T) {
  415. record := &LiveCallRecord{
  416. CallID: "call_expired",
  417. CallHash: hashLiveCallID("call_expired"),
  418. Controller: LiveControllerObserver,
  419. ExpiresAt: time.Now().Add(-time.Minute),
  420. }
  421. store := &liveTestStore{}
  422. require.NoError(t, store.SaveLiveCall(context.Background(), record, time.Hour))
  423. svc := &OpenAIGatewayService{cache: store}
  424. require.True(t, svc.waitForLiveObserverRetry(record),
  425. "过期判定必须留给循环顶部,否则不会写 usage log")
  426. // 控制权已被他人接管时仍必须停止重试,避免与新控制者抢同一个 call。
  427. require.NoError(t, store.SaveLiveCall(context.Background(), &LiveCallRecord{
  428. CallID: record.CallID,
  429. CallHash: record.CallHash,
  430. Controller: LiveControllerProxy,
  431. ExpiresAt: time.Now().Add(time.Hour),
  432. }, time.Hour))
  433. require.False(t, svc.waitForLiveObserverRetry(record))
  434. }
  435. // TestWaitForLiveObserverRetryTreatsStoreErrorAsRetryable 锁定:store 报错(Redis
  436. // 抖动)不等于控制权被接管,必须返回 true 交回 observeLiveCall 循环顶部,由它做
  437. // 有限次重试与 ExpiresAt 兜底 finalize;记录确实不存在时才停止重试。
  438. func TestWaitForLiveObserverRetryTreatsStoreErrorAsRetryable(t *testing.T) {
  439. record := &LiveCallRecord{
  440. CallID: "call_flaky_store",
  441. CallHash: hashLiveCallID("call_flaky_store"),
  442. Controller: LiveControllerObserver,
  443. ExpiresAt: time.Now().Add(time.Hour),
  444. }
  445. store := &liveTestStore{getControllerErr: errors.New("redis: connection refused")}
  446. require.NoError(t, store.SaveLiveCall(context.Background(), record, time.Hour))
  447. svc := &OpenAIGatewayService{cache: store}
  448. require.True(t, svc.waitForLiveObserverRetry(record),
  449. "store 报错必须继续重试,否则 Redis 抖动会让会话静默结束、不留记录")
  450. // 记录已被清理(ErrLiveCallNotFound)不是故障,应停止重试。
  451. require.False(t, (&OpenAIGatewayService{cache: &liveTestStore{}}).waitForLiveObserverRetry(record))
  452. }
  453. // TestObserveLiveCallStoreOutageFallsBackToExpiryFinalize 锁定:observer 遇到持续
  454. // store 报错时不能静默退出,必须按 record.ExpiresAt 兜底 finalize(写 usage log +
  455. // 释放租约)。
  456. func TestObserveLiveCallStoreOutageFallsBackToExpiryFinalize(t *testing.T) {
  457. restore := liveObserverStoreRetryInterval
  458. liveObserverStoreRetryInterval = time.Millisecond
  459. t.Cleanup(func() { liveObserverStoreRetryInterval = restore })
  460. cases := []struct {
  461. name string
  462. inject func(*liveTestStore)
  463. }{
  464. {"GetLiveCall 持续报错", func(s *liveTestStore) { s.getCallErr = errors.New("redis: i/o timeout") }},
  465. {"ClaimLiveController 报错", func(s *liveTestStore) { s.claimErr = errors.New("redis: i/o timeout") }},
  466. }
  467. for _, tc := range cases {
  468. t.Run(tc.name, func(t *testing.T) {
  469. record := &LiveCallRecord{
  470. CallID: "call_store_outage",
  471. CallHash: hashLiveCallID("call_store_outage"),
  472. AccountID: 11,
  473. APIKeyID: 22,
  474. UserID: 33,
  475. LeaseID: "lease-1",
  476. Model: "gpt-live-test",
  477. CreatedAt: time.Now().Add(-time.Minute),
  478. ExpiresAt: time.Now().Add(-time.Second), // 已到期:兜底无需等待
  479. Controller: LiveControllerPending,
  480. }
  481. store := &liveTestStore{}
  482. require.NoError(t, store.SaveLiveCall(context.Background(), record, time.Hour))
  483. tc.inject(store)
  484. concurrencyCache := &liveTestConcurrencyCache{}
  485. usageRepo := &liveTestUsageRepo{}
  486. svc := &OpenAIGatewayService{
  487. cache: store,
  488. concurrencyService: NewConcurrencyService(concurrencyCache),
  489. usageLogRepo: usageRepo,
  490. }
  491. svc.observeLiveCall(record)
  492. concurrencyCache.mu.Lock()
  493. require.Equal(t, 1, concurrencyCache.releases, "store 故障时租约释放不能丢")
  494. concurrencyCache.mu.Unlock()
  495. usageRepo.mu.Lock()
  496. require.Len(t, usageRepo.logs, 1, "store 故障时 usage log 不能丢")
  497. require.Equal(t, RequestTypeLive, usageRepo.logs[0].RequestType)
  498. usageRepo.mu.Unlock()
  499. })
  500. }
  501. }
  502. type liveTestBestEffortUsageRepo struct {
  503. liveTestUsageRepo
  504. bestEffortErr error
  505. bestEffortCalls int
  506. }
  507. func (r *liveTestBestEffortUsageRepo) CreateBestEffort(_ context.Context, _ *UsageLog) error {
  508. r.mu.Lock()
  509. defer r.mu.Unlock()
  510. r.bestEffortCalls++
  511. return r.bestEffortErr
  512. }
  513. // TestFinalizeLiveCallUsageLogFallsBackToSyncCreate 锁定:finalize 是该会话唯一一次
  514. // 落库机会(MarkLiveCallClosed 已标记 first),best-effort 写入失败必须走同步 Create
  515. // 兜底,而不是丢弃错误。
  516. func TestFinalizeLiveCallUsageLogFallsBackToSyncCreate(t *testing.T) {
  517. record := &LiveCallRecord{
  518. CallID: "call_usage_fallback",
  519. CallHash: hashLiveCallID("call_usage_fallback"),
  520. AccountID: 11,
  521. APIKeyID: 22,
  522. UserID: 33,
  523. LeaseID: "lease-1",
  524. Model: "gpt-live-test",
  525. CreatedAt: time.Now().Add(-time.Second),
  526. ExpiresAt: time.Now().Add(time.Hour),
  527. Controller: LiveControllerPending,
  528. }
  529. store := &liveTestStore{}
  530. require.NoError(t, store.SaveLiveCall(context.Background(), record, time.Hour))
  531. usageRepo := &liveTestBestEffortUsageRepo{bestEffortErr: errors.New("usage log queue dropped")}
  532. svc := &OpenAIGatewayService{
  533. cache: store,
  534. concurrencyService: NewConcurrencyService(&liveTestConcurrencyCache{}),
  535. usageLogRepo: usageRepo,
  536. }
  537. svc.finalizeLiveCall(record)
  538. usageRepo.mu.Lock()
  539. defer usageRepo.mu.Unlock()
  540. require.Equal(t, 1, usageRepo.bestEffortCalls)
  541. require.Len(t, usageRepo.logs, 1, "best-effort 失败后必须同步兜底落库")
  542. require.Equal(t, record.CallHash, usageRepo.logs[0].RequestID)
  543. }