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
|
# Local environment files
|
||||||
infra/local/env/*.env
|
infra/local/env/*.env
|
||||||
configs/env/*.env
|
configs/env/*.env
|
||||||
@@ -5,8 +268,14 @@ infra/cloud/volcano/env/*.env
|
|||||||
!infra/local/env/*.env.example
|
!infra/local/env/*.env.example
|
||||||
!configs/env/*.env.example
|
!configs/env/*.env.example
|
||||||
!infra/cloud/volcano/env/*.env.example
|
!infra/cloud/volcano/env/*.env.example
|
||||||
.env
|
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
.env.cloud
|
.env.cloud
|
||||||
.env.*.cloud
|
.env.*.cloud
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.class
|
||||||
|
*.lock
|
||||||
|
*.swp
|
||||||
|
.buildlog/
|
||||||
|
.history
|
||||||
|
|||||||
@@ -6,6 +6,18 @@
|
|||||||
},
|
},
|
||||||
"zai-mcp-server": {
|
"zai-mcp-server": {
|
||||||
"enabled": false
|
"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
|
## Docker Startup
|
||||||
|
|
||||||
Must use environment file when starting services:
|
Always start services with the env file:
|
||||||
```bash
|
```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
|
# Social App Monorepo
|
||||||
|
|
||||||
Flutter + FastAPI + Supabase + Redis + Milvus
|
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:
|
environment:
|
||||||
HOSTNAME: "::"
|
HOSTNAME: "::"
|
||||||
STUDIO_PG_META_URL: http://meta:8080
|
STUDIO_PG_META_URL: http://meta:8080
|
||||||
POSTGRES_PORT: ${POSTGRES_PORT}
|
POSTGRES_PORT: ${SOCIAL_INFRA__POSTGRES__PORT}
|
||||||
POSTGRES_HOST: ${POSTGRES_HOST}
|
POSTGRES_HOST: ${SOCIAL_INFRA__POSTGRES__HOST}
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POSTGRES_DB: ${SOCIAL_INFRA__POSTGRES__DB}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD}
|
||||||
PG_META_CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
|
PG_META_CRYPTO_KEY: ${SOCIAL_INFRA__PG_META__CRYPTO_KEY}
|
||||||
DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION}
|
DEFAULT_ORGANIZATION_NAME: ${SOCIAL_INFRA__STUDIO__DEFAULT_ORGANIZATION}
|
||||||
DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT}
|
DEFAULT_PROJECT_NAME: ${SOCIAL_INFRA__STUDIO__DEFAULT_PROJECT}
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
OPENAI_API_KEY: ${SOCIAL_INFRA__OPENAI__API_KEY:-}
|
||||||
SUPABASE_URL: http://kong:8000
|
SUPABASE_URL: http://kong:8000
|
||||||
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
|
SUPABASE_PUBLIC_URL: ${SOCIAL_INFRA__SUPABASE__PUBLIC_URL}
|
||||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
SUPABASE_ANON_KEY: ${SOCIAL_INFRA__SUPABASE__ANON_KEY}
|
||||||
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
SUPABASE_SERVICE_KEY: ${SOCIAL_INFRA__SUPABASE__SERVICE_ROLE_KEY}
|
||||||
AUTH_JWT_SECRET: ${JWT_SECRET}
|
AUTH_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
|
||||||
LOGFLARE_API_KEY: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
|
LOGFLARE_API_KEY: ${SOCIAL_INFRA__LOGFLARE__PUBLIC_ACCESS_TOKEN}
|
||||||
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
|
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${SOCIAL_INFRA__LOGFLARE__PUBLIC_ACCESS_TOKEN}
|
||||||
LOGFLARE_PRIVATE_ACCESS_TOKEN: ${LOGFLARE_PRIVATE_ACCESS_TOKEN}
|
LOGFLARE_PRIVATE_ACCESS_TOKEN: ${SOCIAL_INFRA__LOGFLARE__PRIVATE_ACCESS_TOKEN}
|
||||||
LOGFLARE_URL: http://analytics:4000
|
LOGFLARE_URL: http://analytics:4000
|
||||||
NEXT_PUBLIC_ENABLE_LOGS: true
|
NEXT_PUBLIC_ENABLE_LOGS: true
|
||||||
NEXT_ANALYTICS_BACKEND_PROVIDER: postgres
|
NEXT_ANALYTICS_BACKEND_PROVIDER: postgres
|
||||||
@@ -75,8 +75,8 @@ services:
|
|||||||
image: kong:2.8.1
|
image: kong:2.8.1
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- ${KONG_HTTP_PORT}:8000/tcp
|
- ${SOCIAL_INFRA__KONG__HTTP_PORT}:8000/tcp
|
||||||
- ${KONG_HTTPS_PORT}:8443/tcp
|
- ${SOCIAL_INFRA__KONG__HTTPS_PORT}:8443/tcp
|
||||||
volumes:
|
volumes:
|
||||||
- ./supabase/volumes/api/kong.yml:/home/kong/temp.yml:ro,z
|
- ./supabase/volumes/api/kong.yml:/home/kong/temp.yml:ro,z
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -89,10 +89,10 @@ services:
|
|||||||
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction
|
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_BUFFER_SIZE: 160k
|
||||||
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
|
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
|
||||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
SUPABASE_ANON_KEY: ${SOCIAL_INFRA__SUPABASE__ANON_KEY}
|
||||||
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
SUPABASE_SERVICE_KEY: ${SOCIAL_INFRA__SUPABASE__SERVICE_ROLE_KEY}
|
||||||
DASHBOARD_USERNAME: ${DASHBOARD_USERNAME}
|
DASHBOARD_USERNAME: ${SOCIAL_INFRA__DASHBOARD__USERNAME}
|
||||||
DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD}
|
DASHBOARD_PASSWORD: ${SOCIAL_INFRA__DASHBOARD__PASSWORD}
|
||||||
entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start'
|
entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start'
|
||||||
|
|
||||||
auth:
|
auth:
|
||||||
@@ -120,32 +120,32 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
GOTRUE_API_HOST: 0.0.0.0
|
GOTRUE_API_HOST: 0.0.0.0
|
||||||
GOTRUE_API_PORT: 9999
|
GOTRUE_API_PORT: 9999
|
||||||
API_EXTERNAL_URL: ${API_EXTERNAL_URL}
|
API_EXTERNAL_URL: ${SOCIAL_INFRA__API_EXTERNAL_URL}
|
||||||
GOTRUE_DB_DRIVER: postgres
|
GOTRUE_DB_DRIVER: postgres
|
||||||
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
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: ${SITE_URL}
|
GOTRUE_SITE_URL: ${SOCIAL_INFRA__SITE__URL}
|
||||||
GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS}
|
GOTRUE_URI_ALLOW_LIST: ${SOCIAL_INFRA__ADDITIONAL_REDIRECT_URLS}
|
||||||
GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP}
|
GOTRUE_DISABLE_SIGNUP: ${SOCIAL_INFRA__AUTH__DISABLE_SIGNUP}
|
||||||
GOTRUE_JWT_ADMIN_ROLES: service_role
|
GOTRUE_JWT_ADMIN_ROLES: service_role
|
||||||
GOTRUE_JWT_AUD: authenticated
|
GOTRUE_JWT_AUD: authenticated
|
||||||
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
|
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
|
||||||
GOTRUE_JWT_EXP: ${JWT_EXPIRY}
|
GOTRUE_JWT_EXP: ${SOCIAL_INFRA__JWT__EXPIRY}
|
||||||
GOTRUE_JWT_SECRET: ${JWT_SECRET}
|
GOTRUE_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
|
||||||
GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP}
|
GOTRUE_EXTERNAL_EMAIL_ENABLED: ${SOCIAL_INFRA__EMAIL__ENABLE_SIGNUP}
|
||||||
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS}
|
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${SOCIAL_INFRA__AUTH__ENABLE_ANONYMOUS_USERS}
|
||||||
GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM}
|
GOTRUE_MAILER_AUTOCONFIRM: ${SOCIAL_INFRA__EMAIL__ENABLE_AUTOCONFIRM}
|
||||||
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL}
|
GOTRUE_SMTP_ADMIN_EMAIL: ${SOCIAL_INFRA__SMTP__ADMIN_EMAIL}
|
||||||
GOTRUE_SMTP_HOST: ${SMTP_HOST}
|
GOTRUE_SMTP_HOST: ${SOCIAL_INFRA__SMTP__HOST}
|
||||||
GOTRUE_SMTP_PORT: ${SMTP_PORT}
|
GOTRUE_SMTP_PORT: ${SOCIAL_INFRA__SMTP__PORT}
|
||||||
GOTRUE_SMTP_USER: ${SMTP_USER}
|
GOTRUE_SMTP_USER: ${SOCIAL_INFRA__SMTP__USER}
|
||||||
GOTRUE_SMTP_PASS: ${SMTP_PASS}
|
GOTRUE_SMTP_PASS: ${SOCIAL_INFRA__SMTP__PASS}
|
||||||
GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME}
|
GOTRUE_SMTP_SENDER_NAME: ${SOCIAL_INFRA__SMTP__SENDER_NAME}
|
||||||
GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE}
|
GOTRUE_MAILER_URLPATHS_INVITE: ${SOCIAL_INFRA__MAILER__URLPATHS_INVITE}
|
||||||
GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION}
|
GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${SOCIAL_INFRA__MAILER__URLPATHS_CONFIRMATION}
|
||||||
GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY}
|
GOTRUE_MAILER_URLPATHS_RECOVERY: ${SOCIAL_INFRA__MAILER__URLPATHS_RECOVERY}
|
||||||
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE}
|
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${SOCIAL_INFRA__MAILER__URLPATHS_EMAIL_CHANGE}
|
||||||
GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP}
|
GOTRUE_EXTERNAL_PHONE_ENABLED: ${SOCIAL_INFRA__AUTH__ENABLE_PHONE_SIGNUP}
|
||||||
GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM}
|
GOTRUE_SMS_AUTOCONFIRM: ${SOCIAL_INFRA__AUTH__ENABLE_PHONE_AUTOCONFIRM}
|
||||||
|
|
||||||
rest:
|
rest:
|
||||||
container_name: supabase-rest
|
container_name: supabase-rest
|
||||||
@@ -157,13 +157,13 @@ services:
|
|||||||
analytics:
|
analytics:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
PGRST_DB_URI: postgres://authenticator:${SOCIAL_INFRA__POSTGRES__PASSWORD}@${SOCIAL_INFRA__POSTGRES__HOST}:${SOCIAL_INFRA__POSTGRES__PORT}/${SOCIAL_INFRA__POSTGRES__DB}
|
||||||
PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
|
PGRST_DB_SCHEMAS: ${SOCIAL_INFRA__PGRST__DB_SCHEMAS}
|
||||||
PGRST_DB_ANON_ROLE: anon
|
PGRST_DB_ANON_ROLE: anon
|
||||||
PGRST_JWT_SECRET: ${JWT_SECRET}
|
PGRST_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
|
||||||
PGRST_DB_USE_LEGACY_GUCS: "false"
|
PGRST_DB_USE_LEGACY_GUCS: "false"
|
||||||
PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
|
PGRST_APP_SETTINGS_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
|
||||||
PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY}
|
PGRST_APP_SETTINGS_JWT_EXP: ${SOCIAL_INFRA__JWT__EXPIRY}
|
||||||
command: ["postgrest"]
|
command: ["postgrest"]
|
||||||
|
|
||||||
realtime:
|
realtime:
|
||||||
@@ -179,7 +179,7 @@ services:
|
|||||||
test:
|
test:
|
||||||
[
|
[
|
||||||
"CMD-SHELL",
|
"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
|
timeout: 5s
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -187,16 +187,16 @@ services:
|
|||||||
start_period: 10s
|
start_period: 10s
|
||||||
environment:
|
environment:
|
||||||
PORT: 4000
|
PORT: 4000
|
||||||
DB_HOST: ${POSTGRES_HOST}
|
DB_HOST: ${SOCIAL_INFRA__POSTGRES__HOST}
|
||||||
DB_PORT: ${POSTGRES_PORT}
|
DB_PORT: ${SOCIAL_INFRA__POSTGRES__PORT}
|
||||||
DB_USER: supabase_admin
|
DB_USER: supabase_admin
|
||||||
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
DB_PASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD}
|
||||||
DB_NAME: ${POSTGRES_DB}
|
DB_NAME: ${SOCIAL_INFRA__POSTGRES__DB}
|
||||||
DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime"
|
DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime"
|
||||||
DB_ENC_KEY: supabaserealtime
|
DB_ENC_KEY: supabaserealtime
|
||||||
API_JWT_SECRET: ${JWT_SECRET}
|
API_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
|
||||||
ANON_KEY: ${ANON_KEY}
|
ANON_KEY: ${SOCIAL_INFRA__SUPABASE__ANON_KEY}
|
||||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
|
SECRET_KEY_BASE: ${SOCIAL_INFRA__SUPAVISOR__SECRET_KEY_BASE}
|
||||||
ERL_AFLAGS: -proto_dist inet_tcp
|
ERL_AFLAGS: -proto_dist inet_tcp
|
||||||
DNS_NODES: "''"
|
DNS_NODES: "''"
|
||||||
RLIMIT_NOFILE: "10000"
|
RLIMIT_NOFILE: "10000"
|
||||||
@@ -232,11 +232,11 @@ services:
|
|||||||
imgproxy:
|
imgproxy:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
environment:
|
environment:
|
||||||
ANON_KEY: ${ANON_KEY}
|
ANON_KEY: ${SOCIAL_INFRA__SUPABASE__ANON_KEY}
|
||||||
SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
SERVICE_KEY: ${SOCIAL_INFRA__SUPABASE__SERVICE_ROLE_KEY}
|
||||||
POSTGREST_URL: http://rest:3000
|
POSTGREST_URL: http://rest:3000
|
||||||
PGRST_JWT_SECRET: ${JWT_SECRET}
|
PGRST_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
|
||||||
DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
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"
|
REQUEST_ALLOW_X_FORWARDED_PATH: "true"
|
||||||
FILE_SIZE_LIMIT: 52428800
|
FILE_SIZE_LIMIT: 52428800
|
||||||
STORAGE_BACKEND: file
|
STORAGE_BACKEND: file
|
||||||
@@ -262,7 +262,7 @@ services:
|
|||||||
IMGPROXY_BIND: ":5001"
|
IMGPROXY_BIND: ":5001"
|
||||||
IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
|
IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
|
||||||
IMGPROXY_USE_ETAG: "true"
|
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
|
IMGPROXY_MAX_SRC_RESOLUTION: 16.8
|
||||||
|
|
||||||
meta:
|
meta:
|
||||||
@@ -276,12 +276,12 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
PG_META_PORT: 8080
|
PG_META_PORT: 8080
|
||||||
PG_META_DB_HOST: ${POSTGRES_HOST}
|
PG_META_DB_HOST: ${SOCIAL_INFRA__POSTGRES__HOST}
|
||||||
PG_META_DB_PORT: ${POSTGRES_PORT}
|
PG_META_DB_PORT: ${SOCIAL_INFRA__POSTGRES__PORT}
|
||||||
PG_META_DB_NAME: ${POSTGRES_DB}
|
PG_META_DB_NAME: ${SOCIAL_INFRA__POSTGRES__DB}
|
||||||
PG_META_DB_USER: supabase_admin
|
PG_META_DB_USER: supabase_admin
|
||||||
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
|
PG_META_DB_PASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD}
|
||||||
CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
|
CRYPTO_KEY: ${SOCIAL_INFRA__PG_META__CRYPTO_KEY}
|
||||||
|
|
||||||
functions:
|
functions:
|
||||||
container_name: supabase-edge-functions
|
container_name: supabase-edge-functions
|
||||||
@@ -293,12 +293,12 @@ services:
|
|||||||
analytics:
|
analytics:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
|
||||||
SUPABASE_URL: http://kong:8000
|
SUPABASE_URL: http://kong:8000
|
||||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
SUPABASE_ANON_KEY: ${SOCIAL_INFRA__SUPABASE__ANON_KEY}
|
||||||
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
|
SUPABASE_SERVICE_ROLE_KEY: ${SOCIAL_INFRA__SUPABASE__SERVICE_ROLE_KEY}
|
||||||
SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
SUPABASE_DB_URL: postgresql://postgres:${SOCIAL_INFRA__POSTGRES__PASSWORD}@${SOCIAL_INFRA__POSTGRES__HOST}:${SOCIAL_INFRA__POSTGRES__PORT}/${SOCIAL_INFRA__POSTGRES__DB}
|
||||||
VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}"
|
VERIFY_JWT: "${SOCIAL_INFRA__FUNCTIONS__VERIFY_JWT}"
|
||||||
command: ["start", "--main-service", "/home/deno/functions/main"]
|
command: ["start", "--main-service", "/home/deno/functions/main"]
|
||||||
|
|
||||||
analytics:
|
analytics:
|
||||||
@@ -319,15 +319,15 @@ services:
|
|||||||
LOGFLARE_NODE_HOST: 127.0.0.1
|
LOGFLARE_NODE_HOST: 127.0.0.1
|
||||||
DB_USERNAME: supabase_admin
|
DB_USERNAME: supabase_admin
|
||||||
DB_DATABASE: _supabase
|
DB_DATABASE: _supabase
|
||||||
DB_HOSTNAME: ${POSTGRES_HOST}
|
DB_HOSTNAME: ${SOCIAL_INFRA__POSTGRES__HOST}
|
||||||
DB_PORT: ${POSTGRES_PORT}
|
DB_PORT: ${SOCIAL_INFRA__POSTGRES__PORT}
|
||||||
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
DB_PASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD}
|
||||||
DB_SCHEMA: _analytics
|
DB_SCHEMA: _analytics
|
||||||
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
|
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${SOCIAL_INFRA__LOGFLARE__PUBLIC_ACCESS_TOKEN}
|
||||||
LOGFLARE_PRIVATE_ACCESS_TOKEN: ${LOGFLARE_PRIVATE_ACCESS_TOKEN}
|
LOGFLARE_PRIVATE_ACCESS_TOKEN: ${SOCIAL_INFRA__LOGFLARE__PRIVATE_ACCESS_TOKEN}
|
||||||
LOGFLARE_SINGLE_TENANT: true
|
LOGFLARE_SINGLE_TENANT: true
|
||||||
LOGFLARE_SUPABASE_MODE: 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
|
POSTGRES_BACKEND_SCHEMA: _analytics
|
||||||
LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true
|
LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true
|
||||||
|
|
||||||
@@ -355,14 +355,14 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_HOST: /var/run/postgresql
|
POSTGRES_HOST: /var/run/postgresql
|
||||||
PGPORT: ${POSTGRES_PORT}
|
PGPORT: ${SOCIAL_INFRA__POSTGRES__PORT}
|
||||||
POSTGRES_PORT: ${POSTGRES_PORT}
|
POSTGRES_PORT: ${SOCIAL_INFRA__POSTGRES__PORT}
|
||||||
PGPASSWORD: ${POSTGRES_PASSWORD}
|
PGPASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD}
|
||||||
PGDATABASE: ${POSTGRES_DB}
|
PGDATABASE: ${SOCIAL_INFRA__POSTGRES__DB}
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POSTGRES_DB: ${SOCIAL_INFRA__POSTGRES__DB}
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
|
||||||
JWT_EXP: ${JWT_EXPIRY}
|
JWT_EXP: ${SOCIAL_INFRA__JWT__EXPIRY}
|
||||||
command:
|
command:
|
||||||
[
|
[
|
||||||
"postgres",
|
"postgres",
|
||||||
@@ -378,7 +378,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./supabase/volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z
|
- ./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:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
[
|
[
|
||||||
@@ -393,7 +393,7 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
environment:
|
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"]
|
command: ["--config", "/etc/vector/vector.yml"]
|
||||||
security_opt:
|
security_opt:
|
||||||
- "label=disable"
|
- "label=disable"
|
||||||
@@ -403,8 +403,8 @@ services:
|
|||||||
image: supabase/supavisor:2.7.4
|
image: supabase/supavisor:2.7.4
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- ${POSTGRES_PORT}:5432
|
- ${SOCIAL_INFRA__POSTGRES__PORT}:5432
|
||||||
- ${POOLER_PROXY_PORT_TRANSACTION}:6543
|
- ${SOCIAL_INFRA__POOLER__PROXY_PORT_TRANSACTION}:6543
|
||||||
volumes:
|
volumes:
|
||||||
- ./supabase/volumes/pooler/pooler.exs:/etc/pooler/pooler.exs:ro,z
|
- ./supabase/volumes/pooler/pooler.exs:/etc/pooler/pooler.exs:ro,z
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -428,22 +428,22 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
PORT: 4000
|
PORT: 4000
|
||||||
POSTGRES_PORT: ${POSTGRES_PORT}
|
POSTGRES_PORT: ${SOCIAL_INFRA__POSTGRES__PORT}
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POSTGRES_DB: ${SOCIAL_INFRA__POSTGRES__DB}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD}
|
||||||
DATABASE_URL: ecto://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase
|
DATABASE_URL: ecto://supabase_admin:${SOCIAL_INFRA__POSTGRES__PASSWORD}@${SOCIAL_INFRA__POSTGRES__HOST}:${SOCIAL_INFRA__POSTGRES__PORT}/_supabase
|
||||||
CLUSTER_POSTGRES: true
|
CLUSTER_POSTGRES: true
|
||||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
|
SECRET_KEY_BASE: ${SOCIAL_INFRA__SUPAVISOR__SECRET_KEY_BASE}
|
||||||
VAULT_ENC_KEY: ${VAULT_ENC_KEY}
|
VAULT_ENC_KEY: ${SOCIAL_INFRA__SUPAVISOR__VAULT_ENC_KEY}
|
||||||
API_JWT_SECRET: ${JWT_SECRET}
|
API_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
|
||||||
METRICS_JWT_SECRET: ${JWT_SECRET}
|
METRICS_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
|
||||||
REGION: local
|
REGION: local
|
||||||
ERL_AFLAGS: -proto_dist inet_tcp
|
ERL_AFLAGS: -proto_dist inet_tcp
|
||||||
POOLER_TENANT_ID: ${POOLER_TENANT_ID}
|
POOLER_TENANT_ID: ${SOCIAL_INFRA__POOLER__TENANT_ID}
|
||||||
POOLER_DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE}
|
POOLER_DEFAULT_POOL_SIZE: ${SOCIAL_INFRA__POOLER__DEFAULT_POOL_SIZE}
|
||||||
POOLER_MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN}
|
POOLER_MAX_CLIENT_CONN: ${SOCIAL_INFRA__POOLER__MAX_CLIENT_CONN}
|
||||||
POOLER_POOL_MODE: transaction
|
POOLER_POOL_MODE: transaction
|
||||||
DB_POOL_SIZE: ${POOLER_DB_POOL_SIZE}
|
DB_POOL_SIZE: ${SOCIAL_INFRA__POOLER__DB_POOL_SIZE}
|
||||||
command:
|
command:
|
||||||
[
|
[
|
||||||
"/bin/sh",
|
"/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