feat(review): 优化审核结果提交和重复数据合并逻辑
Showing
3 changed files
with
375 additions
and
74 deletions
| ... | @@ -646,6 +646,7 @@ | ... | @@ -646,6 +646,7 @@ |
| 646 | <option value="unsure">待确认</option> | 646 | <option value="unsure">待确认</option> |
| 647 | <option value="reviewed">已审核</option> | 647 | <option value="reviewed">已审核</option> |
| 648 | <option value="submitted">已提交</option> | 648 | <option value="submitted">已提交</option> |
| 649 | <option value="deleted">已删除</option> | ||
| 649 | <option value="">全部</option> | 650 | <option value="">全部</option> |
| 650 | <option value="l1_hit">L1 命中</option> | 651 | <option value="l1_hit">L1 命中</option> |
| 651 | <option value="conflict">L1/L2 冲突</option> | 652 | <option value="conflict">L1/L2 冲突</option> |
| ... | @@ -661,7 +662,7 @@ | ... | @@ -661,7 +662,7 @@ |
| 661 | <option value="200">200</option> | 662 | <option value="200">200</option> |
| 662 | </select> | 663 | </select> |
| 663 | <button id="exportBtn">导出标注</button> | 664 | <button id="exportBtn">导出标注</button> |
| 664 | <button id="importReviewedBtn">入库审核通过</button> | 665 | <button id="importReviewedBtn">提交审核结果</button> |
| 665 | <div class="toolbar-right"> | 666 | <div class="toolbar-right"> |
| 666 | <span>审核人:</span><span id="reviewerDisplay"></span> | 667 | <span>审核人:</span><span id="reviewerDisplay"></span> |
| 667 | <button id="logoutBtn" class="secondary">登出</button> | 668 | <button id="logoutBtn" class="secondary">登出</button> |
| ... | @@ -1002,7 +1003,7 @@ | ... | @@ -1002,7 +1003,7 @@ |
| 1002 | group.review_claim_expires_at = record.review_claim_expires_at || ''; | 1003 | group.review_claim_expires_at = record.review_claim_expires_at || ''; |
| 1003 | group.claimed_by_me = group.review_claimed_by === state.reviewer; | 1004 | group.claimed_by_me = group.review_claimed_by === state.reviewer; |
| 1004 | group.reviewed = ['approved_import', 'rejected_duplicate', 'deleted'].includes(record.biz_review_status); | 1005 | group.reviewed = ['approved_import', 'rejected_duplicate', 'deleted'].includes(record.biz_review_status); |
| 1005 | group.submitted = group.reviewed && record.staging_status === 'imported'; | 1006 | group.submitted = group.reviewed && ['imported', 'skipped'].includes(record.staging_status); |
| 1006 | } | 1007 | } |
| 1007 | 1008 | ||
| 1008 | function currentQuery() { | 1009 | function currentQuery() { |
| ... | @@ -1059,12 +1060,12 @@ | ... | @@ -1059,12 +1060,12 @@ |
| 1059 | metric('待审核', row.pending_review_count || '0'), | 1060 | metric('待审核', row.pending_review_count || '0'), |
| 1060 | metric('已审核', row.reviewed_count || '0'), | 1061 | metric('已审核', row.reviewed_count || '0'), |
| 1061 | metric('已提交', row.submitted_count || '0'), | 1062 | metric('已提交', row.submitted_count || '0'), |
| 1062 | metric('确认入库', row.approved_count || '0'), | 1063 | metric('确认不重复', row.approved_count || '0'), |
| 1063 | metric('确认不入库', row.rejected_count || '0'), | 1064 | metric('确认重复', row.rejected_count || '0'), |
| 1064 | metric('已删除', row.deleted_count || '0'), | 1065 | metric('已删除', row.deleted_count || '0'), |
| 1065 | metric('new', row.new_count || '-'), | 1066 | metric('新歌', row.new_count || '-'), |
| 1066 | metric('merge', row.duplicate_count || '0'), | 1067 | metric('重复', row.duplicate_count || '0'), |
| 1067 | metric('skip', row.skip_count || '0') | 1068 | metric('已跳过', row.skip_count || '0') |
| 1068 | ].join(''); | 1069 | ].join(''); |
| 1069 | } | 1070 | } |
| 1070 | 1071 | ||
| ... | @@ -1182,20 +1183,21 @@ | ... | @@ -1182,20 +1183,21 @@ |
| 1182 | </div>`; | 1183 | </div>`; |
| 1183 | } | 1184 | } |
| 1184 | 1185 | ||
| 1185 | async function updateStagingReview(query, decision, note) { | 1186 | async function updateStagingReview(query, decision, note, candidateId = '') { |
| 1186 | return reviewPostJSON('/api/staging-review', { | 1187 | return reviewPostJSON('/api/staging-review', { |
| 1187 | staging_id: String(query.staging_id), | 1188 | staging_id: String(query.staging_id), |
| 1188 | expected_version: Number(query.review_version || 0), | 1189 | expected_version: Number(query.review_version || 0), |
| 1189 | client_token: state.clientToken, | 1190 | client_token: state.clientToken, |
| 1190 | reviewer: state.reviewer, | 1191 | reviewer: state.reviewer, |
| 1191 | decision, | 1192 | decision, |
| 1192 | note | 1193 | note, |
| 1194 | candidate_id: candidateId | ||
| 1193 | }); | 1195 | }); |
| 1194 | } | 1196 | } |
| 1195 | 1197 | ||
| 1196 | const _DB_REVIEW_LABELS = { | 1198 | const _DB_REVIEW_LABELS = { |
| 1197 | approved_import: '确认入库(待入库)', | 1199 | approved_import: '确认不重复(待入库)', |
| 1198 | rejected_duplicate: '确认不入库', | 1200 | rejected_duplicate: '确认重复(待合并作者)', |
| 1199 | unsure: '待确认', | 1201 | unsure: '待确认', |
| 1200 | deleted: '已删除', | 1202 | deleted: '已删除', |
| 1201 | }; | 1203 | }; |
| ... | @@ -1293,8 +1295,9 @@ | ... | @@ -1293,8 +1295,9 @@ |
| 1293 | const needsManualReview = rowDecision(query) === 'review'; | 1295 | const needsManualReview = rowDecision(query) === 'review'; |
| 1294 | const hasFinalReview = Boolean(query.review_status) && | 1296 | const hasFinalReview = Boolean(query.review_status) && |
| 1295 | !['pending', 'not_required', 'unsure'].includes(query.review_status); | 1297 | !['pending', 'not_required', 'unsure'].includes(query.review_status); |
| 1296 | const canUndoReview = hasFinalReview && query.staging_status !== 'imported'; | 1298 | const isSubmitted = ['imported', 'skipped'].includes(query.staging_status); |
| 1297 | const canDeleteRecord = query.staging_status !== 'imported'; | 1299 | const canUndoReview = hasFinalReview && query.staging_status === 'staged'; |
| 1300 | const canDeleteRecord = !['imported', 'skipped'].includes(query.staging_status); | ||
| 1298 | 1301 | ||
| 1299 | // Sticky area: 新入库歌词 + 召回候选(各含音频播放器) | 1302 | // Sticky area: 新入库歌词 + 召回候选(各含音频播放器) |
| 1300 | els.stickyInfo.innerHTML = ` | 1303 | els.stickyInfo.innerHTML = ` |
| ... | @@ -1339,7 +1342,7 @@ | ... | @@ -1339,7 +1342,7 @@ |
| 1339 | <div class="section-head"> | 1342 | <div class="section-head"> |
| 1340 | <h2 class="section-title">人工标注</h2> | 1343 | <h2 class="section-title">人工标注</h2> |
| 1341 | ${hasFinalReview | 1344 | ${hasFinalReview |
| 1342 | ? `<span class="pill ${query.review_status === 'approved_import' ? 'new' : query.review_status === 'rejected_duplicate' ? 'duplicate' : query.review_status === 'deleted' ? 'duplicate' : ''}">${query.review_status === 'deleted' ? '已删除' : query.staging_status === 'imported' ? '已提交' : '已审核'}</span>` | 1345 | ? `<span class="pill ${query.review_status === 'approved_import' ? 'new' : query.review_status === 'rejected_duplicate' ? 'duplicate' : query.review_status === 'deleted' ? 'duplicate' : ''}">${query.review_status === 'deleted' ? '已删除' : isSubmitted ? '已提交' : '已审核'}</span>` |
| 1343 | : ''} | 1346 | : ''} |
| 1344 | </div> | 1347 | </div> |
| 1345 | <div class="section-body"> | 1348 | <div class="section-body"> |
| ... | @@ -1347,8 +1350,10 @@ | ... | @@ -1347,8 +1350,10 @@ |
| 1347 | <div class="review-bar" style="align-items:center"> | 1350 | <div class="review-bar" style="align-items:center"> |
| 1348 | <span style="flex:1"> | 1351 | <span style="flex:1"> |
| 1349 | <b>${esc(query.review_status === 'approved_import' && query.staging_status === 'imported' | 1352 | <b>${esc(query.review_status === 'approved_import' && query.staging_status === 'imported' |
| 1350 | ? '确认入库(已提交)' | 1353 | ? '确认不重复(已入库)' |
| 1351 | : (_DB_REVIEW_LABELS[query.review_status] || query.review_status))}</b> | 1354 | : query.review_status === 'rejected_duplicate' && query.staging_status === 'skipped' |
| 1355 | ? '确认重复(作者已合并)' | ||
| 1356 | : (_DB_REVIEW_LABELS[query.review_status] || query.review_status))}</b> | ||
| 1352 | ${query.reviewed_by ? ` · 审核人 ${esc(query.reviewed_by)}` : ''} | 1357 | ${query.reviewed_by ? ` · 审核人 ${esc(query.reviewed_by)}` : ''} |
| 1353 | ${query.reviewed_at ? ` · ${esc(query.reviewed_at)}` : ''} | 1358 | ${query.reviewed_at ? ` · ${esc(query.reviewed_at)}` : ''} |
| 1354 | ${query.review_note ? ` · 备注: ${esc(query.review_note)}` : ''} | 1359 | ${query.review_note ? ` · 备注: ${esc(query.review_note)}` : ''} |
| ... | @@ -1359,7 +1364,7 @@ | ... | @@ -1359,7 +1364,7 @@ |
| 1359 | <div class="review-bar"> | 1364 | <div class="review-bar"> |
| 1360 | ${['duplicate', 'not_duplicate', 'unsure'].map(value => ` | 1365 | ${['duplicate', 'not_duplicate', 'unsure'].map(value => ` |
| 1361 | <button class="review-choice ${selectedReview.final_decision === value ? 'active' : ''}" data-review="${value}"> | 1366 | <button class="review-choice ${selectedReview.final_decision === value ? 'active' : ''}" data-review="${value}"> |
| 1362 | ${value === 'duplicate' ? '确认不入库' : value === 'not_duplicate' ? '确认入库' : '待确认'} | 1367 | ${value === 'duplicate' ? '确认重复' : value === 'not_duplicate' ? '确认不重复' : '待确认'} |
| 1363 | </button>`).join('')} | 1368 | </button>`).join('')} |
| 1364 | <input id="reviewNote" value="${esc(selectedReview.note || '')}" placeholder="人工备注"> | 1369 | <input id="reviewNote" value="${esc(selectedReview.note || '')}" placeholder="人工备注"> |
| 1365 | <button id="deleteReviewBtn" class="secondary" style="color:#c0392b;border-color:#c0392b">删除</button> | 1370 | <button id="deleteReviewBtn" class="secondary" style="color:#c0392b;border-color:#c0392b">删除</button> |
| ... | @@ -1411,7 +1416,9 @@ | ... | @@ -1411,7 +1416,9 @@ |
| 1411 | renderDetail(); | 1416 | renderDetail(); |
| 1412 | return; | 1417 | return; |
| 1413 | } | 1418 | } |
| 1414 | const payload = await updateStagingReview(query, dbDecision, note); | 1419 | const payload = await updateStagingReview( |
| 1420 | query, dbDecision, note, localDecision === 'duplicate' ? (candidate?.candidate_id || '') : '' | ||
| 1421 | ); | ||
| 1415 | for (const row of state.queryRows) applyReviewSnapshot(row, payload.record); | 1422 | for (const row of state.queryRows) applyReviewSnapshot(row, payload.record); |
| 1416 | // 刷新统计和加载列表并行 | 1423 | // 刷新统计和加载列表并行 |
| 1417 | await Promise.all([reloadSummary(), loadGroups()]); | 1424 | await Promise.all([reloadSummary(), loadGroups()]); |
| ... | @@ -1530,12 +1537,12 @@ | ... | @@ -1530,12 +1537,12 @@ |
| 1530 | alert('历史报表仅供查看,请在 staging-db 中执行入库。'); | 1537 | alert('历史报表仅供查看,请在 staging-db 中执行入库。'); |
| 1531 | return; | 1538 | return; |
| 1532 | } | 1539 | } |
| 1533 | if (!confirm('确认入库数据库中所有"确认入库(待入库)"样本?')) return; | 1540 | if (!confirm('确认提交所有审核结果?“确认不重复”将入库,“确认重复”将把词/曲作者增量合并到已有记录。')) return; |
| 1534 | els.importReviewedBtn.disabled = true; | 1541 | els.importReviewedBtn.disabled = true; |
| 1535 | els.importReviewedBtn.textContent = '入库中...'; | 1542 | els.importReviewedBtn.textContent = '入库中...'; |
| 1536 | try { | 1543 | try { |
| 1537 | const payload = await reviewPostJSON('/api/import-reviewed'); | 1544 | const payload = await reviewPostJSON('/api/import-reviewed'); |
| 1538 | alert(`入库完成:${payload.inserted_count} 条。结果文件:${payload.review_csv}`); | 1545 | alert(`提交完成:不重复入库 ${payload.inserted_count} 条,重复样本 ${payload.duplicate_count || 0} 条,其中作者有增量 ${payload.author_merged_count || 0} 条。结果文件:${payload.review_csv}`); |
| 1539 | // 刷新统计和加载列表并行 | 1546 | // 刷新统计和加载列表并行 |
| 1540 | await Promise.all([reloadSummary(), loadGroups()]); | 1547 | await Promise.all([reloadSummary(), loadGroups()]); |
| 1541 | renderAll(); | 1548 | renderAll(); |
| ... | @@ -1543,14 +1550,17 @@ | ... | @@ -1543,14 +1550,17 @@ |
| 1543 | if (err.status === 409 && err.payload?.code === 'import_conflict') { | 1550 | if (err.status === 409 && err.payload?.code === 'import_conflict') { |
| 1544 | const conflicts = err.payload.conflicts || []; | 1551 | const conflicts = err.payload.conflicts || []; |
| 1545 | const details = conflicts.slice(0, 20).map((row, index) => { | 1552 | const details = conflicts.slice(0, 20).map((row, index) => { |
| 1546 | const types = (row.conflict_types || []).map(type => | 1553 | const typeLabels = { |
| 1547 | type === 'primary_key' ? '主键 ID 冲突' : '来源键冲突' | 1554 | primary_key: '主键 ID 冲突', |
| 1548 | ).join(' + ') || '唯一键冲突'; | 1555 | source_key: '来源键冲突', |
| 1556 | duplicate_target_missing: '未找到已入库的重复目标' | ||
| 1557 | }; | ||
| 1558 | const types = (row.conflict_types || []).map(type => typeLabels[type] || type).join(' + ') || '数据冲突'; | ||
| 1549 | const targetIds = [row.id_conflict_target_id, row.source_conflict_target_id] | 1559 | const targetIds = [row.id_conflict_target_id, row.source_conflict_target_id] |
| 1550 | .filter(value => value !== null && value !== undefined) | 1560 | .filter(value => value !== null && value !== undefined) |
| 1551 | .filter((value, i, values) => values.indexOf(value) === i) | 1561 | .filter((value, i, values) => values.indexOf(value) === i) |
| 1552 | .join('/'); | 1562 | .join('/'); |
| 1553 | return `${index + 1}. ${row.name || '(无歌名)'} | source=${row.source_table_name || '-'}:${row.source_song_id || '-'} | staging_id=${row.staging_id} | 待入库ID=${row.incoming_id || '-'} | ${types} | 正式表ID=${targetIds || '-'}`; | 1563 | return `${index + 1}. ${row.name || '(无歌名)'} | source=${row.source_table_name || '-'}:${row.source_song_id || '-'} | staging_id=${row.staging_id} | 待入库ID=${row.incoming_id || '-'} | 重复候选=${row.matched_song_id || '-'} | ${types} | 正式表ID=${targetIds || '-'}`; |
| 1554 | }); | 1564 | }); |
| 1555 | if (conflicts.length > 20) details.push(`……另有 ${conflicts.length - 20} 条未展开`); | 1565 | if (conflicts.length > 20) details.push(`……另有 ${conflicts.length - 20} 条未展开`); |
| 1556 | alert([ | 1566 | alert([ |
| ... | @@ -1565,7 +1575,7 @@ | ... | @@ -1565,7 +1575,7 @@ |
| 1565 | } | 1575 | } |
| 1566 | } finally { | 1576 | } finally { |
| 1567 | els.importReviewedBtn.disabled = false; | 1577 | els.importReviewedBtn.disabled = false; |
| 1568 | els.importReviewedBtn.textContent = '入库审核通过'; | 1578 | els.importReviewedBtn.textContent = '提交审核结果'; |
| 1569 | } | 1579 | } |
| 1570 | } | 1580 | } |
| 1571 | 1581 | ||
| ... | @@ -1659,7 +1669,7 @@ | ... | @@ -1659,7 +1669,7 @@ |
| 1659 | group.review_claim_expires_at = remote.review_claim_expires_at || ''; | 1669 | group.review_claim_expires_at = remote.review_claim_expires_at || ''; |
| 1660 | group.claimed_by_me = Boolean(remote.claimed_by_me); | 1670 | group.claimed_by_me = Boolean(remote.claimed_by_me); |
| 1661 | group.reviewed = ['approved_import', 'rejected_duplicate', 'deleted'].includes(remote.biz_review_status); | 1671 | group.reviewed = ['approved_import', 'rejected_duplicate', 'deleted'].includes(remote.biz_review_status); |
| 1662 | group.submitted = group.reviewed && remote.staging_status === 'imported'; | 1672 | group.submitted = group.reviewed && ['imported', 'skipped'].includes(remote.staging_status); |
| 1663 | } | 1673 | } |
| 1664 | const query = currentQuery(); | 1674 | const query = currentQuery(); |
| 1665 | const selectedRemote = query ? statuses.get(String(query.staging_id)) : null; | 1675 | const selectedRemote = query ? statuses.get(String(query.staging_id)) : null; | ... | ... |
| ... | @@ -8,17 +8,20 @@ import csv | ... | @@ -8,17 +8,20 @@ import csv |
| 8 | import json | 8 | import json |
| 9 | import mimetypes | 9 | import mimetypes |
| 10 | import os | 10 | import os |
| 11 | import re | ||
| 11 | import secrets | 12 | import secrets |
| 12 | import subprocess | 13 | import subprocess |
| 13 | import sys | 14 | import sys |
| 14 | import threading | 15 | import threading |
| 15 | import time | 16 | import time |
| 17 | import unicodedata | ||
| 16 | from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | 18 | from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer |
| 17 | from pathlib import Path | 19 | from pathlib import Path |
| 18 | from urllib.parse import parse_qs, unquote, urlparse | 20 | from urllib.parse import parse_qs, unquote, urlparse |
| 19 | 21 | ||
| 20 | import pymysql | 22 | import pymysql |
| 21 | import requests | 23 | import requests |
| 24 | import opencc | ||
| 22 | from dbutils.pooled_db import PooledDB | 25 | from dbutils.pooled_db import PooledDB |
| 23 | from dotenv import load_dotenv | 26 | from dotenv import load_dotenv |
| 24 | 27 | ||
| ... | @@ -79,6 +82,9 @@ REVIEW_SCHEMA_COLUMNS = { | ... | @@ -79,6 +82,9 @@ REVIEW_SCHEMA_COLUMNS = { |
| 79 | "review_claim_expires_at": "datetime DEFAULT NULL COMMENT '领取过期时间'", | 82 | "review_claim_expires_at": "datetime DEFAULT NULL COMMENT '领取过期时间'", |
| 80 | "review_version": "bigint(20) NOT NULL DEFAULT '0' COMMENT '审核乐观锁版本'", | 83 | "review_version": "bigint(20) NOT NULL DEFAULT '0' COMMENT '审核乐观锁版本'", |
| 81 | } | 84 | } |
| 85 | _AUTHOR_SEP_RE = re.compile(r"[,,/;;、]") | ||
| 86 | _AUTHOR_T2S = opencc.OpenCC("t2s") | ||
| 87 | _AUTHOR_METADATA_PUNCT = set(' \t\n\r,。!?;:、"“”‘’·…—~!¥()【】《》〈〉「」『』﹏,.:;!?()[]{}<>|/\\_-') | ||
| 82 | 88 | ||
| 83 | 89 | ||
| 84 | class ReviewConflictError(Exception): | 90 | class ReviewConflictError(Exception): |
| ... | @@ -250,8 +256,8 @@ def _staging_summary() -> list[dict[str, str]]: | ... | @@ -250,8 +256,8 @@ def _staging_summary() -> list[dict[str, str]]: |
| 250 | SUM(CASE WHEN biz_review_status = 'approved_import' THEN 1 ELSE 0 END) AS approved_count, | 256 | SUM(CASE WHEN biz_review_status = 'approved_import' THEN 1 ELSE 0 END) AS approved_count, |
| 251 | SUM(CASE WHEN biz_review_status = 'rejected_duplicate' THEN 1 ELSE 0 END) AS rejected_count, | 257 | SUM(CASE WHEN biz_review_status = 'rejected_duplicate' THEN 1 ELSE 0 END) AS rejected_count, |
| 252 | SUM(CASE WHEN biz_review_status = 'deleted' THEN 1 ELSE 0 END) AS deleted_count, | 258 | SUM(CASE WHEN biz_review_status = 'deleted' THEN 1 ELSE 0 END) AS deleted_count, |
| 253 | SUM(CASE WHEN biz_review_status IN ('approved_import', 'rejected_duplicate', 'deleted') AND staging_status <> 'imported' THEN 1 ELSE 0 END) AS reviewed_count, | 259 | SUM(CASE WHEN biz_review_status IN ('approved_import', 'rejected_duplicate', 'deleted') AND staging_status NOT IN ('imported', 'skipped') THEN 1 ELSE 0 END) AS reviewed_count, |
| 254 | SUM(CASE WHEN biz_review_status IN ('approved_import', 'rejected_duplicate', 'deleted') AND staging_status = 'imported' THEN 1 ELSE 0 END) AS submitted_count | 260 | SUM(CASE WHEN biz_review_status IN ('approved_import', 'rejected_duplicate', 'deleted') AND staging_status IN ('imported', 'skipped') THEN 1 ELSE 0 END) AS submitted_count |
| 255 | FROM ( | 261 | FROM ( |
| 256 | SELECT source_song_id, dedup_action, biz_review_status, staging_status | 262 | SELECT source_song_id, dedup_action, biz_review_status, staging_status |
| 257 | FROM {TARGET_TABLE_NAME_TMP} | 263 | FROM {TARGET_TABLE_NAME_TMP} |
| ... | @@ -356,10 +362,13 @@ def _staging_groups_response( | ... | @@ -356,10 +362,13 @@ def _staging_groups_response( |
| 356 | where_clauses.append("s.dedup_action = 'skip'") | 362 | where_clauses.append("s.dedup_action = 'skip'") |
| 357 | elif decision == 'reviewed': | 363 | elif decision == 'reviewed': |
| 358 | where_clauses.append("s.biz_review_status IN ('approved_import', 'rejected_duplicate', 'deleted')") | 364 | where_clauses.append("s.biz_review_status IN ('approved_import', 'rejected_duplicate', 'deleted')") |
| 359 | where_clauses.append("s.staging_status <> 'imported'") | 365 | where_clauses.append("s.staging_status NOT IN ('imported', 'skipped')") |
| 360 | elif decision == 'submitted': | 366 | elif decision == 'submitted': |
| 361 | where_clauses.append("s.biz_review_status IN ('approved_import', 'rejected_duplicate', 'deleted')") | 367 | where_clauses.append("s.biz_review_status IN ('approved_import', 'rejected_duplicate', 'deleted')") |
| 362 | where_clauses.append("s.staging_status = 'imported'") | 368 | where_clauses.append("s.staging_status IN ('imported', 'skipped')") |
| 369 | elif decision == 'deleted': | ||
| 370 | where_clauses.append("s.biz_review_status = 'deleted'") | ||
| 371 | where_clauses.append("s.staging_status = 'deleted'") | ||
| 363 | elif decision == 'pending': | 372 | elif decision == 'pending': |
| 364 | where_clauses.append("(s.biz_review_status IS NULL OR s.biz_review_status = 'pending')") | 373 | where_clauses.append("(s.biz_review_status IS NULL OR s.biz_review_status = 'pending')") |
| 365 | where_clauses.append("s.dedup_action = 'review'") | 374 | where_clauses.append("s.dedup_action = 'review'") |
| ... | @@ -427,7 +436,7 @@ def _staging_groups_response( | ... | @@ -427,7 +436,7 @@ def _staging_groups_response( |
| 427 | "reviewed": row.get("biz_review_status") in {"approved_import", "rejected_duplicate", "deleted"}, | 436 | "reviewed": row.get("biz_review_status") in {"approved_import", "rejected_duplicate", "deleted"}, |
| 428 | "submitted": ( | 437 | "submitted": ( |
| 429 | row.get("biz_review_status") in {"approved_import", "rejected_duplicate", "deleted"} | 438 | row.get("biz_review_status") in {"approved_import", "rejected_duplicate", "deleted"} |
| 430 | and row.get("staging_status") == "imported" | 439 | and row.get("staging_status") in {"imported", "skipped"} |
| 431 | ), | 440 | ), |
| 432 | "staging_id": str(row.get("staging_id") or ""), | 441 | "staging_id": str(row.get("staging_id") or ""), |
| 433 | "review_version": int(row.get("review_version") or 0), | 442 | "review_version": int(row.get("review_version") or 0), |
| ... | @@ -674,6 +683,7 @@ def _update_staging_review( | ... | @@ -674,6 +683,7 @@ def _update_staging_review( |
| 674 | reviewer: str, | 683 | reviewer: str, |
| 675 | expected_version: int, | 684 | expected_version: int, |
| 676 | client_token: str, | 685 | client_token: str, |
| 686 | candidate_id: str = "", | ||
| 677 | ) -> dict[str, object]: | 687 | ) -> dict[str, object]: |
| 678 | """Persist one claimed review using optimistic concurrency control.""" | 688 | """Persist one claimed review using optimistic concurrency control.""" |
| 679 | if decision == "pending": | 689 | if decision == "pending": |
| ... | @@ -686,6 +696,11 @@ def _update_staging_review( | ... | @@ -686,6 +696,11 @@ def _update_staging_review( |
| 686 | biz_review_status = 'deleted', reviewed_by = %s, reviewed_at = NOW(), | 696 | biz_review_status = 'deleted', reviewed_by = %s, reviewed_at = NOW(), |
| 687 | staging_status = 'deleted' | 697 | staging_status = 'deleted' |
| 688 | """ | 698 | """ |
| 699 | elif decision == "rejected_duplicate": | ||
| 700 | status_fields = """ | ||
| 701 | biz_review_status = %s, reviewed_by = %s, reviewed_at = NOW(), | ||
| 702 | matched_song_id = %s | ||
| 703 | """ | ||
| 689 | else: | 704 | else: |
| 690 | status_fields = "biz_review_status = %s, reviewed_by = %s, reviewed_at = NOW()" | 705 | status_fields = "biz_review_status = %s, reviewed_by = %s, reviewed_at = NOW()" |
| 691 | 706 | ||
| ... | @@ -694,9 +709,31 @@ def _update_staging_review( | ... | @@ -694,9 +709,31 @@ def _update_staging_review( |
| 694 | params = [note, staging_id, expected_version, client_token, reviewer] | 709 | params = [note, staging_id, expected_version, client_token, reviewer] |
| 695 | elif decision == "deleted": | 710 | elif decision == "deleted": |
| 696 | params = [reviewer, note, staging_id, expected_version, client_token, reviewer] | 711 | params = [reviewer, note, staging_id, expected_version, client_token, reviewer] |
| 712 | elif decision == "rejected_duplicate": | ||
| 713 | candidate_id = candidate_id.strip() | ||
| 714 | if not candidate_id: | ||
| 715 | raise ValueError("确认重复前请先选择一条召回候选") | ||
| 716 | params = [decision, reviewer, candidate_id, note, staging_id, expected_version, client_token, reviewer] | ||
| 697 | else: | 717 | else: |
| 698 | params = [decision, reviewer, note, staging_id, expected_version, client_token, reviewer] | 718 | params = [decision, reviewer, note, staging_id, expected_version, client_token, reviewer] |
| 699 | with _target_conn() as conn, conn.cursor() as cursor: | 719 | with _target_conn() as conn, conn.cursor() as cursor: |
| 720 | if decision == "rejected_duplicate": | ||
| 721 | cursor.execute( | ||
| 722 | f"SELECT matched_song_id, recalled_candidates FROM {TARGET_TABLE_NAME_TMP} WHERE staging_id = %s", | ||
| 723 | (staging_id,), | ||
| 724 | ) | ||
| 725 | before = cursor.fetchone() or {} | ||
| 726 | allowed_ids = {str(before.get("matched_song_id") or "")} | ||
| 727 | recalled = before.get("recalled_candidates") | ||
| 728 | try: | ||
| 729 | candidates = json.loads(recalled) if isinstance(recalled, str) else (recalled or []) | ||
| 730 | allowed_ids.update(str(item.get("id") or "") for item in candidates if isinstance(item, dict)) | ||
| 731 | except (json.JSONDecodeError, TypeError): | ||
| 732 | pass | ||
| 733 | allowed_ids.discard("") | ||
| 734 | if candidate_id not in allowed_ids: | ||
| 735 | conn.rollback() | ||
| 736 | raise ValueError("选中的重复候选不在当前召回结果中,请刷新后重试") | ||
| 700 | cursor.execute( | 737 | cursor.execute( |
| 701 | f""" | 738 | f""" |
| 702 | UPDATE {TARGET_TABLE_NAME_TMP} | 739 | UPDATE {TARGET_TABLE_NAME_TMP} |
| ... | @@ -724,8 +761,36 @@ def _update_staging_review( | ... | @@ -724,8 +761,36 @@ def _update_staging_review( |
| 724 | return current | 761 | return current |
| 725 | 762 | ||
| 726 | 763 | ||
| 764 | def _merge_author_field(existing: str | None, incoming: str | None) -> str: | ||
| 765 | """Match import_hk_songs.py: preserve existing authors and append unique incoming authors.""" | ||
| 766 | if not incoming: | ||
| 767 | return existing or "" | ||
| 768 | if not existing: | ||
| 769 | return incoming | ||
| 770 | |||
| 771 | def split_authors(text: str) -> list[str]: | ||
| 772 | return [author.strip() for author in _AUTHOR_SEP_RE.split(text) if author.strip()] | ||
| 773 | |||
| 774 | def normalize(author: str) -> str: | ||
| 775 | author = unicodedata.normalize("NFKC", author) | ||
| 776 | author = _AUTHOR_T2S.convert(author.strip().lower()) | ||
| 777 | return "".join(char for char in author if char not in _AUTHOR_METADATA_PUNCT) | ||
| 778 | |||
| 779 | existing_authors = split_authors(existing) | ||
| 780 | incoming_authors = split_authors(incoming) | ||
| 781 | normalized = {normalize(author) for author in existing_authors} | ||
| 782 | result = list(existing_authors) | ||
| 783 | for author in incoming_authors: | ||
| 784 | key = normalize(author) | ||
| 785 | if key not in normalized: | ||
| 786 | result.append(author) | ||
| 787 | normalized.add(key) | ||
| 788 | merged = "、".join(result) | ||
| 789 | return merged if merged != existing else "" | ||
| 790 | |||
| 791 | |||
| 727 | def _import_approved_staging(source_ids: list[str] | None = None) -> dict[str, object]: | 792 | def _import_approved_staging(source_ids: list[str] | None = None) -> dict[str, object]: |
| 728 | """Import approved rows while holding staging-row locks to prevent duplicate imports.""" | 793 | """Finalize reviewed rows: import non-duplicates and merge duplicate authors.""" |
| 729 | columns = ", ".join(f"`{column}`" for column in TARGET_COLUMNS) | 794 | columns = ", ".join(f"`{column}`" for column in TARGET_COLUMNS) |
| 730 | select_columns = ", ".join(f"s.`{column}`" for column in TARGET_COLUMNS) | 795 | select_columns = ", ".join(f"s.`{column}`" for column in TARGET_COLUMNS) |
| 731 | with _target_conn() as conn, conn.cursor() as cursor: | 796 | with _target_conn() as conn, conn.cursor() as cursor: |
| ... | @@ -737,7 +802,8 @@ def _import_approved_staging(source_ids: list[str] | None = None) -> dict[str, o | ... | @@ -737,7 +802,8 @@ def _import_approved_staging(source_ids: list[str] | None = None) -> dict[str, o |
| 737 | lock_params.extend(source_ids) | 802 | lock_params.extend(source_ids) |
| 738 | cursor.execute( | 803 | cursor.execute( |
| 739 | f""" | 804 | f""" |
| 740 | SELECT s.staging_id, s.source_song_id | 805 | SELECT s.staging_id, s.id AS incoming_id, s.source_song_id, s.source_table_name, s.name, |
| 806 | s.lyricist, s.composer, s.biz_review_status, s.matched_song_id | ||
| 741 | FROM {TARGET_TABLE_NAME_TMP} s | 807 | FROM {TARGET_TABLE_NAME_TMP} s |
| 742 | INNER JOIN ( | 808 | INNER JOIN ( |
| 743 | SELECT source_song_id, MAX(staging_id) AS max_staging_id | 809 | SELECT source_song_id, MAX(staging_id) AS max_staging_id |
| ... | @@ -745,7 +811,7 @@ def _import_approved_staging(source_ids: list[str] | None = None) -> dict[str, o | ... | @@ -745,7 +811,7 @@ def _import_approved_staging(source_ids: list[str] | None = None) -> dict[str, o |
| 745 | GROUP BY source_song_id | 811 | GROUP BY source_song_id |
| 746 | ) latest ON s.staging_id = latest.max_staging_id | 812 | ) latest ON s.staging_id = latest.max_staging_id |
| 747 | WHERE s.dedup_action = 'review' | 813 | WHERE s.dedup_action = 'review' |
| 748 | AND s.biz_review_status = 'approved_import' | 814 | AND s.biz_review_status IN ('approved_import', 'rejected_duplicate') |
| 749 | AND s.staging_status = 'staged' | 815 | AND s.staging_status = 'staged' |
| 750 | AND (s.review_claim_expires_at IS NULL OR s.review_claim_expires_at <= NOW()) | 816 | AND (s.review_claim_expires_at IS NULL OR s.review_claim_expires_at <= NOW()) |
| 751 | {source_filter} | 817 | {source_filter} |
| ... | @@ -754,50 +820,80 @@ def _import_approved_staging(source_ids: list[str] | None = None) -> dict[str, o | ... | @@ -754,50 +820,80 @@ def _import_approved_staging(source_ids: list[str] | None = None) -> dict[str, o |
| 754 | """, | 820 | """, |
| 755 | lock_params, | 821 | lock_params, |
| 756 | ) | 822 | ) |
| 757 | approved_rows = cursor.fetchall() | 823 | reviewed_rows = cursor.fetchall() |
| 758 | if not approved_rows: | 824 | if not reviewed_rows: |
| 759 | conn.rollback() | 825 | conn.rollback() |
| 760 | raise ValueError("没有可入库的审核通过样本") | 826 | raise ValueError("没有可提交的已审核样本") |
| 827 | approved_rows = [row for row in reviewed_rows if row["biz_review_status"] == "approved_import"] | ||
| 828 | duplicate_rows = [row for row in reviewed_rows if row["biz_review_status"] == "rejected_duplicate"] | ||
| 761 | staging_ids = [row["staging_id"] for row in approved_rows] | 829 | staging_ids = [row["staging_id"] for row in approved_rows] |
| 762 | approved_source_ids = [str(row["source_song_id"]) for row in approved_rows] | 830 | approved_source_ids = [str(row["source_song_id"]) for row in approved_rows] |
| 763 | staging_placeholders = ",".join(["%s"] * len(staging_ids)) | 831 | duplicate_targets: dict[object, int] = {} |
| 764 | conflicts = _find_import_conflicts(cursor, staging_ids) | 832 | unresolved_duplicates = [] |
| 765 | if conflicts: | 833 | for row in duplicate_rows: |
| 834 | target = _resolve_duplicate_target(cursor, row) | ||
| 835 | if not target: | ||
| 836 | unresolved_duplicates.append({ | ||
| 837 | "staging_id": row["staging_id"], | ||
| 838 | "source_song_id": row["source_song_id"], | ||
| 839 | "source_table_name": row.get("source_table_name"), | ||
| 840 | "name": row.get("name"), | ||
| 841 | "incoming_id": row.get("incoming_id"), | ||
| 842 | "id_conflict_target_id": None, | ||
| 843 | "source_conflict_target_id": None, | ||
| 844 | "conflict_types": ["duplicate_target_missing"], | ||
| 845 | "matched_song_id": row.get("matched_song_id"), | ||
| 846 | }) | ||
| 847 | else: | ||
| 848 | duplicate_targets[row["staging_id"]] = int(target["id"]) | ||
| 849 | if unresolved_duplicates: | ||
| 766 | conn.rollback() | 850 | conn.rollback() |
| 767 | raise ImportConflictError( | 851 | raise ImportConflictError( |
| 768 | f"发现 {len(conflicts)} 条待入库数据与正式表已有记录冲突,本次未写入任何数据", | 852 | f"有 {len(unresolved_duplicates)} 条“确认重复”记录无法找到已入库的重复目标,本次未写入任何数据", |
| 769 | conflicts, | 853 | unresolved_duplicates, |
| 770 | ) | 854 | ) |
| 771 | try: | 855 | |
| 856 | inserted_count = 0 | ||
| 857 | id_map: dict[str, object] = {} | ||
| 858 | if approved_rows: | ||
| 859 | staging_placeholders = ",".join(["%s"] * len(staging_ids)) | ||
| 860 | conflicts = _find_import_conflicts(cursor, staging_ids) | ||
| 861 | if conflicts: | ||
| 862 | conn.rollback() | ||
| 863 | raise ImportConflictError( | ||
| 864 | f"发现 {len(conflicts)} 条待入库数据与正式表已有记录冲突,本次未写入任何数据", | ||
| 865 | conflicts, | ||
| 866 | ) | ||
| 867 | try: | ||
| 868 | cursor.execute( | ||
| 869 | f""" | ||
| 870 | INSERT INTO {TARGET_TABLE_NAME} ({columns}) | ||
| 871 | SELECT {select_columns} | ||
| 872 | FROM {TARGET_TABLE_NAME_TMP} s | ||
| 873 | WHERE s.staging_id IN ({staging_placeholders}) | ||
| 874 | """, | ||
| 875 | staging_ids, | ||
| 876 | ) | ||
| 877 | except pymysql.err.IntegrityError as exc: | ||
| 878 | conn.rollback() | ||
| 879 | conflicts = _find_import_conflicts(cursor, staging_ids) | ||
| 880 | raise ImportConflictError( | ||
| 881 | "入库时发生唯一键或主键冲突,本次未写入任何数据", | ||
| 882 | conflicts, | ||
| 883 | str(exc), | ||
| 884 | ) from exc | ||
| 885 | inserted_count = cursor.rowcount | ||
| 886 | source_placeholders = ",".join(["%s"] * len(approved_source_ids)) | ||
| 772 | cursor.execute( | 887 | cursor.execute( |
| 773 | f""" | 888 | f""" |
| 774 | INSERT INTO {TARGET_TABLE_NAME} ({columns}) | 889 | SELECT source_song_id, id AS target_id |
| 775 | SELECT {select_columns} | 890 | FROM {TARGET_TABLE_NAME} |
| 776 | FROM {TARGET_TABLE_NAME_TMP} s | 891 | WHERE source_song_id IN ({source_placeholders}) |
| 777 | WHERE s.staging_id IN ({staging_placeholders}) | ||
| 778 | """, | 892 | """, |
| 779 | staging_ids, | 893 | approved_source_ids, |
| 780 | ) | 894 | ) |
| 781 | except pymysql.err.IntegrityError as exc: | 895 | id_map = {str(row["source_song_id"]): row["target_id"] for row in cursor.fetchall()} |
| 782 | # 预检与 INSERT 之间仍可能有其他程序写入,回滚后再查一次以返回具体行。 | 896 | |
| 783 | conn.rollback() | ||
| 784 | conflicts = _find_import_conflicts(cursor, staging_ids) | ||
| 785 | raise ImportConflictError( | ||
| 786 | "入库时发生唯一键或主键冲突,本次未写入任何数据", | ||
| 787 | conflicts, | ||
| 788 | str(exc), | ||
| 789 | ) from exc | ||
| 790 | inserted_count = cursor.rowcount | ||
| 791 | source_placeholders = ",".join(["%s"] * len(approved_source_ids)) | ||
| 792 | cursor.execute( | ||
| 793 | f""" | ||
| 794 | SELECT source_song_id, id AS target_id | ||
| 795 | FROM {TARGET_TABLE_NAME} | ||
| 796 | WHERE source_song_id IN ({source_placeholders}) | ||
| 797 | """, | ||
| 798 | approved_source_ids, | ||
| 799 | ) | ||
| 800 | id_map = {str(row["source_song_id"]): row["target_id"] for row in cursor.fetchall()} | ||
| 801 | imported_updated = 0 | 897 | imported_updated = 0 |
| 802 | for approved_row in approved_rows: | 898 | for approved_row in approved_rows: |
| 803 | sid = str(approved_row["source_song_id"]) | 899 | sid = str(approved_row["source_song_id"]) |
| ... | @@ -814,15 +910,89 @@ def _import_approved_staging(source_ids: list[str] | None = None) -> dict[str, o | ... | @@ -814,15 +910,89 @@ def _import_approved_staging(source_ids: list[str] | None = None) -> dict[str, o |
| 814 | (target_id, approved_row["staging_id"]), | 910 | (target_id, approved_row["staging_id"]), |
| 815 | ) | 911 | ) |
| 816 | imported_updated += cursor.rowcount | 912 | imported_updated += cursor.rowcount |
| 913 | |||
| 914 | duplicate_updated = 0 | ||
| 915 | author_merged = 0 | ||
| 916 | for duplicate_row in duplicate_rows: | ||
| 917 | target_id = duplicate_targets[duplicate_row["staging_id"]] | ||
| 918 | cursor.execute( | ||
| 919 | f"SELECT id, lyricist, composer FROM {TARGET_TABLE_NAME} WHERE id = %s AND deleted = '0' FOR UPDATE", | ||
| 920 | (target_id,), | ||
| 921 | ) | ||
| 922 | target = cursor.fetchone() | ||
| 923 | if not target: | ||
| 924 | raise RuntimeError(f"重复目标已失效: target_id={target_id}") | ||
| 925 | merged_lyricist = _merge_author_field(target.get("lyricist"), duplicate_row.get("lyricist")) | ||
| 926 | merged_composer = _merge_author_field(target.get("composer"), duplicate_row.get("composer")) | ||
| 927 | changed = False | ||
| 928 | if merged_lyricist: | ||
| 929 | cursor.execute( | ||
| 930 | f"UPDATE {TARGET_TABLE_NAME} SET lyricist = %s WHERE id = %s", | ||
| 931 | (merged_lyricist, target_id), | ||
| 932 | ) | ||
| 933 | changed = True | ||
| 934 | if merged_composer: | ||
| 935 | cursor.execute( | ||
| 936 | f"UPDATE {TARGET_TABLE_NAME} SET composer = %s WHERE id = %s", | ||
| 937 | (merged_composer, target_id), | ||
| 938 | ) | ||
| 939 | changed = True | ||
| 940 | if changed: | ||
| 941 | author_merged += 1 | ||
| 942 | cursor.execute( | ||
| 943 | f""" | ||
| 944 | UPDATE {TARGET_TABLE_NAME_TMP} | ||
| 945 | SET staging_status = 'skipped', imported_song_id = %s, error_message = NULL, | ||
| 946 | merge_authors = %s, review_version = review_version + 1 | ||
| 947 | WHERE staging_id = %s AND staging_status = 'staged' | ||
| 948 | AND biz_review_status = 'rejected_duplicate' | ||
| 949 | """, | ||
| 950 | (target_id, 1 if changed else 0, duplicate_row["staging_id"]), | ||
| 951 | ) | ||
| 952 | if cursor.rowcount != 1: | ||
| 953 | raise RuntimeError(f"重复样本终态更新失败: staging_id={duplicate_row['staging_id']}") | ||
| 954 | duplicate_updated += 1 | ||
| 817 | conn.commit() | 955 | conn.commit() |
| 818 | return { | 956 | return { |
| 819 | "approved_count": len(approved_rows), | 957 | "approved_count": len(approved_rows), |
| 820 | "inserted_count": inserted_count, | 958 | "inserted_count": inserted_count, |
| 821 | "imported_song_ids_updated": imported_updated, | 959 | "imported_song_ids_updated": imported_updated, |
| 960 | "duplicate_count": len(duplicate_rows), | ||
| 961 | "duplicate_staging_updated": duplicate_updated, | ||
| 962 | "author_merged_count": author_merged, | ||
| 822 | "source_ids": approved_source_ids, | 963 | "source_ids": approved_source_ids, |
| 964 | "duplicate_source_ids": [str(row["source_song_id"]) for row in duplicate_rows], | ||
| 823 | } | 965 | } |
| 824 | 966 | ||
| 825 | 967 | ||
| 968 | def _resolve_duplicate_target(cursor, row: dict[str, object]) -> dict[str, object] | None: | ||
| 969 | """Resolve a human-confirmed duplicate to an existing, active target record.""" | ||
| 970 | matched_id = str(row.get("matched_song_id") or "").strip() | ||
| 971 | if not matched_id: | ||
| 972 | return None | ||
| 973 | try: | ||
| 974 | numeric_id = int(matched_id) | ||
| 975 | except (TypeError, ValueError): | ||
| 976 | numeric_id = None | ||
| 977 | if numeric_id is not None: | ||
| 978 | cursor.execute( | ||
| 979 | f"SELECT id FROM {TARGET_TABLE_NAME} WHERE id = %s AND deleted = '0' FOR UPDATE", | ||
| 980 | (numeric_id,), | ||
| 981 | ) | ||
| 982 | target = cursor.fetchone() | ||
| 983 | if target: | ||
| 984 | return target | ||
| 985 | cursor.execute( | ||
| 986 | f""" | ||
| 987 | SELECT id FROM {TARGET_TABLE_NAME} | ||
| 988 | WHERE source_table_name = %s AND source_song_id = %s AND deleted = '0' | ||
| 989 | FOR UPDATE | ||
| 990 | """, | ||
| 991 | (row.get("source_table_name"), matched_id), | ||
| 992 | ) | ||
| 993 | return cursor.fetchone() | ||
| 994 | |||
| 995 | |||
| 826 | def _find_import_conflicts(cursor, staging_ids: list[object]) -> list[dict[str, object]]: | 996 | def _find_import_conflicts(cursor, staging_ids: list[object]) -> list[dict[str, object]]: |
| 827 | """Return target-table key conflicts for the selected staging rows.""" | 997 | """Return target-table key conflicts for the selected staging rows.""" |
| 828 | if not staging_ids: | 998 | if not staging_ids: |
| ... | @@ -1276,6 +1446,7 @@ class Handler(BaseHTTPRequestHandler): | ... | @@ -1276,6 +1446,7 @@ class Handler(BaseHTTPRequestHandler): |
| 1276 | staging_id = int(payload.get("staging_id") or 0) | 1446 | staging_id = int(payload.get("staging_id") or 0) |
| 1277 | decision = str(payload.get("decision", "")).strip() | 1447 | decision = str(payload.get("decision", "")).strip() |
| 1278 | note = str(payload.get("note", "")).strip() | 1448 | note = str(payload.get("note", "")).strip() |
| 1449 | candidate_id = str(payload.get("candidate_id", "")).strip() | ||
| 1279 | client_token = str(payload.get("client_token") or "").strip() | 1450 | client_token = str(payload.get("client_token") or "").strip() |
| 1280 | expected_version = int(payload.get("expected_version", -1)) | 1451 | expected_version = int(payload.get("expected_version", -1)) |
| 1281 | if not staging_id: | 1452 | if not staging_id: |
| ... | @@ -1285,7 +1456,7 @@ class Handler(BaseHTTPRequestHandler): | ... | @@ -1285,7 +1456,7 @@ class Handler(BaseHTTPRequestHandler): |
| 1285 | if decision not in ("approved_import", "rejected_duplicate", "unsure", "pending", "deleted"): | 1456 | if decision not in ("approved_import", "rejected_duplicate", "unsure", "pending", "deleted"): |
| 1286 | raise ValueError(f"invalid decision: {decision}") | 1457 | raise ValueError(f"invalid decision: {decision}") |
| 1287 | result = _update_staging_review( | 1458 | result = _update_staging_review( |
| 1288 | staging_id, decision, note, reviewer, expected_version, client_token | 1459 | staging_id, decision, note, reviewer, expected_version, client_token, candidate_id |
| 1289 | ) | 1460 | ) |
| 1290 | _json_response(self, {"record": result}) | 1461 | _json_response(self, {"record": result}) |
| 1291 | except ReviewConflictError as exc: | 1462 | except ReviewConflictError as exc: |
| ... | @@ -1310,11 +1481,19 @@ class Handler(BaseHTTPRequestHandler): | ... | @@ -1310,11 +1481,19 @@ class Handler(BaseHTTPRequestHandler): |
| 1310 | if source_ids | 1481 | if source_ids |
| 1311 | else None | 1482 | else None |
| 1312 | ) | 1483 | ) |
| 1313 | approved = [ | 1484 | finalized = [ |
| 1314 | {"source_id": source_id, "review_decision": "import", "review_note": ""} | 1485 | {"source_id": source_id, "review_decision": "import", "review_note": ""} |
| 1315 | for source_id in result["source_ids"] | 1486 | for source_id in result["source_ids"] |
| 1316 | ] | 1487 | ] |
| 1317 | review_csv = _write_review_import_csv(approved) | 1488 | finalized.extend( |
| 1489 | { | ||
| 1490 | "source_id": source_id, | ||
| 1491 | "review_decision": "duplicate_merged", | ||
| 1492 | "review_note": "确认重复,词/曲作者已增量合并到已有记录", | ||
| 1493 | } | ||
| 1494 | for source_id in result["duplicate_source_ids"] | ||
| 1495 | ) | ||
| 1496 | review_csv = _write_review_import_csv(finalized) | ||
| 1318 | _json_response(self, {"review_csv": str(review_csv), **result}) | 1497 | _json_response(self, {"review_csv": str(review_csv), **result}) |
| 1319 | except ReviewAuthError as exc: | 1498 | except ReviewAuthError as exc: |
| 1320 | _error(self, str(exc), status=401) | 1499 | _error(self, str(exc), status=401) | ... | ... |
| ... | @@ -96,6 +96,45 @@ def test_stale_review_returns_conflict_without_overwrite(): | ... | @@ -96,6 +96,45 @@ def test_stale_review_returns_conflict_without_overwrite(): |
| 96 | assert not conn.committed | 96 | assert not conn.committed |
| 97 | 97 | ||
| 98 | 98 | ||
| 99 | def test_rejected_duplicate_persists_the_selected_candidate(): | ||
| 100 | snapshot = { | ||
| 101 | "staging_id": 42, | ||
| 102 | "matched_song_id": None, | ||
| 103 | "recalled_candidates": '[{"id":"existing-song"}]', | ||
| 104 | "biz_review_status": "rejected_duplicate", | ||
| 105 | "review_version": 8, | ||
| 106 | } | ||
| 107 | cursor = FakeCursor(update_rowcount=1, snapshot=snapshot) | ||
| 108 | conn = FakeConnection(cursor) | ||
| 109 | |||
| 110 | with patch.object(dashboard, "_target_conn", return_value=conn): | ||
| 111 | dashboard._update_staging_review( | ||
| 112 | 42, "rejected_duplicate", "same", "alice", 7, "browser-token", "existing-song" | ||
| 113 | ) | ||
| 114 | |||
| 115 | update_sql, params = cursor.executions[1] | ||
| 116 | assert "matched_song_id = %s" in update_sql | ||
| 117 | assert params[:3] == ["rejected_duplicate", "alice", "existing-song"] | ||
| 118 | assert conn.committed | ||
| 119 | |||
| 120 | |||
| 121 | def test_rejected_duplicate_rejects_a_candidate_outside_recall_results(): | ||
| 122 | cursor = FakeCursor( | ||
| 123 | update_rowcount=1, | ||
| 124 | snapshot={"staging_id": 42, "matched_song_id": "candidate-a", "recalled_candidates": "[]"}, | ||
| 125 | ) | ||
| 126 | conn = FakeConnection(cursor) | ||
| 127 | |||
| 128 | with patch.object(dashboard, "_target_conn", return_value=conn): | ||
| 129 | with pytest.raises(ValueError, match="不在当前召回结果"): | ||
| 130 | dashboard._update_staging_review( | ||
| 131 | 42, "rejected_duplicate", "", "alice", 7, "browser-token", "candidate-b" | ||
| 132 | ) | ||
| 133 | |||
| 134 | assert conn.rolled_back | ||
| 135 | assert not any(sql.lstrip().startswith("UPDATE") for sql, _params in cursor.executions) | ||
| 136 | |||
| 137 | |||
| 99 | def test_pending_review_restores_a_deleted_staging_row(): | 138 | def test_pending_review_restores_a_deleted_staging_row(): |
| 100 | snapshot = { | 139 | snapshot = { |
| 101 | "staging_id": 42, | 140 | "staging_id": 42, |
| ... | @@ -185,7 +224,7 @@ class ImportCursor(FakeCursor): | ... | @@ -185,7 +224,7 @@ class ImportCursor(FakeCursor): |
| 185 | def __init__(self) -> None: | 224 | def __init__(self) -> None: |
| 186 | super().__init__(update_rowcount=1, snapshot={}) | 225 | super().__init__(update_rowcount=1, snapshot={}) |
| 187 | self.result_sets = [ | 226 | self.result_sets = [ |
| 188 | [{"staging_id": 42, "source_song_id": "song-1"}], | 227 | [{"staging_id": 42, "source_song_id": "song-1", "biz_review_status": "approved_import"}], |
| 189 | [], | 228 | [], |
| 190 | [{"source_song_id": "song-1", "target_id": 9001}], | 229 | [{"source_song_id": "song-1", "target_id": 9001}], |
| 191 | ] | 230 | ] |
| ... | @@ -217,7 +256,7 @@ def test_import_locks_only_unclaimed_staged_rows(): | ... | @@ -217,7 +256,7 @@ def test_import_locks_only_unclaimed_staged_rows(): |
| 217 | def test_import_conflict_rolls_back_and_exposes_the_staging_row(): | 256 | def test_import_conflict_rolls_back_and_exposes_the_staging_row(): |
| 218 | cursor = ImportCursor() | 257 | cursor = ImportCursor() |
| 219 | cursor.result_sets = [ | 258 | cursor.result_sets = [ |
| 220 | [{"staging_id": 42, "source_song_id": "song-1"}], | 259 | [{"staging_id": 42, "source_song_id": "song-1", "biz_review_status": "approved_import"}], |
| 221 | [{ | 260 | [{ |
| 222 | "staging_id": 42, | 261 | "staging_id": 42, |
| 223 | "source_song_id": "song-1", | 262 | "source_song_id": "song-1", |
| ... | @@ -241,6 +280,69 @@ def test_import_conflict_rolls_back_and_exposes_the_staging_row(): | ... | @@ -241,6 +280,69 @@ def test_import_conflict_rolls_back_and_exposes_the_staging_row(): |
| 241 | assert not any(sql.lstrip().startswith("INSERT") for sql, _params in cursor.executions) | 280 | assert not any(sql.lstrip().startswith("INSERT") for sql, _params in cursor.executions) |
| 242 | 281 | ||
| 243 | 282 | ||
| 283 | class DuplicateImportCursor: | ||
| 284 | def __init__(self) -> None: | ||
| 285 | self.rowcount = 0 | ||
| 286 | self.executions: list[tuple[str, object]] = [] | ||
| 287 | self.current = None | ||
| 288 | |||
| 289 | def __enter__(self): | ||
| 290 | return self | ||
| 291 | |||
| 292 | def __exit__(self, *_args): | ||
| 293 | return False | ||
| 294 | |||
| 295 | def execute(self, sql, params=None): | ||
| 296 | self.executions.append((sql, params)) | ||
| 297 | compact = " ".join(sql.split()) | ||
| 298 | self.rowcount = 0 | ||
| 299 | if "SELECT s.staging_id" in compact and "FOR UPDATE" in compact: | ||
| 300 | self.current = [{ | ||
| 301 | "staging_id": 42, | ||
| 302 | "source_song_id": "new-song", | ||
| 303 | "source_table_name": "hk_song_platform", | ||
| 304 | "name": "same song", | ||
| 305 | "lyricist": "Alice/Bob", | ||
| 306 | "composer": "Composer B", | ||
| 307 | "biz_review_status": "rejected_duplicate", | ||
| 308 | "matched_song_id": "existing-song", | ||
| 309 | }] | ||
| 310 | elif "SELECT id FROM" in compact and "source_table_name = %s" in compact: | ||
| 311 | self.current = {"id": 9001} | ||
| 312 | elif "SELECT id, lyricist, composer" in compact: | ||
| 313 | self.current = {"id": 9001, "lyricist": "Alice", "composer": "Composer A"} | ||
| 314 | elif compact.startswith("UPDATE"): | ||
| 315 | self.current = None | ||
| 316 | self.rowcount = 1 | ||
| 317 | else: | ||
| 318 | raise AssertionError(f"unexpected SQL: {compact}") | ||
| 319 | |||
| 320 | def fetchall(self): | ||
| 321 | return self.current or [] | ||
| 322 | |||
| 323 | def fetchone(self): | ||
| 324 | return self.current | ||
| 325 | |||
| 326 | |||
| 327 | def test_confirmed_duplicate_merges_authors_without_inserting_a_new_song(): | ||
| 328 | cursor = DuplicateImportCursor() | ||
| 329 | conn = FakeConnection(cursor) | ||
| 330 | |||
| 331 | with patch.object(dashboard, "_target_conn", return_value=conn): | ||
| 332 | result = dashboard._import_approved_staging() | ||
| 333 | |||
| 334 | sql_params = [(" ".join(sql.split()), params) for sql, params in cursor.executions] | ||
| 335 | assert not any(sql.startswith("INSERT") for sql, _params in sql_params) | ||
| 336 | assert any("SET lyricist = %s" in sql and params == ("Alice、Bob", 9001) for sql, params in sql_params) | ||
| 337 | assert any("SET composer = %s" in sql and params == ("Composer A、Composer B", 9001) for sql, params in sql_params) | ||
| 338 | assert any("SET staging_status = 'skipped'" in sql for sql, _params in sql_params) | ||
| 339 | assert result["duplicate_count"] == 1 | ||
| 340 | assert result["author_merged_count"] == 1 | ||
| 341 | assert result["inserted_count"] == 0 | ||
| 342 | assert result["duplicate_source_ids"] == ["new-song"] | ||
| 343 | assert conn.committed | ||
| 344 | |||
| 345 | |||
| 244 | def test_browsing_a_list_item_can_auto_claim_without_reloading_groups(): | 346 | def test_browsing_a_list_item_can_auto_claim_without_reloading_groups(): |
| 245 | html = Path("l2_review_dashboard.html").read_text(encoding="utf-8") | 347 | html = Path("l2_review_dashboard.html").read_text(encoding="utf-8") |
| 246 | start = html.index("for (const btn of els.queryList.querySelectorAll('.query-item'))") | 348 | start = html.index("for (const btn of els.queryList.querySelectorAll('.query-item'))") |
| ... | @@ -274,7 +376,17 @@ def test_dashboard_distinguishes_reviewed_from_submitted(): | ... | @@ -274,7 +376,17 @@ def test_dashboard_distinguishes_reviewed_from_submitted(): |
| 274 | assert row["staging_status"] == "imported" | 376 | assert row["staging_status"] == "imported" |
| 275 | assert '<option value="reviewed">已审核</option>' in html | 377 | assert '<option value="reviewed">已审核</option>' in html |
| 276 | assert '<option value="submitted">已提交</option>' in html | 378 | assert '<option value="submitted">已提交</option>' in html |
| 277 | assert "hasFinalReview && query.staging_status !== 'imported'" in html | 379 | assert "hasFinalReview && query.staging_status === 'staged'" in html |
| 380 | |||
| 381 | |||
| 382 | def test_dashboard_has_a_dedicated_deleted_filter(): | ||
| 383 | html = Path("l2_review_dashboard.html").read_text(encoding="utf-8") | ||
| 384 | |||
| 385 | assert '<option value="deleted">已删除</option>' in html | ||
| 386 | source = Path("serve_l2_dashboard.py").read_text(encoding="utf-8") | ||
| 387 | assert "elif decision == 'deleted':" in source | ||
| 388 | assert "s.biz_review_status = 'deleted'" in source | ||
| 389 | assert "s.staging_status = 'deleted'" in source | ||
| 278 | 390 | ||
| 279 | 391 | ||
| 280 | def test_review_access_code_issues_and_validates_edit_session(): | 392 | def test_review_access_code_issues_and_validates_edit_session(): | ... | ... |
-
Please register or sign in to post a comment