| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224 |
- #!/usr/bin/env python3
- """
- 将京东云 OSS 上的所有文件回写进 file_record 表。
- 策略:
- 1. 遍历 OSS bucket 所有对象(分页)
- 2. 并发下载每个对象内容,计算 SHA-256
- 3. 写入 file_record 表(hash 唯一,已存在则跳过)
- 用法:
- python3 scripts/backfill-oss-to-db.py
- python3 scripts/backfill-oss-to-db.py --dry-run # 仅统计不上传
- python3 scripts/backfill-oss-to-db.py --workers 20 # 并发数
- """
- import boto3
- import hashlib
- import pymysql
- from botocore.config import Config
- from concurrent.futures import ThreadPoolExecutor, as_completed
- import argparse
- import logging
- import threading
- from datetime import datetime
- logging.basicConfig(
- level=logging.INFO,
- format="%(asctime)s [%(levelname)s] %(message)s",
- datefmt="%H:%M:%S",
- )
- log = logging.getLogger("backfill")
- ENDPOINT = "s3.cn-north-1.jdcloud-oss.com"
- ACCESS_KEY = "JDC_F351EC3CB1F8593204ABCDA2CBF2"
- SECRET_KEY = "970C0C6985A6BF29D0C79AE3693B9899"
- BUCKET = "cfc"
- REGION = "cn-north-1"
- DB_HOST = "mysql-internet-cn-north-1-23feae22680e4dfa.rds.jdcloud.com"
- DB_PORT = 3306
- DB_USER = "cfc"
- DB_PASS = "cfc@1314"
- DB_NAME = "zxyj"
- PUBLIC_URL_PREFIX = f"https://{BUCKET}.{ENDPOINT}/"
- # 测试目录,跳过
- SKIP_PREFIXES = ("test-connectivity",)
- db_lock = threading.Lock()
- def get_s3():
- 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_all_objects(s3):
- """列出所有对象,返回 [{Key, Size, ContentType, LastModified}]"""
- objects = []
- marker = None
- while True:
- if marker:
- resp = s3.list_objects_v2(Bucket=BUCKET, MaxKeys=1000, StartAfter=marker)
- else:
- resp = s3.list_objects_v2(Bucket=BUCKET, MaxKeys=1000)
- if "Contents" not in resp:
- break
- for obj in resp["Contents"]:
- key = obj["Key"]
- if key.startswith(SKIP_PREFIXES):
- continue
- objects.append({"Key": key, "Size": obj["Size"], "LastModified": obj["LastModified"]})
- marker = key
- if not resp.get("IsTruncated"):
- break
- return objects
- def guess_content_type(key):
- """根据 key 扩展名猜测 Content-Type"""
- ext = key[key.rfind(".") + 1:].lower() if "." in key else ""
- 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",
- "mp4": "video/mp4", "avi": "video/x-msvideo", "mov": "video/quicktime",
- "mp3": "audio/mpeg", "wav": "audio/wav", "m4a": "audio/mp4",
- "txt": "text/plain", "json": "application/json", "html": "text/html",
- "doc": "application/msword", "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
- "xls": "application/vnd.ms-excel", "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- "zip": "application/zip",
- }
- return mapping.get(ext, "application/octet-stream")
- def guess_file_type(content_type):
- if content_type.startswith("image/"):
- return "image"
- if content_type == "application/pdf" or content_type.endswith("pdf"):
- return "pdf"
- if content_type.startswith("video/"):
- return "video"
- if content_type.startswith("audio/"):
- return "audio"
- return "other"
- def guess_category(key):
- """根据 key 路径推断分类"""
- parts = key.split("/")
- if len(parts) >= 3 and parts[1] == "health-reports":
- return "health-reports"
- if len(parts) >= 2:
- return parts[0]
- return "general"
- def process_object(s3, obj, db):
- """下载对象、计算 hash、写库。返回 ('ok'|'skip'|'fail', key)"""
- key = obj["Key"]
- url = PUBLIC_URL_PREFIX + key
- content_type = guess_content_type(key)
- file_type = guess_file_type(content_type)
- category = guess_category(key)
- original_name = key.split("/")[-1]
- try:
- # 下载并计算 SHA-256
- resp = s3.get_object(Bucket=BUCKET, Key=key)
- sha = hashlib.sha256()
- size = 0
- body = resp["Body"]
- while True:
- chunk = body.read(65536)
- if not chunk:
- break
- sha.update(chunk)
- size += len(chunk)
- file_hash = sha.hexdigest()
- now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
- with db_lock:
- with db.cursor() as cursor:
- cursor.execute("SELECT id FROM file_record WHERE hash = %s", (file_hash,))
- if cursor.fetchone():
- return "skip", key
- cursor.execute(
- "INSERT INTO file_record (hash, url, file_size, content_type, "
- "original_filename, category, file_type, created_at, updated_at) "
- "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
- (file_hash, url, size, content_type, original_name,
- category, file_type, now, now),
- )
- db.commit()
- return "ok", key
- except Exception as e:
- log.error("[FAIL] %s: %s", key, e)
- return "fail", key
- def main():
- parser = argparse.ArgumentParser(description="回写 OSS 文件到 file_record 表")
- parser.add_argument("--dry-run", action="store_true", help="仅统计")
- parser.add_argument("--workers", type=int, default=20, help="并发数")
- parser.add_argument("--limit", type=int, default=0, help="处理前 N 个(测试用,0=全部)")
- args = parser.parse_args()
- s3 = get_s3()
- log.info("连接 OSS 成功,正在列举对象...")
- objects = list_all_objects(s3)
- log.info("OSS 对象总数: %d", len(objects))
- if args.limit > 0:
- objects = objects[:args.limit]
- log.info("限制处理前 %d 个", args.limit)
- if args.dry_run:
- log.info("DRY-RUN 模式,仅统计不写库")
- by_type = {}
- for o in objects:
- ct = guess_file_type(guess_content_type(o["Key"]))
- by_type[ct] = by_type.get(ct, 0) + 1
- total_size = sum(o["Size"] for o in objects)
- log.info("文件类型分布: %s", by_type)
- log.info("文件总大小: %.1f MB", total_size / 1024 / 1024)
- return
- db = pymysql.connect(
- host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASS,
- database=DB_NAME, charset="utf8mb4",
- )
- log.info("MySQL 连接成功")
- ok = skip = fail = 0
- try:
- with ThreadPoolExecutor(max_workers=args.workers) as pool:
- futures = [pool.submit(process_object, s3, obj, db) for obj in objects]
- for i, fut in enumerate(as_completed(futures), 1):
- status, key = fut.result()
- if status == "ok":
- ok += 1
- elif status == "skip":
- skip += 1
- else:
- fail += 1
- if i % 200 == 0:
- log.info("进度: %d/%d (ok=%d skip=%d fail=%d)",
- i, len(objects), ok, skip, fail)
- finally:
- db.close()
- log.info("=" * 50)
- log.info("回写完成: 新增=%d, 跳过=%d, 失败=%d", ok, skip, fail)
- if __name__ == "__main__":
- main()
|