fix: harden gallery storage and admin ops

This commit is contained in:
FengLee
2026-05-20 15:25:44 +08:00
parent ca2a009948
commit 4320a1499c
12 changed files with 198 additions and 34 deletions

View File

@@ -99,7 +99,7 @@ Important generation helpers:
- `src/lib/server-api-config.ts`: resolves `customApiKeyId` and `systemApiId`.
- `src/lib/custom-api-fetch.ts`: upstream request/retry/error parsing.
- `src/lib/user-api-manifest.ts` and `src/lib/user-api-manifest-executor.ts`: parse/import per-key user and system API Manifests and execute the selected model's JSON/multipart/poll mapping before falling back to legacy custom API compatibility. User Manifest files are never merged per user; the chosen `customApiKeyId` controls the exact `manifest_path`. Admin system imports use separate `system-api-manifests/<systemApiId>.json` files and generation resolves them from the selected `systemApiId`.
- `pnpm run migration:check` runs `scripts/migration-integrity-check.mjs` as a read-only production migration gate. It checks auth/profile parity, password hash presence, user-owned table references, API key preview metadata, same-user work dedupe state, required schema columns, and `/api/local-storage/*` URL availability without printing secret values.
- `pnpm run migration:check` runs `scripts/migration-integrity-check.mjs` as a read-only production migration gate. It checks auth/profile parity, password hash presence, user-owned table references, API key preview metadata, same-user work dedupe state, required schema columns, and `/api/local-storage/*` URL availability without printing secret values. The default probe base URL is the production web port `http://127.0.0.1:8000`; override with `MIGRATION_CHECK_BASE_URL` when checking another runtime. Storage URL probes are bounded by timeout/concurrency helpers so one slow media URL is counted as a blocker instead of crashing the whole check.
- `pnpm run rainyun:ros-prepare -- --create` runs `scripts/rainyun-ros-prepare.mjs` against Rainyun's ROS control-plane API (`POST https://api.v2.rainyun.com/product/ros/bucket`, body `{ bucket_name, instance_id }`, `x-api-key` header). It writes standard S3-compatible `OBJECT_STORAGE_*` values to `.env.rainyun-object.generated`; secrets are redacted from console output.
## Creation History And Gallery
@@ -128,12 +128,12 @@ All routes in this section require admin unless noted.
| GET/POST/PUT | `/api/admin/orders` | `src/app/api/admin/orders/route.ts` | List/create/update orders. |
| GET/POST/PUT/DELETE | `/api/admin/redeem-codes` | `src/app/api/admin/redeem-codes/route.ts` | Admin redeem-code management. GET lists codes by status/search, POST generates 1-500 unique single-use credit or membership codes, PUT enables/disables unused codes, and DELETE removes unused codes. Membership-code payloads include `membershipTier`, `membershipDurationValue`, and `membershipDurationUnit` (`day`, `month`, `year`). The redeem-code management UI also saves the shared external mall URL through `/api/site-config` as `redeemCodeMallUrl`. |
| GET/PUT | `/api/admin/payment-methods` | `src/app/api/admin/payment-methods/route.ts` | Payment config. |
| GET/POST/PUT/DELETE | `/api/admin/providers` | `src/app/api/admin/providers/route.ts` | Provider registry CRUD. GET is currently not guarded in source; mutations require admin. |
| GET/POST/PUT/DELETE | `/api/admin/providers` | `src/app/api/admin/providers/route.ts` | Provider registry CRUD. All methods require admin bearer auth. |
| GET/POST/PUT/DELETE | `/api/admin/system-apis` | `src/app/api/admin/system-apis/route.ts` | System API config CRUD with encrypted keys, pricing metadata, platform-default visibility, allowed membership tiers, default-model polling fields `pollingMode`/`pollingOrder`, and video entry usage modes `videoUsageModes`. Successful generation jobs charge user credits from this selected row's pricing through `src/lib/generation-credit-service.ts`. |
| POST | `/api/admin/system-apis/smart-import` | `src/app/api/admin/system-apis/smart-import/route.ts` | Admin-only intelligent Manifest import. Creates one global `system_api_configs` row per imported profile/model, resolves the visible API request URL from the Manifest profile/provider, rejects configs without a resolvable relay API request URL, writes `system-api-manifests/<systemApiId>.json`, and leaves API Key as `待填写` for admin review. Optional `profile.capabilities` is returned through system model config for selected-model image option filtering. Imported rows also carry platform-default visibility, membership-tier allowlist, and default polling fields. |
| GET | `/api/admin/system-apis/yuanjie-capabilities` | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts` | Admin-only 元界 AI built-in image/video template preview retained for the system-default-model template path, not for the `智能配置 API` UI. Returns `capabilitiesText`, image templates from `src/lib/yuanjie-image-model-templates.ts`, and video templates from `src/lib/yuanjie-video-model-templates.ts`; it does not call 元界 `/v1/skills` or `/v1/skills/guide`. |
| POST | `/api/admin/system-apis/yuanjie-capabilities` | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts` | Admin-only 元界 AI built-in installer retained for system-default-model template management, not for the generic smart import UI. `{ syncModels: true }` resets only `provider = '元界 AI' AND type = 'image'` rows and installs 17 inactive image rows. `{ syncVideoModels: true }` resets only `provider = '元界 AI' AND type = 'video'` rows and installs inactive video rows with `videoUsageModes`. Rows have no API Key by default; admins must edit each model to set Key, pricing, visibility/member scope, polling, usage mode, and enable it before users can generate. |
| GET/POST/PUT/DELETE | `/api/admin/model-recommendations` | `src/app/api/admin/model-recommendations/route.ts` | Managed model recommendations. |
| GET/POST/PUT/DELETE | `/api/admin/model-recommendations` | `src/app/api/admin/model-recommendations/route.ts` | Managed model recommendations. All methods require admin bearer auth. |
| GET/DELETE | `/api/admin/generation-jobs` | `src/app/api/admin/generation-jobs/route.ts` | Admin task listing and deletion. |
| GET | `/api/admin/gallery/works` | `src/app/api/admin/gallery/works/route.ts` | Admin public gallery work listing for prompt moderation. |
| PUT | `/api/admin/gallery/prompt` | `src/app/api/admin/gallery/prompt/route.ts` | Admin prompt moderation endpoint. Requires email notification success before updating `works.prompt`. |

