Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f306037f7f | |||
| 0a16f6c38b | |||
| c21c0fb748 | |||
| ee3546b371 | |||
| 771c7f8b3b | |||
| 094d22cee7 | |||
| 122e9aaa86 | |||
| 8f1d6f20d9 | |||
| 1a00b23b09 | |||
| 4a36eb8827 | |||
| c329608436 | |||
| 8a1a8406a1 | |||
| 7f0efd9651 | |||
| 4bb1c4205d | |||
| cffb18c535 | |||
| b430743f7a | |||
| cd485a6370 | |||
| 8ef96e8569 | |||
| 82e05fcdbb | |||
| 766277142c | |||
| 5520e18e2e |
@@ -0,0 +1,84 @@
|
||||
name: API Base Image Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- main
|
||||
paths:
|
||||
- 'requirements-base.txt'
|
||||
- 'requirements.txt'
|
||||
- 'infra/docker/api-base.Dockerfile'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-api-base:
|
||||
name: Build API Base Image
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
||||
| bash
|
||||
|
||||
- name: Docker login to Registry
|
||||
shell: sh
|
||||
env:
|
||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
GITEA_REGISTRY_USER: xiaoxia
|
||||
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
for i in 1 2 3; do
|
||||
echo "=== Docker login 尝试 $i/3 ==="
|
||||
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin \
|
||||
&& docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
|
||||
echo "✅ Docker login successful"
|
||||
break
|
||||
fi
|
||||
echo "❌ Docker login 失败(尝试 $i/3),5s 后重试..."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
- name: Build and push API base image
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-api-base:latest"
|
||||
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/saas-api-base:latest"
|
||||
|
||||
echo "=== Building API base image ==="
|
||||
|
||||
# 使用普通 docker build(单平台不需要 buildx)
|
||||
docker build \
|
||||
-f infra/docker/api-base.Dockerfile \
|
||||
-t "${ACR_IMAGE}" \
|
||||
.
|
||||
|
||||
echo ""
|
||||
echo "✅ Image built successfully"
|
||||
|
||||
# 推送到 ACR
|
||||
echo "=== Pushing to ACR ==="
|
||||
docker push "${ACR_IMAGE}"
|
||||
echo "✅ Pushed to ACR"
|
||||
|
||||
# 打标签并推送到 Gitea Packages 作为备份
|
||||
echo "=== Pushing to Gitea Packages ==="
|
||||
docker tag "${ACR_IMAGE}" "${GITEA_IMAGE}"
|
||||
docker push "${GITEA_IMAGE}" || echo "⚠️ Gitea Packages push failed (non-fatal)"
|
||||
echo "✅ Gitea backup push completed"
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
shell: sh
|
||||
run: |
|
||||
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-api-base:latest"
|
||||
docker rmi "${ACR_IMAGE}" 2>/dev/null || true
|
||||
echo "Cleanup done"
|
||||
@@ -633,58 +633,25 @@ jobs:
|
||||
echo "Docker login failed ($i/3), retrying in 5s..."
|
||||
sleep 5
|
||||
done
|
||||
- name: Pre-build worker base images (fallback if not exist)
|
||||
- name: Pre-build worker base image (fallback if not exist)
|
||||
if: matrix.service == 'worker'
|
||||
id: prebuild
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="git.xiaoxiajianji.com/xiaoxia-saas"
|
||||
BASE_BUILDER="${REGISTRY}/worker-base-builder:latest"
|
||||
BASE_RUNTIME="${REGISTRY}/worker-base-runtime:latest"
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
BASE_IMAGE="${REGISTRY}/saas-worker-base:latest"
|
||||
|
||||
# 尝试拉取基础镜像
|
||||
echo "检查基础镜像..."
|
||||
if docker pull "$BASE_BUILDER" 2>/dev/null && docker pull "$BASE_RUNTIME" 2>/dev/null; then
|
||||
echo "基础镜像已存在,使用远程镜像"
|
||||
echo "检查 Worker 基础镜像..."
|
||||
if docker pull "$BASE_IMAGE" 2>/dev/null; then
|
||||
echo "✅ 基础镜像已存在"
|
||||
echo "fallback=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "基础镜像不存在,本地构建(fallback模式)..."
|
||||
|
||||
# 尝试用buildx构建,失败则回退到普通docker build(DooD模式下buildx builder偶发崩溃)
|
||||
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
|
||||
BUILDX_AVAILABLE=true
|
||||
if ! docker buildx create --use --name "$BUILDER_NAME" --driver docker-container > /dev/null 2>&1; then
|
||||
BUILDX_AVAILABLE=false
|
||||
fi
|
||||
if [ "$BUILDX_AVAILABLE" = true ] && ! docker buildx inspect --bootstrap > /dev/null 2>&1; then
|
||||
BUILDX_AVAILABLE=false
|
||||
docker buildx rm "$BUILDER_NAME" > /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
build_base() {
|
||||
local df="$1"
|
||||
local tag="$2"
|
||||
local name="$3"
|
||||
if [ "$BUILDX_AVAILABLE" = true ]; then
|
||||
echo "构建 $name(buildx)..."
|
||||
if docker buildx build --load -f "$df" -t "$tag" . > /dev/null 2>&1; then
|
||||
echo "$name 构建成功"
|
||||
return 0
|
||||
fi
|
||||
echo "buildx失败,回退到普通docker build"
|
||||
BUILDX_AVAILABLE=false
|
||||
docker buildx rm "$BUILDER_NAME" > /dev/null 2>&1 || true
|
||||
fi
|
||||
echo "构建 $name(docker build)..."
|
||||
docker build -f "$df" -t "$tag" .
|
||||
}
|
||||
|
||||
build_base infra/docker/worker-base-builder.Dockerfile "$BASE_BUILDER" "worker-base-builder"
|
||||
build_base infra/docker/worker-base-runtime.Dockerfile "$BASE_RUNTIME" "worker-base-runtime"
|
||||
|
||||
echo "⚠️ 基础镜像不存在,本地构建(fallback模式)..."
|
||||
docker build -f infra/docker/worker-base.Dockerfile -t "$BASE_IMAGE" .
|
||||
echo "fallback=true" >> $GITHUB_OUTPUT
|
||||
echo "基础镜像本地构建完成"
|
||||
echo "✅ Worker 基础镜像本地构建完成"
|
||||
fi
|
||||
|
||||
- name: Build PR image (verify only, no push)
|
||||
@@ -700,15 +667,15 @@ jobs:
|
||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
|
||||
fi
|
||||
|
||||
# Worker fallback模式:基础镜像本地已构建,用普通docker build绕过buildx
|
||||
if [ "${{ matrix.service }}" = "worker" ] && [ "${{ steps.prebuild.outputs.fallback }}" = "true" ]; then
|
||||
echo "Fallback模式:用普通docker build(基础镜像本地已构建)"
|
||||
# Worker: 始终用普通docker build(基础镜像已预装全部依赖,无需buildx)
|
||||
if [ "${{ matrix.service }}" = "worker" ]; then
|
||||
echo "Worker: 使用普通docker build"
|
||||
BUILD_ARG_STR=""
|
||||
for arg in $EXTRA_BUILD_ARGS; do
|
||||
BUILD_ARG_STR="$BUILD_ARG_STR --build-arg $arg"
|
||||
done
|
||||
docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" $BUILD_ARG_STR .
|
||||
echo "Fallback PR Build successful"
|
||||
echo "PR Build successful (worker, no buildx)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -831,6 +798,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Setup buildx builder
|
||||
if: matrix.service != 'worker'
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
@@ -843,41 +811,64 @@ jobs:
|
||||
fi
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
- name: Build and push ${{ matrix.service_display }} image (with retry)
|
||||
- name: Pre-build worker base image (fallback if not exist)
|
||||
if: matrix.service == 'worker'
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
BASE_IMAGE="${REGISTRY}/saas-worker-base:latest"
|
||||
|
||||
echo "检查 Worker 基础镜像..."
|
||||
if docker pull "$BASE_IMAGE" 2>/dev/null; then
|
||||
echo "✅ 基础镜像已存在"
|
||||
else
|
||||
echo "⚠️ 基础镜像不存在,本地构建(fallback)..."
|
||||
docker build -f infra/docker/worker-base.Dockerfile -t "$BASE_IMAGE" .
|
||||
echo "✅ Worker 基础镜像本地构建完成"
|
||||
fi
|
||||
|
||||
- name: Build and push ${{ matrix.service_display }} image
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${GITHUB_SHA}"
|
||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}"
|
||||
|
||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
|
||||
if [ "${{ matrix.service }}" = "web" ]; then
|
||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
|
||||
if [ "${{ matrix.service }}" = "worker" ]; then
|
||||
# Worker: plain docker build(基础镜像已预装全部依赖,无需 buildx)
|
||||
echo "=== Worker: plain docker build ==="
|
||||
docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" --build-arg APP_VERSION="${GITHUB_SHA}" .
|
||||
docker push "${IMAGE_TAG}"
|
||||
echo "✅ Worker image pushed: ${IMAGE_TAG}"
|
||||
else
|
||||
# API/Web: buildx with registry cache
|
||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}"
|
||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
|
||||
if [ "${{ matrix.service }}" = "web" ]; then
|
||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
|
||||
fi
|
||||
|
||||
NO_CACHE_FLAG=""
|
||||
for i in 1 2 3; do
|
||||
echo "=== Docker build 尝试 $i/3 ==="
|
||||
if bash scripts/ci/docker_build_push.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
|
||||
echo "✅ Docker build 成功"
|
||||
break
|
||||
fi
|
||||
echo "❌ Docker build 失败(尝试 $i/3)"
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 10
|
||||
if [ $i -eq 2 ]; then
|
||||
NO_CACHE_FLAG="--no-cache"
|
||||
echo "下次重试将使用 --no-cache"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG}"
|
||||
fi
|
||||
|
||||
# Docker build 带重试:失败自动重试2次,第2次重试加--no-cache
|
||||
NO_CACHE_FLAG=""
|
||||
for i in 1 2 3; do
|
||||
echo "=== Docker build 尝试 $i/3 ==="
|
||||
if bash scripts/ci/docker_build_push.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
|
||||
echo "✅ Docker build 成功"
|
||||
break
|
||||
fi
|
||||
echo "❌ Docker build 失败(尝试 $i/3)"
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 10
|
||||
# 第2次重试使用 --no-cache
|
||||
if [ $i -eq 2 ]; then
|
||||
NO_CACHE_FLAG="--no-cache"
|
||||
echo "下次重试将使用 --no-cache"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG}"
|
||||
- name: Cleanup buildx builder
|
||||
if: always()
|
||||
if: matrix.service != 'worker' && always()
|
||||
shell: sh
|
||||
run: |
|
||||
docker buildx rm ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} 2>/dev/null || true
|
||||
|
||||
@@ -7,35 +7,25 @@ on:
|
||||
- main
|
||||
paths:
|
||||
- 'requirements-base.txt'
|
||||
- 'requirements.txt'
|
||||
- 'requirements-worker.txt'
|
||||
- 'infra/docker/worker-base-builder.Dockerfile'
|
||||
- 'infra/docker/worker-base-runtime.Dockerfile'
|
||||
workflow_dispatch: # 支持手动触发
|
||||
- 'infra/docker/worker-base.Dockerfile'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-worker-base:
|
||||
name: Build Worker Base Images
|
||||
name: Build Worker Base Image
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: builder
|
||||
dockerfile: infra/docker/worker-base-builder.Dockerfile
|
||||
image_name: worker-base-builder
|
||||
cache_name: worker-base-builder-cache
|
||||
- name: runtime
|
||||
dockerfile: infra/docker/worker-base-runtime.Dockerfile
|
||||
image_name: worker-base-runtime
|
||||
cache_name: worker-base-runtime-cache
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
||||
| bash
|
||||
|
||||
- name: Docker login to Registry
|
||||
shell: sh
|
||||
@@ -48,7 +38,8 @@ jobs:
|
||||
set -eu
|
||||
for i in 1 2 3; do
|
||||
echo "=== Docker login 尝试 $i/3 ==="
|
||||
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin && docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
|
||||
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin \
|
||||
&& docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
|
||||
echo "✅ Docker login successful"
|
||||
break
|
||||
fi
|
||||
@@ -56,48 +47,40 @@ jobs:
|
||||
sleep 5
|
||||
done
|
||||
|
||||
- name: Setup buildx builder
|
||||
- name: Build and push Worker base image
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
BUILDER_NAME="ci-builder-${GITHUB_RUN_ID}-${{ matrix.name }}"
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
echo "Created $BUILDER_NAME"
|
||||
else
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
echo "Using existing $BUILDER_NAME"
|
||||
fi
|
||||
docker buildx inspect --bootstrap
|
||||
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-worker-base:latest"
|
||||
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/saas-worker-base:latest"
|
||||
|
||||
echo "=== Building Worker base image ==="
|
||||
|
||||
# 使用普通 docker build(单平台不需要 buildx)
|
||||
docker build \
|
||||
-f infra/docker/worker-base.Dockerfile \
|
||||
-t "${ACR_IMAGE}" \
|
||||
.
|
||||
|
||||
- name: Build and push base image
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:latest"
|
||||
SAFE_REF_NAME=$(echo "${GITHUB_REF_NAME}" | tr '/' '-')
|
||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${SAFE_REF_NAME}"
|
||||
|
||||
echo "=== Building ${{ matrix.name }} base image ==="
|
||||
echo "Image: ${IMAGE_TAG}"
|
||||
echo "Cache: ${CACHE_REF}"
|
||||
|
||||
# 用通用构建脚本
|
||||
bash scripts/ci/docker_build_push.sh ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}"
|
||||
|
||||
# 同时推送到 Gitea Packages 作为备份(可选)
|
||||
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/${{ matrix.image_name }}:latest"
|
||||
docker tag "${IMAGE_TAG}" "${GITEA_IMAGE}"
|
||||
docker push "${GITEA_IMAGE}" || echo "Gitea Packages push failed (non-fatal)"
|
||||
|
||||
echo ""
|
||||
echo "✅ ${{ matrix.name }} base image built and pushed"
|
||||
echo "✅ Image built successfully"
|
||||
|
||||
- name: Cleanup buildx builder
|
||||
# 推送到 ACR
|
||||
echo "=== Pushing to ACR ==="
|
||||
docker push "${ACR_IMAGE}"
|
||||
echo "✅ Pushed to ACR"
|
||||
|
||||
# 打标签并推送到 Gitea Packages 作为备份
|
||||
echo "=== Pushing to Gitea Packages ==="
|
||||
docker tag "${ACR_IMAGE}" "${GITEA_IMAGE}"
|
||||
docker push "${GITEA_IMAGE}" || echo "⚠️ Gitea Packages push failed (non-fatal)"
|
||||
echo "✅ Gitea backup push completed"
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
shell: sh
|
||||
run: |
|
||||
docker buildx rm "ci-builder-${GITHUB_RUN_ID}-${{ matrix.name }}" 2>/dev/null || true
|
||||
docker buildx prune -f 2>/dev/null || true
|
||||
echo "Builder cleanup done"
|
||||
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-worker-base:latest"
|
||||
docker rmi "${ACR_IMAGE}" 2>/dev/null || true
|
||||
docker image prune -f 2>/dev/null || true
|
||||
echo "Cleanup done"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.api.routes._helpers import check_project_access, format_utc_datetime
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
@@ -14,6 +14,7 @@ from app.schemas.asset import (
|
||||
AssetResponse,
|
||||
BatchClassifyRequest,
|
||||
BatchDeleteRequest,
|
||||
BatchGetRequest,
|
||||
BatchMarkRequest,
|
||||
BatchOperationResponse,
|
||||
BatchTagRequest,
|
||||
@@ -370,6 +371,18 @@ def update_asset_review_status(
|
||||
return _to_asset_response(updated)
|
||||
|
||||
|
||||
@router.post("/batch", response_model=List[AssetResponse])
|
||||
def batch_get_assets(
|
||||
request: BatchGetRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
) -> list[AssetResponse]:
|
||||
"""批量获取素材详情(根据 ID 列表)。"""
|
||||
items = asset_repository.find_by_ids(request.ids)
|
||||
storage_service = get_storage_service()
|
||||
return [_to_asset_response(item, storage_service) for item in items]
|
||||
|
||||
|
||||
@router.post("/batch-delete", response_model=BatchOperationResponse)
|
||||
def batch_delete_assets(
|
||||
request: BatchDeleteRequest,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import psycopg2
|
||||
import psycopg
|
||||
import redis
|
||||
from app.config import settings
|
||||
from fastapi import APIRouter, status
|
||||
@@ -49,7 +49,7 @@ async def _check_database() -> dict:
|
||||
"message": "Using in-memory database",
|
||||
}
|
||||
try:
|
||||
conn = psycopg2.connect(settings.DATABASE_URL, connect_timeout=3)
|
||||
conn = psycopg.connect(settings.DATABASE_URL, connect_timeout=3)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT 1")
|
||||
cur.fetchone()
|
||||
@@ -124,7 +124,7 @@ async def _check_migrations() -> dict:
|
||||
"message": "Using in-memory database, no migrations needed",
|
||||
}
|
||||
try:
|
||||
conn = psycopg2.connect(settings.DATABASE_URL, connect_timeout=3)
|
||||
conn = psycopg.connect(settings.DATABASE_URL, connect_timeout=3)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
|
||||
@@ -58,6 +58,12 @@ class AssetResponse(BaseModel):
|
||||
MAX_BATCH_SIZE = 200
|
||||
|
||||
|
||||
class BatchGetRequest(BaseModel):
|
||||
"""批量获取素材详情请求。"""
|
||||
|
||||
ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表")
|
||||
|
||||
|
||||
class BatchDeleteRequest(BaseModel):
|
||||
"""批量删除请求(软删除)。"""
|
||||
|
||||
|
||||
@@ -214,12 +214,8 @@ test.describe("Core generation flow", () => {
|
||||
await titleInput.fill(titleText)
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 5: preview — 需要先生成预览视频,才能进入下一步
|
||||
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible({ timeout: 15000 })
|
||||
// 点击"生成预览"按钮触发预览生成
|
||||
await page.locator(".xx-preview-generate-btn").click()
|
||||
// 等待预览生成完成(后端渲染,可能需要较长时间)
|
||||
await expect(page.getByText("预览生成成功")).toBeVisible({ timeout: 300_000 })
|
||||
// Step 5: preview — 前端实时预览架构改造,无需后端生成预览
|
||||
await expect(page.getByRole("heading", { name: /预览设置/ })).toBeVisible({ timeout: 15000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 6: cover (默认 AI 智能选帧模式,直接下一步)
|
||||
|
||||
@@ -9,6 +9,9 @@ import { refreshAccessToken } from "./login"
|
||||
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** 正在执行刷新操作的 Promise,防止主动刷新和 401 被动刷新并发竞争 */
|
||||
let activeRefreshPromise: Promise<void> | null = null
|
||||
|
||||
/** 提前刷新的缓冲时间(秒) */
|
||||
const REFRESH_BUFFER_SECONDS = 60
|
||||
|
||||
@@ -39,14 +42,55 @@ export function cancelProactiveRefresh(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 token 刷新(带并发锁,供主动刷新和被动 401 共用)
|
||||
* 返回当前刷新操作的 Promise;若已有刷新进行中则复用该 Promise。
|
||||
*/
|
||||
export function executeTokenRefresh(): Promise<void> | null {
|
||||
// 已有刷新进行中 → 复用
|
||||
if (activeRefreshPromise) {
|
||||
return activeRefreshPromise
|
||||
}
|
||||
|
||||
const { user, refreshToken: refreshTokenValue } = useAuthStore.getState()
|
||||
|
||||
// 安全检查:user 或 refreshToken 为空时跳过刷新
|
||||
if (!user || !refreshTokenValue) {
|
||||
return null
|
||||
}
|
||||
|
||||
activeRefreshPromise = (async () => {
|
||||
try {
|
||||
const data = await refreshAccessToken(refreshTokenValue)
|
||||
const newAccessToken = data.access_token
|
||||
const newRefreshToken = data.refresh_token ?? refreshTokenValue
|
||||
|
||||
// 更新 Zustand store + localStorage
|
||||
useAuthStore.getState().setAuth(user, newAccessToken, newRefreshToken)
|
||||
|
||||
// 递归调度下一次刷新
|
||||
scheduleProactiveRefresh()
|
||||
} catch {
|
||||
// 刷新失败 → 清除认证状态,跳转登录页
|
||||
cancelProactiveRefresh()
|
||||
useAuthStore.getState().clearAuth()
|
||||
window.location.href = "/login"
|
||||
} finally {
|
||||
activeRefreshPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return activeRefreshPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度主动刷新:在 token 过期前 REFRESH_BUFFER_SECONDS 秒自动刷新
|
||||
*/
|
||||
export function scheduleProactiveRefresh(): void {
|
||||
cancelProactiveRefresh()
|
||||
|
||||
const accessToken = localStorage.getItem("access_token")
|
||||
const refreshTokenValue = useAuthStore.getState().refreshToken
|
||||
// 统一从 Zustand store 读取(与 setAuth 写入保持一致)
|
||||
const { accessToken, refreshToken: refreshTokenValue } = useAuthStore.getState()
|
||||
|
||||
if (!accessToken || !refreshTokenValue) return
|
||||
|
||||
@@ -59,24 +103,7 @@ export function scheduleProactiveRefresh(): void {
|
||||
// 如果 token 已经过期或即将在缓冲时间内过期,立即刷新
|
||||
const delaySeconds = Math.max(secondsUntilExpiry - REFRESH_BUFFER_SECONDS, 0)
|
||||
|
||||
refreshTimer = setTimeout(async () => {
|
||||
try {
|
||||
const data = await refreshAccessToken(refreshTokenValue)
|
||||
const newAccessToken = data.access_token
|
||||
const newRefreshToken = data.refresh_token ?? refreshTokenValue
|
||||
|
||||
// 更新 Zustand store + localStorage
|
||||
useAuthStore
|
||||
.getState()
|
||||
.setAuth(useAuthStore.getState().user!, newAccessToken, newRefreshToken)
|
||||
|
||||
// 递归调度下一次刷新
|
||||
scheduleProactiveRefresh()
|
||||
} catch {
|
||||
// 刷新失败 → 清除认证状态,跳转登录页
|
||||
cancelProactiveRefresh()
|
||||
useAuthStore.getState().clearAuth()
|
||||
window.location.href = "/login"
|
||||
}
|
||||
refreshTimer = setTimeout(() => {
|
||||
executeTokenRefresh()
|
||||
}, delaySeconds * 1000)
|
||||
}
|
||||
|
||||
+17
-12
@@ -5,8 +5,8 @@
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"
|
||||
import { message } from "antd"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { refreshAccessToken } from "./auth"
|
||||
import { scheduleProactiveRefresh, cancelProactiveRefresh } from "./auth/tokenRefresh"
|
||||
|
||||
import { cancelProactiveRefresh, executeTokenRefresh } from "./auth/tokenRefresh"
|
||||
|
||||
// 创建 Axios 实例
|
||||
const apiClient = axios.create({
|
||||
@@ -98,21 +98,26 @@ apiClient.interceptors.response.use(
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
const data = await refreshAccessToken(refreshToken)
|
||||
const newAccessToken = data.access_token
|
||||
const newRefreshToken = data.refresh_token ?? refreshToken
|
||||
// 使用共享的刷新函数(带并发锁 + 安全检查)
|
||||
const refreshPromise = executeTokenRefresh()
|
||||
if (!refreshPromise) {
|
||||
// user 或 refreshToken 为空,无法刷新
|
||||
cancelProactiveRefresh()
|
||||
useAuthStore.getState().clearAuth()
|
||||
window.location.href = "/"
|
||||
return Promise.reject(new Error("Unable to refresh: missing user or refresh token"))
|
||||
}
|
||||
await refreshPromise
|
||||
|
||||
// 更新 Zustand + localStorage
|
||||
useAuthStore
|
||||
.getState()
|
||||
.setAuth(useAuthStore.getState().user!, newAccessToken, newRefreshToken)
|
||||
// 获取刷新后的新 token
|
||||
const newAccessToken = useAuthStore.getState().accessToken
|
||||
if (!newAccessToken) {
|
||||
return Promise.reject(new Error("Token refresh failed: no new access token"))
|
||||
}
|
||||
|
||||
// 处理排队的请求
|
||||
processQueue(null, newAccessToken)
|
||||
|
||||
// 重新调度主动刷新(基于新 token 的过期时间)
|
||||
scheduleProactiveRefresh()
|
||||
|
||||
// 重试原始请求
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
|
||||
|
||||
@@ -23,13 +23,9 @@ export async function generateCover(
|
||||
templateId: string,
|
||||
data: GenerateCoverRequest,
|
||||
): Promise<GenerateCoverResponse> {
|
||||
const response = await apiClient.post<GenerateCoverResponse>(
|
||||
"/generation/generate-cover",
|
||||
{ ...data, template_id: templateId },
|
||||
{
|
||||
timeout: 300000,
|
||||
params: { template_id: templateId },
|
||||
},
|
||||
)
|
||||
const response = await apiClient.post<GenerateCoverResponse>("/generation/generate-cover", data, {
|
||||
timeout: 300000,
|
||||
params: { template_id: templateId },
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import React, { useMemo } from "react"
|
||||
import { Modal, message } from "antd"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useCloneProgress } from "@/hooks/useCloneProgress"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import GenerateHeader from "./components/GenerateHeader"
|
||||
import GenerateStepsBar from "./components/GenerateStepsBar"
|
||||
@@ -105,6 +107,18 @@ const GeneratePage: React.FC = () => {
|
||||
[userTemplates, selectedTemplate],
|
||||
)
|
||||
|
||||
/* ── 配音音频 URL ── */
|
||||
const { data: voiceMaterials = [] } = useQuery({
|
||||
queryKey: ["assets", "voice"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
})
|
||||
|
||||
const voiceAudioUrl = useMemo(() => {
|
||||
if (!selectedVoice) return undefined
|
||||
const asset = voiceMaterials.find((v) => v.id === selectedVoice)
|
||||
return asset?.file_url || undefined
|
||||
}, [selectedVoice, voiceMaterials])
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
const { goNext, goPrev } = useStepNavigation({
|
||||
currentStep,
|
||||
@@ -236,6 +250,7 @@ const GeneratePage: React.FC = () => {
|
||||
assetsReady={previewAssetsReady}
|
||||
assetsLoading={previewAssetsLoading}
|
||||
titleSettings={titleSettings}
|
||||
voiceAudioUrl={voiceAudioUrl}
|
||||
/>
|
||||
)}
|
||||
{currentStep >= 6 && (
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* 前端预览播放器
|
||||
* 用原生 <video> 标签按时间线播放素材片段
|
||||
* 替代后端 FFmpeg 渲染预览,实现真正的实时预览
|
||||
* 前端预览播放器(多 video 元素方案)
|
||||
* 为每个片段创建独立 <video> 元素,全部预加载
|
||||
* 用 display: none/block 切换当前播放的视频,实现无缝片段切换
|
||||
*
|
||||
* 注意:本组件不创建 .xx-preview-video 容器(由父组件 PreviewVideoPanel 提供)
|
||||
* 避免嵌套 .xx-preview-video 导致 CSS 冲突
|
||||
*/
|
||||
import React, { useMemo, useCallback, useState, useRef, useEffect } from "react"
|
||||
import { PlayCircleOutlined, PauseCircleOutlined, SoundOutlined } from "@ant-design/icons"
|
||||
@@ -18,6 +21,8 @@ interface FrontendPreviewPlayerProps {
|
||||
videoRatio: string
|
||||
/** 是否准备好播放(素材已加载) */
|
||||
ready: boolean
|
||||
/** 配音音频 URL */
|
||||
voiceAudioUrl?: string
|
||||
}
|
||||
|
||||
/** 格式化时间 mm:ss */
|
||||
@@ -41,9 +46,7 @@ function buildPlaybackSegments(
|
||||
const segments: PlaybackSegment[] = []
|
||||
|
||||
assets.forEach((asset, i) => {
|
||||
// 获取素材时长(从 metadata 或顶层字段)
|
||||
const assetDuration = asset.duration || asset.metadata?.duration || 30
|
||||
// 模板片段的时长约束
|
||||
const tplSeg = templateSegments[i] || templateSegments[templateSegments.length - 1]
|
||||
const segDuration = tplSeg
|
||||
? Math.min(tplSeg.duration_max, Math.max(tplSeg.duration_min, assetDuration))
|
||||
@@ -52,9 +55,15 @@ function buildPlaybackSegments(
|
||||
const startTime = 0
|
||||
const endTime = Math.min(startTime + segDuration, assetDuration)
|
||||
|
||||
const videoUrl = asset.file_url || asset.storage_key
|
||||
|
||||
console.log(
|
||||
`[buildPlaybackSegments] 片段 ${i}: assetId=${asset.id}, videoUrl=${videoUrl?.substring(0, 80)}, file_url=${!!asset.file_url}`,
|
||||
)
|
||||
|
||||
segments.push({
|
||||
assetId: asset.id,
|
||||
videoUrl: asset.file_url || asset.storage_key,
|
||||
videoUrl,
|
||||
startTime,
|
||||
endTime,
|
||||
order: i,
|
||||
@@ -67,10 +76,10 @@ function buildPlaybackSegments(
|
||||
const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
assets,
|
||||
template,
|
||||
videoRatio,
|
||||
videoRatio: _videoRatio,
|
||||
ready,
|
||||
voiceAudioUrl,
|
||||
}) => {
|
||||
// 构建播放片段
|
||||
const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template])
|
||||
|
||||
const {
|
||||
@@ -78,14 +87,92 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
currentTime,
|
||||
totalDuration,
|
||||
currentSegmentIndex,
|
||||
isEnded,
|
||||
canPlay,
|
||||
togglePlayPause,
|
||||
seekTo,
|
||||
videoRef,
|
||||
videoRefs,
|
||||
} = useSegmentScheduler(segments)
|
||||
|
||||
// 进度条拖拽
|
||||
// ── 配音音频同步 ──
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const prevIsPlayingRef = useRef(false)
|
||||
|
||||
// 创建/更新 Audio 元素
|
||||
useEffect(() => {
|
||||
if (!voiceAudioUrl) {
|
||||
// 没有配音,清理已有 audio
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.src = ""
|
||||
audioRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio()
|
||||
audioRef.current.preload = "auto"
|
||||
}
|
||||
if (audioRef.current.src !== voiceAudioUrl) {
|
||||
audioRef.current.src = voiceAudioUrl
|
||||
}
|
||||
}, [voiceAudioUrl])
|
||||
|
||||
// 同步播放状态
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio || !audio.src) return
|
||||
|
||||
if (isPlaying && !prevIsPlayingRef.current) {
|
||||
// 刚进入播放
|
||||
audio.currentTime = currentTime
|
||||
audio.play().catch(() => {})
|
||||
} else if (!isPlaying && prevIsPlayingRef.current) {
|
||||
// 刚暂停
|
||||
audio.pause()
|
||||
}
|
||||
prevIsPlayingRef.current = isPlaying
|
||||
}, [isPlaying, currentTime])
|
||||
|
||||
// 片段切换时同步音频 — 将 audio.currentTime 对齐到视频全局时间
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio || !audio.src || !isPlaying) return
|
||||
// 用视频当前的全局时间对齐音频
|
||||
audio.currentTime = currentTime
|
||||
}, [currentSegmentIndex])
|
||||
|
||||
// seek 时同步音频
|
||||
const handleSeekTo = useCallback(
|
||||
(time: number) => {
|
||||
seekTo(time)
|
||||
const audio = audioRef.current
|
||||
if (audio && audio.src) {
|
||||
audio.currentTime = time
|
||||
}
|
||||
},
|
||||
[seekTo],
|
||||
)
|
||||
|
||||
// 播放结束时暂停音频
|
||||
useEffect(() => {
|
||||
if (!isPlaying) {
|
||||
const audio = audioRef.current
|
||||
if (audio) audio.pause()
|
||||
}
|
||||
}, [isPlaying])
|
||||
|
||||
// 清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.src = ""
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── 进度条拖拽 ──
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -94,9 +181,9 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
if (!progressRef.current || totalDuration <= 0) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
seekTo(ratio * totalDuration)
|
||||
handleSeekTo(ratio * totalDuration)
|
||||
},
|
||||
[totalDuration, seekTo],
|
||||
[totalDuration, handleSeekTo],
|
||||
)
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
@@ -113,7 +200,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
if (!progressRef.current || totalDuration <= 0) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
seekTo(ratio * totalDuration)
|
||||
handleSeekTo(ratio * totalDuration)
|
||||
}
|
||||
const handleMouseUp = () => setIsDragging(false)
|
||||
window.addEventListener("mousemove", handleMouseMove)
|
||||
@@ -122,15 +209,25 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
window.removeEventListener("mousemove", handleMouseMove)
|
||||
window.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
}, [isDragging, totalDuration, seekTo])
|
||||
}, [isDragging, totalDuration, handleSeekTo])
|
||||
|
||||
const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") }
|
||||
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
|
||||
|
||||
// 未就绪状态
|
||||
// ── 未就绪状态 ──
|
||||
if (!ready || !assets.length) {
|
||||
return (
|
||||
<div className="xx-preview-empty">
|
||||
<div
|
||||
className="xx-preview-empty"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<SoundOutlined style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }} />
|
||||
<p className="xx-preview-empty-title">准备预览素材...</p>
|
||||
<p className="xx-preview-empty-desc">加载素材后即可预览播放</p>
|
||||
@@ -138,10 +235,21 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
// 无播放片段
|
||||
// ── 无播放片段 ──
|
||||
if (!canPlay) {
|
||||
return (
|
||||
<div className="xx-preview-empty">
|
||||
<div
|
||||
className="xx-preview-empty"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined
|
||||
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
|
||||
/>
|
||||
@@ -152,76 +260,92 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-frontend-preview-player">
|
||||
{/* 视频区域 */}
|
||||
<div className="xx-preview-video" style={{ ...videoAspectStyle, position: "relative" }}>
|
||||
<>
|
||||
{/* ✅ 多 video 元素方案:每个片段一个独立 video,全部预加载
|
||||
通过 display 切换当前播放的视频,无需重新 load,实现无缝切换 */}
|
||||
{segments.map((seg, i) => (
|
||||
<video
|
||||
ref={videoRef}
|
||||
key={seg.assetId}
|
||||
muted
|
||||
ref={(el) => {
|
||||
videoRefs.current[i] = el
|
||||
}}
|
||||
preload="auto"
|
||||
src={seg.videoUrl}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
background: "#000",
|
||||
zIndex: 1,
|
||||
display: i === currentSegmentIndex ? "block" : "none",
|
||||
}}
|
||||
playsInline
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 播放/暂停按钮覆盖 */}
|
||||
{!isPlaying && (
|
||||
<button
|
||||
className="xx-preview-play-btn"
|
||||
onClick={togglePlayPause}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
border: "none",
|
||||
borderRadius: "50%",
|
||||
width: 56,
|
||||
height: 56,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#fff",
|
||||
fontSize: 28,
|
||||
zIndex: 10,
|
||||
transition: "opacity 0.2s",
|
||||
}}
|
||||
>
|
||||
{isEnded ? <PlayCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 当前片段指示器 */}
|
||||
<div
|
||||
{/* 播放按钮覆盖层 */}
|
||||
{!isPlaying && (
|
||||
<button
|
||||
className="xx-preview-play-btn"
|
||||
onClick={togglePlayPause}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 8,
|
||||
background: "rgba(0,0,0,0.6)",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
border: "none",
|
||||
borderRadius: "50%",
|
||||
width: 56,
|
||||
height: 56,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#fff",
|
||||
fontSize: 11,
|
||||
padding: "2px 8px",
|
||||
borderRadius: 4,
|
||||
fontSize: 28,
|
||||
zIndex: 10,
|
||||
transition: "opacity 0.2s",
|
||||
}}
|
||||
>
|
||||
片段 {currentSegmentIndex + 1}/{segments.length}
|
||||
</div>
|
||||
<PlayCircleOutlined />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 片段指示器 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 8,
|
||||
background: "rgba(0,0,0,0.6)",
|
||||
color: "#fff",
|
||||
fontSize: 11,
|
||||
padding: "2px 8px",
|
||||
borderRadius: 4,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
片段 {currentSegmentIndex + 1}/{segments.length}
|
||||
</div>
|
||||
|
||||
{/* 控制条 */}
|
||||
{/* 控制条 — 绝对定位在底部 */}
|
||||
<div
|
||||
className="xx-preview-controls"
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 0",
|
||||
padding: "8px 12px",
|
||||
background: "linear-gradient(transparent, rgba(0,0,0,0.6))",
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{/* 播放/暂停 */}
|
||||
@@ -230,7 +354,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--text-primary, #fff)",
|
||||
color: "#fff",
|
||||
fontSize: 18,
|
||||
cursor: "pointer",
|
||||
padding: 4,
|
||||
@@ -245,7 +369,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary, #999)",
|
||||
color: "rgba(255,255,255,0.8)",
|
||||
minWidth: 80,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
@@ -275,7 +399,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
transition: isDragging ? "none" : "width 0.1s linear",
|
||||
}}
|
||||
/>
|
||||
{/* 进度指示点 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
@@ -293,7 +416,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
* 架构改造:完全去除后端 FFmpeg 预览依赖
|
||||
* - 使用 FrontendPreviewPlayer 直接播放素材片段
|
||||
* - TitleOverlay CSS 层实时响应标题样式变化
|
||||
*
|
||||
* 布局:本组件提供 .xx-preview-video 容器(position: relative + overflow: hidden)
|
||||
* FrontendPreviewPlayer 的内容通过 absolute 定位填充容器
|
||||
* TitleOverlay 通过 absolute 定位 + z-index: 30 覆盖在最上层
|
||||
*/
|
||||
import React, { useMemo } from "react"
|
||||
import React, { useMemo, useRef, useState, useEffect } from "react"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
@@ -27,6 +31,8 @@ interface PreviewVideoPanelProps {
|
||||
assetsLoading: boolean
|
||||
/** 标题设置 — 用于 CSS 实时预览层 */
|
||||
titleSettings?: TitleSettings
|
||||
/** 配音音频 URL */
|
||||
voiceAudioUrl?: string
|
||||
}
|
||||
|
||||
/* ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ── */
|
||||
@@ -73,12 +79,16 @@ function getPositionStyle(position: string): React.CSSProperties {
|
||||
* 构建 CSS 标题层的样式
|
||||
* 所有渲染参数与后端 FFmpeg ASS 字幕一致
|
||||
*/
|
||||
function buildTitleStyle(settings: TitleSettings): React.CSSProperties {
|
||||
const fontSizePercent = (Math.min(settings.size, 36) / ASS_VIDEO_HEIGHT) * 100
|
||||
function buildTitleStyle(settings: TitleSettings, containerHeight: number): React.CSSProperties {
|
||||
// 用 px 计算 fontSize,不再依赖父元素 font-size 的百分比
|
||||
const fontSizePx =
|
||||
containerHeight > 0
|
||||
? (Math.min(settings.size, 96) / ASS_VIDEO_HEIGHT) * containerHeight
|
||||
: (Math.min(settings.size, 96) / ASS_VIDEO_HEIGHT) * 400 // fallback
|
||||
|
||||
const base: React.CSSProperties = {
|
||||
fontFamily: settings.font || "思源黑体",
|
||||
fontSize: `${fontSizePercent}%`,
|
||||
fontSize: `${fontSizePx}px`,
|
||||
color: settings.color || "#ffffff",
|
||||
fontWeight: settings.bold ? 700 : 400,
|
||||
fontStyle: settings.italic ? "italic" : "normal",
|
||||
@@ -103,17 +113,38 @@ function buildTitleStyle(settings: TitleSettings): React.CSSProperties {
|
||||
|
||||
/**
|
||||
* CSS 标题预览覆盖层
|
||||
* 始终渲染:有标题显示标题,无标题显示占位文本"标题预览"
|
||||
* z-index: 20(在视频 z-index:1 和控制条 z-index:10 之上)
|
||||
*/
|
||||
const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSettings }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [containerHeight, setContainerHeight] = useState(400) // fallback
|
||||
|
||||
// ResizeObserver 获取容器实际高度
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const h = entry.contentRect.height
|
||||
if (h > 0) setContainerHeight(h)
|
||||
}
|
||||
})
|
||||
ro.observe(el)
|
||||
// 初始化也读一次
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.height > 0) setContainerHeight(rect.height)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
const positionStyle = useMemo(
|
||||
() => getPositionStyle(titleSettings.position),
|
||||
[titleSettings.position],
|
||||
)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const titleStyle = useMemo(
|
||||
() => buildTitleStyle(titleSettings),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
() => buildTitleStyle(titleSettings, containerHeight),
|
||||
[
|
||||
containerHeight,
|
||||
titleSettings.font,
|
||||
titleSettings.size,
|
||||
titleSettings.color,
|
||||
@@ -124,16 +155,17 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSetting
|
||||
],
|
||||
)
|
||||
|
||||
if (!titleSettings.title?.trim()) return null
|
||||
const displayTitle = titleSettings.title?.trim() || "标题预览"
|
||||
|
||||
return (
|
||||
<div
|
||||
className="xx-preview-title-overlay"
|
||||
ref={containerRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 20,
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -143,7 +175,7 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSetting
|
||||
position: "absolute",
|
||||
}}
|
||||
>
|
||||
{titleSettings.title}
|
||||
{displayTitle}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -158,6 +190,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
assetsReady,
|
||||
assetsLoading,
|
||||
titleSettings,
|
||||
voiceAudioUrl,
|
||||
}) => {
|
||||
const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") }
|
||||
|
||||
@@ -168,33 +201,42 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
{assetsReady && assets.length > 0 && <span className="xx-preview-badge">实时预览</span>}
|
||||
</div>
|
||||
|
||||
{/* 加载中 */}
|
||||
{assetsLoading && (
|
||||
<div className="xx-preview-loading-panel">
|
||||
<div className="xx-preview-video" style={videoAspectStyle}>
|
||||
<div className="xx-preview-loading-center">
|
||||
<LoadingOutlined style={{ fontSize: 36, color: "#fff" }} spin />
|
||||
<p style={{ marginTop: 12, color: "rgba(255,255,255,0.8)", fontSize: 14 }}>
|
||||
加载素材中...
|
||||
</p>
|
||||
</div>
|
||||
{/* ✅ 预览容器 — 唯一的 .xx-preview-video 容器
|
||||
内部所有内容(视频、控制条、标题叠加层)通过 absolute 定位填充 */}
|
||||
<div className="xx-preview-video" style={{ ...videoAspectStyle, position: "relative" }}>
|
||||
{/* 加载中状态 */}
|
||||
{assetsLoading && (
|
||||
<div
|
||||
className="xx-preview-loading-center"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
<LoadingOutlined style={{ fontSize: 36, color: "#fff" }} spin />
|
||||
<p style={{ marginTop: 12, color: "rgba(255,255,255,0.8)", fontSize: 14 }}>
|
||||
加载素材中...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* 前端预览播放器 + CSS 标题叠加 */}
|
||||
{!assetsLoading && (
|
||||
<div className="xx-preview-video" style={{ ...videoAspectStyle, position: "relative" }}>
|
||||
<FrontendPreviewPlayer
|
||||
assets={assets}
|
||||
template={template}
|
||||
videoRatio={videoRatio}
|
||||
ready={assetsReady}
|
||||
/>
|
||||
{/* CSS 标题实时预览层 — 与 FFmpeg ASS 渲染坐标对齐 */}
|
||||
{titleSettings && <TitleOverlay titleSettings={titleSettings} />}
|
||||
</div>
|
||||
)}
|
||||
{/* 前端播放器(视频 + 控制条 + 播放按钮)*/}
|
||||
<FrontendPreviewPlayer
|
||||
assets={assets}
|
||||
template={template}
|
||||
videoRatio={videoRatio}
|
||||
ready={assetsReady}
|
||||
voiceAudioUrl={voiceAudioUrl}
|
||||
/>
|
||||
|
||||
{/* CSS 标题实时预览层 — z-index: 20,始终渲染在内容层之上 */}
|
||||
{titleSettings && <TitleOverlay titleSettings={titleSettings} />}
|
||||
</div>
|
||||
|
||||
{/* 素材信息 */}
|
||||
{assetsReady && assets.length > 0 && (
|
||||
|
||||
@@ -895,13 +895,12 @@
|
||||
max-height: 400px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(135deg, var(--color-gray-900), var(--color-primary-900));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-inverse);
|
||||
font-size: 36px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.xx-preview-video::before {
|
||||
@@ -910,6 +909,7 @@
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 72% 28%, rgba(255, 255, 255, 0.2), transparent 40%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.xx-preview-video video {
|
||||
|
||||
@@ -1,33 +1,22 @@
|
||||
/**
|
||||
* 预览素材加载 Hook
|
||||
* 根据选中的素材 ID 列表,批量获取素材详情(含 file_url、duration 等)
|
||||
* 根据选中的素材 ID 列表,逐个获取素材详情(含 file_url、duration 等)
|
||||
* 供前端预览播放器使用
|
||||
*
|
||||
* 注意:后端没有批量接口(/assets/batch 返回 405),
|
||||
* 因此直接使用 Promise.allSettled 并发请求单个 GET /assets/{id}
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef } from "react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { AxiosResponse } from "axios"
|
||||
|
||||
/** 批量获取素材详情的 API 路径 */
|
||||
const ASSETS_BATCH_URL = "/assets/batch"
|
||||
|
||||
/**
|
||||
* 通过 ID 列表批量获取素材
|
||||
* 优先使用批量接口,失败则回退为逐个获取
|
||||
* 通过 ID 列表逐个获取素材(并发)
|
||||
* 使用 Promise.allSettled 确保单个失败不影响整体
|
||||
*/
|
||||
async function fetchAssetsByIds(ids: string[]): Promise<AssetItem[]> {
|
||||
if (!ids.length) return []
|
||||
|
||||
try {
|
||||
// 尝试批量接口
|
||||
const { default: apiClient } = await import("@/api/client")
|
||||
const response = await apiClient.post(ASSETS_BATCH_URL, { ids })
|
||||
const items: AssetItem[] = response.data?.items || response.data || []
|
||||
if (items.length > 0) return items
|
||||
} catch {
|
||||
// 批量接口不存在,回退为逐个获取
|
||||
}
|
||||
|
||||
// 回退:逐个获取
|
||||
try {
|
||||
const { default: apiClient } = await import("@/api/client")
|
||||
const results = await Promise.allSettled(
|
||||
@@ -64,8 +53,11 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi
|
||||
const [ready, setReady] = useState(false)
|
||||
const requestIdRef = useRef(0)
|
||||
|
||||
// 稳定化 assetIds:只有内容真正变化时才更新引用
|
||||
const stableAssetIds = useStableArray(assetIds)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!assetIds.length || !enabled) {
|
||||
if (!stableAssetIds.length || !enabled) {
|
||||
setAssets([])
|
||||
setReady(false)
|
||||
return
|
||||
@@ -76,7 +68,7 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi
|
||||
setReady(false)
|
||||
|
||||
try {
|
||||
const result = await fetchAssetsByIds(assetIds)
|
||||
const result = await fetchAssetsByIds(stableAssetIds)
|
||||
// 防止竞态:只保留最新请求的结果
|
||||
if (requestIdRef.current === thisRequestId) {
|
||||
setAssets(result)
|
||||
@@ -92,7 +84,7 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [assetIds, enabled])
|
||||
}, [stableAssetIds, enabled])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
@@ -101,4 +93,22 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi
|
||||
return { assets, loading, ready, reload: load }
|
||||
}
|
||||
|
||||
/**
|
||||
* useStableArray — 数组内容稳定化 Hook
|
||||
* 只有数组内容真正变化时才返回新的引用,避免父组件 re-render 导致的无效更新
|
||||
*/
|
||||
function useStableArray<T>(array: T[]): T[] {
|
||||
const ref = useRef<T[]>(array)
|
||||
|
||||
// 比较数组内容是否真正变化
|
||||
const hasChanged =
|
||||
array.length !== ref.current.length || array.some((item, index) => item !== ref.current[index])
|
||||
|
||||
if (hasChanged) {
|
||||
ref.current = array
|
||||
}
|
||||
|
||||
return ref.current
|
||||
}
|
||||
|
||||
export default usePreviewAssets
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* 素材片段调度器 Hook
|
||||
* 控制原生 <video> 元素按时间线依次播放素材片段(入点→出点)
|
||||
* 实现前端预览播放,替代后端 FFmpeg 渲染
|
||||
* 素材片段调度器 Hook(多 video 元素方案 v2)
|
||||
* 每个片段对应一个独立 <video> 元素,全部预加载,通过 display 切换实现无缝播放
|
||||
* 替代单 video + 切 src 方案,消除片段切换延迟
|
||||
*/
|
||||
import React, { useState, useRef, useCallback, useEffect, useMemo } from "react"
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
|
||||
|
||||
/** 单个播放片段 */
|
||||
export interface PlaybackSegment {
|
||||
@@ -27,7 +27,7 @@ export interface SegmentSchedulerState {
|
||||
currentTime: number
|
||||
/** 总时长(秒) */
|
||||
totalDuration: number
|
||||
/** 当前片段索引(在 segments 数组中的位置) */
|
||||
/** 当前片段索引 */
|
||||
currentSegmentIndex: number
|
||||
/** 当前片段的本地播放时间 */
|
||||
segmentLocalTime: number
|
||||
@@ -43,8 +43,8 @@ export interface SegmentSchedulerState {
|
||||
togglePlayPause: () => void
|
||||
/** 跳转到全局时间 */
|
||||
seekTo: (time: number) => void
|
||||
/** 绑定到 <video> 元素 */
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
/** 每个片段对应的 video 元素 ref 数组 */
|
||||
videoRefs: React.MutableRefObject<(HTMLVideoElement | null)[]>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,10 +80,17 @@ function buildTimeline(segments: PlaybackSegment[]): number[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* useSegmentScheduler — 素材片段调度器
|
||||
* useSegmentScheduler — 多 video 元素版素材片段调度器
|
||||
*
|
||||
* 核心改变:
|
||||
* - 每个片段对应一个独立 <video> 元素(由组件渲染,ref 传入)
|
||||
* - 所有 video 在挂载时即设置 src + preload="auto",浏览器自动预加载
|
||||
* - 切换片段仅改 currentSegmentIndex + display,无需重新 load
|
||||
* - 实现无缝切换,无加载延迟
|
||||
*/
|
||||
export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedulerState {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
/** 每个片段对应的 video 元素 ref(由组件 JSX 渲染并绑定) */
|
||||
const videoRefs = useRef<(HTMLVideoElement | null)[]>([])
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0)
|
||||
@@ -91,9 +98,6 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
|
||||
const rafRef = useRef<number>(0)
|
||||
const isSeekingRef = useRef(false)
|
||||
|
||||
// 预加载用的隐藏 video 元素
|
||||
const preloadVideoRef = useRef<HTMLVideoElement | null>(null)
|
||||
|
||||
// 计算时间线
|
||||
const timelineStarts = useMemo(() => buildTimeline(segments), [segments])
|
||||
const totalDuration = useMemo(
|
||||
@@ -109,41 +113,63 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
|
||||
? currentTime - (timelineStarts[currentSegmentIndex] || 0) + currentSegment.startTime
|
||||
: 0
|
||||
|
||||
/** 切换到指定片段 */
|
||||
/**
|
||||
* 切换到指定片段
|
||||
* 不改变 src(video 已在 JSX 中设置),仅 seek + 等待可播
|
||||
*/
|
||||
const switchToSegment = useCallback(
|
||||
(index: number, seekToLocalTime?: number) => {
|
||||
const video = videoRef.current
|
||||
if (!video || index >= segments.length) return
|
||||
(index: number, seekToLocalTime?: number): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
// 暂停当前视频
|
||||
const prevVideo = videoRefs.current[currentSegmentIndex]
|
||||
if (prevVideo) prevVideo.pause()
|
||||
|
||||
const seg = segments[index]
|
||||
video.src = seg.videoUrl
|
||||
|
||||
const localTime = seekToLocalTime ?? seg.startTime
|
||||
// 等待 src 设置后再设置 currentTime
|
||||
const onLoaded = () => {
|
||||
video.currentTime = localTime
|
||||
video.removeEventListener("loadedmetadata", onLoaded)
|
||||
}
|
||||
video.addEventListener("loadedmetadata", onLoaded)
|
||||
|
||||
setCurrentSegmentIndex(index)
|
||||
|
||||
// 预加载下一段
|
||||
if (index + 1 < segments.length) {
|
||||
const nextSeg = segments[index + 1]
|
||||
if (!preloadVideoRef.current) {
|
||||
preloadVideoRef.current = document.createElement("video")
|
||||
preloadVideoRef.current.preload = "auto"
|
||||
const video = videoRefs.current[index]
|
||||
if (!video || index >= segments.length) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
preloadVideoRef.current.src = nextSeg.videoUrl
|
||||
}
|
||||
|
||||
const seg = segments[index]
|
||||
const localTime = seekToLocalTime ?? seg.startTime
|
||||
|
||||
// 设置播放位置
|
||||
video.currentTime = localTime
|
||||
|
||||
// 如果已有足够帧数据,直接 resolve
|
||||
if (video.readyState >= 2) {
|
||||
setCurrentSegmentIndex(index)
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
// 等待 canplay 事件
|
||||
const onCanPlay = () => {
|
||||
video.removeEventListener("canplay", onCanPlay)
|
||||
clearTimeout(timeoutId)
|
||||
setCurrentSegmentIndex(index)
|
||||
resolve()
|
||||
}
|
||||
|
||||
// 10 秒超时保护
|
||||
const timeoutId = setTimeout(() => {
|
||||
video.removeEventListener("canplay", onCanPlay)
|
||||
console.warn(
|
||||
`[useSegmentScheduler] 片段 ${index} 预加载超时 (10s), readyState=${video.readyState}`,
|
||||
)
|
||||
setCurrentSegmentIndex(index)
|
||||
resolve()
|
||||
}, 10000)
|
||||
|
||||
video.addEventListener("canplay", onCanPlay)
|
||||
})
|
||||
},
|
||||
[segments],
|
||||
[segments, currentSegmentIndex],
|
||||
)
|
||||
|
||||
/** 播放循环 — 检测片段边界并切换 */
|
||||
const tick = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
const video = videoRefs.current[currentSegmentIndex]
|
||||
if (!video || isSeekingRef.current) {
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
return
|
||||
@@ -154,23 +180,29 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
|
||||
|
||||
// 检查是否到达出点(容差 0.15s)
|
||||
if (video.currentTime >= seg.endTime - 0.15) {
|
||||
video.pause()
|
||||
const nextIndex = currentSegmentIndex + 1
|
||||
if (nextIndex < segments.length) {
|
||||
// 切到下一段
|
||||
switchToSegment(nextIndex)
|
||||
switchToSegment(nextIndex).then(() => {
|
||||
setIsPlaying(true)
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
const nextVideo = videoRefs.current[nextIndex]
|
||||
if (nextVideo) {
|
||||
nextVideo
|
||||
.play()
|
||||
.catch((e) => console.warn("[useSegmentScheduler] auto-play next segment failed:", e))
|
||||
}
|
||||
})
|
||||
const accumulatedTime =
|
||||
(timelineStarts[currentSegmentIndex] || 0) + (seg.endTime - seg.startTime)
|
||||
setCurrentTime(accumulatedTime)
|
||||
} else {
|
||||
// 播放结束
|
||||
video.pause()
|
||||
setIsPlaying(false)
|
||||
setIsEnded(true)
|
||||
setCurrentTime(totalDuration)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 更新全局时间
|
||||
const globalTime =
|
||||
(timelineStarts[currentSegmentIndex] || 0) + (video.currentTime - seg.startTime)
|
||||
setCurrentTime(Math.max(0, Math.min(globalTime, totalDuration)))
|
||||
@@ -181,33 +213,38 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
|
||||
|
||||
/** 播放 */
|
||||
const play = useCallback(async () => {
|
||||
const video = videoRef.current
|
||||
if (!video || !canPlay) return
|
||||
if (!canPlay) return
|
||||
|
||||
setIsEnded(false)
|
||||
|
||||
// 如果还没设置 src(首次播放),先加载第一段
|
||||
if (!video.src || video.src === "") {
|
||||
switchToSegment(0)
|
||||
// 确保第一段可播放
|
||||
const firstVideo = videoRefs.current[0]
|
||||
if (firstVideo && currentSegmentIndex === 0 && firstVideo.readyState < 2) {
|
||||
await switchToSegment(0)
|
||||
}
|
||||
|
||||
const video = videoRefs.current[currentSegmentIndex]
|
||||
if (!video) return
|
||||
|
||||
try {
|
||||
await video.play()
|
||||
const playPromise = video.play()
|
||||
if (playPromise !== undefined) {
|
||||
await playPromise
|
||||
}
|
||||
setIsPlaying(true)
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
} catch {
|
||||
// 自动播放可能被浏览器阻止,忽略
|
||||
console.warn("[useSegmentScheduler] auto-play blocked by browser")
|
||||
} catch (err) {
|
||||
console.warn("[useSegmentScheduler] 播放失败:", err)
|
||||
}
|
||||
}, [canPlay, switchToSegment, tick])
|
||||
}, [canPlay, switchToSegment, tick, currentSegmentIndex])
|
||||
|
||||
/** 暂停 */
|
||||
const pause = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
const video = videoRefs.current[currentSegmentIndex]
|
||||
if (video) video.pause()
|
||||
setIsPlaying(false)
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
}, [])
|
||||
}, [currentSegmentIndex])
|
||||
|
||||
/** 切换播放/暂停 */
|
||||
const togglePlayPause = useCallback(() => {
|
||||
@@ -217,18 +254,15 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
|
||||
if (isEnded) {
|
||||
// 播放结束后再次播放,从头开始
|
||||
setIsEnded(false)
|
||||
switchToSegment(0, segments[0]?.startTime)
|
||||
const video = videoRef.current
|
||||
if (video) {
|
||||
const onSeeked = () => {
|
||||
video.play().catch(() => {})
|
||||
switchToSegment(0, segments[0]?.startTime).then(() => {
|
||||
const video = videoRefs.current[0]
|
||||
if (video) {
|
||||
video.play().catch((e) => console.warn("[useSegmentScheduler] restart play failed:", e))
|
||||
setIsPlaying(true)
|
||||
setCurrentTime(0)
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
video.removeEventListener("seeked", onSeeked)
|
||||
}
|
||||
video.addEventListener("seeked", onSeeked)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
play()
|
||||
}
|
||||
@@ -237,29 +271,24 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
|
||||
|
||||
/** 跳转到指定全局时间 */
|
||||
const seekTo = useCallback(
|
||||
(time: number) => {
|
||||
const video = videoRef.current
|
||||
if (!video || !canPlay) return
|
||||
async (time: number) => {
|
||||
if (!canPlay) return
|
||||
|
||||
const clampedTime = Math.max(0, Math.min(time, totalDuration))
|
||||
const { index, localTime } = findSegmentAtTime(segments, clampedTime)
|
||||
|
||||
isSeekingRef.current = true
|
||||
|
||||
// 如果片段变了,需要切换 src
|
||||
if (index !== currentSegmentIndex) {
|
||||
switchToSegment(index, localTime)
|
||||
// switchToSegment 会设置 src 并在 loadedmetadata 后设置 currentTime
|
||||
// 所以这里不需要再设置
|
||||
await switchToSegment(index, localTime)
|
||||
} else {
|
||||
video.currentTime = localTime
|
||||
const video = videoRefs.current[index]
|
||||
if (video) video.currentTime = localTime
|
||||
}
|
||||
|
||||
setCurrentTime(clampedTime)
|
||||
setCurrentSegmentIndex(index)
|
||||
setIsEnded(false)
|
||||
|
||||
// 延迟恢复 tick 检测
|
||||
setTimeout(() => {
|
||||
isSeekingRef.current = false
|
||||
}, 200)
|
||||
@@ -267,14 +296,18 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
|
||||
[canPlay, totalDuration, segments, currentSegmentIndex, switchToSegment],
|
||||
)
|
||||
|
||||
// 确保 videoRefs 数组长度与 segments 一致
|
||||
useEffect(() => {
|
||||
videoRefs.current = videoRefs.current.slice(0, segments.length)
|
||||
while (videoRefs.current.length < segments.length) {
|
||||
videoRefs.current.push(null)
|
||||
}
|
||||
}, [segments])
|
||||
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
if (preloadVideoRef.current) {
|
||||
preloadVideoRef.current.src = ""
|
||||
preloadVideoRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -285,11 +318,6 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
|
||||
setCurrentTime(0)
|
||||
setCurrentSegmentIndex(0)
|
||||
setIsEnded(false)
|
||||
const video = videoRef.current
|
||||
if (video) {
|
||||
video.pause()
|
||||
video.src = ""
|
||||
}
|
||||
}, [segments])
|
||||
|
||||
return {
|
||||
@@ -304,7 +332,7 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
|
||||
pause,
|
||||
togglePlayPause,
|
||||
seekTo,
|
||||
videoRef,
|
||||
videoRefs,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,9 +20,16 @@ vi.mock("@/api/auth", () => ({
|
||||
refreshAccessToken: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/auth/tokenRefresh", () => ({
|
||||
scheduleProactiveRefresh: vi.fn(),
|
||||
cancelProactiveRefresh: vi.fn(),
|
||||
executeTokenRefresh: vi.fn(),
|
||||
}))
|
||||
|
||||
import { message } from "antd"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { refreshAccessToken } from "@/api/auth"
|
||||
import { executeTokenRefresh } from "@/api/auth/tokenRefresh"
|
||||
import apiClient from "@/api/client"
|
||||
|
||||
// 从真实实例取出拦截器回调
|
||||
@@ -264,23 +271,28 @@ describe("apiClient - 401 token refresh", () => {
|
||||
expect(window.location.href).toBe("/")
|
||||
})
|
||||
|
||||
it("refreshes token on 401 and calls setAuth", async () => {
|
||||
it("refreshes token on 401 and calls executeTokenRefresh", async () => {
|
||||
const mockSetAuth = vi.fn()
|
||||
vi.mocked(useAuthStore.getState).mockReturnValue({
|
||||
let currentAccessToken = "old-access"
|
||||
vi.mocked(useAuthStore.getState).mockImplementation(() => ({
|
||||
user: { id: "1", email: "test@test.com" },
|
||||
accessToken: "old-access",
|
||||
accessToken: currentAccessToken,
|
||||
refreshToken: "old-refresh",
|
||||
isAuthenticated: true,
|
||||
clearAuth: vi.fn(),
|
||||
setAuth: mockSetAuth,
|
||||
} as any)
|
||||
vi.mocked(refreshAccessToken).mockResolvedValue({
|
||||
access_token: "new-access",
|
||||
refresh_token: "new-refresh",
|
||||
} as never)
|
||||
setAuth: ((_user: any, newAccess: string, _newRefresh: string) => {
|
||||
currentAccessToken = newAccess
|
||||
mockSetAuth(_user, newAccess, _newRefresh)
|
||||
}) as any,
|
||||
}))
|
||||
|
||||
// Mock executeTokenRefresh to simulate successful refresh
|
||||
vi.mocked(executeTokenRefresh).mockImplementation(() => {
|
||||
currentAccessToken = "new-access"
|
||||
mockSetAuth({ id: "1", email: "test@test.com" }, "new-access", "new-refresh")
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
// 拦截器重试时会调用 apiClient(config),会真的发请求,最终会 reject
|
||||
// 但我们只关心刷新逻辑是否正确执行
|
||||
const err = makeAxiosError(401, { detail: "Unauthorized" })
|
||||
|
||||
try {
|
||||
@@ -289,31 +301,45 @@ describe("apiClient - 401 token refresh", () => {
|
||||
// 重试会因为没有真实网络而失败,忽略
|
||||
}
|
||||
|
||||
expect(refreshAccessToken).toHaveBeenCalledWith("old-refresh")
|
||||
expect(executeTokenRefresh).toHaveBeenCalled()
|
||||
expect(mockSetAuth).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("handles refresh failure by logging out", async () => {
|
||||
const mockClearAuth = vi.fn()
|
||||
vi.mocked(useAuthStore.getState).mockReturnValue({
|
||||
// After executeTokenRefresh fails, it clears auth (sets accessToken to null)
|
||||
// and redirects to /login. The promise resolves (doesn't reject).
|
||||
let currentAccessToken: string | null = "old-access"
|
||||
vi.mocked(useAuthStore.getState).mockImplementation(() => ({
|
||||
user: { id: "1", email: "test@test.com" },
|
||||
accessToken: "old-access",
|
||||
accessToken: currentAccessToken,
|
||||
refreshToken: "old-refresh",
|
||||
isAuthenticated: true,
|
||||
clearAuth: mockClearAuth,
|
||||
isAuthenticated: currentAccessToken !== null,
|
||||
clearAuth: (() => {
|
||||
currentAccessToken = null
|
||||
mockClearAuth()
|
||||
window.location.href = "/login"
|
||||
}) as any,
|
||||
setAuth: vi.fn(),
|
||||
} as any)
|
||||
vi.mocked(refreshAccessToken).mockRejectedValue(new Error("refresh failed") as never)
|
||||
}))
|
||||
// Mock executeTokenRefresh: simulates failure → clears auth + redirects
|
||||
vi.mocked(executeTokenRefresh).mockImplementation(() => {
|
||||
currentAccessToken = null
|
||||
mockClearAuth()
|
||||
window.location.href = "/login"
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
const err = makeAxiosError(401, { detail: "Unauthorized" })
|
||||
|
||||
try {
|
||||
await responseErrorInterceptor(err)
|
||||
} catch {
|
||||
// expected
|
||||
// expected - rejects because accessToken is null after failed refresh
|
||||
}
|
||||
|
||||
expect(executeTokenRefresh).toHaveBeenCalled()
|
||||
expect(mockClearAuth).toHaveBeenCalled()
|
||||
expect(window.location.href).toBe("/")
|
||||
expect(window.location.href).toBe("/login")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# ============================================================
|
||||
# API 基础镜像(预构建)
|
||||
# 预装系统依赖 + Python 依赖,业务构建从此镜像开始
|
||||
# 当 requirements-base.txt 或 requirements.txt 变更时重新构建
|
||||
# 目标:将 API Image 构建时间从 15-20 分钟降至 3-5 分钟
|
||||
# ============================================================
|
||||
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
|
||||
|
||||
# 使用阿里云镜像加速
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
|
||||
# 预装系统依赖(gcc 编译 psycopg/pg 扩展,libpq-dev 编译期,libpq5 运行期,ffmpeg 封面取帧)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
libpq5 \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 创建虚拟环境
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
WORKDIR /tmp
|
||||
|
||||
# 预装 Python 基础依赖
|
||||
COPY requirements-base.txt requirements.txt ./
|
||||
RUN pip install --no-cache-dir \
|
||||
-i https://mirrors.aliyun.com/pypi/simple/ \
|
||||
--trusted-host mirrors.aliyun.com \
|
||||
-r requirements-base.txt -r requirements.txt
|
||||
|
||||
# 虚拟环境瘦身
|
||||
RUN find /opt/venv -name "*.so" -type f -exec strip --strip-all {} \; 2>/dev/null || true
|
||||
RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null; \
|
||||
find /opt/venv -name "*.pyc" -delete 2>/dev/null || true
|
||||
|
||||
# 清理临时文件
|
||||
RUN rm -f /tmp/requirements-base.txt /tmp/requirements.txt
|
||||
|
||||
ENV PYTHONPATH=/app
|
||||
+12
-66
@@ -1,81 +1,28 @@
|
||||
# ============================================================
|
||||
# API Dockerfile - FastAPI 应用
|
||||
# 优化:多阶段构建 + pip cache mount + 依赖分层缓存
|
||||
# 优化:从预构建基础镜像开始,仅叠加业务代码
|
||||
# 基础镜像包含所有系统依赖和 Python 依赖,构建时间 < 5 分钟
|
||||
# ============================================================
|
||||
|
||||
# ==================== Builder 阶段 ====================
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS builder
|
||||
|
||||
# 使用阿里云镜像加速
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
|
||||
# 安装编译依赖(仅 builder 需要)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 创建虚拟环境
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
WORKDIR /tmp
|
||||
|
||||
# ---- 依赖分层:基础依赖(变化少,缓存命中率高)----
|
||||
COPY requirements-base.txt /tmp/requirements-base.txt
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
|
||||
pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
|
||||
-r /tmp/requirements-base.txt \
|
||||
&& rm /tmp/requirements-base.txt
|
||||
|
||||
# ---- 依赖分层:业务依赖(变化频繁)----
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
|
||||
pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
|
||||
-r /tmp/requirements.txt \
|
||||
&& rm /tmp/requirements.txt
|
||||
|
||||
# ---- Python 依赖瘦身 ----
|
||||
RUN find /opt/venv -name "*.so" -type f -exec strip --strip-all {} \; 2>/dev/null || true
|
||||
RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null; \
|
||||
find /opt/venv -name "*.pyc" -delete 2>/dev/null || true
|
||||
|
||||
# ==================== Runtime 阶段 ====================
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS runtime
|
||||
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-api-base:latest
|
||||
|
||||
# 构建参数:版本号(CI 传入 commit hash)
|
||||
ARG APP_VERSION=dev
|
||||
|
||||
# 使用阿里云镜像加速
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
|
||||
# 只装运行时需要的库(libpq5 是 psycopg2 运行时依赖,ffmpeg 用于封面兜底取帧)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 从 builder 复制虚拟环境
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制应用代码
|
||||
COPY apps/api/ /app/apps/api/
|
||||
COPY packages/ /app/packages/
|
||||
COPY alembic.ini /app/alembic.ini
|
||||
COPY migrations/ /app/migrations/
|
||||
COPY alembic/ /app/alembic/
|
||||
COPY scripts/ /app/scripts/
|
||||
# 复制应用代码(按变化频率从低到高排序,最大化层缓存命中)
|
||||
COPY alembic.ini ./alembic.ini
|
||||
COPY migrations/ ./migrations/
|
||||
COPY alembic/ ./alembic/
|
||||
COPY scripts/ ./scripts/
|
||||
COPY packages/ ./packages/
|
||||
COPY apps/api/ ./apps/api/
|
||||
|
||||
# 设置环境变量
|
||||
ENV PATH="/opt/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
ENV PYTHONPATH=/app
|
||||
ENV PYTHONPATH=/app:/app/apps/api
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
@@ -84,5 +31,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)"
|
||||
|
||||
# API 入口点
|
||||
WORKDIR /app/apps/api
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["uvicorn", "apps.api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# ============================================================
|
||||
# Worker 统一基础镜像(预构建)
|
||||
# 预装系统依赖 + Python 全部依赖 + CJK 字体
|
||||
# 业务构建从此镜像开始,只需 COPY 业务代码,构建时间 < 5 分钟
|
||||
# 当 requirements-*.txt 变更时重新构建
|
||||
# ============================================================
|
||||
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
|
||||
|
||||
# 使用阿里云镜像加速
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
|
||||
# 预装系统依赖(编译工具 + 运行时 + CJK 字体用于 ASS 字幕渲染)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
g++ \
|
||||
python3-dev \
|
||||
binutils \
|
||||
ffmpeg \
|
||||
libglib2.0-0 \
|
||||
fonts-noto-cjk \
|
||||
&& fc-cache -fv \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 创建虚拟环境
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
WORKDIR /tmp
|
||||
|
||||
# 预装全部 Python 依赖(基础 + Worker 大包 + 业务依赖)
|
||||
COPY requirements-base.txt requirements.txt requirements-worker.txt ./
|
||||
RUN pip install --no-cache-dir \
|
||||
-i https://mirrors.aliyun.com/pypi/simple/ \
|
||||
--trusted-host mirrors.aliyun.com \
|
||||
-r requirements-base.txt \
|
||||
-r requirements-worker.txt \
|
||||
-r requirements.txt
|
||||
|
||||
# 虚拟环境瘦身
|
||||
RUN find /opt/venv -name "*.so" -type f -exec strip --strip-all {} \; 2>/dev/null || true
|
||||
RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null; \
|
||||
find /opt/venv -name "*.pyc" -delete 2>/dev/null || true
|
||||
|
||||
# 清理临时文件
|
||||
RUN rm -f /tmp/requirements-base.txt /tmp/requirements.txt /tmp/requirements-worker.txt
|
||||
|
||||
ENV PYTHONPATH=/app:/app/packages
|
||||
@@ -1,51 +1,16 @@
|
||||
# ============================================================
|
||||
# Worker Dockerfile - 分层缓存优化版
|
||||
# 优化:基础依赖 + Worker大包预构建为基础镜像,业务构建仅叠加业务依赖
|
||||
# 基础镜像:worker-base-builder / worker-base-runtime
|
||||
# Worker Dockerfile - 极简化
|
||||
# 从预构建统一基础镜像开始,仅叠加业务代码
|
||||
# 基础镜像包含:系统依赖 + 全部 Python 依赖 + CJK 字体
|
||||
# 构建时间目标:< 5 分钟
|
||||
# ============================================================
|
||||
|
||||
# ==================== Builder 阶段 ====================
|
||||
# 从预构建的builder基础镜像开始,已经包含:
|
||||
# - 编译工具 (gcc/g++/python3-dev/binutils)
|
||||
# - requirements-base.txt 全部依赖
|
||||
# - requirements-worker.txt 全部依赖 (numpy/scipy/opencv)
|
||||
# - 预strip的.so文件
|
||||
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/worker-base-builder:latest AS builder
|
||||
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-worker-base:latest
|
||||
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
WORKDIR /tmp
|
||||
|
||||
# ---- 安装业务依赖(变化频繁,单独一层)----
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
|
||||
pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
|
||||
-r /tmp/requirements.txt \
|
||||
&& rm /tmp/requirements.txt
|
||||
|
||||
# ---- 增量瘦身(清理新增业务依赖的冗余文件)----
|
||||
RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null; \
|
||||
find /opt/venv -name "*.pyc" -delete 2>/dev/null || true
|
||||
|
||||
# ==================== Runtime 阶段 ====================
|
||||
# 从预构建的runtime基础镜像开始,已经包含:
|
||||
# - ffmpeg
|
||||
# - libglib2.0-0
|
||||
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/worker-base-runtime:latest AS runtime
|
||||
|
||||
# 构建参数:版本号
|
||||
# 构建参数:版本号(CI 传入 commit hash)
|
||||
ARG APP_VERSION=dev
|
||||
|
||||
# 从 builder 复制 Python 虚拟环境
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
# 设置 Python 环境变量
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
ENV PYTHONPATH=/app:/app/packages
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
# 创建非 root 用户(极少变化,放最前)
|
||||
# 创建非 root 用户
|
||||
RUN groupadd -r celery \
|
||||
&& useradd -r -g celery -d /app -s /sbin/nologin celery \
|
||||
&& mkdir -p /app/generated \
|
||||
@@ -53,25 +18,26 @@ RUN groupadd -r celery \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制文件按变化频率从低到高排序,最大化层缓存命中
|
||||
# 设置 Python 环境变量
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
ENV PYTHONPATH=/app:/app/packages
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
# 复制文件(按变化频率从低到高排序,最大化层缓存命中)
|
||||
COPY alembic.ini /app/alembic.ini
|
||||
COPY migrations/ /app/migrations/
|
||||
COPY packages/ /app/packages/
|
||||
COPY apps/api/app/config.py /app/apps/api/app/config.py
|
||||
COPY apps/api/app/core/ /app/apps/api/app/core/
|
||||
|
||||
# 复制 Worker 启动脚本
|
||||
# Worker 启动脚本
|
||||
COPY infra/docker/entrypoint-worker.sh /usr/local/bin/entrypoint-worker.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint-worker.sh
|
||||
|
||||
# 业务代码(变化最频繁,放最后)
|
||||
COPY apps/worker/ /app/apps/worker/
|
||||
|
||||
|
||||
# ---- Install CJK fonts for ASS subtitle rendering ----
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends fonts-noto-cjk && fc-cache -fv && rm -rf /var/lib/apt/lists/*
|
||||
USER celery
|
||||
|
||||
# Worker 入口点
|
||||
WORKDIR /app/apps/worker
|
||||
CMD ["/usr/local/bin/entrypoint-worker.sh"]
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
# 修改此文件会触发完整重新构建,请谨慎修改
|
||||
|
||||
# 数据库(基础层)
|
||||
psycopg2-binary==2.9.9
|
||||
psycopg[binary]==3.2.2
|
||||
sqlalchemy==2.0.35
|
||||
alembic==1.13.3
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
# 用法: docker_build_push.sh [--no-cache] <Dockerfile> <image_tag> <cache_ref> [build_arg...]
|
||||
set -eu
|
||||
|
||||
# 单次 build 超时时间(秒),防止 docker buildx build 无限挂起
|
||||
BUILD_TIMEOUT=1500
|
||||
|
||||
NO_CACHE_FLAG=""
|
||||
if [ "$1" = "--no-cache" ]; then
|
||||
NO_CACHE_FLAG="--no-cache"
|
||||
@@ -43,7 +46,7 @@ build_with_cache_retry() {
|
||||
local build_output
|
||||
local exit_code
|
||||
set +e
|
||||
build_output=$(docker buildx build \
|
||||
build_output=$(timeout ${BUILD_TIMEOUT} docker buildx build \
|
||||
$NO_CACHE_FLAG \
|
||||
$BUILD_ARGS \
|
||||
--cache-from "type=local,src=${LOCAL_CACHE_DIR}" \
|
||||
@@ -60,6 +63,12 @@ build_with_cache_retry() {
|
||||
echo "$build_output"
|
||||
return 0
|
||||
fi
|
||||
# 超时退出(exit code 124)
|
||||
if [ $exit_code -eq 124 ]; then
|
||||
echo "❌ Docker build TIMEOUT after ${BUILD_TIMEOUT}s - build hung and was killed"
|
||||
echo "$build_output" | tail -20
|
||||
return $exit_code
|
||||
fi
|
||||
# 检测到缓存损坏类错误,清掉本地缓存重试
|
||||
if echo "$build_output" | grep -qE "parent snapshot.*not found|snapshot.*does not exist|cache.*corrupt|failed to compute cache key"; then
|
||||
echo "$build_output"
|
||||
@@ -68,7 +77,7 @@ build_with_cache_retry() {
|
||||
rm -rf "${LOCAL_CACHE_DIR}"
|
||||
mkdir -p "${LOCAL_CACHE_DIR}"
|
||||
# 清理buildx builder的内部snapshot状态
|
||||
docker buildx prune -f -a >/dev/null 2>&1 || true
|
||||
docker buildx prune -f -a > /dev/null 2>&1 || true
|
||||
attempt=$((attempt + 1))
|
||||
else
|
||||
# 非缓存类错误,直接输出并返回
|
||||
@@ -78,7 +87,7 @@ build_with_cache_retry() {
|
||||
done
|
||||
# 重试完还是失败,不用本地缓存最后试一次(只从registry读)
|
||||
echo "⚠️ All cached attempts failed, building without local cache..."
|
||||
docker buildx build \
|
||||
timeout ${BUILD_TIMEOUT} docker buildx build \
|
||||
$NO_CACHE_FLAG \
|
||||
$BUILD_ARGS \
|
||||
--cache-from "type=registry,ref=${CACHE_REF}" \
|
||||
@@ -93,6 +102,7 @@ build_with_cache_retry() {
|
||||
echo "=== Step 1: Build & push image (local cache + registry cache, with auto-repair) ==="
|
||||
echo "Local cache: ${LOCAL_CACHE_DIR}"
|
||||
echo "Registry cache: ${CACHE_REF}"
|
||||
echo "Build timeout: ${BUILD_TIMEOUT}s"
|
||||
echo ""
|
||||
|
||||
build_with_cache_retry
|
||||
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# 重建 API 基础镜像脚本
|
||||
# 用途:当 requirements-base.txt 或 requirements.txt 变更时手动触发
|
||||
# 前提:需要在已登录 ACR 的构建服务器上执行
|
||||
# ============================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
IMAGE_NAME="saas-api-base"
|
||||
TAG="latest"
|
||||
FULL_TAG="${REGISTRY}/${IMAGE_NAME}:${TAG}"
|
||||
|
||||
echo "========================================="
|
||||
echo "🔨 Rebuilding API base image"
|
||||
echo " Registry: ${REGISTRY}"
|
||||
echo " Image: ${FULL_TAG}"
|
||||
echo " Context: ${REPO_ROOT}"
|
||||
echo "========================================="
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# 构建并推送
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--tag "${FULL_TAG}" \
|
||||
--push \
|
||||
-f infra/docker/api-base.Dockerfile \
|
||||
.
|
||||
|
||||
echo ""
|
||||
echo "✅ API base image pushed: ${FULL_TAG}"
|
||||
|
||||
# 显示镜像大小
|
||||
docker pull "${FULL_TAG}" > /dev/null 2>&1
|
||||
docker images "${FULL_TAG}" --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Unit tests for apps/api/app/api/routes/health.py
|
||||
|
||||
覆盖 _check_database() 和 _check_migrations() 中 psycopg3 连接逻辑。
|
||||
确保增量覆盖率 ≥ 60%(目标覆盖 lines 52, 127)。
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCheckDatabase:
|
||||
"""Tests for _check_database() health check function."""
|
||||
|
||||
@patch("apps.api.app.api.routes.health.settings")
|
||||
@patch("apps.api.app.api.routes.health.psycopg.connect")
|
||||
async def test_check_database_success(self, mock_connect, mock_settings):
|
||||
"""PostgreSQL 连接成功时返回 healthy。"""
|
||||
mock_settings.USE_IN_MEMORY_DB = False
|
||||
mock_settings.DATABASE_URL = "postgresql+psycopg://test:test@localhost/test"
|
||||
|
||||
# Mock connection and cursor
|
||||
mock_conn = MagicMock()
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
|
||||
mock_cursor.__exit__ = MagicMock(return_value=False)
|
||||
mock_cursor.fetchone.return_value = (1,)
|
||||
mock_conn.cursor.return_value = mock_cursor
|
||||
mock_connect.return_value = mock_conn
|
||||
|
||||
from apps.api.app.api.routes.health import _check_database
|
||||
|
||||
result = await _check_database()
|
||||
|
||||
assert result["status"] == "healthy"
|
||||
assert result["type"] == "postgresql"
|
||||
assert result["message"] == "Database connection successful"
|
||||
mock_connect.assert_called_once_with(
|
||||
"postgresql+psycopg://test:test@localhost/test", connect_timeout=3
|
||||
)
|
||||
mock_cursor.execute.assert_called_once_with("SELECT 1")
|
||||
mock_conn.close.assert_called_once()
|
||||
|
||||
@patch("apps.api.app.api.routes.health.settings")
|
||||
@patch("apps.api.app.api.routes.health.psycopg.connect")
|
||||
async def test_check_database_connection_failure(self, mock_connect, mock_settings):
|
||||
"""PostgreSQL 连接失败时返回 unhealthy。"""
|
||||
mock_settings.USE_IN_MEMORY_DB = False
|
||||
mock_settings.DATABASE_URL = "postgresql+psycopg://test:test@localhost/test"
|
||||
mock_connect.side_effect = Exception("connection refused")
|
||||
|
||||
from apps.api.app.api.routes.health import _check_database
|
||||
|
||||
result = await _check_database()
|
||||
|
||||
assert result["status"] == "unhealthy"
|
||||
assert result["type"] == "postgresql"
|
||||
assert "connection refused" in result["message"]
|
||||
|
||||
@patch("apps.api.app.api.routes.health.settings")
|
||||
async def test_check_database_in_memory(self, mock_settings):
|
||||
"""使用内存数据库时跳过 PostgreSQL 检查。"""
|
||||
mock_settings.USE_IN_MEMORY_DB = True
|
||||
|
||||
from apps.api.app.api.routes.health import _check_database
|
||||
|
||||
result = await _check_database()
|
||||
|
||||
assert result["status"] == "healthy"
|
||||
assert result["type"] == "in_memory"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCheckMigrations:
|
||||
"""Tests for _check_migrations() health check function."""
|
||||
|
||||
@patch("apps.api.app.api.routes.health.settings")
|
||||
@patch("apps.api.app.api.routes.health.psycopg.connect")
|
||||
async def test_check_migrations_success(self, mock_connect, mock_settings):
|
||||
"""所有迁移表存在时返回 healthy。"""
|
||||
mock_settings.USE_IN_MEMORY_DB = False
|
||||
mock_settings.DATABASE_URL = "postgresql+psycopg://test:test@localhost/test"
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
|
||||
mock_cursor.__exit__ = MagicMock(return_value=False)
|
||||
mock_cursor.fetchone.return_value = (5,) # 5 tables found
|
||||
mock_conn.cursor.return_value = mock_cursor
|
||||
mock_connect.return_value = mock_conn
|
||||
|
||||
from apps.api.app.api.routes.health import _check_migrations
|
||||
|
||||
result = await _check_migrations()
|
||||
|
||||
assert result["status"] == "healthy"
|
||||
assert result["message"] == "Database migrations applied"
|
||||
mock_connect.assert_called_once_with(
|
||||
"postgresql+psycopg://test:test@localhost/test", connect_timeout=3
|
||||
)
|
||||
mock_conn.close.assert_called_once()
|
||||
|
||||
@patch("apps.api.app.api.routes.health.settings")
|
||||
@patch("apps.api.app.api.routes.health.psycopg.connect")
|
||||
async def test_check_migrations_missing_tables(self, mock_connect, mock_settings):
|
||||
"""迁移表不完整时返回 unhealthy。"""
|
||||
mock_settings.USE_IN_MEMORY_DB = False
|
||||
mock_settings.DATABASE_URL = "postgresql+psycopg://test:test@localhost/test"
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
|
||||
mock_cursor.__exit__ = MagicMock(return_value=False)
|
||||
mock_cursor.fetchone.return_value = (2,) # Only 2 of 5 tables
|
||||
mock_conn.cursor.return_value = mock_cursor
|
||||
mock_connect.return_value = mock_conn
|
||||
|
||||
from apps.api.app.api.routes.health import _check_migrations
|
||||
|
||||
result = await _check_migrations()
|
||||
|
||||
assert result["status"] == "unhealthy"
|
||||
assert "Missing tables" in result["message"]
|
||||
assert "2/5" in result["message"]
|
||||
|
||||
@patch("apps.api.app.api.routes.health.settings")
|
||||
@patch("apps.api.app.api.routes.health.psycopg.connect")
|
||||
async def test_check_migrations_connection_failure(self, mock_connect, mock_settings):
|
||||
"""数据库连接失败时返回 unhealthy。"""
|
||||
mock_settings.USE_IN_MEMORY_DB = False
|
||||
mock_settings.DATABASE_URL = "postgresql+psycopg://test:test@localhost/test"
|
||||
mock_connect.side_effect = Exception("connection refused")
|
||||
|
||||
from apps.api.app.api.routes.health import _check_migrations
|
||||
|
||||
result = await _check_migrations()
|
||||
|
||||
assert result["status"] == "unhealthy"
|
||||
assert "Migration check failed" in result["message"]
|
||||
|
||||
@patch("apps.api.app.api.routes.health.settings")
|
||||
async def test_check_migrations_in_memory(self, mock_settings):
|
||||
"""使用内存数据库时跳过迁移检查。"""
|
||||
mock_settings.USE_IN_MEMORY_DB = True
|
||||
|
||||
from apps.api.app.api.routes.health import _check_migrations
|
||||
|
||||
result = await _check_migrations()
|
||||
|
||||
assert result["status"] == "healthy"
|
||||
assert "no migrations needed" in result["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestStartupCheck:
|
||||
"""Tests for startup_check() endpoint."""
|
||||
|
||||
@patch("apps.api.app.api.routes.health._check_migrations")
|
||||
@patch("apps.api.app.api.routes.health._check_database")
|
||||
async def test_startup_all_healthy(self, mock_db, mock_mig):
|
||||
"""所有检查通过时返回 started。"""
|
||||
mock_db.return_value = {"status": "healthy"}
|
||||
mock_mig.return_value = {"status": "healthy"}
|
||||
|
||||
from apps.api.app.api.routes.health import startup_check
|
||||
|
||||
result = await startup_check()
|
||||
|
||||
assert result["status"] == "started"
|
||||
|
||||
@patch("apps.api.app.api.routes.health._check_migrations")
|
||||
@patch("apps.api.app.api.routes.health._check_database")
|
||||
async def test_startup_db_unhealthy(self, mock_db, mock_mig):
|
||||
"""数据库不健康时返回 starting + 503。"""
|
||||
mock_db.return_value = {"status": "unhealthy", "message": "fail"}
|
||||
mock_mig.return_value = {"status": "healthy"}
|
||||
|
||||
from apps.api.app.api.routes.health import startup_check
|
||||
|
||||
result = await startup_check()
|
||||
|
||||
assert result.status_code == 503
|
||||
import json
|
||||
body = json.loads(result.body)
|
||||
assert body["status"] == "starting"
|
||||
Reference in New Issue
Block a user