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..3e430e22 --- /dev/null +++ b/.gitea/workflows/backend-quality-gate.yml @@ -0,0 +1,147 @@ +name: Staging Backend Quality Gate + +on: + pull_request: + paths: + - '.gitea/workflows/backend-quality-gate.yml' + - '.gitea/workflows/deploy-staging.yml' + - '.gitea/workflows/migrate-staging-database.yml' + - 'deploy/**' + - 'frontend/**' + - 'jyotish_vedic/**' + - 'scripts/**' + - 'tests/**' + - 'mcp_server.py' + - 'pyproject.toml' + - 'requirements*.txt' + push: + branches: [staging] + workflow_dispatch: + +concurrency: + group: staging-quality-${{ gitea.ref }} + cancel-in-progress: true + +permissions: + contents: read + actions: write + +jobs: + validate: + runs-on: xiaoxin + timeout-minutes: 45 + env: + GITEA_SHA: ${{ gitea.sha }} + steps: + - name: Checkout exact Gitea revision + run: | + set -euo pipefail + [[ "$GITEA_SHA" =~ ^[0-9a-f]{40}$ ]] + 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" + git clean -ffdx + test "$(git rev-parse HEAD)" = "$GITEA_SHA" + test -z "$(git status --porcelain --untracked-files=all)" + + - name: Verify Linux runner toolchain + run: | + set -euo pipefail + python3 --version + node --version + npm --version + docker version + + - name: Install dependencies + 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 + + - name: Validate backend, package, frontend, and database contracts + run: | + set -euo pipefail + export PATH="$PWD/.venv/bin:$PATH" + 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 + python -m build + npm test --prefix frontend + npm run lint --prefix frontend + npm run build --prefix frontend + + publish: + if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/staging' + needs: validate + runs-on: xiaoxin + timeout-minutes: 45 + env: + GITEA_SHA: ${{ gitea.sha }} + GITEA_RUN_ATTEMPT: ${{ gitea.run_attempt }} + REGISTRY_HOST: crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com + IMAGE_REPOSITORY: crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com/copse/jyotisha + steps: + - name: Checkout exact Gitea revision + run: | + set -euo pipefail + [[ "$GITEA_SHA" =~ ^[0-9a-f]{40}$ ]] + 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" + git clean -ffdx + test "$(git rev-parse HEAD)" = "$GITEA_SHA" + test -z "$(git status --porcelain --untracked-files=all)" + + - name: Build and publish exact-SHA ACR images + env: + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + run: | + set -euo pipefail + cleanup() { docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true; } + trap cleanup EXIT + 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" + + - name: Record immutable linux-amd64 image manifest + run: | + set -euo pipefail + [[ "$GITEA_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$GITEA_RUN_ATTEMPT" =~ ^[0-9]+$ ]] + select_digest='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="$(docker manifest inspect "$IMAGE_REPOSITORY:api-$GITEA_SHA" --verbose | python3 -c "$select_digest")" + web_digest="$(docker manifest inspect "$IMAGE_REPOSITORY:web-$GITEA_SHA" --verbose | python3 -c "$select_digest")" + [[ "$api_digest" =~ ^sha256:[0-9a-f]{64}$ ]] + [[ "$web_digest" =~ ^sha256:[0-9a-f]{64}$ ]] + install -d -m 700 artifacts/staging-images + umask 077 + printf 'git_sha=%s\napi_digest=%s\nweb_digest=%s\n' \ + "$GITEA_SHA" "$api_digest" "$web_digest" \ + > artifacts/staging-images/manifest.env + node frontend/scripts/staging-image-manifest.mjs \ + artifacts/staging-images/manifest.env "$GITEA_SHA" "$IMAGE_REPOSITORY" >/dev/null + + - name: Upload immutable staging image manifest + uses: https://gitea.com/actions/upload-artifact@v4 + with: + name: staging-image-manifest-${{ gitea.sha }}-${{ gitea.run_attempt }} + path: artifacts/staging-images/manifest.env + if-no-files-found: error + retention-days: 30 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..0fc142f1 --- /dev/null +++ b/.gitea/workflows/deploy-staging.yml @@ -0,0 +1,188 @@ +name: Deploy staging + +on: + workflow_run: + workflows: ["Staging Backend Quality Gate"] + types: [completed] + 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 to an older tested SHA + required: true + default: false + type: boolean + +permissions: + contents: read + actions: read + +concurrency: + group: staging-mutation + cancel-in-progress: false + queue: max + +jobs: + deploy: + if: gitea.event_name == 'workflow_dispatch' || (gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push' && gitea.event.workflow_run.head_branch == 'staging') + runs-on: xiaoxin + timeout-minutes: 30 + env: + GITEA_SHA: ${{ gitea.sha }} + GITEA_API_URL: ${{ gitea.api_url }} + GITEA_REPOSITORY: ${{ gitea.repository }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + 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: Validate tested revision and gate run + id: revision + env: + REQUESTED_SHA: ${{ gitea.event.workflow_run.head_sha || inputs.deploy_sha }} + WORKFLOW_RUN_ID: ${{ gitea.event.workflow_run.id }} + WORKFLOW_RUN_ATTEMPT: ${{ gitea.event.workflow_run.run_attempt }} + REQUESTED_ROLLBACK: ${{ inputs.allow_rollback || 'false' }} + GITEA_EVENT_NAME: ${{ gitea.event_name }} + run: | + set -euo pipefail + [[ "$REQUESTED_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "deploy_sha must be a lowercase full commit SHA" >&2; exit 1; } + allow_rollback=false + if [[ "$REQUESTED_ROLLBACK" == true ]]; then + [[ "$GITEA_EVENT_NAME" == workflow_dispatch ]] || { echo "rollback authorization is manual-only" >&2; exit 1; } + allow_rollback=true + fi + + gate_run_id="${WORKFLOW_RUN_ID:-}" + gate_run_attempt="${WORKFLOW_RUN_ATTEMPT:-}" + if [[ "$GITEA_EVENT_NAME" == workflow_dispatch ]]; then + runs="$(curl --fail --silent --show-error \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$REQUESTED_SHA&branch=staging&event=push&status=success&limit=100")" + selected_run="$(jq -cer --arg sha "$REQUESTED_SHA" ' + [.workflow_runs[] | select( + (.path | split("@")[0] | endswith("backend-quality-gate.yml")) and + .head_sha == $sha and .head_branch == "staging" and + .event == "push" and .conclusion == "success" + )] | sort_by(.id) | reverse | first + ' <<<"$runs")" + gate_run_id="$(jq -er '.id' <<<"$selected_run")" + gate_run_attempt="$(jq -er '.run_attempt // 0' <<<"$selected_run")" + fi + [[ "$gate_run_id" =~ ^[0-9]+$ ]] || { echo "no successful exact-SHA staging quality gate run found" >&2; exit 1; } + [[ "$gate_run_attempt" =~ ^[0-9]+$ ]] || { echo "invalid staging quality gate run attempt" >&2; exit 1; } + + staging_head="$(git ls-remote https://git.copse.top/root/Jyotisha.git refs/heads/staging | awk '{print $1}')" + [[ "$staging_head" =~ ^[0-9a-f]{40}$ ]] + if [[ "$allow_rollback" == false && "$REQUESTED_SHA" != "$staging_head" ]]; then + echo "stale staging revision refused; use explicit manual rollback only when intended" >&2 + exit 1 + fi + { + echo "sha=$REQUESTED_SHA" + echo "gate_run_id=$gate_run_id" + echo "gate_run_attempt=$gate_run_attempt" + echo "allow_rollback=$allow_rollback" + } >>"$GITHUB_OUTPUT" + + - name: Checkout trusted main controller + env: + DEPLOY_SHA: ${{ steps.revision.outputs.sha }} + run: | + set -euo pipefail + git init . + git remote remove origin 2>/dev/null || true + git remote add origin https://git.copse.top/root/Jyotisha.git + git fetch --no-tags origin main "$DEPLOY_SHA" + git checkout --detach --force origin/main + git merge-base --is-ancestor "$DEPLOY_SHA" HEAD || { echo "staging revision is not in trusted main history" >&2; exit 1; } + + - name: Download gate-produced image manifest + env: + GATE_RUN_ID: ${{ steps.revision.outputs.gate_run_id }} + GATE_RUN_ATTEMPT: ${{ steps.revision.outputs.gate_run_attempt }} + DEPLOY_SHA: ${{ steps.revision.outputs.sha }} + run: | + set -euo pipefail + artifact_name="staging-image-manifest-$DEPLOY_SHA-$GATE_RUN_ATTEMPT" + artifacts="$(curl --fail --silent --show-error \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs/$GATE_RUN_ID/artifacts?name=$artifact_name")" + artifact_id="$(jq -er --arg name "$artifact_name" '[.artifacts[] | select(.name == $name and .expired == false)] | first | .id' <<<"$artifacts")" + [[ "$artifact_id" =~ ^[0-9]+$ ]] + install -d -m 700 artifacts/staging-image + curl --fail --silent --show-error --location \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/artifacts/$artifact_id/zip" \ + --output "${RUNNER_TEMP}/staging-image-manifest.zip" + unzip -q "${RUNNER_TEMP}/staging-image-manifest.zip" -d artifacts/staging-image + [[ -f artifacts/staging-image/manifest.env ]] + + - name: Validate immutable image manifest + id: images + env: + DEPLOY_SHA: ${{ steps.revision.outputs.sha }} + run: | + set -euo pipefail + node frontend/scripts/staging-image-manifest.mjs \ + artifacts/staging-image/manifest.env "$DEPLOY_SHA" "$IMAGE_REPOSITORY" >>"$GITHUB_OUTPUT" + + - name: Deploy exact image digests under pinned SSH identity + env: + SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + DEPLOY_SHA: ${{ steps.revision.outputs.sha }} + API_IMAGE: ${{ steps.images.outputs.api_image }} + WEB_IMAGE: ${{ steps.images.outputs.web_image }} + ALLOW_ROLLBACK: ${{ steps.revision.outputs.allow_rollback }} + run: | + set -euo pipefail + ssh_root="${RUNNER_TEMP}/staging-ssh" + key_path="$ssh_root/id_ed25519" + known_hosts_path="$ssh_root/known_hosts" + incoming="" + 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" + require_current_staging_head() { + [[ "$ALLOW_ROLLBACK" == true ]] && return + current_head="$(git ls-remote https://git.copse.top/root/Jyotisha.git refs/heads/staging | awk '{print $1}')" + [[ "$current_head" == "$DEPLOY_SHA" ]] || { echo "staging advanced during deployment; refusing stale mutation" >&2; exit 1; } + } + cleanup() { + if [[ -n "$incoming" ]]; then + 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 + fi + rm -rf -- "$ssh_root" + } + trap cleanup EXIT + incoming="$(ssh "${ssh_options[@]}" "$remote" "mktemp -d /tmp/jyotisha-staging.XXXXXXXXXX")" + [[ "$incoming" == /tmp/jyotisha-staging.* ]] + 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 "automatic staging rollback or divergent deploy refused" >&2; exit 1; } + forward_verified=true + fi + require_current_staging_head + 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'" + require_current_staging_head diff --git a/.gitea/workflows/migrate-staging-database.yml b/.gitea/workflows/migrate-staging-database.yml new file mode 100644 index 00000000..c985af96 --- /dev/null +++ b/.gitea/workflows/migrate-staging-database.yml @@ -0,0 +1,158 @@ +name: Migrate Staging Database (manual only) + +on: + workflow_dispatch: + inputs: + deploy_sha: + description: Full current staging SHA to migrate + required: true + type: string + +permissions: + contents: read + actions: read + +concurrency: + group: staging-mutation + cancel-in-progress: false + queue: max + +jobs: + migrate: + runs-on: xiaoxin + timeout-minutes: 20 + env: + GITEA_SHA: ${{ gitea.sha }} + GITEA_API_URL: ${{ gitea.api_url }} + GITEA_REPOSITORY: ${{ gitea.repository }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + 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: Validate current staging revision and successful gate + id: revision + env: + DEPLOY_SHA: ${{ inputs.deploy_sha }} + run: | + set -euo pipefail + [[ "$DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "deploy_sha must be a lowercase full commit SHA" >&2; exit 1; } + staging_head="$(git ls-remote https://git.copse.top/root/Jyotisha.git refs/heads/staging | awk '{print $1}')" + [[ "$staging_head" == "$DEPLOY_SHA" ]] || { echo "migration requires current staging head" >&2; exit 1; } + runs="$(curl --fail --silent --show-error \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$DEPLOY_SHA&branch=staging&event=push&status=success&limit=100")" + selected_run="$(jq -cer --arg sha "$DEPLOY_SHA" ' + [.workflow_runs[] | select( + (.path | split("@")[0] | endswith("backend-quality-gate.yml")) and + .head_sha == $sha and .head_branch == "staging" and + .event == "push" and .conclusion == "success" + )] | sort_by(.id) | reverse | first + ' <<<"$runs")" + gate_run_id="$(jq -er '.id' <<<"$selected_run")" + gate_run_attempt="$(jq -er '.run_attempt // 0' <<<"$selected_run")" + [[ "$gate_run_id" =~ ^[0-9]+$ ]] + [[ "$gate_run_attempt" =~ ^[0-9]+$ ]] + { + echo "sha=$DEPLOY_SHA" + echo "gate_run_id=$gate_run_id" + echo "gate_run_attempt=$gate_run_attempt" + } >>"$GITHUB_OUTPUT" + + - name: Checkout trusted main controller + env: + DEPLOY_SHA: ${{ steps.revision.outputs.sha }} + run: | + set -euo pipefail + git init . + git remote remove origin 2>/dev/null || true + git remote add origin https://git.copse.top/root/Jyotisha.git + git fetch --no-tags origin main "$DEPLOY_SHA" + git checkout --detach --force origin/main + git merge-base --is-ancestor "$DEPLOY_SHA" HEAD || { echo "staging revision is not in trusted main history" >&2; exit 1; } + + - name: Download gate-produced migration manifest + env: + GATE_RUN_ID: ${{ steps.revision.outputs.gate_run_id }} + GATE_RUN_ATTEMPT: ${{ steps.revision.outputs.gate_run_attempt }} + DEPLOY_SHA: ${{ steps.revision.outputs.sha }} + run: | + set -euo pipefail + artifact_name="staging-image-manifest-$DEPLOY_SHA-$GATE_RUN_ATTEMPT" + artifacts="$(curl --fail --silent --show-error \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs/$GATE_RUN_ID/artifacts?name=$artifact_name")" + artifact_id="$(jq -er --arg name "$artifact_name" '[.artifacts[] | select(.name == $name and .expired == false)] | first | .id' <<<"$artifacts")" + [[ "$artifact_id" =~ ^[0-9]+$ ]] + install -d -m 700 artifacts/staging-image + curl --fail --silent --show-error --location \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/artifacts/$artifact_id/zip" \ + --output "${RUNNER_TEMP}/staging-image-manifest.zip" + unzip -q "${RUNNER_TEMP}/staging-image-manifest.zip" -d artifacts/staging-image + [[ -f artifacts/staging-image/manifest.env ]] + + - name: Validate digest-pinned migration image + id: image + env: + DEPLOY_SHA: ${{ steps.revision.outputs.sha }} + run: | + set -euo pipefail + node frontend/scripts/staging-image-manifest.mjs \ + artifacts/staging-image/manifest.env "$DEPLOY_SHA" "$IMAGE_REPOSITORY" >>"$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.revision.outputs.sha }} + WEB_IMAGE: ${{ steps.image.outputs.web_image }} + run: | + set -euo pipefail + ssh_root="${RUNNER_TEMP}/staging-migration-ssh" + key_path="$ssh_root/id_ed25519" + known_hosts_path="$ssh_root/known_hosts" + incoming="" + 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" + require_current_staging_head() { + current_head="$(git ls-remote https://git.copse.top/root/Jyotisha.git refs/heads/staging | awk '{print $1}')" + [[ "$current_head" == "$DEPLOY_SHA" ]] || { echo "staging advanced during migration; refusing stale mutation" >&2; exit 1; } + } + cleanup() { + if [[ -n "$incoming" ]]; then + 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 + fi + rm -rf -- "$ssh_root" + } + trap cleanup EXIT + incoming="$(ssh "${ssh_options[@]}" "$remote" "mktemp -d /tmp/jyotisha-staging.XXXXXXXXXX")" + [[ "$incoming" == /tmp/jyotisha-staging.* ]] + 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 + require_current_staging_head + 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'" + require_current_staging_head + + - 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/AGENTS.md b/AGENTS.md index ced21830..8b463b81 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ 任何部署、线上故障、域名、登录或环境变量任务,先读取 `deploy/README.md`,不要重新猜测架构。 - Production domain: `https://jyotisha.chat` -- Source: `https://github.com/jesse-ux/Jyotisha.git` +- Primary source: `https://git.copse.top/root/Jyotisha.git`; GitHub upstream/mirror: `https://github.com/jesse-ux/Jyotisha.git` - Server: Hong Kong Ubuntu 22.04 VPS, `103.117.123.53`, SSH port `22000` - Runtime: `/opt/jyotisha-app`, Docker Compose file `deploy/docker-compose.server.yml` - Secrets: `/opt/jyotisha-app/.env.production`; never print, copy into chat, or commit diff --git a/README.md b/README.md index c003365a..eed91019 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Current production infrastructure: - Compose file: `deploy/docker-compose.server.yml` - Production environment: `/opt/jyotisha-app/.env.production` (`0600`, never commit) - Supabase project: `vtvnfqmonbfuxmqkqdlc` -- Source repository: `https://github.com/jesse-ux/Jyotisha.git` +- Primary source repository: `https://git.copse.top/root/Jyotisha.git`; GitHub upstream/mirror: `https://github.com/jesse-ux/Jyotisha.git` Deployment, recovery, DNS, HTTPS, update and verification commands are documented in [`deploy/README.md`](deploy/README.md). Railway/Vercel remain optional alternatives, not the current production topology. 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..5c5475bb 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -14,7 +14,8 @@ This file is the operational source of truth for the current Jyotisha demo deplo | Capacity | 1 vCPU / 2 GB RAM / 40 GB disk / 5 Mbps | | App directory | `/opt/jyotisha-app` | | Environment file | `/opt/jyotisha-app/.env.production` (`0600`) | -| Source repository | `https://github.com/jesse-ux/Jyotisha.git` | +| Primary source repository | `https://git.copse.top/root/Jyotisha.git` | +| GitHub upstream/mirror | `https://github.com/jesse-ux/Jyotisha.git` | | Supabase project | `vtvnfqmonbfuxmqkqdlc` | This machine is suitable for a client demo and low concurrency. Supabase and the model provider stay managed externally; do not self-host them on this VPS. @@ -73,6 +74,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 @@ -164,11 +173,11 @@ Staging is isolated from production: | PostgreSQL | private Compose network; no published host port | | Business database | local private PostgreSQL (`jyotisha-staging` Compose project) | | Identity | Better Auth + Resend OTP on the same private PostgreSQL cluster | -| GitHub Environment | `staging` | +| Actions control plane | Gitea 1.26.2 (`git.copse.top`) | -The GitHub `staging` Environment contains the secret `STAGING_SSH_PRIVATE_KEY` and the variables `STAGING_HOST`, `STAGING_PORT`, `STAGING_USER`, `STAGING_PATH`, `STAGING_URL`, and `STAGING_KNOWN_HOSTS`. Its deployment branch policy allows the `main` controller branch: GitHub's `workflow_run` event executes from the default branch while the workflow separately requires the successfully tested upstream branch to be `staging`. The controller checks out only `main` with full history, requires the requested staging SHA to be an ancestor of that reviewed history, and uploads only the allowlisted `deploy/` control files. It never executes deployment validators or remote orchestration scripts from the target/rollback revision. The staging key, database, Resend key, and model-provider keys must not be shared with production. Staging image publishing has no Supabase build variables. +Gitea is the primary source repository and Actions control plane. Gitea automatically injects the per-job `${{ secrets.GITEA_TOKEN }}` token; its access is limited by each workflow's `permissions` block and it must not be configured as a repository secret. Configure repository Actions secrets `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, and `STAGING_SSH_PRIVATE_KEY`, plus variables `STAGING_HOST`, `STAGING_PORT`, `STAGING_USER`, `STAGING_PATH`, `STAGING_URL`, and `STAGING_KNOWN_HOSTS`. The `workflow_run` controller is loaded from the default `main` branch while separately requiring the successfully tested upstream branch to be `staging`. The controller checks out only `main` with full history, requires the requested staging SHA to be an ancestor of that reviewed history, and uploads only the allowlisted `deploy/` control files. It never executes deployment validators or remote orchestration scripts from the target/rollback revision. The staging key, database, Resend key, and model-provider keys must not be shared with production. Staging image publishing has no Supabase build variables. GitHub workflows are upstream/mirror fallback only, not the normal staging release path. -`Staging Backend Quality Gate` runs for relevant `pull_request` paths, pushes to `staging`, and `workflow_dispatch`. It validates the Python/database/frontend contract; only a successful push to `staging` publishes the API/web images and a run-bound manifest containing their `sha256` digests. `.github/workflows/deploy-staging.yml` consumes that exact successful run, validates its manifest against the full 40-character commit, and deploys digest references rather than trusting the discoverability tags. +`Staging Backend Quality Gate` runs for relevant `pull_request` paths, pushes to `staging`, and `workflow_dispatch`. It validates the Python/database/frontend contract; only a successful push to `staging` publishes the API/web images and a run-bound manifest containing their `sha256` digests. `.gitea/workflows/deploy-staging.yml` consumes that exact successful run, validates its manifest against the full 40-character commit, and deploys digest references rather than trusting the discoverability tags. The staging env file must include these non-secret selectors so Compose cannot fall back to production paths: @@ -176,22 +185,21 @@ 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. ### First-deploy sequence -1. Complete the server and GitHub bootstrap: create both mode-`0600` env files, preload the reviewed `postgres:17-alpine` image, and configure the staging Environment variables/secrets. No repository-level Supabase variables are required. Deployment and migration workflows use `--pull never` for PostgreSQL, so database image upgrades remain an explicit operator-controlled maintenance action rather than an application-deploy side effect. -2. Merge the reviewed change to `main`, then fast-forward/push that exact reviewed SHA to `staging`; do not create a staging-only target or rely on a `main` workflow dispatch to publish images. +1. Complete the server and Gitea bootstrap: create both mode-`0600` env files, preload the reviewed `postgres:17-alpine` image, and configure the listed Actions variables/secrets. No repository-level Supabase variables are required. Deployment and migration workflows use `--pull never` for PostgreSQL, so database image upgrades remain an explicit operator-controlled maintenance action rather than an application-deploy side effect. +2. Open a PR and merge the reviewed change to `main`, then fast-forward/push that same exact SHA to `staging`; do not create a staging-only target or rely on a `main` workflow dispatch to publish images. 3. The `Staging Backend Quality Gate` runs for that push and, when successful, publishes API/web images plus an artifact binding the exact SHA to both immutable image digests. -4. The automatic `Deploy staging` workflow downloads that gate-run artifact, syncs only the trusted `main` controller's allowlisted `deploy/` files under the shared staging host lock, and validates both `.env.staging` and `.env.staging.database` before any app change. The target application's code is carried only by the digest-pinned images. +4. The automatic `Deploy staging` workflow downloads that gate-run artifact, syncs only the trusted default-`main` controller's allowlisted `deploy/` files under the shared staging host lock, and validates both `.env.staging` and `.env.staging.database` before any app change. The target application's code is carried only by the digest-pinned images. 5. If environment validation fails, fix the server-side env files without committing or copying secrets, then manually rerun `Deploy staging` from `main` with the same successful SHA in `deploy_sha`; the workflow rechecks a successful staging gate for that exact SHA. -6. If the read-only checker reports a pending migration, stop app deployment and run `Migrate Staging Database` manually with the same full SHA; a successful migration re-dispatches `Deploy staging` with that same SHA. -7. Confirm `https://staging.jyotisha.chat/api/health` reports the exact SHA and private API health. +6. If the read-only checker reports a pending migration, stop app deployment and run `Migrate Staging Database` manually with the same full SHA. Migration success does not dispatch deployment. +7. After migration succeeds, the operator must manually start `Deploy staging` from `main` with that same exact SHA, then confirm `https://staging.jyotisha.chat/api/health` reports it and private API health. After the exact-SHA deployment and migrations are verified, use the manual `Configure Staging Rectification Rollout` workflow to change new-case creation. Supply the SHA currently reported by `/api/health`; choose `public` to open all staging accounts, `smoke_only` with canonical test-account UUIDs for a canary, or `paused` to close creation. The workflow updates only the four `RECTIFICATION_V3_*` rollout variables under the shared host lock, recreates `web` and `rectification-v4-worker` with the already deployed image, and rolls back the env file if health does not match the requested audience. Do not edit or print `.env.staging` through CI logs. @@ -252,11 +260,11 @@ PostgreSQL is private: `deploy/docker-compose.postgres.yml` has no `ports` mappi Use this order for every staging revision: -1. Merge the reviewed revision to `main`, then fast-forward/push that same exact SHA to `staging`. +1. Open a PR and merge the reviewed revision to `main`, then fast-forward/push that same exact SHA to `staging`. 2. Wait for `Staging Backend Quality Gate` to pass and publish that exact full SHA's API/web digest manifest. 3. The automatic `Deploy staging` workflow checks the exact SHA in read-only migration-check mode before changing API, web, or Caddy. If it reports pending or drifted migrations, stop; do not retry the application deployment as if it were a migration. 4. Open **Migrate Staging Database -> Run workflow**, select **Use workflow from: main**, and enter the reported full lowercase 40-character SHA in `deploy_sha`. The controller validates that exact SHA against a successful `staging` gate and reviewed `main` history, starts only PostgreSQL, and runs the digest-pinned migrator without executing scripts from the target revision. -5. A successful migration rechecks that `staging` still points at the same exact SHA, prints the ordered migration ledger, and dispatches the `main` controller for digest-pinned deployment with `allow_rollback=false`. If `staging` advanced during migration, it refuses the stale dispatch. Do not substitute a branch name, a short SHA, or a newer commit. +5. A successful migration rechecks that `staging` still points at the same exact SHA and prints the ordered migration ledger, but does not dispatch deployment. The operator must then open **Deploy staging -> Run workflow**, select **Use workflow from: main**, and enter the same exact SHA in `deploy_sha` with `allow_rollback=false`. If `staging` advanced, stop rather than substituting a branch name, short SHA, or newer commit. 6. Confirm `https://staging.jyotisha.chat/api/health` and verify that its deployment SHA is the SHA from step 2. 7. After health verification, create the local encrypted backup described below. 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 2ceb3075..ea97b5ab 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2113,3 +2113,51 @@ - 防复发:任何送往旧事件引擎的日期必须由精度契约测试断言;新增日期表示必须先走共享日历校验,不能在调用端自行拼接或仅增加 Agent 重试步数。 - 相关记录:BUG-116、BUG-118、BUG-120 - 修复版本:本记录所在 staging 发布提交 + +## BUG-122 | self-hosted staging 管理员看不到独立后台入口 + +- 状态:superseded by BUG-123 +- 首次发现: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-123 +- 复发自:BUG-010 +- 修复版本:已由 BUG-123 的同域单会话架构取代 + +## BUG-123 | self-hosted staging 双域后台与主站会话模型冲突 + +- 状态:resolved +- 首次发现:2026-07-29 +- 最近更新:2026-07-29 +- 影响面: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-122 +- 复发自:BUG-122 +- 修复版本:`435e628806390e7ae138363491e7bae63ee801d4`,staging 已验收 + +## BUG-124 | 后台支付入口分散且界面风格不一致 + +- 状态:resolved +- 首次发现:2026-07-29 +- 最近更新: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-122、BUG-123 +- 修复版本:`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/db-migrate.mjs b/frontend/scripts/db-migrate.mjs index 446364b6..6b56215d 100644 --- a/frontend/scripts/db-migrate.mjs +++ b/frontend/scripts/db-migrate.mjs @@ -7,6 +7,14 @@ import pg from "pg"; const { Client } = pg; const migrationFilenamePattern = /^\d{14}_[a-z0-9_]+\.sql$/; const retiredMigrationChecksums = new Map([ + [ + "20260727010000_rectification_v4_conversational_turns.sql", + "1f4fcd5d14b1dc7a280d31e7023777308be5fcc0c49fa46c0fac10f682044115", + ], + [ + "20260727010000_refine_admin_redemption_audit.sql", + "df37255ecfd5bffc34600190de84ff53245ab7e5a91226eb745a010467f08104", + ], [ "20260727010000_admin_users.sql", "785f4fdc65db1028623cc7b5a2571217b913ef9e55f5a17b01658a71612976de", 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..d57241a1 --- /dev/null +++ b/frontend/src/app/admin/users/page.tsx @@ -0,0 +1,33 @@ +"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" }; + +async function loadUsers(): Promise { + 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 || "暂时无法读取管理员列表"); + return payload.users; +} + +export default function AdminUsersPage() { + const [users, setUsers] = useState([]); + const [email, setEmail] = useState(""); + const [error, setError] = useState(""); + useEffect(() => { void loadUsers().then(setUsers).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(""); setUsers(await loadUsers()); + } + 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; } + setUsers(await loadUsers()); + } + 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 69ce6d0c..e143cbc6 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..e7e37cb3 --- /dev/null +++ b/frontend/src/app/api/payment/epay/notify/route.ts @@ -0,0 +1,13 @@ +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { readEpayConfig } from "@/lib/epay/config"; +import { createEpayNotifyHandler } from "@/lib/epay/notify-core"; + +export const runtime = "nodejs"; + +const notify = createEpayNotifyHandler({ + readConfig: readEpayConfig, + settle: async (args) => await createAdminSupabaseClient().rpc("settle_epay_order", args), +}); + +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 cf39112c..92ff7adc 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -803,6 +803,8 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: .auth-links button { min-height: 32px; padding: 0; color: var(--color-action); } .admin-page { background: var(--color-canvas-soft); } +.admin-app-shell { height: 100dvh; min-height: 0; overflow-y: auto; } +.admin-app-shell > *, .admin-app-shell .ant-layout { min-height: 100%; } .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); } .admin-scroll { display: grid; width: min(1200px, 100%); gap: var(--space-5); margin: 0 auto; padding: var(--space-8) var(--space-8) var(--space-16); } @@ -1780,3 +1782,8 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: } .rectification-candidate { min-height: 122px; padding: 12px; scroll-snap-align: 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 188e9573..c1b4b62b 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"; @@ -179,6 +179,7 @@ type Account = { user: { id: string; email: string | null }; credits: number; isAdmin: boolean; + adminUrl: string | null; rectificationPriceCredits: number; hasConfirmedBirthTime: boolean; hasUsableBirthTime: boolean; @@ -926,6 +927,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([]); @@ -1221,6 +1227,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", hasUsableBirthTime: previewProfile.birthTimeStatus === "accepted" || previewProfile.birthTimeStatus === "confirmed", @@ -1656,6 +1663,10 @@ export default function Home() { case "redeem": setRedeemError(""); setRedeemMessage(""); + setPaymentEnabled(false); + setPaymentPackages([]); + setPaymentOrder(null); + setPaymentError(""); break; case "logout": break; @@ -1929,6 +1940,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(); @@ -2682,6 +2734,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() || "你", @@ -2765,10 +2818,17 @@ export default function Home() { ? "正在校正出生时间" : personalChartAvailable ? "基于星盘证据回答" : "回答一般占星知识"} - +
+ {account.isAdmin && account.adminUrl ? ( + +
{!rectificationSurfaceOpen && ( @@ -3157,6 +3217,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); }}> + + +