From 695adb7d6f19c398343237238c040b5f15d60955 Mon Sep 17 00:00:00 2001 From: qzl Date: Tue, 31 Mar 2026 13:32:22 +0800 Subject: [PATCH] feat: initial commit --- .env.example | 95 +++++ .gitignore | 189 +++++++++ AGENTS.md | 39 ++ backend/AGENTS.md | 57 +++ backend/src/__init__.py | 0 backend/src/app.py | 14 + backend/src/core/config/__init__.py | 3 + backend/src/core/config/initial/init_data.py | 234 +++++++++++ backend/src/core/config/settings.py | 263 +++++++++++++ .../static/automation/memory_extraction.yaml | 34 ++ .../config/static/database/llm_catalog.yaml | 70 ++++ .../config/static/database/system_agents.yaml | 28 ++ .../config/static/route/frontend_routes.yaml | 158 ++++++++ backend/src/core/db/__init__.py | 5 + backend/src/core/db/base.py | 37 ++ backend/src/core/db/base_repository.py | 84 ++++ backend/src/core/db/base_service.py | 22 ++ backend/src/core/db/session.py | 34 ++ backend/src/core/db/types.py | 6 + backend/src/core/http/__init__.py | 5 + backend/src/core/http/errors.py | 50 +++ backend/src/core/http/response.py | 38 ++ backend/src/core/logging/__init__.py | 15 + backend/src/core/logging/banner.py | 21 + backend/src/core/logging/config.py | 111 ++++++ backend/src/core/logging/context.py | 15 + backend/src/core/logging/filters.py | 56 +++ backend/src/core/logging/formatters.py | 81 ++++ backend/src/core/logging/handlers.py | 46 +++ backend/src/core/logging/logger.py | 7 + backend/src/core/logging/middleware.py | 84 ++++ backend/src/core/runtime/__init__.py | 0 backend/src/core/runtime/cli.py | 178 +++++++++ backend/src/models/__init__.py | 12 + backend/src/models/divination.py | 41 ++ backend/src/models/feedback.py | 17 + backend/src/models/log.py | 39 ++ backend/src/models/notification.py | 14 + backend/src/models/payment.py | 40 ++ backend/src/models/user.py | 52 +++ backend/src/models/version.py | 19 + backend/src/models/violation.py | 30 ++ backend/src/schemas/__init__.py | 0 backend/src/services/__init__.py | 0 backend/src/v1/__init__.py | 0 docs/reference/backend-features.md | 366 ++++++++++++++++++ infra/docker/docker-compose.yml | 29 ++ infra/scripts/app.sh | 209 ++++++++++ pyproject.toml | 43 ++ 49 files changed, 2990 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 backend/AGENTS.md create mode 100644 backend/src/__init__.py create mode 100644 backend/src/app.py create mode 100644 backend/src/core/config/__init__.py create mode 100644 backend/src/core/config/initial/init_data.py create mode 100644 backend/src/core/config/settings.py create mode 100644 backend/src/core/config/static/automation/memory_extraction.yaml create mode 100644 backend/src/core/config/static/database/llm_catalog.yaml create mode 100644 backend/src/core/config/static/database/system_agents.yaml create mode 100644 backend/src/core/config/static/route/frontend_routes.yaml create mode 100644 backend/src/core/db/__init__.py create mode 100644 backend/src/core/db/base.py create mode 100644 backend/src/core/db/base_repository.py create mode 100644 backend/src/core/db/base_service.py create mode 100644 backend/src/core/db/session.py create mode 100644 backend/src/core/db/types.py create mode 100644 backend/src/core/http/__init__.py create mode 100644 backend/src/core/http/errors.py create mode 100644 backend/src/core/http/response.py create mode 100644 backend/src/core/logging/__init__.py create mode 100644 backend/src/core/logging/banner.py create mode 100644 backend/src/core/logging/config.py create mode 100644 backend/src/core/logging/context.py create mode 100644 backend/src/core/logging/filters.py create mode 100644 backend/src/core/logging/formatters.py create mode 100644 backend/src/core/logging/handlers.py create mode 100644 backend/src/core/logging/logger.py create mode 100644 backend/src/core/logging/middleware.py create mode 100644 backend/src/core/runtime/__init__.py create mode 100644 backend/src/core/runtime/cli.py create mode 100644 backend/src/models/__init__.py create mode 100644 backend/src/models/divination.py create mode 100644 backend/src/models/feedback.py create mode 100644 backend/src/models/log.py create mode 100644 backend/src/models/notification.py create mode 100644 backend/src/models/payment.py create mode 100644 backend/src/models/user.py create mode 100644 backend/src/models/version.py create mode 100644 backend/src/models/violation.py create mode 100644 backend/src/schemas/__init__.py create mode 100644 backend/src/services/__init__.py create mode 100644 backend/src/v1/__init__.py create mode 100644 docs/reference/backend-features.md create mode 100644 infra/docker/docker-compose.yml create mode 100755 infra/scripts/app.sh create mode 100644 pyproject.toml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1dcb292 --- /dev/null +++ b/.env.example @@ -0,0 +1,95 @@ +# 环境变量配置模板(复制到 .env 并填写实际值) +# 警告:切勿将包含真实密钥的 .env 提交到代码仓库 + +############ +# 运行时配置 +############ +ERYAO_RUNTIME__ENVIRONMENT=dev +ERYAO_RUNTIME__DEBUG=true +ERYAO_RUNTIME__LOG_LEVEL=INFO +ERYAO_RUNTIME__SQL_LOG_QUERIES=false +ERYAO_RUNTIME__TRUSTED_PROXY_IPS=[] + +############ +# Web 服务器配置(Uvicorn) +############ +ERYAO_WEB__HOST=0.0.0.0 +ERYAO_WEB__PORT=8000 +ERYAO_WEB__WORKERS=2 + +############ +# Redis 配置 +############ +ERYAO_REDIS__PASSWORD=eryao-redis-2026 +ERYAO_REDIS__HOST=localhost +ERYAO_REDIS__PORT=6379 +ERYAO_REDIS__DB=0 + +############ +# MySQL 数据库配置 +############ +ERYAO_DATABASE__HOST=localhost +ERYAO_DATABASE__PORT=3306 +ERYAO_DATABASE__NAME=eryao +ERYAO_DATABASE__USER=root +ERYAO_DATABASE__PASSWORD=your_mysql_password_here + +############ +# 阿里云短信配置 +############ +ERYAO_ALIYUN_SMS__ACCESS_KEY_ID=your_aliyun_access_key_id +ERYAO_ALIYUN_SMS__ACCESS_KEY_SECRET=your_aliyun_access_key_secret +ERYAO_ALIYUN_SMS__SIGN_NAME=your_sign_name +ERYAO_ALIYUN_SMS__TEMPLATE_CODE=your_template_code + +############ +# 阿里云内容安全配置 +############ +ERYAO_ALIYUN_CONTENT_SECURITY__ACCESS_KEY_ID=your_aliyun_access_key_id +ERYAO_ALIYUN_CONTENT_SECURITY__ACCESS_KEY_SECRET=your_aliyun_access_key_secret + +############ +# 支付宝配置 +############ +ERYAO_ALIPAY__APP_ID=your_app_id +ERYAO_ALIPAY__MERCHANT_ID=your_merchant_id +ERYAO_ALIPAY__PUBLIC_KEY=your_alipay_public_key +ERYAO_ALIPAY__PRIVATE_KEY=your_alipay_private_key +ERYAO_ALIPAY__NOTIFY_URL=https://your-domain.com/api/payment/notify +ERYAO_ALIPAY__SANDBOX=false + +############ +# DeepSeek API 配置 +############ +ERYAO_DEEPSEEK__API_KEY=your_deepseek_api_key + +############ +# 认证配置 +############ +ERYAO_AUTH__TOKEN_EXPIRATION_DAYS=7 +ERYAO_AUTH__TOKEN_REFRESH_THRESHOLD_HOURS=2 + +############ +# 验证码配置 +############ +ERYAO_VERIFICATION__CODE_LENGTH=6 +ERYAO_VERIFICATION__EXPIRATION_MINUTES=5 +ERYAO_VERIFICATION__TEST_MODE=false + +############ +# 敏感词配置 +############ +ERYAO_SENSITIVE_WORD__USE_ALIYUN=true +ERYAO_SENSITIVE_WORD__FALLBACK_TO_LOCAL=true + +############ +# App 版本更新配置 +############ +ERYAO_APP_VERSION__MANIFEST_PATH=deploy/static/releases/manifest.json +ERYAO_APP_VERSION__RELEASE_PATH_PREFIX=releases +ERYAO_APP_VERSION__DOWNLOAD_BASE_URL= + +############ +# CORS 配置 +############ +ERYAO_CORS__ALLOW_ORIGINS=["http://localhost", "http://localhost:3000"] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..315154d --- /dev/null +++ b/.gitignore @@ -0,0 +1,189 @@ +# ============================================ +# Environment & Secrets +# ============================================ +.env +.env.local +.env.*.local +!.env.example + +# ============================================ +# Python +# ============================================ +__pycache__/ +*.py[codz] +*$py.class +*.so +.Python +build/ +dist/ +*.egg-info/ +*.egg +MANIFEST +.tox/ +.nox/ +.coverage +.coverage.* +.pytest_cache/ +htmlcov/ +.env +.envrc +.venv +venv/ +ENV/ +env.bak/ +venv.bak/ +.mypy_cache/ +.dmypy.json +dmypy.json +.pyre/ +.pytype/ +*.log +db.sqlite3 + +# ============================================ +# Flutter +# ============================================ +/bin/cache/ +/bin/internal/ +/dev/benchmarks/ +/dev/bots/ +/dev/docs/ +/dev/integration_tests/**/xcuserdata +/dev/integration_tests/**/Pods +/packages/flutter/coverage/ +version +analysis_benchmark.json +.packages.generated +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +**/generated_plugin_registrant.dart +.packages +.pub-preload-cache/ +.pub/ +build/ +flutter_*.png + +# Android +**/android/**/gradle-wrapper.jar +.gradle/ +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java +**/android/key.properties +*.jks + +# iOS/XCode +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/.last_build_id +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# macOS +**/Flutter/ephemeral/ +**/Pods/ +**/macos/Flutter/GeneratedPluginRegistrant.swift +**/macos/Flutter/ephemeral +**/xcuserdata/ + +# Coverage +coverage/ + +# ============================================ +# Kotlin / Gradle / Android +# ============================================ +*.class +*.log +*.lock +.buildlog/ +.history +build/ +app/build/ +login-service/build/ +.gradle/ +.idea/ +!.idea/codeStyles/ +*.iml +out/ +*.apk +*.aab +*.dex + +# ============================================ +# Node.js +# ============================================ +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +package-lock.json +yarn.lock + +# ============================================ +# Java / Spring Boot +# ============================================ +target/ +*.class +*.jar +*.war +*.ear +hs_err_pid* +spring-boot-*.jar + +# ============================================ +# IDE +# ============================================ +.vscode/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db +*.sublime-* +.idea/ +*.iml +atlassian-ide-plugin.xml +.project +.classpath +.settings/ + +# ============================================ +# Misc +# ============================================ +*.pid +*.seed +*.pid.lock +*.rdb +*.aof +*.pid + +# ============================================ +# Local folders +# ============================================ +old/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..65e86d2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,39 @@ +# Project AGENTS Router + +Root `AGENTS.md` is routing + cross-domain policy only. +Do not place backend/frontend implementation details here. + +## Scope + +- Applies to repository root and cross-domain tasks. +- Subdomain rules: `backend/AGENTS.md`, `apps/AGENTS.md`. +- If rules conflict, use the stricter one. + +## Rule Order + +1. System / developer / platform safety instructions +2. Workspace runtime rules (`AGENTS.md` + `rules/*`) +3. This file (routing + project-level constraints) +4. Subdomain rules (backend/apps) + +## Mandatory Routing + +- `backend/**` must follow `backend/AGENTS.md`. +- `apps/**` must follow `apps/AGENTS.md`. +- Cross-domain changes must satisfy all relevant subdomain rules. +- `infra/**` follows this file plus `infra/` conventions. + +## Project-Wide Constraints + +- Default development branch is `dev`; do not develop directly on `main`. +- Never push unless explicitly requested by the user. +- Keep AGENTS layered and lean: shared rules at root, domain rules in sub-AGENTS. +- **No Error Swallowing**: All exceptions must propagate or be converted to typed errors. Never catch an exception, log it, and silently continue. This destroys debuggability. + +## Protocol Source of Truth + +`docs/protocols/` is the single source of truth for protocol and data format. + +- Update protocol docs before changing data/API/UI contracts. +- Document compatibility strategy (backward-compatible vs migration). +- Keep frontend/backend implementations aligned with documented protocol. diff --git a/backend/AGENTS.md b/backend/AGENTS.md new file mode 100644 index 0000000..dfe90ca --- /dev/null +++ b/backend/AGENTS.md @@ -0,0 +1,57 @@ +# Backend Domain Rules + +This file governs `backend/**` only. Keep it minimal, enforceable, and non-duplicative. + +## Scope & Precedence + +- Inherits root `AGENTS.md` and workspace runtime rules. +- If rules conflict, apply the stricter one. +- Keep backend-only constraints here; do not duplicate root routing logic. + +## Runtime & Commands + +- Python commands must use `uv` (`uv run`, `uv add`). +- Backend startup/shutdown must use `./infra/scripts/app.sh`. +- Check runtime logs from `./logs/*.log`. + +## Code Quality Baseline + +- Do not bypass lint/type gates (`ruff`, `basedpyright`). +- Use project logging (`core.logging`), never `print()` in runtime code. +- HTTP errors must follow RFC 7807 (`application/problem+json`). + +## HTTP Error Contract (Must) + +- Backend must construct error payload using RFC7807 fields plus stable extension fields: `code` and optional `params`. +- `code` must be machine-readable `UPPER_SNAKE_CASE`; do not use free-text `detail` as the only contract. +- Error code registry source of truth: `docs/protocols/common/http-error-codes.md`. +- Any create/modify/deprecate of error codes must update `docs/protocols/common/http-error-codes.md` in the same change. +- Keep response media type as `application/problem+json`. +- Long-term layering target: HTTP transport details stay in routers/global handlers; service/repository/dependencies should raise domain errors (`ApiProblemError` or domain-specific exceptions), not `HTTPException`. +- When refactoring existing code, prefer replacing `HTTPException` in service/repository/dependencies with `ApiProblemError` while preserving status/code semantics. + +## Configuration & Secrets + +- Read env only through `core.config.settings` (`Settings` / `config`). +- Do not use `os.getenv`/manual env parsing in backend runtime. +- Never hardcode keys/tokens/passwords. + +## Architecture Rules + +- Use `schema -> repository -> service` layering. +- Repository: CRUD + query composition only; no auth decisions, no transaction boundary. +- Service: authz + business logic + transaction boundary. +- `owner_id` must come from verified JWT (`sub`), never from client payload. + +## Schema & Contract Rules + +- Schema-first for new/changed data contracts. +- Strong typing required at boundaries (Pydantic/dataclass); avoid weak untyped payload contracts. +- Protocol/data contract changes must stay aligned with `docs/protocols/`. + + +## Testing + +- Follow TDD for feature/bugfix work when practical. +- Prioritize regression tests for changed logic/contracts. +- Real DB tests must use `settings.test.*`; never hardcode test credentials. diff --git a/backend/src/__init__.py b/backend/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/app.py b/backend/src/app.py new file mode 100644 index 0000000..38b8d66 --- /dev/null +++ b/backend/src/app.py @@ -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"} diff --git a/backend/src/core/config/__init__.py b/backend/src/core/config/__init__.py new file mode 100644 index 0000000..b1019f5 --- /dev/null +++ b/backend/src/core/config/__init__.py @@ -0,0 +1,3 @@ +from .settings import Settings, config + +__all__ = ["Settings", "config"] diff --git a/backend/src/core/config/initial/init_data.py b/backend/src/core/config/initial/init_data.py new file mode 100644 index 0000000..4042328 --- /dev/null +++ b/backend/src/core/config/initial/init_data.py @@ -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 diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py new file mode 100644 index 0000000..3ca440f --- /dev/null +++ b/backend/src/core/config/settings.py @@ -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] diff --git a/backend/src/core/config/static/automation/memory_extraction.yaml b/backend/src/core/config/static/automation/memory_extraction.yaml new file mode 100644 index 0000000..c4abc4a --- /dev/null +++ b/backend/src/core/config/static/automation/memory_extraction.yaml @@ -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 diff --git a/backend/src/core/config/static/database/llm_catalog.yaml b/backend/src/core/config/static/database/llm_catalog.yaml new file mode 100644 index 0000000..0ab2857 --- /dev/null +++ b/backend/src/core/config/static/database/llm_catalog.yaml @@ -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 diff --git a/backend/src/core/config/static/database/system_agents.yaml b/backend/src/core/config/static/database/system_agents.yaml new file mode 100644 index 0000000..3b1d51d --- /dev/null +++ b/backend/src/core/config/static/database/system_agents.yaml @@ -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 diff --git a/backend/src/core/config/static/route/frontend_routes.yaml b/backend/src/core/config/static/route/frontend_routes.yaml new file mode 100644 index 0000000..748c7b5 --- /dev/null +++ b/backend/src/core/config/static/route/frontend_routes.yaml @@ -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 diff --git a/backend/src/core/db/__init__.py b/backend/src/core/db/__init__.py new file mode 100644 index 0000000..22c20ff --- /dev/null +++ b/backend/src/core/db/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from core.db.session import AsyncSessionLocal, engine, get_db + +__all__ = ["AsyncSessionLocal", "engine", "get_db"] diff --git a/backend/src/core/db/base.py b/backend/src/core/db/base.py new file mode 100644 index 0000000..3b118e6 --- /dev/null +++ b/backend/src/core/db/base.py @@ -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, + ) diff --git a/backend/src/core/db/base_repository.py b/backend/src/core/db/base_repository.py new file mode 100644 index 0000000..bf191bd --- /dev/null +++ b/backend/src/core/db/base_repository.py @@ -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 diff --git a/backend/src/core/db/base_service.py b/backend/src/core/db/base_service.py new file mode 100644 index 0000000..afde80f --- /dev/null +++ b/backend/src/core/db/base_service.py @@ -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 diff --git a/backend/src/core/db/session.py b/backend/src/core/db/session.py new file mode 100644 index 0000000..0b4a9cf --- /dev/null +++ b/backend/src/core/db/session.py @@ -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 diff --git a/backend/src/core/db/types.py b/backend/src/core/db/types.py new file mode 100644 index 0000000..462f8ff --- /dev/null +++ b/backend/src/core/db/types.py @@ -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") diff --git a/backend/src/core/http/__init__.py b/backend/src/core/http/__init__.py new file mode 100644 index 0000000..8fa5ebb --- /dev/null +++ b/backend/src/core/http/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from core.http.response import ProblemDetails, build_problem_details + +__all__ = ["ProblemDetails", "build_problem_details"] diff --git a/backend/src/core/http/errors.py b/backend/src/core/http/errors.py new file mode 100644 index 0000000..cc331c3 --- /dev/null +++ b/backend/src/core/http/errors.py @@ -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 diff --git a/backend/src/core/http/response.py b/backend/src/core/http/response.py new file mode 100644 index 0000000..636bc4b --- /dev/null +++ b/backend/src/core/http/response.py @@ -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, + ) diff --git a/backend/src/core/logging/__init__.py b/backend/src/core/logging/__init__.py new file mode 100644 index 0000000..5546157 --- /dev/null +++ b/backend/src/core/logging/__init__.py @@ -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", +] diff --git a/backend/src/core/logging/banner.py b/backend/src/core/logging/banner.py new file mode 100644 index 0000000..63500cf --- /dev/null +++ b/backend/src/core/logging/banner.py @@ -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) diff --git a/backend/src/core/logging/config.py b/backend/src/core/logging/config.py new file mode 100644 index 0000000..4e97122 --- /dev/null +++ b/backend/src/core/logging/config.py @@ -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, + ) diff --git a/backend/src/core/logging/context.py b/backend/src/core/logging/context.py new file mode 100644 index 0000000..d909bf3 --- /dev/null +++ b/backend/src/core/logging/context.py @@ -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() diff --git a/backend/src/core/logging/filters.py b/backend/src/core/logging/filters.py new file mode 100644 index 0000000..2139c7a --- /dev/null +++ b/backend/src/core/logging/filters.py @@ -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 diff --git a/backend/src/core/logging/formatters.py b/backend/src/core/logging/formatters.py new file mode 100644 index 0000000..71cba98 --- /dev/null +++ b/backend/src/core/logging/formatters.py @@ -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), + ], + ) diff --git a/backend/src/core/logging/handlers.py b/backend/src/core/logging/handlers.py new file mode 100644 index 0000000..e690f6b --- /dev/null +++ b/backend/src/core/logging/handlers.py @@ -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", + } diff --git a/backend/src/core/logging/logger.py b/backend/src/core/logging/logger.py new file mode 100644 index 0000000..3f11d3c --- /dev/null +++ b/backend/src/core/logging/logger.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +import structlog + + +def get_logger(name: str) -> structlog.stdlib.BoundLogger: + return structlog.get_logger(name) diff --git a/backend/src/core/logging/middleware.py b/backend/src/core/logging/middleware.py new file mode 100644 index 0000000..edf1df6 --- /dev/null +++ b/backend/src/core/logging/middleware.py @@ -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, + ) diff --git a/backend/src/core/runtime/__init__.py b/backend/src/core/runtime/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/core/runtime/cli.py b/backend/src/core/runtime/cli.py new file mode 100644 index 0000000..0fe7a9a --- /dev/null +++ b/backend/src/core/runtime/cli.py @@ -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 ") + 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()) diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py new file mode 100644 index 0000000..d862204 --- /dev/null +++ b/backend/src/models/__init__.py @@ -0,0 +1,12 @@ +from . import user, divination, payment, notification, feedback, version, log, violation + +__all__ = [ + "user", + "divination", + "payment", + "notification", + "feedback", + "version", + "log", + "violation", +] diff --git a/backend/src/models/divination.py b/backend/src/models/divination.py new file mode 100644 index 0000000..45af944 --- /dev/null +++ b/backend/src/models/divination.py @@ -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() + ) diff --git a/backend/src/models/feedback.py b/backend/src/models/feedback.py new file mode 100644 index 0000000..78bb864 --- /dev/null +++ b/backend/src/models/feedback.py @@ -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() + ) diff --git a/backend/src/models/log.py b/backend/src/models/log.py new file mode 100644 index 0000000..f94bf3f --- /dev/null +++ b/backend/src/models/log.py @@ -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() + ) diff --git a/backend/src/models/notification.py b/backend/src/models/notification.py new file mode 100644 index 0000000..e6865dc --- /dev/null +++ b/backend/src/models/notification.py @@ -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) diff --git a/backend/src/models/payment.py b/backend/src/models/payment.py new file mode 100644 index 0000000..eb0b76c --- /dev/null +++ b/backend/src/models/payment.py @@ -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() + ) diff --git a/backend/src/models/user.py b/backend/src/models/user.py new file mode 100644 index 0000000..4cb5ff0 --- /dev/null +++ b/backend/src/models/user.py @@ -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) diff --git a/backend/src/models/version.py b/backend/src/models/version.py new file mode 100644 index 0000000..3a3101f --- /dev/null +++ b/backend/src/models/version.py @@ -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) diff --git a/backend/src/models/violation.py b/backend/src/models/violation.py new file mode 100644 index 0000000..0158e67 --- /dev/null +++ b/backend/src/models/violation.py @@ -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() + ) diff --git a/backend/src/schemas/__init__.py b/backend/src/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/services/__init__.py b/backend/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/v1/__init__.py b/backend/src/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/reference/backend-features.md b/docs/reference/backend-features.md new file mode 100644 index 0000000..b3e7ad3 --- /dev/null +++ b/docs/reference/backend-features.md @@ -0,0 +1,366 @@ +# 后端服务功能模块 + +## 1. 用户认证模块 (`/auth`) + +### 1.1 发送验证码 +- **路径**: `POST /auth/send-code` +- **功能**: 向用户手机号发送短信验证码 +- **参数**: `phoneNumber` (手机号) +- **依赖**: 阿里云短信服务 + +### 1.2 验证码登录 +- **路径**: `POST /auth/login` +- **功能**: 使用手机号+验证码登录,返回用户信息和Token +- **参数**: `phoneNumber`, `code` +- **返回**: `userId`, `phoneNumber`, `token` + +### 1.3 验证Token +- **路径**: `POST /auth/validate-token` +- **功能**: 验证Token有效性,自动刷新即将过期的Token +- **参数**: `token` + +### 1.4 刷新Token +- **路径**: `POST /auth/refresh-token` +- **功能**: 刷新用户Token +- **参数**: `token` + +### 1.5 注销登录 +- **路径**: `POST /auth/logout` +- **功能**: 删除Token,注销登录 +- **参数**: `token` + +--- + +## 2. 用户资料模块 (`/user`) + +### 2.1 获取用户资料 +- **路径**: `GET /user/profile` +- **参数**: `id` (用户ID) +- **返回**: 昵称、性别、生日、个性签名 + +### 2.2 更新用户资料 +- **路径**: `PUT /user/profile` +- **功能**: 更新用户资料(含敏感词检测) +- **参数**: `id`, `nickname`, `gender`, `birthday`, `signature` + +### 2.3 单独更新昵称 +- **路径**: `PUT /user/nickname` +- **功能**: 更新用户昵称(含敏感词检测) +- **参数**: `userId`, `nickname` + +### 2.4 单独更新签名 +- **路径**: `PUT /user/signature` +- **功能**: 更新用户个性签名(含敏感词检测) +- **参数**: `userId`, `signature` + +--- + +## 3. 铜钱系统模块 (`/coin`) + +### 3.1 查询余额 +- **路径**: `GET /coin/balance` +- **参数**: `userId` + +### 3.2 按手机号查询余额 +- **路径**: `GET /coin/balance/phone` +- **参数**: `phoneNumber` + +### 3.3 消费铜钱 +- **路径**: `POST /coin/consume` +- **参数**: `userId` + +### 3.4 按手机号消费铜钱 +- **路径**: `POST /coin/consume/phone` +- **参数**: `phoneNumber` + +### 3.5 重置余额 +- **路径**: `POST /coin/reset` +- **功能**: 重置用户铜钱余额为0(用于注销) +- **参数**: `userId` + +### 3.6 按手机号重置余额 +- **路径**: `POST /coin/reset/phone` +- **参数**: `phoneNumber` + +### 3.7 增加铜钱 +- **路径**: `POST /coin/increase/phone` +- **参数**: `phoneNumber`, `coinCount` + +### 3.8 同步用户铜钱 +- **路径**: `POST /coin/sync` +- **功能**: 手动触发用户铜钱记录同步 + +--- + +## 4. 支付模块 (`/payment`) + +### 4.1 获取支付宝订单 +- **路径**: `GET /payment/alipay/order` +- **参数**: `userId`, `amount`, `coinCount` +- **返回**: 支付宝支付订单信息 + +### 4.2 支付宝异步通知 +- **路径**: `POST /payment/notify` +- **功能**: 处理支付宝异步回调通知 +- **返回**: `success` 或 `fail` + +### 4.3 更新余额 +- **路径**: `POST /payment/update-balance` +- **功能**: 处理支付结果,更新用户铜钱余额 +- **参数**: `userId`, `orderNo`, `tradeNo`, `amount`, `coinCount`, `status` + +--- + +## 5. 卦象历史模块 (`/divination-history`) + +### 5.1 保存卦象记录 +- **路径**: `POST /divination-history/save` +- **参数**: `userId`, `phoneNumber`, `localRecordId`, `jsonData`, `aiResult`, `questionType`, `question`, `timestamp` + +### 5.2 获取卦象记录 +- **路径**: `POST /divination-history/get` +- **参数**: `phoneNumber`, `questionType` (可选) + +### 5.3 删除卦象记录 +- **路径**: `POST /divination-history/delete` +- **参数**: `phoneNumber`, `localRecordId` 或 `localRecordIds` + +### 5.4 统计记录数量 +- **路径**: `GET /divination-history/count/{phoneNumber}` + +### 5.5 批量软删除 +- **路径**: `POST /divination-history/deactivate-all` +- **功能**: 用户注销时批量软删除卦象记录 +- **参数**: `phoneNumber`, `userId` + +--- + +## 6. 解卦溯源模块 (`/divination`) + +### 6.1 增强解卦 +- **路径**: `POST /divination/enhanced` +- **功能**: 调用DeepSeek API进行解卦,支持用户追踪 +- **参数**: `userId`, `questionType`, `question`, `divinationData` + +### 6.2 查询用户解卦记录 +- **路径**: `GET /divination/records/user/{userId}` +- **参数**: `page`, `size` + +### 6.3 按手机号查询记录 +- **路径**: `GET /divination/records/phone/{phoneNumber}` +- **参数**: `page`, `size` + +### 6.4 按追踪ID查询 +- **路径**: `GET /divination/records/trace/{traceId}` + +### 6.5 时间范围查询 +- **路径**: `GET /divination/records/user/{userId}/daterange` +- **参数**: `startTime`, `endTime`, `page`, `size` + +### 6.6 查询失败记录 +- **路径**: `GET /divination/records/failed` + +### 6.7 查询慢请求 +- **路径**: `GET /divination/records/slow` +- **参数**: `durationMs` (默认10000ms) + +### 6.8 统计用户解卦次数 +- **路径**: `GET /divination/stats/user/{userId}/count` + +### 6.9 统计时间范围内记录数 +- **路径**: `GET /divination/stats/daterange/count` +- **参数**: `startTime`, `endTime` + +--- + +## 7. DeepSeek代理模块 (`/deepseek`) + +### 7.1 AI聊天代理 +- **路径**: `POST /deepseek/chat` +- **功能**: 代理DeepSeek API,自动附加用户信息 +- **参数**: 聊天请求体 + +--- + +## 8. 内容审核模块 (`/content-moderation`) + +### 8.1 检测问题内容 +- **路径**: `POST /content-moderation/check-question` +- **功能**: 检测用户问题是否包含敏感词 +- **参数**: `userId`, `question` + +--- + +## 9. 敏感词管理模块 (`/admin/sensitive-words`) + +### 9.1 获取统计信息 +- **路径**: `GET /admin/sensitive-words/statistics` + +### 9.2 添加敏感词 +- **路径**: `POST /admin/sensitive-words/add` +- **参数**: `word`, `type` + +### 9.3 移除敏感词 +- **路径**: `DELETE /admin/sensitive-words/remove` +- **参数**: `word` + +### 9.4 测试检测 +- **路径**: `POST /admin/sensitive-words/test/nickname` +- **路径**: `POST /admin/sensitive-words/test/signature` +- **路径**: `POST /admin/sensitive-words/test/question` +- **参数**: `content` + +--- + +## 10. 违规记录管理模块 (`/admin/violations`) + +### 10.1 获取违规记录列表 +- **路径**: `GET /admin/violations/list` +- **参数**: `page`, `size`, `userId`, `contentType`, `violationType`, `startTime`, `endTime` + +### 10.2 用户违规统计 +- **路径**: `GET /admin/violations/user/{userId}/stats` + +### 10.3 违规类型统计 +- **路径**: `GET /admin/violations/stats/types` + +### 10.4 高频违规用户 +- **路径**: `GET /admin/violations/frequent-violators` +- **参数**: `days`, `threshold` + +### 10.5 清理违规记录 +- **路径**: `DELETE /admin/violations/cleanup` +- **参数**: `daysToKeep` + +### 10.6 获取违规详情 +- **路径**: `GET /admin/violations/{id}` + +--- + +## 11. 敏感词迁移管理模块 (`/admin/sensitive-word-migration`) + +### 11.1 获取配置状态 +- **路径**: `GET /admin/sensitive-word-migration/status` + +### 11.2 切换服务 +- **路径**: `POST /admin/sensitive-word-migration/switch` +- **功能**: 切换本地词库/阿里云服务 +- **参数**: `useAliyun` + +### 11.3 设置降级策略 +- **路径**: `POST /admin/sensitive-word-migration/fallback` +- **参数**: `enableFallback` + +### 11.4 对比测试 +- **路径**: `POST /admin/sensitive-word-migration/compare` +- **功能**: 对比本地和阿里云检测结果 +- **参数**: `content`, `contentType`, `userId` + +### 11.5 批量对比测试 +- **路径**: `POST /admin/sensitive-word-migration/batch-compare` + +### 11.6 健康检查 +- **路径**: `GET /admin/sensitive-word-migration/health-check` + +--- + +## 12. 通知模块 (`/notifications`) + +### 12.1 获取最新通知 +- **路径**: `GET /notifications/latest` + +### 12.2 获取所有通知 +- **路径**: `GET /notifications/all` + +--- + +## 13. 用户反馈模块 (`/feedback`) + +### 13.1 提交反馈 +- **路径**: `POST /feedback` +- **参数**: `user_id`, `phone_number`, `content` + +--- + +## 14. 版本管理模块 (`/version`) + +### 14.1 检查版本更新 +- **路径**: `POST /version/check` +- **参数**: `clientVersion`, `clientVersionCode` + +### 14.2 获取最新版本 +- **路径**: `GET /version/latest` + +--- + +## 15. 网络访问日志模块 (`/admin/network-logs`) + +### 15.1 按用户查询日志 +- **路径**: `GET /admin/network-logs/user/{userId}` + +### 15.2 按IP查询日志 +- **路径**: `GET /admin/network-logs/ip/{clientIp}` + +### 15.3 按时间范围查询 +- **路径**: `GET /admin/network-logs/time-range` + +### 15.4 查询失败记录 +- **路径**: `GET /admin/network-logs/failed` + +### 15.5 检测可疑IP +- **路径**: `GET /admin/network-logs/suspicious` + +### 15.6 统计IP访问次数 +- **路径**: `GET /admin/network-logs/count/ip` + +### 15.7 清理过期日志 +- **路径**: `DELETE /admin/network-logs/cleanup` + +--- + +## 16. 用户数据管理模块 (`/admin/user-data`) + +### 16.1 同步用户数据 +- **路径**: `POST /admin/user-data/sync` + +### 16.2 验证用户数据 +- **路径**: `GET /admin/user-data/validate/user/{userId}` +- **路径**: `GET /admin/user-data/validate/phone/{phoneNumber}` + +### 16.3 修复数据一致性 +- **路径**: `POST /admin/user-data/fix/{phoneNumber}` + +### 16.4 批量验证 +- **路径**: `POST /admin/user-data/validate/batch` + +### 16.5 测试用户信息 +- **路径**: `GET /admin/user-data/test/user-info/{userId}` + +--- + +## 17. 数据清理模块 (`/admin/data-cleanup`) + +### 17.1 清理所有表 +- **路径**: `DELETE /admin/data-cleanup/all` + +### 17.2 清理验证码 +- **路径**: `DELETE /admin/data-cleanup/verification-codes` + +### 17.3 清理支付记录 +- **路径**: `DELETE /admin/data-cleanup/payment-records` + +### 17.4 清理反馈记录 +- **路径**: `DELETE /admin/data-cleanup/feedback` + +--- + +## 第三方服务集成 + +| 服务 | 用途 | 配置 | +|------|------|------| +| 阿里云短信 | 发送验证码 | `aliyun.sms.*` | +| 阿里云内容安全 | 敏感词检测 | `aliyun.content-security.*` | +| DeepSeek API | AI解卦/聊天 | `thirdparty.deepseek.api-key` | +| 支付宝 | 支付充值 | `alipay.*` | +| MySQL | 数据持久化 | `spring.datasource.*` | +| Redis | Token缓存/会话 | `spring.data.redis.*` | diff --git a/infra/docker/docker-compose.yml b/infra/docker/docker-compose.yml new file mode 100644 index 0000000..70ec29e --- /dev/null +++ b/infra/docker/docker-compose.yml @@ -0,0 +1,29 @@ +name: eryao-local + +services: + redis: + image: redis:7-alpine + container_name: eryao-local-redis + restart: unless-stopped + ports: + - "127.0.0.1:${ERYAO_REDIS__PORT:-6379}:6379" + volumes: + - redis_data:/data + environment: + REDIS_PASSWORD: ${ERYAO_REDIS__PASSWORD:-} + command: > + sh -c 'if [ -n "$$REDIS_PASSWORD" ]; then redis-server --appendonly yes --requirepass "$$REDIS_PASSWORD"; else redis-server --appendonly yes; fi' + healthcheck: + test: + [ + "CMD", + "sh", + "-c", + "if [ -n \"$$REDIS_PASSWORD\" ]; then redis-cli -a \"$$REDIS_PASSWORD\" ping; else redis-cli ping; fi", + ] + interval: 5s + timeout: 3s + retries: 5 + +volumes: + redis_data: diff --git a/infra/scripts/app.sh b/infra/scripts/app.sh new file mode 100755 index 0000000..852dfa6 --- /dev/null +++ b/infra/scripts/app.sh @@ -0,0 +1,209 @@ +#!/bin/bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +SESSION_NAME="${SESSION_NAME:-eryao-dev}" +ENV_FILE="$ROOT_DIR/.env" + +usage() { + echo "Usage: $0 {start|stop|restart}" + echo "" + echo "Commands:" + echo " start Start local web process in tmux" + echo " stop Stop tmux session and orphaned local processes" + echo " restart Stop then start all app processes" + exit 1 +} + +load_env_if_exists() { + if [ -f "$ENV_FILE" ]; then + set -a + # shellcheck disable=SC1090 + . "$ENV_FILE" + set +a + fi +} + +is_port_in_use() { + local port="$1" + + if command -v lsof >/dev/null 2>&1; then + lsof -iTCP:"$port" -sTCP:LISTEN -t >/dev/null 2>&1 + return $? + fi + + if command -v ss >/dev/null 2>&1; then + ss -ltn "sport = :$port" | awk 'NR > 1 {exit 0} END {exit 1}' + return $? + fi + + return 1 +} + +collect_listening_pids() { + local port="$1" + + if command -v lsof >/dev/null 2>&1; then + lsof -iTCP:"$port" -sTCP:LISTEN -t | sort -u + return + fi + + if command -v ss >/dev/null 2>&1; then + ss -lptn "sport = :$port" | awk -F 'pid=' 'NF > 1 {split($2, tmp, ","); print tmp[1]}' | sort -u + fi +} + +kill_pids_gracefully() { + local label="$1" + shift + local pids=("$@") + local alive=() + + if [ "${#pids[@]}" -eq 0 ]; then + return + fi + + echo "Stopping ${label}: ${pids[*]}" + kill -TERM "${pids[@]}" 2>/dev/null || true + + for _ in {1..10}; do + alive=() + for pid in "${pids[@]}"; do + if kill -0 "$pid" 2>/dev/null; then + alive+=("$pid") + fi + done + + if [ "${#alive[@]}" -eq 0 ]; then + return + fi + + sleep 1 + done + + echo "Force killing ${label}: ${alive[*]}" + kill -KILL "${alive[@]}" 2>/dev/null || true +} + +kill_matching_processes() { + local label="$1" + local pattern="$2" + local pids + + pids="$(pgrep -f "$pattern" || true)" + if [ -z "$pids" ]; then + return + fi + + # shellcheck disable=SC2086 + kill_pids_gracefully "$label" $pids +} + +kill_listening_processes() { + local label="$1" + local port="$2" + local pids + + pids="$(collect_listening_pids "$port" || true)" + if [ -z "$pids" ]; then + return + fi + + # shellcheck disable=SC2086 + kill_pids_gracefully "$label" $pids +} + +start() { + echo "=== Eryao App Up ===" + echo "This script starts local web process in tmux." + echo "Redis should be managed separately via docker-compose." + echo "NOTE: Database migration must be run separately." + echo "" + + if ! command -v tmux >/dev/null 2>&1; then + echo "Error: tmux is required." >&2 + exit 1 + fi + + if [ ! -f "$ENV_FILE" ]; then + echo "Error: env file not found at $ENV_FILE" >&2 + exit 1 + fi + + load_env_if_exists + + UVICORN_LOG_LEVEL="${ERYAO_RUNTIME__LOG_LEVEL:-info}" + UVICORN_LOG_LEVEL="$(echo "$UVICORN_LOG_LEVEL" | tr '[:upper:]' '[:lower:]')" + WEB_PORT="${ERYAO_WEB__PORT:-8000}" + + if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then + echo "Error: tmux session '$SESSION_NAME' already exists." >&2 + echo "Hint: tmux kill-session -t $SESSION_NAME" >&2 + exit 1 + fi + + if is_port_in_use "$WEB_PORT"; then + echo "Error: web port ${WEB_PORT} is already in use." >&2 + echo "Hint: run '$0 stop' or change ERYAO_WEB__PORT in .env" >&2 + exit 1 + fi + + if [ -z "${ERYAO_DEEPSEEK__API_KEY:-}" ]; then + echo "Warning: ERYAO_DEEPSEEK__API_KEY is empty; deepseek calls may fail." >&2 + fi + + WEB_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src ERYAO_RUNTIME__SERVICE_NAME=web uv run uvicorn backend.src.app:app --host ${ERYAO_WEB__HOST:-0.0.0.0} --port ${WEB_PORT} --workers ${ERYAO_WEB__WORKERS:-2} --log-level ${UVICORN_LOG_LEVEL}" + + echo "Starting tmux web process in session '$SESSION_NAME'..." + + tmux new-session -d -s "$SESSION_NAME" -n web "bash -lc \"$WEB_CMD; echo '[web] exited'; exec bash\"" + + echo "" + echo "=== App Started ===" + echo "Log files will be created in logs/ directory:" + echo " - web.log, web.error.log" + echo "" + echo "tmux attach -t $SESSION_NAME" + echo "tmux list-windows -t $SESSION_NAME" +} + +stop() { + echo "=== Eryao App Down ===" + echo "Stopping tmux app processes (docker redis is not managed here)." + load_env_if_exists + WEB_PORT="${ERYAO_WEB__PORT:-8000}" + + if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then + echo "Stopping tmux session '$SESSION_NAME'..." + tmux kill-session -t "$SESSION_NAME" + else + echo "No tmux session '$SESSION_NAME' found." + fi + + echo "Checking for orphaned processes..." + + kill_matching_processes "uvicorn" "uv run uvicorn backend.src.app:app" + + kill_listening_processes "port ${WEB_PORT} listeners" "$WEB_PORT" + + if is_port_in_use "$WEB_PORT"; then + echo "Warning: port ${WEB_PORT} is still in use after cleanup." >&2 + echo "Hint: check process with 'lsof -iTCP:${WEB_PORT} -sTCP:LISTEN'" >&2 + return 1 + fi + + echo "Session stopped and cleaned up." +} + +restart() { + stop + echo "" + start +} + +case "${1:-}" in + start) start ;; + stop) stop ;; + restart) restart ;; + *) usage ;; +esac diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9aff33d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "eryao" +version = "0.1.0" +description = "觅爻签问后端服务" +requires-python = ">=3.12" +dependencies = [ + "alembic==1.18.4", + "aiomysql==0.2.0", + "cryptography==46.0.3", + "email-validator==2.3.0", + "fastapi==0.135.1", + "pydantic==2.12.5", + "pydantic-settings==2.13.1", + "pyjwt==2.11.0", + "pyyaml==6.0.3", + "redis==7.2.1", + "sqlalchemy[asyncio]==2.0.48", + "structlog==25.5.0", + "uvicorn[standard]==0.41.0", +] + +[project.optional-dependencies] +dev = [ + "httpx==0.28.1", + "pytest==9.0.2", + "pytest-asyncio==1.3.0", + "pytest-cov==7.0.0", +] + +[[tool.uv.index]] +url = "https://pypi.tuna.tsinghua.edu.cn/simple" +default = true + +[tool.pytest.ini_options] +testpaths = ["backend/tests"] +addopts = "-q --import-mode=importlib" +asyncio_mode = "auto" + +[dependency-groups] +dev = [ + "basedpyright==1.38.2", + "pre-commit==4.5.1", +]