From 1b8d6fcce616bf21f4bbde7bd20b0ef607ce04d0 Mon Sep 17 00:00:00 2001 From: linmeng <819991304@qq.com> Date: Mon, 27 Jul 2026 20:19:05 +0800 Subject: [PATCH] feat: add configurable payments and Gitea staging delivery Add database-backed administrators, configurable Alipay packages, idempotent payment settlement and platform reporting. Standardize Gitea workflows on the xiaoxin runner so reviewed staging commits are tested, packaged, and deployed by immutable image digest. --- .../apply-supabase-profile-migrations.yml | 60 ++++++++++ .gitea/workflows/backend-quality-gate.yml | 8 +- .gitea/workflows/ci.yml | 44 ++++++++ .gitea/workflows/deploy-production.yml | 59 ++++++++++ .gitea/workflows/deploy-staging.yml | 105 ++++++++++++++++++ .gitea/workflows/migrate-staging-database.yml | 93 ++++++++++++++++ .gitea/workflows/publish-pypi.yml | 37 ++++++ .gitea/workflows/release-quality-gate.yml | 38 +++++++ .gitea/workflows/test.yml | 40 +++++++ deploy/run-staging-migration.sh | 3 +- docs/BUG_HISTORY.md | 16 +++ frontend/src/app/admin/codes/page.tsx | 2 +- frontend/src/app/admin/layout.tsx | 4 +- frontend/src/app/admin/packages/page.tsx | 12 ++ frontend/src/app/admin/payments/page.tsx | 45 ++++++++ frontend/src/app/admin/users/page.tsx | 32 ++++++ frontend/src/app/api/account/route.ts | 4 +- frontend/src/app/api/admin/codes/route.ts | 9 +- frontend/src/app/api/admin/packages/route.ts | 19 ++++ frontend/src/app/api/admin/payments/route.ts | 88 +++++++++++++++ frontend/src/app/api/admin/users/route.ts | 77 +++++++++++++ .../src/app/api/payment/epay/create/route.ts | 34 ++++++ .../src/app/api/payment/epay/notify/route.ts | 19 ++++ .../src/app/api/payment/epay/status/route.ts | 5 + .../src/app/api/payment/packages/route.ts | 4 + frontend/src/app/globals.css | 14 +++ frontend/src/app/page.tsx | 43 +++++++ frontend/src/lib/epay/config.ts | 44 ++++++++ frontend/src/lib/epay/sign.ts | 18 +++ frontend/src/lib/supabase/admin.ts | 19 ++++ .../migrations/20260727010000_admin_users.sql | 11 ++ .../20260727020000_epay_packages_orders.sql | 69 ++++++++++++ .../20260727030000_payment_admin_stats.sql | 23 ++++ .../tests/admin-payments-contract.test.ts | 38 +++++++ frontend/tests/admin-users-contract.test.ts | 47 ++++++++ frontend/tests/epay-payment-contract.test.ts | 20 ++++ .../tests/staging-backend-workflows.test.ts | 82 ++++++++++++-- 37 files changed, 1257 insertions(+), 28 deletions(-) create mode 100644 .gitea/workflows/apply-supabase-profile-migrations.yml create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/deploy-production.yml create mode 100644 .gitea/workflows/deploy-staging.yml create mode 100644 .gitea/workflows/migrate-staging-database.yml create mode 100644 .gitea/workflows/publish-pypi.yml create mode 100644 .gitea/workflows/release-quality-gate.yml create mode 100644 .gitea/workflows/test.yml create mode 100644 frontend/src/app/admin/packages/page.tsx create mode 100644 frontend/src/app/admin/payments/page.tsx create mode 100644 frontend/src/app/admin/users/page.tsx create mode 100644 frontend/src/app/api/admin/packages/route.ts create mode 100644 frontend/src/app/api/admin/payments/route.ts create mode 100644 frontend/src/app/api/admin/users/route.ts create mode 100644 frontend/src/app/api/payment/epay/create/route.ts create mode 100644 frontend/src/app/api/payment/epay/notify/route.ts create mode 100644 frontend/src/app/api/payment/epay/status/route.ts create mode 100644 frontend/src/app/api/payment/packages/route.ts create mode 100644 frontend/src/lib/epay/config.ts create mode 100644 frontend/src/lib/epay/sign.ts create mode 100644 frontend/supabase/migrations/20260727010000_admin_users.sql create mode 100644 frontend/supabase/migrations/20260727020000_epay_packages_orders.sql create mode 100644 frontend/supabase/migrations/20260727030000_payment_admin_stats.sql create mode 100644 frontend/tests/admin-payments-contract.test.ts create mode 100644 frontend/tests/admin-users-contract.test.ts create mode 100644 frontend/tests/epay-payment-contract.test.ts diff --git a/.gitea/workflows/apply-supabase-profile-migrations.yml b/.gitea/workflows/apply-supabase-profile-migrations.yml new file mode 100644 index 00000000..d804daab --- /dev/null +++ b/.gitea/workflows/apply-supabase-profile-migrations.yml @@ -0,0 +1,60 @@ +name: Apply Supabase profile migrations + +on: + workflow_dispatch: + +concurrency: + group: supabase-profile-migrations + cancel-in-progress: false + +env: + GITEA_SHA: ${{ gitea.sha }} + DEPLOY_HOST: 103.117.123.53 + DEPLOY_PORT: '22000' + DEPLOY_USER: root + DEPLOY_PATH: /opt/jyotisha-app + +jobs: + apply: + runs-on: xiaoxin + timeout-minutes: 15 + steps: + - name: Checkout current Gitea revision + run: | + set -euo pipefail + git init . + git remote remove origin 2>/dev/null || true + git remote add origin https://git.copse.top/root/Jyotisha.git + git fetch --no-tags origin "$GITEA_SHA" main + git checkout --detach --force "$GITEA_SHA" + - name: Verify runner toolchain and require current main + run: | + set -euo pipefail + python3 --version + node --version + npm --version + docker version + test "$(git rev-parse HEAD)" = "$(git ls-remote origin refs/heads/main | awk '{print $1}')" + - name: Configure SSH and apply reviewed files + env: { SSH_PRIVATE_KEY: '${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }}' } + run: | + set -euo pipefail + install -m 700 -d ~/.ssh + printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/jyotisha-production; chmod 600 ~/.ssh/jyotisha-production + printf '%s\n' '[103.117.123.53]:22000 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHQJvN2Mo3Yq8e6ZIK4P2blJ5Vjj0HbknEuk7TyjhMbO' > ~/.ssh/known_hosts + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-production -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" + remote="$DEPLOY_PATH/tmp/profile-migrations/$GITEA_RUN_NUMBER" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "install -m 700 -d '$remote'" + rsync -az -e "ssh $SSH_OPTIONS" frontend/supabase/migrations/20260718*.sql frontend/supabase/migrations/20260721100000_chat_sessions_delete_grant.sql "$DEPLOY_USER@$DEPLOY_HOST:$remote/" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "cd '$DEPLOY_PATH' && REMOTE_DIR='$remote' bash -s" <<'REMOTE' + set -euo pipefail + set +x + trap 'rm -rf "$REMOTE_DIR"' EXIT + set -a; . .env.production; set +a + DB_URL="${SUPABASE_DB_URL:-${DATABASE_URL:-}}" + test -n "$DB_URL" + for sql_file in "$REMOTE_DIR"/*.sql; do + echo "applying $(basename "$sql_file")" + docker run --rm -i postgres:16-alpine psql "$DB_URL" --set ON_ERROR_STOP=1 --quiet < "$sql_file" + done + REMOTE diff --git a/.gitea/workflows/backend-quality-gate.yml b/.gitea/workflows/backend-quality-gate.yml index a30aadef..a925a632 100644 --- a/.gitea/workflows/backend-quality-gate.yml +++ b/.gitea/workflows/backend-quality-gate.yml @@ -1,4 +1,4 @@ -name: Staging Backend Quality Gate +name: Staging Backend Quality Gate (push the reviewed main SHA to staging to auto-deploy) on: pull_request: @@ -31,9 +31,10 @@ jobs: run: | set -euo pipefail git init . + git remote remove origin 2>/dev/null || true git remote add origin "https://git.copse.top/root/Jyotisha.git" git fetch --no-tags origin "${GITEA_SHA}" - git checkout --detach "${GITEA_SHA}" + git checkout --detach --force "${GITEA_SHA}" - name: Verify Linux runner toolchain run: | set -euo pipefail @@ -86,9 +87,10 @@ jobs: run: | set -euo pipefail git init . + git remote remove origin 2>/dev/null || true git remote add origin "https://git.copse.top/root/Jyotisha.git" git fetch --no-tags origin main "${GITEA_SHA}" - git checkout --detach "${GITEA_SHA}" + git checkout --detach --force "${GITEA_SHA}" - name: Build, publish, and deploy immutable staging images env: REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 00000000..e309f03b --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,44 @@ +name: Jyotish Skill CI (manual) + +on: + workflow_dispatch: + +jobs: + validate: + runs-on: xiaoxin + timeout-minutes: 30 + env: + GITEA_SHA: ${{ gitea.sha }} + steps: + - name: Checkout current Gitea revision + run: | + set -euo pipefail + git init . + git remote remove origin 2>/dev/null || true + git remote add origin https://git.copse.top/root/Jyotisha.git + git fetch --no-tags origin "$GITEA_SHA" + git checkout --detach --force "$GITEA_SHA" + - name: Verify runner toolchain + run: | + set -euo pipefail + python3 --version + node --version + npm --version + docker version + - name: Install and validate + env: + NEXT_PUBLIC_SUPABASE_URL: https://ci-placeholder.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-placeholder + run: | + set -euo pipefail + python3 -m pip install --upgrade pip + python3 -m pip install -r requirements.txt -r requirements-dev.txt + npm ci --prefix frontend + ruff check scripts/run_quality_gate.py tests/test_varga_bphs.py tests/test_ashtakavarga_invariants.py tests/test_cli_smoke.py tests/test_yoga_rules_integrity.py + python3 -m py_compile scripts/*.py jyotish_vedic/*.py mcp_server.py + python3 scripts/run_quality_gate.py --profile quick --skip-yoga-logic --skip-frontend-runtime + python3 scripts/commercial_privacy_artifact_scan.py --json + npm test --prefix frontend + npm run lint --prefix frontend + npm run build --prefix frontend + python3 -m build diff --git a/.gitea/workflows/deploy-production.yml b/.gitea/workflows/deploy-production.yml new file mode 100644 index 00000000..7ef96484 --- /dev/null +++ b/.gitea/workflows/deploy-production.yml @@ -0,0 +1,59 @@ +name: Deploy production (manual only) + +on: + workflow_dispatch: + +concurrency: + group: production + cancel-in-progress: false + +env: + GITEA_SHA: ${{ gitea.sha }} + DEPLOY_HOST: 103.117.123.53 + DEPLOY_PORT: '22000' + DEPLOY_USER: root + DEPLOY_PATH: /opt/jyotisha-app + +jobs: + deploy: + runs-on: xiaoxin + timeout-minutes: 30 + steps: + - name: Checkout current Gitea revision + run: | + set -euo pipefail + git init . + git remote remove origin 2>/dev/null || true + git remote add origin https://git.copse.top/root/Jyotisha.git + git fetch --no-tags origin "$GITEA_SHA" main + git checkout --detach --force "$GITEA_SHA" + - name: Verify runner toolchain and current main + run: | + set -euo pipefail + python3 --version + node --version + npm --version + docker version + test "$(git rev-parse HEAD)" = "$(git ls-remote origin refs/heads/main | awk '{print $1}')" + - name: Configure pinned production SSH + env: + SSH_PRIVATE_KEY: ${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }} + run: | + set -euo pipefail + install -m 700 -d ~/.ssh + printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/jyotisha-production + chmod 600 ~/.ssh/jyotisha-production + printf '%s\n' '[103.117.123.53]:22000 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHQJvN2Mo3Yq8e6ZIK4P2blJ5Vjj0HbknEuk7TyjhMbO' > ~/.ssh/known_hosts + - name: Sync and rebuild reviewed revision + run: | + set -euo pipefail + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-production -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" + rsync -az --delete --exclude='.git/' --exclude='.env.production' --exclude='frontend/node_modules/' --exclude='frontend/.next/' -e "ssh $SSH_OPTIONS" ./ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "cd '$DEPLOY_PATH' && GITHUB_SHA='$GITEA_SHA' docker compose --env-file .env.production -f deploy/docker-compose.server.yml up -d --build --remove-orphans" + - name: Verify production + run: | + set -euo pipefail + curl -fsS --retry 12 --retry-delay 5 https://jyotisha.chat/login >/dev/null + test "$(curl -sS -o /dev/null -w '%{http_code}' https://jyotisha.chat/api/account)" = 401 + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-production -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "cd '$DEPLOY_PATH' && docker compose --env-file .env.production -f deploy/docker-compose.server.yml exec -T web node -e 'fetch(\"http://api:5200/api/health\").then(async r=>{const b=await r.json();if(!r.ok||b.status!==\"ok\"||b.swisseph_available!==true)process.exit(1)})'" diff --git a/.gitea/workflows/deploy-staging.yml b/.gitea/workflows/deploy-staging.yml new file mode 100644 index 00000000..697714bf --- /dev/null +++ b/.gitea/workflows/deploy-staging.yml @@ -0,0 +1,105 @@ +name: Deploy staging ACR digest (manual rollback or redeploy) + +on: + workflow_dispatch: + inputs: + deploy_sha: + description: Exact tested 40-character staging commit SHA + required: true + type: string + allow_rollback: + description: Explicitly permit a manual rollback + required: true + default: false + type: boolean + +concurrency: + group: staging-mutation + cancel-in-progress: false + +jobs: + deploy: + runs-on: xiaoxin + timeout-minutes: 30 + env: + GITEA_SHA: ${{ gitea.sha }} + REGISTRY_HOST: crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com + IMAGE_REPOSITORY: crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com/copse/jyotisha + DEPLOY_HOST: ${{ vars.STAGING_HOST }} + DEPLOY_PORT: ${{ vars.STAGING_PORT }} + DEPLOY_USER: ${{ vars.STAGING_USER }} + DEPLOY_PATH: ${{ vars.STAGING_PATH }} + STAGING_URL: ${{ vars.STAGING_URL }} + STAGING_KNOWN_HOSTS: ${{ vars.STAGING_KNOWN_HOSTS }} + steps: + - name: Checkout trusted main controller + run: | + set -euo pipefail + git init . + git remote remove origin 2>/dev/null || true + git remote add origin https://git.copse.top/root/Jyotisha.git + git fetch --no-tags origin main staging + git checkout --detach --force origin/main + - name: Verify runner toolchain + run: | + set -euo pipefail + python3 --version + node --version + npm --version + docker version + - name: Validate revision and resolve immutable ACR images + id: images + env: + DEPLOY_SHA: ${{ inputs.deploy_sha }} + ALLOW_ROLLBACK: ${{ inputs.allow_rollback }} + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + run: | + set -euo pipefail + [[ "$DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "deploy_sha must be a full SHA" >&2; exit 1; } + [[ "$ALLOW_ROLLBACK" == true || "$ALLOW_ROLLBACK" == false ]] || exit 1 + staging_sha="$(git ls-remote origin refs/heads/staging | awk '{print $1}')" + [[ "$staging_sha" =~ ^[0-9a-f]{40}$ ]] || exit 1 + if [[ "$ALLOW_ROLLBACK" != true ]]; then [[ "$DEPLOY_SHA" == "$staging_sha" ]] || { echo "forward redeploy must use staging head" >&2; exit 1; }; fi + git cat-file -e "${DEPLOY_SHA}^{commit}" + git merge-base --is-ancestor "$DEPLOY_SHA" origin/main || { echo "revision is not in reviewed main history" >&2; exit 1; } + printf '%s' "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOST" --username "$REGISTRY_USERNAME" --password-stdin + api_digest="$(docker manifest inspect "${IMAGE_REPOSITORY}:api-${DEPLOY_SHA}" --verbose | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("Descriptor", d).get("digest", ""))')" + web_digest="$(docker manifest inspect "${IMAGE_REPOSITORY}:web-${DEPLOY_SHA}" --verbose | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("Descriptor", d).get("digest", ""))')" + [[ "$api_digest" =~ ^sha256:[0-9a-f]{64}$ && "$web_digest" =~ ^sha256:[0-9a-f]{64}$ ]] || exit 1 + printf 'git_sha=%s\napi_digest=%s\nweb_digest=%s\n' "$DEPLOY_SHA" "$api_digest" "$web_digest" > "${RUNNER_TEMP}/manifest.env" + node frontend/scripts/staging-image-manifest.mjs "${RUNNER_TEMP}/manifest.env" "$DEPLOY_SHA" "$IMAGE_REPOSITORY" >> "$GITHUB_OUTPUT" + echo "deploy_sha=$DEPLOY_SHA" >> "$GITHUB_OUTPUT" + - name: Deploy under pinned SSH host identity + env: + SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + DEPLOY_SHA: ${{ steps.images.outputs.deploy_sha }} + API_IMAGE: ${{ steps.images.outputs.api_image }} + WEB_IMAGE: ${{ steps.images.outputs.web_image }} + ALLOW_ROLLBACK: ${{ inputs.allow_rollback }} + run: | + set -euo pipefail + ssh_root="${RUNNER_TEMP}/staging-ssh" + key_path="$ssh_root/id_ed25519" + known_hosts_path="$ssh_root/known_hosts" + incoming="$DEPLOY_PATH/.incoming/$GITEA_RUN_NUMBER-$GITEA_RUN_ATTEMPT" + install -m 700 -d "$ssh_root" + printf '%s\n' "$SSH_PRIVATE_KEY" | tr -d '\r' > "$key_path" + printf '%s\n' "$STAGING_KNOWN_HOSTS" | tr -d '\r' > "$known_hosts_path" + chmod 600 "$key_path" "$known_hosts_path" + ssh_options=(-i "$key_path" -p "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path") + remote="$DEPLOY_USER@$DEPLOY_HOST" + cleanup() { ssh "${ssh_options[@]}" "$remote" "DOCKER_CONFIG='$incoming/.docker' docker logout '$REGISTRY_HOST' >/dev/null 2>&1 || true; rm -rf -- '$incoming'" >/dev/null 2>&1 || true; docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true; rm -rf -- "$ssh_root"; } + trap cleanup EXIT + ssh "${ssh_options[@]}" "$remote" "install -d -m 700 '$incoming/.docker'" + tar -cf "${RUNNER_TEMP}/deploy.tar" deploy + scp -i "$key_path" -P "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path" "${RUNNER_TEMP}/deploy.tar" "$remote:$incoming/deploy.tar" + ssh "${ssh_options[@]}" "$remote" "tar -xf '$incoming/deploy.tar' -C '$incoming' && rm -f -- '$incoming/deploy.tar'" + previous_sha="$(ssh "${ssh_options[@]}" "$remote" "state='$DEPLOY_PATH/.state/deployed-revision'; if [ -f \"\$state\" ]; then cat \"\$state\"; else id=\$(docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=web' | head -n 1); if [ -n \"\$id\" ]; then docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"\$id\" | sed -n 's/^GITHUB_SHA=//p' | head -n 1; else printf not-deployed; fi; fi")" + [[ "$previous_sha" == not-deployed || "$previous_sha" =~ ^[0-9a-f]{40}$ ]] || exit 1 + forward_verified=false + if [[ "$previous_sha" != not-deployed && "$previous_sha" != "$DEPLOY_SHA" && "$ALLOW_ROLLBACK" != true ]]; then git cat-file -e "${previous_sha}^{commit}" 2>/dev/null || git fetch origin "$previous_sha"; git merge-base --is-ancestor "$previous_sha" "$DEPLOY_SHA" || { echo "default forward-only deployment refused" >&2; exit 1; }; forward_verified=true; fi + printf '%s' "$REGISTRY_PASSWORD" | ssh "${ssh_options[@]}" "$remote" "DOCKER_CONFIG='$incoming/.docker' docker login '$REGISTRY_HOST' --username '$REGISTRY_USERNAME' --password-stdin" + ssh "${ssh_options[@]}" "$remote" "INCOMING_PATH='$incoming' DEPLOY_PATH='$DEPLOY_PATH' API_IMAGE='$API_IMAGE' WEB_IMAGE='$WEB_IMAGE' DEPLOY_SHA='$DEPLOY_SHA' EXPECTED_PREVIOUS_SHA='$previous_sha' ALLOW_ROLLBACK='$ALLOW_ROLLBACK' FORWARD_REVISION_VERIFIED='$forward_verified' DOCKER_CONFIG='$incoming/.docker' STAGING_URL='$STAGING_URL' bash '$incoming/deploy/run-staging-deploy.sh'" diff --git a/.gitea/workflows/migrate-staging-database.yml b/.gitea/workflows/migrate-staging-database.yml new file mode 100644 index 00000000..97f78453 --- /dev/null +++ b/.gitea/workflows/migrate-staging-database.yml @@ -0,0 +1,93 @@ +name: Migrate Staging Database (manual only) + +on: + workflow_dispatch: + inputs: + deploy_sha: + description: Full current staging SHA to migrate + required: true + type: string + +concurrency: + group: staging-mutation + cancel-in-progress: false + +jobs: + migrate: + runs-on: xiaoxin + timeout-minutes: 20 + env: + GITEA_SHA: ${{ gitea.sha }} + REGISTRY_HOST: crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com + IMAGE_REPOSITORY: crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com/copse/jyotisha + DEPLOY_HOST: ${{ vars.STAGING_HOST }} + DEPLOY_PORT: ${{ vars.STAGING_PORT }} + DEPLOY_USER: ${{ vars.STAGING_USER }} + DEPLOY_PATH: ${{ vars.STAGING_PATH }} + STAGING_KNOWN_HOSTS: ${{ vars.STAGING_KNOWN_HOSTS }} + steps: + - name: Checkout trusted main controller + run: | + set -euo pipefail + git init . + git remote remove origin 2>/dev/null || true + git remote add origin https://git.copse.top/root/Jyotisha.git + git fetch --no-tags origin main staging + git checkout --detach --force origin/main + - name: Verify runner toolchain + run: | + set -euo pipefail + python3 --version + node --version + npm --version + docker version + - name: Validate revision and resolve migration image + id: image + env: + DEPLOY_SHA: ${{ inputs.deploy_sha }} + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + run: | + set -euo pipefail + [[ "$DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] || exit 1 + [[ "$(git ls-remote origin refs/heads/staging | awk '{print $1}')" == "$DEPLOY_SHA" ]] || { echo "migration requires current staging head" >&2; exit 1; } + git cat-file -e "${DEPLOY_SHA}^{commit}" + git merge-base --is-ancestor "$DEPLOY_SHA" origin/main || { echo "revision is not in reviewed main history" >&2; exit 1; } + printf '%s' "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOST" --username "$REGISTRY_USERNAME" --password-stdin + web_digest="$(docker manifest inspect "${IMAGE_REPOSITORY}:web-${DEPLOY_SHA}" --verbose | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("Descriptor", d).get("digest", ""))')" + [[ "$web_digest" =~ ^sha256:[0-9a-f]{64}$ ]] || exit 1 + echo "web_image=${IMAGE_REPOSITORY}@${web_digest}" >> "$GITHUB_OUTPUT" + echo "deploy_sha=$DEPLOY_SHA" >> "$GITHUB_OUTPUT" + - name: Apply digest-pinned migration under host lock + env: + SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + DEPLOY_SHA: ${{ steps.image.outputs.deploy_sha }} + WEB_IMAGE: ${{ steps.image.outputs.web_image }} + run: | + set -euo pipefail + ssh_root="${RUNNER_TEMP}/staging-migration-ssh" + key_path="$ssh_root/id_ed25519" + known_hosts_path="$ssh_root/known_hosts" + incoming="$DEPLOY_PATH/.incoming/$GITEA_RUN_NUMBER-$GITEA_RUN_ATTEMPT" + install -m 700 -d "$ssh_root" + printf '%s\n' "$SSH_PRIVATE_KEY" | tr -d '\r' > "$key_path" + printf '%s\n' "$STAGING_KNOWN_HOSTS" | tr -d '\r' > "$known_hosts_path" + chmod 600 "$key_path" "$known_hosts_path" + ssh_options=(-i "$key_path" -p "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path") + remote="$DEPLOY_USER@$DEPLOY_HOST" + cleanup() { ssh "${ssh_options[@]}" "$remote" "DOCKER_CONFIG='$incoming/.docker' docker logout '$REGISTRY_HOST' >/dev/null 2>&1 || true; rm -rf -- '$incoming'" >/dev/null 2>&1 || true; docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true; rm -rf -- "$ssh_root"; } + trap cleanup EXIT + ssh "${ssh_options[@]}" "$remote" "install -d -m 700 '$incoming/.docker'" + tar -cf "${RUNNER_TEMP}/deploy.tar" deploy + scp -i "$key_path" -P "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path" "${RUNNER_TEMP}/deploy.tar" "$remote:$incoming/deploy.tar" + ssh "${ssh_options[@]}" "$remote" "tar -xf '$incoming/deploy.tar' -C '$incoming' && rm -f -- '$incoming/deploy.tar'" + previous_sha="$(ssh "${ssh_options[@]}" "$remote" "state='$DEPLOY_PATH/.state/deployed-revision'; if [ -f \"\$state\" ]; then cat \"\$state\"; else id=\$(docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=web' | head -n 1); if [ -n \"\$id\" ]; then docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"\$id\" | sed -n 's/^GITHUB_SHA=//p' | head -n 1; else printf not-deployed; fi; fi")" + [[ "$previous_sha" == not-deployed || "$previous_sha" =~ ^[0-9a-f]{40}$ ]] || exit 1 + forward_verified=false + if [[ "$previous_sha" != not-deployed && "$previous_sha" != "$DEPLOY_SHA" ]]; then git cat-file -e "${previous_sha}^{commit}" 2>/dev/null || git fetch origin "$previous_sha"; git merge-base --is-ancestor "$previous_sha" "$DEPLOY_SHA" || { echo "migration rollback or divergence refused" >&2; exit 1; }; forward_verified=true; fi + printf '%s' "$REGISTRY_PASSWORD" | ssh "${ssh_options[@]}" "$remote" "DOCKER_CONFIG='$incoming/.docker' docker login '$REGISTRY_HOST' --username '$REGISTRY_USERNAME' --password-stdin" + ssh "${ssh_options[@]}" "$remote" "INCOMING_PATH='$incoming' DEPLOY_PATH='$DEPLOY_PATH' WEB_IMAGE='$WEB_IMAGE' DEPLOY_SHA='$DEPLOY_SHA' EXPECTED_PREVIOUS_SHA='$previous_sha' FORWARD_REVISION_VERIFIED='$forward_verified' DOCKER_CONFIG='$incoming/.docker' bash '$incoming/deploy/run-staging-migration.sh'" + - name: Operator action + run: echo 'Migration complete. Start Deploy staging manually with this exact SHA.' diff --git a/.gitea/workflows/publish-pypi.yml b/.gitea/workflows/publish-pypi.yml new file mode 100644 index 00000000..2d866cf5 --- /dev/null +++ b/.gitea/workflows/publish-pypi.yml @@ -0,0 +1,37 @@ +name: Publish to PyPI (manual only) + +on: + workflow_dispatch: + +jobs: + build-and-publish: + runs-on: xiaoxin + env: + GITEA_SHA: ${{ gitea.sha }} + steps: + - name: Checkout current Gitea revision + run: | + set -euo pipefail + git init . + git remote remove origin 2>/dev/null || true + git remote add origin https://git.copse.top/root/Jyotisha.git + git fetch --no-tags origin "$GITEA_SHA" + git checkout --detach --force "$GITEA_SHA" + - name: Verify runner toolchain + run: | + set -euo pipefail + python3 --version + node --version + npm --version + docker version + - name: Build and check package + run: | + set -euo pipefail + python3 -m pip install build twine + python3 -m build + python3 -m twine check dist/* + - name: Publish to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: python3 -m twine upload --skip-existing dist/* diff --git a/.gitea/workflows/release-quality-gate.yml b/.gitea/workflows/release-quality-gate.yml new file mode 100644 index 00000000..2ac47f70 --- /dev/null +++ b/.gitea/workflows/release-quality-gate.yml @@ -0,0 +1,38 @@ +name: Jyotish Release Quality Gate (manual only) + +on: + workflow_dispatch: + +jobs: + release-quality-gate: + runs-on: xiaoxin + timeout-minutes: 45 + env: + GITEA_SHA: ${{ gitea.sha }} + steps: + - name: Checkout current Gitea revision + run: | + set -euo pipefail + git init . + git remote remove origin 2>/dev/null || true + git remote add origin https://git.copse.top/root/Jyotisha.git + git fetch --no-tags origin "$GITEA_SHA" + git checkout --detach --force "$GITEA_SHA" + - name: Verify runner toolchain + run: | + set -euo pipefail + python3 --version + node --version + npm --version + docker version + - name: Install dependencies and run release gate + env: + NEXT_PUBLIC_SUPABASE_URL: https://ci-placeholder.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-placeholder + run: | + set -euo pipefail + python3 -m pip install --upgrade pip + python3 -m pip install -r requirements.txt -r requirements-dev.txt playwright + python3 -m playwright install --with-deps chromium + npm ci --prefix frontend + python3 scripts/run_quality_gate.py --profile release diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 00000000..b64cabc8 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,40 @@ +name: Jyotish Skill Tests (manual only) + +on: + workflow_dispatch: + +jobs: + test: + runs-on: xiaoxin + env: + GITEA_SHA: ${{ gitea.sha }} + steps: + - name: Checkout current Gitea revision + run: | + set -euo pipefail + git init . + git remote remove origin 2>/dev/null || true + git remote add origin https://git.copse.top/root/Jyotisha.git + git fetch --no-tags origin "$GITEA_SHA" + git checkout --detach --force "$GITEA_SHA" + - name: Verify runner toolchain + run: | + set -euo pipefail + python3 --version + node --version + npm --version + docker version + - name: Install dependencies and run tests + env: + NEXT_PUBLIC_SUPABASE_URL: https://ci-placeholder.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-placeholder + run: | + set -euo pipefail + python3 -m pip install --upgrade pip + python3 -m pip install -r requirements.txt -r requirements-dev.txt + npm ci --prefix frontend + python3 -m pytest -vv --maxfail=1 + python3 tests/run_all.py + npm test --prefix frontend + npm run lint --prefix frontend + npm run build --prefix frontend diff --git a/deploy/run-staging-migration.sh b/deploy/run-staging-migration.sh index 00d611f7..9b342251 100755 --- a/deploy/run-staging-migration.sh +++ b/deploy/run-staging-migration.sh @@ -17,7 +17,8 @@ done echo "unsafe staging migration revision" >&2 exit 1 } -[[ "$WEB_IMAGE" =~ ^ghcr\.io/jesse-ux/jyotisha-web@sha256:[0-9a-f]{64}$ ]] || { +image_pattern='^crpi-d1feco6itet73spp\.cn-hongkong\.personal\.cr\.aliyuncs\.com/copse/jyotisha@sha256:[0-9a-f]{64}$' +[[ "$WEB_IMAGE" =~ $image_pattern ]] || { echo "unsafe staging migration image" >&2 exit 1 } diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 70ea2bca..d28cb005 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1503,3 +1503,19 @@ - 防复发:开放叙事不等于被动倾听;事件轮默认负责自然推进,但不得恢复固定问卷模板。 - 相关记录:BUG-075、BUG-080 - 修复版本:本次修复提交 + +## BUG-082 | Gitea 工作流混用 runner、外部 Actions 与重复 staging 触发 + +- 状态:resolved +- 首次发现:2026-07-27 +- 最近更新:2026-07-27 +- 影响面:Gitea CI、测试镜像发布、测试服务器部署、手动回滚与数据库迁移 +- 用户现象:部分 Gitea workflow 仍使用 `ubuntu-latest`、GitHub Actions checkout/setup 和旧 Gitea Registry;`ci.yml` 与 staging 质量门禁同时监听 staging push,可能重复测试,而手动部署/迁移又把服务器上一版本硬编码成 `not-deployed`。 +- 触发条件:向 staging 推送已合入 main 的 SHA,或手动执行测试环境重部署、回滚和迁移。 +- 根因:工作流从 GitHub 与旧 Gitea Registry 迁移时只改造了自动 staging 主链,没有建立覆盖 `.gitea/workflows/*.yml` 的统一 runner、checkout、触发器、镜像仓库和前序部署状态审计。 +- 修复:全部 job 统一使用 `xiaoxin`,以原生 bash、git fetch 和 detached checkout 取代外部 Actions,并显式检查 runner 的 Python、Node、npm、Docker。仅 `backend-quality-gate.yml` 保留 staging push 自动入口,校验通过后向现有 ACR 共享仓库发布 `api-SHA`/`web-SHA` 并按 digest 部署;其余发布、生产、迁移及回滚入口保持手动。手动 staging 部署和迁移改为校验 full SHA 属于 main、使用 ACR digest、读取服务器当前 SHA,并默认只允许前向变更。 +- 验证:PyYAML `safe_load` 覆盖全部 Gitea YAML;相关 staging shell 脚本以 LF 输入通过 `bash -n`;新增的 3 项 Gitea workflow 审计与 3 项 image manifest 回归通过;禁用项扫描确认不存在 `ubuntu-latest`、GitHub Actions URL 或旧 Registry secret 名。完整双文件命令在当前 Windows/WSL 环境仍被既有 GitHub workflow 文本断言和 Windows 路径传给 WSL bash 的兼容问题阻塞,未伪报全量通过。 +- 防复发:工作流审计测试枚举全部 `.gitea/workflows/*.yml`,逐 job 锁定 `runs-on: xiaoxin`、原生 checkout、唯一 staging push 所有者、manual-only 高风险入口、ACR digest 主链和禁止的旧平台/Registry 标识。 +- 相关记录:无 +- 复发自:无 +- 修复版本:待提交(本地可测) diff --git a/frontend/src/app/admin/codes/page.tsx b/frontend/src/app/admin/codes/page.tsx index f4cec373..014354e9 100644 --- a/frontend/src/app/admin/codes/page.tsx +++ b/frontend/src/app/admin/codes/page.tsx @@ -147,7 +147,7 @@ export default function AdminCodesPage() {

