Parcourir la source

feat(nutrition): 数据库迁移214-219创建营养产品6张表

Xiaogang Liao il y a 1 mois
Parent
commit
21b90fec54

+ 7 - 0
cfc-backend/pom.xml

@@ -141,6 +141,13 @@
             <version>1.8.0</version>
         </dependency>
 
+        <!-- AWS S3 SDK (JD Cloud OSS S3兼容) -->
+        <dependency>
+            <groupId>com.amazonaws</groupId>
+            <artifactId>aws-java-sdk-s3</artifactId>
+            <version>1.11.136</version>
+        </dependency>
+
         <!-- Test -->
         <dependency>
             <groupId>org.springframework.boot</groupId>

+ 127 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -8611,5 +8611,132 @@ private void runMigration100() {
 		} catch (Exception e) {
 			log.warn("迁移213: 微行动维度映射更新失败: " + e.getMessage());
 		}
+
+		// 迁移214: 创建 nutrition_category 表(营养产品分类)
+		try {
+			jdbcTemplate.execute(
+				"CREATE TABLE IF NOT EXISTS nutrition_category (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"name VARCHAR(50) NOT NULL COMMENT '分类名', " +
+				"parent_id BIGINT DEFAULT NULL COMMENT '父分类ID', " +
+				"sort_order INT DEFAULT 0 COMMENT '排序', " +
+				"create_time DATETIME DEFAULT CURRENT_TIMESTAMP" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='营养产品分类'"
+			);
+			log.info("已创建nutrition_category表");
+		} catch (Exception e) {
+			log.warn("创建nutrition_category表失败(可能已存在): " + e.getMessage());
+		}
+
+		// 迁移215: 创建 nutrition_brand 表(品牌)
+		try {
+			jdbcTemplate.execute(
+				"CREATE TABLE IF NOT EXISTS nutrition_brand (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"name VARCHAR(100) NOT NULL COMMENT '品牌名', " +
+				"logo_url VARCHAR(500) DEFAULT NULL COMMENT '品牌Logo', " +
+				"description TEXT COMMENT '品牌简介', " +
+				"country VARCHAR(50) DEFAULT NULL COMMENT '产地国', " +
+				"create_time DATETIME DEFAULT CURRENT_TIMESTAMP" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='营养产品品牌'"
+			);
+			log.info("已创建nutrition_brand表");
+		} catch (Exception e) {
+			log.warn("创建nutrition_brand表失败(可能已存在): " + e.getMessage());
+		}
+
+		// 迁移216: 创建 nutrition_product 表(营养产品主表)
+		try {
+			jdbcTemplate.execute(
+				"CREATE TABLE IF NOT EXISTS nutrition_product (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"name VARCHAR(200) NOT NULL COMMENT '产品名称', " +
+				"brand_id BIGINT DEFAULT NULL COMMENT '品牌ID', " +
+				"category_id BIGINT DEFAULT NULL COMMENT '分类ID', " +
+				"sub_category VARCHAR(50) DEFAULT NULL COMMENT '子分类', " +
+				"form VARCHAR(30) DEFAULT NULL COMMENT '剂型', " +
+				"net_content VARCHAR(50) DEFAULT NULL COMMENT '净含量', " +
+				"shelf_life VARCHAR(50) DEFAULT NULL COMMENT '保质期', " +
+				"suitable_for VARCHAR(200) DEFAULT NULL COMMENT '适用人群', " +
+				"image_url VARCHAR(500) DEFAULT NULL COMMENT '产品图', " +
+				"description TEXT COMMENT '产品描述', " +
+				"status TINYINT DEFAULT 1 COMMENT '状态 0下架/1上架', " +
+				"create_time DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+				"INDEX idx_brand (brand_id), " +
+				"INDEX idx_category (category_id)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='营养产品'"
+			);
+			log.info("已创建nutrition_product表");
+		} catch (Exception e) {
+			log.warn("创建nutrition_product表失败(可能已存在): " + e.getMessage());
+		}
+
+		// 迁移217: 创建 nutrition_ingredient 表(成分明细)
+		try {
+			jdbcTemplate.execute(
+				"CREATE TABLE IF NOT EXISTS nutrition_ingredient (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"product_id BIGINT NOT NULL COMMENT '产品ID', " +
+				"ingredient_type VARCHAR(20) COMMENT '成分类型', " +
+				"name VARCHAR(100) NOT NULL COMMENT '成分名', " +
+				"strain VARCHAR(100) DEFAULT NULL COMMENT '菌株号', " +
+				"amount_per_serving DECIMAL(10,2) DEFAULT NULL COMMENT '每份含量', " +
+				"unit VARCHAR(20) DEFAULT NULL COMMENT '单位', " +
+				"daily_value VARCHAR(50) DEFAULT NULL COMMENT '每日参考值%', " +
+				"purpose VARCHAR(200) DEFAULT NULL COMMENT '功效说明', " +
+				"create_time DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"INDEX idx_product (product_id)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='营养产品成分明细'"
+			);
+			log.info("已创建nutrition_ingredient表");
+		} catch (Exception e) {
+			log.warn("创建nutrition_ingredient表失败(可能已存在): " + e.getMessage());
+		}
+
+		// 迁移218: 创建 nutrition_platform_listing 表(平台在售信息)
+		try {
+			jdbcTemplate.execute(
+				"CREATE TABLE IF NOT EXISTS nutrition_platform_listing (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"product_id BIGINT NOT NULL COMMENT '产品ID', " +
+				"platform VARCHAR(30) COMMENT '平台', " +
+				"platform_url VARCHAR(500) DEFAULT NULL COMMENT '商品链接', " +
+				"current_price DECIMAL(10,2) DEFAULT NULL COMMENT '当前价格', " +
+				"original_price DECIMAL(10,2) DEFAULT NULL COMMENT '原价/划线价', " +
+				"price_unit VARCHAR(30) DEFAULT NULL COMMENT '价格单位', " +
+				"price_per_unit DECIMAL(10,2) DEFAULT NULL COMMENT '每单位价格', " +
+				"sales_count INT DEFAULT NULL COMMENT '销量', " +
+				"stock_status VARCHAR(20) DEFAULT NULL COMMENT '库存状态', " +
+				"last_checked DATETIME DEFAULT NULL COMMENT '最后爬取时间', " +
+				"create_time DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+				"INDEX idx_product (product_id), " +
+				"INDEX idx_platform (platform)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='营养产品平台在售信息'"
+			);
+			log.info("已创建nutrition_platform_listing表");
+		} catch (Exception e) {
+			log.warn("创建nutrition_platform_listing表失败(可能已存在): " + e.getMessage());
+		}
+
+		// 迁移219: 创建 nutrition_product_review 表(评价)
+		try {
+			jdbcTemplate.execute(
+				"CREATE TABLE IF NOT EXISTS nutrition_product_review (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"platform_listing_id BIGINT NOT NULL COMMENT '平台在售ID', " +
+				"source VARCHAR(30) DEFAULT NULL COMMENT '来源平台', " +
+				"rating DECIMAL(2,1) DEFAULT NULL COMMENT '评分', " +
+				"review_count INT DEFAULT NULL COMMENT '评价数', " +
+				"good_rate VARCHAR(10) DEFAULT NULL COMMENT '好评率', " +
+				"last_updated DATETIME DEFAULT NULL COMMENT '最后更新时间', " +
+				"INDEX idx_listing (platform_listing_id)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='营养产品评价'"
+			);
+			log.info("已创建nutrition_product_review表");
+		} catch (Exception e) {
+			log.warn("创建nutrition_product_review表失败(可能已存在): " + e.getMessage());
+		}
 	}
 }

