Files
shanxia2.0/backend/scripts/backfill_crm_contract_attachment_urls.py
T
zlwuandCursor 10c3c5470c chore: sync local PL/CRM/BX and migration work to new Gogs host
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>
2026-08-01 09:19:21 +08:00

277 lines
9.6 KiB
Python

#!/usr/bin/env python3
"""将合同 attachment_files / contract_file 中的旧 CRM 数字 ossId 替换为 MinIO 相对路径。
门户约定:业务字段里的「ossId」实际存的是 fileUrl(相对 object key)。
迁入数据仍为雪花 ossId,会导致预签名路径变成 /ruoyi/<ossId> 而无法访问。
用法:
cd backend
venv/bin/python scripts/backfill_crm_contract_attachment_urls.py --dry-run --env-file .env.production
venv/bin/python scripts/backfill_crm_contract_attachment_urls.py --env-file .env.production
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import re
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse
from sqlalchemy import create_engine, text
from sqlalchemy.engine import Engine
BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT))
DEFAULT_TENANT_NO = "shanxia2.0"
OSS_ID_RE = re.compile(r"^\d{10,20}$")
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(engine: Engine, tenant_no: str) -> Dict[str, str]:
with 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"] and r["url"]}
def resolve_token(raw: Any, oss_map: Dict[str, str]) -> Tuple[str, bool]:
"""返回 (新值, 是否发生了 id→url 替换)。"""
token = str(raw or "").strip()
if not token:
return "", False
if token in oss_map:
return oss_map[token], True
if OSS_ID_RE.match(token):
return token, False # 未命中映射,保持原样并记警告
return token, False
def rewrite_attachment_files(raw: Optional[str], oss_map: Dict[str, str], warnings: List[str], cid: str) -> Tuple[Optional[str], int]:
if raw is None or not str(raw).strip():
return None, 0
value = str(raw).strip()
replaced = 0
if value.startswith("["):
try:
parsed = json.loads(value)
except json.JSONDecodeError:
warnings.append(f"contract#{cid}: attachment_files JSON 无效")
return value, 0
if not isinstance(parsed, list):
return value, 0
out: List[dict] = []
for item in parsed:
if isinstance(item, dict):
oid = str(item.get("ossId") or item.get("oss_id") or "").strip()
new_id, changed = resolve_token(oid, oss_map)
if changed:
replaced += 1
elif oid and OSS_ID_RE.match(oid):
warnings.append(f"contract#{cid}: ossId={oid} 无 sys_oss.url")
out.append({"ossId": new_id or oid, "category": item.get("category") or "other"})
elif isinstance(item, str):
new_id, changed = resolve_token(item, oss_map)
if changed:
replaced += 1
out.append({"ossId": new_id or item, "category": "other"})
return (json.dumps(out, ensure_ascii=False) if out else None), replaced
# 逗号分隔
parts: List[str] = []
for token in value.split(","):
token = token.strip()
if not token:
continue
new_id, changed = resolve_token(token, oss_map)
if changed:
replaced += 1
elif OSS_ID_RE.match(token):
warnings.append(f"contract#{cid}: token={token} 无 sys_oss.url")
parts.append(new_id or token)
return (",".join(parts) if parts else None), replaced
def rewrite_contract_file(raw: Optional[str], oss_map: Dict[str, str], warnings: List[str], cid: str) -> Tuple[Optional[str], int]:
if raw is None or not str(raw).strip():
return None, 0
value = str(raw).strip()
# 可能是单个 id、逗号分隔或 JSON
if value.startswith("["):
return rewrite_attachment_files(value, oss_map, warnings, cid)
new_id, changed = resolve_token(value, oss_map)
if changed:
return new_id, 1
if OSS_ID_RE.match(value):
warnings.append(f"contract#{cid}: contract_file={value} 无 sys_oss.url")
return value, 0
def main() -> int:
parser = argparse.ArgumentParser(description="Backfill contract attachment ossId → MinIO relative URL")
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_contract_attachment_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
engine = create_engine(target_url, pool_pre_ping=True)
print(f"目标库: {urlparse(target_url).path}")
oss_map = load_oss_map(engine, args.tenant_no)
print(f"sys_oss 映射条数: {len(oss_map)}")
with engine.connect() as conn:
rows = [
dict(r._mapping)
for r in conn.execute(
text(
"""
SELECT id, contract_no, attachment_files, contract_file
FROM crm_contract
WHERE del_flag = 0
AND (
(attachment_files IS NOT NULL AND attachment_files <> '')
OR (contract_file IS NOT NULL AND contract_file <> '')
)
ORDER BY id
"""
)
)
]
warnings: List[str] = []
report: List[dict] = []
updates: List[dict] = []
total_replaced = 0
for row in rows:
cid = str(row["id"])
new_att, n1 = rewrite_attachment_files(row.get("attachment_files"), oss_map, warnings, cid)
new_cf, n2 = rewrite_contract_file(row.get("contract_file"), oss_map, warnings, cid)
replaced = n1 + n2
if replaced == 0 and new_att == row.get("attachment_files") and new_cf == row.get("contract_file"):
continue
if replaced == 0:
continue
total_replaced += replaced
updates.append(
{
"id": cid,
"attachment_files": new_att,
"contract_file": new_cf,
}
)
report.append(
{
"contract_id": cid,
"contract_no": row.get("contract_no") or "",
"replaced_count": replaced,
"old_attachment_files": (row.get("attachment_files") or "")[:200],
"new_attachment_files": (new_att or "")[:200],
"old_contract_file": (row.get("contract_file") or "")[:200],
"new_contract_file": (new_cf or "")[:200],
}
)
print(f"含附件合同: {len(rows)} 需更新: {len(updates)} id→url 替换次数: {total_replaced}")
if args.dry_run:
print("\n预览(前 3 条):")
for r in report[:3]:
print(f" {r['contract_no']}: {r['old_attachment_files']!r}")
print(f" -> {r['new_attachment_files']!r}")
else:
now = datetime.now()
applied = 0
with engine.begin() as conn:
for u in updates:
result = conn.execute(
text(
"""
UPDATE crm_contract
SET attachment_files = :attachment_files,
contract_file = :contract_file,
update_time = :update_time
WHERE id = :id
"""
),
{**u, "update_time": now},
)
if result.rowcount:
applied += 1
print(f"已更新合同: {applied}")
report_path = Path(args.report_out)
report_path.parent.mkdir(parents=True, exist_ok=True)
with report_path.open("w", encoding="utf-8", newline="") as f:
w = csv.DictWriter(
f,
fieldnames=[
"contract_id",
"contract_no",
"replaced_count",
"old_attachment_files",
"new_attachment_files",
"old_contract_file",
"new_contract_file",
],
)
w.writeheader()
w.writerows(report)
print(f"明细: {report_path}")
if warnings:
print(f"警告 ({len(warnings)}):")
for w in warnings[:15]:
print(f" - {w}")
if len(warnings) > 15:
print(f" ... 另有 {len(warnings) - 15} 条")
mode = "DRY-RUN" if args.dry_run else "COMMITTED"
print(f"\n=== 合同附件 ossId→URL 回填 [{mode}] ===")
return 0
if __name__ == "__main__":
raise SystemExit(main())