feat: 视频发布导入 API(uploads+site_assets);首页视频先拉 routes 与 VITE_DEFAULT_SITE_ID 回退

Made-with: Cursor
This commit is contained in:
whm
2026-03-21 13:14:02 +08:00
parent db3a8d8cd1
commit dd05748c85
10 changed files with 312 additions and 6 deletions

View File

@@ -0,0 +1,229 @@
// 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()
}