Commit 527d6149 527d6149e175118d3b213143f82dcc65efa45682 by 沈秋雨

feat(review): 支持同词不同曲和同曲不同词的合并类型审核

1 parent 2ad02073
...@@ -991,6 +991,7 @@ ...@@ -991,6 +991,7 @@
991 row.review_version = String(record.review_version ?? row.review_version ?? 0); 991 row.review_version = String(record.review_version ?? row.review_version ?? 0);
992 row.review_claimed_by = record.review_claimed_by ?? ''; 992 row.review_claimed_by = record.review_claimed_by ?? '';
993 row.review_claim_expires_at = record.review_claim_expires_at ?? ''; 993 row.review_claim_expires_at = record.review_claim_expires_at ?? '';
994 row.merge_diff_type = record.merge_diff_type ?? '';
994 row.staging_status = record.staging_status ?? row.staging_status; 995 row.staging_status = record.staging_status ?? row.staging_status;
995 row.claimed_by_me = Boolean(record.claimed_by_me) || row.review_claimed_by === state.reviewer; 996 row.claimed_by_me = Boolean(record.claimed_by_me) || row.review_claimed_by === state.reviewer;
996 } 997 }
...@@ -1183,7 +1184,7 @@ ...@@ -1183,7 +1184,7 @@
1183 </div>`; 1184 </div>`;
1184 } 1185 }
1185 1186
1186 async function updateStagingReview(query, decision, note, candidateId = '') { 1187 async function updateStagingReview(query, decision, note, candidateId = '', mergeDiffType = '') {
1187 return reviewPostJSON('/api/staging-review', { 1188 return reviewPostJSON('/api/staging-review', {
1188 staging_id: String(query.staging_id), 1189 staging_id: String(query.staging_id),
1189 expected_version: Number(query.review_version || 0), 1190 expected_version: Number(query.review_version || 0),
...@@ -1191,13 +1192,16 @@ ...@@ -1191,13 +1192,16 @@
1191 reviewer: state.reviewer, 1192 reviewer: state.reviewer,
1192 decision, 1193 decision,
1193 note, 1194 note,
1194 candidate_id: candidateId 1195 candidate_id: candidateId,
1196 merge_diff_type: mergeDiffType
1195 }); 1197 });
1196 } 1198 }
1197 1199
1198 const _DB_REVIEW_LABELS = { 1200 const _DB_REVIEW_LABELS = {
1199 approved_import: '确认不重复(待入库)', 1201 approved_import: '确认不重复(待入库)',
1200 rejected_duplicate: '确认重复(待合并作者)', 1202 rejected_duplicate: '确认重复(待合并作者)',
1203 same_lyric_diff_melody: '同词不同曲(待合并)',
1204 diff_lyric_same_melody: '同曲不同词(待合并)',
1201 unsure: '待确认', 1205 unsure: '待确认',
1202 deleted: '已删除', 1206 deleted: '已删除',
1203 }; 1207 };
...@@ -1288,6 +1292,10 @@ ...@@ -1288,6 +1292,10 @@
1288 const candidateComposer = candidate?.candidate_composer; 1292 const candidateComposer = candidate?.candidate_composer;
1289 const reason = candidate?.candidate_reason; 1293 const reason = candidate?.candidate_reason;
1290 const selectedReview = candidate ? getReview(candidate) : {}; 1294 const selectedReview = candidate ? getReview(candidate) : {};
1295 // 从 DB 状态恢复 merge_diff_type 对应的按钮高亮
1296 if (!selectedReview.final_decision && query.merge_diff_type) {
1297 selectedReview.final_decision = query.merge_diff_type;
1298 }
1291 const conflict = candidate ? isConflict(candidate) : false; 1299 const conflict = candidate ? isConflict(candidate) : false;
1292 const l1 = candidate ? l1Match(candidate) : false; 1300 const l1 = candidate ? l1Match(candidate) : false;
1293 const queryAudioUrl = query.audio_url || ''; 1301 const queryAudioUrl = query.audio_url || '';
...@@ -1351,9 +1359,11 @@ ...@@ -1351,9 +1359,11 @@
1351 <span style="flex:1"> 1359 <span style="flex:1">
1352 <b>${esc(query.review_status === 'approved_import' && query.staging_status === 'imported' 1360 <b>${esc(query.review_status === 'approved_import' && query.staging_status === 'imported'
1353 ? '确认不重复(已入库)' 1361 ? '确认不重复(已入库)'
1354 : query.review_status === 'rejected_duplicate' && query.staging_status === 'skipped' 1362 : query.merge_diff_type
1355 ? '确认重复(作者已合并)' 1363 ? `${_DB_REVIEW_LABELS[query.merge_diff_type] || query.merge_diff_type}`
1356 : (_DB_REVIEW_LABELS[query.review_status] || query.review_status))}</b> 1364 : query.review_status === 'rejected_duplicate' && query.staging_status === 'skipped'
1365 ? '确认重复(作者已合并)'
1366 : (_DB_REVIEW_LABELS[query.review_status] || query.review_status))}</b>
1357 ${query.reviewed_by ? ` · 审核人 ${esc(query.reviewed_by)}` : ''} 1367 ${query.reviewed_by ? ` · 审核人 ${esc(query.reviewed_by)}` : ''}
1358 ${query.reviewed_at ? ` · ${esc(query.reviewed_at)}` : ''} 1368 ${query.reviewed_at ? ` · ${esc(query.reviewed_at)}` : ''}
1359 ${query.review_note ? ` · 备注: ${esc(query.review_note)}` : ''} 1369 ${query.review_note ? ` · 备注: ${esc(query.review_note)}` : ''}
...@@ -1362,9 +1372,9 @@ ...@@ -1362,9 +1372,9 @@
1362 </div> 1372 </div>
1363 ` : needsManualReview ? ` 1373 ` : needsManualReview ? `
1364 <div class="review-bar"> 1374 <div class="review-bar">
1365 ${['duplicate', 'not_duplicate', 'unsure'].map(value => ` 1375 ${['duplicate', 'same_lyric_diff_melody', 'diff_lyric_same_melody', 'not_duplicate', 'unsure'].map(value => `
1366 <button class="review-choice ${selectedReview.final_decision === value ? 'active' : ''}" data-review="${value}"> 1376 <button class="review-choice ${selectedReview.final_decision === value ? 'active' : ''}" data-review="${value}">
1367 ${value === 'duplicate' ? '确认重复' : value === 'not_duplicate' ? '确认不重复' : '待确认'} 1377 ${{duplicate: '确认重复', same_lyric_diff_melody: '同词不同曲', diff_lyric_same_melody: '同曲不同词', not_duplicate: '确认不重复', unsure: '待确认'}[value]}
1368 </button>`).join('')} 1378 </button>`).join('')}
1369 <input id="reviewNote" value="${esc(selectedReview.note || '')}" placeholder="人工备注"> 1379 <input id="reviewNote" value="${esc(selectedReview.note || '')}" placeholder="人工备注">
1370 <button id="deleteReviewBtn" class="secondary" style="color:#c0392b;border-color:#c0392b">删除</button> 1380 <button id="deleteReviewBtn" class="secondary" style="color:#c0392b;border-color:#c0392b">删除</button>
...@@ -1402,7 +1412,17 @@ ...@@ -1402,7 +1412,17 @@
1402 renderDetail(); 1412 renderDetail();
1403 }); 1413 });
1404 } 1414 }
1405 const _REVIEW_DECISION_MAP = { duplicate: 'rejected_duplicate', not_duplicate: 'approved_import', unsure: 'unsure' }; 1415 const _REVIEW_DECISION_MAP = {
1416 duplicate: 'rejected_duplicate',
1417 same_lyric_diff_melody: 'rejected_duplicate',
1418 diff_lyric_same_melody: 'rejected_duplicate',
1419 not_duplicate: 'approved_import',
1420 unsure: 'unsure'
1421 };
1422 const _MERGE_DIFF_TYPE_MAP = {
1423 same_lyric_diff_melody: 'same_lyric_diff_melody',
1424 diff_lyric_same_melody: 'diff_lyric_same_melody'
1425 };
1406 for (const btn of els.detail.querySelectorAll('.review-choice')) { 1426 for (const btn of els.detail.querySelectorAll('.review-choice')) {
1407 btn.addEventListener('click', async () => { 1427 btn.addEventListener('click', async () => {
1408 if (!candidate) return; 1428 if (!candidate) return;
...@@ -1416,8 +1436,11 @@ ...@@ -1416,8 +1436,11 @@
1416 renderDetail(); 1436 renderDetail();
1417 return; 1437 return;
1418 } 1438 }
1439 const mergeDiffType = _MERGE_DIFF_TYPE_MAP[localDecision] || '';
1419 const payload = await updateStagingReview( 1440 const payload = await updateStagingReview(
1420 query, dbDecision, note, localDecision === 'duplicate' ? (candidate?.candidate_id || '') : '' 1441 query, dbDecision, note,
1442 (localDecision === 'duplicate' || mergeDiffType) ? (candidate?.candidate_id || '') : '',
1443 mergeDiffType
1421 ); 1444 );
1422 for (const row of state.queryRows) applyReviewSnapshot(row, payload.record); 1445 for (const row of state.queryRows) applyReviewSnapshot(row, payload.record);
1423 // 刷新统计和加载列表并行 1446 // 刷新统计和加载列表并行
......
...@@ -324,6 +324,7 @@ def _staging_dashboard_row(row: dict[str, object]) -> dict[str, str]: ...@@ -324,6 +324,7 @@ def _staging_dashboard_row(row: dict[str, object]) -> dict[str, str]:
324 "review_claimed_by": str(row.get("review_claimed_by") or ""), 324 "review_claimed_by": str(row.get("review_claimed_by") or ""),
325 "review_claim_expires_at": str(row.get("review_claim_expires_at") or ""), 325 "review_claim_expires_at": str(row.get("review_claim_expires_at") or ""),
326 "staging_status": str(row.get("staging_status") or ""), 326 "staging_status": str(row.get("staging_status") or ""),
327 "merge_diff_type": str(row.get("merge_diff_type") or ""),
327 "l1_metadata_match": "1" if row.get("l1_matched_id") else "0", 328 "l1_metadata_match": "1" if row.get("l1_matched_id") else "0",
328 "l1_l2_conflict": "0", 329 "l1_l2_conflict": "0",
329 "audio_url": str(row.get("audio_url") or ""), 330 "audio_url": str(row.get("audio_url") or ""),
...@@ -582,7 +583,7 @@ def _review_snapshot(cursor, staging_id: int) -> dict[str, object]: ...@@ -582,7 +583,7 @@ def _review_snapshot(cursor, staging_id: int) -> dict[str, object]:
582 f""" 583 f"""
583 SELECT staging_id, source_song_id, dedup_action, biz_review_status, biz_review_note, 584 SELECT staging_id, source_song_id, dedup_action, biz_review_status, biz_review_note,
584 reviewed_by, reviewed_at, review_version, review_claimed_by, 585 reviewed_by, reviewed_at, review_version, review_claimed_by,
585 review_claim_expires_at, staging_status 586 review_claim_expires_at, staging_status, merge_diff_type
586 FROM {TARGET_TABLE_NAME_TMP} 587 FROM {TARGET_TABLE_NAME_TMP}
587 WHERE staging_id = %s 588 WHERE staging_id = %s
588 """, 589 """,
...@@ -684,6 +685,7 @@ def _update_staging_review( ...@@ -684,6 +685,7 @@ def _update_staging_review(
684 expected_version: int, 685 expected_version: int,
685 client_token: str, 686 client_token: str,
686 candidate_id: str = "", 687 candidate_id: str = "",
688 merge_diff_type: str = "",
687 ) -> dict[str, object]: 689 ) -> dict[str, object]:
688 """Persist one claimed review using optimistic concurrency control.""" 690 """Persist one claimed review using optimistic concurrency control."""
689 if decision == "pending": 691 if decision == "pending":
...@@ -699,7 +701,8 @@ def _update_staging_review( ...@@ -699,7 +701,8 @@ def _update_staging_review(
699 elif decision == "rejected_duplicate": 701 elif decision == "rejected_duplicate":
700 status_fields = """ 702 status_fields = """
701 biz_review_status = %s, reviewed_by = %s, reviewed_at = NOW(), 703 biz_review_status = %s, reviewed_by = %s, reviewed_at = NOW(),
702 matched_song_id = %s 704 matched_song_id = %s,
705 merge_diff_type = NULLIF(%s, '')
703 """ 706 """
704 else: 707 else:
705 status_fields = "biz_review_status = %s, reviewed_by = %s, reviewed_at = NOW()" 708 status_fields = "biz_review_status = %s, reviewed_by = %s, reviewed_at = NOW()"
...@@ -713,7 +716,7 @@ def _update_staging_review( ...@@ -713,7 +716,7 @@ def _update_staging_review(
713 candidate_id = candidate_id.strip() 716 candidate_id = candidate_id.strip()
714 if not candidate_id: 717 if not candidate_id:
715 raise ValueError("确认重复前请先选择一条召回候选") 718 raise ValueError("确认重复前请先选择一条召回候选")
716 params = [decision, reviewer, candidate_id, note, staging_id, expected_version, client_token, reviewer] 719 params = [decision, reviewer, candidate_id, merge_diff_type, note, staging_id, expected_version, client_token, reviewer]
717 else: 720 else:
718 params = [decision, reviewer, note, staging_id, expected_version, client_token, reviewer] 721 params = [decision, reviewer, note, staging_id, expected_version, client_token, reviewer]
719 with _target_conn() as conn, conn.cursor() as cursor: 722 with _target_conn() as conn, conn.cursor() as cursor:
...@@ -1447,6 +1450,7 @@ class Handler(BaseHTTPRequestHandler): ...@@ -1447,6 +1450,7 @@ class Handler(BaseHTTPRequestHandler):
1447 decision = str(payload.get("decision", "")).strip() 1450 decision = str(payload.get("decision", "")).strip()
1448 note = str(payload.get("note", "")).strip() 1451 note = str(payload.get("note", "")).strip()
1449 candidate_id = str(payload.get("candidate_id", "")).strip() 1452 candidate_id = str(payload.get("candidate_id", "")).strip()
1453 merge_diff_type = str(payload.get("merge_diff_type", "")).strip()
1450 client_token = str(payload.get("client_token") or "").strip() 1454 client_token = str(payload.get("client_token") or "").strip()
1451 expected_version = int(payload.get("expected_version", -1)) 1455 expected_version = int(payload.get("expected_version", -1))
1452 if not staging_id: 1456 if not staging_id:
...@@ -1456,7 +1460,7 @@ class Handler(BaseHTTPRequestHandler): ...@@ -1456,7 +1460,7 @@ class Handler(BaseHTTPRequestHandler):
1456 if decision not in ("approved_import", "rejected_duplicate", "unsure", "pending", "deleted"): 1460 if decision not in ("approved_import", "rejected_duplicate", "unsure", "pending", "deleted"):
1457 raise ValueError(f"invalid decision: {decision}") 1461 raise ValueError(f"invalid decision: {decision}")
1458 result = _update_staging_review( 1462 result = _update_staging_review(
1459 staging_id, decision, note, reviewer, expected_version, client_token, candidate_id 1463 staging_id, decision, note, reviewer, expected_version, client_token, candidate_id, merge_diff_type
1460 ) 1464 )
1461 _json_response(self, {"record": result}) 1465 _json_response(self, {"record": result})
1462 except ReviewConflictError as exc: 1466 except ReviewConflictError as exc:
......