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

@@ -1,12 +1,71 @@
# Server # Server
后端目录 后端已按 `FastAPI + PostgreSQL + SQLAlchemy + Alembic` 起好基础工程
当前仓库还没有正式后端实现,这里先独立出 `server/`,后续服务端代码统一放在这里,避免再和前端工程混在根目录或 `web/` 里。 ## 为什么先选 PostgreSQL
建议后续结构 这个项目是报销、审批、员工、流程、审计记录为主,核心特点是
- `server/src/`:业务代码 - 强事务
- `server/config/`:配置 - 多表关联明显
- `server/scripts/`:启动、迁移、初始化脚本 - 审批流和审计日志需要一致性
- `server/tests/`:后端测试 - 后续大概率要做复杂查询、统计和条件筛选
这类系统优先选关系型数据库更合适,`PostgreSQL` 是当前默认推荐。
## Redis 要不要现在上
现在 **不是必须**
先不把 Redis 作为启动前置,原因很直接:
- 当前第一阶段先把核心业务表、接口、权限、审批流跑通
- 如果一开始就把 Redis 绑死,会增加部署和排障复杂度
Redis 更适合后面这些场景:
- 登录态 / token 黑名单
- 热点数据缓存
- 限流
- 分布式锁
- 消息队列 / 后台任务
所以现在的策略是:
- 主数据库:`PostgreSQL`
- Redis`可选能力`,配置已预留,但不是必需依赖
## 目录
- `src/app/`:应用代码
- `alembic/`:数据库迁移
- `tests/`:测试
## 启动
1. 创建虚拟环境并安装依赖
```bash
cd server
python -m venv .venv
.venv\\Scripts\\activate
pip install -e .[dev]
```
2. 在项目根目录准备环境变量
```bash
copy ..\\.env.example ..\\.env
```
3. 启动服务
```bash
uvicorn app.main:app --reload --app-dir src
```
## 迁移
```bash
alembic upgrade head
```

36
server/alembic.ini Normal file
View File

@@ -0,0 +1,36 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+psycopg://postgres:postgres@127.0.0.1:5432/x_financial
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers = console
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s

53
server/alembic/env.py Normal file
View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import get_settings
from app.db.base import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
settings = get_settings()
config.set_main_option("sqlalchemy.url", settings.resolved_database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

47
server/pyproject.toml Normal file
View File

@@ -0,0 +1,47 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "x-financial-server"
version = "0.1.0"
description = "Backend service for X-Financial reimbursement and approval platform."
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115.0,<1.0.0",
"uvicorn[standard]>=0.30.0,<1.0.0",
"sqlalchemy>=2.0.36,<3.0.0",
"alembic>=1.14.0,<2.0.0",
"psycopg[binary]>=3.2.0,<4.0.0",
"pydantic-settings>=2.6.0,<3.0.0",
"python-dotenv>=1.0.1,<2.0.0",
"email-validator>=2.2.0,<3.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3.0,<9.0.0",
"httpx>=0.28.0,<1.0.0",
"ruff>=0.8.0,<1.0.0",
]
redis = [
"redis>=5.2.0,<6.0.0",
]
[tool.setuptools]
package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
from collections.abc import Generator
from sqlalchemy.orm import Session
from app.db.session import get_session_factory
def get_db() -> Generator[Session, None, None]:
db = get_session_factory()()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,6 @@
from fastapi import APIRouter
from app.api.v1.router import router as v1_router
api_router = APIRouter()
api_router.include_router(v1_router)

View File

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

View File

