Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 63 additions & 4 deletions backend/internal/handler/admin/openai_oauth_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ import (

// OpenAIOAuthHandler handles OpenAI OAuth-related operations
type OpenAIOAuthHandler struct {
openaiOAuthService *service.OpenAIOAuthService
adminService service.AdminService
quotaService openAIQuotaService
rateLimitService openAIAccountStateRecoverer
openaiOAuthService *service.OpenAIOAuthService
adminService service.AdminService
quotaService openAIQuotaService
expiryTargetService openAIResetCreditExpiryTargetService
rateLimitService openAIAccountStateRecoverer
}

type openAIQuotaService interface {
Expand All @@ -30,6 +31,11 @@ type openAIQuotaService interface {
ResetCredit(ctx context.Context, accountID int64) (*service.OpenAIQuotaResetResult, error)
}

type openAIResetCreditExpiryTargetService interface {
SetResetCreditExpiryTarget(ctx context.Context, accountID int64, creditID string, leadTimeMinutes int) (*service.Account, error)
CancelResetCreditExpiryTarget(ctx context.Context, accountID int64) (*service.Account, error)
}

type openAIAccountStateRecoverer interface {
RecoverAccountState(ctx context.Context, accountID int64, options service.AccountRecoveryOptions) (*service.SuccessfulTestRecoveryResult, error)
}
Expand Down Expand Up @@ -91,13 +97,66 @@ func NewOpenAIOAuthHandler(
// `== nil` capability guards below and panic instead of returning 400.
if quotaService != nil {
h.quotaService = quotaService
h.expiryTargetService = quotaService
}
if rateLimitService != nil {
h.rateLimitService = rateLimitService
}
return h
}

// SetResetCreditExpiryTarget creates or updates the single-card expiry plan.
// PUT /api/v1/admin/openai/accounts/:id/reset-credit-expiry-target
func (h *OpenAIOAuthHandler) SetResetCreditExpiryTarget(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
if h.expiryTargetService == nil {
response.BadRequest(c, "openai quota service is not enabled")
return
}
var req struct {
CreditID string `json:"credit_id"`
LeadTimeMinutes *int `json:"lead_time_minutes"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
leadTimeMinutes := service.OpenAIResetCreditExpiryTargetDefaultLeadTimeMinutes
if req.LeadTimeMinutes != nil {
leadTimeMinutes = *req.LeadTimeMinutes
}
account, err := h.expiryTargetService.SetResetCreditExpiryTarget(c.Request.Context(), accountID, req.CreditID, leadTimeMinutes)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, dto.AccountFromService(account))
}

// CancelResetCreditExpiryTarget cancels the current single-card expiry plan.
// DELETE /api/v1/admin/openai/accounts/:id/reset-credit-expiry-target
func (h *OpenAIOAuthHandler) CancelResetCreditExpiryTarget(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
if h.expiryTargetService == nil {
response.BadRequest(c, "openai quota service is not enabled")
return
}
account, err := h.expiryTargetService.CancelResetCreditExpiryTarget(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, dto.AccountFromService(account))
}

// OpenAIGenerateAuthURLRequest represents the request for generating OpenAI auth URL
type OpenAIGenerateAuthURLRequest struct {
ProxyID *int64 `json:"proxy_id"`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package admin

import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"

"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)

type expiryTargetHandlerService struct {
account *service.Account
creditID string
leadTime int
setCalls int
cancelCalls int
}

func (s *expiryTargetHandlerService) SetResetCreditExpiryTarget(_ context.Context, _ int64, creditID string, leadTimeMinutes int) (*service.Account, error) {
s.setCalls++
s.creditID = creditID
s.leadTime = leadTimeMinutes
return s.account, nil
}

func (s *expiryTargetHandlerService) CancelResetCreditExpiryTarget(context.Context, int64) (*service.Account, error) {
s.cancelCalls++
return s.account, nil
}

func TestOpenAIResetCreditExpiryTargetHandlers(t *testing.T) {
gin.SetMode(gin.TestMode)
stub := &expiryTargetHandlerService{account: &service.Account{ID: 42, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth}}
handler := &OpenAIOAuthHandler{expiryTargetService: stub}
router := gin.New()
router.PUT("/openai/accounts/:id/reset-credit-expiry-target", handler.SetResetCreditExpiryTarget)
router.DELETE("/openai/accounts/:id/reset-credit-expiry-target", handler.CancelResetCreditExpiryTarget)

for index, test := range []struct {
body, creditID string
leadTime int
}{
{`{"credit_id":"credit-one"}`, "credit-one", service.OpenAIResetCreditExpiryTargetDefaultLeadTimeMinutes},
{`{"credit_id":"credit-two","lead_time_minutes":30}`, "credit-two", 30},
} {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPut, "/openai/accounts/42/reset-credit-expiry-target", bytes.NewBufferString(test.body))
request.Header.Set("content-type", "application/json")
router.ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code)
require.Equal(t, index+1, stub.setCalls)
require.Equal(t, test.creditID, stub.creditID)
require.Equal(t, test.leadTime, stub.leadTime)
}

recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodDelete, "/openai/accounts/42/reset-credit-expiry-target", nil))
require.Equal(t, http.StatusOK, recorder.Code)
require.Equal(t, 1, stub.cancelCalls)
}
44 changes: 44 additions & 0 deletions backend/internal/repository/account_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -2629,6 +2629,50 @@ func (r *accountRepository) UpdateExtra(ctx context.Context, id int64, updates m
return nil
}

func (r *accountRepository) CompareAndSwapExtra(ctx context.Context, id int64, key string, expected any, updates map[string]any) (bool, error) {
updates = stripCodexFingerprintSeedFromExtraUpdate(updates)
if strings.TrimSpace(key) == "" || len(updates) == 0 {
return false, nil
}
payload, err := json.Marshal(updates)
if err != nil {
return false, err
}
expectedPayload, err := json.Marshal(expected)
if err != nil {
return false, err
}
result, err := r.sql.ExecContext(ctx, `
UPDATE accounts
SET extra = COALESCE(extra, '{}'::jsonb) || $1::jsonb,
updated_at = NOW()
WHERE id = $2
AND deleted_at IS NULL
AND COALESCE(extra -> $3::text, 'null'::jsonb) = $4::jsonb
`, string(payload), id, key, string(expectedPayload))
if err != nil {
return false, err
}
affected, err := result.RowsAffected()
if err != nil {
return false, err
}
if affected == 0 {
exists, err := r.ExistsByID(ctx, id)
if err != nil {
return false, err
}
if !exists {
return false, service.ErrAccountNotFound
}
return false, nil
}
if dbent.TxFromContext(ctx) == nil {
r.syncSchedulerAccountSnapshot(ctx, id)
}
return true, nil
}

// UpdateUpstreamBillingProbeSnapshot stores a probe result only while the
// network identity used by that probe is still current.
func (r *accountRepository) UpdateUpstreamBillingProbeSnapshot(
Expand Down
58 changes: 58 additions & 0 deletions backend/internal/repository/account_repo_extra_cas_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package repository

import (
"context"
"regexp"
"testing"

"github.com/DATA-DOG/go-sqlmock"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/stretchr/testify/require"

"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
)

func TestCompareAndSwapExtraUsesExpectedJSONValue(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db)))
t.Cleanup(func() { _ = client.Close() })

mock.ExpectExec(`(?s)`+regexp.QuoteMeta("UPDATE accounts")+`.*`+regexp.QuoteMeta("COALESCE(extra -> $3::text, 'null'::jsonb) = $4::jsonb")).
WithArgs(`{"plan":null,"state":{"status":"done"}}`, int64(27), "plan", `{"plan_id":"old"}`).
WillReturnResult(sqlmock.NewResult(0, 1))
repo := newAccountRepositoryWithSQL(client, db, nil)

swapped, err := repo.CompareAndSwapExtra(context.Background(), 27, "plan", map[string]any{"plan_id": "old"}, map[string]any{
"plan": nil,
"state": map[string]any{"status": "done"},
})

require.NoError(t, err)
require.True(t, swapped)
require.NoError(t, mock.ExpectationsWereMet())
}

func TestCompareAndSwapExtraReportsConflictWithoutOverwriting(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db)))
t.Cleanup(func() { _ = client.Close() })

mock.ExpectExec(`(?s)`+regexp.QuoteMeta("UPDATE accounts")+`.*`+regexp.QuoteMeta("COALESCE(extra -> $3::text, 'null'::jsonb) = $4::jsonb")).
WithArgs(`{"plan":null}`, int64(27), "plan", `{"plan_id":"old"}`).
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectQuery(`(?s)SELECT .*accounts.*id.*FROM .*accounts.*WHERE .*accounts.*id.*LIMIT 1`).
WithArgs(int64(27)).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(27)))
repo := newAccountRepositoryWithSQL(client, db, nil)

swapped, err := repo.CompareAndSwapExtra(context.Background(), 27, "plan", map[string]any{"plan_id": "old"}, map[string]any{"plan": nil})

require.NoError(t, err)
require.False(t, swapped)
require.NoError(t, mock.ExpectationsWereMet())
}
2 changes: 2 additions & 0 deletions backend/internal/server/routes/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,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.PUT("/accounts/:id/reset-credit-expiry-target", h.Admin.OpenAIOAuth.SetResetCreditExpiryTarget)
openai.DELETE("/accounts/:id/reset-credit-expiry-target", h.Admin.OpenAIOAuth.CancelResetCreditExpiryTarget)
}
}

Expand Down
1 change: 1 addition & 0 deletions backend/internal/service/admin_account.go
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,7 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
OllamaCloudUsageAutoRefreshExtraKey,
OllamaCloudUsageSnapshotExtraKey,
OpenAIAutoResetCreditStateExtraKey,
OpenAIAutoResetCreditExpiryTargetExtraKey,
} {
if v, ok := account.Extra[key]; ok {
normalizedExtra[key] = v
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/service/audit_log.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ var auditBodySensitiveExactKeys = func() map[string]struct{} {
// custom_key 为用户自设的平台 API Key 明文,
// session 为 Ollama Cloud 用量的浏览器会话 Cookie 明文。
"proxy_key", "custom_key", "session",
// 上游资源 ID 可用于计划和缓存,但不复制到操作审计正文。
"credit_id",
}
set := make(map[string]struct{}, len(builtin)+len(SensitiveCredentialKeys)+16)
for _, k := range builtin {
Expand Down
3 changes: 2 additions & 1 deletion backend/internal/service/audit_log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ func TestRedactAuditBody_JSONRedactsSecrets(t *testing.T) {
"credentials": {"api_key": "sk-secret-123", "base_url": "https://evil.example.com"},
"new_password": "hunter2",
"totp_code": "123456",
"credit_id": "credit-resource-123",
"nested": [{"access_token": "tok_abc"}]
}`)
out := RedactAuditBody(raw, "application/json")
Expand All @@ -48,7 +49,7 @@ func TestRedactAuditBody_JSONRedactsSecrets(t *testing.T) {
}

// 敏感字段被擦除。
for _, secret := range []string{"sk-secret-123", "hunter2", "123456", "tok_abc"} {
for _, secret := range []string{"sk-secret-123", "hunter2", "123456", "credit-resource-123", "tok_abc"} {
if strings.Contains(out, secret) {
t.Fatalf("redacted body still contains secret %q: %s", secret, out)
}
Expand Down
Loading
Loading