migrate-to-jdcloud.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. #!/usr/bin/env python3
  2. """
  3. 京东云对象存储存量迁移脚本。
  4. 遍历本地 upload.base-dir(默认 /data/cfc-uploads)下的所有文件,
  5. 上传到京东云 OSS bucket cfc,保持原有目录结构。
  6. 用法:
  7. # 查看会迁移哪些文件(不实际上传)
  8. python3 scripts/migrate-to-jdcloud.py --dry-run
  9. # 执行迁移(自动跳过云端已存在的文件)
  10. python3 scripts/migrate-to-jdcloud.py
  11. # 强制覆盖云端已存在的文件
  12. python3 scripts/migrate-to-jdcloud.py --overwrite
  13. # 指定本地目录
  14. python3 scripts/migrate-to-jdcloud.py --local-dir /data/cfc-uploads
  15. # 迁移后回填 file_record 表(用于去重)
  16. python3 scripts/migrate-to-jdcloud.py --backfill
  17. """
  18. import os
  19. import sys
  20. import boto3
  21. import hashlib
  22. import pymysql
  23. from botocore.config import Config
  24. from pathlib import Path
  25. import argparse
  26. import logging
  27. from datetime import datetime
  28. logging.basicConfig(
  29. level=logging.INFO,
  30. format="%(asctime)s [%(levelname)s] %(message)s",
  31. datefmt="%H:%M:%S",
  32. )
  33. log = logging.getLogger("migrate")
  34. # 京东云 OSS 配置
  35. ENDPOINT = "s3.cn-north-1.jdcloud-oss.com"
  36. ACCESS_KEY = "JDC_F351EC3CB1F8593204ABCDA2CBF2"
  37. SECRET_KEY = "970C0C6985A6BF29D0C79AE3693B9899"
  38. BUCKET = "cfc"
  39. REGION = "cn-north-1"
  40. # MySQL 配置(用于回填 file_record 表)
  41. DB_HOST = "mysql-internet-cn-north-1-23feae22680e4dfa.rds.jdcloud.com"
  42. DB_PORT = 3306
  43. DB_USER = "cfc"
  44. DB_PASS = "cfc@1314"
  45. DB_NAME = "zxyj"
  46. # 公网URL前缀
  47. PUBLIC_URL_PREFIX = f"https://{BUCKET}.{ENDPOINT}/"
  48. def get_client():
  49. return boto3.client(
  50. "s3",
  51. endpoint_url=f"https://{ENDPOINT}",
  52. aws_access_key_id=ACCESS_KEY,
  53. aws_secret_access_key=SECRET_KEY,
  54. config=Config(s3={"addressing_style": "path"}, signature_version="s3v4"),
  55. region_name=REGION,
  56. )
  57. def get_db():
  58. return pymysql.connect(
  59. host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASS,
  60. database=DB_NAME, charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor,
  61. )
  62. def list_cloud_keys(client):
  63. """列出云端已存在的所有 key"""
  64. keys = set()
  65. marker = None
  66. while True:
  67. if marker:
  68. resp = client.list_objects_v2(Bucket=BUCKET, MaxKeys=1000, StartAfter=marker)
  69. else:
  70. resp = client.list_objects_v2(Bucket=BUCKET, MaxKeys=1000)
  71. if "Contents" not in resp:
  72. break
  73. for obj in resp["Contents"]:
  74. keys.add(obj["Key"])
  75. marker = obj["Key"]
  76. if not resp.get("IsTruncated"):
  77. break
  78. return keys
  79. def upload_file(client, local_path, key, dry_run=False, overwrite=False, cloud_keys=None):
  80. """上传单个文件,返回 (成功与否, target_url)"""
  81. target_url = PUBLIC_URL_PREFIX + key
  82. if cloud_keys and key in cloud_keys and not overwrite:
  83. log.info(" [SKIP] 已在云端存在: %s", target_url)
  84. return False, target_url
  85. if dry_run:
  86. log.info(" [DRY-RUN] 将上传: %s -> %s", local_path, target_url)
  87. return False, target_url
  88. try:
  89. content_type = guess_content_type(local_path)
  90. extra_args = {"ContentType": content_type} if content_type else {}
  91. client.upload_file(str(local_path), BUCKET, key, ExtraArgs=extra_args)
  92. log.info(" [OK] %s", target_url)
  93. return True, target_url
  94. except Exception as e:
  95. log.error(" [FAIL] %s: %s", local_path, e)
  96. return False, target_url
  97. def guess_content_type(path):
  98. """根据扩展名猜测 Content-Type"""
  99. ext = Path(path).suffix.lower()
  100. mapping = {
  101. ".jpg": "image/jpeg",
  102. ".jpeg": "image/jpeg",
  103. ".png": "image/png",
  104. ".gif": "image/gif",
  105. ".webp": "image/webp",
  106. ".bmp": "image/bmp",
  107. ".svg": "image/svg+xml",
  108. ".pdf": "application/pdf",
  109. ".doc": "application/msword",
  110. ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  111. ".xls": "application/vnd.ms-excel",
  112. ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  113. ".mp4": "video/mp4",
  114. ".mp3": "audio/mpeg",
  115. ".wav": "audio/wav",
  116. ".txt": "text/plain",
  117. ".json": "application/json",
  118. ".zip": "application/zip",
  119. ".html": "text/html",
  120. ".css": "text/css",
  121. ".js": "application/javascript",
  122. }
  123. return mapping.get(ext, "application/octet-stream")
  124. def guess_category(local_path, local_dir):
  125. """根据目录结构猜测文件分类"""
  126. relative = local_path.relative_to(local_dir)
  127. parts = relative.parts
  128. if len(parts) >= 2:
  129. return parts[0]
  130. return "general"
  131. def backfill_file_record(db, local_path, url, local_dir):
  132. """回填 file_record 表"""
  133. category = guess_category(local_path, local_dir)
  134. file_size = local_path.stat().st_size
  135. content_type = guess_content_type(local_path)
  136. original_name = local_path.name
  137. # 计算 SHA-256
  138. sha256_hash = hashlib.sha256()
  139. with open(local_path, "rb") as f:
  140. sha256_hash.update(f.read())
  141. file_hash = sha256_hash.hexdigest()
  142. now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  143. with db.cursor() as cursor:
  144. # 检查是否已存在
  145. cursor.execute("SELECT id FROM file_record WHERE hash = %s", (file_hash,))
  146. if cursor.fetchone():
  147. log.debug(" [SKIP-BACKFILL] hash 已存在: %s", file_hash)
  148. return False
  149. sql = (
  150. "INSERT INTO file_record (hash, url, file_size, content_type, "
  151. "original_filename, category, created_at, updated_at) "
  152. "VALUES (%s, %s, %s, %s, %s, %s, %s, %s)"
  153. )
  154. cursor.execute(sql, (
  155. file_hash, url, file_size, content_type,
  156. original_name, category, now, now,
  157. ))
  158. db.commit()
  159. log.info(" [BACKFILL] %s -> %s", file_hash, url)
  160. return True
  161. def main():
  162. parser = argparse.ArgumentParser(description="迁移本地文件到京东云对象存储")
  163. parser.add_argument("--local-dir", default="/data/cfc-uploads", help="本地文件目录")
  164. parser.add_argument("--dry-run", action="store_true", help="仅预览,不实际上传")
  165. parser.add_argument("--overwrite", action="store_true", help="覆盖云端已存在的文件")
  166. parser.add_argument("--backfill", action="store_true", help="上传后回填 file_record 表(用于去重)")
  167. args = parser.parse_args()
  168. local_dir = Path(args.local_dir)
  169. if not local_dir.is_dir():
  170. log.error("本地目录不存在: %s", local_dir)
  171. sys.exit(1)
  172. # 收集所有本地文件
  173. all_files = sorted(local_dir.rglob("*"))
  174. files = [f for f in all_files if f.is_file()]
  175. if not files:
  176. log.info("本地目录为空,无需迁移")
  177. return
  178. log.info("本地目录: %s", local_dir)
  179. log.info("目标 Bucket: %s", BUCKET)
  180. log.info("目标 Endpoint: %s", ENDPOINT)
  181. log.info("共发现 %d 个文件", len(files))
  182. if args.dry_run:
  183. log.info("模式: DRY-RUN(仅预览不上传)")
  184. elif args.overwrite:
  185. log.info("模式: 覆盖已存在文件")
  186. else:
  187. log.info("模式: 跳过已存在文件")
  188. if args.backfill:
  189. log.info("回填: 启用(写入 file_record 表)")
  190. # 连接京东云
  191. if not args.dry_run:
  192. log.info("正在连接京东云 OSS...")
  193. client = get_client()
  194. try:
  195. client.head_bucket(Bucket=BUCKET)
  196. log.info("Bucket %s 连接成功", BUCKET)
  197. except Exception as e:
  198. log.error("Bucket 连接失败: %s", e)
  199. sys.exit(1)
  200. cloud_keys = list_cloud_keys(client) if not args.overwrite else set()
  201. log.info("云端已有 %d 个文件", len(cloud_keys))
  202. else:
  203. client = None
  204. cloud_keys = set()
  205. # 连接 MySQL(回填模式)
  206. db = None
  207. if args.backfill and not args.dry_run:
  208. try:
  209. db = get_db()
  210. log.info("MySQL 连接成功")
  211. except Exception as e:
  212. log.error("MySQL 连接失败: %s", e)
  213. sys.exit(1)
  214. # 上传文件
  215. success = 0
  216. skipped = 0
  217. failed = 0
  218. backfill_ok = 0
  219. backfill_skip = 0
  220. backfill_fail = 0
  221. for file_path in files:
  222. relative = file_path.relative_to(local_dir)
  223. key = "uploads/" + relative.as_posix()
  224. if client and key in cloud_keys and not args.overwrite:
  225. skipped += 1
  226. # 即使跳过上传,也回填 file_record(如果启用)
  227. if db:
  228. try:
  229. if backfill_file_record(db, file_path, PUBLIC_URL_PREFIX + key, local_dir):
  230. backfill_ok += 1
  231. else:
  232. backfill_skip += 1
  233. except Exception as e:
  234. log.error(" [BACKFILL-FAIL] %s: %s", file_path, e)
  235. backfill_fail += 1
  236. continue
  237. ok, url = upload_file(client, file_path, key, args.dry_run, args.overwrite, cloud_keys)
  238. if ok:
  239. success += 1
  240. if db:
  241. try:
  242. if backfill_file_record(db, file_path, url, local_dir):
  243. backfill_ok += 1
  244. else:
  245. backfill_skip += 1
  246. except Exception as e:
  247. log.error(" [BACKFILL-FAIL] %s: %s", file_path, e)
  248. backfill_fail += 1
  249. else:
  250. if not args.dry_run:
  251. failed += 1
  252. # 总结
  253. if not args.dry_run:
  254. log.info("=" * 50)
  255. parts = [f"成功={success}", f"跳过={skipped}", f"失败={failed}"]
  256. if db:
  257. parts.append(f"回填={backfill_ok}")
  258. parts.append(f"回填跳过={backfill_skip}")
  259. if backfill_fail:
  260. parts.append(f"回填失败={backfill_fail}")
  261. log.info("迁移完成: " + ", ".join(parts))
  262. if failed > 0:
  263. log.warning("有 %d 个文件上传失败,请检查日志", failed)
  264. if db:
  265. db.close()
  266. else:
  267. log.info("=" * 50)
  268. log.info("DRY-RUN 完成: 将上传 %d 个文件,跳过 %d 个(已存在)", success, skipped)
  269. if __name__ == "__main__":
  270. main()