From 265fc77221d8bf2dfbf5d29b729b446d092f4c8b Mon Sep 17 00:00:00 2001 From: mars167 Date: Sat, 28 Mar 2026 20:53:04 +0800 Subject: [PATCH 1/9] fix: stabilize frontend checks and e2e --- e2e/dashboard-pages.spec.ts | 14 +++++---- package.json | 6 +++- playwright.config.ts | 16 +++++++++- scripts/seed-e2e-data.js | 41 +++++++++++++++++++++----- server/src/routes/repositoryRoutes.ts | 4 +++ web/app/dashboard/connections/page.tsx | 14 ++++----- web/app/dashboard/users/page.tsx | 14 ++++----- web/app/login/page.tsx | 8 ++--- 8 files changed, 83 insertions(+), 34 deletions(-) diff --git a/e2e/dashboard-pages.spec.ts b/e2e/dashboard-pages.spec.ts index accc7bb..2a945fb 100644 --- a/e2e/dashboard-pages.spec.ts +++ b/e2e/dashboard-pages.spec.ts @@ -1,6 +1,8 @@ import { test, expect } from '@playwright/test'; import { execSync } from 'child_process'; +const seededRepositoryName = 'codagraph-lite-e2e-fixture'; + test.beforeAll(() => { execSync('node scripts/seed-e2e-data.js', { stdio: 'inherit' }); }); @@ -19,9 +21,10 @@ test('repositories page renders seeded repository', async ({ page }) => { await page.goto('/dashboard/repositories'); await expect(page.getByRole('heading', { name: '仓库管理' })).toBeVisible(); - await expect(page.getByText('mars167')).toBeVisible(); - await expect(page.getByText('codagraph-lite')).toBeVisible(); - await expect(page.getByText('已连接', { exact: true })).toBeVisible(); + await page.getByRole('textbox', { name: 'Search' }).fill(`mars167/${seededRepositoryName}`); + await expect(page.getByText('搜索命中 1', { exact: true })).toBeVisible(); + await expect(page.getByRole('link', { name: seededRepositoryName, exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: '开启 Watch' })).toBeVisible(); expect(pageErrors).toEqual([]); }); @@ -31,7 +34,7 @@ test('jobs page renders seeded job stats and row', async ({ page }) => { await page.goto('/dashboard/jobs'); await expect(page.getByRole('heading', { name: '作业状态' })).toBeVisible(); - await expect(page.getByText('analyze_pr')).toBeVisible(); + await expect(page.getByRole('link', { name: `Job #9301 · ${seededRepositoryName} · PR #42` })).toBeVisible(); expect(pageErrors).toEqual([]); }); @@ -40,7 +43,8 @@ test('history page renders seeded analysis entry', async ({ page }) => { page.on('pageerror', (error) => pageErrors.push(error.message)); await page.goto('/dashboard/history'); - await expect(page.getByRole('heading', { name: '分析历史' })).toBeVisible(); + await expect(page.getByRole('heading', { name: '按 PR 维度回看 Review 历史' })).toBeVisible(); + await expect(page.getByRole('link', { name: `mars167/${seededRepositoryName}` })).toBeVisible(); await expect(page.getByText('E2E validation PR')).toBeVisible(); expect(pageErrors).toEqual([]); }); diff --git a/package.json b/package.json index 93d63fd..cc2d522 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,16 @@ "build:web": "npm --prefix web run build", "build:server": "npm --prefix server run build", "build": "npm run build:web && npm run build:server", + "check:web": "npm --prefix web run lint -- app src next.config.ts", + "check:server": "npm --prefix server run typecheck", + "check": "npm run check:web && npm run check:server && npm run build", "start:web": "npm --prefix web run start", "start:server": "npm --prefix server run start", "start": "concurrently \"npm run start:web\" \"npm run start:server\"", "seed:e2e": "node scripts/seed-e2e-data.js", "e2e:playwright": "npm run seed:e2e && playwright test e2e/dashboard-pages.spec.ts", - "e2e:puppeteer": "npm run seed:e2e && node scripts/puppeteer-dashboard-smoke.js" + "e2e:puppeteer": "npm run seed:e2e && node scripts/puppeteer-dashboard-smoke.js", + "test:e2e": "NEXT_PUBLIC_API_URL=http://127.0.0.1:7901 npm run build && npm run e2e:playwright" }, "repository": { "type": "git", diff --git a/playwright.config.ts b/playwright.config.ts index d3ec019..511c112 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -4,7 +4,21 @@ export default defineConfig({ testDir: './e2e', timeout: 30_000, use: { - baseURL: 'http://localhost:3000', + baseURL: 'http://127.0.0.1:3001', headless: true, }, + webServer: [ + { + command: 'SKIP_REMOTE_REPOSITORY_SYNC=1 BACKEND_PORT=7901 FRONTEND_PORT=3001 CORS_ORIGINS=http://127.0.0.1:3001 npm run start:server', + url: 'http://127.0.0.1:7901/api/health', + reuseExistingServer: !process.env.CI, + timeout: 30_000, + }, + { + command: 'cd web && mkdir -p .next/standalone/.next && ln -sfn ../../static .next/standalone/.next/static && PORT=3001 HOSTNAME=127.0.0.1 NEXT_PUBLIC_API_URL=http://127.0.0.1:7901 node .next/standalone/server.js', + url: 'http://127.0.0.1:3001/login', + reuseExistingServer: !process.env.CI, + timeout: 30_000, + }, + ], }); diff --git a/scripts/seed-e2e-data.js b/scripts/seed-e2e-data.js index fff0bce..9055808 100644 --- a/scripts/seed-e2e-data.js +++ b/scripts/seed-e2e-data.js @@ -1,10 +1,29 @@ const path = require('path'); -const Database = require('../server/node_modules/better-sqlite3'); +const Database = require(require.resolve('better-sqlite3', { + paths: [path.join(__dirname, '..', 'server')], +})); const dbPath = path.join(__dirname, '..', 'server', 'data', 'codagraph-lite.db'); const db = new Database(dbPath); +const seededTimestamp = "datetime('now', 'localtime', '+1 day')"; db.exec(` + CREATE TABLE IF NOT EXISTS oauth_installations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL CHECK(platform IN ('github', 'gitee', 'gitlab')), + auth_type TEXT DEFAULT 'oauth', + github_app_installation_id TEXT, + account_id TEXT NOT NULL, + account_name TEXT, + access_token TEXT NOT NULL, + refresh_token TEXT, + token_expires_at DATETIME, + permissions TEXT, + is_active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT (datetime('now', 'localtime')), + updated_at DATETIME DEFAULT (datetime('now', 'localtime')) + ); + CREATE TABLE IF NOT EXISTS repository ( id INTEGER PRIMARY KEY AUTOINCREMENT, platform TEXT NOT NULL, @@ -60,11 +79,17 @@ db.exec(` updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); + INSERT OR REPLACE INTO oauth_installations ( + id, platform, auth_type, account_id, account_name, access_token, permissions, is_active, created_at, updated_at + ) VALUES ( + 9001, 'github', 'oauth', 'mars167-e2e', 'mars167 E2E', 'e2e-token', 'repo', 1, ${seededTimestamp}, ${seededTimestamp} + ); + INSERT OR REPLACE INTO repository ( id, platform, owner, name, full_name, installation_id, webhook_url, is_active, created_at, updated_at, last_analyzed_at ) VALUES ( - 9101, 'github', 'mars167', 'codagraph-lite', 'mars167/codagraph-lite', - 9001, 'https://example.com/webhook', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + 9101, 'github', 'mars167', 'codagraph-lite-e2e-fixture', 'mars167/codagraph-lite-e2e-fixture', + 9001, 'https://example.com/webhook', 1, ${seededTimestamp}, ${seededTimestamp}, ${seededTimestamp} ); INSERT OR REPLACE INTO analysis ( @@ -72,17 +97,17 @@ db.exec(` status, analysis_result, comment_count, file_count, issue_count, started_at, completed_at, error_message, created_at, updated_at ) VALUES ( - 9201, 'github', 'mars167', 'codagraph-lite', 42, 'E2E validation PR', 'mars167', - 'main', 'feature/e2e', 'completed', '{}', 3, 5, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, - NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + 9201, 'github', 'mars167', 'codagraph-lite-e2e-fixture', 42, 'E2E validation PR', 'mars167', + 'main', 'feature/e2e', 'completed', '{}', 3, 5, 1, ${seededTimestamp}, ${seededTimestamp}, + NULL, ${seededTimestamp}, ${seededTimestamp} ); INSERT OR REPLACE INTO jobs ( id, type, payload, status, priority, attempts, max_attempts, error_message, created_at, updated_at, started_at, completed_at ) VALUES ( - 9301, 'pr_analysis', '{"platform":"github","repo_name":"codagraph-lite","pr_number":"42"}', - 'completed', 3, 1, 3, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + 9301, 'pr_analysis', '{"platform":"github","repo_name":"codagraph-lite-e2e-fixture","pr_number":"42"}', + 'completed', 3, 1, 3, NULL, ${seededTimestamp}, ${seededTimestamp}, ${seededTimestamp}, ${seededTimestamp} ); `); diff --git a/server/src/routes/repositoryRoutes.ts b/server/src/routes/repositoryRoutes.ts index b847223..332b023 100644 --- a/server/src/routes/repositoryRoutes.ts +++ b/server/src/routes/repositoryRoutes.ts @@ -166,6 +166,10 @@ function normalizeRepositoryPayload( } async function hydrateRepositoryCache(platform?: Platform) { + if (process.env.SKIP_REMOTE_REPOSITORY_SYNC === '1') { + return []; + } + const installationModel = getOAuthInstallationModel(); const repositoryModel = getRepositoryModel(); const installations = platform diff --git a/web/app/dashboard/connections/page.tsx b/web/app/dashboard/connections/page.tsx index 883e494..5026c69 100644 --- a/web/app/dashboard/connections/page.tsx +++ b/web/app/dashboard/connections/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { apiClient } from '@/lib/api-client'; import { formatDateTime } from '@/lib/datetime'; import type { OAuthInstallation, Platform } from '@/types'; @@ -47,11 +47,7 @@ export default function ConnectionsPage() { const [actionLoading, setActionLoading] = useState>({}); // 加载连接列表 - useEffect(() => { - loadInstallations(); - }, []); - - const loadInstallations = async () => { + const loadInstallations = useCallback(async () => { try { setIsLoading(true); const response = await apiClient.getOAuthInstallations(); @@ -64,7 +60,11 @@ export default function ConnectionsPage() { } finally { setIsLoading(false); } - }; + }, [error]); + + useEffect(() => { + void loadInstallations(); + }, [loadInstallations]); // 刷新 Token const handleRefreshToken = async (id: string) => { diff --git a/web/app/dashboard/users/page.tsx b/web/app/dashboard/users/page.tsx index f3e30fe..e7bf3ac 100644 --- a/web/app/dashboard/users/page.tsx +++ b/web/app/dashboard/users/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { apiClient } from '@/lib/api-client'; import { formatDateTime } from '@/lib/datetime'; import type { Admin } from '@/types'; @@ -52,11 +52,7 @@ export default function UsersPage() { }); // 加载用户数据 - useEffect(() => { - loadUsersData(); - }, []); - - const loadUsersData = async () => { + const loadUsersData = useCallback(async () => { try { setIsLoading(true); // 获取当前管理员信息 @@ -104,7 +100,11 @@ export default function UsersPage() { } finally { setIsLoading(false); } - }; + }, [error]); + + useEffect(() => { + void loadUsersData(); + }, [loadUsersData]); // 添加用户 const handleAddUser = async () => { diff --git a/web/app/login/page.tsx b/web/app/login/page.tsx index 2a78992..64156bf 100644 --- a/web/app/login/page.tsx +++ b/web/app/login/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import Link from 'next/link'; import React, { useState } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; @@ -108,12 +109,9 @@ export default function LoginPage() {
- + 返回首页 - +
From a818b0077d3e9771c1cbb10f0627e4582c98f23e Mon Sep 17 00:00:00 2001 From: mars167 Date: Sat, 28 Mar 2026 20:55:42 +0800 Subject: [PATCH 2/9] fix: align deployment health checks --- deploy/start.sh | 17 +++++++++++------ deploy/systemd/codagraph-lite-frontend.service | 6 ++++-- deploy/verify.sh | 2 +- server/src/index.ts | 8 ++++++-- web/app/api/health/route.ts | 9 +++++++++ 5 files changed, 31 insertions(+), 11 deletions(-) create mode 100644 web/app/api/health/route.ts diff --git a/deploy/start.sh b/deploy/start.sh index 4d79e04..1c00b00 100644 --- a/deploy/start.sh +++ b/deploy/start.sh @@ -124,14 +124,19 @@ main() { ((attempt++)) sleep 2 - # 检查前端 - if curl -sf http://localhost:3000/api/health >/dev/null 2>&1; then - log_success "前端健康检查通过" - break + local frontend_ok=false + local backend_ok=false + + if curl -sf "http://localhost:${FRONTEND_PORT}/api/health" >/dev/null 2>&1; then + frontend_ok=true fi - # 检查后端 - if curl -sf http://localhost:7900/api/health >/dev/null 2>&1; then + if curl -sf "http://localhost:${BACKEND_PORT}/health" >/dev/null 2>&1; then + backend_ok=true + fi + + if [ "$frontend_ok" = true ] && [ "$backend_ok" = true ]; then + log_success "前端健康检查通过" log_success "后端健康检查通过" break fi diff --git a/deploy/systemd/codagraph-lite-frontend.service b/deploy/systemd/codagraph-lite-frontend.service index 8e62e85..1f5dd38 100644 --- a/deploy/systemd/codagraph-lite-frontend.service +++ b/deploy/systemd/codagraph-lite-frontend.service @@ -8,16 +8,18 @@ After=network.target Wants=network.target [Service] -Type=notify +Type=simple NotifyAccess=all WorkingDirectory=/opt/codagraph-lite/web # 环境变量 Environment="NODE_ENV=production" Environment="NODE_OPTIONS=--max-old-space-size=200" +Environment="PORT=3000" +Environment="HOSTNAME=0.0.0.0" # 启动命令 -ExecStart=/usr/bin/node /opt/codagraph-lite/web/node_modules/.bin/next start +ExecStart=/usr/bin/node /opt/codagraph-lite/web/node_modules/next/dist/bin/next start -p 3000 -H 0.0.0.0 ExecReload=/bin/kill -s HUP $MAINPID # 2u2g 资源限制 diff --git a/deploy/verify.sh b/deploy/verify.sh index bb12808..e5c4ee7 100755 --- a/deploy/verify.sh +++ b/deploy/verify.sh @@ -368,7 +368,7 @@ check_api_health() { check log_info "检查 API 健康状态..." - local backend_url="http://localhost:${BACKEND_PORT:-7900}/api/health" + local backend_url="http://localhost:${BACKEND_PORT:-7900}/health" local frontend_url="http://localhost:${FRONTEND_PORT:-3000}" # 检查后端 API diff --git a/server/src/index.ts b/server/src/index.ts index 280cedc..26b5e4c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -12,6 +12,7 @@ import dotenv from 'dotenv'; import fs from 'fs'; import path from 'path'; +import type { Request, Response } from 'express'; import { logger } from './utils/logger'; import { getQueueService } from './jobs/QueueService'; import { CodeReviewWorker } from './jobs/CodeReviewWorker'; @@ -177,7 +178,7 @@ async function main() { // ============================================ // 6. 健康检查 // ============================================ - app.get('/health', (_req, res) => { + const respondHealth = (_req: Request, res: Response) => { const memoryStatus = memoryMonitor.canStartJob(); res.json({ @@ -191,7 +192,10 @@ async function main() { rss: Math.round(process.memoryUsage().rss / 1024 / 1024), }, }); - }); + }; + + app.get('/health', respondHealth); + app.get('/api/health', respondHealth); // ============================================ // 7. 优雅关闭 diff --git a/web/app/api/health/route.ts b/web/app/api/health/route.ts new file mode 100644 index 0000000..c21450c --- /dev/null +++ b/web/app/api/health/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from 'next/server'; + +export function GET() { + return NextResponse.json({ + status: 'ok', + service: 'CodaGraph-lite Frontend', + timestamp: new Date().toISOString(), + }); +} From 9947bb6cb530b623d19258ecf74cf8731b174c94 Mon Sep 17 00:00:00 2001 From: mars167 Date: Sat, 28 Mar 2026 22:08:17 +0800 Subject: [PATCH 3/9] fix: stabilize auth errors and settings password inputs --- AGENTS.md | 43 +++++++++++++++ server/src/auth/routes.test.ts | 93 ++++++++++++++++++++++++++++++++ server/src/auth/routes.ts | 11 ++-- server/src/models/ActivityLog.ts | 5 +- 4 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 AGENTS.md create mode 100644 server/src/auth/routes.test.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0d3e538 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ +# CodaGraph-lite Project Memory + +## Required Workflow + +- Reproduce the exact bug on the current branch before changing code. +- For interaction bugs, inspect browser behavior first: focus, active element, DOM replacement, network request, and backend log. +- Do not claim a fix based on a different branch, worktree, or prior patch. Verify the current branch contains the change. +- Do not report "fixed" until the same user path has been re-tested in the browser or with a direct request. + +## Known Failure Modes + +### Settings Password Inputs + +- Symptom: on `/dashboard/settings`, the admin password fields only accept one character, then lose focus or appear to clear. +- Real root cause: helper components defined inside `SettingsPage()` can cause subtree remounts on every `setPasswordForm`. +- First things to check: + - whether local helper components are declared inside the page component + - whether the active input node/id changes after one keystroke + - whether password form updates use functional state updates +- Current fix location: `web/app/dashboard/settings/page.tsx` + +### Failed Login Returning 500 + +- Symptom: wrong admin password returns `500 {"details":"FOREIGN KEY constraint failed"}` instead of `401`. +- Real root cause: failed login activity log attempted to insert an invalid `admin_id`. +- First things to check: + - `POST /api/auth/login` response code + - backend log stack under `server/src/auth/routes.ts` + - `server/src/models/ActivityLog.ts` +- Current fix locations: + - `server/src/auth/routes.ts` + - `server/src/models/ActivityLog.ts` + +### Local Frontend / Backend Validation + +- Prefer `http://localhost:3000`, not `http://127.0.0.1:3000`, when validating the real app flow. +- Reason: the backend CORS configuration commonly allows `http://localhost:3000`; using `127.0.0.1` can create a false negative during login and settings verification. + +## Response Discipline + +- Separate symptom from root cause. +- Fix one proven root cause at a time. +- If a patch only addresses an adjacent issue, do not present it as the root fix. diff --git a/server/src/auth/routes.test.ts b/server/src/auth/routes.test.ts new file mode 100644 index 0000000..384248a --- /dev/null +++ b/server/src/auth/routes.test.ts @@ -0,0 +1,93 @@ +import express from 'express'; +import request from 'supertest'; + +const adminModelMock = { + findByUsername: jest.fn(), + verifyPassword: jest.fn(), + updateLastLogin: jest.fn(), +}; + +const activityLogModelMock = { + logFailedLogin: jest.fn(), + logLogin: jest.fn(), + logLogout: jest.fn(), + logPasswordChange: jest.fn(), +}; + +const sessionManagerMock = { + create: jest.fn(), + get: jest.fn(), + destroy: jest.fn(), +}; + +jest.mock('../models/Admin', () => ({ + getAdminModel: () => adminModelMock, +})); + +jest.mock('../models/ActivityLog', () => ({ + getActivityLogModel: () => activityLogModelMock, +})); + +jest.mock('./SessionManager', () => ({ + getSessionManager: () => sessionManagerMock, +})); + +import authRoutes from './routes'; + +describe('authRoutes', () => { + let app: express.Application; + + beforeEach(() => { + jest.clearAllMocks(); + app = express(); + app.use(express.json()); + app.use('/', authRoutes); + }); + + it('returns 401 for wrong passwords and logs failed login against the existing admin', async () => { + adminModelMock.findByUsername.mockReturnValue({ + id: 7, + username: 'admin', + password_hash: 'hashed', + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-28T00:00:00.000Z', + }); + adminModelMock.verifyPassword.mockReturnValue(false); + + const response = await request(app) + .post('/login') + .send({ + username: 'admin', + password: 'wrong-password', + }); + + expect(response.status).toBe(401); + expect(response.body).toEqual({ + error: '用户名或密码错误', + }); + expect(activityLogModelMock.logFailedLogin).toHaveBeenCalledWith( + 7, + 'admin', + expect.any(String), + undefined + ); + }); + + it('returns 401 for unknown users without trying to write an invalid activity log row', async () => { + adminModelMock.findByUsername.mockReturnValue(null); + adminModelMock.verifyPassword.mockReturnValue(false); + + const response = await request(app) + .post('/login') + .send({ + username: 'ghost', + password: 'wrong-password', + }); + + expect(response.status).toBe(401); + expect(response.body).toEqual({ + error: '用户名或密码错误', + }); + expect(activityLogModelMock.logFailedLogin).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/auth/routes.ts b/server/src/auth/routes.ts index 637dd91..680964d 100644 --- a/server/src/auth/routes.ts +++ b/server/src/auth/routes.ts @@ -49,19 +49,18 @@ router.post('/login', async (req: Request, res: Response) => { const adminModel = getAdminModel(); const activityLogModel = getActivityLogModel(); - // 验证密码 - const isValid = adminModel.verifyPassword(username, password); + const admin = adminModel.findByUsername(username); + const isValid = admin ? adminModel.verifyPassword(username, password) : false; if (!isValid) { - // 记录失败登录尝试 - activityLogModel.logFailedLogin(username, ipAddress, userAgent); + if (admin) { + activityLogModel.logFailedLogin(admin.id, username, ipAddress, userAgent); + } return res.status(401).json({ error: '用户名或密码错误', }); } - // 获取管理员信息 - const admin = adminModel.findByUsername(username); if (!admin) { return res.status(401).json({ error: '用户不存在', diff --git a/server/src/models/ActivityLog.ts b/server/src/models/ActivityLog.ts index 1c76a3d..8b6220c 100644 --- a/server/src/models/ActivityLog.ts +++ b/server/src/models/ActivityLog.ts @@ -112,10 +112,9 @@ export class ActivityLogModel { /** * 记录失败登录尝试 */ - logFailedLogin(username: string, ipAddress?: string, userAgent?: string): ActivityLog { - // 对于失败登录,admin_id 为 null,使用 0 占位 + logFailedLogin(adminId: number, username: string, ipAddress?: string, userAgent?: string): ActivityLog { return this.create({ - admin_id: 0, + admin_id: adminId, action: 'failed_login', ip_address: ipAddress, user_agent: userAgent, From 8eb4e12016b52c25f26ee68410f7174d952a842f Mon Sep 17 00:00:00 2001 From: mars167 Date: Sat, 28 Mar 2026 23:08:24 +0800 Subject: [PATCH 4/9] fix: stabilize dashboard job and repository cards --- web/app/dashboard/page.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/web/app/dashboard/page.tsx b/web/app/dashboard/page.tsx index 49bc88c..174a507 100644 --- a/web/app/dashboard/page.tsx +++ b/web/app/dashboard/page.tsx @@ -1143,8 +1143,8 @@ export default function DashboardPage() { -
- +
+ 最近执行的作业 @@ -1184,21 +1184,21 @@ export default function DashboardPage() { - + 工作空间仓库 {workspaceRepositories.length > 0 ? ( -
+
{workspaceRepositories.map((repo) => (
-
+

{repo.fullName}

From 6cf67c22a25774ed7eed6df1ff881a095c03f72a Mon Sep 17 00:00:00 2001 From: mars167 Date: Sat, 28 Mar 2026 23:12:43 +0800 Subject: [PATCH 5/9] fix: center password visibility toggle --- web/src/components/ui/Input.tsx | 113 ++++++++++++++++++-------------- 1 file changed, 63 insertions(+), 50 deletions(-) diff --git a/web/src/components/ui/Input.tsx b/web/src/components/ui/Input.tsx index cbb379a..285eb5a 100644 --- a/web/src/components/ui/Input.tsx +++ b/web/src/components/ui/Input.tsx @@ -5,6 +5,7 @@ export interface InputProps extends React.InputHTMLAttributes error?: string; helperText?: string; containerClassName?: string; + endAdornment?: React.ReactNode; } export function Input({ @@ -13,6 +14,7 @@ export function Input({ helperText, containerClassName = '', className = '', + endAdornment, id, type = 'text', ...props @@ -30,21 +32,28 @@ export function Input({ {label} )} - +
+ + {endAdornment && ( +
+ {endAdornment} +
+ )} +
{error && (

{error}

)} @@ -59,40 +68,44 @@ export function PasswordInput(props: Omit) { const [showPassword, setShowPassword] = React.useState(false); return ( -
- - -
+ setShowPassword((current) => !current)} + className="inline-flex items-center text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" + tabIndex={-1} + > + {showPassword ? ( + + + + ) : ( + + + + + )} + + )} + /> ); } From 2f5225421a088f06d93a869eec4945b5bdb806cd Mon Sep 17 00:00:00 2001 From: mars167 Date: Sun, 29 Mar 2026 00:38:39 +0800 Subject: [PATCH 6/9] feat: add multi-llm settings and trusted origins --- .env.example | 14 +- docs/configuration.md | 6 +- server/src/config/index.test.ts | 20 + server/src/config/index.ts | 28 +- server/src/index.ts | 4 +- server/src/routes/settingsRoutes.ts | 2 +- .../services/SystemSettingsService.test.ts | 163 ++++ server/src/services/SystemSettingsService.ts | 124 ++- web/app/dashboard/settings/page.tsx | 754 +++++++++++++----- web/src/types/index.ts | 12 + 10 files changed, 900 insertions(+), 227 deletions(-) create mode 100644 server/src/config/index.test.ts create mode 100644 server/src/services/SystemSettingsService.test.ts diff --git a/.env.example b/.env.example index bfcd76f..fcc67ef 100644 --- a/.env.example +++ b/.env.example @@ -159,8 +159,8 @@ WEBHOOK_SECRET=changeme_to_random_webhook_secret # CORS 配置 # ============================================ -# 允许的源 (逗号分隔) -CORS_ORIGINS=http://localhost:3000 +# 允许的源 (逗号分隔,支持多个可信域名或 * ) +CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 # 允许的方法 CORS_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS @@ -169,19 +169,19 @@ CORS_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS # LLM 服务配置 # ============================================ -# LLM 提供商 (openai, anthropic, deepseek, etc.) +# LLM 提供商(用于初始化默认 LLM 配置;更多配置可在设置页新增和切换) LLM_PROVIDER=openai -# LLM API 密钥 +# LLM API 密钥(用于初始化默认 LLM 配置) LLM_API_KEY=your_llm_api_key -# LLM 模型名称 +# LLM 模型名称(用于初始化默认 LLM 配置) LLM_MODEL=gpt-4 -# LLM API 端点 (如果使用自定义端点) +# LLM API 端点(如果使用自定义端点,用于初始化默认 LLM 配置) LLM_API_BASE_URL= -# LLM 最大重试次数 +# LLM 最大重试次数(用于初始化默认 LLM 配置) LLM_MAX_RETRIES=3 # ============================================ diff --git a/docs/configuration.md b/docs/configuration.md index 9af4fef..cf32ff3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -392,6 +392,8 @@ WORKSPACE_ROOT=/opt/codagraph-lite/workspace ### LLM 提供商 +这些环境变量会初始化后台里的默认 LLM 配置。需要多套 LLM API 时,可以在设置页继续新增、保存并切换。 + | 环境变量 | 默认值 | 可选值 | |-----------|---------|--------| | `LLM_PROVIDER` | `openai` | `openai`, `anthropic`, `deepseek`, `自定义` | @@ -611,9 +613,9 @@ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" **配置示例**: ```bash # 开发环境 -CORS_ORIGINS=http://localhost:3000 +CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 -# 生产环境(多个域名) +# 生产环境(多个可信域名) CORS_ORIGINS=https://app1.com,https://app2.com # 允许所有(不推荐生产环境) diff --git a/server/src/config/index.test.ts b/server/src/config/index.test.ts new file mode 100644 index 0000000..2900366 --- /dev/null +++ b/server/src/config/index.test.ts @@ -0,0 +1,20 @@ +import { isCorsOriginAllowed } from './index'; + +describe('CORS origin matching', () => { + it('allows multiple trusted origins from env-style lists', () => { + expect(isCorsOriginAllowed([ + 'http://localhost:3000', + 'https://app.example.com', + ], 'https://app.example.com')).toBe(true); + }); + + it('normalizes trailing slashes before matching origins', () => { + expect(isCorsOriginAllowed([ + 'https://app.example.com/', + ], 'https://app.example.com')).toBe(true); + }); + + it('supports wildcard origins', () => { + expect(isCorsOriginAllowed(['*'], 'https://preview.example.com')).toBe(true); + }); +}); diff --git a/server/src/config/index.ts b/server/src/config/index.ts index 5d90aac..3612582 100644 --- a/server/src/config/index.ts +++ b/server/src/config/index.ts @@ -235,6 +235,32 @@ function normalizeCorsMethods(methods: string[]): string[] { return normalized; } +function normalizeCorsOrigin(origin: string): string { + const trimmed = origin.trim(); + if (!trimmed || trimmed === '*') { + return trimmed; + } + + try { + return new URL(trimmed).origin; + } catch { + return trimmed.replace(/\/+$/, ''); + } +} + +function normalizeCorsOrigins(origins: string[]): string[] { + return [...new Set(origins.map(normalizeCorsOrigin).filter(Boolean))]; +} + +export function isCorsOriginAllowed(allowedOrigins: string[], origin: string): boolean { + const normalizedAllowedOrigins = normalizeCorsOrigins(allowedOrigins); + if (normalizedAllowedOrigins.includes('*')) { + return true; + } + + return normalizedAllowedOrigins.includes(normalizeCorsOrigin(origin)); +} + function getReviewMode(key: string, defaultValue: ReviewConfig['defaultMode']): ReviewConfig['defaultMode'] { const value = getEnv(key, defaultValue); if (value === 'normal' || value === 'improve') { @@ -372,7 +398,7 @@ export function loadConfig(): AppConfig { }, cors: { - corsOrigins: getEnvArray('CORS_ORIGINS', ['http://localhost:3000']), + corsOrigins: normalizeCorsOrigins(getEnvArray('CORS_ORIGINS', ['http://localhost:3000'])), corsMethods: normalizeCorsMethods( getEnvArray('CORS_METHODS', ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) ), diff --git a/server/src/index.ts b/server/src/index.ts index 26b5e4c..bb739e7 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -27,7 +27,7 @@ import analysisRoutes from './routes/analysisRoutes'; import settingsRoutes from './routes/settingsRoutes'; import cookieParser from 'cookie-parser'; import cors, { type CorsOptions } from 'cors'; -import { getConfig, printConfigSummary, ConfigurationError } from './config'; +import { getConfig, printConfigSummary, ConfigurationError, isCorsOriginAllowed } from './config'; import { getMemoryMonitor } from './config/memory'; import { getResourceAllocator } from './config/resource'; import { RepositoryWatchService } from './services/RepositoryWatchService'; @@ -109,7 +109,7 @@ async function main() { callback(null, true); return; } - if (config.cors.corsOrigins.includes(origin)) { + if (isCorsOriginAllowed(config.cors.corsOrigins, origin)) { callback(null, true); return; } diff --git a/server/src/routes/settingsRoutes.ts b/server/src/routes/settingsRoutes.ts index a3208f6..ba8b16c 100644 --- a/server/src/routes/settingsRoutes.ts +++ b/server/src/routes/settingsRoutes.ts @@ -9,7 +9,7 @@ const settingsService = getSystemSettingsService(); type LlmTestPayload = Partial>; router.use(authenticate); diff --git a/server/src/services/SystemSettingsService.test.ts b/server/src/services/SystemSettingsService.test.ts new file mode 100644 index 0000000..6fa71f9 --- /dev/null +++ b/server/src/services/SystemSettingsService.test.ts @@ -0,0 +1,163 @@ +const getAllMock = jest.fn(); +const setManyMock = jest.fn(); + +jest.mock('../config', () => ({ + getConfig: () => ({ + server: { + backendPort: 7900, + frontendPort: 3000, + nodeOptions: '--max-old-space-size=512', + }, + logging: { + logLevel: 'info', + }, + jobQueue: { + workerCount: 1, + }, + auth: { + sessionTimeout: 86400, + }, + backup: { + enableAutoBackup: true, + }, + llm: { + llmProvider: 'openai-compatible', + llmApiKey: '', + llmApiBaseUrl: '', + llmModel: '', + llmMaxRetries: 2, + }, + }), +})); + +jest.mock('../models/AppSetting', () => ({ + getAppSettingModel: () => ({ + getAll: getAllMock, + setMany: setManyMock, + }), +})); + +import { SystemSettingsService } from './SystemSettingsService'; + +describe('SystemSettingsService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('normalizes legacy single llm settings into one active profile', () => { + getAllMock.mockReturnValue({ + llmProvider: 'openrouter', + llmApiKey: 'legacy-key', + llmApiBaseUrl: 'https://openrouter.ai/api/v1/', + llmModel: 'openrouter/auto', + llmMaxRetries: 4, + }); + + const service = new SystemSettingsService(); + const settings = service.getSettings(); + + expect(settings.llmProfiles).toHaveLength(1); + expect(settings.activeLlmProfileId).toBe('default-llm-profile'); + expect(settings.llmProfiles[0]).toMatchObject({ + id: 'default-llm-profile', + provider: 'openrouter', + apiKey: 'legacy-key', + apiBaseUrl: 'https://openrouter.ai/api/v1/', + model: 'openrouter/auto', + maxRetries: 4, + }); + expect(service.getLlmConfig()).toEqual({ + provider: 'openrouter', + apiKey: 'legacy-key', + baseUrl: 'https://openrouter.ai/api/v1/', + model: 'openrouter/auto', + maxRetries: 4, + }); + }); + + it('mirrors the selected llm profile back to the legacy flat fields', () => { + getAllMock.mockReturnValue({ + llmProfiles: [ + { + id: 'primary', + name: '主配置', + provider: 'openai', + apiKey: 'primary-key', + apiBaseUrl: '', + model: 'gpt-4.1-mini', + maxRetries: 2, + }, + { + id: 'backup', + name: '备用配置', + provider: 'openrouter', + apiKey: 'backup-key', + apiBaseUrl: 'https://openrouter.ai/api/v1', + model: 'openrouter/auto', + maxRetries: 5, + }, + ], + activeLlmProfileId: 'backup', + }); + + const service = new SystemSettingsService(); + const settings = service.getSettings(); + + expect(settings.llmProvider).toBe('openrouter'); + expect(settings.llmApiKey).toBe('backup-key'); + expect(settings.llmApiBaseUrl).toBe('https://openrouter.ai/api/v1'); + expect(settings.llmModel).toBe('openrouter/auto'); + expect(settings.llmMaxRetries).toBe(5); + expect(service.getLlmConfig()).toEqual({ + provider: 'openrouter', + apiKey: 'backup-key', + baseUrl: 'https://openrouter.ai/api/v1', + model: 'openrouter/auto', + maxRetries: 5, + }); + }); + + it('persists llm profiles together with the active selection', () => { + getAllMock.mockReturnValue({}); + + const service = new SystemSettingsService(); + const result = service.saveSettings({ + llmProfiles: [ + { + id: 'primary', + name: '主配置', + provider: 'openai', + apiKey: 'primary-key', + apiBaseUrl: '', + model: 'gpt-4.1-mini', + maxRetries: 2, + }, + { + id: 'backup', + name: '备用配置', + provider: 'openrouter', + apiKey: 'backup-key', + apiBaseUrl: 'https://openrouter.ai/api/v1', + model: 'openrouter/auto', + maxRetries: 5, + }, + ], + activeLlmProfileId: 'backup', + }); + + expect(setManyMock).toHaveBeenCalledWith(expect.objectContaining({ + activeLlmProfileId: 'backup', + llmProvider: 'openrouter', + llmApiKey: 'backup-key', + llmApiBaseUrl: 'https://openrouter.ai/api/v1', + llmModel: 'openrouter/auto', + llmMaxRetries: 5, + llmProfiles: expect.arrayContaining([ + expect.objectContaining({ id: 'primary', name: '主配置' }), + expect.objectContaining({ id: 'backup', name: '备用配置' }), + ]), + })); + expect(result.activeLlmProfileId).toBe('backup'); + expect(result.llmProfiles).toHaveLength(2); + }); +}); diff --git a/server/src/services/SystemSettingsService.ts b/server/src/services/SystemSettingsService.ts index 74a2ab0..3c88388 100644 --- a/server/src/services/SystemSettingsService.ts +++ b/server/src/services/SystemSettingsService.ts @@ -3,6 +3,16 @@ import { getAppSettingModel } from '../models/AppSetting'; export type PlatformAuthMode = 'oauth_app' | 'pat'; +export interface LlmProfileRecord { + id: string; + name: string; + provider: string; + apiKey: string; + apiBaseUrl: string; + model: string; + maxRetries: number; +} + export interface SystemSettingsRecord { apiPort: number; apiHost: string; @@ -35,6 +45,8 @@ export interface SystemSettingsRecord { llmApiBaseUrl: string; llmModel: string; llmMaxRetries: number; + llmProfiles: LlmProfileRecord[]; + activeLlmProfileId: string; } const settingKeys = [ @@ -69,6 +81,8 @@ const settingKeys = [ 'llmApiBaseUrl', 'llmModel', 'llmMaxRetries', + 'llmProfiles', + 'activeLlmProfileId', ] as const satisfies readonly (keyof SystemSettingsRecord)[]; function clampNumber(value: unknown, fallback: number, min?: number, max?: number): number { @@ -134,11 +148,76 @@ function parseNodeMemoryLimit(nodeOptions: string): number { return match ? parseInt(match[1], 10) : 1024; } +function normalizeLlmProfileId(value: unknown, fallback: string): string { + const next = toStringValue(value, fallback).replace(/\s+/g, '-'); + return next || fallback; +} + +function normalizeLlmProfile( + value: Partial | undefined, + fallback: LlmProfileRecord, + index: number +): LlmProfileRecord { + const fallbackId = fallback.id || `llm-profile-${index + 1}`; + return { + id: normalizeLlmProfileId(value?.id, fallbackId), + name: toStringValue(value?.name, fallback.name) || `LLM 配置 ${index + 1}`, + provider: toStringValue(value?.provider, fallback.provider) || fallback.provider, + apiKey: toStringValue(value?.apiKey, fallback.apiKey), + apiBaseUrl: toStringValue(value?.apiBaseUrl, fallback.apiBaseUrl), + model: toStringValue(value?.model, fallback.model), + maxRetries: clampNumber(value?.maxRetries, fallback.maxRetries, 0, 10), + }; +} + +function normalizeLlmProfiles( + value: unknown, + fallback: LlmProfileRecord[] +): LlmProfileRecord[] { + if (!Array.isArray(value) || value.length === 0) { + return fallback.map((profile, index) => normalizeLlmProfile(profile, profile, index)); + } + + const normalized = value + .map((profile, index) => + normalizeLlmProfile( + typeof profile === 'object' && profile !== null ? profile as Partial : undefined, + fallback[index] || fallback[0], + index + ) + ) + .filter((profile, index, list) => list.findIndex((item) => item.id === profile.id) === index); + + return normalized.length > 0 + ? normalized + : fallback.map((profile, index) => normalizeLlmProfile(profile, profile, index)); +} + +function resolveActiveLlmProfileId( + value: unknown, + profiles: LlmProfileRecord[], + fallback: string +): string { + const nextId = toStringValue(value, fallback); + return profiles.some((profile) => profile.id === nextId) + ? nextId + : profiles[0]?.id || fallback; +} + export class SystemSettingsService { private model = getAppSettingModel(); private buildDefaults(): SystemSettingsRecord { const config = getConfig(); + const defaultLlmProfile: LlmProfileRecord = { + id: 'default-llm-profile', + name: '默认配置', + provider: config.llm.llmProvider || 'openai-compatible', + apiKey: config.llm.llmApiKey || '', + apiBaseUrl: config.llm.llmApiBaseUrl || '', + model: config.llm.llmModel || '', + maxRetries: config.llm.llmMaxRetries, + }; return { apiPort: config.server.backendPort, @@ -167,16 +246,34 @@ export class SystemSettingsService { autoBackupEnabled: config.backup.enableAutoBackup, backupSchedule: 'daily', backupRetentionDays: 7, - llmProvider: config.llm.llmProvider || 'openai-compatible', - llmApiKey: config.llm.llmApiKey || '', - llmApiBaseUrl: config.llm.llmApiBaseUrl || '', - llmModel: config.llm.llmModel || '', - llmMaxRetries: config.llm.llmMaxRetries, + llmProvider: defaultLlmProfile.provider, + llmApiKey: defaultLlmProfile.apiKey, + llmApiBaseUrl: defaultLlmProfile.apiBaseUrl, + llmModel: defaultLlmProfile.model, + llmMaxRetries: defaultLlmProfile.maxRetries, + llmProfiles: [defaultLlmProfile], + activeLlmProfileId: defaultLlmProfile.id, }; } private normalize(input: Partial): SystemSettingsRecord { const defaults = this.buildDefaults(); + const fallbackLegacyProfile = normalizeLlmProfile({ + id: input.activeLlmProfileId || defaults.activeLlmProfileId, + name: defaults.llmProfiles[0]?.name || '默认配置', + provider: input.llmProvider ?? defaults.llmProvider, + apiKey: input.llmApiKey ?? defaults.llmApiKey, + apiBaseUrl: input.llmApiBaseUrl ?? defaults.llmApiBaseUrl, + model: input.llmModel ?? defaults.llmModel, + maxRetries: input.llmMaxRetries ?? defaults.llmMaxRetries, + }, defaults.llmProfiles[0], 0); + const llmProfiles = normalizeLlmProfiles(input.llmProfiles, [fallbackLegacyProfile]); + const activeLlmProfileId = resolveActiveLlmProfileId( + input.activeLlmProfileId, + llmProfiles, + defaults.activeLlmProfileId + ); + const activeLlmProfile = llmProfiles.find((profile) => profile.id === activeLlmProfileId) || llmProfiles[0]; return { apiPort: clampNumber(input.apiPort, defaults.apiPort, 1, 65535), @@ -205,20 +302,19 @@ export class SystemSettingsService { autoBackupEnabled: toBoolean(input.autoBackupEnabled, defaults.autoBackupEnabled), backupSchedule: toStringValue(input.backupSchedule, defaults.backupSchedule) || defaults.backupSchedule, backupRetentionDays: clampNumber(input.backupRetentionDays, defaults.backupRetentionDays, 1, 365), - llmProvider: toStringValue(input.llmProvider, defaults.llmProvider) || defaults.llmProvider, - llmApiKey: toStringValue(input.llmApiKey, defaults.llmApiKey), - llmApiBaseUrl: toStringValue(input.llmApiBaseUrl, defaults.llmApiBaseUrl), - llmModel: toStringValue(input.llmModel, defaults.llmModel), - llmMaxRetries: clampNumber(input.llmMaxRetries, defaults.llmMaxRetries, 0, 10), + llmProvider: activeLlmProfile.provider, + llmApiKey: activeLlmProfile.apiKey, + llmApiBaseUrl: activeLlmProfile.apiBaseUrl, + llmModel: activeLlmProfile.model, + llmMaxRetries: activeLlmProfile.maxRetries, + llmProfiles, + activeLlmProfileId, }; } getSettings(): SystemSettingsRecord { const stored = this.model.getAll() as Partial; - return this.normalize({ - ...this.buildDefaults(), - ...stored, - }); + return this.normalize(stored); } saveSettings(input: Partial): SystemSettingsRecord { diff --git a/web/app/dashboard/settings/page.tsx b/web/app/dashboard/settings/page.tsx index feeca0c..0e2cdbd 100644 --- a/web/app/dashboard/settings/page.tsx +++ b/web/app/dashboard/settings/page.tsx @@ -11,7 +11,7 @@ import { Input, PasswordInput } from '@/components/ui/Input'; import { Loading } from '@/components/ui/Loading'; import { useNotificationHelpers } from '@/contexts/NotificationContext'; import { rememberOAuthStatePlatform } from '@/lib/oauth-state'; -import type { LlmTestResult, OAuthInstallation, Platform, SystemSettings } from '@/types'; +import type { LlmProfile, LlmTestResult, OAuthInstallation, Platform, SystemSettings } from '@/types'; const logLevels = [ { value: 'debug', label: '调试 (Debug)' }, @@ -68,6 +68,16 @@ const platformIcons: Record = { ), }; +const defaultLlmProfile: LlmProfile = { + id: 'default-llm-profile', + name: '默认配置', + provider: 'openai-compatible', + apiKey: '', + apiBaseUrl: '', + model: '', + maxRetries: 2, +}; + const defaultSettings: SystemSettings = { apiPort: 7900, apiHost: 'localhost', @@ -100,6 +110,8 @@ const defaultSettings: SystemSettings = { llmApiBaseUrl: '', llmModel: '', llmMaxRetries: 2, + llmProfiles: [defaultLlmProfile], + activeLlmProfileId: defaultLlmProfile.id, }; type SettingsTab = 'general' | 'security' | 'oauth' | 'llm' | 'database' | 'backup'; @@ -126,6 +138,110 @@ type NumberInputProps = { unit?: string; }; +function createLlmProfileId(): string { + return `llm-profile-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function normalizeLlmProfile(profile: Partial | undefined, index: number): LlmProfile { + const rawMaxRetries = typeof profile?.maxRetries === 'number' ? profile.maxRetries : Number.NaN; + + return { + id: profile?.id?.trim() || defaultLlmProfile.id || `llm-profile-${index + 1}`, + name: typeof profile?.name === 'string' && profile.name.length > 0 ? profile.name : `LLM 配置 ${index + 1}`, + provider: typeof profile?.provider === 'string' && profile.provider.length > 0 ? profile.provider : defaultLlmProfile.provider, + apiKey: typeof profile?.apiKey === 'string' ? profile.apiKey : '', + apiBaseUrl: typeof profile?.apiBaseUrl === 'string' ? profile.apiBaseUrl : '', + model: typeof profile?.model === 'string' ? profile.model : '', + maxRetries: Number.isFinite(rawMaxRetries) + ? Math.max(0, Math.min(10, Math.round(rawMaxRetries))) + : defaultLlmProfile.maxRetries, + }; +} + +function normalizeSettings(input: Partial): SystemSettings { + const base = { + ...defaultSettings, + ...input, + }; + + const legacyProfile = normalizeLlmProfile({ + id: base.activeLlmProfileId || defaultLlmProfile.id, + name: defaultLlmProfile.name, + provider: base.llmProvider, + apiKey: base.llmApiKey, + apiBaseUrl: base.llmApiBaseUrl, + model: base.llmModel, + maxRetries: base.llmMaxRetries, + }, 0); + + const rawProfiles = Array.isArray(base.llmProfiles) && base.llmProfiles.length > 0 + ? base.llmProfiles + : [legacyProfile]; + + const llmProfiles = rawProfiles + .map((profile, index) => normalizeLlmProfile(profile, index)) + .filter((profile, index, list) => list.findIndex((item) => item.id === profile.id) === index); + const activeLlmProfileId = llmProfiles.some((profile) => profile.id === base.activeLlmProfileId) + ? base.activeLlmProfileId + : llmProfiles[0]?.id || defaultLlmProfile.id; + const activeLlmProfile = llmProfiles.find((profile) => profile.id === activeLlmProfileId) || legacyProfile; + + return { + ...base, + llmProvider: activeLlmProfile.provider, + llmApiKey: activeLlmProfile.apiKey, + llmApiBaseUrl: activeLlmProfile.apiBaseUrl, + llmModel: activeLlmProfile.model, + llmMaxRetries: activeLlmProfile.maxRetries, + llmProfiles, + activeLlmProfileId, + }; +} + +function getActiveLlmProfile(settings: SystemSettings): LlmProfile { + return settings.llmProfiles.find((profile) => profile.id === settings.activeLlmProfileId) + || settings.llmProfiles[0] + || defaultLlmProfile; +} + +function getLlmProfileById(settings: SystemSettings, profileId?: string | null): LlmProfile { + if (!profileId) { + return getActiveLlmProfile(settings); + } + + return settings.llmProfiles.find((profile) => profile.id === profileId) + || getActiveLlmProfile(settings); +} + +function updateLlmProfileById( + settings: SystemSettings, + profileId: string, + updater: (profile: LlmProfile) => LlmProfile +): SystemSettings { + const llmProfiles = settings.llmProfiles.map((profile, index) => ( + profile.id === profileId ? normalizeLlmProfile(updater(profile), index) : profile + )); + + return normalizeSettings({ + ...settings, + llmProfiles, + }); +} + +function deleteLlmProfile(settings: SystemSettings, profileId: string): SystemSettings { + const remainingProfiles = settings.llmProfiles.filter((profile) => profile.id !== profileId); + const llmProfiles = remainingProfiles.length > 0 ? remainingProfiles : [defaultLlmProfile]; + const activeLlmProfileId = settings.activeLlmProfileId === profileId + ? llmProfiles[0].id + : settings.activeLlmProfileId; + + return normalizeSettings({ + ...settings, + llmProfiles, + activeLlmProfileId, + }); +} + function mergeModelOptions(result: string[], currentModel: string): string[] { return [...new Set([currentModel, ...result].filter((item) => item.trim().length > 0))]; } @@ -248,6 +364,8 @@ export default function SettingsPage() { const [activeTab, setActiveTab] = useState('general'); const [settings, setSettings] = useState(defaultSettings); const [savedSettings, setSavedSettings] = useState(defaultSettings); + const [selectedLlmProfileId, setSelectedLlmProfileId] = useState(defaultSettings.activeLlmProfileId); + const [isLlmEditorOpen, setIsLlmEditorOpen] = useState(false); const [installations, setInstallations] = useState([]); const [discoveredModels, setDiscoveredModels] = useState([]); const [llmTestResult, setLlmTestResult] = useState(null); @@ -266,18 +384,17 @@ export default function SettingsPage() { try { setIsLoading(true); const response = await apiClient.getSettings(); - const nextSettings = { - ...defaultSettings, - ...(response.data || {}), - }; + const nextSettings = normalizeSettings(response.data || defaultSettings); setSettings(nextSettings); setSavedSettings(nextSettings); + setSelectedLlmProfileId(nextSettings.activeLlmProfileId); setDiscoveredModels(mergeModelOptions([], nextSettings.llmModel)); } catch (err) { console.error('加载设置失败:', err); setSettings(defaultSettings); setSavedSettings(defaultSettings); + setSelectedLlmProfileId(defaultSettings.activeLlmProfileId); } finally { setIsLoading(false); } @@ -299,13 +416,15 @@ export default function SettingsPage() { try { setIsSaving(true); const response = await apiClient.saveSettings(settings); - const persisted = { - ...defaultSettings, - ...(response.data || settings), - }; + const persisted = normalizeSettings(response.data || settings); setSettings(persisted); setSavedSettings(persisted); + setSelectedLlmProfileId((current) => ( + persisted.llmProfiles.some((profile) => profile.id === current) + ? current + : persisted.activeLlmProfileId + )); success('保存成功', '系统设置已保存,运行中服务可能需要重启后完全生效'); } catch (err) { const message = err instanceof Error ? err.message : '保存设置失败'; @@ -351,16 +470,17 @@ export default function SettingsPage() { const handleTestLlm = async () => { try { setIsTestingLlm(true); + const editingLlmProfile = getLlmProfileById(settings, selectedLlmProfileId); const response = await apiClient.testLlmSettings({ - llmProvider: settings.llmProvider, - llmApiKey: settings.llmApiKey, - llmApiBaseUrl: settings.llmApiBaseUrl, - llmModel: settings.llmModel, - llmMaxRetries: settings.llmMaxRetries, + llmProvider: editingLlmProfile.provider, + llmApiKey: editingLlmProfile.apiKey, + llmApiBaseUrl: editingLlmProfile.apiBaseUrl, + llmModel: editingLlmProfile.model, + llmMaxRetries: editingLlmProfile.maxRetries, }); setLlmTestResult(response.data); - setDiscoveredModels(mergeModelOptions(response.data.availableModels, response.data.model || settings.llmModel)); + setDiscoveredModels(mergeModelOptions(response.data.availableModels, response.data.model || editingLlmProfile.model)); success('测试成功', response.data.message); } catch (err) { const message = err instanceof Error ? err.message : 'LLM API 测试失败'; @@ -468,6 +588,7 @@ export default function SettingsPage() { } setSettings(defaultSettings); + setSelectedLlmProfileId(defaultSettings.activeLlmProfileId); setDiscoveredModels(mergeModelOptions(discoveredModels, defaultSettings.llmModel)); setLlmTestResult(null); }; @@ -492,6 +613,29 @@ export default function SettingsPage() { setHasChanges(JSON.stringify(settings) !== JSON.stringify(savedSettings)); }, [savedSettings, settings]); + useEffect(() => { + if (settings.llmProfiles.some((profile) => profile.id === selectedLlmProfileId)) { + return; + } + + setSelectedLlmProfileId(settings.activeLlmProfileId || settings.llmProfiles[0]?.id || defaultLlmProfile.id); + }, [selectedLlmProfileId, settings.activeLlmProfileId, settings.llmProfiles]); + + useEffect(() => { + if (!isLlmEditorOpen) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsLlmEditorOpen(false); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isLlmEditorOpen]); + const tabs: Array<{ id: SettingsTab; label: string; accent: string }> = [ { id: 'general', label: '通用设置', accent: 'from-emerald-500 to-teal-600' }, { id: 'security', label: '管理员与安全', accent: 'from-rose-500 to-red-600' }, @@ -511,8 +655,11 @@ export default function SettingsPage() { ); } - const llmConfigured = settings.llmApiKey.trim().length > 0; - const testBadgeVariant = llmTestResult?.available ? 'success' : llmConfigured ? 'warning' : 'default'; + const activeLlmProfile = getActiveLlmProfile(settings); + const editingLlmProfile = getLlmProfileById(settings, selectedLlmProfileId); + const llmConfigured = activeLlmProfile.apiKey.trim().length > 0; + const editingLlmConfigured = editingLlmProfile.apiKey.trim().length > 0; + const testBadgeVariant = llmTestResult?.available ? 'success' : editingLlmConfigured ? 'warning' : 'default'; return (
@@ -971,221 +1118,428 @@ export default function SettingsPage() { )} {activeTab === 'llm' && ( -
-
- -
+ <> +
+
+
- - -

- {llmProviders.find((item) => item.value === settings.llmProvider)?.description} -

-
+
+
+

API 配置列表

+

+ 每一行是一套完整的 LLM API 配置。点击编辑会以弹窗形式打开详情表单。 +

+
+ +
-
- setSettings({ ...settings, llmApiBaseUrl: event.target.value })} - helperText="如果使用官方 OpenAI/OpenRouter,可留空使用默认地址。" - /> -
-
+
+
+

配置

+

连接信息

+

操作

+
+ {settings.llmProfiles.map((profile) => { + const isActive = profile.id === settings.activeLlmProfileId; + const isSelected = profile.id === editingLlmProfile.id; + + return ( +
+
+
+

+ {profile.name} +

+ {isActive && 当前使用中} + {isSelected && 编辑中} +
+

+ {profile.provider} · {profile.model || '未设置模型'} +

+
-
- setSettings({ ...settings, llmApiKey: event.target.value })} - helperText="会保存在本地管理库中,仅管理后台可见。" - /> - setSettings({ ...settings, llmMaxRetries: value })} - min={0} - max={10} - /> -
+
+

Base URL: {profile.apiBaseUrl || '使用默认地址'}

+

重试: {profile.maxRetries} 次

+
-
-
- setSettings({ ...settings, llmModel: event.target.value })} - list="llm-model-list" - helperText="可以手填,也可以先测试接口后从返回的模型列表中选择。" - /> - - {discoveredModels.map((model) => ( - +
+ + + {settings.llmProfiles.length > 1 && ( + + )} +
+
+ ); + })} +
+ +
-
-
+
+ +
+
-

模型发现

+

最近一次测试

- {discoveredModels.length > 0 ? `已发现 ${discoveredModels.length} 个模型` : '尚未获取模型列表'} + {llmTestResult?.message || '还没有执行过接口测试'}

- 0 ? 'success' : 'default'}> - {discoveredModels.length > 0 ? 'Ready' : 'Idle'} + + {llmTestResult?.available ? 'Available' : editingLlmConfigured ? 'Pending' : 'Unset'}
- {discoveredModels.length > 0 && ( -
- {discoveredModels.slice(0, 8).map((model) => ( - - ))} + +
+
+

Provider

+

+ {llmTestResult?.provider || editingLlmProfile.provider} +

+
+
+

Model

+

+ {llmTestResult?.model || editingLlmProfile.model || '未选择'} +

+
+
+

Latency

+

+ {formatLatency(llmTestResult?.latencyMs)} +

+
+
+

Usage

+

+ {formatCount(llmTestResult?.usage?.totalTokens)} tokens +

+
+
+ + {llmTestResult?.responsePreview && ( +
+

Response Preview

+

{llmTestResult.responsePreview}

)}
-
- -
- -

- 建议先测试连接,再保存到系统默认配置。 -

-
-
- - -
- {llmProviders.map((provider) => ( -
-

{provider.label}

-

{provider.description}

-
- ))} -
-
+ +
-
- -
-
+ {isLlmEditorOpen && ( +
{ + if (event.target === event.currentTarget) { + setIsLlmEditorOpen(false); + } + }} + > +
+
-

最近一次测试

-

- {llmTestResult?.message || '还没有执行过接口测试'} +

+ LLM API 配置 +

+

+ {editingLlmProfile.name} +

+

+ 在弹窗里修改当前选中配置。保存系统设置后才会真正写入后端。

- - {llmTestResult?.available ? 'Available' : llmConfigured ? 'Pending' : 'Unset'} - +
-
-
-

Provider

-

- {llmTestResult?.provider || settings.llmProvider} -

-
-
-

Model

-

- {llmTestResult?.model || settings.llmModel || '未选择'} -

+
+
+
+

编辑状态

+

+ {editingLlmProfile.id === activeLlmProfile.id ? '这套配置当前已经在使用中。' : '这套配置已选中,但尚未切换成当前默认配置。'} +

+
+
+ + +
-
-

Latency

-

- {formatLatency(llmTestResult?.latencyMs)} -

+ +
+
+ { + setSettings((current) => updateLlmProfileById(current, editingLlmProfile.id, (profile) => ({ + ...profile, + name: event.target.value, + }))); + setLlmTestResult(null); + }} + helperText="保存后会作为可切换的 LLM API 配置名称。" + /> +
+ +
+ + +

+ {llmProviders.find((item) => item.value === editingLlmProfile.provider)?.description} +

+
-
-

Usage

-

- {formatCount(llmTestResult?.usage?.totalTokens)} tokens -

+ +
+
+ { + setSettings((current) => updateLlmProfileById(current, editingLlmProfile.id, (profile) => ({ + ...profile, + apiBaseUrl: event.target.value, + }))); + setLlmTestResult(null); + }} + helperText="如果使用官方 OpenAI/OpenRouter,可留空使用默认地址。" + /> +
-
- {llmTestResult?.responsePreview && ( -
-

Response Preview

-

{llmTestResult.responsePreview}

+
+ { + setSettings((current) => updateLlmProfileById(current, editingLlmProfile.id, (profile) => ({ + ...profile, + apiKey: event.target.value, + }))); + setLlmTestResult(null); + }} + helperText="会保存在本地管理库中,仅管理后台可见。" + /> + { + setSettings((current) => updateLlmProfileById(current, editingLlmProfile.id, (profile) => ({ + ...profile, + maxRetries: value, + }))); + setLlmTestResult(null); + }} + min={0} + max={10} + />
- )} -
- - -
-
-

Base URL

-

- {settings.llmApiBaseUrl || '使用 provider 默认地址'} -

-
-
-

模型选择

-

- {settings.llmModel || '尚未指定'} -

+
+
+ { + setSettings((current) => updateLlmProfileById(current, editingLlmProfile.id, (profile) => ({ + ...profile, + model: event.target.value, + }))); + setLlmTestResult(null); + }} + list="llm-model-list" + helperText="可以手填,也可以先测试接口后从返回的模型列表中选择。" + /> + + {discoveredModels.map((model) => ( + +
+ +
+
+
+

模型发现

+

+ {discoveredModels.length > 0 ? `已发现 ${discoveredModels.length} 个模型` : '尚未获取模型列表'} +

+
+ 0 ? 'success' : 'default'}> + {discoveredModels.length > 0 ? 'Ready' : 'Idle'} + +
+ {discoveredModels.length > 0 && ( +
+ {discoveredModels.slice(0, 8).map((model) => ( + + ))} +
+ )} +
+
-
-

重试策略

-

- 最多 {settings.llmMaxRetries} 次重试 + +

+

+ 这里修改的是当前页面内的配置草稿,点击页面顶部“保存设置”后才会正式持久化。

+
- -
-
+
+ )} + )} {activeTab === 'database' && ( diff --git a/web/src/types/index.ts b/web/src/types/index.ts index ee805dd..04a073f 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -413,6 +413,16 @@ export interface MemoryInfo { swapPercentage?: number; } +export interface LlmProfile { + id: string; + name: string; + provider: string; + apiKey: string; + apiBaseUrl: string; + model: string; + maxRetries: number; +} + // 系统设置相关类型 export interface SystemSettings { // 环境变量 @@ -454,6 +464,8 @@ export interface SystemSettings { llmApiBaseUrl: string; llmModel: string; llmMaxRetries: number; + llmProfiles: LlmProfile[]; + activeLlmProfileId: string; } export interface BackupInfo { From 69cb72313569a595b3292180c476cd75286cb9d6 Mon Sep 17 00:00:00 2001 From: mars167 Date: Sun, 29 Mar 2026 01:12:47 +0800 Subject: [PATCH 7/9] fix: ignore empty repository page cache --- web/app/dashboard/repositories/page.tsx | 49 +++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/web/app/dashboard/repositories/page.tsx b/web/app/dashboard/repositories/page.tsx index f2da1c7..fc67e6b 100644 --- a/web/app/dashboard/repositories/page.tsx +++ b/web/app/dashboard/repositories/page.tsx @@ -83,6 +83,36 @@ function makeRepositoryCacheKey(platform: Platform, page: number, pageSize: numb return `${platform}:${page}:${pageSize}`; } +function isRepositoryPageCacheEntry(entry: unknown): entry is RepositoryPageCacheEntry { + if (!entry || typeof entry !== 'object') { + return false; + } + + const candidate = entry as Partial; + return Array.isArray(candidate.repositories) + && typeof candidate.total === 'number' + && typeof candidate.page === 'number' + && typeof candidate.pageSize === 'number' + && (candidate.platform === 'github' || candidate.platform === 'gitee' || candidate.platform === 'gitlab') + && typeof candidate.cachedAt === 'number'; +} + +function shouldCacheRepositoryPageEntry(entry: RepositoryPageCacheEntry): boolean { + return entry.repositories.length > 0; +} + +function sanitizeRepositoryPageCacheStore(store: unknown): RepositoryPageCacheStore { + if (!store || typeof store !== 'object') { + return {}; + } + + return Object.fromEntries( + Object.entries(store).filter(([, entry]) => ( + isRepositoryPageCacheEntry(entry) && shouldCacheRepositoryPageEntry(entry) + )) + ) as RepositoryPageCacheStore; +} + function readRepositoryPageCacheStore(): RepositoryPageCacheStore { if (typeof window === 'undefined') { return {}; @@ -94,8 +124,7 @@ function readRepositoryPageCacheStore(): RepositoryPageCacheStore { return {}; } - const parsed = JSON.parse(raw) as RepositoryPageCacheStore; - return parsed && typeof parsed === 'object' ? parsed : {}; + return sanitizeRepositoryPageCacheStore(JSON.parse(raw)); } catch { return {}; } @@ -107,7 +136,10 @@ function writeRepositoryPageCacheStore(store: RepositoryPageCacheStore): void { } try { - window.sessionStorage.setItem(REPOSITORY_PAGE_CACHE_KEY, JSON.stringify(store)); + window.sessionStorage.setItem( + REPOSITORY_PAGE_CACHE_KEY, + JSON.stringify(sanitizeRepositoryPageCacheStore(store)) + ); } catch { // ignore storage failures } @@ -139,9 +171,18 @@ export default function RepositoriesPage() { const [updatingFavoriteId, setUpdatingFavoriteId] = useState(null); const syncCache = useCallback((entry: RepositoryPageCacheEntry) => { + const cacheKey = makeRepositoryCacheKey(entry.platform, entry.page, entry.pageSize); + if (!shouldCacheRepositoryPageEntry(entry)) { + const nextStore = { ...cacheRef.current }; + delete nextStore[cacheKey]; + cacheRef.current = nextStore; + writeRepositoryPageCacheStore(nextStore); + return; + } + cacheRef.current = { ...cacheRef.current, - [makeRepositoryCacheKey(entry.platform, entry.page, entry.pageSize)]: entry, + [cacheKey]: entry, }; writeRepositoryPageCacheStore(cacheRef.current); }, []); From 949c0f8153e6777c7337e2b10f949ad06d559d8d Mon Sep 17 00:00:00 2001 From: mars167 Date: Sun, 29 Mar 2026 01:49:51 +0800 Subject: [PATCH 8/9] fix: log platform fetch failures with context --- server/src/platform/client.test.ts | 44 ++++++++- server/src/platform/client.ts | 141 ++++++++++++++++++++--------- 2 files changed, 143 insertions(+), 42 deletions(-) diff --git a/server/src/platform/client.test.ts b/server/src/platform/client.test.ts index d6ff6a1..7d1ebef 100644 --- a/server/src/platform/client.test.ts +++ b/server/src/platform/client.test.ts @@ -1,4 +1,4 @@ -import { GiteeApiClient, GitLabApiClient } from './client'; +import { GiteeApiClient, GitHubApiClient, GitLabApiClient } from './client'; describe('GiteeApiClient', () => { afterEach(() => { @@ -169,3 +169,45 @@ describe('GitLabApiClient', () => { ]); }); }); + +describe('GitHubApiClient', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('includes request cause metadata when fetch throws before receiving a response', async () => { + const networkError = new TypeError('fetch failed', { + cause: { + code: 'ENOTFOUND', + syscall: 'getaddrinfo', + hostname: 'api.github.com', + }, + }); + + jest.spyOn(global, 'fetch').mockRejectedValue(networkError); + + const client = new GitHubApiClient('token'); + + await expect(client.getPullRequest('mars167', 'CodaGraph-lite', 12)).rejects.toThrow( + 'GET https://api.github.com/repos/mars167/CodaGraph-lite/pulls/12 失败: fetch failed | code=ENOTFOUND | syscall=getaddrinfo | hostname=api.github.com' + ); + }); + + it('includes status code and response body preview when the platform returns an error response', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ message: 'Bad credentials' }), { + status: 401, + statusText: 'Unauthorized', + headers: { + 'Content-Type': 'application/json', + }, + }) + ); + + const client = new GitHubApiClient('token'); + + await expect(client.getPullRequest('mars167', 'CodaGraph-lite', 12)).rejects.toThrow( + 'GET https://api.github.com/repos/mars167/CodaGraph-lite/pulls/12 失败: 401 Unauthorized | body={"message":"Bad credentials"}' + ); + }); +}); diff --git a/server/src/platform/client.ts b/server/src/platform/client.ts index 3812ec3..11b293b 100644 --- a/server/src/platform/client.ts +++ b/server/src/platform/client.ts @@ -122,6 +122,75 @@ export interface PaginationOptions { state?: 'open' | 'closed' | 'all'; } +function normalizeErrorPreview(value: string): string { + return value.replace(/\s+/g, ' ').trim(); +} + +function pushErrorPart(parts: string[], value: string | null | undefined): void { + if (!value || parts.includes(value)) { + return; + } + + parts.push(value); +} + +function appendRequestErrorMetadata(parts: string[], source: unknown): void { + if (!source || typeof source !== 'object') { + return; + } + + const candidate = source as Record; + const fields: Array<[string, string]> = [ + ['code', 'code'], + ['errno', 'errno'], + ['syscall', 'syscall'], + ['host', 'host'], + ['hostname', 'hostname'], + ['address', 'address'], + ['port', 'port'], + ]; + + for (const [key, label] of fields) { + const value = candidate[key]; + if (typeof value === 'string' || typeof value === 'number') { + pushErrorPart(parts, `${label}=${String(value)}`); + } + } +} + +function describeRequestError(error: unknown): string { + const parts: string[] = []; + const message = error instanceof Error ? normalizeErrorPreview(error.message) : normalizeErrorPreview(String(error)); + pushErrorPart(parts, message); + + const cause = error instanceof Error + ? (error as Error & { cause?: unknown }).cause + : undefined; + + if (cause instanceof Error) { + pushErrorPart(parts, `cause=${normalizeErrorPreview(cause.message)}`); + } else if (typeof cause === 'string') { + pushErrorPart(parts, `cause=${normalizeErrorPreview(cause)}`); + } + + appendRequestErrorMetadata(parts, error); + appendRequestErrorMetadata(parts, cause); + + return parts.join(' | '); +} + +async function describeErrorResponse(response: Response): Promise { + const statusLine = `${response.status} ${response.statusText}`; + + try { + const rawBody = await response.text(); + const preview = normalizeErrorPreview(rawBody).slice(0, 240); + return preview ? `${statusLine} | body=${preview}` : statusLine; + } catch { + return statusLine; + } +} + type GiteeRepositoryResponse = Repository & { clone_url?: string | null; html_url?: string | null; @@ -325,75 +394,65 @@ abstract class BaseApiClient { }; } - /** - * 发起 GET 请求 - */ - protected async get(path: string, options?: RequestInit): Promise { + protected async requestJson( + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + path: string, + options: RequestInit = {} + ): Promise { const url = `${this.baseUrl}${path}`; - const response = await fetch(url, { - method: 'GET', - headers: this.getHeaders(), - ...options, - }); + let response: Response; + + try { + response = await fetch(url, { + method, + headers: this.getHeaders(), + ...options, + }); + } catch (error) { + throw new Error(`${method} ${url} 失败: ${describeRequestError(error)}`); + } if (!response.ok) { - throw new Error(`GET ${url} 失败: ${response.status} ${response.statusText}`); + throw new Error(`${method} ${url} 失败: ${await describeErrorResponse(response)}`); + } + + if (response.status === 204) { + return undefined as T; } return response.json() as Promise; } + /** + * 发起 GET 请求 + */ + protected async get(path: string, options?: RequestInit): Promise { + return this.requestJson('GET', path, options); + } + /** * 发起 POST 请求 */ protected async post(path: string, data?: unknown): Promise { - const url = `${this.baseUrl}${path}`; - const response = await fetch(url, { - method: 'POST', - headers: this.getHeaders(), + return this.requestJson('POST', path, { body: data ? JSON.stringify(data) : undefined, }); - - if (!response.ok) { - throw new Error(`POST ${url} 失败: ${response.status} ${response.statusText}`); - } - - return response.json() as Promise; } /** * 发起 PUT 请求 */ protected async put(path: string, data?: unknown): Promise { - const url = `${this.baseUrl}${path}`; - const response = await fetch(url, { - method: 'PUT', - headers: this.getHeaders(), + return this.requestJson('PUT', path, { body: data ? JSON.stringify(data) : undefined, }); - - if (!response.ok) { - throw new Error(`PUT ${url} 失败: ${response.status} ${response.statusText}`); - } - - return response.json() as Promise; } /** * 发起 DELETE 请求 */ protected async delete(path: string): Promise { - const url = `${this.baseUrl}${path}`; - const response = await fetch(url, { - method: 'DELETE', - headers: this.getHeaders(), - }); - - if (!response.ok) { - throw new Error(`DELETE ${url} 失败: ${response.status} ${response.statusText}`); - } - - return response.json() as Promise; + return this.requestJson('DELETE', path); } /** From 499dc5b43516130e22f3b4d7a6b3385309ec664b Mon Sep 17 00:00:00 2001 From: mars167 Date: Sun, 29 Mar 2026 02:24:30 +0800 Subject: [PATCH 9/9] fix: address actionable pr review feedback --- e2e/dashboard-pages.spec.ts | 6 +++++- package.json | 2 +- playwright.config.ts | 4 ++-- scripts/seed-e2e-data.js | 32 ++++++++++++++++++++++++++++---- server/src/auth/routes.test.ts | 2 ++ server/src/auth/routes.ts | 10 ++-------- server/src/config/index.test.ts | 10 ++++++++++ server/src/config/index.ts | 4 ++++ server/src/models/Admin.ts | 17 +++++++++++------ web/package.json | 2 +- 10 files changed, 66 insertions(+), 23 deletions(-) diff --git a/e2e/dashboard-pages.spec.ts b/e2e/dashboard-pages.spec.ts index 2a945fb..a79e715 100644 --- a/e2e/dashboard-pages.spec.ts +++ b/e2e/dashboard-pages.spec.ts @@ -34,7 +34,11 @@ test('jobs page renders seeded job stats and row', async ({ page }) => { await page.goto('/dashboard/jobs'); await expect(page.getByRole('heading', { name: '作业状态' })).toBeVisible(); - await expect(page.getByRole('link', { name: `Job #9301 · ${seededRepositoryName} · PR #42` })).toBeVisible(); + await expect( + page.getByRole('link', { + name: new RegExp(`Job #\\d+ · ${seededRepositoryName} · PR #\\d+`), + }) + ).toBeVisible(); expect(pageErrors).toEqual([]); }); diff --git a/package.json b/package.json index cc2d522..5b75d93 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "build:web": "npm --prefix web run build", "build:server": "npm --prefix server run build", "build": "npm run build:web && npm run build:server", - "check:web": "npm --prefix web run lint -- app src next.config.ts", + "check:web": "npm --prefix web run lint", "check:server": "npm --prefix server run typecheck", "check": "npm run check:web && npm run check:server && npm run build", "start:web": "npm --prefix web run start", diff --git a/playwright.config.ts b/playwright.config.ts index 511c112..1bca167 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -15,8 +15,8 @@ export default defineConfig({ timeout: 30_000, }, { - command: 'cd web && mkdir -p .next/standalone/.next && ln -sfn ../../static .next/standalone/.next/static && PORT=3001 HOSTNAME=127.0.0.1 NEXT_PUBLIC_API_URL=http://127.0.0.1:7901 node .next/standalone/server.js', - url: 'http://127.0.0.1:3001/login', + command: 'cd web && mkdir -p .next/standalone/.next && if [ -d .next/static ]; then ln -sfn ../../static .next/standalone/.next/static; fi && PORT=3001 HOSTNAME=127.0.0.1 NEXT_PUBLIC_API_URL=http://127.0.0.1:7901 node .next/standalone/server.js', + url: 'http://127.0.0.1:3001/api/health', reuseExistingServer: !process.env.CI, timeout: 30_000, }, diff --git a/scripts/seed-e2e-data.js b/scripts/seed-e2e-data.js index 9055808..e02ddd2 100644 --- a/scripts/seed-e2e-data.js +++ b/scripts/seed-e2e-data.js @@ -1,13 +1,29 @@ const path = require('path'); +const crypto = require('crypto'); const Database = require(require.resolve('better-sqlite3', { paths: [path.join(__dirname, '..', 'server')], })); const dbPath = path.join(__dirname, '..', 'server', 'data', 'codagraph-lite.db'); const db = new Database(dbPath); +// Keep fixture data ahead of existing rows so the e2e smoke tests can locate it deterministically. const seededTimestamp = "datetime('now', 'localtime', '+1 day')"; +const seededAdminPasswordHash = crypto + .createHash('sha256') + .update('changeme') + .digest('hex'); + +try { + db.exec(` + CREATE TABLE IF NOT EXISTS admin ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), + last_login_at TEXT + ); -db.exec(` CREATE TABLE IF NOT EXISTS oauth_installations ( id INTEGER PRIMARY KEY AUTOINCREMENT, platform TEXT NOT NULL CHECK(platform IN ('github', 'gitee', 'gitlab')), @@ -79,6 +95,12 @@ db.exec(` updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); + INSERT OR REPLACE INTO admin ( + id, username, password_hash, created_at, updated_at, last_login_at + ) VALUES ( + 1, 'admin', '${seededAdminPasswordHash}', ${seededTimestamp}, ${seededTimestamp}, NULL + ); + INSERT OR REPLACE INTO oauth_installations ( id, platform, auth_type, account_id, account_name, access_token, permissions, is_active, created_at, updated_at ) VALUES ( @@ -109,7 +131,9 @@ db.exec(` 9301, 'pr_analysis', '{"platform":"github","repo_name":"codagraph-lite-e2e-fixture","pr_number":"42"}', 'completed', 3, 1, 3, NULL, ${seededTimestamp}, ${seededTimestamp}, ${seededTimestamp}, ${seededTimestamp} ); -`); + `); -console.log(`Seeded E2E data into ${dbPath}`); -db.close(); + console.log(`Seeded E2E data into ${dbPath}`); +} finally { + db.close(); +} diff --git a/server/src/auth/routes.test.ts b/server/src/auth/routes.test.ts index 384248a..fb45e43 100644 --- a/server/src/auth/routes.test.ts +++ b/server/src/auth/routes.test.ts @@ -65,6 +65,7 @@ describe('authRoutes', () => { expect(response.body).toEqual({ error: '用户名或密码错误', }); + expect(adminModelMock.verifyPassword).toHaveBeenCalledWith('admin', 'wrong-password'); expect(activityLogModelMock.logFailedLogin).toHaveBeenCalledWith( 7, 'admin', @@ -88,6 +89,7 @@ describe('authRoutes', () => { expect(response.body).toEqual({ error: '用户名或密码错误', }); + expect(adminModelMock.verifyPassword).toHaveBeenCalledWith('ghost', 'wrong-password'); expect(activityLogModelMock.logFailedLogin).not.toHaveBeenCalled(); }); }); diff --git a/server/src/auth/routes.ts b/server/src/auth/routes.ts index 680964d..8d9c681 100644 --- a/server/src/auth/routes.ts +++ b/server/src/auth/routes.ts @@ -49,10 +49,10 @@ router.post('/login', async (req: Request, res: Response) => { const adminModel = getAdminModel(); const activityLogModel = getActivityLogModel(); + const isValid = adminModel.verifyPassword(username, password); const admin = adminModel.findByUsername(username); - const isValid = admin ? adminModel.verifyPassword(username, password) : false; - if (!isValid) { + if (!isValid || !admin) { if (admin) { activityLogModel.logFailedLogin(admin.id, username, ipAddress, userAgent); } @@ -61,12 +61,6 @@ router.post('/login', async (req: Request, res: Response) => { }); } - if (!admin) { - return res.status(401).json({ - error: '用户不存在', - }); - } - // 更新最后登录时间 adminModel.updateLastLogin(admin.id); diff --git a/server/src/config/index.test.ts b/server/src/config/index.test.ts index 2900366..3b29ece 100644 --- a/server/src/config/index.test.ts +++ b/server/src/config/index.test.ts @@ -17,4 +17,14 @@ describe('CORS origin matching', () => { it('supports wildcard origins', () => { expect(isCorsOriginAllowed(['*'], 'https://preview.example.com')).toBe(true); }); + + it('rejects origins outside the trusted allow-list', () => { + expect(isCorsOriginAllowed([ + 'https://app.example.com', + ], 'https://evil.example.com')).toBe(false); + }); + + it('rejects all browser origins when the allow-list is empty', () => { + expect(isCorsOriginAllowed([], 'https://app.example.com')).toBe(false); + }); }); diff --git a/server/src/config/index.ts b/server/src/config/index.ts index 3612582..70d1758 100644 --- a/server/src/config/index.ts +++ b/server/src/config/index.ts @@ -324,6 +324,10 @@ function validateSecurityConfig(config: Partial): void { warnings.push('WEBHOOK_SECRET 使用默认值,存在安全风险'); } + if (config.cors?.corsOrigins?.includes('*')) { + warnings.push('CORS_ORIGINS 包含通配符 *,会允许任意来源访问,仅建议用于受控开发环境'); + } + // 输出警告 warnings.forEach(warning => logger.warn(`⚠️ ${warning}`)); } diff --git a/server/src/models/Admin.ts b/server/src/models/Admin.ts index 49b76ff..f1ec6fc 100644 --- a/server/src/models/Admin.ts +++ b/server/src/models/Admin.ts @@ -13,6 +13,11 @@ import type { } from './types'; import crypto from 'crypto'; +const DUMMY_PASSWORD_HASH = crypto + .createHash('sha256') + .update('codagraph-lite-dummy-password') + .digest(); + export class AdminModel { private db = getConnection(); @@ -107,16 +112,16 @@ export class AdminModel { */ verifyPassword(username: string, password: string): boolean { const admin = this.findByUsername(username); - if (!admin) { - return false; - } - const passwordHash = crypto .createHash('sha256') .update(password) - .digest('hex'); + .digest(); + const expectedHash = admin + ? Buffer.from(admin.password_hash, 'hex') + : DUMMY_PASSWORD_HASH; - return admin.password_hash === passwordHash; + const isMatch = crypto.timingSafeEqual(passwordHash, expectedHash); + return Boolean(admin) && isMatch; } /** diff --git a/web/package.json b/web/package.json index 055b5e4..c981458 100644 --- a/web/package.json +++ b/web/package.json @@ -6,7 +6,7 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint app src next.config.ts" }, "dependencies": { "echarts": "^6.0.0",