# Phase 1: 项目基建（W1）

> **目标：** 搭建前后端项目骨架、定义数据库模型、配置开发环境，确保团队可以立即开始业务开发。  
> **周期：** 第 1 周  
> **任务数：** 4 个  
> **可并行：** 后端骨架 / 前端骨架 / Docker Compose 可完全并行  
> **前置依赖：** 无

---

## 本阶段交付物

| 交付物 | 说明 |
|---|---|
| 后端项目骨架 | FastAPI + SQLAlchemy + Alembic，可运行的健康检查 |
| 数据库 Schema | 全部 12 张表的 ORM 模型 + Alembic 迁移 |
| 前端项目骨架 | Vue3 + TS + Ant Design Vue，路由和 API 层配置 |
| 开发环境 | Docker Compose（PostgreSQL + Redis + MinIO） |

---

## 任务清单

### Task 1.1: 后端项目骨架搭建

**负责人：** 后端工程师 A  
**预计工时：** 1 天  

**Files:**
- Create: `backend/app/__init__.py`
- Create: `backend/app/main.py`
- Create: `backend/app/core/config.py`
- Create: `backend/app/core/database.py`
- Create: `backend/app/core/dependencies.py`
- Create: `backend/app/api/__init__.py`
- Create: `backend/app/api/v1/__init__.py`
- Create: `backend/app/api/v1/router.py`
- Create: `backend/app/models/__init__.py`
- Create: `backend/app/schemas/__init__.py`
- Create: `backend/app/services/__init__.py`
- Create: `backend/requirements.txt`
- Create: `backend/pyproject.toml`
- Create: `backend/Dockerfile`
- Create: `backend/alembic.ini`
- Create: `backend/alembic/env.py`
- Test: `backend/tests/__init__.py`
- Test: `backend/tests/conftest.py`
- Test: `backend/tests/test_health.py`

- [ ] **Step 1: 初始化后端项目结构**

创建 FastAPI 项目骨架，使用以下目录结构：

```
backend/
├── app/
│   ├── __init__.py
│   ├── main.py                  # FastAPI 应用入口
│   ├── core/
│   │   ├── config.py            # Settings（Pydantic BaseSettings）
│   │   ├── database.py          # SQLAlchemy async engine + session
│   │   └── dependencies.py      # 通用依赖注入（db session, 当前用户等）
│   ├── api/
│   │   └── v1/
│   │       ├── __init__.py
│   │       ├── router.py        # v1 路由聚合
│   │       ├── tasks.py         # 报销任务 API
│   │       ├── documents.py     # 票据附件 API
│   │       ├── precheck.py      # 预审结果 API
│   │       └── supplements.py   # 补件 API
│   ├── models/                  # SQLAlchemy ORM models
│   │   ├── __init__.py
│   │   ├── task.py
│   │   ├── reimbursement.py
│   │   ├── document.py
│   │   ├── rule.py
│   │   └── audit.py
│   ├── schemas/                 # Pydantic schemas
│   │   ├── __init__.py
│   │   ├── task.py
│   │   ├── reimbursement.py
│   │   ├── document.py
│   │   └── rule.py
│   ├── services/                # 业务逻辑层
│   │   ├── __init__.py
│   │   ├── task_service.py
│   │   ├── document_service.py
│   │   ├── ocr_service.py
│   │   ├── rule_engine.py
│   │   └── sync_service.py
│   └── agents/                  # Agent 编排层
│       ├── __init__.py
│       ├── orchestrator.py
│       ├── intake_agent.py
│       ├── parse_agent.py
│       ├── rule_check_agent.py
│       ├── explain_agent.py
│       └── sync_agent.py
├── alembic/
│   ├── env.py
│   └── versions/
├── alembic.ini
├── requirements.txt
├── pyproject.toml
├── Dockerfile
└── tests/
    ├── __init__.py
    ├── conftest.py
    └── test_health.py
```

- [ ] **Step 2: 编写核心配置文件**

`backend/app/core/config.py` 使用 Pydantic BaseSettings：

