// 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" } } // importRule:按顺序尝试 SrcRels;若均不存在且 FallbackScanDir 非空,则在该子目录下「仅一个 .mov/.jpg 时」自动选用(兼容实际文件夹命名与文件名不一致) type importRule struct { SrcRels []string Dst string FallbackScanDir string // 相对 视频发布/,仅当目标为视频时用 .mov;封面用 .jpg } // 与 sync-video-assets-to-social.sh 对齐,并增加备选路径(线上常见「实例(一)」内不叫宣传片.mov 的情况) var mappings = []importRule{ {[]string{ "宇恒一号操作计算软件实例(一)/宣传片-封面.jpg", "宇恒一号操作计算软件实例(一)/宇恒一号操作计算软件实例(一)-封面.jpg", }, "video-calc-demo-1-cover.jpg", "宇恒一号操作计算软件实例(一)"}, {[]string{ "宇恒一号操作计算软件实例(一)/宣传片.mov", "宇恒一号操作计算软件实例(一)/宇恒一号操作计算软件实例(一).mov", }, "video-calc-demo-1.mov", "宇恒一号操作计算软件实例(一)"}, {[]string{ "宇恒一号操作计算软件实例(二)/宇恒一号操作计算软件实例(二)-封面.jpg", "宇恒一号操作计算软件实例(二)/宣传片-封面.jpg", }, "video-calc-demo-2-cover.jpg", "宇恒一号操作计算软件实例(二)"}, {[]string{ "宇恒一号操作计算软件实例(二)/宇恒一号操作计算软件实例(二).mov", "宇恒一号操作计算软件实例(二)/宣传片.mov", }, "video-calc-demo-2.mov", "宇恒一号操作计算软件实例(二)"}, {[]string{"宇恒一号AIWord简介/宇恒一号AIWord简介-封面.jpg"}, "video-aiword-cover.jpg", "宇恒一号AIWord简介"}, {[]string{"宇恒一号AIWord简介/宇恒一号AIWord简介.mov"}, "video-aiword.mov", "宇恒一号AIWord简介"}, {[]string{"宇恒一号语音办公实例/宇恒一号语音办公实例-封面.jpg"}, "video-voice-office-cover.jpg", "宇恒一号语音办公实例"}, {[]string{"宇恒一号语音办公实例/宇恒一号语音办公实例.mov"}, "video-voice-office.mov", "宇恒一号语音办公实例"}, {[]string{"宇恒一号,AI 全自动办发票/宇恒一号,AI 全自动办发票-封面.jpg"}, "video-invoice-ai-cover.jpg", "宇恒一号,AI 全自动办发票"}, {[]string{"宇恒一号,AI 全自动办发票/宇恒一号,AI 全自动办发票.mov"}, "video-invoice-ai.mov", "宇恒一号,AI 全自动办发票"}, } func resolveSourceFile(videoPublish string, rule importRule) (absPath, relChosen string, ok bool) { for _, rel := range rule.SrcRels { p := filepath.Join(videoPublish, filepath.FromSlash(rel)) if st, err := os.Stat(p); err == nil && !st.IsDir() { return p, rel, true } } if rule.FallbackScanDir == "" { return "", "", false } ext := strings.ToLower(filepath.Ext(rule.Dst)) if ext != ".mov" && ext != ".jpg" && ext != ".jpeg" { return "", "", false } dir := filepath.Join(videoPublish, filepath.FromSlash(rule.FallbackScanDir)) entries, err := os.ReadDir(dir) if err != nil { return "", "", false } var matches []string for _, e := range entries { if e.IsDir() { continue } ne := strings.ToLower(filepath.Ext(e.Name())) if ext == ".mov" && ne == ".mov" { matches = append(matches, e.Name()) } if (ext == ".jpg" || ext == ".jpeg") && (ne == ".jpg" || ne == ".jpeg") { matches = append(matches, e.Name()) } } if len(matches) != 1 { return "", "", false } rel := filepath.ToSlash(filepath.Join(rule.FallbackScanDir, matches[0])) p := filepath.Join(videoPublish, filepath.FromSlash(rel)) return p, rel, true } 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, srcRelUsed, found := resolveSourceFile(videoPublish, m) if !found { log.Printf("SKIP 源文件不存在(已试备选路径): dst=%s dir=%s", m.Dst, m.FallbackScanDir) 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", srcRelUsed, 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": srcRelUsed, "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", srcRelUsed, 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() }