Commit bde8dd5f bde8dd5fe2425eb6999eaf2e8de14ca934f58a59 by 沈秋雨

feat(audit): 新增 L2 审计脚本与作者补丁脚本

1 parent 65293ff9
#!/usr/bin/env python3
"""补处理 merge+skipped 中 merge_authors=1 的 60 条记录。
暂存表 matched_song_id 存的是 source_song_id,通过 source_song_id 找到
目标库实际记录,再做作者字段增量合并。
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
import pymysql
from dotenv import load_dotenv
sys.path.insert(0, str(Path(__file__).resolve().parent))
from import_hk_songs import _merge_author_field
load_dotenv()
TARGET_DB_CONFIG = {
"host": os.getenv("TARGET_DB_HOST"),
"port": int(os.getenv("TARGET_DB_PORT", 3306)),
"user": os.getenv("TARGET_DB_USER"),
"password": os.getenv("TARGET_DB_PASSWORD"),
"database": os.getenv("TARGET_DB_NAME"),
"charset": "utf8mb4",
"cursorclass": pymysql.cursors.DictCursor,
}
TABLE = os.getenv("TARGET_TABLE_NAME", "hk_songs")
STAGING = "hk_songs_import_staging"
def main(dry_run: bool = False) -> None:
conn = pymysql.connect(**TARGET_DB_CONFIG)
try:
# 1. 拉取所有需要补作者合并的暂存记录
with conn.cursor() as cur:
cur.execute(f"""
SELECT staging_id, matched_song_id, lyricist, composer
FROM `{STAGING}`
WHERE dedup_action = 'merge'
AND staging_status = 'skipped'
AND merge_authors = 1
ORDER BY staging_id
""")
staging_rows = cur.fetchall()
print(f"待处理记录数: {len(staging_rows)}")
# 2. 批量查目标库(按 source_song_id)
source_ids = [r["matched_song_id"] for r in staging_rows if r["matched_song_id"]]
ph = ",".join(["%s"] * len(source_ids))
with conn.cursor() as cur:
cur.execute(
f"SELECT id, name, lyricist, composer, source_song_id FROM `{TABLE}` WHERE source_song_id IN ({ph})",
source_ids,
)
target_rows = {str(r["source_song_id"]): r for r in cur.fetchall()}
print(f"目标库命中: {len(target_rows)} / {len(source_ids)}")
# 3. 计算合并结果
updates: list[dict] = []
skipped: list[dict] = []
for s in staging_rows:
sid = s["matched_song_id"]
target = target_rows.get(str(sid))
if not target:
skipped.append({"staging_id": s["staging_id"], "source_song_id": sid, "reason": "目标库未找到"})
continue
merged_lyricist = _merge_author_field(target["lyricist"], s["lyricist"])
merged_composer = _merge_author_field(target["composer"], s["composer"])
lyricist_changed = bool(merged_lyricist) and merged_lyricist != (target["lyricist"] or "")
composer_changed = bool(merged_composer) and merged_composer != (target["composer"] or "")
if not lyricist_changed and not composer_changed:
skipped.append({"staging_id": s["staging_id"], "source_song_id": sid, "reason": "合并后无变化"})
continue
updates.append({
"target_id": target["id"],
"name": target["name"],
"source_song_id": sid,
"staging_id": s["staging_id"],
"orig_lyricist": target["lyricist"],
"orig_composer": target["composer"],
"new_lyricist": merged_lyricist if lyricist_changed else None,
"new_composer": merged_composer if composer_changed else None,
"staging_lyricist": s["lyricist"],
"staging_composer": s["composer"],
})
print(f"\n需要 UPDATE: {len(updates)} 条")
print(f"无需操作: {len(skipped)} 条")
if dry_run:
print("\n[dry-run] 预览变更(前 20 条):")
for u in updates[:20]:
print(f" id={u['target_id']} name={u['name']}")
if u["new_lyricist"] is not None:
print(f" lyricist: {u['orig_lyricist']!r} -> {u['new_lyricist']!r}")
if u["new_composer"] is not None:
print(f" composer: {u['orig_composer']!r} -> {u['new_composer']!r}")
return
# 4. 执行 UPDATE
updated = 0
errors = 0
with conn.cursor() as cur:
for u in updates:
try:
if u["new_lyricist"] is not None:
cur.execute(
f"UPDATE `{TABLE}` SET lyricist = %s WHERE id = %s",
(u["new_lyricist"], u["target_id"]),
)
if u["new_composer"] is not None:
cur.execute(
f"UPDATE `{TABLE}` SET composer = %s WHERE id = %s",
(u["new_composer"], u["target_id"]),
)
updated += 1
except Exception as e:
errors += 1
print(f" [错误] target_id={u['target_id']}: {e}")
conn.commit()
print(f"\n完成: 已更新 {updated} 条,错误 {errors} 条")
if skipped:
print(f"\n无需操作的 {len(skipped)} 条:")
for s in skipped:
print(f" staging_id={s['staging_id']} source_song_id={s['source_song_id']} 原因={s['reason']}")
finally:
conn.close()
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="补处理 merge+skipped 作者合并")
parser.add_argument("--dry-run", action="store_true", help="只预览,不写库")
args = parser.parse_args()
main(dry_run=args.dry_run)