兑换码管理

- 返回对话 +
管理员管理 充值套餐 支付记录 返回对话
diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx index a5e09cd3..5966771a 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -1,6 +1,6 @@ import { ReactNode } from "react"; import { redirect } from "next/navigation"; -import { isAdminEmail } from "@/lib/supabase/admin"; +import { isAdminUser } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; export const dynamic = "force-dynamic"; @@ -12,7 +12,7 @@ export default async function AdminLayout({ children }: { children: ReactNode }) const { data: { user } } = await supabase.auth.getUser(); if (!user) redirect("/login"); - if (!isAdminEmail(user.email)) redirect("/"); + if (!(await isAdminUser(user))) redirect("/"); return children; } diff --git a/frontend/src/app/admin/packages/page.tsx b/frontend/src/app/admin/packages/page.tsx new file mode 100644 index 00000000..cd9bb1d7 --- /dev/null +++ b/frontend/src/app/admin/packages/page.tsx @@ -0,0 +1,12 @@ +"use client"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import type { FormEvent } from "react"; +type Package = { id: string; name: string; description: string; priceCents: number; credits: number; sortOrder: number; enabled: boolean }; +const empty = { name: "", description: "", priceCents: 100, credits: 10, sortOrder: 0, enabled: true }; +export default function AdminPackagesPage() { const [items, setItems] = useState([]); const [form, setForm] = useState(empty); const [editing, setEditing] = useState(null); const [error, setError] = useState(""); + async function load() { const response = await fetch("/api/admin/packages", { cache: "no-store" }); const data = await response.json(); if (!response.ok) throw new Error(data.error || "读取失败"); setItems(data.packages); } + useEffect(() => { void load().catch((e) => setError(e.message)); }, []); + async function save(event: FormEvent) { event.preventDefault(); setError(""); const response = await fetch("/api/admin/packages", { method: editing ? "PATCH" : "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(editing ? { ...form, id: editing } : form) }); const data = await response.json(); if (!response.ok) { setError(data.error || "保存失败"); return; } setForm(empty); setEditing(null); await load(); } + async function disable(id: string) { const response = await fetch("/api/admin/packages", { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ id }) }); if (!response.ok) setError("停用失败"); else await load(); } + return

充值套餐

兑换码管理 支付记录 返回对话

{editing ? "编辑套餐" : "添加套餐"}

只配置公开套餐,不显示易支付密钥。

{error &&

{error}

}

套餐列表

{items.length} 个套餐

{items.map((item) => )}
名称价格点数状态操作
{item.name}
{item.description}
¥{(item.priceCents / 100).toFixed(2)}{item.credits}{item.enabled ? "启用" : "停用"} {item.enabled && }
; } diff --git a/frontend/src/app/admin/payments/page.tsx b/frontend/src/app/admin/payments/page.tsx new file mode 100644 index 00000000..962a1656 --- /dev/null +++ b/frontend/src/app/admin/payments/page.tsx @@ -0,0 +1,45 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; + +type Order = { orderNo: string; userEmail: string | null; packageName: string | null; moneyCents: number; credits: number; status: string; epayTradeNo: string | null; createdAt: string; paidAt: string | null }; +type Stats = { totalOrders: number; paidOrders: number; pendingOrders: number; failedExpiredOrders: number; paidAmountCents: number; grantedCredits: number }; +const initialStats: Stats = { totalOrders: 0, paidOrders: 0, pendingOrders: 0, failedExpiredOrders: 0, paidAmountCents: 0, grantedCredits: 0 }; +const statusLabels: Record = { pending: "待支付", paid: "已支付", failed: "失败", expired: "已过期" }; +const dateFormatter = new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "short", timeZone: "Asia/Shanghai" }); + +function formatDate(value: string | null) { return value ? dateFormatter.format(new Date(value)) : "—"; } +function formatMoney(cents: number) { return `¥${(cents / 100).toFixed(2)}`; } + +export default function AdminPaymentsPage() { + const [orders, setOrders] = useState([]); + const [stats, setStats] = useState(initialStats); + const [status, setStatus] = useState(""); + const [from, setFrom] = useState(""); + const [to, setTo] = useState(""); + const [offset, setOffset] = useState(0); + const [total, setTotal] = useState(0); + const [hasMore, setHasMore] = useState(false); + const [error, setError] = useState(""); + const limit = 20; + + useEffect(() => { + const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }); + if (status) params.set("status", status); + if (from) params.set("from", new Date(`${from}T00:00:00+08:00`).toISOString()); + if (to) params.set("to", new Date(`${to}T23:59:59.999+08:00`).toISOString()); + void fetch(`/api/admin/payments?${params}`, { cache: "no-store" }).then(async (response) => { + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || "读取支付记录失败"); + setOrders(payload.orders); setStats(payload.stats); setTotal(payload.pagination.total); setHasMore(payload.pagination.hasMore); setError(""); + }).catch((caught) => setError(caught instanceof Error ? caught.message : "读取支付记录失败")); + }, [status, from, to, offset]); + + function filterChange(setter: (value: string) => void, value: string) { setter(value); setOffset(0); } + + return

