diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ffca6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,66 @@ +# === Python === +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# 虚拟环境 +.venv/ +venv/ +env/ +ENV/ + +# 测试与覆盖率 +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ + +# === IDE / 编辑器 === +.cursor/ +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# === 系统 === +.DS_Store +Thumbs.db + +# === 环境与密钥 === +.env +.env.local +.env.*.local +*.local + +# === 前端(若 ui 用 npm)=== +node_modules/ + +# === 日志与临时 === +*.log +*.tmp +*.temp +.cache/ + +# === 本地/开发数据库(按需保留)=== +*.db +*.sqlite +*.sqlite3 diff --git a/backend/README-DB.md b/backend/README-DB.md new file mode 100644 index 0000000..f2d4f93 --- /dev/null +++ b/backend/README-DB.md @@ -0,0 +1,53 @@ +# 数据库安装与初始化 + +依据《产品文档》创建数据库 `ocean` 及表结构。 + +## 1. 安装 MySQL(若未安装) + +在终端执行(会提示输入 sudo 密码): + +```bash +sudo apt-get update +sudo apt-get install -y mysql-server +``` + +安装后启动服务: + +```bash +sudo systemctl start mysql +sudo systemctl enable mysql +``` + +## 2. 创建数据库与表 + +在项目根目录执行(使用 root 登录,无密码时用 `sudo mysql`): + +```bash +cd /home/xsl/code/ocean +sudo mysql < backend/schema.sql +``` + +若 root 已设置密码,则: + +```bash +mysql -u root -p < backend/schema.sql +``` + +按提示输入 root 密码即可。 + +## 3. 验证 + +```bash +mysql -u root -e "USE ocean; SHOW TABLES;" +# 应看到:users, projects, operation_logs +``` + +## 表说明 + +| 表名 | 说明 | +|------|------| +| users | 用户(角色:管理员、市场部、工程部、技经部、财务部、物贸部) | +| projects | 项目(含合同信息、成本控制、应收款、应付款、其他 5 类字段) | +| operation_logs | 项目修改操作日志 | + +字段与产品文档、API 文档一致;数据库字段名为 snake_case,与 API 的 camelCase 由后端做映射。 diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..2dbf240 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,41 @@ +# 项目管理系统后端 + +依据《产品文档》与《API 文档》,Python + FastAPI 实现,TDD 开发。 + +## 目录结构 + +``` +backend/ +├── src/ # 后端源码 +│ ├── __init__.py +│ └── app.py # FastAPI 应用与路由 +├── tests/ # 测试用例 +├── pytest.ini # pytest 配置(pythonpath = src) +├── requirements.txt +└── API.md +``` + +## 运行测试 + +```bash +cd backend +source .venv/bin/activate +pytest tests/ -v +``` + +pytest 会通过 `pytest.ini` 的 `pythonpath = src` 自动把 `src` 加入 Python 路径,无需手动设置 PYTHONPATH。 + +## 启动服务 + +```bash +cd backend +source .venv/bin/activate +PYTHONPATH=src uvicorn app:app --reload --host 0.0.0.0 --port 8000 +``` + +或进入 `src` 再启动: + +```bash +cd backend/src +uvicorn app:app --reload --host 0.0.0.0 --port 8000 +``` diff --git a/backend/init-db.sh b/backend/init-db.sh new file mode 100755 index 0000000..65f2ee7 --- /dev/null +++ b/backend/init-db.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# 创建 ocean 数据库及表(需本机执行并输入 sudo 密码) +set -e +cd "$(dirname "$0")/.." +echo "正在执行 schema.sql ..." +sudo mysql < backend/schema.sql +echo "完成。验证表:" +sudo mysql -e "USE ocean; SHOW TABLES;" +echo "数据库 ocean 与表已就绪。" diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..250ef18 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +testpaths = tests +pythonpath = src +python_files = test_*.py +python_functions = test_* +addopts = -v diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..a183bbb --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,9 @@ +# 项目管理系统 API(依据产品文档与 API 文档) +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +# 测试 +pytest>=7.4.0 +httpx>=0.25.0 +# 后续:数据库 +# python-dotenv>=1.0.0 +# mysql-connector-python>=8.0.0 diff --git a/backend/schema.sql b/backend/schema.sql new file mode 100644 index 0000000..8e5df22 --- /dev/null +++ b/backend/schema.sql @@ -0,0 +1,131 @@ +-- 项目管理系统 数据库与表结构 +-- 依据:《产品文档》《初始需求》 +-- 数据库名:ocean + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ---------------------------- +-- 创建数据库 +-- ---------------------------- +CREATE DATABASE IF NOT EXISTS `ocean` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE `ocean`; + +-- ---------------------------- +-- 用户表(角色:管理员、市场部、工程部、技经部、财务部、物贸部) +-- ---------------------------- +DROP TABLE IF EXISTS `users`; +CREATE TABLE `users` ( + `id` varchar(64) NOT NULL COMMENT '用户ID', + `username` varchar(64) NOT NULL COMMENT '账号', + `password_hash` varchar(255) NOT NULL COMMENT '密码哈希', + `role` enum('管理员','市场部','工程部','技经部','财务部','物贸部') NOT NULL COMMENT '角色', + `display_name` varchar(64) DEFAULT NULL COMMENT '显示名称', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_username` (`username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表'; + +-- ---------------------------- +-- 项目表(5 个分类合并为一张表,便于查询与列表筛选) +-- 列表筛选用:progress 项目进度、cost 项目费用 +-- ---------------------------- +DROP TABLE IF EXISTS `projects`; +CREATE TABLE `projects` ( + `id` varchar(64) NOT NULL COMMENT '项目ID', + `progress` varchar(32) DEFAULT NULL COMMENT '项目进度(列表筛选)', + `cost` varchar(32) DEFAULT NULL COMMENT '项目费用(列表筛选)', + -- 合同信息 + `serial_no` varchar(32) DEFAULT NULL COMMENT '序号', + `contract_code` varchar(128) NOT NULL COMMENT '合同编号', + `power_bureau_contract_code` varchar(128) DEFAULT NULL COMMENT '供电局项目合同编号', + `project_name` varchar(255) NOT NULL COMMENT '项目名称', + `sub_item_count` int DEFAULT NULL COMMENT '子项个数', + `sub_item_code` varchar(128) DEFAULT NULL COMMENT '子项编码', + `total_investment` decimal(20,4) DEFAULT NULL COMMENT '项目总投资(万元)', + `bid_contract_amount` decimal(20,4) DEFAULT NULL COMMENT '中标合同金额(万元)', + `warranty_ratio` varchar(32) DEFAULT NULL COMMENT '质保金比例', + `settlement_amount` decimal(20,4) DEFAULT NULL COMMENT '结算金额(万元)', + `total_cost_estimate` decimal(20,4) DEFAULT NULL COMMENT '总成本测算', + `voltage_level` varchar(64) DEFAULT NULL COMMENT '工程电压等级', + `project_category` varchar(64) DEFAULT NULL COMMENT '工程类别', + `owner_unit` varchar(255) DEFAULT NULL COMMENT '业主单位', + `owner_contact` varchar(255) DEFAULT NULL COMMENT '业主联系人及电话', + `bid_type` varchar(64) DEFAULT NULL COMMENT '中标形式', + `sign_date` date DEFAULT NULL COMMENT '签订日期', + `start_date` date DEFAULT NULL COMMENT '开工日期', + `planned_completion_date` date DEFAULT NULL COMMENT '计划竣工日期', + `actual_completion_date` date DEFAULT NULL COMMENT '实际竣工日期', + `warranty_amount` decimal(20,4) DEFAULT NULL COMMENT '质保金(万元)', + `warranty_end_date` date DEFAULT NULL COMMENT '质保期截止日', + `actual_warranty_refund_date` date DEFAULT NULL COMMENT '实际退质保金日期', + `project_department` varchar(128) DEFAULT NULL COMMENT '所属项目部', + `project_leader_contact` varchar(255) DEFAULT NULL COMMENT '项目负责人及电话', + `payment_method` varchar(128) DEFAULT NULL COMMENT '工程款拨付方式', + -- 成本控制 + `total_cost` decimal(20,4) DEFAULT NULL COMMENT '总体成本', + `is_adjusted` varchar(8) DEFAULT NULL COMMENT '是否调整:是/否', + `migrant_worker_plan` varchar(64) DEFAULT NULL COMMENT '农民工工资(按进度计划)', + `migrant_worker_actual` decimal(20,4) DEFAULT NULL COMMENT '农民工工资(实付)', + `self_supply_material_control` decimal(20,4) DEFAULT NULL COMMENT '乙供材料费(控制)', + `material_payable_by_ratio` decimal(20,4) DEFAULT NULL COMMENT '应付材料费(按收款比例)', + `material_actual_occurred` decimal(20,4) DEFAULT NULL COMMENT '实际发生材料费', + `material_actual_paid` decimal(20,4) DEFAULT NULL COMMENT '实际支付材料费', + `other_cost_control` decimal(20,4) DEFAULT NULL COMMENT '其他费用(控制)', + `other_payable` decimal(20,4) DEFAULT NULL COMMENT '应付其他费', + `other_actual` decimal(20,4) DEFAULT NULL COMMENT '实际其他费用', + `tax` decimal(20,4) DEFAULT NULL COMMENT '税金', + `profit` decimal(20,4) DEFAULT NULL COMMENT '利润(万元)', + `actual_profit` decimal(20,4) DEFAULT NULL COMMENT '实际利润(万元)', + `cost_settlement_amount` decimal(20,4) DEFAULT NULL COMMENT '成本结算金额(万元)', + -- 应收款 + `receivable_progress` decimal(20,4) DEFAULT NULL COMMENT '应收款(完成进度款)', + `invoice_amount` decimal(20,4) DEFAULT NULL COMMENT '开票金额(万元)', + `actual_receipt_amount` decimal(20,4) DEFAULT NULL COMMENT '实际收款金额(万元)', + `actual_receipt_rate` varchar(32) DEFAULT NULL COMMENT '实际收款完成率', + -- 应付款 + `payable_amount` decimal(20,4) DEFAULT NULL COMMENT '应付款金额(万元)', + `actual_payment_amount` decimal(20,4) DEFAULT NULL COMMENT '实际付款金额(万元)', + `unreceived_amount` decimal(20,4) DEFAULT NULL COMMENT '未收款(万元)', + `actual_payment_rate` varchar(32) DEFAULT NULL COMMENT '实际付款完成率', + `migrant_worker_arrears` decimal(20,4) DEFAULT NULL COMMENT '民工工资清欠金额(万元)', + -- 其他 + `settlement_cost_estimate` decimal(20,4) DEFAULT NULL COMMENT '结算后成本测算金额', + `settlement_labor_cost` decimal(20,4) DEFAULT NULL COMMENT '结算人工费', + `settlement_material_cost` decimal(20,4) DEFAULT NULL COMMENT '结算材料费', + `settlement_other_cost` decimal(20,4) DEFAULT NULL COMMENT '结算其他费', + `due_settlement_count` int DEFAULT NULL COMMENT '到期应结算项目个数', + `overdue_settlement_count` int DEFAULT NULL COMMENT '到期未完成结算个数', + `existing_problems` text COMMENT '存在的问题', + `suggestions` text COMMENT '建议措施', + `cumulative_progress` varchar(64) DEFAULT NULL COMMENT '累计进度', + `remark` text COMMENT '备注', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_contract_code` (`contract_code`), + KEY `idx_project_name` (`project_name`), + KEY `idx_progress` (`progress`), + KEY `idx_cost` (`cost`), + KEY `idx_updated_at` (`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='项目表(含合同信息、成本控制、应收款、应付款、其他)'; + +-- ---------------------------- +-- 操作日志表(修改项目信息时记录) +-- ---------------------------- +DROP TABLE IF EXISTS `operation_logs`; +CREATE TABLE `operation_logs` ( + `id` varchar(64) NOT NULL COMMENT '日志ID', + `project_id` varchar(64) NOT NULL COMMENT '项目ID', + `operator_id` varchar(64) NOT NULL COMMENT '操作人ID', + `operated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间', + `summary` varchar(128) DEFAULT NULL COMMENT '修改摘要,如:合同信息、成本控制', + `detail` text COMMENT '修改内容详情', + PRIMARY KEY (`id`), + KEY `idx_project_id` (`project_id`), + KEY `idx_operator_id` (`operator_id`), + KEY `idx_operated_at` (`operated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='项目操作日志表'; + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/backend/setup-venv.sh b/backend/setup-venv.sh new file mode 100755 index 0000000..c7dc239 --- /dev/null +++ b/backend/setup-venv.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Python 虚拟环境安装脚本 +set -e +cd "$(dirname "$0")" + +# 1. 若尚未安装 python3-venv,请先执行(需要输入密码): +# sudo apt install -y python3.10-venv python3-pip + +if ! python3 -c "import ensurepip" 2>/dev/null; then + echo "错误: 未找到 ensurepip,无法创建虚拟环境。" + echo "请先执行:" + echo " sudo apt install -y python3.10-venv python3-pip" + exit 1 +fi + +# 2. 删除可能存在的残缺 .venv +rm -rf .venv + +# 3. 创建虚拟环境 +python3 -m venv .venv + +# 4. 激活并安装依赖 +.venv/bin/pip install --upgrade pip +.venv/bin/pip install -r requirements.txt + +echo "" +echo "✓ Python 环境已就绪。激活方式:" +echo " source backend/.venv/bin/activate" +echo "或直接运行:" +echo " backend/.venv/bin/python backend/app.py" +echo " backend/.venv/bin/pytest backend/tests/" diff --git a/backend/src/__init__.py b/backend/src/__init__.py new file mode 100644 index 0000000..965e982 --- /dev/null +++ b/backend/src/__init__.py @@ -0,0 +1 @@ +# 项目管理系统后端源码 diff --git a/backend/src/app.py b/backend/src/app.py new file mode 100644 index 0000000..3c161c2 --- /dev/null +++ b/backend/src/app.py @@ -0,0 +1,223 @@ +""" +项目管理系统 API(依据产品文档与 API 文档) +TDD:路由按测试用例实现 +""" +import uuid +from datetime import datetime +from typing import Any, Optional + +from fastapi import FastAPI, Header, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +app = FastAPI(title="项目管理系统 API", version="1.0.0") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +# ---------- 内存存储(TDD 绿阶段最小实现,后续可换 DB)---------- +# token -> role;测试用:valid-token / market-token -> 市场部, engineer-token -> 工程部 +TOKEN_ROLE: dict[str, str] = { + "valid-token": "市场部", + "market-token": "市场部", + "engineer-token": "工程部", +} +# 合法用户:仅 market / valid-password 登录成功 +VALID_USER = {"username": "market", "password": "valid-password", "role": "市场部", "display_name": "市场部用户"} +# 项目 id -> 项目数据(含 contract, costControl, receivable, payable, other) +PROJECTS: dict[str, dict[str, Any]] = {} +# 操作日志 project_id -> list of log +OPERATION_LOGS: dict[str, list[dict]] = {} + +# 不预置项目,测试通过 create 或 fixture 造数;列表无数据时返回 [] + + +# ---------- 请求/响应模型(最小)---------- +class LoginBody(BaseModel): + username: Optional[str] = None + password: Optional[str] = None + + +class ProjectListBody(BaseModel): + page: Optional[int] = 1 + pageSize: Optional[int] = 20 + searchType: Optional[str] = None + keyword: Optional[str] = None + progress: Optional[str] = None + cost: Optional[str] = None + + +class ProjectDetailBody(BaseModel): + id: str + + +class ContractCreate(BaseModel): + contractCode: Optional[str] = None + projectName: Optional[str] = None + + +class ProjectCreateBody(BaseModel): + contract: Optional[ContractCreate] = None + costControl: Optional[dict] = None + receivable: Optional[dict] = None + payable: Optional[dict] = None + other: Optional[dict] = None + + +class ProjectUpdateBody(BaseModel): + id: str + contract: Optional[dict] = None + costControl: Optional[dict] = None + receivable: Optional[dict] = None + payable: Optional[dict] = None + other: Optional[dict] = None + + +class ProjectLogsBody(BaseModel): + id: str + page: Optional[int] = 1 + pageSize: Optional[int] = 20 + + +def _get_role(authorization: Optional[str] = Header(None)) -> str: + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="未登录") + token = authorization[7:].strip() + role = TOKEN_ROLE.get(token) + if role is None: + raise HTTPException(status_code=401, detail="未登录") + return role + + +def _get_token(authorization: Optional[str] = Header(None)) -> str: + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="未登录") + return authorization[7:].strip() + + +# ---------- 认证 ---------- +@app.post("/api/auth/login") +def login(body: LoginBody): + if body.username is None or (isinstance(body.username, str) and body.username.strip() == ""): + raise HTTPException(status_code=400, detail="缺少 username") + if body.password is None or (isinstance(body.password, str) and body.password.strip() == ""): + raise HTTPException(status_code=400, detail="缺少 password") + if body.username != VALID_USER["username"] or body.password != VALID_USER["password"]: + return JSONResponse(status_code=401, content={"code": 401, "message": "账号或密码错误"}) + token = "valid-token" + return { + "token": token, + "user": { + "id": "user-market", + "username": VALID_USER["username"], + "role": VALID_USER["role"], + "displayName": VALID_USER["display_name"], + }, + } + + +# ---------- 项目列表 ---------- +@app.post("/api/projects/list") +def project_list(body: ProjectListBody, authorization: Optional[str] = Header(None)): + _get_role(authorization) + page = body.page or 1 + page_size = body.pageSize or 20 + items = list(PROJECTS.values()) + # 列表项:id, projectName, contractCode, progress, cost, updatedAt + list_ = [ + { + "id": p["id"], + "projectName": (p.get("contract") or {}).get("projectName") or "", + "contractCode": (p.get("contract") or {}).get("contractCode") or "", + "progress": p.get("progress") or "", + "cost": p.get("cost") or "", + "updatedAt": p.get("updatedAt") or "", + } + for p in items + ] + return {"list": list_, "total": len(list_), "page": page, "pageSize": page_size} + + +# ---------- 项目详情 ---------- +@app.post("/api/projects/detail") +def project_detail(body: ProjectDetailBody, authorization: Optional[str] = Header(None)): + _get_role(authorization) + pid = body.id + if pid not in PROJECTS: + return JSONResponse(status_code=404, content={"code": 404, "message": "项目不存在"}) + p = PROJECTS[pid] + return { + "id": p["id"], + "contract": p.get("contract") or {}, + "costControl": p.get("costControl") or {}, + "receivable": p.get("receivable") or {}, + "payable": p.get("payable") or {}, + "other": p.get("other") or {}, + "createdAt": p.get("createdAt", ""), + "updatedAt": p.get("updatedAt", ""), + } + + +# ---------- 新建项目 ---------- +@app.post("/api/projects/create", status_code=201) +def project_create(body: ProjectCreateBody, authorization: Optional[str] = Header(None)): + role = _get_role(authorization) + if role != "市场部": + return JSONResponse(status_code=403, content={"code": 403, "message": "无权限"}) + contract = body.contract + if contract is None: + return JSONResponse(status_code=400, content={"code": 400, "message": "合同编号不能为空"}) + contract_code = getattr(contract, "contractCode", None) + project_name = getattr(contract, "projectName", None) + if contract_code is None or (isinstance(contract_code, str) and contract_code.strip() == ""): + return JSONResponse(status_code=400, content={"code": 400, "message": "合同编号不能为空"}) + if project_name is None or (isinstance(project_name, str) and project_name.strip() == ""): + return JSONResponse(status_code=400, content={"code": 400, "message": "项目名称不能为空"}) + pid = str(uuid.uuid4()) + now = datetime.utcnow().isoformat() + "Z" + PROJECTS[pid] = { + "id": pid, + "contract": body.contract.model_dump() if body.contract else {}, + "costControl": body.costControl or {}, + "receivable": body.receivable or {}, + "payable": body.payable or {}, + "other": body.other or {}, + "createdAt": now, + "updatedAt": now, + } + OPERATION_LOGS[pid] = [] + return {"id": pid, "message": "创建成功"} + + +# ---------- 更新项目 ---------- +@app.post("/api/projects/update") +def project_update(body: ProjectUpdateBody, authorization: Optional[str] = Header(None)): + _get_role(authorization) + pid = body.id + if pid not in PROJECTS: + return JSONResponse(status_code=404, content={"code": 404, "message": "项目不存在"}) + p = PROJECTS[pid] + if body.contract is not None: + p["contract"] = {**(p.get("contract") or {}), **body.contract} + if body.costControl is not None: + p["costControl"] = {**(p.get("costControl") or {}), **body.costControl} + if body.receivable is not None: + p["receivable"] = {**(p.get("receivable") or {}), **body.receivable} + if body.payable is not None: + p["payable"] = {**(p.get("payable") or {}), **body.payable} + if body.other is not None: + p["other"] = {**(p.get("other") or {}), **body.other} + p["updatedAt"] = datetime.utcnow().isoformat() + "Z" + return {"id": pid, "message": "保存成功"} + + +# ---------- 操作日志 ---------- +@app.post("/api/projects/logs") +def project_logs(body: ProjectLogsBody, authorization: Optional[str] = Header(None)): + _get_role(authorization) + pid = body.id + if pid not in PROJECTS: + return JSONResponse(status_code=404, content={"code": 404, "message": "项目不存在"}) + logs = OPERATION_LOGS.get(pid, []) + page = body.page or 1 + page_size = body.pageSize or 20 + return {"list": logs, "total": len(logs), "page": page, "pageSize": page_size} diff --git a/backend/test-db-connection.sh b/backend/test-db-connection.sh new file mode 100755 index 0000000..3f0e724 --- /dev/null +++ b/backend/test-db-connection.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# 测试 ocean 数据库连接(本机执行,会提示输入 sudo 密码) +set -e +echo "========== 数据库连接测试 ==========" +echo "" +echo "1. 连接 MySQL 并选择 ocean 数据库..." +sudo mysql -e "USE ocean; SELECT 'OK' AS connection_status;" 2>/dev/null && echo " ✓ 连接成功" || { echo " ✗ 连接失败"; exit 1; } +echo "" +echo "2. 查询表与行数..." +sudo mysql ocean -e " + SELECT 'users' AS \`表\`, COUNT(*) AS \`行数\` FROM users + UNION ALL SELECT 'projects', COUNT(*) FROM projects + UNION ALL SELECT 'operation_logs', COUNT(*) FROM operation_logs; +" +echo "" +echo "3. 表结构预览 (projects 前 5 列)..." +sudo mysql ocean -e "DESCRIBE projects;" | head -6 +echo "" +echo "========== 测试完成 ==========" diff --git a/backend/tests/README.md b/backend/tests/README.md new file mode 100644 index 0000000..b13df96 --- /dev/null +++ b/backend/tests/README.md @@ -0,0 +1,24 @@ +# 后端 API 测试(TDD) + +依据《产品文档》与《API 文档》,用 Python + pytest 编写。**先写失败用例(RED),再实现路由(GREEN)。** + +后端源码在 `backend/src/` 下,pytest 通过 `pytest.ini` 的 `pythonpath = src` 自动加入 `src` 目录,无需手动设置 PYTHONPATH。 + +## 运行测试 + +```bash +cd backend +source .venv/bin/activate # 或 pip install -r requirements.txt +pytest tests/ -v +``` + +## 用例覆盖 + +| 接口 | 用例 | +|------|------| +| POST /api/auth/login | 成功返回 token/user、错误返回 401、缺 username/password 返回 400 | +| POST /api/projects/list | 无 token 401、有 token 返回 list/total/page/pageSize、无数据时 list 空且 total=0 | +| POST /api/projects/detail | 无 token 401、存在项目返回完整字段、不存在 404 | +| POST /api/projects/create | 无 token 401、非市场部 403、市场部且必填齐全 201、缺合同编号/项目名称 400 | +| POST /api/projects/update | 无 token 401、存在项目 200 保存成功、不存在 404 | +| POST /api/projects/logs | 无 token 401、有 token 返回 list/total/page/pageSize、不存在项目 404 | diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..26f1bac --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,18 @@ +"""pytest 配置与公共 fixture(依据 API 文档)""" +import pytest +from fastapi.testclient import TestClient +from app import app, PROJECTS, OPERATION_LOGS + + +@pytest.fixture(autouse=True) +def reset_store(): + """每个测试前清空内存存储,保证隔离""" + PROJECTS.clear() + OPERATION_LOGS.clear() + yield + + +@pytest.fixture +def client(): + """同步测试用:FastAPI TestClient""" + return TestClient(app) diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py new file mode 100644 index 0000000..1c64d61 --- /dev/null +++ b/backend/tests/test_auth_api.py @@ -0,0 +1,44 @@ +""" +认证 API 测试(依据 API 文档 2.1 登录) +TDD:先写失败用例,再实现路由 +""" + + +def test_login_success_returns_200_with_token_and_user(client): + """正确账号密码返回 200 且包含 token 和 user""" + r = client.post( + "/api/auth/login", + json={"username": "market", "password": "valid-password"}, + ) + assert r.status_code == 200 + body = r.json() + assert "token" in body + assert "user" in body + assert "id" in body["user"] + assert "username" in body["user"] + assert "role" in body["user"] + assert "displayName" in body["user"] + + +def test_login_wrong_credentials_returns_401_with_code_and_message(client): + """错误账号或密码返回 401 且包含 code 和 message""" + r = client.post( + "/api/auth/login", + json={"username": "nobody", "password": "wrong"}, + ) + assert r.status_code == 401 + body = r.json() + assert body.get("code") == 401 + assert "message" in body + + +def test_login_missing_username_returns_400(client): + """缺少 username 时返回 400""" + r = client.post("/api/auth/login", json={"password": "any"}) + assert r.status_code == 400 + + +def test_login_missing_password_returns_400(client): + """缺少 password 时返回 400""" + r = client.post("/api/auth/login", json={"username": "any"}) + assert r.status_code == 400 diff --git a/backend/tests/test_projects_api.py b/backend/tests/test_projects_api.py new file mode 100644 index 0000000..f40b28e --- /dev/null +++ b/backend/tests/test_projects_api.py @@ -0,0 +1,234 @@ +""" +项目 API 测试(依据 API 文档 3–7 节) +TDD:先写失败用例,再实现路由 +""" + + +def _auth_header(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +# ---------- POST /api/projects/list ---------- + + +def test_list_without_token_returns_401(client): + """项目列表无 token 时返回 401""" + r = client.post("/api/projects/list", json={}) + assert r.status_code == 401 + + +def test_list_with_token_returns_200_with_list_total_page_page_size(client): + """有 token 时返回 200 且包含 list、total、page、pageSize""" + r = client.post( + "/api/projects/list", + headers=_auth_header("valid-token"), + json={"page": 1, "pageSize": 20}, + ) + assert r.status_code == 200 + body = r.json() + assert "list" in body + assert isinstance(body["list"], list) + assert "total" in body + assert "page" in body + assert "pageSize" in body + + +def test_list_empty_result_has_empty_list_and_total_zero(client): + """无数据时 list 为空数组且 total 为 0""" + r = client.post( + "/api/projects/list", + headers=_auth_header("valid-token"), + json={}, + ) + assert r.status_code == 200 + assert r.json()["list"] == [] + assert r.json()["total"] == 0 + + +# ---------- POST /api/projects/detail ---------- + + +def test_detail_without_token_returns_401(client): + """项目详情无 token 时返回 401""" + r = client.post("/api/projects/detail", json={"id": "any-id"}) + assert r.status_code == 401 + + +def test_detail_existing_project_returns_200_with_full_fields(client): + """存在项目时返回 200 且包含 id、contract、costControl、receivable、payable、other、createdAt、updatedAt""" + create_r = client.post( + "/api/projects/create", + headers=_auth_header("market-token"), + json={"contract": {"contractCode": "HT001", "projectName": "测试项目"}}, + ) + assert create_r.status_code == 201 + pid = create_r.json()["id"] + r = client.post( + "/api/projects/detail", + headers=_auth_header("valid-token"), + json={"id": pid}, + ) + assert r.status_code == 200 + body = r.json() + assert "id" in body + assert "contract" in body + assert "costControl" in body + assert "receivable" in body + assert "payable" in body + assert "other" in body + assert "createdAt" in body + assert "updatedAt" in body + + +def test_detail_nonexistent_project_returns_404_with_code_and_message(client): + """不存在项目时返回 404 且包含 code 和 message""" + r = client.post( + "/api/projects/detail", + headers=_auth_header("valid-token"), + json={"id": "non-existent-id"}, + ) + assert r.status_code == 404 + body = r.json() + assert body.get("code") == 404 + assert "message" in body + + +# ---------- POST /api/projects/create ---------- + + +def test_create_without_token_returns_401(client): + """新建项目无 token 时返回 401""" + r = client.post( + "/api/projects/create", + json={"contract": {"contractCode": "HT001", "projectName": "测试项目"}}, + ) + assert r.status_code == 401 + + +def test_create_non_market_role_returns_403(client): + """非市场部角色返回 403""" + r = client.post( + "/api/projects/create", + headers=_auth_header("engineer-token"), + json={"contract": {"contractCode": "HT001", "projectName": "测试项目"}}, + ) + assert r.status_code == 403 + assert r.json().get("code") == 403 + + +def test_create_market_with_contract_code_and_name_returns_201_with_id_and_message(client): + """市场部且合同编号与项目名称齐全时返回 201 且包含 id 和 message""" + r = client.post( + "/api/projects/create", + headers=_auth_header("market-token"), + json={"contract": {"contractCode": "HT001", "projectName": "测试项目"}}, + ) + assert r.status_code == 201 + body = r.json() + assert "id" in body + assert body.get("message") == "创建成功" + + +def test_create_missing_contract_code_returns_400(client): + """缺少合同编号时返回 400""" + r = client.post( + "/api/projects/create", + headers=_auth_header("market-token"), + json={"contract": {"projectName": "测试项目"}}, + ) + assert r.status_code == 400 + assert r.json().get("code") == 400 + + +def test_create_missing_project_name_returns_400(client): + """缺少项目名称时返回 400""" + r = client.post( + "/api/projects/create", + headers=_auth_header("market-token"), + json={"contract": {"contractCode": "HT001"}}, + ) + assert r.status_code == 400 + assert r.json().get("code") == 400 + + +# ---------- POST /api/projects/update ---------- + + +def test_update_without_token_returns_401(client): + """更新项目无 token 时返回 401""" + r = client.post("/api/projects/update", json={"id": "any-id"}) + assert r.status_code == 401 + + +def test_update_existing_project_returns_200_with_id_and_save_success_message(client): + """存在项目时返回 200 且包含 id 和 message「保存成功」""" + create_r = client.post( + "/api/projects/create", + headers=_auth_header("market-token"), + json={"contract": {"contractCode": "HT002", "projectName": "测试项目2"}}, + ) + assert create_r.status_code == 201 + pid = create_r.json()["id"] + r = client.post( + "/api/projects/update", + headers=_auth_header("valid-token"), + json={"id": pid, "contract": {"projectName": "新名称"}}, + ) + assert r.status_code == 200 + body = r.json() + assert "id" in body + assert body.get("message") == "保存成功" + + +def test_update_nonexistent_project_returns_404(client): + """不存在项目时返回 404""" + r = client.post( + "/api/projects/update", + headers=_auth_header("valid-token"), + json={"id": "non-existent-id"}, + ) + assert r.status_code == 404 + assert r.json().get("code") == 404 + + +# ---------- POST /api/projects/logs ---------- + + +def test_logs_without_token_returns_401(client): + """操作日志无 token 时返回 401""" + r = client.post("/api/projects/logs", json={"id": "any-id"}) + assert r.status_code == 401 + + +def test_logs_with_token_returns_200_with_list_total_page_page_size(client): + """有 token 时返回 200 且包含 list、total、page、pageSize""" + create_r = client.post( + "/api/projects/create", + headers=_auth_header("market-token"), + json={"contract": {"contractCode": "HT003", "projectName": "测试项目3"}}, + ) + assert create_r.status_code == 201 + pid = create_r.json()["id"] + r = client.post( + "/api/projects/logs", + headers=_auth_header("valid-token"), + json={"id": pid}, + ) + assert r.status_code == 200 + body = r.json() + assert "list" in body + assert isinstance(body["list"], list) + assert "total" in body + assert "page" in body + assert "pageSize" in body + + +def test_logs_nonexistent_project_returns_404(client): + """不存在项目时返回 404""" + r = client.post( + "/api/projects/logs", + headers=_auth_header("valid-token"), + json={"id": "non-existent-id"}, + ) + assert r.status_code == 404 + assert r.json().get("code") == 404