feat: add masked token placeholders and desktop icons

This commit is contained in:
cita
2026-03-16 21:36:23 +08:00
parent 2409d9f184
commit 0a06f15e13
37 changed files with 2954 additions and 136 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

@@ -0,0 +1 @@
ALTER TABLE `account_tokens` ADD `value_status` text DEFAULT 'ready' NOT NULL;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -78,6 +78,13 @@
"when": 1773605400000,
"tag": "0011_downstream_api_key_metadata",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1773665311013,
"tag": "0012_account_token_value_status",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -4,7 +4,7 @@ artifactName: ${name}-${version}-${os}-${arch}.${ext}
directories:
output: release
electronDist: node_modules/electron/dist
icon: build/icon.png
icon: build/desktop-icon.png
asar: false
files:
- dist/**/*
+4 -3
View File
@@ -29,14 +29,15 @@
"dev:server": "tsx scripts/dev/run-server.ts --watch",
"dev": "concurrently \"npm run dev:server\" \"vite\"",
"dev:desktop": "concurrently \"npm run dev:server\" \"vite\" \"tsc -p tsconfig.desktop.json --watch --preserveWatchOutput\" \"wait-on tcp:127.0.0.1:4000 http://127.0.0.1:5173 file:dist/desktop/main.js && cross-env METAPI_DESKTOP_DEV_SERVER_URL=http://127.0.0.1:5173 METAPI_DESKTOP_EXTERNAL_SERVER_URL=http://127.0.0.1:4000 electron dist/desktop/main.js\"",
"build:web": "vite build",
"desktop:icons": "node scripts/desktop/generate-icons.mjs",
"build:web": "npm run desktop:icons && vite build",
"build:server": "tsc -p tsconfig.server.json && tsx scripts/dev/copy-runtime-db-generated.ts",
"build:desktop": "tsc -p tsconfig.desktop.json",
"build": "npm run build:web && npm run build:server && npm run build:desktop",
"dist:desktop": "npm run build && electron-builder --config electron-builder.yml --publish never",
"dist:desktop:mac:intel": "npm run build && electron-builder --config electron-builder.yml --publish never --mac --x64",
"package:desktop": "electron-builder --config electron-builder.yml --publish never",
"package:desktop:mac:intel": "electron-builder --config electron-builder.yml --publish never --mac --x64",
"package:desktop": "npm run desktop:icons && electron-builder --config electron-builder.yml --publish never",
"package:desktop:mac:intel": "npm run desktop:icons && electron-builder --config electron-builder.yml --publish never --mac --x64",
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
+85
View File
@@ -0,0 +1,85 @@
import { mkdir } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { pathToFileURL } from 'node:url';
import sharp from 'sharp';
export const DESKTOP_ICON_SIZE = 512;
export const DESKTOP_ICON_PADDING = 40;
export const DESKTOP_ICON_RADIUS = 96;
function createRoundedMask(size, cornerRadius) {
return Buffer.from(
`<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg"><rect width="${size}" height="${size}" rx="${cornerRadius}" ry="${cornerRadius}" fill="#fff"/></svg>`,
);
}
async function renderDesktopIconBuffer({
sourcePath,
size = DESKTOP_ICON_SIZE,
padding = DESKTOP_ICON_PADDING,
cornerRadius = DESKTOP_ICON_RADIUS,
}) {
const innerSize = size - padding * 2;
const roundedMask = createRoundedMask(innerSize, Math.min(cornerRadius, Math.floor(innerSize / 2)));
const roundedInner = await sharp(sourcePath)
.resize(innerSize, innerSize, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.composite([{ input: roundedMask, blend: 'dest-in' }])
.png()
.toBuffer();
return sharp({
create: {
width: size,
height: size,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite([{ input: roundedInner, left: padding, top: padding }])
.png()
.toBuffer();
}
export async function generateDesktopIconAssets({
sourcePath = join(process.cwd(), 'src', 'web', 'public', 'logo.png'),
buildOutputPath = join(process.cwd(), 'build', 'desktop-icon.png'),
webOutputPath = join(process.cwd(), 'src', 'web', 'public', 'desktop-icon.png'),
size = DESKTOP_ICON_SIZE,
padding = DESKTOP_ICON_PADDING,
cornerRadius = DESKTOP_ICON_RADIUS,
} = {}) {
const outputBuffer = await renderDesktopIconBuffer({
sourcePath,
size,
padding,
cornerRadius,
});
await Promise.all([
mkdir(dirname(buildOutputPath), { recursive: true }),
mkdir(dirname(webOutputPath), { recursive: true }),
]);
await Promise.all([
sharp(outputBuffer).toFile(buildOutputPath),
sharp(outputBuffer).toFile(webOutputPath),
]);
return {
buildOutputPath,
webOutputPath,
};
}
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isDirectRun) {
const outputs = await generateDesktopIconAssets();
console.log(`[metapi-desktop] Generated desktop icons:
- ${outputs.buildOutputPath}
- ${outputs.webOutputPath}`);
}
+68
View File
@@ -0,0 +1,68 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import sharp from 'sharp';
import { afterEach, describe, expect, it } from 'vitest';
const tempDirs: string[] = [];
async function makeTempDir() {
const dir = await mkdtemp(join(tmpdir(), 'metapi-desktop-icons-'));
tempDirs.push(dir);
return dir;
}
function alphaAt(buffer: Buffer, width: number, x: number, y: number) {
return buffer[(y * width + x) * 4 + 3];
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
describe('generateDesktopIconAssets', () => {
it('writes rounded desktop icon outputs for build and runtime usage', async () => {
const { generateDesktopIconAssets } = await import('./generate-icons.mjs');
const dir = await makeTempDir();
const sourcePath = join(dir, 'logo.png');
const buildOutputPath = join(dir, 'build.png');
const webOutputPath = join(dir, 'desktop-icon.png');
await sharp({
create: {
width: 512,
height: 512,
channels: 4,
background: { r: 255, g: 112, b: 48, alpha: 1 },
},
}).png().toFile(sourcePath);
await generateDesktopIconAssets({
sourcePath,
buildOutputPath,
webOutputPath,
});
const buildMeta = await sharp(buildOutputPath).metadata();
const webMeta = await sharp(webOutputPath).metadata();
expect(buildMeta.width).toBe(512);
expect(buildMeta.height).toBe(512);
expect(webMeta.width).toBe(512);
expect(webMeta.height).toBe(512);
const { data, info } = await sharp(buildOutputPath)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
expect(alphaAt(data, info.width, 0, 0)).toBe(0);
expect(alphaAt(data, info.width, 12, 12)).toBe(0);
expect(alphaAt(data, info.width, Math.floor(info.width / 2), 6)).toBe(0);
expect(alphaAt(data, info.width, Math.floor(info.width / 2), Math.floor(info.height / 2))).toBe(255);
expect(await sharp(buildOutputPath).png().toBuffer())
.toEqual(await sharp(webOutputPath).png().toBuffer());
});
});
+24
View File
@@ -0,0 +1,24 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('desktop icon assets', () => {
it('keeps runtime icon paths aligned to the generated desktop icon', async () => {
const {
DESKTOP_BUILD_ICON_RELATIVE_PATH,
DESKTOP_RUNTIME_ICON_RELATIVE_PATH,
getDesktopRuntimeIconPath,
} = await import('./iconAssets.js');
expect(DESKTOP_RUNTIME_ICON_RELATIVE_PATH).toBe(join('dist', 'web', 'desktop-icon.png'));
expect(DESKTOP_BUILD_ICON_RELATIVE_PATH).toBe(join('build', 'desktop-icon.png'));
expect(getDesktopRuntimeIconPath('/app')).toBe(join('/app', 'dist', 'web', 'desktop-icon.png'));
});
it('points electron-builder at the generated desktop package icon', async () => {
const configPath = join(process.cwd(), 'electron-builder.yml');
const config = await readFile(configPath, 'utf8');
expect(config).toContain('icon: build/desktop-icon.png');
});
});
+9
View File
@@ -0,0 +1,9 @@
import { join } from 'node:path';
export const DESKTOP_RUNTIME_ICON_RELATIVE_PATH = join('dist', 'web', 'desktop-icon.png');
export const DESKTOP_BUILD_ICON_RELATIVE_PATH = join('build', 'desktop-icon.png');
export const DESKTOP_ICON_PUBLIC_PATH = '/desktop-icon.png';
export function getDesktopRuntimeIconPath(appPath: string) {
return join(appPath, DESKTOP_RUNTIME_ICON_RELATIVE_PATH);
}
+2 -1
View File
@@ -21,6 +21,7 @@ import {
resolveDesktopServerWorkingDir,
waitForServerReady,
} from './runtime.js';
import { getDesktopRuntimeIconPath } from './iconAssets.js';
const { autoUpdater } = electronUpdater;
@@ -51,7 +52,7 @@ function getServerEntryPath() {
}
function getTrayIconPath() {
return join(app.getAppPath(), 'dist', 'web', 'logo.png');
return getDesktopRuntimeIconPath(app.getAppPath());
}
function getWindowIconPath() {
@@ -23,6 +23,15 @@ const ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS: AccountTokenColumnCompatibilityS
postgres: 'ALTER TABLE "account_tokens" ADD COLUMN "token_group" TEXT',
},
},
{
table: 'account_tokens',
column: 'value_status',
addSql: {
sqlite: "ALTER TABLE account_tokens ADD COLUMN value_status text NOT NULL DEFAULT 'ready';",
mysql: "ALTER TABLE `account_tokens` ADD COLUMN `value_status` VARCHAR(191) NOT NULL DEFAULT 'ready'",
postgres: "ALTER TABLE \"account_tokens\" ADD COLUMN \"value_status\" TEXT NOT NULL DEFAULT 'ready'",
},
},
];
function normalizeSchemaErrorMessage(error: unknown): string {
+1 -1
View File
@@ -1,6 +1,6 @@
CREATE TABLE IF NOT EXISTS `sites` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `platform` TEXT NOT NULL, `status` VARCHAR(191) NOT NULL DEFAULT 'active', `api_key` TEXT, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, `proxy_url` TEXT, `use_system_proxy` BOOLEAN DEFAULT false, `custom_headers` JSON, `external_checkin_url` TEXT, `global_weight` DOUBLE DEFAULT 1);
CREATE TABLE IF NOT EXISTS `accounts` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `username` TEXT, `access_token` TEXT NOT NULL, `api_token` TEXT, `balance` DOUBLE DEFAULT 0, `balance_used` DOUBLE DEFAULT 0, `quota` DOUBLE DEFAULT 0, `unit_cost` DOUBLE, `value_score` DOUBLE DEFAULT 0, `status` VARCHAR(191) DEFAULT 'active', `checkin_enabled` BOOLEAN DEFAULT true, `last_checkin_at` DATETIME, `last_balance_refresh` DATETIME, `extra_config` JSON, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS `account_tokens` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `name` TEXT NOT NULL, `token` TEXT NOT NULL, `source` VARCHAR(191) DEFAULT 'manual', `enabled` BOOLEAN DEFAULT true, `is_default` BOOLEAN DEFAULT false, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `token_group` TEXT, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS `account_tokens` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `name` TEXT NOT NULL, `token` TEXT NOT NULL, `source` VARCHAR(191) DEFAULT 'manual', `enabled` BOOLEAN DEFAULT true, `is_default` BOOLEAN DEFAULT false, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `token_group` TEXT, `value_status` VARCHAR(191) NOT NULL DEFAULT 'ready', FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS `checkin_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `status` TEXT NOT NULL, `message` TEXT, `reward` TEXT, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `key` TEXT NOT NULL, `description` TEXT, `enabled` BOOLEAN DEFAULT true, `expires_at` DATETIME, `max_cost` DOUBLE, `used_cost` DOUBLE DEFAULT 0, `max_requests` INT, `used_requests` INT DEFAULT 0, `supported_models` JSON, `allowed_route_ids` JSON, `site_weight_multipliers` JSON, `last_used_at` DATETIME, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `group_name` TEXT, `tags` TEXT);
CREATE TABLE IF NOT EXISTS `events` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `message` TEXT, `level` VARCHAR(191) NOT NULL DEFAULT 'info', `read` BOOLEAN DEFAULT false, `related_id` INT, `related_type` TEXT, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP);
+1 -4
View File
@@ -1,4 +1 @@
ALTER TABLE `downstream_api_keys` ADD COLUMN `group_name` TEXT;
ALTER TABLE `downstream_api_keys` ADD COLUMN `tags` TEXT;
ALTER TABLE `proxy_logs` ADD COLUMN `downstream_api_key_id` INT;
CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`);
ALTER TABLE `account_tokens` ADD COLUMN `value_status` VARCHAR(191) NOT NULL DEFAULT 'ready';
@@ -1,6 +1,6 @@
CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "platform" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'active', "api_key" TEXT, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, "proxy_url" TEXT, "use_system_proxy" BOOLEAN DEFAULT false, "custom_headers" JSONB, "external_checkin_url" TEXT, "global_weight" DOUBLE PRECISION DEFAULT 1);
CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "checkin_enabled" BOOLEAN DEFAULT true, "last_checkin_at" TIMESTAMP, "last_balance_refresh" TIMESTAMP, "extra_config" JSONB, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT true, "is_default" BOOLEAN DEFAULT false, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "token_group" TEXT, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT true, "is_default" BOOLEAN DEFAULT false, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "token_group" TEXT, "value_status" TEXT NOT NULL DEFAULT 'ready', FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL, "description" TEXT, "enabled" BOOLEAN DEFAULT true, "expires_at" TIMESTAMP, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" JSONB, "allowed_route_ids" JSONB, "site_weight_multipliers" JSONB, "last_used_at" TIMESTAMP, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "group_name" TEXT, "tags" TEXT);
CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT NOT NULL DEFAULT 'info', "read" BOOLEAN DEFAULT false, "related_id" INTEGER, "related_type" TEXT, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
+1 -4
View File
@@ -1,4 +1 @@
ALTER TABLE "downstream_api_keys" ADD COLUMN "group_name" TEXT;
ALTER TABLE "downstream_api_keys" ADD COLUMN "tags" TEXT;
ALTER TABLE "proxy_logs" ADD COLUMN "downstream_api_key_id" INTEGER;
CREATE INDEX "proxy_logs_downstream_api_key_created_at_idx" ON "proxy_logs" ("downstream_api_key_id", "created_at");
ALTER TABLE "account_tokens" ADD COLUMN "value_status" TEXT NOT NULL DEFAULT 'ready';
@@ -61,6 +61,12 @@
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"value_status": {
"logicalType": "text",
"notNull": true,
"defaultValue": "'ready'",
"primaryKey": false
}
}
},
+4
View File
@@ -137,6 +137,7 @@ function ensureTokenManagementSchema() {
name text NOT NULL,
token text NOT NULL,
token_group text,
value_status text NOT NULL DEFAULT 'ready',
source text DEFAULT 'manual',
enabled integer DEFAULT true,
is_default integer DEFAULT false,
@@ -152,6 +153,9 @@ function ensureTokenManagementSchema() {
if (!tableColumnExists('account_tokens', 'token_group')) {
execSqliteLegacyCompat('ALTER TABLE account_tokens ADD COLUMN token_group text;');
}
if (!tableColumnExists('account_tokens', 'value_status')) {
execSqliteLegacyCompat("ALTER TABLE account_tokens ADD COLUMN value_status text NOT NULL DEFAULT 'ready';");
}
execSqliteStatement(`
INSERT INTO account_tokens (account_id, name, token, source, enabled, is_default, created_at, updated_at)
+1
View File
@@ -46,6 +46,7 @@ const LEGACY_COMPAT_COLUMNS = new Set([
'sites.external_checkin_url',
'sites.global_weight',
'account_tokens.token_group',
'account_tokens.value_status',
'token_routes.display_name',
'token_routes.display_icon',
'token_routes.decision_snapshot',
+2
View File
@@ -62,6 +62,8 @@ const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [
// 0011: downstream key metadata columns
{ table: 'downstream_api_keys', column: 'group_name' },
{ table: 'downstream_api_keys', column: 'tags' },
// 0012: value_status column on account_tokens
{ table: 'account_tokens', column: 'value_status' },
];
+1
View File
@@ -63,6 +63,7 @@ export const accountTokens = sqliteTable('account_tokens', {
name: text('name').notNull(),
token: text('token').notNull(),
tokenGroup: text('token_group'),
valueStatus: text('value_status').notNull().default('ready'),
source: text('source').default('manual'), // 'manual' | 'sync' | 'legacy'
enabled: integer('enabled', { mode: 'boolean' }).default(true),
isDefault: integer('is_default', { mode: 'boolean' }).default(false),
+5
View File
@@ -11,6 +11,11 @@ describe('schema contract generation', () => {
primaryKey: false,
});
expect(contract.tables.account_tokens.columns.token_group).toBeDefined();
expect(contract.tables.account_tokens.columns.value_status).toMatchObject({
logicalType: 'text',
notNull: true,
defaultValue: "'ready'",
});
expect(contract.tables.site_disabled_models).toBeDefined();
expect(contract.tables.downstream_api_keys).toBeDefined();
expect(contract.tables.proxy_files).toBeDefined();
@@ -3,6 +3,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
import { mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { eq } from 'drizzle-orm';
const { deleteApiTokenMock } = vi.hoisted(() => ({
deleteApiTokenMock: vi.fn(),
@@ -105,6 +106,43 @@ describe('account token batch routes', () => {
expect(rows.every((row) => row.enabled === true)).toBe(true);
});
it('rejects enabling masked_pending placeholders until they are completed', async () => {
await db.update(schema.accountTokens)
.set({
enabled: false,
valueStatus: 'masked_pending' as any,
token: 'sk-mask***tail',
})
.where(eq(schema.accountTokens.id, 1))
.run();
const response = await app.inject({
method: 'POST',
url: '/api/account-tokens/batch',
payload: {
ids: [1, 2],
action: 'enable',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
successIds?: number[];
failedItems?: Array<{ id: number; message: string }>;
};
expect(body.successIds).toEqual([2]);
expect(body.failedItems).toEqual([
expect.objectContaining({
id: 1,
message: expect.stringContaining('待补全令牌'),
}),
]);
const rows = await db.select().from(schema.accountTokens).all();
expect(rows.find((row) => row.id === 1)?.enabled).toBe(false);
expect(rows.find((row) => row.id === 2)?.enabled).toBe(true);
});
it('deletes selected account tokens through the upstream adapter', async () => {
const response = await app.inject({
method: 'POST',
@@ -138,7 +138,7 @@ describe('account tokens sync routes with site status', () => {
expect(tokenRows.length).toBe(0);
});
it('skips masked upstream token values and does not store them', async () => {
it('stores masked upstream token values as masked_pending placeholders', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
getApiTokensMock.mockResolvedValue([
{ name: 'masked-only', key: 'sk-abc***xyz', enabled: true },
@@ -153,12 +153,13 @@ describe('account tokens sync routes with site status', () => {
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
synced: false,
status: 'skipped',
synced: true,
status: 'synced',
reason: 'upstream_masked_tokens',
maskedSkipped: 1,
total: 0,
created: 0,
maskedPending: 1,
pendingTokenIds: [expect.any(Number)],
total: 1,
created: 1,
updated: 0,
});
@@ -166,7 +167,33 @@ describe('account tokens sync routes with site status', () => {
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokenRows.length).toBe(0);
expect(tokenRows).toHaveLength(1);
expect(tokenRows[0]).toMatchObject({
name: 'masked-only',
token: 'sk-abc***xyz',
source: 'sync',
enabled: false,
isDefault: false,
});
expect((tokenRows[0] as any).valueStatus).toBe('masked_pending');
const owner = await db.select()
.from(schema.accounts)
.where(eq(schema.accounts.id, account.id))
.get();
expect(owner?.apiToken ?? null).toBeNull();
const listResponse = await app.inject({
method: 'GET',
url: '/api/account-tokens',
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json()).toMatchObject([
expect.objectContaining({
id: tokenRows[0].id,
valueStatus: 'masked_pending',
}),
]);
});
it('rejects sync and token management for apikey connections', async () => {
@@ -491,6 +518,7 @@ describe('account tokens sync routes with site status', () => {
source: 'sync',
enabled: true,
isDefault: false,
valueStatus: 'masked_pending' as any,
}).returning().get();
const response = await app.inject({
@@ -503,4 +531,139 @@ describe('account tokens sync routes with site status', () => {
success: false,
});
});
it('upgrades an existing masked_pending placeholder when upstream later returns the full token', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-only',
token: 'sk-abc***xyz',
source: 'sync',
enabled: false,
isDefault: false,
tokenGroup: 'default',
valueStatus: 'masked_pending' as any,
}).run();
getApiTokensMock.mockResolvedValue([
{ name: 'masked-only', key: 'sk-real-token-1234', enabled: true, tokenGroup: 'default' },
]);
getApiTokenMock.mockResolvedValue(null);
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/sync/${account.id}`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
synced: true,
status: 'synced',
total: 1,
created: 0,
updated: 1,
});
const tokenRows = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokenRows).toHaveLength(1);
expect(tokenRows[0]).toMatchObject({
name: 'masked-only',
token: 'sk-real-token-1234',
enabled: true,
});
expect((tokenRows[0] as any).valueStatus).toBe('ready');
});
it('does not allow setting a masked_pending placeholder as default', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-token',
token: 'sk-mask***tail',
source: 'sync',
enabled: false,
isDefault: false,
valueStatus: 'masked_pending' as any,
}).returning().get();
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/${token.id}/default`,
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({
success: false,
message: expect.stringContaining('待补全令牌'),
});
});
it('promotes a masked_pending placeholder to ready when a full token is saved', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-token',
token: 'sk-mask***tail',
source: 'sync',
enabled: false,
isDefault: false,
valueStatus: 'masked_pending' as any,
}).returning().get();
const response = await app.inject({
method: 'PUT',
url: `/api/account-tokens/${token.id}`,
payload: {
token: 'sk-real-token-updated',
enabled: true,
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
token: expect.objectContaining({
id: token.id,
enabled: true,
valueStatus: 'ready',
}),
});
const latest = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.id, token.id))
.get();
expect(latest).toMatchObject({
token: 'sk-real-token-updated',
enabled: true,
});
expect((latest as any)?.valueStatus).toBe('ready');
});
it('deletes masked_pending placeholders locally without calling upstream delete', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-token',
token: 'sk-mask***tail',
source: 'sync',
enabled: false,
isDefault: false,
valueStatus: 'masked_pending' as any,
}).returning().get();
const response = await app.inject({
method: 'DELETE',
url: `/api/account-tokens/${token.id}`,
});
expect(response.statusCode).toBe(200);
expect(deleteApiTokenMock).not.toHaveBeenCalled();
const removed = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, token.id)).get();
expect(removed).toBeUndefined();
});
});
+53 -17
View File
@@ -2,12 +2,17 @@
import { and, eq } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import {
ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING,
ACCOUNT_TOKEN_VALUE_STATUS_READY,
ensureDefaultTokenForAccount,
isMaskedPendingAccountToken,
isMaskedTokenValue,
isUsableAccountToken,
listTokensWithRelations,
normalizeTokenForDisplay,
maskToken,
repairDefaultToken,
resolveAccountTokenValueStatus,
setDefaultToken,
syncTokensFromUpstream,
} from '../../services/accountTokenService.js';
@@ -38,7 +43,8 @@ type SyncExecutionResult = {
synced: boolean;
created: number;
updated: number;
maskedSkipped?: number;
maskedPending?: number;
pendingTokenIds?: number[];
total: number;
defaultTokenId?: number | null;
};
@@ -287,13 +293,13 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExe
}
const synced = await syncTokensFromUpstream(accountId, tokens);
if ((synced.created + synced.updated) === 0 && synced.maskedSkipped > 0) {
if ((synced.maskedPending || 0) > 0) {
return {
...base,
status: 'skipped',
status: 'synced',
reason: 'upstream_masked_tokens',
message: `上游返回脱敏令牌(含*),跳过 ${synced.maskedSkipped},无法同步明文令牌`,
synced: false,
message: `上游返回 ${synced.maskedPending}脱敏令牌,已保存为待补全记录,请手动补全明文 token。`,
synced: true,
...synced,
};
}
@@ -326,7 +332,7 @@ async function appendTokenSyncEvent(result: SyncExecutionResult) {
? 'info'
: (result.status === 'skipped' ? 'warning' : 'error');
const detail = result.status === 'synced'
? `新增 ${result.created},更新 ${result.updated},总数 ${result.total}`
? `新增 ${result.created},更新 ${result.updated}待补全 ${result.maskedPending || 0}总数 ${result.total}`
: (result.message || result.reason || 'sync skipped');
try {
@@ -485,15 +491,25 @@ export async function accountTokensRoutes(app: FastifyInstance) {
const existing = await db.select().from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, body.accountId))
.all();
const valueStatus = isMaskedTokenValue(tokenValue)
? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
: ACCOUNT_TOKEN_VALUE_STATUS_READY;
const enabled = valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY
? (body.enabled ?? true)
: false;
const isDefault = valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY
? (body.isDefault ?? false)
: false;
const inserted = await db.insert(schema.accountTokens).values({
accountId: body.accountId,
name: (body.name || '').trim() || (existing.length === 0 ? 'default' : `token-${existing.length + 1}`),
token: tokenValue,
tokenGroup: (body.group || '').trim() || null,
valueStatus,
source: body.source || 'manual',
enabled: body.enabled ?? true,
isDefault: body.isDefault ?? false,
enabled,
isDefault,
createdAt: now,
updatedAt: now,
}).run();
@@ -506,9 +522,9 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(500).send({ success: false, message: '创建令牌失败' });
}
if (body.isDefault || (existing.length === 0 && (body.enabled ?? true))) {
if (valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY && (body.isDefault || (existing.length === 0 && enabled))) {
await setDefaultToken(created.id);
} else if (existing.every((token) => !token.isDefault) && (body.enabled ?? true)) {
} else if (valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY && existing.every((token) => !token.isDefault) && enabled) {
await setDefaultToken(created.id);
}
const coverageRefresh = await refreshCoverageForAccounts([body.accountId]);
@@ -629,7 +645,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
const account = row.accounts;
const site = row.sites;
const adapter = getAdapter(site.platform);
const shouldDeleteUpstream = !isSiteDisabled(site.status) && !!account.accessToken?.trim() && !!adapter;
const shouldDeleteUpstream = !isMaskedPendingAccountToken(existing)
&& !isSiteDisabled(site.status)
&& !!account.accessToken?.trim()
&& !!adapter;
if (shouldDeleteUpstream) {
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
@@ -690,6 +709,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
continue;
}
} else {
if (isMaskedPendingAccountToken(existing)) {
failedItems.push({ id, message: '待补全令牌不能修改启用状态,请先补全明文 token' });
continue;
}
await db.update(schema.accountTokens)
.set({
enabled: action === 'enable',
@@ -736,6 +759,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
const body = request.body;
const updates: Record<string, unknown> = { updatedAt: new Date().toISOString() };
let nextValueStatus = resolveAccountTokenValueStatus(existing);
if (body.name !== undefined) {
updates.name = (body.name || '').trim() || existing.name;
@@ -747,15 +771,24 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: '令牌不能为空' });
}
updates.token = tokenValue;
nextValueStatus = isMaskedTokenValue(tokenValue)
? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
: ACCOUNT_TOKEN_VALUE_STATUS_READY;
updates.valueStatus = nextValueStatus;
}
if (body.group !== undefined) {
updates.tokenGroup = (body.group || '').trim() || null;
}
if (body.enabled !== undefined) updates.enabled = body.enabled;
if (nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) {
updates.enabled = false;
updates.isDefault = false;
} else {
if (body.enabled !== undefined) updates.enabled = body.enabled;
if (body.isDefault !== undefined) updates.isDefault = body.isDefault;
}
if (body.source !== undefined) updates.source = body.source;
if (body.isDefault !== undefined) updates.isDefault = body.isDefault;
await db.update(schema.accountTokens).set(updates).where(eq(schema.accountTokens.id, tokenId)).run();
@@ -764,11 +797,11 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(500).send({ success: false, message: '更新失败' });
}
if (body.isDefault === true) {
if (body.isDefault === true && isUsableAccountToken(latest)) {
setDefaultToken(tokenId);
} else if (latest.isDefault && latest.enabled) {
} else if (latest.isDefault && isUsableAccountToken(latest)) {
setDefaultToken(tokenId);
} else if (existing.isDefault && !latest.enabled) {
} else if (existing.isDefault && !isUsableAccountToken(latest)) {
repairDefaultToken(existing.accountId);
} else if (body.isDefault === false && existing.isDefault) {
repairDefaultToken(existing.accountId);
@@ -793,6 +826,9 @@ export async function accountTokensRoutes(app: FastifyInstance) {
if (isApiKeyConnection(owner)) {
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
}
if (isMaskedPendingAccountToken(tokenRow)) {
return reply.code(400).send({ success: false, message: '待补全令牌不能设为默认,请先补全明文 token' });
}
const success = await setDefaultToken(tokenId);
if (!success) {
return reply.code(404).send({ success: false, message: '令牌不存在' });
@@ -820,7 +856,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
}
if (isMaskedTokenValue(row.account_tokens.token)) {
if (isMaskedPendingAccountToken(row.account_tokens) || isMaskedTokenValue(row.account_tokens.token)) {
return reply.code(409).send({
success: false,
message: '当前仅保存了脱敏令牌,无法展开/复制。请在站点重新生成并同步,或手动更新为完整令牌。',
+2
View File
@@ -3,6 +3,7 @@ import { db, schema } from '../../db/index.js';
import { and, like, desc, eq, or } from 'drizzle-orm';
import { getProxyLogBaseSelectFields } from '../../services/proxyLogStore.js';
import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js';
import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js';
function hasSessionTokenValue(value: string | null | undefined): boolean {
return typeof value === 'string' && value.trim().length > 0;
@@ -134,6 +135,7 @@ export async function searchRoutes(app: FastifyInstance) {
like(schema.tokenModelAvailability.modelName, q),
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
eq(schema.accounts.status, 'active'),
),
)
+11
View File
@@ -18,6 +18,7 @@ import {
withProxyLogSelectFields,
} from '../../services/proxyLogStore.js';
import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js';
import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js';
import {
formatLocalDateTime,
formatUtcSqlDateTime,
@@ -724,6 +725,15 @@ export async function statsRoutes(app: FastifyInstance) {
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
const accountAvailability = await db.select().from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
@@ -989,6 +999,7 @@ export async function statsRoutes(app: FastifyInstance) {
and(
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
+38 -27
View File
@@ -1,10 +1,14 @@
import { FastifyInstance } from 'fastify';
import { and, eq, inArray } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { and, eq, inArray } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import {
ACCOUNT_TOKEN_VALUE_STATUS_READY,
isUsableAccountToken,
} from '../../services/accountTokenService.js';
import { normalizeRouteRoutingStrategy } from '../../services/routeRoutingStrategy.js';
import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import {
clearRouteDecisionSnapshot,
clearRouteDecisionSnapshots,
@@ -19,12 +23,17 @@ function isExactModelPattern(modelPattern: string): boolean {
return !/[\*\?\[]/.test(normalized);
}
async function getDefaultTokenId(accountId: number): Promise<number | null> {
const token = await db.select().from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, accountId), eq(schema.accountTokens.enabled, true), eq(schema.accountTokens.isDefault, true)))
.get();
return token?.id ?? null;
}
async function getDefaultTokenId(accountId: number): Promise<number | null> {
const token = await db.select().from(schema.accountTokens)
.where(and(
eq(schema.accountTokens.accountId, accountId),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.isDefault, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
))
.get();
return isUsableAccountToken(token ?? null) ? token!.id : null;
}
function canonicalModelAlias(modelName: string): string {
const normalized = modelName.trim().toLowerCase();
@@ -58,12 +67,12 @@ async function tokenSupportsModel(tokenId: number, modelName: string): Promise<b
});
}
async function checkTokenBelongsToAccount(tokenId: number, accountId: number): Promise<boolean> {
const row = await db.select().from(schema.accountTokens)
.where(and(eq(schema.accountTokens.id, tokenId), eq(schema.accountTokens.accountId, accountId)))
.get();
return !!row;
}
async function checkTokenBelongsToAccount(tokenId: number, accountId: number): Promise<boolean> {
const row = await db.select().from(schema.accountTokens)
.where(and(eq(schema.accountTokens.id, tokenId), eq(schema.accountTokens.accountId, accountId)))
.get();
return isUsableAccountToken(row ?? null);
}
async function getPatternTokenCandidates(modelPattern: string): Promise<Array<{ tokenId: number; accountId: number; sourceModel: string }>> {
const rows = await db.select().from(schema.tokenModelAvailability)
@@ -72,18 +81,20 @@ async function getPatternTokenCandidates(modelPattern: string): Promise<Array<{
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
const result: Array<{ tokenId: number; accountId: number; sourceModel: string }> = [];
for (const row of rows) {
const modelName = row.token_model_availability.modelName?.trim();
if (!modelName) continue;
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
const result: Array<{ tokenId: number; accountId: number; sourceModel: string }> = [];
for (const row of rows) {
if (!isUsableAccountToken(row.account_tokens)) continue;
const modelName = row.token_model_availability.modelName?.trim();
if (!modelName) continue;
if (!matchesModelPattern(modelName, modelPattern)) continue;
result.push({
tokenId: row.account_tokens.id,
+121 -13
View File
@@ -9,6 +9,14 @@ type UpstreamApiToken = {
tokenGroup?: string | null;
};
type AccountTokenRow = typeof schema.accountTokens.$inferSelect;
export const ACCOUNT_TOKEN_VALUE_STATUS_READY = 'ready' as const;
export const ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING = 'masked_pending' as const;
export type AccountTokenValueStatus =
| typeof ACCOUNT_TOKEN_VALUE_STATUS_READY
| typeof ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING;
export function normalizeTokenForDisplay(token?: string | null, platform?: string | null): string {
if (!token) return '';
const value = token.trim();
@@ -52,6 +60,45 @@ export function isMaskedTokenValue(token: string | null | undefined): boolean {
return value.includes('*') || value.includes('•');
}
function normalizeTokenValueStatus(value: string | null | undefined): AccountTokenValueStatus {
const normalized = (value || '').trim().toLowerCase();
return normalized === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
: ACCOUNT_TOKEN_VALUE_STATUS_READY;
}
export function resolveAccountTokenValueStatus(
value: Pick<AccountTokenRow, 'token' | 'valueStatus'> | string | null | undefined,
): AccountTokenValueStatus {
if (typeof value === 'string' || value == null) {
return normalizeTokenValueStatus(value);
}
const explicit = normalizeTokenValueStatus(value.valueStatus);
if (explicit === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) {
return ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING;
}
return isMaskedTokenValue(value.token)
? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
: ACCOUNT_TOKEN_VALUE_STATUS_READY;
}
export function isReadyAccountToken(token: Pick<AccountTokenRow, 'token' | 'valueStatus'> | null | undefined): boolean {
if (!token) return false;
return resolveAccountTokenValueStatus(token) === ACCOUNT_TOKEN_VALUE_STATUS_READY
&& !isMaskedTokenValue(token.token);
}
export function isMaskedPendingAccountToken(token: Pick<AccountTokenRow, 'token' | 'valueStatus'> | null | undefined): boolean {
if (!token) return false;
return resolveAccountTokenValueStatus(token) === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING;
}
export function isUsableAccountToken(token: AccountTokenRow | null | undefined): boolean {
if (!token) return false;
return token.enabled === true && isReadyAccountToken(token);
}
function normalizeTokenGroup(value: string | null | undefined, tokenName?: string | null): string | null {
const explicit = (value || '').trim();
if (explicit.length > 0) return explicit;
@@ -66,6 +113,15 @@ function normalizeTokenGroup(value: string | null | undefined, tokenName?: strin
return name;
}
function sameTokenGroup(
leftGroup: string | null | undefined,
leftName: string | null | undefined,
rightGroup: string | null | undefined,
rightName: string | null | undefined,
): boolean {
return normalizeTokenGroup(leftGroup, leftName) === normalizeTokenGroup(rightGroup, rightName);
}
async function updateAccountApiToken(accountId: number, tokenValue: string | null) {
await db.update(schema.accounts)
.set({ apiToken: tokenValue || null, updatedAt: new Date().toISOString() })
@@ -85,9 +141,10 @@ export async function getPreferredAccountToken(accountId: number) {
.where(and(eq(schema.accountTokens.accountId, accountId), eq(schema.accountTokens.enabled, true)))
.all();
if (tokens.length === 0) return null;
const usableTokens = tokens.filter(isUsableAccountToken);
if (usableTokens.length === 0) return null;
const preferred = tokens.find((t) => t.isDefault) || tokens[0];
const preferred = usableTokens.find((t) => t.isDefault) || usableTokens[0];
return preferred;
}
@@ -98,6 +155,7 @@ export async function ensureDefaultTokenForAccount(
): Promise<number | null> {
const normalizedToken = normalizeTokenValue(tokenValue);
if (!normalizedToken) return null;
if (isMaskedTokenValue(normalizedToken)) return null;
const tokenGroup = normalizeTokenGroup(options?.tokenGroup, options?.name) || 'default';
const now = new Date().toISOString();
@@ -114,6 +172,7 @@ export async function ensureDefaultTokenForAccount(
name: normalizeTokenName(options?.name, tokens.length + 1),
token: normalizedToken,
tokenGroup,
valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY,
source: options?.source || 'manual',
enabled: options?.enabled ?? true,
isDefault: true,
@@ -131,6 +190,7 @@ export async function ensureDefaultTokenForAccount(
.set({
name: options?.name ? normalizeTokenName(options.name) : target.name,
tokenGroup,
valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY,
source: options?.source || target.source || 'manual',
enabled: options?.enabled ?? target.enabled,
isDefault: true,
@@ -151,7 +211,7 @@ export async function ensureDefaultTokenForAccount(
export async function setDefaultToken(tokenId: number): Promise<boolean> {
const target = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get();
if (!target) return false;
if (!target || !isUsableAccountToken(target)) return false;
const now = new Date().toISOString();
await db.update(schema.accountTokens)
@@ -174,7 +234,7 @@ export async function repairDefaultToken(accountId: number) {
.where(eq(schema.accountTokens.accountId, accountId))
.all();
const enabled = tokens.filter((t) => t.enabled);
const enabled = tokens.filter(isUsableAccountToken);
if (enabled.length === 0) {
await updateAccountApiToken(accountId, null);
return null;
@@ -206,27 +266,30 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
let created = 0;
let updated = 0;
let maskedSkipped = 0;
let maskedPending = 0;
const pendingTokenIds: number[] = [];
let index = existing.length + 1;
for (const upstream of upstreamTokens) {
const tokenValue = normalizeTokenValue(upstream.key);
if (!tokenValue) continue;
if (isMaskedTokenValue(tokenValue)) {
maskedSkipped++;
continue;
}
const tokenName = normalizeTokenName(upstream.name, index);
const enabled = upstream.enabled ?? true;
const tokenGroup = normalizeTokenGroup(upstream.tokenGroup, tokenName);
const nextValueStatus = isMaskedTokenValue(tokenValue)
? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
: ACCOUNT_TOKEN_VALUE_STATUS_READY;
const byToken = existing.find((row) => row.token === tokenValue);
const byToken = existing.find((row) => (
row.token === tokenValue
&& resolveAccountTokenValueStatus(row) === ACCOUNT_TOKEN_VALUE_STATUS_READY
));
if (byToken) {
await db.update(schema.accountTokens)
.set({
name: tokenName,
tokenGroup,
valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY,
source: 'sync',
enabled,
updatedAt: now,
@@ -235,6 +298,7 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
.run();
byToken.name = tokenName;
byToken.tokenGroup = tokenGroup;
byToken.valueStatus = ACCOUNT_TOKEN_VALUE_STATUS_READY;
byToken.enabled = enabled;
byToken.source = 'sync';
byToken.updatedAt = now;
@@ -242,14 +306,52 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
continue;
}
const matchingPlaceholder = existing.find((row) => (
isMaskedPendingAccountToken(row)
&& row.name === tokenName
&& sameTokenGroup(row.tokenGroup, row.name, tokenGroup, tokenName)
));
if (matchingPlaceholder) {
const nextEnabled = nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY ? enabled : false;
await db.update(schema.accountTokens)
.set({
name: tokenName,
token: tokenValue,
tokenGroup,
valueStatus: nextValueStatus,
source: 'sync',
enabled: nextEnabled,
isDefault: false,
updatedAt: now,
})
.where(eq(schema.accountTokens.id, matchingPlaceholder.id))
.run();
matchingPlaceholder.name = tokenName;
matchingPlaceholder.token = tokenValue;
matchingPlaceholder.tokenGroup = tokenGroup;
matchingPlaceholder.valueStatus = nextValueStatus;
matchingPlaceholder.source = 'sync';
matchingPlaceholder.enabled = nextEnabled;
matchingPlaceholder.isDefault = false;
matchingPlaceholder.updatedAt = now;
updated++;
if (nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) {
maskedPending++;
pendingTokenIds.push(matchingPlaceholder.id);
}
continue;
}
const inserted = await db.insert(schema.accountTokens)
.values({
accountId,
name: tokenName,
token: tokenValue,
tokenGroup,
valueStatus: nextValueStatus,
source: 'sync',
enabled,
enabled: nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY ? enabled : false,
isDefault: false,
createdAt: now,
updatedAt: now,
@@ -263,6 +365,10 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
existing.push(createdRow);
created++;
index++;
if (nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) {
maskedPending++;
pendingTokenIds.push(createdRow.id);
}
}
const repaired = await repairDefaultToken(accountId);
@@ -270,7 +376,8 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
return {
created,
updated,
maskedSkipped,
maskedPending,
pendingTokenIds,
total: existing.length,
defaultTokenId: repaired?.id || null,
};
@@ -292,6 +399,7 @@ export async function listTokensWithRelations(accountId?: number) {
const { token, ...tokenMeta } = row.account_tokens;
return {
...tokenMeta,
valueStatus: resolveAccountTokenValueStatus(row.account_tokens),
tokenMasked: maskToken(token, row.sites.platform),
account: {
id: row.accounts.id,
+2
View File
@@ -238,6 +238,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
name: 'default',
token: apiToken,
tokenGroup: 'default',
valueStatus: 'ready',
source: 'legacy',
enabled: true,
isDefault: true,
@@ -520,6 +521,7 @@ async function importAccountsSection(section: AccountsBackupSection): Promise<vo
name: row.name,
token: row.token,
tokenGroup: row.tokenGroup ?? null,
valueStatus: row.valueStatus ?? 'ready',
source: row.source,
enabled: row.enabled,
isDefault: row.isDefault,
@@ -336,13 +336,14 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
for (const row of snapshot.accounts.accountTokens) {
statements.push({
table: 'account_tokens',
columns: ['id', 'account_id', 'name', 'token', 'token_group', 'source', 'enabled', 'is_default', 'created_at', 'updated_at'],
columns: ['id', 'account_id', 'name', 'token', 'token_group', 'value_status', 'source', 'enabled', 'is_default', 'created_at', 'updated_at'],
values: [
asNumber(row.id, 0),
asNumber(row.accountId, 0),
asNullableString(row.name),
asNullableString(row.token),
asNullableString(row.tokenGroup),
asNullableString((row as { valueStatus?: unknown }).valueStatus) ?? 'ready',
asNullableString(row.source) ?? 'manual',
asBoolean(row.enabled, true),
asBoolean(row.isDefault, false),
@@ -252,4 +252,52 @@ describe('refreshModelsForAccount credential discovery', () => {
reason: 'adapter_or_status',
});
});
it('does not scan masked_pending placeholders as token credentials', async () => {
getApiTokenMock.mockResolvedValue(null);
getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => (
token === 'sk-mask***tail' ? ['gpt-5.2-codex'] : []
));
const site = await db.insert(schema.sites).values({
name: 'site-placeholder',
url: 'https://site-placeholder.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'placeholder-user',
accessToken: '',
apiToken: null,
status: 'active',
extraConfig: JSON.stringify({ credentialMode: 'session' }),
}).returning().get();
const placeholder = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-token',
token: 'sk-mask***tail',
source: 'sync',
enabled: true,
isDefault: false,
valueStatus: 'masked_pending' as any,
}).returning().get();
const result = await refreshModelsForAccount(account.id);
expect(result).toMatchObject({
accountId: account.id,
refreshed: true,
status: 'failed',
tokenScanned: 0,
});
const placeholderModels = await db.select().from(schema.tokenModelAvailability)
.where(eq(schema.tokenModelAvailability.tokenId, placeholder.id))
.all();
expect(placeholderModels).toEqual([]);
expect(getModelsMock).not.toHaveBeenCalledWith(site.url, 'sk-mask***tail', account.username);
});
});
+26 -5
View File
@@ -1,7 +1,13 @@
import { and, eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { getAdapter } from './platforms/index.js';
import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accountTokenService.js';
import {
ACCOUNT_TOKEN_VALUE_STATUS_READY,
ensureDefaultTokenForAccount,
getPreferredAccountToken,
isMaskedTokenValue,
isUsableAccountToken,
} from './accountTokenService.js';
import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from './accountExtraConfig.js';
import { invalidateTokenRouterCache } from './tokenRouter.js';
import { setAccountRuntimeHealth } from './accountHealthService.js';
@@ -256,20 +262,27 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
API_TOKEN_DISCOVERY_TIMEOUT_MS,
`api token discovery timeout (${Math.round(API_TOKEN_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
);
if (discoveredApiToken) {
if (discoveredApiToken && !isMaskedTokenValue(discoveredApiToken)) {
ensureDefaultTokenForAccount(account.id, discoveredApiToken, { name: 'default', source: 'sync' });
await db.update(schema.accounts).set({
apiToken: discoveredApiToken,
updatedAt: new Date().toISOString(),
}).where(eq(schema.accounts.id, account.id)).run();
} else {
discoveredApiToken = null;
}
} catch { }
}
let enabledTokens = await db.select()
.from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
.where(and(
eq(schema.accountTokens.accountId, account.id),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
))
.all();
enabledTokens = enabledTokens.filter(isUsableAccountToken);
// Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table.
if (!isApiKeyConnection(account) && enabledTokens.length === 0) {
@@ -278,8 +291,13 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' });
enabledTokens = await db.select()
.from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
.where(and(
eq(schema.accountTokens.accountId, account.id),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
))
.all();
enabledTokens = enabledTokens.filter(isUsableAccountToken);
}
}
@@ -310,6 +328,7 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
const discoverModelsWithCredential = async (credentialRaw: string | null | undefined) => {
const credential = (credentialRaw || '').trim();
if (!credential) return;
if (isMaskedTokenValue(credential)) return;
if (attemptedCredentials.has(credential)) return;
attemptedCredentials.add(credential);
@@ -446,11 +465,13 @@ export async function rebuildTokenRoutesFromAvailability() {
and(
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
const usableTokenRows = tokenRows.filter((row) => isUsableAccountToken(row.account_tokens));
const accountRows = await db.select().from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
@@ -487,7 +508,7 @@ export async function rebuildTokenRoutesFromAvailability() {
modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId });
};
for (const row of tokenRows) {
for (const row of usableTokenRows) {
addModelCandidate(row.token_model_availability.modelName, row.accounts.id, row.account_tokens.id, row.accounts.siteId);
}
+3 -2
View File
@@ -9,6 +9,7 @@ import {
type RouteRoutingStrategy,
} from './routeRoutingStrategy.js';
import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js';
import { isUsableAccountToken } from './accountTokenService.js';
interface RouteMatch {
route: typeof schema.tokenRoutes.$inferSelect;
@@ -1032,10 +1033,10 @@ export class TokenRouter {
account: typeof schema.accounts.$inferSelect;
site?: typeof schema.sites.$inferSelect | null;
token: typeof schema.accountTokens.$inferSelect | null;
}): string | null {
}): string | null {
if (candidate.channel.tokenId) {
if (!candidate.token) return null;
if (!candidate.token.enabled) return null;
if (!isUsableAccountToken(candidate.token)) return null;
const token = candidate.token.token?.trim();
return token ? token : null;
}
+156 -49
View File
@@ -23,8 +23,11 @@ type AccountTokenSyncResult = {
success?: boolean;
synced?: boolean;
message?: string;
reason?: string;
created?: number;
updated?: number;
maskedPending?: number;
pendingTokenIds?: number[];
accountId?: number;
accountName?: string;
account?: {
@@ -65,6 +68,12 @@ const resolveSyncMessage = (result: AccountTokenSyncResult | null | undefined, f
return message || fallback;
};
const isMaskedPendingToken = (token: any): boolean => token?.valueStatus === 'masked_pending';
const isMaskedPendingSyncResult = (result: AccountTokenSyncResult | null | undefined) =>
String(result?.reason || '').trim().toLowerCase() === 'upstream_masked_tokens'
&& Number(result?.maskedPending || 0) > 0;
const resolveAccountLabel = (result: AccountTokenSyncResult | null | undefined) => {
const name = typeof result?.accountName === 'string' ? result.accountName.trim() : '';
if (name) return name;
@@ -130,8 +139,10 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
const [savingEdit, setSavingEdit] = useState(false);
const [editingToken, setEditingToken] = useState<any | null>(null);
const [editingTokenValueLoading, setEditingTokenValueLoading] = useState(false);
const [editingTokenPendingMessage, setEditingTokenPendingMessage] = useState('');
const [createHintModelName, setCreateHintModelName] = useState('');
const [highlightTokenId, setHighlightTokenId] = useState<number | null>(null);
const [pendingAutoOpenTokenId, setPendingAutoOpenTokenId] = useState<number | null>(null);
const [rowLoading, setRowLoading] = useState<Record<string, boolean>>({});
const [selectedTokenIds, setSelectedTokenIds] = useState<number[]>([]);
const [batchActionLoading, setBatchActionLoading] = useState(false);
@@ -173,8 +184,16 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
if (!hasCurrentSelected) {
setSyncingAccountId(syncableAccounts[0]?.id || 0);
}
return {
tokens: nextTokens,
accounts: latestAccounts,
};
} catch (e: any) {
toast.error(e.message || '加载令牌失败');
return {
tokens: [] as any[],
accounts: [] as any[],
};
} finally {
setLoading(false);
}
@@ -346,6 +365,18 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
navigate({ pathname: location.pathname, search: cleanedSearch }, { replace: true });
}, [loading, location.pathname, location.search, navigate, tokens]);
const focusTokenRow = useCallback((tokenId: number) => {
const row = rowRefs.current.get(tokenId);
if (row) {
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
setHighlightTokenId(tokenId);
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
highlightTimerRef.current = setTimeout(() => {
setHighlightTokenId((current) => (current === tokenId ? null : current));
}, 2200);
}, []);
const withRowLoading = async (key: string, fn: () => Promise<void>) => {
setRowLoading((prev) => ({ ...prev, [key]: true }));
try {
@@ -422,13 +453,24 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
setCreateHintModelName('');
setEditingToken(token);
editingTokenIdRef.current = token.id;
setEditingTokenPendingMessage(
isMaskedPendingToken(token)
? '请粘贴完整明文 token;当前本地仅保存了上游返回的脱敏占位值。'
: '',
);
setEditForm({
name: token?.name || '',
token: '',
group: (token?.tokenGroup || '').trim() || 'default',
enabled: token?.enabled !== false,
enabled: isMaskedPendingToken(token) ? true : token?.enabled !== false,
isDefault: !!token?.isDefault,
});
if (isMaskedPendingToken(token)) {
setEditingTokenValueLoading(false);
return;
}
setEditingTokenValueLoading(true);
void api.getAccountTokenValue(token.id)
@@ -454,6 +496,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
setEditingToken(null);
setSavingEdit(false);
setEditingTokenValueLoading(false);
setEditingTokenPendingMessage('');
setEditForm({
name: '',
token: '',
@@ -465,6 +508,10 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
const saveEditPanel = async () => {
if (!editingToken) return;
if (isMaskedPendingToken(editingToken) && !editForm.token.trim()) {
toast.error('请粘贴完整明文 token 后再保存');
return;
}
setSavingEdit(true);
try {
await api.updateAccountToken(editingToken.id, {
@@ -484,6 +531,15 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
}
};
useEffect(() => {
if (!pendingAutoOpenTokenId || loading) return;
const token = tokens.find((item: any) => item.id === pendingAutoOpenTokenId);
if (!token) return;
focusTokenRow(token.id);
openEditPanel(token);
setPendingAutoOpenTokenId(null);
}, [focusTokenRow, loading, openEditPanel, pendingAutoOpenTokenId, tokens]);
const handleTokenRowClick = (tokenId: number, event: React.MouseEvent<HTMLTableRowElement>) => {
if (shouldIgnoreRowSelectionClick(event.target)) return;
const isSelected = selectedTokenIds.includes(tokenId);
@@ -551,6 +607,25 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
const status = resolveSyncStatus(res);
if (status === 'failed') {
toast.error(`同步失败:${resolveSyncMessage(res, '请检查账号令牌或站点状态')}`);
} else if (isMaskedPendingSyncResult(res)) {
toast.info(resolveSyncMessage(res, '上游返回了脱敏令牌,请补全明文 token'));
const loaded = await load();
const pendingIds = Array.isArray(res.pendingTokenIds)
? res.pendingTokenIds.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0)
: [];
const nextTokens = Array.isArray(loaded?.tokens) ? loaded.tokens : [];
if (pendingIds.length === 1) {
const pendingToken = nextTokens.find((token: any) => token.id === pendingIds[0]);
if (pendingToken) {
focusTokenRow(pendingToken.id);
openEditPanel(pendingToken);
} else {
setPendingAutoOpenTokenId(pendingIds[0] || null);
}
} else if (pendingIds.length > 1) {
focusTokenRow(pendingIds[0]!);
}
return;
} else if (status === 'skipped') {
toast.info(`同步已跳过:${resolveSyncMessage(res, '账号缺少可用 Session Cookie')}`);
} else {
@@ -562,7 +637,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
} finally {
setSyncing(false);
}
}, [load, syncingAccountId, toast]);
}, [focusTokenRow, load, openEditPanel, syncingAccountId, toast]);
const handleSyncAll = useCallback(async () => {
setSyncingAll(true);
@@ -594,12 +669,16 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
const failedRows = syncResults.filter((item) => resolveSyncStatus(item) === 'failed');
const skippedRows = syncResults.filter((item) => resolveSyncStatus(item) === 'skipped');
const successRows = syncResults.filter((item) => resolveSyncStatus(item) === 'success');
const maskedRows = syncResults.filter((item) => isMaskedPendingSyncResult(item));
toast.success(`全部同步完成:成功 ${successRows.length},跳过 ${skippedRows.length},失败 ${failedRows.length}`);
failedRows.slice(0, 3).forEach((item) => {
toast.error(`${resolveAccountLabel(item)} 同步失败:${resolveSyncMessage(item, '请检查账号配置')}`);
});
maskedRows.slice(0, 3).forEach((item) => {
toast.info(`${resolveAccountLabel(item)} 需要补全明文 token${resolveSyncMessage(item, '上游返回脱敏令牌')}`);
});
skippedRows.slice(0, 3).forEach((item) => {
toast.info(`${resolveAccountLabel(item)} 已跳过:${resolveSyncMessage(item, '不满足同步条件')}`);
});
@@ -771,6 +850,20 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
>
: {editingToken.account?.username || `account-${editingToken.accountId}`} @ {editingToken.site?.name || '-'}
</div>
{editingTokenPendingMessage ? (
<div
style={{
fontSize: 12,
color: 'var(--color-text-secondary)',
background: 'color-mix(in srgb, var(--color-warning) 12%, var(--color-bg))',
border: '1px solid color-mix(in srgb, var(--color-warning) 28%, transparent)',
borderRadius: 'var(--radius-sm)',
padding: '8px 10px',
}}
>
{editingTokenPendingMessage}
</div>
) : null}
<div style={sectionCardStyle}>
<div style={sectionLabelStyle}></div>
<div>
@@ -1008,6 +1101,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
<div className="mobile-card-list">
{accountClusteredTokens.map((token: any) => {
const loadingPrefix = `token-${token.id}`;
const isPending = isMaskedPendingToken(token);
return (
<MobileCard
key={token.id}
@@ -1049,8 +1143,8 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
<MobileField
label="状态"
value={(
<span className={`badge ${token.enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>
{token.enabled ? '启用' : '禁用'}
<span className={`badge ${isPending ? 'badge-warning' : (token.enabled ? 'badge-success' : 'badge-muted')}`} style={{ fontSize: 11 }}>
{isPending ? '待补全' : (token.enabled ? '启用' : '禁用')}
</span>
)}
/>
@@ -1060,7 +1154,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
/>
<MobileField label="更新时间" value={formatDateTimeLocal(token.updatedAt)} />
<div className="mobile-card-actions">
{!token.isDefault && (
{!isPending && !token.isDefault && (
<button
onClick={() => withRowLoading(`${loadingPrefix}-default`, async () => {
await api.setDefaultAccountToken(token.id);
@@ -1073,31 +1167,35 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
{rowLoading[`${loadingPrefix}-default`] ? <span className="spinner spinner-sm" /> : '设默认'}
</button>
)}
<button
onClick={() => handleCopyToken(token.id, token.name || '')}
disabled={!!rowLoading[`${loadingPrefix}-copy`]}
className="btn btn-link btn-link-primary"
data-testid={`token-copy-${token.id}`}
>
{rowLoading[`${loadingPrefix}-copy`] ? <span className="spinner spinner-sm" /> : '复制'}
</button>
{!isPending ? (
<button
onClick={() => handleCopyToken(token.id, token.name || '')}
disabled={!!rowLoading[`${loadingPrefix}-copy`]}
className="btn btn-link btn-link-primary"
data-testid={`token-copy-${token.id}`}
>
{rowLoading[`${loadingPrefix}-copy`] ? <span className="spinner spinner-sm" /> : '复制'}
</button>
) : null}
<button
onClick={() => openEditPanel(token)}
className="btn btn-link btn-link-info"
>
</button>
<button
onClick={() => withRowLoading(`${loadingPrefix}-toggle`, async () => {
await api.updateAccountToken(token.id, { enabled: !token.enabled });
toast.success(token.enabled ? '令牌已禁用' : '令牌已启用');
await load();
})}
disabled={!!rowLoading[`${loadingPrefix}-toggle`]}
className={`btn btn-link ${token.enabled ? 'btn-link-warning' : 'btn-link-primary'}`}
>
{rowLoading[`${loadingPrefix}-toggle`] ? <span className="spinner spinner-sm" /> : (token.enabled ? '禁用' : '启用')}
{isPending ? '编辑补全' : '编辑'}
</button>
{!isPending ? (
<button
onClick={() => withRowLoading(`${loadingPrefix}-toggle`, async () => {
await api.updateAccountToken(token.id, { enabled: !token.enabled });
toast.success(token.enabled ? '令牌已禁用' : '令牌已启用');
await load();
})}
disabled={!!rowLoading[`${loadingPrefix}-toggle`]}
className={`btn btn-link ${token.enabled ? 'btn-link-warning' : 'btn-link-primary'}`}
>
{rowLoading[`${loadingPrefix}-toggle`] ? <span className="spinner spinner-sm" /> : (token.enabled ? '禁用' : '启用')}
</button>
) : null}
<button
onClick={() => setDeleteConfirm({ mode: 'single', tokenId: token.id, tokenName: token.name || '' })}
disabled={!!rowLoading[`${loadingPrefix}-delete`]}
@@ -1135,6 +1233,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
<tbody>
{accountClusteredTokens.map((token: any, i: number) => {
const loadingPrefix = `token-${token.id}`;
const isPending = isMaskedPendingToken(token);
return (
<tr
key={token.id}
@@ -1179,15 +1278,19 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
<td>{token.account?.username || `account-${token.accountId}`}</td>
<td>{token.tokenGroup || 'default'}</td>
<td>
<span className={`badge ${token.enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>
{token.enabled ? '启用' : '禁用'}
</span>
{isPending ? (
<span className="badge badge-warning" style={{ fontSize: 11 }}></span>
) : (
<span className={`badge ${token.enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>
{token.enabled ? '启用' : '禁用'}
</span>
)}
</td>
<td>{token.isDefault ? <span className="badge badge-warning" style={{ fontSize: 11 }}></span> : '-'}</td>
<td style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{formatDateTimeLocal(token.updatedAt)}</td>
<td className="token-actions-cell" style={{ textAlign: 'right' }}>
<div className="token-table-actions">
{!token.isDefault && (
{!isPending && !token.isDefault && (
<button
onClick={() => withRowLoading(`${loadingPrefix}-default`, async () => {
await api.setDefaultAccountToken(token.id);
@@ -1200,31 +1303,35 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
{rowLoading[`${loadingPrefix}-default`] ? <span className="spinner spinner-sm" /> : '设默认'}
</button>
)}
<button
onClick={() => handleCopyToken(token.id, token.name || '')}
disabled={!!rowLoading[`${loadingPrefix}-copy`]}
className="btn btn-link btn-link-primary token-table-action-btn"
data-testid={`token-copy-${token.id}`}
>
{rowLoading[`${loadingPrefix}-copy`] ? <span className="spinner spinner-sm" /> : '复制'}
</button>
{!isPending ? (
<button
onClick={() => handleCopyToken(token.id, token.name || '')}
disabled={!!rowLoading[`${loadingPrefix}-copy`]}
className="btn btn-link btn-link-primary token-table-action-btn"
data-testid={`token-copy-${token.id}`}
>
{rowLoading[`${loadingPrefix}-copy`] ? <span className="spinner spinner-sm" /> : '复制'}
</button>
) : null}
<button
onClick={() => openEditPanel(token)}
className="btn btn-link btn-link-info token-table-action-btn"
>
</button>
<button
onClick={() => withRowLoading(`${loadingPrefix}-toggle`, async () => {
await api.updateAccountToken(token.id, { enabled: !token.enabled });
toast.success(token.enabled ? '令牌已禁用' : '令牌已启用');
await load();
})}
disabled={!!rowLoading[`${loadingPrefix}-toggle`]}
className={`btn btn-link ${token.enabled ? 'btn-link-warning' : 'btn-link-primary'} token-table-action-btn`}
>
{rowLoading[`${loadingPrefix}-toggle`] ? <span className="spinner spinner-sm" /> : (token.enabled ? '禁用' : '启用')}
{isPending ? '编辑补全' : '编辑'}
</button>
{!isPending ? (
<button
onClick={() => withRowLoading(`${loadingPrefix}-toggle`, async () => {
await api.updateAccountToken(token.id, { enabled: !token.enabled });
toast.success(token.enabled ? '令牌已禁用' : '令牌已启用');
await load();
})}
disabled={!!rowLoading[`${loadingPrefix}-toggle`]}
className={`btn btn-link ${token.enabled ? 'btn-link-warning' : 'btn-link-primary'} token-table-action-btn`}
>
{rowLoading[`${loadingPrefix}-toggle`] ? <span className="spinner spinner-sm" /> : (token.enabled ? '禁用' : '启用')}
</button>
) : null}
<button
onClick={() => setDeleteConfirm({ mode: 'single', tokenId: token.id, tokenName: token.name || '' })}
disabled={!!rowLoading[`${loadingPrefix}-delete`]}
@@ -3,6 +3,7 @@ import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import Accounts from './Accounts.js';
import { TokensPanel } from './Tokens.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
@@ -11,6 +12,7 @@ const { apiMock } = vi.hoisted(() => ({
getAccountTokens: vi.fn(),
getAccountTokenValue: vi.fn(),
getAccountTokenGroups: vi.fn(),
syncAccountTokens: vi.fn(),
updateAccountToken: vi.fn(),
},
}));
@@ -56,6 +58,26 @@ function buildRoot() {
);
}
function buildTokensRoot() {
return create(
<MemoryRouter initialEntries={['/accounts?segment=tokens']}>
<ToastProvider>
<TokensPanel />
</ToastProvider>
</MemoryRouter>,
{
createNodeMock: (element) => {
if (element.type === 'tr' || element.type === 'div') {
return {
scrollIntoView: () => undefined,
};
}
return {};
},
},
);
}
describe('Tokens edit modal and row selection', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -87,6 +109,7 @@ describe('Tokens edit modal and row selection', () => {
id: 22,
name: 'focus-token',
tokenMasked: 'sk-focus****',
valueStatus: 'ready',
enabled: true,
isDefault: false,
updatedAt: '2026-03-07 10:00:00',
@@ -106,6 +129,13 @@ describe('Tokens edit modal and row selection', () => {
apiMock.updateAccountToken.mockResolvedValue({
success: true,
});
apiMock.syncAccountTokens.mockResolvedValue({
success: true,
synced: true,
status: 'synced',
created: 0,
updated: 0,
});
});
afterEach(() => {
@@ -228,4 +258,133 @@ describe('Tokens edit modal and row selection', () => {
root?.unmount();
}
});
it('shows placeholder guidance and saves masked_pending tokens as ready values', async () => {
apiMock.getAccountTokens.mockResolvedValue([
{
id: 33,
name: 'masked-token',
tokenMasked: 'sk-abc***xyz',
valueStatus: 'masked_pending',
enabled: false,
isDefault: false,
updatedAt: '2026-03-16 08:00:00',
accountId: 1,
account: { username: 'session-user' },
site: { name: 'Session Site', url: 'https://session.example.com' },
},
]);
apiMock.getAccountTokenValue.mockRejectedValueOnce(new Error('当前仅保存了脱敏令牌,无法展开/复制。请在站点重新生成并同步,或手动更新为完整令牌。'));
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = buildTokensRoot();
});
await flushMicrotasks();
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('待补全');
expect(rendered).not.toContain('复制');
expect(rendered).not.toContain('设默认');
const editButton = root.root
.findAll((node) => node.type === 'button')
.find((node) => collectText(node).includes('编辑'));
expect(editButton).toBeTruthy();
await act(async () => {
editButton!.props.onClick({ stopPropagation: () => undefined });
});
await flushMicrotasks();
await flushMicrotasks();
const afterOpen = JSON.stringify(root.toJSON());
expect(afterOpen).toContain('请粘贴完整明文 token');
expect(afterOpen).toContain('编辑令牌');
const textarea = root.root.findAll((node) => node.type === 'textarea')[0];
expect(textarea).toBeTruthy();
await act(async () => {
textarea.props.onChange({ target: { value: 'sk-complete-real-token' } });
});
await flushMicrotasks();
const saveButton = root.root
.findAll((node) => node.type === 'button')
.find((node) => collectText(node).includes('保存修改'));
expect(saveButton).toBeTruthy();
await act(async () => {
saveButton!.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateAccountToken).toHaveBeenCalledWith(33, expect.objectContaining({
token: 'sk-complete-real-token',
enabled: true,
}));
} finally {
root?.unmount();
}
});
it('opens the placeholder edit modal automatically after a sync creates one pending token', async () => {
apiMock.getAccountTokens.mockResolvedValue([
{
id: 44,
name: 'masked-after-sync',
tokenMasked: 'sk-xyz***123',
valueStatus: 'masked_pending',
enabled: false,
isDefault: false,
updatedAt: '2026-03-16 09:00:00',
accountId: 1,
account: { username: 'session-user' },
site: { name: 'Session Site', url: 'https://session.example.com' },
},
]);
apiMock.syncAccountTokens.mockResolvedValueOnce({
success: true,
synced: true,
status: 'synced',
reason: 'upstream_masked_tokens',
message: '上游返回 1 条脱敏令牌,已保存为待补全记录,请手动补全明文 token。',
maskedPending: 1,
pendingTokenIds: [44],
created: 1,
updated: 0,
});
apiMock.getAccountTokenValue.mockRejectedValueOnce(new Error('当前仅保存了脱敏令牌,无法展开/复制。请在站点重新生成并同步,或手动更新为完整令牌。'));
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = buildTokensRoot();
});
await flushMicrotasks();
const syncButton = root.root
.findAll((node) => node.type === 'button')
.find((node) => collectText(node).trim() === '同步站点令牌');
expect(syncButton).toBeTruthy();
await act(async () => {
await syncButton!.props.onClick();
});
await flushMicrotasks();
await flushMicrotasks();
await flushMicrotasks();
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('编辑令牌');
expect(rendered).toContain('请粘贴完整明文 token');
expect(rendered).toContain('上游返回 1 条脱敏令牌');
expect(apiMock.syncAccountTokens).toHaveBeenCalledWith(1);
expect(apiMock.getAccountTokenGroups).toHaveBeenCalledWith(1);
} finally {
root?.unmount();
}
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB