Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f412b322f0 | |||
| bc9df316dc | |||
| 5ed2ee1192 | |||
| 061554af89 | |||
| 87cca302f4 | |||
| deb127ae08 | |||
| a9438ed996 | |||
| 72f47592a9 | |||
| b6c340a352 | |||
| 6e76b45d34 | |||
| cd8e77c1f6 | |||
| 4ae6394c95 | |||
| e052c0545e | |||
| ee6fa3e1cf | |||
| afe78fb25f | |||
| 42939a557f |
+153
-151
@@ -81,12 +81,6 @@ jobs:
|
||||
skip_backend: ${{ steps.check.outputs.skip_backend }}
|
||||
skip_frontend: ${{ steps.check.outputs.skip_frontend }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Check changed files
|
||||
id: check
|
||||
shell: bash
|
||||
@@ -125,14 +119,14 @@ jobs:
|
||||
[ ${{ 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
|
||||
curl -sfH "Authorization: token ${GITHUB_TOKEN:-$GITEA_TOKEN}" -o /tmp/_ci_trace.py "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_trace_report.py?ref=${GITHUB_SHA}" 2>/dev/null && python3 /tmp/_ci_trace.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
validate-code-quality:
|
||||
validate-style:
|
||||
needs: dedupe-check
|
||||
if: always() && needs.dedupe-check.outputs.skip_tests != 'true'
|
||||
name: Validate - Code Quality
|
||||
name: Validate - Style
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
timeout-minutes: 6
|
||||
env:
|
||||
PIP_CACHE_DIR: /root/.cache/pip
|
||||
PIP_NO_CACHE_DIR: ''
|
||||
@@ -156,11 +150,12 @@ jobs:
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.cache/pip
|
||||
key: ${{ runner.os }}-pip-codequality-${{ hashFiles('requirements*.txt') }}
|
||||
key: ${{ runner.os }}-pip-style-${{ hashFiles('requirements*.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-codequality-
|
||||
${{ runner.os }}-pip-style-
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
@@ -184,17 +179,9 @@ jobs:
|
||||
[ $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
|
||||
- name: Run style checks
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: bash scripts/ci/validate_code_quality.sh
|
||||
run: bash scripts/ci/validate_style.sh
|
||||
- name: Auto-fix formatting (black + isort)
|
||||
if: failure()
|
||||
shell: sh
|
||||
@@ -210,7 +197,7 @@ jobs:
|
||||
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
|
||||
run: |
|
||||
set +e
|
||||
FAILED_JOB="Validate - Code Quality" python3 scripts/ci_notify_failure.py
|
||||
FAILED_JOB="Validate - Style" python3 scripts/ci_notify_failure.py
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -223,7 +210,7 @@ jobs:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="Validate - Code Quality" python3 scripts/ci_notify.py
|
||||
NOTIFY_MODE=failure JOB_NAME="Validate - Style" python3 scripts/ci_notify.py
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -236,12 +223,16 @@ 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-type-check:
|
||||
|
||||
validate-security:
|
||||
needs: dedupe-check
|
||||
if: always() && needs.dedupe-check.outputs.skip_tests != 'true'
|
||||
name: Validate - Type Check (mypy)
|
||||
name: Validate - Security
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
env:
|
||||
PIP_CACHE_DIR: /root/.cache/pip
|
||||
PIP_NO_CACHE_DIR: ''
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -260,6 +251,116 @@ jobs:
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.cache/pip
|
||||
key: ${{ runner.os }}-pip-security-${{ hashFiles('requirements*.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-security-
|
||||
${{ runner.os }}-pip-
|
||||
- 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 security checks
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: bash scripts/ci/validate_security.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 - Security" 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 - Security" 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-python:
|
||||
needs: dedupe-check
|
||||
if: always() && needs.dedupe-check.outputs.skip_tests != 'true'
|
||||
name: Validate - Python (mypy + alembic)
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
PIP_CACHE_DIR: /root/.cache/pip
|
||||
PIP_NO_CACHE_DIR: ''
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
|
||||
USE_IN_MEMORY_DB: 'false'
|
||||
CI_USE_SHARED_PG: 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Zombie run selfcheck
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
run: bash scripts/ci/ci_run_selfcheck.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.cache/pip
|
||||
key: ${{ runner.os }}-pip-python-${{ hashFiles('requirements*.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-python-
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
@@ -285,6 +386,9 @@ jobs:
|
||||
- name: Run mypy type check
|
||||
shell: bash
|
||||
run: bash scripts/ci/validate_mypy.sh
|
||||
- name: Run alembic migration validation
|
||||
shell: bash
|
||||
run: bash scripts/ci/validate_migration.sh
|
||||
- name: CI failure notification
|
||||
if: failure()
|
||||
shell: sh
|
||||
@@ -293,7 +397,7 @@ jobs:
|
||||
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
|
||||
run: |
|
||||
set +e
|
||||
FAILED_JOB="Validate - Type Check (mypy)" python3 scripts/ci_notify_failure.py
|
||||
FAILED_JOB="Validate - Python (mypy + alembic)" python3 scripts/ci_notify_failure.py
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -306,7 +410,7 @@ jobs:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="Validate - Type Check (mypy)" python3 scripts/ci_notify.py
|
||||
NOTIFY_MODE=failure JOB_NAME="Validate - Python (mypy + alembic)" python3 scripts/ci_notify.py
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -319,92 +423,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-migration:
|
||||
needs: dedupe-check
|
||||
if: always() && needs.dedupe-check.outputs.skip_tests != 'true'
|
||||
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 -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Zombie run selfcheck
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
run: bash scripts/ci/ci_run_selfcheck.sh
|
||||
- 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, dedupe-check]
|
||||
@@ -441,6 +459,7 @@ jobs:
|
||||
run: bash scripts/ci/step_install_ffmpeg.sh
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.cache/pip
|
||||
key: ${{ runner.os }}-pip-unittests-${{ hashFiles('requirements*.txt') }}
|
||||
@@ -495,9 +514,6 @@ jobs:
|
||||
needs:
|
||||
- check-frontend-only
|
||||
- dedupe-check
|
||||
- validate-code-quality
|
||||
- validate-type-check
|
||||
- validate-migration
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
|
||||
USE_IN_MEMORY_DB: 'false'
|
||||
@@ -652,6 +668,7 @@ jobs:
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.npm
|
||||
key: ${{ runner.os }}-npm-vitest-${{ hashFiles('apps/web/package-lock.json') }}
|
||||
@@ -858,19 +875,13 @@ jobs:
|
||||
skip_backend: ${{ steps.check.outputs.skip_backend }}
|
||||
skip_frontend: ${{ steps.check.outputs.skip_frontend }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Check changed paths
|
||||
id: check
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
bash scripts/ci/ci_push_paths.sh
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_push_paths.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_push_paths.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_push_paths.sh
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -881,7 +892,7 @@ jobs:
|
||||
[ ${{ 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
|
||||
curl -sfH "Authorization: token ${GITHUB_TOKEN:-$GITEA_TOKEN}" -o /tmp/_ci_trace.py "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_trace_report.py?ref=${GITHUB_SHA}" 2>/dev/null && python3 /tmp/_ci_trace.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
build-staging:
|
||||
name: Build Staging ${{ matrix.service_display }} Image
|
||||
@@ -1139,9 +1150,7 @@ jobs:
|
||||
- check-push-paths
|
||||
- build-staging
|
||||
- retag-staging-skipped
|
||||
# 显式 success() 状态检查:上游 build/retag 被路径过滤 if 跳过(skipped)时不阻塞本 job;
|
||||
# 上游真正失败时仍然阻断(act_runner 对无状态函数的 if 隐式包 success(),纯 skipped 也会连带跳过)
|
||||
if: success() && github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
|
||||
if: (!cancelled()) && github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -1399,8 +1408,9 @@ jobs:
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
needs:
|
||||
- validate-code-quality
|
||||
- validate-type-check
|
||||
- validate-style
|
||||
- validate-security
|
||||
- validate-python
|
||||
- unit-tests
|
||||
- frontend-lint
|
||||
- frontend-unit-test
|
||||
@@ -1886,9 +1896,9 @@ jobs:
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
needs:
|
||||
- check-frontend-only
|
||||
- validate-code-quality
|
||||
- validate-type-check
|
||||
- validate-migration
|
||||
- validate-style
|
||||
- validate-security
|
||||
- validate-python
|
||||
- unit-tests
|
||||
- integration-tests
|
||||
- frontend-lint
|
||||
@@ -1896,14 +1906,6 @@ jobs:
|
||||
- build-pr
|
||||
timeout-minutes: 3
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
|
||||
- name: Evaluate CI Gate
|
||||
id: gate
|
||||
shell: bash
|
||||
@@ -1912,9 +1914,9 @@ jobs:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
RESULT_CHECK_FRONTEND: ${{ needs.check-frontend-only.result }}
|
||||
RESULT_CODE_QUALITY: ${{ needs.validate-code-quality.result }}
|
||||
RESULT_TYPE_CHECK: ${{ needs.validate-type-check.result }}
|
||||
RESULT_MIGRATION: ${{ needs.validate-migration.result }}
|
||||
RESULT_STYLE: ${{ needs.validate-style.result }}
|
||||
RESULT_SECURITY: ${{ needs.validate-security.result }}
|
||||
RESULT_PYTHON: ${{ needs.validate-python.result }}
|
||||
RESULT_UNIT_TESTS: ${{ needs.unit-tests.result }}
|
||||
RESULT_INTEGRATION: ${{ needs.integration-tests.result }}
|
||||
RESULT_FRONTEND_LINT: ${{ needs.frontend-lint.result }}
|
||||
@@ -1926,9 +1928,9 @@ jobs:
|
||||
echo ""
|
||||
echo "各job结果:"
|
||||
echo " check-frontend-only: $RESULT_CHECK_FRONTEND"
|
||||
echo " validate-code-quality: $RESULT_CODE_QUALITY"
|
||||
echo " validate-type-check: $RESULT_TYPE_CHECK"
|
||||
echo " validate-migration: $RESULT_MIGRATION"
|
||||
echo " validate-style: $RESULT_STYLE"
|
||||
echo " validate-security: $RESULT_SECURITY"
|
||||
echo " validate-python: $RESULT_PYTHON"
|
||||
echo " unit-tests: $RESULT_UNIT_TESTS"
|
||||
echo " integration-tests: $RESULT_INTEGRATION"
|
||||
echo " frontend-lint: $RESULT_FRONTEND_LINT"
|
||||
@@ -1968,9 +1970,9 @@ jobs:
|
||||
# 必填检查项(根据PR类型决定)
|
||||
# 通用检查(所有PR都必须过)
|
||||
REQUIRED_GENERAL=(
|
||||
"validate-code-quality:$RESULT_CODE_QUALITY"
|
||||
"validate-type-check:$RESULT_TYPE_CHECK"
|
||||
"validate-migration:$RESULT_MIGRATION"
|
||||
"validate-style:$RESULT_STYLE"
|
||||
"validate-security:$RESULT_SECURITY"
|
||||
"validate-python:$RESULT_PYTHON"
|
||||
"frontend-lint:$RESULT_FRONTEND_LINT"
|
||||
"build-pr:$RESULT_BUILD_PR"
|
||||
"ai-code-review:$AI_REVIEW_STATUS"
|
||||
@@ -2065,4 +2067,4 @@ jobs:
|
||||
[ "${{ steps.gate.outputs.gate_result }}" = "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
|
||||
curl -sfH "Authorization: token ${GITHUB_TOKEN:-$GITEA_TOKEN}" -o /tmp/_ci_trace.py "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_trace_report.py?ref=${GITHUB_SHA}" 2>/dev/null && python3 /tmp/_ci_trace.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""add duplicate_rate to generated_videos
|
||||
|
||||
Revision ID: 059_duplicate_rate
|
||||
Revises: 058_uq_asset_lib_project_kind
|
||||
Create Date: 2026-08-31
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "059_duplicate_rate"
|
||||
down_revision = "058_uq_asset_lib_project_kind"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("generated_videos", sa.Column("duplicate_rate", sa.Float(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("generated_videos", "duplicate_rate")
|
||||
@@ -0,0 +1,57 @@
|
||||
"""migrate template_segments data to template_clip_configs
|
||||
|
||||
Revision ID: 060_migrate_segments
|
||||
Revises: 059_duplicate_rate
|
||||
Create Date: 2026-08-31
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "060_migrate_segments"
|
||||
down_revision = "059_duplicate_rate"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
dialect = op.get_bind().dialect.name
|
||||
|
||||
if dialect == "postgresql":
|
||||
config_expr = (
|
||||
"CASE WHEN s.material_type IS NOT NULL AND s.material_type != '' "
|
||||
"THEN json_build_object('material_type', s.material_type)::jsonb "
|
||||
"ELSE '{}'::jsonb END"
|
||||
)
|
||||
empty_json = "'{}'::jsonb"
|
||||
else:
|
||||
config_expr = (
|
||||
"CASE WHEN s.material_type IS NOT NULL AND s.material_type != '' "
|
||||
"THEN JSON_OBJECT('material_type', s.material_type) "
|
||||
"ELSE '{}' END"
|
||||
)
|
||||
empty_json = "'{}'"
|
||||
|
||||
sql_str = (
|
||||
"INSERT INTO template_clip_configs "
|
||||
'(id, template_id, clip_type, "order", min_duration, max_duration, '
|
||||
"text_template, material_requirements, transition_effect, config, "
|
||||
"created_at, updated_at) "
|
||||
"SELECT "
|
||||
"s.id, s.template_id, 'main', s.segment_order, "
|
||||
"s.duration_min, s.duration_max, "
|
||||
"'', " + empty_json + ", "
|
||||
"'cut', " + config_expr + ", "
|
||||
"s.created_at, s.updated_at "
|
||||
"FROM template_segments s "
|
||||
"WHERE NOT EXISTS ("
|
||||
" SELECT 1 FROM template_clip_configs c "
|
||||
" WHERE c.template_id = s.template_id"
|
||||
")"
|
||||
)
|
||||
op.execute(sa.text(sql_str))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -26,7 +26,7 @@ from app.schemas.asset import (
|
||||
UpdateAssetReviewRequest,
|
||||
)
|
||||
from app.schemas.tag import TagAssetsRequest
|
||||
from app.services.asset_segment_tracker import compute_asset_availability
|
||||
from app.services.asset_segment_tracker import compute_asset_availability, get_asset_recent_use_counts
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
|
||||
from packages.domain.smart_match import smart_select_assets
|
||||
@@ -608,6 +608,38 @@ def smart_match_assets(
|
||||
continue
|
||||
filtered_results.append(r)
|
||||
|
||||
# 高频使用排除:同一素材在最近 5 个视频中出现超过 3 次则排除
|
||||
MAX_RECENT_USE_COUNT = 3
|
||||
if filtered_results:
|
||||
asset_ids = [getattr(r.asset, "id", "") for r in filtered_results if getattr(r.asset, "id", "")]
|
||||
if asset_ids:
|
||||
try:
|
||||
use_counts = get_asset_recent_use_counts(
|
||||
db=asset_repository.session,
|
||||
asset_ids=asset_ids,
|
||||
recent_video_count=5,
|
||||
)
|
||||
high_use_excluded = set()
|
||||
for r in filtered_results:
|
||||
aid = getattr(r.asset, "id", "")
|
||||
count = use_counts.get(aid, 0)
|
||||
if count > MAX_RECENT_USE_COUNT:
|
||||
logger.info(
|
||||
"smart-match 排除高频使用素材: asset_id=%s use_count=%d limit=%d",
|
||||
aid, count, MAX_RECENT_USE_COUNT,
|
||||
)
|
||||
high_use_excluded.add(id(r))
|
||||
else:
|
||||
pass
|
||||
# 如果排除后不够 limit,放宽到不限制
|
||||
remaining = [r for r in filtered_results if id(r) not in high_use_excluded]
|
||||
if len(remaining) >= request.limit:
|
||||
filtered_results = remaining
|
||||
else:
|
||||
logger.info("smart-match 高频排除后素材不足(%d<%d),保留全部", len(remaining), request.limit)
|
||||
except Exception:
|
||||
logger.warning("smart-match 高频使用查询失败,跳过排除", exc_info=True)
|
||||
|
||||
# 扁平结构:SmartMatchItem 继承 AssetResponse,素材字段直接在条目顶层,
|
||||
# 前端无需解析 item.asset 包装层,item.id / item.usable / 余量字段直接可读
|
||||
items = [
|
||||
|
||||
@@ -52,6 +52,7 @@ def _to_video_response(item, storage: OSSStorageService | None = None) -> VideoI
|
||||
generation_params=item.generation_params,
|
||||
download_url=download_url,
|
||||
generated_at=format_utc_datetime(item.generated_at) if hasattr(item, "generated_at") else "",
|
||||
duplicate_rate=getattr(item, "duplicate_rate", None),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ class VideoItemResponse(BaseModel):
|
||||
generation_params: dict = Field(default_factory=dict)
|
||||
download_url: str | None = None
|
||||
generated_at: str = ""
|
||||
duplicate_rate: float | None = None
|
||||
|
||||
|
||||
class ListVideosResponse(BaseModel):
|
||||
|
||||
@@ -438,3 +438,57 @@ def make_reuse_callback(
|
||||
return result
|
||||
|
||||
return _reuse
|
||||
|
||||
|
||||
def get_asset_recent_use_counts(
|
||||
db: Session,
|
||||
asset_ids: list[str],
|
||||
recent_video_count: int = 5,
|
||||
) -> dict[str, int]:
|
||||
"""统计每个素材在最近 N 个不同 plan_id 中的使用次数。
|
||||
|
||||
遍历素材 metadata 中的 used_time_ranges,统计有多少个不同的 plan_id(去重),
|
||||
返回 {asset_id: count}。只统计最近 recent_video_count 个不同 plan_id 的使用次数。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
asset_ids: 素材 ID 列表
|
||||
recent_video_count: 统计最近多少个不同 plan_id
|
||||
|
||||
Returns:
|
||||
{asset_id: 在最近 recent_video_count 个 plan 中的使用次数}
|
||||
"""
|
||||
if not asset_ids:
|
||||
return {}
|
||||
|
||||
result: dict[str, int] = {}
|
||||
models = db.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all()
|
||||
for model in models:
|
||||
meta = _read_meta(model)
|
||||
ranges = meta.get(USED_RANGES_KEY) or []
|
||||
if not ranges:
|
||||
result[model.id] = 0
|
||||
continue
|
||||
|
||||
# 按 created_at 倒序收集不同 plan_id
|
||||
sorted_ranges = sorted(
|
||||
ranges,
|
||||
key=lambda r: r.get("created_at") or "",
|
||||
reverse=True,
|
||||
)
|
||||
recent_plan_ids: set[str] = set()
|
||||
for r in sorted_ranges:
|
||||
plan_id = r.get("plan_id")
|
||||
if plan_id:
|
||||
recent_plan_ids.add(plan_id)
|
||||
if len(recent_plan_ids) >= recent_video_count:
|
||||
break
|
||||
|
||||
result[model.id] = len(recent_plan_ids)
|
||||
|
||||
# 未找到的素材计为 0
|
||||
for aid in asset_ids:
|
||||
if aid not in result:
|
||||
result[aid] = 0
|
||||
|
||||
return result
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 成片库页面 — V21 设计系统
|
||||
* 卡片网格布局,支持视频播放/下载/分享、批量操作、筛选
|
||||
* 卡片网格布局,支持视频内联播放/下载/分享、批量操作、筛选
|
||||
*
|
||||
* 主组件仅保留 Hook 组装与整体布局
|
||||
* 列表查询 → hooks/useProductList
|
||||
@@ -8,15 +8,12 @@
|
||||
* 筛选栏 → components/ProductFilterBar
|
||||
* 批量操作栏 → components/ProductBatchBar
|
||||
* 空状态 → components/ProductEmptyState
|
||||
* 产品卡片 → components/ProductCard
|
||||
* 视频播放 → components/VideoPlayer
|
||||
* 产品卡片 → components/ProductCard(内联视频播放)
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import React from "react"
|
||||
import { VideoCameraOutlined, DownloadOutlined } from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
import type { ProductItem } from "./types"
|
||||
import { ProductCard } from "./components/ProductCard"
|
||||
import { VideoPlayer } from "./components/VideoPlayer"
|
||||
import { ProductFilterBar } from "./components/ProductFilterBar"
|
||||
import { ProductBatchBar } from "./components/ProductBatchBar"
|
||||
import { ProductEmptyState } from "./components/ProductEmptyState"
|
||||
@@ -53,13 +50,9 @@ const ProductLibrary: React.FC = () => {
|
||||
clearSelection,
|
||||
} = useProductList()
|
||||
|
||||
/* 播放器 */
|
||||
const [playingProduct, setPlayingProduct] = useState<ProductItem | null>(null)
|
||||
|
||||
const {
|
||||
handleDownload,
|
||||
handleShare,
|
||||
handleViewDetail,
|
||||
handleDelete,
|
||||
handlePublish,
|
||||
handleReviewStatusChange,
|
||||
@@ -71,7 +64,7 @@ const ProductLibrary: React.FC = () => {
|
||||
selectedIds,
|
||||
clearSelection,
|
||||
products,
|
||||
setPlayingProduct,
|
||||
setPlayingProduct: () => {}, // 不再使用弹窗播放
|
||||
})
|
||||
|
||||
// ── Loading 状态 ──
|
||||
@@ -146,7 +139,6 @@ const ProductLibrary: React.FC = () => {
|
||||
isSelected={selectedIds.has(product.id)}
|
||||
batchMode={batchMode}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onPlay={setPlayingProduct}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
onDelete={handleDelete}
|
||||
@@ -158,17 +150,6 @@ const ProductLibrary: React.FC = () => {
|
||||
) : (
|
||||
<ProductEmptyState type="empty" />
|
||||
)}
|
||||
|
||||
{/* 视频播放弹窗 */}
|
||||
{playingProduct && (
|
||||
<VideoPlayer
|
||||
product={playingProduct}
|
||||
onClose={() => setPlayingProduct(null)}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
onViewDetail={handleViewDetail}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react"
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { Popconfirm } from "antd"
|
||||
import {
|
||||
CheckOutlined,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ShareAltOutlined,
|
||||
DeleteOutlined,
|
||||
CloudUploadOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { ProductItem } from "../types"
|
||||
import { statusConfig, reviewStatusConfig } from "../constants"
|
||||
@@ -17,7 +18,6 @@ interface ProductCardProps {
|
||||
isSelected: boolean
|
||||
batchMode: boolean
|
||||
onToggleSelect: (id: string) => void
|
||||
onPlay: (product: ProductItem) => void
|
||||
onDownload: (product: ProductItem) => void
|
||||
onShare: (product: ProductItem) => void
|
||||
onDelete: (id: string) => void
|
||||
@@ -30,7 +30,6 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
isSelected,
|
||||
batchMode,
|
||||
onToggleSelect,
|
||||
onPlay,
|
||||
onDownload,
|
||||
onShare,
|
||||
onDelete,
|
||||
@@ -38,17 +37,39 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
onReviewStatusChange,
|
||||
}) => {
|
||||
const st = statusConfig[product.status]
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [aspectRatio, setAspectRatio] = useState<string | null>(null)
|
||||
const [hasVideo, setHasVideo] = useState(!!product.videoUrl)
|
||||
|
||||
/** 查重率样式 */
|
||||
const dupClass =
|
||||
product.duplicateRate <= 5 ? "good" : product.duplicateRate <= 15 ? "warn" : "bad"
|
||||
|
||||
/** 点击卡片 */
|
||||
const handleCardClick = () => {
|
||||
if (batchMode) {
|
||||
onToggleSelect(product.id)
|
||||
/** 视频元数据加载后获取实际比例 */
|
||||
const handleVideoLoaded = useCallback(() => {
|
||||
const v = videoRef.current
|
||||
if (v && v.videoWidth > 0 && v.videoHeight > 0) {
|
||||
setAspectRatio(`${v.videoWidth} / ${v.videoHeight}`)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 视频播放/暂停结束事件 */
|
||||
const handlePlay = useCallback(() => setIsPlaying(true), [])
|
||||
const handlePause = useCallback(() => setIsPlaying(false), [])
|
||||
|
||||
/** 视频出错时降级为缩略图 */
|
||||
const handleVideoError = useCallback(() => setHasVideo(false), [])
|
||||
|
||||
/** 点击缩略图区域:有视频则内联播放,否则不响应 */
|
||||
const handleThumbClick = () => {
|
||||
if (batchMode) return
|
||||
if (!hasVideo || !videoRef.current) return
|
||||
const v = videoRef.current
|
||||
if (v.paused) {
|
||||
v.play()
|
||||
} else {
|
||||
onPlay(product)
|
||||
v.pause()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +79,24 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
onToggleSelect(product.id)
|
||||
}
|
||||
|
||||
/** 卡片容器点击(非批量模式下不再触发弹窗播放) */
|
||||
const handleCardClick = () => {
|
||||
if (batchMode) {
|
||||
onToggleSelect(product.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** 组件卸载时暂停视频 */
|
||||
useEffect(() => {
|
||||
const v = videoRef.current
|
||||
return () => {
|
||||
v?.pause()
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 缩略图区域 style:有实际比例则用实际比例,否则默认 16:9 */
|
||||
const thumbStyle: React.CSSProperties = aspectRatio ? { aspectRatio } : { aspectRatio: "16 / 9" }
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`xx-product-card${isSelected ? " selected" : ""}${product.isPublished ? " published" : ""}`}
|
||||
@@ -102,21 +141,61 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-product-thumb">
|
||||
{product.thumbnailUrl ? (
|
||||
<img
|
||||
className="xx-product-thumb-bg"
|
||||
src={product.thumbnailUrl}
|
||||
alt={product.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
{/* 视频/缩略图区域 */}
|
||||
<div className="xx-product-thumb" style={thumbStyle}>
|
||||
{hasVideo ? (
|
||||
<>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="xx-product-thumb-video"
|
||||
src={product.videoUrl}
|
||||
preload="metadata"
|
||||
onLoadedMetadata={handleVideoLoaded}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onEnded={handlePause}
|
||||
onError={handleVideoError}
|
||||
controls={isPlaying}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleThumbClick()
|
||||
}}
|
||||
/>
|
||||
{/* 未播放时显示播放按钮覆盖层 */}
|
||||
{!isPlaying && (
|
||||
<div className="xx-product-play" onClick={handleThumbClick}>
|
||||
<PlayCircleOutlined />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="xx-product-thumb-bg" style={{ background: product.gradient }} />
|
||||
<>
|
||||
{product.thumbnailUrl ? (
|
||||
<img
|
||||
className="xx-product-thumb-bg"
|
||||
src={product.thumbnailUrl}
|
||||
alt={product.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="xx-product-thumb-bg"
|
||||
style={{
|
||||
background: product.gradient,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "rgba(255,255,255,0.3)",
|
||||
fontSize: "48px",
|
||||
}}
|
||||
>
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-product-play">
|
||||
<PlayCircleOutlined />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="xx-product-play">
|
||||
<PlayCircleOutlined />
|
||||
</div>
|
||||
{product.duration > 0 && (
|
||||
<span className="xx-product-duration">{formatTime(product.duration)}</span>
|
||||
)}
|
||||
|
||||
@@ -260,13 +260,13 @@
|
||||
|
||||
/* 缩略图区域 */
|
||||
.xx-product-thumb {
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 220px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-inverse);
|
||||
background: var(--color-gray-950);
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.xx-product-thumb-bg {
|
||||
@@ -276,6 +276,18 @@
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
/* 内联视频播放器 */
|
||||
.xx-product-thumb-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xx-product-thumb-video[controls] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-product-play {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
@@ -308,6 +308,71 @@ class VideoDeduplicator:
|
||||
|
||||
return sum(similarities) / len(similarities) if similarities else 0.0
|
||||
|
||||
def compute_duplicate_rate(
|
||||
self,
|
||||
fingerprint: VideoFingerprint,
|
||||
project_id: str,
|
||||
current_video_id: str | None,
|
||||
session: Session,
|
||||
) -> float:
|
||||
"""计算当前视频与项目内已有视频的最高相似度百分比。
|
||||
|
||||
遍历项目内所有其他有指纹的视频,对每个计算相似度:
|
||||
- MD5 精确匹配 → 100%
|
||||
- pHash 相似度 → (1.0 - avg_distance / 64) * 100
|
||||
取最高值作为 duplicate_rate(0~100)。
|
||||
如果没有其他视频可比较,返回 0.0。
|
||||
|
||||
Args:
|
||||
fingerprint: 当前视频的指纹
|
||||
project_id: 项目 ID
|
||||
current_video_id: 当前视频 ID(排除自身,可为 None)
|
||||
session: 数据库会话
|
||||
|
||||
Returns:
|
||||
duplicate_rate: 0~100 的浮点数
|
||||
"""
|
||||
# 限制查询最近 100 个视频,避免大项目内存溢出
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
recent_models = (
|
||||
session.query(GeneratedVideoModel)
|
||||
.filter(GeneratedVideoModel.project_id == project_id)
|
||||
.order_by(GeneratedVideoModel.generated_at.desc())
|
||||
.limit(100)
|
||||
.all()
|
||||
)
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
existing_videos = [video_repo._to_domain(m) for m in recent_models]
|
||||
|
||||
max_similarity = 0.0
|
||||
for existing in existing_videos:
|
||||
if current_video_id and existing.id == current_video_id:
|
||||
continue
|
||||
if not existing.video_fingerprint:
|
||||
continue
|
||||
|
||||
ef = existing.video_fingerprint
|
||||
|
||||
# MD5 精确匹配 → 100%
|
||||
if fingerprint.md5 == ef.get("md5"):
|
||||
return 100.0
|
||||
|
||||
# pHash 相似度
|
||||
existing_phashes = ef.get("keyframe_phashes", [])
|
||||
if not existing_phashes or not fingerprint.keyframe_phashes:
|
||||
continue
|
||||
|
||||
min_distances = []
|
||||
for phash in fingerprint.keyframe_phashes:
|
||||
distances = [hamming_distance(phash, ep) for ep in existing_phashes]
|
||||
min_distances.append(min(distances))
|
||||
avg_distance = sum(min_distances) / len(min_distances) if min_distances else 64
|
||||
similarity = (1.0 - avg_distance / 64) * 100
|
||||
max_similarity = max(max_similarity, similarity)
|
||||
|
||||
return round(max(max_similarity, 0.0), 2)
|
||||
|
||||
|
||||
@celery_app.task(bind=True, max_retries=3, name="worker.check_duplicate")
|
||||
def check_duplicate_task(self: Task, generated_video_id: str) -> dict:
|
||||
|
||||
@@ -121,6 +121,15 @@ def create_video_record_and_dedup(
|
||||
generated_video.is_duplicate = False
|
||||
generated_video.duplicate_of = None
|
||||
|
||||
# 计算重复率百分比(与项目内所有已有视频对比取最高相似度)
|
||||
try:
|
||||
dup_rate = deduplicator.compute_duplicate_rate(fingerprint, project_id, video_id, session)
|
||||
generated_video.duplicate_rate = dup_rate
|
||||
logger.info("Duplicate rate for %s: %.2f%%", video_id, dup_rate)
|
||||
except Exception as rate_err:
|
||||
logger.warning("Failed to compute duplicate_rate for %s: %s", video_id, rate_err)
|
||||
generated_video.duplicate_rate = None
|
||||
|
||||
video_repo.update(generated_video)
|
||||
session.commit()
|
||||
logger.info(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Build stage
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/node:20 AS builder
|
||||
ARG SOURCE_HASH=""
|
||||
WORKDIR /app
|
||||
ARG VITE_API_URL=https://saas-api.xiaoxiajianji.com
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
@@ -18,8 +19,10 @@ COPY apps/web/ ./
|
||||
|
||||
# 构建:TS增量编译 + Vite构建,tsbuildinfo用cache mount持久化
|
||||
# node_modules直接使用镜像中已安装的(layer缓存保证完整性)
|
||||
# SOURCE_HASH 变化时强制重新执行(防止 buildkit 幽灵缓存命中)
|
||||
RUN --mount=type=cache,target=/app/apps/web/.tscache,sharing=locked \
|
||||
mkdir -p .tscache \
|
||||
&& echo "SOURCE_HASH=${SOURCE_HASH}" > .cache_bust \
|
||||
&& ./node_modules/.bin/tsc --incremental --tsBuildInfoFile .tscache/tsconfig.tsbuildinfo \
|
||||
&& ./node_modules/.bin/vite build
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ class SQLAlchemyGeneratedVideoRepository:
|
||||
video_fingerprint=json.dumps(video.video_fingerprint) if video.video_fingerprint else None,
|
||||
is_duplicate=video.is_duplicate,
|
||||
duplicate_of=video.duplicate_of,
|
||||
duplicate_rate=video.duplicate_rate,
|
||||
generated_at=video.generated_at,
|
||||
created_at=video.created_at,
|
||||
)
|
||||
@@ -60,6 +61,7 @@ class SQLAlchemyGeneratedVideoRepository:
|
||||
video_fingerprint=json.loads(getattr(model, "video_fingerprint", "null") or "null"),
|
||||
is_duplicate=getattr(model, "is_duplicate", False),
|
||||
duplicate_of=getattr(model, "duplicate_of", None),
|
||||
duplicate_rate=getattr(model, "duplicate_rate", None),
|
||||
generated_at=model.generated_at,
|
||||
created_at=model.created_at,
|
||||
)
|
||||
@@ -74,6 +76,7 @@ class SQLAlchemyGeneratedVideoRepository:
|
||||
model.video_fingerprint = json.dumps(video.video_fingerprint) if video.video_fingerprint else None
|
||||
model.is_duplicate = video.is_duplicate
|
||||
model.duplicate_of = video.duplicate_of
|
||||
model.duplicate_rate = video.duplicate_rate
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return video
|
||||
@@ -204,6 +207,7 @@ class SQLAlchemyGeneratedVideoRepository:
|
||||
video_fingerprint=json.loads(getattr(model, "video_fingerprint", "null") or "null"),
|
||||
is_duplicate=getattr(model, "is_duplicate", False),
|
||||
duplicate_of=getattr(model, "duplicate_of", None),
|
||||
duplicate_rate=getattr(model, "duplicate_rate", None),
|
||||
generated_at=model.generated_at,
|
||||
created_at=model.created_at,
|
||||
)
|
||||
|
||||
@@ -338,6 +338,7 @@ class GeneratedVideoModel(Base):
|
||||
video_fingerprint = Column(Text, nullable=True)
|
||||
is_duplicate = Column(Boolean, nullable=False, default=False)
|
||||
duplicate_of = Column(String(36), nullable=True)
|
||||
duplicate_rate = Column(Float, nullable=True)
|
||||
|
||||
|
||||
class TitleLibraryModel(Base):
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
"""SQLAlchemy implementation of TemplateRepository."""
|
||||
"""SQLAlchemy implementation of TemplateRepository.
|
||||
|
||||
模板 segments 数据源已统一为 template_clip_configs 表。
|
||||
读取时优先 template_clip_configs,回退 template_segments(兼容历史数据)。
|
||||
写入全部走 template_clip_configs。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,6 +15,7 @@ from sqlalchemy.orm import Session
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
EditPlanModel,
|
||||
TemplateCategoryModel,
|
||||
TemplateClipConfigModel,
|
||||
TemplateModel,
|
||||
TemplateSegmentModel,
|
||||
)
|
||||
@@ -47,27 +53,38 @@ class SQLAlchemyTemplateRepository:
|
||||
like_pattern = f"%{keyword}%"
|
||||
query = query.filter(TemplateModel.name.like(like_pattern))
|
||||
if tag:
|
||||
# JSON 数组包含指定标签(MySQL JSON_CONTAINS / SQLite json_each 兼容写法用 LIKE)
|
||||
query = query.filter(TemplateModel.tags.like(f'%"{tag}"%'))
|
||||
query = query.filter(TemplateModel.tags.like(f'"%{tag}"%'))
|
||||
models = query.order_by(TemplateModel.created_at.desc()).offset(skip).limit(limit).all()
|
||||
templates = [self._model_to_entity(m) for m in models]
|
||||
# 批量加载所有 segments,避免 N+1 查询
|
||||
# 批量加载 segments —— 优先 template_clip_configs
|
||||
if templates:
|
||||
template_ids = [t.id for t in templates]
|
||||
seg_models = (
|
||||
self.session.query(TemplateSegmentModel)
|
||||
.filter(TemplateSegmentModel.template_id.in_(template_ids))
|
||||
.order_by(TemplateSegmentModel.segment_order)
|
||||
clip_models = (
|
||||
self.session.query(TemplateClipConfigModel)
|
||||
.filter(TemplateClipConfigModel.template_id.in_(template_ids))
|
||||
.order_by(TemplateClipConfigModel.order)
|
||||
.all()
|
||||
)
|
||||
# 按 template_id 分组
|
||||
seg_map: dict[str, list] = {}
|
||||
for sm in seg_models:
|
||||
seg_map.setdefault(sm.template_id, []).append(
|
||||
self._segment_model_to_entity(sm),
|
||||
clip_map: dict[str, list] = {}
|
||||
for cm in clip_models:
|
||||
clip_map.setdefault(cm.template_id, []).append(
|
||||
self._clip_config_to_segment(cm),
|
||||
)
|
||||
# 对没有 clip_configs 的模板,回退读 template_segments
|
||||
missing_ids = [t.id for t in templates if t.id not in clip_map]
|
||||
if missing_ids:
|
||||
old_models = (
|
||||
self.session.query(TemplateSegmentModel)
|
||||
.filter(TemplateSegmentModel.template_id.in_(missing_ids))
|
||||
.order_by(TemplateSegmentModel.segment_order)
|
||||
.all()
|
||||
)
|
||||
for om in old_models:
|
||||
clip_map.setdefault(om.template_id, []).append(
|
||||
self._segment_model_to_entity(om),
|
||||
)
|
||||
for t in templates:
|
||||
t.segments = seg_map.get(t.id, [])
|
||||
t.segments = clip_map.get(t.id, [])
|
||||
return templates
|
||||
|
||||
def get(self, template_id: str, user_id: str) -> Optional[Template]:
|
||||
@@ -100,7 +117,6 @@ class SQLAlchemyTemplateRepository:
|
||||
is_active=template.is_active,
|
||||
)
|
||||
self.session.add(model)
|
||||
# flush 而非 commit,让 create + create_segments 在同一事务中提交
|
||||
self.session.flush()
|
||||
self.session.refresh(model)
|
||||
result = self._model_to_entity(model)
|
||||
@@ -145,11 +161,8 @@ class SQLAlchemyTemplateRepository:
|
||||
if model is None:
|
||||
return False
|
||||
model.is_active = False
|
||||
# 级联清理关联的 segments,避免孤儿数据
|
||||
self.session.query(TemplateSegmentModel).filter(
|
||||
TemplateSegmentModel.template_id == template_id,
|
||||
).delete(synchronize_session=False)
|
||||
self.session.commit()
|
||||
# 复用 delete_segments_by_template 清理两张表的关联数据
|
||||
self.delete_segments_by_template(template_id)
|
||||
return True
|
||||
|
||||
def count_by_user(
|
||||
@@ -172,7 +185,7 @@ class SQLAlchemyTemplateRepository:
|
||||
if keyword:
|
||||
query = query.filter(TemplateModel.name.like(f"%{keyword}%"))
|
||||
if tag:
|
||||
query = query.filter(TemplateModel.tags.like(f'%"{tag}"%'))
|
||||
query = query.filter(TemplateModel.tags.like(f'"%{tag}"%'))
|
||||
return query.count()
|
||||
|
||||
def copy_template(self, template_id: str, user_id: str, new_name: str) -> Template:
|
||||
@@ -181,9 +194,8 @@ class SQLAlchemyTemplateRepository:
|
||||
if source is None:
|
||||
raise ValueError(f"Template {template_id} not found")
|
||||
|
||||
new_id = str(uuid.uuid4())
|
||||
new_template = Template(
|
||||
id=new_id,
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
name=new_name,
|
||||
mode=source.mode,
|
||||
@@ -197,28 +209,22 @@ class SQLAlchemyTemplateRepository:
|
||||
)
|
||||
created = self.create(new_template)
|
||||
|
||||
# 复制 segments
|
||||
# 复用 create_segments 写入 template_clip_configs
|
||||
new_segments: List[TemplateSegment] = []
|
||||
for seg in source.segments:
|
||||
new_seg = TemplateSegment(
|
||||
id=str(uuid.uuid4()),
|
||||
template_id=new_id,
|
||||
segment_order=seg.segment_order,
|
||||
duration_min=seg.duration_min,
|
||||
duration_max=seg.duration_max,
|
||||
material_type=seg.material_type,
|
||||
new_segments.append(
|
||||
TemplateSegment(
|
||||
id=str(uuid.uuid4()),
|
||||
template_id=created.id,
|
||||
segment_order=seg.segment_order,
|
||||
duration_min=seg.duration_min,
|
||||
duration_max=seg.duration_max,
|
||||
material_type=seg.material_type,
|
||||
)
|
||||
)
|
||||
new_segments.append(new_seg)
|
||||
model = TemplateSegmentModel(
|
||||
id=new_seg.id,
|
||||
template_id=new_seg.template_id,
|
||||
segment_order=new_seg.segment_order,
|
||||
duration_min=new_seg.duration_min,
|
||||
duration_max=new_seg.duration_max,
|
||||
material_type=new_seg.material_type,
|
||||
)
|
||||
self.session.add(model)
|
||||
if new_segments:
|
||||
self.create_segments(new_segments)
|
||||
else:
|
||||
self.session.commit()
|
||||
|
||||
created.segments = new_segments
|
||||
@@ -227,34 +233,58 @@ class SQLAlchemyTemplateRepository:
|
||||
# ── Segments ──
|
||||
|
||||
def list_segments(self, template_id: str) -> List[TemplateSegment]:
|
||||
models = (
|
||||
"""优先从 template_clip_configs 读取,回退读 template_segments。"""
|
||||
clips = (
|
||||
self.session.query(TemplateClipConfigModel)
|
||||
.filter(TemplateClipConfigModel.template_id == template_id)
|
||||
.order_by(TemplateClipConfigModel.order)
|
||||
.all()
|
||||
)
|
||||
if clips:
|
||||
return [self._clip_config_to_segment(m) for m in clips]
|
||||
# 回退:旧表
|
||||
old = (
|
||||
self.session.query(TemplateSegmentModel)
|
||||
.filter(TemplateSegmentModel.template_id == template_id)
|
||||
.order_by(TemplateSegmentModel.segment_order)
|
||||
.all()
|
||||
)
|
||||
return [self._segment_model_to_entity(m) for m in models]
|
||||
return [self._segment_model_to_entity(m) for m in old]
|
||||
|
||||
def create_segments(self, segments: List[TemplateSegment]) -> List[TemplateSegment]:
|
||||
"""写入 template_clip_configs 表。material_type 存入 config JSON。"""
|
||||
for seg in segments:
|
||||
model = TemplateSegmentModel(
|
||||
config = {"material_type": seg.material_type} if seg.material_type else {}
|
||||
model = TemplateClipConfigModel(
|
||||
id=seg.id,
|
||||
template_id=seg.template_id,
|
||||
segment_order=seg.segment_order,
|
||||
duration_min=seg.duration_min,
|
||||
duration_max=seg.duration_max,
|
||||
material_type=seg.material_type,
|
||||
clip_type="main",
|
||||
order=seg.segment_order,
|
||||
min_duration=seg.duration_min,
|
||||
max_duration=seg.duration_max,
|
||||
text_template="",
|
||||
material_requirements={},
|
||||
transition_effect="cut",
|
||||
config=config,
|
||||
)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return segments
|
||||
|
||||
def delete_segments_by_template(self, template_id: str) -> int:
|
||||
count = (
|
||||
self.session.query(TemplateSegmentModel).filter(TemplateSegmentModel.template_id == template_id).delete()
|
||||
"""删除两张表中的 segments 数据,返回删除总数。"""
|
||||
c1 = (
|
||||
self.session.query(TemplateClipConfigModel)
|
||||
.filter(TemplateClipConfigModel.template_id == template_id)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
c2 = (
|
||||
self.session.query(TemplateSegmentModel)
|
||||
.filter(TemplateSegmentModel.template_id == template_id)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
self.session.commit()
|
||||
return count
|
||||
return c1 + c2
|
||||
|
||||
# ── Categories ──
|
||||
|
||||
@@ -366,6 +396,23 @@ class SQLAlchemyTemplateRepository:
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _clip_config_to_segment(model: TemplateClipConfigModel) -> TemplateSegment:
|
||||
"""将 TemplateClipConfigModel 转换为 TemplateSegment 域实体。"""
|
||||
material_type = None
|
||||
if model.config and isinstance(model.config, dict):
|
||||
material_type = model.config.get("material_type")
|
||||
return TemplateSegment(
|
||||
id=model.id,
|
||||
template_id=model.template_id,
|
||||
segment_order=model.order,
|
||||
duration_min=model.min_duration,
|
||||
duration_max=model.max_duration,
|
||||
material_type=material_type,
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _category_model_to_entity(model: TemplateCategoryModel) -> TemplateCategory:
|
||||
return TemplateCategory(
|
||||
|
||||
@@ -26,6 +26,7 @@ class GeneratedVideo:
|
||||
video_fingerprint: dict[str, Any] | None = None
|
||||
is_duplicate: bool = False
|
||||
duplicate_of: str | None = None
|
||||
duplicate_rate: float | None = None
|
||||
generated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ before="${GITHUB_EVENT_BEFORE:-}"
|
||||
after="${GITHUB_SHA:-}"
|
||||
repo="${GITHUB_REPOSITORY:-}"
|
||||
base="${GITHUB_API_URL:-}"
|
||||
ZERO="0000000000000000000000000000000000000000"
|
||||
|
||||
# Gitea Actions 中 push 事件的前一个 SHA 在 event payload 的 before 字段
|
||||
if [ -z "$before" ] && [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "$GITHUB_EVENT_PATH" ]; then
|
||||
@@ -28,8 +29,76 @@ fi
|
||||
|
||||
echo "改动范围检测: before=${before:-<empty>} after=${after}"
|
||||
|
||||
# ── 安全回溯:确保 diff 基准覆盖所有未构建的改动 ──
|
||||
# 问题:concurrency 取消机制会导致前端改动被跳过。被取消的 push 的改动不会被
|
||||
# 后续 push 的 diff 覆盖到,因为 GITHUB_EVENT_BEFORE 只指向上一次 push 的 SHA。
|
||||
# 修复:查询最近一次**实际构建了 web 镜像**的成功 push run,用其 head_sha 作为
|
||||
# diff 基准。这样被取消/跳过的 run 的改动都会被包含在当前 diff 中。
|
||||
if [ -n "$before" ] && [ "$before" != "$ZERO" ] && [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
BRANCH="${GITHUB_REF_NAME:-}"
|
||||
if [ -n "$BRANCH" ]; then
|
||||
SAFE_BASE=$(python3 -c "
|
||||
import json, subprocess, sys
|
||||
|
||||
base = '${base}'
|
||||
repo = '${repo}'
|
||||
token = '${GITHUB_TOKEN}'
|
||||
branch = '${BRANCH}'
|
||||
cur_sha = '${after}'
|
||||
|
||||
def check_run(run_id):
|
||||
\"\"\"Check if this run actually built the web image.\"\"\"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['curl', '-sf', '--max-time', '10',
|
||||
'-H', f'Authorization: token {token}',
|
||||
f'{base}/repos/{repo}/actions/runs/{run_id}/jobs'],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
if r.returncode != 0:
|
||||
return False
|
||||
jobs = json.loads(r.stdout).get('jobs', [])
|
||||
return any(
|
||||
'Build Staging Web' in j.get('name', '')
|
||||
and j.get('conclusion') == 'success'
|
||||
for j in jobs
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['curl', '-sf', '--max-time', '15',
|
||||
'-H', f'Authorization: token {token}',
|
||||
f'{base}/repos/{repo}/actions/runs?status=success&event=push&branch={branch}&per_page=30'],
|
||||
capture_output=True, text=True, timeout=20)
|
||||
if r.returncode != 0:
|
||||
sys.exit(0)
|
||||
d = json.loads(r.stdout)
|
||||
runs = d.get('workflow_runs', []) if isinstance(d, dict) else d
|
||||
for run in runs:
|
||||
sha = run.get('head_sha', '')
|
||||
if sha and sha != cur_sha:
|
||||
if check_run(run['id']):
|
||||
print(sha)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$SAFE_BASE" ] && [ "$SAFE_BASE" != "$before" ]; then
|
||||
echo "🔒 安全回溯: 使用最近实际构建 web 的 commit ${SAFE_BASE:0:8} 替代 before=${before:0:8}"
|
||||
before="$SAFE_BASE"
|
||||
elif [ -z "$SAFE_BASE" ]; then
|
||||
echo "⚠️ 未找到历史成功构建 web 的 push run,保守走全量构建"
|
||||
echo "skip_backend=false" >> "$OUTPUT"
|
||||
echo "skip_frontend=false" >> "$OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
FILES=""
|
||||
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
||||
if [ -n "$before" ] && [ "$before" != "$ZERO" ]; then
|
||||
# Gitea 1.26.x compare API 的顶层 files 字段不填充(始终为空),
|
||||
# 但响应里每个 commit 条目自带的 files 完整可用;聚合区间内所有提交的 files 即可。
|
||||
API_URL="${base}/repos/${repo}/compare/${before}...${after}?per_page=300"
|
||||
|
||||
@@ -19,6 +19,18 @@ for arg in "$@"; do
|
||||
BUILD_ARGS="$BUILD_ARGS --build-arg $arg"
|
||||
done
|
||||
|
||||
# Web 镜像 cache bust:计算 apps/web/ 的 git tree hash
|
||||
# 当源码变化时 hash 变化,buildx 的 ARG 缓存键失效 → vite build 必定重新执行
|
||||
if [ "${DOCKERFILE##*/}" = "web.Dockerfile" ]; then
|
||||
SOURCE_HASH=$(git rev-parse HEAD:apps/web 2>/dev/null || echo "")
|
||||
if [ -n "$SOURCE_HASH" ]; then
|
||||
echo "Web cache bust: SOURCE_HASH=${SOURCE_HASH}"
|
||||
BUILD_ARGS="$BUILD_ARGS --build-arg SOURCE_HASH=${SOURCE_HASH}"
|
||||
else
|
||||
echo "⚠️ 无法计算 apps/web tree hash,跳过 cache bust"
|
||||
fi
|
||||
fi
|
||||
|
||||
BUILDER_NAME="ci-builder-persist"
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
echo "持久 builder 不存在,创建中..."
|
||||
|
||||
@@ -31,6 +31,18 @@ for arg in "$@"; do
|
||||
BUILD_ARGS="$BUILD_ARGS --build-arg $arg"
|
||||
done
|
||||
|
||||
# Web 镜像 cache bust:计算 apps/web/ 的 git tree hash
|
||||
# 当源码变化时 hash 变化,buildx 的 ARG 缓存键失效 → vite build 必定重新执行
|
||||
if [ "${DOCKERFILE##*/}" = "web.Dockerfile" ]; then
|
||||
SOURCE_HASH=$(git rev-parse HEAD:apps/web 2>/dev/null || echo "")
|
||||
if [ -n "$SOURCE_HASH" ]; then
|
||||
echo "Web cache bust: SOURCE_HASH=${SOURCE_HASH}"
|
||||
BUILD_ARGS="$BUILD_ARGS --build-arg SOURCE_HASH=${SOURCE_HASH}"
|
||||
else
|
||||
echo "⚠️ 无法计算 apps/web tree hash,跳过 cache bust"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 确保持久 builder 存在并使用(幂等)
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
echo "持久 builder 不存在,创建中..."
|
||||
|
||||
+26
-14
@@ -1,18 +1,39 @@
|
||||
#!/bin/sh
|
||||
# CI 公共步骤:Checkout 代码(带重试)
|
||||
# CI 公共步骤:Checkout 代码(流式下载+解压,带重试)
|
||||
# 用法:直接 source 或调用,需要 GITHUB_TOKEN 环境变量
|
||||
set -eu
|
||||
|
||||
python3 - <<'PY'
|
||||
import io, os, tarfile, time, urllib.request, urllib.error
|
||||
import io, os, sys, tarfile, time, urllib.request, urllib.error
|
||||
|
||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||
last_err = None
|
||||
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
break
|
||||
# 流式读取:先读少量数据确认连接成功,再大块读取
|
||||
first_chunk = response.read(8192)
|
||||
buf = io.BytesIO()
|
||||
buf.write(first_chunk)
|
||||
while True:
|
||||
chunk = response.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
buf.write(chunk)
|
||||
buf.seek(0)
|
||||
with tarfile.open(fileobj=buf, mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code >= 500 and attempt < 4:
|
||||
@@ -31,14 +52,5 @@ for attempt in range(5):
|
||||
raise
|
||||
else:
|
||||
raise last_err
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
# CI pipeline speedup batch 1
|
||||
|
||||
Regular → Executable
+38
-54
@@ -1,14 +1,20 @@
|
||||
#!/bin/bash
|
||||
# CI Validate: 代码质量与安全扫描(并行Job 1/3)
|
||||
# 包含:密钥扫描、格式检查、安全扫描、依赖漏洞、死代码检测、脚本语法校验
|
||||
# CI Validate: 安全扫描(validate-security)
|
||||
# 包含:密钥扫描、bandit 安全扫描(仅告警)、pip-audit 依赖漏洞(仅告警)、CI 脚本语法校验
|
||||
set -eu
|
||||
|
||||
echo "=== CI Validate: 代码质量与安全扫描 ==="
|
||||
echo "=== CI Validate: 安全扫描 ==="
|
||||
|
||||
# --- 密钥检测 ---
|
||||
echo ""
|
||||
echo "=== [1/6] Secret detection (detect-secrets) ==="
|
||||
python3 -m pip install -q detect-secrets
|
||||
echo "=== [1/4] Secret detection (detect-secrets) ==="
|
||||
python3 -m pip install -q --no-cache-dir detect-secrets || {
|
||||
echo "⚠️ detect-secrets install failed, retrying without cache..."
|
||||
python3 -m pip install -q --no-cache-dir --no-binary :all: detect-secrets || {
|
||||
echo "❌ detect-secrets install failed after retry"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
detect-secrets --version
|
||||
|
||||
detect-secrets scan \
|
||||
@@ -56,23 +62,9 @@ for fpath, items in data.get('results', {}).items():
|
||||
fi
|
||||
echo "✅ Secret scan passed"
|
||||
|
||||
# --- 代码质量检查(全量,PR 和 push 统一标准)---
|
||||
# 历史:PR 侧用增量检查以加速,但会导致 push 侧全量检查失败时 PR 侧感知不到
|
||||
# 现在统一全量检查,确保 CI 真正保护主分支(black/isort/ruff 全量仅多几十秒)
|
||||
# --- Bandit 安全扫描(仅告警)---
|
||||
echo ""
|
||||
echo "=== [2/6] Code quality checks (full scan) ==="
|
||||
SCAN_MODE="full"
|
||||
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
|
||||
|
||||
echo "✅ Code quality checks passed"
|
||||
|
||||
# --- Bandit 安全扫描(仅告警) ---
|
||||
echo ""
|
||||
echo "=== [3/6] Security scan (bandit, advisory only) ==="
|
||||
echo "=== [2/4] Security scan (bandit, advisory only) ==="
|
||||
set +e
|
||||
bandit -r apps packages -q -ll
|
||||
BANDIT_EXIT=$?
|
||||
@@ -83,42 +75,34 @@ else
|
||||
echo "✅ Bandit security scan passed"
|
||||
fi
|
||||
|
||||
# --- Pip-audit 依赖漏洞扫描(仅告警) ---
|
||||
# --- 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
|
||||
echo "=== [3/4] Python dependency vulnerability scan (pip-audit, advisory only) ==="
|
||||
python3 -m pip install -q --no-cache-dir pip-audit || {
|
||||
echo "⚠️ pip-audit install failed (cache issue?), retrying..."
|
||||
python3 -m pip install -q --no-cache-dir pip-audit || {
|
||||
echo "⚠️ pip-audit unavailable, skipping dependency vulnerability scan (advisory)"
|
||||
pip-audit --version 2>/dev/null || true
|
||||
}
|
||||
}
|
||||
if command -v pip-audit >/dev/null 2>&1 || python3 -m pip show pip-audit >/dev/null 2>&1; then
|
||||
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)"
|
||||
else
|
||||
echo "⚠️ pip-audit not available, skipping dependency vulnerability scan (advisory)"
|
||||
fi
|
||||
|
||||
# --- CI脚本语法校验 ---
|
||||
echo ""
|
||||
echo "=== [6/6] CI & shell scripts syntax validation ==="
|
||||
echo "=== [4/4] CI & shell scripts syntax validation ==="
|
||||
SYNTAX_ERROR=0
|
||||
# 检查所有 CI shell 脚本
|
||||
for script in scripts/ci/*.sh; do
|
||||
@@ -154,4 +138,4 @@ fi
|
||||
echo "✅ All CI scripts syntax OK"
|
||||
|
||||
echo ""
|
||||
echo "=== CI Validate: 代码质量与安全扫描 全部通过 ✅ ==="
|
||||
echo "=== CI Validate: 安全扫描 全部通过 ✅ ==="
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
# CI Validate: 代码风格检查(validate-style)
|
||||
# 包含:Python 字节码编译、black 格式、isort 排序、ruff lint、vulture 死代码(仅告警)
|
||||
set -eu
|
||||
|
||||
echo "=== CI Validate: 代码风格检查 ==="
|
||||
|
||||
# --- Python 字节码编译 ---
|
||||
echo ""
|
||||
echo "=== [1/3] Python bytecode compilation ==="
|
||||
python3 -m compileall -q alembic apps packages tests scripts
|
||||
echo "✅ Bytecode compilation passed"
|
||||
|
||||
# --- 代码格式检查(全量)---
|
||||
echo ""
|
||||
echo "=== [2/3] Code formatting (black + isort + ruff) ==="
|
||||
echo "Full scan mode"
|
||||
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
|
||||
echo "✅ Code formatting checks passed"
|
||||
|
||||
# --- Vulture 死代码检测(仅告警)---
|
||||
echo ""
|
||||
echo "=== [3/3] Dead code detection (vulture, advisory only) ==="
|
||||
set +e
|
||||
python3 -m pip install -q --no-cache-dir vulture || echo "⚠️ vulture install failed, skipping dead code detection"
|
||||
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
|
||||
|
||||
echo ""
|
||||
echo "=== CI Validate: 代码风格检查 全部通过 ✅ ==="
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Tests for duplicate_rate computation and API response."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Mock cv2 and numpy before any imports that need them
|
||||
sys.modules.setdefault("cv2", MagicMock())
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "apps" / "api"))
|
||||
sys.path.insert(0, str(ROOT / "packages"))
|
||||
sys.path.insert(0, str(ROOT / "apps" / "worker"))
|
||||
|
||||
|
||||
class TestComputeDuplicateRate:
|
||||
"""Test VideoDeduplicator.compute_duplicate_rate."""
|
||||
|
||||
def _make_fingerprint(self, md5="abc123", phashes=None):
|
||||
from video_processing.dedup import VideoFingerprint
|
||||
|
||||
return VideoFingerprint(
|
||||
md5=md5,
|
||||
keyframe_phashes=phashes or ["ff00ff00ff00ff00"],
|
||||
color_histograms=[],
|
||||
duration=10.0,
|
||||
resolution=(1920, 1080),
|
||||
)
|
||||
|
||||
def _make_existing_video(self, vid, fingerprint_dict):
|
||||
from packages.domain import GeneratedVideo
|
||||
|
||||
return GeneratedVideo(
|
||||
id=vid,
|
||||
project_id="proj1",
|
||||
generation_task_id="task1",
|
||||
name=f"video-{vid}",
|
||||
file_url=f"https://example.com/{vid}.mp4",
|
||||
file_size=1000,
|
||||
duration=10.0,
|
||||
width=1920,
|
||||
height=1080,
|
||||
fps=25.0,
|
||||
video_fingerprint=fingerprint_dict,
|
||||
)
|
||||
|
||||
def test_no_existing_videos_returns_zero(self):
|
||||
from video_processing.dedup import VideoDeduplicator
|
||||
|
||||
deduplicator = VideoDeduplicator()
|
||||
fingerprint = self._make_fingerprint()
|
||||
session = MagicMock()
|
||||
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
mock_repo = MockRepo.return_value
|
||||
session.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = (
|
||||
[]
|
||||
)
|
||||
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
|
||||
|
||||
assert rate == 0.0
|
||||
|
||||
def test_md5_match_returns_100(self):
|
||||
from video_processing.dedup import VideoDeduplicator
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
deduplicator = VideoDeduplicator()
|
||||
fingerprint = self._make_fingerprint(md5="exact_match_md5")
|
||||
session = MagicMock()
|
||||
|
||||
existing = self._make_existing_video("existing1", {"md5": "exact_match_md5", "keyframe_phashes": ["aa"]})
|
||||
# Create a mock model with the domain attributes
|
||||
mock_model = MagicMock(spec=GeneratedVideoModel)
|
||||
mock_model.id = existing.id
|
||||
mock_model.project_id = existing.project_id
|
||||
mock_model.video_fingerprint = existing.video_fingerprint
|
||||
mock_model.generated_at = "2026-01-01"
|
||||
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
mock_repo = MockRepo.return_value
|
||||
mock_repo._to_domain.return_value = existing
|
||||
# Mock the session.query chain
|
||||
session.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [
|
||||
mock_model
|
||||
]
|
||||
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
|
||||
|
||||
assert rate == 100.0
|
||||
|
||||
def test_phash_similarity_computed(self):
|
||||
from video_processing.dedup import VideoDeduplicator
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
deduplicator = VideoDeduplicator()
|
||||
fingerprint = self._make_fingerprint(md5="different_md5", phashes=["ff00ff00ff00ff00"])
|
||||
session = MagicMock()
|
||||
|
||||
existing = self._make_existing_video(
|
||||
"existing1",
|
||||
{"md5": "other_md5", "keyframe_phashes": ["ff00ff00ff00ff03"]},
|
||||
)
|
||||
mock_model = MagicMock(spec=GeneratedVideoModel)
|
||||
mock_model.id = existing.id
|
||||
mock_model.project_id = existing.project_id
|
||||
mock_model.video_fingerprint = existing.video_fingerprint
|
||||
mock_model.generated_at = "2026-01-01"
|
||||
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
mock_repo = MockRepo.return_value
|
||||
mock_repo._to_domain.return_value = existing
|
||||
session.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [
|
||||
mock_model
|
||||
]
|
||||
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
|
||||
|
||||
# hamming distance = 2, similarity = (1 - 2/64) * 100 = 96.875
|
||||
assert rate == pytest.approx(96.88, abs=0.1)
|
||||
|
||||
def test_excludes_self_video(self):
|
||||
from video_processing.dedup import VideoDeduplicator
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
deduplicator = VideoDeduplicator()
|
||||
fingerprint = self._make_fingerprint(md5="same_md5")
|
||||
session = MagicMock()
|
||||
|
||||
self_video = self._make_existing_video("vid1", {"md5": "same_md5", "keyframe_phashes": ["aa"]})
|
||||
mock_model = MagicMock(spec=GeneratedVideoModel)
|
||||
mock_model.id = self_video.id
|
||||
mock_model.project_id = self_video.project_id
|
||||
mock_model.video_fingerprint = self_video.video_fingerprint
|
||||
mock_model.generated_at = "2026-01-01"
|
||||
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
mock_repo = MockRepo.return_value
|
||||
mock_repo._to_domain.return_value = self_video
|
||||
session.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [
|
||||
mock_model
|
||||
]
|
||||
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
|
||||
|
||||
assert rate == 0.0
|
||||
|
||||
def test_takes_max_similarity(self):
|
||||
from video_processing.dedup import VideoDeduplicator
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
deduplicator = VideoDeduplicator()
|
||||
fingerprint = self._make_fingerprint(md5="new_md5", phashes=["ff00ff00ff00ff00"])
|
||||
session = MagicMock()
|
||||
|
||||
existing1 = self._make_existing_video("e1", {"md5": "md5_1", "keyframe_phashes": ["ff00ff00ff00ff0f"]})
|
||||
existing2 = self._make_existing_video("e2", {"md5": "md5_2", "keyframe_phashes": ["ff00ff00ff00ff01"]})
|
||||
mock_model1 = MagicMock(spec=GeneratedVideoModel)
|
||||
mock_model1.id = existing1.id
|
||||
mock_model1.project_id = existing1.project_id
|
||||
mock_model1.video_fingerprint = existing1.video_fingerprint
|
||||
mock_model1.generated_at = "2026-01-02"
|
||||
mock_model2 = MagicMock(spec=GeneratedVideoModel)
|
||||
mock_model2.id = existing2.id
|
||||
mock_model2.project_id = existing2.project_id
|
||||
mock_model2.video_fingerprint = existing2.video_fingerprint
|
||||
mock_model2.generated_at = "2026-01-01"
|
||||
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
mock_repo = MockRepo.return_value
|
||||
mock_repo._to_domain.side_effect = [existing1, existing2]
|
||||
session.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [
|
||||
mock_model1,
|
||||
mock_model2,
|
||||
]
|
||||
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
|
||||
|
||||
# max similarity: e2 distance=1, (1-1/64)*100 = 98.4375
|
||||
assert rate == pytest.approx(98.44, abs=0.1)
|
||||
|
||||
|
||||
class TestDuplicateRateAPI:
|
||||
"""Test that duplicate_rate is returned in API responses."""
|
||||
|
||||
def test_video_item_response_has_duplicate_rate(self):
|
||||
from app.schemas.video_center import VideoItemResponse
|
||||
|
||||
resp = VideoItemResponse(
|
||||
id="v1",
|
||||
project_id="p1",
|
||||
generation_task_id="t1",
|
||||
name="test.mp4",
|
||||
file_url="https://example.com/test.mp4",
|
||||
file_size=1000,
|
||||
duration=10.0,
|
||||
width=1920,
|
||||
height=1080,
|
||||
fps=25.0,
|
||||
duplicate_rate=75.5,
|
||||
)
|
||||
assert resp.duplicate_rate == 75.5
|
||||
|
||||
def test_video_item_response_duplicate_rate_default_none(self):
|
||||
from app.schemas.video_center import VideoItemResponse
|
||||
|
||||
resp = VideoItemResponse(
|
||||
id="v1",
|
||||
project_id="p1",
|
||||
generation_task_id="t1",
|
||||
name="test.mp4",
|
||||
file_url="https://example.com/test.mp4",
|
||||
file_size=1000,
|
||||
duration=10.0,
|
||||
width=1920,
|
||||
height=1080,
|
||||
fps=25.0,
|
||||
)
|
||||
assert resp.duplicate_rate is None
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Tests for get_asset_recent_use_counts and smart-match high-use exclusion."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "apps" / "api"))
|
||||
sys.path.insert(0, str(ROOT / "packages"))
|
||||
|
||||
|
||||
class TestGetAssetRecentUseCounts:
|
||||
"""Test asset_segment_tracker.get_asset_recent_use_counts."""
|
||||
|
||||
def _make_asset_model(self, asset_id, used_time_ranges=None):
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
model = AssetModel(
|
||||
id=asset_id,
|
||||
name=f"asset-{asset_id}",
|
||||
file_type="video",
|
||||
status="ready",
|
||||
asset_library_id="lib1",
|
||||
project_id="proj1",
|
||||
file_size=1000,
|
||||
file_url=f"https://example.com/{asset_id}.mp4",
|
||||
uploaded_by_user_id="user1",
|
||||
)
|
||||
model.classification_result = json.dumps(
|
||||
{
|
||||
"used_time_ranges": used_time_ranges or [],
|
||||
}
|
||||
)
|
||||
return model
|
||||
|
||||
def test_empty_asset_ids_returns_empty(self):
|
||||
from app.services.asset_segment_tracker import get_asset_recent_use_counts
|
||||
|
||||
db = MagicMock()
|
||||
result = get_asset_recent_use_counts(db, [])
|
||||
assert result == {}
|
||||
|
||||
def test_no_usage_returns_zero(self):
|
||||
from app.services.asset_segment_tracker import get_asset_recent_use_counts
|
||||
|
||||
db = MagicMock()
|
||||
model = self._make_asset_model("a1", [])
|
||||
db.query.return_value.filter.return_value.all.return_value = [model]
|
||||
|
||||
result = get_asset_recent_use_counts(db, ["a1"])
|
||||
assert result == {"a1": 0}
|
||||
|
||||
def test_counts_distinct_plan_ids(self):
|
||||
from app.services.asset_segment_tracker import get_asset_recent_use_counts
|
||||
|
||||
db = MagicMock()
|
||||
ranges = [
|
||||
{"start": 0, "end": 5, "plan_id": "plan1", "created_at": "2026-08-01T00:00:00"},
|
||||
{"start": 5, "end": 10, "plan_id": "plan1", "created_at": "2026-08-01T00:01:00"},
|
||||
{"start": 0, "end": 5, "plan_id": "plan2", "created_at": "2026-08-02T00:00:00"},
|
||||
{"start": 0, "end": 5, "plan_id": "plan3", "created_at": "2026-08-03T00:00:00"},
|
||||
]
|
||||
model = self._make_asset_model("a1", ranges)
|
||||
db.query.return_value.filter.return_value.all.return_value = [model]
|
||||
|
||||
result = get_asset_recent_use_counts(db, ["a1"])
|
||||
assert result == {"a1": 3}
|
||||
|
||||
def test_limits_to_recent_n(self):
|
||||
from app.services.asset_segment_tracker import get_asset_recent_use_counts
|
||||
|
||||
db = MagicMock()
|
||||
ranges = [
|
||||
{"start": 0, "end": 5, "plan_id": f"plan{i}", "created_at": f"2026-08-{i+1:02d}T00:00:00"}
|
||||
for i in range(10)
|
||||
]
|
||||
model = self._make_asset_model("a1", ranges)
|
||||
db.query.return_value.filter.return_value.all.return_value = [model]
|
||||
|
||||
result = get_asset_recent_use_counts(db, ["a1"], recent_video_count=5)
|
||||
assert result["a1"] == 5
|
||||
|
||||
def test_missing_asset_defaults_to_zero(self):
|
||||
from app.services.asset_segment_tracker import get_asset_recent_use_counts
|
||||
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.all.return_value = []
|
||||
|
||||
result = get_asset_recent_use_counts(db, ["missing_asset"])
|
||||
assert result == {"missing_asset": 0}
|
||||
@@ -0,0 +1,209 @@
|
||||
"""统一模板 segments 数据源单元测试。
|
||||
|
||||
验证 template_repository 从 template_clip_configs 读取 segments,
|
||||
写入走 template_clip_configs,回退兼容 template_segments。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
Base,
|
||||
TemplateClipConfigModel,
|
||||
TemplateModel,
|
||||
TemplateSegmentModel,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
from packages.domain.template import Template, TemplateSegment
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
s = Session()
|
||||
try:
|
||||
yield s
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def repo(session):
|
||||
return SQLAlchemyTemplateRepository(session)
|
||||
|
||||
|
||||
def _make_template(template_id=None, user_id="u1", name="测试模板", mode="one_take"):
|
||||
tid = template_id or str(uuid.uuid4())
|
||||
return Template(
|
||||
id=tid,
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
mode=mode,
|
||||
category="",
|
||||
tags=[],
|
||||
estimated_duration=30.0,
|
||||
is_active=True,
|
||||
segments=[],
|
||||
)
|
||||
|
||||
|
||||
def _make_segment(template_id, order=1, material_type=None):
|
||||
return TemplateSegment(
|
||||
id=str(uuid.uuid4()),
|
||||
template_id=template_id,
|
||||
segment_order=order,
|
||||
duration_min=5.0,
|
||||
duration_max=10.0,
|
||||
material_type=material_type,
|
||||
)
|
||||
|
||||
|
||||
class TestCreateSegments:
|
||||
def test_writes_to_clip_configs(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
seg = _make_segment(tpl.id, order=1)
|
||||
repo.create_segments([seg])
|
||||
clips = session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.template_id == tpl.id).all()
|
||||
assert len(clips) == 1
|
||||
assert clips[0].clip_type == "main"
|
||||
assert clips[0].order == 1
|
||||
assert clips[0].min_duration == 5.0
|
||||
|
||||
def test_material_type_stored_in_config(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
seg = _make_segment(tpl.id, order=1, material_type="voiceover")
|
||||
repo.create_segments([seg])
|
||||
clip = session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.template_id == tpl.id).first()
|
||||
assert clip.config["material_type"] == "voiceover"
|
||||
|
||||
|
||||
class TestListSegments:
|
||||
def test_reads_from_clip_configs(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
seg = _make_segment(tpl.id, order=1, material_type="voiceover")
|
||||
repo.create_segments([seg])
|
||||
result = repo.list_segments(tpl.id)
|
||||
assert len(result) == 1
|
||||
assert result[0].material_type == "voiceover"
|
||||
|
||||
def test_fallback_to_old_table(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
old = TemplateSegmentModel(
|
||||
id=str(uuid.uuid4()),
|
||||
template_id=tpl.id,
|
||||
segment_order=1,
|
||||
duration_min=3.0,
|
||||
duration_max=8.0,
|
||||
material_type="场景",
|
||||
)
|
||||
session.add(old)
|
||||
session.commit()
|
||||
result = repo.list_segments(tpl.id)
|
||||
assert len(result) == 1
|
||||
assert result[0].material_type == "场景"
|
||||
|
||||
def test_clip_configs_takes_priority(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
seg = _make_segment(tpl.id, order=1)
|
||||
repo.create_segments([seg])
|
||||
old = TemplateSegmentModel(
|
||||
id=str(uuid.uuid4()), template_id=tpl.id, segment_order=1, duration_min=1.0, duration_max=2.0
|
||||
)
|
||||
session.add(old)
|
||||
session.commit()
|
||||
result = repo.list_segments(tpl.id)
|
||||
assert len(result) == 1
|
||||
assert result[0].duration_min == 5.0
|
||||
|
||||
|
||||
class TestListByUser:
|
||||
def test_batch_loads_from_clip_configs(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
seg = _make_segment(tpl.id, order=1, material_type="人物")
|
||||
repo.create_segments([seg])
|
||||
result = repo.list_by_user("u1")
|
||||
assert len(result) == 1
|
||||
assert len(result[0].segments) == 1
|
||||
assert result[0].segments[0].material_type == "人物"
|
||||
|
||||
def test_fallback_for_old_data(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
old = TemplateSegmentModel(
|
||||
id=str(uuid.uuid4()), template_id=tpl.id, segment_order=1, duration_min=2.0, duration_max=6.0
|
||||
)
|
||||
session.add(old)
|
||||
session.commit()
|
||||
result = repo.list_by_user("u1")
|
||||
assert len(result) == 1
|
||||
assert len(result[0].segments) == 1
|
||||
assert result[0].segments[0].duration_min == 2.0
|
||||
|
||||
|
||||
class TestCopyTemplate:
|
||||
def test_copy_writes_to_clip_configs(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
seg = _make_segment(tpl.id, order=1, material_type="voiceover")
|
||||
repo.create_segments([seg])
|
||||
copied = repo.copy_template(tpl.id, "u1", "副本模板")
|
||||
assert copied.id != tpl.id
|
||||
clips = session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.template_id == copied.id).all()
|
||||
assert len(clips) == 1
|
||||
assert clips[0].config["material_type"] == "voiceover"
|
||||
|
||||
def test_copy_empty_segments(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
copied = repo.copy_template(tpl.id, "u1", "空副本")
|
||||
assert len(copied.segments) == 0
|
||||
|
||||
|
||||
class TestDelete:
|
||||
def test_delete_cleans_both_tables(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
seg = _make_segment(tpl.id, order=1)
|
||||
repo.create_segments([seg])
|
||||
old = TemplateSegmentModel(
|
||||
id=str(uuid.uuid4()), template_id=tpl.id, segment_order=1, duration_min=1.0, duration_max=2.0
|
||||
)
|
||||
session.add(old)
|
||||
session.commit()
|
||||
repo.delete(tpl.id, "u1")
|
||||
c1 = session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.template_id == tpl.id).count()
|
||||
c2 = session.query(TemplateSegmentModel).filter(TemplateSegmentModel.template_id == tpl.id).count()
|
||||
assert c1 == 0
|
||||
assert c2 == 0
|
||||
|
||||
def test_delete_segments_by_template(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
seg = _make_segment(tpl.id, order=1)
|
||||
repo.create_segments([seg])
|
||||
old = TemplateSegmentModel(
|
||||
id=str(uuid.uuid4()), template_id=tpl.id, segment_order=2, duration_min=1.0, duration_max=2.0
|
||||
)
|
||||
session.add(old)
|
||||
session.commit()
|
||||
count = repo.delete_segments_by_template(tpl.id)
|
||||
assert count == 2
|
||||
Reference in New Issue
Block a user