支付记录

充值套餐 兑换码管理 返回对话
+

平台支付统计

统计范围按订单创建时间筛选,金额为人民币。

总订单{stats.totalOrders}
已支付{stats.paidOrders}
待支付{stats.pendingOrders}
失败/过期{stats.failedExpiredOrders}
已支付金额{formatMoney(stats.paidAmountCents)}
已赠送点数{stats.grantedCredits}
+

支付记录

{total} 条平台订单

{error &&

{error}

}
{orders.map((order) => )}{orders.length === 0 && }
订单号用户邮箱套餐金额点数状态易支付交易号创建时间支付时间
{order.orderNo}{order.userEmail || "—"}{order.packageName || "—"}{formatMoney(order.moneyCents)}{order.credits}{statusLabels[order.status] || order.status}{order.epayTradeNo || "—"}{formatDate(order.createdAt)}{formatDate(order.paidAt)}
暂无支付记录
第 {total ? offset + 1 : 0}–{Math.min(offset + orders.length, total)} 条
+
; +} diff --git a/frontend/src/app/admin/users/page.tsx b/frontend/src/app/admin/users/page.tsx new file mode 100644 index 00000000..77536ffc --- /dev/null +++ b/frontend/src/app/admin/users/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import Link from "next/link"; +import { FormEvent, useEffect, useState } from "react"; + +type AdminUser = { userId: string | null; email: string | null; createdAt: string | null; source: "env" | "database" }; + +export default function AdminUsersPage() { + const [users, setUsers] = useState([]); + const [email, setEmail] = useState(""); + const [error, setError] = useState(""); + async function load() { + const response = await fetch("/api/admin/users", { cache: "no-store" }); + const payload = await response.json().catch(() => null); + if (!response.ok) throw new Error(payload?.error || "暂时无法读取管理员列表"); + setUsers(payload.users); + } + useEffect(() => { void load().catch((caught) => setError(caught.message)); }, []); + async function add(event: FormEvent) { + event.preventDefault(); setError(""); + const response = await fetch("/api/admin/users", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email }) }); + const payload = await response.json().catch(() => null); + if (!response.ok) { setError(payload?.error || "添加失败"); return; } + setEmail(""); await load(); + } + async function revoke(userId: string) { + const response = await fetch("/api/admin/users", { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ userId }) }); + if (!response.ok) { const payload = await response.json().catch(() => null); setError(payload?.error || "撤销失败"); return; } + await load(); + } + return