```python
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    # App
    APP_NAME: str = "AI Reimbursement Agent"
    APP_VERSION: str = "0.1.0"
    DEBUG: bool = True

    # Database
    DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/x_financial"

    # Redis
    REDIS_URL: str = "redis://localhost:6379/0"

    # MinIO / S3
    MINIO_ENDPOINT: str = "localhost:9000"
    MINIO_ACCESS_KEY: str = "minioadmin"
    MINIO_SECRET_KEY: str = "minioadmin"
    MINIO_BUCKET: str = "reimbursement"

    # OCR
    OCR_PROVIDER: str = "baidu"  # baidu | tencent | mock
    BAIDU_OCR_API_KEY: str = ""
    BAIDU_OCR_SECRET_KEY: str = ""

    # LLM
    LLM_PROVIDER: str = "openai"
    LLM_API_KEY: str = ""
    LLM_MODEL: str = "gpt-4o-mini"
    LLM_BASE_URL: str = ""

    class Config:
        env_file = ".env"

settings = Settings()
```

- [ ] **Step 3: 编写数据库连接和 FastAPI 入口**

`backend/app/core/database.py`:

```python
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings

engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)

class Base(DeclarativeBase):
    pass

async def get_db() -> AsyncSession:
    async with async_session() as session:
        yield session
```

`backend/app/main.py`:

```python
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.api.v1.router import api_router

app = FastAPI(title=settings.APP_NAME, version=settings.APP_VERSION)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

app.include_router(api_router, prefix="/api/v1")

@app.get("/health")
async def health():
    return {"status": "ok", "version": settings.APP_VERSION}
```

- [ ] **Step 4: 编写 requirements.txt**

```
fastapi==0.115.0
uvicorn[standard]==0.30.0
sqlalchemy[asyncio]==2.0.35
asyncpg==0.30.0
alembic==1.13.0
pydantic==2.9.0
pydantic-settings==2.5.0
python-multipart==0.0.9
httpx==0.27.0
redis==5.1.0
minio==7.2.9
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
pytest==8.3.0
pytest-asyncio==0.24.0
```

- [ ] **Step 5: 编写健康检查测试**

`backend/tests/conftest.py`:

```python
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app

@pytest.fixture
async def client():
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as ac:
        yield ac
```

`backend/tests/test_health.py`:

```python
import pytest

@pytest.mark.asyncio
async def test_health(client):
    response = await client.get("/health")
    assert response.status_code == 200
    data = response.json()
    assert data["status"] == "ok"
```

- [ ] **Step 6: 运行测试确认骨架可用**

Run: `cd backend && pip install -r requirements.txt && pytest tests/test_health.py -v`
Expected: PASS

- [ ] **Step 7: Commit**

```bash
git add backend/
git commit -m "feat: 初始化后端项目骨架（FastAPI + SQLAlchemy + Alembic）"
```

---

### Task 1.2: 数据库 Schema + 迁移

**负责人：** 后端工程师 A  
**预计工时：** 1.5 天  
**前置依赖：** Task 1.1  

**Files:**
- Create: `backend/app/models/base.py`
- Create: `backend/app/models/task.py`
- Create: `backend/app/models/reimbursement.py`
- Create: `backend/app/models/document.py`
- Create: `backend/app/models/rule.py`
- Create: `backend/app/models/audit.py`
- Create: `backend/app/models/enums.py`
- Modify: `backend/app/models/__init__.py`
- Test: `backend/tests/test_models.py`

- [ ] **Step 1: 定义枚举类型**

`backend/app/models/enums.py`:

```python
import enum

class TaskStatus(str, enum.Enum):
    CREATED = "created"
    MATERIAL_COLLECTING = "material_collecting"
    PARSING = "parsing"
    DRAFT_GENERATED = "draft_generated"
    PRECHECKING = "prechecking"
    NEED_SUPPLEMENT = "need_supplement"
    PENDING_USER_CONFIRM = "pending_user_confirm"
    SUBMITTING = "submitting"
    SYNCED = "synced"
    SYNC_FAILED = "sync_failed"
    CLOSED = "closed"

class ExpenseType(str, enum.Enum):
    TRAVEL_TRANSPORT = "travel_transport"
    TRAVEL_HOTEL = "travel_hotel"
    TRAVEL_MEAL = "travel_meal"
    LOCAL_TRANSPORT = "local_transport"
    BUSINESS_MEAL = "business_meal"
    OFFICE_SUPPLY = "office_supply"
    COMMUNICATION = "communication"
    OTHER = "other"

class DocumentType(str, enum.Enum):
    VAT_INVOICE = "vat_invoice"
    TRAIN_TICKET = "train_ticket"
    FLIGHT_ITINERARY = "flight_itinerary"
    TAXI_RECEIPT = "taxi_receipt"
    HOTEL_BILL = "hotel_bill"
    PAYMENT_SCREENSHOT = "payment_screenshot"
    TRAVEL_ORDER = "travel_order"
    OTHER_ATTACHMENT = "other_attachment"

class RiskLevel(str, enum.Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
    BLOCKED = "blocked"

class RuleAction(str, enum.Enum):
    PASS = "pass"
    WARN = "warn"
    REQUIRE_EXPLANATION = "require_explanation"
    REQUIRE_ATTACHMENT = "require_attachment"
    REQUIRE_APPROVAL = "require_approval"
    BLOCK = "block"

class SyncStatus(str, enum.Enum):
    SUCCESS = "success"
    FAILED = "failed"
    RETRYING = "retrying"
    PENDING = "pending"
```

- [ ] **Step 2: 定义 Base Mixin**

`backend/app/models/base.py`:

```python
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column

def generate_id():
    return str(uuid.uuid4())

class TimestampMixin:
    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())

class IDMixin:
    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_id)
```

- [ ] **Step 3: 定义 ReimbursementTask 模型**

`backend/app/models/task.py`:

```python
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import IDMixin, TimestampMixin, Base
from app.models.enums import TaskStatus

class ReimbursementTask(IDMixin, TimestampMixin, Base):
    __tablename__ = "reimbursement_task"

    user_id: Mapped[str] = mapped_column(String(36), index=True)
    company_id: Mapped[str] = mapped_column(String(36), index=True)
    task_type: Mapped[str] = mapped_column(String(50), default="travel_expense")
    status: Mapped[TaskStatus] = mapped_column(default=TaskStatus.CREATED)
    user_intent: Mapped[str | None] = mapped_column(Text, nullable=True)
    current_agent: Mapped[str | None] = mapped_column(String(50), nullable=True)

    documents = relationship("ExpenseDocument", back_populates="task", lazy="selectin")
    reimbursement = relationship("ShadowReimbursement", back_populates="task", uselist=False, lazy="selectin")
```

- [ ] **Step 4: 定义 ShadowReimbursement + ReimbursementItem + SupplementRequest + SyncRecord**

`backend/app/models/reimbursement.py` — 字段按开发文档 5.2.2 ~ 5.2.4, 5.2.7, 5.2.8 节：

