Feature/WebUI 增加配置页面,可在页面修改配置内容 (#285)

* feat: setting-page

* fix: 调整配置分类排序

* fix: 调整配置分类排序

* fix: 修改页面文案

* fix: 未配置的选项显示空输入框

* fix: 优化默认配置项,多参数配置

* fix: 配置依赖管理

* fix: 配置依赖管理,全局单例模式

* fix: 数据更新错误问题

* fix: 空值校验

* fix: 默认展示CUSTOM_WEBHOOK_URLS配置
This commit is contained in:
Krane
2026-02-10 22:58:10 +08:00
committed by GitHub
parent b893e78c8b
commit 50b75b6db5
28 changed files with 3473 additions and 59 deletions

2
.gitignore vendored
View File

@@ -75,3 +75,5 @@ CLAUDE.md
# ignore static files
static/
/apps/dsa-desktop/dist/
/apps/dsa-desktop/node_modules/

View File

@@ -16,6 +16,7 @@ FastAPI 应用工厂模块
"""
import os
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from typing import Optional
@@ -28,6 +29,18 @@ from fastapi.responses import FileResponse
from api.v1 import api_v1_router
from api.middlewares.error_handler import add_error_handlers
from api.v1.schemas.common import RootResponse, HealthResponse
from src.services.system_config_service import SystemConfigService
@asynccontextmanager
async def app_lifespan(app: FastAPI):
"""Initialize and release shared services for the app lifecycle."""
app.state.system_config_service = SystemConfigService()
try:
yield
finally:
if hasattr(app.state, "system_config_service"):
delattr(app.state, "system_config_service")
def create_app(static_dir: Optional[Path] = None) -> FastAPI:
@@ -57,6 +70,7 @@ def create_app(static_dir: Optional[Path] = None) -> FastAPI:
"当前版本暂无认证要求"
),
version="1.0.0",
lifespan=app_lifespan,
)
# ============================================================

View File

@@ -12,10 +12,12 @@ API 依赖注入模块
from typing import Generator
from fastapi import Request
from sqlalchemy.orm import Session
from src.storage import DatabaseManager
from src.config import get_config, Config
from src.services.system_config_service import SystemConfigService
def get_db() -> Generator[Session, None, None]:
@@ -58,3 +60,12 @@ def get_database_manager() -> DatabaseManager:
DatabaseManager: 数据库管理器单例对象
"""
return DatabaseManager.get_instance()
def get_system_config_service(request: Request) -> SystemConfigService:
"""Get app-lifecycle shared SystemConfigService instance."""
service = getattr(request.app.state, "system_config_service", None)
if service is None:
service = SystemConfigService()
request.app.state.system_config_service = service
return service

View File

@@ -8,6 +8,6 @@ API v1 Endpoints 模块初始化
1. 导出所有 endpoint 路由模块
"""
from api.v1.endpoints import health, analysis, history, stocks, backtest
from api.v1.endpoints import health, analysis, history, stocks, backtest, system_config
__all__ = ["health", "analysis", "history", "stocks", "backtest"]
__all__ = ["health", "analysis", "history", "stocks", "backtest", "system_config"]

View File

@@ -0,0 +1,167 @@
# -*- coding: utf-8 -*-
"""System configuration endpoints."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from api.deps import get_system_config_service
from api.v1.schemas.common import ErrorResponse
from api.v1.schemas.system_config import (
SystemConfigConflictResponse,
SystemConfigResponse,
SystemConfigSchemaResponse,
SystemConfigValidationErrorResponse,
UpdateSystemConfigRequest,
UpdateSystemConfigResponse,
ValidateSystemConfigRequest,
ValidateSystemConfigResponse,
)
from src.services.system_config_service import ConfigConflictError, ConfigValidationError, SystemConfigService
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get(
"/config",
response_model=SystemConfigResponse,
responses={
200: {"description": "Configuration loaded"},
401: {"description": "Unauthorized", "model": ErrorResponse},
500: {"description": "Internal server error", "model": ErrorResponse},
},
summary="Get system configuration",
description="Read current configuration from .env and return raw values.",
)
def get_system_config(
include_schema: bool = Query(True, description="Whether to include schema metadata"),
service: SystemConfigService = Depends(get_system_config_service),
) -> SystemConfigResponse:
"""Load and return current system configuration."""
try:
payload = service.get_config(include_schema=include_schema)
return SystemConfigResponse.model_validate(payload)
except Exception as exc:
logger.error("Failed to load system configuration: %s", exc, exc_info=True)
raise HTTPException(
status_code=500,
detail={
"error": "internal_error",
"message": "Failed to load system configuration",
},
)
@router.put(
"/config",
response_model=UpdateSystemConfigResponse,
responses={
200: {"description": "Configuration updated"},
400: {"description": "Validation failed", "model": SystemConfigValidationErrorResponse},
409: {"description": "Version conflict", "model": SystemConfigConflictResponse},
500: {"description": "Internal server error", "model": ErrorResponse},
},
summary="Update system configuration",
description="Update key-value pairs in .env. Mask token preserves existing secret values.",
)
def update_system_config(
request: UpdateSystemConfigRequest,
service: SystemConfigService = Depends(get_system_config_service),
) -> UpdateSystemConfigResponse:
"""Validate and persist system configuration updates."""
try:
payload = service.update(
config_version=request.config_version,
items=[item.model_dump() for item in request.items],
mask_token=request.mask_token,
reload_now=request.reload_now,
)
return UpdateSystemConfigResponse.model_validate(payload)
except ConfigValidationError as exc:
raise HTTPException(
status_code=400,
detail={
"error": "validation_failed",
"message": "System configuration validation failed",
"issues": exc.issues,
},
)
except ConfigConflictError as exc:
raise HTTPException(
status_code=409,
detail={
"error": "config_version_conflict",
"message": "Configuration has changed, please reload and retry",
"current_config_version": exc.current_version,
},
)
except Exception as exc:
logger.error("Failed to update system configuration: %s", exc, exc_info=True)
raise HTTPException(
status_code=500,
detail={
"error": "internal_error",
"message": "Failed to update system configuration",
},
)
@router.post(
"/config/validate",
response_model=ValidateSystemConfigResponse,
responses={
200: {"description": "Validation completed"},
500: {"description": "Internal server error", "model": ErrorResponse},
},
summary="Validate system configuration",
description="Validate submitted configuration values without writing to .env.",
)
def validate_system_config(
request: ValidateSystemConfigRequest,
service: SystemConfigService = Depends(get_system_config_service),
) -> ValidateSystemConfigResponse:
"""Run pre-save validation only."""
try:
payload = service.validate(items=[item.model_dump() for item in request.items])
return ValidateSystemConfigResponse.model_validate(payload)
except Exception as exc:
logger.error("Failed to validate system configuration: %s", exc, exc_info=True)
raise HTTPException(
status_code=500,
detail={
"error": "internal_error",
"message": "Failed to validate system configuration",
},
)
@router.get(
"/config/schema",
response_model=SystemConfigSchemaResponse,
responses={
200: {"description": "Schema loaded"},
500: {"description": "Internal server error", "model": ErrorResponse},
},
summary="Get system configuration schema",
description="Return categorized field metadata used for dynamic settings form rendering.",
)
def get_system_config_schema(
service: SystemConfigService = Depends(get_system_config_service),
) -> SystemConfigSchemaResponse:
"""Return schema metadata for system configuration fields."""
try:
payload = service.get_schema()
return SystemConfigSchemaResponse.model_validate(payload)
except Exception as exc:
logger.error("Failed to load system configuration schema: %s", exc, exc_info=True)
raise HTTPException(
status_code=500,
detail={
"error": "internal_error",
"message": "Failed to load system configuration schema",
},
)

View File

@@ -11,7 +11,7 @@ API v1 路由聚合
from fastapi import APIRouter
from api.v1.endpoints import analysis, history, stocks, backtest
from api.v1.endpoints import analysis, history, stocks, backtest, system_config
# 创建 v1 版本主路由
router = APIRouter(prefix="/api/v1")
@@ -39,3 +39,9 @@ router.include_router(
prefix="/backtest",
tags=["Backtest"]
)
router.include_router(
system_config.router,
prefix="/system",
tags=["SystemConfig"]
)

View File

@@ -43,6 +43,21 @@ from api.v1.schemas.backtest import (
BacktestResultsResponse,
PerformanceMetrics,
)
from api.v1.schemas.system_config import (
SystemConfigFieldSchema,
SystemConfigCategorySchema,
SystemConfigSchemaResponse,
SystemConfigItem,
SystemConfigResponse,
SystemConfigUpdateItem,
UpdateSystemConfigRequest,
UpdateSystemConfigResponse,
ValidateSystemConfigRequest,
ConfigValidationIssue,
ValidateSystemConfigResponse,
SystemConfigValidationErrorResponse,
SystemConfigConflictResponse,
)
__all__ = [
# common
@@ -75,4 +90,18 @@ __all__ = [
"BacktestResultItem",
"BacktestResultsResponse",
"PerformanceMetrics",
# system config
"SystemConfigFieldSchema",
"SystemConfigCategorySchema",
"SystemConfigSchemaResponse",
"SystemConfigItem",
"SystemConfigResponse",
"SystemConfigUpdateItem",
"UpdateSystemConfigRequest",
"UpdateSystemConfigResponse",
"ValidateSystemConfigRequest",
"ConfigValidationIssue",
"ValidateSystemConfigResponse",
"SystemConfigValidationErrorResponse",
"SystemConfigConflictResponse",
]

View File

@@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
"""System configuration API schemas."""
from __future__ import annotations
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
class SystemConfigFieldSchema(BaseModel):
"""Metadata schema for a single config field."""
key: str = Field(..., description="Configuration key name")
title: Optional[str] = Field(None, description="Display title")
description: Optional[str] = Field(None, description="Field description")
category: Literal["base", "data_source", "ai_model", "notification", "system", "backtest", "uncategorized"]
data_type: Literal["string", "integer", "number", "boolean", "array", "json", "time"]
ui_control: Literal["text", "password", "number", "select", "textarea", "switch", "time"]
is_sensitive: bool
is_required: bool
is_editable: bool
default_value: Optional[str] = None
options: List[str] = Field(default_factory=list)
validation: Dict[str, Any] = Field(default_factory=dict)
display_order: int
class SystemConfigCategorySchema(BaseModel):
"""Category grouping metadata."""
category: str
title: str
description: Optional[str] = None
display_order: int
fields: List[SystemConfigFieldSchema]
class SystemConfigSchemaResponse(BaseModel):
"""Metadata response for dynamic frontend rendering."""
schema_version: str
categories: List[SystemConfigCategorySchema]
class SystemConfigItem(BaseModel):
"""Config value entry with optional schema metadata."""
model_config = ConfigDict(populate_by_name=True)
key: str
value: str
raw_value_exists: bool
is_masked: bool
schema_: Optional[SystemConfigFieldSchema] = Field(default=None, alias="schema")
class SystemConfigResponse(BaseModel):
"""Read response for current configuration values."""
config_version: str
mask_token: str
items: List[SystemConfigItem]
updated_at: Optional[str] = None
class SystemConfigUpdateItem(BaseModel):
"""Single key-value update item."""
key: str
value: str
class UpdateSystemConfigRequest(BaseModel):
"""Update request payload."""
config_version: str
mask_token: str = "******"
reload_now: bool = True
items: List[SystemConfigUpdateItem] = Field(..., min_length=1)
class UpdateSystemConfigResponse(BaseModel):
"""Update operation result payload."""
success: bool
config_version: str
applied_count: int
skipped_masked_count: int
reload_triggered: bool
updated_keys: List[str]
warnings: List[str] = Field(default_factory=list)
class ValidateSystemConfigRequest(BaseModel):
"""Validation request payload."""
items: List[SystemConfigUpdateItem] = Field(..., min_length=1)
class ConfigValidationIssue(BaseModel):
"""Validation issue details."""
key: str
code: str
message: str
severity: Literal["error", "warning"]
expected: Optional[str] = None
actual: Optional[str] = None
class ValidateSystemConfigResponse(BaseModel):
"""Validation result payload."""
valid: bool
issues: List[ConfigValidationIssue]
class SystemConfigValidationErrorResponse(BaseModel):
"""Error payload for failed update validation."""
error: str
message: str
issues: List[ConfigValidationIssue]
class SystemConfigConflictResponse(BaseModel):
"""Error payload for optimistic lock conflict."""
error: str
message: str
current_config_version: str

View File

@@ -1,42 +1,4 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
width: 100%;
min-height: 100vh;
}

View File

@@ -2,6 +2,7 @@ import type React from 'react';
import {BrowserRouter as Router, Routes, Route, NavLink} from 'react-router-dom';
import HomePage from './pages/HomePage';
import BacktestPage from './pages/BacktestPage';
import SettingsPage from './pages/SettingsPage';
import NotFoundPage from './pages/NotFoundPage';
import './App.css';
@@ -20,11 +21,11 @@ const BacktestIcon: React.FC<{ active?: boolean }> = ({active}) => (
</svg>
);
const SettingsIcon: React.FC = () => (
const SettingsIcon: React.FC<{ active?: boolean }> = ({active}) => (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={active ? 2 : 1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
);
@@ -48,6 +49,12 @@ const NAV_ITEMS: DockItem[] = [
to: '/backtest',
icon: BacktestIcon,
},
{
key: 'settings',
label: '设置',
to: '/settings',
icon: SettingsIcon,
},
];
// Dock 导航栏
@@ -80,17 +87,7 @@ const DockNav: React.FC = () => {
})}
</nav>
<div className="dock-footer">
<button
type="button"
className="dock-item is-placeholder"
title="设置(即将推出)"
aria-disabled="true"
disabled
>
<SettingsIcon/>
</button>
</div>
<div className="dock-footer"/>
</div>
</aside>
);
@@ -108,6 +105,7 @@ const App: React.FC = () => {
<Routes>
<Route path="/" element={<HomePage/>}/>
<Route path="/backtest" element={<BacktestPage/>}/>
<Route path="/settings" element={<SettingsPage/>}/>
<Route path="*" element={<NotFoundPage/>}/>
</Routes>
</main>

View File

@@ -0,0 +1,124 @@
import apiClient from './index';
import { toCamelCase } from './utils';
import type {
SystemConfigConflictResponse,
SystemConfigResponse,
SystemConfigSchemaResponse,
SystemConfigValidationErrorResponse,
UpdateSystemConfigRequest,
UpdateSystemConfigResponse,
ValidateSystemConfigRequest,
ValidateSystemConfigResponse,
} from '../types/systemConfig';
type ApiErrorPayload = {
error?: string;
message?: string;
issues?: unknown;
current_config_version?: string;
};
export class SystemConfigValidationError extends Error {
issues: SystemConfigValidationErrorResponse['issues'];
constructor(message: string, issues: SystemConfigValidationErrorResponse['issues']) {
super(message);
this.name = 'SystemConfigValidationError';
this.issues = issues;
}
}
export class SystemConfigConflictError extends Error {
currentConfigVersion?: string;
constructor(message: string, currentConfigVersion?: string) {
super(message);
this.name = 'SystemConfigConflictError';
this.currentConfigVersion = currentConfigVersion;
}
}
function toSnakeUpdatePayload(payload: UpdateSystemConfigRequest): Record<string, unknown> {
return {
config_version: payload.configVersion,
mask_token: payload.maskToken ?? '******',
reload_now: payload.reloadNow ?? true,
items: payload.items.map((item) => ({
key: item.key,
value: item.value,
})),
};
}
function toSnakeValidatePayload(payload: ValidateSystemConfigRequest): Record<string, unknown> {
return {
items: payload.items.map((item) => ({
key: item.key,
value: item.value,
})),
};
}
function extractApiMessage(error: unknown, fallback: string): string {
if (!error || typeof error !== 'object' || !('response' in error)) {
return fallback;
}
const response = (error as { response?: { data?: ApiErrorPayload } }).response;
return response?.data?.message || fallback;
}
export const systemConfigApi = {
async getConfig(includeSchema = true): Promise<SystemConfigResponse> {
const response = await apiClient.get<Record<string, unknown>>('/api/v1/system/config', {
params: { include_schema: includeSchema },
});
return toCamelCase<SystemConfigResponse>(response.data);
},
async getSchema(): Promise<SystemConfigSchemaResponse> {
const response = await apiClient.get<Record<string, unknown>>('/api/v1/system/config/schema');
return toCamelCase<SystemConfigSchemaResponse>(response.data);
},
async validate(payload: ValidateSystemConfigRequest): Promise<ValidateSystemConfigResponse> {
const response = await apiClient.post<Record<string, unknown>>(
'/api/v1/system/config/validate',
toSnakeValidatePayload(payload),
);
return toCamelCase<ValidateSystemConfigResponse>(response.data);
},
async update(payload: UpdateSystemConfigRequest): Promise<UpdateSystemConfigResponse> {
try {
const response = await apiClient.put<Record<string, unknown>>(
'/api/v1/system/config',
toSnakeUpdatePayload(payload),
);
return toCamelCase<UpdateSystemConfigResponse>(response.data);
} catch (error: unknown) {
if (error && typeof error === 'object' && 'response' in error) {
const status = (error as { response?: { status?: number } }).response?.status;
const payloadData = (error as { response?: { data?: ApiErrorPayload } }).response?.data;
if (status === 400) {
const validationError = toCamelCase<SystemConfigValidationErrorResponse>(payloadData ?? {});
throw new SystemConfigValidationError(
validationError.message || '配置校验失败',
validationError.issues || [],
);
}
if (status === 409) {
const conflict = toCamelCase<SystemConfigConflictResponse>(payloadData ?? {});
throw new SystemConfigConflictError(
conflict.message || '配置版本冲突',
conflict.currentConfigVersion,
);
}
}
throw new Error(extractApiMessage(error, '更新系统配置失败'));
}
},
};

View File

@@ -0,0 +1,37 @@
import type React from 'react';
interface SettingsAlertProps {
title: string;
message: string;
variant?: 'error' | 'success' | 'warning';
actionLabel?: string;
onAction?: () => void;
className?: string;
}
const variantStyles: Record<NonNullable<SettingsAlertProps['variant']>, string> = {
error: 'border-red-500/35 bg-red-500/10 text-red-200',
success: 'border-emerald-500/35 bg-emerald-500/10 text-emerald-200',
warning: 'border-amber-500/35 bg-amber-500/10 text-amber-200',
};
export const SettingsAlert: React.FC<SettingsAlertProps> = ({
title,
message,
variant = 'error',
actionLabel,
onAction,
className = '',
}) => {
return (
<div className={`rounded-xl border px-4 py-3 ${variantStyles[variant]} ${className}`} role="alert">
<p className="text-sm font-semibold">{title}</p>
<p className="mt-1 text-xs opacity-90">{message}</p>
{actionLabel && onAction ? (
<button type="button" className="mt-3 btn-secondary !py-1.5 !px-3 !text-xs" onClick={onAction}>
{actionLabel}
</button>
) : null}
</div>
);
};

View File

@@ -0,0 +1,236 @@
import { useState } from 'react';
import type React from 'react';
import { Select } from '../common';
import type { ConfigValidationIssue, SystemConfigItem } from '../../types/systemConfig';
import { getFieldDescriptionZh, getFieldTitleZh } from '../../utils/systemConfigI18n';
function isMultiValueField(item: SystemConfigItem): boolean {
const validation = (item.schema?.validation ?? {}) as Record<string, unknown>;
return Boolean(validation.multiValue ?? validation.multi_value);
}
function parseMultiValues(value: string): string[] {
if (!value) {
return [''];
}
const values = value.split(',').map((entry) => entry.trim());
return values.length ? values : [''];
}
function serializeMultiValues(values: string[]): string {
return values.map((entry) => entry.trim()).join(',');
}
interface SettingsFieldProps {
item: SystemConfigItem;
value: string;
disabled?: boolean;
onChange: (key: string, value: string) => void;
issues?: ConfigValidationIssue[];
}
function renderFieldControl(
item: SystemConfigItem,
value: string,
disabled: boolean,
onChange: (nextValue: string) => void,
isSecretVisible: boolean,
onToggleSecretVisible: () => void,
) {
const schema = item.schema;
const commonClass = 'input-terminal';
const controlType = schema?.uiControl ?? 'text';
const isMultiValue = isMultiValueField(item);
if (controlType === 'textarea') {
return (
<textarea
className={`${commonClass} min-h-[92px] resize-y`}
value={value}
disabled={disabled || !schema?.isEditable}
onChange={(event) => onChange(event.target.value)}
/>
);
}
if (controlType === 'select' && schema?.options?.length) {
return (
<Select
value={value}
onChange={onChange}
options={schema.options.map((option) => ({ value: option, label: option }))}
disabled={disabled || !schema.isEditable}
placeholder="请选择"
/>
);
}
if (controlType === 'switch') {
const checked = value.trim().toLowerCase() === 'true';
return (
<label className="inline-flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={checked}
disabled={disabled || !schema?.isEditable}
onChange={(event) => onChange(event.target.checked ? 'true' : 'false')}
/>
<span className="text-sm text-secondary">{checked ? '已启用' : '未启用'}</span>
</label>
);
}
if (controlType === 'password') {
if (isMultiValue) {
const values = parseMultiValues(value);
return (
<div className="space-y-2">
{values.map((entry, index) => (
<div className="flex items-center gap-2" key={`${item.key}-${index}`}>
<input
type={isSecretVisible ? 'text' : 'password'}
className={`${commonClass} flex-1`}
value={entry}
disabled={disabled || !schema?.isEditable}
onChange={(event) => {
const nextValues = [...values];
nextValues[index] = event.target.value;
onChange(serializeMultiValues(nextValues));
}}
/>
<button
type="button"
className="btn-secondary !px-3 !py-2 text-xs"
disabled={disabled || !schema?.isEditable || values.length <= 1}
onClick={() => {
const nextValues = values.filter((_, rowIndex) => rowIndex !== index);
onChange(serializeMultiValues(nextValues.length ? nextValues : ['']));
}}
>
</button>
</div>
))}
<div className="flex items-center gap-2">
<button
type="button"
className="btn-secondary !px-3 !py-2 text-xs"
disabled={disabled || !schema?.isEditable}
onClick={() => onChange(serializeMultiValues([...values, '']))}
>
Key
</button>
<button
type="button"
className="btn-secondary !px-3 !py-2 text-xs"
disabled={disabled || !schema?.isEditable}
onClick={onToggleSecretVisible}
>
{isSecretVisible ? '隐藏' : '显示'}
</button>
</div>
</div>
);
}
return (
<div className="flex items-center gap-2">
<input
type={isSecretVisible ? 'text' : 'password'}
className={`${commonClass} flex-1`}
value={value}
disabled={disabled || !schema?.isEditable}
onChange={(event) => onChange(event.target.value)}
/>
<button
type="button"
className="btn-secondary !px-3 !py-2 text-xs"
disabled={disabled || !schema?.isEditable}
onClick={onToggleSecretVisible}
>
{isSecretVisible ? '隐藏' : '显示'}
</button>
</div>
);
}
const inputType = controlType === 'number' ? 'number' : controlType === 'time' ? 'time' : 'text';
return (
<input
type={inputType}
className={commonClass}
value={value}
disabled={disabled || !schema?.isEditable}
onChange={(event) => onChange(event.target.value)}
/>
);
}
export const SettingsField: React.FC<SettingsFieldProps> = ({
item,
value,
disabled = false,
onChange,
issues = [],
}) => {
const schema = item.schema;
const isMultiValue = isMultiValueField(item);
const title = getFieldTitleZh(item.key, item.key);
const description = getFieldDescriptionZh(item.key);
const hasError = issues.some((issue) => issue.severity === 'error');
const [isSecretVisible, setIsSecretVisible] = useState(false);
return (
<div className={`rounded-xl border p-4 ${hasError ? 'border-red-500/35' : 'border-white/8'} bg-elevated/50`}>
<div className="mb-2 flex items-center gap-2">
<label className="text-sm font-semibold text-white" htmlFor={`setting-${item.key}`}>
{title}
</label>
{schema?.isSensitive ? (
<span className="badge badge-purple text-[10px]"></span>
) : null}
</div>
{description ? (
<p className="mb-3 text-xs text-muted" title={description}>
{description}
</p>
) : null}
<div id={`setting-${item.key}`}>
{renderFieldControl(
item,
value,
disabled,
(nextValue) => onChange(item.key, nextValue),
isSecretVisible,
() => setIsSecretVisible((previous) => !previous),
)}
</div>
{schema?.isSensitive ? (
<p className="mt-2 text-[11px] text-secondary">
{isMultiValue ? ' 支持添加多个输入框进行增删。' : ''}
</p>
) : null}
{issues.length ? (
<div className="mt-2 space-y-1">
{issues.map((issue, index) => (
<p
key={`${issue.code}-${issue.key}-${index}`}
className={issue.severity === 'error' ? 'text-xs text-danger' : 'text-xs text-warning'}
>
{issue.message}
</p>
))}
</div>
) : null}
</div>
);
};

View File

@@ -0,0 +1,14 @@
import type React from 'react';
export const SettingsLoading: React.FC = () => {
return (
<div className="space-y-4 animate-fade-in">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="rounded-xl border border-white/8 bg-elevated/60 p-4">
<div className="h-3 w-32 rounded bg-white/10" />
<div className="mt-3 h-10 rounded-lg bg-white/6" />
</div>
))}
</div>
);
};

