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..e8f757fa --- /dev/null +++ b/.gitea/workflows/backend-quality-gate.yml @@ -0,0 +1,279 @@ +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: manman-linux + 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: Install and verify Linux runner toolchain + run: | + set -euo pipefail + packages=() + if ! docker compose version --short 2>/dev/null | grep -Eq '^v?2\.'; then + packages+=(docker-compose-v2) + fi + venv_probe="$(mktemp -d "${RUNNER_TEMP:-/tmp}/jyotisha-venv-probe.XXXXXX")" + if ! python3 -m venv "$venv_probe/venv" >/dev/null 2>&1; then + packages+=(python3-venv) + fi + rm -rf "$venv_probe" + if ! python3 -c 'import pathlib, sysconfig; assert pathlib.Path(sysconfig.get_path("include"), "Python.h").is_file()' >/dev/null 2>&1; then + packages+=(python3-dev) + fi + if ! command -v g++ >/dev/null 2>&1; then + packages+=(g++) + fi + if ! command -v rsync >/dev/null 2>&1; then + packages+=(rsync) + fi + if [ "${#packages[@]}" -gt 0 ]; then + apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 \ + -o Acquire::https::Timeout=30 update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + "${packages[@]}" + fi + python3 --version + curl --version + git --version + openssl version + docker version + docker compose version + docker compose version --short | grep -Eq '^v?2\.' + docker compose --help | grep -q -- '--project-name' + + - name: Prepare pinned Node tooling + env: + NODE_TOOL_SOURCE_IMAGE: swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/node:22-bookworm-slim@sha256:ef343465b6a14bbdf2ab52f6e100ec0659a792464fcf72c462370d88b3df909c + NODE_TOOL_IMAGE: node:22-bookworm-slim + run: | + set -euo pipefail + if ! docker image inspect "$NODE_TOOL_SOURCE_IMAGE" >/dev/null 2>&1; then + for attempt in 1 2 3; do + if timeout 180 docker pull "$NODE_TOOL_SOURCE_IMAGE"; then + break + fi + if [ "$attempt" -eq 3 ]; then + echo "Failed to preload $NODE_TOOL_IMAGE after $attempt attempts" >&2 + exit 1 + fi + sleep $((attempt * 15)) + done + fi + docker tag "$NODE_TOOL_SOURCE_IMAGE" "$NODE_TOOL_IMAGE" + docker image inspect "$NODE_TOOL_IMAGE" >/dev/null + tool_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/jyotisha-node-tools.XXXXXX")" + container_id="$(docker create "$NODE_TOOL_IMAGE")" + trap 'docker rm -f "$container_id" >/dev/null 2>&1 || true' EXIT + docker cp "$container_id:/usr/local/bin/node" "$tool_dir/node" + docker cp "$container_id:/usr/local/lib/node_modules/npm" "$tool_dir/npm-package" + docker rm "$container_id" >/dev/null + trap - EXIT + ln -s "$tool_dir/npm-package/bin/npm-cli.js" "$tool_dir/npm" + chmod 0755 "$tool_dir/node" "$tool_dir/npm-package/bin/npm-cli.js" + test -n "${GITHUB_PATH:-}" + printf '%s\n' "$tool_dir" >> "$GITHUB_PATH" + export PATH="$tool_dir:$PATH" + node --version | grep -Eq '^v22\.' + npm --version + + - name: Preload PostgreSQL integration image + env: + POSTGRES_TEST_SOURCE_IMAGE: public.ecr.aws/docker/library/postgres:17-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193 + POSTGRES_TEST_IMAGE: postgres:17-alpine + run: | + set -euo pipefail + if ! docker image inspect "$POSTGRES_TEST_IMAGE" >/dev/null 2>&1; then + for attempt in 1 2 3; do + if timeout 180 docker pull "$POSTGRES_TEST_SOURCE_IMAGE"; then + docker tag "$POSTGRES_TEST_SOURCE_IMAGE" "$POSTGRES_TEST_IMAGE" + break + fi + if [ "$attempt" -eq 3 ]; then + echo "Failed to preload $POSTGRES_TEST_IMAGE after $attempt attempts" >&2 + exit 1 + fi + sleep $((attempt * 15)) + done + fi + docker image inspect "$POSTGRES_TEST_IMAGE" >/dev/null + + - name: Install dependencies + env: + PIP_INDEX_URL: https://mirrors.aliyun.com/pypi/simple/ + NPM_CONFIG_REGISTRY: https://registry.npmmirror.com + run: | + set -euo pipefail + python3 -m venv .venv + export PATH="$PWD/.venv/bin:$PATH" + python -m pip install --upgrade pip + python -m pip install \ + "mcp==1.28.1" \ + "pydantic==2.13.4" \ + "numpy==2.5.1" \ + "pandas==2.3.3" \ + "timezonefinder==8.2.5" \ + -r requirements.txt -r requirements-dev.txt + 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: manman-linux + 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: Prepare pinned Node tooling + env: + NODE_TOOL_SOURCE_IMAGE: swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/node:22-bookworm-slim@sha256:ef343465b6a14bbdf2ab52f6e100ec0659a792464fcf72c462370d88b3df909c + NODE_TOOL_IMAGE: node:22-bookworm-slim + run: | + set -euo pipefail + if ! docker image inspect "$NODE_TOOL_SOURCE_IMAGE" >/dev/null 2>&1; then + for attempt in 1 2 3; do + if timeout 180 docker pull "$NODE_TOOL_SOURCE_IMAGE"; then + break + fi + if [ "$attempt" -eq 3 ]; then + echo "Failed to preload $NODE_TOOL_IMAGE after $attempt attempts" >&2 + exit 1 + fi + sleep $((attempt * 15)) + done + fi + docker tag "$NODE_TOOL_SOURCE_IMAGE" "$NODE_TOOL_IMAGE" + docker image inspect "$NODE_TOOL_IMAGE" >/dev/null + tool_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/jyotisha-node-tools.XXXXXX")" + cat > "$tool_dir/node" <<'EOF' + #!/usr/bin/env bash + set -euo pipefail + workdir="$(pwd -P)" + exec docker run --rm \ + --user "$(id -u):$(id -g)" \ + --volume "$workdir:$workdir" \ + --workdir "$workdir" \ + --env HOME=/tmp \ + node:22-bookworm-slim "${0##*/}" "$@" + EOF + chmod 0755 "$tool_dir/node" + ln -s node "$tool_dir/npm" + test -n "${GITHUB_PATH:-}" + printf '%s\n' "$tool_dir" >> "$GITHUB_PATH" + export PATH="$tool_dir:$PATH" + node --version + npm --version + + - 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..3f185d75 --- /dev/null +++ b/.gitea/workflows/deploy-staging.yml @@ -0,0 +1,228 @@ +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: manman-linux + 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: Prepare pinned Node tooling + env: + NODE_TOOL_SOURCE_IMAGE: swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/node:22-bookworm-slim@sha256:ef343465b6a14bbdf2ab52f6e100ec0659a792464fcf72c462370d88b3df909c + NODE_TOOL_IMAGE: node:22-bookworm-slim + run: | + set -euo pipefail + if ! docker image inspect "$NODE_TOOL_SOURCE_IMAGE" >/dev/null 2>&1; then + for attempt in 1 2 3; do + if timeout 180 docker pull "$NODE_TOOL_SOURCE_IMAGE"; then + break + fi + if [ "$attempt" -eq 3 ]; then + echo "Failed to preload $NODE_TOOL_IMAGE after $attempt attempts" >&2 + exit 1 + fi + sleep $((attempt * 15)) + done + fi + docker tag "$NODE_TOOL_SOURCE_IMAGE" "$NODE_TOOL_IMAGE" + docker image inspect "$NODE_TOOL_IMAGE" >/dev/null + tool_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/jyotisha-node-tools.XXXXXX")" + cat > "$tool_dir/node" <<'EOF' + #!/usr/bin/env bash + set -euo pipefail + workdir="$(pwd -P)" + exec docker run --rm \ + --user "$(id -u):$(id -g)" \ + --volume "$workdir:$workdir" \ + --workdir "$workdir" \ + --env HOME=/tmp \ + node:22-bookworm-slim "${0##*/}" "$@" + EOF + chmod 0755 "$tool_dir/node" + ln -s node "$tool_dir/npm" + test -n "${GITHUB_PATH:-}" + printf '%s\n' "$tool_dir" >> "$GITHUB_PATH" + export PATH="$tool_dir:$PATH" + node --version + npm --version + + - 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..32bb9f27 --- /dev/null +++ b/.gitea/workflows/migrate-staging-database.yml @@ -0,0 +1,198 @@ +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: manman-linux + 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: Prepare pinned Node tooling + env: + NODE_TOOL_SOURCE_IMAGE: swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/node:22-bookworm-slim@sha256:ef343465b6a14bbdf2ab52f6e100ec0659a792464fcf72c462370d88b3df909c + NODE_TOOL_IMAGE: node:22-bookworm-slim + run: | + set -euo pipefail + if ! docker image inspect "$NODE_TOOL_SOURCE_IMAGE" >/dev/null 2>&1; then + for attempt in 1 2 3; do + if timeout 180 docker pull "$NODE_TOOL_SOURCE_IMAGE"; then + break + fi + if [ "$attempt" -eq 3 ]; then + echo "Failed to preload $NODE_TOOL_IMAGE after $attempt attempts" >&2 + exit 1 + fi + sleep $((attempt * 15)) + done + fi + docker tag "$NODE_TOOL_SOURCE_IMAGE" "$NODE_TOOL_IMAGE" + docker image inspect "$NODE_TOOL_IMAGE" >/dev/null + tool_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/jyotisha-node-tools.XXXXXX")" + cat > "$tool_dir/node" <<'EOF' + #!/usr/bin/env bash + set -euo pipefail + workdir="$(pwd -P)" + exec docker run --rm \ + --user "$(id -u):$(id -g)" \ + --volume "$workdir:$workdir" \ + --workdir "$workdir" \ + --env HOME=/tmp \ + node:22-bookworm-slim "${0##*/}" "$@" + EOF + chmod 0755 "$tool_dir/node" + ln -s node "$tool_dir/npm" + test -n "${GITHUB_PATH:-}" + printf '%s\n' "$tool_dir" >> "$GITHUB_PATH" + export PATH="$tool_dir:$PATH" + node --version + npm --version + + - 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/.github/workflows/configure-staging-rectification-rollout.yml b/.github/workflows/configure-staging-rectification-rollout.yml new file mode 100644 index 00000000..a18fb072 --- /dev/null +++ b/.github/workflows/configure-staging-rectification-rollout.yml @@ -0,0 +1,92 @@ +name: Configure Staging Rectification Rollout + +on: + workflow_dispatch: + inputs: + expected_deploy_sha: + description: Exact 40-character SHA currently deployed to staging + required: true + type: string + audience: + description: New-case creation audience + required: true + default: paused + type: choice + options: + - paused + - smoke_only + - public + synthetic_smoke_user_ids: + description: Comma-separated canonical UUIDs; required only for smoke_only + required: false + type: string + +permissions: + contents: read + +concurrency: + group: staging-mutation + cancel-in-progress: false + +jobs: + configure: + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: staging + url: ${{ vars.STAGING_URL }} + env: + 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 }} + EXPECTED_DEPLOY_SHA: ${{ inputs.expected_deploy_sha }} + ROLLOUT_AUDIENCE: ${{ inputs.audience }} + SYNTHETIC_SMOKE_USER_IDS: ${{ inputs.synthetic_smoke_user_ids }} + + steps: + - name: Checkout trusted controller + uses: actions/checkout@v4 + with: + ref: main + persist-credentials: false + + - name: Validate rollout request and staging target + run: | + set -euo pipefail + [[ "$EXPECTED_DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] + case "$ROLLOUT_AUDIENCE" in paused|smoke_only|public) ;; *) exit 1 ;; esac + if [ "$ROLLOUT_AUDIENCE" = smoke_only ]; then + [[ "$SYNTHETIC_SMOKE_USER_IDS" =~ ^[0-9a-f-]{36}(,[0-9a-f-]{36})*$ ]] + else + test -z "$SYNTHETIC_SMOKE_USER_IDS" + fi + test "$DEPLOY_HOST" = "118.26.111.127" + test "$DEPLOY_PORT" = "22" + test "$DEPLOY_USER" = "deploy" + test "$DEPLOY_PATH" = "/opt/jyotisha-staging" + test "$STAGING_URL" = "https://staging.jyotisha.chat" + test -n "$STAGING_KNOWN_HOSTS" + bash -n deploy/configure-staging-rectification-rollout.sh + + - name: Configure pinned staging SSH + env: + SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} + run: | + set -euo pipefail + test -n "$SSH_PRIVATE_KEY" + install -d -m 700 ~/.ssh + printf '%s\n' "$SSH_PRIVATE_KEY" >~/.ssh/jyotisha-staging + chmod 600 ~/.ssh/jyotisha-staging + printf '%s\n' "$STAGING_KNOWN_HOSTS" >~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + + - name: Apply rollout under staging mutation lock + run: | + set -euo pipefail + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=10" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ + "DEPLOY_PATH='$DEPLOY_PATH' EXPECTED_DEPLOY_SHA='$EXPECTED_DEPLOY_SHA' ROLLOUT_AUDIENCE='$ROLLOUT_AUDIENCE' SYNTHETIC_SMOKE_USER_IDS='$SYNTHETIC_SMOKE_USER_IDS' STAGING_URL='$STAGING_URL' bash -s" \ + < deploy/configure-staging-rectification-rollout.sh diff --git a/.github/workflows/reset-staging-account.yml b/.github/workflows/reset-staging-account.yml new file mode 100644 index 00000000..bfc4af47 --- /dev/null +++ b/.github/workflows/reset-staging-account.yml @@ -0,0 +1,81 @@ +name: Reset Staging Account + +on: + workflow_dispatch: + inputs: + expected_deploy_sha: + description: Exact 40-character SHA currently deployed to staging + required: true + type: string + email: + description: Exact staging account email + required: true + type: string + confirmation: + description: Type RESET followed by a space and the exact email + required: true + type: string + +permissions: + contents: read + +concurrency: + group: staging-mutation + cancel-in-progress: false + +jobs: + reset: + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: staging + url: ${{ vars.STAGING_URL }} + env: + 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 }} + EXPECTED_DEPLOY_SHA: ${{ inputs.expected_deploy_sha }} + RESET_EMAIL: ${{ inputs.email }} + RESET_CONFIRMATION: ${{ inputs.confirmation }} + + steps: + - name: Checkout trusted controller + uses: actions/checkout@v4 + with: + ref: main + persist-credentials: false + + - name: Validate account reset request and staging target + run: | + set -euo pipefail + [[ "$EXPECTED_DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$RESET_EMAIL" =~ ^[[:alnum:]._%+-]+@[[:alnum:].-]+\.[[:alpha:]]{2,63}$ ]] + test "$RESET_CONFIRMATION" = "RESET $RESET_EMAIL" + test "$DEPLOY_HOST" = "118.26.111.127" + test "$DEPLOY_PORT" = "22" + test "$DEPLOY_USER" = "deploy" + test "$DEPLOY_PATH" = "/opt/jyotisha-staging" + test -n "$STAGING_KNOWN_HOSTS" + bash -n deploy/reset-staging-account.sh + + - name: Configure pinned staging SSH + env: + SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} + run: | + set -euo pipefail + test -n "$SSH_PRIVATE_KEY" + install -d -m 700 ~/.ssh + printf '%s\n' "$SSH_PRIVATE_KEY" >~/.ssh/jyotisha-staging + chmod 600 ~/.ssh/jyotisha-staging + printf '%s\n' "$STAGING_KNOWN_HOSTS" >~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + + - name: Reset one staging account under host lock + run: | + set -euo pipefail + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=10" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ + "DEPLOY_PATH='$DEPLOY_PATH' EXPECTED_DEPLOY_SHA='$EXPECTED_DEPLOY_SHA' RESET_EMAIL='$RESET_EMAIL' RESET_CONFIRMATION='$RESET_CONFIRMATION' bash -s" \ + < deploy/reset-staging-account.sh diff --git a/AGENTS.md b/AGENTS.md index b77987c7..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 @@ -153,3 +153,17 @@ Deployment safety rules: 4. 若当轮只能诊断或被阻塞,也要把已确认事实写成 `investigating` 或 `blocked`,不得编造根因或提前标记 `resolved`。 5. `resolved` 必须有与风险相称的证据:至少一个针对性回归测试;生产问题还必须有脱敏后的迁移、部署、健康检查或 smoke 证据。 6. Bug 历史严禁写入姓名、出生资料、邮箱、用户/案例 ID、Cookie、JWT、密码、密钥、完整请求体或模型原文。 + +## Agent skills + +### Issue tracker + +Issues and PRDs are tracked in this repository's GitHub Issues using the `gh` CLI. See `docs/agents/issue-tracker.md`. + +### Triage labels + +Triage uses the canonical `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, and `wontfix` labels. See `docs/agents/triage-labels.md`. + +### Domain docs + +Domain documentation uses the single-context layout. See `docs/agents/domain.md`. diff --git a/BLOCKED.md b/BLOCKED.md index dd472a6a..22905f0e 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -1,3 +1,5 @@ # BLOCKED - 真实收信端到端验收:执行环境没有可识别的 staging 测试邮箱/收件箱变量,仓库只记录发信配置而未提供受控测试邮箱。按任务硬规则不使用他人邮箱;代码、测试和部署继续,部署后的注册、验证码登录与忘记密码真实收信步骤待具备受控邮箱后补验。 +- PostgreSQL 事务反向测试:当前执行环境没有 `docker`、`postgres`、`initdb`、`psql`、Podman/Colima/Lima。`frontend/tests/admin-database.test.ts` 已实现审计触发器故意失败并断言兑换码行数仍为 0 的红灯证据,但本地执行在启动 fixture 前以 `spawnSync docker ENOENT` 阻塞;交由 exact-SHA staging quality gate 的 Docker 环境运行。全量 `npm test` 因同一缺失 Docker 共阻塞 11 项数据库/部署测试,另有 1 项既有真实 DOM 测试因缺 Playwright headless Chromium 阻塞;其余 1031 项通过,skipped/todo=0。 +- staging 两角色浏览器冒烟:已确认受控 admin 测试账号存在且是 `user,admin`,但当前执行环境没有其密码或已登录会话;也未提供受控 viewer 账号。不得读取/猜测凭据或使用他人账号。已完成匿名 shell、5 个资源 401、写请求 401 的服务端冒烟;admin/viewer 登录后浏览器冒烟待授权人员提供受控会话后补验。 diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..e34f2c1c --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,49 @@ +# Jyotisha 产品领域 + +本上下文定义 Jyotisha Agent 对话、回复质量与后台排错所使用的统一业务语言。 + +## Language + +**Agent 会话**: +用户与某一种 Jyotisha Agent 持续交互的容器,例如普通咨询或出生时间校正。 +_避免使用_:聊天记录、咨询(用于泛指所有 Agent 场景时) + +**Agent 对话轮次**: +在 Agent 会话中,从用户输入触发 Agent 生成一条回复开始,到 Agent 完整回复或该次回复失败为止的一次交互。 +_避免使用_:单条消息、一轮对话 + +**Agent 执行尝试**: +使用独立请求标识执行一个 Agent 对话轮次的一次尝试;重试同一轮次会产生新的尝试。 +_避免使用_:重复消息、同一请求 + +**Agent 执行故障**: +Agent 执行尝试因技术异常未能正常产出完整回复。未登录、余额不足和参数不合法等预期业务拒绝不属于执行故障。 +_避免使用_:所有失败请求、报错 + +**未完成回复**: +Agent 执行故障发生前已经展示给用户、但未正常结束的 Agent 输出。 +_避免使用_:正常回复、可评价回复 + +**故障诊断摘要**: +面向管理员的结构化脱敏故障说明,可用于定位执行阶段和失败类型,但不包含敏感原始诊断内容。 +_避免使用_:原始异常、完整日志 + +**故障上下文快照**: +为排查 Agent 执行故障而保留的故障轮次及该次执行实际使用的近期上下文,不等同于完整会话副本。 +_避免使用_:完整聊天记录、错误消息 + +**回复评价**: +用户针对一条完整 Agent 回复提交的当前正向或负向质量判断。评价属于具体回复,而不是整个 Agent 会话。 +_避免使用_:会话评分、点赞记录 + +**不满意原因**: +负向回复评价附带的一个或多个原因分类,可包含用户补充说明。 +_避免使用_:投诉、差评文本 + +**对话质量记录**: +管理后台中供管理员排查或审阅的一项 Agent 执行故障或负向回复评价。 +_避免使用_:聊天日志、客服工单 + +**处理状态**: +对话质量记录的内部处理进度,取值为待处理、处理中、已解决或忽略。 +_避免使用_:用户反馈状态、通知状态 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 c14ce6c3..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,23 @@ 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. Application rollback uses the same workflow: manually dispatch `Deploy staging` from the `main` controller with a previous known-good full SHA that has a successful `Staging Backend Quality Gate` run, and explicitly set `allow_rollback=true`. Normal and migration-triggered deployments reject stale, divergent, or backward revisions. Rollback still consumes the selected gate run's digest manifest and is supported only during that artifact's 30-day retention window; after expiry, stop and prepare a separately reviewed republish/recovery change rather than substituting a mutable tag or assuming the old run can still be rerun. Database migrations are separate and are not rolled back by an application deployment. Restore a staging database backup before running any destructive migration rehearsal. @@ -250,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. @@ -404,11 +414,14 @@ previous-revision smoke SHA must remain pending. If the create flag, migration flag, deployment SHA, or strict UUID allowlist is invalid, creation audience must be `paused`, including for the smoke account. -After the smoke sequence below passes, set +After the smoke sequence below passes, use the guarded rollout workflow to set `RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA` to the exact deployed 40-character -lowercase Git SHA, remove `RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS`, and -restart the web container. Then fetch health again and -verify all of the following against the revision that passed validation: +lowercase Git SHA, remove `RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS`, enable +`RECTIFICATION_AGENT_V5_ENABLED=true`, disable shadow mode, set the canary to +100 percent, and restart both the web and rectification worker containers. The +workflow writes these selectors together so public Case creation cannot silently +fall back to the fixed `v4_legacy` projector. Then fetch health again and verify +all of the following against the revision that passed validation: - `deployment.gitCommit` exactly equals the tested 40-character Git SHA; - `rollout.conversationalRectificationV3.protocol` is @@ -432,9 +445,9 @@ sequence. A plain HTTP `200` is not substitute evidence: event, then a clear event. Verify the ambiguous/future facts do not score. 4. Pause, reload, and resume from a second authenticated browser session. Verify no second rectification charge. -5. Reach a candidate, verify the prior active time is still in force, reject a - mismatched candidate confirmation, then explicitly confirm the exact - candidate. Verify the time changes atomically. +5. Reach a stable candidate range and verify the prior active time remains in + force. Confirm that no exact minute can be accepted and that rectification + does not write `profiles.active_birth_time`. 6. Explicitly continue the saved ordinary question. Verify one normal consultation reservation. Delete its chat and verify the account case still resumes/loads. diff --git a/deploy/configure-staging-rectification-rollout.sh b/deploy/configure-staging-rectification-rollout.sh new file mode 100755 index 00000000..10ef3fd6 --- /dev/null +++ b/deploy/configure-staging-rectification-rollout.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +set -euo pipefail +set +x + +required=(DEPLOY_PATH EXPECTED_DEPLOY_SHA ROLLOUT_AUDIENCE STAGING_URL) +for key in "${required[@]}"; do + if [ -z "${!key:-}" ]; then + echo "required staging rollout input is missing: $key" >&2 + exit 1 + fi +done + +sha_pattern='^[0-9a-f]{40}$' +uuid_pattern='^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' +[[ "$EXPECTED_DEPLOY_SHA" =~ $sha_pattern ]] || { + echo "invalid expected deployment SHA" >&2 + exit 1 +} +case "$ROLLOUT_AUDIENCE" in + paused|smoke_only|public) ;; + *) echo "invalid rollout audience" >&2; exit 1 ;; +esac + +smoke_user_ids="${SYNTHETIC_SMOKE_USER_IDS:-}" +if [ "$ROLLOUT_AUDIENCE" = "smoke_only" ]; then + [ -n "$smoke_user_ids" ] || { + echo "smoke_only requires at least one synthetic user UUID" >&2 + exit 1 + } + IFS=',' read -ra smoke_users <<<"$smoke_user_ids" + for user_id in "${smoke_users[@]}"; do + [[ "$user_id" =~ $uuid_pattern ]] || { + echo "invalid synthetic smoke user UUID" >&2 + exit 1 + } + done +else + [ -z "$smoke_user_ids" ] || { + echo "synthetic smoke users are only valid for smoke_only" >&2 + exit 1 + } +fi + +state_directory="$DEPLOY_PATH/.state" +env_file="$DEPLOY_PATH/.env.staging" +install -d -m 700 "$state_directory" +exec 9>"$state_directory/mutation.lock" +flock -n 9 || { + echo "another staging mutation holds the host lock" >&2 + exit 75 +} + +compose_files=( + -f deploy/docker-compose.server.yml + -f deploy/docker-compose.postgres.yml + -f deploy/docker-compose.staging.yml +) + +[ -f "$env_file" ] || { + echo "staging environment file is missing" >&2 + exit 1 +} +current_sha="$(<"$state_directory/deployed-revision")" +[ "$current_sha" = "$EXPECTED_DEPLOY_SHA" ] || { + echo "deployed staging revision does not match the approved rollout SHA" >&2 + exit 1 +} + +case "$ROLLOUT_AUDIENCE" in + public) + creation_enabled=true + smoke_sha="$EXPECTED_DEPLOY_SHA" + smoke_user_ids="" + ;; + smoke_only) + creation_enabled=true + smoke_sha="" + ;; + paused) + creation_enabled=false + smoke_sha="" + smoke_user_ids="" + ;; +esac + +backup="$(mktemp "$state_directory/rectification-rollout-backup.XXXXXX")" +temporary="$(mktemp "$DEPLOY_PATH/.env.staging.rollout.XXXXXX")" +declare -a compose=() +cleanup() { rm -f -- "$backup" "$temporary"; } +rollback() { + local status=$? + cp -p -- "$backup" "$env_file" + if [ "${#compose[@]}" -gt 0 ]; then + "${compose[@]}" up -d --no-build --pull never --force-recreate --no-deps web rectification-v4-worker >/dev/null 2>&1 || true + fi + exit "$status" +} +trap cleanup EXIT +cp -p -- "$env_file" "$backup" + +awk \ + -v create="$creation_enabled" \ + -v migrations="true" \ + -v smoke_sha="$smoke_sha" \ + -v smoke_users="$smoke_user_ids" \ + -v agent_enabled="$creation_enabled" ' +BEGIN { + values["RECTIFICATION_V3_CREATE_ENABLED"] = create + values["RECTIFICATION_V3_MIGRATIONS_READY"] = migrations + values["RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA"] = smoke_sha + values["RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS"] = smoke_users + values["RECTIFICATION_AGENT_V5_ENABLED"] = agent_enabled + values["RECTIFICATION_AGENT_V5_SHADOW"] = "false" + values["RECTIFICATION_AGENT_V5_CANARY_PERCENT"] = "100" +} +{ + split($0, parts, "=") + key = parts[1] + if (key in values) { + if (!(key in written)) print key "=" values[key] + written[key] = 1 + next + } + print +} +END { + for (key in values) if (!(key in written)) print key "=" values[key] +} +' "$env_file" >"$temporary" +chmod 600 "$temporary" + +cd "$DEPLOY_PATH" +bash deploy/validate-staging-env.sh "$temporary" staging.jyotisha.chat deploy/Caddyfile.staging +mv -f -- "$temporary" "$env_file" +trap rollback ERR + +web_container="$(docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=web' | head -n 1)" +[ -n "$web_container" ] || { + echo "staging web container is missing" >&2 + false +} +export WEB_IMAGE="$(docker inspect --format '{{.Config.Image}}' "$web_container")" +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 GITHUB_SHA="$EXPECTED_DEPLOY_SHA" +compose=(docker compose -p jyotisha-staging --env-file .env.staging "${compose_files[@]}") + +"${compose[@]}" config --quiet +"${compose[@]}" up -d --no-build --pull never --force-recreate --no-deps web rectification-v4-worker + +for service in web rectification-v4-worker; do + container="$(docker ps -q --filter 'label=com.docker.compose.project=jyotisha-staging' --filter "label=com.docker.compose.service=$service" | head -n 1)" + [ -n "$container" ] || { + echo "staging $service container is missing after rollout" >&2 + false + } + runtime_env="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$container")" + grep -Fxq "RECTIFICATION_AGENT_V5_ENABLED=$creation_enabled" <<<"$runtime_env" + grep -Fxq "RECTIFICATION_AGENT_V5_SHADOW=false" <<<"$runtime_env" + grep -Fxq "RECTIFICATION_AGENT_V5_CANARY_PERCENT=100" <<<"$runtime_env" +done + +health="" +for _ in $(seq 1 30); do + health="$(curl --fail --silent --show-error "$STAGING_URL/api/health" 2>/dev/null || true)" + expected_ready=false + [ "$ROLLOUT_AUDIENCE" = public ] && expected_ready=true + if grep -Fq "\"gitCommit\":\"$EXPECTED_DEPLOY_SHA\"" <<<"$health" && + grep -Fq "\"creationAudience\":\"$ROLLOUT_AUDIENCE\"" <<<"$health" && + grep -Fq "\"readyForNewCases\":$expected_ready" <<<"$health"; then + trap - ERR + printf 'rectification rollout audience=%s deployed_sha=%s ready_for_new_cases=%s\n' \ + "$ROLLOUT_AUDIENCE" "$EXPECTED_DEPLOY_SHA" "$([ "$ROLLOUT_AUDIENCE" = public ] && echo true || echo false)" + exit 0 + fi + sleep 2 +done + +echo "staging rollout health verification failed" >&2 +false 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/reset-staging-account.sh b/deploy/reset-staging-account.sh new file mode 100755 index 00000000..844a4050 --- /dev/null +++ b/deploy/reset-staging-account.sh @@ -0,0 +1,290 @@ +#!/usr/bin/env bash +set -euo pipefail +set +x + +required=(DEPLOY_PATH EXPECTED_DEPLOY_SHA RESET_EMAIL RESET_CONFIRMATION) +for key in "${required[@]}"; do + if [ -z "${!key:-}" ]; then + echo "required staging account-reset input is missing: $key" >&2 + exit 1 + fi +done + +[[ "$EXPECTED_DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] || { + echo "invalid expected deployment SHA" >&2 + exit 1 +} +[[ "$RESET_EMAIL" =~ ^[[:alnum:]._%+-]+@[[:alnum:].-]+\.[[:alpha:]]{2,63}$ ]] || { + echo "invalid reset email" >&2 + exit 1 +} +[ "$RESET_CONFIRMATION" = "RESET $RESET_EMAIL" ] || { + echo "account reset confirmation does not match" >&2 + exit 1 +} +[ "$DEPLOY_PATH" = "/opt/jyotisha-staging" ] || { + echo "refusing non-staging deployment path" >&2 + exit 1 +} + +state_directory="$DEPLOY_PATH/.state" +[ -f "$state_directory/deployed-revision" ] || { + echo "staging deployed revision is unavailable" >&2 + exit 1 +} +[ "$(<"$state_directory/deployed-revision")" = "$EXPECTED_DEPLOY_SHA" ] || { + echo "deployed staging revision does not match the approved reset SHA" >&2 + exit 1 +} + +install -d -m 700 "$state_directory" +exec 9>"$state_directory/mutation.lock" +flock -n 9 || { + echo "another staging mutation holds the host lock" >&2 + exit 75 +} + +cd "$DEPLOY_PATH" +compose=(docker compose -p jyotisha-staging -f deploy/docker-compose.postgres.yml) +"${compose[@]}" ps --status running postgres --quiet | grep -q . || { + echo "staging postgres container is not running" >&2 + exit 1 +} + +run_psql() { + "${compose[@]}" exec -T -e RESET_EMAIL="$RESET_EMAIL" postgres sh -ceu ' + exec psql -X -v ON_ERROR_STOP=1 -v target_email="$RESET_EMAIL" \ + -U "$POSTGRES_USER" -d "$POSTGRES_DB" + ' +} + +run_psql <<'SQL' +begin; + +create temporary table reset_snapshot on commit drop as +select + identity_user.id, + identity_user.email, + profile.email as profile_email, + profile.credits, + (select count(*) from identity.accounts value where value.user_id = identity_user.id) as identity_accounts, + (select count(*) from identity.sessions value where value.user_id = identity_user.id) as identity_sessions, + (select count(*) from public.credit_transactions value where value.user_id = identity_user.id) as credit_transactions, + (select count(*) from public.credit_request_cancellations value where value.user_id = identity_user.id) as credit_cancellations, + (select count(*) from public.consultation_requests value where value.user_id = identity_user.id) as consultation_requests, + (select count(*) from public.birth_time_rectification_billing value where value.user_id = identity_user.id) as rectification_billing, + (select count(*) from public.birth_time_rectification_action_receipts value where value.user_id = identity_user.id) as action_receipts, + (select count(*) from public.redemption_codes value where value.redeemed_by = identity_user.id) as redeemed_codes, + (select count(*) from audit.admin_audit_logs value where value.actor_user_id = identity_user.id) as admin_audit_logs +from identity.users identity_user +join auth.users auth_user on auth_user.id = identity_user.id +join public.profiles profile on profile.id = identity_user.id +where lower(btrim(identity_user.email)) = lower(btrim(:'target_email')) + and lower(btrim(auth_user.email)) = lower(btrim(:'target_email')) +for update of identity_user, auth_user, profile; + +do $$ +begin + if (select count(*) from reset_snapshot) <> 1 then + raise exception 'account_not_found_or_identity_bridge_mismatch'; + end if; +end $$; + +select jsonb_build_object( + 'stage', 'preflight', + 'email', snapshot.email, + 'credits', snapshot.credits, + 'identityAccounts', snapshot.identity_accounts, + 'identitySessions', snapshot.identity_sessions, + 'creditTransactions', snapshot.credit_transactions, + 'creditCancellations', snapshot.credit_cancellations, + 'consultationRequests', snapshot.consultation_requests, + 'rectificationBilling', snapshot.rectification_billing, + 'actionReceipts', snapshot.action_receipts, + 'redeemedCodes', snapshot.redeemed_codes, + 'adminAuditLogs', snapshot.admin_audit_logs, + 'chatSessions', (select count(*) from public.chat_sessions value where value.user_id = snapshot.id), + 'chartProfiles', (select count(*) from public.chart_profiles value where value.user_id = snapshot.id), + 'synastryReports', (select count(*) from public.synastry_reports value where value.user_id = snapshot.id), + 'legacyRectificationCases', (select count(*) from public.birth_time_rectification_cases value where value.user_id = snapshot.id), + 'v5RectificationCases', (select count(*) from public.birth_time_rectification_v4_cases value where value.user_id = snapshot.id), + 'v5AgentRuns', (select count(*) from public.birth_time_rectification_agent_runs value where value.user_id = snapshot.id), + 'v5Diagnostics', (select count(*) from public.birth_time_rectification_diagnostics value where value.user_id = snapshot.id), + 'v5Jobs', (select count(*) from public.birth_time_rectification_v4_jobs value where value.user_id = snapshot.id) +) +from reset_snapshot snapshot; + +update public.profiles profile +set name = null, + birth_date = null, + birth_time = null, + country_code = null, + province_code = null, + city_code = null, + district_code = null, + onboarding_payload = null, + onboarding_version = null, + onboarding_generated_at = null, + latitude = null, + longitude = null, + timezone_offset = null, + reported_birth_time = null, + active_birth_time = null, + birth_time_source = null, + birth_time_period = null, + birth_time_clue = null, + uncertainty_before_minutes = null, + uncertainty_after_minutes = null, + birth_time_status = null, + rectification_confidence = null, + rectification_case_id = null, + birth_place_label = null, + birth_place_type = null, + birth_place_provider = null, + birth_place_provider_id = null, + timezone_id = null, + timezone_source = null, + updated_at = pg_catalog.now() +from reset_snapshot snapshot +where profile.id = snapshot.id; + +delete from public.chat_sessions value using reset_snapshot snapshot where value.user_id = snapshot.id; +delete from public.chart_profiles value using reset_snapshot snapshot where value.user_id = snapshot.id; +delete from public.synastry_reports value using reset_snapshot snapshot where value.user_id = snapshot.id; +delete from public.birth_time_rectification_v4_cases value using reset_snapshot snapshot where value.user_id = snapshot.id; +delete from public.birth_time_rectification_cases value using reset_snapshot snapshot where value.user_id = snapshot.id; + +do $$ +begin + if exists ( + select 1 + from reset_snapshot snapshot + join identity.users identity_user on identity_user.id = snapshot.id + join auth.users auth_user on auth_user.id = snapshot.id + join public.profiles profile on profile.id = snapshot.id + where identity_user.email is distinct from snapshot.email + or auth_user.email is distinct from snapshot.email + or profile.email is distinct from snapshot.profile_email + or profile.credits is distinct from snapshot.credits + or (select count(*) from identity.accounts value where value.user_id = snapshot.id) <> snapshot.identity_accounts + or (select count(*) from identity.sessions value where value.user_id = snapshot.id) <> snapshot.identity_sessions + or (select count(*) from public.credit_transactions value where value.user_id = snapshot.id) <> snapshot.credit_transactions + or (select count(*) from public.credit_request_cancellations value where value.user_id = snapshot.id) <> snapshot.credit_cancellations + or (select count(*) from public.consultation_requests value where value.user_id = snapshot.id) <> snapshot.consultation_requests + or (select count(*) from public.birth_time_rectification_billing value where value.user_id = snapshot.id) <> snapshot.rectification_billing + or (select count(*) from public.birth_time_rectification_action_receipts value where value.user_id = snapshot.id) <> snapshot.action_receipts + or (select count(*) from public.redemption_codes value where value.redeemed_by = snapshot.id) <> snapshot.redeemed_codes + or (select count(*) from audit.admin_audit_logs value where value.actor_user_id = snapshot.id) <> snapshot.admin_audit_logs + ) then + raise exception 'preserved_state_changed'; + end if; + + if exists (select 1 from public.chat_sessions value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.chart_profiles value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.synastry_reports value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.birth_time_rectification_cases value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.birth_time_rectification_v4_cases value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.birth_time_rectification_v4_jobs value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.birth_time_rectification_agent_runs value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.birth_time_rectification_diagnostics value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.birth_time_rectification_candidate_feature_snapshots value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.birth_time_rectification_public_messages value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.birth_time_rectification_pending_evidence value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists ( + select 1 from public.profiles profile join reset_snapshot snapshot on profile.id = snapshot.id + where profile.name is not null or profile.birth_date is not null or profile.birth_time is not null + or profile.country_code is not null or profile.province_code is not null or profile.city_code is not null or profile.district_code is not null + or profile.onboarding_payload is not null or profile.onboarding_version is not null or profile.onboarding_generated_at is not null + or profile.latitude is not null or profile.longitude is not null or profile.timezone_offset is not null + or profile.reported_birth_time is not null or profile.active_birth_time is not null or profile.birth_time_source is not null + or profile.birth_time_period is not null or profile.birth_time_clue is not null + or profile.uncertainty_before_minutes is not null or profile.uncertainty_after_minutes is not null + or profile.birth_time_status is not null or profile.rectification_confidence is not null or profile.rectification_case_id is not null + or profile.birth_place_label is not null or profile.birth_place_type is not null or profile.birth_place_provider is not null + or profile.birth_place_provider_id is not null or profile.timezone_id is not null or profile.timezone_source is not null + ) then + raise exception 'reset_state_not_empty'; + end if; +end $$; + +commit; +SQL + +run_psql <<'SQL' +begin; + +create temporary table postflight_target on commit drop as +select identity_user.id, identity_user.email, profile.credits, + not ( + profile.name is null and profile.birth_date is null and profile.birth_time is null + and profile.country_code is null and profile.province_code is null and profile.city_code is null and profile.district_code is null + and profile.onboarding_payload is null and profile.onboarding_version is null and profile.onboarding_generated_at is null + and profile.latitude is null and profile.longitude is null and profile.timezone_offset is null + and profile.reported_birth_time is null and profile.active_birth_time is null and profile.birth_time_source is null + and profile.birth_time_period is null and profile.birth_time_clue is null + and profile.uncertainty_before_minutes is null and profile.uncertainty_after_minutes is null + and profile.birth_time_status is null and profile.rectification_confidence is null and profile.rectification_case_id is null + and profile.birth_place_label is null and profile.birth_place_type is null and profile.birth_place_provider is null + and profile.birth_place_provider_id is null and profile.timezone_id is null and profile.timezone_source is null + ) as profile_not_reset, + lower(btrim(profile.email)) = lower(btrim(identity_user.email)) as profile_email_matches +from identity.users identity_user +join auth.users auth_user on auth_user.id = identity_user.id + and lower(btrim(auth_user.email)) = lower(btrim(identity_user.email)) +join public.profiles profile on profile.id = identity_user.id +where lower(btrim(identity_user.email)) = lower(btrim(:'target_email')); + +do $$ +declare + target_id uuid; +begin + if (select count(*) from postflight_target) <> 1 then + raise exception 'postflight_account_not_found_or_identity_bridge_mismatch'; + end if; + select id into target_id from postflight_target; + + if exists (select 1 from public.chat_sessions value where value.user_id = target_id) + or exists (select 1 from public.chart_profiles value where value.user_id = target_id) + or exists (select 1 from public.synastry_reports value where value.user_id = target_id) + or exists (select 1 from public.birth_time_rectification_cases value where value.user_id = target_id) + or exists (select 1 from public.birth_time_rectification_v4_cases value where value.user_id = target_id) + or exists (select 1 from public.birth_time_rectification_v4_jobs value where value.user_id = target_id) + or exists (select 1 from public.birth_time_rectification_agent_runs value where value.user_id = target_id) + or exists (select 1 from public.birth_time_rectification_diagnostics value where value.user_id = target_id) + or exists (select 1 from public.birth_time_rectification_candidate_feature_snapshots value where value.user_id = target_id) + or exists (select 1 from public.birth_time_rectification_public_messages value where value.user_id = target_id) + or exists (select 1 from public.birth_time_rectification_pending_evidence value where value.user_id = target_id) + or exists (select 1 from postflight_target where profile_not_reset) then + raise exception 'postflight_reset_state_not_empty'; + end if; +end $$; + +select jsonb_build_object( + 'stage', 'postflight', + 'matchedAccounts', (select count(*) from postflight_target), + 'email', (select email from postflight_target), + 'credits', (select credits from postflight_target), + 'profileEmailMatches', (select profile_email_matches from postflight_target), + 'profileNotReset', (select profile_not_reset from postflight_target), + 'chatSessions', (select count(*) from public.chat_sessions value where value.user_id = (select id from postflight_target)), + 'chartProfiles', (select count(*) from public.chart_profiles value where value.user_id = (select id from postflight_target)), + 'synastryReports', (select count(*) from public.synastry_reports value where value.user_id = (select id from postflight_target)), + 'legacyRectificationCases', (select count(*) from public.birth_time_rectification_cases value where value.user_id = (select id from postflight_target)), + 'v5RectificationCases', (select count(*) from public.birth_time_rectification_v4_cases value where value.user_id = (select id from postflight_target)), + 'v5Jobs', (select count(*) from public.birth_time_rectification_v4_jobs value where value.user_id = (select id from postflight_target)), + 'v5AgentRuns', (select count(*) from public.birth_time_rectification_agent_runs value where value.user_id = (select id from postflight_target)), + 'v5Diagnostics', (select count(*) from public.birth_time_rectification_diagnostics value where value.user_id = (select id from postflight_target)), + 'v5FeatureSnapshots', (select count(*) from public.birth_time_rectification_candidate_feature_snapshots value where value.user_id = (select id from postflight_target)), + 'v5PublicMessages', (select count(*) from public.birth_time_rectification_public_messages value where value.user_id = (select id from postflight_target)), + 'v5PendingEvidence', (select count(*) from public.birth_time_rectification_pending_evidence value where value.user_id = (select id from postflight_target)), + 'identityAccounts', (select count(*) from identity.accounts value where value.user_id = (select id from postflight_target)), + 'identitySessions', (select count(*) from identity.sessions value where value.user_id = (select id from postflight_target)), + 'creditTransactions', (select count(*) from public.credit_transactions value where value.user_id = (select id from postflight_target)), + 'creditCancellations', (select count(*) from public.credit_request_cancellations value where value.user_id = (select id from postflight_target)), + 'consultationRequests', (select count(*) from public.consultation_requests value where value.user_id = (select id from postflight_target)), + 'rectificationBilling', (select count(*) from public.birth_time_rectification_billing value where value.user_id = (select id from postflight_target)), + 'actionReceipts', (select count(*) from public.birth_time_rectification_action_receipts value where value.user_id = (select id from postflight_target)) +); + +commit; +SQL 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/sync-staging-tree.sh b/deploy/sync-staging-tree.sh index 005766fa..ccda6697 100755 --- a/deploy/sync-staging-tree.sh +++ b/deploy/sync-staging-tree.sh @@ -6,7 +6,16 @@ if [ "$#" -ne 2 ] || [ ! -d "$1" ] || [ ! -d "$2" ]; then exit 1 fi -rsync -az --delete \ +destination_deploy="$2/deploy" +if [ -d "$destination_deploy" ]; then + docker run --rm --pull never --network none --read-only --user 0:0 \ + --cap-drop ALL --cap-add CHOWN --security-opt no-new-privileges \ + -v "$destination_deploy:/destination" postgres:17-alpine \ + chown -R "$(id -u):$(id -g)" /destination + chmod -R u+rwX "$destination_deploy" +fi + +rsync -az --delete --no-owner --no-group \ --exclude='/.git/' \ --exclude='/.env*' \ --exclude='/.docker/' \ 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 657c8eac..ea97b5ab 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1553,28 +1553,611 @@ - 状态:investigating - 首次发现:2026-07-27 -- 最近更新:2026-07-27 -- 影响面:生时校正 V4 聊天界面、历史恢复、模型选择、下一问规划与 staging 验收 +- 最近更新:2026-07-28 +- 影响面:生时校正聊天 Surface、事件语义、后台 Job、候选计算、诊断、Reasoner、Renderer 与持久化主链 - 用户现象:进入生时校正后看到独立的校正面板、证据区域和固定问题;交互不像普通 session,领域也不再根据用户刚讲的经历动态选择。 -- 触发条件:V4 页面入口渲染旧式 `RectificationV4Panel` 视觉结构,页面给会话容器添加 `is-rectification`,同时问题规划器按硬编码领域顺序和模板生成下一问。 -- 根因:组件 wrapper 无条件绕过原普通聊天 Surface;普通 session CSS 又显式排除 `is-rectification`;`question-planner.ts` 把教育、迁移、关系、事业、财务、健康压力和家庭写成固定顺序与固定文案,测试还把这些实现细节当成产品合同。 -- 修复:V4 复用普通 session 的消息列表、输入框和模型选择器,并从持久化 turns 恢复完整对话;回答时原子保存所选模型 ID,Worker 将完整 turns、事件台账、日期精度、已追问事件与候选范围交给模型动态生成下一问。确定性 planner 只保留日期修订和开放叙述降级,不再轮询领域或输出固定问卷;候选范围仍不得表述为已确认出生分钟。 -- 验证:聚焦 V4/domain/service/replay/handoff/migration、普通 session UI 合同和 consultation entrypoint 共 59 个测试通过;staging 构建、迁移和登录态 smoke 完成后更新为 resolved 并填写精确提交与部署 SHA。 -- 防复发:可见生时校正必须复用普通聊天 Surface;测试应锁定自然语言消息、turn 恢复、模型 ID 传递和无固定领域控件,不得锁定领域顺序或问题模板。模型只负责选择和表达下一条高信息量问题,证据修订、评分、稳定性门、范围接受、handoff 与扣费继续由确定性后端负责。 -- 相关记录:BUG-020、BUG-075、BUG-080、BUG-081、BUG-082、BUG-083、BUG-084 -- 修复版本:待提交(staging 验收中) +- 触发条件:旧 V4 既在界面层使用独立校正结构,又让 `question-planner.ts` 和 `question-author.ts` 直接决定领域顺序与问题文案;模型只负责写下一问,后台没有形成完整 Agent 决策闭环。 +- 根因:产品状态被压缩成“下一问字符串”,事件语义、候选特征、诊断结果、问题机会、模型决策和公开消息之间没有受约束的 durable contract;因此即使替换提示词,系统仍会沿用问卷式控制流,且无法审计模型为何选题或安全重放已完成 Job。 +- 修复:删除旧 `question-planner.ts` 与 `question-author.ts`,将回答处理重构为完整 V5 主链:保存回答并创建后台 Job → Evidence Reconciliation → Candidate Engine / Feature Snapshot → Diagnostics → Opportunity Builder → Bounded Reasoner → Decision Validator → Renderer → Atomic Job Completion。可见层继续复用普通 session 聊天 Surface;Reasoner 只能选择服务端生成的 opportunity 或受约束动作,不能注入分钟、分数、事件或任意问题;Renderer 只表达已验证决定,候选范围不得表述为已确认出生分钟。Agent Run、Public Message、Diagnostics、Feature Snapshot、Pending Evidence 和事件修订均作为一等产物持久化。 +- 验证:67 个 TypeScript 聚焦合同全部通过,覆盖普通 session UI、完整 V5 artifact chain、Reasoner 单次诊断预算、Opportunity 选择、shadow/legacy 隔离和 range-only 输出;7 个 Python 服务合同通过。真实 PostgreSQL 14 已按 V4 → V5 顺序完成 migration dry-run,并跑通 `processing → reasoning → rendering → complete`、五类 artifact 各一条落库和 completed Job 幂等重放。`tsc --noEmit` 未出现 V5 新错误,只剩 `birth-time-journey-engine`、`identity-auth-integration`、`onboarding-route` 三处无关基线错误。当前完成边界为本地可测,尚未提交、推送、迁移 staging 或执行登录态 smoke。 +- 防复发:生时校正不得再次把模型降级为“问题文案生成器”;所有可见动作必须来自 server-owned opportunity,经 bounded reasoner、decision validator 和 renderer 后原子持久化。测试必须同时锁定 legacy/shadow 隔离、artifact 完整性、候选范围边界和 completed-job replay 指纹。 +- 相关记录:BUG-020、BUG-075、BUG-080、BUG-081、BUG-082、BUG-083、BUG-084、BUG-086 +- 修复版本:本地 V5 重构,待提交与 staging 验收 ## BUG-086 | 模型下一问可绕过当前事件而跳成领域问卷 - 状态:investigating - 首次发现:2026-07-27 -- 最近更新:2026-07-27 -- 影响面:生时校正 V4 的模型提问规划、事件日期补全和 staging 对话体验 +- 最近更新:2026-07-28 +- 影响面:生时校正 V5 的当前事件延续、问题机会构建、诊断工具预算、模型决策验证和 Job replay - 用户现象:用户回答“2016 年离家去外地上大学”后,下一问直接变成“请说一次影响较大的搬家或长期迁居”,看起来仍按“升学 → 搬家”模板轮询,而没有承接刚才的具体经历。 -- 触发条件:最新可评分事件只有年份精度,但模型返回新的领域和空 `targetEventId`;Worker 直接接受格式合法的模型结果。 -- 根因:模型提示虽然要求优先延续当前事件,但 Worker 只校验了输出结构,没有把确定性 planner 识别出的必要日期补全当作服务端路由约束;因此模型可越过仍缺月份的当前事件。旧测试只证明模型拿到了完整上下文,没有覆盖模型违反路由建议的情况。 -- 修复:planner 将月份视为足够的首选精度;年份、季度或范围精度仍产生必要的当前事件补全。问题作者收到 `requiredContinuation`,必须围绕该事件自然追问月份或日期;Worker 在信任边界拒绝模型切换事件或领域,并回退到同一事件的开放式日期追问。当前事件达到月份精度后,模型才可根据上下文自由选择下一条高信息量问题,不设领域顺序。 -- 验证:新增用户原句回归,模拟模型错误返回搬家问题,断言 Worker 仍追问“离家去外地上大学”的月份且不出现搬家模板;同时锁定月份精度后模型可自由选题。聚焦 domain/service/replay 共 18 个测试通过;staging 部署与真实登录态 smoke 完成后更新状态。 -- 防复发:模型可以表达和选择下一题,但不能绕过服务端判定的当前事件必要补全;测试必须包含“模型输出合法但路由错误”的对抗用例,不能只测 happy path。 +- 触发条件:当前事件仍缺必要精度,但旧 Worker 只校验模型返回结构;只要模型输出一个格式合法的新领域问题,就可以绕过当前事件和服务端已知证据缺口。 +- 根因:旧方案把“required continuation”作为给模型的提示,而不是服务器拥有的候选动作和最终决策约束;诊断结果也没有独立工具预算、持久化产物和可回放选择依据,无法阻止合法 JSON 携带错误业务路由。 +- 修复:Opportunity Builder 将未解决的当前目标设为独占路由,并只发布带稳定 ID、目标事件、效用分解和隐私成本的问题机会;Bounded Reasoner 最多执行一次只读诊断,最终只能选择活动 opportunity 或受限状态动作;Decision Validator 拒绝不存在、跨 Case、非活动或越权的机会,也禁止模型直接写问题、分钟、分数和事件。Reasoner 不可用、返回非最终诊断或耗尽预算时走同一确定性 fallback policy;Renderer 根据 validated decision 生成自然语言承接,Worker 再通过单一 completion RPC 原子保存全部产物。 +- 验证:对抗合同覆盖“当前目标独占下一问”“只能选择服务端活动 opportunity”“诊断预算耗尽 fail closed”“模型不得注入问题/分钟/事件/分数”和“Reasoner/Renderer 不可用时确定性降级”。真实 PostgreSQL completed-job replay 已验证:相同完整 payload 指纹返回既有 Case;任一 artifact 改变且指纹不同会抛出 `rectification_v5_replay_payload_mismatch`,不会二次写入或接受漂移结果。当前仅完成本地验证,staging 行为仍待发布后验收。 +- 防复发:当前事件延续必须是服务端 opportunity 所有权规则,而不是 prompt 建议;模型输出即使结构合法,也必须经过 bounded tool budget、active-opportunity lookup、decision validation 和 completion payload hash 四层门控。 - 相关记录:BUG-075、BUG-085 -- 修复版本:待提交 +- 修复版本:本地 V5 重构,待提交与 staging 验收 + +## BUG-087 | 语义机会仍退化为固定文案并在拒绝后重复追问 + +- 状态:resolved +- 首次发现:2026-07-29 +- 最近更新:2026-07-29 +- 影响面:生时校正 Agent 的证据协调、问题机会、Reasoner 上下文、Renderer、候选范围提示与 Skill 合同 +- 用户现象:对话会把月份已经明确的经历继续机械追问具体日期,按固定领域顺序轮询;用户表示不知道、跳过或换方向后仍可能回到同一事件,Renderer 还会重复套话和未变化的候选范围。 +- 触发条件:Question Opportunity 直接持久化最终 `prompt`,Renderer 再用服务端问题覆盖自然生成结果;缺失领域按固定数组选择,日期策略把所有非日精度事件视为未完成,且证据协调未完整区分拒绝、未知、换向和回答了另一事件。 +- 根因:机会合同混合了“为什么问、要补什么字段”和“最终怎么说”,导致 Reasoner 看不到最近对话语义、Renderer 无法安全自然表达;同时 target disposition 和重复追问预算不完整,固定领域与日期控制流绕过了信息增益、隐私成本和稳定性诊断。 +- 修复:升级为兼容旧 `prompt` 的 `semantic-question-v2`,由 Builder 同时生成并按 utility 排序最多五个活动机会;补齐 `resolved / unknown / declined / direction_change / answered_other_event / unresolved / not_applicable`,限制同一目标连续追问和回答其他事件后的温和补问次数;月份默认足够,仅在日期敏感诊断不稳定时细化。Reasoner 获得脱敏的最近 Turn、事件、目标和机会语义;Renderer 改为验证自然问题并在失败时使用锚定 fallback,同时只在公开门首次通过或范围实际变化时播报范围,相同范围即使再次计算或收到 offer 决策也不重复播报。受限模型辅助提取仅补充确定性解析缺口,服务端继续校验原文子串和日期。 +- 验证:V6 语义合同、日期敏感性、拒绝/换向、回答新事件不覆盖旧事件、单次补问、Renderer 锚点/单问题/分钟注入/内部信息拒绝、候选范围去重、Reasoner 上下文、模型辅助提取和旧 Opportunity 兼容测试通过;四轮端到端测试覆盖月份职业事件、外地入学、无日期搬家换向和后续职业事件,并保留 exact-minute、Profile 写入、legacy/shadow、V5 候选引擎与 completed-job replay 边界。完整前端测试、lint、TypeScript 与 Python 结果见本任务交付记录。 +- 防复发:问题机会只表达服务器拥有的语义目标和约束,最终文案必须通过 realization validator;日期追问必须有诊断依据,拒绝/换向必须关闭目标,领域选择必须由 utility 和上下文驱动。Skill 明确禁止固定问卷、重复范围、唯一分钟、D60 驱动和公开内部评分/技术轨迹。 +- 相关记录:BUG-068、BUG-075、BUG-080、BUG-081、BUG-082、BUG-085、BUG-086 +- 复发自:BUG-085、BUG-086 +- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1`,本地验证完成,待提交与 staging 发布 + +## BUG-088 | TypeScript 全量检查被过期测试夹具阻塞 + +- 状态:resolved +- 首次发现:2026-07-29 +- 最近更新:2026-07-29 +- 影响面:`npx tsc --noEmit` 本地发布前质量门 +- 用户现象:生产构建通过,但全量 TypeScript 检查在三个测试文件报错:事件评分输入仍传入服务端固定的 `high_rigor`、身份全局缓存清理被控制流误收窄为 `never`、onboarding 测试未归一化 PostgreSQL `Date` 联合类型。 +- 根因:测试夹具落后于现有生产合同;这些报错不来自 V6 Agent 运行时,但会让显式 TypeScript 验证失败。 +- 修复:删除客户端不应拥有的 `high_rigor` 输入;在异步请求结束后从 `globalThis` 重新读取身份缓存;按生产边界把 `Date` 归一化为日期字符串后再构建 onboarding cache identity。 +- 验证:`npx tsc --noEmit`、相关前端测试和生产构建通过。 +- 防复发:测试输入只使用公开类型拥有的字段;异步初始化的全局缓存不要依赖删除前的局部控制流;数据库日期联合类型在进入纯字符串合同前必须归一化。 + +## BUG-089 | Staging CI 自动升级 MCP 2.0 导致旧 FastMCP 导入失败 + +- 状态:resolved +- 首次发现:2026-07-29 +- 最近更新:2026-07-29 +- 影响面:`Staging Backend Quality Gate` 的 Python quick quality gate 与 staging 发布 +- 用户现象:本地完整验证通过,但 staging push 的质量门在导入 `mcp_server.py` 时失败,报 `ModuleNotFoundError: No module named 'mcp.server.fastmcp'`。 +- 根因:`requirements.txt` 与 `pyproject.toml` 仅声明 `mcp>=1.0`;CI 在 2026-07-29 安装了不兼容的 `mcp 2.0.0`,而仓库当前服务端仍使用 MCP 1.x 的 `mcp.server.fastmcp.FastMCP` 导入合同。本地环境保留 `mcp 1.25.0`,因此未复现依赖漂移。 +- 修复:两个发布依赖入口统一限制为 `mcp>=1.0,<2`,继续使用已验证的 MCP 1.x API,不在本次发布中混入 MCP 2.0 迁移。 +- 验证:新增依赖合同测试同时读取 `requirements.txt` 和 `pyproject.toml`,防止任一入口再次放宽到 MCP 2.x;Python quick quality gate 与构建重新执行。 +- 防复发:运行时依赖的主版本兼容边界必须在全部安装入口保持一致;升级 MCP 2.x 必须作为独立迁移处理并先替换导入/API 合同。 +- 相关记录:BUG-087、BUG-088 +- 修复版本:待本次 staging 修复提交与部署验收 + +## BUG-090 | V6 审查发现用户可见分钟注入、家庭事件越权和最新事件排序错误 + +- 状态:resolved +- 首次发现:2026-07-29 +- 最近更新:2026-07-29 +- 影响面:生时纠正 Renderer、模型辅助事件提取、Opportunity 与 Reasoner 最近事件上下文 +- 用户现象:模型可能在 acknowledgement 或 limitation 中声称唯一出生分钟;家庭健康事件可能被矛盾模型字段伪装成本人可评分事件;UUID 排序与创建时间相反时,下一问可能承接较早经历。 +- 根因:分钟安全验证只覆盖 question;辅助提取校验未拒绝 `subject=self` 与非空家庭 `relatedPerson` 的矛盾组合;账本的稳定 UUID 排序被误当作会话时间顺序。 +- 修复:全部用户可见 Renderer 字段统一执行出生分钟和内部信息安全校验并回落到服务器确定性文案;辅助提取在服务器拒绝主体/亲属矛盾并保持家庭事件 `context_only` 边界;Builder 与 Reasoner 显式按 `createdAt`、`eventId`、revision 稳定排序最近事件,不改变证据哈希使用的账本排序。 +- 验证:新增 acknowledgement/limitation 分钟注入、家庭 ICU 事件越权、UUID 与创建时间逆序的回归测试,并重新运行前端完整测试、lint、TypeScript 与构建。 +- 防复发:所有模型可写用户文案共享同一安全边界;模型提取不能决定评分主体;用于哈希的稳定顺序不得被复用为会话时序。 +- 相关记录:BUG-075、BUG-086、BUG-087 +- 修复版本:待本次 staging 修复提交与部署验收 + +## BUG-091 | Staging Case rollout 未启用 V5 Agent 导致 V6 继续输出固定模板 + +- 状态:resolved +- 首次发现:2026-07-29 +- 最近更新:2026-07-29 +- 影响面:staging 生时纠正新 Case 与 V6 未完成 Case 的用户可见回复 +- 用户现象:用户提交“2016 年 9 月离家去外地上大学”后,回复仍固定为“我记下了这段经历。接下来请继续讲另一件……”,没有进入 Semantic Question Renderer。 +- 根因:Case rollout 只写入 V3 创建门和 smoke 状态,没有写入 `RECTIFICATION_AGENT_V5_ENABLED`、`RECTIFICATION_AGENT_V5_SHADOW`、`RECTIFICATION_AGENT_V5_CANARY_PERCENT`;因此 `selectRectificationDeploymentMode()` 把新 Case 持久化为 `v4_legacy`,Orchestrator 必然调用 Legacy Projector。 +- 修复:受控 staging rollout 现在原子写入 V5 Agent 开关,public 与 smoke rollout 使用 `v5_agent`、100% canary,并重建 web/worker;staging 中唯一满足 V6 版本、未完成、无 open Job 条件的错误 Case 已原位升级为 `rectification-evidence-v5` / `v5_agent`,历史 Turn、Event、Job 与 Agent Run 保持不变。 +- 验证:rollout 脚本测试断言三项 V5 选择器只写一次,并在成功前核对 web/worker 容器实际读取的 `enabled`、`shadow`、`canary`;staging 活跃 Case 聚合只剩 `v5_agent`;健康检查保持 exact SHA、public、ready。 +- 防复发:公开 Case rollout 必须同时控制创建门与 Agent deployment mode,并验证运行容器的实际环境;仅有 `readyForNewCases=true` 不再视为新对话 Renderer 已启用的充分证据。 +- 相关记录:BUG-085、BUG-086、BUG-087 +- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1` + +## BUG-092 | Semantic Renderer 成功后仍被静默替换成固定领域模板 + +- 状态:resolved +- 首次发现:2026-07-29 +- 最近更新:2026-07-29 +- 影响面:V6 `ask_new_event` Opportunity 排序、问题验证、Renderer telemetry 与 staging 用户可见下一问 +- 用户现象:用户提交“2020 年 4 月去石油化工研究院实习做研究员”后,系统仍显示“承接……请再说一件……哪次搬家、离乡或长期迁居……”,像固定问卷。 +- 根因:Builder 将最近六轮答案拼接为当前主题,使较早教育事件中的“离家/外地”再次提升 relocation,同时未覆盖领域奖励在已经满足最小领域数后仍占主导;Renderer 对自然 `new_dated_event` 问法使用过窄词面校验,校验失败后静默替换为 Builder 固定 fallback,telemetry 仍记为 `renderer succeeded`。 +- 修复:当前主题只读取最新回答或最新事件;达到两个可评分领域后显著降低纯领域覆盖收益并提高最新主题连续性;移除“承接……请再说一件……”拼接,fallback 改为锚定当前经历的单句问题;放宽自然新事件词面但继续执行单问题、锚点、内部信息和出生分钟安全校验;validator 回退单独记录 `renderer rejected` 与脱敏错误码。 +- 验证:真实两事件重放断言 career 机会优先于旧 relocation 关键词、月份不被细化、旧固定模板被拒绝、锚定研究院实习的自然问题被保留且不等于 fallback;完整前端、lint、TypeScript、Python V5 与 staging smoke 随发布记录执行。 +- 防复发:模型调用成功、Schema 成功和问题被接受必须分开观测;Opportunity utility 不得把历史关键词与“未覆盖领域”组合成伪装的固定轮询。 +- 相关记录:BUG-086、BUG-090、BUG-091 +- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1` + +## BUG-093 | 首次候选评分因跨语言计算规格哈希不一致而失败 + +- 状态:resolved +- 首次发现:2026-07-29 +- 最近更新:2026-07-29 +- 影响面:V5 Candidate Engine 首次达到三事件评分门后的 Feature Snapshot 原子持久化 +- 用户现象:第三件可评分事件已经保存在 Turn,但 Worker 最终显示“这次比较没有完成,回答已经保留,请再试一次”,Case 恢复上一问题且没有写入新事件。 +- 根因:服务器创建 Case 时由 TypeScript 对整数时区 `8` 计算规格哈希;Python 请求归一化把它变成浮点数 `8.0`,而 Python JSON 序列化保留 `.0`。两个运行时对语义相同的规格得到不同哈希,完成事务因此拒绝 Feature Snapshot 并抛出 `rectification_v5_feature_snapshot_mismatch`。 +- 修复:Python 生成跨服务 Calculation Spec 时将整数值的经纬度和时区规范化为整数,使其 JSON 数字表示与 TypeScript `JSON.stringify` 一致;评分算法和候选矩阵不变。 +- 验证:新增已知 TypeScript 哈希向量测试,修复前稳定失败、修复后通过;staging Case `e2d3e1d2-efb0-461d-9914-f890bc2b8569` 的 PostgreSQL 日志确认原始异常,使用同一规格重放确认 Python Feature Snapshot 哈希恢复为 Case 哈希。 +- 防复发:跨语言持久化指纹必须使用已知向量验证 JSON 数字规范化,不能只在各自语言内断言自洽。 +- 相关记录:BUG-087、BUG-092 +- 修复版本:`rectification-v5-matrix-scoring-1`(仅修复输入规范化,算法版本不变) + +## BUG-094 | V5 Agent 消息操作栏在 V4 面板切换后消失 + +- 状态:resolved +- 首次发现:2026-07-29 +- 最近更新:2026-07-29 +- 影响面:生时校正 V5 Agent 对话、助手消息反馈与当前问题重新生成 +- 用户现象:助手消息气泡下不再显示点赞、点踩、复制和重新生成操作。 +- 触发条件:生时校正页面使用 `RectificationV4Panel` 渲染 V5 Agent 会话。 +- 根因:旧对话组件中的消息操作栏没有迁入 V4/V5 共用面板,同时新 V4 API 没有与当前语义问题绑定的重新生成命令。 +- 修复:复用现有消息操作栏样式,仅为 `v5_agent` 的稳定助手消息恢复操作;重新生成只重写当前已验证 Semantic Question Opportunity 的自然语言实现,并通过用户、Case 版本、当前目标和 action ID 原子校验,不重跑事件提取、候选评分、诊断或 Job。 +- 验证:组件资格与反馈互斥测试、V4 service 幂等重放与数据不变量测试、Renderer 安全回落测试、迁移契约测试,以及 staging 精确 SHA 部署和浏览器验收。 +- 防复发:测试锁定 V5 Agent 操作栏、仅当前问题可重跑、legacy/shadow 不启用新 Renderer、重跑不改变 turns/events/snapshots/profile 且 completed Job 不被改写。 +- 相关记录:BUG-090、BUG-091、BUG-092、BUG-093 +- 复发自:无 +- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1` + + +## BUG-095 | 生时校正“思考中”无法证明实际执行步骤且刷新后不可溯源 + +- 状态:resolved +- 首次发现:2026-07-29 +- 最近更新:2026-07-29 +- 影响面:V4/V5 生时校正运行状态、历史助手消息、候选计算与诊断的测试可观测性 +- 用户现象:页面只显示“正在核对星盘信息……”动画,无法判断本轮实际执行了哪些阶段、工具和技法;任务结束或刷新后也无法回看。 +- 根因:Job 只保存一个会被后续步骤覆盖的当前 `phase`,不能还原阶段历史;已持久化的 Public Message 与 Agent Run 工具记录没有被 Case API 投影到对应 Turn;UI 的 thinking 状态只是客户端进度动画,不是模型 reasoning,也不是服务端执行收据。 +- 修复:将“分析过程”定义为与 Turn 关联、可持久化和刷新后可恢复的服务端执行收据;只投影实际发生的阶段、工具调用和 allowlist 技法。供应商显式返回的 reasoning 内容只有通过服务端来源校验与安全过滤后才可作为可选摘要,缺失或不安全时直接省略,不伪造且不读取 hidden chain-of-thought。 +- 安全边界:不公开分数、权重、贡献矩阵、内部 ID/字段、候选分钟、工具参数或原始结果、Prompt、模型内部信息和用户敏感原文;D60 不展示且不驱动结论;未执行、不可用或仅供参考的技法不得显示为已执行。 +- 兼容边界:历史无收据记录继续读取;`v4_legacy` 与 `v5_shadow` 保持原有用户可见回复,shadow 仅持久化新产物而不展示分析轨迹;completed-job replay、原子 completion、`canConfirmExactMinute === false` 和禁止自动写入 `profiles.active_birth_time` 保持不变。 +- 防复发:API 与组件测试必须锁定 Turn 关联、刷新恢复、运行中真实 phase、仅展示实际工具/技法、无安全 reasoning 时不补写摘要,以及旧记录、legacy、shadow 的兼容行为。 +- 相关记录:BUG-011、BUG-086、BUG-087、BUG-094 +- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1` + +## BUG-096 | PostgreSQL 日期对象导致生时纠正 Case 创建误报资料格式错误 + +- 状态:resolved +- 首次发现:2026-07-29 +- 最近更新:2026-07-29 +- 影响面:staging 自托管 PostgreSQL 出生资料读取、历史时区偏移补全与生时纠正 Case 创建 +- 用户现象:已完成出生资料的账号启动生时纠正时返回 400,并显示“出生资料或提交内容格式不正确”。 +- 触发条件:数据库驱动将 `birth_date` 返回为 JavaScript `Date`,且历史资料的 `timezone_offset` 为空、需要根据日期和时区 ID 补全。 +- 根因:共享 `resolveMissingBirthTimezoneOffset()` 只把非空字符串识别为出生日期;合法 `Date` 被当成缺失值,补全提前返回,随后严格出生资料解析以 `Historical timezone offset must be resolved before parsing` 拒绝请求。 +- 修复:共享时区补全 resolver 在读取出生日期时同时接受有效 `Date` 与既有字符串,并统一归一化为 `YYYY-MM-DD`;不放宽后续 Zod 校验,也不改变生时纠正计算规格。 +- 验证:路由回归夹具改为 PostgreSQL 实际返回形态的 `Date`;聚焦测试、前端全量测试、lint、TypeScript、生产构建和 Python V5 服务测试通过,staging 精确 SHA smoke 随本次发布执行。 +- 防复发:所有从 PostgreSQL 进入纯日期字符串合同的共享边界必须先覆盖 `string | Date` 驱动返回类型;Case 创建继续要求历史时区偏移在严格解析前完成。 +- 相关记录:BUG-076、BUG-091、BUG-095 +- 复发自:无 +- 修复版本:待本次 staging 修复提交与部署验收 + +## BUG-097 | 生时纠正分析状态停滞且教育事件被换词重问为迁居 + +- 状态:resolved +- 首次发现:2026-07-29 +- 最近更新:2026-07-29 +- 影响面:V5 Agent 生时纠正运行状态、历史消息布局、Semantic Question Opportunity 排序与 Renderer 安全回退 +- 用户现象:任务实际已经经过“生成语义问题机会、选择下一步动作、生成安全回复”,页面运行中却一直显示“正在整理你刚才提到的经历”;完成后的“分析过程”显示在 Agent 正文下方;用户已经说明离家去外地上大学的年月后,下一问仍把同一经历换词问成搬到新城市或长期离乡。 +- 触发条件:Job 轮询返回新的 `phase`,但聊天消息继续读取首次 Case 响应中的旧 `data.job`;同时教育事件原文中的“离家、外地”命中迁居领域关键词并抬高 relocation 机会,Renderer 失败回退后直接使用带“以当前事件为时间参照”的模板。 +- 根因:Hook 同时维护 `data.job` 与独立 Job state,轮询只更新后者;恢复 processing Case 时 API 没有返回 active Job,刷新后无法继续轮询;`setInterval` 允许并发请求,较旧响应可能覆盖较新 phase;机会排序把零散地点词当成迁居主题信号,没有优先采用最新账本事件的已确认领域;迁居 fallback 和问题验证器没有禁止把当前事件改写成另一件事件继续追问。 +- 修复:轮询结果原子合并回 `data.job`,消息状态直接跟随服务端 `extracting_evidence / planning_question / reasoning / rendering`;Case Store 和 API 为 processing Case 恢复最新 pending/processing Job;轮询改为单飞 `setTimeout` 并拒绝较旧响应;把持久化分析收据移动到 Agent 正文上方并保留正文下方操作栏;迁居关键词收窄为明确搬迁动作,最新事件主题加权以账本领域为准;所有新事件 fallback 明确询问另一件独立事件,Renderer 拒绝旧跨事件模板、同义重复和与所选机会领域不匹配的问题。 +- 验证:组件测试锁定分析过程、正文、操作栏顺序、连续 Job phase 更新及乱序响应不倒退;服务测试锁定 processing 刷新恢复 active Job 和 shadow 年精度 legacy 细化;Agent 测试锁定外地上大学不会提升 relocation、事件数组顺序不改变排序、旧模板与跨领域实现触发安全 fallback;完整前端测试、lint、TypeScript、staging 构建、Python V5 服务测试和 staging 精确 SHA smoke 随本次发布执行。 +- 防复发:UI 只允许一个 Job 真源,恢复 processing Case 必须携带 active Job,轮询必须单飞且单调更新;新事件机会不得把 anchor 当作待细化目标;主题信号优先来自已验证事件领域,地点词不能单独代表迁居;Renderer 必须同时验证“另一件事件”、所选领域语义与安全边界。 +- 相关记录:BUG-090、BUG-093、BUG-094、BUG-095 +- 复发自:BUG-093、BUG-095 +- 修复版本:待本次 staging 修复提交与部署验收 + +## BUG-098 | V6 公开候选门禁未明确 LODO、技法层级与独立 holdout 边界 + +- 状态:resolved/partial +- 首次发现:2026-07-30 +- 最近更新:2026-07-30 +- 影响面:V6 Python 候选扫描、Candidate Snapshot 公开范围门禁、生时纠正 Skill 与参考 Skill 能力声明 +- 用户现象:现有说明容易把“支持某技法”、同一 Case 内的留一诊断和参考 Skill 方法论误读为已完成独立验证;缺失 `KP_cusps` 或 D60 也可能被错误当成公开范围的统一硬阻塞。 +- 根因:文档没有把真实实现链、LODO public gate、active-domain required/optional/reference-only 技法策略和参考 Skill 的未实现能力分开;LOEO/LODO 使用同一事件贡献矩阵做事后减项,不能替代 prospective independent holdout。 +- 修复:记录 V6 的真实链路为跨午夜兼容的逐分钟 Python 扫描、事件贡献矩阵、Snapshot、LOEO/LODO、date sensitivity、neighbor stability 与 candidate split;公开范围新增 LODO 稳定性门禁,并按活跃可评分领域判定 required layer,`KP_cusps` 为 optional、D60 为 reference-only、未知层失败关闭;事件 provenance 仅用于审计且不参与加权。 +- 验证:本工作树的聚焦合同覆盖跨午夜分钟枚举、LODO 低于 `0.8` 拒绝公开范围、required layer 缺失阻塞、`KP_cusps`/D60 缺失不阻塞,以及旧 Snapshot 兼容;本记录不把这些回归误写成独立 holdout 验证。 +- Partial / deferred:per-Case independent holdout 因缺少 prospective sticky partition 与 calibration contract 延期。当前 LOEO/LODO 只证明同一 Case 内的敏感性,不得伪称 prospective、independent 或 calibrated validation 已完成。 +- 安全边界:继续禁止手工 `supports/conflicts` 伪评分、任意外部仓动态加载、唯一分钟结论和自动写入 `profiles.active_birth_time`;参考 Skill 只作方法与审计参照,不成为第二评分真源。 +- 防复发:公开候选必须同时通过事件/领域覆盖、范围宽度、邻近分钟、LOEO、LODO、日期敏感性、计算规格和 required-technique 门禁;任何 holdout 完成声明必须先有稳定分区持久化与校准验收证据。 +- 相关记录:BUG-082、BUG-090、BUG-093、BUG-095 +- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1`,独立 holdout 延期 + +## BUG-099 | 新事件追问可能预设事件存在、换词重复且 Renderer 回退不可追溯 + +- 状态:resolved +- 首次发现:2026-07-30 +- 最近更新:2026-07-30 +- 影响面:Semantic Question Opportunity 生成与排序、跨领域事件去重、Renderer 问题校验、持久化分析历史 +- 用户现象:新事件问题可能直接询问“哪次”经历而暗示该事件必然发生;提示缺少便于回忆但不限定答案的具体线索;与最新事件语义相同的内容可能换一个领域名称再次追问;Renderer 模型问题被接受、被拒绝或改由服务器 fallback 后,历史分析收据无法明确区分实际路径。 +- 触发条件:生成 `ask_new_event` 时仅依赖领域模板或缺失领域;跨领域候选与最新事件共享同一人物、行动或生活转折但没有语义重叠降权;Renderer 不可用、调用失败或问题未通过校验而进入 deterministic fallback。 +- 根因:问题政策尚未明确存在性询问、非穷举回忆线索数量和禁止虚构年龄/日期窗口;utility 合同没有钉死跨领域语义重叠 penalty;分析历史没有要求持久化 Renderer 校验结果与服务器 fallback 来源。 +- 修复要求:所有新事件问题先询问相关经历是否存在,不得预设发生;提供 2–5 个明确标注为示例而非穷举的回忆线索,并允许用户回答其他经历或表示没有;不得发明年龄、人生阶段或日期窗口。若候选与最新事件跨领域但语义重叠,必须在排序前降低 utility,足以判定为同一事件换词时不得再次提问。每次 Renderer 尝试必须在分析历史中留下安全的分类收据,区分模型问题通过校验、模型问题被拒绝,以及服务器 deterministic fallback,并记录不含原始 Prompt 或用户敏感文本的原因类别。 +- 验证:`rectification-agent-v6.test.ts` 覆盖存在性问题、具体回忆线索、退出方式、禁止虚构年龄/日期窗口和跨领域同事件降权;`rectification-analysis-trace.test.ts` 覆盖持久化分析历史中的模型安全校验与服务器 fallback 分类;完整前端测试 1123/1123 通过。 +- 安全边界:分析历史只记录阶段、校验结果、fallback 布尔值或安全原因类别,不记录模型 Prompt、原始候选文本、隐藏推理、内部评分、事件原文或出生资料。 +- 防复发:新事件问题和 Renderer 分析收据必须作为服务端合同测试,而不是只测试最终展示文案;领域名称不同不得绕过同一事件的语义去重。 +- 相关记录:BUG-087、BUG-095、BUG-097 +- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1` + +## BUG-100 | Staging migration 账本中的遗失历史文件阻塞安全发布 + +- 状态:resolved +- 首次发现:2026-07-30 +- 最近更新:2026-07-30 +- 影响面:staging migration check、exact-SHA 应用发布 +- 用户现象:生时纠正 V6 修复已经通过质量门并合入 `main`,但 staging 在切换镜像前报 `migration file missing: 20260727010000_admin_users.sql`,因此仍运行旧版本。 +- 根因:staging 的 append-only migration 账本包含 5 条已不在任何受审仓库历史中的支付/管理 migration 记录;现有 runner 要求每一条账本记录都对应当前文件,因此按顺序安全停止。 +- 修复:在 migration runner 中加入 5 条静态 retired migration 记录,逐条固定校验从 staging 只读账本核对到的 SHA-256;只有文件名和 checksum 同时完全匹配时才允许继续。checksum 漂移、其他遗失 migration 或非法文件名仍然失败关闭;不删除账本记录,也不动态信任数据库返回值。 +- 验证:新增无数据库单元测试覆盖全部 5 条 retired migration 正确 checksum 通过、任一错误 checksum 拒绝、未声明遗失 migration 继续拒绝;staging 发布仍须通过原 migration check、exact digest 和 exact SHA 门禁。 +- 防复发:历史 migration 文件不得从受审仓库删除;若必须兼容已遗失记录,只能用代码审查过的静态 filename + checksum,并保留 fail-closed 测试。 +- 相关记录:BUG-099 +- 修复版本:staging migration integrity compatibility + +## BUG-101 | 正常访谈被 Opportunity 模板与 Renderer 回退主导,事件种类在 Python bridge 丢失 + +- 状态:resolved +- 首次发现:2026-07-30 +- 最近更新:2026-07-30 +- 影响面:V5 Agent 生时纠正常访谈、事件修订暂存、公开问题生成、Python 候选评分语义 +- 用户现象:模型虽然参与推理,但下一步焦点、问题类别和公开回复仍主要由服务器硬编码的 Builder、Opportunity 分类与正则 Renderer 决定;同一轮难以自然识别多件事件,关系开始、结束或变化进入 Python 评分后又退化为通用 relationship 领域。 +- 触发条件:正常 `v5_agent` 路径依次调用 `buildQuestionOpportunities()`、`runBoundedReasoner()` 与 `renderPublicTurn()`;事件通过 TypeScript/Python bridge 时只传 `domain`,没有保留 canonical `event_kind`。 +- 根因:Agent 只在服务器预先枚举的机会中选择,无法基于完整 Case Dossier 自主理解当前访谈焦点并生成下一句;公开回复失败时继续由领域正则模板接管。与此同时 Python legacy request 把领域值当作事件种类,抹平关系事件的 start/end/change 语义。 +- 修复:新增 Director 两阶段合同:服务器提供完整 Case Dossier,Director 可在一轮提出多个 create/revise evidence proposal;服务器验证原文、日期、目标和 opaque ID 后生成 revisions 并重算评分/诊断,Director 再自主选择焦点并直接生成自然问题与公开回复。服务器继续控制 status、phase、snapshot/range gate、内部 ID、精确分钟与单问题安全边界;输出失败只允许同一 Director 做一次安全 repair,再进入通用 fallback。`v5_shadow` 保留 legacy 可见投影与确定性证据行为,只持久化 Director artifacts。Python bridge 与评分 trace 继续传递 canonical `event_kind`,并为 relationship start/end/change 保留可验证的最小区分。 +- 验证:TypeScript 相关套件 114/114 通过,其中 Director 专项 7/7;TypeScript `tsc --noEmit` 与修改文件 ESLint 通过;Python `tests/test_active_rectification_events.py` 14/14 通过。 +- 安全边界:模型不得公开内部 ID、分数、贡献矩阵、工具信息或精确分钟;proposal 必须引用最新回答中的原文与日期文本,所有持久化 revision、候选重算、门禁判断和原子提交仍由服务器拥有。 +- 防复发:正常 Agent 路径不得重新依赖 Opportunity 枚举或领域正则决定访谈内容;Director 合同测试必须覆盖多事件提议、修订目标验证、拒绝/不知道后的换焦点、range gate、内部信息泄露与一次 repair;Python 测试必须断言 `event_kind` 从输入穿透到规则 trace。 +- 相关记录:BUG-095、BUG-097、BUG-099 +- 修复版本:staging + +## BUG-102 | Director Dossier 丢失早期语境、历史 Pending Evidence 与拒答领域 + +- 状态:resolved +- 首次发现:2026-07-30 +- 最近更新:2026-07-30 +- 影响面:V5 Agent Director 的长期会话理解、拒答保护、未解析证据续接与候选诊断循环 +- 用户现象:Director 已经替代正常路径的 Opportunity/Renderer,但超过十二轮的会话只收到“更早还有 N 轮”,`declinedDomains` 固定为空,历史未解决 Pending Evidence 未进入本轮 Dossier;一次只读诊断不足时无法继续观察后再决定下一问。 +- 触发条件:Case 超过十二轮、用户曾拒绝某个问题领域、前序 Turn 留下未解决证据,或 Director 连续需要两类候选诊断。 +- 根因:Dossier Builder 使用计数占位代替早期问答摘要,并未从 Turn 账本派生拒答领域;Job claim 合同也没有携带未解决 Pending Evidence。Director 的诊断处理使用单次 `if`,与 Dossier 声明的一次预算绑定。 +- 修复:Dossier 现在保留最近十二轮原文,并把更早问答压缩为有内容的受限摘要;从历史 Turn 派生拒答领域;Memory/Supabase Job claim 加载未解决 Pending Evidence 并与本轮新增项一并交给 Director。只读诊断改为最多两次的有界循环,公开回复、数据库状态、候选范围门禁与原子提交仍由服务器控制。Prompt 版本升级为 `rectification-director-v2`。 +- 验证:Director 回归测试覆盖早期语境、拒答领域、Pending Evidence 和两次诊断循环;TypeScript 类型检查、相关 ESLint 与目标测试通过。 +- 安全边界:Pending Evidence 仅作为私有 Dossier 输入,公开文本仍经过内部信息、精确分钟、单问题和候选范围门禁校验;工具循环保持只读且最多两次。 +- 防复发:Dossier 测试必须断言早期语境不是计数占位、拒答领域和未解决证据可见;诊断测试必须断言循环有上限且最终返回非诊断动作。 +- 相关记录:BUG-099、BUG-101 +- 修复版本:local follow-up + +## BUG-103 | Director revise 可跨事件覆盖既有 Event ID + +- 状态:resolved +- 首次发现:2026-07-30 +- 最近更新:2026-07-30 +- 影响面:V5 Agent 事件修订暂存、事件账本身份连续性与后续候选评分 +- 用户现象:模型可把“大学入学”的既有 Event ID 修订成“搬家”或其他无关事件,并把未受原文约束的 `proposedSummary` 写入账本。 +- 触发条件:`revise` proposal 引用真实 Target ID 和回答中的日期/Span,但声明了不同 Domain、Kind、Subject、Related Person,或 Span 与原事件语义锚点不连续。 +- 根因:服务器只验证 Target ID 存在、Span/日期来自最新回答,没有验证 Revision 的事件身份连续性;持久化 Summary 直接采用模型提议文本。 +- 修复:`stageAgentEvidenceProposals()` 仅接受 Domain、Kind、Subject、Related Person 与 Target 一致,且最新原文事件摘要仍命中 Target 摘要或原始文本的修订;不连续的提议转为 Pending Evidence,不覆盖原 Event ID。合法修订的 Summary 改用服务器从已验证 Source Span 提取的事件摘要,身份字段与 Scoreability 继续沿用 Target。 +- 验证:新增回归测试证明合法日期修订保留 Event ID 并使用原文摘要,跨领域且含虚构 Summary 的 revise 不产生 Revision、只产生 Pending Evidence。 +- 安全边界:Agent 仍可创建新事件;跨事件内容必须走 `create`,不能借 `revise` 篡改既有账本身份。 +- 防复发:Revision 测试必须同时覆盖合法日期更正和跨事件覆盖拒绝,不能只断言 Target ID 存在。 +- 相关记录:BUG-101、BUG-102 +- 修复版本:local follow-up + +## BUG-104 | Director 可覆盖拒答、日期简答失效、Pending 误关闭与旧候选快照越权 + +- 状态:resolved +- 首次发现:2026-07-30 +- 最近更新:2026-07-30 +- 影响面:V5 Agent Director/Orchestrator、事件 Revision、Pending Evidence 生命周期、Event Kind 评分边界、公开候选范围与回复安全校验 +- 用户现象:明确的“不想说/记不清/换一个”可能被模型覆盖回未解决并继续追问;仅回答月份、日期或时间段无法修订当前事件;自然纠正事件类型或人物会失败;历史 Pending Evidence 可能永久残留或被无关 Revision 错误关闭;旧 `relationship_end` 评分快照仍可能公开或接受;技术层名称可能出现在公开回复。 +- 根因:模型处置优先级高于服务器确定性关闭状态;日期 Revision 复用了创建事件的完整语义要求;修订合同没有区分日期修订与重新分类;Pending completion 没有原子关闭合同,也未按缺口类型验证修订;`relationship_end` 缺少独立评分规则却保留旧 scoreable Snapshot;公开文本过滤只覆盖部分技术层。 +- 修复:服务器关闭状态不可被 Director 覆盖,且关闭后禁止用空 Target ID 的澄清/冲突 Focus 重开原事件;Evidence operation 拆为 `create/revise_date/reclassify/ignore`,日期简答继承 Target 身份与缺失年份,显式纠正追加同 Event ID Revision,人物变化进入 `pending_review`;V5 completion 增加 ownership/target/replay 安全的 Pending resolution,并且 `date_unresolved` 只在日期确实变化后关闭;`relationship_end` 强制 `pending_review`,迁移清除由旧 scoreable 关系结束事件支持的最新 Snapshot,Dossier 与 acceptRange 双重拒绝旧快照;公开回复禁止全部 D-number 技术层、KP、Vimshottari、Narayana、Shadbala 与 Ashtakavarga,只允许公开已批准 Snapshot 的首个范围 Cluster。 +- 验证:Director、V6 Agent、V4 Domain/Service/Migration 与 Python Event Engine 回归覆盖明确拒答、空 Target ID 绕过、日期简答、事件重新分类、Pending 原子关闭与原因匹配、旧快照失效、技术层泄漏、多事件提取和 Event Kind trace;真实 PostgreSQL migration 应用测试通过。 +- 安全边界:服务器继续拥有事实验证、事件身份、Scoreability、候选范围和持久化权限;Agent 只提出结构化计划。禁止公开内部 ID、评分、技术 trace、代表分钟或第二 Cluster,禁止自动写入 Profile 出生时间。 +- 防复发:拒答保护必须有 Orchestrator/Director 级回归;Pending resolution 必须验证缺口已被对应 Revision 补齐;评分政策变化必须同时处理历史 Snapshot;公开技术层过滤按完整技术命名空间测试。 +- 相关记录:BUG-099、BUG-102、BUG-103 +- 修复版本:`birth-time-rectification-v6` / `rectification-director-v2` + +## BUG-105 | 候选差异未驱动下一问、Event Kind 未进入评分且不可回答 Case 被错误复用 + +- 状态:resolved +- 首次发现:2026-07-31 +- 最近更新:2026-07-31 +- 影响面:V6 Agent 问题排序、V5 Python 贡献矩阵、V4 Case 创建/复用、Staging 新建校正后的首次回答 +- 用户现象:诊断已经显示候选簇在特定技术层存在差异时,访谈仍可能继续做通用领域轮询;关系确立与关系变化在评分中缺少语义差异;新建校正后首次提交回答可能返回“当前没有待回答的问题,请刷新后重试。”;已知主体的非评分事件还可能被错误追问“发生在谁身上”。 +- 触发条件:候选分歧只携带技术层而没有可行动的缺失证据;评分仅按 Domain 汇总;创建 Case 时复用 `paused`、没有 Active Job 的 `processing`、或没有 `currentQuestion` 的 `awaiting_answer` Case;`pending_review` 被等同于主体不明确。 +- 根因:Director Dossier 缺少 Candidate Contrast Packet,问题排序对同领域历史提问施加通用重复惩罚;共享评分入口没有按 Event Kind 和真实命中 Rule ID 调整贡献;Service、Memory Store 与 Supabase RPC 的可恢复 Case 条件不一致且没有算法版本隔离;主体澄清条件错误地依赖整个 Scoreability 状态。 +- 修复:共享诊断层按主候选与次候选的静态特征差异计算区分层、按事件贡献差值计算相关事件,再生成不暴露候选分钟的 Candidate Contrast Packet,用 Cluster Rank、区分层、相关事件和缺失 Event Kind 驱动问题机会;候选驱动的不同 Event Kind 不受通用同领域重复惩罚,并优先于无诊断依据的领域轮询,非评分事件不作为 Candidate Split Target;共享 Python 贡献矩阵按真实 Rule ID 对 `relationship_start` 与 `relationship_change` 应用不同 Profile,零 Activation 不凭空加分,`relationship_end` 继续 Fail Closed;算法版本升级到 `rectification-v5-matrix-scoring-2`;三层 Case 复用统一为仅恢复可回答 Case 或拥有 Active Job 的 Processing Case,且必须匹配算法版本;主体澄清仅针对 `subject=other`。 +- 数据库:新增向前迁移,更新新 Case 默认算法版本、保留 range-scoring v1 与 matrix-scoring v1 历史 Candidate Snapshot 解析兼容、废弃未完成的旧算法 Case、标记其活跃 Job 为 Stale,并重建带可恢复状态和算法版本检查的 Case 创建 RPC;不写入 Profile 出生时间。 +- 验证:真实用户回放覆盖复读、大学入学并离家、开始工作、分手和负债,断言关系结束保持 `pending_review`、不会把大学入学换词重问为迁居、D9 内部差异转为关系确立/状态变化的自然存在性问题、公开问题不泄露技术层或候选分钟,并允许“没有、不知道、不想回答、换方向”;Service 回归覆盖 Paused、孤儿 Processing、空问题 Awaiting Answer、新 Case 首次回答与旧算法 Case 替换;Python 回归覆盖关系 Event Kind 反转候选排序及零 Activation。 +- 安全边界:Candidate Contrast 只在服务器内部使用,不向用户公开技术层、分数、代表分钟或第二候选簇;Agent 不能把 `relationship_end` 变为可评分事件,不能确认精确出生分钟,也不能自动写入 Profile。 +- 防复发:完整回放测试必须同时覆盖事件账本、问题排序、退出方式和公开文本;评分版本变化必须同步 Case 复用、数据库默认值和历史快照兼容;Case 创建测试必须随后真实调用一次 Answer。 +- 相关记录:BUG-101、BUG-102、BUG-104 +- 修复版本:local follow-up / `rectification-v5-matrix-scoring-2` + +## BUG-106 | Director 缺少统一工具 Observation 与可更新 Dossier + +- 状态:resolved +- 首次发现:2026-07-31 +- 最近更新:2026-07-31 +- 影响面:V8 生时纠正 Director Runtime、Skill 决策策略、Agent Run 诊断轨迹和未完成 Case 版本元数据 +- 用户现象:Director 虽然能够请求只读诊断,但 Case、候选扫描和证据缺口仍由固定流程一次性塞入 prompt;取得结果后最多再读两次诊断,Dossier 本身不随观察更新,前端处理中也只显示单行状态。 +- 触发条件:最终决策需要依次读取案件、候选差异、证据缺口和某项稳定性诊断才能决定下一问;或模型重复请求本轮已经读取过的静态工具。 +- 根因:Director 合同只有 `request_diagnostic`,Dossier 没有 revision、Observation 和候选假设;Runtime 只累计旁路诊断数组,前端没有把既有 Job phase 投影成稳定的分析步骤。 +- 修复:新增服务器拥有的 `case_read`、`candidate_scan`、`evidence_gap`、`diagnostic_read` 统一只读工具;最终规划首轮只提供 Runtime 与工具可用性,不再预载这些工具拥有的完整数据。每次调用按需暴露当前权威服务器投影、生成结构化 Observation、递增 in-run Dossier revision,并把更新后的 Dossier 交回同一 Director。循环最多 10 轮,静态工具按工具+诊断去重,越界后只允许一次强制收敛;前端复用现有 Job phase 显示“整理事件 / 比较候选 / 确定验证方向”三步进度。Skill/Prompt 升级为 `birth-time-rectification-v8` / `rectification-director-v4`,向前迁移只推进未完成的 Agent Case,不改历史完成结果、评分算法或 Profile 出生时间。 +- 验证:Director 回归覆盖 `case_read → candidate_scan → evidence_gap → diagnostic_read → final`、每轮 Dossier revision/Observation 回灌、重复工具不重复执行、一次强制收敛和单问题最终动作;前端回归覆盖三个公开分析步骤;V8 迁移合同覆盖默认版本、未完成 Agent Case 范围和禁止写入 `active_birth_time`。 +- 安全边界:服务器继续拥有事件事实、工具结果、候选 Snapshot、公开范围门禁、持久化与最终输出验证;Observation 只存在于本次 Agent Run 的内存 Dossier,不成为第二业务真源,也不向前端公开原始结果、内部技术层、候选分钟或评分。 +- 防复发:不得把每次诊断后的 prompt 改回“必须立即结束”;增加新工具时必须定义 observation 是否会在同一 Turn 变化、重复调用策略和总轮次上限。 +- 相关记录:BUG-101、BUG-102、BUG-105 +- 修复版本:`birth-time-rectification-v8` / `rectification-director-v4` + +## BUG-107 | 开放问题收集年份事件后先切换新事件、下一轮再回访旧事件 + +- 状态:resolved(staging pending deployment) +- 首次发现:2026-07-31 +- 最近更新:2026-07-31 +- 影响面:V8 Director 最终规划、确定性 fallback、年份/季度精度事件的访谈连续性 +- 用户现象:开放问题收到一件只有年份的本人事件后,系统先用固定句式要求另一件经历;收到第二件月份明确的经历后,又突然回头追问第一件事件的月份,表现为事件焦点来回跳转。 +- 触发条件:上一问没有 `questionTargetEventId`,本轮新建的可评分事件只有 `year` 或 `quarter` 精度,同时 Director 调用失败、超时或计划被拒绝而进入 fallback;即使 Agent 正常返回,旧校验也未禁止它在未闭合宽日期事件时切换目标。 +- 根因:Orchestrator 只把上一问的 `questionTargetEventId` 传入最终 Dossier,没有把本轮新建且日期仍宽的事件提升为当前目标;Director 因而看见 `targetDisposition=not_applicable`。确定性 fallback 随即输出通用“再说一件经历”模板,后续 Director 才从完整账本重新发现旧事件缺月份。最终计划校验也只保护拒答目标,没有保护 `unresolved` / `answered_other_event` 目标不被放弃。 +- 修复:最终规划优先保留服务器 reconciliation 的未回答目标;否则把本轮新建的 `year` / `quarter` 可评分事件设为 `unresolved` 当前目标。计划校验拒绝在该目标未闭合时切换到无关新事件;fallback 按目标真实日期精度追问月份或时间段,并绑定真实 Event ID,而不是继续输出通用新事件模板。 +- 验证:Director 回归断言未闭合宽日期目标不能被新事件问题放弃,强制 fallback 会锚定该事件并请求 `month`;Orchestrator 回放断言开放问题收到年份事件后,下一问立即补月份且 `targetEventId` 指向新事件。相关 Director/分析轨迹测试、TypeScript 和 ESLint 均通过;完整前端测试 1157 项通过。 +- 防复发:事件连续性由服务器的 `currentTargetEventId + targetDisposition` 约束,不能只靠 Agent prompt;新事件的必要日期精度应在切换话题前闭合,用户明确“不知道/跳过/换方向”时才解除目标。 +- 相关记录:BUG-092、BUG-097、BUG-106 +- 修复版本:local / pending release + +## BUG-108 | V8 丢失事件承接说明且服务器领域模板覆盖 Agent 自主选题 + +- 状态:resolved(staging pending deployment) +- 首次发现:2026-07-31 +- 最近更新:2026-07-31 +- 影响面:V8 Director 最终回复、Candidate Contrast、确定性 fallback、前端可见问题历史与处理中动画 +- 用户现象:用户提供具体经历后,界面只显示下一问,没有显示 Agent 对新线索的承接和公开安全的价值说明;后续问题又容易落入预写的教育、迁居、关系、事业、财务、健康模板,甚至把验收案例中的“大学、实习、搬家”等措辞当成产品脚本。处理中还显示固定三步 checklist,而不是随真实 phase 变化。 +- 触发条件:`v5_agent` 最终规划生成了 `publicReply`,但持久化出口只保存裸问题;同时 Opportunity Builder 通过 `domainPolicy`、关键词、人工 `recallEase/privacyCost` 和领域 `fallbackPrompt` 预先决定候选问题,Renderer 再用领域词表校验模型输出,模型异常时 Director fallback 直接采用人工排序结果。 +- 根因:公开消息和可见问题使用了两个出口;更关键的是服务器同时承担了事实约束和访谈选题,形成“Builder 人工选题 → Director 采用排名 → Renderer 关键词裁决”的双重控制,Agent 实际只能改写模板。 +- 修复:持久化完整的 acknowledgement、公开安全的证据价值说明、limitation 与唯一问题;删除固定领域 `domainPolicy`、领域 recall cues、领域关键词匹配和 V8 Opportunity 排名传参。Candidate Contrast 仅提供完整、顺序稳定的事实观察,Director 根据完整账本、拒答记录、候选差异和只读工具自主决定方向与措辞。无当前目标且模型失败时使用 `domain:null` 的领域中立恢复问题;有未闭合目标时服务器只保护目标连续性并询问必要事实。保留拒答/隐私保护、事实来源验证、单问题、范围门、目标锚点和技术信息过滤。前端移除固定分析 checklist,并把真实 Job phase 映射到 `thinking-orbs` 状态。 +- 验证:回归覆盖 Builder 不再生成六领域问题、Candidate Contrast 返回全部可用缺口而不替 Agent 选题、Renderer 接受不在测试样例中的自然方向、Agent 自主问题不被领域 fallback 替换、拒答领域不能被重新打开、大学与研究院实习回放在模型失败时保持领域中立。`npx tsc --noEmit`、98 项聚焦测试、1160 项完整前端测试、touched-file ESLint 与 `git diff --check` 均通过。 +- 防复发:服务器只提供事实、能力和禁止项;正常 V8 选题与措辞归 Agent。测试案例只能验证不变量和复现回归,不能通过词表、权重或预写问题进入生产决策链。 +- 相关记录:BUG-105、BUG-106、BUG-107 +- 修复版本:staging branch / deployment manifest records exact SHA + +## BUG-109 | 生时校正模型文案可越过公开事实边界 + +- 状态:resolved +- 首次发现:2026-07-31 +- 最近更新:2026-07-31 +- 影响面:V5 Director 公开回复、下一问生成与手动重新生成 +- 用户现象:模型可能直接追问、遗漏服务器承接说明,或把未经计算的候选优劣写入公开问题。 +- 触发条件:Director 返回自由文案,或历史 `selectedOpportunity` 进入问题重新生成路径。 +- 根因:公开回复和问题 wording 曾由模型直接提供;历史兼容分支还可绕过新 focus 合同调用 renderer。 +- 修复:Agent 只选择结构化 focus;服务器根据事件账本、能力矩阵和 focus 生成承接、方法说明及单一问题;历史 `selectedOpportunity` 先转换为 focus,所有重新生成路径共用同一服务器 renderer。 +- 验证:Director、analysis trace、V4 service 聚焦测试 52/52;TypeScript、ESLint 通过;前端全量测试 1164/1164。 +- 防复发:回归测试覆盖模型伪造候选结论、手动 regenerate 以及历史 `selectedOpportunity` 持久化路径。 +- 相关记录:无 +- 复发自:无 +- 修复版本:待发布 + +## BUG-110 | 月份简答未继承目标事件年份导致重复追问并暂停 + +- 状态:resolved(local) +- 首次发现:2026-07-31 +- 最近更新:2026-07-31 +- 影响面:V5 生时校正的目标事件日期补充、Director fallback 与下一问生成 +- 用户现象:已有“2016 年离家去外地上大学”事件时,用户回答“9 月”后没有生成同一事件的新 revision;系统仍把目标视为 unresolved,重复月份问题,随后因 `question_repeated` 进入暂停。 +- 触发条件:当前问题绑定一个仅有年份或季度精度的事件,用户只回答月份、月日、半年或月份区间,且 Agent 不可用或返回重复的临时问题。 +- 根因:确定性 reconciliation 只接受答案中重新出现完整年份的日期;Evidence 阶段又提前校验临时公开问题,导致有效证据提议可能被重复问题校验一并拒绝。 +- 修复:复用目标事件已有年份补全局部日期回答,成功后追加同一 Event ID 的 revision 并关闭目标;Evidence 阶段只验证证据和 target disposition,公开问题只在 final 阶段验证;fallback reason 同时保留原始异常和二次拒绝原因。 +- 验证:回归覆盖“2016 年事件 + 9 月 + Agent 强制不可用”的完整 Orchestrator 流程,确认生成 `2016-09` revision、无 pending、下一问不再绑定原事件且状态保持 `awaiting_answer`;Director 测试确认记录 `fallback_rejected:question_repeated`。 +- 防复发:单元测试分别锁定确定性日期继承、Evidence 阶段边界、fallback 原因和完整两轮回放。 +- 相关记录:BUG-104、BUG-107、BUG-109 +- 复发自:无 +- 修复版本:local / pending release + +## BUG-111 | Director 校验器覆盖 Agent 公开回复且复合事件缺少公开语义 + +- 状态:resolved(local) +- 首次发现:2026-07-31 +- 最近更新:2026-07-31 +- 影响面:V5 Director 公开承接、方法说明、下一问、手动重新生成与复合事件语义 +- 用户现象:Agent 已生成自然承接和下一问时,服务器仍会替换为固定模板;“离家去外地上大学”只暴露教育语义,公开解释无法同时说明教育与迁居层面。 +- 触发条件:最新事件同时包含多个可核对维度,或 Director / 手动 regenerate 返回合规的自然文案。 +- 根因:最终计划校验器同时承担验证和重写职责;事件账本只暴露单一主评分领域;手动 regenerate 路径完全忽略模型输出并直接调用服务器问题模板。 +- 修复:事件账本为同一事件增加只读 `publicSignals`,保留一个主评分身份并补充公开 secondary signals,不新增 Event、不重复计分;最终校验器只验证 grounding、技术边界、候选结论、隐私、单问题、重复问题和目标连续性,不再覆盖合规 Agent 文案;手动 regenerate 改为 Agent 生成、服务器两轮安全校验,不合规后才使用确定性 fallback。 +- 验证:回归覆盖“离家去外地上大学”只保留一条事件但公开 education + relocation、D24 + D4 合法 grounding、家人健康不投射为本人 D30、合规 Agent 文案原样保留、未 grounding 技法与候选结论被拒绝、手动 regenerate 的 repair 与 fallback。 +- 防复发:公开语义只解释用户原话中已存在的复合信号;服务器继续拥有事件身份、评分、事实 grounding 和安全门,正常措辞与提问归 Agent。 +- 相关记录:BUG-107、BUG-108、BUG-109、BUG-110 +- 复发自:BUG-109 +- 修复版本:local / pending release + +## BUG-112 | 活动旧 Case 静默阻止新版 Agentic 生时校正 + +- 状态:resolved(staging pending deployment) +- 首次发现:2026-08-01 +- 最近更新:2026-08-01 +- 影响面:生时校正前端入口、V4 面板、V4 Reasoner 模型选择与运行信息 +- 用户现象:账户存在未结束的旧 Case 时,页面始终进入 `/api/rectification/v4/cases/*`;没有切换新版 Agent 的入口,模型选择器也不影响本轮 Reasoner,fallback 原因与部署版本不可见。 +- 触发条件:打开生时校正时 `loadActiveRectificationV4()` 返回活动 Case。 +- 根因:入口用 `existing ? "v4" : "agentic"` 静默分流;UI 未调用已有 `abandon()`;Reasoner 只读取 Case 固定模型;API 未返回最新 Agent Run 的安全运行摘要。 +- 修复:活动旧 Case 改为显式二选一;进入新版前先结束旧 Case;V4 面板增加同一切换操作;本轮 Turn 模型优先传给 Reasoner并记录实际模型;Case API 只公开最新运行的 mode、model、skill、deployment SHA 与 fallback code。 +- 验证:`frontend/tests/conversational-rectification-component.test.ts`、`frontend/tests/rectification-v4-service.test.ts`。 +- 防复发:入口合同禁止恢复静默 V4 分流;服务测试锁定本轮模型优先级与 runtime trace。 +- 相关记录:BUG-085、BUG-086、BUG-111 +- 修复版本:local / staging pending deployment + +## BUG-113 | 新版 Agentic 生时校正进入会话后不自动生成首次引导 + +- 状态:resolved +- 首次发现:2026-08-02 +- 最近更新:2026-08-02 +- 影响面:生时校正首页入口、Agentic 对话首次挂载、首次可见引导 +- 用户现象:进入“生时校正”后只创建普通 `birth_time_rectification` Session,页面保持空白;网络中没有 `POST /api/rectification/agent`,必须由用户先输入内容才会触发 Agent。 +- 触发条件:账户没有需要继续的旧 V4 Case,入口直接选择新版 Agentic 生时校正。 +- 根因:Agentic MVP 只实现了用户提交消息后的 `send()`,没有迁移旧 V4 在页面挂载时自动启动首次 Agent Turn 的交互契约;后续入口改为默认进入 Agentic 后,这个遗漏被直接暴露。 +- 修复:Agentic 对话首次挂载时只发送一次隐藏的内部启动指令,复用现有 `/api/rectification/agent` 流式路径;界面立即显示 assistant thinking,首条可见说明与问题继续由 Agent 生成,内部指令不渲染为用户消息。 +- 验证:组件回归测试锁定一次性挂载启动、隐藏内部指令和 Agent endpoint 调用入口;前端测试与 lint 覆盖修改文件。 +- 防复发:任何替换生时校正入口或会话实现的改动,都必须保留“用户无需先发消息即可收到 Agent 首次引导”的挂载契约。 +- 相关记录:BUG-085、BUG-112 +- 修复版本:Agentic web opening auto-start + +## BUG-114 | Agentic 生时校正启动指令伪装成用户消息且未知时间被误判为资料缺失 + +- 状态:resolved +- 首次发现:2026-08-03 +- 最近更新:2026-08-03 +- 影响面:首页生时校正入口、`/api/rectification/agent` 首次启动合同、出生资料门、候选范围与确认写入安全门 +- 用户现象:进入生时校正后浏览器发送一段“用户刚进入生时校正会话……”的隐藏 `message`;服务端随后返回“出生日期、时间或出生地点资料不完整”。同时产品入口仍可能恢复或创建 V4 Case,与当前 Agentic 工具链并存。 +- 触发条件:用户从首页进入生时校正;资料使用合法的“只知道时段”或“完全不知道时间”声明,或客户端与服务端出生资料状态不同步。 +- 根因:BUG-113 用伪装成用户消息的字符串补上自动启动,没有建立服务端拥有的 opening operation;产品 wrapper 仍保留 V4 active-case 分流;Agentic profile loader 又把没有具体分钟一律当成缺失,因此合法的不确定时间无法进入最新流程。 +- 修复:产品 wrapper 只挂载 `AgenticRectificationChat`,不再调用或恢复 V4 Case;首次请求改为 `action: "opening"`,服务端把 opening context 注入 Agent Turn,客户端不再发送或渲染隐藏用户指令;首页在创建 Session 前复用 onboarding 资料门,服务端以 `profile_incomplete` 明确回退;`period_only` 使用已声明时段,`unknown` 使用 `00:00–23:59`,跨午夜范围保持原样;gate 返回服务端候选范围,score、diagnostics、features、confirm 拒绝 Agent 自行发明范围,全天宽范围 scan 延后而不伪造中午出生时间。 +- 验证:Agentic 入口、Session、工具、首页入口与组件合同测试覆盖无 V4 产品分流、opening operation、资料回退、时段/未知时间、跨午夜、宽范围降级、候选范围一致性和确认写入门;前端完整测试、lint、build 与 staging 真实 smoke 随本次发布执行。 +- 数据边界:仅废弃 V4 产品入口,历史 V4 代码与数据暂时保留,不在本次发布中做破坏性删除或迁移。 +- 防复发:自动首轮必须是服务端明确 operation,不得伪装成用户文本;“不知道具体分钟”是合法资料状态,不得等同于资料缺失;所有评分与确认工具只能使用服务端候选范围。 +- 相关记录:BUG-112、BUG-113 +- 复发自:BUG-113 +- 修复版本:待本次 staging 修复提交与部署验收 + +## BUG-115 | ISO 出生日期被 Agentic 资料门误判并触发 opening 重试循环 + +- 状态:resolved(local) +- 首次发现:2026-08-03 +- 最近更新:2026-08-03 +- 影响面:`POST /api/rectification/agent`、首页账户资料重新加载、Session 自动恢复、`GET /api/account` 请求频率 +- 用户现象:账户接口已返回完整出生日期、时间线索和地点,Agent opening 仍返回 `profile_incomplete`;页面随后重复请求 `/api/account` 和 `/api/rectification/agent`。部署首轮修复后,刷新页面还会错误回到“先完成出生资料”。 +- 触发条件:数据库驱动把出生日期投影为 `YYYY-MM-DDT00:00:00.000Z`,同时当前活动 Session 是 `birth_time_rectification`。 +- 根因:Agentic profile loader 和首页 `readProfile()` 都把持久化日期直接交给只接受纯 `YYYY-MM-DD` 的资料完整性校验;首轮只兼容了账户 JSON 中的 ISO 字符串,但 staging 自托管 PostgreSQL 数据层在 Agent loader 内实际返回 JavaScript `Date`,因此服务端仍误判 `missing_birth_date`。失败回调清空 `rectificationSessionId` 却未设置现有的自动恢复暂停状态,resume effect 又会立即重新挂载聊天并再次发送 opening。 +- 修复:共享持久化日期规范化函数统一接受合法 `Date`、ISO 字符串和纯日期字符串,由 Agent loader 与首页账户重新加载共同复用;服务端资料失败时先设置 `rectificationError` 暂停自动恢复,资料成功保存后再清除暂停状态。 +- 验证:staging 只读诊断确认目标账户的 `birth_date` 在服务端为 `Date`,时间、地点和误差字段类型均有效;回归覆盖 PostgreSQL `Date`、数据库 ISO 日期、非法日期,以及 profile failure 在清空 Session 前设置自动恢复暂停状态;完整前端测试 1200/1200、lint 0 error、production build 通过。 +- 防复发:数据库日期边界不得假设唯一 JavaScript 序列化形态;所有从账户持久化资料进入完整性校验的路径必须先走同一规范化函数;任何自动挂载请求的失败回调都必须先阻断对应的自动恢复条件。 +- 相关记录:BUG-016、BUG-114 +- 复发自:BUG-114 +- 修复版本:待本次 staging 修复提交与部署验收 + +## BUG-116 | Agent 工具步骤耗尽后静默完成且校正对话刷新即丢失 + +- 状态:resolved(local,空流修复已先部署) +- 首次发现:2026-08-03 +- 最近更新:2026-08-03 +- 影响面:`POST /api/rectification/agent`、Agentic 生时校正消息持久化、首次 opening、余额显示与刷新恢复 +- 用户现象:提交新的人生事件后接口只返回 `{"type":"done","emitted":false}`,页面没有 Agent 回复;刷新页面后此前校正对话全部消失,并再次自动发送 opening、再次预扣咨询点数;页面余额可能保持旧值,让一次请求看起来像多次扣费。 +- 触发条件:Agent 在默认步骤上限内连续调用 `rectification-*` 工具但没有剩余步骤生成公开文本;或 Agentic 校正组件卸载/刷新,而 `chat_sessions.messages` 仍为空。 +- 根因:Mastra 默认步骤上限不足,路由又把空 `textStream` 当作正常完成;新版组件只把消息保存在 React 本地状态,没有复用现有 `chat_sessions` 持久化边界,自动 opening 也只检查本次组件实例的 ref;新 Session 还可能在数据库创建完成前挂载 Agent;请求完成后没有刷新账户余额。 +- 修复:Agent 步骤上限提升为 8,解析后仍无可见文本时返回明确 error 并退款,不再发送 `done false`;请求绑定当前用户的 `birth_time_rectification` Session,成功回复先原子更新完整消息再发送 `done` 并完成扣费;已有持久化消息拒绝重复 opening;客户端从 Session 初始化、成功后同步首页状态并刷新一次账户余额,未收到持久化成功的 `done` 时移除临时 Assistant;新 Session 先创建成功再挂载 Agent,并按 Session key 重建本地对话状态。 +- 验证:`frontend/tests/rectification-agentic-entry.test.ts` 覆盖多步公开回复、空流退款合同、Session 归属与写回、刷新抑制 opening、失败流清理、新 Session 创建顺序;完整测试、lint、build 与 staging 真实刷新/扣费 smoke 随本次发布执行。 +- 数据边界:复用现有 `chat_sessions.messages`,不新增平行对话存储;不从用户粘贴内容擅自回填旧 Session;不修改身份、credits 历史或出生资料。 +- 防复发:公开回复必须同时满足“可见文本 + Session 持久化成功”才能发送完成事件;自动 opening 必须以服务端 Session 历史为准,不能只依赖组件内存。 +- 相关记录:BUG-113、BUG-114、BUG-115 +- 修复版本:空流修复 `e65c8eeda2ff5916f88f18dd345c02beff045e8b` / Session 持久化待本次 staging 发布 + +## BUG-117 | 用户采纳最强候选后无法保存为平台排盘时间 + +- 状态:resolved(local) +- 首次发现:2026-08-04 +- 最近更新:2026-08-04 +- 影响面:Agentic 生时校正候选结果、个人资料出生时间、后续咨询排盘时间 +- 用户现象:`04:55` 已是最强候选,用户多次明确表示“就用 04:55”,但 Agent 因唯一分钟确认门未通过而拒绝保存,个人资料和后续排盘仍未使用该时间。 +- 根因:系统把引擎候选、用户采纳和引擎唯一确认压缩成单一 `confirmed` 状态;没有可持久化的候选身份和用户采纳边界。 +- 修复:引入 `candidate / accepted / confirmed` 三态;服务端持久化候选身份、相对支持度、Session 所有权和 Profile 基线;新增 service-role 原子采纳 RPC;前端展示候选卡并允许用户采用;`accepted` 接入个人资料和全平台排盘。 +- 数据边界:相对支持度仅表示本次候选间的归一化比较,不是统计概率;保留 `reported_birth_time`;`accepted` 不冒充引擎唯一确认。 +- 安全边界:RPC 校验用户、Session、结果身份、有效期、候选成员、最新结果和 Profile 基线;出生申报资料变化使旧候选失效;采纳不计费。 +- 验证:TypeScript 通过;聚焦测试 90/90;完整测试 1221/1221;lint 0 error、3 个既有 warning;production build 通过;本地 PostgreSQL 验证 `04:55` 写为 `accepted`、保留 `05:00` reported time、重复采纳幂等,并验证出生申报时间变化会使结果失效且拒绝再次采纳。 +- 相关记录:BUG-113、BUG-114、BUG-115、BUG-116 +- 修复版本:待提交与发布 + +## BUG-118 | 候选已落库但确认卡不显示,VedAstro 未执行被误述为未通过 + +- 状态:resolved(local,pending deployment) +- 首次发现:2026-08-04 +- 最近更新:2026-08-04 +- 影响面:Agentic 生时校正候选 SSE、self-hosted PostgreSQL 查询兼容层、确认门公开语义 +- 用户现象:`rectification-confirm` 已返回并持久化 `selection_allowed=true` 的候选时间,但页面只显示 Agent 文本,不显示候选确认卡;Agent 同时把 VedAstro 未执行、邻近分钟诊断和留一事件诊断混写成确认门未通过。 +- 根因:候选恢复查询调用 `.gt("expires_at", now)`,而 staging 使用的 `LocalPostgresQueryBuilder` 未实现 `gt`,路由捕获读取异常后仍发送完成事件;外部验证本身只有在本地候选满足事件数、领域数、窄区间、唯一领先和必需层完整时才执行,`missing_mandatory_layers` 会使其保持 `not_evaluated`,并非 VedAstro 调用失败。邻近分钟与留一事件在 technique contract 中仅为诊断项。 +- 修复:在共享本地 PostgreSQL query builder 中实现参数化 `gt` 过滤;保留现有候选卡与 SSE 协议不另起状态;确认工具显式返回外部验证是否已调用、状态和原因,并要求 Agent 区分 `not_evaluated` 与 `fail`,不得把诊断项描述为硬阻塞。 +- 验证:真实 local PostgreSQL business client 回归覆盖未过期候选读取;Agentic 工具回归覆盖 `not_evaluated` 映射为 `external_validation_invoked=false`;Session、entry、candidate persistence 聚焦测试通过。 +- 防复发:self-hosted query builder 新增 Supabase/PostgREST 链式操作时必须由真实 PostgreSQL fixture 覆盖;公开文案必须按 `external_engines.status` 区分未执行、失败和通过。 +- 相关记录:BUG-116、BUG-117 +- 修复版本:待本次 staging 修复提交与部署验收 + +## BUG-119 | 候选采用覆盖兼容出生时间且个人资料不显示双时间记录 + +- 状态:resolved(local,pending deployment) +- 首次发现:2026-08-04 +- 最近更新:2026-08-04 +- 影响面:Agentic 生时校正候选采用、个人资料出生时间展示、账户资料刷新、后续排盘时间 +- 用户现象:采用候选 `05:06` 后,账户虽然返回 `active_birth_time=05:06`,但个人资料仍只展示初始化填写的 `05:00`;数据库兼容字段 `birth_time` 同时被改成 `05:06`,导致“原始填报”与“校正采用”语义混在一起。 +- 根因:候选采用 RPC 主动把 `active_birth_time` 和兼容字段 `birth_time` 同时写为候选时间,旧 `guard_birth_time_journey()` 触发器还会双向镜像这两个字段;客户端 `refreshAccount()` 只刷新账户对象,没有同步个人资料展示使用的独立 `profile` state。 +- 修复:新增向前迁移解除 `birth_time` / `active_birth_time` 双向镜像,候选采用只写服务端拥有的 `active_birth_time`,并修复既有 Agentic 采用记录;保留 `reported_birth_time` 作为用户原始填报。账户刷新同步 `profile` 但不覆盖正在编辑的 draft;个人资料同时展示“当前排盘时间”和“原始填报时间”,候选卡明确采用边界并移除易被误解为概率的进度条。 +- 数据边界:`reported_birth_time` 是原始填报,`active_birth_time` 是平台当前排盘时间,`birth_time` 仅保留旧系统兼容用途;`accepted` 是用户采用,不等于引擎唯一确认。后续咨询继续读取 `active_birth_time`。 +- 验证:聚焦账户、迁移、Agentic UI 合同测试 38/38;本地 PostgreSQL 完整迁移与业务测试通过,验证采用后 `active_birth_time=04:55`、`birth_time_status=accepted`、`reported_birth_time=05:00`、`birth_time=null`。 +- 相关记录:BUG-117、BUG-118 +- 修复版本:待提交与发布 + +## BUG-120 | Agent 仍在追问事件时过早显示候选采用卡且采用后无法改选 + +- 状态:resolved(local,pending deployment) +- 首次发现:2026-08-04 +- 最近更新:2026-08-04 +- 影响面:Agentic 生时校正确认门、候选卡展示时机、候选采用交互与数据库原子写入 +- 用户现象:Agent 回复仍在要求补充事件或确认日期时,页面已经显示三项候选并可立即采用;候选采用后所有选项被禁用,无法在同一批有效候选中改选。 +- 触发条件:确认工具取得至少三条事件、覆盖两个领域且返回候选,但引擎仍为 `continue_rectification`;或用户已经采用当前结果中的一个候选。 +- 根因:确认工具仅用事件数、领域数和候选存在性推导 `selection_allowed`,没有区分“继续收集证据”与“结束收集并邀请选择”;Agent 合同未禁止同轮追问和提供采用;前端与 RPC 又把首次采用误当成不可变终态。 +- 修复:`rectification-confirm` 新增显式 `offer_selection`,继续追问时必须为 `false`,仅在用户要求现在选择或本轮唯一下一步是选择候选时为 `true`;引擎真正通过唯一分钟确认门时仍自动允许确认。候选卡改为桌面端一行三列、移动端横向滚动,说明候选来自当前事件、可继续补充事件并重新计算;采用后保留其他候选可点击。向前迁移允许在候选结果仍有效且 Profile 基线未漂移时原子改选。 +- 数据边界:继续补事件不会把当前候选冒充最终结果;相对支持度不是统计概率;改选只更新 `active_birth_time` 和采用记录,不覆盖 `reported_birth_time`,也不写兼容字段 `birth_time`。 +- 验证:Agent 工具与入口合同聚焦测试 37/37;真实本地 PostgreSQL 业务测试通过 `04:55 -> 05:07` 改选并保持 `reported_birth_time=05:00`、`birth_time=null`;TypeScript、聚焦 ESLint 与 production build 通过;桌面端三列和移动端横向滚动截图已完成视觉检查。 +- 防复发:任何非唯一候选卡必须由显式选择阶段开启;同一 Agent 回复不得既索取新证据又提供采用操作;候选采用测试必须覆盖幂等、改选、过期结果和 Profile 基线漂移。 +- 相关记录:BUG-117、BUG-118、BUG-119 +- 修复版本:待提交与发布 + +## BUG-121 | 月份与区间事件在确认工具中被序列化成错误日期格式并耗尽 Agent 步骤 + +- 状态:resolved +- 首次发现:2026-08-04 +- 最近更新:2026-08-04 +- 影响面:Agentic 生时校正结束收集、V5 评分/诊断、旧确认门适配、无回复退款兜底 +- 用户现象:用户明确表示不再补充事件后,接口返回“生时校正没有生成有效回复,本次不会扣除点数,请重新发送”,没有展示最终候选或后续选择。 +- 触发条件:历史证据同时包含 `year`、`month` 或 `range` 精度;Agent 在结束收集时调用 `rectification-confirm`。月份或年份事件被发送成完整日期,区间事件又可能使用 `/`、`to` 等自然分隔形式。 +- 根因:共享事件 schema 只检查字符串长度;V5 转换只识别 `..` 区间;`toV3Event()` 又把标准化后的 `YYYY-MM-DD` 与 `month`/`year` 精度一起发送给只接受 `YYYY-MM`/`YYYY` 的旧确认端点。引擎持续返回 `event date does not match its precision`,Agent 在 8 个工具步骤内反复修正和重试,最终没有剩余步骤生成公开文本。该问题是 BUG-116 的输入契约残余变体,提高步骤数只能延后失败。 +- 修复:事件日期统一复用严格日历范围转换;V5 保留年月日和区间的 `date_start`/`date_end`,并兼容 `..`、`/`、`to`、中文范围符和紧凑年月范围;旧确认端点按精度发送严格的 `YYYY`、`YYYY-MM`、`YYYY-MM-DD`,区间按旧端点能力降级为年份证据且摘要仍保留原区间语义。工具 schema 同时明确推荐日期格式。 +- 验证:新增 V5 区间归一化和旧确认精度序列化回归;Agentic 工具/入口/会话合同测试 51/51,通过针对性 ESLint、`tsc --noEmit` 和 production webpack build。使用脱敏后的原始长对话本地重放,Agent 在 5 次工具调用内完成 `gate -> score -> diagnostics -> confirm`,工具错误 0,生成 610 字可见候选回复,不再触发空回复退款。 +- 防复发:任何送往旧事件引擎的日期必须由精度契约测试断言;新增日期表示必须先走共享日历校验,不能在调用端自行拼接或仅增加 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/adr/0001-separate-agent-failures-from-reply-ratings.md b/docs/adr/0001-separate-agent-failures-from-reply-ratings.md new file mode 100644 index 00000000..e2f6f626 --- /dev/null +++ b/docs/adr/0001-separate-agent-failures-from-reply-ratings.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# 分离 Agent 执行故障与回复评价 + +所有 Agent 会话使用稳定的会话、轮次、回复、执行尝试和请求标识建立关联,但将技术执行故障与用户对完整回复的质量评价建模为两类记录;服务端和客户端故障按请求标识关联去重,重复执行按轮次聚合展示。这样可以分别衡量系统可用性与回答质量,并保留重试轨迹,而不会把业务拒绝、技术失败和内容不满意混成同一种“报错”。 diff --git a/docs/adr/0002-minimize-and-expire-conversation-quality-content.md b/docs/adr/0002-minimize-and-expire-conversation-quality-content.md new file mode 100644 index 00000000..fcf44d7f --- /dev/null +++ b/docs/adr/0002-minimize-and-expire-conversation-quality-content.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# 最小化并限期保留对话质量正文 + +对话质量记录按排错需要最小化收集:执行故障保存实际使用的上下文,负向评价保存对应 Agent 对话轮次,正向评价只保存脱敏统计元数据;禁止保存密钥、认证信息、系统提示词、原始第三方响应和完整堆栈。故障及负向评价正文最多保留 90 天,用户删除会话或撤回负向评价时提前清除相关正文;不可还原对话的聚合数据、审计轨迹和脱敏管理员备注可以长期保留,以平衡问题追踪与用户隐私。 diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 00000000..b548c538 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,51 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root, or +- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic. +- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src//docs/adr/` for context-scoped decisions. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved. + +## File structure + +Single-context repo (most repos): + +``` +/ +├── CONTEXT.md +├── docs/adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +Multi-context repo (presence of `CONTEXT-MAP.md` at the root): + +``` +/ +├── CONTEXT-MAP.md +├── docs/adr/ ← system-wide decisions +└── src/ + ├── ordering/ + │ ├── CONTEXT.md + │ └── docs/adr/ ← context-specific decisions + └── billing/ + ├── CONTEXT.md + └── docs/adr/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 00000000..82cfbf5b --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,45 @@ +# Issue tracker: GitHub + +Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations. + +## Conventions + +- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies. +- **Read an issue**: `gh issue view --comments`, filtering comments by `jq` and also fetching labels. +- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. +- **Comment on an issue**: `gh issue comment --body "..."` +- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` +- **Close**: `gh issue close --comment "..."` + +Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone. + +## Pull requests as a triage surface + +**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_ + +When set to `yes`, PRs run through the same labels and states as issues, using the `gh pr` equivalents: + +- **Read a PR**: `gh pr view --comments` and `gh pr diff ` for the diff. +- **List external PRs for triage**: `gh pr list --state open --json number,title,body,labels,author,authorAssociation,comments` then keep only `authorAssociation` of `CONTRIBUTOR`, `FIRST_TIME_CONTRIBUTOR`, or `NONE` (drop `OWNER`/`MEMBER`/`COLLABORATOR`). +- **Comment / label / close**: `gh pr comment`, `gh pr edit --add-label`/`--remove-label`, `gh pr close`. + +GitHub shares one number space across issues and PRs, so a bare `#42` may be either — resolve with `gh pr view 42` and fall back to `gh issue view 42`. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue. + +## When a skill says "fetch the relevant ticket" + +Run `gh issue view --comments`. + +## Wayfinding operations + +Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets. + +- **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. `gh issue create --label wayfinder:map`. +- **Child ticket**: an issue linked to the map as a GitHub sub-issue (`gh api` on the sub-issues endpoint). Where sub-issues aren't enabled, add the child to a task list in the map body and put `Part of #` at the top of the child body. Labels: `wayfinder:` (`research`/`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev. +- **Blocking**: GitHub's **native issue dependencies** — the canonical, UI-visible representation. Add an edge with `gh api --method POST repos///issues//dependencies/blocked_by -F issue_id=`, where `` is the blocker's numeric **database id** (`gh api repos///issues/ --jq .id`, _not_ the `#number` or `node_id`). GitHub reports `issue_dependencies_summary.blocked_by` (open blockers only — the live gate). Where dependencies aren't available, fall back to a `Blocked by: #, #` line at the top of the child body. A ticket is unblocked when every blocker is closed. +- **Frontier query**: list the map's open children (`gh issue list --state open`, scoped to the map's sub-issues / task list), drop any with an open blocker (`issue_dependencies_summary.blocked_by > 0`, or an open issue in the `Blocked by` line) or an assignee; first in map order wins. +- **Claim**: `gh issue edit --add-assignee @me` — the session's first write. +- **Resolve**: `gh issue comment --body ""`, then `gh issue close `, then append a context pointer (gist + link) to the map's Decisions-so-far. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 00000000..b716855d --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. + +Edit the right-hand column to match whatever vocabulary you actually use. 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/research/web_vs_local_birth_time_rectification_diagnosis_2026_08_01.md b/docs/research/web_vs_local_birth_time_rectification_diagnosis_2026_08_01.md new file mode 100644 index 00000000..8fa6eab9 --- /dev/null +++ b/docs/research/web_vs_local_birth_time_rectification_diagnosis_2026_08_01.md @@ -0,0 +1,205 @@ +# Web 生时纠正 vs 本地 Claude Code:差异诊断与改进方案 + +> 日期:2026-08-01 +> 状态:诊断完成;改进方案第 4 节"完整 MVP"已实施(见第 7 节) +> 范围:`jyotish-vedic-astrology` skill 方法论层 vs Web `skills/birth-time-rectification` 受限产品层 + +## 结论摘要 + +Web(staging)上用户感受到的"生时纠正交互僵硬、问题像硬编码模板",**不是 bug,而是两种刻意不同的架构**: + +- **本地 Claude Code**:LLM 是**主分析师**,走 `jyotish-vedic-astrology` skill 的完整方法论,可自由多轮提问、运行脚本、交叉验证,最终产出精确出生分钟。 +- **Web(Mastra)**:LLM 是**被约束的叙述者**,走 `skills/birth-time-rectification/SKILL.md`(36 行受限证据工作流)。服务端 Python 引擎 + TS 状态机拥有全部计算(候选扫描/评分/诊断/事件 ID/策略门控),LLM 只能在服务端预建的问题机会里选一个、再渲染短中文回复,永不确认单一分钟。 + +staging 前端实际挂载的是 **v4 rectification** 入口(`RectificationV4Panel` → `/api/rectification/v4/*`)。"硬编码感"主要来自服务端模板问题生成器 `opportunity-builder.ts`,与 v4/v5 模式切换无关。 + +--- + +## 1. 两个系统的架构对比 + +| 维度 | 本地 Claude Code | Web(Mastra v4 rectification) | +|---|---|---| +| 使用的 skill | `jyotish-vedic-astrology`(仓库根 `SKILL.md`,712 行,版本 6.9.14) | `skills/birth-time-rectification/SKILL.md`(36 行) | +| skill 目录结构 | symlink 指向根目录 `SKILL.md` + `references/`(100+ 方法论文档)+ `scripts/`(`jyotish_engine.py` 37 子命令)+ `assets/` | 36 行 SKILL.md + 6 个契约文件在 `skills/birth-time-rectification/references/` + `assets/rectification-capability-matrix.json` | +| 方法论 | 8 大方法(Dasha+Transit、D9 Navamsa、D10 Dasamsa、六亲、外表体质、身体缺陷、职业判断、卜卦【AI 暂不支持】);五阶段流程(收集→±30min→±15min→事件验证→D9/D10 收口到 ±5min→报告);决策树权重 Dasha 40% / D9+D10 35% / 专题层 15% / Nakshatra Pada 10% | 受限证据工作流:服务端扫描候选时间簇→评分→生成高信息量机会;agent 每轮只问一个自然问题;输出候选区间而非确定时间 | +| LLM 角色 | 主分析师,自由推理 | 被约束的叙述者(reasoner 选机会 / renderer 渲染) | +| 计算归属 | LLM 驱动 + `scripts/` 脚本 + 外部 oracle(PyJHora/VedAstro/jyotishganit) | 服务端 Python 引擎 + TS 状态机全拥有 | +| 输出 | 验证后的出生分钟(±5min) | 候选区间(`profiles.active_birth_time` 永不直接写入) | +| 错误处理 | 交互式纠错 | 幂等重放(action receipt + fingerprint)、确定性回退 | + +**两者的关系**:`jyotish-vedic-astrology` skill 内部同时定义了这两层——方法论层(`references/birth-time-rectification-advanced.md`)和受限产品工作流层(独立的 `skills/birth-time-rectification/`)。Web 端刻意只暴露受限产品层。 + +--- + +## 2. 为什么 Web 无法复刻本地交互(6 个根源) + +### 2.1 权威模型相反(设计边界,不是 bug) + +`skills/birth-time-rectification/SKILL.md` 硬边界原文: + +> - The server owns candidate scanning, scores, diagnostics, event IDs, and policy gates. +> - The agent may select one server-provided opportunity or request one server-provided diagnostic. +> - Never invent candidate times, scores, event IDs, dates, techniques, or tool inputs. +> - Never confirm a single minute or write `profiles.active_birth_time`. + +这是一整套产品决策:**计费**(`billing.ts` reserve/complete/release)、**不暴露内部分数**(用户只能看到"候选区间"而非权重/评分)、**可靠性**(服务端计算确定性可审计,LLM 只做叙述)、**truth-overlay 合规**(`references/oracle/rectification_technique_usage_audit_2026_07_19.json` 把 D9/D10/D60 等标为"敏感度证据不是证明")。本地 Claude Code 没有这些约束,所以能做完整方法论。 + +### 2.2 问题是服务端模板("硬编码感"最强处) + +`frontend/src/lib/rectification-agent/opportunity-builder.ts`: + +- **固定模板文案**:`prompt` 字段全部是写死的句子,例如—— + - `clarify_event_subject`:"你刚才提到"X",这件事主要发生在你本人,还是家人或伴侣身上?" + - `refine_event_date`:"关于"X",你还记得更具体的月份或日期吗?不确定也可以只说大概范围。" + - `ask_new_event` 各领域:career/relationship/health_pressure 等各一句。 +- **硬编码 utility 公式**(L24-30):`.35*expectedInformationGain + .20*dateSensitivity + .15*candidateSplitRelevance + .10*domainCoverageGain + .10*recallEase + .10*novelty + routingValue[kind] - repetitionPenalty - privacyCost`,其中 `routingValue` 也是写死的(L14-22)。 +- reasoner(`reasoner-agent.ts`)只按 `opportunityId` 选一个机会,**从不用自己的话提问**。 + +> v3 对话式(`/api/birth-time-conversation`)的 `narrative-agent.ts` 已带 `freeConversation` 设置、允许 agent 自由措辞——但 v3 后端未接入当前 UI 面板。 + +### 2.3 每轮只问一个问题 + +skill turn strategy:"Ask one natural question only"。`reasoner-agent.ts` 的决策被 `rectificationDecisionSchema` 严格约束,`maxToolCalls` 默认 1、最多一次 `run_rectification_diagnostics` 工具调用,然后必须返回终态动作。本地 Claude Code 是自由多轮对话。 + +### 2.4 Mastra skill 懒加载 + +`@mastra/core`(v1.50.1)的 skill 机制:`skills: [skillPath]` 只把 skill **元数据**(name/description,`` 块)注入系统提示;完整 `SKILL.md` 要模型主动调 `skill` 工具才在对话中加载(`node_modules/@mastra/core/dist/` 的 `SkillsProcessor`)。deepseek 走 `structuredOutput` 路径时未必稳定触发 `skill` 工具 → LLM 实际可用的指令比预期少。 + +### 2.5 硬编码业务规则 + +- `references/rectification_policy.v1.json`:`minScoringEvents=1`、`minConfirmationEvents=4`、`minConfirmationDomains=3`、`maxExternalValidationWidthMinutes=15`、`maxConfirmationWidthMinutes=5`、`minConfirmationMarginPercent=20`、`maxPlateauRounds=2`。→ 必须凑够 ≥4 个事件、≥3 个领域,否则一直追问,造成"问卷感"。 +- 时段区间(`orchestrator.ts` L565-594、`handler.ts` L426-451):early_morning/morning/afternoon/evening/late_night;不确定性(医院 ±2min、家庭 5/10/15、约估 15/30/60)。 +- 正则模式(`orchestrator.ts` L132-139):方向切换词/不确定词/肯定否定词/相对日期词。 +- 领域分类关键词表(`evidence-extractor.ts` L101-146)。 +- 回退文案(`narrative-agent.ts` L639-651)。 +- 模型 ID(`handler.ts` L831-832):`deepseek-v4-pro` / `deepseek-v4-flash`。 + +### 2.6 渲染约束 + +- reasoner/renderer 都强制 `structuredOutput` JSON(`reasoner-agent.ts` L124、`renderer-agent.ts` L62)。 +- renderer 还要 `enforceServerQuestion`(L38-40、L63)把服务端预建的 `exactQuestion` 强制覆盖进输出——LLM 措辞被服务端文案顶替。 +- 模型为 deepseek 系列(非 Claude),对话自然度与指令遵循不同。 + +--- + +## 3. staging 入口确认 + +| 项 | 结论 | 证据 | +|---|---|---| +| 前端 UI | **v4 rectification**:`ConversationalBirthTimeRectification` 只是 `RectificationV4Panel` 的别名 | `components/conversational-birth-time-rectification.tsx:20` | +| API 入口 | `/api/rectification/v4/*` | `lib/rectification-v4/client.ts`(cases / active / answer / revise / accept-range / pause/resume/abandon) | +| v4 流程内部 | `runBoundedReasoner`(reasoner-agent.ts)+ `renderPublicTurn`(renderer-agent.ts),两者 `skills: [rectificationSkillPath]`(受限 36 行 skill) | `reasoner-agent.ts:115`、`renderer-agent.ts:16` | +| 模型 | `RECTIFICATION_ORCHESTRATION_MODEL_ID` / `RECTIFICATION_NARRATION_MODEL_ID`(未设则默认目录) | `case-service.ts:46-47` | +| v5 agent vs v4 legacy | 由部署宿主 `.env.staging` 的 `RECTIFICATION_AGENT_V5_ENABLED` / `RECTIFICATION_AGENT_V5_CANARY_PERCENT` / `RECTIFICATION_AGENT_V5_SHADOW` 决定(`feature-policy.ts`),仓库不可见;**两种模式都走同一套受限 skill + 模板问题** | `lib/rectification-agent/feature-policy.ts:26-39` | +| v3 对话式 | 按 rollout audience(paused/smoke_only/public)门控,**未接入当前 UI 面板** | `deploy/configure-staging-rectification-rollout.sh`、`components/rectification-v4-panel.tsx`(无 v3 引用) | +| 部署副本 | Dockerfile 把 `SKILL.md`/`assets`/`references`/`scripts`/`skills` 拷进 `/app/`,symlink 保留 | `deploy/railway-web.Dockerfile:18-22` | + +> 注:`/api/health` 只上报 v3 的 rollout 状态(`rollout.conversationalRectificationV3.creationAudience`),不包含 v5 agent 的开关值,因此 v5 模式是否在 staging 开启需查部署宿主的 `.env.staging`。 + +--- + +## 4. 改进方案(在"服务端拥有计算"护栏内) + +按侵入性从低到高排列,均为**建议**(本次未实施)。任何方案都不得把内部分数/权重/事件 ID 暴露给用户,不得确认单一分钟。 + +### 4.1 即时注入 skill 指令(低侵入,收益高) + +- **改动**:把 36 行 `skills/birth-time-rectification/SKILL.md` 直接内联进 reasoner/renderer 的 `instructions`(`reasoner-agent.ts:117`、`renderer-agent.ts:17`),保留 `skills: [skillPath]` 作为能力来源。 +- **效果**:消除 Mastra skill 懒加载不确定性——模型每轮都确定拥有"turn strategy + public language + 硬边界"指令。 +- **风险**:低。指令与 skill 内容一致,只是从懒加载改为常驻。 + +### 4.2 LLM 起草问题 + 服务端 grounded 校验(中侵入,消除"模板感"核心) + +- **改动**:`opportunity-builder.ts` 保留"选哪个机会"的服务端决策(kind/targetEventId/domain/utility),但把 `prompt` 从"必须原样使用"改为"话题约束";reasoner 用自然语言起草问题文本;新增一个 grounding 校验(复用 `narrative-agent.ts` 的 grounding 思路)确认草稿:① 命中目标事件/领域 ② 不含内部分数/权重/事件 ID ③ 是单问。 +- **效果**:问题随上下文自适应,消灭"你刚才提到X…"的模板感。 +- **风险**:中。需要新增校验层与测试;reasoner 输出 schema 从"选 opportunityId"扩展为"选 opportunityId + 起草文本"。 + +### 4.3 自由对话回合(中侵入) + +- **改动**:服务端没有待处理机会(`opportunities` 为空或全部低效用)时,允许 agent 走"自然回应"而非强制提问。可复用 v3 `narrative-agent.ts` 的 `freeConversation` / `questionsAreOptional` 提示词模式,让 renderer 生成 1-3 句自然中文 + 可选开放收尾。 +- **效果**:不再每轮都是"选择题",更像本地对话。 +- **风险**:中。需防止发散、防止确认未验证分钟;收敛判定仍由服务端掌控。 + +### 4.4 渲染放宽(中侵入) + +- **改动**:renderer 从 `structuredOutput` JSON 改为自然中文文本输出 + 事后校验(`enforceServerQuestion` 保留为兜底,仅当需要明确问题时强制服务端文案)。 +- **效果**:回复更自然,减少 JSON 式僵硬措辞。 +- **风险**:中。需新的文本校验(主题、长度、泄密扫描)。 + +### 4.5 模型目录加入 Claude(低侵入,可选) + +- **改动**:`frontend/src/mastra/model.ts` 的模型目录加入 Claude(如 `claude-sonnet-5`),`RECTIFICATION_NARRATION_MODEL_ID` 指向它。 +- **效果**:叙事/对话质量显著提升(deepseek 在结构化约束下更易模板化)。 +- **风险**:低,纯配置;需确认供应商密钥与成本。 + +--- + +## 5. 不应改动(设计边界) + +以下为 `birth-time-rectification` skill 与产品契约的硬性约束,**任何改进都不得触碰**: + +1. 服务端拥有候选扫描、评分、诊断、事件 ID、策略门控。 +2. 永不确认单一分钟;永不直接写 `profiles.active_birth_time`。 +3. 候选区间只有确定性稳定门通过才对用户可见(`canAcceptRange`)。 +4. 计费幂等(billing reserve/complete/release + action receipt 指纹重放)。 +5. truth-overlay 强制降级:`reference_only`/`blocked`/`partial` 技法不得作为确定性结论(`references/oracle/skill_truth_overlay_2026_07_19.json`)。 +6. 不暴露内部分数、权重、领域标签、工具载荷、agent 轨迹。 + +改进目标是让**叙述/提问的自然度**贴近本地,而不是让 Web 复刻本地的方法论深度——那需要把整条计算链路搬进 LLM 上下文,与现有产品架构冲突。 + +--- + +## 6. 附:关键文件索引 + +| 文件 | 作用 | +|---|---| +| `skills/birth-time-rectification/SKILL.md` | Web 端受限 skill(36 行硬边界) | +| `SKILL.md`(仓库根) | 本地完整 skill(712 行方法论,symlink 到 skill 目录) | +| `frontend/src/lib/rectification-agent/opportunity-builder.ts` | 服务端模板问题生成器(硬编码根源) | +| `frontend/src/lib/rectification-agent/reasoner-agent.ts` | v4/v5 reasoner(选机会 + diagnostic 工具) | +| `frontend/src/lib/rectification-agent/renderer-agent.ts` | v4/v5 renderer(渲染公开回合 + enforceServerQuestion) | +| `frontend/src/lib/rectification-agent/feature-policy.ts` | v4_legacy / v5_shadow / v5_agent 选择 | +| `frontend/src/lib/rectification-v4/case-service.ts` | 建 case、deployment_mode、模型 ID | +| `frontend/src/lib/rectification-v4/supabase-store.ts` | 持久化 deployment_mode/agent_mode | +| `frontend/src/lib/conversational-rectification/narrative-agent.ts` | v3 叙事 agent(freeConversation 参考实现) | +| `frontend/src/app/api/birth-time-conversation/handler.ts` | v3 handler(deepseek 模型 ID、流式) | +| `references/rectification_policy.v1.json` | 收敛门槛硬编码 | +| `deploy/configure-staging-rectification-rollout.sh` | staging rollout(paused/smoke_only/public) | +| `frontend/supabase/migrations/20260728020000_*.sql` | v5 列(deployment_mode/agent_mode/model id/version) | + +--- + +## 7. 已实施:Agentic 生时纠正 MVP(2026-08-01) + +按用户决策"完全复刻本地方法论",实现了一个新的 **agentic 生时纠正**聊天流:LLM 挂载完整 `jyotish-vedic-astrology` skill,像本地 Claude Code 一样驱动方法论,通过引擎工具请求计算(而不是自己瞎算),自由多轮对话,最终经高 rigor 确认门 + 用户明确同意后写回 `profiles.active_birth_time`。 + +### 7.1 新增文件 + +| 文件 | 作用 | +|---|---| +| `frontend/src/mastra/rectification-tools.ts` | 7 个工具包 Python 引擎端点:`rectification-gate`(精度门)、`rectification-scan`(分钟敏感度扫描)、`rectification-score`(V5 矩阵评分)、`rectification-diagnostics`(鲁棒性诊断)、`rectification-candidate-features`(候选静态特征)、`rectification-confirm`(高 rigor 三引擎 parity 确认门)、`rectification-save-birth-time`(服务端双重校验后写 profile) | +| `frontend/src/mastra/agentic-rectification.ts` | agent 工厂:完整 skill + 工具 + 中文指令(方法论流程、truth overlay、保存门控) | +| `frontend/src/lib/rectification-agentic/session.ts` | 会话支持:加载 profile 出生字段、`applyConfirmedBirthTime` 调 service-role RPC 写回 | +| `frontend/src/app/api/rectification/agent/route.ts` | NDJSON 流式端点:认证 → profile → 计费 reserve → agent.stream → delta/done 事件 → settle | +| `frontend/src/components/rectification-agentic-chat.tsx` | 聊天面板:流式渲染、隐藏块解析(suggestions/title/保存哨兵)、错误处理 | +| `frontend/src/components/conversational-birth-time-rectification.tsx` | 入口智能切换:有进行中的 v4 case → v4 面板恢复;否则 → agentic 聊天 | +| `frontend/supabase/migrations/20260801000000_agentic_rectification_profile_write.sql` | `apply_agentic_rectification_birth_time` RPC(security definer,仅 service_role,含基线并发保护) | +| `frontend/tests/rectification-agentic-tools.test.ts` / `rectification-agentic-session.test.ts` | 12 个测试 | + +### 7.2 安全门控(核心) + +LLM 绝不能写任意分钟。`rectification-confirm` 只有在引擎高 rigor 门全过(≥4 事件、≥3 领域、宽度/边际阈值、三引擎 parity、外部 VedAstro 校验)返回 `confirmation_allowed=true` + 确认分钟时,才在会话闭包中设置 `confirmedGate`;`rectification-save-birth-time` 要求请求的时间**恰好等于**该确认分钟,才调用 RPC 写库。RPC 还带 `p_baseline_time` 并发保护(当前 active 时间必须仍是会话开始时的基线)。 + +### 7.3 验证 + +- `npx tsx --test tests/*.test.ts`:**1076 全通过**(含 12 个新测试)。 +- `npx tsc --noEmit`:新文件零错误(仓库剩余 5 个为预先存在)。 +- `npx eslint`:新文件零错误零警告。 + +### 7.4 待办/注意 + +- **引擎端点鉴权**:`rectification-save-birth-time` 走的 RPC 仅 service_role;引擎各 rectification 端点无需 token(与 `runConsultationWorkflow` 一致)。 +- **计费**:按消息 reserve/complete/cancel 咨询点数(复用 `begin/complete/cancel_consultation_credit`)。 +- **v4 保留**:有进行中 v4 case 时仍走 v4 面板恢复,不丢数据。 +- **模型**:默认走当前模型目录;若想让叙事用 Claude,在 `LLM_MODELS_JSON` 加 Claude 项并把 `LLM_DEFAULT_MODEL_ID` 指过去即可。 +- **部署**:新路由无需新环境变量(复用 `JYOTISH_API_BASE`、Supabase 密钥、模型目录);新迁移需在 staging 执行 `db:migrate`。 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/db/migrations/20260727000000_admin_viewer_identity.sql b/frontend/db/migrations/20260727000000_admin_viewer_identity.sql new file mode 100644 index 00000000..9fef5a39 --- /dev/null +++ b/frontend/db/migrations/20260727000000_admin_viewer_identity.sql @@ -0,0 +1,6 @@ +-- Admin-host sessions may be created for read-only viewers. API authorization +-- remains server-side and is resolved from this persisted role on every request. +-- Existing identity migrations already grant admin_runtime these reads; repeat the +-- least-privilege user grant so drifted staging databases fail closed at login. + +grant select on table identity.users to admin_runtime; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index acb1727f..da1047c3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -28,13 +28,19 @@ "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", "thinking-orbs": "^0.1.1", "tsx": "^4.23.1", "tw-animate-css": "^1.4.0", - "zod": "^3.25.76" + "zod": "^3.25.76", + "@ant-design/icons": "^6.3.2", + "@refinedev/antd": "^6.0.3", + "@refinedev/core": "^5.0.12", + "@refinedev/nextjs-router": "^7.0.5", + "antd": "^5.29.3" }, "devDependencies": { "@types/node": "^20", @@ -10459,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", @@ -11691,6 +11703,2358 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "node_modules/@ant-design/icons": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.3.2.tgz", + "integrity": "sha512-B6O5a5XJ4wjtNOfZejXYwHW5zvKV5gYkjGf11dHGLEbKn0ABDGndo41+gfIiXyTFhvESj4XTotuud33mUFid0g==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/icons-svg": "^4.5.0", + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@refinedev/antd": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@refinedev/antd/-/antd-6.0.3.tgz", + "integrity": "sha512-adNxHZJuca3TN4y1zXpamspqc0yi5hAqlzveTKAXLDuZ8C546xPdihmpZ/bWGG1fjYzWKLctb3YIoUw6d/WDgA==", + "license": "MIT", + "dependencies": { + "@ant-design/icons": "^5.5.1", + "@ant-design/pro-layout": "^7.21.1", + "@refinedev/ui-types": "^2.0.1", + "@tanstack/react-query": "^5.81.5", + "antd": "^5.23.0", + "dayjs": "^1.10.7", + "react-markdown": "^6.0.1", + "remark-gfm": "^1.0.0", + "sunflower-antd": "1.0.0-beta.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@refinedev/core": "^5.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "antd": "^5.23.0", + "dayjs": "^1.10.7", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@refinedev/core": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@refinedev/core/-/core-5.0.12.tgz", + "integrity": "sha512-9y5Bi9Lb7XyJmM55b8rCeBTDRCBU41p47OymJldasLfrtpUm2EwI+27DjjNpHTOugymiZsIbLlPtHCPQIXBHcg==", + "license": "MIT", + "dependencies": { + "@refinedev/devtools-internal": "2.0.2", + "@tanstack/react-query": "^5.81.5", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "papaparse": "^5.3.0", + "pluralize": "^8.0.0", + "qs": "^6.10.1", + "tslib": "^2.6.2", + "warn-once": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.81.5", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@refinedev/nextjs-router": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@refinedev/nextjs-router/-/nextjs-router-7.0.5.tgz", + "integrity": "sha512-Z724KBsnEtESGYZMntXEhXr9gmQD/kD6s7poeMY4HeLtWLfNyJPdopHntD4BYMU1ApZweDBJeSqEuWjoL3/x5A==", + "license": "MIT", + "dependencies": { + "qs": "^6.10.1", + "warn-once": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@refinedev/core": "^5.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "next": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/antd": { + "version": "5.29.3", + "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz", + "integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.2.1", + "@ant-design/cssinjs": "^1.23.0", + "@ant-design/cssinjs-utils": "^1.1.3", + "@ant-design/fast-color": "^2.0.6", + "@ant-design/icons": "^5.6.1", + "@ant-design/react-slick": "~1.1.2", + "@babel/runtime": "^7.26.0", + "@rc-component/color-picker": "~2.0.1", + "@rc-component/mutate-observer": "^1.1.0", + "@rc-component/qrcode": "~1.1.0", + "@rc-component/tour": "~1.15.1", + "@rc-component/trigger": "^2.3.0", + "classnames": "^2.5.1", + "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.11", + "rc-cascader": "~3.34.0", + "rc-checkbox": "~3.5.0", + "rc-collapse": "~3.9.0", + "rc-dialog": "~9.6.0", + "rc-drawer": "~7.3.0", + "rc-dropdown": "~4.2.1", + "rc-field-form": "~2.7.1", + "rc-image": "~7.12.0", + "rc-input": "~1.8.0", + "rc-input-number": "~9.5.0", + "rc-mentions": "~2.20.0", + "rc-menu": "~9.16.1", + "rc-motion": "^2.9.5", + "rc-notification": "~5.6.4", + "rc-pagination": "~5.1.0", + "rc-picker": "~4.11.3", + "rc-progress": "~4.0.0", + "rc-rate": "~2.13.1", + "rc-resize-observer": "^1.4.3", + "rc-segmented": "~2.7.0", + "rc-select": "~14.16.8", + "rc-slider": "~11.1.9", + "rc-steps": "~6.0.1", + "rc-switch": "~4.1.0", + "rc-table": "~7.54.0", + "rc-tabs": "~15.7.0", + "rc-textarea": "~1.10.2", + "rc-tooltip": "~6.4.0", + "rc-tree": "~5.13.1", + "rc-tree-select": "~5.27.0", + "rc-upload": "~4.11.0", + "rc-util": "^5.44.4", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@ant-design/colors": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-8.0.1.tgz", + "integrity": "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz", + "integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==", + "license": "MIT" + }, + "node_modules/@rc-component/util": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.12.0.tgz", + "integrity": "sha512-AEjPL8JVdohIITaiXokyjL9WQ6tKWWjAYK9QU16tGNE9JaQABBQy+hA4H2Lup5MgXy9yY3iLrbZJheuU13hTdQ==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^19.2.7" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@refinedev/antd/node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/pro-layout": { + "version": "7.22.7", + "resolved": "https://registry.npmjs.org/@ant-design/pro-layout/-/pro-layout-7.22.7.tgz", + "integrity": "sha512-fvmtNA1r9SaasVIQIQt611VSlNxtVxDbQ3e+1GhYQza3tVJi/3gCZuDyfMfTnbLmf3PaW/YvLkn7MqDbzAzoLA==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.1", + "@ant-design/icons": "^5.0.0", + "@ant-design/pro-provider": "2.16.2", + "@ant-design/pro-utils": "2.18.0", + "@babel/runtime": "^7.18.0", + "@umijs/route-utils": "^4.0.0", + "@umijs/use-params": "^1.0.9", + "classnames": "^2.3.2", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "path-to-regexp": "8.2.0", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.0.6", + "swr": "^2.0.0", + "warning": "^4.0.3" + }, + "peerDependencies": { + "antd": "^4.24.15 || ^5.11.2", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@refinedev/ui-types": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@refinedev/ui-types/-/ui-types-2.0.1.tgz", + "integrity": "sha512-Fxsgr2JEsyEVGr5rMvOasQP5tj/1yD2m4M9XqDZQ+65B/ZB/vbkbB5+ltAhNlX2UwX8jr1mo8fnutHBltYxwfA==", + "license": "MIT", + "dependencies": { + "@refinedev/core": "^5.0.5", + "dayjs": "^1.10.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@refinedev/core": "^5.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/@refinedev/antd/node_modules/react-markdown": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-6.0.3.tgz", + "integrity": "sha512-kQbpWiMoBHnj9myLlmZG9T1JdoT/OEyHK7hqM6CqFT14MAkgWiWBUYijLyBmxbntaN6dCDicPcUhWhci1QYodg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^2.0.0", + "@types/unist": "^2.0.3", + "comma-separated-tokens": "^1.0.0", + "prop-types": "^15.7.2", + "property-information": "^5.3.0", + "react-is": "^17.0.0", + "remark-parse": "^9.0.0", + "remark-rehype": "^8.0.0", + "space-separated-tokens": "^1.1.0", + "style-to-object": "^0.3.0", + "unified": "^9.0.0", + "unist-util-visit": "^2.0.0", + "vfile": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=16", + "react": ">=16" + } + }, + "node_modules/@refinedev/antd/node_modules/remark-gfm": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-1.0.0.tgz", + "integrity": "sha512-KfexHJCiqvrdBZVbQ6RopMZGwaXz6wFJEfByIuEwGf0arvITHjiKKZ1dpXujjH9KZdm1//XJQwgfnJ3lmXaDPA==", + "license": "MIT", + "dependencies": { + "mdast-util-gfm": "^0.1.0", + "micromark-extension-gfm": "^0.3.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/sunflower-antd": { + "version": "1.0.0-beta.3", + "resolved": "https://registry.npmjs.org/sunflower-antd/-/sunflower-antd-1.0.0-beta.3.tgz", + "integrity": "sha512-SAdjHgNemTFNxUF/QJ2KdC0x6wWpY1EsMJMo+F5KIHCDRsUUahjAIldoK+ejH00rPgUoCOhAHQ/ob/J7eyZ5qg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@refinedev/devtools-internal": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@refinedev/devtools-internal/-/devtools-internal-2.0.2.tgz", + "integrity": "sha512-1YYizOW1lyy9ep8eQ7TcUPBooKXIlvzTLjLdDArsQwx7P33cn2uXdqM7So5VhlNFXhjOjAKFgrH5c1jleRF8Jg==", + "license": "MIT", + "dependencies": { + "@refinedev/devtools-shared": "2.0.2", + "@tanstack/react-query": "^5.81.5", + "error-stack-parser": "^2.1.4" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/papaparse": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.4.tgz", + "integrity": "sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ==", + "license": "MIT" + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/warn-once": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz", + "integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==", + "license": "MIT" + }, + "node_modules/antd/node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", + "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "classnames": "^2.3.1", + "csstype": "^3.1.3", + "rc-util": "^5.35.0", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz", + "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.0", + "@babel/runtime": "^7.23.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/antd/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/antd/node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/react-slick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz", + "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.4", + "classnames": "^2.2.5", + "json2mq": "^0.2.0", + "resize-observer-polyfill": "^1.5.1", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.9.0" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.1.tgz", + "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6", + "@babel/runtime": "^7.23.6", + "classnames": "^2.2.6", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", + "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.3.tgz", + "integrity": "sha512-aGv6alnn4HbDEsURzKP+jv13rbi1VxmAYfBNZr5GKF1iohMNWy5tAVoJ1E3cOvzMB1kbUPvCXchM6zSFlRGPhA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz", + "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/portal": "^1.0.0-9", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/trigger": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.3.1.tgz", + "integrity": "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@rc-component/portal": "^1.1.0", + "classnames": "^2.3.2", + "rc-motion": "^2.0.0", + "rc-resize-observer": "^1.3.1", + "rc-util": "^5.44.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "license": "MIT", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, + "node_modules/rc-cascader": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz", + "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "^2.3.1", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-checkbox": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.5.0.tgz", + "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.25.2" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-collapse": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.9.0.tgz", + "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.3.4", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dialog": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz", + "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/portal": "^1.0.0-8", + "classnames": "^2.2.6", + "rc-motion": "^2.3.0", + "rc-util": "^5.21.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-drawer": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.3.0.tgz", + "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@rc-component/portal": "^1.1.1", + "classnames": "^2.2.6", + "rc-motion": "^2.6.1", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dropdown": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.1.tgz", + "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-util": "^5.44.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/rc-field-form": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.7.1.tgz", + "integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/async-validator": "^5.0.3", + "rc-util": "^5.32.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-image": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz", + "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/portal": "^1.0.2", + "classnames": "^2.2.6", + "rc-dialog": "~9.6.0", + "rc-motion": "^2.6.2", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-input": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz", + "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.18.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-input-number": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz", + "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/mini-decimal": "^1.0.1", + "classnames": "^2.2.5", + "rc-input": "~1.8.0", + "rc-util": "^5.40.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-mentions": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz", + "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.5", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-input": "~1.8.0", + "rc-menu": "~9.16.0", + "rc-textarea": "~1.10.0", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-menu": { + "version": "9.16.1", + "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.16.1.tgz", + "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.0.0", + "classnames": "2.x", + "rc-motion": "^2.4.3", + "rc-overflow": "^1.3.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-motion": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz", + "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.44.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-notification": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz", + "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.9.0", + "rc-util": "^5.20.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-pagination": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-5.1.0.tgz", + "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-picker": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz", + "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.1", + "rc-overflow": "^1.3.2", + "rc-resize-observer": "^1.4.0", + "rc-util": "^5.43.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/rc-progress": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-4.0.0.tgz", + "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-rate": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.1.tgz", + "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.0.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-resize-observer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz", + "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.7", + "classnames": "^2.2.1", + "rc-util": "^5.44.1", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-segmented": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.7.1.tgz", + "integrity": "sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-motion": "^2.4.4", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-select": { + "version": "14.16.8", + "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz", + "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.1.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-overflow": "^1.3.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-slider": { + "version": "11.1.9", + "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.9.tgz", + "integrity": "sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-steps": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-6.0.1.tgz", + "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.16.7", + "classnames": "^2.2.3", + "rc-util": "^5.16.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-switch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-4.1.0.tgz", + "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0", + "classnames": "^2.2.1", + "rc-util": "^5.30.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-table": { + "version": "7.54.0", + "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.54.0.tgz", + "integrity": "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/context": "^1.4.0", + "classnames": "^2.2.5", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.44.3", + "rc-virtual-list": "^3.14.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tabs": { + "version": "15.7.0", + "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.7.0.tgz", + "integrity": "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "classnames": "2.x", + "rc-dropdown": "~4.2.0", + "rc-menu": "~9.16.0", + "rc-motion": "^2.6.2", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.34.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-textarea": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.2.tgz", + "integrity": "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-input": "~1.8.0", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tooltip": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.4.0.tgz", + "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.1", + "rc-util": "^5.44.3" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tree": { + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.13.1.tgz", + "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-tree-select": { + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.27.0.tgz", + "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "2.x", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-upload": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.11.0.tgz", + "integrity": "sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.5", + "rc-util": "^5.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-3.0.1.tgz", + "integrity": "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==", + "license": "MIT", + "engines": { + "node": ">=8.x" + } + }, + "node_modules/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "license": "MIT" + }, + "node_modules/@rc-component/util/node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT" + }, + "node_modules/@refinedev/antd/node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/pro-layout/node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/pro-provider": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@ant-design/pro-provider/-/pro-provider-2.16.2.tgz", + "integrity": "sha512-0KmCH1EaOND787Jz6VRMYtLNZmqfT0JPjdUfxhyOxFfnBRfrjyfZgIa6CQoAJLEUMWv57PccWS8wRHVUUk2Yiw==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.1", + "@babel/runtime": "^7.18.0", + "@ctrl/tinycolor": "^3.4.0", + "dayjs": "^1.11.10", + "rc-util": "^5.0.1", + "swr": "^2.0.0" + }, + "peerDependencies": { + "antd": "^4.24.15 || ^5.11.2", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@ant-design/pro-utils": { + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/@ant-design/pro-utils/-/pro-utils-2.18.0.tgz", + "integrity": "sha512-8+ikyrN8L8a8Ph4oeHTOJEiranTj18+9+WHCHjKNdEfukI7Rjn8xpYdLJWb2AUJkb9d4eoAqjd5+k+7w81Df0w==", + "license": "MIT", + "dependencies": { + "@ant-design/icons": "^5.0.0", + "@ant-design/pro-provider": "2.16.2", + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "dayjs": "^1.11.10", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "rc-util": "^5.0.6", + "safe-stable-stringify": "^2.4.3", + "swr": "^2.0.0" + }, + "peerDependencies": { + "antd": "^4.24.15 || ^5.11.2", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@umijs/route-utils": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@umijs/route-utils/-/route-utils-4.0.3.tgz", + "integrity": "sha512-zPEcYhl1cSfkSRDzzGgoD1mDvGjxoOTJFvkn55srfgdQ3NZe2ZMCScCU6DEnOxuKP1XDVf8pqyqCDVd2+RCQIw==", + "license": "MIT" + }, + "node_modules/@umijs/use-params": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@umijs/use-params/-/use-params-1.0.9.tgz", + "integrity": "sha512-QlN0RJSBVQBwLRNxbxjQ5qzqYIGn+K7USppMoIOVlf7fxXHsnQZ2bEsa6Pm74bt6DVQxpUE8HqvdStn6Y9FV1w==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@ant-design/pro-layout/node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/swr": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.2.tgz", + "integrity": "sha512-ej644Y2bvkIajfR32KGeSSdBXQW+ScjGjkybZgSE7kFpk9eGnV44XY9FJylXi+W75pavSX1PVNB57W5EbhGIYw==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@refinedev/antd/node_modules/@types/hast": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", + "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2" + } + }, + "node_modules/@refinedev/antd/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/@refinedev/antd/node_modules/comma-separated-tokens": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz", + "integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/antd/node_modules/property-information": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz", + "integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/antd/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "license": "MIT" + }, + "node_modules/@refinedev/antd/node_modules/remark-parse": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-9.0.0.tgz", + "integrity": "sha512-geKatMwSzEXKHuzBNU1z676sGcDcFoChMK38TgdHJNAYfFtsfHDQG7MoJAjs6sgYMqyLduCYWDIWZIxiPeafEw==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/remark-rehype": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-8.1.0.tgz", + "integrity": "sha512-EbCu9kHgAxKmW1yEYjx3QafMyGY3q8noUbNUI5xyKbaFP89wbhDrKxyIQNukNYthzjNHZu6J7hwFg7hRm1svYA==", + "license": "MIT", + "dependencies": { + "mdast-util-to-hast": "^10.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/space-separated-tokens": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz", + "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/antd/node_modules/style-to-object": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.3.0.tgz", + "integrity": "sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.1.1" + } + }, + "node_modules/@refinedev/antd/node_modules/unified": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/unified/-/unified-9.2.2.tgz", + "integrity": "sha512-Sg7j110mtefBD+qunSLO1lqOEKdrwBFBrR6Qd8f4uwkhWNlbkaqwHse6e7QvD3AP/MNoJdEDLaf8OxYyoWgorQ==", + "license": "MIT", + "dependencies": { + "bail": "^1.0.0", + "extend": "^3.0.0", + "is-buffer": "^2.0.0", + "is-plain-obj": "^2.0.0", + "trough": "^1.0.0", + "vfile": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/unist-util-visit": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz", + "integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0", + "unist-util-visit-parents": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/vfile": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-4.2.1.tgz", + "integrity": "sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "is-buffer": "^2.0.0", + "unist-util-stringify-position": "^2.0.0", + "vfile-message": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/mdast-util-gfm": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-0.1.2.tgz", + "integrity": "sha512-NNkhDx/qYcuOWB7xHUGWZYVXvjPFFd6afg6/e2g+SV4r9q5XUcCbV4Wfa3DLYIiD+xAEZc6K4MGaE/m0KDcPwQ==", + "license": "MIT", + "dependencies": { + "mdast-util-gfm-autolink-literal": "^0.1.0", + "mdast-util-gfm-strikethrough": "^0.2.0", + "mdast-util-gfm-table": "^0.1.0", + "mdast-util-gfm-task-list-item": "^0.1.0", + "mdast-util-to-markdown": "^0.6.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/micromark-extension-gfm": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-0.3.3.tgz", + "integrity": "sha512-oVN4zv5/tAIA+l3GbMi7lWeYpJ14oQyJ3uEim20ktYFAcfX1x3LNlFGGlmrZHt7u9YlKExmyJdDGaTt6cMSR/A==", + "license": "MIT", + "dependencies": { + "micromark": "~2.11.0", + "micromark-extension-gfm-autolink-literal": "~0.5.0", + "micromark-extension-gfm-strikethrough": "~0.6.5", + "micromark-extension-gfm-table": "~0.4.0", + "micromark-extension-gfm-tagfilter": "~0.3.0", + "micromark-extension-gfm-task-list-item": "~0.3.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/devtools-shared": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@refinedev/devtools-shared/-/devtools-shared-2.0.2.tgz", + "integrity": "sha512-3cTjR1mEWn0tHFZBfPD5aVpBGLUhpAkfjqYCwKrijIicr1Utp/j0BqiPRnNqTf+W71HTng3znBpUhnR83u+tuA==", + "license": "MIT", + "dependencies": { + "@tanstack/react-query": "^5.81.5", + "error-stack-parser": "^2.1.4" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/@rc-component/color-picker/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz", + "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", + "license": "MIT" + }, + "node_modules/@rc-component/async-validator": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.2.tgz", + "integrity": "sha512-WYbrZSjzznU1ekD0qFq2qRxt309VoS61MTG5npnFQlKYcoy9IzU8T+ZCIhq5bGAXRbXysABFWTspicMfmWFwow==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.4.tgz", + "integrity": "sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/rc-overflow": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.5.0.tgz", + "integrity": "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.37.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-virtual-list": { + "version": "3.19.2", + "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.19.2.tgz", + "integrity": "sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "classnames": "^2.2.6", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz", + "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, + "node_modules/@refinedev/antd/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/pro-layout/node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@ant-design/pro-utils/node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@refinedev/antd/node_modules/mdast-util-from-markdown": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-0.8.5.tgz", + "integrity": "sha512-2hkTXtYYnr+NubD/g6KGBS/0mFmBcifAsI0yIWRiRo0PjVs6SSOSOdtzbp6kSGnShDN6G5aWZpKQ2lWRy27mWQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^3.0.0", + "mdast-util-to-string": "^2.0.0", + "micromark": "~2.11.0", + "parse-entities": "^2.0.0", + "unist-util-stringify-position": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/mdast-util-to-hast": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-10.2.0.tgz", + "integrity": "sha512-JoPBfJ3gBnHZ18icCwHR50orC9kNH81tiR1gs01D8Q5YpV6adHNO9nKNuFBCJQ941/32PT1a63UF/DitmS3amQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^3.0.0", + "@types/unist": "^2.0.0", + "mdast-util-definitions": "^4.0.0", + "mdurl": "^1.0.0", + "unist-builder": "^2.0.0", + "unist-util-generated": "^1.0.0", + "unist-util-position": "^3.0.0", + "unist-util-visit": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/inline-style-parser": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz", + "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==", + "license": "MIT" + }, + "node_modules/@refinedev/antd/node_modules/bail": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz", + "integrity": "sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@refinedev/antd/node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@refinedev/antd/node_modules/trough": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz", + "integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/antd/node_modules/unist-util-is": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz", + "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/unist-util-visit-parents": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz", + "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/unist-util-stringify-position": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", + "integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/vfile-message": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz", + "integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-stringify-position": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/mdast-util-gfm-autolink-literal": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-0.1.3.tgz", + "integrity": "sha512-GjmLjWrXg1wqMIO9+ZsRik/s7PLwTaeCHVB7vRxUwLntZc8mzmTsLVr6HW1yLokcnhfURsn5zmSVdi3/xWWu1A==", + "license": "MIT", + "dependencies": { + "ccount": "^1.0.0", + "mdast-util-find-and-replace": "^1.1.0", + "micromark": "^2.11.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/mdast-util-gfm-strikethrough": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-0.2.3.tgz", + "integrity": "sha512-5OQLXpt6qdbttcDG/UxYY7Yjj3e8P7X16LzvpX8pIQPYJ/C2Z1qFGMmcw+1PZMUM3Z8wt8NRfYTvCni93mgsgA==", + "license": "MIT", + "dependencies": { + "mdast-util-to-markdown": "^0.6.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/mdast-util-gfm-table": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-0.1.6.tgz", + "integrity": "sha512-j4yDxQ66AJSBwGkbpFEp9uG/LS1tZV3P33fN1gkyRB2LoRL+RR3f76m0HPHaby6F4Z5xr9Fv1URmATlRRUIpRQ==", + "license": "MIT", + "dependencies": { + "markdown-table": "^2.0.0", + "mdast-util-to-markdown": "~0.6.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/mdast-util-gfm-task-list-item": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-0.1.6.tgz", + "integrity": "sha512-/d51FFIfPsSmCIRNp7E6pozM9z1GYPIkSy1urQ8s/o4TC22BZ7DqfHFWiqBD23bc7J3vV1Fc9O4QIHBlfuit8A==", + "license": "MIT", + "dependencies": { + "mdast-util-to-markdown": "~0.6.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/mdast-util-to-markdown": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-0.6.5.tgz", + "integrity": "sha512-XeV9sDE7ZlOQvs45C9UKMtfTcctcaj/pGwH8YLbMHoMOXNNCn2LsqVQOqrF1+/NU8lKDAqozme9SCXWyo9oAcQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "longest-streak": "^2.0.0", + "mdast-util-to-string": "^2.0.0", + "parse-entities": "^2.0.0", + "repeat-string": "^1.0.0", + "zwitch": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/micromark": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-2.11.4.tgz", + "integrity": "sha512-+WoovN/ppKolQOFIAajxi7Lu9kInbPxFuTBVEavFcL8eAfVstoc5MocPmqBeAdBOJV00uaVjegzH4+MA0DN/uA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "parse-entities": "^2.0.0" + } + }, + "node_modules/@refinedev/antd/node_modules/micromark-extension-gfm-autolink-literal": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-0.5.7.tgz", + "integrity": "sha512-ePiDGH0/lhcngCe8FtH4ARFoxKTUelMp4L7Gg2pujYD5CSMb9PbblnyL+AAMud/SNMyusbS2XDSiPIRcQoNFAw==", + "license": "MIT", + "dependencies": { + "micromark": "~2.11.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/micromark-extension-gfm-strikethrough": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-0.6.5.tgz", + "integrity": "sha512-PpOKlgokpQRwUesRwWEp+fHjGGkZEejj83k9gU5iXCbDG+XBA92BqnRKYJdfqfkrRcZRgGuPuXb7DaK/DmxOhw==", + "license": "MIT", + "dependencies": { + "micromark": "~2.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/micromark-extension-gfm-table": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-0.4.3.tgz", + "integrity": "sha512-hVGvESPq0fk6ALWtomcwmgLvH8ZSVpcPjzi0AjPclB9FsVRgMtGZkUcpE0zgjOCFAznKepF4z3hX8z6e3HODdA==", + "license": "MIT", + "dependencies": { + "micromark": "~2.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/micromark-extension-gfm-tagfilter": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-0.3.0.tgz", + "integrity": "sha512-9GU0xBatryXifL//FJH+tAZ6i240xQuFrSL7mYi8f4oZSbc+NvXjkrHemeYP0+L4ZUT+Ptz3b95zhUZnMtoi/Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/micromark-extension-gfm-task-list-item": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-0.3.3.tgz", + "integrity": "sha512-0zvM5iSLKrc/NQl84pZSjGo66aTGd57C1idmlWmE87lkMcXrTxg1uXa/nXomxJytoje9trP0NDLvw4bZ/Z/XCQ==", + "license": "MIT", + "dependencies": { + "micromark": "~2.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, + "node_modules/@ant-design/pro-layout/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/pro-utils/node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@refinedev/antd/node_modules/@types/mdast": { + "version": "3.0.15", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", + "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2" + } + }, + "node_modules/@refinedev/antd/node_modules/mdast-util-to-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-2.0.0.tgz", + "integrity": "sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/parse-entities": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz", + "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==", + "license": "MIT", + "dependencies": { + "character-entities": "^1.0.0", + "character-entities-legacy": "^1.0.0", + "character-reference-invalid": "^1.0.0", + "is-alphanumerical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-hexadecimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-definitions": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-4.0.0.tgz", + "integrity": "sha512-k8AJ6aNnUkB7IE+5azR9h81O5EQ/cTDXtWdMq9Kk5KcEW/8ritU5CeLg/9HhOC++nALHBlaogJ5jz0Ybk3kPMQ==", + "license": "MIT", + "dependencies": { + "unist-util-visit": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", + "license": "MIT" + }, + "node_modules/unist-builder": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-builder/-/unist-builder-2.0.3.tgz", + "integrity": "sha512-f98yt5pnlMWlzP539tPc4grGMsFaQQlP/vM396b00jngsiINumNmsY8rkXjfoi1c6QaM8nQ3vaGDuoKWbe/1Uw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-generated": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/unist-util-generated/-/unist-util-generated-1.1.6.tgz", + "integrity": "sha512-cln2Mm1/CZzN5ttGK7vkoGw+RZ8VcUH6BtGbq98DDtRGquAAOXig1mrBQYelOwMXYS8rK+vZDyyojSjp7JX+Lg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/unist-util-position": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-3.1.0.tgz", + "integrity": "sha512-w+PkwCbYSFw8vpgWD0v7zRCl1FpY3fjDSQ3/N/wNd9Ffa4gPi8+4keqt99N3XW6F99t/mUzp2xAhNmfKWp95QA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/ccount": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-1.1.0.tgz", + "integrity": "sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/antd/node_modules/mdast-util-find-and-replace": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-1.1.1.tgz", + "integrity": "sha512-9cKl33Y21lyckGzpSmEQnIDjEfeeWelN5s1kUW1LwdB0Fkuq2u+4GdqcGEygYxJE8GVqCl0741bYXHgamfWAZA==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "unist-util-is": "^4.0.0", + "unist-util-visit-parents": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/markdown-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz", + "integrity": "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==", + "license": "MIT", + "dependencies": { + "repeat-string": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/antd/node_modules/longest-streak": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-2.0.4.tgz", + "integrity": "sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/@refinedev/antd/node_modules/zwitch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz", + "integrity": "sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@ant-design/pro-utils/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@refinedev/antd/node_modules/character-entities": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz", + "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/antd/node_modules/character-entities-legacy": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", + "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/antd/node_modules/character-reference-invalid": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz", + "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/antd/node_modules/is-alphanumerical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz", + "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/antd/node_modules/is-decimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz", + "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/antd/node_modules/is-hexadecimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", + "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-definitions/node_modules/unist-util-visit": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz", + "integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0", + "unist-util-visit-parents": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/is-alphabetical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", + "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-definitions/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/mdast-util-definitions/node_modules/unist-util-is": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz", + "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-definitions/node_modules/unist-util-visit-parents": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz", + "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 3309278f..9f1c2ea1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,12 +17,17 @@ "worker:rectification-v4": "tsx scripts/rectification-v4-worker.mts" }, "dependencies": { + "@ant-design/icons": "^6.3.2", "@base-ui/react": "^1.6.0", "@gsap/react": "^2.1.2", "@mastra/core": "^1.50.1", + "@refinedev/antd": "^6.0.3", + "@refinedev/core": "^5.0.12", + "@refinedev/nextjs-router": "^7.0.5", "@supabase/ssr": "^0.12.3", "@supabase/supabase-js": "^2.110.5", "@tailwindcss/postcss": "^4.3.2", + "antd": "^5.29.3", "better-auth": "1.6.23", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -37,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 5333dea8..6b56215d 100644 --- a/frontend/scripts/db-migrate.mjs +++ b/frontend/scripts/db-migrate.mjs @@ -6,6 +6,36 @@ 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", + ], + [ + "20260727020000_epay_packages_orders.sql", + "e922177b4d60d04ba9380b19badba1ffbe792304b1580f8748f7ad1b6e855e1b", + ], + [ + "20260727030000_payment_admin_stats.sql", + "b71e46ca696d0ef2b74f239829f9f808dd910742e32d3e3f7dc641a1ad7e767d", + ], + [ + "20260729010000_epay_settings.sql", + "dc3ed919b463e96b79473c19235dceb7cb491362b1f684db070aea80830cf6e9", + ], + [ + "20260730010000_admin_payment_permissions.sql", + "1744437eb133f860930898fd1a33c07440d4a63ff22a8f34c0b5e3ddb286c177", + ], +]); class SafeMigrationError extends Error {} @@ -81,17 +111,22 @@ async function readLedger(client) { return new Map(result.rows.map((row) => [row.filename, row.checksum])); } -function assertLedgerFilesPresent(ledger, files) { +export function assertLedgerFilesPresent(ledger, files) { const reviewedFilenames = new Set(files.map((file) => file.filename)); - for (const filename of ledger.keys()) { - if (!reviewedFilenames.has(filename)) { - if (!migrationFilenamePattern.test(filename)) { - throw new SafeMigrationError( - "migration ledger contains an invalid filename", - ); - } + for (const [filename, recordedChecksum] of ledger) { + if (reviewedFilenames.has(filename)) continue; + if (!migrationFilenamePattern.test(filename)) { + throw new SafeMigrationError( + "migration ledger contains an invalid filename", + ); + } + const retiredChecksum = retiredMigrationChecksums.get(filename); + if (retiredChecksum === undefined) { throw new SafeMigrationError(`migration file missing: ${filename}`); } + if (recordedChecksum !== retiredChecksum) { + throw new SafeMigrationError(`migration checksum mismatch: ${filename}`); + } } } diff --git a/frontend/scripts/rectification-v4-worker.mts b/frontend/scripts/rectification-v4-worker.mts index 93cf685a..72cac9cd 100644 --- a/frontend/scripts/rectification-v4-worker.mts +++ b/frontend/scripts/rectification-v4-worker.mts @@ -1,7 +1,6 @@ import { setTimeout as sleep } from "node:timers/promises"; import { createRectificationV4CandidateEngine } from "../src/lib/rectification-v4/candidate-engine.ts"; import { createRectificationV4SupabaseStore } from "../src/lib/rectification-v4/supabase-store.ts"; -import { authorRectificationV4Question } from "../src/lib/rectification-v4/question-author.ts"; import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts"; import { createAdminSupabaseClient } from "../src/lib/supabase/admin-client-core.ts"; @@ -15,7 +14,6 @@ const worker = createRectificationV4Worker({ engine: createRectificationV4CandidateEngine({ apiBase: process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200", }), - questionAuthor: authorRectificationV4Question, }); do { 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/codes/page.tsx b/frontend/src/app/admin/codes/page.tsx index f4cec373..d195b258 100644 --- a/frontend/src/app/admin/codes/page.tsx +++ b/frontend/src/app/admin/codes/page.tsx @@ -1,200 +1,27 @@ "use client"; -import Link from "next/link"; -import { FormEvent, useEffect, useRef, useState } from "react"; +import { useSearchParams } from "next/navigation"; -type CodeRecord = { - id: string; - mask: string; - credits: number; - expiresAt: string | null; - redeemedBy: string | null; - redeemedEmail: string | null; - redeemedAt: string | null; - note: string | null; - createdAt: string; -}; -type GeneratedCode = { code: string; credits: number; expiresAt: string | null }; +import AuditLogsResource from "@/components/admin/audit-logs-resource"; +import CodesResource from "@/components/admin/codes-resource"; +import ConsultationsResource from "@/components/admin/consultations-resource"; +import CreditTransactionsResource from "@/components/admin/credit-transactions-resource"; +import UsersResource from "@/components/admin/users-resource"; -const previewCodes: CodeRecord[] = [ - { id: "preview-1", mask: "JYOT-••••-7Q9K", credits: 12, expiresAt: "2026-12-31T15:59:00.000Z", redeemedBy: null, redeemedEmail: null, redeemedAt: null, note: "秋季体验", createdAt: "2026-07-16T02:20:00.000Z" }, - { id: "preview-2", mask: "JYOT-••••-2M8A", credits: 6, expiresAt: null, redeemedBy: "preview-user", redeemedEmail: "linyao@example.com", redeemedAt: "2026-07-15T08:30:00.000Z", note: "访谈用户", createdAt: "2026-07-14T03:10:00.000Z" }, - { id: "preview-3", mask: "JYOT-••••-4D1R", credits: 20, expiresAt: "2026-07-01T15:59:00.000Z", redeemedBy: null, redeemedEmail: null, redeemedAt: null, note: null, createdAt: "2026-06-10T06:45:00.000Z" }, -]; +const resourceComponents = { + codes: CodesResource, + users: UsersResource, + "credit-transactions": CreditTransactionsResource, + consultations: ConsultationsResource, + "audit-logs": AuditLogsResource, +} as const; -const dateFormatter = new Intl.DateTimeFormat("zh-CN", { - dateStyle: "medium", - timeStyle: "short", - timeZone: "Asia/Taipei", -}); - -function apiMessage(payload: unknown, fallback: string) { - if (!payload || typeof payload !== "object") return fallback; - const data = payload as Record; - return [data.message, data.error].find((value) => typeof value === "string") as string || fallback; -} - -function redirectForAuth(response: Response) { - if (response.status === 401) window.location.assign("/login"); - if (response.status === 403) window.location.assign("/"); -} - -function codeStatus(code: CodeRecord) { - if (code.redeemedAt) return "已兑换"; - if (code.expiresAt && new Date(code.expiresAt).getTime() <= Date.now()) return "已过期"; - return "可用"; -} - -function formatDate(value: string | null) { - return value ? dateFormatter.format(new Date(value)) : "—"; -} - -export default function AdminCodesPage() { - const [codes, setCodes] = useState([]); - const [generated, setGenerated] = useState([]); - const [credits, setCredits] = useState(10); - const [count, setCount] = useState(1); - const [expiresAt, setExpiresAt] = useState(""); - const [note, setNote] = useState(""); - const [loading, setLoading] = useState(true); - const [creating, setCreating] = useState(false); - const previewMode = useRef(false); - const [error, setError] = useState(""); - const [copyNotice, setCopyNotice] = useState(""); - - useEffect(() => { - if (process.env.NODE_ENV === "development" && new URLSearchParams(window.location.search).get("preview") === "admin") { - const previewFrame = window.requestAnimationFrame(() => { - previewMode.current = true; - setCodes(previewCodes); - setLoading(false); - }); - return () => window.cancelAnimationFrame(previewFrame); - } - - const controller = new AbortController(); - void fetch("/api/admin/codes", { signal: controller.signal, cache: "no-store" }) - .then(async (response) => { - redirectForAuth(response); - const payload = await response.json().catch(() => null); - if (!response.ok) throw new Error(apiMessage(payload, "暂时无法读取兑换码")); - setCodes((payload as { codes: CodeRecord[] }).codes); - }) - .catch((caught) => { - if ((caught as Error).name !== "AbortError") setError(caught instanceof Error ? caught.message : "暂时无法读取兑换码"); - }) - .finally(() => setLoading(false)); - return () => controller.abort(); - }, []); - - async function reloadCodes() { - const response = await fetch("/api/admin/codes", { cache: "no-store" }); - redirectForAuth(response); - const payload = await response.json().catch(() => null); - if (!response.ok) throw new Error(apiMessage(payload, "暂时无法刷新兑换码")); - setCodes((payload as { codes: CodeRecord[] }).codes); - } - - async function createCodes(event: FormEvent) { - event.preventDefault(); - if (creating) return; - setCreating(true); - setError(""); - setGenerated([]); - setCopyNotice(""); - if (process.env.NODE_ENV === "development" && previewMode.current) { - setGenerated(Array.from({ length: count }, (_, index) => ({ - code: `PREVIEW-${String(index + 1).padStart(2, "0")}-JYOTISH`, - credits, - expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, - }))); - setCreating(false); - return; - } - try { - const response = await fetch("/api/admin/codes", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - credits, - count, - ...(expiresAt ? { expiresAt: new Date(expiresAt).toISOString() } : {}), - ...(note.trim() ? { note: note.trim() } : {}), - }), - }); - redirectForAuth(response); - const payload = await response.json().catch(() => null); - if (!response.ok) throw new Error(apiMessage(payload, "生成兑换码失败")); - setGenerated((payload as { codes: GeneratedCode[] }).codes); - await reloadCodes(); - } catch (caught) { - setError(caught instanceof Error ? caught.message : "生成兑换码失败"); - } finally { - setCreating(false); - } - } - - async function copy(text: string) { - try { - await navigator.clipboard.writeText(text); - setCopyNotice("已复制到剪贴板"); - } catch { - setCopyNotice("无法自动复制,请手动选择兑换码"); - } - } - - return ( -
-
-

