migrate_crm_sys_oss.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650
  1. #!/usr/bin/env python3
  2. """将 sx 库 CRM sys_oss 迁入 shanxia2.0,并反查业务表回填 biz_type/biz_id。
  3. 旧 CRM ``sys_oss`` 无 ``biz_type``/``biz_id``;门户新增这两列。迁移分两步:
  4. 1. 整表 INSERT(id=str(oss_id),url 绝对路径→相对 object key)
  5. 2. 扫描业务表 OSS ID 引用,UPDATE 回填 biz;反查不到则保持 NULL(不用 sys_oper_log)
  6. 用法:
  7. cd backend
  8. venv/bin/python scripts/migrate_crm_sys_oss.py --dry-run --env-file .env.production
  9. venv/bin/python scripts/migrate_crm_sys_oss.py --env-file .env.production \\
  10. --user-mapping ../docs/data-migration/mappings/user_mapping_result.csv \\
  11. --mapping-out ../docs/data-migration/mappings/crm_oss_id_mapping.csv \\
  12. --backfill-out ../docs/data-migration/mappings/crm_oss_biz_backfill.csv \\
  13. --unresolved-out ../docs/data-migration/mappings/crm_oss_unresolved.csv
  14. """
  15. from __future__ import annotations
  16. import argparse
  17. import csv
  18. import json
  19. import os
  20. import re
  21. import sys
  22. from dataclasses import dataclass, field
  23. from datetime import datetime
  24. from pathlib import Path
  25. from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
  26. from urllib.parse import urlparse, urlunparse
  27. from sqlalchemy import create_engine, text
  28. from sqlalchemy.engine import Engine
  29. BACKEND_ROOT = Path(__file__).resolve().parents[1]
  30. MIGRATION_DIR = BACKEND_ROOT / "scripts" / "migration"
  31. if str(BACKEND_ROOT) not in sys.path:
  32. sys.path.insert(0, str(BACKEND_ROOT))
  33. if str(MIGRATION_DIR) not in sys.path:
  34. sys.path.insert(0, str(MIGRATION_DIR))
  35. from minio_url import normalize_oss_storage_url # noqa: E402
  36. DEFAULT_TENANT_NO = "shanxia2.0"
  37. SOURCE_TABLE = "sys_oss"
  38. TARGET_TABLE = "sys_oss"
  39. CRM_ADMIN_SOURCE_USER_ID = 1
  40. OSS_ID_RE = re.compile(r"^\d{1,20}$")
  41. @dataclass
  42. class BackfillRule:
  43. table: str
  44. id_column: str
  45. field: str
  46. biz_type: str
  47. attach_type: Optional[str] = None
  48. value_kind: str = "csv" # csv | json_array | bigint
  49. BACKFILL_RULES: List[BackfillRule] = [
  50. BackfillRule("crm_customer", "id", "business_license_images", "crm_customer", "business_license"),
  51. BackfillRule("crm_customer", "id", "business_permit_images", "crm_customer", "business_permit"),
  52. BackfillRule("crm_customer", "id", "other_cert_images", "crm_customer", "other_cert"),
  53. BackfillRule("crm_customer_follow", "id", "attachments", "crm_customer_follow"),
  54. BackfillRule("crm_clue_follow", "id", "attachments", "crm_clue_follow"),
  55. BackfillRule("crm_opportunity_follow", "id", "attachments", "crm_opportunity_follow"),
  56. BackfillRule("crm_quote", "id", "attachments", "crm_quote"),
  57. BackfillRule("crm_contract", "id", "attachment_files", "crm_contract"),
  58. BackfillRule("crm_contract", "id", "contract_file", "crm_contract", "contract_file"),
  59. BackfillRule("crm_product", "id", "images", "crm_product", "image"),
  60. BackfillRule(
  61. "crm_contract_product_price_tier",
  62. "id",
  63. "letter_oss_id",
  64. "crm_contract_product_price_tier",
  65. "letter",
  66. value_kind="bigint",
  67. ),
  68. BackfillRule(
  69. "crm_contract_product_price_tier",
  70. "id",
  71. "letter_oss_ids",
  72. "crm_contract_product_price_tier",
  73. "letter",
  74. value_kind="json_array",
  75. ),
  76. ]
  77. @dataclass
  78. class BizRef:
  79. biz_type: str
  80. biz_id: str
  81. source_table: str
  82. source_field: str
  83. attach_type: Optional[str] = None
  84. @dataclass
  85. class MigrationStats:
  86. source_count: int = 0
  87. inserted: int = 0
  88. updated: int = 0
  89. skipped: int = 0
  90. backfilled: int = 0
  91. unresolved: int = 0
  92. conflicts: int = 0
  93. warnings: List[str] = field(default_factory=list)
  94. def legacy_id_to_str(legacy_id: Any) -> Optional[str]:
  95. if legacy_id is None or str(legacy_id).strip() == "":
  96. return None
  97. try:
  98. return str(int(legacy_id))
  99. except (TypeError, ValueError):
  100. return str(legacy_id).strip()
  101. def derive_source_url(target_url: str, source_db: str = "sx") -> str:
  102. return urlunparse(urlparse(target_url)._replace(path=f"/{source_db}"))
  103. def load_settings_env(env_file: Optional[str]) -> None:
  104. if env_file:
  105. os.environ["ENV_FILE"] = env_file
  106. from dotenv import load_dotenv
  107. candidates: List[Path] = []
  108. if env_file:
  109. p = Path(env_file)
  110. candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
  111. candidates.extend([BACKEND_ROOT / ".env.production", BACKEND_ROOT / ".env"])
  112. for path in candidates:
  113. if path.is_file():
  114. load_dotenv(path, override=True)
  115. break
  116. def load_runtime_settings():
  117. from app.core.config import Settings
  118. return Settings()
  119. def load_user_mapping(path: Path) -> Tuple[Dict[int, str], Optional[str]]:
  120. crm_map: Dict[int, str] = {}
  121. admin_id: Optional[str] = None
  122. with path.open(encoding="utf-8-sig", newline="") as f:
  123. for row in csv.DictReader(f):
  124. new_id = (row.get("new_user_id") or "").strip()
  125. user_no = (row.get("user_no") or "").strip()
  126. crm_raw = (row.get("crm_user_id") or "").strip()
  127. if user_no == "admin" and new_id:
  128. admin_id = new_id
  129. if crm_raw and new_id:
  130. try:
  131. crm_map[int(crm_raw)] = new_id
  132. except ValueError:
  133. pass
  134. return crm_map, admin_id
  135. def fetch_portal_admin_id(target_engine: Engine) -> Optional[str]:
  136. with target_engine.connect() as conn:
  137. row = conn.execute(
  138. text("SELECT id FROM sys_user WHERE user_no='admin' AND del_flag=0 LIMIT 1")
  139. ).first()
  140. return str(row[0]) if row and row[0] else None
  141. def resolve_portal_user_id(
  142. source_user_id: Any,
  143. crm_user_map: Dict[int, str],
  144. admin_id: Optional[str],
  145. stats: MigrationStats,
  146. label: str,
  147. ) -> Optional[str]:
  148. if source_user_id is None or str(source_user_id).strip() == "":
  149. return None
  150. try:
  151. uid = int(source_user_id)
  152. except (TypeError, ValueError):
  153. stats.warnings.append(f"{label}: 非法用户 id {source_user_id!r}")
  154. return None
  155. if uid == CRM_ADMIN_SOURCE_USER_ID:
  156. return admin_id
  157. mapped = crm_user_map.get(uid)
  158. if not mapped:
  159. stats.warnings.append(f"{label}: crm_user_id={uid} 无映射")
  160. return mapped
  161. def fetch_source_rows(engine: Engine) -> List[dict]:
  162. sql = f"SELECT * FROM `{SOURCE_TABLE}` ORDER BY oss_id ASC"
  163. with engine.connect() as conn:
  164. return [dict(r._mapping) for r in conn.execute(text(sql))]
  165. def count_target_rows(engine: Engine, tenant_no: str) -> int:
  166. with engine.connect() as conn:
  167. return int(
  168. conn.execute(
  169. text(f"SELECT COUNT(*) FROM `{TARGET_TABLE}` WHERE tenant_no=:t"),
  170. {"t": tenant_no},
  171. ).scalar_one()
  172. )
  173. def transform_row(
  174. row: dict,
  175. tenant_no: str,
  176. crm_user_map: Dict[int, str],
  177. admin_id: Optional[str],
  178. stats: MigrationStats,
  179. ) -> dict:
  180. oss_id = legacy_id_to_str(row["oss_id"])
  181. relative_url = normalize_oss_storage_url(row.get("url"), row.get("file_name"))
  182. if not relative_url:
  183. stats.warnings.append(f"oss#{oss_id}: url 无法规范化为相对路径")
  184. return {
  185. "id": oss_id,
  186. "tenant_no": tenant_no,
  187. "biz_type": None,
  188. "biz_id": None,
  189. "file_name": (row.get("file_name") or "")[:255],
  190. "original_name": (row.get("original_name") or "")[:255],
  191. "file_suffix": (row.get("file_suffix") or "")[:10],
  192. "url": (relative_url or (row.get("file_name") or ""))[:500],
  193. "file_size": None,
  194. "file_type": None,
  195. "attach_type": "attachment",
  196. "sort_order": 0,
  197. "service": (row.get("service") or "minio")[:20],
  198. "ext1": row.get("ext1"),
  199. "order_num": 0,
  200. "remark": None,
  201. "create_by": resolve_portal_user_id(
  202. row.get("create_by"), crm_user_map, admin_id, stats, f"oss#{oss_id} create_by"
  203. ),
  204. "create_dept_id": "",
  205. "create_time": row.get("create_time") or datetime.now(),
  206. "update_by": resolve_portal_user_id(
  207. row.get("update_by"), crm_user_map, admin_id, stats, f"oss#{oss_id} update_by"
  208. ),
  209. "update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
  210. "del_flag": 0,
  211. }
  212. INSERT_SQL = text(
  213. f"""
  214. INSERT INTO `{TARGET_TABLE}` (
  215. id, tenant_no, biz_type, biz_id, file_name, original_name, file_suffix, url,
  216. file_size, file_type, attach_type, sort_order, service, ext1, order_num, remark,
  217. create_by, create_dept_id, create_time, update_by, update_time, del_flag
  218. ) VALUES (
  219. :id, :tenant_no, :biz_type, :biz_id, :file_name, :original_name, :file_suffix, :url,
  220. :file_size, :file_type, :attach_type, :sort_order, :service, :ext1, :order_num, :remark,
  221. :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
  222. )
  223. """
  224. )
  225. UPDATE_SQL = text(
  226. f"""
  227. UPDATE `{TARGET_TABLE}` SET
  228. file_name=:file_name, original_name=:original_name, file_suffix=:file_suffix, url=:url,
  229. service=:service, ext1=:ext1, tenant_no=:tenant_no,
  230. create_by=:create_by, update_by=:update_by, update_time=:update_time
  231. WHERE id=:id
  232. """
  233. )
  234. UPDATE_BIZ_SQL = text(
  235. f"""
  236. UPDATE `{TARGET_TABLE}` SET
  237. biz_type=:biz_type, biz_id=:biz_id, attach_type=:attach_type, update_time=:update_time
  238. WHERE id=:id AND tenant_no=:tenant_no
  239. AND (biz_type IS NULL OR biz_type = '')
  240. """
  241. )
  242. def target_row_exists(conn, row_id: str) -> bool:
  243. return (
  244. int(conn.execute(text(f"SELECT COUNT(*) FROM `{TARGET_TABLE}` WHERE id=:id"), {"id": row_id}).scalar_one())
  245. > 0
  246. )
  247. def parse_oss_id_tokens(raw: Any, *, value_kind: str = "csv") -> List[str]:
  248. if raw is None:
  249. return []
  250. if value_kind == "bigint":
  251. sid = legacy_id_to_str(raw)
  252. return [sid] if sid and OSS_ID_RE.match(sid) else []
  253. text_val = str(raw).strip()
  254. if not text_val:
  255. return []
  256. if value_kind in ("json_array", "json_oss_list") or (
  257. value_kind == "csv" and text_val.startswith("[")
  258. ):
  259. try:
  260. parsed = json.loads(text_val)
  261. if isinstance(parsed, list):
  262. tokens: List[str] = []
  263. for item in parsed:
  264. if isinstance(item, dict):
  265. sid = legacy_id_to_str(item.get("ossId") or item.get("oss_id"))
  266. if sid and OSS_ID_RE.match(sid):
  267. tokens.append(sid)
  268. else:
  269. token = _normalize_oss_token(str(item))
  270. if token:
  271. tokens.append(token)
  272. return tokens
  273. except json.JSONDecodeError:
  274. if value_kind != "csv":
  275. return []
  276. tokens: List[str] = []
  277. for part in text_val.split(","):
  278. token = _normalize_oss_token(part)
  279. if token:
  280. tokens.append(token)
  281. return tokens
  282. def _normalize_oss_token(part: str) -> Optional[str]:
  283. token = (part or "").strip().strip('"').strip("'")
  284. if not token:
  285. return None
  286. if token.startswith("http://") or token.startswith("https://"):
  287. return None
  288. if "/" in token or "\\" in token:
  289. return None
  290. if not OSS_ID_RE.match(token):
  291. return None
  292. return token
  293. def table_exists(engine: Engine, table_name: str) -> bool:
  294. with engine.connect() as conn:
  295. row = conn.execute(
  296. text(
  297. "SELECT COUNT(*) FROM information_schema.tables "
  298. "WHERE table_schema = DATABASE() AND table_name = :t"
  299. ),
  300. {"t": table_name},
  301. ).scalar_one()
  302. return int(row) > 0
  303. def build_biz_index(
  304. source_engine: Engine,
  305. known_oss_ids: Set[str],
  306. stats: MigrationStats,
  307. ) -> Tuple[Dict[str, BizRef], List[dict]]:
  308. index: Dict[str, BizRef] = {}
  309. conflicts: List[dict] = []
  310. for rule in BACKFILL_RULES:
  311. if not table_exists(source_engine, rule.table):
  312. stats.warnings.append(f"回填跳过:源库无表 {rule.table}")
  313. continue
  314. sql = f"SELECT `{rule.id_column}` AS biz_row_id, `{rule.field}` AS field_value FROM `{rule.table}`"
  315. with source_engine.connect() as conn:
  316. rows = conn.execute(text(sql)).mappings().all()
  317. for row in rows:
  318. biz_id = legacy_id_to_str(row["biz_row_id"])
  319. if not biz_id:
  320. continue
  321. for oss_id in parse_oss_id_tokens(row["field_value"], value_kind=rule.value_kind):
  322. if oss_id not in known_oss_ids:
  323. continue
  324. ref = BizRef(
  325. biz_type=rule.biz_type,
  326. biz_id=biz_id,
  327. source_table=rule.table,
  328. source_field=rule.field,
  329. attach_type=rule.attach_type,
  330. )
  331. existing = index.get(oss_id)
  332. if existing is None:
  333. index[oss_id] = ref
  334. continue
  335. if (
  336. existing.biz_type == ref.biz_type
  337. and existing.biz_id == ref.biz_id
  338. and existing.attach_type == ref.attach_type
  339. ):
  340. continue
  341. conflicts.append(
  342. {
  343. "oss_id": oss_id,
  344. "kept_biz_type": existing.biz_type,
  345. "kept_biz_id": existing.biz_id,
  346. "kept_source": f"{existing.source_table}.{existing.source_field}",
  347. "ignored_biz_type": ref.biz_type,
  348. "ignored_biz_id": ref.biz_id,
  349. "ignored_source": f"{ref.source_table}.{ref.field}",
  350. }
  351. )
  352. stats.conflicts += 1
  353. return index, conflicts
  354. def apply_biz_backfill(
  355. target_engine: Engine,
  356. biz_index: Dict[str, BizRef],
  357. tenant_no: str,
  358. *,
  359. dry_run: bool,
  360. ) -> int:
  361. if dry_run or not biz_index:
  362. return len(biz_index)
  363. now = datetime.now()
  364. updated = 0
  365. with target_engine.begin() as conn:
  366. for oss_id, ref in biz_index.items():
  367. result = conn.execute(
  368. UPDATE_BIZ_SQL,
  369. {
  370. "id": oss_id,
  371. "tenant_no": tenant_no,
  372. "biz_type": ref.biz_type,
  373. "biz_id": ref.biz_id,
  374. "attach_type": ref.attach_type or "attachment",
  375. "update_time": now,
  376. },
  377. )
  378. if result.rowcount:
  379. updated += 1
  380. return updated
  381. def write_mapping_csv(rows: List[dict], path: Path) -> None:
  382. path.parent.mkdir(parents=True, exist_ok=True)
  383. migrated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  384. with path.open("w", encoding="utf-8", newline="") as f:
  385. w = csv.DictWriter(
  386. f,
  387. fieldnames=[
  388. "source_oss_id",
  389. "new_id",
  390. "file_name",
  391. "original_name",
  392. "url_relative",
  393. "migrated_at",
  394. ],
  395. )
  396. w.writeheader()
  397. for row in rows:
  398. sid = legacy_id_to_str(row["oss_id"])
  399. w.writerow(
  400. {
  401. "source_oss_id": sid,
  402. "new_id": sid,
  403. "file_name": row.get("file_name") or "",
  404. "original_name": row.get("original_name") or "",
  405. "url_relative": normalize_oss_storage_url(row.get("url"), row.get("file_name")),
  406. "migrated_at": migrated_at,
  407. }
  408. )
  409. def write_backfill_csv(biz_index: Dict[str, BizRef], path: Path) -> None:
  410. path.parent.mkdir(parents=True, exist_ok=True)
  411. with path.open("w", encoding="utf-8", newline="") as f:
  412. w = csv.DictWriter(
  413. f,
  414. fieldnames=["oss_id", "biz_type", "biz_id", "attach_type", "source_table", "source_field"],
  415. )
  416. w.writeheader()
  417. for oss_id in sorted(biz_index.keys(), key=lambda x: int(x)):
  418. ref = biz_index[oss_id]
  419. w.writerow(
  420. {
  421. "oss_id": oss_id,
  422. "biz_type": ref.biz_type,
  423. "biz_id": ref.biz_id,
  424. "attach_type": ref.attach_type or "",
  425. "source_table": ref.source_table,
  426. "source_field": ref.source_field,
  427. }
  428. )
  429. def write_unresolved_csv(
  430. all_oss_ids: Iterable[str],
  431. biz_index: Dict[str, BizRef],
  432. path: Path,
  433. ) -> None:
  434. path.parent.mkdir(parents=True, exist_ok=True)
  435. unresolved = sorted(set(all_oss_ids) - set(biz_index.keys()), key=lambda x: int(x))
  436. with path.open("w", encoding="utf-8", newline="") as f:
  437. w = csv.DictWriter(f, fieldnames=["oss_id", "reason"])
  438. w.writeheader()
  439. for oss_id in unresolved:
  440. w.writerow({"oss_id": oss_id, "reason": "no_business_reference"})
  441. def print_report(stats: MigrationStats, dry_run: bool) -> None:
  442. mode = "DRY-RUN" if dry_run else "COMMITTED"
  443. print(f"\n=== CRM sys_oss 迁移 [{mode}] ===")
  444. print(f"源表行数: {stats.source_count}")
  445. print(f"插入: {stats.inserted} 更新: {stats.updated} 跳过: {stats.skipped}")
  446. print(f"biz 回填: {stats.backfilled} 未解析 biz: {stats.unresolved} 冲突(保留首条): {stats.conflicts}")
  447. if stats.warnings:
  448. print(f"警告 ({len(stats.warnings)}):")
  449. for w in stats.warnings[:15]:
  450. print(f" - {w}")
  451. if len(stats.warnings) > 15:
  452. print(f" ... 另有 {len(stats.warnings) - 15} 条")
  453. def run_migration(
  454. source_engine: Engine,
  455. target_engine: Engine,
  456. *,
  457. tenant_no: str,
  458. user_mapping_path: Path,
  459. mapping_out: Optional[Path],
  460. backfill_out: Optional[Path],
  461. unresolved_out: Optional[Path],
  462. dry_run: bool,
  463. update_existing: bool,
  464. skip_insert: bool,
  465. skip_backfill: bool,
  466. ) -> MigrationStats:
  467. stats = MigrationStats()
  468. crm_user_map, admin_id = load_user_mapping(user_mapping_path)
  469. if not admin_id:
  470. admin_id = fetch_portal_admin_id(target_engine)
  471. source_rows = fetch_source_rows(source_engine)
  472. stats.source_count = len(source_rows)
  473. known_oss_ids = {legacy_id_to_str(r["oss_id"]) for r in source_rows if legacy_id_to_str(r["oss_id"])}
  474. payloads = [transform_row(r, tenant_no, crm_user_map, admin_id, stats) for r in source_rows]
  475. if not skip_insert:
  476. if dry_run:
  477. print("\n预览(前 5 条):")
  478. for p in payloads[:5]:
  479. print(
  480. f" id={p['id']} url={p['url']!r} file={p['file_name']!r} "
  481. f"biz=({p['biz_type']},{p['biz_id']})"
  482. )
  483. stats.inserted = len(payloads)
  484. else:
  485. with target_engine.begin() as conn:
  486. for payload in payloads:
  487. rid = payload["id"]
  488. if target_row_exists(conn, rid):
  489. if update_existing:
  490. conn.execute(UPDATE_SQL, payload)
  491. stats.updated += 1
  492. else:
  493. stats.skipped += 1
  494. else:
  495. conn.execute(INSERT_SQL, payload)
  496. stats.inserted += 1
  497. biz_index: Dict[str, BizRef] = {}
  498. if not skip_backfill:
  499. biz_index, conflicts = build_biz_index(source_engine, known_oss_ids, stats)
  500. if conflicts and dry_run:
  501. print(f"\n回填冲突预览(前 3 条,共 {len(conflicts)}):")
  502. for item in conflicts[:3]:
  503. print(f" oss={item['oss_id']} kept={item['kept_source']} ignored={item['ignored_source']}")
  504. stats.backfilled = apply_biz_backfill(target_engine, biz_index, tenant_no, dry_run=dry_run)
  505. stats.unresolved = len(known_oss_ids) - len(biz_index)
  506. if mapping_out and not dry_run:
  507. write_mapping_csv(source_rows, mapping_out)
  508. print(f"\nID 映射已写入: {mapping_out}")
  509. if backfill_out:
  510. write_backfill_csv(biz_index, backfill_out)
  511. print(f"biz 回填映射已写入: {backfill_out}")
  512. if unresolved_out:
  513. write_unresolved_csv(known_oss_ids, biz_index, unresolved_out)
  514. print(f"未回填 oss 列表已写入: {unresolved_out}")
  515. print_report(stats, dry_run)
  516. return stats
  517. def main() -> int:
  518. parser = argparse.ArgumentParser(description="Migrate CRM sys_oss from sx to portal")
  519. parser.add_argument("--source-url", default="")
  520. parser.add_argument("--target-url", default="")
  521. parser.add_argument("--tenant-no", default=DEFAULT_TENANT_NO)
  522. parser.add_argument(
  523. "--user-mapping",
  524. default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_mapping_result.csv"),
  525. )
  526. parser.add_argument("--dry-run", action="store_true")
  527. parser.add_argument("--update", action="store_true", help="目标已存在同 id 时更新元数据(不含 biz 覆盖)")
  528. parser.add_argument("--skip-insert", action="store_true", help="仅执行 biz 回填")
  529. parser.add_argument("--skip-backfill", action="store_true", help="仅 INSERT,不回填 biz_type/biz_id")
  530. parser.add_argument("--mapping-out", default="")
  531. parser.add_argument("--backfill-out", default="")
  532. parser.add_argument("--unresolved-out", default="")
  533. parser.add_argument("--env-file", default=os.environ.get("ENV_FILE", ""))
  534. args = parser.parse_args()
  535. load_settings_env(args.env_file or None)
  536. settings = load_runtime_settings()
  537. target_url = args.target_url or settings.DATABASE_URL
  538. source_url = args.source_url or os.environ.get("SX_SOURCE_DATABASE_URL") or derive_source_url(target_url)
  539. print(f"源库: {urlparse(source_url).path}")
  540. print(f"目标库: {urlparse(target_url).path}")
  541. print("数据源: sx.sys_oss(旧 CRM 附件元数据,共用 MinIO)")
  542. print(f"目标表现有 sys_oss: {count_target_rows(create_engine(target_url), args.tenant_no)}")
  543. default_mapping_dir = BACKEND_ROOT.parent / "docs/data-migration/mappings"
  544. mapping_out = Path(args.mapping_out) if args.mapping_out else default_mapping_dir / "crm_oss_id_mapping.csv"
  545. backfill_out = Path(args.backfill_out) if args.backfill_out else default_mapping_dir / "crm_oss_biz_backfill.csv"
  546. unresolved_out = (
  547. Path(args.unresolved_out) if args.unresolved_out else default_mapping_dir / "crm_oss_unresolved.csv"
  548. )
  549. run_migration(
  550. create_engine(source_url, pool_pre_ping=True),
  551. create_engine(target_url, pool_pre_ping=True),
  552. tenant_no=args.tenant_no,
  553. user_mapping_path=Path(args.user_mapping),
  554. mapping_out=mapping_out if not args.dry_run else None,
  555. backfill_out=backfill_out if not args.skip_backfill else None,
  556. unresolved_out=unresolved_out if not args.skip_backfill else None,
  557. dry_run=args.dry_run,
  558. update_existing=args.update,
  559. skip_insert=args.skip_insert,
  560. skip_backfill=args.skip_backfill,
  561. )
  562. return 0
  563. if __name__ == "__main__":
  564. raise SystemExit(main())