feat: 视频发布导入 API(uploads+site_assets);首页视频先拉 routes 与 VITE_DEFAULT_SITE_ID 回退
Made-with: Cursor
This commit is contained in:
12
scripts/import-promotion-to-api.sh
Normal file
12
scripts/import-promotion-to-api.sh
Normal file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# 将 web/promotion/视频发布 导入到 data/uploads + MongoDB site_assets(无需后台手动上传)
|
||||
# 依赖:server/.env 中 MONGODB_URI、MONGODB_DB(与 API 一致);本机可连 Mongo
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/import-promotion-to-api.sh -site=你的站点MongoID
|
||||
# ./scripts/import-promotion-to-api.sh -site=xxx -src=/www/yh_web/web/promotion/视频发布 -upload=/www/yh_web/data/uploads
|
||||
# ./scripts/import-promotion-to-api.sh -site=xxx -dry-run
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT/server"
|
||||
exec go run -mod=vendor ./cmd/promotion-import/ "$@"
|
||||
31
server/cmd/promotion-import/README.md
Normal file
31
server/cmd/promotion-import/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# promotion-import
|
||||
|
||||
将 `web/promotion/视频发布/` 下映射表中的文件复制到 **`{upload}/sites/{site_id}/promotion/social/`**,并在 **`site_assets`** 集合插入记录(与后台「保留原文件名」上传到 `promotion/social` 一致)。
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `-site` | 必填,站点 MongoDB `_id` 字符串 |
|
||||
| `-src` | 可选,`视频发布` 目录;默认 `{项目根}/web/promotion/视频发布` |
|
||||
| `-upload` | 可选,上传根目录;默认 `UPLOAD_DIR` 环境变量或 `{项目根}/data/uploads` |
|
||||
| `-dry-run` | 只打印计划,不写盘、不写库 |
|
||||
|
||||
环境变量与主程序相同:`MONGODB_URI`、`MONGODB_DB`(见 `server/.env`)。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
cd server
|
||||
go run -mod=vendor ./cmd/promotion-import/ -site=69ba1f1f41aeb82acfd609ef
|
||||
```
|
||||
|
||||
Docker 部署时请在**宿主机**对挂载的 `data/uploads` 执行,路径示例:
|
||||
|
||||
```bash
|
||||
./scripts/import-promotion-to-api.sh -site=xxx \
|
||||
-src=/www/yh_web/web/promotion/视频发布 \
|
||||
-upload=/www/yh_web/data/uploads
|
||||
```
|
||||
|
||||
导入后无需重启 API;`promotion-media` 立即可读。
|
||||
229
server/cmd/promotion-import/main.go
Normal file
229
server/cmd/promotion-import/main.go
Normal 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()
|
||||
}
|
||||
@@ -3,3 +3,5 @@
|
||||
|
||||
VITE_APP_DOMAIN=https://yuheng.yuxindazhineng.com
|
||||
VITE_API_BASE=
|
||||
# 可选:与 Mongo 站点 _id 一致;静态 /promotion 缺文件时用于 promotion-media 回退(见 promotionVideos.js)
|
||||
# VITE_DEFAULT_SITE_ID=69ba1f1f41aeb82acfd609ef
|
||||
|
||||
@@ -4,3 +4,5 @@
|
||||
VITE_APP_DOMAIN=https://yuheng.yuxindazhineng.com
|
||||
# 与官网同域,接口走 /api,留空即可
|
||||
VITE_API_BASE=
|
||||
# 可选:填官网 Mongo site_id,静态 /promotion 无视频时强制走 promotion-media 回退
|
||||
# VITE_DEFAULT_SITE_ID=
|
||||
|
||||
@@ -26,12 +26,26 @@
|
||||
|
||||
## 从旧「视频发布」目录迁移
|
||||
|
||||
若本地仍有中文子目录下的素材,在项目根执行:
|
||||
**仅同步到源码 `social/`(给静态站用):**
|
||||
|
||||
```bash
|
||||
./scripts/sync-video-assets-to-social.sh
|
||||
```
|
||||
|
||||
**一键写入「统一 API」目录 + 数据库 `site_assets`(推荐线上,免后台手传):**
|
||||
|
||||
需与 API 共用 `server/.env`(`MONGODB_URI`、`MONGODB_DB`),并指定官网站点 `site_id`(与 `/api/web/routes` 一致):
|
||||
|
||||
```bash
|
||||
chmod +x scripts/import-promotion-to-api.sh
|
||||
./scripts/import-promotion-to-api.sh -site=你的站点MongoID
|
||||
# 预览:./scripts/import-promotion-to-api.sh -site=xxx -dry-run
|
||||
# 自定义路径:-src=/www/yh_web/web/promotion/视频发布 -upload=/www/yh_web/data/uploads
|
||||
```
|
||||
|
||||
效果:文件落到 `data/uploads/sites/<site_id>/promotion/social/`(英文名),MongoDB 写入与后台上传相同结构的记录,并附带 `import_source`、`source_relpath` 便于对照原中文路径。对外 URL 仍为
|
||||
`/api/web/sites/<site_id>/promotion-media/social/<文件名>`。
|
||||
|
||||
## 后台上传
|
||||
|
||||
目录:`promotion/social/`,上传上表文件名,勾选 **保留原文件名**。API 路径为
|
||||
|
||||
@@ -2,10 +2,16 @@
|
||||
|
||||
产品视频已迁移到 **`../social/`** 下 **英文文件名**,与 `web/src/data/promotionVideos.js` 一致。
|
||||
|
||||
迁移命令(在项目根):
|
||||
**只拷到源码 social(静态站):**
|
||||
|
||||
```bash
|
||||
./scripts/sync-video-assets-to-social.sh
|
||||
```
|
||||
|
||||
之后请使用 **`promotion/social/`** 维护素材;本目录可仅作本地备份或留空。
|
||||
**写入线上 uploads + 数据库(免后台手传,与 promotion-media API 一致):**
|
||||
|
||||
```bash
|
||||
./scripts/import-promotion-to-api.sh -site=你的站点MongoID
|
||||
```
|
||||
|
||||
详见 `../social/README.md` 与 `server/cmd/promotion-import/README.md`。
|
||||
|
||||
@@ -4,5 +4,7 @@
|
||||
*/
|
||||
const appDomain = import.meta.env.VITE_APP_DOMAIN || ''
|
||||
const apiBase = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '')
|
||||
/** 单站部署时可选:静态素材缺失且 /web/routes 尚未返回 site_id 时,仍可用 promotion-media 回退 */
|
||||
const defaultWebSiteId = String(import.meta.env.VITE_DEFAULT_SITE_ID || '').trim()
|
||||
|
||||
export { appDomain, apiBase }
|
||||
export { appDomain, apiBase, defaultWebSiteId }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { defaultWebSiteId } from '../config'
|
||||
import { promotionUrl, promotionMediaApiUrl } from '../utils/promotionAssets'
|
||||
|
||||
const SOCIAL = 'social'
|
||||
@@ -95,7 +96,8 @@ export async function pickPromotionAssetUrl(siteId, relPath) {
|
||||
const staticUrl = promotionUrl(relPath)
|
||||
const hasStatic = await promotionStaticUrlExists(staticUrl)
|
||||
if (hasStatic) return staticUrl
|
||||
if (siteId) return promotionMediaApiUrl(siteId, relPath)
|
||||
const sid = String(siteId || defaultWebSiteId || '').trim()
|
||||
if (sid) return promotionMediaApiUrl(sid, relPath)
|
||||
return staticUrl
|
||||
}
|
||||
|
||||
|
||||
@@ -326,7 +326,13 @@ const openFaq = ref(0)
|
||||
const webSiteId = ref(getCachedWebSiteId() || '')
|
||||
const promoVideos = ref(buildPromotionVideos(webSiteId.value))
|
||||
async function refreshPromoVideos() {
|
||||
const id = webSiteId.value || ''
|
||||
let id = webSiteId.value || ''
|
||||
// 避免首屏 watch 早于 /web/routes:无 site_id 时无法回退 API,会死盯静态 404
|
||||
if (!id) {
|
||||
await fetchWebRoutes()
|
||||
id = getCachedWebSiteId() || ''
|
||||
if (id) webSiteId.value = id
|
||||
}
|
||||
try {
|
||||
promoVideos.value = await buildPromotionVideosAsync(id)
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user