完成错误测试用例
This commit is contained in:
+66
@@ -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
|
||||
@@ -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 由后端做映射。
|
||||
@@ -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
|
||||
```
|
||||
Executable
+9
@@ -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 与表已就绪。"
|
||||
@@ -0,0 +1,6 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
pythonpath = src
|
||||
python_files = test_*.py
|
||||
python_functions = test_*
|
||||
addopts = -v
|
||||
@@ -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
|
||||
@@ -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;
|
||||
Executable
+31
@@ -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/"
|
||||
@@ -0,0 +1 @@
|
||||
# 项目管理系统后端源码
|
||||
@@ -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}
|
||||
Executable
+19
@@ -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 "========== 测试完成 =========="
|
||||
@@ -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 |
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user