From f4e35974c6e6bd586a041d8514206b86700c5178 Mon Sep 17 00:00:00 2001 From: linmeng <819991304@qq.com> Date: Thu, 30 Jul 2026 14:38:17 +0800 Subject: [PATCH] Revert "merge: sync GitHub staging to Gitea" This reverts commit a55c69115ddeb2db9afa859309b3d1cbd04401b3, reversing changes made to 02c9c9f3d617e57979a4a532c7c4dfce7c8228c4. --- .../apply-supabase-profile-migrations.yml | 60 ++++ .gitea/workflows/backend-quality-gate.yml | 106 +++++++ .gitea/workflows/ci.yml | 46 +++ .gitea/workflows/deploy-production.yml | 59 ++++ .gitea/workflows/deploy-staging.yml | 105 +++++++ .gitea/workflows/migrate-staging-database.yml | 93 ++++++ .gitea/workflows/publish-pypi.yml | 43 +++ .gitea/workflows/release-quality-gate.yml | 40 +++ .gitea/workflows/test.yml | 42 +++ ...onfigure-staging-rectification-rollout.yml | 4 +- .github/workflows/deploy-staging.yml | 13 +- .../workflows/migrate-staging-database.yml | 13 +- .github/workflows/reset-staging-account.yml | 4 +- deploy/.env.staging.identity.example | 5 +- deploy/Caddyfile.staging | 17 -- deploy/README.md | 11 +- ...configure-staging-rectification-rollout.sh | 1 - deploy/docker-compose.server.yml | 1 - deploy/railway-api.Dockerfile | 9 +- deploy/railway-web.Dockerfile | 2 +- deploy/run-staging-deploy.sh | 52 ++-- deploy/run-staging-migration.sh | 18 +- deploy/validate-staging-env.sh | 16 +- docs/BUG_HISTORY.md | 223 +++----------- docs/operations/self-hosted-identity.md | 30 +- .../plans/2026-07-21-self-hosted-identity.md | 2 + ...2026-07-20-supabase-exit-backend-design.md | 2 + frontend/package-lock.json | 7 + frontend/package.json | 1 + frontend/scripts/staging-image-manifest.mjs | 16 +- frontend/src/app/admin/layout.tsx | 12 +- frontend/src/app/admin/packages/page.tsx | 5 + frontend/src/app/admin/payments/page.tsx | 5 + frontend/src/app/admin/route.ts | 23 +- frontend/src/app/admin/users/page.tsx | 32 +++ frontend/src/app/api/account/route.ts | 7 +- .../src/app/api/admin/epay-settings/route.ts | 132 +++++++++ .../app/api/admin/epay-settings/test/route.ts | 46 +++ frontend/src/app/api/admin/packages/route.ts | 114 ++++++++ frontend/src/app/api/admin/payments/route.ts | 128 +++++++++ frontend/src/app/api/auth/[...all]/route.ts | 1 - .../src/app/api/payment/epay/create/route.ts | 53 ++++ .../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 | 28 ++ frontend/src/app/globals.css | 24 +- frontend/src/app/login/page.tsx | 27 +- frontend/src/app/page.tsx | 76 ++++- frontend/src/components/admin/admin-app.tsx | 73 +++-- .../src/components/admin/codes-resource.tsx | 4 +- .../components/admin/package-management.tsx | 163 +++++++++++ .../components/admin/payment-management.tsx | 271 ++++++++++++++++++ frontend/src/components/app-sidebar.tsx | 6 - frontend/src/components/birth-date-picker.tsx | 13 +- frontend/src/components/email-otp-login.tsx | 4 +- frontend/src/components/ui/calendar.tsx | 6 +- frontend/src/lib/admin/auth-policy.ts | 11 +- frontend/src/lib/admin/auth.ts | 2 +- frontend/src/lib/admin/database.ts | 19 +- frontend/src/lib/admin/providers.ts | 4 +- frontend/src/lib/epay/availability.ts | 43 +++ frontend/src/lib/epay/config-core.ts | 106 +++++++ frontend/src/lib/epay/config.ts | 26 ++ frontend/src/lib/epay/encryption-core.ts | 46 +++ frontend/src/lib/epay/encryption.ts | 3 + frontend/src/lib/epay/gateway-policy.ts | 64 +++++ frontend/src/lib/epay/sign.ts | 18 ++ .../opportunity-builder.ts | 85 ++---- .../lib/rectification-agent/orchestrator.ts | 18 -- .../lib/rectification-agent/renderer-agent.ts | 12 +- frontend/src/lib/supabase/admin.ts | 38 ++- frontend/src/lib/supabase/server.ts | 11 +- frontend/src/modules/identity/auth-factory.ts | 45 +-- frontend/src/modules/identity/auth.ts | 19 +- frontend/src/modules/identity/config.ts | 13 - frontend/src/modules/identity/contracts.ts | 2 - frontend/src/modules/identity/host.ts | 15 +- .../migrations/20260727010000_admin_users.sql | 11 + .../20260727020000_epay_packages_orders.sql | 69 +++++ .../20260727030000_payment_admin_stats.sql | 23 ++ .../20260729010000_epay_settings.sql | 126 ++++++++ ...260730010000_admin_payment_permissions.sql | 28 ++ frontend/tests/account-api.test.ts | 6 + frontend/tests/admin-auth.test.ts | 6 +- frontend/tests/admin-contracts.test.ts | 49 +++- .../tests/admin-payments-contract.test.ts | 94 ++++++ frontend/tests/admin-users-contract.test.ts | 47 +++ .../tests/birth-date-picker-contract.test.ts | 23 +- frontend/tests/epay-payment-contract.test.ts | 29 ++ frontend/tests/epay-settings.test.ts | 183 ++++++++++++ frontend/tests/health-deployment.test.ts | 28 +- frontend/tests/identity-auth-factory.test.ts | 250 ++-------------- .../tests/identity-auth-integration.test.ts | 49 +--- frontend/tests/identity-config.test.ts | 154 ++-------- frontend/tests/identity-host-routing.test.ts | 119 ++------ .../tests/identity-login-provider.test.ts | 15 +- frontend/tests/rectification-agent-v6.test.ts | 50 +--- .../rectification-analysis-trace.test.ts | 1 - frontend/tests/sidebar-contract.test.ts | 13 + .../tests/staging-backend-workflows.test.ts | 70 ++++- frontend/tests/staging-image-manifest.test.ts | 22 ++ scripts/jyotish_engine.py | 22 +- .../references/question-policy.md | 18 +- tests/test_api_server_security.py | 9 +- tests/test_railway_deployment.py | 3 + tests/test_shadbala_complete.py | 6 +- 106 files changed, 3228 insertions(+), 1213 deletions(-) create mode 100644 .gitea/workflows/apply-supabase-profile-migrations.yml create mode 100644 .gitea/workflows/backend-quality-gate.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/epay-settings/route.ts create mode 100644 frontend/src/app/api/admin/epay-settings/test/route.ts 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/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/components/admin/package-management.tsx create mode 100644 frontend/src/components/admin/payment-management.tsx create mode 100644 frontend/src/lib/epay/availability.ts create mode 100644 frontend/src/lib/epay/config-core.ts create mode 100644 frontend/src/lib/epay/config.ts create mode 100644 frontend/src/lib/epay/encryption-core.ts create mode 100644 frontend/src/lib/epay/encryption.ts create mode 100644 frontend/src/lib/epay/gateway-policy.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/supabase/migrations/20260729010000_epay_settings.sql create mode 100644 frontend/supabase/migrations/20260730010000_admin_payment_permissions.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 create mode 100644 frontend/tests/epay-settings.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 new file mode 100644 index 00000000..41f464a3 --- /dev/null +++ b/.gitea/workflows/backend-quality-gate.yml @@ -0,0 +1,106 @@ +name: Deploy staging to test server + +on: + push: + branches: [staging] + +concurrency: + group: staging-deploy + cancel-in-progress: true + +jobs: + deploy: + runs-on: xiaoxin + timeout-minutes: 45 + 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 }} + + steps: + - name: Checkout staging + 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 -c http.connectTimeout=15 -c http.lowSpeedLimit=1024 -c http.lowSpeedTime=30 \ + fetch --depth=1 --no-tags origin "$GITEA_SHA" + git checkout --detach --force "$GITEA_SHA" + + - name: Build and push images + env: + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + run: | + set -euo pipefail + printf '%s' "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOST" --username "$REGISTRY_USERNAME" --password-stdin + docker build -f deploy/railway-api.Dockerfile -t "$IMAGE_REPOSITORY:api-$GITEA_SHA" . + docker build -f deploy/railway-web.Dockerfile -t "$IMAGE_REPOSITORY:web-$GITEA_SHA" . + docker push "$IMAGE_REPOSITORY:api-$GITEA_SHA" + docker push "$IMAGE_REPOSITORY:web-$GITEA_SHA" + docker logout "$REGISTRY_HOST" + + - name: Deploy on test server + env: + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} + STAGING_KNOWN_HOSTS: ${{ vars.STAGING_KNOWN_HOSTS }} + run: | + set -euo pipefail + install -m 700 -d "$RUNNER_TEMP/staging-ssh" + printf '%s\n' "$SSH_PRIVATE_KEY" | tr -d '\r' > "$RUNNER_TEMP/staging-ssh/id_ed25519" + printf '%s\n' "$STAGING_KNOWN_HOSTS" | tr -d '\r' > "$RUNNER_TEMP/staging-ssh/known_hosts" + chmod 600 "$RUNNER_TEMP/staging-ssh/id_ed25519" "$RUNNER_TEMP/staging-ssh/known_hosts" + + SSH_OPTIONS="-i $RUNNER_TEMP/staging-ssh/id_ed25519 -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$RUNNER_TEMP/staging-ssh/known_hosts" + SCP_OPTIONS="-i $RUNNER_TEMP/staging-ssh/id_ed25519 -P $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$RUNNER_TEMP/staging-ssh/known_hosts" + REMOTE="$DEPLOY_USER@$DEPLOY_HOST" + ARCHIVE="$RUNNER_TEMP/deploy-$GITEA_SHA.tar" + REMOTE_ARCHIVE="/tmp/jyotisha-deploy-$GITEA_SHA.tar" + + tar -cf "$ARCHIVE" deploy + scp $SCP_OPTIONS "$ARCHIVE" "$REMOTE:$REMOTE_ARCHIVE" + printf '%s' "$REGISTRY_PASSWORD" | ssh $SSH_OPTIONS "$REMOTE" "sudo docker login '$REGISTRY_HOST' --username '$REGISTRY_USERNAME' --password-stdin" + ssh $SSH_OPTIONS "$REMOTE" " + set -e + sudo install -d '$DEPLOY_PATH' + sudo tar -xf '$REMOTE_ARCHIVE' -C '$DEPLOY_PATH' + rm -f '$REMOTE_ARCHIVE' + cd '$DEPLOY_PATH' + sudo env \ + API_IMAGE='$IMAGE_REPOSITORY:api-$GITEA_SHA' \ + WEB_IMAGE='$IMAGE_REPOSITORY:web-$GITEA_SHA' \ + GITHUB_SHA='$GITEA_SHA' \ + APP_ENV_FILE='../.env.staging' \ + DATABASE_ENV_FILE='../.env.staging.database' \ + CADDYFILE_PATH='./Caddyfile.staging' \ + SITE_ADDRESS='https://staging.jyotisha.chat' \ + docker compose -p jyotisha-staging --env-file .env.staging \ + -f deploy/docker-compose.server.yml \ + -f deploy/docker-compose.postgres.yml \ + -f deploy/docker-compose.staging.yml \ + pull api web + sudo env \ + API_IMAGE='$IMAGE_REPOSITORY:api-$GITEA_SHA' \ + WEB_IMAGE='$IMAGE_REPOSITORY:web-$GITEA_SHA' \ + GITHUB_SHA='$GITEA_SHA' \ + APP_ENV_FILE='../.env.staging' \ + DATABASE_ENV_FILE='../.env.staging.database' \ + CADDYFILE_PATH='./Caddyfile.staging' \ + SITE_ADDRESS='https://staging.jyotisha.chat' \ + docker compose -p jyotisha-staging --env-file .env.staging \ + -f deploy/docker-compose.server.yml \ + -f deploy/docker-compose.postgres.yml \ + -f deploy/docker-compose.staging.yml \ + up -d --no-build --remove-orphans + sudo docker logout '$REGISTRY_HOST' + " + + curl --fail --silent --show-error --retry 12 --retry-delay 5 "$STAGING_URL/api/health" diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 00000000..dc13e1b1 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,46 @@ +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 venv .venv + export PATH="$PWD/.venv/bin:$PATH" + python -m pip install --upgrade pip + python -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 + python -m py_compile scripts/*.py jyotish_vedic/*.py mcp_server.py + python scripts/run_quality_gate.py --profile quick --skip-yoga-logic --skip-frontend-runtime + python scripts/commercial_privacy_artifact_scan.py --json + npm test --prefix frontend + npm run lint --prefix frontend + npm run build --prefix frontend + python -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..6e1d164e --- /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 staging 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 staging + git checkout --detach --force origin/staging + - 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}" + 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); xs=d if isinstance(d,list) else [d]; xs=[x for x in xs if isinstance(x,dict) and isinstance(x.get("Descriptor",x),dict)]; x=next((x for x in xs if x.get("Descriptor",x).get("platform",{}).get("os")=="linux" and x.get("Descriptor",x).get("platform",{}).get("architecture")=="amd64"),None); print(x.get("Descriptor",x).get("digest","") if x else "")')" + web_digest="$(docker manifest inspect "${IMAGE_REPOSITORY}:web-${DEPLOY_SHA}" --verbose | python3 -c 'import json,sys; d=json.load(sys.stdin); xs=d if isinstance(d,list) else [d]; xs=[x for x in xs if isinstance(x,dict) and isinstance(x.get("Descriptor",x),dict)]; x=next((x for x in xs if x.get("Descriptor",x).get("platform",{}).get("os")=="linux" and x.get("Descriptor",x).get("platform",{}).get("architecture")=="amd64"),None); print(x.get("Descriptor",x).get("digest","") if x else "")')" + [[ "$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" + 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" + incoming="$(ssh "${ssh_options[@]}" "$remote" "mktemp -d /tmp/jyotisha-staging.XXXXXXXXXX")" + [[ "$incoming" == /tmp/jyotisha-staging.* ]] + cleanup() { ssh "${ssh_options[@]}" "$remote" "sudo -n docker --config '$incoming/.docker' logout '$REGISTRY_HOST' >/dev/null 2>&1 || true; sudo -n 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=\$(sudo -n 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 sudo -n 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" "sudo -n docker --config '$incoming/.docker' login '$REGISTRY_HOST' --username '$REGISTRY_USERNAME' --password-stdin" + ssh "${ssh_options[@]}" "$remote" "sudo -n env 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' DOCKER_BIN='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..ccbaa422 --- /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 staging 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 staging + git checkout --detach --force origin/staging + - 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}" + 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); xs=d if isinstance(d,list) else [d]; xs=[x for x in xs if isinstance(x,dict) and isinstance(x.get("Descriptor",x),dict)]; x=next((x for x in xs if x.get("Descriptor",x).get("platform",{}).get("os")=="linux" and x.get("Descriptor",x).get("platform",{}).get("architecture")=="amd64"),None); print(x.get("Descriptor",x).get("digest","") if x else "")')" + [[ "$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" + 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" + incoming="$(ssh "${ssh_options[@]}" "$remote" "mktemp -d /tmp/jyotisha-staging.XXXXXXXXXX")" + [[ "$incoming" == /tmp/jyotisha-staging.* ]] + cleanup() { ssh "${ssh_options[@]}" "$remote" "sudo -n docker --config '$incoming/.docker' logout '$REGISTRY_HOST' >/dev/null 2>&1 || true; sudo -n 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=\$(sudo -n 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 sudo -n 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" "sudo -n docker --config '$incoming/.docker' login '$REGISTRY_HOST' --username '$REGISTRY_USERNAME' --password-stdin" + ssh "${ssh_options[@]}" "$remote" "sudo -n env 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' DOCKER_BIN='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..f127b587 --- /dev/null +++ b/.gitea/workflows/publish-pypi.yml @@ -0,0 +1,43 @@ +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 venv .venv + export PATH="$PWD/.venv/bin:$PATH" + python -m pip install --upgrade pip + python -m pip install build twine + python -m build + python -m twine check dist/* + - name: Publish to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: | + set -euo pipefail + export PATH="$PWD/.venv/bin:$PATH" + python -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..b235097f --- /dev/null +++ b/.gitea/workflows/release-quality-gate.yml @@ -0,0 +1,40 @@ +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 venv .venv + export PATH="$PWD/.venv/bin:$PATH" + python -m pip install --upgrade pip + python -m pip install -r requirements.txt -r requirements-dev.txt playwright + python -m playwright install --with-deps chromium + npm ci --prefix frontend + python 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..9e9c3dfd --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,42 @@ +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 venv .venv + export PATH="$PWD/.venv/bin:$PATH" + python -m pip install --upgrade pip + python -m pip install -r requirements.txt -r requirements-dev.txt + npm ci --prefix frontend + python -m pytest -vv --maxfail=1 + python tests/run_all.py + npm test --prefix frontend + npm run lint --prefix frontend + npm run build --prefix frontend diff --git a/.github/workflows/configure-staging-rectification-rollout.yml b/.github/workflows/configure-staging-rectification-rollout.yml index a18fb072..d17b64f6 100644 --- a/.github/workflows/configure-staging-rectification-rollout.yml +++ b/.github/workflows/configure-staging-rectification-rollout.yml @@ -47,10 +47,10 @@ jobs: SYNTHETIC_SMOKE_USER_IDS: ${{ inputs.synthetic_smoke_user_ids }} steps: - - name: Checkout trusted controller + - name: Checkout staging controller uses: actions/checkout@v4 with: - ref: main + ref: staging persist-credentials: false - name: Validate rollout request and staging target diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 25842b41..08c5096b 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -111,10 +111,10 @@ jobs: echo "allow_rollback=$allow_rollback" } >>"$GITHUB_OUTPUT" - - name: Checkout trusted main controller + - name: Checkout staging controller uses: actions/checkout@v4 with: - ref: main + ref: staging fetch-depth: 0 persist-credentials: false @@ -135,16 +135,15 @@ jobs: node frontend/scripts/staging-image-manifest.mjs \ artifacts/staging-image/manifest.env "$DEPLOY_SHA" >>"$GITHUB_OUTPUT" - - name: Verify reviewed revision and staging target + - name: Verify staging revision and target env: DEPLOY_SHA: ${{ steps.revision.outputs.sha }} run: | set -euo pipefail git cat-file -e "$DEPLOY_SHA^{commit}" - git merge-base --is-ancestor "$DEPLOY_SHA" HEAD || { - echo "staging revision is not in the reviewed main history" >&2 - exit 1 - } + if [ "${{ steps.revision.outputs.allow_rollback }}" = "false" ]; then + test "$DEPLOY_SHA" = "$(git rev-parse HEAD)" + fi test "$DEPLOY_HOST" = "118.26.111.127" test "$DEPLOY_PORT" = "22" test "$DEPLOY_USER" = "deploy" diff --git a/.github/workflows/migrate-staging-database.yml b/.github/workflows/migrate-staging-database.yml index 0072cbff..8cdcb46c 100644 --- a/.github/workflows/migrate-staging-database.yml +++ b/.github/workflows/migrate-staging-database.yml @@ -74,10 +74,10 @@ jobs: echo "gate_run_attempt=$gate_run_attempt" } >>"$GITHUB_OUTPUT" - - name: Checkout trusted main controller + - name: Checkout staging controller uses: actions/checkout@v4 with: - ref: main + ref: staging fetch-depth: 0 persist-credentials: false @@ -98,16 +98,13 @@ jobs: node frontend/scripts/staging-image-manifest.mjs \ artifacts/staging-image/manifest.env "$DEPLOY_SHA" >>"$GITHUB_OUTPUT" - - name: Verify reviewed revision and staging target + - name: Verify staging revision and target env: DEPLOY_SHA: ${{ steps.revision.outputs.sha }} run: | set -euo pipefail git cat-file -e "$DEPLOY_SHA^{commit}" - git merge-base --is-ancestor "$DEPLOY_SHA" HEAD || { - echo "staging revision is not in the reviewed main history" >&2 - exit 1 - } + test "$DEPLOY_SHA" = "$(git rev-parse HEAD)" test "$DEPLOY_HOST" = "118.26.111.127" test "$DEPLOY_PORT" = "22" test "$DEPLOY_USER" = "deploy" @@ -213,7 +210,7 @@ jobs: exit 1 } payload="$(jq -cn --arg deploy_sha "$DEPLOY_SHA" \ - '{ref:"main",inputs:{deploy_sha:$deploy_sha,allow_rollback:"false"}}')" + '{ref:"staging",inputs:{deploy_sha:$deploy_sha,allow_rollback:"false"}}')" curl --fail --silent --show-error --request POST \ --header "Authorization: Bearer $GH_TOKEN" \ --header "Accept: application/vnd.github+json" \ diff --git a/.github/workflows/reset-staging-account.yml b/.github/workflows/reset-staging-account.yml index bfc4af47..b26b8248 100644 --- a/.github/workflows/reset-staging-account.yml +++ b/.github/workflows/reset-staging-account.yml @@ -41,10 +41,10 @@ jobs: RESET_CONFIRMATION: ${{ inputs.confirmation }} steps: - - name: Checkout trusted controller + - name: Checkout staging controller uses: actions/checkout@v4 with: - ref: main + ref: staging persist-credentials: false - name: Validate account reset request and staging target diff --git a/deploy/.env.staging.identity.example b/deploy/.env.staging.identity.example index c74373bb..fb5f7365 100644 --- a/deploy/.env.staging.identity.example +++ b/deploy/.env.staging.identity.example @@ -3,20 +3,19 @@ APP_ENV_FILE=../.env.staging CADDYFILE_PATH=./Caddyfile.staging SITE_ADDRESS=https://staging.jyotisha.chat -ADMIN_SITE_ADDRESS=https://admin.staging.jyotisha.chat # Staging-only cutover: identity and business data both use the private local # PostgreSQL service. Production remains on Supabase until a separate cutover. AUTH_PROVIDER=self-hosted SELF_HOSTED_IDENTITY_ENABLED=true AUTH_USER_ORIGIN=https://staging.jyotisha.chat -AUTH_ADMIN_ORIGIN=https://admin.staging.jyotisha.chat IDENTITY_DATABASE_URL=postgresql://identity_runtime:@postgres:5432/jyotisha APP_DATABASE_URL=postgresql://app_runtime:@postgres:5432/jyotisha ADMIN_DATABASE_URL=postgresql://admin_runtime:@postgres:5432/jyotisha BETTER_AUTH_USER_SECRET= -BETTER_AUTH_ADMIN_SECRET= RESEND_API_KEY= RESEND_FROM_EMAIL=Jyotisha Staging ADMIN_EMAILS= +EPAY_CONFIG_ENCRYPTION_KEY= +EPAY_CHAT_ENABLED=false JYOTISH_DYNAMIC_RECTIFICATION_TOKEN= diff --git a/deploy/Caddyfile.staging b/deploy/Caddyfile.staging index 0daab86e..66bbb59c 100644 --- a/deploy/Caddyfile.staging +++ b/deploy/Caddyfile.staging @@ -1,21 +1,4 @@ {$SITE_ADDRESS:https://staging.jyotisha.chat} { encode zstd gzip - - @adminPaths path /admin /admin/* /api/admin/* - respond @adminPaths "Not found" 404 - reverse_proxy web:3000 } - -{$ADMIN_SITE_ADDRESS:https://admin.staging.jyotisha.chat} { - encode zstd gzip - - @adminRoot path / - redir @adminRoot /admin/codes 302 - - @adminSurface path /login /admin /admin/* /api/admin/* /api/auth/* /_next/* /jyotish-logo.png /favicon.ico - handle @adminSurface { - reverse_proxy web:3000 - } - respond "Not found" 404 -} diff --git a/deploy/README.md b/deploy/README.md index 80bf1127..67dc5ce7 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -73,6 +73,14 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=... SUPABASE_SERVICE_ROLE_KEY=... ADMIN_EMAILS=... +# Required to save/read database-backed 易支付 settings. Base64 decoding must +# produce exactly 32 random bytes. Generate independently; never reuse auth keys. +EPAY_CONFIG_ENCRYPTION_KEY= +# Legacy EPAY_GATEWAY_URL / EPAY_PID / EPAY_KEY / EPAY_NOTIFY_URL / +# EPAY_RETURN_URL / EPAY_SITE_NAME remain fallback-only when no database row exists. +# Online packages stay hidden by default; only explicit true enables the fallback. +EPAY_CHAT_ENABLED=false + # Conversational birth-time rectification rollout controls. # Keep migrations false until the ordered database gate below has passed. RECTIFICATION_PRICE_CREDITS=3 @@ -176,10 +184,9 @@ The staging env file must include these non-secret selectors so Compose cannot f APP_ENV_FILE=../.env.staging CADDYFILE_PATH=./Caddyfile.staging SITE_ADDRESS=https://staging.jyotisha.chat -ADMIN_SITE_ADDRESS=https://admin.staging.jyotisha.chat ``` -Staging is fully self-hosted: set `AUTH_PROVIDER=self-hosted` and `SELF_HOSTED_IDENTITY_ENABLED=true`. Add the three role-specific server-only database URLs, separate user/admin Better Auth secrets, origins, and staging-only Resend settings listed in `deploy/.env.staging.identity.example`. Browser code uses same-origin APIs; it receives neither database credentials nor Supabase keys. Production remains on Supabase and is not changed by the staging workflow. See `docs/operations/self-hosted-identity.md` for validation and rollback commands. +Staging is fully self-hosted: set `AUTH_PROVIDER=self-hosted` and `SELF_HOSTED_IDENTITY_ENABLED=true`. Add the three role-specific server-only database URLs, the single `AUTH_USER_ORIGIN` and `BETTER_AUTH_USER_SECRET`, and staging-only Resend settings listed in `deploy/.env.staging.identity.example`. The main-site Better Auth user session is also used by `/admin`; persisted `identity.users.role=admin` is the only self-hosted backend role, while `viewer` and ordinary users are denied. Browser code uses same-origin APIs; it receives neither database credentials nor Supabase keys. Production remains on Supabase and is not changed by the staging workflow. See `docs/operations/self-hosted-identity.md` for validation and rollback commands. After source sync and before `up`, the workflow validates `.env.staging` mode/selectors, explicitly pins the three staging selectors against ambient shell overrides, and runs `docker compose --env-file .env.staging -f deploy/docker-compose.server.yml config --quiet`. For later manual inspections, run the same checks only after the tracked deployment files exist on the server. Do not use a manual gate run from `main` as the first publishing path: publishing requires a successful push to `staging`, while manual `Deploy staging` requires a successful gate run for the exact SHA. diff --git a/deploy/configure-staging-rectification-rollout.sh b/deploy/configure-staging-rectification-rollout.sh index 75cd5bd1..10ef3fd6 100755 --- a/deploy/configure-staging-rectification-rollout.sh +++ b/deploy/configure-staging-rectification-rollout.sh @@ -144,7 +144,6 @@ export APP_ENV_FILE='../.env.staging' export DATABASE_ENV_FILE='../.env.staging.database' export CADDYFILE_PATH='./Caddyfile.staging' export SITE_ADDRESS='https://staging.jyotisha.chat' -export ADMIN_SITE_ADDRESS='https://admin.staging.jyotisha.chat' export GITHUB_SHA="$EXPECTED_DEPLOY_SHA" compose=(docker compose -p jyotisha-staging --env-file .env.staging "${compose_files[@]}") diff --git a/deploy/docker-compose.server.yml b/deploy/docker-compose.server.yml index 2ceab51d..04222ca2 100644 --- a/deploy/docker-compose.server.yml +++ b/deploy/docker-compose.server.yml @@ -53,7 +53,6 @@ services: restart: unless-stopped environment: SITE_ADDRESS: ${SITE_ADDRESS:-https://jyotisha.chat} - ADMIN_SITE_ADDRESS: ${ADMIN_SITE_ADDRESS:-https://admin.staging.jyotisha.chat} ports: - "80:80" - "443:443" diff --git a/deploy/railway-api.Dockerfile b/deploy/railway-api.Dockerfile index 23131945..48f8561f 100644 --- a/deploy/railway-api.Dockerfile +++ b/deploy/railway-api.Dockerfile @@ -1,11 +1,14 @@ -FROM python:3.12-slim +FROM m.daocloud.io/docker.io/library/python:3.12-slim ENV PYTHONUNBUFFERED=1 \ - PIP_NO_CACHE_DIR=1 + PIP_NO_CACHE_DIR=1 \ + PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple/ \ + PIP_DEFAULT_TIMEOUT=60 WORKDIR /app COPY requirements.txt ./ -RUN apt-get update \ +RUN sed -i 's|http://deb.debian.org|https://mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources \ + && apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update \ && apt-get install -y --no-install-recommends build-essential \ && python -m pip install -r requirements.txt \ && apt-get purge -y --auto-remove build-essential \ diff --git a/deploy/railway-web.Dockerfile b/deploy/railway-web.Dockerfile index 5926686d..cf44ea5b 100644 --- a/deploy/railway-web.Dockerfile +++ b/deploy/railway-web.Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-alpine +FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/node:22-alpine WORKDIR /app/frontend COPY frontend/package.json frontend/package-lock.json ./ diff --git a/deploy/run-staging-deploy.sh b/deploy/run-staging-deploy.sh index 580c8dfc..44a4e685 100755 --- a/deploy/run-staging-deploy.sh +++ b/deploy/run-staging-deploy.sh @@ -6,6 +6,11 @@ required=( INCOMING_PATH DEPLOY_PATH API_IMAGE WEB_IMAGE DEPLOY_SHA EXPECTED_PREVIOUS_SHA ALLOW_ROLLBACK DOCKER_CONFIG STAGING_URL ) +case "${DOCKER_BIN:-docker}" in + docker) docker_command=(docker) ;; + "sudo -n docker") docker_command=(sudo -n docker --config "$DOCKER_CONFIG") ;; + *) echo "unsafe staging Docker command" >&2; exit 1 ;; +esac for key in "${required[@]}"; do if [ -z "${!key:-}" ]; then echo "required staging deployment input is missing: $key" >&2 @@ -14,7 +19,7 @@ for key in "${required[@]}"; do done sha_pattern='^[0-9a-f]{40}$' -digest_pattern='^ghcr\.io/jesse-ux/jyotisha-(api|web)@sha256:[0-9a-f]{64}$' +digest_pattern='^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:[1-9][0-9]{0,4})?(/[a-z0-9]+([._-][a-z0-9]+)*)+@sha256:[0-9a-f]{64}$' image_id_pattern='^sha256:[0-9a-f]{64}$' if [[ ! "$DEPLOY_SHA" =~ $sha_pattern ]] || [[ ! "$API_IMAGE" =~ $digest_pattern ]] || @@ -22,12 +27,14 @@ if [[ ! "$DEPLOY_SHA" =~ $sha_pattern ]] || echo "unsafe staging image identity" >&2 exit 1 fi +api_repository="${API_IMAGE%@sha256:*}" +web_repository="${WEB_IMAGE%@sha256:*}" if [ "$ALLOW_ROLLBACK" != "true" ] && [ "$ALLOW_ROLLBACK" != "false" ]; then echo "invalid rollback authorization" >&2 exit 1 fi case "$INCOMING_PATH" in - "$DEPLOY_PATH"/.incoming/*) ;; + /tmp/jyotisha-staging.*) ;; *) echo "unsafe incoming staging path" >&2; exit 1 ;; esac @@ -43,11 +50,11 @@ current_sha="not-deployed" if [ -f "$state_directory/deployed-revision" ]; then current_sha="$(<"$state_directory/deployed-revision")" else - existing_web="$(docker ps -aq \ + existing_web="$("${docker_command[@]}" ps -aq \ --filter 'label=com.docker.compose.project=jyotisha-staging' \ --filter 'label=com.docker.compose.service=web' | head -n 1)" if [ -n "$existing_web" ]; then - discovered_sha="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \ + discovered_sha="$("${docker_command[@]}" inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \ "$existing_web" | sed -n 's/^GITHUB_SHA=//p' | head -n 1)" if [ -n "$discovered_sha" ]; then current_sha="$discovered_sha"; fi fi @@ -69,7 +76,7 @@ if [ "$ALLOW_ROLLBACK" = "false" ] && fi container_id() { - docker ps -aq \ + "${docker_command[@]}" ps -aq \ --filter 'label=com.docker.compose.project=jyotisha-staging' \ --filter "label=com.docker.compose.service=$1" | head -n 1 } @@ -80,20 +87,20 @@ repo_digest_for_container() { local id image_id id="$(container_id "$service")" [ -n "$id" ] || return 0 - image_id="$(docker inspect --format '{{.Image}}' "$id")" - docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$image_id" | + image_id="$("${docker_command[@]}" inspect --format '{{.Image}}' "$id")" + "${docker_command[@]}" image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$image_id" | awk -v prefix="$repository@sha256:" 'index($0, prefix) == 1 { print; exit }' } -previous_api_image="$(repo_digest_for_container api ghcr.io/jesse-ux/jyotisha-api)" -previous_web_image="$(repo_digest_for_container web ghcr.io/jesse-ux/jyotisha-web)" +previous_api_image="$(repo_digest_for_container api "$api_repository")" +previous_web_image="$(repo_digest_for_container web "$web_repository")" previous_api_id="" previous_web_id="" if [ -n "$(container_id api)" ]; then - previous_api_id="$(docker inspect --format '{{.Image}}' "$(container_id api)")" + previous_api_id="$("${docker_command[@]}" inspect --format '{{.Image}}' "$(container_id api)")" fi if [ -n "$(container_id web)" ]; then - previous_web_id="$(docker inspect --format '{{.Image}}' "$(container_id web)")" + previous_web_id="$("${docker_command[@]}" inspect --format '{{.Image}}' "$(container_id web)")" fi rollback_image() { @@ -118,7 +125,7 @@ bash deploy/validate-staging-env.sh \ bash deploy/validate-staging-database-env.sh .env.staging.database compose=( - docker compose -p jyotisha-staging --env-file .env.staging + "${docker_command[@]}" compose -p jyotisha-staging --env-file .env.staging -f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml -f deploy/docker-compose.staging.yml ) @@ -126,7 +133,6 @@ export APP_ENV_FILE='../.env.staging' export DATABASE_ENV_FILE='../.env.staging.database' export CADDYFILE_PATH='./Caddyfile.staging' export SITE_ADDRESS='https://staging.jyotisha.chat' -export ADMIN_SITE_ADDRESS='https://admin.staging.jyotisha.chat' export GITHUB_SHA="$DEPLOY_SHA" "${compose[@]}" config --quiet @@ -172,10 +178,10 @@ verify_container_image() { local id expected_id running_id repo_digests id="$(container_id "$service")" [ -n "$id" ] - expected_id="$(docker image inspect --format '{{.Id}}' "$expected_ref")" - running_id="$(docker inspect --format '{{.Image}}' "$id")" + expected_id="$("${docker_command[@]}" image inspect --format '{{.Id}}' "$expected_ref")" + running_id="$("${docker_command[@]}" inspect --format '{{.Image}}' "$id")" [ "$running_id" = "$expected_id" ] - repo_digests="$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$expected_id")" + repo_digests="$("${docker_command[@]}" image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$expected_id")" grep -Fqx "$expected_ref" <<<"$repo_digests" } verify_container_image api "$API_IMAGE" @@ -184,7 +190,6 @@ verify_container_image rectification-v4-worker "$WEB_IMAGE" "${compose[@]}" exec -T \ -e EXPECTED_SHA="$DEPLOY_SHA" -e STAGING_URL="$STAGING_URL" \ - -e STAGING_ADMIN_URL="https://admin.staging.jyotisha.chat" \ web node --input-type=module <<'NODE' const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); let login; @@ -196,15 +201,10 @@ for (let attempt = 0; attempt < 12; attempt += 1) { await delay(5_000); } if (!login?.ok) process.exit(1); -const adminLogin = await fetch(`${process.env.STAGING_ADMIN_URL}/login`); -if (!adminLogin.ok) process.exit(1); -const adminRoot = await fetch(process.env.STAGING_ADMIN_URL, { redirect: "manual" }); -if ( - adminRoot.status !== 302 || - adminRoot.headers.get("location") !== "/admin/codes" -) process.exit(1); -const adminSession = await fetch(`${process.env.STAGING_ADMIN_URL}/api/auth/get-session`); -if (!adminSession.ok) process.exit(1); +const adminPage = await fetch(`${process.env.STAGING_URL}/admin`, { redirect: "manual" }); +if (adminPage.status !== 307 || adminPage.headers.get("location") !== "/login") process.exit(1); +const adminApi = await fetch(`${process.env.STAGING_URL}/api/admin/session`); +if (adminApi.status !== 401) process.exit(1); const account = await fetch(`${process.env.STAGING_URL}/api/account`); if (account.status !== 401) process.exit(1); const publicHealth = await fetch(`${process.env.STAGING_URL}/api/health`); diff --git a/deploy/run-staging-migration.sh b/deploy/run-staging-migration.sh index 00d611f7..1c859f40 100755 --- a/deploy/run-staging-migration.sh +++ b/deploy/run-staging-migration.sh @@ -6,6 +6,11 @@ required=( INCOMING_PATH DEPLOY_PATH WEB_IMAGE DEPLOY_SHA EXPECTED_PREVIOUS_SHA DOCKER_CONFIG ) +case "${DOCKER_BIN:-docker}" in + docker) docker_command=(docker) ;; + "sudo -n docker") docker_command=(sudo -n docker --config "$DOCKER_CONFIG") ;; + *) echo "unsafe staging Docker command" >&2; exit 1 ;; +esac for key in "${required[@]}"; do if [ -z "${!key:-}" ]; then echo "required staging migration input is missing: $key" >&2 @@ -17,12 +22,13 @@ 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 } case "$INCOMING_PATH" in - "$DEPLOY_PATH"/.incoming/*) ;; + /tmp/jyotisha-staging.*) ;; *) echo "unsafe incoming staging path" >&2; exit 1 ;; esac @@ -38,11 +44,11 @@ current_sha="not-deployed" if [ -f "$state_directory/deployed-revision" ]; then current_sha="$(<"$state_directory/deployed-revision")" else - existing_web="$(docker ps -aq \ + existing_web="$("${docker_command[@]}" ps -aq \ --filter 'label=com.docker.compose.project=jyotisha-staging' \ --filter 'label=com.docker.compose.service=web' | head -n 1)" if [ -n "$existing_web" ]; then - discovered_sha="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \ + discovered_sha="$("${docker_command[@]}" inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \ "$existing_web" | sed -n 's/^GITHUB_SHA=//p' | head -n 1)" if [ -n "$discovered_sha" ]; then current_sha="$discovered_sha"; fi fi @@ -71,8 +77,8 @@ bash deploy/validate-staging-env.sh \ bash deploy/validate-staging-database-env.sh .env.staging.database export DATABASE_ENV_FILE='../.env.staging.database' -compose=(docker compose -p jyotisha-staging -f deploy/docker-compose.postgres.yml) -docker pull "$WEB_IMAGE" +compose=("${docker_command[@]}" compose -p jyotisha-staging -f deploy/docker-compose.postgres.yml) +"${docker_command[@]}" pull "$WEB_IMAGE" "${compose[@]}" up -d --no-build --pull never --wait postgres "${compose[@]}" exec -T postgres psql -v ON_ERROR_STOP=1 -U postgres -d jyotisha \ -f /dev/stdin < deploy/postgres/002-ensure-business-compatibility-roles.sql diff --git a/deploy/validate-staging-env.sh b/deploy/validate-staging-env.sh index d54605cb..807af650 100755 --- a/deploy/validate-staging-env.sh +++ b/deploy/validate-staging-env.sh @@ -41,11 +41,9 @@ require_selector() { require_selector APP_ENV_FILE ../.env.staging require_selector CADDYFILE_PATH ./Caddyfile.staging require_selector SITE_ADDRESS https://staging.jyotisha.chat -require_selector ADMIN_SITE_ADDRESS https://admin.staging.jyotisha.chat require_selector AUTH_PROVIDER self-hosted require_selector SELF_HOSTED_IDENTITY_ENABLED true require_selector AUTH_USER_ORIGIN https://staging.jyotisha.chat -require_selector AUTH_ADMIN_ORIGIN https://admin.staging.jyotisha.chat require_literal() { local key="$1" @@ -88,13 +86,6 @@ if ! [[ "$admin_database_url" =~ ^postgresql://admin_runtime:([A-Za-z0-9._~-]|%[ fi require_literal BETTER_AUTH_USER_SECRET 32 -user_secret="$LITERAL_VALUE" -require_literal BETTER_AUTH_ADMIN_SECRET 32 -admin_secret="$LITERAL_VALUE" -if [ "$user_secret" = "$admin_secret" ]; then - echo "staging identity secrets must be different" >&2 - exit 1 -fi require_literal RESEND_API_KEY 10 require_literal RESEND_FROM_EMAIL 5 if [[ "$LITERAL_VALUE" != *@* ]]; then @@ -106,6 +97,13 @@ if [[ "$LITERAL_VALUE" != *@* ]]; then echo "invalid staging identity setting: ADMIN_EMAILS" >&2 exit 1 fi +require_literal EPAY_CONFIG_ENCRYPTION_KEY 44 +if [ "${#LITERAL_VALUE}" -ne 44 ] || + [[ ! "$LITERAL_VALUE" =~ ^[A-Za-z0-9+/]{43}=$ ]]; then + echo "invalid staging identity setting: EPAY_CONFIG_ENCRYPTION_KEY" >&2 + exit 1 +fi +require_selector EPAY_CHAT_ENABLED false require_literal JYOTISH_DYNAMIC_RECTIFICATION_TOKEN 32 echo "staging environment selectors: valid" diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 623803a3..9d0cc9ba 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1579,196 +1579,51 @@ - 相关记录:BUG-075、BUG-085 - 修复版本:本地 V5 重构,待提交与 staging 验收 -## BUG-087 | 语义机会仍退化为固定文案并在拒绝后重复追问 +## BUG-087 | self-hosted staging 管理员看不到独立后台入口 + +- 状态:superseded by BUG-092 +- 首次发现:2026-07-29 +- 最近更新:2026-07-29 +- 影响面:self-hosted staging 账户菜单、`GET /api/account`、独立后台入口;不影响后台独立登录与 `requireAdminSession` +- 用户现象:身份库已持久化 `admin` 或 `viewer` 角色的用户登录主站后,账户菜单不显示后台入口;即使显示旧入口,主站 `/admin` 路径也会返回 404。 +- 触发条件:`AUTH_PROVIDER=self-hosted`,后台部署在与主站不同的 `AUTH_ADMIN_ORIGIN`,用户角色以逗号分隔形式持久化在 `identity.users.role`。 +- 根因:主站 `isAdminUser` 对 self-hosted 模式直接返回 `false`,没有读取持久化角色;侧栏又把入口写死为主站相对路径 `/admin/codes`。既有后台鉴权已按持久化角色执行,但主站入口发现逻辑没有复用同一授权事实,独立域名部署合同也没有进入账户响应。 +- 修复:self-hosted 分支通过现有 `ADMIN_DATABASE_URL` 管理只读连接查询当前用户的 `identity.users.role`,仅 `admin` 或 `viewer` 可见入口,且不使用 `ADMIN_EMAILS` 替代角色授权;`GET /api/account` 在服务端解析身份配置并返回 `AUTH_ADMIN_ORIGIN + /admin/codes`,Supabase 模式继续返回 `/admin/codes`;账户与侧栏类型透传该 URL,并将文案改为“后台管理”。后台独立登录和 `requireAdminSession` 保持不变。 +- 验证:`frontend/tests/admin-contracts.test.ts`、`frontend/tests/admin-users-contract.test.ts`、`frontend/tests/account-api.test.ts`、`frontend/tests/sidebar-contract.test.ts` 锁定持久化角色、独立后台 URL、服务端环境边界和后台写权限门禁;目标 TypeScript、构建与 staging 登录态 smoke 结果另行记录。 +- 防复发:self-hosted 主站入口发现必须以 `identity.users.role` 为授权事实,不能退回邮箱 allowlist;客户端不得读取后台 origin 环境变量或硬编码主站 `/admin` 路径;后台 API 必须继续独立执行 `requireAdminSession`,入口可见性不得被当作授权。 +- 相关记录:BUG-010、BUG-083、BUG-084、BUG-092 +- 复发自:BUG-010 +- 修复版本:已由 BUG-092 的同域单会话架构取代 + +## BUG-092 | self-hosted staging 双域后台与主站会话模型冲突 - 状态:resolved - 首次发现:2026-07-29 - 最近更新:2026-07-29 -- 影响面:生时校正 Agent 的证据协调、问题机会、Reasoner 上下文、Renderer、候选范围提示与 Skill 合同 -- 用户现象:对话会把月份已经明确的经历继续机械追问具体日期,按固定领域顺序轮询;用户表示不知道、跳过或换方向后仍可能回到同一事件,Renderer 还会重复套话和未变化的候选范围。 -- 触发条件:Question Opportunity 直接持久化最终 `prompt`,Renderer 再用服务端问题覆盖自然生成结果;缺失领域按固定数组选择,日期策略把所有非日精度事件视为未完成,且证据协调未完整区分拒绝、未知、换向和回答了另一事件。 -- 根因:机会合同混合了“为什么问、要补什么字段”和“最终怎么说”,导致 Reasoner 看不到最近对话语义、Renderer 无法安全自然表达;同时 target disposition 和重复追问预算不完整,固定领域与日期控制流绕过了信息增益、隐私成本和稳定性诊断。 -- 修复:升级为兼容旧 `prompt` 的 `semantic-question-v2`,由 Builder 同时生成并按 utility 排序最多五个活动机会;补齐 `resolved / unknown / declined / direction_change / answered_other_event / unresolved / not_applicable`,限制同一目标连续追问和回答其他事件后的温和补问次数;月份默认足够,仅在日期敏感诊断不稳定时细化。Reasoner 获得脱敏的最近 Turn、事件、目标和机会语义;Renderer 改为验证自然问题并在失败时使用锚定 fallback,同时只在公开门首次通过或范围实际变化时播报范围,相同范围即使再次计算或收到 offer 决策也不重复播报。受限模型辅助提取仅补充确定性解析缺口,服务端继续校验原文子串和日期。 -- 验证:V6 语义合同、日期敏感性、拒绝/换向、回答新事件不覆盖旧事件、单次补问、Renderer 锚点/单问题/分钟注入/内部信息拒绝、候选范围去重、Reasoner 上下文、模型辅助提取和旧 Opportunity 兼容测试通过;四轮端到端测试覆盖月份职业事件、外地入学、无日期搬家换向和后续职业事件,并保留 exact-minute、Profile 写入、legacy/shadow、V5 候选引擎与 completed-job replay 边界。完整前端测试、lint、TypeScript 与 Python 结果见本任务交付记录。 -- 防复发:问题机会只表达服务器拥有的语义目标和约束,最终文案必须通过 realization validator;日期追问必须有诊断依据,拒绝/换向必须关闭目标,领域选择必须由 utility 和上下文驱动。Skill 明确禁止固定问卷、重复范围、唯一分钟、D60 驱动和公开内部评分/技术轨迹。 -- 相关记录:BUG-068、BUG-075、BUG-080、BUG-081、BUG-082、BUG-085、BUG-086 -- 复发自:BUG-085、BUG-086 -- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1`,本地验证完成,待提交与 staging 发布 +- 影响面:staging Better Auth 配置、后台页面与 API、登录、账户入口、Caddy、部署校验和 smoke;生产配置不变。 +- 用户现象:管理员需要第二套后台域名和浏览器会话才能进入后台,主站登录态不能直接使用;`viewer` 还被当作后台只读角色,与仅数据库 `admin` 可进入的产品合同冲突。 +- 触发条件:self-hosted staging 同时配置用户与后台 origin/secret、Caddy 拆分两个站点,并按 Host 选择 Better Auth 实例。 +- 根因:早期隔离设计把后台浏览器 surface 当成第二套身份系统,导致入口发现、登录、Cookie、部署变量和授权策略重复;同时把入口可见性与 API 权限错误扩展到 `viewer`。 +- 架构决策:后台复用主站 Better Auth user session;`identity.users.role` 的持久化 `admin` 是唯一后台授权事实。Better Auth 插件的 `/api/auth/admin` endpoint 继续在主站 fail-closed `404`,未知 Host 继续 `421`。 +- 修复:删除活动运行时后台 origin/secret 与 `services.admin`,服务端数据 client 和 `requireAdminSession` 统一读取 user session;后台 layout 增加服务端 gate,匿名转 `/login`、非 admin 不渲染;所有后台 API 保留独立 guard,payments/packages 改用 `requireAdminSession`;`isAdminUser`、账户入口和 Refine policy 收敛为 admin-only;登录取消 Host 分流;staging Caddy、Compose、环境校验、部署脚本、工作流和 smoke 收敛为同域。 +- 验证:身份 config/host/auth、admin policy/contracts、account/sidebar/login、部署/工作流与 admin layout/API guard 合同更新;针对性测试、TypeScript、Next build 与 `git diff --check` 结果记录在本次交付报告。生产部署未执行。 +- 防复发:活动运行配置和测试不得重新引入独立后台域名、`AUTH_ADMIN_ORIGIN`、`BETTER_AUTH_ADMIN_SECRET` 或浏览器 admin auth service;`viewer` 对后台入口、页面、读 API 和写 API 均必须为 `403`;入口可见性不能替代 route guard。 +- 相关记录:BUG-010、BUG-083、BUG-084、BUG-087 +- 复发自:BUG-087 +- 修复版本:`435e628806390e7ae138363491e7bae63ee801d4`,staging 已验收 -## BUG-088 | TypeScript 全量检查被过期测试夹具阻塞 +## BUG-093 | 后台支付入口分散且界面风格不一致 - 状态:resolved - 首次发现:2026-07-29 -- 最近更新:2026-07-29 -- 影响面:`npx tsc --noEmit` 本地发布前质量门 -- 用户现象:生产构建通过,但全量 TypeScript 检查在三个测试文件报错:事件评分输入仍传入服务端固定的 `high_rigor`、身份全局缓存清理被控制流误收窄为 `never`、onboarding 测试未归一化 PostgreSQL `Date` 联合类型。 -- 根因:测试夹具落后于现有生产合同;这些报错不来自 V6 Agent 运行时,但会让显式 TypeScript 验证失败。 -- 修复:删除客户端不应拥有的 `high_rigor` 输入;在异步请求结束后从 `globalThis` 重新读取身份缓存;按生产边界把 `Date` 归一化为日期字符串后再构建 onboarding cache identity。 -- 验证:`npx tsc --noEmit`、相关前端测试和生产构建通过。 -- 防复发:测试输入只使用公开类型拥有的字段;异步初始化的全局缓存不要依赖删除前的局部控制流;数据库日期联合类型在进入纯字符串合同前必须归一化。 - -## BUG-089 | Staging CI 自动升级 MCP 2.0 导致旧 FastMCP 导入失败 - -- 状态:resolved -- 首次发现:2026-07-29 -- 最近更新:2026-07-29 -- 影响面:`Staging Backend Quality Gate` 的 Python quick quality gate 与 staging 发布 -- 用户现象:本地完整验证通过,但 staging push 的质量门在导入 `mcp_server.py` 时失败,报 `ModuleNotFoundError: No module named 'mcp.server.fastmcp'`。 -- 根因:`requirements.txt` 与 `pyproject.toml` 仅声明 `mcp>=1.0`;CI 在 2026-07-29 安装了不兼容的 `mcp 2.0.0`,而仓库当前服务端仍使用 MCP 1.x 的 `mcp.server.fastmcp.FastMCP` 导入合同。本地环境保留 `mcp 1.25.0`,因此未复现依赖漂移。 -- 修复:两个发布依赖入口统一限制为 `mcp>=1.0,<2`,继续使用已验证的 MCP 1.x API,不在本次发布中混入 MCP 2.0 迁移。 -- 验证:新增依赖合同测试同时读取 `requirements.txt` 和 `pyproject.toml`,防止任一入口再次放宽到 MCP 2.x;Python quick quality gate 与构建重新执行。 -- 防复发:运行时依赖的主版本兼容边界必须在全部安装入口保持一致;升级 MCP 2.x 必须作为独立迁移处理并先替换导入/API 合同。 -- 相关记录:BUG-087、BUG-088 -- 修复版本:待本次 staging 修复提交与部署验收 - -## BUG-090 | V6 审查发现用户可见分钟注入、家庭事件越权和最新事件排序错误 - -- 状态:resolved -- 首次发现:2026-07-29 -- 最近更新:2026-07-29 -- 影响面:生时纠正 Renderer、模型辅助事件提取、Opportunity 与 Reasoner 最近事件上下文 -- 用户现象:模型可能在 acknowledgement 或 limitation 中声称唯一出生分钟;家庭健康事件可能被矛盾模型字段伪装成本人可评分事件;UUID 排序与创建时间相反时,下一问可能承接较早经历。 -- 根因:分钟安全验证只覆盖 question;辅助提取校验未拒绝 `subject=self` 与非空家庭 `relatedPerson` 的矛盾组合;账本的稳定 UUID 排序被误当作会话时间顺序。 -- 修复:全部用户可见 Renderer 字段统一执行出生分钟和内部信息安全校验并回落到服务器确定性文案;辅助提取在服务器拒绝主体/亲属矛盾并保持家庭事件 `context_only` 边界;Builder 与 Reasoner 显式按 `createdAt`、`eventId`、revision 稳定排序最近事件,不改变证据哈希使用的账本排序。 -- 验证:新增 acknowledgement/limitation 分钟注入、家庭 ICU 事件越权、UUID 与创建时间逆序的回归测试,并重新运行前端完整测试、lint、TypeScript 与构建。 -- 防复发:所有模型可写用户文案共享同一安全边界;模型提取不能决定评分主体;用于哈希的稳定顺序不得被复用为会话时序。 -- 相关记录:BUG-075、BUG-086、BUG-087 -- 修复版本:待本次 staging 修复提交与部署验收 - -## BUG-091 | Staging Case rollout 未启用 V5 Agent 导致 V6 继续输出固定模板 - -- 状态:resolved -- 首次发现:2026-07-29 -- 最近更新:2026-07-29 -- 影响面:staging 生时纠正新 Case 与 V6 未完成 Case 的用户可见回复 -- 用户现象:用户提交“2016 年 9 月离家去外地上大学”后,回复仍固定为“我记下了这段经历。接下来请继续讲另一件……”,没有进入 Semantic Question Renderer。 -- 根因:Case rollout 只写入 V3 创建门和 smoke 状态,没有写入 `RECTIFICATION_AGENT_V5_ENABLED`、`RECTIFICATION_AGENT_V5_SHADOW`、`RECTIFICATION_AGENT_V5_CANARY_PERCENT`;因此 `selectRectificationDeploymentMode()` 把新 Case 持久化为 `v4_legacy`,Orchestrator 必然调用 Legacy Projector。 -- 修复:受控 staging rollout 现在原子写入 V5 Agent 开关,public 与 smoke rollout 使用 `v5_agent`、100% canary,并重建 web/worker;staging 中唯一满足 V6 版本、未完成、无 open Job 条件的错误 Case 已原位升级为 `rectification-evidence-v5` / `v5_agent`,历史 Turn、Event、Job 与 Agent Run 保持不变。 -- 验证:rollout 脚本测试断言三项 V5 选择器只写一次,并在成功前核对 web/worker 容器实际读取的 `enabled`、`shadow`、`canary`;staging 活跃 Case 聚合只剩 `v5_agent`;健康检查保持 exact SHA、public、ready。 -- 防复发:公开 Case rollout 必须同时控制创建门与 Agent deployment mode,并验证运行容器的实际环境;仅有 `readyForNewCases=true` 不再视为新对话 Renderer 已启用的充分证据。 -- 相关记录:BUG-085、BUG-086、BUG-087 -- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1` - -## BUG-092 | Semantic Renderer 成功后仍被静默替换成固定领域模板 - -- 状态:resolved -- 首次发现:2026-07-29 -- 最近更新:2026-07-29 -- 影响面:V6 `ask_new_event` Opportunity 排序、问题验证、Renderer telemetry 与 staging 用户可见下一问 -- 用户现象:用户提交“2020 年 4 月去石油化工研究院实习做研究员”后,系统仍显示“承接……请再说一件……哪次搬家、离乡或长期迁居……”,像固定问卷。 -- 根因:Builder 将最近六轮答案拼接为当前主题,使较早教育事件中的“离家/外地”再次提升 relocation,同时未覆盖领域奖励在已经满足最小领域数后仍占主导;Renderer 对自然 `new_dated_event` 问法使用过窄词面校验,校验失败后静默替换为 Builder 固定 fallback,telemetry 仍记为 `renderer succeeded`。 -- 修复:当前主题只读取最新回答或最新事件;达到两个可评分领域后显著降低纯领域覆盖收益并提高最新主题连续性;移除“承接……请再说一件……”拼接,fallback 改为锚定当前经历的单句问题;放宽自然新事件词面但继续执行单问题、锚点、内部信息和出生分钟安全校验;validator 回退单独记录 `renderer rejected` 与脱敏错误码。 -- 验证:真实两事件重放断言 career 机会优先于旧 relocation 关键词、月份不被细化、旧固定模板被拒绝、锚定研究院实习的自然问题被保留且不等于 fallback;完整前端、lint、TypeScript、Python V5 与 staging smoke 随发布记录执行。 -- 防复发:模型调用成功、Schema 成功和问题被接受必须分开观测;Opportunity utility 不得把历史关键词与“未覆盖领域”组合成伪装的固定轮询。 -- 相关记录:BUG-086、BUG-090、BUG-091 -- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1` - -## BUG-093 | 首次候选评分因跨语言计算规格哈希不一致而失败 - -- 状态:resolved -- 首次发现:2026-07-29 -- 最近更新:2026-07-29 -- 影响面:V5 Candidate Engine 首次达到三事件评分门后的 Feature Snapshot 原子持久化 -- 用户现象:第三件可评分事件已经保存在 Turn,但 Worker 最终显示“这次比较没有完成,回答已经保留,请再试一次”,Case 恢复上一问题且没有写入新事件。 -- 根因:服务器创建 Case 时由 TypeScript 对整数时区 `8` 计算规格哈希;Python 请求归一化把它变成浮点数 `8.0`,而 Python JSON 序列化保留 `.0`。两个运行时对语义相同的规格得到不同哈希,完成事务因此拒绝 Feature Snapshot 并抛出 `rectification_v5_feature_snapshot_mismatch`。 -- 修复:Python 生成跨服务 Calculation Spec 时将整数值的经纬度和时区规范化为整数,使其 JSON 数字表示与 TypeScript `JSON.stringify` 一致;评分算法和候选矩阵不变。 -- 验证:新增已知 TypeScript 哈希向量测试,修复前稳定失败、修复后通过;staging Case `e2d3e1d2-efb0-461d-9914-f890bc2b8569` 的 PostgreSQL 日志确认原始异常,使用同一规格重放确认 Python Feature Snapshot 哈希恢复为 Case 哈希。 -- 防复发:跨语言持久化指纹必须使用已知向量验证 JSON 数字规范化,不能只在各自语言内断言自洽。 +- 最近更新:2026-07-30 +- 影响面:后台 Refine 侧栏、`/admin/payments`、`/admin/packages`、易支付配置与对话页充值入口。 +- 用户现象:支付记录与支付配置占用两个导航项,页面仍使用主站 `standalone-page/admin-header/admin-section` 样式;套餐新增表单常驻页面,后台默认退出入口还会触发登出,管理员难以直接返回对话;对话页支付入口缺少安全默认关闭和服务端创建订单硬门禁。2026-07-29 复发时,Z-Pay 配置不能折叠且占据长页面,后台受全局 `html/body overflow:hidden` 限制无法纵向滚动,套餐 API 与易支付配置 API 仍调用 self-hosted adapter 不支持的 Supabase builder/RPC。2026-07-30 部署 `dd8e2ad9c7e76d0152b4563c43a45b1e26137035` 后,`GET /api/admin/payments` 与套餐管理仍返回 500。 +- 触发条件:进入同域 `/admin` 后管理支付记录或套餐,或点击 Refine 侧栏底部默认 Logout;复发条件为进入支付管理、展开长配置或调用套餐 CRUD / 易支付配置读写。2026-07-30 的数据库权限复发在 `admin_runtime` 通过 `ADMIN_DATABASE_URL` 查询支付表时稳定触发。 +- 根因:首轮支付后台实现依赖 Supabase 专用关联 select、分页、计数和 Admin Auth 查询;self-hosted staging 的本地 PostgreSQL adapter 不支持这些 builder 能力,支付记录因此统一降级为“支付记录服务暂时不可用”。同页套餐设计也不符合最新后台信息架构,易支付配置响应漏投影 `chat_enabled`,chat 创建订单又依赖服务端提交网关后猜测跳转地址,不兼容标准易支付收银台表单页。复发遗漏源于上轮只把支付记录切换到 PostgreSQL,套餐与配置契约测试没有锁定 self-hosted 数据链,且未覆盖聊天全局滚动边界下的后台专用滚动容器。2026-07-30 的直接根因是 `20260727020000_epay_packages_orders.sql` 只向 Supabase 的 `service_role` / `authenticated` 授权,未向 self-hosted 后台实际使用的 `admin_runtime` 授予 `payment_packages`、`payment_orders` 权限,也未添加对应 RLS 策略;因此数据库健康且新 SHA 已部署,后台 SQL 仍被 PostgreSQL权限门禁拒绝。同日还确认 staging 迁移与部署工作流错误地要求目标 SHA 属于 `main` 历史,使完全独立的测试分支被生产分支阻塞;该控制面耦合导致为恢复 staging 而误合并生产 main。 +- 修复:支付记录改为通过 `queryAdminRows` 执行参数化 SQL,联表 `public.payment_orders`、`public.payment_packages` 和 `identity.users`,以窗口计数保留分页合同并用独立聚合 SQL输出统计;不再使用 Supabase builder 或 Admin Auth。后台在支付管理之后新增独立“套餐管理”资源和页面,套餐新增、编辑、停用、错误重试及原字段保持完整,支付页只保留概览、Z-Pay(易支付)渠道配置和支付记录。配置读取补回 `chat_enabled` 与 `chatEnabled`。创建订单完成登录、开关、配置、SSRF、套餐和订单校验后,直接返回带 `sign/sign_type` 的标准 `submit.php` 收银台 URL,不服务端请求网关、不返回商户密钥;对话页用浏览器打开该 URL,套餐加载异常显示安全错误,正常 `enabled=false` 仍静默隐藏。复发修复将 Z-Pay 配置改为默认收起的 Ant Design `Collapse`,展开后才显示表单和操作;为 AdminApp 增加 `admin-app-shell` 的 `100dvh` 独立纵向滚动边界而不改聊天全局规则;套餐 CRUD 全部改用 `queryAdminRows` 参数化 SQL、UUID 校验、`returning` 与 404;易支付读取仅在 PostgreSQL `42P01` 时回退环境变量,保存直接参数化调用 `public.admin_save_epay_settings` 并使用函数返回行,保留原子审计和脱敏响应。2026-07-30 新增前向迁移 `20260730010000_admin_payment_permissions.sql`,向 `admin_runtime` 最小授予套餐读写、订单只读、易支付配置读取及保存函数执行权限,并为启用 RLS 的支付表补齐角色策略;不授予订单写入或删除权限。Gitea 与 GitHub 的 staging 迁移、部署和测试环境运维工作流统一 checkout `staging`,删除 staging SHA 属于 `main` 历史的要求;生产工作流保持不变。误合入 main 的 PR #1 已由 PR #2 的 revert 恢复,恢复后 main 内容树与合并前提交 `43581ac0f75e7f157032503475e878bd53ad161d` 完全一致。针对 run 1309,Gitea 两条远端工作流的 previous-SHA 探测与 registry login/logout 保持 `sudo -n docker`;脚本只接受受控的 `docker` 或 `sudo -n docker` 数组分支并拒绝其他值,不使用 `eval`。run 1313 证明 sudo Docker login 已成功,但 `run-staging-migration.sh` 第 37 行无法写入 root-owned 部署树下的 `/opt/jyotisha-staging/.state/mutation.lock`。因此迁移与部署工作流改为通过 `sudo -n env` 传入受控环境并以 root 启动整个脚本,脚本内固定 `DOCKER_BIN=docker`,`DOCKER_CONFIG` 仍指向 incoming 的 `.docker`;cleanup 使用 `sudo -n rm -rf` 删除脚本可能创建的 root-owned incoming 内容,Docker logout 仍使用 sudo。 +- 验证:`frontend/tests/admin-contracts.test.ts` 锁定支付、套餐资源顺序;`frontend/tests/admin-payments-contract.test.ts` 锁定本地参数化 SQL、`identity.users` 联表、套餐 SQL CRUD/UUID/404、独立套餐页面、默认折叠和后台专用滚动容器;`frontend/tests/epay-settings.test.ts` 锁定 `chatEnabled` 回显、`queryAdminRows` 读取、参数化 `admin_save_epay_settings`、不依赖 Supabase builder/RPC、默认折叠和不泄露 key。2026-07-29 运行三份契约测试共 27 项全部通过;ESLint、TypeScript 与 `git diff --check` 结果记录在本次交付报告。2026-07-30 线上健康响应证明部署 SHA 为 `dd8e2ad9c7e76d0152b4563c43a45b1e26137035` 且本地业务库、身份库均健康;静态权限审计确认支付迁移缺少 `admin_runtime` grant/RLS。新增权限迁移契约后,支付、套餐、配置三组 21 项回归全部通过。首次独立 staging 迁移 run 1300 在镜像校验阶段暴露 `docker manifest inspect --verbose` 对 ACR 返回单元素数组,而解析器只接受对象,触发 `AttributeError: 'list' object has no attribute 'get'`;Gitea staging 迁移与部署已兼容单平台数组并增加聚焦契约测试。run 1309 进一步确认远端 `deploy` 用户对 `/var/run/docker.sock` 无权限;run 1313 的 sudo Docker login 已成功,随后在迁移脚本第 37 行因 deploy 用户不能写 root-owned `/opt/jyotisha-staging/.state/mutation.lock` 而终止,证明仅提升 Docker 命令不足以覆盖部署树写入。工作流回归现锁定整个脚本由 `sudo -n env` 启动、脚本内 `DOCKER_BIN=docker`、incoming Docker 配置不变、root-owned cleanup 使用 sudo,并继续保留 runner 对两种固定 Docker 命令形式的契约。staging 最终部署 SHA 为 `1f44892a2cf210797e7dc74f49721a8f10c8849d`;迁移台账确认 `20260730010000_admin_payment_permissions.sql` 于 2026-07-30 05:58:38 UTC 应用,数据库 ACL/RLS 与 `admin_save_epay_settings` 的 `admin_runtime` 执行权限均已生效,web/api/postgres 容器健康,三个未登录管理 API 正确返回 401,部署后日志无相关 500。支付、套餐与易支付配置 21 项针对性回归通过,管理员随后确认 `/admin/payments` 与 `/admin/packages` 已恢复。 +- 防复发:self-hosted staging 后台查询不得依赖 LocalPostgresDataClient 未实现的 Supabase builder、RPC 或 Admin Auth 能力;支付与套餐必须保持独立资源顺序。套餐与易支付配置契约必须显式拒绝 Supabase builder/RPC 并锁定参数化 SQL、404、原子函数写入和安全错误响应;支付配置必须默认折叠,后台必须拥有独立滚动容器且不得放宽聊天的全局 `overflow:hidden`。易支付配置读写测试必须同时覆盖数据库列和公开字段;创建订单只生成经公网 SSRF 校验的签名收银台 URL,商户密钥只能参与服务端签名,不得进入 URL、响应、日志或审计。对话支付默认关闭,UI 与创建订单 API 必须共享服务端开关;可用性测试不得提交伪订单或返回 URL、PID、密钥、headers/body。 - 相关记录:BUG-087、BUG-092 -- 修复版本:`rectification-v5-matrix-scoring-1`(仅修复输入规范化,算法版本不变) - -## BUG-094 | V5 Agent 消息操作栏在 V4 面板切换后消失 - -- 状态:resolved -- 首次发现:2026-07-29 -- 最近更新:2026-07-29 -- 影响面:生时校正 V5 Agent 对话、助手消息反馈与当前问题重新生成 -- 用户现象:助手消息气泡下不再显示点赞、点踩、复制和重新生成操作。 -- 触发条件:生时校正页面使用 `RectificationV4Panel` 渲染 V5 Agent 会话。 -- 根因:旧对话组件中的消息操作栏没有迁入 V4/V5 共用面板,同时新 V4 API 没有与当前语义问题绑定的重新生成命令。 -- 修复:复用现有消息操作栏样式,仅为 `v5_agent` 的稳定助手消息恢复操作;重新生成只重写当前已验证 Semantic Question Opportunity 的自然语言实现,并通过用户、Case 版本、当前目标和 action ID 原子校验,不重跑事件提取、候选评分、诊断或 Job。 -- 验证:组件资格与反馈互斥测试、V4 service 幂等重放与数据不变量测试、Renderer 安全回落测试、迁移契约测试,以及 staging 精确 SHA 部署和浏览器验收。 -- 防复发:测试锁定 V5 Agent 操作栏、仅当前问题可重跑、legacy/shadow 不启用新 Renderer、重跑不改变 turns/events/snapshots/profile 且 completed Job 不被改写。 -- 相关记录:BUG-090、BUG-091、BUG-092、BUG-093 -- 复发自:无 -- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1` - - -## BUG-095 | 生时校正“思考中”无法证明实际执行步骤且刷新后不可溯源 - -- 状态:resolved -- 首次发现:2026-07-29 -- 最近更新:2026-07-29 -- 影响面:V4/V5 生时校正运行状态、历史助手消息、候选计算与诊断的测试可观测性 -- 用户现象:页面只显示“正在核对星盘信息……”动画,无法判断本轮实际执行了哪些阶段、工具和技法;任务结束或刷新后也无法回看。 -- 根因:Job 只保存一个会被后续步骤覆盖的当前 `phase`,不能还原阶段历史;已持久化的 Public Message 与 Agent Run 工具记录没有被 Case API 投影到对应 Turn;UI 的 thinking 状态只是客户端进度动画,不是模型 reasoning,也不是服务端执行收据。 -- 修复:将“分析过程”定义为与 Turn 关联、可持久化和刷新后可恢复的服务端执行收据;只投影实际发生的阶段、工具调用和 allowlist 技法。供应商显式返回的 reasoning 内容只有通过服务端来源校验与安全过滤后才可作为可选摘要,缺失或不安全时直接省略,不伪造且不读取 hidden chain-of-thought。 -- 安全边界:不公开分数、权重、贡献矩阵、内部 ID/字段、候选分钟、工具参数或原始结果、Prompt、模型内部信息和用户敏感原文;D60 不展示且不驱动结论;未执行、不可用或仅供参考的技法不得显示为已执行。 -- 兼容边界:历史无收据记录继续读取;`v4_legacy` 与 `v5_shadow` 保持原有用户可见回复,shadow 仅持久化新产物而不展示分析轨迹;completed-job replay、原子 completion、`canConfirmExactMinute === false` 和禁止自动写入 `profiles.active_birth_time` 保持不变。 -- 防复发:API 与组件测试必须锁定 Turn 关联、刷新恢复、运行中真实 phase、仅展示实际工具/技法、无安全 reasoning 时不补写摘要,以及旧记录、legacy、shadow 的兼容行为。 -- 相关记录:BUG-011、BUG-086、BUG-087、BUG-094 -- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1` - -## BUG-096 | PostgreSQL 日期对象导致生时纠正 Case 创建误报资料格式错误 - -- 状态:resolved -- 首次发现:2026-07-29 -- 最近更新:2026-07-29 -- 影响面:staging 自托管 PostgreSQL 出生资料读取、历史时区偏移补全与生时纠正 Case 创建 -- 用户现象:已完成出生资料的账号启动生时纠正时返回 400,并显示“出生资料或提交内容格式不正确”。 -- 触发条件:数据库驱动将 `birth_date` 返回为 JavaScript `Date`,且历史资料的 `timezone_offset` 为空、需要根据日期和时区 ID 补全。 -- 根因:共享 `resolveMissingBirthTimezoneOffset()` 只把非空字符串识别为出生日期;合法 `Date` 被当成缺失值,补全提前返回,随后严格出生资料解析以 `Historical timezone offset must be resolved before parsing` 拒绝请求。 -- 修复:共享时区补全 resolver 在读取出生日期时同时接受有效 `Date` 与既有字符串,并统一归一化为 `YYYY-MM-DD`;不放宽后续 Zod 校验,也不改变生时纠正计算规格。 -- 验证:路由回归夹具改为 PostgreSQL 实际返回形态的 `Date`;聚焦测试、前端全量测试、lint、TypeScript、生产构建和 Python V5 服务测试通过,staging 精确 SHA smoke 随本次发布执行。 -- 防复发:所有从 PostgreSQL 进入纯日期字符串合同的共享边界必须先覆盖 `string | Date` 驱动返回类型;Case 创建继续要求历史时区偏移在严格解析前完成。 -- 相关记录:BUG-076、BUG-091、BUG-095 -- 复发自:无 -- 修复版本:待本次 staging 修复提交与部署验收 - -## BUG-097 | 生时纠正分析状态停滞且教育事件被换词重问为迁居 - -- 状态:resolved -- 首次发现:2026-07-29 -- 最近更新:2026-07-29 -- 影响面:V5 Agent 生时纠正运行状态、历史消息布局、Semantic Question Opportunity 排序与 Renderer 安全回退 -- 用户现象:任务实际已经经过“生成语义问题机会、选择下一步动作、生成安全回复”,页面运行中却一直显示“正在整理你刚才提到的经历”;完成后的“分析过程”显示在 Agent 正文下方;用户已经说明离家去外地上大学的年月后,下一问仍把同一经历换词问成搬到新城市或长期离乡。 -- 触发条件:Job 轮询返回新的 `phase`,但聊天消息继续读取首次 Case 响应中的旧 `data.job`;同时教育事件原文中的“离家、外地”命中迁居领域关键词并抬高 relocation 机会,Renderer 失败回退后直接使用带“以当前事件为时间参照”的模板。 -- 根因:Hook 同时维护 `data.job` 与独立 Job state,轮询只更新后者;恢复 processing Case 时 API 没有返回 active Job,刷新后无法继续轮询;`setInterval` 允许并发请求,较旧响应可能覆盖较新 phase;机会排序把零散地点词当成迁居主题信号,没有优先采用最新账本事件的已确认领域;迁居 fallback 和问题验证器没有禁止把当前事件改写成另一件事件继续追问。 -- 修复:轮询结果原子合并回 `data.job`,消息状态直接跟随服务端 `extracting_evidence / planning_question / reasoning / rendering`;Case Store 和 API 为 processing Case 恢复最新 pending/processing Job;轮询改为单飞 `setTimeout` 并拒绝较旧响应;把持久化分析收据移动到 Agent 正文上方并保留正文下方操作栏;迁居关键词收窄为明确搬迁动作,最新事件主题加权以账本领域为准;所有新事件 fallback 明确询问另一件独立事件,Renderer 拒绝旧跨事件模板、同义重复和与所选机会领域不匹配的问题。 -- 验证:组件测试锁定分析过程、正文、操作栏顺序、连续 Job phase 更新及乱序响应不倒退;服务测试锁定 processing 刷新恢复 active Job 和 shadow 年精度 legacy 细化;Agent 测试锁定外地上大学不会提升 relocation、事件数组顺序不改变排序、旧模板与跨领域实现触发安全 fallback;完整前端测试、lint、TypeScript、staging 构建、Python V5 服务测试和 staging 精确 SHA smoke 随本次发布执行。 -- 防复发:UI 只允许一个 Job 真源,恢复 processing Case 必须携带 active Job,轮询必须单飞且单调更新;新事件机会不得把 anchor 当作待细化目标;主题信号优先来自已验证事件领域,地点词不能单独代表迁居;Renderer 必须同时验证“另一件事件”、所选领域语义与安全边界。 -- 相关记录:BUG-090、BUG-093、BUG-094、BUG-095 -- 复发自:BUG-093、BUG-095 -- 修复版本:待本次 staging 修复提交与部署验收 - -## BUG-098 | V6 公开候选门禁未明确 LODO、技法层级与独立 holdout 边界 - -- 状态:resolved/partial -- 首次发现:2026-07-30 -- 最近更新:2026-07-30 -- 影响面:V6 Python 候选扫描、Candidate Snapshot 公开范围门禁、生时纠正 Skill 与参考 Skill 能力声明 -- 用户现象:现有说明容易把“支持某技法”、同一 Case 内的留一诊断和参考 Skill 方法论误读为已完成独立验证;缺失 `KP_cusps` 或 D60 也可能被错误当成公开范围的统一硬阻塞。 -- 根因:文档没有把真实实现链、LODO public gate、active-domain required/optional/reference-only 技法策略和参考 Skill 的未实现能力分开;LOEO/LODO 使用同一事件贡献矩阵做事后减项,不能替代 prospective independent holdout。 -- 修复:记录 V6 的真实链路为跨午夜兼容的逐分钟 Python 扫描、事件贡献矩阵、Snapshot、LOEO/LODO、date sensitivity、neighbor stability 与 candidate split;公开范围新增 LODO 稳定性门禁,并按活跃可评分领域判定 required layer,`KP_cusps` 为 optional、D60 为 reference-only、未知层失败关闭;事件 provenance 仅用于审计且不参与加权。 -- 验证:本工作树的聚焦合同覆盖跨午夜分钟枚举、LODO 低于 `0.8` 拒绝公开范围、required layer 缺失阻塞、`KP_cusps`/D60 缺失不阻塞,以及旧 Snapshot 兼容;本记录不把这些回归误写成独立 holdout 验证。 -- Partial / deferred:per-Case independent holdout 因缺少 prospective sticky partition 与 calibration contract 延期。当前 LOEO/LODO 只证明同一 Case 内的敏感性,不得伪称 prospective、independent 或 calibrated validation 已完成。 -- 安全边界:继续禁止手工 `supports/conflicts` 伪评分、任意外部仓动态加载、唯一分钟结论和自动写入 `profiles.active_birth_time`;参考 Skill 只作方法与审计参照,不成为第二评分真源。 -- 防复发:公开候选必须同时通过事件/领域覆盖、范围宽度、邻近分钟、LOEO、LODO、日期敏感性、计算规格和 required-technique 门禁;任何 holdout 完成声明必须先有稳定分区持久化与校准验收证据。 -- 相关记录:BUG-082、BUG-090、BUG-093、BUG-095 -- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1`,独立 holdout 延期 - -## BUG-099 | 新事件追问可能预设事件存在、换词重复且 Renderer 回退不可追溯 - -- 状态:resolved -- 首次发现:2026-07-30 -- 最近更新:2026-07-30 -- 影响面:Semantic Question Opportunity 生成与排序、跨领域事件去重、Renderer 问题校验、持久化分析历史 -- 用户现象:新事件问题可能直接询问“哪次”经历而暗示该事件必然发生;提示缺少便于回忆但不限定答案的具体线索;与最新事件语义相同的内容可能换一个领域名称再次追问;Renderer 模型问题被接受、被拒绝或改由服务器 fallback 后,历史分析收据无法明确区分实际路径。 -- 触发条件:生成 `ask_new_event` 时仅依赖领域模板或缺失领域;跨领域候选与最新事件共享同一人物、行动或生活转折但没有语义重叠降权;Renderer 不可用、调用失败或问题未通过校验而进入 deterministic fallback。 -- 根因:问题政策尚未明确存在性询问、非穷举回忆线索数量和禁止虚构年龄/日期窗口;utility 合同没有钉死跨领域语义重叠 penalty;分析历史没有要求持久化 Renderer 校验结果与服务器 fallback 来源。 -- 修复要求:所有新事件问题先询问相关经历是否存在,不得预设发生;提供 2–5 个明确标注为示例而非穷举的回忆线索,并允许用户回答其他经历或表示没有;不得发明年龄、人生阶段或日期窗口。若候选与最新事件跨领域但语义重叠,必须在排序前降低 utility,足以判定为同一事件换词时不得再次提问。每次 Renderer 尝试必须在分析历史中留下安全的分类收据,区分模型问题通过校验、模型问题被拒绝,以及服务器 deterministic fallback,并记录不含原始 Prompt 或用户敏感文本的原因类别。 -- 验证:`rectification-agent-v6.test.ts` 覆盖存在性问题、具体回忆线索、退出方式、禁止虚构年龄/日期窗口和跨领域同事件降权;`rectification-analysis-trace.test.ts` 覆盖持久化分析历史中的模型安全校验与服务器 fallback 分类;完整前端测试 1123/1123 通过。 -- 安全边界:分析历史只记录阶段、校验结果、fallback 布尔值或安全原因类别,不记录模型 Prompt、原始候选文本、隐藏推理、内部评分、事件原文或出生资料。 -- 防复发:新事件问题和 Renderer 分析收据必须作为服务端合同测试,而不是只测试最终展示文案;领域名称不同不得绕过同一事件的语义去重。 -- 相关记录:BUG-087、BUG-095、BUG-097 -- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1` +- 复发自:BUG-093 +- 修复版本:`d44a414`(权限迁移),staging 部署 `1f44892a2cf210797e7dc74f49721a8f10c8849d` diff --git a/docs/operations/self-hosted-identity.md b/docs/operations/self-hosted-identity.md index 2e7009cb..0b1e634d 100644 --- a/docs/operations/self-hosted-identity.md +++ b/docs/operations/self-hosted-identity.md @@ -4,29 +4,27 @@ Staging uses Better Auth and the private local PostgreSQL cluster for both ident ## Staging mode -Keep these two values exactly as shown: +Keep these values exactly as shown: ```dotenv AUTH_PROVIDER=self-hosted SELF_HOSTED_IDENTITY_ENABLED=true +AUTH_USER_ORIGIN=https://staging.jyotisha.chat ``` -This makes both login hosts use isolated Better Auth surfaces. Public and admin sessions have different secrets and host-only cookie prefixes. Server routes translate the Better Auth session into PostgreSQL request claims and use the reviewed existing RLS/RPC business contract. The browser uses only same-origin APIs and does not need Supabase configuration. +Staging has one browser identity surface on the main site. The same Better Auth user session serves ordinary pages and `/admin`; there is no independent admin origin, secret, cookie, or login host. Server routes translate that session into PostgreSQL request claims. Admin authorization then reads the persisted `identity.users.role` value and permits only `admin`; `viewer` and ordinary users receive `403`. The main auth route continues to return `404` for Better Auth `/api/auth/admin` plugin endpoints, and unknown hosts fail closed with `421`. Use [the tracked staging identity example](../../deploy/.env.staging.identity.example) as a list of names only. Replace bracketed values directly on the server and keep `/opt/jyotisha-staging/.env.staging` owned by `deploy` with mode `0600`. -Generate separate secrets locally on the server: +Generate `BETTER_AUTH_USER_SECRET` locally on the server: ```bash openssl rand -base64 32 -openssl rand -base64 32 ``` -Do not reuse either value as a PostgreSQL password. `IDENTITY_DATABASE_URL`, `APP_DATABASE_URL`, and `ADMIN_DATABASE_URL` use their matching passwords from `.env.staging.database`, percent-encoded only in each URL password component. All three must point to the private Compose hostname `postgres:5432/jyotisha`; never publish PostgreSQL on a host port. +Do not reuse it as a PostgreSQL password. `IDENTITY_DATABASE_URL`, `APP_DATABASE_URL`, and `ADMIN_DATABASE_URL` use their matching passwords from `.env.staging.database`, percent-encoded only in each URL password component. All three must point to the private Compose hostname `postgres:5432/jyotisha`; never publish PostgreSQL on a host port. -The Resend key must be staging-only. `RESEND_FROM_EMAIL` must use a sender/domain verified in Resend. CI never receives this key and uses an in-memory sender. - -Set `ADMIN_EMAILS` to the staging administrator allowlist. Generate an independent `JYOTISH_DYNAMIC_RECTIFICATION_TOKEN` and place the same value in the shared application env consumed by the web and private API containers; do not reuse a database or Better Auth secret. +The Resend key must be staging-only. `RESEND_FROM_EMAIL` must use a sender/domain verified in Resend. CI never receives this key and uses an in-memory sender. `ADMIN_EMAILS` remains relevant only to the legacy Supabase production path; it is not self-hosted admin authorization. Validate without printing values: @@ -38,17 +36,17 @@ bash deploy/validate-staging-env.sh .env.staging ## Migration and smoke checks -Apply the reviewed PostgreSQL migrations through the existing `Migrate Staging Database` workflow before deploying the web image. The workflow first ensures the compatibility roles exist, then applies the identity schema, the local `auth` compatibility layer, and all reviewed business migrations under the migration ledger. Better Auth users are transactionally projected into `auth.users`, which creates their business profile through the existing trigger. +Apply the reviewed PostgreSQL migrations through the existing `Migrate Staging Database` workflow before deploying the web image. Better Auth users are transactionally projected into `auth.users`, which creates their business profile through the existing trigger. -After deployment: +After deployment, verify the single-domain contract: ```bash -curl -fsS https://admin.staging.jyotisha.chat/login >/dev/null -curl -fsS https://admin.staging.jyotisha.chat/api/auth/get-session -test "$(curl -sS -o /dev/null -w '%{http_code}' https://staging.jyotisha.chat/admin/codes)" = 404 +curl -fsS https://staging.jyotisha.chat/login >/dev/null +test "$(curl -sS -o /dev/null -w '%{http_code}' https://staging.jyotisha.chat/api/admin/session)" = 401 +test "$(curl -sS -o /dev/null -w '%{http_code}' https://staging.jyotisha.chat/api/account)" = 401 ``` -The admin root redirects to `/admin/codes`; the public host rejects `/admin` and `/api/admin` paths. An unknown or unpromoted email cannot create an admin session. Promote an imported staging user only through a reviewed database/admin operation; the persisted `identity.users.role` value must include `admin` before the admin OTP flow can issue a cookie. +An anonymous `/admin` request redirects to `/login`. An authenticated non-admin, including a persisted `viewer`, must not render the admin layout and every `/api/admin/*` route must independently return `403`. Promote a staging user only through a reviewed database operation; the persisted role must include `admin` before the main-site session can enter the backend. ## Optional import rehearsal @@ -75,8 +73,8 @@ Reruns are idempotent by UUID and the whole import is transactional. Duplicate c ## Rollback and rotation -An application rollback must use a previously validated staging image and does not reverse database migrations. Existing self-hosted sessions and data remain in PostgreSQL; do not delete identity or business rows during application rollback. Returning staging to Supabase would require a separate reviewed data-reconciliation and provider-switch change, not an environment-only toggle. +An application rollback must use a previously validated staging image and does not reverse database migrations. Existing self-hosted sessions and data remain in PostgreSQL; do not delete identity or business rows during application rollback. Returning staging to Supabase requires a separate reviewed data-reconciliation and provider-switch change. -Rotating either Better Auth secret invalidates only that surface's existing sessions. Rotate user and admin secrets separately, restart the web service, and verify the corresponding host. Rotate a leaked Resend key in Resend first, replace the server value, then restart. Never print the old or new values. +Rotating `BETTER_AUTH_USER_SECRET` invalidates all self-hosted browser sessions, including admins. Restart the web service and verify login, anonymous admin API rejection, admin access, and viewer rejection. Rotate a leaked Resend key in Resend first, replace the server value, then restart. Never print old or new values. Production `AUTH_PROVIDER=self-hosted` remains blocked until data reconciliation passes, production backups and restore drills exist, operational monitoring is ready, and a separate reviewed production cutover plan is approved. diff --git a/docs/superpowers/plans/2026-07-21-self-hosted-identity.md b/docs/superpowers/plans/2026-07-21-self-hosted-identity.md index aaa37b31..53c61c26 100644 --- a/docs/superpowers/plans/2026-07-21-self-hosted-identity.md +++ b/docs/superpowers/plans/2026-07-21-self-hosted-identity.md @@ -1,3 +1,5 @@ +> Superseded 2026-07-29: staging browser identity and admin access now use one main-site Better Auth user session; the dual-domain admin surface in this historical plan is inactive. + # Self-Hosted Identity Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. diff --git a/docs/superpowers/specs/2026-07-20-supabase-exit-backend-design.md b/docs/superpowers/specs/2026-07-20-supabase-exit-backend-design.md index 1484b232..cb86551c 100644 --- a/docs/superpowers/specs/2026-07-20-supabase-exit-backend-design.md +++ b/docs/superpowers/specs/2026-07-20-supabase-exit-backend-design.md @@ -1,3 +1,5 @@ +> Superseded 2026-07-29: staging browser identity and admin access now use one main-site Better Auth user session; the dual-domain admin surface in this historical specification is inactive. + # Jyotisha Supabase Exit and Self-Hosted Backend Design Date: 2026-07-20 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0adbd951..da1047c3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -28,6 +28,7 @@ "react-dom": "19.2.4", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "server-only": "^0.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.2", @@ -10464,6 +10465,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4811a6ca..9f1c2ea1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,6 +42,7 @@ "react-dom": "19.2.4", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "server-only": "^0.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.2", diff --git a/frontend/scripts/staging-image-manifest.mjs b/frontend/scripts/staging-image-manifest.mjs index bb27f13d..767c5644 100644 --- a/frontend/scripts/staging-image-manifest.mjs +++ b/frontend/scripts/staging-image-manifest.mjs @@ -5,8 +5,14 @@ import { pathToFileURL } from "node:url"; const shaPattern = /^[0-9a-f]{40}$/; const digestPattern = /^sha256:[0-9a-f]{64}$/; const expectedKeys = ["git_sha", "api_digest", "web_digest"]; +const defaultRegistry = "ghcr.io/jesse-ux"; +const acrRepository = "crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com/copse/jyotisha"; +const registryPattern = /^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?)(?::[1-9][0-9]{0,4})?(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*$/; -export function parseStagingImageManifest(text, expectedSha) { +export function parseStagingImageManifest(text, expectedSha, registry = defaultRegistry) { + if (!registryPattern.test(registry)) { + throw new Error("invalid staging image registry"); + } if (!shaPattern.test(expectedSha)) { throw new Error("invalid expected staging revision"); } @@ -37,12 +43,13 @@ export function parseStagingImageManifest(text, expectedSha) { } } + const sharedRepository = registry === acrRepository; return { gitSha: expectedSha, apiDigest: values.get("api_digest"), webDigest: values.get("web_digest"), - apiImage: `ghcr.io/jesse-ux/jyotisha-api@${values.get("api_digest")}`, - webImage: `ghcr.io/jesse-ux/jyotisha-web@${values.get("web_digest")}`, + apiImage: `${sharedRepository ? registry : `${registry}/jyotisha-api`}@${values.get("api_digest")}`, + webImage: `${sharedRepository ? registry : `${registry}/jyotisha-web`}@${values.get("web_digest")}`, }; } @@ -52,13 +59,14 @@ const invokedPath = process.argv[1] if (invokedPath === import.meta.url) { try { - const [manifestPath, expectedSha] = process.argv.slice(2); + const [manifestPath, expectedSha, registry] = process.argv.slice(2); if (!manifestPath || !expectedSha) { throw new Error("manifest path and expected revision are required"); } const manifest = parseStagingImageManifest( await readFile(manifestPath, "utf8"), expectedSha, + registry, ); process.stdout.write( [ diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx index b331ca7b..c7e13d4b 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -1,11 +1,21 @@ import "@refinedev/antd/dist/reset.css"; import "antd/dist/reset.css"; import type { ReactNode } from "react"; +import { redirect } from "next/navigation"; import { AdminApp } from "@/components/admin/admin-app"; +import { AdminAuthorizationError, requireAdminSession } from "@/lib/admin/auth"; export const dynamic = "force-dynamic"; -export default function AdminLayout({ children }: { children: ReactNode }) { +export default async function AdminLayout({ children }: { children: ReactNode }) { + try { + await requireAdminSession("read"); + } catch (error) { + if (error instanceof AdminAuthorizationError) { + redirect(error.status === 401 ? "/login" : "/"); + } + throw error; + } 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..2ab9a048 --- /dev/null +++ b/frontend/src/app/admin/packages/page.tsx @@ -0,0 +1,5 @@ +import { PackageManagement } from "@/components/admin/package-management"; + +export default function AdminPackagesPage() { + return ; +} diff --git a/frontend/src/app/admin/payments/page.tsx b/frontend/src/app/admin/payments/page.tsx new file mode 100644 index 00000000..bf3b1f10 --- /dev/null +++ b/frontend/src/app/admin/payments/page.tsx @@ -0,0 +1,5 @@ +import PaymentManagement from "@/components/admin/payment-management"; + +export default function AdminPaymentsPage() { + return ; +} diff --git a/frontend/src/app/admin/route.ts b/frontend/src/app/admin/route.ts index 43b0bd97..cc04f81d 100644 --- a/frontend/src/app/admin/route.ts +++ b/frontend/src/app/admin/route.ts @@ -1,6 +1,19 @@ -export function GET() { - return new Response(null, { - status: 307, - headers: { location: "/admin/codes" }, - }); +import { AdminAuthorizationError, requireAdminSession } from "@/lib/admin/auth"; + +export async function GET() { + try { + await requireAdminSession("read"); + return new Response(null, { + status: 307, + headers: { location: "/admin/codes" }, + }); + } catch (error) { + if (error instanceof AdminAuthorizationError) { + return new Response(null, { + status: 307, + headers: { location: error.status === 401 ? "/login" : "/" }, + }); + } + throw error; + } } 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 552f3c49..d40a93e1 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"; @@ -109,11 +109,14 @@ export async function GET() { profile, Array.isArray(rectificationCaseRows) ? rectificationCaseRows : [], ); + const isAdmin = await isAdminUser(user); + const adminUrl = isAdmin ? "/admin/codes" : null; return NextResponse.json({ user: { id: user.id, email: user.email ?? null }, credits: profile.credits, - isAdmin: isAdminEmail(user.email), + isAdmin, + adminUrl, rectificationPriceCredits, hasConfirmedBirthTime: profile.birth_time_status === "confirmed" && typeof profile.active_birth_time === "string", diff --git a/frontend/src/app/api/admin/epay-settings/route.ts b/frontend/src/app/api/admin/epay-settings/route.ts new file mode 100644 index 00000000..50cdd7b5 --- /dev/null +++ b/frontend/src/app/api/admin/epay-settings/route.ts @@ -0,0 +1,132 @@ +import crypto from "node:crypto"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requireAdminSession } from "@/lib/admin/auth"; +import { isPostgresError, queryAdminRows } from "@/lib/admin/database"; +import { adminErrorResponse } from "@/lib/admin/http"; +import { suggestedEpayUrls } from "@/lib/epay/config"; +import { encryptEpayKey } from "@/lib/epay/encryption"; + +export const runtime = "nodejs"; + +const httpUrl = z.string().trim().min(1).max(2048).url().refine((value) => /^https?:\/\//i.test(value), "必须使用 HTTP(S)"); +const settingsSchema = z.object({ + gatewayUrl: httpUrl, + pid: z.string().trim().min(1).max(200), + notifyUrl: httpUrl, + returnUrl: httpUrl, + siteName: z.string().trim().min(1).max(100), + chatEnabled: z.boolean(), + newKey: z.string().min(1).max(1000).optional(), +}).strict(); + +type SettingsRow = { + gateway_url: string; + pid: string; + encrypted_key: string; + notify_url: string; + return_url: string; + site_name: string; + chat_enabled: boolean; + updated_at?: Date; +}; + +function publicSettings(row: SettingsRow, source: "database" | "environment") { + return { + gatewayUrl: row.gateway_url, + pid: row.pid, + notifyUrl: row.notify_url, + returnUrl: row.return_url, + siteName: row.site_name, + chatEnabled: row.chat_enabled, + keyConfigured: Boolean(row.encrypted_key), + complete: Boolean(row.gateway_url && row.pid && row.encrypted_key && row.notify_url && row.return_url && row.site_name), + source, + updatedAt: source === "database" ? row.updated_at?.toISOString() ?? null : null, + }; +} + +function environmentSettings() { + const defaults = suggestedEpayUrls(); + const row: SettingsRow = { + gateway_url: process.env.EPAY_GATEWAY_URL?.trim() || "", + pid: process.env.EPAY_PID?.trim() || "", + encrypted_key: process.env.EPAY_KEY?.trim() ? "configured" : "", + notify_url: process.env.EPAY_NOTIFY_URL?.trim() || defaults.notifyUrl, + return_url: process.env.EPAY_RETURN_URL?.trim() || defaults.returnUrl, + site_name: process.env.EPAY_SITE_NAME?.trim() || "Jyotisha", + chat_enabled: ["true", "1"].includes(process.env.EPAY_CHAT_ENABLED?.trim().toLowerCase() || ""), + }; + return publicSettings(row, "environment"); +} + +async function databaseRow() { + try { + const rows = await queryAdminRows(` + select gateway_url, pid, encrypted_key, notify_url, return_url, site_name, chat_enabled, updated_at + from public.epay_settings + where id = true + limit 1 + `); + return rows[0] ?? null; + } catch (error) { + if (isPostgresError(error) && error.code === "42P01") return null; + throw error; + } +} + +export async function GET() { + try { + await requireAdminSession("read"); + const row = await databaseRow(); + if (row) return NextResponse.json(publicSettings(row, "database")); + const settings = environmentSettings(); + return NextResponse.json(settings.complete || settings.keyConfigured + ? settings + : { ...settings, source: "unconfigured" }); + } catch (error) { + return adminErrorResponse(error); + } +} + +export async function PUT(request: Request) { + try { + const session = await requireAdminSession("write"); + const parsed = settingsSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "易支付配置参数不正确" }, { status: 400 }); + + const existing = await databaseRow(); + if (!existing && !parsed.data.newKey) { + return NextResponse.json({ error: "首次保存数据库配置时必须输入新的商户密钥" }, { status: 400 }); + } + const encryptedKey = parsed.data.newKey + ? encryptEpayKey(parsed.data.newKey) + : existing!.encrypted_key; + try { + const rows = await queryAdminRows(` + select * from public.admin_save_epay_settings( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 + ) + `, [ + session.user.id, + session.user.email, + session.role, + crypto.randomUUID(), + parsed.data.gatewayUrl.replace(/\/+$/, ""), + parsed.data.pid, + encryptedKey, + parsed.data.notifyUrl, + parsed.data.returnUrl, + parsed.data.siteName, + parsed.data.chatEnabled, + Boolean(parsed.data.newKey), + ]); + if (!rows[0]) return NextResponse.json({ error: "保存易支付配置失败" }, { status: 500 }); + return NextResponse.json(publicSettings(rows[0], "database")); + } catch { + return NextResponse.json({ error: "保存易支付配置失败" }, { status: 500 }); + } + } catch (error) { + return adminErrorResponse(error); + } +} diff --git a/frontend/src/app/api/admin/epay-settings/test/route.ts b/frontend/src/app/api/admin/epay-settings/test/route.ts new file mode 100644 index 00000000..de157d34 --- /dev/null +++ b/frontend/src/app/api/admin/epay-settings/test/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server"; +import { AdminAuthorizationError, requireAdminSession } from "@/lib/admin/auth"; +import { adminErrorResponse } from "@/lib/admin/http"; +import { epaySubmitUrl, readEpayConfig } from "@/lib/epay/config"; +import { assertPublicGatewayUrl } from "@/lib/epay/gateway-policy"; + +export const runtime = "nodejs"; + +function reachableStatus(status: number) { + return status >= 200 && status < 500; +} + +export async function POST() { + try { + await requireAdminSession("write"); + const config = await readEpayConfig(); + const submitUrl = epaySubmitUrl(config.gatewayUrl); + await assertPublicGatewayUrl(submitUrl); + const startedAt = performance.now(); + let response = await fetch(submitUrl, { + method: "HEAD", + redirect: "manual", + signal: AbortSignal.timeout(8_000), + }); + if (response.status === 405 || response.status === 501) { + response = await fetch(submitUrl, { + method: "GET", + redirect: "manual", + signal: AbortSignal.timeout(8_000), + }); + } + const available = reachableStatus(response.status); + return NextResponse.json({ + available, + message: available ? "当前已保存的易支付配置可访问" : "当前已保存的易支付配置暂不可用", + latencyMs: Math.round(performance.now() - startedAt), + status: response.status, + }); + } catch (error) { + if (error instanceof AdminAuthorizationError) return adminErrorResponse(error); + return NextResponse.json({ + available: false, + message: "当前已保存的易支付配置暂不可用", + }); + } +} 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..6601686d --- /dev/null +++ b/frontend/src/app/api/admin/packages/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requireAdminSession } from "@/lib/admin/auth"; +import { queryAdminRows } from "@/lib/admin/database"; +import { adminErrorResponse } from "@/lib/admin/http"; + +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(), +}).strict(); +const updateSchema = schema.extend({ id: z.string().uuid() }); +const idSchema = z.object({ id: z.string().uuid() }).strict(); + +type PackageRow = { + id: string; + name: string; + description: string; + price_cents: number; + credits: number; + sort_order: number; + enabled: boolean; + created_at: Date; + updated_at: Date; +}; + +function output(row: PackageRow) { + 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.toISOString(), + updatedAt: row.updated_at.toISOString(), + }; +} + +export async function GET() { + try { + await requireAdminSession("read"); + const rows = await queryAdminRows(` + select id, name, description, price_cents, credits, sort_order, enabled, created_at, updated_at + from public.payment_packages + order by sort_order, created_at + `); + return NextResponse.json({ packages: rows.map(output) }); + } catch (error) { + return adminErrorResponse(error); + } +} + +export async function POST(request: Request) { + try { + const auth = await requireAdminSession("write"); + const parsed = schema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 }); + const p = parsed.data; + const rows = await queryAdminRows(` + insert into public.payment_packages + (name, description, price_cents, credits, sort_order, enabled, created_by) + values ($1, $2, $3, $4, $5, $6, $7) + returning id, name, description, price_cents, credits, sort_order, enabled, created_at, updated_at + `, [p.name, p.description, p.priceCents, p.credits, p.sortOrder, p.enabled, auth.user.id]); + return NextResponse.json({ package: output(rows[0]) }, { status: 201 }); + } catch (error) { + return adminErrorResponse(error); + } +} + +export async function PATCH(request: Request) { + try { + await requireAdminSession("write"); + const parsed = updateSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 }); + const p = parsed.data; + const rows = await queryAdminRows(` + update public.payment_packages + set name = $2, description = $3, price_cents = $4, credits = $5, + sort_order = $6, enabled = $7, updated_at = clock_timestamp() + where id = $1 + returning id, name, description, price_cents, credits, sort_order, enabled, created_at, updated_at + `, [p.id, p.name, p.description, p.priceCents, p.credits, p.sortOrder, p.enabled]); + if (!rows[0]) return NextResponse.json({ error: "套餐不存在" }, { status: 404 }); + return NextResponse.json({ package: output(rows[0]) }); + } catch (error) { + return adminErrorResponse(error); + } +} + +export async function DELETE(request: Request) { + try { + await requireAdminSession("write"); + const parsed = idSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 }); + const rows = await queryAdminRows<{ id: string }>(` + update public.payment_packages + set enabled = false, updated_at = clock_timestamp() + where id = $1 + returning id + `, [parsed.data.id]); + if (!rows[0]) return NextResponse.json({ error: "套餐不存在" }, { status: 404 }); + return NextResponse.json({ ok: true }); + } catch (error) { + return adminErrorResponse(error); + } +} 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..f0399050 --- /dev/null +++ b/frontend/src/app/api/admin/payments/route.ts @@ -0,0 +1,128 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requireAdminSession } from "@/lib/admin/auth"; +import { queryAdminRows } from "@/lib/admin/database"; +import { adminErrorResponse } from "@/lib/admin/http"; + +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), +}); + +type PaymentOrderRow = { + order_no: string; + user_email: string | null; + package_name: string | null; + money_cents: number; + credits: number; + status: string; + epay_trade_no: string | null; + created_at: Date; + paid_at: Date | null; + total_count: string; +}; + +type PaymentStatsRow = { + total_orders: string; + paid_orders: string; + pending_orders: string; + failed_expired_orders: string; + paid_amount_cents: string; + granted_credits: string; +}; + +export async function GET(request: Request) { + try { + await requireAdminSession("read"); + + 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 values: unknown[] = []; + const conditions: string[] = []; + if (status) { + values.push(status); + conditions.push(`o.status = $${values.length}`); + } + if (from) { + values.push(from); + conditions.push(`o.created_at >= $${values.length}::timestamptz`); + } + if (to) { + values.push(to); + conditions.push(`o.created_at <= $${values.length}::timestamptz`); + } + const statsValues: unknown[] = []; + const dateConditions: string[] = []; + if (from) { + statsValues.push(from); + dateConditions.push(`o.created_at >= $${statsValues.length}::timestamptz`); + } + if (to) { + statsValues.push(to); + dateConditions.push(`o.created_at <= $${statsValues.length}::timestamptz`); + } + values.push(limit, offset); + + const [rows, statsRows] = await Promise.all([ + queryAdminRows(` + select + o.order_no, u.email as user_email, p.name as package_name, + o.money_cents, o.credits, o.status, o.epay_trade_no, + o.created_at, o.paid_at, count(*) over()::text as total_count + from public.payment_orders o + left join public.payment_packages p on p.id = o.package_id + left join identity.users u on u.id = o.user_id + ${conditions.length ? `where ${conditions.join(" and ")}` : ""} + order by o.created_at desc, o.order_no asc + limit $${values.length - 1} offset $${values.length} + `, values), + queryAdminRows(` + select + count(*)::text as total_orders, + count(*) filter (where o.status = 'paid')::text as paid_orders, + count(*) filter (where o.status = 'pending')::text as pending_orders, + count(*) filter (where o.status in ('failed', 'expired'))::text as failed_expired_orders, + coalesce(sum(o.money_cents) filter (where o.status = 'paid'), 0)::text as paid_amount_cents, + coalesce(sum(o.credits) filter (where o.status = 'paid'), 0)::text as granted_credits + from public.payment_orders o + ${dateConditions.length ? `where ${dateConditions.join(" and ")}` : ""} + `, statsValues), + ]); + + const orders = rows.map((row) => ({ + orderNo: row.order_no, + userEmail: row.user_email, + packageName: row.package_name, + moneyCents: row.money_cents, + credits: row.credits, + status: row.status, + epayTradeNo: row.epay_trade_no, + createdAt: row.created_at.toISOString(), + paidAt: row.paid_at?.toISOString() ?? null, + })); + const rawStats = statsRows[0]; + 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 = Number(rows[0]?.total_count ?? 0); + return NextResponse.json({ orders, stats, pagination: { limit, offset, total, hasMore: offset + orders.length < total } }); + } catch (error) { + const response = adminErrorResponse(error); + if (response.status === 401 || response.status === 403) return response; + return NextResponse.json({ error: "支付记录服务暂时不可用" }, { status: 500 }); + } +} diff --git a/frontend/src/app/api/auth/[...all]/route.ts b/frontend/src/app/api/auth/[...all]/route.ts index 2a25646d..21755f8f 100644 --- a/frontend/src/app/api/auth/[...all]/route.ts +++ b/frontend/src/app/api/auth/[...all]/route.ts @@ -24,7 +24,6 @@ async function dispatch( const services = getIdentityAuthServices(); const handlers = createHostIsolatedAuthHandlers(config, { user: toNextJsHandler(services.user), - admin: toNextJsHandler(services.admin), }); return handlers[method](request); } 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..9686accb --- /dev/null +++ b/frontend/src/app/api/payment/epay/create/route.ts @@ -0,0 +1,53 @@ +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 { readEpayAvailability } from "@/lib/epay/availability"; +import { epaySubmitUrl, readEpayConfig, EpayConfigurationError } from "@/lib/epay/config"; +import { assertPublicGatewayUrl } from "@/lib/epay/gateway-policy"; + +export const runtime = "nodejs"; +const schema = z.object({ packageId: z.string().uuid() }); + +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 availability = await readEpayAvailability(); + if (!availability.enabled) return NextResponse.json({ error: "在线支付暂未开放", code: "EPAY_DISABLED" }, { status: 403 }); + const config = await readEpayConfig(); + const submitUrl = epaySubmitUrl(config.gatewayUrl); + await assertPublicGatewayUrl(submitUrl); + + 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 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 signedParams = { ...params, sign: epaySign(params, config.key), sign_type: "MD5" }; + const payUrl = new URL(submitUrl); + for (const [name, value] of Object.entries(signedParams)) payUrl.searchParams.set(name, value); + return NextResponse.json({ orderNo, payUrl: payUrl.toString(), qrCode: null }); + } 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..7c3b7523 --- /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 = await 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..062128af --- /dev/null +++ b/frontend/src/app/api/payment/packages/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { readEpayAvailability } from "@/lib/epay/availability"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; + +export const runtime = "nodejs"; + +export async function GET() { + const availability = await readEpayAvailability(); + if (!availability.enabled) return NextResponse.json({ enabled: false, packages: [] }); + + 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({ enabled: false, packages: [] }); + return NextResponse.json({ + enabled: true, + packages: (data || []).map((item) => ({ + id: item.id, + name: item.name, + description: item.description, + priceCents: item.price_cents, + credits: item.credits, + })), + }); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 46b5d654..30f01106 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -367,7 +367,13 @@ button:disabled { cursor: default; opacity: .45; } .chat-header span { color: var(--color-ink-secondary); margin-top: var(--space-1); font-size: 12px; } .status { display: inline-block; margin: 0 6px 1px 0; border-radius: 50%; background: var(--color-success); width: 7px; height: 7px; margin-right: var(--space-2); } .status-loading { background: var(--color-action); } -.credit-button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 11px; cursor: pointer; font-size: 13px; font-variant-numeric: tabular-nums; transition: background-color 120ms ease-out, transform 120ms ease-out; min-width: 64px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-soft); color: var(--color-ink-secondary); font-weight: 500; } +.chat-header > .chat-header-actions { min-width: auto; display: flex; flex: 0 0 auto; align-items: center; gap: var(--space-2); } +.admin-button, .credit-button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-soft); color: var(--color-ink-secondary); transition: background-color 120ms ease-out, border-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; } +.admin-button { width: 44px; flex: 0 0 44px; } +.admin-button svg { width: 18px; height: 18px; } +.admin-button:hover, .credit-button:hover { border-color: color-mix(in srgb, var(--color-action) 35%, var(--color-border)); background: var(--color-action-soft); color: var(--color-action-hover); } +.admin-button:active, .credit-button:active { transform: translateY(1px); } +.credit-button { gap: 6px; min-width: 64px; padding: 0 11px; cursor: pointer; font-size: 13px; font-variant-numeric: tabular-nums; font-weight: 500; } .conversation { min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding-bottom: var(--composer-reserve); background: var(--color-canvas); } .conversation.is-empty { display: grid; place-items: center; padding: var(--space-8); } @@ -788,6 +794,8 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: .auth-links { display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--space-2); } .auth-links button { min-height: 32px; padding: 0; color: var(--color-action); } +.admin-app-shell { height: 100dvh; min-height: 0; overflow-y: auto; } +.admin-app-shell > *, .admin-app-shell .ant-layout { min-height: 100%; } .admin-page { background: var(--color-canvas-soft); } .admin-header { position: sticky; z-index: 4; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--color-border); min-height: 88px; padding: 0 var(--space-8); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); } .admin-header h1 { font-size: var(--type-display-md); } @@ -805,6 +813,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); } @@ -1706,3 +1722,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/login/page.tsx b/frontend/src/app/login/page.tsx index 38692169..63196fc2 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -1,35 +1,14 @@ -import { headers } from "next/headers"; - import { EmailOtpLogin } from "@/components/email-otp-login"; -import { - isSelfHostedIdentityEnabled, - readIdentityConfig, - readSelfHostedIdentityConfig, -} from "@/modules/identity/config"; -import { resolveIdentitySurface } from "@/modules/identity/host"; +import { readIdentityConfig } from "@/modules/identity/config"; export const dynamic = "force-dynamic"; export default async function LoginPage() { const config = readIdentityConfig(process.env); - let provider = config.provider; - let passwordEnabled = false; - let passwordOnly = false; - if (isSelfHostedIdentityEnabled(process.env)) { - const selfHosted = readSelfHostedIdentityConfig(process.env); - const surface = resolveIdentitySurface( - (await headers()).get("host"), - selfHosted, - ); - if (surface === "admin") provider = "self-hosted"; - passwordEnabled = provider === "self-hosted"; - passwordOnly = surface === "admin"; - } return ( ); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 18f7f1fa..9309c3cc 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import dynamic from "next/dynamic"; -import { ArrowUp, ArrowUpRight, Sparkles, Square, X } from "lucide-react"; +import { ArrowUp, ArrowUpRight, ShieldCheck, Sparkles, Square, X } from "lucide-react"; import { useGSAP } from "@gsap/react"; import { gsap } from "gsap"; import { useEffect, useRef, useState } from "react"; @@ -180,6 +180,7 @@ type Account = { user: { id: string; email: string | null }; credits: number; isAdmin: boolean; + adminUrl: string | null; rectificationPriceCredits: number; hasConfirmedBirthTime: boolean; rectificationCase: AccountRectificationCaseState | null; @@ -924,6 +925,11 @@ export default function Home() { const [redeemError, setRedeemError] = useState(""); const [redeemMessage, setRedeemMessage] = useState(""); const [redeeming, setRedeeming] = useState(false); + const [paymentEnabled, setPaymentEnabled] = 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([]); @@ -1239,6 +1245,7 @@ export default function Home() { user: { id: "preview-user", email: "preview@local.test" }, credits: 8, isAdmin: false, + adminUrl: null, rectificationPriceCredits: 1, hasConfirmedBirthTime: previewProfile.birthTimeStatus === "confirmed", rectificationCase: null, @@ -1669,6 +1676,10 @@ export default function Home() { case "redeem": setRedeemError(""); setRedeemMessage(""); + setPaymentEnabled(false); + setPaymentPackages([]); + setPaymentOrder(null); + setPaymentError(""); break; case "logout": break; @@ -1941,6 +1952,47 @@ 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 && payload?.enabled === true) { + setPaymentEnabled(true); + setPaymentPackages(payload.packages || []); + return; + } + if (!response.ok) setPaymentError("套餐支付暂时不可用,请稍后重试"); + }).catch(() => { + setPaymentError("套餐支付暂时不可用,请稍后重试"); + }); + }, [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 || "创建支付失败"); + if (typeof payload?.orderNo !== "string" || typeof payload?.payUrl !== "string") throw new Error("创建支付失败"); + setPaymentOrder({ orderNo: payload.orderNo, payUrl: payload.payUrl, qrCode: payload.qrCode ?? null, status: "pending" }); + 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(); @@ -2752,6 +2804,7 @@ export default function Home() { email: account.user.email || "尚未读取邮箱", credits: account.credits, isAdmin: account.isAdmin, + adminUrl: account.adminUrl, initial: profile.name.trim().slice(0, 1) || account.user.email?.slice(0, 1).toUpperCase() || "你", @@ -2835,10 +2888,17 @@ export default function Home() { ? "正在校正出生时间" : personalChartAvailable ? "基于星盘证据回答" : "回答一般占星知识"} - +
+ {account.isAdmin && account.adminUrl ? ( + +
{!rectificationSurfaceOpen && ( @@ -3222,6 +3282,12 @@ export default function Home() { {redeemError &&

{redeemError}

} {redeemMessage &&

{redeemMessage}

} + {paymentEnabled &&
+

套餐充值

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

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

} +
} + {paymentError &&

{paymentError}

} )} diff --git a/frontend/src/components/admin/admin-app.tsx b/frontend/src/components/admin/admin-app.tsx index 0f6620b1..045daa24 100644 --- a/frontend/src/components/admin/admin-app.tsx +++ b/frontend/src/components/admin/admin-app.tsx @@ -1,16 +1,20 @@ "use client"; import { + ArrowLeftOutlined, AuditOutlined, + CreditCardOutlined, GiftOutlined, + ShoppingOutlined, MessageOutlined, TeamOutlined, TransactionOutlined, } from "@ant-design/icons"; import { Authenticated, Refine } from "@refinedev/core"; -import { ErrorComponent, ThemedLayout, useNotificationProvider } from "@refinedev/antd"; +import { ErrorComponent, ThemedLayout, ThemedSider, useNotificationProvider } from "@refinedev/antd"; import routerProvider from "@refinedev/nextjs-router"; -import { App as AntdApp, ConfigProvider, Spin, theme } from "antd"; +import { App as AntdApp, ConfigProvider, Menu, Spin, theme } from "antd"; +import Link from "next/link"; import type { ReactNode } from "react"; import { @@ -19,39 +23,58 @@ import { adminDataProvider, } from "@/lib/admin/providers"; +function AdminSider() { + return ( + ( + <> + {items} + } title="返回对话"> + {collapsed ? null : "返回对话"} + + + )} + /> + ); +} + export function AdminApp({ children }: { children: ReactNode }) { const notificationProvider = useNotificationProvider(); return ( - - - + + + } }, + { name: "payments", list: "/admin/payments", meta: { label: "支付管理", icon: } }, + { name: "packages", list: "/admin/packages", meta: { label: "套餐管理", icon: } }, { name: "users", list: "/admin/codes?resource=users", meta: { label: "用户资料", icon: } }, { name: "credit-transactions", list: "/admin/codes?resource=credit-transactions", meta: { label: "积分流水", icon: } }, { name: "consultations", list: "/admin/codes?resource=consultations", meta: { label: "咨询请求", icon: } }, { name: "audit-logs", list: "/admin/codes?resource=audit-logs", meta: { label: "审计日志", icon: } }, ]} - options={{ - syncWithLocation: true, - warnWhenUnsavedChanges: true, - title: { text: "Jyotisha 后台" }, - }} - > - 正在验证后台权限} + options={{ + syncWithLocation: true, + warnWhenUnsavedChanges: true, + title: { text: "Jyotisha 后台" }, + }} > - {children} - - - - + 正在验证后台权限} + > + {children} + + + + + ); } diff --git a/frontend/src/components/admin/codes-resource.tsx b/frontend/src/components/admin/codes-resource.tsx index da2ef5d8..28e14515 100644 --- a/frontend/src/components/admin/codes-resource.tsx +++ b/frontend/src/components/admin/codes-resource.tsx @@ -38,7 +38,7 @@ const statusColors: Record = { }; export default function CodesPage() { - const { data: role } = usePermissions<"admin" | "viewer">({}); + const { data: role } = usePermissions<"admin">({}); const { data: identity } = useGetIdentity(); const { mutate: createCodes, mutation: createMutation } = useCreate<{ id: string; generated: CodeRecord[] }>(); const { mutate: updateCode, mutation: updateMutation } = useUpdate(); @@ -137,7 +137,7 @@ export default function CodesPage() { { label: "已兑换", value: "redeemed" }, { label: "已撤销", value: "revoked" }, ]} - extra={writable ? : viewer 只读} + extra={writable ? : null} /> setCreateOpen(false)} footer={null} destroyOnHidden> diff --git a/frontend/src/components/admin/package-management.tsx b/frontend/src/components/admin/package-management.tsx new file mode 100644 index 00000000..b77d2731 --- /dev/null +++ b/frontend/src/components/admin/package-management.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { PlusOutlined } from "@ant-design/icons"; +import { List } from "@refinedev/antd"; +import { Alert, App, Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Row, Col, Space, Switch, Table, Tag, Typography, type TableColumnsType } from "antd"; +import { useCallback, useEffect, useState } from "react"; + +const { Text } = Typography; + +type PaymentPackage = { + id: string; + name: string; + description: string; + priceCents: number; + credits: number; + sortOrder: number; + enabled: boolean; +}; + +type PackageFormValues = Omit & { priceYuan: number }; + +function formatMoney(cents: number) { + return `¥${(cents / 100).toFixed(2)}`; +} + +async function responsePayload(response: Response) { + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || "请求失败"); + return payload; +} + +export function PackageManagement() { + const { message } = App.useApp(); + const [packageForm] = Form.useForm(); + const [packages, setPackages] = useState([]); + const [packagesLoading, setPackagesLoading] = useState(true); + const [packagesError, setPackagesError] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [editingPackage, setEditingPackage] = useState(null); + const [saving, setSaving] = useState(false); + const [disablingId, setDisablingId] = useState(null); + + const loadPackages = useCallback(async () => { + setPackagesLoading(true); + setPackagesError(""); + try { + const payload = await responsePayload(await fetch("/api/admin/packages", { cache: "no-store" })); + setPackages(payload.packages); + } catch (error) { + setPackagesError(error instanceof Error ? error.message : "读取套餐失败"); + } finally { + setPackagesLoading(false); + } + }, []); + + useEffect(() => { + const timer = window.setTimeout(() => void loadPackages(), 0); + return () => window.clearTimeout(timer); + }, [loadPackages]); + + function openCreateModal() { + setEditingPackage(null); + packageForm.setFieldsValue({ name: "", description: "", priceYuan: 1, credits: 10, sortOrder: 0, enabled: true }); + setModalOpen(true); + } + + function openEditModal(item: PaymentPackage) { + setEditingPackage(item); + packageForm.setFieldsValue({ + name: item.name, + description: item.description, + priceYuan: item.priceCents / 100, + credits: item.credits, + sortOrder: item.sortOrder, + enabled: item.enabled, + }); + setModalOpen(true); + } + + function closeModal() { + if (saving) return; + setModalOpen(false); + setEditingPackage(null); + packageForm.resetFields(); + } + + async function savePackage(values: PackageFormValues) { + setSaving(true); + try { + await responsePayload(await fetch("/api/admin/packages", { + method: editingPackage ? "PATCH" : "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ...(editingPackage ? { id: editingPackage.id } : {}), + name: values.name.trim(), + description: values.description?.trim() ?? "", + priceCents: Math.round(values.priceYuan * 100), + credits: values.credits, + sortOrder: values.sortOrder, + enabled: values.enabled, + }), + })); + message.success(editingPackage ? "套餐已更新" : "套餐已添加"); + setModalOpen(false); + setEditingPackage(null); + packageForm.resetFields(); + await loadPackages(); + } catch (error) { + message.error(error instanceof Error ? error.message : "保存套餐失败"); + } finally { + setSaving(false); + } + } + + async function disablePackage(id: string) { + setDisablingId(id); + try { + await responsePayload(await fetch("/api/admin/packages", { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id }), + })); + message.success("套餐已停用"); + await loadPackages(); + } catch (error) { + message.error(error instanceof Error ? error.message : "停用套餐失败"); + } finally { + setDisablingId(null); + } + } + + const columns: TableColumnsType = [ + { title: "名称", dataIndex: "name", render: (_, item) => {item.name}{item.description || "暂无描述"} }, + { title: "价格", dataIndex: "priceCents", align: "right", render: formatMoney }, + { title: "点数", dataIndex: "credits", align: "right" }, + { title: "排序", dataIndex: "sortOrder", align: "right" }, + { title: "状态", dataIndex: "enabled", render: (enabled) => {enabled ? "启用" : "停用"} }, + { title: "操作", key: "actions", fixed: "right", render: (_, item) => {item.enabled && disablePackage(item.id)}>} }, + ]; + + return ( + + } onClick={openCreateModal}>添加套餐}> + + {packagesError && void loadPackages()}>重试} />} + rowKey="id" columns={columns} dataSource={packages} loading={packagesLoading} pagination={false} scroll={{ x: "max-content" }} /> + + + packageForm.submit()} onCancel={closeModal} destroyOnHidden afterClose={() => packageForm.resetFields()} maskClosable={!saving} keyboard={!saving}> + form={packageForm} layout="vertical" onFinish={savePackage} requiredMark="optional" initialValues={{ priceYuan: 1, credits: 10, sortOrder: 0, enabled: true }}> + + + + + + + + + + + + ); +} diff --git a/frontend/src/components/admin/payment-management.tsx b/frontend/src/components/admin/payment-management.tsx new file mode 100644 index 00000000..cedadbcd --- /dev/null +++ b/frontend/src/components/admin/payment-management.tsx @@ -0,0 +1,271 @@ +"use client"; + +import { List } from "@refinedev/antd"; +import { + Alert, + App, + Button, + Card, + Col, + Collapse, + DatePicker, + Form, + Input, + Row, + Select, + Space, + Statistic, + Switch, + Table, + Tag, + Typography, + type TableColumnsType, +} from "antd"; +import type { Dayjs } from "dayjs"; +import { useCallback, useEffect, useState } from "react"; + +const { Text } = Typography; + +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 PaymentStats = { + totalOrders: number; + paidOrders: number; + pendingOrders: number; + failedExpiredOrders: number; + paidAmountCents: number; + grantedCredits: number; +}; + +type PaymentFilters = { status?: string; dates?: [Dayjs, Dayjs] }; +type EpaySettings = { + gatewayUrl: string; + pid: string; + notifyUrl: string; + returnUrl: string; + siteName: string; + chatEnabled: boolean; + keyConfigured: boolean; + complete: boolean; + source: "database" | "environment" | "unconfigured"; +}; +type EpaySettingsForm = Pick & { newKey?: string }; + +const initialStats: PaymentStats = { + totalOrders: 0, + paidOrders: 0, + pendingOrders: 0, + failedExpiredOrders: 0, + paidAmountCents: 0, + grantedCredits: 0, +}; +const statusLabels: Record = { pending: "待支付", paid: "已支付", failed: "失败", expired: "已过期" }; +const statusColors: Record = { pending: "gold", paid: "green", failed: "red", expired: "default" }; +const dateFormatter = new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "short", timeZone: "Asia/Shanghai" }); +const pageSize = 20; + +function formatDate(value: string | null) { + return value ? dateFormatter.format(new Date(value)) : "—"; +} + +function formatMoney(cents: number) { + return `¥${(cents / 100).toFixed(2)}`; +} + +async function responsePayload(response: Response) { + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || "请求失败"); + return payload; +} + +export default function PaymentManagement() { + const { message } = App.useApp(); + const [filterForm] = Form.useForm(); + const [epayForm] = Form.useForm(); + const [orders, setOrders] = useState([]); + const [stats, setStats] = useState(initialStats); + const [paymentLoading, setPaymentLoading] = useState(true); + const [paymentError, setPaymentError] = useState(""); + const [filters, setFilters] = useState({}); + const [offset, setOffset] = useState(0); + const [total, setTotal] = useState(0); + const [epaySettings, setEpaySettings] = useState(null); + const [epayLoading, setEpayLoading] = useState(true); + const [epaySaving, setEpaySaving] = useState(false); + const [epayTesting, setEpayTesting] = useState(false); + const [epayError, setEpayError] = useState(""); + + const loadPayments = useCallback(async () => { + setPaymentLoading(true); + setPaymentError(""); + const params = new URLSearchParams({ limit: String(pageSize), offset: String(offset) }); + if (filters.status) params.set("status", filters.status); + if (filters.dates?.[0]) params.set("from", filters.dates[0].startOf("day").toISOString()); + if (filters.dates?.[1]) params.set("to", filters.dates[1].endOf("day").toISOString()); + try { + const payload = await responsePayload(await fetch(`/api/admin/payments?${params}`, { cache: "no-store" })); + setOrders(payload.orders); + setStats(payload.stats); + setTotal(payload.pagination.total); + } catch (error) { + setPaymentError(error instanceof Error ? error.message : "读取支付记录失败"); + } finally { + setPaymentLoading(false); + } + }, [filters, offset]); + + const loadEpaySettings = useCallback(async () => { + setEpayLoading(true); + setEpayError(""); + try { + const payload: EpaySettings = await responsePayload(await fetch("/api/admin/epay-settings", { cache: "no-store" })); + setEpaySettings(payload); + epayForm.setFieldsValue({ + gatewayUrl: payload.gatewayUrl, + pid: payload.pid, + notifyUrl: payload.notifyUrl, + returnUrl: payload.returnUrl, + siteName: payload.siteName, + chatEnabled: payload.chatEnabled, + newKey: "", + }); + } catch (error) { + setEpayError(error instanceof Error ? error.message : "读取易支付配置失败"); + } finally { + setEpayLoading(false); + } + }, [epayForm]); + + useEffect(() => { + const timer = window.setTimeout(() => void loadPayments(), 0); + return () => window.clearTimeout(timer); + }, [loadPayments]); + useEffect(() => { + const timer = window.setTimeout(() => void loadEpaySettings(), 0); + return () => window.clearTimeout(timer); + }, [loadEpaySettings]); + + async function testEpayAvailability() { + setEpayTesting(true); + try { + const payload = await responsePayload(await fetch("/api/admin/epay-settings/test", { method: "POST" })); + if (payload.available) message.success(`${payload.message}(${payload.status},${payload.latencyMs}ms)`); + else message.error(payload.message || "当前已保存的易支付配置暂不可用"); + } catch (error) { + message.error(error instanceof Error ? error.message : "当前已保存的易支付配置暂不可用"); + } finally { + setEpayTesting(false); + } + } + + async function saveEpaySettings(values: EpaySettingsForm) { + setEpaySaving(true); + try { + await responsePayload(await fetch("/api/admin/epay-settings", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...values, newKey: values.newKey || undefined }), + })); + epayForm.setFieldValue("newKey", ""); + message.success("Z-Pay(易支付)配置已保存"); + await loadEpaySettings(); + } catch (error) { + message.error(error instanceof Error ? error.message : "保存易支付配置失败"); + } finally { + setEpaySaving(false); + } + } + + const orderColumns: TableColumnsType = [ + { title: "订单号", dataIndex: "orderNo", render: (value) => {value} }, + { title: "用户邮箱", dataIndex: "userEmail", render: (value) => value || "—" }, + { title: "套餐", dataIndex: "packageName", render: (value) => value || "—" }, + { title: "金额", dataIndex: "moneyCents", align: "right", render: formatMoney }, + { title: "点数", dataIndex: "credits", align: "right" }, + { title: "状态", dataIndex: "status", render: (value) => {statusLabels[value] || value} }, + { title: "易支付交易号", dataIndex: "epayTradeNo", render: (value) => value || "—" }, + { title: "创建时间", dataIndex: "createdAt", render: formatDate }, + { title: "支付时间", dataIndex: "paidAt", render: formatDate }, + ]; + return ( + + + + + + + + + + + + + + } + > + + 配置兼容标准 Z-Pay / 易支付协议的支付网关、商户凭据、回调地址与对话页开关。 + {epaySettings && 来源:{{ database: "数据库", environment: "环境变量", unconfigured: "未配置" }[epaySettings.source]}{epaySettings.complete ? "配置完整" : "配置不完整"}{epaySettings.keyConfigured ? "密钥已配置" : "密钥未配置"}{epaySettings.chatEnabled ? "对话支付开放" : "对话支付关闭"}} + {epayError && void loadEpaySettings()}>重试} />} + + form={epayForm} layout="vertical" onFinish={saveEpaySettings} requiredMark="optional"> + + + + + + + + + + + + + + + ), + }]} + /> + + 共 {total} 条平台订单}> + +
{ setOffset(0); setFilters(values); }}> + + +