diff --git a/.dockerignore b/.dockerignore index 61b4cbf..6c65cef 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,3 +11,8 @@ node_modules .env.local .env.*.local *.log + +# Deployment templates and operator documentation are not runtime inputs. +deploy +docs +.project-docs diff --git a/.env.example b/.env.example index 37edab4..63e3d55 100644 --- a/.env.example +++ b/.env.example @@ -35,11 +35,27 @@ ZHINIAN_WORKER_POLL_INTERVAL_MS=5000 ZHINIAN_WORKER_LOCK_TIMEOUT_MS=300000 ZHINIAN_WORKER_RETRY_BASE_MS=10000 ZHINIAN_WORKER_RETRY_MAX_MS=300000 +ZHINIAN_WORKER_REQUEST_TIMEOUT_MS=120000 -# Supabase SaaS data layer. If empty, the app uses .runtime/data/web-app-state.json. -NEXT_PUBLIC_SUPABASE_URL= -NEXT_PUBLIC_SUPABASE_ANON_KEY= -SUPABASE_SERVICE_ROLE_KEY= +# Data layer. Use local only for development/single-instance operation. +# Production must explicitly select postgres; it never falls back to container-local JSON. +ZHINIAN_DATA_BACKEND=local +DATABASE_URL= +# Migration runner only: PostgreSQL role used by the Web DATABASE_URL. +DATABASE_APP_ROLE= +DATABASE_SSL_MODE=disable +# For RDS SSL, set verify-full and mount the downloaded CA certificate at this path. +DATABASE_CA_CERT_PATH= +DATABASE_POOL_MAX=10 +DATABASE_IDLE_TIMEOUT_MS=30000 +DATABASE_CONNECTION_TIMEOUT_MS=5000 +DATABASE_STATEMENT_TIMEOUT_MS=30000 +DATABASE_APPLICATION_NAME=zhinian-web + +# Obsolete: the direct PostgreSQL adapter does not use Supabase/PostgREST variables. +# NEXT_PUBLIC_SUPABASE_URL= +# NEXT_PUBLIC_SUPABASE_ANON_KEY= +# SUPABASE_SERVICE_ROLE_KEY= # Image creation engine: jimeng, evolink, or bailian. IMAGE_GENERATE_ENGINE=jimeng diff --git a/.project-docs/30-worklog/tasks/20260812-rds-postgres-adapter-7f2c1a.md b/.project-docs/30-worklog/tasks/20260812-rds-postgres-adapter-7f2c1a.md new file mode 100644 index 0000000..ad52848 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260812-rds-postgres-adapter-7f2c1a.md @@ -0,0 +1,63 @@ +# Task: Add direct PostgreSQL support for Alibaba Cloud RDS + +## Identity + +- Task ID: 20260812-rds-postgres-adapter-7f2c1a +- Mode: Feature +- Branch: codex/rds-postgres-adapter-7f2c1a +- Worktree: D:\Datas\OthersProjects\NianAIGC-rds-postgres-7f2c1a +- Base commit: 0bcb149fad8e4131fdf960cc61106ac5912cf34f +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Replace the Supabase/PostgREST persistence path in `data-store`, `account-store`, and `billing-store` with a shared server-only PostgreSQL adapter backed by `pg`. +- Migrate the bootstrap/import scripts to the same PostgreSQL configuration contract while preserving their existing public behavior. +- Add a versioned PostgreSQL migration runner and an RDS-compatible baseline schema that preserves atomic job claiming and wallet posting. +- Add fail-closed production backend selection, database readiness, Docker runtime support, and ACK deployment manifests for Web, Worker, migration Job, Service, Ingress, ConfigMap, and Secret templates. +- Update environment and deployment documentation for Alibaba Cloud ACK + RDS PostgreSQL. +- Preserve the local JSON backend for development and existing unit tests. + +## Intent And Constraints + +- `ZHINIAN_DATA_BACKEND=postgres` must never silently fall back to container-local JSON when database configuration or connectivity is missing. +- Keep store exports and route/component callers stable; isolate pooling, TLS, timeouts, transactions, and SQL execution behind one deep server-only module. +- Keep the Worker as an HTTP poller; only the Web workload connects directly to PostgreSQL in the current architecture. +- Retain the PostgreSQL functions that provide `FOR UPDATE SKIP LOCKED` job claiming and atomic wallet ledger posting. +- Use parameterized SQL and explicit transactions where an operation crosses multiple statements. +- Inject credentials through Kubernetes Secrets; do not place RDS passwords or application secrets in images or ConfigMaps. +- Use TLS verification for RDS when SSL is enabled; do not introduce `rejectUnauthorized: false` as a production shortcut. +- Do not modify canonical `.project-docs` files in feature mode; record promotion candidates here for later integration. +- No live RDS credentials or ACK cluster access are available, so actual external connectivity and rollout remain explicitly unverified. + +## Outcome + +- Replaced the Supabase/PostgREST runtime path with a shared server-only `pg` adapter across data, account, and billing persistence while preserving explicit local JSON mode for development and tests. +- Added fail-closed production backend selection, verified-CA TLS configuration, pooled queries/transactions, full schema-and-privilege readiness, versioned/checksummed/advisory-locked migrations, and constrained application-role grants. +- Migrated bootstrap/import tooling, preserved database-side atomic job claim and wallet posting, and hardened authentication/password and wallet idempotency concurrency paths. +- Added Docker migration assets and eight ACK manifests covering namespace, configuration, secret templates, migration Job, Web, Worker, Service isolation, and Ingress, with updated Chinese/English deployment guidance. +- Final independent Sol review returned `PASS` with no blocking findings. + +## Verification + +- `npm ci --ignore-scripts` — passed. +- `pnpm install --lockfile-only --frozen-lockfile` — passed; npm/pnpm Next and React resolutions remain aligned. +- `npm test -- --run` — passed, 31 files / 119 tests. +- `npx tsc --noEmit --pretty false --incremental false` — passed. +- `npm run build` — passed with `/api/ready` in the production route output. +- `npm run deploy:check` — passed static assertions for all 8 ACK manifests. +- PostgreSQL/account/worker script `node --check` commands — passed. +- `git diff --check` and project documentation drift checks — passed. +- Not externally verified: no live RDS credentials, ACK kubeconfig, `psql`, or available Docker daemon were present, so real migration execution, database concurrency, image startup, and cluster rollout remain deployment checks. + +## Follow-ups + +- Before production cutover, back up the target database, provision separate migration/application roles, mount the Alibaba Cloud RDS CA, verify the VPC/internal endpoint and whitelist, and run the one-shot migration Job before Web rollout. +- Keep Web at one replica until generated assets are externalized to OSS/shared object storage; PostgreSQL alone does not make container-local files multi-replica safe. +- Move the image to a non-root runtime user in a later hardening change after writable runtime paths and ownership are explicitly defined. + +## Promotion Candidates + +- Promote the explicit `ZHINIAN_DATA_BACKEND` fail-closed contract, shared server-only PostgreSQL boundary, and versioned migration ownership model into canonical architecture/deployment memory after this feature is integrated. +- Record that the Worker remains an HTTP-only poller and does not require RDS credentials in the current architecture. diff --git a/Dockerfile b/Dockerfile index ac04fed..718f928 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,7 @@ COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/.next ./.next COPY --from=builder /app/public ./public COPY --from=builder /app/scripts ./scripts +COPY --from=builder /app/database ./database COPY --from=builder /app/next.config.ts ./next.config.ts RUN mkdir -p /app/.runtime/data /app/.runtime/uploads /app/.runtime/generated-results diff --git a/README.md b/README.md index 81c1a46..9c6d344 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ [完整中文说明](./README.zh-CN.md) +Production deployment on Alibaba Cloud ACK uses direct RDS PostgreSQL. See +[`docs/DEPLOYMENT.md`](./docs/DEPLOYMENT.md) and the templates in [`deploy/ack`](./deploy/ack). +Use `/api/health` for process liveness and `/api/ready` for database-backed readiness. + 这是 `智念AIGC平台` 的 Web 极简 MVP。当前产品只保留核心闭环:统一创作图片/视频、在任务模块查看详情与下载结果,以及必要设置。 运维部署与 API 对接: @@ -63,11 +67,11 @@ Docker 部署默认使用 `docker-compose.yml` 同时启动 Web 服务和 `zhini ## 平台账号体系 -平台不再依赖外部 OAuth2/SSO。浏览器用户统一使用手机号和密码登录,账号数据由平台自己管理:生产环境使用 Supabase/Postgres,本地开发使用 `.runtime/data/platform-accounts.json`。 +平台不再依赖外部 OAuth2/SSO。浏览器用户统一使用手机号和密码登录,账号数据由平台自己管理:生产环境直连 PostgreSQL,本地开发使用 `.runtime/data/platform-accounts.json`。 平台支持超级管理员、组织管理员和普通用户三层角色。组织管理员只能管理本组织普通用户和查看组织汇总用量,不能查看日志、系统配置或管理组织生命周期;普通用户只能访问自己的创作、素材、任务和账户安全。 -核心配置:`ZHINIAN_AUTH_REQUIRED`、`ZHINIAN_AUTH_SESSION_SECRET`、`NEXT_PUBLIC_SUPABASE_URL`、`SUPABASE_SERVICE_ROLE_KEY`、`ZHINIAN_DATA_DIR`。 +核心配置:`ZHINIAN_AUTH_REQUIRED`、`ZHINIAN_AUTH_SESSION_SECRET`、`ZHINIAN_DATA_BACKEND`、`DATABASE_URL`、`ZHINIAN_DATA_DIR`。 首次部署时执行一次: @@ -229,21 +233,21 @@ cp .env.example .env.local - `SEEDANCE_RESOLUTION`:支持 `480p`、`720p`、`1080p`、`4k`;Seedance 2.0 fast 不支持 `1080p` - `SEEDANCE_MOCK` - `ALI_OSS_*`:用于上传素材和生成结果转存 -- `NEXT_PUBLIC_SUPABASE_URL` -- `NEXT_PUBLIC_SUPABASE_ANON_KEY` -- `SUPABASE_SERVICE_ROLE_KEY` +- `ZHINIAN_DATA_BACKEND`:生产使用 `postgres`,开发可使用 `local` +- `DATABASE_URL`:仅服务端读取的 PostgreSQL 连接串 +- `DATABASE_SSL_MODE` / `DATABASE_CA_CERT_PATH`:RDS TLS 验证配置 -如果 Supabase 未配置,应用会使用 `.runtime/data/web-app-state.json` 做本地开发数据层。如果 OSS 未配置,上传和 mock 结果会保存到 `.runtime/uploads` 和 `.runtime/generated-results`,并通过 Web 路由提供访问。 +当 `ZHINIAN_DATA_BACKEND=local` 时,应用使用 `.runtime/data/web-app-state.json` 作为单实例开发数据层。生产 `postgres` 模式缺少连接配置会直接失败,不会静默写入本地 JSON。如果 OSS 未配置,上传和 mock 结果会保存到 `.runtime/uploads` 和 `.runtime/generated-results`,并通过 Web 路由提供访问。 ## 数据库 -Supabase/Postgres 表结构在: +版本化 PostgreSQL 迁移在: ```text -supabase/schema.sql +database/migrations/ ``` -升级已启用 Supabase 的部署时,先在 Supabase SQL Editor 重新执行该幂等脚本。它会迁移用量快照字段、按 `job_id` 去重历史记录,并解除任务删除对用量记录的级联删除。 +首次部署和每次发布均先执行 `npm run db:migrate`。迁移器使用 advisory lock、版本记录和校验和,避免多个发布任务并发迁移或已应用脚本被静默改写。 当前仍保留必要数据表,供上传、生成任务和用量记录使用: @@ -256,7 +260,7 @@ supabase/schema.sql ## 任务管理与开放 API -平台支持服务端任务管理:页面和 `/api/v1` 创建任务后只入队,Worker 统一提交供应商、轮询、转存结果、失败重试和 Webhook 回调。生产部署建议配置 Supabase/Postgres;本地开发可继续使用 `.runtime/data/web-app-state.json`。 +平台支持服务端任务管理:页面和 `/api/v1` 创建任务后只入队,Worker 统一提交供应商、轮询、转存结果、失败重试和 Webhook 回调。生产部署使用 PostgreSQL;本地开发可继续使用 `.runtime/data/web-app-state.json`。 开放 API 使用 API Key: diff --git a/README.zh-CN.md b/README.zh-CN.md index 2ad0ef9..4f211b8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,5 +1,9 @@ # 智念AIGC平台中文说明 +阿里云 ACK 生产部署使用直连 RDS PostgreSQL。配置、迁移和发布步骤见 +[`docs/DEPLOYMENT.md`](./docs/DEPLOYMENT.md) 与 [`deploy/ack`](./deploy/ack) 模板。 +`/api/health` 仅检查进程存活,`/api/ready` 检查数据库就绪状态。 + 智念AIGC平台是一个面向图片与视频创作的 Web 工作台。当前版本聚焦核心生产链路:提示词创作、素材上传、图片生成、视频生成、任务详情与结果下载和接口配置。 ## 运维与对接文档 @@ -29,7 +33,7 @@ - React 19 - TypeScript - GSAP -- Supabase/Postgres 可选 +- PostgreSQL(生产)/本地 JSON(开发) - Aliyun OSS 可选 - Vitest @@ -147,7 +151,7 @@ npm run info ## 平台账号体系 -平台不再依赖外部 OAuth2/SSO。所有浏览器用户统一使用手机号和密码登录,账号数据由平台自己管理:生产环境使用 Supabase/Postgres,本地开发使用 `.runtime/data/platform-accounts.json`。 +平台不再依赖外部 OAuth2/SSO。所有浏览器用户统一使用手机号和密码登录,账号数据由平台自己管理:生产环境直连 PostgreSQL,本地开发使用 `.runtime/data/platform-accounts.json`。 角色分为超级管理员、组织管理员和普通用户。组织管理员只能管理本组织普通用户和查看组织汇总用量,不能查看日志、系统配置或管理组织生命周期;普通用户只能访问自己的创作、素材、任务和账户安全。 @@ -159,8 +163,8 @@ npm run info | `ZHINIAN_AUTH_SESSION_SECRET` | 长随机字符串,用于签名 HttpOnly 会话 Cookie | | `ZHINIAN_BILLING_REQUIRED` | 真实任务计费开关,默认启用;停用时真实任务免计费 | | `ZHINIAN_BILLING_ACCOUNT_*` | 成员线下转账时展示的对公账户名称、开户行、银行账号和对接信息 | -| `NEXT_PUBLIC_SUPABASE_URL` | 生产 Supabase URL | -| `SUPABASE_SERVICE_ROLE_KEY` | 服务端 Supabase Service Role Key | +| `ZHINIAN_DATA_BACKEND` | 生产设为 `postgres`,开发可设为 `local` | +| `DATABASE_URL` | 仅服务端使用的 PostgreSQL 连接串 | | `ZHINIAN_DATA_DIR` | 本地账号 JSON 数据目录,可选 | 首次部署时执行一次: @@ -177,7 +181,7 @@ npm run bootstrap:admin -- --phone 13800138000 --password '请替换为强密码 ## 组织账号管理 -组织、账号、角色、停用、密码重置和归档均由平台本地接口处理,不再调用外部组织服务。生产部署前请先在 Supabase SQL Editor 执行幂等脚本 [`supabase/schema.sql`](./supabase/schema.sql)。 +组织、账号、角色、停用、密码重置和归档均由平台本地接口处理,不再调用外部组织服务。生产部署前请先运行 `npm run db:migrate` 应用 [`database/migrations`](./database/migrations) 中的版本化迁移。 ## 账号、组织用量与计费 @@ -208,7 +212,7 @@ npm run bootstrap:admin -- --phone 13800138000 --password '请替换为强密码 其中 EvoLink 按固定 `1 USD = 7.20 CNY` 换算,并在价格目录中列出质量、分辨率、画面比例和参考图数量档位;参数化报价按基础成本乘以所选档位系数,组合倍率取所选档位中的最高倍率。即梦 4.6 官方计费说明要求以控制台实时价格为准,因此该条目是平台维护的参考基准。超管只在价格目录中调整上浮倍率,标准成本、参数档案和规则状态由平台维护;来源链接和定价口径会随规则保留。 -使用 Supabase/Postgres 时,升级前必须在 Supabase SQL Editor 重新执行 [`supabase/schema.sql`](./supabase/schema.sql)。脚本是幂等的,会为任务补充用量快照字段、将历史用量按 `job_id` 去重,并解除删除任务时对用量记录的级联删除。未配置 Supabase 时,本地 JSON 数据会在读取时按相同口径兼容旧记录。 +使用 PostgreSQL 时,升级前必须执行 `npm run db:migrate`。迁移器会串行应用尚未执行的版本,并拒绝校验和发生变化的已应用脚本。`local` 模式下,本地 JSON 数据会在读取时按相同口径兼容旧记录。 ## 引擎说明 @@ -322,11 +326,11 @@ cp .env.example .env.local | `SEEDANCE_DURATION` | 默认视频秒数 | | `SEEDANCE_RESOLUTION` | 默认视频分辨率 | | `ALI_OSS_*` | 上传素材和生成结果转存配置 | -| `NEXT_PUBLIC_SUPABASE_URL` | Supabase URL | -| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase 匿名 Key | -| `SUPABASE_SERVICE_ROLE_KEY` | Supabase 服务端 Key | +| `ZHINIAN_DATA_BACKEND` | `postgres` 或 `local` | +| `DATABASE_URL` | PostgreSQL 连接串(仅放 Secret) | +| `DATABASE_SSL_MODE` / `DATABASE_CA_CERT_PATH` | RDS TLS 验证配置 | -未配置 Supabase 时,应用会使用 `.runtime/data/web-app-state.json` 作为本地开发数据层。未配置 OSS 时,上传和生成结果会写入 `.runtime/uploads` 与 `.runtime/generated-results`。 +`ZHINIAN_DATA_BACKEND=local` 时,应用使用 `.runtime/data/web-app-state.json` 作为单实例开发数据层;生产 `postgres` 模式配置错误会直接失败。未配置 OSS 时,上传和生成结果会写入 `.runtime/uploads` 与 `.runtime/generated-results`。 ## 项目结构 @@ -337,7 +341,7 @@ lib/ 业务逻辑、服务端适配器、接口客户端 lib/ui/motion.ts GSAP 动效工具层 public/logo/ 品牌 Logo scripts/ 启动、健康检查与信息脚本 -supabase/schema.sql 数据库结构 +database/migrations/ 版本化 PostgreSQL 迁移 tests/ Vitest 测试 Dockerfile Docker 镜像构建 docker-compose.yml 服务器部署编排 diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 3ce1833..ee3b24e 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -5,6 +5,7 @@ import { getVisibleImageCapabilities } from "@/lib/jimeng/capabilities"; import { shouldMockVisualApi } from "@/lib/volcengine/visual-client"; import { getSeedanceConfig, shouldMockSeedance } from "@/lib/seedance/client"; import { getBailianConfig, shouldMockBailian } from "@/lib/bailian/client"; +import { getDatabaseStatus } from "@/lib/server/database"; export const runtime = "nodejs"; @@ -12,6 +13,12 @@ export async function GET() { const evolink = getEvolinkImageSettings(); const auth = getAuthRuntimeConfig(); const bailian = getBailianConfig(); + let database: { backend: "local" | "postgres" | "invalid"; configured: boolean }; + try { + database = getDatabaseStatus(); + } catch { + database = { backend: "invalid", configured: false }; + } return jsonOk({ ok: true, appId: "zhinian-web-studio", @@ -21,6 +28,7 @@ export async function GET() { seedanceMode: shouldMockSeedance() ? "mock" : "seedance", bailianMode: shouldMockBailian() ? "mock" : bailian.apiKey ? "bailian" : "missing", authMode: authConfigSummary(auth), + database, capabilities: [ ...getVisibleImageCapabilities().map((capability) => { const engine = getEffectiveImageEngine(capability.id); diff --git a/app/api/ready/route.ts b/app/api/ready/route.ts new file mode 100644 index 0000000..aa5ffa2 --- /dev/null +++ b/app/api/ready/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { checkDatabaseReadiness, getDatabaseStatus } from "@/lib/server/database"; + +export const runtime = "nodejs"; + +const READINESS_TIMEOUT_MS = 3_000; + +export async function GET() { + let database: { backend: "local" | "postgres" | "invalid"; configured: boolean }; + try { + database = getDatabaseStatus(); + } catch { + database = { backend: "invalid", configured: false }; + return NextResponse.json({ ok: false, database }, { status: 503 }); + } + + let timeout: ReturnType | undefined; + try { + await Promise.race([ + checkDatabaseReadiness(), + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error("Database readiness timed out")), READINESS_TIMEOUT_MS); + }) + ]); + return NextResponse.json({ ok: true, database }); + } catch { + return NextResponse.json({ ok: false, database }, { status: 503 }); + } finally { + if (timeout) clearTimeout(timeout); + } +} diff --git a/app/billing/error.tsx b/app/billing/error.tsx index 5be04d1..8e0be3a 100644 --- a/app/billing/error.tsx +++ b/app/billing/error.tsx @@ -12,7 +12,7 @@ export default function BillingError({ reset }: { error: Error & { digest?: stri
计费中心

计费服务暂时不可用

-

请先刷新页面。如果平台使用 Supabase,请确认已在 SQL Editor 执行最新的 supabase/schema.sql

+

请先刷新页面。如果问题持续,请检查服务端日志,并确认已通过 npm run db:migrate 完成数据库迁移。

