2c9ba9d6b3
新增 experience_feedback 表(32 表迁移,alembic head b7d1f4a92c3e),
老师傅对系统推荐方案给出"采纳 / 调整 / 拒绝"反馈,按"产品指纹 +
工艺参数"为键跨任务匹配,下次同指纹产品分析自动消费。
变更内容:
- src/moldinsight/models/experience_feedback.py(new)ORM:Base 单点来源、
跨模块裸 FK(user_id / processing_task_id / stp_file_id)、不建 ORM
relationship;fingerprint JSON 列存跨任务匹配键
- src/moldinsight/models/__init__.py 导出 ExperienceFeedback
- migrations/versions/b7d1f4a92c3e_add_experience_feedback.py(new)32 表
迁移;fingerprint 列 PG 下加 GIN 索引(jsonb_path_query 支持)
- src/shared/database/init_db.py 加 3 个权限码(view_experience_feedback /
feedback_experience_hint / manage_experience_feedback)+ 新角色
process_engineer;admin 角色 permissions 同步补齐;init_permissions /
init_roles 改为按 code 比对(新增保留已有 id,避免 FK 引用失效)——
修复既有 DB 启动期漏掉新权限的幂等 bug
- src/moldinsight/services/experience_feedback_service.py(new)service:
compute_fingerprint 分桶(bbox_aspect / volume_bucket / face_bucket /
undercut_class / material_family / is_foam)/ record_feedback(D9 边界:
service.flush + 路由 commit;D17 衰减:同 stp_file_id 整体续期 90 天 TTL,
无 celery beat 依赖)/ list_hints_for_task / resolve_for_process_params
- src/moldinsight/api/experience_feedback_router.py(new)路由:Pydantic
模型写在路由文件内(项目硬规则);POST /api/tasks/{task_id}/experience-feedback
+ GET /api/tasks/{task_id}/experience-hints;归属 TaskQueryService.ensure_task_access
+ User.has_permission 全仓首次调用点
- src/moldinsight/api/__init__.py ROUTE_MODULES 注册新路由
- tests/test_model_ownership.py EXPECTED_TABLES 加 experience_feedback
(31→32)
- tests/test_experience_feedback_fingerprint.py(new)分桶参数化覆盖
bbox / volume / face / undercut / material / is_foam 各边界值
- tests/test_experience_feedback_router.py(new)API 契约 9 例
(401/403/422/200 路径 + 衰减续期 + 任务归属校验 + ORM 注册收口)
- docs/STATUS.md 顶部加 2026-09-23 批 1 日志条目
- docs/TECH_DEBT.md D17 加批 1 已完成描述 + 剩余工作清单
- docs/API_CONTRACT.md §3.2 加 D17 端点表格
测试基线:185 passed, 9 skipped(净增 59 测试)。
Co-Authored-By: Claude Code <noreply@anthropic.com>
319 lines
13 KiB
Python
319 lines
13 KiB
Python
"""D17 Human-in-Loop 闭环:API 契约测试。
|
||
|
||
覆盖:
|
||
- POST /api/tasks/{task_id}/experience-feedback
|
||
- 401(无登录态 —— 由 Depends(get_current_active_user) 处理)
|
||
- 403(user 角色无 feedback_experience_hint 权限)
|
||
- 200(admin 角色有 manage_experience_feedback 全权限)
|
||
- 200(process_engineer 角色有 feedback_experience_hint 权限)
|
||
- GET /api/tasks/{task_id}/experience-hints
|
||
- 200 命中(同 stp_file_id 历史反馈聚合)
|
||
- cache invalidation(POST 写完后视图失效)
|
||
- D9 边界:record_feedback 失败时 db 不留半成品
|
||
"""
|
||
import pytest
|
||
from fastapi import FastAPI
|
||
from httpx import AsyncClient, ASGITransport
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||
from sqlalchemy.orm import configure_mappers
|
||
|
||
from shared.models.base import Base
|
||
from shared.models.identity import User, Role, Permission, RolePermission, UserRole
|
||
from shared.services.auth_service import get_current_active_user
|
||
from shared.database.database import get_db_session
|
||
|
||
from moldinsight.api.experience_feedback_router import router as feedback_router
|
||
from moldinsight.models import (
|
||
STPFile, GeometryData, MoldCavityData, ProcessingTask, ExperienceFeedback,
|
||
)
|
||
|
||
|
||
@pytest.fixture
|
||
async def feedback_client(async_engine, seeded_db):
|
||
"""构造带 experience_feedback_router 的 test app。
|
||
|
||
与 conftest.client 不同,这里我们用 seeded_db 的 user=tester,但通过依赖覆盖
|
||
让所有请求都以 admin 身份进(admin 是项目测试约定身份)。
|
||
|
||
关键点:override 返回的 User 必须用 selectinload 预加载 user_roles → role → role_permissions → permission,
|
||
否则 User.has_permission() 内部访问 self.roles 触发跨 session lazy load 失败。
|
||
"""
|
||
from sqlalchemy.orm import selectinload
|
||
|
||
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||
|
||
test_app = FastAPI()
|
||
test_app.include_router(feedback_router)
|
||
|
||
async def override_get_db_session():
|
||
async with session_factory() as session:
|
||
yield session
|
||
|
||
async def override_get_current_active_user():
|
||
async with session_factory() as session:
|
||
result = await session.execute(
|
||
select(User)
|
||
.where(User.username == "tester")
|
||
.options(
|
||
selectinload(User.user_roles)
|
||
.selectinload(UserRole.role)
|
||
.selectinload(Role.role_permissions)
|
||
.selectinload(RolePermission.permission)
|
||
)
|
||
)
|
||
return result.scalar_one()
|
||
|
||
test_app.dependency_overrides[get_db_session] = override_get_db_session
|
||
test_app.dependency_overrides[get_current_active_user] = override_get_current_active_user
|
||
|
||
transport = ASGITransport(app=test_app)
|
||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||
yield ac
|
||
|
||
test_app.dependency_overrides.clear()
|
||
|
||
|
||
async def _grant_permission(session, user, code):
|
||
"""给测试 user 加指定 permission_code。
|
||
|
||
注意:User.is_superuser 是 @property(派生自 role.code == "admin"),
|
||
不能直接赋值;admin 权限通过给 user 关联 'admin' role 触发。
|
||
"""
|
||
# 找/创建 permission
|
||
perm_row = await session.execute(select(Permission).where(Permission.code == code))
|
||
perm = perm_row.scalar_one_or_none()
|
||
if perm is None:
|
||
perm = Permission(code=code, name=code, module="moldinsight")
|
||
session.add(perm)
|
||
await session.flush()
|
||
|
||
# 找/创建 role(用 permission code 作 role code,便于复用)
|
||
role_row = await session.execute(select(Role).where(Role.code == code))
|
||
role = role_row.scalar_one_or_none()
|
||
if role is None:
|
||
role = Role(code=code, name=code, is_system=False)
|
||
session.add(role)
|
||
await session.flush()
|
||
rp = RolePermission(role_id=role.id, permission_id=perm.id)
|
||
session.add(rp)
|
||
|
||
# 关联 user(如未关联)
|
||
user_role_row = await session.execute(
|
||
select(UserRole).where(UserRole.user_id == user.id, UserRole.role_id == role.id)
|
||
)
|
||
if user_role_row.scalar_one_or_none() is None:
|
||
session.add(UserRole(user_id=user.id, role_id=role.id))
|
||
await session.commit()
|
||
|
||
|
||
# ── POST /experience-feedback 测试 ──
|
||
|
||
async def test_submit_feedback_403_without_permission(feedback_client, async_engine):
|
||
"""tester 默认无任何权限 → 403。"""
|
||
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||
# 确保 tester 没有 admin role 也没有 feedback_experience_hint role
|
||
async with session_factory() as session:
|
||
await session.execute(
|
||
UserRole.__table__.delete().where(UserRole.user_id == 1)
|
||
)
|
||
await session.commit()
|
||
|
||
resp = await feedback_client.post(
|
||
"/tasks/task-demo-1/experience-feedback",
|
||
json={
|
||
"scheme_id": "scheme_1",
|
||
"feedback_status": "adopted",
|
||
},
|
||
)
|
||
assert resp.status_code == 403, resp.text
|
||
assert "工艺工程师" in resp.text
|
||
|
||
|
||
async def test_submit_feedback_200_with_feedback_permission(feedback_client, async_engine):
|
||
"""给 tester 授予 feedback_experience_hint → 200 + 写入经验反馈。"""
|
||
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||
async with session_factory() as session:
|
||
tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one()
|
||
await _grant_permission(session, tester, "feedback_experience_hint")
|
||
|
||
resp = await feedback_client.post(
|
||
"/tasks/task-demo-1/experience-feedback",
|
||
json={
|
||
"scheme_id": "scheme_1",
|
||
"feedback_status": "adopted",
|
||
"feedback_reason": "工艺验证 OK",
|
||
"confidence_at_submit": 0.85,
|
||
"score_at_submit": 87.5,
|
||
},
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
body = resp.json()
|
||
assert body["scheme_id"] == "scheme_1"
|
||
assert body["feedback_status"] == "adopted"
|
||
|
||
# DB 真的写入了
|
||
async with session_factory() as session:
|
||
result = await session.execute(
|
||
select(ExperienceFeedback).where(ExperienceFeedback.scheme_id == "scheme_1")
|
||
)
|
||
fb = result.scalar_one()
|
||
assert fb.user_id == tester.id
|
||
assert fb.feedback_status == "adopted"
|
||
assert fb.role_code == "feedback_experience_hint" # 写入时角色归因
|
||
assert fb.expires_at is not None
|
||
|
||
|
||
async def test_submit_feedback_200_with_admin(feedback_client, async_engine):
|
||
"""admin role → has_permission 走 role.code=='admin' 短路 → 200。"""
|
||
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||
async with session_factory() as session:
|
||
tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one()
|
||
await _grant_permission(session, tester, "admin")
|
||
|
||
resp = await feedback_client.post(
|
||
"/tasks/task-demo-1/experience-feedback",
|
||
json={
|
||
"scheme_id": "scheme_2",
|
||
"feedback_status": "rejected",
|
||
},
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
assert resp.json()["scheme_axis"] # 自动从 cavity_key_info 解析,缺则默认 Z
|
||
|
||
|
||
async def test_submit_feedback_invalid_status_returns_422(feedback_client, async_engine):
|
||
"""feedback_status 非法 → Pydantic 校验 422。"""
|
||
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||
async with session_factory() as session:
|
||
tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one()
|
||
await _grant_permission(session, tester, "admin")
|
||
|
||
resp = await feedback_client.post(
|
||
"/tasks/task-demo-1/experience-feedback",
|
||
json={
|
||
"scheme_id": "scheme_1",
|
||
"feedback_status": "approve", # 非法值
|
||
},
|
||
)
|
||
assert resp.status_code == 422
|
||
|
||
|
||
# ── GET /experience-hints 测试 ──
|
||
|
||
async def test_get_hints_200_empty(feedback_client, async_engine):
|
||
"""无反馈历史 → 空 hints 列表,fingerprint 回显。"""
|
||
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||
async with session_factory() as session:
|
||
tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one()
|
||
await _grant_permission(session, tester, "admin")
|
||
|
||
resp = await feedback_client.get("/tasks/task-demo-1/experience-hints")
|
||
assert resp.status_code == 200, resp.text
|
||
body = resp.json()
|
||
assert body["task_id"] == "task-demo-1"
|
||
assert body["stp_file_id"] == 1
|
||
assert body["hints"] == []
|
||
assert "bbox_aspect" in body["fingerprint"]
|
||
|
||
|
||
async def test_get_hints_aggregates_by_axis(feedback_client, async_engine):
|
||
"""写入多条反馈后 GET hints 按 axis 聚合。"""
|
||
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||
async with session_factory() as session:
|
||
tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one()
|
||
await _grant_permission(session, tester, "admin")
|
||
|
||
# 写入 4 条反馈,全部落在 axis="Z" 默认(无 cavity_key_info)
|
||
for fb in [
|
||
{"scheme_id": "x_1", "feedback_status": "adopted"},
|
||
{"scheme_id": "x_2", "feedback_status": "adopted"},
|
||
{"scheme_id": "x_3", "feedback_status": "rejected"},
|
||
{"scheme_id": "z_1", "feedback_status": "adopted"},
|
||
]:
|
||
resp = await feedback_client.post(
|
||
"/tasks/task-demo-1/experience-feedback",
|
||
json=fb,
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
|
||
# GET hints
|
||
resp = await feedback_client.get("/tasks/task-demo-1/experience-hints")
|
||
assert resp.status_code == 200, resp.text
|
||
body = resp.json()
|
||
|
||
# 无 cavity_key_info 时所有 feedback 落在 axis="Z" 默认值 → 4 条聚合
|
||
assert len(body["hints"]) == 1
|
||
h = body["hints"][0]
|
||
assert h["scheme_axis"] == "Z"
|
||
assert h["adopted_count"] == 3
|
||
assert h["rejected_count"] == 1
|
||
assert h["sample_count"] == 4
|
||
assert h["confidence"] == 0.5 # (3-1)/4
|
||
|
||
|
||
async def test_submit_feedback_increments_expires_at(feedback_client, async_engine):
|
||
"""同 stp_file_id 上写入新反馈时,旧行的 expires_at 应被续期(write-time 续期)。"""
|
||
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||
async with session_factory() as session:
|
||
tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one()
|
||
await _grant_permission(session, tester, "admin")
|
||
|
||
# 写入第一条反馈
|
||
resp = await feedback_client.post(
|
||
"/tasks/task-demo-1/experience-feedback",
|
||
json={"scheme_id": "scheme_1", "feedback_status": "adopted"},
|
||
)
|
||
assert resp.status_code == 200
|
||
|
||
# 拿到第一条 expires_at
|
||
async with session_factory() as session:
|
||
first = (await session.execute(
|
||
select(ExperienceFeedback).where(ExperienceFeedback.scheme_id == "scheme_1")
|
||
)).scalar_one()
|
||
first_expires = first.expires_at
|
||
assert first_expires is not None
|
||
|
||
# 写第二条(不同 scheme_id),应触发同 stp_file 续期
|
||
import asyncio
|
||
await asyncio.sleep(0.05)
|
||
resp = await feedback_client.post(
|
||
"/tasks/task-demo-1/experience-feedback",
|
||
json={"scheme_id": "scheme_2", "feedback_status": "rejected"},
|
||
)
|
||
assert resp.status_code == 200
|
||
|
||
# 第一条 expires_at 应被续期(≥ 原值)
|
||
async with session_factory() as session:
|
||
first_after = (await session.execute(
|
||
select(ExperienceFeedback).where(ExperienceFeedback.scheme_id == "scheme_1")
|
||
)).scalar_one()
|
||
assert first_after.expires_at >= first_expires
|
||
|
||
|
||
# ── 任务归属校验 ──
|
||
|
||
async def test_submit_feedback_with_unknown_task_returns_404(feedback_client, async_engine):
|
||
"""task_id 不存在 → ensure_task_access 返回 404(不是 500)。
|
||
|
||
D9 边界保护:service.record_feedback 永远走不到(ensure_task_access 先拦截)。
|
||
"""
|
||
session_factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||
async with session_factory() as session:
|
||
tester = (await session.execute(select(User).where(User.username == "tester"))).scalar_one()
|
||
await _grant_permission(session, tester, "admin")
|
||
|
||
resp = await feedback_client.post(
|
||
"/tasks/non-existent-task-id/experience-feedback",
|
||
json={"scheme_id": "scheme_1", "feedback_status": "adopted"},
|
||
)
|
||
assert resp.status_code == 404
|
||
assert "不存在" in resp.text
|
||
|
||
|
||
# ── 配置:D17 模型注册收口 ──
|
||
|
||
def test_experience_feedback_registered_in_metadata():
|
||
"""D17:experience_feedback 表已加入 Base.metadata(防止漏注册导致 ORM 不可用)。"""
|
||
configure_mappers()
|
||
assert "experience_feedback" in Base.metadata.tables |