Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .github/workflows/downstream-verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,13 @@ jobs:
src/i18n/__tests__/accountConsoleLocales.spec.ts \
src/__tests__/integration/data-import.spec.ts \
src/components/admin/account-jobs/__tests__/AccountTaskDrawer.spec.ts \
src/stores/__tests__/accountJobs.spec.ts \
src/composables/__tests__/useTicketSelection.spec.ts \
src/api/__tests__/accountOperationIdempotency.spec.ts \
src/components/admin/account/__tests__/CodexTicketHarvestModal.spec.ts \
src/components/admin/account/__tests__/BatchTestAccountModal.spec.ts \
src/components/admin/account/__tests__/AccountBulkActionsBar.spec.ts \
src/components/admin/account/__tests__/AccountActionMenu.spark_shadow.spec.ts \
src/components/admin/account/__tests__/AccountConsoleFilters.spec.ts \
src/components/admin/account/__tests__/AccountUsageViews.spec.ts \
src/components/account/__tests__/AccountUsageCell.spec.ts \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,14 @@ func TestCodexTicketRepositoryConcurrentLeaseAndAccountInvalidation(t *testing.T
var lease string
require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT lease_id FROM openai_codex_ticket_runtime WHERE account_id=$1 AND model=$2`, a.ID, model).Scan(&lease))
require.Equal(t, claims[0].LeaseID, lease)
// Disable and re-enable before IO completes must still reject its old result.
// Business status changes do not revoke a credential owner's ticket claim.
_, err = integrationDB.ExecContext(ctx, `UPDATE accounts SET status='disabled' WHERE id=$1`, a.ID)
require.NoError(t, err)
_, err = integrationDB.ExecContext(ctx, `UPDATE accounts SET status='active' WHERE id=$1`, a.ID)
require.NoError(t, err)
applied, err := r.FinishCodexTicket(ctx, claims[0], successfulTicket(a, model, now), service.CodexTicketResult{Success: true}, now)
require.NoError(t, err)
require.False(t, applied)
require.True(t, applied)
// A legacy ticket without an identity stamp cannot survive changing owner.
raw, _ := json.Marshal(map[string]any{"codex_turn_ticket:" + model: map[string]any{"state": "gAAAAA" + strings.Repeat("B", 286), "length": 292, "expires_at": now.Add(time.Hour)}})
_, err = integrationDB.ExecContext(ctx, `UPDATE accounts SET extra=extra||$2::jsonb WHERE id=$1`, a.ID, string(raw))
Expand All @@ -129,6 +129,47 @@ func TestCodexTicketRepositoryConcurrentLeaseAndAccountInvalidation(t *testing.T
require.NotContains(t, loaded.Extra, "codex_turn_ticket:"+model)
}

func TestCodexTicketInactiveAccountsHarvestAndRenewWithoutEnabling(t *testing.T) {
for _, accountType := range []string{service.AccountTypeOAuth, service.AccountTypeSetupToken} {
for _, status := range []string{"disabled", "error"} {
t.Run(accountType+"/"+status, func(t *testing.T) {
ctx := context.Background()
r, a, now := newTicketIntegrationAccount(t)
_, err := integrationDB.ExecContext(ctx, `UPDATE accounts SET type=$2,status=$3,schedulable=false,error_message='keep diagnostic' WHERE id=$1`, a.ID, accountType, status)
require.NoError(t, err)
a, err = r.GetByID(ctx, a.ID)
require.NoError(t, err)
const model = "gpt-6-astra"
claim, err := r.ClaimCodexTicket(ctx, a.ID, model, "manual", 0, true, false, now)
require.NoError(t, err)
ticket := successfulTicket(a, model, now)
applied, err := r.FinishCodexTicket(ctx, claim, ticket, service.CodexTicketResult{Success: true}, now)
require.NoError(t, err)
require.True(t, applied)
renewAt := ticket.ExpiresAt.Add(-time.Minute)
renewal, err := r.ClaimCodexTicket(ctx, a.ID, model, "renewal", 0, false, false, renewAt)
require.NoError(t, err)
applied, err = r.FinishCodexTicket(ctx, renewal, successfulTicket(a, model, renewAt), service.CodexTicketResult{Success: true}, renewAt)
require.NoError(t, err)
require.True(t, applied)
loaded, err := r.GetByID(ctx, a.ID)
require.NoError(t, err)
require.Equal(t, status, loaded.Status)
require.False(t, loaded.Schedulable)
require.Equal(t, "keep diagnostic", loaded.ErrorMessage)
// Deletion still revokes a live claim and rejects the late result.
claim, err = r.ClaimCodexTicket(ctx, a.ID, model, "manual-again", 0, true, true, renewAt)
require.NoError(t, err)
_, err = integrationDB.ExecContext(ctx, `UPDATE accounts SET deleted_at=NOW() WHERE id=$1`, a.ID)
require.NoError(t, err)
applied, err = r.FinishCodexTicket(ctx, claim, successfulTicket(a, model, renewAt), service.CodexTicketResult{Success: true}, renewAt)
require.NoError(t, err)
require.False(t, applied)
})
}
}
}

func TestCodexTicketRepositoryInterruptedRenewalIsConsumed(t *testing.T) {
ctx := context.Background()
r, a, now := newTicketIntegrationAccount(t)
Expand Down
6 changes: 4 additions & 2 deletions backend/internal/service/codex_ticket_lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,16 @@ func CodexTicketAccountIdentity(a *Account) string {
}

func CodexTicketAccountEligible(a *Account) bool {
return isOpenAICodexTicketAccount(a) && a.Status == StatusActive
// Ticket ownership is independent of business scheduling. Administrators may
// prepare and renew tickets while an OAuth-like account is disabled/in error.
return isOpenAICodexTicketAccount(a)
}

func CodexTicketFailure(code string) CodexTicketResult {
messages := map[string]string{
"ticket_disabled": "票据总开关未开启",
"ticket_proxy_missing": "未配置打票代理",
"ticket_ineligible": "仅启用状态的 OpenAI OAuth/Setup Token 非影子账号可打票",
"ticket_ineligible": "仅 OpenAI OAuth/Setup Token 非影子账号可打票",
"ticket_model_invalid": "模型不在票据配置范围内",
"ticket_busy": "该账号和模型已有打票任务",
"ticket_interrupted": "上次请求已开始,结果未确认;不会自动重复发送",
Expand Down
36 changes: 36 additions & 0 deletions backend/internal/service/codex_ticket_manual_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,39 @@ func TestCodexTicketManualOnlyOneRequestAndPersistence(t *testing.T) {
})
}
}

func TestCodexTicketEligibilityIndependentOfAccountStatus(t *testing.T) {
for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} {
for _, status := range []string{StatusActive, "disabled", "error"} {
t.Run(accountType+"/"+status, func(t *testing.T) {
a := ticketTestAccount(41)
a.Type, a.Status, a.Schedulable, a.ErrorMessage = accountType, status, false, "preserve diagnostic"
r := newCodexTicketMemoryStore(a)
var calls atomic.Int64
upstream := &codexTicketFuncUpstream{do: func(*http.Request) (*http.Response, error) {
calls.Add(1)
resp := codexTicketResponse()
resp.Header.Set(openAICodexTurnStateHeader, fakeCodexTicketState(292))
return resp, nil
}}
s := ticketTestService(t, config.OpenAICodexTicketConfig{Enabled: true, HarvestProxyURL: "http://proxy.example.com:8080"}, upstream)
s.accountRepo = r
result := s.HarvestCodexTicket(context.Background(), a.ID, "gpt-6-astra", "manual", 0, false)
require.True(t, result.Success)
require.Equal(t, int64(1), calls.Load())
require.Equal(t, "ready", r.states["gpt-6-astra"].Phase)
require.Equal(t, status, a.Status)
require.False(t, a.Schedulable)
require.Equal(t, "preserve diagnostic", a.ErrorMessage)
})
}
}
for _, a := range []*Account{
{Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
{Platform: PlatformAnthropic, Type: AccountTypeOAuth},
{Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: func() *int64 { id := int64(1); return &id }()},
nil,
} {
require.False(t, CodexTicketAccountEligible(a))
}
}
25 changes: 25 additions & 0 deletions backend/migrations/245_codex_ticket_account_status_independent.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
-- Account status controls business scheduling, not ticket preparation/renewal.
-- Preserve deletion and credential-owner invalidation, including in-flight IO.
-- Existing stopped renewals are deliberately not enrolled by this migration.
CREATE OR REPLACE FUNCTION invalidate_codex_ticket_account_claims() RETURNS TRIGGER AS $$
DECLARE principal_changed BOOLEAN;
BEGIN
principal_changed := NEW.platform IS DISTINCT FROM OLD.platform
OR NEW.type IS DISTINCT FROM OLD.type
OR NEW.parent_account_id IS DISTINCT FROM OLD.parent_account_id
OR COALESCE(NEW.credentials->>'chatgpt_account_id','') IS DISTINCT FROM COALESCE(OLD.credentials->>'chatgpt_account_id','')
OR COALESCE(NEW.credentials->>'chatgpt_user_id','') IS DISTINCT FROM COALESCE(OLD.credentials->>'chatgpt_user_id','')
OR COALESCE(NEW.credentials->>'organization_id','') IS DISTINCT FROM COALESCE(OLD.credentials->>'organization_id','')
OR (COALESCE(NEW.credentials->>'chatgpt_account_id','')='' AND COALESCE(NEW.credentials->>'chatgpt_user_id','')='' AND
COALESCE(NEW.credentials->>'email','') IS DISTINCT FROM COALESCE(OLD.credentials->>'email',''));
IF NEW.deleted_at IS DISTINCT FROM OLD.deleted_at OR principal_changed THEN
UPDATE openai_codex_ticket_runtime SET phase='stopped',next_at=NULL,lease_id='',lease_until=NULL,updated_at=NOW()
WHERE account_id=NEW.id;
NEW.extra := (SELECT COALESCE(jsonb_object_agg(e.key, CASE WHEN e.key LIKE 'codex_ticket_runtime:%'
THEN e.value || '{"phase":"stopped","next_attempt_at":null}'::jsonb ELSE e.value END), '{}'::jsonb)
FROM jsonb_each(CASE WHEN jsonb_typeof(NEW.extra)='object' THEN NEW.extra ELSE '{}'::jsonb END) e
WHERE NOT (principal_changed AND e.key LIKE 'codex_turn_ticket:%'));
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
1 change: 1 addition & 0 deletions frontend/src/__tests__/integration/data-import.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
vi.mock('@/components/admin/account-jobs/AccountOperationDialog.vue', () => ({ default: { props: ['job', 'show'], template: '<div><slot/><slot name="footer"/></div>' } }))
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import ImportDataModal from '@/components/admin/account/ImportDataModal.vue'
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/api/__tests__/accountOperationIdempotency.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { webcrypto } from 'node:crypto'
import { AxiosHeaders, type InternalAxiosRequestConfig } from 'axios'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { stabilizeAccountOperationKey, settleAccountOperation, clearAccountOperationKeys } from '../accountOperationIdempotency'
const request = (key: string, ids = [1]) => ({ method: 'post', url: '/admin/accounts/batch-delete', data: { account_ids: ids }, headers: new AxiosHeaders({ 'Idempotency-Key': key }) }) as InternalAxiosRequestConfig
afterEach(() => { clearAccountOperationKeys(); vi.unstubAllGlobals() })
describe('uncertain account operation submission', () => {
it('reuses the key after a lost response, isolates changed targets and releases confirmed submissions', async () => {
vi.stubGlobal('crypto', webcrypto)
const first = request('first'); await stabilizeAccountOperationKey(first)
const retry = request('new-key'); await stabilizeAccountOperationKey(retry)
expect(retry.headers.get('Idempotency-Key')).toBe('first')
const changed = request('changed', [2]); await stabilizeAccountOperationKey(changed)
expect(changed.headers.get('Idempotency-Key')).toBe('changed')
settleAccountOperation(retry)
const next = request('next'); await stabilizeAccountOperationKey(next)
expect(next.headers.get('Idempotency-Key')).toBe('next')
})
it('does not retain another administrator session or affect non-operation requests', async () => {
vi.stubGlobal('crypto', webcrypto)
await stabilizeAccountOperationKey(request('old'))
clearAccountOperationKeys()
const next = request('next'); await stabilizeAccountOperationKey(next)
expect(next.headers.get('Idempotency-Key')).toBe('next')
const unrelated = request('unrelated'); unrelated.url = '/admin/settings'
await stabilizeAccountOperationKey(unrelated)
expect(unrelated.headers.get('Idempotency-Key')).toBe('unrelated')
})
})
24 changes: 24 additions & 0 deletions frontend/src/api/accountOperationIdempotency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { InternalAxiosRequestConfig } from 'axios'

// Keep only unresolved submissions. A timeout may have happened after the
// server committed the operation; retrying that payload must reuse its key.
const unresolved = new Map<string, string>()
let session = 0
type OperationConfig = InternalAxiosRequestConfig & { accountOperationSignature?: string }
export async function stabilizeAccountOperationKey(config: OperationConfig): Promise<void> {
const url = String(config.url || '')
const key = config.headers?.get('Idempotency-Key')
if (!key || !/^\/admin\/(accounts(?:\/|$)|account-jobs(?:\/|$))/.test(url)) return
const epoch = session
const payload = `${config.method}:${url}:${typeof config.data === 'string' ? config.data : JSON.stringify(config.data)}`
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload))
if (epoch !== session) throw new Error('Account operation session changed')
const signature = `${epoch}:` + Array.from(new Uint8Array(digest), n => n.toString(16).padStart(2, '0')).join('')
if (!unresolved.has(signature)) unresolved.set(signature, String(key))
config.headers.set('Idempotency-Key', unresolved.get(signature)!)
config.accountOperationSignature = signature
}
export function settleAccountOperation(config?: OperationConfig): void {
if (config?.accountOperationSignature) unresolved.delete(config.accountOperationSignature)
}
export function clearAccountOperationKeys(): void { unresolved.clear(); session++ }
6 changes: 5 additions & 1 deletion frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from './adminUIRequest'
import { refreshAuthTokens } from './tokenRefresh'
import { getAPIBaseURL } from './url'
import { settleAccountOperation, stabilizeAccountOperationKey } from './accountOperationIdempotency'
export { buildApiUrl, buildGatewayUrl } from './url'

// ==================== Axios Instance Configuration ====================
Expand All @@ -39,7 +40,8 @@ const getUserTimezone = (): string => {
}

apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
async (config: InternalAxiosRequestConfig) => {
await stabilizeAccountOperationKey(config)
// Attach token from localStorage
const token = localStorage.getItem('auth_token')
if (token && config.headers) {
Expand Down Expand Up @@ -80,6 +82,7 @@ apiClient.interceptors.request.use(

apiClient.interceptors.response.use(
(response: AxiosResponse) => {
settleAccountOperation(response.config)
// Unwrap standard API response format { code, message, data }
const apiResponse = response.data as ApiResponse<unknown>
if (apiResponse && typeof apiResponse === 'object' && 'code' in apiResponse) {
Expand Down Expand Up @@ -112,6 +115,7 @@ apiClient.interceptors.response.use(
// Handle common errors
if (error.response) {
const { status, data } = error.response
if (status >= 400 && status < 500 && status !== 408) settleAccountOperation(error.config)
const url = String(error.config?.url || '')

// Validate `data` shape to avoid HTML error pages breaking our error handling.
Expand Down
10 changes: 6 additions & 4 deletions frontend/src/components/account/BulkEditAccountModal.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<BaseDialog
<AccountOperationDialog :job="operationJob"
:show="show"
:title="t('admin.accounts.bulkEdit.title')"
width="wide"
Expand Down Expand Up @@ -1466,7 +1466,7 @@
</button>
</div>
</template>
</BaseDialog>
</AccountOperationDialog>

<ConfirmDialog
:show="showMixedChannelWarning"
Expand Down Expand Up @@ -1495,7 +1495,7 @@ import type {
OpenAIEndpointCapability,
OpenAIResponsesMode
} from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import AccountOperationDialog from '@/components/admin/account-jobs/AccountOperationDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
Expand Down Expand Up @@ -1555,6 +1555,8 @@ const emit = defineEmits<{
}>()

const { t } = useI18n()
const operationJob = ref<AccountJob | null>(null)
watch(() => props.show, show => { if (show) operationJob.value = null })
const appStore = useAppStore()

// Platform awareness
Expand Down Expand Up @@ -2318,8 +2320,8 @@ const submitBulkUpdate = async (baseUpdates: Record<string, unknown>) => {
})
: await adminAPI.accounts.bulkUpdate(props.accountIds, updates)
pendingUpdatesForConfirm.value = null
operationJob.value = job
emit('updated', job)
handleClose()
} catch (error: any) {
// 兜底:多平台混合场景下,预检查跳过,由后端 409 触发确认框
if (error.status === 409 && error.error === 'mixed_channel_warning') {
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/components/account/CreateAccountModal.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<BaseDialog
<AccountOperationDialog :job="operationJob"
:show="show"
:title="t('admin.accounts.createAccount')"
width="wide"
Expand Down Expand Up @@ -3778,7 +3778,7 @@
</button>
</div>
</template>
</BaseDialog>
</AccountOperationDialog>

<!-- Gemini Help Dialog -->
<BaseDialog
Expand Down Expand Up @@ -4051,6 +4051,7 @@ import type {
AccountAvailableModel
} from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import AccountOperationDialog from '@/components/admin/account-jobs/AccountOperationDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
Expand Down Expand Up @@ -4221,6 +4222,8 @@ interface Props {
}

const props = defineProps<Props>()
const operationJob = ref<AccountJob | null>(null)
watch(() => props.show, show => { if (show) operationJob.value = null })
const emit = defineEmits<{
close: []
created: [job?: AccountJob]
Expand Down Expand Up @@ -6773,8 +6776,8 @@ const handleOpenAIImportCodexSession = async (content: string) => {
update_existing: true
})

operationJob.value = job
emit('created', job)
handleClose()
} catch (error: any) {
oauthClient.error.value =
error.response?.data?.detail ||
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
vi.mock('@/components/admin/account-jobs/AccountOperationDialog.vue', () => ({ default: { props: ['job', 'show'], template: '<div><slot/><slot name="footer"/></div>' } }))
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { nextTick } from 'vue'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
vi.mock('@/components/admin/account-jobs/AccountOperationDialog.vue', () => ({ default: { props: ['job', 'show'], template: '<div><slot/><slot name="footer"/></div>' } }))
import { defineComponent, type PropType } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<template>
<AccountOperationDialog :show="show" :title="title" :job="job" width="normal" @close="emit('close')">
<p data-test="confirm-dialog" class="text-sm leading-relaxed text-muted">{{ message }}</p>
<p v-if="error" role="alert" class="mt-3 text-sm text-red-600">{{ error }}</p>
<template #footer>
<button class="btn btn-secondary" :disabled="busy" @click="emit('close')">{{ t('common.cancel') }}</button>
<button data-test="confirm-dialog-submit" class="btn" :class="danger ? 'btn-danger' : 'btn-primary'" :disabled="busy" @click="start">{{ busy ? t('common.processing') : confirmText || t('common.confirm') }}</button>
</template>
</AccountOperationDialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import AccountOperationDialog from './AccountOperationDialog.vue'
import type { AccountJob } from '@/api/admin/accountJobs'
const props = defineProps<{ show: boolean; title: string; message: string; danger?: boolean; confirmText?: string; execute: () => Promise<AccountJob> }>()
const emit = defineEmits<{ close: []; submitted: [job: AccountJob] }>()
const { t } = useI18n()
const job = ref<AccountJob | null>(null), busy = ref(false), error = ref('')
watch(() => props.show, show => { if (show) { job.value = null; error.value = '' } })
watch(() => props.execute, () => { job.value = null; error.value = '' })
async function start() {
if (busy.value || job.value) return
busy.value = true; error.value = ''
try { job.value = await props.execute(); emit('submitted', job.value) }
catch { error.value = t('admin.accountTasks.actionFailed') }
finally { busy.value = false }
}
</script>
Loading
Loading