| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210 |
- #!/usr/bin/env python3
- """
- 京东云对象存储存量迁移脚本。
- 遍历本地 upload.base-dir(默认 /data/cfc-uploads)下的所有文件,
- 上传到京东云 OSS bucket cfc,保持原有目录结构。
- 用法:
- # 查看会迁移哪些文件(不实际上传)
- python3 scripts/migrate-to-jdcloud.py --dry-run
- # 执行迁移(自动跳过云端已存在的文件)
- python3 scripts/migrate-to-jdcloud.py
- # 强制覆盖云端已存在的文件
- python3 scripts/migrate-to-jdcloud.py --overwrite
- # 指定本地目录
- python3 scripts/migrate-to-jdcloud.py --local-dir /data/cfc-uploads
- """
- import os
- import sys
- import boto3
- from botocore.config import Config
- from pathlib import Path
- import argparse
- import logging
- logging.basicConfig(
- level=logging.INFO,
- format="%(asctime)s [%(levelname)s] %(message)s",
- datefmt="%H:%M:%S",
- )
- log = logging.getLogger("migrate")
- # 京东云 OSS 配置
- ENDPOINT = "s3.cn-north-1.jdcloud-oss.com"
- ACCESS_KEY = "JDC_F351EC3CB1F8593204ABCDA2CBF2"
- SECRET_KEY = "970C0C6985A6BF29D0C79AE3693B9899"
- BUCKET = "cfc"
- REGION = "cn-north-1"
- # 公网URL前缀
- PUBLIC_URL_PREFIX = f"https://{BUCKET}.{ENDPOINT}/"
- def get_client():
- return boto3.client(
- "s3",
- endpoint_url=f"https://{ENDPOINT}",
- aws_access_key_id=ACCESS_KEY,
- aws_secret_access_key=SECRET_KEY,
- config=Config(s3={"addressing_style": "path"}, signature_version="s3v4"),
- region_name=REGION,
- )
- def list_cloud_keys(client):
- """列出云端已存在的所有 key"""
- keys = set()
- marker = None
- while True:
- if marker:
- resp = client.list_objects_v2(Bucket=BUCKET, MaxKeys=1000, StartAfter=marker)
- else:
- resp = client.list_objects_v2(Bucket=BUCKET, MaxKeys=1000)
- if "Contents" not in resp:
- break
- for obj in resp["Contents"]:
- keys.add(obj["Key"])
- marker = obj["Key"]
- if not resp.get("IsTruncated"):
- break
- return keys
- def upload_file(client, local_path, key, dry_run=False, overwrite=False, cloud_keys=None):
- """上传单个文件"""
- target_url = PUBLIC_URL_PREFIX + key
- if cloud_keys and key in cloud_keys and not overwrite:
- log.info(" [SKIP] 已在云端存在: %s", target_url)
- return False
- if dry_run:
- log.info(" [DRY-RUN] 将上传: %s -> %s", local_path, target_url)
- return False
- try:
- content_type = guess_content_type(local_path)
- extra_args = {"ContentType": content_type} if content_type else {}
- client.upload_file(str(local_path), BUCKET, key, ExtraArgs=extra_args)
- log.info(" [OK] %s", target_url)
- return True
- except Exception as e:
- log.error(" [FAIL] %s: %s", local_path, e)
- return False
- def guess_content_type(path):
- """根据扩展名猜测 Content-Type"""
- ext = Path(path).suffix.lower()
- mapping = {
- ".jpg": "image/jpeg",
- ".jpeg": "image/jpeg",
- ".png": "image/png",
- ".gif": "image/gif",
- ".webp": "image/webp",
- ".bmp": "image/bmp",
- ".svg": "image/svg+xml",
- ".pdf": "application/pdf",
- ".doc": "application/msword",
- ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
- ".xls": "application/vnd.ms-excel",
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- ".mp4": "video/mp4",
- ".mp3": "audio/mpeg",
- ".wav": "audio/wav",
- ".txt": "text/plain",
- ".json": "application/json",
- ".zip": "application/zip",
- ".html": "text/html",
- ".css": "text/css",
- ".js": "application/javascript",
- }
- return mapping.get(ext, "application/octet-stream")
- def main():
- parser = argparse.ArgumentParser(description="迁移本地文件到京东云对象存储")
- parser.add_argument("--local-dir", default="/data/cfc-uploads", help="本地文件目录")
- parser.add_argument("--dry-run", action="store_true", help="仅预览,不实际上传")
- parser.add_argument("--overwrite", action="store_true", help="覆盖云端已存在的文件")
- args = parser.parse_args()
- local_dir = Path(args.local_dir)
- if not local_dir.is_dir():
- log.error("本地目录不存在: %s", local_dir)
- sys.exit(1)
- # 收集所有本地文件
- all_files = sorted(local_dir.rglob("*"))
- files = [f for f in all_files if f.is_file()]
- if not files:
- log.info("本地目录为空,无需迁移")
- return
- log.info("本地目录: %s", local_dir)
- log.info("目标 Bucket: %s", BUCKET)
- log.info("目标 Endpoint: %s", ENDPOINT)
- log.info("共发现 %d 个文件", len(files))
- if args.dry_run:
- log.info("模式: DRY-RUN(仅预览不上传)")
- elif args.overwrite:
- log.info("模式: 覆盖已存在文件")
- else:
- log.info("模式: 跳过已存在文件")
- # 连接京东云
- if not args.dry_run:
- log.info("正在连接京东云 OSS...")
- client = get_client()
- # 验证连通性
- try:
- client.head_bucket(Bucket=BUCKET)
- log.info("Bucket %s 连接成功", BUCKET)
- except Exception as e:
- log.error("Bucket 连接失败: %s", e)
- sys.exit(1)
- cloud_keys = list_cloud_keys(client) if not args.overwrite else set()
- log.info("云端已有 %d 个文件", len(cloud_keys))
- else:
- client = None
- cloud_keys = set()
- # 上传文件
- success = 0
- skipped = 0
- failed = 0
- for file_path in files:
- # 计算相对路径作为 key(保持目录结构)
- relative = file_path.relative_to(local_dir)
- key = "uploads/" + relative.as_posix()
- if client and key in cloud_keys and not args.overwrite:
- skipped += 1
- continue
- if upload_file(client, file_path, key, args.dry_run, args.overwrite, cloud_keys):
- success += 1
- else:
- failed += 1
- # 总结
- if not args.dry_run:
- log.info("=" * 50)
- log.info("迁移完成: 成功=%d, 跳过=%d, 失败=%d", success, skipped, failed)
- if failed > 0:
- log.warning("有 %d 个文件上传失败,请检查日志", failed)
- else:
- log.info("=" * 50)
- log.info("DRY-RUN 完成: 将上传 %d 个文件,跳过 %d 个(已存在)", success, skipped)
- if __name__ == "__main__":
- main()
|