管理员管理

兑换码管理

添加管理员

仅能添加已经注册的 Supabase 用户。

{error &&

{error}

}

当前管理员

{users.length} 位

{users.map((user) => )}
邮箱来源添加时间操作
{user.email || "—"}{user.source === "env" ? "环境配置" : "后台配置"}{user.createdAt ? new Date(user.createdAt).toLocaleString("zh-CN") : "—"}{user.userId && }
; +} diff --git a/frontend/src/app/api/account/route.ts b/frontend/src/app/api/account/route.ts index 9f151eb9..16b40846 100644 --- a/frontend/src/app/api/account/route.ts +++ b/frontend/src/app/api/account/route.ts @@ -8,7 +8,7 @@ import { applyAccountProfileConcurrencyGuards, resolveAccountBirthTimeApplicationPatch, } from "@/lib/account-profile-patch"; -import { createAdminSupabaseClient, isAdminEmail } from "@/lib/supabase/admin"; +import { createAdminSupabaseClient, isAdminUser } from "@/lib/supabase/admin"; import { isSupabaseConfigurationError, } from "@/lib/supabase/config"; @@ -90,7 +90,7 @@ export async function GET() { return NextResponse.json({ user: { id: user.id, email: user.email ?? null }, credits: profile.credits, - isAdmin: isAdminEmail(user.email), + isAdmin: await isAdminUser(user), rectificationPriceCredits, hasConfirmedBirthTime: profile.birth_time_status === "confirmed" && typeof profile.active_birth_time === "string", diff --git a/frontend/src/app/api/admin/codes/route.ts b/frontend/src/app/api/admin/codes/route.ts index e4f22605..be2becd3 100644 --- a/frontend/src/app/api/admin/codes/route.ts +++ b/frontend/src/app/api/admin/codes/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { createAdminSupabaseClient, - isAdminEmail, + isAdminUser, } from "@/lib/supabase/admin"; import { generateRedeemCode, @@ -11,7 +11,6 @@ import { } from "@/lib/supabase/codes"; import { isSupabaseConfigurationError, - SupabaseConfigurationError, } from "@/lib/supabase/config"; import { createServerSupabaseClient } from "@/lib/supabase/server"; @@ -25,14 +24,10 @@ const createCodesSchema = z.object({ }); async function requireAdmin() { - if (!process.env.ADMIN_EMAILS?.trim()) { - throw new SupabaseConfigurationError(["ADMIN_EMAILS"]); - } - const supabase = await createServerSupabaseClient(); const { data: { user }, error } = await supabase.auth.getUser(); if (error || !user) return { response: NextResponse.json({ error: "请先登录" }, { status: 401 }) }; - if (!isAdminEmail(user.email)) { + if (!(await isAdminUser(user))) { return { response: NextResponse.json({ error: "无管理员权限" }, { status: 403 }) }; } return { user }; diff --git a/frontend/src/app/api/admin/packages/route.ts b/frontend/src/app/api/admin/packages/route.ts new file mode 100644 index 00000000..f99607a0 --- /dev/null +++ b/frontend/src/app/api/admin/packages/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { createAdminSupabaseClient, isAdminUser } from "@/lib/supabase/admin"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +export const runtime = "nodejs"; +const schema = z.object({ name: z.string().trim().min(1).max(80), description: z.string().trim().max(500), priceCents: z.number().int().positive().max(100_000_000), credits: z.number().int().positive().max(10_000_000), sortOrder: z.number().int().min(-100_000).max(100_000), enabled: z.boolean() }); +async function requireAdmin() { + const client = await createServerSupabaseClient(); + const { data: { user } } = await client.auth.getUser(); + if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + if (!(await isAdminUser(user))) return NextResponse.json({ error: "无管理员权限" }, { status: 403 }); + return user; +} +function output(row: Record) { return { id: row.id, name: row.name, description: row.description, priceCents: row.price_cents, credits: row.credits, sortOrder: row.sort_order, enabled: row.enabled, createdAt: row.created_at, updatedAt: row.updated_at }; } +export async function GET() { const auth = await requireAdmin(); if (auth instanceof NextResponse) return auth; const { data, error } = await createAdminSupabaseClient().from("payment_packages").select("*").order("sort_order").order("created_at"); if (error) return NextResponse.json({ error: "暂时无法读取套餐" }, { status: 500 }); return NextResponse.json({ packages: (data || []).map(output) }); } +export async function POST(request: Request) { const auth = await requireAdmin(); if (auth instanceof NextResponse) return auth; const parsed = schema.safeParse(await request.json().catch(() => null)); if (!parsed.success) return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 }); const p = parsed.data; const { data, error } = await createAdminSupabaseClient().from("payment_packages").insert({ name: p.name, description: p.description, price_cents: p.priceCents, credits: p.credits, sort_order: p.sortOrder, enabled: p.enabled, created_by: auth.id }).select().single(); if (error) return NextResponse.json({ error: "创建套餐失败" }, { status: 500 }); return NextResponse.json({ package: output(data) }, { status: 201 }); } +export async function PATCH(request: Request) { const auth = await requireAdmin(); if (auth instanceof NextResponse) return auth; const body = await request.json().catch(() => null); const id = typeof body?.id === "string" ? body.id : ""; const parsed = schema.safeParse(body); if (!id || !parsed.success) return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 }); const p = parsed.data; const { data, error } = await createAdminSupabaseClient().from("payment_packages").update({ name: p.name, description: p.description, price_cents: p.priceCents, credits: p.credits, sort_order: p.sortOrder, enabled: p.enabled, updated_at: new Date().toISOString() }).eq("id", id).select().single(); if (error) return NextResponse.json({ error: "更新套餐失败" }, { status: 500 }); return NextResponse.json({ package: output(data) }); } +export async function DELETE(request: Request) { const auth = await requireAdmin(); if (auth instanceof NextResponse) return auth; const body = await request.json().catch(() => null); if (typeof body?.id !== "string") return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 }); const { error } = await createAdminSupabaseClient().from("payment_packages").update({ enabled: false, updated_at: new Date().toISOString() }).eq("id", body.id); if (error) return NextResponse.json({ error: "停用套餐失败" }, { status: 500 }); return NextResponse.json({ ok: true }); } diff --git a/frontend/src/app/api/admin/payments/route.ts b/frontend/src/app/api/admin/payments/route.ts new file mode 100644 index 00000000..b0e136ca --- /dev/null +++ b/frontend/src/app/api/admin/payments/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { createAdminSupabaseClient, isAdminUser } from "@/lib/supabase/admin"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +export const runtime = "nodejs"; + +const querySchema = z.object({ + status: z.enum(["pending", "paid", "failed", "expired"]).optional(), + from: z.string().datetime({ offset: true }).optional(), + to: z.string().datetime({ offset: true }).optional(), + limit: z.coerce.number().int().min(1).max(100).default(20), + offset: z.coerce.number().int().min(0).default(0), +}); + +async function requireAdmin() { + const supabase = await createServerSupabaseClient(); + const { data: { user }, error } = await supabase.auth.getUser(); + if (error || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + if (!(await isAdminUser(user))) return NextResponse.json({ error: "无管理员权限" }, { status: 403 }); + return user; +} + +export async function GET(request: Request) { + try { + const auth = await requireAdmin(); + if (auth instanceof NextResponse) return auth; + + const url = new URL(request.url); + const parsed = querySchema.safeParse(Object.fromEntries(url.searchParams)); + if (!parsed.success) return NextResponse.json({ error: "查询参数不正确" }, { status: 400 }); + const { status, from, to, limit, offset } = parsed.data; + if (from && to && new Date(from) > new Date(to)) return NextResponse.json({ error: "开始日期不能晚于结束日期" }, { status: 400 }); + + const admin = createAdminSupabaseClient(); + let ordersQuery = admin + .from("payment_orders") + .select("order_no,user_id,money_cents,credits,status,epay_trade_no,paid_at,created_at,payment_packages(name)", { count: "exact" }) + .order("created_at", { ascending: false }) + .range(offset, offset + limit - 1); + if (status) ordersQuery = ordersQuery.eq("status", status); + if (from) ordersQuery = ordersQuery.gte("created_at", from); + if (to) ordersQuery = ordersQuery.lte("created_at", to); + + const statsPromise = admin.rpc("get_payment_order_stats", { + p_from: from ?? null, + p_to: to ?? null, + }); + const [{ data, error, count }, statsResult] = await Promise.all([ordersQuery, statsPromise]); + if (error || statsResult.error) return NextResponse.json({ error: "暂时无法读取支付记录" }, { status: 500 }); + + const emailEntries = await Promise.all([...new Set((data ?? []).map((row) => row.user_id))].map(async (userId) => { + const result = await admin.auth.admin.getUserById(userId); + return [userId, result.data.user?.email ?? null] as const; + })); + const emails = new Map(emailEntries); + const orders = (data ?? []).map((row) => { + const relation = row.payment_packages as { name?: string } | { name?: string }[] | null; + const packageName = Array.isArray(relation) ? relation[0]?.name : relation?.name; + return { + orderNo: row.order_no, + userEmail: emails.get(row.user_id) ?? null, + packageName: packageName ?? null, + moneyCents: row.money_cents, + credits: row.credits, + status: row.status, + epayTradeNo: row.epay_trade_no, + createdAt: row.created_at, + paidAt: row.paid_at, + }; + }); + const rawStats = Array.isArray(statsResult.data) ? statsResult.data[0] : statsResult.data; + const stats = { + totalOrders: Number(rawStats?.total_orders ?? 0), + paidOrders: Number(rawStats?.paid_orders ?? 0), + pendingOrders: Number(rawStats?.pending_orders ?? 0), + failedExpiredOrders: Number(rawStats?.failed_expired_orders ?? 0), + paidAmountCents: Number(rawStats?.paid_amount_cents ?? 0), + grantedCredits: Number(rawStats?.granted_credits ?? 0), + }; + const total = count ?? 0; + return NextResponse.json({ orders, stats, pagination: { limit, offset, total, hasMore: offset + orders.length < total } }); + } catch (error) { + if (isSupabaseConfigurationError(error)) return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); + return NextResponse.json({ error: "支付记录服务暂时不可用" }, { status: 500 }); + } +} diff --git a/frontend/src/app/api/admin/users/route.ts b/frontend/src/app/api/admin/users/route.ts new file mode 100644 index 00000000..b299aede --- /dev/null +++ b/frontend/src/app/api/admin/users/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { createAdminSupabaseClient, isAdminUser } from "@/lib/supabase/admin"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +export const runtime = "nodejs"; + +async function requireAdmin() { + const supabase = await createServerSupabaseClient(); + const { data: { user }, error } = await supabase.auth.getUser(); + if (error || !user) return { response: NextResponse.json({ error: "请先登录" }, { status: 401 }) }; + if (!(await isAdminUser(user))) return { response: NextResponse.json({ error: "无管理员权限" }, { status: 403 }) }; + return { user }; +} + +function envAdmins() { + return (process.env.ADMIN_EMAILS ?? "").split(",").map((email) => email.trim().toLowerCase()).filter(Boolean); +} + +export async function GET() { + try { + const auth = await requireAdmin(); + if ("response" in auth) return auth.response; + const admin = createAdminSupabaseClient(); + const { data, error } = await admin.from("admin_users").select("user_id,created_at,created_by").is("revoked_at", null).order("created_at", { ascending: true }); + if (error) return NextResponse.json({ error: "暂时无法读取管理员列表" }, { status: 500 }); + const users = await Promise.all((data ?? []).map(async (row) => { + const result = await admin.auth.admin.getUserById(row.user_id); + return { userId: row.user_id, email: result.data.user?.email ?? null, createdAt: row.created_at, createdBy: row.created_by, source: "database" as const }; + })); + return NextResponse.json({ users: [...envAdmins().map((email) => ({ userId: null, email, createdAt: null, createdBy: null, source: "env" as const })), ...users] }); + } catch (error) { + if (isSupabaseConfigurationError(error)) return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); + return NextResponse.json({ error: "管理员服务暂时不可用" }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const auth = await requireAdmin(); + if ("response" in auth) return auth.response; + const parsed = z.object({ email: z.string().trim().email() }).safeParse(await request.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "邮箱格式不正确" }, { status: 400 }); + const email = parsed.data.email.toLowerCase(); + if (envAdmins().includes(email)) return NextResponse.json({ error: "该用户已由环境配置管理" }, { status: 409 }); + const admin = createAdminSupabaseClient(); + const { data: users, error: listError } = await admin.auth.admin.listUsers({ page: 1, perPage: 1000 }); + if (listError) return NextResponse.json({ error: "暂时无法查找用户" }, { status: 500 }); + const target = users.users.find((candidate) => candidate.email?.trim().toLowerCase() === email); + if (!target) return NextResponse.json({ error: "该邮箱尚未注册" }, { status: 404 }); + const { error } = await admin.from("admin_users").upsert({ user_id: target.id, created_by: auth.user.id, revoked_at: null, revoked_by: null }); + if (error) return NextResponse.json({ error: "添加管理员失败" }, { status: 500 }); + return NextResponse.json({ ok: true }, { status: 201 }); + } catch (error) { + if (isSupabaseConfigurationError(error)) return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); + return NextResponse.json({ error: "管理员服务暂时不可用" }, { status: 500 }); + } +} + +export async function DELETE(request: Request) { + try { + const auth = await requireAdmin(); + if ("response" in auth) return auth.response; + const parsed = z.object({ userId: z.string().uuid() }).safeParse(await request.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "用户参数不正确" }, { status: 400 }); + const admin = createAdminSupabaseClient(); + const target = await admin.auth.admin.getUserById(parsed.data.userId); + if (target.data.user?.email && envAdmins().includes(target.data.user.email.toLowerCase())) return NextResponse.json({ error: "环境配置管理员不可撤销" }, { status: 409 }); + const { error } = await admin.from("admin_users").update({ revoked_at: new Date().toISOString(), revoked_by: auth.user.id }).eq("user_id", parsed.data.userId).is("revoked_at", null); + if (error) return NextResponse.json({ error: "撤销管理员失败" }, { status: 500 }); + return NextResponse.json({ ok: true }); + } catch (error) { + if (isSupabaseConfigurationError(error)) return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); + return NextResponse.json({ error: "管理员服务暂时不可用" }, { status: 500 }); + } +} diff --git a/frontend/src/app/api/payment/epay/create/route.ts b/frontend/src/app/api/payment/epay/create/route.ts new file mode 100644 index 00000000..c4f20234 --- /dev/null +++ b/frontend/src/app/api/payment/epay/create/route.ts @@ -0,0 +1,34 @@ +import crypto from "node:crypto"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { epaySign } from "@/lib/epay/sign"; +import { epaySubmitUrl, readEpayConfig, EpayConfigurationError } from "@/lib/epay/config"; + +export const runtime = "nodejs"; +const schema = z.object({ packageId: z.string().uuid() }); +function safeUpstreamUrl(value: unknown, gateway: URL) { if (typeof value !== "string") return null; try { const url = new URL(value, gateway); return url.origin === gateway.origin ? url.toString() : null; } catch { return null; } } +export async function POST(request: Request) { + try { + const client = await createServerSupabaseClient(); const { data: { user } } = await client.auth.getUser(); + if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + const parsed = schema.safeParse(await request.json().catch(() => null)); if (!parsed.success) return NextResponse.json({ error: "请选择有效套餐" }, { status: 400 }); + const admin = createAdminSupabaseClient(); const { data: pack, error: packError } = await admin.from("payment_packages").select("id,name,price_cents,credits,enabled").eq("id", parsed.data.packageId).eq("enabled", true).maybeSingle(); + if (packError || !pack) return NextResponse.json({ error: "套餐不存在或已下架" }, { status: 404 }); + const config = readEpayConfig(); const orderNo = `JY${Date.now().toString(36)}${crypto.randomBytes(10).toString("hex")}`; + const { error: orderError } = await admin.from("payment_orders").insert({ order_no: orderNo, user_id: user.id, package_id: pack.id, money_cents: pack.price_cents, credits: pack.credits }); + if (orderError) return NextResponse.json({ error: "创建订单失败" }, { status: 500 }); + const params = { money: (pack.price_cents / 100).toFixed(2), name: pack.name, notify_url: config.notifyUrl, out_trade_no: orderNo, pid: config.pid, return_url: config.returnUrl, sitename: config.siteName, type: "alipay" }; + const body = new URLSearchParams({ ...params, sign: epaySign(params, config.key), sign_type: "MD5" }); + const upstream = await fetch(epaySubmitUrl(config.gatewayUrl), { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body, signal: AbortSignal.timeout(10_000) }); + if (!upstream.ok) return NextResponse.json({ error: "支付网关暂时不可用" }, { status: 502 }); + const text = await upstream.text(); let payload: Record = {}; try { const json = JSON.parse(text); if (json && typeof json === "object") payload = json; } catch { /* gateway may return HTML */ } + const payUrl = safeUpstreamUrl(payload.payurl ?? payload.pay_url ?? payload.url ?? (text.trim().startsWith("http") ? text.trim() : null), config.gatewayUrl); + const qrCode = safeUpstreamUrl(payload.qrcode ?? payload.qr_code, config.gatewayUrl); + return NextResponse.json({ orderNo, payUrl, qrCode }); + } catch (error) { + if (error instanceof EpayConfigurationError) return NextResponse.json({ error: "易支付尚未配置", code: "EPAY_NOT_CONFIGURED" }, { status: 503 }); + return NextResponse.json({ error: "创建支付失败" }, { status: 500 }); + } +} diff --git a/frontend/src/app/api/payment/epay/notify/route.ts b/frontend/src/app/api/payment/epay/notify/route.ts new file mode 100644 index 00000000..c47e8004 --- /dev/null +++ b/frontend/src/app/api/payment/epay/notify/route.ts @@ -0,0 +1,19 @@ +import crypto from "node:crypto"; +import { NextResponse } from "next/server"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { epaySign, timingSafeSignEqual } from "@/lib/epay/sign"; +import { readEpayConfig } from "@/lib/epay/config"; +export const runtime = "nodejs"; +async function notify(request: Request) { + try { + const config = readEpayConfig(); const raw = request.method === "GET" ? new URL(request.url).search.slice(1) : await request.text(); const params = new URLSearchParams(raw); const values: Record = {}; params.forEach((value, key) => { values[key] = value; }); + if (!timingSafeSignEqual(values.sign, epaySign(values, config.key)) || values.pid !== config.pid || values.trade_status !== "TRADE_SUCCESS" || !values.out_trade_no || !values.money) return new NextResponse("success", { status: 200 }); + const moneyCents = Math.round(Number(values.money) * 100); if (!Number.isSafeInteger(moneyCents) || moneyCents <= 0) return new NextResponse("success", { status: 200 }); + const hash = crypto.createHash("sha256").update(raw).digest("hex"); + const { error } = await createAdminSupabaseClient().rpc("settle_epay_order", { p_order_no: values.out_trade_no, p_trade_no: values.trade_no || values.transaction_id || values.out_trade_no, p_money_cents: moneyCents, p_payload_hash: hash }); + if (error) return new NextResponse("success", { status: 200 }); + return new NextResponse("success", { status: 200 }); + } catch { return new NextResponse("success", { status: 200 }); } +} +export async function POST(request: Request) { return notify(request); } +export async function GET(request: Request) { return notify(request); } diff --git a/frontend/src/app/api/payment/epay/status/route.ts b/frontend/src/app/api/payment/epay/status/route.ts new file mode 100644 index 00000000..a1451a94 --- /dev/null +++ b/frontend/src/app/api/payment/epay/status/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from "next/server"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +export const runtime = "nodejs"; +export async function GET(request: Request) { const client = await createServerSupabaseClient(); const { data: { user } } = await client.auth.getUser(); if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); const orderNo = new URL(request.url).searchParams.get("orderNo"); if (!orderNo) return NextResponse.json({ error: "缺少订单号" }, { status: 400 }); const { data, error } = await createAdminSupabaseClient().from("payment_orders").select("order_no,status,credits,paid_at").eq("order_no", orderNo).eq("user_id", user.id).maybeSingle(); if (error) return NextResponse.json({ error: "暂时无法查询订单" }, { status: 500 }); if (!data) return NextResponse.json({ error: "订单不存在" }, { status: 404 }); return NextResponse.json({ orderNo: data.order_no, status: data.status, credits: data.credits, paidAt: data.paid_at }); } diff --git a/frontend/src/app/api/payment/packages/route.ts b/frontend/src/app/api/payment/packages/route.ts new file mode 100644 index 00000000..f54e0e59 --- /dev/null +++ b/frontend/src/app/api/payment/packages/route.ts @@ -0,0 +1,4 @@ +import { NextResponse } from "next/server"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +export const runtime = "nodejs"; +export async function GET() { const { data, error } = await createAdminSupabaseClient().from("payment_packages").select("id,name,description,price_cents,credits,sort_order").eq("enabled", true).order("sort_order").order("created_at"); if (error) return NextResponse.json({ error: "暂时无法读取充值套餐" }, { status: 500 }); return NextResponse.json({ packages: (data || []).map((p) => ({ id: p.id, name: p.name, description: p.description, priceCents: p.price_cents, credits: p.credits })) }); } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index acc0d6fe..8555771a 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -749,6 +749,14 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: .code-status { display: inline-flex; min-height: 28px; align-items: center; padding: 0 9px; border-radius: var(--radius-md); background: var(--color-canvas-muted); color: var(--color-ink-secondary); } .status-可用 { background: var(--color-success-muted); color: var(--color-success); } .status-已过期, .status-已兑换, .empty-cell { color: var(--color-ink-tertiary); } +.payment-stats { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 1px; margin-top: 16px; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-border); } +.payment-stats > div { display: grid; gap: 6px; padding: var(--space-4); background: var(--color-canvas-muted); } +.payment-stats span, .payment-filters label span { color: var(--color-ink-secondary); font-size: 12px; } +.payment-stats strong { font-size: 20px; font-variant-numeric: tabular-nums; } +.payment-filters { display: flex; flex-wrap: wrap; gap: var(--space-4); margin-top: 16px; } +.payment-filters label { display: grid; gap: 6px; } +.payment-pagination { display: flex; align-items: center; justify-content: flex-end; gap: var(--space-3); margin-top: 16px; color: var(--color-ink-secondary); font-size: 13px; } +@media (max-width: 767px) { .payment-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } .payment-pagination { justify-content: space-between; } } @media (hover: hover) { .new-chat:not(:disabled):hover { background: var(--color-surface-dark-raised); } @@ -1660,3 +1668,9 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: .birth-time-clock-menu.select-content { width: 108px; min-width: 108px; } .birth-time-clock-menu .select-item { justify-content: flex-start; } + +.payment-qr-wrap { position: relative; width: min(220px, 72vw); aspect-ratio: 1; margin: 14px auto; padding: 10px; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: #fff; box-shadow: var(--shadow-elevated); } +.payment-qr-wrap img { display: block; width: 100%; height: 100%; object-fit: contain; } +.payment-qr-badge { position: absolute; top: 50%; left: 50%; display: grid; width: 44px; height: 44px; padding: 4px; transform: translate(-50%, -50%); border: 4px solid #fff; border-radius: 12px; background: #fff; box-shadow: 0 2px 10px rgb(0 0 0 / 18%); } +.payment-qr-badge svg { display: block; width: 100%; height: 100%; } + diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 88753e40..9b97ce94 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -903,6 +903,10 @@ export default function Home() { const [redeemError, setRedeemError] = useState(""); const [redeemMessage, setRedeemMessage] = useState(""); const [redeeming, setRedeeming] = useState(false); + const [paymentPackages, setPaymentPackages] = useState>([]); + const [paymentOrder, setPaymentOrder] = useState<{ orderNo: string; payUrl: string | null; qrCode: string | null; status: string } | null>(null); + const [paymentError, setPaymentError] = useState(""); + const [payingPackageId, setPayingPackageId] = useState(null); const [signingOut, setSigningOut] = useState(false); const [sessions, setSessions] = useState([]); const [pinnedSessionIds, setPinnedSessionIds] = useState([]); @@ -1955,6 +1959,39 @@ export default function Home() { } } + useEffect(() => { + if (activeAccountDialog !== "redeem") return; + void fetch("/api/payment/packages", { cache: "no-store" }).then(async (response) => { + const payload = await response.json().catch(() => null); + if (response.ok) setPaymentPackages(payload.packages || []); + }); + }, [activeAccountDialog]); + + useEffect(() => { + if (!paymentOrder || paymentOrder.status === "paid") return; + const timer = window.setInterval(() => { + void fetch(`/api/payment/epay/status?orderNo=${encodeURIComponent(paymentOrder.orderNo)}`, { cache: "no-store" }).then(async (response) => { + const payload = await response.json().catch(() => null); + if (!response.ok) return; + setPaymentOrder((current) => current ? { ...current, status: payload.status } : current); + if (payload.status === "paid") void refreshAccount(); + }); + }, 3000); + return () => window.clearInterval(timer); + }, [paymentOrder]); + + async function createPayment(packageId: string) { + if (payingPackageId) return; + setPayingPackageId(packageId); setPaymentError(""); + try { + const response = await fetch("/api/payment/epay/create", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ packageId }) }); + const payload = await response.json().catch(() => null); + if (!response.ok) throw new Error(payload?.error || "创建支付失败"); + setPaymentOrder({ orderNo: payload.orderNo, payUrl: payload.payUrl, qrCode: payload.qrCode, status: "pending" }); + if (payload.payUrl) window.open(payload.payUrl, "_blank", "noopener,noreferrer"); + } catch (caught) { setPaymentError(caught instanceof Error ? caught.message : "创建支付失败"); } finally { setPayingPackageId(null); } + } + async function redeem(event: FormEvent) { event.preventDefault(); const code = redeemCode.trim(); @@ -3516,6 +3553,12 @@ export default function Home() { {redeemError &&

{redeemError}

} {redeemMessage &&

{redeemMessage}

} +
+

充值套餐

+ {paymentPackages.map((item) =>
{item.name}{item.description || `${item.credits} 点`}
¥{(item.priceCents / 100).toFixed(2)}
)} + {paymentError &&

{paymentError}

} + {paymentOrder &&

订单 {paymentOrder.orderNo}:{paymentOrder.status === "paid" ? "支付成功,点数已到账" : "等待支付"}

{paymentOrder.qrCode &&
支付宝支付二维码
{paymentOrder.payUrl && 打开支付页面}
} +
)} diff --git a/frontend/src/lib/epay/config.ts b/frontend/src/lib/epay/config.ts new file mode 100644 index 00000000..f3b5620d --- /dev/null +++ b/frontend/src/lib/epay/config.ts @@ -0,0 +1,44 @@ +import "server-only"; + +const DEFAULT_NOTIFY_URL = "https://jyotisha.chat/api/payment/epay/notify"; + +export class EpayConfigurationError extends Error { + constructor(message: string) { + super(message); + this.name = "EpayConfigurationError"; + } +} + +function required(name: string) { + const value = process.env[name]?.trim(); + if (!value) throw new EpayConfigurationError(`${name} 未配置`); + return value; +} + +export function readEpayConfig() { + const gateway = required("EPAY_GATEWAY_URL").replace(/\/+$/, ""); + let gatewayUrl: URL; + try { + gatewayUrl = new URL(gateway); + } catch { + throw new EpayConfigurationError("EPAY_GATEWAY_URL 无效"); + } + if (!/^https?:$/.test(gatewayUrl.protocol)) throw new EpayConfigurationError("EPAY_GATEWAY_URL 必须使用 HTTP(S)"); + return { + gatewayUrl, + pid: required("EPAY_PID"), + key: required("EPAY_KEY"), + notifyUrl: process.env.EPAY_NOTIFY_URL?.trim() || DEFAULT_NOTIFY_URL, + returnUrl: process.env.EPAY_RETURN_URL?.trim() || "https://jyotisha.chat/", + siteName: process.env.EPAY_SITE_NAME?.trim() || "Jyotisha", + }; +} + +export function epaySubmitUrl(gatewayUrl: URL) { + const url = new URL(gatewayUrl.toString()); + url.pathname = `${url.pathname.replace(/\/$/, "")}/submit.php`; + url.search = ""; + return url; +} + +export { DEFAULT_NOTIFY_URL }; diff --git a/frontend/src/lib/epay/sign.ts b/frontend/src/lib/epay/sign.ts new file mode 100644 index 00000000..3b4ae36e --- /dev/null +++ b/frontend/src/lib/epay/sign.ts @@ -0,0 +1,18 @@ +import crypto from "node:crypto"; + +export function epayCanonical(params: Record) { + return Object.entries(params) + .filter(([key, value]) => key !== "sign" && key !== "sign_type" && value !== null && value !== undefined && String(value) !== "") + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}=${value}`) + .join("&"); +} + +export function epaySign(params: Record, key: string) { + return crypto.createHash("md5").update(`${epayCanonical(params)}${key}`).digest("hex"); +} + +export function timingSafeSignEqual(actual: string | null | undefined, expected: string) { + if (!actual || actual.length !== expected.length) return false; + return crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(expected)); +} diff --git a/frontend/src/lib/supabase/admin.ts b/frontend/src/lib/supabase/admin.ts index 97c6d148..0cb83ef8 100644 --- a/frontend/src/lib/supabase/admin.ts +++ b/frontend/src/lib/supabase/admin.ts @@ -36,3 +36,22 @@ export function isAdminEmail(email: string | null | undefined) { .split(",") .some((candidate) => candidate.trim().toLowerCase() === normalized); } + +export async function isAdminUser(user: { id?: string | null; email?: string | null } | null | undefined) { + if (!user?.email) return false; + if (isAdminEmail(user.email)) return true; + if (process.env.AUTH_PROVIDER?.trim() === "self-hosted" || !user.id) return false; + + try { + const admin = createAdminSupabaseClient(); + const { data, error } = await admin + .from("admin_users") + .select("user_id") + .eq("user_id", user.id) + .is("revoked_at", null) + .maybeSingle(); + return !error && Boolean(data); + } catch { + return false; + } +} diff --git a/frontend/supabase/migrations/20260727010000_admin_users.sql b/frontend/supabase/migrations/20260727010000_admin_users.sql new file mode 100644 index 00000000..8787cb5e --- /dev/null +++ b/frontend/supabase/migrations/20260727010000_admin_users.sql @@ -0,0 +1,11 @@ +create table public.admin_users ( + user_id uuid primary key references auth.users(id) on delete cascade, + created_at timestamptz not null default now(), + created_by uuid not null references auth.users(id), + revoked_at timestamptz, + revoked_by uuid references auth.users(id) +); + +alter table public.admin_users enable row level security; +revoke all on table public.admin_users from anon, authenticated; +grant select, insert, update on table public.admin_users to service_role; diff --git a/frontend/supabase/migrations/20260727020000_epay_packages_orders.sql b/frontend/supabase/migrations/20260727020000_epay_packages_orders.sql new file mode 100644 index 00000000..dd08fc20 --- /dev/null +++ b/frontend/supabase/migrations/20260727020000_epay_packages_orders.sql @@ -0,0 +1,69 @@ +begin; + +alter table public.credit_transactions drop constraint if exists credit_transactions_transaction_type_check; +alter table public.credit_transactions add constraint credit_transactions_transaction_type_check + check (transaction_type in ('redeem', 'reserve', 'refund', 'payment')); +alter table public.credit_transactions drop constraint if exists credit_transactions_amount_check; +alter table public.credit_transactions add constraint credit_transactions_amount_check + check ((transaction_type = 'reserve' and amount < 0) or (transaction_type in ('redeem', 'refund', 'payment') and amount > 0)); + +create table public.payment_packages ( + id uuid primary key default gen_random_uuid(), + name text not null check (char_length(name) between 1 and 80), + description text not null default '' check (char_length(description) <= 500), + price_cents integer not null check (price_cents > 0), + credits integer not null check (credits > 0), + sort_order integer not null default 0, + enabled boolean not null default true, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + created_by uuid references auth.users(id) on delete set null +); + +create table public.payment_orders ( + id uuid primary key default gen_random_uuid(), + order_no text not null unique check (char_length(order_no) between 16 and 100), + user_id uuid not null references auth.users(id) on delete cascade, + package_id uuid not null references public.payment_packages(id) on delete restrict, + money_cents integer not null check (money_cents > 0), + credits integer not null check (credits > 0), + status text not null default 'pending' check (status in ('pending', 'paid', 'failed', 'expired')), + epay_trade_no text, + raw_notify_payload_hash text, + paid_at timestamptz, + created_at timestamptz not null default now() +); +create unique index payment_orders_trade_no_idx on public.payment_orders(epay_trade_no) where epay_trade_no is not null; +create index payment_orders_user_created_idx on public.payment_orders(user_id, created_at desc); + +alter table public.payment_packages enable row level security; +alter table public.payment_orders enable row level security; +revoke all on public.payment_packages, public.payment_orders from anon, authenticated; +grant select on public.payment_orders to authenticated; +create policy payment_orders_select_own on public.payment_orders for select to authenticated using ((select auth.uid()) = user_id); + +grant all on public.payment_packages, public.payment_orders to service_role; + +create or replace function public.settle_epay_order(p_order_no text, p_trade_no text, p_money_cents integer, p_payload_hash text) +returns table (success boolean, status text, credits integer) +language plpgsql security definer set search_path = public, pg_temp +as $$ +declare v_order public.payment_orders%rowtype; v_balance integer; +begin + select * into v_order from public.payment_orders where order_no = btrim(p_order_no) for update; + if not found or v_order.money_cents <> p_money_cents then return query select false, 'invalid'::text, null::integer; return; end if; + if v_order.status = 'paid' then return query select true, 'paid'::text, v_order.credits; return; end if; + select credits into v_balance from public.profiles where id = v_order.user_id for update; + if not found then return query select false, 'profile_missing'::text, null::integer; return; end if; + update public.profiles set credits = credits + v_order.credits, updated_at = now() where id = v_order.user_id returning credits into v_balance; + insert into public.credit_transactions(user_id, transaction_type, amount, balance_after, request_id) + values (v_order.user_id, 'payment', v_order.credits, v_balance, v_order.order_no) + on conflict (user_id, transaction_type, request_id) do nothing; + update public.payment_orders set status='paid', epay_trade_no=p_trade_no, raw_notify_payload_hash=p_payload_hash, paid_at=now() where id=v_order.id; + return query select true, 'paid'::text, v_order.credits; +end; +$$; +revoke all on function public.settle_epay_order(text, text, integer, text) from public, anon, authenticated; +grant execute on function public.settle_epay_order(text, text, integer, text) to service_role; + +commit; diff --git a/frontend/supabase/migrations/20260727030000_payment_admin_stats.sql b/frontend/supabase/migrations/20260727030000_payment_admin_stats.sql new file mode 100644 index 00000000..886cae87 --- /dev/null +++ b/frontend/supabase/migrations/20260727030000_payment_admin_stats.sql @@ -0,0 +1,23 @@ +begin; + +create index if not exists payment_orders_created_status_idx on public.payment_orders(created_at desc, status); + +create or replace function public.get_payment_order_stats(p_from timestamptz default null, p_to timestamptz default null) +returns table (total_orders bigint, paid_orders bigint, pending_orders bigint, failed_expired_orders bigint, paid_amount_cents bigint, granted_credits bigint) +language sql security definer set search_path = public, pg_temp +as $$ + select + count(*)::bigint, + count(*) filter (where status = 'paid')::bigint, + count(*) filter (where status = 'pending')::bigint, + count(*) filter (where status in ('failed', 'expired'))::bigint, + coalesce(sum(money_cents) filter (where status = 'paid'), 0)::bigint, + coalesce(sum(credits) filter (where status = 'paid'), 0)::bigint + from public.payment_orders + where (p_from is null or created_at >= p_from) + and (p_to is null or created_at <= p_to); +$$; +revoke all on function public.get_payment_order_stats(timestamptz, timestamptz) from public, anon, authenticated; +grant execute on function public.get_payment_order_stats(timestamptz, timestamptz) to service_role; + +commit; diff --git a/frontend/tests/admin-payments-contract.test.ts b/frontend/tests/admin-payments-contract.test.ts new file mode 100644 index 00000000..13634de1 --- /dev/null +++ b/frontend/tests/admin-payments-contract.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const root = new URL("../", import.meta.url); +const route = readFileSync(new URL("src/app/api/admin/payments/route.ts", root), "utf8"); +const page = readFileSync(new URL("src/app/admin/payments/page.tsx", root), "utf8"); +const packagesPage = readFileSync(new URL("src/app/admin/packages/page.tsx", root), "utf8"); +const migration = readFileSync(new URL("supabase/migrations/20260727030000_payment_admin_stats.sql", root), "utf8"); + +test("支付后台接口只允许管理员并查询平台订单", () => { + assert.match(route, /isAdminUser\(user\)/); + assert.match(route, /from\("payment_orders"\)/); + assert.match(route, /auth\.admin\.getUserById/); + assert.match(route, /payment_packages\(name\)/); + assert.match(route, /order_no|orderNo/); + assert.doesNotMatch(route, /SUPABASE_SERVICE_ROLE_KEY/); + assert.doesNotMatch(route, /raw_notify_payload/); +}); + +test("支付接口包含筛选、统计和分页契约", () => { + for (const field of ["status", "from", "to", "limit", "offset"]) assert.match(route, new RegExp(field)); + for (const field of ["totalOrders", "paidOrders", "pendingOrders", "failedExpiredOrders", "paidAmountCents", "grantedCredits"]) assert.match(route, new RegExp(field)); + assert.match(route, /max\(100\)/); + assert.match(route, /count: "exact"/); + assert.match(route, /hasMore/); + assert.match(migration, /get_payment_order_stats/); + assert.match(migration, /status = 'paid'/); +}); + +test("后台支付页面与现有套餐页有入口", () => { + assert.match(page, /平台支付统计/); + assert.match(page, /支付记录/); + assert.match(page, /paidAmountCents/); + assert.match(page, /上一页/); + assert.match(page, /下一页/); + assert.match(packagesPage, /href="\/admin\/payments"/); +}); diff --git a/frontend/tests/admin-users-contract.test.ts b/frontend/tests/admin-users-contract.test.ts new file mode 100644 index 00000000..a3ef3f1c --- /dev/null +++ b/frontend/tests/admin-users-contract.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const root = new URL("../", import.meta.url); +const adminSource = readFileSync(new URL("src/lib/supabase/admin.ts", root), "utf8"); +const layoutSource = readFileSync(new URL("src/app/admin/layout.tsx", root), "utf8"); +const codesSource = readFileSync(new URL("src/app/api/admin/codes/route.ts", root), "utf8"); +const accountSource = readFileSync(new URL("src/app/api/account/route.ts", root), "utf8"); +const usersSource = readFileSync(new URL("src/app/api/admin/users/route.ts", root), "utf8"); +const migration = readFileSync(new URL("supabase/migrations/20260727010000_admin_users.sql", root), "utf8"); + +test("ADMIN_EMAILS remains a case-insensitive comma-separated allowlist", () => { + assert.match(adminSource, /configured/); + assert.match(adminSource, /split\(\",\"\)/); + assert.match(adminSource, /toLowerCase/); + assert.match(adminSource, /export function isAdminEmail/); +}); + +test("admin surfaces await database-backed administrator checks", () => { + assert.match(adminSource, /export async function isAdminUser/); + assert.match(adminSource, /from\("admin_users"\)/); + assert.match(layoutSource, /await isAdminUser\(user\)/); + assert.match(codesSource, /await isAdminUser\(user\)/); + assert.match(accountSource, /isAdmin: await isAdminUser\(user\)/); +}); + +test("admin_users migration is service-role-only and auditable", () => { + assert.match(migration, /user_id uuid primary key references auth\.users\(id\)/); + assert.match(migration, /created_at timestamptz/); + assert.match(migration, /created_by uuid/); + assert.match(migration, /revoked_at timestamptz/); + assert.match(migration, /revoked_by uuid/); + assert.match(migration, /enable row level security/); + assert.match(migration, /revoke all on table public\.admin_users from anon, authenticated/); + assert.match(migration, /grant select, insert, update on table public\.admin_users to service_role/); +}); + +test("admin users route exposes guarded list, add, and soft revoke contracts", () => { + assert.match(usersSource, /export async function GET/); + assert.match(usersSource, /export async function POST/); + assert.match(usersSource, /export async function DELETE/); + assert.match(usersSource, /auth\.admin\.listUsers/); + assert.match(usersSource, /upsert\(\{ user_id: target\.id, created_by: auth\.user\.id/); + assert.match(usersSource, /revoked_at: new Date\(\)\.toISOString\(\)/); + assert.match(usersSource, /环境配置管理员不可撤销/); +}); diff --git a/frontend/tests/epay-payment-contract.test.ts b/frontend/tests/epay-payment-contract.test.ts new file mode 100644 index 00000000..92171c44 --- /dev/null +++ b/frontend/tests/epay-payment-contract.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { epayCanonical, epaySign } from "../src/lib/epay/sign"; + +const migration = readFileSync(new URL("../supabase/migrations/20260727020000_epay_packages_orders.sql", import.meta.url), "utf8"); + +test("易支付签名过滤空值并按键排序", () => { + const params = { money: "10.00", pid: "10001", name: "套餐", empty: "", sign_type: "MD5" }; + assert.equal(epayCanonical(params), "money=10.00&name=套餐&pid=10001"); + assert.equal(epaySign(params, "secret"), "79dd3a13f9fd32622fa2197c0a2d7b66"); +}); + +test("支付迁移包含套餐、订单、payment 类型与原子结算", () => { + assert.match(migration, /create table public\.payment_packages/); + assert.match(migration, /create table public\.payment_orders/); + assert.match(migration, /transaction_type in \('redeem', 'reserve', 'refund', 'payment'\)/); + assert.match(migration, /settle_epay_order/); + assert.match(migration, /on conflict \(user_id, transaction_type, request_id\) do nothing/); +}); diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index ae3eb8d3..f8b311ed 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; @@ -30,10 +30,13 @@ const syncScript = new URL( "../../deploy/sync-staging-tree.sh", import.meta.url, ); +const giteaWorkflowDirectory = new URL("../../.gitea/workflows/", import.meta.url); const giteaQualityWorkflow = new URL( - "../../.gitea/workflows/backend-quality-gate.yml", - import.meta.url, + "backend-quality-gate.yml", + giteaWorkflowDirectory, ); +const giteaDeployWorkflow = new URL("deploy-staging.yml", giteaWorkflowDirectory); +const giteaMigrationWorkflow = new URL("migrate-staging-database.yml", giteaWorkflowDirectory); function read(url: URL): string { return readFileSync(url, "utf8"); @@ -380,21 +383,76 @@ test("production remains manual-only and separate from staging database automati assert.doesNotMatch(production, /docker-compose\.postgres\.yml|db:migrate/); }); -test("Gitea staging push uses the xiaoxin Linux runner and immutable ACR images", () => { +test("all Gitea workflows use xiaoxin, native checkout, and safe triggers", () => { + const names = readdirSync(giteaWorkflowDirectory) + .filter((name) => name.endsWith(".yml")); + assert.ok(names.length > 0); + const workflows = new Map(names.map((name) => [name, read(new URL(name, giteaWorkflowDirectory))])); + + for (const [name, workflow] of workflows) { + const jobs = workflow.match(/^\s{4}runs-on:\s*(.+)$/gm) ?? []; + assert.ok(jobs.length > 0, `${name} has no jobs`); + assert.ok(jobs.every((line) => line.trim() === "runs-on: xiaoxin"), name); + assert.doesNotMatch(workflow, /ubuntu-latest|github\.com\/actions|actions\/(?:checkout|setup-)|GITEA_OUTPUT/, name); + assert.match(workflow, /git init \./, name); + assert.match(workflow, /git fetch --no-tags origin/, name); + } + + const stagingPushOwners = [...workflows] + .filter(([, workflow]) => /push:\n\s+branches:\s*\[staging\]/.test(workflow)) + .map(([name]) => name); + assert.deepEqual(stagingPushOwners, ["backend-quality-gate.yml"]); + for (const name of [ + "ci.yml", + "deploy-production.yml", + "deploy-staging.yml", + "migrate-staging-database.yml", + "apply-supabase-profile-migrations.yml", + "release-quality-gate.yml", + "test.yml", + "publish-pypi.yml", + ]) { + const workflow = workflows.get(name) ?? ""; + assert.match(workflow, /^on:\n\s+workflow_dispatch:/m, name); + assert.doesNotMatch(workflow, /\n\s+(?:push|pull_request|workflow_run):/, name); + } + const all = [...workflows.values()].join("\n"); + assert.doesNotMatch(all, /GITEA_REGISTRY_USERNAME|GITEA_REGISTRY_TOKEN|git\.copse\.top\/root\/jyotisha-(?:api|web)/); +}); + +test("Gitea staging push validates once then publishes and deploys immutable ACR images", () => { const workflow = read(giteaQualityWorkflow); - assert.equal(workflow.match(/runs-on: xiaoxin/g)?.length, 2); - assert.match(workflow, /set -euo pipefail/); - assert.equal(workflow.match(/git fetch --no-tags origin/g)?.length, 2); - assert.doesNotMatch(workflow, /github\.com\/actions/); + assert.match(workflow, /publish-and-deploy:[\s\S]*needs: validate/); + assert.match(workflow, /gitea\.event_name == 'push'.*refs\/heads\/staging/); assert.match(workflow, /crpi-d1feco6itet73spp\.cn-hongkong\.personal\.cr\.aliyuncs\.com\/copse\/jyotisha/); - assert.match(workflow, /secrets\.REGISTRY_USERNAME/); - assert.match(workflow, /secrets\.REGISTRY_PASSWORD/); assert.match(workflow, /api_tag="\$\{IMAGE_REPOSITORY\}:api-\$\{GITEA_SHA\}"/); assert.match(workflow, /web_tag="\$\{IMAGE_REPOSITORY\}:web-\$\{GITEA_SHA\}"/); + assert.match(workflow, /api_ref=.*RepoDigests/); + assert.match(workflow, /web_ref=.*RepoDigests/); + assert.match(workflow, /API_IMAGE='\$api_image'.*bash '\$incoming\/deploy\/run-staging-deploy\.sh'/); assert.match(workflow, /EXPECTED_PREVIOUS_SHA='\$previous_sha'/); assert.match(workflow, /git merge-base --is-ancestor "\$previous_sha" "\$GITEA_SHA"/); - assert.match(workflow, /scp_options=\(-i "\$key_path" -P "\$DEPLOY_PORT"/); - assert.doesNotMatch(workflow, /shell: powershell|17631000304|copse\.ai\.2026/); +}); + +test("manual Gitea staging deploy and migration use shared ACR digests and live previous SHA", () => { + const deployment = read(giteaDeployWorkflow); + const migration = read(giteaMigrationWorkflow); + for (const workflow of [deployment, migration]) { + assert.match(workflow, /deploy_sha:/); + assert.match(workflow, /git merge-base --is-ancestor "\$DEPLOY_SHA" origin\/main/); + assert.match(workflow, /IMAGE_REPOSITORY: crpi-d1feco6itet73spp\.cn-hongkong\.personal\.cr\.aliyuncs\.com\/copse\/jyotisha/); + assert.match(workflow, /secrets\.REGISTRY_USERNAME/); + assert.match(workflow, /secrets\.REGISTRY_PASSWORD/); + assert.match(workflow, /STAGING_KNOWN_HOSTS/); + assert.match(workflow, /previous_sha="\$\(ssh/); + assert.doesNotMatch(workflow, /EXPECTED_PREVIOUS_SHA='not-deployed'/); + } + assert.match(deployment, /allow_rollback:/); + assert.match(deployment, /default forward-only deployment refused/); + assert.match(deployment, /run-staging-deploy\.sh/); + assert.match(migration, /run-staging-migration\.sh/); + assert.doesNotMatch(migration, /\n\s+push:|workflow_run:/); + assert.match(read(migrationScript), /crpi-d1feco6itet73spp\\\.cn-hongkong\\\.personal\\\.cr\\\.aliyuncs\\\.com\/copse\/jyotisha@sha256/); }); test("staging scripts pass shell syntax validation", () => {