View File

@@ -0,0 +1,3 @@
export * from './SettingsAlert';
export * from './SettingsField';
export * from './SettingsLoading';

View File

@@ -1,4 +1,5 @@
export { useTaskStream } from './useTaskStream';
export { useSystemConfig } from './useSystemConfig';
export type {
SSEEventType,
SSEEvent,

View File

@@ -0,0 +1,353 @@
import { useCallback, useMemo, useState } from 'react';
import { systemConfigApi, SystemConfigConflictError, SystemConfigValidationError } from '../api/systemConfig';
import type {
ConfigValidationIssue,
SystemConfigCategorySchema,
SystemConfigItem,
SystemConfigUpdateItem,
} from '../types/systemConfig';
type ToastState = {
type: 'success' | 'error';
message: string;
} | null;
type RetryAction = 'load' | 'save' | null;
type SaveResult = {
success: boolean;
message?: string;
issues?: ConfigValidationIssue[];
};
const CATEGORY_DISPLAY_ORDER: Record<string, number> = {
base: 10,
ai_model: 20,
data_source: 30,
notification: 40,
system: 50,
backtest: 60,
uncategorized: 99,
};
function sortItemsByOrder(items: SystemConfigItem[]): SystemConfigItem[] {
return [...items].sort((a, b) => {
const left = a.schema?.displayOrder ?? 9999;
const right = b.schema?.displayOrder ?? 9999;
if (left !== right) {
return left - right;
}
return a.key.localeCompare(b.key);
});
}
function getReadableError(error: unknown, fallback: string): string {
if (error instanceof Error && error.message) {
return error.message;
}
return fallback;
}
function isMultiValueSchema(schema: SystemConfigItem['schema'] | undefined): boolean {
const validation = (schema?.validation ?? {}) as Record<string, unknown>;
return Boolean(validation.multiValue ?? validation.multi_value);
}
function normalizeFieldValue(value: string, schema: SystemConfigItem['schema'] | undefined): string {
if (!isMultiValueSchema(schema)) {
return value;
}
return value
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
.join(',');
}
export function useSystemConfig() {
// Server state
const [configVersion, setConfigVersion] = useState<string>('');
const [maskToken, setMaskToken] = useState<string>('******');
const [serverItems, setServerItems] = useState<SystemConfigItem[]>([]);
// UI state
const [draftValues, setDraftValues] = useState<Record<string, string>>({});
const [activeCategory, setActiveCategory] = useState<string>('base');
const [validationIssues, setValidationIssues] = useState<ConfigValidationIssue[]>([]);
const [toast, setToast] = useState<ToastState>(null);
// Request state
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [retryAction, setRetryAction] = useState<RetryAction>(null);
const mergedItems = useMemo(() => {
return sortItemsByOrder(
serverItems.map((item) => ({
...item,
value: draftValues[item.key] ?? item.value,
})),
);
}, [draftValues, serverItems]);
const serverItemByKey = useMemo(() => {
const map: Record<string, SystemConfigItem> = {};
for (const item of serverItems) {
map[item.key] = item;
}
return map;
}, [serverItems]);
const categories = useMemo<SystemConfigCategorySchema[]>(() => {
// Infer tabs from loaded config item schema metadata.
const categoryMap = new Map<string, SystemConfigCategorySchema>();
for (const item of mergedItems) {
if (!item.schema) {
continue;
}
const category = item.schema.category;
if (!categoryMap.has(category)) {
categoryMap.set(category, {
category,
title: category.replace('_', ' ').replace(/\b\w/g, (char) => char.toUpperCase()),
description: '',
displayOrder: CATEGORY_DISPLAY_ORDER[category] ?? 999,
fields: [],
});
}
categoryMap.get(category)?.fields.push(item.schema);
}
return [...categoryMap.values()].sort((a, b) => a.displayOrder - b.displayOrder);
}, [mergedItems]);
const itemsByCategory = useMemo(() => {
const map: Record<string, SystemConfigItem[]> = {};
for (const item of mergedItems) {
const category = item.schema?.category ?? 'uncategorized';
if (!map[category]) {
map[category] = [];
}
map[category].push(item);
}
return map;
}, [mergedItems]);
const dirtyKeys = useMemo(() => {
const keys: string[] = [];
for (const item of serverItems) {
const draftRaw = draftValues[item.key];
if (draftRaw === undefined) {
continue;
}
const normalizedDraft = normalizeFieldValue(draftRaw, item.schema);
const normalizedCurrent = normalizeFieldValue(item.value, item.schema);
if (normalizedDraft !== normalizedCurrent) {
keys.push(item.key);
}
}
return keys;
}, [draftValues, serverItems]);
const hasDirty = dirtyKeys.length > 0;
const issueByKey = useMemo(() => {
const map: Record<string, ConfigValidationIssue[]> = {};
for (const issue of validationIssues) {
if (!map[issue.key]) {
map[issue.key] = [];
}
map[issue.key].push(issue);
}
return map;
}, [validationIssues]);
const applyServerPayload = useCallback(
(items: SystemConfigItem[], version: string, token: string) => {
const sorted = sortItemsByOrder(items);
setServerItems(sorted);
setConfigVersion(version);
setMaskToken(token || '******');
const draft: Record<string, string> = {};
for (const item of sorted) {
draft[item.key] = item.value;
}
setDraftValues(draft);
const defaultCategory = sorted[0]?.schema?.category || 'base';
setActiveCategory((current) => {
const exists = sorted.some((item) => item.schema?.category === current);
return exists ? current : defaultCategory;
});
setValidationIssues([]);
},
[],
);
const load = useCallback(async () => {
setIsLoading(true);
setLoadError(null);
setRetryAction(null);
try {
const config = await systemConfigApi.getConfig(true);
applyServerPayload(config.items, config.configVersion, config.maskToken);
setToast(null);
} catch (error: unknown) {
setLoadError(getReadableError(error, '加载系统配置失败'));
setRetryAction('load');
} finally {
setIsLoading(false);
}
}, [applyServerPayload]);
const resetDraft = useCallback(() => {
const next: Record<string, string> = {};
for (const item of serverItems) {
next[item.key] = item.value;
}
setDraftValues(next);
setValidationIssues([]);
setSaveError(null);
}, [serverItems]);
const setDraftValue = useCallback((key: string, value: string) => {
setDraftValues((previous) => ({
...previous,
[key]: value,
}));
}, []);
const getChangedItems = useCallback((): SystemConfigUpdateItem[] => {
return dirtyKeys
.map((key) => {
const serverItem = serverItemByKey[key];
const normalizedValue = normalizeFieldValue(draftValues[key] ?? '', serverItem?.schema);
return {
key,
value: normalizedValue,
};
})
.filter((item) => {
const serverItem = serverItemByKey[item.key];
const normalizedCurrent = normalizeFieldValue(serverItem?.value ?? '', serverItem?.schema);
return item.value !== normalizedCurrent;
});
}, [dirtyKeys, draftValues, serverItemByKey]);
const save = useCallback(async (): Promise<SaveResult> => {
if (!hasDirty) {
setToast({ type: 'success', message: '当前没有可保存的修改。' });
return { success: true, message: '当前没有可保存的修改' };
}
setIsSaving(true);
setSaveError(null);
setRetryAction(null);
const changedItems = getChangedItems();
try {
const validateResult = await systemConfigApi.validate({ items: changedItems });
setValidationIssues(validateResult.issues || []);
if (!validateResult.valid) {
setSaveError('配置校验未通过,请先修正表单错误。');
setRetryAction('save');
return {
success: false,
message: '配置校验未通过',
issues: validateResult.issues,
};
}
const updateResult = await systemConfigApi.update({
configVersion,
maskToken,
reloadNow: true,
items: changedItems,
});
const refreshed = await systemConfigApi.getConfig(true);
applyServerPayload(refreshed.items, refreshed.configVersion, refreshed.maskToken);
const warningText = updateResult.warnings?.length
? `;警告:${updateResult.warnings.join('')}`
: '';
setToast({ type: 'success', message: `配置已更新${warningText}` });
return { success: true };
} catch (error: unknown) {
if (error instanceof SystemConfigValidationError) {
setValidationIssues(error.issues);
setSaveError(error.message || '配置校验失败');
} else if (error instanceof SystemConfigConflictError) {
setSaveError(`${error.message},请先重新加载配置。`);
} else {
setSaveError(getReadableError(error, '保存配置失败'));
}
setToast({ type: 'error', message: '配置保存失败。' });
setRetryAction('save');
return { success: false, message: '保存失败' };
} finally {
setIsSaving(false);
}
}, [
applyServerPayload,
configVersion,
getChangedItems,
hasDirty,
maskToken,
]);
const retry = useCallback(async () => {
if (retryAction === 'load') {
await load();
return;
}
if (retryAction === 'save') {
await save();
}
}, [load, retryAction, save]);
const clearToast = useCallback(() => {
setToast(null);
}, []);
return {
// Server state
configVersion,
serverItems,
categories,
itemsByCategory,
issueByKey,
// UI state
activeCategory,
setActiveCategory,
hasDirty,
dirtyCount: dirtyKeys.length,
toast,
clearToast,
// Request state
isLoading,
isSaving,
loadError,
saveError,
retryAction,
// Actions
load,
retry,
save,
resetDraft,
setDraftValue,
};
}

View File

@@ -0,0 +1,165 @@
import type React from 'react';
import { useEffect } from 'react';
import { useSystemConfig } from '../hooks';
import { SettingsAlert, SettingsField, SettingsLoading } from '../components/settings';
import { getCategoryDescriptionZh, getCategoryTitleZh } from '../utils/systemConfigI18n';
const SettingsPage: React.FC = () => {
const {
categories,
itemsByCategory,
issueByKey,
activeCategory,
setActiveCategory,
hasDirty,
dirtyCount,
toast,
clearToast,
isLoading,
isSaving,
loadError,
saveError,
retryAction,
load,
retry,
save,
setDraftValue,
} = useSystemConfig();
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (!toast) {
return;
}
const timer = window.setTimeout(() => {
clearToast();
}, 3200);
return () => {
window.clearTimeout(timer);
};
}, [clearToast, toast]);
const activeItems = itemsByCategory[activeCategory] || [];
return (
<div className="min-h-screen px-4 pb-6 pt-4 md:px-6">
<header className="mb-4 rounded-2xl border border-white/8 bg-card/80 p-4 backdrop-blur-sm">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-xl font-semibold text-white"></h1>
<p className="text-sm text-secondary">
使 .env
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<button type="button" className="btn-secondary" onClick={() => void load()} disabled={isLoading || isSaving}>
</button>
<button
type="button"
className="btn-primary"
onClick={() => void save()}
disabled={!hasDirty || isSaving || isLoading}
>
{isSaving ? '保存中...' : `保存配置${dirtyCount ? ` (${dirtyCount})` : ''}`}
</button>
</div>
</div>
{saveError ? (
<SettingsAlert
className="mt-3"
title="保存失败"
message={saveError}
actionLabel={retryAction === 'save' ? '重试保存' : undefined}
onAction={retryAction === 'save' ? () => void retry() : undefined}
/>
) : null}
</header>
{loadError ? (
<SettingsAlert
title="加载设置失败"
message={loadError}
actionLabel={retryAction === 'load' ? '重试加载' : '重新加载'}
onAction={() => void retry()}
className="mb-4"
/>
) : null}
{isLoading ? (
<SettingsLoading />
) : (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[260px_1fr]">
<aside className="rounded-2xl border border-white/8 bg-card/60 p-3 backdrop-blur-sm">
<p className="mb-2 text-xs uppercase tracking-wide text-muted"></p>
<div className="space-y-2">
{categories.map((category) => {
const isActive = category.category === activeCategory;
const count = (itemsByCategory[category.category] || []).length;
const title = getCategoryTitleZh(category.category, category.title);
const description = getCategoryDescriptionZh(category.category, category.description);
return (
<button
key={category.category}
type="button"
className={`w-full rounded-lg border px-3 py-2 text-left transition ${
isActive
? 'border-accent bg-cyan/10 text-white'
: 'border-white/8 bg-elevated/40 text-secondary hover:border-white/16 hover:text-white'
}`}
onClick={() => setActiveCategory(category.category)}
>
<span className="flex items-center justify-between text-sm font-medium">
{title}
<span className="text-xs text-muted">{count}</span>
</span>
{description ? <span className="mt-1 block text-xs text-muted">{description}</span> : null}
</button>
);
})}
</div>
</aside>
<section className="space-y-3 rounded-2xl border border-white/8 bg-card/60 p-4 backdrop-blur-sm">
{activeItems.length ? (
activeItems.map((item) => (
<SettingsField
key={item.key}
item={item}
value={item.value}
disabled={isSaving}
onChange={setDraftValue}
issues={issueByKey[item.key] || []}
/>
))
) : (
<div className="rounded-xl border border-white/8 bg-elevated/40 p-5 text-sm text-secondary">
</div>
)}
</section>
</div>
)}
{toast ? (
<div className="fixed bottom-5 right-5 z-50 w-[320px] max-w-[calc(100vw-24px)]">
<SettingsAlert
title={toast.type === 'success' ? '操作成功' : '操作失败'}
message={toast.message}
variant={toast.type === 'success' ? 'success' : 'error'}
/>
</div>
) : null}
</div>
);
};
export default SettingsPage;

