feat(api): add config management REST endpoints

Add /admin/api/config routes for runtime configuration:
- GET /: Retrieve all config groups with field metadata and values
- PUT /: Validate and persist configuration overrides
- POST /reset: Reset specified keys to defaults (remove overrides)

Features:
- Sensitive field masking (passwords, secrets, API keys)
- Field validation (URL, enum, number range, boolean)
- Readonly field protection
- Grouped field organization with metadata
This commit is contained in:
jeffusion
2026-03-03 16:32:01 +08:00
parent d946423d45
commit d375a4c82d
2 changed files with 271 additions and 0 deletions

269
src/controllers/config.ts Normal file
View File

@@ -0,0 +1,269 @@
import { Hono } from 'hono';
import { configManager, type AppConfig } from '../config/config-manager';
import { CONFIG_FIELDS, CONFIG_GROUPS, type ConfigFieldMeta } from '../config/config-schema';
import { logger } from '../utils/logger';
// ── Constants ────────────────────────────────────────────────────────────────
const MASKED_VALUE = '••••••••';
/** Number fields that must be integers (decimal not allowed). */
const INTEGER_FIELDS = new Set([
'PORT',
'REVIEW_MAX_PARALLEL_RUNS',
'REVIEW_MAX_FILES_PER_RUN',
'REVIEW_MAX_FILE_CONTENT_CHARS',
'REVIEW_COMMAND_TIMEOUT_MS',
'FEW_SHOT_EXAMPLES_COUNT',
'MAX_REFLECTION_ROUNDS',
]);
/** Fast lookup from envKey → field metadata. */
const FIELDS_MAP = new Map<string, ConfigFieldMeta>(
CONFIG_FIELDS.map((f) => [f.envKey, f]),
);
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Map an envKey to its effective value from the resolved AppConfig.
* Explicit switch — no dynamic property access.
*/
function getEffectiveValue(
envKey: string,
current: AppConfig,
): string | number | boolean | undefined {
switch (envKey) {
// Gitea
case 'GITEA_API_URL': return current.gitea.apiUrl;
case 'GITEA_ACCESS_TOKEN': return current.gitea.accessToken;
case 'GITEA_ADMIN_TOKEN': return current.admin.giteaAdminToken;
// OpenAI
case 'OPENAI_BASE_URL': return current.openai.baseUrl;
case 'OPENAI_API_KEY': return current.openai.apiKey;
case 'OPENAI_MODEL': return current.openai.model;
case 'CUSTOM_SUMMARY_PROMPT': return current.openai.customSummaryPrompt;
case 'CUSTOM_LINE_COMMENT_PROMPT': return current.openai.customLineCommentPrompt;
// Feishu
case 'FEISHU_WEBHOOK_URL': return current.feishu.webhookUrl;
case 'FEISHU_WEBHOOK_SECRET': return current.feishu.webhookSecret;
// App
case 'PORT': return current.app.port;
case 'WEBHOOK_SECRET': return current.app.webhookSecret;
// Admin
case 'ADMIN_PASSWORD': return current.admin.password;
case 'JWT_SECRET': return current.admin.jwtSecret;
// Review
case 'REVIEW_ENGINE': return current.review.engine;
case 'REVIEW_WORKDIR': return current.review.workdir;
case 'REVIEW_MODEL_PLANNER': return current.review.modelPlanner;
case 'REVIEW_MODEL_SPECIALIST': return current.review.modelSpecialist;
case 'REVIEW_MODEL_JUDGE': return current.review.modelJudge;
case 'REVIEW_MAX_PARALLEL_RUNS': return current.review.maxParallelRuns;
case 'REVIEW_MAX_FILES_PER_RUN': return current.review.maxFilesPerRun;
case 'REVIEW_MAX_FILE_CONTENT_CHARS': return current.review.maxFileContentChars;
case 'REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE': return current.review.autoPublishMinConfidence;
case 'REVIEW_ENABLE_HUMAN_GATE': return current.review.enableHumanGate;
case 'REVIEW_ALLOWED_COMMANDS': return current.review.allowedCommands.join(',');
case 'REVIEW_COMMAND_TIMEOUT_MS': return current.review.commandTimeoutMs;
// Memory
case 'QDRANT_URL': return current.review.qdrantUrl;
case 'ENABLE_MEMORY': return current.review.enableMemory;
case 'FEW_SHOT_EXAMPLES_COUNT': return current.review.fewShotExamplesCount;
case 'ENABLE_REFLECTION': return current.review.enableReflection;
case 'MAX_REFLECTION_ROUNDS': return current.review.maxReflectionRounds;
case 'ENABLE_DEBATE': return current.review.enableDebate;
case 'DEBATE_THRESHOLD': return current.review.debateThreshold;
default: return undefined;
}
}
/**
* Validate a single field value against its metadata.
* Returns an error message string, or `null` if valid.
*/
function validateField(field: ConfigFieldMeta, key: string, value: string): string | null {
switch (field.type) {
case 'url': {
try {
new URL(value);
} catch {
return `${field.label}${key})必须是有效的 URL`;
}
return null;
}
case 'enum': {
if (field.enumValues && !field.enumValues.includes(value)) {
return `${field.label}${key})必须是以下值之一: ${field.enumValues.join(', ')}`;
}
return null;
}
case 'boolean': {
if (value !== 'true' && value !== 'false') {
return `${field.label}${key})必须是布尔值`;
}
return null;
}
case 'number': {
const num = Number(value);
if (isNaN(num)) {
return `${field.label}${key})必须是有效的数字`;
}
if (INTEGER_FIELDS.has(key) && !Number.isInteger(num)) {
return `${field.label}${key})必须是整数`;
}
if (field.min !== undefined && num < field.min) {
return `${field.label}${key})不能小于 ${field.min}`;
}
if (field.max !== undefined && num > field.max) {
return `${field.label}${key})不能大于 ${field.max}`;
}
return null;
}
default:
// string, text — no special validation
return null;
}
}
// ── Router ───────────────────────────────────────────────────────────────────
export const configRouter = new Hono();
/**
* GET / — Return all configuration groups, fields with metadata,
* effective values, and source. Sensitive fields are masked.
*/
configRouter.get('/', (c) => {
const current = configManager.getCurrent();
const groups = CONFIG_GROUPS.map((group) => {
const groupFields = CONFIG_FIELDS.filter((f) => f.group === group.key);
const fields = groupFields.map((field) => {
const rawValue = getEffectiveValue(field.envKey, current);
const hasValue = rawValue !== undefined && rawValue !== '';
const source = configManager.getSource(field.envKey);
const value = field.sensitive && hasValue ? MASKED_VALUE : rawValue;
return {
envKey: field.envKey,
label: field.label,
description: field.description,
type: field.type,
sensitive: field.sensitive,
...(field.readonly && { readonly: true }),
...(field.readonlyWarning !== undefined && { readonlyWarning: field.readonlyWarning }),
...(field.enumValues !== undefined && { enumValues: field.enumValues }),
...(field.min !== undefined && { min: field.min }),
...(field.max !== undefined && { max: field.max }),
...(field.defaultValue !== undefined && { defaultValue: field.defaultValue }),
value,
hasValue,
source,
};
});
return {
key: group.key,
label: group.label,
description: group.description,
icon: group.icon,
fields,
};
});
return c.json({ groups });
});
/**
* PUT / — Validate and persist override updates.
* Masked sentinel ('••••••••') for sensitive fields is silently skipped.
* Empty string '' causes the key to be reset (deleted from overrides).
*/
configRouter.put('/', async (c) => {
try {
const body = await c.req.json<Record<string, unknown>>();
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return c.json({ message: '保存配置失败', error: '请求体必须是 JSON 对象' }, 400);
}
const updates: Record<string, string> = {};
const errors: string[] = [];
for (const [key, rawValue] of Object.entries(body)) {
const field = FIELDS_MAP.get(key);
if (!field) {
errors.push(`未知配置项: ${key}`);
continue;
}
// Reject readonly fields
if (field.readonly) {
errors.push(`${field.label}${key})为只读配置,无法通过 GUI 修改`);
continue;
}
const value = String(rawValue ?? '');
// Skip masked sentinel for sensitive fields — do not overwrite with mask
if (field.sensitive && value === MASKED_VALUE) {
continue;
}
// Empty string → reset (ConfigManager deletes the key)
if (value === '') {
updates[key] = '';
continue;
}
const fieldError = validateField(field, key, value);
if (fieldError) {
errors.push(fieldError);
continue;
}
updates[key] = value;
}
if (errors.length > 0) {
return c.json({ message: '保存配置失败', error: errors.join('; ') }, 400);
}
await configManager.setOverrides(updates);
return c.json({ success: true, message: '配置已保存' });
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : String(error);
logger.error('保存配置失败:', error);
return c.json({ message: '保存配置失败', error: errMsg }, 500);
}
});
/**
* POST /reset — Remove specified keys from overrides (revert to env / default).
*/
configRouter.post('/reset', async (c) => {
try {
const { keys } = await c.req.json<{ keys: unknown }>();
if (!Array.isArray(keys) || !keys.every((k): k is string => typeof k === 'string')) {
return c.json({ message: '保存配置失败', error: 'keys 必须是字符串数组' }, 400);
}
const unknownKeys = keys.filter((k) => !FIELDS_MAP.has(k));
if (unknownKeys.length > 0) {
return c.json(
{ message: '保存配置失败', error: `未知配置项: ${unknownKeys.join(', ')}` },
400,
);
}
await configManager.resetKeys(keys);
return c.json({ success: true, message: '配置已重置' });
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : String(error);
logger.error('重置配置失败:', error);
return c.json({ message: '保存配置失败', error: errMsg }, 500);
}
});

View File

@@ -4,6 +4,7 @@ import { serveStatic } from 'hono/bun';
import { handleGiteaWebhook } from './controllers/review';
import { adminController } from './controllers/admin';
import { feedbackRouter, initializeFeedbackSystem } from './controllers/feedback';
import { configRouter } from './controllers/config';
import config from './config';
import { reviewEngine } from './review/engine';
import OpenAI from 'openai';
@@ -45,6 +46,7 @@ const adminProtected = new Hono();
adminProtected.use('/*', jwt({ secret: config.admin.jwtSecret, alg: 'HS256' }));
adminProtected.route('/', adminController.protectedRoutes);
adminProtected.route('/feedback', feedbackRouter);
adminProtected.route('/config', configRouter);
app.route('/admin/api', adminProtected);