mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
Feature/WebUI 增加配置页面,可在页面修改配置内容 (#285)
* feat: setting-page * fix: 调整配置分类排序 * fix: 调整配置分类排序 * fix: 修改页面文案 * fix: 未配置的选项显示空输入框 * fix: 优化默认配置项,多参数配置 * fix: 配置依赖管理 * fix: 配置依赖管理,全局单例模式 * fix: 数据更新错误问题 * fix: 空值校验 * fix: 默认展示CUSTOM_WEBHOOK_URLS配置
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -75,3 +75,5 @@ CLAUDE.md
|
||||
|
||||
# ignore static files
|
||||
static/
|
||||
/apps/dsa-desktop/dist/
|
||||
/apps/dsa-desktop/node_modules/
|
||||
|
||||
14
api/app.py
14
api/app.py
@@ -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,
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
|
||||
11
api/deps.py
11
api/deps.py
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
167
api/v1/endpoints/system_config.py
Normal file
167
api/v1/endpoints/system_config.py
Normal 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",
|
||||
},
|
||||
)
|
||||
@@ -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"]
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
132
api/v1/schemas/system_config.py
Normal file
132
api/v1/schemas/system_config.py
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
124
apps/dsa-web/src/api/systemConfig.ts
Normal file
124
apps/dsa-web/src/api/systemConfig.ts
Normal 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, '更新系统配置失败'));
|
||||
}
|
||||
},
|
||||
};
|
||||
37
apps/dsa-web/src/components/settings/SettingsAlert.tsx
Normal file
37
apps/dsa-web/src/components/settings/SettingsAlert.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
236
apps/dsa-web/src/components/settings/SettingsField.tsx
Normal file
236
apps/dsa-web/src/components/settings/SettingsField.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
14
apps/dsa-web/src/components/settings/SettingsLoading.tsx
Normal file
14
apps/dsa-web/src/components/settings/SettingsLoading.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
3
apps/dsa-web/src/components/settings/index.ts
Normal file
3
apps/dsa-web/src/components/settings/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './SettingsAlert';
|
||||
export * from './SettingsField';
|
||||
export * from './SettingsLoading';
|
||||
@@ -1,4 +1,5 @@
|
||||
export { useTaskStream } from './useTaskStream';
|
||||
export { useSystemConfig } from './useSystemConfig';
|
||||
export type {
|
||||
SSEEventType,
|
||||
SSEEvent,
|
||||
|
||||
353
apps/dsa-web/src/hooks/useSystemConfig.ts
Normal file
353
apps/dsa-web/src/hooks/useSystemConfig.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
165
apps/dsa-web/src/pages/SettingsPage.tsx
Normal file
165
apps/dsa-web/src/pages/SettingsPage.tsx
Normal 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;
|
||||
122
apps/dsa-web/src/types/systemConfig.ts
Normal file
122
apps/dsa-web/src/types/systemConfig.ts
Normal 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;
|
||||
}
|
||||
93
apps/dsa-web/src/utils/systemConfigI18n.ts
Normal file
93
apps/dsa-web/src/utils/systemConfigI18n.ts
Normal 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 || '';
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
147
src/core/config_manager.py
Normal 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
587
src/core/config_registry.py
Normal 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"
|
||||
330
src/services/system_config_service.py
Normal file
330
src/services/system_config_service.py
Normal 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
|
||||
101
tests/test_get_latest_data.py
Normal file
101
tests/test_get_latest_data.py
Normal 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()
|
||||
89
tests/test_system_config_api.py
Normal file
89
tests/test_system_config_api.py
Normal 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()
|
||||
86
tests/test_system_config_service.py
Normal file
86
tests/test_system_config_service.py
Normal 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()
|
||||
Reference in New Issue
Block a user