View File

@@ -0,0 +1,122 @@
export type SystemConfigCategory =
| 'base'
| 'data_source'
| 'ai_model'
| 'notification'
| 'system'
| 'backtest'
| 'uncategorized';
export type SystemConfigDataType =
| 'string'
| 'integer'
| 'number'
| 'boolean'
| 'array'
| 'json'
| 'time';
export type SystemConfigUIControl =
| 'text'
| 'password'
| 'number'
| 'select'
| 'textarea'
| 'switch'
| 'time';
export interface SystemConfigFieldSchema {
key: string;
title?: string;
description?: string;
category: SystemConfigCategory;
dataType: SystemConfigDataType;
uiControl: SystemConfigUIControl;
isSensitive: boolean;
isRequired: boolean;
isEditable: boolean;
defaultValue?: string | null;
options: string[];
validation: Record<string, unknown>;
displayOrder: number;
}
export interface SystemConfigCategorySchema {
category: SystemConfigCategory;
title: string;
description?: string;
displayOrder: number;
fields: SystemConfigFieldSchema[];
}
export interface SystemConfigSchemaResponse {
schemaVersion: string;
categories: SystemConfigCategorySchema[];
}
export interface SystemConfigItem {
key: string;
value: string;
rawValueExists: boolean;
isMasked: boolean;
schema?: SystemConfigFieldSchema;
}
export interface SystemConfigResponse {
configVersion: string;
maskToken: string;
items: SystemConfigItem[];
updatedAt?: string;
}
export interface SystemConfigUpdateItem {
key: string;
value: string;
}
export interface UpdateSystemConfigRequest {
configVersion: string;
maskToken?: string;
reloadNow?: boolean;
items: SystemConfigUpdateItem[];
}
export interface UpdateSystemConfigResponse {
success: boolean;
configVersion: string;
appliedCount: number;
skippedMaskedCount: number;
reloadTriggered: boolean;
updatedKeys: string[];
warnings: string[];
}
export interface ValidateSystemConfigRequest {
items: SystemConfigUpdateItem[];
}
export interface ConfigValidationIssue {
key: string;
code: string;
message: string;
severity: 'error' | 'warning';
expected?: string;
actual?: string;
}
export interface ValidateSystemConfigResponse {
valid: boolean;
issues: ConfigValidationIssue[];
}
export interface SystemConfigValidationErrorResponse {
error: string;
message: string;
issues: ConfigValidationIssue[];
}
export interface SystemConfigConflictResponse {
error: string;
message: string;
currentConfigVersion: string;
}

