// 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 下智能选文件;EpisodeScan 在非空时扫描 视频发布 下含「实例+一/二」的子目录(兼容半角括号、文件夹名略有差异) type importRule struct { SrcRels []string Dst string FallbackScanDir string EpisodeScan string // "一" 或 "二" } // 与 sync-video-assets-to-social.sh 对齐,并增加备选路径(线上常见「实例(一)」内不叫宣传片.mov 的情况) var mappings = []importRule{ {[]string{ "宇恒一号操作计算软件实例(一)/宣传片-封面.jpg", "宇恒一号操作计算软件实例(一)/宇恒一号操作计算软件实例(一)-封面.jpg", "宇恒一号操作计算软件实例(一)/宣传片-封面.jpg", "宇恒一号操作计算软件实例(一)/宇恒一号操作计算软件实例(一)-封面.jpg", }, "video-calc-demo-1-cover.jpg", "宇恒一号操作计算软件实例(一)", "一"}, {[]string{ "宇恒一号操作计算软件实例(一)/宣传片.mov", "宇恒一号操作计算软件实例(一)/宇恒一号操作计算软件实例(一).mov", "宇恒一号操作计算软件实例(一)/宣传片.mov", "宇恒一号操作计算软件实例(一)/宇恒一号操作计算软件实例(一).mov", }, "video-calc-demo-1.mov", "宇恒一号操作计算软件实例(一)", "一"}, {[]string{ "宇恒一号操作计算软件实例(二)/宇恒一号操作计算软件实例(二)-封面.jpg", "宇恒一号操作计算软件实例(二)/宣传片-封面.jpg", "宇恒一号操作计算软件实例(二)/宇恒一号操作计算软件实例(二)-封面.jpg", "宇恒一号操作计算软件实例(二)/宣传片-封面.jpg", }, "video-calc-demo-2-cover.jpg", "宇恒一号操作计算软件实例(二)", "二"}, {[]string{ "宇恒一号操作计算软件实例(二)/宇恒一号操作计算软件实例(二).mov", "宇恒一号操作计算软件实例(二)/宣传片.mov", "宇恒一号操作计算软件实例(二)/宇恒一号操作计算软件实例(二).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 discoverEpisodeDir(videoPublish, episode string) (dirName string, ok bool) { full := "(" + episode + ")" half := "(" + episode + ")" entries, err := os.ReadDir(videoPublish) if err != nil { return "", false } var hits []string for _, e := range entries { if !e.IsDir() { continue } n := e.Name() if !strings.Contains(n, "实例") { continue } marked := strings.Contains(n, full) || strings.Contains(n, half) if !marked { continue } if episode == "一" && (strings.Contains(n, "(二)") || strings.Contains(n, "(二)")) { continue } if episode == "二" && (strings.Contains(n, "(一)") || strings.Contains(n, "(一)")) { continue } hits = append(hits, n) } if len(hits) == 0 { return "", false } if len(hits) == 1 { return hits[0], true } for _, h := range hits { if strings.Contains(h, "软件") { return h, true } } return hits[0], true } func pickMediaInDir(videoPublish, dirName string, dstFile string) (absPath, relChosen string, ok bool) { ext := strings.ToLower(filepath.Ext(dstFile)) if ext != ".mov" && ext != ".jpg" && ext != ".jpeg" { return "", "", false } dir := filepath.Join(videoPublish, filepath.FromSlash(dirName)) entries, err := os.ReadDir(dir) if err != nil { return "", "", false } type cand struct { name string size int64 } var movs, imgs []cand for _, e := range entries { if e.IsDir() { continue } ne := strings.ToLower(filepath.Ext(e.Name())) p := filepath.Join(dir, e.Name()) st, err := os.Stat(p) if err != nil { continue } sz := st.Size() if ne == ".mov" { movs = append(movs, cand{e.Name(), sz}) } if ne == ".jpg" || ne == ".jpeg" { imgs = append(imgs, cand{e.Name(), sz}) } } pickMov := func() (string, bool) { if len(movs) == 0 { return "", false } best := movs[0] for _, c := range movs[1:] { if c.size > best.size { best = c } } return best.name, true } pickImg := func() (string, bool) { if len(imgs) == 0 { return "", false } for _, c := range imgs { if strings.Contains(c.name, "封面") { return c.name, true } } best := imgs[0] for _, c := range imgs[1:] { if c.size > best.size { best = c } } return best.name, true } var name string var found bool switch ext { case ".mov": name, found = pickMov() case ".jpg", ".jpeg": name, found = pickImg() default: return "", "", false } if !found { return "", "", false } rel := filepath.ToSlash(filepath.Join(dirName, name)) return filepath.Join(videoPublish, filepath.FromSlash(rel)), rel, true } 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 } } tryDirs := []string{} if rule.FallbackScanDir != "" { tryDirs = append(tryDirs, rule.FallbackScanDir) } if rule.EpisodeScan != "" { if d, ok := discoverEpisodeDir(videoPublish, rule.EpisodeScan); ok { // 避免与固定目录重复 dup := false for _, x := range tryDirs { if x == d { dup = true break } } if !dup { tryDirs = append(tryDirs, d) } } } for _, dirName := range tryDirs { if abs, rel, ok := pickMediaInDir(videoPublish, dirName, rule.Dst); ok { return abs, rel, true } } return "", "", false } 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 episode=%s", m.Dst, m.EpisodeScan) 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() }