所有需求实现完成

This commit is contained in:
Your Name
2026-02-01 15:57:42 +08:00
parent e651b92d7c
commit 6e1174a59a
16 changed files with 2354 additions and 173 deletions
+181 -10
View File
@@ -53,12 +53,177 @@
---
## 3. 项目列表
## 2.2 用户管理(仅管理员)
以下接口仅**管理员**角色可调用,用于查看、创建、修改、删除其他用户。非管理员返回 HTTP 403。
### 2.2.0 用户列表
| 项目 | 说明 |
|------|------|
| **接口** | `POST /api/users/list` |
| **说明** | 分页获取用户列表(不含密码) |
| **权限** | 已登录,且角色为管理员 |
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| page | number | 否 | 页码,从 1 开始,默认 1 |
| pageSize | number | 否 | 每页条数,默认 100 |
#### 响应参数(成功,HTTP 200
| 参数名 | 类型 | 说明 |
|--------|------|------|
| list | array | 用户列表 |
| list[].id | string | 用户 ID |
| list[].username | string | 账号 |
| list[].role | string | 角色 |
| list[].displayName | string | 显示名称 |
| list[].createdAt | string | 创建时间,ISO8601 |
| list[].updatedAt | string | 更新时间,ISO8601 |
| total | number | 总条数 |
| page | number | 当前页码 |
| pageSize | number | 每页条数 |
---
### 2.2.1 创建用户
| 项目 | 说明 |
|------|------|
| **接口** | `POST /api/users/create` |
| **说明** | 创建新用户 |
| **权限** | 已登录,且角色为管理员 |
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| username | string | 是 | 账号(唯一) |
| password | string | 是 | 密码 |
| role | string | 是 | 角色:管理员、市场部、工程部、技经部、财务部、物贸部 |
| displayName | string | 否 | 显示名称,默认同 username |
#### 响应参数(成功,HTTP 201
| 参数名 | 类型 | 说明 |
|--------|------|------|
| id | string | 新用户 ID |
| message | string | 固定为「创建成功」 |
#### 响应参数(失败)
| HTTP 状态 | 说明 |
|-----------|------|
| 400 | 账号/密码为空、角色无效、账号已存在 |
| 403 | 非管理员 |
---
### 2.2.2 更新用户
| 项目 | 说明 |
|------|------|
| **接口** | `POST /api/users/update` |
| **说明** | 修改指定用户(仅更新传入的字段) |
| **权限** | 已登录,且角色为管理员 |
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | string | 是 | 用户 ID |
| password | string | 否 | 新密码,不传则不修改 |
| role | string | 否 | 新角色 |
| displayName | string | 否 | 新显示名称 |
#### 响应参数(成功,HTTP 200
| 参数名 | 类型 | 说明 |
|--------|------|------|
| id | string | 用户 ID |
| message | string | 固定为「保存成功」 |
#### 响应参数(失败)
| HTTP 状态 | 说明 |
|-----------|------|
| 400 | 角色无效等 |
| 403 | 非管理员 |
| 404 | 用户不存在 |
---
### 2.2.3 删除用户
| 项目 | 说明 |
|------|------|
| **接口** | `POST /api/users/delete` |
| **说明** | 删除指定用户。不能删除当前登录用户。 |
| **权限** | 已登录,且角色为管理员 |
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | string | 是 | 用户 ID |
#### 响应参数(成功,HTTP 200
| 参数名 | 类型 | 说明 |
|--------|------|------|
| message | string | 固定为「删除成功」 |
#### 响应参数(失败)
| HTTP 状态 | 说明 |
|-----------|------|
| 400 | 不能删除当前登录用户 |
| 403 | 非管理员 |
| 404 | 用户不存在 |
---
## 3. 项目统计
| 项目 | 说明 |
|------|------|
| **接口** | `POST /api/projects/statistics` |
| **说明** | 获取项目统计信息。传入 statisticsType=全部 返回各类型数量(byType);传入具体类型返回该类型项目列表 |
| **权限** | 已登录,所有角色可访问 |
#### 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| statisticsType | string | 否 | 全部(默认)返回各类型数量;基建工程、业扩项目、户表、客户工程、营销项目、检修-技改-抢修、其他 返回该类型项目列表 |
#### 响应参数(statisticsType=全部)
| 参数名 | 类型 | 说明 |
|--------|------|------|
| total | number | 项目总数 |
| statisticsType | string | 固定为「全部」 |
| byType | object | 各统计类型数量,如 { "基建工程": 10, "业扩项目": 5, "其他": 3 } |
#### 响应参数(statisticsType 为具体类型)
| 参数名 | 类型 | 说明 |
|--------|------|------|
| total | number | 该类型项目数 |
| statisticsType | string | 请求的统计类型 |
| list | array | 该类型项目列表,结构同项目列表 list[].* |
---
## 4. 项目列表
| 项目 | 说明 |
|------|------|
| **接口** | `POST /api/projects/list` |
| **说明** | 分页获取项目列表,支持按名称/合同编号搜索、按进度/费用/合同金额筛选、时间筛选、日期异常筛选 |
| **说明** | 分页获取项目列表,支持按名称/合同编号搜索、按进度/费用/合同金额/统计类型筛选、时间筛选、日期异常筛选 |
| **权限** | 已登录,所有角色可访问 |
#### 请求参数
@@ -77,6 +242,7 @@
| dateAbnormal | boolean | 否 | 为 true 时只返回「日期异常」项目(四个日期中任意一个非「日期正常」) |
| amountMin | number | 否 | 合同金额(万元)下限,按中标合同金额筛选 |
| amountMax | number | 否 | 合同金额(万元)上限,按中标合同金额筛选 |
| statisticsType | string | 否 | 统计类型筛选:全部、基建工程、业扩项目、户表、客户工程、营销项目、检修-技改-抢修、其他 |
#### 响应参数(成功,HTTP 200
@@ -84,6 +250,7 @@
|--------|------|------|
| list | array | 项目列表,无数据时为空数组 |
| list[].id | string | 项目 ID |
| list[].statisticsType | string | 统计类型 |
| list[].projectName | string | 项目名称 |
| list[].contractCode | string | 合同编号 |
| list[].progress | string | 项目进度 |
@@ -101,7 +268,7 @@
---
## 4. 项目详情
## 5. 项目详情
| 项目 | 说明 |
|------|------|
@@ -137,7 +304,7 @@
---
## 5. 新建项目
## 6. 新建项目
| 项目 | 说明 |
|------|------|
@@ -183,7 +350,7 @@
---
## 6. 更新项目
## 7. 更新项目
| 项目 | 说明 |
|------|------|
@@ -218,7 +385,7 @@
---
## 7. 操作日志
## 8. 操作日志
| 项目 | 说明 |
|------|------|
@@ -258,7 +425,7 @@
---
## 8. 项目信息字段(5 个分类)
## 9. 项目信息字段(5 个分类)
以下为各分类在 **请求/响应** 中使用的字段定义;单位、是否必填、校验规则由后端统一规定,前端与后端对齐。
@@ -349,7 +516,7 @@
---
## 9. 项目列表/筛选枚举
## 10. 项目列表/筛选枚举
以下由后端定义并提供给前端(如通过配置接口或文档约定):
@@ -360,7 +527,7 @@
---
## 10. 统一错误响应
## 11. 统一错误响应
| HTTP 状态 | 说明 |
|-----------|------|
@@ -394,11 +561,15 @@
---
## 11. 接口一览
## 12. 接口一览
| 接口 | 方法 | 说明 | 权限 |
|------|------|------|------|
| /api/auth/login | POST | 登录 | 公开 |
| /api/users/list | POST | 用户列表(分页) | 已登录且管理员 |
| /api/users/create | POST | 创建用户 | 已登录且管理员 |
| /api/users/update | POST | 更新用户 | 已登录且管理员 |
| /api/users/delete | POST | 删除用户 | 已登录且管理员 |
| /api/projects/list | POST | 项目列表(搜索、筛选、分页) | 已登录 |
| /api/projects/detail | POST | 项目详情 | 已登录 |
| /api/projects/create | POST | 新建项目 | 已登录且市场部 |
+1
View File
@@ -36,6 +36,7 @@ CREATE TABLE `projects` (
`id` varchar(64) NOT NULL COMMENT '项目ID',
`progress` varchar(32) DEFAULT NULL COMMENT '项目进度(列表筛选)',
`cost` varchar(32) DEFAULT NULL COMMENT '项目费用(列表筛选)',
`statistics_type` varchar(32) DEFAULT NULL COMMENT '统计类型:基建工程、业扩项目、户表、客户工程、营销项目、检修-技改-抢修、其他',
-- 合同信息
`serial_no` varchar(32) DEFAULT NULL COMMENT '序号',
`contract_code` varchar(128) NOT NULL COMMENT '合同编号',
+28
View File
@@ -47,6 +47,34 @@ cd backend && source .venv/bin/activate && PYTHONPATH=src python scripts/clean_d
---
## migrate_add_statistics_type.py:为 projects 表增加统计类型列
为已有 `projects` 表添加 `statistics_type` 列(若不存在)。
```bash
cd backend && PYTHONPATH=src python scripts/migrate_add_statistics_type.py
```
**migrate_statistics_type_repair.py**:将已有数据中的「检修、技改、抢修」统一改为「检修-技改-抢修」(一次性执行)。
```bash
cd backend && PYTHONPATH=src python scripts/migrate_statistics_type_repair.py
```
---
## fill_statistics_type_from_xls.py:按「统计」表填充统计类型
根据 `docs/example.xls` 的「统计」表,为已有项目填充 `statistics_type`(基建工程、业扩项目、户表、客户工程、营销项目、检修-技改-抢修、其他)。导入数据后建议执行一次。
```bash
cd backend && PYTHONPATH=src python scripts/fill_statistics_type_from_xls.py
# 或指定 xls 路径
PYTHONPATH=src python scripts/fill_statistics_type_from_xls.py /path/to/example.xls
```
---
## import_xls.py:导入 example.xls 到 projects 表
`docs/example.xls` 中的项目数据导入 ocean 数据库的 `projects` 表。
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""
根据 docs/example.xls 的「统计」表,为 projects 表填充 statistics_type。
统计表结构:一、基建工程(3-29) 二、业扩项目(33-50) 三、户表(54-71) 四、客户工程 五、营销项目 六、检修、技改、抢修 -> 统计类型存为「检修-技改-抢修」
通过 项目名称 或 所属项目部 包含统计表中的名称/关键字进行匹配。
用法:cd backend && PYTHONPATH=src python3 scripts/fill_statistics_type_from_xls.py [path_to_example.xls]
"""
import sys
from pathlib import Path
_backend = Path(__file__).resolve().parent.parent
if str(_backend) not in sys.path:
sys.path.insert(0, str(_backend))
import xlrd
from src.database import get_connection
def _normalize(s):
if s is None:
return ""
return str(s).strip().replace("\n", "").replace("\r", "")
def collect_names_from_stat_sheet(wb: xlrd.Book) -> dict:
"""从「统计」表收集各类型的名称/关键字列表。返回 { 统计类型: [名称1, 名称2, ...] }"""
if "统计" not in wb.sheet_names():
return {}
sh = wb.sheet_by_name("统计")
out = {
"基建工程": [],
"业扩项目": [],
"户表": [],
"客户工程": [],
"营销项目": [],
"检修-技改-抢修": [],
}
# 一、基建工程: row 0 title, row 1 header, rows 3-29 data, col 1 = 项目名称
for r in range(3, 30):
v = _normalize(sh.cell_value(r, 1))
if v and v != "合计" and not v.replace(".", "").isdigit():
out["基建工程"].append(v)
# 二、业扩项目: row 30 title, row 31 header?, row 32 合计, rows 33-50 col 0
for r in range(33, 51):
v = _normalize(sh.cell_value(r, 0))
if v and v != "合计" and "业扩" in v:
out["业扩项目"].append(v)
# 三、户表: row 51 title, row 52 header, row 53 合计, rows 54-71 col 0
for r in range(54, 72):
v = _normalize(sh.cell_value(r, 0))
if v and v != "合计" and "户表" in v:
out["户表"].append(v)
# 四、客户工程: row 72 title, row 73 header(序号,所属项目部,项目个数,总投资), 74+ 所属项目部 col 1
for r in range(75, 96):
v = _normalize(sh.cell_value(r, 1))
if v and v != "合计" and not v.replace(".", "").isdigit():
out["客户工程"].append(v)
# 五、营销项目: row 96 title, rows 97-107
for r in range(97, 108):
v = _normalize(sh.cell_value(r, 0)) or _normalize(sh.cell_value(r, 1))
if v and v != "合计":
out["营销项目"].append(v)
# 六、检修、技改、抢修: row 108 title, rows 109+ col 1 主项目名称 -> 统计类型「检修-技改-抢修」
for r in range(109, min(134, sh.nrows)):
v = _normalize(sh.cell_value(r, 1))
if v and v not in ("合计", "小计", ""):
out["检修-技改-抢修"].append(v)
return out
def main():
if len(sys.argv) >= 2:
xls_path = Path(sys.argv[1]).resolve()
else:
xls_path = _backend.parent / "docs" / "example.xls"
if not xls_path.exists():
xls_path = _backend / "docs" / "example.xls"
if not xls_path.exists():
print(f"未找到文件:{xls_path}")
sys.exit(1)
wb = xlrd.open_workbook(str(xls_path))
by_type = collect_names_from_stat_sheet(wb)
# 基建工程:从「凯里XX供电局」「都匀XX供电局」提取 XX,匹配 所属项目部 含 XX
build_keywords = []
for n in by_type.get("基建工程", []):
n = _normalize(n)
if "供电局" in n:
# 凯里城区供电分局 -> 城区,凯里三穗供电局 -> 三穗,都匀瓮安供电局 -> 瓮安
for prefix in ("凯里", "都匀"):
if n.startswith(prefix):
mid = n[len(prefix) :].split("供电局")[0].strip()
if mid and len(mid) <= 6:
build_keywords.append((mid, "基建工程"))
break
# 匹配顺序:检修-技改-抢修、业扩、户表、营销、基建(先关键字再名称)、客户工程
order = ["检修-技改-抢修", "业扩项目", "户表", "营销项目", "基建工程", "客户工程"]
with get_connection() as conn:
cur = conn.cursor(dictionary=True)
cur.execute("SELECT id, project_name, project_department FROM projects")
rows = cur.fetchall()
cur.close()
updated = 0
with get_connection() as conn:
cur = conn.cursor()
for r in rows:
pid = r["id"]
pname = _normalize(r.get("project_name") or "")
pdept = _normalize(r.get("project_department") or "")
st = None
# 基建:项目部关键字匹配(如 剑河项目部 -> 基建工程)
for kw, typ in build_keywords:
if kw in pdept or kw in pname:
st = typ
break
if not st:
for t in order:
names = by_type.get(t, [])
for n in names:
if n and (n in pname or n in pdept or (len(n) >= 4 and pname.startswith(n[:4]))):
st = t
break
if st:
break
if not st:
st = "其他"
cur.execute("UPDATE projects SET statistics_type = %s WHERE id = %s", (st, pid))
updated += 1
conn.commit()
cur.close()
print(f"已根据统计表填充 statistics_type,共更新 {updated} 条项目。")
if __name__ == "__main__":
main()
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""
为 projects 表增加 statistics_type 列(若不存在)。
用法:cd backend && PYTHONPATH=src python3 scripts/migrate_add_statistics_type.py
"""
import sys
from pathlib import Path
_backend = Path(__file__).resolve().parent.parent
if str(_backend) not in sys.path:
sys.path.insert(0, str(_backend))
from src.database import get_connection
def main():
with get_connection() as conn:
cur = conn.cursor()
try:
cur.execute(
"ALTER TABLE projects "
"ADD COLUMN statistics_type varchar(32) DEFAULT NULL "
"COMMENT '统计类型:基建工程、业扩项目、户表、客户工程、营销项目、检修-技改-抢修、其他' "
"AFTER cost"
)
conn.commit()
print("已为 projects 表添加 statistics_type 列。")
except Exception as e:
if "Duplicate column name" in str(e):
print("列已存在,无需迁移。")
else:
raise
finally:
cur.close()
if __name__ == "__main__":
main()
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""
将 projects 表中 statistics_type 为「检修、技改、抢修」的项统一改为「检修-技改-抢修」。
用法:cd backend && PYTHONPATH=src python3 scripts/migrate_statistics_type_repair.py
"""
import sys
from pathlib import Path
_backend = Path(__file__).resolve().parent.parent
if str(_backend) not in sys.path:
sys.path.insert(0, str(_backend))
from src.database import get_connection
def main():
with get_connection() as conn:
cur = conn.cursor()
cur.execute(
"UPDATE projects SET statistics_type = %s WHERE statistics_type = %s",
("检修-技改-抢修", "检修、技改、抢修"),
)
n = cur.rowcount
conn.commit()
cur.close()
print(f"已将 statistics_type「检修、技改、抢修」更新为「检修-技改-抢修」,共 {n} 条。")
if __name__ == "__main__":
main()
+122
View File
@@ -35,6 +35,11 @@ class ProjectListBody(BaseModel):
dateAbnormal: Optional[bool] = None # True:只查日期异常项目
amountMin: Optional[float] = None # 合同金额(万元)下限
amountMax: Optional[float] = None # 合同金额(万元)上限
statisticsType: Optional[str] = None # 全部|基建工程|业扩项目|户表|客户工程|营销项目|检修-技改-抢修|其他
class ProjectStatisticsBody(BaseModel):
statisticsType: Optional[str] = None # 全部 返回各类型数量;具体类型返回该类型项目列表
class ProjectDetailBody(BaseModel):
@@ -52,6 +57,7 @@ class ProjectCreateBody(BaseModel):
receivable: Optional[dict] = None
payable: Optional[dict] = None
other: Optional[dict] = None
statisticsType: Optional[str] = None # 基建工程|业扩项目|户表|客户工程|营销项目|检修-技改-抢修|其他
class ProjectUpdateBody(BaseModel):
@@ -61,6 +67,7 @@ class ProjectUpdateBody(BaseModel):
receivable: Optional[dict] = None
payable: Optional[dict] = None
other: Optional[dict] = None
statisticsType: Optional[str] = None
class ProjectLogsBody(BaseModel):
@@ -69,6 +76,29 @@ class ProjectLogsBody(BaseModel):
pageSize: Optional[int] = 20
class UserCreateBody(BaseModel):
username: Optional[str] = None
password: Optional[str] = None
role: Optional[str] = None
displayName: Optional[str] = None
class UserUpdateBody(BaseModel):
id: str
password: Optional[str] = None
role: Optional[str] = None
displayName: Optional[str] = None
class UserDeleteBody(BaseModel):
id: str
class UserListBody(BaseModel):
page: Optional[int] = 1
pageSize: Optional[int] = 100
def _get_current_user(authorization: Optional[str] = Header(None)) -> dict:
"""从 Authorization Bearer token 获取当前用户,否则 401。"""
if not authorization or not authorization.startswith("Bearer "):
@@ -80,6 +110,13 @@ def _get_current_user(authorization: Optional[str] = Header(None)) -> dict:
return user
def _require_admin(user: dict):
"""要求当前用户为管理员,否则返回 403 JSONResponse(路由需 return 其返回值)。"""
if user.get("role") != "管理员":
return JSONResponse(status_code=403, content={"code": 403, "message": "仅管理员可操作"})
return None
# ---------- 认证 ----------
@app.post("/api/auth/login")
def login(body: LoginBody):
@@ -126,10 +163,21 @@ def project_list(body: ProjectListBody, authorization: Optional[str] = Header(No
date_abnormal=body.dateAbnormal,
amount_min=body.amountMin,
amount_max=body.amountMax,
statistics_type=body.statisticsType,
)
return {"list": list_, "total": total, "page": page, "pageSize": page_size}
# ---------- 项目统计 ----------
@app.post("/api/projects/statistics")
def project_statistics(body: ProjectStatisticsBody, authorization: Optional[str] = Header(None)):
"""获取项目统计信息。statisticsType=全部 返回各类型数量;具体类型返回该类型项目列表。"""
_get_current_user(authorization)
st = (body.statisticsType or "全部").strip()
data = projects_repo.get_project_statistics(st)
return data
# ---------- 项目详情 ----------
@app.post("/api/projects/detail")
def project_detail(body: ProjectDetailBody, authorization: Optional[str] = Header(None)):
@@ -162,6 +210,7 @@ def project_create(body: ProjectCreateBody, authorization: Optional[str] = Heade
receivable=body.receivable,
payable=body.payable,
other=body.other,
statistics_type=body.statisticsType,
)
return {"id": pid, "message": "创建成功"}
@@ -179,6 +228,7 @@ def project_update(body: ProjectUpdateBody, authorization: Optional[str] = Heade
receivable=body.receivable,
payable=body.payable,
other=body.other,
statistics_type=body.statisticsType,
)
summary_parts = []
if body.contract is not None:
@@ -201,6 +251,78 @@ def project_update(body: ProjectUpdateBody, authorization: Optional[str] = Heade
return {"id": body.id, "message": "保存成功"}
# ---------- 用户管理(仅管理员) ----------
@app.post("/api/users/list")
def user_list(body: UserListBody, authorization: Optional[str] = Header(None)):
user = _get_current_user(authorization)
r = _require_admin(user)
if r is not None:
return r
page = body.page or 1
page_size = body.pageSize or 100
list_, total = auth.list_users(page=page, page_size=page_size)
return {"list": list_, "total": total, "page": page, "pageSize": page_size}
@app.post("/api/users/create", status_code=201)
def user_create(body: UserCreateBody, authorization: Optional[str] = Header(None)):
user = _get_current_user(authorization)
r = _require_admin(user)
if r is not None:
return r
if not body.username or (isinstance(body.username, str) and not body.username.strip()):
return JSONResponse(status_code=400, content={"code": 400, "message": "账号不能为空"})
if not body.password or (isinstance(body.password, str) and not body.password.strip()):
return JSONResponse(status_code=400, content={"code": 400, "message": "密码不能为空"})
if not body.role or body.role.strip() not in auth.ROLES:
return JSONResponse(status_code=400, content={"code": 400, "message": "角色无效"})
try:
uid = auth.create_user(
username=body.username.strip(),
password=body.password,
role=body.role.strip(),
display_name=body.displayName.strip() if body.displayName else None,
)
return {"id": uid, "message": "创建成功"}
except ValueError as e:
return JSONResponse(status_code=400, content={"code": 400, "message": str(e)})
@app.post("/api/users/update")
def user_update(body: UserUpdateBody, authorization: Optional[str] = Header(None)):
user = _get_current_user(authorization)
r = _require_admin(user)
if r is not None:
return r
if not auth.get_user_by_id(body.id):
return JSONResponse(status_code=404, content={"code": 404, "message": "用户不存在"})
if body.role is not None and body.role.strip() not in auth.ROLES:
return JSONResponse(status_code=400, content={"code": 400, "message": "角色无效"})
try:
auth.update_user(
body.id,
password=body.password,
role=body.role.strip() if body.role else None,
display_name=body.displayName.strip() if body.displayName else None,
)
return {"id": body.id, "message": "保存成功"}
except ValueError as e:
return JSONResponse(status_code=400, content={"code": 400, "message": str(e)})
@app.post("/api/users/delete")
def user_delete(body: UserDeleteBody, authorization: Optional[str] = Header(None)):
user = _get_current_user(authorization)
r = _require_admin(user)
if r is not None:
return r
if body.id == user["id"]:
return JSONResponse(status_code=400, content={"code": 400, "message": "不能删除当前登录用户"})
if not auth.delete_user(body.id):
return JSONResponse(status_code=404, content={"code": 404, "message": "用户不存在"})
return {"message": "删除成功"}
# ---------- 操作日志 ----------
@app.post("/api/projects/logs")
def project_logs(body: ProjectLogsBody, authorization: Optional[str] = Header(None)):
+130
View File
@@ -68,3 +68,133 @@ def get_user_by_token(token: str) -> Optional[dict[str, Any]]:
def hash_password(plain: str) -> str:
"""密码哈希(用于初始化用户等)"""
return pwd_ctx.hash(plain)
def _hash_bcrypt(plain: str) -> str:
"""使用 bcrypt 生成哈希,与 _verify_password 兼容"""
return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
ROLES = ("管理员", "市场部", "工程部", "技经部", "财务部", "物贸部")
def create_user(username: str, password: str, role: str, display_name: Optional[str] = None) -> str:
"""创建用户,返回用户 id。username 重复则抛 ValueError。"""
username = username.strip()
if not username:
raise ValueError("账号不能为空")
if not password or len(password.strip()) < 1:
raise ValueError("密码不能为空")
if role not in ROLES:
raise ValueError("角色无效")
uid = new_id()
pwd_hash = _hash_bcrypt(password)
display = (display_name or username).strip() or username
with get_connection() as conn:
cur = conn.cursor()
try:
cur.execute(
"INSERT INTO users (id, username, password_hash, role, display_name) VALUES (%s, %s, %s, %s, %s)",
(uid, username, pwd_hash, role, display),
)
conn.commit()
except Exception as e:
if "Duplicate entry" in str(e) or "1062" in str(e):
raise ValueError("账号已存在")
raise
finally:
cur.close()
return uid
def list_users(page: int = 1, page_size: int = 100) -> tuple[list[dict], int]:
"""分页获取用户列表(不含密码),返回 (list, total)。"""
with get_connection() as conn:
cur = conn.cursor(dictionary=True)
cur.execute("SELECT COUNT(*) AS cnt FROM users")
total = cur.fetchone()["cnt"]
offset = (page - 1) * page_size
cur.execute(
"SELECT id, username, role, display_name, created_at, updated_at FROM users ORDER BY created_at DESC LIMIT %s OFFSET %s",
(page_size, offset),
)
rows = cur.fetchall()
cur.close()
list_ = [
{
"id": r["id"],
"username": r["username"],
"role": r["role"],
"displayName": r["display_name"] or r["username"],
"createdAt": r["created_at"].isoformat() if r.get("created_at") else "",
"updatedAt": r["updated_at"].isoformat() if r.get("updated_at") else "",
}
for r in rows
]
return list_, total
def get_user_by_id(user_id: str) -> Optional[dict[str, Any]]:
"""按 id 查询用户(不含密码),不存在返回 None。"""
with get_connection() as conn:
cur = conn.cursor(dictionary=True)
cur.execute(
"SELECT id, username, role, display_name, created_at, updated_at FROM users WHERE id = %s",
(user_id,),
)
row = cur.fetchone()
cur.close()
if not row:
return None
return {
"id": row["id"],
"username": row["username"],
"role": row["role"],
"displayName": row["display_name"] or row["username"],
"createdAt": row["created_at"].isoformat() if row.get("created_at") else None,
"updatedAt": row["updated_at"].isoformat() if row.get("updated_at") else None,
}
def update_user(
user_id: str,
password: Optional[str] = None,
role: Optional[str] = None,
display_name: Optional[str] = None,
) -> bool:
"""更新用户(仅更新传入的字段)。不存在返回 False。"""
if not get_user_by_id(user_id):
return False
if role is not None and role not in ROLES:
raise ValueError("角色无效")
updates = []
params = []
if password is not None and len(password.strip()) > 0:
updates.append("password_hash = %s")
params.append(_hash_bcrypt(password))
if role is not None:
updates.append("role = %s")
params.append(role)
if display_name is not None:
updates.append("display_name = %s")
params.append(display_name.strip() or None)
if not updates:
return True
params.append(user_id)
with get_connection() as conn:
cur = conn.cursor()
cur.execute("UPDATE users SET " + ", ".join(updates) + " WHERE id = %s", params)
conn.commit()
cur.close()
return True
def delete_user(user_id: str) -> bool:
"""删除用户。不存在返回 False。"""
with get_connection() as conn:
cur = conn.cursor()
cur.execute("DELETE FROM users WHERE id = %s", (user_id,))
n = cur.rowcount
conn.commit()
cur.close()
return n > 0
+84 -3
View File
@@ -171,12 +171,16 @@ def list_projects(
date_abnormal: Optional[bool] = None,
amount_min: Optional[float] = None,
amount_max: Optional[float] = None,
statistics_type: Optional[str] = None,
) -> tuple[list[dict], int]:
"""分页列表,支持按项目名称/合同编号搜索、按进度/费用/合同金额筛选、时间筛选、日期异常筛选。返回 (list, total)。"""
"""分页列表,支持按项目名称/合同编号搜索、按进度/费用/合同金额/统计类型筛选、时间筛选、日期异常筛选。返回 (list, total)。"""
with get_connection() as conn:
cur = conn.cursor(dictionary=True)
where_parts = []
params: list[Any] = []
if statistics_type and statistics_type.strip() and statistics_type.strip() != "全部":
where_parts.append("statistics_type = %s")
params.append(statistics_type.strip())
if keyword and keyword.strip():
if search_type == "name":
where_parts.append("project_name LIKE %s")
@@ -226,7 +230,7 @@ def list_projects(
cur.execute(
f"""SELECT id, project_name, contract_code, progress, cost, updated_at,
bid_contract_amount, owner_unit, sign_date, sign_date_str,
project_department, total_cost, project_leader_contact
project_department, total_cost, project_leader_contact, statistics_type
FROM projects WHERE {where_sql} ORDER BY updated_at DESC LIMIT %s OFFSET %s""",
tuple(params) + (page_size, offset),
)
@@ -235,7 +239,12 @@ def list_projects(
list_ = []
for r in rows:
_sd = r.get("sign_date")
sign_val = r.get("sign_date_str") or (_sd.strftime("%Y-%m-%d") if _sd else None)
sign_date_str_val = r.get("sign_date_str")
# 只有不是「日期正常」时才显示原文;否则显示实际日期
if sign_date_str_val and str(sign_date_str_val).strip() != DATE_NORMAL:
sign_val = sign_date_str_val
else:
sign_val = (_sd.strftime("%Y-%m-%d") if _sd else None) or (sign_date_str_val or "")
list_.append({
"id": r["id"],
"projectName": r["project_name"] or "",
@@ -249,10 +258,76 @@ def list_projects(
"projectDepartment": r.get("project_department") or "",
"totalCost": str(r["total_cost"]) if r.get("total_cost") is not None else "",
"projectLeaderContact": r.get("project_leader_contact") or "",
"statisticsType": r.get("statistics_type") or "",
})
return list_, total
# 统计类型枚举(与前端/API 一致)
STATISTICS_TYPES = (
"全部",
"基建工程",
"业扩项目",
"户表",
"客户工程",
"营销项目",
"检修-技改-抢修",
"其他",
)
def get_project_statistics(statistics_type: Optional[str] = None) -> dict:
"""获取项目统计信息。statistics_type 为「全部」或空时返回各类型数量;否则返回该类型下的 list 与 total。"""
with get_connection() as conn:
cur = conn.cursor(dictionary=True)
if not statistics_type or statistics_type.strip() == "全部":
cur.execute(
"""SELECT statistics_type AS type, COUNT(*) AS cnt FROM projects
GROUP BY statistics_type"""
)
rows = cur.fetchall()
cur.close()
by_type = {r["type"] or "其他": r["cnt"] for r in rows}
total = sum(by_type.values())
return {"total": total, "statisticsType": "全部", "byType": by_type}
st = statistics_type.strip()
cur.execute("SELECT COUNT(*) AS cnt FROM projects WHERE statistics_type = %s", (st,))
total = cur.fetchone()["cnt"]
cur.execute(
"""SELECT id, project_name, contract_code, progress, cost, updated_at,
bid_contract_amount, owner_unit, sign_date, sign_date_str,
project_department, total_cost, project_leader_contact, statistics_type
FROM projects WHERE statistics_type = %s ORDER BY updated_at DESC""",
(st,),
)
rows = cur.fetchall()
cur.close()
list_ = []
for r in rows:
_sd = r.get("sign_date")
sign_date_str_val = r.get("sign_date_str")
if sign_date_str_val and str(sign_date_str_val).strip() != DATE_NORMAL:
sign_val = sign_date_str_val
else:
sign_val = (_sd.strftime("%Y-%m-%d") if _sd else None) or (sign_date_str_val or "")
list_.append({
"id": r["id"],
"projectName": r["project_name"] or "",
"contractCode": r["contract_code"] or "",
"progress": r["progress"] or "",
"cost": r["cost"] or "",
"updatedAt": r["updated_at"].isoformat() if r.get("updated_at") else "",
"bidContractAmount": r.get("bid_contract_amount") or "",
"ownerUnit": r.get("owner_unit") or "",
"signDate": sign_val or "",
"projectDepartment": r.get("project_department") or "",
"totalCost": str(r["total_cost"]) if r.get("total_cost") is not None else "",
"projectLeaderContact": r.get("project_leader_contact") or "",
"statisticsType": r.get("statistics_type") or "",
})
return {"total": total, "statisticsType": st, "list": list_}
def get_project(project_id: str) -> Optional[dict[str, Any]]:
"""按 id 查项目,返回 API 形态详情,不存在返回 None。"""
with get_connection() as conn:
@@ -271,6 +346,7 @@ def create_project(
receivable: Optional[dict] = None,
payable: Optional[dict] = None,
other: Optional[dict] = None,
statistics_type: Optional[str] = None,
) -> str:
"""新建项目,合同信息必含 contract_code、project_name。返回新项目 id。"""
pid = new_id()
@@ -285,6 +361,8 @@ def create_project(
row["id"] = pid
row["progress"] = (row.get("cumulative_progress") or row.get("project_name") or "")[:32] if row.get("cumulative_progress") or row.get("project_name") else ""
row["cost"] = ""
if statistics_type is not None and str(statistics_type).strip():
row["statistics_type"] = str(statistics_type).strip()
row["created_at"] = now
row["updated_at"] = now
cols = [k for k in row.keys()]
@@ -307,6 +385,7 @@ def update_project(
receivable: Optional[dict] = None,
payable: Optional[dict] = None,
other: Optional[dict] = None,
statistics_type: Optional[str] = None,
) -> bool:
"""更新项目(合并传入的字段),不存在返回 False。"""
c = _api_to_db(contract, CONTRACT_API_TO_DB)
@@ -316,6 +395,8 @@ def update_project(
pay = _api_to_db(payable, PAYABLE_API_TO_DB)
oth = _api_to_db(other, OTHER_API_TO_DB)
merged = {**c, **cc, **recv, **pay, **oth}
if statistics_type is not None:
merged["statistics_type"] = str(statistics_type).strip() if statistics_type else None
if not merged:
return project_exists(project_id)
merged["updated_at"] = datetime.utcnow()
+4
View File
@@ -37,6 +37,10 @@ TEST_BASE_URL=http://localhost:8000 pytest tests/ -v
| 接口 | 用例 |
|------|------|
| POST /api/auth/login | 成功返回 token/user、错误凭证 401、缺 username/password 返回 400 |
| POST /api/users/create | 无 token 401、非管理员 403、管理员 201+id+message、缺 username/password/role 400 |
| POST /api/users/update | 无 token 401、非管理员 403、管理员 200+id+message、不存在 404 |
| POST /api/users/delete | 无 token 401、非管理员 403、管理员 200+message、不存在 404、不能删自己 400 |
| POST /api/projects/statistics | 无 token 401;有 token 默认/全部 返回 total、statisticsType、byType;具体类型返回 total、statisticsType、list |
| POST /api/projects/list | 无 token 401;有 token 返回 list/total/page/pageSize;无数据 list 空且 total=0;支持 searchType+keyword(name/code)、progress、cost、dateFilterType+dateFrom/dateTo、dateAbnormal;列表项含 id/projectName/contractCode/progress/cost/updatedAtpage/pageSize 回显 |
| POST /api/projects/detail | 无 token 401、缺 id 400/422、存在项目返回完整五类字段、不存在 404 |
| POST /api/projects/create | 无 token 401、非市场部 403、市场部且必填齐全 201、缺合同编号/项目名称 400、校验失败可含 errors 数组 |
+63 -5
View File
@@ -10,6 +10,64 @@ def _auth_header(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
# ---------- POST /api/projects/statisticsAPI 文档第 3 节)----------
def test_statistics_without_token_returns_401(client):
"""项目统计无 token 时返回 401"""
r = client.post("/api/projects/statistics", json={})
assert r.status_code == 401
def test_statistics_with_token_default_returns_200_with_total_and_by_type(client, auth_token):
"""有 token 且未传 statisticsType(或传全部)时返回 200,含 total、statisticsType、byType"""
r = client.post(
"/api/projects/statistics",
headers=_auth_header(auth_token),
json={},
)
assert r.status_code == 200
body = r.json()
assert "total" in body
assert isinstance(body["total"], int)
assert "statisticsType" in body
assert body["statisticsType"] == "全部"
assert "byType" in body
assert isinstance(body["byType"], dict)
def test_statistics_with_statistics_type_all_returns_by_type(client, auth_token):
"""statisticsType=全部 时返回 byType 各类型数量"""
r = client.post(
"/api/projects/statistics",
headers=_auth_header(auth_token),
json={"statisticsType": "全部"},
)
assert r.status_code == 200
body = r.json()
assert body.get("statisticsType") == "全部"
assert "byType" in body
for _, count in body["byType"].items():
assert isinstance(count, (int, float))
def test_statistics_with_specific_type_returns_200_with_total_and_list(client, auth_token):
"""statisticsType 为具体类型时返回 200,含 total、statisticsType、list"""
r = client.post(
"/api/projects/statistics",
headers=_auth_header(auth_token),
json={"statisticsType": "基建工程"},
)
assert r.status_code == 200
body = r.json()
assert "total" in body
assert isinstance(body["total"], int)
assert "statisticsType" in body
assert body["statisticsType"] == "基建工程"
assert "list" in body
assert isinstance(body["list"], list)
# ---------- POST /api/projects/list ----------
@@ -495,7 +553,7 @@ def test_e2e_login_then_get_project_list(client):
def test_e2e_login_then_create_project_then_list_includes_it(client):
"""E2E:登录(市场部)→ 创建项目 → 获取列表,列表中包含新建项目"""
"""E2E:登录(市场部)→ 创建项目 → 按名称搜索列表,列表中包含新建项目"""
token = _login_and_get_token(client, "market", "123456")
project_name = "E2E流程新建项目"
contract_code = "HT-E2E-001"
@@ -509,14 +567,14 @@ def test_e2e_login_then_create_project_then_list_includes_it(client):
list_r = client.post(
"/api/projects/list",
headers=_auth_header(token),
json={"page": 1, "pageSize": 100},
json={"page": 1, "pageSize": 20, "searchType": "name", "keyword": project_name},
)
assert list_r.status_code == 200
items = list_r.json().get("list", [])
ids = [p["id"] for p in items]
assert pid in ids
names = [p.get("projectName") for p in items if p.get("id") == pid]
assert project_name in names or len(names) >= 1
assert pid in ids, f"新建项目 id {pid} 应在搜索结果中,list ids: {ids[:5]}..."
found = next((p for p in items if p.get("id") == pid), None)
assert found is not None and found.get("projectName") == project_name
def test_e2e_login_then_create_then_detail_then_update_then_detail_reflects(client):
+252
View File
@@ -0,0 +1,252 @@
"""
用户管理 API 测试(依据 API 文档 2.2 节)
仅管理员可创建、修改、删除用户。测试对已启动服务发起 HTTP 请求,使用 admin token。
"""
import uuid
def _auth_header(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
# ---------- POST /api/users/create ----------
def test_users_create_without_token_returns_401(client):
"""创建用户无 token 时返回 401"""
r = client.post(
"/api/users/create",
json={"username": "u1", "password": "123456", "role": "工程部"},
)
assert r.status_code == 401
def test_users_create_non_admin_returns_403(client, engineer_token):
"""非管理员创建用户返回 403"""
r = client.post(
"/api/users/create",
headers=_auth_header(engineer_token),
json={"username": "u_engineer_cannot", "password": "123456", "role": "工程部"},
)
assert r.status_code == 403
assert r.json().get("code") == 403
def test_users_create_admin_returns_201_with_id_and_message(client, auth_token):
"""管理员创建用户返回 201 且包含 id 和 message「创建成功」"""
username = f"testuser_create_{uuid.uuid4().hex[:8]}"
r = client.post(
"/api/users/create",
headers=_auth_header(auth_token),
json={
"username": username,
"password": "123456",
"role": "技经部",
"displayName": "测试创建用户",
},
)
assert r.status_code == 201
body = r.json()
assert "id" in body
assert body.get("message") == "创建成功"
def test_users_create_missing_username_returns_400(client, auth_token):
"""创建用户缺少 username 时返回 400"""
r = client.post(
"/api/users/create",
headers=_auth_header(auth_token),
json={"password": "123456", "role": "工程部"},
)
assert r.status_code == 400
assert r.json().get("code") == 400
def test_users_create_missing_password_returns_400(client, auth_token):
"""创建用户缺少 password 时返回 400"""
r = client.post(
"/api/users/create",
headers=_auth_header(auth_token),
json={"username": "testuser_nopwd", "role": "工程部"},
)
assert r.status_code == 400
assert r.json().get("code") == 400
def test_users_create_missing_role_returns_400(client, auth_token):
"""创建用户缺少 role 时返回 400"""
r = client.post(
"/api/users/create",
headers=_auth_header(auth_token),
json={"username": "testuser_norole", "password": "123456"},
)
assert r.status_code == 400
assert r.json().get("code") == 400
# ---------- POST /api/users/update ----------
def test_users_update_without_token_returns_401(client):
"""更新用户无 token 时返回 401"""
r = client.post("/api/users/update", json={"id": "any-id"})
assert r.status_code == 401
def test_users_update_non_admin_returns_403(client, engineer_token, auth_token):
"""非管理员更新用户返回 403"""
username = f"testuser_for_update_{uuid.uuid4().hex[:8]}"
create_r = client.post(
"/api/users/create",
headers=_auth_header(auth_token),
json={"username": username, "password": "123456", "role": "财务部"},
)
assert create_r.status_code == 201
uid = create_r.json()["id"]
r = client.post(
"/api/users/update",
headers=_auth_header(engineer_token),
json={"id": uid, "displayName": "不应成功"},
)
assert r.status_code == 403
assert r.json().get("code") == 403
def test_users_update_admin_returns_200_with_id_and_message(client, auth_token):
"""管理员更新用户返回 200 且包含 id 和 message「保存成功」"""
username = f"testuser_update_{uuid.uuid4().hex[:8]}"
create_r = client.post(
"/api/users/create",
headers=_auth_header(auth_token),
json={"username": username, "password": "123456", "role": "物贸部"},
)
assert create_r.status_code == 201
uid = create_r.json()["id"]
r = client.post(
"/api/users/update",
headers=_auth_header(auth_token),
json={"id": uid, "displayName": "更新后的显示名", "role": "财务部"},
)
assert r.status_code == 200
body = r.json()
assert body.get("id") == uid
assert body.get("message") == "保存成功"
def test_users_update_nonexistent_returns_404(client, auth_token):
"""更新不存在用户返回 404"""
r = client.post(
"/api/users/update",
headers=_auth_header(auth_token),
json={"id": "non-existent-user-id", "displayName": "任意"},
)
assert r.status_code == 404
assert r.json().get("code") == 404
# ---------- POST /api/users/delete ----------
def test_users_delete_without_token_returns_401(client):
"""删除用户无 token 时返回 401"""
r = client.post("/api/users/delete", json={"id": "any-id"})
assert r.status_code == 401
def test_users_delete_non_admin_returns_403(client, engineer_token, auth_token):
"""非管理员删除用户返回 403"""
username = f"testuser_for_del_{uuid.uuid4().hex[:8]}"
create_r = client.post(
"/api/users/create",
headers=_auth_header(auth_token),
json={"username": username, "password": "123456", "role": "工程部"},
)
assert create_r.status_code == 201
uid = create_r.json()["id"]
r = client.post(
"/api/users/delete",
headers=_auth_header(engineer_token),
json={"id": uid},
)
assert r.status_code == 403
assert r.json().get("code") == 403
def test_users_delete_admin_returns_200_with_message(client, auth_token):
"""管理员删除用户返回 200 且 message「删除成功」"""
username = f"testuser_delete_{uuid.uuid4().hex[:8]}"
create_r = client.post(
"/api/users/create",
headers=_auth_header(auth_token),
json={"username": username, "password": "123456", "role": "工程部"},
)
assert create_r.status_code == 201
uid = create_r.json()["id"]
r = client.post(
"/api/users/delete",
headers=_auth_header(auth_token),
json={"id": uid},
)
assert r.status_code == 200
assert r.json().get("message") == "删除成功"
def test_users_delete_nonexistent_returns_404(client, auth_token):
"""删除不存在用户返回 404"""
r = client.post(
"/api/users/delete",
headers=_auth_header(auth_token),
json={"id": "non-existent-user-id"},
)
assert r.status_code == 404
assert r.json().get("code") == 404
def test_users_delete_self_returns_400(client, auth_token):
"""管理员不能删除当前登录用户自己,返回 400"""
# 先登录拿到当前用户 id(从 login 响应的 user.id
login_r = client.post("/api/auth/login", json={"username": "admin", "password": "123456"})
assert login_r.status_code == 200
current_user_id = login_r.json()["user"]["id"]
r = client.post(
"/api/users/delete",
headers=_auth_header(auth_token),
json={"id": current_user_id},
)
assert r.status_code == 400
body = r.json()
assert body.get("code") == 400
assert "message" in body
# ---------- E2E:管理员 创建 → 修改 → 删除 用户 ----------
def test_e2e_admin_create_then_update_then_delete_user(client, auth_token):
"""E2E:管理员创建用户 → 修改用户 → 删除用户"""
username = f"testuser_e2e_{uuid.uuid4().hex[:8]}"
create_r = client.post(
"/api/users/create",
headers=_auth_header(auth_token),
json={"username": username, "password": "123456", "role": "市场部", "displayName": "E2E初始"},
)
assert create_r.status_code == 201
uid = create_r.json()["id"]
assert uid
update_r = client.post(
"/api/users/update",
headers=_auth_header(auth_token),
json={"id": uid, "displayName": "E2E已修改", "role": "技经部"},
)
assert update_r.status_code == 200
assert update_r.json().get("message") == "保存成功"
delete_r = client.post(
"/api/users/delete",
headers=_auth_header(auth_token),
json={"id": uid},
)
assert delete_r.status_code == 200
assert delete_r.json().get("message") == "删除成功"