feat: 服务端 promotion 视频自动转码;首页宣传册预览与 mp4 配置
Made-with: Cursor
This commit is contained in:
@@ -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_assets(compose up 后执行)
|
||||
# 官网站点 MongoDB _id;pull-and-restart 会把本文件里「.env 缺失的键」自动追加到 server/.env,一般无需手改
|
||||
YH_IMPORT_PROMOTION_SITE_ID=69ba1f1f41aeb82acfd609ef
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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 删除站点资源
|
||||
|
||||
197
server/handlers/promotion_transcode.go
Normal file
197
server/handlers/promotion_transcode.go
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -225,5 +225,9 @@ func main() {
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
// 启动后扫描 uploads 下各站点 promotion 中遗留的 .mov 并补转码(与上传后异步转码逻辑一致)
|
||||
go handlers.SweepPromotionTranscodeOnStartup()
|
||||
|
||||
r.Run(":" + port)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user