// promotion-import:将 web/promotion/视频发布 下中文路径素材导入到 uploads + site_assets(与后台上传到 promotion/social 一致) // // 用法(在项目 server 目录,已配置 server/.env 中 MONGODB_URI / MONGODB_DB): // // go run -mod=vendor ./cmd/promotion-import/ -site=站点MongoID // // 或指定路径: // // go run -mod=vendor ./cmd/promotion-import/ -site=xxx -src=/www/yh_web/web/promotion/视频发布 -upload=/www/yh_web/data/uploads package main import ( "context" "flag" "fmt" "io" "log" "os" "path/filepath" "strings" "time" "yh_web/server/config" "github.com/joho/godotenv" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" ) func loadEnv() { wd, _ := os.Getwd() serverDir := wd if !strings.HasSuffix(filepath.Clean(wd), "server") { serverDir = filepath.Join(wd, "server") } envPath := filepath.Clean(filepath.Join(serverDir, ".env")) if _, err := os.Stat(envPath); err == nil { _ = godotenv.Load(envPath) log.Printf("已加载: %s", envPath) } } func mimeForExt(ext string) string { switch strings.ToLower(ext) { case ".mov": return "video/quicktime" case ".mp4": return "video/mp4" case ".webm": return "video/webm" case ".jpg", ".jpeg": return "image/jpeg" case ".png": return "image/png" default: return "application/octet-stream" } } // 与 scripts/sync-video-assets-to-social.sh 一致:源相对「视频发布」目录,目标为 promotion/social 下英文名 var mappings = []struct { SrcRel string // 相对 视频发布/ Dst string // 仅文件名,落在 promotion/social/ }{ {"宇恒一号操作计算软件实例(一)/宣传片-封面.jpg", "video-calc-demo-1-cover.jpg"}, {"宇恒一号操作计算软件实例(一)/宣传片.mov", "video-calc-demo-1.mov"}, {"宇恒一号操作计算软件实例(二)/宇恒一号操作计算软件实例(二)-封面.jpg", "video-calc-demo-2-cover.jpg"}, {"宇恒一号操作计算软件实例(二)/宇恒一号操作计算软件实例(二).mov", "video-calc-demo-2.mov"}, {"宇恒一号AIWord简介/宇恒一号AIWord简介-封面.jpg", "video-aiword-cover.jpg"}, {"宇恒一号AIWord简介/宇恒一号AIWord简介.mov", "video-aiword.mov"}, {"宇恒一号语音办公实例/宇恒一号语音办公实例-封面.jpg", "video-voice-office-cover.jpg"}, {"宇恒一号语音办公实例/宇恒一号语音办公实例.mov", "video-voice-office.mov"}, {"宇恒一号,AI 全自动办发票/宇恒一号,AI 全自动办发票-封面.jpg", "video-invoice-ai-cover.jpg"}, {"宇恒一号,AI 全自动办发票/宇恒一号,AI 全自动办发票.mov", "video-invoice-ai.mov"}, } func main() { loadEnv() siteID := flag.String("site", "", "站点 MongoDB ObjectID(必填,与 /web/routes 的 site_id 一致)") srcRoot := flag.String("src", "", "「视频发布」目录绝对路径;默认尝试项目 web/promotion/视频发布") uploadRoot := flag.String("upload", "", "上传根目录(内含 sites/);默认 data/uploads 或环境变量 UPLOAD_DIR") dryRun := flag.Bool("dry-run", false, "只打印计划,不写盘、不写库") flag.Parse() if strings.TrimSpace(*siteID) == "" { log.Fatal("请指定 -site=站点ID") } wd, _ := os.Getwd() projectRoot := wd if strings.HasSuffix(filepath.Clean(wd), "server") { projectRoot = filepath.Join(wd, "..") } projectRoot = filepath.Clean(projectRoot) videoPublish := *srcRoot if videoPublish == "" { videoPublish = filepath.Join(projectRoot, "web", "promotion", "视频发布") } videoPublish = filepath.Clean(videoPublish) uploadDir := *uploadRoot if uploadDir == "" { uploadDir = os.Getenv("UPLOAD_DIR") } if uploadDir == "" { uploadDir = filepath.Join(projectRoot, "data", "uploads") } uploadDir = filepath.Clean(uploadDir) mongoURI := os.Getenv("MONGODB_URI") if mongoURI == "" { mongoURI = "mongodb://localhost:27017" } if dbName := os.Getenv("MONGODB_DB"); dbName != "" { config.DBName = dbName } if *dryRun { log.Printf("[dry-run] 视频发布源: %s", videoPublish) log.Printf("[dry-run] 上传根: %s", uploadDir) log.Printf("[dry-run] site_id: %s", *siteID) } if !*dryRun { if err := config.ConnectMongoDB(mongoURI); err != nil { log.Fatalf("MongoDB: %v", err) } defer config.CloseMongoDB() } db := config.GetDB(config.DBName) if db == nil && !*dryRun { log.Fatal("数据库未连接") } var coll *mongo.Collection if db != nil { coll = db.Collection("site_assets") } ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() ok, skip, fail := 0, 0, 0 for _, m := range mappings { from := filepath.Join(videoPublish, filepath.FromSlash(m.SrcRel)) if _, err := os.Stat(from); err != nil { log.Printf("SKIP 源文件不存在: %s", from) skip++ continue } destDir := filepath.Join(uploadDir, "sites", *siteID, "promotion", "social") destPath := filepath.Join(destDir, m.Dst) relPath := filepath.ToSlash(filepath.Join("sites", *siteID, "promotion", "social", m.Dst)) if *dryRun { log.Printf("COPY %s -> %s | DB file_path=%s", from, destPath, relPath) ok++ continue } if err := os.MkdirAll(destDir, 0755); err != nil { log.Printf("FAIL 创建目录 %s: %v", destDir, err) fail++ continue } if err := copyFile(from, destPath); err != nil { log.Printf("FAIL 复制 %s: %v", m.SrcRel, err) fail++ continue } _ = os.Chmod(destPath, 0644) fi, _ := os.Stat(destPath) size := int64(0) if fi != nil { size = fi.Size() } ext := strings.ToLower(filepath.Ext(m.Dst)) ct := mimeForExt(ext) _, _ = coll.DeleteMany(ctx, bson.M{"site_id": *siteID, "file_path": relPath}) doc := bson.M{ "site_id": *siteID, "name": m.Dst, "file_path": relPath, "size": size, "content_type": ct, "downloadable": false, "created_at": time.Now().Format(time.RFC3339), "import_source": "video_publish_legacy", "source_relpath": m.SrcRel, "promotion_alias": filepath.ToSlash(filepath.Join("promotion", "social", m.Dst)), } if _, err := coll.InsertOne(ctx, doc); err != nil { log.Printf("FAIL 写库 %s: %v", relPath, err) fail++ continue } log.Printf("OK %s -> %s", m.SrcRel, relPath) ok++ } fmt.Printf("\n完成: 成功=%d 跳过=%d 失败=%d\n", ok, skip, fail) if fail > 0 { os.Exit(1) } } func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.Create(dst) if err != nil { return err } defer func() { _ = out.Close() }() if _, err := io.Copy(out, in); err != nil { return err } return out.Sync() }