From dd05748c8556ef982cd92e94ad05d9c49f288616 Mon Sep 17 00:00:00 2001 From: whm <973418690@qq.com> Date: Sat, 21 Mar 2026 13:14:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A7=86=E9=A2=91=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=20API(uploads+site=5Fassets)=EF=BC=9B?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E8=A7=86=E9=A2=91=E5=85=88=E6=8B=89=20routes?= =?UTF-8?q?=20=E4=B8=8E=20VITE=5FDEFAULT=5FSITE=5FID=20=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- scripts/import-promotion-to-api.sh | 12 ++ server/cmd/promotion-import/README.md | 31 ++++ server/cmd/promotion-import/main.go | 229 ++++++++++++++++++++++++++ web/.env.example | 2 + web/.env.production | 2 + web/promotion/social/README.md | 16 +- web/promotion/视频发布/README.md | 10 +- web/src/config.js | 4 +- web/src/data/promotionVideos.js | 4 +- web/src/views/Home.vue | 8 +- 10 files changed, 312 insertions(+), 6 deletions(-) create mode 100644 scripts/import-promotion-to-api.sh create mode 100644 server/cmd/promotion-import/README.md create mode 100644 server/cmd/promotion-import/main.go diff --git a/scripts/import-promotion-to-api.sh b/scripts/import-promotion-to-api.sh new file mode 100644 index 0000000..777c73e --- /dev/null +++ b/scripts/import-promotion-to-api.sh @@ -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/ "$@" diff --git a/server/cmd/promotion-import/README.md b/server/cmd/promotion-import/README.md new file mode 100644 index 0000000..505872d --- /dev/null +++ b/server/cmd/promotion-import/README.md @@ -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` 立即可读。 diff --git a/server/cmd/promotion-import/main.go b/server/cmd/promotion-import/main.go new file mode 100644 index 0000000..6a78307 --- /dev/null +++ b/server/cmd/promotion-import/main.go @@ -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() +} diff --git a/web/.env.example b/web/.env.example index 9df518b..eac2d5e 100644 --- a/web/.env.example +++ b/web/.env.example @@ -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 diff --git a/web/.env.production b/web/.env.production index 353bfe1..0e3791b 100644 --- a/web/.env.production +++ b/web/.env.production @@ -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= diff --git a/web/promotion/social/README.md b/web/promotion/social/README.md index ebd0433..847f0f9 100644 --- a/web/promotion/social/README.md +++ b/web/promotion/social/README.md @@ -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//promotion/social/`(英文名),MongoDB 写入与后台上传相同结构的记录,并附带 `import_source`、`source_relpath` 便于对照原中文路径。对外 URL 仍为 +`/api/web/sites//promotion-media/social/<文件名>`。 + ## 后台上传 目录:`promotion/social/`,上传上表文件名,勾选 **保留原文件名**。API 路径为 diff --git a/web/promotion/视频发布/README.md b/web/promotion/视频发布/README.md index ae5900c..d83dda6 100644 --- a/web/promotion/视频发布/README.md +++ b/web/promotion/视频发布/README.md @@ -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`。 diff --git a/web/src/config.js b/web/src/config.js index 302ad97..aaac518 100644 --- a/web/src/config.js +++ b/web/src/config.js @@ -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 } diff --git a/web/src/data/promotionVideos.js b/web/src/data/promotionVideos.js index 55cbcb7..9a1e51e 100644 --- a/web/src/data/promotionVideos.js +++ b/web/src/data/promotionVideos.js @@ -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 } diff --git a/web/src/views/Home.vue b/web/src/views/Home.vue index 6db8f31..c570bdd 100644 --- a/web/src/views/Home.vue +++ b/web/src/views/Home.vue @@ -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 {