backfill_crm_contract_attachment_urls.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. #!/usr/bin/env python3
  2. """将合同 attachment_files / contract_file 中的旧 CRM 数字 ossId 替换为 MinIO 相对路径。
  3. 门户约定:业务字段里的「ossId」实际存的是 fileUrl(相对 object key)。
  4. 迁入数据仍为雪花 ossId,会导致预签名路径变成 /ruoyi/<ossId> 而无法访问。
  5. 用法:
  6. cd backend
  7. venv/bin/python scripts/backfill_crm_contract_attachment_urls.py --dry-run --env-file .env.production
  8. venv/bin/python scripts/backfill_crm_contract_attachment_urls.py --env-file .env.production
  9. """
  10. from __future__ import annotations
  11. import argparse
  12. import csv
  13. import json
  14. import os
  15. import re
  16. import sys
  17. from datetime import datetime
  18. from pathlib import Path
  19. from typing import Any, Dict, List, Optional, Tuple
  20. from urllib.parse import urlparse
  21. from sqlalchemy import create_engine, text
  22. from sqlalchemy.engine import Engine
  23. BACKEND_ROOT = Path(__file__).resolve().parents[1]
  24. if str(BACKEND_ROOT) not in sys.path:
  25. sys.path.insert(0, str(BACKEND_ROOT))
  26. DEFAULT_TENANT_NO = "shanxia2.0"
  27. OSS_ID_RE = re.compile(r"^\d{10,20}$")
  28. def load_settings_env(env_file: Optional[str]) -> None:
  29. if env_file:
  30. os.environ["ENV_FILE"] = env_file
  31. from dotenv import load_dotenv
  32. candidates: List[Path] = []
  33. if env_file:
  34. p = Path(env_file)
  35. candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
  36. candidates.extend([BACKEND_ROOT / ".env.production", BACKEND_ROOT / ".env"])
  37. for path in candidates:
  38. if path.is_file():
  39. load_dotenv(path, override=True)
  40. break
  41. def load_runtime_settings():
  42. from app.core.config import Settings
  43. return Settings()
  44. def load_oss_map(engine: Engine, tenant_no: str) -> Dict[str, str]:
  45. with engine.connect() as conn:
  46. rows = conn.execute(
  47. text("SELECT id, url FROM sys_oss WHERE tenant_no=:t AND del_flag=0"),
  48. {"t": tenant_no},
  49. ).mappings().all()
  50. return {str(r["id"]): (r["url"] or "").strip() for r in rows if r["id"] and r["url"]}
  51. def resolve_token(raw: Any, oss_map: Dict[str, str]) -> Tuple[str, bool]:
  52. """返回 (新值, 是否发生了 id→url 替换)。"""
  53. token = str(raw or "").strip()
  54. if not token:
  55. return "", False
  56. if token in oss_map:
  57. return oss_map[token], True
  58. if OSS_ID_RE.match(token):
  59. return token, False # 未命中映射,保持原样并记警告
  60. return token, False
  61. def rewrite_attachment_files(raw: Optional[str], oss_map: Dict[str, str], warnings: List[str], cid: str) -> Tuple[Optional[str], int]:
  62. if raw is None or not str(raw).strip():
  63. return None, 0
  64. value = str(raw).strip()
  65. replaced = 0
  66. if value.startswith("["):
  67. try:
  68. parsed = json.loads(value)
  69. except json.JSONDecodeError:
  70. warnings.append(f"contract#{cid}: attachment_files JSON 无效")
  71. return value, 0
  72. if not isinstance(parsed, list):
  73. return value, 0
  74. out: List[dict] = []
  75. for item in parsed:
  76. if isinstance(item, dict):
  77. oid = str(item.get("ossId") or item.get("oss_id") or "").strip()
  78. new_id, changed = resolve_token(oid, oss_map)
  79. if changed:
  80. replaced += 1
  81. elif oid and OSS_ID_RE.match(oid):
  82. warnings.append(f"contract#{cid}: ossId={oid} 无 sys_oss.url")
  83. out.append({"ossId": new_id or oid, "category": item.get("category") or "other"})
  84. elif isinstance(item, str):
  85. new_id, changed = resolve_token(item, oss_map)
  86. if changed:
  87. replaced += 1
  88. out.append({"ossId": new_id or item, "category": "other"})
  89. return (json.dumps(out, ensure_ascii=False) if out else None), replaced
  90. # 逗号分隔
  91. parts: List[str] = []
  92. for token in value.split(","):
  93. token = token.strip()
  94. if not token:
  95. continue
  96. new_id, changed = resolve_token(token, oss_map)
  97. if changed:
  98. replaced += 1
  99. elif OSS_ID_RE.match(token):
  100. warnings.append(f"contract#{cid}: token={token} 无 sys_oss.url")
  101. parts.append(new_id or token)
  102. return (",".join(parts) if parts else None), replaced
  103. def rewrite_contract_file(raw: Optional[str], oss_map: Dict[str, str], warnings: List[str], cid: str) -> Tuple[Optional[str], int]:
  104. if raw is None or not str(raw).strip():
  105. return None, 0
  106. value = str(raw).strip()
  107. # 可能是单个 id、逗号分隔或 JSON
  108. if value.startswith("["):
  109. return rewrite_attachment_files(value, oss_map, warnings, cid)
  110. new_id, changed = resolve_token(value, oss_map)
  111. if changed:
  112. return new_id, 1
  113. if OSS_ID_RE.match(value):
  114. warnings.append(f"contract#{cid}: contract_file={value} 无 sys_oss.url")
  115. return value, 0
  116. def main() -> int:
  117. parser = argparse.ArgumentParser(description="Backfill contract attachment ossId → MinIO relative URL")
  118. parser.add_argument("--target-url", default="")
  119. parser.add_argument("--tenant-no", default=DEFAULT_TENANT_NO)
  120. parser.add_argument("--env-file", default="")
  121. parser.add_argument("--dry-run", action="store_true")
  122. parser.add_argument(
  123. "--report-out",
  124. default=str(
  125. BACKEND_ROOT.parent
  126. / "docs/data-migration/mappings/crm_contract_attachment_url_backfill.csv"
  127. ),
  128. )
  129. args = parser.parse_args()
  130. load_settings_env(args.env_file or None)
  131. settings = load_runtime_settings()
  132. target_url = args.target_url or settings.DATABASE_URL
  133. engine = create_engine(target_url, pool_pre_ping=True)
  134. print(f"目标库: {urlparse(target_url).path}")
  135. oss_map = load_oss_map(engine, args.tenant_no)
  136. print(f"sys_oss 映射条数: {len(oss_map)}")
  137. with engine.connect() as conn:
  138. rows = [
  139. dict(r._mapping)
  140. for r in conn.execute(
  141. text(
  142. """
  143. SELECT id, contract_no, attachment_files, contract_file
  144. FROM crm_contract
  145. WHERE del_flag = 0
  146. AND (
  147. (attachment_files IS NOT NULL AND attachment_files <> '')
  148. OR (contract_file IS NOT NULL AND contract_file <> '')
  149. )
  150. ORDER BY id
  151. """
  152. )
  153. )
  154. ]
  155. warnings: List[str] = []
  156. report: List[dict] = []
  157. updates: List[dict] = []
  158. total_replaced = 0
  159. for row in rows:
  160. cid = str(row["id"])
  161. new_att, n1 = rewrite_attachment_files(row.get("attachment_files"), oss_map, warnings, cid)
  162. new_cf, n2 = rewrite_contract_file(row.get("contract_file"), oss_map, warnings, cid)
  163. replaced = n1 + n2
  164. if replaced == 0 and new_att == row.get("attachment_files") and new_cf == row.get("contract_file"):
  165. continue
  166. if replaced == 0:
  167. continue
  168. total_replaced += replaced
  169. updates.append(
  170. {
  171. "id": cid,
  172. "attachment_files": new_att,
  173. "contract_file": new_cf,
  174. }
  175. )
  176. report.append(
  177. {
  178. "contract_id": cid,
  179. "contract_no": row.get("contract_no") or "",
  180. "replaced_count": replaced,
  181. "old_attachment_files": (row.get("attachment_files") or "")[:200],
  182. "new_attachment_files": (new_att or "")[:200],
  183. "old_contract_file": (row.get("contract_file") or "")[:200],
  184. "new_contract_file": (new_cf or "")[:200],
  185. }
  186. )
  187. print(f"含附件合同: {len(rows)} 需更新: {len(updates)} id→url 替换次数: {total_replaced}")
  188. if args.dry_run:
  189. print("\n预览(前 3 条):")
  190. for r in report[:3]:
  191. print(f" {r['contract_no']}: {r['old_attachment_files']!r}")
  192. print(f" -> {r['new_attachment_files']!r}")
  193. else:
  194. now = datetime.now()
  195. applied = 0
  196. with engine.begin() as conn:
  197. for u in updates:
  198. result = conn.execute(
  199. text(
  200. """
  201. UPDATE crm_contract
  202. SET attachment_files = :attachment_files,
  203. contract_file = :contract_file,
  204. update_time = :update_time
  205. WHERE id = :id
  206. """
  207. ),
  208. {**u, "update_time": now},
  209. )
  210. if result.rowcount:
  211. applied += 1
  212. print(f"已更新合同: {applied}")
  213. report_path = Path(args.report_out)
  214. report_path.parent.mkdir(parents=True, exist_ok=True)
  215. with report_path.open("w", encoding="utf-8", newline="") as f:
  216. w = csv.DictWriter(
  217. f,
  218. fieldnames=[
  219. "contract_id",
  220. "contract_no",
  221. "replaced_count",
  222. "old_attachment_files",
  223. "new_attachment_files",
  224. "old_contract_file",
  225. "new_contract_file",
  226. ],
  227. )
  228. w.writeheader()
  229. w.writerows(report)
  230. print(f"明细: {report_path}")
  231. if warnings:
  232. print(f"警告 ({len(warnings)}):")
  233. for w in warnings[:15]:
  234. print(f" - {w}")
  235. if len(warnings) > 15:
  236. print(f" ... 另有 {len(warnings) - 15} 条")
  237. mode = "DRY-RUN" if args.dry_run else "COMMITTED"
  238. print(f"\n=== 合同附件 ossId→URL 回填 [{mode}] ===")
  239. return 0
  240. if __name__ == "__main__":
  241. raise SystemExit(main())