| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237 |
- #!/usr/bin/env python3
- """用目标 sys_oss 将 sx 客户证照 OSS ID 回填为门户相对 URL。
- 场景:客户表已迁、当时 sys_oss 未齐,导致 business_license_img 等为空。
- 用法:
- cd backend
- venv/bin/python scripts/backfill_crm_customer_license_urls.py --dry-run --env-file .env.production
- venv/bin/python scripts/backfill_crm_customer_license_urls.py --env-file .env.production
- """
- from __future__ import annotations
- import argparse
- import csv
- import os
- import sys
- from datetime import datetime
- from pathlib import Path
- from typing import Any, Dict, List, Optional, Tuple
- from urllib.parse import urlparse, urlunparse
- from sqlalchemy import create_engine, text
- from sqlalchemy.engine import Engine
- BACKEND_ROOT = Path(__file__).resolve().parents[1]
- MIGRATION_DIR = BACKEND_ROOT / "scripts" / "migration"
- if str(BACKEND_ROOT) not in sys.path:
- sys.path.insert(0, str(BACKEND_ROOT))
- if str(MIGRATION_DIR) not in sys.path:
- sys.path.insert(0, str(MIGRATION_DIR))
- from minio_url import resolve_oss_id_csv # noqa: E402
- DEFAULT_TENANT_NO = "shanxia2.0"
- FIELD_MAP = (
- # (sx_col, portal_col)
- ("business_license_images", "business_license_img"),
- ("business_permit_images", "business_permit_images"),
- ("other_cert_images", "other_cert_images"),
- )
- def legacy_id_to_str(legacy_id: Any) -> Optional[str]:
- if legacy_id is None or str(legacy_id).strip() == "":
- return None
- try:
- return str(int(legacy_id))
- except (TypeError, ValueError):
- return str(legacy_id).strip()
- def derive_source_url(target_url: str, source_db: str = "sx") -> str:
- return urlunparse(urlparse(target_url)._replace(path=f"/{source_db}"))
- def load_settings_env(env_file: Optional[str]) -> None:
- if env_file:
- os.environ["ENV_FILE"] = env_file
- from dotenv import load_dotenv
- candidates: List[Path] = []
- if env_file:
- p = Path(env_file)
- candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
- candidates.extend([BACKEND_ROOT / ".env.production", BACKEND_ROOT / ".env"])
- for path in candidates:
- if path.is_file():
- load_dotenv(path, override=True)
- break
- def load_runtime_settings():
- from app.core.config import Settings
- return Settings()
- def load_oss_map(target_engine: Engine, tenant_no: str) -> Dict[str, str]:
- with target_engine.connect() as conn:
- rows = conn.execute(
- text("SELECT id, url FROM sys_oss WHERE tenant_no=:t AND del_flag=0"),
- {"t": tenant_no},
- ).mappings().all()
- return {str(r["id"]): (r["url"] or "").strip() for r in rows if r["id"]}
- def fetch_source_customers(source_engine: Engine) -> List[dict]:
- sql = """
- SELECT id, business_license_images, business_permit_images, other_cert_images
- FROM crm_customer
- WHERE del_flag = 0
- ORDER BY id ASC
- """
- with source_engine.connect() as conn:
- return [dict(r._mapping) for r in conn.execute(text(sql))]
- def build_updates(
- source_rows: List[dict],
- oss_map: Dict[str, str],
- ) -> Tuple[List[dict], List[str]]:
- updates: List[dict] = []
- warnings: List[str] = []
- for row in source_rows:
- cid = legacy_id_to_str(row["id"])
- if not cid:
- continue
- if not any(str(row.get(s) or "").strip() for s, _ in FIELD_MAP):
- continue
- payload = {"id": cid}
- for src_col, tgt_col in FIELD_MAP:
- raw = row.get(src_col)
- if raw is None or str(raw).strip() == "":
- payload[tgt_col] = None
- continue
- resolved = resolve_oss_id_csv(raw, oss_map) or None
- if not resolved:
- warnings.append(f"customer#{cid} {src_col}={raw!r} 未能解析为 URL")
- payload[tgt_col] = resolved
- updates.append(payload)
- return updates, warnings
- UPDATE_SQL = text(
- """
- UPDATE crm_customer SET
- business_license_img = :business_license_img,
- business_permit_images = :business_permit_images,
- other_cert_images = :other_cert_images,
- update_time = :update_time
- WHERE id = :id
- """
- )
- def write_report_csv(rows: List[dict], path: Path) -> None:
- path.parent.mkdir(parents=True, exist_ok=True)
- with path.open("w", encoding="utf-8", newline="") as f:
- w = csv.DictWriter(
- f,
- fieldnames=[
- "customer_id",
- "business_license_img",
- "business_permit_images",
- "other_cert_images",
- ],
- )
- w.writeheader()
- for r in rows:
- w.writerow(
- {
- "customer_id": r["id"],
- "business_license_img": r.get("business_license_img") or "",
- "business_permit_images": r.get("business_permit_images") or "",
- "other_cert_images": r.get("other_cert_images") or "",
- }
- )
- def main() -> int:
- parser = argparse.ArgumentParser(description="Backfill CRM customer license URLs from sys_oss")
- parser.add_argument("--source-url", default="")
- parser.add_argument("--target-url", default="")
- parser.add_argument("--tenant-no", default=DEFAULT_TENANT_NO)
- parser.add_argument("--env-file", default="")
- parser.add_argument("--dry-run", action="store_true")
- parser.add_argument(
- "--report-out",
- default=str(
- BACKEND_ROOT.parent / "docs/data-migration/mappings/crm_customer_license_url_backfill.csv"
- ),
- )
- args = parser.parse_args()
- load_settings_env(args.env_file or None)
- settings = load_runtime_settings()
- target_url = args.target_url or settings.DATABASE_URL
- source_url = args.source_url or derive_source_url(target_url, "sx")
- source_engine = create_engine(source_url, pool_pre_ping=True)
- target_engine = create_engine(target_url, pool_pre_ping=True)
- print(f"源库: {urlparse(source_url).path}")
- print(f"目标库: {urlparse(target_url).path}")
- oss_map = load_oss_map(target_engine, args.tenant_no)
- print(f"sys_oss 映射条数: {len(oss_map)}")
- source_rows = fetch_source_customers(source_engine)
- updates, warnings = build_updates(source_rows, oss_map)
- with_url = sum(
- 1
- for u in updates
- if any(u.get(t) for _, t in FIELD_MAP)
- )
- print(f"源客户: {len(source_rows)} 待回填行: {len(updates)} 至少一证有 URL: {with_url}")
- if args.dry_run:
- print("\n预览(前 5 条):")
- for u in updates[:5]:
- print(
- f" id={u['id']} license={u.get('business_license_img')!r} "
- f"permit={u.get('business_permit_images')!r} other={u.get('other_cert_images')!r}"
- )
- else:
- now = datetime.now()
- applied = 0
- missing = 0
- with target_engine.begin() as conn:
- for u in updates:
- payload = {**u, "update_time": now}
- result = conn.execute(UPDATE_SQL, payload)
- if result.rowcount:
- applied += 1
- else:
- missing += 1
- print(f"已更新: {applied} 目标无此客户: {missing}")
- report_path = Path(args.report_out)
- write_report_csv(updates, report_path)
- print(f"回填明细: {report_path}")
- if warnings:
- print(f"警告 ({len(warnings)}):")
- for w in warnings[:20]:
- print(f" - {w}")
- if len(warnings) > 20:
- print(f" ... 另有 {len(warnings) - 20} 条")
- mode = "DRY-RUN" if args.dry_run else "COMMITTED"
- print(f"\n=== 客户证照 URL 回填 [{mode}] ===")
- return 0
- if __name__ == "__main__":
- raise SystemExit(main())
|