View File

@@ -192,7 +192,7 @@ Fullscreen image overlays should accept a thumbnail fallback and display it imme
`/api/health` caches storage health briefly and bounds object bucket probing, so health checks do not block page monitoring on a slow object-storage HEAD request. Optional runtime schema checks cache success or non-owner skips; production migrations should still apply schema changes explicitly, but request paths should not repeatedly run DDL.
For a production move from local disk to cloud server plus object storage, use this order: create a full DB/file backup, run `pnpm run migration:check` against the source runtime, prepare Rainyun ROS with `pnpm run rainyun:ros-prepare -- --create` if a bucket still needs to be created, copy reviewed `OBJECT_STORAGE_*` values into `.env.local` with `STORAGE_MODE=dual`, run `pnpm run storage:sync-object -- --dry-run`, run `pnpm run storage:sync-object`, run `pnpm run storage:sync-object -- --verify-only`, deploy/reload, run `pnpm run migration:check` again, and verify `/api/health`, gallery/history images, downloads, login, and API generation. Only switch to `STORAGE_MODE=object` after the object bucket and migration integrity checks have passed and a rollback plan exists.
For a production move from local disk to cloud server plus object storage, use this order: create a full DB/file backup, run `pnpm run migration:check` against the source runtime, prepare Rainyun ROS with `pnpm run rainyun:ros-prepare -- --create` if a bucket still needs to be created, copy reviewed `OBJECT_STORAGE_*` values into `.env.local` with `STORAGE_MODE=dual`, run `pnpm run storage:sync-object -- --dry-run`, run `pnpm run storage:sync-object`, run `pnpm run storage:sync-object -- --verify-only`, deploy/reload, run `pnpm run migration:check` again, and verify `/api/health`, gallery/history images, downloads, login, and API generation. The migration checker defaults to `http://127.0.0.1:8000` and uses bounded storage URL probe helpers; override `MIGRATION_CHECK_BASE_URL`, timeout, or concurrency only when intentionally checking a different runtime. Only switch to `STORAGE_MODE=object` after the object bucket and migration integrity checks have passed and a rollback plan exists.
When syncing source into production, exclude the repo-root runtime storage directory as `/local-storage/` only. A broad `local-storage/` rsync exclude also skips `src/app/api/local-storage/[...path]/route.ts`, leaving production on stale file-serving code while the local repo appears fixed.