View File

@@ -0,0 +1,93 @@
import type { SystemConfigCategory } from '../types/systemConfig';
const categoryTitleMap: Record<SystemConfigCategory, string> = {
base: '基础设置',
data_source: '数据源',
ai_model: 'AI 模型',
notification: '通知渠道',
system: '系统设置',
backtest: '回测配置',
uncategorized: '未分类',
};
const categoryDescriptionMap: Partial<Record<SystemConfigCategory, string>> = {
base: '管理自选股与基础运行参数。',
data_source: '管理行情数据源与优先级策略。',
ai_model: '管理模型供应商、模型名称与推理参数。',
notification: '管理机器人、Webhook 和消息推送配置。',
system: '管理调度、日志、端口等系统级参数。',
backtest: '管理回测开关、评估窗口和引擎参数。',
uncategorized: '暂未归类的配置项。',
};
const fieldTitleMap: Record<string, string> = {
STOCK_LIST: '自选股列表',
TUSHARE_TOKEN: 'Tushare Token',
TAVILY_API_KEYS: 'Tavily API Keys',
SERPAPI_API_KEYS: 'SerpAPI API Keys',
BRAVE_API_KEYS: 'Brave API Keys',
REALTIME_SOURCE_PRIORITY: '实时数据源优先级',
GEMINI_API_KEY: 'Gemini API Key',
GEMINI_MODEL: 'Gemini 模型',
GEMINI_TEMPERATURE: 'Gemini 温度参数',
OPENAI_API_KEY: 'OpenAI API Key',
OPENAI_BASE_URL: 'OpenAI Base URL',
OPENAI_MODEL: 'OpenAI 模型',
WECHAT_WEBHOOK_URL: '企业微信 Webhook',
DINGTALK_APP_KEY: '钉钉 App Key',
DINGTALK_APP_SECRET: '钉钉 App Secret',
PUSHPLUS_TOKEN: 'PushPlus Token',
SCHEDULE_TIME: '定时任务时间',
HTTP_PROXY: 'HTTP 代理',
LOG_LEVEL: '日志级别',
WEBUI_PORT: 'WebUI 端口',
BACKTEST_ENABLED: '启用回测',
BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口(交易日)',
BACKTEST_MIN_AGE_DAYS: '回测最小历史天数',
BACKTEST_ENGINE_VERSION: '回测引擎版本',
BACKTEST_NEUTRAL_BAND_PCT: '回测中性区间阈值(%',
};
const fieldDescriptionMap: Record<string, string> = {
STOCK_LIST: '使用逗号分隔股票代码例如600519,300750。',
TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。',
TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。',
SERPAPI_API_KEYS: '用于新闻检索的 SerpAPI 密钥,支持逗号分隔多个。',
BRAVE_API_KEYS: '用于新闻检索的 Brave Search 密钥,支持逗号分隔多个。',
REALTIME_SOURCE_PRIORITY: '按逗号分隔填写数据源调用优先级。',
GEMINI_API_KEY: '用于 Gemini 服务调用的密钥。',
GEMINI_MODEL: '设置 Gemini 分析模型名称。',
GEMINI_TEMPERATURE: '控制模型输出随机性,范围通常为 0.0 到 2.0。',
OPENAI_API_KEY: '用于 OpenAI 兼容服务调用的密钥。',
OPENAI_BASE_URL: 'OpenAI 兼容 API 地址,例如 https://api.deepseek.com/v1。',
OPENAI_MODEL: 'OpenAI 兼容模型名称,例如 gpt-4o-mini、deepseek-chat。',
WECHAT_WEBHOOK_URL: '企业微信机器人 Webhook 地址。',
DINGTALK_APP_KEY: '钉钉应用模式 App Key。',
DINGTALK_APP_SECRET: '钉钉应用模式 App Secret。',
PUSHPLUS_TOKEN: 'PushPlus 推送令牌。',
SCHEDULE_TIME: '每日定时任务执行时间,格式为 HH:MM。',
HTTP_PROXY: '网络代理地址,可留空。',
LOG_LEVEL: '设置日志输出级别。',
WEBUI_PORT: 'Web 页面服务监听端口。',
BACKTEST_ENABLED: '是否启用回测功能true/false。',
BACKTEST_EVAL_WINDOW_DAYS: '回测评估窗口长度,单位为交易日。',
BACKTEST_MIN_AGE_DAYS: '仅回测早于该天数的分析记录。',
BACKTEST_ENGINE_VERSION: '回测引擎版本标识,用于区分结果版本。',
BACKTEST_NEUTRAL_BAND_PCT: '中性区间阈值百分比,例如 2 表示 -2%~+2%。',
};
export function getCategoryTitleZh(category: SystemConfigCategory, fallback?: string): string {
return categoryTitleMap[category] || fallback || category;
}
export function getCategoryDescriptionZh(category: SystemConfigCategory, fallback?: string): string {
return categoryDescriptionMap[category] || fallback || '';
}
export function getFieldTitleZh(key: string, fallback?: string): string {
return fieldTitleMap[key] || fallback || key;
}
export function getFieldDescriptionZh(key: string, fallback?: string): string {
return fieldDescriptionMap[key] || fallback || '';
}

