diff --git a/.github/workflows/backend-quality-gate.yml b/.github/workflows/backend-quality-gate.yml index da169138..c3de0aec 100644 --- a/.github/workflows/backend-quality-gate.yml +++ b/.github/workflows/backend-quality-gate.yml @@ -49,6 +49,8 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install -r requirements.txt -r requirements-dev.txt + python -m pip install playwright + python -m playwright install --with-deps chrome npm ci --prefix frontend - name: Run Python quick quality gate 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..ced21830 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 new file mode 100644 index 00000000..22905f0e --- /dev/null +++ b/BLOCKED.md @@ -0,0 +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/deploy/README.md b/deploy/README.md index c14ce6c3..fc9142b9 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -193,6 +193,8 @@ After source sync and before `up`, the workflow validates `.env.staging` mode/se 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. +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. Inspect staging without printing secrets: diff --git a/deploy/configure-staging-rectification-rollout.sh b/deploy/configure-staging-rectification-rollout.sh new file mode 100755 index 00000000..4edfbf9b --- /dev/null +++ b/deploy/configure-staging-rectification-rollout.sh @@ -0,0 +1,167 @@ +#!/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" ' +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 +} +{ + 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 ADMIN_SITE_ADDRESS='https://admin.staging.jyotisha.chat' +export GITHUB_SHA="$EXPECTED_DEPLOY_SHA" +compose=(docker compose -p jyotisha-staging --env-file .env.staging "${compose_files[@]}") + +"${compose[@]}" config --quiet +"${compose[@]}" up -d --no-build --pull never --force-recreate --no-deps web rectification-v4-worker + +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.staging.yml b/deploy/docker-compose.staging.yml index aab684bb..7619dcd2 100644 --- a/deploy/docker-compose.staging.yml +++ b/deploy/docker-compose.staging.yml @@ -4,5 +4,24 @@ services: - default - app + rectification-v4-worker: + image: ${WEB_IMAGE:-jyotisha-web:local} + restart: unless-stopped + env_file: + - ${APP_ENV_FILE:-../.env.staging} + environment: + GITHUB_SHA: ${GITHUB_SHA} + JYOTISH_API_BASE: http://api:5200 + working_dir: /app/frontend + command: ["npm", "run", "worker:rectification-v4"] + depends_on: + api: + condition: service_healthy + postgres: + condition: service_healthy + networks: + - default + - app + networks: app: 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 056a8898..931ad5d7 100755 --- a/deploy/run-staging-deploy.sh +++ b/deploy/run-staging-deploy.sh @@ -158,7 +158,8 @@ rollback() { echo "staging verification failed; restoring prior application images" >&2 API_IMAGE="$previous_api_target" WEB_IMAGE="$previous_web_target" \ GITHUB_SHA="$current_sha" \ - "${compose[@]}" up -d --no-build --remove-orphans api web caddy || true + "${compose[@]}" up -d --no-build --remove-orphans \ + api web rectification-v4-worker caddy || true fi exit "$status" } @@ -181,6 +182,7 @@ verify_container_image() { } verify_container_image api "$API_IMAGE" verify_container_image web "$WEB_IMAGE" +verify_container_image rectification-v4-worker "$WEB_IMAGE" "${compose[@]}" exec -T \ -e EXPECTED_SHA="$DEPLOY_SHA" -e STAGING_URL="$STAGING_URL" \ diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 44465744..329fcca0 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -186,21 +186,21 @@ - 复发自:无 - 修复版本:待提交(本地可测) -## BUG-010 | 浏览器直连 Supabase 写会话泄露 `TypeError: Load failed` +## BUG-010 | 浏览器直连 Supabase 导致自托管 PostgreSQL staging 误报未配置 - 状态:resolved - 首次发现:2026-07-22 -- 最近更新:2026-07-22 -- 影响面:回答完成后的聊天记录持久化、移动 Safari 错误提示 -- 用户现象:回答已经生成,但页面反复显示“云端同步失败:TypeError: Load failed”,并要求复制保存后重试。 -- 触发条件:浏览器直接向 Supabase `chat_sessions` 发起跨域写入时发生传输失败。 -- 根因:会话读取和多数业务写入已经使用同源 Next.js API,但会话创建与更新仍由浏览器客户端直写 Supabase;异常原文又被拼进回答错误区域。 -- 修复:新增同源 `POST /api/sessions` 与 `PATCH /api/sessions/[id]`,服务端校验登录、所有权和写入负载;客户端对可重试失败短重试一次,并把最终失败降级为输入区状态提示,不再把浏览器异常原文渲染成回答错误。 -- 验证:`frontend/tests/chat-session-write.test.ts` 覆盖同源路由、所有者约束、短重试和 `Load failed` 脱敏;相关咨询与资料回归测试通过。 -- 防复发:会话写入契约禁止页面直接调用 `supabase.from("chat_sessions")`;网络异常必须映射为稳定用户文案。 +- 最近更新:2026-07-27 +- 影响面:聊天首页启动、会话模型选择、退出登录;自托管 PostgreSQL staging。 +- 用户现象:数据库和身份服务正常,首页却显示“Supabase 尚未配置”。 +- 触发条件:`AUTH_PROVIDER=self-hosted` 且不提供浏览器 Supabase 公钥。 +- 根因:页面启动、会话模型更新和退出登录仍直接创建浏览器 Supabase client;仅服务端 API 已切换到同源 PostgreSQL 路径。 +- 修复:`/api/account` 返回当前用户完整 profile;首页通过 `/api/account`、`/api/sessions` 和 `/api/sessions/:id` 读写,退出使用 self-hosted identity client。 +- 验证:`frontend/tests/chat-session-write.test.ts`、`frontend/tests/session-model-persistence.test.ts`、`frontend/tests/account-api.test.ts`、ESLint、`next build`。 +- 防复发:首页不得引用 `createBrowserSupabaseClient`;会话与 profile 只能经同源 API 访问。 - 相关记录:BUG-001、BUG-003 -- 复发自:无 -- 修复版本:待提交(本地可测) +- 复发自:BUG-010 +- 修复版本:待发布 staging ## BUG-011 | 对话消息暴露内部证据审计状态 @@ -1504,82 +1504,77 @@ - 相关记录:BUG-075、BUG-080 - 修复版本:本次修复提交 -## BUG-082 | Gitea 工作流混用 runner、外部 Actions 与重复 staging 触发 +## BUG-082 | 生时校正缺少独立证据状态机并把分钟峰值误导为产品答案 - 状态:resolved -- 首次发现:2026-07-27 -- 最近更新:2026-07-27 -- 影响面:Gitea CI、测试镜像发布、测试服务器部署、手动回滚与数据库迁移 -- 用户现象:部分 Gitea workflow 仍使用 `ubuntu-latest`、GitHub Actions checkout/setup 和旧 Gitea Registry;`ci.yml` 与 staging 质量门禁同时监听 staging push,可能重复测试,而手动部署/迁移又把服务器上一版本硬编码成 `not-deployed`。 -- 触发条件:向 staging 推送已合入 main 的 SHA,或手动执行测试环境重部署、回滚和迁移。 -- 根因:工作流从 GitHub 与旧 Gitea Registry 迁移时只改造了自动 staging 主链,没有建立覆盖 `.gitea/workflows/*.yml` 的统一 runner、checkout、触发器、镜像仓库和前序部署状态审计。 -- 修复:全部 job 统一使用 `xiaoxin`,以原生 bash、git fetch 和 detached checkout 取代外部 Actions,并显式检查 runner 的 Python、Node、npm、Docker。仅 `backend-quality-gate.yml` 保留 staging push 自动入口,校验通过后向现有 ACR 共享仓库发布 `api-SHA`/`web-SHA` 并按 digest 部署;其余发布、生产、迁移及回滚入口保持手动。手动 staging 部署和迁移改为校验 full SHA 属于 main、使用 ACR digest、读取服务器当前 SHA,并默认只允许前向变更。 -- 验证:PyYAML `safe_load` 覆盖全部 Gitea YAML;相关 staging shell 脚本以 LF 输入通过 `bash -n`;新增的 3 项 Gitea workflow 审计与 3 项 image manifest 回归通过;禁用项扫描确认不存在 `ubuntu-latest`、GitHub Actions URL 或旧 Registry secret 名。完整双文件命令在当前 Windows/WSL 环境仍被既有 GitHub workflow 文本断言和 Windows 路径传给 WSL bash 的兼容问题阻塞,未伪报全量通过。 -- 防复发:工作流审计测试枚举全部 `.gitea/workflows/*.yml`,逐 job 锁定 `runs-on: xiaoxin`、原生 checkout、唯一 staging push 所有者、manual-only 高风险入口、ACR digest 主链和禁止的旧平台/Registry 标识。 -- 相关记录:无 -- 复发自:无 +- 首次发现:2026-07-26 +- 最近更新:2026-07-26 +- 影响面:生时校正建案、事件采集、日期修订、候选评分、暂停恢复、范围保存、原咨询问题 handoff 与扣费幂等 +- 用户现象:专业 Agent 可以持续收集跨领域事件、逐步补充日期并做稳定性复核,但 Web 流程把对话、抽取、评分和叙事绑在单次请求中;容易重复追问同一事件、出现等待状态无下一题、在低置信度下突出单个峰值分钟,并且刷新或多设备继续时缺少独立业务真源。 +- 根因:旧 `conversational-evidence-v3` 同时承担聊天体验和校时状态机;人生事件没有稳定的 `eventId + revision` 修订链,问题没有持久化目标事件,分钟扫描结果也没有强制经过范围聚类、日期敏感性、逐项排除和邻近分钟门控。聊天会话、计算任务和咨询 handoff 的生命周期耦合,无法独立恢复和仲裁并发。 +- 修复:新增 `rectification-evidence-v4` 独立领域模型、Case/Turn/Event Revision/Snapshot/Job/Handoff 持久化状态机和后台 Worker。先完成教育、迁移、关系、事业、财务、健康压力、家庭七领域覆盖,再对非日精度事件按 `targetEventId` 追加同一事件 revision;已追问或跳过的事件不循环。候选分钟先聚类,再通过事件数、领域数、范围宽度、邻近分钟、leave-one-out、日期敏感性和计算口径 hash 门;公开合同固定 `canConfirmExactMinute=false`,只允许用户主动保存候选范围,不更新 `profiles.active_birth_time`。Handoff 和扣费由 PostgreSQL lease、幂等 action 与 settlement receipt 保护。 +- 验证:前端 V4/domain/service/migration/replay/handoff/consultation continuation 33 个测试通过;Python 2 个测试通过;目标 ESLint 与 Webpack 生产构建通过。隔离 PostgreSQL 12 项不变量通过;真实 PostgreSQL + Python E2E 完成七领域与定向日期修订后进入 `range_ready`,主要范围为 `05:26–05:30`,无空问题死状态,原 `active_birth_time` 不变且未主动保存前 `acceptedRange` 为空。静态浏览器验收确认日期修订输入框、范围保存按钮、非确认分钟文案和 390px 无横向溢出;真实登录态 UI 因隔离服务未连接认证配置,本轮未声称已验收。 +- 防复发:生时校正业务真源必须是 V4 Case 和 append-only 事件台账,不得从聊天正文反向恢复;任何公开结果只能显示通过稳定性门的范围,单分钟峰值不得成为可保存结果;证据不足必须生成下一题或明确暂停,不能进入 `awaiting_answer + currentQuestion=null`;V4 路径不得写 `profiles.active_birth_time`。 +- 相关记录:BUG-067、BUG-069、BUG-072、BUG-074、BUG-075、BUG-080、BUG-081 - 修复版本:待提交(本地可测) -## BUG-083 | Gitea xiaoxin Runner 向系统 Python 安装依赖触发 PEP 668 + +## BUG-083 | staging 质量门使用无可用 Linux sandbox 的浏览器运行时导致验收失败 - 状态:resolved - 首次发现:2026-07-27 - 最近更新:2026-07-27 -- 影响面:Gitea 后端质量门禁、CI、完整测试、发布质量门禁与 PyPI 发布 -- 用户现象:xiaoxin Runner 执行 `python3 -m pip install` 时以 `externally-managed-environment` 失败,工作流无法进入后续 Ruff、pytest、Playwright、构建或上传阶段。 -- 触发条件:基于启用 PEP 668 的系统 Python 运行任一包含 pip 安装的 `.gitea/workflows/*.yml`。 -- 根因:BUG-082 统一 Gitea Runner 与原生工具链时只审计了 runner、checkout、触发器和镜像主链,没有约束 Python 依赖隔离;五个工作流仍直接向 Runner 的系统 Python 执行 pip 安装,且 Gitea step 之间不会自动延续 shell 内的 PATH 修改。 -- 修复:所有包含 pip 安装的 Gitea workflow 都先创建仓库内 `.venv` 并在同一安装 step 显式将其 `bin` 目录置于 PATH;后续独立 Python step(包括后端验证与 PyPI 上传)再次显式设置同一 PATH。未使用 `--break-system-packages`,也未修改 GitHub workflows。 -- 验证:前端 Gitea workflow 契约测试枚举全部 `.gitea/workflows/*.yml`,确认五个 pip workflow 均创建并选择 `.venv`、后续 Python step 重新选择 `.venv`、不存在裸 `pip install` 或 `--break-system-packages`;全部 Gitea YAML 完成解析验证。 -- 防复发:工作流审计必须动态枚举全部 Gitea YAML;任何出现 pip install 的 workflow 都必须创建 `.venv`,任何随后执行 Python、Ruff、pytest、Playwright 或构建/上传命令的独立 step 都必须显式导出 venv PATH。 +- 影响面:生时校正 V4 的 390px 真实浏览器验收、staging 镜像发布与后续部署 +- 用户现象:PR 质量门已通过且相同提交的其余 1031 个测试成功,但推送到 `staging` 后唯一的真实 Chromium 测试未能取得 DevTools 端口,导致不可变镜像未发布、部署未继续。 +- 触发条件:`backend-quality-gate.yml` 未显式供应带系统 sandbox 的浏览器,或安装 Playwright 默认的 `chromium-headless-shell` 后直接用原始二进制启动。 +- 根因:真实浏览器测试刻意保留 Chromium sandbox,但 Playwright 默认下载的 `chromium-headless-shell` 是原始构建;当前 GitHub Linux Runner 无法为该原始二进制建立所需 sandbox,进程以 `SIGTRAP` 退出。仅依赖 Runner 偶然预装的 Chrome 也不具备可重复性。 +- 修复:通过 Playwright 官方 `chrome` 安装通道供应系统级 stable Chrome 及其依赖,再执行真实浏览器合同;避免选择无系统 sandbox 包装的 headless shell,不跳过验收,也不增加 `--no-sandbox`。 +- 验证:workflow 合同回归断言 Playwright stable Chrome 必须被安装;聚焦部署合同测试、完整前端测试、目标 ESLint、生产构建与 `git diff --check` 通过;staging 质量门和部署结果另行记录。 +- 防复发:保留浏览器 sandbox 的 CI 必须使用具备系统 sandbox 包装的浏览器安装通道,不能直接启动无相应宿主配置的原始 headless shell,也不能依赖 hosted runner 的偶然预装状态。 - 相关记录:BUG-082 -- 复发自:BUG-082 -- 修复版本:待提交(本地可测) +- 修复版本:本次修复提交 -## BUG-084 | Gitea Runner 构建镜像访问 Docker Hub 超时 +## BUG-084 | staging V4 Worker 与候选引擎位于不同 Docker 网络 - 状态:resolved -- 首次发现:2026-07-28 +- 首次发现:2026-07-27 +- 最近更新:2026-07-27 +- 影响面:生时校正 V4 后台评分、第三条及后续可评分事件、staging 登录态验收 +- 用户现象:Case 创建和前两条事件成功,第三条事件触发候选引擎后 Worker Job 失败并返回 `fetch failed`。 +- 触发条件:staging Worker 同时需要访问 PostgreSQL `app` 网络和 API 所在的 Compose `default` 网络。 +- 根因:`rectification-v4-worker` 只加入 `app` 网络;`api` 只在 `default` 网络。Worker 虽正确配置 `JYOTISH_API_BASE=http://api:5200`,但 Docker DNS 无法跨网络解析和连接 `api`。 +- 修复:让 staging Worker 同时加入 `default` 与 `app` 网络,不改变 production compose,也不暴露 API 端口。 +- 验证:部署合同测试固定 Worker 的内部 API 地址和双网络配置;聚焦测试、Compose 渲染、目标 ESLint、`git diff --check` 及 staging 登录态 smoke 另行记录。 +- 防复发:需要同时访问内部 API 与 staging PostgreSQL 的服务必须在 Compose 合同中显式加入两个现有私有网络。 +- 相关记录:BUG-082 +- 修复版本:本次修复提交 + +## BUG-085 | 生时校正回退为独立面板和固定领域问卷 + +- 状态:investigating +- 首次发现:2026-07-27 - 最近更新:2026-07-28 -- 影响面:Gitea staging 镜像构建与发布 -- 用户现象:镜像构建在解析 `python:3.12-slim` 基础镜像时访问 `registry-1.docker.io` 超时,退出码为 1;代码检出、镜像仓库登录均已成功。 -- 触发条件:xiaoxin Runner 构建 `deploy/railway-api.Dockerfile` 或 `deploy/railway-web.Dockerfile`,且到 Docker Hub 的 HTTPS 连接不可用或不稳定。 -- 根因:Railway API/Web Dockerfile 直接依赖 Docker Hub 官方 registry;本次失败发生在 Dockerfile 第 1 行的基础镜像 metadata 拉取阶段,不是应用代码、依赖安装或 ACR 登录失败。 -- 修复:将 API 的 Python 基础镜像和 Web 的 Node 基础镜像切换为可从当前网络稳定访问的阿里云公共镜像同步地址;增加 Dockerfile 契约断言,防止后续恢复为 Docker Hub 直连。 -- 验证:`tests/test_railway_deployment.py` 的静态契约已更新;当前本机 pytest 7.4.4 低于项目要求的 pytest 8.0,测试执行被项目配置门禁阻断,未伪报通过。 -- 防复发:镜像构建前必须验证基础镜像 registry 可达;统一基础镜像来源,不要在受限 Runner 上直接依赖 Docker Hub;若镜像同步源发生变更,应先更新契约和执行实际构建验证。 -- 相关记录:BUG-082、BUG-083 -- 复发自:无 -- 修复版本:待提交(本地可测) +- 影响面:生时校正聊天 Surface、事件语义、后台 Job、候选计算、诊断、Reasoner、Renderer 与持久化主链 +- 用户现象:进入生时校正后看到独立的校正面板、证据区域和固定问题;交互不像普通 session,领域也不再根据用户刚讲的经历动态选择。 +- 触发条件:旧 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-085 | 无严格领域路由时 full-reading 丢失解释源回退 +## BUG-086 | 模型下一问可绕过当前事件而跳成领域问卷 -- 状态:resolved -- 首次发现:2026-07-28 +- 状态:investigating +- 首次发现:2026-07-27 - 最近更新:2026-07-28 -- 影响面:`scripts/run_quality_gate.py` quick profile、`full-reading` AI prompt pack -- 用户现象:CORE_PYTEST_TARGETS 在 `tests/test_cli_smoke.py::test_full_reading_reports_ayanamsa_metadata_and_ai_prompt_pack` 失败,`interpretation_source_pack.core_rule_source_refs` 实际为空数组。 -- 触发条件:full-reading 结果没有可选为 primary 的 career、relationship 或 finance strict workflow contract。 -- 根因:存在两个 Windows 直接执行兼容问题:引擎只把 `scripts/` 而非仓库根加入导入路径,使 strict evidence 服务找不到根目录 `mcp_server.py`;修复该处后,同一测试又暴露 `NamedTemporaryFile` 在仍打开时交给子进程读取会触发 `PermissionError`。此外 prompt pack 只把已加载 fallback 用于 `domain_invocation_layers`,其他解释源字段仍只读取可能为空的 primary strict audit;继续执行 quick 集合还发现能力审计测试仍固定为旧的三条应用路由,未包含现有三个后台页面,以及 Shadbala 常量路径测试硬编码 POSIX 分隔符而在 Windows 失败。 -- 修复:直接执行引擎时显式加入仓库根路径;primary strict audit 缺失时从 `existing_interpretation_source_pack()` 回退解释源字段;oracle queue 改为在临时目录中写入已关闭的普通文件后再交给 validator 子进程;能力审计断言同步为当前六条应用路由;Shadbala 路径断言改为解析后比较末级路径组件。 -- 验证:目标失败测试通过;CORE_PYTEST_TARGETS quick pytest 集合全量通过(仅 1 条既有跳过及弃用警告)。 -- 防复发:无 primary strict route 的 full-reading 也必须保留仓库解释源清单;CLI smoke 继续锁定核心五源的精确顺序。 -- 相关记录:BUG-014、ERR-032 -- 复发自:无 -- 修复版本:待提交(本地可测) - -## BUG-086 | Gitea staging 发布步骤过长且瞬时故障需全量重跑 - -- 状态:resolved -- 首次发现:2026-07-28 -- 最近更新:2026-07-28 -- 影响面:Gitea staging 镜像构建、ACR 推送与测试服务部署 -- 用户现象:staging 自动部署先后在基础镜像、依赖下载及 Web 构建失败;Web 最终明确报错为 `frontend/src/app/page.tsx:3560` JSX 解析失败。 -- 触发条件:`staging` push 后,Runner 使用 `registry.cn-hangzhou.aliyuncs.com/library/python:3.12-slim` 或对应 Node 地址解析基础镜像。 -- 根因:阿里云 `library/*` 地址并非当前凭据可访问的公共镜像仓库,xiaoxin 直连 Docker Hub 超时;同时充值订单二维码条件渲染缺少右花括号,导致相邻 `payUrl` 条件 JSX 无法解析。 -- 修复:工作流保持单 job 构建发布部署链路;checkout 使用 depth-1 浅拉取和网络超时;API/Web 使用实测可达的国内基础镜像,API 使用阿里云 APT/PyPI;补齐二维码条件渲染的 `}`;服务器 deploy 目录由 root 管理,因此上传后的目录创建、控制文件解包及 Docker 操作均使用免交互 sudo。 -- 验证:run 1264 暴露 JSX 错误,本地修复后 `npm run build` 完整通过;run 1265 暴露普通 `tar` 权限错误;run 1266 暴露 sudo 清理 Compose 镜像环境。最终 Gitea run 1267(job 3795)checkout、双镜像构建/ACR 推送、测试服务器部署全部成功;服务器 API/Web 均运行 `a7ff511f5520c5e2369e91ee3934989d34dedf5e` 且 healthy,外部 `/login=200`、未登录 `/api/account=401`、`/api/health=200`,健康响应部署 SHA 与目标一致。 -- 防复发:基础镜像必须在 xiaoxin 完整 pull 验证;APT/PyPI 基于 Runner 实测并设置有限超时;部署契约测试固定浅拉取、sudo 解包和 `sudo env` 镜像变量传递。 -- 相关记录:BUG-082、BUG-083、BUG-084 -- 复发自:BUG-082 -- 修复版本:`a7ff511`(Gitea staging,run 1267 已部署验证) +- 影响面:生时校正 V5 的当前事件延续、问题机会构建、诊断工具预算、模型决策验证和 Job replay +- 用户现象:用户回答“2016 年离家去外地上大学”后,下一问直接变成“请说一次影响较大的搬家或长期迁居”,看起来仍按“升学 → 搬家”模板轮询,而没有承接刚才的具体经历。 +- 触发条件:当前事件仍缺必要精度,但旧 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 验收 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/research/birth_time_rectification_v4_design_2026_07_26.md b/docs/research/birth_time_rectification_v4_design_2026_07_26.md new file mode 100644 index 00000000..468d6fa9 --- /dev/null +++ b/docs/research/birth_time_rectification_v4_design_2026_07_26.md @@ -0,0 +1,383 @@ +# 生时校正 V4 重构设计 — 2026-07-26 + +## 1. 结论 + +生时校正不应继续作为“聊天接口里顺便跑一次模型和分钟扫描”的功能维护。V4 将它重构为独立、可恢复、可审计的证据工作流: + +1. 先收集跨领域、带日期精度的人生事件; +2. 再对已经存在但日期较粗的事件做定向修订; +3. 后台 Worker 对冻结的计算口径和证据集合评分; +4. 只输出通过稳定性门槛的候选时间范围; +5. 用户主动保存后,才把该范围交给原咨询问题继续使用。 + +产品永久边界:**V4 不确认单分钟,不把峰值分钟显示为真实出生时间,不修改 `profiles.active_birth_time`。** + +本文记录已经落地到本地可测试工作树的目标架构,而不是对旧实现的小修补。 + +## 2. 交互记录暴露的旧架构问题 + +用户提供的 Agent 交互记录说明,专业校时需要的不只是一个聊天框,而是一套持续数十轮仍保持一致的证据系统。旧 Web 流程无法稳定复现该过程,主要原因不是文案不够像 Agent,而是职责没有拆开。 + +### 2.1 预设年份问卷替代了真实证据 + +旧流程先给出宽年份选项,再不断要求把同一事件从年份段缩到年份、季度、月份。这样会产生三个问题: + +- 问题本身暗示事件应该发生在哪个窗口,带来确认偏差; +- 用户已经给过事件后,系统仍可能把它当成下一条新事件; +- 为了进入评分而取区间中点,会制造不存在的日期精度。 + +V4 改为先接收用户自己声明的事件和日期,再把后续日期补充写成同一事件的新 revision。 + +### 2.2 对话、抽取、评分和结果表达在同一请求中耦合 + +旧流程把事件抽取、技术计算、叙事生成和下一题规划放在一个请求生命周期里。任一模型超时、跨语言 schema 漂移或计算耗时,都可能让已提交的经历看起来没有保存。 + +V4 的同步请求只负责持久化答案并创建 Job;Worker 异步完成抽取、评分、稳定性检查和下一题规划。用户提交成功后可以离开页面,稍后继续。 + +### 2.3 “第一名分钟”被误当成产品答案 + +分钟扫描必然会产生一个最高分,但最高分不等于真实出生分钟。旧交互即使同时声明低置信度,仍会把一个具体分钟写成“建议暂用时间”,视觉上压过候选范围和不确定性说明。 + +V4 将 `representativeTime` 限定为内部聚类数据。公开快照中的 `canConfirmExactMinute` 是字面量 `false`;UI 只显示 `startTime–endTime`,不显示峰值分钟。 + +### 2.4 没有稳定的事件身份和修订链 + +用户可能先说“某年发生”,后续补成“某年某月”,也可能纠正原先年份。覆盖旧记录会丢失审计信息;新建一条记录又会重复计分。 + +V4 使用 `eventId + revision`: + +- `eventId` 表示同一人生事件; +- 每次补充创建 append-only revision; +- `supersedesRevisionId` 指向上一版本; +- 评分只读取每个 `eventId` 的最新 revision。 + +### 2.5 会话状态不足以承担业务状态 + +浏览器会话可能被删除、刷新或从另一设备继续。旧流程把关键进度附着在聊天消息上,难以保证幂等、恢复、并发提交、扣费和原问题 handoff。 + +V4 将 Case、Turn、Event Revision、Snapshot、Job 和 Handoff 独立持久化;聊天会话只是入口和展示容器,不再是生时校正业务真源。 + +## 3. 产品合同 + +### 3.1 输入 + +- 已保存出生日期; +- 用户声明的候选时间和不确定范围; +- 出生地点坐标与时区; +- 固定计算口径:Lahiri、Mean Node、一分钟步长; +- 用户主动提供的人生事件。 + +### 3.2 证据领域 + +| 领域 | 典型事件 | 评分状态 | +| --- | --- | --- | +| `education` | 升学、复读、毕业、转学 | scoreable | +| `relocation` | 搬家、长期迁居、离乡 | scoreable | +| `relationship` | 重要关系开始或结束 | scoreable | +| `career` | 入职、离职、转行、职责突变 | scoreable | +| `finance` | 收入、负债、资产明显变化 | scoreable | +| `health_pressure` | 疾病、手术、事故、长期压力起点 | scoreable | +| `family` | 家庭结构或亲属重大事件 | context-only | +| `other` | 其他明确、重要、可核对事件 | 按领域能力决定 | + +家庭事件先保留为上下文,不应为了“凑够领域”接入没有可靠 scorer 的技术层。 + +### 3.3 输出 + +- 一个主要候选范围; +- 可选的次级候选范围; +- 支持该范围的事件; +- 冲突或区分力不足的事件; +- 稳定性门结果; +- 用户是否已主动保存该范围。 + +### 3.4 永久禁止 + +- 不确认单分钟; +- 不把 `representativeTime` 作为公开结论; +- 不自动保存候选范围; +- 不修改 `profiles.active_birth_time`; +- 不因聊天文案或模型失败丢弃已经持久化的答案; +- 不用区间中点伪造事件日期; +- 不把同一事件的日期补充重复计分。 + +## 4. 两阶段问题规划 + +### 4.1 阶段一:领域覆盖 + +按低回忆成本优先收集七个领域。每次只问一个开放问题,要求用户自己提供事件与尽可能准确的年月,不先展示系统猜测的年份窗口。 + +当前确定性顺序为: + +```text +education → relocation → relationship → career → finance → health_pressure → family +``` + +规划器已经保留 `candidateSplitByDomain` 输入;未来只有当评分引擎能给出可解释的领域区分力时,才允许在不增加模型自由度的前提下动态排序。 + +### 4.2 阶段二:日期精度修订 + +完成领域覆盖后,对最新 revision 仍不是 `day` 精度的 scoreable 事件定向追问: + +- 问题保存 `targetEventId`; +- Turn 保存 `questionTargetEventId`; +- Worker 用该 ID 将回答追加到原事件; +- 如果回答没有可解析日期,不创建伪 revision; +- 已经追问过或用户跳过的事件写入尝试集合,不循环追问。 + +如果所有可修订事件都已处理但仍未通过稳定性门,则询问新的、日期明确的重要事件;用户可以暂停或结束。 + +## 5. 状态机 + +### 5.1 Case 状态 + +```mermaid +stateDiagram-v2 + [*] --> awaiting_answer: create case + awaiting_answer --> processing: submit answer + processing --> awaiting_answer: worker plans next question + processing --> range_ready: range passes gate + awaiting_answer --> paused: pause + paused --> awaiting_answer: resume + awaiting_answer --> abandoned: abandon + range_ready --> abandoned: abandon without save + range_ready --> range_ready: explicitly save accepted range +``` + +| 状态 | 含义 | `currentQuestion` | +| --- | --- | --- | +| `awaiting_answer` | 等待用户回答 | 必须非空 | +| `processing` | 答案已保存,后台处理中 | 空 | +| `range_ready` | 有候选范围;可能尚未保存 | 可空;稳定范围就绪时为空 | +| `paused` | 用户主动暂停 | 保留可恢复进度 | +| `abandoned` | 用户结束本次校正 | 空 | + +关键不变量:证据不足时必须生成下一题,不能出现 `status=awaiting_answer` 且 `currentQuestion=null`。 + +### 5.2 Worker 阶段 + +```text +extracting_evidence + → scoring_candidates + → checking_robustness + → planning_question + → collecting_evidence | complete +``` + +Case 状态描述用户能做什么,Phase 描述系统正在做什么。两者不能混用。 + +### 5.3 Job 状态 + +```text +pending → processing → completed + └→ failed +pending/expired processing → processing by another worker +obsolete input → stale +``` + +Job 使用十分钟 lease。只有持有有效 lease 且输入 case version、证据 hash、计算口径 hash 仍匹配的 Worker 可以完成任务。 + +### 5.4 Handoff 状态 + +```text +pending → claimed → executing → consumed +``` + +过期的 `claimed/executing` lease 可恢复为 `pending`。Handoff 使用 `requestId`、`claimActionId` 和 settlement receipt 保证多设备重试不会重复执行或重复扣费。 + +## 6. 数据模型 + +### 6.1 Case + +Case 冻结一次校正的业务口径: + +- `calculationSpec`; +- `calculationSpecHash`; +- `evidenceSetHash`; +- `version`; +- `currentQuestion`; +- `latestSnapshot`; +- `acceptedRange`。 + +`acceptedRange` 在用户点击“保存这个范围”前始终为 `null`。 + +### 6.2 Turn + +Turn 是用户回答的不可变输入记录: + +- 本轮问题 ID、领域和目标事件 ID; +- 可见问题文本; +- 用户原始回答; +- `actionId`; +- 提交时的 case version。 + +失败重试时可以恢复同一个问题,不需要从聊天文本反向猜测当时问了什么。 + +### 6.3 Event 与 Event Revision + +Event 提供稳定身份;Revision 保存领域、事件类型、摘要、原文、日期范围、精度和评分资格。 + +评分前执行 `latestEventRevisions()`,确保同一事件只使用最新版本。原始回答保留,技术评分不依赖经过润色的叙事文案。 + +### 6.4 Candidate Snapshot + +每次评分都生成不可变 Snapshot: + +- 输入证据 hash; +- 计算口径 hash; +- 算法版本; +- 全部分钟候选分数; +- 候选聚类; +- 稳定性结果; +- 决策门原因。 + +Snapshot 使刷新、复算和算法升级可审计,不用覆盖上一轮结果。 + +## 7. 候选聚类与稳定性门 + +### 7.1 聚类 + +当前算法取峰值分数的相对 `0.97` 以上候选,将相邻分钟合并为 cluster,并按峰值和 score mass 排序。 + +`representativeTime` 仅用于内部描述 cluster 峰值。公开结果使用 `startTime` 和 `endTime`。 + +### 7.2 可保存范围门槛 + +主要范围必须同时满足: + +- 至少 5 个 scoreable 事件; +- 至少 3 个 scoreable 领域; +- cluster 宽度至少 2 分钟,拒绝单分钟结果; +- cluster 宽度不超过 15 分钟; +- 邻近分钟支持至少 2 分钟; +- leave-one-out 保留率至少 0.8; +- 日期敏感性保留率至少 0.8; +- 计算口径 hash 与建案时一致。 + +无论是否通过,`canConfirmExactMinute` 永远为 `false`。 + +## 8. 请求与后台执行边界 + +### 8.1 同步 API 负责 + +- 认证和输入校验; +- 幂等 action; +- optimistic case version 检查; +- 保存 Turn; +- 创建 pending Job; +- 返回 `202` 和可轮询 Job。 + +### 8.2 Worker 负责 + +- 抽取新事件或定向修订; +- 计算 evidence hash; +- 调用 Python 候选引擎; +- 聚类与稳定性门; +- 生成 Snapshot; +- 规划下一题; +- 原子完成 Job 和 Case 状态迁移。 + +非关键叙事模型不在 V4 完成链路上。确定性问题和业务状态在模型不可用时仍可继续。 + +## 9. 数据库不变量 + +迁移 `20260726020000_birth_time_rectification_v4.sql` 将关键规则下沉到 PostgreSQL: + +1. 同一用户最多一个未结束、未接受范围的活动 Case; +2. `actionId` 幂等,同一 action 不能绑定不同问题; +3. `expectedCaseVersion` 不匹配时拒绝陈旧写入; +4. 未通过快照门或范围与最新主要 cluster 不一致时不能保存; +5. 有效 Worker lease 不能被抢占,过期 lease 可以接管; +6. Worker 只能完成仍匹配输入 hash 和 version 的 Job; +7. Handoff claim、begin、refund 和 settlement 可重试; +8. 一次 Handoff 最多产生一次成功扣费; +9. 取消的请求可以重新生成 request key; +10. 整个 V4 migration 不更新 `profiles.active_birth_time`。 + +## 10. UI 设计 + +### 10.1 首屏 + +必须同时告诉用户: + +- 正在比较的声明候选边界; +- 当前流程先核对经历; +- 结果只会是候选范围,不是已确认分钟; +- 原咨询问题已保留。 + +### 10.2 处理中 + +提交后立即显示“回答已经保存,计算在后台继续”。允许用户离开页面,不用让浏览器请求一直等待。 + +### 10.3 日期修订 + +日期修订问题必须有可见 label 和输入框,不得只显示空卡片。问题明确允许“不记得/跳过”,避免为了通过流程而编造日期。 + +### 10.4 结果 + +只显示: + +- 主要/次级候选范围; +- 支持经历; +- 冲突或区分力不足; +- 不确定性说明; +- “保存这个范围”操作。 + +不显示内部事件 ID、hash、技术 packet、模型错误、评分明细或峰值分钟。 + +### 10.5 保存与继续咨询 + +点击“保存这个范围”只写 `acceptedRange`。之后用户可以把该范围带回原问题;咨询服务将其作为“未验证候选范围”使用,而不是已确认出生时间。 + +## 11. 错误与恢复策略 + +- 输入不合法:同步返回稳定中文错误,不创建 Job; +- 陈旧版本:返回冲突,客户端刷新 Case; +- Worker 失败:Job 标记 failed,恢复原问题; +- Worker 崩溃:lease 到期后其他 Worker 接管; +- 证据不足:生成下一题,不伪装成技术异常; +- 用户暂停:保留 Case、事件和问题; +- 用户结束:Case 进入 abandoned,出生资料不改写; +- 页面刷新:从活动 Case、Job 和事件台账恢复; +- 多设备 handoff:由数据库 lease 和 settlement receipt 仲裁。 + +## 12. 已落地代码边界 + +```text +frontend/src/lib/rectification-v4/ 领域模型、规划、评分适配、Store、Worker +frontend/src/app/api/rectification/v4/ HTTP API +frontend/src/components/rectification-v4-panel.tsx +frontend/src/hooks/use-rectification-v4.ts +frontend/scripts/rectification-v4-worker.mts +scripts/active_rectification_events_v4.py +frontend/supabase/migrations/20260726020000_birth_time_rectification_v4.sql +frontend/tests/rectification-v4-*.test.ts +tests/test_active_rectification_events_v4.py +``` + +首页旧聊天入口继续保留外壳和历史兼容,但新的活动流程由 `RectificationV4Panel` 和 V4 API 驱动。 + +## 13. 本地验收结果 + +截至 2026-07-26: + +- 前端 V4、handoff、replay、consultation continuation:33 个测试通过; +- Python 引擎:2 个测试通过; +- 目标 ESLint:通过; +- `npm run build -- --webpack`:通过,27 个页面生成成功; +- PostgreSQL:12 项迁移和并发/扣费不变量通过; +- 真实 PostgreSQL + Python Engine E2E:最终进入 `range_ready`,主要范围 `05:26–05:30`; +- E2E 后 `active_birth_time` 保持原值,`acceptedRange` 保持空值; +- 静态浏览器预览:日期修订输入框、范围保存按钮、不确认分钟文案和 390px 无横向溢出均通过。 + +本轮没有提交、推送或部署。由于当前本地认证浏览器与隔离 V4 数据库/服务环境没有安全地连在一起,尚未声称“真实登录态端到端 UI”已验收;发布前仍需在正确的本地或 staging 认证环境执行一次完整用户操作 smoke。 + +## 14. 发布前必须补齐 + +1. 应用迁移并核对 migration ledger; +2. 启动独立 Worker,确认部署环境包含数据库和 Python API 配置; +3. 用测试账户完成:建案 → 七领域 → 日期修订 → 范围就绪 → 主动保存 → 原问题 handoff; +4. 证明刷新和另一设备可恢复; +5. 证明重复提交、过期版本和 Worker 接管不重复事件、不重复扣费; +6. 再次核对 `profiles.active_birth_time` 未被 V4 路径更新; +7. 将验收绑定到精确部署 Git SHA,而不是只看 HTTP 200。 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 4527f605..0adbd951 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -32,8 +32,14 @@ "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", @@ -43,7 +49,6 @@ "eslint": "^9", "eslint-config-next": "16.2.10", "supabase": "^2.109.1", - "tsx": "^4.23.1", "typescript": "^5" } }, @@ -671,7 +676,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -688,7 +692,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -705,7 +708,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -722,7 +724,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -739,7 +740,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -756,7 +756,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -773,7 +772,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -790,7 +788,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -807,7 +804,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -824,7 +820,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -841,7 +836,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -858,7 +852,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -875,7 +868,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -892,7 +884,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -909,7 +900,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -926,7 +916,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -943,7 +932,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -960,7 +948,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -977,7 +964,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -994,7 +980,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1011,7 +996,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1028,7 +1012,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1045,7 +1028,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1062,7 +1044,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1079,7 +1060,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1096,7 +1076,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5477,7 +5456,6 @@ "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -6321,7 +6299,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -11008,7 +10985,6 @@ "version": "4.23.1", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "~0.28.0" @@ -11720,6 +11696,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 bff01bc2..4811a6ca 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,15 +13,21 @@ "db:migrate": "node scripts/db-migrate.mjs", "db:migrate:check": "node scripts/db-migrate.mjs --check", "lint": "eslint", - "data:china": "node scripts/pull-china-locations.mjs" + "data:china": "node scripts/pull-china-locations.mjs", + "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", @@ -40,6 +46,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" }, @@ -51,7 +58,6 @@ "eslint": "^9", "eslint-config-next": "16.2.10", "supabase": "^2.109.1", - "tsx": "^4.23.1", "typescript": "^5" } } diff --git a/frontend/scripts/rectification-v4-worker.mts b/frontend/scripts/rectification-v4-worker.mts new file mode 100644 index 00000000..72cac9cd --- /dev/null +++ b/frontend/scripts/rectification-v4-worker.mts @@ -0,0 +1,23 @@ +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 { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts"; +import { createAdminSupabaseClient } from "../src/lib/supabase/admin-client-core.ts"; + +const intervalMs = Number(process.env.RECTIFICATION_V4_WORKER_POLL_MS ?? "1000"); +const once = process.argv.includes("--once"); +let stopped = false; +for (const signal of ["SIGINT", "SIGTERM"] as const) process.once(signal, () => { stopped = true; }); + +const worker = createRectificationV4Worker({ + store: createRectificationV4SupabaseStore(createAdminSupabaseClient()), + engine: createRectificationV4CandidateEngine({ + apiBase: process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200", + }), +}); + +do { + const worked = await worker.runOnce(); + if (once) break; + if (!worked) await sleep(Number.isFinite(intervalMs) && intervalMs >= 100 ? intervalMs : 1000); +} while (!stopped); diff --git a/frontend/src/app/admin/codes/page.tsx b/frontend/src/app/admin/codes/page.tsx index 014354e9..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 5966771a..b331ca7b 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -1,18 +1,11 @@ -import { ReactNode } from "react"; -import { redirect } from "next/navigation"; -import { isAdminUser } from "@/lib/supabase/admin"; -import { createServerSupabaseClient } from "@/lib/supabase/server"; +import "@refinedev/antd/dist/reset.css"; +import "antd/dist/reset.css"; +import type { ReactNode } from "react"; + +import { AdminApp } from "@/components/admin/admin-app"; 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 (!(await isAdminUser(user))) redirect("/"); - - return children; +export default function AdminLayout({ children }: { children: ReactNode }) { + return {children}; } diff --git a/frontend/src/app/admin/route.ts b/frontend/src/app/admin/route.ts new file mode 100644 index 00000000..43b0bd97 --- /dev/null +++ b/frontend/src/app/admin/route.ts @@ -0,0 +1,6 @@ +export function GET() { + return new Response(null, { + status: 307, + headers: { location: "/admin/codes" }, + }); +} diff --git a/frontend/src/app/api/account/password/route.ts b/frontend/src/app/api/account/password/route.ts new file mode 100644 index 00000000..6e8febf8 --- /dev/null +++ b/frontend/src/app/api/account/password/route.ts @@ -0,0 +1,97 @@ +import { getIdentityAuthServices } from "@/modules/identity/auth"; +import { + isSelfHostedIdentityEnabled, + readSelfHostedIdentityConfig, +} from "@/modules/identity/config"; +import { resolveIdentitySurface } from "@/modules/identity/host"; + +export const dynamic = "force-dynamic"; + +async function userSession(request: Request) { + if (!isSelfHostedIdentityEnabled(process.env)) return null; + const config = readSelfHostedIdentityConfig(process.env); + if (resolveIdentitySurface(request.headers.get("host"), config) !== "user") { + return null; + } + const services = getIdentityAuthServices(); + const session = await services.user.api.getSession({ headers: request.headers }); + return session ? { services, session } : null; +} + +async function hasCredentialPassword( + services: ReturnType, + userId: string, +): Promise { + const result = await services.pool.query( + ` + select 1 + from identity.accounts + where user_id = $1 + and provider_id = 'credential' + and password is not null + limit 1 + `, + [userId], + ); + return result.rowCount === 1; +} + +export async function GET(request: Request): Promise { + const context = await userSession(request); + if (!context) { + return Response.json({ error: "请先登录" }, { status: 401 }); + } + return Response.json({ + hasPassword: await hasCredentialPassword( + context.services, + context.session.user.id, + ), + }); +} + +export async function POST(request: Request): Promise { + const context = await userSession(request); + if (!context) { + return Response.json({ error: "请先登录" }, { status: 401 }); + } + + let body: { newPassword?: unknown }; + try { + body = (await request.json()) as { newPassword?: unknown }; + } catch { + return Response.json({ error: "请求格式错误" }, { status: 400 }); + } + if ( + typeof body.newPassword !== "string" || + body.newPassword.length < 8 || + body.newPassword.length > 128 + ) { + return Response.json({ error: "密码长度须为 8–128 位" }, { status: 400 }); + } + if ( + await hasCredentialPassword(context.services, context.session.user.id) + ) { + return Response.json( + { error: "此账户已设置密码,原密码未被更改" }, + { status: 409 }, + ); + } + + try { + await context.services.user.api.setPassword({ + headers: request.headers, + body: { newPassword: body.newPassword }, + }); + return Response.json({ ok: true }); + } catch { + if ( + await hasCredentialPassword(context.services, context.session.user.id) + ) { + return Response.json( + { error: "此账户已设置密码,原密码未被更改" }, + { status: 409 }, + ); + } + return Response.json({ error: "暂时无法设置密码" }, { status: 500 }); + } +} diff --git a/frontend/src/app/api/account/route.ts b/frontend/src/app/api/account/route.ts index 16b40846..e151abcf 100644 --- a/frontend/src/app/api/account/route.ts +++ b/frontend/src/app/api/account/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { parseRectificationPriceCredits, } from "@/lib/birth-time-consultation-consent"; -import { resolveAccountRectificationCase } from "@/lib/account-rectification-case"; +import { resolveAccountRectificationCase, resolveAccountRectificationV4Case } from "@/lib/account-rectification-case"; import { accountProfilePatchSchema, applyAccountProfileConcurrencyGuards, @@ -26,6 +26,14 @@ function isMissingProfileColumn(error: { code?: string; message?: string } | nul || message.includes("column"); } +function isMissingRectificationV4Relation(error: { code?: string; message?: string } | null) { + const message = error?.message?.toLowerCase() ?? ""; + return error?.code === "42P01" + || error?.code === "PGRST205" + || message.includes('relation "public.birth_time_rectification_v4_cases" does not exist') + || (message.includes("schema cache") && message.includes("birth_time_rectification_v4_cases")); +} + export async function GET() { try { const supabase = await createServerSupabaseClient(); @@ -40,6 +48,19 @@ export async function GET() { process.env.RECTIFICATION_PRICE_CREDITS, ); const admin = createAdminSupabaseClient(); + const { data: rectificationV4CaseData, error: rectificationV4CaseError } = await admin + .from("birth_time_rectification_v4_cases") + .select("id,status,version,accepted_range_start,updated_at") + .eq("user_id", user.id) + .neq("status", "abandoned") + .is("accepted_range_start", null) + .order("updated_at", { ascending: false }) + .limit(1); + if (rectificationV4CaseError && !isMissingRectificationV4Relation(rectificationV4CaseError)) { + return NextResponse.json({ error: "暂时无法读取生时校正状态" }, { status: 500 }); + } + const rectificationV4CaseRows = rectificationV4CaseError ? [] : rectificationV4CaseData; + const { data: rectificationCaseRows, error: rectificationCaseError } = await admin .from("birth_time_rectification_cases") .select("id,journey_protocol,status,turn_version,revision_of_case_id,baseline_active_time,declared_birth_input,updated_at") @@ -57,14 +78,14 @@ export async function GET() { // an older case. A concurrently created case simply appears on refresh. let { data: profile, error: profileError } = await supabase .from("profiles") - .select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source") + .select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,name,birth_time,rectification_case_id") .eq("id", userId) .single(); if (profileError && isMissingProfileColumn(profileError)) { const fallback = await supabase .from("profiles") - .select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset") + .select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,name,birth_time,rectification_case_id") .eq("id", userId) .single(); profile = fallback.data ? { @@ -82,7 +103,9 @@ export async function GET() { if (profileError || !profile) { return NextResponse.json({ error: "暂时无法读取账户余额" }, { status: 500 }); } - const rectificationCase = resolveAccountRectificationCase( + const rectificationCase = resolveAccountRectificationV4Case( + Array.isArray(rectificationV4CaseRows) ? rectificationV4CaseRows : [], + ) ?? resolveAccountRectificationCase( profile, Array.isArray(rectificationCaseRows) ? rectificationCaseRows : [], ); @@ -95,6 +118,7 @@ export async function GET() { hasConfirmedBirthTime: profile.birth_time_status === "confirmed" && typeof profile.active_birth_time === "string", rectificationCase, + profile, birthLocation: { label: profile.birth_place_label ?? null, placeType: profile.birth_place_type ?? null, 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 be2becd3..bab2b438 100644 --- a/frontend/src/app/api/admin/codes/route.ts +++ b/frontend/src/app/api/admin/codes/route.ts @@ -1,108 +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, - isAdminUser, -} from "@/lib/supabase/admin"; + adminErrorResponse, + invalidQueryResponse, + parseListQuery, + requestId, +} from "@/lib/admin/http"; import { generateRedeemCode, hashRedeemCode, maskRedeemCode, } from "@/lib/supabase/codes"; -import { - isSupabaseConfigurationError, -} 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() { - const supabase = await createServerSupabaseClient(); - const { data: { user }, error } = await supabase.auth.getUser(); - if (error || !user) return { response: NextResponse.json({ error: "请先登录" }, { status: 401 }) }; - if (!(await isAdminUser(user))) { - return { response: NextResponse.json({ error: "无管理员权限" }, { status: 403 }) }; - } - return { user }; +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 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/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 index b299aede..79d28afc 100644 --- a/frontend/src/app/api/admin/users/route.ts +++ b/frontend/src/app/api/admin/users/route.ts @@ -1,77 +1,85 @@ import { NextResponse } from "next/server"; -import { z } from "zod"; -import { createAdminSupabaseClient, isAdminUser } from "@/lib/supabase/admin"; -import { isSupabaseConfigurationError } from "@/lib/supabase/config"; -import { createServerSupabaseClient } from "@/lib/supabase/server"; + +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"; -async function requireAdmin() { - const supabase = await createServerSupabaseClient(); - const { data: { user }, error } = await supabase.auth.getUser(); - if (error || !user) return { response: NextResponse.json({ error: "请先登录" }, { status: 401 }) }; - if (!(await isAdminUser(user))) return { response: NextResponse.json({ error: "无管理员权限" }, { status: 403 }) }; - return { user }; -} +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; +}; -function envAdmins() { - return (process.env.ADMIN_EMAILS ?? "").split(",").map((email) => email.trim().toLowerCase()).filter(Boolean); -} +const sortColumns = new Map([ + ["createdAt", "u.created_at"], + ["email", "u.email"], + ["credits", "p.credits"], + ["name", "u.name"], +]); -export async function GET() { +export const POST = readonlyAdminMutation; +export const PUT = readonlyAdminMutation; +export const PATCH = readonlyAdminMutation; +export const DELETE = readonlyAdminMutation; + +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("admin_users").select("user_id,created_at,created_by").is("revoked_at", null).order("created_at", { ascending: true }); - if (error) return NextResponse.json({ error: "暂时无法读取管理员列表" }, { status: 500 }); - const users = await Promise.all((data ?? []).map(async (row) => { - const result = await admin.auth.admin.getUserById(row.user_id); - return { userId: row.user_id, email: result.data.user?.email ?? null, createdAt: row.created_at, createdBy: row.created_by, source: "database" as const }; - })); - return NextResponse.json({ users: [...envAdmins().map((email) => ({ userId: null, email, createdAt: null, createdBy: null, source: "env" as const })), ...users] }); + 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) { - if (isSupabaseConfigurationError(error)) return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); - return NextResponse.json({ error: "管理员服务暂时不可用" }, { status: 500 }); - } -} - -export async function POST(request: Request) { - try { - const auth = await requireAdmin(); - if ("response" in auth) return auth.response; - const parsed = z.object({ email: z.string().trim().email() }).safeParse(await request.json().catch(() => null)); - if (!parsed.success) return NextResponse.json({ error: "邮箱格式不正确" }, { status: 400 }); - const email = parsed.data.email.toLowerCase(); - if (envAdmins().includes(email)) return NextResponse.json({ error: "该用户已由环境配置管理" }, { status: 409 }); - const admin = createAdminSupabaseClient(); - const { data: users, error: listError } = await admin.auth.admin.listUsers({ page: 1, perPage: 1000 }); - if (listError) return NextResponse.json({ error: "暂时无法查找用户" }, { status: 500 }); - const target = users.users.find((candidate) => candidate.email?.trim().toLowerCase() === email); - if (!target) return NextResponse.json({ error: "该邮箱尚未注册" }, { status: 404 }); - const { error } = await admin.from("admin_users").upsert({ user_id: target.id, created_by: auth.user.id, revoked_at: null, revoked_by: null }); - if (error) return NextResponse.json({ error: "添加管理员失败" }, { status: 500 }); - return NextResponse.json({ ok: true }, { status: 201 }); - } catch (error) { - if (isSupabaseConfigurationError(error)) return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); - return NextResponse.json({ error: "管理员服务暂时不可用" }, { status: 500 }); - } -} - -export async function DELETE(request: Request) { - try { - const auth = await requireAdmin(); - if ("response" in auth) return auth.response; - const parsed = z.object({ userId: z.string().uuid() }).safeParse(await request.json().catch(() => null)); - if (!parsed.success) return NextResponse.json({ error: "用户参数不正确" }, { status: 400 }); - const admin = createAdminSupabaseClient(); - const target = await admin.auth.admin.getUserById(parsed.data.userId); - if (target.data.user?.email && envAdmins().includes(target.data.user.email.toLowerCase())) return NextResponse.json({ error: "环境配置管理员不可撤销" }, { status: 409 }); - const { error } = await admin.from("admin_users").update({ revoked_at: new Date().toISOString(), revoked_by: auth.user.id }).eq("user_id", parsed.data.userId).is("revoked_at", null); - if (error) return NextResponse.json({ error: "撤销管理员失败" }, { status: 500 }); - return NextResponse.json({ ok: true }); - } catch (error) { - if (isSupabaseConfigurationError(error)) return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); - return NextResponse.json({ error: "管理员服务暂时不可用" }, { status: 500 }); + return adminErrorResponse(error); } } diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index e2c798ea..691de977 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -5,6 +5,7 @@ import { getGeneralJyotishAgent, getJyotishAgent, runConsultationWorkflow, + toAgentConsultationContext, } from "@/mastra"; import { languageModelConfigurationMessage, @@ -33,8 +34,9 @@ import { } from "@/lib/consultation-route-service"; import { createRectificationHandoffService, + createRectificationV4HandoffService, type RectificationHandoffExecution, - type RectificationHandoffService, + type RectificationV4HandoffExecution, } from "@/lib/rectification-handoff-service"; import { z } from "zod"; @@ -56,20 +58,38 @@ const chatRequestMetadataSchema = z.object({ .default([]), }); -const rectificationHandoffSchema = z.object({ +const rectificationV3HandoffSchema = z.object({ + protocol: z.literal("conversational-evidence-v3").optional(), caseId: z.string().uuid(), turnVersion: z.number().int().nonnegative(), claimActionId: z.string().uuid(), requestId: z.string().uuid(), }).strict(); +const rectificationV4HandoffSchema = z.object({ + protocol: z.literal("rectification-evidence-v4"), + caseId: z.string().uuid(), + caseVersion: z.number().int().nonnegative(), + claimActionId: z.string().uuid(), + requestId: z.string().uuid(), +}).strict(); + +const v4ContinuationRequestSchema = z.object({ + ...chatRequestMetadataSchema.shape, + consultationMode: consultationBirthTimeModeSchema, + question: z.string().trim().min(1).max(500), + theme: z.enum(["career", "marriage", "wealth", "timing", "general"]), + entrypoint: z.undefined().optional(), + rectificationHandoff: rectificationV4HandoffSchema, +}).strict(); + const chartChatRequestSchema = consultationInputSchema.extend({ ...chatRequestMetadataSchema.shape, consultationMode: consultationBirthTimeModeSchema.exclude(["general_no_birth_time"]) .optional() .default("verified_chart"), entrypoint: consultationEntrypointSchema.optional(), - rectificationHandoff: rectificationHandoffSchema.optional(), + rectificationHandoff: rectificationV3HandoffSchema.optional(), }).strict(); const generalChatRequestSchema = z.object({ @@ -80,7 +100,11 @@ const generalChatRequestSchema = z.object({ entrypoint: z.undefined().optional(), }).strict(); -const chatRequestSchema = z.union([generalChatRequestSchema, chartChatRequestSchema]); +const chatRequestSchema = z.union([ + v4ContinuationRequestSchema, + generalChatRequestSchema, + chartChatRequestSchema, +]); function currentTimeContext(now = new Date()) { const chinaTime = new Date(now.getTime() + 8 * 60 * 60 * 1000) @@ -94,6 +118,54 @@ function chinaCalendarDate(now: Date) { return new Date(now.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10); } +function rangeBoundaryWorkflowContext( + start: Awaited>, + end: Awaited>, + acceptedRange: Readonly<{ start: string; end: string }>, +) { + const startConsumer = start.consumer_context; + const endConsumer = end.consumer_context; + return { + ...start, + success: start.success && end.success, + consumer_context: { + ...startConsumer, + core_status: startConsumer.core_status === "blocked" || endConsumer.core_status === "blocked" + ? "blocked" + : startConsumer.core_status === "degraded" || endConsumer.core_status === "degraded" + ? "degraded" + : "ready", + available_layers: startConsumer.available_layers.filter((layer) => + endConsumer.available_layers.includes(layer)), + missing_route_layers: [...new Set([ + ...startConsumer.missing_route_layers, + ...endConsumer.missing_route_layers, + ])], + hard_blockers: [...new Set([ + ...startConsumer.hard_blockers, + ...endConsumer.hard_blockers, + ])], + answer_policy: { + ...startConsumer.answer_policy, + can_answer_direction: startConsumer.answer_policy.can_answer_direction + && endConsumer.answer_policy.can_answer_direction, + can_answer_precise_timing: false, + birth_time_confidence: "accepted_candidate_range", + candidate_is_confirmed: false, + require_boundary_agreement: true, + }, + }, + candidate_range: { + ...acceptedRange, + claim_status: "candidate_range_not_birth_time_truth", + }, + range_boundary_contexts: { + start: toAgentConsultationContext(start), + end: toAgentConsultationContext(end), + }, + }; +} + async function recordModelUsage( accounting: ReturnType, userId: string, @@ -182,25 +254,21 @@ export async function POST(request: Request) { const handoff = "rectificationHandoff" in parsed.data ? parsed.data.rectificationHandoff : undefined; - let handoffService: RectificationHandoffService | null = null; - let handoffExecution: RectificationHandoffExecution | null = null; + const v4Handoff = handoff?.protocol === "rectification-evidence-v4"; + let handoffExecution: RectificationHandoffExecution | RectificationV4HandoffExecution | null = null; + let settleHandoffRequest: ((emitted: boolean) => Promise) | null = null; let handoffSettlement: Promise | null = null; async function settleHandoff(emitted: boolean) { - if (!handoff || !handoffService || !handoffExecution + if (!settleHandoffRequest || !handoffExecution || handoffExecution.status !== "ready") return; - handoffSettlement ??= handoffService.settle({ - userId, - caseId: handoff.caseId, - claimActionId: handoff.claimActionId, - requestId: handoff.requestId, - emitted, - }).then(() => undefined); + handoffSettlement ??= settleHandoffRequest(emitted); await handoffSettlement; } if (handoff) { - if (!["verified_chart", "unverified_birth_time"].includes(parsed.data.consultationMode) + if ((!v4Handoff + && !["verified_chart", "unverified_birth_time"].includes(parsed.data.consultationMode)) || parsed.data.entrypoint !== undefined || requestId !== handoff.requestId) { return NextResponse.json( @@ -212,15 +280,41 @@ export async function POST(request: Request) { ); } try { - handoffService = createRectificationHandoffService(accounting); - handoffExecution = await handoffService.beginExecution({ - userId, - caseId: handoff.caseId, - turnVersion: handoff.turnVersion, - claimActionId: handoff.claimActionId, - requestId: handoff.requestId, - question: parsed.data.question, - }); + if (handoff.protocol === "rectification-evidence-v4") { + const service = createRectificationV4HandoffService(accounting); + handoffExecution = await service.beginExecution({ + userId, + caseId: handoff.caseId, + caseVersion: handoff.caseVersion, + claimActionId: handoff.claimActionId, + requestId: handoff.requestId, + question: parsed.data.question, + }); + settleHandoffRequest = (emitted) => service.settle({ + userId, + caseId: handoff.caseId, + claimActionId: handoff.claimActionId, + requestId: handoff.requestId, + emitted, + }).then(() => undefined); + } else { + const service = createRectificationHandoffService(accounting); + handoffExecution = await service.beginExecution({ + userId, + caseId: handoff.caseId, + turnVersion: handoff.turnVersion, + claimActionId: handoff.claimActionId, + requestId: handoff.requestId, + question: parsed.data.question, + }); + settleHandoffRequest = (emitted) => service.settle({ + userId, + caseId: handoff.caseId, + claimActionId: handoff.claimActionId, + requestId: handoff.requestId, + emitted, + }).then(() => undefined); + } } catch { return NextResponse.json( { @@ -275,6 +369,10 @@ export async function POST(request: Request) { prepared = await prepareConsultationRoute({ userId, mode: parsed.data.consultationMode, + ...(v4Handoff && handoffExecution?.status === "ready" + && "acceptedRange" in handoffExecution + ? { candidateRange: handoffExecution.acceptedRange } + : {}), async loadProfile(profileUserId) { const { data, error } = await supabase .from("profiles") @@ -439,7 +537,9 @@ export async function POST(request: Request) { try { const { history } = parsed.data; const name = prepared.serverChart?.name ?? parsed.data.name; - const consultationMode: ConsultationBirthTimeMode = prepared.consultationMode; + const consultationMode: ConsultationBirthTimeMode = v4Handoff + ? "unverified_birth_time" + : prepared.consultationMode; if (!shouldRunBirthChartWorkflow(consultationMode)) { const result = await getGeneralJyotishAgent(selectedModel).stream([ { @@ -484,6 +584,78 @@ export async function POST(request: Request) { } if (!prepared.serverChart) throw new Error("server_chart_truth_missing"); + if (v4Handoff) { + if (!handoffExecution || handoffExecution.status !== "ready" + || !("acceptedRange" in handoffExecution)) { + throw new Error("rectification_v4_range_missing"); + } + const acceptedRange = handoffExecution.acceptedRange; + const boundaryInput = (time: string) => { + const [hour, minute] = time.split(":").map(Number); + return consultationInputSchema.parse({ + ...prepared.serverChart?.toolInput, + hour, + minute, + entryMode: "direct_chart", + question: resolvedQuestion.modelQuestion, + theme: parsed.data.theme, + }); + }; + const [startWorkflow, endWorkflow] = await Promise.all([ + runConsultationWorkflow(boundaryInput(acceptedRange.start)), + runConsultationWorkflow(boundaryInput(acceptedRange.end)), + ]); + const workflowContext = rangeBoundaryWorkflowContext( + startWorkflow, + endWorkflow, + acceptedRange, + ); + const workflowReceipt = consultationWorkflowReceipt(workflowContext); + const result = await getJyotishAgent(selectedModel, workflowContext).stream([ + ...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), + name ? `用户称呼:${name}` : "", + resolvedQuestion.modelQuestion, + `本次只能使用已保存候选范围 ${acceptedRange.start}–${acceptedRange.end} 的两个边界共同支持的结论。`, + "不得选择中点、峰值或单一代表分钟,不得把候选范围说成已确认出生时间。", + ].filter(Boolean).join("\n"), + }, + ]); + const completeAndRecordUsage = async () => { + await complete(); + void recordModelUsage( + accounting, + userId, + requestId, + modelSelection.usageModelId, + result.totalUsage, + ); + }; + const settleInterrupted = (emitted: boolean) => + settle(emitted ? completeAndRecordUsage : cancel); + return streamTextResponse(result.textStream, { + transformText: createBirthTimeModeOutputGuard("unverified_birth_time", false), + mode: "mastra", + requestId, + headers: { + "x-jyotish-workflow-route": workflowReceipt.route, + "x-jyotish-workflow-status": workflowReceipt.status, + "x-jyotish-technique-truth": workflowReceipt.techniqueTruth, + "x-jyotish-precise-timing": "blocked", + "x-jyotish-missing-layers": workflowReceipt.missingLayers, + "x-jyotish-birth-time-mode": "unverified_birth_time", + }, + onFirstOutput: () => settle(completeAndRecordUsage), + onComplete: () => settle(completeAndRecordUsage), + onError: (_error, emitted) => settleInterrupted(emitted), + onCancel: settleInterrupted, + }); + } const toolInput = consultationInputSchema.parse({ ...prepared.serverChart.toolInput, // Unverified use is still a normal chart calculation with a hard answer diff --git a/frontend/src/app/api/rectification/v4/_server.ts b/frontend/src/app/api/rectification/v4/_server.ts new file mode 100644 index 00000000..32445068 --- /dev/null +++ b/frontend/src/app/api/rectification/v4/_server.ts @@ -0,0 +1,98 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { parseBirthTimeProfile } from "@/lib/birth-time-journey-adapters"; +import { assessBirthTime } from "@/lib/birth-time-journey"; +import { resolveMissingBirthTimezoneOffset } from "@/lib/birth-profile-timezone"; +import type { CalculationSpec } from "@/lib/rectification-v4/contracts"; +import { createRectificationV4CaseService } from "@/lib/rectification-v4/case-service"; +import { RectificationV4StoreError } from "@/lib/rectification-v4/store"; +import { createRectificationV4SupabaseStore } from "@/lib/rectification-v4/supabase-store"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +const idSchema = z.string().uuid(); + +export async function rectificationV4Context() { + const auth = await createServerSupabaseClient(); + const { data: { user }, error } = await auth.auth.getUser(); + if (error || !user) throw new RectificationV4HttpError(401, "请先登录后再继续生时校正。"); + const admin = createAdminSupabaseClient(); + return { + userId: user.id, + auth, + service: createRectificationV4CaseService(createRectificationV4SupabaseStore(admin)), + }; +} + +export async function requestBody(request: Request, schema: z.ZodType): Promise { + const body = await request.json().catch(() => null); + const parsed = schema.safeParse(body); + if (!parsed.success) throw new RectificationV4HttpError(400, "提交内容不完整,请检查后重试。"); + return parsed.data; +} + +export function routeId(value: string): string { + const parsed = idSchema.safeParse(value); + if (!parsed.success) throw new RectificationV4HttpError(404, "没有找到这次生时校正记录。"); + return parsed.data; +} + +export async function calculationSpecForUser( + auth: Awaited>, + 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") + .eq("id", userId).maybeSingle(); + if (error) throw error; + if (!data) throw new RectificationV4HttpError(409, "请先补全出生日期、时间线索和出生地点。"); + const profile = await resolveMissingBirthTimezoneOffset(data); + const assessment = parseBirthTimeProfile(profile); + const range = assessBirthTime(assessment, { kind: "unavailable" }).reportedRange; + return { + version: "rectification-calculation-spec-v4", + birthDate: assessment.date, + candidateRange: { + start: range.startTime ?? "00:00", + end: range.endTime ?? "23:59", + }, + latitude: assessment.location.lat, + longitude: assessment.location.lon, + timezoneOffsetHours: assessment.location.tz, + ayanamsa: "lahiri", + nodeMode: "mean", + minuteStep: 1, + }; +} + +export class RectificationV4HttpError extends Error { + constructor(readonly status: number, message: string) { + super(message); + } +} + +export function rectificationV4Error(error: unknown): NextResponse { + if (error instanceof RectificationV4HttpError) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + if (error instanceof RectificationV4StoreError) { + const responses: Record = { + not_found: [404, "没有找到这次生时校正记录。"], + stale_version: [409, "记录已在其他位置更新,正在重新载入。"], + invalid_state: [409, "当前状态无法执行这个操作,请刷新后重试。"], + stale_job: [409, "这次计算已过期,请以最新结果为准。"], + lease_lost: [409, "计算任务已由其他进程接管,请稍后刷新。"], + }; + const response = responses[error.code]; + return NextResponse.json({ error: response[1] }, { status: response[0] }); + } + if (error instanceof z.ZodError) { + return NextResponse.json({ error: "出生资料或提交内容格式不正确。" }, { status: 400 }); + } + if (isSupabaseConfigurationError(error)) { + return NextResponse.json({ error: "生时校正服务尚未配置。" }, { status: 503 }); + } + console.error("rectification_v4_route_failed", error); + return NextResponse.json({ error: "暂时无法处理,请稍后再试。" }, { status: 500 }); +} diff --git a/frontend/src/app/api/rectification/v4/cases/[caseId]/abandon/route.ts b/frontend/src/app/api/rectification/v4/cases/[caseId]/abandon/route.ts new file mode 100644 index 00000000..8241cf02 --- /dev/null +++ b/frontend/src/app/api/rectification/v4/cases/[caseId]/abandon/route.ts @@ -0,0 +1,12 @@ +import { rectificationV4Error } from "../../../_server"; +import { transitionCase } from "../../_action"; + +export const runtime = "nodejs"; + +export async function POST(request: Request, { params }: { params: Promise<{ caseId: string }> }) { + try { + return await transitionCase(request, params, "abandon"); + } catch (error) { + return rectificationV4Error(error); + } +} diff --git a/frontend/src/app/api/rectification/v4/cases/[caseId]/accept-range/route.ts b/frontend/src/app/api/rectification/v4/cases/[caseId]/accept-range/route.ts new file mode 100644 index 00000000..f266f8ae --- /dev/null +++ b/frontend/src/app/api/rectification/v4/cases/[caseId]/accept-range/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { acceptRangeRequestSchema } 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, acceptRangeRequestSchema); + const context = await rectificationV4Context(); + const result = await context.service.acceptRange({ + ...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/rectification/v4/cases/[caseId]/answers/route.ts b/frontend/src/app/api/rectification/v4/cases/[caseId]/answers/route.ts new file mode 100644 index 00000000..04f498b8 --- /dev/null +++ b/frontend/src/app/api/rectification/v4/cases/[caseId]/answers/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { answerRequestSchema } 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, answerRequestSchema); + const context = await rectificationV4Context(); + const result = await context.service.answer({ ...body, userId: context.userId, caseId: routeId((await params).caseId) }); + return result + ? NextResponse.json(result, { status: 202 }) + : NextResponse.json({ error: "当前没有待回答的问题,请刷新后重试。" }, { status: 409 }); + } catch (error) { + return rectificationV4Error(error); + } +} 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 new file mode 100644 index 00000000..31955439 --- /dev/null +++ b/frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { reviseEventRequestSchema } from "@/lib/rectification-v4/contracts"; +import { appendEventRevision } from "@/lib/rectification-v4/evidence-ledger"; +import { rectificationV4Context, rectificationV4Error, requestBody, routeId } from "../../../../../_server"; + +export const runtime = "nodejs"; + +export async function POST(request: Request, { params }: { params: Promise<{ caseId: string; eventId: string }> }) { + try { + const body = await requestBody(request, reviseEventRequestSchema); + const context = await rectificationV4Context(); + const values = await params; + const caseId = routeId(values.caseId); + const eventId = routeId(values.eventId); + const current = await context.service.loadCase(context.userId, caseId); + if (!current) return NextResponse.json({ error: "没有找到这次生时校正记录。" }, { status: 404 }); + const revision = appendEventRevision(current.events, { + eventId, + domain: body.domain, + eventKind: body.eventKind, + subject: body.subject, + relatedPerson: body.relatedPerson, + summary: body.summary, + rawText: body.rawText, + dateRange: body.dateRange, + scoreability: body.scoreability, + }); + return NextResponse.json(await context.service.reviseEvent({ + userId: context.userId, + caseId, + actionId: body.actionId, + expectedCaseVersion: body.expectedCaseVersion, + revision, + }), { status: 202 }); + } catch (error) { + return rectificationV4Error(error); + } +} diff --git a/frontend/src/app/api/rectification/v4/cases/[caseId]/pause/route.ts b/frontend/src/app/api/rectification/v4/cases/[caseId]/pause/route.ts new file mode 100644 index 00000000..25ebac8e --- /dev/null +++ b/frontend/src/app/api/rectification/v4/cases/[caseId]/pause/route.ts @@ -0,0 +1,12 @@ +import { rectificationV4Error } from "../../../_server"; +import { transitionCase } from "../../_action"; + +export const runtime = "nodejs"; + +export async function POST(request: Request, { params }: { params: Promise<{ caseId: string }> }) { + try { + return await transitionCase(request, params, "pause"); + } catch (error) { + return rectificationV4Error(error); + } +} diff --git a/frontend/src/app/api/rectification/v4/cases/[caseId]/resume/route.ts b/frontend/src/app/api/rectification/v4/cases/[caseId]/resume/route.ts new file mode 100644 index 00000000..f0c871d3 --- /dev/null +++ b/frontend/src/app/api/rectification/v4/cases/[caseId]/resume/route.ts @@ -0,0 +1,12 @@ +import { rectificationV4Error } from "../../../_server"; +import { transitionCase } from "../../_action"; + +export const runtime = "nodejs"; + +export async function POST(request: Request, { params }: { params: Promise<{ caseId: string }> }) { + try { + return await transitionCase(request, params, "resume"); + } catch (error) { + return rectificationV4Error(error); + } +} diff --git a/frontend/src/app/api/rectification/v4/cases/[caseId]/route.ts b/frontend/src/app/api/rectification/v4/cases/[caseId]/route.ts new file mode 100644 index 00000000..bbe25c56 --- /dev/null +++ b/frontend/src/app/api/rectification/v4/cases/[caseId]/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { rectificationV4Context, rectificationV4Error, routeId } from "../../_server"; + +export const runtime = "nodejs"; + +export async function GET(_request: Request, { params }: { params: Promise<{ caseId: string }> }) { + try { + const context = await rectificationV4Context(); + const result = await context.service.loadCase(context.userId, routeId((await params).caseId)); + return result ? NextResponse.json(result) : NextResponse.json({ error: "没有找到这次生时校正记录。" }, { status: 404 }); + } catch (error) { + return rectificationV4Error(error); + } +} diff --git a/frontend/src/app/api/rectification/v4/cases/_action.ts b/frontend/src/app/api/rectification/v4/cases/_action.ts new file mode 100644 index 00000000..decb0ba2 --- /dev/null +++ b/frontend/src/app/api/rectification/v4/cases/_action.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { caseActionRequestSchema } from "@/lib/rectification-v4/contracts"; +import { rectificationV4Context, requestBody, routeId } from "../_server"; + +export async function transitionCase( + request: Request, + params: Promise<{ caseId: string }>, + kind: "pause" | "resume" | "abandon", +) { + const body = await requestBody(request, caseActionRequestSchema); + const context = await rectificationV4Context(); + return NextResponse.json(await context.service.transition({ + ...body, + userId: context.userId, + caseId: routeId((await params).caseId), + kind, + })); +} diff --git a/frontend/src/app/api/rectification/v4/cases/active/route.ts b/frontend/src/app/api/rectification/v4/cases/active/route.ts new file mode 100644 index 00000000..4ac525c7 --- /dev/null +++ b/frontend/src/app/api/rectification/v4/cases/active/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { rectificationV4Context, rectificationV4Error } from "../../_server"; + +export const runtime = "nodejs"; + +export async function GET() { + try { + const context = await rectificationV4Context(); + const result = await context.service.loadActive(context.userId); + return result ? NextResponse.json(result) : new NextResponse(null, { status: 204 }); + } catch (error) { + return rectificationV4Error(error); + } +} diff --git a/frontend/src/app/api/rectification/v4/cases/route.ts b/frontend/src/app/api/rectification/v4/cases/route.ts new file mode 100644 index 00000000..ece53c16 --- /dev/null +++ b/frontend/src/app/api/rectification/v4/cases/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { createCaseRequestSchema } from "@/lib/rectification-v4/contracts"; +import { calculationSpecForUser, rectificationV4Context, rectificationV4Error, requestBody } from "../_server"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + try { + const body = await requestBody(request, createCaseRequestSchema); + const context = await rectificationV4Context(); + return NextResponse.json(await context.service.createCase({ + userId: context.userId, + actionId: body.actionId, + calculationSpec: await calculationSpecForUser(context.auth, context.userId), + })); + } catch (error) { + return rectificationV4Error(error); + } +} diff --git a/frontend/src/app/api/rectification/v4/handoff/route.ts b/frontend/src/app/api/rectification/v4/handoff/route.ts new file mode 100644 index 00000000..51f33631 --- /dev/null +++ b/frontend/src/app/api/rectification/v4/handoff/route.ts @@ -0,0 +1,30 @@ +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { createRectificationV4HandoffService } from "@/lib/rectification-handoff-service"; +import { + createRectificationV4HandoffHandlers, + type RectificationV4HandoffRouteDependencies, +} from "@/lib/rectification-v4/handoff-route"; + +export const runtime = "nodejs"; + +const dependencies: RectificationV4HandoffRouteDependencies = { + async authenticate() { + const supabase = await createServerSupabaseClient(); + const { data: { user }, error } = await supabase.auth.getUser(); + return error || !user ? null : { userId: user.id }; + }, + service() { + return createRectificationV4HandoffService(createAdminSupabaseClient()); + }, +}; + +const handlers = createRectificationV4HandoffHandlers(dependencies); + +export async function GET(request: Request) { + return handlers.get(request); +} + +export async function POST(request: Request) { + return handlers.post(request); +} diff --git a/frontend/src/app/api/rectification/v4/jobs/[jobId]/route.ts b/frontend/src/app/api/rectification/v4/jobs/[jobId]/route.ts new file mode 100644 index 00000000..c37dbc02 --- /dev/null +++ b/frontend/src/app/api/rectification/v4/jobs/[jobId]/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { rectificationV4Context, rectificationV4Error, routeId } from "../../_server"; + +export const runtime = "nodejs"; + +export async function GET(_request: Request, { params }: { params: Promise<{ jobId: string }> }) { + try { + const context = await rectificationV4Context(); + const job = await context.service.loadJob(context.userId, routeId((await params).jobId)); + return job ? NextResponse.json({ job }) : NextResponse.json({ error: "没有找到这次处理任务。" }, { status: 404 }); + } 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/globals.css b/frontend/src/app/globals.css index 8555771a..e93ff96e 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -213,7 +213,7 @@ button:disabled { cursor: default; opacity: .45; } .auth-brand strong { font-weight: 600; } .auth-step { transition: opacity 180ms ease-out, transform 180ms var(--ease-out); } @starting-style { .auth-step { opacity: 0; transform: translateY(6px); } } -.inline-actions { display: flex; justify-content: space-between; gap: 10px; } +.inline-actions { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px; } .section-title { display: flex; align-items: center; justify-content: space-between; gap: 18px; } .code-form { display: grid; grid-template-columns: 120px 120px 200px minmax(180px, 1fr) auto; align-items: end; gap: 12px; margin-top: 18px; } .code-form label { display: grid; gap: 7px; } @@ -722,7 +722,7 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: .auth-story > div > p:not(.auth-kicker) { max-width: 480px; margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-md); line-height: 1.65; text-wrap: pretty; } .auth-kicker { margin: 0; color: var(--color-action); font-size: var(--type-overline); font-weight: 500; letter-spacing: 1.5px; text-transform: uppercase; } .auth-footnote { margin: 0; color: var(--color-ink-tertiary); font-size: var(--type-caption); } -.auth-panel { width: 100%; display: flex; flex-direction: column; justify-content: center; padding: var(--space-12); border: 0; border-radius: 0; background: var(--color-canvas); } +.auth-panel { width: 100%; min-width: 0; display: flex; flex-direction: column; justify-content: center; padding: var(--space-12); border: 0; border-radius: 0; background: var(--color-canvas); } .auth-brand { align-items: center; gap: 10px; font-size: 17px; display: none; margin-bottom: var(--space-10); } .auth-panel h1 { font-size: var(--type-display-md); } .page-intro { line-height: 1.6; margin: var(--space-3) 0 var(--space-8); color: var(--color-ink-secondary); font-size: var(--type-body-md); } @@ -731,6 +731,12 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: .stack-form .button-primary { margin-top: var(--space-2); } .otp-input { text-align: center; font-size: 20px; font-variant-numeric: tabular-nums; letter-spacing: .35em; font-family: var(--font-mono); } .inline-actions button { min-height: 44px; padding: 0; border: 0; background: transparent; cursor: pointer; color: var(--color-action); font-size: 13px; } +.auth-mode-nav { display: grid; gap: var(--space-3); margin: calc(var(--space-4) * -1) 0 var(--space-6); } +.auth-mode-tabs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); padding: 3px; border-radius: var(--radius-md); background: var(--color-canvas-muted); } +.auth-mode-tabs button, .auth-links button { min-width: 0; min-height: 40px; border: 0; background: transparent; cursor: pointer; color: var(--color-ink-secondary); font: inherit; font-size: 13px; } +.auth-mode-tabs button[aria-pressed="true"] { border-radius: calc(var(--radius-md) - 2px); background: var(--color-canvas); color: var(--color-ink); box-shadow: var(--shadow-soft); } +.auth-links { display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--space-2); } +.auth-links button { min-height: 32px; padding: 0; color: var(--color-action); } .admin-page { background: var(--color-canvas-soft); } .admin-header { position: sticky; z-index: 4; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--color-border); min-height: 88px; padding: 0 var(--space-8); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); } @@ -842,21 +848,6 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: .conversational-composer-footer button, .conversational-confirmation button, .conversational-original-question button { width: 100%; } } -/* Conversational rectification shell */ -.conversation.is-rectification { overflow: hidden; padding-bottom: 0; } -.rectification-chat { height: 100%; min-height: 0; display: flex; flex-direction: column; color: var(--color-ink); } -.rectification-message-list { min-height: 0; flex: 1; overflow-y: auto; overscroll-behavior: contain; padding-bottom: var(--space-6); } -.rectification-correction-target button { min-height: 44px; padding-inline: var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; } -.rectification-composer-wrap { position: static; flex: 0 0 auto; } -.rectification-correction-target { width: min(760px, 100%); display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin: 0 auto var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-caption); } -.rectification-correction-target p { margin: 0; } -.conversational-empty-state { width: min(760px, 100%); margin: var(--space-8) auto; color: var(--color-ink-secondary); } -.conversational-loading { min-height: min(52vh, 460px); display: grid; place-items: center; padding: var(--space-6); } - -@media (max-width: 430px) { - .rectification-correction-target { align-items: stretch; display: grid; } -} - /* Starter workbench */ .welcome { width: min(1040px, 100%); diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 6cc08837..38692169 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -13,6 +13,8 @@ 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( @@ -20,6 +22,14 @@ export default async function LoginPage() { selfHosted, ); if (surface === "admin") provider = "self-hosted"; + passwordEnabled = provider === "self-hosted"; + passwordOnly = surface === "admin"; } - return ; + return ( + + ); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index a3623a8d..4b6fdac9 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -15,6 +15,7 @@ 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"; @@ -53,19 +54,11 @@ 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 { - ConversationalRectificationRequestError, - conversationalRectificationHistoryForTurn, - sendConversationalRectificationCommand, -} from "@/lib/conversational-rectification/client"; -import type { ConversationalRectificationTurn } from "@/lib/conversational-rectification/contracts"; -import { - createDurableRectificationQuestionHandoffClient, createRectificationQuestionHandoffCoordinator, - DurableRectificationHandoffError, } from "@/lib/rectification-question-handoff"; import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey"; -import type { ConversationalRectificationMessage } from "@/hooks/use-conversational-rectification"; import { requestBirthTimeAssessment, type JourneyClientResponse, @@ -99,7 +92,7 @@ import { resolveSessionModelId, type PublicLanguageModelCatalog, } from "@/lib/public-models"; -import { createBrowserSupabaseClient } from "@/lib/supabase/client"; +import { selfHostedOtpActions } from "@/modules/identity/client"; gsap.registerPlugin(useGSAP); @@ -174,13 +167,6 @@ type ChatSession = { rectificationCaseId: string | null; }; -function durableRectificationMessages( - messages: readonly ConversationalRectificationMessage[], -): Message[] { - const durable = messages.map(({ role, text }) => ({ role, text })); - if (durable.length <= 500) return durable; - return [durable[0]!, ...durable.slice(-(500 - 1))]; -} type RequestError = { sessionId: string; message: string }; type StreamingReply = { sessionId: string; text: string }; type BirthPlace = { @@ -197,6 +183,7 @@ type Account = { rectificationPriceCredits: number; hasConfirmedBirthTime: boolean; rectificationCase: AccountRectificationCaseState | null; + profile: unknown; }; type OnboardingStep = "name" | "birth" | "place" | "rectification"; type AccountDialog = "profile" | "redeem" | "logout"; @@ -210,10 +197,17 @@ type DailyStarlanguageApiResponse = { }; type SessionReadResult = { readonly sessions: ChatSession[]; readonly fallbackSessionIds: string[] }; type ConsultationRectificationHandoff = Readonly<{ + protocol?: "conversational-evidence-v3"; caseId: string; turnVersion: number; claimActionId: string; requestId: string; +}> | Readonly<{ + protocol: "rectification-evidence-v4"; + caseId: string; + caseVersion: number; + claimActionId: string; + requestId: string; }>; type PendingConsultation = { readonly requestId: string; @@ -883,6 +877,33 @@ async function fetchModelCatalog(signal?: AbortSignal) { return parsePublicModelCatalog(payload); } +async function fetchSessions(signal?: AbortSignal): Promise { + const response = await fetch("/api/sessions", { signal, cache: "no-store" }); + if (response.status === 401) { + window.location.assign("/login"); + throw new Error("请先登录"); + } + const payload = await response.json().catch(() => null); + if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取聊天记录")); + return payload && typeof payload === "object" ? (payload as { sessions?: unknown }).sessions : null; +} + +async function patchSessionModel(sessionId: string, modelId: string, signal?: AbortSignal) { + const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ model_id: modelId }), + signal, + }); + if (response.status === 401) { + window.location.assign("/login"); + throw new Error("请先登录"); + } + const payload = await response.json().catch(() => null); + if (!response.ok) throw new Error(payloadMessage(payload, "模型选择暂时无法同步到云端。")); +} + export default function Home() { const [profile, setProfile] = useState(emptyProfile); const [profileDraft, setProfileDraft] = useState(emptyProfile); @@ -930,8 +951,6 @@ export default function Home() { ); const [rectificationSessionId, setRectificationSessionId] = useState(null); const [rectificationReturnSessionId, setRectificationReturnSessionId] = useState(null); - const [rectificationInitialTurn, setRectificationInitialTurn] = useState(null); - const [rectificationOpeningAssistantText, setRectificationOpeningAssistantText] = useState(""); const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState(null); const [rectificationLoading, setRectificationLoading] = useState(false); const [rectificationMutationPending, setRectificationMutationPending] = useState(false); @@ -973,9 +992,6 @@ export default function Home() { const activeOnboardingRequestIdentity = useRef(""); const accountRefreshGuard = useRef(createLatestAccountRequestGuard()); const rectificationQuestionHandoff = useRef(createRectificationQuestionHandoffCoordinator()); - const durableRectificationQuestionHandoff = useRef( - createDurableRectificationQuestionHandoffClient(), - ); const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined); const rectificationOpenInFlight = useRef(false); const rectificationContinuationInFlight = useRef(false); @@ -992,9 +1008,6 @@ export default function Home() { const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0]; const activeRectificationSession = activeSession?.sessionType === "birth_time_rectification"; - const visibleRectificationTurn = activeSession?.id === rectificationSessionId - ? rectificationInitialTurn - : null; const rectificationSurfaceOpen = activeRectificationSession && activeSession.id === rectificationSessionId; const visibleSessions = sessions @@ -1029,7 +1042,7 @@ export default function Home() { || !account || !modelCatalog || activeSession?.sessionType !== "birth_time_rectification" - || visibleRectificationTurn + || activeSession.id === rectificationSessionId || rectificationLoading || rectificationMutationPending || rectificationContinuationPending @@ -1046,7 +1059,7 @@ export default function Home() { rectificationError, rectificationLoading, rectificationMutationPending, - visibleRectificationTurn, + rectificationSessionId, ]); useEffect(() => { @@ -1233,6 +1246,7 @@ export default function Home() { rectificationPriceCredits: 1, hasConfirmedBirthTime: previewProfile.birthTimeStatus === "confirmed", rectificationCase: null, + profile: previewProfile, }); setModelCatalog(previewModelCatalog); setProfile(previewProfile); @@ -1255,16 +1269,7 @@ export default function Home() { return; } - const supabase = createBrowserSupabaseClient(); - const { data: authData, error: authError } = await supabase.auth.getSession(); - if (authError) throw authError; - if (controller.signal.aborted) return; - if (!authData.session) { - window.location.assign("/login"); - return; - } - - const [nextAccount, modelCatalogResult] = await Promise.all([ + const [nextAccount, modelCatalogResult, sessionsPayload] = await Promise.all([ fetchAccount(controller.signal), fetchModelCatalog(controller.signal) .then((catalog) => ({ catalog, unavailable: false })) @@ -1272,50 +1277,30 @@ export default function Home() { if (caught instanceof Error && caught.name === "AbortError") throw caught; return { catalog: null, unavailable: true }; }), + fetchSessions(controller.signal), ]); const nextModelCatalog = modelCatalogResult.catalog; - const [profileResult, sessionsResult] = await Promise.all([ - supabase - .from("profiles") - .select("name,birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,latitude,longitude,timezone_id,timezone_offset,timezone_source") - .eq("id", nextAccount.user.id) - .abortSignal(controller.signal) - .maybeSingle(), - supabase - .from("chat_sessions") - .select("id,title,theme,model_id,messages,session_type,rectification_case_id,updated_at") - .abortSignal(controller.signal) - .order("updated_at", { ascending: false }), - ]); - - if (profileResult.error) throw profileResult.error; - if (sessionsResult.error) throw sessionsResult.error; - - const parsedSessions = readSessions(sessionsResult.data, nextModelCatalog); + const parsedSessions = readSessions(sessionsPayload, nextModelCatalog); let nextSessions = parsedSessions.sessions; if (nextSessions.length === 0) { if (controller.signal.aborted) return; const initialSession = createSession(nextModelCatalog?.defaultModelId ?? ""); - const { error } = await supabase - .from("chat_sessions") - .insert({ - id: initialSession.id, - user_id: nextAccount.user.id, + if (nextModelCatalog) { + await writeChatSession(initialSession.id, { title: initialSession.title, theme: initialSession.theme, - model_id: initialSession.modelId || null, + model_id: initialSession.modelId, messages: initialSession.messages, session_type: initialSession.sessionType, rectification_case_id: initialSession.rectificationCaseId, updated_at: new Date(initialSession.updatedAt).toISOString(), - }) - .abortSignal(controller.signal); - if (error) throw error; + }, "create"); + } nextSessions = [initialSession]; } if (controller.signal.aborted) return; - const nextProfile = readProfile(profileResult.data); + const nextProfile = readProfile(nextAccount.profile); setAccount(nextAccount); setModelCatalog(nextModelCatalog); setProfile(nextProfile); @@ -1332,14 +1317,14 @@ export default function Home() { setAccountError(""); if (nextModelCatalog && parsedSessions.fallbackSessionIds.length > 0) { - const { error } = await supabase - .from("chat_sessions") - .update({ model_id: nextModelCatalog.defaultModelId }) - .eq("user_id", nextAccount.user.id) - .in("id", parsedSessions.fallbackSessionIds) - .abortSignal(controller.signal); - if (error && !controller.signal.aborted) { - setComposerNotice("已在当前页面切换为默认模型,但云端同步失败;刷新后可能需要重新选择。"); + try { + await Promise.all(parsedSessions.fallbackSessionIds.map((sessionId) => + patchSessionModel(sessionId, nextModelCatalog.defaultModelId, controller.signal), + )); + } catch { + if (!controller.signal.aborted) { + setComposerNotice("已在当前页面切换为默认模型,但云端同步失败;刷新后可能需要重新选择。"); + } } } } catch (caught) { @@ -1617,7 +1602,7 @@ export default function Home() { setComposerNotice(""); if (nextSession?.sessionType === "birth_time_rectification") { setRectificationError(""); - if (nextSession.id !== rectificationSessionId || !rectificationInitialTurn) { + if (nextSession.id !== rectificationSessionId) { resumeRectificationSession.current(nextSession); } } @@ -1641,18 +1626,19 @@ export default function Home() { try { await modelPersistence.current.enqueue(nextSession.id, () => persistSessionModelSelection( - async ({ values, sessionId, userId: ownerId }) => { + async ({ values, sessionId }) => { if (process.env.NODE_ENV === "development" && uiPreview.current) { return { found: true, error: null }; } - const { data, error } = await createBrowserSupabaseClient() - .from("chat_sessions") - .update(values) - .eq("id", sessionId) - .eq("user_id", ownerId) - .select("id") - .maybeSingle(); - return { found: Boolean(data), error: error?.message ?? null }; + try { + await patchSessionModel(sessionId, values.model_id); + return { found: true, error: null }; + } catch (error) { + return { + found: false, + error: error instanceof Error ? error.message : "模型选择暂时无法同步到云端。", + }; + } }, userId, nextSession.id, @@ -2027,8 +2013,7 @@ export default function Home() { setSigningOut(true); setAccountError(""); try { - const { error } = await createBrowserSupabaseClient().auth.signOut(); - if (error) throw error; + await selfHostedOtpActions.signOut(); window.location.assign("/login"); } catch (caught) { const message = caught instanceof Error ? caught.message : "退出失败"; @@ -2079,236 +2064,42 @@ export default function Home() { ); } - function synchronizeRectificationQuestion( - turn: ConversationalRectificationTurn, - fallbackSession: ChatSession | undefined = activeSession, - ) { - if (!turn.pendingConsultationQuestion) return; - if (!rectificationQuestionHandoff.current.peek() && fallbackSession) { - rectificationQuestionHandoff.current.synchronizeDurableQuestion( - turn.pendingConsultationQuestion, - { sessionId: fallbackSession.id, theme: fallbackSession.theme }, - ); - } - setRectificationPendingQuestion(turn.pendingConsultationQuestion); - } - async function openBirthTimeRectification( pendingConsultationQuestion: string | null = null, sourceSessionOverride: ChatSession | null = null, ) { if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationOpenInFlight.current - || rectificationMutationPending - || rectificationContinuationInFlight.current) return; + || rectificationMutationPending || rectificationContinuationInFlight.current) return; const sourceSession = sourceSessionOverride ?? activeSession; if (!sourceSession) return; - const action = resolveRectificationCardAction({ - rectificationCase: account.rectificationCase, - hasConfirmedBirthTime: account.hasConfirmedBirthTime, - }); - const sourceBoundCaseId = sourceSession.sessionType === "birth_time_rectification" - ? sourceSession.rectificationCaseId - : null; - const accountResumeCase = action === "resume" ? account.rectificationCase : null; - const resumeTarget = sourceBoundCaseId - ? { - caseId: sourceBoundCaseId, - turnVersion: accountResumeCase?.caseId === sourceBoundCaseId - ? accountResumeCase.turnVersion - : 0, - } - : accountResumeCase; - const resumableSession = !sourceBoundCaseId && accountResumeCase - ? sessions.find((session) => session.sessionType === "birth_time_rectification" - && session.rectificationCaseId === accountResumeCase.caseId) - ?? sessions.find((session) => session.sessionType === "birth_time_rectification" - && session.rectificationCaseId === null) - ?? null - : null; - const canReuseSourceRectificationSession = sourceSession.sessionType === "birth_time_rectification" - && (sourceBoundCaseId !== null || accountResumeCase !== null); - const rectificationSession = canReuseSourceRectificationSession + const existing = sourceSession.sessionType === "birth_time_rectification" ? sourceSession - : resumableSession ?? createSession(modelCatalog.defaultModelId, "birth_time_rectification"); - const reusingRectificationSession = canReuseSourceRectificationSession - || resumableSession !== null; - const localHandoff = rectificationQuestionHandoff.current.peek(); - const requestedQuestion = pendingConsultationQuestion - ?? (reusingRectificationSession - ? null - : localHandoff?.question) - ?? null; + : sessions.find((session) => session.sessionType === "birth_time_rectification") ?? null; + const rectificationSession = existing ?? createSession(modelCatalog.defaultModelId, "birth_time_rectification"); + const requestedQuestion = pendingConsultationQuestion ?? rectificationQuestionHandoff.current.peek()?.question ?? null; + rectificationOpenInFlight.current = true; + setRectificationLoading(true); + setRectificationError(""); + setRectificationPendingQuestion(requestedQuestion); setDraft(""); setDraftTheme(null); setDraftEntrypoint(null); - setRectificationPendingQuestion(requestedQuestion); - setRectificationInitialTurn(null); - setRectificationOpeningAssistantText(""); - setRectificationError(""); - setRectificationLoading(true); - if (!reusingRectificationSession) { - setSessions((current) => [ - rectificationSession, - ...current.filter((session) => session.id !== rectificationSession.id), - ]); - } - if (sourceSession && sourceSession.id !== rectificationSession.id) { - setRectificationReturnSessionId(sourceSession.id); - } + if (sourceSession.id !== rectificationSession.id) setRectificationReturnSessionId(sourceSession.id); setRectificationSessionId(rectificationSession.id); activeSessionIdRef.current = rectificationSession.id; setActiveSessionId(rectificationSession.id); - try { - let turn: ConversationalRectificationTurn; - if (!resumeTarget) { - const durable = requestedQuestion !== null || localHandoff !== null - ? await durableRectificationQuestionHandoff.current.load() - : null; - if (durable && durable.status !== "consumed") { - turn = durable.turn; - } else { - try { - turn = await sendConversationalRectificationCommand({ - type: "start", - actionId: globalThis.crypto.randomUUID(), - modelId: rectificationSession.modelId, - pendingConsultationQuestion: requestedQuestion, - }, { - onNarrativeDelta(text) { - setRectificationOpeningAssistantText((current) => current + text); - }, - }); - } catch (error) { - // A stale account snapshot can make an existing unfinished case look - // like a fresh start. The database rejects that second case with a - // 409 to protect billing; refresh and resume the durable case instead. - if (!(error instanceof ConversationalRectificationRequestError) - || error.status !== 409) throw error; - const latest = await fetchAccount(); - if (!latest.rectificationCase) throw error; - setAccount(latest); - turn = await sendConversationalRectificationCommand({ - type: "resume", - caseId: latest.rectificationCase.caseId, - actionId: globalThis.crypto.randomUUID(), - turnVersion: latest.rectificationCase.turnVersion, - }, { - onNarrativeDelta(text) { - setRectificationOpeningAssistantText((current) => current + text); - }, - }); - } - } - } else { - let current = resumeTarget; - const canAttachQuestion = pendingConsultationQuestion - && accountResumeCase?.caseId === current.caseId; - if (canAttachQuestion) { - try { - turn = await durableRectificationQuestionHandoff.current.attach({ - caseId: current.caseId, - turnVersion: current.turnVersion, - question: pendingConsultationQuestion, - }); - } catch (error) { - if (!(error instanceof DurableRectificationHandoffError) - || error.status !== 409) throw error; - const latest = await fetchAccount(); - if (!latest.rectificationCase - || latest.rectificationCase.caseId !== current.caseId) throw error; - current = latest.rectificationCase; - setAccount(latest); - turn = await durableRectificationQuestionHandoff.current.attach({ - caseId: current.caseId, - turnVersion: current.turnVersion, - question: pendingConsultationQuestion, - }); - } - } else { - try { - turn = await sendConversationalRectificationCommand({ - type: "resume", - caseId: current.caseId, - actionId: globalThis.crypto.randomUUID(), - turnVersion: current.turnVersion, - }, { - onNarrativeDelta(text) { - setRectificationOpeningAssistantText((value) => value + text); - }, - }); - } catch (error) { - if (!(error instanceof ConversationalRectificationRequestError) - || error.status !== 409) throw error; - const latest = await fetchAccount(); - if (!latest.rectificationCase - || latest.rectificationCase.caseId !== current.caseId) throw error; - current = latest.rectificationCase; - setAccount(latest); - turn = await sendConversationalRectificationCommand({ - type: "resume", - caseId: current.caseId, - actionId: globalThis.crypto.randomUUID(), - turnVersion: current.turnVersion, - }, { - onNarrativeDelta(text) { - setRectificationOpeningAssistantText((value) => value + text); - }, - }); - } - } - } - const recoveredConversation = conversationalRectificationHistoryForTurn(turn); - const firstSessionMessages = recoveredConversation.length > 0 - ? recoveredConversation.map(({ role, text }) => ({ role, text })) - : !reusingRectificationSession && rectificationSession.messages.length === 0 - ? [{ role: "assistant" as const, text: turn.narrative.trim() }] - : rectificationSession.messages; - const boundSession = { - ...rectificationSession, - messages: firstSessionMessages, - rectificationCaseId: turn.caseId, - updatedAt: rectificationSession.rectificationCaseId === turn.caseId - && firstSessionMessages === rectificationSession.messages - ? rectificationSession.updatedAt - : timestamp(), - }; - setRectificationInitialTurn(turn); - setRectificationOpeningAssistantText(""); - synchronizeRectificationQuestion(turn, sourceSession); - setComposerNotice(""); - if (!reusingRectificationSession) { - setSessions((current) => [boundSession, ...current.filter((session) => session.id !== boundSession.id)]); - void rectificationPersistence.current.enqueue( - boundSession.id, - () => persistSession(boundSession, "create"), - ).catch(() => { - setComposerNotice("校正已经开始,但会话关联暂时未同步到云端。"); - }); - } else if (boundSession !== rectificationSession) { - updateSession(rectificationSession.id, () => boundSession); - void rectificationPersistence.current.enqueue( - boundSession.id, - () => persistSession(boundSession), - ).catch(() => { - setComposerNotice("校正已经开始,但会话关联暂时未同步到云端。"); - }); - } - } catch (caught) { - const message = caught instanceof Error - ? caught.message - : "生时校正暂时无法继续,请稍后重试。"; - setRectificationError(message); - setComposerNotice(message); - if (!reusingRectificationSession) { - setSessions((current) => current.filter((session) => session.id !== rectificationSession.id)); - setRectificationSessionId(null); - if (sourceSession) { - activeSessionIdRef.current = sourceSession.id; - setActiveSessionId(sourceSession.id); - } + try { + if (!existing) { + setSessions((current) => [rectificationSession, ...current.filter((session) => session.id !== rectificationSession.id)]); + await rectificationPersistence.current.enqueue( + rectificationSession.id, + () => persistSession(rectificationSession, "create"), + ); } + } catch { + setComposerNotice("生时校正已打开,但会话列表暂时未同步到云端。"); } finally { rectificationOpenInFlight.current = false; setRectificationLoading(false); @@ -2319,78 +2110,6 @@ export default function Home() { void openBirthTimeRectification(null, session); }; - function handleConversationalRectificationTurn( - turn: ConversationalRectificationTurn, - messages: readonly ConversationalRectificationMessage[], - ) { - const requestIdentity = accountRefreshGuard.current.begin(); - setRectificationInitialTurn(turn); - synchronizeRectificationQuestion(turn); - if (activeSession?.sessionType === "birth_time_rectification") { - const boundSession = { - ...activeSession, - messages: durableRectificationMessages(messages), - rectificationCaseId: turn.caseId, - updatedAt: timestamp(), - }; - updateSession(activeSession.id, () => boundSession); - void rectificationPersistence.current.enqueue( - boundSession.id, - () => persistSession(boundSession), - ).catch(() => { - setComposerNotice("校正进度已经保留,但会话关联暂时未同步到云端。"); - }); - } - setAccount((current) => current ? { - ...current, - hasConfirmedBirthTime: current.hasConfirmedBirthTime - || (turn.status === "completed" && turn.candidate.status === "confirmed"), - rectificationCase: ["active", "paused", "confirming"].includes(turn.status) - ? { - caseId: turn.caseId, - journeyProtocol: "conversational-evidence-v3", - status: turn.status, - turnVersion: turn.turnVersion, - isRevision: current.rectificationCase?.caseId === turn.caseId - ? current.rectificationCase.isRevision - : current.hasConfirmedBirthTime, - preservesActiveTime: current.rectificationCase?.caseId === turn.caseId - ? current.rectificationCase.preservesActiveTime - : current.hasConfirmedBirthTime, - } - : current.rectificationCase?.caseId === turn.caseId - ? null - : current.rectificationCase, - } : current); - if (turn.status === "completed" - && turn.candidate.status === "confirmed" - && turn.candidate.representativeTime) { - setProfile((current) => ({ - ...current, - time: turn.candidate.representativeTime ?? current.time, - birthTimeStatus: "confirmed", - rectificationCaseId: turn.caseId, - })); - setProfileDraft((current) => ({ - ...current, - time: turn.candidate.representativeTime ?? current.time, - birthTimeStatus: "confirmed", - rectificationCaseId: turn.caseId, - })); - } - void fetchAccount() - .then((latest) => { - if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return; - setAccount((current) => { - if (turn.status === "completed" || turn.status === "abandoned") return latest; - if (latest.rectificationCase?.caseId !== turn.caseId - || latest.rectificationCase.turnVersion < turn.turnVersion) return current; - return latest; - }); - }) - .catch(() => undefined); - } - async function draftSynastryQuestionFromChart(record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) { if (record.role !== "other") return; if (synastryPendingId) return; @@ -2748,7 +2467,8 @@ export default function Home() { modelId: currentSession.modelId, name: profile.name, consultationMode: consultationRoute.mode, - ...(consultationRoute.mode === "general_no_birth_time" ? {} : { + ...(consultationRoute.mode === "general_no_birth_time" + || rectificationHandoff?.protocol === "rectification-evidence-v4" ? {} : { entrypoint: entrypoint ?? undefined, year, month, @@ -2902,13 +2622,10 @@ export default function Home() { } - async function continueRectificationOriginalQuestion(question: string) { + async function continueRectificationOriginalQuestion(continuation: RectificationV4Continuation) { + const question = continuation.question; if (rectificationContinuationInFlight.current || rectificationMutationPending || rectificationLoading || !activeSession || !account) return; - const confirmedTurn = rectificationInitialTurn; - if (!confirmedTurn || confirmedTurn.status !== "completed" - || confirmedTurn.pendingConsultationQuestion !== question - || !confirmedTurn.actions.includes("continue_original_question")) return; if (account.credits <= 0) { openAccountDialog("redeem", creditTrigger.current); return; @@ -2935,9 +2652,9 @@ export default function Home() { setRectificationContinuationPending(true); setRectificationError(""); try { - const durableClaim = await durableRectificationQuestionHandoff.current.claim({ - caseId: confirmedTurn.caseId, - turnVersion: confirmedTurn.turnVersion, + const durableClaim = await claimRectificationV4Handoff({ + caseId: continuation.caseId, + caseVersion: continuation.caseVersion, question, }); if (durableClaim.status === "in_progress") { @@ -2972,8 +2689,9 @@ export default function Home() { null, context.sessionId, { + protocol: "rectification-evidence-v4", caseId: durableClaim.caseId, - turnVersion: durableClaim.turnVersion, + caseVersion: durableClaim.caseVersion, claimActionId: durableClaim.claimActionId, requestId: durableClaim.requestId, }, @@ -2982,9 +2700,7 @@ export default function Home() { ); if (completed) { setRectificationPendingQuestion(null); - setComposerNotice(confirmedTurn.candidate.status === "confirmed" - ? "已使用新确认时间继续回答原问题。" - : "已保留候选范围,并按未确认出生分钟的边界继续回答原问题。"); + setComposerNotice("已按候选范围边界继续回答原问题。"); } else { setComposerNotice("原问题仍保留,可再次点击继续回答。"); } @@ -3162,8 +2878,9 @@ export default function Home() { -
- {!rectificationSurfaceOpen && (!activeSession?.messages.length ? ( + {!rectificationSurfaceOpen && ( +
+ {!activeSession?.messages.length ? (
{!profileComplete ? ( <> @@ -3286,7 +3003,7 @@ export default function Home() {
- {rectificationCardAction === "resume" - ? "继续同一案例,不重复收费。" - : `固定费用 ${account.rectificationPriceCredits} 点;首轮有效分析后收取。`} + 进度会自动保存;结果只作为候选范围,不会改写已填报出生时间。
@@ -3347,30 +3062,21 @@ export default function Home() { {activeError &&

{activeError}

}
- ))} - {rectificationSurfaceOpen && (!visibleRectificationTurn && rectificationError ? ( -
-

{rectificationError}

- -
- ) : ( - void selectSessionModel(modelId)} - pendingConsultationQuestion={rectificationPendingQuestion} - continuationPending={rectificationContinuationPending} - onPendingChange={setRectificationMutationPending} - onTurn={handleConversationalRectificationTurn} - onContinueOriginalQuestion={(question) => void continueRectificationOriginalQuestion(question)} - /> - ))} -
+ )} +
+ )} + + {rectificationSurfaceOpen && ( + void selectSessionModel(modelId)} + pendingConsultationQuestion={rectificationPendingQuestion} + continuationPending={rectificationContinuationPending} + onPendingChange={setRectificationMutationPending} + onContinueOriginalQuestion={(continuation) => void continueRectificationOriginalQuestion(continuation)} + /> + )} {!rectificationSurfaceOpen &&
{activeSuggestions.length > 0 && ( diff --git a/frontend/src/components/admin/admin-app.tsx b/frontend/src/components/admin/admin-app.tsx new file mode 100644 index 00000000..0f6620b1 --- /dev/null +++ b/frontend/src/components/admin/admin-app.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { + AuditOutlined, + GiftOutlined, + MessageOutlined, + TeamOutlined, + TransactionOutlined, +} from "@ant-design/icons"; +import { Authenticated, Refine } from "@refinedev/core"; +import { ErrorComponent, ThemedLayout, useNotificationProvider } from "@refinedev/antd"; +import routerProvider from "@refinedev/nextjs-router"; +import { App as AntdApp, ConfigProvider, Spin, theme } from "antd"; +import type { ReactNode } from "react"; + +import { + adminAccessControlProvider, + adminAuthProvider, + adminDataProvider, +} from "@/lib/admin/providers"; + +export function AdminApp({ children }: { children: ReactNode }) { + const notificationProvider = useNotificationProvider(); + return ( + + + } }, + { 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..da2ef5d8 --- /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" | "viewer">({}); + 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 ? : viewer 只读} + /> + + 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/resource-table.tsx b/frontend/src/components/admin/resource-table.tsx new file mode 100644 index 00000000..95e596b5 --- /dev/null +++ b/frontend/src/components/admin/resource-table.tsx @@ -0,0 +1,65 @@ +"use client"; + +import type { BaseRecord } from "@refinedev/core"; +import { List, useTable } from "@refinedev/antd"; +import { Alert, Empty, Form, Input, Select, Space, Table, type TableColumnsType } from "antd"; +import type { ReactNode } from "react"; + +export type ResourceFilterOption = { label: string; value: string }; + +export function ResourceTable({ + resource, + title, + columns, + statusOptions, + extra, +}: { + resource: string; + title: string; + columns: TableColumnsType; + statusOptions?: ResourceFilterOption[]; + extra?: ReactNode; +}) { + const { tableProps, searchFormProps, tableQuery } = useTable({ + resource, + syncWithLocation: true, + pagination: { pageSize: 20 }, + onSearch(values) { + return [ + { field: "q", operator: "contains", value: values.q }, + { field: "status", operator: "eq", value: values.status }, + ]; + }, + }); + + const error = tableQuery.error; + return ( + + +
+ + {statusOptions && ( + +