```python
from sqlalchemy import String, Text, Numeric, Date, ForeignKey, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from decimal import Decimal
from datetime import date
from app.models.base import IDMixin, TimestampMixin, Base
from app.models.enums import RiskLevel, SyncStatus

class ShadowReimbursement(IDMixin, TimestampMixin, Base):
    __tablename__ = "shadow_reimbursement"

    task_id: Mapped[str] = mapped_column(String(36), ForeignKey("reimbursement_task.id"), unique=True)
    applicant_id: Mapped[str] = mapped_column(String(36))
    department_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
    cost_center_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
    project_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
    reimbursement_type: Mapped[str] = mapped_column(String(50))
    reason: Mapped[str | None] = mapped_column(Text, nullable=True)
    total_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=Decimal("0"))
    currency: Mapped[str] = mapped_column(String(10), default="CNY")
    risk_level: Mapped[str | None] = mapped_column(String(20), nullable=True)
    precheck_status: Mapped[str | None] = mapped_column(String(30), nullable=True)
    backend_system: Mapped[str | None] = mapped_column(String(50), nullable=True)
    backend_bill_id: Mapped[str | None] = mapped_column(String(50), nullable=True)
    sync_status: Mapped[str | None] = mapped_column(String(20), nullable=True)

    task = relationship("ReimbursementTask", back_populates="reimbursement")
    items = relationship("ReimbursementItem", back_populates="reimbursement", lazy="selectin")

class ReimbursementItem(IDMixin, TimestampMixin, Base):
    __tablename__ = "reimbursement_item"

    reimbursement_id: Mapped[str] = mapped_column(String(36), ForeignKey("shadow_reimbursement.id"))
    expense_type: Mapped[str] = mapped_column(String(50))
    amount: Mapped[Decimal] = mapped_column(Numeric(12, 2))
    tax_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
    occurred_at: Mapped[date | None] = mapped_column(Date, nullable=True)
    city: Mapped[str | None] = mapped_column(String(50), nullable=True)
    vendor_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
    invoice_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
    policy_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
    risk_level: Mapped[str | None] = mapped_column(String(20), nullable=True)
    remark: Mapped[str | None] = mapped_column(Text, nullable=True)

    reimbursement = relationship("ShadowReimbursement", back_populates="items")

class SupplementRequest(IDMixin, TimestampMixin, Base):
    __tablename__ = "supplement_request"

    reimbursement_id: Mapped[str] = mapped_column(String(36), ForeignKey("shadow_reimbursement.id"))
    request_type: Mapped[str] = mapped_column(String(30))  # attachment / explanation / field_modify
    target_item_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
    message: Mapped[str] = mapped_column(Text)
    status: Mapped[str] = mapped_column(String(20), default="pending")  # pending / resolved / closed
    user_response: Mapped[str | None] = mapped_column(Text, nullable=True)
    resolved_at: Mapped[date | None] = mapped_column(Date, nullable=True)

class SyncRecord(IDMixin, TimestampMixin, Base):
    __tablename__ = "sync_record"

    reimbursement_id: Mapped[str] = mapped_column(String(36), ForeignKey("shadow_reimbursement.id"))
    target_system: Mapped[str] = mapped_column(String(50))
    request_payload: Mapped[dict | None] = mapped_column(Text, nullable=True)  # JSON string
    response_payload: Mapped[dict | None] = mapped_column(Text, nullable=True)  # JSON string
    sync_status: Mapped[str] = mapped_column(String(20), default="pending")
    backend_bill_id: Mapped[str | None] = mapped_column(String(50), nullable=True)
    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
```

- [ ] **Step 5: 定义 ExpenseDocument 模型**

`backend/app/models/document.py` — 字段按开发文档 5.2.4 节：

```python
from sqlalchemy import String, Text, Numeric, Date, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from decimal import Decimal
from datetime import date
from app.models.base import IDMixin, TimestampMixin, Base

class ExpenseDocument(IDMixin, TimestampMixin, Base):
    __tablename__ = "expense_document"

    reimbursement_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("shadow_reimbursement.id"), nullable=True)
    item_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
    task_id: Mapped[str] = mapped_column(String(36), ForeignKey("reimbursement_task.id"))
    document_type: Mapped[str] = mapped_column(String(30))
    file_url: Mapped[str] = mapped_column(String(500))
    ocr_status: Mapped[str] = mapped_column(String(20), default="pending")  # pending / processing / done / failed
    extracted_json: Mapped[dict | None] = mapped_column(Text, nullable=True)
    invoice_code: Mapped[str | None] = mapped_column(String(30), nullable=True)
    invoice_number: Mapped[str | None] = mapped_column(String(30), nullable=True)
    invoice_date: Mapped[date | None] = mapped_column(Date, nullable=True)
    amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
    tax_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
    seller_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
    buyer_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
    verify_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
    duplicate_status: Mapped[str | None] = mapped_column(String(20), nullable=True)

    task = relationship("ReimbursementTask", back_populates="documents")
```

- [ ] **Step 6: 定义 ExpenseRule + RuleHit 模型**

`backend/app/models/rule.py` — 字段按开发文档 5.2.5, 5.2.6 节：

