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