View File

@@ -26,6 +26,10 @@
{
"name": "History",
"description": "历史记录相关接口"
},
{
"name": "SystemConfig",
"description": "系统配置管理接口"
}
],
"paths": {
@@ -379,6 +383,193 @@
}
}
}
},
"/api/v1/system/config": {
"get": {
"tags": [
"SystemConfig"
],
"summary": "获取系统配置",
"description": "读取当前系统配置并返回分类后的字段列表。敏感字段返回真实值,前端自行控制显示/隐藏。",
"operationId": "getSystemConfig",
"parameters": [
{
"name": "include_schema",
"in": "query",
"description": "是否携带字段元数据(默认 true",
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "配置读取成功",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SystemConfigResponse"
}
}
}
},
"401": {
"description": "未认证",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "读取失败",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
},
"put": {
"tags": [
"SystemConfig"
],
"summary": "更新系统配置",
"description": "按键更新系统配置。若字段值为掩码(如 ******),则保留原值不覆盖。",
"operationId": "updateSystemConfig",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateSystemConfigRequest"
}
}
}
},
"responses": {
"200": {
"description": "更新成功",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateSystemConfigResponse"
}
}
}
},
"400": {
"description": "参数校验失败",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SystemConfigValidationErrorResponse"
}
}
}
},
"409": {
"description": "配置版本冲突",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SystemConfigConflictResponse"
}
}
}
},
"500": {
"description": "更新失败",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/api/v1/system/config/validate": {
"post": {
"tags": [
"SystemConfig"
],
"summary": "校验配置",
"description": "仅校验提交内容,不写入 .env。用于前端保存前预检查。",
"operationId": "validateSystemConfig",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidateSystemConfigRequest"
}
}
}
},
"responses": {
"200": {
"description": "校验完成",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidateSystemConfigResponse"
}
}
}
},
"500": {
"description": "校验失败",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/api/v1/system/config/schema": {
"get": {
"tags": [
"SystemConfig"
],
"summary": "获取配置字段元数据",
"description": "返回配置分类、字段类型、校验规则和 UI 控件建议,用于前端动态渲染。",
"operationId": "getSystemConfigSchema",
"responses": {
"200": {
"description": "元数据读取成功",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SystemConfigSchemaResponse"
}
}
}
},
"500": {
"description": "元数据读取失败",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
}
},
"components": {
@@ -935,6 +1126,412 @@
"current_price"
]
},
"SystemConfigFieldSchema": {
"type": "object",
"description": "系统配置字段元数据",
"properties": {
"key": {
"type": "string",
"description": "配置键名ENV 变量)",
"example": "GEMINI_API_KEY"
},
"title": {
"type": "string",
"description": "前端展示标题",
"example": "Gemini API Key"
},
"description": {
"type": "string",
"description": "字段说明"
},
"category": {
"type": "string",
"enum": [
"base",
"data_source",
"ai_model",
"notification",
"system",
"backtest",
"uncategorized"
],
"description": "分类"
},
"data_type": {
"type": "string",
"enum": [
"string",
"integer",
"number",
"boolean",
"array",
"json",
"time"
]
},
"ui_control": {
"type": "string",
"enum": [
"text",
"password",
"number",
"select",
"textarea",
"switch",
"time"
],
"description": "前端控件建议"
},
"is_sensitive": {
"type": "boolean"
},
"is_required": {
"type": "boolean"
},
"is_editable": {
"type": "boolean"
},
"default_value": {
"type": "string",
"nullable": true
},
"options": {
"type": "array",
"items": {
"type": "string"
},
"description": "可选值列表(用于 select"
},
"validation": {
"type": "object",
"description": "校验规则定义min/max/regex/enum/custom"
},
"display_order": {
"type": "integer"
}
},
"required": [
"key",
"category",
"data_type",
"ui_control",
"is_sensitive",
"is_required",
"is_editable",
"display_order"
]
},
"SystemConfigCategorySchema": {
"type": "object",
"description": "配置分类元数据",
"properties": {
"category": {
"type": "string"
},
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"display_order": {
"type": "integer"
},
"fields": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SystemConfigFieldSchema"
}
}
},
"required": [
"category",
"title",
"display_order",
"fields"
]
},
"SystemConfigSchemaResponse": {
"type": "object",
"description": "配置元数据响应",
"properties": {
"schema_version": {
"type": "string",
"example": "2026-02-09"
},
"categories": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SystemConfigCategorySchema"
}
}
},
"required": [
"schema_version",
"categories"
]
},
"SystemConfigItem": {
"type": "object",
"description": "系统配置项(含脱敏显示值)",
"properties": {
"key": {
"type": "string"
},
"value": {
"type": "string",
"description": "当前配置值(敏感字段返回真实值)"
},
"raw_value_exists": {
"type": "boolean",
"description": "敏感字段是否存在真实值(不返回真实值)"
},
"is_masked": {
"type": "boolean"
},
"schema": {
"$ref": "#/components/schemas/SystemConfigFieldSchema"
}
},
"required": [
"key",
"value",
"raw_value_exists",
"is_masked"
]
},
"SystemConfigResponse": {
"type": "object",
"description": "系统配置读取响应",
"properties": {
"config_version": {
"type": "string",
"description": "配置版本(用于乐观锁)",
"example": "2026-02-09T13:20:31Z:sha256:4f9a..."
},
"mask_token": {
"type": "string",
"example": "******"
},
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SystemConfigItem"
}
},
"updated_at": {
"type": "string",
"format": "date-time"
}
},
"required": [
"config_version",
"mask_token",
"items"
]
},
"SystemConfigUpdateItem": {
"type": "object",
"description": "配置更新项",
"properties": {
"key": {
"type": "string",
"example": "STOCK_LIST"
},
"value": {
"type": "string",
"description": "字段新值;若敏感字段传入掩码 token 则表示保持原值"
}
},
"required": [
"key",
"value"
]
},
"UpdateSystemConfigRequest": {
"type": "object",
"description": "系统配置更新请求",
"properties": {
"config_version": {
"type": "string"
},
"mask_token": {
"type": "string",
"default": "******"
},
"reload_now": {
"type": "boolean",
"default": true
},
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SystemConfigUpdateItem"
},
"minItems": 1
}
},
"required": [
"config_version",
"items"
]
},
"UpdateSystemConfigResponse": {
"type": "object",
"description": "系统配置更新结果",
"properties": {
"success": {
"type": "boolean"
},
"config_version": {
"type": "string"
},
"applied_count": {
"type": "integer"
},
"skipped_masked_count": {
"type": "integer"
},
"reload_triggered": {
"type": "boolean"
},
"updated_keys": {
"type": "array",
"items": {
"type": "string"
}
},
"warnings": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"success",
"config_version",
"applied_count",
"skipped_masked_count",
"reload_triggered",
"updated_keys"
]
},
"ValidateSystemConfigRequest": {
"type": "object",
"description": "配置校验请求",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SystemConfigUpdateItem"
},
"minItems": 1
}
},
"required": [
"items"
]
},
"ConfigValidationIssue": {
"type": "object",
"description": "配置校验问题",
"properties": {
"key": {
"type": "string"
},
"code": {
"type": "string",
"example": "invalid_format"
},
"message": {
"type": "string"
},
"severity": {
"type": "string",
"enum": [
"error",
"warning"
]
},
"expected": {
"type": "string"
},
"actual": {
"type": "string"
}
},
"required": [
"key",
"code",
"message",
"severity"
]
},
"ValidateSystemConfigResponse": {
"type": "object",
"description": "配置校验结果",
"properties": {
"valid": {
"type": "boolean"
},
"issues": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ConfigValidationIssue"
}
}
},
"required": [
"valid",
"issues"
]
},
"SystemConfigValidationErrorResponse": {
"type": "object",
"description": "配置更新校验失败响应",
"properties": {
"error": {
"type": "string",
"example": "validation_failed"
},
"message": {
"type": "string"
},
"issues": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ConfigValidationIssue"
}
}
},
"required": [
"error",
"message",
"issues"
]
},
"SystemConfigConflictResponse": {
"type": "object",
"description": "配置版本冲突响应",
"properties": {
"error": {
"type": "string",
"example": "config_version_conflict"
},
"message": {
"type": "string"
},
"current_config_version": {
"type": "string"
}
},
"required": [
"error",
"message",
"current_config_version"
]
},
"ErrorResponse": {
"type": "object",
"properties": {

View File

@@ -17,15 +17,23 @@ from dotenv import load_dotenv, dotenv_values
from dataclasses import dataclass, field
def setup_env():
"""初始化环境变量(支持从 .env 加载)"""
def setup_env(override: bool = False):
"""
Initialize environment variables from .env file.
Args:
override: If True, overwrite existing environment variables with values
from .env file. Set to True when reloading config after updates.
Default is False to preserve behavior on initial load where
system environment variables take precedence.
"""
# src/config.py -> src/ -> root
env_file = os.getenv("ENV_FILE")
if env_file:
env_path = Path(env_file)
else:
env_path = Path(__file__).parent.parent / '.env'
load_dotenv(dotenv_path=env_path)
load_dotenv(dotenv_path=env_path, override=override)
@dataclass

147
src/core/config_manager.py Normal file
View File

@@ -0,0 +1,147 @@
"""Configuration file manager with atomic read/write behavior."""
from __future__ import annotations
import hashlib
import os
import re
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Set, Tuple
from dotenv import dotenv_values
_ASSIGNMENT_PATTERN = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$")
class ConfigManager:
"""Manage `.env` read/write operations with optimistic versioning."""
def __init__(self, env_path: Optional[Path] = None):
self._env_path = env_path or self._resolve_env_path()
self._lock = threading.RLock()
@property
def env_path(self) -> Path:
"""Return active `.env` path."""
return self._env_path
def read_config_map(self) -> Dict[str, str]:
"""Read key-value mapping from `.env` file."""
if not self._env_path.exists():
return {}
values = dotenv_values(self._env_path)
return {
str(key): "" if value is None else str(value)
for key, value in values.items()
if key is not None
}
def get_config_version(self) -> str:
"""Return deterministic version string based on file state."""
if not self._env_path.exists():
return "missing:0"
content = self._env_path.read_bytes()
file_stat = self._env_path.stat()
content_hash = hashlib.sha256(content).hexdigest()
return f"{file_stat.st_mtime_ns}:{content_hash}"
def get_updated_at(self) -> Optional[str]:
"""Return `.env` last update time in ISO8601 format."""
if not self._env_path.exists():
return None
file_stat = self._env_path.stat()
updated_at = datetime.fromtimestamp(file_stat.st_mtime, tz=timezone.utc)
return updated_at.isoformat()
def apply_updates(
self,
updates: Iterable[Tuple[str, str]],
sensitive_keys: Set[str],
mask_token: str,
) -> Tuple[List[str], List[str], str]:
"""Apply updates into `.env` file using atomic replace semantics."""
with self._lock:
current_values = self.read_config_map()
mutable_updates: Dict[str, str] = {}
skipped_masked: List[str] = []
for key, value in updates:
key_upper = key.upper()
current_value = current_values.get(key_upper)
if key_upper in sensitive_keys and value == mask_token:
if current_value not in (None, ""):
skipped_masked.append(key_upper)
continue
if current_value == value:
continue
mutable_updates[key_upper] = value
if mutable_updates:
self._atomic_upsert(mutable_updates)
return list(mutable_updates.keys()), skipped_masked, self.get_config_version()
def _atomic_upsert(self, updates: Dict[str, str]) -> None:
"""Write updates with temp file + fsync + rename strategy."""
lines = self._read_lines()
key_to_index = self._find_last_key_indexes(lines)
for key, value in updates.items():
line_value = value.replace("\n", "")
new_line = f"{key}={line_value}"
if key in key_to_index:
lines[key_to_index[key]] = new_line
else:
lines.append(new_line)
if not self._env_path.parent.exists():
self._env_path.parent.mkdir(parents=True, exist_ok=True)
temp_path = self._env_path.with_suffix(self._env_path.suffix + ".tmp")
content = "\n".join(lines)
if content and not content.endswith("\n"):
content += "\n"
with temp_path.open("w", encoding="utf-8", newline="\n") as file_obj:
file_obj.write(content)
file_obj.flush()
os.fsync(file_obj.fileno())
os.replace(temp_path, self._env_path)
def _read_lines(self) -> List[str]:
if not self._env_path.exists():
return []
return self._env_path.read_text(encoding="utf-8").splitlines()
@staticmethod
def _find_last_key_indexes(lines: List[str]) -> Dict[str, int]:
key_to_index: Dict[str, int] = {}
for index, raw_line in enumerate(lines):
stripped = raw_line.strip()
if not stripped or stripped.startswith("#"):
continue
matched = _ASSIGNMENT_PATTERN.match(raw_line)
if not matched:
continue
key_to_index[matched.group(1).upper()] = index
return key_to_index
@staticmethod
def _resolve_env_path() -> Path:
env_file = os.getenv("ENV_FILE")
if env_file:
return Path(env_file).resolve()
return (Path(__file__).resolve().parent.parent.parent / ".env").resolve()

587
src/core/config_registry.py Normal file
View File

@@ -0,0 +1,587 @@
# -*- coding: utf-8 -*-
"""Configuration field metadata registry.
This module is the single source of truth for configuration UI metadata,
validation hints, and category grouping.
"""
from __future__ import annotations
from copy import deepcopy
from typing import Any, Dict, List, Optional
SCHEMA_VERSION = "2026-02-09"
_CATEGORY_DEFINITIONS: List[Dict[str, Any]] = [
{
"category": "base",
"title": "Base Settings",
"description": "Watchlist and foundational application settings.",
"display_order": 10,
},
{
"category": "ai_model",
"title": "AI Model",
"description": "Model providers, model names, and inference parameters.",
"display_order": 20,
},
{
"category": "data_source",
"title": "Data Source",
"description": "Market data provider credentials and priority settings.",
"display_order": 30,
},
{
"category": "notification",
"title": "Notification",
"description": "Bot, webhook, and push channel related settings.",
"display_order": 40,
},
{
"category": "system",
"title": "System",
"description": "Runtime and scheduling controls.",
"display_order": 50,
},
{
"category": "backtest",
"title": "Backtest",
"description": "Backtest engine behavior and evaluation parameters.",
"display_order": 60,
},
{
"category": "uncategorized",
"title": "Uncategorized",
"description": "Keys not mapped in the field registry.",
"display_order": 99,
},
]
_FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"STOCK_LIST": {
"title": "Stock List",
"description": "Comma-separated watchlist stock codes.",
"category": "base",
"data_type": "array",
"ui_control": "textarea",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "600519,300750,002594",
"options": [],
"validation": {"min_items": 1},
"display_order": 10,
},
"TUSHARE_TOKEN": {
"title": "Tushare Token",
"description": "Token for Tushare Pro API.",
"category": "data_source",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 10,
},
"REALTIME_SOURCE_PRIORITY": {
"title": "Realtime Source Priority",
"description": "Comma-separated priority for realtime quote providers.",
"category": "data_source",
"data_type": "string",
"ui_control": "text",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "tencent,akshare_sina,efinance,akshare_em",
"options": [],
"validation": {},
"display_order": 20,
},
"TAVILY_API_KEYS": {
"title": "Tavily API Keys",
"description": "Comma-separated Tavily API keys.",
"category": "data_source",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {"multi_value": True, "delimiter": ","},
"display_order": 30,
},
"SERPAPI_API_KEYS": {
"title": "SerpAPI Keys",
"description": "Comma-separated SerpAPI keys.",
"category": "data_source",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {"multi_value": True, "delimiter": ","},
"display_order": 40,
},
"BRAVE_API_KEYS": {
"title": "Brave API Keys",
"description": "Comma-separated Brave Search API keys.",
"category": "data_source",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {"multi_value": True, "delimiter": ","},
"display_order": 50,
},
"GEMINI_API_KEY": {
"title": "Gemini API Key",
"description": "API key for Gemini service.",
"category": "ai_model",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 10,
},
"GEMINI_MODEL": {
"title": "Gemini Model",
"description": "Gemini model name.",
"category": "ai_model",
"data_type": "string",
"ui_control": "text",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "gemini-3-flash-preview",
"options": [],
"validation": {},
"display_order": 20,
},
"GEMINI_TEMPERATURE": {
"title": "Gemini Temperature",
"description": "Temperature in range [0.0, 2.0].",
"category": "ai_model",
"data_type": "number",
"ui_control": "number",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "0.7",
"options": [],
"validation": {"min": 0.0, "max": 2.0},
"display_order": 30,
},
"OPENAI_API_KEY": {
"title": "OpenAI API Key",
"description": "API key for OpenAI-compatible service.",
"category": "ai_model",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 40,
},
"OPENAI_BASE_URL": {
"title": "OpenAI Base URL",
"description": "Base URL for OpenAI-compatible endpoint.",
"category": "ai_model",
"data_type": "string",
"ui_control": "text",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 50,
},
"OPENAI_MODEL": {
"title": "OpenAI Model",
"description": "Model name for OpenAI-compatible endpoint.",
"category": "ai_model",
"data_type": "string",
"ui_control": "text",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "gpt-4o-mini",
"options": [],
"validation": {},
"display_order": 60,
},
"WECHAT_WEBHOOK_URL": {
"title": "WeChat Webhook URL",
"description": "Webhook URL for enterprise WeChat bot.",
"category": "notification",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 10,
},
"DINGTALK_APP_KEY": {
"title": "DingTalk App Key",
"description": "DingTalk app key.",
"category": "notification",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 20,
},
"DINGTALK_APP_SECRET": {
"title": "DingTalk App Secret",
"description": "DingTalk app secret.",
"category": "notification",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 30,
},
"PUSHPLUS_TOKEN": {
"title": "PushPlus Token",
"description": "Token for PushPlus notifications.",
"category": "notification",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 40,
},
"CUSTOM_WEBHOOK_URLS": {
"title": "Custom Webhook URLs",
"description": "Comma-separated webhook URLs for custom notifications (DingTalk, Discord, Slack, etc.).",
"category": "notification",
"data_type": "array",
"ui_control": "textarea",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {"multi_value": True, "delimiter": ","},
"display_order": 50,
},
"CUSTOM_WEBHOOK_BEARER_TOKEN": {
"title": "Custom Webhook Bearer Token",
"description": "Bearer token for authenticated custom webhooks.",
"category": "notification",
"data_type": "string",
"ui_control": "password",
"is_sensitive": True,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 51,
},
"SCHEDULE_TIME": {
"title": "Schedule Time",
"description": "Daily schedule time in HH:MM format.",
"category": "system",
"data_type": "time",
"ui_control": "time",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "18:00",
"options": [],
"validation": {"pattern": r"^([01]\d|2[0-3]):[0-5]\d$"},
"display_order": 10,
},
"HTTP_PROXY": {
"title": "HTTP Proxy",
"description": "Optional HTTP proxy endpoint.",
"category": "system",
"data_type": "string",
"ui_control": "text",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 20,
},
"LOG_LEVEL": {
"title": "Log Level",
"description": "Application log level.",
"category": "system",
"data_type": "string",
"ui_control": "select",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "INFO",
"options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
"validation": {"enum": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]},
"display_order": 30,
},
"WEBUI_PORT": {
"title": "Web UI Port",
"description": "Port for Web UI service.",
"category": "system",
"data_type": "integer",
"ui_control": "number",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "8000",
"options": [],
"validation": {"min": 1, "max": 65535},
"display_order": 40,
},
"BACKTEST_ENABLED": {
"title": "Backtest Enabled",
"description": "Whether backtest is enabled.",
"category": "backtest",
"data_type": "boolean",
"ui_control": "switch",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "true",
"options": [],
"validation": {},
"display_order": 10,
},
"BACKTEST_EVAL_WINDOW_DAYS": {
"title": "Backtest Eval Window Days",
"description": "Backtest evaluation window in trading days.",
"category": "backtest",
"data_type": "integer",
"ui_control": "number",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "10",
"options": [],
"validation": {"min": 1, "max": 365},
"display_order": 20,
},
"BACKTEST_MIN_AGE_DAYS": {
"title": "Backtest Min Age Days",
"description": "Only evaluate analysis records older than this threshold.",
"category": "backtest",
"data_type": "integer",
"ui_control": "number",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "14",
"options": [],
"validation": {"min": 0, "max": 3650},
"display_order": 30,
},
"BACKTEST_ENGINE_VERSION": {
"title": "Backtest Engine Version",
"description": "Backtest engine version label.",
"category": "backtest",
"data_type": "string",
"ui_control": "text",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "v1",
"options": [],
"validation": {},
"display_order": 40,
},
"BACKTEST_NEUTRAL_BAND_PCT": {
"title": "Backtest Neutral Band Pct",
"description": "Neutral return band percentage for outcome labeling.",
"category": "backtest",
"data_type": "number",
"ui_control": "number",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "2.0",
"options": [],
"validation": {"min": 0.0, "max": 100.0},
"display_order": 50,
},
}
def get_category_definitions() -> List[Dict[str, Any]]:
"""Return deep-copied category metadata."""
return deepcopy(_CATEGORY_DEFINITIONS)
def get_registered_field_keys() -> List[str]:
"""Return all explicitly registered keys."""
return list(_FIELD_DEFINITIONS.keys())
def get_field_definition(key: str, value_hint: Optional[str] = None) -> Dict[str, Any]:
"""Return field definition for key, including inferred fallback metadata."""
key_upper = key.upper()
if key_upper in _FIELD_DEFINITIONS:
field = deepcopy(_FIELD_DEFINITIONS[key_upper])
field["key"] = key_upper
return field
category = _infer_category(key_upper)
data_type = _infer_data_type(key_upper, value_hint)
field = {
"key": key_upper,
"title": key_upper.replace("_", " ").title(),
"description": "Auto-inferred field metadata.",
"category": category,
"data_type": data_type,
"ui_control": _infer_ui_control(data_type, key_upper),
"is_sensitive": _is_sensitive_key(key_upper),
"is_required": False,
"is_editable": True,
"default_value": None,
"options": [],
"validation": {},
"display_order": 9000,
}
return field
def build_schema_response() -> Dict[str, Any]:
"""Build schema payload grouped by category."""
category_map: Dict[str, Dict[str, Any]] = {}
for category in get_category_definitions():
category_map[category["category"]] = {**category, "fields": []}
for key in sorted(_FIELD_DEFINITIONS.keys()):
field = get_field_definition(key)
category_map[field["category"]]["fields"].append(field)
categories = sorted(category_map.values(), key=lambda item: item["display_order"])
for category in categories:
category["fields"] = sorted(
category["fields"],
key=lambda item: (item.get("display_order", 9999), item["key"]),
)
return {
"schema_version": SCHEMA_VERSION,
"categories": categories,
}
def _is_sensitive_key(key: str) -> bool:
markers = ("KEY", "TOKEN", "SECRET", "PASSWORD")
return any(marker in key for marker in markers)
def _infer_category(key: str) -> str:
if key == "STOCK_LIST":
return "base"
if key.startswith("BACKTEST_"):
return "backtest"
if key.startswith(("GEMINI_", "OPENAI_")):
return "ai_model"
if key.endswith("_PRIORITY") or key.startswith(
(
"TUSHARE",
"AKSHARE",
"EFINANCE",
"PYTDX",
"BAOSTOCK",
"YFINANCE",
"TAVILY",
"SERPAPI",
"BRAVE",
)
):
return "data_source"
if key.startswith((
"WECHAT",
"FEISHU",
"TELEGRAM",
"EMAIL",
"PUSHOVER",
"PUSHPLUS",
"SERVERCHAN",
"DINGTALK",
"DISCORD",
"CUSTOM_WEBHOOK",
"WECOM",
"ASTRBOT",
)) or "WEBHOOK" in key:
return "notification"
if key.startswith(("LOG_", "SCHEDULE_", "WEBUI_", "HTTP_", "HTTPS_", "MAX_", "DEBUG")):
return "system"
return "uncategorized"
def _infer_data_type(key: str, value_hint: Optional[str]) -> str:
if key.endswith("_TIME"):
return "time"
if value_hint is None:
return "string"
lowered = value_hint.strip().lower()
if lowered in {"true", "false"}:
return "boolean"
try:
int(value_hint)
return "integer"
except (TypeError, ValueError):
pass
try:
float(value_hint)
return "number"
except (TypeError, ValueError):
pass
if key in {"STOCK_LIST", "EMAIL_RECEIVERS", "CUSTOM_WEBHOOK_URLS"}:
return "array"
return "string"
def _infer_ui_control(data_type: str, key: str) -> str:
if _is_sensitive_key(key):
return "password"
if data_type == "boolean":
return "switch"
if data_type in {"integer", "number"}:
return "number"
if data_type == "time":
return "time"
if data_type == "array":
return "textarea"
return "text"

