feat: yearly_targets 写权限收紧,12步冒烟全绿

This commit is contained in:
simonkoson
2026-05-20 11:29:28 +08:00
parent 93fd327b13
commit 7cc0bf21ff
6 changed files with 521 additions and 8 deletions
+5
View File
@@ -147,6 +147,11 @@ AI Prompt 模板必须默认走双段、备选纯叙述。docxtpl 模板设计
| `zebian` | 责编 | 维护仪表盘 / 知识库 / 排期 |
| `biandao` | 编导 | 使用 TPS / 查看知识库 / 生成报题单 / 个人热点雷达 |
**yearly_targets 写权限收紧(2026-05-20)**:
- POST/PATCH: zhipianren + zebian 可写,biandao 返回 403
- DELETE: 仅 zhipianren 可操作,zebian 和 biandao 返回 403
- GET: 三角色均可读
**所有 SQL CHECK、SQLModel Enum、API 鉴权、前端权限判断、菜单显示**——一律使用拼音值,**不得替换为英文**。
---
+19
View File
@@ -68,6 +68,25 @@
---
## 角色权限表
> 数据来源:`.clinerules` 5.5 节 + `project_plan.md` 第四节
| 资源 / 操作 | zhipianren(制片人) | zebian(责编) | biandao(编导) |
|---|---|---|---|
| **yearly_targets** | | | |
| GET 列表/详情 | ✅ | ✅ | ✅ |
| POST 新增 | ✅ | ✅ | ❌ 403 |
| PATCH 修改 | ✅ | ✅ | ❌ 403 |
| DELETE 删除 | ✅ | ❌ 403 | ❌ 403 |
| **episodes** | | | |
| GET | ✅ | ✅ | ✅ |
| POST/PATCH/DELETE | ✅ | ✅ | 受限(见 API 文档) |
> 规则:.clinerules 5.5 角色枚举锁定拼音值(zhipianren / zebian / biandao),不许新增枚举。
---
## 技术栈速览
- 前端:React 18 + Vite + Ant Design 5 + Zustand(JavaScript,不用 TS)
+6 -6
View File
@@ -22,9 +22,9 @@ router = APIRouter(prefix="/api/yearly_targets", tags=["年度目标"])
@router.get("", response_model=list[YearlyTargetResponse])
def list_targets(
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian, UserRole.biandao)),
):
"""获取所有年度目标(仅管理员/责编)。"""
"""获取所有年度目标(三角色均可读)。"""
statement = select(YearlyTarget).order_by(YearlyTarget.year.desc())
targets = session.exec(statement).all()
return targets
@@ -34,9 +34,9 @@ def list_targets(
def get_target(
target_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian, UserRole.biandao)),
):
"""根据 ID 获取单个年度目标(仅管理员/责编)。"""
"""根据 ID 获取单个年度目标(三角色均可读)。"""
target = session.get(YearlyTarget, target_id)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标不存在")
@@ -101,9 +101,9 @@ def update_target(
def delete_target(
target_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
current_user: User = Depends(require_role(UserRole.zhipianren)),
):
"""删除年度目标(仅管理员/责编)。"""
"""删除年度目标(仅制片人)。"""
target = session.get(YearlyTarget, target_id)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标不存在")
+7 -2
View File
@@ -21,8 +21,13 @@ class YearlyTargetCreate(YearlyTargetBase):
pass
class YearlyTargetUpdate(YearlyTargetBase):
pass
class YearlyTargetUpdate(BaseModel):
"""PATCH 时允许只传部分字段,year 不可修改(由 API 层 pop 掉)。"""
year: int | None = None
base_target: float | None = None
stretch_target: float | None = None
issued_date: date | None = None
notes: str | None = None
class YearlyTargetResponse(YearlyTargetBase):
@@ -0,0 +1,419 @@
"""
yearly_targets 写权限收紧 — 冒烟测试脚本
幂等,可重复跑。
确保后端已启动: uvicorn app.main:app --reload
12 步覆盖:
1. zhipianren POST → 201
2. zebian POST → 201
3. biandao POST → 403
4. 未登录 POST → 401
5. zhipianren PATCH → 200
6. zebian PATCH → 200
7. biandao PATCH → 403
8. zhipianren DELETE → 200
9. zebian DELETE → 403
10. biandao DELETE → 403
11. biandao GET 列表 → 200
12. zhipianren GET 列表 → 200
"""
import sys
from pathlib import Path
backend_dir = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(backend_dir))
import httpx
BASE = "http://localhost:8000"
HEADERS = {"Content-Type": "application/json"}
def login(username: str, password: str) -> str:
"""登录并返回 session cookie。"""
r = httpx.post(f"{BASE}/api/auth/login", json={"username": username, "password": password})
r.raise_for_status()
return r.cookies.get("milsci_session", "")
def step(name: str, expected_status: int, method: str, url: str, **kwargs):
"""执行一个 HTTP 步骤,比较实际状态码与期望值。"""
cookies = kwargs.pop("cookies", {})
if method == "GET":
r = httpx.get(url, cookies=cookies, timeout=10)
elif method == "POST":
r = httpx.post(url, cookies=cookies, headers=HEADERS, timeout=10, **kwargs)
elif method == "PATCH":
r = httpx.patch(url, cookies=cookies, headers=HEADERS, timeout=10, **kwargs)
elif method == "DELETE":
r = httpx.delete(url, cookies=cookies, timeout=10)
else:
raise ValueError(method)
status_ok = r.status_code == expected_status
mark = "[OK]" if status_ok else f"[FAIL expected {expected_status} got {r.status_code}]"
print(f" {mark} [{method}] {name}")
if not status_ok:
print(f" Response: {r.text[:300]}")
return r, status_ok
def ensure_user(cookies: dict, username: str, display_name: str, role: str, password: str = "Test1234"):
"""幂等创建测试账号(如果不存在)。"""
# 先尝试登录(账号可能已存在)
try:
r = httpx.get(f"{BASE}/api/users/me", cookies=cookies, timeout=5)
if r.status_code == 200:
me = r.json()
if me.get("username") == username:
print(f" [SKIP] user {username} already exists, reusing")
return
except Exception:
pass
# 尝试创建
r = httpx.post(
f"{BASE}/api/users",
json={"username": username, "display_name": display_name, "password": password, "role": role},
cookies=cookies,
headers=HEADERS,
timeout=10,
)
if r.status_code == 201:
print(f" [OK] created {username} ({role})")
elif r.status_code == 409:
print(f" [SKIP] {username} already exists")
else:
print(f" [WARN] create {username} returned {r.status_code}: {r.text[:100]}")
def cleanup_test_target(cookies: dict):
"""清理测试用年度目标(2027),幂等。"""
try:
r = httpx.get(f"{BASE}/api/yearly_targets", cookies=cookies, timeout=10)
if r.status_code == 200:
for t in r.json():
if t.get("year") == 2027:
httpx.delete(f"{BASE}/api/yearly_targets/{t['id']}", cookies=cookies, timeout=10)
print(f" [CLEAN] removed existing 2027 target ID={t['id']}")
except Exception:
pass
def main():
print("=" * 60)
print(" yearly_targets 写权限收紧 — 冒烟测试")
print("=" * 60)
# Step 0: 用 zhipianren 登录,准备测试账号
print("\n[Step 0] Login as zhipianren and ensure test accounts exist")
try:
admin_cookie = login("simonkoson", "liutong65")
except Exception as e:
print(f" [FAIL] cannot login as simonkoson: {e}")
return
admin_cookies = {"milsci_session": admin_cookie}
print(" [OK] logged in as simonkoson (zhipianren)")
# 幂等创建 zebian / biandao 测试账号
ensure_user(admin_cookies, "test_zebian_smoke", "TestZebra Smoke", "zebian")
ensure_user(admin_cookies, "test_biandao_smoke", "TestBiandao Smoke", "biandao")
# 登录 zebian 和 biandao 获取 cookie
print("\n[Step 0b] Login as zebian and biandao")
zebian_cookie = login("test_zebian_smoke", "Test1234")
zebian_cookies = {"milsci_session": zebian_cookie}
print(" [OK] logged in as test_zebian_smoke (zebian)")
biandao_cookie = login("test_biandao_smoke", "Test1234")
biandao_cookies = {"milsci_session": biandao_cookie}
print(" [OK] logged in as test_biandao_smoke (biandao)")
# 清理可能残留的 2027 目标
cleanup_test_target(admin_cookies)
results = []
# =====================================================================
# Step 1: zhipianren POST → 201
# =====================================================================
print("\n[Step 1] POST new yearly target — zhipianren → 201")
r, ok = step(
"POST /api/yearly_targets (zhipianren)",
201,
"POST",
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=admin_cookies,
)
target_id = r.json().get("id") if ok else None
results.append(("Step 1: zhipianren POST 201", ok))
# =====================================================================
# Step 2: zebian POST → 201
# =====================================================================
print("\n[Step 2] POST new yearly target — zebian → 201")
# 清理 2027(刚被 zhipianren 创建了)
cleanup_test_target(admin_cookies)
r, ok = step(
"POST /api/yearly_targets (zebian)",
201,
"POST",
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=zebian_cookies,
)
target_id2 = r.json().get("id") if ok else None
results.append(("Step 2: zebian POST 201", ok))
# =====================================================================
# Step 3: biandao POST → 403
# =====================================================================
print("\n[Step 3] POST new yearly target — biandao → 403")
cleanup_test_target(admin_cookies)
r, ok = step(
"POST /api/yearly_targets (biandao)",
403,
"POST",
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=biandao_cookies,
)
results.append(("Step 3: biandao POST 403", ok))
# =====================================================================
# Step 4: 未登录 POST → 401
# =====================================================================
print("\n[Step 4] POST new yearly target — not logged in → 401")
r, ok = step(
"POST /api/yearly_targets (no auth)",
401,
"POST",
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies={},
)
results.append(("Step 4: no auth POST 401", ok))
# =====================================================================
# Step 5: zhipianren PATCH → 200
# =====================================================================
print("\n[Step 5] PATCH yearly target — zhipianren → 200")
# 先用 admin 建一个可 PATCH 的目标
cleanup_test_target(admin_cookies)
r = httpx.post(
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=admin_cookies,
headers=HEADERS,
timeout=10,
)
patch_id = r.json().get("id") if r.status_code == 201 else None
if patch_id:
r, ok = step(
"PATCH /api/yearly_targets/{id} (zhipianren)",
200,
"PATCH",
f"{BASE}/api/yearly_targets/{patch_id}",
json={"base_target": 0.67},
cookies=admin_cookies,
)
else:
ok = False
print(" [FAIL] could not create target for PATCH test")
results.append(("Step 5: zhipianren PATCH 200", ok))
# =====================================================================
# Step 6: zebian PATCH → 200
# =====================================================================
print("\n[Step 6] PATCH yearly target — zebian → 200")
cleanup_test_target(admin_cookies)
r = httpx.post(
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=admin_cookies,
headers=HEADERS,
timeout=10,
)
patch_id2 = r.json().get("id") if r.status_code == 201 else None
if patch_id2:
r, ok = step(
"PATCH /api/yearly_targets/{id} (zebian)",
200,
"PATCH",
f"{BASE}/api/yearly_targets/{patch_id2}",
json={"stretch_target": 0.92},
cookies=zebian_cookies,
)
else:
ok = False
print(" [FAIL] could not create target for PATCH test")
results.append(("Step 6: zebian PATCH 200", ok))
# =====================================================================
# Step 7: biandao PATCH → 403
# =====================================================================
print("\n[Step 7] PATCH yearly target — biandao → 403")
cleanup_test_target(admin_cookies)
r = httpx.post(
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=admin_cookies,
headers=HEADERS,
timeout=10,
)
patch_id3 = r.json().get("id") if r.status_code == 201 else None
if patch_id3:
r, ok = step(
"PATCH /api/yearly_targets/{id} (biandao)",
403,
"PATCH",
f"{BASE}/api/yearly_targets/{patch_id3}",
json={"base_target": 0.68},
cookies=biandao_cookies,
)
else:
ok = False
print(" [FAIL] could not create target for PATCH test")
results.append(("Step 7: biandao PATCH 403", ok))
# =====================================================================
# Step 8: zhipianren DELETE → 200
# =====================================================================
print("\n[Step 8] DELETE yearly target — zhipianren → 200")
cleanup_test_target(admin_cookies)
r = httpx.post(
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=admin_cookies,
headers=HEADERS,
timeout=10,
)
del_id = r.json().get("id") if r.status_code == 201 else None
if del_id:
r, ok = step(
"DELETE /api/yearly_targets/{id} (zhipianren)",
204,
"DELETE",
f"{BASE}/api/yearly_targets/{del_id}",
cookies=admin_cookies,
)
else:
ok = False
print(" [FAIL] could not create target for DELETE test")
results.append(("Step 8: zhipianren DELETE 204", ok))
# =====================================================================
# Step 9: zebian DELETE → 403
# =====================================================================
print("\n[Step 9] DELETE yearly target — zebian → 403")
cleanup_test_target(admin_cookies)
r = httpx.post(
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=admin_cookies,
headers=HEADERS,
timeout=10,
)
del_id2 = r.json().get("id") if r.status_code == 201 else None
if del_id2:
r, ok = step(
"DELETE /api/yearly_targets/{id} (zebian)",
403,
"DELETE",
f"{BASE}/api/yearly_targets/{del_id2}",
cookies=zebian_cookies,
)
else:
ok = False
print(" [FAIL] could not create target for DELETE test")
results.append(("Step 9: zebian DELETE 403", ok))
# =====================================================================
# Step 10: biandao DELETE → 403
# =====================================================================
print("\n[Step 10] DELETE yearly target — biandao → 403")
cleanup_test_target(admin_cookies)
r = httpx.post(
f"{BASE}/api/yearly_targets",
json={"year": 2027, "base_target": 0.66, "stretch_target": 0.91},
cookies=admin_cookies,
headers=HEADERS,
timeout=10,
)
del_id3 = r.json().get("id") if r.status_code == 201 else None
if del_id3:
r, ok = step(
"DELETE /api/yearly_targets/{id} (biandao)",
403,
"DELETE",
f"{BASE}/api/yearly_targets/{del_id3}",
cookies=biandao_cookies,
)
else:
ok = False
print(" [FAIL] could not create target for DELETE test")
results.append(("Step 10: biandao DELETE 403", ok))
# =====================================================================
# Step 11: biandao GET 列表 → 200
# =====================================================================
print("\n[Step 11] GET yearly targets list — biandao → 200")
r, ok = step(
"GET /api/yearly_targets (biandao)",
200,
"GET",
f"{BASE}/api/yearly_targets",
cookies=biandao_cookies,
)
results.append(("Step 11: biandao GET 200", ok))
# =====================================================================
# Step 12: zhipianren GET 列表 → 200
# =====================================================================
print("\n[Step 12] GET yearly targets list — zhipianren → 200")
r, ok = step(
"GET /api/yearly_targets (zhipianren)",
200,
"GET",
f"{BASE}/api/yearly_targets",
cookies=admin_cookies,
)
results.append(("Step 12: zhipianren GET 200", ok))
# =====================================================================
# Summary
# =====================================================================
print("\n" + "=" * 60)
print(" 结果汇总")
print("=" * 60)
passed = sum(1 for _, ok in results if ok)
total = len(results)
for name, ok in results:
mark = "[OK]" if ok else "[FAIL]"
print(f" {mark} {name}")
print(f"\n PASS: {passed}/{total}")
if passed == total:
print(" ALL PASS")
else:
print(" SOME FAILURES")
print("\n测试账号已保留(供后续手动清理):")
print(" - test_zebian_smoke (zebian)")
print(" - test_biandao_smoke (biandao)")
print("\n" + "=" * 60)
if __name__ == "__main__":
main()
+65
View File
@@ -390,3 +390,68 @@ site-packages 里。**记入待办**,Phase 4 部署前或 Task 3 启动前评估
| 20c0a1d | chore: backups/ 进 .gitignore(Task 2 收尾)(2026-05-20) |
**Task 2 完整收尾,Phase 2 Task 2 状态:✅ 完工。**
---
## 第十二章 yearly_targets 写权限收紧(2026-05-20
**状态:✅ 完工。冒烟 12/12 全绿。**
### 12.1 任务来源
task brief 明确要求收紧 yearly_targets 表的写权限,是业务红线。
### 12.2 改动文件
| 文件 | 改动内容 |
|------|----------|
| `backend/app/api/yearly_targets.py` | 5处 `require_role` 参数修正 |
| `backend/app/schemas/yearly_target.py` | `YearlyTargetUpdate` 改为部分字段可选(支持 PATCH 部分更新) |
| `backend/scripts/smoke_yearly_targets_auth.py` | 新建,12步幂等冒烟脚本 |
| `README.md` | 新增"角色权限表"节 |
| `.clinerules` 5.5 | 新增 yearly_targets 写权限收紧说明 |
| `logs/phase2_log.md` | 本章记录 |
### 12.3 权限矩阵最终状态
| 接口 | zhipianren | zebian | biandao |
|------|-----------|--------|---------|
| GET 列表/详情 | ✅ 200 | ✅ 200 | ✅ 200 |
| POST 新增 | ✅ 201 | ✅ 201 | ❌ 403 |
| PATCH 修改 | ✅ 200 | ✅ 200 | ❌ 403 |
| DELETE 删除 | ✅ 204 | ❌ 403 | ❌ 403 |
### 12.4 GET 权限修改说明
GET 权限修改原本不在 task brief 范围,是 Plan 阶段自查发现并扩大的范围。
Opus 审 Plan 时同意纳入,理由:权限矩阵整体校正一次做完更合理。
此条在 Opus 审方案 v2 中明确记录,phase2_log 本章也写入此说明,不得糊涂带过。
### 12.5 冒烟实测记录
`backend/scripts/smoke_yearly_targets_auth.py`12步结果:
| 步骤 | 场景 | 预期 | 结果 |
|------|------|------|------|
| 1 | zhipianren POST | 201 | ✅ |
| 2 | zebian POST | 201 | ✅ |
| 3 | biandao POST | 403 | ✅ |
| 4 | 未登录 POST | 401 | ✅ |
| 5 | zhipianren PATCH | 200 | ✅ |
| 6 | zebian PATCH | 200 | ✅ |
| 7 | biandao PATCH | 403 | ✅ |
| 8 | zhipianren DELETE | 204 | ✅ |
| 9 | zebian DELETE | 403 | ✅ |
| 10 | biandao DELETE | 403 | ✅ |
| 11 | biandao GET 列表 | 200 | ✅ |
| 12 | zhipianren GET 列表 | 200 | ✅ |
**12/12 全部通过。**
### 12.6 测试账号保留
冒烟脚本末尾显式打印保留账号(供后续手动清理):
- `test_zebian_smoke` (zebian)
- `test_biandao_smoke` (biandao)