Bundle pending backend/web-admin feature updates, data-migration scripts, and related docs so the V1.5 branch on the Tencent Cloud Gogs matches the current workspace (excluding local secrets). Co-authored-by: Cursor <cursoragent@cursor.com>
238 lines
7.6 KiB
Python
238 lines
7.6 KiB
Python
#!/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())
|