feat(review): 新增审核编辑暗号及会话登录功能
- 在 .env.example 添加 REVIEW_ACCESS_CODE 和 REVIEW_SESSION_TTL_SECONDS 配置项 - 在 l2_review_dashboard.html 增加审核登录弹窗界面及样式 - 实现异步登录流程,支持昵称和暗号输入,完成服务端认证 - 使用本地存储和会话存储管理审核者昵称和认证令牌 - 对所有编辑相关的 API 请求附加认证信息,实现服务端权限校验 - 服务端新增审核登录和会话验证接口,支持暗号验证和会话续期 - 审核操作接口添加会话有效性检查,非法或过期会话返回 401 - 新增 ReviewAuthError 自定义异常用于认证错误处理 - 修改入库脚本执行错误时返回更友好信息 - 相关前后端代码统一处理认证状态,支持登录失效自动刷新提醒 - README.md 中新增审核登录暗号配置及使用说明 - 添加后端认证逻辑的单元测试,验证正确登录和错误拒绝情况
Showing
5 changed files
with
267 additions
and
35 deletions
| ... | @@ -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 = '') { |
| 730 | els.loginReviewer.value = prefillReviewer; | ||
| 731 | els.loginAccessCode.value = ''; | ||
| 732 | els.loginError.textContent = ''; | ||
| 733 | els.loginOverlay.classList.remove('hidden'); | ||
| 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; | ||
| 749 | localStorage.setItem('l2ReviewerName', state.reviewer); | ||
| 750 | sessionStorage.setItem('l2ReviewAuthToken', state.authToken); | ||
| 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() { | ||
| 683 | state.reviewer = (localStorage.getItem('l2ReviewerName') || '').trim(); | 765 | state.reviewer = (localStorage.getItem('l2ReviewerName') || '').trim(); |
| 684 | if (!state.reviewer) { | ||
| 685 | state.reviewer = (window.prompt('请输入你的昵称') || '').trim(); | ||
| 686 | } | ||
| 687 | if (!state.reviewer) throw new Error('必须填写你的昵称后才能开始审核'); | ||
| 688 | localStorage.setItem('l2ReviewerName', state.reviewer); | ||
| 689 | els.reviewerInput.value = state.reviewer; | ||
| 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,23 +1705,21 @@ | ... | @@ -1589,23 +1705,21 @@ |
| 1589 | renderAll(); | 1705 | renderAll(); |
| 1590 | }); | 1706 | }); |
| 1591 | 1707 | ||
| 1592 | try { | 1708 | async function bootstrap() { |
| 1593 | initReviewerIdentity(); | 1709 | await initReviewerIdentity(); |
| 1594 | } catch (err) { | 1710 | window.setInterval(() => { |
| 1595 | els.detail.innerHTML = `<div class="empty">${esc(err.message)}</div>`; | 1711 | if (!document.hidden) syncVisibleReviewStatuses(); |
| 1596 | throw err; | 1712 | }, 15000); |
| 1597 | } | 1713 | window.setInterval(() => { |
| 1598 | window.setInterval(() => { | 1714 | if (!document.hidden) reloadSummary().catch(console.error); |
| 1599 | if (!document.hidden) syncVisibleReviewStatuses(); | 1715 | }, 60000); |
| 1600 | }, 15000); | 1716 | window.setInterval(() => { |
| 1601 | window.setInterval(() => { | 1717 | if (!document.hidden) heartbeatCurrentReview(); |
| 1602 | if (!document.hidden) reloadSummary().catch(console.error); | 1718 | }, 60000); |
| 1603 | }, 60000); | 1719 | await loadRuns(); |
| 1604 | window.setInterval(() => { | 1720 | } |
| 1605 | if (!document.hidden) heartbeatCurrentReview(); | 1721 | |
| 1606 | }, 60000); | 1722 | bootstrap().catch(err => { |
| 1607 | |||
| 1608 | loadRuns().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 | ) | ... | ... |
-
Please register or sign in to post a comment