Commit cbe31001 cbe3100183b9d29e7568b7b5591baf7f12205960 by 沈秋雨

feat(review): 新增审核编辑暗号及会话登录功能

- 在 .env.example 添加 REVIEW_ACCESS_CODE 和 REVIEW_SESSION_TTL_SECONDS 配置项
- 在 l2_review_dashboard.html 增加审核登录弹窗界面及样式
- 实现异步登录流程,支持昵称和暗号输入,完成服务端认证
- 使用本地存储和会话存储管理审核者昵称和认证令牌
- 对所有编辑相关的 API 请求附加认证信息,实现服务端权限校验
- 服务端新增审核登录和会话验证接口,支持暗号验证和会话续期
- 审核操作接口添加会话有效性检查,非法或过期会话返回 401
- 新增 ReviewAuthError 自定义异常用于认证错误处理
- 修改入库脚本执行错误时返回更友好信息
- 相关前后端代码统一处理认证状态,支持登录失效自动刷新提醒
- README.md 中新增审核登录暗号配置及使用说明
- 添加后端认证逻辑的单元测试,验证正确登录和错误拒绝情况
1 parent 96799bc7
...@@ -14,6 +14,11 @@ TARGET_DB_NAME= ...@@ -14,6 +14,11 @@ TARGET_DB_NAME=
14 TARGET_TABLE_NAME=hk_songs_test 14 TARGET_TABLE_NAME=hk_songs_test
15 TARGET_TABLE_NAME_TMP=hk_songs_import_staging 15 TARGET_TABLE_NAME_TMP=hk_songs_import_staging
16 16
17 # L2 审核页面编辑暗号;未配置时所有写操作禁用
18 REVIEW_ACCESS_CODE=
19 # 编辑会话有效期(秒),默认 12 小时
20 REVIEW_SESSION_TTL_SECONDS=43200
21
17 # run_etl.py 默认读取并回写的导入状态表:yinyan_song_records 或 yinyan_song_records2 22 # run_etl.py 默认读取并回写的导入状态表:yinyan_song_records 或 yinyan_song_records2
18 YINYAN_IMPORT_TABLE=yinyan_song_records 23 YINYAN_IMPORT_TABLE=yinyan_song_records
19 24
......
...@@ -144,6 +144,14 @@ python -m pytest test_dedup.py::TestL2Benchmark::test_l2_recall_topk_efficiency_ ...@@ -144,6 +144,14 @@ python -m pytest test_dedup.py::TestL2Benchmark::test_l2_recall_topk_efficiency_
144 144
145 ### 7. 启动复核前端 145 ### 7. 启动复核前端
146 146
147 先在 `.env` 配置审核编辑暗号(支持中文或英文):
148
149 ```dotenv
150 REVIEW_ACCESS_CODE=请替换成你的暗号
151 ```
152
153 暗号只在服务端校验。验证成功后,当前浏览器标签页会获得默认有效期 12 小时的临时编辑会话;未配置暗号或验证失败时,领取、审核、撤销、删除和入库接口均不可调用。
154
147 首次启用多人审核时,先让服务补齐领取和乐观锁字段(已有审核结果不会被修改): 155 首次启用多人审核时,先让服务补齐领取和乐观锁字段(已有审核结果不会被修改):
148 156
149 ```bash 157 ```bash
......
...@@ -547,6 +547,34 @@ ...@@ -547,6 +547,34 @@
547 547
548 #reviewerInput { width: 130px; } 548 #reviewerInput { width: 130px; }
549 549
550 .login-overlay {
551 position: fixed;
552 inset: 0;
553 z-index: 1000;
554 display: flex;
555 align-items: center;
556 justify-content: center;
557 background: rgba(245, 247, 250, .96);
558 }
559
560 .login-overlay.hidden { display: none; }
561
562 .login-panel {
563 width: min(380px, calc(100vw - 32px));
564 padding: 24px;
565 border: 1px solid var(--line);
566 border-radius: 12px;
567 background: #fff;
568 box-shadow: 0 12px 36px rgba(30, 48, 70, .16);
569 }
570
571 .login-panel h2 { margin: 0 0 8px; }
572 .login-panel p { margin: 0 0 18px; color: var(--muted); font-size: 13px; }
573 .login-panel label { display: block; margin: 10px 0 5px; font-size: 13px; }
574 .login-panel input { width: 100%; box-sizing: border-box; }
575 .login-panel button { width: 100%; margin-top: 16px; }
576 .login-error { min-height: 20px; margin-top: 10px; color: #b42318; font-size: 13px; }
577
550 @media (max-width: 1100px) { 578 @media (max-width: 1100px) {
551 main { grid-template-columns: 1fr; height: auto; } 579 main { grid-template-columns: 1fr; height: auto; }
552 aside { border-right: 0; border-bottom: 1px solid var(--line); max-height: 420px; } 580 aside { border-right: 0; border-bottom: 1px solid var(--line); max-height: 420px; }
...@@ -558,6 +586,18 @@ ...@@ -558,6 +586,18 @@
558 </style> 586 </style>
559 </head> 587 </head>
560 <body> 588 <body>
589 <div id="loginOverlay" class="login-overlay">
590 <form id="loginForm" class="login-panel">
591 <h2>进入审核</h2>
592 <p>昵称用于记录审核人;暗号仅发送到服务端验证。</p>
593 <label for="loginReviewer">昵称</label>
594 <input id="loginReviewer" maxlength="64" autocomplete="username" required>
595 <label for="loginAccessCode">暗号</label>
596 <input id="loginAccessCode" type="password" autocomplete="current-password" required>
597 <button id="loginBtn" type="submit">进入编辑</button>
598 <div id="loginError" class="login-error"></div>
599 </form>
600 </div>
561 <header> 601 <header>
562 <h1>歌词召回复核</h1> 602 <h1>歌词召回复核</h1>
563 <div class="toolbar"> 603 <div class="toolbar">
...@@ -646,6 +686,7 @@ ...@@ -646,6 +686,7 @@
646 lyricsCache: new Map(), 686 lyricsCache: new Map(),
647 reviewer: '', 687 reviewer: '',
648 clientToken: '', 688 clientToken: '',
689 authToken: '',
649 syncBusy: false, 690 syncBusy: false,
650 detailGeneration: 0, 691 detailGeneration: 0,
651 queryLoadGeneration: 0, 692 queryLoadGeneration: 0,
...@@ -671,7 +712,13 @@ ...@@ -671,7 +712,13 @@
671 queryList: document.getElementById('queryList'), 712 queryList: document.getElementById('queryList'),
672 detail: document.getElementById('scrollDetail'), 713 detail: document.getElementById('scrollDetail'),
673 stickyInfo: document.getElementById('stickyInfo'), 714 stickyInfo: document.getElementById('stickyInfo'),
674 syncNotice: document.getElementById('syncNotice') 715 syncNotice: document.getElementById('syncNotice'),
716 loginOverlay: document.getElementById('loginOverlay'),
717 loginForm: document.getElementById('loginForm'),
718 loginReviewer: document.getElementById('loginReviewer'),
719 loginAccessCode: document.getElementById('loginAccessCode'),
720 loginBtn: document.getElementById('loginBtn'),
721 loginError: document.getElementById('loginError')
675 }; 722 };
676 723
677 function newClientToken() { 724 function newClientToken() {
...@@ -679,16 +726,65 @@ ...@@ -679,16 +726,65 @@
679 return `${Date.now()}-${Math.random().toString(16).slice(2)}`; 726 return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
680 } 727 }
681 728
682 function initReviewerIdentity() { 729 function showReviewLogin(prefillReviewer = '') {
683 state.reviewer = (localStorage.getItem('l2ReviewerName') || '').trim(); 730 els.loginReviewer.value = prefillReviewer;
684 if (!state.reviewer) { 731 els.loginAccessCode.value = '';
685 state.reviewer = (window.prompt('请输入你的昵称') || '').trim(); 732 els.loginError.textContent = '';
686 } 733 els.loginOverlay.classList.remove('hidden');
687 if (!state.reviewer) throw new Error('必须填写你的昵称后才能开始审核'); 734 window.setTimeout(() => (prefillReviewer ? els.loginAccessCode : els.loginReviewer).focus(), 0);
735 return new Promise(resolve => {
736 els.loginForm.onsubmit = async event => {
737 event.preventDefault();
738 const reviewer = els.loginReviewer.value.trim();
739 const accessCode = els.loginAccessCode.value;
740 els.loginBtn.disabled = true;
741 els.loginError.textContent = '';
742 try {
743 const session = await postJSON('/api/review-login', {
744 reviewer,
745 access_code: accessCode
746 });
747 state.reviewer = session.reviewer;
748 state.authToken = session.session_token;
688 localStorage.setItem('l2ReviewerName', state.reviewer); 749 localStorage.setItem('l2ReviewerName', state.reviewer);
750 sessionStorage.setItem('l2ReviewAuthToken', state.authToken);
689 els.reviewerInput.value = state.reviewer; 751 els.reviewerInput.value = state.reviewer;
752 els.loginAccessCode.value = '';
753 els.loginOverlay.classList.add('hidden');
754 resolve();
755 } catch (error) {
756 els.loginError.textContent = error.message;
757 } finally {
758 els.loginBtn.disabled = false;
759 }
760 };
761 });
762 }
763
764 async function initReviewerIdentity() {
765 state.reviewer = (localStorage.getItem('l2ReviewerName') || '').trim();
690 state.clientToken = sessionStorage.getItem('l2ReviewClientToken') || newClientToken(); 766 state.clientToken = sessionStorage.getItem('l2ReviewClientToken') || newClientToken();
691 sessionStorage.setItem('l2ReviewClientToken', state.clientToken); 767 sessionStorage.setItem('l2ReviewClientToken', state.clientToken);
768 state.authToken = sessionStorage.getItem('l2ReviewAuthToken') || '';
769 els.loginReviewer.value = state.reviewer;
770
771 if (state.reviewer && state.authToken) {
772 try {
773 const session = await postJSON('/api/review-login', {
774 reviewer: state.reviewer,
775 session_token: state.authToken
776 });
777 state.authToken = session.session_token;
778 els.reviewerInput.value = state.reviewer;
779 els.loginOverlay.classList.add('hidden');
780 return;
781 } catch {
782 sessionStorage.removeItem('l2ReviewAuthToken');
783 state.authToken = '';
784 }
785 }
786
787 await showReviewLogin(state.reviewer);
692 } 788 }
693 789
694 function showSyncNotice(message) { 790 function showSyncNotice(message) {
...@@ -807,6 +903,24 @@ ...@@ -807,6 +903,24 @@
807 return payload; 903 return payload;
808 } 904 }
809 905
906 async function reviewPostJSON(url, body = {}) {
907 try {
908 return await postJSON(url, {
909 ...body,
910 reviewer: state.reviewer,
911 session_token: state.authToken
912 });
913 } catch (error) {
914 if (error.status === 401) {
915 sessionStorage.removeItem('l2ReviewAuthToken');
916 state.authToken = '';
917 alert(`${error.message}\n页面将刷新,请重新输入暗号。`);
918 window.location.reload();
919 }
920 throw error;
921 }
922 }
923
810 function applyReviewSnapshot(row, record) { 924 function applyReviewSnapshot(row, record) {
811 if (!row || !record) return; 925 if (!row || !record) return;
812 row.review_status = record.biz_review_status ?? row.review_status; 926 row.review_status = record.biz_review_status ?? row.review_status;
...@@ -847,7 +961,7 @@ ...@@ -847,7 +961,7 @@
847 // new / merge / skip 等记录只浏览,不应触发领取请求。 961 // new / merge / skip 等记录只浏览,不应触发领取请求。
848 if (!reviewable) return true; 962 if (!reviewable) return true;
849 try { 963 try {
850 const payload = await postJSON('/api/review-claim', { 964 const payload = await reviewPostJSON('/api/review-claim', {
851 // bigint 必须按字符串传输,不能转成会丢精度的 JavaScript Number。 965 // bigint 必须按字符串传输,不能转成会丢精度的 JavaScript Number。
852 staging_id: String(query.staging_id), 966 staging_id: String(query.staging_id),
853 reviewer: state.reviewer, 967 reviewer: state.reviewer,
...@@ -994,7 +1108,7 @@ ...@@ -994,7 +1108,7 @@
994 } 1108 }
995 1109
996 async function updateStagingReview(query, decision, note) { 1110 async function updateStagingReview(query, decision, note) {
997 return postJSON('/api/staging-review', { 1111 return reviewPostJSON('/api/staging-review', {
998 staging_id: String(query.staging_id), 1112 staging_id: String(query.staging_id),
999 expected_version: Number(query.review_version || 0), 1113 expected_version: Number(query.review_version || 0),
1000 client_token: state.clientToken, 1114 client_token: state.clientToken,
...@@ -1344,7 +1458,7 @@ ...@@ -1344,7 +1458,7 @@
1344 els.importReviewedBtn.disabled = true; 1458 els.importReviewedBtn.disabled = true;
1345 els.importReviewedBtn.textContent = '入库中...'; 1459 els.importReviewedBtn.textContent = '入库中...';
1346 try { 1460 try {
1347 const payload = await postJSON('/api/import-reviewed', { reviewer: state.reviewer }); 1461 const payload = await reviewPostJSON('/api/import-reviewed');
1348 alert(`入库完成:${payload.inserted_count} 条。结果文件:${payload.review_csv}`); 1462 alert(`入库完成:${payload.inserted_count} 条。结果文件:${payload.review_csv}`);
1349 await loadGroups(); 1463 await loadGroups();
1350 await reloadSummary(); 1464 await reloadSummary();
...@@ -1468,7 +1582,7 @@ ...@@ -1468,7 +1582,7 @@
1468 if (state.run?.type !== 'staging' || !query?.staging_id || !query.review_claimed_by) return; 1582 if (state.run?.type !== 'staging' || !query?.staging_id || !query.review_claimed_by) return;
1469 const heartbeatStagingId = String(query.staging_id); 1583 const heartbeatStagingId = String(query.staging_id);
1470 try { 1584 try {
1471 const payload = await postJSON('/api/review-heartbeat', { 1585 const payload = await reviewPostJSON('/api/review-heartbeat', {
1472 staging_id: String(query.staging_id), 1586 staging_id: String(query.staging_id),
1473 client_token: state.clientToken 1587 client_token: state.clientToken
1474 }); 1588 });
...@@ -1528,9 +1642,11 @@ ...@@ -1528,9 +1642,11 @@
1528 els.reviewerInput.value = state.reviewer; 1642 els.reviewerInput.value = state.reviewer;
1529 return; 1643 return;
1530 } 1644 }
1531 state.reviewer = nextReviewer; 1645 els.reviewerInput.value = state.reviewer;
1646 sessionStorage.removeItem('l2ReviewAuthToken');
1647 state.authToken = '';
1648 await showReviewLogin(nextReviewer);
1532 state.clientToken = newClientToken(); 1649 state.clientToken = newClientToken();
1533 localStorage.setItem('l2ReviewerName', state.reviewer);
1534 sessionStorage.setItem('l2ReviewClientToken', state.clientToken); 1650 sessionStorage.setItem('l2ReviewClientToken', state.clientToken);
1535 await loadGroups(); 1651 await loadGroups();
1536 renderAll(); 1652 renderAll();
...@@ -1589,12 +1705,8 @@ ...@@ -1589,12 +1705,8 @@
1589 renderAll(); 1705 renderAll();
1590 }); 1706 });
1591 1707
1592 try { 1708 async function bootstrap() {
1593 initReviewerIdentity(); 1709 await initReviewerIdentity();
1594 } catch (err) {
1595 els.detail.innerHTML = `<div class="empty">${esc(err.message)}</div>`;
1596 throw err;
1597 }
1598 window.setInterval(() => { 1710 window.setInterval(() => {
1599 if (!document.hidden) syncVisibleReviewStatuses(); 1711 if (!document.hidden) syncVisibleReviewStatuses();
1600 }, 15000); 1712 }, 15000);
...@@ -1604,8 +1716,10 @@ ...@@ -1604,8 +1716,10 @@
1604 window.setInterval(() => { 1716 window.setInterval(() => {
1605 if (!document.hidden) heartbeatCurrentReview(); 1717 if (!document.hidden) heartbeatCurrentReview();
1606 }, 60000); 1718 }, 60000);
1719 await loadRuns();
1720 }
1607 1721
1608 loadRuns().catch(err => { 1722 bootstrap().catch(err => {
1609 els.detail.innerHTML = `<div class="empty">${esc(err.message)}</div>`; 1723 els.detail.innerHTML = `<div class="empty">${esc(err.message)}</div>`;
1610 }); 1724 });
1611 </script> 1725 </script>
......
...@@ -8,8 +8,10 @@ import csv ...@@ -8,8 +8,10 @@ import csv
8 import json 8 import json
9 import mimetypes 9 import mimetypes
10 import os 10 import os
11 import secrets
11 import subprocess 12 import subprocess
12 import sys 13 import sys
14 import threading
13 import time 15 import time
14 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 16 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
15 from pathlib import Path 17 from pathlib import Path
...@@ -27,6 +29,10 @@ REPORT_DIR.mkdir(parents=True, exist_ok=True) ...@@ -27,6 +29,10 @@ REPORT_DIR.mkdir(parents=True, exist_ok=True)
27 GROUP_INDEX_CACHE: dict[tuple[str, int, int, str], list[dict[str, object]]] = {} 29 GROUP_INDEX_CACHE: dict[tuple[str, int, int, str], list[dict[str, object]]] = {}
28 load_dotenv(ROOT / ".env") 30 load_dotenv(ROOT / ".env")
29 REVIEW_CLAIM_TTL_SECONDS = max(60, int(os.getenv("REVIEW_CLAIM_TTL_SECONDS", "600"))) 31 REVIEW_CLAIM_TTL_SECONDS = max(60, int(os.getenv("REVIEW_CLAIM_TTL_SECONDS", "600")))
32 REVIEW_ACCESS_CODE = os.getenv("REVIEW_ACCESS_CODE", "")
33 REVIEW_SESSION_TTL_SECONDS = max(300, int(os.getenv("REVIEW_SESSION_TTL_SECONDS", "43200")))
34 REVIEW_SESSIONS: dict[str, tuple[str, float]] = {}
35 REVIEW_SESSIONS_LOCK = threading.Lock()
30 36
31 TARGET_DB_CONFIG = { 37 TARGET_DB_CONFIG = {
32 "host": os.getenv("TARGET_DB_HOST"), 38 "host": os.getenv("TARGET_DB_HOST"),
...@@ -70,6 +76,52 @@ class ReviewConflictError(Exception): ...@@ -70,6 +76,52 @@ class ReviewConflictError(Exception):
70 self.current_record = current_record or {} 76 self.current_record = current_record or {}
71 77
72 78
79 class ReviewAuthError(Exception):
80 """The caller does not have a valid review editing session."""
81
82
83 def _review_login(
84 reviewer: str,
85 access_code: str = "",
86 session_token: str = "",
87 ) -> dict[str, object]:
88 reviewer = reviewer.strip()
89 if not reviewer:
90 raise ReviewAuthError("请输入昵称")
91 now = time.time()
92 with REVIEW_SESSIONS_LOCK:
93 expired = [token for token, (_name, expires_at) in REVIEW_SESSIONS.items() if expires_at <= now]
94 for token in expired:
95 REVIEW_SESSIONS.pop(token, None)
96 existing = REVIEW_SESSIONS.get(session_token) if session_token else None
97 if existing and existing[0] == reviewer and existing[1] > now:
98 REVIEW_SESSIONS[session_token] = (reviewer, now + REVIEW_SESSION_TTL_SECONDS)
99 return {"reviewer": reviewer, "session_token": session_token, "expires_in": REVIEW_SESSION_TTL_SECONDS}
100 if not REVIEW_ACCESS_CODE:
101 raise ReviewAuthError("服务端尚未配置 REVIEW_ACCESS_CODE,编辑功能已禁用")
102 if not secrets.compare_digest(access_code.encode("utf-8"), REVIEW_ACCESS_CODE.encode("utf-8")):
103 raise ReviewAuthError("暗号错误")
104 token = secrets.token_urlsafe(32)
105 with REVIEW_SESSIONS_LOCK:
106 REVIEW_SESSIONS[token] = (reviewer, now + REVIEW_SESSION_TTL_SECONDS)
107 return {"reviewer": reviewer, "session_token": token, "expires_in": REVIEW_SESSION_TTL_SECONDS}
108
109
110 def _require_review_session(payload: dict[str, object]) -> str:
111 reviewer = str(payload.get("reviewer") or "").strip()
112 session_token = str(payload.get("session_token") or "").strip()
113 now = time.time()
114 with REVIEW_SESSIONS_LOCK:
115 session = REVIEW_SESSIONS.get(session_token)
116 if not session or session[1] <= now:
117 REVIEW_SESSIONS.pop(session_token, None)
118 raise ReviewAuthError("编辑会话无效或已过期,请重新输入暗号")
119 if session[0] != reviewer:
120 raise ReviewAuthError("编辑会话与当前昵称不匹配")
121 REVIEW_SESSIONS[session_token] = (reviewer, now + REVIEW_SESSION_TTL_SECONDS)
122 return reviewer
123
124
73 def _conflict_response(handler: BaseHTTPRequestHandler, exc: ReviewConflictError) -> None: 125 def _conflict_response(handler: BaseHTTPRequestHandler, exc: ReviewConflictError) -> None:
74 _json_response( 126 _json_response(
75 handler, 127 handler,
...@@ -512,7 +564,7 @@ def _claim_staging_review(staging_id: int, reviewer: str, client_token: str) -> ...@@ -512,7 +564,7 @@ def _claim_staging_review(staging_id: int, reviewer: str, client_token: str) ->
512 review_claim_expires_at = DATE_ADD(NOW(), INTERVAL %s SECOND), 564 review_claim_expires_at = DATE_ADD(NOW(), INTERVAL %s SECOND),
513 review_version = review_version + 1 565 review_version = review_version + 1
514 WHERE staging_id = %s 566 WHERE staging_id = %s
515 AND staging_status <> 'imported' 567 AND staging_status NOT IN ('imported', 'deleted')
516 AND ( 568 AND (
517 biz_review_status IN ('pending', 'unsure') 569 biz_review_status IN ('pending', 'unsure')
518 OR biz_review_status = 'not_required' 570 OR biz_review_status = 'not_required'
...@@ -533,6 +585,8 @@ def _claim_staging_review(staging_id: int, reviewer: str, client_token: str) -> ...@@ -533,6 +585,8 @@ def _claim_staging_review(staging_id: int, reviewer: str, client_token: str) ->
533 message = f"找不到 staging_id={staging_id},请刷新页面后重试" 585 message = f"找不到 staging_id={staging_id},请刷新页面后重试"
534 elif current.get("staging_status") == "imported": 586 elif current.get("staging_status") == "imported":
535 message = "该记录已经入库,只能浏览,不能再领取审核" 587 message = "该记录已经入库,只能浏览,不能再领取审核"
588 elif current.get("staging_status") == "deleted":
589 message = "该记录已被删除,不能领取审核"
536 elif current.get("review_claimed_by"): 590 elif current.get("review_claimed_by"):
537 message = f"该记录已由 {current['review_claimed_by']} 领取" 591 message = f"该记录已由 {current['review_claimed_by']} 领取"
538 else: 592 else:
...@@ -988,10 +1042,15 @@ def _run_import(review_csv: Path) -> dict[str, object]: ...@@ -988,10 +1042,15 @@ def _run_import(review_csv: Path) -> dict[str, object]:
988 stderr=subprocess.STDOUT, 1042 stderr=subprocess.STDOUT,
989 check=False, 1043 check=False,
990 ) 1044 )
1045 output_tail = result.stdout[-12000:]
1046 if result.returncode != 0:
1047 raise RuntimeError(
1048 f"入库脚本执行失败 (exit={result.returncode}):\n{output_tail}"
1049 )
991 return { 1050 return {
992 "command": " ".join(cmd), 1051 "command": " ".join(cmd),
993 "returncode": result.returncode, 1052 "returncode": result.returncode,
994 "output": result.stdout[-12000:], 1053 "output": output_tail,
995 } 1054 }
996 1055
997 1056
...@@ -1093,22 +1152,39 @@ class Handler(BaseHTTPRequestHandler): ...@@ -1093,22 +1152,39 @@ class Handler(BaseHTTPRequestHandler):
1093 1152
1094 def do_POST(self) -> None: 1153 def do_POST(self) -> None:
1095 parsed = urlparse(self.path) 1154 parsed = urlparse(self.path)
1155 if parsed.path == "/api/review-login":
1156 try:
1157 length = int(self.headers.get("Content-Length", "0"))
1158 payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}")
1159 result = _review_login(
1160 str(payload.get("reviewer") or ""),
1161 str(payload.get("access_code") or ""),
1162 str(payload.get("session_token") or ""),
1163 )
1164 _json_response(self, result)
1165 except ReviewAuthError as exc:
1166 _error(self, str(exc), status=401)
1167 except Exception as exc: # noqa: BLE001
1168 _error(self, str(exc), status=400)
1169 return
1096 if parsed.path in {"/api/review-claim", "/api/review-heartbeat"}: 1170 if parsed.path in {"/api/review-claim", "/api/review-heartbeat"}:
1097 try: 1171 try:
1098 length = int(self.headers.get("Content-Length", "0")) 1172 length = int(self.headers.get("Content-Length", "0"))
1099 payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}") 1173 payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}")
1174 reviewer = _require_review_session(payload)
1100 staging_id = int(payload.get("staging_id") or 0) 1175 staging_id = int(payload.get("staging_id") or 0)
1101 client_token = str(payload.get("client_token") or "").strip() 1176 client_token = str(payload.get("client_token") or "").strip()
1102 if not staging_id: 1177 if not staging_id:
1103 raise ValueError("staging_id is required") 1178 raise ValueError("staging_id is required")
1104 if parsed.path == "/api/review-claim": 1179 if parsed.path == "/api/review-claim":
1105 reviewer = str(payload.get("reviewer") or "").strip()
1106 current = _claim_staging_review(staging_id, reviewer, client_token) 1180 current = _claim_staging_review(staging_id, reviewer, client_token)
1107 else: 1181 else:
1108 current = _heartbeat_staging_review(staging_id, client_token) 1182 current = _heartbeat_staging_review(staging_id, client_token)
1109 _json_response(self, {"record": current}) 1183 _json_response(self, {"record": current})
1110 except ReviewConflictError as exc: 1184 except ReviewConflictError as exc:
1111 _conflict_response(self, exc) 1185 _conflict_response(self, exc)
1186 except ReviewAuthError as exc:
1187 _error(self, str(exc), status=401)
1112 except Exception as exc: # noqa: BLE001 1188 except Exception as exc: # noqa: BLE001
1113 _error(self, str(exc), status=400) 1189 _error(self, str(exc), status=400)
1114 return 1190 return
...@@ -1116,10 +1192,10 @@ class Handler(BaseHTTPRequestHandler): ...@@ -1116,10 +1192,10 @@ class Handler(BaseHTTPRequestHandler):
1116 try: 1192 try:
1117 length = int(self.headers.get("Content-Length", "0")) 1193 length = int(self.headers.get("Content-Length", "0"))
1118 payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}") 1194 payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}")
1195 reviewer = _require_review_session(payload)
1119 staging_id = int(payload.get("staging_id") or 0) 1196 staging_id = int(payload.get("staging_id") or 0)
1120 decision = str(payload.get("decision", "")).strip() 1197 decision = str(payload.get("decision", "")).strip()
1121 note = str(payload.get("note", "")).strip() 1198 note = str(payload.get("note", "")).strip()
1122 reviewer = str(payload.get("reviewer") or "").strip()
1123 client_token = str(payload.get("client_token") or "").strip() 1199 client_token = str(payload.get("client_token") or "").strip()
1124 expected_version = int(payload.get("expected_version", -1)) 1200 expected_version = int(payload.get("expected_version", -1))
1125 if not staging_id: 1201 if not staging_id:
...@@ -1134,6 +1210,8 @@ class Handler(BaseHTTPRequestHandler): ...@@ -1134,6 +1210,8 @@ class Handler(BaseHTTPRequestHandler):
1134 _json_response(self, {"record": result}) 1210 _json_response(self, {"record": result})
1135 except ReviewConflictError as exc: 1211 except ReviewConflictError as exc:
1136 _conflict_response(self, exc) 1212 _conflict_response(self, exc)
1213 except ReviewAuthError as exc:
1214 _error(self, str(exc), status=401)
1137 except Exception as exc: # noqa: BLE001 1215 except Exception as exc: # noqa: BLE001
1138 _error(self, str(exc), status=400) 1216 _error(self, str(exc), status=400)
1139 return 1217 return
...@@ -1143,6 +1221,7 @@ class Handler(BaseHTTPRequestHandler): ...@@ -1143,6 +1221,7 @@ class Handler(BaseHTTPRequestHandler):
1143 try: 1221 try:
1144 length = int(self.headers.get("Content-Length", "0")) 1222 length = int(self.headers.get("Content-Length", "0"))
1145 payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}") 1223 payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}")
1224 _require_review_session(payload)
1146 source_ids = payload.get("source_ids") 1225 source_ids = payload.get("source_ids")
1147 if source_ids is not None and not isinstance(source_ids, list): 1226 if source_ids is not None and not isinstance(source_ids, list):
1148 raise ValueError("source_ids must be a list") 1227 raise ValueError("source_ids must be a list")
...@@ -1157,6 +1236,8 @@ class Handler(BaseHTTPRequestHandler): ...@@ -1157,6 +1236,8 @@ class Handler(BaseHTTPRequestHandler):
1157 ] 1236 ]
1158 review_csv = _write_review_import_csv(approved) 1237 review_csv = _write_review_import_csv(approved)
1159 _json_response(self, {"review_csv": str(review_csv), **result}) 1238 _json_response(self, {"review_csv": str(review_csv), **result})
1239 except ReviewAuthError as exc:
1240 _error(self, str(exc), status=401)
1160 except Exception as exc: # noqa: BLE001 - local diagnostic API 1241 except Exception as exc: # noqa: BLE001 - local diagnostic API
1161 _error(self, str(exc), status=400) 1242 _error(self, str(exc), status=400)
1162 1243
......
...@@ -194,3 +194,27 @@ def test_dashboard_distinguishes_reviewed_from_submitted(): ...@@ -194,3 +194,27 @@ def test_dashboard_distinguishes_reviewed_from_submitted():
194 assert '<option value="reviewed">已审核</option>' in html 194 assert '<option value="reviewed">已审核</option>' in html
195 assert '<option value="submitted">已提交</option>' in html 195 assert '<option value="submitted">已提交</option>' in html
196 assert "hasFinalReview && query.staging_status !== 'imported'" in html 196 assert "hasFinalReview && query.staging_status !== 'imported'" in html
197
198
199 def test_review_access_code_issues_and_validates_edit_session():
200 dashboard.REVIEW_SESSIONS.clear()
201 with patch.object(dashboard, "REVIEW_ACCESS_CODE", "中文暗号"):
202 login = dashboard._review_login("alice", "中文暗号")
203 reviewer = dashboard._require_review_session(
204 {"reviewer": "alice", "session_token": login["session_token"]}
205 )
206
207 assert reviewer == "alice"
208 assert login["session_token"]
209
210
211 def test_wrong_access_code_and_mismatched_reviewer_are_rejected():
212 dashboard.REVIEW_SESSIONS.clear()
213 with patch.object(dashboard, "REVIEW_ACCESS_CODE", "correct"):
214 with pytest.raises(dashboard.ReviewAuthError, match="暗号错误"):
215 dashboard._review_login("alice", "wrong")
216 login = dashboard._review_login("alice", "correct")
217 with pytest.raises(dashboard.ReviewAuthError, match="昵称不匹配"):
218 dashboard._require_review_session(
219 {"reviewer": "bob", "session_token": login["session_token"]}
220 )
......