Commit 666fd63f 666fd63f37d1b16b94ae2388cddb880b0efdad1e by 沈秋雨

feat(audit): 添加hk_songs重复数据审计及处理脚本

- 新增 audit_hk_songs_duplicates.py,支持基于name、lyricist和composer字段检测重复数据
- 支持生成重复组和重复详情的SQL查询语句
- 实现重复数据的合并计划生成及软删除重复记录功能
- 支持修复暂存表中merge跳过的记录,实现existing_into_new和new_into_existing的合并处理
- 增加命令行参数,支持输出CSV、应用合并和修复合并方向处理

feat(backfill): 增量补全bpm_class字段脚本

- 新增 backfill_bpm_class.py,从源库获取录音BPM并映射为bpm_class分类
- 支持指定目标表和每批处理条数,支持dry-run模式仅打印计划
- 采用主版本优先策略查询录音BPM数据,跳过无效或异常BPM

fix(import): 优化歌词处理和缓存逻辑

- 判断歌词文本是否实质为空,避免无效歌词上传OSS
- 修正合并逻辑中merge_direction对staging状态和导入ID的设置
- 扩展原声正则匹配规则,增加用户创作原声匹配
- 新增构建歌词缓存项和追加写入缓存函数,减少重复下载
- 调整load_existing_l2_candidates增加seen_keys参数避免重复缓存加载
1 parent 3f2cfa61
1 #!/usr/bin/env python3
2 """Audit duplicate rows in hk_songs by name, lyricist, and composer.
3
4 The audit is read-only by default. Pass --fix-merge to process pending
5 merge-direction records from the import staging table.
6 """
7
8 from __future__ import annotations
9
10 import argparse
11 import csv
12 import os
13 import re
14 from pathlib import Path
15 from typing import Any, Iterable
16
17 import pymysql
18 from dotenv import load_dotenv
19 from tqdm import tqdm
20
21
22 load_dotenv()
23
24 TARGET_DB_CONFIG = {
25 "host": os.getenv("TARGET_DB_HOST"),
26 "port": int(os.getenv("TARGET_DB_PORT", 3306)),
27 "user": os.getenv("TARGET_DB_USER"),
28 "password": os.getenv("TARGET_DB_PASSWORD"),
29 "database": os.getenv("TARGET_DB_NAME"),
30 "charset": "utf8mb4",
31 "cursorclass": pymysql.cursors.DictCursor,
32 }
33
34 SOURCE_DB_CONFIG = {
35 "host": os.getenv("SOURCE_DB_HOST"),
36 "port": int(os.getenv("SOURCE_DB_PORT", 3306)),
37 "user": os.getenv("SOURCE_DB_USER"),
38 "password": os.getenv("SOURCE_DB_PASSWORD"),
39 "database": os.getenv("SOURCE_DB_NAME"),
40 "charset": "utf8mb4",
41 "cursorclass": pymysql.cursors.DictCursor,
42 }
43
44 DEFAULT_TARGET_TABLE = os.getenv("TARGET_TABLE_NAME", "hk_songs")
45 DEFAULT_STAGING_TABLE = os.getenv("TARGET_TABLE_NAME_TMP", "hk_songs_import_staging")
46 OUTPUT_DIR = Path(__file__).resolve().parent / "output" / "reports"
47
48 _IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9_]+$")
49
50
51 def quote_identifier(identifier: str) -> str:
52 """Return a backtick-quoted SQL identifier after strict validation."""
53 if not _IDENTIFIER_RE.fullmatch(identifier):
54 raise ValueError(f"Unsafe SQL identifier: {identifier!r}")
55 return f"`{identifier}`"
56
57
58 def _key_expr(column: str, *, case_sensitive: bool = True) -> str:
59 expr = f"COALESCE(NULLIF(TRIM({column}), ''), '')"
60 if case_sensitive:
61 return f"{expr} COLLATE utf8mb4_bin"
62 return expr
63
64
65 def _where_clause(include_deleted: bool) -> str:
66 return "" if include_deleted else "WHERE deleted = '0'"
67
68
69 def _limit_clause(limit: int | None) -> str:
70 if limit is None:
71 return ""
72 if limit <= 0:
73 raise ValueError("--limit must be greater than 0")
74 return f"LIMIT {limit}"
75
76
77 def build_duplicate_group_sql(
78 table_name: str,
79 *,
80 include_deleted: bool = False,
81 limit: int | None = None,
82 case_sensitive: bool = True,
83 ) -> str:
84 """Build SQL that returns duplicate metadata groups."""
85 table = quote_identifier(table_name)
86 where = _where_clause(include_deleted)
87 limit_sql = _limit_clause(limit)
88 return f"""
89 SELECT
90 {_key_expr('name', case_sensitive=case_sensitive)} AS name_key,
91 {_key_expr('lyricist', case_sensitive=case_sensitive)} AS lyricist_key,
92 {_key_expr('composer', case_sensitive=case_sensitive)} AS composer_key,
93 COUNT(*) AS duplicate_count,
94 GROUP_CONCAT(id ORDER BY id SEPARATOR ',') AS song_ids,
95 GROUP_CONCAT(source_song_id ORDER BY id SEPARATOR ',') AS source_song_ids
96 FROM {table}
97 {where}
98 GROUP BY name_key, lyricist_key, composer_key
99 HAVING COUNT(*) > 1
100 ORDER BY duplicate_count DESC, name_key, lyricist_key, composer_key
101 {limit_sql}
102 """.strip()
103
104
105 def build_duplicate_detail_sql(
106 table_name: str,
107 *,
108 include_deleted: bool = False,
109 limit: int | None = None,
110 case_sensitive: bool = True,
111 ) -> str:
112 """Build SQL that returns all rows belonging to duplicate metadata groups."""
113 table = quote_identifier(table_name)
114 where = _where_clause(include_deleted)
115 limit_sql = _limit_clause(limit)
116 key_select = f"""
117 {_key_expr('name', case_sensitive=case_sensitive)} AS name_key,
118 {_key_expr('lyricist', case_sensitive=case_sensitive)} AS lyricist_key,
119 {_key_expr('composer', case_sensitive=case_sensitive)} AS composer_key
120 """.strip()
121 return f"""
122 SELECT
123 s.id,
124 s.name,
125 s.lyricist,
126 s.composer,
127 s.singer,
128 s.source_table_name,
129 s.source_song_id,
130 s.deleted,
131 s.create_time,
132 s.modify_time,
133 dup.duplicate_count
134 FROM (
135 SELECT
136 id, name, lyricist, composer, singer, source_table_name, source_song_id,
137 deleted, create_time, modify_time,
138 {key_select}
139 FROM {table}
140 {where}
141 ) AS s
142 JOIN (
143 SELECT
144 {_key_expr('name', case_sensitive=case_sensitive)} AS name_key,
145 {_key_expr('lyricist', case_sensitive=case_sensitive)} AS lyricist_key,
146 {_key_expr('composer', case_sensitive=case_sensitive)} AS composer_key,
147 COUNT(*) AS duplicate_count
148 FROM {table}
149 {where}
150 GROUP BY name_key, lyricist_key, composer_key
151 HAVING COUNT(*) > 1
152 ) AS dup
153 ON dup.name_key = s.name_key
154 AND dup.lyricist_key = s.lyricist_key
155 AND dup.composer_key = s.composer_key
156 ORDER BY dup.duplicate_count DESC, s.name_key, s.lyricist_key, s.composer_key, s.id
157 {limit_sql}
158 """.strip()
159
160
161 def build_duplicate_detail_with_record_count_sql(
162 table_name: str,
163 staging_table_name: str,
164 *,
165 include_deleted: bool = False,
166 limit: int | None = None,
167 case_sensitive: bool = True,
168 ) -> str:
169 """Build SQL returning duplicate rows with staging record_count attached."""
170 detail_sql = build_duplicate_detail_sql(
171 table_name,
172 include_deleted=include_deleted,
173 limit=limit,
174 case_sensitive=case_sensitive,
175 )
176 staging_table = quote_identifier(staging_table_name)
177 return f"""
178 SELECT
179 d.*,
180 COALESCE(rc.record_count, 0) AS record_count
181 FROM (
182 {detail_sql}
183 ) AS d
184 LEFT JOIN (
185 SELECT source_song_id, MAX(COALESCE(record_count, 0)) AS record_count
186 FROM {staging_table}
187 GROUP BY source_song_id
188 ) AS rc
189 ON rc.source_song_id = d.source_song_id
190 ORDER BY d.duplicate_count DESC, d.name, d.lyricist, d.composer, d.id
191 """.strip()
192
193
194 def _row_group_key(row: dict[str, Any], *, case_sensitive: bool = True) -> tuple[str, str, str]:
195 name = (row.get("name") or "").strip()
196 lyricist = (row.get("lyricist") or "").strip()
197 composer = (row.get("composer") or "").strip()
198 if not case_sensitive:
199 name, lyricist, composer = name.lower(), lyricist.lower(), composer.lower()
200 return (name, lyricist, composer)
201
202
203 def build_merge_plan(rows: Iterable[dict[str, Any]], *, case_sensitive: bool = True) -> list[dict[str, Any]]:
204 """Choose one survivor per duplicate group and return loser merge actions."""
205 grouped: dict[tuple[str, str, str], list[dict[str, Any]]] = {}
206 for row in rows:
207 grouped.setdefault(_row_group_key(row, case_sensitive=case_sensitive), []).append(row)
208
209 plan: list[dict[str, Any]] = []
210 for key, group_rows in grouped.items():
211 if len(group_rows) < 2:
212 continue
213 sorted_rows = sorted(
214 group_rows,
215 key=lambda row: (-(int(row.get("record_count") or 0)), int(row["id"])),
216 )
217 survivor = sorted_rows[0]
218 for loser in sorted_rows[1:]:
219 plan.append(
220 {
221 "name": key[0],
222 "lyricist": key[1],
223 "composer": key[2],
224 "survivor_id": survivor["id"],
225 "survivor_source_song_id": survivor.get("source_song_id"),
226 "survivor_record_count": int(survivor.get("record_count") or 0),
227 "survivor_lyricist": survivor.get("lyricist"),
228 "survivor_composer": survivor.get("composer"),
229 "loser_id": loser["id"],
230 "loser_source_song_id": loser.get("source_song_id"),
231 "loser_record_count": int(loser.get("record_count") or 0),
232 "loser_lyricist": loser.get("lyricist"),
233 "loser_composer": loser.get("composer"),
234 "reason": "record_count smaller; tie keeps lower id",
235 }
236 )
237 return plan
238
239
240 def build_soft_delete_sql(table_name: str, loser_count: int) -> str:
241 """Build a soft-delete SQL statement for planned loser rows."""
242 if loser_count <= 0:
243 raise ValueError("loser_count must be greater than 0")
244 table = quote_identifier(table_name)
245 placeholders = ",".join(["%s"] * loser_count)
246 return f"""
247 UPDATE {table}
248 SET deleted = '1',
249 modify_time = NOW(),
250 off_shelf_remark = 'metadata duplicate merged by audit script'
251 WHERE deleted = '0'
252 AND id IN ({placeholders})
253 """.strip()
254
255
256 def apply_soft_delete_plan(conn, table_name: str, plan: list[dict[str, Any]]) -> int:
257 loser_ids = [item["loser_id"] for item in plan]
258 if not loser_ids:
259 return 0
260 sql = build_soft_delete_sql(table_name, len(loser_ids))
261 with conn.cursor() as cursor:
262 cursor.execute(sql, loser_ids)
263 return cursor.rowcount
264
265
266 def apply_merge_plan(conn, table_name: str, plan: list[dict[str, Any]]) -> dict[str, int]:
267 """软删除 loser 并将其独有作者增量合并到幸存者,在同一事务内完成。"""
268 from import_hk_songs import _merge_author_field
269
270 if not plan:
271 return {"soft_deleted": 0, "author_merged": 0}
272
273 table = quote_identifier(table_name)
274
275 # 按幸存者聚合,把同一幸存者的所有 loser 作者依次合并进来
276 survivors: dict[int, dict[str, Any]] = {}
277 for item in plan:
278 sid = int(item["survivor_id"])
279 if sid not in survivors:
280 survivors[sid] = {
281 "orig_lyricist": item.get("survivor_lyricist") or "",
282 "orig_composer": item.get("survivor_composer") or "",
283 "lyricist": item.get("survivor_lyricist") or "",
284 "composer": item.get("survivor_composer") or "",
285 }
286 s = survivors[sid]
287 result_l = _merge_author_field(s["lyricist"], item.get("loser_lyricist"))
288 if result_l:
289 s["lyricist"] = result_l
290 result_c = _merge_author_field(s["composer"], item.get("loser_composer"))
291 if result_c:
292 s["composer"] = result_c
293
294 with conn.cursor() as cursor:
295 # 1. 批量软删除 loser
296 loser_ids = [int(item["loser_id"]) for item in plan]
297 cursor.execute(build_soft_delete_sql(table_name, len(loser_ids)), loser_ids)
298 soft_deleted = cursor.rowcount
299
300 # 2. 作者增量合并(每个幸存者最多两条 UPDATE)
301 author_merged = 0
302 for sid, s in survivors.items():
303 if s["lyricist"] != s["orig_lyricist"]:
304 cursor.execute(
305 f"UPDATE {table} SET {quote_identifier('lyricist')} = %s WHERE id = %s",
306 (s["lyricist"], sid),
307 )
308 author_merged += 1
309 if s["composer"] != s["orig_composer"]:
310 cursor.execute(
311 f"UPDATE {table} SET {quote_identifier('composer')} = %s WHERE id = %s",
312 (s["composer"], sid),
313 )
314 author_merged += 1
315
316 return {"soft_deleted": soft_deleted, "author_merged": author_merged}
317
318
319 def summarize_duplicate_groups(groups: Iterable[dict[str, Any]]) -> dict[str, int]:
320 rows = list(groups)
321 duplicate_rows = sum(int(row["duplicate_count"]) for row in rows)
322 return {
323 "duplicate_groups": len(rows),
324 "duplicate_rows": duplicate_rows,
325 "extra_duplicate_rows": duplicate_rows - len(rows),
326 }
327
328
329 def fetch_rows(conn, sql: str) -> list[dict[str, Any]]:
330 with conn.cursor() as cursor:
331 cursor.execute(sql)
332 return list(cursor.fetchall())
333
334
335 def write_csv(path: Path, rows: list[dict[str, Any]]) -> None:
336 path.parent.mkdir(parents=True, exist_ok=True)
337 fieldnames = list(rows[0].keys()) if rows else []
338 with path.open("w", encoding="utf-8-sig", newline="") as f:
339 writer = csv.DictWriter(f, fieldnames=fieldnames)
340 writer.writeheader()
341 writer.writerows(rows)
342
343
344 def fix_pending_merge_directions(
345 source_conn,
346 target_conn,
347 staging_table: str,
348 target_table: str,
349 *,
350 dry_run: bool = False,
351 skip_oss: bool = False,
352 ) -> None:
353 """补处理暂存表中 merge+skipped 记录,执行 existing_into_new 的物理合并。
354
355 - existing_into_new(新记录录音多):歌词上传 OSS → INSERT 主表 →
356 软删旧记录 → 作者合并 → 更新暂存状态为 imported
357 - new_into_existing(旧记录录音多):仅补执行作者合并(幂等)
358 """
359 from import_hk_songs import (
360 INSERT_SQL_TEMPLATE,
361 _SOFT_DELETE_SQL,
362 _merge_author_field,
363 execute_author_merges,
364 execute_soft_deletes,
365 get_oss_bucket,
366 load_source_record_counts,
367 process_lyrics,
368 )
369
370 staging_tbl = quote_identifier(staging_table)
371 target_tbl = quote_identifier(target_table)
372
373 # 1. 拉取所有 merge+skipped 暂存记录
374 with target_conn.cursor() as cursor:
375 cursor.execute(
376 f"SELECT * FROM {staging_tbl} WHERE dedup_action='merge' AND staging_status='skipped' ORDER BY staging_id"
377 )
378 staging_rows: list[dict] = list(cursor.fetchall())
379
380 if not staging_rows:
381 print("暂存表中没有待处理的 merge+skipped 记录")
382 return
383
384 print(f"找到 {len(staging_rows)} 条 merge+skipped 暂存记录")
385
386 # 2. 批量拉取 matched_song_id → source_song_id 及现有作者(从目标主表)
387 matched_ids = {int(r["matched_song_id"]) for r in staging_rows if r.get("matched_song_id")}
388 matched_to_source_sid: dict[int, str] = {}
389 matched_to_authors: dict[int, dict[str, str]] = {}
390 if matched_ids:
391 ph = ",".join(["%s"] * len(matched_ids))
392 with target_conn.cursor() as cursor:
393 cursor.execute(
394 f"SELECT id, source_song_id, lyricist, composer FROM {target_tbl} WHERE id IN ({ph})",
395 list(matched_ids),
396 )
397 for row in cursor.fetchall():
398 matched_to_source_sid[row["id"]] = str(row["source_song_id"] or "")
399 matched_to_authors[row["id"]] = {
400 "lyricist": row.get("lyricist") or "",
401 "composer": row.get("composer") or "",
402 }
403
404 # 3. 批量查询源库录音数(用于方向判断)
405 source_sids = set(matched_to_source_sid.values()) - {""}
406 source_record_counts: dict[str, int] = {}
407 if source_sids and source_conn:
408 try:
409 source_record_counts = load_source_record_counts(source_conn, source_sids)
410 except Exception as e:
411 print(f"警告: 查询源库录音数失败: {e},将仅处理 merge_authors,不做 existing_into_new 分流")
412
413 # 4. 初始化 OSS(只有真正需要上传时才初始化)
414 bucket = None
415 if not skip_oss and not dry_run:
416 try:
417 bucket = get_oss_bucket()
418 except Exception as e:
419 print(f"警告: OSS 初始化失败: {e},歌词将保留原始文本(等同 --skip-oss)")
420 skip_oss = True
421
422 insert_sql = INSERT_SQL_TEMPLATE.format(table=target_table)
423 update_staging_sql = (
424 f"UPDATE {staging_tbl} SET staging_status=%s, imported_song_id=%s WHERE staging_id=%s"
425 )
426
427 stats = {"existing_into_new": 0, "author_merged": 0, "soft_deleted": 0, "no_op": 0, "errors": 0}
428
429 for srow in tqdm(staging_rows, desc="修复 merge 记录", unit="条"):
430 staging_id = srow["staging_id"]
431 new_record_id = srow["id"]
432 matched_id_raw = srow.get("matched_song_id")
433 matched_id: int | None = int(matched_id_raw) if matched_id_raw is not None else None
434 new_count = int(srow.get("record_count") or 0)
435 merge_authors_flag = bool(srow.get("merge_authors"))
436
437 # 方向判断
438 existing_count: int | None = None
439 if matched_id and source_record_counts:
440 src_sid = matched_to_source_sid.get(matched_id)
441 if src_sid:
442 existing_count = source_record_counts.get(src_sid)
443 merge_direction = (
444 "existing_into_new"
445 if existing_count is not None and new_count > existing_count
446 else "new_into_existing"
447 )
448
449 try:
450 with target_conn.cursor() as cursor:
451 if merge_direction == "existing_into_new":
452 if dry_run:
453 print(
454 f"[dry-run] existing_into_new staging_id={staging_id} "
455 f"new_id={new_record_id} matched_id={matched_id} "
456 f"new_count={new_count} existing_count={existing_count}"
457 )
458 stats["existing_into_new"] += 1
459 continue
460
461 # 歌词上传 OSS(staging.lyrics_url 在 skip_oss=True 时存的是原始文本)
462 raw_lyrics = srow.get("lyrics_url")
463 if raw_lyrics and str(raw_lyrics).startswith("http"):
464 lyrics_url, lrc_url = raw_lyrics, srow.get("lrc_url")
465 else:
466 lyrics_url, lrc_url = process_lyrics(bucket, raw_lyrics, str(new_record_id), skip_oss)
467
468 # INSERT 主表(45 列,与 INSERT_SQL_TEMPLATE 同构)
469 row_tuple = (
470 new_record_id, srow["name"], srow["lyricist"], srow["composer"],
471 srow["issue_status"], srow["intro"],
472 srow["audio_url"], srow["accompany_url"], lyrics_url, lrc_url,
473 srow["song_time"], srow["song_start"], srow["song_end"],
474 srow["creation_url"], srow["opern_url"], srow["cover_version"], srow["issue_time"],
475 srow["cover_url"], srow["animation_type"], srow["bpm_class"], srow["review_status"],
476 srow["in_status"], srow["song_status"], srow["commit_time"], srow["review_time"],
477 srow["shelf_time"], srow["review_remark"], srow["create_time"], srow["creator"],
478 srow["modify_time"], srow["modifier"], srow["deleted"], srow["cooperate_type"], srow["singer"],
479 srow["off_shelf_remark"], srow["musician_id"], srow["commit_id"], srow["sheet_music"],
480 srow["commit_desc"], srow["price"], srow["source_table_name"], srow["source_song_id"],
481 srow["lyric_archive_element_id"], srow["melody_archive_element_id"], srow["audio_fingerprint"],
482 )
483 cursor.execute(insert_sql, row_tuple)
484 stats["existing_into_new"] += 1
485
486 # 软删旧记录
487 if matched_id:
488 cursor.execute(_SOFT_DELETE_SQL, (matched_id,))
489 stats["soft_deleted"] += 1
490
491 # 作者合并(反向:把旧记录独有作者增量到新记录)
492 if merge_authors_flag and matched_id:
493 old = matched_to_authors.get(matched_id, {})
494 merged_lyricist = _merge_author_field(srow.get("lyricist") or "", old.get("lyricist") or "")
495 merged_composer = _merge_author_field(srow.get("composer") or "", old.get("composer") or "")
496 if merged_lyricist or merged_composer:
497 n = execute_author_merges(cursor, [{"target_id": new_record_id, "lyricist": merged_lyricist, "composer": merged_composer}])
498 if n == 0:
499 raise RuntimeError(f"作者合并失败 target_id={new_record_id}")
500 stats["author_merged"] += 1
501
502 cursor.execute(update_staging_sql, ("imported", new_record_id, staging_id))
503 target_conn.commit()
504
505 else: # new_into_existing:仅补作者合并
506 if not matched_id:
507 stats["no_op"] += 1
508 continue
509 if dry_run:
510 stats["author_merged" if merge_authors_flag else "no_op"] += 1
511 continue
512 if merge_authors_flag:
513 old = matched_to_authors.get(matched_id, {})
514 merged_lyricist = _merge_author_field(old.get("lyricist") or "", srow.get("lyricist") or "")
515 merged_composer = _merge_author_field(old.get("composer") or "", srow.get("composer") or "")
516 if merged_lyricist or merged_composer:
517 n = execute_author_merges(cursor, [{"target_id": matched_id, "lyricist": merged_lyricist, "composer": merged_composer}])
518 if n == 0:
519 raise RuntimeError(f"作者合并失败 target_id={matched_id}")
520 stats["author_merged"] += 1
521 else:
522 stats["no_op"] += 1
523 else:
524 stats["no_op"] += 1
525 cursor.execute(update_staging_sql, ("imported", matched_id, staging_id))
526 target_conn.commit()
527
528 except Exception as e:
529 target_conn.rollback()
530 stats["errors"] += 1
531 print(f"错误: staging_id={staging_id} 处理失败: {e}")
532
533 print(
534 f"修复完成: existing_into_new入库={stats['existing_into_new']} | "
535 f"旧记录软删={stats['soft_deleted']} | 作者合并={stats['author_merged']} | "
536 f"无操作={stats['no_op']} | 错误={stats['errors']}"
537 )
538
539
540 def main() -> int:
541 parser = argparse.ArgumentParser(
542 description="Audit duplicate hk_songs rows by exact name + lyricist + composer."
543 )
544 parser.add_argument("--limit", type=int, default=None, help="Limit duplicate groups/details returned")
545 parser.add_argument("--include-deleted", action="store_true", help="Include deleted rows")
546 parser.add_argument("--details", action="store_true", help="Print duplicate row details instead of group summary rows")
547 parser.add_argument(
548 "--case-insensitive",
549 action="store_true",
550 help="Use the table's default case-insensitive collation instead of exact utf8mb4_bin comparison",
551 )
552 parser.add_argument("--csv", type=Path, help="Optional CSV output path")
553 parser.add_argument("--merge-plan-csv", type=Path, help="Optional CSV output path for merge plan")
554 parser.add_argument("--apply-merge", action="store_true", help="Soft-delete duplicate loser rows")
555 parser.add_argument(
556 "--fix-merge",
557 action="store_true",
558 help="补处理暂存表中 merge+skipped 记录:existing_into_new 入库+软删旧记录,new_into_existing 补作者合并",
559 )
560 parser.add_argument("--skip-oss", action="store_true", help="--fix-merge 时跳过歌词 OSS 上传,保留原始文本")
561 parser.add_argument("--dry-run", action="store_true", help="仅打印计划,不写库(配合 --fix-merge 使用)")
562 args = parser.parse_args()
563
564 # --fix-merge 模式:连双库,执行合并方向后处理
565 if args.fix_merge:
566 target_conn = pymysql.connect(**TARGET_DB_CONFIG)
567 source_conn = None
568 try:
569 try:
570 source_conn = pymysql.connect(**SOURCE_DB_CONFIG)
571 except Exception as e:
572 print(f"警告: 源库连接失败: {e},将跳过录音数查询(只处理 merge_authors)")
573 fix_pending_merge_directions(
574 source_conn, target_conn,
575 staging_table=DEFAULT_STAGING_TABLE,
576 target_table=DEFAULT_TARGET_TABLE,
577 dry_run=args.dry_run,
578 skip_oss=args.skip_oss,
579 )
580 finally:
581 target_conn.close()
582 if source_conn:
583 source_conn.close()
584 return 0
585
586 case_sensitive = not args.case_insensitive
587 group_sql = build_duplicate_group_sql(
588 DEFAULT_TARGET_TABLE,
589 include_deleted=args.include_deleted,
590 limit=args.limit,
591 case_sensitive=case_sensitive,
592 )
593 detail_sql = build_duplicate_detail_sql(
594 DEFAULT_TARGET_TABLE,
595 include_deleted=args.include_deleted,
596 limit=args.limit,
597 case_sensitive=case_sensitive,
598 )
599 detail_with_count_sql = build_duplicate_detail_with_record_count_sql(
600 DEFAULT_TARGET_TABLE,
601 DEFAULT_STAGING_TABLE,
602 include_deleted=args.include_deleted,
603 limit=args.limit,
604 case_sensitive=case_sensitive,
605 )
606
607 conn = pymysql.connect(**TARGET_DB_CONFIG)
608 try:
609 groups = fetch_rows(conn, group_sql)
610 summary = summarize_duplicate_groups(groups)
611 print(
612 f"table={DEFAULT_TARGET_TABLE} duplicate_groups={summary['duplicate_groups']} "
613 f"duplicate_rows={summary['duplicate_rows']} "
614 f"extra_duplicate_rows={summary['extra_duplicate_rows']} "
615 f"case_sensitive={case_sensitive}"
616 )
617
618 rows = fetch_rows(conn, detail_sql) if args.details or args.csv else groups
619 merge_plan: list[dict[str, Any]] = []
620 if args.merge_plan_csv or args.apply_merge:
621 if args.limit is not None and args.apply_merge:
622 print(f"警告: --limit 限制的是明细行数而非重复组数,--apply-merge 可能只处理部分重复组")
623 duplicate_rows_with_counts = fetch_rows(conn, detail_with_count_sql)
624 merge_plan = build_merge_plan(duplicate_rows_with_counts, case_sensitive=case_sensitive)
625 print(f"merge_plan_losers={len(merge_plan)}")
626 for row in rows[:20]:
627 print(row)
628 if len(rows) > 20:
629 print(f"... {len(rows) - 20} more rows")
630
631 if args.csv:
632 output_path = args.csv
633 if not output_path.is_absolute():
634 output_path = OUTPUT_DIR / output_path
635 write_csv(output_path, rows)
636 print(f"csv={output_path}")
637 if args.merge_plan_csv:
638 output_path = args.merge_plan_csv
639 if not output_path.is_absolute():
640 output_path = OUTPUT_DIR / output_path
641 write_csv(output_path, merge_plan)
642 print(f"merge_plan_csv={output_path}")
643 if args.apply_merge:
644 stats = apply_merge_plan(conn, DEFAULT_TARGET_TABLE, merge_plan)
645 conn.commit()
646 print(f"soft_deleted_rows={stats['soft_deleted']} author_merged_fields={stats['author_merged']}")
647 finally:
648 conn.close()
649
650 return 1 if groups else 0
651
652
653 if __name__ == "__main__":
654 raise SystemExit(main())
1 #!/usr/bin/env python3
2 """增量补全 hk_songs_test(或任意目标表)中缺失的 bpm_class 字段。
3
4 从源库 hk_music_record_state.bpm 取主版本录音的 BPM 值,按以下规则映射:
5 bpm = 0 或 NULL → NULL(跳过)
6 bpm < 80 → 1(慢歌)
7 80 ≤ bpm ≤ 120 → 2(中速)
8 bpm > 120 → 3(快歌)
9
10 用法:
11 python backfill_bpm_class.py # 默认更新 hk_songs_test
12 python backfill_bpm_class.py --table hk_songs # 更新其他表
13 python backfill_bpm_class.py --dry-run # 只打印,不写库
14 python backfill_bpm_class.py --batch-size 500 # 每批处理条数(默认200)
15 """
16
17 from __future__ import annotations
18
19 import argparse
20 import logging
21 import os
22
23 import pymysql
24 from dotenv import load_dotenv
25 from tqdm import tqdm
26
27 load_dotenv(os.path.join(os.path.dirname(__file__), ".env"))
28
29 logging.basicConfig(
30 level=logging.INFO,
31 format="%(asctime)s %(levelname)s %(message)s",
32 datefmt="%H:%M:%S",
33 )
34 logger = logging.getLogger(__name__)
35
36 TARGET_DB_CONFIG = {
37 "host": os.getenv("TARGET_DB_HOST"),
38 "port": int(os.getenv("TARGET_DB_PORT", 3306)),
39 "user": os.getenv("TARGET_DB_USER"),
40 "password": os.getenv("TARGET_DB_PASSWORD"),
41 "database": os.getenv("TARGET_DB_NAME"),
42 "charset": "utf8mb4",
43 "cursorclass": pymysql.cursors.DictCursor,
44 }
45
46 SOURCE_DB_CONFIG = {
47 "host": os.getenv("SOURCE_DB_HOST"),
48 "port": int(os.getenv("SOURCE_DB_PORT", 3306)),
49 "user": os.getenv("SOURCE_DB_USER"),
50 "password": os.getenv("SOURCE_DB_PASSWORD"),
51 "database": os.getenv("SOURCE_DB_NAME"),
52 "charset": "utf8mb4",
53 "cursorclass": pymysql.cursors.DictCursor,
54 }
55
56 # 主版本优先:同一首歌取 is_main_version=1 的录音,没有则取 min(record_id)
57 BPM_QUERY = """
58 SELECT sp.id AS song_id, rs.bpm
59 FROM hk_song_platform sp
60 INNER JOIN (
61 SELECT song_id,
62 COALESCE(
63 MIN(CASE WHEN is_main_version = 1 THEN record_id END),
64 MIN(record_id)
65 ) AS record_id
66 FROM hk_song_and_record
67 GROUP BY song_id
68 ) sr ON sr.song_id = sp.id
69 LEFT JOIN hk_music_record r
70 ON r.id = sr.record_id AND r.deleted = 0x00
71 LEFT JOIN hk_music_record_state rs
72 ON rs.record_id = r.id AND rs.deleted = 0x00
73 WHERE sp.id IN ({placeholders})
74 """
75
76
77 def bpm_to_class(bpm_str: str | None) -> int | None:
78 if not bpm_str:
79 return None
80 try:
81 bpm = int(float(bpm_str))
82 except (ValueError, TypeError):
83 return None
84 if bpm <= 0:
85 return None
86 if bpm < 80:
87 return 1
88 if bpm <= 120:
89 return 2
90 return 3
91
92
93 def fetch_bpm_map(src_conn, song_ids: list[str]) -> dict[str, int | None]:
94 """从源库批量查 bpm,返回 {source_song_id: bpm_class}。"""
95 if not song_ids:
96 return {}
97 placeholders = ",".join(["%s"] * len(song_ids))
98 sql = BPM_QUERY.format(placeholders=placeholders)
99 with src_conn.cursor() as cur:
100 cur.execute(sql, song_ids)
101 rows = cur.fetchall()
102 return {str(r["song_id"]): bpm_to_class(r["bpm"]) for r in rows}
103
104
105 def backfill(table: str, batch_size: int, dry_run: bool) -> None:
106 tgt = pymysql.connect(**TARGET_DB_CONFIG)
107 src = pymysql.connect(**SOURCE_DB_CONFIG)
108
109 try:
110 # 查需要补全的记录总数
111 with tgt.cursor() as cur:
112 cur.execute(
113 f"SELECT COUNT(*) AS cnt FROM `{table}` "
114 f"WHERE bpm_class IS NULL AND source_song_id IS NOT NULL AND deleted = '0'"
115 )
116 total = cur.fetchone()["cnt"]
117
118 logger.info(f"待补全 bpm_class 的记录数: {total},表: {table}")
119 if total == 0:
120 logger.info("无需更新,退出。")
121 return
122
123 updated = no_bpm = 0
124 last_id = 0 # 用 id > last_id 游标代替 OFFSET,避免跳过无bpm记录
125
126 with tqdm(total=total, unit="条") as bar:
127 while True:
128 with tgt.cursor() as cur:
129 cur.execute(
130 f"SELECT id, source_song_id FROM `{table}` "
131 f"WHERE bpm_class IS NULL AND source_song_id IS NOT NULL AND deleted = '0' "
132 f"AND id > %s ORDER BY id LIMIT %s",
133 (last_id, batch_size),
134 )
135 rows = cur.fetchall()
136
137 if not rows:
138 break
139
140 last_id = rows[-1]["id"]
141 song_ids = [r["source_song_id"] for r in rows]
142 bpm_map = fetch_bpm_map(src, song_ids)
143
144 for row in rows:
145 sid = row["source_song_id"]
146 bpm_class = bpm_map.get(str(sid))
147
148 if bpm_class is None:
149 no_bpm += 1
150 bar.update(1)
151 continue
152
153 if dry_run:
154 logger.debug(f"[DRY-RUN] id={row['id']} source_song_id={sid} -> bpm_class={bpm_class}")
155 updated += 1
156 bar.update(1)
157 continue
158
159 with tgt.cursor() as cur:
160 cur.execute(
161 f"UPDATE `{table}` SET bpm_class = %s WHERE id = %s",
162 (bpm_class, row["id"]),
163 )
164 tgt.commit()
165 updated += 1
166 bar.update(1)
167
168 prefix = "[DRY-RUN] " if dry_run else ""
169 print(f"\n{'=' * 50}")
170 print(f"{prefix}bpm_class 补全完成")
171 print(f" 成功更新: {updated} 条")
172 print(f" 源库无bpm: {no_bpm} 条(保持 NULL)")
173 print(f" 合计处理: {updated + no_bpm} 条(共 {total} 条待补全)")
174 print(f"{'=' * 50}")
175
176 finally:
177 tgt.close()
178 src.close()
179
180
181 def main() -> None:
182 parser = argparse.ArgumentParser(description="增量补全 bpm_class 字段")
183 parser.add_argument("--table", default="hk_songs_test", help="目标表名(默认 hk_songs_test)")
184 parser.add_argument("--batch-size", type=int, default=200, help="每批处理条数(默认 200)")
185 parser.add_argument("--dry-run", action="store_true", help="只打印计划,不写库")
186 args = parser.parse_args()
187 backfill(args.table, args.batch_size, args.dry_run)
188
189
190 if __name__ == "__main__":
191 main()
...@@ -447,7 +447,7 @@ def process_lyrics(bucket, lyric_text, record_id, skip_oss=False): ...@@ -447,7 +447,7 @@ def process_lyrics(bucket, lyric_text, record_id, skip_oss=False):
447 """处理歌词:始终上传 .txt 到 lyrics_url;如果是 LRC 格式,额外上传 .lrc 到 lrc_url 447 """处理歌词:始终上传 .txt 到 lyrics_url;如果是 LRC 格式,额外上传 .lrc 到 lrc_url
448 返回 (lyrics_url, lrc_url) 448 返回 (lyrics_url, lrc_url)
449 """ 449 """
450 if not lyric_text: 450 if not lyric_text or _is_lyrics_effectively_empty(lyric_text):
451 return None, None 451 return None, None
452 452
453 if skip_oss: 453 if skip_oss:
...@@ -571,8 +571,14 @@ def build_staging_tuple(tuple_row: tuple, action: dict, import_batch_id: str, re ...@@ -571,8 +571,14 @@ def build_staging_tuple(tuple_row: tuple, action: dict, import_batch_id: str, re
571 imported_song_id = tuple_row[0] 571 imported_song_id = tuple_row[0]
572 elif dedup_action == 'merge': 572 elif dedup_action == 'merge':
573 biz_review_status = 'not_required' 573 biz_review_status = 'not_required'
574 staging_status = 'skipped' 574 # existing_into_new:胜出方为新记录,已入主表,标记 imported
575 imported_song_id = None 575 # new_into_existing:胜出方为旧记录,新记录不入主表,标记 skipped
576 if action.get('merge_direction') == 'existing_into_new':
577 staging_status = 'imported'
578 imported_song_id = tuple_row[0]
579 else:
580 staging_status = 'skipped'
581 imported_song_id = None
576 elif dedup_action == 'skip': 582 elif dedup_action == 'skip':
577 biz_review_status = 'not_required' 583 biz_review_status = 'not_required'
578 staging_status = 'skipped' 584 staging_status = 'skipped'
...@@ -620,8 +626,21 @@ def process_row_with_oss(args_tuple): ...@@ -620,8 +626,21 @@ def process_row_with_oss(args_tuple):
620 _T2S = opencc.OpenCC('t2s') 626 _T2S = opencc.OpenCC('t2s')
621 _METADATA_PUNCT = set(' \t\n\r,。!?;:、"“”‘’·…—~!¥()【】《》〈〉「」『』﹏,.:;!?()[]{}<>|/\\_-') 627 _METADATA_PUNCT = set(' \t\n\r,。!?;:、"“”‘’·…—~!¥()【】《》〈〉「」『』﹏,.:;!?()[]{}<>|/\\_-')
622 _INSTRUMENTAL_LYRIC_RE = re.compile(r'(?:纯音乐|純音樂|无歌词|無歌詞|没有歌词|沒有歌詞|instrumental)', re.IGNORECASE) 628 _INSTRUMENTAL_LYRIC_RE = re.compile(r'(?:纯音乐|純音樂|无歌词|無歌詞|没有歌词|沒有歌詞|instrumental)', re.IGNORECASE)
623 _ORIGINAL_SOUND_RE = re.compile(r'^@\S+的原声$') 629 _ORIGINAL_SOUND_RE = re.compile(r'^(@\S+的原声|用户创作的原声)$')
624 _UNKNOWN_AUTHORS = {'不详', '未知', '无', '佚名', 'unknown', 'none', 'n/a', ''} 630 _UNKNOWN_AUTHORS = {'不详', '未知', '无', '佚名', 'unknown', 'none', 'n/a', ''}
631 # 歌词内容虽非空字符串,但实质为空占位符,应视同无歌词处理
632 _EMPTY_LYRIC_PLACEHOLDERS = {'空', '无', '暂无', '無', '暫無', '无内容', '無內容', '无歌词', '無歌詞'}
633
634
635 def _is_lyrics_effectively_empty(text: str | None) -> bool:
636 """判断歌词是否实质为空:完全为空、纯空白,或内容仅为占位符(如"空"、"无"等)。"""
637 if not text:
638 return True
639 stripped = text.strip()
640 if not stripped:
641 return True
642 normalized = _T2S.convert(unicodedata.normalize('NFKC', stripped)).lower()
643 return normalized in _EMPTY_LYRIC_PLACEHOLDERS
625 644
626 645
627 def _normalize_meta(text: str | None) -> str: 646 def _normalize_meta(text: str | None) -> str:
...@@ -722,13 +741,40 @@ def _decode_lyric_content(content: bytes) -> str: ...@@ -722,13 +741,40 @@ def _decode_lyric_content(content: bytes) -> str:
722 return content.decode('utf-8', errors='replace') 741 return content.decode('utf-8', errors='replace')
723 742
724 743
725 def _write_existing_lyrics_cache(cache_path: Path, cache_items: dict[tuple[str, str], dict]) -> None: 744 def _append_existing_lyrics_cache_items(cache_path: Path, cache_items: list[dict]) -> None:
745 if not cache_items:
746 return
726 cache_path.parent.mkdir(parents=True, exist_ok=True) 747 cache_path.parent.mkdir(parents=True, exist_ok=True)
727 tmp_path = cache_path.with_suffix(cache_path.suffix + '.tmp') 748 with cache_path.open('a', encoding='utf-8') as f:
728 with tmp_path.open('w', encoding='utf-8') as f: 749 for item in cache_items:
729 for item in cache_items.values():
730 f.write(json.dumps(item, ensure_ascii=False) + '\n') 750 f.write(json.dumps(item, ensure_ascii=False) + '\n')
731 tmp_path.replace(cache_path) 751
752
753 def build_lyrics_cache_item(tuple_row: tuple, source_row: dict) -> dict | None:
754 url = tuple_row[OSS_LYRIC_INDEX]
755 lyrics = source_row.get('lyrics_txt_content')
756 if not url or not str(url).startswith(('http://', 'https://')):
757 return None
758 if not lyrics or not str(lyrics).strip() or _is_lyrics_effectively_empty(str(lyrics)):
759 return None
760 return {
761 'id': str(tuple_row[0]),
762 'url': str(url),
763 'lyrics': str(lyrics),
764 'name': tuple_row[1],
765 'singer': tuple_row[33],
766 'lyricist': tuple_row[2],
767 'composer': tuple_row[3],
768 }
769
770
771 def flush_pending_lyrics_cache(cache_path: Path, pending_items: list[dict]) -> int:
772 if not pending_items:
773 return 0
774 _append_existing_lyrics_cache_items(cache_path, pending_items)
775 flushed = len(pending_items)
776 pending_items.clear()
777 return flushed
732 778
733 779
734 def _read_existing_lyrics_cache(cache_path: Path) -> dict[tuple[str, str], dict]: 780 def _read_existing_lyrics_cache(cache_path: Path) -> dict[tuple[str, str], dict]:
...@@ -752,61 +798,18 @@ def _read_existing_lyrics_cache(cache_path: Path) -> dict[tuple[str, str], dict] ...@@ -752,61 +798,18 @@ def _read_existing_lyrics_cache(cache_path: Path) -> dict[tuple[str, str], dict]
752 return cached_by_key 798 return cached_by_key
753 799
754 800
755 def sync_inserted_lyrics_cache(
756 cache_path: Path,
757 inserted_rows: list[tuple[tuple, dict]],
758 seen_keys: set[tuple[str, str]] | None = None,
759 ) -> int:
760 """把本次已成功插入目标库的歌词追加同步到 L2 启动缓存。"""
761 if not inserted_rows:
762 return 0
763
764 new_items = []
765 for tuple_row, source_row in inserted_rows:
766 url = tuple_row[OSS_LYRIC_INDEX]
767 lyrics = source_row.get('lyrics_txt_content')
768 if not url or not str(url).startswith(('http://', 'https://')):
769 continue
770 if not lyrics or not str(lyrics).strip():
771 continue
772
773 record_id = str(tuple_row[0])
774 key = (record_id, str(url))
775 if seen_keys is not None and key in seen_keys:
776 continue
777 item = {
778 'id': record_id,
779 'url': str(url),
780 'lyrics': str(lyrics),
781 'name': tuple_row[1],
782 'singer': tuple_row[33],
783 'lyricist': tuple_row[2],
784 'composer': tuple_row[3],
785 }
786 new_items.append(item)
787 if seen_keys is not None:
788 seen_keys.add(key)
789
790 if not new_items:
791 return 0
792
793 cache_path.parent.mkdir(parents=True, exist_ok=True)
794 with cache_path.open('a', encoding='utf-8') as f:
795 for item in new_items:
796 f.write(json.dumps(item, ensure_ascii=False) + '\n')
797 return len(new_items)
798
799
800 def load_existing_l2_candidates( 801 def load_existing_l2_candidates(
801 rows: list[dict], 802 rows: list[dict],
802 cache_path: Path, 803 cache_path: Path,
803 downloader=download_file, 804 downloader=download_file,
805 seen_keys: set[tuple[str, str]] | None = None,
804 ) -> tuple[list[LyricRecord], dict[str, int]]: 806 ) -> tuple[list[LyricRecord], dict[str, int]]:
805 """从目标库 lyrics_url 行构建 L2 候选;本地缓存已下载歌词,避免重启全量拉 OSS。""" 807 """从目标库 lyrics_url 行构建 L2 候选;本地缓存已下载歌词,避免重启全量拉 OSS。"""
806 cached_by_key = _read_existing_lyrics_cache(cache_path) 808 cached_by_key = _read_existing_lyrics_cache(cache_path)
809 local_seen_keys = seen_keys if seen_keys is not None else set()
810 local_seen_keys.update(cached_by_key.keys())
807 811
808 records: list[LyricRecord] = [] 812 records: list[LyricRecord] = []
809 next_cache: dict[tuple[str, str], dict] = {}
810 stats = {'cached': 0, 'downloaded': 0, 'failed': 0} 813 stats = {'cached': 0, 'downloaded': 0, 'failed': 0}
811 814
812 rows_with_url = [ 815 rows_with_url = [
...@@ -818,48 +821,47 @@ def load_existing_l2_candidates( ...@@ -818,48 +821,47 @@ def load_existing_l2_candidates(
818 f"已有缓存={len(cached_by_key)}, 缓存文件={cache_path}" 821 f"已有缓存={len(cached_by_key)}, 缓存文件={cache_path}"
819 ) 822 )
820 823
821 try: 824 for idx, row in enumerate(rows_with_url, start=1):
822 for idx, row in enumerate(rows_with_url, start=1): 825 url = row.get('lyrics_url')
823 url = row.get('lyrics_url') 826
824 827 record_id = str(row['id'])
825 record_id = str(row['id']) 828 key = (record_id, str(url))
826 key = (record_id, str(url)) 829 item = cached_by_key.get(key)
827 item = cached_by_key.get(key) 830 if item:
828 if item: 831 stats['cached'] += 1
829 stats['cached'] += 1 832 else:
830 else: 833 content, _ = downloader(str(url), timeout=10)
831 content, _ = downloader(str(url), timeout=10) 834 if not content:
832 if not content: 835 stats['failed'] += 1
833 stats['failed'] += 1 836 continue
834 continue 837 item = {
835 item = { 838 'id': record_id,
836 'id': record_id, 839 'url': str(url),
837 'url': str(url), 840 'lyrics': _decode_lyric_content(content),
838 'lyrics': _decode_lyric_content(content), 841 'name': row.get('name'),
839 'name': row.get('name'), 842 'singer': row.get('singer'),
840 'singer': row.get('singer'), 843 'lyricist': row.get('lyricist'),
841 'lyricist': row.get('lyricist'), 844 'composer': row.get('composer'),
842 'composer': row.get('composer'), 845 }
843 } 846 stats['downloaded'] += 1
844 stats['downloaded'] += 1 847 if key not in local_seen_keys:
845 848 _append_existing_lyrics_cache_items(cache_path, [item])
846 next_cache[key] = item 849 local_seen_keys.add(key)
847 records.append(LyricRecord( 850
848 record_id=record_id, 851 records.append(LyricRecord(
849 lyrics=item['lyrics'], 852 record_id=record_id,
850 title=row.get('name') or item.get('name'), 853 lyrics=item['lyrics'],
851 artist=row.get('singer') or item.get('singer'), 854 title=row.get('name') or item.get('name'),
852 lyricist=row.get('lyricist') or item.get('lyricist'), 855 artist=row.get('singer') or item.get('singer'),
853 composer=row.get('composer') or item.get('composer'), 856 lyricist=row.get('lyricist') or item.get('lyricist'),
854 )) 857 composer=row.get('composer') or item.get('composer'),
855 858 ))
856 if idx % 50 == 0 or idx == len(rows_with_url): 859
857 logger.info( 860 if idx % 50 == 0 or idx == len(rows_with_url):
858 f"目标库歌词缓存进度: {idx}/{len(rows_with_url)} | " 861 logger.info(
859 f"命中={stats['cached']} 下载={stats['downloaded']} 失败={stats['failed']}" 862 f"目标库歌词缓存进度: {idx}/{len(rows_with_url)} | "
860 ) 863 f"命中={stats['cached']} 下载={stats['downloaded']} 失败={stats['failed']}"
861 finally: 864 )
862 _write_existing_lyrics_cache(cache_path, next_cache)
863 865
864 return records, stats 866 return records, stats
865 867
...@@ -881,7 +883,7 @@ def format_dedup_stats(stats: dict) -> str: ...@@ -881,7 +883,7 @@ def format_dedup_stats(stats: dict) -> str:
881 f"L1命中={stats['l1_dup']} | " 883 f"L1命中={stats['l1_dup']} | "
882 f"L2合并命中={stats['l2_dup']} | L2待审={stats['l2_review']} | " 884 f"L2合并命中={stats['l2_dup']} | L2待审={stats['l2_review']} | "
883 f"L2新词={stats['l2_new']} | 作者合并={stats['author_merged']} | " 885 f"L2新词={stats['l2_new']} | 作者合并={stats['author_merged']} | "
884 f"最终跳过={stats['skipped']}" 886 f"胜出软删={stats['soft_deleted']} | 最终跳过={stats['skipped']}"
885 ) 887 )
886 888
887 889
...@@ -961,7 +963,7 @@ def check_l2( ...@@ -961,7 +963,7 @@ def check_l2(
961 recalled_candidates: top-K 召回候选信息列表,即使最终判定为 new 也返回。 963 recalled_candidates: top-K 召回候选信息列表,即使最终判定为 new 也返回。
962 """ 964 """
963 lyrics_text = row.get('lyrics_txt_content') 965 lyrics_text = row.get('lyrics_txt_content')
964 if not lyrics_text or not lyrics_text.strip(): 966 if _is_lyrics_effectively_empty(lyrics_text):
965 return 'new', 1.0, None, '无歌词内容,跳过 L2', [] 967 return 'new', 1.0, None, '无歌词内容,跳过 L2', []
966 if is_instrumental_lyrics(lyrics_text): 968 if is_instrumental_lyrics(lyrics_text):
967 return 'new', 1.0, None, '歌词内容包含纯音乐/无歌词提示,跳过 L2', [] 969 return 'new', 1.0, None, '歌词内容包含纯音乐/无歌词提示,跳过 L2', []
...@@ -1036,6 +1038,8 @@ def _writers_differ(row: dict, candidate: LyricRecord | None) -> bool: ...@@ -1036,6 +1038,8 @@ def _writers_differ(row: dict, candidate: LyricRecord | None) -> bool:
1036 1038
1037 _AUTHOR_SEP_RE = re.compile(r'[,,/;;、]') 1039 _AUTHOR_SEP_RE = re.compile(r'[,,/;;、]')
1038 _AUTHOR_MERGE_SQL = 'UPDATE `{table}` SET {{col}} = %s WHERE id = %s'.format(table=TARGET_TABLE_NAME) 1040 _AUTHOR_MERGE_SQL = 'UPDATE `{table}` SET {{col}} = %s WHERE id = %s'.format(table=TARGET_TABLE_NAME)
1041 # 软删(败者):deleted='1'。主表 modify_time 由 ON UPDATE CURRENT_TIMESTAMP 自动维护。
1042 _SOFT_DELETE_SQL = "UPDATE `{table}` SET `deleted` = '1' WHERE id = %s".format(table=TARGET_TABLE_NAME)
1039 1043
1040 1044
1041 def _merge_author_field(existing: str | None, new: str | None) -> str: 1045 def _merge_author_field(existing: str | None, new: str | None) -> str:
...@@ -1063,28 +1067,50 @@ def _merge_author_field(existing: str | None, new: str | None) -> str: ...@@ -1063,28 +1067,50 @@ def _merge_author_field(existing: str | None, new: str | None) -> str:
1063 1067
1064 1068
1065 def execute_author_merges(cursor, author_merge_tasks: list[dict]) -> int: 1069 def execute_author_merges(cursor, author_merge_tasks: list[dict]) -> int:
1066 """执行作者字段增量合并 UPDATE,返回成功合并的记录数。""" 1070 """执行作者字段增量合并 UPDATE,返回成功合并的记录数。
1071
1072 每个 task 需含 target_id(UPDATE 目标主键)。matched_id 字段向后兼容:
1073 优先用 target_id,缺省回退 matched_id(即 new_into_existing 时旧记录作为目标)。
1074 """
1067 merged_count = 0 1075 merged_count = 0
1068 for task in author_merge_tasks: 1076 for task in author_merge_tasks:
1077 target_id = task.get('target_id') or task.get('matched_id')
1078 if target_id is None:
1079 continue
1069 try: 1080 try:
1070 if task['lyricist']: 1081 if task['lyricist']:
1071 cursor.execute( 1082 cursor.execute(
1072 _AUTHOR_MERGE_SQL.format(col='lyricist'), 1083 _AUTHOR_MERGE_SQL.format(col='lyricist'),
1073 (task['lyricist'], task['matched_id']), 1084 (task['lyricist'], target_id),
1074 ) 1085 )
1075 if task['composer']: 1086 if task['composer']:
1076 cursor.execute( 1087 cursor.execute(
1077 _AUTHOR_MERGE_SQL.format(col='composer'), 1088 _AUTHOR_MERGE_SQL.format(col='composer'),
1078 (task['composer'], task['matched_id']), 1089 (task['composer'], target_id),
1079 ) 1090 )
1080 merged_count += 1 1091 merged_count += 1
1081 except Exception as e: 1092 except Exception as e:
1082 logger.error( 1093 logger.error(
1083 f"作者字段合并失败 matched_id={task['matched_id']}: {e}" 1094 f"作者字段合并失败 target_id={target_id}: {e}"
1084 ) 1095 )
1085 return merged_count 1096 return merged_count
1086 1097
1087 1098
1099 def execute_soft_deletes(cursor, soft_delete_ids: list[int]) -> int:
1100 """软删败者记录:UPDATE 主表 SET deleted='1' WHERE id IN (...)。返回软删条数。"""
1101 if not soft_delete_ids:
1102 return 0
1103 deleted_count = 0
1104 # 去重,避免同批次多个新记录指向同一败者时重复 UPDATE
1105 for sid in set(soft_delete_ids):
1106 try:
1107 cursor.execute(_SOFT_DELETE_SQL, (sid,))
1108 deleted_count += 1
1109 except Exception as e:
1110 logger.error(f"软删失败 id={sid}: {e}")
1111 return deleted_count
1112
1113
1088 def _is_short_exact_l2_review(decision: str, reason: str) -> bool: 1114 def _is_short_exact_l2_review(decision: str, reason: str) -> bool:
1089 return decision == 'review' and '规范化后的原文哈希一致' in reason and '有效歌词过短' in reason 1115 return decision == 'review' and '规范化后的原文哈希一致' in reason and '有效歌词过短' in reason
1090 1116
...@@ -1104,7 +1130,7 @@ def classify_dedup_action( ...@@ -1104,7 +1130,7 @@ def classify_dedup_action(
1104 """ 1130 """
1105 # 规则1:歌词为空 + 词曲作者不详 → 不导 1131 # 规则1:歌词为空 + 词曲作者不详 → 不导
1106 lyrics_text = row.get('lyrics_txt_content') or '' 1132 lyrics_text = row.get('lyrics_txt_content') or ''
1107 no_lyrics = not lyrics_text or not lyrics_text.strip() 1133 no_lyrics = _is_lyrics_effectively_empty(lyrics_text)
1108 lyricist_raw = (row.get('lyricist') or '').strip() 1134 lyricist_raw = (row.get('lyricist') or '').strip()
1109 composer_raw = (row.get('composer') or '').strip() 1135 composer_raw = (row.get('composer') or '').strip()
1110 if no_lyrics and lyricist_raw.lower() in _UNKNOWN_AUTHORS and composer_raw.lower() in _UNKNOWN_AUTHORS: 1136 if no_lyrics and lyricist_raw.lower() in _UNKNOWN_AUTHORS and composer_raw.lower() in _UNKNOWN_AUTHORS:
...@@ -1229,7 +1255,8 @@ def classify_dedup_action( ...@@ -1229,7 +1255,8 @@ def classify_dedup_action(
1229 pass 1255 pass
1230 1256
1231 # 规则3:歌词相似但歌名与词曲作者均不一致 → 洗盗蹭嫌疑,强制 review 1257 # 规则3:歌词相似但歌名与词曲作者均不一致 → 洗盗蹭嫌疑,强制 review
1232 if matched_candidate: 1258 exact_primary_hash_match = reason.startswith('规范化后的原文歌词哈希完全一致')
1259 if matched_candidate and not exact_primary_hash_match:
1233 _name_match = _normalize_meta(row.get('name')) == _normalize_meta(matched_candidate.title) 1260 _name_match = _normalize_meta(row.get('name')) == _normalize_meta(matched_candidate.title)
1234 _authors_match = not _writers_differ(row, matched_candidate) 1261 _authors_match = not _writers_differ(row, matched_candidate)
1235 if not _name_match and not _authors_match: 1262 if not _name_match and not _authors_match:
...@@ -1250,8 +1277,8 @@ def classify_dedup_action( ...@@ -1250,8 +1277,8 @@ def classify_dedup_action(
1250 } 1277 }
1251 1278
1252 # 歌词质量兜底:现有记录无歌词但新记录有歌词 → 强制人工复核 1279 # 歌词质量兜底:现有记录无歌词但新记录有歌词 → 强制人工复核
1253 existing_has_lyrics = matched_candidate and bool(matched_candidate.lyrics and matched_candidate.lyrics.strip()) 1280 existing_has_lyrics = matched_candidate and bool(matched_candidate.lyrics and matched_candidate.lyrics.strip()) and not _is_lyrics_effectively_empty(matched_candidate.lyrics)
1254 new_has_lyrics = bool(lyrics_text and lyrics_text.strip()) 1281 new_has_lyrics = bool(lyrics_text and lyrics_text.strip()) and not _is_lyrics_effectively_empty(lyrics_text)
1255 if not existing_has_lyrics and new_has_lyrics: 1282 if not existing_has_lyrics and new_has_lyrics:
1256 return { 1283 return {
1257 'action': 'review', 1284 'action': 'review',
...@@ -1291,7 +1318,7 @@ def classify_dedup_action( ...@@ -1291,7 +1318,7 @@ def classify_dedup_action(
1291 } 1318 }
1292 1319
1293 # 空歌词 / 歌词获取失败:L2 无法比对,必须人工复核,不能自动入库 1320 # 空歌词 / 歌词获取失败:L2 无法比对,必须人工复核,不能自动入库
1294 no_lyrics = not lyrics_text or not lyrics_text.strip() 1321 no_lyrics = _is_lyrics_effectively_empty(lyrics_text)
1295 if no_lyrics and not is_instrumental_lyrics(lyrics_text): 1322 if no_lyrics and not is_instrumental_lyrics(lyrics_text):
1296 return { 1323 return {
1297 'action': 'review', 1324 'action': 'review',
...@@ -1673,8 +1700,11 @@ def main(): ...@@ -1673,8 +1700,11 @@ def main():
1673 with target_conn.cursor(pymysql.cursors.DictCursor) as cursor: 1700 with target_conn.cursor(pymysql.cursors.DictCursor) as cursor:
1674 cursor.execute(lyric_sql) 1701 cursor.execute(lyric_sql)
1675 existing_lyric_rows = cursor.fetchall() 1702 existing_lyric_rows = cursor.fetchall()
1676 existing_records, cache_stats = load_existing_l2_candidates(existing_lyric_rows, lyrics_cache_path) 1703 existing_records, cache_stats = load_existing_l2_candidates(
1677 lyrics_cache_seen_keys = set(_read_existing_lyrics_cache(lyrics_cache_path).keys()) 1704 existing_lyric_rows,
1705 lyrics_cache_path,
1706 seen_keys=lyrics_cache_seen_keys,
1707 )
1678 for record in existing_records: 1708 for record in existing_records:
1679 l2_candidates.add(record) 1709 l2_candidates.add(record)
1680 logger.info( 1710 logger.info(
...@@ -1719,7 +1749,7 @@ def main(): ...@@ -1719,7 +1749,7 @@ def main():
1719 1749
1720 if action['action'] == 'new': 1750 if action['action'] == 'new':
1721 lyrics_text = row.get('lyrics_txt_content') 1751 lyrics_text = row.get('lyrics_txt_content')
1722 if lyrics_text and lyrics_text.strip(): 1752 if lyrics_text and lyrics_text.strip() and not _is_lyrics_effectively_empty(lyrics_text):
1723 l2_candidates.add(LyricRecord( 1753 l2_candidates.add(LyricRecord(
1724 record_id=str(record_id), 1754 record_id=str(record_id),
1725 lyrics=lyrics_text, 1755 lyrics=lyrics_text,
...@@ -1751,7 +1781,7 @@ def main(): ...@@ -1751,7 +1781,7 @@ def main():
1751 stats = { 1781 stats = {
1752 'inserted': 0, 'errors': 0, 1782 'inserted': 0, 'errors': 0,
1753 'l1_dup': 0, 'l2_dup': 0, 'l2_review': 0, 'l2_new': 0, 1783 'l1_dup': 0, 'l2_dup': 0, 'l2_review': 0, 'l2_new': 0,
1754 'author_merged': 0, 'skipped': 0, 1784 'author_merged': 0, 'soft_deleted': 0, 'skipped': 0,
1755 } 1785 }
1756 start_time = time.time() 1786 start_time = time.time()
1757 1787
...@@ -1765,6 +1795,7 @@ def main(): ...@@ -1765,6 +1795,7 @@ def main():
1765 # ===== 后台 DB 写入线程:与下一批次的 OSS 处理并行 ===== 1795 # ===== 后台 DB 写入线程:与下一批次的 OSS 处理并行 =====
1766 db_write_queue: queue.Queue = queue.Queue(maxsize=2) 1796 db_write_queue: queue.Queue = queue.Queue(maxsize=2)
1767 db_write_errors: list[str] = [] 1797 db_write_errors: list[str] = []
1798 pending_cache_items: list[dict] = []
1768 1799
1769 def _background_db_writer(): 1800 def _background_db_writer():
1770 """后台线程:顺序写入 DB,与主线程 OSS 处理并行。""" 1801 """后台线程:顺序写入 DB,与主线程 OSS 处理并行。"""
...@@ -1773,7 +1804,7 @@ def main(): ...@@ -1773,7 +1804,7 @@ def main():
1773 if item is None: 1804 if item is None:
1774 db_write_queue.task_done() 1805 db_write_queue.task_done()
1775 break 1806 break
1776 batch_data, staging_data, batch_cache_rows, batch_size_actual, batch_start, author_merge_tasks = item 1807 batch_data, staging_data, batch_size_actual, batch_start, author_merge_tasks, soft_delete_ids = item
1777 try: 1808 try:
1778 with target_conn.cursor() as cursor: 1809 with target_conn.cursor() as cursor:
1779 if batch_data: 1810 if batch_data:
...@@ -1784,17 +1815,14 @@ def main(): ...@@ -1784,17 +1815,14 @@ def main():
1784 merged_cnt = execute_author_merges(cursor, author_merge_tasks) 1815 merged_cnt = execute_author_merges(cursor, author_merge_tasks)
1785 with stats_lock: 1816 with stats_lock:
1786 stats['author_merged'] += merged_cnt 1817 stats['author_merged'] += merged_cnt
1818 if soft_delete_ids:
1819 del_cnt = execute_soft_deletes(cursor, soft_delete_ids)
1820 with stats_lock:
1821 stats['soft_deleted'] += del_cnt
1787 target_conn.commit() 1822 target_conn.commit()
1788 batch_inserted = len(batch_data) 1823 batch_inserted = len(batch_data)
1789 with stats_lock: 1824 with stats_lock:
1790 stats['inserted'] += batch_inserted 1825 stats['inserted'] += batch_inserted
1791 cache_added = sync_inserted_lyrics_cache(
1792 lyrics_cache_path,
1793 batch_cache_rows,
1794 lyrics_cache_seen_keys,
1795 )
1796 if cache_added:
1797 logger.info(f"目标库歌词缓存同步新增: {cache_added} 条")
1798 except Exception as e: 1826 except Exception as e:
1799 logger.error(f"批量写入失败 (offset {batch_start}): {e}") 1827 logger.error(f"批量写入失败 (offset {batch_start}): {e}")
1800 target_conn.rollback() 1828 target_conn.rollback()
...@@ -1807,17 +1835,11 @@ def main(): ...@@ -1807,17 +1835,11 @@ def main():
1807 target_conn.commit() 1835 target_conn.commit()
1808 with stats_lock: 1836 with stats_lock:
1809 stats['inserted'] += 1 1837 stats['inserted'] += 1
1810 if row_i < len(batch_cache_rows):
1811 sync_inserted_lyrics_cache(
1812 lyrics_cache_path,
1813 [batch_cache_rows[row_i]],
1814 lyrics_cache_seen_keys,
1815 )
1816 except Exception as e2: 1838 except Exception as e2:
1817 with stats_lock: 1839 with stats_lock:
1818 stats['errors'] += 1 1840 stats['errors'] += 1
1819 logger.error(f"单条写入失败 (batch offset {batch_start + row_i}): {e2}") 1841 logger.error(f"单条写入失败 (batch offset {batch_start + row_i}): {e2}")
1820 # 降级模式下也尝试执行作者合并 1842 # 降级模式下也尝试执行作者合并和软删
1821 if author_merge_tasks: 1843 if author_merge_tasks:
1822 try: 1844 try:
1823 with target_conn.cursor() as cursor: 1845 with target_conn.cursor() as cursor:
...@@ -1827,6 +1849,15 @@ def main(): ...@@ -1827,6 +1849,15 @@ def main():
1827 stats['author_merged'] += merged_cnt 1849 stats['author_merged'] += merged_cnt
1828 except Exception as e_merge: 1850 except Exception as e_merge:
1829 logger.error(f"降级作者合并失败 (offset {batch_start}): {e_merge}") 1851 logger.error(f"降级作者合并失败 (offset {batch_start}): {e_merge}")
1852 if soft_delete_ids:
1853 try:
1854 with target_conn.cursor() as cursor:
1855 del_cnt = execute_soft_deletes(cursor, soft_delete_ids)
1856 target_conn.commit()
1857 with stats_lock:
1858 stats['soft_deleted'] += del_cnt
1859 except Exception as e_del:
1860 logger.error(f"降级软删失败 (offset {batch_start}): {e_del}")
1830 pbar_db.update(batch_size_actual) 1861 pbar_db.update(batch_size_actual)
1831 with stats_lock: 1862 with stats_lock:
1832 pbar_db.set_postfix( 1863 pbar_db.set_postfix(
...@@ -1836,6 +1867,7 @@ def main(): ...@@ -1836,6 +1867,7 @@ def main():
1836 l2=stats['l2_dup'], 1867 l2=stats['l2_dup'],
1837 rev=stats['l2_review'], 1868 rev=stats['l2_review'],
1838 auth=stats['author_merged'], 1869 auth=stats['author_merged'],
1870 softdel=stats['soft_deleted'],
1839 skip=stats['skipped'], 1871 skip=stats['skipped'],
1840 refresh=False 1872 refresh=False
1841 ) 1873 )
...@@ -1853,6 +1885,7 @@ def main(): ...@@ -1853,6 +1885,7 @@ def main():
1853 new_rows: list = [] 1885 new_rows: list = []
1854 row_actions: dict = {} 1886 row_actions: dict = {}
1855 author_merge_tasks: list[dict] = [] 1887 author_merge_tasks: list[dict] = []
1888 soft_delete_ids: list[int] = []
1856 1889
1857 if not args.skip_dedup and not args.review_result_csv: 1890 if not args.skip_dedup and not args.review_result_csv:
1858 for row in batch_rows: 1891 for row in batch_rows:
...@@ -1874,35 +1907,52 @@ def main(): ...@@ -1874,35 +1907,52 @@ def main():
1874 if action['action'] == 'merge': 1907 if action['action'] == 'merge':
1875 with stats_lock: 1908 with stats_lock:
1876 stats['l2_dup'] += 1 1909 stats['l2_dup'] += 1
1910 merge_dir = action.get('merge_direction') or 'new_into_existing'
1877 merge_note = ';作者字段需增量合并' if action.get('merge_authors') else '' 1911 merge_note = ';作者字段需增量合并' if action.get('merge_authors') else ''
1912 dir_note = f';方向={merge_dir}'
1878 dedup_report.write(record_id, record_name, 'L2', 'merge', 1913 dedup_report.write(record_id, record_name, 'L2', 'merge',
1879 confidence=f'{confidence:.4f}', 1914 confidence=f'{confidence:.4f}',
1880 matched_id=matched_action_id, reason=f'{reason}{merge_note}') 1915 matched_id=matched_action_id, reason=f'{reason}{merge_note}{dir_note}')
1881 if review_report: 1916 if review_report:
1882 review_report.write(row, action) 1917 review_report.write(row, action)
1883 1918
1884 # 收集作者增量合并任务 1919 if merge_dir == 'existing_into_new':
1885 if action.get('merge_authors') and action.get('matched_id'): 1920 # 新记录录音更多,胜出方为新记录:进 new_rows 走 OSS+INSERT
1886 matched_cand = action.get('matched_candidate') 1921 new_rows.append(row)
1887 merged_lyricist = _merge_author_field( 1922 lyrics_text = row.get('lyrics_txt_content')
1888 getattr(matched_cand, 'lyricist', None) if matched_cand else None, 1923 if lyrics_text and lyrics_text.strip() and not _is_lyrics_effectively_empty(lyrics_text):
1889 row.get('lyricist'), 1924 l2_candidates.add(LyricRecord(
1890 ) 1925 record_id=str(record_id),
1891 merged_composer = _merge_author_field( 1926 lyrics=lyrics_text,
1892 getattr(matched_cand, 'composer', None) if matched_cand else None, 1927 title=record_name,
1893 row.get('composer'), 1928 artist=row.get('singer'),
1894 ) 1929 lyricist=row.get('lyricist'),
1895 if merged_lyricist or merged_composer: 1930 composer=row.get('composer'),
1896 author_merge_tasks.append({ 1931 ))
1897 'matched_id': int(action['matched_id']), 1932 # 反向作者增量任务和软删 id 在步骤3拿到新记录 id 后收集
1898 'lyricist': merged_lyricist, 1933 else:
1899 'composer': merged_composer, 1934 # new_into_existing:新记录不入主表,作者增量到旧记录
1900 'source_id': record_id, 1935 if action.get('merge_authors') and action.get('matched_id'):
1901 }) 1936 matched_cand = action.get('matched_candidate')
1902 logger.debug( 1937 merged_lyricist = _merge_author_field(
1903 f"作者合并任务: matched_id={action['matched_id']}, " 1938 getattr(matched_cand, 'lyricist', None) if matched_cand else None,
1904 f"lyricist→{merged_lyricist!r}, composer→{merged_composer!r}" 1939 row.get('lyricist'),
1905 ) 1940 )
1941 merged_composer = _merge_author_field(
1942 getattr(matched_cand, 'composer', None) if matched_cand else None,
1943 row.get('composer'),
1944 )
1945 if merged_lyricist or merged_composer:
1946 author_merge_tasks.append({
1947 'target_id': int(action['matched_id']),
1948 'lyricist': merged_lyricist,
1949 'composer': merged_composer,
1950 'source_id': record_id,
1951 })
1952 logger.debug(
1953 f"作者合并任务(new→existing): target_id={action['matched_id']}, "
1954 f"lyricist→{merged_lyricist!r}, composer→{merged_composer!r}"
1955 )
1906 1956
1907 elif action['action'] == 'review': 1957 elif action['action'] == 'review':
1908 with stats_lock: 1958 with stats_lock:
...@@ -1932,7 +1982,7 @@ def main(): ...@@ -1932,7 +1982,7 @@ def main():
1932 new_rows.append(row) 1982 new_rows.append(row)
1933 1983
1934 lyrics_text = row.get('lyrics_txt_content') 1984 lyrics_text = row.get('lyrics_txt_content')
1935 if lyrics_text and lyrics_text.strip(): 1985 if lyrics_text and lyrics_text.strip() and not _is_lyrics_effectively_empty(lyrics_text):
1936 l2_candidates.add(LyricRecord( 1986 l2_candidates.add(LyricRecord(
1937 record_id=str(record_id), 1987 record_id=str(record_id),
1938 lyrics=lyrics_text, 1988 lyrics=lyrics_text,
...@@ -1965,11 +2015,21 @@ def main(): ...@@ -1965,11 +2015,21 @@ def main():
1965 2015
1966 # 按顺序组装 batch_data 2016 # 按顺序组装 batch_data
1967 batch_data = [results_map[i] for i in range(len(new_rows)) if i in results_map] 2017 batch_data = [results_map[i] for i in range(len(new_rows)) if i in results_map]
1968 batch_cache_rows = [ 2018 cache_items = []
1969 (results_map[i], new_rows[i]) 2019 for i in range(len(new_rows)):
1970 for i in range(len(new_rows)) 2020 if i not in results_map:
1971 if i in results_map 2021 continue
1972 ] 2022 item = build_lyrics_cache_item(results_map[i], new_rows[i])
2023 if not item:
2024 continue
2025 key = (item['id'], item['url'])
2026 if key in lyrics_cache_seen_keys:
2027 continue
2028 lyrics_cache_seen_keys.add(key)
2029 cache_items.append(item)
2030 if cache_items:
2031 pending_cache_items.extend(cache_items)
2032 logger.debug(f"目标库歌词缓存待追加: +{len(cache_items)} 条,累计待写={len(pending_cache_items)} 条")
1973 2033
1974 # ===== 步骤3:构建 staging tuples ===== 2034 # ===== 步骤3:构建 staging tuples =====
1975 if not args.skip_dedup and not args.review_result_csv: 2035 if not args.skip_dedup and not args.review_result_csv:
...@@ -1984,9 +2044,42 @@ def main(): ...@@ -1984,9 +2044,42 @@ def main():
1984 staging_data.append(build_staging_tuple(results_map[i], action, import_batch_id, 2044 staging_data.append(build_staging_tuple(results_map[i], action, import_batch_id,
1985 record_count=row.get('record_count'))) 2045 record_count=row.get('record_count')))
1986 2046
2047 # existing_into_new:新记录已入主表,收集反向作者增量 + 旧记录软删
2048 if action.get('action') == 'merge' and action.get('merge_direction') == 'existing_into_new':
2049 new_record_id = results_map[i][0]
2050 matched_cand = action.get('matched_candidate')
2051 if action.get('merge_authors') and action.get('matched_id'):
2052 # 方向反转:把旧记录独有作者增量到新记录
2053 merged_lyricist = _merge_author_field(
2054 row.get('lyricist'),
2055 getattr(matched_cand, 'lyricist', None) if matched_cand else None,
2056 )
2057 merged_composer = _merge_author_field(
2058 row.get('composer'),
2059 getattr(matched_cand, 'composer', None) if matched_cand else None,
2060 )
2061 if merged_lyricist or merged_composer:
2062 author_merge_tasks.append({
2063 'target_id': new_record_id,
2064 'lyricist': merged_lyricist,
2065 'composer': merged_composer,
2066 'source_id': row['source_id'],
2067 })
2068 logger.debug(
2069 f"作者合并任务(existing→new): target_id={new_record_id}, "
2070 f"lyricist→{merged_lyricist!r}, composer→{merged_composer!r}"
2071 )
2072 try:
2073 soft_delete_ids.append(int(action['matched_id']))
2074 except (TypeError, ValueError):
2075 logger.warning(f"existing_into_new 软删:matched_id 无效 source_id={row['source_id']}")
2076
1987 for row in batch_rows: 2077 for row in batch_rows:
1988 action = row_actions.get(row['source_id']) 2078 action = row_actions.get(row['source_id'])
1989 if action and action['action'] in ('merge', 'review', 'skip'): 2079 if action and action['action'] in ('merge', 'review', 'skip'):
2080 # existing_into_new merge 已在 new_rows 循环里建了 staging,跳过避免重复
2081 if action['action'] == 'merge' and action.get('merge_direction') == 'existing_into_new':
2082 continue
1990 tuple_row = build_row_tuple(row, None, skip_oss=True) 2083 tuple_row = build_row_tuple(row, None, skip_oss=True)
1991 staging_data.append(build_staging_tuple(tuple_row, action, import_batch_id, 2084 staging_data.append(build_staging_tuple(tuple_row, action, import_batch_id,
1992 record_count=row.get('record_count'))) 2085 record_count=row.get('record_count')))
...@@ -1998,10 +2091,13 @@ def main(): ...@@ -1998,10 +2091,13 @@ def main():
1998 # ===== 步骤4:交给后台线程写入 DB(主线程继续下一批次 OSS)===== 2091 # ===== 步骤4:交给后台线程写入 DB(主线程继续下一批次 OSS)=====
1999 if not args.skip_dedup: 2092 if not args.skip_dedup:
2000 mark_rows_in_l1_index(batch_rows, l1_index) 2093 mark_rows_in_l1_index(batch_rows, l1_index)
2001 db_write_queue.put((batch_data, staging_data, batch_cache_rows, batch_size_actual, batch_start, author_merge_tasks)) 2094 db_write_queue.put((batch_data, staging_data, batch_size_actual, batch_start, author_merge_tasks, soft_delete_ids))
2002 2095
2003 # 所有批次已入队;等待后台 DB 写入收尾后停止线程 2096 # 所有批次已入队;等待后台 DB 写入收尾后停止线程
2004 stop_db_writer(db_write_queue, db_writer_thread) 2097 stop_db_writer(db_write_queue, db_writer_thread)
2098 cache_flushed = flush_pending_lyrics_cache(lyrics_cache_path, pending_cache_items)
2099 if cache_flushed:
2100 logger.info(f"目标库歌词缓存追加写入完成: {cache_flushed} 条")
2005 2101
2006 pbar_oss.close() 2102 pbar_oss.close()
2007 pbar_db.close() 2103 pbar_db.close()
...@@ -2025,11 +2121,15 @@ def main(): ...@@ -2025,11 +2121,15 @@ def main():
2025 logger.warning("收到中断信号,正在等待已入队 DB 写入完成...") 2121 logger.warning("收到中断信号,正在等待已入队 DB 写入完成...")
2026 if 'db_write_queue' in locals() and 'db_writer_thread' in locals(): 2122 if 'db_write_queue' in locals() and 'db_writer_thread' in locals():
2027 stop_db_writer(db_write_queue, db_writer_thread) 2123 stop_db_writer(db_write_queue, db_writer_thread)
2124 if 'pending_cache_items' in locals():
2125 cache_flushed = flush_pending_lyrics_cache(lyrics_cache_path, pending_cache_items)
2126 if cache_flushed:
2127 logger.info(f"目标库歌词缓存追加写入完成: {cache_flushed} 条")
2028 if 'pbar_oss' in locals(): 2128 if 'pbar_oss' in locals():
2029 pbar_oss.close() 2129 pbar_oss.close()
2030 if 'pbar_db' in locals(): 2130 if 'pbar_db' in locals():
2031 pbar_db.close() 2131 pbar_db.close()
2032 logger.warning("已停止导入;未入队的 OSS 结果不会写入 DB,可安全重跑继续。") 2132 logger.warning("已停止导入;未入队的 OSS 结果不会写入 DB/缓存,可安全重跑继续。")
2033 raise 2133 raise
2034 2134
2035 finally: 2135 finally:
......
...@@ -667,7 +667,8 @@ ...@@ -667,7 +667,8 @@
667 metric('平均召回', row.avg_recalled_candidates || '-'), 667 metric('平均召回', row.avg_recalled_candidates || '-'),
668 metric('命中数', row.hit_count || '0'), 668 metric('命中数', row.hit_count || '0'),
669 metric('merge', row.duplicate_count || '0'), 669 metric('merge', row.duplicate_count || '0'),
670 metric('review', row.review_count || '0') 670 metric('review', row.review_count || '0'),
671 metric('skip', row.skip_count || '0')
671 ].join(''); 672 ].join('');
672 } 673 }
673 674
......
...@@ -211,10 +211,17 @@ class DuplicateChecker: ...@@ -211,10 +211,17 @@ class DuplicateChecker:
211 decision = DuplicateDecision.REVIEW 211 decision = DuplicateDecision.REVIEW
212 confidence = 0.95 212 confidence = 0.95
213 reason = "原文哈希一致,但疑似整段翻译结构拆分置信度较低,需要人工复核" 213 reason = "原文哈希一致,但疑似整段翻译结构拆分置信度较低,需要人工复核"
214 elif not _has_enough_primary_text_chars( 214 elif not (
215 query.normalized, 215 _has_enough_primary_text_chars(
216 candidate.normalized, 216 query.normalized,
217 min_chars=self.exact_duplicate_min_primary_chars, 217 candidate.normalized,
218 min_chars=self.exact_duplicate_min_primary_chars,
219 )
220 or _has_enough_primary_lyrics(
221 query.normalized,
222 candidate.normalized,
223 min_lines=self.auto_duplicate_min_primary_lines,
224 )
218 ): 225 ):
219 decision = DuplicateDecision.REVIEW 226 decision = DuplicateDecision.REVIEW
220 confidence = 0.95 227 confidence = 0.95
......
1 #!/usr/bin/env python3
2 """从 hk_songs_test 迁移数据到 hk_songs,同时将所有资源 URL 转存到 COS。
3
4 用法:
5 python migrate_test_to_prod.py --limit 10
6 python migrate_test_to_prod.py --limit 10 --dry-run
7 python migrate_test_to_prod.py --limit 10 --skip-cos # 跳过文件转存,URL 透传
8 python migrate_test_to_prod.py --offset 50 --limit 50 # 分批导入
9 """
10
11 from __future__ import annotations
12
13 import argparse
14 import logging
15 import os
16 import time
17 from pathlib import Path
18
19 import pymysql
20 import requests
21 from dotenv import load_dotenv
22 from qcloud_cos import CosConfig, CosS3Client
23
24 load_dotenv()
25
26 logging.basicConfig(
27 level=logging.INFO,
28 format="%(asctime)s %(levelname)s %(message)s",
29 datefmt="%H:%M:%S",
30 )
31 logger = logging.getLogger(__name__)
32
33 # ==================== 数据库配置 ====================
34
35 DB_CONFIG = {
36 "host": os.getenv("TARGET_DB_HOST"),
37 "port": int(os.getenv("TARGET_DB_PORT", 3306)),
38 "user": os.getenv("TARGET_DB_USER"),
39 "password": os.getenv("TARGET_DB_PASSWORD"),
40 "database": os.getenv("TARGET_DB_NAME"),
41 "charset": "utf8mb4",
42 "cursorclass": pymysql.cursors.DictCursor,
43 }
44
45 SOURCE_TABLE = "hk_songs_test"
46 TARGET_TABLE = "hk_songs"
47
48 SOURCE_DB_CONFIG = {
49 "host": os.getenv("SOURCE_DB_HOST"),
50 "port": int(os.getenv("SOURCE_DB_PORT", 3306)),
51 "user": os.getenv("SOURCE_DB_USER"),
52 "password": os.getenv("SOURCE_DB_PASSWORD"),
53 "database": os.getenv("SOURCE_DB_NAME"),
54 "charset": "utf8mb4",
55 "cursorclass": pymysql.cursors.DictCursor,
56 }
57
58 # ==================== COS 配置 ====================
59
60 COS_CONFIG = {
61 "secret_id": os.getenv("COS_SECRET_ID"),
62 "secret_key": os.getenv("COS_SECRET_KEY"),
63 "bucket": os.getenv("COS_BUCKET"),
64 "region": os.getenv("COS_REGION"),
65 "endpoint": os.getenv("COS_ENDPOINT"),
66 "cdn_domain": os.getenv("COS_CDN_DOMAIN"),
67 }
68
69 # 需要转存到 COS 的 URL 字段
70 URL_FIELDS = [
71 ("audio_url", "audio"),
72 ("accompany_url", "accompany"),
73 ("lyrics_url", "lyric"),
74 ("lrc_url", "lyric"),
75 ("creation_url", "creation"),
76 ("opern_url", "opern"),
77 ("cover_url", "cover"),
78 ]
79
80 # 字段默认扩展名
81 FIELD_DEFAULT_EXT = {
82 "audio": ".mp3",
83 "accompany": ".mp3",
84 "lyric": ".txt",
85 "creation": ".mp3",
86 "opern": ".mid",
87 "cover": ".jpg",
88 }
89
90 # ==================== COS 客户端(全局单例)====================
91
92 def _make_cos_client() -> CosS3Client:
93 config = CosConfig(
94 Region=COS_CONFIG["region"],
95 SecretId=COS_CONFIG["secret_id"],
96 SecretKey=COS_CONFIG["secret_key"],
97 )
98 return CosS3Client(config)
99
100
101 _cos_client: CosS3Client | None = None
102
103
104 def get_cos_client() -> CosS3Client:
105 global _cos_client
106 if _cos_client is None:
107 _cos_client = _make_cos_client()
108 return _cos_client
109
110
111 def upload_to_cos(content: bytes, cos_key: str, content_type: str, max_retries: int = 5) -> str | None:
112 """上传内容到 COS,返回访问 URL;失败返回 None。"""
113 import io
114
115 ct = content_type or "application/octet-stream"
116 for attempt in range(1, max_retries + 1):
117 try:
118 get_cos_client().put_object(
119 Bucket=COS_CONFIG["bucket"],
120 Body=io.BytesIO(content),
121 Key=cos_key,
122 ContentType=ct,
123 )
124 return f"/{cos_key}"
125 except Exception as e:
126 logger.warning(f"COS 上传失败 ({attempt}/{max_retries}): {cos_key}, {e}")
127 if attempt < max_retries:
128 time.sleep(2 ** attempt)
129 return None
130
131
132 # ==================== 文件下载 ====================
133
134 def download_file(url: str, timeout: int = 30, max_retries: int = 3) -> tuple[bytes | None, str | None]:
135 if not url or not url.startswith(("http://", "https://")):
136 return None, None
137 last_err = None
138 for attempt in range(1, max_retries + 1):
139 try:
140 resp = requests.get(url, timeout=timeout, stream=True)
141 if resp.status_code == 200:
142 return resp.content, resp.headers.get("Content-Type", "").split(";")[0].strip()
143 logger.debug(f"下载 HTTP {resp.status_code}: {url}")
144 except Exception as e:
145 last_err = e
146 if attempt < max_retries:
147 time.sleep(2 ** attempt)
148 logger.warning(f"下载失败(已重试 {max_retries} 次): {url}, 错误: {last_err}")
149 return None, None
150
151
152 def _guess_ext(url: str, content_type: str | None, field_type: str) -> str:
153 if "." in url.split("/")[-1]:
154 ext = "." + url.split("/")[-1].split(".")[-1].split("?")[0]
155 if 1 < len(ext) <= 6:
156 return ext
157 type_map = {
158 "audio/mpeg": ".mp3", "audio/wav": ".wav", "audio/x-wav": ".wav",
159 "audio/ogg": ".ogg", "audio/flac": ".flac", "audio/aac": ".aac",
160 "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp",
161 "image/gif": ".gif", "text/plain": ".txt",
162 "audio/midi": ".mid", "application/x-midi": ".mid",
163 }
164 for ct, ext in type_map.items():
165 if ct in (content_type or ""):
166 return ext
167 return FIELD_DEFAULT_EXT.get(field_type, "")
168
169
170 # ==================== BPM 查询 ====================
171
172 _BPM_SQL = """
173 SELECT sp.id AS song_id, rs.bpm
174 FROM hk_song_platform sp
175 INNER JOIN (
176 SELECT song_id,
177 COALESCE(MIN(CASE WHEN is_main_version = 1 THEN record_id END), MIN(record_id)) AS record_id
178 FROM hk_song_and_record GROUP BY song_id
179 ) sr ON sr.song_id = sp.id
180 LEFT JOIN hk_music_record r ON r.id = sr.record_id AND r.deleted = 0x00
181 LEFT JOIN hk_music_record_state rs ON rs.record_id = r.id AND rs.deleted = 0x00
182 WHERE sp.id IN ({placeholders})
183 """
184
185
186 def _bpm_to_class(bpm_str: str | None) -> int | None:
187 if not bpm_str:
188 return None
189 try:
190 bpm = int(float(bpm_str))
191 except (ValueError, TypeError):
192 return None
193 if bpm <= 0:
194 return None
195 return 1 if bpm < 80 else (2 if bpm <= 120 else 3)
196
197
198 def fetch_bpm_class_map(src_conn, song_ids: list) -> dict[str, int | None]:
199 if not song_ids:
200 return {}
201 placeholders = ",".join(["%s"] * len(song_ids))
202 with src_conn.cursor() as cur:
203 cur.execute(_BPM_SQL.format(placeholders=placeholders), song_ids)
204 return {str(r["song_id"]): _bpm_to_class(r["bpm"]) for r in cur.fetchall()}
205
206 def _is_lyrics_text(value: str | None) -> bool:
207 """判断 lyrics_url/lrc_url 字段存的是文本内容而非 URL 或路径。"""
208 if not value:
209 return False
210 # http(s):// 开头是完整 URL,/ 开头是路径,都不是文本内容
211 return not value.startswith(("http://", "https://", "/"))
212
213
214 def process_url(url: str | None, field_type: str, record_id: int, skip_cos: bool) -> str | None:
215 if not url:
216 return None
217 if skip_cos:
218 return url
219
220 # 已经是路径格式(如 /music_library/...),直接透传
221 if url.startswith("/"):
222 return url
223
224 # lyrics_url/lrc_url 可能存的是文本内容,直接上传文本
225 if field_type == "lyric" and _is_lyrics_text(url):
226 cos_key = f"music_library/lyric/{record_id}.txt"
227 return upload_to_cos(url.encode("utf-8"), cos_key, "text/plain; charset=utf-8") or url
228
229 content, content_type = download_file(url)
230 if not content:
231 return url # 下载失败透传
232
233 ext = _guess_ext(url, content_type, field_type)
234 cos_key = f"music_library/{field_type}/{record_id}{ext}"
235 return upload_to_cos(content, cos_key, content_type or "application/octet-stream") or url
236
237
238 # ==================== 主迁移逻辑 ====================
239
240 # hk_songs 目标字段(去掉 staging 专用字段)
241 TARGET_COLUMNS = [
242 "id", "name", "lyricist", "composer", "issue_status", "intro",
243 "audio_url", "accompany_url", "lyrics_url", "lrc_url",
244 "song_time", "song_start", "song_end",
245 "creation_url", "opern_url", "cover_version", "issue_time",
246 "cover_url", "animation_type", "bpm_class", "review_status",
247 "in_status", "song_status", "commit_time", "review_time",
248 "shelf_time", "review_remark", "create_time", "creator",
249 "modify_time", "modifier", "deleted", "cooperate_type", "singer",
250 "off_shelf_remark", "musician_id", "commit_id", "sheet_music",
251 "commit_desc", "price", "source_table_name", "source_song_id",
252 "lyric_archive_element_id", "melody_archive_element_id", "audio_fingerprint",
253 ]
254
255 INSERT_SQL = (
256 f"INSERT INTO `{TARGET_TABLE}` ({', '.join(f'`{c}`' for c in TARGET_COLUMNS)}) "
257 f"VALUES ({', '.join(['%s'] * len(TARGET_COLUMNS))})"
258 )
259
260
261 def migrate(limit: int, offset: int, dry_run: bool, skip_cos: bool) -> None:
262 conn = pymysql.connect(**DB_CONFIG)
263 src_conn = pymysql.connect(**SOURCE_DB_CONFIG)
264 try:
265 with conn.cursor() as cur:
266 # 查询源数据(只取未软删除的)
267 cur.execute(
268 f"SELECT * FROM `{SOURCE_TABLE}` WHERE deleted = '0' LIMIT %s OFFSET %s",
269 (limit, offset),
270 )
271 rows = cur.fetchall()
272
273 logger.info(f"读取 {SOURCE_TABLE} {len(rows)} 条(offset={offset})")
274
275 # 批量预查 bpm_class
276 song_ids = [r["source_song_id"] for r in rows if r.get("source_song_id")]
277 bpm_class_map = fetch_bpm_class_map(src_conn, song_ids)
278
279 inserted_list: list[dict] = []
280 skipped_list: list[dict] = []
281
282 for row in rows:
283 record_id = row["id"]
284
285 # 检查目标表是否已存在相同 id
286 with conn.cursor() as cur:
287 cur.execute(f"SELECT id FROM `{TARGET_TABLE}` WHERE id = %s", (record_id,))
288 if cur.fetchone():
289 skipped_list.append({"id": record_id, "name": row.get("name"), "reason": "已存在"})
290 continue
291
292 # 转存 URL 字段
293 converted = dict(row)
294
295 # 补全 bpm_class(优先用已有值,否则从源库补)
296 if not converted.get("bpm_class") and row.get("source_song_id"):
297 converted["bpm_class"] = bpm_class_map.get(str(row["source_song_id"]))
298 for field, field_type in URL_FIELDS:
299 original = row.get(field)
300 if original:
301 new_url = process_url(original, field_type, record_id, skip_cos)
302 converted[field] = new_url
303 if new_url != original:
304 logger.info(f" [{field}] id={record_id}: {original[:60]}... -> COS")
305
306 # 补充 source_table_name(标记来源)
307 if not converted.get("source_table_name"):
308 converted["source_table_name"] = SOURCE_TABLE
309
310 values = tuple(converted.get(col) for col in TARGET_COLUMNS)
311
312 if dry_run:
313 inserted_list.append({"id": record_id, "name": row.get("name"), "lyricist": row.get("lyricist"), "composer": row.get("composer")})
314 continue
315
316 with conn.cursor() as cur:
317 try:
318 cur.execute(INSERT_SQL, values)
319 conn.commit()
320 inserted_list.append({"id": record_id, "name": row.get("name"), "lyricist": row.get("lyricist"), "composer": row.get("composer")})
321 except pymysql.err.IntegrityError as e:
322 conn.rollback()
323 skipped_list.append({"id": record_id, "name": row.get("name"), "reason": str(e)})
324
325 # ── 结尾汇总 ──
326 prefix = "[DRY-RUN] " if dry_run else ""
327 print(f"\n{'=' * 60}")
328 print(f"{prefix}导入结果:成功 {len(inserted_list)} 条,跳过 {len(skipped_list)} 条,共读取 {len(rows)} 条")
329 print("=" * 60)
330
331 if inserted_list:
332 action = "将导入" if dry_run else "已导入"
333 print(f"\n{prefix}{action} ({len(inserted_list)} 条):")
334 print(f" {'ID':<12} {'歌名':<30} {'作词':<15} {'作曲'}")
335 print(f" {'-'*12} {'-'*30} {'-'*15} {'-'*15}")
336 for r in inserted_list:
337 print(f" {str(r['id']):<12} {str(r['name'] or ''):<30} {str(r['lyricist'] or ''):<15} {str(r['composer'] or '')}")
338
339 if skipped_list:
340 print(f"\n跳过 ({len(skipped_list)} 条):")
341 print(f" {'ID':<12} {'歌名':<30} 原因")
342 print(f" {'-'*12} {'-'*30} {'-'*20}")
343 for r in skipped_list:
344 print(f" {str(r['id']):<12} {str(r['name'] or ''):<30} {r['reason']}")
345 print()
346 finally:
347 conn.close()
348 src_conn.close()
349
350
351 def main() -> None:
352 parser = argparse.ArgumentParser(description="从 hk_songs_test 迁移数据到 hk_songs,资源文件转存 COS")
353 parser.add_argument("--limit", type=int, default=10, help="迁移条数(默认 10)")
354 parser.add_argument("--offset", type=int, default=0, help="从第几条开始(默认 0)")
355 parser.add_argument("--dry-run", action="store_true", help="仅打印计划,不写库也不上传")
356 parser.add_argument("--skip-cos", action="store_true", help="跳过 COS 转存,URL 直接透传")
357 args = parser.parse_args()
358
359 logger.info(f"开始迁移: {SOURCE_TABLE} -> {TARGET_TABLE}, limit={args.limit}, offset={args.offset}, "
360 f"dry_run={args.dry_run}, skip_cos={args.skip_cos}")
361 migrate(args.limit, args.offset, args.dry_run, args.skip_cos)
362
363
364 if __name__ == "__main__":
365 main()
...@@ -103,7 +103,8 @@ def _staging_summary() -> list[dict[str, str]]: ...@@ -103,7 +103,8 @@ def _staging_summary() -> list[dict[str, str]]:
103 COUNT(DISTINCT source_song_id) AS total_count, 103 COUNT(DISTINCT source_song_id) AS total_count,
104 SUM(CASE WHEN dedup_action = 'new' THEN 1 ELSE 0 END) AS new_count, 104 SUM(CASE WHEN dedup_action = 'new' THEN 1 ELSE 0 END) AS new_count,
105 SUM(CASE WHEN dedup_action = 'merge' THEN 1 ELSE 0 END) AS merge_count, 105 SUM(CASE WHEN dedup_action = 'merge' THEN 1 ELSE 0 END) AS merge_count,
106 SUM(CASE WHEN dedup_action = 'review' THEN 1 ELSE 0 END) AS review_count 106 SUM(CASE WHEN dedup_action = 'review' THEN 1 ELSE 0 END) AS review_count,
107 SUM(CASE WHEN dedup_action = 'skip' THEN 1 ELSE 0 END) AS skip_count
107 FROM ( 108 FROM (
108 SELECT source_song_id, dedup_action 109 SELECT source_song_id, dedup_action
109 FROM {TARGET_TABLE_NAME_TMP} 110 FROM {TARGET_TABLE_NAME_TMP}
...@@ -125,6 +126,7 @@ def _staging_summary() -> list[dict[str, str]]: ...@@ -125,6 +126,7 @@ def _staging_summary() -> list[dict[str, str]]:
125 "hit_count": str((row.get("merge_count") or 0) + (row.get("review_count") or 0)), 126 "hit_count": str((row.get("merge_count") or 0) + (row.get("review_count") or 0)),
126 "duplicate_count": str(row.get("merge_count") or 0), 127 "duplicate_count": str(row.get("merge_count") or 0),
127 "review_count": str(row.get("review_count") or 0), 128 "review_count": str(row.get("review_count") or 0),
129 "skip_count": str(row.get("skip_count") or 0),
128 "new_count": str(row.get("new_count") or 0), 130 "new_count": str(row.get("new_count") or 0),
129 "total_count": str(row.get("total_count") or 0), 131 "total_count": str(row.get("total_count") or 0),
130 }] 132 }]
......
1 #!/usr/bin/env python3
2 """Tests for hk_songs duplicate audit SQL/report helpers."""
3
4 from audit_hk_songs_duplicates import (
5 build_duplicate_detail_sql,
6 build_duplicate_group_sql,
7 build_merge_plan,
8 build_soft_delete_sql,
9 summarize_duplicate_groups,
10 )
11
12
13 def test_group_sql_uses_name_lyricist_composer_and_deleted_filter():
14 sql = build_duplicate_group_sql("hk_songs_test", include_deleted=False, limit=20)
15
16 assert "`hk_songs_test`" in sql
17 assert "COALESCE(NULLIF(TRIM(name), ''), '') COLLATE utf8mb4_bin" in sql
18 assert "COALESCE(NULLIF(TRIM(lyricist), ''), '') COLLATE utf8mb4_bin" in sql
19 assert "COALESCE(NULLIF(TRIM(composer), ''), '') COLLATE utf8mb4_bin" in sql
20 assert "WHERE deleted = '0'" in sql
21 assert "HAVING COUNT(*) > 1" in sql
22 assert "LIMIT 20" in sql
23
24
25 def test_group_sql_can_use_database_default_case_insensitive_collation():
26 sql = build_duplicate_group_sql("hk_songs_test", case_sensitive=False)
27
28 assert "COLLATE utf8mb4_bin" not in sql
29
30
31 def test_detail_sql_joins_duplicate_groups_back_to_rows():
32 sql = build_duplicate_detail_sql("hk_songs_test", include_deleted=True, limit=None)
33
34 assert "JOIN (" in sql
35 assert "dup.name_key" in sql
36 assert "s.name_key" in sql
37 assert "WHERE deleted = '0'" not in sql
38 assert "LIMIT" not in sql
39
40
41 def test_summary_counts_groups_rows_and_extra_duplicates():
42 groups = [
43 {"duplicate_count": 2},
44 {"duplicate_count": 5},
45 ]
46
47 summary = summarize_duplicate_groups(groups)
48
49 assert summary == {
50 "duplicate_groups": 2,
51 "duplicate_rows": 7,
52 "extra_duplicate_rows": 5,
53 }
54
55
56 def test_merge_plan_keeps_row_with_largest_record_count():
57 rows = [
58 {"id": 10, "name": "A", "lyricist": "L", "composer": "C", "source_song_id": "s10", "record_count": 1},
59 {"id": 11, "name": "A", "lyricist": "L", "composer": "C", "source_song_id": "s11", "record_count": 3},
60 {"id": 12, "name": "B", "lyricist": "", "composer": "", "source_song_id": "s12", "record_count": None},
61 {"id": 13, "name": "B", "lyricist": None, "composer": None, "source_song_id": "s13", "record_count": 2},
62 ]
63
64 plan = build_merge_plan(rows)
65
66 assert [item["survivor_id"] for item in plan] == [11, 13]
67 assert [item["loser_id"] for item in plan] == [10, 12]
68 assert plan[0]["survivor_record_count"] == 3
69 assert plan[0]["loser_record_count"] == 1
70
71
72 def test_merge_plan_uses_lowest_id_as_tie_breaker():
73 rows = [
74 {"id": 22, "name": "A", "lyricist": "L", "composer": "C", "source_song_id": "s22", "record_count": 2},
75 {"id": 21, "name": "A", "lyricist": "L", "composer": "C", "source_song_id": "s21", "record_count": 2},
76 ]
77
78 plan = build_merge_plan(rows)
79
80 assert plan[0]["survivor_id"] == 21
81 assert plan[0]["loser_id"] == 22
82
83
84 def test_soft_delete_sql_updates_only_planned_losers():
85 sql = build_soft_delete_sql("hk_songs_test", 3)
86
87 assert "UPDATE `hk_songs_test`" in sql
88 assert "SET deleted = '1'" in sql
89 assert "WHERE deleted = '0'" in sql
90 assert "id IN (%s,%s,%s)" in sql
...@@ -44,8 +44,9 @@ from import_hk_songs import ( ...@@ -44,8 +44,9 @@ from import_hk_songs import (
44 process_lyrics, 44 process_lyrics,
45 DedupReport, 45 DedupReport,
46 L2CandidateIndex, 46 L2CandidateIndex,
47 build_lyrics_cache_item,
48 flush_pending_lyrics_cache,
47 format_dedup_stats, 49 format_dedup_stats,
48 sync_inserted_lyrics_cache,
49 load_approved_import_ids, 50 load_approved_import_ids,
50 load_existing_l2_candidates, 51 load_existing_l2_candidates,
51 ) 52 )
...@@ -153,6 +154,7 @@ class TestDedupStatsLog: ...@@ -153,6 +154,7 @@ class TestDedupStatsLog:
153 'l2_review': 23, 154 'l2_review': 23,
154 'l2_new': 1725, 155 'l2_new': 1725,
155 'author_merged': 1, 156 'author_merged': 1,
157 'soft_deleted': 0,
156 'skipped': 0, 158 'skipped': 0,
157 } 159 }
158 160
...@@ -193,6 +195,27 @@ class TestDbWriterShutdown: ...@@ -193,6 +195,27 @@ class TestDbWriterShutdown:
193 class TestExistingLyricsCache: 195 class TestExistingLyricsCache:
194 """测试目标库已有歌词加载缓存""" 196 """测试目标库已有歌词加载缓存"""
195 197
198 def test_build_lyrics_cache_item_uses_processed_tuple_and_source_lyrics(self):
199 tuple_row = [None] * 45
200 tuple_row[0] = 200
201 tuple_row[1] = '新歌'
202 tuple_row[2] = '新词'
203 tuple_row[3] = '新曲'
204 tuple_row[8] = 'https://oss.example.test/200.txt'
205 tuple_row[33] = '新歌手'
206
207 item = build_lyrics_cache_item(tuple(tuple_row), {'lyrics_txt_content': '新歌词正文'})
208
209 assert item == {
210 'id': '200',
211 'url': 'https://oss.example.test/200.txt',
212 'lyrics': '新歌词正文',
213 'name': '新歌',
214 'singer': '新歌手',
215 'lyricist': '新词',
216 'composer': '新曲',
217 }
218
196 def test_load_existing_l2_candidates_reuses_cached_lyrics(self, tmp_path): 219 def test_load_existing_l2_candidates_reuses_cached_lyrics(self, tmp_path):
197 cache_path = tmp_path / 'lyrics_cache.jsonl' 220 cache_path = tmp_path / 'lyrics_cache.jsonl'
198 cached = { 221 cached = {
...@@ -271,69 +294,44 @@ class TestExistingLyricsCache: ...@@ -271,69 +294,44 @@ class TestExistingLyricsCache:
271 assert cached['id'] == '100' 294 assert cached['id'] == '100'
272 assert cached['lyrics'] == '已下载歌词' 295 assert cached['lyrics'] == '已下载歌词'
273 296
274 def test_sync_inserted_lyrics_cache_adds_committed_inserted_rows(self, tmp_path): 297 def test_load_existing_l2_candidates_all_cached_does_not_rewrite_full_cache(self, tmp_path):
275 cache_path = tmp_path / 'lyrics_cache.jsonl' 298 cache_path = tmp_path / 'lyrics_cache.jsonl'
276 cache_path.write_text( 299 cached = {
277 json.dumps({ 300 'id': '100',
278 'id': '100', 301 'url': 'https://oss.example.test/100.txt',
279 'url': 'https://oss.example.test/100.txt', 302 'lyrics': '缓存里的歌词',
280 'lyrics': '已有歌词', 303 }
281 }, ensure_ascii=False) + '\n', 304 original_cache = json.dumps(cached, ensure_ascii=False) + '\n'
282 encoding='utf-8', 305 cache_path.write_text(original_cache, encoding='utf-8')
283 ) 306 rows = [{'id': 100, 'lyrics_url': 'https://oss.example.test/100.txt'}]
284 tuple_row = [None] * 45
285 tuple_row[0] = 200
286 tuple_row[1] = '新歌'
287 tuple_row[2] = '新词'
288 tuple_row[3] = '新曲'
289 tuple_row[8] = 'https://oss.example.test/200.txt'
290 tuple_row[33] = '新歌手'
291 source_row = {'lyrics_txt_content': '新歌词正文'}
292 307
293 added = sync_inserted_lyrics_cache(cache_path, [(tuple(tuple_row), source_row)]) 308 records, stats = load_existing_l2_candidates(rows, cache_path)
294 309
295 cache_items = [ 310 assert [r.record_id for r in records] == ['100']
296 json.loads(line) 311 assert stats == {'cached': 1, 'downloaded': 0, 'failed': 0}
297 for line in cache_path.read_text(encoding='utf-8').splitlines() 312 assert cache_path.read_text(encoding='utf-8') == original_cache
298 ]
299 assert added == 1
300 assert len(cache_items) == 2
301 assert cache_items[1]['id'] == '200'
302 assert cache_items[1]['url'] == 'https://oss.example.test/200.txt'
303 assert cache_items[1]['lyrics'] == '新歌词正文'
304 313
305 def test_sync_inserted_lyrics_cache_appends_without_reading_full_cache(self, tmp_path, monkeypatch): 314 def test_flush_pending_lyrics_cache_appends_items_and_clears_buffer(self, tmp_path):
306 cache_path = tmp_path / 'lyrics_cache.jsonl' 315 cache_path = tmp_path / 'lyrics_cache.jsonl'
307 cache_path.write_text( 316 pending_items = [{
308 json.dumps({ 317 'id': '200',
309 'id': '100', 318 'url': 'https://oss.example.test/200.txt',
310 'url': 'https://oss.example.test/100.txt', 319 'lyrics': '新歌词',
311 'lyrics': '已有歌词', 320 }]
312 }, ensure_ascii=False) + '\n',
313 encoding='utf-8',
314 )
315 tuple_row = [None] * 45
316 tuple_row[0] = 201
317 tuple_row[1] = '新歌'
318 tuple_row[2] = '新词'
319 tuple_row[3] = '新曲'
320 tuple_row[8] = 'https://oss.example.test/201.txt'
321 tuple_row[33] = '新歌手'
322 321
323 def fail_if_full_cache_read(path): 322 flushed = flush_pending_lyrics_cache(cache_path, pending_items)
324 raise AssertionError('should not read full cache during append sync')
325
326 monkeypatch.setattr(import_script, '_read_existing_lyrics_cache', fail_if_full_cache_read)
327
328 added = sync_inserted_lyrics_cache(
329 cache_path,
330 [(tuple(tuple_row), {'lyrics_txt_content': '新增歌词'})],
331 )
332 323
333 cache_lines = cache_path.read_text(encoding='utf-8').splitlines() 324 cache_items = [
334 assert added == 1 325 json.loads(line)
335 assert len(cache_lines) == 2 326 for line in cache_path.read_text(encoding='utf-8').splitlines()
336 assert json.loads(cache_lines[-1])['id'] == '201' 327 ]
328 assert flushed == 1
329 assert pending_items == []
330 assert cache_items == [{
331 'id': '200',
332 'url': 'https://oss.example.test/200.txt',
333 'lyrics': '新歌词',
334 }]
337 335
338 336
339 # ==================================================================== 337 # ====================================================================
...@@ -1399,6 +1397,130 @@ class TestDedupIntegration: ...@@ -1399,6 +1397,130 @@ class TestDedupIntegration:
1399 assert accepted == [302] # id=302 通过两层去重 1397 assert accepted == [302] # id=302 通过两层去重
1400 assert len(self.l2_candidates) == 2 # 原1条 + 新增1条 1398 assert len(self.l2_candidates) == 2 # 原1条 + 新增1条
1401 1399
1400 def test_merge_direction_new_into_existing_when_new_has_fewer_records(self, monkeypatch):
1401 """新记录录音数少于旧记录时,方向应为 new_into_existing。"""
1402 import import_hk_songs
1403 # matched_id 为目标库 id='100',其 source_song_id='src_100',旧记录录音多
1404 monkeypatch.setitem(import_hk_songs._target_id_to_source_sid, 100, 'src_100')
1405 lyrics = self.l2_candidates[0].lyrics
1406 row = {
1407 'id': 210,
1408 'name': '晴天',
1409 'lyricist': '周杰伦',
1410 'composer': '周杰伦',
1411 'lyrics_txt_content': lyrics,
1412 'singer': '周杰伦',
1413 'record_count': 1, # 新记录录音少
1414 }
1415 source_record_counts = {'src_100': 5} # 旧记录录音多
1416
1417 action = classify_dedup_action(
1418 row, self.l1_index, self.checker, self.l2_candidates,
1419 source_record_counts=source_record_counts,
1420 )
1421
1422 assert action['action'] == 'merge'
1423 assert action['merge_direction'] == 'new_into_existing'
1424
1425 def test_merge_direction_existing_into_new_when_new_has_more_records(self, monkeypatch):
1426 """新记录录音数多于旧记录时,方向应为 existing_into_new。"""
1427 import import_hk_songs
1428 monkeypatch.setitem(import_hk_songs._target_id_to_source_sid, 100, 'src_100')
1429 lyrics = self.l2_candidates[0].lyrics
1430 row = {
1431 'id': 211,
1432 'name': '晴天',
1433 'lyricist': '周杰伦',
1434 'composer': '周杰伦',
1435 'lyrics_txt_content': lyrics,
1436 'singer': '周杰伦',
1437 'record_count': 10, # 新记录录音更多
1438 }
1439 source_record_counts = {'src_100': 2} # 旧记录录音少
1440
1441 action = classify_dedup_action(
1442 row, self.l1_index, self.checker, self.l2_candidates,
1443 source_record_counts=source_record_counts,
1444 )
1445
1446 assert action['action'] == 'merge'
1447 assert action['merge_direction'] == 'existing_into_new'
1448
1449
1450 # ====================================================================
1451 # build_staging_tuple 合并方向
1452 # ====================================================================
1453
1454 class TestBuildStagingTupleMergeDirection:
1455 """build_staging_tuple 对不同 merge_direction 的 staging_status 判定。"""
1456
1457 _BASE_TUPLE = tuple([1] + [None] * 44) # 45-element stub,id=1
1458
1459 def test_new_into_existing_staging_is_skipped(self):
1460 action = {
1461 'action': 'merge', 'merge_direction': 'new_into_existing',
1462 'decision': 'duplicate', 'confidence': 0.99,
1463 'matched_id': '999', 'l1_matched_id': None,
1464 'merge_authors': False, 'reason': '测试',
1465 }
1466 from import_hk_songs import build_staging_tuple
1467 t = build_staging_tuple(self._BASE_TUPLE, action, 'batch_001')
1468 # staging_status 在 tuple 第 -4 位(counted from end)
1469 # 字段顺序:...biz_review_status, staging_status, imported_song_id, recalled_json
1470 staging_status = t[-3]
1471 imported_song_id = t[-2]
1472 assert staging_status == 'skipped'
1473 assert imported_song_id is None
1474
1475 def test_existing_into_new_staging_is_imported(self):
1476 action = {
1477 'action': 'merge', 'merge_direction': 'existing_into_new',
1478 'decision': 'duplicate', 'confidence': 0.99,
1479 'matched_id': '999', 'l1_matched_id': None,
1480 'merge_authors': False, 'reason': '测试',
1481 }
1482 from import_hk_songs import build_staging_tuple
1483 t = build_staging_tuple(self._BASE_TUPLE, action, 'batch_001')
1484 staging_status = t[-3]
1485 imported_song_id = t[-2]
1486 assert staging_status == 'imported'
1487 assert imported_song_id == 1 # tuple_row[0]
1488
1489
1490 # ====================================================================
1491 # execute_soft_deletes
1492 # ====================================================================
1493
1494 class TestExecuteSoftDeletes:
1495 """execute_soft_deletes 的逻辑正确性(不连真库)。"""
1496
1497 def test_empty_ids_returns_zero(self):
1498 from import_hk_songs import execute_soft_deletes
1499 class FakeCursor:
1500 def execute(self, *a):
1501 raise AssertionError("不应被调用")
1502 assert execute_soft_deletes(FakeCursor(), []) == 0
1503
1504 def test_deduplicates_ids(self):
1505 from import_hk_songs import execute_soft_deletes
1506 called = []
1507 class FakeCursor:
1508 def execute(self, sql, params):
1509 called.append(params[0])
1510 result = execute_soft_deletes(FakeCursor(), [5, 5, 5])
1511 assert result == 1 # 去重后只 UPDATE 一次
1512 assert called == [5]
1513
1514 def test_returns_count_of_unique_ids(self):
1515 from import_hk_songs import execute_soft_deletes
1516 called = []
1517 class FakeCursor:
1518 def execute(self, sql, params):
1519 called.append(params[0])
1520 result = execute_soft_deletes(FakeCursor(), [1, 2, 3, 2])
1521 assert result == 3
1522 assert set(called) == {1, 2, 3}
1523
1402 1524
1403 # ==================================================================== 1525 # ====================================================================
1404 # 边界情况 1526 # 边界情况
......