feat: initial commit

This commit is contained in:
qzl
2026-03-31 13:32:22 +08:00
commit 695adb7d6f
49 changed files with 2990 additions and 0 deletions
View File
+14
View File
@@ -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"}
+3
View File
@@ -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
+263
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
from __future__ import annotations
from core.db.session import AsyncSessionLocal, engine, get_db
__all__ = ["AsyncSessionLocal", "engine", "get_db"]
+37
View File
@@ -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,
)
+84
View File
@@ -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
+22
View File
@@ -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
+34
View File
@@ -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
+6
View File
@@ -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")
+5
View File
@@ -0,0 +1,5 @@
from __future__ import annotations
from core.http.response import ProblemDetails, build_problem_details
__all__ = ["ProblemDetails", "build_problem_details"]
+50
View File
@@ -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
+38
View File
@@ -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,
)
+15
View File
@@ -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",
]
+21
View File
@@ -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)
+111
View File
@@ -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,
)
+15
View File
@@ -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()
+56
View File
@@ -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
+81
View File
@@ -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),
],
)
+46
View File
@@ -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",
}
+7
View File
@@ -0,0 +1,7 @@
from __future__ import annotations
import structlog
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
return structlog.get_logger(name)
+84
View File
@@ -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,
)
+178
View File
@@ -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())
+12
View File
@@ -0,0 +1,12 @@
from . import user, divination, payment, notification, feedback, version, log, violation
__all__ = [
"user",
"divination",
"payment",
"notification",
"feedback",
"version",
"log",
"violation",
]
+41
View File
@@ -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()
)
+17
View File
@@ -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()
)
+39
View File
@@ -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()
)
+14
View File
@@ -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)
+40
View File
@@ -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()
)
+52
View File
@@ -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)
+19
View File
@@ -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)
+30
View File
@@ -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()
)
View File
View File
View File