refactor: align repo layout and logging safeguards

This commit is contained in:
qzl
2026-01-29 17:02:09 +08:00
parent 6af0989fe7
commit c2e65fa157
56 changed files with 1881 additions and 890 deletions
+162
View File
@@ -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)、587TLS
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 服务使用)
############
# 是否验证 JWTtrue 验证,false 不验证
SOCIAL_INFRA__FUNCTIONS__VERIFY_JWT=false
############
# 日志与分析配置(Docker 服务使用)
############
# Docker Socket 路径:用于容器日志收集
SOCIAL_INFRA__DOCKER__SOCKET_LOCATION=/var/run/docker.sock
+270 -1
View File
@@ -1,3 +1,266 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# Pipfile.lock
# UV
# uv.lock
# poetry
# poetry.lock
# poetry.toml
# pdm
# pdm.lock
# pdm.toml
.pdm-python
.pdm-build/
# pixi
# pixi.lock
.pixi
# PEP 582
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# Redis
*.rdb
*.aof
*.pid
# RabbitMQ
mnesia/
rabbitmq/
rabbitmq-data/
# ActiveMQ
activemq-data/
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Streamlit
.streamlit/secrets.toml
# Flutter/Dart/Pub related
**/doc/api/
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
**/generated_plugin_registrant.dart
.packages
.pub-preload-cache/
.pub/
build/
flutter_*.png
linked_*.ds
unlinked.ds
unlinked_spec.ds
# Android related
**/android/**/gradle-wrapper.jar
.gradle/
**/android/captures/
**/android/gradlew
**/android/gradlew.bat
**/android/local.properties
**/android/**/GeneratedPluginRegistrant.java
**/android/key.properties
*.jks
# iOS/XCode related
**/ios/**/*.mode1v3
**/ios/**/*.mode2v3
**/ios/**/*.moved-aside
**/ios/**/*.pbxuser
**/ios/**/*.perspectivev3
**/ios/**/*sync/
**/ios/**/.sconsign.dblite
**/ios/**/.tags*
**/ios/**/.vagrant/
**/ios/**/DerivedData/
**/ios/**/Icon?
**/ios/**/Pods/
**/ios/**/.symlinks/
**/ios/**/profile
**/ios/**/xcuserdata
**/ios/.generated/
**/ios/Flutter/.last_build_id
**/ios/Flutter/App.framework
**/ios/Flutter/Flutter.framework
**/ios/Flutter/Flutter.podspec
**/ios/Flutter/Generated.xcconfig
**/ios/Flutter/ephemeral
**/ios/Flutter/app.flx
**/ios/Flutter/app.zip
**/ios/Flutter/flutter_assets/
**/ios/Flutter/flutter_export_environment.sh
**/ios/ServiceDefinitions.json
**/ios/Runner/GeneratedPluginRegistrant.*
# macOS
**/Flutter/ephemeral/
**/Pods/
**/macos/Flutter/GeneratedPluginRegistrant.swift
**/macos/Flutter/ephemeral
**/xcuserdata/
# Windows
**/windows/flutter/generated_plugin_registrant.cc
**/windows/flutter/generated_plugin_registrant.h
**/windows/flutter/generated_plugins.cmake
# Linux
**/linux/flutter/generated_plugin_registrant.cc
**/linux/flutter/generated_plugin_registrant.h
**/linux/flutter/generated_plugins.cmake
# Coverage
coverage/
# Symbols
app.*.symbols
# Exceptions to above rules.
!**/ios/**/default.mode1v3
!**/ios/**/default.mode2v3
!**/ios/**/default.pbxuser
!**/ios/**/default.perspectivev3
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
!/dev/ci/**/Gemfile.lock
# Local environment files
infra/local/env/*.env
configs/env/*.env
@@ -5,8 +268,14 @@ infra/cloud/volcano/env/*.env
!infra/local/env/*.env.example
!configs/env/*.env.example
!infra/cloud/volcano/env/*.env.example
.env
.env.local
.env.*.local
.env.cloud
.env.*.cloud
# Misc
*.class
*.lock
*.swp
.buildlog/
.history
+12
View File
@@ -6,6 +6,18 @@
},
"zai-mcp-server": {
"enabled": false
},
"postgres_dev": {
"type": "local",
"command": [
"docker",
"run",
"-i",
"--rm",
"mcp/postgres",
"postgresql://supabase:${POSTGRES_PASSWORD}@host.docker.internal:54322/linksy"
],
"enabled": true
}
}
}
+13
View File
@@ -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/
+53 -2
View File
@@ -1,6 +1,57 @@
## Docker Startup
Must use environment file when starting services:
Always start services with the env file:
```bash
docker compose --env-file infra/env/.env.local -f infra/local/docker-compose.yml up -d
docker compose --env-file .env -f docker/docker-compose.yml up -d
```
## Python Environment
**MUST use uv for dependency management and virtual environment execution.**
- All Python commands: `uv run <command>`
- Add dependencies: `uv add <package>`
- All dependencies declared in `pyproject.toml`
## Code Quality Checks
**Git pre-commit hook enforces code quality before commit.**
Pre-commit hook automatically runs on api/ directory:
- `ruff check` - code style and linting
- `basedpyright` - type checking with error level
If any error detected, commit is rejected. Fix errors before committing.
Do not bypass or weaken checks (no ignores, disables, or config relaxations). Resolve the underlying issues.
## TDD First Policy
**Principle: tests before implementation.**
### Coverage Requirements
- Minimum coverage: 80%
- Required test types:
- Unit: isolated functions, utilities, components
- Integration: API endpoints, database operations
- E2E: critical user flows (Playwright)
### Limited Exceptions
- Docs-only changes (README, comments, formatting) may skip integration/E2E
- Non-runtime config changes may skip E2E if no behavior changes
- Any runtime code change requires unit + integration + E2E
- If an exception is used, record the reason in the PR/test notes
### Mandatory TDD Workflow
1. Write tests (RED) - they must fail
2. Run tests - confirm failure
3. Implement minimal code (GREEN) - only to pass
4. Run tests - confirm success
5. Refactor (IMPROVE)
6. Verify coverage - must be 80%+
### Enforcement
- Must use the `tdd-guide` agent for new features
- Do not write implementation before tests
- Do not lower coverage requirements
- Must include unit, integration, and E2E tests
-14
View File
@@ -1,17 +1,3 @@
# Social App Monorepo
Flutter + FastAPI + Supabase + Redis + Milvus
## 说明
本仓库仅初始化结构,不包含业务实现
## 目录结构
- `apps/` —— 可运行应用(Flutter / FastAPI / Worker
- `infra/` —— 基础设施(本地 docker / 云部署 / 迁移)
- `configs/` —— 配置规范与公共配置模板(不含密钥)
- `tools/` —— 脚本与生成器
- `docs/` —— 文档与规则
详见 `docs/rules/repo-structure.md`
View File
View File
+3
View File
@@ -0,0 +1,3 @@
from .settings import Settings, config
__all__ = ["Settings", "config"]
+113
View File
@@ -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()
+15
View File
@@ -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",
]
+57
View File
@@ -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)
+103
View File
@@ -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,
)
+15
View File
@@ -0,0 +1,15 @@
from __future__ import annotations
from structlog import contextvars
def bind_context(**values: object) -> None:
contextvars.bind_contextvars(**values)
def clear_context() -> None:
contextvars.clear_contextvars()
def get_context() -> dict[str, object]:
return contextvars.get_contextvars()
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
import logging
import re
from collections.abc import Callable
from typing import cast
from structlog.types import EventDict
_NORMALIZE_PATTERN = re.compile(r"[^a-z0-9]")
def _normalize_key(value: str) -> str:
return _NORMALIZE_PATTERN.sub("", value.lower())
def _is_sensitive_key(key: object, sensitive_fields: set[str]) -> bool:
normalized_key = _normalize_key(str(key))
return normalized_key in sensitive_fields or any(
fragment in normalized_key for fragment in sensitive_fields
)
def _redact_value(value: object, sensitive_fields: set[str]) -> object:
if isinstance(value, dict):
typed_value = cast(dict[str, object], value)
return {
key: (
"[REDACTED]"
if _is_sensitive_key(key, sensitive_fields)
else _redact_value(inner, sensitive_fields)
)
for key, inner in typed_value.items()
}
if isinstance(value, list):
return [_redact_value(item, sensitive_fields) for item in value]
return value
def build_sensitive_data_processor(
sensitive_fields: list[str],
) -> Callable[[object, str, EventDict], EventDict]:
normalized = {_normalize_key(field) for field in sensitive_fields}
def processor(
_logger: object, _method_name: str, event_dict: EventDict
) -> EventDict:
return cast(EventDict, _redact_value(event_dict, normalized))
return processor
class ErrorLevelFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
return record.levelno >= logging.ERROR
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
from structlog.dev import ConsoleRenderer
from structlog.processors import JSONRenderer
from structlog.stdlib import ProcessorFormatter
from structlog.types import EventDict
import structlog
from core.logging.filters import build_sensitive_data_processor
def ensure_message_key(
_logger: object, _method_name: str, event_dict: EventDict
) -> EventDict:
if "message" in event_dict:
return event_dict
if "event" not in event_dict:
return event_dict
without_event = {key: value for key, value in event_dict.items() if key != "event"}
return {**without_event, "message": event_dict["event"]}
def build_processor_formatter(
sensitive_fields: list[str] | None = None,
) -> ProcessorFormatter:
redact = build_sensitive_data_processor(sensitive_fields or [])
return ProcessorFormatter(
foreign_pre_chain=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.CallsiteParameterAdder(
parameters=[
structlog.processors.CallsiteParameter.MODULE,
structlog.processors.CallsiteParameter.FUNC_NAME,
structlog.processors.CallsiteParameter.LINENO,
]
),
structlog.stdlib.ExtraAdder(),
ensure_message_key,
],
processors=[
redact,
ensure_message_key,
ProcessorFormatter.remove_processors_meta,
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
JSONRenderer(sort_keys=True),
],
)
def build_plain_formatter(
sensitive_fields: list[str] | None = None,
) -> ProcessorFormatter:
redact = build_sensitive_data_processor(sensitive_fields or [])
return ProcessorFormatter(
foreign_pre_chain=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.CallsiteParameterAdder(
parameters=[
structlog.processors.CallsiteParameter.MODULE,
structlog.processors.CallsiteParameter.FUNC_NAME,
structlog.processors.CallsiteParameter.LINENO,
]
),
structlog.stdlib.ExtraAdder(),
ensure_message_key,
],
processors=[
redact,
ensure_message_key,
ProcessorFormatter.remove_processors_meta,
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
ConsoleRenderer(colors=False),
],
)
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
from pathlib import Path
from core.config.settings import RuntimeSettings
def build_file_handler_config(
runtime: RuntimeSettings,
file_path: Path,
level: str,
formatter: str,
filters: list[str] | None = None,
) -> dict[str, object]:
filter_list = list(filters or [])
base_config: dict[str, object] = {
"level": level,
"formatter": formatter,
"filename": str(file_path),
"encoding": "utf-8",
}
if filter_list:
base_config = {**base_config, "filters": filter_list}
if runtime.log_rotation == "time":
return {
**base_config,
"class": "logging.handlers.TimedRotatingFileHandler",
"when": runtime.log_rotation_when,
"interval": runtime.log_rotation_interval,
"backupCount": runtime.log_rotation_backup_count,
}
if runtime.log_rotation == "size":
return {
**base_config,
"class": "logging.handlers.RotatingFileHandler",
"maxBytes": runtime.log_rotation_max_bytes,
"backupCount": runtime.log_rotation_backup_count,
}
return {
**base_config,
"class": "logging.FileHandler",
}
+7
View File
@@ -0,0 +1,7 @@
from __future__ import annotations
import structlog
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
return structlog.get_logger(name)
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import re
from collections.abc import MutableMapping
from typing import cast
from uuid import uuid4
from fastapi import FastAPI, Request
from starlette.requests import Request as StarletteRequest
from starlette.responses import JSONResponse, Response
from starlette.types import ASGIApp, Receive, Scope, Send
from core.logging.context import bind_context, clear_context
from core.logging.logger import get_logger
class RequestContextMiddleware:
app: ASGIApp
_header_name: str
_request_id_pattern: re.Pattern[str]
def __init__(self, app: ASGIApp, header_name: str = "X-Request-ID") -> None:
self.app = app
self._header_name = header_name
self._request_id_pattern = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope.get("type") != "http":
await self.app(scope, receive, send)
return
request = StarletteRequest(scope, receive=receive)
request_id = self._normalize_request_id(request.headers.get(self._header_name))
client_ip = request.client.host if request.client else None
user_id = getattr(request.state, "user_id", None)
request.state.request_id = request_id
bind_context(
request_id=request_id,
method=request.method,
path=request.url.path,
client_ip=client_ip,
user_id=user_id,
)
async def send_wrapper(message: MutableMapping[str, object]) -> None:
if message.get("type") == "http.response.start":
raw_headers = message.get("headers")
headers = list(cast(list[tuple[bytes, bytes]], raw_headers or []))
header_key = self._header_name.lower().encode()
if not any(item[0].lower() == header_key for item in headers):
headers.append((header_key, request_id.encode()))
message = {**message, "headers": headers}
await send(message)
try:
await self.app(scope, receive, send_wrapper)
finally:
clear_context()
def _normalize_request_id(self, request_id: str | None) -> str:
if request_id and self._request_id_pattern.match(request_id):
return request_id
return str(uuid4())
def register_exception_handlers(app: FastAPI) -> None:
logger = get_logger("core.logging.exception")
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> Response:
request_id = getattr(request.state, "request_id", None)
logger.exception(
"Unhandled exception",
error_type=exc.__class__.__name__,
request_id=request_id,
)
headers = {"X-Request-ID": request_id} if request_id else None
return JSONResponse(
status_code=500,
content={"detail": "Internal Server Error"},
headers=headers,
)
+11
View File
@@ -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))
+96
View File
@@ -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()
+45
View File
@@ -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
+140
View File
@@ -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]"
+30
View File
@@ -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"
+35
View File
@@ -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
-2
View File
@@ -1,2 +0,0 @@
# FastAPI 服务占位文件
# 后续添加依赖和配置
-2
View File
@@ -1,2 +0,0 @@
# Flutter 应用占位文件
# 后续添加依赖和配置
-2
View File
@@ -1,2 +0,0 @@
# 异步任务/队列服务占位文件
# 预留:后续可能添加
-1
View File
@@ -1 +0,0 @@
# 放后端开发项目需要的环境变量
-4
View File
@@ -1,4 +0,0 @@
{
"apiBaseUrl": "http://localhost:8000",
"environment": "development"
}
-4
View File
@@ -1,4 +0,0 @@
{
"apiBaseUrl": "https://api.yourdomain.com",
"environment": "production"
}
-2
View File
@@ -1,2 +0,0 @@
# OpenAPI 规范占位文件
# 后续通过 FastAPI 自动生成
@@ -47,22 +47,22 @@ services:
environment:
HOSTNAME: "::"
STUDIO_PG_META_URL: http://meta:8080
POSTGRES_PORT: ${POSTGRES_PORT}
POSTGRES_HOST: ${POSTGRES_HOST}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
PG_META_CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION}
DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
POSTGRES_PORT: ${SOCIAL_INFRA__POSTGRES__PORT}
POSTGRES_HOST: ${SOCIAL_INFRA__POSTGRES__HOST}
POSTGRES_DB: ${SOCIAL_INFRA__POSTGRES__DB}
POSTGRES_PASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD}
PG_META_CRYPTO_KEY: ${SOCIAL_INFRA__PG_META__CRYPTO_KEY}
DEFAULT_ORGANIZATION_NAME: ${SOCIAL_INFRA__STUDIO__DEFAULT_ORGANIZATION}
DEFAULT_PROJECT_NAME: ${SOCIAL_INFRA__STUDIO__DEFAULT_PROJECT}
OPENAI_API_KEY: ${SOCIAL_INFRA__OPENAI__API_KEY:-}
SUPABASE_URL: http://kong:8000
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
AUTH_JWT_SECRET: ${JWT_SECRET}
LOGFLARE_API_KEY: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
LOGFLARE_PRIVATE_ACCESS_TOKEN: ${LOGFLARE_PRIVATE_ACCESS_TOKEN}
SUPABASE_PUBLIC_URL: ${SOCIAL_INFRA__SUPABASE__PUBLIC_URL}
SUPABASE_ANON_KEY: ${SOCIAL_INFRA__SUPABASE__ANON_KEY}
SUPABASE_SERVICE_KEY: ${SOCIAL_INFRA__SUPABASE__SERVICE_ROLE_KEY}
AUTH_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
LOGFLARE_API_KEY: ${SOCIAL_INFRA__LOGFLARE__PUBLIC_ACCESS_TOKEN}
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${SOCIAL_INFRA__LOGFLARE__PUBLIC_ACCESS_TOKEN}
LOGFLARE_PRIVATE_ACCESS_TOKEN: ${SOCIAL_INFRA__LOGFLARE__PRIVATE_ACCESS_TOKEN}
LOGFLARE_URL: http://analytics:4000
NEXT_PUBLIC_ENABLE_LOGS: true
NEXT_ANALYTICS_BACKEND_PROVIDER: postgres
@@ -75,8 +75,8 @@ services:
image: kong:2.8.1
restart: unless-stopped
ports:
- ${KONG_HTTP_PORT}:8000/tcp
- ${KONG_HTTPS_PORT}:8443/tcp
- ${SOCIAL_INFRA__KONG__HTTP_PORT}:8000/tcp
- ${SOCIAL_INFRA__KONG__HTTPS_PORT}:8443/tcp
volumes:
- ./supabase/volumes/api/kong.yml:/home/kong/temp.yml:ro,z
depends_on:
@@ -89,10 +89,10 @@ services:
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
DASHBOARD_USERNAME: ${DASHBOARD_USERNAME}
DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD}
SUPABASE_ANON_KEY: ${SOCIAL_INFRA__SUPABASE__ANON_KEY}
SUPABASE_SERVICE_KEY: ${SOCIAL_INFRA__SUPABASE__SERVICE_ROLE_KEY}
DASHBOARD_USERNAME: ${SOCIAL_INFRA__DASHBOARD__USERNAME}
DASHBOARD_PASSWORD: ${SOCIAL_INFRA__DASHBOARD__PASSWORD}
entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start'
auth:
@@ -120,32 +120,32 @@ services:
environment:
GOTRUE_API_HOST: 0.0.0.0
GOTRUE_API_PORT: 9999
API_EXTERNAL_URL: ${API_EXTERNAL_URL}
API_EXTERNAL_URL: ${SOCIAL_INFRA__API_EXTERNAL_URL}
GOTRUE_DB_DRIVER: postgres
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
GOTRUE_SITE_URL: ${SITE_URL}
GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS}
GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP}
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${SOCIAL_INFRA__POSTGRES__PASSWORD}@${SOCIAL_INFRA__POSTGRES__HOST}:${SOCIAL_INFRA__POSTGRES__PORT}/${SOCIAL_INFRA__POSTGRES__DB}
GOTRUE_SITE_URL: ${SOCIAL_INFRA__SITE__URL}
GOTRUE_URI_ALLOW_LIST: ${SOCIAL_INFRA__ADDITIONAL_REDIRECT_URLS}
GOTRUE_DISABLE_SIGNUP: ${SOCIAL_INFRA__AUTH__DISABLE_SIGNUP}
GOTRUE_JWT_ADMIN_ROLES: service_role
GOTRUE_JWT_AUD: authenticated
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
GOTRUE_JWT_EXP: ${JWT_EXPIRY}
GOTRUE_JWT_SECRET: ${JWT_SECRET}
GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP}
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS}
GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM}
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL}
GOTRUE_SMTP_HOST: ${SMTP_HOST}
GOTRUE_SMTP_PORT: ${SMTP_PORT}
GOTRUE_SMTP_USER: ${SMTP_USER}
GOTRUE_SMTP_PASS: ${SMTP_PASS}
GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME}
GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE}
GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION}
GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY}
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE}
GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP}
GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM}
GOTRUE_JWT_EXP: ${SOCIAL_INFRA__JWT__EXPIRY}
GOTRUE_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
GOTRUE_EXTERNAL_EMAIL_ENABLED: ${SOCIAL_INFRA__EMAIL__ENABLE_SIGNUP}
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${SOCIAL_INFRA__AUTH__ENABLE_ANONYMOUS_USERS}
GOTRUE_MAILER_AUTOCONFIRM: ${SOCIAL_INFRA__EMAIL__ENABLE_AUTOCONFIRM}
GOTRUE_SMTP_ADMIN_EMAIL: ${SOCIAL_INFRA__SMTP__ADMIN_EMAIL}
GOTRUE_SMTP_HOST: ${SOCIAL_INFRA__SMTP__HOST}
GOTRUE_SMTP_PORT: ${SOCIAL_INFRA__SMTP__PORT}
GOTRUE_SMTP_USER: ${SOCIAL_INFRA__SMTP__USER}
GOTRUE_SMTP_PASS: ${SOCIAL_INFRA__SMTP__PASS}
GOTRUE_SMTP_SENDER_NAME: ${SOCIAL_INFRA__SMTP__SENDER_NAME}
GOTRUE_MAILER_URLPATHS_INVITE: ${SOCIAL_INFRA__MAILER__URLPATHS_INVITE}
GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${SOCIAL_INFRA__MAILER__URLPATHS_CONFIRMATION}
GOTRUE_MAILER_URLPATHS_RECOVERY: ${SOCIAL_INFRA__MAILER__URLPATHS_RECOVERY}
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${SOCIAL_INFRA__MAILER__URLPATHS_EMAIL_CHANGE}
GOTRUE_EXTERNAL_PHONE_ENABLED: ${SOCIAL_INFRA__AUTH__ENABLE_PHONE_SIGNUP}
GOTRUE_SMS_AUTOCONFIRM: ${SOCIAL_INFRA__AUTH__ENABLE_PHONE_AUTOCONFIRM}
rest:
container_name: supabase-rest
@@ -157,13 +157,13 @@ services:
analytics:
condition: service_healthy
environment:
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
PGRST_DB_URI: postgres://authenticator:${SOCIAL_INFRA__POSTGRES__PASSWORD}@${SOCIAL_INFRA__POSTGRES__HOST}:${SOCIAL_INFRA__POSTGRES__PORT}/${SOCIAL_INFRA__POSTGRES__DB}
PGRST_DB_SCHEMAS: ${SOCIAL_INFRA__PGRST__DB_SCHEMAS}
PGRST_DB_ANON_ROLE: anon
PGRST_JWT_SECRET: ${JWT_SECRET}
PGRST_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
PGRST_DB_USE_LEGACY_GUCS: "false"
PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY}
PGRST_APP_SETTINGS_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
PGRST_APP_SETTINGS_JWT_EXP: ${SOCIAL_INFRA__JWT__EXPIRY}
command: ["postgrest"]
realtime:
@@ -179,7 +179,7 @@ services:
test:
[
"CMD-SHELL",
'curl -sSfL --head -o /dev/null -H "Authorization: Bearer ${ANON_KEY}" http://localhost:4000/api/tenants/realtime-dev/health',
'curl -sSfL --head -o /dev/null -H "Authorization: Bearer ${SOCIAL_INFRA__SUPABASE__ANON_KEY}" http://localhost:4000/api/tenants/realtime-dev/health',
]
timeout: 5s
interval: 30s
@@ -187,16 +187,16 @@ services:
start_period: 10s
environment:
PORT: 4000
DB_HOST: ${POSTGRES_HOST}
DB_PORT: ${POSTGRES_PORT}
DB_HOST: ${SOCIAL_INFRA__POSTGRES__HOST}
DB_PORT: ${SOCIAL_INFRA__POSTGRES__PORT}
DB_USER: supabase_admin
DB_PASSWORD: ${POSTGRES_PASSWORD}
DB_NAME: ${POSTGRES_DB}
DB_PASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD}
DB_NAME: ${SOCIAL_INFRA__POSTGRES__DB}
DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime"
DB_ENC_KEY: supabaserealtime
API_JWT_SECRET: ${JWT_SECRET}
ANON_KEY: ${ANON_KEY}
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
API_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
ANON_KEY: ${SOCIAL_INFRA__SUPABASE__ANON_KEY}
SECRET_KEY_BASE: ${SOCIAL_INFRA__SUPAVISOR__SECRET_KEY_BASE}
ERL_AFLAGS: -proto_dist inet_tcp
DNS_NODES: "''"
RLIMIT_NOFILE: "10000"
@@ -232,11 +232,11 @@ services:
imgproxy:
condition: service_started
environment:
ANON_KEY: ${ANON_KEY}
SERVICE_KEY: ${SERVICE_ROLE_KEY}
ANON_KEY: ${SOCIAL_INFRA__SUPABASE__ANON_KEY}
SERVICE_KEY: ${SOCIAL_INFRA__SUPABASE__SERVICE_ROLE_KEY}
POSTGREST_URL: http://rest:3000
PGRST_JWT_SECRET: ${JWT_SECRET}
DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
PGRST_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
DATABASE_URL: postgres://supabase_storage_admin:${SOCIAL_INFRA__POSTGRES__PASSWORD}@${SOCIAL_INFRA__POSTGRES__HOST}:${SOCIAL_INFRA__POSTGRES__PORT}/${SOCIAL_INFRA__POSTGRES__DB}
REQUEST_ALLOW_X_FORWARDED_PATH: "true"
FILE_SIZE_LIMIT: 52428800
STORAGE_BACKEND: file
@@ -262,7 +262,7 @@ services:
IMGPROXY_BIND: ":5001"
IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
IMGPROXY_USE_ETAG: "true"
IMGPROXY_ENABLE_WEBP_DETECTION: ${IMGPROXY_ENABLE_WEBP_DETECTION}
IMGPROXY_ENABLE_WEBP_DETECTION: ${SOCIAL_INFRA__IMGPROXY__ENABLE_WEBP_DETECTION}
IMGPROXY_MAX_SRC_RESOLUTION: 16.8
meta:
@@ -276,12 +276,12 @@ services:
condition: service_healthy
environment:
PG_META_PORT: 8080
PG_META_DB_HOST: ${POSTGRES_HOST}
PG_META_DB_PORT: ${POSTGRES_PORT}
PG_META_DB_NAME: ${POSTGRES_DB}
PG_META_DB_HOST: ${SOCIAL_INFRA__POSTGRES__HOST}
PG_META_DB_PORT: ${SOCIAL_INFRA__POSTGRES__PORT}
PG_META_DB_NAME: ${SOCIAL_INFRA__POSTGRES__DB}
PG_META_DB_USER: supabase_admin
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
PG_META_DB_PASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD}
CRYPTO_KEY: ${SOCIAL_INFRA__PG_META__CRYPTO_KEY}
functions:
container_name: supabase-edge-functions
@@ -293,12 +293,12 @@ services:
analytics:
condition: service_healthy
environment:
JWT_SECRET: ${JWT_SECRET}
JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
SUPABASE_URL: http://kong:8000
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}"
SUPABASE_ANON_KEY: ${SOCIAL_INFRA__SUPABASE__ANON_KEY}
SUPABASE_SERVICE_ROLE_KEY: ${SOCIAL_INFRA__SUPABASE__SERVICE_ROLE_KEY}
SUPABASE_DB_URL: postgresql://postgres:${SOCIAL_INFRA__POSTGRES__PASSWORD}@${SOCIAL_INFRA__POSTGRES__HOST}:${SOCIAL_INFRA__POSTGRES__PORT}/${SOCIAL_INFRA__POSTGRES__DB}
VERIFY_JWT: "${SOCIAL_INFRA__FUNCTIONS__VERIFY_JWT}"
command: ["start", "--main-service", "/home/deno/functions/main"]
analytics:
@@ -319,15 +319,15 @@ services:
LOGFLARE_NODE_HOST: 127.0.0.1
DB_USERNAME: supabase_admin
DB_DATABASE: _supabase
DB_HOSTNAME: ${POSTGRES_HOST}
DB_PORT: ${POSTGRES_PORT}
DB_PASSWORD: ${POSTGRES_PASSWORD}
DB_HOSTNAME: ${SOCIAL_INFRA__POSTGRES__HOST}
DB_PORT: ${SOCIAL_INFRA__POSTGRES__PORT}
DB_PASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD}
DB_SCHEMA: _analytics
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
LOGFLARE_PRIVATE_ACCESS_TOKEN: ${LOGFLARE_PRIVATE_ACCESS_TOKEN}
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${SOCIAL_INFRA__LOGFLARE__PUBLIC_ACCESS_TOKEN}
LOGFLARE_PRIVATE_ACCESS_TOKEN: ${SOCIAL_INFRA__LOGFLARE__PRIVATE_ACCESS_TOKEN}
LOGFLARE_SINGLE_TENANT: true
LOGFLARE_SUPABASE_MODE: true
POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase
POSTGRES_BACKEND_URL: postgresql://supabase_admin:${SOCIAL_INFRA__POSTGRES__PASSWORD}@${SOCIAL_INFRA__POSTGRES__HOST}:${SOCIAL_INFRA__POSTGRES__PORT}/_supabase
POSTGRES_BACKEND_SCHEMA: _analytics
LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true
@@ -355,14 +355,14 @@ services:
condition: service_healthy
environment:
POSTGRES_HOST: /var/run/postgresql
PGPORT: ${POSTGRES_PORT}
POSTGRES_PORT: ${POSTGRES_PORT}
PGPASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
PGDATABASE: ${POSTGRES_DB}
POSTGRES_DB: ${POSTGRES_DB}
JWT_SECRET: ${JWT_SECRET}
JWT_EXP: ${JWT_EXPIRY}
PGPORT: ${SOCIAL_INFRA__POSTGRES__PORT}
POSTGRES_PORT: ${SOCIAL_INFRA__POSTGRES__PORT}
PGPASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD}
POSTGRES_PASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD}
PGDATABASE: ${SOCIAL_INFRA__POSTGRES__DB}
POSTGRES_DB: ${SOCIAL_INFRA__POSTGRES__DB}
JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
JWT_EXP: ${SOCIAL_INFRA__JWT__EXPIRY}
command:
[
"postgres",
@@ -378,7 +378,7 @@ services:
restart: unless-stopped
volumes:
- ./supabase/volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z
- ${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock:ro,z
- ${SOCIAL_INFRA__DOCKER__SOCKET_LOCATION}:/var/run/docker.sock:ro,z
healthcheck:
test:
[
@@ -393,7 +393,7 @@ services:
interval: 5s
retries: 3
environment:
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${SOCIAL_INFRA__LOGFLARE__PUBLIC_ACCESS_TOKEN}
command: ["--config", "/etc/vector/vector.yml"]
security_opt:
- "label=disable"
@@ -403,8 +403,8 @@ services:
image: supabase/supavisor:2.7.4
restart: unless-stopped
ports:
- ${POSTGRES_PORT}:5432
- ${POOLER_PROXY_PORT_TRANSACTION}:6543
- ${SOCIAL_INFRA__POSTGRES__PORT}:5432
- ${SOCIAL_INFRA__POOLER__PROXY_PORT_TRANSACTION}:6543
volumes:
- ./supabase/volumes/pooler/pooler.exs:/etc/pooler/pooler.exs:ro,z
healthcheck:
@@ -428,22 +428,22 @@ services:
condition: service_healthy
environment:
PORT: 4000
POSTGRES_PORT: ${POSTGRES_PORT}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
DATABASE_URL: ecto://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase
POSTGRES_PORT: ${SOCIAL_INFRA__POSTGRES__PORT}
POSTGRES_DB: ${SOCIAL_INFRA__POSTGRES__DB}
POSTGRES_PASSWORD: ${SOCIAL_INFRA__POSTGRES__PASSWORD}
DATABASE_URL: ecto://supabase_admin:${SOCIAL_INFRA__POSTGRES__PASSWORD}@${SOCIAL_INFRA__POSTGRES__HOST}:${SOCIAL_INFRA__POSTGRES__PORT}/_supabase
CLUSTER_POSTGRES: true
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
VAULT_ENC_KEY: ${VAULT_ENC_KEY}
API_JWT_SECRET: ${JWT_SECRET}
METRICS_JWT_SECRET: ${JWT_SECRET}
SECRET_KEY_BASE: ${SOCIAL_INFRA__SUPAVISOR__SECRET_KEY_BASE}
VAULT_ENC_KEY: ${SOCIAL_INFRA__SUPAVISOR__VAULT_ENC_KEY}
API_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
METRICS_JWT_SECRET: ${SOCIAL_INFRA__JWT__SECRET}
REGION: local
ERL_AFLAGS: -proto_dist inet_tcp
POOLER_TENANT_ID: ${POOLER_TENANT_ID}
POOLER_DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE}
POOLER_MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN}
POOLER_TENANT_ID: ${SOCIAL_INFRA__POOLER__TENANT_ID}
POOLER_DEFAULT_POOL_SIZE: ${SOCIAL_INFRA__POOLER__DEFAULT_POOL_SIZE}
POOLER_MAX_CLIENT_CONN: ${SOCIAL_INFRA__POOLER__MAX_CLIENT_CONN}
POOLER_POOL_MODE: transaction
DB_POOL_SIZE: ${POOLER_DB_POOL_SIZE}
DB_POOL_SIZE: ${SOCIAL_INFRA__POOLER__DB_POOL_SIZE}
command:
[
"/bin/sh",
-38
View File
@@ -1,38 +0,0 @@
# 技术栈选择
## 背景
本项目需要构建一个跨平台社交应用,支持本地开发和云端部署。
## 决策
1. **前端框架:Flutter**
- 跨平台支持(iOS / Android / Web
- 高性能原生渲染
- 丰富的 UI 组件
2. **后端框架:FastAPI**
- 高性能异步框架
- 自动生成 OpenAPI 文档
- 类型安全
3. **数据库:SupabasePostgreSQL**
- 开箱即用的 PostgreSQL
- 内置认证和权限管理
- 实时订阅功能
4. **缓存:Redis**
- 高性能键值存储
- 支持多种数据结构
5. **向量数据库:Milvus**
- 高性能向量检索
- 支持大规模向量存储
- 适合 RAG 和推荐场景
## 后续考虑
根据业务发展,可能需要评估:
- CDN 方案
- 消息队列
- 监控和日志系统
-17
View File
@@ -1,17 +0,0 @@
# 系统架构概述
## 技术栈
- **前端**Flutter
- **后端**FastAPI
- **数据库**SupabasePostgreSQL
- **缓存**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)。
### 方案对比(至少两种)
| 方案 | 描述 | 优点 | 缺点 | 结论 |
|------|------|------|------|------|
| 方案 Astdlib logging + 自定义 JSON Formatter | 纯标准库实现 JSON formatter + handler/filters | 依赖最少,符合标准库,易与 Celery/FastAPI 集成 | 结构化上下文绑定与 request_id 传递需手写 | 可作为备选最小方案 |
| 方案 Bstdlib logging + structlog | 用 structlog 生成结构化事件,输出到 logging handler | 结构化上下文与 contextvars 支持好,兼容 logging handler | 引入第三方依赖与配置复杂度 | 推荐主方案 |
| 方案 Cloguru | 直接使用 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** |
-203
View File
@@ -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()` 读取本地应用变量
-21
View File
@@ -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/` 目录下
-25
View File
@@ -1,25 +0,0 @@
# 火山云部署指南
## 准备工作
1. 火山云账号
2. 配置云端环境变量
3. 准备镜像仓库
## 环境变量模板
云端模板文件位置:
```bash
cp infra/cloud/volcano/env/.env.example infra/cloud/volcano/env/.env
```
## 部署流程
待补充详细步骤...
## 注意事项
- 确保所有敏感信息使用环境变量或密钥管理
- 遵循最小权限原则
- 配置适当的监控和日志
-272
View File
@@ -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
- ReDochttp://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
# 检查 6379Redis
lsof -i :6379
# 检查 54322Postgres
lsof -i :54322
# 检查 19530Milvus
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`
-89
View File
@@ -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
-90
View File
@@ -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
+38
View File
@@ -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",
]
+22
View File
@@ -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"
}