mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-03-27 10:05:50 +00:00
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:
269
src/controllers/config.ts
Normal file
269
src/controllers/config.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user