@@ -0,0 +1 @@
__all__ = ["health", "employees", "reimbursements"]

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from fastapi import APIRouter, status
from app.core.bootstrap import build_bootstrap_state, persist_bootstrap_config
from app.core.config import get_settings
from app.schemas.bootstrap import BootstrapSetupPayload, BootstrapStateRead
router = APIRouter(prefix="/bootstrap")
@router.get("", response_model=BootstrapStateRead)
def get_bootstrap_state() -> BootstrapStateRead:
return build_bootstrap_state(get_settings())
@router.post("", response_model=BootstrapStateRead, status_code=status.HTTP_201_CREATED)
def initialize_bootstrap(payload: BootstrapSetupPayload) -> BootstrapStateRead:
return persist_bootstrap_config(payload, get_settings())

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.schemas.employee import EmployeeCreate, EmployeeRead
from app.services.employee import EmployeeService
router = APIRouter()
DbSession = Annotated[Session, Depends(get_db)]
@router.get("", response_model=list[EmployeeRead])
def list_employees(db: DbSession) -> list[EmployeeRead]:
return EmployeeService(db).list_employees()
@router.post("", response_model=EmployeeRead, status_code=status.HTTP_201_CREATED)
def create_employee(payload: EmployeeCreate, db: DbSession) -> EmployeeRead:
return EmployeeService(db).create_employee(payload)
@router.get("/{employee_id}", response_model=EmployeeRead)
def get_employee(employee_id: str, db: DbSession) -> EmployeeRead:
employee = EmployeeService(db).get_employee(employee_id)
if employee is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Employee not found")
return employee

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
from fastapi import APIRouter
from sqlalchemy import text
from app.core.config import get_settings
from app.db.session import get_engine
router = APIRouter(prefix="/health")
@router.get("")
def health_check() -> dict[str, object]:
settings = get_settings()
database_ok = False
database_error = None
if settings.setup_completed:
try:
with get_engine().connect() as connection:
connection.execute(text("SELECT 1"))
database_ok = True
except Exception as exc: # pragma: no cover - runtime connectivity branch
database_error = str(exc)
return {
"status": "ok" if database_ok else "degraded",
"database": {
"configured": settings.setup_completed,
"ok": database_ok,
"error": database_error,
},
"redis": {"configured": bool(settings.redis_url), "enabled": bool(settings.redis_url)},
}

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.schemas.reimbursement import ReimbursementCreate, ReimbursementRead
from app.services.reimbursement import ReimbursementService
router = APIRouter()
DbSession = Annotated[Session, Depends(get_db)]
@router.get("", response_model=list[ReimbursementRead])
def list_reimbursements(db: DbSession) -> list[ReimbursementRead]:
return ReimbursementService(db).list_reimbursements()
@router.post("", response_model=ReimbursementRead, status_code=status.HTTP_201_CREATED)
def create_reimbursement(payload: ReimbursementCreate, db: DbSession) -> ReimbursementRead:
return ReimbursementService(db).create_reimbursement(payload)
@router.get("/{request_id}", response_model=ReimbursementRead)
def get_reimbursement(request_id: str, db: DbSession) -> ReimbursementRead:
request = ReimbursementService(db).get_reimbursement(request_id)
if request is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Request not found")
return request

View File

@@ -0,0 +1,12 @@
from fastapi import APIRouter
from app.api.v1.endpoints.bootstrap import router as bootstrap_router
from app.api.v1.endpoints.employees import router as employees_router
from app.api.v1.endpoints.health import router as health_router
from app.api.v1.endpoints.reimbursements import router as reimbursements_router
router = APIRouter()
router.include_router(health_router, tags=["health"])
router.include_router(bootstrap_router, tags=["bootstrap"])
router.include_router(employees_router, prefix="/employees", tags=["employees"])
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])

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()

View File

@@ -0,0 +1 @@
__all__ = ["base", "session"]

View File

@@ -0,0 +1,6 @@
from app.db.base_class import Base
from app.models.approval import ApprovalRecord
from app.models.employee import Employee
from app.models.reimbursement import ReimbursementRequest
__all__ = ["Base", "Employee", "ReimbursementRequest", "ApprovalRecord"]

View File

@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

View File

@@ -0,0 +1,44 @@
from __future__ import annotations
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import get_settings
_engine: Engine | None = None
_session_factory: sessionmaker[Session] | None = None
def configure_session_factory() -> None:
global _engine, _session_factory
settings = get_settings()
if _engine is not None:
_engine.dispose()
_engine = create_engine(
settings.resolved_database_url,
echo=settings.sqlalchemy_echo,
pool_pre_ping=True,
)
_session_factory = sessionmaker(bind=_engine, autoflush=False, autocommit=False)
def get_engine() -> Engine:
global _engine
if _engine is None:
configure_session_factory()
return _engine
def get_session_factory() -> sessionmaker[Session]:
global _session_factory
if _session_factory is None:
configure_session_factory()
return _session_factory