View File

@@ -0,0 +1,330 @@
# -*- coding: utf-8 -*-
"""System configuration service for `.env` based settings."""
from __future__ import annotations
import logging
import re
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
from src.config import Config, setup_env
from src.core.config_manager import ConfigManager
from src.core.config_registry import (
build_schema_response,
get_category_definitions,
get_field_definition,
get_registered_field_keys,
)
logger = logging.getLogger(__name__)
class ConfigValidationError(Exception):
"""Raised when one or more submitted fields fail validation."""
def __init__(self, issues: List[Dict[str, Any]]):
super().__init__("Configuration validation failed")
self.issues = issues
class ConfigConflictError(Exception):
"""Raised when submitted config_version is stale."""
def __init__(self, current_version: str):
super().__init__("Configuration version conflict")
self.current_version = current_version
class SystemConfigService:
"""Service layer for reading, validating, and updating runtime configuration."""
def __init__(self, manager: Optional[ConfigManager] = None):
self._manager = manager or ConfigManager()
def get_schema(self) -> Dict[str, Any]:
"""Return grouped schema metadata for UI rendering."""
return build_schema_response()
def get_config(self, include_schema: bool = True, mask_token: str = "******") -> Dict[str, Any]:
"""Return current config values without server-side secret masking."""
config_map = self._manager.read_config_map()
registered_keys = set(get_registered_field_keys())
all_keys = set(config_map.keys()) | registered_keys
category_orders = {
item["category"]: item["display_order"]
for item in get_category_definitions()
}
schema_by_key: Dict[str, Dict[str, Any]] = {
key: get_field_definition(key, config_map.get(key, ""))
for key in all_keys
}
items: List[Dict[str, Any]] = []
for key in all_keys:
raw_value = config_map.get(key, "")
field_schema = schema_by_key[key]
item: Dict[str, Any] = {
"key": key,
"value": raw_value,
"raw_value_exists": bool(raw_value),
"is_masked": False,
}
if include_schema:
item["schema"] = field_schema
items.append(item)
items.sort(
key=lambda item: (
category_orders.get(schema_by_key[item["key"]].get("category", "uncategorized"), 999),
schema_by_key[item["key"]].get("display_order", 9999),
item["key"],
)
)
return {
"config_version": self._manager.get_config_version(),
"mask_token": mask_token,
"items": items,
"updated_at": self._manager.get_updated_at(),
}
def validate(self, items: Sequence[Dict[str, str]], mask_token: str = "******") -> Dict[str, Any]:
"""Validate submitted items without writing to `.env`."""
issues = self._collect_issues(items=items, mask_token=mask_token)
valid = not any(issue["severity"] == "error" for issue in issues)
return {
"valid": valid,
"issues": issues,
}
def update(
self,
config_version: str,
items: Sequence[Dict[str, str]],
mask_token: str = "******",
reload_now: bool = True,
) -> Dict[str, Any]:
"""Validate and persist updates into `.env`, then reload runtime config."""
current_version = self._manager.get_config_version()
if current_version != config_version:
raise ConfigConflictError(current_version=current_version)
issues = self._collect_issues(items=items, mask_token=mask_token)
errors = [issue for issue in issues if issue["severity"] == "error"]
if errors:
raise ConfigValidationError(issues=errors)
updates: List[Tuple[str, str]] = []
sensitive_keys: Set[str] = set()
for item in items:
key = item["key"].upper()
value = item["value"]
updates.append((key, value))
field_schema = get_field_definition(key)
if bool(field_schema.get("is_sensitive", False)):
sensitive_keys.add(key)
updated_keys, skipped_masked_keys, new_version = self._manager.apply_updates(
updates=updates,
sensitive_keys=sensitive_keys,
mask_token=mask_token,
)
warnings: List[str] = []
reload_triggered = False
if reload_now:
try:
Config.reset_instance()
setup_env(override=True)
config = Config.get_instance()
warnings = config.validate()
reload_triggered = True
except Exception as exc: # pragma: no cover - defensive branch
logger.error("Configuration reload failed: %s", exc, exc_info=True)
warnings.append("Configuration updated but reload failed")
return {
"success": True,
"config_version": new_version,
"applied_count": len(updated_keys),
"skipped_masked_count": len(skipped_masked_keys),
"reload_triggered": reload_triggered,
"updated_keys": updated_keys,
"warnings": warnings,
}
def _collect_issues(self, items: Sequence[Dict[str, str]], mask_token: str) -> List[Dict[str, Any]]:
"""Collect field-level and cross-field validation issues."""
current_map = self._manager.read_config_map()
effective_map = dict(current_map)
issues: List[Dict[str, Any]] = []
updated_map: Dict[str, str] = {}
for item in items:
key = item["key"].upper()
value = item["value"]
field_schema = get_field_definition(key, value)
is_sensitive = bool(field_schema.get("is_sensitive", False))
if is_sensitive and value == mask_token and current_map.get(key):
continue
updated_map[key] = value
effective_map[key] = value
issues.extend(self._validate_value(key=key, value=value, field_schema=field_schema))
issues.extend(self._validate_cross_field(effective_map=effective_map, updated_keys=set(updated_map.keys())))
return issues
@staticmethod
def _validate_value(key: str, value: str, field_schema: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Validate a single field value against schema metadata."""
issues: List[Dict[str, Any]] = []
data_type = field_schema.get("data_type", "string")
validation = field_schema.get("validation", {}) or {}
is_required = field_schema.get("is_required", False)
# Empty values are valid for non-required fields (skip type validation)
if not value.strip() and not is_required:
return issues
if "\n" in value:
issues.append(
{
"key": key,
"code": "invalid_value",
"message": "Value cannot contain newline characters",
"severity": "error",
"expected": "single-line value",
"actual": "contains newline",
}
)
return issues
if data_type == "integer":
try:
numeric = int(value)
except ValueError:
return [
{
"key": key,
"code": "invalid_type",
"message": "Value must be an integer",
"severity": "error",
"expected": "integer",
"actual": value,
}
]
issues.extend(SystemConfigService._validate_numeric_range(key, numeric, validation))
elif data_type == "number":
try:
numeric = float(value)
except ValueError:
return [
{
"key": key,
"code": "invalid_type",
"message": "Value must be a number",
"severity": "error",
"expected": "number",
"actual": value,
}
]
issues.extend(SystemConfigService._validate_numeric_range(key, numeric, validation))
elif data_type == "boolean":
if value.strip().lower() not in {"true", "false"}:
issues.append(
{
"key": key,
"code": "invalid_type",
"message": "Value must be true or false",
"severity": "error",
"expected": "true|false",
"actual": value,
}
)
elif data_type == "time":
pattern = validation.get("pattern") or r"^([01]\d|2[0-3]):[0-5]\d$"
if not re.match(pattern, value.strip()):
issues.append(
{
"key": key,
"code": "invalid_format",
"message": "Value must be in HH:MM format",
"severity": "error",
"expected": "HH:MM",
"actual": value,
}
)
if "enum" in validation and value and value not in validation["enum"]:
issues.append(
{
"key": key,
"code": "invalid_enum",
"message": "Value is not in allowed options",
"severity": "error",
"expected": ",".join(validation["enum"]),
"actual": value,
}
)
return issues
@staticmethod
def _validate_numeric_range(key: str, numeric_value: float, validation: Dict[str, Any]) -> List[Dict[str, Any]]:
issues: List[Dict[str, Any]] = []
min_value = validation.get("min")
max_value = validation.get("max")
if min_value is not None and numeric_value < min_value:
issues.append(
{
"key": key,
"code": "out_of_range",
"message": "Value is lower than minimum",
"severity": "error",
"expected": f">={min_value}",
"actual": str(numeric_value),
}
)
if max_value is not None and numeric_value > max_value:
issues.append(
{
"key": key,
"code": "out_of_range",
"message": "Value is greater than maximum",
"severity": "error",
"expected": f"<={max_value}",
"actual": str(numeric_value),
}
)
return issues
@staticmethod
def _validate_cross_field(effective_map: Dict[str, str], updated_keys: Set[str]) -> List[Dict[str, Any]]:
"""Validate dependencies across multiple keys."""
issues: List[Dict[str, Any]] = []
token_value = (effective_map.get("TELEGRAM_BOT_TOKEN") or "").strip()
chat_id_value = (effective_map.get("TELEGRAM_CHAT_ID") or "").strip()
if token_value and not chat_id_value and (
"TELEGRAM_BOT_TOKEN" in updated_keys or "TELEGRAM_CHAT_ID" in updated_keys
):
issues.append(
{
"key": "TELEGRAM_CHAT_ID",
"code": "missing_dependency",
"message": "TELEGRAM_CHAT_ID is required when TELEGRAM_BOT_TOKEN is set",
"severity": "error",
"expected": "non-empty TELEGRAM_CHAT_ID",
"actual": chat_id_value,
}
)
return issues

View File

@@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
"""
===================================
get_latest_data 测试
===================================
职责:
1. 验证 get_latest_data 方法
2. 测试返回数据按日期降序排列
3. 测试 days 参数限制
"""
import os
import tempfile
import unittest
from datetime import date, timedelta
import pandas as pd
from src.config import Config
from src.storage import DatabaseManager, StockDaily
class GetLatestDataTestCase(unittest.TestCase):
"""get_latest_data 方法测试"""
#
# def setUp(self) -> None:
# """为每个用例初始化独立数据库"""
# self._temp_dir = tempfile.TemporaryDirectory()
# self._db_path = os.path.join(self._temp_dir.name, "test_get_latest_data.db")
# os.environ["DATABASE_PATH"] = self._db_path
#
# Config._instance = None
# DatabaseManager.reset_instance()
# self.db = DatabaseManager.get_instance()
#
# def tearDown(self) -> None:
# """清理资源"""
# DatabaseManager.reset_instance()
# self._temp_dir.cleanup()
def _insert_stock_data(self, code: str, days_ago: int, close: float) -> None:
"""插入测试用股票数据"""
target_date = date.today() - timedelta(days=days_ago)
df = pd.DataFrame([{
'date': target_date,
'open': close - 1,
'high': close + 1,
'low': close - 2,
'close': close,
'volume': 1000000,
'amount': 10000000,
'pct_chg': 1.5,
}])
self.db.save_daily_data(df, code, data_source="TestData")
def test_get_latest_data_returns_empty_when_no_data(self) -> None:
"""无数据时返回空列表"""
result = self.db.get_latest_data("999999", days=2)
self.assertEqual(result, [])
def test_get_latest_data_returns_correct_count(self) -> None:
"""返回正确数量的数据"""
# 插入5天数据
for i in range(5):
self._insert_stock_data("600519", days_ago=i, close=100.0 + i)
# 请求2天数据
result = self.db.get_latest_data("600519", days=2)
self.assertEqual(len(result), 2)
# 请求5天数据
result = self.db.get_latest_data("600519", days=5)
self.assertEqual(len(result), 5)
def test_get_latest_data_ordered_by_date_desc(self) -> None:
"""验证数据按日期降序排列"""
# 插入3天数据
for i in range(3):
self._insert_stock_data("600519", days_ago=i, close=100.0 + i)
result = self.db.get_latest_data("600519", days=3)
# 验证日期降序(最新日期在前)
self.assertEqual(len(result), 3)
self.assertGreater(result[0].date, result[1].date)
self.assertGreater(result[1].date, result[2].date)
def test_get_latest_data_filters_by_code(self) -> None:
"""验证按股票代码过滤"""
# 插入不同股票的数据
self._insert_stock_data("600519", days_ago=0, close=100.0)
self._insert_stock_data("000001", days_ago=0, close=50.0)
result = self.db.get_latest_data("600519", days=5)
self.assertEqual(len(result), 1)
self.assertEqual(result[0].code, "600519")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
"""Integration tests for system configuration API endpoints."""
import os
import tempfile
import unittest
from pathlib import Path
from fastapi.testclient import TestClient
from api.app import create_app
from src.config import Config
class SystemConfigApiTestCase(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.env_path = Path(self.temp_dir.name) / ".env"
self.env_path.write_text(
"\n".join(
[
"STOCK_LIST=600519,000001",
"GEMINI_API_KEY=secret-key-value",
"SCHEDULE_TIME=18:00",
"LOG_LEVEL=INFO",
]
)
+ "\n",
encoding="utf-8",
)
os.environ["ENV_FILE"] = str(self.env_path)
Config.reset_instance()
app = create_app(static_dir=Path(self.temp_dir.name) / "empty-static")
self.client = TestClient(app)
def tearDown(self) -> None:
Config.reset_instance()
os.environ.pop("ENV_FILE", None)
self.temp_dir.cleanup()
def test_get_config_returns_raw_secret_value(self) -> None:
response = self.client.get("/api/v1/system/config")
self.assertEqual(response.status_code, 200)
payload = response.json()
item_map = {item["key"]: item for item in payload["items"]}
self.assertEqual(item_map["GEMINI_API_KEY"]["value"], "secret-key-value")
self.assertFalse(item_map["GEMINI_API_KEY"]["is_masked"])
def test_put_config_updates_secret_and_plain_field(self) -> None:
current = self.client.get("/api/v1/system/config").json()
response = self.client.put(
"/api/v1/system/config",
json={
"config_version": current["config_version"],
"mask_token": "******",
"reload_now": False,
"items": [
{"key": "GEMINI_API_KEY", "value": "new-secret-value"},
{"key": "STOCK_LIST", "value": "600519,300750"},
],
},
)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["applied_count"], 2)
self.assertEqual(payload["skipped_masked_count"], 0)
env_content = self.env_path.read_text(encoding="utf-8")
self.assertIn("STOCK_LIST=600519,300750", env_content)
self.assertIn("GEMINI_API_KEY=new-secret-value", env_content)
def test_put_config_returns_conflict_when_version_is_stale(self) -> None:
response = self.client.put(
"/api/v1/system/config",
json={
"config_version": "stale-version",
"items": [{"key": "STOCK_LIST", "value": "600519"}],
},
)
self.assertEqual(response.status_code, 409)
payload = response.json()
self.assertEqual(payload["error"], "config_version_conflict")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
"""Unit tests for system configuration service."""
import os
import tempfile
import unittest
from pathlib import Path
from src.config import Config
from src.core.config_manager import ConfigManager
from src.services.system_config_service import ConfigConflictError, SystemConfigService
class SystemConfigServiceTestCase(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.env_path = Path(self.temp_dir.name) / ".env"
self.env_path.write_text(
"\n".join(
[
"STOCK_LIST=600519,000001",
"GEMINI_API_KEY=secret-key-value",
"SCHEDULE_TIME=18:00",
"LOG_LEVEL=INFO",
]
)
+ "\n",
encoding="utf-8",
)
os.environ["ENV_FILE"] = str(self.env_path)
Config.reset_instance()
self.manager = ConfigManager(env_path=self.env_path)
self.service = SystemConfigService(manager=self.manager)
def tearDown(self) -> None:
Config.reset_instance()
os.environ.pop("ENV_FILE", None)
self.temp_dir.cleanup()
def test_get_config_returns_raw_sensitive_values(self) -> None:
payload = self.service.get_config(include_schema=True)
items = {item["key"]: item for item in payload["items"]}
self.assertIn("GEMINI_API_KEY", items)
self.assertEqual(items["GEMINI_API_KEY"]["value"], "secret-key-value")
self.assertFalse(items["GEMINI_API_KEY"]["is_masked"])
self.assertTrue(items["GEMINI_API_KEY"]["raw_value_exists"])
def test_update_preserves_masked_secret(self) -> None:
old_version = self.manager.get_config_version()
response = self.service.update(
config_version=old_version,
items=[
{"key": "GEMINI_API_KEY", "value": "******"},
{"key": "STOCK_LIST", "value": "600519,300750"},
],
mask_token="******",
reload_now=False,
)
self.assertTrue(response["success"])
self.assertEqual(response["applied_count"], 1)
self.assertEqual(response["skipped_masked_count"], 1)
self.assertIn("STOCK_LIST", response["updated_keys"])
current_map = self.manager.read_config_map()
self.assertEqual(current_map["STOCK_LIST"], "600519,300750")
self.assertEqual(current_map["GEMINI_API_KEY"], "secret-key-value")
def test_validate_reports_invalid_time(self) -> None:
validation = self.service.validate(items=[{"key": "SCHEDULE_TIME", "value": "25:70"}])
self.assertFalse(validation["valid"])
self.assertTrue(any(issue["code"] == "invalid_format" for issue in validation["issues"]))
def test_update_raises_conflict_for_stale_version(self) -> None:
with self.assertRaises(ConfigConflictError):
self.service.update(
config_version="stale-version",
items=[{"key": "STOCK_LIST", "value": "600519"}],
reload_now=False,
)
if __name__ == "__main__":
unittest.main()