feat: initial commit
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI(
|
||||
title="Eryao API",
|
||||
description="觅爻签问后端服务",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,3 @@
|
||||
from .settings import Settings, config
|
||||
|
||||
__all__ = ["Settings", "config"]
|
||||
@@ -0,0 +1,234 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from schemas.agent.system_agent import SystemAgentLLMConfig
|
||||
from core.db.session import AsyncSessionLocal
|
||||
from core.logging import get_logger
|
||||
from models.llm import Llm
|
||||
from models.llm_factory import LlmFactory
|
||||
from models.system_agents import SystemAgents
|
||||
|
||||
logger = get_logger("core.config.initial.init_data")
|
||||
|
||||
|
||||
class LlmFactorySeed(BaseModel):
|
||||
name: str
|
||||
request_url: str
|
||||
avatar: str | None = None
|
||||
|
||||
|
||||
class LlmSeed(BaseModel):
|
||||
model_code: str
|
||||
factory_name: str
|
||||
pricing_tiers: list[dict[str, float | int]]
|
||||
|
||||
|
||||
class LlmCatalogSeed(BaseModel):
|
||||
factories: list[LlmFactorySeed]
|
||||
llms: list[LlmSeed]
|
||||
|
||||
|
||||
class SystemAgentsSeed(BaseModel):
|
||||
agent_type: str
|
||||
llm_model_code: str
|
||||
status: str
|
||||
config: SystemAgentLLMConfig | None = None
|
||||
|
||||
|
||||
class SystemAgentsYaml(BaseModel):
|
||||
agents: list[SystemAgentsSeed]
|
||||
|
||||
|
||||
def _default_catalog_path() -> Path:
|
||||
return (
|
||||
Path(__file__).resolve().parents[1] / "static" / "database" / "llm_catalog.yaml"
|
||||
)
|
||||
|
||||
|
||||
def load_llm_catalog(catalog_path: Path | None = None) -> dict[str, Any]:
|
||||
path = catalog_path or _default_catalog_path()
|
||||
with path.open("r", encoding="utf-8") as file:
|
||||
loaded = yaml.safe_load(file) or {}
|
||||
if not isinstance(loaded, dict):
|
||||
raise ValueError(f"Invalid LLM catalog format: {path}")
|
||||
raw_factories = loaded.get("factories", [])
|
||||
raw_llms = loaded.get("llms", [])
|
||||
if not isinstance(raw_factories, list) or not isinstance(raw_llms, list):
|
||||
raise ValueError(f"Invalid LLM catalog sections: {path}")
|
||||
try:
|
||||
parsed = LlmCatalogSeed.model_validate(
|
||||
{
|
||||
"factories": list(raw_factories),
|
||||
"llms": list(raw_llms),
|
||||
}
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise ValueError(f"Invalid LLM catalog data: {path}") from exc
|
||||
|
||||
return parsed.model_dump()
|
||||
|
||||
|
||||
def _default_system_agents_path() -> Path:
|
||||
return (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "static"
|
||||
/ "database"
|
||||
/ "system_agents.yaml"
|
||||
)
|
||||
|
||||
|
||||
def load_system_agents(catalog_path: Path | None = None) -> dict[str, Any]:
|
||||
path = catalog_path or _default_system_agents_path()
|
||||
with path.open("r", encoding="utf-8") as file:
|
||||
loaded = yaml.safe_load(file) or {}
|
||||
if not isinstance(loaded, dict):
|
||||
raise ValueError(f"Invalid system agents format: {path}")
|
||||
raw_agents = loaded.get("agents", [])
|
||||
if not isinstance(raw_agents, list):
|
||||
raise ValueError(f"Invalid system agents agents section: {path}")
|
||||
try:
|
||||
parsed = SystemAgentsYaml.model_validate({"agents": list(raw_agents)})
|
||||
except ValidationError as exc:
|
||||
raise ValueError(f"Invalid system agents data: {path}") from exc
|
||||
|
||||
return parsed.model_dump()
|
||||
|
||||
|
||||
async def _upsert_factory(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
name: str,
|
||||
request_url: str,
|
||||
avatar: str | None,
|
||||
) -> uuid.UUID:
|
||||
result = await session.execute(select(LlmFactory).where(LlmFactory.name == name))
|
||||
factory = result.scalar_one_or_none()
|
||||
|
||||
if factory is None:
|
||||
factory = LlmFactory(name=name, request_url=request_url, avatar=avatar)
|
||||
session.add(factory)
|
||||
await session.flush()
|
||||
else:
|
||||
factory.request_url = request_url
|
||||
factory.avatar = avatar
|
||||
|
||||
return factory.id
|
||||
|
||||
|
||||
async def _upsert_llm(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
model_code: str,
|
||||
factory_id: uuid.UUID,
|
||||
) -> None:
|
||||
result = await session.execute(select(Llm).where(Llm.model_code == model_code))
|
||||
llm = result.scalar_one_or_none()
|
||||
if llm is None:
|
||||
session.add(Llm(model_code=model_code, factory_id=factory_id))
|
||||
return
|
||||
llm.factory_id = factory_id
|
||||
|
||||
|
||||
async def _upsert_system_agents(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
agent_type: str,
|
||||
llm_id: uuid.UUID,
|
||||
status: str,
|
||||
config: dict[str, Any],
|
||||
) -> None:
|
||||
result = await session.execute(
|
||||
select(SystemAgents).where(SystemAgents.agent_type == agent_type)
|
||||
)
|
||||
catalog_entry = result.scalar_one_or_none()
|
||||
|
||||
if catalog_entry is None:
|
||||
session.add(
|
||||
SystemAgents(
|
||||
agent_type=agent_type,
|
||||
llm_id=llm_id,
|
||||
status=status,
|
||||
config=config,
|
||||
)
|
||||
)
|
||||
else:
|
||||
catalog_entry.llm_id = llm_id
|
||||
catalog_entry.status = status
|
||||
catalog_entry.config = config
|
||||
|
||||
|
||||
async def initialize_system_agents() -> None:
|
||||
"""Initialize system agents from YAML."""
|
||||
catalog = load_system_agents()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
async with session.begin():
|
||||
for agent in catalog["agents"]:
|
||||
result = await session.execute(
|
||||
select(Llm).where(Llm.model_code == agent["llm_model_code"])
|
||||
)
|
||||
llm = result.scalar_one_or_none()
|
||||
if llm is None:
|
||||
raise RuntimeError(
|
||||
f"LLM model '{agent['llm_model_code']}' not found for agent type '{agent['agent_type']}'"
|
||||
)
|
||||
|
||||
await _upsert_system_agents(
|
||||
session,
|
||||
agent_type=agent["agent_type"],
|
||||
llm_id=llm.id,
|
||||
status=agent["status"],
|
||||
config=SystemAgentLLMConfig.model_validate(
|
||||
agent.get("config") or {}
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
logger.info("Initialized system agents")
|
||||
|
||||
|
||||
async def initialize_llm_catalog() -> None:
|
||||
"""Initialize LLM catalog from YAML."""
|
||||
catalog = load_llm_catalog()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
async with session.begin():
|
||||
factory_id_by_name: dict[str, uuid.UUID] = {}
|
||||
for factory in catalog["factories"]:
|
||||
factory_id = await _upsert_factory(
|
||||
session,
|
||||
name=factory["name"],
|
||||
request_url=factory["request_url"],
|
||||
avatar=factory.get("avatar"),
|
||||
)
|
||||
factory_id_by_name[factory["name"]] = factory_id
|
||||
|
||||
for llm in catalog["llms"]:
|
||||
factory_name = llm["factory_name"]
|
||||
resolved_factory_id = factory_id_by_name.get(factory_name)
|
||||
if resolved_factory_id is None:
|
||||
raise RuntimeError(
|
||||
f"Factory '{factory_name}' not found for model '{llm['model_code']}'"
|
||||
)
|
||||
await _upsert_llm(
|
||||
session,
|
||||
model_code=llm["model_code"],
|
||||
factory_id=resolved_factory_id,
|
||||
)
|
||||
|
||||
logger.info("Initialized LLM factory/model seed data")
|
||||
|
||||
|
||||
async def initialize_data() -> bool:
|
||||
"""Initialize bootstrap data."""
|
||||
await initialize_llm_catalog()
|
||||
await initialize_system_agents()
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,263 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Literal
|
||||
from urllib.parse import quote
|
||||
|
||||
from pydantic import (
|
||||
AnyHttpUrl,
|
||||
BaseModel,
|
||||
Field,
|
||||
computed_field,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
def _resolve_project_root() -> Path:
|
||||
current = Path(__file__).resolve()
|
||||
for parent in current.parents:
|
||||
if (
|
||||
(parent / "pyproject.toml").is_file()
|
||||
and (parent / "backend").is_dir()
|
||||
and (parent / "infra").is_dir()
|
||||
):
|
||||
return parent
|
||||
|
||||
for parent in current.parents:
|
||||
if parent.name == "backend":
|
||||
return parent.parent
|
||||
|
||||
return Path.cwd().resolve()
|
||||
|
||||
|
||||
class RuntimeSettings(BaseModel):
|
||||
environment: Literal["dev", "test", "prod"] = "dev"
|
||||
service_name: str = "app"
|
||||
debug: bool = True
|
||||
log_level: str = "INFO"
|
||||
log_json: bool = True
|
||||
log_rotation: Literal["time", "size", "none"] = "time"
|
||||
log_rotation_when: str = "midnight"
|
||||
log_rotation_interval: int = 1
|
||||
log_rotation_backup_count: int = 14
|
||||
log_rotation_max_bytes: int = 10_000_000
|
||||
log_dir: str = "logs"
|
||||
log_error_dir: str = "logs/errors"
|
||||
log_file_name: str = ""
|
||||
log_error_file_name: str = ""
|
||||
log_sensitive_fields: list[str] = Field(
|
||||
default_factory=lambda: [
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
"api_key",
|
||||
"authorization",
|
||||
"cookie",
|
||||
"client_ip",
|
||||
"user_id",
|
||||
]
|
||||
)
|
||||
sql_log_queries: bool = False
|
||||
trusted_proxy_ips: list[str] = Field(default_factory=list)
|
||||
|
||||
@field_validator("log_dir", mode="before")
|
||||
@classmethod
|
||||
def lock_log_dir(cls, _: object) -> str:
|
||||
return "logs"
|
||||
|
||||
@field_validator("log_error_dir", mode="before")
|
||||
@classmethod
|
||||
def lock_log_error_dir(cls, _: object) -> str:
|
||||
return "logs/errors"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def ensure_service_scoped_log_file_names(self) -> "RuntimeSettings":
|
||||
service = "".join(
|
||||
char if char.isalnum() or char in {"-", "_"} else "-"
|
||||
for char in self.service_name
|
||||
).strip("-_")
|
||||
service_name = service or "app"
|
||||
|
||||
if not self.log_file_name.strip():
|
||||
self.log_file_name = f"{service_name}.log"
|
||||
if not self.log_error_file_name.strip():
|
||||
self.log_error_file_name = f"{service_name}.error.log"
|
||||
|
||||
return self
|
||||
|
||||
|
||||
class CorsSettings(BaseModel):
|
||||
allow_origins: list[str] = Field(
|
||||
default_factory=lambda: [
|
||||
"http://localhost",
|
||||
"http://localhost:3000",
|
||||
]
|
||||
)
|
||||
allow_credentials: bool = True
|
||||
allow_methods: list[str] = Field(default_factory=lambda: ["*"])
|
||||
allow_headers: list[str] = Field(default_factory=lambda: ["*"])
|
||||
|
||||
|
||||
class RedisSettings(BaseModel):
|
||||
host: str = "redis"
|
||||
port: int = 6379
|
||||
password: str | None = None
|
||||
db: int = 0
|
||||
socket_connect_timeout: float = 1.0
|
||||
socket_timeout: float = 1.0
|
||||
max_connections: int = 10
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def url(self) -> str:
|
||||
if self.password:
|
||||
password = quote(self.password, safe="")
|
||||
return f"redis://:{password}@{self.host}:{self.port}/{self.db}"
|
||||
return f"redis://{self.host}:{self.port}/{self.db}"
|
||||
|
||||
|
||||
class DatabaseSettings(BaseModel):
|
||||
host: str = "localhost"
|
||||
port: int = 3306
|
||||
name: str = "eryao"
|
||||
user: str = "root"
|
||||
password: str = "CHANGE_ME"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def url(self) -> str:
|
||||
password = quote(self.password, safe="")
|
||||
return (
|
||||
f"mysql+aiomysql://{self.user}:{password}"
|
||||
f"@{self.host}:{self.port}/{self.name}"
|
||||
)
|
||||
|
||||
|
||||
class AppVersionSettings(BaseModel):
|
||||
manifest_path: str = Field(
|
||||
default="deploy/static/releases/manifest.json",
|
||||
description="发布清单文件路径,相对于项目根目录",
|
||||
)
|
||||
release_path_prefix: str = Field(
|
||||
default="releases",
|
||||
description="下载 URL 中文件目录前缀",
|
||||
)
|
||||
download_base_url: AnyHttpUrl | None = Field(
|
||||
default=None,
|
||||
description="下载链接基础域名,如 https://your-domain.com",
|
||||
)
|
||||
|
||||
@field_validator("download_base_url", mode="before")
|
||||
@classmethod
|
||||
def empty_download_base_url_to_none(cls, value: object) -> object:
|
||||
if value == "":
|
||||
return None
|
||||
return value
|
||||
|
||||
@field_validator("manifest_path")
|
||||
@classmethod
|
||||
def validate_manifest_path(cls, value: str) -> str:
|
||||
normalized = Path(value)
|
||||
if normalized.is_absolute() or ".." in normalized.parts:
|
||||
raise ValueError("manifest_path must be a safe relative path")
|
||||
return value
|
||||
|
||||
|
||||
class AliyunSmsSettings(BaseModel):
|
||||
access_key_id: str = "CHANGE_ME"
|
||||
access_key_secret: str = "CHANGE_ME"
|
||||
sign_name: str = "CHANGE_ME"
|
||||
template_code: str = "CHANGE_ME"
|
||||
region_id: str = "cn-hangzhou"
|
||||
endpoint: str = "dysmsapi.aliyuncs.com"
|
||||
test_mode: bool = False
|
||||
|
||||
|
||||
class AliyunContentSecuritySettings(BaseModel):
|
||||
access_key_id: str = "CHANGE_ME"
|
||||
access_key_secret: str = "CHANGE_ME"
|
||||
endpoint: str = "green-cip.cn-shenzhen.aliyuncs.com"
|
||||
|
||||
|
||||
class AlipaySettings(BaseModel):
|
||||
app_id: str = "CHANGE_ME"
|
||||
merchant_id: str = "CHANGE_ME"
|
||||
public_key: str = "CHANGE_ME"
|
||||
private_key: str = "CHANGE_ME"
|
||||
sign_type: str = "RSA2"
|
||||
notify_url: str = ""
|
||||
timeout_express: str = "30m"
|
||||
sandbox: bool = False
|
||||
|
||||
|
||||
class DeepSeekSettings(BaseModel):
|
||||
api_key: str = "CHANGE_ME"
|
||||
|
||||
|
||||
class AuthSettings(BaseModel):
|
||||
token_expiration_days: int = 7
|
||||
token_refresh_threshold_hours: int = 2
|
||||
|
||||
|
||||
class VerificationSettings(BaseModel):
|
||||
code_length: int = 6
|
||||
expiration_minutes: int = 5
|
||||
test_mode: bool = False
|
||||
|
||||
|
||||
class SensitiveWordSettings(BaseModel):
|
||||
use_aliyun: bool = True
|
||||
fallback_to_local: bool = True
|
||||
|
||||
|
||||
class TestSettings(BaseModel):
|
||||
phone: str = ""
|
||||
password: str = ""
|
||||
|
||||
|
||||
def _resolve_env_file() -> str:
|
||||
current = Path(__file__).resolve()
|
||||
for parent in [current, *current.parents]:
|
||||
candidate = parent / ".env"
|
||||
if candidate.is_file():
|
||||
return str(candidate)
|
||||
return ".env"
|
||||
|
||||
|
||||
PROJECT_ROOT = _resolve_project_root()
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
runtime: RuntimeSettings = RuntimeSettings()
|
||||
cors: CorsSettings = CorsSettings()
|
||||
redis: RedisSettings = RedisSettings()
|
||||
database: DatabaseSettings = DatabaseSettings()
|
||||
app_version: AppVersionSettings = AppVersionSettings()
|
||||
aliyun_sms: AliyunSmsSettings = AliyunSmsSettings()
|
||||
aliyun_content_security: AliyunContentSecuritySettings = (
|
||||
AliyunContentSecuritySettings()
|
||||
)
|
||||
alipay: AlipaySettings = AlipaySettings()
|
||||
deepseek: DeepSeekSettings = DeepSeekSettings()
|
||||
auth: AuthSettings = AuthSettings()
|
||||
verification: VerificationSettings = VerificationSettings()
|
||||
sensitive_word: SensitiveWordSettings = SensitiveWordSettings()
|
||||
test: TestSettings = Field(default_factory=TestSettings)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
return self.database.url
|
||||
|
||||
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
|
||||
env_file=_resolve_env_file(),
|
||||
env_prefix="ERYAO_",
|
||||
env_nested_delimiter="__",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
|
||||
config = Settings() # type: ignore[reportCallIssue]
|
||||
@@ -0,0 +1,34 @@
|
||||
input_template: |
|
||||
你正在执行一次"自动化记忆回顾与整理"任务。
|
||||
|
||||
任务目标:
|
||||
1) 回顾最近两天的聊天与上下文,识别用户长期偏好、习惯和关键事实的变化。
|
||||
2) 对已经失效、被否定或明显过期的信息执行遗忘。
|
||||
3) 对新增且有证据支持的信息执行写入。
|
||||
4) 严禁编造;没有证据就不要写入。
|
||||
5) 只更新最小必要字段,避免过度覆盖。
|
||||
|
||||
输出要求:
|
||||
- 必须使用以下固定格式输出:
|
||||
<----------【周期任务输出】---------->
|
||||
【记忆回顾】<一句人性化总结,说明今天主要发生了什么>
|
||||
【新增记忆】<按"X条:要点1;要点2"描述;没有则写"0条">
|
||||
【遗忘记忆】<按"X条:要点1;要点2"描述;没有则写"0条">
|
||||
【未来展望】<基于本次记忆变化,给出1-2条温和、可执行的后续建议;若暂无建议则说明"可继续观察">
|
||||
|
||||
表达风格:
|
||||
- 语言自然、温和、可读,像助理在做每日回顾。
|
||||
- 结论先行,避免空话,不要输出与任务无关的闲聊内容。
|
||||
enabled_tools:
|
||||
- memory.write
|
||||
- memory.forget
|
||||
context:
|
||||
source: latest_chat
|
||||
window_mode: day
|
||||
window_count: 2
|
||||
schedule:
|
||||
type: daily
|
||||
run_at:
|
||||
hour: 8
|
||||
minute: 0
|
||||
weekdays: null
|
||||
@@ -0,0 +1,70 @@
|
||||
factories:
|
||||
- name: dashscope
|
||||
request_url: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/qwen-color.png
|
||||
|
||||
- name: minimax
|
||||
request_url: https://api.minimaxi.com/v1
|
||||
avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/minimax-color.png
|
||||
|
||||
- name: moonshot
|
||||
request_url: https://api.moonshot.cn/v1
|
||||
avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/moonshot.png
|
||||
|
||||
- name: deepseek
|
||||
request_url: https://api.deepseek.com/v1
|
||||
avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/deepseek-color.png
|
||||
|
||||
- name: volcengine
|
||||
request_url: https://ark.cn-beijing.volces.com/api/v3
|
||||
avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/doubao-color.png
|
||||
|
||||
- name: zai
|
||||
request_url: https://api.z.ai/api/paas/v4
|
||||
avatar: https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light/zai.png
|
||||
|
||||
llms:
|
||||
# qwen3.5-flash (3 tiers: 128K, 256K, 1M)
|
||||
- model_code: qwen3.5-flash
|
||||
factory_name: dashscope
|
||||
pricing_tiers:
|
||||
- max_prompt_tokens: 128000
|
||||
input_cost_per_token: 0.0000002
|
||||
output_cost_per_token: 0.000002
|
||||
cache_hit_cost_per_token: 0.00000002
|
||||
- max_prompt_tokens: 256000
|
||||
input_cost_per_token: 0.0000008
|
||||
output_cost_per_token: 0.000008
|
||||
cache_hit_cost_per_token: 0.00000008
|
||||
- max_prompt_tokens: 1000000
|
||||
input_cost_per_token: 0.0000012
|
||||
output_cost_per_token: 0.000012
|
||||
cache_hit_cost_per_token: 0.00000012
|
||||
|
||||
- model_code: qwen3.5-35b-a3b
|
||||
factory_name: dashscope
|
||||
pricing_tiers:
|
||||
- max_prompt_tokens: 128000
|
||||
input_cost_per_token: 0.0000004
|
||||
output_cost_per_token: 0.0000032
|
||||
- max_prompt_tokens: 256000
|
||||
input_cost_per_token: 0.0000016
|
||||
output_cost_per_token: 0.0000128
|
||||
|
||||
- model_code: deepseek-chat
|
||||
factory_name: deepseek
|
||||
pricing_tiers:
|
||||
- max_prompt_tokens: 128000
|
||||
input_cost_per_token: 0.000002
|
||||
output_cost_per_token: 0.000003
|
||||
cache_hit_cost_per_token: 0.0000002
|
||||
|
||||
- model_code: qwen3.5-27b
|
||||
factory_name: dashscope
|
||||
pricing_tiers:
|
||||
- max_prompt_tokens: 128000
|
||||
input_cost_per_token: 0.0000006
|
||||
output_cost_per_token: 0.0000048
|
||||
- max_prompt_tokens: 256000
|
||||
input_cost_per_token: 0.0000018
|
||||
output_cost_per_token: 0.0000144
|
||||
@@ -0,0 +1,28 @@
|
||||
agents:
|
||||
- agent_type: router
|
||||
llm_model_code: qwen3.5-flash
|
||||
status: active
|
||||
config:
|
||||
temperature: 0.7
|
||||
max_tokens: null
|
||||
timeout_seconds: 30
|
||||
context_messages:
|
||||
mode: day
|
||||
count: 2
|
||||
enabled_tools: []
|
||||
|
||||
- agent_type: worker
|
||||
llm_model_code: qwen3.5-flash
|
||||
status: active
|
||||
config:
|
||||
temperature: 0.7
|
||||
max_tokens: null
|
||||
timeout_seconds: 30
|
||||
context_messages:
|
||||
mode: number
|
||||
count: 20
|
||||
enabled_tools:
|
||||
- calendar.read
|
||||
- calendar.write
|
||||
- calendar.share
|
||||
- user.lookup
|
||||
@@ -0,0 +1,158 @@
|
||||
version: "1.0"
|
||||
routes:
|
||||
- route_id: auth.boot
|
||||
path: /boot
|
||||
description: Bootstraps auth session and redirects to login or home.
|
||||
category: auth
|
||||
auth_required: false
|
||||
- route_id: auth.login
|
||||
path: /login
|
||||
description: Login entry for unauthenticated users.
|
||||
category: auth
|
||||
auth_required: false
|
||||
- route_id: home.main
|
||||
path: /
|
||||
description: Main assistant home screen.
|
||||
category: home
|
||||
auth_required: true
|
||||
- route_id: message.invite_list
|
||||
path: /messages/invites
|
||||
description: Lists message invitations.
|
||||
category: messages
|
||||
auth_required: true
|
||||
- route_id: message.invite_detail
|
||||
path: /messages/invites/{id}
|
||||
description: Shows details for a single invitation.
|
||||
category: messages
|
||||
auth_required: true
|
||||
path_params:
|
||||
- id
|
||||
- route_id: contacts.list
|
||||
path: /contacts
|
||||
description: Contact list and quick relationship actions.
|
||||
category: contacts
|
||||
auth_required: true
|
||||
- route_id: contacts.add
|
||||
path: /contacts/add
|
||||
description: Create or edit a contact profile.
|
||||
category: contacts
|
||||
auth_required: true
|
||||
- route_id: calendar.dayweek
|
||||
path: /calendar/dayweek
|
||||
description: Day and week calendar view.
|
||||
category: calendar
|
||||
auth_required: true
|
||||
query_params:
|
||||
- date
|
||||
- from
|
||||
- route_id: calendar.month
|
||||
path: /calendar/month
|
||||
description: Month calendar overview.
|
||||
category: calendar
|
||||
auth_required: true
|
||||
query_params:
|
||||
- from
|
||||
- route_id: calendar.event_detail
|
||||
path: /calendar/events/{id}
|
||||
description: Detail page for one calendar event.
|
||||
category: calendar
|
||||
auth_required: true
|
||||
path_params:
|
||||
- id
|
||||
- route_id: calendar.event_create
|
||||
path: /calendar/events/new
|
||||
description: Create page for one calendar event.
|
||||
category: calendar
|
||||
auth_required: true
|
||||
query_params:
|
||||
- date
|
||||
- route_id: calendar.event_edit
|
||||
path: /calendar/events/{id}/edit
|
||||
description: Edit page for one calendar event.
|
||||
category: calendar
|
||||
auth_required: true
|
||||
path_params:
|
||||
- id
|
||||
- route_id: calendar.event_share
|
||||
path: /calendar/events/{id}/share
|
||||
description: Share settings page for one calendar event.
|
||||
category: calendar
|
||||
auth_required: true
|
||||
path_params:
|
||||
- id
|
||||
- route_id: todo.list
|
||||
path: /todo
|
||||
description: Todo quadrants and backlog overview.
|
||||
category: todo
|
||||
auth_required: true
|
||||
- route_id: todo.create
|
||||
path: /todo/new
|
||||
description: Create page for one todo item.
|
||||
category: todo
|
||||
auth_required: true
|
||||
- route_id: todo.detail
|
||||
path: /todo/{id}
|
||||
description: Detail page for one todo item.
|
||||
category: todo
|
||||
auth_required: true
|
||||
path_params:
|
||||
- id
|
||||
- route_id: todo.edit
|
||||
path: /todo/{id}/edit
|
||||
description: Dedicated subpage for editing one todo item (not an in-page modal).
|
||||
category: todo
|
||||
auth_required: true
|
||||
path_params:
|
||||
- id
|
||||
- route_id: settings.main
|
||||
path: /settings
|
||||
description: Settings hub page.
|
||||
category: settings
|
||||
auth_required: true
|
||||
- route_id: settings.features
|
||||
path: /settings/features
|
||||
description: Automation job list page.
|
||||
category: settings
|
||||
auth_required: true
|
||||
- route_id: settings.job_new
|
||||
path: /settings/job/new
|
||||
description: Create page for one automation job.
|
||||
category: settings
|
||||
auth_required: true
|
||||
- route_id: settings.job_detail
|
||||
path: /settings/job/{id}
|
||||
description: Detail page for one automation job.
|
||||
category: settings
|
||||
auth_required: true
|
||||
path_params:
|
||||
- id
|
||||
- route_id: settings.memory
|
||||
path: /settings/memory
|
||||
description: Memory preferences and controls.
|
||||
category: settings
|
||||
auth_required: true
|
||||
- route_id: settings.memory_user
|
||||
path: /settings/memory/user
|
||||
description: User memory summary view.
|
||||
category: settings
|
||||
auth_required: true
|
||||
- route_id: settings.memory_work
|
||||
path: /settings/memory/work
|
||||
description: Work memory summary view.
|
||||
category: settings
|
||||
auth_required: true
|
||||
- route_id: settings.memory_user_edit
|
||||
path: /settings/memory/user/edit
|
||||
description: Edit user memory details.
|
||||
category: settings
|
||||
auth_required: true
|
||||
- route_id: settings.memory_work_edit
|
||||
path: /settings/memory/work/edit
|
||||
description: Edit work memory details.
|
||||
category: settings
|
||||
auth_required: true
|
||||
- route_id: settings.edit_profile
|
||||
path: /edit-profile
|
||||
description: Profile editing page.
|
||||
category: settings
|
||||
auth_required: true
|
||||
@@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.db.session import AsyncSessionLocal, engine, get_db
|
||||
|
||||
__all__ = ["AsyncSessionLocal", "engine", "get_db"]
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all ORM models."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Adds created_at and updated_at timestamps."""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class SoftDeleteMixin:
|
||||
"""Adds soft delete timestamp column."""
|
||||
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from sqlalchemy import Select, select, update
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.db.base import Base
|
||||
|
||||
ModelType = TypeVar("ModelType", bound=Base)
|
||||
|
||||
|
||||
class BaseRepository(Generic[ModelType]):
|
||||
_session: AsyncSession
|
||||
_model: type[ModelType]
|
||||
|
||||
def __init__(self, session: AsyncSession, model: type[ModelType]) -> None:
|
||||
self._session = session
|
||||
self._model = model
|
||||
|
||||
def _deleted_at_column(self) -> Any | None:
|
||||
return getattr(self._model, "deleted_at", None)
|
||||
|
||||
def _apply_soft_delete_filter(self, stmt: Select) -> Select:
|
||||
deleted_at = self._deleted_at_column()
|
||||
if deleted_at is None:
|
||||
return stmt
|
||||
return stmt.where(deleted_at.is_(None))
|
||||
|
||||
async def get_by_id(self, entity_id: Any) -> ModelType | None:
|
||||
id_column = getattr(self._model, "id")
|
||||
stmt = select(self._model).where(id_column == entity_id)
|
||||
stmt = self._apply_soft_delete_filter(stmt)
|
||||
result = await self._session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_one(self, *filters: Any) -> ModelType | None:
|
||||
stmt = select(self._model).where(*filters)
|
||||
stmt = self._apply_soft_delete_filter(stmt)
|
||||
result = await self._session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def update_by_id(
|
||||
self, entity_id: Any, update_data: dict[str, Any]
|
||||
) -> ModelType | None:
|
||||
if not update_data:
|
||||
return await self.get_by_id(entity_id)
|
||||
|
||||
id_column = getattr(self._model, "id")
|
||||
stmt = update(self._model).where(id_column == entity_id)
|
||||
deleted_at = self._deleted_at_column()
|
||||
if deleted_at is not None:
|
||||
stmt = stmt.where(deleted_at.is_(None))
|
||||
stmt = stmt.values(**update_data).returning(self._model)
|
||||
|
||||
try:
|
||||
result = await self._session.execute(stmt)
|
||||
await self._session.flush()
|
||||
return result.scalar_one_or_none()
|
||||
except SQLAlchemyError:
|
||||
raise
|
||||
|
||||
async def soft_delete_by_id(self, entity_id: Any) -> ModelType | None:
|
||||
deleted_at = self._deleted_at_column()
|
||||
if deleted_at is None:
|
||||
raise ValueError("Soft delete is not supported for this model")
|
||||
|
||||
id_column = getattr(self._model, "id")
|
||||
stmt = (
|
||||
update(self._model)
|
||||
.where(id_column == entity_id)
|
||||
.where(deleted_at.is_(None))
|
||||
.values(deleted_at=datetime.now(timezone.utc))
|
||||
.returning(self._model)
|
||||
)
|
||||
|
||||
try:
|
||||
result = await self._session.execute(stmt)
|
||||
await self._session.flush()
|
||||
return result.scalar_one_or_none()
|
||||
except SQLAlchemyError:
|
||||
raise
|
||||
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from core.auth.models import CurrentUser
|
||||
|
||||
|
||||
class BaseService:
|
||||
_current_user: CurrentUser | None
|
||||
|
||||
def __init__(self, current_user: CurrentUser | None) -> None:
|
||||
self._current_user = current_user
|
||||
|
||||
def require_current_user(self) -> CurrentUser:
|
||||
if self._current_user is None:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
return self._current_user
|
||||
|
||||
def require_user_id(self) -> UUID:
|
||||
return self.require_current_user().id
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from core.config.settings import config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
engine: AsyncEngine = create_async_engine(
|
||||
config.database_url,
|
||||
echo=config.runtime.sql_log_queries,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
AsyncSessionLocal: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Dependency that provides a database session.
|
||||
|
||||
The session is automatically closed when the request completes.
|
||||
Note: The caller (service layer) is responsible for commit/rollback.
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
@@ -0,0 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import JSON
|
||||
from sqlalchemy.dialects.mysql import JSON as MySQLJSON
|
||||
|
||||
json_type = JSON().with_variant(MySQLJSON, "mysql")
|
||||
@@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.http.response import ProblemDetails, build_problem_details
|
||||
|
||||
__all__ = ["ProblemDetails", "build_problem_details"]
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ApiProblemError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
status_code: int,
|
||||
detail: str | dict[str, Any],
|
||||
code: str | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
resolved_detail = detail
|
||||
resolved_code = code
|
||||
resolved_params = params
|
||||
|
||||
if isinstance(detail, dict):
|
||||
payload = detail
|
||||
resolved_code = resolved_code or str(
|
||||
payload.get("code") or "INTERNAL_ERROR"
|
||||
)
|
||||
resolved_detail = str(payload.get("detail") or "Request failed")
|
||||
raw_params = payload.get("params")
|
||||
if resolved_params is None and isinstance(raw_params, dict):
|
||||
resolved_params = raw_params
|
||||
|
||||
if not isinstance(resolved_detail, str):
|
||||
resolved_detail = str(resolved_detail)
|
||||
if not resolved_code or not isinstance(resolved_code, str):
|
||||
resolved_code = "INTERNAL_ERROR"
|
||||
|
||||
super().__init__(resolved_detail)
|
||||
self.status_code = status_code
|
||||
self.code = resolved_code
|
||||
self.detail = resolved_detail
|
||||
self.params = resolved_params
|
||||
|
||||
|
||||
def problem_payload(
|
||||
*,
|
||||
code: str,
|
||||
detail: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"code": code, "detail": detail}
|
||||
if params:
|
||||
payload["params"] = params
|
||||
return payload
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ProblemDetails(BaseModel):
|
||||
type: str = "about:blank"
|
||||
title: str
|
||||
status: int
|
||||
detail: str
|
||||
instance: str | None = None
|
||||
code: str | None = None
|
||||
params: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def build_problem_details(
|
||||
*,
|
||||
status_code: int,
|
||||
detail: str,
|
||||
type_value: str = "about:blank",
|
||||
title: str | None = None,
|
||||
instance: str | None = None,
|
||||
code: str | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> ProblemDetails:
|
||||
resolved_title = title or HTTPStatus(status_code).phrase
|
||||
return ProblemDetails(
|
||||
type=type_value,
|
||||
title=resolved_title,
|
||||
status=status_code,
|
||||
detail=detail,
|
||||
instance=instance,
|
||||
code=code,
|
||||
params=params,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.logging.banner import log_service_banner
|
||||
from core.logging.config import configure_logging
|
||||
from core.logging.context import bind_context, clear_context, get_context
|
||||
from core.logging.logger import get_logger
|
||||
|
||||
__all__ = [
|
||||
"bind_context",
|
||||
"clear_context",
|
||||
"configure_logging",
|
||||
"get_context",
|
||||
"get_logger",
|
||||
"log_service_banner",
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import structlog
|
||||
|
||||
|
||||
def build_service_banner(service_name: str, environment: str) -> str:
|
||||
service_upper = service_name.upper()
|
||||
border = "=" * 50
|
||||
lines = [
|
||||
border,
|
||||
f" {service_upper}",
|
||||
f" Environment: {environment}",
|
||||
border,
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def log_service_banner(service_name: str, environment: str) -> None:
|
||||
logger = structlog.get_logger("banner")
|
||||
banner = build_service_banner(service_name, environment)
|
||||
logger.info(banner)
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from logging.config import dictConfig
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import structlog
|
||||
|
||||
from core.config.settings import PROJECT_ROOT, RuntimeSettings, Settings, config
|
||||
from core.logging.formatters import (
|
||||
build_plain_formatter,
|
||||
build_processor_formatter,
|
||||
ensure_message_key,
|
||||
)
|
||||
from core.logging.filters import build_sensitive_data_processor
|
||||
from core.logging.handlers import build_file_handler_config
|
||||
|
||||
|
||||
def _ensure_log_dirs(runtime: RuntimeSettings) -> None:
|
||||
_resolve_log_path(runtime.log_dir).mkdir(parents=True, exist_ok=True)
|
||||
_resolve_log_path(runtime.log_error_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _resolve_log_path(path: str) -> Path:
|
||||
candidate = Path(path)
|
||||
if candidate.is_absolute():
|
||||
return candidate
|
||||
return PROJECT_ROOT / candidate
|
||||
|
||||
|
||||
def build_logging_config(runtime: RuntimeSettings) -> dict[str, object]:
|
||||
log_dir = _resolve_log_path(runtime.log_dir)
|
||||
error_dir = _resolve_log_path(runtime.log_error_dir)
|
||||
formatter_name = "json" if runtime.log_json else "plain"
|
||||
|
||||
file_handler = build_file_handler_config(
|
||||
runtime,
|
||||
file_path=log_dir / runtime.log_file_name,
|
||||
level=runtime.log_level,
|
||||
formatter=formatter_name,
|
||||
)
|
||||
error_handler = build_file_handler_config(
|
||||
runtime,
|
||||
file_path=error_dir / runtime.log_error_file_name,
|
||||
level="ERROR",
|
||||
formatter=formatter_name,
|
||||
filters=["error_only"],
|
||||
)
|
||||
|
||||
return {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"filters": {
|
||||
"error_only": {
|
||||
"()": "core.logging.filters.ErrorLevelFilter",
|
||||
}
|
||||
},
|
||||
"formatters": {
|
||||
"json": {
|
||||
"()": build_processor_formatter,
|
||||
"sensitive_fields": runtime.log_sensitive_fields,
|
||||
},
|
||||
"plain": {
|
||||
"()": build_plain_formatter,
|
||||
"sensitive_fields": runtime.log_sensitive_fields,
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"file": file_handler,
|
||||
"error": error_handler,
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["file", "error"],
|
||||
"level": runtime.log_level,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def configure_logging(settings: Settings | None = None) -> None:
|
||||
active_settings = settings if settings is not None else cast(Settings, config)
|
||||
runtime = active_settings.runtime
|
||||
|
||||
try:
|
||||
_ensure_log_dirs(runtime)
|
||||
dictConfig(build_logging_config(runtime))
|
||||
except (OSError, ValueError) as exc:
|
||||
logging.basicConfig(level=runtime.log_level)
|
||||
logging.getLogger(__name__).error("Logging setup failed", exc_info=exc)
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
||||
structlog.processors.CallsiteParameterAdder(
|
||||
parameters=[
|
||||
structlog.processors.CallsiteParameter.MODULE,
|
||||
structlog.processors.CallsiteParameter.FUNC_NAME,
|
||||
structlog.processors.CallsiteParameter.LINENO,
|
||||
]
|
||||
),
|
||||
build_sensitive_data_processor(runtime.log_sensitive_fields),
|
||||
ensure_message_key,
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.processors.UnicodeDecoder(),
|
||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||
],
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from structlog import contextvars
|
||||
|
||||
|
||||
def bind_context(**values: object) -> None:
|
||||
contextvars.bind_contextvars(**values)
|
||||
|
||||
|
||||
def clear_context() -> None:
|
||||
contextvars.clear_contextvars()
|
||||
|
||||
|
||||
def get_context() -> dict[str, object]:
|
||||
return contextvars.get_contextvars()
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from typing import cast
|
||||
|
||||
from structlog.types import EventDict
|
||||
|
||||
|
||||
_NORMALIZE_PATTERN = re.compile(r"[^a-z0-9]")
|
||||
|
||||
|
||||
def _normalize_key(value: str) -> str:
|
||||
return _NORMALIZE_PATTERN.sub("", value.lower())
|
||||
|
||||
|
||||
def _is_sensitive_key(key: object, sensitive_fields: set[str]) -> bool:
|
||||
normalized_key = _normalize_key(str(key))
|
||||
return normalized_key in sensitive_fields or any(
|
||||
fragment in normalized_key for fragment in sensitive_fields
|
||||
)
|
||||
|
||||
|
||||
def _redact_value(value: object, sensitive_fields: set[str]) -> object:
|
||||
if isinstance(value, dict):
|
||||
typed_value = cast(dict[str, object], value)
|
||||
return {
|
||||
key: (
|
||||
"[REDACTED]"
|
||||
if _is_sensitive_key(key, sensitive_fields)
|
||||
else _redact_value(inner, sensitive_fields)
|
||||
)
|
||||
for key, inner in typed_value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_redact_value(item, sensitive_fields) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def build_sensitive_data_processor(
|
||||
sensitive_fields: list[str],
|
||||
) -> Callable[[object, str, EventDict], EventDict]:
|
||||
normalized = {_normalize_key(field) for field in sensitive_fields}
|
||||
|
||||
def processor(
|
||||
_logger: object, _method_name: str, event_dict: EventDict
|
||||
) -> EventDict:
|
||||
return cast(EventDict, _redact_value(event_dict, normalized))
|
||||
|
||||
return processor
|
||||
|
||||
|
||||
class ErrorLevelFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
return record.levelno >= logging.ERROR
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from structlog.dev import ConsoleRenderer
|
||||
from structlog.processors import JSONRenderer
|
||||
from structlog.stdlib import ProcessorFormatter
|
||||
from structlog.types import EventDict
|
||||
import structlog
|
||||
|
||||
from core.logging.filters import build_sensitive_data_processor
|
||||
|
||||
|
||||
def ensure_message_key(
|
||||
_logger: object, _method_name: str, event_dict: EventDict
|
||||
) -> EventDict:
|
||||
if "message" in event_dict:
|
||||
return event_dict
|
||||
if "event" not in event_dict:
|
||||
return event_dict
|
||||
|
||||
without_event = {key: value for key, value in event_dict.items() if key != "event"}
|
||||
return {**without_event, "message": event_dict["event"]}
|
||||
|
||||
|
||||
def build_processor_formatter(
|
||||
sensitive_fields: list[str] | None = None,
|
||||
) -> ProcessorFormatter:
|
||||
redact = build_sensitive_data_processor(sensitive_fields or [])
|
||||
return ProcessorFormatter(
|
||||
foreign_pre_chain=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
||||
structlog.processors.CallsiteParameterAdder(
|
||||
parameters=[
|
||||
structlog.processors.CallsiteParameter.MODULE,
|
||||
structlog.processors.CallsiteParameter.FUNC_NAME,
|
||||
structlog.processors.CallsiteParameter.LINENO,
|
||||
]
|
||||
),
|
||||
structlog.stdlib.ExtraAdder(),
|
||||
ensure_message_key,
|
||||
],
|
||||
processors=[
|
||||
redact,
|
||||
ensure_message_key,
|
||||
ProcessorFormatter.remove_processors_meta,
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.processors.UnicodeDecoder(),
|
||||
JSONRenderer(sort_keys=True),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def build_plain_formatter(
|
||||
sensitive_fields: list[str] | None = None,
|
||||
) -> ProcessorFormatter:
|
||||
redact = build_sensitive_data_processor(sensitive_fields or [])
|
||||
return ProcessorFormatter(
|
||||
foreign_pre_chain=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
||||
structlog.processors.CallsiteParameterAdder(
|
||||
parameters=[
|
||||
structlog.processors.CallsiteParameter.MODULE,
|
||||
structlog.processors.CallsiteParameter.FUNC_NAME,
|
||||
structlog.processors.CallsiteParameter.LINENO,
|
||||
]
|
||||
),
|
||||
structlog.stdlib.ExtraAdder(),
|
||||
ensure_message_key,
|
||||
],
|
||||
processors=[
|
||||
redact,
|
||||
ensure_message_key,
|
||||
ProcessorFormatter.remove_processors_meta,
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.processors.UnicodeDecoder(),
|
||||
ConsoleRenderer(colors=False),
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from core.config.settings import RuntimeSettings
|
||||
|
||||
|
||||
def build_file_handler_config(
|
||||
runtime: RuntimeSettings,
|
||||
file_path: Path,
|
||||
level: str,
|
||||
formatter: str,
|
||||
filters: list[str] | None = None,
|
||||
) -> dict[str, object]:
|
||||
filter_list = list(filters or [])
|
||||
base_config: dict[str, object] = {
|
||||
"level": level,
|
||||
"formatter": formatter,
|
||||
"filename": str(file_path),
|
||||
"encoding": "utf-8",
|
||||
}
|
||||
|
||||
if filter_list:
|
||||
base_config = {**base_config, "filters": filter_list}
|
||||
|
||||
if runtime.log_rotation == "time":
|
||||
return {
|
||||
**base_config,
|
||||
"class": "logging.handlers.TimedRotatingFileHandler",
|
||||
"when": runtime.log_rotation_when,
|
||||
"interval": runtime.log_rotation_interval,
|
||||
"backupCount": runtime.log_rotation_backup_count,
|
||||
}
|
||||
|
||||
if runtime.log_rotation == "size":
|
||||
return {
|
||||
**base_config,
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"maxBytes": runtime.log_rotation_max_bytes,
|
||||
"backupCount": runtime.log_rotation_backup_count,
|
||||
}
|
||||
|
||||
return {
|
||||
**base_config,
|
||||
"class": "logging.FileHandler",
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import structlog
|
||||
|
||||
|
||||
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
||||
return structlog.get_logger(name)
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import MutableMapping
|
||||
from typing import cast
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from starlette.requests import Request as StarletteRequest
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from core.logging.context import bind_context, clear_context
|
||||
from core.logging.logger import get_logger
|
||||
|
||||
|
||||
class RequestContextMiddleware:
|
||||
app: ASGIApp
|
||||
_header_name: str
|
||||
_request_id_pattern: re.Pattern[str]
|
||||
|
||||
def __init__(self, app: ASGIApp, header_name: str = "X-Request-ID") -> None:
|
||||
self.app = app
|
||||
self._header_name = header_name
|
||||
self._request_id_pattern = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope.get("type") != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
request = StarletteRequest(scope, receive=receive)
|
||||
request_id = self._normalize_request_id(request.headers.get(self._header_name))
|
||||
client_ip = request.client.host if request.client else None
|
||||
user_id = getattr(request.state, "user_id", None)
|
||||
|
||||
request.state.request_id = request_id
|
||||
|
||||
bind_context(
|
||||
request_id=request_id,
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
client_ip=client_ip,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
async def send_wrapper(message: MutableMapping[str, object]) -> None:
|
||||
if message.get("type") == "http.response.start":
|
||||
raw_headers = message.get("headers")
|
||||
headers = list(cast(list[tuple[bytes, bytes]], raw_headers or []))
|
||||
header_key = self._header_name.lower().encode()
|
||||
if not any(item[0].lower() == header_key for item in headers):
|
||||
headers.append((header_key, request_id.encode()))
|
||||
message = {**message, "headers": headers}
|
||||
await send(message)
|
||||
|
||||
try:
|
||||
await self.app(scope, receive, send_wrapper)
|
||||
finally:
|
||||
clear_context()
|
||||
|
||||
def _normalize_request_id(self, request_id: str | None) -> str:
|
||||
if request_id and self._request_id_pattern.match(request_id):
|
||||
return request_id
|
||||
return str(uuid4())
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
logger = get_logger("core.logging.exception")
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception) -> Response:
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
logger.exception(
|
||||
"Unhandled exception",
|
||||
error_type=exc.__class__.__name__,
|
||||
request_id=request_id,
|
||||
)
|
||||
headers = {"X-Request-ID": request_id} if request_id else None
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal Server Error"},
|
||||
headers=headers,
|
||||
)
|
||||
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from core.automation.scheduler import run_automation_scheduler_scan
|
||||
from core.config.initial.init_data import initialize_data
|
||||
from core.config.settings import config
|
||||
from core.logging import get_logger
|
||||
|
||||
logger = get_logger("core.runtime.cli")
|
||||
|
||||
|
||||
def _resolve_alembic_path() -> Path:
|
||||
"""Resolve alembic.ini path relative to project root."""
|
||||
project_root = Path(__file__).parents[3]
|
||||
alembic_path = project_root / "alembic" / "alembic.ini"
|
||||
if not alembic_path.exists():
|
||||
raise FileNotFoundError(f"Alembic config not found at {alembic_path}")
|
||||
return alembic_path
|
||||
|
||||
|
||||
def _redact_sensitive(text: str) -> str:
|
||||
"""Redact sensitive information from log output."""
|
||||
import re
|
||||
|
||||
SENSITIVE_KEYS = ("password", "token", "secret", "api_key")
|
||||
pattern = r"(?i)(" + "|".join(SENSITIVE_KEYS) + r")\s*[:=]\s*[\"']?([^\"',\n]+)"
|
||||
redacted = re.sub(pattern, r"\1=***", text)
|
||||
|
||||
auth_pattern = r"(?i)(authorization)\s*[:=]\s*[^\n]+"
|
||||
redacted = re.sub(auth_pattern, r"\1=***", redacted)
|
||||
|
||||
redacted = re.sub(r"://[^:]+:[^@]+@", "://***:***@", redacted)
|
||||
return redacted
|
||||
|
||||
|
||||
def run_migrations() -> bool:
|
||||
"""Run alembic migrations in a subprocess to avoid event loop conflicts."""
|
||||
import os
|
||||
|
||||
logger.info("Running alembic migrations")
|
||||
try:
|
||||
config_path = _resolve_alembic_path()
|
||||
logger.info("Using alembic config", path=str(config_path))
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = "backend/src"
|
||||
|
||||
result = subprocess.run(
|
||||
["uv", "run", "alembic", "-c", str(config_path), "upgrade", "head"],
|
||||
cwd=Path(__file__).parents[3],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(
|
||||
"Migration failed",
|
||||
returncode=result.returncode,
|
||||
stderr=_redact_sensitive(result.stderr),
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info("Migrations completed successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Migration failed", error=str(e))
|
||||
return False
|
||||
|
||||
|
||||
async def run_init_data() -> bool:
|
||||
"""Initialize bootstrap data."""
|
||||
logger.info("Running init-data")
|
||||
try:
|
||||
result = await initialize_data()
|
||||
if result:
|
||||
logger.info("Init-data completed successfully")
|
||||
else:
|
||||
logger.error("Init-data returned False")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error("Init-data failed", error=str(e))
|
||||
return False
|
||||
|
||||
|
||||
async def bootstrap() -> bool:
|
||||
"""Run migrations followed by init-data."""
|
||||
logger.info("Starting bootstrap (migrate + init-data)")
|
||||
|
||||
if not run_migrations():
|
||||
logger.error("Bootstrap aborted: migrations failed")
|
||||
return False
|
||||
|
||||
if not await run_init_data():
|
||||
logger.error("Bootstrap aborted: init-data failed")
|
||||
return False
|
||||
|
||||
logger.info("Bootstrap completed successfully")
|
||||
return True
|
||||
|
||||
|
||||
async def run_automation_scheduler_forever() -> None:
|
||||
if not config.automation_scheduler.enabled:
|
||||
logger.info("Automation scheduler disabled by config")
|
||||
return
|
||||
|
||||
interval_seconds = int(config.automation_scheduler.interval_seconds)
|
||||
batch_limit = int(config.automation_scheduler.batch_limit)
|
||||
logger.info(
|
||||
"Starting automation scheduler",
|
||||
interval_seconds=interval_seconds,
|
||||
batch_limit=batch_limit,
|
||||
)
|
||||
|
||||
async def scan_job() -> None:
|
||||
try:
|
||||
await run_automation_scheduler_scan(limit=batch_limit)
|
||||
except Exception as exc:
|
||||
logger.exception("Automation scheduler scan failed", error=str(exc))
|
||||
|
||||
scheduler = AsyncIOScheduler()
|
||||
scheduler.add_job(
|
||||
scan_job,
|
||||
trigger=IntervalTrigger(seconds=interval_seconds),
|
||||
id="automation_scheduler_scan",
|
||||
name="Automation scheduler scan",
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
scheduler.start()
|
||||
|
||||
stop_event = asyncio.Event()
|
||||
try:
|
||||
await stop_event.wait()
|
||||
finally:
|
||||
scheduler.shutdown(wait=False)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""CLI entry point."""
|
||||
if len(sys.argv) < 2:
|
||||
logger.error("No command provided")
|
||||
logger.info("Usage: python -m core.runtime.cli <command>")
|
||||
logger.info(
|
||||
"Available commands: migrate, init-data, bootstrap, automation-scheduler"
|
||||
)
|
||||
return 1
|
||||
|
||||
command = sys.argv[1]
|
||||
|
||||
if command == "migrate":
|
||||
success = run_migrations()
|
||||
elif command == "init-data":
|
||||
success = asyncio.run(run_init_data())
|
||||
elif command == "bootstrap":
|
||||
success = asyncio.run(bootstrap())
|
||||
elif command == "automation-scheduler":
|
||||
asyncio.run(run_automation_scheduler_forever())
|
||||
return 0
|
||||
else:
|
||||
logger.error("Unknown command", command=command)
|
||||
logger.info(
|
||||
"Available commands: migrate, init-data, bootstrap, automation-scheduler"
|
||||
)
|
||||
return 1
|
||||
|
||||
return 0 if success else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,12 @@
|
||||
from . import user, divination, payment, notification, feedback, version, log, violation
|
||||
|
||||
__all__ = [
|
||||
"user",
|
||||
"divination",
|
||||
"payment",
|
||||
"notification",
|
||||
"feedback",
|
||||
"version",
|
||||
"log",
|
||||
"violation",
|
||||
]
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.db.base import Base, TimestampMixin
|
||||
from sqlalchemy import BigInteger, DateTime, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
class DivinationRecord(TimestampMixin, Base):
|
||||
__tablename__ = "user_divination_records"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
trace_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
question: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
question_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
divination_data: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
deepseek_request: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
deepseek_response: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
interpretation_result: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
api_success: Mapped[bool] = mapped_column(Integer, nullable=False, default=0)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
api_duration_ms: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
phone_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
|
||||
|
||||
class DivinationHistory(TimestampMixin, Base):
|
||||
__tablename__ = "user_divination_history"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
phone_number: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
local_record_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
json_data: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
ai_result: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
question_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
question: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
timestamp: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Integer, nullable=False, default=1)
|
||||
sync_time: Mapped[str] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.db.base import Base
|
||||
from sqlalchemy import BigInteger, DateTime, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
class UserFeedback(Base):
|
||||
__tablename__ = "user_feedback"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
phone_number: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[str] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.db.base import Base
|
||||
from sqlalchemy import BigInteger, DateTime, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
class NetworkAccessLog(Base):
|
||||
__tablename__ = "network_access_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
phone_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
client_ip: Mapped[str] = mapped_column(String(45), nullable=False)
|
||||
client_port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
server_ip: Mapped[str] = mapped_column(String(45), nullable=False)
|
||||
server_port: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
http_method: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||
request_path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
request_url: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
user_agent: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
device_info: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
response_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
processing_time_ms: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
request_size: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
response_size: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
x_forwarded_for: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
x_real_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||
referer: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
operation_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
operation_result: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
session_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
access_time: Mapped[str] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
created_at: Mapped[str] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.db.base import Base
|
||||
from sqlalchemy import BigInteger, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notification"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
timestamp: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.db.base import Base, TimestampMixin
|
||||
from sqlalchemy import BigInteger, DateTime, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
class PaymentOrder(TimestampMixin, Base):
|
||||
__tablename__ = "payment_order"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
order_no: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
amount: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
coin_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
subject: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
body: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
channel: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="CREATED")
|
||||
trade_no: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
payment_time: Mapped[str | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class PaymentRecord(Base):
|
||||
__tablename__ = "payment_record"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
order_no: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
trade_no: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
channel: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
notify_type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
trade_status: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
notify_data: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
process_status: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
process_message: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
coin_added: Mapped[bool] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_at: Mapped[str] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.db.base import Base, TimestampMixin
|
||||
from sqlalchemy import BigInteger, DateTime, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
class User(TimestampMixin, Base):
|
||||
__tablename__ = "user_profile"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
phone_number: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
|
||||
nickname: Mapped[str] = mapped_column(String(50), nullable=False, default="")
|
||||
gender: Mapped[str] = mapped_column(String(10), nullable=False, default="男")
|
||||
birthday: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="2000-01-01"
|
||||
)
|
||||
signature: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
|
||||
|
||||
class UserToken(Base):
|
||||
__tablename__ = "user_tokens"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
token: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
expire_time: Mapped[str] = mapped_column(DateTime, nullable=False)
|
||||
created_at: Mapped[str] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class VerificationCode(Base):
|
||||
__tablename__ = "verification_codes"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
phone_number: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(6), nullable=False)
|
||||
expiration_time: Mapped[str] = mapped_column(DateTime, nullable=False)
|
||||
created_at: Mapped[str] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
used: Mapped[bool] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
|
||||
class UserCoin(Base):
|
||||
__tablename__ = "user_coin"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True)
|
||||
phone_number: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
coin_balance: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
|
||||
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.db.base import Base, TimestampMixin
|
||||
from sqlalchemy import BigInteger, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
class AppVersion(TimestampMixin, Base):
|
||||
__tablename__ = "app_version"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
version_name: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
|
||||
version_code: Mapped[int] = mapped_column(Integer, unique=True, nullable=False)
|
||||
min_supported_version: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
min_supported_code: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
is_force_update: Mapped[bool] = mapped_column(Integer, nullable=False, default=0)
|
||||
update_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
download_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Integer, nullable=False, default=1)
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.db.base import Base
|
||||
from sqlalchemy import BigInteger, DateTime, Float, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
class SensitiveWordViolation(Base):
|
||||
__tablename__ = "sensitive_word_violations"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
content_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
original_content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
violation_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
detection_service: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="LOCAL"
|
||||
)
|
||||
risk_level: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
confidence: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
aliyun_response: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
matched_words: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
client_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
violation_time: Mapped[str] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
created_at: Mapped[str] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
Reference in New Issue
Block a user