feat: initial commit

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