```python
from sqlalchemy import String, Text, Boolean
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import IDMixin, TimestampMixin, Base

class ExpenseRule(IDMixin, TimestampMixin, Base):
    __tablename__ = "expense_rule"

    rule_code: Mapped[str] = mapped_column(String(50), unique=True)
    rule_name: Mapped[str] = mapped_column(String(100))
    expense_type: Mapped[str] = mapped_column(String(50))
    condition_json: Mapped[dict] = mapped_column(Text)  # JSON string
    action: Mapped[str] = mapped_column(String(30))
    severity: Mapped[str] = mapped_column(String(20))
    message_template: Mapped[str] = mapped_column(Text)
    policy_ref: Mapped[str | None] = mapped_column(String(200), nullable=True)
    enabled: Mapped[bool] = mapped_column(Boolean, default=True)
    version: Mapped[str] = mapped_column(String(10), default="1.0")

class RuleHit(IDMixin, TimestampMixin, Base):
    __tablename__ = "rule_hit"

    reimbursement_id: Mapped[str] = mapped_column(String(36))
    item_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
    rule_id: Mapped[str] = mapped_column(String(36))
    severity: Mapped[str] = mapped_column(String(20))
    hit_result: Mapped[dict | None] = mapped_column(Text, nullable=True)  # JSON string
    explanation: Mapped[str | None] = mapped_column(Text, nullable=True)
    suggestion: Mapped[str | None] = mapped_column(Text, nullable=True)
```

- [ ] **Step 7: 定义 AuditLog 模型**

`backend/app/models/audit.py`:

```python
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import IDMixin, TimestampMixin, Base

class AuditLog(IDMixin, TimestampMixin, Base):
    __tablename__ = "audit_log"

    action: Mapped[str] = mapped_column(String(50), index=True)  # upload / ocr / agent / rule_hit / supplement / confirm / sync
    actor: Mapped[str] = mapped_column(String(36), index=True)
    target_type: Mapped[str] = mapped_column(String(50))  # task / document / reimbursement / rule
    target_id: Mapped[str] = mapped_column(String(36))
    detail: Mapped[str | None] = mapped_column(Text, nullable=True)  # JSON string
```

- [ ] **Step 8: 更新 `models/__init__.py` 导出所有模型**

```python
from app.models.task import ReimbursementTask
from app.models.reimbursement import ShadowReimbursement, ReimbursementItem, SupplementRequest, SyncRecord
from app.models.document import ExpenseDocument
from app.models.rule import ExpenseRule, RuleHit
from app.models.audit import AuditLog
from app.models.base import Base

__all__ = [
    "ReimbursementTask", "ShadowReimbursement", "ReimbursementItem",
    "ExpenseDocument", "ExpenseRule", "RuleHit",
    "SupplementRequest", "SyncRecord", "AuditLog", "Base",
]
```

- [ ] **Step 9: 生成 Alembic 迁移**

Run: `cd backend && alembic revision --autogenerate -m "init schema"`
Run: `cd backend && alembic upgrade head`

- [ ] **Step 10: 编写模型测试**

`backend/tests/test_models.py` — 验证所有表能正确创建和插入数据。

- [ ] **Step 11: Commit**

```bash
git add backend/
git commit -m "feat: 完成所有数据模型定义和数据库迁移"
```

---

### Task 1.3: 前端项目骨架搭建

**负责人：** 前端工程师  
**预计工时：** 1 天  
**前置依赖：** 无（可与 Task 1.1 并行）

**Files:**
- Create: Vue3 + TypeScript 项目（Vite 初始化）
- Create: `frontend/src/router/index.ts`
- Create: `frontend/src/stores/`
- Create: `frontend/src/api/`
- Create: `frontend/src/views/`
- Create: `frontend/src/components/`
- Create: `frontend/src/layouts/`

- [ ] **Step 1: 初始化 Vue3 项目**

```bash
npm create vite@latest frontend -- --template vue-ts
cd frontend
npm install ant-design-vue @ant-design/icons-vue vue-router pinia axios dayjs
```

目录结构：

```
frontend/
├── src/
│   ├── api/                  # API 调用
│   │   ├── index.ts          # axios 实例
│   │   ├── task.ts           # 报销任务 API
│   │   ├── document.ts       # 票据 API
│   │   └── precheck.ts       # 预审 API
│   ├── components/           # 通用组件
│   │   ├── FileUpload.vue
│   │   ├── ExpenseTable.vue
│   │   └── RuleHitCard.vue
│   ├── layouts/
│   │   └── MainLayout.vue
│   ├── router/
│   │   └── index.ts
│   ├── stores/
│   │   ├── task.ts
│   │   └── user.ts
│   ├── views/
│   │   ├── HomeView.vue         # 报销入口
│   │   ├── UploadView.vue       # 票据上传
│   │   ├── DraftView.vue        # 报销草稿
│   │   ├── PrecheckView.vue     # 预审结果
│   │   ├── SupplementView.vue   # 补件交互
│   │   ├── ConfirmView.vue      # 提交确认
│   │   └── AuditView.vue        # 审计日志
│   ├── App.vue
│   └── main.ts
├── index.html
├── vite.config.ts
├── tsconfig.json
└── package.json
```

