Commit 527d6149 527d6149e175118d3b213143f82dcc65efa45682 by 沈秋雨

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

1 parent 2ad02073
......@@ -991,6 +991,7 @@
row.review_version = String(record.review_version ?? row.review_version ?? 0);
row.review_claimed_by = record.review_claimed_by ?? '';
row.review_claim_expires_at = record.review_claim_expires_at ?? '';
row.merge_diff_type = record.merge_diff_type ?? '';
row.staging_status = record.staging_status ?? row.staging_status;
row.claimed_by_me = Boolean(record.claimed_by_me) || row.review_claimed_by === state.reviewer;
}
......@@ -1183,7 +1184,7 @@
</div>`;
}
async function updateStagingReview(query, decision, note, candidateId = '') {
async function updateStagingReview(query, decision, note, candidateId = '', mergeDiffType = '') {
return reviewPostJSON('/api/staging-review', {
staging_id: String(query.staging_id),
expected_version: Number(query.review_version || 0),
......@@ -1191,13 +1192,16 @@
reviewer: state.reviewer,
decision,
note,
candidate_id: candidateId
candidate_id: candidateId,
merge_diff_type: mergeDiffType
});
}
const _DB_REVIEW_LABELS = {
approved_import: '确认不重复(待入库)',
rejected_duplicate: '确认重复(待合并作者)',
same_lyric_diff_melody: '同词不同曲(待合并)',
diff_lyric_same_melody: '同曲不同词(待合并)',
unsure: '待确认',
deleted: '已删除',
};
......@@ -1288,6 +1292,10 @@
const candidateComposer = candidate?.candidate_composer;
const reason = candidate?.candidate_reason;
const selectedReview = candidate ? getReview(candidate) : {};
// 从 DB 状态恢复 merge_diff_type 对应的按钮高亮
if (!selectedReview.final_decision && query.merge_diff_type) {
selectedReview.final_decision = query.merge_diff_type;
}
const conflict = candidate ? isConflict(candidate) : false;
const l1 = candidate ? l1Match(candidate) : false;
const queryAudioUrl = query.audio_url || '';
......@@ -1351,9 +1359,11 @@
<span style="flex:1">
<b>${esc(query.review_status === 'approved_import' && query.staging_status === 'imported'
? '确认不重复(已入库)'
: query.review_status === 'rejected_duplicate' && query.staging_status === 'skipped'
? '确认重复(作者已合并)'
: (_DB_REVIEW_LABELS[query.review_status] || query.review_status))}</b>
: query.merge_diff_type
? `${_DB_REVIEW_LABELS[query.merge_diff_type] || query.merge_diff_type}`
: query.review_status === 'rejected_duplicate' && query.staging_status === 'skipped'
? '确认重复(作者已合并)'
: (_DB_REVIEW_LABELS[query.review_status] || query.review_status))}</b>
${query.reviewed_by ? ` · 审核人 ${esc(query.reviewed_by)}` : ''}
${query.reviewed_at ? ` · ${esc(query.reviewed_at)}` : ''}
${query.review_note ? ` · 备注: ${esc(query.review_note)}` : ''}
......@@ -1362,9 +1372,9 @@
</div>
` : needsManualReview ? `
<div class="review-bar">
${['duplicate', 'not_duplicate', 'unsure'].map(value => `
${['duplicate', 'same_lyric_diff_melody', 'diff_lyric_same_melody', 'not_duplicate', 'unsure'].map(value => `
<button class="review-choice ${selectedReview.final_decision === value ? 'active' : ''}" data-review="${value}">
${value === 'duplicate' ? '确认重复' : value === 'not_duplicate' ? '确认不重复' : '待确认'}
${{duplicate: '确认重复', same_lyric_diff_melody: '同词不同曲', diff_lyric_same_melody: '同曲不同词', not_duplicate: '确认不重复', unsure: '待确认'}[value]}
</button>`).join('')}
<input id="reviewNote" value="${esc(selectedReview.note || '')}" placeholder="人工备注">
<button id="deleteReviewBtn" class="secondary" style="color:#c0392b;border-color:#c0392b">删除</button>
......@@ -1402,7 +1412,17 @@
renderDetail();
});
}
const _REVIEW_DECISION_MAP = { duplicate: 'rejected_duplicate', not_duplicate: 'approved_import', unsure: 'unsure' };
const _REVIEW_DECISION_MAP = {
duplicate: 'rejected_duplicate',
same_lyric_diff_melody: 'rejected_duplicate',
diff_lyric_same_melody: 'rejected_duplicate',
not_duplicate: 'approved_import',
unsure: 'unsure'
};
const _MERGE_DIFF_TYPE_MAP = {
same_lyric_diff_melody: 'same_lyric_diff_melody',
diff_lyric_same_melody: 'diff_lyric_same_melody'
};
for (const btn of els.detail.querySelectorAll('.review-choice')) {
btn.addEventListener('click', async () => {
if (!candidate) return;
......@@ -1416,8 +1436,11 @@
renderDetail();
return;
}
const mergeDiffType = _MERGE_DIFF_TYPE_MAP[localDecision] || '';
const payload = await updateStagingReview(
query, dbDecision, note, localDecision === 'duplicate' ? (candidate?.candidate_id || '') : ''
query, dbDecision, note,
(localDecision === 'duplicate' || mergeDiffType) ? (candidate?.candidate_id || '') : '',
mergeDiffType
);
for (const row of state.queryRows) applyReviewSnapshot(row, payload.record);
// 刷新统计和加载列表并行
......
......@@ -324,6 +324,7 @@ def _staging_dashboard_row(row: dict[str, object]) -> dict[str, str]:
"review_claimed_by": str(row.get("review_claimed_by") or ""),
"review_claim_expires_at": str(row.get("review_claim_expires_at") or ""),
"staging_status": str(row.get("staging_status") or ""),
"merge_diff_type": str(row.get("merge_diff_type") or ""),
"l1_metadata_match": "1" if row.get("l1_matched_id") else "0",
"l1_l2_conflict": "0",
"audio_url": str(row.get("audio_url") or ""),
......@@ -582,7 +583,7 @@ def _review_snapshot(cursor, staging_id: int) -> dict[str, object]:
f"""
SELECT staging_id, source_song_id, dedup_action, biz_review_status, biz_review_note,
reviewed_by, reviewed_at, review_version, review_claimed_by,
review_claim_expires_at, staging_status
review_claim_expires_at, staging_status, merge_diff_type
FROM {TARGET_TABLE_NAME_TMP}
WHERE staging_id = %s
""",
......@@ -684,6 +685,7 @@ def _update_staging_review(
expected_version: int,
client_token: str,
candidate_id: str = "",
merge_diff_type: str = "",
) -> dict[str, object]:
"""Persist one claimed review using optimistic concurrency control."""
if decision == "pending":
......@@ -699,7 +701,8 @@ def _update_staging_review(
elif decision == "rejected_duplicate":
status_fields = """
biz_review_status = %s, reviewed_by = %s, reviewed_at = NOW(),
matched_song_id = %s
matched_song_id = %s,
merge_diff_type = NULLIF(%s, '')
"""
else:
status_fields = "biz_review_status = %s, reviewed_by = %s, reviewed_at = NOW()"
......@@ -713,7 +716,7 @@ def _update_staging_review(
candidate_id = candidate_id.strip()
if not candidate_id:
raise ValueError("确认重复前请先选择一条召回候选")
params = [decision, reviewer, candidate_id, note, staging_id, expected_version, client_token, reviewer]
params = [decision, reviewer, candidate_id, merge_diff_type, note, staging_id, expected_version, client_token, reviewer]
else:
params = [decision, reviewer, note, staging_id, expected_version, client_token, reviewer]
with _target_conn() as conn, conn.cursor() as cursor:
......@@ -1447,6 +1450,7 @@ class Handler(BaseHTTPRequestHandler):
decision = str(payload.get("decision", "")).strip()
note = str(payload.get("note", "")).strip()
candidate_id = str(payload.get("candidate_id", "")).strip()
merge_diff_type = str(payload.get("merge_diff_type", "")).strip()
client_token = str(payload.get("client_token") or "").strip()
expected_version = int(payload.get("expected_version", -1))
if not staging_id:
......@@ -1456,7 +1460,7 @@ class Handler(BaseHTTPRequestHandler):
if decision not in ("approved_import", "rejected_duplicate", "unsure", "pending", "deleted"):
raise ValueError(f"invalid decision: {decision}")
result = _update_staging_review(
staging_id, decision, note, reviewer, expected_version, client_token, candidate_id
staging_id, decision, note, reviewer, expected_version, client_token, candidate_id, merge_diff_type
)
_json_response(self, {"record": result})
except ReviewConflictError as exc:
......