Merge pull request 'fix: P0 upload 404 (project/library mismatch) + worker 3 bugs + 秒传 URL' (#2109) from fix/2109-p0-upload-404-and-worker-bugs into develop
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 36s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m53s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 3m42s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m44s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 25s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m29s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 50s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 7m14s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 5m31s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 4m20s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m46s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 3m15s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m50s
CI/CD Pipeline / Validate - Style (push) Successful in 23m16s
CI/CD Pipeline / Integration Tests (push) Successful in 22m20s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 27m47s
CI/CD Pipeline / Unit Tests (push) Failing after 35m7s
CI/CD Pipeline / Validate - Security (push) Successful in 44m31s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped

fix: P0 upload 404 + worker 3 bugs + duplicated URL + deploy override cleanup
This commit was merged in pull request #2109.
This commit is contained in:
2026-09-30 23:31:50 +08:00
12 changed files with 149 additions and 75 deletions
+2
View File
@@ -390,6 +390,7 @@ async def prepare_direct_upload(
duplicated=True,
skip_transfer=True,
asset_id=existing.id,
url=existing.file_url or storage_service.get_url(existing.storage_key) or "",
)
file_id = uuid4().hex[:8]
@@ -443,6 +444,7 @@ async def prepare_direct_upload(
duplicated=False,
skip_transfer=False,
asset_id=pending_asset_id,
url="",
)
+2
View File
@@ -29,6 +29,8 @@ class DirectUploadPrepareResponse(BaseModel):
duplicated: bool = False
skip_transfer: bool = False
asset_id: str = ""
# duplicated=true 时填充已存在素材的公网 URL,前端可直接用而不必再调 complete
url: str = Field(default="", description="duplicated=true 时已存在素材的公网 URL")
class DirectUploadCompleteRequest(BaseModel):
+2
View File
@@ -150,6 +150,8 @@ export interface DirectUploadPrepareResult {
* 两个字段是同一语义的别名(后端可能只返回其一),前端任意为 true 即视为命中去重。
*/
skip_transfer?: boolean
/** duplicated=true 时后端返回已存在素材的公网 URL,前端直接用而不必再调 complete */
url?: string
}
/** 直传完成确认返回 */
+47 -6
View File
@@ -3,9 +3,24 @@
*/
import apiClient from "../client"
import { getOrCreateDefaultProject } from "../projects"
import { ensureDefaultLibrary } from "./libraries"
import type { DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types"
import { computeFileHash, makeClientUploadId } from "./uploadDedup"
/** 根据 File.type 推断素材库 kind(image/video/voice);无法推断时默认 image */
function inferKindFromFile(file: File): "image" | "video" | "voice" {
const t = (file.type || "").toLowerCase()
if (t.startsWith("image/")) return "image"
if (t.startsWith("video/")) return "video"
if (t.startsWith("audio/")) return "voice"
// 兜底:按扩展名再判一次
const name = file.name.toLowerCase()
if (/\.(png|jpe?g|gif|webp|bmp|svg|avif)$/.test(name)) return "image"
if (/\.(mp4|mov|webm|avi|mkv|flv|wmv|m4v)$/.test(name)) return "video"
if (/\.(mp3|wav|m4a|aac|ogg|flac|opus|webm)$/.test(name)) return "voice"
return "image"
}
/** 预签名直传准备 */
export const prepareDirectUpload = async (data: {
project_id: string
@@ -108,6 +123,8 @@ const putToOSS = (
/** 单个文件的上传阶段信息(供批量上传队列做状态绑定) */
export interface DirectUploadHandle {
/** 实际使用的素材库(内部解析出来,便于调用方做后续 UI/缓存操作) */
library: { id: string; kind: "image" | "video" | "voice" }
/** prepare 返回(含可能的预建 asset_id) */
prepared: DirectUploadPrepareResult
/** 直传 OSS(可重复调用用于重试) */
@@ -119,10 +136,17 @@ export interface DirectUploadHandle {
/**
* 准备一次直传:调 prepare 拿到签名表单(后端可能同时预建 uploading 态 asset),
* 返回分段执行的 handle,调用方自行控制 transfer/complete 时机(便于队列并发与重试)。
*
* 修复 P0 404:library_id 改为可选;未传时自动根据文件类型在默认项目下确保对应素材库存在,
* 避免调用方从「全部素材库列表」里挑一个 library_id、但与默认项目 project_id 不匹配,
* 导致后端返回 "Asset library not found" 404。
*/
export const prepareDirectUploadHandle = async (data: {
file: File
library_id: string
/** 素材库 ID;未传时按文件类型自动在默认项目下 ensure-default */
library_id?: string
/** 显式指定素材库 kind;未传时按 MIME/扩展名推断 */
kind?: "image" | "video" | "voice"
/** 前端算好的文件内容哈希(SHA-256 hex),prepare/complete 均携带 */
fileHash?: string
/** 本次逻辑上传的幂等 token,prepare/complete 一致、重试复用 */
@@ -138,9 +162,17 @@ export const prepareDirectUploadHandle = async (data: {
throw new Error(`初始化默认项目失败,无法开始上传:${reason}`)
}
// 解析 library_id:调用方传了就用,没传就按 kind 自动 ensure-default
let resolvedLibraryId = data.library_id
const resolvedKind = data.kind ?? inferKindFromFile(data.file)
if (!resolvedLibraryId) {
const lib = await ensureDefaultLibrary({ project_id: project.id, kind: resolvedKind })
resolvedLibraryId = lib.id
}
const prepared = await prepareDirectUpload({
project_id: project.id,
library_id: data.library_id,
library_id: resolvedLibraryId,
filename: data.file.name,
content_type: data.file.type || "application/octet-stream",
file_size: data.file.size,
@@ -149,12 +181,13 @@ export const prepareDirectUploadHandle = async (data: {
})
return {
library: { id: resolvedLibraryId, kind: resolvedKind },
prepared,
transfer: (onProgress) => putToOSS(prepared, data.file, onProgress),
complete: () =>
completeDirectUpload({
project_id: project.id,
library_id: data.library_id,
library_id: resolvedLibraryId,
storage_key: prepared.storage_key,
file_hash: data.fileHash,
client_upload_id: data.clientUploadId,
@@ -164,10 +197,17 @@ export const prepareDirectUploadHandle = async (data: {
}
}
/** 直传上传(大文件推荐),支持可选进度回调;一次性完成 prepare→transfer→complete */
/** 直传上传(大文件推荐),支持可选进度回调;一次性完成 prepare→transfer→complete
*
* P0 404 修复:library_id 可选;不传时内部按文件类型自动匹配正确项目下的素材库,
* 保证 project_id 与 library_id 必然一致。
*/
export const uploadAssetDirect = async (data: {
file: File
library_id: string
/** 素材库 ID;可选,不传按文件类型自动解析默认项目下的对应素材库(推荐用法) */
library_id?: string
/** 显式指定素材库 kind;未传时按文件 MIME/扩展名推断 */
kind?: "image" | "video" | "voice"
onProgress?: (percent: number) => void
/** 文件内容哈希;未传时自动补算(配音/封面/克隆等非队列链路统一受益) */
fileHash?: string
@@ -180,6 +220,7 @@ export const uploadAssetDirect = async (data: {
const handle = await prepareDirectUploadHandle({
file: data.file,
library_id: data.library_id,
kind: data.kind,
fileHash,
clientUploadId,
})
@@ -188,7 +229,7 @@ export const uploadAssetDirect = async (data: {
return {
storage_key: handle.prepared.storage_key,
ingest_job_id: "",
url: "",
url: handle.prepared.url || "",
duplicated: true,
asset_id: handle.prepared.asset_id,
}
@@ -17,7 +17,7 @@ import CoverSettingsModal from "./cover-settings/CoverSettingsModal"
import CoverEditorModal from "./cover-settings/CoverEditorModal"
import { useSharedCover } from "@/components/cover/useSharedCover"
import { generateCover as apiGenerateCover } from "@/api/generation"
import { uploadAssetDirect, getAssetLibraries } from "@/api/assets"
import { uploadAssetDirect } from "@/api/assets"
interface Step6CoverSettingsProps {
coverSettings: CoverConfig
@@ -176,15 +176,8 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
thumbnail_url: previewUrl,
mode: "upload",
})
// 查找图片素材库(复用批量封面的逻辑)
const libs = await getAssetLibraries()
const imageLib = libs.find((l) => l.kind === "image") || libs[0]
if (!imageLib) {
hide()
message.error("未找到素材库,请先创建图片素材库")
return previewUrl
}
const result = await uploadAssetDirect({ file, library_id: imageLib.id })
// 后端自动在默认项目下确保图片素材库存在(P0 404 修复)
const result = await uploadAssetDirect({ file, kind: "image" })
const realUrl = result?.url || ""
if (!realUrl) {
hide()
@@ -12,7 +12,7 @@
import { useCallback, useState } from "react"
import { message } from "antd"
import { generateCover } from "@/api/generation"
import { uploadAssetDirect, getAssetLibraries } from "@/api/assets"
import { uploadAssetDirect } from "@/api/assets"
import type { GeneratedVideo } from "@/api/template-editor"
/** onCoversChange 支持直接传值或函数式 updater(函数式用于串行回写避免闭包覆盖) */
@@ -182,15 +182,9 @@ export function useBatchCovers({
async (index: number, file: File) => {
addUploading(index)
try {
const libs = await getAssetLibraries()
const imageLib = libs.find((l) => l.kind === "image") || libs[0]
if (!imageLib) {
message.error("未找到素材库,请先创建")
return
}
const result = await uploadAssetDirect({
file,
library_id: imageLib.id,
kind: "image",
})
const url = result?.url || ""
if (url) {
@@ -15,7 +15,7 @@ import {
AudioOutlined,
} from "@ant-design/icons"
import { Button as AntdButton, message, Select } from "antd"
import { uploadAssetDirect, getAssetLibraries, getAssetsByKind } from "@/api/assets"
import { uploadAssetDirect, getAssetsByKind } from "@/api/assets"
import {
FUSION_LEVELS,
STYLE_STRENGTHS,
@@ -238,12 +238,9 @@ const ViralVideoPage: React.FC = () => {
kind: "image" | "video" | "voice",
onProgress?: (pct: number) => Promise<void> | void,
) => {
const libs = await getAssetLibraries()
const lib = libs.find((l: { kind: string; id: string }) => l.kind === kind) || libs[0]
if (!lib) throw new Error("未找到素材库")
const res = await uploadAssetDirect({
file,
library_id: lib.id,
kind,
onProgress: (p: number) => onProgress?.(Math.round(p * 100)),
})
return res.url
@@ -1,25 +1,26 @@
import { useState, useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import {
uploadAssetDirect,
getAssetLibraries,
getIngestJob,
type AssetLibraryItem,
} from "@/api/assets"
import { uploadAssetDirect, getIngestJob, type AssetLibraryItem } from "@/api/assets"
import { tagAsset } from "@/api/tags"
import { type VoiceGender, type VoiceMaterial } from "../../../types"
interface UseVoiceUploadOptions {
voiceLibrary?: { id: string; kind: string }
createLibMutation: { mutateAsync: () => Promise<AssetLibraryItem>; isPending: boolean }
createLibMutation?: {
mutateAsync: () => Promise<AssetLibraryItem>
isPending: boolean
}
}
/**
* 配音素材上传 Hook
* 封装上传流程:获取库 → 上传文件 → 获取时长 → 创建记录 → 打标签
*/
export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUploadOptions) {
export function useVoiceUpload({
voiceLibrary,
createLibMutation: _createLibMutation,
}: UseVoiceUploadOptions) {
const queryClient = useQueryClient()
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
@@ -33,24 +34,12 @@ export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUplo
}) => {
setUploadProgress(0)
try {
// 1. 获取或等待 voice library
let lib = voiceLibrary
if (!lib) {
if (createLibMutation.isPending) {
await createLibMutation.mutateAsync()
}
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: () => getAssetLibraries(),
})
lib = libs.find((l: AssetLibraryItem) => l.kind === "voice")
if (!lib) throw new Error("无法创建配音库")
}
// 2. 上传文件(带进度,后端自动创建 ingest job)
// 1. 上传文件:后端自动在默认项目下确保配音库存在(P0 404 修复)
// 兼容 voiceLibrary 参数:若调用方已传入正确的库 ID 则直接复用,否则内部自动解析
const complete = await uploadAssetDirect({
file: data.file,
library_id: lib.id,
library_id: voiceLibrary?.id,
kind: "voice",
onProgress: (p) => setUploadProgress(p),
})
@@ -1,6 +1,6 @@
import { useState, useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { uploadAssetDirect, getAssetLibraries, getIngestJob } from "@/api/assets"
import { uploadAssetDirect, getIngestJob } from "@/api/assets"
/**
* 配音上传 Hook
@@ -23,18 +23,10 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
mutationFn: async (data: { file: File; name: string; description: string }) => {
setUploadProgress(0)
try {
/* 获取或创建默认配音库 */
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: () => getAssetLibraries(),
})
const lib = libs.find((l) => l.kind === "voice")
if (!lib) throw new Error("配音库不存在,请先在配音库页面创建")
/* 直传文件(后端会自动创建 ingest job) */
/* 直传文件(后端会自动在默认项目下确保配音库存在,P0 404 修复) */
const complete = await uploadAssetDirect({
file: data.file,
library_id: lib.id,
kind: "voice",
onProgress: (p) => setUploadProgress(p),
})
+19 -10
View File
@@ -381,10 +381,15 @@ def _step_tts(job: ViralVideoJob, copy_text: str):
try:
from pathlib import Path as _Path
from services.tts_service_factory import get_tts_service
# 使用绝对包路径,避免 celery worker 因 cwd/PYTHONPATH 微小差异找不到 services 模块
from apps.worker.services.tts_service_factory import get_tts_service
tts_service = get_tts_service()
result = tts_service.synthesize(text=copy_text, voice_id=job.persona_id or "default")
# 兼容老接口:部分 provider 只接收 text 参数
try:
result = tts_service.synthesize(text=copy_text, voice_id=job.persona_id or "default")
except TypeError:
result = tts_service.synthesize(text=copy_text)
if result is None:
return None
p = _Path(result) if not isinstance(result, _Path) else result
@@ -593,15 +598,19 @@ def _make_placeholder_clip(tmpdir, idx: int, duration: int):
def _step_upload(job: ViralVideoJob, video_path: str) -> str:
"""步骤 10: OSS 上传。"""
try:
from video_processing.oss_helpers import upload_to_oss
"""步骤 9: OSS 上传。"""
from pathlib import Path
video_url = upload_to_oss(video_path, prefix="viral-video/")
return video_url
except Exception as e:
logger.error("[爆款视频] OSS 上传失败: %s", e)
raise
from video_processing.oss_helpers import upload_to_oss
local = Path(video_path)
# 构造 OSS key,与 generation.py 规则对齐:generated/viral-video/<user_id>/<job_id>/<filename>
storage_key = f"generated/viral-video/{job.user_id}/{job.id}/{local.name}"
logger.info("[爆款视频] 开始上传成片: local=%s key=%s size=%d", local, storage_key, local.stat().st_size)
video_url = upload_to_oss(local, storage_key)
if not video_url:
raise RuntimeError(f"OSS 上传失败: storage_key={storage_key}")
return video_url
# ── 主编排器 ────────────────────────────────────────────────────────────
+32 -1
View File
@@ -298,6 +298,14 @@ class DoubaoClient:
"Content-Type": "application/json",
}
create_url = f"{self.base_url}/contents/generations/tasks"
logger.info(
"Seedance 创建任务请求: url=%s model=%s duration=%ds ratio=%s gen_audio=%s",
create_url,
video_model,
duration,
ratio,
generate_audio,
)
# 1) 创建任务(带重试)
task_id: str | None = None
@@ -305,6 +313,13 @@ class DoubaoClient:
for attempt in range(self.max_retries + 1):
try:
resp = httpx.post(create_url, headers=headers, json=create_payload, timeout=self.timeout)
if resp.status_code >= 400:
# 把响应体完整打出来(通常含 error.code/message,能直接定位:模型未开通/Key 无权限/模型 ID 错误)
logger.error(
"Seedance 创建任务 HTTP %d: body=%s",
resp.status_code,
(resp.text or "")[:1000],
)
resp.raise_for_status()
data = resp.json()
task_id = data.get("id")
@@ -320,7 +335,16 @@ class DoubaoClient:
)
time.sleep(wait)
if not task_id:
logger.error("Seedance 创建任务最终失败: %s", last_error)
logger.error(
"Seedance 创建任务最终失败: model=%s base_url=%s err=%s 【排查建议】"
"1) 确认方舟控制台已开通 Doubao-Seedance-2.5 模型;"
"2) DOUBAO_API_KEY 对应的账号有该模型调用权限;"
"3) DOUBAO_BASE_URL 必须为 https://ark.cn-beijing.volces.com/api/v3;"
"4) 若控制台用「推理接入点」(endpoint),请把 DOUBAO_VIDEO_MODEL 改为 ep-xxx 接入点 ID。",
video_model,
self.base_url,
last_error,
)
return None
logger.info("Seedance 任务已创建: task_id=%s model=%s duration=%ds", task_id, video_model, duration)
@@ -352,6 +376,13 @@ class DoubaoClient:
last_error = RuntimeError(f"task {status}")
break
# queued / running: 继续轮询
except httpx.HTTPStatusError as e:
last_error = e
logger.warning(
"Seedance 轮询 HTTP %d: body=%s",
e.response.status_code,
(e.response.text or "")[:500],
)
except Exception as e:
last_error = e
logger.debug("Seedance 轮询异常: %s", e)
+22
View File
@@ -137,6 +137,28 @@ fi
echo "✅ compose.yml ready: $COMPOSE_FILE_PATH ($(wc -l < "$COMPOSE_FILE_PATH") lines)"
ln -sf "$NGINX_CONF_FILE" "$INFRA_DOCKER_DIR/nginx-${COMPOSE_ENV_VALUE}.conf" 2>/dev/null || true
# 封装 docker compose 调用:统一 --env-file(compose 默认只读取 project 目录下的 .env,
# 我们的 .env 在 $INFRA_DOCKER_DIR/../../.env,必须显式传入才能读到 GENERATED_FILES_HOST_DIR 等变量)
# ── 防御:清理可能残留的 docker-compose.override.yml / compose.override.yml ──
# 历史上运维曾用 override 文件固定镜像 tag 排查问题,若忘记删除会导致新镜像 tag 不生效,
# Worker 一直跑旧镜像(本次 P0 404 排查中即踩过此坑)。这里每次部署都主动清理。
for override in "$INFRA_DOCKER_DIR/docker-compose.override.yml" "$INFRA_DOCKER_DIR/compose.override.yml" "$INFRA_DOCKER_DIR/override.yml"; do
if [ -f "$override" ]; then
echo "⚠️ Found stale override file, removing: $override"
rm -f "$override"
fi
done
# ── 防御:清理可能残留的 docker-compose.override.yml / compose.override.yml ──
# 历史上运维曾用 override 文件固定镜像 tag 排查问题,若忘记删除会导致新镜像 tag 不生效,
# Worker 一直跑旧镜像(本次 P0 404 排查中即踩过此坑)。这里每次部署都主动清理。
for override in "$INFRA_DOCKER_DIR/docker-compose.override.yml" "$INFRA_DOCKER_DIR/compose.override.yml" "$INFRA_DOCKER_DIR/override.yml"; do
if [ -f "$override" ]; then
echo "⚠️ Found stale override file, removing: $override"
rm -f "$override"
fi
done
# 封装 docker compose 调用:统一 --env-file(compose 默认只读取 project 目录下的 .env,
# 我们的 .env 在 $INFRA_DOCKER_DIR/../../.env,必须显式传入才能读到 GENERATED_FILES_HOST_DIR 等变量)
compose() {