Files
web/server/cmd/promotion-import/main.go

534 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
"sort"
"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 dirHasMov(dir string) bool {
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, e := range entries {
if e.IsDir() {
continue
}
if strings.EqualFold(filepath.Ext(e.Name()), ".mov") {
return true
}
}
return false
}
func dirHasJpeg(dir string) bool {
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
if ext == ".jpg" || ext == ".jpeg" {
return true
}
}
return false
}
// 列出「操作与计算」类子目录:含「实例」、内有 .mov 或 .jpg避免仅封面无 mov 的(一)被漏掉),排除 AIWord/语音/发票等
func listCalcInstanceDirsForPairing(videoPublish string) []string {
entries, err := os.ReadDir(videoPublish)
if err != nil {
return nil
}
skipSubstr := []string{"AIWord", "语音", "发票", "全自动"}
var out []string
outer:
for _, e := range entries {
if !e.IsDir() {
continue
}
n := e.Name()
if !strings.Contains(n, "实例") {
continue
}
for _, s := range skipSubstr {
if strings.Contains(n, s) {
continue outer
}
}
sub := filepath.Join(videoPublish, n)
if !dirHasMov(sub) && !dirHasJpeg(sub) {
continue
}
out = append(out, n)
}
return out
}
// 将目录排序为 demo-1 在前、demo-2 在后(优先认全角/半角「一」「二」标记)
func orderCalcDirsForDemo12(dirs []string) []string {
if len(dirs) <= 1 {
return dirs
}
type scored struct {
name string
prio int
}
var xs []scored
for _, d := range dirs {
p := 100
switch {
case strings.Contains(d, "(一)") || strings.Contains(d, "(一)"):
p = 1
case strings.Contains(d, "(二)") || strings.Contains(d, "(二)"):
p = 2
case strings.Contains(d, "一") && !strings.Contains(d, "二"):
p = 5
case strings.Contains(d, "二"):
p = 6
}
xs = append(xs, scored{d, p})
}
sort.Slice(xs, func(i, j int) bool {
if xs[i].prio != xs[j].prio {
return xs[i].prio < xs[j].prio
}
return xs[i].name < xs[j].name
})
out := make([]string, len(xs))
for i, x := range xs {
out[i] = x.name
}
return out
}
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, calcPair []string) (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
}
}
// 恰好两个「实例」类目录且无法按名称命中时:排序后第 1 个 -> demo-1第 2 个 -> demo-2
if rule.EpisodeScan == "一" && len(calcPair) >= 2 {
if abs, rel, ok := pickMediaInDir(videoPublish, calcPair[0], rule.Dst); ok {
return abs, rel, true
}
}
if rule.EpisodeScan == "一" && len(calcPair) == 1 {
if abs, rel, ok := pickMediaInDir(videoPublish, calcPair[0], rule.Dst); ok {
return abs, rel, true
}
}
if rule.EpisodeScan == "二" && len(calcPair) >= 2 {
if abs, rel, ok := pickMediaInDir(videoPublish, calcPair[1], 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)
calcPair := orderCalcDirsForDemo12(listCalcInstanceDirsForPairing(videoPublish))
if *dryRun && len(calcPair) > 0 {
log.Printf("[dry-run] 操作与计算类目录配对顺序(1<-[0], 2<-[1]): %v", calcPair)
}
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, calcPair)
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()
}