From 95bf864c8bd787e56bcb39c4ef9f647be5fcfa38 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Wed, 16 Sep 2026 02:17:48 +0800 Subject: [PATCH 1/2] feat(openai): show Codex credits and manage referral invitations --- .gitignore | 1 + .../handler/admin/openai_oauth_handler.go | 11 +- .../openai_oauth_handler_reset_quota_test.go | 47 +++- .../handler/admin/openai_referral_handler.go | 96 ++++++++ .../admin/openai_referral_handler_test.go | 90 +++++++ backend/internal/server/routes/admin.go | 2 + .../service/openai_quota_credits_test.go | 85 +++++++ .../internal/service/openai_quota_service.go | 39 ++- .../service/openai_quota_spark_window_test.go | 4 + .../service/openai_referral_service.go | 227 ++++++++++++++++++ .../service/openai_referral_service_test.go | 132 ++++++++++ docs/codex-credits.md | 51 ++++ frontend/src/api/admin/accounts.ts | 26 ++ .../account/OpenAIQuotaResetCell.vue | 77 +++++- .../components/account/OpenAIReferralCell.vue | 220 +++++++++++++++++ .../OpenAIQuotaResetCell.spark_shadow.spec.ts | 89 ++++++- .../__tests__/OpenAIReferralCell.spec.ts | 141 +++++++++++ .../src/i18n/locales/en/admin/accounts.ts | 39 ++- .../src/i18n/locales/zh/admin/accounts.ts | 35 ++- frontend/src/types/index.ts | 5 + frontend/src/types/openaiReferrals.ts | 25 ++ 21 files changed, 1421 insertions(+), 21 deletions(-) create mode 100644 backend/internal/handler/admin/openai_referral_handler.go create mode 100644 backend/internal/handler/admin/openai_referral_handler_test.go create mode 100644 backend/internal/service/openai_quota_credits_test.go create mode 100644 backend/internal/service/openai_referral_service.go create mode 100644 backend/internal/service/openai_referral_service_test.go create mode 100644 docs/codex-credits.md create mode 100644 frontend/src/components/account/OpenAIReferralCell.vue create mode 100644 frontend/src/components/account/__tests__/OpenAIReferralCell.spec.ts create mode 100644 frontend/src/types/openaiReferrals.ts diff --git a/.gitignore b/.gitignore index 20ed12275e87..cab1d1cae6db 100644 --- a/.gitignore +++ b/.gitignore @@ -141,6 +141,7 @@ docs/* !docs/COMPOSITE_GROUPS.md !docs/PLUGIN_DEVELOPMENT.md !docs/channel-monitor-v2-safe-defaults.md +!docs/codex-credits.md !docs/legal/ !docs/legal/*.md !docs/screenshots/ diff --git a/backend/internal/handler/admin/openai_oauth_handler.go b/backend/internal/handler/admin/openai_oauth_handler.go index 031fe437fdd5..b96bf426a6da 100644 --- a/backend/internal/handler/admin/openai_oauth_handler.go +++ b/backend/internal/handler/admin/openai_oauth_handler.go @@ -21,12 +21,14 @@ type OpenAIOAuthHandler struct { openaiOAuthService *service.OpenAIOAuthService adminService service.AdminService quotaService openAIQuotaService + referralService openAIReferralService rateLimitService openAIAccountStateRecoverer } type openAIQuotaService interface { QueryUsage(ctx context.Context, accountID int64) (*service.OpenAIQuotaUsage, error) CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *service.OpenAIRateLimitResetCredits) error + CacheCreditsSnapshot(ctx context.Context, accountID int64, usage *service.OpenAIQuotaUsage) error CachePostResetSnapshot(ctx context.Context, accountID int64, usage *service.OpenAIQuotaUsage) error ResetCredit(ctx context.Context, accountID int64) (*service.OpenAIQuotaResetResult, error) } @@ -57,7 +59,8 @@ type openAIQuotaResetResponse struct { // failed display-cache write must never discard a successful upstream read. type openAIQuotaRefreshResponse struct { service.OpenAIQuotaUsage - CachePersisted bool `json:"cache_persisted"` + CachePersisted bool `json:"cache_persisted"` + CreditsCachePersisted bool `json:"credits_cache_persisted"` } // openAIQuotaResetPostProcessContext detaches the post-reset bookkeeping from the @@ -92,6 +95,7 @@ func NewOpenAIOAuthHandler( // `== nil` capability guards below and panic instead of returning 400. if quotaService != nil { h.quotaService = quotaService + h.referralService = quotaService } if rateLimitService != nil { h.rateLimitService = rateLimitService @@ -522,6 +526,11 @@ func (h *OpenAIOAuthHandler) RefreshQuota(c *gin.Context) { service.NotifyOpenAIAutoResetCredit(accountID) refreshResponse := openAIQuotaRefreshResponse{OpenAIQuotaUsage: *usage} + if err := h.quotaService.CacheCreditsSnapshot(c.Request.Context(), accountID, usage); err != nil { + slog.Warn("openai_quota_credits_cache_persist_failed", "account_id", accountID, "error", err) + } else { + refreshResponse.CreditsCachePersisted = true + } // A failed snapshot write leaves the previous cache intact — report it as a // partial success instead of discarding the usage payload we just fetched, // which would leave the card without a credit count at all. diff --git a/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go b/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go index 90677b3bf4da..0556a02f89d1 100644 --- a/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go +++ b/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go @@ -17,11 +17,14 @@ import ( ) type openAIQuotaWorkflowStub struct { - resetResult *service.OpenAIQuotaResetResult - resetErr error - queryResult *service.OpenAIQuotaUsage - queryErr error - cacheErr error + resetResult *service.OpenAIQuotaResetResult + resetErr error + queryResult *service.OpenAIQuotaUsage + queryErr error + cacheErr error + creditsCacheErr error + creditsCacheCalls int + cachedCreditsUsage *service.OpenAIQuotaUsage resetCalls int queryCalls int @@ -48,6 +51,12 @@ func (s *openAIQuotaWorkflowStub) CacheResetCreditsSnapshot(ctx context.Context, return s.cacheErr } +func (s *openAIQuotaWorkflowStub) CacheCreditsSnapshot(_ context.Context, _ int64, usage *service.OpenAIQuotaUsage) error { + s.creditsCacheCalls++ + s.cachedCreditsUsage = usage + return s.creditsCacheErr +} + func (s *openAIQuotaWorkflowStub) CachePostResetSnapshot(ctx context.Context, _ int64, _ *service.OpenAIQuotaUsage) error { s.cacheCalls++ s.cacheCtxErr = ctx.Err() @@ -410,6 +419,34 @@ func TestOpenAIRefreshQuota_PersistFailureStillReturnsUsage(t *testing.T) { require.Equal(t, 1, quota.cacheCalls) } +func TestOpenAIRefreshQuota_CreditsPersistIndependently(t *testing.T) { + for _, tc := range []struct { + name string + resetErr error + creditsErr error + }{ + {name: "both saved"}, + {name: "reset details missing", resetErr: errors.New("missing expirations")}, + {name: "points cache failed", creditsErr: errors.New("write failed")}, + } { + t.Run(tc.name, func(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + balance := "1250.75" + quota.queryResult.Credits = &service.OpenAICredits{HasCredits: true, Balance: &balance} + quota.cacheErr = tc.resetErr + quota.creditsCacheErr = tc.creditsErr + status, envelope := performOpenAIQuotaRefreshRequest(t, &OpenAIOAuthHandler{quotaService: quota}) + require.Equal(t, http.StatusOK, status) + require.Equal(t, tc.resetErr == nil, envelope.Data.CachePersisted) + require.Equal(t, tc.creditsErr == nil, envelope.Data.CreditsCachePersisted) + require.Equal(t, quota.queryResult.Credits, envelope.Data.Credits) + require.Equal(t, quota.queryResult, quota.cachedCreditsUsage) + require.Equal(t, 1, quota.creditsCacheCalls) + require.Zero(t, quota.resetCalls) + }) + } +} + // An empty-but-successful upstream read must not be dereferenced blindly. func TestOpenAIQuotaEmptyUsageIsHandledWithoutPanic(t *testing.T) { t.Run("refresh reports an internal error", func(t *testing.T) { diff --git a/backend/internal/handler/admin/openai_referral_handler.go b/backend/internal/handler/admin/openai_referral_handler.go new file mode 100644 index 000000000000..4025fac65641 --- /dev/null +++ b/backend/internal/handler/admin/openai_referral_handler.go @@ -0,0 +1,96 @@ +package admin + +import ( + "context" + "net/http" + "strconv" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +type openAIReferralService interface { + QueryReferralEligibility(context.Context, int64) (*service.OpenAIReferralEligibility, error) + CacheReferralSnapshot(context.Context, int64, *service.OpenAIReferralEligibility) error + SendReferralInvite(context.Context, int64, service.OpenAIReferralSendRequest) (*service.OpenAIReferralSendResult, error) +} + +type openAIReferralRefreshResponse struct { + Eligibility *service.OpenAIReferralEligibility `json:"eligibility"` + CachePersisted bool `json:"cache_persisted"` +} + +type openAIReferralSendResponse struct { + service.OpenAIReferralSendResult + openAIReferralRefreshResponse + RefreshFailed bool `json:"refresh_failed"` +} + +func (h *OpenAIOAuthHandler) referralAccountID(c *gin.Context) (int64, bool) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + response.BadRequest(c, "Invalid account ID") + return 0, false + } + if h.referralService == nil { + response.BadRequest(c, "OpenAI referral service is not enabled") + return 0, false + } + return id, true +} + +// RefreshReferrals persists a display snapshot, hence POST and admin audit. +func (h *OpenAIOAuthHandler) RefreshReferrals(c *gin.Context) { + id, ok := h.referralAccountID(c) + if !ok { + return + } + eligibility, err := h.referralService.QueryReferralEligibility(c.Request.Context(), id) + if err != nil { + response.ErrorFrom(c, err) + return + } + if eligibility == nil { + response.Error(c, http.StatusBadGateway, "Empty invitation eligibility response") + return + } + cacheErr := h.referralService.CacheReferralSnapshot(c.Request.Context(), id, eligibility) + response.Success(c, openAIReferralRefreshResponse{Eligibility: eligibility, CachePersisted: cacheErr == nil}) +} + +func (h *OpenAIOAuthHandler) SendReferralInvite(c *gin.Context) { + id, ok := h.referralAccountID(c) + if !ok { + return + } + var input service.OpenAIReferralSendRequest + if err := c.ShouldBindJSON(&input); err != nil { + response.BadRequest(c, "Invalid invitation request") + return + } + result, err := h.referralService.SendReferralInvite(c.Request.Context(), id, input) + if err != nil { + response.ErrorFrom(c, err) + return + } + if result == nil || !result.Sent { + response.Error(c, http.StatusBadGateway, "Invitation outcome is unknown; check Codex before sending again") + return + } + // The email is already sent. Refresh failure must not turn it into a failed + // submission and encourage a duplicate send, even if the browser disconnects. + ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 8*time.Second) + defer cancel() + eligibility, refreshErr := h.referralService.QueryReferralEligibility(ctx, id) + if refreshErr != nil { + eligibility = nil + } + cacheErr := h.referralService.CacheReferralSnapshot(ctx, id, eligibility) + response.Success(c, openAIReferralSendResponse{ + OpenAIReferralSendResult: *result, + openAIReferralRefreshResponse: openAIReferralRefreshResponse{Eligibility: eligibility, CachePersisted: cacheErr == nil}, + RefreshFailed: eligibility == nil, + }) +} diff --git a/backend/internal/handler/admin/openai_referral_handler_test.go b/backend/internal/handler/admin/openai_referral_handler_test.go new file mode 100644 index 000000000000..4bfca710f407 --- /dev/null +++ b/backend/internal/handler/admin/openai_referral_handler_test.go @@ -0,0 +1,90 @@ +//go:build unit + +package admin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type referralHandlerStub struct { + eligibility *service.OpenAIReferralEligibility + queryErr, cacheErr error + input service.OpenAIReferralSendRequest + cached *service.OpenAIReferralEligibility + sends int +} + +func (s *referralHandlerStub) QueryReferralEligibility(context.Context, int64) (*service.OpenAIReferralEligibility, error) { + return s.eligibility, s.queryErr +} +func (s *referralHandlerStub) CacheReferralSnapshot(_ context.Context, _ int64, e *service.OpenAIReferralEligibility) error { + s.cached = e + return s.cacheErr +} +func (s *referralHandlerStub) SendReferralInvite(_ context.Context, _ int64, input service.OpenAIReferralSendRequest) (*service.OpenAIReferralSendResult, error) { + s.sends++ + s.input = input + return &service.OpenAIReferralSendResult{Email: input.Email, Sent: true}, nil +} + +func referralHandlerRequest(t *testing.T, stub *referralHandlerStub, path, body string) *httptest.ResponseRecorder { + t.Helper() + gin.SetMode(gin.TestMode) + h := &OpenAIOAuthHandler{referralService: stub} + router := gin.New() + router.POST("/:id/referrals/refresh", h.RefreshReferrals) + router.POST("/:id/referrals/invite", h.SendReferralInvite) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))) + return rec +} + +func TestOpenAIReferralHandlerRefresh(t *testing.T) { + count := 2 + stub := &referralHandlerStub{eligibility: &service.OpenAIReferralEligibility{ShouldShow: true, AvailableInvites: &count}, cacheErr: errors.New("cache failed")} + rec := referralHandlerRequest(t, stub, "/100/referrals/refresh", "") + require.Equal(t, http.StatusOK, rec.Code) + var body struct { + Data openAIReferralRefreshResponse `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.Equal(t, 2, *body.Data.Eligibility.AvailableInvites) + require.False(t, body.Data.CachePersisted) + require.Zero(t, stub.sends) +} + +func TestOpenAIReferralHandlerSentSurvivesRefreshFailure(t *testing.T) { + stub := &referralHandlerStub{queryErr: errors.New("upstream timed out")} + rec := referralHandlerRequest(t, stub, "/100/referrals/invite", `{"email":"friend@example.com","program_id":"codex_referral_consumer","confirmed":true}`) + require.Equal(t, http.StatusOK, rec.Code) + var body struct { + Data openAIReferralSendResponse `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.True(t, body.Data.Sent) + require.Equal(t, "friend@example.com", body.Data.Email) + require.True(t, body.Data.RefreshFailed) + require.True(t, body.Data.CachePersisted) + require.Nil(t, stub.cached, "stale remaining count must be invalidated") + require.True(t, stub.input.Confirmed) + require.Equal(t, 1, stub.sends) +} + +func TestOpenAIReferralHandlerRejectsInvalidInput(t *testing.T) { + stub := &referralHandlerStub{} + for _, path := range []string{"/invalid/referrals/invite", "/0/referrals/invite", "/100/referrals/invite"} { + rec := referralHandlerRequest(t, stub, path, "not-json") + require.Equal(t, http.StatusBadRequest, rec.Code) + } + require.Zero(t, stub.sends) +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index c16ef695e03f..9b43abe4b9c6 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -454,6 +454,8 @@ func registerOpenAIOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) { openai.GET("/accounts/:id/quota", h.Admin.OpenAIOAuth.QueryQuota) openai.POST("/accounts/:id/quota/refresh", h.Admin.OpenAIOAuth.RefreshQuota) openai.POST("/accounts/:id/reset-quota", h.Admin.OpenAIOAuth.ResetQuota) + openai.POST("/accounts/:id/referrals/refresh", h.Admin.OpenAIOAuth.RefreshReferrals) + openai.POST("/accounts/:id/referrals/invite", h.Admin.OpenAIOAuth.SendReferralInvite) } } diff --git a/backend/internal/service/openai_quota_credits_test.go b/backend/internal/service/openai_quota_credits_test.go new file mode 100644 index 000000000000..9a9a6b1adac2 --- /dev/null +++ b/backend/internal/service/openai_quota_credits_test.go @@ -0,0 +1,85 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestQueryUsageCodexCredits(t *testing.T) { + for _, tc := range []struct { + name string + body string + want string + }{ + {"decimal balance", `{"credits":{"has_credits":true,"unlimited":false,"balance":"12345678901234567890.0123"}}`, `{"has_credits":true,"unlimited":false,"balance":"12345678901234567890.0123"}`}, + {"zero", `{"credits":{"has_credits":false,"unlimited":false,"balance":"0"}}`, `{"has_credits":false,"unlimited":false,"balance":"0"}`}, + {"unlimited", `{"credits":{"has_credits":false,"unlimited":true,"balance":null}}`, `{"has_credits":false,"unlimited":true,"balance":null}`}, + {"hidden balance", `{"credits":{"has_credits":true,"unlimited":false}}`, `{"has_credits":true,"unlimited":false,"balance":null}`}, + {"absent", `{}`, `null`}, + {"null", `{"credits":null}`, `null`}, + } { + t.Run(tc.name, func(t *testing.T) { + account := &Account{ID: 100, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, + Credentials: map[string]any{"chatgpt_account_id": "test-workspace"}} + repo := &stubQuotaAccountRepo{accounts: map[int64]*Account{100: account}} + tokens := &stubQuotaTokenCache{tokens: map[string]string{OpenAITokenCacheKey(account): "test-token"}} + var paths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + require.Equal(t, "test-workspace", r.Header.Get("ChatGPT-Account-ID")) + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/backend-api/wham/usage" { + _, _ = w.Write([]byte(tc.body)) + return + } + // Reset-card details may be unavailable without losing points. + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + defer srv.Close() + svc := NewOpenAIQuotaService(repo, nil, NewOpenAITokenProvider(repo, tokens, nil), newQuotaRedirectingFactory(srv)) + usage, err := svc.QueryUsage(context.Background(), 100) + require.NoError(t, err) + require.Positive(t, usage.FetchedAt) + encoded, err := json.Marshal(usage.Credits) + require.NoError(t, err) + require.JSONEq(t, tc.want, string(encoded)) + require.Equal(t, []string{"/backend-api/wham/usage", "/backend-api/wham/rate-limit-reset-credits"}, paths) + require.Zero(t, repo.extraUpdateCalls, "read-only query must not write account state") + }) + } +} + +func TestCacheCodexCreditsSnapshot(t *testing.T) { + ctx := context.Background() + repo := &stubQuotaAccountRepo{} + svc := &OpenAIQuotaService{accountRepo: repo} + balance := "1200.50" + usage := &OpenAIQuotaUsage{FetchedAt: 123, Credits: &OpenAICredits{HasCredits: true, Balance: &balance}} + + // No reset-card details are required to persist the balance on the queried row. + require.NoError(t, svc.CacheCreditsSnapshot(ctx, 200, usage)) + encoded, err := json.Marshal(repo.extraUpdates[200][openaiQuotaCreditsKey]) + require.NoError(t, err) + require.JSONEq(t, `{"credits":{"has_credits":true,"unlimited":false,"balance":"1200.50"},"fetched_at":123}`, string(encoded)) + require.NotContains(t, repo.extraUpdates, int64(100), "a shadow row must not overwrite its parent") + require.NotContains(t, repo.extraUpdates[200], openaiQuotaResetCreditsKey) + + // Missing credits from a successful read invalidate the previous balance. + require.NoError(t, svc.CacheCreditsSnapshot(ctx, 200, &OpenAIQuotaUsage{FetchedAt: 456})) + encoded, err = json.Marshal(repo.extraUpdates[200][openaiQuotaCreditsKey]) + require.NoError(t, err) + require.JSONEq(t, `{"credits":null,"fetched_at":456}`, string(encoded)) + + require.Error(t, svc.CacheCreditsSnapshot(ctx, 200, nil)) + require.Equal(t, 2, repo.extraUpdateCalls) + repo.extraUpdateErr = errors.New("database unavailable") + require.ErrorContains(t, svc.CacheCreditsSnapshot(ctx, 200, usage), "database unavailable") +} diff --git a/backend/internal/service/openai_quota_service.go b/backend/internal/service/openai_quota_service.go index b05241b222f0..6ff3e6df8add 100644 --- a/backend/internal/service/openai_quota_service.go +++ b/backend/internal/service/openai_quota_service.go @@ -35,6 +35,7 @@ const ( openaiQuotaSecFetchMode = "no-cors" openaiQuotaSecFetchDest = "empty" openaiQuotaResetCreditsKey = "codex_reset_credit_snapshot" + openaiQuotaCreditsKey = "codex_credits_snapshot" ) // OpenAIRateLimitWindow describes a single rate-limit window returned by @@ -75,6 +76,21 @@ type OpenAIRateLimitResetCredits struct { Credits []OpenAIRateLimitResetCreditDetail `json:"credits,omitempty"` } +// OpenAICredits is the spendable Codex credit balance from /wham/usage. +// It is separate from reset credits. Upstream represents the balance as a +// nullable decimal string; keep that representation to preserve precision. +// Source: Codex 41ece455b7fa, codex-backend-openapi-models/src/models/credit_status_details.rs. +type OpenAICredits struct { + HasCredits bool `json:"has_credits"` + Unlimited bool `json:"unlimited"` + Balance *string `json:"balance"` +} + +type openAICreditsSnapshot struct { + Credits *OpenAICredits `json:"credits"` + FetchedAt int64 `json:"fetched_at"` +} + // OpenAIQuotaUsage is the typed projection of /wham/usage we expose to the UI. // Fields not relevant to the quota card are intentionally omitted to keep the // surface narrow; full upstream payload preservation is unnecessary. @@ -86,6 +102,7 @@ type OpenAIQuotaUsage struct { RateLimit *OpenAIRateLimit `json:"rate_limit,omitempty"` AdditionalRateLimits []OpenAIAdditionalRateLimit `json:"additional_rate_limits,omitempty"` RateLimitResetCredits *OpenAIRateLimitResetCredits `json:"rate_limit_reset_credits,omitempty"` + Credits *OpenAICredits `json:"credits,omitempty"` FetchedAt int64 `json:"fetched_at"` autoResetCandidates []openAIAutoResetCreditCandidate } @@ -228,16 +245,36 @@ func (s *OpenAIQuotaService) CacheResetCreditsSnapshot(ctx context.Context, acco return s.cacheResetCreditsSnapshot(ctx, accountID, credits, nil) } +// CacheCreditsSnapshot stores the queried row's display snapshot independently +// of reset-credit expiration details. A successful read with absent credits +// replaces the previous balance with unknown, never with a fabricated zero. +func (s *OpenAIQuotaService) CacheCreditsSnapshot(ctx context.Context, accountID int64, usage *OpenAIQuotaUsage) error { + if usage == nil { + return infraerrors.New(http.StatusBadGateway, "OPENAI_QUOTA_EMPTY_USAGE", "openai quota query returned an empty result") + } + if err := s.accountRepo.UpdateExtra(ctx, accountID, map[string]any{ + openaiQuotaCreditsKey: openAICreditsSnapshot{Credits: usage.Credits, FetchedAt: usage.FetchedAt}, + }); err != nil { + return infraerrors.New(http.StatusInternalServerError, "OPENAI_QUOTA_CACHE_WRITE_FAILED", "failed to cache Codex credits").WithCause(err) + } + return nil +} + // CachePostResetSnapshot persists the credits and usage windows observed after a reset. func (s *OpenAIQuotaService) CachePostResetSnapshot(ctx context.Context, accountID int64, usage *OpenAIQuotaUsage) error { if usage == nil { return s.cacheResetCreditsSnapshot(ctx, accountID, nil, nil) } + updates := buildOpenAIAutoResetUsageUpdates(usage, time.Now()) + if updates == nil { + updates = make(map[string]any) + } + updates[openaiQuotaCreditsKey] = openAICreditsSnapshot{Credits: usage.Credits, FetchedAt: usage.FetchedAt} return s.cacheResetCreditsSnapshot( ctx, accountID, usage.RateLimitResetCredits, - buildOpenAIAutoResetUsageUpdates(usage, time.Now()), + updates, ) } diff --git a/backend/internal/service/openai_quota_spark_window_test.go b/backend/internal/service/openai_quota_spark_window_test.go index b1f636d41627..d5cd81767f19 100644 --- a/backend/internal/service/openai_quota_spark_window_test.go +++ b/backend/internal/service/openai_quota_spark_window_test.go @@ -700,7 +700,10 @@ func TestCachePostResetSnapshot(t *testing.T) { repo := &stubQuotaAccountRepo{} svc := &OpenAIQuotaService{accountRepo: repo} credits := &OpenAIRateLimitResetCredits{AvailableCount: 0} + balance := "1200.50" usage := &OpenAIQuotaUsage{ + Credits: &OpenAICredits{HasCredits: true, Balance: &balance}, + FetchedAt: 123, RateLimitResetCredits: credits, RateLimit: &OpenAIRateLimit{ PrimaryWindow: &OpenAIRateLimitWindow{ @@ -715,6 +718,7 @@ func TestCachePostResetSnapshot(t *testing.T) { require.NoError(t, svc.CachePostResetSnapshot(context.Background(), 100, usage)) require.Equal(t, 1, repo.extraUpdateCalls) require.Equal(t, credits, repo.extraUpdates[100][openaiQuotaResetCreditsKey]) + require.Equal(t, openAICreditsSnapshot{Credits: usage.Credits, FetchedAt: 123}, repo.extraUpdates[100][openaiQuotaCreditsKey]) require.Equal(t, 0.0, repo.extraUpdates[100]["codex_5h_used_percent"]) require.Equal(t, 0.0, repo.extraUpdates[100]["codex_7d_used_percent"]) } diff --git a/backend/internal/service/openai_referral_service.go b/backend/internal/service/openai_referral_service.go new file mode 100644 index 000000000000..be9eeb568e47 --- /dev/null +++ b/backend/internal/service/openai_referral_service.go @@ -0,0 +1,227 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "net/mail" + "strings" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/imroc/req/v3" +) + +// Desktop 26.908.40834, app-primary-44ec287874b7.js: SIt, EIt, PIt, FIt. +// Referrals use /backend-api/referrals, not /backend-api/wham. +const ( + openAIReferralURL = "https://chatgpt.com/backend-api/referrals/invite" + openAIReferralSnapshotKey = "codex_referral_snapshot" + openAIReferralConsumer = "codex_referral_consumer" + openAIReferralWorkspace = "codex_referral_workspace" + openAIReferralEntrypoint = "persistent" +) + +type OpenAIReferralGrant struct { + GrantType string `json:"grant_type"` + Recipient string `json:"recipient"` + Amount float64 `json:"amount"` +} + +type OpenAIReferralEligibility struct { + ShouldShow bool `json:"should_show"` + RemainingSendCapacity *int `json:"remaining_send_capacity"` + RemainingRewardCapacity *int `json:"remaining_reward_capacity"` + RequiresExplicitConfirmation *bool `json:"requires_explicit_confirmation"` + OfferID string `json:"offer_id,omitempty"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Rules []string `json:"rules,omitempty"` + Grants []OpenAIReferralGrant `json:"grants,omitempty"` + ProgramID string `json:"program_id"` + AvailableInvites *int `json:"available_invites"` + FetchedAt int64 `json:"fetched_at"` +} + +// referralCapacity follows the desktop's send/reward capacity constraints. +// The desktop additionally caps one batch at five; this API sends one email. +func referralCapacity(e *OpenAIReferralEligibility) *int { + if !e.ShouldShow { + zero := 0 + return &zero + } + if e.RemainingSendCapacity == nil { + return nil + } + count := max(0, *e.RemainingSendCapacity) + if len(e.Grants) > 0 || (e.OfferID != "" && e.OfferID != "none") { + if e.RemainingRewardCapacity == nil { + return nil + } + count = min(count, max(0, *e.RemainingRewardCapacity)) + } + return &count +} + +func (s *OpenAIQuotaService) referralAccount(ctx context.Context, id int64, sending bool) (*Account, error) { + if s == nil || s.accountRepo == nil { + return nil, infraerrors.New(http.StatusServiceUnavailable, "OPENAI_REFERRAL_NOT_CONFIGURED", "referral service is unavailable") + } + a, err := s.accountRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if a == nil { + return nil, ErrAccountNotFound + } + if a.Platform != PlatformOpenAI || a.Type != AccountTypeOAuth { + return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_REFERRAL_INVALID_ACCOUNT", "referrals require an OpenAI OAuth account") + } + if a.IsShadow() { + if sending { + return nil, infraerrors.New(http.StatusConflict, "OPENAI_REFERRAL_SHADOW_ACCOUNT", "send invitations from the parent account") + } + return resolveCredentialAccount(ctx, s.accountRepo, a) + } + return a, nil +} + +func referralProgram(a *Account) string { + if strings.EqualFold(a.GetCredential("account_type"), "workspace") { + return openAIReferralWorkspace + } + switch strings.ToLower(strings.TrimSpace(a.GetCredential("plan_type"))) { + case "team", "business", "self_serve_business_prolite", "self_serve_business_usage_based", "free_workspace", "enterprise", "enterprise_cbp_usage_based", "enterprise_cbp_automation", "edu", "education": + return openAIReferralWorkspace + default: + return openAIReferralConsumer + } +} + +func (s *OpenAIQuotaService) referralRequest(ctx context.Context, id int64) (*req.Request, error) { + token, accountID, proxy, fedRAMP, err := s.prepareUpstreamCall(ctx, id) + if err != nil { + return nil, err + } + client, err := s.privacyClientFactory(proxy) + if err != nil { + return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_CLIENT_ERROR", "failed to build referral client") + } + headers, _, err := s.buildCodexQuotaHeaders(ctx, id, token, accountID, fedRAMP) + if err != nil { + return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_AUTH_ERROR", "failed to authenticate referral request") + } + // Sending an invitation has no documented idempotency key. Never retry it. + return client.R().SetContext(ctx).SetHeaders(headers).SetRetryCount(0), nil +} + +func (s *OpenAIQuotaService) QueryReferralEligibility(ctx context.Context, id int64) (*OpenAIReferralEligibility, error) { + account, err := s.referralAccount(ctx, id, false) + if err != nil { + return nil, err + } + callCtx, cancel := context.WithTimeout(ctx, openaiQuotaUpstreamTimeout) + defer cancel() + r, err := s.referralRequest(callCtx, id) + if err != nil { + return nil, err + } + program := referralProgram(account) + resp, err := r.SetQueryParams(map[string]string{"program_id": program, "entrypoint": openAIReferralEntrypoint}).Get(openAIReferralURL + "/eligibility") + if err != nil { + return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_QUERY_FAILED", "failed to query invitation eligibility") + } + if !resp.IsSuccessState() { + return nil, referralHTTPError(resp.StatusCode) + } + var result *OpenAIReferralEligibility + if err := json.Unmarshal(resp.Bytes(), &result); err != nil || result == nil { + return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_INVALID_RESPONSE", "invalid invitation eligibility response") + } + result.ProgramID = program + result.AvailableInvites = referralCapacity(result) + result.FetchedAt = time.Now().Unix() + return result, nil +} + +func (s *OpenAIQuotaService) CacheReferralSnapshot(ctx context.Context, id int64, eligibility *OpenAIReferralEligibility) error { + return s.accountRepo.UpdateExtra(ctx, id, map[string]any{openAIReferralSnapshotKey: eligibility}) +} + +type OpenAIReferralSendRequest struct { + Email string `json:"email"` + ProgramID string `json:"program_id"` + Confirmed bool `json:"confirmed"` +} + +type OpenAIReferralSendResult struct { + Email string `json:"email"` + Sent bool `json:"sent"` +} + +func (s *OpenAIQuotaService) SendReferralInvite(ctx context.Context, id int64, input OpenAIReferralSendRequest) (*OpenAIReferralSendResult, error) { + email := strings.TrimSpace(input.Email) + address, err := mail.ParseAddress(email) + if err != nil || address.Address != email || len(email) > 254 || strings.ContainsAny(email, "\r\n") { + return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_REFERRAL_INVALID_EMAIL", "enter one valid email address") + } + account, err := s.referralAccount(ctx, id, true) + if err != nil { + return nil, err + } + if input.ProgramID != referralProgram(account) { + return nil, infraerrors.New(http.StatusConflict, "OPENAI_REFERRAL_PROGRAM_CHANGED", "refresh invitation eligibility before sending") + } + // Recheck the server's current eligibility rather than trusting a UI cache. + eligibility, err := s.QueryReferralEligibility(ctx, id) + if err != nil { + return nil, err + } + if !eligibility.ShouldShow || eligibility.AvailableInvites == nil || *eligibility.AvailableInvites <= 0 { + return nil, infraerrors.New(http.StatusConflict, "OPENAI_REFERRAL_UNAVAILABLE", "no invitations are currently available") + } + if (eligibility.RequiresExplicitConfirmation == nil || *eligibility.RequiresExplicitConfirmation) && !input.Confirmed { + return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_REFERRAL_CONFIRMATION_REQUIRED", "confirm the recipient's consent before sending") + } + callCtx, cancel := context.WithTimeout(ctx, openaiQuotaUpstreamTimeout) + defer cancel() + r, err := s.referralRequest(callCtx, id) + if err != nil { + return nil, err + } + resp, err := r.SetBody(map[string]any{ + "program_id": eligibility.ProgramID, "entrypoint": openAIReferralEntrypoint, "emails": []string{email}, + }).Post(openAIReferralURL) + if err != nil { + return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_SEND_UNKNOWN", "invitation outcome is unknown; check the invitation status in Codex before sending again") + } + if !resp.IsSuccessState() { + return nil, referralHTTPError(resp.StatusCode) + } + var payload struct { + Invites []json.RawMessage `json:"invites"` + } + if err := json.Unmarshal(resp.Bytes(), &payload); err != nil || len(payload.Invites) != 1 || string(payload.Invites[0]) == "null" { + return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_SEND_UNKNOWN", "invitation outcome is unknown; check the invitation status in Codex before sending again") + } + return &OpenAIReferralSendResult{Email: email, Sent: true}, nil +} + +// Avoid exposing upstream response bodies, which can contain personal data or +// credentials. The status still distinguishes validation, duplicates and limits. +func referralHTTPError(status int) error { + switch status { + case http.StatusBadRequest, http.StatusUnprocessableEntity: + return infraerrors.New(http.StatusBadRequest, "OPENAI_REFERRAL_REJECTED", "the invitation request was rejected; check the email and eligibility") + case http.StatusUnauthorized: + return infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_AUTH_ERROR", "upstream authentication failed; refresh the account credentials") + case http.StatusForbidden: + return infraerrors.New(http.StatusForbidden, "OPENAI_REFERRAL_FORBIDDEN", "this account is not eligible for invitations") + case http.StatusConflict: + return infraerrors.New(http.StatusConflict, "OPENAI_REFERRAL_ALREADY_EXISTS", "an invitation already exists for this recipient") + case http.StatusTooManyRequests: + return infraerrors.New(http.StatusTooManyRequests, "OPENAI_REFERRAL_RATE_LIMITED", "invitation limit reached; try again later") + default: + return infraerrors.New(http.StatusBadGateway, "OPENAI_REFERRAL_UPSTREAM_ERROR", "invitation service is unavailable") + } +} diff --git a/backend/internal/service/openai_referral_service_test.go b/backend/internal/service/openai_referral_service_test.go new file mode 100644 index 000000000000..47efba6712d4 --- /dev/null +++ b/backend/internal/service/openai_referral_service_test.go @@ -0,0 +1,132 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/stretchr/testify/require" +) + +func referralTestService(t *testing.T, plan string, handler http.HandlerFunc) (*OpenAIQuotaService, *stubQuotaAccountRepo) { + t.Helper() + a := &Account{ID: 100, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, + Credentials: map[string]any{"chatgpt_account_id": "workspace-test", "plan_type": plan}} + repo := &stubQuotaAccountRepo{accounts: map[int64]*Account{100: a}} + tokens := &stubQuotaTokenCache{tokens: map[string]string{OpenAITokenCacheKey(a): "test-token"}} + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + return NewOpenAIQuotaService(repo, nil, NewOpenAITokenProvider(repo, tokens, nil), newQuotaRedirectingFactory(srv)), repo +} + +func TestOpenAIReferralSend(t *testing.T) { + for _, tc := range []struct{ plan, program string }{ + {"plus", openAIReferralConsumer}, {"team", openAIReferralWorkspace}, + {"self_serve_business_usage_based", openAIReferralWorkspace}, + } { + t.Run(tc.plan, func(t *testing.T) { + var gets, posts int + svc, repo := referralTestService(t, tc.plan, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + require.Equal(t, "workspace-test", r.Header.Get("ChatGPT-Account-ID")) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/backend-api/referrals/invite/eligibility": + gets++ + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, tc.program, r.URL.Query().Get("program_id")) + require.Equal(t, "persistent", r.URL.Query().Get("entrypoint")) + _, _ = w.Write([]byte(`{"should_show":true,"remaining_send_capacity":8,"remaining_reward_capacity":3,"grants":[{"grant_type":"rate_limit_reset_credit","amount":1,"recipient":"referrer"}],"rules":["Offer rule"]}`)) + case "/backend-api/referrals/invite": + posts++ + require.Equal(t, http.MethodPost, r.Method) + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.Equal(t, map[string]any{"program_id": tc.program, "entrypoint": "persistent", "emails": []any{"friend@example.com"}}, body) + _, _ = w.Write([]byte(`{"invites":[{"referral_id":"test-invite","email":"friend@example.com"}]}`)) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + }) + eligibility, err := svc.QueryReferralEligibility(context.Background(), 100) + require.NoError(t, err) + require.Equal(t, 3, *eligibility.AvailableInvites) + require.Equal(t, []string{"Offer rule"}, eligibility.Rules) + require.Positive(t, eligibility.FetchedAt) + require.NoError(t, svc.CacheReferralSnapshot(context.Background(), 100, eligibility)) + require.Equal(t, eligibility, repo.extraUpdates[100][openAIReferralSnapshotKey]) + result, err := svc.SendReferralInvite(context.Background(), 100, OpenAIReferralSendRequest{ + Email: " friend@example.com ", ProgramID: tc.program, Confirmed: true, + }) + require.NoError(t, err) + require.True(t, result.Sent) + require.Equal(t, "friend@example.com", result.Email) + require.Equal(t, 2, gets, "send must recheck eligibility") + require.Equal(t, 1, posts) + }) + } +} + +func TestOpenAIReferralSendGuards(t *testing.T) { + for _, tc := range []struct { + name, email, body, reason string + confirmed, shadow bool + }{ + {"invalid email", "bad-email", `{}`, "OPENAI_REFERRAL_INVALID_EMAIL", true, false}, + {"multiple emails", "a@example.com,b@example.com", `{}`, "OPENAI_REFERRAL_INVALID_EMAIL", true, false}, + {"display name", "User ", `{}`, "OPENAI_REFERRAL_INVALID_EMAIL", true, false}, + {"header injection", "a@example.com\r\nBcc: b@example.com", `{}`, "OPENAI_REFERRAL_INVALID_EMAIL", true, false}, + {"no consent", "a@example.com", `{"should_show":true,"remaining_send_capacity":2}`, "OPENAI_REFERRAL_CONFIRMATION_REQUIRED", false, false}, + {"ineligible", "a@example.com", `{"should_show":false,"remaining_send_capacity":2}`, "OPENAI_REFERRAL_UNAVAILABLE", true, false}, + {"exhausted", "a@example.com", `{"should_show":true,"remaining_send_capacity":0}`, "OPENAI_REFERRAL_UNAVAILABLE", true, false}, + {"unknown capacity", "a@example.com", `{"should_show":true}`, "OPENAI_REFERRAL_UNAVAILABLE", true, false}, + {"reward exhausted", "a@example.com", `{"should_show":true,"remaining_send_capacity":3,"offer_id":"credits_250","remaining_reward_capacity":0}`, "OPENAI_REFERRAL_UNAVAILABLE", true, false}, + {"unknown reward capacity", "a@example.com", `{"should_show":true,"remaining_send_capacity":3,"grants":[{}]}`, "OPENAI_REFERRAL_UNAVAILABLE", true, false}, + {"null response", "a@example.com", `null`, "OPENAI_REFERRAL_INVALID_RESPONSE", true, false}, + {"shadow", "a@example.com", `{}`, "OPENAI_REFERRAL_SHADOW_ACCOUNT", true, true}, + } { + t.Run(tc.name, func(t *testing.T) { + posts := 0 + svc, repo := referralTestService(t, "plus", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + posts++ + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tc.body)) + }) + if tc.shadow { + parentID := int64(200) + repo.accounts[100].ParentAccountID = &parentID + } + _, err := svc.SendReferralInvite(context.Background(), 100, OpenAIReferralSendRequest{Email: tc.email, ProgramID: openAIReferralConsumer, Confirmed: tc.confirmed}) + require.Equal(t, tc.reason, infraerrors.Reason(err)) + require.Zero(t, posts) + }) + } +} + +func TestOpenAIReferralSendDoesNotRetryOrExposeUpstreamErrors(t *testing.T) { + for _, status := range []int{400, 401, 403, 409, 429, 500} { + t.Run(http.StatusText(status), func(t *testing.T) { + posts := 0 + svc, _ := referralTestService(t, "plus", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(`{"should_show":true,"remaining_send_capacity":2,"requires_explicit_confirmation":false,"offer_id":"none"}`)) + return + } + posts++ + w.WriteHeader(status) + _, _ = w.Write([]byte(`{"detail":"secret-token someone@example.com"}`)) + }) + _, err := svc.SendReferralInvite(context.Background(), 100, OpenAIReferralSendRequest{Email: "friend@example.com", ProgramID: openAIReferralConsumer}) + require.Error(t, err) + require.NotContains(t, err.Error(), "secret-token") + require.NotContains(t, err.Error(), "someone@example.com") + require.Equal(t, 1, posts) + }) + } +} diff --git a/docs/codex-credits.md b/docs/codex-credits.md new file mode 100644 index 000000000000..2b7c48a7fdff --- /dev/null +++ b/docs/codex-credits.md @@ -0,0 +1,51 @@ +# Codex 点数查询 + +Codex 源码核对版本:`41ece455b7fa7166f4fc38522952afdaa2604e18`。 + +- `codex-rs/backend-client/src/client/rate_limit_resets.rs`:`get_rate_limit_status()` 使用 `GET /wham/usage`;ChatGPT 完整地址为 `https://chatgpt.com/backend-api/wham/usage`。Codex API 路由模式使用 `/api/codex/usage`。 +- `codex-rs/backend-client/src/client.rs`:`headers()` 添加身份认证和 `ChatGPT-Account-Id`,`map_credits()` 读取点数对象。 +- `codex-rs/codex-backend-openapi-models/src/models/credit_status_details.rs`:`has_credits`、`unlimited` 为布尔值,`balance` 为可缺省、可空的字符串。 +- `codex-rs/codex-api/src/rate_limits.rs`:推理响应也可以通过 `x-codex-credits-has-credits`、`x-codex-credits-unlimited` 和 `x-codex-credits-balance` 响应头携带点数快照。 + +点数是 `credits`;重置卡是 `rate_limit_reset_credits`。两者独立,不能混用。点数余额按原始十进制字符串保留,不折算成 sub2api 用户余额或美元。 + +## sub2api 接入 + +复用现有 OAuth token 刷新、账号绑定及代理客户端。管理员账户用量区域点击「点数」或「次数」,通过 `POST /api/v1/admin/openai/accounts/:id/quota/refresh` 一次刷新两种数据。只读查询仍使用 `GET /api/v1/admin/openai/accounts/:id/quota`。 + +`credits` 随额度查询和重置后的查询结果返回。点数缓存保存在账户 `extra.codex_credits_snapshot`,包含 `credits` 和 Unix 秒时间戳 `fetched_at`,无需数据库迁移。`credits_cache_persisted` 单独报告点数缓存是否保存成功,不受重置卡到期明细缺失影响。余额按钮提示中显示查询时间,点击可刷新。 + +- 有限点数:显示余额字符串,保留小数。 +- `unlimited: true`:显示「无限」,优先于其他字段。 +- `has_credits: false`:显示 `0`。 +- 有点数但余额未公开:显示「可用」。 +- 缺失或空的 `credits`:显示 `—`,不当作零余额;成功刷新会清除旧余额快照。 +- 请求失败:保留最近一次查询结果并显示错误。 +- Spark 影子账号:使用既有母账号凭证解析逻辑查询,缓存保存在被查询行。 + +本接入只查询和展示点数,不触发购买或消费;原有重置操作仍只使用重置卡。 + +## 可邀请次数与邀请用户 + +邀请接口核对来源为本机桌面应用 `26.908.40834` 的 `app.asar/webview/assets/app-primary-44ec287874b7.js`,对应 `SIt`(资格查询)、`EIt`(发送)、`PIt`(邀请计划)、`FIt`(次数约束)。邀请功能不在本机 Rust CLI 源码中。 + +上游接口: + +```http +GET https://chatgpt.com/backend-api/referrals/invite/eligibility?program_id=codex_referral_consumer&entrypoint=persistent +POST https://chatgpt.com/backend-api/referrals/invite +Content-Type: application/json + +{"program_id":"codex_referral_consumer","entrypoint":"persistent","emails":["friend@example.com"]} +``` + +个人邀请使用 `codex_referral_consumer`,工作空间邀请使用 `codex_referral_workspace`。sub2api 优先使用已保存的 `account_type` 判断工作空间,否则根据 `plan_type` 选择。资格接口返回的 `should_show` 决定是否可发送;`remaining_send_capacity` 是剩余发送次数,有奖励时还受 `remaining_reward_capacity` 限制。保留空值表示未知,不硬编码历史活动的邀请上限。每次发送一个邮箱。 + +管理员入口:账户用量区域的「可邀请」查询按钮与「邀请用户」弹窗。支持以下接口: + +- `POST /api/v1/admin/openai/accounts/:id/referrals/refresh`:查询并缓存资格和次数到 `extra.codex_referral_snapshot`,包含查询时间。返回 `eligibility` 和 `cache_persisted`。 +- `POST /api/v1/admin/openai/accounts/:id/referrals/invite`:请求体为 `email`、`program_id`、`confirmed`。后端重新查询资格,验证计划、邮箱、次数及收件人同意状态,再发送邀请。 + +活动说明和资格规则显示上游的 `title`、`description` 和 `rules`。`requires_explicit_confirmation` 不为 `false` 时必须勾选收件人同意;此值不伪造为已同意。Spark 影子账号可查询母账号次数,但发送需从母账号操作。 + +发送成功后,后端以有时限的独立上下文刷新次数。回读失败仍返回 `sent: true`、`refresh_failed: true`,并尝试清空过期快照。发送请求不自动重试;网络错误或无法确认的响应要求先在 Codex 核对状态。接口错误不会向前端透传上游原始响应中的凭证或无关个人信息。 diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 7e6e04280faf..47ffc1c60651 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -4,6 +4,7 @@ */ import { apiClient } from '../client' +import type { OpenAIReferralRefreshResult, OpenAIReferralSendResult } from '@/types/openaiReferrals' import type { Account, AccountListItem, @@ -914,9 +915,16 @@ export interface OpenAIQuotaUsage { rate_limit?: OpenAIRateLimit | null additional_rate_limits?: OpenAIAdditionalRateLimit[] rate_limit_reset_credits?: OpenAIRateLimitResetCredits | null + credits?: OpenAICredits | null fetched_at: number } +export interface OpenAICredits { + has_credits: boolean + unlimited: boolean + balance: string | null +} + export interface OpenAIQuotaResetCredit { id?: string reset_type?: string @@ -944,6 +952,7 @@ export interface OpenAIQuotaResetResult { /** Usage payload plus whether the reset-credit snapshot was persisted. */ export interface OpenAIQuotaRefreshResult extends OpenAIQuotaUsage { cache_persisted: boolean + credits_cache_persisted?: boolean } /** @@ -962,6 +971,23 @@ export async function refreshOpenAIQuota(id: number): Promise { + const { data } = await apiClient.post( + `/admin/openai/accounts/${id}/referrals/refresh` + ) + return data +} + +export async function sendOpenAIReferralInvite( + id: number, + input: { email: string; program_id: string; confirmed: boolean } +): Promise { + const { data } = await apiClient.post( + `/admin/openai/accounts/${id}/referrals/invite`, input, { timeout: 90_000 } + ) + return data +} + /** * Consume one rate-limit-reset credit for an OpenAI/Codex OAuth account. * diff --git a/frontend/src/components/account/OpenAIQuotaResetCell.vue b/frontend/src/components/account/OpenAIQuotaResetCell.vue index 6a3225b8bf59..60a8465634e7 100644 --- a/frontend/src/components/account/OpenAIQuotaResetCell.vue +++ b/frontend/src/components/account/OpenAIQuotaResetCell.vue @@ -8,8 +8,8 @@ The 5h / 7d window bars are deliberately NOT rendered here — the local active-sampling display (UsageProgressBar in AccountUsageCell) already - owns that real estate. This cell is purely about the rate-limit reset - credit: query its count, consume one if needed. + owns that real estate. This cell queries Codex points and reset credits, + and lets the operator consume a reset credit if needed. -->
@@ -61,6 +61,23 @@ {{ t('admin.accounts.openaiQuotaReset.reset') }} + + + +
+ +
+ {{ t('admin.accounts.openaiQuotaReset.pointsCachePersistFailed') }}
(null) const resetWarning = ref(null) const showResetConfirm = ref(false) const showResetCreditDetails = ref(false) +const creditsCacheWarning = ref(false) + +const readCachedCredits = (account: Account) => { + const snapshot = account.extra?.codex_credits_snapshot + const credits = snapshot?.credits + if (!credits || typeof credits.has_credits !== 'boolean' || typeof credits.unlimited !== 'boolean') return null + if (credits.balance != null && typeof credits.balance !== 'string') return null + return { credits, fetched_at: snapshot.fetched_at } +} +const creditsData = ref(readCachedCredits(props.account)) +const creditsDisplay = computed(() => { + const credits = creditsData.value?.credits + if (!credits) return '—' + if (credits.unlimited) return t('admin.accounts.openaiQuotaReset.pointsUnlimited') + if (!credits.has_credits) return '0' + const balance = credits.balance?.trim() + // Keep the upstream decimal string intact, including fractional points. + if (balance && Number.isFinite(Number(balance)) && Number(balance) >= 0) return balance + return t('admin.accounts.openaiQuotaReset.pointsAvailable') +}) +const creditsButtonTitle = computed(() => { + const fetchedAt = creditsData.value?.fetched_at + const refresh = t('admin.accounts.openaiQuotaReset.pointsTooltip') + if (!fetchedAt || !Number.isFinite(fetchedAt)) return refresh + return `${refresh}\n${t('admin.accounts.openaiQuotaReset.pointsUpdatedAt', { + time: new Date(fetchedAt * 1000).toLocaleString() + })}` +}) + +const updateCredits = (usage: OpenAIQuotaUsage | null) => { + creditsData.value = usage?.credits ? { credits: usage.credits, fetched_at: usage.fetched_at } : null +} type AutoResetCreditState = NonNullable['codex_auto_reset_credit_state']> const validAutoResetStatuses = new Set(['checking', 'available', 'resetting', 'success', 'no_credit', 'failed']) @@ -383,14 +433,19 @@ const toggleResetCreditDetails = () => { } const handleQuery = async () => { - if (loading.value) return + if (loading.value || resetting.value) return + const accountID = props.account.id loading.value = true + creditsCacheWarning.value = false error.value = null resetMessage.value = null resetWarning.value = null showResetCreditDetails.value = false try { - const result = await refreshOpenAIQuota(props.account.id) + const result = await refreshOpenAIQuota(accountID) + if (props.account.id !== accountID) return + updateCredits(result) + creditsCacheWarning.value = result.credits_cache_persisted === false // The upstream read succeeded even when the snapshot write was rejected, so // the live count is always adopted. Only the persisted view is left alone, // which keeps the displayed expirations consistent with what is stored. @@ -401,9 +456,10 @@ const handleQuery = async () => { resetWarning.value = t('admin.accounts.openaiQuotaReset.refreshCachePersistFailed') } } catch (e) { + if (props.account.id !== accountID) return error.value = extractErrorMessage(e) } finally { - loading.value = false + if (props.account.id === accountID) loading.value = false } } @@ -424,11 +480,15 @@ const confirmReset = async () => { return } resetting.value = true + const accountID = props.account.id + creditsCacheWarning.value = false error.value = null resetMessage.value = null resetWarning.value = null try { - const result: OpenAIQuotaResetResult = await resetOpenAIQuota(props.account.id) + const result: OpenAIQuotaResetResult = await resetOpenAIQuota(accountID) + if (props.account.id !== accountID) return + updateCredits(result.quota ?? null) showResetCreditDetails.value = false if (result.cache_refreshed && result.quota) { data.value = result.quota @@ -453,9 +513,10 @@ const confirmReset = async () => { }) } } catch (e) { + if (props.account.id !== accountID) return error.value = extractErrorMessage(e) } finally { - resetting.value = false + if (props.account.id === accountID) resetting.value = false } } @@ -465,6 +526,8 @@ watch( // Account row may be reused across paginated lists; reset local state. cachedData.value = readCachedResetCredits(props.account) data.value = cachedData.value + creditsData.value = readCachedCredits(props.account) + creditsCacheWarning.value = false error.value = null resetMessage.value = null resetWarning.value = null diff --git a/frontend/src/components/account/OpenAIReferralCell.vue b/frontend/src/components/account/OpenAIReferralCell.vue new file mode 100644 index 000000000000..8496a07796ac --- /dev/null +++ b/frontend/src/components/account/OpenAIReferralCell.vue @@ -0,0 +1,220 @@ + + + diff --git a/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts b/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts index 280725bc5b65..53101d8f9267 100644 --- a/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts +++ b/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts @@ -3,11 +3,13 @@ import { flushPromises, mount } from '@vue/test-utils' import OpenAIQuotaResetCell from '../OpenAIQuotaResetCell.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import type { Account } from '@/types' -import { refreshOpenAIQuota, resetOpenAIQuota } from '@/api/admin/accounts' +import { refreshOpenAIQuota, resetOpenAIQuota, type OpenAIQuotaRefreshResult } from '@/api/admin/accounts' vi.mock('@/api/admin/accounts', () => ({ refreshOpenAIQuota: vi.fn(), resetOpenAIQuota: vi.fn(), + refreshOpenAIReferrals: vi.fn(), + sendOpenAIReferralInvite: vi.fn(), })) vi.mock('vue-i18n', async () => { @@ -64,6 +66,89 @@ beforeEach(() => { vi.mocked(resetOpenAIQuota).mockReset() }) +describe('OpenAIQuotaResetCell — Codex 点数', () => { + const points = (wrapper: ReturnType) => wrapper.get('[data-testid="codex-credits"]') + const balance = { has_credits: true, unlimited: false, balance: '12345678901234567890.0123' } + + it.each([ + { name: 'decimal precision', credits: balance, expected: balance.balance }, + { name: 'zero', credits: { has_credits: false, unlimited: false, balance: '0' }, expected: '0' }, + { name: 'unlimited takes precedence', credits: { has_credits: false, unlimited: true, balance: null }, expected: 'admin.accounts.openaiQuotaReset.pointsUnlimited' }, + { name: 'hidden balance', credits: { has_credits: true, unlimited: false, balance: null }, expected: 'admin.accounts.openaiQuotaReset.pointsAvailable' }, + { name: 'invalid balance', credits: { has_credits: true, unlimited: false, balance: 'NaN' }, expected: 'admin.accounts.openaiQuotaReset.pointsAvailable' }, + { name: 'absent credits', credits: undefined, expected: '—' }, + { name: 'null credits', credits: null, expected: '—' }, + ])('queries and displays $name independently of reset cards', async ({ credits, expected }) => { + vi.mocked(refreshOpenAIQuota).mockResolvedValue({ + credits, fetched_at: 1770000000, cache_persisted: false, credits_cache_persisted: true, + }) + const wrapper = mount(OpenAIQuotaResetCell, { props: { account: makeAccount({}) } }) + expect(points(wrapper).text()).toContain('—') + await points(wrapper).trigger('click') + await flushPromises() + expect(refreshOpenAIQuota).toHaveBeenCalledWith(1) + expect(points(wrapper).text()).toContain(expected) + expect(resetButton(wrapper).attributes('disabled')).toBeDefined() + expect(resetOpenAIQuota).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('rehydrates points with their query timestamp without an upstream request', () => { + const wrapper = mount(OpenAIQuotaResetCell, { props: { account: makeAccount({ + extra: { codex_credits_snapshot: { credits: balance, fetched_at: 1770000000 } }, + }) } }) + expect(points(wrapper).text()).toContain(balance.balance) + expect(points(wrapper).attributes('title')).toContain('admin.accounts.openaiQuotaReset.pointsUpdatedAt:') + expect(refreshOpenAIQuota).not.toHaveBeenCalled() + expect(resetButton(wrapper).attributes('disabled')).toBeDefined() + wrapper.unmount() + }) + + it('keeps the last balance on a request failure, but clears it on a successful unknown response', async () => { + const wrapper = mount(OpenAIQuotaResetCell, { props: { account: makeAccount({ + extra: { codex_credits_snapshot: { credits: balance, fetched_at: 1770000000 } }, + }) } }) + vi.mocked(refreshOpenAIQuota).mockRejectedValueOnce(new Error('network unavailable')) + await points(wrapper).trigger('click') + await flushPromises() + expect(points(wrapper).text()).toContain(balance.balance) + expect(wrapper.text()).toContain('network unavailable') + + vi.mocked(refreshOpenAIQuota).mockResolvedValueOnce({ fetched_at: 1770000001, cache_persisted: true, credits_cache_persisted: true }) + await points(wrapper).trigger('click') + await flushPromises() + expect(points(wrapper).text()).toContain('—') + expect(wrapper.text()).not.toContain(balance.balance) + wrapper.unmount() + }) + + it('reports a points-cache write failure while displaying the live balance', async () => { + vi.mocked(refreshOpenAIQuota).mockResolvedValue({ + credits: balance, fetched_at: 1770000000, cache_persisted: true, credits_cache_persisted: false, + }) + const wrapper = mount(OpenAIQuotaResetCell, { props: { account: makeAccount({}) } }) + await points(wrapper).trigger('click') + await flushPromises() + expect(points(wrapper).text()).toContain(balance.balance) + expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.pointsCachePersistFailed') + wrapper.unmount() + }) + + it('does not apply a pending response to another account row', async () => { + let resolve!: (value: OpenAIQuotaRefreshResult) => void + vi.mocked(refreshOpenAIQuota).mockReturnValue(new Promise((done) => { resolve = done })) + const wrapper = mount(OpenAIQuotaResetCell, { props: { account: makeAccount({}) } }) + await points(wrapper).trigger('click') + expect(points(wrapper).attributes('disabled')).toBeDefined() + await wrapper.setProps({ account: makeAccount({ id: 2 }) }) + resolve({ credits: balance, fetched_at: 1770000000, cache_persisted: true, credits_cache_persisted: true }) + await flushPromises() + expect(points(wrapper).text()).toContain('—') + expect(points(wrapper).attributes('disabled')).toBeUndefined() + wrapper.unmount() + }) +}) + describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => { it('影子账号(parent_account_id 非空)的 reset 按钮被禁用且提示在母账号重置', () => { const account = makeAccount({ parent_account_id: 100 }) @@ -242,6 +327,7 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => { cache_refreshed: true, account_state_recovered: true, quota: { + credits: { has_credits: true, unlimited: false, balance: '999.25' }, rate_limit_reset_credits: { available_count: 0, credits: [], @@ -267,6 +353,7 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => { expect(resetOpenAIQuota).toHaveBeenCalledWith(1) expect(refreshOpenAIQuota).not.toHaveBeenCalled() + expect(wrapper.get('[data-testid="codex-credits"]').text()).toContain('999.25') expect(wrapper.text()).not.toContain('admin.accounts.openaiQuotaReset.expiresAt:') expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.resetSuccess') expect(wrapper.emitted('account-updated')).toEqual([[recoveredAccount]]) diff --git a/frontend/src/components/account/__tests__/OpenAIReferralCell.spec.ts b/frontend/src/components/account/__tests__/OpenAIReferralCell.spec.ts new file mode 100644 index 000000000000..f8bb1d991554 --- /dev/null +++ b/frontend/src/components/account/__tests__/OpenAIReferralCell.spec.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import OpenAIReferralCell from '../OpenAIReferralCell.vue' +import type { Account } from '@/types' +import type { OpenAIReferralEligibility, OpenAIReferralRefreshResult } from '@/types/openaiReferrals' +import { refreshOpenAIReferrals, sendOpenAIReferralInvite } from '@/api/admin/accounts' + +vi.mock('@/api/admin/accounts', () => ({ refreshOpenAIReferrals: vi.fn(), sendOpenAIReferralInvite: vi.fn() })) +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string, params?: Record) => params ? `${key}:${Object.values(params).join(',')}` : key }) })) + +const eligibility: OpenAIReferralEligibility = { + should_show: true, remaining_send_capacity: 3, remaining_reward_capacity: 2, + requires_explicit_confirmation: true, program_id: 'codex_referral_consumer', available_invites: 2, + fetched_at: 1770000000, title: 'Test offer', rules: ['Test eligibility rule'], +} +const account = (overrides: Partial = {}) => ({ + id: 1, name: 'Test account', platform: 'openai', type: 'oauth', + ...overrides, +}) as Account +const mountCell = (value = account()) => mount(OpenAIReferralCell, { + props: { account: value }, global: { stubs: { teleport: true } }, +}) +const open = async (wrapper: ReturnType) => { + await wrapper.get('[data-testid="referral-open"]').trigger('click') + await flushPromises() +} +const completeForm = async (wrapper: ReturnType) => { + await wrapper.get('[data-testid="referral-email"]').setValue('friend@example.com') + await wrapper.get('[data-testid="referral-consent"]').setValue(true) +} + +beforeEach(() => { + vi.mocked(refreshOpenAIReferrals).mockReset().mockResolvedValue({ eligibility: { ...eligibility }, cache_persisted: true }) + vi.mocked(sendOpenAIReferralInvite).mockReset() +}) + +describe('OpenAIReferralCell', () => { + it('shows cached capacity without sending requests, then refreshes when opening the form', async () => { + const wrapper = mountCell(account({ extra: { codex_referral_snapshot: eligibility } })) + expect(wrapper.get('[data-testid="referral-count"]').text()).toContain('2') + expect(refreshOpenAIReferrals).not.toHaveBeenCalled() + await open(wrapper) + expect(refreshOpenAIReferrals).toHaveBeenCalledWith(1) + expect(wrapper.text()).toContain('Test account') + expect(wrapper.text()).toContain('Test eligibility rule') + expect(wrapper.get('[data-testid="referral-send"]').attributes('disabled')).toBeDefined() + expect(sendOpenAIReferralInvite).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('requires a valid email and consent, and clears consent when the recipient changes', async () => { + const wrapper = mountCell() + await open(wrapper) + await wrapper.get('[data-testid="referral-email"]').setValue('invalid') + await wrapper.get('[data-testid="referral-consent"]').setValue(true) + await wrapper.get('form').trigger('submit') + expect(sendOpenAIReferralInvite).not.toHaveBeenCalled() + await wrapper.get('[data-testid="referral-email"]').setValue('friend@example.com') + expect((wrapper.get('[data-testid="referral-consent"]').element as HTMLInputElement).checked).toBe(false) + expect(wrapper.get('[data-testid="referral-send"]').attributes('disabled')).toBeDefined() + await wrapper.get('[data-testid="referral-consent"]').setValue(true) + expect(wrapper.get('[data-testid="referral-send"]').attributes('disabled')).toBeUndefined() + wrapper.unmount() + }) + + it.each([0, null])('blocks sends when remaining capacity is %s', async (capacity) => { + vi.mocked(refreshOpenAIReferrals).mockResolvedValue({ eligibility: { ...eligibility, available_invites: capacity }, cache_persisted: true }) + const wrapper = mountCell() + await open(wrapper) + await completeForm(wrapper) + await wrapper.get('form').trigger('submit') + expect(sendOpenAIReferralInvite).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('admin.accounts.openaiReferral.unavailable') + wrapper.unmount() + }) + + it('sends once and displays the recipient and refreshed count', async () => { + vi.mocked(sendOpenAIReferralInvite).mockResolvedValue({ + sent: true, email: 'friend@example.com', eligibility: { ...eligibility, available_invites: 1 }, cache_persisted: true, refresh_failed: false, + }) + const wrapper = mountCell() + await open(wrapper) + await completeForm(wrapper) + await wrapper.get('form').trigger('submit') + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(sendOpenAIReferralInvite).toHaveBeenCalledTimes(1) + expect(sendOpenAIReferralInvite).toHaveBeenCalledWith(1, { email: 'friend@example.com', program_id: 'codex_referral_consumer', confirmed: true }) + expect(wrapper.text()).toContain('admin.accounts.openaiReferral.sent:friend@example.com') + expect(wrapper.get('[data-testid="referral-count"]').text()).toContain('1') + expect((wrapper.get('[data-testid="referral-email"]').element as HTMLInputElement).value).toBe('') + wrapper.unmount() + }) + + it('keeps send success when the capacity refresh fails and disables another send', async () => { + vi.mocked(sendOpenAIReferralInvite).mockResolvedValue({ sent: true, email: 'friend@example.com', eligibility: null, cache_persisted: true, refresh_failed: true }) + const wrapper = mountCell() + await open(wrapper) + await completeForm(wrapper) + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(wrapper.text()).toContain('admin.accounts.openaiReferral.sent:friend@example.com') + expect(wrapper.text()).toContain('admin.accounts.openaiReferral.refreshFailed') + expect(wrapper.get('[data-testid="referral-count"]').text()).toContain('—') + await completeForm(wrapper) + expect(wrapper.get('[data-testid="referral-send"]').attributes('disabled')).toBeDefined() + wrapper.unmount() + }) + + it('does not automatically retry an uncertain send', async () => { + vi.mocked(sendOpenAIReferralInvite).mockRejectedValue({ reason: 'OPENAI_REFERRAL_SEND_UNKNOWN' }) + const wrapper = mountCell() + await open(wrapper) + await completeForm(wrapper) + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(wrapper.text()).toContain('admin.accounts.openaiReferral.sendUnknown') + expect(sendOpenAIReferralInvite).toHaveBeenCalledTimes(1) + expect(wrapper.get('[data-testid="referral-send"]').attributes('disabled')).toBeDefined() + wrapper.unmount() + }) + + it('prevents sending from a shadow account', () => { + const wrapper = mountCell(account({ parent_account_id: 100 })) + expect(wrapper.get('[data-testid="referral-open"]').attributes('disabled')).toBeDefined() + expect(wrapper.get('[data-testid="referral-open"]').attributes('title')).toContain('shadowHint') + wrapper.unmount() + }) + + it('does not apply a pending query to a different account', async () => { + let resolve!: (value: OpenAIReferralRefreshResult) => void + vi.mocked(refreshOpenAIReferrals).mockReturnValue(new Promise((done) => { resolve = done })) + const wrapper = mountCell() + await wrapper.get('[data-testid="referral-count"]').trigger('click') + await wrapper.setProps({ account: account({ id: 2 }) }) + resolve({ eligibility, cache_persisted: true }) + await flushPromises() + expect(wrapper.get('[data-testid="referral-count"]').text()).toContain('—') + wrapper.unmount() + }) +}) diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 8c675a1c31cd..b25f0f8c8700 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -1585,13 +1585,44 @@ export default { estimatedTotalCost: 'Est. total ${cost}', estimatedTotalCostTooltip: 'Estimated total cost at 100% utilization, based on current window cost and utilization' }, + openaiReferral: { + available: 'Invites left', + invite: 'Invite user', + fromAccount: 'Inviting account:', + personal: 'Invite a friend', + workspace: 'Invite a coworker', + email: 'Recipient email', + consent: 'I have this person’s consent to send them an invitation.', + send: 'Send invitation', + sending: 'Sending…', + sent: 'Invitation sent to {email}', + queryHint: 'Click to query remaining invitations', + checkedAt: 'Checked: {time}. Click to refresh.', + unavailable: 'Invitations are unavailable. Eligibility requirements may not be met, or the limit has been reached.', + invalidEmail: 'Enter one valid email address.', + rejected: 'The invitation was rejected. Check the email and offer eligibility.', + alreadyInvited: 'An invitation already exists for this email. Check its status in Codex.', + rateLimited: 'The invitation rate or capacity limit has been reached. Try again later.', + sendUnknown: 'The invitation outcome is unknown. Check its status in Codex before deciding whether to retry.', + programChanged: 'The account’s referral program changed. Refresh eligibility before sending.', + consentRequired: 'Confirm that you have the recipient’s consent first.', + shadowHint: 'Send invitations from the parent account.', + cacheFailed: 'Live capacity was fetched, but the cache could not be saved. Query again.', + refreshFailed: 'The invitation was sent, but remaining capacity could not be refreshed. Query again.' + }, openaiQuotaReset: { - count: 'Credits', + count: 'Resets', + points: 'Points', + pointsUnlimited: 'Unlimited', + pointsAvailable: 'Available', + pointsTooltip: 'Click to query Codex points and reset credits', + pointsUpdatedAt: 'Balance checked: {time}', + pointsCachePersistFailed: 'Showing live points, but the cache could not be saved. Query again.', reset: 'Reset', - countTooltipLoad: 'Click to load the available reset-credit count', - countTooltipRefresh: 'Click to refresh the available reset-credit count', + countTooltipLoad: 'Click to load the available reset-credit count and points balance', + countTooltipRefresh: 'Click to refresh the available reset-credit count and points balance', resetTooltipReady: 'Consume 1 reset credit to immediately restore the window', - resetTooltipNeedQuery: 'Click Credits first to load the available count', + resetTooltipNeedQuery: 'Click Resets first to load the available count', resetTooltipNoCredits: 'No reset credits available', resetTooltipShadow: 'Spark shadow accounts cannot reset credits; reset on the parent account', expiresAt: 'Expires {time}', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index a93e5484212d..fc4bb944269c 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -503,11 +503,42 @@ export default { estimatedTotalCost: '预计总费用 ${cost}', estimatedTotalCostTooltip: '根据当前窗口费用和使用率估算达到 100% 使用率时的总费用' }, + openaiReferral: { + available: '可邀请', + invite: '邀请用户', + fromAccount: '邀请账号:', + personal: '邀请朋友', + workspace: '邀请同事', + email: '受邀人邮箱', + consent: '我已取得此人的同意,可以向其发送邀请。', + send: '发送邀请', + sending: '正在发送…', + sent: '邀请已发送至 {email}', + queryHint: '点击查询剩余可邀请次数', + checkedAt: '查询时间:{time},点击刷新', + unavailable: '当前无法邀请用户,可能未满足活动条件或次数已用完。', + invalidEmail: '请输入一个有效的邮箱地址。', + rejected: '上游拒绝了邀请,请检查邮箱和活动资格。', + alreadyInvited: '此邮箱已有邀请,请在 Codex 中查看邀请状态。', + rateLimited: '邀请频率或次数已达上限,请稍后再试。', + sendUnknown: '暂时无法确认邀请是否已发送,请先在 Codex 中核对邀请状态,再决定是否重试。', + programChanged: '账号的邀请计划已变化,请刷新邀请资格后再发送。', + consentRequired: '请先确认已取得受邀人的同意。', + shadowHint: '请在母账号上发送邀请。', + cacheFailed: '实时次数已获取,但缓存保存失败,请重新查询。', + refreshFailed: '邀请已发送,但剩余次数刷新失败,请重新查询。' + }, openaiQuotaReset: { count: '次数', + points: '点数', + pointsUnlimited: '无限', + pointsAvailable: '可用', + pointsTooltip: '点击查询 Codex 点数余额和重置次数', + pointsUpdatedAt: '余额查询时间:{time}', + pointsCachePersistFailed: '已显示实时点数,但缓存保存失败,请重新查询。', reset: '重置', - countTooltipLoad: '点击查询剩余重置次数', - countTooltipRefresh: '点击刷新剩余重置次数', + countTooltipLoad: '点击查询剩余重置次数和点数余额', + countTooltipRefresh: '点击刷新剩余重置次数和点数余额', resetTooltipReady: '消耗 1 次重置次数以立即恢复当前窗口', resetTooltipNeedQuery: '先点击「次数」加载剩余重置次数', resetTooltipNoCredits: '没有可用的重置次数', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 60eb7e58020c..a2022c78dd6b 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1179,6 +1179,11 @@ export interface Account { available_count?: number credits?: { expires_at?: string }[] } + codex_credits_snapshot?: { + credits: { has_credits: boolean; unlimited: boolean; balance: string | null } | null + fetched_at: number + } + codex_referral_snapshot?: import('./openaiReferrals').OpenAIReferralEligibility | null auto_reset_credit_enabled?: boolean auto_reset_credit_5h_threshold?: number auto_reset_credit_7d_threshold?: number diff --git a/frontend/src/types/openaiReferrals.ts b/frontend/src/types/openaiReferrals.ts new file mode 100644 index 000000000000..a9ef1af965a1 --- /dev/null +++ b/frontend/src/types/openaiReferrals.ts @@ -0,0 +1,25 @@ +export interface OpenAIReferralEligibility { + should_show: boolean + remaining_send_capacity: number | null + remaining_reward_capacity: number | null + requires_explicit_confirmation: boolean | null + offer_id?: string + title?: string + description?: string + rules?: string[] + grants?: { grant_type: string; recipient: string; amount: number }[] + program_id: string + available_invites: number | null + fetched_at: number +} + +export interface OpenAIReferralRefreshResult { + eligibility: OpenAIReferralEligibility | null + cache_persisted: boolean +} + +export interface OpenAIReferralSendResult extends OpenAIReferralRefreshResult { + email: string + sent: boolean + refresh_failed: boolean +} From 76346b262278168b84809ffd22415fd705e22146 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Wed, 16 Sep 2026 02:23:16 +0800 Subject: [PATCH 2/2] docs: add Codex referral UI validation screenshots --- .gitignore | 2 ++ docs/codex-credits.md | 8 ++++++++ docs/screenshots/codex-referral-dialog.png | Bin 0 -> 30837 bytes docs/screenshots/codex-referral-success.png | Bin 0 -> 33194 bytes 4 files changed, 10 insertions(+) create mode 100644 docs/screenshots/codex-referral-dialog.png create mode 100644 docs/screenshots/codex-referral-success.png diff --git a/.gitignore b/.gitignore index cab1d1cae6db..64b5dde4fdc8 100644 --- a/.gitignore +++ b/.gitignore @@ -147,6 +147,8 @@ docs/* !docs/screenshots/ docs/screenshots/* !docs/screenshots/mobile-account-actions-menu.png +!docs/screenshots/codex-referral-dialog.png +!docs/screenshots/codex-referral-success.png .serena/ .codex/ frontend/coverage/ diff --git a/docs/codex-credits.md b/docs/codex-credits.md index 2b7c48a7fdff..9abc17ca45bd 100644 --- a/docs/codex-credits.md +++ b/docs/codex-credits.md @@ -49,3 +49,11 @@ Content-Type: application/json 活动说明和资格规则显示上游的 `title`、`description` 和 `rules`。`requires_explicit_confirmation` 不为 `false` 时必须勾选收件人同意;此值不伪造为已同意。Spark 影子账号可查询母账号次数,但发送需从母账号操作。 发送成功后,后端以有时限的独立上下文刷新次数。回读失败仍返回 `sent: true`、`refresh_failed: true`,并尝试清空过期快照。发送请求不自动重试;网络错误或无法确认的响应要求先在 Codex 核对状态。接口错误不会向前端透传上游原始响应中的凭证或无关个人信息。 + +### 界面测试 + +以下截图使用真实 Vue 组件和模拟 API 响应,不包含真实账号凭证,也没有发送邮件。 + +![邀请弹窗与同意确认](screenshots/codex-referral-dialog.png) + +![模拟发送成功后刷新剩余次数](screenshots/codex-referral-success.png) diff --git a/docs/screenshots/codex-referral-dialog.png b/docs/screenshots/codex-referral-dialog.png new file mode 100644 index 0000000000000000000000000000000000000000..b0ec8a019d40a27ff57baf4df7347e4fad0c29f4 GIT binary patch literal 30837 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 BNg4nE literal 0 HcmV?d00001 diff --git a/docs/screenshots/codex-referral-success.png b/docs/screenshots/codex-referral-success.png new file mode 100644 index 0000000000000000000000000000000000000000..e443315e8b6bb0855abde9c0f8384f643e46ffb4 GIT binary patch literal 33194 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

vUv55m^TE3m5Zg^Dv{t*2^c*sK?bI-4g zUbRL;gZBLf*EqL<99yp%_I>Le)9M*h`COciA}so*d05TB_V1Y8@R9n{lMF zh&ko?e`U!7@)hUdeakNSeWkY;AcQnmL z_!usG!O)kvt@jkz$y0#pvlicHOgk>EMp^U(5iZOoVET{@BqhyH!%n|b@%pctb;|>V z)z0s>M})k)_~2xd?Us|uw+@WFDm?_Skyrh#baJl(R8(!BS?MJ~srp!btER$PNQ6(x zh?2)8>@Fd@QCTG*P_&atq9@X$NI*+|P))i&`;;B-$(mn^7{ z%hrI?LGm4s9WRB#Jy6|(P!ts$;!b3!rdWX19VPAN7)ydg7uT%Yj;ixSa#Pmp8{qYX zLxJC(iI4k@ZKJLnH2L}KU-hg0#!lPzzP@rLPG#@sy4r`Yx+&^=1w}>rXO#~0drTI` zI=z@`75(Oo5-%6n!(Ke~Br9$w|3q*PklgOr)ERj9x54$_O-EM?>M_x(KDLAgPpJ>Y z^6*zpFCHpw0v>XBKi5zFsdN#Y_(PcG!Jy2VpN6P>I8rlBg-x&`DC>C!zwi8HRdjO_ zzQW4R$@Tf3il+T$7$6$l3I)xOOMqoSpVz4?Gr|X$#ID&TzkC z-yCvUdGe7`5tqB8UAQQowdf5G2}vr_wy`V#*{a%|R(-7MD`!%BeDCU3-2;E)gY_%B zZ`*M^4G?$4{`)J)&LfY2=iVslTjg{o=znH^P~>&@*j9F)LE12OE1QPKb>0Ds