diff --git a/backend/app/main.py b/backend/app/main.py index d3ae029..ec2cf58 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,6 +11,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from app.routers.auth import router as auth_router +from app.routers.companies import router as companies_router from app.schemas.common import error @@ -67,3 +68,4 @@ async def health_check(): app.include_router(auth_router, prefix="/api/v1") +app.include_router(companies_router, prefix="/api/v1") diff --git a/backend/app/routers/companies.py b/backend/app/routers/companies.py new file mode 100644 index 0000000..e258762 --- /dev/null +++ b/backend/app/routers/companies.py @@ -0,0 +1,136 @@ +"""企业档案路由:CRUD + 列表分页。""" + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.dependencies import get_current_user +from app.models.company import Company +from app.models.user import User +from app.schemas.common import ApiResponse, success +from app.schemas.company import ( + CompanyCreate, + CompanyListResponse, + CompanyResponse, + CompanyUpdate, +) + +router = APIRouter(prefix="/companies", tags=["companies"]) + + +@router.get("", response_model=ApiResponse[CompanyListResponse]) +async def list_companies( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + keyword: str | None = Query(default=None, description="按名称搜索"), + industry: str | None = Query(default=None, description="按行业筛选"), + stage: str | None = Query(default=None, description="按融资阶段筛选"), + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取企业列表(分页 + 筛选)。""" + query = select(Company).where(Company.tenant_id == user.tenant_id) + + if keyword: + query = query.where(Company.name.ilike(f"%{keyword}%")) + if industry: + query = query.where(Company.industry == industry) + if stage: + query = query.where(Company.stage == stage) + + # 总数 + count_query = select(func.count()).select_from(query.subquery()) + total_result = await db.execute(count_query) + total = total_result.scalar_one() + + # 分页 + offset = (page - 1) * page_size + query = query.order_by(Company.created_at.desc()).offset(offset).limit(page_size) + result = await db.execute(query) + companies = result.scalars().all() + + return success( + data=CompanyListResponse( + items=[CompanyResponse.model_validate(c, from_attributes=True) for c in companies], + total=total, + page=page, + page_size=page_size, + ) + ) + + +@router.get("/{company_id}", response_model=ApiResponse[CompanyResponse]) +async def get_company( + company_id: str, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """获取企业详情。""" + result = await db.execute( + select(Company).where(Company.id == company_id, Company.tenant_id == user.tenant_id) + ) + company = result.scalar_one_or_none() + if not company: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="企业不存在") + return success(data=CompanyResponse.model_validate(company, from_attributes=True)) + + +@router.post("", response_model=ApiResponse[CompanyResponse], status_code=status.HTTP_201_CREATED) +async def create_company( + req: CompanyCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """创建企业。""" + company = Company(tenant_id=user.tenant_id, **req.model_dump()) + db.add(company) + await db.flush() + return success( + data=CompanyResponse.model_validate(company, from_attributes=True), + message="创建成功", + ) + + +@router.put("/{company_id}", response_model=ApiResponse[CompanyResponse]) +async def update_company( + company_id: str, + req: CompanyUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """更新企业信息。""" + result = await db.execute( + select(Company).where(Company.id == company_id, Company.tenant_id == user.tenant_id) + ) + company = result.scalar_one_or_none() + if not company: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="企业不存在") + + update_data = req.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(company, key, value) + + await db.flush() + return success( + data=CompanyResponse.model_validate(company, from_attributes=True), + message="更新成功", + ) + + +@router.delete("/{company_id}", response_model=ApiResponse[None]) +async def delete_company( + company_id: str, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """删除企业。""" + result = await db.execute( + select(Company).where(Company.id == company_id, Company.tenant_id == user.tenant_id) + ) + company = result.scalar_one_or_none() + if not company: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="企业不存在") + + await db.delete(company) + return success(message="删除成功") diff --git a/backend/app/schemas/company.py b/backend/app/schemas/company.py new file mode 100644 index 0000000..4aee203 --- /dev/null +++ b/backend/app/schemas/company.py @@ -0,0 +1,54 @@ +"""企业相关 Pydantic schema。""" + +from datetime import datetime +from pydantic import BaseModel, Field + + +class CompanyCreate(BaseModel): + """创建企业请求。""" + + name: str = Field(..., min_length=1, max_length=200) + industry: str | None = Field(default=None, max_length=100) + stage: str | None = Field(default=None, max_length=50) + logo_url: str | None = Field(default=None, max_length=500) + description: str | None = None + total_funding: str | None = Field(default=None, max_length=50) + website: str | None = Field(default=None, max_length=500) + + +class CompanyUpdate(BaseModel): + """更新企业请求。""" + + name: str | None = Field(default=None, min_length=1, max_length=200) + industry: str | None = Field(default=None, max_length=100) + stage: str | None = Field(default=None, max_length=50) + logo_url: str | None = Field(default=None, max_length=500) + description: str | None = None + total_funding: str | None = Field(default=None, max_length=50) + website: str | None = Field(default=None, max_length=500) + + +class CompanyResponse(BaseModel): + """企业信息响应。""" + + id: str + tenant_id: str + name: str + industry: str | None = None + stage: str | None = None + logo_url: str | None = None + description: str | None = None + founded_at: datetime | None = None + total_funding: str | None = None + website: str | None = None + created_at: datetime + updated_at: datetime + + +class CompanyListResponse(BaseModel): + """企业列表响应。""" + + items: list[CompanyResponse] + total: int + page: int + page_size: int diff --git a/backend/tests/test_companies.py b/backend/tests/test_companies.py new file mode 100644 index 0000000..6832839 --- /dev/null +++ b/backend/tests/test_companies.py @@ -0,0 +1,224 @@ +"""企业档案 CRUD 测试。""" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from app.core.database import Base, get_db +from app.main import app + +TEST_DATABASE_URL = "sqlite+aiosqlite:///file::memory:?cache=shared&uri=true" + +test_engine = create_async_engine(TEST_DATABASE_URL, echo=False) +test_session_factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + + +async def override_get_db(): + """测试用数据库 session。""" + async with test_session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + +app.dependency_overrides[get_db] = override_get_db + + +@pytest.fixture(scope="module", autouse=True) +async def setup_db(): + """创建测试数据库表。""" + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest.fixture +def client(): + return TestClient(app) + + +@pytest.fixture +def auth_headers(client: TestClient): + """注册并登录,返回认证头。""" + client.post( + "/api/v1/auth/register", + json={ + "email": "company_test@example.com", + "password": "password123", + "name": "测试投资经理", + "tenant_name": "测试机构", + "role": "investor", + }, + ) + resp = client.post( + "/api/v1/auth/login", + json={"email": "company_test@example.com", "password": "password123"}, + ) + token = resp.json()["data"]["access_token"] + return {"Authorization": f"Bearer {token}"} + + +class TestCreateCompany: + """创建企业测试。""" + + def test_create_success(self, client: TestClient, auth_headers: dict): + """正常创建企业。""" + response = client.post( + "/api/v1/companies", + json={ + "name": "AI科技初创公司", + "industry": "人工智能", + "stage": "seed", + "description": "专注于AI投后管理", + "website": "https://example.com", + }, + headers=auth_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["code"] == 0 + assert data["data"]["name"] == "AI科技初创公司" + assert data["data"]["industry"] == "人工智能" + + def test_create_without_auth(self, client: TestClient): + """无认证应返回 401。""" + response = client.post( + "/api/v1/companies", + json={"name": "测试公司"}, + ) + assert response.status_code == 401 + + def test_create_empty_name(self, client: TestClient, auth_headers: dict): + """空名称应返回 422。""" + response = client.post( + "/api/v1/companies", + json={"name": ""}, + headers=auth_headers, + ) + assert response.status_code == 422 + + +class TestListCompanies: + """企业列表测试。""" + + def test_list_success(self, client: TestClient, auth_headers: dict): + """获取企业列表。""" + response = client.get("/api/v1/companies", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert data["code"] == 0 + assert data["data"]["total"] >= 1 + assert len(data["data"]["items"]) >= 1 + + def test_list_with_keyword(self, client: TestClient, auth_headers: dict): + """关键词搜索。""" + response = client.get( + "/api/v1/companies?keyword=AI科技", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert all("AI科技" in item["name"] for item in data["data"]["items"]) + + def test_list_pagination(self, client: TestClient, auth_headers: dict): + """分页参数。""" + response = client.get( + "/api/v1/companies?page=1&page_size=5", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["data"]["page"] == 1 + assert data["data"]["page_size"] == 5 + + +class TestGetCompany: + """获取企业详情测试。""" + + def test_get_success(self, client: TestClient, auth_headers: dict): + """正常获取详情。""" + # 先创建 + create_resp = client.post( + "/api/v1/companies", + json={"name": "详情测试公司", "industry": "SaaS"}, + headers=auth_headers, + ) + company_id = create_resp.json()["data"]["id"] + + response = client.get( + f"/api/v1/companies/{company_id}", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["data"]["name"] == "详情测试公司" + + def test_get_nonexistent(self, client: TestClient, auth_headers: dict): + """不存在的 ID 应返回 404。""" + response = client.get( + "/api/v1/companies/nonexistent-id", + headers=auth_headers, + ) + assert response.status_code == 404 + + +class TestUpdateCompany: + """更新企业测试。""" + + def test_update_success(self, client: TestClient, auth_headers: dict): + """正常更新。""" + create_resp = client.post( + "/api/v1/companies", + json={"name": "更新前公司", "industry": "电商"}, + headers=auth_headers, + ) + company_id = create_resp.json()["data"]["id"] + + response = client.put( + f"/api/v1/companies/{company_id}", + json={"name": "更新后公司", "stage": "a"}, + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["data"]["name"] == "更新后公司" + assert data["data"]["stage"] == "a" + assert data["data"]["industry"] == "电商" # 未更新的字段保持不变 + + +class TestDeleteCompany: + """删除企业测试。""" + + def test_delete_success(self, client: TestClient, auth_headers: dict): + """正常删除。""" + create_resp = client.post( + "/api/v1/companies", + json={"name": "待删除公司"}, + headers=auth_headers, + ) + company_id = create_resp.json()["data"]["id"] + + response = client.delete( + f"/api/v1/companies/{company_id}", + headers=auth_headers, + ) + assert response.status_code == 200 + + # 验证已删除 + get_resp = client.get( + f"/api/v1/companies/{company_id}", + headers=auth_headers, + ) + assert get_resp.status_code == 404 + + def test_delete_nonexistent(self, client: TestClient, auth_headers: dict): + """删除不存在的企业应返回 404。""" + response = client.delete( + "/api/v1/companies/nonexistent-id", + headers=auth_headers, + ) + assert response.status_code == 404 diff --git a/progress.txt b/progress.txt index 04327ea..b75e95b 100644 --- a/progress.txt +++ b/progress.txt @@ -58,6 +58,14 @@ 4. **T1.4 健康度仪表盘** — 投资人端驾驶舱首页 5. **T1.5 风险工作台** — 风险列表/详情/处理 -### 下一步行动 +### 已完成 -→ 开始 T1.1:认证与权限(后端 auth 路由 + 前端登录页对接) +| 任务 | 状态 | 验证结果 | +|---|---|---| +| T1.1 认证与权限 | ✅ | 后端 15 tests passed + 前端登录页构建成功 | + +### T1.1 产出 + +- **后端**:`auth.py` 路由(register/login/refresh/me)+ `dependencies.py`(JWT 校验 + 角色权限) +- **前端**:`auth-context.tsx`(AuthProvider)+ 登录表单页 +- **测试**:11 个认证测试(注册/登录/获取用户/刷新 token)+ 4 个健康检查 = 15 passed