37
server/src/app/main.py Normal file
View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.router import api_router
from app.core.config import get_settings
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(
title=settings.app_name,
debug=settings.app_debug,
version="0.1.0",
)
if settings.cors_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix=settings.api_v1_prefix)
@app.get("/", tags=["root"])
def root() -> dict[str, str]:
return {"message": f"{settings.app_name} is running"}
return app
app = create_app()

View File

@@ -0,0 +1,5 @@
from app.models.approval import ApprovalRecord
from app.models.employee import Employee
from app.models.reimbursement import ReimbursementRequest
__all__ = ["ApprovalRecord", "Employee", "ReimbursementRequest"]

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
class ApprovalRecord(Base):
__tablename__ = "approval_records"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
request_id: Mapped[str] = mapped_column(ForeignKey("reimbursement_requests.id"), index=True)
approver_id: Mapped[str | None] = mapped_column(ForeignKey("employees.id"), nullable=True)
action: Mapped[str] = mapped_column(String(50), index=True)
comment: Mapped[str | None] = mapped_column(Text(), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
request = relationship("ReimbursementRequest", back_populates="approval_records")

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
class Employee(Base):
__tablename__ = "employees"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
employee_no: Mapped[str] = mapped_column(String(50), unique=True, index=True)
name: Mapped[str] = mapped_column(String(100), index=True)
department: Mapped[str] = mapped_column(String(100), index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
reimbursement_requests = relationship("ReimbursementRequest", back_populates="employee")

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Numeric, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
class ReimbursementRequest(Base):
__tablename__ = "reimbursement_requests"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
request_no: Mapped[str] = mapped_column(String(50), unique=True, index=True)
employee_id: Mapped[str] = mapped_column(ForeignKey("employees.id"), index=True)
title: Mapped[str] = mapped_column(String(200))
category: Mapped[str] = mapped_column(String(50), index=True)
status: Mapped[str] = mapped_column(String(50), default="draft", index=True)
amount: Mapped[Decimal] = mapped_column(Numeric(12, 2))
reason: Mapped[str | None] = mapped_column(Text(), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
employee = relationship("Employee", back_populates="reimbursement_requests")
approval_records = relationship("ApprovalRecord", back_populates="request", cascade="all, delete-orphan")

View File

@@ -0,0 +1 @@
__all__ = ["employee", "reimbursement"]

View File

@@ -0,0 +1,20 @@
from sqlalchemy.orm import Session
from app.models.employee import Employee
class EmployeeRepository:
def __init__(self, db: Session) -> None:
self.db = db
def list(self) -> list[Employee]:
return self.db.query(Employee).order_by(Employee.created_at.desc()).all()
def get(self, employee_id: str) -> Employee | None:
return self.db.query(Employee).filter(Employee.id == employee_id).first()
def create(self, employee: Employee) -> Employee:
self.db.add(employee)
self.db.commit()
self.db.refresh(employee)
return employee

View File

@@ -0,0 +1,20 @@
from sqlalchemy.orm import Session
from app.models.reimbursement import ReimbursementRequest
class ReimbursementRepository:
def __init__(self, db: Session) -> None:
self.db = db
def list(self) -> list[ReimbursementRequest]:
return self.db.query(ReimbursementRequest).order_by(ReimbursementRequest.created_at.desc()).all()
def get(self, request_id: str) -> ReimbursementRequest | None:
return self.db.query(ReimbursementRequest).filter(ReimbursementRequest.id == request_id).first()
def create(self, request: ReimbursementRequest) -> ReimbursementRequest:
self.db.add(request)
self.db.commit()
self.db.refresh(request)
return request

View File

@@ -0,0 +1 @@
__all__ = ["employee", "reimbursement"]

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
from pydantic import BaseModel, EmailStr, Field, field_validator
class BootstrapCompanyRead(BaseModel):
name: str
code: str
admin_email: str
class BootstrapConnectionRead(BaseModel):
host: str
port: int
class BootstrapDatabaseRead(BaseModel):
driver: str
host: str
port: int
name: str
username: str
password_configured: bool
class BootstrapCacheRead(BaseModel):
enabled: bool
url: str
class BootstrapStateRead(BaseModel):
initialized: bool
company: BootstrapCompanyRead
web: BootstrapConnectionRead
server: BootstrapConnectionRead
database: BootstrapDatabaseRead
redis: BootstrapCacheRead
class BootstrapSetupPayload(BaseModel):
company_name: str = Field(min_length=2, max_length=80)
company_code: str = Field(default="", max_length=32)
admin_email: EmailStr | None = None
postgres_host: str = Field(min_length=1, max_length=255)
postgres_port: int = Field(default=5432, ge=1, le=65535)
postgres_db: str = Field(min_length=1, max_length=128)
postgres_user: str = Field(min_length=1, max_length=128)
postgres_password: str = Field(min_length=1, max_length=255)
redis_url: str | None = Field(default=None, max_length=255)
@field_validator(
"company_name",
"company_code",
"postgres_host",
"postgres_db",
"postgres_user",
"postgres_password",
"redis_url",
mode="before",
)
@classmethod
def strip_string(cls, value: str | None) -> str | None:
if value is None:
return None
return value.strip()

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr
class EmployeeCreate(BaseModel):
employee_no: str
name: str
department: str
email: EmailStr
class EmployeeRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
employee_no: str
name: str
department: str
email: EmailStr
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict
class ReimbursementCreate(BaseModel):
request_no: str
employee_id: str
title: str
category: str
amount: Decimal
reason: str | None = None
class ReimbursementRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
request_no: str
employee_id: str
title: str
category: str
status: str
amount: Decimal
reason: str | None
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1 @@
__all__ = ["employee", "reimbursement"]

View File

@@ -0,0 +1,20 @@
from sqlalchemy.orm import Session
from app.models.employee import Employee
from app.repositories.employee import EmployeeRepository
from app.schemas.employee import EmployeeCreate
class EmployeeService:
def __init__(self, db: Session) -> None:
self.repository = EmployeeRepository(db)
def list_employees(self) -> list[Employee]:
return self.repository.list()
def get_employee(self, employee_id: str) -> Employee | None:
return self.repository.get(employee_id)
def create_employee(self, payload: EmployeeCreate) -> Employee:
employee = Employee(**payload.model_dump())
return self.repository.create(employee)

View File

@@ -0,0 +1,20 @@
from sqlalchemy.orm import Session
from app.models.reimbursement import ReimbursementRequest
from app.repositories.reimbursement import ReimbursementRepository
from app.schemas.reimbursement import ReimbursementCreate
class ReimbursementService:
def __init__(self, db: Session) -> None:
self.repository = ReimbursementRepository(db)
def list_reimbursements(self) -> list[ReimbursementRequest]:
return self.repository.list()
def get_reimbursement(self, request_id: str) -> ReimbursementRequest | None:
return self.repository.get(request_id)
def create_reimbursement(self, payload: ReimbursementCreate) -> ReimbursementRequest:
request = ReimbursementRequest(**payload.model_dump(), status="draft")
return self.repository.create(request)

View File

@@ -0,0 +1,92 @@
Metadata-Version: 2.4
Name: x-financial-server
Version: 0.1.0
Summary: Backend service for X-Financial reimbursement and approval platform.
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: fastapi<1.0.0,>=0.115.0
Requires-Dist: uvicorn[standard]<1.0.0,>=0.30.0
Requires-Dist: sqlalchemy<3.0.0,>=2.0.36
Requires-Dist: alembic<2.0.0,>=1.14.0
Requires-Dist: psycopg[binary]<4.0.0,>=3.2.0
Requires-Dist: pydantic-settings<3.0.0,>=2.6.0
Requires-Dist: python-dotenv<2.0.0,>=1.0.1
Requires-Dist: email-validator<3.0.0,>=2.2.0
Provides-Extra: dev
Requires-Dist: pytest<9.0.0,>=8.3.0; extra == "dev"
Requires-Dist: httpx<1.0.0,>=0.28.0; extra == "dev"
Requires-Dist: ruff<1.0.0,>=0.8.0; extra == "dev"
Provides-Extra: redis
Requires-Dist: redis<6.0.0,>=5.2.0; extra == "redis"
# Server
后端已按 `FastAPI + PostgreSQL + SQLAlchemy + Alembic` 起好基础工程。
## 为什么先选 PostgreSQL
这个项目是报销、审批、员工、流程、审计记录为主,核心特点是:
- 强事务
- 多表关联明显
- 审批流和审计日志需要一致性
- 后续大概率要做复杂查询、统计和条件筛选
这类系统优先选关系型数据库更合适,`PostgreSQL` 是当前默认推荐。
## Redis 要不要现在上
现在 **不是必须**。
先不把 Redis 作为启动前置,原因很直接:
- 当前第一阶段先把核心业务表、接口、权限、审批流跑通
- 如果一开始就把 Redis 绑死,会增加部署和排障复杂度
Redis 更适合后面这些场景:
- 登录态 / token 黑名单
- 热点数据缓存
- 限流
- 分布式锁
- 消息队列 / 后台任务
所以现在的策略是:
- 主数据库:`PostgreSQL`
- Redis`可选能力`,配置已预留,但不是必需依赖
## 目录
- `src/app/`:应用代码
- `alembic/`:数据库迁移
- `tests/`:测试
## 启动
1. 创建虚拟环境并安装依赖
```bash
cd server
python -m venv .venv
.venv\\Scripts\\activate
pip install -e .[dev]
```
2. 在项目根目录准备环境变量
```bash
copy ..\\.env.example ..\\.env
```
3. 启动服务
```bash
uvicorn app.main:app --reload --app-dir src
```
## 迁移
```bash
alembic upgrade head
```

View File

@@ -0,0 +1,41 @@
README.md
pyproject.toml
src/app/__init__.py
src/app/main.py
src/app/api/__init__.py
src/app/api/deps.py
src/app/api/router.py
src/app/api/v1/__init__.py
src/app/api/v1/router.py
src/app/api/v1/endpoints/__init__.py
src/app/api/v1/endpoints/bootstrap.py
src/app/api/v1/endpoints/employees.py
src/app/api/v1/endpoints/health.py
src/app/api/v1/endpoints/reimbursements.py
src/app/core/__init__.py
src/app/core/bootstrap.py
src/app/core/config.py
src/app/db/__init__.py
src/app/db/base.py
src/app/db/base_class.py
src/app/db/session.py
src/app/models/__init__.py
src/app/models/approval.py
src/app/models/employee.py
src/app/models/reimbursement.py
src/app/repositories/__init__.py
src/app/repositories/employee.py
src/app/repositories/reimbursement.py
src/app/schemas/__init__.py
src/app/schemas/bootstrap.py
src/app/schemas/employee.py
src/app/schemas/reimbursement.py
src/app/services/__init__.py
src/app/services/employee.py
src/app/services/reimbursement.py
src/x_financial_server.egg-info/PKG-INFO
src/x_financial_server.egg-info/SOURCES.txt
src/x_financial_server.egg-info/dependency_links.txt
src/x_financial_server.egg-info/requires.txt
src/x_financial_server.egg-info/top_level.txt
tests/test_imports.py

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,16 @@
fastapi<1.0.0,>=0.115.0
uvicorn[standard]<1.0.0,>=0.30.0
sqlalchemy<3.0.0,>=2.0.36
alembic<2.0.0,>=1.14.0
psycopg[binary]<4.0.0,>=3.2.0
pydantic-settings<3.0.0,>=2.6.0
python-dotenv<2.0.0,>=1.0.1
email-validator<3.0.0,>=2.2.0
[dev]
pytest<9.0.0,>=8.3.0
httpx<1.0.0,>=0.28.0
ruff<1.0.0,>=0.8.0
[redis]
redis<6.0.0,>=5.2.0

View File

@@ -0,0 +1 @@
app

227
server/start.sh Normal file
View File

@@ -0,0 +1,227 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
ROOT_ENV_FILE="$ROOT_DIR/.env"
ROOT_ENV_EXAMPLE_FILE="$ROOT_DIR/.env.example"
VENV_DIR="$SCRIPT_DIR/.venv"
MODE="${1:-start}"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
if [ ! -f "$ROOT_ENV_FILE" ]; then
if [ -f "$ROOT_ENV_EXAMPLE_FILE" ]; then
warn "Root .env not found. Creating it from .env.example"
cp "$ROOT_ENV_EXAMPLE_FILE" "$ROOT_ENV_FILE"
else
error "Root .env and .env.example are both missing."
fi
fi
set -a
. "$ROOT_ENV_FILE"
set +a
SERVER_HOST="${SERVER_HOST:-127.0.0.1}"
SERVER_PORT="${SERVER_PORT:-8000}"
is_wsl() {
grep -qi microsoft /proc/version 2>/dev/null
}
is_windows_mount() {
case "$SCRIPT_DIR" in
/mnt/*) return 0 ;;
*) return 1 ;;
esac
}
find_unix_python() {
if command -v python >/dev/null 2>&1; then
echo "python"
return 0
fi
if command -v python3 >/dev/null 2>&1; then
echo "python3"
return 0
fi
return 1
}
find_windows_python() {
if command -v py.exe >/dev/null 2>&1; then
echo "py.exe -3"
return 0
fi
if command -v python.exe >/dev/null 2>&1; then
echo "python.exe"
return 0
fi
return 1
}
venv_python_path() {
if [ "${VENV_LAYOUT:-auto}" = "windows" ]; then
if [ -x "$VENV_DIR/Scripts/python.exe" ]; then
echo "$VENV_DIR/Scripts/python.exe"
return 0
fi
return 1
fi
if [ "${VENV_LAYOUT:-auto}" = "unix" ]; then
if [ -x "$VENV_DIR/bin/python" ]; then
echo "$VENV_DIR/bin/python"
return 0
fi
return 1
fi
if [ -x "$VENV_DIR/Scripts/python.exe" ]; then
echo "$VENV_DIR/Scripts/python.exe"
return 0
fi
if [ -x "$VENV_DIR/bin/python" ]; then
echo "$VENV_DIR/bin/python"
return 0
fi
return 1
}
run_bootstrap_python() {
$PYTHON_BOOTSTRAP "$@"
}
dependencies_ready() {
"$PYTHON_BIN" -c "import fastapi, uvicorn, sqlalchemy, alembic, pydantic_settings" >/dev/null 2>&1
}
pip_ready() {
"$PYTHON_BIN" -m pip --version >/dev/null 2>&1
}
create_venv() {
info "Creating virtual environment at ./server/.venv"
if [ -d "$VENV_DIR" ]; then
rm -rf "$VENV_DIR"
fi
run_bootstrap_python -m venv "$VENV_DIR"
if ! PYTHON_BIN="$(venv_python_path)"; then
error "Virtual environment was not created successfully."
fi
}
ensure_pip() {
if pip_ready; then
return 0
fi
warn "pip is missing in .venv, attempting repair"
if "$PYTHON_BIN" -m ensurepip --upgrade >/dev/null 2>&1 && pip_ready; then
info "pip restored successfully"
return 0
fi
warn "Recreating .venv because pip repair failed"
rm -rf "$VENV_DIR"
create_venv
if "$PYTHON_BIN" -m ensurepip --upgrade >/dev/null 2>&1 && pip_ready; then
info "pip restored after recreating .venv"
return 0
fi
error "pip could not be created inside .venv. Install Python with venv and pip support, then rerun the script."
}
ensure_python_bootstrap() {
if is_wsl && is_windows_mount; then
if find_windows_python >/dev/null 2>&1; then
PYTHON_BOOTSTRAP="$(find_windows_python)"
VENV_LAYOUT="windows"
info "Detected WSL on a Windows-mounted project"
info "Using Windows Python directly from bash"
return 0
fi
if find_unix_python >/dev/null 2>&1; then
PYTHON_BOOTSTRAP="$(find_unix_python)"
VENV_LAYOUT="unix"
warn "Windows Python not found, falling back to WSL Python"
return 0
fi
error "Neither Windows Python nor WSL Python is available in PATH."
fi
if ! PYTHON_BOOTSTRAP="$(find_unix_python)"; then
error "Python is not installed or not available in PATH. Install Python 3.11+ first so the script can create server/.venv automatically."
fi
VENV_LAYOUT="unix"
}
ensure_dependencies() {
ensure_python_bootstrap
if ! PYTHON_BIN="$(venv_python_path)"; then
warn "Python virtual environment not found"
create_venv
fi
ensure_pip
if dependencies_ready; then
info "Server dependencies are ready."
return 0
fi
warn "Server dependencies are missing or incomplete"
info "Running .venv Python dependency installation"
"$PYTHON_BIN" -m pip install --upgrade pip
"$PYTHON_BIN" -m pip install -e ".[dev]"
if ! dependencies_ready; then
error "Server dependencies are still incomplete after installation."
fi
info "Server dependencies are ready."
}
start_server() {
info "Starting FastAPI server..."
info "Access: http://$SERVER_HOST:$SERVER_PORT"
echo ""
exec "$PYTHON_BIN" -m uvicorn app.main:app --reload --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT"
}
case "$MODE" in
deps)
ensure_dependencies
;;
start)
ensure_dependencies
start_server
;;
*)
error "Unknown mode: $MODE. Use one of: deps, start"
;;
esac

View File

@@ -0,0 +1,2 @@
def test_placeholder() -> None:
assert True

148
start.sh
View File

@@ -2,6 +2,150 @@
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR/web" ENV_FILE="$SCRIPT_DIR/.env"
ENV_EXAMPLE_FILE="$SCRIPT_DIR/.env.example"
MODE="${1:-all}"
exec ./start.sh RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
if [ ! -f "$ENV_FILE" ]; then
if [ -f "$ENV_EXAMPLE_FILE" ]; then
warn ".env not found. Creating it from .env.example"
cp "$ENV_EXAMPLE_FILE" "$ENV_FILE"
else
error ".env and .env.example are both missing."
fi
fi
set -a
. "$ENV_FILE"
set +a
SERVER_STARTUP_TIMEOUT="${SERVER_STARTUP_TIMEOUT:-300}"
SETUP_COMPLETED="${SETUP_COMPLETED:-false}"
prepare_web() {
info "Preparing web dependencies..."
(
cd "$SCRIPT_DIR/web"
./start.sh deps
)
}
prepare_server() {
info "Preparing server dependencies..."
(
cd "$SCRIPT_DIR/server"
./start.sh deps
)
}
start_web() {
prepare_web
cd "$SCRIPT_DIR/web"
exec ./start.sh start
}
start_server() {
prepare_server
cd "$SCRIPT_DIR/server"
exec ./start.sh start
}
start_setup_web() {
warn "Initial setup is not completed. Starting web only."
warn "Finish the setup form first. After setup is saved, run ./start.sh again to launch FastAPI as well."
prepare_web
cd "$SCRIPT_DIR/web"
exec ./start.sh start
}
start_all() {
local server_pid=""
prepare_web
prepare_server
cleanup() {
if [ -n "$server_pid" ] && kill -0 "$server_pid" 2>/dev/null; then
warn "Stopping FastAPI server..."
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
fi
}
trap cleanup EXIT INT TERM
info "Starting FastAPI server..."
(
cd "$SCRIPT_DIR/server"
./start.sh start
) &
server_pid=$!
wait_for_server() {
local base_url="http://${SERVER_HOST:-127.0.0.1}:${SERVER_PORT:-8000}${API_V1_PREFIX:-/api/v1}/bootstrap"
local attempt=1
local max_attempts="$SERVER_STARTUP_TIMEOUT"
if ! command -v curl >/dev/null 2>&1; then
warn "curl not found, skipping backend readiness check."
return 0
fi
info "Waiting for FastAPI bootstrap endpoint..."
while [ "$attempt" -le "$max_attempts" ]; do
if ! kill -0 "$server_pid" 2>/dev/null; then
wait "$server_pid" 2>/dev/null || true
error "FastAPI process exited before becoming ready. Run ./server/start.sh start directly to inspect the backend error."
fi
if curl --silent --fail "$base_url" >/dev/null 2>&1; then
info "FastAPI bootstrap endpoint is ready."
return 0
fi
if [ $((attempt % 15)) -eq 0 ]; then
warn "FastAPI is still starting. First run may take longer while .venv and dependencies are prepared."
fi
sleep 1
attempt=$((attempt + 1))
done
error "FastAPI did not become ready within ${SERVER_STARTUP_TIMEOUT}s: $base_url"
}
wait_for_server
info "Starting web frontend..."
cd "$SCRIPT_DIR/web"
./start.sh start
}
case "$MODE" in
web)
start_web
;;
server)
start_server
;;
all)
if [ "$SETUP_COMPLETED" = "true" ]; then
start_all
else
start_setup_web
fi
;;
*)
error "Unknown mode: $MODE. Use one of: web, server, all"
;;
esac