Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1af1d24d3c | |||
| 10897b3ee1 | |||
| c5a949d502 | |||
| 98e74646f9 | |||
| de53975784 | |||
| 0a447b4b68 | |||
| cad16b3f87 | |||
| 5ecc75c5ba | |||
| 17494a1cde | |||
| 0320bea6b5 |
+1
-2
@@ -1,2 +1 @@
|
||||
CI trigger file - safe to delete
|
||||
updated!
|
||||
trigger: 1784009947
|
||||
|
||||
@@ -172,250 +172,6 @@ jobs:
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
|
||||
validate-code-quality:
|
||||
name: Validate - Code Quality
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
permissions:
|
||||
contents: write
|
||||
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: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-base.txt && break
|
||||
echo "pip install requirements-base.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements.txt && break
|
||||
echo "pip install requirements.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-dev.txt && break
|
||||
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install --no-binary :all: black==26.5.1 isort==8.0.1 && break
|
||||
echo "pip install black/isort 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
- name: Run code quality and security checks
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: bash scripts/ci/validate_code_quality.sh
|
||||
- name: Auto-fix formatting (black + isort)
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: python3 scripts/ci/auto_fix_formatting.py
|
||||
- name: CI failure notification
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
|
||||
run: |
|
||||
set +e
|
||||
FAILED_JOB="Validate - Code Quality" python3 scripts/ci_notify_failure.py
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_end.sh
|
||||
- name: Notify on failure
|
||||
continue-on-error: true
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="Validate - Code Quality" python3 scripts/ci_notify.py
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
validate-type-check:
|
||||
name: Validate - Type Check (mypy)
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
permissions:
|
||||
contents: read
|
||||
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: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-base.txt && break
|
||||
echo "pip install requirements-base.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements.txt && break
|
||||
echo "pip install requirements.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-dev.txt && break
|
||||
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
- name: Run mypy type check
|
||||
shell: bash
|
||||
run: bash scripts/ci/validate_mypy.sh
|
||||
- name: CI failure notification
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
|
||||
run: |
|
||||
set +e
|
||||
FAILED_JOB="Validate - Type Check (mypy)" python3 scripts/ci_notify_failure.py
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_end.sh
|
||||
- name: Notify on failure
|
||||
continue-on-error: true
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="Validate - Type Check (mypy)" python3 scripts/ci_notify.py
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
validate-migration:
|
||||
name: Validate - Migration (alembic)
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
|
||||
USE_IN_MEMORY_DB: 'false'
|
||||
CI_USE_SHARED_PG: 'true'
|
||||
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: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-base.txt && break
|
||||
echo "pip install requirements-base.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements.txt && break
|
||||
echo "pip install requirements.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-dev.txt && break
|
||||
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
- name: Run alembic migration validation
|
||||
shell: bash
|
||||
run: bash scripts/ci/validate_migration.sh
|
||||
- name: CI failure notification
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
|
||||
run: |
|
||||
set +e
|
||||
FAILED_JOB="Validate - Migration (alembic)" python3 scripts/ci_notify_failure.py
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_end.sh
|
||||
- name: Notify on failure
|
||||
continue-on-error: true
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="Validate - Migration (alembic)" python3 scripts/ci_notify.py
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
unit-tests:
|
||||
needs: check-frontend-only
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
@@ -631,11 +387,9 @@ jobs:
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
- name: Run Vitest (incremental for PRs, full for main branches)
|
||||
- name: Run Vitest with coverage
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: bash scripts/ci/vitest_incremental.sh
|
||||
run: bash scripts/ci/step_frontend_run.sh "npx --no-install vitest run --coverage"
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -662,125 +416,6 @@ jobs:
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
|
||||
build-pr:
|
||||
name: PR Build ${{ matrix.service_display }} Image
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
if: github.event_name == 'pull_request'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- service: api
|
||||
service_display: API
|
||||
dockerfile: infra/docker/api.Dockerfile
|
||||
image_name: xiaoxia-saas-api
|
||||
cache_name: api-cache
|
||||
timeout: 30
|
||||
- service: worker
|
||||
service_display: Worker
|
||||
dockerfile: infra/docker/worker.Dockerfile
|
||||
image_name: xiaoxia-saas-worker
|
||||
cache_name: worker-cache
|
||||
timeout: 40
|
||||
- service: web
|
||||
service_display: Web
|
||||
dockerfile: infra/docker/web.Dockerfile
|
||||
image_name: xiaoxia-saas-web
|
||||
cache_name: web-cache
|
||||
timeout: 30
|
||||
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: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Docker login to Registry (for cache read)
|
||||
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 attempt $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 failed ($i/3), retrying in 5s..."
|
||||
sleep 5
|
||||
done
|
||||
- name: Build PR image (verify only, no push)
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:pr-${GITHUB_SHA}"
|
||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:develop"
|
||||
|
||||
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 "PR Build attempt $i/3"
|
||||
if bash scripts/ci/docker_build_only.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
|
||||
echo "PR Build successful"
|
||||
break
|
||||
fi
|
||||
echo "PR Build failed (attempt $i/3)"
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 10
|
||||
if [ $i -eq 2 ]; then
|
||||
NO_CACHE_FLAG="--no-cache"
|
||||
echo "Next retry with --no-cache"
|
||||
fi
|
||||
done
|
||||
echo
|
||||
echo "${{ matrix.service_display }} PR build verified: ${IMAGE_TAG}"
|
||||
- name: Cleanup buildx builder
|
||||
if: always()
|
||||
shell: sh
|
||||
run: |
|
||||
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
|
||||
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
|
||||
docker buildx prune -f 2>/dev/null || true
|
||||
echo "Builder cleanup done"
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_end.sh
|
||||
- name: Notify on failure
|
||||
continue-on-error: true
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="PR Build ${{ matrix.service_display }} Image" python3 scripts/ci_notify.py
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
build-staging:
|
||||
name: Build Staging ${{ matrix.service_display }} Image
|
||||
runs-on: runtime-builder
|
||||
@@ -895,15 +530,6 @@ jobs:
|
||||
|
||||
echo
|
||||
echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG}"
|
||||
- name: Cleanup buildx builder
|
||||
if: always()
|
||||
shell: sh
|
||||
run: |
|
||||
docker buildx rm ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} 2>/dev/null || true
|
||||
docker buildx rm ci-builder 2>/dev/null || true
|
||||
docker buildx prune -f 2>/dev/null || true
|
||||
echo "Builder cleanup done"
|
||||
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
|
||||
@@ -235,9 +235,6 @@ jobs:
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
"CI/CD Pipeline / PR Build API Image (pull_request)"
|
||||
"CI/CD Pipeline / PR Build Worker Image (pull_request)"
|
||||
"CI/CD Pipeline / PR Build Web Image (pull_request)"
|
||||
)
|
||||
echo "检查required门禁(与分支保护一致)"
|
||||
fi
|
||||
|
||||
Executable → Regular
+4
-4
@@ -503,7 +503,7 @@ async def send_verification_code(
|
||||
request: SendVerificationCodeRequest,
|
||||
) -> SendVerificationCodeResponse:
|
||||
"""发送验证码(手机或邮箱)"""
|
||||
from app.dependencies import get_db_session
|
||||
from app.dependencies import get_db
|
||||
|
||||
from packages.adapters.sms.sms_service import get_sms_service
|
||||
from packages.adapters.smtp import get_email_service
|
||||
@@ -516,7 +516,7 @@ async def send_verification_code(
|
||||
)
|
||||
from packages.application.auth.verification_code_service import VerificationCodeService
|
||||
|
||||
db = next(get_db_session())
|
||||
db = next(get_db())
|
||||
repo = SQLAlchemyVerificationCodeRepository(db)
|
||||
vc_service = VerificationCodeService(repo=repo)
|
||||
sms_service = get_sms_service()
|
||||
@@ -549,7 +549,7 @@ async def bind_contact(
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> BindContactResponse:
|
||||
"""绑定手机号和/或邮箱(需登录态)"""
|
||||
from app.dependencies import get_db_session
|
||||
from app.dependencies import get_db
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.verification_code_repository import (
|
||||
SQLAlchemyVerificationCodeRepository,
|
||||
@@ -560,7 +560,7 @@ async def bind_contact(
|
||||
)
|
||||
from packages.application.auth.verification_code_service import VerificationCodeService
|
||||
|
||||
db = next(get_db_session())
|
||||
db = next(get_db())
|
||||
vc_repo = SQLAlchemyVerificationCodeRepository(db)
|
||||
vc_service = VerificationCodeService(repo=vc_repo)
|
||||
|
||||
|
||||
@@ -639,84 +639,19 @@ def get_draft_plan_id(
|
||||
template_id: str,
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
) -> str:
|
||||
"""
|
||||
路径依赖:根据 template_id 获取或创建草稿,返回 plan_id。
|
||||
|
||||
这是模板编辑器路由的核心依赖——所有编辑器端点都先经过这里,
|
||||
确保 template_id → plan_id 的映射始终存在。
|
||||
|
||||
兼容策略:优先从新模板系统(edit_templates 表)查找,
|
||||
若不存在则回退到旧模板系统(templates 表),确保用户自建模板可用。
|
||||
"""
|
||||
tpl_svc, plan_svc = services
|
||||
user_id = str(current_user.user.id)
|
||||
|
||||
# 1. 草稿已存在 → 直接返回
|
||||
draft = tpl_svc.get_template_draft(template_id)
|
||||
if draft is not None:
|
||||
return draft.id
|
||||
|
||||
# 2. 新系统有模板 → 用新服务创建草稿
|
||||
if tpl_svc.get_template(template_id) is not None:
|
||||
draft = tpl_svc.create_template_draft(template_id, user_id=user_id)
|
||||
return draft.id
|
||||
|
||||
# 3. 回退到旧模板系统(templates 表)
|
||||
old_repo = SQLAlchemyTemplateRepository(db)
|
||||
old_template = old_repo.get(template_id, user_id=user_id)
|
||||
if old_template is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
|
||||
|
||||
# 4. 基于旧模板创建草稿计划
|
||||
from app.services.plan_generator_service import PlanGeneratorService
|
||||
|
||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
|
||||
|
||||
# 构造伪 EditTemplate 对象(只填 generate_from_template 需要的字段)
|
||||
pseudo_template = EditTemplate(
|
||||
id=old_template.id,
|
||||
name=old_template.name,
|
||||
editing_mode=old_template.mode,
|
||||
status=EditTemplateStatus.ACTIVE,
|
||||
)
|
||||
|
||||
# 将旧模板 segments 转换为 clip_configs
|
||||
clip_configs: list[TemplateClipConfig] = []
|
||||
for seg in old_template.segments or []:
|
||||
clip_configs.append(
|
||||
TemplateClipConfig(
|
||||
id=f"seg_{seg.id}",
|
||||
template_id=old_template.id,
|
||||
clip_type=ClipType.MAIN,
|
||||
order=seg.segment_order,
|
||||
min_duration=seg.duration_min,
|
||||
max_duration=seg.duration_max,
|
||||
)
|
||||
)
|
||||
|
||||
generator = PlanGeneratorService(db)
|
||||
result = generator.generate_from_template(
|
||||
template=pseudo_template,
|
||||
clip_configs=clip_configs,
|
||||
asset_ids=[],
|
||||
created_by_user_id=user_id,
|
||||
name=f"{old_template.name} - 草稿",
|
||||
)
|
||||
plan = result["plan"]
|
||||
|
||||
# 标记为模板草稿(后续可复用 tpl_svc.get_template_draft 的查找逻辑)
|
||||
plan_svc.update_plan_config(plan.id, {"is_template_draft": True})
|
||||
|
||||
logger.info(
|
||||
"旧模板自动创建草稿: template_id=%s draft_plan_id=%s user_id=%s",
|
||||
tpl_svc, _ = services
|
||||
draft = tpl_svc.get_or_create_draft(
|
||||
template_id,
|
||||
plan.id,
|
||||
user_id,
|
||||
user_id=str(current_user.user.id),
|
||||
)
|
||||
return plan.id
|
||||
return draft.id
|
||||
|
||||
|
||||
# ── 草稿核心端点 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -20,56 +20,24 @@ export const useLogin = () => {
|
||||
const data = await mutation.mutateAsync(credentials)
|
||||
const refreshToken = data.refresh_token ?? null
|
||||
|
||||
// 先保存 token
|
||||
localStorage.setItem("access_token", data.access_token)
|
||||
if (refreshToken) {
|
||||
localStorage.setItem("refresh_token", refreshToken)
|
||||
} else {
|
||||
localStorage.removeItem("refresh_token")
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
const user = await authApi.getCurrentUser()
|
||||
setAuth(user, data.access_token, refreshToken)
|
||||
|
||||
// 跳转到登录前页面或首页
|
||||
const redirect = localStorage.getItem("login_redirect") || "/"
|
||||
localStorage.removeItem("login_redirect")
|
||||
navigate(redirect, { replace: true })
|
||||
navigate("/")
|
||||
return data
|
||||
}
|
||||
|
||||
return { ...mutation, mutateAsync: login }
|
||||
}
|
||||
|
||||
// 微信登录 Hook(用于回调后处理登录状态)
|
||||
export const useWechatCallback = () => {
|
||||
const navigate = useNavigate()
|
||||
const setAuth = useAuthStore((state) => state.setAuth)
|
||||
const setUser = useAuthStore((state) => state.setUser)
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ code, state }: { code: string; state: string }) =>
|
||||
authApi.wechatCallback(code, state),
|
||||
})
|
||||
|
||||
const handleCallback = async (code: string, state: string) => {
|
||||
// 校验 state
|
||||
const savedState = localStorage.getItem("wechat_state")
|
||||
if (!savedState || savedState !== state) {
|
||||
throw new Error("安全校验失败")
|
||||
}
|
||||
localStorage.removeItem("wechat_state")
|
||||
|
||||
const result = await mutation.mutateAsync({ code, state })
|
||||
const user = await authApi.getCurrentUser()
|
||||
setAuth(user, result.access_token, result.refresh_token)
|
||||
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
// 绑定成功后跳转
|
||||
const finishLogin = () => {
|
||||
const redirect = localStorage.getItem("login_redirect") || "/"
|
||||
localStorage.removeItem("login_redirect")
|
||||
navigate(redirect, { replace: true })
|
||||
}
|
||||
|
||||
return { ...mutation, handleCallback, finishLogin, setUser }
|
||||
}
|
||||
|
||||
// 注册 Hook
|
||||
export const useRegister = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -41,13 +41,6 @@ const Login: React.FC = () => {
|
||||
const result = await getWechatAuthUrl()
|
||||
// 保存 state 到 localStorage 用于回调时验证
|
||||
localStorage.setItem("wechat_state", result.state)
|
||||
// 记录登录前的来源页,登录成功后跳回
|
||||
const from = window.location.pathname + window.location.search
|
||||
if (from !== "/login" && from !== "/register") {
|
||||
localStorage.setItem("login_redirect", from)
|
||||
} else {
|
||||
localStorage.removeItem("login_redirect")
|
||||
}
|
||||
// 跳转到微信授权页
|
||||
window.location.href = result.auth_url
|
||||
} catch (error) {
|
||||
|
||||
@@ -28,28 +28,25 @@ const WechatCallback: React.FC = () => {
|
||||
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
// 校验 state,防止 CSRF
|
||||
const savedState = localStorage.getItem("wechat_state")
|
||||
if (!savedState || savedState !== state) {
|
||||
setError("安全校验失败,请重新登录")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
localStorage.removeItem("wechat_state")
|
||||
|
||||
const result = await wechatCallback(code, state)
|
||||
|
||||
// 保存 token
|
||||
localStorage.setItem("access_token", result.access_token)
|
||||
if (result.refresh_token) {
|
||||
localStorage.setItem("refresh_token", result.refresh_token)
|
||||
} else {
|
||||
localStorage.removeItem("refresh_token")
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
const userData = await getCurrentUser()
|
||||
const user: User = normalizeUser(userData)
|
||||
setAuth(user, result.access_token, result.refresh_token)
|
||||
|
||||
if (result.binding_complete) {
|
||||
// 已绑定,跳转到登录前页面或首页
|
||||
// 已绑定,直接跳转到首页
|
||||
message.success("登录成功")
|
||||
const redirect = localStorage.getItem("login_redirect") || "/"
|
||||
localStorage.removeItem("login_redirect")
|
||||
navigate(redirect, { replace: true })
|
||||
navigate("/app/dashboard")
|
||||
} else {
|
||||
// 未绑定,显示绑定弹窗
|
||||
setLoading(false)
|
||||
@@ -64,14 +61,10 @@ const WechatCallback: React.FC = () => {
|
||||
handleCallback()
|
||||
}, [searchParams, navigate, setAuth])
|
||||
|
||||
const handleBindSuccess = (user: User) => {
|
||||
const setUser = useAuthStore.getState().setUser
|
||||
setUser(user)
|
||||
const handleBindSuccess = (_user: User) => {
|
||||
setShowBindModal(false)
|
||||
message.success("绑定成功")
|
||||
const redirect = localStorage.getItem("login_redirect") || "/"
|
||||
localStorage.removeItem("login_redirect")
|
||||
navigate(redirect, { replace: true })
|
||||
navigate("/app/dashboard")
|
||||
}
|
||||
|
||||
const handleBindCancel = () => {
|
||||
|
||||
Executable → Regular
+3
-13
@@ -7,18 +7,8 @@ import { renderHook, act } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
|
||||
const mockNavigate = vi.fn()
|
||||
const mockSetAuth = vi.fn((_user: any, accessToken: string, refreshToken?: string | null) => {
|
||||
localStorage.setItem("access_token", accessToken)
|
||||
if (refreshToken) {
|
||||
localStorage.setItem("refresh_token", refreshToken)
|
||||
} else {
|
||||
localStorage.removeItem("refresh_token")
|
||||
}
|
||||
})
|
||||
const mockClearAuth = vi.fn(() => {
|
||||
localStorage.removeItem("access_token")
|
||||
localStorage.removeItem("refresh_token")
|
||||
})
|
||||
const mockSetAuth = vi.fn()
|
||||
const mockClearAuth = vi.fn()
|
||||
const mockMutateAsync = vi.fn()
|
||||
const mockQueryClear = vi.fn()
|
||||
|
||||
@@ -109,7 +99,7 @@ describe("useAuth hooks", () => {
|
||||
expect(localStorage.getItem("access_token")).toBe("access-123")
|
||||
expect(localStorage.getItem("refresh_token")).toBe("refresh-456")
|
||||
expect(mockSetAuth).toHaveBeenCalled()
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true })
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/")
|
||||
})
|
||||
|
||||
it("没有 refresh_token 时从 localStorage 移除", async () => {
|
||||
|
||||
+18
-55
@@ -1,69 +1,33 @@
|
||||
# ============================================================
|
||||
# API Dockerfile - FastAPI 应用
|
||||
# 优化:多阶段构建 + pip cache mount + 依赖分层缓存
|
||||
# API Dockerfile - 专门用于 FastAPI 应用
|
||||
# 优化:依赖分层缓存 + 多阶段构建基础层
|
||||
# ============================================================
|
||||
|
||||
# ==================== 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
|
||||
# 基础镜像:Python 3.12
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
|
||||
|
||||
# 构建参数:版本号(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
|
||||
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 运行时依赖)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 从 builder 复制虚拟环境
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
# 安装系统依赖
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# ---- 依赖分层:基础依赖(变化少,缓存命中率高)----
|
||||
COPY requirements-base.txt /tmp/requirements-base.txt
|
||||
|
||||
RUN python -m venv /opt/venv && /opt/venv/bin/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 /opt/venv/bin/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
|
||||
|
||||
# 复制应用代码
|
||||
COPY apps/api/ /app/apps/api/
|
||||
COPY packages/ /app/packages/
|
||||
@@ -79,8 +43,7 @@ ENV PYTHONUNBUFFERED=1
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
# 健康检查
|
||||
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)"
|
||||
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
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
# ============================================================
|
||||
# Worker Dockerfile - 优化版(多阶段构建 + 镜像瘦身 + cache mount加速)
|
||||
# Worker Dockerfile - 优化版(多阶段构建 + 镜像瘦身)
|
||||
# 优化项:
|
||||
# 1. 多阶段构建:builder 阶段安装编译依赖,runtime 阶段只保留运行时
|
||||
# 2. ffmpeg 通过 apt 安装(阿里云镜像加速,几秒完成,稳定可靠)
|
||||
# 2. ffmpeg 静态编译替换:从 apt 安装(457MB) 改为静态二进制(~80MB)
|
||||
# 3. Python 依赖瘦身:strip .so 调试符号 + 清理测试文件 + 清理缓存
|
||||
# 4. pip cache mount:加速依赖下载(跨构建共享pip wheel缓存)
|
||||
# ============================================================
|
||||
|
||||
# ==================== Builder 阶段 ====================
|
||||
@@ -20,8 +19,19 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
g++ \
|
||||
python3-dev \
|
||||
binutils \
|
||||
wget \
|
||||
xz-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---- 下载静态编译 ffmpeg ----
|
||||
# 使用 johnvansickle.com 的静态编译版本(业界标准)
|
||||
RUN cd /tmp \
|
||||
&& wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz \
|
||||
&& tar xf ffmpeg-release-amd64-static.tar.xz \
|
||||
&& cp ffmpeg-*-amd64-static/ffmpeg /usr/local/bin/ffmpeg \
|
||||
&& cp ffmpeg-*-amd64-static/ffprobe /usr/local/bin/ffprobe \
|
||||
&& chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe \
|
||||
&& rm -rf ffmpeg-*
|
||||
|
||||
# ---- 安装 Python 依赖 ----
|
||||
WORKDIR /tmp
|
||||
@@ -32,22 +42,19 @@ ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# 基础依赖
|
||||
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 \
|
||||
RUN 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
|
||||
|
||||
# Worker 专属大包
|
||||
COPY requirements-worker.txt /tmp/requirements-worker.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 \
|
||||
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
|
||||
-r /tmp/requirements-worker.txt \
|
||||
&& rm /tmp/requirements-worker.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 \
|
||||
RUN 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
|
||||
|
||||
@@ -79,10 +86,13 @@ RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debia
|
||||
|
||||
# 安装最小运行时依赖(opencv-python-headless 需要 libglib2.0-0)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
libglib2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 从 builder 复制 ffmpeg 静态二进制
|
||||
COPY --from=builder /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg
|
||||
COPY --from=builder /usr/local/bin/ffprobe /usr/local/bin/ffprobe
|
||||
|
||||
# 从 builder 复制 Python 虚拟环境
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
|
||||
@@ -8,10 +8,8 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
from threading import Lock
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -19,39 +17,6 @@ import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATE_TTL_SECONDS = 600 # state 有效期 10 分钟
|
||||
|
||||
|
||||
class MemoryStateStore:
|
||||
"""内存 state 存储(简单实现,单节点可用)
|
||||
|
||||
多实例部署时建议替换为 Redis 实现。
|
||||
"""
|
||||
|
||||
def __init__(self, ttl_seconds: int = STATE_TTL_SECONDS):
|
||||
self._ttl = ttl_seconds
|
||||
self._states: dict[str, float] = {} # state -> expire_at
|
||||
self._lock = Lock()
|
||||
|
||||
def put(self, state: str) -> None:
|
||||
with self._lock:
|
||||
self._clean_expired()
|
||||
self._states[state] = time.time() + self._ttl
|
||||
|
||||
def verify_and_consume(self, state: str) -> bool:
|
||||
with self._lock:
|
||||
self._clean_expired()
|
||||
if state in self._states:
|
||||
del self._states[state]
|
||||
return True
|
||||
return False
|
||||
|
||||
def _clean_expired(self) -> None:
|
||||
now = time.time()
|
||||
expired = [s for s, exp in self._states.items() if exp < now]
|
||||
for s in expired:
|
||||
del self._states[s]
|
||||
|
||||
|
||||
@dataclass
|
||||
class WechatUserInfo:
|
||||
@@ -76,8 +41,7 @@ class WechatOAuthService:
|
||||
self.app_id = app_id or os.environ.get("WECHAT_OPEN_APP_ID", "")
|
||||
self.app_secret = app_secret or os.environ.get("WECHAT_OPEN_APP_SECRET", "")
|
||||
self.redirect_uri = redirect_uri or os.environ.get("WECHAT_OPEN_REDIRECT_URI", "")
|
||||
# state 存储(CSRF 防护),默认内存实现
|
||||
self._state_store = state_store or MemoryStateStore()
|
||||
self._state_store = state_store # 可选:state 存储(Redis/内存),用于 CSRF 防护
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""检查微信配置是否完整"""
|
||||
@@ -91,8 +55,6 @@ class WechatOAuthService:
|
||||
(授权URL, state)
|
||||
"""
|
||||
state = uuid4().hex
|
||||
# 保存 state 用于回调校验(防 CSRF)
|
||||
self._state_store.put(state)
|
||||
|
||||
if not self.is_configured():
|
||||
# 未配置时返回 mock URL,方便前端联调
|
||||
@@ -130,11 +92,6 @@ class WechatOAuthService:
|
||||
if not code:
|
||||
return None, "缺少授权码"
|
||||
|
||||
# 校验 state(防 CSRF)—— 一次性使用
|
||||
if not state or not self._state_store.verify_and_consume(state):
|
||||
logger.warning("微信回调 state 校验失败: state=%s", state)
|
||||
return None, "无效的 state 参数,请求可能已过期或被篡改"
|
||||
|
||||
if not self.is_configured():
|
||||
# 开发模式:返回 mock 用户信息
|
||||
logger.info("微信未配置,使用 mock 用户信息")
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
# 数据库(基础层)
|
||||
psycopg2-binary==2.9.10
|
||||
psycopg[binary]==3.2.2
|
||||
psycopg[binary]>=3.2.2
|
||||
sqlalchemy==2.0.35
|
||||
alembic==1.13.3
|
||||
|
||||
|
||||
@@ -11,4 +11,4 @@ pytest==8.3.3
|
||||
pytest-asyncio==0.24.0
|
||||
pytest-cov==6.0.0
|
||||
pytest-timeout==2.3.1
|
||||
diff-cover==8.0.3
|
||||
diff-cover>=8.0
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
# 这些包体积大,API 服务不需要安装
|
||||
|
||||
# 数值计算
|
||||
numpy==1.26.4
|
||||
numpy>=1.24.0
|
||||
|
||||
# 科学计算
|
||||
scipy==1.13.1
|
||||
scipy>=1.10.0
|
||||
|
||||
# 计算机视觉(视频去重、帧处理)
|
||||
opencv-python-headless==4.10.0.84
|
||||
opencv-python-headless>=4.8.0
|
||||
|
||||
# 图像处理
|
||||
Pillow==10.4.0
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Agent代码提交前自动格式化+质量检查脚本
|
||||
# 用法: scripts/agent-commit.sh <commit_message> [files...]
|
||||
# 效果: 自动跑black+isort+ruff check,通过后才commit+push
|
||||
set -e
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "用法: $0 <commit_message> [file1 file2 ...]"
|
||||
echo "示例: $0 \"feat: add new api\" apps/api/src/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
COMMIT_MSG="$1"
|
||||
shift
|
||||
|
||||
TARGETS="${@:-.}"
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
REPO_ROOT=$(pwd)
|
||||
echo "仓库根目录: $REPO_ROOT"
|
||||
echo "提交信息: $COMMIT_MSG"
|
||||
echo "目标路径: $TARGETS"
|
||||
echo ""
|
||||
|
||||
# 后端代码格式化(Python文件)
|
||||
PYTHON_FILES=$(find $TARGETS -name "*.py" -type f 2>/dev/null | head -100 || true)
|
||||
if [ -n "$PYTHON_FILES" ]; then
|
||||
echo "=== Step 1/4: 后端代码格式化 (black) ==="
|
||||
if command -v black &> /dev/null; then
|
||||
black $TARGETS 2>&1 | tail -3
|
||||
echo "✅ black 完成"
|
||||
else
|
||||
echo "⚠️ 未安装black,跳过"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "=== Step 2/4: import排序 (isort) ==="
|
||||
if command -v isort &> /dev/null; then
|
||||
isort $TARGETS 2>&1 | tail -3
|
||||
echo "✅ isort 完成"
|
||||
else
|
||||
echo "⚠️ 未安装isort,跳过"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "=== Step 3/4: 代码质量检查 (ruff check) ==="
|
||||
if command -v ruff &> /dev/null; then
|
||||
RUFF_OUTPUT=$(ruff check $TARGETS 2>&1) || true
|
||||
RUFF_ERRORS=$(echo "$RUFF_OUTPUT" | grep -c "^" || echo 0)
|
||||
if [ "$RUFF_ERRORS" -le 2 ] || echo "$RUFF_OUTPUT" | grep -q "All checks passed"; then
|
||||
echo "✅ ruff 检查通过(错误数: $RUFF_ERRORS)"
|
||||
else
|
||||
echo "❌ ruff 发现以下问题:"
|
||||
echo "$RUFF_OUTPUT" | head -30
|
||||
echo ""
|
||||
echo "请修复后重新提交,或手动忽略特定问题"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "⚠️ 未安装ruff,跳过"
|
||||
fi
|
||||
echo ""
|
||||
else
|
||||
echo "ℹ️ 未检测到Python文件,跳过后端格式化"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# 前端代码格式化(TS/TSX文件)
|
||||
TS_FILES=$(find $TARGETS -name "*.ts" -o -name "*.tsx" -type f 2>/dev/null | head -100 || true)
|
||||
if [ -n "$TS_FILES" ] && [ -f "apps/web/package.json" ]; then
|
||||
echo "=== Step 4/4: 前端代码格式化 (prettier) ==="
|
||||
if command -v npx &> /dev/null; then
|
||||
cd apps/web && npx prettier --write "src/**/*.{ts,tsx}" 2>&1 | tail -3 || true
|
||||
cd "$REPO_ROOT"
|
||||
echo "✅ prettier 完成"
|
||||
else
|
||||
echo "⚠️ 未安装npx,跳过前端格式化"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Git操作
|
||||
echo "=== 提交代码 ==="
|
||||
git add -A
|
||||
git diff --cached --stat
|
||||
echo ""
|
||||
git commit -m "$COMMIT_MSG"
|
||||
echo ""
|
||||
echo "✅ 本地提交完成"
|
||||
|
||||
# 可选:自动推送
|
||||
if [ "$AGENT_AUTO_PUSH" = "true" ]; then
|
||||
echo "正在推送到远程..."
|
||||
git push
|
||||
echo "✅ 推送完成"
|
||||
else
|
||||
echo "ℹ️ 本地已提交,如需推送执行: git push"
|
||||
echo " 设置 AGENT_AUTO_PUSH=true 可自动推送"
|
||||
fi
|
||||
+13
-15
@@ -31,22 +31,20 @@ def main():
|
||||
print("pending")
|
||||
return
|
||||
|
||||
# 筛选目标context,按时间倒序取最新的
|
||||
matching = [s for s in statuses if s.get("context") == target_context]
|
||||
if not matching:
|
||||
# 找不到说明CI还没开始写状态,返回pending继续等待
|
||||
print("pending")
|
||||
return
|
||||
# API返回按时间倒序,第一个就是最新的
|
||||
for s in statuses:
|
||||
if s.get("context") == target_context:
|
||||
status = s.get("status", "pending")
|
||||
# skipped 视为通过(条件跳过的任务不需要等)
|
||||
if status == "skipped":
|
||||
print("success")
|
||||
else:
|
||||
print(status)
|
||||
return
|
||||
|
||||
# Gitea statuses API按时间正序返回,必须取最新的一条
|
||||
latest = max(matching, key=lambda s: s.get("created_at", ""))
|
||||
status = latest.get("status", "pending")
|
||||
|
||||
# skipped 视为通过(条件跳过的任务不需要等)
|
||||
if status == "skipped":
|
||||
print("success")
|
||||
else:
|
||||
print(status)
|
||||
# 找不到这个context说明CI还没开始写状态,返回pending继续等待
|
||||
# (如果workflow真的被跳过,它会有一条status为skipped的记录)
|
||||
print("pending")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -168,28 +168,6 @@ def main():
|
||||
return
|
||||
|
||||
api_url = os.environ.get("GITHUB_API_URL", "")
|
||||
|
||||
# 获取PR作者信息,判断是人还是Agent提交的
|
||||
pr_info_url = f"{api_url}/repos/{repo}/pulls/{pr_number}"
|
||||
req_pr = urllib.request.Request(pr_info_url, headers={"Authorization": f"token {token}"})
|
||||
with urllib.request.urlopen(req_pr) as resp:
|
||||
pr_info = json.loads(resp.read())
|
||||
pr_author = pr_info.get("user", {}).get("login", "")
|
||||
print(f"PR作者: {pr_author}")
|
||||
|
||||
# 判断是否为Agent提交的PR
|
||||
# Agent账号:actions, auto-approve-bot 等bot用户
|
||||
# 人提交的PR(如xiaoxia):只诊断不自动修
|
||||
agent_authors = {"actions", "auto-approve-bot", "gitea-actions"}
|
||||
is_agent_pr = pr_author in agent_authors or "bot" in pr_author.lower()
|
||||
|
||||
if is_agent_pr:
|
||||
print(f"检测到Agent提交的PR(作者: {pr_author}),将自动修复并推送")
|
||||
fix_mode = "auto_fix_and_push"
|
||||
else:
|
||||
print(f"检测到人提交的PR(作者: {pr_author}),仅诊断不自动修改")
|
||||
print("(如需自动修复,请用Agent账号提交PR,或手动运行格式化脚本)")
|
||||
fix_mode = "diagnose_only"
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
token = os.environ.get("GITHUB_TOKEN", "")
|
||||
scan_mode = os.environ.get("SCAN_MODE", "full")
|
||||
@@ -253,26 +231,6 @@ def main():
|
||||
print("没有需要提交的格式改动")
|
||||
return
|
||||
|
||||
# 诊断模式:只报告问题,不修改不推送
|
||||
if fix_mode == "diagnose_only":
|
||||
print()
|
||||
print("=" * 50)
|
||||
print("📋 格式问题诊断报告(人提交的PR,仅诊断不自动修复)")
|
||||
print("=" * 50)
|
||||
print()
|
||||
print("以下文件存在格式问题,建议手动修复:")
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
print(f" {line}")
|
||||
print()
|
||||
print("修复方式:")
|
||||
print(" 后端(Python): 运行 black + isort")
|
||||
print(" 前端: 运行 prettier --write")
|
||||
print(" 或使用 scripts/agent-commit.sh 提交(自动格式化)")
|
||||
print()
|
||||
print("=" * 50)
|
||||
# 以非0状态码退出,让CI继续报失败(因为问题没修)
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
print("变更文件:")
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/bin/bash
|
||||
# PR构建专用:只构建不推送,只读缓存不写,用于PR阶段验证Dockerfile
|
||||
set -eu
|
||||
|
||||
NO_CACHE_FLAG=""
|
||||
if [ "$1" = "--no-cache" ]; then
|
||||
NO_CACHE_FLAG="--no-cache"
|
||||
shift
|
||||
fi
|
||||
|
||||
DOCKERFILE="$1"
|
||||
IMAGE_TAG="$2"
|
||||
CACHE_REF="$3"
|
||||
shift 3
|
||||
BUILD_ARGS=""
|
||||
for arg in "$@"; do
|
||||
BUILD_ARGS="$BUILD_ARGS --build-arg $arg"
|
||||
done
|
||||
|
||||
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
else
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
fi
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
CACHE_NAME=$(echo "$CACHE_REF" | tr "/" "_" | tr ":" "-")
|
||||
LOCAL_CACHE_DIR="/tmp/buildx-cache/${CACHE_NAME}"
|
||||
mkdir -p "$LOCAL_CACHE_DIR"
|
||||
|
||||
echo "=== PR Build: build only, no push, read-only cache ==="
|
||||
echo "Dockerfile: ${DOCKERFILE}"
|
||||
echo "Image tag: ${IMAGE_TAG}"
|
||||
echo ""
|
||||
|
||||
build_with_retry() {
|
||||
local attempt=1
|
||||
local max_attempts=2
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
local build_output
|
||||
local exit_code
|
||||
set +e
|
||||
build_output=$(docker buildx build \
|
||||
$NO_CACHE_FLAG \
|
||||
$BUILD_ARGS \
|
||||
--cache-from "type=local,src=${LOCAL_CACHE_DIR}" \
|
||||
--cache-from "type=registry,ref=${CACHE_REF}" \
|
||||
-f "${DOCKERFILE}" \
|
||||
-t "${IMAGE_TAG}" \
|
||||
--load \
|
||||
. 2>&1)
|
||||
exit_code=$?
|
||||
set -e
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
echo "$build_output"
|
||||
return 0
|
||||
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"
|
||||
echo "Local cache corrupted, cleaning and retrying ($attempt/$max_attempts)..."
|
||||
rm -rf "${LOCAL_CACHE_DIR}"
|
||||
mkdir -p "${LOCAL_CACHE_DIR}"
|
||||
docker buildx prune -f -a >/dev/null 2>&1 || true
|
||||
attempt=$((attempt + 1))
|
||||
else
|
||||
echo "$build_output"
|
||||
return $exit_code
|
||||
fi
|
||||
done
|
||||
echo "Local cache failed, building with registry cache only..."
|
||||
docker buildx build \
|
||||
$NO_CACHE_FLAG \
|
||||
$BUILD_ARGS \
|
||||
--cache-from "type=registry,ref=${CACHE_REF}" \
|
||||
-f "${DOCKERFILE}" \
|
||||
-t "${IMAGE_TAG}" \
|
||||
--load \
|
||||
.
|
||||
}
|
||||
|
||||
build_with_retry
|
||||
echo ""
|
||||
echo "PR build OK (not pushed): ${IMAGE_TAG}"
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/bin/bash
|
||||
# 通用Docker镜像构建+推送脚本(local cache为主 + registry cache共享)
|
||||
# 通用Docker镜像构建+推送脚本(local cache为主 + registry cache兜底)
|
||||
# M-2优化:解决registry缓存导入慢(247s)和推送不稳定问题
|
||||
# 用法: docker_build_push.sh [--no-cache] <Dockerfile> <image_tag> <cache_ref> [build_arg...]
|
||||
set -eu
|
||||
|
||||
@@ -34,7 +35,7 @@ LOCAL_CACHE_DIR="/tmp/buildx-cache/${CACHE_NAME}"
|
||||
|
||||
mkdir -p "$LOCAL_CACHE_DIR"
|
||||
|
||||
# 缓存源:local优先(带自动修复),registry兜底读写
|
||||
# 缓存源:local优先(带自动修复),registry兜底
|
||||
# 本地缓存损坏时自动清理后重试,避免snapshot not found导致构建全挂
|
||||
build_with_cache_retry() {
|
||||
local attempt=1
|
||||
@@ -47,7 +48,7 @@ build_with_cache_retry() {
|
||||
$NO_CACHE_FLAG \
|
||||
$BUILD_ARGS \
|
||||
--cache-from "type=local,src=${LOCAL_CACHE_DIR}" \
|
||||
--cache-from "type=registry,ref=${CACHE_REF}" \
|
||||
--cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" \
|
||||
--cache-to "type=local,dest=${LOCAL_CACHE_DIR},mode=max" \
|
||||
--cache-to "type=registry,ref=${CACHE_REF},mode=max,ignore-error=true" \
|
||||
-f "${DOCKERFILE}" \
|
||||
@@ -81,7 +82,7 @@ build_with_cache_retry() {
|
||||
docker buildx build \
|
||||
$NO_CACHE_FLAG \
|
||||
$BUILD_ARGS \
|
||||
--cache-from "type=registry,ref=${CACHE_REF}" \
|
||||
--cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" \
|
||||
--cache-to "type=local,dest=${LOCAL_CACHE_DIR},mode=max" \
|
||||
--cache-to "type=registry,ref=${CACHE_REF},mode=max,ignore-error=true" \
|
||||
-f "${DOCKERFILE}" \
|
||||
@@ -90,7 +91,7 @@ build_with_cache_retry() {
|
||||
.
|
||||
}
|
||||
|
||||
echo "=== Step 1: Build & push image (local cache + registry cache, with auto-repair) ==="
|
||||
echo "=== Step 1: Build & push image (local cache + registry read, with auto-repair) ==="
|
||||
echo "Local cache: ${LOCAL_CACHE_DIR}"
|
||||
echo "Registry cache: ${CACHE_REF}"
|
||||
echo ""
|
||||
@@ -100,7 +101,6 @@ build_with_cache_retry
|
||||
echo ""
|
||||
echo "Image pushed: ${IMAGE_TAG}"
|
||||
echo "Local cache updated"
|
||||
echo "Registry cache updated (if supported)"
|
||||
|
||||
echo ""
|
||||
echo "Build completed: ${IMAGE_TAG}"
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
#!/bin/sh
|
||||
# CI 公共步骤:前端依赖安装(在 docker node 容器中运行)
|
||||
# 优化:增加国内npm镜像源,加重试间隔
|
||||
# 用法:step_frontend_install.sh [模式]
|
||||
# 模式: full (默认) - 完整安装所有依赖
|
||||
# vitest - 同full(保持接口兼容)
|
||||
set -eu
|
||||
|
||||
MODE="${1:-full}"
|
||||
|
||||
echo "=== 前端依赖安装开始 (模式: $MODE) ==="
|
||||
|
||||
# npm国内镜像源(加速下载,减少网络失败)
|
||||
NPM_REGISTRY="https://registry.npmmirror.com"
|
||||
|
||||
# npm ci 带重试(网络不稳定时自动重试)
|
||||
for i in 1 2 3; do
|
||||
echo "npm ci 尝试 $i/3 (镜像: $NPM_REGISTRY)"
|
||||
docker run --rm \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc "npm config set registry $NPM_REGISTRY && npm ci --no-audit --no-fund" && break
|
||||
sh -lc "npm ci --no-audit --no-fund" && break
|
||||
echo "npm ci 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 10
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo "=== 前端依赖安装完成 ==="
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
#!/bin/bash
|
||||
# CI Validate: 代码质量与安全扫描(并行Job 1/3)
|
||||
# 包含:密钥扫描、格式检查、安全扫描、依赖漏洞、死代码检测、脚本语法校验
|
||||
set -eu
|
||||
|
||||
echo "=== CI Validate: 代码质量与安全扫描 ==="
|
||||
|
||||
# --- 密钥检测 ---
|
||||
echo ""
|
||||
echo "=== [1/6] Secret detection (detect-secrets) ==="
|
||||
python3 -m pip install -q detect-secrets
|
||||
detect-secrets --version
|
||||
|
||||
detect-secrets scan \
|
||||
--all-files \
|
||||
--exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \
|
||||
--exclude-files '\.(md|rst|txt|lock|example|sample|min\.js|min\.css|spec\.ts|test\.ts|test\.py)$' \
|
||||
--exclude-files '(package-lock|yarn\.lock|poetry\.lock|Pipfile\.lock)$' \
|
||||
--disable-plugin Base64HighEntropyString \
|
||||
--disable-plugin HexHighEntropyString \
|
||||
--disable-plugin BasicAuthDetector \
|
||||
--disable-plugin KeywordDetector \
|
||||
--disable-plugin IPPublicDetector \
|
||||
> /tmp/secrets-scan.json 2>&1
|
||||
|
||||
FOUND=$(python3 -c "
|
||||
import json
|
||||
try:
|
||||
with open('/tmp/secrets-scan.json') as f:
|
||||
data = json.load(f)
|
||||
results = data.get('results', {})
|
||||
total = sum(len(v) for v in results.values())
|
||||
print(total)
|
||||
except Exception:
|
||||
print('error')
|
||||
")
|
||||
|
||||
echo "Secrets detected: $FOUND"
|
||||
if [ "$FOUND" != "0" ] && [ "$FOUND" != "error" ]; then
|
||||
echo ""
|
||||
echo "=== Secret details ==="
|
||||
python3 -c "
|
||||
import json
|
||||
with open('/tmp/secrets-scan.json') as f:
|
||||
data = json.load(f)
|
||||
for fpath, items in data.get('results', {}).items():
|
||||
for item in items:
|
||||
line = item.get('line_number', '?')
|
||||
stype = item.get('type', '?')
|
||||
hashed = item.get('hashed_secret', '')[:16]
|
||||
print(f' {fpath}:{line} [{stype}] {hashed}...')
|
||||
"
|
||||
echo ""
|
||||
echo "ERROR: Potential secrets detected in code!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Secret scan passed"
|
||||
|
||||
# --- 增量/全量模式判断 ---
|
||||
echo ""
|
||||
echo "=== [2/6] Code quality checks ==="
|
||||
SCAN_MODE="full"
|
||||
CHANGED_PY_FILES=""
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_REF_NAME:-}" ] && [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=100"
|
||||
set +e
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL")
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||
set -e
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
CHANGED_PY_FILES=$(echo "$BODY" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
files = json.load(sys.stdin)
|
||||
py_files = [f['filename'] for f in files if f['filename'].endswith('.py') and f['status'] != 'removed']
|
||||
print(' '.join(py_files))
|
||||
except Exception:
|
||||
print('')
|
||||
")
|
||||
if [ -n "$CHANGED_PY_FILES" ]; then
|
||||
SCAN_MODE="incremental"
|
||||
echo "Incremental mode: $(echo "$CHANGED_PY_FILES" | wc -w) Python files changed"
|
||||
else
|
||||
SCAN_MODE="skip_py"
|
||||
echo "No Python files changed in this PR"
|
||||
fi
|
||||
else
|
||||
echo "WARN: API returned HTTP $HTTP_CODE, falling back to full scan"
|
||||
fi
|
||||
else
|
||||
echo "Full scan mode (not a PR event)"
|
||||
fi
|
||||
|
||||
if [ "$SCAN_MODE" = "incremental" ]; then
|
||||
# 防御性过滤
|
||||
EXISTING_PY_FILES=""
|
||||
for f in $CHANGED_PY_FILES; do
|
||||
if [ -f "$f" ]; then
|
||||
if [ -z "$EXISTING_PY_FILES" ]; then
|
||||
EXISTING_PY_FILES="$f"
|
||||
else
|
||||
EXISTING_PY_FILES="$EXISTING_PY_FILES $f"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
CHANGED_PY_FILES="$EXISTING_PY_FILES"
|
||||
|
||||
python3 -m compileall -q $CHANGED_PY_FILES
|
||||
python3 -m black --check --fast $CHANGED_PY_FILES
|
||||
python3 -m isort --check-only $CHANGED_PY_FILES
|
||||
RUFF_FILES=$(echo "$CHANGED_PY_FILES" | tr ' ' '\n' | grep -v '^scripts/' | grep -v '^$' | xargs)
|
||||
if [ -n "$RUFF_FILES" ]; then
|
||||
python3 -m ruff check $RUFF_FILES --statistics
|
||||
else
|
||||
echo "No ruff-checkable files changed, skipping"
|
||||
fi
|
||||
elif [ "$SCAN_MODE" = "skip_py" ]; then
|
||||
echo "No Python files changed - skipping Python lint checks"
|
||||
else
|
||||
echo "Full scan mode"
|
||||
python3 -m compileall -q alembic apps packages tests scripts
|
||||
python3 -m black --check --fast alembic apps packages tests scripts
|
||||
python3 -m isort --check-only alembic apps packages tests scripts
|
||||
python3 -m ruff check apps packages tests --statistics
|
||||
fi
|
||||
echo "✅ Code quality checks passed"
|
||||
|
||||
# --- Bandit 安全扫描(仅告警) ---
|
||||
echo ""
|
||||
echo "=== [3/6] Security scan (bandit, advisory only) ==="
|
||||
set +e
|
||||
bandit -r apps packages -q -ll
|
||||
BANDIT_EXIT=$?
|
||||
set -e
|
||||
if [ "$BANDIT_EXIT" -ne 0 ]; then
|
||||
echo "⚠️ Bandit found security issues (advisory mode - not blocking CI)"
|
||||
else
|
||||
echo "✅ Bandit security scan passed"
|
||||
fi
|
||||
|
||||
# --- Pip-audit 依赖漏洞扫描(仅告警) ---
|
||||
echo ""
|
||||
echo "=== [4/6] Python dependency vulnerability scan (pip-audit, advisory only) ==="
|
||||
python3 -m pip install -q pip-audit
|
||||
pip-audit --version
|
||||
EXIT_CODE=0
|
||||
for req_file in requirements.txt requirements-base.txt requirements-dev.txt; do
|
||||
if [ -f "$req_file" ]; then
|
||||
echo "--- Scanning $req_file ---"
|
||||
pip-audit -r "$req_file" --desc on 2>&1 | head -40 || EXIT_CODE=$?
|
||||
echo ""
|
||||
fi
|
||||
done
|
||||
echo "pip-audit scan completed (advisory mode - warnings only, not blocking CI)"
|
||||
|
||||
# --- Vulture 死代码检测(仅告警) ---
|
||||
echo ""
|
||||
echo "=== [5/6] Dead code detection (vulture, advisory only) ==="
|
||||
set +e
|
||||
python3 -m pip install -q vulture
|
||||
vulture --version
|
||||
echo "告警模式,不阻断CI。置信度>=90%建议尽快确认。"
|
||||
echo ""
|
||||
vulture apps packages scripts \
|
||||
--exclude "tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py" \
|
||||
--min-confidence 70 \
|
||||
2>&1 | sort -t'(' -k2 -rn | head -80
|
||||
echo ""
|
||||
echo "=== vulture scan summary ==="
|
||||
echo "发现潜在死代码(可能包含框架装饰器注册的函数,为误报)"
|
||||
echo "建议:定期人工审查高置信度(>=90%)条目"
|
||||
set -e
|
||||
|
||||
# --- Release 脚本语法校验 ---
|
||||
echo ""
|
||||
echo "=== [6/6] Release scripts syntax validation ==="
|
||||
bash -n scripts/backup_postgres.sh
|
||||
bash -n scripts/restore_postgres_plan.sh
|
||||
bash -n scripts/init_production_env.sh
|
||||
echo "✅ Release scripts syntax OK"
|
||||
|
||||
echo ""
|
||||
echo "=== CI Validate: 代码质量与安全扫描 全部通过 ✅ ==="
|
||||
@@ -1,182 +0,0 @@
|
||||
#!/bin/bash
|
||||
# CI Validate: Alembic迁移验证(并行Job 3/3)
|
||||
# 需要PostgreSQL数据库
|
||||
set -eu
|
||||
|
||||
echo "=== CI Validate: Alembic迁移验证 ==="
|
||||
|
||||
# --- DooD模式检测:确定宿主机访问地址 ---
|
||||
detect_docker_host() {
|
||||
local test_port="${1:-5432}"
|
||||
|
||||
local candidates=()
|
||||
|
||||
# 1. host.docker.internal
|
||||
if python3 -c "import socket; socket.gethostbyname('host.docker.internal')" 2>/dev/null; then
|
||||
candidates+=("host.docker.internal")
|
||||
fi
|
||||
|
||||
# 2. docker0 桥接网关
|
||||
candidates+=("172.17.0.1")
|
||||
|
||||
# 3. 默认网关
|
||||
local gw=""
|
||||
gw=$(ip route 2>/dev/null | grep default | awk '{print $3}' | head -1)
|
||||
if [ -n "$gw" ] && [ "$gw" != "127.0.0.1" ]; then
|
||||
candidates+=("$gw")
|
||||
fi
|
||||
|
||||
# 4. 宿主机同网段的.1或.254
|
||||
local my_ip=""
|
||||
my_ip=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
if [ -n "$my_ip" ]; then
|
||||
local subnet=$(echo "$my_ip" | cut -d. -f1-3)
|
||||
candidates+=("${subnet}.1")
|
||||
candidates+=("${subnet}.254")
|
||||
fi
|
||||
|
||||
# 5. 127.0.0.1 最后尝试
|
||||
candidates+=("127.0.0.1")
|
||||
|
||||
for candidate in "${candidates[@]}"; do
|
||||
if python3 -c "
|
||||
import socket
|
||||
s = socket.socket()
|
||||
s.settimeout(2)
|
||||
try:
|
||||
s.connect(('$candidate', $test_port))
|
||||
s.close()
|
||||
print('ok')
|
||||
except:
|
||||
pass
|
||||
" 2>/dev/null | grep -q ok; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
echo "127.0.0.1"
|
||||
return 1
|
||||
}
|
||||
|
||||
# 获取宿主机IP
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
DOCKER_HOST_IP=$(detect_docker_host 5433)
|
||||
if [ "$DOCKER_HOST_IP" = "127.0.0.1" ]; then
|
||||
DOCKER_HOST_IP=$(detect_docker_host 22)
|
||||
fi
|
||||
echo "检测到DooD模式,宿主机地址: $DOCKER_HOST_IP"
|
||||
else
|
||||
DOCKER_HOST_IP="127.0.0.1"
|
||||
echo "非DooD模式,使用 127.0.0.1"
|
||||
fi
|
||||
PG_HOST="$DOCKER_HOST_IP"
|
||||
echo "PG host: $PG_HOST"
|
||||
|
||||
# 指数退避TCP连接检查
|
||||
wait_tcp_ready() {
|
||||
local host="$1"
|
||||
local port="$2"
|
||||
local max_attempts="${3:-5}"
|
||||
local delay=1
|
||||
local attempt=1
|
||||
while [ "$attempt" -le "$max_attempts" ]; do
|
||||
if python3 -c "import socket; s=socket.socket(); s.settimeout(3); s.connect(('$host', $port)); s.close()" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
echo "TCP连接尝试 $attempt/$max_attempts 失败,${delay}s后重试..."
|
||||
sleep "$delay"
|
||||
delay=$((delay * 2))
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
USE_SHARED_PG="${CI_USE_SHARED_PG:-false}"
|
||||
|
||||
if [ "$USE_SHARED_PG" = "true" ]; then
|
||||
# 使用常驻共享PG实例
|
||||
echo "使用常驻共享PG实例(CI_USE_SHARED_PG=true)"
|
||||
SHARED_PG_HOST="$PG_HOST"
|
||||
SHARED_PG_PORT="5433"
|
||||
SHARED_PG_USER="postgres"
|
||||
SHARED_PG_PASSWORD="ci_pg_2026!"
|
||||
CI_DB_NAME="ci_run_${GITHUB_RUN_ID:-$$}"
|
||||
|
||||
echo "等待共享PG连接就绪..."
|
||||
wait_tcp_ready "$SHARED_PG_HOST" "$SHARED_PG_PORT" 5
|
||||
|
||||
echo "创建测试数据库: $CI_DB_NAME"
|
||||
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
cur.execute(f'CREATE DATABASE \"$CI_DB_NAME\"')
|
||||
cur.close()
|
||||
conn.close()
|
||||
"
|
||||
export DATABASE_URL="postgresql+psycopg://${SHARED_PG_USER}:${SHARED_PG_PASSWORD}@${SHARED_PG_HOST}:${SHARED_PG_PORT}/${CI_DB_NAME}"
|
||||
echo "✅ 共享PG数据库已创建: $CI_DB_NAME"
|
||||
|
||||
# 执行迁移
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
|
||||
echo "✅ Alembic migrations applied successfully"
|
||||
|
||||
# 清理数据库
|
||||
echo "清理测试数据库: $CI_DB_NAME"
|
||||
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
|
||||
cur.close()
|
||||
conn.close()
|
||||
" 2>/dev/null || echo "WARN: 数据库清理失败"
|
||||
echo "✅ 共享PG数据库已清理"
|
||||
else
|
||||
# 使用临时PG容器(默认模式)
|
||||
echo "使用临时PG容器模式"
|
||||
PG_CONTAINER=ci-pg-validate-migration-${GITHUB_RUN_ID:-$$}
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$PG_CONTAINER" \
|
||||
--shm-size=256m \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-e POSTGRES_DB=xiaoxia_saas \
|
||||
-P \
|
||||
--health-cmd "pg_isready -U postgres" \
|
||||
--health-interval 3s \
|
||||
--health-timeout 3s \
|
||||
--health-retries 20 \
|
||||
postgres:16-alpine
|
||||
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | cut -d: -f2)
|
||||
echo "PostgreSQL port: $PG_PORT"
|
||||
export DATABASE_URL="postgresql+psycopg://postgres:postgres@${PG_HOST}:${PG_PORT}/xiaoxia_saas"
|
||||
|
||||
# 等待容器健康
|
||||
for i in $(seq 1 30); do
|
||||
if docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" 2>/dev/null | grep -q healthy; then
|
||||
echo "PostgreSQL container is healthy on port $PG_PORT"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for PostgreSQL container health... ($i/30)"
|
||||
sleep 2
|
||||
done
|
||||
docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" | grep -q healthy
|
||||
|
||||
# TCP连通性检查
|
||||
echo "验证TCP连通性 ($PG_HOST:$PG_PORT)..."
|
||||
wait_tcp_ready "$PG_HOST" "$PG_PORT" 5
|
||||
echo "TCP connectivity to PostgreSQL confirmed on port $PG_PORT"
|
||||
|
||||
# 执行迁移
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head
|
||||
echo "✅ Alembic migrations applied successfully"
|
||||
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== CI Validate: Alembic迁移验证 通过 ✅ ==="
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/bin/bash
|
||||
# CI Validate: Mypy类型检查(并行Job 2/3)
|
||||
set -eu
|
||||
|
||||
echo "=== CI Validate: Mypy类型检查 ==="
|
||||
|
||||
bash scripts/ci/mypy_check.sh
|
||||
|
||||
echo ""
|
||||
echo "=== CI Validate: Mypy类型检查 通过 ✅ ==="
|
||||
@@ -1,78 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Vitest 增量执行脚本
|
||||
# PR模式下只跑与改动文件相关的测试,大幅节省时间
|
||||
# 用法: bash scripts/ci/vitest_incremental.sh
|
||||
set -eu
|
||||
|
||||
cd apps/web
|
||||
|
||||
# 如果不是PR事件,直接全量跑
|
||||
if [ "${GITHUB_EVENT_NAME:-}" != "pull_request" ]; then
|
||||
echo "非PR模式,全量执行Vitest"
|
||||
npx --no-install vitest run --coverage
|
||||
exit $?
|
||||
fi
|
||||
|
||||
# 获取PR改动的文件列表
|
||||
PR_NUMBER=$(echo "${GITHUB_REF:-}" | sed 's|refs/pull/||; s|/.*||')
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
echo "无法获取PR编号,全量执行Vitest"
|
||||
npx --no-install vitest run --coverage
|
||||
exit $?
|
||||
fi
|
||||
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=100"
|
||||
CHANGED_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
files = json.load(sys.stdin)
|
||||
web_files = []
|
||||
for f in files:
|
||||
fname = f['filename']
|
||||
# 只关注前端源码文件
|
||||
if fname.startswith('apps/web/src/') and fname.endswith(('.ts', '.tsx', '.js', '.jsx')) and f['status'] != 'removed':
|
||||
# 去掉apps/web/前缀,变成相对路径
|
||||
web_files.append(fname.replace('apps/web/', ''))
|
||||
print(' '.join(web_files))
|
||||
except Exception as e:
|
||||
print('')
|
||||
")
|
||||
|
||||
if [ -z "$CHANGED_FILES" ]; then
|
||||
echo "PR未改动前端源码文件,跳过Vitest"
|
||||
echo "(如果配置了前端单测门禁,请确保至少有一个相关测试)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
FILE_COUNT=$(echo "$CHANGED_FILES" | wc -w)
|
||||
echo "PR改动了 $FILE_COUNT 个前端文件"
|
||||
echo "改动文件: $CHANGED_FILES"
|
||||
|
||||
# 如果改动文件太多(超过30个),全量跑更可靠
|
||||
if [ "$FILE_COUNT" -gt 30 ]; then
|
||||
echo "改动文件较多(>$FILE_COUNT),降级为全量执行以确保覆盖"
|
||||
npx --no-install vitest run --coverage
|
||||
exit $?
|
||||
fi
|
||||
|
||||
# 使用vitest --related 跑增量测试
|
||||
echo ""
|
||||
echo "=== 增量执行 Vitest(只跑相关测试)==="
|
||||
echo "相关源文件: $CHANGED_FILES"
|
||||
echo ""
|
||||
|
||||
set +e
|
||||
npx --no-install vitest run --related $CHANGED_FILES
|
||||
VITEST_EXIT=$?
|
||||
set -e
|
||||
|
||||
if [ "$VITEST_EXIT" -eq 0 ]; then
|
||||
echo ""
|
||||
echo "✅ 增量测试通过"
|
||||
echo "(仅覆盖与改动相关的测试用例)"
|
||||
exit 0
|
||||
else
|
||||
echo ""
|
||||
echo "❌ 增量测试失败"
|
||||
exit $VITEST_EXIT
|
||||
fi
|
||||
+96
-154
@@ -1,9 +1,22 @@
|
||||
#!/bin/sh
|
||||
# ===========================================
|
||||
# Staging 部署脚本(SSH 模式,并行优化版)
|
||||
# Staging 部署脚本(SSH 模式,支持自动回滚)
|
||||
# ===========================================
|
||||
# 通过 SSH 在 staging 服务器上执行
|
||||
#
|
||||
# 环境变量:
|
||||
# IMAGE_TAG - 镜像版本 tag(如 commit SHA 或分支名)
|
||||
# REGISTRY_TOKEN - Registry 访问令牌
|
||||
# REGISTRY - Registry 地址(默认 xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji)
|
||||
# REGISTRY_USER - Registry 用户名(默认 xiaoxia)
|
||||
# ENV_FILE - 环境变量文件路径
|
||||
# GENERATED_DIR - 生成文件目录
|
||||
# SKIP_MIGRATION - 跳过数据库迁移(true/false,默认 false)
|
||||
# SKIP_ROLLBACK - 失败时跳过自动回滚(true/false,默认 false)
|
||||
|
||||
set -eu
|
||||
|
||||
# ---- 重试工具函数 ----
|
||||
retry_cmd() {
|
||||
local max_attempts=$1
|
||||
local backoff=$2
|
||||
@@ -60,9 +73,10 @@ mkdir -p "$GENERATED_DIR"
|
||||
mkdir -p "$LEGACY_ASSETS_DIR"
|
||||
|
||||
echo "==========================================="
|
||||
echo " Staging 部署 - $IMAGE_TAG (并行优化版)"
|
||||
echo " Staging 部署 - $IMAGE_TAG"
|
||||
echo "==========================================="
|
||||
|
||||
# ---- 记录当前运行的镜像版本(用于回滚) ----
|
||||
echo "Recording current image versions for rollback..."
|
||||
PREV_API_IMAGE=""
|
||||
PREV_WORKER_IMAGE=""
|
||||
@@ -81,6 +95,7 @@ for c in xiaoxia-api-staging xiaoxia-worker-staging xiaoxia-web-staging; do
|
||||
fi
|
||||
done
|
||||
|
||||
# ---- 回滚函数 ----
|
||||
rollback() {
|
||||
echo ""
|
||||
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
|
||||
@@ -93,6 +108,7 @@ rollback() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 停止当前(失败的)新容器
|
||||
echo "Stopping new containers..."
|
||||
docker rm -f xiaoxia-api-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
|
||||
@@ -100,6 +116,7 @@ rollback() {
|
||||
|
||||
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
|
||||
|
||||
# 恢复 API
|
||||
if [ -n "$PREV_API_IMAGE" ]; then
|
||||
echo "Rolling back API to: $PREV_API_IMAGE"
|
||||
docker run -d \
|
||||
@@ -120,9 +137,12 @@ rollback() {
|
||||
--health-retries 3 \
|
||||
--health-start-period 40s \
|
||||
$LOG_OPTS \
|
||||
"$PREV_API_IMAGE" &
|
||||
"$PREV_API_IMAGE"
|
||||
else
|
||||
echo "No previous API image to roll back to"
|
||||
fi
|
||||
|
||||
# 恢复 Worker
|
||||
if [ -n "$PREV_WORKER_IMAGE" ]; then
|
||||
echo "Rolling back Worker to: $PREV_WORKER_IMAGE"
|
||||
docker run -d \
|
||||
@@ -144,9 +164,12 @@ rollback() {
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
$LOG_OPTS \
|
||||
"$PREV_WORKER_IMAGE" &
|
||||
"$PREV_WORKER_IMAGE"
|
||||
else
|
||||
echo "No previous Worker image to roll back to"
|
||||
fi
|
||||
|
||||
# 恢复 Web
|
||||
if [ -n "$PREV_WEB_IMAGE" ]; then
|
||||
echo "Rolling back Web to: $PREV_WEB_IMAGE"
|
||||
LEGACY_VOLUME=""
|
||||
@@ -164,11 +187,12 @@ rollback() {
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$PREV_WEB_IMAGE" &
|
||||
"$PREV_WEB_IMAGE"
|
||||
else
|
||||
echo "No previous Web image to roll back to"
|
||||
fi
|
||||
|
||||
wait
|
||||
|
||||
# 等待 API 回滚后恢复健康
|
||||
if [ -n "$PREV_API_IMAGE" ]; then
|
||||
echo "Waiting for rolled-back API to become healthy..."
|
||||
i=0
|
||||
@@ -200,6 +224,7 @@ rollback() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ---- 登录 Registry ----
|
||||
if [ -n "$REGISTRY_TOKEN" ]; then
|
||||
echo "=========================================="
|
||||
echo " Login to Registry (with retries)"
|
||||
@@ -208,64 +233,28 @@ if [ -n "$REGISTRY_TOKEN" ]; then
|
||||
retry_docker_login
|
||||
fi
|
||||
|
||||
# ---- 并行 Pull 三个镜像 ----
|
||||
# ---- Pull 新版本镜像 ----
|
||||
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
echo "=========================================="
|
||||
echo " Pull images (parallel, up to 3 retries each)"
|
||||
echo " Pull images (with retries)"
|
||||
echo "=========================================="
|
||||
PULL_LOG_DIR="/tmp/staging-pull-$$"
|
||||
mkdir -p "$PULL_LOG_DIR"
|
||||
|
||||
retry_docker_pull "$REGISTRY_API" > "$PULL_LOG_DIR/api.log" 2>&1 &
|
||||
PID_API=$!
|
||||
retry_docker_pull "$REGISTRY_WORKER" > "$PULL_LOG_DIR/worker.log" 2>&1 &
|
||||
PID_WORKER=$!
|
||||
retry_docker_pull "$REGISTRY_WEB" > "$PULL_LOG_DIR/web.log" 2>&1 &
|
||||
PID_WEB=$!
|
||||
|
||||
wait $PID_API $PID_WORKER $PID_WEB
|
||||
|
||||
echo ""
|
||||
echo "Pull 结果:"
|
||||
PULL_FAILED=0
|
||||
for svc in api worker web; do
|
||||
if tail -1 "$PULL_LOG_DIR/$svc.log" 2>/dev/null | grep -qE "Status:|Downloaded|already exists|is up to date"; then
|
||||
echo " OK $svc"
|
||||
elif grep -qE "Digest:|Status: Downloaded" "$PULL_LOG_DIR/$svc.log" 2>/dev/null; then
|
||||
echo " OK $svc"
|
||||
else
|
||||
# 检查docker pull返回值不直接,用镜像是否存在来判断
|
||||
img_var="REGISTRY_$(echo $svc | tr '[:lower:]' '[:upper:]')"
|
||||
img_val=$(eval echo "\$$img_var")
|
||||
if docker image inspect "$img_val" >/dev/null 2>&1; then
|
||||
echo " OK $svc"
|
||||
else
|
||||
echo " FAIL $svc"
|
||||
tail -5 "$PULL_LOG_DIR/$svc.log" 2>/dev/null || true
|
||||
PULL_FAILED=$((PULL_FAILED + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
rm -rf "$PULL_LOG_DIR"
|
||||
|
||||
if [ "$PULL_FAILED" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "ERROR: $PULL_FAILED 个镜像 pull 失败"
|
||||
exit 1
|
||||
fi
|
||||
retry_docker_pull "$REGISTRY_API"
|
||||
retry_docker_pull "$REGISTRY_WORKER"
|
||||
retry_docker_pull "$REGISTRY_WEB"
|
||||
|
||||
echo "All images pulled."
|
||||
|
||||
# ---- 备份 legacy assets ----
|
||||
echo "Backing up legacy assets from current web container..."
|
||||
if docker inspect xiaoxia-web-staging >/dev/null 2>&1; then
|
||||
_tmpdir="/tmp/legacy-assets-$$"
|
||||
rm -rf "$_tmpdir"
|
||||
mkdir -p "$_tmpdir"
|
||||
docker cp xiaoxia-web-staging:/usr/share/nginx/html/assets/. "$_tmpdir/" 2>/dev/null || true
|
||||
# 只有目录非空才拷贝,避免覆盖有内容的 legacy assets
|
||||
if [ -d "$_tmpdir" ] && [ "$(ls -A "$_tmpdir" 2>/dev/null)" ]; then
|
||||
cp -an "$_tmpdir"/. "$LEGACY_ASSETS_DIR"/ 2>/dev/null || true
|
||||
echo "Legacy assets backed up: $(ls "$_tmpdir" | wc -l) files"
|
||||
@@ -275,11 +264,13 @@ else
|
||||
echo "No existing web container, skipping legacy assets backup"
|
||||
fi
|
||||
|
||||
# 清理 7 天前的 legacy assets
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ]; then
|
||||
find "$LEGACY_ASSETS_DIR" -type f -mtime +7 -delete 2>/dev/null || true
|
||||
echo "Legacy assets cleanup done (retain 7 days)"
|
||||
fi
|
||||
|
||||
# ---- 检查基础设施容器 ----
|
||||
echo "Checking infrastructure containers..."
|
||||
for c in xiaoxia-postgres-staging xiaoxia-redis-staging; do
|
||||
if ! docker inspect "$c" >/dev/null 2>&1; then
|
||||
@@ -293,8 +284,10 @@ for c in xiaoxia-postgres-staging xiaoxia-redis-staging; do
|
||||
fi
|
||||
done
|
||||
|
||||
# ---- 创建网络(不存在则创建) ----
|
||||
docker network create xiaoxia-net-staging 2>/dev/null || true
|
||||
|
||||
# ---- 数据库迁移 ----
|
||||
if [ "$SKIP_MIGRATION" != "true" ]; then
|
||||
echo "Running database migrations..."
|
||||
docker run --rm \
|
||||
@@ -303,6 +296,8 @@ if [ "$SKIP_MIGRATION" != "true" ]; then
|
||||
-e APP_ENV=staging \
|
||||
"$REGISTRY_API" sh -c "cd /app && alembic upgrade head" || {
|
||||
echo "ERROR: Database migration failed"
|
||||
echo "Note: Migration failures are NOT automatically rolled back (data safety)"
|
||||
echo "Please manually check and fix the migration, then redeploy"
|
||||
exit 1
|
||||
}
|
||||
echo "Migrations completed."
|
||||
@@ -310,6 +305,7 @@ else
|
||||
echo "Skipping migrations (SKIP_MIGRATION=true)"
|
||||
fi
|
||||
|
||||
# ---- 停止旧容器 ----
|
||||
echo "Stopping old containers..."
|
||||
docker rm -f xiaoxia-api-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
|
||||
@@ -317,14 +313,8 @@ docker rm -f xiaoxia-web-staging 2>/dev/null || true
|
||||
|
||||
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
|
||||
|
||||
# ---- 并行启动三个容器 ----
|
||||
echo "Starting all containers (parallel)..."
|
||||
|
||||
LEGACY_VOLUME=""
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
|
||||
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
|
||||
fi
|
||||
|
||||
# ---- 启动 API ----
|
||||
echo "Starting API container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-api-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
@@ -343,9 +333,10 @@ docker run -d \
|
||||
--health-retries 3 \
|
||||
--health-start-period 40s \
|
||||
$LOG_OPTS \
|
||||
"$REGISTRY_API" &
|
||||
PID_API_START=$!
|
||||
"$REGISTRY_API" || rollback
|
||||
|
||||
# ---- 启动 Worker ----
|
||||
echo "Starting Worker container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-worker-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
@@ -365,9 +356,18 @@ docker run -d \
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
$LOG_OPTS \
|
||||
"$REGISTRY_WORKER" &
|
||||
PID_WORKER_START=$!
|
||||
"$REGISTRY_WORKER" || rollback
|
||||
|
||||
# ---- 启动 Web ----
|
||||
LEGACY_VOLUME=""
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
|
||||
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
|
||||
echo "Web container: legacy assets mounted (fallback)"
|
||||
else
|
||||
echo "Web container: no legacy assets to mount"
|
||||
fi
|
||||
|
||||
echo "Starting Web container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-web-staging \
|
||||
--network xiaoxia-net-staging \
|
||||
@@ -379,111 +379,53 @@ docker run -d \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$REGISTRY_WEB" &
|
||||
PID_WEB_START=$!
|
||||
"$REGISTRY_WEB" || rollback
|
||||
|
||||
wait $PID_API_START $PID_WORKER_START $PID_WEB_START
|
||||
|
||||
START_FAILED=0
|
||||
for c in xiaoxia-api-staging xiaoxia-worker-staging xiaoxia-web-staging; do
|
||||
if ! docker inspect "$c" >/dev/null 2>&1; then
|
||||
echo " FAIL $c: not created"
|
||||
START_FAILED=$((START_FAILED + 1))
|
||||
else
|
||||
state=$(docker inspect -f '{{.State.Status}}' "$c")
|
||||
if [ "$state" = "running" ] || [ "$state" = "starting" ]; then
|
||||
echo " OK $c: $state"
|
||||
else
|
||||
echo " FAIL $c: $state"
|
||||
docker logs --tail 20 "$c" 2>/dev/null || true
|
||||
START_FAILED=$((START_FAILED + 1))
|
||||
fi
|
||||
# ---- 等待 API 健康 ----
|
||||
echo "Waiting for API to become healthy..."
|
||||
i=0
|
||||
while [ "$i" -lt 40 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
|
||||
echo "API is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/40)"
|
||||
sleep 3
|
||||
done
|
||||
|
||||
if [ "$START_FAILED" -gt 0 ]; then
|
||||
echo "ERROR: $START_FAILED 个容器启动失败"
|
||||
rollback
|
||||
fi
|
||||
|
||||
# ---- 并行等待 API 和 Web 健康 ----
|
||||
echo ""
|
||||
echo "Waiting for API + Web health (parallel)..."
|
||||
|
||||
HEALTH_LOG_DIR="/tmp/staging-health-$$"
|
||||
mkdir -p "$HEALTH_LOG_DIR"
|
||||
|
||||
(
|
||||
i=0
|
||||
while [ "$i" -lt 40 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
|
||||
echo "API healthy after $((i * 3))s"
|
||||
exit 0
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 3
|
||||
done
|
||||
echo "API FAILED after 120s"
|
||||
exit 1
|
||||
) > "$HEALTH_LOG_DIR/api.log" 2>&1 &
|
||||
PID_API_HEALTH=$!
|
||||
|
||||
(
|
||||
i=0
|
||||
while [ "$i" -lt 15 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:3001/ >/dev/null 2>&1; then
|
||||
echo "Web healthy after $((i * 2))s"
|
||||
exit 0
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 2
|
||||
done
|
||||
echo "Web FAILED after 30s"
|
||||
exit 1
|
||||
) > "$HEALTH_LOG_DIR/web.log" 2>&1 &
|
||||
PID_WEB_HEALTH=$!
|
||||
|
||||
set +e
|
||||
wait $PID_API_HEALTH
|
||||
API_EXIT=$?
|
||||
wait $PID_WEB_HEALTH
|
||||
WEB_EXIT=$?
|
||||
set -e
|
||||
|
||||
echo ""
|
||||
echo "健康检查结果:"
|
||||
API_OK=0
|
||||
WEB_OK=0
|
||||
if [ "$API_EXIT" -eq 0 ]; then
|
||||
echo " OK API: $(cat "$HEALTH_LOG_DIR/api.log")"
|
||||
API_OK=1
|
||||
else
|
||||
echo " FAIL API: 120s未就绪"
|
||||
if [ "$i" -ge 40 ]; then
|
||||
echo "ERROR: API did not become healthy within 120s"
|
||||
docker logs --tail 50 xiaoxia-api-staging
|
||||
fi
|
||||
|
||||
if [ "$WEB_EXIT" -eq 0 ]; then
|
||||
echo " OK Web: $(cat "$HEALTH_LOG_DIR/web.log")"
|
||||
WEB_OK=1
|
||||
else
|
||||
echo " FAIL Web: 30s未就绪"
|
||||
docker logs --tail 30 xiaoxia-web-staging
|
||||
fi
|
||||
|
||||
rm -rf "$HEALTH_LOG_DIR"
|
||||
|
||||
if [ "$API_OK" -eq 0 ] || [ "$WEB_OK" -eq 0 ]; then
|
||||
echo ""
|
||||
echo "ERROR: 健康检查失败"
|
||||
rollback
|
||||
fi
|
||||
|
||||
# ---- 等待 Web 健康 ----
|
||||
echo "Waiting for Web to become healthy..."
|
||||
i=0
|
||||
while [ "$i" -lt 15 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:3001/ >/dev/null 2>&1; then
|
||||
echo "Web is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/15)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$i" -ge 15 ]; then
|
||||
echo "ERROR: Web did not become healthy within 30s"
|
||||
docker logs --tail 30 xiaoxia-web-staging
|
||||
rollback
|
||||
fi
|
||||
|
||||
# ---- 清理旧镜像 ----
|
||||
echo "Cleaning up old images..."
|
||||
docker image prune -af --filter "until=168h" 2>/dev/null || true
|
||||
docker builder prune -af --filter "until=168h" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "=== Staging deployment complete (并行优化版) ==="
|
||||
echo "=== Staging deployment complete ==="
|
||||
echo "API: http://127.0.0.1:8000"
|
||||
echo "Web: http://127.0.0.1:3001"
|
||||
echo "Version: $IMAGE_TAG"
|
||||
|
||||
@@ -102,38 +102,3 @@ class TestClassificationJobCreate:
|
||||
job = ClassificationJob.create(project_id="p", asset_id="a")
|
||||
assert job.created_at.tzinfo is not None
|
||||
assert job.updated_at.tzinfo is not None
|
||||
|
||||
|
||||
class TestClassificationJobState:
|
||||
"""ClassificationJob 状态操作测试"""
|
||||
|
||||
def test_set_processing(self):
|
||||
job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1")
|
||||
job.status = ClassificationJobStatus.PROCESSING
|
||||
assert job.status == ClassificationJobStatus.PROCESSING
|
||||
|
||||
def test_set_completed_with_result(self):
|
||||
job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1")
|
||||
job.status = ClassificationJobStatus.COMPLETED
|
||||
job.classification = AssetClassification.SCENIC
|
||||
job.confidence = 0.95
|
||||
assert job.status == ClassificationJobStatus.COMPLETED
|
||||
assert job.classification == "scenic"
|
||||
assert job.confidence == pytest.approx(0.95)
|
||||
|
||||
def test_set_failed_with_error(self):
|
||||
job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1")
|
||||
job.status = ClassificationJobStatus.FAILED
|
||||
job.error_message = "model timeout"
|
||||
assert job.status == ClassificationJobStatus.FAILED
|
||||
assert job.error_message == "model timeout"
|
||||
|
||||
def test_confidence_range_zero(self):
|
||||
job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1")
|
||||
job.confidence = 0.0
|
||||
assert job.confidence == 0.0
|
||||
|
||||
def test_confidence_range_one(self):
|
||||
job = ClassificationJob.create(project_id="proj-1", asset_id="asset-1")
|
||||
job.confidence = 1.0
|
||||
assert job.confidence == 1.0
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
"""剪辑计划领域模型单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
|
||||
|
||||
class TestEditPlanStatus:
|
||||
"""EditPlanStatus 枚举测试."""
|
||||
|
||||
def test_status_values(self):
|
||||
assert EditPlanStatus.DRAFT.value == "draft"
|
||||
assert EditPlanStatus.EDITING.value == "editing"
|
||||
assert EditPlanStatus.RENDERING.value == "rendering"
|
||||
assert EditPlanStatus.COMPLETED.value == "completed"
|
||||
assert EditPlanStatus.FAILED.value == "failed"
|
||||
|
||||
def test_status_is_str(self):
|
||||
assert isinstance(EditPlanStatus.DRAFT, str)
|
||||
assert EditPlanStatus.DRAFT == "draft"
|
||||
|
||||
|
||||
class TestEditPlanCreate:
|
||||
"""创建剪辑计划测试."""
|
||||
|
||||
def test_create_basic(self):
|
||||
plan = EditPlan.create(template_id="tpl_001", name="测试计划")
|
||||
assert plan.id
|
||||
assert len(plan.id) == 32 # uuid4 hex
|
||||
assert plan.template_id == "tpl_001"
|
||||
assert plan.name == "测试计划"
|
||||
assert plan.status == EditPlanStatus.DRAFT
|
||||
assert plan.total_duration == 0.0
|
||||
assert plan.config == {}
|
||||
assert plan.source_edit_plan_id == ""
|
||||
assert plan.project_id == ""
|
||||
assert plan.created_by_user_id == ""
|
||||
|
||||
def test_create_with_all_fields(self):
|
||||
plan = EditPlan.create(
|
||||
template_id="tpl_001",
|
||||
name="完整测试计划",
|
||||
config={"key": "value"},
|
||||
total_duration=60.5,
|
||||
source_edit_plan_id="src_001",
|
||||
project_id="proj_001",
|
||||
created_by_user_id="user_001",
|
||||
)
|
||||
assert plan.name == "完整测试计划"
|
||||
assert plan.total_duration == 60.5
|
||||
assert plan.config == {"key": "value"}
|
||||
assert plan.source_edit_plan_id == "src_001"
|
||||
assert plan.project_id == "proj_001"
|
||||
assert plan.created_by_user_id == "user_001"
|
||||
|
||||
def test_create_empty_name_raises(self):
|
||||
with pytest.raises(ValueError, match="名称不能为空"):
|
||||
EditPlan.create(template_id="tpl_001", name="")
|
||||
|
||||
def test_create_whitespace_name_raises(self):
|
||||
with pytest.raises(ValueError, match="名称不能为空"):
|
||||
EditPlan.create(template_id="tpl_001", name=" ")
|
||||
|
||||
def test_create_empty_template_id_raises(self):
|
||||
with pytest.raises(ValueError, match="template_id 不能为空"):
|
||||
EditPlan.create(template_id="", name="测试")
|
||||
|
||||
def test_create_whitespace_template_id_raises(self):
|
||||
with pytest.raises(ValueError, match="template_id 不能为空"):
|
||||
EditPlan.create(template_id=" ", name="测试")
|
||||
|
||||
def test_create_name_stripped(self):
|
||||
plan = EditPlan.create(template_id="tpl_001", name=" 我的计划 ")
|
||||
assert plan.name == "我的计划"
|
||||
|
||||
def test_create_template_id_stripped(self):
|
||||
plan = EditPlan.create(template_id=" tpl_001 ", name="测试")
|
||||
assert plan.template_id == "tpl_001"
|
||||
|
||||
def test_create_timestamps_set(self):
|
||||
before = datetime.now(timezone.utc)
|
||||
plan = EditPlan.create(template_id="tpl_001", name="测试")
|
||||
after = datetime.now(timezone.utc)
|
||||
assert before <= plan.created_at <= after
|
||||
assert before <= plan.updated_at <= after
|
||||
|
||||
def test_create_config_none_defaults_to_empty(self):
|
||||
plan = EditPlan.create(template_id="tpl_001", name="测试", config=None)
|
||||
assert plan.config == {}
|
||||
|
||||
|
||||
class TestEditPlanStateMachine:
|
||||
"""状态机流转测试."""
|
||||
|
||||
def _make_plan(self, status: EditPlanStatus) -> EditPlan:
|
||||
return EditPlan(
|
||||
id="test_id",
|
||||
template_id="tpl_001",
|
||||
name="测试计划",
|
||||
status=status,
|
||||
)
|
||||
|
||||
def test_draft_to_editing(self):
|
||||
plan = self._make_plan(EditPlanStatus.DRAFT)
|
||||
plan.start_editing()
|
||||
assert plan.status == EditPlanStatus.EDITING
|
||||
assert plan.updated_at > plan.created_at
|
||||
|
||||
def test_editing_to_rendering(self):
|
||||
plan = self._make_plan(EditPlanStatus.EDITING)
|
||||
plan.start_rendering()
|
||||
assert plan.status == EditPlanStatus.RENDERING
|
||||
|
||||
def test_rendering_to_completed(self):
|
||||
plan = self._make_plan(EditPlanStatus.RENDERING)
|
||||
plan.mark_completed()
|
||||
assert plan.status == EditPlanStatus.COMPLETED
|
||||
|
||||
def test_rendering_to_failed(self):
|
||||
plan = self._make_plan(EditPlanStatus.RENDERING)
|
||||
plan.mark_failed()
|
||||
assert plan.status == EditPlanStatus.FAILED
|
||||
|
||||
def test_completed_to_editing_resume(self):
|
||||
plan = self._make_plan(EditPlanStatus.COMPLETED)
|
||||
plan.resume_editing()
|
||||
assert plan.status == EditPlanStatus.EDITING
|
||||
|
||||
def test_failed_to_editing_resume(self):
|
||||
plan = self._make_plan(EditPlanStatus.FAILED)
|
||||
plan.resume_editing()
|
||||
assert plan.status == EditPlanStatus.EDITING
|
||||
|
||||
def test_failed_to_draft_reset(self):
|
||||
plan = self._make_plan(EditPlanStatus.FAILED)
|
||||
plan.reset_to_draft()
|
||||
assert plan.status == EditPlanStatus.DRAFT
|
||||
|
||||
def test_invalid_start_editing_from_editing(self):
|
||||
plan = self._make_plan(EditPlanStatus.EDITING)
|
||||
with pytest.raises(ValueError, match="只有 draft 状态"):
|
||||
plan.start_editing()
|
||||
|
||||
def test_invalid_start_editing_from_rendering(self):
|
||||
plan = self._make_plan(EditPlanStatus.RENDERING)
|
||||
with pytest.raises(ValueError):
|
||||
plan.start_editing()
|
||||
|
||||
def test_invalid_start_rendering_from_draft(self):
|
||||
plan = self._make_plan(EditPlanStatus.DRAFT)
|
||||
with pytest.raises(ValueError, match="只有 editing 状态"):
|
||||
plan.start_rendering()
|
||||
|
||||
def test_invalid_start_rendering_from_completed(self):
|
||||
plan = self._make_plan(EditPlanStatus.COMPLETED)
|
||||
with pytest.raises(ValueError):
|
||||
plan.start_rendering()
|
||||
|
||||
def test_invalid_mark_completed_from_draft(self):
|
||||
plan = self._make_plan(EditPlanStatus.DRAFT)
|
||||
with pytest.raises(ValueError, match="只有 rendering 状态"):
|
||||
plan.mark_completed()
|
||||
|
||||
def test_invalid_mark_failed_from_editing(self):
|
||||
plan = self._make_plan(EditPlanStatus.EDITING)
|
||||
with pytest.raises(ValueError):
|
||||
plan.mark_failed()
|
||||
|
||||
def test_invalid_resume_editing_from_draft(self):
|
||||
plan = self._make_plan(EditPlanStatus.DRAFT)
|
||||
with pytest.raises(ValueError, match="只有 completed/failed 状态"):
|
||||
plan.resume_editing()
|
||||
|
||||
def test_invalid_resume_editing_from_rendering(self):
|
||||
plan = self._make_plan(EditPlanStatus.RENDERING)
|
||||
with pytest.raises(ValueError):
|
||||
plan.resume_editing()
|
||||
|
||||
def test_invalid_reset_to_draft_from_draft(self):
|
||||
plan = self._make_plan(EditPlanStatus.DRAFT)
|
||||
with pytest.raises(ValueError, match="只有 failed 状态"):
|
||||
plan.reset_to_draft()
|
||||
|
||||
def test_invalid_reset_to_draft_from_completed(self):
|
||||
plan = self._make_plan(EditPlanStatus.COMPLETED)
|
||||
with pytest.raises(ValueError):
|
||||
plan.reset_to_draft()
|
||||
|
||||
def test_state_transition_updates_updated_at(self):
|
||||
plan = self._make_plan(EditPlanStatus.DRAFT)
|
||||
old_updated = plan.updated_at
|
||||
plan.start_editing()
|
||||
assert plan.updated_at >= old_updated
|
||||
|
||||
|
||||
class TestEditPlanDataclass:
|
||||
"""数据类属性测试."""
|
||||
|
||||
def test_slots_prevents_dynamic_attributes(self):
|
||||
plan = EditPlan(id="1", template_id="t1", name="test")
|
||||
with pytest.raises(AttributeError):
|
||||
plan.new_field = "value"
|
||||
|
||||
def test_full_flow_draft_editing_rendering_completed(self):
|
||||
"""完整流程:草稿 → 编辑 → 渲染 → 完成."""
|
||||
plan = EditPlan.create(template_id="tpl_001", name="完整流程")
|
||||
assert plan.status == EditPlanStatus.DRAFT
|
||||
|
||||
plan.start_editing()
|
||||
assert plan.status == EditPlanStatus.EDITING
|
||||
|
||||
plan.start_rendering()
|
||||
assert plan.status == EditPlanStatus.RENDERING
|
||||
|
||||
plan.mark_completed()
|
||||
assert plan.status == EditPlanStatus.COMPLETED
|
||||
|
||||
def test_full_flow_draft_editing_rendering_failed_reset(self):
|
||||
"""完整流程:草稿 → 编辑 → 渲染 → 失败 → 重置 → 编辑 → 渲染 → 完成."""
|
||||
plan = EditPlan.create(template_id="tpl_001", name="失败重试流程")
|
||||
|
||||
plan.start_editing()
|
||||
plan.start_rendering()
|
||||
plan.mark_failed()
|
||||
assert plan.status == EditPlanStatus.FAILED
|
||||
|
||||
plan.reset_to_draft()
|
||||
assert plan.status == EditPlanStatus.DRAFT
|
||||
|
||||
plan.start_editing()
|
||||
plan.start_rendering()
|
||||
plan.mark_completed()
|
||||
assert plan.status == EditPlanStatus.COMPLETED
|
||||
@@ -1,40 +0,0 @@
|
||||
"""
|
||||
EditingMode 剪辑模式枚举单元测试
|
||||
"""
|
||||
|
||||
from packages.domain.editing_mode import EditingMode
|
||||
|
||||
|
||||
class TestEditingMode:
|
||||
"""EditingMode 枚举测试"""
|
||||
|
||||
def test_all_modes_exist(self):
|
||||
assert EditingMode.ONE_TAKE == "one_take"
|
||||
assert EditingMode.PIP == "pip"
|
||||
assert EditingMode.VOICE_OVER == "voice_over"
|
||||
assert EditingMode.VOICE_PIP == "voice_pip"
|
||||
|
||||
def test_total_count(self):
|
||||
assert len(EditingMode) == 4
|
||||
|
||||
def test_is_string_type(self):
|
||||
for mode in EditingMode:
|
||||
assert isinstance(mode.value, str)
|
||||
assert isinstance(mode, str)
|
||||
|
||||
def test_mode_descriptions(self):
|
||||
"""验证模式值有意义"""
|
||||
assert "one" in EditingMode.ONE_TAKE
|
||||
assert "pip" in EditingMode.PIP
|
||||
assert "voice" in EditingMode.VOICE_OVER
|
||||
assert "voice" in EditingMode.VOICE_PIP
|
||||
|
||||
def test_usage_in_comparison(self):
|
||||
mode = EditingMode.ONE_TAKE
|
||||
assert mode == "one_take"
|
||||
assert mode != "pip"
|
||||
|
||||
def test_iterable(self):
|
||||
modes = list(EditingMode)
|
||||
assert len(modes) == 4
|
||||
assert EditingMode.ONE_TAKE in modes
|
||||
@@ -139,58 +139,3 @@ class TestGeneratedVideoCreate:
|
||||
video = GeneratedVideo.create(project_id="p", generation_task_id="t", name="n", file_url="u")
|
||||
assert video.created_at.tzinfo is not None
|
||||
assert video.generated_at.tzinfo is not None
|
||||
|
||||
|
||||
class TestGeneratedVideoProperties:
|
||||
"""GeneratedVideo 属性测试"""
|
||||
|
||||
def test_default_status_completed(self):
|
||||
gv = GeneratedVideo.create(
|
||||
project_id="proj-1",
|
||||
generation_task_id="task-1",
|
||||
name="测试视频",
|
||||
file_url="https://example.com/video.mp4",
|
||||
)
|
||||
assert gv.status == "completed"
|
||||
|
||||
def test_default_review_status(self):
|
||||
gv = GeneratedVideo.create(
|
||||
project_id="proj-1",
|
||||
generation_task_id="task-1",
|
||||
name="测试视频",
|
||||
file_url="https://example.com/video.mp4",
|
||||
)
|
||||
assert gv.review_status == "pending_review"
|
||||
|
||||
def test_set_status(self):
|
||||
gv = GeneratedVideo.create(
|
||||
project_id="proj-1",
|
||||
generation_task_id="task-1",
|
||||
name="测试视频",
|
||||
file_url="https://example.com/video.mp4",
|
||||
)
|
||||
gv.status = "failed"
|
||||
assert gv.status == "failed"
|
||||
|
||||
def test_mark_as_duplicate(self):
|
||||
gv = GeneratedVideo.create(
|
||||
project_id="proj-1",
|
||||
generation_task_id="task-1",
|
||||
name="测试视频",
|
||||
file_url="https://example.com/video.mp4",
|
||||
)
|
||||
gv.is_duplicate = True
|
||||
gv.duplicate_of = "video-original"
|
||||
assert gv.is_duplicate is True
|
||||
assert gv.duplicate_of == "video-original"
|
||||
|
||||
def test_set_fingerprint(self):
|
||||
gv = GeneratedVideo.create(
|
||||
project_id="proj-1",
|
||||
generation_task_id="task-1",
|
||||
name="测试视频",
|
||||
file_url="https://example.com/video.mp4",
|
||||
)
|
||||
fingerprint = {"phash": "abc123", "md5": "def456"}
|
||||
gv.video_fingerprint = fingerprint
|
||||
assert gv.video_fingerprint == fingerprint
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
"""MemoryStateStore 单元测试 - 微信 OAuth state 存储
|
||||
|
||||
覆盖:正常存取、一次性消费、过期清理、并发安全、空 state 处理。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from threading import Thread
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestMemoryStateStore:
|
||||
def test_put_and_verify_success(self):
|
||||
"""正常存入并校验成功"""
|
||||
from packages.application.auth.wechat_oauth_service import MemoryStateStore
|
||||
|
||||
store = MemoryStateStore()
|
||||
store.put("test_state_123")
|
||||
assert store.verify_and_consume("test_state_123") is True
|
||||
|
||||
def test_verify_nonexistent_state_fails(self):
|
||||
"""不存在的 state 校验失败"""
|
||||
from packages.application.auth.wechat_oauth_service import MemoryStateStore
|
||||
|
||||
store = MemoryStateStore()
|
||||
assert store.verify_and_consume("nonexistent") is False
|
||||
|
||||
def test_state_single_use(self):
|
||||
"""state 只能消费一次(防重放)"""
|
||||
from packages.application.auth.wechat_oauth_service import MemoryStateStore
|
||||
|
||||
store = MemoryStateStore()
|
||||
store.put("single_use_state")
|
||||
assert store.verify_and_consume("single_use_state") is True
|
||||
assert store.verify_and_consume("single_use_state") is False
|
||||
|
||||
def test_empty_state_rejected(self):
|
||||
"""空字符串 state 校验失败"""
|
||||
from packages.application.auth.wechat_oauth_service import MemoryStateStore
|
||||
|
||||
store = MemoryStateStore()
|
||||
store.put("")
|
||||
# 空字符串作为 key 技术上可以存,但业务层应该拒绝
|
||||
# 这里验证 store 本身行为一致性
|
||||
assert store.verify_and_consume("") is True # 存入了就能通过一次
|
||||
assert store.verify_and_consume("") is False # 消费后就没了
|
||||
|
||||
def test_expired_state_cleaned(self):
|
||||
"""过期 state 会被清理,校验失败"""
|
||||
from packages.application.auth.wechat_oauth_service import MemoryStateStore
|
||||
|
||||
# TTL 设为 0.01 秒,快速过期
|
||||
store = MemoryStateStore(ttl_seconds=0.01)
|
||||
store.put("expire_me")
|
||||
time.sleep(0.02)
|
||||
assert store.verify_and_consume("expire_me") is False
|
||||
|
||||
def test_multiple_states_independent(self):
|
||||
"""多个 state 互不影响"""
|
||||
from packages.application.auth.wechat_oauth_service import MemoryStateStore
|
||||
|
||||
store = MemoryStateStore()
|
||||
store.put("state_a")
|
||||
store.put("state_b")
|
||||
store.put("state_c")
|
||||
|
||||
# 消费 b
|
||||
assert store.verify_and_consume("state_b") is True
|
||||
assert store.verify_and_consume("state_b") is False
|
||||
|
||||
# a 和 c 仍然有效
|
||||
assert store.verify_and_consume("state_a") is True
|
||||
assert store.verify_and_consume("state_c") is True
|
||||
|
||||
def test_clean_expired_doesnt_touch_valid(self):
|
||||
"""过期清理不影响未过期的 state"""
|
||||
from packages.application.auth.wechat_oauth_service import MemoryStateStore
|
||||
|
||||
store = MemoryStateStore(ttl_seconds=10)
|
||||
store.put("valid_state")
|
||||
|
||||
# 手动触发清理(通过 verify 触发内部 clean_expired)
|
||||
# 由于所有 state 都没过期,清理不影响
|
||||
assert store.verify_and_consume("valid_state") is True
|
||||
|
||||
def test_thread_safety_concurrent_put(self):
|
||||
"""并发写入不丢数据"""
|
||||
from packages.application.auth.wechat_oauth_service import MemoryStateStore
|
||||
|
||||
store = MemoryStateStore(ttl_seconds=60)
|
||||
states = [f"state_{i}" for i in range(100)]
|
||||
|
||||
def put_states(states_list):
|
||||
for s in states_list:
|
||||
store.put(s)
|
||||
|
||||
threads = [Thread(target=put_states, args=(states[i * 20 : (i + 1) * 20],)) for i in range(5)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# 每个 state 都能消费一次
|
||||
for s in states:
|
||||
assert store.verify_and_consume(s) is True
|
||||
|
||||
def test_thread_safety_concurrent_consume(self):
|
||||
"""并发消费同一个 state 只有一个能成功"""
|
||||
from packages.application.auth.wechat_oauth_service import MemoryStateStore
|
||||
|
||||
store = MemoryStateStore()
|
||||
store.put("contested_state")
|
||||
|
||||
results = []
|
||||
|
||||
def try_consume():
|
||||
results.append(store.verify_and_consume("contested_state"))
|
||||
|
||||
threads = [Thread(target=try_consume) for _ in range(10)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# 只有一个成功,其余失败
|
||||
assert sum(1 for r in results if r) == 1
|
||||
assert sum(1 for r in results if not r) == 9
|
||||
|
||||
def test_default_ttl_is_10_minutes(self):
|
||||
"""默认 TTL 是 600 秒(10分钟)"""
|
||||
from packages.application.auth.wechat_oauth_service import (
|
||||
STATE_TTL_SECONDS,
|
||||
MemoryStateStore,
|
||||
)
|
||||
|
||||
assert STATE_TTL_SECONDS == 600
|
||||
store = MemoryStateStore()
|
||||
# 验证默认值生效:存入后立即验证应该通过
|
||||
store.put("default_ttl_test")
|
||||
assert store.verify_and_consume("default_ttl_test") is True
|
||||
@@ -1,166 +0,0 @@
|
||||
"""
|
||||
PresetVoice 预置音色领域模型单元测试
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from domain.preset_voices import (
|
||||
PRESET_VOICES,
|
||||
PresetVoice,
|
||||
get_preset_voice_by_id,
|
||||
get_preset_voices,
|
||||
is_preset_voice,
|
||||
)
|
||||
|
||||
|
||||
class TestPresetVoice:
|
||||
"""PresetVoice 数据类测试"""
|
||||
|
||||
def test_create_required_fields(self):
|
||||
v = PresetVoice(
|
||||
voice_id="test_v1",
|
||||
name="测试音色",
|
||||
description="测试描述",
|
||||
gender="female",
|
||||
)
|
||||
assert v.voice_id == "test_v1"
|
||||
assert v.name == "测试音色"
|
||||
assert v.description == "测试描述"
|
||||
assert v.gender == "female"
|
||||
|
||||
def test_default_language(self):
|
||||
v = PresetVoice(voice_id="v1", name="n", description="d", gender="female")
|
||||
assert v.language == "zh-CN"
|
||||
|
||||
def test_default_preview_url(self):
|
||||
v = PresetVoice(voice_id="v1", name="n", description="d", gender="female")
|
||||
assert v.preview_url == ""
|
||||
|
||||
def test_default_tags_none(self):
|
||||
v = PresetVoice(voice_id="v1", name="n", description="d", gender="female")
|
||||
assert v.tags is None
|
||||
|
||||
def test_custom_tags(self):
|
||||
v = PresetVoice(
|
||||
voice_id="v1",
|
||||
name="n",
|
||||
description="d",
|
||||
gender="female",
|
||||
tags=["温柔", "女声"],
|
||||
)
|
||||
assert v.tags == ["温柔", "女声"]
|
||||
|
||||
def test_is_frozen(self):
|
||||
v = PresetVoice(voice_id="v1", name="n", description="d", gender="female")
|
||||
with pytest.raises(AttributeError):
|
||||
v.name = "改了"
|
||||
|
||||
|
||||
class TestPresetVoiceToDict:
|
||||
"""to_dict 序列化测试"""
|
||||
|
||||
def test_to_dict_basic(self):
|
||||
v = PresetVoice(
|
||||
voice_id="longxiaochun_v3",
|
||||
name="龙小淳",
|
||||
description="温柔女声",
|
||||
gender="female",
|
||||
language="zh-CN",
|
||||
preview_url="https://example.com/audio.mp3",
|
||||
tags=["温柔", "女声"],
|
||||
)
|
||||
d = v.to_dict()
|
||||
assert d["voice_id"] == "longxiaochun_v3"
|
||||
assert d["name"] == "龙小淳"
|
||||
assert d["description"] == "温柔女声"
|
||||
assert d["gender"] == "female"
|
||||
assert d["language"] == "zh-CN"
|
||||
assert d["preview_url"] == "https://example.com/audio.mp3"
|
||||
assert d["tags"] == ["温柔", "女声"]
|
||||
|
||||
def test_to_dict_tags_none_becomes_empty_list(self):
|
||||
v = PresetVoice(voice_id="v1", name="n", description="d", gender="female")
|
||||
d = v.to_dict()
|
||||
assert d["tags"] == []
|
||||
|
||||
|
||||
class TestPresetVoiceList:
|
||||
"""预置音色列表测试"""
|
||||
|
||||
def test_list_not_empty(self):
|
||||
voices = get_preset_voices()
|
||||
assert len(voices) > 0
|
||||
|
||||
def test_all_are_preset_voice_instances(self):
|
||||
for v in PRESET_VOICES:
|
||||
assert isinstance(v, PresetVoice)
|
||||
|
||||
def test_voice_ids_unique(self):
|
||||
ids = [v.voice_id for v in PRESET_VOICES]
|
||||
assert len(ids) == len(set(ids))
|
||||
|
||||
def test_all_have_required_fields(self):
|
||||
for v in PRESET_VOICES:
|
||||
assert v.voice_id
|
||||
assert v.name
|
||||
assert v.description
|
||||
assert v.gender in ("male", "female")
|
||||
assert v.language
|
||||
|
||||
def test_total_count(self):
|
||||
assert len(PRESET_VOICES) == 8
|
||||
|
||||
|
||||
class TestGetPresetVoiceById:
|
||||
"""按 ID 查询预置音色测试"""
|
||||
|
||||
def test_existing_voice(self):
|
||||
v = get_preset_voice_by_id("longxiaochun_v3")
|
||||
assert v is not None
|
||||
assert v.name == "龙小淳"
|
||||
assert v.gender == "female"
|
||||
|
||||
def test_nonexistent_voice(self):
|
||||
v = get_preset_voice_by_id("nonexistent_voice")
|
||||
assert v is None
|
||||
|
||||
def test_empty_string(self):
|
||||
v = get_preset_voice_by_id("")
|
||||
assert v is None
|
||||
|
||||
|
||||
class TestIsPresetVoice:
|
||||
"""判断是否预置音色测试"""
|
||||
|
||||
def test_existing_is_preset(self):
|
||||
assert is_preset_voice("longxiaochen_v3") is True
|
||||
|
||||
def test_nonexistent_not_preset(self):
|
||||
assert is_preset_voice("custom_voice_123") is False
|
||||
|
||||
def test_empty_not_preset(self):
|
||||
assert is_preset_voice("") is False
|
||||
|
||||
|
||||
class TestPresetVoiceSamples:
|
||||
"""预置音色样本验证"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"voice_id,expected_name,gender",
|
||||
[
|
||||
("longxiaochun_v3", "龙小淳", "female"),
|
||||
("longxiaoxia_v3", "龙小夏", "female"),
|
||||
("longxiaochen_v3", "龙小晨", "male"),
|
||||
("longyue_v3", "龙悦", "female"),
|
||||
("longshu_v3", "龙书", "male"),
|
||||
("longjing_v3", "龙静", "female"),
|
||||
("longbo_v3", "龙博", "male"),
|
||||
("longtian_v3", "龙甜", "female"),
|
||||
],
|
||||
)
|
||||
def test_all_preset_voices_sample(self, voice_id, expected_name, gender):
|
||||
v = get_preset_voice_by_id(voice_id)
|
||||
assert v is not None
|
||||
assert v.name == expected_name
|
||||
assert v.gender == gender
|
||||
assert v.language == "zh-CN"
|
||||
assert len(v.tags or []) >= 2
|
||||
@@ -1,84 +0,0 @@
|
||||
"""
|
||||
Recipe 配方领域模型单元测试
|
||||
"""
|
||||
|
||||
from packages.domain.recipe import Recipe, RecipeItem
|
||||
|
||||
|
||||
class TestRecipeItem:
|
||||
"""RecipeItem 测试"""
|
||||
|
||||
def test_create_item(self):
|
||||
item = RecipeItem(
|
||||
id="item-1",
|
||||
recipe_id="recipe-1",
|
||||
item_type="asset",
|
||||
item_id="asset-123",
|
||||
position=0,
|
||||
)
|
||||
assert item.id == "item-1"
|
||||
assert item.recipe_id == "recipe-1"
|
||||
assert item.item_type == "asset"
|
||||
assert item.item_id == "asset-123"
|
||||
assert item.position == 0
|
||||
assert item.metadata_ == {}
|
||||
|
||||
def test_item_with_metadata(self):
|
||||
item = RecipeItem(
|
||||
id="item-1",
|
||||
recipe_id="r1",
|
||||
item_type="voice",
|
||||
item_id="voice-1",
|
||||
position=2,
|
||||
metadata_={"speed": 1.0, "pitch": 0},
|
||||
)
|
||||
assert item.metadata_["speed"] == 1.0
|
||||
assert item.metadata_["pitch"] == 0
|
||||
|
||||
|
||||
class TestRecipe:
|
||||
"""Recipe 测试"""
|
||||
|
||||
def test_create_minimal(self):
|
||||
r = Recipe(id="r1", user_id="u1", name="我的配方")
|
||||
assert r.id == "r1"
|
||||
assert r.user_id == "u1"
|
||||
assert r.name == "我的配方"
|
||||
|
||||
def test_default_values(self):
|
||||
r = Recipe(id="r1", user_id="u1", name="n")
|
||||
assert r.description == ""
|
||||
assert r.template_id == ""
|
||||
assert r.generation_params == {}
|
||||
assert r.items == []
|
||||
assert r.is_active is True
|
||||
assert r.metadata_ == {}
|
||||
|
||||
def test_with_items(self):
|
||||
items = [
|
||||
RecipeItem(id="i1", recipe_id="r1", item_type="asset", item_id="a1", position=0),
|
||||
RecipeItem(id="i2", recipe_id="r1", item_type="title", item_id="t1", position=1),
|
||||
]
|
||||
r = Recipe(id="r1", user_id="u1", name="n", items=items)
|
||||
assert len(r.items) == 2
|
||||
assert r.items[0].item_type == "asset"
|
||||
assert r.items[1].item_type == "title"
|
||||
|
||||
def test_with_generation_params(self):
|
||||
params = {"mode": "one_take", "duration": 30}
|
||||
r = Recipe(id="r1", user_id="u1", name="n", generation_params=params)
|
||||
assert r.generation_params["mode"] == "one_take"
|
||||
|
||||
def test_recipe_inactive(self):
|
||||
r = Recipe(id="r1", user_id="u1", name="n", is_active=False)
|
||||
assert r.is_active is False
|
||||
|
||||
def test_has_timestamps(self):
|
||||
r = Recipe(id="r1", user_id="u1", name="n")
|
||||
assert r.created_at is not None
|
||||
assert r.updated_at is not None
|
||||
|
||||
def test_all_item_types(self):
|
||||
for itype in ["asset", "title", "voice"]:
|
||||
item = RecipeItem(id="i1", recipe_id="r1", item_type=itype, item_id="x", position=0)
|
||||
assert item.item_type == itype
|
||||
+414
-183
@@ -1,59 +1,73 @@
|
||||
"""字幕领域模型单元测试."""
|
||||
"""
|
||||
Subtitle 字幕领域模型单元测试
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import pytest
|
||||
|
||||
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline, SubtitleWord
|
||||
from packages.domain.subtitle import (
|
||||
SubtitleSegment,
|
||||
SubtitleTimeline,
|
||||
SubtitleWord,
|
||||
)
|
||||
|
||||
|
||||
class TestSubtitleWord:
|
||||
"""SubtitleWord 测试."""
|
||||
"""SubtitleWord 测试"""
|
||||
|
||||
def test_basic_properties(self):
|
||||
word = SubtitleWord(text="你好", start=1.0, end=1.5)
|
||||
assert word.text == "你好"
|
||||
assert word.start == 1.0
|
||||
assert word.end == 1.5
|
||||
assert word.duration == 0.5
|
||||
def test_duration_positive(self):
|
||||
word = SubtitleWord(text="你好", start=1.0, end=2.5)
|
||||
assert word.duration == pytest.approx(1.5)
|
||||
|
||||
def test_duration_zero_when_end_before_start(self):
|
||||
word = SubtitleWord(text="test", start=2.0, end=1.0)
|
||||
def test_duration_zero(self):
|
||||
word = SubtitleWord(text="a", start=5.0, end=5.0)
|
||||
assert word.duration == 0.0
|
||||
|
||||
def test_duration_zero_when_same_time(self):
|
||||
word = SubtitleWord(text="test", start=1.0, end=1.0)
|
||||
def test_duration_negative_returns_zero(self):
|
||||
"""测试结束时间小于开始时间时返回 0"""
|
||||
word = SubtitleWord(text="a", start=3.0, end=1.0)
|
||||
assert word.duration == 0.0
|
||||
|
||||
|
||||
class TestSubtitleSegment:
|
||||
"""SubtitleSegment 测试."""
|
||||
"""SubtitleSegment 测试"""
|
||||
|
||||
def test_basic_properties(self):
|
||||
seg = SubtitleSegment(text="大家好", start=0.0, end=2.0)
|
||||
assert seg.text == "大家好"
|
||||
assert seg.start == 0.0
|
||||
assert seg.end == 2.0
|
||||
assert seg.duration == 2.0
|
||||
assert seg.char_count == 3
|
||||
def test_duration(self):
|
||||
seg = SubtitleSegment(text="你好世界", start=0.0, end=3.0)
|
||||
assert seg.duration == pytest.approx(3.0)
|
||||
|
||||
def test_duration_zero(self):
|
||||
seg = SubtitleSegment(text="test", start=5.0, end=5.0)
|
||||
assert seg.duration == 0.0
|
||||
|
||||
def test_duration_negative_returns_zero(self):
|
||||
seg = SubtitleSegment(text="test", start=5.0, end=2.0)
|
||||
assert seg.duration == 0.0
|
||||
|
||||
def test_char_count(self):
|
||||
seg = SubtitleSegment(text="你好世界", start=0, end=1)
|
||||
assert seg.char_count == 4
|
||||
|
||||
def test_char_count_empty(self):
|
||||
seg = SubtitleSegment(text="", start=0, end=1)
|
||||
assert seg.char_count == 0
|
||||
|
||||
def test_default_words_empty(self):
|
||||
seg = SubtitleSegment(text="test", start=0, end=1)
|
||||
assert seg.words == []
|
||||
|
||||
def test_duration_with_words(self):
|
||||
def test_with_words(self):
|
||||
words = [
|
||||
SubtitleWord(text="大", start=0.0, end=0.5),
|
||||
SubtitleWord(text="家", start=0.5, end=1.0),
|
||||
SubtitleWord(text="好", start=1.0, end=1.5),
|
||||
SubtitleWord(text="你好", start=0.0, end=1.0),
|
||||
SubtitleWord(text="世界", start=1.0, end=2.0),
|
||||
]
|
||||
seg = SubtitleSegment(text="大家好", start=0.0, end=1.5, words=words)
|
||||
assert seg.duration == 1.5
|
||||
assert seg.char_count == 3
|
||||
assert len(seg.words) == 3
|
||||
|
||||
def test_duration_zero_when_end_before_start(self):
|
||||
seg = SubtitleSegment(text="test", start=3.0, end=1.0)
|
||||
assert seg.duration == 0.0
|
||||
seg = SubtitleSegment(text="你好世界", start=0.0, end=2.0, words=words)
|
||||
assert len(seg.words) == 2
|
||||
assert seg.words[0].text == "你好"
|
||||
assert seg.words[1].text == "世界"
|
||||
|
||||
|
||||
class TestSubtitleTimelineBasics:
|
||||
"""SubtitleTimeline 基础属性测试."""
|
||||
"""SubtitleTimeline 基础属性测试"""
|
||||
|
||||
def test_empty_timeline(self):
|
||||
tl = SubtitleTimeline()
|
||||
@@ -62,211 +76,428 @@ class TestSubtitleTimelineBasics:
|
||||
assert tl.language == "zh"
|
||||
assert tl.total_duration == 0.0
|
||||
|
||||
def test_single_segment(self):
|
||||
seg = SubtitleSegment(text="测试", start=0.0, end=1.0)
|
||||
tl = SubtitleTimeline(segments=[seg])
|
||||
assert tl.segment_count == 1
|
||||
assert tl.total_chars == 2
|
||||
|
||||
def test_multiple_segments(self):
|
||||
segs = [
|
||||
SubtitleSegment(text="第一句", start=0.0, end=1.0),
|
||||
SubtitleSegment(text="第二句", start=1.0, end=2.0),
|
||||
SubtitleSegment(text="第三句", start=2.0, end=3.0),
|
||||
]
|
||||
tl = SubtitleTimeline(segments=segs, total_duration=3.0)
|
||||
def test_segment_count(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="a", start=0, end=1),
|
||||
SubtitleSegment(text="b", start=1, end=2),
|
||||
SubtitleSegment(text="c", start=2, end=3),
|
||||
]
|
||||
)
|
||||
assert tl.segment_count == 3
|
||||
|
||||
def test_total_chars(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="你好", start=0, end=1),
|
||||
SubtitleSegment(text="世界", start=1, end=2),
|
||||
SubtitleSegment(text="abcde", start=2, end=3),
|
||||
]
|
||||
)
|
||||
assert tl.total_chars == 9
|
||||
assert tl.total_duration == 3.0
|
||||
|
||||
def test_custom_language(self):
|
||||
tl = SubtitleTimeline(language="en")
|
||||
assert tl.language == "en"
|
||||
|
||||
def test_custom_total_duration(self):
|
||||
tl = SubtitleTimeline(total_duration=60.0)
|
||||
assert tl.total_duration == 60.0
|
||||
|
||||
class TestSubtitleTimelineMergeShort:
|
||||
"""合并短字幕片段测试."""
|
||||
|
||||
def test_empty_or_single_no_change(self):
|
||||
class TestMergeShortSegments:
|
||||
"""merge_short_segments 测试"""
|
||||
|
||||
def test_single_segment_no_merge(self):
|
||||
"""单个片段不需要合并"""
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="a", start=0, end=1),
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=8)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "a"
|
||||
|
||||
def test_empty_timeline(self):
|
||||
"""空时间轴"""
|
||||
tl = SubtitleTimeline()
|
||||
result = tl.merge_short_segments()
|
||||
result = tl.merge_short_segments(min_chars=8)
|
||||
assert result.segment_count == 0
|
||||
|
||||
seg = SubtitleSegment(text="短", start=0.0, end=0.5)
|
||||
tl2 = SubtitleTimeline(segments=[seg])
|
||||
result2 = tl2.merge_short_segments()
|
||||
assert result2.segment_count == 1
|
||||
|
||||
def test_merge_short_segments(self):
|
||||
segs = [
|
||||
SubtitleSegment(text="你好", start=0.0, end=0.5),
|
||||
SubtitleSegment(text="世界", start=0.5, end=1.0),
|
||||
SubtitleSegment(text="今天天气很好", start=1.0, end=2.0),
|
||||
]
|
||||
tl = SubtitleTimeline(segments=segs)
|
||||
result = tl.merge_short_segments(min_chars=4)
|
||||
# "你好"+"世界"=4字,合并;"今天天气很好"=6字,保留
|
||||
assert result.segment_count == 2
|
||||
def test_all_short_segments_merge_into_one(self):
|
||||
"""所有短片段合并成一个"""
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="你", start=0, end=0.5),
|
||||
SubtitleSegment(text="好", start=0.5, end=1.0),
|
||||
SubtitleSegment(text="世", start=1.0, end=1.5),
|
||||
SubtitleSegment(text="界", start=1.5, end=2.0),
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=8)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "你好世界"
|
||||
assert result.segments[0].start == 0.0
|
||||
assert result.segments[0].end == 1.0
|
||||
assert result.segments[1].text == "今天天气很好"
|
||||
assert result.segments[0].start == 0
|
||||
assert result.segments[0].end == 2.0
|
||||
|
||||
def test_merge_trailing_short_to_last(self):
|
||||
segs = [
|
||||
SubtitleSegment(text="一二三四五六七八", start=0.0, end=1.0),
|
||||
SubtitleSegment(text="短", start=1.0, end=1.2),
|
||||
SubtitleSegment(text="尾", start=1.2, end=1.4),
|
||||
]
|
||||
tl = SubtitleTimeline(segments=segs)
|
||||
result = tl.merge_short_segments(min_chars=4)
|
||||
# "一二三四五六七八"=8字 → 保留
|
||||
# "短"+"尾"=2字 < 4 → 合并到上一段
|
||||
def test_merge_short_segments_preserves_timing(self):
|
||||
"""合并后时间轴正确"""
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="你好", start=1.0, end=2.0),
|
||||
SubtitleSegment(text="世界", start=2.0, end=3.5),
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=10)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "一二三四五六七八短尾"
|
||||
assert result.segments[0].start == 1.0
|
||||
assert result.segments[0].end == 3.5
|
||||
|
||||
def test_merge_with_words(self):
|
||||
words1 = [SubtitleWord(text="你", start=0.0, end=0.25), SubtitleWord(text="好", start=0.25, end=0.5)]
|
||||
words2 = [SubtitleWord(text="世", start=0.5, end=0.75), SubtitleWord(text="界", start=0.75, end=1.0)]
|
||||
segs = [
|
||||
SubtitleSegment(text="你好", start=0.0, end=0.5, words=words1),
|
||||
SubtitleSegment(text="世界", start=0.5, end=1.0, words=words2),
|
||||
]
|
||||
tl = SubtitleTimeline(segments=segs)
|
||||
result = tl.merge_short_segments(min_chars=4)
|
||||
def test_merge_short_segments_with_words(self):
|
||||
"""合并后词级信息保留"""
|
||||
w1 = SubtitleWord(text="你好", start=0.0, end=1.0)
|
||||
w2 = SubtitleWord(text="世界", start=1.0, end=2.0)
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="你好", start=0.0, end=1.0, words=[w1]),
|
||||
SubtitleSegment(text="世界", start=1.0, end=2.0, words=[w2]),
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=10)
|
||||
assert len(result.segments[0].words) == 2
|
||||
assert result.segments[0].words[0].text == "你好"
|
||||
assert result.segments[0].words[1].text == "世界"
|
||||
|
||||
def test_multiple_merged_groups(self):
|
||||
"""多个合并组 — 短段会和后续段累积到够数才提交"""
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="一二三四五六七八", start=0, end=2), # 8字,够数,提交
|
||||
SubtitleSegment(text="九", start=2, end=2.5), # 1字,入buffer
|
||||
SubtitleSegment(text="十", start=2.5, end=3), # 1字,入buffer(共2字)
|
||||
SubtitleSegment(text="一二三四五六七八九十", start=3, end=5), # 10字,入buffer后共12字,够数提交
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=8)
|
||||
# 第1段:"一二三四五六七八"(8字直接提交)
|
||||
# 第2段:"九十" + "一二三四五六七八九十" 累积到12字一起提交
|
||||
assert result.segment_count == 2
|
||||
assert result.segments[0].text == "一二三四五六七八"
|
||||
assert result.segments[1].text == "九十一二三四五六七八九十"
|
||||
|
||||
def test_remaining_short_merged_with_last(self):
|
||||
"""剩余短片段合并到最后一段"""
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="一二三四五六七八", start=0, end=2), # 8字
|
||||
SubtitleSegment(text="一二三", start=2, end=3), # 3字,不够
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=8)
|
||||
# 最后的3字会合并到上一段(因为 < min_chars)
|
||||
assert result.segment_count == 1
|
||||
assert len(result.segments[0].words) == 4
|
||||
assert result.segments[0].text == "一二三四五六七八一二三"
|
||||
|
||||
def test_custom_min_chars(self):
|
||||
"""自定义最小字数 — 累积到够数就提交,剩余短的合并到最后"""
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="一二", start=0, end=1),
|
||||
SubtitleSegment(text="三四", start=1, end=2),
|
||||
SubtitleSegment(text="五六", start=2, end=3),
|
||||
]
|
||||
)
|
||||
# min_chars=3:
|
||||
# "一二"(2字) → 不够
|
||||
# +"三四"(共4字) → 够了,提交"一二三四",buffer清空
|
||||
# "五六"(2字) → 循环结束,剩余<min_chars且merged非空 → 合并到最后一段
|
||||
# 结果:1段 "一二三四五六"
|
||||
result = tl.merge_short_segments(min_chars=3)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "一二三四五六"
|
||||
|
||||
def test_preserves_language_and_duration(self):
|
||||
segs = [SubtitleSegment(text="短", start=0.0, end=0.5)]
|
||||
tl = SubtitleTimeline(segments=segs, language="ja", total_duration=0.5)
|
||||
result = tl.merge_short_segments(min_chars=4)
|
||||
assert result.language == "ja"
|
||||
assert result.total_duration == 0.5
|
||||
"""合并后保留语言和总时长"""
|
||||
tl = SubtitleTimeline(
|
||||
segments=[SubtitleSegment(text="a", start=0, end=1)],
|
||||
language="en",
|
||||
total_duration=60.0,
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=8)
|
||||
assert result.language == "en"
|
||||
assert result.total_duration == 60.0
|
||||
|
||||
def test_does_not_modify_original(self):
|
||||
"""不修改原时间轴"""
|
||||
segments = [
|
||||
SubtitleSegment(text="a", start=0, end=1),
|
||||
SubtitleSegment(text="b", start=1, end=2),
|
||||
]
|
||||
tl = SubtitleTimeline(segments=segments)
|
||||
result = tl.merge_short_segments(min_chars=5)
|
||||
# 原时间轴不变
|
||||
assert tl.segment_count == 2
|
||||
assert result is not tl
|
||||
|
||||
|
||||
class TestSubtitleTimelineSplitLong:
|
||||
"""拆分长字幕片段测试."""
|
||||
class TestSplitLongSegments:
|
||||
"""split_long_segments 测试"""
|
||||
|
||||
def test_short_segments_no_change(self):
|
||||
segs = [SubtitleSegment(text="短句", start=0.0, end=1.0)]
|
||||
tl = SubtitleTimeline(segments=segs)
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
def test_short_segments_no_split(self):
|
||||
"""短片段不需要拆分"""
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="你好", start=0, end=1),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=20)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "你好"
|
||||
|
||||
def test_split_by_sentence_punctuation(self):
|
||||
text = "今天天气很好。我们出去散步吧!"
|
||||
seg = SubtitleSegment(text=text, start=0.0, end=10.0)
|
||||
tl = SubtitleTimeline(segments=[seg])
|
||||
result = tl.split_long_segments(max_chars=8)
|
||||
assert result.segment_count >= 2
|
||||
assert result.segments[0].text.endswith("。")
|
||||
assert result.total_chars == len(text)
|
||||
|
||||
def test_split_long_text_no_punctuation_hard_cut(self):
|
||||
text = "一二三四五六七八九十十一十二十三十四十五十六十七十八"
|
||||
seg = SubtitleSegment(text=text, start=0.0, end=10.0)
|
||||
tl = SubtitleTimeline(segments=[seg])
|
||||
result = tl.split_long_segments(max_chars=8)
|
||||
def test_single_long_segment_split_by_punctuation(self):
|
||||
"""长片段按标点拆分"""
|
||||
text = "你好世界。今天天气真好,我们出去玩吧!"
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text=text, start=0, end=10.0),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
# 应该被拆成多段
|
||||
assert result.segment_count > 1
|
||||
# 所有片段都不超过 max_chars
|
||||
for s in result.segments:
|
||||
assert s.char_count <= 8
|
||||
# 每段都不超过 max_chars(除了硬切的情况)
|
||||
for seg in result.segments:
|
||||
assert seg.char_count <= len(text) # 至少比原文短
|
||||
|
||||
def test_split_preserves_total_text(self):
|
||||
"""拆分后总文本不变"""
|
||||
text = "你好世界。今天天气真好,我们出去玩吧!明天再见。"
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text=text, start=0, end=10.0),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=8)
|
||||
merged_text = "".join(s.text for s in result.segments)
|
||||
assert merged_text == text
|
||||
|
||||
def test_split_time_proportional(self):
|
||||
text = "一二三四。五六七八。"
|
||||
seg = SubtitleSegment(text=text, start=0.0, end=10.0)
|
||||
tl = SubtitleTimeline(segments=[seg])
|
||||
result = tl.split_long_segments(max_chars=4)
|
||||
assert result.segment_count >= 2
|
||||
# 总时长保持一致
|
||||
assert abs(result.segments[-1].end - 10.0) < 0.01
|
||||
"""拆分后时间按字数比例分配"""
|
||||
text = "一二三四五六七八九十。" # 11字
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text=text, start=0, end=10.0),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=5)
|
||||
# 总时长不变
|
||||
assert result.segments[0].start == 0.0
|
||||
assert result.segments[-1].end == pytest.approx(10.0)
|
||||
# 各段首尾相接
|
||||
for i in range(len(result.segments) - 1):
|
||||
assert result.segments[i].end == pytest.approx(result.segments[i + 1].start)
|
||||
|
||||
def test_split_with_words(self):
|
||||
"""拆分时词级信息正确分配"""
|
||||
words = [
|
||||
SubtitleWord(text="一", start=0.0, end=0.5),
|
||||
SubtitleWord(text="二", start=0.5, end=1.0),
|
||||
SubtitleWord(text="三", start=1.0, end=1.5),
|
||||
SubtitleWord(text="四", start=1.5, end=2.0),
|
||||
SubtitleWord(text="你好", start=0.0, end=1.0),
|
||||
SubtitleWord(text="世界", start=1.0, end=2.0),
|
||||
SubtitleWord(text="你好吗", start=2.0, end=3.5),
|
||||
]
|
||||
text = "一二三四五六七八"
|
||||
seg = SubtitleSegment(text=text, start=0.0, end=4.0, words=words)
|
||||
tl = SubtitleTimeline(segments=[seg])
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="你好世界。你好吗?", start=0.0, end=3.5, words=words),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=4)
|
||||
assert result.segment_count >= 2
|
||||
# 词的总数应该不变
|
||||
# 第一段应该有前几个词
|
||||
assert len(result.segments) >= 2
|
||||
total_words = sum(len(s.words) for s in result.segments)
|
||||
assert total_words == 4
|
||||
assert total_words == 3 # 词的总数不变
|
||||
|
||||
def test_split_preserves_language(self):
|
||||
seg = SubtitleSegment(text="test", start=0.0, end=1.0)
|
||||
tl = SubtitleTimeline(segments=[seg], language="en")
|
||||
result = tl.split_long_segments(max_chars=2)
|
||||
assert result.language == "en"
|
||||
def test_multiple_mixed_segments(self):
|
||||
"""混合长短片段"""
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="短", start=0, end=1), # 短
|
||||
SubtitleSegment(text="一二三四五六七八九十一二三四五六七八九十", start=1, end=5), # 长
|
||||
SubtitleSegment(text="也短", start=5, end=6), # 短
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
assert result.segment_count >= 3 # 至少3段(中间被拆成多段)
|
||||
# 第一段还是原来的短的
|
||||
assert result.segments[0].text == "短"
|
||||
# 最后一段还是原来的短的
|
||||
assert result.segments[-1].text == "也短"
|
||||
|
||||
def test_no_punctuation_hard_split(self):
|
||||
"""没有标点时硬切"""
|
||||
text = "一二三四五六七八九十一二三四五六七八九十一二三四五"
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text=text, start=0, end=10.0),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
assert result.segment_count >= 3
|
||||
for seg in result.segments:
|
||||
# 硬切的每段应该 <= max_chars
|
||||
assert seg.char_count <= 10
|
||||
|
||||
def test_preserves_language_and_duration(self):
|
||||
"""拆分后保留语言和总时长"""
|
||||
tl = SubtitleTimeline(
|
||||
segments=[SubtitleSegment(text="a", start=0, end=1)],
|
||||
language="ja",
|
||||
total_duration=30.0,
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=20)
|
||||
assert result.language == "ja"
|
||||
assert result.total_duration == 30.0
|
||||
|
||||
def test_does_not_modify_original(self):
|
||||
"""不修改原时间轴"""
|
||||
original_text = "一二三四五六七八九十一二三四五六七八九十"
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text=original_text, start=0, end=5),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=8)
|
||||
assert tl.segment_count == 1
|
||||
assert tl.segments[0].text == original_text
|
||||
assert result is not tl
|
||||
|
||||
|
||||
class TestSplitTextByPunctuation:
|
||||
"""标点拆分静态方法测试."""
|
||||
"""_split_text_by_punctuation 静态方法测试"""
|
||||
|
||||
def test_short_text_no_split(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation("你好世界", 10)
|
||||
assert result == ["你好世界"]
|
||||
|
||||
def test_split_at_sentence_end(self):
|
||||
"""在句末标点处断开"""
|
||||
result = SubtitleTimeline._split_text_by_punctuation("你好。世界。", 5)
|
||||
assert len(result) == 2
|
||||
assert result[0] == "你好。"
|
||||
assert result[1] == "世界。"
|
||||
|
||||
def test_split_at_comma(self):
|
||||
"""在逗号处断开(超过最大长度时)"""
|
||||
text = "一二三四五六七八,二二三四五六七八。"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_no_punctuation_hard_split(self):
|
||||
"""没有标点时硬切"""
|
||||
result = SubtitleTimeline._split_text_by_punctuation("一二三四五六七八九十", 5)
|
||||
assert len(result) == 2
|
||||
assert result[0] == "一二三四五"
|
||||
assert result[1] == "六七八九十"
|
||||
|
||||
def test_empty_text(self):
|
||||
# 空字符串循环不执行,current为空不append,返回空列表
|
||||
result = SubtitleTimeline._split_text_by_punctuation("", 10)
|
||||
assert result == []
|
||||
|
||||
def test_short_text_no_split(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation("短文本", 10)
|
||||
assert len(result) == 1
|
||||
def test_mixed_punctuation(self):
|
||||
"""混合标点"""
|
||||
text = "你好!吃饭了吗?是的,我吃过了。"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 6)
|
||||
# 验证所有段加起来等于原文
|
||||
assert "".join(result) == text
|
||||
|
||||
def test_split_by_period(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation("第一句。第二句。", 4)
|
||||
assert len(result) >= 2
|
||||
assert "。" in result[0]
|
||||
|
||||
def test_split_by_exclamation(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation("你好!世界!", 3)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_split_by_comma_when_long(self):
|
||||
text = "这是一个很长的句子,中间有逗号分隔,后面还有内容"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 8)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_no_punctuation_hard_cut(self):
|
||||
text = "一二三四五六七八九十十一十二十三十四十五"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 8)
|
||||
assert len(result) > 1
|
||||
for part in result:
|
||||
assert len(part) <= 8
|
||||
|
||||
def test_sentence_end_triggers_split_when_half_max(self):
|
||||
# 句末标点在 max_chars//2 以上就拆分
|
||||
text = "你好世界。abcdefghij"
|
||||
def test_sentence_end_with_min_length(self):
|
||||
"""句末标点断句的「半长门槛」只在未超max_chars时生效;
|
||||
超过max_chars回溯找标点时,即使首段很短也会断开。"""
|
||||
# "你好。" 3字 < max_chars//2(5),未超max_chars时不会主动断开
|
||||
# 但加上后面的"世界很大很美好"后超过10字,回溯找标点找到"。",强制断开
|
||||
text = "你好。世界很大很美好。"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
|
||||
# "你好世界。"=5字 < 10但>=5(half),应该拆分
|
||||
assert len(result) >= 2
|
||||
# 超过max_chars时回溯断开,首段可能很短
|
||||
assert len(result) == 2
|
||||
assert result[0] == "你好。"
|
||||
assert result[1] == "世界很大很美好。"
|
||||
# 总文本不变
|
||||
assert "".join(result) == text
|
||||
|
||||
def test_exclamation_and_question_marks(self):
|
||||
"""感叹号和问号也算句末标点"""
|
||||
text = "你好吗!我很好!你呢?"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 4)
|
||||
assert len(result) >= 3
|
||||
|
||||
|
||||
class TestMergeSegments:
|
||||
"""_merge_segments 静态方法测试."""
|
||||
"""_merge_segments 静态方法测试"""
|
||||
|
||||
def test_merge_empty(self):
|
||||
def test_merge_two_segments(self):
|
||||
result = SubtitleTimeline._merge_segments(
|
||||
[
|
||||
SubtitleSegment(text="你好", start=0.0, end=1.0),
|
||||
SubtitleSegment(text="世界", start=1.0, end=2.0),
|
||||
]
|
||||
)
|
||||
assert result.text == "你好世界"
|
||||
assert result.start == 0.0
|
||||
assert result.end == 2.0
|
||||
|
||||
def test_merge_empty_list(self):
|
||||
result = SubtitleTimeline._merge_segments([])
|
||||
assert result.text == ""
|
||||
assert result.start == 0
|
||||
assert result.end == 0
|
||||
|
||||
def test_merge_single(self):
|
||||
def test_merge_single_segment(self):
|
||||
seg = SubtitleSegment(text="test", start=1.0, end=2.0)
|
||||
result = SubtitleTimeline._merge_segments([seg])
|
||||
assert result.text == "test"
|
||||
assert result.start == 1.0
|
||||
assert result.end == 2.0
|
||||
|
||||
def test_merge_multiple(self):
|
||||
segs = [
|
||||
SubtitleSegment(text="第一", start=0.0, end=1.0),
|
||||
SubtitleSegment(text="第二", start=1.0, end=2.0),
|
||||
]
|
||||
result = SubtitleTimeline._merge_segments(segs)
|
||||
assert result.text == "第一第二"
|
||||
def test_merge_preserves_words(self):
|
||||
w1 = SubtitleWord(text="你好", start=0.0, end=1.0)
|
||||
w2 = SubtitleWord(text="世界", start=1.0, end=2.0)
|
||||
result = SubtitleTimeline._merge_segments(
|
||||
[
|
||||
SubtitleSegment(text="你好", start=0.0, end=1.0, words=[w1]),
|
||||
SubtitleSegment(text="世界", start=1.0, end=2.0, words=[w2]),
|
||||
]
|
||||
)
|
||||
assert len(result.words) == 2
|
||||
assert result.words[0].text == "你好"
|
||||
assert result.words[1].text == "世界"
|
||||
|
||||
def test_merge_non_contiguous_segments(self):
|
||||
"""合并非连续片段(有间隙)"""
|
||||
result = SubtitleTimeline._merge_segments(
|
||||
[
|
||||
SubtitleSegment(text="a", start=0.0, end=1.0),
|
||||
SubtitleSegment(text="b", start=3.0, end=4.0),
|
||||
]
|
||||
)
|
||||
assert result.start == 0.0
|
||||
assert result.end == 2.0
|
||||
assert result.end == 4.0
|
||||
assert result.text == "ab"
|
||||
|
||||
|
||||
class TestMergeAndSplitRoundtrip:
|
||||
"""合并和拆分的组合测试"""
|
||||
|
||||
def test_split_then_merge_approximate(self):
|
||||
"""拆分后再合并,总字数和总时长基本一致"""
|
||||
original_text = "你好世界。今天天气真好,我们出去玩吧!明天见。"
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text=original_text, start=0.0, end=10.0),
|
||||
]
|
||||
)
|
||||
split = tl.split_long_segments(max_chars=5)
|
||||
merged = split.merge_short_segments(min_chars=50) # 足够大的min_chars让它们都合并
|
||||
assert merged.segment_count == 1
|
||||
assert merged.segments[0].text == original_text
|
||||
assert merged.segments[0].start == 0.0
|
||||
assert merged.segments[0].end == pytest.approx(10.0)
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"""
|
||||
Tag 标签领域模型单元测试
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.tag import Tag
|
||||
|
||||
|
||||
class TestTagCreate:
|
||||
"""创建标签测试"""
|
||||
|
||||
def test_create_basic(self):
|
||||
tag = Tag.create(user_id="user-1", name="风景")
|
||||
assert tag.id is not None
|
||||
assert len(tag.id) == 32
|
||||
assert tag.user_id == "user-1"
|
||||
assert tag.name == "风景"
|
||||
|
||||
def test_create_strips_name(self):
|
||||
tag = Tag.create(user_id="user-1", name=" 风景 ")
|
||||
assert tag.name == "风景"
|
||||
|
||||
def test_create_empty_name_raises(self):
|
||||
with pytest.raises(ValueError, match="标签名称不能为空"):
|
||||
Tag.create(user_id="user-1", name="")
|
||||
|
||||
def test_create_whitespace_name_raises(self):
|
||||
with pytest.raises(ValueError, match="标签名称不能为空"):
|
||||
Tag.create(user_id="user-1", name=" ")
|
||||
|
||||
def test_create_has_created_at(self):
|
||||
tag = Tag.create(user_id="user-1", name="美食")
|
||||
assert tag.created_at is not None
|
||||
@@ -1,153 +0,0 @@
|
||||
"""
|
||||
Template 模板领域模型单元测试
|
||||
"""
|
||||
|
||||
from domain.template import Template, TemplateCategory, TemplateSegment
|
||||
|
||||
|
||||
class TestTemplateSegment:
|
||||
"""TemplateSegment 测试"""
|
||||
|
||||
def test_create_segment(self):
|
||||
seg = TemplateSegment(
|
||||
id="seg-1",
|
||||
template_id="tpl-1",
|
||||
segment_order=1,
|
||||
duration_min=3.0,
|
||||
duration_max=5.0,
|
||||
)
|
||||
assert seg.id == "seg-1"
|
||||
assert seg.template_id == "tpl-1"
|
||||
assert seg.segment_order == 1
|
||||
assert seg.duration_min == 3.0
|
||||
assert seg.duration_max == 5.0
|
||||
assert seg.material_type is None
|
||||
|
||||
def test_segment_with_material_type(self):
|
||||
seg = TemplateSegment(
|
||||
id="seg-1",
|
||||
template_id="tpl-1",
|
||||
segment_order=0,
|
||||
duration_min=2.0,
|
||||
duration_max=4.0,
|
||||
material_type="人物",
|
||||
)
|
||||
assert seg.material_type == "人物"
|
||||
|
||||
def test_segment_has_timestamps(self):
|
||||
seg = TemplateSegment(
|
||||
id="seg-1",
|
||||
template_id="tpl-1",
|
||||
segment_order=1,
|
||||
duration_min=1.0,
|
||||
duration_max=2.0,
|
||||
)
|
||||
assert seg.created_at is not None
|
||||
assert seg.updated_at is not None
|
||||
|
||||
|
||||
class TestTemplate:
|
||||
"""Template 测试"""
|
||||
|
||||
def test_create_template_minimal(self):
|
||||
t = Template(
|
||||
id="tpl-1",
|
||||
user_id="user-1",
|
||||
name="测试模板",
|
||||
mode="one_take",
|
||||
)
|
||||
assert t.id == "tpl-1"
|
||||
assert t.user_id == "user-1"
|
||||
assert t.name == "测试模板"
|
||||
assert t.mode == "one_take"
|
||||
|
||||
def test_default_values(self):
|
||||
t = Template(id="tpl-1", user_id="u1", name="n", mode="one_take")
|
||||
assert t.category == ""
|
||||
assert t.tags == []
|
||||
assert t.title_config == {}
|
||||
assert t.subtitle_config == {}
|
||||
assert t.bgm_config == {}
|
||||
assert t.estimated_duration == 0.0
|
||||
assert t.segments == []
|
||||
assert t.is_active is True
|
||||
|
||||
def test_with_segments(self):
|
||||
segs = [
|
||||
TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=2, duration_max=4),
|
||||
TemplateSegment(id="s2", template_id="t1", segment_order=1, duration_min=3, duration_max=5),
|
||||
]
|
||||
t = Template(
|
||||
id="tpl-1",
|
||||
user_id="u1",
|
||||
name="n",
|
||||
mode="voice_over",
|
||||
segments=segs,
|
||||
)
|
||||
assert len(t.segments) == 2
|
||||
assert t.segments[0].segment_order == 0
|
||||
assert t.segments[1].segment_order == 1
|
||||
|
||||
def test_all_modes(self):
|
||||
for mode in ["pip", "voice_pip", "one_take", "voice_over"]:
|
||||
t = Template(id="t1", user_id="u1", name="n", mode=mode)
|
||||
assert t.mode == mode
|
||||
|
||||
def test_with_configs(self):
|
||||
t = Template(
|
||||
id="t1",
|
||||
user_id="u1",
|
||||
name="n",
|
||||
mode="one_take",
|
||||
title_config={"font_size": 24, "color": "#ffffff"},
|
||||
subtitle_config={"style": "bottom"},
|
||||
bgm_config={"volume": 0.5},
|
||||
)
|
||||
assert t.title_config["font_size"] == 24
|
||||
assert t.subtitle_config["style"] == "bottom"
|
||||
assert t.bgm_config["volume"] == 0.5
|
||||
|
||||
def test_estimated_duration(self):
|
||||
t = Template(
|
||||
id="t1",
|
||||
user_id="u1",
|
||||
name="n",
|
||||
mode="one_take",
|
||||
estimated_duration=30.5,
|
||||
)
|
||||
assert t.estimated_duration == 30.5
|
||||
|
||||
def test_is_active_false(self):
|
||||
t = Template(id="t1", user_id="u1", name="n", mode="one_take", is_active=False)
|
||||
assert t.is_active is False
|
||||
|
||||
def test_has_timestamps(self):
|
||||
t = Template(id="t1", user_id="u1", name="n", mode="one_take")
|
||||
assert t.created_at is not None
|
||||
assert t.updated_at is not None
|
||||
|
||||
def test_tags_list(self):
|
||||
t = Template(
|
||||
id="t1",
|
||||
user_id="u1",
|
||||
name="n",
|
||||
mode="one_take",
|
||||
tags=["风景", "vlog"],
|
||||
)
|
||||
assert "风景" in t.tags
|
||||
assert "vlog" in t.tags
|
||||
assert len(t.tags) == 2
|
||||
|
||||
|
||||
class TestTemplateCategory:
|
||||
"""TemplateCategory 测试"""
|
||||
|
||||
def test_create_category(self):
|
||||
cat = TemplateCategory(id="cat-1", user_id="u1", name="风景")
|
||||
assert cat.id == "cat-1"
|
||||
assert cat.user_id == "u1"
|
||||
assert cat.name == "风景"
|
||||
|
||||
def test_category_has_timestamp(self):
|
||||
cat = TemplateCategory(id="cat-1", user_id="u1", name="风景")
|
||||
assert cat.created_at is not None
|
||||
@@ -1,127 +0,0 @@
|
||||
"""
|
||||
EditTemplateVersion 模板版本领域模型单元测试
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from domain.template_version import EditTemplateVersion
|
||||
|
||||
|
||||
class TestEditTemplateVersionCreate:
|
||||
"""创建模板版本测试"""
|
||||
|
||||
def test_create_required_fields(self):
|
||||
v = EditTemplateVersion.create(template_id="tpl-1", version=1)
|
||||
assert v.id is not None
|
||||
assert len(v.id) == 32
|
||||
assert v.template_id == "tpl-1"
|
||||
assert v.version == 1
|
||||
|
||||
def test_default_values(self):
|
||||
v = EditTemplateVersion.create(template_id="tpl-1", version=1)
|
||||
assert v.name == ""
|
||||
assert v.editing_mode == "one_take"
|
||||
assert v.config == {}
|
||||
assert v.clip_configs == []
|
||||
assert v.change_note == ""
|
||||
assert v.published_by == ""
|
||||
|
||||
def test_with_name_and_mode(self):
|
||||
v = EditTemplateVersion.create(
|
||||
template_id="tpl-1",
|
||||
version=2,
|
||||
name="风景Vlog模板",
|
||||
editing_mode="voice_over",
|
||||
)
|
||||
assert v.name == "风景Vlog模板"
|
||||
assert v.editing_mode == "voice_over"
|
||||
|
||||
def test_with_config(self):
|
||||
config = {
|
||||
"title": {"font_size": 24},
|
||||
"subtitle": {"style": "bottom"},
|
||||
"bgm": {"volume": 0.5},
|
||||
}
|
||||
v = EditTemplateVersion.create(
|
||||
template_id="tpl-1",
|
||||
version=1,
|
||||
config=config,
|
||||
)
|
||||
assert v.config == config
|
||||
assert v.config["title"]["font_size"] == 24
|
||||
|
||||
def test_with_clip_configs(self):
|
||||
clips = [
|
||||
{"clip_id": 1, "duration": 3.0, "transition": "fade"},
|
||||
{"clip_id": 2, "duration": 5.0, "transition": "slide"},
|
||||
]
|
||||
v = EditTemplateVersion.create(
|
||||
template_id="tpl-1",
|
||||
version=1,
|
||||
clip_configs=clips,
|
||||
)
|
||||
assert len(v.clip_configs) == 2
|
||||
assert v.clip_configs[0]["clip_id"] == 1
|
||||
|
||||
def test_config_none_defaults_to_empty_dict(self):
|
||||
v = EditTemplateVersion.create(template_id="tpl-1", version=1, config=None)
|
||||
assert v.config == {}
|
||||
|
||||
def test_clip_configs_none_defaults_to_empty_list(self):
|
||||
v = EditTemplateVersion.create(template_id="tpl-1", version=1, clip_configs=None)
|
||||
assert v.clip_configs == []
|
||||
|
||||
def test_with_change_note(self):
|
||||
v = EditTemplateVersion.create(
|
||||
template_id="tpl-1",
|
||||
version=3,
|
||||
change_note="优化转场效果,新增滤镜",
|
||||
)
|
||||
assert v.change_note == "优化转场效果,新增滤镜"
|
||||
|
||||
def test_with_published_by(self):
|
||||
v = EditTemplateVersion.create(
|
||||
template_id="tpl-1",
|
||||
version=1,
|
||||
published_by="user-123",
|
||||
)
|
||||
assert v.published_by == "user-123"
|
||||
|
||||
def test_full_version(self):
|
||||
config = {"bgm": {"volume": 0.3}}
|
||||
clips = [{"clip_id": 1, "duration": 2.5}]
|
||||
v = EditTemplateVersion.create(
|
||||
template_id="tpl-abc",
|
||||
version=5,
|
||||
name="正式版v5",
|
||||
editing_mode="one_take",
|
||||
config=config,
|
||||
clip_configs=clips,
|
||||
change_note="第五次发布",
|
||||
published_by="admin",
|
||||
)
|
||||
assert v.template_id == "tpl-abc"
|
||||
assert v.version == 5
|
||||
assert v.name == "正式版v5"
|
||||
assert v.editing_mode == "one_take"
|
||||
assert v.config == config
|
||||
assert v.clip_configs == clips
|
||||
assert v.change_note == "第五次发布"
|
||||
assert v.published_by == "admin"
|
||||
|
||||
def test_version_number(self):
|
||||
for ver in [1, 2, 5, 10, 99]:
|
||||
v = EditTemplateVersion.create(template_id="t1", version=ver)
|
||||
assert v.version == ver
|
||||
|
||||
def test_has_created_at(self):
|
||||
v = EditTemplateVersion.create(template_id="t1", version=1)
|
||||
assert v.created_at is not None
|
||||
|
||||
|
||||
class TestEditTemplateVersionSlots:
|
||||
"""slots 模式属性测试"""
|
||||
|
||||
def test_cannot_add_new_attribute(self):
|
||||
v = EditTemplateVersion.create(template_id="t1", version=1)
|
||||
with pytest.raises(AttributeError):
|
||||
v.nonexistent_field = "value" # type: ignore[attr-defined]
|
||||
@@ -1,78 +0,0 @@
|
||||
"""
|
||||
TitleLibraryItem 标题库领域模型单元测试
|
||||
"""
|
||||
|
||||
from packages.domain.title_library import TitleLibraryItem
|
||||
|
||||
|
||||
class TestTitleLibraryItem:
|
||||
"""TitleLibraryItem 测试"""
|
||||
|
||||
def test_create_minimal(self):
|
||||
item = TitleLibraryItem(id="t1", user_id="u1", name="标题1", text="这是标题文本")
|
||||
assert item.id == "t1"
|
||||
assert item.user_id == "u1"
|
||||
assert item.name == "标题1"
|
||||
assert item.text == "这是标题文本"
|
||||
|
||||
def test_default_values(self):
|
||||
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t")
|
||||
assert item.category == "default"
|
||||
assert item.description == ""
|
||||
assert item.tags == []
|
||||
assert item.usage_count == 0
|
||||
assert item.is_active is True
|
||||
assert item.metadata_ == {}
|
||||
|
||||
def test_with_category(self):
|
||||
item = TitleLibraryItem(
|
||||
id="t1",
|
||||
user_id="u1",
|
||||
name="n",
|
||||
text="t",
|
||||
category="美食",
|
||||
)
|
||||
assert item.category == "美食"
|
||||
|
||||
def test_with_tags(self):
|
||||
item = TitleLibraryItem(
|
||||
id="t1",
|
||||
user_id="u1",
|
||||
name="n",
|
||||
text="t",
|
||||
tags=["爆款", "美食"],
|
||||
)
|
||||
assert len(item.tags) == 2
|
||||
assert "爆款" in item.tags
|
||||
|
||||
def test_usage_count(self):
|
||||
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t")
|
||||
assert item.usage_count == 0
|
||||
item.usage_count = 10
|
||||
assert item.usage_count == 10
|
||||
|
||||
def test_inactive(self):
|
||||
item = TitleLibraryItem(
|
||||
id="t1",
|
||||
user_id="u1",
|
||||
name="n",
|
||||
text="t",
|
||||
is_active=False,
|
||||
)
|
||||
assert item.is_active is False
|
||||
|
||||
def test_with_metadata(self):
|
||||
meta = {"source": "import", "quality": "high"}
|
||||
item = TitleLibraryItem(
|
||||
id="t1",
|
||||
user_id="u1",
|
||||
name="n",
|
||||
text="t",
|
||||
metadata_=meta,
|
||||
)
|
||||
assert item.metadata_["source"] == "import"
|
||||
|
||||
def test_has_timestamps(self):
|
||||
item = TitleLibraryItem(id="t1", user_id="u1", name="n", text="t")
|
||||
assert item.created_at is not None
|
||||
assert item.updated_at is not None
|
||||
@@ -1,12 +1,14 @@
|
||||
"""TTS 配音配置领域模型单元测试."""
|
||||
"""
|
||||
TTS 配音配置模型单元测试
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import pytest
|
||||
|
||||
from packages.domain.tts_config import TtsConfig
|
||||
|
||||
|
||||
class TestTtsConfigDefaults:
|
||||
"""默认值测试."""
|
||||
"""默认值测试"""
|
||||
|
||||
def test_default_values(self):
|
||||
config = TtsConfig()
|
||||
@@ -21,184 +23,180 @@ class TestTtsConfigDefaults:
|
||||
|
||||
|
||||
class TestTtsConfigParse:
|
||||
"""parse 方法测试."""
|
||||
"""parse 方法测试"""
|
||||
|
||||
def test_parse_none_returns_default(self):
|
||||
def test_parse_none(self):
|
||||
config = TtsConfig.parse(None)
|
||||
assert config.enabled is False
|
||||
assert config.speed == 1.0
|
||||
|
||||
def test_parse_empty_dict_returns_default(self):
|
||||
def test_parse_empty_dict(self):
|
||||
config = TtsConfig.parse({})
|
||||
assert config.enabled is False
|
||||
|
||||
def test_parse_not_dict_returns_default(self):
|
||||
config = TtsConfig.parse("invalid")
|
||||
def test_parse_not_dict(self):
|
||||
config = TtsConfig.parse("not a dict")
|
||||
assert config.enabled is False
|
||||
config2 = TtsConfig.parse(123)
|
||||
assert config2.enabled is False
|
||||
config3 = TtsConfig.parse([])
|
||||
assert config3.enabled is False
|
||||
|
||||
def test_parse_enabled_false_ignores_other_fields(self):
|
||||
data = {
|
||||
"enabled": False,
|
||||
"voice_id": "test_voice",
|
||||
"speed": 2.0,
|
||||
"text": "hello",
|
||||
}
|
||||
config = TtsConfig.parse(data)
|
||||
def test_parse_disabled_returns_minimal(self):
|
||||
"""disabled 时直接返回 enabled=False,忽略其他字段"""
|
||||
config = TtsConfig.parse(
|
||||
{
|
||||
"enabled": False,
|
||||
"voice_id": "v123",
|
||||
"speed": 1.5,
|
||||
}
|
||||
)
|
||||
assert config.enabled is False
|
||||
assert config.voice_id == ""
|
||||
assert config.speed == 1.0
|
||||
assert config.voice_id == "" # 不保留
|
||||
|
||||
def test_parse_basic_enabled(self):
|
||||
data = {"enabled": True, "voice_id": "voice_001"}
|
||||
config = TtsConfig.parse(data)
|
||||
def test_parse_enabled_true(self):
|
||||
config = TtsConfig.parse(
|
||||
{
|
||||
"enabled": True,
|
||||
"voice_id": "voice_001",
|
||||
"speed": 1.2,
|
||||
"pitch": 2.5,
|
||||
"volume": 0.5,
|
||||
"text": "你好世界",
|
||||
"align_mode": "subtitle",
|
||||
"overlap_mode": "mix",
|
||||
}
|
||||
)
|
||||
assert config.enabled is True
|
||||
assert config.voice_id == "voice_001"
|
||||
assert config.speed == 1.0
|
||||
assert config.pitch == 0.0
|
||||
assert config.volume == 0.8
|
||||
|
||||
def test_parse_full_config(self):
|
||||
data = {
|
||||
"enabled": True,
|
||||
"voice_id": "voice_001",
|
||||
"speed": 1.5,
|
||||
"pitch": 2.0,
|
||||
"volume": 0.9,
|
||||
"text": "测试配音文本",
|
||||
"align_mode": "subtitle",
|
||||
"overlap_mode": "mix",
|
||||
}
|
||||
config = TtsConfig.parse(data)
|
||||
assert config.enabled is True
|
||||
assert config.voice_id == "voice_001"
|
||||
assert config.speed == 1.5
|
||||
assert config.pitch == 2.0
|
||||
assert config.volume == 0.9
|
||||
assert config.text == "测试配音文本"
|
||||
assert config.speed == 1.2
|
||||
assert config.pitch == 2.5
|
||||
assert config.volume == 0.5
|
||||
assert config.text == "你好世界"
|
||||
assert config.align_mode == "subtitle"
|
||||
assert config.overlap_mode == "mix"
|
||||
|
||||
def test_parse_enabled_non_bool_fallback(self):
|
||||
data = {"enabled": "true", "voice_id": "v1"}
|
||||
config = TtsConfig.parse(data)
|
||||
def test_parse_enabled_not_bool_false(self):
|
||||
"""enabled 不是 bool 时视为 False"""
|
||||
config = TtsConfig.parse({"enabled": "true"})
|
||||
assert config.enabled is False
|
||||
|
||||
def test_parse_voice_id_non_string_fallback(self):
|
||||
data = {"enabled": True, "voice_id": 123}
|
||||
config = TtsConfig.parse(data)
|
||||
def test_parse_enabled_not_bool_zero(self):
|
||||
config = TtsConfig.parse({"enabled": 0})
|
||||
assert config.enabled is False
|
||||
|
||||
def test_parse_voice_id_not_string(self):
|
||||
config = TtsConfig.parse({"enabled": True, "voice_id": 123})
|
||||
assert config.voice_id == ""
|
||||
|
||||
def test_parse_speed_non_numeric_fallback(self):
|
||||
data = {"enabled": True, "speed": "fast"}
|
||||
config = TtsConfig.parse(data)
|
||||
def test_parse_speed_not_number(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": "fast"})
|
||||
assert config.speed == 1.0
|
||||
|
||||
def test_parse_pitch_non_numeric_fallback(self):
|
||||
data = {"enabled": True, "pitch": "high"}
|
||||
config = TtsConfig.parse(data)
|
||||
def test_parse_pitch_not_number(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": "high"})
|
||||
assert config.pitch == 0.0
|
||||
|
||||
def test_parse_volume_non_numeric_fallback(self):
|
||||
data = {"enabled": True, "volume": "loud"}
|
||||
config = TtsConfig.parse(data)
|
||||
def test_parse_volume_not_number(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": "loud"})
|
||||
assert config.volume == 0.8
|
||||
|
||||
def test_parse_text_non_string_fallback(self):
|
||||
data = {"enabled": True, "text": 12345}
|
||||
config = TtsConfig.parse(data)
|
||||
def test_parse_text_not_string(self):
|
||||
config = TtsConfig.parse({"enabled": True, "text": 12345})
|
||||
assert config.text == ""
|
||||
|
||||
def test_parse_align_mode_invalid_fallback(self):
|
||||
data = {"enabled": True, "align_mode": "invalid"}
|
||||
config = TtsConfig.parse(data)
|
||||
def test_parse_align_mode_invalid(self):
|
||||
config = TtsConfig.parse({"enabled": True, "align_mode": "invalid"})
|
||||
assert config.align_mode == "full"
|
||||
|
||||
def test_parse_overlap_mode_invalid_fallback(self):
|
||||
data = {"enabled": True, "overlap_mode": "invalid"}
|
||||
config = TtsConfig.parse(data)
|
||||
def test_parse_align_mode_subtitle(self):
|
||||
config = TtsConfig.parse({"enabled": True, "align_mode": "subtitle"})
|
||||
assert config.align_mode == "subtitle"
|
||||
|
||||
def test_parse_align_mode_full(self):
|
||||
config = TtsConfig.parse({"enabled": True, "align_mode": "full"})
|
||||
assert config.align_mode == "full"
|
||||
|
||||
def test_parse_overlap_mode_invalid(self):
|
||||
config = TtsConfig.parse({"enabled": True, "overlap_mode": "invalid"})
|
||||
assert config.overlap_mode == "replace"
|
||||
|
||||
def test_parse_overlap_mode_replace(self):
|
||||
config = TtsConfig.parse({"enabled": True, "overlap_mode": "replace"})
|
||||
assert config.overlap_mode == "replace"
|
||||
|
||||
def test_parse_overlap_mode_mix(self):
|
||||
config = TtsConfig.parse({"enabled": True, "overlap_mode": "mix"})
|
||||
assert config.overlap_mode == "mix"
|
||||
|
||||
def test_parse_integer_speed(self):
|
||||
"""int 类型的 speed 应该被转成 float"""
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 2})
|
||||
assert config.speed == 2.0
|
||||
assert isinstance(config.speed, float)
|
||||
|
||||
def test_parse_integer_pitch(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": -5})
|
||||
assert config.pitch == -5.0
|
||||
assert isinstance(config.pitch, float)
|
||||
|
||||
def test_parse_integer_volume(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": 1})
|
||||
assert config.volume == 1.0
|
||||
assert isinstance(config.volume, float)
|
||||
|
||||
|
||||
class TestTtsConfigClamp:
|
||||
"""边界钳制测试."""
|
||||
"""边界钳制测试"""
|
||||
|
||||
def test_speed_below_min_clamped(self):
|
||||
data = {"enabled": True, "speed": 0.1}
|
||||
config = TtsConfig.parse(data)
|
||||
def test_speed_too_low(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 0.1})
|
||||
assert config.speed == 0.5
|
||||
|
||||
def test_speed_above_max_clamped(self):
|
||||
data = {"enabled": True, "speed": 3.0}
|
||||
config = TtsConfig.parse(data)
|
||||
def test_speed_too_high(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 3.0})
|
||||
assert config.speed == 2.0
|
||||
|
||||
def test_speed_at_min_ok(self):
|
||||
data = {"enabled": True, "speed": 0.5}
|
||||
config = TtsConfig.parse(data)
|
||||
def test_speed_lower_boundary(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 0.5})
|
||||
assert config.speed == 0.5
|
||||
|
||||
def test_speed_at_max_ok(self):
|
||||
data = {"enabled": True, "speed": 2.0}
|
||||
config = TtsConfig.parse(data)
|
||||
def test_speed_upper_boundary(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 2.0})
|
||||
assert config.speed == 2.0
|
||||
|
||||
def test_pitch_below_min_clamped(self):
|
||||
data = {"enabled": True, "pitch": -20}
|
||||
config = TtsConfig.parse(data)
|
||||
def test_pitch_too_low(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": -20})
|
||||
assert config.pitch == -12
|
||||
|
||||
def test_pitch_above_max_clamped(self):
|
||||
data = {"enabled": True, "pitch": 20}
|
||||
config = TtsConfig.parse(data)
|
||||
def test_pitch_too_high(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": 20})
|
||||
assert config.pitch == 12
|
||||
|
||||
def test_pitch_at_min_ok(self):
|
||||
data = {"enabled": True, "pitch": -12}
|
||||
config = TtsConfig.parse(data)
|
||||
def test_pitch_lower_boundary(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": -12})
|
||||
assert config.pitch == -12
|
||||
|
||||
def test_pitch_at_max_ok(self):
|
||||
data = {"enabled": True, "pitch": 12}
|
||||
config = TtsConfig.parse(data)
|
||||
def test_pitch_upper_boundary(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": 12})
|
||||
assert config.pitch == 12
|
||||
|
||||
def test_volume_below_min_clamped(self):
|
||||
data = {"enabled": True, "volume": -0.5}
|
||||
config = TtsConfig.parse(data)
|
||||
def test_volume_negative(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": -0.5})
|
||||
assert config.volume == 0.0
|
||||
|
||||
def test_volume_above_max_clamped(self):
|
||||
data = {"enabled": True, "volume": 2.0}
|
||||
config = TtsConfig.parse(data)
|
||||
def test_volume_over_one(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": 1.5})
|
||||
assert config.volume == 1.0
|
||||
|
||||
def test_volume_at_min_ok(self):
|
||||
data = {"enabled": True, "volume": 0.0}
|
||||
config = TtsConfig.parse(data)
|
||||
def test_volume_zero(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": 0.0})
|
||||
assert config.volume == 0.0
|
||||
|
||||
def test_volume_at_max_ok(self):
|
||||
data = {"enabled": True, "volume": 1.0}
|
||||
config = TtsConfig.parse(data)
|
||||
def test_volume_one(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": 1.0})
|
||||
assert config.volume == 1.0
|
||||
|
||||
def test_int_speed_converted_to_float(self):
|
||||
data = {"enabled": True, "speed": 1}
|
||||
config = TtsConfig.parse(data)
|
||||
assert isinstance(config.speed, float)
|
||||
assert config.speed == 1.0
|
||||
|
||||
def test_int_pitch_converted_to_float(self):
|
||||
data = {"enabled": True, "pitch": 5}
|
||||
config = TtsConfig.parse(data)
|
||||
assert isinstance(config.pitch, float)
|
||||
assert config.pitch == 5.0
|
||||
|
||||
def test_int_volume_converted_to_float(self):
|
||||
data = {"enabled": True, "volume": 1}
|
||||
config = TtsConfig.parse(data)
|
||||
assert isinstance(config.volume, float)
|
||||
assert config.volume == 1.0
|
||||
def test_clamp_via_direct_construction(self):
|
||||
"""直接构造也应该钳制(通过 _clamp 方法)"""
|
||||
config = TtsConfig(enabled=True, speed=5.0, pitch=100, volume=-1)
|
||||
config._clamp()
|
||||
assert config.speed == 2.0
|
||||
assert config.pitch == 12
|
||||
assert config.volume == 0.0
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
"""
|
||||
VerificationCode 验证码领域模型单元测试
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from domain.verification_code import VerificationCode
|
||||
|
||||
|
||||
class TestVerificationCodeCreate:
|
||||
"""创建验证码测试"""
|
||||
|
||||
def test_create_default_6digit_code(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind")
|
||||
assert vc.id is not None
|
||||
assert len(vc.id) == 32 # uuid4 hex
|
||||
assert vc.recipient == "test@example.com"
|
||||
assert vc.code_type == "email_bind"
|
||||
assert len(vc.code) == 6
|
||||
assert vc.code.isdigit()
|
||||
assert vc.used_at is None
|
||||
assert vc.attempts == 0
|
||||
|
||||
def test_create_custom_code(self):
|
||||
vc = VerificationCode.create("13800138000", "phone_bind", custom_code="123456")
|
||||
assert vc.code == "123456"
|
||||
|
||||
def test_create_default_ttl_300s(self):
|
||||
before = datetime.now(timezone.utc)
|
||||
vc = VerificationCode.create("test@example.com", "email_login")
|
||||
after = datetime.now(timezone.utc)
|
||||
expected_expiry_min = before + timedelta(seconds=300)
|
||||
expected_expiry_max = after + timedelta(seconds=300)
|
||||
assert expected_expiry_min <= vc.expires_at <= expected_expiry_max
|
||||
|
||||
def test_create_custom_ttl(self):
|
||||
vc = VerificationCode.create("test@example.com", "reset_password", ttl_seconds=60)
|
||||
expected = datetime.now(timezone.utc) + timedelta(seconds=60)
|
||||
diff = abs((vc.expires_at - expected).total_seconds())
|
||||
assert diff < 2
|
||||
|
||||
def test_create_recipient_stripped(self):
|
||||
vc = VerificationCode.create(" test@example.com ", "email_bind")
|
||||
assert vc.recipient == "test@example.com"
|
||||
|
||||
def test_create_phone_recipient(self):
|
||||
vc = VerificationCode.create("13800138000", "phone_login")
|
||||
assert vc.recipient == "13800138000"
|
||||
assert vc.code_type == "phone_login"
|
||||
|
||||
def test_create_sets_created_at(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind")
|
||||
assert vc.created_at is not None
|
||||
assert isinstance(vc.created_at, datetime)
|
||||
|
||||
|
||||
class TestVerificationCodeExpiry:
|
||||
"""过期状态测试"""
|
||||
|
||||
def test_fresh_code_not_expired(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind")
|
||||
assert vc.is_expired is False
|
||||
|
||||
def test_expired_code_is_expired(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind", ttl_seconds=-60)
|
||||
assert vc.is_expired is True
|
||||
|
||||
def test_boundary_not_expired_at_expiry_time(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
vc = VerificationCode.create("test@example.com", "email_bind")
|
||||
vc.expires_at = now + timedelta(seconds=1)
|
||||
assert vc.is_expired is False
|
||||
|
||||
def test_boundary_expired_right_after(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind")
|
||||
vc.expires_at = datetime.now(timezone.utc) - timedelta(microseconds=1)
|
||||
assert vc.is_expired is True
|
||||
|
||||
|
||||
class TestVerificationCodeUsed:
|
||||
"""使用状态测试"""
|
||||
|
||||
def test_fresh_code_not_used(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind")
|
||||
assert vc.is_used is False
|
||||
|
||||
def test_mark_used(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind")
|
||||
vc.mark_used()
|
||||
assert vc.is_used is True
|
||||
assert vc.used_at is not None
|
||||
assert isinstance(vc.used_at, datetime)
|
||||
|
||||
def test_mark_used_sets_recent_time(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind")
|
||||
before = datetime.now(timezone.utc)
|
||||
vc.mark_used()
|
||||
after = datetime.now(timezone.utc)
|
||||
assert before <= vc.used_at <= after
|
||||
|
||||
def test_mark_used_idempotent(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind")
|
||||
vc.mark_used()
|
||||
first_used_at = vc.used_at
|
||||
vc.mark_used()
|
||||
# 第二次会更新时间
|
||||
assert vc.used_at >= first_used_at
|
||||
|
||||
|
||||
class TestVerificationCodeValidity:
|
||||
"""有效性(未过期+未使用)测试"""
|
||||
|
||||
def test_fresh_code_is_valid(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind")
|
||||
assert vc.is_valid is True
|
||||
|
||||
def test_expired_code_not_valid(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind", ttl_seconds=-10)
|
||||
assert vc.is_valid is False
|
||||
|
||||
def test_used_code_not_valid(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind")
|
||||
vc.mark_used()
|
||||
assert vc.is_valid is False
|
||||
|
||||
def test_expired_and_used_not_valid(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind", ttl_seconds=-10)
|
||||
vc.mark_used()
|
||||
assert vc.is_valid is False
|
||||
|
||||
|
||||
class TestVerificationCodeAttempts:
|
||||
"""尝试次数测试"""
|
||||
|
||||
def test_initial_attempts_zero(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind")
|
||||
assert vc.attempts == 0
|
||||
|
||||
def test_increment_attempts(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind")
|
||||
vc.increment_attempts()
|
||||
assert vc.attempts == 1
|
||||
|
||||
def test_increment_attempts_multiple(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_bind")
|
||||
for _ in range(5):
|
||||
vc.increment_attempts()
|
||||
assert vc.attempts == 5
|
||||
|
||||
|
||||
class TestVerificationCodeTypes:
|
||||
"""不同验证码类型测试"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code_type",
|
||||
[
|
||||
"email_bind",
|
||||
"phone_bind",
|
||||
"email_login",
|
||||
"phone_login",
|
||||
"reset_password",
|
||||
],
|
||||
)
|
||||
def test_all_supported_types(self, code_type):
|
||||
vc = VerificationCode.create("test@example.com", code_type)
|
||||
assert vc.code_type == code_type
|
||||
assert vc.is_valid is True
|
||||
@@ -1,95 +0,0 @@
|
||||
"""
|
||||
VoiceLibraryItem 配音库领域模型单元测试
|
||||
"""
|
||||
|
||||
from packages.domain.voice_library import VoiceLibraryItem
|
||||
|
||||
|
||||
class TestVoiceLibraryItem:
|
||||
"""VoiceLibraryItem 测试"""
|
||||
|
||||
def test_create_minimal(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="我的配音")
|
||||
assert item.id == "v1"
|
||||
assert item.user_id == "u1"
|
||||
assert item.name == "我的配音"
|
||||
|
||||
def test_default_values(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n")
|
||||
assert item.text == ""
|
||||
assert item.voice_provider == ""
|
||||
assert item.voice_id == ""
|
||||
assert item.voice_name == ""
|
||||
assert item.audio_url == ""
|
||||
assert item.duration == 0
|
||||
assert item.file_size == 0
|
||||
assert item.status == "completed"
|
||||
assert item.project_id is None
|
||||
assert item.tags == []
|
||||
assert item.metadata_ == {}
|
||||
|
||||
def test_with_voice_info(self):
|
||||
item = VoiceLibraryItem(
|
||||
id="v1",
|
||||
user_id="u1",
|
||||
name="温柔女声",
|
||||
text="大家好",
|
||||
voice_provider="cosyvoice",
|
||||
voice_id="longxiaochun_v3",
|
||||
voice_name="龙小淳",
|
||||
)
|
||||
assert item.voice_provider == "cosyvoice"
|
||||
assert item.voice_id == "longxiaochun_v3"
|
||||
assert item.voice_name == "龙小淳"
|
||||
|
||||
def test_with_audio_info(self):
|
||||
item = VoiceLibraryItem(
|
||||
id="v1",
|
||||
user_id="u1",
|
||||
name="n",
|
||||
audio_url="https://example.com/audio.wav",
|
||||
duration=15.5,
|
||||
file_size=102400,
|
||||
)
|
||||
assert item.audio_url == "https://example.com/audio.wav"
|
||||
assert item.duration == 15.5
|
||||
assert item.file_size == 102400
|
||||
|
||||
def test_with_project_id(self):
|
||||
item = VoiceLibraryItem(
|
||||
id="v1",
|
||||
user_id="u1",
|
||||
name="n",
|
||||
project_id="proj-123",
|
||||
)
|
||||
assert item.project_id == "proj-123"
|
||||
|
||||
def test_status_values(self):
|
||||
for status in ["pending", "processing", "completed", "failed"]:
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", status=status)
|
||||
assert item.status == status
|
||||
|
||||
def test_with_tags(self):
|
||||
item = VoiceLibraryItem(
|
||||
id="v1",
|
||||
user_id="u1",
|
||||
name="n",
|
||||
tags=["温柔", "女声", "解说"],
|
||||
)
|
||||
assert len(item.tags) == 3
|
||||
assert "温柔" in item.tags
|
||||
|
||||
def test_with_metadata(self):
|
||||
item = VoiceLibraryItem(
|
||||
id="v1",
|
||||
user_id="u1",
|
||||
name="n",
|
||||
metadata_={"speed": 1.0, "pitch": 0.5},
|
||||
)
|
||||
assert item.metadata_["speed"] == 1.0
|
||||
assert item.metadata_["pitch"] == 0.5
|
||||
|
||||
def test_has_timestamps(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n")
|
||||
assert item.created_at is not None
|
||||
assert item.updated_at is not None
|
||||
@@ -271,49 +271,8 @@ class TestWechatOAuthService:
|
||||
from packages.application.auth.wechat_oauth_service import WechatOAuthService
|
||||
|
||||
service = WechatOAuthService(app_id="", app_secret="", redirect_uri="")
|
||||
# 先生成授权 URL 获得有效 state(state 会被存入 store)
|
||||
_, valid_state = service.generate_auth_url()
|
||||
user_info, err = service.handle_callback("test_code", valid_state)
|
||||
user_info, err = service.handle_callback("test_code", "test_state")
|
||||
assert err is None
|
||||
assert user_info is not None
|
||||
assert "mock" in user_info.openid
|
||||
assert user_info.nickname == "微信测试用户"
|
||||
|
||||
def test_callback_invalid_state_rejected(self):
|
||||
"""无效 state 应被拒绝(CSRF 防护)"""
|
||||
from packages.application.auth.wechat_oauth_service import WechatOAuthService
|
||||
|
||||
service = WechatOAuthService(app_id="", app_secret="", redirect_uri="")
|
||||
# 直接用随机 state 调用,未经过 generate_auth_url
|
||||
user_info, err = service.handle_callback("test_code", "random_fake_state")
|
||||
assert err is not None
|
||||
assert "state" in err
|
||||
assert user_info is None
|
||||
|
||||
def test_callback_state_single_use(self):
|
||||
"""state 只能使用一次(防重放)"""
|
||||
from packages.application.auth.wechat_oauth_service import WechatOAuthService
|
||||
|
||||
service = WechatOAuthService(app_id="", app_secret="", redirect_uri="")
|
||||
_, valid_state = service.generate_auth_url()
|
||||
|
||||
# 第一次使用:成功
|
||||
user_info, err = service.handle_callback("test_code", valid_state)
|
||||
assert err is None
|
||||
assert user_info is not None
|
||||
|
||||
# 第二次使用相同 state:失败(已被消费)
|
||||
user_info2, err2 = service.handle_callback("test_code", valid_state)
|
||||
assert err2 is not None
|
||||
assert "state" in err2
|
||||
assert user_info2 is None
|
||||
|
||||
def test_callback_empty_state_rejected(self):
|
||||
"""空 state 应被拒绝"""
|
||||
from packages.application.auth.wechat_oauth_service import WechatOAuthService
|
||||
|
||||
service = WechatOAuthService(app_id="", app_secret="", redirect_uri="")
|
||||
user_info, err = service.handle_callback("test_code", "")
|
||||
assert err is not None
|
||||
assert "state" in err
|
||||
assert user_info is None
|
||||
|
||||
Reference in New Issue
Block a user