refactor: align repo layout and logging safeguards
This commit is contained in:
+162
@@ -0,0 +1,162 @@
|
||||
# 统一环境变量配置模板(根目录 .env.example)
|
||||
# 使用方法:复制到 .env 并填写实际值
|
||||
# 警告:切勿将包含真实密钥的 .env 提交到代码仓库
|
||||
# 命名规则:前缀 SOCIAL_,层级分隔符 __(例如 SOCIAL_INFRA__POSTGRES__PASSWORD)
|
||||
|
||||
############
|
||||
# 运行时配置(API 后端使用)
|
||||
############
|
||||
# 运行环境:dev(开发)、test(测试)、prod(生产)
|
||||
SOCIAL_RUNTIME__ENVIRONMENT=dev
|
||||
# 调试模式:true 开启详细日志和错误堆栈,false 生产环境建议关闭
|
||||
SOCIAL_RUNTIME__DEBUG=true
|
||||
# 日志级别:DEBUG、INFO、WARNING、ERROR、CRITICAL
|
||||
SOCIAL_RUNTIME__LOG_LEVEL=INFO
|
||||
# 是否记录 SQL 查询日志:开发调试时可开启,生产环境建议关闭
|
||||
SOCIAL_RUNTIME__SQL_LOG_QUERIES=false
|
||||
|
||||
############
|
||||
# 应用配置(API 后端使用)
|
||||
############
|
||||
# API 服务监听地址:0.0.0.0 表示所有网络接口,本地开发可用 127.0.0.1
|
||||
SOCIAL_APP__HOST=0.0.0.0
|
||||
# API 服务监听端口
|
||||
SOCIAL_APP__PORT=8000
|
||||
# 是否启用代码热重载:开发环境 true,生产环境 false
|
||||
SOCIAL_APP__RELOAD=true
|
||||
|
||||
############
|
||||
# 基础设施密钥(Docker 服务使用)
|
||||
############
|
||||
# PostgreSQL 数据库超级用户密码:生产环境必须更换强密码
|
||||
SOCIAL_INFRA__POSTGRES__PASSWORD=CHANGE_ME
|
||||
# JWT 签名密钥(Supabase 认证服务使用):生产环境必须更换
|
||||
SOCIAL_INFRA__JWT__SECRET=CHANGE_ME
|
||||
# Supabase 匿名访问密钥:用于前端匿名访问 API
|
||||
SOCIAL_INFRA__SUPABASE__ANON_KEY=CHANGE_ME
|
||||
# Supabase 服务角色密钥:拥有完全权限,仅后端服务使用,切勿泄露
|
||||
SOCIAL_INFRA__SUPABASE__SERVICE_ROLE_KEY=CHANGE_ME
|
||||
# Supabase 管理后台用户名
|
||||
SOCIAL_INFRA__DASHBOARD__USERNAME=supabase
|
||||
# Supabase 管理后台密码
|
||||
SOCIAL_INFRA__DASHBOARD__PASSWORD=CHANGE_ME
|
||||
# Supabase 数据库连接池加密密钥
|
||||
SOCIAL_INFRA__SUPAVISOR__SECRET_KEY_BASE=CHANGE_ME
|
||||
# Supabase Vault 加密密钥
|
||||
SOCIAL_INFRA__SUPAVISOR__VAULT_ENC_KEY=CHANGE_ME
|
||||
# Supabase Postgres Meta 加密密钥
|
||||
SOCIAL_INFRA__PG_META__CRYPTO_KEY=CHANGE_ME
|
||||
# Logflare 公共访问令牌
|
||||
SOCIAL_INFRA__LOGFLARE__PUBLIC_ACCESS_TOKEN=CHANGE_ME
|
||||
# Logflare 私有访问令牌
|
||||
SOCIAL_INFRA__LOGFLARE__PRIVATE_ACCESS_TOKEN=CHANGE_ME
|
||||
|
||||
############
|
||||
# 基础设施数据库配置(Docker 服务使用)
|
||||
############
|
||||
# PostgreSQL 容器内主机名:Docker 内部使用
|
||||
SOCIAL_INFRA__POSTGRES__HOST=db
|
||||
# 数据库名称:与初始化脚本保持一致
|
||||
SOCIAL_INFRA__POSTGRES__DB=linksy
|
||||
# PostgreSQL 容器内端口
|
||||
SOCIAL_INFRA__POSTGRES__PORT=54322
|
||||
|
||||
############
|
||||
# Supavisor 数据库连接池配置(Docker 服务使用)
|
||||
############
|
||||
# 连接池代理端口(事务模式)
|
||||
SOCIAL_INFRA__POOLER__PROXY_PORT_TRANSACTION=6543
|
||||
# 每个数据库连接池的默认大小
|
||||
SOCIAL_INFRA__POOLER__DEFAULT_POOL_SIZE=20
|
||||
# 连接池最大客户端连接数
|
||||
SOCIAL_INFRA__POOLER__MAX_CLIENT_CONN=100
|
||||
# 连接池租户 ID
|
||||
SOCIAL_INFRA__POOLER__TENANT_ID=local-tenant
|
||||
# 每个数据库的连接池大小
|
||||
SOCIAL_INFRA__POOLER__DB_POOL_SIZE=5
|
||||
|
||||
############
|
||||
# API 网关 Kong 配置(Docker 服务使用)
|
||||
############
|
||||
# Kong HTTP 端口映射到宿主机的端口
|
||||
SOCIAL_INFRA__KONG__HTTP_PORT=8001
|
||||
# Kong HTTPS 端口映射到宿主机的端口
|
||||
SOCIAL_INFRA__KONG__HTTPS_PORT=8443
|
||||
|
||||
############
|
||||
# PostgREST API 配置(Docker 服务使用)
|
||||
############
|
||||
# PostgREST 暴露的数据库模式列表,逗号分隔
|
||||
SOCIAL_INFRA__PGRST__DB_SCHEMAS=public,storage,graphql_public
|
||||
|
||||
############
|
||||
# 认证服务 GoTrue 配置(Docker 服务使用)
|
||||
############
|
||||
# 站点 URL:用于生成回调链接等,通常为前端地址
|
||||
SOCIAL_INFRA__SITE__URL=http://localhost:3000
|
||||
# 允许的重定向 URL 列表,逗号分隔
|
||||
SOCIAL_INFRA__ADDITIONAL_REDIRECT_URLS=
|
||||
# JWT 过期时间(秒)
|
||||
SOCIAL_INFRA__JWT__EXPIRY=3600
|
||||
# 是否禁用用户注册:true 禁止,false 允许
|
||||
SOCIAL_INFRA__AUTH__DISABLE_SIGNUP=false
|
||||
# API 外部访问 URL:用于 Kong 网关对外暴露的地址
|
||||
SOCIAL_INFRA__API_EXTERNAL_URL=http://localhost:8001
|
||||
############
|
||||
# Supabase 公共访问 URL:用于前端/SDK/Studio 访问(可与 API 外部地址不同)
|
||||
# 反向代理场景请填代理后的公网地址
|
||||
SOCIAL_INFRA__SUPABASE__PUBLIC_URL=http://localhost:8001
|
||||
# 邮箱验证链接路径
|
||||
SOCIAL_INFRA__MAILER__URLPATHS_CONFIRMATION="/auth/v1/verify"
|
||||
# 邮箱邀请链接路径
|
||||
SOCIAL_INFRA__MAILER__URLPATHS_INVITE="/auth/v1/verify"
|
||||
# 邮箱找回密码链接路径
|
||||
SOCIAL_INFRA__MAILER__URLPATHS_RECOVERY="/auth/v1/verify"
|
||||
# 邮箱变更确认链接路径
|
||||
SOCIAL_INFRA__MAILER__URLPATHS_EMAIL_CHANGE="/auth/v1/verify"
|
||||
# 是否启用邮箱注册:true 启用,false 禁用
|
||||
SOCIAL_INFRA__EMAIL__ENABLE_SIGNUP=true
|
||||
# 是否自动确认邮箱:true 注册后自动登录,false 需要验证邮箱
|
||||
SOCIAL_INFRA__EMAIL__ENABLE_AUTOCONFIRM=false
|
||||
# 管理员邮箱地址:用于发送系统通知等
|
||||
SOCIAL_INFRA__SMTP__ADMIN_EMAIL=admin@example.com
|
||||
# SMTP 服务器主机地址
|
||||
SOCIAL_INFRA__SMTP__HOST=supabase-mail
|
||||
# SMTP 服务器端口:25(不加密)、465(SSL)、587(TLS)
|
||||
SOCIAL_INFRA__SMTP__PORT=2500
|
||||
# SMTP 用户名
|
||||
SOCIAL_INFRA__SMTP__USER=fake_mail_user
|
||||
# SMTP 密码
|
||||
SOCIAL_INFRA__SMTP__PASS=fake_mail_password
|
||||
# 发件人显示名称
|
||||
SOCIAL_INFRA__SMTP__SENDER_NAME=fake_sender
|
||||
# 是否允许匿名用户访问:true 允许,false 禁止
|
||||
SOCIAL_INFRA__AUTH__ENABLE_ANONYMOUS_USERS=false
|
||||
# 是否启用手机号注册:true 启用,false 禁用
|
||||
SOCIAL_INFRA__AUTH__ENABLE_PHONE_SIGNUP=true
|
||||
# 是否自动确认手机号:true 自动验证,false 需要短信验证码
|
||||
SOCIAL_INFRA__AUTH__ENABLE_PHONE_AUTOCONFIRM=true
|
||||
|
||||
############
|
||||
# Supabase Studio 配置(Docker 服务使用)
|
||||
############
|
||||
# 默认组织名称
|
||||
SOCIAL_INFRA__STUDIO__DEFAULT_ORGANIZATION=Default Organization
|
||||
# 默认项目名称
|
||||
SOCIAL_INFRA__STUDIO__DEFAULT_PROJECT=Default Project
|
||||
# 是否启用 WebP 图片格式检测:true 启用自动转换,false 禁用
|
||||
SOCIAL_INFRA__IMGPROXY__ENABLE_WEBP_DETECTION=true
|
||||
# OpenAI API 密钥:用于 Supabase AI 功能
|
||||
SOCIAL_INFRA__OPENAI__API_KEY=
|
||||
|
||||
############
|
||||
# Edge Functions 配置(Docker 服务使用)
|
||||
############
|
||||
# 是否验证 JWT:true 验证,false 不验证
|
||||
SOCIAL_INFRA__FUNCTIONS__VERIFY_JWT=false
|
||||
|
||||
############
|
||||
# 日志与分析配置(Docker 服务使用)
|
||||
############
|
||||
# Docker Socket 路径:用于容器日志收集
|
||||
SOCIAL_INFRA__DOCKER__SOCKET_LOCATION=/var/run/docker.sock
|
||||
+270
-1
@@ -1,3 +1,266 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[codz]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
# Pipfile.lock
|
||||
|
||||
# UV
|
||||
# uv.lock
|
||||
|
||||
# poetry
|
||||
# poetry.lock
|
||||
# poetry.toml
|
||||
|
||||
# pdm
|
||||
# pdm.lock
|
||||
# pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# pixi
|
||||
# pixi.lock
|
||||
.pixi
|
||||
|
||||
# PEP 582
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# Redis
|
||||
*.rdb
|
||||
*.aof
|
||||
*.pid
|
||||
|
||||
# RabbitMQ
|
||||
mnesia/
|
||||
rabbitmq/
|
||||
rabbitmq-data/
|
||||
|
||||
# ActiveMQ
|
||||
activemq-data/
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.envrc
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Marimo
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
|
||||
# Streamlit
|
||||
.streamlit/secrets.toml
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
**/generated_plugin_registrant.dart
|
||||
.packages
|
||||
.pub-preload-cache/
|
||||
.pub/
|
||||
build/
|
||||
flutter_*.png
|
||||
linked_*.ds
|
||||
unlinked.ds
|
||||
unlinked_spec.ds
|
||||
|
||||
# Android related
|
||||
**/android/**/gradle-wrapper.jar
|
||||
.gradle/
|
||||
**/android/captures/
|
||||
**/android/gradlew
|
||||
**/android/gradlew.bat
|
||||
**/android/local.properties
|
||||
**/android/**/GeneratedPluginRegistrant.java
|
||||
**/android/key.properties
|
||||
*.jks
|
||||
|
||||
# iOS/XCode related
|
||||
**/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/Flutter/flutter_export_environment.sh
|
||||
**/ios/ServiceDefinitions.json
|
||||
**/ios/Runner/GeneratedPluginRegistrant.*
|
||||
|
||||
# macOS
|
||||
**/Flutter/ephemeral/
|
||||
**/Pods/
|
||||
**/macos/Flutter/GeneratedPluginRegistrant.swift
|
||||
**/macos/Flutter/ephemeral
|
||||
**/xcuserdata/
|
||||
|
||||
# Windows
|
||||
**/windows/flutter/generated_plugin_registrant.cc
|
||||
**/windows/flutter/generated_plugin_registrant.h
|
||||
**/windows/flutter/generated_plugins.cmake
|
||||
|
||||
# Linux
|
||||
**/linux/flutter/generated_plugin_registrant.cc
|
||||
**/linux/flutter/generated_plugin_registrant.h
|
||||
**/linux/flutter/generated_plugins.cmake
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
|
||||
# Symbols
|
||||
app.*.symbols
|
||||
|
||||
# Exceptions to above rules.
|
||||
!**/ios/**/default.mode1v3
|
||||
!**/ios/**/default.mode2v3
|
||||
!**/ios/**/default.pbxuser
|
||||
!**/ios/**/default.perspectivev3
|
||||
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
|
||||
!/dev/ci/**/Gemfile.lock
|
||||
|
||||
# Local environment files
|
||||
infra/local/env/*.env
|
||||
configs/env/*.env
|
||||
@@ -5,8 +268,14 @@ infra/cloud/volcano/env/*.env
|
||||
!infra/local/env/*.env.example
|
||||
!configs/env/*.env.example
|
||||
!infra/cloud/volcano/env/*.env.example
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.cloud
|
||||
.env.*.cloud
|
||||
|
||||
# Misc
|
||||
*.class
|
||||
*.lock
|
||||
*.swp
|
||||
.buildlog/
|
||||
.history
|
||||
|
||||
@@ -6,6 +6,18 @@
|
||||
},
|
||||
"zai-mcp-server": {
|
||||
"enabled": false
|
||||
},
|
||||
"postgres_dev": {
|
||||
"type": "local",
|
||||
"command": [
|
||||
"docker",
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"mcp/postgres",
|
||||
"postgresql://supabase:${POSTGRES_PASSWORD}@host.docker.internal:54322/linksy"
|
||||
],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
repos:
|
||||
- repo: https://github.com/DetachHead/basedpyright-prek-mirror
|
||||
rev: 1.37.2
|
||||
hooks:
|
||||
- id: basedpyright
|
||||
args: [--level=error]
|
||||
files: ^api/
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.9.6
|
||||
hooks:
|
||||
- id: ruff
|
||||
files: ^api/
|
||||
@@ -1,6 +1,57 @@
|
||||
## Docker Startup
|
||||
|
||||
Must use environment file when starting services:
|
||||
Always start services with the env file:
|
||||
```bash
|
||||
docker compose --env-file infra/env/.env.local -f infra/local/docker-compose.yml up -d
|
||||
docker compose --env-file .env -f docker/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
## Python Environment
|
||||
|
||||
**MUST use uv for dependency management and virtual environment execution.**
|
||||
|
||||
- All Python commands: `uv run <command>`
|
||||
- Add dependencies: `uv add <package>`
|
||||
- All dependencies declared in `pyproject.toml`
|
||||
|
||||
## Code Quality Checks
|
||||
|
||||
**Git pre-commit hook enforces code quality before commit.**
|
||||
|
||||
Pre-commit hook automatically runs on api/ directory:
|
||||
- `ruff check` - code style and linting
|
||||
- `basedpyright` - type checking with error level
|
||||
|
||||
If any error detected, commit is rejected. Fix errors before committing.
|
||||
Do not bypass or weaken checks (no ignores, disables, or config relaxations). Resolve the underlying issues.
|
||||
|
||||
|
||||
## TDD First Policy
|
||||
|
||||
**Principle: tests before implementation.**
|
||||
|
||||
### Coverage Requirements
|
||||
- Minimum coverage: 80%
|
||||
- Required test types:
|
||||
- Unit: isolated functions, utilities, components
|
||||
- Integration: API endpoints, database operations
|
||||
- E2E: critical user flows (Playwright)
|
||||
|
||||
### Limited Exceptions
|
||||
- Docs-only changes (README, comments, formatting) may skip integration/E2E
|
||||
- Non-runtime config changes may skip E2E if no behavior changes
|
||||
- Any runtime code change requires unit + integration + E2E
|
||||
- If an exception is used, record the reason in the PR/test notes
|
||||
|
||||
### Mandatory TDD Workflow
|
||||
1. Write tests (RED) - they must fail
|
||||
2. Run tests - confirm failure
|
||||
3. Implement minimal code (GREEN) - only to pass
|
||||
4. Run tests - confirm success
|
||||
5. Refactor (IMPROVE)
|
||||
6. Verify coverage - must be 80%+
|
||||
|
||||
### Enforcement
|
||||
- Must use the `tdd-guide` agent for new features
|
||||
- Do not write implementation before tests
|
||||
- Do not lower coverage requirements
|
||||
- Must include unit, integration, and E2E tests
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
# Social App Monorepo
|
||||
|
||||
Flutter + FastAPI + Supabase + Redis + Milvus
|
||||
|
||||
## 说明
|
||||
|
||||
本仓库仅初始化结构,不包含业务实现
|
||||
|
||||
## 目录结构
|
||||
|
||||
- `apps/` —— 可运行应用(Flutter / FastAPI / Worker)
|
||||
- `infra/` —— 基础设施(本地 docker / 云部署 / 迁移)
|
||||
- `configs/` —— 配置规范与公共配置模板(不含密钥)
|
||||
- `tools/` —— 脚本与生成器
|
||||
- `docs/` —— 文档与规则
|
||||
|
||||
详见 `docs/rules/repo-structure.md`
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .settings import Settings, config
|
||||
|
||||
__all__ = ["Settings", "config"]
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, computed_field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class RuntimeSettings(BaseModel):
|
||||
environment: Literal["dev", "test", "prod"] = "dev"
|
||||
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 = "app.log"
|
||||
log_error_file_name: str = "error.log"
|
||||
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
|
||||
|
||||
|
||||
class AppSettings(BaseModel):
|
||||
host: str = "0.0.0.0"
|
||||
port: int = Field(default=8000, ge=1, le=65535)
|
||||
reload: bool = True
|
||||
|
||||
|
||||
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 SupabaseSettings(BaseModel):
|
||||
url: str = "http://localhost:8001"
|
||||
anon_key: str = "CHANGE_ME"
|
||||
service_role_key: str = "CHANGE_ME"
|
||||
jwt_secret: str | None = None
|
||||
|
||||
|
||||
class InfraSupabaseSettings(BaseModel):
|
||||
public_url: str = "http://localhost:8001"
|
||||
anon_key: str = "CHANGE_ME"
|
||||
service_role_key: str = "CHANGE_ME"
|
||||
|
||||
|
||||
class InfraJwtSettings(BaseModel):
|
||||
secret: str = "CHANGE_ME"
|
||||
|
||||
|
||||
class InfraSettings(BaseModel):
|
||||
api_external_url: str = "http://localhost:8001"
|
||||
supabase: InfraSupabaseSettings = Field(default_factory=InfraSupabaseSettings)
|
||||
jwt: InfraJwtSettings = Field(default_factory=InfraJwtSettings)
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
runtime: RuntimeSettings = RuntimeSettings()
|
||||
app: AppSettings = AppSettings()
|
||||
cors: CorsSettings = CorsSettings()
|
||||
infra: InfraSettings = Field(default_factory=InfraSettings)
|
||||
|
||||
@computed_field
|
||||
def supabase(self) -> SupabaseSettings:
|
||||
return SupabaseSettings(
|
||||
url=self.infra.supabase.public_url or self.infra.api_external_url,
|
||||
anon_key=self.infra.supabase.anon_key,
|
||||
service_role_key=self.infra.supabase.service_role_key,
|
||||
jwt_secret=self.infra.jwt.secret,
|
||||
)
|
||||
|
||||
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
|
||||
env_file=_resolve_env_file(),
|
||||
env_prefix="SOCIAL_",
|
||||
env_nested_delimiter="__",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
|
||||
config = Settings()
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.logging import celery
|
||||
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",
|
||||
"celery",
|
||||
"clear_context",
|
||||
"configure_logging",
|
||||
"get_context",
|
||||
"get_logger",
|
||||
]
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import cast
|
||||
|
||||
from celery import Celery, signals
|
||||
|
||||
from core.config.settings import Settings
|
||||
from core.logging.config import configure_logging
|
||||
from core.logging.context import bind_context, clear_context
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CelerySignalHandlers:
|
||||
on_setup_logging: Callable[..., None]
|
||||
on_after_setup_task_logger: Callable[..., None]
|
||||
on_task_prerun: Callable[..., None]
|
||||
on_task_postrun: Callable[..., None]
|
||||
|
||||
|
||||
def build_celery_signal_handlers(
|
||||
settings: Settings | None = None,
|
||||
) -> CelerySignalHandlers:
|
||||
def on_setup_logging(*_args: object, **_kwargs: object) -> None:
|
||||
configure_logging(settings)
|
||||
|
||||
def on_after_setup_task_logger(*_args: object, **_kwargs: object) -> None:
|
||||
configure_logging(settings)
|
||||
|
||||
def on_task_prerun(*_args: object, **kwargs: object) -> None:
|
||||
task_id = cast(str | None, kwargs.get("task_id"))
|
||||
task = kwargs.get("task")
|
||||
task_name = getattr(task, "name", None)
|
||||
bind_context(task_id=task_id, task_name=task_name)
|
||||
|
||||
def on_task_postrun(*_args: object, **_kwargs: object) -> None:
|
||||
clear_context()
|
||||
|
||||
return CelerySignalHandlers(
|
||||
on_setup_logging=on_setup_logging,
|
||||
on_after_setup_task_logger=on_after_setup_task_logger,
|
||||
on_task_prerun=on_task_prerun,
|
||||
on_task_postrun=on_task_postrun,
|
||||
)
|
||||
|
||||
|
||||
def configure_celery_app(app: Celery, settings: Settings | None = None) -> None:
|
||||
app.conf.worker_hijack_root_logger = False
|
||||
|
||||
handlers = build_celery_signal_handlers(settings)
|
||||
signals.setup_logging.connect(handlers.on_setup_logging, weak=False)
|
||||
signals.after_setup_task_logger.connect(
|
||||
handlers.on_after_setup_task_logger, weak=False
|
||||
)
|
||||
signals.task_prerun.connect(handlers.on_task_prerun, weak=False)
|
||||
signals.task_postrun.connect(handlers.on_task_postrun, weak=False)
|
||||
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from logging.config import dictConfig
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
|
||||
from core.config.settings import RuntimeSettings, Settings
|
||||
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:
|
||||
Path(runtime.log_dir).mkdir(parents=True, exist_ok=True)
|
||||
Path(runtime.log_error_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def build_logging_config(runtime: RuntimeSettings) -> dict[str, object]:
|
||||
log_dir = Path(runtime.log_dir)
|
||||
error_dir = 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 or Settings()
|
||||
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,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def pytest_configure() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
src_path = root / "api" / "src"
|
||||
if str(src_path) not in sys.path:
|
||||
sys.path.append(str(src_path))
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from playwright.sync_api import sync_playwright
|
||||
import uvicorn
|
||||
|
||||
from core.config.settings import Settings
|
||||
from core.logging.config import configure_logging
|
||||
from core.logging.middleware import (
|
||||
RequestContextMiddleware,
|
||||
register_exception_handlers,
|
||||
)
|
||||
|
||||
|
||||
def _read_json_lines(path: Path) -> list[dict[str, object]]:
|
||||
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _find_free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
def _wait_for_port(host: str, port: int, timeout: float = 5.0) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
if sock.connect_ex((host, port)) == 0:
|
||||
return
|
||||
time.sleep(0.05)
|
||||
raise RuntimeError("Server did not start in time")
|
||||
|
||||
|
||||
def _start_server(app: FastAPI, host: str, port: int):
|
||||
config = uvicorn.Config(app, host=host, port=port, log_level="info")
|
||||
server = uvicorn.Server(config)
|
||||
thread = threading.Thread(target=server.run, daemon=True)
|
||||
thread.start()
|
||||
_wait_for_port(host, port)
|
||||
return server, thread
|
||||
|
||||
|
||||
def test_e2e_error_logging(tmp_path: Path) -> None:
|
||||
settings = Settings()
|
||||
runtime = settings.runtime.model_copy(
|
||||
update={
|
||||
"log_dir": str(tmp_path),
|
||||
"log_error_dir": str(tmp_path / "errors"),
|
||||
"log_rotation": "size",
|
||||
"log_rotation_max_bytes": 2048,
|
||||
}
|
||||
)
|
||||
configure_logging(settings.model_copy(update={"runtime": runtime}))
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(RequestContextMiddleware) # type: ignore[arg-type]
|
||||
register_exception_handlers(app)
|
||||
|
||||
@app.get("/boom")
|
||||
async def boom() -> dict[str, str]:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
host = "127.0.0.1"
|
||||
port = _find_free_port()
|
||||
server, thread = _start_server(app, host, port)
|
||||
|
||||
try:
|
||||
with sync_playwright() as playwright:
|
||||
request_context = playwright.request.new_context(
|
||||
base_url=f"http://{host}:{port}"
|
||||
)
|
||||
response = request_context.get(
|
||||
"/boom",
|
||||
headers={"X-Request-ID": "e2e-5000"},
|
||||
)
|
||||
assert response.status == 500
|
||||
request_context.dispose()
|
||||
finally:
|
||||
server.should_exit = True
|
||||
thread.join(timeout=5)
|
||||
|
||||
error_entries = _read_json_lines(Path(tmp_path) / "errors" / "error.log")
|
||||
entry = next(
|
||||
item for item in error_entries if item.get("message") == "Unhandled exception"
|
||||
)
|
||||
|
||||
assert entry["request_id"] == "e2e-5000"
|
||||
exception = str(entry["exception"])
|
||||
assert "Traceback" in exception
|
||||
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from core.config.settings import Settings
|
||||
from core.logging.config import configure_logging
|
||||
from core.logging.logger import get_logger
|
||||
from core.logging.middleware import (
|
||||
RequestContextMiddleware,
|
||||
register_exception_handlers,
|
||||
)
|
||||
|
||||
|
||||
def _read_json_lines(path: Path) -> list[dict[str, object]]:
|
||||
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _configure_test_logging(tmp_path: Path) -> None:
|
||||
settings = Settings()
|
||||
runtime = settings.runtime.model_copy(
|
||||
update={
|
||||
"log_dir": str(tmp_path),
|
||||
"log_error_dir": str(tmp_path / "errors"),
|
||||
"log_rotation": "size",
|
||||
"log_rotation_max_bytes": 2048,
|
||||
}
|
||||
)
|
||||
test_settings = settings.model_copy(update={"runtime": runtime})
|
||||
|
||||
configure_logging(test_settings)
|
||||
|
||||
|
||||
def test_middleware_binds_request_context(tmp_path: Path) -> None:
|
||||
_configure_test_logging(tmp_path)
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(RequestContextMiddleware) # type: ignore[arg-type]
|
||||
|
||||
@app.get("/ok")
|
||||
async def ok() -> dict[str, str]:
|
||||
logger = get_logger("tests.ok")
|
||||
logger.info("request accepted", context_key="context_value")
|
||||
return {"status": "ok"}
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/ok", headers={"X-Request-ID": "req-1234"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["X-Request-ID"] == "req-1234"
|
||||
|
||||
log_entries = _read_json_lines(Path(tmp_path) / "app.log")
|
||||
entry = next(
|
||||
item for item in log_entries if item.get("message") == "request accepted"
|
||||
)
|
||||
assert entry["message"] == "request accepted"
|
||||
assert entry["request_id"] == "req-1234"
|
||||
assert entry["method"] == "GET"
|
||||
assert entry["path"] == "/ok"
|
||||
assert entry["context_key"] == "context_value"
|
||||
|
||||
logging.shutdown()
|
||||
|
||||
|
||||
def test_exception_handler_logs_stack_and_sends_500(tmp_path: Path) -> None:
|
||||
_configure_test_logging(tmp_path)
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(RequestContextMiddleware)
|
||||
register_exception_handlers(app)
|
||||
|
||||
@app.get("/boom")
|
||||
async def boom() -> dict[str, str]:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
response = client.get("/boom", headers={"X-Request-ID": "req-5000"})
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json()["detail"] == "Internal Server Error"
|
||||
|
||||
error_entries = _read_json_lines(Path(tmp_path) / "errors" / "error.log")
|
||||
assert error_entries
|
||||
entry = error_entries[-1]
|
||||
assert entry["level"] == "error"
|
||||
assert entry["request_id"] == "req-5000"
|
||||
exception = str(entry["exception"])
|
||||
assert "Traceback" in exception
|
||||
assert "test_fastapi_logging_integration" in exception
|
||||
|
||||
logging.shutdown()
|
||||
|
||||
|
||||
def test_invalid_request_id_is_replaced_and_used_in_error_context(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_configure_test_logging(tmp_path)
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(RequestContextMiddleware)
|
||||
register_exception_handlers(app)
|
||||
|
||||
@app.get("/boom")
|
||||
async def boom() -> dict[str, str]:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
response = client.get("/boom", headers={"X-Request-ID": "bad"})
|
||||
|
||||
assert response.status_code == 500
|
||||
|
||||
response_request_id = response.headers["X-Request-ID"]
|
||||
assert response_request_id != "bad"
|
||||
|
||||
error_entries = _read_json_lines(Path(tmp_path) / "errors" / "error.log")
|
||||
assert error_entries
|
||||
entry = error_entries[-1]
|
||||
assert entry["request_id"] == response_request_id
|
||||
exception = str(entry["exception"])
|
||||
assert "Traceback" in exception
|
||||
|
||||
logging.shutdown()
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from celery import Celery
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
from core.logging import celery as celery_logging
|
||||
from core.logging.context import clear_context, get_context
|
||||
|
||||
|
||||
class DummyTask:
|
||||
name: str = "tasks.sample"
|
||||
|
||||
|
||||
def test_celery_prerun_binds_task_context() -> None:
|
||||
handlers = celery_logging.build_celery_signal_handlers()
|
||||
|
||||
handlers.on_task_prerun(task_id="task-123", task=DummyTask())
|
||||
context = get_context()
|
||||
|
||||
assert context["task_id"] == "task-123"
|
||||
assert context["task_name"] == "tasks.sample"
|
||||
|
||||
clear_context()
|
||||
|
||||
|
||||
def test_celery_setup_logging_calls_configure(monkeypatch: MonkeyPatch) -> None:
|
||||
called = {"value": False}
|
||||
|
||||
def fake_configure_logging(settings: object | None = None) -> None:
|
||||
called["value"] = True
|
||||
|
||||
monkeypatch.setattr(celery_logging, "configure_logging", fake_configure_logging)
|
||||
handlers = celery_logging.build_celery_signal_handlers()
|
||||
|
||||
handlers.on_setup_logging()
|
||||
|
||||
assert called["value"] is True
|
||||
|
||||
|
||||
def test_configure_celery_app_disables_hijack() -> None:
|
||||
app = Celery("test")
|
||||
|
||||
celery_logging.configure_celery_app(app)
|
||||
|
||||
assert app.conf.worker_hijack_root_logger is False
|
||||
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
import structlog
|
||||
|
||||
from core.config.settings import Settings
|
||||
from core.logging.config import build_logging_config, configure_logging
|
||||
|
||||
|
||||
def _get_handlers(config: dict[str, object]) -> dict[str, dict[str, object]]:
|
||||
return cast(dict[str, dict[str, object]], config["handlers"])
|
||||
|
||||
|
||||
def test_build_logging_config_time_rotation(tmp_path: Path) -> None:
|
||||
settings = Settings()
|
||||
runtime = settings.runtime.model_copy(
|
||||
update={
|
||||
"log_dir": str(tmp_path),
|
||||
"log_error_dir": str(tmp_path / "errors"),
|
||||
"log_rotation": "time",
|
||||
}
|
||||
)
|
||||
|
||||
config = build_logging_config(runtime)
|
||||
handlers = _get_handlers(config)
|
||||
|
||||
assert handlers["file"]["class"] == "logging.handlers.TimedRotatingFileHandler"
|
||||
assert handlers["error"]["class"] == "logging.handlers.TimedRotatingFileHandler"
|
||||
assert handlers["error"]["level"] == "ERROR"
|
||||
|
||||
|
||||
def test_build_logging_config_size_rotation(tmp_path: Path) -> None:
|
||||
settings = Settings()
|
||||
runtime = settings.runtime.model_copy(
|
||||
update={
|
||||
"log_dir": str(tmp_path),
|
||||
"log_error_dir": str(tmp_path / "errors"),
|
||||
"log_rotation": "size",
|
||||
"log_rotation_max_bytes": 2048,
|
||||
}
|
||||
)
|
||||
|
||||
config = build_logging_config(runtime)
|
||||
handlers = _get_handlers(config)
|
||||
|
||||
assert handlers["file"]["class"] == "logging.handlers.RotatingFileHandler"
|
||||
assert handlers["error"]["class"] == "logging.handlers.RotatingFileHandler"
|
||||
assert handlers["file"]["maxBytes"] == 2048
|
||||
|
||||
|
||||
def test_build_logging_config_plain_formatter_when_disabled(tmp_path: Path) -> None:
|
||||
settings = Settings()
|
||||
runtime = settings.runtime.model_copy(
|
||||
update={
|
||||
"log_dir": str(tmp_path),
|
||||
"log_error_dir": str(tmp_path / "errors"),
|
||||
"log_json": False,
|
||||
}
|
||||
)
|
||||
|
||||
config = build_logging_config(runtime)
|
||||
handlers = _get_handlers(config)
|
||||
|
||||
assert handlers["file"]["formatter"] == "plain"
|
||||
assert handlers["error"]["formatter"] == "plain"
|
||||
|
||||
|
||||
def _read_last_log_entry(log_path: Path) -> dict[str, object]:
|
||||
assert log_path.exists(), f"Expected log file at {log_path}"
|
||||
entries = [
|
||||
json.loads(line) for line in log_path.read_text().splitlines() if line.strip()
|
||||
]
|
||||
assert entries, "Expected at least one log entry in app.log"
|
||||
return entries[-1]
|
||||
|
||||
|
||||
def _flush_root_handlers() -> None:
|
||||
root_logger = logging.getLogger()
|
||||
for handler in root_logger.handlers:
|
||||
if hasattr(handler, "flush"):
|
||||
handler.flush()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def configured_logging(tmp_path: Path) -> Iterator[Path]:
|
||||
settings = Settings()
|
||||
runtime = settings.runtime.model_copy(
|
||||
update={
|
||||
"log_dir": str(tmp_path),
|
||||
"log_error_dir": str(tmp_path / "errors"),
|
||||
"log_rotation": "size",
|
||||
"log_rotation_max_bytes": 2048,
|
||||
"log_json": True,
|
||||
}
|
||||
)
|
||||
root_logger = logging.getLogger()
|
||||
original_handlers = root_logger.handlers[:]
|
||||
original_level = root_logger.level
|
||||
|
||||
configure_logging(settings.model_copy(update={"runtime": runtime}))
|
||||
|
||||
yield tmp_path
|
||||
|
||||
for handler in root_logger.handlers:
|
||||
handler.close()
|
||||
root_logger.handlers = original_handlers
|
||||
root_logger.setLevel(original_level)
|
||||
structlog.reset_defaults()
|
||||
|
||||
|
||||
def test_stdlib_logging_redacts_sensitive_fields(configured_logging: Path) -> None:
|
||||
logger = logging.getLogger("tests.stdlib")
|
||||
logger.info("login", extra={"password": "secret", "token": "abc"})
|
||||
|
||||
_flush_root_handlers()
|
||||
|
||||
log_path = configured_logging / "app.log"
|
||||
entry = _read_last_log_entry(log_path)
|
||||
|
||||
assert entry["password"] == "[REDACTED]"
|
||||
assert entry["token"] == "[REDACTED]"
|
||||
|
||||
|
||||
def test_structlog_redacts_sensitive_fields(configured_logging: Path) -> None:
|
||||
logger = structlog.get_logger("tests.structlog")
|
||||
logger.info("login", password="secret", token="abc")
|
||||
|
||||
_flush_root_handlers()
|
||||
|
||||
log_path = configured_logging / "app.log"
|
||||
entry = _read_last_log_entry(log_path)
|
||||
|
||||
assert entry["password"] == "[REDACTED]"
|
||||
assert entry["token"] == "[REDACTED]"
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.logging.filters import build_sensitive_data_processor
|
||||
|
||||
|
||||
def test_redact_sensitive_fields_masks_values() -> None:
|
||||
processor = build_sensitive_data_processor(
|
||||
["password", "token", "api_key", "cookie"]
|
||||
)
|
||||
|
||||
event: dict[str, object] = {
|
||||
"message": "login",
|
||||
"password": "secret",
|
||||
"access_token": "token-123",
|
||||
"apiKey": "apikey-123",
|
||||
"set-cookie": "cookie-1",
|
||||
"nested": {"token": "abc", "safe": "ok"},
|
||||
"list": [{"password": "x"}],
|
||||
}
|
||||
|
||||
redacted = processor(None, "info", event)
|
||||
|
||||
assert redacted["password"] == "[REDACTED]"
|
||||
assert redacted["access_token"] == "[REDACTED]"
|
||||
assert redacted["apiKey"] == "[REDACTED]"
|
||||
assert redacted["set-cookie"] == "[REDACTED]"
|
||||
assert redacted["nested"]["token"] == "[REDACTED]"
|
||||
assert redacted["nested"]["safe"] == "ok"
|
||||
assert redacted["list"][0]["password"] == "[REDACTED]"
|
||||
assert event["password"] == "secret"
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
from core.config.settings import Settings
|
||||
|
||||
|
||||
def test_runtime_settings_defaults() -> None:
|
||||
settings = Settings()
|
||||
|
||||
assert settings.runtime.log_json is True
|
||||
assert settings.runtime.log_rotation == "time"
|
||||
assert settings.runtime.log_rotation_when == "midnight"
|
||||
assert settings.runtime.log_rotation_interval == 1
|
||||
assert settings.runtime.log_rotation_backup_count == 14
|
||||
assert settings.runtime.log_rotation_max_bytes == 10_000_000
|
||||
assert settings.runtime.log_dir == "logs"
|
||||
assert settings.runtime.log_error_dir == "logs/errors"
|
||||
assert settings.runtime.log_file_name == "app.log"
|
||||
assert settings.runtime.log_error_file_name == "error.log"
|
||||
assert "password" in settings.runtime.log_sensitive_fields
|
||||
|
||||
|
||||
def test_runtime_settings_env_override(monkeypatch: MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("SOCIAL_RUNTIME__LOG_DIR", "var/logs")
|
||||
monkeypatch.setenv("SOCIAL_RUNTIME__LOG_ERROR_DIR", "var/logs/errors")
|
||||
monkeypatch.setenv("SOCIAL_RUNTIME__LOG_ROTATION", "size")
|
||||
monkeypatch.setenv("SOCIAL_RUNTIME__LOG_ROTATION_MAX_BYTES", "2048")
|
||||
|
||||
settings = Settings()
|
||||
|
||||
assert settings.runtime.log_dir == "var/logs"
|
||||
assert settings.runtime.log_error_dir == "var/logs/errors"
|
||||
assert settings.runtime.log_rotation == "size"
|
||||
assert settings.runtime.log_rotation_max_bytes == 2048
|
||||
@@ -1,2 +0,0 @@
|
||||
# FastAPI 服务占位文件
|
||||
# 后续添加依赖和配置
|
||||
@@ -1,2 +0,0 @@
|
||||
# Flutter 应用占位文件
|
||||
# 后续添加依赖和配置
|
||||
@@ -1,2 +0,0 @@
|
||||
# 异步任务/队列服务占位文件
|
||||
# 预留:后续可能添加
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
# 放后端开发项目需要的环境变量
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"apiBaseUrl": "http://localhost:8000",
|
||||
"environment": "development"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"apiBaseUrl": "https://api.yourdomain.com",
|
||||
"environment": "production"
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
# OpenAPI 规范占位文件
|
||||
# 后续通过 FastAPI 自动生成
|
||||
@@ -47,22 +47,22 @@ services:
|
||||
environment:
|
||||
HOSTNAME: "::"
|
||||
STUDIO_PG_META_URL: http://meta:8080
|
||||
POSTGRES_PORT: ${POSTGRES_PORT}
|
||||
POSTGRES_HOST: ${POSTGRES_HOST}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
PG_META_CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
|
||||
DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION}
|
||||
DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
POSTGRES_PORT: ${SOCIAL_INFRA__POSTGRES__PORT}
|
||||
POSTGRES_HOST: ${SOCIAL_INFRA__POSTGRES__HOST}
|
||||
POSTGRES_DB: ${SOCIAL_INFRA__POSTGRES__DB}
|
||||
POSTGRES_PASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD}
|
||||
PG_META_CRYPTO_KEY: ${SOCIAL_INFRA__PG_META__CRYPTO_KEY}
|
||||
DEFAULT_ORGANIZATION_NAME: ${SOCIAL_INFRA__STUDIO__DEFAULT_ORGANIZATION}
|
||||
DEFAULT_PROJECT_NAME: ${SOCIAL_INFRA__STUDIO__DEFAULT_PROJECT}
|
||||
OPENAI_API_KEY: ${SOCIAL_INFRA__OPENAI__API_KEY:-}
|
||||
SUPABASE_URL: http://kong:8000
|
||||
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
|
||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
||||
AUTH_JWT_SECRET: ${JWT_SECRET}
|
||||
LOGFLARE_API_KEY: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
|
||||
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
|
||||
LOGFLARE_PRIVATE_ACCESS_TOKEN: ${LOGFLARE_PRIVATE_ACCESS_TOKEN}
|
||||
SUPABASE_PUBLIC_URL: ${SOCIAL_INFRA__SUPABASE__PUBLIC_URL}
|
||||
SUPABASE_ANON_KEY: ${SOCIAL_INFRA__SUPABASE__ANON_KEY}
|
||||
SUPABASE_SERVICE_KEY: ${SOCIAL_INFRA__SUPABASE__SERVICE_ROLE_KEY}
|
||||
AUTH_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
|
||||
LOGFLARE_API_KEY: ${SOCIAL_INFRA__LOGFLARE__PUBLIC_ACCESS_TOKEN}
|
||||
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${SOCIAL_INFRA__LOGFLARE__PUBLIC_ACCESS_TOKEN}
|
||||
LOGFLARE_PRIVATE_ACCESS_TOKEN: ${SOCIAL_INFRA__LOGFLARE__PRIVATE_ACCESS_TOKEN}
|
||||
LOGFLARE_URL: http://analytics:4000
|
||||
NEXT_PUBLIC_ENABLE_LOGS: true
|
||||
NEXT_ANALYTICS_BACKEND_PROVIDER: postgres
|
||||
@@ -75,8 +75,8 @@ services:
|
||||
image: kong:2.8.1
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- ${KONG_HTTP_PORT}:8000/tcp
|
||||
- ${KONG_HTTPS_PORT}:8443/tcp
|
||||
- ${SOCIAL_INFRA__KONG__HTTP_PORT}:8000/tcp
|
||||
- ${SOCIAL_INFRA__KONG__HTTPS_PORT}:8443/tcp
|
||||
volumes:
|
||||
- ./supabase/volumes/api/kong.yml:/home/kong/temp.yml:ro,z
|
||||
depends_on:
|
||||
@@ -89,10 +89,10 @@ services:
|
||||
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction
|
||||
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
|
||||
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
|
||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
||||
DASHBOARD_USERNAME: ${DASHBOARD_USERNAME}
|
||||
DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD}
|
||||
SUPABASE_ANON_KEY: ${SOCIAL_INFRA__SUPABASE__ANON_KEY}
|
||||
SUPABASE_SERVICE_KEY: ${SOCIAL_INFRA__SUPABASE__SERVICE_ROLE_KEY}
|
||||
DASHBOARD_USERNAME: ${SOCIAL_INFRA__DASHBOARD__USERNAME}
|
||||
DASHBOARD_PASSWORD: ${SOCIAL_INFRA__DASHBOARD__PASSWORD}
|
||||
entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start'
|
||||
|
||||
auth:
|
||||
@@ -120,32 +120,32 @@ services:
|
||||
environment:
|
||||
GOTRUE_API_HOST: 0.0.0.0
|
||||
GOTRUE_API_PORT: 9999
|
||||
API_EXTERNAL_URL: ${API_EXTERNAL_URL}
|
||||
API_EXTERNAL_URL: ${SOCIAL_INFRA__API_EXTERNAL_URL}
|
||||
GOTRUE_DB_DRIVER: postgres
|
||||
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||
GOTRUE_SITE_URL: ${SITE_URL}
|
||||
GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS}
|
||||
GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP}
|
||||
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${SOCIAL_INFRA__POSTGRES__PASSWORD}@${SOCIAL_INFRA__POSTGRES__HOST}:${SOCIAL_INFRA__POSTGRES__PORT}/${SOCIAL_INFRA__POSTGRES__DB}
|
||||
GOTRUE_SITE_URL: ${SOCIAL_INFRA__SITE__URL}
|
||||
GOTRUE_URI_ALLOW_LIST: ${SOCIAL_INFRA__ADDITIONAL_REDIRECT_URLS}
|
||||
GOTRUE_DISABLE_SIGNUP: ${SOCIAL_INFRA__AUTH__DISABLE_SIGNUP}
|
||||
GOTRUE_JWT_ADMIN_ROLES: service_role
|
||||
GOTRUE_JWT_AUD: authenticated
|
||||
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
|
||||
GOTRUE_JWT_EXP: ${JWT_EXPIRY}
|
||||
GOTRUE_JWT_SECRET: ${JWT_SECRET}
|
||||
GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP}
|
||||
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS}
|
||||
GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM}
|
||||
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL}
|
||||
GOTRUE_SMTP_HOST: ${SMTP_HOST}
|
||||
GOTRUE_SMTP_PORT: ${SMTP_PORT}
|
||||
GOTRUE_SMTP_USER: ${SMTP_USER}
|
||||
GOTRUE_SMTP_PASS: ${SMTP_PASS}
|
||||
GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME}
|
||||
GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE}
|
||||
GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION}
|
||||
GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY}
|
||||
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE}
|
||||
GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP}
|
||||
GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM}
|
||||
GOTRUE_JWT_EXP: ${SOCIAL_INFRA__JWT__EXPIRY}
|
||||
GOTRUE_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
|
||||
GOTRUE_EXTERNAL_EMAIL_ENABLED: ${SOCIAL_INFRA__EMAIL__ENABLE_SIGNUP}
|
||||
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${SOCIAL_INFRA__AUTH__ENABLE_ANONYMOUS_USERS}
|
||||
GOTRUE_MAILER_AUTOCONFIRM: ${SOCIAL_INFRA__EMAIL__ENABLE_AUTOCONFIRM}
|
||||
GOTRUE_SMTP_ADMIN_EMAIL: ${SOCIAL_INFRA__SMTP__ADMIN_EMAIL}
|
||||
GOTRUE_SMTP_HOST: ${SOCIAL_INFRA__SMTP__HOST}
|
||||
GOTRUE_SMTP_PORT: ${SOCIAL_INFRA__SMTP__PORT}
|
||||
GOTRUE_SMTP_USER: ${SOCIAL_INFRA__SMTP__USER}
|
||||
GOTRUE_SMTP_PASS: ${SOCIAL_INFRA__SMTP__PASS}
|
||||
GOTRUE_SMTP_SENDER_NAME: ${SOCIAL_INFRA__SMTP__SENDER_NAME}
|
||||
GOTRUE_MAILER_URLPATHS_INVITE: ${SOCIAL_INFRA__MAILER__URLPATHS_INVITE}
|
||||
GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${SOCIAL_INFRA__MAILER__URLPATHS_CONFIRMATION}
|
||||
GOTRUE_MAILER_URLPATHS_RECOVERY: ${SOCIAL_INFRA__MAILER__URLPATHS_RECOVERY}
|
||||
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${SOCIAL_INFRA__MAILER__URLPATHS_EMAIL_CHANGE}
|
||||
GOTRUE_EXTERNAL_PHONE_ENABLED: ${SOCIAL_INFRA__AUTH__ENABLE_PHONE_SIGNUP}
|
||||
GOTRUE_SMS_AUTOCONFIRM: ${SOCIAL_INFRA__AUTH__ENABLE_PHONE_AUTOCONFIRM}
|
||||
|
||||
rest:
|
||||
container_name: supabase-rest
|
||||
@@ -157,13 +157,13 @@ services:
|
||||
analytics:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||
PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
|
||||
PGRST_DB_URI: postgres://authenticator:${SOCIAL_INFRA__POSTGRES__PASSWORD}@${SOCIAL_INFRA__POSTGRES__HOST}:${SOCIAL_INFRA__POSTGRES__PORT}/${SOCIAL_INFRA__POSTGRES__DB}
|
||||
PGRST_DB_SCHEMAS: ${SOCIAL_INFRA__PGRST__DB_SCHEMAS}
|
||||
PGRST_DB_ANON_ROLE: anon
|
||||
PGRST_JWT_SECRET: ${JWT_SECRET}
|
||||
PGRST_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
|
||||
PGRST_DB_USE_LEGACY_GUCS: "false"
|
||||
PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
|
||||
PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY}
|
||||
PGRST_APP_SETTINGS_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
|
||||
PGRST_APP_SETTINGS_JWT_EXP: ${SOCIAL_INFRA__JWT__EXPIRY}
|
||||
command: ["postgrest"]
|
||||
|
||||
realtime:
|
||||
@@ -179,7 +179,7 @@ services:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
'curl -sSfL --head -o /dev/null -H "Authorization: Bearer ${ANON_KEY}" http://localhost:4000/api/tenants/realtime-dev/health',
|
||||
'curl -sSfL --head -o /dev/null -H "Authorization: Bearer ${SOCIAL_INFRA__SUPABASE__ANON_KEY}" http://localhost:4000/api/tenants/realtime-dev/health',
|
||||
]
|
||||
timeout: 5s
|
||||
interval: 30s
|
||||
@@ -187,16 +187,16 @@ services:
|
||||
start_period: 10s
|
||||
environment:
|
||||
PORT: 4000
|
||||
DB_HOST: ${POSTGRES_HOST}
|
||||
DB_PORT: ${POSTGRES_PORT}
|
||||
DB_HOST: ${SOCIAL_INFRA__POSTGRES__HOST}
|
||||
DB_PORT: ${SOCIAL_INFRA__POSTGRES__PORT}
|
||||
DB_USER: supabase_admin
|
||||
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
DB_NAME: ${POSTGRES_DB}
|
||||
DB_PASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD}
|
||||
DB_NAME: ${SOCIAL_INFRA__POSTGRES__DB}
|
||||
DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime"
|
||||
DB_ENC_KEY: supabaserealtime
|
||||
API_JWT_SECRET: ${JWT_SECRET}
|
||||
ANON_KEY: ${ANON_KEY}
|
||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
|
||||
API_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
|
||||
ANON_KEY: ${SOCIAL_INFRA__SUPABASE__ANON_KEY}
|
||||
SECRET_KEY_BASE: ${SOCIAL_INFRA__SUPAVISOR__SECRET_KEY_BASE}
|
||||
ERL_AFLAGS: -proto_dist inet_tcp
|
||||
DNS_NODES: "''"
|
||||
RLIMIT_NOFILE: "10000"
|
||||
@@ -232,11 +232,11 @@ services:
|
||||
imgproxy:
|
||||
condition: service_started
|
||||
environment:
|
||||
ANON_KEY: ${ANON_KEY}
|
||||
SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
||||
ANON_KEY: ${SOCIAL_INFRA__SUPABASE__ANON_KEY}
|
||||
SERVICE_KEY: ${SOCIAL_INFRA__SUPABASE__SERVICE_ROLE_KEY}
|
||||
POSTGREST_URL: http://rest:3000
|
||||
PGRST_JWT_SECRET: ${JWT_SECRET}
|
||||
DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||
PGRST_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
|
||||
DATABASE_URL: postgres://supabase_storage_admin:${SOCIAL_INFRA__POSTGRES__PASSWORD}@${SOCIAL_INFRA__POSTGRES__HOST}:${SOCIAL_INFRA__POSTGRES__PORT}/${SOCIAL_INFRA__POSTGRES__DB}
|
||||
REQUEST_ALLOW_X_FORWARDED_PATH: "true"
|
||||
FILE_SIZE_LIMIT: 52428800
|
||||
STORAGE_BACKEND: file
|
||||
@@ -262,7 +262,7 @@ services:
|
||||
IMGPROXY_BIND: ":5001"
|
||||
IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
|
||||
IMGPROXY_USE_ETAG: "true"
|
||||
IMGPROXY_ENABLE_WEBP_DETECTION: ${IMGPROXY_ENABLE_WEBP_DETECTION}
|
||||
IMGPROXY_ENABLE_WEBP_DETECTION: ${SOCIAL_INFRA__IMGPROXY__ENABLE_WEBP_DETECTION}
|
||||
IMGPROXY_MAX_SRC_RESOLUTION: 16.8
|
||||
|
||||
meta:
|
||||
@@ -276,12 +276,12 @@ services:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PG_META_PORT: 8080
|
||||
PG_META_DB_HOST: ${POSTGRES_HOST}
|
||||
PG_META_DB_PORT: ${POSTGRES_PORT}
|
||||
PG_META_DB_NAME: ${POSTGRES_DB}
|
||||
PG_META_DB_HOST: ${SOCIAL_INFRA__POSTGRES__HOST}
|
||||
PG_META_DB_PORT: ${SOCIAL_INFRA__POSTGRES__PORT}
|
||||
PG_META_DB_NAME: ${SOCIAL_INFRA__POSTGRES__DB}
|
||||
PG_META_DB_USER: supabase_admin
|
||||
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
|
||||
PG_META_DB_PASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD}
|
||||
CRYPTO_KEY: ${SOCIAL_INFRA__PG_META__CRYPTO_KEY}
|
||||
|
||||
functions:
|
||||
container_name: supabase-edge-functions
|
||||
@@ -293,12 +293,12 @@ services:
|
||||
analytics:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
|
||||
SUPABASE_URL: http://kong:8000
|
||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
|
||||
SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||
VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}"
|
||||
SUPABASE_ANON_KEY: ${SOCIAL_INFRA__SUPABASE__ANON_KEY}
|
||||
SUPABASE_SERVICE_ROLE_KEY: ${SOCIAL_INFRA__SUPABASE__SERVICE_ROLE_KEY}
|
||||
SUPABASE_DB_URL: postgresql://postgres:${SOCIAL_INFRA__POSTGRES__PASSWORD}@${SOCIAL_INFRA__POSTGRES__HOST}:${SOCIAL_INFRA__POSTGRES__PORT}/${SOCIAL_INFRA__POSTGRES__DB}
|
||||
VERIFY_JWT: "${SOCIAL_INFRA__FUNCTIONS__VERIFY_JWT}"
|
||||
command: ["start", "--main-service", "/home/deno/functions/main"]
|
||||
|
||||
analytics:
|
||||
@@ -319,15 +319,15 @@ services:
|
||||
LOGFLARE_NODE_HOST: 127.0.0.1
|
||||
DB_USERNAME: supabase_admin
|
||||
DB_DATABASE: _supabase
|
||||
DB_HOSTNAME: ${POSTGRES_HOST}
|
||||
DB_PORT: ${POSTGRES_PORT}
|
||||
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
DB_HOSTNAME: ${SOCIAL_INFRA__POSTGRES__HOST}
|
||||
DB_PORT: ${SOCIAL_INFRA__POSTGRES__PORT}
|
||||
DB_PASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD}
|
||||
DB_SCHEMA: _analytics
|
||||
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
|
||||
LOGFLARE_PRIVATE_ACCESS_TOKEN: ${LOGFLARE_PRIVATE_ACCESS_TOKEN}
|
||||
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${SOCIAL_INFRA__LOGFLARE__PUBLIC_ACCESS_TOKEN}
|
||||
LOGFLARE_PRIVATE_ACCESS_TOKEN: ${SOCIAL_INFRA__LOGFLARE__PRIVATE_ACCESS_TOKEN}
|
||||
LOGFLARE_SINGLE_TENANT: true
|
||||
LOGFLARE_SUPABASE_MODE: true
|
||||
POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase
|
||||
POSTGRES_BACKEND_URL: postgresql://supabase_admin:${SOCIAL_INFRA__POSTGRES__PASSWORD}@${SOCIAL_INFRA__POSTGRES__HOST}:${SOCIAL_INFRA__POSTGRES__PORT}/_supabase
|
||||
POSTGRES_BACKEND_SCHEMA: _analytics
|
||||
LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true
|
||||
|
||||
@@ -355,14 +355,14 @@ services:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
POSTGRES_HOST: /var/run/postgresql
|
||||
PGPORT: ${POSTGRES_PORT}
|
||||
POSTGRES_PORT: ${POSTGRES_PORT}
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
PGDATABASE: ${POSTGRES_DB}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
JWT_EXP: ${JWT_EXPIRY}
|
||||
PGPORT: ${SOCIAL_INFRA__POSTGRES__PORT}
|
||||
POSTGRES_PORT: ${SOCIAL_INFRA__POSTGRES__PORT}
|
||||
PGPASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD}
|
||||
POSTGRES_PASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD}
|
||||
PGDATABASE: ${SOCIAL_INFRA__POSTGRES__DB}
|
||||
POSTGRES_DB: ${SOCIAL_INFRA__POSTGRES__DB}
|
||||
JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
|
||||
JWT_EXP: ${SOCIAL_INFRA__JWT__EXPIRY}
|
||||
command:
|
||||
[
|
||||
"postgres",
|
||||
@@ -378,7 +378,7 @@ services:
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./supabase/volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z
|
||||
- ${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock:ro,z
|
||||
- ${SOCIAL_INFRA__DOCKER__SOCKET_LOCATION}:/var/run/docker.sock:ro,z
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
@@ -393,7 +393,7 @@ services:
|
||||
interval: 5s
|
||||
retries: 3
|
||||
environment:
|
||||
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
|
||||
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${SOCIAL_INFRA__LOGFLARE__PUBLIC_ACCESS_TOKEN}
|
||||
command: ["--config", "/etc/vector/vector.yml"]
|
||||
security_opt:
|
||||
- "label=disable"
|
||||
@@ -403,8 +403,8 @@ services:
|
||||
image: supabase/supavisor:2.7.4
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- ${POSTGRES_PORT}:5432
|
||||
- ${POOLER_PROXY_PORT_TRANSACTION}:6543
|
||||
- ${SOCIAL_INFRA__POSTGRES__PORT}:5432
|
||||
- ${SOCIAL_INFRA__POOLER__PROXY_PORT_TRANSACTION}:6543
|
||||
volumes:
|
||||
- ./supabase/volumes/pooler/pooler.exs:/etc/pooler/pooler.exs:ro,z
|
||||
healthcheck:
|
||||
@@ -428,22 +428,22 @@ services:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PORT: 4000
|
||||
POSTGRES_PORT: ${POSTGRES_PORT}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
DATABASE_URL: ecto://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase
|
||||
POSTGRES_PORT: ${SOCIAL_INFRA__POSTGRES__PORT}
|
||||
POSTGRES_DB: ${SOCIAL_INFRA__POSTGRES__DB}
|
||||
POSTGRES_PASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD}
|
||||
DATABASE_URL: ecto://supabase_admin:${SOCIAL_INFRA__POSTGRES__PASSWORD}@${SOCIAL_INFRA__POSTGRES__HOST}:${SOCIAL_INFRA__POSTGRES__PORT}/_supabase
|
||||
CLUSTER_POSTGRES: true
|
||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
|
||||
VAULT_ENC_KEY: ${VAULT_ENC_KEY}
|
||||
API_JWT_SECRET: ${JWT_SECRET}
|
||||
METRICS_JWT_SECRET: ${JWT_SECRET}
|
||||
SECRET_KEY_BASE: ${SOCIAL_INFRA__SUPAVISOR__SECRET_KEY_BASE}
|
||||
VAULT_ENC_KEY: ${SOCIAL_INFRA__SUPAVISOR__VAULT_ENC_KEY}
|
||||
API_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
|
||||
METRICS_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
|
||||
REGION: local
|
||||
ERL_AFLAGS: -proto_dist inet_tcp
|
||||
POOLER_TENANT_ID: ${POOLER_TENANT_ID}
|
||||
POOLER_DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE}
|
||||
POOLER_MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN}
|
||||
POOLER_TENANT_ID: ${SOCIAL_INFRA__POOLER__TENANT_ID}
|
||||
POOLER_DEFAULT_POOL_SIZE: ${SOCIAL_INFRA__POOLER__DEFAULT_POOL_SIZE}
|
||||
POOLER_MAX_CLIENT_CONN: ${SOCIAL_INFRA__POOLER__MAX_CLIENT_CONN}
|
||||
POOLER_POOL_MODE: transaction
|
||||
DB_POOL_SIZE: ${POOLER_DB_POOL_SIZE}
|
||||
DB_POOL_SIZE: ${SOCIAL_INFRA__POOLER__DB_POOL_SIZE}
|
||||
command:
|
||||
[
|
||||
"/bin/sh",
|
||||
@@ -1,38 +0,0 @@
|
||||
# 技术栈选择
|
||||
|
||||
## 背景
|
||||
|
||||
本项目需要构建一个跨平台社交应用,支持本地开发和云端部署。
|
||||
|
||||
## 决策
|
||||
|
||||
1. **前端框架:Flutter**
|
||||
- 跨平台支持(iOS / Android / Web)
|
||||
- 高性能原生渲染
|
||||
- 丰富的 UI 组件
|
||||
|
||||
2. **后端框架:FastAPI**
|
||||
- 高性能异步框架
|
||||
- 自动生成 OpenAPI 文档
|
||||
- 类型安全
|
||||
|
||||
3. **数据库:Supabase(PostgreSQL)**
|
||||
- 开箱即用的 PostgreSQL
|
||||
- 内置认证和权限管理
|
||||
- 实时订阅功能
|
||||
|
||||
4. **缓存:Redis**
|
||||
- 高性能键值存储
|
||||
- 支持多种数据结构
|
||||
|
||||
5. **向量数据库:Milvus**
|
||||
- 高性能向量检索
|
||||
- 支持大规模向量存储
|
||||
- 适合 RAG 和推荐场景
|
||||
|
||||
## 后续考虑
|
||||
|
||||
根据业务发展,可能需要评估:
|
||||
- CDN 方案
|
||||
- 消息队列
|
||||
- 监控和日志系统
|
||||
@@ -1,17 +0,0 @@
|
||||
# 系统架构概述
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **前端**:Flutter
|
||||
- **后端**:FastAPI
|
||||
- **数据库**:Supabase(PostgreSQL)
|
||||
- **缓存**:Redis
|
||||
- **向量数据库**:Milvus
|
||||
- **部署**:Docker + 火山云(未来)
|
||||
|
||||
## 架构特点
|
||||
|
||||
- Monorepo 结构
|
||||
- 微服务架构(API + Worker)
|
||||
- 云原生设计
|
||||
- 支持本地 Docker 开发和云端部署
|
||||
@@ -0,0 +1,147 @@
|
||||
# Plan: FastAPI + Celery 日志管理器系统
|
||||
|
||||
**Date:** 2026-01-29
|
||||
**Author:** AI Assistant
|
||||
**Status:** Draft
|
||||
|
||||
## Overview
|
||||
|
||||
构建一个统一、可扩展的日志管理器系统,覆盖 FastAPI 与 Celery worker 的运行时日志,提供结构化 JSON 输出、错误分离、日志轮转与上下文追踪。目标是满足生产环境可观测性需求,便于检索、关联与故障排查,并与当前项目配置体系保持一致。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
- [ ] 统一管理 FastAPI 与 Celery worker 日志
|
||||
- [ ] 日志持久化到 `logs/`,错误日志单独输出到 `logs/errors/`
|
||||
- [ ] 支持按大小或按时间进行日志轮转
|
||||
- [ ] 结构化日志(JSON),包含时间戳、级别、模块/函数、消息与上下文
|
||||
- [ ] ERROR/CRITICAL 记录完整堆栈与错误上下文
|
||||
- [ ] 支持环境差异化配置(dev/test/prod)
|
||||
|
||||
### Non-Functional
|
||||
- [ ] 性能:日志写入对请求延迟影响可控,支持异步队列化扩展
|
||||
- [ ] 安全:避免记录敏感信息,支持字段脱敏
|
||||
- [ ] 可维护性:模块化、可测试、与现有配置体系一致
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### 调研摘要
|
||||
- Python 官方建议使用 `logging` + `dictConfig` 管理多 handler、多 formatter 与过滤器,适用于生产环境配置化管理。
|
||||
- FastAPI 通常通过中间件注入 request_id 和上下文,并使用结构化日志输出以便集中检索。
|
||||
- Celery 官方文档建议在自定义场景下关闭 `worker_hijack_root_logger`,通过信号配置自定义 handler。
|
||||
- 结构化日志库中,structlog 更贴近标准 logging,可与 `logging` 生态协同;loguru 简化配置但替换性强、与 Celery 深度集成时可控性较弱。
|
||||
- 生产环境推荐 JSON 结构化日志 + 轮转 + 错误分离,并通过外部系统聚合与告警(如 Sentry)。
|
||||
|
||||
### 方案对比(至少两种)
|
||||
| 方案 | 描述 | 优点 | 缺点 | 结论 |
|
||||
|------|------|------|------|------|
|
||||
| 方案 A:stdlib logging + 自定义 JSON Formatter | 纯标准库实现 JSON formatter + handler/filters | 依赖最少,符合标准库,易与 Celery/FastAPI 集成 | 结构化上下文绑定与 request_id 传递需手写 | 可作为备选最小方案 |
|
||||
| 方案 B:stdlib logging + structlog | 用 structlog 生成结构化事件,输出到 logging handler | 结构化上下文与 contextvars 支持好,兼容 logging handler | 引入第三方依赖与配置复杂度 | 推荐主方案 |
|
||||
| 方案 C:loguru | 直接使用 loguru logger | 配置简单、体验好 | 与 Celery/标准 logging 生态整合成本高 | 不推荐作为主方案 |
|
||||
|
||||
### 选型结论
|
||||
- 采用方案 B:`logging` 作为底座,structlog 负责结构化事件与上下文绑定;保留可切换到方案 A 的最小实现路径。
|
||||
- 通过 `dictConfig` 做环境配置,使用 Rotating/TimedRotating handler 支持按大小或时间轮转。
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Phase 1: 基础日志骨架与配置 (3 hours)
|
||||
1. 新增日志配置模型(Settings 扩展),支持环境、轮转方式与路径配置。
|
||||
2. 创建日志模块骨架:formatter、handler、filter、context。
|
||||
3. 集成 `dictConfig` 初始化入口,支持 dev/test/prod 配置切换。
|
||||
|
||||
### Phase 2: FastAPI 集成与上下文 (4 hours)
|
||||
1. 实现请求中间件:生成 `request_id`,绑定用户与请求上下文(IP、路径、方法)。
|
||||
2. 定义异常处理器:捕获未处理异常并记录堆栈与上下文。
|
||||
3. 添加应用启动时日志初始化流程。
|
||||
|
||||
### Phase 3: Celery 集成 (3 hours)
|
||||
1. 在 Celery 应用配置中设置 `worker_hijack_root_logger = False`。
|
||||
2. 使用 Celery 信号(`setup_logging`、`after_setup_task_logger`)初始化日志并注入 task 上下文。
|
||||
3. 统一日志格式、error 处理与 request_id 关联(如 task_id)。
|
||||
|
||||
### Phase 4: 错误分离与轮转策略 (3 hours)
|
||||
1. 添加 error handler:仅接受 ERROR/CRITICAL,输出到 `logs/errors/`。
|
||||
2. 实现轮转策略配置(按大小、按时间),并提供统一切换配置项。
|
||||
3. 增加字段脱敏与敏感字段黑名单过滤器。
|
||||
|
||||
### Phase 5: 可选增强功能 (4 hours)
|
||||
1. 日志查询与过滤接口(基础 API + 分页)。
|
||||
2. 日志聚合统计(按级别/模块/时间窗口)。
|
||||
3. Sentry 集成与异常告警。
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| api/src/core/config/settings.py | 扩展日志相关配置模型 |
|
||||
|
||||
## Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| api/src/core/logging/__init__.py | 模块导出与初始化入口 |
|
||||
| api/src/core/logging/config.py | dictConfig 构建与环境配置 |
|
||||
| api/src/core/logging/formatters.py | JSON formatter 与字段规范 |
|
||||
| api/src/core/logging/handlers.py | 文件、控制台、错误 handler |
|
||||
| api/src/core/logging/filters.py | 等级过滤、敏感字段脱敏 |
|
||||
| api/src/core/logging/context.py | contextvars 绑定与获取 |
|
||||
| api/src/core/logging/middleware.py | FastAPI 请求中间件 |
|
||||
| api/src/core/logging/celery.py | Celery 日志信号集成 |
|
||||
| api/src/core/logging/examples.py | 使用示例(可选) |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- [ ] structlog: 结构化日志与 contextvars 支持
|
||||
- [ ] python-json-logger(备选): 若需要纯 logging JSON formatter
|
||||
- [ ] sentry-sdk(可选): 异常告警与追踪
|
||||
|
||||
## 配置示例
|
||||
|
||||
```toml
|
||||
# .env 示例(通过 pydantic settings 读取)
|
||||
SOCIAL_RUNTIME__LOG_LEVEL=INFO
|
||||
SOCIAL_RUNTIME__LOG_JSON=true
|
||||
SOCIAL_RUNTIME__LOG_ROTATION=TIME
|
||||
SOCIAL_RUNTIME__LOG_ROTATION_WHEN=midnight
|
||||
SOCIAL_RUNTIME__LOG_ROTATION_BACKUP_COUNT=14
|
||||
SOCIAL_RUNTIME__LOG_DIR=logs
|
||||
SOCIAL_RUNTIME__LOG_ERROR_DIR=logs/errors
|
||||
```
|
||||
|
||||
## 使用示例代码
|
||||
|
||||
```python
|
||||
from core.logging import configure_logging, get_logger
|
||||
|
||||
configure_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
logger.info("user login", extra={"user_id": "u_123"})
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Unit Tests:** formatter 输出结构、filter 脱敏规则、context 绑定行为
|
||||
- **Integration Tests:** FastAPI 中间件注入的 request_id 与错误分离写入
|
||||
- **E2E Tests:** 关键流程触发错误,验证 error 日志输出与轮转
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| Celery 日志被自动劫持导致重复或丢失 | High | Medium | 设置 `worker_hijack_root_logger=False` 并通过信号统一配置 |
|
||||
| 结构化字段不一致导致下游解析失败 | Medium | Medium | 统一 schema,增加单元测试与校验 |
|
||||
| 误记录敏感信息 | High | Medium | 增加脱敏过滤器与字段黑名单 |
|
||||
| 日志量过大影响性能 | Medium | Medium | 轮转 + 级别控制 + 可选异步队列化 |
|
||||
|
||||
## Estimated Effort
|
||||
|
||||
| Phase | Effort |
|
||||
|-------|--------|
|
||||
| Phase 1 | 3 hours |
|
||||
| Phase 2 | 4 hours |
|
||||
| Phase 3 | 3 hours |
|
||||
| Phase 4 | 3 hours |
|
||||
| Phase 5 | 4 hours |
|
||||
| **Total** | **17 hours** |
|
||||
@@ -1,203 +0,0 @@
|
||||
# 配置规则与使用原则
|
||||
|
||||
## 配置文件组织
|
||||
|
||||
### 1. 环境变量规范(真相源)
|
||||
|
||||
**文件位置**:`configs/env/.env.example`
|
||||
|
||||
**作用**:
|
||||
- 定义应用层环境变量的名称、类型、用途和敏感级别
|
||||
- 作为应用配置的"真相源"(source of truth)
|
||||
- 不可直接用于运行,仅作为参考和规范
|
||||
- Docker/Supabase 栈配置不在本文件,使用 `infra/local/env/.env.example`
|
||||
- 本文件仅包含应用层连接配置(如 `SUPABASE_URL`、`DATABASE_URL`),不包含 Supabase 栈运行变量
|
||||
- 不得在 `infra/local/env/.env.example` 中添加应用层变量(如 `PUBLIC_`、`API_`)
|
||||
|
||||
**变量分类**:
|
||||
- `public`:可公开的信息,如服务地址、端口号
|
||||
- `secret`:敏感信息,如密钥、密码、连接串
|
||||
|
||||
**变量块说明**:
|
||||
- A. 通用环境(APP_ENV、LOG_LEVEL、TZ)
|
||||
- B. Flutter 配置(仅 PUBLIC_ 变量)
|
||||
- C. FastAPI 服务配置
|
||||
- D. Supabase / Postgres 连接配置
|
||||
- E. Redis 配置
|
||||
- F. Milvus 配置
|
||||
- G. 对象存储配置(可选)
|
||||
- H. 其他配置
|
||||
|
||||
### 2. 本地开发配置
|
||||
|
||||
**文件位置**:`configs/env/.env`
|
||||
|
||||
**作用**:
|
||||
- 本地应用层的实际配置文件
|
||||
- 从 `configs/env/.env.example` 复制后填入真实值
|
||||
- **禁止提交到 Git 仓库**
|
||||
|
||||
**创建方式**:
|
||||
```bash
|
||||
cp configs/env/.env.example configs/env/.env
|
||||
# 编辑 configs/env/.env,填入实际值
|
||||
```
|
||||
|
||||
### 3. 本地 Supabase 栈配置
|
||||
|
||||
**文件位置**:`infra/local/env/.env`
|
||||
|
||||
**作用**:
|
||||
- 本地 Supabase + 周边依赖的实际配置文件
|
||||
- 从 `infra/local/env/.env.example` 复制后填入真实值
|
||||
- **禁止提交到 Git 仓库**
|
||||
|
||||
**创建方式**:
|
||||
```bash
|
||||
cp infra/local/env/.env.example infra/local/env/.env
|
||||
```
|
||||
|
||||
### 4. 云端部署配置
|
||||
|
||||
**方式一:环境文件**
|
||||
- 文件位置:`infra/cloud/volcano/env/.env`(不提交)
|
||||
- 从 `infra/cloud/volcano/env/.env.example` 复制后填入云端真实值
|
||||
- 仅用于本地测试云端连接
|
||||
|
||||
**方式二:Secret 注入(推荐)**
|
||||
- 使用火山云或其他云平台的 Secret 管理服务
|
||||
- 通过 CI/CD 或容器运行时注入环境变量
|
||||
- 代码无需修改,通过环境切换
|
||||
|
||||
### 5. 应用特定配置
|
||||
|
||||
**Flutter 配置**:
|
||||
- `configs/flutter/dev.json` —— 开发环境
|
||||
- `configs/flutter/prod.json` —— 生产环境
|
||||
- 通过 `--dart-define` 在构建时注入
|
||||
|
||||
## 安全原则
|
||||
|
||||
### Public vs Secret
|
||||
|
||||
**Public 变量**:
|
||||
- 可公开的服务地址和端口号
|
||||
- 前端可直接使用的配置
|
||||
- 示例:`PUBLIC_API_BASE_URL`、`API_PORT`
|
||||
|
||||
**Secret 变量**:
|
||||
- 密钥、密码、连接串
|
||||
- 仅服务端使用的敏感信息
|
||||
- 示例:`DATABASE_URL`、`REDIS_URL`、`JWT_SECRET`
|
||||
|
||||
### Flutter 配置限制
|
||||
|
||||
**严格规则**:
|
||||
- Flutter 只能使用以 `PUBLIC_` 开头的变量
|
||||
- 严禁将任何 secret 信息注入到 Flutter
|
||||
- 通过 `--dart-define` 在构建时注入,运行时不可修改
|
||||
|
||||
**示例**:
|
||||
```bash
|
||||
# ✅ 正确:Flutter 使用 PUBLIC_ 变量
|
||||
flutter run --dart-define=PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
|
||||
# ❌ 错误:Flutter 不能使用 secret
|
||||
flutter run --dart-define=DATABASE_URL=postgresql://user:pass@localhost/db
|
||||
```
|
||||
|
||||
### 后端配置读取
|
||||
|
||||
**严格规则**:
|
||||
- `apps/api` 和 `apps/worker` 只能通过环境变量读取配置
|
||||
- 不得直接读取 `infra/local/*.env` 文件路径
|
||||
- 使用 Python 的 `os.getenv()` 或环境变量库
|
||||
|
||||
**示例**:
|
||||
```python
|
||||
# ✅ 正确:通过环境变量读取
|
||||
import os
|
||||
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
|
||||
# ❌ 错误:直接读取本地配置文件
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv("configs/env/.env") # 禁止
|
||||
```
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 1. 本地开发
|
||||
|
||||
1. 复制环境变量模板:
|
||||
```bash
|
||||
make env
|
||||
```
|
||||
|
||||
2. 启动依赖服务:
|
||||
```bash
|
||||
make up
|
||||
```
|
||||
|
||||
3. 启动后端服务:
|
||||
```bash
|
||||
make api-dev
|
||||
```
|
||||
|
||||
4. 启动 Flutter 应用:
|
||||
```bash
|
||||
make flutter-dev
|
||||
```
|
||||
|
||||
### 2. 云端部署
|
||||
|
||||
1. 准备云端环境变量:
|
||||
- 将 `.env.example` 中的变量映射到云平台 Secret
|
||||
- 或创建 `infra/cloud/volcano/env/.env`(仅用于本地测试)
|
||||
|
||||
2. 修改配置指向云端:
|
||||
```bash
|
||||
# 示例:修改 .env 指向火山云托管地址
|
||||
REDIS_URL=redis://volcano-redis:6379/0
|
||||
MILVUS_URI=https://volcano-milvus:19530
|
||||
DATABASE_URL=postgresql://user:pass@volcano-postgres/db
|
||||
```
|
||||
|
||||
3. 部署应用:
|
||||
- 通过 CI/CD 自动注入环境变量
|
||||
- 或通过云平台控制台配置
|
||||
|
||||
## 配置优先级
|
||||
|
||||
从高到低:
|
||||
1. 运行时环境变量(最高)
|
||||
2. 环境文件(`.env`)
|
||||
3. 配置文件(`configs/flutter/*.json`)
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 如何切换环境?
|
||||
|
||||
**本地开发**:使用 `configs/env/.env`
|
||||
**云端部署**:使用云平台 Secret 注入
|
||||
|
||||
### Q: 如何确保 secret 不泄露?
|
||||
|
||||
- 所有 secret 变量使用 `CHANGE_ME` 占位符
|
||||
- 本地配置文件(`configs/env/.env`、`infra/local/env/.env`)添加到 `.gitignore`
|
||||
- 云端使用密钥管理服务注入
|
||||
|
||||
### Q: 如何测试云端配置?
|
||||
|
||||
1. 在本地创建 `infra/cloud/volcano/env/.env`(不提交)
|
||||
2. 修改变量指向云端地址
|
||||
3. 导出环境变量测试:
|
||||
```bash
|
||||
export $(cat infra/cloud/volcano/env/.env | xargs)
|
||||
```
|
||||
|
||||
### Q: Compose 如何读取配置?
|
||||
|
||||
- 使用 `--env-file infra/local/env/.env` 注入本地 Supabase 栈变量
|
||||
- Compose 文件为 `infra/local/docker-compose.yml`
|
||||
- 应用代码通过 `os.getenv()` 读取本地应用变量
|
||||
@@ -1,21 +0,0 @@
|
||||
# 目录结构规则
|
||||
|
||||
## 顶层目录(必须遵守)
|
||||
|
||||
仓库根目录只能包含以下目录:
|
||||
|
||||
- `apps/` —— 可运行应用(Flutter / FastAPI / Worker)
|
||||
- `infra/` —— 基础设施(本地 docker / 云部署 / 迁移)
|
||||
- `configs/` —— 配置规范与公共配置模板(不含密钥)
|
||||
- `tools/` —— 脚本与生成器
|
||||
- `docs/` —— 文档与规则
|
||||
- `.github/`(可选,用于 CI/CD)
|
||||
- `README.md`
|
||||
- `Makefile`(可选)
|
||||
|
||||
## 禁止事项
|
||||
|
||||
- 禁止在根目录直接出现:`backend/`、`ui/`、`docker/`、`scripts/` 等非规范目录
|
||||
- 所有业务代码必须放在 `apps/` 目录下
|
||||
- 所有配置文件必须放在 `configs/` 目录下
|
||||
- 所有基础设施相关代码必须放在 `infra/` 目录下
|
||||
@@ -1,25 +0,0 @@
|
||||
# 火山云部署指南
|
||||
|
||||
## 准备工作
|
||||
|
||||
1. 火山云账号
|
||||
2. 配置云端环境变量
|
||||
3. 准备镜像仓库
|
||||
|
||||
## 环境变量模板
|
||||
|
||||
云端模板文件位置:
|
||||
|
||||
```bash
|
||||
cp infra/cloud/volcano/env/.env.example infra/cloud/volcano/env/.env
|
||||
```
|
||||
|
||||
## 部署流程
|
||||
|
||||
待补充详细步骤...
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 确保所有敏感信息使用环境变量或密钥管理
|
||||
- 遵循最小权限原则
|
||||
- 配置适当的监控和日志
|
||||
@@ -1,272 +0,0 @@
|
||||
# 本地开发指南
|
||||
|
||||
## 前置要求
|
||||
|
||||
- Docker 和 Docker Compose
|
||||
- Flutter SDK
|
||||
- Python 3.11+
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 配置环境变量
|
||||
|
||||
```bash
|
||||
make env
|
||||
```
|
||||
|
||||
按照提示创建并编辑应用配置 `configs/env/.env`,并创建 Supabase 本地栈配置 `infra/local/env/.env`。
|
||||
|
||||
创建配置文件:
|
||||
|
||||
```bash
|
||||
cp configs/env/.env.example configs/env/.env
|
||||
cp infra/local/env/.env.example infra/local/env/.env
|
||||
```
|
||||
|
||||
确保以下变量配置正确:
|
||||
- `DATABASE_URL`(连接到 localhost:54322)
|
||||
- `REDIS_URL`(连接到 localhost:6379)
|
||||
- `MILVUS_URI`(连接到 localhost:19530)
|
||||
|
||||
### 2. 启动依赖服务
|
||||
|
||||
```bash
|
||||
make up
|
||||
```
|
||||
|
||||
这将启动以下服务:
|
||||
- **Redis**:端口 6379
|
||||
- **Milvus**:端口 19530 (gRPC) / 19111 (HTTP)
|
||||
- **Postgres**:端口 54322
|
||||
|
||||
### 3. 检查服务状态
|
||||
|
||||
```bash
|
||||
make ps
|
||||
```
|
||||
|
||||
确保所有服务显示为 `Up` 状态。
|
||||
|
||||
### 4. 查看服务日志
|
||||
|
||||
```bash
|
||||
# 查看所有服务日志
|
||||
make logs
|
||||
|
||||
# 查看特定服务日志
|
||||
make logs SERVICE=redis
|
||||
make logs SERVICE=milvus
|
||||
make logs SERVICE=db
|
||||
```
|
||||
|
||||
## 启动应用
|
||||
|
||||
### 启动 FastAPI 后端
|
||||
|
||||
```bash
|
||||
make api-dev
|
||||
```
|
||||
|
||||
或手动启动:
|
||||
|
||||
```bash
|
||||
cd apps/api
|
||||
|
||||
# 创建虚拟环境(首次)
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# 安装依赖(首次)
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 启动服务
|
||||
uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
后端启动后,访问:
|
||||
- API 文档:http://localhost:8000/docs
|
||||
- ReDoc:http://localhost:8000/redoc
|
||||
|
||||
### 启动 Flutter 应用
|
||||
|
||||
```bash
|
||||
make flutter-dev
|
||||
```
|
||||
|
||||
或手动启动:
|
||||
|
||||
```bash
|
||||
cd apps/mobile
|
||||
|
||||
# 安装依赖(首次)
|
||||
flutter pub get
|
||||
|
||||
# 启动开发服务器
|
||||
flutter run --dart-define=PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
|
||||
# 或指定设备
|
||||
flutter run -d chrome --dart-define=PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
构建 Android APK:
|
||||
|
||||
```bash
|
||||
flutter build apk --dart-define=PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
## 初始化 Milvus
|
||||
|
||||
```bash
|
||||
make milvus-init
|
||||
```
|
||||
|
||||
或手动运行初始化脚本:
|
||||
|
||||
```bash
|
||||
bash tools/scripts/init_milvus.sh
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
### 依赖服务管理
|
||||
|
||||
```bash
|
||||
# 启动服务
|
||||
make up
|
||||
|
||||
# 停止服务
|
||||
make down
|
||||
|
||||
# 重启服务
|
||||
make down && make up
|
||||
|
||||
# 查看状态
|
||||
make ps
|
||||
|
||||
# 查看日志
|
||||
make logs
|
||||
|
||||
# 清理数据(警告:会丢失数据)
|
||||
make clean
|
||||
```
|
||||
|
||||
### 应用管理
|
||||
|
||||
```bash
|
||||
# 启动后端
|
||||
make api-dev
|
||||
|
||||
# 启动前端
|
||||
make flutter-dev
|
||||
|
||||
# 配置环境变量
|
||||
make env
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 端口冲突
|
||||
|
||||
如果启动依赖服务时出现端口冲突:
|
||||
|
||||
1. 检查端口占用:
|
||||
```bash
|
||||
# 检查 6379(Redis)
|
||||
lsof -i :6379
|
||||
|
||||
# 检查 54322(Postgres)
|
||||
lsof -i :54322
|
||||
|
||||
# 检查 19530(Milvus)
|
||||
lsof -i :19530
|
||||
```
|
||||
|
||||
2. 停止占用端口的进程,或修改 `infra/local/docker-compose.yml` 中的端口映射
|
||||
|
||||
### 容器未健康
|
||||
|
||||
如果服务状态显示 `Up (health: starting)` 但一直未变成 `Up (healthy)`:
|
||||
|
||||
1. 查看服务日志:
|
||||
```bash
|
||||
make logs SERVICE=<service_name>
|
||||
```
|
||||
|
||||
2. 检查依赖服务是否正常启动:
|
||||
```bash
|
||||
make ps
|
||||
```
|
||||
|
||||
3. 重启服务:
|
||||
```bash
|
||||
docker compose -f infra/local/docker-compose.yml --env-file infra/local/env/.env restart <service_name>
|
||||
```
|
||||
|
||||
### 后端无法连接数据库
|
||||
|
||||
1. 检查 Postgres 是否正常启动:
|
||||
```bash
|
||||
make ps
|
||||
```
|
||||
|
||||
2. 检查环境变量配置:
|
||||
```bash
|
||||
cat configs/env/.env | grep DATABASE_URL
|
||||
```
|
||||
|
||||
3. 确保数据库 URL 格式正确:
|
||||
```
|
||||
postgresql://postgres:postgres@localhost:54322/postgres
|
||||
```
|
||||
|
||||
### Flutter 无法连接后端
|
||||
|
||||
1. 确保后端服务已启动并监听在 8000 端口:
|
||||
```bash
|
||||
curl http://localhost:8000/docs
|
||||
```
|
||||
|
||||
2. 检查 Flutter 的 API_BASE_URL 是否正确注入:
|
||||
```bash
|
||||
flutter run --dart-define=PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
3. 如果使用模拟器,确保能访问 localhost:
|
||||
- Android 模拟器:使用 `10.0.2.2` 代替 `localhost`
|
||||
- iOS 模拟器:使用 `localhost` 即可
|
||||
|
||||
### Milvus 连接失败
|
||||
|
||||
1. 检查 Milvus 服务是否健康:
|
||||
```bash
|
||||
make ps
|
||||
```
|
||||
|
||||
2. 等待 Milvus 完全启动(可能需要 1-2 分钟):
|
||||
```bash
|
||||
make logs SERVICE=milvus
|
||||
```
|
||||
|
||||
3. 测试连接:
|
||||
```bash
|
||||
curl http://localhost:19530/healthz
|
||||
```
|
||||
|
||||
## 清理环境
|
||||
|
||||
```bash
|
||||
# 停止所有服务
|
||||
make down
|
||||
|
||||
# 清理数据卷(警告:会丢失所有数据)
|
||||
make clean
|
||||
|
||||
# 完全清理(包括未使用的镜像)
|
||||
docker system prune -a
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
- 阅读架构文档:`docs/architecture/overview.md`
|
||||
- 了解配置规则:`docs/rules/config-rules.md`
|
||||
- 查看技术栈决策:`docs/adr/0001-tech-stack.md`
|
||||
@@ -1,89 +0,0 @@
|
||||
# Infra
|
||||
|
||||
Local Docker environment for Supabase stack and supporting services.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Copy environment file:
|
||||
```bash
|
||||
cp infra/env/.env.example infra/env/.env.local
|
||||
```
|
||||
|
||||
2. Update `infra/env/.env.local` with your secrets and configurations.
|
||||
**Important**: Ensure all required fields are filled (no `CHANGE_ME` values).
|
||||
|
||||
3. Start services:
|
||||
```bash
|
||||
docker compose --env-file infra/env/.env.local -f infra/local/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
## Access
|
||||
|
||||
- **Supabase Studio**: http://localhost:8001
|
||||
Credentials from `DASHBOARD_USERNAME` and `DASHBOARD_PASSWORD` in `.env.local`
|
||||
- **Qdrant**: http://localhost:6333
|
||||
- **Redis**: localhost:6379
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port Conflicts
|
||||
|
||||
If `localhost:8001` is unreachable, check for port conflicts:
|
||||
|
||||
**Linux/WSL:**
|
||||
```bash
|
||||
ss -ltnp 'sport = :8001'
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```powershell
|
||||
Get-NetTCPConnection -LocalPort 8001 | Select-Object LocalAddress, LocalPort, State, OwningProcess
|
||||
Get-Process -Id (Get-NetTCPConnection -LocalPort 8001).OwningProcess
|
||||
```
|
||||
|
||||
If a Windows service occupies the port, either:
|
||||
- Stop the conflicting service
|
||||
- Change `KONG_HTTP_PORT`, `API_EXTERNAL_URL`, and `SUPABASE_PUBLIC_URL` in `.env.local`
|
||||
|
||||
### Environment Variables Not Applied
|
||||
|
||||
If Docker reports warnings about missing variables, verify:
|
||||
- The `--env-file` path is correct
|
||||
- All required variables are set in `.env.local` (no empty values)
|
||||
|
||||
### Service Health
|
||||
|
||||
Check service status:
|
||||
```bash
|
||||
docker compose --env-file infra/env/.env.local -f infra/local/docker-compose.yml ps
|
||||
```
|
||||
|
||||
View logs:
|
||||
```bash
|
||||
docker compose --env-file infra/env/.env.local -f infra/local/docker-compose.yml logs <service-name>
|
||||
```
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Description |
|
||||
|------------|---------------------------------------|
|
||||
| kong | API gateway (8001/8443) |
|
||||
| studio | Supabase UI (via Kong) |
|
||||
| auth | Authentication (GoTrue) |
|
||||
| db | PostgreSQL database |
|
||||
| rest | PostgREST API |
|
||||
| realtime | Realtime subscriptions |
|
||||
| storage | Storage API |
|
||||
| functions | Edge functions |
|
||||
| analytics | Logflare logging |
|
||||
| vector | Log aggregator |
|
||||
| supavisor | Database connection pooler |
|
||||
| qdrant | Vector database |
|
||||
| redis | Cache/message broker |
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Never commit `.env.local` to version control
|
||||
- Always use `--env-file` when running docker compose
|
||||
- Port conflicts on Windows (especially 8000) can prevent Kong from starting
|
||||
- Kong configuration is auto-generated from templates on container start
|
||||
Vendored
-90
@@ -1,90 +0,0 @@
|
||||
# Local Docker (Supabase stack) environment example
|
||||
# Copy to infra/env/.env.local and fill values
|
||||
# Do not commit real secrets
|
||||
|
||||
############
|
||||
# Secrets
|
||||
############
|
||||
POSTGRES_PASSWORD=CHANGE_ME
|
||||
JWT_SECRET=CHANGE_ME
|
||||
ANON_KEY=CHANGE_ME
|
||||
SERVICE_ROLE_KEY=CHANGE_ME
|
||||
DASHBOARD_USERNAME=supabase
|
||||
DASHBOARD_PASSWORD=CHANGE_ME
|
||||
SECRET_KEY_BASE=CHANGE_ME
|
||||
VAULT_ENC_KEY=CHANGE_ME
|
||||
PG_META_CRYPTO_KEY=CHANGE_ME
|
||||
|
||||
############
|
||||
# Database
|
||||
############
|
||||
POSTGRES_HOST=db
|
||||
POSTGRES_DB=postgres
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
############
|
||||
# Supavisor -- Database pooler
|
||||
############
|
||||
POOLER_PROXY_PORT_TRANSACTION=6543
|
||||
POOLER_DEFAULT_POOL_SIZE=20
|
||||
POOLER_MAX_CLIENT_CONN=100
|
||||
POOLER_TENANT_ID=local-tenant
|
||||
POOLER_DB_POOL_SIZE=5
|
||||
|
||||
############
|
||||
# API Proxy - Kong
|
||||
############
|
||||
KONG_HTTP_PORT=8001
|
||||
KONG_HTTPS_PORT=8443
|
||||
|
||||
############
|
||||
# API - PostgREST
|
||||
############
|
||||
PGRST_DB_SCHEMAS=public,storage,graphql_public
|
||||
|
||||
############
|
||||
# Auth - GoTrue
|
||||
############
|
||||
SITE_URL=http://localhost:3000
|
||||
ADDITIONAL_REDIRECT_URLS=
|
||||
JWT_EXPIRY=3600
|
||||
DISABLE_SIGNUP=false
|
||||
API_EXTERNAL_URL=http://localhost:8001
|
||||
MAILER_URLPATHS_CONFIRMATION="/auth/v1/verify"
|
||||
MAILER_URLPATHS_INVITE="/auth/v1/verify"
|
||||
MAILER_URLPATHS_RECOVERY="/auth/v1/verify"
|
||||
MAILER_URLPATHS_EMAIL_CHANGE="/auth/v1/verify"
|
||||
ENABLE_EMAIL_SIGNUP=true
|
||||
ENABLE_EMAIL_AUTOCONFIRM=false
|
||||
SMTP_ADMIN_EMAIL=admin@example.com
|
||||
SMTP_HOST=supabase-mail
|
||||
SMTP_PORT=2500
|
||||
SMTP_USER=fake_mail_user
|
||||
SMTP_PASS=fake_mail_password
|
||||
SMTP_SENDER_NAME=fake_sender
|
||||
ENABLE_ANONYMOUS_USERS=false
|
||||
ENABLE_PHONE_SIGNUP=true
|
||||
ENABLE_PHONE_AUTOCONFIRM=true
|
||||
|
||||
############
|
||||
# Studio
|
||||
############
|
||||
STUDIO_DEFAULT_ORGANIZATION=Default Organization
|
||||
STUDIO_DEFAULT_PROJECT=Default Project
|
||||
SUPABASE_PUBLIC_URL=http://localhost:8000
|
||||
IMGPROXY_ENABLE_WEBP_DETECTION=true
|
||||
OPENAI_API_KEY=
|
||||
|
||||
############
|
||||
# Functions
|
||||
############
|
||||
FUNCTIONS_VERIFY_JWT=false
|
||||
|
||||
############
|
||||
# Logs / Analytics
|
||||
############
|
||||
LOGFLARE_PUBLIC_ACCESS_TOKEN=CHANGE_ME
|
||||
LOGFLARE_PRIVATE_ACCESS_TOKEN=CHANGE_ME
|
||||
DOCKER_SOCKET_LOCATION=/var/run/docker.sock
|
||||
GOOGLE_PROJECT_ID=GOOGLE_PROJECT_ID
|
||||
GOOGLE_PROJECT_NUMBER=GOOGLE_PROJECT_NUMBER
|
||||
@@ -0,0 +1,38 @@
|
||||
[project]
|
||||
name = "social-app"
|
||||
version = "0.1.0"
|
||||
description = "Social application backend"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"basedpyright>=1.37.2",
|
||||
"celery>=5.6.2",
|
||||
"fastapi>=0.128.0",
|
||||
"pydantic>=2.11.0",
|
||||
"pydantic-settings>=2.10.0",
|
||||
"structlog>=24.4.0",
|
||||
"supabase>=2.27.2",
|
||||
"uvicorn[standard]>=0.40.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"httpx>=0.28.0",
|
||||
"playwright>=1.49.0",
|
||||
"pytest>=8.3.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"pytest-cov>=5.0.0",
|
||||
]
|
||||
|
||||
[[tool.uv.index]]
|
||||
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||
default = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["api/tests"]
|
||||
addopts = "-q"
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pre-commit>=4.5.1",
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"include": ["api"],
|
||||
"exclude": ["**/__pycache__", "**/node_modules", "**/.git"],
|
||||
"typeCheckingMode": "standard",
|
||||
"pythonVersion": "3.12",
|
||||
"pythonPlatform": "Linux",
|
||||
"stubPath": "",
|
||||
"extraPaths": [
|
||||
"api/src"
|
||||
],
|
||||
"reportAssignmentType": "none",
|
||||
"reportMissingImports": "error",
|
||||
"reportMissingTypeStubs": "none",
|
||||
"reportUnknownMemberType": "information",
|
||||
"reportUnknownParameterType": "information",
|
||||
"reportUnknownVariableType": "information",
|
||||
"reportUntypedFunctionDecorator": "warning",
|
||||
"reportUnannotatedClassAttribute": "warning",
|
||||
"reportDeprecated": "warning",
|
||||
"reportPrivateImportUsage": "none",
|
||||
"reportImportCycles": "none"
|
||||
}
|
||||
Reference in New Issue
Block a user