feat: 服务端 promotion 视频自动转码;首页宣传册预览与 mp4 配置

Made-with: Cursor
This commit is contained in:
whm
2026-03-23 16:12:43 +08:00
parent d37e9a3663
commit ea90052e7e
15 changed files with 965 additions and 279 deletions

View File

@@ -0,0 +1,197 @@
package handlers
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"yh_web/server/config"
)
func skipPromotionTranscode() bool {
v := strings.TrimSpace(os.Getenv("SKIP_PROMOTION_TRANSCODE"))
return v == "1" || strings.EqualFold(v, "true")
}
func ffmpegAvailable() bool {
_, err := exec.LookPath("ffmpeg")
return err == nil
}
func isMOVUnderPromotion(relPath string, ext string) bool {
if strings.ToLower(ext) != ".mov" {
return false
}
return strings.Contains(filepath.ToSlash(relPath), "/promotion/")
}
func mp4PathForMOV(movPath string) string {
return strings.TrimSuffix(movPath, filepath.Ext(movPath)) + ".mp4"
}
func needsTranscode(movPath, mp4Path string) bool {
mi, err1 := os.Stat(movPath)
if err1 != nil || mi.IsDir() {
return false
}
pi, err2 := os.Stat(mp4Path)
if err2 != nil {
return true
}
return mi.ModTime().After(pi.ModTime())
}
// runFFmpegMOVToMP4 将 mov 转为浏览器通用 mp4与前端/脚本参数一致)
func runFFmpegMOVToMP4(ctx context.Context, movPath, mp4Path string) error {
cmd := exec.CommandContext(ctx, "ffmpeg",
"-y", "-i", movPath,
"-c:v", "libx264", "-profile:v", "high", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
mp4Path,
)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, string(out))
}
return nil
}
func relPathFromUploadRoot(uploadRoot, fullPath string) (string, error) {
r, err := filepath.Rel(uploadRoot, fullPath)
if err != nil {
return "", err
}
return filepath.ToSlash(r), nil
}
// replaceMOVWithMP4InDB 将 site_assets 中对应 .mov 记录更新为 .mp4转码成功后调用
func replaceMOVWithMP4InDB(siteID, oldRelPath, mp4FullPath string, insertedID any) {
if config.MongoClient == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("site_assets")
newRel := strings.TrimSuffix(oldRelPath, filepath.Ext(oldRelPath)) + ".mp4"
fi, err := os.Stat(mp4FullPath)
if err != nil {
log.Printf("[promotion-transcode] stat mp4: %v", err)
return
}
set := bson.M{
"file_path": newRel,
"name": filepath.Base(newRel),
"size": fi.Size(),
"content_type": "video/mp4",
}
filter := bson.M{"site_id": siteID, "file_path": oldRelPath}
if oid, ok := insertedID.(bson.ObjectID); ok && !oid.IsZero() {
filter = bson.M{"_id": oid, "site_id": siteID}
}
_, err = coll.UpdateOne(ctx, filter, bson.M{"$set": set})
if err != nil {
log.Printf("[promotion-transcode] 更新数据库失败: %v", err)
}
}
// ScheduleTranscodeAfterUpload 上传保存成功后异步promotion 下 .mov -> .mp4并更新本条 site_assets
func ScheduleTranscodeAfterUpload(siteID, relPath, movFullPath string, insertedID any) {
if skipPromotionTranscode() || !isMOVUnderPromotion(relPath, filepath.Ext(movFullPath)) {
return
}
go func() {
if !ffmpegAvailable() {
log.Printf("[promotion-transcode] 已上传 .mov 但未安装 ffmpeg无法转码: %s", relPath)
return
}
mp4Full := mp4PathForMOV(movFullPath)
if !needsTranscode(movFullPath, mp4Full) {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
defer cancel()
log.Printf("[promotion-transcode] 开始转码: %s -> %s", movFullPath, mp4Full)
if err := runFFmpegMOVToMP4(ctx, movFullPath, mp4Full); err != nil {
log.Printf("[promotion-transcode] 转码失败 %s: %v", relPath, err)
return
}
if err := os.Remove(movFullPath); err != nil {
log.Printf("[promotion-transcode] 删除原 .mov 失败(可手动删): %v", err)
}
replaceMOVWithMP4InDB(siteID, relPath, mp4Full, insertedID)
log.Printf("[promotion-transcode] 完成: %s", newRelLog(relPath))
}()
}
func newRelLog(oldRel string) string {
return strings.TrimSuffix(oldRel, filepath.Ext(oldRel)) + ".mp4"
}
// SweepPromotionTranscodeOnStartup 扫描 uploads/sites/*/promotion/**.mov补转码并同步数据库已有文件
func SweepPromotionTranscodeOnStartup() {
time.Sleep(3 * time.Second)
if skipPromotionTranscode() {
log.Println("[promotion-transcode] 启动扫描已跳过 SKIP_PROMOTION_TRANSCODE=1")
return
}
if !ffmpegAvailable() {
log.Println("[promotion-transcode] 启动扫描跳过:未找到 ffmpeg安装后可重启服务")
return
}
root := getUploadDir()
sitesDir := filepath.Join(root, "sites")
fi, err := os.Stat(sitesDir)
if err != nil || !fi.IsDir() {
return
}
entries, err := os.ReadDir(sitesDir)
if err != nil {
return
}
for _, e := range entries {
if !e.IsDir() {
continue
}
siteID := e.Name()
promoRoot := filepath.Join(sitesDir, siteID, "promotion")
_ = filepath.WalkDir(promoRoot, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if strings.ToLower(filepath.Ext(path)) != ".mov" {
return nil
}
mp4Full := mp4PathForMOV(path)
if !needsTranscode(path, mp4Full) {
return nil
}
rel, err := relPathFromUploadRoot(root, path)
if err != nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
log.Printf("[promotion-transcode] [启动补转] %s", rel)
err = runFFmpegMOVToMP4(ctx, path, mp4Full)
cancel()
if err != nil {
log.Printf("[promotion-transcode] [启动补转] 失败 %s: %v", rel, err)
return nil
}
_ = os.Remove(path)
replaceMOVWithMP4InDB(siteID, rel, mp4Full, nil)
log.Printf("[promotion-transcode] [启动补转] 完成 %s", newRelLog(rel))
return nil
})
}
}