diff --git a/components/billing-manager.tsx b/components/billing-manager.tsx index bf02935..a8dfd53 100644 --- a/components/billing-manager.tsx +++ b/components/billing-manager.tsx @@ -317,14 +317,14 @@ export function BillingManager({ isSuperAdmin }: { isSuperAdmin: boolean }) { async function readApiPayload>(response: Response): Promise { const text = await response.text(); if (!text.trim()) { - return { error: response.status >= 500 ? "计费服务暂时不可用,请检查服务端日志和 Supabase 计费表结构。" : "服务器未返回有效内容。" } as T & { error?: string }; + return { error: response.status >= 500 ? "计费服务暂时不可用,请检查服务端日志和 PostgreSQL 迁移状态。" : "服务器未返回有效内容。" } as T & { error?: string }; } try { return JSON.parse(text) as T & { error?: string }; } catch { return { error: response.status >= 500 - ? "计费服务返回了服务器错误,请检查服务端日志;若使用 Supabase,请先执行 supabase/schema.sql。" + ? "计费服务返回了服务器错误,请检查服务端日志,并确认已执行 npm run db:migrate。" : `服务器返回了无效响应(HTTP ${response.status})。` } as T & { error?: string }; } diff --git a/database/migrations/0001_initial_schema.sql b/database/migrations/0001_initial_schema.sql new file mode 100644 index 0000000..4b007d8 --- /dev/null +++ b/database/migrations/0001_initial_schema.sql @@ -0,0 +1,467 @@ +create table if not exists assets ( + id text primary key, + owner_id text not null, + kind text not null, + name text not null, + url text not null, + storage_path text, + source text not null, + tags text[] not null default '{}', + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists generation_jobs ( + id text primary key, + owner_id text not null, + external_client_id text, + capability text not null, + provider text not null, + req_key text not null, + status text not null, + prompt text, + input_asset_ids text[] not null default '{}', + input_urls text[] not null default '{}', + output_asset_ids text[] not null default '{}', + provider_task_id text, + request_payload jsonb not null default '{}'::jsonb, + response_payload jsonb, + error jsonb, + retry_of text, + idempotency_key text, + idempotency_fingerprint text, + priority integer not null default 0, + attempts integer not null default 0, + max_attempts integer not null default 3, + scheduled_at timestamptz not null default now(), + locked_at timestamptz, + locked_by text, + started_at timestamptz, + completed_at timestamptz, + webhook_url text, + webhook_attempts integer not null default 0, + webhook_last_status jsonb, + usage_context jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +alter table generation_jobs add column if not exists external_client_id text; +alter table generation_jobs add column if not exists idempotency_key text; +alter table generation_jobs add column if not exists idempotency_fingerprint text; +alter table generation_jobs add column if not exists priority integer not null default 0; +alter table generation_jobs add column if not exists attempts integer not null default 0; +alter table generation_jobs add column if not exists max_attempts integer not null default 3; +alter table generation_jobs add column if not exists scheduled_at timestamptz not null default now(); +alter table generation_jobs add column if not exists locked_at timestamptz; +alter table generation_jobs add column if not exists locked_by text; +alter table generation_jobs add column if not exists started_at timestamptz; +alter table generation_jobs add column if not exists completed_at timestamptz; +alter table generation_jobs add column if not exists webhook_url text; +alter table generation_jobs add column if not exists webhook_attempts integer not null default 0; +alter table generation_jobs add column if not exists webhook_last_status jsonb; +alter table generation_jobs add column if not exists usage_context jsonb; + +create table if not exists usage_events ( + id text primary key, + owner_id text not null, + job_id text not null, + source text not null default 'platform', + capability text not null, + provider text, + req_key text, + account_username text, + account_display_name text, + tenant_id text, + organization_id text, + organization_name text, + quantity integer not null default 1, + estimated_unit text not null default 'job', + created_at timestamptz not null default now() +); + +alter table usage_events add column if not exists source text; +alter table usage_events add column if not exists provider text; +alter table usage_events add column if not exists req_key text; +alter table usage_events add column if not exists account_username text; +alter table usage_events add column if not exists account_display_name text; +alter table usage_events add column if not exists tenant_id text; +alter table usage_events add column if not exists organization_id text; +alter table usage_events add column if not exists organization_name text; +alter table usage_events add column if not exists quantity integer not null default 1; +alter table usage_events add column if not exists estimated_unit text not null default 'job'; + +update usage_events as usage +set source = case + when jobs.external_client_id is not null or usage.owner_id like 'api:%' then 'api' + else 'platform' + end, + provider = coalesce(usage.provider, jobs.provider), + req_key = coalesce(usage.req_key, jobs.req_key), + quantity = 1, + estimated_unit = 'job' +from generation_jobs as jobs +where jobs.id = usage.job_id; + +update usage_events +set source = case when owner_id like 'api:%' then 'api' else 'platform' end, + quantity = 1, + estimated_unit = 'job' +where source is null; + +alter table usage_events alter column source set default 'platform'; +alter table usage_events alter column source set not null; +alter table usage_events alter column estimated_unit set default 'job'; +alter table usage_events drop constraint if exists usage_events_job_id_fkey; + +create table if not exists projects ( + id text primary key, + owner_id text not null, + name text not null, + brief text not null default '', + type text not null default 'custom', + asset_ids text[] not null default '{}', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists image_templates ( + id text primary key, + owner_id text not null, + name text not null, + description text, + prompt text not null, + preview_image_url text, + settings jsonb not null default '{}'::jsonb, + sort_order integer not null default 0, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists assets_owner_created_idx on assets(owner_id, created_at desc); +create index if not exists generation_jobs_owner_created_idx on generation_jobs(owner_id, created_at desc); +create index if not exists generation_jobs_status_idx on generation_jobs(status); +create index if not exists generation_jobs_claim_idx on generation_jobs(status, scheduled_at, locked_at, priority desc); +create index if not exists generation_jobs_external_client_idx on generation_jobs(owner_id, external_client_id, created_at desc); +create unique index if not exists generation_jobs_idempotency_idx + on generation_jobs(owner_id, external_client_id, idempotency_key) + where external_client_id is not null and idempotency_key is not null; +create index if not exists usage_events_owner_created_idx on usage_events(owner_id, created_at desc); +do $$ +begin + if exists ( + select 1 + from usage_events + group by job_id + having count(*) > 1 + ) then + raise exception using + errcode = '23505', + message = 'USAGE_EVENTS_DUPLICATE_JOB_ID: back up and clean duplicate usage_events.job_id rows before retrying this migration'; + end if; +end; +$$; +create unique index if not exists usage_events_job_id_idx on usage_events(job_id); +create index if not exists usage_events_source_created_idx on usage_events(source, created_at desc); +create index if not exists usage_events_organization_created_idx on usage_events(organization_id, created_at desc); +create index if not exists image_templates_owner_sort_idx on image_templates(owner_id, sort_order asc, updated_at desc); + +create or replace function claim_generation_jobs( + p_worker_id text, + p_limit integer default 1, + p_lock_timeout_seconds integer default 300 +) +returns setof generation_jobs +language plpgsql +set search_path = public, pg_temp +as $$ +declare + v_now timestamptz := now(); +begin + return query + with candidates as ( + select id + from generation_jobs + where status in ('queued', 'running') + and coalesce(scheduled_at, created_at) <= v_now + and ( + locked_at is null + or locked_at < v_now - make_interval(secs => p_lock_timeout_seconds) + ) + order by coalesce(priority, 0) desc, coalesce(scheduled_at, created_at) asc, created_at asc + limit greatest(1, least(coalesce(p_limit, 1), 20)) + for update skip locked + ), + updated as ( + update generation_jobs + set locked_at = v_now, + locked_by = p_worker_id, + started_at = coalesce(generation_jobs.started_at, v_now), + updated_at = v_now + where id in (select id from candidates) + returning generation_jobs.* + ) + select * from updated; +end; +$$; + +revoke all on function claim_generation_jobs(text, integer, integer) from public; + +create table if not exists platform_organizations ( + id text primary key, + name text not null unique, + status text not null default 'active' check (status in ('active', 'disabled')), + archive_owner_id text not null unique, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists platform_users ( + id text primary key, + phone text not null unique, + display_name text not null, + role text not null default 'user' check (role in ('super_admin', 'organization_admin', 'user')), + organization_id text references platform_organizations(id) on delete set null, + status text not null default 'active' check (status in ('active', 'disabled')), + password_hash text not null, + password_salt text not null, + failed_login_count integer not null default 0, + locked_until timestamptz, + session_version integer not null default 1, + last_login_at timestamptz, + legacy_subject text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists platform_account_migrations ( + id text primary key, + legacy_owner_id text not null unique, + legacy_phone text, + platform_user_id text not null, + created_at timestamptz not null default now() +); + +create index if not exists platform_users_organization_idx on platform_users(organization_id, created_at desc); +create index if not exists platform_users_status_idx on platform_users(status, role); +create index if not exists platform_account_migrations_user_idx on platform_account_migrations(platform_user_id); + +alter table generation_jobs add column if not exists billing jsonb; +alter table usage_events add column if not exists charged_amount_fen bigint; +alter table usage_events add column if not exists currency text; + +create table if not exists billing_price_rules ( + id text primary key, + provider text not null check (provider in ('volcengine-visual', 'evolink', 'seedance', 'bailian', 'mock')), + capability text not null check (capability in ('image.generate', 'video.generate')), + req_key text, + variant_key text, + unit text not null check (unit in ('request', 'image', 'video_second')), + standard_unit_price_fen bigint not null check (standard_unit_price_fen >= 0), + markup_multiplier numeric(12, 4) not null default 1.0000 check (markup_multiplier >= 1), + enabled boolean not null default true, + conditions jsonb not null default '{}'::jsonb, + quantity_source text check (quantity_source in ('request', 'image_count', 'duration')), + priority integer not null default 0, + note text, + source jsonb, + parameter_dimensions jsonb not null default '[]'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +alter table billing_price_rules add column if not exists variant_key text; +alter table billing_price_rules add column if not exists source jsonb; +alter table billing_price_rules add column if not exists conditions jsonb not null default '{}'::jsonb; +alter table billing_price_rules add column if not exists quantity_source text; +alter table billing_price_rules add column if not exists priority integer not null default 0; +alter table billing_price_rules add column if not exists parameter_dimensions jsonb not null default '[]'::jsonb; +alter table billing_price_rules drop constraint if exists billing_price_rules_quantity_source_check; +alter table billing_price_rules add constraint billing_price_rules_quantity_source_check check (quantity_source is null or quantity_source in ('request', 'image_count', 'duration')); + +drop index if exists billing_price_rules_match_idx; +create unique index if not exists billing_price_rules_match_idx + on billing_price_rules(provider, capability, coalesce(req_key, ''), coalesce(variant_key, ''), coalesce(conditions, '{}'::jsonb)); + +create table if not exists billing_wallets ( + organization_id text primary key references platform_organizations(id) on delete restrict, + balance_fen bigint not null default 0 check (balance_fen >= 0), + total_recharged_fen bigint not null default 0 check (total_recharged_fen >= 0), + total_charged_fen bigint not null default 0 check (total_charged_fen >= 0), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists billing_ledger ( + id text primary key, + organization_id text not null references platform_organizations(id) on delete restrict, + account_id text, + job_id text, + kind text not null check (kind in ('recharge', 'charge', 'refund', 'adjustment')), + delta_fen bigint not null check (delta_fen <> 0), + balance_after_fen bigint not null check (balance_after_fen >= 0), + currency text not null default 'CNY' check (currency = 'CNY'), + idempotency_key text not null, + description text not null, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +do $$ +declare + v_constraint record; + v_index record; +begin + for v_constraint in + select constraint_schema, constraint_name + from information_schema.table_constraints + where table_schema = current_schema() + and table_name = 'billing_ledger' + and constraint_type = 'UNIQUE' + and array( + select key_column_usage.column_name::text + from information_schema.key_column_usage + where key_column_usage.constraint_schema = table_constraints.constraint_schema + and key_column_usage.constraint_name = table_constraints.constraint_name + and key_column_usage.table_name = table_constraints.table_name + order by key_column_usage.ordinal_position + ) = array['idempotency_key']::text[] + loop + execute format('alter table %I.%I drop constraint %I', current_schema(), 'billing_ledger', v_constraint.constraint_name); + end loop; + + for v_index in + select indexes.schemaname, indexes.indexname + from pg_indexes as indexes + join pg_class as index_class on index_class.relname = indexes.indexname + join pg_namespace as index_namespace + on index_namespace.oid = index_class.relnamespace + and index_namespace.nspname = indexes.schemaname + join pg_index as index_metadata on index_metadata.indexrelid = index_class.oid + join pg_attribute as indexed_column + on indexed_column.attrelid = index_metadata.indrelid + and indexed_column.attnum = index_metadata.indkey[0] + left join pg_constraint as backing_constraint on backing_constraint.conindid = index_class.oid + where indexes.schemaname = current_schema() + and indexes.tablename = 'billing_ledger' + and index_metadata.indisunique + and index_metadata.indnkeyatts = 1 + and indexed_column.attname = 'idempotency_key' + and backing_constraint.oid is null + loop + execute format('drop index %I.%I', v_index.schemaname, v_index.indexname); + end loop; +end; +$$; + +create unique index if not exists billing_ledger_organization_idempotency_idx + on billing_ledger(organization_id, idempotency_key); +create index if not exists billing_ledger_organization_created_idx on billing_ledger(organization_id, created_at desc); +create index if not exists billing_ledger_account_created_idx on billing_ledger(account_id, created_at desc); +create index if not exists billing_ledger_job_idx on billing_ledger(job_id); + +create or replace function billing_post_wallet_entry( + p_ledger_id text, + p_organization_id text, + p_account_id text, + p_job_id text, + p_kind text, + p_delta_fen bigint, + p_currency text, + p_idempotency_key text, + p_description text, + p_metadata jsonb +) +returns table ( + ledger_id text, + balance_after_fen bigint, + balance_fen bigint, + total_recharged_fen bigint, + total_charged_fen bigint, + created_at timestamptz, + updated_at timestamptz, + delta_fen bigint +) +language plpgsql +set search_path = public, pg_temp +as $$ +declare + v_existing billing_ledger%rowtype; + v_wallet billing_wallets%rowtype; + v_entry billing_ledger%rowtype; + v_account_id text; +begin + if p_currency <> 'CNY' then + raise exception 'BILLING_UNSUPPORTED_CURRENCY'; + end if; + if p_delta_fen = 0 then + raise exception 'BILLING_ZERO_DELTA'; + end if; + + v_account_id := case when p_kind in ('recharge', 'adjustment') then null else p_account_id end; + + perform pg_advisory_xact_lock( + hashtextextended(jsonb_build_array(p_organization_id, p_idempotency_key)::text, 0) + ); + + select * into v_existing + from billing_ledger + where organization_id = p_organization_id + and idempotency_key = p_idempotency_key; + if found then + if v_existing.account_id is distinct from v_account_id + or v_existing.job_id is distinct from p_job_id + or v_existing.kind is distinct from p_kind + or v_existing.delta_fen is distinct from p_delta_fen + or v_existing.currency is distinct from p_currency + then + raise exception using + errcode = 'P0001', + message = 'BILLING_IDEMPOTENCY_PAYLOAD_MISMATCH'; + end if; + + select * into v_wallet from billing_wallets where organization_id = p_organization_id; + return query select v_existing.id, v_existing.balance_after_fen, v_wallet.balance_fen, + v_wallet.total_recharged_fen, v_wallet.total_charged_fen, v_existing.created_at, + v_wallet.updated_at, v_existing.delta_fen; + return; + end if; + + insert into billing_wallets(organization_id) + values (p_organization_id) + on conflict (organization_id) do nothing; + + select * into v_wallet from billing_wallets + where organization_id = p_organization_id + for update; + + if p_delta_fen < 0 and v_wallet.balance_fen < abs(p_delta_fen) then + raise exception 'BILLING_INSUFFICIENT_BALANCE'; + end if; + + update billing_wallets + set balance_fen = v_wallet.balance_fen + p_delta_fen, + total_recharged_fen = v_wallet.total_recharged_fen + case when p_kind = 'recharge' and p_delta_fen > 0 then p_delta_fen else 0 end, + total_charged_fen = v_wallet.total_charged_fen + case when p_kind = 'charge' and p_delta_fen < 0 then abs(p_delta_fen) else 0 end, + updated_at = now() + where organization_id = p_organization_id + returning * into v_wallet; + + insert into billing_ledger( + id, organization_id, account_id, job_id, kind, delta_fen, + balance_after_fen, currency, idempotency_key, description, metadata + ) values ( + p_ledger_id, p_organization_id, + v_account_id, + p_job_id, p_kind, p_delta_fen, + v_wallet.balance_fen, p_currency, p_idempotency_key, p_description, coalesce(p_metadata, '{}'::jsonb) + ) returning * into v_entry; + + return query select v_entry.id, v_entry.balance_after_fen, v_wallet.balance_fen, + v_wallet.total_recharged_fen, v_wallet.total_charged_fen, v_entry.created_at, + v_wallet.updated_at, v_entry.delta_fen; +end; +$$; + +revoke all on function billing_post_wallet_entry(text, text, text, text, text, bigint, text, text, text, jsonb) from public; diff --git a/deploy/ack/configmap.yaml b/deploy/ack/configmap.yaml new file mode 100644 index 0000000..9a0193b --- /dev/null +++ b/deploy/ack/configmap.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: zhinian-runtime + namespace: zhinian +data: + NODE_ENV: production + PORT: "3000" + ZHINIAN_DATA_BACKEND: postgres + ZHINIAN_AUTH_REQUIRED: auto + ZHINIAN_WORKER_BASE_URL: http://zhinian-web:3000 + DATABASE_SSL_MODE: verify-full + DATABASE_CA_CERT_PATH: /etc/zhinian/rds/ca.pem + DATABASE_POOL_MAX: "10" + DATABASE_CONNECTION_TIMEOUT_MS: "5000" + DATABASE_IDLE_TIMEOUT_MS: "30000" + DATABASE_STATEMENT_TIMEOUT_MS: "30000" + DATABASE_APPLICATION_NAME: zhinian-web diff --git a/deploy/ack/ingress.yaml b/deploy/ack/ingress.yaml new file mode 100644 index 0000000..1c6c74b --- /dev/null +++ b/deploy/ack/ingress.yaml @@ -0,0 +1,33 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: zhinian-web + namespace: zhinian + annotations: + nginx.ingress.kubernetes.io/proxy-body-size: 100m +spec: + ingressClassName: nginx + rules: + - host: REPLACE_WITH_PUBLIC_HOST + http: + paths: + # Longest-prefix matching sends public internal-API traffic to the + # selectorless deny Service instead of the Web workload. + - path: /api/internal/worker + pathType: Prefix + backend: + service: + name: zhinian-public-deny + port: + number: 80 + - path: / + pathType: Prefix + backend: + service: + name: zhinian-web + port: + number: 3000 + tls: + - hosts: + - REPLACE_WITH_PUBLIC_HOST + secretName: REPLACE_WITH_TLS_SECRET diff --git a/deploy/ack/migration-job.yaml b/deploy/ack/migration-job.yaml new file mode 100644 index 0000000..9c1e8ed --- /dev/null +++ b/deploy/ack/migration-job.yaml @@ -0,0 +1,64 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: zhinian-db-migrate + namespace: zhinian +spec: + backoffLimit: 2 + ttlSecondsAfterFinished: 86400 + template: + metadata: + labels: + app.kubernetes.io/name: zhinian + app.kubernetes.io/component: database-migration + spec: + restartPolicy: Never + automountServiceAccountToken: false + securityContext: + seccompProfile: + type: RuntimeDefault + containers: + - name: migrate + image: REGISTRY/PROJECT/zhinian-aigc:REPLACE_TAG + imagePullPolicy: IfNotPresent + command: ["node", "scripts/migrate-postgres.mjs"] + env: + - name: NODE_ENV + value: production + - name: ZHINIAN_DATA_BACKEND + value: postgres + # Must match the username in zhinian-web-db/DATABASE_URL. + - name: DATABASE_APP_ROLE + value: REPLACE_WITH_RDS_APP_ROLE + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: zhinian-migration-db + key: DATABASE_URL + - name: DATABASE_SSL_MODE + value: verify-full + - name: DATABASE_CA_CERT_PATH + value: /etc/zhinian/rds/ca.pem + - name: DATABASE_CONNECTION_TIMEOUT_MS + value: "5000" + - name: DATABASE_STATEMENT_TIMEOUT_MS + value: "60000" + volumeMounts: + - name: rds-ca + mountPath: /etc/zhinian/rds + readOnly: true + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + volumes: + - name: rds-ca + secret: + secretName: zhinian-rds-ca diff --git a/deploy/ack/namespace.yaml b/deploy/ack/namespace.yaml new file mode 100644 index 0000000..0774041 --- /dev/null +++ b/deploy/ack/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: zhinian diff --git a/deploy/ack/secrets.example.yaml b/deploy/ack/secrets.example.yaml new file mode 100644 index 0000000..38099d9 --- /dev/null +++ b/deploy/ack/secrets.example.yaml @@ -0,0 +1,36 @@ +# Example only. Replace every placeholder and keep the populated file out of Git. +apiVersion: v1 +kind: Secret +metadata: + name: zhinian-web-db + namespace: zhinian +type: Opaque +stringData: + DATABASE_URL: postgresql://APP_USER:APP_PASSWORD@RDS_INTERNAL_HOST:5432/APP_DATABASE +--- +apiVersion: v1 +kind: Secret +metadata: + name: zhinian-migration-db + namespace: zhinian +type: Opaque +stringData: + DATABASE_URL: postgresql://MIGRATION_USER:MIGRATION_PASSWORD@RDS_INTERNAL_HOST:5432/APP_DATABASE +--- +apiVersion: v1 +kind: Secret +metadata: + name: zhinian-worker-auth + namespace: zhinian +type: Opaque +stringData: + ZHINIAN_INTERNAL_WORKER_TOKEN: REPLACE_WITH_A_LONG_RANDOM_VALUE +--- +apiVersion: v1 +kind: Secret +metadata: + name: zhinian-web-auth + namespace: zhinian +type: Opaque +stringData: + ZHINIAN_AUTH_SESSION_SECRET: REPLACE_WITH_A_DIFFERENT_LONG_RANDOM_VALUE diff --git a/deploy/ack/service.yaml b/deploy/ack/service.yaml new file mode 100644 index 0000000..67e323a --- /dev/null +++ b/deploy/ack/service.yaml @@ -0,0 +1,28 @@ +apiVersion: v1 +kind: Service +metadata: + name: zhinian-web + namespace: zhinian +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: zhinian + app.kubernetes.io/component: web + ports: + - name: http + port: 3000 + targetPort: 3000 +--- +# Deliberately selectorless: the public Ingress routes internal API paths here, +# where there are no endpoints, instead of forwarding them to Web. +apiVersion: v1 +kind: Service +metadata: + name: zhinian-public-deny + namespace: zhinian +spec: + type: ClusterIP + ports: + - name: deny + port: 80 + targetPort: 8080 diff --git a/deploy/ack/web.yaml b/deploy/ack/web.yaml new file mode 100644 index 0000000..87d25cb --- /dev/null +++ b/deploy/ack/web.yaml @@ -0,0 +1,95 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: zhinian-web + namespace: zhinian +spec: + # Keep one replica until uploaded/generated files are stored in OSS or another + # shared object store. PostgreSQL alone does not make local runtime files shared. + replicas: 1 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 # Budget RDS connections for (replicas + maxSurge) * DATABASE_POOL_MAX. + maxUnavailable: 0 + selector: + matchLabels: + app.kubernetes.io/name: zhinian + app.kubernetes.io/component: web + template: + metadata: + labels: + app.kubernetes.io/name: zhinian + app.kubernetes.io/component: web + spec: + automountServiceAccountToken: false + securityContext: + seccompProfile: + type: RuntimeDefault + containers: + - name: web + image: REGISTRY/PROJECT/zhinian-aigc:REPLACE_TAG + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 3000 + envFrom: + - configMapRef: + name: zhinian-runtime + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: zhinian-web-db + key: DATABASE_URL + - name: ZHINIAN_INTERNAL_WORKER_TOKEN + valueFrom: + secretKeyRef: + name: zhinian-worker-auth + key: ZHINIAN_INTERNAL_WORKER_TOKEN + - name: ZHINIAN_AUTH_SESSION_SECRET + valueFrom: + secretKeyRef: + name: zhinian-web-auth + key: ZHINIAN_AUTH_SESSION_SECRET + volumeMounts: + - name: rds-ca + mountPath: /etc/zhinian/rds + readOnly: true + startupProbe: + httpGet: + path: /api/health + port: http + periodSeconds: 5 + failureThreshold: 24 + readinessProbe: + httpGet: + path: /api/ready + port: http + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /api/health + port: http + periodSeconds: 20 + timeoutSeconds: 3 + failureThreshold: 3 + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: "1" + memory: 1Gi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + # The current image runs as root. Add a fixed non-root image user and + # verify /app/.runtime permissions before enabling runAsNonRoot. + volumes: + - name: rds-ca + secret: + secretName: zhinian-rds-ca diff --git a/deploy/ack/worker.yaml b/deploy/ack/worker.yaml new file mode 100644 index 0000000..c84ba82 --- /dev/null +++ b/deploy/ack/worker.yaml @@ -0,0 +1,49 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: zhinian-worker + namespace: zhinian +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: zhinian + app.kubernetes.io/component: worker + template: + metadata: + labels: + app.kubernetes.io/name: zhinian + app.kubernetes.io/component: worker + spec: + automountServiceAccountToken: false + securityContext: + seccompProfile: + type: RuntimeDefault + containers: + - name: worker + image: REGISTRY/PROJECT/zhinian-aigc:REPLACE_TAG + imagePullPolicy: IfNotPresent + command: ["node", "scripts/worker.mjs"] + env: + - name: NODE_ENV + value: production + - name: ZHINIAN_WORKER_BASE_URL + value: http://zhinian-web:3000 + - name: ZHINIAN_WORKER_REQUEST_TIMEOUT_MS + value: "120000" + - name: ZHINIAN_INTERNAL_WORKER_TOKEN + valueFrom: + secretKeyRef: + name: zhinian-worker-auth + key: ZHINIAN_INTERNAL_WORKER_TOKEN + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index e54e40a..bd92ed3 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,5 +1,80 @@ # 智念AIGC平台部署说明 +## 阿里云 ACK + RDS PostgreSQL(生产) + +生产环境设置 `ZHINIAN_DATA_BACKEND=postgres`,并通过 Kubernetes Secret 注入 +`DATABASE_URL`。优先使用 RDS 内网连接地址;ACK 节点/Pod 与 RDS 必须位于同一 +或网络可达的 VPC,并在 RDS 白名单或安全组中仅放行实际工作负载网段。 + +启用 RDS SSL 后,下载实例对应的 CA,创建 `zhinian-rds-ca` Secret,并将其挂载到 +`/etc/zhinian/rds/ca.pem`;同时设置 `DATABASE_SSL_MODE=verify-full` 和 +`DATABASE_CA_CERT_PATH=/etc/zhinian/rds/ca.pem`。不要使用关闭证书校验的配置。 + +```bash +kubectl -n zhinian create secret generic zhinian-rds-ca \ + --from-file=ca.pem=./path/to/downloaded-rds-ca.pem +kubectl apply -f deploy/ack/configmap.yaml +kubectl apply -f deploy/ack/secrets.example.yaml # 仅作模板;先替换全部占位值 +kubectl apply -f deploy/ack/migration-job.yaml +kubectl -n zhinian wait --for=condition=complete job/zhinian-db-migrate --timeout=5m +kubectl apply -f deploy/ack/web.yaml -f deploy/ack/worker.yaml \ + -f deploy/ack/service.yaml -f deploy/ack/ingress.yaml +``` + +迁移 Job 运行 `node scripts/migrate-postgres.mjs`,读取镜像内 +`database/migrations/*.sql`。迁移使用独立的 `zhinian-migration-db` Secret,以便 +授予建表/变更权限;Web 的 `zhinian-web-db` 应只具有应用运行权限。每次发布先运行 +迁移并确认成功,再滚动 Web。 + +首次把已有数据库纳入版本化迁移前必须先做 RDS 快照/逻辑备份,并在维护窗口执行。迁移器 +遇到重复的历史 `usage_events.job_id` 会安全失败并要求人工审计,不会自动删除计费/用量 +记录;清理后重新运行同一 Job。 + +替换模板占位符后,可先运行 `npm run deploy:check` 做仓库内静态契约检查;真正发布前仍需 +使用目标 ACK 集群的 `kubectl apply --dry-run=server` 验证 CRD/准入策略和 Ingress 行为。 + +ACK 中的 Secret/ConfigMap 才是配置事实来源。不要在 `/settings` 页面修改生产密钥:该 +页面写入容器内 `.env.local`,Pod 重建会丢失,也不会自动更新 Kubernetes Secret。应修改 +Secret/ConfigMap 后触发 Deployment 滚动更新。 + +模板默认 Web 为 1 副本,因为仅启用 PostgreSQL 并不会共享上传/生成文件。确认这些文件 +已使用 OSS 或其他共享对象存储后,才可水平扩容;本地 `.runtime` 数据或文件不能在副本 +之间共享。Worker 只持有内部 token 并调用集群内 `zhinian-web` Service,不持有 +`DATABASE_URL`。Ingress 通过更长的 `/api/internal/worker` Prefix 把公网请求路由到 +没有 Endpoint 的 `zhinian-public-deny` Service,因此不会把内部接口转发给 Web;也可以 +在 API Gateway/WAF 再配置等价拒绝规则。不要默认添加依赖 Terway 或特定 ACK 托管组件 +的 webhook/白名单注解。 + +探针约定:`/api/health` 是不查询数据库的进程存活检查;`/api/ready` 会确认 PostgreSQL +关键表存在,并验证应用角色具备任务表读写和两个业务函数的执行权限,失败返回 503。 +启动探针避免迁移/冷启动期间过早重启。Worker 每次内部 tick 默认 120 秒超时,避免网络 +半开连接让轮询进程永久卡住;可用 `ZHINIAN_WORKER_REQUEST_TIMEOUT_MS` 调整。 + +连接预算按 `Web 副本数 × DATABASE_POOL_MAX` 计算,并为迁移、管理连接和故障切换 +预留余量。滚动更新默认可能短暂同时存在旧、新 Pod;模板将 `maxSurge` 设为 1,容量 +预算至少覆盖 `(replicas + 1) × pool max`,否则应降低 pool 或使用 `maxSurge: 0`。 + +当前 Docker runner 默认以 Node Alpine 镜像的 root 用户运行,模板没有虚构一个未经 +镜像验证的 UID。生产加固应在镜像中创建固定非 root 用户、修正 `/app/.runtime` 权限, +验证写入与启动后,再把 Pod 设置为 `runAsNonRoot: true`。 + +关键数据库变量: + +| 变量 | 说明 | +| --- | --- | +| `ZHINIAN_DATA_BACKEND` | 生产固定为 `postgres`;配置错误不会降级到本地 JSON | +| `DATABASE_URL` | PostgreSQL URI,仅存 Secret;不要写入镜像、ConfigMap 或日志 | +| `DATABASE_APP_ROLE` | 迁移 Job 使用;与 Web 的 RDS 用户名一致,用于授予最小应用权限 | +| `DATABASE_SSL_MODE` | `disable` 或 `verify-full`;RDS SSL 生产建议 `verify-full` | +| `DATABASE_CA_CERT_PATH` | 已挂载 CA 文件路径 | +| `DATABASE_POOL_MAX` | 单个 Web Pod 最大连接数 | +| `DATABASE_CONNECTION_TIMEOUT_MS` | 建连超时;模板为 5000 ms | +| `DATABASE_IDLE_TIMEOUT_MS` | 空闲连接回收时间 | +| `DATABASE_STATEMENT_TIMEOUT_MS` | SQL 语句超时 | + +旧 `NEXT_PUBLIC_SUPABASE_URL`、`NEXT_PUBLIC_SUPABASE_ANON_KEY` 和 +`SUPABASE_SERVICE_ROLE_KEY` 已废弃,直连 PostgreSQL 路径不会读取它们。 + 本文面向运维部署。推荐使用 Docker Compose,同一套编排会启动 Web 服务和任务 Worker。 ## 服务器要求 @@ -45,8 +120,10 @@ NEXT_PUBLIC_APP_URL=https://你的域名 ZHINIAN_AUTH_REQUIRED=auto ZHINIAN_AUTH_SESSION_SECRET=请替换为强随机会话密钥 -NEXT_PUBLIC_SUPABASE_URL=https://你的项目.supabase.co -SUPABASE_SERVICE_ROLE_KEY=请替换为服务端密钥 +ZHINIAN_DATA_BACKEND=postgres +DATABASE_URL=postgresql://应用账号:密码@RDS内网地址:5432/数据库名 +DATABASE_SSL_MODE=verify-full +DATABASE_CA_CERT_PATH=/etc/zhinian/rds/ca.pem ZHINIAN_API_KEYS=partner-a:请替换为强随机key ZHINIAN_INTERNAL_WORKER_TOKEN=请替换为强随机token @@ -82,7 +159,12 @@ ALI_OSS_PUBLIC_BASE_URL= npm run bootstrap:admin -- --phone 13800138000 --password '请替换为强密码' --name '平台超级管理员' ``` -生产部署前请在 Supabase SQL Editor 执行幂等脚本 [`supabase/schema.sql`](../supabase/schema.sql),然后运行一次超级管理员初始化命令。旧账号使用 `npm run migrate:accounts -- path/to/legacy-accounts.json` 导入;迁移会保留用量并把历史素材、任务、项目和模板映射到本地账号。 +生产部署前设置 `DATABASE_APP_ROLE` 并执行 `npm run db:migrate`,然后运行一次超级管理员 +初始化命令。迁移器只向该应用角色显式授予当前业务表 DML 和两个数据库函数 EXECUTE; +不会授予未来对象的默认权限、`schema_migrations` 或 DDL 权限。新增表/函数时必须随对应 +版本迁移显式更新授权。旧账号使用 +`npm run migrate:accounts -- path/to/legacy-accounts.json` 导入;迁移会保留用量并把历史 +素材、任务、项目和模板映射到平台账号。 ## 旧版组织账号接口(已停用) @@ -149,10 +231,10 @@ Docker Compose 会挂载: ./.runtime:/app/.runtime ``` -本地 JSON 数据层、上传文件和生成结果都会放在 `.runtime/` 下。生产环境如果未启用 Supabase/Postgres,请定期备份该目录。 +本地 JSON 数据层、上传文件和生成结果都会放在 `.runtime/` 下。`local` 仅适合单实例开发;如临时使用,必须备份该目录。 服务端日志也会放在 `.runtime/logs/` 下,建议和运行时数据一起备份或接入服务器日志采集。 -如果生产环境启用了 Supabase/Postgres,发布包含用量和计费管理的版本前,必须在 Supabase SQL Editor 重新执行仓库中的 `supabase/schema.sql`。脚本会幂等升级表结构、补充计费规则的模型变体、来源和参数档位字段、按 `job_id` 去重历史用量,并把计量记录调整为不随生成任务删除。首次打开 `/billing` 或提交真实任务时,系统会自动导入内置标准成本目录;平台参数档案会同步,已有倍率会保留,超级管理员只维护上浮倍率。 +生产 PostgreSQL 发布前必须执行 `npm run db:migrate`,或先完成 ACK 的 `zhinian-db-migrate` Job。迁移器使用版本记录、校验和、事务和 advisory lock;迁移成功后再滚动 Web。首次打开 `/billing` 或提交真实任务时,系统会自动导入内置标准成本目录;平台参数档案会同步,已有倍率会保留,超级管理员只维护上浮倍率。 建议备份: diff --git a/findings.md b/findings.md index a758488..40631e9 100644 --- a/findings.md +++ b/findings.md @@ -569,3 +569,34 @@ - The create-page estimate card uses the compact `预估消耗` label and stays in the same parameter row at the tested desktop width; a live quote check showed EvoLink Image2 standard at `¥2.04` for one image. - `调整倍率` now opens an in-app modal with the current multiplier, standard cost, current customer price, projected customer price, range validation, keyboard focus, and Escape dismissal. The browser `window.prompt` path is removed. - Design polish followed the taste-skill direction: compact enterprise information hierarchy, shared alignment grid, restrained motion, and an in-context dialog instead of a browser-owned prompt. + +## 2026-08-12 - Alibaba Cloud RDS PostgreSQL Refactor + +### Confirmed facts +- The current production database path is not a direct PostgreSQL connection. `data-store`, `account-store`, and `billing-store` use `@supabase/supabase-js` over PostgREST and fall back to local JSON when Supabase variables are absent. +- `scripts/worker.mjs` remains an HTTP poller and does not connect to the database; in the current architecture only Web pods require a database pool. +- The existing SQL model uses PostgreSQL-compatible tables, JSONB/array/timestamp types, indexes, `FOR UPDATE SKIP LOCKED`, and two PL/pgSQL functions. Its application coupling is Supabase client semantics, not Supabase-only SQL namespaces. +- Existing tests intentionally clear Supabase variables and exercise local JSON; there is no real PostgreSQL or RDS integration test and no live RDS credential is available in this workspace. +- The existing `/api/health` route reports process/configuration state but does not probe database connectivity. + +### Decisions +- Introduce explicit `ZHINIAN_DATA_BACKEND=local|postgres`. Production defaults must fail closed when the backend is missing or invalid; PostgreSQL mode must fail when `DATABASE_URL` is absent. +- Preserve local JSON for development/tests, but never silently fall back from an explicitly selected PostgreSQL backend. +- Centralize `pg.Pool`, TLS certificate loading, numeric environment validation, transactions, query execution, connection shutdown, and readiness in one server-only module. +- Keep current store exports stable so routes, services, and components do not learn database transport details. +- Preserve database-side `claim_generation_jobs` and `billing_post_wallet_entry` functions for concurrency correctness. +- Use an independent versioned migration runner with advisory locking; ACK runs it as a one-shot Job before Web rollout, not as a per-pod init container. +- Web receives database/session/provider/storage secrets; Worker receives only its internal token and internal Web URL. + +### Unverified assumptions and external boundaries +- The target RDS PostgreSQL major version, instance connection limit, internal endpoint, TLS enforcement setting, CA bundle, database/user names, and network ACL/security-group rules are unknown and must be supplied/verified during deployment. +- Without live credentials or an ACK context, code/build/schema checks can verify implementation shape but cannot prove real RDS connectivity, migration success, or cluster rollout. + +### Implementation and audit findings +- Direct PostgreSQL now uses a lazy process-level `pg.Pool`, explicit backend selection, validated integer limits, verified CA TLS, parameterized queries, a single-client transaction helper, and schema/privilege-aware readiness. Connection-string SSL query parameters are rejected so they cannot silently override the mounted CA policy. +- The three stores and both account scripts no longer use Supabase/PostgREST. Local JSON remains explicit for development/tests; production without a valid backend or PostgreSQL URL fails closed. +- The migration runner serializes with a session advisory lock, verifies immutable checksums, wraps each version in a transaction, provisions exact current-object grants for a constrained application role, revokes public function execution, and deliberately avoids blanket/default future-object grants. +- Read-only audits found and implementation fixed four concurrency/semantic defects: concurrent duplicate wallet idempotency, concurrent failed-login count loss, PostgreSQL billing-update 23505 mapping, and JSONB condition-array canonicalization. +- ACK templates now keep Web at one replica until storage is externalized, separate Web/Worker/DB secrets, isolate the public internal-worker prefix through a selectorless Service, mount the RDS CA, disable service-account token mounts, and split liveness from database/schema readiness. +- The first final Sol review returned FAIL and uncovered additional production risks. All reported code findings were addressed before re-review: wallet idempotency is organization-scoped and binds immutable account/job/kind/amount/currency fields while treating description/metadata as mutable audit detail; destructive usage deduplication became a fail-safe preflight; app grants are explicit with no future-object defaults; readiness covers all runtime tables; password changes use a row-lock transaction; server-only imports are enforced; and npm/pnpm resolve the same pinned Next/React toolchain. +- Main-thread final verification passed 31 test files / 119 tests, TypeScript, the Next production build, all 8 ACK manifest assertions, frozen lockfile validation, script syntax, diff hygiene, and documentation drift. The final independent Sol review returned `PASS`; live RDS/ACK/Docker verification remains explicitly outside the available environment. diff --git a/lib/server/account-store.ts b/lib/server/account-store.ts index 849c087..0c4720c 100644 --- a/lib/server/account-store.ts +++ b/lib/server/account-store.ts @@ -1,6 +1,7 @@ +import "server-only"; + import { readFile, rename, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { createClient, type SupabaseClient } from "@supabase/supabase-js"; import type { AccountMigration, AccountStatus, @@ -11,6 +12,7 @@ import type { } from "@/lib/types"; import { hashLocalPassword, verifyLocalPassword } from "@/lib/server/auth/password"; import { createId } from "@/lib/server/ids"; +import { isPostgresBackend, queryDatabase, withDatabaseTransaction } from "@/lib/server/database"; import { reassignOwnerData } from "@/lib/server/data-store"; import { dataDir, DEFAULT_OWNER_ID, ensureRuntimeDirs } from "@/lib/server/runtime"; @@ -71,17 +73,16 @@ export function isValidPhone(value: string): boolean { } export function platformAccountStoreConfigured(): boolean { - return Boolean(process.env.SUPABASE_SERVICE_ROLE_KEY && process.env.NEXT_PUBLIC_SUPABASE_URL) || Boolean(process.env.ZHINIAN_AUTH_SESSION_SECRET); + return (isPostgresBackend() ? Boolean(process.env.DATABASE_URL?.trim()) : Boolean(process.env.ZHINIAN_AUTH_SESSION_SECRET)); } export async function listPlatformOrganizations(options: { includeDisabled?: boolean } = {}): Promise { - const supabase = getSupabaseAdmin(); - if (supabase) { - let query = supabase.from("platform_organizations").select("*").order("created_at", { ascending: true }); - if (!options.includeDisabled) query = query.eq("status", "active"); - const { data, error } = await query; - if (error) throw new AccountStoreError(error.message, 500); - return (data || []).map(organizationFromRow); + if (isPostgresBackend()) { + const result = await queryDatabase>( + `SELECT * FROM platform_organizations ${options.includeDisabled ? "" : "WHERE status = $1"} ORDER BY created_at ASC`, + options.includeDisabled ? [] : ["active"] + ); + return result.rows.map(organizationFromRow); } const state = await readLocalState(); return state.organizations @@ -90,11 +91,9 @@ export async function listPlatformOrganizations(options: { includeDisabled?: boo } export async function getPlatformOrganization(id: string): Promise { - const supabase = getSupabaseAdmin(); - if (supabase) { - const { data, error } = await supabase.from("platform_organizations").select("*").eq("id", id).maybeSingle(); - if (error) throw new AccountStoreError(error.message, 500); - return data ? organizationFromRow(data) : null; + if (isPostgresBackend()) { + const result = await queryDatabase>("SELECT * FROM platform_organizations WHERE id = $1", [id]); + return result.rows[0] ? organizationFromRow(result.rows[0]) : null; } const state = await readLocalState(); return state.organizations.find((organization) => organization.id === id) || null; @@ -113,11 +112,16 @@ export async function createPlatformOrganization(name: string): Promise>( + "INSERT INTO platform_organizations (id, name, status, archive_owner_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *", + [organization.id, organization.name, organization.status, organization.archiveOwnerId, organization.createdAt, organization.updatedAt] + ); + return organizationFromRow(result.rows[0]); + } catch (error) { + throw databaseError(error); + } } return mutateLocalState((state) => { if (state.organizations.some((item) => item.name === normalizedName)) throw new AccountStoreError("组织名称已存在。", 409); @@ -127,18 +131,25 @@ export async function createPlatformOrganization(name: string): Promise { - const nextPatch: Record = { updated_at: new Date().toISOString() }; + const nextPatch: Record = {}; if (patch.name !== undefined) { const name = patch.name.trim(); if (!name) throw new AccountStoreError("组织名称不能为空。", 400); nextPatch.name = name; } if (patch.status !== undefined) nextPatch.status = patch.status; - const supabase = getSupabaseAdmin(); - if (supabase) { - const { data, error } = await supabase.from("platform_organizations").update(nextPatch).eq("id", id).select("*").single(); - if (error) throw new AccountStoreError(error.message, error.code === "23505" ? 409 : 500); - return organizationFromRow(data); + if (isPostgresBackend()) { + try { + const result = await queryDatabase>( + "UPDATE platform_organizations SET name = COALESCE($2, name), status = COALESCE($3, status), updated_at = $4 WHERE id = $1 RETURNING *", + [id, nextPatch.name ?? null, nextPatch.status ?? null, new Date().toISOString()] + ); + if (!result.rows[0]) throw new AccountStoreError("Organization not found", 404); + return organizationFromRow(result.rows[0]); + } catch (error) { + if (error instanceof AccountStoreError) throw error; + throw databaseError(error); + } } return mutateLocalState((state) => { const organization = state.organizations.find((item) => item.id === id); @@ -156,10 +167,8 @@ export async function updatePlatformOrganization(id: string, patch: { name?: str export async function deletePlatformOrganization(id: string): Promise { const users = await listPlatformUsers({ organizationId: id, includeDisabled: true }); if (users.length) throw new AccountStoreError("组织仍有账号,不能删除。", 409); - const supabase = getSupabaseAdmin(); - if (supabase) { - const { error } = await supabase.from("platform_organizations").delete().eq("id", id); - if (error) throw new AccountStoreError(error.message, 500); + if (isPostgresBackend()) { + await queryDatabase("DELETE FROM platform_organizations WHERE id = $1", [id]); return; } await mutateLocalState((state) => { @@ -168,15 +177,17 @@ export async function deletePlatformOrganization(id: string): Promise { } export async function listPlatformUsers(filters: PlatformUserFilters = {}): Promise { - const supabase = getSupabaseAdmin(); - if (supabase) { - let query = supabase.from("platform_users").select("*").order("created_at", { ascending: false }); - if (filters.organizationId) query = query.eq("organization_id", filters.organizationId); - if (filters.role) query = query.eq("role", filters.role); - if (!filters.includeDisabled) query = query.eq("status", "active"); - const { data, error } = await query; - if (error) throw new AccountStoreError(error.message, 500); - return (data || []).map(userFromRow); + if (isPostgresBackend()) { + const clauses: string[] = []; + const values: unknown[] = []; + if (filters.organizationId) clauses.push(`organization_id = $${values.push(filters.organizationId)}`); + if (filters.role) clauses.push(`role = $${values.push(filters.role)}`); + if (!filters.includeDisabled) clauses.push(`status = $${values.push("active")}`); + const result = await queryDatabase>( + `SELECT * FROM platform_users ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY created_at DESC`, + values + ); + return result.rows.map(userFromRow); } const state = await readLocalState(); return state.users @@ -187,13 +198,12 @@ export async function listPlatformUsers(filters: PlatformUserFilters = {}): Prom } export async function getPlatformUserById(id: string, options: { includeDisabled?: boolean } = {}): Promise { - const supabase = getSupabaseAdmin(); - if (supabase) { - let query = supabase.from("platform_users").select("*").eq("id", id); - if (!options.includeDisabled) query = query.eq("status", "active"); - const { data, error } = await query.maybeSingle(); - if (error) throw new AccountStoreError(error.message, 500); - return data ? userFromRow(data) : null; + if (isPostgresBackend()) { + const result = await queryDatabase>( + `SELECT * FROM platform_users WHERE id = $1 ${options.includeDisabled ? "" : "AND status = $2"}`, + options.includeDisabled ? [id] : [id, "active"] + ); + return result.rows[0] ? userFromRow(result.rows[0]) : null; } const state = await readLocalState(); const user = state.users.find((item) => item.id === id) || null; @@ -203,13 +213,12 @@ export async function getPlatformUserById(id: string, options: { includeDisabled export async function findPlatformUserByPhone(phone: string, options: { includeDisabled?: boolean } = {}): Promise { const normalizedPhone = normalizePhone(phone); - const supabase = getSupabaseAdmin(); - if (supabase) { - let query = supabase.from("platform_users").select("*").eq("phone", normalizedPhone); - if (!options.includeDisabled) query = query.eq("status", "active"); - const { data, error } = await query.maybeSingle(); - if (error) throw new AccountStoreError(error.message, 500); - return data ? userFromRow(data) : null; + if (isPostgresBackend()) { + const result = await queryDatabase>( + `SELECT * FROM platform_users WHERE phone = $1 ${options.includeDisabled ? "" : "AND status = $2"}`, + options.includeDisabled ? [normalizedPhone] : [normalizedPhone, "active"] + ); + return result.rows[0] ? userFromRow(result.rows[0]) : null; } const state = await readLocalState(); const user = state.users.find((item) => item.phone === normalizedPhone) || null; @@ -248,11 +257,17 @@ export async function createPlatformUser(input: CreatePlatformUserInput): Promis createdAt: now, updatedAt: now }; - const supabase = getSupabaseAdmin(); - if (supabase) { - const { data, error } = await supabase.from("platform_users").insert(userToRow(user)).select("*").single(); - if (error) throw new AccountStoreError(error.message, error.code === "23505" ? 409 : 500); - return userFromRow(data); + if (isPostgresBackend()) { + try { + const row = userToRow(user); + const result = await queryDatabase>( + "INSERT INTO platform_users (id, phone, display_name, role, organization_id, status, password_hash, password_salt, failed_login_count, locked_until, session_version, last_login_at, legacy_subject, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *", + Object.values(row) + ); + return userFromRow(result.rows[0]); + } catch (error) { + throw databaseError(error); + } } return mutateLocalState((state) => { state.users.push(user); @@ -287,11 +302,19 @@ export async function updatePlatformUser(id: string, patch: UpdatePlatformUserIn sessionVersion: nextPassword || patch.role || patch.organizationId !== undefined || patch.status ? current.sessionVersion + 1 : current.sessionVersion, updatedAt: new Date().toISOString() }; - const supabase = getSupabaseAdmin(); - if (supabase) { - const { data, error } = await supabase.from("platform_users").update(userToRow(next)).eq("id", id).select("*").single(); - if (error) throw new AccountStoreError(error.message, 500); - return userFromRow(data); + if (isPostgresBackend()) { + try { + const row = userToRow(next); + const result = await queryDatabase>( + "UPDATE platform_users SET phone=$2, display_name=$3, role=$4, organization_id=$5, status=$6, password_hash=$7, password_salt=$8, failed_login_count=$9, locked_until=$10, session_version=$11, last_login_at=$12, legacy_subject=$13, created_at=$14, updated_at=$15 WHERE id=$1 RETURNING *", + Object.values(row) + ); + if (!result.rows[0]) throw new AccountStoreError("Account not found", 404); + return userFromRow(result.rows[0]); + } catch (error) { + if (error instanceof AccountStoreError) throw error; + throw databaseError(error); + } } return mutateLocalState((state) => { const index = state.users.findIndex((item) => item.id === id); @@ -307,19 +330,70 @@ export async function deletePlatformUser(id: string): Promise { if (user.role === "super_admin") throw new AccountStoreError("不能直接删除超级管理员账号。", 400); const organization = user.organizationId ? await getPlatformOrganization(user.organizationId) : null; const archiveOwnerId = organization?.archiveOwnerId || `archive:global`; - await reassignOwnerData(user.id, archiveOwnerId); - const supabase = getSupabaseAdmin(); - if (supabase) { - const { error } = await supabase.from("platform_users").delete().eq("id", id); - if (error) throw new AccountStoreError(error.message, 500); + if (isPostgresBackend()) { + await withDatabaseTransaction(async (client) => { + for (const table of ["assets", "generation_jobs", "projects", "image_templates"] as const) { + await client.query(`UPDATE ${table} SET owner_id = $2 WHERE owner_id = $1`, [user.id, archiveOwnerId]); + } + const result = await client.query("DELETE FROM platform_users WHERE id = $1 RETURNING id", [id]); + if (!result.rowCount) throw new AccountStoreError("Account not found", 404); + }); return; } + await reassignOwnerData(user.id, archiveOwnerId); await mutateLocalState((state) => { state.users = state.users.filter((item) => item.id !== id); }); } export async function authenticatePlatformUser(phone: string, password: string): Promise { + if (isPostgresBackend()) { + const result = await withDatabaseTransaction< + { user: PlatformUserRecord; error?: never } | { user?: never; error: AccountLoginError } + >(async (client) => { + const result = await client.query>( + "SELECT * FROM platform_users WHERE phone = $1 FOR UPDATE", + [normalizePhone(phone)] + ); + const user = result.rows[0] ? userFromRow(result.rows[0]) : null; + if (!user) throw new AccountLoginError(); + if (user.status !== "active") throw new AccountLoginError("Account is disabled.", 403); + if (user.role !== "super_admin" && user.organizationId) { + const organizationResult = await client.query>( + "SELECT * FROM platform_organizations WHERE id = $1", + [user.organizationId] + ); + const organization = organizationResult.rows[0] ? organizationFromRow(organizationResult.rows[0]) : null; + if (!organization || organization.status !== "active") { + throw new AccountLoginError("The account organization is disabled.", 403); + } + } + if (user.lockedUntil && user.lockedUntil > new Date().toISOString()) { + throw new AccountLoginError("Too many failed login attempts. Try again in 15 minutes.", 423); + } + const valid = await verifyLocalPassword(password, user.passwordHash, user.passwordSalt); + const now = new Date().toISOString(); + if (!valid) { + const failedLoginCount = user.failedLoginCount + 1; + const lockedUntil = failedLoginCount >= MAX_LOGIN_FAILURES ? new Date(Date.now() + LOCK_DURATION_MS).toISOString() : null; + await client.query( + "UPDATE platform_users SET failed_login_count=$2, locked_until=$3, updated_at=$4 WHERE id=$1", + [user.id, lockedUntil ? 0 : failedLoginCount, lockedUntil, now] + ); + return { error: lockedUntil + ? new AccountLoginError("Too many failed login attempts. Try again in 15 minutes.", 423) + : new AccountLoginError() }; + } + const updated = await client.query>( + "UPDATE platform_users SET failed_login_count=0, locked_until=NULL, last_login_at=$2, updated_at=$2 WHERE id=$1 RETURNING *", + [user.id, now] + ); + if (!updated.rows[0]) throw new AccountLoginError(); + return { user: userFromRow(updated.rows[0]) }; + }); + if (result.error) throw result.error; + return result.user; + } const user = await findPlatformUserByPhone(phone, { includeDisabled: true }); if (!user) throw new AccountLoginError(); if (user.status !== "active") throw new AccountLoginError("账号已停用,请联系管理员。", 403); @@ -349,6 +423,27 @@ export async function authenticatePlatformUser(phone: string, password: string): } export async function changeOwnPassword(userId: string, currentPassword: string, nextPassword: string): Promise { + if (isPostgresBackend()) { + return withDatabaseTransaction(async (client) => { + const result = await client.query>( + "SELECT * FROM platform_users WHERE id = $1 FOR UPDATE", + [userId] + ); + const user = result.rows[0] ? userFromRow(result.rows[0]) : null; + if (!user || user.status !== "active") throw new AccountStoreError("Account not found or disabled.", 404); + if (!await verifyLocalPassword(currentPassword, user.passwordHash, user.passwordSalt)) { + throw new AccountStoreError("The current password is incorrect.", 400); + } + if (!nextPassword || nextPassword.length < 8) throw new AccountStoreError("The new password must be at least 8 characters.", 400); + const password = await hashLocalPassword(nextPassword); + const updated = await client.query>( + "UPDATE platform_users SET password_hash=$2, password_salt=$3, session_version=session_version+1, updated_at=$4 WHERE id=$1 RETURNING *", + [userId, password.hash, password.salt, new Date().toISOString()] + ); + if (!updated.rows[0]) throw new AccountStoreError("Account not found", 404); + return userFromRow(updated.rows[0]); + }); + } const user = await getPlatformUserById(userId, { includeDisabled: true }); if (!user || user.status !== "active") throw new AccountStoreError("账号不存在或已停用。", 404); if (!await verifyLocalPassword(currentPassword, user.passwordHash, user.passwordSalt)) { @@ -364,11 +459,13 @@ export async function upsertAccountMigration(input: Omit>( + "INSERT INTO platform_account_migrations (id, legacy_owner_id, legacy_phone, platform_user_id, created_at) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (legacy_owner_id) DO UPDATE SET id=EXCLUDED.id, legacy_phone=EXCLUDED.legacy_phone, platform_user_id=EXCLUDED.platform_user_id, created_at=EXCLUDED.created_at RETURNING *", + Object.values(row) + ); + return migrationFromRow(result.rows[0]); } return mutateLocalState((state) => { const index = state.migrations.findIndex((item) => item.legacyOwnerId === input.legacyOwnerId); @@ -385,10 +482,12 @@ async function updateLoginState(id: string, patch: { failedLoginCount: number; l ...(patch.lastLoginAt ? { last_login_at: patch.lastLoginAt } : {}), updated_at: new Date().toISOString() }; - const supabase = getSupabaseAdmin(); - if (supabase) { - const { error } = await supabase.from("platform_users").update(values).eq("id", id); - if (error) throw new AccountStoreError(error.message, 500); + if (isPostgresBackend()) { + const result = await queryDatabase( + "UPDATE platform_users SET failed_login_count=$2, locked_until=$3, last_login_at=COALESCE($4, last_login_at), updated_at=$5 WHERE id=$1 RETURNING id", + [id, values.failed_login_count, values.locked_until, patch.lastLoginAt ?? null, values.updated_at] + ); + if (!result.rowCount) throw new AccountStoreError("Account not found", 404); return; } await mutateLocalState((state) => { @@ -519,11 +618,9 @@ function normalizeMigration(value: AccountMigration): AccountMigration { }; } -function getSupabaseAdmin(): SupabaseClient | null { - const url = process.env.NEXT_PUBLIC_SUPABASE_URL; - const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - if (!url || !serviceRoleKey) return null; - return createClient(url, serviceRoleKey, { auth: { persistSession: false } }); +function databaseError(error: unknown): AccountStoreError { + const database = error as { code?: string; message?: string }; + return new AccountStoreError(database.message || "Database operation failed", database.code === "23505" ? 409 : 500); } function organizationToRow(organization: PlatformOrganization) { @@ -543,8 +640,8 @@ function organizationFromRow(row: Record): PlatformOrganization name: String(row.name || ""), status: row.status === "disabled" ? "disabled" : "active", archiveOwnerId: String(row.archive_owner_id || `archive:${row.id}`), - createdAt: String(row.created_at), - updatedAt: String(row.updated_at) + createdAt: timestampFromRow(row.created_at), + updatedAt: timestampFromRow(row.updated_at) }; } @@ -579,12 +676,12 @@ function userFromRow(row: Record): PlatformUserRecord { passwordHash: String(row.password_hash || ""), passwordSalt: String(row.password_salt || ""), failedLoginCount: Number(row.failed_login_count || 0), - lockedUntil: row.locked_until ? String(row.locked_until) : undefined, + lockedUntil: row.locked_until ? timestampFromRow(row.locked_until) : undefined, sessionVersion: Number(row.session_version || 1), - lastLoginAt: row.last_login_at ? String(row.last_login_at) : undefined, + lastLoginAt: row.last_login_at ? timestampFromRow(row.last_login_at) : undefined, legacySubject: row.legacy_subject ? String(row.legacy_subject) : undefined, - createdAt: String(row.created_at), - updatedAt: String(row.updated_at) + createdAt: timestampFromRow(row.created_at), + updatedAt: timestampFromRow(row.updated_at) }); } @@ -604,6 +701,10 @@ function migrationFromRow(row: Record): AccountMigration { legacyOwnerId: String(row.legacy_owner_id), legacyPhone: row.legacy_phone ? String(row.legacy_phone) : undefined, platformUserId: String(row.platform_user_id), - createdAt: String(row.created_at) + createdAt: timestampFromRow(row.created_at) }; } + +function timestampFromRow(value: unknown): string { + return value instanceof Date ? value.toISOString() : String(value); +} diff --git a/lib/server/billing-store.ts b/lib/server/billing-store.ts index 97b2d6b..ad3121c 100644 --- a/lib/server/billing-store.ts +++ b/lib/server/billing-store.ts @@ -1,6 +1,8 @@ +import "server-only"; + import { readFile, rename, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { isPostgresBackend, queryDatabase } from "@/lib/server/database"; import { createId } from "@/lib/server/ids"; import { dataDir, ensureRuntimeDirs } from "@/lib/server/runtime"; import type { @@ -62,13 +64,14 @@ export class InsufficientBalanceError extends BillingStoreError { } export async function listBillingPriceRules(options: { includeDisabled?: boolean } = {}): Promise { - const supabase = getSupabaseAdmin(); - if (supabase) { - let query = supabase.from("billing_price_rules").select("*").order("provider").order("capability").order("updated_at", { ascending: false }); - if (!options.includeDisabled) query = query.eq("enabled", true); - const { data, error } = await query; - if (error) throw new BillingStoreError(error.message, 500); - return (data || []).map(priceRuleFromRow); + if (isPostgresBackend()) { + const result = await billingQuery( + `SELECT * FROM billing_price_rules + WHERE ($1::boolean OR enabled = true) + ORDER BY provider ASC, capability ASC, updated_at DESC`, + [Boolean(options.includeDisabled)] + ); + return result.map(priceRuleFromRow); } const state = await readState(); return state.priceRules @@ -77,11 +80,9 @@ export async function listBillingPriceRules(options: { includeDisabled?: boolean } export async function getBillingPriceRule(id: string): Promise { - const supabase = getSupabaseAdmin(); - if (supabase) { - const { data, error } = await supabase.from("billing_price_rules").select("*").eq("id", id).maybeSingle(); - if (error) throw new BillingStoreError(error.message, 500); - return data ? priceRuleFromRow(data) : null; + if (isPostgresBackend()) { + const [row] = await billingQuery("SELECT * FROM billing_price_rules WHERE id = $1 LIMIT 1", [id]); + return row ? priceRuleFromRow(row) : null; } const state = await readState(); return state.priceRules.find((rule) => rule.id === id) || null; @@ -95,11 +96,24 @@ export async function createBillingPriceRule(input: BillingPriceRuleInput): Prom createdAt: input.createdAt || now, updatedAt: input.updatedAt || now }; - const supabase = getSupabaseAdmin(); - if (supabase) { - const { data, error } = await supabase.from("billing_price_rules").insert(priceRuleToRow(rule)).select("*").single(); - if (error) throw new BillingStoreError(error.message, error.code === "23505" ? 409 : 500); - return priceRuleFromRow(data); + if (isPostgresBackend()) { + const row = priceRuleToRow(rule); + const [created] = await billingQuery( + `INSERT INTO billing_price_rules ( + id, provider, capability, req_key, variant_key, unit, standard_unit_price_fen, + markup_multiplier, enabled, conditions, quantity_source, priority, note, source, + parameter_dimensions, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11, $12, $13, + $14::jsonb, $15::jsonb, $16, $17 + ) RETURNING *`, + [row.id, row.provider, row.capability, row.req_key, row.variant_key, row.unit, + row.standard_unit_price_fen, row.markup_multiplier, row.enabled, JSON.stringify(row.conditions), + row.quantity_source, row.priority, row.note, JSON.stringify(row.source), + JSON.stringify(row.parameter_dimensions), row.created_at, row.updated_at], + true + ); + return priceRuleFromRow(created); } return mutateLocalState((state) => { if (state.priceRules.some((item) => item.id === rule.id)) throw new BillingStoreError("计费规则 ID 已存在。", 409); @@ -115,11 +129,22 @@ export async function updateBillingPriceRule(id: string, patch: Partial { const index = state.priceRules.findIndex((item) => item.id === id); @@ -156,22 +181,17 @@ export async function updateBillingPriceTierMultiplier(input: { } export async function getOrganizationWallet(organizationId: string): Promise { - const supabase = getSupabaseAdmin(); - if (supabase) { - const { data, error } = await supabase.from("billing_wallets").select("*").eq("organization_id", organizationId).maybeSingle(); - if (error) throw new BillingStoreError(error.message, 500); - return data ? walletFromRow(data) : emptyWallet(organizationId); + if (isPostgresBackend()) { + const [row] = await billingQuery("SELECT * FROM billing_wallets WHERE organization_id = $1 LIMIT 1", [organizationId]); + return row ? walletFromRow(row) : emptyWallet(organizationId); } const state = await readState(); return state.wallets.find((wallet) => wallet.organizationId === organizationId) || emptyWallet(organizationId); } export async function listOrganizationWallets(): Promise { - const supabase = getSupabaseAdmin(); - if (supabase) { - const { data, error } = await supabase.from("billing_wallets").select("*").order("updated_at", { ascending: false }); - if (error) throw new BillingStoreError(error.message, 500); - return (data || []).map(walletFromRow); + if (isPostgresBackend()) { + return (await billingQuery("SELECT * FROM billing_wallets ORDER BY updated_at DESC")).map(walletFromRow); } const state = await readState(); return [...state.wallets].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); @@ -179,31 +199,26 @@ export async function listOrganizationWallets(): Promise { export async function postWalletEntry(input: WalletEntryInput): Promise<{ entry: BillingLedgerEntry; wallet: OrganizationWallet }> { const deltaFen = Math.trunc(input.deltaFen); - if (!Number.isFinite(deltaFen) || deltaFen === 0) throw new BillingStoreError("账务变动金额不能为 0。", 400); + if (!Number.isSafeInteger(deltaFen) || deltaFen === 0) throw new BillingStoreError("账务变动金额必须是安全整数且不能为 0。", 400); if (!input.organizationId) throw new BillingStoreError("组织 ID 不能为空。", 400); if (!input.idempotencyKey) throw new BillingStoreError("账务幂等键不能为空。", 400); const accountId = effectiveLedgerAccountId(input); const currency = input.currency || "CNY"; const metadata = input.metadata || {}; - const supabase = getSupabaseAdmin(); - if (supabase) { - const { data, error } = await supabase.rpc("billing_post_wallet_entry", { - p_ledger_id: createId("ledger"), - p_organization_id: input.organizationId, - p_account_id: accountId || null, - p_job_id: input.jobId || null, - p_kind: input.kind, - p_delta_fen: deltaFen, - p_currency: currency, - p_idempotency_key: input.idempotencyKey, - p_description: input.description, - p_metadata: metadata + if (isPostgresBackend()) { + const [row] = await billingQuery( + `SELECT * FROM billing_post_wallet_entry( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb + )`, + [createId("ledger"), input.organizationId, accountId || null, input.jobId || null, + input.kind, deltaFen, currency, input.idempotencyKey, input.description, JSON.stringify(metadata)] + ).catch((error: unknown) => { + if (error instanceof BillingStoreError && /BILLING_INSUFFICIENT_BALANCE/i.test(error.message)) throw new InsufficientBalanceError(); + if (error instanceof BillingStoreError && /BILLING_IDEMPOTENCY_PAYLOAD_MISMATCH/i.test(error.message)) { + throw new BillingStoreError("账务幂等键已被不同请求使用。", 409); + } + throw error; }); - if (error) { - if (/BILLING_INSUFFICIENT_BALANCE/i.test(error.message)) throw new InsufficientBalanceError(); - throw new BillingStoreError(error.message, 500); - } - const row = firstRpcRow(data); if (!row) throw new BillingStoreError("账务服务未返回流水结果。", 500); return { entry: ledgerFromRpcRow(row, input, accountId), @@ -212,8 +227,17 @@ export async function postWalletEntry(input: WalletEntryInput): Promise<{ entry: } return mutateLocalState((state) => { - const existing = state.ledgerEntries.find((entry) => entry.idempotencyKey === input.idempotencyKey); + const existing = state.ledgerEntries.find( + (entry) => entry.organizationId === input.organizationId && entry.idempotencyKey === input.idempotencyKey + ); if (existing) { + if (existing.accountId !== accountId + || existing.jobId !== input.jobId + || existing.kind !== input.kind + || existing.deltaFen !== deltaFen + || existing.currency !== currency) { + throw new BillingStoreError("账务幂等键已被不同请求使用。", 409); + } const wallet = state.wallets.find((item) => item.organizationId === input.organizationId) || emptyWallet(input.organizationId); return { entry: existing, wallet }; } @@ -222,9 +246,9 @@ export async function postWalletEntry(input: WalletEntryInput): Promise<{ entry: const now = new Date().toISOString(); const nextWallet: OrganizationWallet = { ...wallet, - balanceFen: wallet.balanceFen + deltaFen, - totalRechargedFen: wallet.totalRechargedFen + (input.kind === "recharge" && deltaFen > 0 ? deltaFen : 0), - totalChargedFen: wallet.totalChargedFen + (input.kind === "charge" && deltaFen < 0 ? Math.abs(deltaFen) : 0), + balanceFen: safeInteger(wallet.balanceFen + deltaFen, "local billing wallet balance"), + totalRechargedFen: safeInteger(wallet.totalRechargedFen + (input.kind === "recharge" && deltaFen > 0 ? deltaFen : 0), "local billing wallet total recharged"), + totalChargedFen: safeInteger(wallet.totalChargedFen + (input.kind === "charge" && deltaFen < 0 ? Math.abs(deltaFen) : 0), "local billing wallet total charged"), updatedAt: now }; const entry: BillingLedgerEntry = { @@ -251,16 +275,18 @@ export async function postWalletEntry(input: WalletEntryInput): Promise<{ entry: export async function listBillingLedgerEntries(filters: BillingLedgerFilters = {}): Promise { const limit = Math.max(1, Math.min(filters.limit || 100, 500)); - const supabase = getSupabaseAdmin(); - if (supabase) { - let query = supabase.from("billing_ledger").select("*").order("created_at", { ascending: false }).limit(limit); - if (filters.organizationId) query = query.eq("organization_id", filters.organizationId); - if (filters.accountId) query = query.eq("account_id", filters.accountId); - if (filters.jobId) query = query.eq("job_id", filters.jobId); - if (filters.kind) query = query.eq("kind", filters.kind); - const { data, error } = await query; - if (error) throw new BillingStoreError(error.message, 500); - return (data || []).map(ledgerFromRow); + if (isPostgresBackend()) { + const rows = await billingQuery( + `SELECT * FROM billing_ledger + WHERE ($1::text IS NULL OR organization_id = $1) + AND ($2::text IS NULL OR account_id = $2) + AND ($3::text IS NULL OR job_id = $3) + AND ($4::text IS NULL OR kind = $4) + ORDER BY created_at DESC + LIMIT $5`, + [filters.organizationId || null, filters.accountId || null, filters.jobId || null, filters.kind || null, limit] + ); + return rows.map(ledgerFromRow); } const state = await readState(); return state.ledgerEntries @@ -321,13 +347,6 @@ function emptyWallet(organizationId: string): OrganizationWallet { }; } -function getSupabaseAdmin(): SupabaseClient | null { - const url = process.env.NEXT_PUBLIC_SUPABASE_URL; - const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - if (!url || !serviceRoleKey) return null; - return createClient(url, serviceRoleKey, { auth: { persistSession: false } }); -} - function priceRuleToRow(rule: Partial) { return { id: rule.id, @@ -336,10 +355,10 @@ function priceRuleToRow(rule: Partial) { req_key: rule.reqKey || null, variant_key: rule.variantKey || null, unit: rule.unit, - standard_unit_price_fen: rule.standardUnitPriceFen, - markup_multiplier: rule.markupMultiplier, + standard_unit_price_fen: safeInteger(rule.standardUnitPriceFen, "billing price rule standard unit price"), + markup_multiplier: finiteNumber(rule.markupMultiplier, "billing price rule markup multiplier"), enabled: rule.enabled, - conditions: rule.conditions || {}, + conditions: canonicalConditionValue(rule.conditions || {}) as BillingRuleConditions, quantity_source: rule.quantitySource || null, priority: rule.priority || 0, note: rule.note || null, @@ -358,8 +377,8 @@ function priceRuleFromRow(row: Record): BillingPriceRule { reqKey: optionalString(row.req_key), variantKey: optionalString(row.variant_key), unit: row.unit as BillingPriceRule["unit"], - standardUnitPriceFen: Number(row.standard_unit_price_fen || 0), - markupMultiplier: Number(row.markup_multiplier || 1), + standardUnitPriceFen: safeInteger(row.standard_unit_price_fen, "billing_price_rules.standard_unit_price_fen"), + markupMultiplier: finiteNumber(row.markup_multiplier ?? 1, "billing_price_rules.markup_multiplier"), enabled: row.enabled !== false, conditions: isRecord(row.conditions) ? row.conditions as BillingRuleConditions : undefined, quantitySource: row.quantity_source === "request" || row.quantity_source === "image_count" || row.quantity_source === "duration" @@ -368,9 +387,9 @@ function priceRuleFromRow(row: Record): BillingPriceRule { priority: Number.isFinite(Number(row.priority)) ? Number(row.priority) : 0, note: optionalString(row.note), source: isRecord(row.source) ? row.source as BillingPriceRule["source"] : undefined, - parameterDimensions: Array.isArray(row.parameter_dimensions) ? row.parameter_dimensions as BillingParameterDimension[] : undefined, - createdAt: String(row.created_at), - updatedAt: String(row.updated_at) + parameterDimensions: billingParameterDimensions(row.parameter_dimensions), + createdAt: databaseTimestamp(row.created_at, "billing_price_rules.created_at"), + updatedAt: databaseTimestamp(row.updated_at, "billing_price_rules.updated_at") }; } @@ -397,20 +416,20 @@ function canonicalConditionValue(value: unknown): unknown { function walletFromRow(row: Record): OrganizationWallet { return { organizationId: String(row.organization_id), - balanceFen: Number(row.balance_fen || 0), - totalRechargedFen: Number(row.total_recharged_fen || 0), - totalChargedFen: Number(row.total_charged_fen || 0), - updatedAt: String(row.updated_at || new Date().toISOString()) + balanceFen: safeInteger(row.balance_fen, "billing_wallets.balance_fen"), + totalRechargedFen: safeInteger(row.total_recharged_fen, "billing_wallets.total_recharged_fen"), + totalChargedFen: safeInteger(row.total_charged_fen, "billing_wallets.total_charged_fen"), + updatedAt: databaseTimestamp(row.updated_at ?? new Date(), "billing_wallets.updated_at") }; } function walletFromRpcRow(row: Record, organizationId: string): OrganizationWallet { return { organizationId, - balanceFen: Number(row.balance_fen || row.balance_after_fen || 0), - totalRechargedFen: Number(row.total_recharged_fen || 0), - totalChargedFen: Number(row.total_charged_fen || 0), - updatedAt: String(row.updated_at || row.created_at || new Date().toISOString()) + balanceFen: safeInteger(row.balance_fen ?? row.balance_after_fen, "billing_post_wallet_entry.balance_fen"), + totalRechargedFen: safeInteger(row.total_recharged_fen, "billing_post_wallet_entry.total_recharged_fen"), + totalChargedFen: safeInteger(row.total_charged_fen, "billing_post_wallet_entry.total_charged_fen"), + updatedAt: databaseTimestamp(row.updated_at ?? row.created_at ?? new Date(), "billing_post_wallet_entry.updated_at") }; } @@ -421,13 +440,13 @@ function ledgerFromRow(row: Record): BillingLedgerEntry { accountId: optionalString(row.account_id), jobId: optionalString(row.job_id), kind: row.kind as BillingLedgerKind, - deltaFen: Number(row.delta_fen || 0), - balanceAfterFen: Number(row.balance_after_fen || 0), + deltaFen: safeInteger(row.delta_fen, "billing_ledger.delta_fen"), + balanceAfterFen: safeInteger(row.balance_after_fen, "billing_ledger.balance_after_fen"), currency: row.currency === "CNY" ? "CNY" : "CNY", idempotencyKey: String(row.idempotency_key), description: String(row.description || ""), metadata: isRecord(row.metadata) ? row.metadata : {}, - createdAt: String(row.created_at) + createdAt: databaseTimestamp(row.created_at, "billing_ledger.created_at") }; } @@ -438,13 +457,13 @@ function ledgerFromRpcRow(row: Record, input: WalletEntryInput, accountId, jobId: input.jobId, kind: input.kind, - deltaFen: Number(row.delta_fen ?? input.deltaFen), - balanceAfterFen: Number(row.balance_after_fen || 0), + deltaFen: safeInteger(row.delta_fen ?? input.deltaFen, "billing_post_wallet_entry.delta_fen"), + balanceAfterFen: safeInteger(row.balance_after_fen, "billing_post_wallet_entry.balance_after_fen"), currency: input.currency || "CNY", idempotencyKey: input.idempotencyKey, description: input.description, metadata: input.metadata || {}, - createdAt: String(row.created_at || new Date().toISOString()) + createdAt: databaseTimestamp(row.created_at ?? new Date(), "billing_post_wallet_entry.created_at") }; } @@ -453,11 +472,6 @@ function effectiveLedgerAccountId(input: Pick | null { - if (Array.isArray(value)) return isRecord(value[0]) ? value[0] : null; - return isRecord(value) ? value : null; -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -468,10 +482,71 @@ function optionalString(value: unknown): string | undefined { return trimmed || undefined; } +async function billingQuery( + text: string, + values: readonly unknown[] = [], + conflictOnUniqueViolation = false +): Promise[]> { + try { + const result = await queryDatabase>(text, values); + return result.rows; + } catch (error) { + if (error instanceof BillingStoreError) throw error; + const message = error instanceof Error ? error.message : String(error); + const code = isRecord(error) ? optionalString(error.code) : undefined; + throw new BillingStoreError(message, conflictOnUniqueViolation && code === "23505" ? 409 : 500); + } +} + +function safeInteger(value: unknown, field: string): number { + const numberValue = typeof value === "bigint" ? Number(value) : Number(value ?? 0); + if (!Number.isSafeInteger(numberValue)) { + throw new BillingStoreError(`数据库字段 ${field} 超出 JavaScript 安全整数范围。`, 500); + } + return numberValue; +} + +function finiteNumber(value: unknown, field: string): number { + const numberValue = Number(value); + if (!Number.isFinite(numberValue)) throw new BillingStoreError(`数据库字段 ${field} 不是有限数值。`, 500); + return numberValue; +} + +function databaseTimestamp(value: unknown, field: string): string { + if (value instanceof Date && Number.isFinite(value.getTime())) return value.toISOString(); + if (typeof value === "string") { + const parsed = new Date(value); + if (Number.isFinite(parsed.getTime())) return parsed.toISOString(); + } + throw new BillingStoreError(`数据库字段 ${field} 不是有效时间。`, 500); +} + +function billingParameterDimensions(value: unknown): BillingParameterDimension[] | undefined { + if (!Array.isArray(value)) return undefined; + return value.map((dimension, dimensionIndex) => { + if (!isRecord(dimension) || !Array.isArray(dimension.tiers)) { + throw new BillingStoreError(`数据库字段 billing_price_rules.parameter_dimensions[${dimensionIndex}] 格式无效。`, 500); + } + return { + ...dimension, + tiers: dimension.tiers.map((tier, tierIndex) => { + if (!isRecord(tier)) { + throw new BillingStoreError(`数据库字段 billing_price_rules.parameter_dimensions[${dimensionIndex}].tiers[${tierIndex}] 格式无效。`, 500); + } + return { + ...tier, + standardFactor: finiteNumber(tier.standardFactor, `billing_price_rules.parameter_dimensions[${dimensionIndex}].tiers[${tierIndex}].standardFactor`), + markupMultiplier: finiteNumber(tier.markupMultiplier, `billing_price_rules.parameter_dimensions[${dimensionIndex}].tiers[${tierIndex}].markupMultiplier`) + }; + }) + } as BillingParameterDimension; + }); +} + function normalizeBillingStoreErrorMessage(message: string): string { if (/(billing_|billing_post_wallet_entry|variant_key|standard_unit_price_fen|markup_multiplier|source)/i.test(message) - && /(schema cache|relation .* does not exist|table .* does not exist|column .* does not exist|could not find|function .* does not exist)/i.test(message)) { - return "计费数据库尚未初始化或未完成升级,请在 Supabase SQL Editor 执行仓库中的 supabase/schema.sql 后重启服务。"; + && /(relation .* does not exist|table .* does not exist|column .* does not exist|function .* does not exist)/i.test(message)) { + return "计费数据库尚未初始化或未完成升级,请先对 PostgreSQL 数据库运行仓库中的版本化迁移,再重启服务。"; } return message; } diff --git a/lib/server/data-store.ts b/lib/server/data-store.ts index a7b35b2..81136ed 100644 --- a/lib/server/data-store.ts +++ b/lib/server/data-store.ts @@ -1,7 +1,9 @@ +import "server-only"; + import { readFile, rename, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { createClient, type SupabaseClient } from "@supabase/supabase-js"; import type { AppState, Asset, BillingParameterSnapshot, BillingPriceSource, BillingQuantitySource, BillingRuleConditions, BillingSelectedParameterTier, GenerationCapability, GenerationJob, GenerationStatus, ImageTemplate, Project, UsageContext, UsageEvent, UsageSource } from "@/lib/types"; +import { isPostgresBackend, queryDatabase, withDatabaseTransaction } from "@/lib/server/database"; import { createId } from "@/lib/server/ids"; import { dataDir, DEFAULT_OWNER_ID, ensureRuntimeDirs } from "@/lib/server/runtime"; @@ -36,37 +38,27 @@ export type ClaimGenerationJobsInput = { }; export async function listAssets(ownerId = DEFAULT_OWNER_ID): Promise { - const supabase = getSupabaseAdmin(); - if (supabase) { - const { data, error } = await supabase - .from("assets") - .select("*") - .eq("owner_id", ownerId) - .order("created_at", { ascending: false }); - if (error) throw new Error(error.message); - return (data || []).map(assetFromRow); + if (isPostgresBackend()) { + const { rows } = await queryDatabase("SELECT * FROM assets WHERE owner_id = $1 ORDER BY created_at DESC", [ownerId]); + return rows.map(assetFromRow); } const state = await readState(); return state.assets.filter((asset) => asset.ownerId === ownerId).sort(sortNewest); } export async function getAsset(id: string): Promise { - const supabase = getSupabaseAdmin(); - if (supabase) { - const { data, error } = await supabase.from("assets").select("*").eq("id", id).maybeSingle(); - if (error) throw new Error(error.message); - return data ? assetFromRow(data) : null; + if (isPostgresBackend()) { + const { rows } = await queryDatabase("SELECT * FROM assets WHERE id = $1 LIMIT 1", [id]); + return rows[0] ? assetFromRow(rows[0]) : null; } const state = await readState(); return state.assets.find((asset) => asset.id === id) || null; } export async function getAssetByStoragePath(storagePath: string): Promise { - const supabase = getSupabaseAdmin(); - if (supabase) { - const { data, error } = await supabase.from("assets").select("*").eq("storage_path", storagePath).maybeSingle(); - if (error) throw new Error(error.message); - return data ? assetFromRow(data) : null; + if (isPostgresBackend()) { + const { rows } = await queryDatabase("SELECT * FROM assets WHERE storage_path = $1 LIMIT 1", [storagePath]); + return rows[0] ? assetFromRow(rows[0]) : null; } const state = await readState(); return state.assets.find((asset) => asset.storagePath === storagePath) || null; @@ -83,11 +75,8 @@ export async function createAsset(input: AssetInput): Promise { createdAt: input.createdAt || now, updatedAt: input.updatedAt || now }; - const supabase = getSupabaseAdmin(); - if (supabase) { - const { data, error } = await supabase.from("assets").insert(assetToRow(asset)).select("*").single(); - if (error) throw new Error(error.message); - return assetFromRow(data); + if (isPostgresBackend()) { + return assetFromRow(await insertRow("assets", assetToRow(asset))); } return mutateLocalState((state) => { state.assets.unshift(asset); @@ -98,10 +87,8 @@ export async function createAsset(input: AssetInput): Promise { export async function deleteAsset(id: string): Promise { const existing = await getAsset(id); if (!existing) return null; - const supabase = getSupabaseAdmin(); - if (supabase) { - const { error } = await supabase.from("assets").delete().eq("id", id); - if (error) throw new Error(error.message); + if (isPostgresBackend()) { + await queryDatabase("DELETE FROM assets WHERE id = $1", [id]); return existing; } return mutateLocalState((state) => { @@ -122,21 +109,16 @@ export async function listGenerationJobs(ownerId = DEFAULT_OWNER_ID, limit = 200 export async function listGenerationJobsFiltered(filters: GenerationJobListFilters = {}): Promise { const ownerId = filters.ownerId || DEFAULT_OWNER_ID; const limit = filters.limit || 200; - const supabase = getSupabaseAdmin(); - if (supabase) { - let query = supabase - .from("generation_jobs") - .select("*") - .eq("owner_id", ownerId) - .order("created_at", { ascending: false }) - .limit(limit); - if (filters.externalClientId) query = query.eq("external_client_id", filters.externalClientId); - if (filters.status) query = query.eq("status", filters.status); - if (filters.capability) query = query.eq("capability", filters.capability); - if (filters.before) query = query.lt("created_at", filters.before); - const { data, error } = await query; - if (error) throw new Error(error.message); - return (data || []).map(jobFromRow); + if (isPostgresBackend()) { + const clauses = ["owner_id = $1"]; + const values: unknown[] = [ownerId]; + addFilter(clauses, values, "external_client_id", filters.externalClientId); + addFilter(clauses, values, "status", filters.status); + addFilter(clauses, values, "capability", filters.capability); + if (filters.before) { values.push(filters.before); clauses.push(`created_at < $${values.length}`); } + values.push(limit); + const { rows } = await queryDatabase(`SELECT * FROM generation_jobs WHERE ${clauses.join(" AND ")} ORDER BY created_at DESC LIMIT $${values.length}`, values); + return rows.map(jobFromRow); } const state = await readState(); return state.generationJobs @@ -150,11 +132,9 @@ export async function listGenerationJobsFiltered(filters: GenerationJobListFilte } export async function getGenerationJob(id: string): Promise { - const supabase = getSupabaseAdmin(); - if (supabase) { - const { data, error } = await supabase.from("generation_jobs").select("*").eq("id", id).maybeSingle(); - if (error) throw new Error(error.message); - return data ? jobFromRow(data) : null; + if (isPostgresBackend()) { + const { rows } = await queryDatabase("SELECT * FROM generation_jobs WHERE id = $1 LIMIT 1", [id]); + return rows[0] ? jobFromRow(rows[0]) : null; } const state = await readState(); return state.generationJobs.find((job) => job.id === id) || null; @@ -178,11 +158,8 @@ export async function createGenerationJob(input: JobInput): Promise { state.generationJobs.unshift(job); @@ -195,17 +172,9 @@ export async function findGenerationJobByIdempotency( idempotencyKey: string, ownerId = DEFAULT_OWNER_ID ): Promise { - const supabase = getSupabaseAdmin(); - if (supabase) { - const { data, error } = await supabase - .from("generation_jobs") - .select("*") - .eq("owner_id", ownerId) - .eq("external_client_id", externalClientId) - .eq("idempotency_key", idempotencyKey) - .maybeSingle(); - if (error) throw new Error(error.message); - return data ? jobFromRow(data) : null; + if (isPostgresBackend()) { + const { rows } = await queryDatabase("SELECT * FROM generation_jobs WHERE owner_id = $1 AND external_client_id = $2 AND idempotency_key = $3 LIMIT 1", [ownerId, externalClientId, idempotencyKey]); + return rows[0] ? jobFromRow(rows[0]) : null; } const state = await readState(); return state.generationJobs.find((job) => ( @@ -218,15 +187,9 @@ export async function findGenerationJobByIdempotency( export async function claimGenerationJobs(input: ClaimGenerationJobsInput): Promise { const limit = Math.max(1, Math.min(input.limit || 1, 20)); const lockTimeoutMs = input.lockTimeoutMs ?? 5 * 60 * 1000; - const supabase = getSupabaseAdmin(); - if (supabase) { - const { data, error } = await supabase.rpc("claim_generation_jobs", { - p_worker_id: input.workerId, - p_limit: limit, - p_lock_timeout_seconds: Math.ceil(lockTimeoutMs / 1000) - }); - if (error) throw new Error(`claim_generation_jobs failed: ${error.message}`); - return (Array.isArray(data) ? data : []).map(jobFromRow); + if (isPostgresBackend()) { + const { rows } = await queryDatabase("SELECT * FROM claim_generation_jobs($1, $2, $3)", [input.workerId, limit, Math.ceil(lockTimeoutMs / 1000)]); + return rows.map(jobFromRow); } return mutateLocalState((state) => { @@ -253,21 +216,11 @@ export async function clearGenerationJobLock( options: { clearProviderTaskId?: boolean } = {} ): Promise { const updatedAt = new Date().toISOString(); - const supabase = getSupabaseAdmin(); - if (supabase) { - const { data, error } = await supabase - .from("generation_jobs") - .update({ - ...jobToRow({ ...patch, updatedAt } as GenerationJob), - locked_at: null, - locked_by: null, - ...(options.clearProviderTaskId ? { provider_task_id: null } : {}) - }) - .eq("id", id) - .select("*") - .single(); - if (error) throw new Error(error.message); - return jobFromRow(data); + if (isPostgresBackend()) { + return jobFromRow(await updateRow("generation_jobs", id, { + ...jobToRow({ ...patch, updatedAt } as GenerationJob), locked_at: null, locked_by: null, + ...(options.clearProviderTaskId ? { provider_task_id: null } : {}) + })); } return mutateLocalState((state) => { const index = state.generationJobs.findIndex((job) => job.id === id); @@ -286,16 +239,8 @@ export async function clearGenerationJobLock( export async function updateGenerationJob(id: string, patch: Partial): Promise { const updatedAt = new Date().toISOString(); - const supabase = getSupabaseAdmin(); - if (supabase) { - const { data, error } = await supabase - .from("generation_jobs") - .update(jobToRow({ ...patch, updatedAt } as GenerationJob)) - .eq("id", id) - .select("*") - .single(); - if (error) throw new Error(error.message); - return jobFromRow(data); + if (isPostgresBackend()) { + return jobFromRow(await updateRow("generation_jobs", id, jobToRow({ ...patch, updatedAt } as GenerationJob))); } return mutateLocalState((state) => { const index = state.generationJobs.findIndex((job) => job.id === id); @@ -308,10 +253,8 @@ export async function updateGenerationJob(id: string, patch: Partial { const existing = await getGenerationJob(id); if (!existing) return null; - const supabase = getSupabaseAdmin(); - if (supabase) { - const { error } = await supabase.from("generation_jobs").delete().eq("id", id); - if (error) throw new Error(error.message); + if (isPostgresBackend()) { + await queryDatabase("DELETE FROM generation_jobs WHERE id = $1", [id]); return existing; } return mutateLocalState((state) => { @@ -327,17 +270,18 @@ export async function recordUsageEvent(input: UsageInput): Promise { ownerId: input.ownerId || DEFAULT_OWNER_ID, createdAt: input.createdAt || new Date().toISOString() }; - const supabase = getSupabaseAdmin(); - if (supabase) { - const existing = await findSupabaseUsageEventByJobId(supabase, usage.jobId); + if (isPostgresBackend()) { + const existing = await findDatabaseUsageEventByJobId(usage.jobId); if (existing) return existing; - const { data, error } = await supabase.from("usage_events").insert(usageToRow(usage)).select("*").single(); - if (error?.code === "23505") { - const raced = await findSupabaseUsageEventByJobId(supabase, usage.jobId); - if (raced) return raced; + try { + return usageFromRow(await insertRow("usage_events", usageToRow(usage))); + } catch (error) { + if (isUniqueViolation(error)) { + const raced = await findDatabaseUsageEventByJobId(usage.jobId); + if (raced) return raced; + } + throw error; } - if (error) throw new Error(error.message); - return usageFromRow(data); } return mutateLocalState((state) => { const existing = state.usageEvents.find((event) => event.jobId === usage.jobId); @@ -369,26 +313,15 @@ export async function recordUsageForJob(job: GenerationJob): Promise { - const supabase = getSupabaseAdmin(); - if (supabase) { - const rows: Record[] = []; - const pageSize = 1000; - for (let offset = 0; ; offset += pageSize) { - let query = supabase - .from("usage_events") - .select("*") - .order("created_at", { ascending: false }) - .range(offset, offset + pageSize - 1); - if (filters.ownerId) query = query.eq("owner_id", filters.ownerId); - if (filters.source) query = query.eq("source", filters.source); - if (filters.from) query = query.gte("created_at", filters.from); - if (filters.to) query = query.lt("created_at", filters.to); - const { data, error } = await query; - if (error) throw new Error(error.message); - const page = (data || []) as Record[]; - rows.push(...page); - if (page.length < pageSize) break; - } + if (isPostgresBackend()) { + const clauses: string[] = []; + const values: unknown[] = []; + addFilter(clauses, values, "owner_id", filters.ownerId); + addFilter(clauses, values, "source", filters.source); + if (filters.from) { values.push(filters.from); clauses.push(`created_at >= $${values.length}`); } + if (filters.to) { values.push(filters.to); clauses.push(`created_at < $${values.length}`); } + const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : ""; + const { rows } = await queryDatabase(`SELECT * FROM usage_events${where} ORDER BY created_at DESC`, values); return dedupeUsageEvents(rows.map(usageFromRow)); } @@ -403,21 +336,18 @@ export async function listUsageEvents(filters: UsageEventListFilters = {}): Prom } export async function listProjects(ownerId = DEFAULT_OWNER_ID): Promise { + if (isPostgresBackend()) { + const { rows } = await queryDatabase("SELECT * FROM projects WHERE owner_id = $1 ORDER BY created_at DESC", [ownerId]); + return rows.map(projectFromRow); + } const state = await readState(); return state.projects.filter((project) => project.ownerId === ownerId).sort(sortNewest); } export async function listImageTemplates(ownerId = DEFAULT_OWNER_ID): Promise { - const supabase = getSupabaseAdmin(); - if (supabase) { - const { data, error } = await supabase - .from("image_templates") - .select("*") - .eq("owner_id", ownerId) - .order("sort_order", { ascending: true }) - .order("updated_at", { ascending: false }); - if (error) throw new Error(error.message); - return (data || []).map(imageTemplateFromRow); + if (isPostgresBackend()) { + const { rows } = await queryDatabase("SELECT * FROM image_templates WHERE owner_id = $1 ORDER BY sort_order ASC, updated_at DESC", [ownerId]); + return rows.map(imageTemplateFromRow); } const state = await readState(); return state.imageTemplates @@ -426,11 +356,9 @@ export async function listImageTemplates(ownerId = DEFAULT_OWNER_ID): Promise { - const supabase = getSupabaseAdmin(); - if (supabase) { - const { data, error } = await supabase.from("image_templates").select("*").eq("id", id).maybeSingle(); - if (error) throw new Error(error.message); - return data ? imageTemplateFromRow(data) : null; + if (isPostgresBackend()) { + const { rows } = await queryDatabase("SELECT * FROM image_templates WHERE id = $1 LIMIT 1", [id]); + return rows[0] ? imageTemplateFromRow(rows[0]) : null; } const state = await readState(); return state.imageTemplates.find((template) => template.id === id) || null; @@ -447,11 +375,8 @@ export async function createImageTemplate(input: ImageTemplateInput): Promise { state.imageTemplates.unshift(template); @@ -473,17 +398,14 @@ export async function updateImageTemplate( settings: patch.settings || existing.settings, updatedAt: new Date().toISOString() }; - const supabase = getSupabaseAdmin(); - if (supabase) { - const { data, error } = await supabase - .from("image_templates") - .update(imageTemplateToRow(updated)) - .eq("id", id) - .eq("owner_id", ownerId) - .select("*") - .maybeSingle(); - if (error) throw new Error(error.message); - return data ? imageTemplateFromRow(data) : null; + if (isPostgresBackend()) { + const row = imageTemplateToRow(updated); + const entries = Object.entries(row); + const values = entries.map(([, value]) => value); + values.push(id, ownerId); + const assignments = entries.map(([column], index) => `${column} = $${index + 1}`).join(", "); + const { rows } = await queryDatabase(`UPDATE image_templates SET ${assignments} WHERE id = $${values.length - 1} AND owner_id = $${values.length} RETURNING *`, values); + return rows[0] ? imageTemplateFromRow(rows[0]) : null; } return mutateLocalState((state) => { const index = state.imageTemplates.findIndex((template) => template.id === id && template.ownerId === ownerId); @@ -497,10 +419,8 @@ export async function updateImageTemplate( export async function deleteImageTemplate(id: string, ownerId: string): Promise { const existing = await getImageTemplate(id); if (!existing || existing.ownerId !== ownerId) return null; - const supabase = getSupabaseAdmin(); - if (supabase) { - const { error } = await supabase.from("image_templates").delete().eq("id", id).eq("owner_id", ownerId); - if (error) throw new Error(error.message); + if (isPostgresBackend()) { + await queryDatabase("DELETE FROM image_templates WHERE id = $1 AND owner_id = $2", [id, ownerId]); return existing; } return mutateLocalState((state) => { @@ -511,12 +431,12 @@ export async function deleteImageTemplate(id: string, ownerId: string): Promise< export async function reassignOwnerData(fromOwnerId: string, toOwnerId: string): Promise { if (!fromOwnerId || !toOwnerId || fromOwnerId === toOwnerId) return; - const supabase = getSupabaseAdmin(); - if (supabase) { - for (const table of ["assets", "generation_jobs", "projects", "image_templates"] as const) { - const { error } = await supabase.from(table).update({ owner_id: toOwnerId }).eq("owner_id", fromOwnerId); - if (error) throw new Error(error.message); - } + if (isPostgresBackend()) { + await withDatabaseTransaction(async (client) => { + for (const table of ["assets", "generation_jobs", "projects", "image_templates", "usage_events"] as const) { + await client.query(`UPDATE ${table} SET owner_id = $1 WHERE owner_id = $2`, [toOwnerId, fromOwnerId]); + } + }); return; } await mutateLocalState((state) => { @@ -571,15 +491,6 @@ function normalizeState(raw: Partial): AppState { }; } -function getSupabaseAdmin(): SupabaseClient | null { - const url = process.env.NEXT_PUBLIC_SUPABASE_URL; - const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - if (!url || !serviceRoleKey) return null; - return createClient(url, serviceRoleKey, { - auth: { persistSession: false } - }); -} - function sortNewest(a: T, b: T): number { return b.createdAt.localeCompare(a.createdAt); } @@ -631,8 +542,8 @@ function assetFromRow(row: Record): Asset { source: row.source as Asset["source"], tags: Array.isArray(row.tags) ? row.tags.map(String) : [], metadata: isRecord(row.metadata) ? row.metadata : {}, - createdAt: String(row.created_at), - updatedAt: String(row.updated_at) + createdAt: requiredTimestamp(row.created_at, "assets.created_at"), + updatedAt: requiredTimestamp(row.updated_at, "assets.updated_at") }; } @@ -697,11 +608,11 @@ function jobFromRow(row: Record): GenerationJob { priority: optionalNumber(row.priority), attempts: optionalNumber(row.attempts), maxAttempts: optionalNumber(row.max_attempts), - scheduledAt: optionalString(row.scheduled_at), - lockedAt: optionalString(row.locked_at), + scheduledAt: optionalTimestamp(row.scheduled_at), + lockedAt: optionalTimestamp(row.locked_at), lockedBy: optionalString(row.locked_by), - startedAt: optionalString(row.started_at), - completedAt: optionalString(row.completed_at), + startedAt: optionalTimestamp(row.started_at), + completedAt: optionalTimestamp(row.completed_at), webhookUrl: optionalString(row.webhook_url), webhookAttempts: optionalNumber(row.webhook_attempts), webhookLastStatus: isRecord(row.webhook_last_status) @@ -715,8 +626,8 @@ function jobFromRow(row: Record): GenerationJob { : undefined, usageContext: usageContextFromValue(row.usage_context), billing: billingJobChargeFromValue(row.billing), - createdAt: String(row.created_at), - updatedAt: String(row.updated_at) + createdAt: requiredTimestamp(row.created_at, "generation_jobs.created_at"), + updatedAt: requiredTimestamp(row.updated_at, "generation_jobs.updated_at") }; } @@ -760,14 +671,13 @@ function usageFromRow(row: Record): UsageEvent { estimatedUnit: row.estimated_unit === "video_second" || row.estimated_unit === "image" ? row.estimated_unit : "job", chargedAmountFen: optionalNumber(row.charged_amount_fen), currency: row.currency === "CNY" ? "CNY" : undefined, - createdAt: String(row.created_at) + createdAt: requiredTimestamp(row.created_at, "usage_events.created_at") }; } -async function findSupabaseUsageEventByJobId(supabase: SupabaseClient, jobId: string): Promise { - const { data, error } = await supabase.from("usage_events").select("*").eq("job_id", jobId).maybeSingle(); - if (error) throw new Error(error.message); - return data ? usageFromRow(data as Record) : null; +async function findDatabaseUsageEventByJobId(jobId: string): Promise { + const { rows } = await queryDatabase("SELECT * FROM usage_events WHERE job_id = $1 LIMIT 1", [jobId]); + return rows[0] ? usageFromRow(rows[0]) : null; } function enrichLegacyUsageEvent(event: UsageEvent, job?: GenerationJob): UsageEvent { @@ -901,11 +811,61 @@ function imageTemplateFromRow(row: Record): ImageTemplate { quality: row.settings.quality === "low" || row.settings.quality === "medium" || row.settings.quality === "high" ? row.settings.quality : undefined } : {}, sortOrder: optionalNumber(row.sort_order) ?? 0, - createdAt: String(row.created_at), - updatedAt: String(row.updated_at) + createdAt: requiredTimestamp(row.created_at, "image_templates.created_at"), + updatedAt: requiredTimestamp(row.updated_at, "image_templates.updated_at") }; } +function projectFromRow(row: Record): Project { + return { + id: String(row.id), + ownerId: String(row.owner_id), + name: String(row.name || ""), + brief: String(row.brief || ""), + type: isProjectType(row.type) ? row.type : "custom", + assetIds: Array.isArray(row.asset_ids) ? row.asset_ids.map(String) : [], + createdAt: requiredTimestamp(row.created_at, "projects.created_at"), + updatedAt: requiredTimestamp(row.updated_at, "projects.updated_at") + }; +} + +function isProjectType(value: unknown): value is Project["type"] { + return value === "brand" || value === "store" || value === "commerce" || value === "event" || value === "course" || value === "ip" || value === "custom"; +} + +async function insertRow(table: string, row: Record): Promise> { + const entries = Object.entries(row); + const columns = entries.map(([column]) => column).join(", "); + const placeholders = entries.map((_, index) => `$${index + 1}`).join(", "); + const { rows } = await queryDatabase(`INSERT INTO ${table} (${columns}) VALUES (${placeholders}) RETURNING *`, entries.map(([, value]) => value)); + return rows[0]; +} + +async function updateRow(table: string, id: string, row: Record): Promise> { + const entries = Object.entries(row); + if (!entries.length) { + const { rows } = await queryDatabase(`SELECT * FROM ${table} WHERE id = $1`, [id]); + if (!rows[0]) throw new Error(`${table} row not found: ${id}`); + return rows[0]; + } + const values = entries.map(([, value]) => value); + values.push(id); + const assignments = entries.map(([column], index) => `${column} = $${index + 1}`).join(", "); + const { rows } = await queryDatabase(`UPDATE ${table} SET ${assignments} WHERE id = $${values.length} RETURNING *`, values); + if (!rows[0]) throw new Error(`${table} row not found: ${id}`); + return rows[0]; +} + +function addFilter(clauses: string[], values: unknown[], column: string, value: unknown): void { + if (value === undefined || value === null || value === "") return; + values.push(value); + clauses.push(`${column} = $${values.length}`); +} + +function isUniqueViolation(error: unknown): boolean { + return isRecord(error) && error.code === "23505"; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -916,6 +876,20 @@ function optionalString(value: unknown): string | undefined { return trimmed || undefined; } +function requiredTimestamp(value: unknown, field: string): string { + const timestamp = optionalTimestamp(value); + if (!timestamp) throw new Error(`Invalid PostgreSQL timestamp: ${field}`); + return timestamp; +} + +function optionalTimestamp(value: unknown): string | undefined { + if (value === undefined || value === null || value === "") return undefined; + if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value.toISOString(); + if (typeof value !== "string") return undefined; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString(); +} + function optionalNumber(value: unknown): number | undefined { if (value === undefined || value === null || value === "") return undefined; const parsed = Number(value); diff --git a/lib/server/database.ts b/lib/server/database.ts new file mode 100644 index 0000000..3d3b27c --- /dev/null +++ b/lib/server/database.ts @@ -0,0 +1,158 @@ +import "server-only"; + +import { readFileSync } from "node:fs"; +import { Pool, type PoolClient, type PoolConfig, type QueryResult, type QueryResultRow } from "pg"; + +export type DataBackend = "local" | "postgres"; + +let pool: Pool | undefined; + +export function getDataBackend(): DataBackend { + const configured = process.env.ZHINIAN_DATA_BACKEND?.trim().toLowerCase(); + if (configured === "local" || configured === "postgres") return configured; + if (!configured && process.env.NODE_ENV !== "production") return "local"; + throw new Error("ZHINIAN_DATA_BACKEND must be explicitly set to 'local' or 'postgres'"); +} + +export function isPostgresBackend(): boolean { + return getDataBackend() === "postgres"; +} + +export async function queryDatabase( + text: string, + values: readonly unknown[] = [] +): Promise> { + return getPool().query(text, [...values]); +} + +export async function withDatabaseTransaction(fn: (client: PoolClient) => Promise): Promise { + const client = await getPool().connect(); + try { + await client.query("BEGIN"); + const result = await fn(client); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +export async function checkDatabaseReadiness(): Promise { + if (!isPostgresBackend()) return; + const { rows } = await queryDatabase<{ ready: boolean }>(` + WITH required_table_privileges(table_name, privilege_name) AS ( + VALUES + ('assets', 'SELECT'), ('assets', 'INSERT'), ('assets', 'DELETE'), + ('generation_jobs', 'SELECT'), ('generation_jobs', 'INSERT'), ('generation_jobs', 'UPDATE'), ('generation_jobs', 'DELETE'), + ('usage_events', 'SELECT'), ('usage_events', 'INSERT'), ('usage_events', 'UPDATE'), + ('projects', 'SELECT'), ('projects', 'UPDATE'), + ('image_templates', 'SELECT'), ('image_templates', 'INSERT'), ('image_templates', 'UPDATE'), ('image_templates', 'DELETE'), + ('platform_organizations', 'SELECT'), ('platform_organizations', 'INSERT'), ('platform_organizations', 'UPDATE'), ('platform_organizations', 'DELETE'), + ('platform_users', 'SELECT'), ('platform_users', 'INSERT'), ('platform_users', 'UPDATE'), ('platform_users', 'DELETE'), + ('platform_account_migrations', 'SELECT'), ('platform_account_migrations', 'INSERT'), ('platform_account_migrations', 'UPDATE'), + ('billing_price_rules', 'SELECT'), ('billing_price_rules', 'INSERT'), ('billing_price_rules', 'UPDATE'), + ('billing_wallets', 'SELECT'), ('billing_wallets', 'INSERT'), ('billing_wallets', 'UPDATE'), + ('billing_ledger', 'SELECT'), ('billing_ledger', 'INSERT') + ) + SELECT + NOT EXISTS ( + SELECT 1 + FROM required_table_privileges + WHERE to_regclass('public.' || table_name) IS NULL + OR NOT has_table_privilege(current_user, 'public.' || table_name, privilege_name) + ) + AND has_function_privilege( + current_user, + 'public.claim_generation_jobs(text,integer,integer)', + 'EXECUTE' + ) + AND has_function_privilege( + current_user, + 'public.billing_post_wallet_entry(text,text,text,text,text,bigint,text,text,text,jsonb)', + 'EXECUTE' + ) AS ready + `); + if (!rows[0]?.ready) { + throw new Error("PostgreSQL schema or application privileges are not ready"); + } +} + +export function getDatabaseStatus(): { backend: DataBackend; configured: boolean } { + const backend = getDataBackend(); + return { backend, configured: backend === "local" || Boolean(process.env.DATABASE_URL?.trim()) }; +} + +export async function closeDatabasePool(): Promise { + const current = pool; + pool = undefined; + if (current) await current.end(); +} + +function getPool(): Pool { + if (!isPostgresBackend()) throw new Error("PostgreSQL is unavailable when ZHINIAN_DATA_BACKEND=local"); + if (pool) return pool; + + const connectionString = process.env.DATABASE_URL?.trim(); + if (!connectionString) throw new Error("DATABASE_URL is required when ZHINIAN_DATA_BACKEND=postgres"); + assertConnectionStringContract(connectionString); + const config: PoolConfig = { + connectionString, + max: positiveInteger("DATABASE_POOL_MAX", 10), + idleTimeoutMillis: nonNegativeInteger("DATABASE_IDLE_TIMEOUT_MS", 30_000), + connectionTimeoutMillis: positiveInteger("DATABASE_CONNECTION_TIMEOUT_MS", 10_000), + statement_timeout: positiveInteger("DATABASE_STATEMENT_TIMEOUT_MS", 30_000), + application_name: process.env.DATABASE_APPLICATION_NAME?.trim() || "zhinian-web" + }; + const sslMode = process.env.DATABASE_SSL_MODE?.trim().toLowerCase() || "disable"; + if (sslMode === "verify-full") { + const caPath = process.env.DATABASE_CA_CERT_PATH?.trim(); + if (!caPath) throw new Error("DATABASE_CA_CERT_PATH is required when DATABASE_SSL_MODE=verify-full"); + config.ssl = { ca: readFileSync(caPath, "utf8"), rejectUnauthorized: true }; + } else if (sslMode !== "disable") { + throw new Error("DATABASE_SSL_MODE must be 'disable' or 'verify-full'"); + } + pool = new Pool(config); + pool.on("error", (error) => console.error("Unexpected PostgreSQL pool error", error)); + return pool; +} + +function assertConnectionStringContract(connectionString: string): void { + let parsed: URL; + try { + parsed = new URL(connectionString); + } catch { + throw new Error("DATABASE_URL must be a valid PostgreSQL connection URI"); + } + if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") { + throw new Error("DATABASE_URL must use the postgres:// or postgresql:// scheme"); + } + const sslParameters = [...parsed.searchParams.keys()].filter((key) => key.toLowerCase().startsWith("ssl")); + if (sslParameters.length > 0) { + throw new Error( + `DATABASE_URL must not contain SSL query parameters (${sslParameters.join(", ")}); use DATABASE_SSL_MODE and DATABASE_CA_CERT_PATH` + ); + } +} + +function positiveInteger(name: string, fallback: number): number { + const value = integer(name, fallback); + if (value <= 0) throw new Error(`${name} must be a positive integer`); + return value; +} + +function nonNegativeInteger(name: string, fallback: number): number { + const value = integer(name, fallback); + if (value < 0) throw new Error(`${name} must be a non-negative integer`); + return value; +} + +function integer(name: string, fallback: number): number { + const raw = process.env[name]?.trim(); + if (!raw) return fallback; + const value = Number(raw); + if (!Number.isSafeInteger(value)) throw new Error(`${name} must be an integer`); + return value; +} diff --git a/package-lock.json b/package-lock.json index 223861a..fcdf574 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,25 +8,27 @@ "name": "zhinian-creation-assistant", "version": "0.1.0", "dependencies": { - "@supabase/supabase-js": "^2.49.4", "ali-oss": "^6.23.0", "clsx": "^2.1.1", "graceful-fs": "^4.2.11", "gsap": "^3.15.0", "lucide-react": "^0.468.0", - "next": "^15.1.4", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "next": "15.5.18", + "pg": "^8.16.3", + "react": "19.2.6", + "react-dom": "19.2.6", + "server-only": "0.0.1", "zod": "^3.24.1" }, "devDependencies": { "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", "@types/node": "^22.10.5", - "@types/react": "^19.0.4", - "@types/react-dom": "^19.0.2", - "typescript": "^5.7.2", - "vitest": "^4.1.7" + "@types/pg": "^8.15.5", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "typescript": "5.9.3", + "vitest": "4.1.7" }, "engines": { "node": ">=20" @@ -1016,90 +1018,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@supabase/auth-js": { - "version": "2.106.2", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.106.2.tgz", - "integrity": "sha512-VcAjUErkHkhC5Jaf+g/G1qbkQrFh8edaCdHa7pxJmHUjkWKjT7UnYCtPA89XV0N0GIYRkEqJZw5V62CtOxTmBQ==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/functions-js": { - "version": "2.106.2", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.106.2.tgz", - "integrity": "sha512-oRnr0QrL8H+zTO1YyQ1QjiHZU/957jvubbxSJTUm2XLAgzoGGV9Tahfyd+uvLsBLRVmXLtpU3oyCjdQIvkGMOA==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/phoenix": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz", - "integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==", - "license": "MIT" - }, - "node_modules/@supabase/postgrest-js": { - "version": "2.106.2", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.106.2.tgz", - "integrity": "sha512-tDOzyPgp9pIRMR2x6C9+uDSJrnXSzxLtt3d7nC+Lrsy3jnJDHYfdQC/xcRyhJE/TOBJ0heSqRKR3UmejDjZxsw==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/realtime-js": { - "version": "2.106.2", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.106.2.tgz", - "integrity": "sha512-LdRGT7DNhyZkPjubUv5bSdAZ0jSEX8wTHvx7htj7+K59TOZRvz4TuQK7tL2RWxyIZVeFMRluL04SzWS61rKnUA==", - "license": "MIT", - "dependencies": { - "@supabase/phoenix": "^0.4.2", - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/storage-js": { - "version": "2.106.2", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.106.2.tgz", - "integrity": "sha512-xgKCSYuev1YarV+iVqr+zlfgSyremnJtn8T0NCT8L4XmMv1CLtESc0Q6kNp8+mKWdX/8ND0nzm7OMKx08kwNAw==", - "license": "MIT", - "dependencies": { - "iceberg-js": "^0.8.1", - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/supabase-js": { - "version": "2.106.2", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.106.2.tgz", - "integrity": "sha512-2/RZ/1fmJx/MRSEDG2Xk8+J4JVk5clM9V0uSI6kUTrcS32KA89DtqI5RUOC9r6mzY3WBC9qexLjssIHjbLyVJA==", - "license": "MIT", - "dependencies": { - "@supabase/auth-js": "2.106.2", - "@supabase/functions-js": "2.106.2", - "@supabase/postgrest-js": "2.106.2", - "@supabase/realtime-js": "2.106.2", - "@supabase/storage-js": "2.106.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1239,10 +1157,22 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.21.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/@types/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AYdtudzabjLZgVgRZmAnU8bAnVUXzuJX2IYHeSIiIHm68olD+LgQYCGWdtcNYnP0uq9c4S4NibVG3Ni7VbKW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", - "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "version": "19.2.14", + "resolved": "https://mirrors.cloud.tencent.com/npm/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", "dependencies": { @@ -1960,15 +1890,6 @@ "ms": "^2.0.0" } }, - "node_modules/iceberg-js": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", - "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", - "license": "MIT", - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -2600,6 +2521,95 @@ "through": "~2.3" } }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2653,6 +2663,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://mirrors.cloud.tencent.com/npm/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -2841,6 +2890,12 @@ "node": ">=10" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -2974,6 +3029,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", diff --git a/package.json b/package.json index dbaa49a..8d31683 100644 --- a/package.json +++ b/package.json @@ -16,30 +16,34 @@ "health": "node scripts/health-check.mjs", "info": "node scripts/print-app-info.mjs", "bootstrap:admin": "node scripts/bootstrap-admin.mjs", + "db:migrate": "node scripts/migrate-postgres.mjs", + "deploy:check": "node scripts/check-ack-manifests.mjs", "migrate:accounts": "node scripts/import-legacy-accounts.mjs", "test": "vitest run", "test:watch": "vitest" }, "dependencies": { - "@supabase/supabase-js": "^2.49.4", "ali-oss": "^6.23.0", "clsx": "^2.1.1", "graceful-fs": "^4.2.11", "gsap": "^3.15.0", "lucide-react": "^0.468.0", - "next": "^15.1.4", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "next": "15.5.18", + "pg": "^8.16.3", + "react": "19.2.6", + "react-dom": "19.2.6", + "server-only": "0.0.1", "zod": "^3.24.1" }, "devDependencies": { "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", "@types/node": "^22.10.5", - "@types/react": "^19.0.4", - "@types/react-dom": "^19.0.2", - "typescript": "^5.7.2", - "vitest": "^4.1.7" + "@types/pg": "^8.15.5", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "typescript": "5.9.3", + "vitest": "4.1.7" }, "overrides": { "postcss": "^8.5.10" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e26b897..eceb514 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - '@supabase/supabase-js': - specifier: ^2.49.4 - version: 2.112.2 ali-oss: specifier: ^6.23.0 version: 6.23.0 @@ -25,16 +22,22 @@ importers: version: 3.15.0 lucide-react: specifier: ^0.468.0 - version: 0.468.0(react@19.2.8) + version: 0.468.0(react@19.2.6) next: - specifier: ^15.1.4 - version: 15.5.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: 15.5.18 + version: 15.5.18(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + pg: + specifier: ^8.16.3 + version: 8.23.0 react: - specifier: ^19.0.0 - version: 19.2.8 + specifier: 19.2.6 + version: 19.2.6 react-dom: - specifier: ^19.0.0 - version: 19.2.8(react@19.2.8) + specifier: 19.2.6 + version: 19.2.6(react@19.2.6) + server-only: + specifier: 0.0.1 + version: 0.0.1 zod: specifier: ^3.24.1 version: 3.25.76 @@ -44,22 +47,25 @@ importers: version: 6.10.0(@testing-library/dom@10.4.1) '@testing-library/react': specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@types/node': specifier: ^22.10.5 version: 22.20.1 + '@types/pg': + specifier: ^8.15.5 + version: 8.21.0 '@types/react': - specifier: ^19.0.4 - version: 19.2.18 + specifier: 19.2.14 + version: 19.2.14 '@types/react-dom': - specifier: ^19.0.2 - version: 19.2.4(@types/react@19.2.18) + specifier: 19.2.3 + version: 19.2.3(@types/react@19.2.14) typescript: - specifier: ^5.7.2 + specifier: 5.9.3 version: 5.9.3 vitest: - specifier: ^4.1.7 - version: 4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)) + specifier: 4.1.7 + version: 4.1.7(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)) packages: @@ -111,105 +117,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -237,57 +227,53 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@next/env@15.5.23': - resolution: {integrity: sha512-Mv3Z9hVbFcPnoLevsZ6rnX1TBtyHb5E17yN7HTPDXSXxeNsGBjUFrdbjRXKKXIOhfth7/cg6Ay7PZ2UFawaWsQ==} + '@next/env@15.5.18': + resolution: {integrity: sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==} - '@next/swc-darwin-arm64@15.5.23': - resolution: {integrity: sha512-SrEwOROH/rhA03F59hHtdhgtfZMWGzr5duDBWgRQt2rS3mJhqMKOcnNx6txOd0/i3E3D3uFKYFvyHsEiwQxzag==} + '@next/swc-darwin-arm64@15.5.18': + resolution: {integrity: sha512-w0WvQf1n+txiwns/9pwIQteCJpZTbxzO2SE0FLcwuD4v0WEh1JPOjdyxWL21XwJsdpx8cFRjyzxzCS/siP7HcQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.5.23': - resolution: {integrity: sha512-f0FpFbG2EhDCuptBGcfrLcYMDuQAhe6m1QA4VVfXFrIBoFXvXt/olGbBkYkloKlXQtmhuzvtdYyuu/6zf07GIg==} + '@next/swc-darwin-x64@15.5.18': + resolution: {integrity: sha512-znn71QmDuxm+BOaglihMZfvyySMnNljkVIY5Z2TCssBmm+WqL6c19VhtH5ktFkHa8EZ2bnTUpcNcmNSQsg67og==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.5.23': - resolution: {integrity: sha512-WlNtfepUXKX2u2ZsJZ8c3c8+tJSRZqsYzoMwLOY72A8ucKCCgxgNhiePA3qzFYahVWrwcQd8jOeJmBinc+VFVQ==} + '@next/swc-linux-arm64-gnu@15.5.18': + resolution: {integrity: sha512-yPPe5MNL+igZUa+OsqQJisqSfh6oarIuA1Q0BDxljGJhRQyZeP+WRHh7rs/jZUGMh5aY0YdIjXZG0VohkKkUdw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] - '@next/swc-linux-arm64-musl@15.5.23': - resolution: {integrity: sha512-W/6qKk7UG93mg14PmQC+2urt69MIdwTBLNQ6MJyeC4wOCIHCjz+VfgssvS1pK7mgYBtLC1g6VKNoHD9xB0WWGg==} + '@next/swc-linux-arm64-musl@15.5.18': + resolution: {integrity: sha512-glaCczEWIrHsokFZ3pP08U4BpKxwIdnT+txdOM32OBgpL9Yw4aqx8NejmgtZQZOdstQ5f0L3CasIZudzCuD+nw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] - '@next/swc-linux-x64-gnu@15.5.23': - resolution: {integrity: sha512-vzefI32mi6VMk96RaTAyxApgfGbiFzQBXVsekEjsDv1fr48mlABTWx0sUYhaYCBHWqCalxmz3DxbxFcbFvzNtw==} + '@next/swc-linux-x64-gnu@15.5.18': + resolution: {integrity: sha512-oUfg2EgJmU3R0OCOWiokGFUTvZiPfXtriXiuF3YNxRoROCdgvTedHIzYoeKH34gsZxS/V7mHbfq2hpAHwhH1/A==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] - '@next/swc-linux-x64-musl@15.5.23': - resolution: {integrity: sha512-qppK/3dTGOTI+aoWWBZc3DshFIhrzgL8guATlaN9V6M1QJxbkP/rhEZ22tdICsQ/2WWXopMZ2Jokzj2u3uKY3Q==} + '@next/swc-linux-x64-musl@15.5.18': + resolution: {integrity: sha512-JLxSP3KTd9iu/bvUMQxH7RJo9xKSHf55/6RPE4a6FTSZygGn7uvZbCej0AHXydwkggQGSD9UddSjwv6Xz5ESfA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] - '@next/swc-win32-arm64-msvc@15.5.23': - resolution: {integrity: sha512-Wc29KFOdT7XBcII3Vtmw7aoU8Uk3Mes/FNJfhFeSHdYBFJWMcR/DsI8U9BCPUhq/uycsUVuqSKGthW15tLsigA==} + '@next/swc-win32-arm64-msvc@15.5.18': + resolution: {integrity: sha512-ir1v7enP52K2HNz3tQQvwF+x7VNxBk1ciiZ18WBPvxf4C59IqdfmHPJYK3vH7rSxpuCVw/8C712wTXNAtEp+NA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.5.23': - resolution: {integrity: sha512-/C7wRW4fa9s/PKA18zGPPpVmx8ycgVpP8yOxro4gzGTzjPJdscbAP3ODeFvgiIovxD176Z2J/SXO9t8PJKHLeQ==} + '@next/swc-win32-x64-msvc@15.5.18': + resolution: {integrity: sha512-LIu5me6QTANCd25E7I5uIEfvgQ06RK7tvHAbYo3zCb3VpxQEPvMcSpd87NwUABDT6MbGPdEGR5VRiK4PPTJhQg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -330,42 +316,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.2.3': resolution: {integrity: sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.2.3': resolution: {integrity: sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.2.3': resolution: {integrity: sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.2.3': resolution: {integrity: sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.2.3': resolution: {integrity: sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.2.3': resolution: {integrity: sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==} @@ -391,38 +371,6 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@supabase/auth-js@2.112.2': - resolution: {integrity: sha512-l1InCp4j98d09LZ6+RgubgF4eVPGBGXcLEhFusLg1qUCHJ2IEkYu5FohKK+eaFmIOwEk0kqG/j/lycw5e15mcQ==} - engines: {node: '>=22.0.0'} - - '@supabase/functions-js@2.112.2': - resolution: {integrity: sha512-oMuSWN0ERmrG9S6kOM0bwhHmESGVl3kMtkZl2dNCU/r89hMiziX4GfD1omNo9QcBDele4N0GwSZ7hdbpuiA35A==} - engines: {node: '>=22.0.0'} - - '@supabase/phoenix@0.4.5': - resolution: {integrity: sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==} - - '@supabase/postgrest-js@2.112.2': - resolution: {integrity: sha512-ewhhtRny/HFRGhUTTg/PsqIatsl8OhW8Eha/Tz4S+SRAXBnuhKei9ZpsQTgL/3XcH9UEwuPQyQgQ9itq7nRQeg==} - engines: {node: '>=22.0.0'} - - '@supabase/realtime-js@2.112.2': - resolution: {integrity: sha512-cd9/CEUJ6Go13FxtfiuC5rYELJtuQzVzTXlGG+XjSppjDS+anq+xo++WQe7ZRUNTuHOCeyKRwmx9Hw/OQJ04ig==} - engines: {node: '>=22.0.0'} - - '@supabase/storage-js@2.112.2': - resolution: {integrity: sha512-6jyBq/J1iXOHNpbjCZS7gFcDk49iM1MCJUVkDl71gLd/+XnLDzpUBs8icGebtwiHpl4kVszxIRDYAosbF4Rsig==} - engines: {node: '>=22.0.0'} - - '@supabase/supabase-js@2.112.2': - resolution: {integrity: sha512-UyI1epU9B4X51HvNpkmlwTdF20fEcz2vyvrcDKVzFN4jZN41f5iQRqsiIQjAY5OVJD6ljqA/1g9JQeOTvFHpkA==} - engines: {node: '>=22.0.0'} - peerDependencies: - '@opentelemetry/api': '>=1.0.0' - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -467,19 +415,22 @@ packages: '@types/node@22.20.1': resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} - '@types/react-dom@19.2.4': - resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} + '@types/pg@8.21.0': + resolution: {integrity: sha512-AYdtudzabjLZgVgRZmAnU8bAnVUXzuJX2IYHeSIiIHm68olD+LgQYCGWdtcNYnP0uq9c4S4NibVG3Ni7VbKW7Q==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: '@types/react': ^19.2.0 - '@types/react@19.2.18': - resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} - '@vitest/expect@4.1.10': - resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + '@vitest/expect@4.1.7': + resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} - '@vitest/mocker@4.1.10': - resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + '@vitest/mocker@4.1.7': + resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -489,20 +440,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.10': - resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + '@vitest/pretty-format@4.1.7': + resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} - '@vitest/runner@4.1.10': - resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + '@vitest/runner@4.1.7': + resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==} - '@vitest/snapshot@4.1.10': - resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + '@vitest/snapshot@4.1.7': + resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==} - '@vitest/spy@4.1.10': - resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + '@vitest/spy@4.1.7': + resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} - '@vitest/utils@4.1.10': - resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@vitest/utils@4.1.7': + resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} address@1.2.2: resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==} @@ -718,10 +669,6 @@ packages: humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} - iceberg-js@0.8.1: - resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} - engines: {node: '>=20.0.0'} - iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -793,28 +740,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.33.0: resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.33.0: resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.33.0: resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.33.0: resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} @@ -881,8 +824,8 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - next@15.5.23: - resolution: {integrity: sha512-Gvd2WKgvxIXCGotxcI1im/Uf3rS3J3oZGw0g/uskg6AVBZhyE3aAbujkYWzS3xLmEPEtTLfkaVQUKK0KMTSIkA==} + next@15.5.18: + resolution: {integrity: sha512-eKL8zUJkX9Y5lE+RX/2YJoItVdGlIscyVyboeD9wSpp0PaGqjoA4tTpT2qPqz9ax+5IzGESyLSeZ/RCwbSZ2uQ==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: @@ -937,6 +880,40 @@ packages: pause-stream@0.0.11: resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.16.0: + resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.23.0: + resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -955,6 +932,22 @@ packages: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -969,16 +962,16 @@ packages: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} - react-dom@19.2.8: - resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + react-dom@19.2.6: + resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} peerDependencies: - react: ^19.2.8 + react: ^19.2.6 react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - react@19.2.8: - resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + react@19.2.6: + resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} readable-stream@2.3.8: @@ -1018,6 +1011,9 @@ packages: engines: {node: '>=10'} hasBin: true + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1045,6 +1041,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -1184,20 +1184,20 @@ packages: yaml: optional: true - vitest@4.1.10: - resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + vitest@4.1.7: + resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.10 - '@vitest/browser-preview': 4.1.10 - '@vitest/browser-webdriverio': 4.1.10 - '@vitest/coverage-istanbul': 4.1.10 - '@vitest/coverage-v8': 4.1.10 - '@vitest/ui': 4.1.10 + '@vitest/browser-playwright': 4.1.7 + '@vitest/browser-preview': 4.1.7 + '@vitest/browser-webdriverio': 4.1.7 + '@vitest/coverage-istanbul': 4.1.7 + '@vitest/coverage-v8': 4.1.7 + '@vitest/ui': 4.1.7 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -1370,30 +1370,30 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@next/env@15.5.23': {} + '@next/env@15.5.18': {} - '@next/swc-darwin-arm64@15.5.23': + '@next/swc-darwin-arm64@15.5.18': optional: true - '@next/swc-darwin-x64@15.5.23': + '@next/swc-darwin-x64@15.5.18': optional: true - '@next/swc-linux-arm64-gnu@15.5.23': + '@next/swc-linux-arm64-gnu@15.5.18': optional: true - '@next/swc-linux-arm64-musl@15.5.23': + '@next/swc-linux-arm64-musl@15.5.18': optional: true - '@next/swc-linux-x64-gnu@15.5.23': + '@next/swc-linux-x64-gnu@15.5.18': optional: true - '@next/swc-linux-x64-musl@15.5.23': + '@next/swc-linux-x64-musl@15.5.18': optional: true - '@next/swc-win32-arm64-msvc@15.5.23': + '@next/swc-win32-arm64-msvc@15.5.18': optional: true - '@next/swc-win32-x64-msvc@15.5.23': + '@next/swc-win32-x64-msvc@15.5.18': optional: true '@oxc-project/types@0.143.0': {} @@ -1444,38 +1444,6 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@supabase/auth-js@2.112.2': - dependencies: - tslib: 2.8.1 - - '@supabase/functions-js@2.112.2': - dependencies: - tslib: 2.8.1 - - '@supabase/phoenix@0.4.5': {} - - '@supabase/postgrest-js@2.112.2': - dependencies: - tslib: 2.8.1 - - '@supabase/realtime-js@2.112.2': - dependencies: - '@supabase/phoenix': 0.4.5 - tslib: 2.8.1 - - '@supabase/storage-js@2.112.2': - dependencies: - iceberg-js: 0.8.1 - tslib: 2.8.1 - - '@supabase/supabase-js@2.112.2': - dependencies: - '@supabase/auth-js': 2.112.2 - '@supabase/functions-js': 2.112.2 - '@supabase/postgrest-js': 2.112.2 - '@supabase/realtime-js': 2.112.2 - '@supabase/storage-js': 2.112.2 - '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -1501,15 +1469,15 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@babel/runtime': 7.29.7 '@testing-library/dom': 10.4.1 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) '@types/aria-query@5.0.4': {} @@ -1526,52 +1494,58 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/react-dom@19.2.4(@types/react@19.2.18)': + '@types/pg@8.21.0': dependencies: - '@types/react': 19.2.18 + '@types/node': 22.20.1 + pg-protocol: 1.16.0 + pg-types: 2.2.0 - '@types/react@19.2.18': + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': dependencies: csstype: 3.2.3 - '@vitest/expect@4.1.10': + '@vitest/expect@4.1.7': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@22.20.1))': + '@vitest/mocker@4.1.7(vite@8.2.1(@types/node@22.20.1))': dependencies: - '@vitest/spy': 4.1.10 + '@vitest/spy': 4.1.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: vite: 8.2.1(@types/node@22.20.1) - '@vitest/pretty-format@4.1.10': + '@vitest/pretty-format@4.1.7': dependencies: tinyrainbow: 3.1.1 - '@vitest/runner@4.1.10': + '@vitest/runner@4.1.7': dependencies: - '@vitest/utils': 4.1.10 + '@vitest/utils': 4.1.7 pathe: 2.0.3 - '@vitest/snapshot@4.1.10': + '@vitest/snapshot@4.1.7': dependencies: - '@vitest/pretty-format': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/pretty-format': 4.1.7 + '@vitest/utils': 4.1.7 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.10': {} + '@vitest/spy@4.1.7': {} - '@vitest/utils@4.1.10': + '@vitest/utils@4.1.7': dependencies: - '@vitest/pretty-format': 4.1.10 + '@vitest/pretty-format': 4.1.7 convert-source-map: 2.0.0 tinyrainbow: 3.1.1 @@ -1770,8 +1744,6 @@ snapshots: dependencies: ms: 2.1.3 - iceberg-js@0.8.1: {} - iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -1851,9 +1823,9 @@ snapshots: lodash@4.18.1: {} - lucide-react@0.468.0(react@19.2.8): + lucide-react@0.468.0(react@19.2.6): dependencies: - react: 19.2.8 + react: 19.2.6 lz-string@1.5.0: {} @@ -1885,24 +1857,24 @@ snapshots: nanoid@3.3.18: {} - next@15.5.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@15.5.18(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: - '@next/env': 15.5.23 + '@next/env': 15.5.18 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001809 postcss: 8.4.31 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - styled-jsx: 5.1.6(react@19.2.8) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + styled-jsx: 5.1.6(react@19.2.6) optionalDependencies: - '@next/swc-darwin-arm64': 15.5.23 - '@next/swc-darwin-x64': 15.5.23 - '@next/swc-linux-arm64-gnu': 15.5.23 - '@next/swc-linux-arm64-musl': 15.5.23 - '@next/swc-linux-x64-gnu': 15.5.23 - '@next/swc-linux-x64-musl': 15.5.23 - '@next/swc-win32-arm64-msvc': 15.5.23 - '@next/swc-win32-x64-msvc': 15.5.23 + '@next/swc-darwin-arm64': 15.5.18 + '@next/swc-darwin-x64': 15.5.18 + '@next/swc-linux-arm64-gnu': 15.5.18 + '@next/swc-linux-arm64-musl': 15.5.18 + '@next/swc-linux-x64-gnu': 15.5.18 + '@next/swc-linux-x64-musl': 15.5.18 + '@next/swc-win32-arm64-msvc': 15.5.18 + '@next/swc-win32-x64-msvc': 15.5.18 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -1935,6 +1907,41 @@ snapshots: dependencies: through: 2.3.8 + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.23.0): + dependencies: + pg: 8.23.0 + + pg-protocol@1.16.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.23.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.23.0) + pg-protocol: 1.16.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@4.0.5: {} @@ -1953,6 +1960,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -1971,14 +1988,14 @@ snapshots: es-define-property: 1.0.1 side-channel: 1.1.1 - react-dom@19.2.8(react@19.2.8): + react-dom@19.2.6(react@19.2.6): dependencies: - react: 19.2.8 + react: 19.2.6 scheduler: 0.27.0 react-is@17.0.2: {} - react@19.2.8: {} + react@19.2.6: {} readable-stream@2.3.8: dependencies: @@ -2032,6 +2049,8 @@ snapshots: semver@7.8.5: optional: true + server-only@0.0.1: {} + sharp@0.34.5: dependencies: '@img/colour': 1.1.0 @@ -2096,6 +2115,8 @@ snapshots: source-map-js@1.2.1: {} + split2@4.2.0: {} + stackback@0.0.2: {} statuses@1.5.0: {} @@ -2120,10 +2141,10 @@ snapshots: dependencies: min-indent: 1.0.1 - styled-jsx@5.1.6(react@19.2.8): + styled-jsx@5.1.6(react@19.2.6): dependencies: client-only: 0.0.1 - react: 19.2.8 + react: 19.2.6 thenify-all@1.6.0: dependencies: @@ -2194,15 +2215,15 @@ snapshots: '@types/node': 22.20.1 fsevents: 2.3.3 - vitest@4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)): + vitest@4.1.7(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)): dependencies: - '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@22.20.1)) - '@vitest/pretty-format': 4.1.10 - '@vitest/runner': 4.1.10 - '@vitest/snapshot': 4.1.10 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/expect': 4.1.7 + '@vitest/mocker': 4.1.7(vite@8.2.1(@types/node@22.20.1)) + '@vitest/pretty-format': 4.1.7 + '@vitest/runner': 4.1.7 + '@vitest/snapshot': 4.1.7 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 es-module-lexer: 2.3.1 expect-type: 1.4.0 magic-string: 0.30.21 diff --git a/progress.md b/progress.md index c5f9d6b..5fb32ea 100644 --- a/progress.md +++ b/progress.md @@ -1553,3 +1553,23 @@ - Updated README/API billing semantics and added regression tests for insufficient balance, unbound super-admin image billing, super-admin Seedance settlement, usage cost recording, and ordinary unbound rejection. - Verification: focused billing tests 17/17, full Vitest 24 files / 96 tests, TypeScript, production build, and `git diff --check` passed. - The repository-local test/typecheck wrappers initially could not find `node`; reran the same checks with the bundled workspace Node runtime and they passed. + +## Session: 2026-08-12 - Alibaba Cloud RDS PostgreSQL Adapter + +### Phase 77: Planning and Gate - Complete +- Initialized project-memory templates on an isolated `codex/rds-postgres-adapter-7f2c1a` worktree and committed only that baseline so feature drift can be enforced without touching the dirty `main` worktree. +- Claimed task `20260812-rds-postgres-adapter-7f2c1a`; Concurrent Task Gate and Planning Gate passed. +- Read the active task, project memory entry set, relevant architecture/domain/evidence/commitment files, peer Docker task, and existing task planning history. +- Confirmed no semantic conflict with the peer task: it only verifies the Docker build command and does not authorize or perform application changes. +- Chose one deep PostgreSQL module with stable store interfaces, explicit backend selection, fail-closed production behavior, versioned migrations, and ACK workload-specific secret boundaries. +- **Status:** complete + +### Phase 77: Implementation +- Added the server-only PostgreSQL adapter and replaced Supabase access in data, account, and billing stores while preserving local JSON mode and public store contracts. +- Added versioned migration tooling, application-role grants, direct PostgreSQL bootstrap/import scripts, Docker migration assets, `/api/ready`, and ACK workload templates. +- Completed two independent read-only audits and fixed all findings, including migration Job backend selection, application grants, public Ingress isolation, wallet idempotency concurrency, failed-login concurrency, billing conflict mapping, and JSONB condition normalization. +- The first mandated Sol final review returned FAIL. Fixed every reported code item and started a clean second Sol review: tenant-safe payload-bound wallet idempotency, fail-safe historical usage duplicate detection, precise/no-default grants, full runtime privilege readiness, atomic password changes, server-only module guards, and aligned npm/pnpm runtime versions. +- Verification passes: `npm ci --ignore-scripts`, frozen pnpm lock validation, `npm run deploy:check` for 8 manifests, 31 Vitest files / 119 tests, `tsc --noEmit --incremental false`, Next production build (including `/api/ready`), script syntax, SQL static assertions, documentation drift, and `git diff --check`. +- External validation remains unavailable: no live RDS credentials, ACK kubeconfig, Docker daemon, or `psql`; these are deployment prerequisites, not locally claimed results. +- The second mandated Sol review and the post-fix incremental review both returned `PASS` with no blocking findings. The last regression found by the main-thread verification was corrected so wallet idempotency binds immutable accounting fields while allowing description/metadata audit details to evolve across a retry. +- **Status:** complete diff --git a/scripts/bootstrap-admin.mjs b/scripts/bootstrap-admin.mjs index cbfb70c..8020b08 100644 --- a/scripts/bootstrap-admin.mjs +++ b/scripts/bootstrap-admin.mjs @@ -2,114 +2,79 @@ import { existsSync, readFileSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { randomBytes, scryptSync } from "node:crypto"; -import { createClient } from "@supabase/supabase-js"; +import { closePostgresPool, createPostgresPool, getScriptDataBackend } from "./postgres-client.mjs"; loadEnvFile(".env"); loadEnvFile(".env.local"); -const args = parseArgs(process.argv.slice(2)); -const phone = normalizePhone(args.phone || process.env.ZHINIAN_BOOTSTRAP_ADMIN_PHONE || ""); -const password = args.password || process.env.ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD || ""; -const displayName = args.name || process.env.ZHINIAN_BOOTSTRAP_ADMIN_NAME || "平台超级管理员"; +let pool; +try { + await main(); +} catch (error) { + console.error(`初始化失败:${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; +} finally { + await closePostgresPool(pool); +} -if (!/^\+?[0-9]{6,20}$/.test(phone)) fail("请通过 --phone 或 ZHINIAN_BOOTSTRAP_ADMIN_PHONE 提供有效手机号。"); -if (password.length < 8) fail("请通过 --password 或 ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD 提供至少 8 位密码。"); +async function main() { + const args = parseArgs(process.argv.slice(2)); + const phone = normalizePhone(args.phone || process.env.ZHINIAN_BOOTSTRAP_ADMIN_PHONE || ""); + const password = args.password || process.env.ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD || ""; + const displayName = args.name || process.env.ZHINIAN_BOOTSTRAP_ADMIN_NAME || "平台超级管理员"; + if (!/^\+?[0-9]{6,20}$/.test(phone)) throw new Error("请通过 --phone 或 ZHINIAN_BOOTSTRAP_ADMIN_PHONE 提供有效手机号。"); + if (password.length < 8) throw new Error("请通过 --password 或 ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD 提供至少 8 位密码。"); -const credential = hashPassword(password); -const now = new Date().toISOString(); -const supabase = getSupabase(); - -if (supabase) { - const { data: existing, error: lookupError } = await supabase.from("platform_users").select("id, role, password_hash").eq("role", "super_admin").limit(1).maybeSingle(); - if (lookupError) fail(lookupError.message); - const { data: phoneOwner, error: phoneLookupError } = await supabase.from("platform_users").select("id").eq("phone", phone).limit(1).maybeSingle(); - if (phoneLookupError) fail(phoneLookupError.message); - if (phoneOwner && phoneOwner.id !== existing?.id) fail("该手机号已经绑定其他账号,不能初始化为超级管理员。"); - if (existing && existing.password_hash) fail("平台已经存在超级管理员,初始化已停止。"); - if (existing) { - const { error } = await supabase.from("platform_users").update({ - phone, - display_name: displayName, - password_hash: credential.hash, - password_salt: credential.salt, - status: "active", - failed_login_count: 0, - locked_until: null, - session_version: 1, - updated_at: now - }).eq("id", existing.id); - if (error) fail(error.message); - console.log(`已初始化超级管理员:${phone}(${existing.id})`); - } else { - const user = { - id: `user_${randomBytes(8).toString("hex")}`, - phone, - display_name: displayName, - role: "super_admin", - organization_id: null, - status: "active", - password_hash: credential.hash, - password_salt: credential.salt, - failed_login_count: 0, - locked_until: null, - session_version: 1, - created_at: now, - updated_at: now - }; - const { error } = await supabase.from("platform_users").insert(user); - if (error) fail(error.message); - console.log(`已初始化超级管理员:${phone}(${user.id})`); + const credential = hashPassword(password); + const now = new Date().toISOString(); + if (getScriptDataBackend() === "postgres") { + pool = createPostgresPool({ applicationName: "zhinian-bootstrap-admin" }); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query("SELECT pg_advisory_xact_lock($1)", ["7308731946202609"]); + const existing = (await client.query("SELECT id, password_hash FROM platform_users WHERE role = $1 ORDER BY created_at ASC LIMIT 1 FOR UPDATE", ["super_admin"])).rows[0]; + const phoneOwner = (await client.query("SELECT id FROM platform_users WHERE phone = $1 LIMIT 1", [phone])).rows[0]; + if (phoneOwner && phoneOwner.id !== existing?.id) throw new Error("该手机号已经绑定其他账号,不能初始化为超级管理员。"); + if (existing?.password_hash) throw new Error("平台已经存在超级管理员,初始化已停止。"); + const userId = existing?.id || `user_${randomBytes(8).toString("hex")}`; + if (existing) { + await client.query("UPDATE platform_users SET phone=$2, display_name=$3, password_hash=$4, password_salt=$5, status=$6, failed_login_count=0, locked_until=NULL, session_version=1, updated_at=$7 WHERE id=$1 RETURNING id", [userId, phone, displayName, credential.hash, credential.salt, "active", now]); + } else { + await client.query("INSERT INTO platform_users (id, phone, display_name, role, organization_id, status, password_hash, password_salt, failed_login_count, locked_until, session_version, created_at, updated_at) VALUES ($1,$2,$3,$4,NULL,$5,$6,$7,0,NULL,1,$8,$8) RETURNING id", [userId, phone, displayName, "super_admin", "active", credential.hash, credential.salt, now]); + } + await client.query("COMMIT"); + console.log(`已初始化超级管理员:${phone}(${userId})`); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + return; } -} else { + const dataDirectory = process.env.ZHINIAN_DATA_DIR || join(process.cwd(), ".runtime", "data"); await mkdir(dataDirectory, { recursive: true }); const path = join(dataDirectory, "platform-accounts.json"); const state = await readState(path); const existing = state.users.find((user) => user.role === "super_admin"); const phoneOwner = state.users.find((user) => user.phone === phone && user.id !== existing?.id); - if (phoneOwner) fail("该手机号已经绑定其他账号,不能初始化为超级管理员。"); - if (existing && existing.passwordHash) fail("平台已经存在超级管理员,初始化已停止。"); - const user = existing || { - id: `user_${randomBytes(8).toString("hex")}`, - phone, - displayName, - role: "super_admin", - status: "active", - failedLoginCount: 0, - sessionVersion: 1, - createdAt: now, - updatedAt: now - }; - Object.assign(user, { - phone, - displayName, - passwordHash: credential.hash, - passwordSalt: credential.salt, - organizationId: undefined, - failedLoginCount: 0, - lockedUntil: undefined, - sessionVersion: 1, - updatedAt: now - }); + if (phoneOwner) throw new Error("该手机号已经绑定其他账号,不能初始化为超级管理员。"); + if (existing?.passwordHash) throw new Error("平台已经存在超级管理员,初始化已停止。"); + const user = existing || { id: `user_${randomBytes(8).toString("hex")}`, phone, displayName, role: "super_admin", status: "active", failedLoginCount: 0, sessionVersion: 1, createdAt: now, updatedAt: now }; + Object.assign(user, { phone, displayName, passwordHash: credential.hash, passwordSalt: credential.salt, organizationId: undefined, failedLoginCount: 0, lockedUntil: undefined, sessionVersion: 1, updatedAt: now }); if (!existing) state.users.push(user); await writeFile(path, JSON.stringify(state, null, 2)); console.log(`已初始化超级管理员:${phone}(${user.id})`); } -function getSupabase() { - const url = process.env.NEXT_PUBLIC_SUPABASE_URL; - const key = process.env.SUPABASE_SERVICE_ROLE_KEY; - return url && key ? createClient(url, key, { auth: { persistSession: false } }) : null; -} - function hashPassword(value) { const salt = randomBytes(16).toString("hex"); return { salt, hash: scryptSync(value, salt, 64).toString("hex") }; } -function normalizePhone(value) { - return value.trim().replace(/[\s()-]/g, ""); -} +function normalizePhone(value) { return value.trim().replace(/[\s()-]/g, ""); } function parseArgs(values) { const result = {}; @@ -123,28 +88,14 @@ function parseArgs(values) { async function readState(path) { if (!existsSync(path)) return { users: [], organizations: [], migrations: [] }; - try { - return JSON.parse(await readFile(path, "utf8")); - } catch { - return { users: [], organizations: [], migrations: [] }; - } + try { return JSON.parse(await readFile(path, "utf8")); } catch { return { users: [], organizations: [], migrations: [] }; } } function loadEnvFile(path) { if (!existsSync(path)) return; - const text = requireFile(path); - for (const line of text.split(/\r?\n/)) { + for (const line of readFileSync(path, "utf8").split(/\r?\n/)) { const match = line.match(/^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)\s*$/); if (!match || process.env[match[1]]) continue; process.env[match[1]] = match[2].replace(/^['"]|['"]$/g, ""); } } - -function requireFile(path) { - return readFileSync(path, "utf8"); -} - -function fail(message) { - console.error(`初始化失败:${message}`); - process.exit(1); -} diff --git a/scripts/check-ack-manifests.mjs b/scripts/check-ack-manifests.mjs new file mode 100644 index 0000000..7c8fe50 --- /dev/null +++ b/scripts/check-ack-manifests.mjs @@ -0,0 +1,37 @@ +import { readFileSync, readdirSync } from "node:fs"; + +const directory = new URL("../deploy/ack/", import.meta.url); +const files = readdirSync(directory).filter((name) => name.endsWith(".yaml")).sort(); + +for (const file of files) { + const text = readFileSync(new URL(file, directory), "utf8"); + assert(text.includes("apiVersion:"), `${file}: missing apiVersion`); + assert(text.includes("kind:"), `${file}: missing kind`); + assert(!text.includes("server-snippet"), `${file}: must not depend on disabled snippet annotations`); +} + +const migrationJob = read("migration-job.yaml"); +assert(migrationJob.includes("name: ZHINIAN_DATA_BACKEND\n value: postgres"), "migration Job must select postgres"); +assert(migrationJob.includes("name: DATABASE_APP_ROLE"), "migration Job must provision the Web role"); +assert(migrationJob.includes("secretName: zhinian-rds-ca"), "migration Job must mount the RDS CA"); + +const web = read("web.yaml"); +assert(/^\s*replicas: 1\s*$/m.test(web), "Web must default to one replica until object storage is shared"); +assert(web.includes("path: /api/ready"), "Web must use database-aware readiness"); + +const ingress = read("ingress.yaml"); +assert(ingress.includes("path: /api/internal/worker"), "Ingress must intercept the internal worker prefix"); +assert(ingress.includes("name: zhinian-public-deny"), "Ingress must route the internal prefix away from Web"); + +const service = read("service.yaml"); +assert(service.includes("name: zhinian-public-deny"), "selectorless deny Service is required"); + +console.log(`ACK manifest assertions passed (${files.length} files)`); + +function read(file) { + return readFileSync(new URL(file, directory), "utf8"); +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} diff --git a/scripts/import-legacy-accounts.mjs b/scripts/import-legacy-accounts.mjs index b779fa0..48aa37d 100644 --- a/scripts/import-legacy-accounts.mjs +++ b/scripts/import-legacy-accounts.mjs @@ -2,88 +2,77 @@ import { existsSync, readFileSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { randomBytes, scryptSync } from "node:crypto"; -import { createClient } from "@supabase/supabase-js"; +import { closePostgresPool, createPostgresPool, getScriptDataBackend } from "./postgres-client.mjs"; loadEnvFile(".env"); loadEnvFile(".env.local"); -const inputPath = process.argv[2]; -if (!inputPath) fail("用法:npm run migrate:accounts -- path/to/legacy-accounts.json"); - -const input = JSON.parse(await readFile(inputPath, "utf8")); -const accounts = Array.isArray(input.accounts) ? input.accounts : []; -if (!accounts.length) fail("迁移文件中的 accounts 不能为空。"); -const organizations = Array.isArray(input.organizations) ? input.organizations : []; -const supabase = getSupabase(); - -if (supabase) { - await migrateSupabase(accounts, organizations, supabase); -} else { - await migrateLocal(accounts, organizations); +let pool; +try { + await main(); +} catch (error) { + console.error(`迁移失败:${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; +} finally { + await closePostgresPool(pool); } -console.log(`已迁移 ${accounts.length} 个账号及其历史归属。`); +async function main() { + const inputPath = process.argv[2]; + if (!inputPath) throw new Error("用法:npm run migrate:accounts -- path/to/legacy-accounts.json"); + const input = JSON.parse(await readFile(inputPath, "utf8")); + const accounts = Array.isArray(input.accounts) ? input.accounts : []; + if (!accounts.length) throw new Error("迁移文件中的 accounts 不能为空。"); + const organizations = Array.isArray(input.organizations) ? input.organizations : []; + if (getScriptDataBackend() === "postgres") { + pool = createPostgresPool({ applicationName: "zhinian-import-legacy-accounts" }); + await migratePostgres(accounts, organizations, pool); + } else { + await migrateLocal(accounts, organizations); + } + console.log(`已迁移 ${accounts.length} 个账号及其历史归属。`); +} -async function migrateSupabase(accounts, organizations, supabase) { +async function migratePostgres(accounts, organizations, databasePool) { for (const organization of organizations) { if (!organization?.id || !organization?.name) continue; - const { error } = await supabase.from("platform_organizations").upsert({ - id: String(organization.id), - name: String(organization.name), - status: organization.status === "disabled" ? "disabled" : "active", - archive_owner_id: `archive:${organization.id}` - }, { onConflict: "id" }); - if (error) fail(error.message); + await databasePool.query( + "INSERT INTO platform_organizations (id,name,status,archive_owner_id) VALUES ($1,$2,$3,$4) ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name,status=EXCLUDED.status,archive_owner_id=EXCLUDED.archive_owner_id,updated_at=now() RETURNING id", + [String(organization.id), String(organization.name), organization.status === "disabled" ? "disabled" : "active", `archive:${organization.id}`] + ); } for (const account of accounts) { const record = normalizeAccount(account); - const { data: existing, error: lookupError } = await supabase.from("platform_users").select("id").eq("phone", record.phone).maybeSingle(); - if (lookupError) fail(lookupError.message); - const userId = existing?.id || `user_${randomBytes(8).toString("hex")}`; const credential = hashPassword(record.password); - const now = new Date().toISOString(); - const { error: userError } = await supabase.from("platform_users").upsert({ - id: userId, - phone: record.phone, - display_name: record.displayName, - role: record.role, - organization_id: record.organizationId || null, - status: "active", - password_hash: credential.hash, - password_salt: credential.salt, - failed_login_count: 0, - locked_until: null, - session_version: 1, - legacy_subject: record.legacyOwnerId, - updated_at: now - }, { onConflict: "id" }); - if (userError) fail(userError.message); - await reassignSupabaseOwner(supabase, record.legacyOwnerId, userId, record); - const { error: mappingError } = await supabase.from("platform_account_migrations").upsert({ - id: `migration_${randomBytes(8).toString("hex")}`, - legacy_owner_id: record.legacyOwnerId, - legacy_phone: record.phone, - platform_user_id: userId - }, { onConflict: "legacy_owner_id" }); - if (mappingError) fail(mappingError.message); + const client = await databasePool.connect(); + try { + await client.query("BEGIN"); + await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`legacy-account:${record.phone}`]); + const existing = (await client.query("SELECT id FROM platform_users WHERE phone=$1 FOR UPDATE", [record.phone])).rows[0]; + const userId = existing?.id || `user_${randomBytes(8).toString("hex")}`; + const now = new Date().toISOString(); + await client.query( + "INSERT INTO platform_users (id,phone,display_name,role,organization_id,status,password_hash,password_salt,failed_login_count,locked_until,session_version,legacy_subject,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,0,NULL,1,$9,$10,$10) ON CONFLICT (id) DO UPDATE SET phone=EXCLUDED.phone,display_name=EXCLUDED.display_name,role=EXCLUDED.role,organization_id=EXCLUDED.organization_id,status=EXCLUDED.status,password_hash=EXCLUDED.password_hash,password_salt=EXCLUDED.password_salt,failed_login_count=0,locked_until=NULL,session_version=platform_users.session_version+1,legacy_subject=EXCLUDED.legacy_subject,updated_at=EXCLUDED.updated_at RETURNING id", + [userId, record.phone, record.displayName, record.role, record.organizationId || null, "active", credential.hash, credential.salt, record.legacyOwnerId, now] + ); + for (const table of ["assets", "generation_jobs", "projects", "image_templates"]) { + await client.query(`UPDATE ${table} SET owner_id=$2 WHERE owner_id=$1`, [record.legacyOwnerId, userId]); + } + await client.query("UPDATE usage_events SET owner_id=$2, account_username=$3, account_display_name=$4, organization_id=$5 WHERE owner_id=$1", [record.legacyOwnerId, userId, record.phone, record.displayName, record.organizationId || null]); + await client.query( + "INSERT INTO platform_account_migrations (id,legacy_owner_id,legacy_phone,platform_user_id) VALUES ($1,$2,$3,$4) ON CONFLICT (legacy_owner_id) DO UPDATE SET id=EXCLUDED.id,legacy_phone=EXCLUDED.legacy_phone,platform_user_id=EXCLUDED.platform_user_id,created_at=now() RETURNING id", + [`migration_${randomBytes(8).toString("hex")}`, record.legacyOwnerId, record.phone, userId] + ); + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } } } -async function reassignSupabaseOwner(supabase, legacyOwnerId, userId, account) { - for (const table of ["assets", "generation_jobs", "projects", "image_templates"] ) { - const { error } = await supabase.from(table).update({ owner_id: userId }).eq("owner_id", legacyOwnerId); - if (error) fail(error.message); - } - const usagePatch = { - owner_id: userId, - account_username: account.phone, - account_display_name: account.displayName, - organization_id: account.organizationId || null - }; - const { error } = await supabase.from("usage_events").update(usagePatch).eq("owner_id", legacyOwnerId); - if (error) fail(error.message); -} - async function migrateLocal(accounts, organizations) { const dataDirectory = process.env.ZHINIAN_DATA_DIR || join(process.cwd(), ".runtime", "data"); await mkdir(dataDirectory, { recursive: true }); @@ -92,106 +81,42 @@ async function migrateLocal(accounts, organizations) { const now = new Date().toISOString(); for (const organization of organizations) { if (!organization?.id || !organization?.name) continue; - const next = { - id: String(organization.id), - name: String(organization.name), - status: organization.status === "disabled" ? "disabled" : "active", - archiveOwnerId: `archive:${organization.id}`, - createdAt: now, - updatedAt: now - }; + const next = { id: String(organization.id), name: String(organization.name), status: organization.status === "disabled" ? "disabled" : "active", archiveOwnerId: `archive:${organization.id}`, createdAt: now, updatedAt: now }; const index = state.organizations.findIndex((item) => item.id === next.id); - if (index >= 0) state.organizations[index] = { ...state.organizations[index], ...next }; - else state.organizations.push(next); + if (index >= 0) state.organizations[index] = { ...state.organizations[index], ...next }; else state.organizations.push(next); } for (const account of accounts) { const record = normalizeAccount(account); const credential = hashPassword(record.password); let user = state.users.find((item) => item.phone === record.phone); if (!user) { - user = { - id: `user_${randomBytes(8).toString("hex")}`, - phone: record.phone, - displayName: record.displayName, - role: record.role, - organizationId: record.organizationId, - status: "active", - failedLoginCount: 0, - sessionVersion: 1, - createdAt: now, - updatedAt: now - }; + user = { id: `user_${randomBytes(8).toString("hex")}`, phone: record.phone, displayName: record.displayName, role: record.role, organizationId: record.organizationId, status: "active", failedLoginCount: 0, sessionVersion: 1, createdAt: now, updatedAt: now }; state.users.push(user); } - Object.assign(user, { - displayName: record.displayName, - role: record.role, - organizationId: record.organizationId, - status: "active", - passwordHash: credential.hash, - passwordSalt: credential.salt, - failedLoginCount: 0, - lockedUntil: undefined, - sessionVersion: (user.sessionVersion || 1) + 1, - legacySubject: record.legacyOwnerId, - updatedAt: now - }); + Object.assign(user, { displayName: record.displayName, role: record.role, organizationId: record.organizationId, status: "active", passwordHash: credential.hash, passwordSalt: credential.salt, failedLoginCount: 0, lockedUntil: undefined, sessionVersion: (user.sessionVersion || 1) + 1, legacySubject: record.legacyOwnerId, updatedAt: now }); reassignLocalOwner(state, record.legacyOwnerId, user.id, record); - const migration = { - id: `migration_${randomBytes(8).toString("hex")}`, - legacyOwnerId: record.legacyOwnerId, - legacyPhone: record.phone, - platformUserId: user.id, - createdAt: now - }; + const migration = { id: `migration_${randomBytes(8).toString("hex")}`, legacyOwnerId: record.legacyOwnerId, legacyPhone: record.phone, platformUserId: user.id, createdAt: now }; const mappingIndex = state.migrations.findIndex((item) => item.legacyOwnerId === record.legacyOwnerId); - if (mappingIndex >= 0) state.migrations[mappingIndex] = migration; - else state.migrations.push(migration); + if (mappingIndex >= 0) state.migrations[mappingIndex] = migration; else state.migrations.push(migration); } await writeFile(path, JSON.stringify(state, null, 2)); } function reassignLocalOwner(state, legacyOwnerId, userId, account) { - for (const collection of [state.assets, state.generationJobs, state.projects, state.imageTemplates]) { - for (const item of collection) if (item.ownerId === legacyOwnerId) item.ownerId = userId; - } - for (const event of state.usageEvents) { - if (event.ownerId !== legacyOwnerId) continue; - event.ownerId = userId; - event.accountUsername = account.phone; - event.accountDisplayName = account.displayName; - event.organizationId = account.organizationId; - } + for (const collection of [state.assets, state.generationJobs, state.projects, state.imageTemplates]) for (const item of collection) if (item.ownerId === legacyOwnerId) item.ownerId = userId; + for (const event of state.usageEvents) if (event.ownerId === legacyOwnerId) Object.assign(event, { ownerId: userId, accountUsername: account.phone, accountDisplayName: account.displayName, organizationId: account.organizationId }); } function normalizeAccount(account) { - if (!account?.legacyOwnerId || !account?.phone || !account?.password || !account?.displayName) { - fail("每个账号必须提供 legacyOwnerId、phone、displayName 和 password。"); - } + if (!account?.legacyOwnerId || !account?.phone || !account?.password || !account?.displayName) throw new Error("每个账号必须提供 legacyOwnerId、phone、displayName 和 password。"); const phone = String(account.phone).trim().replace(/[\s()-]/g, ""); - if (!/^\+?[0-9]{6,20}$/.test(phone)) fail(`手机号格式不正确:${phone}`); + if (!/^\+?[0-9]{6,20}$/.test(phone)) throw new Error(`手机号格式不正确:${phone}`); const role = account.role === "super_admin" || account.role === "organization_admin" ? account.role : "user"; - if (role !== "super_admin" && !account.organizationId) fail(`普通账号缺少 organizationId:${phone}`); - return { - legacyOwnerId: String(account.legacyOwnerId), - phone, - displayName: String(account.displayName).trim(), - password: String(account.password), - role, - organizationId: account.organizationId ? String(account.organizationId) : undefined - }; + if (role !== "super_admin" && !account.organizationId) throw new Error(`普通账号缺少 organizationId:${phone}`); + return { legacyOwnerId: String(account.legacyOwnerId), phone, displayName: String(account.displayName).trim(), password: String(account.password), role, organizationId: account.organizationId ? String(account.organizationId) : undefined }; } -function hashPassword(value) { - const salt = randomBytes(16).toString("hex"); - return { salt, hash: scryptSync(value, salt, 64).toString("hex") }; -} - -function getSupabase() { - const url = process.env.NEXT_PUBLIC_SUPABASE_URL; - const key = process.env.SUPABASE_SERVICE_ROLE_KEY; - return url && key ? createClient(url, key, { auth: { persistSession: false } }) : null; -} +function hashPassword(value) { const salt = randomBytes(16).toString("hex"); return { salt, hash: scryptSync(value, salt, 64).toString("hex") }; } function loadEnvFile(path) { if (!existsSync(path)) return; @@ -202,25 +127,9 @@ function loadEnvFile(path) { } } -function fail(message) { - console.error(`迁移失败:${message}`); - process.exit(1); -} - async function readState(path) { try { const raw = JSON.parse(await readFile(path, "utf8")); - return { - users: Array.isArray(raw.users) ? raw.users : [], - organizations: Array.isArray(raw.organizations) ? raw.organizations : [], - migrations: Array.isArray(raw.migrations) ? raw.migrations : [], - assets: Array.isArray(raw.assets) ? raw.assets : [], - generationJobs: Array.isArray(raw.generationJobs) ? raw.generationJobs : [], - usageEvents: Array.isArray(raw.usageEvents) ? raw.usageEvents : [], - projects: Array.isArray(raw.projects) ? raw.projects : [], - imageTemplates: Array.isArray(raw.imageTemplates) ? raw.imageTemplates : [] - }; - } catch { - return { users: [], organizations: [], migrations: [], assets: [], generationJobs: [], usageEvents: [], projects: [], imageTemplates: [] }; - } + return { users: Array.isArray(raw.users) ? raw.users : [], organizations: Array.isArray(raw.organizations) ? raw.organizations : [], migrations: Array.isArray(raw.migrations) ? raw.migrations : [], assets: Array.isArray(raw.assets) ? raw.assets : [], generationJobs: Array.isArray(raw.generationJobs) ? raw.generationJobs : [], usageEvents: Array.isArray(raw.usageEvents) ? raw.usageEvents : [], projects: Array.isArray(raw.projects) ? raw.projects : [], imageTemplates: Array.isArray(raw.imageTemplates) ? raw.imageTemplates : [] }; + } catch { return { users: [], organizations: [], migrations: [], assets: [], generationJobs: [], usageEvents: [], projects: [], imageTemplates: [] }; } } diff --git a/scripts/migrate-postgres.mjs b/scripts/migrate-postgres.mjs new file mode 100644 index 0000000..ea8bc2c --- /dev/null +++ b/scripts/migrate-postgres.mjs @@ -0,0 +1,204 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + closePostgresPool, + createPostgresPool, + getScriptDataBackend, + quotePostgresIdentifier +} from "./postgres-client.mjs"; + +loadEnvFile(".env"); +loadEnvFile(".env.local"); + +const MIGRATION_LOCK_ID = "7308731946202608"; +const migrationsDirectory = fileURLToPath(new URL("../database/migrations/", import.meta.url)); +let pool; +let client; +let locked = false; + +try { + if (getScriptDataBackend() !== "postgres") { + throw new Error("Database migrations require ZHINIAN_DATA_BACKEND=postgres"); + } + const applicationRole = process.env.DATABASE_APP_ROLE?.trim(); + if (!applicationRole && process.env.NODE_ENV === "production") { + throw new Error("DATABASE_APP_ROLE is required in production so application privileges can be provisioned"); + } + if (applicationRole) quotePostgresIdentifier(applicationRole); + pool = createPostgresPool({ applicationName: "zhinian-migrate" }); + client = await pool.connect(); + await client.query("SELECT pg_advisory_lock($1)", [MIGRATION_LOCK_ID]); + locked = true; + await client.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version text PRIMARY KEY, + checksum text NOT NULL, + applied_at timestamptz NOT NULL DEFAULT now() + ) + `); + + const migrations = await discoverMigrations(migrationsDirectory); + const { rows: appliedRows } = await client.query("SELECT version, checksum FROM schema_migrations"); + const applied = new Map(appliedRows.map((row) => [row.version, row.checksum])); + const discoveredVersions = new Set(migrations.map((migration) => migration.version)); + + for (const version of applied.keys()) { + if (!discoveredVersions.has(version)) { + throw new Error(`Applied migration ${version} is missing from the migration directory; refusing to continue`); + } + } + + for (const migration of migrations) { + const recordedChecksum = applied.get(migration.version); + if (recordedChecksum && recordedChecksum !== migration.checksum) { + throw new Error(`Applied migration ${migration.version} has changed; refusing to continue`); + } + } + + for (const migration of migrations) { + if (applied.has(migration.version)) continue; + await client.query("BEGIN"); + try { + await client.query(migration.sql); + await client.query( + "INSERT INTO schema_migrations(version, checksum) VALUES ($1, $2)", + [migration.version, migration.checksum] + ); + await client.query("COMMIT"); + console.log(`Applied migration ${migration.version}`); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } + } + + if (applicationRole) { + await provisionApplicationRole(client, applicationRole); + await verifyApplicationRole(client, applicationRole); + console.log("Provisioned PostgreSQL privileges for the configured application role"); + } + + console.log(`Database migrations are current (${migrations.length} discovered)`); +} catch (error) { + console.error(`Database migration failed: ${safeErrorMessage(error)}`); + process.exitCode = 1; +} finally { + if (client) { + if (locked) { + try { + await client.query("SELECT pg_advisory_unlock($1)", [MIGRATION_LOCK_ID]); + } catch { + // Closing the session below also releases the advisory lock. + } + } + client.release(); + } + await closePostgresPool(pool); +} + +async function provisionApplicationRole(client, role) { + const quotedRole = quotePostgresIdentifier(role); + const applicationTables = applicationRoleTablePrivileges(); + const managedTableNames = applicationTables.map(([table]) => `public.${quotePostgresIdentifier(table)}`).join(", "); + await client.query("BEGIN"); + try { + await client.query("REVOKE CREATE ON SCHEMA public FROM PUBLIC"); + await client.query(`GRANT USAGE ON SCHEMA public TO ${quotedRole}`); + await client.query(`REVOKE ALL ON TABLE ${managedTableNames} FROM ${quotedRole}`); + for (const [table, privileges] of applicationTables) { + await client.query( + `GRANT ${privileges} ON TABLE public.${quotePostgresIdentifier(table)} TO ${quotedRole}` + ); + } + await client.query("REVOKE ALL ON FUNCTION public.claim_generation_jobs(text, integer, integer) FROM PUBLIC"); + await client.query( + "REVOKE ALL ON FUNCTION public.billing_post_wallet_entry(text, text, text, text, text, bigint, text, text, text, jsonb) FROM PUBLIC" + ); + await client.query( + `GRANT EXECUTE ON FUNCTION public.claim_generation_jobs(text, integer, integer) TO ${quotedRole}` + ); + await client.query( + `GRANT EXECUTE ON FUNCTION public.billing_post_wallet_entry(text, text, text, text, text, bigint, text, text, text, jsonb) TO ${quotedRole}` + ); + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } +} + +async function verifyApplicationRole(client, role) { + const checks = applicationRoleTablePrivileges().map(([table, privileges]) => [ + `public.${table}`, + privileges.replaceAll(" ", "") + ]); + const result = await client.query( + `SELECT + bool_and(has_table_privilege($1, table_name, privileges)) AS tables_ready, + has_function_privilege($1, 'public.claim_generation_jobs(text,integer,integer)', 'EXECUTE') AS claim_ready, + has_function_privilege( + $1, + 'public.billing_post_wallet_entry(text,text,text,text,text,bigint,text,text,text,jsonb)', + 'EXECUTE' + ) AS billing_ready + FROM unnest($2::text[], $3::text[]) AS required(table_name, privileges)`, + [role, checks.map(([table]) => table), checks.map(([, privileges]) => privileges)] + ); + const status = result.rows[0]; + if (!status?.tables_ready || !status.claim_ready || !status.billing_ready) { + throw new Error("Application role privilege verification failed"); + } +} + +function applicationRoleTablePrivileges() { + return [ + ["assets", "SELECT, INSERT, DELETE"], + ["generation_jobs", "SELECT, INSERT, UPDATE, DELETE"], + ["usage_events", "SELECT, INSERT, UPDATE"], + ["projects", "SELECT, UPDATE"], + ["image_templates", "SELECT, INSERT, UPDATE, DELETE"], + ["platform_organizations", "SELECT, INSERT, UPDATE, DELETE"], + ["platform_users", "SELECT, INSERT, UPDATE, DELETE"], + ["platform_account_migrations", "SELECT, INSERT, UPDATE"], + ["billing_price_rules", "SELECT, INSERT, UPDATE"], + ["billing_wallets", "SELECT, INSERT, UPDATE"], + ["billing_ledger", "SELECT, INSERT"] + ]; +} + +async function discoverMigrations(directory) { + const files = readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".sql")) + .map((entry) => entry.name) + .sort((left, right) => left.localeCompare(right, "en")); + const migrations = []; + for (const file of files) { + const sql = await readFile(join(directory, file), "utf8"); + migrations.push({ + version: basename(file, ".sql"), + checksum: createHash("sha256").update(sql).digest("hex"), + sql + }); + } + return migrations; +} + +function loadEnvFile(path) { + if (!existsSync(path)) return; + for (const line of readFileSync(path, "utf8").split(/\r?\n/)) { + const match = line.match(/^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)\s*$/); + if (!match || process.env[match[1]]) continue; + process.env[match[1]] = match[2].replace(/^['"]|['"]$/g, ""); + } +} + +function safeErrorMessage(error) { + if (!(error instanceof Error)) return "Unknown error"; + let message = error.message; + const connectionString = process.env.DATABASE_URL?.trim(); + if (connectionString) message = message.replaceAll(connectionString, "[redacted DATABASE_URL]"); + return message.replace(/postgres(?:ql)?:\/\/[^\s@]+@/gi, "postgresql://[redacted]@"); +} diff --git a/scripts/postgres-client.d.mts b/scripts/postgres-client.d.mts new file mode 100644 index 0000000..7faff56 --- /dev/null +++ b/scripts/postgres-client.d.mts @@ -0,0 +1,9 @@ +import type { Pool } from "pg"; + +export function getScriptDataBackend(env?: NodeJS.ProcessEnv): "local" | "postgres"; +export function createPostgresPool(options?: { + env?: NodeJS.ProcessEnv; + applicationName?: string; +}): Pool; +export function closePostgresPool(pool?: Pool): Promise; +export function quotePostgresIdentifier(value: string): string; diff --git a/scripts/postgres-client.mjs b/scripts/postgres-client.mjs new file mode 100644 index 0000000..f10b1c3 --- /dev/null +++ b/scripts/postgres-client.mjs @@ -0,0 +1,85 @@ +import { readFileSync } from "node:fs"; +import pg from "pg"; + +const { Pool } = pg; + +export function getScriptDataBackend(env = process.env) { + const backend = env.ZHINIAN_DATA_BACKEND?.trim().toLowerCase(); + if (backend === "local" || backend === "postgres") return backend; + throw new Error("ZHINIAN_DATA_BACKEND must be explicitly set to 'local' or 'postgres'"); +} + +export function createPostgresPool({ env = process.env, applicationName = "zhinian-script" } = {}) { + const connectionString = env.DATABASE_URL?.trim(); + if (!connectionString) throw new Error("DATABASE_URL is required when ZHINIAN_DATA_BACKEND=postgres"); + assertConnectionStringContract(connectionString); + + const config = { + connectionString, + max: positiveInteger(env, "DATABASE_POOL_MAX", 10), + idleTimeoutMillis: nonNegativeInteger(env, "DATABASE_IDLE_TIMEOUT_MS", 30_000), + connectionTimeoutMillis: positiveInteger(env, "DATABASE_CONNECTION_TIMEOUT_MS", 10_000), + statement_timeout: positiveInteger(env, "DATABASE_STATEMENT_TIMEOUT_MS", 30_000), + application_name: applicationName + }; + + const sslMode = env.DATABASE_SSL_MODE?.trim().toLowerCase() || "disable"; + if (sslMode === "verify-full") { + const caPath = env.DATABASE_CA_CERT_PATH?.trim(); + if (!caPath) throw new Error("DATABASE_CA_CERT_PATH is required when DATABASE_SSL_MODE=verify-full"); + config.ssl = { ca: readFileSync(caPath, "utf8"), rejectUnauthorized: true }; + } else if (sslMode !== "disable") { + throw new Error("DATABASE_SSL_MODE must be 'disable' or 'verify-full'"); + } + + return new Pool(config); +} + +export async function closePostgresPool(pool) { + if (pool) await pool.end(); +} + +export function quotePostgresIdentifier(value) { + if (typeof value !== "string" || !/^[a-z_][a-z0-9_]{0,62}$/.test(value)) { + throw new Error("PostgreSQL identifier must match [a-z_][a-z0-9_]{0,62}"); + } + return `"${value}"`; +} + +function assertConnectionStringContract(connectionString) { + let parsed; + try { + parsed = new URL(connectionString); + } catch { + throw new Error("DATABASE_URL must be a valid PostgreSQL connection URI"); + } + if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") { + throw new Error("DATABASE_URL must use the postgres:// or postgresql:// scheme"); + } + const sslParameters = [...parsed.searchParams.keys()].filter((key) => key.toLowerCase().startsWith("ssl")); + if (sslParameters.length > 0) { + throw new Error( + `DATABASE_URL must not contain SSL query parameters (${sslParameters.join(", ")}); use DATABASE_SSL_MODE and DATABASE_CA_CERT_PATH` + ); + } +} + +function positiveInteger(env, name, fallback) { + const value = integer(env, name, fallback); + if (value <= 0) throw new Error(`${name} must be a positive integer`); + return value; +} + +function nonNegativeInteger(env, name, fallback) { + const value = integer(env, name, fallback); + if (value < 0) throw new Error(`${name} must be a non-negative integer`); + return value; +} + +function integer(env, name, fallback) { + const raw = env[name]?.trim(); + if (!raw) return fallback; + const value = Number(raw); + if (!Number.isSafeInteger(value)) throw new Error(`${name} must be an integer`); + return value; +} diff --git a/scripts/worker.mjs b/scripts/worker.mjs index 3302838..0785c9c 100755 --- a/scripts/worker.mjs +++ b/scripts/worker.mjs @@ -4,6 +4,7 @@ const baseUrl = (process.env.ZHINIAN_WORKER_BASE_URL || process.env.NEXT_PUBLIC_ const token = (process.env.ZHINIAN_INTERNAL_WORKER_TOKEN || "").trim(); const intervalMs = positiveInt(process.env.ZHINIAN_WORKER_INTERVAL_MS, 5000); const limit = positiveInt(process.env.ZHINIAN_WORKER_BATCH_SIZE, 3); +const requestTimeoutMs = positiveInt(process.env.ZHINIAN_WORKER_REQUEST_TIMEOUT_MS, 120000); const once = process.argv.includes("--once"); const workerId = process.env.ZHINIAN_WORKER_ID || `worker-${Math.random().toString(16).slice(2)}`; @@ -19,7 +20,8 @@ async function tick() { "Content-Type": "application/json", ...(token ? { "X-Zhinian-Worker-Token": token } : {}) }, - body: JSON.stringify({ workerId, limit }) + body: JSON.stringify({ workerId, limit }), + signal: AbortSignal.timeout(requestTimeoutMs) }); const text = await response.text(); if (!response.ok) throw new Error(`Worker tick failed: ${response.status} ${text}`); diff --git a/supabase/schema.sql b/supabase/schema.sql index 55f8ea5..ae7bfd4 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -1,3 +1,7 @@ +-- Compatibility snapshot for existing Supabase deployments. +-- New PostgreSQL/RDS deployments must use `npm run db:migrate`; do not use this +-- file as an unversioned migration source. + create table if not exists assets ( id text primary key, owner_id text not null, @@ -89,6 +93,8 @@ alter table usage_events add column if not exists account_display_name text; alter table usage_events add column if not exists tenant_id text; alter table usage_events add column if not exists organization_id text; alter table usage_events add column if not exists organization_name text; +alter table usage_events add column if not exists quantity integer not null default 1; +alter table usage_events add column if not exists estimated_unit text not null default 'job'; update usage_events as usage set source = case @@ -113,11 +119,6 @@ alter table usage_events alter column source set not null; alter table usage_events alter column estimated_unit set default 'job'; alter table usage_events drop constraint if exists usage_events_job_id_fkey; -delete from usage_events as later -using usage_events as earlier -where later.job_id = earlier.job_id - and (later.created_at, later.id) > (earlier.created_at, earlier.id); - create table if not exists projects ( id text primary key, owner_id text not null, @@ -151,6 +152,20 @@ create unique index if not exists generation_jobs_idempotency_idx on generation_jobs(owner_id, external_client_id, idempotency_key) where external_client_id is not null and idempotency_key is not null; create index if not exists usage_events_owner_created_idx on usage_events(owner_id, created_at desc); +do $$ +begin + if exists ( + select 1 + from usage_events + group by job_id + having count(*) > 1 + ) then + raise exception using + errcode = '23505', + message = 'USAGE_EVENTS_DUPLICATE_JOB_ID: back up and clean duplicate usage_events.job_id rows before retrying this migration'; + end if; +end; +$$; create unique index if not exists usage_events_job_id_idx on usage_events(job_id); create index if not exists usage_events_source_created_idx on usage_events(source, created_at desc); create index if not exists usage_events_organization_created_idx on usage_events(organization_id, created_at desc); @@ -163,6 +178,7 @@ create or replace function claim_generation_jobs( ) returns setof generation_jobs language plpgsql +set search_path = public, pg_temp as $$ declare v_now timestamptz := now(); @@ -194,6 +210,8 @@ begin end; $$; +revoke all on function claim_generation_jobs(text, integer, integer) from public; + create table if not exists platform_organizations ( id text primary key, name text not null unique, @@ -288,12 +306,61 @@ create table if not exists billing_ledger ( delta_fen bigint not null check (delta_fen <> 0), balance_after_fen bigint not null check (balance_after_fen >= 0), currency text not null default 'CNY' check (currency = 'CNY'), - idempotency_key text not null unique, + idempotency_key text not null, description text not null, metadata jsonb not null default '{}'::jsonb, created_at timestamptz not null default now() ); +do $$ +declare + v_constraint record; + v_index record; +begin + for v_constraint in + select constraint_schema, constraint_name + from information_schema.table_constraints + where table_schema = current_schema() + and table_name = 'billing_ledger' + and constraint_type = 'UNIQUE' + and array( + select key_column_usage.column_name::text + from information_schema.key_column_usage + where key_column_usage.constraint_schema = table_constraints.constraint_schema + and key_column_usage.constraint_name = table_constraints.constraint_name + and key_column_usage.table_name = table_constraints.table_name + order by key_column_usage.ordinal_position + ) = array['idempotency_key']::text[] + loop + execute format('alter table %I.%I drop constraint %I', current_schema(), 'billing_ledger', v_constraint.constraint_name); + end loop; + + for v_index in + select indexes.schemaname, indexes.indexname + from pg_indexes as indexes + join pg_class as index_class on index_class.relname = indexes.indexname + join pg_namespace as index_namespace + on index_namespace.oid = index_class.relnamespace + and index_namespace.nspname = indexes.schemaname + join pg_index as index_metadata on index_metadata.indexrelid = index_class.oid + join pg_attribute as indexed_column + on indexed_column.attrelid = index_metadata.indrelid + and indexed_column.attnum = index_metadata.indkey[0] + left join pg_constraint as backing_constraint on backing_constraint.conindid = index_class.oid + where indexes.schemaname = current_schema() + and indexes.tablename = 'billing_ledger' + and index_metadata.indisunique + and index_metadata.indnkeyatts = 1 + and indexed_column.attname = 'idempotency_key' + and backing_constraint.oid is null + loop + execute format('drop index %I.%I', v_index.schemaname, v_index.indexname); + end loop; +end; +$$; + +create unique index if not exists billing_ledger_organization_idempotency_idx + on billing_ledger(organization_id, idempotency_key); create index if not exists billing_ledger_organization_created_idx on billing_ledger(organization_id, created_at desc); create index if not exists billing_ledger_account_created_idx on billing_ledger(account_id, created_at desc); create index if not exists billing_ledger_job_idx on billing_ledger(job_id); @@ -321,11 +388,13 @@ returns table ( delta_fen bigint ) language plpgsql +set search_path = public, pg_temp as $$ declare v_existing billing_ledger%rowtype; v_wallet billing_wallets%rowtype; v_entry billing_ledger%rowtype; + v_account_id text; begin if p_currency <> 'CNY' then raise exception 'BILLING_UNSUPPORTED_CURRENCY'; @@ -334,9 +403,29 @@ begin raise exception 'BILLING_ZERO_DELTA'; end if; - select * into v_existing from billing_ledger where idempotency_key = p_idempotency_key; + v_account_id := case when p_kind in ('recharge', 'adjustment') then null else p_account_id end; + + perform pg_advisory_xact_lock( + hashtextextended(jsonb_build_array(p_organization_id, p_idempotency_key)::text, 0) + ); + + select * into v_existing + from billing_ledger + where organization_id = p_organization_id + and idempotency_key = p_idempotency_key; if found then - select * into v_wallet from billing_wallets where organization_id = v_existing.organization_id; + if v_existing.account_id is distinct from v_account_id + or v_existing.job_id is distinct from p_job_id + or v_existing.kind is distinct from p_kind + or v_existing.delta_fen is distinct from p_delta_fen + or v_existing.currency is distinct from p_currency + then + raise exception using + errcode = 'P0001', + message = 'BILLING_IDEMPOTENCY_PAYLOAD_MISMATCH'; + end if; + + select * into v_wallet from billing_wallets where organization_id = p_organization_id; return query select v_existing.id, v_existing.balance_after_fen, v_wallet.balance_fen, v_wallet.total_recharged_fen, v_wallet.total_charged_fen, v_existing.created_at, v_wallet.updated_at, v_existing.delta_fen; @@ -368,7 +457,7 @@ begin balance_after_fen, currency, idempotency_key, description, metadata ) values ( p_ledger_id, p_organization_id, - case when p_kind in ('recharge', 'adjustment') then null else p_account_id end, + v_account_id, p_job_id, p_kind, p_delta_fen, v_wallet.balance_fen, p_currency, p_idempotency_key, p_description, coalesce(p_metadata, '{}'::jsonb) ) returning * into v_entry; @@ -378,3 +467,5 @@ begin v_wallet.updated_at, v_entry.delta_fen; end; $$; + +revoke all on function billing_post_wallet_entry(text, text, text, text, text, bigint, text, text, text, jsonb) from public; diff --git a/task_plan.md b/task_plan.md index fd4e098..93747e0 100644 --- a/task_plan.md +++ b/task_plan.md @@ -4,7 +4,7 @@ Replace the external OAuth2 account dependency with a platform-owned phone/password account system using three roles: super administrator, organization administrator, and ordinary user. Preserve legacy account identity and business history through an import/mapping path while enforcing one account per organization. ## Current Phase -Phase 67 - Unified Default Billing Multiplier complete +Phase 77 - Alibaba Cloud RDS PostgreSQL Adapter complete ## Phases @@ -601,3 +601,13 @@ Phase 67 - Unified Default Billing Multiplier complete - [x] Add regression coverage for insufficient balance, unbound super-admin submission, and Seedance settlement without a wallet ledger - [x] Verify focused/full tests, TypeScript, production build, and diff hygiene - **Status:** complete + +### Phase 77: Alibaba Cloud RDS PostgreSQL Adapter +- [x] Add one server-only PostgreSQL module for explicit backend selection, pool lifecycle, validated TLS/timeouts, parameterized queries, transactions, and readiness. +- [x] Replace Supabase/PostgREST access in data, account, and billing stores while preserving their exported interfaces and local JSON development backend. +- [x] Migrate bootstrap/import tooling and package dependencies from `@supabase/supabase-js` to `pg`. +- [x] Add versioned, advisory-locked database migration tooling and an RDS-compatible baseline schema preserving atomic claim and wallet functions. +- [x] Add database readiness, Docker migration support, and production-oriented ACK Web/Worker/Service/Ingress/ConfigMap/Secret/migration manifests. +- [x] Update environment/deployment guidance and add focused regression coverage. +- [x] Run focused tests, full tests, TypeScript, production build, migration validation, diff hygiene, and final read-only Sol review. +- **Status:** complete diff --git a/tests/account-store-postgres-auth.test.ts b/tests/account-store-postgres-auth.test.ts new file mode 100644 index 0000000..249575e --- /dev/null +++ b/tests/account-store-postgres-auth.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { hashLocalPassword, queryDatabase, transactionQuery, verifyLocalPassword, withDatabaseTransaction, state } = vi.hoisted(() => { + const state = { + user: {} as Record, + transactionTail: Promise.resolve() as Promise + }; + const transactionQuery = vi.fn(async (text: string, values: readonly unknown[] = []) => { + if (text.includes("SELECT * FROM platform_users")) return { rows: [{ ...state.user }], rowCount: 1 }; + if (text.includes("SET failed_login_count=$2")) { + state.user.failed_login_count = values[1]; + state.user.locked_until = values[2]; + state.user.updated_at = values[3]; + return { rows: [], rowCount: 1 }; + } + if (text.includes("SET failed_login_count=0")) { + state.user.failed_login_count = 0; + state.user.locked_until = null; + state.user.last_login_at = values[1]; + state.user.updated_at = values[1]; + return { rows: [{ ...state.user }], rowCount: 1 }; + } + if (text.includes("SET password_hash=$2")) { + state.user.password_hash = values[1]; + state.user.password_salt = values[2]; + state.user.session_version = Number(state.user.session_version) + 1; + state.user.updated_at = values[3]; + return { rows: [{ ...state.user }], rowCount: 1 }; + } + throw new Error(`Unexpected SQL: ${text}`); + }); + const withDatabaseTransaction = vi.fn((callback: (client: { query: typeof transactionQuery }) => Promise) => { + const run = state.transactionTail.then(() => callback({ query: transactionQuery })); + state.transactionTail = run.catch(() => undefined); + return run; + }); + return { + hashLocalPassword: vi.fn(async (password: string) => ({ hash: `hash:${password}`, salt: `salt:${password}` })), + queryDatabase: vi.fn(), + transactionQuery, + verifyLocalPassword: vi.fn(async (password: string, hash: string) => hash === `hash:${password}`), + withDatabaseTransaction, + state + }; +}); + +vi.mock("@/lib/server/database", () => ({ + isPostgresBackend: () => true, + queryDatabase, + withDatabaseTransaction +})); + +vi.mock("@/lib/server/data-store", () => ({ + reassignOwnerData: vi.fn() +})); + +vi.mock("@/lib/server/auth/password", () => ({ + hashLocalPassword, + verifyLocalPassword +})); + +import { authenticatePlatformUser, changeOwnPassword } from "@/lib/server/account-store"; + +describe("PostgreSQL account authentication", () => { + beforeEach(() => { + queryDatabase.mockReset(); + transactionQuery.mockClear(); + withDatabaseTransaction.mockClear(); + hashLocalPassword.mockClear(); + verifyLocalPassword.mockClear(); + state.transactionTail = Promise.resolve(); + state.user = { + id: "user-1", + phone: "13800138000", + display_name: "Concurrent user", + role: "super_admin", + organization_id: null, + status: "active", + password_hash: "hash", + password_salt: "salt", + failed_login_count: 0, + locked_until: null, + session_version: 1, + last_login_at: null, + legacy_subject: null, + created_at: new Date("2026-08-12T00:00:00.000Z"), + updated_at: new Date("2026-08-12T00:00:00.000Z") + }; + }); + + it("allows only one concurrent password change using the same current password", async () => { + state.user.password_hash = "hash:current-password"; + state.user.password_salt = "salt:current-password"; + + const attempts = await Promise.allSettled([ + changeOwnPassword("user-1", "current-password", "next-password-one"), + changeOwnPassword("user-1", "current-password", "next-password-two") + ]); + + expect(attempts[0]).toMatchObject({ status: "fulfilled", value: { sessionVersion: 2 } }); + expect(attempts[1]).toMatchObject({ status: "rejected", reason: { status: 400 } }); + expect(state.user.password_hash).toBe("hash:next-password-one"); + expect(state.user.session_version).toBe(2); + expect(withDatabaseTransaction).toHaveBeenCalledTimes(2); + expect(transactionQuery.mock.calls.filter(([sql]) => String(sql).includes("FOR UPDATE"))).toHaveLength(2); + expect(transactionQuery.mock.calls.filter(([sql]) => String(sql).includes("session_version=session_version+1"))).toHaveLength(1); + expect(hashLocalPassword).toHaveBeenCalledTimes(1); + expect(queryDatabase).not.toHaveBeenCalled(); + }); + + it("serializes concurrent failures on the user row and locks on the fifth attempt", async () => { + const attempts = await Promise.allSettled( + Array.from({ length: 5 }, () => authenticatePlatformUser("138 0013 8000", "wrong-password")) + ); + + expect(attempts.map((attempt) => attempt.status === "rejected" && attempt.reason.status)).toEqual([401, 401, 401, 401, 423]); + expect(state.user.failed_login_count).toBe(0); + expect(new Date(String(state.user.locked_until)).getTime()).toBeGreaterThan(Date.now()); + expect(withDatabaseTransaction).toHaveBeenCalledTimes(5); + expect(transactionQuery.mock.calls.filter(([sql]) => String(sql).includes("FOR UPDATE"))).toHaveLength(5); + expect(queryDatabase).not.toHaveBeenCalled(); + + await expect(authenticatePlatformUser("13800138000", "wrong-password")).rejects.toMatchObject({ status: 423 }); + expect(transactionQuery.mock.calls.filter(([sql]) => String(sql).includes("SET failed_login_count=$2"))).toHaveLength(5); + }); +}); diff --git a/tests/account-store.test.ts b/tests/account-store.test.ts index e2968c5..f2f8abf 100644 --- a/tests/account-store.test.ts +++ b/tests/account-store.test.ts @@ -30,8 +30,7 @@ describe("platform account ownership lifecycle", () => { vi.stubEnv("ZHINIAN_DATA_DIR", dataDirectory); vi.stubEnv("ZHINIAN_AUTH_REQUIRED", "1"); vi.stubEnv("ZHINIAN_AUTH_SESSION_SECRET", "test-platform-session-secret"); - vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", ""); - vi.stubEnv("SUPABASE_SERVICE_ROLE_KEY", ""); + vi.stubEnv("ZHINIAN_DATA_BACKEND", "local"); const organization = await createPlatformOrganization("归档组织"); archiveOwnerId = organization.archiveOwnerId; user = await createPlatformUser({ diff --git a/tests/auth-password-route.test.ts b/tests/auth-password-route.test.ts index b550746..04e5c1c 100644 --- a/tests/auth-password-route.test.ts +++ b/tests/auth-password-route.test.ts @@ -23,8 +23,7 @@ describe("platform password auth route", () => { vi.stubEnv("ZHINIAN_AUTH_REQUIRED", "1"); vi.stubEnv("ZHINIAN_AUTH_SESSION_SECRET", SESSION_SECRET); vi.stubEnv("ZHINIAN_AUTH_DISABLED", ""); - vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", ""); - vi.stubEnv("SUPABASE_SERVICE_ROLE_KEY", ""); + vi.stubEnv("ZHINIAN_DATA_BACKEND", "local"); resetLocalAuthRateLimitForTests(); const organization = await createPlatformOrganization("测试组织"); diff --git a/tests/billing-postgres-mapping.test.ts b/tests/billing-postgres-mapping.test.ts new file mode 100644 index 0000000..3121a63 --- /dev/null +++ b/tests/billing-postgres-mapping.test.ts @@ -0,0 +1,201 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { queryDatabase } = vi.hoisted(() => ({ queryDatabase: vi.fn() })); + +vi.mock("@/lib/server/database", () => ({ + getDataBackend: () => "postgres", + isPostgresBackend: () => true, + queryDatabase, + withDatabaseTransaction: vi.fn() +})); + +import { + BillingStoreError, + createBillingPriceRule, + listBillingPriceRules, + postWalletEntry, + updateBillingPriceRule +} from "@/lib/server/billing-store"; + +describe("billing PostgreSQL mapping", () => { + beforeEach(() => queryDatabase.mockReset()); + + it("maps pg bigint, numeric, and timestamptz values safely", async () => { + queryDatabase.mockResolvedValueOnce({ + rows: [{ + id: "price-1", + provider: "bailian", + capability: "image.generate", + req_key: "image", + variant_key: null, + unit: "image", + standard_unit_price_fen: "9007199254740991", + markup_multiplier: "1.2500", + enabled: true, + conditions: {}, + quantity_source: "image_count", + priority: 1, + note: null, + source: null, + parameter_dimensions: [], + created_at: new Date("2026-08-12T01:02:03.000Z"), + updated_at: new Date("2026-08-12T04:05:06.000Z") + }] + }); + + const [rule] = await listBillingPriceRules(); + + expect(rule).toMatchObject({ + standardUnitPriceFen: Number.MAX_SAFE_INTEGER, + markupMultiplier: 1.25, + createdAt: "2026-08-12T01:02:03.000Z", + updatedAt: "2026-08-12T04:05:06.000Z" + }); + expect(queryDatabase.mock.calls[0][0]).toContain("$1::boolean"); + expect(queryDatabase.mock.calls[0][1]).toEqual([false]); + }); + + it("rejects bigint values that cannot be represented without precision loss", async () => { + queryDatabase.mockResolvedValueOnce({ + rows: [{ + id: "price-unsafe", + provider: "bailian", + capability: "image.generate", + unit: "image", + standard_unit_price_fen: "9007199254740992", + markup_multiplier: "1.1", + enabled: true, + conditions: {}, + priority: 0, + parameter_dimensions: [], + created_at: new Date(), + updated_at: new Date() + }] + }); + + await expect(listBillingPriceRules()).rejects.toMatchObject({ status: 500 } satisfies Partial); + }); + + it("canonicalizes nested condition objects and arrays before PostgreSQL inserts", async () => { + queryDatabase.mockResolvedValueOnce({ + rows: [{ + id: "price-canonical", + provider: "bailian", + capability: "image.generate", + req_key: "image", + variant_key: null, + unit: "image", + standard_unit_price_fen: "100", + markup_multiplier: "1.2", + enabled: true, + conditions: { quality: { values: ["high", "standard"] } }, + quantity_source: "image_count", + priority: 0, + note: null, + source: null, + parameter_dimensions: [], + created_at: new Date("2026-08-12T01:00:00.000Z"), + updated_at: new Date("2026-08-12T01:00:00.000Z") + }] + }); + + await createBillingPriceRule({ + id: "price-canonical", + provider: "bailian", + capability: "image.generate", + reqKey: "image", + unit: "image", + standardUnitPriceFen: 100, + markupMultiplier: 1.2, + enabled: true, + conditions: { quality: { values: ["standard", "high"] } }, + quantitySource: "image_count" + }); + + expect(queryDatabase.mock.calls[0][1][9]).toBe(JSON.stringify({ quality: { values: ["high", "standard"] } })); + }); + + it("maps PostgreSQL unique violations during rule updates to conflict", async () => { + queryDatabase + .mockResolvedValueOnce({ + rows: [{ + id: "price-update", + provider: "bailian", + capability: "image.generate", + req_key: "image", + variant_key: null, + unit: "image", + standard_unit_price_fen: "100", + markup_multiplier: "1.2", + enabled: true, + conditions: {}, + quantity_source: "image_count", + priority: 0, + note: null, + source: null, + parameter_dimensions: [], + created_at: new Date("2026-08-12T01:00:00.000Z"), + updated_at: new Date("2026-08-12T01:00:00.000Z") + }] + }) + .mockRejectedValueOnce(Object.assign(new Error("duplicate key value violates unique constraint"), { code: "23505" })); + + await expect(updateBillingPriceRule("price-update", { + conditions: { quality: { values: ["standard", "high"] } } + })).rejects.toMatchObject({ status: 409 } satisfies Partial); + + expect(queryDatabase.mock.calls[1][1][9]).toBe(JSON.stringify({ quality: { values: ["high", "standard"] } })); + }); + + it("calls the atomic wallet function with positional parameters and maps its row", async () => { + queryDatabase.mockResolvedValueOnce({ + rows: [{ + ledger_id: "ledger-1", + balance_after_fen: "1250", + balance_fen: "1250", + total_recharged_fen: "1500", + total_charged_fen: "250", + delta_fen: "-250", + created_at: new Date("2026-08-12T06:00:00.000Z"), + updated_at: new Date("2026-08-12T06:00:00.000Z") + }] + }); + + const result = await postWalletEntry({ + organizationId: "org-1", + accountId: "account-1", + jobId: "job-1", + kind: "charge", + deltaFen: -250, + idempotencyKey: "charge:job-1", + description: "generation charge", + metadata: { provider: "bailian" } + }); + + expect(result.entry).toMatchObject({ + id: "ledger-1", + deltaFen: -250, + balanceAfterFen: 1250, + createdAt: "2026-08-12T06:00:00.000Z" + }); + expect(result.wallet).toMatchObject({ balanceFen: 1250, totalRechargedFen: 1500, totalChargedFen: 250 }); + expect(queryDatabase.mock.calls[0][0]).toContain("billing_post_wallet_entry"); + expect(queryDatabase.mock.calls[0][0]).toContain("$10::jsonb"); + expect(queryDatabase.mock.calls[0][1].slice(1)).toEqual([ + "org-1", "account-1", "job-1", "charge", -250, "CNY", "charge:job-1", + "generation charge", JSON.stringify({ provider: "bailian" }) + ]); + }); + + it("maps idempotency payload drift to a conflict", async () => { + queryDatabase.mockRejectedValueOnce(new Error("BILLING_IDEMPOTENCY_PAYLOAD_MISMATCH")); + + await expect(postWalletEntry({ + organizationId: "org-1", + kind: "recharge", + deltaFen: 100, + idempotencyKey: "same-key", + description: "request" + })).rejects.toMatchObject({ status: 409 } satisfies Partial); + }); +}); diff --git a/tests/billing.test.ts b/tests/billing.test.ts index 3d85cec..65cfe0b 100644 --- a/tests/billing.test.ts +++ b/tests/billing.test.ts @@ -29,8 +29,7 @@ describe("organization billing", () => { runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-billing-")); vi.stubEnv("ZHINIAN_RUNTIME_DIR", runtimeDir); vi.stubEnv("ZHINIAN_BILLING_REQUIRED", "1"); - vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", ""); - vi.stubEnv("SUPABASE_SERVICE_ROLE_KEY", ""); + vi.stubEnv("ZHINIAN_DATA_BACKEND", "local"); }); afterEach(async () => { @@ -433,7 +432,8 @@ describe("organization billing", () => { accountId: "user-1", jobId: job.id, idempotencyKey: `job-charge:${job.id}`, - description: "重复扣费" + description: "重试时更新的审计文案", + metadata: { retry: true } })).entry.id).toBe(charged.billing?.ledgerEntryId); const failed = await updateGenerationJob(charged.id, { status: "failed" }); @@ -445,6 +445,41 @@ describe("organization billing", () => { expect(ledger[0].balanceAfterFen).toBe(1000); }); + it("scopes local wallet idempotency by organization and rejects payload drift", async () => { + const first = await postWalletEntry({ + organizationId: "org-1", + kind: "recharge", + deltaFen: 100, + idempotencyKey: "shared-key", + description: "same request" + }); + const otherOrganization = await postWalletEntry({ + organizationId: "org-2", + kind: "recharge", + deltaFen: 100, + idempotencyKey: "shared-key", + description: "same request" + }); + + expect(otherOrganization.entry.id).not.toBe(first.entry.id); + const retried = await postWalletEntry({ + organizationId: "org-1", + kind: "recharge", + deltaFen: 100, + idempotencyKey: "shared-key", + description: "updated audit label", + metadata: { retry: true } + }); + expect(retried.entry.id).toBe(first.entry.id); + await expect(postWalletEntry({ + organizationId: "org-1", + kind: "recharge", + deltaFen: 200, + idempotencyKey: "shared-key", + description: "same request" + })).rejects.toMatchObject({ status: 409 }); + }); + it("blocks an ordinary generation before provider dispatch when the organization balance is insufficient", async () => { await createBillingPriceRule({ id: "rule-insufficient-image", diff --git a/tests/data-store-concurrency.test.ts b/tests/data-store-concurrency.test.ts index 76392e3..40850f9 100644 --- a/tests/data-store-concurrency.test.ts +++ b/tests/data-store-concurrency.test.ts @@ -11,24 +11,20 @@ import { DEFAULT_OWNER_ID } from "@/lib/server/runtime"; let runtimeDir = ""; let previousRuntimeDir: string | undefined; -let previousSupabaseUrl: string | undefined; -let previousSupabaseKey: string | undefined; +let previousDataBackend: string | undefined; describe("local data store concurrency", () => { beforeEach(async () => { runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-store-")); previousRuntimeDir = process.env.ZHINIAN_RUNTIME_DIR; - previousSupabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - previousSupabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + previousDataBackend = process.env.ZHINIAN_DATA_BACKEND; process.env.ZHINIAN_RUNTIME_DIR = runtimeDir; - delete process.env.NEXT_PUBLIC_SUPABASE_URL; - delete process.env.SUPABASE_SERVICE_ROLE_KEY; + process.env.ZHINIAN_DATA_BACKEND = "local"; }); afterEach(async () => { restoreEnv("ZHINIAN_RUNTIME_DIR", previousRuntimeDir); - restoreEnv("NEXT_PUBLIC_SUPABASE_URL", previousSupabaseUrl); - restoreEnv("SUPABASE_SERVICE_ROLE_KEY", previousSupabaseKey); + restoreEnv("ZHINIAN_DATA_BACKEND", previousDataBackend); await rm(runtimeDir, { force: true, recursive: true }); }); diff --git a/tests/database-readiness-contract.test.ts b/tests/database-readiness-contract.test.ts new file mode 100644 index 0000000..8665cb9 --- /dev/null +++ b/tests/database-readiness-contract.test.ts @@ -0,0 +1,25 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +describe("PostgreSQL readiness contract", () => { + it("checks every runtime table and both atomic functions", async () => { + const source = await readFile(new URL("../lib/server/database.ts", import.meta.url), "utf8"); + for (const table of [ + "assets", + "generation_jobs", + "usage_events", + "projects", + "image_templates", + "platform_organizations", + "platform_users", + "platform_account_migrations", + "billing_price_rules", + "billing_wallets", + "billing_ledger" + ]) { + expect(source).toContain(`('${table}',`); + } + expect(source).toContain("claim_generation_jobs(text,integer,integer)"); + expect(source).toContain("billing_post_wallet_entry(text,text,text,text,text,bigint,text,text,text,jsonb)"); + }); +}); diff --git a/tests/image-templates.test.ts b/tests/image-templates.test.ts index 889056c..32453ba 100644 --- a/tests/image-templates.test.ts +++ b/tests/image-templates.test.ts @@ -13,24 +13,20 @@ import { extractMaterialPlaceholders } from "@/lib/prompt/material-placeholders" let runtimeDir = ""; let previousRuntimeDir: string | undefined; -let previousSupabaseUrl: string | undefined; -let previousSupabaseKey: string | undefined; +let previousDataBackend: string | undefined; describe("image templates", () => { beforeEach(async () => { runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-templates-")); previousRuntimeDir = process.env.ZHINIAN_RUNTIME_DIR; - previousSupabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - previousSupabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + previousDataBackend = process.env.ZHINIAN_DATA_BACKEND; process.env.ZHINIAN_RUNTIME_DIR = runtimeDir; - delete process.env.NEXT_PUBLIC_SUPABASE_URL; - delete process.env.SUPABASE_SERVICE_ROLE_KEY; + process.env.ZHINIAN_DATA_BACKEND = "local"; }); afterEach(async () => { restoreEnv("ZHINIAN_RUNTIME_DIR", previousRuntimeDir); - restoreEnv("NEXT_PUBLIC_SUPABASE_URL", previousSupabaseUrl); - restoreEnv("SUPABASE_SERVICE_ROLE_KEY", previousSupabaseKey); + restoreEnv("ZHINIAN_DATA_BACKEND", previousDataBackend); await rm(runtimeDir, { force: true, recursive: true }); }); diff --git a/tests/postgres-client-config.test.ts b/tests/postgres-client-config.test.ts new file mode 100644 index 0000000..5c7fb96 --- /dev/null +++ b/tests/postgres-client-config.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { + createPostgresPool, + getScriptDataBackend, + quotePostgresIdentifier +} from "../scripts/postgres-client.mjs"; + +describe("PostgreSQL script configuration", () => { + it("requires an explicit data backend", () => { + expect(() => getScriptDataBackend({ NODE_ENV: "test" })).toThrow("ZHINIAN_DATA_BACKEND"); + expect(getScriptDataBackend({ NODE_ENV: "test", ZHINIAN_DATA_BACKEND: "postgres" })).toBe("postgres"); + }); + + it("rejects connection-string SSL options that could override the verified CA configuration", () => { + expect(() => createPostgresPool({ + env: { + NODE_ENV: "test", + ZHINIAN_DATA_BACKEND: "postgres", + DATABASE_URL: "postgresql://app:secret@rds.example:5432/app?sslmode=no-verify" + } + })).toThrow("must not contain SSL query parameters"); + }); + + it("quotes PostgreSQL role identifiers without allowing SQL syntax injection", () => { + expect(quotePostgresIdentifier("zhinian_app")).toBe('"zhinian_app"'); + expect(() => quotePostgresIdentifier('app"role')).toThrow("identifier"); + expect(() => quotePostgresIdentifier("bad\0role")).toThrow("identifier"); + }); +}); diff --git a/tests/postgres-privilege-contract.test.ts b/tests/postgres-privilege-contract.test.ts new file mode 100644 index 0000000..d84f2fb --- /dev/null +++ b/tests/postgres-privilege-contract.test.ts @@ -0,0 +1,14 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +describe("PostgreSQL application-role privilege contract", () => { + it("uses explicit current-object grants and no blanket future-object grants", async () => { + const source = await readFile(new URL("../scripts/migrate-postgres.mjs", import.meta.url), "utf8"); + expect(source).toContain('["billing_wallets", "SELECT, INSERT, UPDATE"]'); + expect(source).toContain('["billing_ledger", "SELECT, INSERT"]'); + expect(source).toContain("applicationRoleTablePrivileges().map"); + expect(source).toContain("REVOKE ALL ON TABLE"); + expect(source).not.toContain("ALTER DEFAULT PRIVILEGES"); + expect(source).not.toContain("ALL FUNCTIONS IN SCHEMA"); + }); +}); diff --git a/tests/postgres-script-contract.test.ts b/tests/postgres-script-contract.test.ts new file mode 100644 index 0000000..96c8b66 --- /dev/null +++ b/tests/postgres-script-contract.test.ts @@ -0,0 +1,14 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +describe("PostgreSQL account script concurrency contract", () => { + it("serializes bootstrap and legacy imports that target the same logical account", async () => { + const [bootstrap, legacyImport] = await Promise.all([ + readFile(new URL("../scripts/bootstrap-admin.mjs", import.meta.url), "utf8"), + readFile(new URL("../scripts/import-legacy-accounts.mjs", import.meta.url), "utf8") + ]); + expect(bootstrap).toContain("pg_advisory_xact_lock"); + expect(legacyImport).toContain("pg_advisory_xact_lock(hashtextextended($1, 0))"); + expect(legacyImport).toContain("session_version=platform_users.session_version+1"); + }); +}); diff --git a/tests/server-only-stub.ts b/tests/server-only-stub.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/tests/server-only-stub.ts @@ -0,0 +1 @@ +export {}; diff --git a/tests/task-management.test.ts b/tests/task-management.test.ts index 0a6587e..1dc5667 100644 --- a/tests/task-management.test.ts +++ b/tests/task-management.test.ts @@ -20,8 +20,7 @@ let runtimeDir = ""; const previousEnv = new Map(); const envNames = [ "ZHINIAN_RUNTIME_DIR", - "NEXT_PUBLIC_SUPABASE_URL", - "SUPABASE_SERVICE_ROLE_KEY", + "ZHINIAN_DATA_BACKEND", "ZHINIAN_API_KEYS", "JIMENG_VISUAL_MOCK", "IMAGE_GENERATE_ENGINE", @@ -42,13 +41,12 @@ describe("task management and public API helpers", () => { runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-tasks-")); for (const name of envNames) previousEnv.set(name, process.env[name]); process.env.ZHINIAN_RUNTIME_DIR = runtimeDir; + process.env.ZHINIAN_DATA_BACKEND = "local"; process.env.ZHINIAN_API_KEYS = "agent-a:secret-a,agent-b:secret-b"; process.env.JIMENG_VISUAL_MOCK = "true"; delete process.env.IMAGE_GENERATE_ENGINE; delete process.env.EVOLINK_MOCK; delete process.env.EVOLINK_API_KEY; - delete process.env.NEXT_PUBLIC_SUPABASE_URL; - delete process.env.SUPABASE_SERVICE_ROLE_KEY; delete process.env.VOLCENGINE_ACCESS_KEY_ID; delete process.env.VOLCENGINE_SECRET_ACCESS_KEY; delete process.env.ALI_OSS_ENDPOINT; diff --git a/tests/usage-service.test.ts b/tests/usage-service.test.ts index 9f99c1a..a8cefbc 100644 --- a/tests/usage-service.test.ts +++ b/tests/usage-service.test.ts @@ -15,15 +15,14 @@ import type { GenerationJob, UsageContext } from "@/lib/types"; let runtimeDir = ""; const previousEnv = new Map(); -const envNames = ["ZHINIAN_RUNTIME_DIR", "NEXT_PUBLIC_SUPABASE_URL", "SUPABASE_SERVICE_ROLE_KEY"]; +const envNames = ["ZHINIAN_RUNTIME_DIR", "ZHINIAN_DATA_BACKEND"]; describe("usage metering and reports", () => { beforeEach(async () => { runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-usage-")); for (const name of envNames) previousEnv.set(name, process.env[name]); process.env.ZHINIAN_RUNTIME_DIR = runtimeDir; - delete process.env.NEXT_PUBLIC_SUPABASE_URL; - delete process.env.SUPABASE_SERVICE_ROLE_KEY; + process.env.ZHINIAN_DATA_BACKEND = "local"; }); afterEach(async () => { diff --git a/tests/wallet-sql-contract.test.ts b/tests/wallet-sql-contract.test.ts new file mode 100644 index 0000000..343ebb5 --- /dev/null +++ b/tests/wallet-sql-contract.test.ts @@ -0,0 +1,58 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +const schemaPaths = [ + "../database/migrations/0001_initial_schema.sql", + "../supabase/schema.sql" +]; + +function readSchema(relativePath: string): string { + return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8").replace(/\r\n/g, "\n"); +} + +describe.each(schemaPaths)("wallet SQL contract: %s", (schemaPath) => { + const sql = readSchema(schemaPath); + + it("scopes wallet idempotency to an organization", () => { + expect(sql).not.toMatch(/idempotency_key\s+text\s+not\s+null\s+unique/i); + expect(sql).toMatch(/create unique index if not exists billing_ledger_organization_idempotency_idx\s+on billing_ledger\s*\(organization_id, idempotency_key\)/i); + expect(sql).toMatch(/drop constraint %I/); + expect(sql).toMatch(/drop index %I\.%I/); + expect(sql).toMatch(/hashtextextended\(jsonb_build_array\(p_organization_id, p_idempotency_key\)::text, 0\)/i); + expect(sql).toMatch(/from billing_ledger\s+where organization_id = p_organization_id\s+and idempotency_key = p_idempotency_key/i); + }); + + it("rejects reuse of an idempotency key with a different payload", () => { + for (const comparison of [ + "v_existing.account_id is distinct from v_account_id", + "v_existing.job_id is distinct from p_job_id", + "v_existing.kind is distinct from p_kind", + "v_existing.delta_fen is distinct from p_delta_fen", + "v_existing.currency is distinct from p_currency" + ]) { + expect(sql.toLowerCase()).toContain(comparison); + } + expect(sql.toLowerCase()).not.toContain("v_existing.description is distinct from p_description"); + expect(sql.toLowerCase()).not.toContain("v_existing.metadata is distinct from coalesce(p_metadata"); + expect(sql).toMatch(/errcode = 'P0001'[\s\S]*message = 'BILLING_IDEMPOTENCY_PAYLOAD_MISMATCH'/); + }); + + it("fails safely instead of deleting duplicate usage events", () => { + expect(sql).not.toMatch(/delete\s+from\s+usage_events/i); + expect(sql).toMatch(/group by job_id\s+having count\(\*\) > 1/i); + expect(sql).toMatch(/errcode = '23505'[\s\S]*USAGE_EVENTS_DUPLICATE_JOB_ID/); + expect(sql.indexOf("USAGE_EVENTS_DUPLICATE_JOB_ID")).toBeLessThan(sql.indexOf("create unique index if not exists usage_events_job_id_idx")); + }); +}); + +it("keeps the migration and Supabase compatibility snapshot synchronized", () => { + const migration = readSchema(schemaPaths[0]); + const snapshot = readSchema(schemaPaths[1]).replace( + /^-- Compatibility snapshot for existing Supabase deployments\.\r?\n-- New PostgreSQL\/RDS deployments must use `npm run db:migrate`; do not use this\r?\n-- file as an unversioned migration source\.\r?\n\r?\n/, + "" + ); + + expect(snapshot).toBe(migration); +}); diff --git a/tests/worker-script.test.ts b/tests/worker-script.test.ts index cfc4d98..b786a28 100644 --- a/tests/worker-script.test.ts +++ b/tests/worker-script.test.ts @@ -21,4 +21,11 @@ describe("worker script configuration", () => { stderr: expect.stringContaining("ZHINIAN_INTERNAL_WORKER_TOKEN is required") }); }); + + it("bounds each internal tick request with AbortSignal.timeout", async () => { + const source = await import("node:fs/promises").then(({ readFile }) => + readFile(new URL("../scripts/worker.mjs", import.meta.url), "utf8") + ); + expect(source).toContain("AbortSignal.timeout(requestTimeoutMs)"); + }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 08af0ae..3848a9a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,7 +7,8 @@ export default defineConfig({ }, resolve: { alias: { - "@": new URL(".", import.meta.url).pathname + "@": new URL(".", import.meta.url).pathname, + "server-only": new URL("./tests/server-only-stub.ts", import.meta.url).pathname } } });