migrate-to-jdcloud.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  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. """
  16. import os
  17. import sys
  18. import boto3
  19. from botocore.config import Config
  20. from pathlib import Path
  21. import argparse
  22. import logging
  23. logging.basicConfig(
  24. level=logging.INFO,
  25. format="%(asctime)s [%(levelname)s] %(message)s",
  26. datefmt="%H:%M:%S",
  27. )
  28. log = logging.getLogger("migrate")
  29. # 京东云 OSS 配置
  30. ENDPOINT = "s3.cn-north-1.jdcloud-oss.com"
  31. ACCESS_KEY = "JDC_F351EC3CB1F8593204ABCDA2CBF2"
  32. SECRET_KEY = "970C0C6985A6BF29D0C79AE3693B9899"
  33. BUCKET = "cfc"
  34. REGION = "cn-north-1"
  35. # 公网URL前缀
  36. PUBLIC_URL_PREFIX = f"https://{BUCKET}.{ENDPOINT}/"
  37. def get_client():
  38. return boto3.client(
  39. "s3",
  40. endpoint_url=f"https://{ENDPOINT}",
  41. aws_access_key_id=ACCESS_KEY,
  42. aws_secret_access_key=SECRET_KEY,
  43. config=Config(s3={"addressing_style": "path"}, signature_version="s3v4"),
  44. region_name=REGION,
  45. )
  46. def list_cloud_keys(client):
  47. """列出云端已存在的所有 key"""
  48. keys = set()
  49. marker = None
  50. while True:
  51. if marker:
  52. resp = client.list_objects_v2(Bucket=BUCKET, MaxKeys=1000, StartAfter=marker)
  53. else:
  54. resp = client.list_objects_v2(Bucket=BUCKET, MaxKeys=1000)
  55. if "Contents" not in resp:
  56. break
  57. for obj in resp["Contents"]:
  58. keys.add(obj["Key"])
  59. marker = obj["Key"]
  60. if not resp.get("IsTruncated"):
  61. break
  62. return keys
  63. def upload_file(client, local_path, key, dry_run=False, overwrite=False, cloud_keys=None):
  64. """上传单个文件"""
  65. target_url = PUBLIC_URL_PREFIX + key
  66. if cloud_keys and key in cloud_keys and not overwrite:
  67. log.info(" [SKIP] 已在云端存在: %s", target_url)
  68. return False
  69. if dry_run:
  70. log.info(" [DRY-RUN] 将上传: %s -> %s", local_path, target_url)
  71. return False
  72. try:
  73. content_type = guess_content_type(local_path)
  74. extra_args = {"ContentType": content_type} if content_type else {}
  75. client.upload_file(str(local_path), BUCKET, key, ExtraArgs=extra_args)
  76. log.info(" [OK] %s", target_url)
  77. return True
  78. except Exception as e:
  79. log.error(" [FAIL] %s: %s", local_path, e)
  80. return False
  81. def guess_content_type(path):
  82. """根据扩展名猜测 Content-Type"""
  83. ext = Path(path).suffix.lower()
  84. mapping = {
  85. ".jpg": "image/jpeg",
  86. ".jpeg": "image/jpeg",
  87. ".png": "image/png",
  88. ".gif": "image/gif",
  89. ".webp": "image/webp",
  90. ".bmp": "image/bmp",
  91. ".svg": "image/svg+xml",
  92. ".pdf": "application/pdf",
  93. ".doc": "application/msword",
  94. ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  95. ".xls": "application/vnd.ms-excel",
  96. ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  97. ".mp4": "video/mp4",
  98. ".mp3": "audio/mpeg",
  99. ".wav": "audio/wav",
  100. ".txt": "text/plain",
  101. ".json": "application/json",
  102. ".zip": "application/zip",
  103. ".html": "text/html",
  104. ".css": "text/css",
  105. ".js": "application/javascript",
  106. }
  107. return mapping.get(ext, "application/octet-stream")
  108. def main():
  109. parser = argparse.ArgumentParser(description="迁移本地文件到京东云对象存储")
  110. parser.add_argument("--local-dir", default="/data/cfc-uploads", help="本地文件目录")
  111. parser.add_argument("--dry-run", action="store_true", help="仅预览,不实际上传")
  112. parser.add_argument("--overwrite", action="store_true", help="覆盖云端已存在的文件")
  113. args = parser.parse_args()
  114. local_dir = Path(args.local_dir)
  115. if not local_dir.is_dir():
  116. log.error("本地目录不存在: %s", local_dir)
  117. sys.exit(1)
  118. # 收集所有本地文件
  119. all_files = sorted(local_dir.rglob("*"))
  120. files = [f for f in all_files if f.is_file()]
  121. if not files:
  122. log.info("本地目录为空,无需迁移")
  123. return
  124. log.info("本地目录: %s", local_dir)
  125. log.info("目标 Bucket: %s", BUCKET)
  126. log.info("目标 Endpoint: %s", ENDPOINT)
  127. log.info("共发现 %d 个文件", len(files))
  128. if args.dry_run:
  129. log.info("模式: DRY-RUN(仅预览不上传)")
  130. elif args.overwrite:
  131. log.info("模式: 覆盖已存在文件")
  132. else:
  133. log.info("模式: 跳过已存在文件")
  134. # 连接京东云
  135. if not args.dry_run:
  136. log.info("正在连接京东云 OSS...")
  137. client = get_client()
  138. # 验证连通性
  139. try:
  140. client.head_bucket(Bucket=BUCKET)
  141. log.info("Bucket %s 连接成功", BUCKET)
  142. except Exception as e:
  143. log.error("Bucket 连接失败: %s", e)
  144. sys.exit(1)
  145. cloud_keys = list_cloud_keys(client) if not args.overwrite else set()
  146. log.info("云端已有 %d 个文件", len(cloud_keys))
  147. else:
  148. client = None
  149. cloud_keys = set()
  150. # 上传文件
  151. success = 0
  152. skipped = 0
  153. failed = 0
  154. for file_path in files:
  155. # 计算相对路径作为 key(保持目录结构)
  156. relative = file_path.relative_to(local_dir)
  157. key = "uploads/" + relative.as_posix()
  158. if client and key in cloud_keys and not args.overwrite:
  159. skipped += 1
  160. continue
  161. if upload_file(client, file_path, key, args.dry_run, args.overwrite, cloud_keys):
  162. success += 1
  163. else:
  164. failed += 1
  165. # 总结
  166. if not args.dry_run:
  167. log.info("=" * 50)
  168. log.info("迁移完成: 成功=%d, 跳过=%d, 失败=%d", success, skipped, failed)
  169. if failed > 0:
  170. log.warning("有 %d 个文件上传失败,请检查日志", failed)
  171. else:
  172. log.info("=" * 50)
  173. log.info("DRY-RUN 完成: 将上传 %d 个文件,跳过 %d 个(已存在)", success, skipped)
  174. if __name__ == "__main__":
  175. main()