初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1 @@
|
||||
"""核心基础设施。"""
|
||||
@@ -0,0 +1,16 @@
|
||||
"""配置集中入口。
|
||||
|
||||
所有配置集中在 ``core/config/``,按领域拆分子配置文件,主 ``Settings`` 多继承聚合。
|
||||
详见 [后端分层架构设计](../../../../../docs/architecture/backend-layering-design.md) §7。
|
||||
|
||||
- ``.env`` 永远放在项目根目录(backend 父级),``Settings`` 初始化时以**绝对路径**
|
||||
通过 ``_env_file`` 传入,不使用相对路径,不依赖 ``python-dotenv``。
|
||||
- ``ai_provider/`` 只**消费**本目录的配置,不持有自己的
|
||||
``settings.py`` / ``config.py``;领域数据结构(``ModelConfig`` 等)保留在
|
||||
``ai_provider/config/models.py``。
|
||||
"""
|
||||
|
||||
from app.core.config.sandbox_config import SandboxSettings
|
||||
from app.core.config.settings import Settings, settings
|
||||
|
||||
__all__ = ["SandboxSettings", "Settings", "settings"]
|
||||
@@ -0,0 +1,53 @@
|
||||
"""应用基础配置:运行环境、端口、CORS、默认模型提示等。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class AppConfig(BaseSettings):
|
||||
"""应用基础配置。"""
|
||||
|
||||
# ==================== 基础配置 ====================
|
||||
app_name: str = "魂镜后端" # 应用名称
|
||||
app_env: Literal["dev", "prod"] = "dev" # 运行环境:dev(开发) / prod(生产)
|
||||
debug: bool = False # 调试模式
|
||||
timezone: str = "Asia/Shanghai" # 时区
|
||||
api_prefix: str = "/api/v1" # API 路由前缀
|
||||
backend_host: str = "0.0.0.0" # 监听地址
|
||||
backend_port: int = 8000 # 监听端口
|
||||
cors_allow_origins: str = "http://localhost:3000" # CORS 允许的源,逗号分隔
|
||||
|
||||
# ==================== 业务默认值 ====================
|
||||
default_owner_scope: str = "local-user" # 默认所有者作用域
|
||||
default_model_hint: str = "deepseek-v4-flash" # 默认真实远程逻辑模型提示
|
||||
|
||||
# ==================== 默认人物模板配置 ====================
|
||||
character_templates_path: str = (
|
||||
"../config/character_templates.yaml" # 人物模板配置文件路径(相对 backend)
|
||||
)
|
||||
|
||||
# ==================== 文件存储 ====================
|
||||
avatar_dir: str = "../db/avatars" # 角色头像文件存储目录(相对 backend)
|
||||
|
||||
# ==================== 默认管理员用户 ====================
|
||||
default_admin_username: str = "admin" # 种子默认管理员用户名
|
||||
default_admin_email: str = "admin@hunjing.local" # 种子默认管理员邮箱
|
||||
default_admin_password: str = "admin123" # 种子默认管理员密码(引导用,生产立即修改)
|
||||
|
||||
@property
|
||||
def cors_origins(self) -> list[str]:
|
||||
"""解析 CORS 允许的源为列表。"""
|
||||
return [origin.strip() for origin in self.cors_allow_origins.split(",") if origin.strip()]
|
||||
|
||||
@property
|
||||
def is_development(self) -> bool:
|
||||
"""是否为开发环境。"""
|
||||
return self.app_env == "dev"
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
"""是否为生产环境。"""
|
||||
return self.app_env == "prod"
|
||||
@@ -0,0 +1,29 @@
|
||||
"""认证与鉴权配置。
|
||||
|
||||
当前(v1 单用户)仅含管理端点访问凭证 ``admin_token``;v2 多用户上线后扩展为
|
||||
完整认证配置(JWT 签发密钥、access/refresh token TTL 等),见
|
||||
[多用户与租户隔离设计](../../../docs/product/master-prd.md)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class AuthConfig(BaseSettings):
|
||||
"""认证与鉴权配置。"""
|
||||
|
||||
# ==================== 管理端点访问(v1) ====================
|
||||
# 司命台管理 API(/admin/*)的访问凭证。部署期配置,非用户登录凭证:
|
||||
# 请求需在 Authorization 头携带 ``Bearer <ADMIN_TOKEN>``,经 require_admin 校验。
|
||||
# 为空时管理端点 fail-closed(返回 503),强制部署时配置,避免裸奔。
|
||||
# 生产环境务必使用强随机值;v2 多用户上线后由完整认证体系取代。
|
||||
admin_token: str = "" # 管理端点访问凭证(为空则 /admin 返回 503)
|
||||
|
||||
# ==================== JWT 配置(v2 多用户) ====================
|
||||
# JWT 签名密钥。为空时认证端点 fail-closed(503);生产环境务必配置强随机值。
|
||||
jwt_secret: str = ""
|
||||
# Access Token 有效期(秒),默认 15 分钟
|
||||
access_token_ttl: int = 900
|
||||
# Refresh Token 有效期(秒),默认 7 天
|
||||
refresh_token_ttl: int = 604800
|
||||
@@ -0,0 +1,27 @@
|
||||
"""数据库配置:SQLite / PostgreSQL 连接参数。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class DatabaseConfig(BaseSettings):
|
||||
"""数据库配置。
|
||||
|
||||
本地开发默认使用 SQLite(``aiosqlite``);正式环境通过 ``DATABASE_TYPE=pgsql``
|
||||
切换到 PostgreSQL(``asyncpg``)。``DATABASE_URL`` 优先级最高,可直接覆盖。
|
||||
"""
|
||||
|
||||
database_type: Literal["sqlite", "pgsql"] = "sqlite" # 数据库类型
|
||||
sqlite_database_path: str = "../db/hunjing.sqlite3" # SQLite 数据库路径(相对 backend)
|
||||
database_url: str = "" # 自定义数据库连接 URL(优先级最高)
|
||||
postgres_host: str = "127.0.0.1" # PostgreSQL 主机
|
||||
postgres_port: int = 5432 # PostgreSQL 端口
|
||||
postgres_db: str = "hunjing" # PostgreSQL 数据库名
|
||||
postgres_user: str = "hunjing" # PostgreSQL 用户名
|
||||
postgres_password: str = "change-me" # PostgreSQL 密码
|
||||
postgres_ssl: bool = False # 是否启用 SSL
|
||||
postgres_pool_size: int = 10 # 连接池大小
|
||||
postgres_max_overflow: int = 20 # 连接池最大溢出连接数
|
||||
@@ -0,0 +1,15 @@
|
||||
"""日志配置。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class LogConfig(BaseSettings):
|
||||
"""日志配置(loguru)。"""
|
||||
|
||||
log_dir: str = "../logs" # 日志目录(相对于 backend 目录)
|
||||
log_level: str = "INFO" # 日志级别:DEBUG/INFO/WARNING/ERROR
|
||||
log_access: bool = True # 是否启用访问日志
|
||||
log_retention_days: int = 14 # 日志保留天数
|
||||
log_rotation_size: str = "10 MB" # 日志轮转大小
|
||||
@@ -0,0 +1,46 @@
|
||||
"""模型网关(神识)env 配置:各 provider 的 API Key / Base URL 与 endpoint 池策略。
|
||||
|
||||
注意区分**配置字段**与**领域数据结构**:
|
||||
- 本文件只承载「从 env 读的开关 / 参数 / 密钥」。
|
||||
- 领域数据结构(``ModelConfig`` / ``ModelEndpointConfig``,从 ``config/models.yaml`` 加载)
|
||||
保留在 ``ai_provider/config/models.py``,不外迁到 ``core/config``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class ModelGatewayConfig(BaseSettings):
|
||||
"""模型网关 env 配置。"""
|
||||
|
||||
# ==================== 模型配置文件 ====================
|
||||
model_config_path: str = "../config/models.yaml" # 模型配置文件路径(相对 backend)
|
||||
|
||||
# ==================== provider API Key / Base URL ====================
|
||||
openai_api_key: str = "" # OpenAI API Key
|
||||
openai_base_url: str = "https://api.openai.com/v1" # OpenAI API 地址
|
||||
deepseek_api_key: str = "" # DeepSeek API Key
|
||||
deepseek_base_url: str = "https://api.deepseek.com" # DeepSeek API 地址(OpenAI 兼容)
|
||||
xiaomi_mimo_api_key: str = "" # 小米 MiMo API Key
|
||||
xiaomi_mimo_base_url: str = "https://api.xiaomimimo.com/v1" # 小米 MiMo API 地址
|
||||
anthropic_api_key: str = "" # Anthropic API Key
|
||||
anthropic_base_url: str = "https://api.anthropic.com" # Anthropic API 地址
|
||||
volcengine_ark_api_key: str = "" # 火山方舟 / 豆包 API Key
|
||||
volcengine_ark_base_url: str = "https://ark.cn-beijing.volces.com/api/v3" # 火山方舟 API 地址
|
||||
custom_llm_api_key: str = "" # 自定义 LLM API Key
|
||||
custom_llm_base_url: str = "" # 自定义 LLM API 地址
|
||||
yuanqi_api_key: str = "" # 元炁本地推理引擎 API Key
|
||||
yuanqi_base_url: str = "http://localhost:11434" # 元炁本地推理引擎 API 地址
|
||||
|
||||
# ==================== endpoint 池配置 ====================
|
||||
# 物理 endpoint 池路由、failover 与重试策略(见 ai_provider/pool.py 与 resilience.py)
|
||||
model_endpoint_pool_enabled: bool = True # 是否启用 endpoint 池路由(False 时走逻辑模型直连)
|
||||
model_endpoint_pool_dry_run_selection: bool = False # dry-run:只预选并记录,不实际路由
|
||||
model_endpoint_pool_expose_debug_headers: bool = False # 是否在响应头暴露 endpoint 路由调试信息
|
||||
model_endpoint_pool_agent_failover_enabled: bool = (
|
||||
False # Agent 调用是否允许跨 endpoint failover
|
||||
)
|
||||
model_endpoint_pool_per_endpoint_max_retries: int = 1 # 单 endpoint 失败时的本地重试次数
|
||||
model_endpoint_pool_retry_base_delay_seconds: float = 0.25 # endpoint 本地重试基础退避(秒)
|
||||
model_endpoint_pool_retry_max_delay_seconds: float = 2.0 # endpoint 本地重试最大退避(秒)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""运行时与 SSE 配置:事件流、超时、工具运行时开关等。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class RuntimeConfig(BaseSettings):
|
||||
"""SSE / 运行时 / 工具运行时配置。"""
|
||||
|
||||
# ==================== SSE 配置 ====================
|
||||
enable_sse: bool = True # 是否启用 SSE
|
||||
max_text_message_bytes: int = 32768 # 最大文本消息字节数
|
||||
sse_poll_interval_seconds: float = 0.01 # SSE 轮询间隔(秒,10ms:前端逐段平滑增量)
|
||||
sse_heartbeat_seconds: float = 15.0 # SSE 心跳间隔(秒)
|
||||
run_timeout_seconds: int = 120 # 运行超时时间(秒)
|
||||
# Agent 循环最大轮数:模型每轮仍带工具调用则继续,超限截断返回(见 agent.runtime.DEFAULT_MAX_ITERATIONS)
|
||||
agent_max_iterations: int = 8
|
||||
# 单次高风险工具确认挂起超时(秒):须小于 run_timeout_seconds,超时按拒绝处理;
|
||||
# 若 >= run_timeout_seconds,run 整体超时会先到,导致确认中的 run 直接 failed
|
||||
confirmation_timeout_seconds: int = 60
|
||||
# 是否启用数据库持久化的确认存储(用于多 worker 部署);关闭则使用进程内 InMemoryConfirmationStore(默认)
|
||||
use_db_confirmation_store: bool = False
|
||||
run_max_events: int = 1000 # 最大事件数
|
||||
event_payload_max_bytes: int = 65536 # 事件负载最大字节数
|
||||
memory_injection_limit: int = 5 # 记忆注入条数上限
|
||||
|
||||
# ==================== 工具运行时配置 ====================
|
||||
enable_tool_runtime: bool = True # 是否启用百工工具运行时(司命 + 沙箱)
|
||||
tool_timeout_seconds: int = 600 # 单个工具调用超时秒数
|
||||
|
||||
# ==================== 洛书规划器 ====================
|
||||
enable_deliberative_planning: bool = False # 是否启用显式多步规划(§10.2 增强能力)
|
||||
|
||||
# ==================== 大周天(长期演化循环) ====================
|
||||
enable_big_cycle: bool = True # 是否启用大周天自动触发
|
||||
big_cycle_memory_threshold: int = 20 # 触发大周天的记忆数阈值
|
||||
big_cycle_min_interval_seconds: int = 300 # 两次大周天最小间隔(秒),避免频繁触发
|
||||
big_cycle_model: str = "deepseek-v4-flash" # 大周天候选生成所用模型(models.yaml name 或 tag)
|
||||
big_cycle_max_candidates: int = 10 # 单次大周天生成的演化候选上限
|
||||
big_cycle_token_budget: int = 64000 # 单次大周天的 token 预算上限(超限跳过非关键阶段)
|
||||
big_cycle_max_drift: float = 2.0 # 最大累积漂移值(超过后暂停自动演化)
|
||||
big_cycle_drift_threshold: float = 0.6 # 单次演化候选的漂移阈值(>= 此值不自动应用)
|
||||
|
||||
# ==================== 内驱力(太一枢)[PLANNED] ====================
|
||||
enable_intrinsic_drive: bool = False # 是否启用内驱力扫描(curiosity / exploration)
|
||||
drive_scan_interval_seconds: int = 3600 # 内驱力扫描间隔秒数
|
||||
drive_max_curiosity_per_scan: int = 5 # 单次扫描最多好奇问题数
|
||||
drive_allow_interrupt: bool = False # 是否允许内驱力中断当前任务
|
||||
|
||||
# ==================== 司命安全策略 ====================
|
||||
# 工具策略 YAML 路径(相对 backend);未配置时 SafetyGovernor 使用内置默认空策略。
|
||||
safety_policy_path: str = ""
|
||||
safety_always_confirm_high_risk: bool = True # 高风险动作是否总是确认
|
||||
|
||||
# ==================== MCP / 联网工具网络超时 ====================
|
||||
mcp_list_timeout_seconds: float = 10.0 # MCP tools/list 请求超时(秒)
|
||||
mcp_call_timeout_seconds: float = 30.0 # MCP tools/call 请求超时(秒)
|
||||
tool_web_http_timeout_seconds: float = 15.0 # fetch_url 的 HTTP 超时(秒)
|
||||
|
||||
# ==================== MCP stdio 子进程 ====================
|
||||
mcp_stdio_start_timeout_seconds: float = 15.0 # stdio 子进程启动超时(秒)
|
||||
mcp_stdio_call_timeout_seconds: float = 30.0 # stdio 单次 tools/call 超时(秒)
|
||||
mcp_stdio_stop_timeout_seconds: float = 5.0 # SIGTERM → SIGKILL 等待(秒)
|
||||
|
||||
# ==================== 向量记忆检索 ====================
|
||||
vector_db_enabled: bool = False # 是否启用向量数据库(关则纯 SQL 检索)
|
||||
vector_db_type: str = "chroma" # 向量库类型:chroma / milvus
|
||||
chromadb_host: str = "" # ChromaDB HTTP 主机(空则内存模式)
|
||||
chromadb_port: int = 8000 # ChromaDB HTTP 端口
|
||||
chroma_collection: str = "hunjing_memories" # ChromaDB collection 名
|
||||
chroma_persist_directory: str = "../db/chroma" # ChromaDB 持久化目录(相对 backend)
|
||||
embedding_model_hint: str = "bge-m3" # embedding 模型逻辑名
|
||||
|
||||
# ==================== 自动记忆候选 [PLANNED] ====================
|
||||
enable_memory_candidates: bool = False # 是否启用自动记忆候选抽取
|
||||
memory_candidate_min_importance: float = 0.7 # 候选记忆重要性阈值(0.0~1.0)
|
||||
|
||||
# ==================== 审计日志(司命枢·起居注)[PLANNED] ====================
|
||||
enable_audit_log: bool = True # 是否启用审计日志
|
||||
|
||||
# ==================== 感知(烛照枢)[PLANNED] ====================
|
||||
enable_camera: bool = False # 是否启用摄像头(视觉感知)
|
||||
enable_microphone: bool = False # 是否启用麦克风(听觉感知)
|
||||
perception_save_raw_media: bool = False # 是否保存原始感知文件
|
||||
perception_max_media_mb: int = 20 # 感知文件最大大小(MB)
|
||||
|
||||
# ==================== 语音服务 [PLANNED] ====================
|
||||
enable_asr: bool = False # 是否启用 ASR(语音转文字)
|
||||
enable_tts: bool = False # 是否启用 TTS(文字转语音)
|
||||
enable_voice_training: bool = False # 是否启用语音训练(微调 TTS 音色)
|
||||
enable_voice_chat: bool = False # 是否启用实时语音对话
|
||||
|
||||
# ==================== 导入限制 [PLANNED] ====================
|
||||
import_max_file_mb: int = 20 # 人物资料导入文件最大大小(MB)
|
||||
import_audio_max_duration_seconds: int = 600 # 语音样本最大时长(秒)
|
||||
import_allowed_audio_mime_types: str = (
|
||||
"audio/wav,audio/mpeg,audio/mp4,audio/flac" # 允许上传的语音样本 MIME 类型
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""乾坤沙箱配置。
|
||||
|
||||
从环境变量加载(与根目录 ``.env`` 统一)。字段名与 env 名一一对应(去除前缀后 snake_case
|
||||
即 env 名),由 ``pydantic-settings`` 自动绑定,无需手动 ``os.getenv``。
|
||||
|
||||
- ``rust_sandbox_url`` 取自 ``RUST_SANDBOX_URL``(后端连沙箱的地址)。
|
||||
- ``sandbox_auth_secret`` 与沙箱端 ``SANDBOX_AUTH_SECRET`` 共享,用于 HMAC 授权凭证签发。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class SandboxSettings(BaseSettings):
|
||||
"""沙箱客户端运行配置。"""
|
||||
|
||||
rust_sandbox_url: str = "http://127.0.0.1:8090" # RUST_SANDBOX_URL:后端连沙箱的地址
|
||||
sandbox_auth_enabled: bool = True # SANDBOX_AUTH_ENABLED:是否启用 HMAC 授权
|
||||
sandbox_auth_secret: str = "" # SANDBOX_AUTH_SECRET:与沙箱端共享的 HMAC 密钥
|
||||
sandbox_default_timeout_ms: int = 30_000 # SANDBOX_DEFAULT_TIMEOUT_MS:默认执行超时
|
||||
sandbox_auth_ttl_seconds: int = 30 # SANDBOX_AUTH_TTL_SECONDS:授权凭证有效期
|
||||
|
||||
def http_timeout(self, exec_timeout_ms: int | None) -> float:
|
||||
"""HTTP 读取超时:执行超时 + 5s 余量,下限 10s。"""
|
||||
exec_ms = (
|
||||
exec_timeout_ms if exec_timeout_ms is not None else self.sandbox_default_timeout_ms
|
||||
)
|
||||
return max(exec_ms / 1000 + 5.0, 10.0)
|
||||
@@ -0,0 +1,144 @@
|
||||
"""主 Settings:多继承所有子配置,统一以绝对路径 ``_env_file`` 加载 ``.env``。
|
||||
|
||||
子配置见同目录下的 ``app_config`` / ``auth_config`` / ``database_config`` / ``log_config`` /
|
||||
``runtime_config`` / ``model_config`` / ``sandbox_config``。本文件聚合字段,并承载
|
||||
跨子配置的路径解析与计算属性。
|
||||
|
||||
配置优先级(自高到低):命令行参数 > 系统环境变量 > ``.env`` > 类属性默认值。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from app.core.config.app_config import AppConfig
|
||||
from app.core.config.auth_config import AuthConfig
|
||||
from app.core.config.database_config import DatabaseConfig
|
||||
from app.core.config.log_config import LogConfig
|
||||
from app.core.config.model_config import ModelGatewayConfig
|
||||
from app.core.config.runtime_config import RuntimeConfig
|
||||
from app.core.config.sandbox_config import SandboxSettings
|
||||
from app.core.config.time_config import TimeConfig
|
||||
|
||||
# backend 目录:app/core/config/settings.py → parents[3] = backend/
|
||||
_BACKEND_DIR = Path(__file__).resolve().parents[3]
|
||||
# .env 永远在项目根目录(backend 父级),以绝对路径定位
|
||||
_ENV_FILE = _BACKEND_DIR.parent / ".env"
|
||||
|
||||
|
||||
class Settings(
|
||||
AppConfig,
|
||||
AuthConfig,
|
||||
DatabaseConfig,
|
||||
LogConfig,
|
||||
RuntimeConfig,
|
||||
ModelGatewayConfig,
|
||||
SandboxSettings,
|
||||
TimeConfig,
|
||||
BaseSettings,
|
||||
):
|
||||
"""应用配置类。
|
||||
|
||||
所有配置项可通过 ``.env`` 文件或环境变量覆盖。路径配置会自动解析为绝对路径。
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=str(_ENV_FILE), # 绝对路径,不使用 python-dotenv
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False, # 环境变量名不区分大小写
|
||||
extra="ignore", # 忽略未定义的额外字段
|
||||
)
|
||||
|
||||
# ==================== 路径解析 ====================
|
||||
|
||||
@cached_property
|
||||
def backend_dir(self) -> Path:
|
||||
"""backend 目录路径。"""
|
||||
return _BACKEND_DIR
|
||||
|
||||
@cached_property
|
||||
def project_root(self) -> Path:
|
||||
"""项目根目录路径(backend 的父级)。"""
|
||||
return _BACKEND_DIR.parent
|
||||
|
||||
def resolve_from_backend(self, value: str) -> Path:
|
||||
"""解析相对于 backend 目录的路径为绝对路径。
|
||||
|
||||
Args:
|
||||
value: 相对路径或绝对路径字符串
|
||||
|
||||
Returns:
|
||||
解析后的绝对路径
|
||||
"""
|
||||
path = Path(value)
|
||||
if path.is_absolute():
|
||||
return path
|
||||
return (self.backend_dir / path).resolve()
|
||||
|
||||
# ==================== 计算属性 ====================
|
||||
|
||||
@property
|
||||
def resolved_database_url(self) -> str:
|
||||
"""解析数据库连接 URL。
|
||||
|
||||
如果设置了 ``database_url`` 则直接使用,否则根据 ``database_type`` 构建。
|
||||
"""
|
||||
if self.database_url:
|
||||
return self.database_url
|
||||
if self.database_type == "sqlite":
|
||||
db_path = self.resolve_from_backend(self.sqlite_database_path)
|
||||
return f"sqlite+aiosqlite:///{db_path}"
|
||||
password = self.postgres_password
|
||||
ssl = "?ssl=require" if self.postgres_ssl else ""
|
||||
return (
|
||||
f"postgresql+asyncpg://{self.postgres_user}:{password}"
|
||||
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}{ssl}"
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def resolved_log_dir(self) -> Path:
|
||||
"""解析日志目录为绝对路径。"""
|
||||
return self.resolve_from_backend(self.log_dir)
|
||||
|
||||
@cached_property
|
||||
def resolved_character_templates_path(self) -> Path:
|
||||
"""解析人物模板配置文件路径。
|
||||
|
||||
如果配置文件不存在,尝试使用示例配置文件。
|
||||
"""
|
||||
configured = self.resolve_from_backend(self.character_templates_path)
|
||||
if configured.exists():
|
||||
return configured
|
||||
example = self.project_root / "config" / "character_templates.example.yaml"
|
||||
if example.exists():
|
||||
return example
|
||||
return configured
|
||||
|
||||
@cached_property
|
||||
def resolved_model_config_path(self) -> Path:
|
||||
"""解析模型配置文件路径。
|
||||
|
||||
如果配置文件不存在,尝试使用示例配置文件。
|
||||
"""
|
||||
configured = self.resolve_from_backend(self.model_config_path)
|
||||
if configured.exists():
|
||||
return configured
|
||||
# 如果配置文件不存在,尝试使用示例配置
|
||||
example = self.project_root / "config" / "models.example.yaml"
|
||||
if example.exists():
|
||||
return example
|
||||
return configured
|
||||
|
||||
@cached_property
|
||||
def resolved_avatar_dir(self) -> Path:
|
||||
"""解析角色头像存储目录为绝对路径。"""
|
||||
path = self.resolve_from_backend(self.avatar_dir)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
# 全局配置实例
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""辰正时态配置。
|
||||
|
||||
所有字段可通过环境变量或 ``.env`` 覆盖(字段名即环境变量名,由 ``Settings.model_config``
|
||||
统一加载,无前缀,与既有 ``AppConfig`` 等同级配置一致)。默认值对齐辰正设计 §7.1
|
||||
离线档位与 §7.2/§7.3 触发阈值。v1 输入无 character 维度的时区/位置(character 模型
|
||||
未持有时区字段),统一用此处的默认值;v2 从档案/会话覆盖。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class TimeConfig(BaseSettings):
|
||||
"""辰正时态服务配置。"""
|
||||
|
||||
# 时区与位置(v1 全局默认;v2 从档案/会话覆盖)
|
||||
user_timezone: str = "Asia/Shanghai"
|
||||
home_timezone: str = "Asia/Shanghai"
|
||||
default_city: str = "上海"
|
||||
default_locale: str = "zh-CN"
|
||||
|
||||
# 离线档位阈值(分钟),对齐设计 §7.1 / duration.py 默认
|
||||
offline_active_minutes: float = 60
|
||||
offline_short_absence_minutes: float = 720
|
||||
offline_long_absence_minutes: float = 4320
|
||||
offline_reunion_impact_minutes: float = 4320
|
||||
|
||||
# 感慨触发(§7.2)
|
||||
sentiment_prewarm_days: int = 7
|
||||
# 认识天数有感节点(逗号分隔;§7.2 duration_node)
|
||||
sentiment_duration_nodes: str = "100,200,365,500,730,1000"
|
||||
# 强节气名(§7.2 solar_term;立春/冬至/清明/中秋/除夕等)
|
||||
sentiment_strong_solar_terms: str = "立春,清明,冬至,立夏,立秋,立冬"
|
||||
|
||||
# 关怀触发(§7.3)
|
||||
care_enabled: bool = True
|
||||
jetlag_threshold_minutes: int = 180
|
||||
late_night_min_turns: int = 3
|
||||
|
||||
@property
|
||||
def duration_nodes(self) -> set[int]:
|
||||
"""解析 ``sentiment_duration_nodes`` 为整数集合。"""
|
||||
return {int(x) for x in self.sentiment_duration_nodes.split(",") if x.strip()}
|
||||
|
||||
@property
|
||||
def strong_solar_terms(self) -> set[str]:
|
||||
"""解析强节气名集合。"""
|
||||
return {s.strip() for s in self.sentiment_strong_solar_terms.split(",") if s.strip()}
|
||||
@@ -0,0 +1,123 @@
|
||||
"""统一错误响应与异常处理。"""
|
||||
|
||||
from contextvars import ContextVar
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app.core.ids import new_trace_id
|
||||
from app.core.logging import log_with_context
|
||||
from app.core.security import redact_value
|
||||
from app.modules.safety.exceptions import SafetyError
|
||||
|
||||
trace_id_var: ContextVar[str | None] = ContextVar("trace_id", default=None)
|
||||
|
||||
ERROR_HTTP_STATUS: dict[str, int] = {
|
||||
"invalid_request": status.HTTP_400_BAD_REQUEST,
|
||||
"unauthorized": status.HTTP_401_UNAUTHORIZED,
|
||||
"permission_denied": status.HTTP_403_FORBIDDEN,
|
||||
"requires_confirmation": status.HTTP_403_FORBIDDEN,
|
||||
"not_found": status.HTTP_404_NOT_FOUND,
|
||||
"conflict": status.HTTP_409_CONFLICT,
|
||||
"payload_too_large": status.HTTP_413_CONTENT_TOO_LARGE,
|
||||
"unsupported_media_type": status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
"validation_failed": status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
"context_overflow": status.HTTP_400_BAD_REQUEST,
|
||||
"run_timeout": status.HTTP_504_GATEWAY_TIMEOUT,
|
||||
"model_error": status.HTTP_502_BAD_GATEWAY,
|
||||
"model_rate_limited": status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
"model_timeout": status.HTTP_504_GATEWAY_TIMEOUT,
|
||||
"model_config_error": status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
"model_unavailable": status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
"model_provider_key_missing": status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
"tool_error": status.HTTP_502_BAD_GATEWAY,
|
||||
"safety_rejected": status.HTTP_403_FORBIDDEN,
|
||||
"rate_limited": status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
"internal_error": status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
code: str,
|
||||
message: str,
|
||||
*,
|
||||
status_code: int | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.status_code = status_code or ERROR_HTTP_STATUS.get(code, status.HTTP_400_BAD_REQUEST)
|
||||
self.details = details or {}
|
||||
|
||||
|
||||
def current_trace_id() -> str:
|
||||
trace_id = trace_id_var.get()
|
||||
if trace_id is None:
|
||||
trace_id = new_trace_id()
|
||||
trace_id_var.set(trace_id)
|
||||
return trace_id
|
||||
|
||||
|
||||
def error_response(error: AppError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=error.status_code,
|
||||
content={
|
||||
"error": {
|
||||
"code": error.code,
|
||||
"message": error.message,
|
||||
"trace_id": current_trace_id(),
|
||||
"details": redact_value(error.details),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
@app.exception_handler(AppError)
|
||||
async def handle_app_error(_request: Request, exc: AppError) -> JSONResponse:
|
||||
return error_response(exc)
|
||||
|
||||
@app.exception_handler(SafetyError)
|
||||
async def handle_safety_error(_request: Request, exc: SafetyError) -> JSONResponse:
|
||||
# 司命异常自带契约错误码(如 permission_denied → 403),按错误码映射 HTTP 状态。
|
||||
return error_response(AppError(exc.code, exc.message))
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def handle_validation_error(
|
||||
_request: Request, exc: RequestValidationError
|
||||
) -> JSONResponse:
|
||||
return error_response(
|
||||
AppError(
|
||||
"validation_failed",
|
||||
"请求字段校验失败。",
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
details={"errors": exc.errors()},
|
||||
)
|
||||
)
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def handle_http_error(_request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
||||
code = "not_found" if exc.status_code == status.HTTP_404_NOT_FOUND else "invalid_request"
|
||||
return error_response(AppError(code, str(exc.detail), status_code=exc.status_code))
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def handle_unexpected_error(_request: Request, exc: Exception) -> JSONResponse:
|
||||
log_with_context(
|
||||
"未处理异常",
|
||||
level="ERROR",
|
||||
trace_id=current_trace_id(),
|
||||
error_type=exc.__class__.__name__,
|
||||
)
|
||||
return error_response(
|
||||
AppError(
|
||||
"internal_error",
|
||||
"服务端内部错误。",
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
details={"type": exc.__class__.__name__},
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,131 @@
|
||||
"""统一 ID 生成。"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
def new_id(prefix: str) -> str:
|
||||
return f"{prefix}{uuid4().hex}"
|
||||
|
||||
|
||||
def new_character_id() -> str:
|
||||
return new_id("character_")
|
||||
|
||||
|
||||
def new_session_id() -> str:
|
||||
return new_id("session_")
|
||||
|
||||
|
||||
def new_message_id() -> str:
|
||||
return new_id("message_")
|
||||
|
||||
|
||||
def new_run_id() -> str:
|
||||
return new_id("run_")
|
||||
|
||||
|
||||
def new_event_id() -> str:
|
||||
return new_id("event_")
|
||||
|
||||
|
||||
def new_trace_id() -> str:
|
||||
return new_id("trace_")
|
||||
|
||||
|
||||
def new_memory_id() -> str:
|
||||
return new_id("memory_")
|
||||
|
||||
|
||||
def new_memory_link_id() -> str:
|
||||
return new_id("memory_link_")
|
||||
|
||||
|
||||
def new_personality_id() -> str:
|
||||
return new_id("personality_")
|
||||
|
||||
|
||||
def new_personality_proposal_id() -> str:
|
||||
return new_id("personality_proposal_")
|
||||
|
||||
|
||||
def new_audit_id() -> str:
|
||||
return new_id("audit_")
|
||||
|
||||
|
||||
def new_goal_id() -> str:
|
||||
return new_id("goal_")
|
||||
|
||||
|
||||
def new_world_entity_id() -> str:
|
||||
return new_id("world_entity_")
|
||||
|
||||
|
||||
def new_world_relation_id() -> str:
|
||||
return new_id("world_relation_")
|
||||
|
||||
|
||||
def new_plan_id() -> str:
|
||||
return new_id("plan_")
|
||||
|
||||
|
||||
def new_import_job_id() -> str:
|
||||
return new_id("import_")
|
||||
|
||||
|
||||
def new_import_item_id() -> str:
|
||||
return new_id("import_item_")
|
||||
|
||||
|
||||
def new_profile_candidate_id() -> str:
|
||||
return new_id("profile_candidate_")
|
||||
|
||||
|
||||
def new_memory_candidate_id() -> str:
|
||||
return new_id("memory_candidate_")
|
||||
|
||||
|
||||
def new_cycle_id() -> str:
|
||||
return new_id("cycle_")
|
||||
|
||||
|
||||
def new_evolution_candidate_id() -> str:
|
||||
return new_id("evolution_")
|
||||
|
||||
|
||||
def new_curiosity_id() -> str:
|
||||
return new_id("curiosity_")
|
||||
|
||||
|
||||
def new_subgoal_id() -> str:
|
||||
return new_id("subgoal_")
|
||||
|
||||
|
||||
def new_skill_id() -> str:
|
||||
return new_id("skill_")
|
||||
|
||||
|
||||
def new_mcp_server_id() -> str:
|
||||
return new_id("mcp_")
|
||||
|
||||
|
||||
def new_anniversary_id() -> str:
|
||||
return new_id("anniv_")
|
||||
|
||||
|
||||
def new_user_id() -> str:
|
||||
return new_id("user_")
|
||||
|
||||
|
||||
def new_role_id() -> str:
|
||||
return new_id("rl_")
|
||||
|
||||
|
||||
def new_permission_id() -> str:
|
||||
return new_id("pm_")
|
||||
|
||||
|
||||
def new_lorebook_id() -> str:
|
||||
return new_id("lorebook_")
|
||||
|
||||
|
||||
def new_lorebook_entry_id() -> str:
|
||||
return new_id("lorebook_entry_")
|
||||
@@ -0,0 +1,235 @@
|
||||
"""loguru 日志配置(后端日志系统的唯一出口)。
|
||||
|
||||
提供全局日志配置、标准 logging 接管、上下文管理和便捷的日志记录函数。
|
||||
所有日志(含 uvicorn / SQLAlchemy 等第三方库)统一汇入 loguru,
|
||||
文件统一存放在项目根目录的 logs 文件夹下。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from contextvars import ContextVar
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# ==================== 上下文变量 ====================
|
||||
# 用于在异步请求中传递日志上下文
|
||||
|
||||
_request_id_var: ContextVar[str | None] = ContextVar("request_id", default=None)
|
||||
"""请求 ID 上下文变量,用于追踪单个请求的完整链路。"""
|
||||
|
||||
_user_id_var: ContextVar[str | None] = ContextVar("user_id", default=None)
|
||||
"""用户 ID 上下文变量,用于记录当前操作用户。"""
|
||||
|
||||
|
||||
class InterceptHandler(logging.Handler):
|
||||
"""标准 ``logging`` → loguru 转发器。
|
||||
|
||||
uvicorn / SQLAlchemy / httpx / langchain 等第三方库一律使用标准 ``logging`` 模块;
|
||||
把本 handler 装到 root logger 后,它们的日志会统一汇入 loguru,与应用自有日志
|
||||
走同一 sink、格式与落盘策略,实现整个后端日志系统的单一出口。
|
||||
|
||||
注:此处 ``import logging`` 与「避免使用 logging 模块」规范并不冲突——那条规范
|
||||
针对的是「用 logging 记业务日志」,而本 handler 的职责恰是把 logging 的输出
|
||||
导入 loguru,是统一日志出口的必要接管机制。
|
||||
"""
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
# 将标准 logging 的级别名映射为 loguru 级别
|
||||
try:
|
||||
level: str | int = logger.level(record.levelname).name
|
||||
except ValueError:
|
||||
level = record.levelno
|
||||
|
||||
# 沿调用栈回溯到真正的日志发出处,让 loguru 记录正确的文件名 / 行号
|
||||
frame, depth = logging.currentframe(), 2
|
||||
while frame and frame.f_code.co_filename == logging.__file__:
|
||||
frame = frame.f_back
|
||||
depth += 1
|
||||
|
||||
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""配置全局日志系统。
|
||||
|
||||
配置内容:
|
||||
1. 创建日志目录
|
||||
2. 移除 loguru 默认处理器
|
||||
3. 添加控制台输出(彩色,级别跟随 debug 开关与 LOG_LEVEL)
|
||||
4. 添加主日志文件(按大小轮转、压缩)
|
||||
5. 添加错误日志单独文件(保留更久)
|
||||
6. 添加访问日志(可选)
|
||||
7. 接管标准 logging,使 uvicorn / SQLAlchemy 等第三方日志汇入 loguru
|
||||
|
||||
日志文件位置:
|
||||
- {project_root}/logs/app.log # 主日志
|
||||
- {project_root}/logs/errors.log # 错误日志
|
||||
- {project_root}/logs/access.log # 访问日志(可选)
|
||||
"""
|
||||
# 获取并创建日志目录
|
||||
log_dir: Path = settings.resolved_log_dir
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 移除 loguru 默认的 stderr 处理器
|
||||
logger.remove()
|
||||
|
||||
# 判断是否为开发环境。开发 reload 进程不使用异步队列,避免退出时残留 multiprocessing semaphore。
|
||||
is_dev = settings.is_development
|
||||
enqueue_logs = not is_dev
|
||||
|
||||
# 控制台输出格式(带颜色,便于阅读)
|
||||
console_format = (
|
||||
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
|
||||
"<level>{level: <8}</level> | "
|
||||
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan>"
|
||||
"{extra} - <level>{message}</level>\n{exception}"
|
||||
)
|
||||
|
||||
# 文件输出格式(纯文本,便于解析)
|
||||
file_format = (
|
||||
"{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | "
|
||||
"{name}:{function}:{line}{extra} | {message}\n{exception}"
|
||||
)
|
||||
|
||||
# ----- 控制台输出 -----
|
||||
# 控制台级别跟随 debug 开关与 LOG_LEVEL:debug=true → DEBUG,否则用 LOG_LEVEL。
|
||||
console_level = "DEBUG" if settings.debug else settings.log_level
|
||||
logger.add(
|
||||
sys.stderr, # 输出到标准错误
|
||||
level=console_level, # 日志级别
|
||||
format=console_format, # 彩色格式
|
||||
colorize=is_dev, # 开发环境启用颜色
|
||||
enqueue=enqueue_logs, # 生产环境异步写入,提高性能
|
||||
backtrace=is_dev, # 开发环境显示完整回溯
|
||||
diagnose=is_dev, # 开发环境显示诊断信息
|
||||
)
|
||||
|
||||
# ----- 主日志文件 -----
|
||||
# 记录所有级别日志,按大小轮转,自动压缩
|
||||
logger.add(
|
||||
log_dir / "app.log", # 日志文件路径
|
||||
level=settings.log_level, # 日志级别
|
||||
format=file_format, # 纯文本格式
|
||||
rotation=settings.log_rotation_size, # 轮转大小:10 MB
|
||||
retention=f"{settings.log_retention_days} days", # 保留天数:14天
|
||||
compression="gz", # 压缩旧日志
|
||||
encoding="utf-8", # 编码
|
||||
enqueue=enqueue_logs, # 生产环境异步写入
|
||||
backtrace=False, # 不保存完整回溯
|
||||
diagnose=False, # 不保存诊断信息
|
||||
)
|
||||
|
||||
# ----- 错误日志单独文件 -----
|
||||
# 只记录 ERROR 及以上级别,保留更久,便于故障排查
|
||||
logger.add(
|
||||
log_dir / "errors.log", # 错误日志文件路径
|
||||
level="ERROR", # 只记录 ERROR 及以上
|
||||
format=file_format, # 纯文本格式
|
||||
rotation="50 MB", # 轮转大小:50 MB
|
||||
retention="30 days", # 保留天数:30天
|
||||
compression="gz", # 压缩旧日志
|
||||
encoding="utf-8", # 编码
|
||||
enqueue=enqueue_logs, # 生产环境异步写入
|
||||
backtrace=True, # 错误时保留完整堆栈
|
||||
diagnose=True, # 错误时保留诊断信息
|
||||
)
|
||||
|
||||
# ----- 访问日志(可选) -----
|
||||
# 通过 filter 只记录标记为 access 类型的日志
|
||||
if settings.log_access:
|
||||
logger.add(
|
||||
log_dir / "access.log", # 访问日志文件路径
|
||||
level="INFO", # INFO 及以上
|
||||
format=file_format, # 纯文本格式
|
||||
rotation="100 MB", # 轮转大小:100 MB
|
||||
retention="7 days", # 保留天数:7天
|
||||
compression="gz", # 压缩旧日志
|
||||
encoding="utf-8", # 编码
|
||||
enqueue=enqueue_logs, # 生产环境异步写入
|
||||
filter=lambda r: r["extra"].get("type") == "access", # 只记录访问日志
|
||||
)
|
||||
|
||||
# ----- 接管标准 logging -----
|
||||
# force=True 清掉第三方库自带的 handler(含 uvicorn 默认 dictConfig);
|
||||
# level=0 让记录全量进入,最终级别过滤交由上方 loguru sink 完成。
|
||||
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
|
||||
|
||||
# 应用已有自研访问日志中间件(trace_middleware + log_access,含 trace_id /
|
||||
# method / path / status / duration 等结构化字段),关闭 uvicorn.access 以
|
||||
# 避免访问日志重复落盘。
|
||||
logging.getLogger("uvicorn.access").disabled = True
|
||||
|
||||
|
||||
# ==================== 日志上下文管理 ====================
|
||||
|
||||
|
||||
def set_request_id(request_id: str) -> None:
|
||||
"""设置当前请求的 ID。
|
||||
|
||||
Args:
|
||||
request_id: 请求唯一标识,用于链路追踪
|
||||
"""
|
||||
_request_id_var.set(request_id)
|
||||
|
||||
|
||||
def set_user_id(user_id: str) -> None:
|
||||
"""设置当前操作用户的 ID。
|
||||
|
||||
Args:
|
||||
user_id: 用户唯一标识
|
||||
"""
|
||||
_user_id_var.set(user_id)
|
||||
|
||||
|
||||
def clear_log_context() -> None:
|
||||
"""清除当前日志上下文。
|
||||
|
||||
在请求结束后调用,避免上下文污染。
|
||||
"""
|
||||
_request_id_var.set(None)
|
||||
_user_id_var.set(None)
|
||||
|
||||
|
||||
def get_log_context() -> dict[str, Any]:
|
||||
"""获取当前日志上下文。
|
||||
|
||||
Returns:
|
||||
包含 request_id 和 user_id 的字典(如果已设置)
|
||||
"""
|
||||
context = {}
|
||||
if rid := _request_id_var.get():
|
||||
context["request_id"] = rid
|
||||
if uid := _user_id_var.get():
|
||||
context["user_id"] = uid
|
||||
return context
|
||||
|
||||
|
||||
def log_with_context(message: str, level: str = "INFO", **kwargs: Any) -> None:
|
||||
"""带上下文记录日志。
|
||||
|
||||
自动附加当前请求的 request_id 和 user_id。
|
||||
|
||||
Args:
|
||||
message: 日志消息
|
||||
level: 日志级别(DEBUG/INFO/WARNING/ERROR)
|
||||
**kwargs: 额外的上下文字段
|
||||
"""
|
||||
context = get_log_context()
|
||||
context.update(kwargs)
|
||||
logger.bind(**context).log(level, message)
|
||||
|
||||
|
||||
def log_access(message: str, **kwargs: Any) -> None:
|
||||
"""记录访问日志。
|
||||
|
||||
自动添加 type="access" 标记,会被 access.log 过滤器捕获。
|
||||
|
||||
Args:
|
||||
message: 日志消息
|
||||
**kwargs: 额外的上下文字段(如 method, path, status, duration 等)
|
||||
"""
|
||||
logger.bind(type="access", **kwargs).info(message)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""敏感信息脱敏工具。"""
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
SENSITIVE_KEYS = {
|
||||
"api_key",
|
||||
"apikey",
|
||||
"authorization",
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"private_key",
|
||||
}
|
||||
_REDACTED = "[REDACTED]"
|
||||
_BEARER_RE = re.compile(r"Bearer\s+[A-Za-z0-9._~+/-]+=*", re.IGNORECASE)
|
||||
|
||||
|
||||
def redact_text(value: str) -> str:
|
||||
return _BEARER_RE.sub("Bearer [REDACTED]", value)
|
||||
|
||||
|
||||
def redact_mapping(data: Mapping[str, Any]) -> dict[str, Any]:
|
||||
redacted: dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
normalized = key.lower().replace("-", "_")
|
||||
if normalized in SENSITIVE_KEYS or any(part in normalized for part in SENSITIVE_KEYS):
|
||||
redacted[key] = _REDACTED
|
||||
else:
|
||||
redacted[key] = redact_value(value)
|
||||
return redacted
|
||||
|
||||
|
||||
def redact_value(value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
return redact_text(value)
|
||||
if isinstance(value, Mapping):
|
||||
return redact_mapping(value)
|
||||
if isinstance(value, list):
|
||||
return [redact_value(item) for item in value]
|
||||
return value
|
||||
@@ -0,0 +1,15 @@
|
||||
"""时间工具。"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def to_iso(value: datetime | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
Reference in new issue
Block a user