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", () => {