diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml
new file mode 100644
index 0000000..15c9bf6
--- /dev/null
+++ b/.github/workflows/ci-core.yml
@@ -0,0 +1,560 @@
+name: CI Core
+
+on:
+ workflow_call:
+ inputs:
+ pr_number:
+ description: Pull request number used for concurrency and artifacts
+ required: true
+ type: string
+ base_sha:
+ description: Base commit used to calculate changed paths
+ required: true
+ type: string
+ head_sha:
+ description: Pull request head commit
+ required: true
+ type: string
+ merge_sha:
+ description: GitHub-generated merge commit to test
+ required: true
+ type: string
+ publish_status:
+ description: Publish CI / required from the trusted main refresh path
+ required: false
+ type: boolean
+ default: false
+
+concurrency:
+ group: ci-pr-${{ inputs.pr_number }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ changes:
+ name: 识别变更范围
+ runs-on: ubuntu-latest
+ outputs:
+ backend: ${{ steps.paths.outputs.backend }}
+ frontend: ${{ steps.paths.outputs.frontend }}
+ docker_backend: ${{ steps.paths.outputs.docker_backend }}
+ docker_frontend: ${{ steps.paths.outputs.docker_frontend }}
+ compose: ${{ steps.paths.outputs.compose }}
+ nginx: ${{ steps.paths.outputs.nginx }}
+ dependencies: ${{ steps.paths.outputs.dependencies }}
+ steps:
+ - name: 检出待测合并提交
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ ref: ${{ inputs.merge_sha }}
+ fetch-depth: 0
+
+ - name: 计算条件任务
+ id: paths
+ env:
+ BASE_SHA: ${{ inputs.base_sha }}
+ HEAD_SHA: ${{ inputs.head_sha }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ git diff --name-only "$BASE_SHA" "$HEAD_SHA" > "$RUNNER_TEMP/changed-files"
+
+ backend=false
+ frontend=false
+ docker_backend=false
+ docker_frontend=false
+ compose=false
+ nginx=false
+ dependencies=false
+ force_all=false
+
+ while IFS= read -r file; do
+ case "$file" in
+ .github/workflows/*)
+ force_all=true
+ ;;
+ backend/*)
+ backend=true
+ docker_backend=true
+ ;;
+ frontend/Unispeaking_fronted/*)
+ frontend=true
+ docker_frontend=true
+ ;;
+ esac
+ case "$file" in
+ backend/unispeaking-server/pom.xml|backend/unispeaking-server/.mvn/*|backend/unispeaking-server/mvnw)
+ dependencies=true
+ ;;
+ frontend/Unispeaking_fronted/package.json|frontend/Unispeaking_fronted/package-lock.json)
+ dependencies=true
+ ;;
+ deploy/docker-compose.yml|deploy/docker-compose.yaml|deploy/compose.yml|deploy/compose.yaml|deploy/env/.env.example)
+ compose=true
+ ;;
+ deploy/nginx/*)
+ nginx=true
+ ;;
+ esac
+ done < "$RUNNER_TEMP/changed-files"
+
+ if [[ "$force_all" == true ]]; then
+ backend=true
+ frontend=true
+ docker_backend=true
+ docker_frontend=true
+ compose=true
+ nginx=true
+ dependencies=true
+ fi
+
+ {
+ echo "backend=$backend"
+ echo "frontend=$frontend"
+ echo "docker_backend=$docker_backend"
+ echo "docker_frontend=$docker_frontend"
+ echo "compose=$compose"
+ echo "nginx=$nginx"
+ echo "dependencies=$dependencies"
+ } >> "$GITHUB_OUTPUT"
+
+ - name: 记录本次待测对象
+ env:
+ PR_NUMBER: ${{ inputs.pr_number }}
+ BASE_SHA: ${{ inputs.base_sha }}
+ HEAD_SHA: ${{ inputs.head_sha }}
+ MERGE_SHA: ${{ inputs.merge_sha }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir -p "$RUNNER_TEMP/ci-metadata"
+ jq -n \
+ --arg pr_number "$PR_NUMBER" \
+ --arg base_sha "$BASE_SHA" \
+ --arg head_sha "$HEAD_SHA" \
+ --arg merge_sha "$MERGE_SHA" \
+ '{
+ pr_number: $pr_number,
+ base_sha: $base_sha,
+ head_sha: $head_sha,
+ merge_sha: $merge_sha
+ }' > "$RUNNER_TEMP/ci-metadata/metadata.json"
+
+ - name: 保存待测对象元数据
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: ci-metadata-${{ inputs.pr_number }}-${{ github.run_id }}
+ path: ${{ runner.temp }}/ci-metadata/metadata.json
+ if-no-files-found: error
+ retention-days: 1
+
+ backend:
+ name: 后端编译、单测与打包
+ needs: changes
+ if: needs.changes.outputs.backend == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - name: 检出待测合并提交
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ ref: ${{ inputs.merge_sha }}
+
+ - name: 配置 Java 21
+ uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
+ with:
+ distribution: temurin
+ java-version: "21"
+ cache: maven
+ cache-dependency-path: backend/unispeaking-server/pom.xml
+
+ - name: 编译、测试并打包
+ working-directory: backend/unispeaking-server
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir -p ci-logs
+ ./mvnw --batch-mode --no-transfer-progress clean verify 2>&1 \
+ | tee ci-logs/backend-unit.log
+
+ - name: 保存单元测试与覆盖率报告
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: backend-unit-reports-${{ inputs.pr_number }}
+ path: |
+ backend/unispeaking-server/target/surefire-reports
+ backend/unispeaking-server/target/site/jacoco-unit
+ backend/unispeaking-server/ci-logs/backend-unit.log
+ if-no-files-found: error
+ retention-days: 30
+
+ - name: 传递单元测试覆盖率数据
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: backend-unit-coverage-${{ inputs.pr_number }}
+ path: |
+ backend/unispeaking-server/target/jacoco-unit.exec
+ backend/unispeaking-server/target/classes
+ if-no-files-found: error
+ retention-days: 1
+
+ integration:
+ name: PostgreSQL 与 Redis 集成测试
+ needs: changes
+ if: needs.changes.outputs.backend == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - name: 检出待测合并提交
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ ref: ${{ inputs.merge_sha }}
+
+ - name: 配置 Java 21
+ uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
+ with:
+ distribution: temurin
+ java-version: "21"
+ cache: maven
+ cache-dependency-path: backend/unispeaking-server/pom.xml
+
+ - name: 运行容器集成测试
+ working-directory: backend/unispeaking-server
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir -p ci-logs
+ ./mvnw --batch-mode --no-transfer-progress \
+ -Pci-integration -DskipUnitTests verify 2>&1 \
+ | tee ci-logs/backend-integration.log
+
+ - name: 保存集成测试报告
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: backend-integration-reports-${{ inputs.pr_number }}
+ path: |
+ backend/unispeaking-server/target/failsafe-reports
+ backend/unispeaking-server/target/site/jacoco-integration
+ backend/unispeaking-server/ci-logs/backend-integration.log
+ if-no-files-found: error
+ retention-days: 30
+
+ - name: 传递集成测试覆盖率数据
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: backend-integration-coverage-${{ inputs.pr_number }}
+ path: backend/unispeaking-server/target/jacoco-integration.exec
+ if-no-files-found: error
+ retention-days: 1
+
+ coverage:
+ name: 合并覆盖率并检查 70% 门槛
+ needs:
+ - changes
+ - backend
+ - integration
+ if: >-
+ always() &&
+ needs.changes.outputs.backend == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: 确认前置任务成功
+ if: needs.backend.result != 'success' || needs.integration.result != 'success'
+ run: exit 1
+
+ - name: 检出待测合并提交
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ ref: ${{ inputs.merge_sha }}
+
+ - name: 配置 Java 21
+ uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
+ with:
+ distribution: temurin
+ java-version: "21"
+ cache: maven
+ cache-dependency-path: backend/unispeaking-server/pom.xml
+
+ - name: 下载单元测试覆盖率数据
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
+ with:
+ name: backend-unit-coverage-${{ inputs.pr_number }}
+ path: backend/unispeaking-server/target
+
+ - name: 下载集成测试覆盖率数据
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
+ with:
+ name: backend-integration-coverage-${{ inputs.pr_number }}
+ path: backend/unispeaking-server/target
+
+ - name: 合并报告并执行覆盖率门禁
+ working-directory: backend/unispeaking-server
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir -p ci-logs
+ ./mvnw --batch-mode --no-transfer-progress \
+ -Pcoverage-aggregate \
+ -DskipUnitTests \
+ -DskipIntegrationTests \
+ verify 2>&1 | tee ci-logs/backend-coverage.log
+
+ - name: 保存合并覆盖率报告
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: backend-coverage-${{ inputs.pr_number }}
+ path: |
+ backend/unispeaking-server/target/site/jacoco-aggregate
+ backend/unispeaking-server/ci-logs/backend-coverage.log
+ if-no-files-found: error
+ retention-days: 30
+
+ frontend:
+ name: 前端依赖与生产构建
+ needs: changes
+ if: needs.changes.outputs.frontend == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ defaults:
+ run:
+ working-directory: frontend/Unispeaking_fronted
+ steps:
+ - name: 检出待测合并提交
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ ref: ${{ inputs.merge_sha }}
+
+ - name: 配置 Node.js 22
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: "22"
+ cache: npm
+ cache-dependency-path: frontend/Unispeaking_fronted/package-lock.json
+
+ - name: 安装锁定依赖
+ run: npm ci
+
+ - name: 检查路由
+ run: npm run check:routes
+
+ - name: 检查 Realtime 事件
+ run: npm run check:realtime-events
+
+ - name: 生产构建
+ run: npm run build
+
+ docker_backend:
+ name: 构建后端镜像
+ needs: changes
+ if: needs.changes.outputs.docker_backend == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - name: 检出待测合并提交
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ ref: ${{ inputs.merge_sha }}
+
+ - name: 验证后端镜像构建
+ run: >-
+ docker build
+ --file backend/unispeaking-server/Dockerfile
+ --tag unispeaking-backend:ci
+ backend/unispeaking-server
+
+ docker_frontend:
+ name: 构建前端镜像
+ needs: changes
+ if: needs.changes.outputs.docker_frontend == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - name: 检出待测合并提交
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ ref: ${{ inputs.merge_sha }}
+
+ - name: 验证前端镜像构建
+ run: >-
+ docker build
+ --file frontend/Unispeaking_fronted/Dockerfile
+ --tag unispeaking-frontend:ci
+ frontend/Unispeaking_fronted
+
+ compose:
+ name: 校验 Compose 配置
+ needs: changes
+ if: needs.changes.outputs.compose == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: 检出待测合并提交
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ ref: ${{ inputs.merge_sha }}
+
+ - name: 准备 CI 环境模板
+ run: cp deploy/env/.env.example deploy/env/.env
+
+ - name: 校验配置
+ run: docker compose --env-file deploy/env/.env.example -f deploy/docker-compose.yml config --quiet
+
+ nginx:
+ name: 校验 Nginx 配置
+ needs: changes
+ if: needs.changes.outputs.nginx == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: 检出待测合并提交
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ ref: ${{ inputs.merge_sha }}
+
+ - name: 执行 nginx -t
+ run: >-
+ docker run --rm
+ --add-host backend:127.0.0.1
+ --add-host frontend:127.0.0.1
+ --volume "$PWD/deploy/nginx/nginx.conf:/etc/nginx/nginx.conf:ro"
+ nginx:1.27-alpine
+ nginx -t
+
+ dependency_review:
+ name: 审查新增依赖漏洞
+ needs: changes
+ if: needs.changes.outputs.dependencies == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ steps:
+ - name: 审查依赖差异
+ uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
+ with:
+ base-ref: ${{ inputs.base_sha }}
+ head-ref: ${{ inputs.head_sha }}
+ fail-on-severity: high
+ comment-summary-in-pr: never
+
+ required:
+ name: 核心检查汇总
+ if: always()
+ needs:
+ - changes
+ - backend
+ - integration
+ - coverage
+ - frontend
+ - docker_backend
+ - docker_frontend
+ - compose
+ - nginx
+ - dependency_review
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ statuses: write
+ steps:
+ - name: 汇总所有必要任务
+ id: summary
+ continue-on-error: true
+ env:
+ CHANGES_RESULT: ${{ needs.changes.result }}
+ BACKEND_RESULT: ${{ needs.backend.result }}
+ INTEGRATION_RESULT: ${{ needs.integration.result }}
+ COVERAGE_RESULT: ${{ needs.coverage.result }}
+ FRONTEND_RESULT: ${{ needs.frontend.result }}
+ DOCKER_BACKEND_RESULT: ${{ needs.docker_backend.result }}
+ DOCKER_FRONTEND_RESULT: ${{ needs.docker_frontend.result }}
+ COMPOSE_RESULT: ${{ needs.compose.result }}
+ NGINX_RESULT: ${{ needs.nginx.result }}
+ DEPENDENCY_RESULT: ${{ needs.dependency_review.result }}
+ BACKEND_REQUIRED: ${{ needs.changes.outputs.backend }}
+ FRONTEND_REQUIRED: ${{ needs.changes.outputs.frontend }}
+ DOCKER_BACKEND_REQUIRED: ${{ needs.changes.outputs.docker_backend }}
+ DOCKER_FRONTEND_REQUIRED: ${{ needs.changes.outputs.docker_frontend }}
+ COMPOSE_REQUIRED: ${{ needs.changes.outputs.compose }}
+ NGINX_REQUIRED: ${{ needs.changes.outputs.nginx }}
+ DEPENDENCY_REQUIRED: ${{ needs.changes.outputs.dependencies }}
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ if [[ "$CHANGES_RESULT" != success ]]; then
+ echo "变更识别任务未成功:$CHANGES_RESULT"
+ exit 1
+ fi
+
+ require_job() {
+ local name="$1"
+ local result="$2"
+ local required="$3"
+ if [[ "$required" == true && "$result" != success ]]; then
+ echo "$name 为必要任务,但结果为 $result"
+ return 1
+ fi
+ if [[ "$required" != true && "$result" != skipped && "$result" != success ]]; then
+ echo "$name 非必要任务,但出现异常结果 $result"
+ return 1
+ fi
+ }
+
+ require_job backend "$BACKEND_RESULT" "$BACKEND_REQUIRED"
+ require_job integration "$INTEGRATION_RESULT" "$BACKEND_REQUIRED"
+ require_job coverage "$COVERAGE_RESULT" "$BACKEND_REQUIRED"
+ require_job frontend "$FRONTEND_RESULT" "$FRONTEND_REQUIRED"
+ require_job docker_backend "$DOCKER_BACKEND_RESULT" "$DOCKER_BACKEND_REQUIRED"
+ require_job docker_frontend "$DOCKER_FRONTEND_RESULT" "$DOCKER_FRONTEND_REQUIRED"
+ require_job compose "$COMPOSE_RESULT" "$COMPOSE_REQUIRED"
+ require_job nginx "$NGINX_RESULT" "$NGINX_REQUIRED"
+ require_job dependency_review "$DEPENDENCY_RESULT" "$DEPENDENCY_REQUIRED"
+
+ - name: 发布 main 更新重检状态
+ if: always() && inputs.publish_status
+ env:
+ GH_TOKEN: ${{ github.token }}
+ MERGE_SHA: ${{ inputs.merge_sha }}
+ SUMMARY_OUTCOME: ${{ steps.summary.outcome }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ state=failure
+ description="UniSpeaking 必要 CI 检查未通过"
+ if [[ "$SUMMARY_OUTCOME" == success ]]; then
+ state=success
+ description="UniSpeaking 必要 CI 检查已通过"
+ fi
+
+ jq -n \
+ --arg state "$state" \
+ --arg context "CI / required" \
+ --arg description "$description" \
+ --arg target_url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
+ '{
+ state: $state,
+ context: $context,
+ description: $description,
+ target_url: $target_url
+ }' > "$RUNNER_TEMP/status.json"
+
+ curl --fail-with-body --silent --show-error \
+ --request POST \
+ --header "Authorization: Bearer $GH_TOKEN" \
+ --header "Accept: application/vnd.github+json" \
+ --header "X-GitHub-Api-Version: 2022-11-28" \
+ --data-binary "@$RUNNER_TEMP/status.json" \
+ "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/statuses/$MERGE_SHA"
+
+ - name: 执行最终门禁
+ if: always() && steps.summary.outcome != 'success'
+ run: exit 1
diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml
new file mode 100644
index 0000000..bc0852a
--- /dev/null
+++ b/.github/workflows/ci-pr.yml
@@ -0,0 +1,26 @@
+name: CI
+
+on:
+ pull_request:
+ branches:
+ - main
+ types:
+ - opened
+ - reopened
+ - synchronize
+ - ready_for_review
+
+permissions:
+ contents: read
+ pull-requests: read
+
+jobs:
+ core:
+ name: PR 核心检查
+ if: github.event.pull_request.draft == false
+ uses: ./.github/workflows/ci-core.yml
+ with:
+ pr_number: ${{ format('{0}', github.event.pull_request.number) }}
+ base_sha: ${{ github.event.pull_request.base.sha }}
+ head_sha: ${{ github.event.pull_request.head.sha }}
+ merge_sha: ${{ github.sha }}
diff --git a/.github/workflows/ci-refresh-pr.yml b/.github/workflows/ci-refresh-pr.yml
new file mode 100644
index 0000000..83f3060
--- /dev/null
+++ b/.github/workflows/ci-refresh-pr.yml
@@ -0,0 +1,125 @@
+name: CI Refresh PR
+
+on:
+ workflow_call:
+ inputs:
+ pr_number:
+ required: true
+ type: string
+ initial_head_sha:
+ required: true
+ type: string
+ main_sha:
+ required: true
+ type: string
+
+permissions:
+ contents: read
+ pull-requests: read
+ statuses: write
+
+jobs:
+ resolve:
+ name: 等待最新 merge SHA
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ outputs:
+ runnable: ${{ steps.merge.outputs.runnable }}
+ base_sha: ${{ steps.merge.outputs.base_sha }}
+ head_sha: ${{ steps.merge.outputs.head_sha }}
+ merge_sha: ${{ steps.merge.outputs.merge_sha }}
+ steps:
+ - name: 等待 GitHub 生成最新合并提交
+ id: merge
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR_NUMBER: ${{ inputs.pr_number }}
+ INITIAL_HEAD_SHA: ${{ inputs.initial_head_sha }}
+ MAIN_SHA: ${{ inputs.main_sha }}
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ api() {
+ curl --fail-with-body --silent --show-error \
+ --location \
+ --header "Authorization: Bearer $GH_TOKEN" \
+ --header "Accept: application/vnd.github+json" \
+ --header "X-GitHub-Api-Version: 2022-11-28" \
+ "$@"
+ }
+
+ echo "runnable=false" >> "$GITHUB_OUTPUT"
+ for attempt in {1..24}; do
+ echo "等待最新 merge SHA($attempt/24)"
+ pr_json=$(api "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")
+ state=$(jq -r '.state' <<< "$pr_json")
+ draft=$(jq -r '.draft' <<< "$pr_json")
+ base_sha=$(jq -r '.base.sha' <<< "$pr_json")
+ head_sha=$(jq -r '.head.sha' <<< "$pr_json")
+ mergeable=$(jq -r '.mergeable // "unknown"' <<< "$pr_json")
+
+ if [[ "$state" != open || "$draft" == true || "$head_sha" != "$INITIAL_HEAD_SHA" ]]; then
+ echo "PR 状态或 head 已变化,由对应事件启动的新 CI 接管。"
+ exit 0
+ fi
+ if [[ "$base_sha" != "$MAIN_SHA" || "$mergeable" == unknown ]]; then
+ sleep 10
+ continue
+ fi
+ if [[ "$mergeable" == false ]]; then
+ echo "PR 与最新 main 冲突,跳过测试。"
+ exit 0
+ fi
+
+ merge_ref=$(api "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/pull/$PR_NUMBER/merge")
+ merge_sha=$(jq -r '.object.sha' <<< "$merge_ref")
+ merge_commit=$(api "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/commits/$merge_sha")
+ first_parent=$(jq -r '.parents[0].sha' <<< "$merge_commit")
+ second_parent=$(jq -r '.parents[1].sha' <<< "$merge_commit")
+
+ if [[ "$first_parent" != "$MAIN_SHA" || "$second_parent" != "$head_sha" ]]; then
+ sleep 10
+ continue
+ fi
+
+ {
+ echo "runnable=true"
+ echo "base_sha=$base_sha"
+ echo "head_sha=$head_sha"
+ echo "merge_sha=$merge_sha"
+ } >> "$GITHUB_OUTPUT"
+
+ jq -n \
+ --arg state pending \
+ --arg context "CI / required" \
+ --arg description "正在针对最新 main 重新检查" \
+ --arg target_url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
+ '{
+ state: $state,
+ context: $context,
+ description: $description,
+ target_url: $target_url
+ }' > "$RUNNER_TEMP/status.json"
+
+ api \
+ --request POST \
+ --data-binary "@$RUNNER_TEMP/status.json" \
+ "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/statuses/$merge_sha"
+ exit 0
+ done
+
+ echo "GitHub 未在等待窗口内生成包含最新 main 的 merge SHA。"
+ exit 1
+
+ core:
+ name: 对最新 main 重检
+ needs: resolve
+ if: needs.resolve.outputs.runnable == 'true'
+ uses: ./.github/workflows/ci-core.yml
+ with:
+ pr_number: ${{ inputs.pr_number }}
+ base_sha: ${{ needs.resolve.outputs.base_sha }}
+ head_sha: ${{ needs.resolve.outputs.head_sha }}
+ merge_sha: ${{ needs.resolve.outputs.merge_sha }}
+ publish_status: true
diff --git a/.github/workflows/ci-refresh.yml b/.github/workflows/ci-refresh.yml
new file mode 100644
index 0000000..112102e
--- /dev/null
+++ b/.github/workflows/ci-refresh.yml
@@ -0,0 +1,69 @@
+name: CI Refresh
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pull-requests: read
+ statuses: write
+
+jobs:
+ discover:
+ name: 查找开放 PR
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ outputs:
+ prs: ${{ steps.prs.outputs.prs }}
+ steps:
+ - name: 查询以 main 为目标的开放 PR
+ id: prs
+ env:
+ GH_TOKEN: ${{ github.token }}
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ all='[]'
+ page=1
+ while true; do
+ response=$(curl --fail-with-body --silent --show-error \
+ --header "Authorization: Bearer $GH_TOKEN" \
+ --header "Accept: application/vnd.github+json" \
+ --header "X-GitHub-Api-Version: 2022-11-28" \
+ "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/pulls?state=open&base=main&per_page=100&page=$page")
+ all=$(jq -cn \
+ --argjson all "$all" \
+ --argjson batch "$response" \
+ '$all + $batch')
+ count=$(jq 'length' <<< "$response")
+ if (( count < 100 )); then
+ break
+ fi
+ ((page += 1))
+ done
+
+ prs=$(jq -c \
+ '[.[] | select(.draft == false) | {
+ number: (.number | tostring),
+ initial_head_sha: .head.sha
+ }]' <<< "$all")
+ echo "prs=$prs" >> "$GITHUB_OUTPUT"
+
+ refresh:
+ name: 重检 PR
+ needs: discover
+ if: needs.discover.outputs.prs != '[]'
+ strategy:
+ fail-fast: false
+ max-parallel: 10
+ matrix:
+ pr: ${{ fromJSON(needs.discover.outputs.prs) }}
+ uses: ./.github/workflows/ci-refresh-pr.yml
+ with:
+ pr_number: ${{ matrix.pr.number }}
+ initial_head_sha: ${{ matrix.pr.initial_head_sha }}
+ main_sha: ${{ github.sha }}
diff --git a/.github/workflows/ci-status.yml b/.github/workflows/ci-status.yml
new file mode 100644
index 0000000..fee4a2c
--- /dev/null
+++ b/.github/workflows/ci-status.yml
@@ -0,0 +1,109 @@
+name: CI Status
+
+on:
+ workflow_run:
+ workflows:
+ - CI
+ types:
+ - completed
+
+permissions:
+ actions: read
+ contents: read
+ pull-requests: read
+ statuses: write
+
+jobs:
+ publish:
+ name: 发布 CI / required
+ if: github.event.workflow_run.event == 'pull_request'
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: 校验待测对象并发布状态
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RUN_ID: ${{ github.event.workflow_run.id }}
+ RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
+ RUN_URL: ${{ github.event.workflow_run.html_url }}
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ api() {
+ curl --fail-with-body --silent --show-error \
+ --location \
+ --header "Authorization: Bearer $GH_TOKEN" \
+ --header "Accept: application/vnd.github+json" \
+ --header "X-GitHub-Api-Version: 2022-11-28" \
+ "$@"
+ }
+
+ run_json=$(api "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID")
+ pr_number=$(jq -r '.pull_requests[0].number // empty' <<< "$run_json")
+ if [[ -z "$pr_number" ]]; then
+ echo "运行未关联 PR,不发布状态。"
+ exit 0
+ fi
+
+ artifact_name="ci-metadata-$pr_number-$RUN_ID"
+ artifacts=$(api "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts?per_page=100")
+ artifact_url=$(jq -r \
+ --arg name "$artifact_name" \
+ '.artifacts[] | select(.name == $name and .expired == false) | .archive_download_url' \
+ <<< "$artifacts" | head -n 1)
+ if [[ -z "$artifact_url" ]]; then
+ echo "未找到待测对象元数据;草稿、取消或初始化失败的运行不发布状态。"
+ exit 0
+ fi
+
+ mkdir -p "$RUNNER_TEMP/ci-metadata"
+ api "$artifact_url" > "$RUNNER_TEMP/ci-metadata.zip"
+ unzip -q "$RUNNER_TEMP/ci-metadata.zip" -d "$RUNNER_TEMP/ci-metadata"
+ metadata="$RUNNER_TEMP/ci-metadata/metadata.json"
+
+ recorded_pr=$(jq -r '.pr_number' "$metadata")
+ recorded_base=$(jq -r '.base_sha' "$metadata")
+ recorded_head=$(jq -r '.head_sha' "$metadata")
+ recorded_merge=$(jq -r '.merge_sha' "$metadata")
+ if [[ "$recorded_pr" != "$pr_number" ]]; then
+ echo "元数据中的 PR 编号不匹配,拒绝发布。"
+ exit 1
+ fi
+
+ pr_json=$(api "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/pulls/$pr_number")
+ current_base=$(jq -r '.base.sha' <<< "$pr_json")
+ current_head=$(jq -r '.head.sha' <<< "$pr_json")
+ current_merge=$(api "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/pull/$pr_number/merge" \
+ | jq -r '.object.sha')
+
+ if [[ "$recorded_base" != "$current_base" ||
+ "$recorded_head" != "$current_head" ||
+ "$recorded_merge" != "$current_merge" ]]; then
+ echo "检测到过期 CI 结果,不覆盖当前 PR 状态。"
+ exit 0
+ fi
+
+ state=failure
+ description="UniSpeaking 必要 CI 检查未通过"
+ if [[ "$RUN_CONCLUSION" == success ]]; then
+ state=success
+ description="UniSpeaking 必要 CI 检查已通过"
+ fi
+
+ jq -n \
+ --arg state "$state" \
+ --arg context "CI / required" \
+ --arg description "$description" \
+ --arg target_url "$RUN_URL" \
+ '{
+ state: $state,
+ context: $context,
+ description: $description,
+ target_url: $target_url
+ }' > "$RUNNER_TEMP/status.json"
+
+ api \
+ --request POST \
+ --data-binary "@$RUNNER_TEMP/status.json" \
+ "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/statuses/$recorded_merge"
diff --git a/CLAUDE.md b/CLAUDE.md
index b46b0ce..62230e7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -527,12 +527,26 @@ src/test/java/com/unispeaking/{same-package}
- 真实供应商手工测试不放在默认 `src/test`,避免依赖密钥、网络和本机文件。
- 未引用夹具、旧包路径测试和已删除功能测试必须同步删除。
- Bug 修复必须添加能复现问题的回归测试。
+- 每次修改生产代码时,必须同步新增或更新对应测试文件,覆盖新增行为、受影响分支和
+ 兼容性边界;不得以“改动较小”或“后续补测”为由只提交实现代码。
+- 后端自动化测试的全局行覆盖率不得低于 80%。启动类、纯 DTO、简单属性绑定类和明确
+ 生成代码可以按 CI 约定排除,Service、Controller、Repository、Provider、鉴权、
+ 会话、评分和状态机等核心业务代码不得通过排除规则规避覆盖率要求。
+- 当前 CI 的强制门槛暂时保持为 70%,用于降低建设初期的协作阻力。这是过渡性自动
+ 门禁,不代表开发质量标准降低;测试基线稳定达到 80% 后,再单独调整 CI 阈值。
提交前至少执行:
```bash
cd backend/unispeaking-server
-./mvnw test
+./mvnw --batch-mode --no-transfer-progress clean verify
+./mvnw --batch-mode --no-transfer-progress \
+ -Pci-integration -DskipUnitTests verify
+./mvnw --batch-mode --no-transfer-progress \
+ -Pcoverage-aggregate \
+ -DskipUnitTests \
+ -DskipIntegrationTests \
+ verify
```
修改前端接口契约时,还须执行前端构建和路由/实时协议检查。
@@ -559,5 +573,6 @@ cd backend/unispeaking-server
- 持久化仅使用 MyBatis-Plus,复合主键使用完整条件。
- 认证、授权、输入校验、超时和错误转换完整。
- 配置无真实密钥,日志无敏感数据。
-- 新增行为有测试,默认测试不依赖外网或真实账号。
-- `./mvnw test` 全部通过。
+- 所有生产代码改动均同步补齐测试,默认测试不依赖外网或真实账号。
+- 全局行覆盖率达到 80% 的开发质量标准;CI 在过渡期仍按 70% 自动门禁执行。
+- 单元测试、容器集成测试和覆盖率检查全部通过。
diff --git a/README.md b/README.md
index dda0d8b..80174bb 100644
--- a/README.md
+++ b/README.md
@@ -54,8 +54,7 @@ IELTS、英文面试、个人主页统计和会员页面目前主要完成了前
├── deploy
│ ├── docker-compose.yml
│ ├── env
-│ ├── nginx
-│ └── postgres 独立建表脚本
+│ └── nginx
├── docs 架构、接口和部署文档
└── CLAUDE.md 后端架构与开发规范
```
@@ -153,9 +152,10 @@ createdb -U postgres unispeaking
CREATE DATABASE unispeaking;
```
-后端启动时会执行
-[`src/main/resources/db/schema.sql`](backend/unispeaking-server/src/main/resources/db/schema.sql)
-创建或补齐当前表结构。运行账号必须具有建表和建索引权限。
+后端启动时由 Flyway 执行
+[`V1__baseline.sql`](backend/unispeaking-server/src/main/resources/db/migration/V1__baseline.sql)
+创建或补齐当前表结构。新库直接执行 V1;已有数据库会先以版本 0 纳入管理,再执行
+幂等迁移并保留现有数据。运行账号必须具有建表、建索引和管理 Flyway 历史表的权限。
当前主要数据表:
@@ -322,10 +322,13 @@ Browser <========= authenticated WebSocket ==========> Spring Boot
```bash
cd backend/unispeaking-server
-./mvnw test
+./mvnw --batch-mode --no-transfer-progress clean verify
+./mvnw --batch-mode --no-transfer-progress \
+ -Pci-integration -DskipUnitTests verify
```
-当前测试基线:181 项测试通过。
+当前测试基线:186 项单元测试、7 项 PostgreSQL/Redis 容器集成测试通过,合并后的
+JaCoCo 全局行覆盖率为 73.80%。
前端:
@@ -342,12 +345,13 @@ npm run check:realtime-events
- [完整业务架构](docs/UniSpeaking架构设计(完整版).md)
- [前后端接口文档](docs/frontend-backend-interface-contract.md)
- [部署与配置](docs/deployment.md)
+- [持续集成与分支保护](docs/ci.md)
- [用户、场景与会话标识](docs/用户会话标识与用量归属流程.md)
## 当前边界
- PostgreSQL 是持久化真相来源;运行时代码只允许使用 MyBatis-Plus。
-- Redis 和消息队列尚未启用。
+- Redis 仅用于 CI 容器烟测,生产运行时和消息队列尚未启用。
- 自由聊天内容不进入长期存储。
- 自定义场景、学习内容、逐轮评分和会话报告写入 PostgreSQL。
- IELTS、面试、个人统计和会员功能尚需继续开发后端接口。
diff --git a/backend/unispeaking-server/.dockerignore b/backend/unispeaking-server/.dockerignore
new file mode 100644
index 0000000..666bfbc
--- /dev/null
+++ b/backend/unispeaking-server/.dockerignore
@@ -0,0 +1,5 @@
+target
+.git
+.idea
+*.iml
+logs
diff --git a/backend/unispeaking-server/Dockerfile b/backend/unispeaking-server/Dockerfile
index 36d5197..873268a 100644
--- a/backend/unispeaking-server/Dockerfile
+++ b/backend/unispeaking-server/Dockerfile
@@ -1,7 +1,10 @@
FROM eclipse-temurin:21-jdk AS build
WORKDIR /workspace
COPY . .
-RUN ./mvnw package -DskipTests
+RUN ./mvnw --batch-mode --no-transfer-progress \
+ -DskipUnitTests \
+ -DskipIntegrationTests \
+ package
FROM eclipse-temurin:21-jre
WORKDIR /app
diff --git a/backend/unispeaking-server/pom.xml b/backend/unispeaking-server/pom.xml
index efeac05..7ed67e8 100644
--- a/backend/unispeaking-server/pom.xml
+++ b/backend/unispeaking-server/pom.xml
@@ -29,6 +29,12 @@
21
3.5.17
+ 0.8.13
+ 2.0.5
+ false
+ true
+
+
@@ -65,6 +71,15 @@
postgresql
runtime
+
+ org.springframework.boot
+ spring-boot-starter-flyway
+
+
+ org.flywaydb
+ flyway-database-postgresql
+ runtime
+
de.sciss
jump3r
@@ -102,6 +117,23 @@
h2
test
+
+ org.testcontainers
+ testcontainers-junit-jupiter
+ ${testcontainers.version}
+ test
+
+
+ org.testcontainers
+ testcontainers-postgresql
+ ${testcontainers.version}
+ test
+
+
+ io.lettuce
+ lettuce-core
+ test
+
@@ -158,10 +190,159 @@
org.apache.maven.plugins
maven-surefire-plugin
- -javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar
+ ${skipUnitTests}
+ @{jacoco.unit.argLine} -javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar
+
+ org.apache.maven.plugins
+ maven-failsafe-plugin
+
+ ${skipIntegrationTests}
+ @{jacoco.integration.argLine} -javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar
+
+
+
+
+ integration-test
+ verify
+
+
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+ ${jacoco.version}
+
+
+ com/unispeaking/UniSpeakingApplication*
+ com/unispeaking/domain/dto/**
+ com/unispeaking/infrastructure/config/JwtProperties*
+ com/unispeaking/infrastructure/ai/qwen/RealtimeProperties*
+
+
+
+
+ prepare-unit-tests
+
+ prepare-agent
+
+
+ jacoco.unit.argLine
+ ${project.build.directory}/jacoco-unit.exec
+
+
+
+ report-unit-tests
+ verify
+
+ report
+
+
+ ${project.build.directory}/jacoco-unit.exec
+ ${project.reporting.outputDirectory}/jacoco-unit
+
+
+
+ prepare-integration-tests
+ pre-integration-test
+
+ prepare-agent-integration
+
+
+ jacoco.integration.argLine
+ ${project.build.directory}/jacoco-integration.exec
+
+
+
+ report-integration-tests
+ verify
+
+ report-integration
+
+
+ ${project.build.directory}/jacoco-integration.exec
+ ${project.reporting.outputDirectory}/jacoco-integration
+
+
+
+
+
+
+ ci-integration
+
+ false
+
+
+
+ coverage-aggregate
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+
+
+ merge-coverage-data
+ verify
+
+ merge
+
+
+
+
+ ${project.build.directory}
+
+ jacoco-unit.exec
+ jacoco-integration.exec
+
+
+
+ ${project.build.directory}/jacoco-merged.exec
+
+
+
+ report-aggregate-coverage
+ verify
+
+ report
+
+
+ ${project.build.directory}/jacoco-merged.exec
+ ${project.reporting.outputDirectory}/jacoco-aggregate
+
+
+
+ check-aggregate-coverage
+ verify
+
+ check
+
+
+ ${project.build.directory}/jacoco-merged.exec
+
+
+ BUNDLE
+
+
+ LINE
+ COVEREDRATIO
+ 0.70
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/unispeaking-server/src/main/resources/application.yaml b/backend/unispeaking-server/src/main/resources/application.yaml
index 201dd98..477fbea 100644
--- a/backend/unispeaking-server/src/main/resources/application.yaml
+++ b/backend/unispeaking-server/src/main/resources/application.yaml
@@ -8,10 +8,15 @@ spring:
username: ${DATABASE_USERNAME:postgres}
password: ${DATABASE_PASSWORD:}
driver-class-name: org.postgresql.Driver
+ flyway:
+ enabled: true
+ locations: classpath:db/migration
+ baseline-on-migrate: true
+ baseline-version: 0
+ validate-on-migrate: true
sql:
init:
- mode: always
- schema-locations: classpath:db/schema.sql
+ mode: never
servlet:
multipart:
max-file-size: 10MB
diff --git a/backend/unispeaking-server/src/main/resources/db/schema.sql b/backend/unispeaking-server/src/main/resources/db/migration/V1__baseline.sql
similarity index 99%
rename from backend/unispeaking-server/src/main/resources/db/schema.sql
rename to backend/unispeaking-server/src/main/resources/db/migration/V1__baseline.sql
index 0b325a0..0fde13a 100644
--- a/backend/unispeaking-server/src/main/resources/db/schema.sql
+++ b/backend/unispeaking-server/src/main/resources/db/migration/V1__baseline.sql
@@ -1,3 +1,4 @@
+-- Flyway V1: current production schema plus idempotent legacy upgrades.
CREATE TABLE IF NOT EXISTS "user" (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(254) NOT NULL UNIQUE,
diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/common/evaluation/policy/TooShortEvaluationPolicyTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/common/evaluation/policy/TooShortEvaluationPolicyTest.java
new file mode 100644
index 0000000..bbcb0c7
--- /dev/null
+++ b/backend/unispeaking-server/src/test/java/com/unispeaking/common/evaluation/policy/TooShortEvaluationPolicyTest.java
@@ -0,0 +1,50 @@
+package com.unispeaking.common.evaluation.policy;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.math.BigDecimal;
+import org.junit.jupiter.api.Test;
+
+class TooShortEvaluationPolicyTest {
+
+ @Test
+ void createsRecognizableZeroScoreResult() {
+ var result = TooShortEvaluationPolicy.createResult(3, "Hi");
+
+ assertEquals(3, result.turnNo());
+ assertEquals("Hi", result.transcript());
+ assertTrue(result.words().isEmpty());
+ assertTrue(TooShortEvaluationPolicy.isTooShort(result));
+ }
+
+ @Test
+ void requiresEveryZeroScoreAndExactFeedback() {
+ assertFalse(TooShortEvaluationPolicy.isTooShort(null));
+ assertFalse(TooShortEvaluationPolicy.isTooShort(
+ BigDecimal.ONE,
+ BigDecimal.ZERO,
+ BigDecimal.ZERO,
+ BigDecimal.ZERO,
+ BigDecimal.ZERO,
+ BigDecimal.ZERO,
+ TooShortEvaluationPolicy.FEEDBACK_SUMMARY));
+ assertFalse(TooShortEvaluationPolicy.isTooShort(
+ BigDecimal.ZERO,
+ BigDecimal.ZERO,
+ BigDecimal.ZERO,
+ BigDecimal.ZERO,
+ BigDecimal.ZERO,
+ BigDecimal.ZERO,
+ " 过短,不予评分 "));
+ assertFalse(TooShortEvaluationPolicy.isTooShort(
+ null,
+ BigDecimal.ZERO,
+ BigDecimal.ZERO,
+ BigDecimal.ZERO,
+ BigDecimal.ZERO,
+ BigDecimal.ZERO,
+ TooShortEvaluationPolicy.FEEDBACK_SUMMARY));
+ }
+}
diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/integration/PostgresPersistenceIT.java b/backend/unispeaking-server/src/test/java/com/unispeaking/integration/PostgresPersistenceIT.java
new file mode 100644
index 0000000..fd5d6d6
--- /dev/null
+++ b/backend/unispeaking-server/src/test/java/com/unispeaking/integration/PostgresPersistenceIT.java
@@ -0,0 +1,493 @@
+package com.unispeaking.integration;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.unispeaking.common.evaluation.model.EndingTone;
+import com.unispeaking.common.evaluation.model.PronunciationAssessmentResult;
+import com.unispeaking.common.evaluation.model.PronunciationPhonemeResult;
+import com.unispeaking.common.evaluation.model.PronunciationWordResult;
+import com.unispeaking.common.evaluation.model.WordReadStatus;
+import com.unispeaking.domain.dto.evaluation.DialogueReportResult;
+import com.unispeaking.domain.dto.scene.LearningContentItem;
+import com.unispeaking.domain.dto.scene.SceneGenerationResponse;
+import com.unispeaking.domain.dto.session.Message;
+import com.unispeaking.domain.po.auth.UserAccount;
+import com.unispeaking.domain.po.auth.UserRole;
+import com.unispeaking.domain.po.auth.UserStatus;
+import com.unispeaking.domain.po.profile.UserProfile;
+import com.unispeaking.domain.po.scene.CustomSceneDefinition;
+import com.unispeaking.infrastructure.persistence.entity.evaluation.CustomTurnEvaluation;
+import com.unispeaking.infrastructure.persistence.entity.evaluation.PronunciationWordDetail;
+import com.unispeaking.infrastructure.persistence.repository.evaluation.SceneSentenceReadingRepository;
+import com.unispeaking.infrastructure.persistence.repository.evaluation.SessionEvaluationRepository;
+import com.unispeaking.infrastructure.persistence.repository.evaluation.TurnEvaluationRepository;
+import com.unispeaking.infrastructure.persistence.repository.scene.MybatisSceneRepository;
+import com.unispeaking.infrastructure.persistence.repository.session.SessionMessageRepository;
+import com.unispeaking.infrastructure.persistence.repository.user.MybatisUserAccountRepository;
+import com.unispeaking.infrastructure.persistence.repository.user.MybatisUserProfileRepository;
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.List;
+import java.util.UUID;
+import org.flywaydb.core.Flyway;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+@SpringBootTest
+@Testcontainers
+class PostgresPersistenceIT {
+
+ @Container
+ static final PostgreSQLContainer> POSTGRES =
+ new PostgreSQLContainer<>("postgres:17-alpine")
+ .withDatabaseName("unispeaking_it")
+ .withUsername("unispeaking")
+ .withPassword("unispeaking");
+
+ @DynamicPropertySource
+ static void postgresProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
+ registry.add("spring.datasource.username", POSTGRES::getUsername);
+ registry.add("spring.datasource.password", POSTGRES::getPassword);
+ registry.add("spring.datasource.driver-class-name", POSTGRES::getDriverClassName);
+ registry.add("spring.flyway.enabled", () -> true);
+ registry.add("spring.flyway.baseline-on-migrate", () -> true);
+ registry.add("spring.flyway.baseline-version", () -> "0");
+ registry.add("spring.flyway.locations", () -> "classpath:db/migration");
+ registry.add("spring.sql.init.mode", () -> "never");
+ registry.add(
+ "mybatis-plus.type-handlers-package",
+ () -> "com.unispeaking.infrastructure.persistence.typehandler");
+ }
+
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ @Autowired
+ private MybatisUserAccountRepository userAccountRepository;
+
+ @Autowired
+ private MybatisUserProfileRepository userProfileRepository;
+
+ @Autowired
+ private MybatisSceneRepository sceneRepository;
+
+ @Autowired
+ private SessionMessageRepository sessionMessageRepository;
+
+ @Autowired
+ private TurnEvaluationRepository turnEvaluationRepository;
+
+ @Autowired
+ private SessionEvaluationRepository sessionEvaluationRepository;
+
+ @Autowired
+ private SceneSentenceReadingRepository sentenceReadingRepository;
+
+ @BeforeEach
+ void clearBusinessTables() {
+ jdbcTemplate.execute("""
+ TRUNCATE TABLE
+ sentence_evaluation,
+ session_evaluation,
+ turn_evaluation,
+ session_message,
+ sentence,
+ phrase,
+ "word",
+ scene,
+ user_preference,
+ "user"
+ """);
+ }
+
+ @Test
+ void migratesEmptyDatabaseAndRegistersFlywayHistory() {
+ Integer migrationCount = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM flyway_schema_history WHERE success",
+ Integer.class);
+ String successFactorType = jdbcTemplate.queryForObject(
+ """
+ SELECT data_type
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'scene'
+ AND column_name = 'success_factor'
+ """,
+ String.class);
+
+ assertEquals(1, migrationCount);
+ assertEquals("jsonb", successFactorType);
+ }
+
+ @Test
+ void persistsUserProfileAndLastLoginAgainstPostgres() {
+ UUID userId = UUID.fromString("11111111-1111-4111-8111-111111111111");
+ Instant createdAt = Instant.parse("2026-07-31T06:00:00Z");
+ UserAccount account = new UserAccount(
+ userId,
+ "ci@example.com",
+ "encoded-password",
+ "CI User",
+ UserRole.USER,
+ UserStatus.ACTIVE,
+ 0,
+ null,
+ createdAt,
+ createdAt);
+
+ userAccountRepository.create(account);
+ userAccountRepository.updateLastLoginAt(
+ userId,
+ Instant.parse("2026-07-31T07:00:00Z"));
+ UserProfile profile = new UserProfile(
+ userId.toString(),
+ "B",
+ "Clara",
+ "MODERATE",
+ "zh-CN",
+ "喜欢旅行",
+ "{\"translation_enabled\":true}");
+ userProfileRepository.save(profile);
+ userProfileRepository.save(profile.withPreferences(
+ "James",
+ "NATURAL",
+ "C",
+ "喜欢旅行和咖啡"));
+
+ assertEquals(
+ "ci@example.com",
+ userAccountRepository.findById(userId).orElseThrow().username());
+ assertEquals(
+ Instant.parse("2026-07-31T07:00:00Z"),
+ userAccountRepository.findByUsername("ci@example.com")
+ .orElseThrow()
+ .lastLoginAt());
+ UserProfile saved = userProfileRepository
+ .findByUserId(userId.toString())
+ .orElseThrow();
+ assertEquals("James", saved.voiceId());
+ assertEquals("C", saved.level());
+ assertEquals("喜欢旅行和咖啡", saved.memoryText());
+ }
+
+ @Test
+ void persistsSceneContentReadsAssetsAndHonorsSoftDelete() throws Exception {
+ CustomSceneDefinition definition = sceneDefinition();
+ SceneGenerationResponse response = new SceneGenerationResponse(
+ definition.sceneId(),
+ definition.wordList(),
+ definition.phraseList(),
+ definition.sentenceList(),
+ "prompt");
+
+ sceneRepository.saveCustomScene(definition, response);
+
+ SceneGenerationResponse generated = sceneRepository
+ .findGeneratedById(definition.sceneId())
+ .orElseThrow();
+ assertEquals("word_it1", generated.wordList().getFirst().contentId());
+ assertEquals("phrase_it1", generated.phraseList().getFirst().contentId());
+ assertEquals("sentence_it1", generated.sentenceList().getFirst().contentId());
+ assertEquals(
+ objectMapper.readTree("{\"minimum_user_turns\":2}"),
+ objectMapper.readTree(sceneRepository
+ .findCustomDefinitionById(definition.sceneId())
+ .orElseThrow()
+ .successFactorJson()));
+ assertEquals(1, sceneRepository.findAssetsByUserId(definition.userId()).size());
+ assertTrue(sceneRepository.findAssetsByUserId("not-a-uuid").isEmpty());
+
+ jdbcTemplate.update(
+ "UPDATE scene SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?",
+ definition.sceneId());
+
+ assertTrue(sceneRepository.findGeneratedById(definition.sceneId()).isEmpty());
+ assertTrue(sceneRepository.findAssetsByUserId(definition.userId()).isEmpty());
+ }
+
+ @Test
+ void storesMessagesAndEvaluationJsonbWithCompositeKeys() {
+ sessionMessageRepository.append(
+ "custom_it1",
+ "session_it1",
+ 2,
+ new Message(0, " Welcome. ", null));
+ sessionMessageRepository.append(
+ "custom_it1",
+ "session_it1",
+ 1,
+ new Message(1, " Hello. ", null));
+ sessionMessageRepository.append(
+ "custom_it1",
+ "session_old",
+ 1,
+ new Message(1, "Old message", null));
+
+ assertEquals(
+ List.of("Hello.", "Welcome."),
+ sessionMessageRepository.findMessages("session_it1").stream()
+ .map(Message::content)
+ .toList());
+ assertEquals(
+ "custom_it1",
+ sessionMessageRepository.findSceneId("session_it1").orElseThrow());
+ assertEquals(
+ 1,
+ sessionMessageRepository.deleteObsoleteForScene(
+ "custom_it1",
+ "session_it1"));
+
+ CustomTurnEvaluation first = turnEvaluation(1, new BigDecimal("82"));
+ CustomTurnEvaluation second = turnEvaluation(2, new BigDecimal("86"));
+ turnEvaluationRepository.upsert(first);
+ turnEvaluationRepository.upsert(second);
+ turnEvaluationRepository.upsert(turnEvaluation(
+ 1,
+ new BigDecimal("91")));
+
+ List saved =
+ turnEvaluationRepository.findAll("session_it1");
+ assertEquals(2, saved.size());
+ assertEquals(new BigDecimal("91.00"), saved.getFirst().overallScore());
+ assertEquals("coffee", saved.getFirst().words().getFirst().text());
+ assertEquals(
+ 1,
+ turnEvaluationRepository.findBefore("session_it1", 2).size());
+ assertEquals(
+ "object",
+ jdbcTemplate.queryForObject(
+ """
+ SELECT jsonb_typeof(pronunciation_details)
+ FROM turn_evaluation
+ WHERE session_id = 'session_it1' AND turn_no = 1
+ """,
+ String.class));
+ }
+
+ @Test
+ void storesSessionArraysAndSentenceReadingDetails() {
+ CustomSceneDefinition definition = sceneDefinition();
+ sceneRepository.saveCustomScene(
+ definition,
+ new SceneGenerationResponse(
+ definition.sceneId(),
+ definition.wordList(),
+ definition.phraseList(),
+ definition.sentenceList(),
+ "prompt"));
+ DialogueReportResult report = new DialogueReportResult(
+ new BigDecimal("88"),
+ new BigDecimal("87"),
+ new BigDecimal("86"),
+ new BigDecimal("85"),
+ new BigDecimal("84"),
+ new BigDecimal("86"),
+ "表达稳定",
+ List.of("内容清楚", "语速自然"),
+ List.of("增加连接词"));
+
+ sessionEvaluationRepository.save(
+ definition.sceneId(),
+ "session_it1",
+ report);
+ sessionEvaluationRepository.save(
+ definition.sceneId(),
+ "session_it1",
+ report);
+ String readingId = sentenceReadingRepository.saveAttempt(
+ definition.sceneId(),
+ definition.sentenceList().getFirst(),
+ pronunciationAssessment());
+
+ assertEquals(
+ List.of("内容清楚", "语速自然"),
+ sessionEvaluationRepository.find("session_it1")
+ .orElseThrow()
+ .strengths());
+ assertEquals(
+ definition.sceneId(),
+ sessionEvaluationRepository.findRecord("session_it1")
+ .orElseThrow()
+ .sceneId());
+ assertEquals(
+ 1,
+ sessionEvaluationRepository.findBySceneId(definition.sceneId()).size());
+ assertEquals(
+ definition.sceneId(),
+ sentenceReadingRepository
+ .findSceneIdBySentenceId("sentence_it1")
+ .orElseThrow());
+ assertTrue(readingId.startsWith("sentence_reading_"));
+ assertEquals(
+ "object",
+ jdbcTemplate.queryForObject(
+ "SELECT jsonb_typeof(score_detail) FROM sentence_evaluation WHERE id = ?",
+ String.class,
+ readingId));
+ }
+
+ @Test
+ void baselinesLegacySchemaAtZeroAndKeepsExistingData() {
+ String schema = "legacy_ci";
+ jdbcTemplate.execute("DROP SCHEMA IF EXISTS " + schema + " CASCADE");
+ jdbcTemplate.execute("CREATE SCHEMA " + schema);
+ jdbcTemplate.execute("""
+ CREATE TABLE legacy_ci."user" (
+ id UUID PRIMARY KEY,
+ username VARCHAR(128) NOT NULL UNIQUE,
+ password_hash VARCHAR(255) NOT NULL,
+ nickname VARCHAR(32),
+ role VARCHAR(16) NOT NULL DEFAULT 'USER',
+ status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
+ auth_version BIGINT NOT NULL DEFAULT 0,
+ last_login_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )
+ """);
+ jdbcTemplate.update(
+ """
+ INSERT INTO legacy_ci."user"
+ (id, username, password_hash)
+ VALUES (?::uuid, ?, ?)
+ """,
+ "22222222-2222-4222-8222-222222222222",
+ "legacy@example.com",
+ "legacy-password");
+
+ Flyway.configure()
+ .dataSource(
+ POSTGRES.getJdbcUrl(),
+ POSTGRES.getUsername(),
+ POSTGRES.getPassword())
+ .schemas(schema)
+ .defaultSchema(schema)
+ .locations("classpath:db/migration")
+ .baselineOnMigrate(true)
+ .baselineVersion("0")
+ .load()
+ .migrate();
+
+ assertEquals(
+ 1,
+ jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM legacy_ci.\"user\" WHERE username = 'legacy@example.com'",
+ Integer.class));
+ assertEquals(
+ List.of("0", "1"),
+ jdbcTemplate.queryForList(
+ """
+ SELECT version
+ FROM legacy_ci.flyway_schema_history
+ WHERE version IS NOT NULL
+ ORDER BY installed_rank
+ """,
+ String.class));
+ assertFalse(jdbcTemplate.queryForList(
+ """
+ SELECT table_name
+ FROM information_schema.tables
+ WHERE table_schema = 'legacy_ci'
+ AND table_name = 'scene'
+ """,
+ String.class).isEmpty());
+
+ jdbcTemplate.execute("DROP SCHEMA " + schema + " CASCADE");
+ }
+
+ private CustomSceneDefinition sceneDefinition() {
+ return new CustomSceneDefinition(
+ "custom_it1",
+ "11111111-1111-4111-8111-111111111111",
+ "酒店入住",
+ "酒店前台",
+ "前台接待员",
+ "住客",
+ "完成入住",
+ "保持礼貌",
+ "{\"minimum_user_turns\":2}",
+ List.of(new LearningContentItem(
+ "word_it1",
+ "reservation",
+ "预订",
+ "/ˌrezərˈveɪʃn/")),
+ List.of(new LearningContentItem(
+ "phrase_it1",
+ "check in",
+ "办理入住",
+ "/tʃek ɪn/")),
+ List.of(new LearningContentItem(
+ "sentence_it1",
+ "I have a reservation.",
+ "我有预订。",
+ "")));
+ }
+
+ private CustomTurnEvaluation turnEvaluation(
+ int turnNo,
+ BigDecimal overallScore) {
+ return new CustomTurnEvaluation(
+ "custom_it1",
+ "session_it1",
+ turnNo,
+ "I would like some coffee.",
+ overallScore,
+ new BigDecimal("82"),
+ new BigDecimal("80"),
+ new BigDecimal("100"),
+ new BigDecimal("86"),
+ new BigDecimal("83"),
+ "表达清楚。",
+ "I'd like some coffee, please.",
+ List.of(new PronunciationWordDetail(
+ 0,
+ "coffee",
+ new BigDecimal("88"),
+ List.of(new PronunciationWordDetail.Phoneme(
+ 0,
+ "k",
+ "k",
+ new BigDecimal("90"),
+ 0,
+ 1)))));
+ }
+
+ private PronunciationAssessmentResult pronunciationAssessment() {
+ return new PronunciationAssessmentResult(
+ new BigDecimal("88"),
+ new BigDecimal("87"),
+ new BigDecimal("86"),
+ new BigDecimal("100"),
+ new BigDecimal("89"),
+ new BigDecimal("90"),
+ EndingTone.FALL,
+ List.of(new PronunciationWordResult(
+ 0,
+ "reservation",
+ WordReadStatus.NORMAL,
+ new BigDecimal("88"),
+ new BigDecimal("89"),
+ true,
+ List.of(new PronunciationPhonemeResult(
+ 0,
+ "r",
+ "r",
+ new BigDecimal("90"),
+ 0,
+ 1)))));
+ }
+}
diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/integration/RedisContainerIT.java b/backend/unispeaking-server/src/test/java/com/unispeaking/integration/RedisContainerIT.java
new file mode 100644
index 0000000..c0e1481
--- /dev/null
+++ b/backend/unispeaking-server/src/test/java/com/unispeaking/integration/RedisContainerIT.java
@@ -0,0 +1,64 @@
+package com.unispeaking.integration;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.lettuce.core.RedisClient;
+import io.lettuce.core.RedisURI;
+import io.lettuce.core.api.StatefulRedisConnection;
+import io.lettuce.core.api.sync.RedisCommands;
+import java.time.Duration;
+import org.junit.jupiter.api.Test;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.DockerImageName;
+
+@Testcontainers
+class RedisContainerIT {
+
+ private static final int REDIS_PORT = 6379;
+
+ @Container
+ static final GenericContainer> REDIS =
+ new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine"))
+ .withExposedPorts(REDIS_PORT)
+ .waitingFor(Wait.forListeningPort());
+
+ @Test
+ void writesReadsAndExpiresValue() throws InterruptedException {
+ RedisURI uri = RedisURI.builder()
+ .withHost(REDIS.getHost())
+ .withPort(REDIS.getMappedPort(REDIS_PORT))
+ .withTimeout(Duration.ofSeconds(5))
+ .build();
+ RedisClient client = RedisClient.create(uri);
+ try (StatefulRedisConnection connection =
+ client.connect()) {
+ RedisCommands commands = connection.sync();
+ String key = "ci:ttl";
+
+ commands.psetex(key, 800, "ready");
+
+ assertEquals("ready", commands.get(key));
+ long remainingTtl = commands.pttl(key);
+ assertTrue(remainingTtl > 0 && remainingTtl <= 800);
+ awaitExpiration(commands, key);
+ assertNull(commands.get(key));
+ }
+ finally {
+ client.shutdown();
+ }
+ }
+
+ private void awaitExpiration(
+ RedisCommands commands,
+ String key) throws InterruptedException {
+ long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos();
+ while (commands.get(key) != null && System.nanoTime() < deadline) {
+ Thread.sleep(50);
+ }
+ }
+}
diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/service/scene/ScenarioDialogueStateMachineTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/service/scene/ScenarioDialogueStateMachineTest.java
new file mode 100644
index 0000000..3db3eca
--- /dev/null
+++ b/backend/unispeaking-server/src/test/java/com/unispeaking/service/scene/ScenarioDialogueStateMachineTest.java
@@ -0,0 +1,157 @@
+package com.unispeaking.service.scene;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.unispeaking.common.exception.BusinessException;
+import com.unispeaking.domain.dto.session.Message;
+import com.unispeaking.domain.po.scene.CustomSceneDefinition;
+import com.unispeaking.domain.po.scene.ScenarioDialogueEvent;
+import com.unispeaking.domain.po.scene.ScenarioSuccessFactor;
+import com.unispeaking.domain.vo.scene.ScenarioDialogueCompletionReason;
+import com.unispeaking.domain.vo.scene.ScenarioDialogueEventType;
+import com.unispeaking.domain.vo.scene.ScenarioDialogueStage;
+import com.unispeaking.infrastructure.persistence.repository.session.SessionMessageRepository;
+import com.unispeaking.service.scene.impl.ScenarioDialogueEventExtractor;
+import com.unispeaking.service.scene.impl.ScenarioDialogueStateMachine;
+import com.unispeaking.service.scene.impl.ScenarioSuccessFactorParser;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class ScenarioDialogueStateMachineTest {
+
+ private ScenarioDialogueEventExtractor eventExtractor;
+ private SessionMessageRepository messageRepository;
+ private ScenarioDialogueStateMachine stateMachine;
+
+ @BeforeEach
+ void setUp() {
+ ScenarioSuccessFactorParser successFactorParser =
+ mock(ScenarioSuccessFactorParser.class);
+ eventExtractor = mock(ScenarioDialogueEventExtractor.class);
+ messageRepository = mock(SessionMessageRepository.class);
+ when(successFactorParser.parse(any()))
+ .thenReturn(new ScenarioSuccessFactor(
+ 1,
+ 4,
+ Map.of(
+ "drink", "choose a drink",
+ "payment", "choose payment"),
+ "complete the order",
+ "Thank the learner."));
+ when(messageRepository.findMessages(anyString()))
+ .thenReturn(List.of(new Message(0, "What would you like?", null)));
+ stateMachine = new ScenarioDialogueStateMachine(
+ successFactorParser,
+ eventExtractor,
+ messageRepository);
+ }
+
+ @Test
+ void advancesFromGreetingThroughConfirmationAndClosing() {
+ var started = stateMachine.start("session_1", scene());
+ assertEquals(ScenarioDialogueStage.GREETING, started.stage());
+ assertTrue(started.controlInstruction().contains("choose a drink"));
+ assertFalse(started.completed());
+
+ when(eventExtractor.extract(any(), anyString(), anyList()))
+ .thenReturn(
+ outcome("drink", "latte"),
+ outcome("payment", "card"),
+ new ScenarioDialogueEvent(
+ ScenarioDialogueEventType.USER_CONFIRMED,
+ Map.of(),
+ 0.99));
+
+ var collecting = stateMachine.advance("session_1", 1, "A latte.");
+ assertEquals(ScenarioDialogueStage.COLLECTING_INFORMATION, collecting.stage());
+ assertTrue(collecting.outcomes().stream()
+ .anyMatch(outcome -> outcome.outcomeId().equals("drink")
+ && outcome.satisfied()));
+
+ var duplicate = stateMachine.advance("session_1", 1, "Use cash.");
+ assertEquals(1, duplicate.effectiveUserTurns());
+ verify(eventExtractor, times(1)).extract(any(), anyString(), anyList());
+
+ var confirmation = stateMachine.advance("session_1", 2, "By card.");
+ assertEquals(ScenarioDialogueStage.CONFIRMATION, confirmation.stage());
+ assertTrue(confirmation.controlInstruction().contains("final confirmation"));
+
+ var completed = stateMachine.advance("session_1", 3, "That is correct.");
+ assertTrue(completed.completed());
+ assertEquals(ScenarioDialogueCompletionReason.GOAL_ACHIEVED,
+ completed.completionReason());
+ assertTrue(completed.controlInstruction().contains("Thank the learner."));
+
+ var closing = stateMachine.beginClosing("session_1");
+ assertEquals(ScenarioDialogueStage.CLOSING, closing.stage());
+ assertTrue(closing.controlInstruction().isEmpty());
+ assertEquals(3,
+ stateMachine.advance("session_1", 4, "ignored")
+ .effectiveUserTurns());
+ }
+
+ @Test
+ void recordsExtractorFailureAndValidatesInput() {
+ stateMachine.start("session_2", scene());
+ when(eventExtractor.extract(any(), anyString(), anyList()))
+ .thenThrow(new IllegalStateException("provider unavailable"));
+
+ var response = stateMachine.advance("session_2", 1, " Continue ");
+
+ assertEquals(1, response.effectiveUserTurns());
+ assertTrue(response.warning().contains("语义目标提取失败"));
+ assertThrows(BusinessException.class,
+ () -> stateMachine.advance("session_2", 0, "invalid"));
+ assertThrows(BusinessException.class,
+ () -> stateMachine.advance("session_2", 2, " "));
+ }
+
+ @Test
+ void removesStateAndRejectsUnknownSession() {
+ stateMachine.start("session_3", scene());
+ assertTrue(stateMachine.findState("session_3").isPresent());
+
+ stateMachine.remove("session_3");
+
+ assertTrue(stateMachine.findState("session_3").isEmpty());
+ BusinessException exception = assertThrows(
+ BusinessException.class,
+ () -> stateMachine.getState("session_3"));
+ assertEquals("SCENARIO_STATE_NOT_FOUND", exception.code());
+ }
+
+ private CustomSceneDefinition scene() {
+ return new CustomSceneDefinition(
+ "custom_1",
+ "11111111-1111-4111-8111-111111111111",
+ "Coffee",
+ "Cafe",
+ "Barista",
+ "Customer",
+ "Order coffee",
+ "",
+ "{}",
+ List.of(),
+ List.of(),
+ List.of());
+ }
+
+ private ScenarioDialogueEvent outcome(String key, String value) {
+ return new ScenarioDialogueEvent(
+ ScenarioDialogueEventType.OUTCOME_UPDATE,
+ Map.of(key, value),
+ 0.95);
+ }
+}
diff --git a/backend/unispeaking-server/src/test/resources/application.yaml b/backend/unispeaking-server/src/test/resources/application.yaml
index 234e833..5be1113 100644
--- a/backend/unispeaking-server/src/test/resources/application.yaml
+++ b/backend/unispeaking-server/src/test/resources/application.yaml
@@ -6,6 +6,8 @@ spring:
username: sa
password:
driver-class-name: org.h2.Driver
+ flyway:
+ enabled: false
sql:
init:
mode: never
diff --git a/deploy/postgres/phrase.sql b/deploy/postgres/phrase.sql
deleted file mode 100644
index 7ad396d..0000000
--- a/deploy/postgres/phrase.sql
+++ /dev/null
@@ -1,82 +0,0 @@
-BEGIN;
-
-CREATE TABLE IF NOT EXISTS phrase (
- scene_id VARCHAR(64) NOT NULL,
- phrase_id VARCHAR(64) NOT NULL,
- phrase VARCHAR(255) NOT NULL,
- phonetic VARCHAR(255),
- translation TEXT NOT NULL,
- created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT phrase_pk PRIMARY KEY (scene_id, phrase_id),
- CONSTRAINT phrase_id_check
- CHECK (phrase_id LIKE 'phrase\_%' ESCAPE '\'),
- CONSTRAINT phrase_scene_id_check CHECK (scene_id LIKE 'custom\_%' ESCAPE '\'),
- CONSTRAINT phrase_text_check CHECK (BTRIM(phrase) <> ''),
- CONSTRAINT phrase_translation_check CHECK (BTRIM(translation) <> '')
-);
-
-DO $$
-BEGIN
- IF EXISTS (
- SELECT 1
- FROM information_schema.columns
- WHERE table_schema = 'public'
- AND table_name = 'phrase'
- AND column_name = 'id'
- ) AND NOT EXISTS (
- SELECT 1
- FROM information_schema.columns
- WHERE table_schema = 'public'
- AND table_name = 'phrase'
- AND column_name = 'phrase_id'
- ) THEN
- ALTER TABLE phrase RENAME COLUMN id TO phrase_id;
- END IF;
-END;
-$$;
-
-ALTER TABLE phrase
-DROP CONSTRAINT IF EXISTS phrase_pkey;
-
-DO $$
-BEGIN
- IF NOT EXISTS (
- SELECT 1
- FROM pg_constraint
- WHERE conrelid = 'phrase'::REGCLASS
- AND contype = 'p'
- ) THEN
- ALTER TABLE phrase
- ADD CONSTRAINT phrase_pk PRIMARY KEY (scene_id, phrase_id);
- END IF;
-END;
-$$;
-
-COMMENT ON TABLE phrase IS
-'自定义场景的词组学习内容';
-
-COMMENT ON COLUMN phrase.scene_id IS
-'逻辑关联 scene.id,不设置数据库外键';
-
-COMMENT ON COLUMN phrase.phrase_id IS
-'词组业务标识,与 scene_id 共同构成主键';
-
-CREATE OR REPLACE FUNCTION set_phrase_updated_at()
-RETURNS TRIGGER
-LANGUAGE plpgsql
-AS $$
-BEGIN
- NEW.updated_at = CURRENT_TIMESTAMP;
- RETURN NEW;
-END;
-$$;
-
-DROP TRIGGER IF EXISTS phrase_set_updated_at ON phrase;
-
-CREATE TRIGGER phrase_set_updated_at
-BEFORE UPDATE ON phrase
-FOR EACH ROW
-EXECUTE FUNCTION set_phrase_updated_at();
-
-COMMIT;
diff --git a/deploy/postgres/scene.sql b/deploy/postgres/scene.sql
deleted file mode 100644
index 46f318c..0000000
--- a/deploy/postgres/scene.sql
+++ /dev/null
@@ -1,58 +0,0 @@
-BEGIN;
-
-CREATE TABLE IF NOT EXISTS scene (
- id VARCHAR(64) PRIMARY KEY,
- user_id UUID NOT NULL,
- title VARCHAR(128) NOT NULL,
- background TEXT NOT NULL,
- ai_role TEXT NOT NULL,
- user_role TEXT NOT NULL,
- learning_goal TEXT NOT NULL,
- custom_instruction TEXT,
- success_factor JSONB NOT NULL DEFAULT '{}'::JSONB,
- created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- deleted_at TIMESTAMPTZ,
- CONSTRAINT scene_id_check CHECK (id LIKE 'custom\_%' ESCAPE '\'),
- CONSTRAINT scene_title_check CHECK (BTRIM(title) <> ''),
- CONSTRAINT scene_success_factor_check
- CHECK (JSONB_TYPEOF(success_factor) = 'object')
-);
-
-CREATE INDEX IF NOT EXISTS idx_scene_user_id
-ON scene (user_id);
-
-CREATE INDEX IF NOT EXISTS idx_scene_active_user_updated_at
-ON scene (user_id, updated_at DESC)
-WHERE deleted_at IS NULL;
-
-COMMENT ON TABLE scene IS
-'用户创建的自定义场景定义;学习内容和练习结果由其他业务实体保存';
-
-COMMENT ON COLUMN scene.user_id IS
-'逻辑关联 user.id,不设置数据库外键';
-
-COMMENT ON COLUMN scene.success_factor IS
-'状态机用于判断场景是否成功完成的 JSON 条件对象';
-
-COMMENT ON COLUMN scene.deleted_at IS
-'软删除时间;为空表示场景有效';
-
-CREATE OR REPLACE FUNCTION set_scene_updated_at()
-RETURNS TRIGGER
-LANGUAGE plpgsql
-AS $$
-BEGIN
- NEW.updated_at = CURRENT_TIMESTAMP;
- RETURN NEW;
-END;
-$$;
-
-DROP TRIGGER IF EXISTS scene_set_updated_at ON scene;
-
-CREATE TRIGGER scene_set_updated_at
-BEFORE UPDATE ON scene
-FOR EACH ROW
-EXECUTE FUNCTION set_scene_updated_at();
-
-COMMIT;
diff --git a/deploy/postgres/sentence.sql b/deploy/postgres/sentence.sql
deleted file mode 100644
index 1bc7b7d..0000000
--- a/deploy/postgres/sentence.sql
+++ /dev/null
@@ -1,171 +0,0 @@
-BEGIN;
-
-CREATE TABLE IF NOT EXISTS sentence (
- scene_id VARCHAR(64) NOT NULL,
- sentence_id VARCHAR(64) NOT NULL,
- sentence TEXT NOT NULL,
- translation TEXT NOT NULL,
- created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT sentence_pk PRIMARY KEY (scene_id, sentence_id),
- CONSTRAINT sentence_content_id_check
- CHECK (sentence_id LIKE 'sentence\_%' ESCAPE '\'),
- CONSTRAINT sentence_scene_id_check
- CHECK (scene_id LIKE 'custom\_%' ESCAPE '\'),
- CONSTRAINT sentence_text_check CHECK (BTRIM(sentence) <> ''),
- CONSTRAINT sentence_translation_check CHECK (BTRIM(translation) <> '')
-);
-
-CREATE TABLE IF NOT EXISTS sentence_evaluation (
- id VARCHAR(64) PRIMARY KEY,
- scene_id VARCHAR(64) NOT NULL,
- sentence_id VARCHAR(64) NOT NULL,
- overall_score NUMERIC(5, 2) NOT NULL,
- score_detail JSONB NOT NULL DEFAULT '{}'::JSONB,
- created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT sentence_evaluation_id_check
- CHECK (id LIKE 'sentence\_reading\_%' ESCAPE '\'),
- CONSTRAINT sentence_evaluation_scene_id_check
- CHECK (scene_id LIKE 'custom\_%' ESCAPE '\'),
- CONSTRAINT sentence_evaluation_sentence_id_check
- CHECK (sentence_id LIKE 'sentence\_%' ESCAPE '\'),
- CONSTRAINT sentence_evaluation_overall_score_check
- CHECK (overall_score BETWEEN 0 AND 100),
- CONSTRAINT sentence_evaluation_score_detail_check
- CHECK (JSONB_TYPEOF(score_detail) = 'object')
-);
-
-DO $$
-BEGIN
- IF EXISTS (
- SELECT 1
- FROM information_schema.columns
- WHERE table_schema = 'public'
- AND table_name = 'sentence'
- AND column_name = 'id'
- ) THEN
- EXECUTE '
- INSERT INTO sentence_evaluation (
- id,
- scene_id,
- sentence_id,
- overall_score,
- score_detail,
- created_at,
- updated_at
- )
- SELECT
- id,
- scene_id,
- sentence_id,
- overall_score,
- score_detail,
- created_at,
- updated_at
- FROM sentence
- WHERE overall_score IS NOT NULL
- ON CONFLICT (id) DO NOTHING
- ';
- EXECUTE '
- DELETE FROM sentence AS duplicate
- USING sentence AS retained
- WHERE duplicate.scene_id = retained.scene_id
- AND duplicate.sentence_id = retained.sentence_id
- AND (duplicate.created_at, duplicate.id)
- > (retained.created_at, retained.id)
- ';
- ALTER TABLE sentence
- DROP CONSTRAINT IF EXISTS sentence_pkey;
- ALTER TABLE sentence
- DROP CONSTRAINT IF EXISTS sentence_record_id_check;
- ALTER TABLE sentence
- DROP CONSTRAINT IF EXISTS sentence_overall_score_check;
- ALTER TABLE sentence
- DROP CONSTRAINT IF EXISTS sentence_score_detail_check;
- ALTER TABLE sentence
- DROP COLUMN id;
- ALTER TABLE sentence
- DROP COLUMN overall_score;
- ALTER TABLE sentence
- DROP COLUMN score_detail;
- END IF;
-END;
-$$;
-
-ALTER TABLE sentence
-DROP CONSTRAINT IF EXISTS sentence_pk;
-
-ALTER TABLE sentence
-ADD CONSTRAINT sentence_pk PRIMARY KEY (scene_id, sentence_id);
-
-DROP INDEX IF EXISTS idx_sentence_scene_id;
-
-CREATE INDEX IF NOT EXISTS idx_sentence_content_id
-ON sentence (sentence_id);
-
-DROP INDEX IF EXISTS idx_sentence_scene_content_created_at;
-
-CREATE INDEX IF NOT EXISTS idx_sentence_evaluation_scene_sentence
-ON sentence_evaluation (scene_id, sentence_id, created_at DESC);
-
-CREATE INDEX IF NOT EXISTS idx_sentence_evaluation_created_at
-ON sentence_evaluation (created_at DESC);
-
-COMMENT ON TABLE sentence IS
-'自定义场景的参考句子资产,不保存朗读评分';
-
-COMMENT ON COLUMN sentence.scene_id IS
-'场景业务标识,与 sentence_id 共同构成主键';
-
-COMMENT ON COLUMN sentence.sentence_id IS
-'参考句子的业务标识,与 scene_id 共同构成主键';
-
-COMMENT ON TABLE sentence_evaluation IS
-'用户每次朗读场景句子的评分记录';
-
-COMMENT ON COLUMN sentence_evaluation.scene_id IS
-'场景业务标识,不设置数据库外键';
-
-COMMENT ON COLUMN sentence_evaluation.sentence_id IS
-'句子业务标识,与 scene_id 一起定位 sentence 资产,不设置数据库外键';
-
-COMMENT ON COLUMN sentence_evaluation.score_detail IS
-'JSON 格式的逐词及逐音素评分细则';
-
-CREATE OR REPLACE FUNCTION set_sentence_updated_at()
-RETURNS TRIGGER
-LANGUAGE plpgsql
-AS $$
-BEGIN
- NEW.updated_at = CURRENT_TIMESTAMP;
- RETURN NEW;
-END;
-$$;
-
-DROP TRIGGER IF EXISTS sentence_set_updated_at ON sentence;
-
-CREATE TRIGGER sentence_set_updated_at
-BEFORE UPDATE ON sentence
-FOR EACH ROW
-EXECUTE FUNCTION set_sentence_updated_at();
-
-CREATE OR REPLACE FUNCTION set_sentence_evaluation_updated_at()
-RETURNS TRIGGER
-LANGUAGE plpgsql
-AS $$
-BEGIN
- NEW.updated_at = CURRENT_TIMESTAMP;
- RETURN NEW;
-END;
-$$;
-
-DROP TRIGGER IF EXISTS sentence_evaluation_set_updated_at
-ON sentence_evaluation;
-
-CREATE TRIGGER sentence_evaluation_set_updated_at
-BEFORE UPDATE ON sentence_evaluation
-FOR EACH ROW
-EXECUTE FUNCTION set_sentence_evaluation_updated_at();
-
-COMMIT;
diff --git a/deploy/postgres/sentence_evaluation.sql b/deploy/postgres/sentence_evaluation.sql
deleted file mode 100644
index af3625b..0000000
--- a/deploy/postgres/sentence_evaluation.sql
+++ /dev/null
@@ -1,59 +0,0 @@
-BEGIN;
-
-CREATE TABLE IF NOT EXISTS sentence_evaluation (
- id VARCHAR(64) PRIMARY KEY,
- scene_id VARCHAR(64) NOT NULL,
- sentence_id VARCHAR(64) NOT NULL,
- overall_score NUMERIC(5, 2) NOT NULL,
- score_detail JSONB NOT NULL DEFAULT '{}'::JSONB,
- created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT sentence_evaluation_id_check
- CHECK (id LIKE 'sentence\_reading\_%' ESCAPE '\'),
- CONSTRAINT sentence_evaluation_scene_id_check
- CHECK (scene_id LIKE 'custom\_%' ESCAPE '\'),
- CONSTRAINT sentence_evaluation_sentence_id_check
- CHECK (sentence_id LIKE 'sentence\_%' ESCAPE '\'),
- CONSTRAINT sentence_evaluation_overall_score_check
- CHECK (overall_score BETWEEN 0 AND 100),
- CONSTRAINT sentence_evaluation_score_detail_check
- CHECK (JSONB_TYPEOF(score_detail) = 'object')
-);
-
-CREATE INDEX IF NOT EXISTS idx_sentence_evaluation_scene_sentence
-ON sentence_evaluation (scene_id, sentence_id, created_at DESC);
-
-CREATE INDEX IF NOT EXISTS idx_sentence_evaluation_created_at
-ON sentence_evaluation (created_at DESC);
-
-COMMENT ON TABLE sentence_evaluation IS
-'用户每次朗读场景句子的评分记录';
-
-COMMENT ON COLUMN sentence_evaluation.scene_id IS
-'场景业务标识,不设置数据库外键';
-
-COMMENT ON COLUMN sentence_evaluation.sentence_id IS
-'句子业务标识,与 scene_id 一起定位 sentence 资产,不设置数据库外键';
-
-COMMENT ON COLUMN sentence_evaluation.score_detail IS
-'JSON 格式的逐词及逐音素评分细则';
-
-CREATE OR REPLACE FUNCTION set_sentence_evaluation_updated_at()
-RETURNS TRIGGER
-LANGUAGE plpgsql
-AS $$
-BEGIN
- NEW.updated_at = CURRENT_TIMESTAMP;
- RETURN NEW;
-END;
-$$;
-
-DROP TRIGGER IF EXISTS sentence_evaluation_set_updated_at
-ON sentence_evaluation;
-
-CREATE TRIGGER sentence_evaluation_set_updated_at
-BEFORE UPDATE ON sentence_evaluation
-FOR EACH ROW
-EXECUTE FUNCTION set_sentence_evaluation_updated_at();
-
-COMMIT;
diff --git a/deploy/postgres/session_evaluation.sql b/deploy/postgres/session_evaluation.sql
deleted file mode 100644
index e7957e9..0000000
--- a/deploy/postgres/session_evaluation.sql
+++ /dev/null
@@ -1,97 +0,0 @@
-BEGIN;
-
-CREATE TABLE IF NOT EXISTS session_evaluation (
- session_id VARCHAR(64) PRIMARY KEY,
- scene_id VARCHAR(64),
- accuracy_score NUMERIC(5, 2) NOT NULL,
- fluency_score NUMERIC(5, 2) NOT NULL,
- grammar_score NUMERIC(5, 2) NOT NULL,
- vocabulary_score NUMERIC(5, 2) NOT NULL,
- naturalness_score NUMERIC(5, 2) NOT NULL,
- final_score NUMERIC(5, 2) NOT NULL,
- summary TEXT NOT NULL,
- strengths TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
- improvements TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
- created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT session_evaluation_session_id_check
- CHECK (BTRIM(session_id) <> ''),
- CONSTRAINT session_evaluation_scene_id_check
- CHECK (scene_id IS NULL OR BTRIM(scene_id) <> ''),
- CONSTRAINT session_evaluation_accuracy_score_check
- CHECK (accuracy_score BETWEEN 0 AND 100),
- CONSTRAINT session_evaluation_fluency_score_check
- CHECK (fluency_score BETWEEN 0 AND 100),
- CONSTRAINT session_evaluation_grammar_score_check
- CHECK (grammar_score BETWEEN 0 AND 100),
- CONSTRAINT session_evaluation_vocabulary_score_check
- CHECK (vocabulary_score BETWEEN 0 AND 100),
- CONSTRAINT session_evaluation_naturalness_score_check
- CHECK (naturalness_score BETWEEN 0 AND 100),
- CONSTRAINT session_evaluation_final_score_check
- CHECK (final_score BETWEEN 0 AND 100),
- CONSTRAINT session_evaluation_summary_check
- CHECK (BTRIM(summary) <> '')
-);
-
-ALTER TABLE session_evaluation
-ADD COLUMN IF NOT EXISTS scene_id VARCHAR(64);
-
-DO $$
-BEGIN
- IF TO_REGCLASS('public.session_message') IS NOT NULL THEN
- EXECUTE '
- UPDATE session_evaluation AS evaluation
- SET scene_id = message.scene_id
- FROM (
- SELECT DISTINCT ON (session_id) session_id, scene_id
- FROM session_message
- ORDER BY session_id, message_no
- ) AS message
- WHERE evaluation.session_id = message.session_id
- AND evaluation.scene_id IS NULL
- ';
- END IF;
-END;
-$$;
-
-CREATE INDEX IF NOT EXISTS idx_session_evaluation_scene_id
-ON session_evaluation (scene_id, created_at DESC);
-
-CREATE INDEX IF NOT EXISTS idx_session_evaluation_created_at
-ON session_evaluation (created_at DESC);
-
-COMMENT ON TABLE session_evaluation IS
-'一场会话结束后的综合练习评分;每个 session_id 只保存一份结果';
-
-COMMENT ON COLUMN session_evaluation.session_id IS
-'会话业务标识,与其他会话表保持 VARCHAR(64),不设置数据库外键';
-
-COMMENT ON COLUMN session_evaluation.scene_id IS
-'场景业务标识,用于保留同一场景的历次五维评分,不设置数据库外键';
-
-COMMENT ON COLUMN session_evaluation.strengths IS
-'本场会话表现较好的方面';
-
-COMMENT ON COLUMN session_evaluation.improvements IS
-'本场会话需要改进的方面';
-
-CREATE OR REPLACE FUNCTION set_session_evaluation_updated_at()
-RETURNS TRIGGER
-LANGUAGE plpgsql
-AS $$
-BEGIN
- NEW.updated_at = CURRENT_TIMESTAMP;
- RETURN NEW;
-END;
-$$;
-
-DROP TRIGGER IF EXISTS session_evaluation_set_updated_at
-ON session_evaluation;
-
-CREATE TRIGGER session_evaluation_set_updated_at
-BEFORE UPDATE ON session_evaluation
-FOR EACH ROW
-EXECUTE FUNCTION set_session_evaluation_updated_at();
-
-COMMIT;
diff --git a/deploy/postgres/session_message.sql b/deploy/postgres/session_message.sql
deleted file mode 100644
index 475bd93..0000000
--- a/deploy/postgres/session_message.sql
+++ /dev/null
@@ -1,73 +0,0 @@
-BEGIN;
-
-CREATE TABLE IF NOT EXISTS session_message (
- scene_id VARCHAR(64) NOT NULL,
- session_id VARCHAR(64) NOT NULL,
- message_no INTEGER NOT NULL,
- owner SMALLINT NOT NULL,
- content TEXT NOT NULL,
- audio_url TEXT,
- created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- update_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT session_message_pk
- PRIMARY KEY (session_id, message_no),
- CONSTRAINT session_message_scene_id_check
- CHECK (BTRIM(scene_id) <> ''),
- CONSTRAINT session_message_session_id_check
- CHECK (BTRIM(session_id) <> ''),
- CONSTRAINT session_message_no_check
- CHECK (message_no > 0),
- CONSTRAINT session_message_owner_check
- CHECK (owner IN (0, 1)),
- CONSTRAINT session_message_content_check
- CHECK (BTRIM(content) <> ''),
- CONSTRAINT session_message_audio_url_check
- CHECK (audio_url IS NULL OR BTRIM(audio_url) <> '')
-);
-
-CREATE INDEX IF NOT EXISTS idx_session_message_scene_id
-ON session_message (scene_id);
-
-CREATE INDEX IF NOT EXISTS idx_session_message_created_at
-ON session_message (created_at DESC);
-
-COMMENT ON TABLE session_message IS
-'按消息保存自定义场景会话的最终完整转写,不保存流式 delta';
-
-COMMENT ON COLUMN session_message.scene_id IS
-'场景业务标识,不设置数据库外键';
-
-COMMENT ON COLUMN session_message.session_id IS
-'会话业务标识,不设置数据库外键';
-
-COMMENT ON COLUMN session_message.message_no IS
-'消息在当前会话中的顺序号,从 1 开始';
-
-COMMENT ON COLUMN session_message.owner IS
-'消息归属:0 表示 AI,1 表示用户';
-
-COMMENT ON COLUMN session_message.content IS
-'用户或 AI 的最终完整对话转写,不保存流式 delta';
-
-COMMENT ON COLUMN session_message.audio_url IS
-'可选的音频对象存储地址';
-
-CREATE OR REPLACE FUNCTION set_session_message_update_at()
-RETURNS TRIGGER
-LANGUAGE plpgsql
-AS $$
-BEGIN
- NEW.update_at = CURRENT_TIMESTAMP;
- RETURN NEW;
-END;
-$$;
-
-DROP TRIGGER IF EXISTS session_message_set_update_at
-ON session_message;
-
-CREATE TRIGGER session_message_set_update_at
-BEFORE UPDATE ON session_message
-FOR EACH ROW
-EXECUTE FUNCTION set_session_message_update_at();
-
-COMMIT;
diff --git a/deploy/postgres/turn_evaluation.sql b/deploy/postgres/turn_evaluation.sql
deleted file mode 100644
index 95aa843..0000000
--- a/deploy/postgres/turn_evaluation.sql
+++ /dev/null
@@ -1,122 +0,0 @@
-BEGIN;
-
-CREATE TABLE IF NOT EXISTS turn_evaluation (
- session_id VARCHAR(64) NOT NULL,
- turn_no INTEGER NOT NULL,
- scene_id VARCHAR(64) NOT NULL,
- transcript TEXT NOT NULL,
- overall_score NUMERIC(5, 2) NOT NULL,
- rhythm_score NUMERIC(5, 2) NOT NULL,
- tone_score NUMERIC(5, 2),
- integrity_score NUMERIC(5, 2) NOT NULL,
- pronunciation_score NUMERIC(5, 2) NOT NULL,
- fluency_score NUMERIC(5, 2) NOT NULL,
- feedback_summary TEXT NOT NULL,
- suggested_expression TEXT NOT NULL,
- pronunciation_details JSONB NOT NULL DEFAULT '{}'::JSONB,
- created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT turn_evaluation_pk PRIMARY KEY (session_id, turn_no),
- CONSTRAINT turn_evaluation_scene_id_check
- CHECK (BTRIM(scene_id) <> ''),
- CONSTRAINT turn_evaluation_session_id_check
- CHECK (BTRIM(session_id) <> ''),
- CONSTRAINT turn_evaluation_turn_no_check
- CHECK (turn_no > 0),
- CONSTRAINT turn_evaluation_transcript_check
- CHECK (BTRIM(transcript) <> ''),
- CONSTRAINT turn_evaluation_overall_score_check
- CHECK (overall_score BETWEEN 0 AND 100),
- CONSTRAINT turn_evaluation_rhythm_score_check
- CHECK (rhythm_score BETWEEN 0 AND 100),
- CONSTRAINT turn_evaluation_tone_score_check
- CHECK (tone_score IS NULL OR tone_score BETWEEN 0 AND 100),
- CONSTRAINT turn_evaluation_integrity_score_check
- CHECK (integrity_score BETWEEN 0 AND 100),
- CONSTRAINT turn_evaluation_pronunciation_score_check
- CHECK (pronunciation_score BETWEEN 0 AND 100),
- CONSTRAINT turn_evaluation_fluency_score_check
- CHECK (fluency_score BETWEEN 0 AND 100),
- CONSTRAINT turn_evaluation_feedback_summary_check
- CHECK (BTRIM(feedback_summary) <> ''),
- CONSTRAINT turn_evaluation_pronunciation_details_check
- CHECK (JSONB_TYPEOF(pronunciation_details) = 'object')
-);
-
-DO $$
-BEGIN
- ALTER TABLE turn_evaluation
- DROP CONSTRAINT IF EXISTS turn_evaluation_session_turn_uk;
- IF EXISTS (
- SELECT 1
- FROM information_schema.columns
- WHERE table_schema = 'public'
- AND table_name = 'turn_evaluation'
- AND column_name = 'id'
- ) THEN
- ALTER TABLE turn_evaluation
- DROP CONSTRAINT IF EXISTS turn_evaluation_pkey;
- ALTER TABLE turn_evaluation
- DROP COLUMN id;
- END IF;
-END;
-$$;
-
-DO $$
-BEGIN
- IF NOT EXISTS (
- SELECT 1
- FROM pg_constraint
- WHERE conrelid = 'turn_evaluation'::REGCLASS
- AND contype = 'p'
- ) THEN
- ALTER TABLE turn_evaluation
- ADD CONSTRAINT turn_evaluation_pk
- PRIMARY KEY (session_id, turn_no);
- END IF;
-END;
-$$;
-
-CREATE INDEX IF NOT EXISTS idx_turn_evaluation_scene_id
-ON turn_evaluation (scene_id);
-
-CREATE INDEX IF NOT EXISTS idx_turn_evaluation_created_at
-ON turn_evaluation (created_at DESC);
-
-COMMENT ON TABLE turn_evaluation IS
-'会话中每一轮用户发言的单句评分;session_id 与 turn_no 唯一确定一轮';
-
-COMMENT ON COLUMN turn_evaluation.scene_id IS
-'场景业务标识,不设置数据库外键';
-
-COMMENT ON COLUMN turn_evaluation.session_id IS
-'会话业务标识,不设置数据库外键';
-
-COMMENT ON COLUMN turn_evaluation.turn_no IS
-'用户发言在当前会话中的轮次,从 1 开始';
-
-COMMENT ON COLUMN turn_evaluation.tone_score IS
-'语调评分;供应商未返回该维度时允许为空';
-
-COMMENT ON COLUMN turn_evaluation.pronunciation_details IS
-'逐词和逐音素发音评分明细 JSON 对象';
-
-CREATE OR REPLACE FUNCTION set_turn_evaluation_updated_at()
-RETURNS TRIGGER
-LANGUAGE plpgsql
-AS $$
-BEGIN
- NEW.updated_at = CURRENT_TIMESTAMP;
- RETURN NEW;
-END;
-$$;
-
-DROP TRIGGER IF EXISTS turn_evaluation_set_updated_at
-ON turn_evaluation;
-
-CREATE TRIGGER turn_evaluation_set_updated_at
-BEFORE UPDATE ON turn_evaluation
-FOR EACH ROW
-EXECUTE FUNCTION set_turn_evaluation_updated_at();
-
-COMMIT;
diff --git a/deploy/postgres/user_preference.sql b/deploy/postgres/user_preference.sql
deleted file mode 100644
index 9cfb3f0..0000000
--- a/deploy/postgres/user_preference.sql
+++ /dev/null
@@ -1,163 +0,0 @@
-BEGIN;
-
-CREATE TABLE IF NOT EXISTS "user" (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- username VARCHAR(254) NOT NULL UNIQUE,
- password_hash VARCHAR(255) NOT NULL,
- nickname VARCHAR(32),
- role VARCHAR(16) NOT NULL DEFAULT 'USER',
- status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
- auth_version BIGINT NOT NULL DEFAULT 0,
- last_login_at TIMESTAMPTZ,
- created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT user_role_check CHECK (role IN ('USER', 'ADMIN')),
- CONSTRAINT user_status_check CHECK (status IN ('ACTIVE', 'DISABLED', 'LOCKED')),
- CONSTRAINT user_auth_version_check CHECK (auth_version >= 0)
-);
-
-ALTER TABLE "user"
-ALTER COLUMN username TYPE VARCHAR(254);
-
-CREATE TABLE IF NOT EXISTS user_preference (
- user_id UUID PRIMARY KEY,
- preferred_voice VARCHAR(64),
- preferred_ai_speech_speed VARCHAR(16) NOT NULL DEFAULT 'NATURAL',
- preferences JSONB NOT NULL DEFAULT '{}'::JSONB,
- memory_text TEXT,
- cefr_level VARCHAR(4),
- created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT user_preference_cefr_level_check
- CHECK (cefr_level IS NULL OR cefr_level IN ('A', 'B', 'C', 'D')),
- CONSTRAINT user_preference_speech_speed_check
- CHECK (preferred_ai_speech_speed IN ('SLOWER', 'MODERATE', 'NATURAL', 'FASTER'))
-);
-
-COMMENT ON COLUMN user_preference.memory_text IS
-'用户主动维护的长期档案摘要,仅包含兴趣与熟悉背景、昵称或称谓、年龄段、代词和敏感话题边界,不存储逐轮对话或会话历史摘要';
-
-UPDATE user_preference
-SET memory_text = NULL
-WHERE memory_text LIKE '本次会话摘要(本地):%';
-
-INSERT INTO "user" (
- id,
- username,
- password_hash,
- nickname,
- role,
- status
-)
-VALUES (
- '11111111-1111-4111-8111-111111111111',
- 'demo@example.com',
- '$2y$12$Oxho43Xny2DnCJ1BT2BtvOb5b6CeH6xNI/fa5wBT7jLLkvbHsPK9W',
- 'Demo User',
- 'USER',
- 'ACTIVE'
-)
-ON CONFLICT (id) DO UPDATE
-SET username = EXCLUDED.username,
- password_hash = EXCLUDED.password_hash,
- nickname = EXCLUDED.nickname,
- role = EXCLUDED.role,
- status = EXCLUDED.status;
-
-DO $$
-DECLARE
- current_user_id_type TEXT;
-BEGIN
- SELECT data_type
- INTO current_user_id_type
- FROM information_schema.columns
- WHERE table_schema = 'public'
- AND table_name = 'user_preference'
- AND column_name = 'user_id';
-
- IF current_user_id_type <> 'uuid' THEN
- UPDATE user_preference
- SET user_id = '11111111-1111-4111-8111-111111111111'
- WHERE user_id = 'demo-user-001';
-
- ALTER TABLE user_preference
- ALTER COLUMN user_id TYPE UUID
- USING user_id::UUID;
- END IF;
-END;
-$$;
-
-ALTER TABLE user_preference
-DROP CONSTRAINT IF EXISTS user_preference_cefr_level_check;
-
-UPDATE user_preference
-SET cefr_level = CASE cefr_level
- WHEN 'A1' THEN 'A'
- WHEN 'A2' THEN 'B'
- WHEN 'B1' THEN 'C'
- WHEN 'B2' THEN 'D'
- WHEN 'C1' THEN 'D'
- WHEN 'C2' THEN 'D'
- ELSE cefr_level
-END
-WHERE cefr_level IS NOT NULL;
-
-ALTER TABLE user_preference
-ADD CONSTRAINT user_preference_cefr_level_check
-CHECK (cefr_level IS NULL OR cefr_level IN ('A', 'B', 'C', 'D'));
-
-ALTER TABLE user_preference
-DROP CONSTRAINT IF EXISTS user_preference_speech_speed_check;
-
-ALTER TABLE user_preference
-ADD CONSTRAINT user_preference_speech_speed_check
-CHECK (preferred_ai_speech_speed IN ('SLOWER', 'MODERATE', 'NATURAL', 'FASTER'));
-
-CREATE OR REPLACE FUNCTION set_updated_at()
-RETURNS TRIGGER
-LANGUAGE plpgsql
-AS $$
-BEGIN
- NEW.updated_at = CURRENT_TIMESTAMP;
- RETURN NEW;
-END;
-$$;
-
-DROP TRIGGER IF EXISTS user_set_updated_at ON "user";
-CREATE TRIGGER user_set_updated_at
-BEFORE UPDATE ON "user"
-FOR EACH ROW
-EXECUTE FUNCTION set_updated_at();
-
-DROP TRIGGER IF EXISTS user_preference_set_updated_at ON user_preference;
-CREATE TRIGGER user_preference_set_updated_at
-BEFORE UPDATE ON user_preference
-FOR EACH ROW
-EXECUTE FUNCTION set_updated_at();
-
-DROP FUNCTION IF EXISTS set_user_preference_updated_at();
-
-INSERT INTO user_preference (
- user_id,
- preferred_voice,
- preferred_ai_speech_speed,
- preferences,
- memory_text,
- cefr_level
-)
-VALUES (
- '11111111-1111-4111-8111-111111111111',
- 'Katerina',
- 'NATURAL',
- '{"translation_enabled": true, "learning_goal": "daily_conversation"}'::JSONB,
- '兴趣与背景:喜欢科技、电影和旅行,从事软件产品相关工作,熟悉会议和演示场景。个人信息:昵称 Demo;未提供年龄段、代词和敏感话题边界。',
- 'C'
-)
-ON CONFLICT (user_id) DO UPDATE
-SET preferred_voice = EXCLUDED.preferred_voice,
- preferred_ai_speech_speed = EXCLUDED.preferred_ai_speech_speed,
- preferences = EXCLUDED.preferences,
- memory_text = EXCLUDED.memory_text,
- cefr_level = EXCLUDED.cefr_level;
-
-COMMIT;
diff --git a/deploy/postgres/word.sql b/deploy/postgres/word.sql
deleted file mode 100644
index 1dd590a..0000000
--- a/deploy/postgres/word.sql
+++ /dev/null
@@ -1,61 +0,0 @@
-BEGIN;
-
-CREATE TABLE IF NOT EXISTS "word" (
- scene_id VARCHAR(64) NOT NULL,
- word_id VARCHAR(64) NOT NULL,
- word VARCHAR(128) NOT NULL,
- phonetic VARCHAR(128),
- translation TEXT NOT NULL,
- created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- CONSTRAINT word_pk PRIMARY KEY (scene_id, word_id),
- CONSTRAINT word_id_check CHECK (word_id LIKE 'word\_%' ESCAPE '\'),
- CONSTRAINT word_scene_id_check CHECK (scene_id LIKE 'custom\_%' ESCAPE '\'),
- CONSTRAINT word_text_check CHECK (BTRIM(word) <> ''),
- CONSTRAINT word_translation_check CHECK (BTRIM(translation) <> '')
-);
-
-ALTER TABLE "word"
-DROP CONSTRAINT IF EXISTS word_pkey;
-
-DO $$
-BEGIN
- IF NOT EXISTS (
- SELECT 1
- FROM pg_constraint
- WHERE conrelid = '"word"'::REGCLASS
- AND contype = 'p'
- ) THEN
- ALTER TABLE "word"
- ADD CONSTRAINT word_pk PRIMARY KEY (scene_id, word_id);
- END IF;
-END;
-$$;
-
-COMMENT ON TABLE "word" IS
-'自定义场景的单词学习内容';
-
-COMMENT ON COLUMN "word".scene_id IS
-'逻辑关联 scene.id,不设置数据库外键';
-
-COMMENT ON COLUMN "word".word_id IS
-'单词业务标识,与 scene_id 共同构成主键';
-
-CREATE OR REPLACE FUNCTION set_word_updated_at()
-RETURNS TRIGGER
-LANGUAGE plpgsql
-AS $$
-BEGIN
- NEW.updated_at = CURRENT_TIMESTAMP;
- RETURN NEW;
-END;
-$$;
-
-DROP TRIGGER IF EXISTS word_set_updated_at ON "word";
-
-CREATE TRIGGER word_set_updated_at
-BEFORE UPDATE ON "word"
-FOR EACH ROW
-EXECUTE FUNCTION set_word_updated_at();
-
-COMMIT;
diff --git a/docs/ci.md b/docs/ci.md
new file mode 100644
index 0000000..9ecff5a
--- /dev/null
+++ b/docs/ci.md
@@ -0,0 +1,103 @@
+# UniSpeaking 持续集成与分支保护
+
+本文档对应 RFC《UniSpeaking 最小可用 CI 方案》,说明第一阶段 CI 的运行方式、
+报告位置和仓库设置。CI 只证明代码可以安全合并,不发布制品、不部署环境,也不把
+Redis 引入生产运行时。
+
+## 工作流
+
+| 文件 | 职责 |
+| --- | --- |
+| `ci-pr.yml` | 在 PR 创建、重新打开、新提交和转为 Ready 时测试 GitHub merge commit |
+| `ci-core.yml` | 按变更范围运行后端、前端、Docker、Compose、Nginx 和依赖检查 |
+| `ci-refresh.yml` | `main` 更新后查找全部开放 PR,并行发起重检 |
+| `ci-refresh-pr.yml` | 等待包含最新 `main` 的 merge SHA,跳过冲突或已变化的 PR |
+| `ci-status.yml` | 在可信上下文校验当前 base、head、merge SHA 后发布 `CI / required` |
+
+同一 PR 的核心检查使用 `ci-pr-` 并发组。出现新提交或 `main` 更新时,旧检查
+会自动取消;不同 PR 可以并行执行。PR 工作流只使用只读权限,不接收仓库 Secrets。
+状态写入任务不检出或执行 PR 代码,过期结果不会覆盖当前 merge SHA。
+
+工作流文件本身发生变化时会强制运行全部检查。其他变更按路径执行:
+
+- 后端:编译、186 项单元测试、打包、PostgreSQL/Redis 集成测试、70% 行覆盖率和镜像构建;
+- 前端:Node.js 22、`npm ci`、路由与 Realtime 事件检查、生产构建和镜像构建;
+- Compose、环境模板或 Nginx:配置解析或 `nginx -t`;
+- Maven/npm 依赖文件:Dependency Review,High 和 Critical 阻止合并。
+
+## 本地复现
+
+后端单元测试、打包和覆盖率数据:
+
+```bash
+cd backend/unispeaking-server
+./mvnw --batch-mode --no-transfer-progress clean verify
+```
+
+PostgreSQL 与 Redis 集成测试:
+
+```bash
+./mvnw --batch-mode --no-transfer-progress \
+ -Pci-integration -DskipUnitTests verify
+```
+
+合并两类测试的 JaCoCo 数据并执行 70% 门禁:
+
+```bash
+./mvnw --batch-mode --no-transfer-progress \
+ -Pcoverage-aggregate \
+ -DskipUnitTests \
+ -DskipIntegrationTests \
+ verify
+```
+
+集成测试使用 Testcontainers,需要本机 Docker 可用;测试结束后容器会自动清理。
+
+前端:
+
+```bash
+cd frontend/Unispeaking_fronted
+npm ci
+npm run check:routes
+npm run check:realtime-events
+npm run build
+```
+
+## 报告
+
+GitHub Actions 在对应运行的 Artifacts 中保留以下内容 30 天:
+
+- Surefire 单元测试报告和必要日志;
+- Failsafe 集成测试报告和必要日志;
+- JaCoCo 单元、集成及合并覆盖率报告。
+
+PR 不上传后端 JAR、前端 `dist` 或 Docker 镜像。用于在任务间合并覆盖率和校验 merge SHA
+的临时数据只保留 1 天。
+
+## 首次启用与分支保护
+
+新增工作流的首个 PR 合并前,默认分支尚不存在可供 Ruleset 选择的
+`CI / required` 状态。首次启用按以下顺序完成:
+
+1. 审核并合入工作流;
+2. 创建一个验证 PR,确认 `CI / required` 首次成功;
+3. 在仓库 Settings → Rules → Rulesets 中为 `main` 创建规则;
+4. 要求通过 PR、至少一人批准,并把 `CI / required` 设为必需状态;
+5. 禁止直接 Push、禁止绕过失败门禁,并要求分支无冲突后才能合并;
+6. 分别验证新提交取消旧运行、`main` 更新重检、冲突跳过和条件任务。
+
+Ruleset 只绑定稳定状态 `CI / required`,不绑定可能调整名称的内部 Job。配置规则需要
+仓库管理员权限,且必须在默认分支存在工作流并产生首个成功状态后完成。
+
+## 提交与 PR
+
+CI 实现提交采用 Conventional Commit 结构,标题与正文均使用中文说明:
+
+```text
+(): <中文摘要>
+
+<说明做了什么、原因以及必要的验证或兼容性影响>
+```
+
+PR 正文应关联《UniSpeaking 最小可用 CI 方案》,简述“做了什么、怎么做、验证结果和
+范围说明”,总长度不超过 1500 字符;不粘贴完整日志或重复 RFC 全文。
diff --git a/frontend/Unispeaking_fronted/.dockerignore b/frontend/Unispeaking_fronted/.dockerignore
index 16006f4..26fd83e 100644
--- a/frontend/Unispeaking_fronted/.dockerignore
+++ b/frontend/Unispeaking_fronted/.dockerignore
@@ -1,3 +1,5 @@
-dist
node_modules
-npm-debug.log*
+dist
+.git
+.idea
+*.log
diff --git a/frontend/Unispeaking_fronted/Dockerfile b/frontend/Unispeaking_fronted/Dockerfile
index 21429d6..0ac90ec 100644
--- a/frontend/Unispeaking_fronted/Dockerfile
+++ b/frontend/Unispeaking_fronted/Dockerfile
@@ -9,7 +9,9 @@ COPY package.json package-lock.json ./
RUN npm ci
COPY . .
-RUN npm run build
+RUN npm run check:routes \
+ && npm run check:realtime-events \
+ && npm run build
FROM nginx:1.27-alpine