backfill-oss-to-db.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. #!/usr/bin/env python3
  2. """
  3. 将京东云 OSS 上的所有文件回写进 file_record 表。
  4. 策略:
  5. 1. 遍历 OSS bucket 所有对象(分页)
  6. 2. 并发下载每个对象内容,计算 SHA-256
  7. 3. 写入 file_record 表(hash 唯一,已存在则跳过)
  8. 用法:
  9. python3 scripts/backfill-oss-to-db.py
  10. python3 scripts/backfill-oss-to-db.py --dry-run # 仅统计不上传
  11. python3 scripts/backfill-oss-to-db.py --workers 20 # 并发数
  12. """
  13. import boto3
  14. import hashlib
  15. import pymysql
  16. from botocore.config import Config
  17. from concurrent.futures import ThreadPoolExecutor, as_completed
  18. import argparse
  19. import logging
  20. import threading
  21. from datetime import datetime
  22. logging.basicConfig(
  23. level=logging.INFO,
  24. format="%(asctime)s [%(levelname)s] %(message)s",
  25. datefmt="%H:%M:%S",
  26. )
  27. log = logging.getLogger("backfill")
  28. ENDPOINT = "s3.cn-north-1.jdcloud-oss.com"
  29. ACCESS_KEY = "JDC_F351EC3CB1F8593204ABCDA2CBF2"
  30. SECRET_KEY = "970C0C6985A6BF29D0C79AE3693B9899"
  31. BUCKET = "cfc"
  32. REGION = "cn-north-1"
  33. DB_HOST = "mysql-internet-cn-north-1-23feae22680e4dfa.rds.jdcloud.com"
  34. DB_PORT = 3306
  35. DB_USER = "cfc"
  36. DB_PASS = "cfc@1314"
  37. DB_NAME = "zxyj"
  38. PUBLIC_URL_PREFIX = f"https://{BUCKET}.{ENDPOINT}/"
  39. # 测试目录,跳过
  40. SKIP_PREFIXES = ("test-connectivity",)
  41. db_lock = threading.Lock()
  42. def get_s3():
  43. return boto3.client(
  44. "s3",
  45. endpoint_url=f"https://{ENDPOINT}",
  46. aws_access_key_id=ACCESS_KEY,
  47. aws_secret_access_key=SECRET_KEY,
  48. config=Config(s3={"addressing_style": "path"}, signature_version="s3v4"),
  49. region_name=REGION,
  50. )
  51. def list_all_objects(s3):
  52. """列出所有对象,返回 [{Key, Size, ContentType, LastModified}]"""
  53. objects = []
  54. marker = None
  55. while True:
  56. if marker:
  57. resp = s3.list_objects_v2(Bucket=BUCKET, MaxKeys=1000, StartAfter=marker)
  58. else:
  59. resp = s3.list_objects_v2(Bucket=BUCKET, MaxKeys=1000)
  60. if "Contents" not in resp:
  61. break
  62. for obj in resp["Contents"]:
  63. key = obj["Key"]
  64. if key.startswith(SKIP_PREFIXES):
  65. continue
  66. objects.append({"Key": key, "Size": obj["Size"], "LastModified": obj["LastModified"]})
  67. marker = key
  68. if not resp.get("IsTruncated"):
  69. break
  70. return objects
  71. def guess_content_type(key):
  72. """根据 key 扩展名猜测 Content-Type"""
  73. ext = key[key.rfind(".") + 1:].lower() if "." in key else ""
  74. mapping = {
  75. "jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png",
  76. "gif": "image/gif", "webp": "image/webp", "bmp": "image/bmp",
  77. "svg": "image/svg+xml", "pdf": "application/pdf",
  78. "mp4": "video/mp4", "avi": "video/x-msvideo", "mov": "video/quicktime",
  79. "mp3": "audio/mpeg", "wav": "audio/wav", "m4a": "audio/mp4",
  80. "txt": "text/plain", "json": "application/json", "html": "text/html",
  81. "doc": "application/msword", "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  82. "xls": "application/vnd.ms-excel", "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  83. "zip": "application/zip",
  84. }
  85. return mapping.get(ext, "application/octet-stream")
  86. def guess_file_type(content_type):
  87. if content_type.startswith("image/"):
  88. return "image"
  89. if content_type == "application/pdf" or content_type.endswith("pdf"):
  90. return "pdf"
  91. if content_type.startswith("video/"):
  92. return "video"
  93. if content_type.startswith("audio/"):
  94. return "audio"
  95. return "other"
  96. def guess_category(key):
  97. """根据 key 路径推断分类"""
  98. parts = key.split("/")
  99. if len(parts) >= 3 and parts[1] == "health-reports":
  100. return "health-reports"
  101. if len(parts) >= 2:
  102. return parts[0]
  103. return "general"
  104. def process_object(s3, obj, db):
  105. """下载对象、计算 hash、写库。返回 ('ok'|'skip'|'fail', key)"""
  106. key = obj["Key"]
  107. url = PUBLIC_URL_PREFIX + key
  108. content_type = guess_content_type(key)
  109. file_type = guess_file_type(content_type)
  110. category = guess_category(key)
  111. original_name = key.split("/")[-1]
  112. try:
  113. # 下载并计算 SHA-256
  114. resp = s3.get_object(Bucket=BUCKET, Key=key)
  115. sha = hashlib.sha256()
  116. size = 0
  117. body = resp["Body"]
  118. while True:
  119. chunk = body.read(65536)
  120. if not chunk:
  121. break
  122. sha.update(chunk)
  123. size += len(chunk)
  124. file_hash = sha.hexdigest()
  125. now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  126. with db_lock:
  127. with db.cursor() as cursor:
  128. cursor.execute("SELECT id FROM file_record WHERE hash = %s", (file_hash,))
  129. if cursor.fetchone():
  130. return "skip", key
  131. cursor.execute(
  132. "INSERT INTO file_record (hash, url, file_size, content_type, "
  133. "original_filename, category, file_type, created_at, updated_at) "
  134. "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
  135. (file_hash, url, size, content_type, original_name,
  136. category, file_type, now, now),
  137. )
  138. db.commit()
  139. return "ok", key
  140. except Exception as e:
  141. log.error("[FAIL] %s: %s", key, e)
  142. return "fail", key
  143. def main():
  144. parser = argparse.ArgumentParser(description="回写 OSS 文件到 file_record 表")
  145. parser.add_argument("--dry-run", action="store_true", help="仅统计")
  146. parser.add_argument("--workers", type=int, default=20, help="并发数")
  147. parser.add_argument("--limit", type=int, default=0, help="处理前 N 个(测试用,0=全部)")
  148. args = parser.parse_args()
  149. s3 = get_s3()
  150. log.info("连接 OSS 成功,正在列举对象...")
  151. objects = list_all_objects(s3)
  152. log.info("OSS 对象总数: %d", len(objects))
  153. if args.limit > 0:
  154. objects = objects[:args.limit]
  155. log.info("限制处理前 %d 个", args.limit)
  156. if args.dry_run:
  157. log.info("DRY-RUN 模式,仅统计不写库")
  158. by_type = {}
  159. for o in objects:
  160. ct = guess_file_type(guess_content_type(o["Key"]))
  161. by_type[ct] = by_type.get(ct, 0) + 1
  162. total_size = sum(o["Size"] for o in objects)
  163. log.info("文件类型分布: %s", by_type)
  164. log.info("文件总大小: %.1f MB", total_size / 1024 / 1024)
  165. return
  166. db = pymysql.connect(
  167. host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASS,
  168. database=DB_NAME, charset="utf8mb4",
  169. )
  170. log.info("MySQL 连接成功")
  171. ok = skip = fail = 0
  172. try:
  173. with ThreadPoolExecutor(max_workers=args.workers) as pool:
  174. futures = [pool.submit(process_object, s3, obj, db) for obj in objects]
  175. for i, fut in enumerate(as_completed(futures), 1):
  176. status, key = fut.result()
  177. if status == "ok":
  178. ok += 1
  179. elif status == "skip":
  180. skip += 1
  181. else:
  182. fail += 1
  183. if i % 200 == 0:
  184. log.info("进度: %d/%d (ok=%d skip=%d fail=%d)",
  185. i, len(objects), ok, skip, fail)
  186. finally:
  187. db.close()
  188. log.info("=" * 50)
  189. log.info("回写完成: 新增=%d, 跳过=%d, 失败=%d", ok, skip, fail)
  190. if __name__ == "__main__":
  191. main()