feat: add FastAPI backend with PostgreSQL and start script fixes

- Add server/ directory with FastAPI backend
- Fix server/start.sh to properly handle venv on Windows/Git Bash
- Add alembic migrations and pyproject.toml
- Add server tests

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 17:43:47 +08:00
parent 9785fb527b
commit 83d7da3d62
46 changed files with 1438 additions and 9 deletions

View File

@@ -0,0 +1 @@
__all__ = ["config"]

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
from pathlib import Path
from urllib.parse import quote_plus
from dotenv import set_key
from app.core.config import ROOT_DIR, Settings, refresh_settings
from app.db.session import configure_session_factory
from app.schemas.bootstrap import BootstrapSetupPayload, BootstrapStateRead
ENV_FILE = ROOT_DIR / ".env"
ENV_EXAMPLE_FILE = ROOT_DIR / ".env.example"
def ensure_env_file() -> Path:
if ENV_FILE.exists():
return ENV_FILE
if ENV_EXAMPLE_FILE.exists():
ENV_FILE.write_text(ENV_EXAMPLE_FILE.read_text(encoding="utf-8"), encoding="utf-8")
else:
ENV_FILE.write_text("", encoding="utf-8")
return ENV_FILE
def build_database_url(payload: BootstrapSetupPayload) -> str:
username = quote_plus(payload.postgres_user)
password = quote_plus(payload.postgres_password)
return (
f"postgresql+psycopg://{username}:{password}"
f"@{payload.postgres_host}:{payload.postgres_port}/{payload.postgres_db}"
)
def build_bootstrap_state(settings: Settings) -> BootstrapStateRead:
return BootstrapStateRead(
initialized=settings.setup_completed,
company={
"name": settings.company_name,
"code": settings.company_code,
"admin_email": settings.admin_email,
},
web={"host": settings.web_host, "port": settings.web_port},
server={"host": settings.app_host, "port": settings.app_port},
database={
"driver": "postgresql",
"host": settings.postgres_host,
"port": settings.postgres_port,
"name": settings.postgres_db,
"username": settings.postgres_user,
"password_configured": bool(settings.postgres_password),
},
redis={"enabled": bool(settings.redis_url), "url": settings.redis_url or ""},
)
def persist_bootstrap_config(payload: BootstrapSetupPayload, settings: Settings) -> BootstrapStateRead:
env_file = ensure_env_file()
database_url = build_database_url(payload)
vite_api_base_url = f"http://{settings.app_host}:{settings.app_port}{settings.api_v1_prefix}"
updates = {
"SETUP_COMPLETED": "true",
"COMPANY_NAME": payload.company_name,
"COMPANY_CODE": payload.company_code,
"ADMIN_EMAIL": payload.admin_email or "",
"POSTGRES_HOST": payload.postgres_host,
"POSTGRES_PORT": str(payload.postgres_port),
"POSTGRES_DB": payload.postgres_db,
"POSTGRES_USER": payload.postgres_user,
"POSTGRES_PASSWORD": payload.postgres_password,
"DATABASE_URL": database_url,
"REDIS_URL": payload.redis_url or "",
"VITE_API_BASE_URL": vite_api_base_url,
}
for key, value in updates.items():
set_key(str(env_file), key, value, quote_mode="auto", encoding="utf-8")
refreshed_settings = refresh_settings(updates)
configure_session_factory()
return build_bootstrap_state(refreshed_settings)

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
from functools import lru_cache
from os import environ
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
SERVER_DIR = Path(__file__).resolve().parents[3]
ROOT_DIR = SERVER_DIR.parent
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=(ROOT_DIR / ".env", SERVER_DIR / ".env"),
env_file_encoding="utf-8",
extra="ignore",
)
app_name: str = Field(default="X-Financial Server", alias="APP_NAME")
app_env: str = Field(default="local", alias="APP_ENV")
app_debug: bool = Field(default=True, alias="APP_DEBUG")
setup_completed: bool = Field(default=False, alias="SETUP_COMPLETED")
company_name: str = Field(default="", alias="COMPANY_NAME")
company_code: str = Field(default="", alias="COMPANY_CODE")
admin_email: str = Field(default="", alias="ADMIN_EMAIL")
web_host: str = Field(default="127.0.0.1", alias="WEB_HOST")
web_port: int = Field(default=5173, alias="WEB_PORT")
app_host: str = Field(default="127.0.0.1", alias="SERVER_HOST")
app_port: int = Field(default=8000, alias="SERVER_PORT")
api_v1_prefix: str = Field(default="/api/v1", alias="API_V1_PREFIX")
postgres_host: str = Field(default="127.0.0.1", alias="POSTGRES_HOST")
postgres_port: int = Field(default=5432, alias="POSTGRES_PORT")
postgres_db: str = Field(default="x_financial", alias="POSTGRES_DB")
postgres_user: str = Field(default="postgres", alias="POSTGRES_USER")
postgres_password: str = Field(default="postgres", alias="POSTGRES_PASSWORD")
database_url: str | None = Field(default=None, alias="DATABASE_URL")
sqlalchemy_echo: bool = Field(default=False, alias="SQLALCHEMY_ECHO")
redis_url: str | None = Field(default=None, alias="REDIS_URL")
cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS")
vite_api_base_url: str = Field(default="http://127.0.0.1:8000/api/v1", alias="VITE_API_BASE_URL")
@property
def resolved_database_url(self) -> str:
if self.database_url:
return self.database_url
return (
f"postgresql+psycopg://{self.postgres_user}:{self.postgres_password}"
f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
)
@lru_cache
def get_settings() -> Settings:
return Settings()
def refresh_settings(updated_values: dict[str, str]) -> Settings:
for key, value in updated_values.items():
environ[key] = value
get_settings.cache_clear()
return get_settings()