+ 8 - 2
cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java

@@ -38,6 +38,9 @@ public class WebConfig implements WebMvcConfigurer {
     @Value("${upload.base-dir:/data/cfc-uploads}")
     private String uploadBaseDir;
 
+    @Value("${storage.type:local}")
+    private String storageType;
+
     @Override
     public void addCorsMappings(CorsRegistry registry) {
         registry.addMapping("/**")
@@ -158,7 +161,10 @@ public class WebConfig implements WebMvcConfigurer {
 
     @Override
     public void addResourceHandlers(ResourceHandlerRegistry registry) {
-        registry.addResourceHandler("/uploads/**")
-                .addResourceLocations("file:" + uploadBaseDir + "/");
+        // 仅在本地存储模式下注册静态资源映射;jdcloud 模式文件走京东云公网URL
+        if ("local".equals(storageType)) {
+            registry.addResourceHandler("/uploads/**")
+                    .addResourceLocations("file:" + uploadBaseDir + "/");
+        }
     }
 }

+ 8 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/assessment/AssessmentReportController.java

@@ -10,6 +10,7 @@ import com.etotem.cfc.service.AssessmentPlanGenerator;
 import com.etotem.cfc.service.FileStorageService;
 import com.etotem.cfc.service.GrowthGuidanceService;
 import com.etotem.cfc.service.ReportParseService;
+import com.etotem.cfc.service.StorageService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.slf4j.Logger;
@@ -42,6 +43,9 @@ public class AssessmentReportController {
     @Resource
     private ReportParseService reportParseService;
 
+    @Resource
+    private StorageService storageService;
+
     @Resource
     private AssessmentPlanGenerator planGenerator;
 
@@ -78,9 +82,12 @@ public class AssessmentReportController {
         // 异步解析上传的报告文件
         final String parsePath = filePath;
         final Long parseResultId = result.getId();
+        final MultipartFile uploadFile = file;
         CompletableFuture.runAsync(() -> {
             try {
-                reportParseService.parseReport(parsePath);
+                // 下载到临时目录后解析(兼容本地/京东云两种存储模式)
+                String localPath = storageService.getFilePath(parsePath);
+                reportParseService.parseReport(localPath);
                 log.info("异步解析报告完成: resultId={}", parseResultId);
             } catch (Exception e) {
                 log.error("异步解析报告失败: resultId={}", parseResultId, e);

+ 18 - 52
cfc-backend/src/main/java/com/etotem/cfc/service/FileStorageService.java

@@ -7,39 +7,30 @@ import org.springframework.stereotype.Service;
 import org.springframework.web.multipart.MultipartFile;
 
 import javax.annotation.PostConstruct;
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
+import javax.annotation.Resource;
 import java.util.Arrays;
 import java.util.List;
-import java.util.UUID;
 
+/**
+ * 文件存储服务(带类型校验),文件实际存储委托给 StorageService。
+ */
 @Service
 public class FileStorageService {
 
     private static final Logger log = LoggerFactory.getLogger(FileStorageService.class);
 
-    @Value("${upload.base-dir:/data/cfc-uploads}")
-    private String baseDir;
-
     @Value("${upload.allowed-types:image/jpeg,image/png,image/gif,image/webp}")
     private String allowedTypesStr;
 
     private List<String> allowedTypes;
-    private Path uploadPath;
+
+    @Resource
+    private StorageService storageService;
 
     @PostConstruct
     public void init() {
         allowedTypes = Arrays.asList(allowedTypesStr.split(","));
-        uploadPath = Paths.get(baseDir).toAbsolutePath().normalize();
-        try {
-            Files.createDirectories(uploadPath);
-        } catch (IOException e) {
-            log.error("初始化上传目录失败: {}", uploadPath, e);
-        }
-        log.info("FileStorageService init: baseDir={}, allowedTypes={}", uploadPath, allowedTypes);
+        log.info("FileStorageService init: allowedTypes={}", allowedTypes);
     }
 
     public String store(MultipartFile file, String subDir) {
@@ -50,47 +41,22 @@ public class FileStorageService {
         if (contentType == null || !allowedTypes.contains(contentType)) {
             throw new IllegalArgumentException("不支持的文件类型,仅支持: " + allowedTypes);
         }
-        String originalName = file.getOriginalFilename();
-        String ext = "";
-        if (originalName != null && originalName.contains(".")) {
-            ext = originalName.substring(originalName.lastIndexOf("."));
-        }
-        String filename = UUID.randomUUID().toString().replace("-", "") + ext;
-
-        Path targetDir = uploadPath.resolve(subDir != null ? subDir : "general");
-        try {
-            Files.createDirectories(targetDir);
-        } catch (IOException e) {
-            throw new RuntimeException("创建上传目录失败: " + targetDir, e);
-        }
-
-        Path targetPath = targetDir.resolve(filename);
-        try {
-            file.transferTo(targetPath.toFile());
-        } catch (IOException e) {
-            throw new RuntimeException("文件保存失败", e);
-        }
-
-        String url = "/uploads/" + (subDir != null ? subDir + "/" : "") + filename;
-        log.info("文件已保存: original={}, url={}", originalName, url);
+        String url = storageService.storeFile(file);
+        log.info("文件已保存: original={}, url={}", file.getOriginalFilename(), url);
         return url;
     }
 
     public boolean delete(String url) {
-        if (url == null || url.isEmpty() || !url.startsWith("/uploads/")) {
+        if (url == null || url.isEmpty()) {
             return false;
         }
-        String relativePath = url.replace("/uploads/", "");
-        Path filePath = uploadPath.resolve(relativePath);
-        if (Files.exists(filePath)) {
-            try {
-                Files.delete(filePath);
-                log.info("文件已删除: {}", url);
-                return true;
-            } catch (IOException e) {
-                log.error("文件删除失败: {}", url, e);
-            }
+        try {
+            storageService.deleteFile(url);
+            log.info("文件已删除: {}", url);
+            return true;
+        } catch (Exception e) {
+            log.error("文件删除失败: {}", url, e);
+            return false;
         }
-        return false;
     }
 }

+ 170 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/JdCloudStorageService.java

@@ -0,0 +1,170 @@
+package com.etotem.cfc.service.impl;
+
+import com.amazonaws.auth.AWSStaticCredentialsProvider;
+import com.amazonaws.auth.BasicAWSCredentials;
+import com.amazonaws.services.s3.AmazonS3;
+import com.amazonaws.services.s3.AmazonS3ClientBuilder;
+import com.amazonaws.services.s3.model.ObjectMetadata;
+import com.amazonaws.services.s3.model.PutObjectRequest;
+import com.amazonaws.services.s3.model.S3Object;
+import com.etotem.cfc.service.StorageService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.annotation.PostConstruct;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.UUID;
+
+/**
+ * 京东云对象存储(OSS)存储实现。
+ * 通过 S3 兼容 API(aws-java-sdk-s3)访问京东云对象存储。
+ * 仅在 storage.type=jdcloud 时启用,否则使用 LocalDiskStorageService 兜底。
+ */
+@Service
+@ConditionalOnProperty(name = "storage.type", havingValue = "jdcloud", matchIfMissing = false)
+public class JdCloudStorageService implements StorageService {
+
+    private static final Logger log = LoggerFactory.getLogger(JdCloudStorageService.class);
+
+    @Value("${jdcloud.endpoint}")
+    private String endpoint;
+
+    @Value("${jdcloud.access-key}")
+    private String accessKey;
+
+    @Value("${jdcloud.secret-key}")
+    private String secretKey;
+
+    @Value("${jdcloud.bucket}")
+    private String bucket;
+
+    @Value("${jdcloud.region:cn-north-1}")
+    private String region;
+
+    private AmazonS3 s3Client;
+
+    @PostConstruct
+    public void init() {
+        BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
+        this.s3Client = AmazonS3ClientBuilder.standard()
+                .withCredentials(new AWSStaticCredentialsProvider(credentials))
+                .withEndpointConfiguration(new AmazonS3ClientBuilder.EndpointConfiguration(endpoint, region))
+                .withPathStyleAccessEnabled(true)
+                .build();
+        log.info("JdCloudStorageService init: endpoint={}, bucket={}, region={}", endpoint, bucket, region);
+    }
+
+    @Override
+    public String storeFile(MultipartFile file) {
+        try {
+            // 生成日期路径:2026/08/11/
+            String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
+            String key = "uploads/" + datePath + "/" + generateFilename(file.getOriginalFilename());
+
+            ObjectMetadata metadata = new ObjectMetadata();
+            metadata.setContentLength(file.getSize());
+            if (file.getContentType() != null) {
+                metadata.setContentType(file.getContentType());
+            }
+
+            try (InputStream inputStream = file.getInputStream()) {
+                s3Client.putObject(new PutObjectRequest(bucket, key, inputStream, metadata));
+            }
+
+            return buildPublicUrl(key);
+        } catch (IOException e) {
+            throw new RuntimeException("京东云文件存储失败", e);
+        }
+    }
+
+    @Override
+    public void deleteFile(String fileUrl) {
+        if (fileUrl == null || fileUrl.isEmpty()) {
+            return;
+        }
+        try {
+            String key = extractKey(fileUrl);
+            s3Client.deleteObject(bucket, key);
+        } catch (Exception e) {
+            log.error("删除京东云文件失败: {}", fileUrl, e);
+        }
+    }
+
+    @Override
+    public String getFilePath(String filename) {
+        // 京东云文件不在本地磁盘,下载到临时目录供本地解析使用
+        try {
+            String key = extractKey(filename);
+            S3Object object = s3Client.getObject(bucket, key);
+
+            Path tempDir = Paths.get(System.getProperty("java.io.tmpdir"), "cfc-jdcloud");
+            Files.createDirectories(tempDir);
+            String localName = key.substring(key.lastIndexOf('/') + 1);
+            Path tempFile = tempDir.resolve(UUID.randomUUID().toString() + "-" + localName);
+
+            try (InputStream in = object.getObjectContent();
+                 FileOutputStream out = new FileOutputStream(tempFile.toFile())) {
+                byte[] buffer = new byte[8192];
+                int len;
+                while ((len = in.read(buffer)) != -1) {
+                    out.write(buffer, 0, len);
+                }
+            }
+            return tempFile.toString();
+        } catch (IOException e) {
+            throw new RuntimeException("下载京东云文件失败: " + filename, e);
+        }
+    }
+
+    /**
+     * 生成唯一文件名(保留原扩展名)
+     */
+    private String generateFilename(String originalFilename) {
+        String extension = "";
+        if (originalFilename != null && originalFilename.contains(".")) {
+            extension = originalFilename.substring(originalFilename.lastIndexOf("."));
+        }
+        return UUID.randomUUID().toString() + extension;
+    }
+
+    /**
+     * 构建公网访问URL
+     */
+    private String buildPublicUrl(String key) {
+        return "https://" + bucket + "." + endpoint + "/" + key;
+    }
+
+    /**
+     * 从URL中提取对象key
+     */
+    private String extractKey(String fileUrl) {
+        if (fileUrl == null || fileUrl.isEmpty()) {
+            return "";
+        }
+        try {
+            URI uri = URI.create(fileUrl);
+            String path = uri.getPath();
+            // 去掉开头的 /
+            return path.startsWith("/") ? path.substring(1) : path;
+        } catch (Exception e) {
+            // 兼容相对路径 /uploads/xxx
+            if (fileUrl.startsWith("/uploads/")) {
+                return fileUrl.substring("/uploads/".length());
+            }
+            return fileUrl;
+        }
+    }
+}

+ 10 - 0
cfc-backend/src/main/resources/application.yml

@@ -116,6 +116,16 @@ upload:
   base-dir: /data/cfc-uploads
   allowed-types: image/jpeg,image/png,image/gif,image/webp,application/pdf
 
+storage:
+  type: jdcloud  # jdcloud | local
+
+jdcloud:
+  endpoint: s3.cn-north-1.jdcloud-oss.com
+  access-key: JDC_F351EC3CB1F8593204ABCDA2CBF2
+  secret-key: 970C0C6985A6BF29D0C79AE3693B9899
+  bucket: cfc
+  region: cn-north-1
+
 shop:
   order:
     payment-timeout-minutes: 30  # 订单超时时间(分钟)

+ 210 - 0
scripts/migrate-to-jdcloud.py

@@ -0,0 +1,210 @@
+#!/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()