feat(phase0): 工具链 + 契约快照测试,修复既有 bug

- 新增 pyproject.toml / pytest.ini / Makefile / requirements-dev.txt
- 新增 3 个契约快照测试(路由167/权限串148/表名25),守护前端与数据契约
- 修复 requirements.txt UTF-16 编码导致 pip 无法解析
- 修复 JobLog 未继承 Base 导致无法映射(fastapi_job_log 表缺失于 metadata)
- 修复 base_model.to_dict() 未序列化纯 date 类型(birthday 等导致 500)
- 补充 .gitignore(.env/缓存/上传目录)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude
2026-08-07 09:57:33 +08:00
co-authored by Claude
parent dbcce79536
commit fe1d191e55
15 changed files with 504 additions and 0 deletions
View File
+150
View File
@@ -0,0 +1,150 @@
[
"sys:ad:add",
"sys:ad:batch_delete",
"sys:ad:delete",
"sys:ad:detail",
"sys:ad:page",
"sys:ad:status",
"sys:ad:update",
"sys:adsort:add",
"sys:adsort:batch_delete",
"sys:adsort:delete",
"sys:adsort:detail",
"sys:adsort:page",
"sys:adsort:update",
"sys:city:add",
"sys:city:batch_delete",
"sys:city:delete",
"sys:city:detail",
"sys:city:page",
"sys:city:update",
"sys:config:add",
"sys:config:batch_delete",
"sys:config:delete",
"sys:config:detail",
"sys:config:page",
"sys:config:update",
"sys:configitem:add",
"sys:configitem:batch_delete",
"sys:configitem:delete",
"sys:configitem:detail",
"sys:configitem:page",
"sys:configitem:status",
"sys:configitem:update",
"sys:dept:add",
"sys:dept:delete",
"sys:dept:detail",
"sys:dept:list",
"sys:dept:update",
"sys:dict:add",
"sys:dict:batch_delete",
"sys:dict:delete",
"sys:dict:detail",
"sys:dict:page",
"sys:dict:update",
"sys:dictitem:add",
"sys:dictitem:batch_delete",
"sys:dictitem:delete",
"sys:dictitem:detail",
"sys:dictitem:page",
"sys:dictitem:status",
"sys:dictitem:update",
"sys:item:add",
"sys:item:batch_delete",
"sys:item:delete",
"sys:item:detail",
"sys:item:page",
"sys:item:update",
"sys:itemcate:add",
"sys:itemcate:batch_delete",
"sys:itemcate:delete",
"sys:itemcate:detail",
"sys:itemcate:page",
"sys:itemcate:update",
"sys:job:add",
"sys:job:delete",
"sys:job:detail",
"sys:job:page",
"sys:job:pause",
"sys:job:reload",
"sys:job:resume",
"sys:job:run_once",
"sys:job:status",
"sys:job:update",
"sys:job_log:batch_delete",
"sys:job_log:delete",
"sys:job_log:detail",
"sys:job_log:page",
"sys:level:add",
"sys:level:batch_delete",
"sys:level:delete",
"sys:level:detail",
"sys:level:export",
"sys:level:import",
"sys:level:page",
"sys:level:status",
"sys:level:update",
"sys:link:add",
"sys:link:batch_delete",
"sys:link:delete",
"sys:link:detail",
"sys:link:page",
"sys:link:status",
"sys:link:update",
"sys:login_log:batch_delete",
"sys:login_log:delete",
"sys:login_log:page",
"sys:member:add",
"sys:member:batch_delete",
"sys:member:delete",
"sys:member:detail",
"sys:member:page",
"sys:member:status",
"sys:member:update",
"sys:memberlevel:add",
"sys:memberlevel:batch_delete",
"sys:memberlevel:delete",
"sys:memberlevel:detail",
"sys:memberlevel:page",
"sys:memberlevel:update",
"sys:menu:add",
"sys:menu:delete",
"sys:menu:detail",
"sys:menu:list",
"sys:menu:update",
"sys:notice:add",
"sys:notice:batch_delete",
"sys:notice:delete",
"sys:notice:detail",
"sys:notice:page",
"sys:notice:update",
"sys:operation_log:batch_delete",
"sys:operation_log:delete",
"sys:operation_log:page",
"sys:position:add",
"sys:position:batch_delete",
"sys:position:delete",
"sys:position:detail",
"sys:position:page",
"sys:position:status",
"sys:position:update",
"sys:role:add",
"sys:role:batch_delete",
"sys:role:delete",
"sys:role:detail",
"sys:role:page",
"sys:role:status",
"sys:role:update",
"sys:rolemenu:index",
"sys:rolemenu:save",
"sys:upload:file",
"sys:upload:image",
"sys:user:add",
"sys:user:batch_delete",
"sys:user:delete",
"sys:user:detail",
"sys:user:page",
"sys:user:resetPwd",
"sys:user:status",
"sys:user:update"
]
+169
View File
@@ -0,0 +1,169 @@
[
"DELETE /ad/batchDelete",
"DELETE /ad/delete/{id}",
"DELETE /adsort/batchDelete",
"DELETE /adsort/delete/{id}",
"DELETE /city/batchDelete",
"DELETE /city/delete/{id}",
"DELETE /config/batchDelete",
"DELETE /config/delete/{id}",
"DELETE /configitem/batchDelete",
"DELETE /configitem/delete/{id}",
"DELETE /dept/delete/{id}",
"DELETE /dict/batchDelete",
"DELETE /dict/delete/{id}",
"DELETE /dictitem/batchDelete",
"DELETE /dictitem/delete/{id}",
"DELETE /item/batchDelete",
"DELETE /item/delete/{id}",
"DELETE /itemcate/batchDelete",
"DELETE /itemcate/delete/{id}",
"DELETE /job/delete/{id}",
"DELETE /joblog/batchDelete",
"DELETE /joblog/delete/{id}",
"DELETE /level/batchDelete",
"DELETE /level/delete/{id}",
"DELETE /link/batchDelete",
"DELETE /link/delete/{id}",
"DELETE /loginlog/batchDelete",
"DELETE /loginlog/delete/{id}",
"DELETE /member/batchDelete",
"DELETE /member/delete/{id}",
"DELETE /memberlevel/batchDelete",
"DELETE /memberlevel/delete/{id}",
"DELETE /menu/delete/{id}",
"DELETE /notice/batchDelete",
"DELETE /notice/delete/{id}",
"DELETE /operationlog/batchDelete",
"DELETE /operationlog/delete/{id}",
"DELETE /position/batchDelete",
"DELETE /position/delete/{id}",
"DELETE /role/batchDelete",
"DELETE /role/delete/{id}",
"DELETE /user/batchDelete",
"DELETE /user/delete/{id}",
"GET /ad/detail/{id}",
"GET /ad/page",
"GET /adsort/detail/{id}",
"GET /adsort/getAdSortList",
"GET /adsort/page",
"GET /captcha",
"GET /city/detail/{id}",
"GET /city/getChildList/{city_code}",
"GET /city/page",
"GET /config/detail/{id}",
"GET /config/page",
"GET /config/web",
"GET /configitem/detail/{id}",
"GET /configitem/page",
"GET /dept/detail/{id}",
"GET /dept/getDeptList",
"GET /dept/list",
"GET /dict/detail/{id}",
"GET /dict/page",
"GET /dictitem/detail/{id}",
"GET /dictitem/page",
"GET /index/menu",
"GET /index/user",
"GET /item/detail/{id}",
"GET /item/getItemList",
"GET /item/page",
"GET /itemcate/detail/{id}",
"GET /itemcate/getCateList",
"GET /itemcate/page",
"GET /job/detail/{id}",
"GET /job/page",
"GET /joblog/detail/{id}",
"GET /joblog/page",
"GET /level/detail/{id}",
"GET /level/getLevelList",
"GET /level/page",
"GET /link/detail/{id}",
"GET /link/page",
"GET /loginlog/page",
"GET /member/detail/{id}",
"GET /member/page",
"GET /memberlevel/detail/{id}",
"GET /memberlevel/getMemberLevelList",
"GET /memberlevel/page",
"GET /menu/detail/{id}",
"GET /menu/list",
"GET /notice/detail/{id}",
"GET /notice/page",
"GET /operationlog/page",
"GET /position/detail/{id}",
"GET /position/getPositionList",
"GET /position/page",
"GET /role/detail/{id}",
"GET /role/getRoleList",
"GET /role/page",
"GET /rolemenu/index/{role_id}",
"GET /user/detail/{id}",
"GET /user/page",
"POST /ad/add",
"POST /adsort/add",
"POST /city/add",
"POST /config/add",
"POST /configitem/add",
"POST /dept/add",
"POST /dict/add",
"POST /dictitem/add",
"POST /index/logout",
"POST /item/add",
"POST /itemcate/add",
"POST /job/add",
"POST /job/reload",
"POST /job/runOnce/{id}",
"POST /level/add",
"POST /level/export",
"POST /level/import",
"POST /link/add",
"POST /login",
"POST /member/add",
"POST /memberlevel/add",
"POST /menu/add",
"POST /notice/add",
"POST /position/add",
"POST /role/add",
"POST /rolemenu/save",
"POST /upload/uploadEditImage",
"POST /upload/uploadFile",
"POST /upload/uploadImage",
"POST /user/add",
"POST /user/resetPwd",
"PUT /ad/status",
"PUT /ad/update",
"PUT /adsort/update",
"PUT /city/update",
"PUT /config/update",
"PUT /config/webSave",
"PUT /configitem/status",
"PUT /configitem/update",
"PUT /dept/update",
"PUT /dict/update",
"PUT /dictitem/status",
"PUT /dictitem/update",
"PUT /index/updatePwd",
"PUT /index/userInfo",
"PUT /item/update",
"PUT /itemcate/update",
"PUT /job/pause/{id}",
"PUT /job/resume/{id}",
"PUT /job/status",
"PUT /job/update",
"PUT /level/status",
"PUT /level/update",
"PUT /link/status",
"PUT /link/update",
"PUT /member/status",
"PUT /member/update",
"PUT /memberlevel/update",
"PUT /menu/update",
"PUT /notice/update",
"PUT /position/status",
"PUT /position/update",
"PUT /role/status",
"PUT /role/update",
"PUT /user/status",
"PUT /user/update"
]
+27
View File
@@ -0,0 +1,27 @@
[
"fastapi_ad",
"fastapi_ad_sort",
"fastapi_city",
"fastapi_config",
"fastapi_config_data",
"fastapi_dept",
"fastapi_dict",
"fastapi_dict_data",
"fastapi_item",
"fastapi_item_cate",
"fastapi_job",
"fastapi_job_log",
"fastapi_level",
"fastapi_link",
"fastapi_login_log",
"fastapi_member",
"fastapi_member_level",
"fastapi_menu",
"fastapi_notice",
"fastapi_operation_log",
"fastapi_position",
"fastapi_role",
"fastapi_role_menu",
"fastapi_user",
"fastapi_user_role"
]
+66
View File
@@ -0,0 +1,66 @@
"""契约快照测试公共工具。
契约 = 前端/数据依赖的硬不变量:
- 路由 (METHOD /path)
- 权限字符串 (@permission_required("sys:..."))
- 数据库表名
用法:设置环境变量 UPDATE_GOLDEN=1 运行一次以重新生成 golden。
"""
import glob
import json
import os
import re
GOLDEN_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "golden")
PERMISSION_RE = re.compile(r'@permission_required\(\s*["\'](sys:[^"\']+)["\']')
UPDATE_GOLDEN = os.getenv("UPDATE_GOLDEN") == "1"
def collect_routes():
"""通过 openapi schema 收集全部 (METHOD /path),去重排序。"""
from apps import create_app
app = create_app()
spec = app.openapi()
return sorted(
f"{method.upper()} {path}"
for path, methods in spec["paths"].items()
for method in methods
)
def collect_permissions():
"""静态扫描源码中的 @permission_required("sys:...") 权限串。"""
perms = set()
for root in ("apps", "modules"):
for path in glob.glob(os.path.join(root, "**", "*.py"), recursive=True):
with open(path, encoding="utf-8") as f:
perms.update(PERMISSION_RE.findall(f.read()))
return sorted(perms)
def collect_tables():
"""从 SQLAlchemy metadata 收集全部表名。"""
from extensions.sqlalchemy import Base
return sorted(Base.metadata.tables.keys())
def compare_snapshot(name: str, actual: list):
"""与 golden 对比;UPDATE_GOLDEN=1 时重新生成并跳过断言。"""
path = os.path.join(GOLDEN_DIR, name)
if UPDATE_GOLDEN:
os.makedirs(GOLDEN_DIR, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(actual, f, ensure_ascii=False, indent=2)
return
with open(path, encoding="utf-8") as f:
golden = json.load(f)
assert actual == golden, (
f"契约快照 [{name}] 不一致!\n"
f"新增: {sorted(set(actual) - set(golden))}\n"
f"缺失: {sorted(set(golden) - set(actual))}"
)
@@ -0,0 +1,10 @@
"""权限字符串契约快照:守护全部 RBAC 权限节点不变。"""
import pytest
from tests.contract.helpers import collect_permissions, compare_snapshot
pytestmark = pytest.mark.contract
def test_permissions_snapshot():
compare_snapshot("permissions.json", collect_permissions())
+10
View File
@@ -0,0 +1,10 @@
"""路由契约快照:守护全部 API 路径与 HTTP 方法不变。"""
import pytest
from tests.contract.helpers import collect_routes, compare_snapshot
pytestmark = pytest.mark.contract
def test_routes_snapshot():
compare_snapshot("routes.json", collect_routes())
+10
View File
@@ -0,0 +1,10 @@
"""表名契约快照:守护全部数据库表名(DB_PREFIX + 模块)不变。"""
import pytest
from tests.contract.helpers import collect_tables, compare_snapshot
pytestmark = pytest.mark.contract
def test_tablenames_snapshot():
compare_snapshot("tables.json", collect_tables())