- [ ] **Step 2: 配置路由和布局**

`frontend/src/router/index.ts`:

```typescript
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  { path: '/', name: 'home', component: () => import('@/views/HomeView.vue') },
  { path: '/task/:taskId/upload', name: 'upload', component: () => import('@/views/UploadView.vue') },
  { path: '/task/:taskId/draft', name: 'draft', component: () => import('@/views/DraftView.vue') },
  { path: '/task/:taskId/precheck', name: 'precheck', component: () => import('@/views/PrecheckView.vue') },
  { path: '/task/:taskId/supplement', name: 'supplement', component: () => import('@/views/SupplementView.vue') },
  { path: '/task/:taskId/confirm', name: 'confirm', component: () => import('@/views/ConfirmView.vue') },
  { path: '/audit', name: 'audit', component: () => import('@/views/AuditView.vue') },
]

export default createRouter({ history: createWebHistory(), routes })
```

- [ ] **Step 3: 配置 API 封装**

`frontend/src/api/index.ts`:

```typescript
import axios from 'axios'

const api = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1',
  timeout: 30000,
})

export default api
```

- [ ] **Step 4: 确认前端能正常启动**

Run: `cd frontend && npm run dev`
Expected: 浏览器访问 http://localhost:5173 能看到页面

- [ ] **Step 5: Commit**

```bash
git add frontend/
git commit -m "feat: 初始化前端项目骨架（Vue3 + TypeScript + Ant Design Vue）"
```

---

### Task 1.4: Docker Compose 开发环境

**负责人：** 后端工程师 B  
**预计工时：** 0.5 天  
**前置依赖：** 无（可与 Task 1.1、1.3 并行）

**Files:**
- Create: `docker-compose.yml`
- Create: `backend/Dockerfile`
- Create: `frontend/Dockerfile`
- Create: `.env.example`

- [ ] **Step 1: 编写 docker-compose.yml**

```yaml
version: "3.8"
services:
  postgres:
    image: postgres:15
    environment:
      POSTGRES_DB: x_financial
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

  minio:
    image: minio/minio
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    ports:
      - "9000:9000"
      - "9001:9001"
    volumes:
      - minio_data:/data

volumes:
  pgdata:
  minio_data:
```

- [ ] **Step 2: 编写 .env.example**

```env
# 数据库
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/x_financial

# Redis
REDIS_URL=redis://localhost:6379/0

# MinIO
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=reimbursement

# OCR（必填：正式环境；开发环境可用 mock）
OCR_PROVIDER=mock
BAIDU_OCR_API_KEY=
BAIDU_OCR_SECRET_KEY=

# LLM（必填：正式环境）
LLM_PROVIDER=openai
LLM_API_KEY=
LLM_MODEL=gpt-4o-mini
LLM_BASE_URL=

# 前端
VITE_API_BASE_URL=http://localhost:8000/api/v1
```

- [ ] **Step 3: 编写后端 Dockerfile**

```dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
```

- [ ] **Step 4: 验证环境启动**

Run: `docker-compose up -d`
Run: `docker-compose ps`
Expected: postgres, redis, minio 均为 running

- [ ] **Step 5: Commit**

```bash
git add docker-compose.yml .env.example backend/Dockerfile frontend/Dockerfile
git commit -m "feat: 添加 Docker Compose 开发环境（PostgreSQL + Redis + MinIO）"
```

---

## 本阶段完成检查

- [ ] `cd backend && pytest tests/test_health.py -v` 通过
- [ ] `cd backend && alembic upgrade head` 无报错，所有表已创建
- [ ] `cd frontend && npm run dev` 能正常启动
- [ ] `docker-compose up -d` 三个服务均 running
- [ ] `.env.example` 已创建，配置项说明完整
