Compare commits

..

10 Commits

Author SHA1 Message Date
xiaoxia 1af1d24d3c chore: trigger ci for #665
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 14m47s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 14m47s
PR Automation / Auto Approve on CI Green (pull_request) Failing after 14m48s
AI Code Review / AI Code Review (pull_request) Failing after 14m49s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 12s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Failing after 33s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m23s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 1m56s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m2s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 19s
2026-07-21 14:04:29 +08:00
CI Bot 10897b3ee1 style: auto-format with black + isort + prettier [ci skip] 2026-07-21 12:53:54 +08:00
xiaoxia c5a949d502 fix(test): 修复B017宽泛异常捕获 + StrEnum跨版本兼容问题
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 13s
AI Code Review / AI Code Review (pull_request) Successful in 3m13s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m45s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m41s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 4m17s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 5m15s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 7m3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m18s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m55s
1. 将3个frozen测试的 pytest.raises(Exception) 改为 FrozenInstanceError
   (B017 assert-raises-exception 规则)
2. VoiceGender 测试 str() 断言改为 .value 比较
   (不同Python版本StrEnum的str()行为可能不同)
2026-07-21 12:06:32 +08:00
xiaoxia 98e74646f9 chore: trigger ci 2026-07-21 12:06:28 +08:00
CI Bot de53975784 style: auto-format with black + isort + prettier [ci skip] 2026-07-21 12:06:25 +08:00
CI Bot 0a447b4b68 test(unit): 新增 classification/generated_video 两个模块测试
- classification: 12 个测试(枚举 + ClassificationJob 工厂方法)
- generated_video: 11 个测试(工厂方法 + 参数校验)
2026-07-21 12:06:22 +08:00
CI Bot cad16b3f87 style: auto-format with black + isort + prettier [ci skip] 2026-07-21 12:06:19 +08:00
CI Bot 5ecc75c5ba test(unit): 新增 filter_presets/template_clip_config 两个模块测试
- filter_presets: 31 个测试,含 build_ffmpeg_filter 强度插值逻辑
- template_clip_config: 28 个测试

本轮 Batch2 共 7 个模块,231 个测试用例
2026-07-21 12:06:15 +08:00
CI Bot 17494a1cde test(unit): 新增 transition_presets/tts_job 两个模块测试
- transition_presets: 32 个测试,100% 覆盖率
- tts_job: 40 个测试,96% 覆盖率

共新增 72 个测试用例
2026-07-21 12:06:11 +08:00
CI Bot 0320bea6b5 test(unit): 新增 voice_presets/preset_bgm/edit_plan_clip 三个模块测试
- voice_presets: 33 个测试,94% 覆盖率
- preset_bgm: 28 个测试,100% 覆盖率
- edit_plan_clip: 39 个测试,96% 覆盖率

共新增 100 个测试用例
2026-07-21 12:06:07 +08:00
42 changed files with 738 additions and 3275 deletions
+1 -2
View File
@@ -1,2 +1 @@
CI trigger file - safe to delete
updated!
trigger: 1784009947
+2 -376
View File
@@ -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
-3
View File
@@ -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
View File
@@ -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)
+4 -69
View File
@@ -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
# ── 草稿核心端点 ────────────────────────────────────────────────────────────
+9 -41
View File
@@ -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()
-7
View File
@@ -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) {
+12 -19
View File
@@ -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 = () => {
+3 -13
View File
@@ -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
View File
@@ -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
+20 -10
View File
@@ -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 用户信息")
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
-99
View File
@@ -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
View File
@@ -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__":
-42
View File
@@ -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"):
-84
View File
@@ -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}"
+6 -6
View File
@@ -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}"
+6 -7
View File
@@ -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 "=== 前端依赖安装完成 ==="
-186
View File
@@ -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: 代码质量与安全扫描 全部通过 ✅ ==="
-182
View File
@@ -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迁移验证 通过 ✅ ==="
-10
View File
@@ -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类型检查 通过 ✅ ==="
-78
View File
@@ -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
View File
@@ -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"
-35
View File
@@ -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
-237
View File
@@ -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
-40
View File
@@ -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
-55
View File
@@ -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
-142
View File
@@ -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
-166
View File
@@ -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
-84
View File
@@ -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
View File
@@ -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)
-34
View File
@@ -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
-153
View File
@@ -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
-127
View File
@@ -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]
-78
View File
@@ -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
+123 -125
View File
@@ -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
-168
View File
@@ -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
-95
View File
@@ -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 获得有效 statestate 会被存入 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