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

@@ -9,6 +9,9 @@ GIN_MODE=release
# 对外域名CORS、日志与 nginx 反代域名一致(官网 https://yuheng.yuxindazhineng.com/
ALLOWED_ORIGINS=https://yuheng.yuxindazhineng.com
# 设为 1 时跳过 uploads 下 promotion 视频的 .mov→.mp4 转码(无 ffmpeg 时可临时关闭)
# SKIP_PROMOTION_TRANSCODE=1
# 部署时自动导入「视频发布」到 data/uploads + site_assetscompose up 后执行)
# 官网站点 MongoDB _idpull-and-restart 会把本文件里「.env 缺失的键」自动追加到 server/.env一般无需手改
YH_IMPORT_PROMOTION_SITE_ID=69ba1f1f41aeb82acfd609ef

View File

@@ -13,7 +13,8 @@ RUN CGO_ENABLED=0 go build -mod=vendor -o /app/server .
ARG REGISTRY_MIRROR=docker.m.daocloud.io/library/
FROM ${REGISTRY_MIRROR}alpine:3.19
WORKDIR /app
RUN apk add --no-cache ca-certificates tzdata
# 产品视频 .mov → .mp4 服务端转码handlers/promotion_transcode.go
RUN apk add --no-cache ca-certificates tzdata ffmpeg
ENV TZ=Asia/Shanghai
COPY --from=builder /app/server .
EXPOSE 8088

View File

@@ -1,7 +1,8 @@
# 仅运行时:不包含二进制,启动时挂载 deploy/api 到 /app/app/server 由宿主机构建
ARG REGISTRY_MIRROR=docker.m.daocloud.io/library/
FROM ${REGISTRY_MIRROR}alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
# 与编译镜像一致:挂载的二进制需在容器内调用 ffmpeg 做推广视频转码
RUN apk add --no-cache ca-certificates tzdata ffmpeg
ENV TZ=Asia/Shanghai
WORKDIR /app
EXPOSE 8088

View File

@@ -22,3 +22,8 @@ go run main.go
```
默认端口 8080
## 推广视频转码promotion 目录)
上传到 `sites/{site_id}/promotion/**.mov` 后,服务会异步转 **MP4**(需本机安装 **ffmpeg**,与 Docker 镜像一致)。启动时也会扫描遗留 `.mov` 补转码。详见 `handlers/promotion_transcode.go`
关闭:`SKIP_PROMOTION_TRANSCODE=1`

View File

@@ -306,6 +306,8 @@ func UploadSiteAsset(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"id": res.InsertedID, "file_path": doc.FilePath, "message": "上传成功"})
// promotion 目录下 .mov 由服务端异步转 .mp4 并更新本条资源(需容器/主机安装 ffmpeg
ScheduleTranscodeAfterUpload(siteID, relPath, destPath, res.InsertedID)
}
// DeleteSiteAsset 删除站点资源

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
})
}
}

View File

@@ -225,5 +225,9 @@ func main() {
if port == "" {
port = "8080"
}
// 启动后扫描 uploads 下各站点 promotion 中遗留的 .mov 并补转码(与上传后异步转码逻辑一致)
go handlers.SweepPromotionTranscodeOnStartup()
r.Run(":" + port)
}