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__ = ["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