View File

@@ -120,8 +120,9 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
| --- | --- | --- |
| Dashboard stats wrong | `src/modules/console/pages/console-dashboard-page.tsx`, `src/app/api/admin/dashboard/route.ts`, `src/app/api/admin/stats/route.ts` | SQL source tables, safe query fallbacks, admin auth. |
| User management bug | `src/components/admin/user-management-tab.tsx`, `src/app/api/admin/users/route.ts` | Role/tier mapping, active flag, admin auth. |
| API/model management list opens for anonymous users or admin tab loads empty provider/recommendation rows | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/providers/route.ts`, `src/app/api/admin/model-recommendations/route.ts`, `src/lib/admin-auth.ts` | Provider and recommendation GET routes require admin auth just like mutations. The admin tab's initial `fetch('/api/admin/providers')` and `fetch('/api/admin/model-recommendations')` must include bearer auth headers; anonymous requests should return 401. |
| Order/payment bug | `src/components/admin/order-management-tab.tsx`, `src/components/admin/payment-tab.tsx`, `src/app/api/admin/orders/route.ts`, `src/app/api/admin/payment-methods/route.ts`, `src/lib/server-payment-config.ts` | Payment config encryption, order status, request shape. |
| Data import/export bug | `src/components/admin/data-management-tab.tsx`, `src/app/api/admin/data-export/route.ts`, `src/app/api/admin/data-import/route.ts`, `scripts/migration-integrity-check.mjs` | JSON format, table coverage, admin auth, `_media` coverage, import transaction/savepoints, password hash/encrypted secret preservation, and work dedupe by URL/source URL/media SHA scoped to the same `user_id` so one user's private work cannot collapse into another user's row. Run `pnpm run migration:check` for the read-only migration gate. |
| Data import/export bug | `src/components/admin/data-management-tab.tsx`, `src/app/api/admin/data-export/route.ts`, `src/app/api/admin/data-import/route.ts`, `scripts/migration-integrity-check.mjs`, `scripts/migration-integrity-check-helpers.mjs` | JSON format, table coverage, admin auth, `_media` coverage, import transaction/savepoints, password hash/encrypted secret preservation, and work dedupe by URL/source URL/media SHA scoped to the same `user_id` so one user's private work cannot collapse into another user's row. Run `pnpm run migration:check` for the read-only migration gate. The checker should default to the web port 8000 and use bounded timeout/concurrency helpers for `/api/local-storage/*` probes so one slow or missing media URL is reported rather than crashing the whole script. |
| Admin email send/settings bug, test email blank, or user notification email blank | `src/components/admin/settings-tab.tsx`, `src/app/api/admin/email-settings/route.ts`, `src/app/api/admin/send-email/route.ts`, `src/lib/email-service.ts` | SMTP config, template rendering, send logs, MIME multipart assembly, and body encoding. Keep both text/plain and text/html parts non-empty, preserve required MIME blank separator lines, and fold base64 body lines to MIME-safe lengths before the SMTP DATA terminator. |
| Upgrade page stuck/fails | `src/components/admin/system-upgrade-tab.tsx`, `src/app/api/admin/upgrade/route.ts`, `scripts/admin-upgrade-runner.mjs`, `scripts/backup-create.sh`, `scripts/backup-restore.sh` | State dir, package limits, disk checks, backup validation, restore safety backup, PM2 restart command, stale status. |
| Platform logs missing or system log page says loading failed | `src/components/admin/log-management-tab.tsx`, `src/app/api/admin/logs/route.ts`, `src/lib/platform-logs.ts`, routes that call `writePlatformLog` | The page loads `/api/admin/logs`; verify the route exists, admin bearer auth is valid, `ensurePlatformLogSchema()` runs before querying, log retention cleanup is not failing, and API calls actually write logs. |

View File

@@ -88,9 +88,9 @@ Use this document to jump directly to code before broad searching.
| Built-in model options | `src/lib/model-config.ts`, `src/lib/model-config-types.ts` | Image/video model lists, ratios, sizes, inference helpers, and fallback style preset seed labels. Runtime style ordering comes from DB. |
| Public model config API | `src/app/api/model-config/route.ts`, `src/app/api/style-presets/route.ts` | Returns model/provider config plus DB-backed image style presets for clients. |
| User custom API keys | `src/lib/custom-api-store.ts`, `src/app/api/user-api-keys/route.ts`, `src/components/profile/api-key-manager.tsx` | User-owned encrypted API credentials. |
| Admin provider presets | `src/app/api/admin/providers/route.ts`, `src/components/admin/api-management-tab.tsx` | Provider registry, default API URL/model/type. |
| Admin provider presets | `src/app/api/admin/providers/route.ts`, `src/components/admin/api-management-tab.tsx` | Provider registry, default API URL/model/type. Reads and mutations require admin bearer auth; the admin tab must send `Authorization` for the initial list fetch too. |
| Admin system API configs | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts` | Encrypted shared system API credentials, pricing metadata, platform-default visibility, per-model membership-tier allowlists, and default-model polling fields (`polling_mode`, `polling_order`). The admin list browses system models by provider, then model type, then individual model rows for editing. Video models can be priced by per-use count (`fixed`), per-second duration (`duration` using `duration_price_per_second`), or token mode. Token billing input/output prices are configured as credits per 1M tokens in the console UI; the `input_price_per_1k`/`output_price_per_1k` DB/API field names are legacy-compatible storage names only. |
| Model recommendations | `src/app/api/admin/model-recommendations/route.ts` | Admin-controlled displayed/recommended model lists. |
| Model recommendations | `src/app/api/admin/model-recommendations/route.ts`, `src/components/admin/api-management-tab.tsx` | Admin-controlled displayed/recommended model lists. Reads and mutations require admin bearer auth. |
## Profile, Credits, Orders
@@ -127,7 +127,7 @@ Use this document to jump directly to code before broad searching.
| Orders | `src/components/admin/order-management-tab.tsx` | `src/app/api/admin/orders/route.ts` |
| Announcements | `src/components/admin/announcement-tab.tsx` | `src/app/api/announcements/route.ts` |
| Gallery management | `src/components/admin/gallery-management-tab.tsx` | `src/app/api/admin/gallery/works/route.ts`, `src/app/api/admin/gallery/prompt/route.ts`, `src/lib/admin-gallery-prompt-service.ts`, `src/lib/admin-gallery-works-pagination.ts`. Lists public works with admin page/pageSize pagination, edits prompt text, opens a required notification email dialog with built-in reason templates, and only completes the update after email send success. |
| Data import/export | `src/components/admin/data-management-tab.tsx` | `src/app/api/admin/data-export/route.ts`, `src/app/api/admin/data-import/route.ts`, `scripts/migration-integrity-check.mjs`. Export bundles storage URLs from works/site config into `_media`; import restores those files through `src/lib/local-storage.ts`, maps old IDs, merges duplicate works only within the same `user_id`, and runs DB writes in a transaction. Import preserves password hashes, encrypted API keys, `manifest_path`, system API pricing fields, and `redeem_codes` state so users, credentials, works, intelligent API configs, and unused/used redemption state survive migration. Run `pnpm run migration:check` before and after production migration. |
| Data import/export | `src/components/admin/data-management-tab.tsx` | `src/app/api/admin/data-export/route.ts`, `src/app/api/admin/data-import/route.ts`, `scripts/migration-integrity-check.mjs`, `scripts/migration-integrity-check-helpers.mjs`. Export bundles storage URLs from works/site config into `_media`; import restores those files through `src/lib/local-storage.ts`, maps old IDs, merges duplicate works only within the same `user_id`, and runs DB writes in a transaction. Import preserves password hashes, encrypted API keys, `manifest_path`, system API pricing fields, and `redeem_codes` state so users, credentials, works, intelligent API configs, and unused/used redemption state survive migration. Run `pnpm run migration:check` before and after production migration; the checker defaults to port 8000 and counts bounded media probe failures instead of aborting on the first slow URL. |
| System upgrade | `src/components/admin/system-upgrade-tab.tsx` | `src/app/api/admin/upgrade/route.ts`, `scripts/admin-upgrade-runner.mjs` |
| Logs/tasks | `src/components/admin/log-management-tab.tsx`, `src/components/admin/task-management-tab.tsx` | `src/lib/platform-logs.ts`, `src/app/api/admin/logs/route.ts`, `src/app/api/admin/generation-jobs/route.ts` |
| Settings | `src/components/admin/settings-tab.tsx` | `src/app/api/site-config/route.ts`, `src/app/api/admin/email-settings/route.ts`, `src/app/api/admin/send-email/route.ts` |

View File

@@ -16,6 +16,7 @@
"start": "bash ./scripts/start.sh",
"test:admin-gallery-prompt": "node --no-warnings ./scripts/test-admin-gallery-prompt-service.mjs",
"test:gallery-response": "node --no-warnings ./scripts/test-gallery-response.mjs",
"test:ops-hardening": "node --no-warnings ./scripts/test-ops-hardening.mjs",
"pm2:restart": "pm2 startOrReload ecosystem.config.cjs --update-env",
"pm2:save": "pm2 save",
"migration:check": "node ./scripts/migration-integrity-check.mjs",

View File

@@ -0,0 +1,36 @@
export function getMigrationCheckBaseUrl(env = process.env) {
const explicit = String(env.MIGRATION_CHECK_BASE_URL || env.APP_BASE_URL || '').trim();
if (explicit) return explicit.replace(/\/+$/, '');
const port = String(env.MIGRATION_CHECK_WEB_PORT || env.WEB_PORT || env.PORT || '8000').trim();
return `http://127.0.0.1:${port}`;
}
export function getMigrationStorageUrlTimeoutMs(env = process.env) {
const parsed = Number(env.MIGRATION_CHECK_STORAGE_URL_TIMEOUT_MS || 10_000);
return Number.isFinite(parsed) && parsed > 0 ? Math.min(Math.floor(parsed), 60_000) : 10_000;
}
export function getMigrationStorageUrlConcurrency(env = process.env) {
const parsed = Number(env.MIGRATION_CHECK_STORAGE_URL_CONCURRENCY || 8);
return Number.isFinite(parsed) && parsed > 0 ? Math.min(Math.floor(parsed), 20) : 8;
}
export async function checkStorageUrl(baseUrl, storageUrl, options = {}) {
const timeoutMs = Number(options.timeoutMs || 10_000);
const fetchImpl = options.fetchImpl || fetch;
try {
const response = await fetchImpl(`${baseUrl}${storageUrl}`, {
signal: AbortSignal.timeout(timeoutMs),
});
await response.body?.cancel?.();
if (!response.ok) {
return { ok: false, error: `HTTP ${response.status}` };
}
return { ok: true };
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : String(error),
};
}
}

View File

@@ -1,6 +1,12 @@
#!/usr/bin/env node
import fs from 'fs';
import { Pool } from 'pg';
import {
checkStorageUrl,
getMigrationCheckBaseUrl,
getMigrationStorageUrlConcurrency,
getMigrationStorageUrlTimeoutMs,
} from './migration-integrity-check-helpers.mjs';
loadEnvFile('.env.local');
@@ -10,8 +16,10 @@ if (!connectionString) {
process.exit(1);
}
const baseUrl = process.env.MIGRATION_CHECK_BASE_URL || 'http://127.0.0.1:5000';
const baseUrl = getMigrationCheckBaseUrl();
const maxStorageUrls = Number(process.env.MIGRATION_CHECK_STORAGE_URL_LIMIT || 200);
const storageUrlTimeoutMs = getMigrationStorageUrlTimeoutMs();
const storageUrlConcurrency = getMigrationStorageUrlConcurrency();
const pool = new Pool({ connectionString, max: 2 });
const checks = [];
@@ -120,12 +128,20 @@ async function checkLocalStorageUrls() {
`, [Number.isFinite(maxStorageUrls) && maxStorageUrls > 0 ? maxStorageUrls : 200]);
let missing = 0;
for (const row of res.rows) {
const response = await fetch(`${baseUrl}${row.url}`);
if (!response.ok) missing += 1;
await response.body?.cancel?.();
}
let checked = 0;
let cursor = 0;
const workers = Array.from({ length: Math.min(storageUrlConcurrency, Math.max(1, res.rows.length)) }, async () => {
while (cursor < res.rows.length) {
const row = res.rows[cursor++];
const result = await checkStorageUrl(baseUrl, row.url, { timeoutMs: storageUrlTimeoutMs });
checked += 1;
if (!result.ok) missing += 1;
}
});
await Promise.all(workers);
checks.push({ name: 'local_storage_urls_checked', severity: 'info', value: res.rows.length });
checks.push({ name: 'local_storage_urls_probe_completed', severity: 'info', value: checked });
checks.push({ name: 'local_storage_urls_missing', severity: 'blocker', value: missing });
}

View File

@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import {
checkStorageUrl,
getMigrationCheckBaseUrl,
} from './migration-integrity-check-helpers.mjs';
const repoRoot = path.resolve(import.meta.dirname, '..');
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
await runTest('local-storage route serves thumbnails from local disk and redirects object originals', () => {
const source = read('src/app/api/local-storage/[...path]/route.ts');
assert.match(source, /filePath\.startsWith\('thumbnails\/'\)/);
assert.match(source, /localStorage\.fileExists\(filePath\)/);
assert.match(source, /localStorage\.readFile\(filePath\)/);
assert.match(source, /localStorage\.generateObjectReadUrl\(filePath,\s*300\)/);
assert.match(source, /NextResponse\.redirect\(objectUrl/);
});
await runTest('admin provider and recommendation reads require admin auth', () => {
for (const relativePath of [
'src/app/api/admin/providers/route.ts',
'src/app/api/admin/model-recommendations/route.ts',
]) {
const source = read(relativePath);
assert.match(source, /export async function GET\(request: NextRequest\)/, relativePath);
assert.match(source, /const authError = await requireAdmin\(request\)/, relativePath);
assert.match(source, /if \(authError\) return authError;/, relativePath);
}
const tabSource = read('src/components/admin/api-management-tab.tsx');
assert.match(tabSource, /fetch\('\/api\/admin\/providers', \{ headers: authHeaders\(accessToken\) \}\)/);
assert.match(tabSource, /fetch\('\/api\/admin\/model-recommendations', \{ headers: authHeaders\(accessToken\) \}\)/);
});
await runTest('migration check defaults to production web port unless overridden', () => {
assert.equal(getMigrationCheckBaseUrl({}), 'http://127.0.0.1:8000');
assert.equal(
getMigrationCheckBaseUrl({ MIGRATION_CHECK_BASE_URL: 'http://127.0.0.1:5000' }),
'http://127.0.0.1:5000',
);
});
await runTest('migration storage URL check records fetch failures instead of throwing', async () => {
const result = await checkStorageUrl('http://127.0.0.1:8000', '/api/local-storage/missing.webp', {
timeoutMs: 10,
fetchImpl: async () => {
throw new Error('connect timeout');
},
});
assert.deepEqual(result, {
ok: false,
error: 'connect timeout',
});
});
await runTest('migration integrity script uses resilient storage URL helpers', () => {
const source = read('scripts/migration-integrity-check.mjs');
assert.match(source, /getMigrationCheckBaseUrl\(\)/);
assert.match(source, /getMigrationStorageUrlTimeoutMs\(\)/);
assert.match(source, /getMigrationStorageUrlConcurrency\(\)/);
assert.match(source, /checkStorageUrl\(baseUrl, row\.url/);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -18,7 +18,10 @@ async function readBody(request: NextRequest) {
return request.json().catch(() => ({}));
}
export async function GET() {
export async function GET(request: NextRequest) {
const authError = await requireAdmin(request);
if (authError) return authError;
const client = await getDbClient();
try {
const result = await client.query(

View File

@@ -19,7 +19,10 @@ async function readBody(request: NextRequest) {
return request.json().catch(() => ({}));
}
export async function GET() {
export async function GET(request: NextRequest) {
const authError = await requireAdmin(request);
if (authError) return authError;
const client = await getDbClient();
try {
const result = await client.query(

View File

@@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
import { localStorage } from '@/lib/local-storage';
import path from 'path';
const THUMBNAIL_CACHE_CONTROL = 'public, max-age=31536000, immutable';
const LOCAL_CACHE_CONTROL = 'private, max-age=300';
export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
try {
const { path: pathSegments } = await params;
@@ -9,26 +12,42 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
if (!filePath) {
return NextResponse.json({ error: 'Invalid file path' }, { status: 400 });
}
if (!await localStorage.fileExistsAsync(filePath)) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
if (filePath.startsWith('thumbnails/')) {
if (!localStorage.fileExists(filePath)) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
return serveLocalBuffer(filePath, localStorage.readFile(filePath), THUMBNAIL_CACHE_CONTROL);
}
const fileBuffer = await localStorage.readFileAsync(filePath);
const contentType = getContentType(filePath);
return new NextResponse(new Uint8Array(fileBuffer), {
headers: {
'Content-Type': contentType,
'Content-Disposition': `inline; filename="${path.basename(filePath)}"`,
},
});
if (localStorage.fileExists(filePath)) {
return serveLocalBuffer(filePath, localStorage.readFile(filePath), LOCAL_CACHE_CONTROL);
}
const objectUrl = localStorage.generateObjectReadUrl(filePath, 300);
if (objectUrl) {
const response = NextResponse.redirect(objectUrl, 302);
response.headers.set('Cache-Control', 'private, max-age=60');
return response;
}
return NextResponse.json({ error: 'File not found' }, { status: 404 });
} catch (error) {
console.error('[Local Storage API] Error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
function serveLocalBuffer(filePath: string, fileBuffer: Buffer, cacheControl: string): NextResponse {
return new NextResponse(new Uint8Array(fileBuffer), {
headers: {
'Content-Type': getContentType(filePath),
'Content-Disposition': `inline; filename="${path.basename(filePath)}"`,
'Cache-Control': cacheControl,
},
});
}
function normalizeStoragePath(value: string): string | null {
try {
const decoded = decodeURIComponent(value);

View File

@@ -295,19 +295,21 @@ export default function ApiManagementTab() {
setModelConfigLoading(true);
try {
const [providersRes, recommendationsRes] = await Promise.all([
fetch('/api/admin/providers'),
fetch('/api/admin/model-recommendations'),
fetch('/api/admin/providers', { headers: authHeaders(accessToken) }),
fetch('/api/admin/model-recommendations', { headers: authHeaders(accessToken) }),
]);
const providersData = await providersRes.json();
const recommendationsData = await recommendationsRes.json();
if (!providersRes.ok) throw new Error(providersData.error || '供应商配置加载失败');
if (!recommendationsRes.ok) throw new Error(recommendationsData.error || '推荐模型加载失败');
setProviders(providersData.providers || []);
setRecommendations(recommendationsData.recommendations || []);
} catch {
toast.error('模型配置加载失败');
} catch (error) {
toast.error(error instanceof Error ? error.message : '模型配置加载失败');
} finally {
setModelConfigLoading(false);
}
}, []);
}, [accessToken]);
useEffect(() => {
fetchModelConfig();