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参数避免重复缓存加载
Showing
9 changed files
with
1752 additions
and
220 deletions
audit_hk_songs_duplicates.py
0 → 100644
| 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()) |
backfill_bpm_class.py
0 → 100644
| 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 | ... | ... |
migrate_test_to_prod.py
0 → 100644
| 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 | }] | ... | ... |
test_audit_hk_songs_duplicates.py
0 → 100644
| 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 | # 边界情况 | ... | ... |
-
Please register or sign in to post a comment