| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650 |
- #!/usr/bin/env python3
- """将 sx 库 CRM sys_oss 迁入 shanxia2.0,并反查业务表回填 biz_type/biz_id。
- 旧 CRM ``sys_oss`` 无 ``biz_type``/``biz_id``;门户新增这两列。迁移分两步:
- 1. 整表 INSERT(id=str(oss_id),url 绝对路径→相对 object key)
- 2. 扫描业务表 OSS ID 引用,UPDATE 回填 biz;反查不到则保持 NULL(不用 sys_oper_log)
- 用法:
- cd backend
- venv/bin/python scripts/migrate_crm_sys_oss.py --dry-run --env-file .env.production
- venv/bin/python scripts/migrate_crm_sys_oss.py --env-file .env.production \\
- --user-mapping ../docs/data-migration/mappings/user_mapping_result.csv \\
- --mapping-out ../docs/data-migration/mappings/crm_oss_id_mapping.csv \\
- --backfill-out ../docs/data-migration/mappings/crm_oss_biz_backfill.csv \\
- --unresolved-out ../docs/data-migration/mappings/crm_oss_unresolved.csv
- """
- from __future__ import annotations
- import argparse
- import csv
- import json
- import os
- import re
- import sys
- from dataclasses import dataclass, field
- from datetime import datetime
- from pathlib import Path
- from typing import Any, Dict, Iterable, List, Optional, Set, 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 normalize_oss_storage_url # noqa: E402
- DEFAULT_TENANT_NO = "shanxia2.0"
- SOURCE_TABLE = "sys_oss"
- TARGET_TABLE = "sys_oss"
- CRM_ADMIN_SOURCE_USER_ID = 1
- OSS_ID_RE = re.compile(r"^\d{1,20}$")
- @dataclass
- class BackfillRule:
- table: str
- id_column: str
- field: str
- biz_type: str
- attach_type: Optional[str] = None
- value_kind: str = "csv" # csv | json_array | bigint
- BACKFILL_RULES: List[BackfillRule] = [
- BackfillRule("crm_customer", "id", "business_license_images", "crm_customer", "business_license"),
- BackfillRule("crm_customer", "id", "business_permit_images", "crm_customer", "business_permit"),
- BackfillRule("crm_customer", "id", "other_cert_images", "crm_customer", "other_cert"),
- BackfillRule("crm_customer_follow", "id", "attachments", "crm_customer_follow"),
- BackfillRule("crm_clue_follow", "id", "attachments", "crm_clue_follow"),
- BackfillRule("crm_opportunity_follow", "id", "attachments", "crm_opportunity_follow"),
- BackfillRule("crm_quote", "id", "attachments", "crm_quote"),
- BackfillRule("crm_contract", "id", "attachment_files", "crm_contract"),
- BackfillRule("crm_contract", "id", "contract_file", "crm_contract", "contract_file"),
- BackfillRule("crm_product", "id", "images", "crm_product", "image"),
- BackfillRule(
- "crm_contract_product_price_tier",
- "id",
- "letter_oss_id",
- "crm_contract_product_price_tier",
- "letter",
- value_kind="bigint",
- ),
- BackfillRule(
- "crm_contract_product_price_tier",
- "id",
- "letter_oss_ids",
- "crm_contract_product_price_tier",
- "letter",
- value_kind="json_array",
- ),
- ]
- @dataclass
- class BizRef:
- biz_type: str
- biz_id: str
- source_table: str
- source_field: str
- attach_type: Optional[str] = None
- @dataclass
- class MigrationStats:
- source_count: int = 0
- inserted: int = 0
- updated: int = 0
- skipped: int = 0
- backfilled: int = 0
- unresolved: int = 0
- conflicts: int = 0
- warnings: List[str] = field(default_factory=list)
- 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_user_mapping(path: Path) -> Tuple[Dict[int, str], Optional[str]]:
- crm_map: Dict[int, str] = {}
- admin_id: Optional[str] = None
- with path.open(encoding="utf-8-sig", newline="") as f:
- for row in csv.DictReader(f):
- new_id = (row.get("new_user_id") or "").strip()
- user_no = (row.get("user_no") or "").strip()
- crm_raw = (row.get("crm_user_id") or "").strip()
- if user_no == "admin" and new_id:
- admin_id = new_id
- if crm_raw and new_id:
- try:
- crm_map[int(crm_raw)] = new_id
- except ValueError:
- pass
- return crm_map, admin_id
- def fetch_portal_admin_id(target_engine: Engine) -> Optional[str]:
- with target_engine.connect() as conn:
- row = conn.execute(
- text("SELECT id FROM sys_user WHERE user_no='admin' AND del_flag=0 LIMIT 1")
- ).first()
- return str(row[0]) if row and row[0] else None
- def resolve_portal_user_id(
- source_user_id: Any,
- crm_user_map: Dict[int, str],
- admin_id: Optional[str],
- stats: MigrationStats,
- label: str,
- ) -> Optional[str]:
- if source_user_id is None or str(source_user_id).strip() == "":
- return None
- try:
- uid = int(source_user_id)
- except (TypeError, ValueError):
- stats.warnings.append(f"{label}: 非法用户 id {source_user_id!r}")
- return None
- if uid == CRM_ADMIN_SOURCE_USER_ID:
- return admin_id
- mapped = crm_user_map.get(uid)
- if not mapped:
- stats.warnings.append(f"{label}: crm_user_id={uid} 无映射")
- return mapped
- def fetch_source_rows(engine: Engine) -> List[dict]:
- sql = f"SELECT * FROM `{SOURCE_TABLE}` ORDER BY oss_id ASC"
- with engine.connect() as conn:
- return [dict(r._mapping) for r in conn.execute(text(sql))]
- def count_target_rows(engine: Engine, tenant_no: str) -> int:
- with engine.connect() as conn:
- return int(
- conn.execute(
- text(f"SELECT COUNT(*) FROM `{TARGET_TABLE}` WHERE tenant_no=:t"),
- {"t": tenant_no},
- ).scalar_one()
- )
- def transform_row(
- row: dict,
- tenant_no: str,
- crm_user_map: Dict[int, str],
- admin_id: Optional[str],
- stats: MigrationStats,
- ) -> dict:
- oss_id = legacy_id_to_str(row["oss_id"])
- relative_url = normalize_oss_storage_url(row.get("url"), row.get("file_name"))
- if not relative_url:
- stats.warnings.append(f"oss#{oss_id}: url 无法规范化为相对路径")
- return {
- "id": oss_id,
- "tenant_no": tenant_no,
- "biz_type": None,
- "biz_id": None,
- "file_name": (row.get("file_name") or "")[:255],
- "original_name": (row.get("original_name") or "")[:255],
- "file_suffix": (row.get("file_suffix") or "")[:10],
- "url": (relative_url or (row.get("file_name") or ""))[:500],
- "file_size": None,
- "file_type": None,
- "attach_type": "attachment",
- "sort_order": 0,
- "service": (row.get("service") or "minio")[:20],
- "ext1": row.get("ext1"),
- "order_num": 0,
- "remark": None,
- "create_by": resolve_portal_user_id(
- row.get("create_by"), crm_user_map, admin_id, stats, f"oss#{oss_id} create_by"
- ),
- "create_dept_id": "",
- "create_time": row.get("create_time") or datetime.now(),
- "update_by": resolve_portal_user_id(
- row.get("update_by"), crm_user_map, admin_id, stats, f"oss#{oss_id} update_by"
- ),
- "update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
- "del_flag": 0,
- }
- INSERT_SQL = text(
- f"""
- INSERT INTO `{TARGET_TABLE}` (
- id, tenant_no, biz_type, biz_id, file_name, original_name, file_suffix, url,
- file_size, file_type, attach_type, sort_order, service, ext1, order_num, remark,
- create_by, create_dept_id, create_time, update_by, update_time, del_flag
- ) VALUES (
- :id, :tenant_no, :biz_type, :biz_id, :file_name, :original_name, :file_suffix, :url,
- :file_size, :file_type, :attach_type, :sort_order, :service, :ext1, :order_num, :remark,
- :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
- )
- """
- )
- UPDATE_SQL = text(
- f"""
- UPDATE `{TARGET_TABLE}` SET
- file_name=:file_name, original_name=:original_name, file_suffix=:file_suffix, url=:url,
- service=:service, ext1=:ext1, tenant_no=:tenant_no,
- create_by=:create_by, update_by=:update_by, update_time=:update_time
- WHERE id=:id
- """
- )
- UPDATE_BIZ_SQL = text(
- f"""
- UPDATE `{TARGET_TABLE}` SET
- biz_type=:biz_type, biz_id=:biz_id, attach_type=:attach_type, update_time=:update_time
- WHERE id=:id AND tenant_no=:tenant_no
- AND (biz_type IS NULL OR biz_type = '')
- """
- )
- def target_row_exists(conn, row_id: str) -> bool:
- return (
- int(conn.execute(text(f"SELECT COUNT(*) FROM `{TARGET_TABLE}` WHERE id=:id"), {"id": row_id}).scalar_one())
- > 0
- )
- def parse_oss_id_tokens(raw: Any, *, value_kind: str = "csv") -> List[str]:
- if raw is None:
- return []
- if value_kind == "bigint":
- sid = legacy_id_to_str(raw)
- return [sid] if sid and OSS_ID_RE.match(sid) else []
- text_val = str(raw).strip()
- if not text_val:
- return []
- if value_kind in ("json_array", "json_oss_list") or (
- value_kind == "csv" and text_val.startswith("[")
- ):
- try:
- parsed = json.loads(text_val)
- if isinstance(parsed, list):
- tokens: List[str] = []
- for item in parsed:
- if isinstance(item, dict):
- sid = legacy_id_to_str(item.get("ossId") or item.get("oss_id"))
- if sid and OSS_ID_RE.match(sid):
- tokens.append(sid)
- else:
- token = _normalize_oss_token(str(item))
- if token:
- tokens.append(token)
- return tokens
- except json.JSONDecodeError:
- if value_kind != "csv":
- return []
- tokens: List[str] = []
- for part in text_val.split(","):
- token = _normalize_oss_token(part)
- if token:
- tokens.append(token)
- return tokens
- def _normalize_oss_token(part: str) -> Optional[str]:
- token = (part or "").strip().strip('"').strip("'")
- if not token:
- return None
- if token.startswith("http://") or token.startswith("https://"):
- return None
- if "/" in token or "\\" in token:
- return None
- if not OSS_ID_RE.match(token):
- return None
- return token
- def table_exists(engine: Engine, table_name: str) -> bool:
- with engine.connect() as conn:
- row = conn.execute(
- text(
- "SELECT COUNT(*) FROM information_schema.tables "
- "WHERE table_schema = DATABASE() AND table_name = :t"
- ),
- {"t": table_name},
- ).scalar_one()
- return int(row) > 0
- def build_biz_index(
- source_engine: Engine,
- known_oss_ids: Set[str],
- stats: MigrationStats,
- ) -> Tuple[Dict[str, BizRef], List[dict]]:
- index: Dict[str, BizRef] = {}
- conflicts: List[dict] = []
- for rule in BACKFILL_RULES:
- if not table_exists(source_engine, rule.table):
- stats.warnings.append(f"回填跳过:源库无表 {rule.table}")
- continue
- sql = f"SELECT `{rule.id_column}` AS biz_row_id, `{rule.field}` AS field_value FROM `{rule.table}`"
- with source_engine.connect() as conn:
- rows = conn.execute(text(sql)).mappings().all()
- for row in rows:
- biz_id = legacy_id_to_str(row["biz_row_id"])
- if not biz_id:
- continue
- for oss_id in parse_oss_id_tokens(row["field_value"], value_kind=rule.value_kind):
- if oss_id not in known_oss_ids:
- continue
- ref = BizRef(
- biz_type=rule.biz_type,
- biz_id=biz_id,
- source_table=rule.table,
- source_field=rule.field,
- attach_type=rule.attach_type,
- )
- existing = index.get(oss_id)
- if existing is None:
- index[oss_id] = ref
- continue
- if (
- existing.biz_type == ref.biz_type
- and existing.biz_id == ref.biz_id
- and existing.attach_type == ref.attach_type
- ):
- continue
- conflicts.append(
- {
- "oss_id": oss_id,
- "kept_biz_type": existing.biz_type,
- "kept_biz_id": existing.biz_id,
- "kept_source": f"{existing.source_table}.{existing.source_field}",
- "ignored_biz_type": ref.biz_type,
- "ignored_biz_id": ref.biz_id,
- "ignored_source": f"{ref.source_table}.{ref.field}",
- }
- )
- stats.conflicts += 1
- return index, conflicts
- def apply_biz_backfill(
- target_engine: Engine,
- biz_index: Dict[str, BizRef],
- tenant_no: str,
- *,
- dry_run: bool,
- ) -> int:
- if dry_run or not biz_index:
- return len(biz_index)
- now = datetime.now()
- updated = 0
- with target_engine.begin() as conn:
- for oss_id, ref in biz_index.items():
- result = conn.execute(
- UPDATE_BIZ_SQL,
- {
- "id": oss_id,
- "tenant_no": tenant_no,
- "biz_type": ref.biz_type,
- "biz_id": ref.biz_id,
- "attach_type": ref.attach_type or "attachment",
- "update_time": now,
- },
- )
- if result.rowcount:
- updated += 1
- return updated
- def write_mapping_csv(rows: List[dict], path: Path) -> None:
- path.parent.mkdir(parents=True, exist_ok=True)
- migrated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
- with path.open("w", encoding="utf-8", newline="") as f:
- w = csv.DictWriter(
- f,
- fieldnames=[
- "source_oss_id",
- "new_id",
- "file_name",
- "original_name",
- "url_relative",
- "migrated_at",
- ],
- )
- w.writeheader()
- for row in rows:
- sid = legacy_id_to_str(row["oss_id"])
- w.writerow(
- {
- "source_oss_id": sid,
- "new_id": sid,
- "file_name": row.get("file_name") or "",
- "original_name": row.get("original_name") or "",
- "url_relative": normalize_oss_storage_url(row.get("url"), row.get("file_name")),
- "migrated_at": migrated_at,
- }
- )
- def write_backfill_csv(biz_index: Dict[str, BizRef], 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=["oss_id", "biz_type", "biz_id", "attach_type", "source_table", "source_field"],
- )
- w.writeheader()
- for oss_id in sorted(biz_index.keys(), key=lambda x: int(x)):
- ref = biz_index[oss_id]
- w.writerow(
- {
- "oss_id": oss_id,
- "biz_type": ref.biz_type,
- "biz_id": ref.biz_id,
- "attach_type": ref.attach_type or "",
- "source_table": ref.source_table,
- "source_field": ref.source_field,
- }
- )
- def write_unresolved_csv(
- all_oss_ids: Iterable[str],
- biz_index: Dict[str, BizRef],
- path: Path,
- ) -> None:
- path.parent.mkdir(parents=True, exist_ok=True)
- unresolved = sorted(set(all_oss_ids) - set(biz_index.keys()), key=lambda x: int(x))
- with path.open("w", encoding="utf-8", newline="") as f:
- w = csv.DictWriter(f, fieldnames=["oss_id", "reason"])
- w.writeheader()
- for oss_id in unresolved:
- w.writerow({"oss_id": oss_id, "reason": "no_business_reference"})
- def print_report(stats: MigrationStats, dry_run: bool) -> None:
- mode = "DRY-RUN" if dry_run else "COMMITTED"
- print(f"\n=== CRM sys_oss 迁移 [{mode}] ===")
- print(f"源表行数: {stats.source_count}")
- print(f"插入: {stats.inserted} 更新: {stats.updated} 跳过: {stats.skipped}")
- print(f"biz 回填: {stats.backfilled} 未解析 biz: {stats.unresolved} 冲突(保留首条): {stats.conflicts}")
- if stats.warnings:
- print(f"警告 ({len(stats.warnings)}):")
- for w in stats.warnings[:15]:
- print(f" - {w}")
- if len(stats.warnings) > 15:
- print(f" ... 另有 {len(stats.warnings) - 15} 条")
- def run_migration(
- source_engine: Engine,
- target_engine: Engine,
- *,
- tenant_no: str,
- user_mapping_path: Path,
- mapping_out: Optional[Path],
- backfill_out: Optional[Path],
- unresolved_out: Optional[Path],
- dry_run: bool,
- update_existing: bool,
- skip_insert: bool,
- skip_backfill: bool,
- ) -> MigrationStats:
- stats = MigrationStats()
- crm_user_map, admin_id = load_user_mapping(user_mapping_path)
- if not admin_id:
- admin_id = fetch_portal_admin_id(target_engine)
- source_rows = fetch_source_rows(source_engine)
- stats.source_count = len(source_rows)
- known_oss_ids = {legacy_id_to_str(r["oss_id"]) for r in source_rows if legacy_id_to_str(r["oss_id"])}
- payloads = [transform_row(r, tenant_no, crm_user_map, admin_id, stats) for r in source_rows]
- if not skip_insert:
- if dry_run:
- print("\n预览(前 5 条):")
- for p in payloads[:5]:
- print(
- f" id={p['id']} url={p['url']!r} file={p['file_name']!r} "
- f"biz=({p['biz_type']},{p['biz_id']})"
- )
- stats.inserted = len(payloads)
- else:
- with target_engine.begin() as conn:
- for payload in payloads:
- rid = payload["id"]
- if target_row_exists(conn, rid):
- if update_existing:
- conn.execute(UPDATE_SQL, payload)
- stats.updated += 1
- else:
- stats.skipped += 1
- else:
- conn.execute(INSERT_SQL, payload)
- stats.inserted += 1
- biz_index: Dict[str, BizRef] = {}
- if not skip_backfill:
- biz_index, conflicts = build_biz_index(source_engine, known_oss_ids, stats)
- if conflicts and dry_run:
- print(f"\n回填冲突预览(前 3 条,共 {len(conflicts)}):")
- for item in conflicts[:3]:
- print(f" oss={item['oss_id']} kept={item['kept_source']} ignored={item['ignored_source']}")
- stats.backfilled = apply_biz_backfill(target_engine, biz_index, tenant_no, dry_run=dry_run)
- stats.unresolved = len(known_oss_ids) - len(biz_index)
- if mapping_out and not dry_run:
- write_mapping_csv(source_rows, mapping_out)
- print(f"\nID 映射已写入: {mapping_out}")
- if backfill_out:
- write_backfill_csv(biz_index, backfill_out)
- print(f"biz 回填映射已写入: {backfill_out}")
- if unresolved_out:
- write_unresolved_csv(known_oss_ids, biz_index, unresolved_out)
- print(f"未回填 oss 列表已写入: {unresolved_out}")
- print_report(stats, dry_run)
- return stats
- def main() -> int:
- parser = argparse.ArgumentParser(description="Migrate CRM sys_oss from sx to portal")
- parser.add_argument("--source-url", default="")
- parser.add_argument("--target-url", default="")
- parser.add_argument("--tenant-no", default=DEFAULT_TENANT_NO)
- parser.add_argument(
- "--user-mapping",
- default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_mapping_result.csv"),
- )
- parser.add_argument("--dry-run", action="store_true")
- parser.add_argument("--update", action="store_true", help="目标已存在同 id 时更新元数据(不含 biz 覆盖)")
- parser.add_argument("--skip-insert", action="store_true", help="仅执行 biz 回填")
- parser.add_argument("--skip-backfill", action="store_true", help="仅 INSERT,不回填 biz_type/biz_id")
- parser.add_argument("--mapping-out", default="")
- parser.add_argument("--backfill-out", default="")
- parser.add_argument("--unresolved-out", default="")
- parser.add_argument("--env-file", default=os.environ.get("ENV_FILE", ""))
- 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 os.environ.get("SX_SOURCE_DATABASE_URL") or derive_source_url(target_url)
- print(f"源库: {urlparse(source_url).path}")
- print(f"目标库: {urlparse(target_url).path}")
- print("数据源: sx.sys_oss(旧 CRM 附件元数据,共用 MinIO)")
- print(f"目标表现有 sys_oss: {count_target_rows(create_engine(target_url), args.tenant_no)}")
- default_mapping_dir = BACKEND_ROOT.parent / "docs/data-migration/mappings"
- mapping_out = Path(args.mapping_out) if args.mapping_out else default_mapping_dir / "crm_oss_id_mapping.csv"
- backfill_out = Path(args.backfill_out) if args.backfill_out else default_mapping_dir / "crm_oss_biz_backfill.csv"
- unresolved_out = (
- Path(args.unresolved_out) if args.unresolved_out else default_mapping_dir / "crm_oss_unresolved.csv"
- )
- run_migration(
- create_engine(source_url, pool_pre_ping=True),
- create_engine(target_url, pool_pre_ping=True),
- tenant_no=args.tenant_no,
- user_mapping_path=Path(args.user_mapping),
- mapping_out=mapping_out if not args.dry_run else None,
- backfill_out=backfill_out if not args.skip_backfill else None,
- unresolved_out=unresolved_out if not args.skip_backfill else None,
- dry_run=args.dry_run,
- update_existing=args.update,
- skip_insert=args.skip_insert,
- skip_backfill=args.skip_backfill,
- )
- return 0
- if __name__ == "__main__":
- raise SystemExit(main())
|