diff --git a/src/server/contracts/accountsRoutePayloads.ts b/src/server/contracts/accountsRoutePayloads.ts index 134f36c..76132f6 100644 --- a/src/server/contracts/accountsRoutePayloads.ts +++ b/src/server/contracts/accountsRoutePayloads.ts @@ -21,9 +21,9 @@ const accountUpdatePayloadSchema = z.object({ apiToken: z.union([z.string(), z.null()]).optional(), status: z.string().optional(), checkinEnabled: z.boolean().optional(), - unitCost: z.number().optional(), + unitCost: z.union([z.number(), z.null()]).optional(), extraConfig: z.union([z.string(), z.record(z.string(), z.unknown()), z.null()]).optional(), - refreshToken: z.string().optional(), + refreshToken: z.union([z.string(), z.null()]).optional(), tokenExpiresAt: z.union([z.number(), z.string(), z.null()]).optional(), isPinned: z.boolean().optional(), sortOrder: z.number().int().min(0).optional(), @@ -98,6 +98,9 @@ function formatAccountsPayloadError(error: z.ZodError): string { if (firstPath === 'checkinEnabled') { return 'Invalid checkinEnabled. Expected boolean.'; } + if (firstPath === 'unitCost') { + return 'Invalid unitCost. Expected number or null.'; + } if (firstPath === 'credentialMode') { return 'Invalid credentialMode. Expected auto/session/apikey.'; } @@ -123,10 +126,10 @@ function formatAccountsPayloadError(error: z.ZodError): string { return 'Invalid platformUserId. Expected positive number.'; } if (firstPath === 'refreshToken') { - return 'Invalid refreshToken. Expected string.'; + return 'Invalid refreshToken. Expected string or null.'; } if (firstPath === 'tokenExpiresAt') { - return 'Invalid tokenExpiresAt. Expected number or string.'; + return 'Invalid tokenExpiresAt. Expected number, string, or null.'; } if (firstPath === 'accountId') { return '账号 ID 无效'; diff --git a/src/server/routes/api/accountTokens.batch.test.ts b/src/server/routes/api/accountTokens.batch.test.ts index 7e1f474..9dbd025 100644 --- a/src/server/routes/api/accountTokens.batch.test.ts +++ b/src/server/routes/api/accountTokens.batch.test.ts @@ -159,6 +159,39 @@ describe('account token batch routes', () => { expect(remaining.map((item) => item.id)).toEqual([2]); }); + it('accepts the edit-panel payload when updating account token metadata without changing token value', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/account-tokens/1', + payload: { + name: 'token-1-updated', + group: 'default', + enabled: true, + isDefault: false, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + token: { + id: 1, + name: 'token-1-updated', + tokenGroup: 'default', + enabled: true, + isDefault: false, + }, + }); + + const row = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, 1)).get(); + expect(row).toMatchObject({ + name: 'token-1-updated', + tokenGroup: 'default', + enabled: true, + isDefault: false, + }); + }); + it('rejects invalid account token batch action', async () => { const response = await app.inject({ method: 'POST', diff --git a/src/server/routes/api/accounts.credentialMode.test.ts b/src/server/routes/api/accounts.credentialMode.test.ts index 84312b9..bd64dd0 100644 --- a/src/server/routes/api/accounts.credentialMode.test.ts +++ b/src/server/routes/api/accounts.credentialMode.test.ts @@ -415,7 +415,8 @@ describe('accounts credential mode', { timeout: 15_000 }, () => { method: 'PUT', url: `/api/accounts/${account.id}`, payload: { - refreshToken: '', + refreshToken: null, + tokenExpiresAt: null, }, }); expect(clearResponse.statusCode).toBe(200); @@ -427,6 +428,54 @@ describe('accounts credential mode', { timeout: 15_000 }, () => { expect(parsedCleared.sub2apiAuth).toBeUndefined(); }); + it('accepts nullable optional fields from the edit panel payload', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Editable Site', + url: 'https://editable.example.com', + platform: 'new-api', + }).returning().get(); + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'before-edit', + accessToken: 'access-token', + status: 'active', + unitCost: 25, + extraConfig: JSON.stringify({ + proxyUrl: 'http://127.0.0.1:7890', + }), + }).returning().get(); + + const response = await app.inject({ + method: 'PUT', + url: `/api/accounts/${account.id}`, + payload: { + username: 'after-edit', + status: 'disabled', + checkinEnabled: false, + unitCost: null, + accessToken: 'access-token-updated', + apiToken: null, + isPinned: false, + refreshToken: null, + tokenExpiresAt: null, + proxyUrl: null, + }, + }); + + expect(response.statusCode).toBe(200); + const updated = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get(); + expect(updated).toMatchObject({ + username: 'after-edit', + status: 'disabled', + checkinEnabled: false, + unitCost: null, + accessToken: 'access-token-updated', + apiToken: null, + isPinned: false, + }); + expect(JSON.parse(updated?.extraConfig || '{}')).not.toHaveProperty('proxyUrl'); + }); + it('does not refresh models for pin-only account edits', async () => { const site = await db.insert(schema.sites).values({ name: 'Pinned Site', diff --git a/src/server/routes/api/downstreamApiKeys.test.ts b/src/server/routes/api/downstreamApiKeys.test.ts index 8c1f120..1b2e31c 100644 --- a/src/server/routes/api/downstreamApiKeys.test.ts +++ b/src/server/routes/api/downstreamApiKeys.test.ts @@ -163,6 +163,67 @@ describe('downstream api keys routes', () => { expect(listRes.json()).toMatchObject({ success: true, items: [] }); }); + it('clears nullable editor fields when updating a downstream api key', async () => { + const createRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys', + payload: { + name: 'editor-key', + key: 'sk-editor-key-001', + description: 'editor consumer', + groupName: '项目A', + enabled: true, + expiresAt: '2026-12-31T00:00:00.000Z', + maxCost: 12.5, + maxRequests: 500, + }, + }); + + expect(createRes.statusCode).toBe(200); + const keyId = (createRes.json() as { item: { id: number } }).item.id; + + const updateRes = await app.inject({ + method: 'PUT', + url: `/api/downstream-keys/${keyId}`, + payload: { + name: 'editor-key-updated', + key: 'sk-editor-key-001', + description: '', + groupName: null, + enabled: true, + expiresAt: null, + maxCost: null, + maxRequests: null, + }, + }); + + expect(updateRes.statusCode).toBe(200); + expect(updateRes.json()).toMatchObject({ + success: true, + item: { + id: keyId, + name: 'editor-key-updated', + description: null, + groupName: null, + expiresAt: null, + maxCost: null, + maxRequests: null, + }, + }); + + const row = await db.select().from(schema.downstreamApiKeys) + .where(eq(schema.downstreamApiKeys.id, keyId)) + .get(); + expect(row).toMatchObject({ + name: 'editor-key-updated', + description: null, + groupName: null, + expiresAt: null, + maxCost: null, + maxRequests: null, + }); + }); + it('supports batch enable/disable/reset/delete operations', async () => { const inserted = await db.insert(schema.downstreamApiKeys).values([ { diff --git a/src/server/routes/api/downstreamApiKeys.ts b/src/server/routes/api/downstreamApiKeys.ts index 4b78542..b018fe9 100644 --- a/src/server/routes/api/downstreamApiKeys.ts +++ b/src/server/routes/api/downstreamApiKeys.ts @@ -536,21 +536,22 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { const existingView = toDownstreamApiKeyPolicyView(existing); const body = parsedBody.data; + const hasOwn = (key: string) => Object.prototype.hasOwnProperty.call(body, key); let normalized: ReturnType; try { normalized = normalizeDownstreamApiKeyPayload({ - name: body.name ?? existing.name, - key: body.key ?? existing.key, - description: body.description ?? existing.description, - groupName: body.groupName ?? existing.groupName, - tags: body.tags ?? existingView.tags, - enabled: body.enabled ?? existing.enabled, - expiresAt: body.expiresAt ?? existing.expiresAt, - maxCost: body.maxCost ?? existing.maxCost, - maxRequests: body.maxRequests ?? existing.maxRequests, - supportedModels: body.supportedModels ?? existingView.supportedModels, - allowedRouteIds: body.allowedRouteIds ?? existingView.allowedRouteIds, - siteWeightMultipliers: body.siteWeightMultipliers ?? existingView.siteWeightMultipliers, + name: hasOwn('name') ? body.name : existing.name, + key: hasOwn('key') ? body.key : existing.key, + description: hasOwn('description') ? body.description : existing.description, + groupName: hasOwn('groupName') ? body.groupName : existing.groupName, + tags: hasOwn('tags') ? body.tags : existingView.tags, + enabled: hasOwn('enabled') ? body.enabled : existing.enabled, + expiresAt: hasOwn('expiresAt') ? body.expiresAt : existing.expiresAt, + maxCost: hasOwn('maxCost') ? body.maxCost : existing.maxCost, + maxRequests: hasOwn('maxRequests') ? body.maxRequests : existing.maxRequests, + supportedModels: hasOwn('supportedModels') ? body.supportedModels : existingView.supportedModels, + allowedRouteIds: hasOwn('allowedRouteIds') ? body.allowedRouteIds : existingView.allowedRouteIds, + siteWeightMultipliers: hasOwn('siteWeightMultipliers') ? body.siteWeightMultipliers : existingView.siteWeightMultipliers, }); } catch (error: unknown) { return reply.code(400).send({ success: false, message: (error as Error)?.message || '参数无效' }); diff --git a/src/server/routes/api/monitor.test.ts b/src/server/routes/api/monitor.test.ts index 6843efe..42e7f8e 100644 --- a/src/server/routes/api/monitor.test.ts +++ b/src/server/routes/api/monitor.test.ts @@ -3,6 +3,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { mkdtempSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { eq } from 'drizzle-orm'; type DbModule = typeof import('../../db/index.js'); @@ -50,4 +51,34 @@ describe('monitor routes', () => { message: 'Invalid ldohCookie. Expected string or null.', }); }); + + it('accepts null monitor cookie payloads and clears the stored cookie', async () => { + const saveResponse = await app.inject({ + method: 'PUT', + url: '/api/monitor/config', + payload: { + ldohCookie: 'ld_auth_session=abcdefghijklmnopqrstuvwxyz', + }, + }); + expect(saveResponse.statusCode).toBe(200); + + const clearResponse = await app.inject({ + method: 'PUT', + url: '/api/monitor/config', + payload: { + ldohCookie: null, + }, + }); + + expect(clearResponse.statusCode).toBe(200); + expect(clearResponse.json()).toMatchObject({ + success: true, + ldohCookieConfigured: false, + }); + + const saved = await db.select().from(schema.settings) + .where(eq(schema.settings.key, 'monitor_ldoh_cookie')) + .get(); + expect(saved?.value).toBe('""'); + }); }); diff --git a/src/server/routes/api/sites.proxyUrl.test.ts b/src/server/routes/api/sites.proxyUrl.test.ts index b833b66..61f97d3 100644 --- a/src/server/routes/api/sites.proxyUrl.test.ts +++ b/src/server/routes/api/sites.proxyUrl.test.ts @@ -215,6 +215,49 @@ describe('sites proxy settings', () => { expect(payload.useSystemProxy).toBe(true); }); + it('clears optional editor fields when updating a site with empty strings', async () => { + const created = await app.inject({ + method: 'POST', + url: '/api/sites', + payload: { + name: 'editable-site', + url: 'https://editable-site.example.com', + platform: 'new-api', + proxyUrl: 'http://127.0.0.1:8080', + useSystemProxy: true, + customHeaders: JSON.stringify({ + 'x-site-scope': 'internal', + }), + externalCheckinUrl: 'https://checkin.example.com/welfare', + }, + }); + expect(created.statusCode).toBe(200); + const site = created.json() as { id: number }; + + const response = await app.inject({ + method: 'PUT', + url: `/api/sites/${site.id}`, + payload: { + proxyUrl: '', + useSystemProxy: false, + customHeaders: '', + externalCheckinUrl: '', + }, + }); + + expect(response.statusCode).toBe(200); + const payload = response.json() as { + proxyUrl?: string | null; + useSystemProxy?: boolean; + customHeaders?: string | null; + externalCheckinUrl?: string | null; + }; + expect(payload.proxyUrl).toBeNull(); + expect(payload.useSystemProxy).toBe(false); + expect(payload.customHeaders).toBeNull(); + expect(payload.externalCheckinUrl).toBeNull(); + }); + it('returns a conflict response when updating a site to an existing platform and url', async () => { const first = await app.inject({ method: 'POST', diff --git a/src/server/routes/api/tokens.route-update-rebuild.test.ts b/src/server/routes/api/tokens.route-update-rebuild.test.ts index f37538d..936a597 100644 --- a/src/server/routes/api/tokens.route-update-rebuild.test.ts +++ b/src/server/routes/api/tokens.route-update-rebuild.test.ts @@ -578,6 +578,54 @@ describe('PUT /api/routes/:id route rebuild', () => { }); }); + it('accepts null tokenId when updating a channel and falls back to the account default token', async () => { + const seeded = await seedAccountWithToken('gpt-4o-mini'); + const alternateToken = await db.insert(schema.accountTokens).values({ + accountId: seeded.account.id, + name: 'token-alt', + token: 'sk-token-alt', + enabled: true, + isDefault: false, + }).returning().get(); + await db.insert(schema.tokenModelAvailability).values({ + tokenId: alternateToken.id, + modelName: 'gpt-4o-mini', + available: true, + }).run(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-4o-mini', + enabled: true, + }).returning().get(); + const channel = await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: seeded.account.id, + tokenId: alternateToken.id, + sourceModel: 'gpt-4o-mini', + priority: 0, + weight: 10, + enabled: true, + manualOverride: true, + }).returning().get(); + + const response = await app.inject({ + method: 'PUT', + url: `/api/channels/${channel.id}`, + payload: { + tokenId: null, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + id: channel.id, + tokenId: seeded.token.id, + }); + + const updated = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).get(); + expect(updated?.tokenId).toBe(seeded.token.id); + }); + it('prefers an exact route over a colliding explicit-group display name', async () => { const exactCandidate = await seedAccountWithToken('claude-opus-4-6'); const groupedCandidate = await seedAccountWithToken('claude-opus-4-5'); diff --git a/src/server/routes/api/tokens.ts b/src/server/routes/api/tokens.ts index 01deeda..6c70600 100644 --- a/src/server/routes/api/tokens.ts +++ b/src/server/routes/api/tokens.ts @@ -1360,7 +1360,7 @@ export async function tokensRoutes(app: FastifyInstance) { if (body.priority !== undefined) updates.priority = body.priority; if (body.weight !== undefined) updates.weight = body.weight; if (body.enabled !== undefined) updates.enabled = body.enabled; - if (body.tokenId !== undefined) updates.tokenId = body.tokenId; + if (body.tokenId !== undefined) updates.tokenId = nextTokenId; await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run(); await clearRouteDecisionSnapshot(channel.routeId);