兑换码管理

- 返回对话 -
- -
-
-

生成兑换码

完整兑换码只在本次生成结果中显示,请立即复制保存。

-
- - - - - -
- {error &&

{error}

} -
- - {generated.length > 0 && ( -
-
-

本次生成的完整码

离开或刷新页面后将不再显示。

- -
-
- {generated.map((item) => ( -
{item.code}{item.credits} 点
- ))} -
- {copyNotice &&

{copyNotice}

} -
- )} - -
-

兑换码状态

{loading ? "正在读取…" : `${codes.length} 条记录`}

-
- - - - {codes.map((code) => ( - - - - ))} - {!loading && codes.length === 0 && } - -
兑换码点数状态有效期兑换账户兑换时间备注创建时间
{code.mask}{code.credits}{codeStatus(code)}{formatDate(code.expiresAt)}{code.redeemedEmail || code.redeemedBy || "—"}{formatDate(code.redeemedAt)}{code.note || "—"}{formatDate(code.createdAt)}
尚未生成兑换码
-
-
-
-
- ); +export default function AdminResourcesPage() { + const requested = useSearchParams().get("resource") ?? "codes"; + const Resource = resourceComponents[ + requested in resourceComponents + ? requested as keyof typeof resourceComponents + : "codes" + ]; + return ; } diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx index a5e09cd3..c7e13d4b 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -1,18 +1,21 @@ -import { ReactNode } from "react"; +import "@refinedev/antd/dist/reset.css"; +import "antd/dist/reset.css"; +import type { ReactNode } from "react"; import { redirect } from "next/navigation"; -import { isAdminEmail } from "@/lib/supabase/admin"; -import { createServerSupabaseClient } from "@/lib/supabase/server"; + +import { AdminApp } from "@/components/admin/admin-app"; +import { AdminAuthorizationError, requireAdminSession } from "@/lib/admin/auth"; export const dynamic = "force-dynamic"; export default async function AdminLayout({ children }: { children: ReactNode }) { - if (process.env.NODE_ENV === "development" && process.env.ENABLE_ADMIN_PREVIEW === "1") return children; - - const supabase = await createServerSupabaseClient(); - const { data: { user } } = await supabase.auth.getUser(); - - if (!user) redirect("/login"); - if (!isAdminEmail(user.email)) redirect("/"); - - return children; + 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 new file mode 100644 index 00000000..cc04f81d --- /dev/null +++ b/frontend/src/app/admin/route.ts @@ -0,0 +1,19 @@ +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 552f3c49..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,14 +109,19 @@ 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", + hasUsableBirthTime: (profile.birth_time_status === "accepted" || profile.birth_time_status === "confirmed") + && typeof profile.active_birth_time === "string", rectificationCase, profile, birthLocation: { diff --git a/frontend/src/app/api/admin/audit-logs/route.ts b/frontend/src/app/api/admin/audit-logs/route.ts new file mode 100644 index 00000000..ffdf00b1 --- /dev/null +++ b/frontend/src/app/api/admin/audit-logs/route.ts @@ -0,0 +1,84 @@ +import { NextResponse } from "next/server"; + +import { requireAdminSession } from "@/lib/admin/auth"; +import { pageOffset, queryAdminRows } from "@/lib/admin/database"; +import { + adminErrorResponse, + invalidQueryResponse, + parseListQuery, + readonlyAdminMutation, +} from "@/lib/admin/http"; + +export const runtime = "nodejs"; + +type AuditRow = { + id: string; + actor_user_id: string; + actor_email: string; + actor_role: string; + action: string; + target_type: string; + target_id: string; + before_value: Record | null; + after_value: Record | null; + request_id: string; + created_at: Date; + total_count: string; +}; + +const sortColumns = new Map([ + ["createdAt", "a.created_at"], + ["action", "a.action"], + ["actorEmail", "a.actor_email"], +]); + +export const POST = readonlyAdminMutation; +export const PUT = readonlyAdminMutation; +export const PATCH = readonlyAdminMutation; +export const DELETE = readonlyAdminMutation; + +export async function GET(request: Request) { + try { + await requireAdminSession(); + const parsed = parseListQuery(request); + if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); + const { page, pageSize, sort, order, q, status } = parsed.data; + const values: unknown[] = []; + const conditions: string[] = []; + if (q) { + values.push(`%${q}%`); + conditions.push(`(a.actor_email ilike $${values.length} or a.request_id ilike $${values.length})`); + } + if (status) { + values.push(status); + conditions.push(`a.action = $${values.length}`); + } + values.push(pageSize, pageOffset(page, pageSize)); + const sortColumn = sortColumns.get(sort ?? "createdAt") ?? "a.created_at"; + const rows = await queryAdminRows(` + select a.*, count(*) over()::text as total_count + from audit.admin_audit_logs a + ${conditions.length ? `where ${conditions.join(" and ")}` : ""} + order by ${sortColumn} ${order === "asc" ? "asc" : "desc"}, a.id asc + limit $${values.length - 1} offset $${values.length} + `, values); + return NextResponse.json({ + data: rows.map((row) => ({ + id: row.id, + actorUserId: row.actor_user_id, + actorEmail: row.actor_email, + actorRole: row.actor_role, + action: row.action, + targetType: row.target_type, + targetId: row.target_id, + before: row.before_value, + after: row.after_value, + requestId: row.request_id, + createdAt: row.created_at.toISOString(), + })), + total: Number(rows[0]?.total_count ?? 0), + }); + } catch (error) { + return adminErrorResponse(error); + } +} diff --git a/frontend/src/app/api/admin/codes/[id]/route.ts b/frontend/src/app/api/admin/codes/[id]/route.ts new file mode 100644 index 00000000..c53af63d --- /dev/null +++ b/frontend/src/app/api/admin/codes/[id]/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { requireAdminSession } from "@/lib/admin/auth"; +import { runCodeRpc } from "@/lib/admin/codes"; +import { + adminErrorResponse, + invalidQueryResponse, + requestId, +} from "@/lib/admin/http"; + +export const runtime = "nodejs"; + +const paramsSchema = z.object({ id: z.string().uuid() }); +const updateCodeSchema = z.object({ + note: z.string().trim().max(500).nullable().optional(), + expiresAt: z.string().datetime({ offset: true }).nullable().optional(), +}).refine((value) => "note" in value || "expiresAt" in value, { + message: "至少提供一个可修改字段", +}); + +export async function PATCH( + request: Request, + context: { params: Promise<{ id: string }> }, +) { + try { + const session = await requireAdminSession("write"); + const parsedParams = paramsSchema.safeParse(await context.params); + const parsedBody = updateCodeSchema.safeParse(await request.json().catch(() => null)); + if (!parsedParams.success || !parsedBody.success) { + return invalidQueryResponse(); + } + const body = parsedBody.data; + const rows = await runCodeRpc( + "admin_update_redemption_code", + session, + requestId(request), + { + p_code_id: parsedParams.data.id, + p_set_note: "note" in body, + p_note: body.note ?? null, + p_set_expires_at: "expiresAt" in body, + p_expires_at: body.expiresAt ?? null, + }, + ); + return NextResponse.json({ data: rows[0] }); + } catch (error) { + return adminErrorResponse(error); + } +} + +export async function DELETE( + request: Request, + context: { params: Promise<{ id: string }> }, +) { + try { + const session = await requireAdminSession("write"); + const parsed = paramsSchema.safeParse(await context.params); + if (!parsed.success) return invalidQueryResponse(); + const rows = await runCodeRpc( + "admin_revoke_redemption_code", + session, + requestId(request), + { p_code_id: parsed.data.id }, + ); + return NextResponse.json({ data: rows[0] }); + } catch (error) { + return adminErrorResponse(error); + } +} diff --git a/frontend/src/app/api/admin/codes/route.ts b/frontend/src/app/api/admin/codes/route.ts index e4f22605..bab2b438 100644 --- a/frontend/src/app/api/admin/codes/route.ts +++ b/frontend/src/app/api/admin/codes/route.ts @@ -1,113 +1,143 @@ import { NextResponse } from "next/server"; import { z } from "zod"; + +import { requireAdminSession } from "@/lib/admin/auth"; +import { mapCode, runCodeRpc, type RedemptionCodeRecord } from "@/lib/admin/codes"; +import { pageOffset, queryAdminRows } from "@/lib/admin/database"; import { - createAdminSupabaseClient, - isAdminEmail, -} from "@/lib/supabase/admin"; + adminErrorResponse, + invalidQueryResponse, + parseListQuery, + requestId, +} from "@/lib/admin/http"; import { generateRedeemCode, hashRedeemCode, maskRedeemCode, } from "@/lib/supabase/codes"; -import { - isSupabaseConfigurationError, - SupabaseConfigurationError, -} from "@/lib/supabase/config"; -import { createServerSupabaseClient } from "@/lib/supabase/server"; export const runtime = "nodejs"; const createCodesSchema = z.object({ credits: z.number().int().positive().max(1_000_000), count: z.number().int().min(1).max(100), - expiresAt: z.string().datetime({ offset: true }).optional(), - note: z.string().trim().max(500).optional(), + expiresAt: z.string().datetime({ offset: true }).nullable().optional(), + note: z.string().trim().max(500).nullable().optional(), }); -async function requireAdmin() { - if (!process.env.ADMIN_EMAILS?.trim()) { - throw new SupabaseConfigurationError(["ADMIN_EMAILS"]); - } +type CodeRow = { + id: string; + code_mask: string; + credits: number; + expires_at: Date | null; + note: string | null; + created_at: Date; + redeemed_by: string | null; + redeemed_email: string | null; + redeemed_at: Date | null; + revoked_by: string | null; + revoked_at: Date | null; + total_count: string; +}; - const supabase = await createServerSupabaseClient(); - const { data: { user }, error } = await supabase.auth.getUser(); - if (error || !user) return { response: NextResponse.json({ error: "请先登录" }, { status: 401 }) }; - if (!isAdminEmail(user.email)) { - return { response: NextResponse.json({ error: "无管理员权限" }, { status: 403 }) }; - } - return { user }; +const sortColumns = new Map([ + ["createdAt", "c.created_at"], + ["expiresAt", "c.expires_at"], + ["credits", "c.credits"], + ["status", "status"], +]); + +function serializedCodeRow(row: CodeRow) { + return mapCode({ + ...row, + expires_at: row.expires_at?.toISOString() ?? null, + created_at: row.created_at.toISOString(), + redeemed_at: row.redeemed_at?.toISOString() ?? null, + revoked_at: row.revoked_at?.toISOString() ?? null, + }); } -export async function GET() { +export async function GET(request: Request) { try { - const auth = await requireAdmin(); - if ("response" in auth) return auth.response; - - const admin = createAdminSupabaseClient(); - const { data, error } = await admin - .from("redemption_codes") - .select("id,code_mask,credits,expires_at,note,created_at,redeemed_by,redeemed_email,redeemed_at") - .order("created_at", { ascending: false }) - .limit(100); - - if (error) { - return NextResponse.json({ error: "暂时无法读取兑换码列表" }, { status: 500 }); + await requireAdminSession(); + const parsed = parseListQuery(request); + if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); + const { page, pageSize, sort, order, q, status } = parsed.data; + const values: unknown[] = []; + const conditions: string[] = []; + if (q) { + values.push(`%${q}%`); + conditions.push(`(c.code_mask ilike $${values.length} or c.note ilike $${values.length})`); } - + if (status && ["available", "expired", "redeemed", "revoked"].includes(status)) { + const clauses = { + available: "c.redeemed_at is null and c.revoked_at is null and (c.expires_at is null or c.expires_at > now())", + expired: "c.redeemed_at is null and c.revoked_at is null and c.expires_at <= now()", + redeemed: "c.redeemed_at is not null", + revoked: "c.revoked_at is not null", + }; + conditions.push(clauses[status as keyof typeof clauses]); + } + values.push(pageSize, pageOffset(page, pageSize)); + const sortColumn = sortColumns.get(sort ?? "createdAt") ?? "c.created_at"; + const rows = await queryAdminRows(` + select c.id, c.code_mask, c.credits, c.expires_at, c.note, + c.created_at, c.redeemed_by, c.redeemed_email, c.redeemed_at, + c.revoked_by, c.revoked_at, + case + when c.redeemed_at is not null then 'redeemed' + when c.revoked_at is not null then 'revoked' + when c.expires_at is not null and c.expires_at <= now() then 'expired' + else 'available' + end as status, + count(*) over()::text as total_count + from public.redemption_codes c + ${conditions.length ? `where ${conditions.join(" and ")}` : ""} + order by ${sortColumn} ${order === "asc" ? "asc" : "desc"}, c.id asc + limit $${values.length - 1} offset $${values.length} + `, values); return NextResponse.json({ - codes: data.map((code) => ({ - id: code.id, - mask: code.code_mask, - credits: code.credits, - expiresAt: code.expires_at, - note: code.note, - createdAt: code.created_at, - redeemedBy: code.redeemed_by, - redeemedEmail: code.redeemed_email, - redeemedAt: code.redeemed_at, - })), + data: rows.map(serializedCodeRow), + total: Number(rows[0]?.total_count ?? 0), }); } catch (error) { - if (isSupabaseConfigurationError(error)) { - return NextResponse.json({ error: "Supabase 或管理员白名单尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); - } - return NextResponse.json({ error: "兑换码管理服务暂时不可用" }, { status: 500 }); + return adminErrorResponse(error); } } export async function POST(request: Request) { try { - const auth = await requireAdmin(); - if ("response" in auth) return auth.response; - + const session = await requireAdminSession("write"); const parsed = createCodesSchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: "兑换码参数不正确" }, { status: 400 }); - } - - const { credits, count, expiresAt, note } = parsed.data; - const codes = Array.from({ length: count }, generateRedeemCode); - const admin = createAdminSupabaseClient(); - const { error } = await admin.from("redemption_codes").insert(codes.map((code) => ({ - code_hash: hashRedeemCode(code), - code_mask: maskRedeemCode(code), - credits, - expires_at: expiresAt ?? null, - note: note || null, - created_by: auth.user.id, - }))); - - if (error) { - return NextResponse.json({ error: "生成兑换码失败,请重试" }, { status: 500 }); - } - + if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); + const plainCodes = Array.from({ length: parsed.data.count }, generateRedeemCode); + const records = plainCodes.map((code) => ({ + codeHash: hashRedeemCode(code), + codeMask: maskRedeemCode(code), + credits: parsed.data.credits, + expiresAt: parsed.data.expiresAt ?? null, + note: parsed.data.note || null, + })); + const operationRequestId = requestId(request); + const stored = await runCodeRpc( + "admin_create_redemption_codes", + session, + operationRequestId, + { p_codes: records }, + ); + const byMask = new Map( + stored.map((record) => [record.mask, record]), + ); return NextResponse.json({ - codes: codes.map((code) => ({ code, credits, expiresAt: expiresAt ?? null, note: note || null })), + data: { + id: operationRequestId, + generated: plainCodes.map((code) => ({ + ...(byMask.get(maskRedeemCode(code)) ?? {}), + code, + })), + }, }, { status: 201 }); } catch (error) { - if (isSupabaseConfigurationError(error)) { - return NextResponse.json({ error: "Supabase 或管理员白名单尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); - } - return NextResponse.json({ error: "兑换码管理服务暂时不可用" }, { status: 500 }); + return adminErrorResponse(error); } } diff --git a/frontend/src/app/api/admin/consultations/route.ts b/frontend/src/app/api/admin/consultations/route.ts new file mode 100644 index 00000000..51ce4c53 --- /dev/null +++ b/frontend/src/app/api/admin/consultations/route.ts @@ -0,0 +1,79 @@ +import { NextResponse } from "next/server"; + +import { requireAdminSession } from "@/lib/admin/auth"; +import { pageOffset, queryAdminRows } from "@/lib/admin/database"; +import { + adminErrorResponse, + invalidQueryResponse, + parseListQuery, + readonlyAdminMutation, +} from "@/lib/admin/http"; + +export const runtime = "nodejs"; + +type ConsultationRow = { + id: string; + user_id: string; + email: string | null; + request_id: string; + status: string; + created_at: Date; + updated_at: Date; + total_count: string; +}; + +const sortColumns = new Map([ + ["createdAt", "c.created_at"], + ["updatedAt", "c.updated_at"], + ["status", "c.status"], +]); + +export const POST = readonlyAdminMutation; +export const PUT = readonlyAdminMutation; +export const PATCH = readonlyAdminMutation; +export const DELETE = readonlyAdminMutation; + +export async function GET(request: Request) { + try { + await requireAdminSession(); + const parsed = parseListQuery(request); + if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); + const { page, pageSize, sort, order, q, status } = parsed.data; + const values: unknown[] = []; + const conditions: string[] = []; + if (q) { + values.push(`%${q}%`); + conditions.push(`(p.email ilike $${values.length} or c.request_id ilike $${values.length})`); + } + if (status && ["reserved", "completed", "cancelled"].includes(status)) { + values.push(status); + conditions.push(`c.status = $${values.length}`); + } + values.push(pageSize, pageOffset(page, pageSize)); + const sortColumn = sortColumns.get(sort ?? "createdAt") ?? "c.created_at"; + const rows = await queryAdminRows(` + select c.user_id || ':' || c.request_id as id, c.user_id, p.email, + c.request_id, c.status, c.created_at, c.updated_at, + count(*) over()::text as total_count + from public.consultation_requests c + left join public.profiles p on p.id = c.user_id + ${conditions.length ? `where ${conditions.join(" and ")}` : ""} + order by ${sortColumn} ${order === "asc" ? "asc" : "desc"}, c.request_id asc + limit $${values.length - 1} offset $${values.length} + `, values); + return NextResponse.json({ + data: rows.map((row) => ({ + id: row.id, + userId: row.user_id, + email: row.email, + requestId: row.request_id, + status: row.status, + createdAt: row.created_at.toISOString(), + updatedAt: row.updated_at.toISOString(), + })), + total: Number(rows[0]?.total_count ?? 0), + }); + } catch (error) { + return adminErrorResponse(error); + } +} diff --git a/frontend/src/app/api/admin/credit-transactions/route.ts b/frontend/src/app/api/admin/credit-transactions/route.ts new file mode 100644 index 00000000..adf86190 --- /dev/null +++ b/frontend/src/app/api/admin/credit-transactions/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from "next/server"; + +import { requireAdminSession } from "@/lib/admin/auth"; +import { pageOffset, queryAdminRows } from "@/lib/admin/database"; +import { + adminErrorResponse, + invalidQueryResponse, + parseListQuery, + readonlyAdminMutation, +} from "@/lib/admin/http"; + +export const runtime = "nodejs"; + +type TransactionRow = { + id: string; + user_id: string; + email: string | null; + transaction_type: string; + amount: number; + balance_after: number; + request_id: string; + model: string | null; + input_tokens: number | null; + output_tokens: number | null; + created_at: Date; + total_count: string; +}; + +const sortColumns = new Map([ + ["createdAt", "t.created_at"], + ["amount", "t.amount"], + ["balanceAfter", "t.balance_after"], + ["type", "t.transaction_type"], +]); + +export const POST = readonlyAdminMutation; +export const PUT = readonlyAdminMutation; +export const PATCH = readonlyAdminMutation; +export const DELETE = readonlyAdminMutation; + +export async function GET(request: Request) { + try { + await requireAdminSession(); + const parsed = parseListQuery(request); + if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); + const { page, pageSize, sort, order, q, status } = parsed.data; + const values: unknown[] = []; + const conditions: string[] = []; + if (q) { + values.push(`%${q}%`); + conditions.push(`(p.email ilike $${values.length} or t.request_id ilike $${values.length})`); + } + if (status && ["redeem", "reserve", "refund"].includes(status)) { + values.push(status); + conditions.push(`t.transaction_type = $${values.length}`); + } + values.push(pageSize, pageOffset(page, pageSize)); + const sortColumn = sortColumns.get(sort ?? "createdAt") ?? "t.created_at"; + const rows = await queryAdminRows(` + select t.id, t.user_id, p.email, t.transaction_type, t.amount, + t.balance_after, t.request_id, t.model, t.input_tokens, + t.output_tokens, t.created_at, count(*) over()::text as total_count + from public.credit_transactions t + left join public.profiles p on p.id = t.user_id + ${conditions.length ? `where ${conditions.join(" and ")}` : ""} + order by ${sortColumn} ${order === "asc" ? "asc" : "desc"}, t.id asc + limit $${values.length - 1} offset $${values.length} + `, values); + return NextResponse.json({ + data: rows.map((row) => ({ + id: row.id, + userId: row.user_id, + email: row.email, + type: row.transaction_type, + amount: row.amount, + balanceAfter: row.balance_after, + requestId: row.request_id, + model: row.model, + inputTokens: row.input_tokens, + outputTokens: row.output_tokens, + createdAt: row.created_at.toISOString(), + })), + total: Number(rows[0]?.total_count ?? 0), + }); + } catch (error) { + return adminErrorResponse(error); + } +} 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/admin/session/route.ts b/frontend/src/app/api/admin/session/route.ts new file mode 100644 index 00000000..036a6061 --- /dev/null +++ b/frontend/src/app/api/admin/session/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; + +import { requireAdminSession } from "@/lib/admin/auth"; +import { adminErrorResponse } from "@/lib/admin/http"; + +export const runtime = "nodejs"; + +export async function GET() { + try { + const { user, role } = await requireAdminSession(); + return NextResponse.json({ + user: { + id: user.id, + email: user.email, + name: user.name, + role, + }, + }); + } catch (error) { + return adminErrorResponse(error); + } +} diff --git a/frontend/src/app/api/admin/users/route.ts b/frontend/src/app/api/admin/users/route.ts new file mode 100644 index 00000000..79d28afc --- /dev/null +++ b/frontend/src/app/api/admin/users/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from "next/server"; + +import { requireAdminSession } from "@/lib/admin/auth"; +import { pageOffset, queryAdminRows } from "@/lib/admin/database"; +import { + adminErrorResponse, + invalidQueryResponse, + parseListQuery, + readonlyAdminMutation, +} from "@/lib/admin/http"; + +export const runtime = "nodejs"; + +type UserRow = { + id: string; + email: string; + name: string | null; + role: string; + email_verified: boolean; + banned: boolean; + created_at: Date; + credits: number; + birth_date: string | null; + birth_time_status: string | null; + birth_place_label: string | null; + total_count: string; +}; + +const sortColumns = new Map([ + ["createdAt", "u.created_at"], + ["email", "u.email"], + ["credits", "p.credits"], + ["name", "u.name"], +]); + +export const POST = readonlyAdminMutation; +export const PUT = readonlyAdminMutation; +export const PATCH = readonlyAdminMutation; +export const DELETE = readonlyAdminMutation; + +export async function GET(request: Request) { + try { + await requireAdminSession(); + const parsed = parseListQuery(request); + if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); + const { page, pageSize, sort, order, q } = parsed.data; + const values: unknown[] = []; + const conditions: string[] = []; + if (q) { + values.push(`%${q}%`); + conditions.push(`(u.email ilike $${values.length} or u.name ilike $${values.length})`); + } + values.push(pageSize, pageOffset(page, pageSize)); + const sortColumn = sortColumns.get(sort ?? "createdAt") ?? "u.created_at"; + const rows = await queryAdminRows(` + select + u.id, u.email, u.name, u.role, u.email_verified, u.banned, + u.created_at, p.credits, p.birth_date, p.birth_time_status, + p.birth_place_label, count(*) over()::text as total_count + from identity.users u + join public.profiles p on p.id = u.id + ${conditions.length ? `where ${conditions.join(" and ")}` : ""} + order by ${sortColumn} ${order === "asc" ? "asc" : "desc"}, u.id asc + limit $${values.length - 1} offset $${values.length} + `, values); + return NextResponse.json({ + data: rows.map((row) => ({ + id: row.id, + email: row.email, + name: row.name, + role: row.role, + emailVerified: row.email_verified, + banned: row.banned, + createdAt: row.created_at.toISOString(), + credits: row.credits, + birthDate: row.birth_date, + birthTimeStatus: row.birth_time_status, + birthPlace: row.birth_place_label, + })), + total: Number(rows[0]?.total_count ?? 0), + }); + } catch (error) { + return adminErrorResponse(error); + } +} 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/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts new file mode 100644 index 00000000..387790da --- /dev/null +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -0,0 +1,411 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { parseAgentReply } from "@/lib/agent-reply"; +import type { ChatMessage } from "@/lib/chat-message-view"; +import { getAgenticRectificationAgent } from "@/mastra/agentic-rectification"; +import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model"; +import { blocksPromptExtraction } from "@/lib/consult-safety"; +import { runCreditRpc } from "@/lib/consultation-billing"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { + AgenticRectificationProfileError, + acceptAgenticRectificationCandidate, + createAgenticRectificationContext, + loadAgenticRectificationProfile, + loadLatestAgenticRectificationResult, +} from "@/lib/rectification-agentic/session"; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +const agenticRectificationConversationFields = { + requestId: z.string().uuid(), + sessionId: z.string().uuid(), + modelId: z.string().trim().min(1).max(64).optional(), + name: z.string().trim().max(80).optional().default(""), + history: z + .array( + z.object({ + role: z.enum(["user", "assistant"]), + text: z.string().max(4000), + }), + ) + .max(30) + .default([]), +}; + +const agenticRectificationRequestSchema = z.discriminatedUnion("action", [ + z.object({ + ...agenticRectificationConversationFields, + action: z.literal("opening"), + }).strict(), + z.object({ + ...agenticRectificationConversationFields, + action: z.literal("message"), + message: z.string().trim().min(1).max(4000), + }).strict(), + z.object({ + action: z.literal("accept_candidate"), + sessionId: z.string().uuid(), + resultId: z.string().uuid(), + time: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/), + }).strict(), +]); + +const openingContext = "The user opened birth-time rectification. Begin the session now: run the required gate, briefly explain the evidence-based process in Simplified Chinese, and ask exactly one natural question about the most useful dated life event. Do not mention this server event."; +const agenticRectificationMaxSteps = 8; + +function readPersistedMessages(value: unknown): ChatMessage[] { + if (!Array.isArray(value)) return []; + return value.flatMap((item): ChatMessage[] => { + if (!item || typeof item !== "object") return []; + const message = item as Partial; + if ((message.role !== "user" && message.role !== "assistant") || typeof message.text !== "string") return []; + return [{ + role: message.role, + text: message.text.slice(0, 100_000), + ...(Array.isArray(message.suggestions) + ? { suggestions: message.suggestions.filter((suggestion): suggestion is string => typeof suggestion === "string").slice(0, 3) } + : {}), + }]; + }); +} + +function currentTimeContext(now = new Date()) { + const chinaTime = new Date(now.getTime() + 8 * 60 * 60 * 1000) + .toISOString() + .replace("T", " ") + .slice(0, 19); + return `服务端当前时间(权威):${now.toISOString()};中国标准时间(UTC+8):${chinaTime}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`; +} + +async function recordModelUsage( + accounting: ReturnType, + userId: string, + requestId: string, + modelId: string, + usage: Promise<{ inputTokens?: number; outputTokens?: number }>, +) { + try { + const resolved = await usage; + const { error } = await accounting + .from("credit_transactions") + .update({ + model: modelId, + input_tokens: Math.max(0, Math.trunc(resolved.inputTokens ?? 0)), + output_tokens: Math.max(0, Math.trunc(resolved.outputTokens ?? 0)), + }) + .eq("user_id", userId) + .eq("transaction_type", "reserve") + .eq("request_id", requestId); + if (error) console.warn(`[agentic-rectification] unable to record usage request=${requestId}`); + } catch (error) { + console.warn(`[agentic-rectification] usage read failed request=${requestId}`, error instanceof Error ? error.name : "UnknownError"); + } +} + +export async function GET(request: Request) { + let supabase: Awaited>; + let accounting: ReturnType; + try { + supabase = await createServerSupabaseClient(); + accounting = createAdminSupabaseClient(); + } catch { + return NextResponse.json({ error: "服务尚未配置" }, { status: 503 }); + } + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + const sessionId = new URL(request.url).searchParams.get("sessionId") ?? ""; + if (!z.string().uuid().safeParse(sessionId).success) return NextResponse.json({ error: "请求格式不正确" }, { status: 400 }); + const { data: session, error } = await supabase + .from("chat_sessions") + .select("id,session_type") + .eq("id", sessionId) + .eq("user_id", user.id) + .maybeSingle(); + if (error) return NextResponse.json({ error: "暂时无法读取生时校正会话" }, { status: 503 }); + if (!session || session.session_type !== "birth_time_rectification") return NextResponse.json({ error: "生时校正会话不存在" }, { status: 404 }); + try { + return NextResponse.json({ result: await loadLatestAgenticRectificationResult(accounting, user.id, sessionId) }); + } catch { + return NextResponse.json({ error: "暂时无法读取候选结果" }, { status: 503 }); + } +} + +export async function POST(request: Request) { + let supabase: Awaited>; + let accounting: ReturnType; + try { + supabase = await createServerSupabaseClient(); + accounting = createAdminSupabaseClient(); + } catch { + return NextResponse.json( + { error: "服务尚未配置", message: "请先配置 Supabase 环境变量。" }, + { status: 503 }, + ); + } + + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json( + { error: "请先登录", message: "登录后才能开始生时校正。" }, + { status: 401 }, + ); + } + + const parsed = agenticRectificationRequestSchema.safeParse( + await request.json().catch(() => null), + ); + if (!parsed.success) { + return NextResponse.json( + { error: "请求格式不正确", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const promptSource = parsed.data.action === "accept_candidate" ? "" : [ + parsed.data.action === "message" ? parsed.data.message : "", + ...parsed.data.history.filter((message) => message.role === "user").map((message) => message.text), + ].join("\n"); + if (blocksPromptExtraction(promptSource)) { + return NextResponse.json( + { error: "无法处理该请求", message: "我不能提供系统提示词、技能原文或任何密钥。你可以继续描述人生事件。" }, + { status: 400 }, + ); + } + + const userId = user.id; + const requestTime = new Date(); + + const { data: chatSession, error: chatSessionError } = await supabase + .from("chat_sessions") + .select("id,messages,session_type") + .eq("id", parsed.data.sessionId) + .eq("user_id", userId) + .maybeSingle(); + if (chatSessionError) { + return NextResponse.json( + { error: "暂时无法读取生时校正会话", message: "请稍后重试。" }, + { status: 503 }, + ); + } + if (!chatSession || chatSession.session_type !== "birth_time_rectification") { + return NextResponse.json( + { error: "生时校正会话不存在", message: "请重新进入生时校正。" }, + { status: 404 }, + ); + } + if (parsed.data.action === "accept_candidate") { + const accepted = await acceptAgenticRectificationCandidate( + accounting, + userId, + parsed.data.sessionId, + parsed.data.time, + parsed.data.resultId, + ); + if (!accepted.ok) { + return NextResponse.json( + { error: "暂时无法采用该候选时间", message: accepted.reason }, + { status: 409 }, + ); + } + return NextResponse.json(accepted); + } + const conversation = parsed.data; + const requestId = conversation.requestId; + const persistedMessages = readPersistedMessages(chatSession.messages); + if (conversation.action === "opening" && persistedMessages.length > 0) { + return NextResponse.json( + { code: "opening_already_started", error: "生时校正已开始", message: "已有校正记录,无需重复生成首次引导。" }, + { status: 409 }, + ); + } + + let profile; + try { + profile = await loadAgenticRectificationProfile(accounting, userId); + } catch (error) { + if (error instanceof AgenticRectificationProfileError) { + if (error.code === "profile_unavailable") { + return NextResponse.json( + { error: "暂时无法核对出生资料", message: "请稍后重试。" }, + { status: 503 }, + ); + } + return NextResponse.json( + { + code: "profile_incomplete", + error: "出生资料尚未完成", + message: "请先完成出生日期、出生时间线索和出生地点资料。", + }, + { status: 409 }, + ); + } + return NextResponse.json( + { error: "暂时无法核对出生资料", message: "请稍后重试。" }, + { status: 503 }, + ); + } + + const selectedModel = (conversation.modelId ? resolveLanguageModel(conversation.modelId) : null) + ?? defaultLanguageModel(); + if (!selectedModel) { + return NextResponse.json( + { error: "模型暂不可用", message: "请选择其他模型后重新发送,本次不会扣除点数。" }, + { status: 409 }, + ); + } + + let reserveResult; + try { + reserveResult = await runCreditRpc( + accounting, + "begin_consultation_credit", + userId, + requestId, + ); + } catch (error) { + const reason = error instanceof Error ? error.name : "UnknownError"; + console.error(`[agentic-rectification] credit reserve failed request=${requestId} reason=${reason}`); + return NextResponse.json( + { error: "暂时无法确认咨询点数", message: "请稍后重试。" }, + { status: 503 }, + ); + } + if (!reserveResult.success) { + const insufficient = reserveResult.error_code === "insufficient_credits"; + return NextResponse.json( + { + error: insufficient ? "咨询点数不足" : "暂时无法扣除咨询点数", + message: insufficient ? "请先兑换咨询点数后再继续。" : reserveResult.error_code || "请稍后重试。", + }, + { status: insufficient ? 402 : 503 }, + ); + } + + const ctx = createAgenticRectificationContext(accounting, userId, profile, conversation.sessionId); + const agent = getAgenticRectificationAgent(selectedModel, ctx); + + const encoder = new TextEncoder(); + const body = new ReadableStream({ + async start(controller) { + let emitted = false; + let raw = ""; + let settled = false; + const settle = async (complete: boolean) => { + if (settled) return; + settled = true; + try { + if (complete) { + await runCreditRpc(accounting, "complete_consultation_credit", userId, requestId); + } else { + await runCreditRpc(accounting, "cancel_consultation_credit", userId, requestId); + } + } catch (error) { + const reason = error instanceof Error ? error.name : "UnknownError"; + console.warn(`[agentic-rectification] credit settle failed request=${requestId} complete=${complete} reason=${reason}`); + } + }; + const send = (event: Record) => { + controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`)); + }; + try { + const result = await agent.stream( + [ + ...conversation.history.map((message) => message.role === "user" + ? { role: "user" as const, content: message.text } + : { role: "assistant" as const, content: message.text }), + { + role: "user", + content: [ + currentTimeContext(requestTime), + conversation.name ? `用户称呼:${conversation.name}` : "", + conversation.action === "opening" ? openingContext : conversation.message, + ].filter(Boolean).join("\n"), + }, + ], + { maxSteps: agenticRectificationMaxSteps }, + ); + for await (const chunk of result.textStream) { + if (/\S/.test(chunk)) emitted = true; + raw += chunk; + send({ type: "delta", text: chunk }); + } + void recordModelUsage( + accounting, + userId, + requestId, + selectedModel.id, + result.totalUsage, + ); + const reply = parseAgentReply(raw, "general"); + if (!emitted || !reply.text) { + console.warn(`[agentic-rectification] empty response request=${requestId}`); + send({ type: "error", message: "生时校正没有生成有效回复,本次不会扣除点数,请重新发送。" }); + await settle(false); + controller.close(); + return; + } + const requestHistory = conversation.history.map((message) => ({ + role: message.role, + text: message.text, + } satisfies ChatMessage)); + const baseMessages = requestHistory.length > persistedMessages.length + ? requestHistory + : persistedMessages; + const nextMessages: ChatMessage[] = [ + ...baseMessages, + ...(conversation.action === "message" + ? [{ role: "user" as const, text: conversation.message }] + : []), + { role: "assistant" as const, text: reply.text, suggestions: reply.suggestions }, + ].slice(-500); + const { data: savedSession, error: saveError } = await supabase + .from("chat_sessions") + .update({ messages: nextMessages, updated_at: new Date().toISOString() }) + .eq("id", conversation.sessionId) + .eq("user_id", userId) + .eq("session_type", "birth_time_rectification") + .select("id") + .maybeSingle(); + if (saveError || !savedSession) throw new Error("RectificationSessionPersistenceError"); + try { + const candidateResult = await loadLatestAgenticRectificationResult(accounting, userId, conversation.sessionId); + if (candidateResult) send({ type: "candidates", result: candidateResult }); + } catch { + console.warn(`[agentic-rectification] unable to read candidate result request=${requestId}`); + } + send({ type: "done", emitted: true }); + await settle(true); + controller.close(); + } catch (error) { + const reason = error instanceof Error ? error.name : "UnknownError"; + console.error(`[agentic-rectification] generation failed request=${requestId} reason=${reason}`); + try { + send({ type: "error", message: "生时校正暂时不可用,请稍后再试。" }); + } catch { + // controller may already be errored + } + await settle(false); + try { + controller.close(); + } catch { + // already closed + } + } + }, + }); + + return new Response(body, { + headers: { + "cache-control": "no-cache, no-transform", + "content-type": "application/x-ndjson; charset=utf-8", + "x-accel-buffering": "no", + "x-ayanam-request-id": requestId, + }, + }); +} diff --git a/frontend/src/app/api/rectification/v4/_server.ts b/frontend/src/app/api/rectification/v4/_server.ts index 32445068..6cd759d3 100644 --- a/frontend/src/app/api/rectification/v4/_server.ts +++ b/frontend/src/app/api/rectification/v4/_server.ts @@ -43,13 +43,33 @@ export async function calculationSpecForUser( userId: string, ): Promise { const { data, error } = await auth.from("profiles") - .select("birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset") + .select("birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_source,timezone_offset") .eq("id", userId).maybeSingle(); if (error) throw error; if (!data) throw new RectificationV4HttpError(409, "请先补全出生日期、时间线索和出生地点。"); - const profile = await resolveMissingBirthTimezoneOffset(data); + let resolvedLocalTimeStatus: CalculationSpec["localTimeStatus"] | undefined; + const profile = await resolveMissingBirthTimezoneOffset(data, { + fetchImpl: async (input, init) => { + const response = await fetch(input, init); + const payload = await response.clone().json().catch(() => null) as { localTimeStatus?: unknown } | null; + const status = payload?.localTimeStatus; + if (status === "resolved" || status === "not_provided" || status === "ambiguous" || status === "nonexistent") { + resolvedLocalTimeStatus = status; + } + return response; + }, + }); const assessment = parseBirthTimeProfile(profile); const range = assessBirthTime(assessment, { kind: "unavailable" }).reportedRange; + const birthTimeSource = typeof data.birth_time_source === "string" && data.birth_time_source.trim() + ? data.birth_time_source.trim() as CalculationSpec["birthTimeSource"] + : undefined; + const timezoneId = typeof data.timezone_id === "string" && data.timezone_id.trim() + ? data.timezone_id.trim() + : undefined; + const timezoneSource = typeof data.timezone_source === "string" && data.timezone_source.trim() + ? data.timezone_source.trim() + : undefined; return { version: "rectification-calculation-spec-v4", birthDate: assessment.date, @@ -60,6 +80,10 @@ export async function calculationSpecForUser( latitude: assessment.location.lat, longitude: assessment.location.lon, timezoneOffsetHours: assessment.location.tz, + ...(birthTimeSource ? { birthTimeSource } : {}), + ...(timezoneId ? { timezoneId } : {}), + ...(timezoneSource ? { timezoneSource } : {}), + ...(resolvedLocalTimeStatus ? { localTimeStatus: resolvedLocalTimeStatus } : {}), ayanamsa: "lahiri", nodeMode: "mean", minuteStep: 1, diff --git a/frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts b/frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts index 23690572..dd26fe02 100644 --- a/frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts +++ b/frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { reviseEventRequestSchema } from "@/lib/rectification-v4/contracts"; -import { appendEventRevision } from "@/lib/rectification-v4/evidence-ledger"; +import { appendEventRevision, eventDateProvenance } from "@/lib/rectification-v4/evidence-ledger"; import { rectificationV4Context, rectificationV4Error, requestBody, routeId } from "../../../../../_server"; export const runtime = "nodejs"; @@ -18,9 +18,12 @@ export async function POST(request: Request, { params }: { params: Promise<{ cas eventId, domain: body.domain, eventKind: body.eventKind, + subject: body.subject, + relatedPerson: body.relatedPerson, summary: body.summary, rawText: body.rawText, dateRange: body.dateRange, + ...eventDateProvenance(body), scoreability: body.scoreability, }); return NextResponse.json(await context.service.reviseEvent({ diff --git a/frontend/src/app/api/rectification/v4/cases/[caseId]/regenerate/route.ts b/frontend/src/app/api/rectification/v4/cases/[caseId]/regenerate/route.ts new file mode 100644 index 00000000..d98398c6 --- /dev/null +++ b/frontend/src/app/api/rectification/v4/cases/[caseId]/regenerate/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { caseActionRequestSchema } from "@/lib/rectification-v4/contracts"; +import { rectificationV4Context, rectificationV4Error, requestBody, routeId } from "../../../_server"; + +export const runtime = "nodejs"; + +export async function POST(request: Request, { params }: { params: Promise<{ caseId: string }> }) { + try { + const body = await requestBody(request, caseActionRequestSchema); + const context = await rectificationV4Context(); + const result = await context.service.regenerateQuestion({ + ...body, + userId: context.userId, + caseId: routeId((await params).caseId), + }); + return result + ? NextResponse.json(result) + : NextResponse.json({ error: "当前问题不能重新生成,请刷新后重试。" }, { status: 409 }); + } catch (error) { + return rectificationV4Error(error); + } +} diff --git a/frontend/src/app/api/redeem/route.ts b/frontend/src/app/api/redeem/route.ts index 506a9df4..c4e9227e 100644 --- a/frontend/src/app/api/redeem/route.ts +++ b/frontend/src/app/api/redeem/route.ts @@ -12,6 +12,7 @@ const redeemErrors: Record = { unauthorized: { status: 401, message: "请先登录" }, invalid_code: { status: 404, message: "兑换码不存在" }, expired_code: { status: 410, message: "兑换码已过期" }, + revoked_code: { status: 410, message: "兑换码已撤销" }, already_redeemed: { status: 409, message: "兑换码已被使用" }, profile_missing: { status: 500, message: "账户资料不存在,请稍后重试" }, }; diff --git a/frontend/src/app/birth-time-choice.css b/frontend/src/app/birth-time-choice.css index 7d1626e3..fdc9d7c2 100644 --- a/frontend/src/app/birth-time-choice.css +++ b/frontend/src/app/birth-time-choice.css @@ -9,7 +9,7 @@ .birth-time-choice-option.is-primary { width: 100%; padding: var(--space-3) var(--space-4); } .birth-time-choice-option.is-secondary { min-height: 44px; flex: 1 1 0; padding: var(--space-2) var(--space-4); color: var(--color-ink-secondary); text-align: center; } .birth-time-choice-option:hover:not(:disabled) { border-color: var(--color-border-strong); background: var(--color-canvas-soft); } -.birth-time-choice-option:active:not(:disabled) { transform: translateY(1px); } +.birth-time-choice-option:active:not(:disabled) { transform: scale(.96); } .birth-time-choice-option[data-selected="true"] { border-color: var(--color-action); background: var(--color-action-soft); color: var(--color-ink); } .birth-time-choice-option:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; } .birth-time-choice-question:disabled .birth-time-choice-option { cursor: wait; opacity: .62; } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index e665c9a2..92ff7adc 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -28,8 +28,8 @@ --color-action-on-dark: #b76f58; --color-ink: #1d1d1f; --color-ink-strong: #32322f; - --color-ink-secondary: #676762; - --color-ink-tertiary: #8a8983; + --color-ink-secondary: #5f5f59; + --color-ink-tertiary: #6a6963; --color-canvas: #fbfaf7; --color-canvas-soft: #f3f2ee; --color-canvas-muted: #ebe9e3; @@ -54,6 +54,7 @@ --color-dark-hover: rgba(251, 250, 247, .1); --color-dark-muted: #aaa8a2; --color-dark-border: rgba(251, 250, 247, .16); + --shadow-soft: 0 1px 2px rgba(29, 29, 31, .06), 0 8px 20px -18px rgba(29, 29, 31, .2); --shadow-elevated: 0 1px 2px rgba(29, 29, 31, .07), 0 12px 28px -16px rgba(29, 29, 31, .18); --font-display: "Tiempos Headline", "Songti SC", STSong, "Noto Serif CJK SC", Georgia, serif; --font-body: StyreneB, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; @@ -224,7 +225,7 @@ button:disabled { cursor: default; opacity: .45; } .status-已过期, .status-已兑换 { color: var(--color-ink-secondary); } .empty-cell { color: var(--color-ink-secondary); text-align: center !important; } -.new-chat:not(:disabled):active, .session-list button:not(:disabled):active, .profile-trigger:not(:disabled):active, .credit-button:not(:disabled):active, .account-menu-item:not(:disabled):active, .starter-list > button:not(:disabled):active, .composer-suggestions button:not(:disabled):active, .composer button:not(:disabled):active, .button-primary:not(:disabled):active, .button-secondary:not(:disabled):active, .dialog-close:not(:disabled):active, .generated-list button:not(:disabled):active, .inline-actions button:not(:disabled):active { transform: scale(.98); } +.new-chat:not(:disabled):active, .session-main:not(:disabled):active, .session-menu-trigger:not(:disabled):active, .session-action-item:not([data-disabled]):active, .profile-trigger:not(:disabled):active, .credit-button:not(:disabled):active, .account-menu-item:not([data-disabled]):active, .starter-list > button:not(:disabled):active, .composer-suggestions button:not(:disabled):active, .composer button:not(:disabled):active, .button-primary:not(:disabled):active, .button-secondary:not(:disabled):active, .dialog-close:not(:disabled):active, .generated-list button:not(:disabled):active, .inline-actions button:not(:disabled):active { transform: scale(.96); } @keyframes app-loading-orbit { to { transform: rotate(360deg); } } @keyframes pulse { from { opacity: .28; transform: translateY(1px); } to { opacity: 1; transform: translateY(-1px); } } @@ -251,7 +252,7 @@ button:disabled { cursor: default; opacity: .45; } .brand-row { padding: 0 4px; } .brand-mark { width: 26px; height: 26px; } .session-nav { overflow: hidden; } - .session-list { overflow-x: hidden; } + .session-list { overflow-x: clip; } .chat-header > div { min-width: 0; flex: 1; } .chat-header strong { max-width: 100%; } .onboarding-card { padding: 16px; } @@ -276,7 +277,7 @@ button:disabled { cursor: default; opacity: .45; } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; transition-delay: 0s !important; } - .model-selector-popup[data-starting-style], .model-selector-popup[data-ending-style], .account-menu-popup[data-starting-style], .account-menu-popup[data-ending-style], [role="tooltip"][data-starting-style], [role="tooltip"][data-ending-style] { transform: none; } + .model-selector-popup[data-starting-style], .model-selector-popup[data-ending-style], .session-actions[data-starting-style], .session-actions[data-ending-style], .account-menu-popup[data-starting-style], .account-menu-popup[data-ending-style], [role="tooltip"][data-starting-style], [role="tooltip"][data-ending-style] { transform: none; } .auth-step { transform: none; transition: opacity 80ms linear !important; } @starting-style { .auth-step { opacity: 0; transform: none; } } } @@ -311,31 +312,37 @@ button:disabled { cursor: default; opacity: .45; } [data-sidebar="sidebar"]:focus { outline: none; } .brand-row { display: flex; align-items: center; min-height: 48px; gap: var(--space-3); padding: 0 var(--space-2); font-family: var(--font-display); font-size: var(--type-title-lg); letter-spacing: -.3px; } .brand-row strong { font-weight: 400; } -.brand-mark, .auth-brand span { width: 32px; height: 32px; border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px var(--color-border); } -.auth-story-brand img { width: 32px; height: 32px; border-radius: 50%; object-fit: contain; box-shadow: 0 0 0 1px var(--color-border); } +.brand-mark, .auth-brand span { width: 32px; height: 32px; border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px oklch(0 0 0 / .1); } +.auth-story-brand img { width: 32px; height: 32px; border-radius: 50%; object-fit: contain; box-shadow: 0 0 0 1px oklch(0 0 0 / .1); } .new-chat { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: center; gap: var(--space-2); padding: 0 var(--space-3); border: 0; background: var(--sidebar-primary); color: var(--sidebar-primary-foreground); cursor: pointer; font-size: var(--type-body-sm); transition: background-color 120ms ease-out, transform 120ms ease-out; margin: var(--space-4) 0 var(--space-6); border-radius: var(--radius-md); font-weight: 500; } .session-nav-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--space-2); padding: 0 var(--space-3) var(--space-2); } .sidebar-label { min-height: 44px; display: flex; align-items: center; color: var(--sidebar-muted-foreground); font-size: var(--type-overline); font-weight: 500; letter-spacing: 1.5px; } +.sidebar-label:focus-visible { outline: 3px solid color-mix(in srgb, var(--sidebar-ring) 56%, transparent); outline-offset: 2px; border-radius: var(--radius-sm); } .session-nav-toggle { min-height: 44px; padding: 0 var(--space-2); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--sidebar-muted-foreground); cursor: pointer; font-size: var(--type-overline); transition: background-color 120ms ease-out, color 120ms ease-out; } .session-list { min-height: 0; display: flex; flex-direction: column; gap: var(--space-1); } -.session-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: center; border-radius: var(--radius-md); color: var(--sidebar-muted-foreground); transition: background-color 120ms ease-out, color 120ms ease-out; } +.session-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: center; border-radius: var(--radius-lg); color: var(--sidebar-muted-foreground); transition: background-color 120ms ease-out, box-shadow 120ms ease-out, color 120ms ease-out; } .session-row:hover, .session-row:focus-within { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); } -.session-row:has(.session-main[data-active="true"]) { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); } +.session-row:has(.session-main[data-active="true"]) { background: var(--sidebar-accent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sidebar-ring) 22%, transparent); color: var(--sidebar-accent-foreground); } .session-main { position: relative; width: 100%; min-height: 52px; display: grid; gap: 2px; padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4); border: 0; border-radius: var(--radius-md); background: transparent; color: inherit; cursor: pointer; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; } .session-main[data-active="true"] { color: var(--sidebar-accent-foreground); background: transparent; } .session-main[data-active="true"]::before { position: absolute; border-radius: 3px; content: ""; top: var(--space-3); bottom: var(--space-3); left: var(--space-1); width: 2px; background: var(--sidebar-ring); } .session-title { min-width: 0; display: flex; align-items: center; gap: var(--space-1); overflow: hidden; line-height: 1.35; font-size: var(--type-body-sm); font-weight: 500; } .session-title > svg { width: var(--space-3); height: var(--space-3); flex: 0 0 auto; color: var(--sidebar-ring); } -.session-main small { color: inherit; line-height: 1.3; opacity: .72; font-size: var(--type-overline); } -.session-menu-trigger { width: 44px; height: 44px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 0; background: transparent; color: inherit; cursor: pointer; opacity: .64; transition: opacity 120ms ease-out, color 120ms ease-out; } +.session-main small { color: var(--color-ink-tertiary); line-height: 1.3; font-size: var(--type-overline); } +.session-menu-trigger { width: 44px; height: 44px; display: grid; place-items: center; justify-self: center; padding: 0; border: 0; border-radius: 0; background: transparent; color: inherit; cursor: pointer; opacity: .64; transition: background-color 120ms ease-out, color 120ms ease-out, opacity 120ms ease-out, transform 120ms ease-out; } .session-menu-trigger > svg { width: 18px; height: 18px; } .session-row:hover .session-menu-trigger, .session-row:focus-within .session-menu-trigger, .session-menu-trigger[aria-expanded="true"] { opacity: 1; } -.session-menu-trigger:hover, .session-menu-trigger[aria-expanded="true"] { background: transparent; color: inherit; } -.session-actions { position: absolute; z-index: 4; top: calc(100% - var(--space-1)); right: 0; min-width: calc(var(--space-24) + var(--space-12)); display: grid; gap: var(--space-1); padding: var(--space-2); border: 1px solid var(--sidebar-border); border-radius: var(--radius-md); background: var(--color-canvas); box-shadow: var(--shadow-elevated); } -.session-actions button { width: 100%; min-height: 44px; display: flex; align-items: center; gap: var(--space-2); padding: 0 var(--space-3); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-ink-secondary); cursor: pointer; text-align: left; font-size: var(--type-body-sm); } -.session-actions button > svg { width: 16px; height: 16px; } -.session-actions button:hover { background: var(--color-canvas-muted); color: var(--color-ink); } -.session-actions .session-action-danger { color: var(--color-danger); } +.session-menu-trigger:hover, .session-menu-trigger[aria-expanded="true"] { background: var(--color-canvas); color: var(--color-ink); } +.session-actions-positioner { z-index: 30; max-width: calc(100vw - var(--space-6)); max-height: calc(100dvh - var(--space-6)); outline: 0; } +.session-actions { min-width: 176px; max-height: var(--available-height); overflow-y: auto; display: grid; gap: var(--space-1); padding: var(--space-2); border: 1px solid var(--sidebar-border); border-radius: var(--radius-xl); background: var(--color-canvas); box-shadow: var(--shadow-elevated); transform-origin: var(--transform-origin); transition: opacity 120ms ease-out, transform 120ms var(--ease-out); } +.session-actions[data-starting-style], .session-actions[data-ending-style] { opacity: 0; transform: translateY(var(--space-1)) scale(.98); } +.session-action-item { width: 100%; min-height: 44px; display: flex; align-items: center; gap: var(--space-2); padding: 0 var(--space-3); border: 0; border-radius: var(--radius-md); outline: 0; background: transparent; color: var(--color-ink-secondary); cursor: pointer; text-align: left; user-select: none; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; font-size: var(--type-body-sm); } +.session-action-item > svg { width: 16px; height: 16px; color: var(--color-ink-tertiary); transition: color 120ms ease-out; } +.session-action-item:hover, .session-action-item[data-highlighted] { background: var(--color-canvas-muted); color: var(--color-ink); } +.session-action-item:hover > svg, .session-action-item[data-highlighted] > svg { color: var(--color-ink); } +.session-actions-separator { height: 1px; margin: var(--space-1) var(--space-3); background: var(--color-border); } +.session-actions .session-action-danger, .session-actions .session-action-danger > svg { color: var(--color-danger); } +.session-actions .session-action-danger[data-highlighted] { background: var(--color-danger-muted); } .sidebar-footer { position: relative; margin-top: 0; padding-top: 10px; border-top: 1px solid var(--sidebar-border); } .profile-trigger { width: 100%; display: grid; grid-template-columns: 34px minmax(0, 1fr) 18px; align-items: center; gap: 9px; padding: 5px 7px; border: 0; background: transparent; color: var(--sidebar-foreground); cursor: pointer; text-align: left; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 56px; border-radius: var(--radius-md); } .profile-initial { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 50%; font-size: 12px; text-transform: uppercase; border: 0; background: var(--color-action); color: var(--color-on-dark); font-weight: 500; } @@ -343,7 +350,7 @@ button:disabled { cursor: default; opacity: .45; } [data-sidebar="trigger"] { width: 44px; height: 44px; display: grid; flex: 0 0 auto; place-items: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--sidebar-foreground); cursor: pointer; } [data-sidebar="trigger"] svg { width: 18px; height: 18px; } [data-sidebar="trigger"]:hover { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); } -[data-sidebar="trigger"]:active { transform: translateY(1px); } +[data-sidebar="trigger"]:active { transform: scale(.96); } [data-sidebar="rail"] { position: absolute; z-index: 2; top: 0; right: calc(var(--space-1) / -1); width: var(--space-2); min-width: var(--space-2); height: 100%; min-height: 0; padding: 0; border: 0; background: transparent; cursor: col-resize; transition: background-color 120ms ease-out, color 120ms ease-out; } [data-sidebar="rail"]:focus-visible { outline: 3px solid color-mix(in srgb, var(--sidebar-ring) 56%, transparent); outline-offset: -2px; } @@ -490,7 +497,7 @@ button:disabled { cursor: default; opacity: .45; } .conversational-narrative .message-markdown { overflow-wrap: anywhere; color: var(--color-ink-secondary); font-size: var(--type-body-sm); line-height: 1.8; } .conversational-narrative .message-markdown > :first-child { margin-top: 0; } .conversational-narrative .message-markdown > :last-child { margin-bottom: 0; } -.conversational-narrative .message-markdown h2, .conversational-narrative .message-markdown h3 { margin: var(--space-4) 0 var(--space-2); font-family: var(--font-sans); font-size: var(--type-body-md); font-weight: 600; letter-spacing: 0; } +.conversational-narrative .message-markdown h2, .conversational-narrative .message-markdown h3 { margin: var(--space-4) 0 var(--space-2); font-family: var(--font-body); font-size: var(--type-body-md); font-weight: 600; letter-spacing: 0; } .conversational-answer-pending { min-width: 0; display: grid; grid-template-columns: 40px minmax(0, 1fr); align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); border: 1px solid color-mix(in srgb, var(--color-action) 24%, var(--color-border)); border-radius: var(--radius-lg); background: var(--color-action-soft); } .conversational-answer-pending .app-loading-symbol { width: 40px; height: 40px; } .conversational-answer-pending .app-loading-mark { width: 22px; height: 22px; } @@ -562,7 +569,7 @@ button:disabled { cursor: default; opacity: .45; } .starter-note { margin: 10px 0 0; color: var(--color-ink-secondary); line-height: 1.5; grid-column: 1 / -1; font-size: 13px; } .message-list { margin: 0 auto; width: min(900px, 100%); padding: var(--space-8) var(--space-8) var(--space-16); } .message { display: flex; animation: message-enter 160ms var(--ease-out) both; padding: var(--space-2) 0; } -.agent-avatar { width: 32px; height: 32px; display: block; flex: 0 0 32px; margin-top: var(--space-2); border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px var(--color-border); } +.agent-avatar { width: 32px; height: 32px; display: block; flex: 0 0 32px; margin-top: var(--space-2); border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px oklch(0 0 0 / .1); } .message-content { min-width: 0; max-width: min(80%, 680px); } .message-bubble { overflow: hidden; border: 0; padding: var(--space-3) var(--space-4); border-radius: var(--radius-lg); background: var(--color-canvas-muted); } .message-assistant .message-bubble { border-radius: 0; background: transparent; padding: var(--space-3) 0; } @@ -601,8 +608,58 @@ button:disabled { cursor: default; opacity: .45; } .rectification-message-actions button:focus-visible { outline: 2px solid color-mix(in srgb, var(--color-focus) 52%, transparent); outline-offset: 1px; } .rectification-message-actions button:disabled { cursor: default; opacity: 0.32; } .rectification-message-actions svg { width: 13px; height: 13px; stroke-width: 1.65; } +.rectification-analysis { + width: min(620px, calc(100% - 42px)); + margin: -2px 0 var(--space-1) 42px; + color: var(--color-ink-secondary); + font-size: var(--type-caption); +} +.rectification-analysis > summary { + width: fit-content; + display: flex; + align-items: center; + gap: var(--space-2); + min-height: 30px; + padding: 0 var(--space-2); + border-radius: var(--radius-sm); + color: var(--color-ink-tertiary); + cursor: pointer; + list-style: none; + transition: background-color 120ms ease-out, color 120ms ease-out; +} +.rectification-analysis > summary::-webkit-details-marker { display: none; } +.rectification-analysis > summary::before { + width: 6px; + height: 6px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + content: ""; + transform: rotate(-45deg); + transition: transform 120ms ease-out; +} +.rectification-analysis[open] > summary::before { transform: rotate(45deg) translate(-1px, -1px); } +.rectification-analysis > summary:hover { background: var(--color-canvas-muted); color: var(--color-ink-secondary); } +.rectification-analysis > summary:focus-visible { outline: 2px solid color-mix(in srgb, var(--color-focus) 52%, transparent); outline-offset: 1px; } +.rectification-analysis > summary small { color: var(--color-ink-muted); font-size: inherit; } +.rectification-analysis-content { + display: grid; + gap: var(--space-3); + margin: var(--space-1) 0 var(--space-2); + padding: var(--space-3) var(--space-4); + border: 1px solid color-mix(in srgb, var(--color-border) 76%, transparent); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-canvas-muted) 54%, transparent); +} +.rectification-analysis-content section { display: grid; gap: var(--space-2); } +.rectification-analysis-content h4 { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 600; } +.rectification-analysis-content ol, +.rectification-analysis-content ul { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; } +.rectification-analysis-content li { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); } +.rectification-analysis-content li span { min-width: 0; color: var(--color-ink-secondary); } +.rectification-analysis-content li small { flex: 0 0 auto; color: var(--color-ink-muted); } +.rectification-analysis-content p { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.55; } .message p, .message-markdown { color: var(--color-ink-strong); font-size: var(--type-body-md); line-height: 1.65; text-wrap: pretty; word-break: auto-phrase; } -.message-evidence-status { margin: var(--space-3) 0 0; padding-top: var(--space-2); border-top: 1px solid var(--color-border); color: var(--color-ink-muted); font-size: var(--type-body-sm); line-height: 1.5; } +.message-evidence-status { margin: var(--space-3) 0 0; padding-top: var(--space-2); border-top: 1px solid var(--color-border); color: var(--color-ink-tertiary); font-size: var(--type-body-sm); line-height: 1.5; } .message-user p { line-height: 1.55; color: var(--color-ink); font-size: var(--type-body-sm); } .message-markdown h2, .message-markdown h3 { margin: 24px 0 10px; color: var(--color-ink); font-family: var(--font-display); font-weight: 400; letter-spacing: -.3px; } .message-markdown h2 { font-size: var(--type-display-sm); } @@ -646,21 +703,23 @@ button:disabled { cursor: default; opacity: .45; } .profile-trigger .chevron { transition: transform 120ms var(--ease-out); } .profile-trigger .chevron.is-open { transform: rotate(-90deg); } :has(> .account-menu-popup) { z-index: 30; max-width: calc(100vw - var(--space-6)); max-height: calc(100dvh - var(--space-6)); outline: 0; } -.account-menu-popup { width: min(280px, calc(100vw - var(--space-6))); max-height: var(--available-height); overflow-y: auto; padding: var(--space-2); border: 1px solid var(--sidebar-border); border-radius: var(--radius-lg); background: var(--color-canvas); color: var(--sidebar-foreground); box-shadow: var(--shadow-elevated); transform-origin: var(--transform-origin); transition: opacity 120ms ease-out, transform 120ms var(--ease-out); } +.account-menu-popup { width: min(280px, calc(100vw - var(--space-6))); max-height: var(--available-height); overflow-y: auto; padding: var(--space-2); border: 1px solid var(--sidebar-border); border-radius: var(--radius-xl); background: var(--color-canvas); color: var(--sidebar-foreground); box-shadow: var(--shadow-elevated); transform-origin: var(--transform-origin); transition: opacity 120ms ease-out, transform 120ms var(--ease-out); } .account-menu-popup[data-starting-style], .account-menu-popup[data-ending-style] { opacity: 0; transform: translateY(var(--space-1)); } -.account-menu-identity { min-width: 0; display: grid; grid-template-columns: 36px minmax(0, 1fr); align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-3) var(--space-3); } -.account-menu-avatar { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 50%; background: var(--color-action); color: var(--color-on-dark); font-size: var(--type-caption); font-weight: 500; text-transform: uppercase; } +.account-menu-identity { min-width: 0; display: grid; grid-template-columns: 40px minmax(0, 1fr); align-items: center; gap: var(--space-3); margin-bottom: var(--space-1); padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-canvas-muted); } +.account-menu-avatar { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 50%; background: var(--color-action); color: var(--color-on-dark); font-size: var(--type-caption); font-weight: 500; text-transform: uppercase; } .account-menu-identity > span:last-child { min-width: 0; } .account-menu-identity b, .account-menu-identity small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .account-menu-identity b { color: var(--color-ink); font-size: var(--type-body-sm); font-weight: 500; } .account-menu-identity small { margin-top: var(--space-1); color: var(--color-ink-tertiary); font-size: var(--type-overline); } .account-menu-item { width: 100%; min-height: 44px; display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); padding: 0 var(--space-3); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-ink); cursor: pointer; text-align: left; text-decoration: none; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; } +.account-menu-item[data-highlighted] { background: var(--color-canvas-muted); outline: 0; } .account-menu-item > svg { width: 18px; height: 18px; color: var(--color-ink-tertiary); } .account-menu-item > svg:last-child { width: 16px; height: 16px; } .account-menu-item > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--type-body-sm); } .account-menu-item > small { color: var(--color-ink-tertiary); font-size: var(--type-caption); font-variant-numeric: tabular-nums; } .account-menu-separator { height: 1px; margin: var(--space-2) var(--space-3); background: var(--color-border); } .account-menu-danger, .account-menu-danger > svg { color: var(--color-danger); } +.account-menu-danger[data-highlighted] { background: var(--color-danger-muted); } :has(> [role="tooltip"]) { z-index: 40; max-width: calc(100vw - var(--space-6)); pointer-events: none; } [role="tooltip"] { max-width: min(240px, calc(100vw - var(--space-6))); padding: var(--space-2) var(--space-3); border: 1px solid var(--sidebar-border); border-radius: var(--radius-md); background: var(--sidebar-solid); color: var(--sidebar-foreground); box-shadow: var(--shadow-elevated); pointer-events: none; font-size: var(--type-caption); font-weight: 500; line-height: 1.4; transform-origin: var(--transform-origin); transition: opacity 120ms ease-out, transform 120ms var(--ease-out); } @@ -668,6 +727,9 @@ button:disabled { cursor: default; opacity: .45; } .account-modal-overlay { position: fixed; z-index: 40; inset: 0; display: grid; place-items: center; padding: var(--space-4); background: var(--color-scrim); animation: account-overlay-enter 180ms ease-out both; } .account-modal { width: min(100%, 560px); max-height: min(84dvh, 760px); overflow-y: auto; padding: var(--space-8); border: 1px solid var(--color-border); border-radius: var(--radius-xl); background: var(--color-canvas); box-shadow: var(--shadow-elevated); animation: account-dialog-enter 180ms var(--ease-out) both; } +.profile-modal { width: min(100%, 680px); scroll-padding-block-start: calc(var(--space-8) + 72px); } +.profile-modal .account-modal-header { position: sticky; z-index: 1; top: 0; margin: calc(var(--space-8) * -1) calc(var(--space-8) * -1) var(--space-5); padding: var(--space-8) var(--space-8) var(--space-5); border-bottom: 1px solid var(--color-border); background: var(--color-canvas); } +.profile-modal .birth-section { padding-top: var(--space-5); } .redeem-modal { width: min(100%, 420px); } .logout-modal { width: min(100%, 400px); } .account-modal h2, .auth-panel h1, .admin-header h1 { font-family: var(--font-display); font-weight: 400; letter-spacing: -.5px; text-wrap: balance; } @@ -684,6 +746,7 @@ button:disabled { cursor: default; opacity: .45; } .section-toggle b, .section-heading b { display: block; font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; } .section-toggle small, .section-heading small { display: block; margin-top: 4px; color: var(--color-ink-secondary); font-weight: 400; font-size: var(--type-caption); } .default-chart-card { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); margin-top: var(--space-5); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas-muted); } +.default-chart-card > div, .chart-library-item > div:first-child, .chart-library-actions { min-width: 0; } .default-chart-card span, .default-chart-card small { display: block; color: var(--color-ink-secondary); font-size: var(--type-caption); } .default-chart-card strong { display: block; margin: 4px 0; color: var(--color-ink); font-size: var(--type-body-md); font-weight: 500; } .chart-library-panel { display: grid; gap: var(--space-5); margin-top: var(--space-5); } @@ -694,6 +757,7 @@ button:disabled { cursor: default; opacity: .45; } .chart-library-item strong { color: var(--color-ink); font-size: var(--type-body-md); font-weight: 500; } .chart-library-item small, .chart-library-item > span, .empty-library-copy { color: var(--color-ink-secondary); font-size: var(--type-caption); } .chart-library-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); } +.chart-library-actions > * { max-width: 100%; } .chart-library-form { padding-top: var(--space-4); border-top: 1px solid var(--color-border); } .synastry-report-card { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas-muted); } .synastry-report-card span, .synastry-report-card small, .synastry-report-card li { color: var(--color-ink-secondary); font-size: var(--type-caption); } @@ -739,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); } @@ -797,6 +863,7 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: .message-content { max-width: 88%; } .composer-wrap { padding: var(--space-2) var(--space-3) max(var(--space-3), env(safe-area-inset-bottom)); } .account-modal { max-height: calc(100dvh - var(--space-8)); padding: var(--space-6); } + .profile-modal .account-modal-header { margin: calc(var(--space-6) * -1) calc(var(--space-6) * -1) var(--space-4); padding: var(--space-6) var(--space-6) var(--space-4); } .auth-page { padding: 0; } .auth-shell { min-height: 100dvh; grid-template-columns: 1fr; grid-template-rows: auto 1fr; border-radius: 0; box-shadow: none; } .auth-story { min-height: 248px; padding: var(--space-8) var(--space-6); } @@ -810,6 +877,8 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: } @media (max-width: 480px) { + .default-chart-card, .chart-library-item { flex-direction: column; align-items: stretch; } + .chart-library-actions { justify-content: flex-start; } .birth-time-assessment-heading { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-2); } .birth-time-status-badge { justify-self: start; } .birth-time-detail-grid, .birth-time-range-summary, .birth-time-answer-list, .birth-time-candidate-grid { grid-template-columns: 1fr; } @@ -1194,7 +1263,7 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: background: transparent; cursor: pointer; text-align: left; - transition: background-color 220ms ease-out, flex-grow 360ms var(--ease-out); + transition: background-color 150ms ease-out; } .starter-theme-card:last-child { @@ -1203,15 +1272,9 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: .starter-theme-card:not(:disabled):hover, .starter-theme-card:focus-visible { - flex-grow: 1.62; background: color-mix(in srgb, var(--color-action-soft) 56%, var(--color-canvas)); } -.starter-theme-accordion:has(.starter-theme-card:not(:disabled):hover) .starter-theme-card:not(:hover), -.starter-theme-accordion:has(.starter-theme-card:focus-visible) .starter-theme-card:not(:focus-visible) { - flex-grow: .78; -} - .starter-theme-card:not(:disabled):hover .starter-arrow, .starter-theme-card:focus-visible .starter-arrow { color: var(--color-action); @@ -1375,11 +1438,9 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: border-bottom: 0; } - .starter-theme-accordion:has(.starter-theme-card:not(:disabled):hover) .starter-theme-card:not(:hover), - .starter-theme-accordion:has(.starter-theme-card:focus-visible) .starter-theme-card:not(:focus-visible), .starter-theme-card:not(:disabled):hover, .starter-theme-card:focus-visible { - flex-grow: 1; + background: color-mix(in srgb, var(--color-action-soft) 56%, var(--color-canvas)); } .starter-content span { @@ -1539,6 +1600,11 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: max-width: 88%; } + .rectification-analysis { + width: calc(100% - 38px); + margin-left: 38px; + } + .conversation:not(.is-empty):not(.is-rectification) + .composer-wrap { padding-top: var(--space-3); padding-bottom: var(--space-3); @@ -1651,3 +1717,73 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: .birth-time-clock-menu.select-content { width: 108px; min-width: 108px; } .birth-time-clock-menu .select-item { justify-content: flex-start; } + +.rectification-candidates { + display: grid; + gap: 14px; + margin: 8px 0 16px; + padding: 18px; + border: 1px solid var(--border); + border-radius: 16px; + background: color-mix(in srgb, var(--card) 92%, transparent); +} +.rectification-candidates-heading { display: grid; gap: 5px; } +.rectification-candidates-heading strong { font-size: 16px; } +.rectification-candidates-heading span, +.rectification-candidate-support { color: var(--muted-foreground); font-size: 12px; line-height: 1.5; } +.rectification-candidate-list { + display: grid; + width: 100%; + min-width: 0; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} +.rectification-candidate { + display: grid; + gap: 10px; + min-width: 0; + min-height: 132px; + padding: 14px; + border: 1px solid var(--border); + border-radius: 12px; + color: var(--foreground); + background: var(--card); + font: inherit; + text-align: left; + cursor: pointer; +} +.rectification-candidate:hover:not(:disabled) { border-color: color-mix(in srgb, var(--primary) 45%, var(--border)); } +.rectification-candidate:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } +.rectification-candidate:disabled { cursor: default; opacity: 1; } +.rectification-candidate.is-selected { + border-color: color-mix(in srgb, var(--primary) 55%, var(--border)); + background: color-mix(in srgb, var(--primary) 7%, var(--card)); +} +.rectification-candidate-time { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.rectification-candidate-time strong { font-size: 22px; font-variant-numeric: tabular-nums; } +.rectification-candidate-badge { + padding: 2px 7px; + border-radius: 999px; + color: var(--primary) !important; + background: color-mix(in srgb, var(--primary) 10%, transparent); + font-size: 11px !important; + font-weight: 650; +} +.rectification-candidate-action { align-self: end; color: var(--primary); font-size: 13px; font-weight: 650; } +.rectification-saved { margin: 8px 0 16px; color: var(--foreground); font-size: 14px; } +@media (max-width: 640px) { + .rectification-candidates { padding: 14px; } + .rectification-candidate-list { + grid-template-columns: none; + grid-auto-flow: column; + grid-auto-columns: minmax(180px, 78%); + overflow-x: auto; + scroll-snap-type: x proximity; + } + .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 18f7f1fa..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"; @@ -15,7 +15,6 @@ import { import { BirthTimeIntakeFields } from "@/components/birth-time-intake"; import { AppLoadingIndicator } from "@/components/app-loading-indicator"; import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification"; -import type { RectificationV4Continuation } from "@/components/rectification-v4-panel"; import { ChatMessageContent } from "@/components/chat-message-content"; import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row"; import { ModelSelector } from "@/components/model-selector"; @@ -38,6 +37,7 @@ import { describeBirthTimeDraft, isDeclaredBirthProfileComplete, isBirthTimeDraftReady, + normalizePersistedBirthDate, type BirthTimeDraft, type BirthTimeSource, } from "@/lib/birth-time-intake-model"; @@ -54,7 +54,6 @@ import { type RectificationCardAction, } from "@/lib/birth-time-consultation-consent"; import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode"; -import { claimRectificationV4Handoff } from "@/lib/rectification-v4/client"; import { createRectificationQuestionHandoffCoordinator, } from "@/lib/rectification-question-handoff"; @@ -180,8 +179,10 @@ type Account = { user: { id: string; email: string | null }; credits: number; isAdmin: boolean; + adminUrl: string | null; rectificationPriceCredits: number; hasConfirmedBirthTime: boolean; + hasUsableBirthTime: boolean; rectificationCase: AccountRectificationCaseState | null; profile: unknown; }; @@ -609,7 +610,9 @@ function readProfile(value: unknown): Profile { longitude?: unknown; timezone_offset?: unknown; }; - const date = typeof profile.birth_date === "string" ? profile.birth_date : profile.date; + const date = normalizePersistedBirthDate( + typeof profile.birth_date === "string" ? profile.birth_date : profile.date, + ); const legacyTime = typeof profile.birth_time === "string" ? profile.birth_time.slice(0, 5) : profile.time; const time = typeof profile.active_birth_time === "string" ? profile.active_birth_time.slice(0, 5) @@ -625,7 +628,7 @@ function readProfile(value: unknown): Profile { const reportedTime = persistedReportedTime || (source === "legacy_import" ? time : ""); const knownPeriods = ["early_morning", "morning", "afternoon", "evening", "late_night"] as const; const period = knownPeriods.find((item) => item === profile.birth_time_period) ?? ""; - const knownStatuses = ["reported", "assessing", "rectifying", "candidate", "confirmed"] as const; + const knownStatuses = ["reported", "assessing", "rectifying", "candidate", "accepted", "confirmed"] as const; const status = knownStatuses.find((item) => item === profile.birth_time_status) ?? (time ? "confirmed" : ""); const provinceCode = typeof profile.province_code === "string" ? profile.province_code : profile.provinceCode; @@ -924,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([]); @@ -946,11 +954,9 @@ export default function Home() { createBirthTimeConsultationConsentState, ); const [rectificationSessionId, setRectificationSessionId] = useState(null); - const [rectificationReturnSessionId, setRectificationReturnSessionId] = useState(null); const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState(null); const [rectificationLoading, setRectificationLoading] = useState(false); const [rectificationMutationPending, setRectificationMutationPending] = useState(false); - const [rectificationContinuationPending, setRectificationContinuationPending] = useState(false); const [rectificationError, setRectificationError] = useState(""); const [hydrated, setHydrated] = useState(false); const [profileSaving, setProfileSaving] = useState(false); @@ -990,7 +996,6 @@ export default function Home() { const rectificationQuestionHandoff = useRef(createRectificationQuestionHandoffCoordinator()); const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined); const rectificationOpenInFlight = useRef(false); - const rectificationContinuationInFlight = useRef(false); const uiPreview = useRef(false); const uiPreviewMode = useRef(null); const birthTimeRevisionPending = useRef(false); @@ -1016,14 +1021,13 @@ export default function Home() { || cancellationPending || creatingSession || rectificationMutationPending - || rectificationContinuationPending || !account || !modelCatalog; const activeStreamingText = streamingReply && streamingReply.sessionId === activeSession?.id ? streamingReply.text : ""; const accountId = account?.user.id; const rectificationCardAction = resolveRectificationCardAction({ rectificationCase: account?.rectificationCase ?? null, - hasConfirmedBirthTime: account?.hasConfirmedBirthTime ?? false, + hasUsableBirthTime: account?.hasUsableBirthTime ?? false, }); const rectificationCardLabel = rectificationCardLabels[rectificationCardAction]; const onboardingFingerprint = onboardingProfileFingerprint(profile); @@ -1041,8 +1045,7 @@ export default function Home() { || activeSession.id === rectificationSessionId || rectificationLoading || rectificationMutationPending - || rectificationContinuationPending - || creatingSession + || creatingSession || rectificationError) return; resumeRectificationSession.current(activeSession); }, [ @@ -1051,7 +1054,6 @@ export default function Home() { creatingSession, hydrated, modelCatalog, - rectificationContinuationPending, rectificationError, rectificationLoading, rectificationMutationPending, @@ -1072,20 +1074,6 @@ export default function Home() { localStorage.setItem(`${prefix}archived`, JSON.stringify(archivedSessionIds)); }, [accountId, archivedSessionIds, hydrated, pinnedSessionIds]); - useEffect(() => { - if (!sessionMenuId) return; - function closeSessionMenu(event: Event) { - if (event instanceof globalThis.KeyboardEvent && event.key !== "Escape") return; - if (event instanceof MouseEvent && (event.target as Element | null)?.closest(".session-row")) return; - setSessionMenuId(null); - } - window.addEventListener("mousedown", closeSessionMenu); - window.addEventListener("keydown", closeSessionMenu); - return () => { - window.removeEventListener("mousedown", closeSessionMenu); - window.removeEventListener("keydown", closeSessionMenu); - }; - }, [sessionMenuId]); const activeSuggestions = activeSession?.messages.reduce( (latest, message) => message.role === "assistant" && message.suggestions?.length ? message.suggestions : latest, [], @@ -1239,8 +1227,10 @@ 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", rectificationCase: null, profile: previewProfile, }); @@ -1416,9 +1406,10 @@ export default function Home() { }, [hydrated, profile, profileComplete]); useEffect(() => { + if (starterHomeVisible) return; const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; conversationEnd.current?.scrollIntoView({ behavior: isLoading || reduceMotion ? "auto" : "smooth", block: "end" }); - }, [activeSessionId, activeSession?.messages.length, activeStreamingText, isLoading, onboardingPending, onboardingStep, presetMessageFinished, profileComplete]); + }, [activeSessionId, activeSession?.messages.length, activeStreamingText, isLoading, onboardingPending, onboardingStep, presetMessageFinished, profileComplete, starterHomeVisible]); useEffect(() => { if (hydrated && accountId && !profileComplete && onboardingStep === "name" && presetMessageFinished && activeAccountDialog === null) { @@ -1453,13 +1444,16 @@ export default function Home() { try { const latest = await fetchAccount(); if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return; + const nextProfile = readProfile(latest.profile); + setProfile(nextProfile); setAccount((current) => { if (current?.rectificationCase && latest.rectificationCase?.caseId === current.rectificationCase.caseId && latest.rectificationCase.turnVersion < current.rectificationCase.turnVersion) { return { ...latest, - hasConfirmedBirthTime: latest.hasConfirmedBirthTime || current.hasConfirmedBirthTime, + hasConfirmedBirthTime: latest.hasConfirmedBirthTime, + hasUsableBirthTime: latest.hasUsableBirthTime, rectificationCase: current.rectificationCase, }; } @@ -1669,6 +1663,10 @@ export default function Home() { case "redeem": setRedeemError(""); setRedeemMessage(""); + setPaymentEnabled(false); + setPaymentPackages([]); + setPaymentOrder(null); + setPaymentError(""); break; case "logout": break; @@ -1824,6 +1822,7 @@ export default function Home() { await persistProfile(profileDraft); setProfile(profileDraft); setProfileDraft(profileDraft); + setRectificationError(""); if (declarationChanged) { setBirthTimeConsultationConsent(createBirthTimeConsultationConsentState()); setAccount((current) => current ? { ...current, rectificationCase: null } : current); @@ -1941,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(); @@ -2032,7 +2072,15 @@ export default function Home() { sourceSessionOverride: ChatSession | null = null, ) { if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationOpenInFlight.current - || rectificationMutationPending || rectificationContinuationInFlight.current) return; + || rectificationMutationPending) return; + const missingStep = missingProfileStep(profile); + if (missingStep) { + setRectificationSessionId(null); + setRectificationPendingQuestion(null); + setOnboardingStep(missingStep); + setComposerNotice("请先完成出生资料,再开始生时校正。"); + return; + } const sourceSession = sourceSessionOverride ?? activeSession; if (!sourceSession) return; const existing = sourceSession.sessionType === "birth_time_rectification" @@ -2044,25 +2092,24 @@ export default function Home() { rectificationOpenInFlight.current = true; setRectificationLoading(true); setRectificationError(""); - setRectificationPendingQuestion(requestedQuestion); - setDraft(""); - setDraftTheme(null); - setDraftEntrypoint(null); - if (sourceSession.id !== rectificationSession.id) setRectificationReturnSessionId(sourceSession.id); - setRectificationSessionId(rectificationSession.id); - activeSessionIdRef.current = rectificationSession.id; - setActiveSessionId(rectificationSession.id); try { if (!existing) { - setSessions((current) => [rectificationSession, ...current.filter((session) => session.id !== rectificationSession.id)]); await rectificationPersistence.current.enqueue( rectificationSession.id, () => persistSession(rectificationSession, "create"), ); + setSessions((current) => [rectificationSession, ...current.filter((session) => session.id !== rectificationSession.id)]); } + setRectificationPendingQuestion(requestedQuestion); + setDraft(""); + setDraftTheme(null); + setDraftEntrypoint(null); + setRectificationSessionId(rectificationSession.id); + activeSessionIdRef.current = rectificationSession.id; + setActiveSessionId(rectificationSession.id); } catch { - setComposerNotice("生时校正已打开,但会话列表暂时未同步到云端。"); + setComposerNotice("生时校正会话暂时无法创建,请稍后重试。"); } finally { rectificationOpenInFlight.current = false; setRectificationLoading(false); @@ -2073,6 +2120,30 @@ export default function Home() { void openBirthTimeRectification(null, session); }; + function handleRectificationProfileIncomplete() { + setRectificationError("profile_incomplete"); + setRectificationSessionId(null); + setRectificationPendingQuestion(null); + const missingStep = missingProfileStep(profile); + if (missingStep) { + setOnboardingStep(missingStep); + setComposerNotice("请先完成出生资料,再开始生时校正。"); + return; + } + openAccountDialog("profile"); + setProfileNotice("服务端未能读取完整出生资料,请重新确认并保存。"); + void refreshAccount(); + } + + function handleRectificationMessagesChange(messages: Message[]) { + if (!rectificationSessionId) return; + updateSession(rectificationSessionId, (session) => ({ + ...session, + messages, + updatedAt: timestamp(), + })); + } + async function draftSynastryQuestionFromChart(record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) { if (record.role !== "other") return; if (synastryPendingId) return; @@ -2585,96 +2656,6 @@ export default function Home() { } - async function continueRectificationOriginalQuestion(continuation: RectificationV4Continuation) { - const question = continuation.question; - if (rectificationContinuationInFlight.current || rectificationMutationPending - || rectificationLoading || !activeSession || !account) return; - if (account.credits <= 0) { - openAccountDialog("redeem", creditTrigger.current); - return; - } - if (rectificationQuestionHandoff.current.peek() - && !sessions.some((session) => session.id === rectificationQuestionHandoff.current.peek()?.sessionId)) { - rectificationQuestionHandoff.current.clear(); - } - const localHandoff = rectificationQuestionHandoff.current.peek(); - const returnSession = (localHandoff - ? sessions.find((session) => session.id === localHandoff.sessionId) - : null) - ?? (rectificationReturnSessionId - ? sessions.find((session) => session.id === rectificationReturnSessionId) - : null) - ?? sessions.find((session) => session.sessionType === "consultation") - ?? null; - if (!returnSession) { - setComposerNotice("没有找到原问题所在的会话,请从会话列表打开原问题后重试。"); - return; - } - - rectificationContinuationInFlight.current = true; - setRectificationContinuationPending(true); - setRectificationError(""); - try { - const durableClaim = await claimRectificationV4Handoff({ - caseId: continuation.caseId, - caseVersion: continuation.caseVersion, - question, - }); - if (durableClaim.status === "in_progress") { - setComposerNotice("原问题正在另一设备继续回答;完成后刷新即可查看,不会重复扣点。"); - return; - } - if (durableClaim.status === "consumed") { - activeSessionIdRef.current = returnSession.id; - setActiveSessionId(returnSession.id); - setRectificationPendingQuestion(null); - setComposerNotice("原问题已经继续回答,不会再次发送或扣点。"); - return; - } - if (durableClaim.status !== "claimed") { - setComposerNotice("原问题仍保留,请刷新校正状态后重试。"); - return; - } - const completed = await rectificationQuestionHandoff.current.continueOriginalQuestion( - question, - { sessionId: returnSession.id, theme: returnSession.theme }, - async (context) => { - activeSessionIdRef.current = context.sessionId; - setActiveSessionId(context.sessionId); - setBirthTimeConsultationConsent((current) => clearBirthTimeConsultationConsent( - current, - context.sessionId, - )); - return send( - context.question, - context.theme, - null, - null, - context.sessionId, - { - protocol: "rectification-evidence-v4", - caseId: durableClaim.caseId, - caseVersion: durableClaim.caseVersion, - claimActionId: durableClaim.claimActionId, - requestId: durableClaim.requestId, - }, - ); - }, - ); - if (completed) { - setRectificationPendingQuestion(null); - setComposerNotice("已按候选范围边界继续回答原问题。"); - } else { - setComposerNotice("原问题仍保留,可再次点击继续回答。"); - } - } catch { - setComposerNotice("原问题仍保留,可再次点击继续回答。"); - } finally { - rectificationContinuationInFlight.current = false; - setRectificationContinuationPending(false); - } - } - useGSAP(() => { if (!starterHomeVisible || !starterWorkbench.current) return; const motion = gsap.matchMedia(); @@ -2718,6 +2699,7 @@ export default function Home() { } function handleComposerKeyDown(event: KeyboardEvent) { + if (event.nativeEvent.isComposing) return; if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); event.currentTarget.form?.requestSubmit(); @@ -2752,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() || "你", @@ -2835,10 +2818,17 @@ export default function Home() { ? "正在校正出生时间" : personalChartAvailable ? "基于星盘证据回答" : "回答一般占星知识"} - +
+ {account.isAdmin && account.adminUrl ? ( + +
{!rectificationSurfaceOpen && ( @@ -3022,7 +3012,7 @@ export default function Home() { {chatMessageViews(activeSession.messages, isLoading, activeStreamingText).map((message) => ( ))} - {activeError &&

{activeError}

} + {activeError &&

{activeError}

}
)} @@ -3031,13 +3021,18 @@ export default function Home() { {rectificationSurfaceOpen && ( void selectSessionModel(modelId)} + onMessagesChange={handleRectificationMessagesChange} + onCompleted={() => void refreshAccount()} pendingConsultationQuestion={rectificationPendingQuestion} - continuationPending={rectificationContinuationPending} onPendingChange={setRectificationMutationPending} - onContinueOriginalQuestion={(continuation) => void continueRectificationOriginalQuestion(continuation)} + onProfileIncomplete={handleRectificationProfileIncomplete} + onSaved={() => void refreshAccount()} /> )} @@ -3222,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 new file mode 100644 index 00000000..045daa24 --- /dev/null +++ b/frontend/src/components/admin/admin-app.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { + ArrowLeftOutlined, + AuditOutlined, + CreditCardOutlined, + GiftOutlined, + ShoppingOutlined, + MessageOutlined, + TeamOutlined, + TransactionOutlined, +} from "@ant-design/icons"; +import { Authenticated, Refine } from "@refinedev/core"; +import { ErrorComponent, ThemedLayout, ThemedSider, useNotificationProvider } from "@refinedev/antd"; +import routerProvider from "@refinedev/nextjs-router"; +import { App as AntdApp, ConfigProvider, Menu, Spin, theme } from "antd"; +import Link from "next/link"; +import type { ReactNode } from "react"; + +import { + adminAccessControlProvider, + adminAuthProvider, + 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 后台" }, + }} + > + 正在验证后台权限
} + > + {children} + + + + + + ); +} + +export { ErrorComponent as AdminErrorComponent }; diff --git a/frontend/src/components/admin/audit-logs-resource.tsx b/frontend/src/components/admin/audit-logs-resource.tsx new file mode 100644 index 00000000..0b7479ad --- /dev/null +++ b/frontend/src/components/admin/audit-logs-resource.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { Descriptions, Tag, type TableColumnsType } from "antd"; + +import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table"; + +type AuditRecord = { + id: string; + actorEmail: string; + actorRole: string; + action: string; + targetId: string; + before: Record | null; + after: Record | null; + requestId: string; + createdAt: string; +}; + +const columns: TableColumnsType = [ + { title: "操作者", dataIndex: "actorEmail", sorter: true }, + { title: "角色", dataIndex: "actorRole", render: (value) => {value} }, + { title: "动作", dataIndex: "action", sorter: true }, + { title: "目标 ID", dataIndex: "targetId" }, + { title: "Request ID", dataIndex: "requestId" }, + { title: "时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate }, +]; + +export default function AuditLogsPage() { + return + resource="audit-logs" + title="审计日志(只读)" + columns={columns} + statusOptions={[ + { label: "生成兑换码", value: "redemption_code.create" }, + { label: "修改兑换码", value: "redemption_code.update" }, + { label: "撤销兑换码", value: "redemption_code.revoke" }, + ]} + extra={} + />; +} diff --git a/frontend/src/components/admin/codes-resource.tsx b/frontend/src/components/admin/codes-resource.tsx new file mode 100644 index 00000000..28e14515 --- /dev/null +++ b/frontend/src/components/admin/codes-resource.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useCreate, useDelete, useGetIdentity, usePermissions, useUpdate } from "@refinedev/core"; +import { Button, DatePicker, Form, Input, InputNumber, Modal, Space, Tag, Typography, type TableColumnsType } from "antd"; +import dayjs from "dayjs"; +import { useState } from "react"; + +import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table"; +import type { AdminIdentity } from "@/lib/admin/providers"; + +type CodeRecord = { + id: string; + code?: string; + mask: string; + credits: number; + expiresAt: string | null; + note: string | null; + createdAt: string; + redeemedEmail: string | null; + redeemedAt: string | null; + revokedAt: string | null; + status: "available" | "expired" | "redeemed" | "revoked"; +}; + +type CreateValues = { + credits: number; + count: number; + expiresAt?: ReturnType; + note?: string; +}; +type EditValues = { note?: string; expiresAt?: ReturnType | null }; + +const statusColors: Record = { + available: "green", + expired: "orange", + redeemed: "blue", + revoked: "red", +}; + +export default function CodesPage() { + 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(); + const { mutate: revokeCode, mutation: revokeMutation } = useDelete(); + const [createOpen, setCreateOpen] = useState(false); + const [editRecord, setEditRecord] = useState(null); + const [generated, setGenerated] = useState([]); + const [createForm] = Form.useForm(); + const [editForm] = Form.useForm(); + const writable = role === "admin"; + + function submitCreate(values: CreateValues) { + createCodes({ + resource: "codes", + values: { + credits: values.credits, + count: values.count, + expiresAt: values.expiresAt?.toISOString() ?? null, + note: values.note?.trim() || null, + }, + successNotification: false, + }, { + onSuccess(result) { + setGenerated(result.data.generated); + setCreateOpen(false); + createForm.resetFields(); + }, + }); + } + + function submitEdit(values: EditValues) { + if (!editRecord) return; + updateCode({ + resource: "codes", + id: editRecord.id, + values: { + note: values.note?.trim() || null, + expiresAt: values.expiresAt?.toISOString() ?? null, + }, + }, { onSuccess: () => setEditRecord(null) }); + } + + function confirmRevoke(record: CodeRecord) { + Modal.confirm({ + title: "撤销此兑换码?", + content: `${record.mask} 撤销后不可兑换,且不能恢复。`, + okText: "确认撤销", + okButtonProps: { danger: true }, + cancelText: "取消", + onOk: () => new Promise((resolve, reject) => { + revokeCode({ resource: "codes", id: record.id }, { + onSuccess: () => resolve(), + onError: () => reject(new Error("撤销失败")), + }); + }), + }); + } + + const columns: TableColumnsType = [ + { title: "兑换码", dataIndex: "mask" }, + { title: "点数", dataIndex: "credits", sorter: true }, + { title: "状态", dataIndex: "status", sorter: true, render: (value) => {value} }, + { title: "到期时间", dataIndex: "expiresAt", sorter: true, render: formatAdminDate }, + { title: "备注", dataIndex: "note", render: (value) => value || "—" }, + { title: "兑换账户", dataIndex: "redeemedEmail", render: (value) => value || "—" }, + { title: "兑换时间", dataIndex: "redeemedAt", render: formatAdminDate }, + { title: "撤销时间", dataIndex: "revokedAt", render: formatAdminDate }, + { title: "创建时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate }, + { + title: "操作", + fixed: "right", + render: (_, record) => writable && record.status !== "redeemed" && record.status !== "revoked" ? ( + + + + + ) : "—", + }, + ]; + + return ( + <> + + resource="codes" + title={`兑换码${identity ? ` · ${identity.email} (${identity.role})` : ""}`} + columns={columns} + statusOptions={[ + { label: "可用", value: "available" }, + { label: "已过期", value: "expired" }, + { label: "已兑换", value: "redeemed" }, + { label: "已撤销", value: "revoked" }, + ]} + extra={writable ? : null} + /> + + setCreateOpen(false)} footer={null} destroyOnHidden> +
+ + + + + +
+
+ + 0} onCancel={() => setGenerated([])} footer={}> + 关闭后无法再次查看完整兑换码,请立即安全保存。 + {generated.map((record) => {record.code})} + + + setEditRecord(null)} footer={null} destroyOnHidden> +
+ + + +
+
+ + ); +} diff --git a/frontend/src/components/admin/consultations-resource.tsx b/frontend/src/components/admin/consultations-resource.tsx new file mode 100644 index 00000000..fda31052 --- /dev/null +++ b/frontend/src/components/admin/consultations-resource.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { Tag, type TableColumnsType } from "antd"; + +import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table"; + +type ConsultationRecord = { + id: string; + email: string | null; + requestId: string; + status: string; + createdAt: string; + updatedAt: string; +}; + +const colors: Record = { + reserved: "gold", + completed: "green", + cancelled: "default", +}; +const columns: TableColumnsType = [ + { title: "用户", dataIndex: "email", render: (value) => value || "—" }, + { title: "请求 ID", dataIndex: "requestId" }, + { title: "状态", dataIndex: "status", sorter: true, render: (value) => {value} }, + { title: "创建时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate }, + { title: "更新时间", dataIndex: "updatedAt", sorter: true, render: formatAdminDate }, +]; + +export default function ConsultationsPage() { + return + resource="consultations" + title="咨询请求(只读)" + columns={columns} + statusOptions={[ + { label: "已预扣", value: "reserved" }, + { label: "已完成", value: "completed" }, + { label: "已取消", value: "cancelled" }, + ]} + />; +} diff --git a/frontend/src/components/admin/credit-transactions-resource.tsx b/frontend/src/components/admin/credit-transactions-resource.tsx new file mode 100644 index 00000000..4e1ebff7 --- /dev/null +++ b/frontend/src/components/admin/credit-transactions-resource.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { Tag, type TableColumnsType } from "antd"; + +import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table"; + +type TransactionRecord = { + id: string; + email: string | null; + type: string; + amount: number; + balanceAfter: number; + requestId: string; + model: string | null; + inputTokens: number | null; + outputTokens: number | null; + createdAt: string; +}; + +const columns: TableColumnsType = [ + { title: "用户", dataIndex: "email", render: (value) => value || "—" }, + { title: "类型", dataIndex: "type", sorter: true, render: (value) => {value} }, + { title: "变动", dataIndex: "amount", sorter: true, render: (value) => value > 0 ? `+${value}` : value }, + { title: "余额", dataIndex: "balanceAfter", sorter: true }, + { title: "请求 ID", dataIndex: "requestId" }, + { title: "模型", dataIndex: "model", render: (value) => value || "—" }, + { title: "输入/输出 token", render: (_, row) => `${row.inputTokens ?? "—"} / ${row.outputTokens ?? "—"}` }, + { title: "时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate }, +]; + +export default function CreditTransactionsPage() { + return + resource="credit-transactions" + title="积分流水(只读)" + columns={columns} + statusOptions={[ + { label: "兑换", value: "redeem" }, + { label: "预扣", value: "reserve" }, + { label: "退款", value: "refund" }, + ]} + />; +} 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); }}> + + + + + )} +
+ {error && } + + {...tableProps} + columns={columns} + rowKey="id" + locale={{ emptyText: }} + scroll={{ x: "max-content" }} + /> +
+
+ ); +} + +export function formatAdminDate(value: string | null | undefined) { + return value ? new Intl.DateTimeFormat("zh-CN", { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)) : "—"; +} diff --git a/frontend/src/components/admin/users-resource.tsx b/frontend/src/components/admin/users-resource.tsx new file mode 100644 index 00000000..a4e93281 --- /dev/null +++ b/frontend/src/components/admin/users-resource.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { Tag, type TableColumnsType } from "antd"; + +import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table"; + +type UserRecord = { + id: string; + email: string; + name: string | null; + role: string; + emailVerified: boolean; + banned: boolean; + createdAt: string; + credits: number; + birthDate: string | null; + birthTimeStatus: string | null; + birthPlace: string | null; +}; + +const columns: TableColumnsType = [ + { title: "邮箱", dataIndex: "email", sorter: true }, + { title: "姓名", dataIndex: "name", sorter: true, render: (value) => value || "—" }, + { title: "角色", dataIndex: "role", render: (value) => {value} }, + { title: "积分", dataIndex: "credits", sorter: true }, + { title: "出生日期", dataIndex: "birthDate", render: (value) => value || "—" }, + { title: "出生时间状态", dataIndex: "birthTimeStatus", render: (value) => value || "—" }, + { title: "出生地", dataIndex: "birthPlace", render: (value) => value || "—" }, + { title: "邮箱验证", dataIndex: "emailVerified", render: (value) => value ? "已验证" : "未验证" }, + { title: "状态", dataIndex: "banned", render: (value) => value ? 已禁用 : 正常 }, + { title: "注册时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate }, +]; + +export default function UsersPage() { + return resource="users" title="用户资料(只读)" columns={columns} />; +} diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 6a4a2516..b3d5f902 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -1,11 +1,9 @@ "use client"; -import { Popover } from "@base-ui/react/popover"; -import Link from "next/link"; +import { Menu } from "@base-ui/react/menu"; import { ChevronRight, Gift, - KeyRound, LogOut, MessageSquareText, Plus, @@ -37,7 +35,6 @@ export type SidebarAccount = { name: string; email: string; credits: number; - isAdmin: boolean; initial: string; }; @@ -79,15 +76,15 @@ export function AppSidebar({ const historyHeadingRef = useRef(null); const isCollapsedDesktop = state === "collapsed" && !isMobile; const showExpandedContent = !isCollapsedDesktop; - const popoverPlacement = `${viewport}:${state}`; - const previousPopoverPlacement = useRef(popoverPlacement); + const menuPlacement = `${viewport}:${state}`; + const previousMenuPlacement = useRef(menuPlacement); useEffect(() => { - if (previousPopoverPlacement.current !== popoverPlacement && accountMenuOpen) { + if (previousMenuPlacement.current !== menuPlacement && accountMenuOpen) { onAccountMenuOpenChange(false); } - previousPopoverPlacement.current = popoverPlacement; - }, [accountMenuOpen, onAccountMenuOpenChange, popoverPlacement]); + previousMenuPlacement.current = menuPlacement; + }, [accountMenuOpen, menuPlacement, onAccountMenuOpenChange]); function handleNewChat() { onNewChat(); @@ -101,11 +98,6 @@ export function AppSidebar({ }); } - function handleAccountAction(action: () => void) { - onAccountMenuOpenChange(false); - action(); - } - return ( @@ -185,46 +177,42 @@ export function AppSidebar({ - - + {showExpandedContent ? {account.name} : null} {showExpandedContent ? - - + + - +
{account.name}{account.email}
- - - {account.isAdmin && onAccountMenuOpenChange(false)}> -