From 196fdde83f9064cf14030e3807f326e8ed4b4342 Mon Sep 17 00:00:00 2001 From: inman Date: Wed, 12 Aug 2026 12:13:06 +0800 Subject: [PATCH] feat: add local auth billing and usage management --- .env.example | 54 +- .gitignore | 1 + README.md | 81 +- README.zh-CN.md | 113 +- app/accounts/page.tsx | 7 +- app/api/admin/accounts/groups/route.ts | 50 +- app/api/admin/accounts/password/route.ts | 57 +- app/api/admin/accounts/route.ts | 327 +- app/api/admin/billing/account/route.ts | 27 + app/api/admin/billing/adjustments/route.ts | 75 + app/api/admin/billing/prices/[id]/route.ts | 45 + app/api/admin/billing/prices/route.ts | 16 + app/api/admin/billing/route.ts | 49 + app/api/admin/organizations/route.ts | 89 + app/api/admin/usage/route.ts | 61 + app/api/assets/[id]/inpaint/route.ts | 47 - app/api/assets/[id]/upscale/route.ts | 31 - app/api/assets/route.ts | 2 +- app/api/auth/callback/route.ts | 10 +- app/api/auth/captcha/route.ts | 24 +- app/api/auth/login/route.ts | 10 +- app/api/auth/logout/route.ts | 13 +- app/api/auth/me/route.ts | 1 + app/api/auth/password/change/route.ts | 29 + app/api/auth/password/route.ts | 145 +- app/api/billing/quote/route.ts | 23 + app/api/billing/route.ts | 24 + app/api/generations/image/[id]/retry/route.ts | 9 +- app/api/generations/image/[id]/route.ts | 7 +- app/api/generations/image/route.ts | 10 +- app/api/generations/video/[id]/route.ts | 7 +- app/api/generations/video/route.ts | 9 +- app/api/logs/route.ts | 14 +- app/api/settings/route.ts | 6 +- app/api/usage/route.ts | 22 + app/api/v1/jobs/[id]/cancel/route.ts | 7 +- app/api/v1/jobs/route.ts | 2 +- app/api/v1/openapi.json/route.ts | 10 +- app/assets/page.tsx | 18 +- app/auth/admin-login/page.tsx | 40 +- app/auth/login/page.tsx | 10 +- app/billing/error.tsx | 20 + app/billing/page.tsx | 10 + app/create/page.tsx | 6 +- app/globals.css | 3932 ++++++++++++++++- app/image-edit/page.tsx | 2 +- app/layout.tsx | 4 +- app/logs/page.tsx | 4 +- app/settings/page.tsx | 16 +- app/usage/page.tsx | 10 + components/account-manager.tsx | 809 ++-- components/account-security-panel.tsx | 82 + components/account-usage-menu.tsx | 156 + components/app-shell.tsx | 25 +- components/asset-manager.tsx | 27 +- components/auth-login-panel.tsx | 45 +- components/billing-manager.tsx | 767 ++++ components/create-studio.tsx | 622 ++- components/image-editor.tsx | 386 -- components/log-manager.tsx | 24 - components/settings-panel.tsx | 41 +- components/usage-manager.tsx | 350 ++ docs/API.md | 35 +- docs/DEPLOYMENT.md | 33 +- findings.md | 269 ++ lib/auth/config.ts | 9 +- lib/auth/permissions.ts | 58 +- lib/auth/session.ts | 14 +- lib/billing.ts | 147 + lib/evolink/image-client.ts | 25 +- lib/jimeng/capabilities.ts | 61 +- lib/seedance/client.ts | 22 + lib/server/account-store.ts | 609 +++ lib/server/app-settings.ts | 149 +- lib/server/auth/current-user.ts | 56 +- lib/server/auth/jwt.ts | 5 +- lib/server/auth/local.ts | 103 + lib/server/auth/oauth.ts | 3 +- lib/server/auth/password.ts | 24 +- lib/server/billing-catalog.ts | 289 ++ lib/server/billing-service.ts | 626 +++ lib/server/billing-store.ts | 477 ++ lib/server/data-store.ts | 224 +- lib/server/generation-service.ts | 158 +- lib/server/public-api-jobs.ts | 8 +- lib/server/seedance-billing.ts | 171 + lib/server/storage.ts | 32 - lib/server/task-manager.ts | 10 +- lib/server/usage-context.ts | 83 + lib/server/usage-service.ts | 281 ++ lib/server/video-generation-service.ts | 175 +- lib/types.ts | 228 +- lib/usage.ts | 180 + lib/video-settings.ts | 2 +- middleware.ts | 29 +- next.config.ts | 3 + package.json | 4 +- pnpm-lock.yaml | 2244 ++++++++++ progress.md | 320 ++ scripts/bootstrap-admin.mjs | 150 + scripts/import-legacy-accounts.mjs | 226 + scripts/print-app-info.mjs | 6 +- supabase/schema.sql | 239 +- task_plan.md | 223 +- tests/account-store.test.ts | 97 + tests/auth-login-panel.test.ts | 15 +- tests/auth-password-route.test.ts | 284 +- tests/auth-permissions.test.ts | 50 +- tests/auth-session.test.ts | 26 + tests/bailian-client.test.ts | 1 + tests/billing.test.ts | 631 +++ tests/evolink-image-client.test.ts | 25 +- tests/frontend-environment-copy.test.ts | 40 + tests/jimeng-capabilities.test.ts | 22 +- tests/organization-client.test.ts | 2 + tests/seedance-client.test.ts | 10 + tests/task-management.test.ts | 10 + tests/usage-service.test.ts | 179 + tsconfig.json | 23 +- 119 files changed, 15695 insertions(+), 2650 deletions(-) create mode 100644 app/api/admin/billing/account/route.ts create mode 100644 app/api/admin/billing/adjustments/route.ts create mode 100644 app/api/admin/billing/prices/[id]/route.ts create mode 100644 app/api/admin/billing/prices/route.ts create mode 100644 app/api/admin/billing/route.ts create mode 100644 app/api/admin/organizations/route.ts create mode 100644 app/api/admin/usage/route.ts delete mode 100644 app/api/assets/[id]/inpaint/route.ts delete mode 100644 app/api/assets/[id]/upscale/route.ts create mode 100644 app/api/auth/password/change/route.ts create mode 100644 app/api/billing/quote/route.ts create mode 100644 app/api/billing/route.ts create mode 100644 app/api/usage/route.ts create mode 100644 app/billing/error.tsx create mode 100644 app/billing/page.tsx create mode 100644 app/usage/page.tsx create mode 100644 components/account-security-panel.tsx create mode 100644 components/account-usage-menu.tsx create mode 100644 components/billing-manager.tsx delete mode 100644 components/image-editor.tsx create mode 100644 components/usage-manager.tsx create mode 100644 lib/billing.ts create mode 100644 lib/server/account-store.ts create mode 100644 lib/server/auth/local.ts create mode 100644 lib/server/billing-catalog.ts create mode 100644 lib/server/billing-service.ts create mode 100644 lib/server/billing-store.ts create mode 100644 lib/server/seedance-billing.ts create mode 100644 lib/server/usage-context.ts create mode 100644 lib/server/usage-service.ts create mode 100644 lib/usage.ts create mode 100644 pnpm-lock.yaml create mode 100644 scripts/bootstrap-admin.mjs create mode 100644 scripts/import-legacy-accounts.mjs create mode 100644 tests/account-store.test.ts create mode 100644 tests/billing.test.ts create mode 100644 tests/frontend-environment-copy.test.ts create mode 100644 tests/seedance-client.test.ts create mode 100644 tests/usage-service.test.ts diff --git a/.env.example b/.env.example index 775c229..37edab4 100644 --- a/.env.example +++ b/.env.example @@ -11,48 +11,16 @@ ZHINIAN_LOG_DIR= ZHINIAN_LOG_MAX_BYTES=5242880 ZHINIAN_PUBLIC_BASE_URL=http://127.0.0.1:3000 -# Account login / Web SSO. +# Platform-owned account login. # Production requires login by default. Set ZHINIAN_AUTH_REQUIRED=0 only for trusted local development. ZHINIAN_AUTH_REQUIRED=auto -ZHINIAN_AUTH_BASE_URL=https:///auth -ZHINIAN_AUTH_CLIENT_ID=custom -ZHINIAN_AUTH_CLIENT_SECRET=custom -ZHINIAN_ADMIN_AUTH_CLIENT_ID=app -ZHINIAN_ADMIN_AUTH_CLIENT_SECRET=app -# Optional tenant for platform password login. Defaults to ZHINIAN_ORG_TENANT_ID when empty. -ZHINIAN_AUTH_TENANT_ID= -# Optional tenant for admin password login; leave empty for the default admin tenant. -ZHINIAN_ADMIN_AUTH_TENANT_ID= -ZHINIAN_AUTH_SCOPE=server -ZHINIAN_AUTH_ISSUER=https://pig4cloud.com -ZHINIAN_AUTH_PASSWORD_ENC_KEY=thanks,pig4cloud ZHINIAN_AUTH_SESSION_SECRET=change-me-to-a-long-random-secret -# Comma-separated authorities that can access logs/settings/accounts. -ZHINIAN_ADMIN_AUTHORITIES=ROLE_ADMIN,sys_user_view,sys_log_view,sys_config_view -# Comma-separated usernames that can access logs/settings/accounts. Defaults include ceshiop. -ZHINIAN_ADMIN_USERS=ceshiop -# Optional overrides when endpoints do not follow AUTH_BASE defaults. -ZHINIAN_AUTH_AUTHORIZE_URL= -ZHINIAN_AUTH_TOKEN_URL= -ZHINIAN_AUTH_JWKS_URL= -ZHINIAN_AUTH_LOGOUT_URL= - -# Organization/member management API from basic-capability-services-biz. -# Base URL should include the gateway/service prefix before /organization... -ZHINIAN_ORG_API_BASE_URL= -# Optional fallback token. Account management forwards the current logged-in access_token by default. -ZHINIAN_ORG_API_TOKEN= -ZHINIAN_STAFF_API_BASE_URL= -# Optional fallback token. If empty, uses the current logged-in access_token or ZHINIAN_ORG_API_TOKEN. -ZHINIAN_STAFF_API_TOKEN= -ZHINIAN_ORG_TENANT_ID= -ZHINIAN_ORG_ID= -# Optional path overrides when the gateway exposes organization APIs through wrapper routes. -ZHINIAN_ORG_LIST_PATH= -ZHINIAN_ORG_GROUP_LIST_PATH= -ZHINIAN_ORG_ROLE_LIST_PATH= -# Defaults to /adminOrganization/organizationMember/organizationMemberList through hotelStaff. -ZHINIAN_ORG_MEMBER_LIST_PATH= +ZHINIAN_BILLING_REQUIRED=1 +ZHINIAN_BILLING_ACCOUNT_NAME= +ZHINIAN_BILLING_ACCOUNT_BANK= +ZHINIAN_BILLING_ACCOUNT_NUMBER= +ZHINIAN_BILLING_CONTACT= +# Run `npm run bootstrap:admin -- --phone 13800138000 --password 'change-me-now'` once to create the first super admin. # Public API v1 and worker task management. # Format: accountId:key,anotherAccount:anotherKey. @@ -73,9 +41,8 @@ NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= SUPABASE_SERVICE_ROLE_KEY= -# Image creation engines by capability: jimeng or evolink. +# Image creation engine: jimeng, evolink, or bailian. IMAGE_GENERATE_ENGINE=jimeng -IMAGE_INPAINT_ENGINE=jimeng # Volcengine Visual API for Jimeng image capabilities. VOLCENGINE_ACCESS_KEY_ID= @@ -84,17 +51,14 @@ VOLCENGINE_REGION=cn-north-1 VOLCENGINE_SERVICE=cv VOLCENGINE_VISUAL_ENDPOINT=https://visual.volcengineapi.com JIMENG_IMAGE_GENERATE_46_REQ_KEY=jimeng_seedream46_cvtob -JIMENG_IMAGE_INPAINT_REQ_KEY=jimeng_image2image_dream_inpaint -JIMENG_IMAGE_UPSCALE_REQ_KEY=jimeng_i2i_seed3_tilesr_cvtob # auto mocks image jobs when Volcengine credentials are missing. JIMENG_VISUAL_MOCK=auto -# EvoLink GPT Image 2 relay for image generation and inpainting. +# EvoLink GPT Image 2 relay for image generation. EVOLINK_API_KEY= EVOLINK_BASE_URL=https://api.evolink.ai EVOLINK_IMAGE_MODEL=gpt-image-2 EVOLINK_IMAGE_QUALITY=medium -EVOLINK_IMAGE_RESOLUTION=2K # auto mocks EvoLink image jobs when EVOLINK_API_KEY is missing. EVOLINK_MOCK=auto diff --git a/.gitignore b/.gitignore index 9775467..7fe6397 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .runtime/ .next/ +.next-dev/ .env .env.local .env.*.local diff --git a/README.md b/README.md index a7260db..81c1a46 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [完整中文说明](./README.zh-CN.md) -这是 `智念AIGC平台` 的 Web 极简 MVP。当前产品只保留核心闭环:统一创作图片/视频、查看结果、局部重绘、智能超清和必要设置。 +这是 `智念AIGC平台` 的 Web 极简 MVP。当前产品只保留核心闭环:统一创作图片/视频、在任务模块查看详情与下载结果,以及必要设置。 运维部署与 API 对接: @@ -51,16 +51,33 @@ Docker 部署默认使用 `docker-compose.yml` 同时启动 Web 服务和 `zhini ## Web MVP 信息架构 - `/` 自动跳转到 `/create` -- `/create` 创作,合并图片、视频、局部重绘、智能超清 -- `/assets` 结果,保留历史资产和历史生成任务 -- `/image-edit` 兼容旧入口,自动跳转到创作页的局部重绘 +- `/create` 创作,合并图片和视频 +- `/create` 右侧任务模块,保留历史任务、详情、结果预览和下载 +- `/image-edit` 兼容旧入口,自动跳转到普通创作页 +- `/billing` 计费中心,组织余额、成员消耗和管理员组织上账 - `/logs` 日志,管理员可见 - `/settings` 设置,管理员可见 -- `/accounts` 账号管理,管理员可见 +- `/accounts` 账户安全与账号管理,所有登录用户可见;管理员额外维护组织和成员 -普通用户主导航只显示创作和结果;管理员会额外看到日志、设置和账号管理。不包含工作台、项目、模板中心、Billing 或桌面端入口。 +普通用户主导航显示创作、账号和计费;管理员会额外看到日志、设置和用量。账号页中的组织和成员管理仍由管理员权限控制。不包含独立结果目录、工作台、项目、模板中心或桌面端入口。 -## 账户登录 / SSO +## 平台账号体系 + +平台不再依赖外部 OAuth2/SSO。浏览器用户统一使用手机号和密码登录,账号数据由平台自己管理:生产环境使用 Supabase/Postgres,本地开发使用 `.runtime/data/platform-accounts.json`。 + +平台支持超级管理员、组织管理员和普通用户三层角色。组织管理员只能管理本组织普通用户和查看组织汇总用量,不能查看日志、系统配置或管理组织生命周期;普通用户只能访问自己的创作、素材、任务和账户安全。 + +核心配置:`ZHINIAN_AUTH_REQUIRED`、`ZHINIAN_AUTH_SESSION_SECRET`、`NEXT_PUBLIC_SUPABASE_URL`、`SUPABASE_SERVICE_ROLE_KEY`、`ZHINIAN_DATA_DIR`。 + +首次部署时执行一次: + +```bash +npm run bootstrap:admin -- --phone 13800138000 --password '请替换为强密码' --name '平台超级管理员' +``` + +旧账号迁移使用 `npm run migrate:accounts -- path/to/legacy-accounts.json`。迁移会按旧 owner ID 和手机号更新历史素材、任务、项目、模板及用量归属;外部密码不会迁移。 + +## 旧版外部认证(已停用) 发布环境默认要求账户登录。Web 登录支持两种方式:一是 OAuth2 Authorization Code,用户跳转到认证中心登录,本服务在 `/api/auth/callback` 后端换 token;二是在本项目登录页直接输入账号、密码和图形验证码,由本服务后端调用 `${AUTH_BASE}/oauth2/token` 的 password grant。两种方式都会通过 `${AUTH_BASE}/oauth2/jwks` 本地验签 JWT,再写入 HttpOnly 会话 cookie。 @@ -84,14 +101,14 @@ https://你的域名/api/auth/callback - `ZHINIAN_AUTH_ISSUER=https://pig4cloud.com` - `ZHINIAN_AUTH_PASSWORD_ENC_KEY=thanks,pig4cloud`:按认证中心 `security.encode-key` 对 password grant 的密码做 AES-CFB 加密 - `ZHINIAN_AUTH_SESSION_SECRET`:长随机字符串,用于签名本地登录态 -- `ZHINIAN_ADMIN_AUTHORITIES`:逗号分隔的管理员权限码;命中后可访问 `/logs`、`/settings`、`/accounts` +- `ZHINIAN_ADMIN_AUTHORITIES`:逗号分隔的专用管理员角色精确白名单,例如 `ROLE_ADMIN,SUPER_ADMIN` - `ZHINIAN_ADMIN_USERS=ceshiop`:逗号分隔的管理员账号;默认 `ceshiop` 是管理员 -`/create`、`/assets`、`/settings`、`/logs`、`/accounts`、第一方生成/资产 API、以及本地上传和生成结果文件都会受登录态保护。`/logs`、`/settings`、`/accounts` 和 `/api/admin/*` 需要管理员权限。`/api/v1/*` 继续使用 `ZHINIAN_API_KEYS`,不走浏览器 SSO。 +`/create`、`/billing`、`/settings`、`/logs`、`/accounts`、`/usage`、第一方生成/资产/用量/计费 API、以及本地上传和生成结果文件都会受登录态保护。`/logs`、`/settings`、`/usage` 和 `/api/admin/*` 要求管理员登录入口创建的会话,以及管理员账号或专用角色白名单;`/accounts` 对所有登录用户开放,普通用户只看到自己的账户信息和修改密码,管理员额外看到组织与成员管理。普通入口登录始终是普通会话,即使使用管理员账号也不会显示或开放管理功能。`ROLE_1`、`sys_user_view` 和其他通用 `SYS_*` 权限不会授予管理员访问权。`/api/v1/*` 继续使用 `ZHINIAN_API_KEYS`,不走浏览器 SSO。 -如果认证中心客户端未加入 `security.ignore-clients`,`/oauth2/token` 可能返回“验证码不能为空”。普通账号登录默认使用 `custom/custom`;登录页里的“管理员登录”入口使用 `app/app`。两组 client 都需要认证中心允许 password grant。 +如果认证中心客户端未加入 `security.ignore-clients`,`/oauth2/token` 可能返回“验证码不能为空”。普通账号登录默认使用 `custom/custom`;登录页里的“管理员登录”入口使用 `app/app`。两组 client 都需要认证中心允许 password grant。普通账号从管理员入口登录会收到无权限提示且不会写入会话;旧版未记录入口类型的会话统一按普通会话处理。 -## 组织账号管理 +## 旧版组织账号接口(已停用) 后台账号管理通过组织模块接口维护成员、角色、部门绑定和成员状态,并通过企业端用户接口创建用户、重置密码。按运维提供的组织能力接口文档,需要在服务端配置: @@ -105,23 +122,31 @@ https://你的域名/api/auth/callback 成员分页列表通过 `hotelStaff` 的 `/adminOrganization/organizationMember/organizationMemberList` 对外代理;只有直连基础组织服务内部 `/organizationMember/organizationMemberList` 时才需要 `from: Y`。创建账号会优先调用 `/adminOrganization/organizationMember/addOrganizationMemberAndCreatePlatformUser`,密码重置调用 `/adminPcUser/resetPlatformUserPassword`;这些接口默认使用当前登录账号的 token,要求该账号具备管理员角色 `1`。 +## 账号、组织用量与计费 + +平台用量页统计登录用户使用真实服务商后成功完成的任务;失败、取消、过期、Mock 和开放 API 客户端任务不会计入。普通用户点击页头账号 ID 查看快捷周期和最近记录;管理员通过 `/usage` 按日期、组织、账号、功能类型和服务商查看汇总、趋势及明细。 + +计费目录由平台维护各接口的标准成本与参数档案,超级管理员只调整上浮倍率。真实生成任务提交时按“基础标准成本 × 参数档位系数 × 任务数量 × 组合倍率”报价并从组织余额冻结,单价、参数、倍率、数量和最终金额会快照到任务;每个任务在创作结果和历史任务中显示扣费状态。普通用户余额不足时会被拒绝提交并提示“余额不足,请先充值”,不会提交服务商;超级管理员仍计算并记录生成费用,但不检查或扣减组织额度,也不产生钱包扣费、退款流水。Seedance 成功后按上游 `usage.completion_tokens` 重新结算,多退少补;没有返回用量时保留冻结金额。组织成员通过 `/billing` 查看组织余额、自己的消耗和账务流水,余额属于组织而不是个人;充值和人工余额调整也只记入组织账本,不设置个人上账归属。 + +当前组织余额由超级管理员直接上账,充值和人工余额调整不选择个人归属;未来接入用户自主支付时,支付成功回调自动入账,不设置人工审核队列。余额不足或未配置对应计费规则时,真实生成任务不会提交给服务商;本地 Mock 任务免计费。未绑定组织的开放 API 任务暂保持兼容,不纳入组织余额扣费。 + +系统首次打开超管计费中心或提交真实任务时会自动补齐平台标准价格目录,不覆盖已有目录。当前默认目录为:百炼 `wan2.7-image-pro` ¥0.50/张,百炼 `wan2.7-i2v-2026-04-25` 720P ¥0.60/秒、1080P ¥1.00/秒;火山方舟 `doubao-seedance-2-0-260128` 480P/720P/1080P/4K 分别为 ¥0.46/秒、¥0.99/秒、¥2.48/秒、¥5.05/秒;EvoLink `gpt-image-2` medium/1K/1:1/无参考图基础估算 ¥0.34/张(固定汇率 1 USD = 7.20 CNY),并列出质量、分辨率、画面比例和参考图数量档位;即梦 `jimeng_seedream46_cvtob` 暂按公开资源包折算参考 ¥0.20/张,官方实时计费以控制台为准。参数化报价按基础成本乘以所选参数档位系数,组合倍率取所选档位中的最高倍率;超管只在 `/billing` 调整倍率,标准成本和参数档案由平台维护。 + +任务会快照账号、租户和组织归属,用量记录不会随任务、素材或账号删除。统计统一采用 `Asia/Shanghai`,明细不展示提示词、素材或生成结果。 + ## 图片创作引擎 -图片生成和局部重绘支持在设置页「状态」里按功能切换创作引擎: +图片生成支持在设置页「状态」里按功能切换创作引擎: - `jimeng`:默认引擎,走火山 Visual 即梦能力。 - `evolink`:走 EvoLink GPT Image 2 中转站,提交任务后轮询 EvoLink task 结果。 - `bailian`: uses Alibaba Cloud Model Studio Wan 2.7 for text/reference image generation and image-to-video. -智能超清仍走即梦超清能力。 - ## 即梦图片能力 -V1 接入三类能力: +V1 接入图片生成能力: - `image.generate`:即梦图片生成 4.6,默认 `req_key=jimeng_seedream46_cvtob` -- `image.inpaint`:交互编辑 inpainting,默认 `req_key=jimeng_image2image_dream_inpaint` -- `image.upscale`:智能超清,默认 `req_key=jimeng_i2i_seed3_tilesr_cvtob` 后端统一走火山 Visual 异步任务: @@ -132,7 +157,7 @@ V1 接入三类能力: ## EvoLink 图片能力 -设置 `IMAGE_GENERATE_ENGINE=evolink` 或 `IMAGE_INPAINT_ENGINE=evolink` 后,对应功能会使用 EvoLink: +设置 `IMAGE_GENERATE_ENGINE=evolink` 后,图片生成会使用 EvoLink: - 提交:`POST /v1/images/generations` - 查询:`GET /v1/tasks/{task_id}` @@ -149,8 +174,8 @@ V1 接入三类能力: - 素材统一上传:一个入口上传图片、视频或音频,不再拆分参考图、主体、分镜等栏目。 - `@素材` 引用:上传后自动绑定为 `@图片1`、`@视频1`、`@音频1`,chip 和 @ 候选项都显示缩略图。 - 提示词校验:通过 `/api/prompt/assemble` 检查提示词中引用的素材是否已绑定。 -- 工作台只负责生成输入和提交,不展示任务列表或最近结果。 -- 结果保存:图片和视频生成结果会写入资产记录,并在 `/assets` 保留历史任务与历史资产。 +- 任务模块:创作页右侧直接展示任务列表,点击任务可查看完整提示词、输入要素、生成参数、状态和结果。 +- 结果保存:图片和视频生成结果会写入资产记录,并在任务详情和任务卡中提供预览与下载。 未配置 `SEEDANCE_API_KEY` 时,`SEEDANCE_MOCK=auto` 会自动使用旧模板样片作为 mock 成品,方便先验收工作流。 @@ -177,6 +202,8 @@ cp .env.example .env.local - `ZHINIAN_AUTH_SESSION_SECRET` - `ZHINIAN_ADMIN_AUTHORITIES` - `ZHINIAN_ADMIN_USERS` +- `ZHINIAN_BILLING_REQUIRED=1`:启用真实任务组织计费;本地调试可设为 `0` +- `ZHINIAN_BILLING_ACCOUNT_NAME`、`ZHINIAN_BILLING_ACCOUNT_BANK`、`ZHINIAN_BILLING_ACCOUNT_NUMBER`、`ZHINIAN_BILLING_CONTACT`:对公收款账户信息,为未来自动支付入账预留 - `ZHINIAN_ORG_API_BASE_URL` - `ZHINIAN_ORG_API_TOKEN` - `ZHINIAN_STAFF_API_BASE_URL` @@ -184,7 +211,6 @@ cp .env.example .env.local - `ZHINIAN_ORG_TENANT_ID` - `ZHINIAN_ORG_ID` - `IMAGE_GENERATE_ENGINE=jimeng` 或 `evolink` -- `IMAGE_INPAINT_ENGINE=jimeng` 或 `evolink` - `VOLCENGINE_ACCESS_KEY_ID` - `VOLCENGINE_SECRET_ACCESS_KEY` - `VOLCENGINE_REGION=cn-north-1` @@ -194,14 +220,13 @@ cp .env.example .env.local - `EVOLINK_BASE_URL=https://api.evolink.ai` - `EVOLINK_IMAGE_MODEL=gpt-image-2` - `EVOLINK_IMAGE_QUALITY=medium` -- `EVOLINK_IMAGE_RESOLUTION=2K` - `EVOLINK_MOCK=auto` - `SEEDANCE_API_KEY` - `SEEDANCE_BASE_URL` - `SEEDANCE_MODEL` - `SEEDANCE_RATIO`:支持 `16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive` - `SEEDANCE_DURATION`:Seedance 2.0 支持 `4` 到 `15` 的整数秒,或 `-1` 让模型自动选择 -- `SEEDANCE_RESOLUTION`:支持 `480p`、`720p`、`1080p`;Seedance 2.0 fast 不支持 `1080p` +- `SEEDANCE_RESOLUTION`:支持 `480p`、`720p`、`1080p`、`4k`;Seedance 2.0 fast 不支持 `1080p` - `SEEDANCE_MOCK` - `ALI_OSS_*`:用于上传素材和生成结果转存 - `NEXT_PUBLIC_SUPABASE_URL` @@ -218,11 +243,16 @@ Supabase/Postgres 表结构在: supabase/schema.sql ``` +升级已启用 Supabase 的部署时,先在 Supabase SQL Editor 重新执行该幂等脚本。它会迁移用量快照字段、按 `job_id` 去重历史记录,并解除任务删除对用量记录的级联删除。 + 当前仍保留必要数据表,供上传、生成任务和用量记录使用: - `assets` - `generation_jobs` - `usage_events` +- `billing_price_rules` +- `billing_wallets` +- `billing_ledger` ## 任务管理与开放 API @@ -258,8 +288,6 @@ ZHINIAN_INTERNAL_WORKER_TOKEN=change-me-worker-token - `GET /api/generations/image` - `GET /api/generations/image/[id]` - `POST /api/generations/image/[id]/retry` -- `POST /api/assets/[id]/inpaint` -- `POST /api/assets/[id]/upscale` - `POST /api/generations/video` - `GET /api/generations/video` - `GET /api/generations/video/[id]` @@ -276,7 +304,8 @@ ZHINIAN_INTERNAL_WORKER_TOKEN=change-me-worker-token 当前已覆盖: -- 即梦能力矩阵:4.6/inpainting/upscale 启用 +- 即梦能力矩阵:图片生成启用 +- 组织钱包、计费规则、管理员上账和扣费幂等流水 - 即梦请求参数构造 - 分镜提示词与 `@素材` 引用编排 - 火山 Visual 签名 canonical request diff --git a/README.zh-CN.md b/README.zh-CN.md index f09b2b6..2ad0ef9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,6 +1,6 @@ # 智念AIGC平台中文说明 -智念AIGC平台是一个面向图片与视频创作的 Web 工作台。当前版本聚焦核心生产链路:提示词创作、素材上传、图片生成、视频生成、局部重绘、智能超清、历史资产管理和接口配置。 +智念AIGC平台是一个面向图片与视频创作的 Web 工作台。当前版本聚焦核心生产链路:提示词创作、素材上传、图片生成、视频生成、任务详情与结果下载和接口配置。 ## 运维与对接文档 @@ -11,12 +11,14 @@ ## 功能概览 - 统一创作入口:`/create` -- 结果资产管理:`/assets` +- 任务管理:在 `/create` 右侧任务模块查看详情、提示词、输入要素和生成结果 - 服务与引擎配置:`/settings`,管理员可见 - 后台日志管理:`/logs`,管理员可见 -- 账号管理:`/accounts`,管理员可见 +- 账户与成员:`/accounts`,登录用户可修改自己的密码,管理员额外管理组织和成员 +- 个人用量:点击页头账号 ID 查看真实任务次数 +- 用量管理:`/usage`,管理员可见 +- 计费中心:`/billing`,所有组织成员可见,超级管理员负责规则、组织余额与直接上账 - 图片生成:即梦图片生成 4.6 或 EvoLink GPT Image 2 -- 图片编辑:局部重绘、智能超清 - 视频生成:Seedance 2.0 - 素材引用:上传后可在提示词中使用 `@图片1`、`@视频1`、`@音频1` - 本地开发兜底:未配置真实接口时,可使用 mock 流程完成产品验收 @@ -135,63 +137,78 @@ npm run info | 路由 | 用途 | |------|------| | `/` | 自动跳转到 `/create` | -| `/create` | 统一创作入口 | +| `/create` | 统一创作入口、任务列表、任务详情和结果下载 | | `/create?mode=video` | 视频生成模式 | -| `/create?mode=inpaint` | 局部重绘模式 | -| `/create?mode=upscale` | 智能超清模式 | -| `/assets` | 历史任务与资产 | | `/logs` | 后台日志管理 | | `/settings` | 接口、引擎和服务配置 | -| `/accounts` | 组织成员账号管理 | +| `/accounts` | 账户安全、组织和成员账号管理 | +| `/usage` | 平台账号与组织用量管理(管理员) | +| `/billing` | 组织余额、成员消耗、账务流水与组织上账 | -## 账户登录 / SSO +## 平台账号体系 -发布环境默认启用账户登录保护。Web 端支持 OAuth2 Authorization Code 和账号密码验证码两种登录方式:授权码模式会跳转认证中心,平台后端在 `/api/auth/callback` 用授权码换 token;账号密码方式会在本项目登录页提交账号、密码和图形验证码,由后端调用 `${AUTH_BASE}/oauth2/token` 的 password grant。两种方式都会通过 JWKS 本地验签 JWT,然后写入 HttpOnly 会话 cookie。 +平台不再依赖外部 OAuth2/SSO。所有浏览器用户统一使用手机号和密码登录,账号数据由平台自己管理:生产环境使用 Supabase/Postgres,本地开发使用 `.runtime/data/platform-accounts.json`。 -认证中心客户端需要配置回调地址: - -```text -https://你的域名/api/auth/callback -``` +角色分为超级管理员、组织管理员和普通用户。组织管理员只能管理本组织普通用户和查看组织汇总用量,不能查看日志、系统配置或管理组织生命周期;普通用户只能访问自己的创作、素材、任务和账户安全。 核心配置: | 变量 | 说明 | |------|------| | `ZHINIAN_AUTH_REQUIRED` | `auto` 默认策略;生产启用,本地可信开发可设 `0` | -| `ZHINIAN_AUTH_BASE_URL` | 认证服务网关地址,例如 `https:///auth` | -| `ZHINIAN_AUTH_CLIENT_ID` | 普通账号 OAuth2 客户端 ID,默认 `custom` | -| `ZHINIAN_AUTH_CLIENT_SECRET` | 普通账号 OAuth2 客户端密钥,只能保存在服务端 | -| `ZHINIAN_ADMIN_AUTH_CLIENT_ID` | 管理员登录入口 OAuth2 客户端 ID,默认 `app` | -| `ZHINIAN_ADMIN_AUTH_CLIENT_SECRET` | 管理员登录入口 OAuth2 客户端密钥,只能保存在服务端 | -| `ZHINIAN_AUTH_TENANT_ID` | 普通账号 password grant 的 `tenantId`;为空时复用 `ZHINIAN_ORG_TENANT_ID` | -| `ZHINIAN_ADMIN_AUTH_TENANT_ID` | 管理员 password grant 的 `tenantId`,通常留空 | -| `ZHINIAN_AUTH_SCOPE` | 默认 `server` | -| `ZHINIAN_AUTH_ISSUER` | JWT issuer,默认 `https://pig4cloud.com` | -| `ZHINIAN_AUTH_PASSWORD_ENC_KEY` | 按认证中心 `security.encode-key` 对 password grant 的密码做 AES-CFB 加密,默认示例 `thanks,pig4cloud` | -| `ZHINIAN_AUTH_SESSION_SECRET` | 本地会话签名密钥,使用长随机字符串 | -| `ZHINIAN_ADMIN_AUTHORITIES` | 管理员权限码白名单,逗号分隔 | -| `ZHINIAN_ADMIN_USERS` | 管理员账号白名单,逗号分隔,默认 `ceshiop` | +| `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_DIR` | 本地账号 JSON 数据目录,可选 | -受保护范围包括 `/create`、`/assets`、`/settings`、`/logs`、`/accounts`、第一方生成/资产 API,以及本地 `/uploads/*` 和 `/generated-results/*` 文件。普通用户主导航只显示创作和结果;`/logs`、`/settings`、`/accounts` 和 `/api/admin/*` 需要管理员权限。开放 `/api/v1/*` 仍使用 API Key,Worker 仍使用内部 token,不走浏览器 SSO。 +首次部署时执行一次: -如果认证中心客户端没有加入 `security.ignore-clients`,`/oauth2/token` 可能返回“验证码不能为空”。普通账号登录默认使用 `custom/custom`;登录页里的“管理员登录”入口使用 `app/app`。两组 client 都需要认证中心允许 password grant。 +```bash +npm run bootstrap:admin -- --phone 13800138000 --password '请替换为强密码' --name '平台超级管理员' +``` + +管理员创建账号时设置初始密码;所有登录用户可以在“账号”中自行修改密码。登录失败 5 次会锁定 15 分钟,同时启用 IP 限流。停用账号不能登录,彻底删除账号时用量记录保留,素材和任务会转入组织归档账号。 + +旧账号迁移使用 `npm run migrate:accounts -- path/to/legacy-accounts.json`。迁移文件需要提供旧 owner ID、手机号、显示名称、所属组织和管理员设置的新密码;迁移会更新历史素材、任务、项目、模板和用量的账号归属,并写入映射记录。 + +开放 `/api/v1/*` 仍使用 API Key,Worker 仍使用内部 token,不走浏览器账号登录。 ## 组织账号管理 -后台账号管理通过组织模块接口维护成员、角色、部门绑定和成员状态,并通过企业端用户接口创建用户、重置密码。按运维提供的组织能力接口文档,需要在服务端配置: +组织、账号、角色、停用、密码重置和归档均由平台本地接口处理,不再调用外部组织服务。生产部署前请先在 Supabase SQL Editor 执行幂等脚本 [`supabase/schema.sql`](./supabase/schema.sql)。 -| 变量 | 说明 | -|------|------| -| `ZHINIAN_ORG_API_BASE_URL` | 组织能力网关或统一服务前缀,例如 `https:///hotelStaff` | -| `ZHINIAN_ORG_API_TOKEN` | 备用 Bearer Token;默认优先转发当前登录管理员的 `access_token` | -| `ZHINIAN_STAFF_API_BASE_URL` | 企业端用户服务网关或统一服务前缀,例如 `https:///hotelStaff` | -| `ZHINIAN_STAFF_API_TOKEN` | 备用 Bearer Token,可为空,默认优先转发当前登录管理员的 `access_token` | -| `ZHINIAN_ORG_TENANT_ID` | 需要多租户 Header 时填写 | -| `ZHINIAN_ORG_ID` | 默认组织 ID,可为空,系统会优先使用组织列表第一项 | -| `ZHINIAN_ORG_MEMBER_LIST_PATH` | 可选,成员查询路径覆盖;默认 `/adminOrganization/organizationMember/organizationMemberList` | +## 账号、组织用量与计费 -成员分页列表通过 `hotelStaff` 的 `/adminOrganization/organizationMember/organizationMemberList` 对外代理;只有直连基础组织服务内部 `/organizationMember/organizationMemberList` 时才需要 `from: Y`。创建账号会优先调用 `/adminOrganization/organizationMember/addOrganizationMemberAndCreatePlatformUser`,密码重置调用 `/adminPcUser/resetPlatformUserPassword`;这些接口默认使用当前登录账号的 token,要求该账号具备管理员角色 `1`。 +- 仅统计平台内登录用户调用真实服务商后成功完成的任务。 +- 成功任务会进入用量记录;一次返回多张图片的计费数量以超级管理员配置的标准计费单位为准。 +- 失败、取消、过期、Mock 和开放 API 客户端任务不计入。 +- 任务创建时会快照账号、租户和组织归属;无法匹配的记录进入“未归属组织”。 +- 用户删除任务、素材或账号不会删除计量记录;用量明细不保存或展示提示词、素材与生成结果。 +- 统计日和自然月统一使用 `Asia/Shanghai`。 + +普通用户点击页头账号 ID,可切换今天、近 7 天、近 30 天和本月,并查看最近 5 条记录。管理员通过 `/usage` 按日期、组织、账号、功能类型和服务商查看汇总、趋势及明细;所有组织成员可在 `/billing` 查看组织余额和自己的净消耗。 + +超级管理员在 `/billing` 配置各接口来源的标准单价与上浮倍率。真实生成任务按“标准计费单位 × 数量 × 上浮倍率”在提交时从组织余额冻结,并在任务中保存计费快照;失败、取消、过期任务会在最终终态退款。普通用户余额不足时会被拒绝提交并提示“余额不足,请先充值”,不会提交服务商;超级管理员仍计算并记录生成费用,但不检查或扣减组织额度,也不产生钱包扣费、退款流水。Seedance 成功后按上游返回的 `usage.completion_tokens` 重新结算,多退少补;上游没有返回用量时保留冻结金额。当前充值和人工余额调整由超级管理员在“余额与上账”中直接记入组织额度,不设置个人上账归属;未来接入用户自主支付时,支付成功回调将自动入账,不进入人工审核队列。未绑定组织的开放 API 任务暂保持兼容,不纳入组织余额扣费。 + +首次打开超管计费中心或提交真实任务时,系统会自动补齐以下平台标准成本目录(不会覆盖已有目录)。默认上浮倍率为 `1.2×`,最终用户价按整数分计算并向上取整: + +| 渠道/模型 | 变体 | 基础价 | 计费单位 | +| --- | --- | ---: | --- | +| 百炼 `wan2.7-image-pro` | — | ¥0.50 | 每张 | +| 百炼 `wan2.7-i2v-2026-04-25` | 720P | ¥0.60 | 每秒 | +| 百炼 `wan2.7-i2v-2026-04-25` | 1080P | ¥1.00 | 每秒 | +| 火山方舟 `doubao-seedance-2-0-260128` | 480P | ¥0.46 | 每秒 | +| 火山方舟 `doubao-seedance-2-0-260128` | 720P | ¥0.99 | 每秒 | +| 火山方舟 `doubao-seedance-2-0-260128` | 1080P | ¥2.48 | 每秒 | +| 火山方舟 `doubao-seedance-2-0-260128` | 4K | ¥5.05 | 每秒 | +| EvoLink `gpt-image-2` | medium / 1K / 1:1 / 无参考图基础估算 | ¥0.34 | 每张 | +| 即梦 `jimeng_seedream46_cvtob` | 公开资源包折算参考 | ¥0.20 | 每张 | + +其中 EvoLink 按固定 `1 USD = 7.20 CNY` 换算,并在价格目录中列出质量、分辨率、画面比例和参考图数量档位;参数化报价按基础成本乘以所选档位系数,组合倍率取所选档位中的最高倍率。即梦 4.6 官方计费说明要求以控制台实时价格为准,因此该条目是平台维护的参考基准。超管只在价格目录中调整上浮倍率,标准成本、参数档案和规则状态由平台维护;来源链接和定价口径会随规则保留。 + +使用 Supabase/Postgres 时,升级前必须在 Supabase SQL Editor 重新执行 [`supabase/schema.sql`](./supabase/schema.sql)。脚本是幂等的,会为任务补充用量快照字段、将历史用量按 `job_id` 去重,并解除删除任务时对用量记录的级联删除。未配置 Supabase 时,本地 JSON 数据会在读取时按相同口径兼容旧记录。 ## 引擎说明 @@ -203,11 +220,6 @@ https://你的域名/api/auth/callback - `evolink`:EvoLink GPT Image 2 中转接口 - `bailian`:阿里云百炼万相 2.7,支持文生图、最多 9 张参考图生图,以及 1–2 张首尾帧图生视频 -### 图片编辑 - -- 局部重绘:即梦 inpainting 或 EvoLink inpaint -- 智能超清:即梦超清能力 - ### 视频生成 视频生成使用 Seedance 2.0。 @@ -217,7 +229,7 @@ https://你的域名/api/auth/callback - `duration`:`4` 到 `15` 的整数秒 - `duration=-1`:允许在环境变量或服务端归一化中表示模型自动选择 - `ratio`:`16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive` -- `resolution`:`480p`、`720p`、`1080p` +- `resolution`:`480p`、`720p`、`1080p`、`4k` - Seedance 2.0 fast 不支持 `1080p` ## 任务管理与开放 API @@ -247,7 +259,7 @@ curl -X POST http://127.0.0.1:3000/api/v1/jobs \ | 接口 | 说明 | |------|------| -| `GET /api/v1/capabilities` | 查询图片、修图、超清、视频能力 | +| `GET /api/v1/capabilities` | 查询图片生成、视频生成能力 | | `POST /api/v1/assets` | 上传文件或注册外部素材 URL | | `GET /api/v1/assets` | 查询素材 | | `POST /api/v1/jobs` | 创建生成任务 | @@ -283,7 +295,7 @@ cp .env.example .env.local | `ZHINIAN_AUTH_TENANT_ID` | 普通账号 password grant 租户 ID;为空时复用 `ZHINIAN_ORG_TENANT_ID` | | `ZHINIAN_ADMIN_AUTH_TENANT_ID` | 管理员 password grant 租户 ID,通常留空 | | `ZHINIAN_AUTH_SESSION_SECRET` | 本地登录态签名密钥 | -| `ZHINIAN_ADMIN_AUTHORITIES` | 管理员权限码白名单 | +| `ZHINIAN_ADMIN_AUTHORITIES` | 专用管理员角色精确白名单 | | `ZHINIAN_ADMIN_USERS` | 管理员账号白名单,默认 `ceshiop` | | `ZHINIAN_ORG_API_BASE_URL` | 组织账号接口 Base URL | | `ZHINIAN_ORG_API_TOKEN` | 组织账号接口备用 Bearer Token | @@ -296,7 +308,6 @@ cp .env.example .env.local | `ZHINIAN_WEBHOOK_SECRET` | Webhook 签名密钥,可选 | | `ZHINIAN_WORKER_*` | Worker 间隔、批量、锁超时、重试配置 | | `IMAGE_GENERATE_ENGINE` | 图片生成引擎:`jimeng` 或 `evolink` | -| `IMAGE_INPAINT_ENGINE` | 局部重绘引擎:`jimeng` 或 `evolink` | | `BAILIAN_API_KEY` | 阿里云百炼 API Key | | `BAILIAN_BASE_URL` | 百炼业务空间兼容地址;系统自动派生原生异步接口 | | `BAILIAN_IMAGE_MODEL` | 图片模型,默认 `wan2.7-image-pro` | diff --git a/app/accounts/page.tsx b/app/accounts/page.tsx index 80ab80e..bc621df 100644 --- a/app/accounts/page.tsx +++ b/app/accounts/page.tsx @@ -1,9 +1,10 @@ import { AccountManager } from "@/components/account-manager"; -import { requireAdminUser } from "@/lib/server/auth/current-user"; +import { hasAdminSessionAccess } from "@/lib/auth/permissions"; +import { requireAppSession } from "@/lib/server/auth/current-user"; export const dynamic = "force-dynamic"; export default async function AccountsPage() { - await requireAdminUser(); - return ; + const session = await requireAppSession(); + return ; } diff --git a/app/api/admin/accounts/groups/route.ts b/app/api/admin/accounts/groups/route.ts index c4f66e9..bfe0236 100644 --- a/app/api/admin/accounts/groups/route.ts +++ b/app/api/admin/accounts/groups/route.ts @@ -1,51 +1,7 @@ -import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; -import { requireAdminSession } from "@/lib/server/auth/current-user"; -import { - createOrganizationGroup, - getOrganizationApiConfig, - listOrganizationGroups -} from "@/lib/server/organization-client"; +import { jsonError } from "@/lib/server/api"; export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; -export async function POST(request: Request) { - try { - const session = await requireAdminSession(); - const context = { accessToken: session.accessToken }; - const config = getOrganizationApiConfig(context.accessToken); - if (!config.configured) { - throw Object.assign(new Error(`组织接口配置不完整:${config.missing.join(", ")}`), { status: 503 }); - } - const body = await readJsonBody>(request); - const organizationId = requiredString(body, "organizationId", "组织"); - const groupName = requiredString(body, "groupName", "部门名称"); - await createOrganizationGroup({ - organizationId, - groupName, - groupDesc: optionalString(body.groupDesc), - parentId: optionalString(body.parentId) - }, context); - const groups = await listOrganizationGroups(organizationId, context); - const group = groups.find((item) => item.groupName === groupName) || null; - return jsonOk({ ok: true, group, groups }); - } catch (error) { - return jsonError(error, 500, { request, source: "api.admin.accounts.groups", logClientErrors: true }); - } -} - -function requiredString(body: Record, key: string, label: string): string { - const value = optionalString(body[key]); - if (!value) throw badRequest(`${label}不能为空。`); - return value; -} - -function optionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -function badRequest(message: string): Error & { status: number } { - const error = new Error(message) as Error & { status: number }; - error.status = 400; - return error; +export async function POST() { + return jsonError(Object.assign(new Error("平台账号体系不再使用外部部门接口,请直接管理组织。"), { status: 410 }), 410); } diff --git a/app/api/admin/accounts/password/route.ts b/app/api/admin/accounts/password/route.ts index 2f9a744..163267e 100644 --- a/app/api/admin/accounts/password/route.ts +++ b/app/api/admin/accounts/password/route.ts @@ -1,6 +1,7 @@ import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; import { requireAdminSession } from "@/lib/server/auth/current-user"; -import { getOrganizationApiConfig, resetPlatformUserPassword } from "@/lib/server/organization-client"; +import { AccountStoreError, getPlatformUserById, updatePlatformUser } from "@/lib/server/account-store"; +import { hasSuperAdminAccess } from "@/lib/auth/permissions"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -8,53 +9,23 @@ export const dynamic = "force-dynamic"; export async function POST(request: Request) { try { const session = await requireAdminSession(); - const context = { accessToken: session.accessToken }; - const config = getOrganizationApiConfig(context.accessToken); - if (!config.staffConfigured) { - throw Object.assign(new Error(`企业端用户接口配置不完整:${config.staffMissing.join(", ")}`), { status: 503 }); - } const body = await readJsonBody>(request); - await resetPlatformUserPassword({ - tenantId: tenantIdNumber(config.tenantId), - userId: requiredNumber(body.userId, "用户 ID"), - newPassword: requiredString(body, "newPassword", "新密码"), - mustChangePassword: booleanValue(body.mustChangePassword, true) - }, context); - return jsonOk({ ok: true }); + const userId = requiredString(body.userId, "账号 ID"); + const target = await getPlatformUserById(userId, { includeDisabled: true }); + if (!target) throw new AccountStoreError("账号不存在。", 404); + if (!hasSuperAdminAccess(session.user) && (session.user.role !== "organization_admin" || target.role !== "user" || target.organizationId !== session.user.organizationId)) { + throw new AccountStoreError("组织管理员只能重置本组织普通用户密码。", 403); + } + const newPassword = requiredString(body.newPassword, "新密码"); + if (newPassword.length < 8) throw new AccountStoreError("新密码至少需要 8 位。", 400); + const user = await updatePlatformUser(userId, { password: newPassword, clearLoginLock: true }); + return jsonOk({ ok: true, userId: user.id }); } catch (error) { return jsonError(error, 500, { request, source: "api.admin.accounts.password", logClientErrors: true }); } } -function requiredString(body: Record, key: string, label: string): string { - const value = body[key]; +function requiredString(value: unknown, label: string): string { if (typeof value === "string" && value.trim()) return value.trim(); - throw badRequest(`${label}不能为空。`); -} - -function requiredNumber(value: unknown, label: string): number { - const parsed = typeof value === "number" ? value : Number(value); - if (Number.isFinite(parsed)) return parsed; - throw badRequest(`${label}必须是数字。`); -} - -function tenantIdNumber(value: string): number { - const tenantId = Number(value); - if (!Number.isFinite(tenantId)) throw badRequest("租户 ID 必须是数字。"); - return tenantId; -} - -function booleanValue(value: unknown, fallback: boolean): boolean { - if (typeof value === "boolean") return value; - if (typeof value === "string") { - if (value === "true") return true; - if (value === "false") return false; - } - return fallback; -} - -function badRequest(message: string): Error & { status: number } { - const error = new Error(message) as Error & { status: number }; - error.status = 400; - return error; + throw new AccountStoreError(`${label}不能为空。`, 400); } diff --git a/app/api/admin/accounts/route.ts b/app/api/admin/accounts/route.ts index aca27c8..f927af6 100644 --- a/app/api/admin/accounts/route.ts +++ b/app/api/admin/accounts/route.ts @@ -1,19 +1,18 @@ import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; import { requireAdminSession } from "@/lib/server/auth/current-user"; import { - addOrganizationMemberAndCreatePlatformUser, - emptyPage, - getOrganizationApiConfig, - isOrganizationPermissionDenied, - isOrganizationRouteNotFound, - listOrganizationGroups, - listOrganizationMembers, - listOrganizationRoles, - listOrganizations, - modifyOrganizationMemberStatus, - removeOrganizationMember, - updateOrganizationMember -} from "@/lib/server/organization-client"; + AccountStoreError, + createPlatformUser, + deletePlatformUser, + getPlatformUserById, + listPlatformOrganizations, + listPlatformUsers, + updatePlatformUser, + type PlatformUserFilters +} from "@/lib/server/account-store"; +import { hasSuperAdminAccess } from "@/lib/auth/permissions"; +import type { AuthUser } from "@/lib/auth/session"; +import type { PlatformRole } from "@/lib/types"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -21,92 +20,24 @@ export const dynamic = "force-dynamic"; export async function GET(request: Request) { try { const session = await requireAdminSession(); - const context = { accessToken: session.accessToken }; - const config = getOrganizationApiConfig(context.accessToken); const url = new URL(request.url); - const pageNum = positiveInteger(url.searchParams.get("pageNum"), 1); - const pageSize = positiveInteger(url.searchParams.get("pageSize"), 10, 50); - - if (!config.configured) { - return jsonOk({ - configured: false, - missing: config.missing, - staffConfigured: config.staffConfigured, - staffMissing: config.staffMissing, - selectedOrganizationId: config.defaultOrganizationId, - organizations: [], - groups: [], - roles: [], - members: emptyPage(pageNum, pageSize), - warnings: [], - memberListAvailable: false, - passwordManagementAvailable: config.staffConfigured - }); - } - - const warnings: string[] = []; - let organizations: Awaited> = []; - try { - organizations = await listOrganizations(context); - } catch (error) { - if (!isOrganizationRouteNotFound(error) || !config.defaultOrganizationId) throw error; - warnings.push(error.message); - organizations = [{ - organizationId: config.defaultOrganizationId, - organizationName: config.defaultOrganizationId - }]; - } - const selectedOrganizationId = url.searchParams.get("organizationId")?.trim() || - config.defaultOrganizationId || - organizations[0]?.organizationId || - ""; - - if (!selectedOrganizationId) { - return jsonOk({ - configured: true, - missing: [], - staffConfigured: config.staffConfigured, - staffMissing: config.staffMissing, - selectedOrganizationId: "", - organizations, - groups: [], - roles: [], - members: emptyPage(pageNum, pageSize), - warnings: uniqueWarnings(warnings), - memberListAvailable: false, - passwordManagementAvailable: config.staffConfigured - }); - } - - const [groupsResult, rolesResult, membersResult] = await Promise.allSettled([ - listOrganizationGroups(selectedOrganizationId, context), - listOrganizationRoles(selectedOrganizationId, context), - listOrganizationMembers({ - organizationId: selectedOrganizationId, - pageNum, - pageSize, - memberName: url.searchParams.get("memberName") || undefined, - memberStatus: statusFilter(url.searchParams.get("memberStatus")) - }, context) + const isSuperAdmin = hasSuperAdminAccess(session.user); + const organizationId = isSuperAdmin + ? optionalString(url.searchParams.get("organizationId")) + : requiredOrganizationId(session.user.organizationId); + const filters: PlatformUserFilters = { organizationId, includeDisabled: true, role: isSuperAdmin ? undefined : "user" }; + const [members, organizations] = await Promise.all([ + listPlatformUsers(filters), + listPlatformOrganizations({ includeDisabled: isSuperAdmin }) ]); - const groups = routeFallback(groupsResult, warnings, []); - const roles = routeFallback(rolesResult, warnings, []); - const members = memberListFallback(membersResult, warnings, emptyPage(pageNum, pageSize)); - const memberListAvailable = membersResult.status === "fulfilled"; - return jsonOk({ configured: true, - missing: [], - staffConfigured: config.staffConfigured, - staffMissing: config.staffMissing, - selectedOrganizationId, - organizations, - groups, - roles, - members, - warnings: uniqueWarnings(warnings), - memberListAvailable, - passwordManagementAvailable: config.staffConfigured + currentOrganizationId: organizationId || null, + organizations: isSuperAdmin ? organizations.map(publicOrganization) : organizations.filter((item) => item.id === organizationId).map(publicOrganization), + members: members.map(publicUser), + canManageOrganizations: isSuperAdmin, + canAssignOrganizationAdmin: isSuperAdmin, + canCreateSuperAdmin: isSuperAdmin }); } catch (error) { return jsonError(error, 500, { request, source: "api.admin.accounts", logClientErrors: true }); @@ -116,29 +47,24 @@ export async function GET(request: Request) { export async function POST(request: Request) { try { const session = await requireAdminSession(); - const context = { accessToken: session.accessToken }; - const config = getOrganizationApiConfig(context.accessToken); - if (!config.staffConfigured) { - throw Object.assign(new Error(`企业端用户接口配置不完整:${config.staffMissing.join(", ")}`), { status: 503 }); - } const body = await readJsonBody>(request); - const memberName = requiredString(body, "memberName", "成员名称"); - const memberPhone = requiredString(body, "memberPhone", "手机号"); - const user = await addOrganizationMemberAndCreatePlatformUser({ - tenantId: tenantIdNumber(config.tenantId), - username: optionalString(body.username), - phone: optionalString(body.phone) || memberPhone, - name: optionalString(body.name) || memberName, - nickname: optionalString(body.nickname) || optionalString(body.name) || memberName, - initialPassword: optionalString(body.initialPassword), - mustChangePassword: booleanValue(body.mustChangePassword, true), - memberName, - memberPhone, - roleId: requiredString(body, "roleId", "角色"), - organizationId: requiredString(body, "organizationId", "组织"), - groupId: requiredString(body, "groupId", "部门") - }, context); - return jsonOk({ ok: true, user }); + const isSuperAdmin = hasSuperAdminAccess(session.user); + const role = parseRole(body.role, isSuperAdmin ? "user" : "user"); + if (!isSuperAdmin && role !== "user") throw new AccountStoreError("组织管理员只能创建普通用户。", 403); + const organizationId = role === "super_admin" + ? optionalString(body.organizationId) + : isSuperAdmin + ? requiredString(body.organizationId, "组织") + : requiredOrganizationId(session.user.organizationId); + const user = await createPlatformUser({ + phone: requiredString(body.phone, "手机号"), + displayName: requiredString(body.displayName, "显示名称"), + password: requiredString(body.password, "初始密码"), + role, + organizationId, + legacySubject: optionalString(body.legacySubject) + }); + return jsonOk({ ok: true, user: publicUser(user) }, { status: 201 }); } catch (error) { return jsonError(error, 500, { request, source: "api.admin.accounts", logClientErrors: true }); } @@ -147,19 +73,25 @@ export async function POST(request: Request) { export async function PATCH(request: Request) { try { const session = await requireAdminSession(); - const context = { accessToken: session.accessToken }; const body = await readJsonBody>(request); - const update = { - memberId: requiredString(body, "memberId", "成员 ID"), - memberName: optionalString(body.memberName), - roleId: optionalString(body.roleId), - groupId: optionalString(body.groupId) - }; - if (!update.memberName && !update.roleId && !update.groupId) { - throw badRequest("至少需要修改一个成员字段。"); + const target = await getTarget(body); + assertCanManageTarget(session.user, target, "修改"); + const isSuperAdmin = hasSuperAdminAccess(session.user); + const role = body.role === undefined ? undefined : parseRole(body.role, target.role); + if (!isSuperAdmin && role !== undefined && role !== target.role) { + throw new AccountStoreError("组织管理员不能修改账号角色。", 403); } - await updateOrganizationMember(update, context); - return jsonOk({ ok: true }); + if (!isSuperAdmin && body.organizationId !== undefined) { + throw new AccountStoreError("组织管理员不能修改账号归属。", 403); + } + const user = await updatePlatformUser(target.id, { + displayName: optionalString(body.displayName), + role, + organizationId: isSuperAdmin && body.organizationId !== undefined ? optionalString(body.organizationId) : undefined, + status: parseStatus(body.status), + clearLoginLock: body.clearLoginLock === true + }); + return jsonOk({ ok: true, user: publicUser(user) }); } catch (error) { return jsonError(error, 500, { request, source: "api.admin.accounts", logClientErrors: true }); } @@ -168,13 +100,14 @@ export async function PATCH(request: Request) { export async function PUT(request: Request) { try { const session = await requireAdminSession(); - const context = { accessToken: session.accessToken }; const body = await readJsonBody>(request); - await modifyOrganizationMemberStatus({ - memberId: requiredString(body, "memberId", "成员 ID"), - memberStatus: memberStatus(requiredString(body, "memberStatus", "成员状态")) - }, context); - return jsonOk({ ok: true }); + const target = await getTarget(body); + assertCanManageTarget(session.user, target, "变更状态"); + const status = parseStatus(requiredString(body.status, "账号状态")); + if (!status) throw new AccountStoreError("账号状态只能是 active 或 disabled。", 400); + if (target.id === session.user.id && status === "disabled") throw new AccountStoreError("不能停用当前登录账号。", 400); + const user = await updatePlatformUser(target.id, { status, clearLoginLock: status === "active" }); + return jsonOk({ ok: true, user: publicUser(user) }); } catch (error) { return jsonError(error, 500, { request, source: "api.admin.accounts", logClientErrors: true }); } @@ -183,84 +116,72 @@ export async function PUT(request: Request) { export async function DELETE(request: Request) { try { const session = await requireAdminSession(); - const context = { accessToken: session.accessToken }; const body = await readJsonBody>(request); - await removeOrganizationMember(requiredString(body, "memberId", "成员 ID"), context); - return jsonOk({ ok: true }); + const target = await getTarget(body); + assertCanManageTarget(session.user, target, "删除"); + if (target.id === session.user.id) throw new AccountStoreError("不能删除当前登录账号。", 400); + await deletePlatformUser(target.id); + return jsonOk({ ok: true, archivedOwnerId: target.organizationId ? `archive:${target.organizationId}` : "archive:global" }); } catch (error) { return jsonError(error, 500, { request, source: "api.admin.accounts", logClientErrors: true }); } } -function requiredString(body: Record, key: string, label: string): string { - const value = optionalString(body[key]); - if (!value) throw badRequest(`${label}不能为空。`); +async function getTarget(body: Record) { + const id = requiredString(body.userId, "账号 ID"); + const user = await getPlatformUserById(id, { includeDisabled: true }); + if (!user) throw new AccountStoreError("账号不存在。", 404); + return user; +} + +function assertCanManageTarget(actor: AuthUser, target: { id: string; role: PlatformRole; organizationId?: string }, action: string) { + if (hasSuperAdminAccess(actor)) return; + if (actor.role !== "organization_admin" || target.role !== "user" || actor.organizationId !== target.organizationId) { + throw new AccountStoreError(`组织管理员不能${action}该账号。`, 403); + } +} + +function publicOrganization(organization: { id: string; name: string; status: string }) { + return { id: organization.id, name: organization.name, status: organization.status }; +} + +function publicUser(user: { id: string; phone: string; displayName: string; role: PlatformRole; organizationId?: string; status: string; createdAt: string; lastLoginAt?: string; lockedUntil?: string }) { + return { + id: user.id, + phone: user.phone, + displayName: user.displayName, + role: user.role, + organizationId: user.organizationId || null, + status: user.status, + createdAt: user.createdAt, + lastLoginAt: user.lastLoginAt || null, + lockedUntil: user.lockedUntil || null + }; +} + +function parseRole(value: unknown, fallback: PlatformRole): PlatformRole { + if (value === undefined || value === null || value === "") return fallback; + if (value === "super_admin" || value === "organization_admin" || value === "user") return value; + throw new AccountStoreError("账号角色不正确。", 400); +} + +function parseStatus(value: unknown): "active" | "disabled" | undefined { + if (value === undefined || value === null || value === "") return undefined; + if (value === "active" || value === "disabled") return value; + throw new AccountStoreError("账号状态不正确。", 400); +} + +function requiredString(value: unknown, label: string): string { + const normalized = optionalString(value); + if (!normalized) throw new AccountStoreError(`${label}不能为空。`, 400); + return normalized; +} + +function requiredOrganizationId(value: string | undefined): string { + if (!value) throw new AccountStoreError("当前账号没有组织归属。", 403); return value; } function optionalString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } - -function memberStatus(value: string): "0" | "1" | "2" { - if (value === "0" || value === "1" || value === "2") return value; - throw badRequest("成员状态只能是 0、1、2。"); -} - -function statusFilter(value: string | null): string | undefined { - return value === "0" || value === "1" || value === "2" ? value : undefined; -} - -function routeFallback(result: PromiseSettledResult, warnings: string[], fallback: T): T { - if (result.status === "fulfilled") return result.value; - if (!isOrganizationRouteNotFound(result.reason)) throw result.reason; - warnings.push(result.reason.message); - return fallback; -} - -function memberListFallback(result: PromiseSettledResult, warnings: string[], fallback: T): T { - if (result.status === "fulfilled") return result.value; - if (!isOrganizationRouteNotFound(result.reason) && !isOrganizationPermissionDenied(result.reason)) { - throw result.reason; - } - warnings.push(memberListWarning(result.reason)); - return fallback; -} - -function memberListWarning(error: unknown): string { - if (isOrganizationPermissionDenied(error)) { - return "当前登录 token 缺少上游 hotelStaff 管理员角色,成员列表暂不可用。"; - } - return error instanceof Error ? error.message : String(error); -} - -function uniqueWarnings(warnings: string[]): string[] { - return [...new Set(warnings.filter(Boolean))]; -} - -function tenantIdNumber(value: string): number { - const tenantId = Number(value); - if (!Number.isFinite(tenantId)) throw badRequest("租户 ID 必须是数字。"); - return tenantId; -} - -function booleanValue(value: unknown, fallback: boolean): boolean { - if (typeof value === "boolean") return value; - if (typeof value === "string") { - if (value === "true") return true; - if (value === "false") return false; - } - return fallback; -} - -function positiveInteger(value: string | null, fallback: number, max = 200): number { - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed < 1) return fallback; - return Math.min(parsed, max); -} - -function badRequest(message: string): Error & { status: number } { - const error = new Error(message) as Error & { status: number }; - error.status = 400; - return error; -} diff --git a/app/api/admin/billing/account/route.ts b/app/api/admin/billing/account/route.ts new file mode 100644 index 0000000..d715752 --- /dev/null +++ b/app/api/admin/billing/account/route.ts @@ -0,0 +1,27 @@ +import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; +import { requireSuperAdminUser } from "@/lib/server/auth/current-user"; +import { getBillingAccountConfig } from "@/lib/server/billing-service"; +import { saveApiSettings } from "@/lib/server/app-settings"; + +export const runtime = "nodejs"; + +export async function PATCH(request: Request) { + try { + await requireSuperAdminUser(); + const body = await readJsonBody>(request); + const values = { + ZHINIAN_BILLING_ACCOUNT_NAME: normalize(body.accountName), + ZHINIAN_BILLING_ACCOUNT_BANK: normalize(body.bankName), + ZHINIAN_BILLING_ACCOUNT_NUMBER: normalize(body.accountNumber), + ZHINIAN_BILLING_CONTACT: normalize(body.contact) + }; + await saveApiSettings(values); + return jsonOk({ billingAccount: getBillingAccountConfig() }); + } catch (error) { + return jsonError(error, 500, { request, source: "api.admin.billing.account" }); + } +} + +function normalize(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} diff --git a/app/api/admin/billing/adjustments/route.ts b/app/api/admin/billing/adjustments/route.ts new file mode 100644 index 0000000..36dd53f --- /dev/null +++ b/app/api/admin/billing/adjustments/route.ts @@ -0,0 +1,75 @@ +import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; +import { requireSuperAdminUser } from "@/lib/server/auth/current-user"; +import { getPlatformOrganization } from "@/lib/server/account-store"; +import { postOrganizationTopUp } from "@/lib/server/billing-service"; +import { postWalletEntry } from "@/lib/server/billing-store"; +import { createId } from "@/lib/server/ids"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + try { + const operator = await requireSuperAdminUser(); + const body = await readJsonBody>(request); + const organizationId = requiredString(body.organizationId, "组织"); + const organization = await getPlatformOrganization(organizationId); + if (!organization) throw badRequest("组织不存在。"); + + const amountFen = parseAmountFen(body.amountFen, body.amountYuan); + if (amountFen <= 0) throw badRequest("请输入大于 0 的金额。"); + const direction = body.direction === "debit" ? "debit" : body.direction === "credit" ? "credit" : null; + if (!direction) throw badRequest("余额变动方向无效。"); + + const note = requiredString(body.note, "备注"); + const amountLabel = (amountFen / 100).toFixed(2); + const idempotencyKey = `manual-adjustment:${createId("entry")}`; + const metadata = { + operation: direction === "credit" ? "admin_top_up" : "manual_adjustment", + direction, + note, + operatorId: operator.id, + amountYuan: amountLabel + }; + const result = direction === "credit" + ? await postOrganizationTopUp({ + organizationId, + amountFen, + idempotencyKey, + description: `管理员上账 · ${note}`, + metadata + }) + : await postWalletEntry({ + organizationId, + kind: "adjustment", + deltaFen: -amountFen, + idempotencyKey, + description: `管理员扣减 · ${note}`, + metadata + }); + return jsonOk({ wallet: result.wallet, entry: result.entry }); + } catch (error) { + return jsonError(error, 500, { request, source: "api.admin.billing.adjustments" }); + } +} + +function parseAmountFen(amountFen: unknown, amountYuan: unknown): number { + const fen = Number(amountFen); + if (Number.isFinite(fen) && fen > 0) return Math.round(fen); + const yuan = Number(amountYuan); + if (!Number.isFinite(yuan) || yuan <= 0) return 0; + return Math.round(yuan * 100); +} + +function requiredString(value: unknown, label: string): string { + const normalized = optionalString(value); + if (!normalized) throw badRequest(`${label}不能为空。`); + return normalized; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function badRequest(message: string): Error & { status: number } { + return Object.assign(new Error(message), { status: 400 }); +} diff --git a/app/api/admin/billing/prices/[id]/route.ts b/app/api/admin/billing/prices/[id]/route.ts new file mode 100644 index 0000000..6bf9fb0 --- /dev/null +++ b/app/api/admin/billing/prices/[id]/route.ts @@ -0,0 +1,45 @@ +import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; +import { requireSuperAdminSession } from "@/lib/server/auth/current-user"; +import { getBillingPriceRule, updateBillingPriceRule, updateBillingPriceTierMultiplier } from "@/lib/server/billing-store"; + +export const runtime = "nodejs"; + +export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) { + try { + await requireSuperAdminSession(); + const { id } = await context.params; + const body = await readJsonBody>(request); + const unsupportedFields = Object.keys(body).filter((key) => !["markupMultiplier", "dimensionKey", "tierValue"].includes(key)); + if (unsupportedFields.length) throw badRequest("平台标准价格与参数由系统维护,超管仅可调整上浮倍率。"); + const multiplier = Number(body.markupMultiplier); + if (!Number.isFinite(multiplier) || multiplier < 1 || multiplier > 1000) { + throw badRequest("上浮倍率必须在 1.00 至 1000.00 之间。"); + } + const dimensionKey = typeof body.dimensionKey === "string" ? body.dimensionKey.trim() : ""; + const tierValue = typeof body.tierValue === "string" ? body.tierValue : ""; + if ((dimensionKey && !tierValue) || (!dimensionKey && tierValue)) { + throw badRequest("参数档位倍率更新必须同时提供参数维度和档位。"); + } + const existing = await getBillingPriceRule(id); + if (!existing) return jsonError("计费规则不存在", 404); + if (!dimensionKey && existing.parameterDimensions?.length) { + throw badRequest("当前服务包含参数档位,请指定要调整的参数档位倍率。"); + } + const rule = dimensionKey || tierValue + ? await updateBillingPriceTierMultiplier({ + ruleId: id, + dimensionKey, + tierValue, + markupMultiplier: Number(multiplier.toFixed(4)) + }) + : await updateBillingPriceRule(id, { markupMultiplier: Number(multiplier.toFixed(4)) }); + if (!rule) return jsonError("计费规则不存在", 404); + return jsonOk({ rule }); + } catch (error) { + return jsonError(error, 500, { request, source: "api.admin.billing.prices.updateMultiplier" }); + } +} + +function badRequest(message: string): Error & { status: number } { + return Object.assign(new Error(message), { status: 400 }); +} diff --git a/app/api/admin/billing/prices/route.ts b/app/api/admin/billing/prices/route.ts new file mode 100644 index 0000000..1e1c953 --- /dev/null +++ b/app/api/admin/billing/prices/route.ts @@ -0,0 +1,16 @@ +import { jsonError, jsonOk } from "@/lib/server/api"; +import { requireSuperAdminSession } from "@/lib/server/auth/current-user"; +import { listBillingPriceRules } from "@/lib/server/billing-store"; +import { ensureDefaultBillingPriceRules } from "@/lib/server/billing-catalog"; + +export const runtime = "nodejs"; + +export async function GET() { + try { + await requireSuperAdminSession(); + await ensureDefaultBillingPriceRules(); + return jsonOk({ priceRules: await listBillingPriceRules({ includeDisabled: true }) }); + } catch (error) { + return jsonError(error, 500, { source: "api.admin.billing.prices" }); + } +} diff --git a/app/api/admin/billing/route.ts b/app/api/admin/billing/route.ts new file mode 100644 index 0000000..edc8cd1 --- /dev/null +++ b/app/api/admin/billing/route.ts @@ -0,0 +1,49 @@ +import { jsonError, jsonOk } from "@/lib/server/api"; +import { requireSuperAdminSession } from "@/lib/server/auth/current-user"; +import { listPlatformOrganizations, listPlatformUsers } from "@/lib/server/account-store"; +import { listBillingLedgerEntries, listBillingPriceRules, listOrganizationWallets } from "@/lib/server/billing-store"; +import { ensureDefaultBillingPriceRules } from "@/lib/server/billing-catalog"; +import { getBillingAccountConfig } from "@/lib/server/billing-service"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + await requireSuperAdminSession(); + await ensureDefaultBillingPriceRules(); + const [organizations, wallets, members, ledger, priceRules] = await Promise.all([ + listPlatformOrganizations({ includeDisabled: true }), + listOrganizationWallets(), + listPlatformUsers({ includeDisabled: true }), + listBillingLedgerEntries({ limit: 500 }), + listBillingPriceRules({ includeDisabled: true }) + ]); + const walletByOrganization = new Map(wallets.map((wallet) => [wallet.organizationId, wallet])); + return jsonOk({ + billingAccount: getBillingAccountConfig(), + organizations: organizations.map((organization) => ({ + ...organization, + wallet: walletByOrganization.get(organization.id) || { + organizationId: organization.id, + balanceFen: 0, + totalRechargedFen: 0, + totalChargedFen: 0, + updatedAt: organization.updatedAt + } + })), + members: members.map((member) => ({ + id: member.id, + displayName: member.displayName, + phone: member.phone, + role: member.role, + organizationId: member.organizationId || null, + status: member.status + })), + ledger, + priceRules + }); + } catch (error) { + return jsonError(error, 500, { source: "api.admin.billing" }); + } +} diff --git a/app/api/admin/organizations/route.ts b/app/api/admin/organizations/route.ts new file mode 100644 index 0000000..59a718a --- /dev/null +++ b/app/api/admin/organizations/route.ts @@ -0,0 +1,89 @@ +import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; +import { requireAdminSession } from "@/lib/server/auth/current-user"; +import { + AccountStoreError, + createPlatformOrganization, + deletePlatformOrganization, + listPlatformOrganizations, + updatePlatformOrganization +} from "@/lib/server/account-store"; +import { hasSuperAdminAccess } from "@/lib/auth/permissions"; +import type { AuthUser } from "@/lib/auth/session"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const session = await requireAdminSession(); + const organizations = await listPlatformOrganizations({ includeDisabled: hasSuperAdminAccess(session.user) }); + return jsonOk({ organizations: organizations + .filter((organization) => hasSuperAdminAccess(session.user) || organization.id === session.user.organizationId) + .map((organization) => ({ id: organization.id, name: organization.name, status: organization.status })) }); + } catch (error) { + return jsonError(error, 500, { source: "api.admin.organizations" }); + } +} + +export async function POST(request: Request) { + try { + const session = await requireAdminSession(); + requireSuperAdmin(session.user); + const body = await readJsonBody>(request); + const organization = await createPlatformOrganization(requiredString(body.name, "组织名称")); + return jsonOk({ ok: true, organization: publicOrganization(organization) }, { status: 201 }); + } catch (error) { + return jsonError(error, 500, { request, source: "api.admin.organizations", logClientErrors: true }); + } +} + +export async function PATCH(request: Request) { + try { + const session = await requireAdminSession(); + requireSuperAdmin(session.user); + const body = await readJsonBody>(request); + const organization = await updatePlatformOrganization(requiredString(body.organizationId, "组织 ID"), { + name: optionalString(body.name), + status: parseStatus(body.status) + }); + return jsonOk({ ok: true, organization: publicOrganization(organization) }); + } catch (error) { + return jsonError(error, 500, { request, source: "api.admin.organizations", logClientErrors: true }); + } +} + +export async function DELETE(request: Request) { + try { + const session = await requireAdminSession(); + requireSuperAdmin(session.user); + const body = await readJsonBody>(request); + await deletePlatformOrganization(requiredString(body.organizationId, "组织 ID")); + return jsonOk({ ok: true }); + } catch (error) { + return jsonError(error, 500, { request, source: "api.admin.organizations", logClientErrors: true }); + } +} + +function requireSuperAdmin(user: AuthUser) { + if (!hasSuperAdminAccess(user)) throw new AccountStoreError("需要超级管理员权限。", 403); +} + +function publicOrganization(organization: { id: string; name: string; status: string }) { + return { id: organization.id, name: organization.name, status: organization.status }; +} + +function requiredString(value: unknown, label: string): string { + const normalized = optionalString(value); + if (!normalized) throw new AccountStoreError(`${label}不能为空。`, 400); + return normalized; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function parseStatus(value: unknown): "active" | "disabled" | undefined { + if (value === undefined || value === null || value === "") return undefined; + if (value === "active" || value === "disabled") return value; + throw new AccountStoreError("组织状态不正确。", 400); +} diff --git a/app/api/admin/usage/route.ts b/app/api/admin/usage/route.ts new file mode 100644 index 0000000..8f46608 --- /dev/null +++ b/app/api/admin/usage/route.ts @@ -0,0 +1,61 @@ +import { jsonError, jsonOk } from "@/lib/server/api"; +import { requireAdminSession } from "@/lib/server/auth/current-user"; +import { listPlatformOrganizations } from "@/lib/server/account-store"; +import { hasSuperAdminAccess } from "@/lib/auth/permissions"; +import { getAdminUsageReport } from "@/lib/server/usage-service"; +import type { GenerationCapability, GenerationProvider } from "@/lib/types"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + try { + const session = await requireAdminSession(); + const url = new URL(request.url); + const isSuperAdmin = hasSuperAdminAccess(session.user); + const organizations = (await listPlatformOrganizations({ includeDisabled: isSuperAdmin })) + .filter((organization) => isSuperAdmin || organization.id === session.user.organizationId) + .map((organization) => ({ organizationId: organization.id, organizationName: organization.name })); + const report = await getAdminUsageReport({ + startDate: optionalParam(url, "startDate"), + endDate: optionalParam(url, "endDate"), + organizationId: isSuperAdmin ? optionalParam(url, "organizationId") : session.user.organizationId, + ownerId: isSuperAdmin ? optionalParam(url, "ownerId") : undefined, + capability: parseCapability(optionalParam(url, "capability")), + provider: parseProvider(optionalParam(url, "provider")) + }, organizations); + if (isSuperAdmin) return jsonOk(report); + return jsonOk({ + ...report, + accounts: [], + recent: [], + options: { ...report.options, accounts: [] } + }); + } catch (error) { + return jsonError(error, 500, { request, source: "api.admin.usage", logClientErrors: true }); + } +} + +function optionalParam(url: URL, name: string): string | undefined { + return url.searchParams.get(name)?.trim() || undefined; +} + +function parseCapability(value?: string): GenerationCapability | undefined { + if (!value) return undefined; + if (value === "image.generate" || value === "video.generate") { + return value; + } + throw badRequest("不支持的功能类型。"); +} + +function parseProvider(value?: string): GenerationProvider | undefined { + if (!value) return undefined; + if (value === "volcengine-visual" || value === "evolink" || value === "seedance" || value === "bailian") { + return value; + } + throw badRequest("不支持的服务商。"); +} + +function badRequest(message: string): Error & { status: number } { + return Object.assign(new Error(message), { status: 400 }); +} diff --git a/app/api/assets/[id]/inpaint/route.ts b/app/api/assets/[id]/inpaint/route.ts deleted file mode 100644 index 082fd82..0000000 --- a/app/api/assets/[id]/inpaint/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { getAsset } from "@/lib/server/data-store"; -import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; -import { requireAppUser } from "@/lib/server/auth/current-user"; -import { requestOrigin } from "@/lib/server/runtime"; -import { saveMaskDataUrl } from "@/lib/server/storage"; -import { submitImageJob } from "@/lib/server/generation-service"; - -export const runtime = "nodejs"; - -export async function POST(request: Request, context: { params: Promise<{ id: string }> }) { - try { - const user = await requireAppUser(); - const { id } = await context.params; - const asset = await getAsset(id); - if (!asset || asset.ownerId !== user.id) return jsonError(new Error("Asset not found."), 404); - const body = await readJsonBody<{ - prompt?: string; - maskDataUrl?: string; - maskUrl?: string; - seed?: number; - }>(request); - let maskUrl = body.maskUrl; - let maskAssetId: string | undefined; - if (body.maskDataUrl) { - const mask = await saveMaskDataUrl({ - ownerId: asset.ownerId, - dataUrl: body.maskDataUrl, - origin: requestOrigin(request), - jobHint: asset.id - }); - maskUrl = mask.url; - maskAssetId = mask.id; - } - if (!maskUrl) throw new Error("maskDataUrl or maskUrl is required for inpainting."); - const job = await submitImageJob({ - ownerId: user.id, - capability: "image.inpaint", - prompt: body.prompt || "删除", - imageUrls: [asset.url, maskUrl], - inputAssetIds: [asset.id, ...(maskAssetId ? [maskAssetId] : [])], - seed: typeof body.seed === "number" ? body.seed : undefined - }, requestOrigin(request)); - return jsonOk({ job }, { status: 202 }); - } catch (error) { - return jsonError(error); - } -} diff --git a/app/api/assets/[id]/upscale/route.ts b/app/api/assets/[id]/upscale/route.ts deleted file mode 100644 index 3d4c1c4..0000000 --- a/app/api/assets/[id]/upscale/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { getAsset } from "@/lib/server/data-store"; -import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; -import { requireAppUser } from "@/lib/server/auth/current-user"; -import { requestOrigin } from "@/lib/server/runtime"; -import { submitImageJob } from "@/lib/server/generation-service"; - -export const runtime = "nodejs"; - -export async function POST(request: Request, context: { params: Promise<{ id: string }> }) { - try { - const user = await requireAppUser(); - const { id } = await context.params; - const asset = await getAsset(id); - if (!asset || asset.ownerId !== user.id) return jsonError(new Error("Asset not found."), 404); - const body = await readJsonBody<{ - resolution?: "4k" | "8k"; - scale?: number; - }>(request); - const job = await submitImageJob({ - ownerId: user.id, - capability: "image.upscale", - imageUrls: [asset.url], - inputAssetIds: [asset.id], - resolution: body.resolution === "8k" ? "8k" : "4k", - scale: typeof body.scale === "number" ? body.scale : undefined - }, requestOrigin(request)); - return jsonOk({ job }, { status: 202 }); - } catch (error) { - return jsonError(error); - } -} diff --git a/app/api/assets/route.ts b/app/api/assets/route.ts index 835eef5..c4b6aff 100644 --- a/app/api/assets/route.ts +++ b/app/api/assets/route.ts @@ -21,7 +21,7 @@ export async function POST(request: Request) { name?: string; kind?: AssetKind; tags?: string[]; - source?: "upload" | "generated" | "edited" | "upscaled" | "external" | "seed"; + source?: "upload" | "generated" | "external" | "seed"; }>(request); const user = await requireAppUser(); if (!body.url) throw new Error("url is required"); diff --git a/app/api/auth/callback/route.ts b/app/api/auth/callback/route.ts index f978906..f7418a6 100644 --- a/app/api/auth/callback/route.ts +++ b/app/api/auth/callback/route.ts @@ -1,11 +1,5 @@ -import { completeAuthorizationCallback, redirectToLoginWithError } from "@/lib/server/auth/oauth"; - -export const runtime = "nodejs"; +import { NextResponse } from "next/server"; export async function GET(request: Request) { - try { - return await completeAuthorizationCallback(request); - } catch { - return redirectToLoginWithError(request, "callback_failed"); - } + return NextResponse.redirect(new URL("/auth/login?error=callback_failed", request.url)); } diff --git a/app/api/auth/captcha/route.ts b/app/api/auth/captcha/route.ts index 68501b7..eda4aa3 100644 --- a/app/api/auth/captcha/route.ts +++ b/app/api/auth/captcha/route.ts @@ -1,25 +1,7 @@ -import { getAuthRuntimeConfig } from "@/lib/auth/config"; -import { jsonError } from "@/lib/server/api"; +import { jsonOk } from "@/lib/server/api"; export const runtime = "nodejs"; -export async function GET(request: Request) { - try { - const config = getAuthRuntimeConfig(); - if (!config.authBaseUrl) throw new Error("认证中心地址未配置。"); - const randomStr = new URL(request.url).searchParams.get("randomStr")?.trim(); - if (!randomStr) throw new Error("randomStr is required."); - const response = await fetch(`${config.authBaseUrl}/code/image?randomStr=${encodeURIComponent(randomStr)}`, { - cache: "no-store" - }); - if (!response.ok) throw new Error(`验证码获取失败:${response.status}`); - return new Response(new Uint8Array(await response.arrayBuffer()), { - headers: { - "Content-Type": response.headers.get("content-type") || "image/png", - "Cache-Control": "no-store" - } - }); - } catch (error) { - return jsonError(error, 500); - } +export async function GET() { + return jsonOk({ enabled: false, message: "平台账号登录不使用外部验证码。" }); } diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 1fa2da2..6ac8fb2 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -1,11 +1,5 @@ -import { createAuthorizeRedirect, redirectToLoginWithError } from "@/lib/server/auth/oauth"; - -export const runtime = "nodejs"; +import { NextResponse } from "next/server"; export async function GET(request: Request) { - try { - return await createAuthorizeRedirect(request); - } catch { - return redirectToLoginWithError(request, "auth_not_configured"); - } + return NextResponse.redirect(new URL("/auth/login", request.url)); } diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index 0033dc5..50065e4 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -1,11 +1,18 @@ -import { clearAuthCookies } from "@/lib/server/auth/oauth"; +import { NextResponse } from "next/server"; +import { clearPlatformSessionCookies } from "@/lib/server/auth/local"; export const runtime = "nodejs"; export async function GET(request: Request) { - return clearAuthCookies(request); + return logoutResponse(request); } export async function POST(request: Request) { - return clearAuthCookies(request); + return logoutResponse(request); +} + +function logoutResponse(request: Request) { + const response = NextResponse.redirect(new URL("/auth/login?loggedOut=1", request.url)); + clearPlatformSessionCookies(response, request.url); + return response; } diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts index 11eaa4c..43435f9 100644 --- a/app/api/auth/me/route.ts +++ b/app/api/auth/me/route.ts @@ -11,6 +11,7 @@ export async function GET() { authenticated: Boolean(session), authRequired: config.required, authConfigured: config.configured, + authMode: session?.authMode || null, user: session?.user || null }); } diff --git a/app/api/auth/password/change/route.ts b/app/api/auth/password/change/route.ts new file mode 100644 index 0000000..193e168 --- /dev/null +++ b/app/api/auth/password/change/route.ts @@ -0,0 +1,29 @@ +import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; +import { requireAppSession } from "@/lib/server/auth/current-user"; +import { changeOwnPassword } from "@/lib/server/account-store"; +import { createPlatformSession, setPlatformSessionCookie } from "@/lib/server/auth/local"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + try { + const session = await requireAppSession(); + const body = await readJsonBody>(request); + const currentPassword = requiredString(body.currentPassword, "当前密码"); + const newPassword = requiredString(body.newPassword, "新密码"); + const confirmPassword = requiredString(body.confirmPassword, "确认密码"); + if (newPassword !== confirmPassword) throw Object.assign(new Error("两次输入的新密码不一致。"), { status: 400 }); + const user = await changeOwnPassword(session.user.id, currentPassword, newPassword); + const nextSession = await createPlatformSession(user); + const response = jsonOk({ ok: true, user: nextSession.user }); + await setPlatformSessionCookie(response, request.url, nextSession); + return response; + } catch (error) { + return jsonError(error, 400, { request, source: "api.auth.password.change" }); + } +} + +function requiredString(value: unknown, label: string): string { + if (typeof value === "string" && value.trim()) return value.trim(); + throw Object.assign(new Error(`${label}不能为空。`), { status: 400 }); +} diff --git a/app/api/auth/password/route.ts b/app/api/auth/password/route.ts index 5c24725..5a0be9a 100644 --- a/app/api/auth/password/route.ts +++ b/app/api/auth/password/route.ts @@ -1,134 +1,45 @@ import { getAuthRuntimeConfig, safeNextPath } from "@/lib/auth/config"; -import { createSessionCookieValue } from "@/lib/auth/session"; import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; -import { createSessionFromClaims, verifyAuthJwt } from "@/lib/server/auth/jwt"; -import { prepareAuthPassword } from "@/lib/server/auth/password"; -import { setSessionCookieValue } from "@/lib/server/auth/session-cookie"; +import { authenticatePlatformUser } from "@/lib/server/account-store"; +import { + checkIpLoginRateLimit, + clearIpLoginRateLimit, + clientIpFromRequest, + createPlatformSession, + setPlatformSessionCookie +} from "@/lib/server/auth/local"; export const runtime = "nodejs"; - -type PasswordTokenResponse = { - access_token?: string; - refresh_token?: string; - expires_in?: string | number; - token_type?: string; - error?: string; - error_description?: string; - msg?: string; - message?: string; - [key: string]: unknown; -}; +export const dynamic = "force-dynamic"; export async function POST(request: Request) { + const ip = clientIpFromRequest(request); try { - const body = await readJsonBody<{ - username?: string; - password?: string; - password_encrypted?: boolean; - passwordEncrypted?: boolean; - code?: string; - randomStr?: string; - next?: string; - authMode?: string; - }>(request); - const config = getAuthRuntimeConfig({ clientMode: body.authMode === "admin" ? "admin" : "default" }); - if (!config.configured || !config.tokenUrl || !config.clientSecret || !config.sessionSecret) { - throw new PasswordLoginError(`认证配置不完整:${config.missing.join(", ") || "未知配置"}`, 500); + const config = getAuthRuntimeConfig(); + if (!config.configured || !config.sessionSecret) { + throw Object.assign(new Error(`账号认证配置不完整:${config.missing.join(", ") || "ZHINIAN_AUTH_SESSION_SECRET"}`), { status: 503 }); } - const username = body.username?.trim(); - const password = body.password || ""; - const code = body.code?.trim(); - const randomStr = body.randomStr?.trim(); - if (!username || !password) throw new PasswordLoginError("账号和密码不能为空。"); - - const token = await exchangePasswordToken({ - tokenUrl: config.tokenUrl, - clientId: config.clientId, - clientSecret: config.clientSecret, - scope: config.scope, - tenantId: config.tenantId, - username, - password: prepareAuthPassword(password, { - passwordEncrypted: body.password_encrypted || body.passwordEncrypted, - passwordEncryptionKey: config.passwordEncryptionKey - }), - code, - randomStr - }); - if (!token.access_token) throw new PasswordLoginError("认证中心没有返回 access_token。", 502); - const claims = await verifyAuthJwt(token.access_token, config); - const session = createSessionFromClaims(claims, config, parseExpiresIn(token.expires_in), { - accessToken: token.access_token, - tokenType: token.token_type - }); + checkIpLoginRateLimit(ip); + const body = await readJsonBody>(request); + const phone = stringValue(body.phone) || stringValue(body.username); + const password = stringValue(body.password); + if (!phone || !password) throw Object.assign(new Error("手机号和密码不能为空。"), { status: 400 }); + const user = await authenticatePlatformUser(phone, password); + clearIpLoginRateLimit(ip); + const session = await createPlatformSession(user); const response = jsonOk({ ok: true, - redirectTo: safeNextPath(body.next), - user: session.user + redirectTo: safeNextPath(stringValue(body.next)), + user: session.user, + authMode: session.authMode }); - setSessionCookieValue( - response, - request.url, - await createSessionCookieValue(session, config.sessionSecret), - new Date(session.expiresAt * 1000) - ); + await setPlatformSessionCookie(response, request.url, session); return response; } catch (error) { - return jsonError(error, 401); + return jsonError(error, 401, { request, source: "api.auth.password", logClientErrors: false }); } } -async function exchangePasswordToken(input: { - tokenUrl: string; - clientId: string; - clientSecret: string; - scope: string; - tenantId?: string; - username: string; - password: string; - code?: string; - randomStr?: string; -}): Promise { - const form = new URLSearchParams(); - form.set("grant_type", "password"); - form.set("scope", input.scope); - form.set("username", input.username); - form.set("password", input.password); - if (input.tenantId) form.set("tenantId", input.tenantId); - if (input.code) form.set("code", input.code); - if (input.randomStr) form.set("randomStr", input.randomStr); - const headers: Record = { - Authorization: `Basic ${Buffer.from(`${input.clientId}:${input.clientSecret}`).toString("base64")}`, - "Content-Type": "application/x-www-form-urlencoded" - }; - if (input.tenantId) headers.tenantId = input.tenantId; - const response = await fetch(input.tokenUrl, { - method: "POST", - headers, - body: form - }); - const payload = await response.json().catch(() => ({})) as PasswordTokenResponse; - if (!response.ok) { - throw new PasswordLoginError(payload.error_description || payload.msg || payload.message || payload.error || "登录失败。", response.status); - } - return payload; -} - -function parseExpiresIn(value: string | number | undefined): number | undefined { - if (typeof value === "number" && Number.isFinite(value)) return value; - if (typeof value === "string" && value.trim()) { - const parsed = Number(value); - if (Number.isFinite(parsed)) return parsed; - } - return undefined; -} - -class PasswordLoginError extends Error { - status: number; - - constructor(message: string, status = 400) { - super(message); - this.name = "PasswordLoginError"; - this.status = status; - } +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; } diff --git a/app/api/billing/quote/route.ts b/app/api/billing/quote/route.ts new file mode 100644 index 0000000..da0e115 --- /dev/null +++ b/app/api/billing/quote/route.ts @@ -0,0 +1,23 @@ +import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; +import { requireAppSession } from "@/lib/server/auth/current-user"; +import { requestOrigin } from "@/lib/server/runtime"; +import { resolvePlatformUsageContext } from "@/lib/server/usage-context"; +import { quoteImageGeneration, type SubmitImageJobInput } from "@/lib/server/generation-service"; +import { quoteVideoGeneration, type SubmitVideoJobInput } from "@/lib/server/video-generation-service"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + try { + const session = await requireAppSession(); + const body = await readJsonBody>(request); + const usageContext = await resolvePlatformUsageContext(session); + const kind = body.kind === "video" || body.capability === "video.generate" ? "video" : "image"; + const quote = kind === "video" + ? await quoteVideoGeneration({ ...body, ownerId: session.user.id, usageContext } as SubmitVideoJobInput, requestOrigin(request)) + : await quoteImageGeneration({ ...body, capability: "image.generate", ownerId: session.user.id, usageContext } as SubmitImageJobInput, requestOrigin(request)); + return jsonOk({ quote }); + } catch (error) { + return jsonError(error, 500, { request, source: "api.billing.quote" }); + } +} diff --git a/app/api/billing/route.ts b/app/api/billing/route.ts new file mode 100644 index 0000000..471245e --- /dev/null +++ b/app/api/billing/route.ts @@ -0,0 +1,24 @@ +import { jsonError, jsonOk } from "@/lib/server/api"; +import { requireAppSession } from "@/lib/server/auth/current-user"; +import { getBillingOverview } from "@/lib/server/billing-service"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const session = await requireAppSession(); + const organizationId = session.user.organizationId; + if (!organizationId) throw Object.assign(new Error("当前账号未绑定组织。"), { status: 422 }); + const overview = await getBillingOverview({ organizationId, accountId: session.user.id }); + return jsonOk({ + organization: { + id: organizationId, + name: session.user.organizationName || organizationId + }, + ...overview + }); + } catch (error) { + return jsonError(error, 500, { source: "api.billing" }); + } +} diff --git a/app/api/generations/image/[id]/retry/route.ts b/app/api/generations/image/[id]/retry/route.ts index b8f4d8b..339b9ec 100644 --- a/app/api/generations/image/[id]/retry/route.ts +++ b/app/api/generations/image/[id]/retry/route.ts @@ -1,15 +1,18 @@ import { jsonError, jsonOk } from "@/lib/server/api"; -import { requireAppUser } from "@/lib/server/auth/current-user"; +import { requireAppSession } from "@/lib/server/auth/current-user"; import { requestOrigin } from "@/lib/server/runtime"; import { retryImageJob } from "@/lib/server/generation-service"; +import { resolvePlatformUsageContext } from "@/lib/server/usage-context"; export const runtime = "nodejs"; export async function POST(request: Request, context: { params: Promise<{ id: string }> }) { try { - const user = await requireAppUser(); + const session = await requireAppSession(); + const user = session.user; const { id } = await context.params; - const job = await retryImageJob(id, requestOrigin(request), user.id); + const usageContext = await resolvePlatformUsageContext(session); + const job = await retryImageJob(id, requestOrigin(request), user.id, usageContext); return jsonOk({ job }, { status: 202 }); } catch (error) { return jsonError(error); diff --git a/app/api/generations/image/[id]/route.ts b/app/api/generations/image/[id]/route.ts index 2c176d2..5928cb8 100644 --- a/app/api/generations/image/[id]/route.ts +++ b/app/api/generations/image/[id]/route.ts @@ -1,7 +1,8 @@ -import { deleteAsset, deleteGenerationJob, getAsset, getGenerationJob } from "@/lib/server/data-store"; +import { deleteAsset, deleteGenerationJob, getAsset, getGenerationJob, updateGenerationJob } from "@/lib/server/data-store"; import { jsonError, jsonOk } from "@/lib/server/api"; import { requireAppUser } from "@/lib/server/auth/current-user"; import { deleteStoredAsset } from "@/lib/server/storage"; +import { refundGenerationCharge } from "@/lib/server/billing-service"; export const runtime = "nodejs"; @@ -31,6 +32,10 @@ export async function DELETE(_request: Request, context: { params: Promise<{ id: await deleteAsset(asset.id); deletedAssetIds.push(asset.id); } + const cancellableJob = ["queued", "running"].includes(job.status) + ? await updateGenerationJob(job.id, { status: "cancelled", completedAt: new Date().toISOString() }) + : job; + await refundGenerationCharge(cancellableJob, "任务已删除"); await deleteGenerationJob(id); return jsonOk({ ok: true, deletedJobId: id, deletedAssetIds }); } catch (error) { diff --git a/app/api/generations/image/route.ts b/app/api/generations/image/route.ts index 3882436..08edccd 100644 --- a/app/api/generations/image/route.ts +++ b/app/api/generations/image/route.ts @@ -1,11 +1,12 @@ import { getGenerationJob, listGenerationJobs } from "@/lib/server/data-store"; import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; -import { requireAppUser } from "@/lib/server/auth/current-user"; +import { requireAppSession, requireAppUser } from "@/lib/server/auth/current-user"; import { requestOrigin } from "@/lib/server/runtime"; import { submitImageJob } from "@/lib/server/generation-service"; import { assemblePrompt, type PromptAssemblyInput, type PromptMaterial } from "@/lib/prompt/assembler"; import type { EnabledImageCapability } from "@/lib/types"; import type { ImageCreationEngine } from "@/lib/evolink/image-client"; +import { resolvePlatformUsageContext } from "@/lib/server/usage-context"; export const runtime = "nodejs"; @@ -21,7 +22,9 @@ export async function GET() { export async function POST(request: Request) { try { - const user = await requireAppUser(); + const session = await requireAppSession(); + const user = session.user; + const usageContext = await resolvePlatformUsageContext(session); const body = await readJsonBody<{ capability?: EnabledImageCapability; engine?: ImageCreationEngine; @@ -58,7 +61,8 @@ export async function POST(request: Request) { min_ratio: asNumber(body.min_ratio), max_ratio: asNumber(body.max_ratio), force_single: Boolean(body.force_single), - quality: typeof body.quality === "string" ? body.quality : undefined + quality: typeof body.quality === "string" ? body.quality : undefined, + usageContext }, requestOrigin(request)); return jsonOk({ job: await getGenerationJob(job.id) }, { status: 202 }); } catch (error) { diff --git a/app/api/generations/video/[id]/route.ts b/app/api/generations/video/[id]/route.ts index df62cc6..feb27c5 100644 --- a/app/api/generations/video/[id]/route.ts +++ b/app/api/generations/video/[id]/route.ts @@ -1,7 +1,8 @@ -import { deleteAsset, deleteGenerationJob, getAsset, getGenerationJob } from "@/lib/server/data-store"; +import { deleteAsset, deleteGenerationJob, getAsset, getGenerationJob, updateGenerationJob } from "@/lib/server/data-store"; import { jsonError, jsonOk } from "@/lib/server/api"; import { requireAppUser } from "@/lib/server/auth/current-user"; import { deleteStoredAsset } from "@/lib/server/storage"; +import { refundGenerationCharge } from "@/lib/server/billing-service"; export const runtime = "nodejs"; @@ -31,6 +32,10 @@ export async function DELETE(_request: Request, context: { params: Promise<{ id: await deleteAsset(asset.id); deletedAssetIds.push(asset.id); } + const cancellableJob = ["queued", "running"].includes(job.status) + ? await updateGenerationJob(job.id, { status: "cancelled", completedAt: new Date().toISOString() }) + : job; + await refundGenerationCharge(cancellableJob, "任务已删除"); await deleteGenerationJob(id); return jsonOk({ ok: true, deletedJobId: id, deletedAssetIds }); } catch (error) { diff --git a/app/api/generations/video/route.ts b/app/api/generations/video/route.ts index 392794d..5912c19 100644 --- a/app/api/generations/video/route.ts +++ b/app/api/generations/video/route.ts @@ -1,8 +1,9 @@ import { listGenerationJobs } from "@/lib/server/data-store"; import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; -import { requireAppUser } from "@/lib/server/auth/current-user"; +import { requireAppSession, requireAppUser } from "@/lib/server/auth/current-user"; import { requestOrigin } from "@/lib/server/runtime"; import { submitVideoJob, type SubmitVideoJobInput } from "@/lib/server/video-generation-service"; +import { resolvePlatformUsageContext } from "@/lib/server/usage-context"; export const runtime = "nodejs"; @@ -18,9 +19,11 @@ export async function GET() { export async function POST(request: Request) { try { - const user = await requireAppUser(); + const session = await requireAppSession(); + const user = session.user; const body = await readJsonBody>(request); - const job = await submitVideoJob({ ...body, ownerId: user.id }, requestOrigin(request)); + const usageContext = await resolvePlatformUsageContext(session); + const job = await submitVideoJob({ ...body, ownerId: user.id, usageContext }, requestOrigin(request)); return jsonOk({ job }, { status: 202 }); } catch (error) { return jsonError(error); diff --git a/app/api/logs/route.ts b/app/api/logs/route.ts index c38e4c1..b7acef1 100644 --- a/app/api/logs/route.ts +++ b/app/api/logs/route.ts @@ -1,13 +1,13 @@ import { jsonError, jsonOk } from "@/lib/server/api"; -import { requireAdminUser } from "@/lib/server/auth/current-user"; -import { appLogFilePath, appLogMaxBytes, clearAppLogs, listAppLogs, type AppLogLevel } from "@/lib/server/log-manager"; +import { requireSuperAdminUser } from "@/lib/server/auth/current-user"; +import { clearAppLogs, listAppLogs, type AppLogLevel } from "@/lib/server/log-manager"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET(request: Request) { try { - await requireAdminUser(); + await requireSuperAdminUser(); const url = new URL(request.url); const level = parseLevel(url.searchParams.get("level")); const q = url.searchParams.get("q") || undefined; @@ -17,11 +17,7 @@ export async function GET(request: Request) { q, limit: Number.isFinite(limit) ? limit : 100 }); - return jsonOk({ - entries, - logPath: appLogFilePath(), - maxBytes: appLogMaxBytes() - }); + return jsonOk({ entries }); } catch (error) { return jsonError(error, 500, { request, source: "api.logs" }); } @@ -29,7 +25,7 @@ export async function GET(request: Request) { export async function DELETE(request: Request) { try { - await requireAdminUser(); + await requireSuperAdminUser(); await clearAppLogs(); return jsonOk({ ok: true }); } catch (error) { diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index e7e5523..ab9f35d 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -1,12 +1,12 @@ import { getApiSettings, saveApiSettings } from "@/lib/server/app-settings"; import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; -import { requireAdminUser } from "@/lib/server/auth/current-user"; +import { requireSuperAdminUser } from "@/lib/server/auth/current-user"; export const runtime = "nodejs"; export async function GET() { try { - await requireAdminUser(); + await requireSuperAdminUser(); return jsonOk(await getApiSettings()); } catch (error) { return jsonError(error, 500); @@ -15,7 +15,7 @@ export async function GET() { export async function POST(request: Request) { try { - await requireAdminUser(); + await requireSuperAdminUser(); const body = await readJsonBody<{ values?: Record }>(request); return jsonOk(await saveApiSettings(body.values || {})); } catch (error) { diff --git a/app/api/usage/route.ts b/app/api/usage/route.ts new file mode 100644 index 0000000..5e30bd2 --- /dev/null +++ b/app/api/usage/route.ts @@ -0,0 +1,22 @@ +import { jsonError, jsonOk } from "@/lib/server/api"; +import { requireAppUser } from "@/lib/server/auth/current-user"; +import { getPersonalUsageReport } from "@/lib/server/usage-service"; +import type { UsagePreset } from "@/lib/usage"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + try { + const user = await requireAppUser(); + const preset = parsePreset(new URL(request.url).searchParams.get("preset")); + return jsonOk(await getPersonalUsageReport(user.id, preset)); + } catch (error) { + return jsonError(error, 500, { request, source: "api.usage" }); + } +} + +function parsePreset(value: string | null): UsagePreset { + if (value === "today" || value === "7d" || value === "30d" || value === "month") return value; + return "month"; +} diff --git a/app/api/v1/jobs/[id]/cancel/route.ts b/app/api/v1/jobs/[id]/cancel/route.ts index be2bc38..9a1ff3b 100644 --- a/app/api/v1/jobs/[id]/cancel/route.ts +++ b/app/api/v1/jobs/[id]/cancel/route.ts @@ -2,6 +2,7 @@ import { clearGenerationJobLock, getGenerationJob, updateGenerationJob } from "@ import { jsonError, jsonOk } from "@/lib/server/api"; import { authenticatePublicApiRequest, publicApiOwnerId } from "@/lib/server/public-api-auth"; import { publicApiError } from "@/lib/server/public-api-response"; +import { refundGenerationCharge } from "@/lib/server/billing-service"; export const runtime = "nodejs"; @@ -16,12 +17,12 @@ export async function POST(request: Request, context: { params: Promise<{ id: st if (["succeeded", "failed", "expired", "cancelled"].includes(job.status)) { return jsonOk({ job }); } - await updateGenerationJob(id, { + const cancelled = await updateGenerationJob(id, { status: "cancelled", completedAt: new Date().toISOString() }); - const cancelled = await clearGenerationJobLock(id); - return jsonOk({ job: cancelled }); + const refunded = await refundGenerationCharge(cancelled, "任务已取消") || cancelled; + return jsonOk({ job: await clearGenerationJobLock(refunded.id) }); } catch (error) { return publicApiError(error); } diff --git a/app/api/v1/jobs/route.ts b/app/api/v1/jobs/route.ts index b82f427..1613568 100644 --- a/app/api/v1/jobs/route.ts +++ b/app/api/v1/jobs/route.ts @@ -52,7 +52,7 @@ function parseStatus(value: string | null): GenerationStatus | undefined { function parseCapability(value: string | null): GenerationCapability | undefined { if (!value) return undefined; - if (["image.generate", "image.inpaint", "image.upscale", "video.generate"].includes(value)) { + if (["image.generate", "video.generate"].includes(value)) { return value as GenerationCapability; } throw new Error(`Unsupported capability filter: ${value}`); diff --git a/app/api/v1/openapi.json/route.ts b/app/api/v1/openapi.json/route.ts index 549b639..3c7916f 100644 --- a/app/api/v1/openapi.json/route.ts +++ b/app/api/v1/openapi.json/route.ts @@ -38,7 +38,7 @@ export async function GET(request: Request) { name: { type: "string", example: "result.png" }, url: { type: "string", format: "uri" }, storagePath: { type: "string" }, - source: { type: "string", enum: ["upload", "generated", "edited", "upscaled", "external", "seed"] }, + source: { type: "string", enum: ["upload", "generated", "external", "seed"] }, tags: { type: "array", items: { type: "string" } }, metadata: { type: "object", additionalProperties: true }, createdAt: { type: "string", format: "date-time" }, @@ -83,7 +83,7 @@ export async function GET(request: Request) { }, GenerationCapability: { type: "string", - enum: ["image.generate", "image.inpaint", "image.upscale", "video.generate"] + enum: ["image.generate", "video.generate"] }, GenerationStatus: { type: "string", @@ -121,11 +121,9 @@ export async function GET(request: Request) { }, width: { type: "integer", example: 1440 }, height: { type: "integer", example: 2560 }, - scale: { type: "number", minimum: 1, maximum: 100, description: "Jimeng text influence for image.generate and detail strength for image.upscale." }, + scale: { type: "number", minimum: 1, maximum: 100, description: "Jimeng text influence for image.generate." }, force_single: { type: "boolean" }, - quality: { type: "string", enum: ["low", "medium", "high"], description: "EvoLink image quality for image.generate and image.inpaint." }, - resolution: { type: "string", enum: ["4k", "8k"], description: "Upscale resolution for image.upscale." }, - seed: { type: "integer" }, + quality: { type: "string", enum: ["low", "medium", "high"], description: "EvoLink image quality for image.generate." }, priority: { type: "integer", minimum: -100, maximum: 100 }, webhookUrl: { type: "string", format: "uri" }, idempotencyKey: { type: "string", description: "Optional body-level idempotency key. Header Idempotency-Key is preferred." } diff --git a/app/assets/page.tsx b/app/assets/page.tsx index 8754b16..9f92fdd 100644 --- a/app/assets/page.tsx +++ b/app/assets/page.tsx @@ -1,19 +1,7 @@ -import { AssetManager } from "@/components/asset-manager"; +import { redirect } from "next/navigation"; export const dynamic = "force-dynamic"; -export default async function AssetsPage({ - searchParams -}: { - searchParams?: Promise>; -}) { - const params = await searchParams; - const viewParam = Array.isArray(params?.view) ? params?.view[0] : params?.view; - const taskIdParam = Array.isArray(params?.taskId) ? params?.taskId[0] : params?.taskId; - return ( - - ); +export default function AssetsPage() { + redirect("/create"); } diff --git a/app/auth/admin-login/page.tsx b/app/auth/admin-login/page.tsx index e5dfe5b..d36d432 100644 --- a/app/auth/admin-login/page.tsx +++ b/app/auth/admin-login/page.tsx @@ -1,13 +1,4 @@ import { redirect } from "next/navigation"; -import { AuthLoginPanel } from "@/components/auth-login-panel"; -import { getAuthRuntimeConfig, safeNextPath } from "@/lib/auth/config"; -import { getOptionalAuthSession } from "@/lib/server/auth/current-user"; - -const errorMessages: Record = { - auth_not_configured: "认证配置不完整,请先在服务器环境变量中配置 SSO。", - callback_failed: "登录回调处理失败,请重新登录。", - state_invalid: "登录状态已失效,请重新登录。" -}; export default async function AdminLoginPage({ searchParams @@ -15,33 +6,6 @@ export default async function AdminLoginPage({ searchParams?: Promise>; }) { const params = await searchParams; - const next = safeNextPath(singleParam(params?.next)); - const session = await getOptionalAuthSession(); - if (session) redirect(next); - - const config = getAuthRuntimeConfig({ clientMode: "admin" }); - const errorCode = singleParam(params?.error); - const message = errorCode ? errorMessages[errorCode] || "登录失败,请重新登录。" : null; - - return ( - - ); -} - -function singleParam(value: string | string[] | undefined): string | undefined { - return Array.isArray(value) ? value[0] : value; -} - -function loginHref(path: string, next: string): string { - return `${path}?next=${encodeURIComponent(next)}`; + const next = Array.isArray(params?.next) ? params?.next[0] : params?.next; + redirect(next ? `/auth/login?next=${encodeURIComponent(next)}` : "/auth/login"); } diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx index bcba700..d6d2a5d 100644 --- a/app/auth/login/page.tsx +++ b/app/auth/login/page.tsx @@ -4,8 +4,8 @@ import { getAuthRuntimeConfig, safeNextPath } from "@/lib/auth/config"; import { getOptionalAuthSession } from "@/lib/server/auth/current-user"; const errorMessages: Record = { - auth_not_configured: "认证配置不完整,请先在服务器环境变量中配置 SSO。", - callback_failed: "登录回调处理失败,请重新登录。", + auth_not_configured: "账号认证配置不完整,请联系管理员。", + callback_failed: "登录失败,请重新登录。", state_invalid: "登录状态已失效,请重新登录。" }; @@ -29,8 +29,6 @@ export default async function LoginPage({ configured={config.configured} message={message} missing={!config.configured && config.required ? config.missing : []} - alternateHref={loginHref("/auth/admin-login", next)} - alternateLabel="管理员登录" /> ); } @@ -38,7 +36,3 @@ export default async function LoginPage({ function singleParam(value: string | string[] | undefined): string | undefined { return Array.isArray(value) ? value[0] : value; } - -function loginHref(path: string, next: string): string { - return `${path}?next=${encodeURIComponent(next)}`; -} diff --git a/app/billing/error.tsx b/app/billing/error.tsx new file mode 100644 index 0000000..5be04d1 --- /dev/null +++ b/app/billing/error.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useEffect } from "react"; + +export default function BillingError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + useEffect(() => { + // Keep the error boundary intentionally quiet in production; the API/log layer records the server cause. + }, []); + + return ( +
+
+ 计费中心 +

计费服务暂时不可用

+

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

+ +
+
+ ); +} diff --git a/app/billing/page.tsx b/app/billing/page.tsx new file mode 100644 index 0000000..eab93ff --- /dev/null +++ b/app/billing/page.tsx @@ -0,0 +1,10 @@ +import { BillingManager } from "@/components/billing-manager"; +import { hasSuperAdminAccess } from "@/lib/auth/permissions"; +import { requireAppSession } from "@/lib/server/auth/current-user"; + +export const dynamic = "force-dynamic"; + +export default async function BillingPage() { + const session = await requireAppSession(); + return ; +} diff --git a/app/create/page.tsx b/app/create/page.tsx index d19e720..04a8ef6 100644 --- a/app/create/page.tsx +++ b/app/create/page.tsx @@ -7,10 +7,6 @@ export default async function CreatePage({ }) { const params = await searchParams; const modeParam = Array.isArray(params?.mode) ? params?.mode[0] : params?.mode; - const initialMode = modeParam === "video" || modeParam === "inpaint" || modeParam === "upscale" - ? modeParam - : modeParam === "edit" - ? "inpaint" - : "image"; + const initialMode = modeParam === "video" ? "video" : "image"; return ; } diff --git a/app/globals.css b/app/globals.css index c3cb5b6..b898008 100644 --- a/app/globals.css +++ b/app/globals.css @@ -416,10 +416,6 @@ h3 { margin-inline: auto; } -.create-studio.enhance-studio { - max-width: 1240px; -} - .create-mode-bar { display: flex; align-items: center; @@ -440,6 +436,9 @@ h3 { .create-actions { margin-left: auto; + display: inline-flex; + align-items: center; + gap: 10px; } .prompt-label-row { @@ -593,6 +592,16 @@ h3 { border-radius: 8px; background: #fbfdfd; box-shadow: 0 1px 0 rgba(255, 255, 255, 0.74) inset; + cursor: pointer; + transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease; +} + +.create-task-card:hover, +.create-task-card:focus-visible { + border-color: rgba(22, 122, 91, 0.46); + box-shadow: 0 10px 24px rgba(15, 118, 110, 0.1); + outline: none; + transform: translateY(-1px); } .create-task-thumb { @@ -693,10 +702,336 @@ h3 { white-space: nowrap; } +.create-task-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + min-width: max-content; +} + +.create-task-actions .icon-button { + width: 30px; + min-width: 30px; + height: 30px; + min-height: 30px; + padding: 0; +} + .task-preview-dialog { width: min(960px, calc(100vw - 48px)); } +.task-detail-backdrop { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + place-items: center; + padding: 20px; + background: rgba(15, 23, 42, 0.48); + backdrop-filter: blur(8px); +} + +.task-detail-dialog { + width: min(980px, 100%); + max-height: min(860px, calc(100dvh - 40px)); + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(215, 224, 228, 0.96); + border-radius: var(--radius); + background: var(--panel); + box-shadow: var(--shadow-strong); +} + +.task-detail-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 16px 18px 14px; + border-bottom: 1px solid var(--line); + background: #fbfdfd; +} + +.task-detail-title-wrap { + min-width: 0; + display: grid; + gap: 4px; +} + +.task-detail-kicker { + color: var(--green-dark); + font-size: 11px; + font-weight: 900; + letter-spacing: 0.08em; +} + +.task-detail-head h2 { + min-width: 0; + max-width: 760px; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 18px; +} + +.task-detail-subline { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 7px; + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.task-detail-scroll { + min-width: 0; + min-height: 0; + overflow-y: auto; + display: grid; + gap: 12px; + padding: 16px 18px 20px; + background: #f7faf9; +} + +.task-detail-section { + min-width: 0; + display: grid; + gap: 10px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 10px; + background: #ffffff; +} + +.task-detail-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.task-detail-section-head > div { + min-width: 0; + display: grid; + gap: 3px; +} + +.task-detail-section-head h3 { + margin: 0; + font-size: 14px; +} + +.task-detail-section-head span { + color: var(--muted); + font-size: 11px; + font-weight: 800; +} + +.task-detail-output-grid, +.task-detail-material-grid { + min-width: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; +} + +.task-detail-output-card, +.task-detail-material-card { + min-width: 0; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 8px; + background: #fbfcfa; +} + +.task-detail-output-media { + min-height: 160px; + display: grid; + place-items: center; + overflow: hidden; + background: #eef4f4; +} + +.task-detail-media { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + background: #ffffff; +} + +.task-detail-media.large { + max-height: 320px; +} + +.task-detail-media.thumb { + object-fit: cover; +} + +.task-detail-audio { + width: calc(100% - 20px); + margin: 10px; +} + +.task-detail-output-footer { + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 9px; +} + +.task-detail-output-footer > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--ink); + font-size: 12px; + font-weight: 800; +} + +.task-detail-output-footer .button { + flex: 0 0 auto; +} + +.task-detail-prompt { + min-width: 0; + min-height: 72px; + margin: 0; + padding: 11px 12px; + border: 1px solid #dce8e4; + border-radius: 8px; + background: #fbfdfc; + color: var(--ink); + font-size: 13px; + line-height: 1.65; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.task-detail-material-card { + display: grid; + grid-template-columns: 62px minmax(0, 1fr); + align-items: center; + gap: 9px; + padding: 7px; +} + +.task-detail-material-media { + width: 62px; + height: 62px; + display: grid; + place-items: center; + overflow: hidden; + border-radius: 6px; + background: #eef4f4; + color: var(--muted); +} + +.task-detail-material-copy { + min-width: 0; + display: grid; + gap: 4px; +} + +.task-detail-material-copy strong, +.task-detail-material-copy span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-detail-material-copy strong { + color: var(--green-dark); + font-size: 12px; +} + +.task-detail-material-copy span { + color: var(--muted); + font-size: 11px; +} + +.task-detail-columns { + min-width: 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.task-detail-value-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 14px; + margin: 0; +} + +.task-detail-value-grid div { + min-width: 0; + display: grid; + gap: 3px; +} + +.task-detail-value-grid dt { + color: var(--muted); + font-size: 11px; + font-weight: 800; +} + +.task-detail-value-grid dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + color: var(--ink); + font-size: 12px; + line-height: 1.45; +} + +.task-detail-empty { + min-width: 0; + flex-wrap: wrap; + min-height: 62px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px; + border: 1px dashed #cbdcd6; + border-radius: 8px; + color: var(--muted); + font-size: 12px; + text-align: center; +} + +.task-detail-empty span { + min-width: 0; + overflow-wrap: anywhere; +} + +.task-detail-empty.pending { + color: var(--warning); + background: #fffaf0; +} + +.task-detail-error { + display: grid; + gap: 4px; + padding: 12px 14px; + border: 1px solid #f1c5bd; + border-radius: 10px; + background: #fff7f5; + color: #9f3e30; + font-size: 12px; + line-height: 1.5; +} + .image-template-rail-head { min-height: 34px; display: flex; @@ -2267,34 +2602,6 @@ h3 { color: var(--ink); } -.log-storage { - display: grid; - grid-template-columns: minmax(0, 1.5fr) repeat(2, minmax(150px, 0.6fr)); - gap: 10px; -} - -.log-storage div { - min-width: 0; - display: grid; - gap: 4px; - padding: 10px 12px; - border: 1px solid var(--line); - border-radius: 8px; - background: #f8faf8; -} - -.log-storage span { - color: var(--muted); - font-size: 12px; - font-weight: 700; -} - -.log-storage strong { - min-width: 0; - overflow-wrap: anywhere; - font-size: 13px; -} - .log-list { display: grid; gap: 10px; @@ -2388,7 +2695,7 @@ h3 { .account-actions { display: grid; grid-template-columns: minmax(220px, 1.1fr) minmax(260px, 1fr) minmax(150px, 0.45fr) auto; - align-items: end; + align-items: start; gap: 12px; } @@ -2426,6 +2733,62 @@ h3 { gap: 12px; } +.account-org-list { + display: grid; + gap: 8px; +} + +.account-org-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-soft); +} + +.account-org-item > div:first-child { + display: grid; + gap: 3px; + min-width: 0; +} + +.account-org-actions { + display: flex; + align-items: center; + gap: 4px; + flex: 0 0 auto; +} + +.account-org-item small, +.account-loading, +.account-empty { + color: var(--muted); + font-size: 13px; +} + +.account-loading, +.account-empty { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 120px; +} + +.settings-page-stack { + display: grid; + gap: 16px; + max-width: 980px; + margin: 0 auto; +} + +.account-security-panel .auth-password-form { + max-width: 520px; +} + .account-department-form { display: grid; grid-template-columns: minmax(180px, 0.72fr) minmax(240px, 1fr) auto; @@ -2852,6 +3215,1415 @@ h3 { } +/* Billing center redesign: calm B2B finance workspace with one accent and sparse data groups. */ +.billing-modern { + width: 100%; + max-width: none; + padding-bottom: 56px; + --billing-radius: 16px; + --billing-surface: #ffffff; + --billing-surface-soft: #f1f5f1; + --billing-ink-soft: #53635d; + --billing-accent: var(--green); +} + +.billing-page-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin: 2px 0 20px; +} + +.billing-page-heading { + min-width: 0; +} + +.billing-header-line { + display: inline-flex; + align-items: center; + gap: 7px; + margin-bottom: 9px; + color: var(--muted); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.billing-live-dot, +.billing-org-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--green); +} + +.billing-title-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 11px; +} + +.billing-title-row h1 { + margin: 0; + font-size: clamp(30px, 4vw, 44px); + letter-spacing: -0.045em; + line-height: 1; +} + +.billing-view-tag, +.billing-shared-badge, +.billing-section-count { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--billing-ink-soft); + font-size: 11px; + font-weight: 800; +} + +.billing-view-tag { + min-height: 25px; + padding: 0 9px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--billing-surface); +} + +.billing-page-heading > p { + max-width: 42rem; + margin: 10px 0 0; + color: var(--muted); + font-size: 14px; + line-height: 1.6; +} + +.billing-refresh { + min-height: 38px; + display: inline-flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + padding: 0 13px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--billing-surface); + color: var(--ink); + font-size: 12px; + font-weight: 800; + transition: transform var(--duration-fast) var(--ease-out), border-color var(--duration-fast) var(--ease-out), box-shadow var(--duration-fast) var(--ease-out); +} + +.billing-refresh:hover:not(:disabled) { + border-color: var(--line-strong); + box-shadow: var(--shadow-soft); + transform: translateY(-1px); +} + +.billing-refresh:active, +.billing-action-button:active, +.billing-price-actions button:active { + transform: translateY(1px) scale(0.98); +} + +.billing-feedback { + min-height: 0; + margin-bottom: 14px; +} + +.billing-alert { + padding: 11px 14px; + border: 1px solid var(--line); + border-radius: 11px; + font-size: 13px; + line-height: 1.5; +} + +.billing-alert-error { + border-color: #e7c1ba; + background: #fff7f5; + color: #8d3425; +} + +.billing-alert-success { + border-color: #b9ddce; + background: #f1fbf6; + color: var(--green-dark); +} + +.billing-overview { + display: grid; + grid-template-columns: minmax(0, 1.45fr) minmax(300px, 0.75fr); + gap: 14px; + margin-bottom: 14px; +} + +.billing-balance-card { + min-height: 278px; + display: flex; + flex-direction: column; + padding: 27px 29px 23px; + border-radius: var(--billing-radius); + background: #173d35; + color: #f7fbf8; + box-shadow: 0 18px 40px rgba(23, 61, 53, 0.13); +} + +.billing-balance-topline, +.billing-balance-topline > div, +.billing-account-heading, +.billing-form-heading, +.billing-form-footer, +.billing-section-heading, +.billing-price-actions { + display: flex; + align-items: center; +} + +.billing-balance-topline { + justify-content: space-between; + gap: 16px; +} + +.billing-balance-topline > div { + align-items: flex-start; + flex-direction: column; + gap: 4px; +} + +.billing-balance-topline span:first-child, +.billing-usage-card span, +.billing-balance-footer span, +.billing-account-field span, +.billing-field > span, +.billing-price-values small, +.billing-list-head, +.billing-wallet-row small { + color: var(--muted); + font-size: 11px; + line-height: 1.4; +} + +.billing-balance-card .billing-balance-topline span:first-child { + color: rgba(247, 251, 248, 0.68); +} + +.billing-balance-topline strong { + font-size: 14px; + font-weight: 800; +} + +.billing-shared-badge { + min-height: 27px; + padding: 0 9px; + border: 1px solid rgba(222, 244, 235, 0.22); + border-radius: 999px; + color: rgba(247, 251, 248, 0.84); +} + +.billing-balance-value { + margin-top: 45px; + font-size: clamp(38px, 6vw, 64px); + font-weight: 800; + letter-spacing: -0.065em; + line-height: 0.95; +} + +.billing-balance-card p { + max-width: 34rem; + margin: 13px 0 0; + color: rgba(247, 251, 248, 0.69); + font-size: 13px; + line-height: 1.5; +} + +.billing-balance-footer { + display: flex; + flex-wrap: wrap; + gap: 10px 25px; + margin-top: auto; + padding-top: 22px; + border-top: 1px solid rgba(226, 245, 236, 0.16); +} + +.billing-balance-footer span { + color: rgba(247, 251, 248, 0.62); +} + +.billing-balance-footer b { + margin-left: 5px; + color: #f7fbf8; + font-size: 12px; +} + +.billing-usage-stack { + display: grid; + gap: 14px; +} + +.billing-usage-card { + min-height: 132px; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 13px; + padding: 21px; + border: 1px solid var(--line); + border-radius: var(--billing-radius); + background: var(--billing-surface); + box-shadow: 0 8px 25px rgba(31, 43, 37, 0.035); +} + +.billing-usage-card > svg { + color: #98a7a0; +} + +.billing-usage-card > div:nth-child(2) { + display: grid; + gap: 5px; +} + +.billing-usage-card strong { + font-size: 25px; + letter-spacing: -0.04em; + line-height: 1; +} + +.billing-usage-card small { + color: var(--muted); + font-size: 11px; +} + +.billing-usage-icon { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border-radius: 11px; +} + +.billing-usage-icon-blue { + background: #edf5fb; + color: var(--blue); +} + +.billing-usage-icon-green { + background: #eaf6f0; + color: var(--green); +} + +.billing-section { + margin-top: 14px; + padding: 24px; + border: 1px solid var(--line); + border-radius: var(--billing-radius); + background: var(--billing-surface); + box-shadow: 0 8px 25px rgba(31, 43, 37, 0.035); +} + +.billing-section-heading { + justify-content: space-between; + gap: 18px; + margin-bottom: 21px; +} + +.billing-section-heading h2 { + margin: 0; + font-size: 19px; + letter-spacing: -0.025em; +} + +.billing-section-heading p { + max-width: 56rem; + margin: 6px 0 0; + color: var(--muted); + font-size: 13px; + line-height: 1.5; +} + +.billing-section-action { + flex: 0 0 auto; +} + +.billing-section-count { + min-height: 28px; + padding: 0 9px; + border: 1px solid var(--line); + border-radius: 999px; +} + +.billing-account-card, +.billing-form { + min-width: 0; + padding: 19px; + border: 1px solid var(--line); + border-radius: 13px; + background: var(--billing-surface-soft); +} + +.billing-account-heading { + align-items: flex-start; + gap: 11px; +} + +.billing-account-icon { + width: 34px; + height: 34px; + display: grid; + flex: 0 0 auto; + place-items: center; + border-radius: 10px; + background: #dceee5; + color: var(--green-dark); +} + +.billing-account-heading > div:nth-child(2) { + min-width: 0; + flex: 1; +} + +.billing-account-heading h3 { + margin: 1px 0 4px; + font-size: 14px; +} + +.billing-account-heading p { + margin: 0; + color: var(--muted); + font-size: 11px; + line-height: 1.45; +} + +.billing-configured, +.billing-unconfigured, +.billing-status { + display: inline-flex; + align-items: center; + min-height: 23px; + padding: 0 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 800; + white-space: nowrap; +} + +.billing-configured, +.billing-status-enabled, +.billing-status-approved { + background: #e4f5ed; + color: var(--green-dark); +} + +.billing-unconfigured, +.billing-status-pending { + background: #fff4dd; + color: #8c6418; +} + +.billing-status-rejected, +.billing-status-disabled { + background: #fff0ed; + color: #9d3c2d; +} + +.billing-account-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 19px; +} + +.billing-account-field { + min-width: 0; + display: grid; + gap: 5px; + padding: 10px 11px; + border: 1px solid rgba(205, 218, 210, 0.8); + border-radius: 9px; + background: rgba(255, 255, 255, 0.66); +} + +.billing-account-field strong { + min-width: 0; + overflow: hidden; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.billing-empty-note { + margin: 20px 0 2px; + color: var(--muted); + font-size: 12px; + line-height: 1.6; +} + +.billing-form { + background: #fbfcfa; +} + +.billing-form-heading { + justify-content: space-between; + gap: 12px; + margin-bottom: 17px; +} + +.billing-form-heading > div { + display: grid; + gap: 4px; +} + +.billing-form-heading strong { + font-size: 14px; +} + +.billing-form-heading span, +.billing-form-footer span { + color: var(--muted); + font-size: 11px; +} + +.billing-form-heading > svg { + color: var(--green); +} + +.billing-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px 12px; +} + +.billing-field { + min-width: 0; + display: grid; + gap: 7px; +} + +.billing-field > span { + color: var(--ink); + font-size: 11px; + font-weight: 800; +} + +.billing-field input, +.billing-field select { + width: 100%; + min-height: 39px; + padding: 0 11px; + border: 1px solid var(--line); + border-radius: 9px; + outline: none; + background: #ffffff; + color: var(--ink); + font-size: 12px; + transition: border-color var(--duration-fast) var(--ease-out), box-shadow var(--duration-fast) var(--ease-out); +} + +.billing-field input::placeholder { + color: #8b9791; +} + +.billing-field input:focus, +.billing-field select:focus { + border-color: var(--green); + box-shadow: 0 0 0 3px rgba(22, 122, 91, 0.12); +} + +.billing-form-footer { + justify-content: space-between; + gap: 12px; + margin-top: 17px; + padding-top: 14px; + border-top: 1px solid var(--line); +} + +.billing-action-button { + min-height: 36px; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 0 13px; + border: 1px solid var(--green); + border-radius: 9px; + background: var(--green); + color: #ffffff; + font-size: 12px; + font-weight: 800; + box-shadow: 0 7px 14px rgba(22, 122, 91, 0.14); + transition: transform var(--duration-fast) var(--ease-out), background var(--duration-fast) var(--ease-out); +} + +.billing-action-button:hover:not(:disabled) { + background: var(--green-dark); +} + +.billing-ledger-list, +.billing-wallet-list { + min-width: 0; +} + +.billing-list-head, +.billing-ledger-row, +.billing-wallet-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(190px, auto); + align-items: center; + gap: 18px; +} + +.billing-list-head { + padding: 0 0 9px; + border-bottom: 1px solid var(--line); +} + +.billing-list-head > span:last-child { + text-align: right; +} + +.billing-ledger-row, +.billing-wallet-row { + min-height: 70px; + padding: 12px 0; + border-bottom: 1px solid rgba(220, 227, 221, 0.78); +} + +.billing-ledger-row:last-child, +.billing-wallet-row:last-child { + border-bottom: 0; + padding-bottom: 0; +} + +.billing-ledger-main, +.billing-wallet-row > div:first-child { + min-width: 0; + display: flex; + align-items: center; + gap: 11px; +} + +.billing-ledger-main > div, +.billing-wallet-row > div:first-child > div { + min-width: 0; + display: grid; + gap: 4px; +} + +.billing-ledger-main strong, +.billing-wallet-row strong { + min-width: 0; + overflow: hidden; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.billing-ledger-main small, +.billing-wallet-row small { + min-width: 0; + overflow: hidden; + color: var(--muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.billing-ledger-icon { + width: 32px; + height: 32px; + display: grid; + flex: 0 0 auto; + place-items: center; + border-radius: 9px; + background: var(--billing-surface-soft); + color: var(--muted); +} + +.billing-ledger-icon-charge { + background: #eef5fb; + color: var(--blue); +} + +.billing-ledger-icon-recharge, +.billing-ledger-icon-refund { + background: #e9f6ef; + color: var(--green); +} + +.billing-ledger-amount, +.billing-wallet-row > div:last-child { + display: grid; + justify-items: end; + gap: 4px; +} + +.billing-ledger-amount strong, +.billing-wallet-row > div:last-child strong { + font-size: 13px; +} + +.billing-ledger-amount small, +.billing-wallet-row > div:last-child small { + color: var(--muted); + font-size: 11px; +} + +.billing-positive { + color: var(--green-dark); +} + +.billing-negative { + color: #b14d3d; +} + +.billing-empty-state { + min-height: 112px; + display: grid; + place-items: center; + align-content: center; + gap: 8px; + color: var(--muted); + font-size: 12px; +} + +.billing-pricing-section { + overflow: hidden; +} + +.billing-pricing-heading { + align-items: flex-end; +} + +.billing-formula { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + color: var(--muted); + font-size: 11px; +} + +.billing-formula b { + color: #9da9a3; + font-size: 14px; +} + +.billing-formula strong { + color: var(--green-dark); +} + +.billing-provider-mark { + width: 35px; + height: 35px; + display: grid; + flex: 0 0 auto; + place-items: center; + border-radius: 10px; + background: #edf2ee; + color: var(--green-dark); + font-size: 11px; + font-weight: 900; +} + +.billing-source-line { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + overflow: hidden; + color: var(--muted); + font-size: 10px; + line-height: 1.35; + white-space: nowrap; +} + +.billing-source-line a { + flex: 0 0 auto; + color: var(--blue); + font-weight: 800; +} + +.billing-source-line span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.billing-price-catalog { + display: grid; + gap: 12px; +} + +.billing-price-service-card { + overflow: hidden; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--billing-surface); +} + +.billing-price-service-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 14px 16px 10px; + background: var(--billing-surface-soft); +} + +.billing-price-service-title { + min-width: 0; + display: flex; + align-items: center; + gap: 10px; +} + +.billing-price-service-title > div, +.billing-price-tier-name { + min-width: 0; + display: grid; + gap: 4px; +} + +.billing-price-service-title strong, +.billing-price-tier-name strong { + color: var(--ink); + font-size: 13px; +} + +.billing-price-service-title small, +.billing-price-tier-name small { + overflow: hidden; + color: var(--muted); + font-size: 10px; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} + +.billing-price-base { + flex: 0 0 auto; + display: grid; + justify-items: end; + gap: 3px; +} + +.billing-price-base > span, +.billing-price-dimension-heading small, +.billing-price-service-meta { + color: var(--muted); + font-size: 10px; +} + +.billing-price-base strong { + color: var(--green-dark); + font-size: 15px; +} + +.billing-price-base strong small { + margin-left: 3px; + color: var(--muted); + font-size: 10px; + font-weight: 700; +} + +.billing-price-service-meta { + display: flex; + gap: 10px; + padding: 0 16px 12px; + line-height: 1.45; +} + +.billing-price-service-meta span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.billing-price-service-meta a { + flex: 0 0 auto; + color: var(--blue); + font-weight: 800; +} + +.billing-price-dimensions { + border-top: 1px solid var(--line); +} + +.billing-price-dimension { + padding: 13px 16px 3px; +} + +.billing-price-dimension + .billing-price-dimension { + border-top: 1px solid rgba(220, 227, 221, 0.78); +} + +.billing-price-dimension-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-bottom: 8px; +} + +.billing-price-dimension-heading > div { + display: grid; + gap: 3px; +} + +.billing-price-dimension-heading strong { + color: var(--green-dark); + font-size: 12px; +} + +.billing-price-tier-head, +.billing-price-tier-row, +.billing-price-legacy-row { + display: grid; + grid-template-columns: minmax(170px, 1.7fr) minmax(105px, 0.8fr) minmax(105px, 0.8fr) minmax(62px, 0.45fr) auto; + align-items: center; + gap: 14px; +} + +.billing-price-tier-head { + min-height: 27px; + color: var(--muted); + font-size: 10px; + font-weight: 800; +} + +.billing-price-tier-row, +.billing-price-legacy-row { + min-height: 55px; + padding: 9px 0; + border-top: 1px solid rgba(220, 227, 221, 0.78); +} + +.billing-price-tier-row-disabled { + opacity: 0.55; +} + +.billing-price-values { + display: grid; + gap: 4px; +} + +.billing-price-values strong { + font-size: 15px; + letter-spacing: -0.02em; +} + +.billing-multiplier { + color: var(--ink); + font-size: 12px; + font-weight: 800; +} + +.billing-price-actions { + display: flex; + justify-content: flex-end; + gap: 4px; +} + +.billing-price-actions button { + min-height: 29px; + padding: 0 8px; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--billing-surface); + color: var(--ink); + font-size: 11px; + font-weight: 800; + transition: transform var(--duration-fast) var(--ease-out), border-color var(--duration-fast) var(--ease-out), background var(--duration-fast) var(--ease-out); +} + +.billing-price-actions button:hover:not(:disabled) { + border-color: var(--line-strong); + background: var(--billing-surface-soft); +} + +.billing-price-actions button:last-child { + color: var(--muted); +} + +.billing-admin-zone { + margin-top: 14px; +} + +.billing-account-section .billing-account-card { + background: var(--billing-surface-soft); +} + +.billing-admin-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 14px; +} + +.billing-admin-grid .billing-section { + min-width: 0; +} + +.billing-org-dot-disabled { + background: var(--coral); +} + +.billing-loading { + display: grid; + gap: 14px; +} + +.billing-skeleton, +.billing-skeleton-grid > div { + min-height: 125px; + border-radius: var(--billing-radius); + background: linear-gradient(90deg, #edf1ed 0%, #f8faf8 50%, #edf1ed 100%); + background-size: 220% 100%; + animation: billing-skeleton-pulse 1.6s ease-in-out infinite; +} + +.billing-skeleton-large { + min-height: 278px; +} + +.billing-skeleton-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.billing-skeleton-section { + min-height: 320px; +} + +@keyframes billing-skeleton-pulse { + 0%, 100% { background-position: 100% 0; } + 50% { background-position: 0 0; } +} + +@media (prefers-reduced-motion: reduce) { + .billing-skeleton, + .billing-skeleton-grid > div { + animation: none; + } +} + +/* Price source metadata stays on one measured baseline instead of relying on flex stretching. */ +.billing-price-service-meta { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + align-items: center; + gap: 12px; + min-height: 32px; +} + +.billing-price-service-meta a, +.billing-price-source-placeholder { + min-width: max-content; + min-height: 24px; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0 7px; + border: 1px solid #c8ded5; + border-radius: 6px; + background: #f2faf6; + color: var(--green-dark); + font-size: 10px; + font-weight: 800; + line-height: 1; + white-space: nowrap; +} + +.billing-price-service-meta a:hover { + border-color: #9dc8b8; + background: #e8f6ef; +} + +.billing-price-service-meta .billing-price-note { + min-width: 0; + overflow: hidden; + color: var(--muted); + font-size: 10px; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} + +.billing-modal-backdrop { + position: fixed; + inset: 0; + z-index: 80; + display: grid; + place-items: center; + padding: 24px; + background: rgba(24, 32, 29, 0.38); + backdrop-filter: blur(6px); +} + +.billing-modal { + width: min(460px, 100%); + overflow: hidden; + border: 1px solid rgba(215, 224, 228, 0.96); + border-radius: 16px; + background: var(--billing-surface, #ffffff); + box-shadow: 0 24px 72px rgba(31, 43, 37, 0.2), 0 2px 10px rgba(31, 43, 37, 0.08); + animation: billing-modal-enter 180ms var(--ease-out, ease-out); +} + +@keyframes billing-modal-enter { + from { opacity: 0; transform: translateY(8px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.billing-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 20px 16px; + border-bottom: 1px solid var(--line); + background: #fbfdfd; +} + +.billing-modal-title-wrap { + min-width: 0; + display: flex; + align-items: flex-start; + gap: 10px; +} + +.billing-modal-icon { + display: inline-grid; + place-items: center; + width: 32px; + height: 32px; + flex: 0 0 auto; + border-radius: 9px; + color: var(--green-dark); + background: #e5f4ec; +} + +.billing-modal-header h2 { + margin: 0; + color: var(--ink); + font-size: 17px; + letter-spacing: -0.02em; + line-height: 1.25; +} + +.billing-modal-header p { + max-width: 320px; + margin: 4px 0 0; + overflow: hidden; + color: var(--muted); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.billing-modal-close { + width: 32px; + height: 32px; + display: inline-grid; + place-items: center; + flex: 0 0 auto; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--muted); +} + +.billing-modal-close:hover:not(:disabled) { + border-color: var(--line); + background: var(--billing-surface-soft, #f1f5f1); + color: var(--ink); +} + +.billing-modal-close:focus-visible, +.billing-modal input:focus-visible, +.billing-modal .billing-secondary-button:focus-visible, +.billing-modal .billing-action-button:focus-visible { + outline: 3px solid rgba(37, 99, 235, 0.18); + outline-offset: 2px; +} + +.billing-price-edit-form { + display: grid; + gap: 16px; + padding: 20px; +} + +.billing-modal-field { + display: grid; + gap: 7px; +} + +.billing-modal-field > span { + color: var(--ink); + font-size: 12px; + font-weight: 800; +} + +.billing-modal-input-wrap { + display: flex; + align-items: center; + gap: 9px; + min-height: 44px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: #ffffff; + transition: border-color var(--duration-fast) var(--ease-out), box-shadow var(--duration-fast) var(--ease-out); +} + +.billing-modal-input-wrap:focus-within { + border-color: var(--green); + box-shadow: 0 0 0 3px rgba(22, 122, 91, 0.12); +} + +.billing-modal-input-wrap input { + width: 100%; + min-width: 0; + min-height: 40px; + padding: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--ink); + font-size: 17px; + font-weight: 800; +} + +.billing-modal-input-wrap strong { + color: var(--muted); + font-size: 16px; +} + +.billing-modal-field small { + color: var(--muted); + font-size: 11px; +} + +.billing-price-edit-preview { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + padding: 11px; + border: 1px solid var(--line); + border-radius: 10px; + background: #f7faf8; +} + +.billing-price-edit-preview > div { + min-width: 0; + display: grid; + gap: 4px; +} + +.billing-price-edit-preview span { + overflow: hidden; + color: var(--muted); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.billing-price-edit-preview strong { + overflow: hidden; + color: var(--ink); + font-size: 14px; + letter-spacing: -0.02em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.billing-price-edit-preview strong small { + color: var(--muted); + font-size: 10px; + font-weight: 700; +} + +.billing-price-edit-preview-next { + padding-left: 8px; + border-left: 1px solid var(--line); +} + +.billing-price-edit-preview-next strong { + color: var(--green-dark); +} + +.billing-modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 4px; +} + +@media (max-width: 560px) { + .billing-modal-backdrop { + align-items: end; + padding: 12px; + } + + .billing-modal { + width: 100%; + border-radius: 14px; + } + + .billing-price-edit-preview { + grid-template-columns: 1fr 1fr; + } + + .billing-price-edit-preview-next { + grid-column: 1 / -1; + padding-top: 8px; + padding-left: 0; + border-top: 1px solid var(--line); + border-left: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .billing-modal { + animation: none; + } +} + +@media (max-width: 1080px) { + .billing-overview { + grid-template-columns: minmax(0, 1fr); + } + + .billing-usage-stack { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + +} + +@media (max-width: 860px) { + .billing-admin-grid { + grid-template-columns: minmax(0, 1fr); + } + + .billing-price-service-heading { + align-items: flex-start; + } + + .billing-price-tier-head { + display: none; + } + + .billing-price-tier-row, + .billing-price-legacy-row { + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px 12px; + padding: 12px 0; + } + + .billing-price-tier-name { + grid-column: 1 / -1; + } + + .billing-price-tier-row .billing-price-values:nth-child(2), + .billing-price-legacy-row .billing-price-values:nth-child(2) { + grid-column: 1; + } + + .billing-price-tier-row .billing-price-values:nth-child(3), + .billing-price-legacy-row .billing-price-values:nth-child(3) { + grid-column: 2; + } + + .billing-price-tier-row .billing-multiplier, + .billing-price-legacy-row .billing-multiplier { + grid-column: 1; + } + + .billing-price-tier-row .billing-price-actions, + .billing-price-legacy-row .billing-price-actions { + grid-column: 2; + } +} + +@media (max-width: 640px) { + .billing-modern { + padding-bottom: 44px; + } + + .billing-page-header, + .billing-section-heading, + .billing-form-footer { + align-items: flex-start; + flex-direction: column; + } + + .billing-page-header { + gap: 16px; + margin-bottom: 20px; + } + + .billing-refresh { + width: 100%; + justify-content: center; + } + + .billing-balance-card { + min-height: 255px; + padding: 22px 20px 19px; + } + + .billing-balance-value { + margin-top: 37px; + font-size: 48px; + } + + .billing-usage-stack, + .billing-skeleton-grid, + .billing-form-grid { + grid-template-columns: minmax(0, 1fr); + } + + .billing-usage-card { + min-height: 108px; + } + + .billing-section { + padding: 18px; + } + + .billing-form-footer { + gap: 11px; + } + + .billing-action-button { + width: 100%; + justify-content: center; + } + + .billing-list-head, + .billing-ledger-row, + .billing-wallet-row { + grid-template-columns: minmax(0, 1fr) auto; + gap: 11px; + } + + .billing-ledger-main strong, + .billing-wallet-row strong { + white-space: normal; + } + + .billing-account-grid { + grid-template-columns: minmax(0, 1fr); + } + + .billing-formula { + align-self: flex-start; + } + +} + /* UI/UX Pro Max pass: professional creator workspace tokens and interaction layer. */ :root { color-scheme: light; @@ -3069,7 +4841,7 @@ button:active:not(:disabled), display: grid; } -.main.create-main .create-studio:not(.enhance-studio) { +.main.create-main .create-studio { width: 100%; height: 100%; min-height: 0; @@ -3410,10 +5182,6 @@ button:active:not(:disabled), max-width: 1760px; } -.create-studio.enhance-studio { - max-width: 1280px; -} - .create-mode-bar { position: static; z-index: auto; @@ -3437,6 +5205,89 @@ button:active:not(:disabled), font-size: 15px; } +.billing-summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.billing-summary-item { + display: grid; + gap: 6px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-raised); +} + +.billing-summary-item span, +.billing-price-table small { + color: var(--muted); + font-size: 12px; +} + +.billing-account-card { + padding: 1.25rem; + border: 1px solid var(--line); + border-radius: 1.25rem; + background: var(--surface-raised); +} + +.billing-account-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.8rem; +} + +.billing-account-field { + display: grid; + gap: 0.3rem; + padding: 0.85rem 0.95rem; + border-radius: 0.9rem; + background: var(--surface); +} + +.billing-account-field span { + color: var(--muted); + font-size: 0.75rem; +} + +.billing-account-field strong { + overflow-wrap: anywhere; +} + +.billing-metric-value { + font-size: clamp(20px, 2.4vw, 30px); +} + +.billing-positive { + color: var(--green-dark); +} + +.billing-negative { + color: var(--danger); +} + +.billing-task-cost { + color: var(--ink-soft); +} + +.billing-ledger-table, +.billing-price-table, +.billing-wallet-table { + overflow-x: auto; +} + +@media (max-width: 820px) { + .billing-summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .billing-account-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + .prompt-editor-wrap { border: 1px solid var(--line); border-radius: var(--radius); @@ -3662,6 +5513,642 @@ button:active:not(:disabled), } } +/* Usage metering: personal account popover and administrator workspace. */ +.account-usage-menu { + position: relative; + min-width: 0; +} + +.account-chip-button { + cursor: pointer; + transition: border-color 160ms ease, background 160ms ease, box-shadow 160ms ease; +} + +.account-chip-button:hover, +.account-chip-button[aria-expanded="true"] { + border-color: rgba(22, 122, 91, 0.4); + background: #ffffff; + box-shadow: 0 8px 22px rgba(31, 43, 37, 0.1); +} + +.account-chip-button:focus-visible { + outline: 3px solid rgba(22, 122, 91, 0.18); + outline-offset: 2px; +} + +.account-chip-button .account-chevron { + width: 14px; + height: 14px; + transition: transform 160ms ease; +} + +.account-chip-button .account-chevron.open { + transform: rotate(180deg); +} + +.account-usage-popover { + position: absolute; + top: calc(100% + 10px); + right: 0; + z-index: 50; + width: min(390px, calc(100vw - 24px)); + max-height: min(620px, calc(100dvh - 92px)); + overflow: auto; + padding: 16px; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 24px 64px rgba(31, 43, 37, 0.18); + color: var(--ink); + animation: usage-popover-in 160ms ease-out both; +} + +@keyframes usage-popover-in { + from { opacity: 0; transform: translateY(-6px) scale(0.985); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.account-usage-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.account-usage-head > div { + min-width: 0; + display: grid; + gap: 3px; +} + +.account-usage-head span, +.account-usage-head small { + color: var(--muted); + font-size: 11px; + font-weight: 700; +} + +.account-usage-head strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 16px; +} + +.account-usage-head small { + max-width: 44%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-usage-tabs { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-bottom: 12px; +} + +.account-usage-tabs button { + min-width: 0; + padding: 7px 4px; + font-size: 11px; +} + +.account-usage-loading, +.account-usage-error, +.account-usage-empty { + min-height: 84px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--muted); + font-size: 12px; + text-align: center; +} + +.account-usage-error { + min-height: auto; + margin-bottom: 10px; + padding: 9px 10px; + border: 1px solid #e6b8ae; + border-radius: 8px; + background: #fff8f6; + color: #8d2f21; +} + +.account-usage-loading svg { + width: 17px; + height: 17px; +} + +.account-usage-content { + display: grid; + gap: 12px; + transition: opacity 140ms ease; +} + +.account-usage-content.refreshing, +.usage-content.refreshing { + opacity: 0.58; + pointer-events: none; +} + +.account-usage-total { + display: grid; + grid-template-columns: 1fr auto; + align-items: end; + gap: 2px 12px; + padding: 14px; + border: 1px solid #cfe0d5; + border-radius: 10px; + background: #f1f7f3; +} + +.account-usage-total span, +.account-usage-total small { + color: var(--muted); + font-size: 11px; + font-weight: 700; +} + +.account-usage-total strong { + grid-row: 1 / span 2; + grid-column: 2; + color: var(--green-dark); + font-size: 34px; + line-height: 1; +} + +.account-usage-breakdown { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.account-usage-breakdown > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 9px 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fbfcfa; + font-size: 12px; +} + +.account-usage-breakdown span { + color: var(--muted); +} + +.account-usage-recent { + display: grid; + gap: 0; + border-top: 1px solid var(--line); + padding-top: 10px; +} + +.account-usage-section-title { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; + font-size: 12px; +} + +.account-usage-section-title svg { + width: 15px; + height: 15px; + color: var(--green); +} + +.account-usage-record { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 0; + border-bottom: 1px solid #edf1ed; +} + +.account-usage-record:last-child { + border-bottom: 0; +} + +.account-usage-record > div { + min-width: 0; + display: grid; + gap: 3px; +} + +.account-usage-record strong, +.account-usage-record small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-usage-record strong { + font-size: 12px; +} + +.account-usage-record small, +.account-usage-record time { + color: var(--muted); + font-size: 10px; +} + +.account-usage-record time { + flex: 0 0 auto; +} + +.usage-manager, +.usage-content { + display: grid; + gap: 14px; +} + +.usage-filters { + display: grid; + grid-template-columns: repeat(6, minmax(140px, 1fr)); + align-items: end; + gap: 12px; +} + +.usage-filters .field { + margin-bottom: 0; +} + +.usage-filters .field > span { + font-size: 12px; + font-weight: 800; +} + +.usage-filter-actions { + grid-column: 1 / -1; + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.usage-filter-actions svg { + width: 16px; + height: 16px; +} + +.usage-feedback { + display: grid; + gap: 8px; +} + +.usage-warning { + border-color: #ead2a4; + background: #fffaf0; + color: #755214; +} + +.usage-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.usage-metric { + position: relative; + min-height: 128px; + display: grid; + align-content: space-between; + gap: 8px; + overflow: hidden; +} + +.usage-metric > span { + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.usage-metric > strong { + font-size: 30px; + line-height: 1; +} + +.usage-metric > strong small { + margin-left: 5px; + color: var(--muted); + font-size: 12px; +} + +.usage-metric-icon { + position: absolute; + top: 16px; + right: 16px; + width: 36px; + height: 36px; + display: grid; + place-items: center; + border-radius: 9px; + background: #edf5f0; + color: var(--green); +} + +.usage-metric-icon svg { + width: 18px; + height: 18px; +} + +.usage-analysis-grid { + display: grid; + grid-template-columns: minmax(0, 1.6fr) minmax(300px, 0.8fr); + gap: 14px; +} + +.usage-trend-panel, +.usage-breakdown-panel, +.usage-table-panel { + min-width: 0; +} + +.panel-head.compact { + margin-bottom: 8px; +} + +.usage-trend { + min-height: 220px; + display: flex; + align-items: stretch; + gap: 8px; + overflow-x: auto; + padding: 16px 4px 4px; +} + +.usage-trend-point { + min-width: 32px; + flex: 1 0 32px; + display: grid; + grid-template-rows: 18px 1fr 24px; + align-items: end; + gap: 5px; + text-align: center; +} + +.usage-trend-value { + min-height: 18px; + color: var(--muted); + font-size: 10px; + font-weight: 800; +} + +.usage-trend-track { + height: 142px; + display: flex; + align-items: flex-end; + border-radius: 6px; + background: #f0f3ef; + overflow: hidden; +} + +.usage-trend-track span { + width: 100%; + display: block; + border-radius: 6px 6px 2px 2px; + background: var(--green); + transition: height 220ms ease; +} + +.usage-trend-point small { + color: var(--muted); + font-size: 9px; + white-space: nowrap; +} + +.usage-breakdown { + display: grid; + gap: 13px; +} + +.usage-breakdown.compact { + gap: 9px; +} + +.usage-breakdown-row { + display: grid; + gap: 6px; +} + +.usage-breakdown-row > div:first-child { + display: flex; + justify-content: space-between; + gap: 12px; + color: var(--muted-strong); + font-size: 12px; +} + +.usage-progress { + height: 7px; + overflow: hidden; + border-radius: 999px; + background: #edf1ed; +} + +.usage-progress span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--green); +} + +.usage-provider-divider { + height: 1px; + margin: 18px 0 14px; + background: var(--line); +} + +.usage-table { + display: grid; +} + +.usage-table-row { + min-width: 0; + display: grid; + align-items: center; + gap: 14px; + min-height: 56px; + padding: 10px 0; + border-bottom: 1px solid var(--line); + font-size: 13px; +} + +.usage-organization-table .usage-table-row, +.usage-account-table .usage-table-row { + grid-template-columns: minmax(190px, 1.4fr) minmax(130px, 0.7fr) minmax(90px, 0.45fr) minmax(150px, 0.7fr); +} + +.usage-table-row:last-child { + border-bottom: 0; +} + +.usage-table-row time, +.usage-table-row > span { + color: var(--muted); +} + +.usage-table-head { + min-height: 34px; + padding-top: 0; + color: var(--muted); + font-size: 11px; + font-weight: 800; +} + +.usage-account-name { + min-width: 0; + display: grid; + gap: 3px; +} + +.usage-account-name strong, +.usage-account-name small { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.usage-account-name small { + color: var(--muted); + font-size: 11px; +} + +.usage-detail-list { + display: grid; +} + +.usage-detail-row { + display: grid; + grid-template-columns: minmax(170px, 1.1fr) minmax(190px, 1fr) minmax(150px, 0.7fr) auto; + align-items: center; + gap: 14px; + min-height: 64px; + padding: 10px 0; + border-bottom: 1px solid var(--line); +} + +.usage-detail-row:last-child { + border-bottom: 0; +} + +.usage-detail-row > div { + min-width: 0; + display: grid; + gap: 3px; +} + +.usage-detail-row > div strong, +.usage-detail-row > div small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.usage-detail-row > div small, +.usage-detail-row time { + color: var(--muted); + font-size: 11px; +} + +.usage-empty { + grid-column: 1 / -1; + min-height: 110px; + display: grid; + place-items: center; + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +@media (max-width: 1080px) { + .usage-filters { + grid-template-columns: repeat(3, minmax(150px, 1fr)); + } + + .usage-analysis-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 780px) { + .usage-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .usage-organization-table .usage-table-row, + .usage-account-table .usage-table-row, + .usage-detail-row { + grid-template-columns: minmax(0, 1fr) auto; + } + + .usage-table-head { + display: none; + } + + .usage-detail-row > time, + .usage-detail-row > .status { + justify-self: end; + } +} + +@media (max-width: 620px) { + .account-usage-popover { + position: fixed; + top: 108px; + left: 12px; + right: 12px; + width: auto; + max-height: calc(100dvh - 120px); + } + + .usage-filters { + grid-template-columns: 1fr; + padding: 14px; + } + + .usage-filter-actions { + grid-column: auto; + } + + .usage-filter-actions .button { + flex: 1; + } + + .usage-metrics { + grid-template-columns: 1fr; + } + + .usage-metric { + min-height: 108px; + } + + .usage-table-row, + .usage-detail-row { + gap: 8px 12px; + } +} + +@media (prefers-reduced-motion: reduce) { + .account-usage-popover { + animation: none; + } + + .account-chip-button, + .account-chip-button .account-chevron, + .usage-trend-track span { + transition: none; + } +} + @media (max-width: 1180px) { .create-workbench-layout.with-template-column { grid-template-columns: minmax(315px, 375px) minmax(0, 1fr); @@ -3788,8 +6275,7 @@ button:active:not(:disabled), grid-template-columns: repeat(2, minmax(0, 1fr)); } - .log-actions, - .log-storage { + .log-actions { grid-template-columns: 1fr; } @@ -4012,6 +6498,44 @@ button:active:not(:disabled), height: 64px; } + .create-task-actions { + gap: 4px; + } + + .create-task-actions .create-task-link { + padding-inline: 7px; + } + + .task-detail-backdrop { + align-items: start; + padding: 12px; + } + + .task-detail-dialog { + max-height: calc(100dvh - 24px); + } + + .task-detail-head { + padding: 12px; + } + + .task-detail-scroll { + padding: 12px; + } + + .task-detail-columns { + grid-template-columns: 1fr; + } + + .task-detail-output-grid, + .task-detail-material-grid { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + } + + .task-detail-head h2 { + font-size: 16px; + } + .image-template-column { position: static; top: auto; @@ -4522,3 +7046,1331 @@ button:active:not(:disabled), min-height: 34px; } } + +/* Keep the billing account card intentionally stacked on narrow screens after legacy shell rules. */ +@media (max-width: 640px) { + .billing-modern .billing-account-grid { + grid-template-columns: minmax(0, 1fr); + } +} + +/* Billing center task tabs: keep the finance workspace compact while preserving keyboard navigation. */ +.billing-modern .billing-tabs-shell { + margin: 0 0 14px; + border-bottom: 1px solid var(--line); +} + +.billing-modern .billing-tabs { + display: flex; + gap: 4px; + overflow-x: auto; + scrollbar-width: none; +} + +.billing-modern .billing-tabs::-webkit-scrollbar { + display: none; +} + +.billing-modern .billing-tab { + position: relative; + min-width: 116px; + min-height: 52px; + display: grid; + align-content: center; + gap: 3px; + padding: 7px 13px 9px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--muted); + text-align: left; + white-space: nowrap; + cursor: pointer; + transition: color var(--duration-fast) var(--ease-out), border-color var(--duration-fast) var(--ease-out), background var(--duration-fast) var(--ease-out); +} + +.billing-modern .billing-tab:hover { + background: rgba(15, 118, 110, 0.05); + color: var(--ink); +} + +.billing-modern .billing-tab:focus-visible, +.billing-modern .billing-secondary-button:focus-visible, +.billing-modern .billing-text-button:focus-visible, +.billing-modern .billing-admin-stat:focus-visible { + outline: 3px solid rgba(37, 99, 235, 0.18); + outline-offset: 2px; +} + +.billing-modern .billing-tab.is-active { + border-color: var(--green); + color: var(--ink); +} + +.billing-modern .billing-tab span { + font-size: 13px; + font-weight: 800; +} + +.billing-modern .billing-tab small { + color: var(--muted); + font-size: 10px; +} + +.billing-modern .billing-tab-panel { + min-width: 0; +} + +.billing-modern .billing-overview { + margin-bottom: 12px; + grid-template-columns: minmax(0, 1.55fr) minmax(360px, 0.8fr); +} + +.billing-modern .billing-balance-card { + min-height: 220px; + padding: 20px 24px 17px; +} + +.billing-modern .billing-balance-value { + margin-top: 26px; +} + +.billing-modern .billing-usage-card { + min-height: 96px; + padding: 14px 17px; +} + +.billing-modern .billing-section { + margin-top: 0; + padding: 20px; +} + +.billing-modern .billing-section-heading { + margin-bottom: 13px; +} + +.billing-admin-overview-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 14px; +} + +.billing-admin-stat { + min-width: 0; + min-height: 72px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-rows: auto auto; + align-content: center; + gap: 4px 12px; + padding: 11px 15px; + border: 1px solid var(--line); + border-radius: 13px; + background: var(--billing-surface); + color: var(--ink); + text-align: left; + cursor: pointer; + transition: border-color var(--duration-fast) var(--ease-out), background var(--duration-fast) var(--ease-out), transform var(--duration-fast) var(--ease-out); +} + +.billing-admin-stat:hover { + border-color: rgba(15, 118, 110, 0.38); + background: #fbfdfb; + transform: translateY(-1px); +} + +.billing-admin-stat > span, +.billing-admin-stat > em { + color: var(--muted); + font-size: 11px; + font-style: normal; +} + +.billing-admin-stat > strong { + grid-column: 2; + grid-row: 1 / span 2; + align-self: center; + font-size: 26px; + letter-spacing: -0.045em; + line-height: 1; +} + +.billing-admin-stat > strong small { + color: var(--muted); + font-size: 12px; + font-weight: 700; + letter-spacing: 0; +} + +.billing-admin-stat > em { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.billing-admin-stat > em svg { + color: var(--green); +} + +.billing-balance-admin-layout, +.billing-account-settings-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(360px, 0.92fr); + gap: 12px; + align-items: start; +} + +.billing-subpanel { + min-width: 0; + padding: 16px; + border: 1px solid var(--line); + border-radius: 12px; + background: #fbfcfa; +} + +.billing-subpanel-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 13px; +} + +.billing-subpanel-heading h3 { + margin: 0; + font-size: 14px; +} + +.billing-subpanel-heading p { + margin: 4px 0 0; + color: var(--muted); + font-size: 11px; +} + +.billing-subpanel-heading > svg { + color: var(--green); +} + +.billing-member-balance-panel { + margin-top: 12px; + padding: 16px; + border: 1px solid var(--line); + border-radius: 12px; + background: #fbfcfa; +} + +.billing-member-balance-head, +.billing-member-balance-row { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(190px, 0.9fr); + align-items: center; + gap: 16px; +} + +.billing-member-balance-head { + padding: 0 0 9px; + border-bottom: 1px solid var(--line); + color: var(--muted); + font-size: 11px; +} + +.billing-member-balance-head span:last-child { + text-align: right; +} + +.billing-member-balance-row { + min-height: 62px; + padding: 10px 0; + border-bottom: 1px solid rgba(220, 227, 221, 0.78); +} + +.billing-member-balance-row:last-child { + border-bottom: 0; + padding-bottom: 0; +} + +.billing-member-balance-row > div { + min-width: 0; + display: grid; + gap: 4px; +} + +.billing-member-balance-row strong { + overflow: hidden; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.billing-member-balance-row small { + overflow: hidden; + color: var(--muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.billing-adjustment-form, +.billing-account-editor { + background: #fbfcfa; +} + +.billing-adjustment-note { + display: flex; + align-items: center; + gap: 7px; + margin: -2px 0 16px; + padding: 9px 10px; + border-radius: 9px; + background: #eef7f1; + color: var(--muted); + font-size: 11px; +} + +.billing-adjustment-note svg { + color: var(--green); +} + +.billing-adjustment-note b { + color: var(--ink); + font-size: 13px; +} + +.billing-adjustment-full-field { + grid-column: 1 / -1; +} + +.billing-account-settings-layout { + grid-template-columns: minmax(270px, 0.75fr) minmax(0, 1.25fr); +} + +.billing-account-editor-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.billing-form-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.billing-secondary-button, +.billing-text-button { + min-height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 11px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--billing-surface); + color: var(--ink); + font-size: 11px; + font-weight: 800; + cursor: pointer; + transition: border-color var(--duration-fast) var(--ease-out), background var(--duration-fast) var(--ease-out), transform var(--duration-fast) var(--ease-out); +} + +.billing-secondary-button:hover, +.billing-text-button:hover { + border-color: var(--line-strong); + background: var(--billing-surface-soft); +} + +.billing-text-button { + min-height: 28px; + padding-inline: 9px; + color: var(--green-dark); +} + +@media (max-width: 980px) { + .billing-admin-overview-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .billing-modern .billing-overview { + grid-template-columns: minmax(0, 1fr); + } + + .billing-balance-admin-layout, + .billing-account-settings-layout { + grid-template-columns: minmax(0, 1fr); + } + + .billing-member-balance-head, + .billing-member-balance-row { + grid-template-columns: minmax(0, 1fr) auto; + } + +} + +@media (max-width: 640px) { + .billing-modern .billing-tabs { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + overflow: visible; + } + + .billing-modern .billing-tab { + min-width: 0; + min-height: 44px; + padding-inline: 7px; + } + + .billing-modern .billing-tab small { + display: none; + } + + .billing-modern .billing-balance-card { + min-height: 228px; + padding: 21px 19px 18px; + } + + .billing-modern .billing-balance-value { + margin-top: 29px; + } + + .billing-admin-overview-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .billing-admin-stat { + min-height: 68px; + } + + .billing-admin-stat:last-child { + grid-column: 1 / -1; + } + + .billing-account-editor-grid { + grid-template-columns: minmax(0, 1fr); + } + + .billing-form-actions { + width: 100%; + } + + .billing-form-actions > * { + flex: 1; + } +} + +.billing-modern .billing-empty-state { + min-height: 76px; +} + +.billing-route-error { + min-height: 70vh; + display: grid; + place-items: center; + padding: 32px 20px; + background: var(--paper); +} + +.billing-route-error-card { + width: min(100%, 520px); + padding: 32px; + border: 1px solid var(--line); + border-radius: 18px; + background: var(--billing-surface); + box-shadow: 0 18px 50px rgb(31 43 35 / 8%); +} + +.billing-route-error-kicker { + color: var(--green-dark); + font-size: 11px; + font-weight: 800; + letter-spacing: .12em; + text-transform: uppercase; +} + +.billing-route-error-card h1 { + margin: 12px 0 10px; + color: var(--ink); + font-size: clamp(24px, 4vw, 34px); + letter-spacing: -.04em; +} + +.billing-route-error-card p { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.75; +} + +.billing-route-error-card code { + padding: 2px 5px; + border-radius: 5px; + background: var(--billing-surface-soft); + color: var(--ink); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; +} + +.billing-route-error-card button { + min-height: 38px; + margin-top: 24px; + padding: 0 16px; + border: 0; + border-radius: 9px; + background: var(--green-dark); + color: white; + font-size: 12px; + font-weight: 800; + cursor: pointer; +} + +@media (prefers-reduced-motion: reduce) { + .billing-modern .billing-tab, + .billing-modern .billing-admin-stat, + .billing-modern .billing-secondary-button, + .billing-modern .billing-text-button { + transition: none; + } +} + +/* Account directory redesign: a restrained utility surface for identity and access. */ +.account-directory { + --account-radius: 14px; + --account-radius-small: 10px; + --account-surface-soft: #f5f8f8; + display: grid; + gap: 18px; + max-width: 1180px; + margin: 0 auto; +} + +.account-directory-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: flex-end; + gap: 24px; + padding: 4px 2px 2px; +} + +.account-directory-intro h1 { + margin: 0; + color: var(--ink); + font-size: clamp(30px, 3.4vw, 40px); + letter-spacing: -0.055em; + line-height: 1.08; +} + +.account-directory-intro p { + max-width: 520px; + margin: 10px 0 0; + color: var(--muted); + font-size: 14px; +} + +.account-section-label { + display: inline-flex; + margin-bottom: 8px; + color: var(--green-dark); + font-size: 11px; + font-weight: 850; + letter-spacing: 0.12em; + line-height: 1; + text-transform: uppercase; +} + +.account-identity-summary { + display: flex; + align-items: center; + gap: 11px; + min-width: 270px; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: var(--account-radius); + background: rgba(255, 255, 255, 0.7); +} + +.account-identity-summary > div { + min-width: 0; + display: grid; + gap: 3px; +} + +.account-identity-summary strong, +.account-identity-summary span, +.account-identity-summary small { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-identity-summary strong { + font-size: 14px; +} + +.account-identity-summary span, +.account-identity-summary small { + color: var(--muted); + font-size: 11px; +} + +.account-identity-summary small { + color: var(--green-dark); +} + +.account-directory-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + align-items: start; + gap: 18px; +} + +.account-directory-layout.personal-only { + grid-template-columns: minmax(0, 720px) 320px; +} + +.account-directory-main, +.account-directory-rail { + min-width: 0; + display: grid; + align-content: start; + gap: 18px; +} + +.account-security-card, +.account-profile-card, +.account-admin-card, +.account-members-panel { + min-width: 0; + border: 1px solid var(--line); + border-radius: var(--account-radius); + background: var(--surface); + box-shadow: var(--shadow-soft); +} + +.account-security-card, +.account-profile-card, +.account-admin-card, +.account-members-panel { + padding: 20px; +} + +.account-panel-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.account-panel-heading h2 { + margin: 0; + color: var(--ink); + font-size: 19px; + letter-spacing: -0.035em; + line-height: 1.2; +} + +.account-panel-heading p { + max-width: 560px; + margin: 7px 0 0; + color: var(--muted); + font-size: 13px; + line-height: 1.55; +} + +.account-panel-heading > svg { + width: 19px; + height: 19px; + color: var(--green-dark); + flex: 0 0 auto; +} + +.account-panel-icon { + display: inline-grid; + place-items: center; + width: 38px; + height: 38px; + border: 1px solid #cbe4dd; + border-radius: 12px; + background: #edf8f4; + color: var(--green-dark); + flex: 0 0 auto; +} + +.account-panel-icon svg { + width: 18px; + height: 18px; +} + +.account-security-heading { + align-items: center; +} + +.account-security-form { + display: grid; + gap: 14px; + margin-top: 18px; +} + +.account-security-fields { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.account-directory .field { + min-width: 0; + margin-bottom: 0; + gap: 6px; +} + +.account-directory .field > span, +.account-filter-field > span { + color: var(--muted-strong); + font-size: 12px; + font-weight: 800; +} + +.account-directory .field input, +.account-directory .field select, +.account-filter-field select { + min-height: 40px; + border-color: var(--line); + border-radius: var(--account-radius-small); + background: var(--surface); + color: var(--ink); + font-size: 13px; +} + +.account-directory .field input::placeholder { + color: #8996a0; +} + +.account-directory .field input:focus, +.account-directory .field select:focus, +.account-filter-field select:focus { + border-color: var(--focus); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); + outline: 0; +} + +.account-form-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 14px; + padding-top: 14px; + border-top: 1px solid var(--line); +} + +.account-form-footer .button { + min-height: 40px; + flex: 0 0 auto; +} + +.account-feedback { + padding: 11px 13px; + border: 1px solid var(--line); + border-radius: var(--account-radius-small); + font-size: 13px; + line-height: 1.5; +} + +.account-feedback.success { + border-color: #b9ded3; + background: #f1fbf8; + color: var(--green-dark); +} + +.account-feedback.error { + border-color: #e6b8ae; + background: #fff6f3; + color: #8d2f21; +} + +.account-profile-list { + display: grid; + gap: 0; + margin: 17px 0 0; +} + +.account-profile-list > div { + display: grid; + grid-template-columns: 82px minmax(0, 1fr); + gap: 14px; + padding: 11px 0; + border-bottom: 1px solid var(--line); +} + +.account-profile-list > div:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.account-profile-list dt { + color: var(--muted); + font-size: 12px; +} + +.account-profile-list dd { + min-width: 0; + margin: 0; + color: var(--ink); + font-size: 13px; + font-weight: 750; + overflow-wrap: anywhere; + text-align: right; +} + +.account-card-description { + margin: 9px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.55; +} + +.account-admin-card .account-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 11px; + margin-top: 17px; +} + +.account-admin-card .account-form-grid .field { + min-width: 0; +} + +.account-form-wide { + grid-column: 1 / -1; +} + +.account-create-submit { + width: 100%; + min-height: 42px; + box-shadow: none; +} + +.account-admin-card .account-department-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: flex-end; + gap: 10px; + margin-top: 17px; +} + +.account-admin-card .account-department-form .button { + min-height: 40px; + padding-inline: 12px; +} + +.account-org-list { + max-height: 246px; + margin-top: 15px; + overflow: auto; +} + +.account-org-item { + padding: 9px 0; + border: 0; + border-bottom: 1px solid var(--line); + border-radius: 0; + background: transparent; +} + +.account-org-item:last-child { + border-bottom: 0; +} + +.account-org-item strong { + font-size: 13px; +} + +.account-org-item small { + font-size: 11px; +} + +.account-org-item small.org-active { + color: var(--green-dark); +} + +.account-org-item small.org-disabled { + color: var(--coral); +} + +.account-members-heading { + align-items: flex-end; +} + +.account-members-tools { + display: flex; + align-items: flex-end; + gap: 8px; + flex: 0 0 auto; +} + +.account-filter-field { + display: grid; + gap: 5px; + min-width: 142px; +} + +.account-filter-field select { + min-height: 38px; + padding: 0 9px; +} + +.account-members-panel .account-table { + margin-top: 18px; +} + +.account-members-panel .account-row { + grid-template-columns: minmax(190px, 1.35fr) minmax(140px, 0.9fr) 78px auto; + min-height: 64px; + padding: 11px 0; + border-bottom-color: #e6ecee; +} + +.account-members-panel .account-row:last-child { + border-bottom: 0; +} + +.account-members-panel .account-head { + min-height: 34px; + padding-top: 0; + color: var(--muted); + font-size: 11px; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.account-member-identity { + min-width: 0; + display: flex; + align-items: center; + gap: 10px; +} + +.account-avatar { + display: inline-grid; + place-items: center; + width: 34px; + height: 34px; + border: 1px solid #cfe5df; + border-radius: 11px; + background: #edf8f4; + color: var(--green-dark); + font-size: 12px; + font-weight: 850; + flex: 0 0 auto; +} + +.account-avatar.large { + width: 42px; + height: 42px; + border-radius: 13px; + font-size: 14px; +} + +.account-member-main { + gap: 2px; +} + +.account-member-main strong { + font-size: 13px; +} + +.account-member-main small { + color: var(--muted); + font-size: 11px; +} + +.account-role-cell { + min-width: 0; + color: var(--muted-strong); + font-size: 12px; +} + +.account-members-panel .account-status-select { + width: 100%; + min-height: 36px; + padding: 0 8px; + border-radius: var(--account-radius-small); + font-size: 12px; +} + +.account-status-badge { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 800; +} + +.account-status-badge.active { + background: #e9f7f1; + color: var(--green-dark); +} + +.account-status-badge.disabled { + background: #fff0ed; + color: #8d2f21; +} + +.account-members-panel .account-row-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 5px; +} + +.account-members-panel .account-row-actions .icon-button, +.account-org-actions .icon-button { + width: 34px; + min-height: 34px; + border-radius: 9px; +} + +.account-directory .button, +.account-directory .icon-button { + transition: transform 160ms var(--ease-out), border-color 160ms ease, background 160ms ease, box-shadow 160ms ease; +} + +.account-directory .button:hover:not(:disabled), +.account-directory .icon-button:hover:not(:disabled) { + border-color: var(--line-strong); + box-shadow: 0 5px 14px rgba(15, 23, 42, 0.07); +} + +.account-directory .button:active:not(:disabled), +.account-directory .icon-button:active:not(:disabled) { + transform: translateY(1px); +} + +.account-directory .button:focus-visible, +.account-directory .icon-button:focus-visible, +.account-directory select:focus-visible, +.account-directory input:focus-visible { + outline: 3px solid rgba(37, 99, 235, 0.18); + outline-offset: 2px; +} + +.account-loading { + display: grid; + justify-items: center; + gap: 10px; + min-height: 158px; + padding: 24px 0; + color: var(--muted); + font-size: 12px; +} + +.account-skeleton-list { + display: grid; + gap: 9px; + width: min(100%, 500px); +} + +.account-skeleton-list span { + display: block; + height: 12px; + border-radius: 999px; + background: linear-gradient(90deg, #edf2f2 0%, #f8fbfb 50%, #edf2f2 100%); +} + +.account-skeleton-list span:nth-child(2) { + width: 84%; +} + +.account-skeleton-list span:nth-child(3) { + width: 68%; +} + +.account-empty { + display: grid; + place-items: center; + min-height: 158px; + color: var(--muted); + font-size: 13px; +} + +@media (prefers-reduced-motion: reduce) { + .account-directory .button, + .account-directory .icon-button { + transition: none; + } +} + +/* Consolidated account workspace: one identity, one surface, three ordered sections. */ +.account-directory-head { + grid-template-columns: minmax(0, 1fr); + align-items: end; + gap: 28px; +} + +.account-workspace { + overflow: hidden; + border: 1px solid var(--line); + border-radius: 16px; + background: var(--surface); + box-shadow: var(--shadow-soft); +} + +.account-workspace > .account-security-card, +.account-workspace > .account-management-section, +.account-workspace > .account-members-panel { + border: 0; + border-radius: 0; + box-shadow: none; +} + +.account-workspace > .account-security-card, +.account-workspace > .account-management-section { + border-bottom: 1px solid var(--line); +} + +.account-workspace > .account-security-card { + padding: 24px; +} + +.account-management-section { + padding: 24px; +} + +.account-workspace-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} + +.account-workspace-heading h2 { + margin: 0; + color: var(--ink); + font-size: 20px; + letter-spacing: -0.04em; + line-height: 1.2; +} + +.account-workspace-heading p { + margin: 7px 0 0; + color: var(--muted); + font-size: 13px; +} + +.account-workspace-heading > svg { + width: 20px; + height: 20px; + color: var(--green-dark); + flex: 0 0 auto; +} + +.account-management-grid { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(300px, 0.8fr); + gap: 24px; + margin-top: 22px; +} + +.account-management-form { + min-width: 0; +} + +.account-management-form + .account-management-form { + padding-left: 24px; + border-left: 1px solid var(--line); +} + +.account-subsection-heading { + display: grid; + gap: 5px; + margin-bottom: 15px; +} + +.account-subsection-heading > span { + color: var(--muted); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; +} + +.account-subsection-heading h3 { + margin: 0; + color: var(--ink); + font-size: 16px; + letter-spacing: -0.025em; +} + +.account-management-form .account-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 11px; + margin-top: 0; +} + +.account-management-form .account-department-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: flex-end; + gap: 10px; + margin: 0; +} + +.account-management-form .account-department-form .button { + min-height: 40px; + padding-inline: 12px; +} + +.account-management-form .account-org-list { + max-height: 216px; + margin-top: 15px; +} + +.account-workspace > .account-members-panel { + padding: 24px; +} + +.account-workspace .account-members-panel .account-table { + margin-top: 18px; +} + +/* Create controls: keep the parameter choices and the resulting price in one visual row. */ +.generation-settings-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(160px, 172px); + align-items: stretch; + gap: 12px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--line); +} + +.generation-settings-row .inline-settings, +.main.create-main .generation-settings-row .inline-settings { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(76px, 1fr)); + align-items: start; + gap: 10px; + min-width: 0; + height: auto; + margin: 0; + padding: 0; + overflow: visible; + border-top: 0; + flex: 0 1 auto; +} + +.generation-settings-row .inline-settings .inline-field, +.generation-settings-row .inline-settings .inline-field:nth-child(2) { + width: auto; + min-width: 0; + margin: 0; + flex: 0 1 auto; +} + +.generation-settings-row .inline-field label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.generation-settings-row .inline-field select { + width: 100%; + min-width: 0; + padding: 8px; + font-size: 12px; +} + +.billing-estimate { + display: grid; + align-content: center; + gap: 4px; + min-width: 0; + padding: 10px 12px 9px 14px; + border: 1px solid #b8d9cc; + border-radius: 12px; + background: #f3faf7; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82); + transition: border-color 180ms ease, background-color 180ms ease; +} + +.billing-estimate-head { + display: flex; + align-items: center; + min-width: 0; + gap: 6px; +} + +.billing-estimate-head { + color: var(--green-dark); + font-size: 12px; + font-weight: 800; + white-space: nowrap; +} + +.billing-estimate-icon { + display: inline-grid; + place-items: center; + width: 24px; + height: 24px; + flex: 0 0 auto; + border-radius: 7px; + color: #167a5b; + background: #dff2e9; +} + +.billing-estimate-amount { + color: var(--ink); + font-size: 24px; + letter-spacing: -0.045em; + line-height: 1.05; +} + +.billing-estimate.is-loading { + border-color: #c8dcd6; + background: #f7fbfa; +} + +.billing-estimate.is-loading .billing-estimate-amount { + color: var(--muted); +} + +.main.create-main .create-main-column { + container: create-main-column / inline-size; +} + +@media (min-width: 1181px) and (max-width: 1600px) { + .main.create-main .create-workbench-layout.with-template-column { + grid-template-columns: minmax(320px, 0.9fr) minmax(420px, 1.2fr) minmax(340px, 0.9fr); + } +} + +@container create-main-column (max-width: 400px) { + .generation-settings-row { + grid-template-columns: 1fr; + } +} + +@media (max-width: 720px) { + .generation-settings-row { + grid-template-columns: 1fr; + } + + .billing-estimate { + min-height: 76px; + } +} + +@media (max-width: 560px) { + .generation-settings-row { + gap: 10px; + margin-top: 12px; + padding-top: 12px; + } + + .generation-settings-row .inline-settings, + .main.create-main .generation-settings-row .inline-settings { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + } +} diff --git a/app/image-edit/page.tsx b/app/image-edit/page.tsx index 7c87560..3775762 100644 --- a/app/image-edit/page.tsx +++ b/app/image-edit/page.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; export default function ImageEditPage() { - redirect("/create?mode=inpaint"); + redirect("/create"); } diff --git a/app/layout.tsx b/app/layout.tsx index 3076a36..35c12df 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -7,7 +7,7 @@ import "./globals.css"; export const metadata: Metadata = { title: "智念AIGC平台", - description: "智念AIGC平台:统一创作图片、视频、局部重绘与智能超清。" + description: "智念AIGC平台:统一创作图片与视频。" }; export default async function RootLayout({ children }: { children: React.ReactNode }) { @@ -20,7 +20,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo strategy="beforeInteractive" dangerouslySetInnerHTML={{ __html: randomUUIDPolyfillScript }} /> - {children} + {children} ); diff --git a/app/logs/page.tsx b/app/logs/page.tsx index fcac528..f1be42c 100644 --- a/app/logs/page.tsx +++ b/app/logs/page.tsx @@ -1,9 +1,9 @@ import { LogManager } from "@/components/log-manager"; -import { requireAdminUser } from "@/lib/server/auth/current-user"; +import { requireSuperAdminUser } from "@/lib/server/auth/current-user"; export const dynamic = "force-dynamic"; export default async function LogsPage() { - await requireAdminUser(); + await requireSuperAdminUser(); return ; } diff --git a/app/settings/page.tsx b/app/settings/page.tsx index bc4eba2..cc1105e 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,9 +1,19 @@ import { SettingsPanel } from "@/components/settings-panel"; -import { requireAdminUser } from "@/lib/server/auth/current-user"; +import { hasSuperAdminAccess } from "@/lib/auth/permissions"; +import { requireAppSession } from "@/lib/server/auth/current-user"; export const dynamic = "force-dynamic"; export default async function SettingsPage() { - await requireAdminUser(); - return ; + const session = await requireAppSession(); + return ( +
+ {hasSuperAdminAccess(session.user) ? : ( +
+

系统设置

+

系统配置仅由超级管理员维护。

+
+ )} +
+ ); } diff --git a/app/usage/page.tsx b/app/usage/page.tsx new file mode 100644 index 0000000..9b8ed8e --- /dev/null +++ b/app/usage/page.tsx @@ -0,0 +1,10 @@ +import { UsageManager } from "@/components/usage-manager"; +import { hasSuperAdminAccess } from "@/lib/auth/permissions"; +import { requireAdminSession } from "@/lib/server/auth/current-user"; + +export const dynamic = "force-dynamic"; + +export default async function UsagePage() { + const session = await requireAdminSession(); + return ; +} diff --git a/components/account-manager.tsx b/components/account-manager.tsx index f7218e6..4688785 100644 --- a/components/account-manager.tsx +++ b/components/account-manager.tsx @@ -1,640 +1,331 @@ "use client"; -import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; -import { CirclePause, KeyRound, Loader2, Pencil, Plus, RefreshCw, Save, Search, Trash2, UserCheck, UserPlus, UserX } from "lucide-react"; -import { crossfadeIn, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion"; +import { useEffect, useMemo, useState } from "react"; +import { Building2, KeyRound, Loader2, Pencil, Plus, RefreshCw, ShieldCheck, Trash2, UserPlus } from "lucide-react"; +import { AccountSecurityPanel } from "@/components/account-security-panel"; -type OrganizationInfo = { - organizationId: string; - organizationName: string; -}; +type Role = "super_admin" | "organization_admin" | "user"; +type Status = "active" | "disabled"; -type OrganizationGroupInfo = { - groupId: string; - groupName: string; -}; - -type OrganizationRoleInfo = { - roleId: string; - roleName: string; -}; - -type OrganizationMemberInfo = { - memberId: string; - memberName: string; - memberStatus: string; - memberPhone: string; - organizationId: string; - memberRoleId?: string; - memberRoleName?: string; - memberGroupId?: string; - memberGroupName?: string; - memberUserId?: string; -}; - -type PageResult = { - records: T[]; - total: number; - size: number; - current: number; - pages: number; +type Organization = { id: string; name: string; status: Status }; +type Member = { + id: string; + phone: string; + displayName: string; + role: Role; + organizationId: string | null; + status: Status; + createdAt: string; + lastLoginAt: string | null; + lockedUntil: string | null; }; type AccountPayload = { - configured: boolean; - missing: string[]; - staffConfigured: boolean; - staffMissing: string[]; - selectedOrganizationId: string; - organizations: OrganizationInfo[]; - groups: OrganizationGroupInfo[]; - roles: OrganizationRoleInfo[]; - members: PageResult; - warnings?: string[]; - memberListAvailable?: boolean; - passwordManagementAvailable: boolean; + currentOrganizationId: string | null; + organizations: Organization[]; + members: Member[]; + canManageOrganizations: boolean; + canAssignOrganizationAdmin: boolean; }; -type MemberForm = { - organizationId: string; - username: string; - memberName: string; - memberPhone: string; - initialPassword: string; - mustChangePassword: boolean; - roleId: string; - groupId: string; +type AccountManagerProps = { + canManageAccounts: boolean; }; -type DepartmentForm = { - groupName: string; - groupDesc: string; +const roleLabels: Record = { + super_admin: "超级管理员", + organization_admin: "组织管理员", + user: "普通用户" }; -const blankForm: MemberForm = { - organizationId: "", - username: "", - memberName: "", - memberPhone: "", - initialPassword: "", - mustChangePassword: true, - roleId: "", - groupId: "" -}; - -const blankDepartmentForm: DepartmentForm = { - groupName: "", - groupDesc: "" -}; - -const STATUS_OPTIONS = [ - { value: "", label: "全部状态" }, - { value: "0", label: "在岗" }, - { value: "1", label: "休假" }, - { value: "2", label: "停用" } -]; - -export function AccountManager() { +export function AccountManager({ canManageAccounts }: AccountManagerProps) { const [payload, setPayload] = useState(null); - const [organizationId, setOrganizationId] = useState(""); - const [status, setStatus] = useState(""); - const [query, setQuery] = useState(""); - const [draftQuery, setDraftQuery] = useState(""); - const [pageNum, setPageNum] = useState(1); - const [form, setForm] = useState(blankForm); - const [departmentForm, setDepartmentForm] = useState(blankDepartmentForm); - const [editingId, setEditingId] = useState(null); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(canManageAccounts); const [saving, setSaving] = useState(false); - const [creatingGroup, setCreatingGroup] = useState(false); const [message, setMessage] = useState(null); const [error, setError] = useState(null); - const managerRef = useRef(null); - const listRef = useRef(null); - const feedbackRef = useRef(null); + const [selectedOrganizationId, setSelectedOrganizationId] = useState(""); + const [form, setForm] = useState({ phone: "", displayName: "", password: "", role: "user" as Role }); + const [organizationName, setOrganizationName] = useState(""); - const selectedOrganizationId = organizationId || payload?.selectedOrganizationId || ""; - const members = payload?.members.records || []; - const total = payload?.members.total || 0; - const pages = payload?.members.pages || 0; - const memberListAvailable = payload?.memberListAvailable !== false; - const warnings = payload?.warnings || []; - const canSubmit = Boolean(payload?.configured && selectedOrganizationId && form.memberName.trim() && - (editingId || (payload.staffConfigured && form.memberPhone.trim())) && - selectedRoleId(payload, form) && selectedGroupId(payload, form)); + const selectedOrganization = useMemo( + () => payload?.organizations.find((organization) => organization.id === selectedOrganizationId), + [payload?.organizations, selectedOrganizationId] + ); + const members = payload?.members || []; - const currentOrganization = useMemo(() => { - return payload?.organizations.find((item) => item.organizationId === selectedOrganizationId); - }, [payload?.organizations, selectedOrganizationId]); - - useEffect(() => { - void loadAccounts(); - }, [organizationId, pageNum, query, status]); - - useEffect(() => { - return runScopedMotion(managerRef, (scope) => revealChildren(scope)); - }, []); - - useEffect(() => { - crossfadeIn(listRef.current); - }, [members.length, pageNum, query, status, selectedOrganizationId]); - - useEffect(() => { - pulseFeedback(feedbackRef.current); - }, [error, message]); - - async function loadAccounts() { + async function loadAccounts(organizationId = selectedOrganizationId) { + if (!canManageAccounts) return; setLoading(true); setError(null); try { - const params = new URLSearchParams({ - pageNum: String(pageNum), - pageSize: "10" - }); - if (organizationId) params.set("organizationId", organizationId); - if (query) params.set("memberName", query); - if (status) params.set("memberStatus", status); - const response = await fetch(`/api/admin/accounts?${params.toString()}`, { cache: "no-store" }); - const nextPayload = await response.json() as AccountPayload & { error?: string }; - if (!response.ok) throw new Error(nextPayload.error || "读取账号数据失败"); - setPayload(nextPayload); - syncSelectedOrganization(nextPayload); - syncFormDefaults(nextPayload); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + const query = organizationId ? `?organizationId=${encodeURIComponent(organizationId)}` : ""; + const response = await fetch(`/api/admin/accounts${query}`, { cache: "no-store" }); + const next = await response.json() as AccountPayload & { error?: string }; + if (!response.ok) throw new Error(next.error || "账号列表加载失败。"); + setPayload(next); + setSelectedOrganizationId(next.currentOrganizationId || ""); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : String(requestError)); } finally { setLoading(false); } } - function syncSelectedOrganization(nextPayload: AccountPayload) { - if (!organizationId && nextPayload.selectedOrganizationId) { - setOrganizationId(nextPayload.selectedOrganizationId); - } - } + useEffect(() => { + if (canManageAccounts) void loadAccounts(); + else setLoading(false); + }, [canManageAccounts]); - function syncFormDefaults(nextPayload: AccountPayload) { - setForm((current) => { - const nextOrganizationId = nextPayload.selectedOrganizationId || current.organizationId; - return { - ...current, - organizationId: nextOrganizationId, - roleId: nextPayload.roles.some((role) => role.roleId === current.roleId) - ? current.roleId - : nextPayload.roles[0]?.roleId || "", - groupId: nextPayload.groups.some((group) => group.groupId === current.groupId) - ? current.groupId - : nextPayload.groups[0]?.groupId || "" - }; - }); - } - - function submitSearch(event: FormEvent) { - event.preventDefault(); - setPageNum(1); - setQuery(draftQuery.trim()); - } - - async function submitMember(event: FormEvent) { + async function createUser(event: React.FormEvent) { event.preventDefault(); setSaving(true); - setError(null); setMessage(null); + setError(null); try { - const roleId = selectedRoleId(payload, form); - const groupId = selectedGroupId(payload, form); - const body = editingId ? { - memberId: editingId, - memberName: form.memberName.trim(), - roleId, - groupId - } : { - organizationId: selectedOrganizationId, - username: form.username.trim(), - phone: form.memberPhone.trim(), - name: form.memberName.trim(), - initialPassword: form.initialPassword.trim(), - mustChangePassword: form.mustChangePassword, - memberName: form.memberName.trim(), - memberPhone: form.memberPhone.trim(), - roleId, - groupId - }; const response = await fetch("/api/admin/accounts", { - method: editingId ? "PATCH" : "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body) - }); - const result = await response.json(); - if (!response.ok) throw new Error(result.error || "保存成员失败"); - const initialPassword = result.user?.initialPassword; - const successMessage = editingId - ? "成员信息已更新。" - : initialPassword - ? `成员已创建,初始密码:${initialPassword}` - : "成员已创建。"; - setMessage(!editingId && !memberListAvailable - ? `${successMessage} 成员列表暂不可用,上游授权后可在列表看到。` - : successMessage); - resetForm(); - await loadAccounts(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setSaving(false); - } - } - - async function changeStatus(memberId: string, memberStatus: "0" | "1" | "2") { - await mutateMember("PUT", { memberId, memberStatus }, "成员状态已更新。"); - } - - async function removeMember(memberId: string) { - if (!window.confirm("确定删除这个成员?")) return; - await mutateMember("DELETE", { memberId }, "成员已删除。"); - } - - async function resetPassword(member: OrganizationMemberInfo) { - if (!member.memberUserId) { - setError("该成员还没有绑定企业端用户,不能重置密码。"); - return; - } - const newPassword = window.prompt(`请输入 ${member.memberName} 的新密码`); - if (!newPassword) return; - setSaving(true); - setError(null); - setMessage(null); - try { - const response = await fetch("/api/admin/accounts/password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - userId: member.memberUserId, - newPassword, - mustChangePassword: true + phone: form.phone, + displayName: form.displayName, + password: form.password, + role: form.role, + organizationId: selectedOrganization?.id }) }); - const result = await response.json(); - if (!response.ok) throw new Error(result.error || "重置密码失败"); - setMessage("密码已重置,用户下次登录需要修改密码。"); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + const result = await response.json().catch(() => ({})) as { error?: string }; + if (!response.ok) throw new Error(result.error || "账号创建失败。"); + setMessage("账号已创建。"); + setForm({ phone: "", displayName: "", password: "", role: "user" }); + await loadAccounts(selectedOrganization?.id || ""); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : String(requestError)); } finally { setSaving(false); } } - async function mutateMember(method: "PUT" | "DELETE", body: Record, successMessage: string) { + async function createOrganization(event: React.FormEvent) { + event.preventDefault(); + if (!organizationName.trim()) return; setSaving(true); + setMessage(null); + setError(null); + try { + const response = await fetch("/api/admin/organizations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: organizationName }) + }); + const result = await response.json().catch(() => ({})) as { error?: string }; + if (!response.ok) throw new Error(result.error || "组织创建失败。"); + setOrganizationName(""); + setMessage("组织已创建。"); + await loadAccounts(); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : String(requestError)); + } finally { + setSaving(false); + } + } + + async function renameOrganization(organization: Organization) { + const name = window.prompt("请输入新的组织名称", organization.name)?.trim(); + if (!name || name === organization.name) return; + await mutateOrganization("PATCH", { organizationId: organization.id, name }, "组织名称已更新。"); + } + + async function toggleOrganization(organization: Organization) { + const status: Status = organization.status === "active" ? "disabled" : "active"; + await mutateOrganization("PATCH", { organizationId: organization.id, status }, status === "active" ? "组织已启用。" : "组织已停用。登录和新账号创建会受到限制。"); + } + + async function deleteOrganization(organization: Organization) { + if (!window.confirm(`确定删除“${organization.name}”吗?只有没有任何账号的组织才可以删除。`)) return; + await mutateOrganization("DELETE", { organizationId: organization.id }, "组织已删除。"); + } + + async function mutateOrganization(method: string, body: Record, successMessage: string) { setError(null); setMessage(null); try { - const response = await fetch("/api/admin/accounts", { + const response = await fetch("/api/admin/organizations", { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); - const result = await response.json(); - if (!response.ok) throw new Error(result.error || "成员操作失败"); + const result = await response.json().catch(() => ({})) as { error?: string }; + if (!response.ok) throw new Error(result.error || "组织操作失败。"); setMessage(successMessage); - await loadAccounts(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setSaving(false); + if (method === "DELETE") { + setSelectedOrganizationId(""); + await loadAccounts(""); + } else { + await loadAccounts(selectedOrganizationId); + } + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : String(requestError)); } } - async function submitGroup(event: FormEvent) { - event.preventDefault(); - if (!selectedOrganizationId) { - setError("请先选择组织。"); - return; - } - const groupName = departmentForm.groupName.trim(); - if (!groupName) { - setError("部门名称不能为空。"); - return; - } - setCreatingGroup(true); + async function changeStatus(member: Member) { + const nextStatus: Status = member.status === "active" ? "disabled" : "active"; + await mutateMember("PUT", { userId: member.id, status: nextStatus }, nextStatus === "active" ? "账号已启用。" : "账号已停用。"); + } + + async function resetPassword(member: Member) { + const password = window.prompt(`为 ${member.displayName} 设置新密码(至少 8 位)`); + if (!password) return; + await mutateMember("POST", { userId: member.id, newPassword: password }, "密码已重置。", "/api/admin/accounts/password"); + } + + async function changeRole(member: Member, nextRole: Role) { + await mutateMember("PATCH", { userId: member.id, role: nextRole }, "账号角色已更新。"); + } + + async function deleteMember(member: Member) { + if (!window.confirm(`确定彻底删除“${member.displayName}”吗?素材和任务会转移到组织归档账号,用量记录保留。`)) return; + await mutateMember("DELETE", { userId: member.id }, "账号已删除。"); + } + + async function mutateMember(method: string, body: Record, successMessage: string, endpoint = "/api/admin/accounts") { setError(null); setMessage(null); try { - const response = await fetch("/api/admin/accounts/groups", { - method: "POST", + const response = await fetch(endpoint, { + method, headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - organizationId: selectedOrganizationId, - groupName, - groupDesc: departmentForm.groupDesc.trim() - }) + body: JSON.stringify(body) }); - const result = await response.json() as { error?: string; group?: OrganizationGroupInfo | null }; - if (!response.ok) throw new Error(result.error || "创建部门失败"); - setDepartmentForm(blankDepartmentForm); - await loadAccounts(); - if (result.group?.groupId) { - setForm((current) => ({ ...current, groupId: result.group?.groupId || current.groupId })); - } - setMessage("部门已创建。"); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setCreatingGroup(false); + const result = await response.json().catch(() => ({})) as { error?: string }; + if (!response.ok) throw new Error(result.error || "操作失败。"); + setMessage(successMessage); + await loadAccounts(selectedOrganizationId); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : String(requestError)); } } - function editMember(member: OrganizationMemberInfo) { - setEditingId(member.memberId); - setForm({ - organizationId: member.organizationId || selectedOrganizationId, - username: "", - memberName: member.memberName || "", - memberPhone: member.memberPhone || "", - initialPassword: "", - mustChangePassword: true, - roleId: member.memberRoleId || payload?.roles[0]?.roleId || "", - groupId: member.memberGroupId || payload?.groups[0]?.groupId || "" - }); - } - - function resetForm() { - setEditingId(null); - setForm({ - organizationId: selectedOrganizationId, - username: "", - memberName: "", - memberPhone: "", - initialPassword: "", - mustChangePassword: true, - roleId: payload?.roles[0]?.roleId || "", - groupId: payload?.groups[0]?.groupId || "" - }); - } - return ( -
-
-
-

账号管理

+
+
+
+

账户与访问

-
- {payload?.organizations.length || 0} 组织 - {total} 成员 - {payload?.configured ? "已接入" : "待配置"} -
-
+ -
- -
-
+ {message ?
{message}
: null} + {error ?
{error}
: null} - {error || message ? ( -
- {error ?
{error}
: null} - {message ?
{message}
: null} -
- ) : null} +
+ - {warnings.length ? ( -
- {accountWarningText(warnings[0])} -
- ) : null} - - {!payload?.configured ? ( -
- 组织接口未配置:需要组织能力网关地址,用于读取组织、部门、角色和成员列表。 -
- ) : !payload.passwordManagementAvailable ? ( -
- 企业端用户接口未配置:{payload?.staffMissing.join("、") || "ZHINIAN_STAFF_API_BASE_URL、ZHINIAN_ORG_TENANT_ID"}。配置后可创建企业用户并重置密码。 -
- ) : null} - -
-
-
-

部门管理

-

{currentOrganization?.organizationName || selectedOrganizationId || "请选择组织"}

-
-
-
- - - -
-
- -
-
-
-

{editingId ? "编辑成员" : "新建用户"}

-

{currentOrganization?.organizationName || selectedOrganizationId || "请选择组织"}

-
- {editingId ? ( - - ) : null} -
-
- - - - - - - -
- -
-
-
- -
- {loading && !payload ? ( -
- - 正在读取账号 -
- ) : members.length ? ( -
-
- 成员 - 角色 / 部门 - 状态 - 操作 -
- {members.map((member) => ( -
-
- {member.memberName} - {member.memberPhone || member.memberId} + {canManageAccounts ? ( + <> +
+
+
+

组织与成员管理

-
- {member.memberRoleName || "-"} - {member.memberGroupName || "-"} +
+ +
+
+
+

创建账号

+
+
+ + + + {payload?.canAssignOrganizationAdmin ? : null} + + +
- {memberStatusLabel(member.memberStatus)} -
- - - - - + + {payload?.canManageOrganizations ? ( +
+
+

组织目录

+
+
+ + +
+
{payload.organizations.map((organization) => ( +
+
{organization.name}{organization.status === "active" ? "启用" : "停用"}
+
+ + + +
+
+ ))}
+
+ ) : null} +
+
+ +
+
+
+

{selectedOrganization?.name || "全平台账号"}

+
+
+ +
- ))} -
- ) : ( -
{accountEmptyText(payload)}
- )} - {pages > 1 ? ( -
- - {pageNum} / {pages} - -
+ + {loading ? : members.length ? ( +
+
账号角色状态操作
+ {members.map((member) => ( +
+
+ +
{member.displayName}{member.phone}
+
+
{payload?.canAssignOrganizationAdmin ? : {roleLabels[member.role]}}
+
{member.status === "active" ? "启用" : "停用"}
+
+ + + +
+
+ ))} +
+ ) :
当前组织还没有账号。
} +
+ ) : null}
); } -function selectedRoleId(payload: AccountPayload | null, form: MemberForm): string { - if (payload?.roles.some((role) => role.roleId === form.roleId)) return form.roleId; - return payload?.roles[0]?.roleId || ""; +function AccountLoading() { + return ( +
+ + 正在加载账号 +
+ ); } -function selectedGroupId(payload: AccountPayload | null, form: MemberForm): string { - if (payload?.groups.some((group) => group.groupId === form.groupId)) return form.groupId; - return payload?.groups[0]?.groupId || ""; -} - -function memberStatusLabel(status: string): string { - if (status === "0") return "在岗"; - if (status === "1") return "休假"; - if (status === "2") return "停用"; - return status || "未知"; -} - -function accountEmptyText(payload: AccountPayload | null): string { - if (!payload?.configured) return "组织接口配置后显示成员"; - if (payload.memberListAvailable === false) return "成员列表暂不可用,新增账号仍可使用"; - return "暂无成员"; -} - -function accountWarningText(message: string): string { - if (/仅管理员角色|上游.*管理员|hotelStaff 管理员|管理员角色/i.test(message)) { - return "成员列表需要认证中心管理员角色 1。当前账号已允许进入本平台后台,但上游 hotelStaff 暂未授权成员列表查询。"; - } - if (/成员列表|organizationMemberList|ZHINIAN_ORG_MEMBER_LIST_PATH/i.test(message)) { - return "成员列表待接入:当前组织服务还没有开放成员查询,新增账号仍可使用。"; - } - return message; +function accountInitial(value: string) { + return value.trim().slice(0, 2) || "账"; } diff --git a/components/account-security-panel.tsx b/components/account-security-panel.tsx new file mode 100644 index 0000000..a4da557 --- /dev/null +++ b/components/account-security-panel.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useState } from "react"; +import { KeyRound, Loader2 } from "lucide-react"; + +export function AccountSecurityPanel() { + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + async function submit(event: React.FormEvent) { + event.preventDefault(); + setMessage(null); + setError(null); + if (newPassword.length < 8) { + setError("新密码至少需要 8 位。"); + return; + } + if (newPassword !== confirmPassword) { + setError("两次输入的新密码不一致。"); + return; + } + setSaving(true); + try { + const response = await fetch("/api/auth/password/change", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ currentPassword, newPassword, confirmPassword }) + }); + const payload = await response.json().catch(() => ({})) as { error?: string }; + if (!response.ok) throw new Error(payload.error || "密码修改失败。"); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setMessage("密码已修改。"); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : String(requestError)); + } finally { + setSaving(false); + } + } + + return ( +
+
+
+

修改密码

+
+ +
+ + {message ?
{message}
: null} + {error ?
{error}
: null} + +
+
+ + + +
+
+ +
+
+
+ ); +} diff --git a/components/account-usage-menu.tsx b/components/account-usage-menu.tsx new file mode 100644 index 0000000..fdaaf5f --- /dev/null +++ b/components/account-usage-menu.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { ChevronDown, Clock3, Loader2, UserCircle } from "lucide-react"; +import type { AuthUser } from "@/lib/auth/session"; +import { + USAGE_PRESET_OPTIONS, + type PersonalUsageReport, + type UsagePreset +} from "@/lib/usage"; + +export function AccountUsageMenu({ user }: { user: AuthUser }) { + const [open, setOpen] = useState(false); + const [preset, setPreset] = useState("month"); + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const containerRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onPointerDown = (event: PointerEvent) => { + if (!containerRef.current?.contains(event.target as Node)) setOpen(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + useEffect(() => { + if (!open) return; + const controller = new AbortController(); + setLoading(true); + setError(null); + fetch(`/api/usage?preset=${preset}`, { cache: "no-store", signal: controller.signal }) + .then(async (response) => { + const payload = await response.json() as PersonalUsageReport & { error?: string }; + if (!response.ok) throw new Error(payload.error || "读取用量失败"); + setReport(payload); + }) + .catch((nextError) => { + if (nextError instanceof DOMException && nextError.name === "AbortError") return; + setError(nextError instanceof Error ? nextError.message : String(nextError)); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [open, preset]); + + return ( +
+ + + {open ? ( + + ) : null} +
+ ); +} + +function formatUsageTime(value: string): string { + return new Intl.DateTimeFormat("zh-CN", { + timeZone: "Asia/Shanghai", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false + }).format(new Date(value)); +} diff --git a/components/app-shell.tsx b/components/app-shell.tsx index c3856dc..ae636c7 100644 --- a/components/app-shell.tsx +++ b/components/app-shell.tsx @@ -5,37 +5,41 @@ import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { - Archive, + BarChart3, ShieldCheck, LogIn, LogOut, ScrollText, Settings, Sparkles, - UserCircle + WalletCards } from "lucide-react"; import clsx from "clsx"; import { revealChildren, runScopedMotion } from "@/lib/ui/motion"; import type { AuthUser } from "@/lib/auth/session"; +import { AccountUsageMenu } from "@/components/account-usage-menu"; const nav = [ { href: "/create", label: "创作", icon: Sparkles }, - { href: "/assets", label: "结果", icon: Archive }, - { href: "/logs", label: "日志", icon: ScrollText, adminOnly: true }, - { href: "/settings", label: "设置", icon: Settings, adminOnly: true }, - { href: "/accounts", label: "账号", icon: ShieldCheck, adminOnly: true } + { href: "/logs", label: "日志", icon: ScrollText, requiredRole: "super" as const }, + { href: "/settings", label: "设置", icon: Settings, requiredRole: "super" as const }, + { href: "/accounts", label: "账号", icon: ShieldCheck }, + { href: "/usage", label: "用量", icon: BarChart3, requiredRole: "admin" as const }, + { href: "/billing", label: "计费", icon: WalletCards } ]; export function AppShell({ children, user, authRequired, - isAdmin + isAdmin, + isSuperAdmin }: { children: React.ReactNode; user?: AuthUser | null; authRequired?: boolean; isAdmin?: boolean; + isSuperAdmin?: boolean; }) { const pathname = usePathname(); const shellRef = useRef(null); @@ -60,7 +64,7 @@ export function AppShell({
@@ -261,7 +262,7 @@ export function AssetManager({ initialView = "assets", initialTaskId }: AssetMan

{job.prompt?.slice(0, 90) || capabilityLabel(job.capability)}

{capabilityLabel(job.capability)} / {job.reqKey}

-

{durationLabel(job, durationNow)} / 输入 {job.inputAssetIds.length || job.inputUrls.length} 个,输出 {job.outputAssetIds.length} 个

+

{durationLabel(job, durationNow)} / 输入 {job.inputAssetIds.length || job.inputUrls.length} 个,输出 {job.outputAssetIds.length} 个 / {billingLabel(job)}

{statusLabel(job.status)} @@ -353,6 +354,10 @@ function JobDetails({ job, now }: { job: GenerationJob; now: number }) {
输入/输出
{job.inputAssetIds.length || job.inputUrls.length} / {job.outputAssetIds.length}
+
+
扣费
+
{billingLabel(job)}
+
{job.error ? (
错误
@@ -446,9 +451,8 @@ function kindLabel(asset: Asset) { function sourceLabel(source: Asset["source"]) { if (source === "upload") return "上传"; if (source === "generated") return "生成"; - if (source === "edited") return "重绘"; - if (source === "upscaled") return "超清"; - if (source === "seed") return "示例"; + if (source === "edited" || source === "upscaled") return "历史结果"; + if (source === "seed") return "素材"; return "外部"; } @@ -471,17 +475,24 @@ function providerLabel(provider: GenerationJob["provider"]) { if (provider === "evolink") return "EvoLink"; if (provider === "seedance") return "Seedance"; if (provider === "bailian") return "阿里云百炼"; - return "Mock"; + return "系统生成"; } function capabilityLabel(capability?: GenerationJob["capability"]) { if (capability === "image.generate") return "图片生成"; - if (capability === "image.inpaint") return "局部重绘"; - if (capability === "image.upscale") return "智能超清"; if (capability === "video.generate") return "视频生成"; return "生成任务"; } +function billingLabel(job: GenerationJob) { + if (!job.billing) return job.provider === "mock" ? "免计费" : "未计费"; + if (job.billing.quotaExempt) return `超管费用 ${formatBillingAmount(job.billing.amountFen)}(不计额度)`; + if (job.billing.status === "refunded") return `已退款 ${formatBillingAmount(job.billing.amountFen)}`; + if (job.billing.status === "pending") return `预计 ${formatBillingAmount(job.billing.amountFen)}`; + if (job.billing.status === "not_charged") return "未扣费"; + return `扣费 ${formatBillingAmount(job.billing.amountFen)}`; +} + function jobPath(job: GenerationJob) { return job.capability === "video.generate" ? `/api/generations/video/${job.id}` : `/api/generations/image/${job.id}`; } diff --git a/components/auth-login-panel.tsx b/components/auth-login-panel.tsx index aae5927..f24d2f7 100644 --- a/components/auth-login-panel.tsx +++ b/components/auth-login-panel.tsx @@ -3,7 +3,6 @@ import { useEffect, useRef, useState } from "react"; import type { FormEvent } from "react"; import Image from "next/image"; -import Link from "next/link"; import { Loader2, LogIn } from "lucide-react"; import { pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion"; @@ -13,10 +12,7 @@ export function AuthLoginPanel({ message, missing, title = "账户登录", - submitLabel = "登录", - authMode, - alternateHref, - alternateLabel + submitLabel = "登录" }: { next: string; configured: boolean; @@ -24,11 +20,8 @@ export function AuthLoginPanel({ missing?: string[]; title?: string; submitLabel?: string; - authMode?: string; - alternateHref?: string; - alternateLabel?: string; }) { - const [username, setUsername] = useState(""); + const [phone, setPhone] = useState(""); const [password, setPassword] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -47,11 +40,17 @@ export function AuthLoginPanel({ async function submit(event: FormEvent) { event.preventDefault(); if (!configured || submitting) return; + const formData = new FormData(event.currentTarget); + const submittedPhone = formStringValue(formData.get("phone")); + const submittedPassword = formStringValue(formData.get("password")); + if (!submittedPhone || !submittedPassword) { + setError("手机号和密码不能为空。"); + return; + } setSubmitting(true); setError(null); try { - const payload: Record = { username, password, next }; - if (authMode) payload.authMode = authMode; + const payload: Record = { phone: submittedPhone, password: submittedPassword, next }; const response = await fetch("/api/auth/password", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -91,19 +90,24 @@ export function AuthLoginPanel({ - - {alternateHref && alternateLabel ? ( - - {alternateLabel} - - ) : null}
); } + +function formStringValue(value: FormDataEntryValue | null): string { + return typeof value === "string" ? value.trim() : ""; +} diff --git a/components/billing-manager.tsx b/components/billing-manager.tsx new file mode 100644 index 0000000..bf02935 --- /dev/null +++ b/components/billing-manager.tsx @@ -0,0 +1,767 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEvent, type ReactNode } from "react"; +import { + ArrowUpRight, + Banknote, + Building2, + Check, + ChevronRight, + CircleDollarSign, + Landmark, + Loader2, + Pencil, + Plus, + ReceiptText, + RefreshCw, + Settings2, + UsersRound, + WalletCards, + X +} from "lucide-react"; +import { billingUnitLabel, formatBillingAmount } from "@/lib/billing"; +import { pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion"; +import type { BillingAccountConfig, BillingParameterDimension, BillingParameterTier, BillingPriceRule, OrganizationWallet } from "@/lib/types"; + +type BillingPayload = { + organization: { id: string; name: string }; + billingAccount: BillingAccountConfig; + wallet: OrganizationWallet; + ledger: LedgerEntry[]; + summary: LedgerSummary; + personal: LedgerSummary; +}; + +type LedgerEntry = { + id: string; + organizationId: string; + accountId?: string; + jobId?: string; + kind: "recharge" | "charge" | "refund" | "adjustment"; + deltaFen: number; + balanceAfterFen: number; + description: string; + metadata: Record; + createdAt: string; +}; + +type LedgerSummary = { + rechargeFen: number; + chargedFen: number; + refundedFen: number; + netConsumedFen: number; +}; + +type AdminMember = { + id: string; + displayName: string; + phone: string; + role: "super_admin" | "organization_admin" | "user"; + organizationId: string | null; + status: "active" | "disabled"; +}; + +type AdminPayload = { + billingAccount: BillingAccountConfig; + organizations: Array<{ + id: string; + name: string; + status: string; + wallet: OrganizationWallet; + }>; + members: AdminMember[]; + ledger: LedgerEntry[]; + priceRules: BillingPriceRule[]; +}; + +type BillingTabId = "overview" | "ledger" | "pricing" | "balance" | "account"; + +type BillingTabDefinition = { + id: BillingTabId; + label: string; +}; + +type AdjustmentDraft = { + organizationId: string; + direction: "credit" | "debit"; + amountYuan: string; + note: string; +}; + +type PriceEditTarget = { + dimensionKey?: string; + tierValue?: string; + label: string; + markupMultiplier: number; +}; + +type PriceEditState = { + rule: BillingPriceRule; + target: PriceEditTarget; +}; + +const emptyAccountDraft: BillingAccountConfig = { + accountName: "", + bankName: "", + accountNumber: "", + contact: "" +}; + +const emptyAdjustmentDraft: AdjustmentDraft = { + organizationId: "", + direction: "credit", + amountYuan: "", + note: "" +}; + +export function BillingManager({ isSuperAdmin }: { isSuperAdmin: boolean }) { + const [billing, setBilling] = useState(null); + const [admin, setAdmin] = useState(null); + const [accountDraft, setAccountDraft] = useState(emptyAccountDraft); + const [adjustmentDraft, setAdjustmentDraft] = useState(emptyAdjustmentDraft); + const [activeTab, setActiveTab] = useState("overview"); + const [editingAccount, setEditingAccount] = useState(false); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [priceEditState, setPriceEditState] = useState(null); + const managerRef = useRef(null); + const feedbackRef = useRef(null); + + const tabs = useMemo(() => isSuperAdmin ? [ + { id: "overview", label: "概览" }, + { id: "pricing", label: "价格与计费" }, + { id: "balance", label: "余额与上账" }, + { id: "account", label: "收款设置" } + ] : [ + { id: "overview", label: "概览" }, + { id: "ledger", label: "账务流水" } + ], [isSuperAdmin]); + + const selectedAdjustmentOrganization = useMemo( + () => admin?.organizations.find((organization) => organization.id === adjustmentDraft.organizationId), + [admin?.organizations, adjustmentDraft.organizationId] + ); + + useEffect(() => { + void load(); + setActiveTab("overview"); + }, [isSuperAdmin]); + + useEffect(() => runScopedMotion(managerRef, (scope) => revealChildren(scope)), []); + + useEffect(() => { + pulseFeedback(feedbackRef.current); + }, [error, notice]); + + async function load() { + setLoading(true); + setError(null); + try { + if (isSuperAdmin) { + const adminResponse = await fetch("/api/admin/billing", { cache: "no-store" }); + const adminPayload = await readApiPayload(adminResponse); + if (!adminResponse.ok) throw new Error(adminPayload.error || "读取超管计费数据失败"); + setAdmin(adminPayload); + setBilling(buildAdminBillingPayload(adminPayload)); + setAccountDraft({ ...emptyAccountDraft, ...adminPayload.billingAccount }); + setAdjustmentDraft((current) => ({ + ...current, + organizationId: current.organizationId || adminPayload.organizations[0]?.id || "" + })); + } else { + const billingResponse = await fetch("/api/billing", { cache: "no-store" }); + const billingPayload = await readApiPayload(billingResponse); + if (!billingResponse.ok) throw new Error(billingPayload.error || "读取计费数据失败"); + setBilling(billingPayload); + } + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : String(nextError)); + } finally { + setLoading(false); + } + } + + async function saveBillingAccount(event: FormEvent) { + event.preventDefault(); + setSaving(true); + setError(null); + setNotice(null); + try { + const response = await fetch("/api/admin/billing/account", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(accountDraft) + }); + const payload = await readApiPayload<{ billingAccount?: BillingAccountConfig }>(response); + if (!response.ok) throw new Error(payload.error || "保存收款账户失败"); + setAccountDraft({ ...emptyAccountDraft, ...(payload.billingAccount || accountDraft) }); + setEditingAccount(false); + setNotice("对公收款账户已更新,成员将看到最新信息。"); + await load(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : String(nextError)); + } finally { + setSaving(false); + } + } + + async function submitAdjustment(event: FormEvent) { + event.preventDefault(); + setSaving(true); + setError(null); + setNotice(null); + try { + const response = await fetch("/api/admin/billing/adjustments", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + organizationId: adjustmentDraft.organizationId, + direction: adjustmentDraft.direction, + amountYuan: adjustmentDraft.amountYuan, + note: adjustmentDraft.note + }) + }); + const payload = await readApiPayload<{ wallet?: OrganizationWallet }>(response); + if (!response.ok) throw new Error(payload.error || "余额调整失败"); + setAdjustmentDraft((current) => ({ ...current, amountYuan: "", note: "" })); + setNotice(`${adjustmentDraft.direction === "credit" ? "上账" : "扣减"}已完成,${selectedAdjustmentOrganization?.name || "组织"}余额为 ${formatBillingAmount(payload.wallet?.balanceFen || 0)}。`); + await load(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : String(nextError)); + } finally { + setSaving(false); + } + } + + async function savePriceMultiplier(markupMultiplier: string) { + if (!priceEditState) return; + const { rule, target } = priceEditState; + const saved = await mutate(`/api/admin/billing/prices/${encodeURIComponent(rule.id)}`, { + markupMultiplier, + ...(target.dimensionKey && target.tierValue !== undefined ? { dimensionKey: target.dimensionKey, tierValue: target.tierValue } : {}) + }, `${target.label}倍率已更新。`); + if (saved) setPriceEditState(null); + } + + async function mutate(url: string, body: Record, successMessage: string, method = "PATCH"): Promise { + setSaving(true); + setError(null); + setNotice(null); + try { + const response = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + const payload = await readApiPayload>(response); + if (!response.ok) throw new Error(payload.error || "保存失败"); + setNotice(successMessage); + await load(); + return true; + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : String(nextError)); + return false; + } finally { + setSaving(false); + } + } + + return ( +
+
+
+
+

计费中心

+ {isSuperAdmin ? "超级管理员" : "成员视图"} +
+
+ +
+ +
+ {error ?
{error}
: null} + {notice ?
{notice}
: null} +
+ + {billing ? : loading ? : null} + + {billing ? : null} + + {!isSuperAdmin && billing ? ( + <> + {activeTab === "overview" ? <> + setActiveTab("ledger")} />} /> + : null} + {activeTab === "ledger" ? 最近 {Math.min(billing.ledger.length, 100)} 条} /> : null} + + ) : null} + + {isSuperAdmin && admin && billing ? ( + <> + {activeTab === "overview" ? : null} + {activeTab === "pricing" ? setPriceEditState({ rule, target })} /> : null} + {activeTab === "balance" ? : null} + {activeTab === "account" ? setEditingAccount(true)} onCancel={() => { setEditingAccount(false); setAccountDraft({ ...emptyAccountDraft, ...admin.billingAccount }); }} onChange={(key, value) => setAccountDraft((current) => ({ ...current, [key]: value }))} onSubmit={saveBillingAccount} /> : null} + + ) : null} + {priceEditState ? setPriceEditState(null)} onSubmit={savePriceMultiplier} /> : null} +
+ ); +} + +async function readApiPayload>(response: Response): Promise { + const text = await response.text(); + if (!text.trim()) { + return { error: response.status >= 500 ? "计费服务暂时不可用,请检查服务端日志和 Supabase 计费表结构。" : "服务器未返回有效内容。" } as T & { error?: string }; + } + try { + return JSON.parse(text) as T & { error?: string }; + } catch { + return { + error: response.status >= 500 + ? "计费服务返回了服务器错误,请检查服务端日志;若使用 Supabase,请先执行 supabase/schema.sql。" + : `服务器返回了无效响应(HTTP ${response.status})。` + } as T & { error?: string }; + } +} + +function BillingTabs({ tabs, activeTab, onChange }: { tabs: BillingTabDefinition[]; activeTab: BillingTabId; onChange: (id: BillingTabId) => void }) { + const tabRefs = useRef>([]); + + function moveTab(event: KeyboardEvent, index: number) { + if (!(["ArrowLeft", "ArrowRight", "Home", "End"] as string[]).includes(event.key)) return; + event.preventDefault(); + const nextIndex = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : (index + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length; + const next = tabs[nextIndex]; + if (!next) return; + onChange(next.id); + window.requestAnimationFrame(() => tabRefs.current[nextIndex]?.focus()); + } + + return ; +} + +function buildAdminBillingPayload(admin: AdminPayload): BillingPayload { + const wallet = admin.organizations.reduce((total, organization) => ({ + organizationId: "platform", + balanceFen: total.balanceFen + organization.wallet.balanceFen, + totalRechargedFen: total.totalRechargedFen + organization.wallet.totalRechargedFen, + totalChargedFen: total.totalChargedFen + organization.wallet.totalChargedFen, + updatedAt: total.updatedAt > organization.wallet.updatedAt ? total.updatedAt : organization.wallet.updatedAt + }), { + organizationId: "platform", + balanceFen: 0, + totalRechargedFen: 0, + totalChargedFen: 0, + updatedAt: new Date(0).toISOString() + }); + return { + organization: { id: "platform", name: "全平台组织" }, + billingAccount: admin.billingAccount, + wallet, + ledger: admin.ledger, + summary: summarizeLedger(admin.ledger), + personal: emptyLedgerSummary() + }; +} + +function summarizeLedger(entries: LedgerEntry[]): LedgerSummary { + const rechargeFen = entries + .filter((entry) => entry.kind === "recharge" || entry.kind === "adjustment" && entry.deltaFen > 0) + .reduce((sum, entry) => sum + Math.max(0, entry.deltaFen), 0); + const chargedFen = entries + .filter((entry) => entry.kind === "charge") + .reduce((sum, entry) => sum + Math.max(0, -entry.deltaFen), 0); + const refundedFen = entries + .filter((entry) => entry.kind === "refund") + .reduce((sum, entry) => sum + Math.max(0, entry.deltaFen), 0); + return { rechargeFen, chargedFen, refundedFen, netConsumedFen: Math.max(0, chargedFen - refundedFen) }; +} + +function emptyLedgerSummary(): LedgerSummary { + return { rechargeFen: 0, chargedFen: 0, refundedFen: 0, netConsumedFen: 0 }; +} + +function AdminOverview({ billing, admin, onChangeTab }: { billing: BillingPayload; admin: AdminPayload; onChangeTab: (id: BillingTabId) => void }) { + const enabledRules = admin.priceRules.filter((rule) => rule.enabled).length; + return
+
+ + + +
+ 组织流水} /> +
; +} + +function PriceManagement({ admin, saving, onEdit }: { admin: AdminPayload; saving: boolean; onEdit: (rule: BillingPriceRule, target: PriceEditTarget) => void }) { + return
+

价格目录

标准成本×倍率=用户价
+ +
; +} + +function BalanceManagement({ admin, adjustmentDraft, setAdjustmentDraft, selectedOrganization, saving, onSubmit }: { admin: AdminPayload; adjustmentDraft: AdjustmentDraft; setAdjustmentDraft: (draft: AdjustmentDraft) => void; selectedOrganization?: AdminPayload["organizations"][number]; saving: boolean; onSubmit: (event: FormEvent) => void }) { + return
+ {admin.organizations.length} 个组织} /> +
+

组织钱包

+ +
+

组织成员用量

余额和上账都归组织所有,成员数据仅用于查看各自的额度消耗。

+
; +} + +function AdjustmentForm({ draft, selectedOrganization, organizations, saving, onChange, onSubmit }: { draft: AdjustmentDraft; selectedOrganization?: AdminPayload["organizations"][number]; organizations: AdminPayload["organizations"]; saving: boolean; onChange: (draft: AdjustmentDraft) => void; onSubmit: (event: FormEvent) => void }) { + return
+
手工上账 / 余额调整
+
组织当前余额 {formatBillingAmount(selectedOrganization?.wallet.balanceFen || 0)}
+
+ onChange({ ...draft, organizationId: value })} options={organizations.map((organization) => ({ value: organization.id, label: `${organization.name}${organization.status === "active" ? "" : "(已停用)"}` }))} /> + onChange({ ...draft, direction: value as AdjustmentDraft["direction"] })} options={[{ value: "credit", label: "上账增加余额" }, { value: "debit", label: "扣减余额" }]} /> + onChange({ ...draft, amountYuan: value })} placeholder="例如 1000" required inputMode="decimal" /> +
onChange({ ...draft, note: value })} placeholder="例如:2026 年度服务预存" required />
+
+
本次变动全部计入组织额度,管理员与员工共同使用
+
; +} + +function AccountSettings({ account, draft, editing, saving, onEdit, onCancel, onChange, onSubmit }: { account: BillingAccountConfig; draft: BillingAccountConfig; editing: boolean; saving: boolean; onEdit: () => void; onCancel: () => void; onChange: (key: keyof BillingAccountConfig, value: string) => void; onSubmit: (event: FormEvent) => void }) { + return
+ 编辑账户 : null} /> +
+ + {editing ?
+
配置对公账户
+
+ onChange("accountName", value)} placeholder="公司或平台对公账户名称" /> + onChange("bankName", value)} placeholder="例如:中国银行北京分行" /> + onChange("accountNumber", value)} placeholder="对公银行账号" /> + onChange("contact", value)} placeholder="联系人、电话或邮箱" /> +
+
留空字段会在成员端显示为未配置
+
: null} +
+
; +} + +function BillingOverview({ billing, isSuperAdmin }: { billing: BillingPayload; isSuperAdmin: boolean }) { + return
+
+
组织可用余额{billing.organization.name}
共享账本
+ {formatBillingAmount(billing.wallet.balanceFen)} +
累计充值 {formatBillingAmount(billing.summary.rechargeFen)}累计扣费 {formatBillingAmount(billing.summary.chargedFen)}{isSuperAdmin ? "账本状态" : "我的净消耗"} {isSuperAdmin ? "正常" : formatBillingAmount(billing.personal.netConsumedFen)}
+
+
+
我的净消耗{formatBillingAmount(billing.personal.netConsumedFen)}
+
组织净消耗{formatBillingAmount(billing.summary.netConsumedFen)}
+
+
; +} + +function LedgerSection({ entries, title, limit = 100, action }: { entries: LedgerEntry[]; title: string; limit?: number; action?: ReactNode }) { + return
+ + +
; +} + +function TabLink({ label, onClick }: { label: string; onClick: () => void }) { + return ; +} + +function BillingLoading() { + return
; +} + +function SectionHeading({ title, action }: { title: string; action?: ReactNode }) { + return

{title}

{action ?
{action}
: null}
; +} + +function BillingField({ label, value, onChange, placeholder, required = false, inputMode }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; required?: boolean; inputMode?: "decimal" | "tel" | "text" }) { + return ; +} + +function BillingSelect({ label, value, onChange, options }: { label: string; value: string; onChange: (value: string) => void; options: Array<{ value: string; label: string }> }) { + return ; +} + +function BillingAccountCard({ account, admin = false }: { account: BillingAccountConfig; admin?: boolean }) { + const configured = Boolean(account.accountName || account.bankName || account.accountNumber); + return
+

对公收款账户

{configured ? "已配置" : "待配置"}
+ {configured ?
:

超级管理员尚未配置对公收款账户信息,请先在收款设置中补充。

} +
; +} + +function AccountField({ label, value }: { label: string; value?: string }) { + return
{label}{value || "—"}
; +} + +function LedgerTable({ entries }: { entries: LedgerEntry[] }) { + return
+
事项变动 / 余额
+ {entries.length ? entries.map((entry) =>
+
{entry.description}{entry.kind !== "recharge" && entry.kind !== "adjustment" && entry.metadata.accountName ? ` · ${String(entry.metadata.accountName)}` : entry.jobId ? ` · 任务 ${entry.jobId.slice(0, 10)}` : ""}
+
{entry.deltaFen < 0 ? "−" : "+"}{formatBillingAmount(Math.abs(entry.deltaFen))}余额 {formatBillingAmount(entry.balanceAfterFen)}
+
) :
暂无账务流水
} +
; +} + +function PriceMultiplierDialog({ + state, + saving, + onClose, + onSubmit +}: { + state: PriceEditState; + saving: boolean; + onClose: () => void; + onSubmit: (value: string) => Promise; +}) { + const [value, setValue] = useState(String(state.target.markupMultiplier)); + const inputRef = useRef(null); + const standardUnitPriceFen = priceEditStandardUnitPriceFen(state.rule, state.target); + const parsedValue = Number(value); + const valid = value.trim() !== "" && Number.isFinite(parsedValue) && parsedValue >= 1 && parsedValue <= 1000; + const currentCustomerPrice = customerUnitPriceFen(standardUnitPriceFen, state.target.markupMultiplier); + const nextCustomerPrice = valid ? customerUnitPriceFen(standardUnitPriceFen, parsedValue) : null; + + useEffect(() => { + setValue(String(state.target.markupMultiplier)); + window.requestAnimationFrame(() => inputRef.current?.focus()); + }, [state.rule.id, state.target.dimensionKey, state.target.tierValue, state.target.markupMultiplier]); + + useEffect(() => { + function handleKeyDown(event: globalThis.KeyboardEvent) { + if (event.key === "Escape" && !saving) onClose(); + } + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose, saving]); + + function submit(event: FormEvent) { + event.preventDefault(); + if (!valid || saving) return; + void onSubmit(value.trim()); + } + + return ( +
{ if (event.target === event.currentTarget && !saving) onClose(); }}> +
event.stopPropagation()}> +
+
+ +
+

调整上浮倍率

+

{state.target.label}

+
+
+ +
+ +
+ + +
+
标准成本{formatBillingAmount(standardUnitPriceFen)} / {billingUnitLabel(state.rule.unit)}
+
当前用户价{formatBillingAmount(currentCustomerPrice)}
+
调整后用户价{nextCustomerPrice === null ? "—" : formatBillingAmount(nextCustomerPrice)}
+
+ +
+ + +
+
+
+
+ ); +} + +function priceEditStandardUnitPriceFen(rule: BillingPriceRule, target: PriceEditTarget): number { + if (!target.dimensionKey || target.tierValue === undefined) return rule.standardUnitPriceFen; + const dimension = rule.parameterDimensions?.find((item) => item.key === target.dimensionKey); + const tier = dimension?.tiers.find((item) => String(item.value) === target.tierValue); + return Math.ceil(rule.standardUnitPriceFen * Number(tier?.standardFactor || 1)); +} + +function PriceCatalog({ rules, onEdit, disabled }: { rules: BillingPriceRule[]; onEdit: (rule: BillingPriceRule, target: PriceEditTarget) => void; disabled: boolean }) { + const groups = groupPriceRules(rules); + return
+ {groups.length ? groups.map(([key, group]) => group.length === 1 ? : ) :
暂无平台价格标准。
} +
; +} + +function groupPriceRules(rules: BillingPriceRule[]): Array<[string, BillingPriceRule[]]> { + const groups = new Map(); + for (const rule of rules) { + const key = [rule.provider, rule.capability, rule.reqKey || ""].join("\u0000"); + const group = groups.get(key) || []; + group.push(rule); + groups.set(key, group); + } + return [...groups.entries()]; +} + +function PriceServiceCard({ rule, onEdit, disabled }: { rule: BillingPriceRule; onEdit: (rule: BillingPriceRule, target: PriceEditTarget) => void; disabled: boolean }) { + const dimensions = rule.parameterDimensions?.filter((dimension) => dimension.tiers.length) || []; + return
+
+
{providerName(rule.provider).slice(0, 2)}
{providerName(rule.provider)} · {rule.capability === "video.generate" ? "视频生成" : "图片生成"}{rule.reqKey || "默认服务"}{rule.variantKey ? ` · ${rule.variantKey}` : ""}
+
基准成本{formatBillingAmount(rule.standardUnitPriceFen)}/{billingUnitLabel(rule.unit)}
+
+
+ {rule.source?.url ? 查看价格来源 : 平台标准目录} + {rule.note || "平台标准目录"} +
+ {dimensions.length ?
+ {dimensions.map((dimension) => )} +
: } +
; +} + +function PriceServiceGroup({ rules, onEdit, disabled }: { rules: BillingPriceRule[]; onEdit: (rule: BillingPriceRule, target: PriceEditTarget) => void; disabled: boolean }) { + const rule = rules[0]; + return
+
+
{providerName(rule.provider).slice(0, 2)}
{providerName(rule.provider)} · {rule.capability === "video.generate" ? "视频生成" : "图片生成"}{rule.reqKey || "默认服务"}
+
平台参数档位{rules.length}
+
+
+ {rule.source?.url ? 查看价格来源 : 平台标准目录} + 平台标准目录按参数档位列出,用户选择后自动匹配。 +
+
+
参数档位不同标准费率独立计费,倍率仅影响用户价
+
参数档位标准成本用户价倍率操作
+
{rules.map((item) => )}
+
+
; +} + +function PriceDimension({ rule, dimension, onEdit, disabled }: { rule: BillingPriceRule; dimension: BillingParameterDimension; onEdit: (rule: BillingPriceRule, target: PriceEditTarget) => void; disabled: boolean }) { + const baseline = dimension.tiers.find((tier) => String(tier.value).toLowerCase() === String(dimension.baselineValue).toLowerCase()); + return
+
{dimension.label}基准档位:{baseline?.label || String(dimension.baselineValue)} · 组合报价按实际选择自动计算
+
参数档位标准成本用户价倍率操作
+
{dimension.tiers.map((tier) => )}
+
; +} + +function PriceTierRow({ rule, dimension, tier, onEdit, disabled }: { rule: BillingPriceRule; dimension: BillingParameterDimension; tier: BillingParameterTier; onEdit: (rule: BillingPriceRule, target: PriceEditTarget) => void; disabled: boolean }) { + const standardUnitPriceFen = Math.ceil(rule.standardUnitPriceFen * tier.standardFactor); + return
+
{tier.label}{String(tier.value)}{tier.note ? ` · ${tier.note}` : ""}
+
{formatBillingAmount(standardUnitPriceFen)}/{billingUnitLabel(rule.unit)}
+
{formatBillingAmount(customerUnitPriceFen(standardUnitPriceFen, tier.markupMultiplier))}/{billingUnitLabel(rule.unit)}
+ {tier.markupMultiplier.toFixed(2)}× +
+
; +} + +function LegacyPriceRow({ rule, onEdit, disabled }: { rule: BillingPriceRule; onEdit: (rule: BillingPriceRule, target: PriceEditTarget) => void; disabled: boolean }) { + return
+
{rule.variantKey || (describeRuleConditions(rule) || "默认参数")}{rule.enabled ? "平台标准档位" : "已停用"}
+
{formatBillingAmount(rule.standardUnitPriceFen)}/{billingUnitLabel(rule.unit)}
+
{formatBillingAmount(customerUnitPriceFen(rule.standardUnitPriceFen, rule.markupMultiplier))}/{billingUnitLabel(rule.unit)}
+ {rule.markupMultiplier.toFixed(2)}× +
+
; +} + +function WalletTable({ wallets }: { wallets: AdminPayload["organizations"] }) { + return
+
组织余额 / 累计扣费
+ {wallets.length ? wallets.map((item) =>
{item.name}{item.status === "active" ? "正常" : "已停用"}
{formatBillingAmount(item.wallet.balanceFen)}累计扣费 {formatBillingAmount(item.wallet.totalChargedFen)}
) :
暂无组织
} +
; +} + +function MemberBalanceTable({ members, entries }: { members: AdminMember[]; entries: LedgerEntry[] }) { + const organizationMembers = members.filter((member) => member.organizationId); + return
+
成员净消耗
+ {organizationMembers.length ? organizationMembers.map((member) => { + const usage = memberUsage(entries, member.id); + return
{member.displayName}{member.phone}{member.status === "disabled" ? " · 已停用" : ""}
{formatBillingAmount(usage.netConsumedFen)}扣费 {formatBillingAmount(usage.chargedFen)} · 退款 {formatBillingAmount(usage.refundedFen)}
; + }) :
暂无组织成员
} +
; +} + +function memberUsage(entries: LedgerEntry[], accountId: string) { + const personal = entries.filter((entry) => entry.accountId === accountId); + const chargedFen = personal.filter((entry) => entry.kind === "charge").reduce((sum, entry) => sum + Math.max(0, -entry.deltaFen), 0); + const refundedFen = personal.filter((entry) => entry.kind === "refund").reduce((sum, entry) => sum + Math.max(0, entry.deltaFen), 0); + return { chargedFen, refundedFen, netConsumedFen: Math.max(0, chargedFen - refundedFen) }; +} + +function customerUnitPriceFen(standardUnitPriceFen: number, markupMultiplier: number): number { + return Math.ceil(standardUnitPriceFen * markupMultiplier); +} + +function describeRuleConditions(rule: BillingPriceRule): string { + const entries = Object.entries(rule.conditions || {}); + if (!entries.length) return ""; + return entries.map(([key, value]) => `${conditionName(key)}=${conditionValueLabel(value)}`).join(" · "); +} + +function conditionName(key: string) { + const labels: Record = { + resolution: "分辨率", + size: "尺寸", + aspectRatio: "比例", + quality: "质量", + duration: "时长", + imageCount: "张数", + referenceImageCount: "参考图", + model: "模型" + }; + return labels[key] || key; +} + +function conditionValueLabel(value: unknown): string { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + const item = value as { min?: number; max?: number; values?: unknown[] }; + if (item.values?.length) return item.values.join("/"); + if (item.min !== undefined || item.max !== undefined) return `${item.min ?? "-∞"}~${item.max ?? "+∞"}`; + } + return String(value); +} + +function providerName(provider: string) { + if (provider === "volcengine-visual") return "即梦"; + if (provider === "evolink") return "EvoLink"; + if (provider === "seedance") return "Seedance"; + if (provider === "bailian") return "百炼"; + return provider; +} + +function formatTime(value: string) { + return new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }).format(new Date(value)); +} diff --git a/components/create-studio.tsx b/components/create-studio.tsx index fa64279..57c4c5b 100644 --- a/components/create-studio.tsx +++ b/components/create-studio.tsx @@ -1,9 +1,8 @@ "use client"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react"; -import { Check, Download, ExternalLink, Film, ImageIcon, ImagePlus, ImageUp, Loader2, Music, Paintbrush, Pencil, Plus, RefreshCw, Save, Send, Upload, X } from "lucide-react"; +import { Check, CircleDollarSign, Download, Film, ImageIcon, ImagePlus, Info, Loader2, Music, Pencil, Plus, RefreshCw, Save, Send, Upload, X } from "lucide-react"; import clsx from "clsx"; -import { ImageEditor, type ImageEditMode } from "@/components/image-editor"; import { clampPage, pageItems, Pagination } from "@/components/pagination"; import { crossfadeIn, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion"; import { VIDEO_DURATION_DEFAULT, VIDEO_DURATION_OPTIONS, VIDEO_RATIOS, VIDEO_RESOLUTIONS, clampVideoDuration } from "@/lib/video-settings"; @@ -17,9 +16,10 @@ import { type MaterialDraftKind } from "@/lib/prompt/material-draft"; import { extractMaterialPlaceholders } from "@/lib/prompt/material-placeholders"; +import { formatBillingAmount } from "@/lib/billing"; +import type { BillingQuote } from "@/lib/types"; type GenerateMode = "image" | "video"; -type StudioMode = GenerateMode | ImageEditMode; type MaterialKind = PromptMaterial["type"]; type ImageGenerateEngine = "jimeng" | "evolink" | "bailian"; type VideoGenerateEngine = "seedance" | "bailian"; @@ -123,8 +123,8 @@ const defaultTemplateForm: TemplateForm = { sortOrder: "0" }; -export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMode }) { - const [mode, setMode] = useState(initialMode); +export function CreateStudio({ initialMode = "image" }: { initialMode?: GenerateMode }) { + const [mode, setMode] = useState(initialMode); const [promptByMode, setPromptByMode] = useState>({ image: "", video: "" @@ -143,11 +143,13 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo const [templateEditorOpen, setTemplateEditorOpen] = useState(false); const [editingTemplateId, setEditingTemplateId] = useState(null); const [previewTemplate, setPreviewTemplate] = useState(null); - const [previewTaskAsset, setPreviewTaskAsset] = useState(null); + const [taskDetailJobId, setTaskDetailJobId] = useState(null); const [durationNow, setDurationNow] = useState(() => Date.now()); const [templateForm, setTemplateForm] = useState(defaultTemplateForm); const [templateRestoreState, setTemplateRestoreState] = useState(null); const [busy, setBusy] = useState(false); + const [billingQuote, setBillingQuote] = useState(null); + const [billingQuoteLoading, setBillingQuoteLoading] = useState(false); const [uploading, setUploading] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); @@ -155,7 +157,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo const [imageEngine, setImageEngine] = useState("jimeng"); const [jimengInfluence, setJimengInfluence] = useState(jimengInfluenceOptions[1].id); const [evolinkQuality, setEvolinkQuality] = useState(evolinkQualityOptions[1].id); - const [videoEngine, setVideoEngine] = useState("seedance"); + const [videoEngine, setVideoEngine] = useState("bailian"); const [videoRatio, setVideoRatio] = useState("9:16"); const [videoDuration, setVideoDuration] = useState(VIDEO_DURATION_DEFAULT); const [videoResolution, setVideoResolution] = useState("720p"); @@ -173,7 +175,6 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo const templatePromptRef = useRef(null); const materialDraftInputRef = useRef(null); - const isImageEditMode = mode === "inpaint" || mode === "upscale"; const generateMode: GenerateMode = mode === "video" ? "video" : "image"; const prompt = promptByMode[generateMode]; const selectedJimengInfluence = jimengInfluenceOptions.find((option) => option.id === jimengInfluence) || jimengInfluenceOptions[1]; @@ -212,6 +213,10 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo for (const asset of taskAssets) map.set(asset.id, asset); return map; }, [taskAssets]); + const selectedTask = useMemo( + () => recentJobs.find((job) => job.id === taskDetailJobId) || null, + [recentJobs, taskDetailJobId] + ); const hasLiveTasks = useMemo(() => recentJobs.some((job) => !isTerminalStatus(job.status)), [recentJobs]); useEffect(() => { @@ -288,6 +293,48 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo return () => window.clearInterval(timer); }, [hasLiveTasks]); + useEffect(() => { + if (!selectedTask) return undefined; + function handleEscape(event: globalThis.KeyboardEvent) { + if (event.key === "Escape") setTaskDetailJobId(null); + } + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + }, [selectedTask?.id]); + + useEffect(() => { + let active = true; + const timer = window.setTimeout(async () => { + if (!prompt.trim() || missingMaterialPlaceholders.length) { + if (active) setBillingQuote(null); + if (active) setBillingQuoteLoading(false); + return; + } + setBillingQuoteLoading(true); + try { + const response = await fetch("/api/billing/quote", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...buildGenerationBody(), kind: generateMode }) + }); + const payload = await response.json().catch(() => ({})); + if (active) { + setBillingQuote(response.ok && payload.quote ? payload.quote as BillingQuote : null); + } + } catch { + if (active) { + setBillingQuote(null); + } + } finally { + if (active) setBillingQuoteLoading(false); + } + }, 320); + return () => { + active = false; + window.clearTimeout(timer); + }; + }, [generateMode, imageEngine, imageSize.height, imageSize.width, selectedEvolinkQuality.quality, selectedJimengInfluence.scale, videoDuration, videoEngine, videoRatio, videoResolution, materials, missingMaterialPlaceholders.length, prompt]); + async function loadImageTemplates(isActive: () => boolean = () => true) { setTemplatesLoading(true); setTemplateError(null); @@ -734,8 +781,36 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo } } + function buildGenerationBody(): Record { + if (generateMode === "image") { + return { + capability: "image.generate", + engine: imageEngine, + prompt, + materials: materials.filter((material) => material.type === "image"), + width: imageSize.width, + height: imageSize.height, + ...(imageEngine === "evolink" + ? { quality: selectedEvolinkQuality.quality } + : { scale: selectedJimengInfluence.scale }), + force_single: true + }; + } + return { + kind: "video", + capability: "video.generate", + engine: videoEngine, + prompt, + materials, + settings: { + ...(videoEngine === "seedance" ? { ratio: videoRatio } : {}), + duration: videoDuration, + resolution: videoEngine === "bailian" ? videoResolution.toUpperCase() : videoResolution + } + }; + } + async function submit() { - if (isImageEditMode) return; if (missingMaterialPlaceholders.length) { setError(`请先上传 ${missingMaterialPlaceholders.map((placeholder) => placeholder.token).join("、")}。`); return; @@ -751,29 +826,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo throw new Error("百炼图生视频请上传 1 张首帧图,或按顺序上传 2 张首尾帧图。"); } } - const body = generateMode === "image" - ? { - capability: "image.generate", - engine: imageEngine, - prompt, - materials: materials.filter((material) => material.type === "image"), - width: imageSize.width, - height: imageSize.height, - ...(imageEngine === "evolink" - ? { quality: selectedEvolinkQuality.quality } - : { scale: selectedJimengInfluence.scale }), - force_single: true - } - : { - engine: videoEngine, - prompt, - materials, - settings: { - ...(videoEngine === "seedance" ? { ratio: videoRatio } : {}), - duration: videoDuration, - resolution: videoEngine === "bailian" ? videoResolution.toUpperCase() : videoResolution - } - }; + const body = buildGenerationBody(); const response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -786,7 +839,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo setRecentJobs((items) => [submittedJob, ...items.filter((item) => item.id !== submittedJob.id)].sort((a, b) => b.createdAt.localeCompare(a.createdAt))); } void loadTaskModuleData(() => true, { silent: true }); - setNotice(`${generateMode === "image" ? "图片" : "视频"}生成已提交。任务已进入「结果」,生成完成后结果资产会自动保留。`); + setNotice(`${generateMode === "image" ? "图片" : "视频"}生成已提交。任务已进入任务模块,生成完成后结果可在任务详情中查看和下载。`); setPrompt(""); setActiveTemplateId(null); setMaterials([]); @@ -812,45 +865,27 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo 视频 - -
+
+
- {!isImageEditMode ? ( -
- -
- ) : null}
); } return ( -
- {isImageEditMode ? ( -
{ modePanelRef.current = node; }} data-animate> -
- {renderCreateModeBar()} - -
-
- ) : ( -
{ modePanelRef.current = node; }} data-animate> +
+
{ modePanelRef.current = node; }} data-animate>
{templateEditorOpen ? (
@@ -1179,7 +1217,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo
{imageEngineLabel(templateForm.engine)} - {templateForm.engine === "evolink" ? selectedTemplateEvolinkQuality.label : selectedTemplateJimengInfluence.label} + {templateForm.engine === "evolink" ? selectedTemplateEvolinkQuality.label : templateForm.engine === "bailian" ? "智能推理" : selectedTemplateJimengInfluence.label} {templateForm.size}
@@ -1207,14 +1245,26 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo > Image2 +
-
- - {pickerOpen ? ( -
-
- {visibleImageAssets.map((asset) => ( - - ))} - {!imageAssets.length ? ( -
暂无结果图片,可先生成图片,或直接上传图片。
- ) : null} -
- -
- ) : null} - {mode === "inpaint" ? ( - <> -
- - setPrompt(event.target.value)} /> -
-
- - setBrush(Number(event.target.value))} /> -
- - ) : ( - <> -
- - -
-
- - setScale(Number(event.target.value))} /> -
- - )} -
- - {mode === "inpaint" ? ( - - ) : null} -
- {error || notice ? ( -
- {error ?
{error}
: null} - {notice ?
{notice}
: null} -
- ) : null} - - -
-

{mode === "inpaint" ? "黑色保留,白色重绘" : "素材增强"}

- {imageUrl ? ( -
- 待处理素材 - {mode === "inpaint" ? ( - { - drawing.current = true; - event.currentTarget.setPointerCapture(event.pointerId); - paint(event); - }} - onPointerMove={paint} - onPointerUp={() => { - drawing.current = false; - }} - /> - ) : null} -
- ) : ( -
选择素材后即可开始局部重绘或超清。
- )} -

- 输出结果会自动保存到结果。 -

-
-
- ); -} - -function isEditableImageAsset(asset: Asset) { - if (asset.kind === "mask") return false; - if (asset.tags.includes("mask")) return false; - if (typeof asset.metadata.maskRule === "string") return false; - const contentType = typeof asset.metadata.contentType === "string" ? asset.metadata.contentType.toLowerCase() : ""; - return asset.kind === "image" || asset.kind === "reference" || contentType.startsWith("image/"); -} - -function isResultImageAsset(asset: Asset, outputAssetIds: Set) { - if (!outputAssetIds.has(asset.id)) return false; - if (!isEditableImageAsset(asset)) return false; - return asset.source === "generated" || asset.source === "edited" || asset.source === "upscaled"; -} - -function sourceLabel(source: Asset["source"]) { - if (source === "upload") return "上传"; - if (source === "generated") return "生成"; - if (source === "edited") return "重绘"; - if (source === "upscaled") return "超清"; - if (source === "seed") return "示例"; - return "外部"; -} - -function capabilityLabel(capability: GenerationJob["capability"]) { - if (capability === "image.generate") return "图片生成"; - if (capability === "image.inpaint") return "局部重绘"; - if (capability === "image.upscale") return "智能超清"; - return "图片任务"; -} - -function buildMaskDataUrl(canvas: HTMLCanvasElement | null) { - if (!canvas) return undefined; - const mask = document.createElement("canvas"); - mask.width = canvas.width; - mask.height = canvas.height; - const ctx = mask.getContext("2d"); - if (!ctx) return undefined; - ctx.fillStyle = "black"; - ctx.fillRect(0, 0, mask.width, mask.height); - ctx.drawImage(canvas, 0, 0); - return mask.toDataURL("image/png"); -} - -function displayAssetUrl(url: string) { - if (typeof window === "undefined") return url; - try { - const parsed = new URL(url, window.location.origin); - if (parsed.hostname === "0.0.0.0") { - parsed.protocol = window.location.protocol; - parsed.host = window.location.host; - return parsed.toString(); - } - return parsed.toString(); - } catch { - return url; - } -} diff --git a/components/log-manager.tsx b/components/log-manager.tsx index b02d939..1a15867 100644 --- a/components/log-manager.tsx +++ b/components/log-manager.tsx @@ -21,8 +21,6 @@ type LogEntry = { type LogPayload = { entries: LogEntry[]; - logPath: string; - maxBytes: number; }; const LEVEL_TABS: Array<{ id: LogLevel | "all"; label: string }> = [ @@ -165,21 +163,6 @@ export function LogManager() {
) : null} -
-
- 日志文件 - {payload?.logPath || ".runtime/logs/server-events.jsonl"} -
-
- 单文件上限 - {formatBytes(payload?.maxBytes || 0)} -
-
- 当前筛选 - {levelLabel(level)} / {query || "全部"} -
-
-
{loading && !payload ? (
@@ -236,10 +219,3 @@ function formatTime(value: string): string { if (Number.isNaN(date.getTime())) return value; return date.toLocaleString("zh-CN", { hour12: false }); } - -function formatBytes(value: number): string { - if (!value) return "-"; - if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`; - if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`; - return `${value} B`; -} diff --git a/components/settings-panel.tsx b/components/settings-panel.tsx index 24d200f..bb872e6 100644 --- a/components/settings-panel.tsx +++ b/components/settings-panel.tsx @@ -23,15 +23,13 @@ type SettingsGroup = { }; type SettingsPayload = { - envPath: string; - modes: { - visual: string; - evolink: string; - seedance: string; - bailian: string; - auth: string; - organization: string; - data: string; + services: { + visual: boolean; + evolink: boolean; + seedance: boolean; + bailian: boolean; + auth: boolean; + organization: boolean; }; capabilities: Array<{ id: string; @@ -46,8 +44,8 @@ type SettingsPayload = { label: string; engine: string; engineLabel: string; - mode: string; - modeLabel: string; + connected: boolean; + connectionLabel: string; reqKey: string; configurable: boolean; field?: SettingsField; @@ -224,13 +222,12 @@ export function SettingsPanel() {
- - - - - - - + + + + + +
@@ -260,7 +257,7 @@ export function SettingsPanel() { ) : ( {assignment.engineLabel} )} - {assignment.modeLabel} + {assignment.connectionLabel}
{assignment.reqKey}
@@ -293,10 +290,8 @@ function ServiceBadge({ label, value, ready }: { label: string; value: string; r ); } -function authModeLabel(mode?: string) { - if (mode === "configured") return "已启用"; - if (mode === "missing") return "待配置"; - return "未启用"; +function serviceStatusLabel(connected?: boolean) { + return connected ? "已连接" : "待配置"; } function shortGroupLabel(id: string, title: string) { diff --git a/components/usage-manager.tsx b/components/usage-manager.tsx new file mode 100644 index 0000000..3d9a455 --- /dev/null +++ b/components/usage-manager.tsx @@ -0,0 +1,350 @@ +"use client"; + +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { BarChart3, Building2, CalendarDays, Loader2, RefreshCw, RotateCcw, Users } from "lucide-react"; +import { crossfadeIn, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion"; +import { usagePresetRange, type AdminUsageReport } from "@/lib/usage"; + +type UsageFilters = { + startDate: string; + endDate: string; + organizationId: string; + ownerId: string; + capability: string; + provider: string; +}; + +function initialFilters(): UsageFilters { + const range = usagePresetRange("month"); + return { + startDate: range.startDate, + endDate: range.endDate, + organizationId: "", + ownerId: "", + capability: "", + provider: "" + }; +} + +export function UsageManager({ isSuperAdmin }: { isSuperAdmin: boolean }) { + const [filters, setFilters] = useState(initialFilters); + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const managerRef = useRef(null); + const contentRef = useRef(null); + const feedbackRef = useRef(null); + + useEffect(() => { + const controller = new AbortController(); + void loadUsage(controller.signal); + return () => controller.abort(); + }, [filters]); + + useEffect(() => runScopedMotion(managerRef, (scope) => revealChildren(scope)), []); + + useEffect(() => { + crossfadeIn(contentRef.current); + }, [report]); + + useEffect(() => { + pulseFeedback(feedbackRef.current); + }, [error, report?.warnings]); + + async function loadUsage(signal?: AbortSignal) { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(filters)) { + if (value) params.set(key, value); + } + const response = await fetch(`/api/admin/usage?${params.toString()}`, { + cache: "no-store", + signal + }); + const payload = await response.json() as AdminUsageReport & { error?: string }; + if (!response.ok) throw new Error(payload.error || "读取用量数据失败"); + setReport(payload); + } catch (nextError) { + if (nextError instanceof DOMException && nextError.name === "AbortError") return; + setError(nextError instanceof Error ? nextError.message : String(nextError)); + } finally { + if (!signal?.aborted) setLoading(false); + } + } + + function updateFilter(key: keyof UsageFilters, value: string) { + setFilters((current) => ({ ...current, [key]: value })); + } + + function resetFilters() { + setFilters(initialFilters()); + } + + return ( +
+
+
+

用量管理

+

按成功任务统计平台内账号与组织的真实服务用量。

+
+
+ 北京时间 + {report?.range.label || "本月"} +
+
+ +
+ + + {isSuperAdmin ? ( + <> + + + + ) : null} + + +
+ + +
+
+ + {error || report?.warnings?.length ? ( +
+ {error ?
{error}
: null} + {!error && report?.warnings?.length ? ( +
{report.warnings.join(";")}
+ ) : null} +
+ ) : null} + + {loading && !report ? ( +
+
+ ) : report ? ( +
+
+ } label="成功任务" value={report.summary.total} suffix="次" /> + } label="活跃账号" value={report.summary.activeAccounts} suffix="个" /> + } label="活跃组织" value={report.summary.activeOrganizations} suffix="个" /> + } label="日均用量" value={report.summary.averagePerDay} suffix="次" /> +
+ +
+
+
+
+

用量趋势

+

{report.range.dayCount > 62 ? "按月汇总" : "按日汇总"}

+
+
+ +
+
+
+
+

功能分布

+

每个成功任务计 1 次

+
+
+ +
+
+

服务商分布

+
+ +
+
+ +
+
+
+

组织汇总

+

{report.organizations.length} 个统计分组

+
+
+
+ + {report.organizations.length ? report.organizations.map((row) => ( +
+ {row.organizationName} + {row.accountCount} 个 + {row.count} + +
+ )) : } +
+
+ + {isSuperAdmin ?
+
+
+

账号汇总

+

{report.accounts.length} 个活跃账号

+
+
+
+ + {report.accounts.length ? report.accounts.map((row) => ( +
+
+ {row.accountName} + {row.accountUsername ? {row.accountUsername} : null} +
+ {row.organizationName} + {row.count} + +
+ )) : } +
+
: null} + + {isSuperAdmin ?
+
+
+

用量明细

+

仅显示计量元数据,不展示创作内容

+
+ 最近 {report.recent.length} 条 +
+
+ {report.recent.length ? report.recent.map((record) => ( +
+
+ {record.accountName} + {record.organizationName} +
+
+ {record.capabilityLabel} + {record.providerLabel}{record.reqKey ? ` · ${record.reqKey}` : ""} +
+ + 1 次 +
+ )) : } +
+
: null} +
+ ) : null} +
+ ); +} + +function UsageMetric({ icon, label, value, suffix }: { icon: ReactNode; label: string; value: number; suffix: string }) { + return ( +
+ + {label} + {value}{suffix} +
+ ); +} + +function UsageTrend({ report }: { report: AdminUsageReport }) { + const max = Math.max(1, ...report.trend.map((point) => point.count)); + if (!report.trend.length) return ; + return ( +
+ {report.trend.map((point) => ( +
+
{point.count || ""}
+
+ +
+ {point.label} +
+ ))} +
+ ); +} + +function UsageBreakdown({ items, total, compact = false }: { items: AdminUsageReport["byCapability"]; total: number; compact?: boolean }) { + if (!items.length) return ; + return ( +
+ {items.map((item) => ( +
+
{item.label}{item.count}
+
+
+ ))} +
+ ); +} + +function UsageEmpty({ label }: { label: string }) { + return
{label}
; +} + +function formatAdminTime(value?: string): string { + if (!value) return "—"; + return new Intl.DateTimeFormat("zh-CN", { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false + }).format(new Date(value)); +} diff --git a/docs/API.md b/docs/API.md index f6d4909..36c190b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -86,10 +86,16 @@ curl -X POST https://你的域名/api/v1/jobs \ | capability | 说明 | | --- | --- | | `image.generate` | 图片生成 | -| `image.inpaint` | 局部重绘 | -| `image.upscale` | 智能超清 | | `video.generate` | Seedance 视频生成 | +## 计费说明 + +开放 API 仍按 API Key 和账号分区运行。当前未绑定组织的开放 API 任务保持兼容,不从组织钱包扣费;平台浏览器用户的真实图片/视频任务会按超级管理员配置的服务商标准单价、计费单位和上浮倍率,从所属组织余额冻结。普通用户余额不足时不会提交服务商并返回余额不足错误;超级管理员仍保存计算费用,但不检查、冻结或扣减组织额度,也不产生钱包扣费、退款流水。计费规则与最终金额会随任务保存,任务失败、取消或过期后由 Worker 在最终终态退款;Seedance 成功后按 `usage.completion_tokens` 多退少补,缺少该字段时保留冻结金额。 + +组织余额当前只能由超级管理员通过 `/billing` 的“余额与上账”直接入账。所有充值和人工余额调整都只记入组织账本,不存在个人上账归属;组织管理员和员工共同使用组织额度。未来接入支付时,支付成功回调应使用同一幂等上账逻辑自动入账,不产生待审核申请。余额不足时,平台会拒绝创建真实计费任务并返回错误;Mock 任务免计费。 + +首次加载超管计费中心或提交真实任务时会自动补齐内置标准成本目录,默认倍率为 `1.2×`,已有同服务商/能力/模型/变体规则会同步平台维护的标准成本与参数档案,但保留已配置倍率。视频规则按分辨率匹配 `resolution=480p|720p|1080p|4k`;参数化规则按服务下的参数维度选择档位,标准成本为基础成本乘以各档位系数,组合倍率取所选档位中的最高倍率;最终金额使用整数分并向上取整。EvoLink 默认按固定 `1 USD = 7.20 CNY` 换算,并列出质量、分辨率、画面比例和参考图数量档位;即梦 4.6 使用公开资源包折算值作为平台维护的参考标准,实时价格以火山控制台为准。超级管理员只调整倍率,标准成本和参数档案不通过后台修改。 + ## 查询任务 查询单个任务: @@ -194,29 +200,6 @@ curl -X POST https://你的域名/api/v1/assets \ 图片生成参数会按当前引擎生效:即梦使用 `scale` 控制文本影响,EvoLink 使用 `quality` 控制生成质量。 -智能超清: - -```json -{ - "capability": "image.upscale", - "imageUrls": ["https://example.com/input.png"], - "resolution": "4k" -} -``` - -局部重绘: - -```json -{ - "capability": "image.inpaint", - "prompt": "把选中区域文字替换成新品上市", - "imageUrls": [ - "https://example.com/original.png", - "https://example.com/mask.png" - ] -} -``` - ## 视频任务示例 ```json @@ -242,7 +225,7 @@ curl -X POST https://你的域名/api/v1/assets \ - `duration`:`4` 到 `15` 秒 - `ratio`:`16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive` -- `resolution`:`480p`、`720p`、`1080p` +- `resolution`:`480p`、`720p`、`1080p`、`4k` ## 幂等 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index d807c87..e54e40a 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -44,19 +44,9 @@ HOSTNAME=0.0.0.0 NEXT_PUBLIC_APP_URL=https://你的域名 ZHINIAN_AUTH_REQUIRED=auto -ZHINIAN_AUTH_BASE_URL=https:///auth -ZHINIAN_AUTH_CLIENT_ID=custom -ZHINIAN_AUTH_CLIENT_SECRET=custom -ZHINIAN_ADMIN_AUTH_CLIENT_ID=app -ZHINIAN_ADMIN_AUTH_CLIENT_SECRET=app -ZHINIAN_AUTH_TENANT_ID= -ZHINIAN_ADMIN_AUTH_TENANT_ID= -ZHINIAN_AUTH_SCOPE=server -ZHINIAN_AUTH_ISSUER=https://pig4cloud.com -ZHINIAN_AUTH_PASSWORD_ENC_KEY=thanks,pig4cloud ZHINIAN_AUTH_SESSION_SECRET=请替换为强随机会话密钥 -ZHINIAN_ADMIN_AUTHORITIES=ROLE_ADMIN,sys_user_view,sys_log_view,sys_config_view -ZHINIAN_ADMIN_USERS=ceshiop +NEXT_PUBLIC_SUPABASE_URL=https://你的项目.supabase.co +SUPABASE_SERVICE_ROLE_KEY=请替换为服务端密钥 ZHINIAN_API_KEYS=partner-a:请替换为强随机key ZHINIAN_INTERNAL_WORKER_TOKEN=请替换为强随机token @@ -69,7 +59,6 @@ ZHINIAN_WEBHOOK_SECRET=请替换为webhook签名密钥 ```env IMAGE_GENERATE_ENGINE=evolink -IMAGE_INPAINT_ENGINE=jimeng EVOLINK_API_KEY= @@ -87,15 +76,15 @@ ALI_OSS_PUBLIC_BASE_URL= 如果不配置真实供应商密钥,mock 配置会保留本地验收能力,但生产对接应配置真实密钥。 -认证中心客户端需要允许回调地址: +平台账号部署说明: ```text -https://你的域名/api/auth/callback +npm run bootstrap:admin -- --phone 13800138000 --password '请替换为强密码' --name '平台超级管理员' ``` -如果使用登录页内的账号密码方式,需要确认客户端支持 `password` 和 `refresh_token` grant,并已加入认证中心配置 `security.ignore-clients`。普通账号登录使用 `ZHINIAN_AUTH_CLIENT_ID` / `ZHINIAN_AUTH_CLIENT_SECRET`,默认 `custom/custom`,并会把 `ZHINIAN_AUTH_TENANT_ID` 作为 `tenantId` 传给认证中心;`ZHINIAN_AUTH_TENANT_ID` 为空时复用 `ZHINIAN_ORG_TENANT_ID`。登录页里的“管理员登录”入口使用 `ZHINIAN_ADMIN_AUTH_CLIENT_ID` / `ZHINIAN_ADMIN_AUTH_CLIENT_SECRET`,默认 `app/app`;管理员租户只在显式配置 `ZHINIAN_ADMIN_AUTH_TENANT_ID` 时传递。 +生产部署前请在 Supabase SQL Editor 执行幂等脚本 [`supabase/schema.sql`](../supabase/schema.sql),然后运行一次超级管理员初始化命令。旧账号使用 `npm run migrate:accounts -- path/to/legacy-accounts.json` 导入;迁移会保留用量并把历史素材、任务、项目和模板映射到本地账号。 -## 组织账号接口 +## 旧版组织账号接口(已停用) 如需启用 `/accounts` 后台账号管理,按组织能力接口文档配置: @@ -127,6 +116,8 @@ Web 后台可在登录后访问: https://你的域名/logs https://你的域名/settings https://你的域名/accounts +https://你的域名/usage +https://你的域名/billing ``` 日志默认写入 `.runtime/logs/server-events.jsonl`,用于查看 API 500 错误、Worker 任务异常、错误栈和请求路径。可通过环境变量 `ZHINIAN_LOG_DIR` 调整目录,通过 `ZHINIAN_LOG_MAX_BYTES` 调整单文件轮转大小。 @@ -161,6 +152,8 @@ Docker Compose 会挂载: 本地 JSON 数据层、上传文件和生成结果都会放在 `.runtime/` 下。生产环境如果未启用 Supabase/Postgres,请定期备份该目录。 服务端日志也会放在 `.runtime/logs/` 下,建议和运行时数据一起备份或接入服务器日志采集。 +如果生产环境启用了 Supabase/Postgres,发布包含用量和计费管理的版本前,必须在 Supabase SQL Editor 重新执行仓库中的 `supabase/schema.sql`。脚本会幂等升级表结构、补充计费规则的模型变体、来源和参数档位字段、按 `job_id` 去重历史用量,并把计量记录调整为不随生成任务删除。首次打开 `/billing` 或提交真实任务时,系统会自动导入内置标准成本目录;平台参数档案会同步,已有倍率会保留,超级管理员只维护上浮倍率。 + 建议备份: ```bash @@ -209,8 +202,10 @@ curl https://你的域名/api/v1/openapi.json 确认: - 未登录访问 Web 页面会跳转到 `/auth/login` -- 普通用户登录后只看到创作和结果 -- 管理员登录后可访问 `/logs`、`/settings`、`/accounts` +- 普通用户登录后看到创作、账号和计费;账号页可修改自己的密码,任务详情、输入要素和结果下载均在创作页右侧任务模块完成,点击页头账号 ID 可查看自己的快捷周期用量 +- 从管理员入口登录且命中管理员账号或专用角色白名单后,可访问 `/logs`、`/settings`、`/usage`,并在 `/accounts` 额外维护组织和成员;超级管理员还可配置 `/billing` 计费规则和组织余额 +- `/usage` 可按日期、组织、账号、功能和服务商筛选,Mock 与开放 API 任务不计入 +- `/billing` 可查看组织余额、成员消耗和账务流水;管理员通过“余额与上账”直接记入组织额度 - `/api/health` 返回 `ok: true` - `/logs` 可查看后台错误日志 - `/api/v1/capabilities` 使用 API Key 可访问 diff --git a/findings.md b/findings.md index 5b162a5..a758488 100644 --- a/findings.md +++ b/findings.md @@ -53,6 +53,14 @@ | Decision | Rationale | |----------|-----------| +## 2026-08-11 - Task Module Consolidation Findings +- The visible result directory is the top navigation item `结果`, backed by `/assets` and `components/asset-manager.tsx`; it currently exposes both an asset gallery and a task-history view. +- The create page already has an independent right-side `任务模块` in `components/create-studio.tsx`. Each task card currently shows a thumbnail, prompt-derived name, status, elapsed time, billing label, and a `查看详情` link that navigates to `/assets?view=tasks&taskId=...`. +- The task module already loads `/api/assets`, `/api/generations/image`, and `/api/generations/video`, so it has the local asset records needed for previews and downloads. +- `GenerationJob` persists `prompt`, `inputAssetIds`, `inputUrls`, `requestPayload`, `provider`, `reqKey`, status/timing/error, output asset IDs, and billing metadata. Image jobs store the original input under `requestPayload.input`; video jobs additionally store assembled materials and normalized `requestPayload.settings`. +- Existing first-party asset download route is `/api/assets/:id/download`; generated result storage is served through `/generated-results/[...path]` and local files live under `.runtime/generated-results`. +- Current physical `.runtime/generated-results` contains local verification data and must not be deleted merely to remove the visible result page; deleting that directory would remove task preview/download backing files. + ## Issues Encountered | Issue | Resolution | |-------|------------| @@ -300,3 +308,264 @@ - The right task module now targets `560-640px` on desktop/wide screens so task name, status, elapsed time, and `查看详情` can stay on one horizontal row. - Queued/running jobs without output assets should render a clear `生成中` thumbnail placeholder instead of a generic empty asset icon. - Completed image-task thumbnails are preview actions inside `/create`; clicking them opens the same large asset-preview surface while `查看详情` remains the route to the full task page. + +## 2026-08-11 Platform-Owned Account System Findings + +### Existing authentication boundary +- Browser authentication currently depends on an external OAuth2/OIDC-style service: authorization-code callback and password-grant login exchange against `ZHINIAN_AUTH_BASE_URL`. +- The app signs the resulting `AuthSession` into the `zhinian_session` cookie and derives first-party ownership from external claims such as `auth::`. +- `lib/types.ts` has only a minimal `AppState.users` demo shape (`id`, `email`, `displayName`); it is not an authentication user store. +- `supabase/schema.sql` has no local users, organizations, memberships, password credential, or audit schema. +- The current `/accounts` implementation proxies an external organization/member service and must be replaced for local account management. +- `.runtime/data/web-app-state.json` contains a demo user, while historical assets/jobs use external-auth owner IDs; a migration must preserve those links via an import/mapping layer. + +### Confirmed product decisions +- Production source of truth: platform-owned user data in Supabase/Postgres; local development fallback: `.runtime/data` JSON. +- OAuth2 dependency is removed from browser account authentication. +- Roles: `super_admin`, `organization_admin`, `user`. +- Super admins manage all organizations, accounts, logs, system settings, and global usage. +- Organization admins manage ordinary users in their own organization, reset passwords, and view organization aggregate usage; they do not view logs, manage system settings or organization lifecycle, view member assets/tasks, or grant organization-admin roles. +- Ordinary users access their own creation/assets/tasks and can change their own password. +- One account is created once and belongs to one organization only; no cross-organization membership or organization switching. +- Organizations are created, renamed, disabled, or deleted only by super admins. +- New accounts are administrator-created; public registration is not provided. +- Login identifier is a unique immutable phone number; email is removed. +- Users cannot self-edit their phone number. +- Admins set initial and reset passwords; users may change passwords in settings; first-login password change is not forced. +- Accounts can be disabled or permanently deleted. On permanent deletion, login identity is removed, usage records are retained for finance reconciliation, and assets/tasks are transferred to an organization archive owner. +- Legacy accounts and history are migrated by phone/account mapping; external password hashes are not migrated, so administrators set new passwords without forced first-login change. +- Unified phone/password login is used for all roles; users do not select a role at login. +- Login security: five failed attempts trigger a 15-minute account lock, with IP-level request limiting. +- The first super admin is created through a one-time initialization command. + +### Implementation defaults pending no further product decision +- Use Node's built-in `crypto.scrypt` password hashing with per-user salts and constant-time verification; never store plaintext passwords. +- Keep the existing signed/chunked HttpOnly session-cookie mechanism, but issue sessions from local user records rather than external JWT claims. +- Treat the migration export/mapping file as an operator-provided input; do not invent legacy account rows that are not present in the repository. + +### 2026-08-11 Implementation result +- Platform browser authentication is now first-party and uses only the immutable phone identifier plus a local password; the old OAuth routes remain as compatibility redirects/deprecated helpers and are not part of browser authentication. +- Supabase production tables and local JSON fallback share the same account-store contract, so login, role checks, lockout, organization scoping, and password changes do not depend on the external organization service. +- Organization status is enforced at login/session refresh. Disabling an organization therefore blocks its organization-admin and ordinary-user sessions while preserving their records for later reactivation or audit. +- Organization-admin usage responses intentionally omit account-level options/details; only the organization aggregate view is exposed to that role. +- Account hard deletion removes the login identity, reassigns assets/jobs/projects/templates to the organization archive owner, and leaves usage events untouched for finance reconciliation. +- Final local verification passed with 22 test files / 80 tests, TypeScript, script syntax checks, production build, health endpoint, login page, and role-boundary HTTP smoke tests. + +## Session: 2026-08-11 - Enterprise Billing Discovery + +### Official pricing catalog findings — 2026-08-11 +- Alibaba Model Studio `wan2.7-image-pro` Beijing price is ¥0.50/image; `wan2.7-i2v` is ¥0.60/second at 720P and ¥1.00/second at 1080P. The current environment uses the dated `wan2.7-i2v-2026-04-25` model key, so the default rule follows that key. +- Volcengine Ark `doubao-seedance-2.0` official pricing is token-based. For the documented no-input-video, 16:9, 5-second example, the output-only totals are ¥2.31 at 480P, ¥4.97 at 720P, and ¥12.39 at 1080P; the catalog stores those as ¥0.46, ¥0.99, and ¥2.48 per output second after fen rounding. +- EvoLink GPT Image 2 is token-based. The official estimator page gives approximately $0.047 for medium quality, 1K, 1:1, and no reference image; with the confirmed fixed FX rate of 7.20, the catalog stores ¥0.34/image as a rounded baseline rather than claiming a fixed provider bill. +- Jimeng/Volcengine Visual 4.6's official billing page says the latest price is shown in the console and bills by successful call. The catalog therefore uses the public ¥200/1000-image activity package only as an enabled, editable reference baseline of ¥0.20/image and labels it for administrator review. +- Default rules are inserted only when the same provider/capability/model/variant is absent. A pre-existing generic rule for a provider/capability suppresses automatic defaults for that scope, preserving administrator fallback behavior. + +### Confirmed from current code +- Platform-owned accounts and roles already exist: `super_admin`, `organization_admin`, and `user`. +- Platform generation jobs carry `usageContext` with account and organization identity when a first-party session is available. +- `UsageEvent` currently records capability/provider/account/organization and a quantity of `1`, but has no provider unit cost, markup, charged amount, currency, wallet, recharge, or ledger reference. +- `recordUsageForJob()` is idempotent by `jobId`, excludes mock and public API jobs, and currently only creates an analytics event after the job path is established; it is not a financial debit. +- Existing usage reports aggregate task counts and are already role-aware through `/api/usage` and `/api/admin/usage`; they need to be extended with money and balance views. +- The data layer supports local JSON fallback and Supabase/Postgres, so billing must preserve both storage contracts and use an atomic/idempotent financial operation in production. +- Existing workspace has uncommitted account-system changes owned by the user; future billing edits must be additive and avoid resetting unrelated work. +- The current Node executable is not available as `node` in the shell PATH during this discovery call; verification should use the workspace dependency/runtime path if needed. + +### Open decision for user +- Recommended billing unit: charge each successful provider request using the provider's source-unit price from a super-admin-managed price catalog, then multiply by a configurable organization/platform markup; store the final amount and the exact price/multiplier snapshot on the debit ledger so later price changes cannot rewrite history. + +### Confirmed decisions and removal scope +- User confirmed the billing model: provider-native billing quantity × super-admin-managed markup, with the final amount and pricing snapshot retained in the ledger. +- User requested removal of both image capabilities: `image.upscale` (高清/智能超清) and `image.inpaint` (局部重绘). +- Removal must cover the shared capability types, provider capability builders, generation routes, asset edit routes, create/editor UI, engine/settings status, public API validation/OpenAPI, tests, documentation, and stale environment variables. +- Remaining first-party generation capabilities are image generation and video generation; mock behavior remains available for local verification. +- Generation jobs are currently queued before provider execution, so billing integration should reserve funds at submission, release/refund the reservation on provider failure/cancellation/expiry, and finalize the debit on successful completion. This preserves balance safety while making the amount visible on the generation record. + +### Capability removal implementation map +- `components/create-studio.tsx` currently embeds the edit-mode switch and `ImageEditor`; the remaining studio should use only `GenerateMode = image | video`. +- `components/image-editor.tsx`, `app/api/assets/[id]/inpaint/route.ts`, and `app/api/assets/[id]/upscale/route.ts` are capability-specific and can be removed without affecting upload or normal generation. +- `app/image-edit/page.tsx` is a legacy compatibility entry; it will redirect to `/create` without an edit mode so old bookmarks do not expose a removed capability. +- `lib/jimeng/capabilities.ts`, `lib/evolink/image-client.ts`, `lib/server/app-settings.ts`, public API parsing/OpenAPI, usage labels, and documentation contain stale capability definitions that must be reduced to image/video generation. +- Historical asset `source` values `edited` and `upscaled` remain readable for backward compatibility, but no new job/API/UI path will create or advertise them. + +### Billing implementation map +- Billing amounts use integer fen (`CNY`) to avoid floating-point ledger drift; price rules hold provider, capability, optional exact `reqKey`, native unit, standard unit price, and multiplier. +- A generation job stores a billing quote snapshot in `generation_jobs.billing`; charge/refund ledger entries are keyed by `job-charge:` and `job-refund:` for idempotency. +- Real provider jobs require an organization and a matching enabled price rule; mock jobs remain uncharged so local mock mode can still be used before real provider prices are configured. +- Public API jobs without an organization context remain uncharged for backward compatibility; first-party platform jobs use the authenticated organization context. +- User billing routes expose organization balance, organization ledger, personal ledger summary, and offline recharge requests; super-admin routes expose price rules, organization wallets, and recharge review operations. + +### Billing implementation result +- Added local `billing-state.json` fallback and Supabase `billing_price_rules`, `billing_wallets`, `billing_ledger`, and `billing_recharge_requests` tables with the `billing_post_wallet_entry` atomic RPC. +- Added corporate transfer account configuration through the super-admin service settings and exposed the account details on the member billing page before recharge submission. +- Added billing labels to create-task and asset-task views so each generation record exposes the quoted/charged/refunded amount. +- Final verification uses the project Vitest runner rather than Bun's built-in runner; all 23 test files and 81 tests pass. + +## Session: 2026-08-11 - Billing Center UI Redesign + +### Visual audit +- The previous super-admin page rendered balance cards, account settings, ledgers, price rules, organization wallets, and recharge review as one continuous stack. The long default-rule notes made the pricing area disproportionately tall and weakened the primary balance/action hierarchy. +- The redesign keeps the page light and editorial-minimal: organization balance is the first visual anchor, personal and organization consumption are secondary, and operational controls are grouped by role and task. +- Price-source links and explanatory notes remain available but are truncated to compact metadata lines. Custom rule creation is hidden behind an explicit disclosure so the default catalog stays readable. +- The page uses existing Lucide icons and motion helpers; no new dependency or image asset was needed for this data-heavy internal finance surface. + +### Tabbed operations and balance semantics +- The existing billing domain already models one organization wallet plus account-attributed immutable ledger entries. It does not model a personal wallet, so manual member selection must remain attribution-only to avoid contradicting the shared organization balance requirement. +- Existing super-admin settings persistence already supports the four corporate-account fields through `saveApiSettings`; the billing center now exposes the same path through a focused billing endpoint instead of duplicating configuration storage. +- The admin billing payload now includes organizations, members, all recent ledger entries, price rules, and recharge requests so the balance tab can show both organization wallets and per-member net consumption without additional per-row requests. + +### Billing error recovery — 2026-08-11 +- Local fallback storage reproduces the new billing page and all initial billing endpoints successfully; the reported generic server error is therefore not reproducible without the user's deployed request/response or server log. +- The most likely external-state failure is an unapplied or partially applied Supabase upgrade: billing tables/RPCs or Phase 53 `variant_key`/`source` columns are missing even though the application code expects them. +- Billing storage now translates matching Supabase schema-cache/relation/column/function errors into an explicit instruction to execute `supabase/schema.sql`; the client also handles HTML/plain-text 500 responses without throwing a JSON parse error. + +### Super-admin blank state — 2026-08-11 +- A super-admin is intentionally not required to belong to an organization. The member billing endpoint therefore returns 422 for that identity, which must not prevent the admin billing center from rendering. +- The super-admin view now treats `/api/admin/billing` as its source of truth and aggregates organization wallets and ledger entries into a platform overview payload for the balance hero and recent ledger. + +### Parameterized billing rules — 2026-08-11 +- The billing domain currently stores only `variantKey`, `unit`, base unit price, and markup. Matching only reads `resolution`; it cannot distinguish user-selected quality, size, aspect ratio, or reference-image count. +- The generation request payload already carries nested `settings`, `providerPayload`, and `input` records. Video requests expose duration/resolution/ratio; image requests expose provider-specific size/quality and image count in their provider payloads/settings. +- The safe extension is structured JSON conditions plus an explicit quantity source. Legacy `variantKey` values remain readable and are translated into a resolution condition during matching. +- Quote selection should prefer exact `reqKey` and the highest number of matching conditions; equal-specificity matches must be rejected as ambiguous in configuration rather than silently choosing by insertion order. +- The client may request a quote for preview, but task submission must call the same server matcher again and persist the normalized parameters, matched rule, and final amount snapshot. + +### Parameterized billing implementation — 2026-08-11 +- `BillingPriceRule` now supports `conditions`, `quantitySource`, and `priority`; the Supabase schema persists them and the local JSON store canonicalizes condition keys/values for duplicate detection. +- Normalized billing parameters are derived from the actual provider payload rather than from a client-supplied price. Current UI choices map as follows:画幅→`size`/`aspectRatio`, EvoLink quality→`quality`, Jimeng text influence→`scale`, video ratio→`aspectRatio`, video duration→`duration`, video resolution→`resolution`, and image/video references→`referenceImageCount`. +- A generic rule remains a deliberate fallback. To require every combination to be explicitly configured, the super-admin can disable/remove the generic fallback and keep only conditional rules; an unmatched enabled scope then returns a billing configuration error before a real job is created. +- The quote snapshot stores the effective legacy/structured conditions, normalized parameter map, quantity source, quantity, markup, and final fen amount. Later price edits therefore cannot rewrite historical ledger entries. + +### Task detail consolidation — 2026-08-11 +- The user confirmed that “删除结果目录” means removing the visible result-directory experience from the front end, not deleting generated-result files or storage records. +- The user confirmed that task details, prompt/material inspection, result preview, and download should all be handled inside the `/create` task module. +- The user confirmed that the platform is desktop-only; no additional mobile breakpoint work is part of this change. +- The implementation keeps `/api/assets`, `/api/assets/[id]/download`, `/api/v1/assets`, and local/OSS result storage intact. `/assets` remains only as a compatibility redirect to `/create`. + +### Direct billing top-ups — 2026-08-11 +- The current recharge flow is request/review based: members POST `/api/billing` with payer and transfer details, the request is stored as `pending`, and super admins PATCH `/api/admin/billing/recharges/:id` to approve or reject it. +- The billing center exposes this workflow through a member `线下充值` tab, a super-admin `充值审核` tab, a pending-recharge metric, and review actions in `RechargeTable`. +- The existing `/api/admin/billing/adjustments` endpoint already supports direct positive/negative organization-wallet entries; positive entries are the correct current administrator top-up path and write immutable ledger entries. +- The billing account configuration (`/api/admin/billing/account`) should remain because it contains the organization’s future payment/account information, but it should not be tied to a review queue. +- The recharge-request model is only referenced by the billing UI, billing GET/POST routes, the admin review route, billing store/service, schema table, docs, and billing tests. It can be removed from the active application surface without affecting task charging/refunding. +- Product rule confirmed by the user: no manual review; administrator top-ups post directly, and a future user payment callback should post automatically after payment success. +- The implementation now removes the active request/review UI and APIs, keeps the corporate account settings for future payment display, and exposes `postOrganizationTopUp` as the shared idempotent entry point for administrator credits and future payment-success callbacks. +- Verification confirms there are no remaining active references to recharge requests, review endpoints, review UI, or pending-review CSS. The direct top-up test records a `recharge` ledger entry and preserves shared-wallet attribution. + +### Simplified billing price controls — 2026-08-11 +- The current `PriceManagement` UI exposes provider, capability, unit, standard price, markup, req key, legacy variant, quantity source, priority, seven condition fields, notes, custom rule creation, JSON condition editing, and enable/disable controls. +- The catalog is already auto-seeded from provider reference prices and parameter variants; the requested admin responsibility is only the customer-facing markup multiplier. +- The safe product boundary is read-only standard catalog data plus a single multiplier update action. The admin price PATCH route should reject edits to base price, conditions, quantity, provider/model identity, and enabled state. +- Implemented the boundary: the billing center now renders a compact read-only price catalog, and each row exposes only `调整倍率`; the former parameterized-rule creation form and structural controls are removed. +- The admin price collection endpoint is now read-only, while the item PATCH endpoint accepts only `markupMultiplier` and rejects changes to platform standard costs, provider/model identity, parameters, quantity rules, priority, notes, and enabled state. +- Verification passed with `bunx tsc --noEmit`, `bunx vitest run` (23 files / 86 tests), Node-backed production build, `git diff --check`, browser no-legacy-form/no-horizontal-overflow checks, and a clean local-server restart. + +### Parameterized billing catalog — 2026-08-11 +- User confirmed that every service/model must list its cost-affecting parameter tiers, while the super administrator only edits the multiplier for each tier. +- Confirmed implementation direction: platform-owned parameter dimensions (for example quality, resolution, and reference-image count) are listed under each service; the quote engine combines the selected tiers instead of requiring a manually maintained Cartesian-product rule for every combination. +- EvoLink GPT Image 2 exposes quality, resolution, size/aspect, count, and reference-image controls; its public page states that quality changes output-token cost and that high quality is approximately four times medium, so a single generic medium rule is insufficient. +- Implemented `parameterDimensions` on the platform catalog. Each tier exposes a read-only standard factor/rate and an editable markup multiplier; combinations multiply the standard factors and use the highest selected markup multiplier once. +- EvoLink now lists quality, resolution, aspect-ratio, and reference-image tiers. Existing video resolution rules are grouped under their service/model, while legacy single-rate services remain visible as a default tier. +- The admin price PATCH route accepts either a legacy service multiplier or a dimension/tier multiplier target, while rejecting standard-cost and catalog-structure edits. The Supabase schema adds `parameter_dimensions` and the seed path backfills existing built-in rules without overwriting configured multipliers. +- Verification passed: EvoLink medium/high quote tests, tier-multiplier update test, `bunx tsc --noEmit`, `bunx vitest run` (23 files / 88 tests), `bun run build`, browser service-card/dimension/overflow checks, `git diff --check`, and clean local-server health (`/api/health` 200). + +### Account directory redesign — 2026-08-11 +- The current `/settings` page renders `AccountSecurityPanel`, while `/accounts` is currently restricted to admin sessions and only renders administrator organization/member management. +- Moving password change into `/accounts` without changing access would remove ordinary users' ability to change their own password. The safe product interpretation is to make `/accounts` authenticated-user accessible, render self-security for every user, and keep `/api/admin/*` and administrator management controls role-protected. +- The existing account page is a dense vertical stack with a six-column create form that becomes visually cramped at the tested desktop viewport. The redesign will use a restrained two-column desktop composition: personal security as the stable account surface, administrator controls in a separate work area, and members in a dedicated list. +- Existing API contracts and field names remain unchanged: `/api/auth/password/change`, `/api/admin/accounts`, `/api/admin/accounts/password`, and `/api/admin/organizations` continue to own their current operations. +- Design read: modern minimalist enterprise utility surface, preserving the current green accent and light theme with `DESIGN_VARIANCE 5`, `MOTION_INTENSITY 3`, and `VISUAL_DENSITY 4`. +- Final layout uses a stable desktop grid at the tested 1280px viewport with no horizontal document overflow. The page keeps the account directory as a real product surface rather than a marketing composition, so no generated imagery or decorative motion was added. + +### Account workspace correction — 2026-08-11 +- The first account redesign over-separated identity, security, admin actions, and member data. “账户信息” and “超级管理员” were two labels for the same current-user identity and should not be rendered as separate surfaces. +- The corrected information architecture is a single workspace with a single identity summary, followed by security, administrator operations, and member directory sections. This removes visual fragmentation while preserving all actions and APIs. + +### Organization-only billing top-ups — 2026-08-12 +- The billing wallet is already organization-scoped, but the administrator adjustment API and UI still accepted an optional `accountId` for recharge/debit ledger entries. +- The member balance table still exposed “归属上账”, which contradicts the confirmed rule that organization administrators and employees all use the same organization quota and that no personal top-up ownership exists. +- New recharge and balance-adjustment entries will be normalized without `accountId`; generation charge/refund entries retain the acting account ID so member consumption reporting remains available. Existing historical ledger entries are preserved. +- Implemented the invariant in the local billing store and Supabase wallet RPC; stale callers may still send `accountId`, but new recharge/adjustment rows are persisted without it. +- Removed the member selector and “归属上账” action from the billing center, while retaining the member consumption read-only table. +- Ledger display now hides historical personal labels for recharge/adjustment rows; task charge/refund rows can still show the executing member for consumption context. +- Verification passed with focused billing tests (13/13), full Vitest suite (24 files / 92 tests), TypeScript, production build, and `git diff --check` for the reviewed tracked files. + +### Frontend encoding diagnosis — 2026-08-12 +- Source files inspected (`app/layout.tsx`, billing page/component, and global CSS) decode as UTF-8 without BOM, null bytes, or replacement characters. +- Both `127.0.0.1:3000` and `localhost:3000` return `text/html; charset=utf-8`; HTML contains correct Chinese strings and no common mojibake markers. +- `agent-browser` rendered the login page and an isolated auth-disabled billing page with correct Chinese text; the billing screenshot also appears visually normal. +- Two Next dev-server processes currently listen on the same workspace/port family (IPv4 and IPv6), so stale browser state or hitting different dev processes is a plausible local explanation. No encoding source change was made because the reported corruption is not reproducible yet. + +### Autofilled login submission — 2026-08-12 +- `AuthLoginPanel` disabled the submit button when React state values were empty, even though browser autofill could populate the visible inputs without firing the state `onChange` handler. +- The form now has named, required controls and reads `phone`/`password` from `new FormData(event.currentTarget)` on submit; the button is only disabled by unavailable configuration or an active submission. +- A browser regression check filled the displayed phone/password and confirmed a `POST /api/auth/password` request was sent after clicking the button. +- Verification passed with the focused auth-panel tests (2/2), full Vitest suite (24 files / 92 tests), TypeScript, and production build. + +### Next development cache recovery — 2026-08-12 +- The reported runtime overlay was `Cannot find module '/9971.js'`, with the require stack rooted in `.next/server/webpack-runtime.js` and the password route. +- The active `.next` directory referenced `9971.js` but the chunk was missing; two Next dev servers were also running against the same workspace/port family. +- Stopped both stale dev servers, moved `.next` to `.next.corrupt-20260812-1042` instead of deleting it, and started one clean `next dev` server on `127.0.0.1:3000`. +- The clean server compiled `/create`, `/auth/login`, `/api/auth/password`, and `/api/health`; browser verification reached the login API and received the expected 401 for intentionally invalid credentials, with no missing-module overlay. + +### Dev/production cache isolation — 2026-08-12 +- The follow-up `ENOENT` referenced a missing `lucide-react` vendor chunk under `.next/server/vendor-chunks`, showing that a later development restart was still sharing the production build directory. +- `next.config.ts` now selects `.next-dev` for `next dev` and keeps `.next` for production builds; `.next-dev/` is ignored by Git. +- Moved the partially generated `.next-dev` directory to `.next-dev.corrupt-20260812-1108` for recovery, then started exactly one clean development server. +- Browser and HTTP verification passed: `/auth/login` rendered Chinese labels without a runtime overlay, `/api/health` returned 200, `/create` redirected to login as expected, and the regenerated Lucide vendor chunk exists in `.next-dev`. + +### Local super-admin credential recovery — 2026-08-12 +- The local account store contains the super-admin phone and password hash, not a recoverable plaintext password; the old password therefore could not be displayed safely. +- After the user selected the super administrator, generated a new strong password and updated the account through `/api/admin/accounts/password`; the new password is not stored in source, documentation, or planning files. +- Saved the credential under the project browser vault profile `super-admin`, then cleared cookies and verified the saved profile logs in successfully and reaches `/create` with super-admin navigation. + +### Inline generation cost estimate — 2026-08-11 +- The create page already debounced requests to `/api/billing/quote` with the same generation payload used on submit, but the preview lived beside the top-level submit button rather than beside the parameters that change the quote. +- The cost card now surfaces the resolved amount, quantity, effective multiplier, and matched parameter tiers next to the parameter controls. It also distinguishes initial, loading, and unavailable states without inventing a client-side price. +- The center workbench is narrow at 1280px because the template and task rails are both visible. A container query keeps the estimate beside the fields when there is room and stacks it inside the parameter area when the actual center column is too narrow; the document remains overflow-free. + +### Fixed EvoLink quote and user-facing estimate — 2026-08-11 +- The overcharge came from an invisible 2K provider default being combined with the 4× high-quality factor and the 1.5× platform multiplier: `¥0.34 × 4 × 4 × 1.5 = ¥8.16`. +- The confirmed product rule is now enforced in the provider client: EvoLink requests use fixed 1K resolution, and the billing catalog defaults to the same 1K tier. The old resolution environment setting/configuration field was removed to prevent request/quote drift. +- The previous `1.5×` multiplier produced `¥0.51/张` for medium and `¥2.04/张` for high quality; the platform-wide default is now `1.2×`, so the same baseline becomes `¥0.41/张` and `¥1.64/张`. +- Ordinary users now see only `本次预计消耗额度` and the amount; platform multiplier, quantity, selected tiers, and helper/status copy are not rendered in the estimate card. Super-admin price controls remain available in the admin billing center. +- Earlier browser verification confirmed the card resolved to `¥0.51` for standard and `¥2.04` for high under the previous 1.5× baseline; the current 1.2× values are covered by the live quote checks recorded below. + +### Unified default billing multiplier — 2026-08-11 +- The platform default markup is now `1.20×` for all built-in provider rules and every seeded parameter tier, replacing the previous `1.50×` default. +- The local initialized billing state was synchronized to `1.20×` for all service rules and parameter tiers; standard-cost values remain unchanged. +- The admin adjustment capability remains available for intentional service/tier overrides; only the default baseline changed. + +### Cross-provider pricing audit — 2026-08-11 +- The shared quote formula is `ceil(effective provider standard unit cost × native quantity × one platform markup)`. Parameter standard factors are applied to the provider baseline first; the platform multiplier is not applied once per parameter dimension. +- Bailian `wan2.7-image-pro` currently matches the official Beijing price of ¥0.50 per generated image. Its current platform quote is ¥0.60/image at the default 1.20× multiplier. +- Bailian `wan2.7-i2v-2026-04-25` currently matches the official Beijing prices of ¥0.60/second at 720P and ¥1.00/second at 1080P. The current quantity source is video duration, so a 5-second quote is ¥3.60 and ¥6.00 respectively after 1.20× markup. No additional parameter multiplier is indicated by the official model page. +- Ark `doubao-seedance-2.0` currently stores the documented no-input-video, 16:9, 5-second examples as rounded per-second baselines: ¥0.46/second (480P), ¥0.99/second (720P), and ¥2.48/second (1080P). These produce current platform quotes of ¥2.76, ¥5.94, and ¥14.88 for five seconds at 1.20×. +- The Ark source explicitly defines Seedance video billing as `token price × token usage`, with token usage affected by input-video duration, output duration, output dimensions, and frame rate; input video costs more than no-input video, and exact usage is returned in `usage.completion_tokens`. The current flat resolution-only Seedance catalog is therefore correct only as a no-input-video estimate, not as an exact quote for all accepted video/audio/material combinations. +- The current Seedance UI/API accepts video and audio materials, but billing normalization records only duration and resolution for the Seedance rule. This is a pricing-model gap: input-video tasks can be underquoted, and exact ratio/frame-rate/material dimensions are not represented in the catalog. +- The current Seedance per-second storage also introduces sample-rounding drift versus the official 5-second totals: 0.46×5=¥2.30 vs ¥2.31, 0.99×5=¥4.95 vs ¥4.97, and 2.48×5=¥12.40 vs ¥12.39. The difference is small but comes from rounding the sample total to a per-second fen price before multiplying. +- Jimeng/Volcengine Visual `jimeng_seedream46_cvtob` has no stable public API price in the current official API document. The catalog's ¥0.20/image is explicitly an editable reference baseline derived from the public ¥200/1000-image activity package, not a guaranteed live API list price. The create UI forces single-image output, so the current one-image quantity is consistent with the active first-party flow. +- No code or production pricing data was changed during this audit. A follow-up implementation needs a product decision for Seedance: keep conservative fixed reference estimates, or add provider-native token/usage reconciliation and a visible estimate policy for input-video jobs. + +### Seedance native usage settlement — 2026-08-12 +- The confirmed policy is: submit-time billing is a wallet reservation; successful Seedance tasks settle against the provider's `usage.completion_tokens`; a missing usage field leaves the reservation unchanged. +- Seedance 2.0 token prices are represented as fen per million tokens: 480p/720p `4600` without input video and `2800` with input video; 1080p `5100`/`3100`; 4K `2600`/`1600`. The platform markup is applied once to the actual token cost. +- The initial Seedance quote now uses the official token-usage estimate. If an input video is present without duration metadata, the reserve uses the supported 15-second upper bound so the organization is not under-reserved by default. +- Query responses read both top-level and nested `usage.completion_tokens`/`completionTokens` shapes. Settlement uses `job-settlement:{jobId}` as its idempotency key, so worker retries cannot create a second adjustment. +- A lower actual amount creates one `refund` difference entry; a higher actual amount creates one additional `charge` difference entry. The job billing snapshot is updated to the final amount and retains the reservation and provider usage details. +- The Seedance catalog now includes the documented 4K reference variant, and the shared video resolution options accept `4k`; fast-model resolution restrictions remain unchanged. + +### Unbound account quote preview — 2026-08-12 +- The empty estimate was caused by the current platform super-admin record having no `organizationId`. The quote route reused the strict organization check used for real task submission, returned an error, and the client rendered the missing quote as `—`. +- Quote-only helpers now allow an unbound session to resolve platform pricing. This does not change charging: `submitImageJob`/`submitVideoJob` still call the strict path, and an unbound real task cannot be submitted or charged. +- Browser verification of the reported parameter combination (`Image2`, `9:16`, `精细`) now returns `¥1.64`; direct service verification with one reference image resolves the same amount and parameter snapshot. + +### Quota guard and super-admin billing exemption — 2026-08-12 +- Confirmed product rule: ordinary platform accounts must not dispatch a real generation task when the shared organization balance cannot cover the frozen quote; the API should return the existing 402 insufficient-balance error so the create page can show it. +- Confirmed product rule: super-admins still receive a provider-cost quote and successful jobs still write the calculated amount into usage records, but super-admin generation is quota-exempt. It must not require an organization, inspect wallet balance, create charge/refund ledger entries, or block on quota. +- The existing submission path already charges before worker/provider dispatch and maps `InsufficientBalanceError` to HTTP 402. The missing pieces are role propagation, a persisted quota-exemption marker, and skipping wallet operations while retaining Seedance actual-usage settlement in the billing snapshot. + +### Billing UI alignment and multiplier dialog — 2026-08-11 +- Price-source links and explanatory notes now use a two-track metadata row: the source action keeps its intrinsic width while the note truncates in the remaining space, preventing baseline drift across service cards. +- 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. diff --git a/lib/auth/config.ts b/lib/auth/config.ts index 020bc5a..dfb70f3 100644 --- a/lib/auth/config.ts +++ b/lib/auth/config.ts @@ -33,13 +33,10 @@ export function getAuthRuntimeConfig(options: { clientMode?: AuthClientMode } = const sessionSecret = envValue("ZHINIAN_AUTH_SESSION_SECRET", "AUTH_SESSION_SECRET", "NEXTAUTH_SECRET"); const explicitRequired = boolEnv("ZHINIAN_AUTH_REQUIRED"); const disabled = boolEnv("ZHINIAN_AUTH_DISABLED") === true; - const hasAnyAuthConfig = Boolean(authBaseUrl || client.clientSecret || sessionSecret); - const required = disabled ? false : explicitRequired ?? (process.env.NODE_ENV === "production" || Boolean(authBaseUrl)); - const wantsConfiguration = required || hasAnyAuthConfig; + const required = disabled ? false : explicitRequired ?? (process.env.NODE_ENV === "production" || Boolean(sessionSecret)); + const wantsConfiguration = required || Boolean(sessionSecret); const missing: string[] = []; - if (wantsConfiguration && !authBaseUrl) missing.push("ZHINIAN_AUTH_BASE_URL"); - if (wantsConfiguration && !client.clientSecret) missing.push(client.missingSecretKey); if (wantsConfiguration && !sessionSecret) missing.push("ZHINIAN_AUTH_SESSION_SECRET"); return { @@ -51,7 +48,7 @@ export function getAuthRuntimeConfig(options: { clientMode?: AuthClientMode } = tokenUrl: endpointUrl(authBaseUrl, "ZHINIAN_AUTH_TOKEN_URL", "/oauth2/token"), jwksUrl: endpointUrl(authBaseUrl, "ZHINIAN_AUTH_JWKS_URL", "/oauth2/jwks"), logoutUrl: endpointUrl(authBaseUrl, "ZHINIAN_AUTH_LOGOUT_URL", "/token/logout"), - clientId: client.clientId, + clientId: "platform", clientSecret: client.clientSecret, scope, issuer, diff --git a/lib/auth/permissions.ts b/lib/auth/permissions.ts index 466fbe4..06d0dc1 100644 --- a/lib/auth/permissions.ts +++ b/lib/auth/permissions.ts @@ -1,45 +1,26 @@ -import type { AuthUser } from "@/lib/auth/session"; +import type { AuthSession, AuthUser } from "@/lib/auth/session"; const DEFAULT_ADMIN_AUTHORITIES = [ "ROLE_ADMIN", - "ROLE_1", - "1", "ADMIN", "SUPER_ADMIN", "SYS_ADMIN", - "ZHINIAN_ADMIN", - "sys_user_view", - "sys_user_add", - "sys_user_edit", - "sys_role_view", - "sys_log_view", - "sys_config_view", - "sys_client_view" + "ZHINIAN_ADMIN" ]; const DEFAULT_ADMIN_USERS = [ "ceshiop" ]; -const ADMIN_PREFIXES = [ - "SYS_USER_", - "SYS_ROLE_", - "SYS_MENU_", - "SYS_LOG_", - "SYS_CONFIG_", - "SYS_CLIENT_", - "ADMIN:" -]; - export function configuredAdminAuthorities(): string[] { const configured = process.env.ZHINIAN_ADMIN_AUTHORITIES; - if (configured === undefined) return DEFAULT_ADMIN_AUTHORITIES; + if (configured === undefined || !configured.trim()) return DEFAULT_ADMIN_AUTHORITIES; return splitConfiguredList(configured); } export function configuredAdminUsers(): string[] { const configured = process.env.ZHINIAN_ADMIN_USERS; - if (configured === undefined) return DEFAULT_ADMIN_USERS; + if (configured === undefined || !configured.trim()) return DEFAULT_ADMIN_USERS; return splitConfiguredList(configured); } @@ -49,18 +30,35 @@ export function hasAdminAccess( adminUsers?: string[] ): boolean { if (!user) return false; + if (user.role) return user.role === "super_admin" || user.role === "organization_admin"; const allowedUsers = new Set((adminUsers ?? configuredAdminUsers()).map(normalizeAccountName)); - const identities = [user.username, user.subject, user.displayName, user.id] + const identities = [user.username, user.subject] .map((item) => item ? normalizeAccountName(item) : "") .filter(Boolean); if (identities.some((identity) => allowedUsers.has(identity))) return true; - if (!shouldUseAuthorityGrants(adminAuthorities)) return false; const allowed = new Set((adminAuthorities ?? configuredAdminAuthorities()).map(normalizeAuthority)); + return user.authorities.some((authority) => allowed.has(normalizeAuthority(authority))); +} + +export function hasSuperAdminAccess(user: AuthUser | null | undefined): boolean { + if (!user) return false; + if (user.role) return user.role === "super_admin"; + const allowedAuthorities = new Set(configuredAdminAuthorities().map(normalizeAuthority)); return user.authorities.some((authority) => { const normalized = normalizeAuthority(authority); - return allowed.has(normalized) || ADMIN_PREFIXES.some((prefix) => normalized.startsWith(prefix)); - }); + return normalized === "SUPER_ADMIN" || normalized === "ROLE_SUPER_ADMIN"; + }) || (user.username ? configuredAdminUsers().some((item) => normalizeAccountName(item) === normalizeAccountName(user.username || "")) : false) || + user.authorities.some((authority) => allowedAuthorities.has(normalizeAuthority(authority)) && normalizeAuthority(authority) === "SUPER_ADMIN"); +} + +export function hasOrganizationAdminAccess(user: AuthUser | null | undefined): boolean { + if (!user) return false; + return user.role === "organization_admin"; +} + +export function hasAdminSessionAccess(session: AuthSession | null | undefined): boolean { + return session?.authMode === "admin" && hasAdminAccess(session.user); } export function normalizeAuthority(value: string): string { @@ -77,9 +75,3 @@ function splitConfiguredList(value: string): string[] { .map((item) => item.trim()) .filter(Boolean); } - -function shouldUseAuthorityGrants(explicitAuthorities?: string[]): boolean { - if (explicitAuthorities !== undefined) return true; - if (process.env.ZHINIAN_ADMIN_AUTHORITIES !== undefined) return true; - return process.env.ZHINIAN_ADMIN_USERS === undefined; -} diff --git a/lib/auth/session.ts b/lib/auth/session.ts index dd5df60..765609a 100644 --- a/lib/auth/session.ts +++ b/lib/auth/session.ts @@ -1,19 +1,30 @@ +import type { PlatformRole } from "@/lib/types"; + export type AuthUser = { id: string; subject: string; username?: string; + phone?: string; displayName: string; clientId: string; tenantId?: string; + organizationId?: string; + organizationName?: string; + role?: PlatformRole; + status?: "active" | "disabled"; authorities: string[]; scope: string[]; }; +export type AuthMode = "user" | "admin"; + export type AuthSession = { version: 1; + authMode: AuthMode; user: AuthUser; issuedAt: number; expiresAt: number; + sessionVersion?: number; accessToken?: string; tokenType?: string; }; @@ -84,12 +95,13 @@ export async function parseSessionCookieValue( secret: string, nowSeconds = Math.floor(Date.now() / 1000) ): Promise { - const session = await parseSignedJsonValue(value, secret); + const session = await parseSignedJsonValue & { authMode?: unknown }>(value, secret); if (!session || session.version !== 1) return null; if (!session.user?.id || !session.user.clientId || !session.expiresAt) return null; if (session.expiresAt <= nowSeconds) return null; return { ...session, + authMode: session.authMode === "admin" ? "admin" : "user", accessToken: typeof session.accessToken === "string" ? session.accessToken : undefined, tokenType: typeof session.tokenType === "string" ? session.tokenType : undefined, user: { diff --git a/lib/billing.ts b/lib/billing.ts new file mode 100644 index 0000000..65cc19a --- /dev/null +++ b/lib/billing.ts @@ -0,0 +1,147 @@ +import type { + BillingConditionValue, + BillingRuleConditions, + BillingParameterSnapshot, + BillingPriceRule, + BillingQuote, + BillingSelectedParameterTier, + BillingUnit, + GenerationCapability, + GenerationProvider +} from "@/lib/types"; + +export const BILLING_CURRENCY = "CNY" as const; + +export const BILLING_UNIT_OPTIONS: Array<{ value: BillingUnit; label: string }> = [ + { value: "request", label: "按次" }, + { value: "image", label: "按张" }, + { value: "video_second", label: "按秒" } +]; + +export function calculateBillingAmountFen(standardUnitPriceFen: number, quantity: number, markupMultiplier: number): number { + if (!Number.isFinite(standardUnitPriceFen) || standardUnitPriceFen < 0) throw new Error("标准单价必须是非负金额。"); + if (!Number.isFinite(quantity) || quantity <= 0) throw new Error("计费数量必须大于 0。"); + if (!Number.isFinite(markupMultiplier) || markupMultiplier < 1) throw new Error("上浮倍率不能低于 1.00。"); + return Math.max(0, Math.ceil(standardUnitPriceFen * quantity * markupMultiplier)); +} + +export function quoteFromPriceRule(input: { + rule: BillingPriceRule; + provider: GenerationProvider; + capability: GenerationCapability; + reqKey: string; + quantity: number; + parameters?: BillingParameterSnapshot; + conditions?: BillingRuleConditions; + quantitySource?: BillingPriceRule["quantitySource"]; +}): BillingQuote { + const { rule } = input; + if (rule.provider !== input.provider || rule.capability !== input.capability) { + throw new Error("计费规则与生成服务不匹配。"); + } + const parameterPricing = resolveBillingParameterPricing(rule, input.parameters); + if (!parameterPricing) throw new Error("当前生成参数没有对应的平台标准价格。"); + const standardUnitPriceFen = parameterPricing.standardUnitPriceFen; + const markupMultiplier = parameterPricing.markupMultiplier; + return { + priceRuleId: rule.id, + provider: input.provider, + capability: input.capability, + reqKey: input.reqKey, + variantKey: rule.variantKey, + unit: rule.unit, + quantity: input.quantity, + standardUnitPriceFen, + markupMultiplier, + amountFen: calculateBillingAmountFen(standardUnitPriceFen, input.quantity, markupMultiplier), + currency: BILLING_CURRENCY, + conditions: input.conditions ?? rule.conditions, + quantitySource: input.quantitySource ?? rule.quantitySource, + parameters: input.parameters, + baseStandardUnitPriceFen: rule.parameterDimensions?.length ? rule.standardUnitPriceFen : undefined, + parameterTiers: parameterPricing.parameterTiers.length ? parameterPricing.parameterTiers : undefined, + source: rule.source + }; +} + +export type BillingParameterPricing = { + standardUnitPriceFen: number; + markupMultiplier: number; + parameterTiers: BillingSelectedParameterTier[]; +}; + +export function resolveBillingParameterPricing(rule: BillingPriceRule, parameters: BillingParameterSnapshot = {}): BillingParameterPricing | null { + const dimensions = rule.parameterDimensions?.filter((dimension) => dimension.tiers.length) || []; + if (!dimensions.length) { + return { + standardUnitPriceFen: rule.standardUnitPriceFen, + markupMultiplier: rule.markupMultiplier, + parameterTiers: [] + }; + } + + let standardFactor = 1; + let markupMultiplier = rule.parameterDimensions?.length ? 1 : rule.markupMultiplier; + const parameterTiers: BillingSelectedParameterTier[] = []; + for (const dimension of dimensions) { + const actualValue = parameters[dimension.key] ?? dimension.defaultValue ?? dimension.baselineValue; + const tier = dimension.tiers.find((candidate) => candidate.enabled && billingConditionMatchesValue(candidate.match ?? candidate.value, actualValue)); + if (!tier) return null; + const factor = Number(tier.standardFactor); + const tierMarkup = Number(tier.markupMultiplier); + if (!Number.isFinite(factor) || factor <= 0 || !Number.isFinite(tierMarkup) || tierMarkup < 1) return null; + standardFactor *= factor; + // A combination has one customer-facing uplift. The highest selected + // tier multiplier wins so editing one expensive tier never lowers the + // configured uplift of another selected tier. + markupMultiplier = Math.max(markupMultiplier, tierMarkup); + parameterTiers.push({ + ...tier, + dimensionKey: dimension.key, + dimensionLabel: dimension.label, + actualValue, + standardUnitPriceFen: Math.ceil(rule.standardUnitPriceFen * factor) + }); + } + return { + standardUnitPriceFen: Math.ceil(rule.standardUnitPriceFen * standardFactor), + markupMultiplier, + parameterTiers + }; +} + +export function billingConditionMatchesValue(condition: BillingConditionValue, actual: string | number | boolean): boolean { + if (typeof condition === "object" && condition !== null && !Array.isArray(condition)) { + if (Array.isArray(condition.values) && !condition.values.some((value) => billingScalarEquals(value, actual))) return false; + const numericActual = Number(actual); + if (condition.min !== undefined && (!Number.isFinite(numericActual) || numericActual < condition.min)) return false; + if (condition.max !== undefined && (!Number.isFinite(numericActual) || numericActual > condition.max)) return false; + return true; + } + return billingScalarEquals(condition as string | number | boolean, actual); +} + +function billingScalarEquals(left: string | number | boolean, right: string | number | boolean): boolean { + if (typeof left === "number" || typeof right === "number") return Number(left) === Number(right); + if (typeof left === "boolean" || typeof right === "boolean") return Boolean(left) === Boolean(right); + return String(left).trim().toLowerCase() === String(right).trim().toLowerCase(); +} + +export function formatBillingAmount(fen: number, currency = BILLING_CURRENCY): string { + const amount = Math.max(0, Number(fen || 0)) / 100; + return new Intl.NumberFormat("zh-CN", { + style: "currency", + currency, + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }).format(amount); +} + +export function billingUnitLabel(unit: BillingUnit): string { + return BILLING_UNIT_OPTIONS.find((item) => item.value === unit)?.label || unit; +} + +export function billingQuantityLabel(quantity: number, unit: BillingUnit): string { + const value = Number.isInteger(quantity) ? String(quantity) : quantity.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""); + return `${value}${unit === "video_second" ? " 秒" : unit === "image" ? " 张" : " 次"}`; +} diff --git a/lib/evolink/image-client.ts b/lib/evolink/image-client.ts index 0f5ec7e..53297a4 100644 --- a/lib/evolink/image-client.ts +++ b/lib/evolink/image-client.ts @@ -2,12 +2,13 @@ import type { EnabledImageCapability, GenerationStatus } from "@/lib/types"; export type ImageCreationEngine = "jimeng" | "evolink" | "bailian"; +export const EVOLINK_PLATFORM_RESOLUTION = "1K" as const; + export type EvolinkImageSettings = { apiKey?: string; baseUrl: string; model: string; quality?: string; - resolution?: string; }; export type EvolinkImageConfig = EvolinkImageSettings & { @@ -33,11 +34,9 @@ export function getSelectedImageEngine(): ImageCreationEngine { } export function getEffectiveImageEngine(capability: EnabledImageCapability, override?: unknown): ImageCreationEngine { - if (capability === "image.upscale") return "jimeng"; const overrideEngine = normalizeImageEngine(override); if (overrideEngine) return overrideEngine; if (capability === "image.generate") return selectedEngineFrom(process.env.IMAGE_GENERATE_ENGINE); - if (capability === "image.inpaint") return selectedEngineFrom(process.env.IMAGE_INPAINT_ENGINE); return getSelectedImageEngine(); } @@ -62,8 +61,7 @@ export function getEvolinkImageSettings(): EvolinkImageSettings { apiKey: process.env.EVOLINK_API_KEY?.trim() || undefined, baseUrl: (process.env.EVOLINK_BASE_URL || "https://api.evolink.ai").replace(/\/+$/, ""), model: process.env.EVOLINK_IMAGE_MODEL || "gpt-image-2", - quality: cleanOptional(process.env.EVOLINK_IMAGE_QUALITY), - resolution: cleanOptional(process.env.EVOLINK_IMAGE_RESOLUTION || "2K") + quality: cleanOptional(process.env.EVOLINK_IMAGE_QUALITY) }; } @@ -85,33 +83,20 @@ export function buildEvolinkImagePayload( input: Record, settings = getEvolinkImageSettings() ): Record { - if (capability === "image.upscale") { - throw new Error("EvoLink image engine does not support upscale in this integration."); - } - const prompt = String(input.prompt || "").trim(); const imageUrls = asStringArray(input.imageUrls); const payload: Record = { model: settings.model, - prompt: prompt || (capability === "image.inpaint" ? "删除" : ""), + prompt, n: 1 }; if (!payload.prompt) throw new Error("Prompt is required for image generation."); const quality = cleanOptional(typeof input.quality === "string" ? input.quality : undefined) || settings.quality; if (quality) payload.quality = quality; - if (settings.resolution) payload.resolution = settings.resolution; + payload.resolution = EVOLINK_PLATFORM_RESOLUTION; assignSize(payload, input); - if (capability === "image.inpaint") { - if (imageUrls.length !== 2) { - throw new Error("EvoLink inpainting requires original image and mask URLs."); - } - payload.image_urls = [imageUrls[0]]; - payload.mask_url = imageUrls[1]; - return payload; - } - if (imageUrls.length) payload.image_urls = imageUrls; return payload; } diff --git a/lib/jimeng/capabilities.ts b/lib/jimeng/capabilities.ts index fe0b379..079d829 100644 --- a/lib/jimeng/capabilities.ts +++ b/lib/jimeng/capabilities.ts @@ -5,7 +5,7 @@ export type JimengCapabilityConfig = { label: string; reqKey: string; enabled: boolean; - route: "generate" | "inpaint" | "upscale"; + route: "generate"; description: string; docsUrl: string; }; @@ -20,24 +20,6 @@ export function getJimengCapabilities(): Record ): Record { - if (capability === "image.generate") { - const payload: Record = { - req_key: reqKey, - prompt: String(input.prompt || "").trim() - }; - if (!payload.prompt) throw new Error("Prompt is required for image generation."); - const imageUrls = asStringArray(input.imageUrls); - if (imageUrls.length) payload.image_urls = imageUrls; - assignOptional(payload, input, ["scale", "width", "height", "min_ratio", "max_ratio", "force_single"]); - return payload; - } - - if (capability === "image.inpaint") { - const imageUrls = asStringArray(input.imageUrls); - if (imageUrls.length !== 2) { - throw new Error("Inpainting requires exactly two public image URLs: original image and mask."); - } - return { - req_key: reqKey, - image_urls: imageUrls, - prompt: String(input.prompt || "删除").trim() || "删除", - ...(typeof input.seed === "number" ? { seed: input.seed } : {}) - }; - } - - const imageUrls = asStringArray(input.imageUrls); - if (imageUrls.length !== 1) { - throw new Error("Upscale requires exactly one public image URL."); - } - const resolution = input.resolution === "8k" ? "8k" : "4k"; - return { + const payload: Record = { req_key: reqKey, - image_urls: imageUrls, - resolution, - ...(typeof input.scale === "number" ? { scale: input.scale } : {}) + prompt: String(input.prompt || "").trim() }; + if (!payload.prompt) throw new Error("Prompt is required for image generation."); + const imageUrls = asStringArray(input.imageUrls); + if (imageUrls.length) payload.image_urls = imageUrls; + assignOptional(payload, input, ["scale", "width", "height", "min_ratio", "max_ratio", "force_single"]); + return payload; } export function buildJimengQueryPayload(reqKey: string, taskId: string): Record { diff --git a/lib/seedance/client.ts b/lib/seedance/client.ts index 6fcd3a2..a6f3037 100644 --- a/lib/seedance/client.ts +++ b/lib/seedance/client.ts @@ -26,9 +26,14 @@ export type SeedanceQueryResult = { status: "queued" | "running" | "succeeded" | "failed" | "cancelled"; resultUrl?: string; errorMessage?: string; + usage?: SeedanceUsage; raw: Record; }; +export type SeedanceUsage = { + completionTokens: number; +}; + export function getSeedanceConfig() { const model = process.env.SEEDANCE_MODEL || "doubao-seedance-2-0-260128"; return { @@ -107,10 +112,21 @@ export async function querySeedanceTask(providerTaskId: string): Promise }; } +export function extractSeedanceUsage(value: unknown): SeedanceUsage | undefined { + const root = recordValue(value); + const data = recordValue(root?.data); + const usage = recordValue(root?.usage) || recordValue(data?.usage); + if (!usage) return undefined; + const completionTokens = Number(usage.completion_tokens ?? usage.completionTokens); + if (!Number.isFinite(completionTokens) || completionTokens <= 0) return undefined; + return { completionTokens: Math.floor(completionTokens) }; +} + function normalizeSeedanceStatus(status: unknown): SeedanceQueryResult["status"] { const value = String(status || "").toLowerCase(); if (["succeeded", "success", "completed"].includes(value)) return "succeeded"; @@ -119,3 +135,9 @@ function normalizeSeedanceStatus(status: unknown): SeedanceQueryResult["status"] if (["running", "processing", "generating"].includes(value)) return "running"; return "queued"; } + +function recordValue(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : undefined; +} diff --git a/lib/server/account-store.ts b/lib/server/account-store.ts new file mode 100644 index 0000000..849c087 --- /dev/null +++ b/lib/server/account-store.ts @@ -0,0 +1,609 @@ +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, + PlatformOrganization, + PlatformRole, + PlatformUserRecord, + OrganizationStatus +} from "@/lib/types"; +import { hashLocalPassword, verifyLocalPassword } from "@/lib/server/auth/password"; +import { createId } from "@/lib/server/ids"; +import { reassignOwnerData } from "@/lib/server/data-store"; +import { dataDir, DEFAULT_OWNER_ID, ensureRuntimeDirs } from "@/lib/server/runtime"; + +const STORE_FILE = "platform-accounts.json"; +const MAX_LOGIN_FAILURES = 5; +const LOCK_DURATION_MS = 15 * 60 * 1000; +let localWriteQueue: Promise = Promise.resolve(); + +type AccountState = { + users: PlatformUserRecord[]; + organizations: PlatformOrganization[]; + migrations: AccountMigration[]; +}; + +export type PlatformUserFilters = { + organizationId?: string; + role?: PlatformRole; + includeDisabled?: boolean; +}; + +export type CreatePlatformUserInput = { + phone: string; + displayName: string; + password: string; + role: PlatformRole; + organizationId?: string; + legacySubject?: string; +}; + +export type UpdatePlatformUserInput = Partial> & { + password?: string; + clearLoginLock?: boolean; +}; + +export class AccountStoreError extends Error { + status: number; + + constructor(message: string, status = 400) { + super(message); + this.name = "AccountStoreError"; + this.status = status; + } +} + +export class AccountLoginError extends AccountStoreError { + constructor(message = "手机号或密码错误。", status = 401) { + super(message, status); + this.name = "AccountLoginError"; + } +} + +export function normalizePhone(value: string): string { + return value.trim().replace(/[\s()-]/g, ""); +} + +export function isValidPhone(value: string): boolean { + return /^\+?[0-9]{6,20}$/.test(normalizePhone(value)); +} + +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); +} + +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); + } + const state = await readLocalState(); + return state.organizations + .filter((organization) => options.includeDisabled || organization.status === "active") + .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); +} + +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; + } + const state = await readLocalState(); + return state.organizations.find((organization) => organization.id === id) || null; +} + +export async function createPlatformOrganization(name: string): Promise { + const normalizedName = name.trim(); + if (!normalizedName) throw new AccountStoreError("组织名称不能为空。", 400); + const now = new Date().toISOString(); + const id = createId("org"); + const organization: PlatformOrganization = { + id, + name: normalizedName, + status: "active", + archiveOwnerId: `archive:${id}`, + createdAt: now, + updatedAt: now + }; + const supabase = getSupabaseAdmin(); + if (supabase) { + const { data, error } = await supabase.from("platform_organizations").insert(organizationToRow(organization)).select("*").single(); + if (error) throw new AccountStoreError(error.message, error.code === "23505" ? 409 : 500); + return organizationFromRow(data); + } + return mutateLocalState((state) => { + if (state.organizations.some((item) => item.name === normalizedName)) throw new AccountStoreError("组织名称已存在。", 409); + state.organizations.push(organization); + return organization; + }); +} + +export async function updatePlatformOrganization(id: string, patch: { name?: string; status?: OrganizationStatus }): Promise { + const nextPatch: Record = { updated_at: new Date().toISOString() }; + 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); + } + return mutateLocalState((state) => { + const organization = state.organizations.find((item) => item.id === id); + if (!organization) throw new AccountStoreError("组织不存在。", 404); + if (patch.name !== undefined && state.organizations.some((item) => item.id !== id && item.name === patch.name?.trim())) { + throw new AccountStoreError("组织名称已存在。", 409); + } + if (patch.name !== undefined) organization.name = patch.name.trim(); + if (patch.status !== undefined) organization.status = patch.status; + organization.updatedAt = new Date().toISOString(); + return organization; + }); +} + +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); + return; + } + await mutateLocalState((state) => { + state.organizations = state.organizations.filter((organization) => organization.id !== id); + }); +} + +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); + } + const state = await readLocalState(); + return state.users + .filter((user) => !filters.organizationId || user.organizationId === filters.organizationId) + .filter((user) => !filters.role || user.role === filters.role) + .filter((user) => filters.includeDisabled || user.status === "active") + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)); +} + +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; + } + const state = await readLocalState(); + const user = state.users.find((item) => item.id === id) || null; + if (user && !options.includeDisabled && user.status !== "active") return null; + return user; +} + +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; + } + const state = await readLocalState(); + const user = state.users.find((item) => item.phone === normalizedPhone) || null; + if (user && !options.includeDisabled && user.status !== "active") return null; + return user; +} + +export async function createPlatformUser(input: CreatePlatformUserInput): Promise { + const phone = normalizePhone(input.phone); + if (!isValidPhone(phone)) throw new AccountStoreError("手机号格式不正确。", 400); + if (!input.displayName.trim()) throw new AccountStoreError("显示名称不能为空。", 400); + if (input.password.length < 8) throw new AccountStoreError("初始密码至少需要 8 位。", 400); + const organization = input.organizationId ? await getPlatformOrganization(input.organizationId) : null; + if (input.organizationId && (!organization || organization.status !== "active")) { + throw new AccountStoreError("账号归属的组织不存在或已停用。", 400); + } + if (input.role !== "super_admin" && (!organization || organization.status !== "active")) { + throw new AccountStoreError("普通账号必须归属有效组织。", 400); + } + const existing = await findPlatformUserByPhone(phone, { includeDisabled: true }); + if (existing) throw new AccountStoreError("该手机号已创建账号。", 409); + const password = await hashLocalPassword(input.password); + const now = new Date().toISOString(); + const user: PlatformUserRecord = { + id: createId("user"), + phone, + displayName: input.displayName.trim(), + role: input.role, + organizationId: input.organizationId, + status: "active", + passwordHash: password.hash, + passwordSalt: password.salt, + failedLoginCount: 0, + sessionVersion: 1, + legacySubject: input.legacySubject, + 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); + } + return mutateLocalState((state) => { + state.users.push(user); + return user; + }); +} + +export async function updatePlatformUser(id: string, patch: UpdatePlatformUserInput): Promise { + const current = await getPlatformUserById(id, { includeDisabled: true }); + if (!current) throw new AccountStoreError("账号不存在。", 404); + const nextOrganizationId = patch.organizationId !== undefined ? patch.organizationId : current.organizationId; + const nextRole = patch.role || current.role; + const organization = nextOrganizationId ? await getPlatformOrganization(nextOrganizationId) : null; + if (nextOrganizationId && (!organization || organization.status !== "active")) { + throw new AccountStoreError("账号归属的组织不存在或已停用。", 400); + } + if (nextRole !== "super_admin") { + if (!organization || organization.status !== "active") throw new AccountStoreError("普通账号必须归属有效组织。", 400); + } + if (patch.password !== undefined && patch.password.length < 8) throw new AccountStoreError("新密码至少需要 8 位。", 400); + const nextPassword = patch.password ? await hashLocalPassword(patch.password) : null; + const next: PlatformUserRecord = { + ...current, + displayName: patch.displayName?.trim() || current.displayName, + role: nextRole, + organizationId: nextOrganizationId, + status: patch.status || current.status, + passwordHash: nextPassword?.hash || current.passwordHash, + passwordSalt: nextPassword?.salt || current.passwordSalt, + failedLoginCount: patch.clearLoginLock ? 0 : current.failedLoginCount, + lockedUntil: patch.clearLoginLock ? undefined : current.lockedUntil, + 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); + } + return mutateLocalState((state) => { + const index = state.users.findIndex((item) => item.id === id); + if (index < 0) throw new AccountStoreError("账号不存在。", 404); + state.users[index] = next; + return next; + }); +} + +export async function deletePlatformUser(id: string): Promise { + const user = await getPlatformUserById(id, { includeDisabled: true }); + if (!user) throw new AccountStoreError("账号不存在。", 404); + 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); + return; + } + await mutateLocalState((state) => { + state.users = state.users.filter((item) => item.id !== id); + }); +} + +export async function authenticatePlatformUser(phone: string, password: string): Promise { + const user = await findPlatformUserByPhone(phone, { includeDisabled: true }); + if (!user) throw new AccountLoginError(); + if (user.status !== "active") throw new AccountLoginError("账号已停用,请联系管理员。", 403); + if (user.role !== "super_admin" && user.organizationId) { + const organization = await getPlatformOrganization(user.organizationId); + if (!organization || organization.status !== "active") throw new AccountLoginError("所属组织已停用,请联系管理员。", 403); + } + if (user.lockedUntil && user.lockedUntil > new Date().toISOString()) { + throw new AccountLoginError("登录失败次数过多,请 15 分钟后再试。", 423); + } + const valid = await verifyLocalPassword(password, user.passwordHash, user.passwordSalt); + if (!valid) { + const failedLoginCount = user.failedLoginCount + 1; + const lockedUntil = failedLoginCount >= MAX_LOGIN_FAILURES ? new Date(Date.now() + LOCK_DURATION_MS).toISOString() : undefined; + await updateLoginState(user.id, { + failedLoginCount: lockedUntil ? 0 : failedLoginCount, + lockedUntil + }); + if (lockedUntil) throw new AccountLoginError("登录失败次数过多,请 15 分钟后再试。", 423); + throw new AccountLoginError(); + } + const now = new Date().toISOString(); + await updateLoginState(user.id, { failedLoginCount: 0, lockedUntil: null, lastLoginAt: now }); + const refreshed = await getPlatformUserById(user.id, { includeDisabled: true }); + if (!refreshed) throw new AccountLoginError(); + return refreshed; +} + +export async function changeOwnPassword(userId: string, currentPassword: string, nextPassword: string): Promise { + const user = await getPlatformUserById(userId, { includeDisabled: true }); + if (!user || user.status !== "active") throw new AccountStoreError("账号不存在或已停用。", 404); + if (!await verifyLocalPassword(currentPassword, user.passwordHash, user.passwordSalt)) { + throw new AccountStoreError("当前密码不正确。", 400); + } + if (!nextPassword || nextPassword.length < 8) throw new AccountStoreError("新密码至少需要 8 位。", 400); + return updatePlatformUser(userId, { password: nextPassword }); +} + +export async function upsertAccountMigration(input: Omit): Promise { + const migration: AccountMigration = { + ...input, + id: createId("migration"), + createdAt: new Date().toISOString() + }; + const supabase = getSupabaseAdmin(); + if (supabase) { + const { data, error } = await supabase.from("platform_account_migrations").upsert(migrationToRow(migration), { onConflict: "legacy_owner_id" }).select("*").single(); + if (error) throw new AccountStoreError(error.message, 500); + return migrationFromRow(data); + } + return mutateLocalState((state) => { + const index = state.migrations.findIndex((item) => item.legacyOwnerId === input.legacyOwnerId); + if (index >= 0) state.migrations[index] = migration; + else state.migrations.push(migration); + return migration; + }); +} + +async function updateLoginState(id: string, patch: { failedLoginCount: number; lockedUntil?: string | null; lastLoginAt?: string }) { + const values = { + failed_login_count: patch.failedLoginCount, + locked_until: patch.lockedUntil || null, + ...(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); + return; + } + await mutateLocalState((state) => { + const user = state.users.find((item) => item.id === id); + if (!user) return; + user.failedLoginCount = patch.failedLoginCount; + user.lockedUntil = patch.lockedUntil || undefined; + if (patch.lastLoginAt) user.lastLoginAt = patch.lastLoginAt; + user.updatedAt = new Date().toISOString(); + }); +} + +async function readLocalState(): Promise { + await ensureRuntimeDirs(); + const path = join(dataDir(), STORE_FILE); + try { + return normalizeState(JSON.parse(await readFile(path, "utf8"))); + } catch { + const state = normalizeState({}); + await writeLocalState(state); + return state; + } +} + +async function writeLocalState(state: AccountState): Promise { + await ensureRuntimeDirs(); + const path = join(dataDir(), STORE_FILE); + const temp = `${path}.${createId("tmp")}.tmp`; + await writeFile(temp, JSON.stringify(state, null, 2)); + await rename(temp, path); +} + +async function mutateLocalState(mutator: (state: AccountState) => T): Promise { + const run = localWriteQueue.then(async () => { + const state = await readLocalState(); + const result = mutator(state); + await writeLocalState(state); + return result; + }); + localWriteQueue = run.catch(() => undefined); + return run; +} + +function normalizeState(raw: Partial): AccountState { + const organizations = Array.isArray(raw.organizations) ? raw.organizations.map(normalizeOrganization) : []; + const users = Array.isArray(raw.users) ? raw.users.map(normalizeUser) : []; + if (!users.length && !authRequiredByEnv()) { + const now = new Date().toISOString(); + const organization: PlatformOrganization = { + id: "org-demo", + name: "演示组织", + status: "active", + archiveOwnerId: "archive:org-demo", + createdAt: now, + updatedAt: now + }; + organizations.push(organization); + users.push({ + id: DEFAULT_OWNER_ID, + phone: "13800000000", + displayName: "智念演示用户", + role: "super_admin", + organizationId: organization.id, + status: "active", + passwordHash: "", + passwordSalt: "", + failedLoginCount: 0, + sessionVersion: 1, + createdAt: now, + updatedAt: now + }); + } + return { + users, + organizations, + migrations: Array.isArray(raw.migrations) ? raw.migrations.map(normalizeMigration) : [] + }; +} + +function authRequiredByEnv(): boolean { + const disabled = process.env.ZHINIAN_AUTH_DISABLED?.trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(disabled || "")) return false; + const explicit = process.env.ZHINIAN_AUTH_REQUIRED?.trim().toLowerCase(); + if (["0", "false", "no", "off"].includes(explicit || "")) return false; + return true; +} + +function normalizeOrganization(value: PlatformOrganization): PlatformOrganization { + const now = new Date().toISOString(); + return { + id: String(value.id), + name: String(value.name || "未命名组织"), + status: value.status === "disabled" ? "disabled" : "active", + archiveOwnerId: String(value.archiveOwnerId || `archive:${value.id}`), + createdAt: String(value.createdAt || now), + updatedAt: String(value.updatedAt || now) + }; +} + +function normalizeUser(value: PlatformUserRecord): PlatformUserRecord { + const now = new Date().toISOString(); + return { + id: String(value.id), + phone: normalizePhone(String(value.phone || "")), + displayName: String(value.displayName || "未命名用户"), + role: value.role === "super_admin" || value.role === "organization_admin" ? value.role : "user", + organizationId: value.organizationId ? String(value.organizationId) : undefined, + status: value.status === "disabled" ? "disabled" : "active", + passwordHash: String(value.passwordHash || ""), + passwordSalt: String(value.passwordSalt || ""), + failedLoginCount: Number(value.failedLoginCount || 0), + lockedUntil: value.lockedUntil ? String(value.lockedUntil) : undefined, + sessionVersion: Number(value.sessionVersion || 1), + lastLoginAt: value.lastLoginAt ? String(value.lastLoginAt) : undefined, + legacySubject: value.legacySubject ? String(value.legacySubject) : undefined, + createdAt: String(value.createdAt || now), + updatedAt: String(value.updatedAt || now) + }; +} + +function normalizeMigration(value: AccountMigration): AccountMigration { + return { + id: String(value.id), + legacyOwnerId: String(value.legacyOwnerId), + legacyPhone: value.legacyPhone ? normalizePhone(String(value.legacyPhone)) : undefined, + platformUserId: String(value.platformUserId), + createdAt: String(value.createdAt || new Date().toISOString()) + }; +} + +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 organizationToRow(organization: PlatformOrganization) { + return { + id: organization.id, + name: organization.name, + status: organization.status, + archive_owner_id: organization.archiveOwnerId, + created_at: organization.createdAt, + updated_at: organization.updatedAt + }; +} + +function organizationFromRow(row: Record): PlatformOrganization { + return { + id: String(row.id), + 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) + }; +} + +function userToRow(user: PlatformUserRecord) { + return { + id: user.id, + phone: user.phone, + display_name: user.displayName, + role: user.role, + organization_id: user.organizationId || null, + status: user.status, + password_hash: user.passwordHash, + password_salt: user.passwordSalt, + failed_login_count: user.failedLoginCount, + locked_until: user.lockedUntil || null, + session_version: user.sessionVersion, + last_login_at: user.lastLoginAt || null, + legacy_subject: user.legacySubject || null, + created_at: user.createdAt, + updated_at: user.updatedAt + }; +} + +function userFromRow(row: Record): PlatformUserRecord { + return normalizeUser({ + id: String(row.id), + phone: String(row.phone || ""), + displayName: String(row.display_name || ""), + role: row.role as PlatformRole, + organizationId: row.organization_id ? String(row.organization_id) : undefined, + status: row.status as AccountStatus, + 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, + sessionVersion: Number(row.session_version || 1), + lastLoginAt: row.last_login_at ? String(row.last_login_at) : undefined, + legacySubject: row.legacy_subject ? String(row.legacy_subject) : undefined, + createdAt: String(row.created_at), + updatedAt: String(row.updated_at) + }); +} + +function migrationToRow(migration: AccountMigration) { + return { + id: migration.id, + legacy_owner_id: migration.legacyOwnerId, + legacy_phone: migration.legacyPhone || null, + platform_user_id: migration.platformUserId, + created_at: migration.createdAt + }; +} + +function migrationFromRow(row: Record): AccountMigration { + return { + id: String(row.id), + 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) + }; +} diff --git a/lib/server/app-settings.ts b/lib/server/app-settings.ts index 726280b..36a3493 100644 --- a/lib/server/app-settings.ts +++ b/lib/server/app-settings.ts @@ -4,7 +4,7 @@ import { getEvolinkImageSettings, getSelectedImageEngine, shouldMockEvolinkApi, import { authConfigSummary, getAuthRuntimeConfig } from "@/lib/auth/config"; import { getJimengCapabilities } from "@/lib/jimeng/capabilities"; import { getSeedanceConfig, shouldMockSeedance } from "@/lib/seedance/client"; -import { accountManagementConfigured } from "@/lib/server/organization-client"; +import { platformAccountStoreConfigured } from "@/lib/server/account-store"; import { rootDir } from "@/lib/server/runtime"; import { shouldMockVisualApi } from "@/lib/volcengine/visual-client"; import type { EnabledImageCapability } from "@/lib/types"; @@ -33,8 +33,8 @@ export type EngineAssignment = { label: string; engine: string; engineLabel: string; - mode: string; - modeLabel: string; + connected: boolean; + connectionLabel: string; reqKey: string; configurable: boolean; field?: SettingsField; @@ -52,8 +52,8 @@ const settingDefinitions: Array<{ }> = [ { id: "auth", - title: "账户登录 SSO", - description: "用于发布环境的统一认证中心登录;client_secret 与 session secret 只保存在服务端。", + title: "平台账号安全", + description: "平台自建手机号账号登录;密码哈希只保存在服务端,登录会话使用 HttpOnly 签名 Cookie。", fields: [ { key: "ZHINIAN_AUTH_REQUIRED", @@ -66,32 +66,28 @@ const settingDefinitions: Array<{ { label: "停用", value: "0" } ] }, - { key: "ZHINIAN_AUTH_BASE_URL", label: "Auth Base URL" }, - { key: "ZHINIAN_AUTH_CLIENT_ID", label: "普通客户端 ID", defaultValue: "custom" }, - { key: "ZHINIAN_AUTH_CLIENT_SECRET", label: "客户端密钥", secret: true, type: "password" }, - { key: "ZHINIAN_ADMIN_AUTH_CLIENT_ID", label: "管理员客户端 ID", defaultValue: "app" }, - { key: "ZHINIAN_ADMIN_AUTH_CLIENT_SECRET", label: "管理员客户端密钥", secret: true, type: "password" }, - { key: "ZHINIAN_AUTH_TENANT_ID", label: "普通登录租户 ID", description: "为空时复用 ZHINIAN_ORG_TENANT_ID" }, - { key: "ZHINIAN_ADMIN_AUTH_TENANT_ID", label: "管理员登录租户 ID", description: "通常留空,避免管理员登录被普通租户影响" }, - { key: "ZHINIAN_AUTH_SCOPE", label: "Scope", defaultValue: "server" }, - { key: "ZHINIAN_AUTH_ISSUER", label: "Issuer", defaultValue: "https://pig4cloud.com" }, - { key: "ZHINIAN_AUTH_PASSWORD_ENC_KEY", label: "Password Encryption Key", secret: true, type: "password" }, { key: "ZHINIAN_AUTH_SESSION_SECRET", label: "会话签名密钥", secret: true, type: "password" } ] }, { - id: "organization", - title: "组织账号接口", - description: "用于后台账号管理的组织、成员、角色和部门接口;服务端代调用组织服务。", + id: "billing", + title: "企业计费", + description: "管理真实任务计费开关和成员线下转账时看到的对公账户信息。", fields: [ - { key: "ZHINIAN_ADMIN_AUTHORITIES", label: "管理员权限码", description: "逗号分隔,如 ROLE_ADMIN,sys_user_view" }, - { key: "ZHINIAN_ADMIN_USERS", label: "管理员账号", description: "逗号分隔,默认 ceshiop" }, - { key: "ZHINIAN_ORG_API_BASE_URL", label: "组织服务 Base URL", description: "如 https://gateway.example.com/hotelStaff" }, - { key: "ZHINIAN_ORG_API_TOKEN", label: "组织服务备用 Token", secret: true, type: "password" }, - { key: "ZHINIAN_STAFF_API_BASE_URL", label: "企业端用户服务 Base URL", description: "如 https://gateway.example.com/hotel-staff-server-biz" }, - { key: "ZHINIAN_STAFF_API_TOKEN", label: "企业端用户服务备用 Token", secret: true, type: "password" }, - { key: "ZHINIAN_ORG_TENANT_ID", label: "租户 ID" }, - { key: "ZHINIAN_ORG_ID", label: "默认组织 ID" } + { + key: "ZHINIAN_BILLING_REQUIRED", + label: "真实任务计费", + type: "select", + defaultValue: "1", + options: [ + { label: "启用", value: "1" }, + { label: "停用(免计费)", value: "0" } + ] + }, + { key: "ZHINIAN_BILLING_ACCOUNT_NAME", label: "对公账户名称" }, + { key: "ZHINIAN_BILLING_ACCOUNT_BANK", label: "开户行" }, + { key: "ZHINIAN_BILLING_ACCOUNT_NUMBER", label: "银行账号" }, + { key: "ZHINIAN_BILLING_CONTACT", label: "充值对接信息" } ] }, { @@ -106,24 +102,12 @@ const settingDefinitions: Array<{ { id: "evolink", title: "EvoLink 图片 API", - description: "用于 GPT Image 2 图片生成和局部重绘;Base URL 和模型使用系统默认值即可。", + description: "用于 GPT Image 2 图片生成;Base URL 和模型使用系统默认值即可。", fields: [ { key: "EVOLINK_API_KEY", label: "EvoLink API Key", secret: true, type: "password" }, { key: "EVOLINK_BASE_URL", label: "Base URL", defaultValue: "https://api.evolink.ai" }, { key: "EVOLINK_IMAGE_MODEL", label: "图片模型", defaultValue: "gpt-image-2" }, - { key: "EVOLINK_IMAGE_QUALITY", label: "质量", defaultValue: "medium" }, - { key: "EVOLINK_IMAGE_RESOLUTION", label: "分辨率", defaultValue: "2K" }, - { - key: "EVOLINK_MOCK", - label: "Mock 策略", - type: "select", - defaultValue: "auto", - options: [ - { label: "自动", value: "auto" }, - { label: "总是 Mock", value: "true" }, - { label: "总是真实接口", value: "false" } - ] - } + { key: "EVOLINK_IMAGE_QUALITY", label: "质量", defaultValue: "medium" } ] }, { @@ -142,14 +126,13 @@ const settingDefinitions: Array<{ { key: "BAILIAN_API_KEY", label: "百炼 API Key", secret: true, type: "password" }, { key: "BAILIAN_BASE_URL", label: "Base URL", defaultValue: "https://llm-126wneubbdo6dbr5.cn-beijing.maas.aliyuncs.com/compatible-mode/v1" }, { key: "BAILIAN_IMAGE_MODEL", label: "图片模型", defaultValue: "wan2.7-image-pro" }, - { key: "BAILIAN_VIDEO_MODEL", label: "视频模型", defaultValue: "wan2.7-i2v-2026-04-25" }, - { key: "BAILIAN_MOCK", label: "Mock 策略", type: "select", defaultValue: "auto", options: [{ label: "自动", value: "auto" }, { label: "总是 Mock", value: "true" }, { label: "总是真实接口", value: "false" }] } + { key: "BAILIAN_VIDEO_MODEL", label: "视频模型", defaultValue: "wan2.7-i2v-2026-04-25" } ] }, { id: "oss", title: "OSS 资产存储", - description: "用于上传素材、mask、生成图和视频结果的公网转存;对象前缀使用默认值。", + description: "用于上传素材、生成图和视频结果的公网转存;对象前缀使用默认值。", fields: [ { key: "ALI_OSS_ENDPOINT", label: "Endpoint" }, { key: "ALI_OSS_BUCKET", label: "Bucket" }, @@ -176,22 +159,12 @@ const engineFieldDefinitions: FieldDefinition[] = [ key: "VIDEO_GENERATE_ENGINE", label: "视频生成", type: "select", - defaultValue: "seedance", + defaultValue: "bailian", options: [ { label: "Seedance", value: "seedance" }, { label: "阿里云百炼 Wan 2.7", value: "bailian" } ] }, - { - key: "IMAGE_INPAINT_ENGINE", - label: "局部重绘", - type: "select", - defaultValue: "jimeng", - options: [ - { label: "即梦 / 火山视觉", value: "jimeng" }, - { label: "EvoLink GPT Image 2", value: "evolink" } - ] - } ]; const allowedKeys = new Set([ @@ -216,15 +189,13 @@ export async function getApiSettings() { const auth = getAuthRuntimeConfig(); const engineAssignments = buildEngineAssignments(fileEnv); return { - envPath: envFilePath(), - modes: { - visual: shouldMockVisualApi() ? "mock" : "real", - evolink: shouldMockEvolinkApi() ? "mock" : "real", - seedance: shouldMockSeedance() ? "mock" : "real", - bailian: shouldMockBailian() ? "mock" : getBailianConfig().apiKey ? "real" : "missing", - auth: authConfigSummary(auth), - organization: accountManagementConfigured() ? "configured" : "missing", - data: process.env.SUPABASE_SERVICE_ROLE_KEY ? "supabase" : "local" + services: { + visual: !shouldMockVisualApi(), + evolink: !shouldMockEvolinkApi(), + seedance: !shouldMockSeedance(), + bailian: !shouldMockBailian() && Boolean(getBailianConfig().apiKey), + auth: authConfigSummary(auth) === "configured", + organization: platformAccountStoreConfigured() }, capabilities: [ ...Object.values(getJimengCapabilities()).map((capability) => { @@ -279,7 +250,8 @@ function buildEngineAssignments(fileEnv: Map): EngineAssignment[ const seedance = getSeedanceConfig(); const bailian = getBailianConfig(); const generateEngine = currentEngineValue(engineFieldDefinitions[0], fileEnv); - const inpaintEngine = currentEngineValue(engineFieldDefinitions[2], fileEnv); + const videoEngine = currentVideoEngineValue(engineFieldDefinitions[1], fileEnv); + const videoConnected = videoEngine === "bailian" ? !shouldMockBailian() : !shouldMockSeedance(); return [ imageEngineAssignment({ @@ -290,34 +262,16 @@ function buildEngineAssignments(fileEnv: Map): EngineAssignment[ field: engineFieldDefinitions[0], fileEnv }), - imageEngineAssignment({ - capability: "image.inpaint", - label: "局部重绘", - engine: inpaintEngine, - reqKey: inpaintEngine === "evolink" ? evolink.model : capabilities["image.inpaint"].reqKey, - field: engineFieldDefinitions[2], - fileEnv - }), - { - id: "image.upscale", - label: "智能超清", - engine: "jimeng", - engineLabel: "即梦", - mode: shouldMockVisualApi() ? "mock" : "volcengine", - modeLabel: shouldMockVisualApi() ? "Mock" : "即梦真实接口", - reqKey: capabilities["image.upscale"].reqKey, - configurable: false - }, { id: "video.generate", label: "视频生成", - engine: currentVideoEngineValue(engineFieldDefinitions[1], fileEnv), - engineLabel: currentVideoEngineValue(engineFieldDefinitions[1], fileEnv) === "bailian" ? "阿里云百炼" : "Seedance", - mode: currentVideoEngineValue(engineFieldDefinitions[1], fileEnv) === "bailian" ? (shouldMockBailian() ? "mock" : "bailian") : (shouldMockSeedance() ? "mock" : "seedance"), - modeLabel: currentVideoEngineValue(engineFieldDefinitions[1], fileEnv) === "bailian" ? (shouldMockBailian() ? "Mock" : "百炼真实接口") : (shouldMockSeedance() ? "Mock" : "Seedance 真实接口"), - reqKey: currentVideoEngineValue(engineFieldDefinitions[1], fileEnv) === "bailian" ? bailian.videoModel : seedance.model, + engine: videoEngine, + engineLabel: videoEngine === "bailian" ? "阿里云百炼" : "Seedance", + connected: videoConnected, + connectionLabel: videoConnected ? "已连接" : "待配置", + reqKey: videoEngine === "bailian" ? bailian.videoModel : seedance.model, configurable: true, - field: fieldWithValue(engineFieldDefinitions[1], currentVideoEngineValue(engineFieldDefinitions[1], fileEnv), isConfigured(engineFieldDefinitions[1].key, fileEnv)) + field: fieldWithValue(engineFieldDefinitions[1], videoEngine, isConfigured(engineFieldDefinitions[1].key, fileEnv)) } ]; } @@ -330,14 +284,14 @@ function imageEngineAssignment(input: { field: FieldDefinition; fileEnv: Map; }): EngineAssignment { - const mode = modeForImageEngine(input.engine); + const connected = imageEngineConnected(input.engine); return { id: input.capability, label: input.label, engine: input.engine, engineLabel: imageEngineLabel(input.engine), - mode, - modeLabel: modeLabel(mode, input.engine), + connected, + connectionLabel: connected ? "已连接" : "待配置", reqKey: input.reqKey, configurable: true, field: fieldWithValue(input.field, currentEngineValue(input.field, input.fileEnv), isConfigured(input.field.key, input.fileEnv)) @@ -371,15 +325,10 @@ function imageEngineLabel(engine: ImageCreationEngine) { return engine === "evolink" ? "EvoLink" : engine === "bailian" ? "阿里云百炼" : "即梦"; } -function modeForImageEngine(engine: ImageCreationEngine) { - if (engine === "evolink") return shouldMockEvolinkApi() ? "mock" : "evolink"; - if (engine === "bailian") return shouldMockBailian() ? "mock" : "bailian"; - return shouldMockVisualApi() ? "mock" : "volcengine"; -} - -function modeLabel(mode: string, engine: ImageCreationEngine) { - if (mode === "mock") return "Mock"; - return engine === "evolink" ? "EvoLink 真实接口" : engine === "bailian" ? "百炼真实接口" : "即梦真实接口"; +function imageEngineConnected(engine: ImageCreationEngine) { + if (engine === "evolink") return !shouldMockEvolinkApi(); + if (engine === "bailian") return !shouldMockBailian(); + return !shouldMockVisualApi(); } function currentVideoEngineValue(field: FieldDefinition, fileEnv: Map) { diff --git a/lib/server/auth/current-user.ts b/lib/server/auth/current-user.ts index df05d2c..ff8f2bb 100644 --- a/lib/server/auth/current-user.ts +++ b/lib/server/auth/current-user.ts @@ -1,8 +1,10 @@ import { cookies } from "next/headers"; import { SESSION_COOKIE_NAME, getAuthRuntimeConfig } from "@/lib/auth/config"; -import { hasAdminAccess } from "@/lib/auth/permissions"; +import { hasAdminSessionAccess, hasSuperAdminAccess } from "@/lib/auth/permissions"; import { parseSessionCookieValue, readChunkedCookieValue, type AuthSession, type AuthUser } from "@/lib/auth/session"; import { DEFAULT_OWNER_ID } from "@/lib/server/runtime"; +import { getPlatformOrganization, getPlatformUserById } from "@/lib/server/account-store"; +import { authUserFromPlatformRecord } from "@/lib/server/auth/local"; export class AuthRequiredError extends Error { status = 401; @@ -25,9 +27,13 @@ export class AuthConfigurationError extends Error { const localUser: AuthUser = { id: DEFAULT_OWNER_ID, subject: DEFAULT_OWNER_ID, - username: "demo", - displayName: "智念演示用户", + username: "13800000000", + phone: "13800000000", + displayName: "智念用户", clientId: "local-dev", + role: "super_admin", + organizationId: "org-demo", + organizationName: "演示组织", authorities: ["zhinian_admin"], scope: [] }; @@ -36,6 +42,7 @@ function localSession(): AuthSession { const now = Math.floor(Date.now() / 1000); return { version: 1, + authMode: "admin", user: localUser, issuedAt: now, expiresAt: now + 24 * 60 * 60 @@ -46,10 +53,22 @@ export async function getOptionalAuthSession(): Promise { const config = getAuthRuntimeConfig(); if (!config.sessionSecret) return null; const cookieStore = await cookies(); - return parseSessionCookieValue( + const session = await parseSessionCookieValue( readChunkedCookieValue(SESSION_COOKIE_NAME, (name) => cookieStore.get(name)?.value), config.sessionSecret ); + if (!session || session.user.clientId !== "platform") return null; + const account = await getPlatformUserById(session.user.id); + if (!account || account.status !== "active") return null; + if (session.sessionVersion && session.sessionVersion !== account.sessionVersion) return null; + const organization = account.organizationId ? await getPlatformOrganization(account.organizationId) : null; + if (account.role !== "super_admin" && account.organizationId && (!organization || organization.status !== "active")) return null; + return { + ...session, + authMode: account.role === "user" ? "user" : "admin", + user: authUserFromPlatformRecord(account, organization), + sessionVersion: account.sessionVersion + }; } export async function requireAppSession(): Promise { @@ -82,23 +101,44 @@ export async function requireAdminUser(): Promise { export async function requireAdminSession(): Promise { const session = await requireAppSession(); - if (!hasAdminAccess(session.user)) throw new AdminRequiredError(); + if (!hasAdminSessionAccess(session)) throw new AdminRequiredError(); return session; } +export class SuperAdminRequiredError extends Error { + status = 403; + + constructor(message = "需要超级管理员权限。") { + super(message); + this.name = "SuperAdminRequiredError"; + } +} + +export async function requireSuperAdminSession(): Promise { + const session = await requireAppSession(); + if (!hasSuperAdminAccess(session.user)) throw new SuperAdminRequiredError(); + return session; +} + +export async function requireSuperAdminUser(): Promise { + return (await requireSuperAdminSession()).user; +} + export async function getShellAuthState(): Promise<{ user: AuthUser | null; authRequired: boolean; authConfigured: boolean; isAdmin: boolean; + isSuperAdmin: boolean; }> { const config = getAuthRuntimeConfig(); const session = await getOptionalAuthSession(); - const user = session?.user || (!config.required ? localUser : null); + const shellSession = session || (!config.required ? localSession() : null); return { - user, + user: shellSession?.user || null, authRequired: config.required, authConfigured: config.configured, - isAdmin: hasAdminAccess(user) + isAdmin: hasAdminSessionAccess(shellSession), + isSuperAdmin: hasSuperAdminAccess(shellSession?.user) }; } diff --git a/lib/server/auth/jwt.ts b/lib/server/auth/jwt.ts index 207f747..7b78e2f 100644 --- a/lib/server/auth/jwt.ts +++ b/lib/server/auth/jwt.ts @@ -1,7 +1,7 @@ import { createPublicKey, createVerify } from "node:crypto"; import type { JsonWebKey as CryptoJsonWebKey, KeyObject } from "node:crypto"; import { getAuthRuntimeConfig, type AuthRuntimeConfig } from "@/lib/auth/config"; -import type { AuthSession, AuthUser } from "@/lib/auth/session"; +import type { AuthMode, AuthSession, AuthUser } from "@/lib/auth/session"; export type AuthTokenClaims = { iss?: string; @@ -76,7 +76,7 @@ export function createSessionFromClaims( claims: AuthTokenClaims, config: AuthRuntimeConfig, tokenResponseExpiresIn?: number, - token?: { accessToken?: string; tokenType?: string } + token?: { accessToken?: string; tokenType?: string; authMode?: AuthMode } ): AuthSession { const now = Math.floor(Date.now() / 1000); const jwtExpiry = numberClaim(claims.exp); @@ -84,6 +84,7 @@ export function createSessionFromClaims( const expiresAt = Math.min(jwtExpiry || responseExpiry || now, responseExpiry || jwtExpiry || now); return { version: 1, + authMode: token?.authMode === "admin" ? "admin" : "user", user: userFromClaims(claims, config), issuedAt: now, expiresAt, diff --git a/lib/server/auth/local.ts b/lib/server/auth/local.ts new file mode 100644 index 0000000..1169d80 --- /dev/null +++ b/lib/server/auth/local.ts @@ -0,0 +1,103 @@ +import type { NextResponse } from "next/server"; +import { SESSION_COOKIE_NAME, shouldUseSecureAuthCookie } from "@/lib/auth/config"; +import type { AuthMode, AuthSession, AuthUser } from "@/lib/auth/session"; +import type { PlatformOrganization, PlatformUserRecord } from "@/lib/types"; +import { clearSessionCookieValues, setSessionCookieValue } from "@/lib/server/auth/session-cookie"; +import { createSessionCookieValue } from "@/lib/auth/session"; +import { getPlatformOrganization } from "@/lib/server/account-store"; + +export const LOCAL_SESSION_TTL_SECONDS = 24 * 60 * 60; + +const ipAttempts = new Map(); + +export function authUserFromPlatformRecord(user: PlatformUserRecord, organization?: PlatformOrganization | null): AuthUser { + const role = user.role; + const authorities = role === "super_admin" + ? ["ROLE_SUPER_ADMIN", "SUPER_ADMIN"] + : role === "organization_admin" + ? ["ROLE_ORGANIZATION_ADMIN", "ORGANIZATION_ADMIN"] + : ["ROLE_USER"]; + return { + id: user.id, + subject: user.id, + username: user.phone, + phone: user.phone, + displayName: user.displayName, + clientId: "platform", + organizationId: user.organizationId, + organizationName: organization?.name, + role, + status: user.status, + authorities, + scope: [] + }; +} + +export async function createPlatformSession(user: PlatformUserRecord): Promise { + const organization = user.organizationId ? await getPlatformOrganization(user.organizationId) : null; + const now = Math.floor(Date.now() / 1000); + const authMode: AuthMode = user.role === "user" ? "user" : "admin"; + return { + version: 1, + authMode, + user: authUserFromPlatformRecord(user, organization), + issuedAt: now, + expiresAt: now + LOCAL_SESSION_TTL_SECONDS, + sessionVersion: user.sessionVersion + }; +} + +export async function setPlatformSessionCookie( + response: NextResponse, + requestUrl: string, + session: AuthSession +) { + const secret = process.env.ZHINIAN_AUTH_SESSION_SECRET || process.env.AUTH_SESSION_SECRET || process.env.NEXTAUTH_SECRET; + if (!secret) throw new Error("ZHINIAN_AUTH_SESSION_SECRET 未配置。"); + setSessionCookieValue( + response, + requestUrl, + await createSessionCookieValue(session, secret), + new Date(session.expiresAt * 1000) + ); +} + +export function clearPlatformSessionCookies(response: NextResponse, requestUrl: string) { + clearSessionCookieValues(response, requestUrl); + response.cookies.set(SESSION_COOKIE_NAME, "", { + httpOnly: true, + sameSite: "lax", + secure: shouldUseSecureAuthCookie(requestUrl), + path: "/", + maxAge: 0 + }); +} + +export function clientIpFromRequest(request: Request): string { + return request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip")?.trim() || + "unknown"; +} + +export function checkIpLoginRateLimit(ip: string): void { + const now = Date.now(); + const current = ipAttempts.get(ip); + if (!current || current.resetAt <= now) { + ipAttempts.set(ip, { count: 1, resetAt: now + 15 * 60 * 1000 }); + return; + } + if (current.count >= 30) { + const error = new Error("请求过于频繁,请稍后再试。") as Error & { status: number }; + error.status = 429; + throw error; + } + current.count += 1; +} + +export function clearIpLoginRateLimit(ip: string): void { + ipAttempts.delete(ip); +} + +export function resetLocalAuthRateLimitForTests(): void { + ipAttempts.clear(); +} diff --git a/lib/server/auth/oauth.ts b/lib/server/auth/oauth.ts index a914dc9..0da54b4 100644 --- a/lib/server/auth/oauth.ts +++ b/lib/server/auth/oauth.ts @@ -92,7 +92,8 @@ export async function completeAuthorizationCallback(request: Request): Promise { + const derived = await scrypt(password, salt, LOCAL_PASSWORD_KEY_LENGTH) as Buffer; + return { hash: derived.toString("hex"), salt }; +} + +export async function verifyLocalPassword(password: string, hash: string, salt: string): Promise { + if (!password || !hash || !salt) return false; + const derived = await scrypt(password, salt, LOCAL_PASSWORD_KEY_LENGTH) as Buffer; + const expected = Buffer.from(hash, "hex"); + return expected.length === derived.length && timingSafeEqual(expected, derived); +} export function prepareAuthPassword(password: string, input: { passwordEncrypted?: boolean; diff --git a/lib/server/billing-catalog.ts b/lib/server/billing-catalog.ts new file mode 100644 index 0000000..f39ba27 --- /dev/null +++ b/lib/server/billing-catalog.ts @@ -0,0 +1,289 @@ +import { + createBillingPriceRule, + listBillingPriceRules, + updateBillingPriceRule, + type BillingPriceRuleInput +} from "@/lib/server/billing-store"; +import type { BillingPriceRule } from "@/lib/types"; + +export const BILLING_USD_CNY_RATE = 7.2; +export const DEFAULT_BILLING_MARKUP_MULTIPLIER = 1.2; +export const BILLING_CATALOG_OBSERVED_AT = "2026-08-11"; + +const BAILIAN_IMAGE_URL = "https://help.aliyun.com/zh/model-studio/wan2-7-image-pro"; +const BAILIAN_VIDEO_URL = "https://help.aliyun.com/zh/model-studio/wan2-7-i2v"; +const EVOLINK_IMAGE_URL = "https://evolink.ai/zh/gpt-image-2"; +const VOLC_ARK_PRICING_URL = "https://www.volcengine.com/docs/82379/1544106?lang=zh"; +const JIMENG_PRICING_URL = "https://www.volcengine.com/activity/jimeng"; + +const EVOLINK_GPT_IMAGE_DIMENSIONS = [ + { + key: "quality", + label: "生成质量", + baselineValue: "medium", + defaultValue: "medium", + tiers: [ + { value: "low", label: "快速", standardFactor: 0.11, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true, note: "官方口径约为 medium 的 0.11 倍成本。" }, + { value: "medium", label: "标准", standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true }, + { value: "high", label: "精细", standardFactor: 4, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true, note: "官方口径约为 medium 的 4 倍成本。" } + ] + }, + { + key: "resolution", + label: "分辨率", + baselineValue: "1K", + defaultValue: "1K", + tiers: [ + { value: "1K", label: "1K", standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true }, + { value: "2K", label: "2K", standardFactor: 4, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true, note: "按像素预算相对 1K 的估算倍率。" }, + { value: "4K", label: "4K", standardFactor: 8, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true, note: "按平台支持的最大像素预算估算。" } + ] + }, + { + key: "aspectRatio", + label: "画面比例", + baselineValue: "1:1", + defaultValue: "1:1", + tiers: [ + { value: "1:1", label: "1:1", standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true }, + { value: "4:3", label: "4:3", standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true }, + { value: "16:9", label: "16:9", standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true }, + { value: "9:16", label: "9:16", standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true }, + { value: "other", label: "其他比例", match: {}, standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true, note: "同一分辨率档位下按相同比例预算估算。" } + ] + }, + { + key: "referenceImageCount", + label: "参考图数量", + baselineValue: 0, + defaultValue: 0, + tiers: [ + { value: 0, label: "无参考图", standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true }, + { value: "1–4", label: "1–4 张", match: { min: 1, max: 4 }, standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true, note: "EvoLink 上游按 image input token 计费,当前平台按基础生成成本估算。" }, + { value: "5–8", label: "5–8 张", match: { min: 5, max: 8 }, standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true, note: "EvoLink 上游按 image input token 计费,当前平台按基础生成成本估算。" }, + { value: "9–16", label: "9–16 张", match: { min: 9, max: 16 }, standardFactor: 1, markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, enabled: true, note: "EvoLink 上游按 image input token 计费,当前平台按基础生成成本估算。" } + ] + } +] satisfies NonNullable; + +export const DEFAULT_BILLING_PRICE_RULES: BillingPriceRuleInput[] = [ + { + id: "base-volcengine-jimeng-seedream46", + provider: "volcengine-visual", + capability: "image.generate", + reqKey: "jimeng_seedream46_cvtob", + unit: "image", + standardUnitPriceFen: 20, + markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, + enabled: true, + note: "即梦4.6参考基准:公开活动页按 ¥200/1000 张折算;官方实时价格以供应商控制台为准。", + source: { + url: JIMENG_PRICING_URL, + currency: "CNY", + unitPrice: 0.2, + basis: "公开资源包 ¥200/1000 张折算;非 API 实时刊例价", + observedAt: BILLING_CATALOG_OBSERVED_AT + } + }, + { + id: "base-evolink-gpt-image-2", + provider: "evolink", + capability: "image.generate", + reqKey: "gpt-image-2", + unit: "image", + standardUnitPriceFen: 34, + markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, + enabled: true, + note: "EvoLink 按 token 计费;当前以 medium / 1K / 1:1 / 无参考图作为平台标准基准,参数档位另列。", + parameterDimensions: EVOLINK_GPT_IMAGE_DIMENSIONS, + source: { + url: EVOLINK_IMAGE_URL, + currency: "USD", + unitPrice: 0.047, + fxRate: BILLING_USD_CNY_RATE, + basis: "页面估算:medium、1K、1:1、无参考图,约 $0.047/张;折算 ¥0.3384,账本按 ¥0.34", + observedAt: BILLING_CATALOG_OBSERVED_AT + } + }, + { + id: "base-bailian-wan27-image-pro", + provider: "bailian", + capability: "image.generate", + reqKey: "wan2.7-image-pro", + unit: "image", + standardUnitPriceFen: 50, + markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, + enabled: true, + note: "北京地域官方基础价:¥0.50/张。", + source: { + url: BAILIAN_IMAGE_URL, + currency: "CNY", + unitPrice: 0.5, + basis: "北京地域,按成功生成图片计费", + observedAt: BILLING_CATALOG_OBSERVED_AT + } + }, + { + id: "base-bailian-wan27-i2v-720p", + provider: "bailian", + capability: "video.generate", + reqKey: "wan2.7-i2v-2026-04-25", + variantKey: "resolution=720p", + unit: "video_second", + standardUnitPriceFen: 60, + markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, + enabled: true, + note: "北京地域官方基础价:720P ¥0.60/秒。", + source: { + url: BAILIAN_VIDEO_URL, + currency: "CNY", + unitPrice: 0.6, + basis: "北京地域,720P,按成功视频秒数计费", + observedAt: BILLING_CATALOG_OBSERVED_AT + } + }, + { + id: "base-bailian-wan27-i2v-1080p", + provider: "bailian", + capability: "video.generate", + reqKey: "wan2.7-i2v-2026-04-25", + variantKey: "resolution=1080p", + unit: "video_second", + standardUnitPriceFen: 100, + markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, + enabled: true, + note: "北京地域官方基础价:1080P ¥1.00/秒。", + source: { + url: BAILIAN_VIDEO_URL, + currency: "CNY", + unitPrice: 1, + basis: "北京地域,1080P,按成功视频秒数计费", + observedAt: BILLING_CATALOG_OBSERVED_AT + } + }, + { + id: "base-seedance-2-0-480p", + provider: "seedance", + capability: "video.generate", + reqKey: "doubao-seedance-2-0-260128", + variantKey: "resolution=480p", + unit: "video_second", + standardUnitPriceFen: 46, + markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, + enabled: true, + note: "官方 5 秒、16:9、无输入视频样例折算:¥2.31/5秒≈¥0.46/秒;实际按方舟 token 用量结算。", + source: { + url: VOLC_ARK_PRICING_URL, + currency: "CNY", + unitPrice: 0.46, + basis: "doubao-seedance-2.0,480P,无输入视频、16:9、5 秒示例折算;实际 token 计费可能随输入变化", + observedAt: BILLING_CATALOG_OBSERVED_AT + } + }, + { + id: "base-seedance-2-0-720p", + provider: "seedance", + capability: "video.generate", + reqKey: "doubao-seedance-2-0-260128", + variantKey: "resolution=720p", + unit: "video_second", + standardUnitPriceFen: 99, + markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, + enabled: true, + note: "官方 5 秒、16:9、无输入视频样例折算:¥4.97/5秒≈¥0.99/秒;实际按方舟 token 用量结算。", + source: { + url: VOLC_ARK_PRICING_URL, + currency: "CNY", + unitPrice: 0.99, + basis: "doubao-seedance-2.0,720P,无输入视频、16:9、5 秒示例折算;实际 token 计费可能随输入变化", + observedAt: BILLING_CATALOG_OBSERVED_AT + } + }, + { + id: "base-seedance-2-0-1080p", + provider: "seedance", + capability: "video.generate", + reqKey: "doubao-seedance-2-0-260128", + variantKey: "resolution=1080p", + unit: "video_second", + standardUnitPriceFen: 248, + markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, + enabled: true, + note: "官方 5 秒、16:9、无输入视频样例折算:¥12.39/5秒≈¥2.48/秒;实际按方舟 token 用量结算。", + source: { + url: VOLC_ARK_PRICING_URL, + currency: "CNY", + unitPrice: 2.48, + basis: "doubao-seedance-2.0,1080P,无输入视频、16:9、5 秒示例折算;实际 token 计费可能随输入变化", + observedAt: BILLING_CATALOG_OBSERVED_AT + } + }, + { + id: "base-seedance-2-0-4k", + provider: "seedance", + capability: "video.generate", + reqKey: "doubao-seedance-2-0-260128", + variantKey: "resolution=4k", + unit: "video_second", + standardUnitPriceFen: 505, + markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER, + enabled: true, + note: "官方 5 秒、16:9、无输入视频样例折算:¥25.27/5秒≈¥5.05/秒;实际按方舟 token 用量结算。", + source: { + url: VOLC_ARK_PRICING_URL, + currency: "CNY", + unitPrice: 5.05, + basis: "doubao-seedance-2.0,4K,无输入视频、16:9、5 秒示例折算;实际 token 计费可能随输入变化", + observedAt: BILLING_CATALOG_OBSERVED_AT + } + } +]; + +export async function ensureDefaultBillingPriceRules(): Promise { + const existing = await listBillingPriceRules({ includeDisabled: true }); + const created: BillingPriceRule[] = []; + for (const candidate of DEFAULT_BILLING_PRICE_RULES) { + const candidateKey = priceRuleMatchKey(candidate); + const existingRule = existing.find((rule) => priceRuleMatchKey(rule) === candidateKey); + if (existingRule) { + const updated = await updateBillingPriceRule(existingRule.id, { + reqKey: candidate.reqKey, + variantKey: candidate.variantKey, + unit: candidate.unit, + standardUnitPriceFen: candidate.standardUnitPriceFen, + enabled: candidate.enabled, + conditions: candidate.conditions, + quantitySource: candidate.quantitySource, + priority: candidate.priority, + note: candidate.note, + source: candidate.source, + parameterDimensions: candidate.parameterDimensions + }); + if (updated) { + const index = existing.findIndex((rule) => rule.id === updated.id); + if (index >= 0) existing[index] = updated; + } + continue; + } + if (existing.some((rule) => rule.provider === candidate.provider + && rule.capability === candidate.capability + && !rule.reqKey + && !rule.variantKey)) continue; + try { + const rule = await createBillingPriceRule(candidate); + created.push(rule); + } catch (error) { + if (!isConflict(error)) throw error; + } + } + return [...existing, ...created]; +} + +function priceRuleMatchKey(rule: Pick): string { + const conditions = Object.fromEntries(Object.entries(rule.conditions || {}).sort(([left], [right]) => left.localeCompare(right))); + return [rule.provider, rule.capability, rule.reqKey || "", rule.variantKey || "", JSON.stringify(conditions)].join("\u0000"); +} + +function isConflict(error: unknown): boolean { + return typeof error === "object" && error !== null && "status" in error && (error as { status?: unknown }).status === 409; +} diff --git a/lib/server/billing-service.ts b/lib/server/billing-service.ts new file mode 100644 index 0000000..4c7baf3 --- /dev/null +++ b/lib/server/billing-service.ts @@ -0,0 +1,626 @@ +import { quoteFromPriceRule, resolveBillingParameterPricing } from "@/lib/billing"; +import { + getOrganizationWallet, + InsufficientBalanceError, + listBillingPriceRules, + listBillingLedgerEntries, + postWalletEntry +} from "@/lib/server/billing-store"; +import { ensureDefaultBillingPriceRules } from "@/lib/server/billing-catalog"; +import { + calculateSeedanceActualAmountFen, + estimateSeedanceAmountFen, + requestHasInputVideo, + requestInputVideoDurationSeconds, + seedanceTokenPriceFenPerMillion +} from "@/lib/server/seedance-billing"; +import { getGenerationJob, updateGenerationJob } from "@/lib/server/data-store"; +import type { + BillingAccountConfig, + BillingConditionValue, + BillingJobCharge, + BillingParameterSnapshot, + BillingPriceRule, + BillingQuantitySource, + BillingRuleConditions, + BillingScalar, + GenerationCapability, + GenerationJob, + GenerationProvider, + UsageContext +} from "@/lib/types"; + +export class BillingConfigurationError extends Error { + status = 503; + + constructor(message: string) { + super(message); + this.name = "BillingConfigurationError"; + } +} + +export type BillingGenerationInput = { + provider: GenerationProvider; + capability: GenerationCapability; + reqKey: string; + requestPayload: Record; + usageContext?: UsageContext; + externalClientId?: string; + allowUnboundOrganization?: boolean; +}; + +export async function quoteGenerationCharge(input: BillingGenerationInput): Promise { + if (!billingEnabled() || input.provider === "mock") return undefined; + const quotaExempt = isQuotaExemptUsageContext(input.usageContext); + if (!input.usageContext?.organizationId && !quotaExempt) { + if (input.externalClientId || input.usageContext?.source === "api") return undefined; + if (!input.allowUnboundOrganization) { + throw new BillingConfigurationError("当前账号未绑定组织,暂时无法提交计费生成任务。"); + } + } + await ensureDefaultBillingPriceRules(); + const rule = await findMatchingPriceRule(input); + if (!rule) { + throw new BillingConfigurationError(`尚未配置 ${input.provider} / ${input.capability} 的计费规则,请联系超级管理员。`); + } + const parameters = normalizeBillingParameters(input.requestPayload); + if (!resolveBillingParameterPricing(rule, parameters)) { + throw new BillingConfigurationError(`尚未配置 ${input.provider} / ${input.capability} 当前参数组合的标准价格,请联系超级管理员。`); + } + const quantity = quantityForRule(rule, parameters); + const quote: BillingJobCharge = { + ...quoteFromPriceRule({ + rule, + provider: input.provider, + capability: input.capability, + reqKey: input.reqKey, + quantity, + parameters, + conditions: effectiveBillingRuleConditions(rule) + }), + status: "pending", + quotaExempt: quotaExempt || undefined + }; + if (input.provider !== "seedance" || input.reqKey !== "doubao-seedance-2-0-260128") { + return quote; + } + + const inputVideo = requestHasInputVideo(input.requestPayload); + const estimatedAmountFen = estimateSeedanceAmountFen({ + resolution: parameters.resolution, + aspectRatio: parameters.aspectRatio, + outputDurationSeconds: quantity, + inputVideo, + inputVideoDurationSeconds: requestInputVideoDurationSeconds(input.requestPayload), + markupMultiplier: quote.markupMultiplier + }); + const reservedAmountFen = Math.max(quote.amountFen, estimatedAmountFen); + return { + ...quote, + amountFen: reservedAmountFen, + reservedAmountFen, + settlementStatus: "pending" + }; +} + +export async function chargeGenerationJob(job: GenerationJob): Promise { + const billing = job.billing; + if (!billing || billing.status !== "pending") return job; + if (isQuotaExemptBilling(job)) { + return updateGenerationJob(job.id, { + billing: { + ...billing, + quotaExempt: true, + status: "not_charged", + settlementStatus: job.provider === "seedance" ? "pending" : billing.settlementStatus + } + }); + } + if (!job.usageContext?.organizationId) return job; + const reservedAmountFen = billing.reservedAmountFen ?? billing.amountFen; + const result = await postWalletEntry({ + organizationId: job.usageContext.organizationId, + accountId: job.usageContext.accountId, + jobId: job.id, + kind: "charge", + deltaFen: -reservedAmountFen, + idempotencyKey: `job-charge:${job.id}`, + description: `${capabilityLabel(job.capability)} · ${job.reqKey}`, + metadata: { + quote: { ...billing, reservedAmountFen }, + accountName: job.usageContext.displayName, + organizationName: job.usageContext.organizationName + } + }); + return updateGenerationJob(job.id, { + billing: { + ...billing, + reservedAmountFen, + status: "charged", + settlementStatus: job.provider === "seedance" ? "pending" : billing.settlementStatus, + ledgerEntryId: result.entry.id, + chargedAt: result.entry.createdAt + } + }); +} + +export async function refundGenerationCharge(jobOrId: GenerationJob | string, reason: string): Promise { + const job = typeof jobOrId === "string" ? await getGenerationJob(jobOrId) : jobOrId; + if (!job) return null; + if (!["failed", "expired", "cancelled"].includes(job.status)) return job; + const billing = job.billing; + if (!billing || isQuotaExemptBilling(job) || billing.status !== "charged" || !job.usageContext?.organizationId) return job; + const result = await postWalletEntry({ + organizationId: job.usageContext.organizationId, + accountId: job.usageContext.accountId, + jobId: job.id, + kind: "refund", + deltaFen: billing.amountFen, + idempotencyKey: `job-refund:${job.id}`, + description: `${capabilityLabel(job.capability)}失败退款 · ${reason}`, + metadata: { + chargeLedgerEntryId: billing.ledgerEntryId, + reason, + quote: billing + } + }); + return updateGenerationJob(job.id, { + billing: { + ...billing, + status: "refunded", + refundLedgerEntryId: result.entry.id, + refundedAt: result.entry.createdAt, + refundReason: reason + } + }); +} + +export async function settleSeedanceGenerationCharge( + jobOrId: GenerationJob | string, + completionTokens?: number +): Promise { + const job = typeof jobOrId === "string" ? await getGenerationJob(jobOrId) : jobOrId; + if (!job) return null; + const billing = job.billing; + const quotaExempt = isQuotaExemptBilling(job); + const organizationId = job.usageContext?.organizationId; + const chargeReady = billing?.status === "charged" || quotaExempt && billing?.status === "not_charged"; + if (job.provider !== "seedance" || !billing || !chargeReady || !quotaExempt && !organizationId) return job; + if (billing.settlementStatus === "settled" || billing.settlementStatus === "estimated") return job; + + if (!Number.isFinite(completionTokens) || Number(completionTokens) <= 0) { + return updateGenerationJob(job.id, { + billing: { + ...billing, + settlementStatus: "estimated", + settlementReason: "provider_usage_unavailable", + settledAt: new Date().toISOString() + } + }); + } + + const inputVideo = requestHasInputVideo(job.requestPayload); + const resolution = billing.parameters?.resolution; + const actualAmountFen = calculateSeedanceActualAmountFen({ + resolution, + inputVideo, + completionTokens: Number(completionTokens), + markupMultiplier: billing.markupMultiplier + }); + const deltaFen = actualAmountFen - billing.amountFen; + let settlementLedgerEntryId: string | undefined; + let settledAt = new Date().toISOString(); + if (deltaFen !== 0 && !quotaExempt && organizationId) { + const settlement = await postWalletEntry({ + organizationId, + accountId: job.usageContext?.accountId, + jobId: job.id, + kind: deltaFen > 0 ? "charge" : "refund", + deltaFen: -deltaFen, + idempotencyKey: `job-settlement:${job.id}`, + description: deltaFen > 0 ? `${capabilityLabel(job.capability)}实际用量补扣` : `${capabilityLabel(job.capability)}实际用量差额退回`, + metadata: { + operation: "seedance_actual_settlement", + reservedAmountFen: billing.reservedAmountFen ?? billing.amountFen, + chargedAmountFen: billing.amountFen, + actualAmountFen, + completionTokens: Math.floor(Number(completionTokens)), + inputVideo, + resolution: String(resolution || "720p") + } + }); + settlementLedgerEntryId = settlement.entry.id; + settledAt = settlement.entry.createdAt; + } + + return updateGenerationJob(job.id, { + billing: { + ...billing, + amountFen: actualAmountFen, + settlementStatus: "settled", + settlementLedgerEntryId, + settledAt, + providerUsage: { + completionTokens: Math.floor(Number(completionTokens)), + resolution: String(resolution || "720p"), + inputVideo, + tokenPriceFenPerMillion: seedanceTokenPriceFenPerMillion(resolution, inputVideo) + } + } + }); +} + +export async function getOrganizationBillingSnapshot(organizationId: string) { + const wallet = await getOrganizationWallet(organizationId); + return { + wallet, + balanceFen: wallet.balanceFen, + balanceYuan: wallet.balanceFen / 100 + }; +} + +export function getBillingAccountConfig(): BillingAccountConfig { + return { + accountName: optionalEnv("ZHINIAN_BILLING_ACCOUNT_NAME"), + bankName: optionalEnv("ZHINIAN_BILLING_ACCOUNT_BANK"), + accountNumber: optionalEnv("ZHINIAN_BILLING_ACCOUNT_NUMBER"), + contact: optionalEnv("ZHINIAN_BILLING_CONTACT") + }; +} + +/** + * Direct organization top-up entry point for administrator actions and future + * payment-success callbacks. The caller must only invoke it after the payment + * provider has already confirmed the amount when self-service payments exist. + */ +export async function postOrganizationTopUp(input: { + organizationId: string; + amountFen: number; + idempotencyKey: string; + description?: string; + metadata?: Record; +}) { + if (!Number.isFinite(input.amountFen) || input.amountFen <= 0) { + throw Object.assign(new Error("上账金额必须大于 0。"), { status: 400 }); + } + return postWalletEntry({ + organizationId: input.organizationId, + kind: "recharge", + deltaFen: Math.round(input.amountFen), + idempotencyKey: input.idempotencyKey, + description: input.description || "组织余额上账", + metadata: { + operation: "organization_top_up", + ...input.metadata + } + }); +} + +export async function getBillingOverview(input: { organizationId: string; accountId: string }) { + const [wallet, ledger, personalLedger] = await Promise.all([ + getOrganizationWallet(input.organizationId), + listBillingLedgerEntries({ organizationId: input.organizationId, limit: 500 }), + listBillingLedgerEntries({ organizationId: input.organizationId, accountId: input.accountId, limit: 500 }) + ]); + return { + wallet, + ledger, + billingAccount: getBillingAccountConfig(), + summary: summarizeLedger(ledger), + personal: summarizeLedger(personalLedger) + }; +} + +function summarizeLedger(entries: Array<{ kind: string; deltaFen: number }>) { + const rechargeFen = entries.filter((entry) => entry.kind === "recharge" || entry.kind === "adjustment" && entry.deltaFen > 0).reduce((sum, entry) => sum + Math.max(0, entry.deltaFen), 0); + const chargedFen = entries.filter((entry) => entry.kind === "charge").reduce((sum, entry) => sum + Math.max(0, -entry.deltaFen), 0); + const refundedFen = entries.filter((entry) => entry.kind === "refund").reduce((sum, entry) => sum + Math.max(0, entry.deltaFen), 0); + return { rechargeFen, chargedFen, refundedFen, netConsumedFen: Math.max(0, chargedFen - refundedFen) }; +} + +function billingEnabled(): boolean { + return process.env.ZHINIAN_BILLING_REQUIRED !== "0"; +} + +function isQuotaExemptUsageContext(usageContext?: UsageContext): boolean { + return usageContext?.source === "platform" && usageContext.role === "super_admin"; +} + +function isQuotaExemptBilling(job: GenerationJob): boolean { + return Boolean(job.billing?.quotaExempt || isQuotaExemptUsageContext(job.usageContext)); +} + +export async function findMatchingPriceRule(input: BillingGenerationInput): Promise { + const rules = await listBillingPriceRules({ includeDisabled: false }); + return findMatchingBillingPriceRule(rules, input); +} + +export function findMatchingBillingPriceRule(rules: BillingPriceRule[], input: Pick): BillingPriceRule | null { + const parameters = normalizeBillingParameters(input.requestPayload); + const scoped = rules.filter((rule) => rule.provider === input.provider && rule.capability === input.capability); + const matches = scoped.flatMap((rule) => { + if (rule.reqKey && rule.reqKey !== input.reqKey) return []; + const conditions = effectiveBillingRuleConditions(rule); + if (!conditionsMatchParameters(conditions, parameters)) return []; + return [{ + rule, + reqKeySpecificity: rule.reqKey ? 1 : 0, + conditionSpecificity: Object.keys(conditions).length, + priority: Number.isFinite(rule.priority) ? Number(rule.priority) : 0 + }]; + }); + if (!matches.length) return null; + + matches.sort((left, right) => right.reqKeySpecificity - left.reqKeySpecificity + || right.conditionSpecificity - left.conditionSpecificity + || right.priority - left.priority + || left.rule.id.localeCompare(right.rule.id)); + const winner = matches[0]; + const ambiguous = matches.filter((item) => item !== winner + && item.reqKeySpecificity === winner.reqKeySpecificity + && item.conditionSpecificity === winner.conditionSpecificity + && item.priority === winner.priority); + if (ambiguous.length) { + throw new BillingConfigurationError(`计费规则配置存在歧义:${[winner.rule.id, ...ambiguous.map((item) => item.rule.id)].join("、")}。请让条件更具体或调整优先级。`); + } + return winner.rule; +} + +export function normalizeBillingParameters(requestPayload: Record): BillingParameterSnapshot { + const settings = recordValue(requestPayload.settings); + const providerPayload = recordValue(requestPayload.providerPayload); + const input = recordValue(requestPayload.input); + const providerParameters = recordValue(providerPayload?.parameters); + const providerInput = recordValue(providerPayload?.input); + const inputSettings = recordValue(input?.settings); + const assembled = recordValue(requestPayload.assembled); + const parameters: BillingParameterSnapshot = {}; + + setParameter(parameters, "model", firstValue(providerPayload?.model, input?.model, settings?.model)); + setParameter(parameters, "resolution", firstValue( + settings?.resolution, + providerParameters?.resolution, + providerPayload?.resolution, + inputSettings?.resolution, + input?.resolution + ), normalizeTextParameter); + setParameter(parameters, "size", firstValue( + providerPayload?.size, + providerParameters?.size, + inputSettings?.size, + input?.size + ), normalizeSizeParameter); + + const width = numberParameter(firstValue(providerPayload?.width, input?.width)); + const height = numberParameter(firstValue(providerPayload?.height, input?.height)); + if (parameters.size === undefined && width !== undefined && height !== undefined) { + parameters.size = `${width}*${height}`; + } + const aspectRatio = firstValue( + settings?.ratio, + settings?.aspectRatio, + providerPayload?.ratio, + providerParameters?.ratio, + inputSettings?.ratio, + input?.ratio, + input?.aspectRatio + ); + const normalizedAspectRatio = normalizeAspectRatioParameter(aspectRatio, width, height); + if (normalizedAspectRatio !== undefined) parameters.aspectRatio = normalizedAspectRatio; + + setParameter(parameters, "quality", firstValue( + input?.quality, + settings?.quality, + providerPayload?.quality, + providerParameters?.quality + ), normalizeTextParameter); + setParameter(parameters, "duration", firstValue( + settings?.duration, + providerParameters?.duration, + providerPayload?.duration, + inputSettings?.duration, + input?.duration + ), numberParameter); + + const imageCount = numberParameter(firstValue( + providerPayload?.n, + providerParameters?.n, + input?.n, + input?.imageCount + )); + if (imageCount !== undefined && imageCount > 0) parameters.imageCount = Math.ceil(imageCount); + + const referenceImageCount = countReferenceImages({ requestPayload, input, providerPayload, providerInput, assembled }); + if (referenceImageCount > 0) parameters.referenceImageCount = referenceImageCount; + + const inputVideo = countReferenceVideos({ input, assembled }); + if (inputVideo > 0) parameters.inputVideo = true; + + setParameter(parameters, "scale", firstValue(input?.scale, settings?.scale), numberParameter); + setParameter(parameters, "generateAudio", firstValue( + settings?.generate_audio, + settings?.generateAudio, + providerParameters?.generate_audio, + providerParameters?.generateAudio, + input?.generate_audio, + input?.generateAudio + ), booleanParameter); + + return parameters; +} + +export function effectiveBillingRuleConditions(rule: BillingPriceRule): BillingRuleConditions { + const legacyConditions = parseLegacyVariantKey(rule.variantKey); + return { ...legacyConditions, ...(rule.conditions || {}) }; +} + +export function quantityForRule(rule: BillingPriceRule, parameters: BillingParameterSnapshot): number { + const source = rule.quantitySource || defaultQuantitySource(rule.unit); + if (source === "request") return 1; + if (source === "duration") { + const duration = Number(parameters.duration); + return Number.isFinite(duration) && duration > 0 ? Math.ceil(duration) : 1; + } + const imageCount = Number(parameters.imageCount); + return Number.isFinite(imageCount) && imageCount > 0 ? Math.ceil(imageCount) : 1; +} + +function defaultQuantitySource(unit: BillingPriceRule["unit"]): BillingQuantitySource { + if (unit === "video_second") return "duration"; + if (unit === "image") return "image_count"; + return "request"; +} + +function conditionsMatchParameters(conditions: BillingRuleConditions, parameters: BillingParameterSnapshot): boolean { + return Object.entries(conditions).every(([key, condition]) => conditionMatchesValue(condition, parameters[key])); +} + +function conditionMatchesValue(condition: BillingConditionValue, actual: BillingScalar | undefined): boolean { + if (actual === undefined) return false; + if (typeof condition === "object" && condition !== null && !Array.isArray(condition)) { + if (Array.isArray(condition.values) && !condition.values.some((value) => scalarEquals(value, actual))) return false; + const numericActual = Number(actual); + if (condition.min !== undefined && (!Number.isFinite(numericActual) || numericActual < condition.min)) return false; + if (condition.max !== undefined && (!Number.isFinite(numericActual) || numericActual > condition.max)) return false; + return true; + } + if (typeof condition === "string" || typeof condition === "number" || typeof condition === "boolean") { + return scalarEquals(condition, actual); + } + return false; +} + +function scalarEquals(left: BillingScalar, right: BillingScalar): boolean { + if (typeof left === "number" || typeof right === "number") return Number(left) === Number(right); + if (typeof left === "boolean" || typeof right === "boolean") return Boolean(left) === Boolean(right); + return normalizeTextParameter(left) === normalizeTextParameter(right); +} + +function parseLegacyVariantKey(variantKey?: string): BillingRuleConditions { + if (!variantKey) return {}; + const conditions: BillingRuleConditions = {}; + for (const part of variantKey.split(/[;,]/)) { + const separator = part.indexOf("="); + if (separator <= 0) continue; + const key = part.slice(0, separator).trim(); + const value = part.slice(separator + 1).trim(); + if (!key || !value) continue; + if (["model", "resolution", "size", "aspectRatio", "ratio", "quality"].includes(key)) { + const normalized = normalizeTextParameter(value); + if (normalized !== undefined) conditions[key === "ratio" ? "aspectRatio" : key] = normalized; + continue; + } + if (["duration", "imageCount", "referenceImageCount", "scale"].includes(key)) { + const number = Number(value); + if (Number.isFinite(number)) conditions[key] = number; + } + } + return conditions; +} + +function countReferenceImages(input: { + requestPayload: Record; + input?: Record; + providerPayload?: Record; + providerInput?: Record; + assembled?: Record; +}): number { + const urls = [ + ...stringArray(input.input?.imageUrls), + ...stringArray(input.providerPayload?.image_urls), + ...stringArray(input.providerPayload?.imageUrls) + ]; + const materials = [ + ...(Array.isArray(input.input?.materials) ? input.input.materials : []), + ...(Array.isArray(input.assembled?.materials) ? input.assembled.materials : []) + ]; + const materialImages = materials.filter((item) => { + const record = recordValue(item); + return record?.type === "image" || typeof record?.url === "string"; + }).length; + const messages = Array.isArray(input.providerInput?.messages) ? input.providerInput.messages : []; + const messageImages = messages.reduce((count, message) => { + const content = recordValue(message)?.content; + return count + (Array.isArray(content) ? content.filter((item) => Boolean(recordValue(item)?.image)).length : 0); + }, 0); + return Math.max(urls.length, materialImages, messageImages); +} + +function countReferenceVideos(input: { input?: Record; assembled?: Record }): number { + const materials = [ + ...(Array.isArray(input.input?.materials) ? input.input.materials : []), + ...(Array.isArray(input.assembled?.materials) ? input.assembled.materials : []) + ]; + return materials.filter((item) => recordValue(item)?.type === "video").length; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : []; +} + +function setParameter( + target: BillingParameterSnapshot, + key: string, + value: unknown, + normalize: (value: unknown) => BillingScalar | undefined = normalizeScalarParameter +) { + const normalized = normalize(value); + if (normalized !== undefined) target[key] = normalized; +} + +function firstValue(...values: unknown[]): unknown { + return values.find((value) => value !== undefined && value !== null && value !== ""); +} + +function normalizeScalarParameter(value: unknown): BillingScalar | undefined { + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value; + return undefined; +} + +function normalizeTextParameter(value: unknown): string | undefined { + if (typeof value !== "string" && typeof value !== "number") return undefined; + const normalized = String(value).trim().toLowerCase().replace(/\s+/g, ""); + return normalized || undefined; +} + +function normalizeSizeParameter(value: unknown): string | undefined { + const normalized = normalizeTextParameter(value); + return normalized?.replace(/[×x]/g, "*"); +} + +function normalizeAspectRatioParameter(value: unknown, width?: number, height?: number): string | undefined { + const normalized = normalizeTextParameter(value); + if (normalized) return normalized; + if (width === undefined || height === undefined || height <= 0) return undefined; + const ratio = width / height; + const common = [[1, 1], [4, 3], [3, 2], [16, 9], [9, 16], [21, 9], [9, 21], [2, 3], [3, 4]] as const; + const match = common.find(([numerator, denominator]) => Math.abs(ratio - numerator / denominator) < 0.02); + return match ? `${match[0]}:${match[1]}` : ratio.toFixed(3).replace(/0+$/, "").replace(/\.$/, ""); +} + +function numberParameter(value: unknown): number | undefined { + const number = Number(value); + return Number.isFinite(number) ? number : undefined; +} + +function booleanParameter(value: unknown): boolean | undefined { + if (typeof value === "boolean") return value; + if (value === "true" || value === "1") return true; + if (value === "false" || value === "0") return false; + return undefined; +} + +function recordValue(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : undefined; +} + +function capabilityLabel(capability: GenerationCapability): string { + return capability === "video.generate" ? "视频生成" : "图片生成"; +} + +function optionalEnv(name: string): string | undefined { + const value = process.env[name]?.trim(); + return value || undefined; +} + +export { InsufficientBalanceError }; diff --git a/lib/server/billing-store.ts b/lib/server/billing-store.ts new file mode 100644 index 0000000..97b2d6b --- /dev/null +++ b/lib/server/billing-store.ts @@ -0,0 +1,477 @@ +import { readFile, rename, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { createId } from "@/lib/server/ids"; +import { dataDir, ensureRuntimeDirs } from "@/lib/server/runtime"; +import type { + BillingCurrency, + BillingLedgerEntry, + BillingLedgerKind, + BillingParameterDimension, + BillingPriceRule, + BillingRuleConditions, + OrganizationWallet +} from "@/lib/types"; + +const STORE_FILE = "billing-state.json"; +let localWriteQueue: Promise = Promise.resolve(); + +type BillingState = { + priceRules: BillingPriceRule[]; + wallets: OrganizationWallet[]; + ledgerEntries: BillingLedgerEntry[]; +}; + +export type BillingPriceRuleInput = Omit & Partial>; + +export type BillingLedgerFilters = { + organizationId?: string; + accountId?: string; + jobId?: string; + kind?: BillingLedgerKind; + limit?: number; +}; + +export type WalletEntryInput = { + organizationId: string; + accountId?: string; + jobId?: string; + kind: BillingLedgerKind; + deltaFen: number; + currency?: BillingCurrency; + idempotencyKey: string; + description: string; + metadata?: Record; +}; + +export class BillingStoreError extends Error { + status: number; + + constructor(message: string, status = 400) { + super(normalizeBillingStoreErrorMessage(message)); + this.name = "BillingStoreError"; + this.status = status; + } +} + +export class InsufficientBalanceError extends BillingStoreError { + constructor(message = "余额不足,请先充值。") { + super(message, 402); + this.name = "InsufficientBalanceError"; + } +} + +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); + } + const state = await readState(); + return state.priceRules + .filter((rule) => options.includeDisabled || rule.enabled) + .sort((left, right) => left.provider.localeCompare(right.provider) || left.capability.localeCompare(right.capability) || right.updatedAt.localeCompare(left.updatedAt)); +} + +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; + } + const state = await readState(); + return state.priceRules.find((rule) => rule.id === id) || null; +} + +export async function createBillingPriceRule(input: BillingPriceRuleInput): Promise { + const now = new Date().toISOString(); + const rule: BillingPriceRule = { + ...input, + id: input.id || createId("price"), + 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); + } + return mutateLocalState((state) => { + if (state.priceRules.some((item) => item.id === rule.id)) throw new BillingStoreError("计费规则 ID 已存在。", 409); + if (state.priceRules.some((item) => priceRuleMatchKey(item) === priceRuleMatchKey(rule))) { + throw new BillingStoreError("相同服务商、能力、模型和变体的计费规则已存在。", 409); + } + state.priceRules.unshift(rule); + return rule; + }); +} + +export async function updateBillingPriceRule(id: string, patch: Partial>): Promise { + const existing = await getBillingPriceRule(id); + if (!existing) return null; + const updated: BillingPriceRule = { ...existing, ...patch, updatedAt: new Date().toISOString() }; + const supabase = getSupabaseAdmin(); + if (supabase) { + const { data, error } = await supabase.from("billing_price_rules").update(priceRuleToRow(updated)).eq("id", id).select("*").maybeSingle(); + if (error) throw new BillingStoreError(error.message, 500); + return data ? priceRuleFromRow(data) : null; + } + return mutateLocalState((state) => { + const index = state.priceRules.findIndex((item) => item.id === id); + if (index === -1) return null; + if (state.priceRules.some((item) => item.id !== id && priceRuleMatchKey(item) === priceRuleMatchKey(updated))) { + throw new BillingStoreError("相同服务商、能力、模型和参数条件的计费规则已存在。", 409); + } + state.priceRules[index] = updated; + return updated; + }); +} + +export async function updateBillingPriceTierMultiplier(input: { + ruleId: string; + dimensionKey: string; + tierValue: string; + markupMultiplier: number; +}): Promise { + if (!Number.isFinite(input.markupMultiplier) || input.markupMultiplier < 1 || input.markupMultiplier > 1000) { + throw new BillingStoreError("上浮倍率必须在 1.00 至 1000.00 之间。", 400); + } + const existing = await getBillingPriceRule(input.ruleId); + if (!existing) return null; + const dimensions = existing.parameterDimensions || []; + const dimension = dimensions.find((item) => item.key === input.dimensionKey); + if (!dimension) throw new BillingStoreError("平台价格参数不存在。", 404); + const tierIndex = dimension.tiers.findIndex((item) => String(item.value) === input.tierValue); + if (tierIndex === -1) throw new BillingStoreError("平台价格档位不存在。", 404); + const nextDimensions = dimensions.map((item) => item.key !== input.dimensionKey ? item : { + ...item, + tiers: item.tiers.map((tier, index) => index === tierIndex ? { ...tier, markupMultiplier: input.markupMultiplier } : tier) + }); + return updateBillingPriceRule(input.ruleId, { parameterDimensions: nextDimensions }); +} + +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); + } + 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); + } + const state = await readState(); + return [...state.wallets].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); +} + +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 (!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 (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), + wallet: walletFromRpcRow(row, input.organizationId) + }; + } + + return mutateLocalState((state) => { + const existing = state.ledgerEntries.find((entry) => entry.idempotencyKey === input.idempotencyKey); + if (existing) { + const wallet = state.wallets.find((item) => item.organizationId === input.organizationId) || emptyWallet(input.organizationId); + return { entry: existing, wallet }; + } + const wallet = state.wallets.find((item) => item.organizationId === input.organizationId) || emptyWallet(input.organizationId); + if (deltaFen < 0 && wallet.balanceFen < Math.abs(deltaFen)) throw new InsufficientBalanceError(); + 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), + updatedAt: now + }; + const entry: BillingLedgerEntry = { + id: createId("ledger"), + organizationId: input.organizationId, + accountId, + jobId: input.jobId, + kind: input.kind, + deltaFen, + balanceAfterFen: nextWallet.balanceFen, + currency, + idempotencyKey: input.idempotencyKey, + description: input.description, + metadata, + createdAt: now + }; + const walletIndex = state.wallets.findIndex((item) => item.organizationId === input.organizationId); + if (walletIndex === -1) state.wallets.push(nextWallet); + else state.wallets[walletIndex] = nextWallet; + state.ledgerEntries.unshift(entry); + return { entry, wallet: nextWallet }; + }); +} + +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); + } + const state = await readState(); + return state.ledgerEntries + .filter((entry) => !filters.organizationId || entry.organizationId === filters.organizationId) + .filter((entry) => !filters.accountId || entry.accountId === filters.accountId) + .filter((entry) => !filters.jobId || entry.jobId === filters.jobId) + .filter((entry) => !filters.kind || entry.kind === filters.kind) + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) + .slice(0, limit); +} + +async function readState(): Promise { + await ensureRuntimeDirs(); + const path = join(dataDir(), STORE_FILE); + try { + return normalizeState(JSON.parse(await readFile(path, "utf8"))); + } catch { + const state = normalizeState({}); + await writeState(state); + return state; + } +} + +async function writeState(state: BillingState): Promise { + await ensureRuntimeDirs(); + const path = join(dataDir(), STORE_FILE); + const temp = `${path}.${createId("tmp")}.tmp`; + await writeFile(temp, JSON.stringify(state, null, 2)); + await rename(temp, path); +} + +async function mutateLocalState(mutator: (state: BillingState) => T): Promise { + const run = localWriteQueue.then(async () => { + const state = await readState(); + const result = mutator(state); + await writeState(state); + return result; + }); + localWriteQueue = run.catch(() => undefined); + return run; +} + +function normalizeState(raw: Partial): BillingState { + return { + priceRules: Array.isArray(raw.priceRules) ? raw.priceRules : [], + wallets: Array.isArray(raw.wallets) ? raw.wallets : [], + ledgerEntries: Array.isArray(raw.ledgerEntries) ? raw.ledgerEntries : [] + }; +} + +function emptyWallet(organizationId: string): OrganizationWallet { + return { + organizationId, + balanceFen: 0, + totalRechargedFen: 0, + totalChargedFen: 0, + updatedAt: new Date(0).toISOString() + }; +} + +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, + provider: rule.provider, + capability: rule.capability, + req_key: rule.reqKey || null, + variant_key: rule.variantKey || null, + unit: rule.unit, + standard_unit_price_fen: rule.standardUnitPriceFen, + markup_multiplier: rule.markupMultiplier, + enabled: rule.enabled, + conditions: rule.conditions || {}, + quantity_source: rule.quantitySource || null, + priority: rule.priority || 0, + note: rule.note || null, + source: rule.source || null, + parameter_dimensions: rule.parameterDimensions || [], + created_at: rule.createdAt, + updated_at: rule.updatedAt + }; +} + +function priceRuleFromRow(row: Record): BillingPriceRule { + return { + id: String(row.id), + provider: row.provider as BillingPriceRule["provider"], + capability: row.capability as BillingPriceRule["capability"], + 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), + 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" + ? row.quantity_source + : undefined, + 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) + }; +} + +function priceRuleMatchKey(rule: Pick): string { + return [rule.provider, rule.capability, rule.reqKey || "", rule.variantKey || "", canonicalConditions(rule.conditions)].join("\u0000"); +} + +function canonicalConditions(conditions?: BillingRuleConditions): string { + if (!conditions || !isRecord(conditions)) return "{}"; + const entries = Object.entries(conditions) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => [key, canonicalConditionValue(value)] as const); + return JSON.stringify(Object.fromEntries(entries)); +} + +function canonicalConditionValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalConditionValue).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))); + if (isRecord(value)) { + return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, canonicalConditionValue(item)])); + } + return value; +} + +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()) + }; +} + +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()) + }; +} + +function ledgerFromRow(row: Record): BillingLedgerEntry { + return { + id: String(row.id), + organizationId: String(row.organization_id), + 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), + 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) + }; +} + +function ledgerFromRpcRow(row: Record, input: WalletEntryInput, accountId?: string): BillingLedgerEntry { + return { + id: String(row.ledger_id || row.id), + organizationId: input.organizationId, + accountId, + jobId: input.jobId, + kind: input.kind, + deltaFen: Number(row.delta_fen ?? input.deltaFen), + balanceAfterFen: Number(row.balance_after_fen || 0), + currency: input.currency || "CNY", + idempotencyKey: input.idempotencyKey, + description: input.description, + metadata: input.metadata || {}, + createdAt: String(row.created_at || new Date().toISOString()) + }; +} + +function effectiveLedgerAccountId(input: Pick): string | undefined { + if (input.kind === "recharge" || input.kind === "adjustment") return undefined; + return optionalString(input.accountId); +} + +function firstRpcRow(value: unknown): Record | 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); +} + +function optionalString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +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 后重启服务。"; + } + return message; +} diff --git a/lib/server/data-store.ts b/lib/server/data-store.ts index 496a1e0..a7b35b2 100644 --- a/lib/server/data-store.ts +++ b/lib/server/data-store.ts @@ -1,7 +1,7 @@ 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, GenerationCapability, GenerationJob, GenerationStatus, ImageTemplate, Project, UsageEvent } from "@/lib/types"; +import type { AppState, Asset, BillingParameterSnapshot, BillingPriceSource, BillingQuantitySource, BillingRuleConditions, BillingSelectedParameterTier, GenerationCapability, GenerationJob, GenerationStatus, ImageTemplate, Project, UsageContext, UsageEvent, UsageSource } from "@/lib/types"; import { createId } from "@/lib/server/ids"; import { dataDir, DEFAULT_OWNER_ID, ensureRuntimeDirs } from "@/lib/server/runtime"; @@ -22,6 +22,13 @@ export type GenerationJobListFilters = { before?: string; }; +export type UsageEventListFilters = { + ownerId?: string; + source?: UsageSource; + from?: string; + to?: string; +}; + export type ClaimGenerationJobsInput = { workerId: string; limit?: number; @@ -303,15 +310,12 @@ export async function deleteGenerationJob(id: string): Promise { state.generationJobs = state.generationJobs.filter((job) => job.id !== id); - state.usageEvents = state.usageEvents.filter((event) => event.jobId !== id); return existing; }); } @@ -325,16 +329,79 @@ export async function recordUsageEvent(input: UsageInput): Promise { }; const supabase = getSupabaseAdmin(); if (supabase) { + const existing = await findSupabaseUsageEventByJobId(supabase, 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; + } if (error) throw new Error(error.message); return usageFromRow(data); } return mutateLocalState((state) => { + const existing = state.usageEvents.find((event) => event.jobId === usage.jobId); + if (existing) return existing; state.usageEvents.unshift(usage); return usage; }); } +export async function recordUsageForJob(job: GenerationJob): Promise { + if (job.provider === "mock" || job.externalClientId || job.usageContext?.source === "api") return null; + return recordUsageEvent({ + ownerId: job.ownerId, + jobId: job.id, + source: "platform", + capability: job.capability, + provider: job.provider, + reqKey: job.reqKey, + accountUsername: job.usageContext?.username, + accountDisplayName: job.usageContext?.displayName, + tenantId: job.usageContext?.tenantId, + organizationId: job.usageContext?.organizationId, + organizationName: job.usageContext?.organizationName, + quantity: job.billing?.quantity || 1, + estimatedUnit: job.billing?.unit === "video_second" ? "video_second" : job.billing?.unit === "image" ? "image" : "job", + chargedAmountFen: job.billing?.amountFen, + currency: job.billing?.currency + }); +} + +export async function listUsageEvents(filters: UsageEventListFilters = {}): 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; + } + return dedupeUsageEvents(rows.map(usageFromRow)); + } + + const state = await readState(); + const jobs = new Map(state.generationJobs.map((job) => [job.id, job])); + return dedupeUsageEvents(state.usageEvents.map((event) => enrichLegacyUsageEvent(event, jobs.get(event.jobId)))) + .filter((event) => !filters.ownerId || event.ownerId === filters.ownerId) + .filter((event) => !filters.source || event.source === filters.source) + .filter((event) => !filters.from || event.createdAt >= filters.from) + .filter((event) => !filters.to || event.createdAt < filters.to) + .sort(sortNewest); +} + export async function listProjects(ownerId = DEFAULT_OWNER_ID): Promise { const state = await readState(); return state.projects.filter((project) => project.ownerId === ownerId).sort(sortNewest); @@ -442,6 +509,24 @@ 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); + } + return; + } + await mutateLocalState((state) => { + for (const asset of state.assets) if (asset.ownerId === fromOwnerId) asset.ownerId = toOwnerId; + for (const job of state.generationJobs) if (job.ownerId === fromOwnerId) job.ownerId = toOwnerId; + for (const project of state.projects) if (project.ownerId === fromOwnerId) project.ownerId = toOwnerId; + for (const template of state.imageTemplates) if (template.ownerId === fromOwnerId) template.ownerId = toOwnerId; + }); +} + async function readState(): Promise { await ensureRuntimeDirs(); const path = join(dataDir(), STORE_FILE); @@ -582,6 +667,8 @@ function jobToRow(job: Partial) { if (job.webhookUrl !== undefined) row.webhook_url = job.webhookUrl; if (job.webhookAttempts !== undefined) row.webhook_attempts = job.webhookAttempts; if (job.webhookLastStatus !== undefined) row.webhook_last_status = job.webhookLastStatus; + if (job.usageContext !== undefined) row.usage_context = job.usageContext; + if (job.billing !== undefined) row.billing = job.billing; if (job.createdAt !== undefined) row.created_at = job.createdAt; if (job.updatedAt !== undefined) row.updated_at = job.updatedAt; return row; @@ -626,6 +713,8 @@ function jobFromRow(row: Record): GenerationJob { nextAttemptAt: optionalString(row.webhook_last_status.nextAttemptAt || row.webhook_last_status.next_attempt_at) } : undefined, + usageContext: usageContextFromValue(row.usage_context), + billing: billingJobChargeFromValue(row.billing), createdAt: String(row.created_at), updatedAt: String(row.updated_at) }; @@ -636,9 +725,19 @@ function usageToRow(usage: UsageEvent) { id: usage.id, owner_id: usage.ownerId, job_id: usage.jobId, + source: usage.source || "platform", capability: usage.capability, + provider: usage.provider, + req_key: usage.reqKey, + account_username: usage.accountUsername, + account_display_name: usage.accountDisplayName, + tenant_id: usage.tenantId, + organization_id: usage.organizationId, + organization_name: usage.organizationName, quantity: usage.quantity, estimated_unit: usage.estimatedUnit, + charged_amount_fen: usage.chargedAmountFen, + currency: usage.currency, created_at: usage.createdAt }; } @@ -648,13 +747,128 @@ function usageFromRow(row: Record): UsageEvent { id: String(row.id), ownerId: String(row.owner_id), jobId: String(row.job_id), + source: row.source === "api" ? "api" : "platform", capability: row.capability as UsageEvent["capability"], + provider: optionalString(row.provider) as UsageEvent["provider"], + reqKey: optionalString(row.req_key), + accountUsername: optionalString(row.account_username), + accountDisplayName: optionalString(row.account_display_name), + tenantId: optionalString(row.tenant_id), + organizationId: optionalString(row.organization_id), + organizationName: optionalString(row.organization_name), quantity: Number(row.quantity || 0), - estimatedUnit: row.estimated_unit as UsageEvent["estimatedUnit"], + 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) }; } +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; +} + +function enrichLegacyUsageEvent(event: UsageEvent, job?: GenerationJob): UsageEvent { + const source = event.source || (job?.externalClientId || event.ownerId.startsWith("api:") ? "api" : "platform"); + return { + ...event, + source, + provider: event.provider || job?.provider, + reqKey: event.reqKey || job?.reqKey, + accountUsername: event.accountUsername || job?.usageContext?.username, + accountDisplayName: event.accountDisplayName || job?.usageContext?.displayName, + tenantId: event.tenantId || job?.usageContext?.tenantId, + organizationId: event.organizationId || job?.usageContext?.organizationId, + organizationName: event.organizationName || job?.usageContext?.organizationName, + quantity: 1, + estimatedUnit: "job" + }; +} + +function dedupeUsageEvents(events: UsageEvent[]): UsageEvent[] { + const seen = new Set(); + return events.filter((event) => { + if (seen.has(event.jobId)) return false; + seen.add(event.jobId); + return true; + }); +} + +function usageContextFromValue(value: unknown): UsageContext | undefined { + if (!isRecord(value) || value.source !== "platform" && value.source !== "api") return undefined; + const accountId = optionalString(value.accountId); + const displayName = optionalString(value.displayName); + if (!accountId || !displayName) return undefined; + return { + source: value.source, + accountId, + username: optionalString(value.username), + displayName, + role: value.role === "super_admin" || value.role === "organization_admin" || value.role === "user" ? value.role : undefined, + tenantId: optionalString(value.tenantId), + organizationId: optionalString(value.organizationId), + organizationName: optionalString(value.organizationName) + }; +} + +function billingJobChargeFromValue(value: unknown): GenerationJob["billing"] { + if (!isRecord(value)) return undefined; + if (value.currency !== "CNY") return undefined; + if (value.status !== "not_charged" && value.status !== "pending" && value.status !== "charged" && value.status !== "refunded") return undefined; + if (typeof value.priceRuleId !== "string" || typeof value.provider !== "string" || typeof value.capability !== "string" || typeof value.reqKey !== "string") return undefined; + if (value.unit !== "request" && value.unit !== "image" && value.unit !== "video_second") return undefined; + const quantity = optionalNumber(value.quantity); + const standardUnitPriceFen = optionalNumber(value.standardUnitPriceFen); + const markupMultiplier = optionalNumber(value.markupMultiplier); + const amountFen = optionalNumber(value.amountFen); + if (quantity === undefined || standardUnitPriceFen === undefined || markupMultiplier === undefined || amountFen === undefined) return undefined; + return { + priceRuleId: value.priceRuleId, + provider: value.provider as GenerationJob["provider"], + capability: value.capability as GenerationJob["capability"], + reqKey: value.reqKey, + variantKey: optionalString(value.variantKey), + unit: value.unit, + quantity, + standardUnitPriceFen, + markupMultiplier, + amountFen, + currency: "CNY", + conditions: isRecord(value.conditions) ? value.conditions as BillingRuleConditions : undefined, + quantitySource: value.quantitySource === "request" || value.quantitySource === "image_count" || value.quantitySource === "duration" ? value.quantitySource as BillingQuantitySource : undefined, + parameters: isRecord(value.parameters) ? value.parameters as BillingParameterSnapshot : undefined, + baseStandardUnitPriceFen: optionalNumber(value.baseStandardUnitPriceFen), + parameterTiers: Array.isArray(value.parameterTiers) ? value.parameterTiers as BillingSelectedParameterTier[] : undefined, + source: isRecord(value.source) ? value.source as BillingPriceSource : undefined, + quotaExempt: value.quotaExempt === true, + status: value.status, + reservedAmountFen: optionalNumber(value.reservedAmountFen), + settlementStatus: value.settlementStatus === "pending" || value.settlementStatus === "settled" || value.settlementStatus === "estimated" ? value.settlementStatus : undefined, + settlementLedgerEntryId: optionalString(value.settlementLedgerEntryId), + settledAt: optionalString(value.settledAt), + settlementReason: optionalString(value.settlementReason), + providerUsage: isRecord(value.providerUsage) + && Number.isFinite(Number(value.providerUsage.completionTokens)) + && typeof value.providerUsage.resolution === "string" + && typeof value.providerUsage.inputVideo === "boolean" + && Number.isFinite(Number(value.providerUsage.tokenPriceFenPerMillion)) + ? { + completionTokens: Number(value.providerUsage.completionTokens), + resolution: value.providerUsage.resolution, + inputVideo: value.providerUsage.inputVideo, + tokenPriceFenPerMillion: Number(value.providerUsage.tokenPriceFenPerMillion) + } + : undefined, + ledgerEntryId: optionalString(value.ledgerEntryId), + refundLedgerEntryId: optionalString(value.refundLedgerEntryId), + chargedAt: optionalString(value.chargedAt), + refundedAt: optionalString(value.refundedAt), + refundReason: optionalString(value.refundReason) + }; +} + function imageTemplateToRow(template: Partial) { const row: Record = {}; if (template.id !== undefined) row.id = template.id; diff --git a/lib/server/generation-service.ts b/lib/server/generation-service.ts index a97a157..59f93fc 100644 --- a/lib/server/generation-service.ts +++ b/lib/server/generation-service.ts @@ -20,13 +20,14 @@ import { import { createGenerationJob, getGenerationJob, - recordUsageEvent, + recordUsageForJob, updateGenerationJob } from "@/lib/server/data-store"; +import { chargeGenerationJob, quoteGenerationCharge } from "@/lib/server/billing-service"; import { createMockImageBuffer } from "@/lib/server/mock-image"; import { importRemoteImageAsAsset, saveGeneratedAsset } from "@/lib/server/storage"; import { DEFAULT_OWNER_ID, toAbsoluteUrl } from "@/lib/server/runtime"; -import type { EnabledImageCapability, GenerationJob, VisualTaskQueryResponse } from "@/lib/types"; +import type { BillingJobCharge, EnabledImageCapability, GenerationJob, UsageContext, VisualTaskQueryResponse } from "@/lib/types"; import { queryVisualTask, shouldMockVisualApi, submitVisualTask } from "@/lib/volcengine/visual-client"; import { bailianResultUrls, @@ -53,18 +54,91 @@ export type SubmitImageJobInput = { min_ratio?: number; max_ratio?: number; force_single?: boolean; - resolution?: "4k" | "8k"; quality?: string; - seed?: number; retryOf?: string; idempotencyKey?: string; idempotencyFingerprint?: string; priority?: number; maxAttempts?: number; webhookUrl?: string; + usageContext?: UsageContext; }; +type PreparedImageGeneration = { + ownerId: string; + normalizedUrls: string[]; + engine: ImageCreationEngine; + providerPayload: Record; + missingBailianKey: boolean; + provider: "mock" | "bailian" | "evolink" | "volcengine-visual"; + reqKey: string; + requestPayload: Record; +}; + +export async function quoteImageGeneration(input: SubmitImageJobInput, origin: string): Promise { + const prepared = prepareImageGeneration(input, origin); + if (prepared.missingBailianKey) return undefined; + return quoteGenerationCharge({ + provider: prepared.provider, + capability: input.capability, + reqKey: prepared.reqKey, + requestPayload: prepared.requestPayload, + usageContext: input.usageContext, + externalClientId: input.externalClientId, + allowUnboundOrganization: true + }); +} + export async function submitImageJob(input: SubmitImageJobInput, origin: string): Promise { + const prepared = prepareImageGeneration(input, origin); + const { ownerId, normalizedUrls, providerPayload, missingBailianKey, provider, reqKey, requestPayload } = prepared; + const billing = missingBailianKey ? undefined : await quoteGenerationCharge({ + provider, + capability: input.capability, + reqKey, + requestPayload, + usageContext: input.usageContext, + externalClientId: input.externalClientId + }); + let job = await createGenerationJob({ + ownerId, + externalClientId: input.externalClientId, + capability: input.capability, + provider, + reqKey, + status: missingBailianKey ? "failed" : "queued", + prompt: input.prompt, + inputAssetIds: input.inputAssetIds || [], + inputUrls: normalizedUrls, + outputAssetIds: [], + requestPayload, + error: missingBailianKey ? { message: "缺少 BAILIAN_API_KEY,请先在设置页配置阿里云百炼 API Key。", retryable: true } : undefined, + retryOf: input.retryOf, + idempotencyKey: input.idempotencyKey, + idempotencyFingerprint: input.idempotencyFingerprint, + priority: input.priority, + maxAttempts: input.maxAttempts, + webhookUrl: input.webhookUrl, + usageContext: input.usageContext, + billing + }); + if (!billing) return job; + try { + return await chargeGenerationJob(job); + } catch (error) { + await updateGenerationJob(job.id, { + status: "failed", + billing: { ...billing, status: "not_charged" }, + error: { + message: error instanceof Error ? error.message : String(error), + retryable: false + } + }).catch(() => undefined); + throw error; + } +} + +function prepareImageGeneration(input: SubmitImageJobInput, origin: string): PreparedImageGeneration { const ownerId = input.ownerId || DEFAULT_OWNER_ID; const capability = getEnabledImageCapability(input.capability); const normalizedUrls = (input.imageUrls || []).map((url) => toAbsoluteUrl(url, origin)); @@ -78,38 +152,20 @@ export async function submitImageJob(input: SubmitImageJobInput, origin: string) const mock = engine === "bailian" ? shouldMockBailian() : engine === "evolink" ? shouldMockEvolinkApi() : shouldMockVisualApi(); const missingBailianKey = engine === "bailian" && !mock && !bailianConfig.apiKey; const reqKey = engine === "bailian" ? bailianConfig.imageModel : engine === "evolink" ? getEvolinkImageSettings().model : capability.reqKey; - let job = await createGenerationJob({ - ownerId, - externalClientId: input.externalClientId, - capability: input.capability, - provider: mock ? "mock" : engine === "bailian" ? "bailian" : engine === "evolink" ? "evolink" : "volcengine-visual", - reqKey, - status: missingBailianKey ? "failed" : "queued", - prompt: input.prompt, - inputAssetIds: input.inputAssetIds || [], - inputUrls: normalizedUrls, - outputAssetIds: [], - requestPayload: { - engine, - input, - providerPayload - }, - error: missingBailianKey ? { message: "缺少 BAILIAN_API_KEY,请先在设置页配置阿里云百炼 API Key。", retryable: true } : undefined, - retryOf: input.retryOf, - idempotencyKey: input.idempotencyKey, - idempotencyFingerprint: input.idempotencyFingerprint, - priority: input.priority, - maxAttempts: input.maxAttempts, - webhookUrl: input.webhookUrl - }); - - return job; + const provider = mock ? "mock" : engine === "bailian" ? "bailian" : engine === "evolink" ? "evolink" : "volcengine-visual"; + const requestPayload = { + engine, + input, + providerPayload + }; + return { ownerId, normalizedUrls, engine, providerPayload, missingBailianKey, provider, reqKey, requestPayload }; } export async function advanceImageJob(jobId: string, origin: string): Promise { - const job = await getGenerationJob(jobId); + let job = await getGenerationJob(jobId); if (!job) throw new Error(`Generation job not found: ${jobId}`); if (["succeeded", "failed", "expired", "cancelled"].includes(job.status)) return job; + if (job.billing?.status === "pending") job = await chargeGenerationJob(job); if (job.provider === "mock") return completeMockJob(job, origin); if (!job.providerTaskId) return dispatchImageJob(job); return syncImageJob(job.id, origin); @@ -245,13 +301,7 @@ export async function syncImageJob(jobId: string, origin: string): Promise asset.id), responsePayload: response }); } @@ -350,13 +400,7 @@ async function syncEvolinkImageJob(job: GenerationJob, origin: string): Promise< tags: assetTagsForJob(job) })); } - await recordUsageEvent({ - ownerId: job.ownerId, - jobId: job.id, - capability: job.capability, - quantity: assets.length, - estimatedUnit: "image" - }); + await recordUsageForJob(job); return updateGenerationJob(job.id, { status: "succeeded", outputAssetIds: assets.map((asset) => asset.id), @@ -364,7 +408,12 @@ async function syncEvolinkImageJob(job: GenerationJob, origin: string): Promise< }); } -export async function retryImageJob(jobId: string, origin: string, ownerId?: string): Promise { +export async function retryImageJob( + jobId: string, + origin: string, + ownerId?: string, + usageContext?: UsageContext +): Promise { const job = await getGenerationJob(jobId); if (!job) throw new Error(`Generation job not found: ${jobId}`); if (ownerId && job.ownerId !== ownerId) throw new Error(`Generation job not found: ${jobId}`); @@ -373,6 +422,7 @@ export async function retryImageJob(jobId: string, origin: string, ownerId?: str ...input, ownerId: ownerId || job.ownerId, capability: job.capability as EnabledImageCapability, + usageContext: usageContext || job.usageContext || input.usageContext, retryOf: job.id }, origin); } @@ -383,8 +433,7 @@ async function completeMockJob(job: GenerationJob, origin: string): Promise = { + "480p": { withoutVideo: 4_600, withVideo: 2_800 }, + "720p": { withoutVideo: 4_600, withVideo: 2_800 }, + "1080p": { withoutVideo: 5_100, withVideo: 3_100 }, + "4k": { withoutVideo: 2_600, withVideo: 1_600 } + }; + const price = prices[normalized] || prices["720p"]; + return inputVideo ? price.withVideo : price.withoutVideo; +} + +export function calculateSeedanceActualAmountFen(input: SeedanceActualBillingInput): number { + if (!Number.isFinite(input.completionTokens) || input.completionTokens <= 0) { + throw new Error("Seedance 实际 token 用量必须大于 0。"); + } + if (!Number.isFinite(input.markupMultiplier) || input.markupMultiplier < 1) { + throw new Error("Seedance 计费倍率不能低于 1.00。"); + } + const tokenPriceFenPerMillion = seedanceTokenPriceFenPerMillion(input.resolution, input.inputVideo); + return Math.max(1, Math.ceil( + input.completionTokens * tokenPriceFenPerMillion * input.markupMultiplier / SEEDANCE_TOKEN_SCALE + )); +} + +export function estimateSeedanceAmountFen(input: SeedanceBillingEstimateInput): number { + if (!Number.isFinite(input.outputDurationSeconds) || input.outputDurationSeconds <= 0) { + throw new Error("Seedance 输出时长必须大于 0。"); + } + const inputVideo = Boolean(input.inputVideo); + const inputDuration = inputVideo + ? normalizeInputVideoDuration(input.inputVideoDurationSeconds) + : 0; + const completionTokens = estimateSeedanceCompletionTokens({ + resolution: input.resolution, + aspectRatio: input.aspectRatio, + outputDurationSeconds: input.outputDurationSeconds, + inputVideoDurationSeconds: inputDuration + }); + return Math.max(1, Math.ceil( + completionTokens * seedanceTokenPriceFenPerMillion(input.resolution, inputVideo) * input.markupMultiplier / SEEDANCE_TOKEN_SCALE + )); +} + +export function estimateSeedanceCompletionTokens(input: { + resolution?: unknown; + aspectRatio?: unknown; + outputDurationSeconds: number; + inputVideoDurationSeconds?: number; +}): number { + const dimensions = seedanceOutputDimensions(input.resolution, input.aspectRatio); + const inputDuration = Math.max(0, Number(input.inputVideoDurationSeconds) || 0); + const outputDuration = Math.max(0, Number(input.outputDurationSeconds) || 0); + return Math.ceil( + (inputDuration + outputDuration) * dimensions.width * dimensions.height * SEEDANCE_FPS / 1024 + ); +} + +/** + * 没有媒体元数据时按官方可接收的最大输入视频时长冻结,避免先扣少、成功后补扣造成余额不足。 + */ +export function normalizeInputVideoDuration(value: unknown): number { + const duration = Number(value); + if (!Number.isFinite(duration) || duration <= 0) return VIDEO_DURATION_MAX; + return Math.min(VIDEO_DURATION_MAX, duration); +} + +export function normalizeSeedanceResolution(value: unknown): string { + const normalized = String(value || "720p").trim().toLowerCase(); + if (normalized === "4k" || normalized === "4K".toLowerCase()) return "4k"; + if (normalized === "480p" || normalized === "1080p") return normalized; + return "720p"; +} + +export function seedanceOutputDimensions(resolution: unknown, aspectRatio: unknown): { width: number; height: number } { + const base = { + "480p": { width: 854, height: 480 }, + "720p": { width: 1280, height: 720 }, + "1080p": { width: 1920, height: 1080 }, + "4k": { width: 3840, height: 2160 } + }[normalizeSeedanceResolution(resolution)] || { width: 1280, height: 720 }; + const ratio = parseAspectRatio(aspectRatio); + const area = base.width * base.height; + return { + width: Math.max(1, Math.round(Math.sqrt(area * ratio))), + height: Math.max(1, Math.round(Math.sqrt(area / ratio))) + }; +} + +export function requestHasInputVideo(requestPayload: Record): boolean { + return requestMaterials(requestPayload).some((material) => String(material.type || "").toLowerCase() === "video"); +} + +export function requestInputVideoDurationSeconds(requestPayload: Record): number | undefined { + const videoMaterials = requestMaterials(requestPayload) + .filter((material) => String(material.type || "").toLowerCase() === "video"); + if (!videoMaterials.length) return undefined; + const knownDurations = videoMaterials + .map((material) => firstFiniteNumber( + material.duration, + material.durationSeconds, + recordValue(material.metadata)?.duration, + recordValue(material.metadata)?.durationSeconds + )) + .filter((value): value is number => value !== undefined && value > 0); + if (!knownDurations.length) return undefined; + return Math.min(VIDEO_DURATION_MAX, knownDurations.reduce((sum, value) => sum + value, 0)); +} + +function requestMaterials(requestPayload: Record): Array> { + const assembled = recordValue(requestPayload.assembled); + const input = recordValue(requestPayload.input); + const assembledMaterials = recordArray(assembled?.materials); + const inputMaterials = recordArray(input?.materials); + const materials = assembledMaterials.length ? assembledMaterials : inputMaterials; + return materials; +} + +function parseAspectRatio(value: unknown): number { + const normalized = String(value || "16:9").trim().toLowerCase(); + const match = normalized.match(/^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/); + if (!match) return 16 / 9; + const width = Number(match[1]); + const height = Number(match[2]); + return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 + ? width / height + : 16 / 9; +} + +function recordArray(value: unknown): Array> { + return Array.isArray(value) ? value.map(recordValue).filter((item): item is Record => Boolean(item)) : []; +} + +function recordValue(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : undefined; +} + +function firstFiniteNumber(...values: unknown[]): number | undefined { + for (const value of values) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} diff --git a/lib/server/storage.ts b/lib/server/storage.ts index 17527aa..6363716 100644 --- a/lib/server/storage.ts +++ b/lib/server/storage.ts @@ -146,29 +146,6 @@ export async function importRemoteAssetAsAsset(input: { }); } -export async function saveMaskDataUrl(input: { - ownerId: string; - dataUrl: string; - origin: string; - jobHint?: string; -}): Promise { - const parsed = parseDataUrl(input.dataUrl); - return saveGeneratedAsset({ - ownerId: input.ownerId, - bytes: parsed.bytes, - fileName: `mask-${input.jobHint || createId("brush")}.png`, - contentType: parsed.contentType, - origin: input.origin, - source: "edited", - capability: "image.inpaint", - kind: "mask", - tags: ["mask"], - metadata: { - maskRule: "black keeps original pixels, white repaints selected pixels" - } - }); -} - export async function readLocalServedFile(area: "uploads" | "generated-results", pathParts: string[]): Promise<{ bytes: Buffer; contentType: string; @@ -415,15 +392,6 @@ function inferKind(contentType: string): AssetKind { return "other"; } -function parseDataUrl(dataUrl: string): { contentType: string; bytes: Buffer } { - const match = dataUrl.match(/^data:([^;,]+);base64,(.+)$/); - if (!match) throw new Error("Invalid mask data URL."); - return { - contentType: match[1], - bytes: Buffer.from(match[2], "base64") - }; -} - function extensionForContentType(contentType: string): string { if (contentType.includes("jpeg") || contentType.includes("jpg")) return ".jpg"; if (contentType.includes("webp")) return ".webp"; diff --git a/lib/server/task-manager.ts b/lib/server/task-manager.ts index 5d93fc0..28d743c 100644 --- a/lib/server/task-manager.ts +++ b/lib/server/task-manager.ts @@ -10,6 +10,7 @@ import { advanceVideoJob } from "@/lib/server/video-generation-service"; import { recordAppLog } from "@/lib/server/log-manager"; import { requestOrigin } from "@/lib/server/runtime"; import { deliverJobWebhook } from "@/lib/server/webhook"; +import { refundGenerationCharge } from "@/lib/server/billing-service"; import type { GenerationJob, GenerationStatus } from "@/lib/types"; export type WorkerTickResult = { @@ -111,9 +112,12 @@ async function settleAdvancedJob(job: GenerationJob): Promise<{ } if (TERMINAL_STATUSES.has(current.status)) { - const terminalJob = await clearGenerationJobLock(current.id, { - attempts: current.status === "failed" ? (current.attempts || 0) + 1 : current.attempts, - completedAt: current.completedAt || now.toISOString() + const refunded = current.status === "succeeded" + ? current + : await refundGenerationCharge(current, current.error?.message || `任务${current.status}`) || current; + const terminalJob = await clearGenerationJobLock(refunded.id, { + attempts: refunded.status === "failed" ? (refunded.attempts || 0) + 1 : refunded.attempts, + completedAt: refunded.completedAt || now.toISOString() }); const webhook = await deliverJobWebhook(terminalJob); if (webhook.lastStatus) { diff --git a/lib/server/usage-context.ts b/lib/server/usage-context.ts new file mode 100644 index 0000000..fb8ed0f --- /dev/null +++ b/lib/server/usage-context.ts @@ -0,0 +1,83 @@ +import type { AuthSession } from "@/lib/auth/session"; +import { hasSuperAdminAccess } from "@/lib/auth/permissions"; +import { + getOrganizationApiConfig, + listOrganizations, + type OrganizationInfo +} from "@/lib/server/organization-client"; +import type { UsageContext } from "@/lib/types"; + +const ORGANIZATION_CACHE_MS = 5 * 60 * 1000; +const organizationCache = new Map(); + +export async function resolvePlatformUsageContext(session: AuthSession): Promise { + const { user } = session; + const context: UsageContext = { + source: "platform", + accountId: user.id, + username: user.username, + displayName: user.displayName, + role: hasSuperAdminAccess(user) ? "super_admin" : user.role, + tenantId: user.tenantId, + organizationId: user.organizationId, + organizationName: user.organizationName + }; + if (user.organizationId) return context; + if (!user.tenantId) return context; + + const cached = organizationCache.get(user.tenantId); + if (cached && cached.expiresAt > Date.now()) { + return { + ...context, + organizationId: cached.organizationId, + organizationName: cached.organizationName + }; + } + + const config = getOrganizationApiConfig(session.accessToken); + if (config.defaultOrganizationId && config.tenantId === user.tenantId) { + cacheOrganization(user.tenantId, { + organizationId: config.defaultOrganizationId + }); + return { ...context, organizationId: config.defaultOrganizationId }; + } + + const organization = await findOrganizationForTenant(user.tenantId, session.accessToken); + if (!organization) return context; + cacheOrganization(user.tenantId, organization); + return { + ...context, + organizationId: organization.organizationId, + organizationName: organization.organizationName + }; +} + +export function clearUsageOrganizationCacheForTests() { + organizationCache.clear(); +} + +async function findOrganizationForTenant(tenantId: string, accessToken?: string): Promise { + const contexts = accessToken ? [{ accessToken }, {}] : [{}]; + for (const context of contexts) { + try { + const organizations = await listOrganizations(context); + const match = organizations.find((item) => String(item.organizationBindTenantId ?? "") === tenantId); + if (match) return match; + } catch { + // Organization lookup must never block a generation request. Missing matches remain unassigned. + } + } + return null; +} + +function cacheOrganization(tenantId: string, organization: { organizationId: string; organizationName?: string }) { + organizationCache.set(tenantId, { + expiresAt: Date.now() + ORGANIZATION_CACHE_MS, + organizationId: organization.organizationId, + organizationName: organization.organizationName + }); +} diff --git a/lib/server/usage-service.ts b/lib/server/usage-service.ts new file mode 100644 index 0000000..a054b13 --- /dev/null +++ b/lib/server/usage-service.ts @@ -0,0 +1,281 @@ +import { createHash } from "node:crypto"; +import { listUsageEvents } from "@/lib/server/data-store"; +import type { OrganizationInfo } from "@/lib/server/organization-client"; +import type { GenerationCapability, GenerationProvider, UsageEvent } from "@/lib/types"; +import { + CAPABILITY_OPTIONS, + UNASSIGNED_ORGANIZATION_ID, + capabilityLabel, + providerLabel, + shiftDateKey, + usageDateKey, + usageDateRange, + usagePresetRange, + type AdminUsageReport, + type PersonalUsageReport, + type UsageAccountRow, + type UsageCountItem, + type UsageOrganizationRow, + type UsagePreset, + type UsageRecordView, + type UsageTrendPoint +} from "@/lib/usage"; + +export type AdminUsageFilters = { + startDate?: string; + endDate?: string; + organizationId?: string; + ownerId?: string; + capability?: GenerationCapability; + provider?: GenerationProvider; +}; + +export async function getPersonalUsageReport( + ownerId: string, + preset: UsagePreset, + now = new Date() +): Promise { + const range = usagePresetRange(preset, now); + const events = eligibleEvents(await listUsageEvents({ + ownerId, + source: "platform", + from: range.from, + to: range.to + })); + return { + preset, + range, + total: events.length, + byCapability: capabilityBreakdown(events), + recent: events.slice(0, 5).map((event) => usageRecordView(event)) + }; +} + +export async function getAdminUsageReport( + filters: AdminUsageFilters, + organizations: OrganizationInfo[] = [], + now = new Date() +): Promise { + const defaultRange = usagePresetRange("month", now); + const range = filters.startDate || filters.endDate + ? usageDateRange(filters.startDate || defaultRange.startDate, filters.endDate || defaultRange.endDate) + : defaultRange; + const baseEvents = eligibleEvents(await listUsageEvents({ + source: "platform", + from: range.from, + to: range.to + })); + const organizationNames = new Map(organizations.map((organization) => [ + organization.organizationId, + organization.organizationName || organization.organizationId + ])); + const baseRecords = baseEvents.map((event) => usageRecordView(event, organizationNames)); + const records = baseRecords.filter((record) => { + if (filters.organizationId && record.organizationId !== filters.organizationId) return false; + if (filters.ownerId && record.ownerId !== filters.ownerId) return false; + if (filters.capability && record.capability !== filters.capability) return false; + if (filters.provider && record.provider !== filters.provider) return false; + return true; + }); + const filteredEventsById = new Map(baseEvents.map((event) => [event.id, event])); + const filteredEvents = records.map((record) => filteredEventsById.get(record.id)).filter((event): event is UsageEvent => Boolean(event)); + const organizationRows = organizationBreakdown(records); + const accountRows = accountBreakdown(records); + const optionRecords = filters.organizationId + ? baseRecords.filter((record) => record.organizationId === filters.organizationId) + : baseRecords; + + return { + range, + summary: { + total: records.length, + activeAccounts: new Set(records.map((record) => record.ownerId)).size, + activeOrganizations: new Set(records + .map((record) => record.organizationId) + .filter((id) => id !== UNASSIGNED_ORGANIZATION_ID)).size, + averagePerDay: Number((records.length / range.dayCount).toFixed(1)) + }, + trend: trendBreakdown(records, range.startDate, range.endDate, range.dayCount), + byCapability: capabilityBreakdown(filteredEvents), + byProvider: providerBreakdown(filteredEvents), + organizations: organizationRows, + accounts: accountRows, + recent: records.slice(0, 100), + options: { + organizations: organizationOptions(organizations, optionRecords), + accounts: accountOptions(optionRecords), + capabilities: CAPABILITY_OPTIONS.map((item) => ({ value: item.value, label: item.label })), + providers: providerOptions(baseRecords) + } + }; +} + +function eligibleEvents(events: UsageEvent[]): UsageEvent[] { + return events + .filter((event) => event.source !== "api" && event.provider !== "mock") + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)); +} + +function usageRecordView(event: UsageEvent, organizationNames = new Map()): UsageRecordView { + const organizationId = event.organizationId || UNASSIGNED_ORGANIZATION_ID; + return { + id: event.id, + jobId: event.jobId, + ownerId: event.ownerId, + accountName: event.accountDisplayName || event.accountUsername || historicalAccountName(event.ownerId), + accountUsername: event.accountUsername, + organizationId, + organizationName: event.organizationName || organizationNames.get(organizationId) || "未归属组织", + capability: event.capability, + capabilityLabel: capabilityLabel(event.capability), + provider: event.provider, + providerLabel: providerLabel(event.provider), + reqKey: event.reqKey, + createdAt: event.createdAt + }; +} + +function capabilityBreakdown(events: UsageEvent[]): UsageCountItem[] { + const counts = countBy(events, (event) => event.capability); + return CAPABILITY_OPTIONS.map((item) => ({ + key: item.value, + label: item.label, + count: counts.get(item.value) || 0 + })); +} + +function providerBreakdown(events: UsageEvent[]): UsageCountItem[] { + const counts = countBy(events, (event) => event.provider || "unknown"); + return [...counts.entries()] + .map(([key, count]) => ({ key, label: providerLabel(key === "unknown" ? undefined : key as GenerationProvider), count })) + .sort(sortCountRows); +} + +function organizationBreakdown(records: UsageRecordView[]): UsageOrganizationRow[] { + const rows = new Map }>(); + for (const record of records) { + const existing = rows.get(record.organizationId) || { + organizationId: record.organizationId, + organizationName: record.organizationName, + count: 0, + accountCount: 0, + accounts: new Set(), + lastUsedAt: undefined + }; + existing.count += 1; + existing.accounts.add(record.ownerId); + existing.accountCount = existing.accounts.size; + existing.lastUsedAt = latestTime(existing.lastUsedAt, record.createdAt); + rows.set(record.organizationId, existing); + } + return [...rows.values()] + .map(({ accounts: _accounts, ...row }) => row) + .sort((left, right) => { + const count = sortCountRows(left, right); + if (count) return count; + if (left.organizationId === UNASSIGNED_ORGANIZATION_ID) return 1; + if (right.organizationId === UNASSIGNED_ORGANIZATION_ID) return -1; + return left.organizationName.localeCompare(right.organizationName, "zh-CN"); + }); +} + +function accountBreakdown(records: UsageRecordView[]): UsageAccountRow[] { + const rows = new Map(); + for (const record of records) { + const existing = rows.get(record.ownerId) || { + ownerId: record.ownerId, + accountName: record.accountName, + accountUsername: record.accountUsername, + organizationId: record.organizationId, + organizationName: record.organizationName, + count: 0, + lastUsedAt: undefined + }; + existing.count += 1; + existing.lastUsedAt = latestTime(existing.lastUsedAt, record.createdAt); + rows.set(record.ownerId, existing); + } + return [...rows.values()].sort(sortCountRows); +} + +function trendBreakdown( + records: UsageRecordView[], + startDate: string, + endDate: string, + dayCount: number +): UsageTrendPoint[] { + if (dayCount > 62) { + const monthly = countBy(records, (record) => usageDateKey(record.createdAt).slice(0, 7)); + return [...monthly.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([date, count]) => ({ + date, + label: date, + count + })); + } + const daily = countBy(records, (record) => usageDateKey(record.createdAt)); + const points: UsageTrendPoint[] = []; + for (let date = startDate; date <= endDate; date = shiftDateKey(date, 1)) { + points.push({ date, label: date.slice(5), count: daily.get(date) || 0 }); + } + return points; +} + +function organizationOptions(organizations: OrganizationInfo[], records: UsageRecordView[]) { + const options = new Map(); + for (const organization of organizations) { + options.set(organization.organizationId, organization.organizationName || organization.organizationId); + } + for (const record of records) options.set(record.organizationId, record.organizationName); + return [...options.entries()] + .map(([value, label]) => ({ value, label })) + .sort((left, right) => { + if (left.value === UNASSIGNED_ORGANIZATION_ID) return 1; + if (right.value === UNASSIGNED_ORGANIZATION_ID) return -1; + return left.label.localeCompare(right.label, "zh-CN"); + }); +} + +function accountOptions(records: UsageRecordView[]) { + const options = new Map(); + for (const record of records) { + if (!options.has(record.ownerId)) { + const username = record.accountUsername ? `(${record.accountUsername})` : ""; + options.set(record.ownerId, `${record.accountName}${username}`); + } + } + return [...options.entries()] + .map(([value, label]) => ({ value, label })) + .sort((left, right) => left.label.localeCompare(right.label, "zh-CN")); +} + +function providerOptions(records: UsageRecordView[]) { + const options = new Map(); + for (const record of records) { + if (record.provider) options.set(record.provider, record.providerLabel); + } + return [...options.entries()] + .map(([value, label]) => ({ value, label })) + .sort((left, right) => left.label.localeCompare(right.label, "zh-CN")); +} + +function historicalAccountName(ownerId: string): string { + const id = createHash("sha256").update(ownerId).digest("hex").slice(0, 8).toUpperCase(); + return `历史账号 ${id}`; +} + +function countBy(items: T[], keyFor: (item: T) => string): Map { + const counts = new Map(); + for (const item of items) { + const key = keyFor(item); + counts.set(key, (counts.get(key) || 0) + 1); + } + return counts; +} + +function latestTime(current: string | undefined, candidate: string): string { + return !current || candidate > current ? candidate : current; +} + +function sortCountRows(left: { count: number }, right: { count: number }): number { + return right.count - left.count; +} diff --git a/lib/server/video-generation-service.ts b/lib/server/video-generation-service.ts index 105c086..826af54 100644 --- a/lib/server/video-generation-service.ts +++ b/lib/server/video-generation-service.ts @@ -3,13 +3,14 @@ import { createAsset, createGenerationJob, getGenerationJob, - recordUsageEvent, + recordUsageForJob, updateGenerationJob } from "@/lib/server/data-store"; +import { chargeGenerationJob, quoteGenerationCharge, settleSeedanceGenerationCharge } from "@/lib/server/billing-service"; import { DEFAULT_OWNER_ID } from "@/lib/server/runtime"; import { importRemoteAssetAsAsset } from "@/lib/server/storage"; import { createSeedanceTask, getSeedanceConfig, querySeedanceTask, shouldMockSeedance, type SeedanceSettings } from "@/lib/seedance/client"; -import type { GenerationJob } from "@/lib/types"; +import type { BillingJobCharge, GenerationJob, UsageContext } from "@/lib/types"; import { normalizeVideoDuration, normalizeVideoRatio, normalizeVideoResolution } from "@/lib/video-settings"; import { bailianResultUrls, bailianStatus, bailianTaskId, buildBailianVideoPayload, getBailianConfig, queryBailianTask, shouldMockBailian, submitBailianTask } from "@/lib/bailian/client"; @@ -28,11 +29,91 @@ export type SubmitVideoJobInput = PromptAssemblyInput & { priority?: number; maxAttempts?: number; webhookUrl?: string; + usageContext?: UsageContext; }; +type PreparedVideoGeneration = { + ownerId: string; + engine: VideoCreationEngine; + config: ReturnType; + assembled: ReturnType; + finalPrompt: string; + settings: SeedanceSettings; + missingBailianKey: boolean; + provider: "mock" | "bailian" | "seedance"; + reqKey: string; + requestPayload: Record; +}; + +export async function quoteVideoGeneration(input: SubmitVideoJobInput, origin: string): Promise { + const prepared = prepareVideoGeneration(input, origin); + if (prepared.missingBailianKey) return undefined; + return quoteGenerationCharge({ + provider: prepared.provider, + capability: "video.generate", + reqKey: prepared.reqKey, + requestPayload: prepared.requestPayload, + usageContext: input.usageContext, + externalClientId: input.externalClientId, + allowUnboundOrganization: true + }); +} + export async function submitVideoJob(input: SubmitVideoJobInput, origin: string): Promise { + const prepared = prepareVideoGeneration(input, origin); + const { ownerId, engine, config, assembled, finalPrompt, settings, missingBailianKey, provider, reqKey, requestPayload } = prepared; + const billing = missingBailianKey ? undefined : await quoteGenerationCharge({ + provider, + capability: "video.generate", + reqKey, + requestPayload, + usageContext: input.usageContext, + externalClientId: input.externalClientId + }); + let job = await createGenerationJob({ + ownerId, + externalClientId: input.externalClientId, + capability: "video.generate", + provider, + reqKey, + status: missingBailianKey ? "failed" : "queued", + prompt: finalPrompt, + inputAssetIds: input.materials?.map((material) => material.id).filter(Boolean) as string[] || [], + inputUrls: assembled.materials.map((material) => material.url), + outputAssetIds: [], + requestPayload, + error: missingBailianKey ? { message: "缺少 BAILIAN_API_KEY,请先在设置页配置阿里云百炼 API Key。", retryable: true } : undefined, + retryOf: input.retryOf, + idempotencyKey: input.idempotencyKey, + idempotencyFingerprint: input.idempotencyFingerprint, + priority: input.priority, + maxAttempts: input.maxAttempts, + webhookUrl: input.webhookUrl, + usageContext: input.usageContext, + billing + }); + if (!billing) return job; + try { + return await chargeGenerationJob(job); + } catch (error) { + await updateGenerationJob(job.id, { + status: "failed", + billing: { ...billing, status: "not_charged" }, + error: { + message: error instanceof Error ? error.message : String(error), + retryable: false + } + }).catch(() => undefined); + throw error; + } +} + +function prepareVideoGeneration(input: SubmitVideoJobInput, origin: string): PreparedVideoGeneration { const ownerId = input.ownerId || DEFAULT_OWNER_ID; - const engine: VideoCreationEngine = input.engine === "bailian" ? "bailian" : "seedance"; + const configuredEngine = process.env.VIDEO_GENERATE_ENGINE === "seedance" ? "seedance" : "bailian"; + const engine: VideoCreationEngine = input.engine === "seedance" || input.engine === "bailian" + ? input.engine + : configuredEngine; const config = getSeedanceConfig(); const assembled = assemblePrompt({ ...input, @@ -52,39 +133,32 @@ export async function submitVideoJob(input: SubmitVideoJobInput, origin: string) } const mock = engine === "bailian" ? shouldMockBailian() : shouldMockSeedance(); const missingBailianKey = engine === "bailian" && !mock && !getBailianConfig().apiKey; - let job = await createGenerationJob({ + const provider = mock ? "mock" : engine; + const requestPayload = { + input, + assembled, + settings, + engine + }; + return { ownerId, - externalClientId: input.externalClientId, - capability: "video.generate", - provider: mock ? "mock" : engine, + engine, + config, + assembled, + finalPrompt, + settings, + missingBailianKey, + provider, reqKey: engine === "bailian" ? getBailianConfig().videoModel : config.model, - status: missingBailianKey ? "failed" : "queued", - prompt: finalPrompt, - inputAssetIds: input.materials?.map((material) => material.id).filter(Boolean) as string[] || [], - inputUrls: assembled.materials.map((material) => material.url), - outputAssetIds: [], - requestPayload: { - input, - assembled, - settings, - engine - }, - error: missingBailianKey ? { message: "缺少 BAILIAN_API_KEY,请先在设置页配置阿里云百炼 API Key。", retryable: true } : undefined, - retryOf: input.retryOf, - idempotencyKey: input.idempotencyKey, - idempotencyFingerprint: input.idempotencyFingerprint, - priority: input.priority, - maxAttempts: input.maxAttempts, - webhookUrl: input.webhookUrl - }); - - return job; + requestPayload + }; } export async function advanceVideoJob(jobId: string, origin: string): Promise { - const job = await getGenerationJob(jobId); + let job = await getGenerationJob(jobId); if (!job) throw new Error(`Generation job not found: ${jobId}`); if (["succeeded", "failed", "cancelled", "expired"].includes(job.status)) return job; + if (job.billing?.status === "pending") job = await chargeGenerationJob(job); if (job.provider === "mock") return completeMockVideoJob(job); if (!job.providerTaskId) return dispatchVideoJob(job, origin); return syncVideoJob(job.id, origin); @@ -143,7 +217,7 @@ export async function syncVideoJob(jobId: string, origin: string): Promise) { +async function completeRemoteVideo( + job: GenerationJob, + origin: string, + resultUrl: string, + responsePayload: Record, + completionTokens?: number +) { + const settledJob = job.provider === "seedance" + ? await settleSeedanceGenerationCharge(job, completionTokens) || job + : job; const asset = await importRemoteAssetAsAsset({ - ownerId: job.ownerId, + ownerId: settledJob.ownerId, url: resultUrl, origin, source: "generated", capability: "video.generate", - jobId: job.id, + jobId: settledJob.id, index: 0, fallbackContentType: "video/mp4", - tags: assetTagsForJob(job) + tags: assetTagsForJob(settledJob) }); - await recordUsageEvent({ - ownerId: job.ownerId, - jobId: job.id, - capability: "video.generate", - quantity: 1, - estimatedUnit: "job" - }); - return updateGenerationJob(job.id, { + await recordUsageForJob(settledJob); + return updateGenerationJob(settledJob.id, { status: "succeeded", outputAssetIds: [asset.id], responsePayload @@ -186,7 +263,12 @@ export async function retryVideoJob(jobId: string, origin: string, ownerId?: str if (!job) throw new Error(`Generation job not found: ${jobId}`); if (ownerId && job.ownerId !== ownerId) throw new Error(`Generation job not found: ${jobId}`); const input = (job.requestPayload.input || {}) as SubmitVideoJobInput; - return submitVideoJob({ ...input, ownerId: ownerId || job.ownerId, retryOf: job.id }, origin); + return submitVideoJob({ + ...input, + ownerId: ownerId || job.ownerId, + usageContext: job.usageContext || input.usageContext, + retryOf: job.id + }, origin); } async function completeMockVideoJob(job: GenerationJob): Promise { @@ -204,13 +286,6 @@ async function completeMockVideoJob(job: GenerationJob): Promise jobId: job.id } }); - await recordUsageEvent({ - ownerId: job.ownerId, - jobId: job.id, - capability: "video.generate", - quantity: 1, - estimatedUnit: "job" - }); return updateGenerationJob(job.id, { status: "succeeded", outputAssetIds: [asset.id], diff --git a/lib/types.ts b/lib/types.ts index 5c910f2..f491408 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,7 +1,5 @@ export type ImageCapability = - | "image.generate" - | "image.inpaint" - | "image.upscale"; + | "image.generate"; export type EnabledImageCapability = ImageCapability; @@ -9,6 +7,214 @@ export type VideoCapability = "video.generate"; export type GenerationCapability = ImageCapability | VideoCapability; +export type GenerationProvider = + | "volcengine-visual" + | "evolink" + | "seedance" + | "bailian" + | "mock"; + +export type BillingCurrency = "CNY"; + +export type BillingUnit = "request" | "image" | "video_second"; + +export type BillingPriceSource = { + url: string; + currency: "CNY" | "USD"; + unitPrice: number; + fxRate?: number; + basis?: string; + observedAt?: string; +}; + +export type BillingScalar = string | number | boolean; + +export type BillingConditionValue = + | BillingScalar + | { + min?: number; + max?: number; + values?: BillingScalar[]; + }; + +export type BillingRuleConditions = Record; + +export type BillingQuantitySource = "request" | "image_count" | "duration"; + +export type BillingParameterSnapshot = Record; + +export type BillingParameterTier = { + value: BillingScalar; + label: string; + standardFactor: number; + markupMultiplier: number; + enabled: boolean; + match?: BillingConditionValue; + note?: string; +}; + +export type BillingParameterDimension = { + key: string; + label: string; + baselineValue: BillingScalar; + defaultValue?: BillingScalar; + tiers: BillingParameterTier[]; +}; + +export type BillingSelectedParameterTier = BillingParameterTier & { + dimensionKey: string; + dimensionLabel: string; + actualValue: BillingScalar; + standardUnitPriceFen: number; +}; + +export type BillingPriceRule = { + id: string; + provider: GenerationProvider; + capability: GenerationCapability; + reqKey?: string; + variantKey?: string; + unit: BillingUnit; + standardUnitPriceFen: number; + markupMultiplier: number; + enabled: boolean; + conditions?: BillingRuleConditions; + quantitySource?: BillingQuantitySource; + priority?: number; + note?: string; + source?: BillingPriceSource; + parameterDimensions?: BillingParameterDimension[]; + createdAt: string; + updatedAt: string; +}; + +export type BillingQuote = { + priceRuleId: string; + provider: GenerationProvider; + capability: GenerationCapability; + reqKey: string; + variantKey?: string; + unit: BillingUnit; + quantity: number; + standardUnitPriceFen: number; + markupMultiplier: number; + amountFen: number; + currency: BillingCurrency; + conditions?: BillingRuleConditions; + quantitySource?: BillingQuantitySource; + parameters?: BillingParameterSnapshot; + baseStandardUnitPriceFen?: number; + parameterTiers?: BillingSelectedParameterTier[]; + source?: BillingPriceSource; + quotaExempt?: boolean; +}; + +export type BillingJobCharge = BillingQuote & { + status: "not_charged" | "pending" | "charged" | "refunded"; + reservedAmountFen?: number; + settlementStatus?: "pending" | "settled" | "estimated"; + settlementLedgerEntryId?: string; + settledAt?: string; + settlementReason?: string; + providerUsage?: { + completionTokens: number; + resolution: string; + inputVideo: boolean; + tokenPriceFenPerMillion: number; + }; + ledgerEntryId?: string; + refundLedgerEntryId?: string; + chargedAt?: string; + refundedAt?: string; + refundReason?: string; +}; + +export type OrganizationWallet = { + organizationId: string; + balanceFen: number; + totalRechargedFen: number; + totalChargedFen: number; + updatedAt: string; +}; + +export type BillingLedgerKind = "recharge" | "charge" | "refund" | "adjustment"; + +export type BillingLedgerEntry = { + id: string; + organizationId: string; + accountId?: string; + jobId?: string; + kind: BillingLedgerKind; + deltaFen: number; + balanceAfterFen: number; + currency: BillingCurrency; + idempotencyKey: string; + description: string; + metadata: Record; + createdAt: string; +}; + +export type BillingAccountConfig = { + accountName?: string; + bankName?: string; + accountNumber?: string; + contact?: string; +}; + +export type UsageSource = "platform" | "api"; + +export type PlatformRole = "super_admin" | "organization_admin" | "user"; + +export type AccountStatus = "active" | "disabled"; + +export type OrganizationStatus = "active" | "disabled"; + +export type PlatformOrganization = { + id: string; + name: string; + status: OrganizationStatus; + archiveOwnerId: string; + createdAt: string; + updatedAt: string; +}; + +export type PlatformUserRecord = { + id: string; + phone: string; + displayName: string; + role: PlatformRole; + organizationId?: string; + status: AccountStatus; + passwordHash: string; + passwordSalt: string; + failedLoginCount: number; + lockedUntil?: string; + sessionVersion: number; + lastLoginAt?: string; + legacySubject?: string; + createdAt: string; + updatedAt: string; +}; + +export type AccountMigration = { + id: string; + legacyOwnerId: string; + legacyPhone?: string; + platformUserId: string; + createdAt: string; +}; + +export type UsageContext = { + source: UsageSource; + accountId: string; + username?: string; + displayName: string; + role?: PlatformRole; + tenantId?: string; + organizationId?: string; + organizationName?: string; +}; + export type AssetKind = "image" | "video" | "mask" | "reference" | "other"; export type GenerationStatus = @@ -46,7 +252,7 @@ export type GenerationJob = { ownerId: string; externalClientId?: string; capability: GenerationCapability; - provider: "volcengine-visual" | "evolink" | "seedance" | "bailian" | "mock"; + provider: GenerationProvider; reqKey: string; status: GenerationStatus; prompt?: string; @@ -75,6 +281,8 @@ export type GenerationJob = { webhookUrl?: string; webhookAttempts?: number; webhookLastStatus?: WebhookLastStatus; + usageContext?: UsageContext; + billing?: BillingJobCharge; createdAt: string; updatedAt: string; }; @@ -83,9 +291,19 @@ export type UsageEvent = { id: string; ownerId: string; jobId: string; + source?: UsageSource; capability: GenerationCapability; + provider?: GenerationProvider; + reqKey?: string; + accountUsername?: string; + accountDisplayName?: string; + tenantId?: string; + organizationId?: string; + organizationName?: string; quantity: number; - estimatedUnit: "image" | "job"; + estimatedUnit: "image" | "job" | "video_second"; + chargedAmountFen?: number; + currency?: BillingCurrency; createdAt: string; }; diff --git a/lib/usage.ts b/lib/usage.ts new file mode 100644 index 0000000..3299e8d --- /dev/null +++ b/lib/usage.ts @@ -0,0 +1,180 @@ +import type { GenerationCapability, GenerationProvider } from "@/lib/types"; + +export const USAGE_TIME_ZONE = "Asia/Shanghai"; +export const UNASSIGNED_ORGANIZATION_ID = "__unassigned__"; + +export type UsagePreset = "today" | "7d" | "30d" | "month"; + +export type UsageDateRange = { + from: string; + to: string; + startDate: string; + endDate: string; + label: string; + dayCount: number; +}; + +export type UsageCountItem = { + key: string; + label: string; + count: number; +}; + +export type UsageRecordView = { + id: string; + jobId: string; + ownerId: string; + accountName: string; + accountUsername?: string; + organizationId: string; + organizationName: string; + capability: GenerationCapability; + capabilityLabel: string; + provider?: GenerationProvider; + providerLabel: string; + reqKey?: string; + createdAt: string; +}; + +export type PersonalUsageReport = { + preset: UsagePreset; + range: UsageDateRange; + total: number; + byCapability: UsageCountItem[]; + recent: UsageRecordView[]; +}; + +export type UsageTrendPoint = { + date: string; + label: string; + count: number; +}; + +export type UsageOrganizationRow = { + organizationId: string; + organizationName: string; + count: number; + accountCount: number; + lastUsedAt?: string; +}; + +export type UsageAccountRow = { + ownerId: string; + accountName: string; + accountUsername?: string; + organizationId: string; + organizationName: string; + count: number; + lastUsedAt?: string; +}; + +export type UsageFilterOption = { + value: string; + label: string; +}; + +export type AdminUsageReport = { + range: UsageDateRange; + summary: { + total: number; + activeAccounts: number; + activeOrganizations: number; + averagePerDay: number; + }; + trend: UsageTrendPoint[]; + byCapability: UsageCountItem[]; + byProvider: UsageCountItem[]; + organizations: UsageOrganizationRow[]; + accounts: UsageAccountRow[]; + recent: UsageRecordView[]; + options: { + organizations: UsageFilterOption[]; + accounts: UsageFilterOption[]; + capabilities: UsageFilterOption[]; + providers: UsageFilterOption[]; + }; + warnings?: string[]; +}; + +export const USAGE_PRESET_OPTIONS: Array<{ value: UsagePreset; label: string }> = [ + { value: "today", label: "今天" }, + { value: "7d", label: "近 7 天" }, + { value: "30d", label: "近 30 天" }, + { value: "month", label: "本月" } +]; + +export const CAPABILITY_OPTIONS: Array<{ value: GenerationCapability; label: string }> = [ + { value: "image.generate", label: "图片生成" }, + { value: "video.generate", label: "视频生成" } +]; + +export function capabilityLabel(capability: GenerationCapability): string { + return CAPABILITY_OPTIONS.find((item) => item.value === capability)?.label || capability; +} + +export function providerLabel(provider?: GenerationProvider): string { + if (provider === "volcengine-visual") return "即梦图片"; + if (provider === "evolink") return "EvoLink"; + if (provider === "seedance") return "Seedance"; + if (provider === "bailian") return "阿里云百炼"; + if (provider === "mock") return "系统"; + return "未知服务商"; +} + +export function usagePresetRange(preset: UsagePreset, now = new Date()): UsageDateRange { + const today = dateKeyInChina(now); + let startDate = today; + if (preset === "7d") startDate = shiftDateKey(today, -6); + if (preset === "30d") startDate = shiftDateKey(today, -29); + if (preset === "month") startDate = `${today.slice(0, 7)}-01`; + return usageDateRange(startDate, today, USAGE_PRESET_OPTIONS.find((item) => item.value === preset)?.label); +} + +export function usageDateRange(startDate: string, endDate: string, label?: string): UsageDateRange { + if (!isDateKey(startDate) || !isDateKey(endDate)) throw badDateRange("日期格式必须为 YYYY-MM-DD。"); + if (startDate > endDate) throw badDateRange("开始日期不能晚于结束日期。"); + const dayCount = daysBetween(startDate, endDate) + 1; + if (dayCount > 3660) throw badDateRange("单次查询最多支持 10 年。"); + return { + from: `${startDate}T00:00:00+08:00`, + to: `${shiftDateKey(endDate, 1)}T00:00:00+08:00`, + startDate, + endDate, + label: label || (startDate === endDate ? startDate : `${startDate} 至 ${endDate}`), + dayCount + }; +} + +export function usageDateKey(iso: string): string { + return dateKeyInChina(new Date(iso)); +} + +export function shiftDateKey(date: string, days: number): string { + const value = new Date(`${date}T00:00:00Z`); + value.setUTCDate(value.getUTCDate() + days); + return value.toISOString().slice(0, 10); +} + +function dateKeyInChina(date: Date): string { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: USAGE_TIME_ZONE, + year: "numeric", + month: "2-digit", + day: "2-digit" + }).formatToParts(date); + const values = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${values.year}-${values.month}-${values.day}`; +} + +function isDateKey(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + return new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value; +} + +function daysBetween(startDate: string, endDate: string): number { + return Math.round((Date.parse(`${endDate}T00:00:00Z`) - Date.parse(`${startDate}T00:00:00Z`)) / 86_400_000); +} + +function badDateRange(message: string): Error & { status: number } { + return Object.assign(new Error(message), { status: 400 }); +} diff --git a/lib/video-settings.ts b/lib/video-settings.ts index 6cfd2da..48497c5 100644 --- a/lib/video-settings.ts +++ b/lib/video-settings.ts @@ -11,7 +11,7 @@ export const VIDEO_RATIO_DEFAULT = "9:16"; export const VIDEO_RATIOS = ["9:16", "16:9", "1:1", "4:3", "3:4", "21:9", "adaptive"] as const; export const VIDEO_RESOLUTION_DEFAULT = "720p"; -export const VIDEO_RESOLUTIONS = ["720p", "1080p", "480p"] as const; +export const VIDEO_RESOLUTIONS = ["720p", "1080p", "480p", "4k"] as const; export const VIDEO_FAST_RESOLUTIONS = ["720p", "480p"] as const; type VideoDurationOptions = { diff --git a/middleware.ts b/middleware.ts index 588cffe..5e4b194 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,6 +1,6 @@ import { NextResponse, type NextRequest } from "next/server"; import { SESSION_COOKIE_NAME, getAuthRuntimeConfig, safeNextPath } from "@/lib/auth/config"; -import { hasAdminAccess } from "@/lib/auth/permissions"; +import { hasAdminSessionAccess, hasSuperAdminAccess } from "@/lib/auth/permissions"; import { parseSessionCookieValue, readChunkedCookieValue } from "@/lib/auth/session"; export async function middleware(request: NextRequest) { @@ -14,11 +14,17 @@ export async function middleware(request: NextRequest) { config.sessionSecret ); if (session) { - if (isAdminPath(pathname) && !hasAdminAccess(session.user)) { + const access = requiredAccess(pathname); + const allowed = access === "super" + ? hasSuperAdminAccess(session.user) + : access === "admin" + ? hasAdminSessionAccess(session) + : true; + if (!allowed) { if (pathname.startsWith("/api/")) { - return NextResponse.json({ error: "需要管理员权限。" }, { status: 403 }); + return NextResponse.json({ error: access === "super" ? "需要超级管理员权限。" : "需要管理员权限。" }, { status: 403 }); } - return NextResponse.redirect(new URL("/create", request.url)); + return NextResponse.redirect(new URL(access === "super" ? "/create" : "/create", request.url)); } return NextResponse.next(); } @@ -44,11 +50,15 @@ export const config = { "/logs/:path*", "/settings/:path*", "/accounts/:path*", + "/usage/:path*", + "/billing/:path*", "/image-edit/:path*", "/uploads/:path*", "/generated-results/:path*", "/api/assets/:path*", "/api/generations/:path*", + "/api/usage/:path*", + "/api/billing/:path*", "/api/logs/:path*", "/api/prompt/:path*", "/api/settings/:path*", @@ -56,11 +66,8 @@ export const config = { ] }; -function isAdminPath(pathname: string): boolean { - return pathname.startsWith("/logs") || - pathname.startsWith("/settings") || - pathname.startsWith("/accounts") || - pathname.startsWith("/api/logs") || - pathname.startsWith("/api/settings") || - pathname.startsWith("/api/admin"); +function requiredAccess(pathname: string): "super" | "admin" | null { + if (pathname.startsWith("/logs") || pathname.startsWith("/api/logs") || pathname.startsWith("/api/settings")) return "super"; + if (pathname.startsWith("/usage") || pathname.startsWith("/api/admin")) return "admin"; + return null; } diff --git a/next.config.ts b/next.config.ts index 7e136c0..25aab3b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,9 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + // Keep dev artifacts separate from production builds. Running `next build` + // while a dev server is alive must not invalidate the dev server's chunks. + distDir: process.env.NODE_ENV === "development" ? ".next-dev" : ".next", devIndicators: false, experimental: { serverActions: { diff --git a/package.json b/package.json index efaac90..dbaa49a 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "productName": "智念AIGC平台", - "description": "Minimal Web MVP for Jimeng image generation, image editing, upscaling, and video generation.", + "description": "Enterprise Web platform for image and video generation with organization billing.", "scripts": { "dev": "next dev", "build": "next build", @@ -15,6 +15,8 @@ "worker:once": "node scripts/worker.mjs --once", "health": "node scripts/health-check.mjs", "info": "node scripts/print-app-info.mjs", + "bootstrap:admin": "node scripts/bootstrap-admin.mjs", + "migrate:accounts": "node scripts/import-legacy-accounts.mjs", "test": "vitest run", "test:watch": "vitest" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..e26b897 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2244 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@supabase/supabase-js': + specifier: ^2.49.4 + version: 2.112.2 + ali-oss: + specifier: ^6.23.0 + version: 6.23.0 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + graceful-fs: + specifier: ^4.2.11 + version: 4.2.11 + gsap: + specifier: ^3.15.0 + version: 3.15.0 + lucide-react: + specifier: ^0.468.0 + version: 0.468.0(react@19.2.8) + next: + specifier: ^15.1.4 + version: 15.5.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: + specifier: ^19.0.0 + version: 19.2.8 + react-dom: + specifier: ^19.0.0 + version: 19.2.8(react@19.2.8) + zod: + specifier: ^3.24.1 + version: 3.25.76 + devDependencies: + '@testing-library/jest-dom': + specifier: ^6.6.3 + 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) + '@types/node': + specifier: ^22.10.5 + version: 22.20.1 + '@types/react': + specifier: ^19.0.4 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.0.2 + version: 19.2.4(@types/react@19.2.18) + typescript: + specifier: ^5.7.2 + 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)) + +packages: + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + 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==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@next/env@15.5.23': + resolution: {integrity: sha512-Mv3Z9hVbFcPnoLevsZ6rnX1TBtyHb5E17yN7HTPDXSXxeNsGBjUFrdbjRXKKXIOhfth7/cg6Ay7PZ2UFawaWsQ==} + + '@next/swc-darwin-arm64@15.5.23': + resolution: {integrity: sha512-SrEwOROH/rhA03F59hHtdhgtfZMWGzr5duDBWgRQt2rS3mJhqMKOcnNx6txOd0/i3E3D3uFKYFvyHsEiwQxzag==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@15.5.23': + resolution: {integrity: sha512-f0FpFbG2EhDCuptBGcfrLcYMDuQAhe6m1QA4VVfXFrIBoFXvXt/olGbBkYkloKlXQtmhuzvtdYyuu/6zf07GIg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@15.5.23': + resolution: {integrity: sha512-WlNtfepUXKX2u2ZsJZ8c3c8+tJSRZqsYzoMwLOY72A8ucKCCgxgNhiePA3qzFYahVWrwcQd8jOeJmBinc+VFVQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-musl@15.5.23': + resolution: {integrity: sha512-W/6qKk7UG93mg14PmQC+2urt69MIdwTBLNQ6MJyeC4wOCIHCjz+VfgssvS1pK7mgYBtLC1g6VKNoHD9xB0WWGg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/swc-linux-x64-gnu@15.5.23': + resolution: {integrity: sha512-vzefI32mi6VMk96RaTAyxApgfGbiFzQBXVsekEjsDv1fr48mlABTWx0sUYhaYCBHWqCalxmz3DxbxFcbFvzNtw==} + 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==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/swc-win32-arm64-msvc@15.5.23': + resolution: {integrity: sha512-Wc29KFOdT7XBcII3Vtmw7aoU8Uk3Mes/FNJfhFeSHdYBFJWMcR/DsI8U9BCPUhq/uycsUVuqSKGthW15tLsigA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@15.5.23': + resolution: {integrity: sha512-/C7wRW4fa9s/PKA18zGPPpVmx8ycgVpP8yOxro4gzGTzjPJdscbAP3ODeFvgiIovxD176Z2J/SXO9t8PJKHLeQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.143.0': + resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} + + '@rolldown/binding-android-arm64@1.2.3': + resolution: {integrity: sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.3': + resolution: {integrity: sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.3': + resolution: {integrity: sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.3': + resolution: {integrity: sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.3': + resolution: {integrity: sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.3': + resolution: {integrity: sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==} + 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==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.3': + resolution: {integrity: sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.3': + resolution: {integrity: sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@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==} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.10.0': + resolution: {integrity: sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + deprecated: Incorrect minor release with breaking changes (Node >=22 and required @testing-library/dom peer). Use 6.9.1 for the 6.x line, or upgrade to 7.0.0. + peerDependencies: + '@testing-library/dom': '>=10 <11' + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@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==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + address@1.2.2: + resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==} + engines: {node: '>= 10.0.0'} + + agentkeepalive@3.5.3: + resolution: {integrity: sha512-yqXL+k5rr8+ZRpOAntkaaRgWgE5o8ESAj5DyRmVTCSoZxXmqemb9Dd7T4i5UzwuERdLAJUy6XzR9zFVuf0kzkw==} + engines: {node: '>= 4.0.0'} + + ali-oss@6.23.0: + resolution: {integrity: sha512-FipRmyd16Pr/tEey/YaaQ/24Pc3HEpLM9S1DRakEuXlSLXNIJnu1oJtHM53eVYpvW3dXapSjrip3xylZUTIZVQ==} + engines: {node: '>=8'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + bowser@1.9.4: + resolution: {integrity: sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==} + + builtin-status-codes@3.0.0: + resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001809: + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + copy-to@2.0.1: + resolution: {integrity: sha512-3DdaFaU/Zf1AnpLiFDeNCD4TOWe3Zl2RZaTzUvWiIk5ERzcCodOE20Vqq4fzCbNoHURFHT4/us/Lfq+S2zyY4w==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dateformat@2.2.0: + resolution: {integrity: sha512-GODcnWq3YGoTnygPfi02ygEiRxqUxpJwuRHjdhJYuxpcZmDq4rjBiXYmbCCzStxo176ixfLT6i4NPwQooRySnw==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + default-user-agent@1.0.0: + resolution: {integrity: sha512-bDF7bg6OSNcSwFWPu4zYKpVkJZQYVrAANMYB8bc9Szem1D0yKdm4sa/rOCs2aC9+2GMqQ7KnwtZRvDhmLF0dXw==} + engines: {node: '>= 0.10.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + digest-header@1.1.0: + resolution: {integrity: sha512-glXVh42vz40yZb9Cq2oMOt70FIoWiv+vxNvdKdU8CwjLad25qHM3trLxhl9bVjdr6WaslIXhWpn0NO8T/67Qjg==} + engines: {node: '>= 8.0.0'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + end-or-error@1.0.1: + resolution: {integrity: sha512-OclLMSug+k2A0JKuf494im25ANRBVW8qsjmwbgX7lQ8P82H21PQ1PWkoYwb9y5yMBS69BPlwtzdIFClo3+7kOQ==} + engines: {node: '>= 0.11.14'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + formstream@1.5.2: + resolution: {integrity: sha512-NASf0lgxC1AyKNXQIrXTEYkiX99LhCEXTkiGObXAkpBui86a4u8FjH1o2bGb3PpqI3kafC+yw4zWeK6l6VHTgg==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-ready@1.0.0: + resolution: {integrity: sha512-mFXCZPJIlcYcth+N8267+mghfYN9h3EhsDa6JSnbA3Wrhh/XFpuowviFcsDeYZtKspQyWyJqfs4O6P8CHeTwzw==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + gsap@3.15.0: + resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + 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'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-class-hotfix@0.0.6: + resolution: {integrity: sha512-0n+pzCC6ICtVr/WXnN2f03TK/3BfXY7me4cjCAqT8TYXEl0+JBRoqBo94JJHXcyDSLUeWbNX8Fvy5g5RJdAstQ==} + + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + + is-type-of@1.4.0: + resolution: {integrity: sha512-EddYllaovi5ysMLMEN7yzHEKh8A850cZ7pykrY1aNRQGn/CDjRDE9qEWbIdt7xGEVJmjBXzU/fNnC4ABTm8tEQ==} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isstream@0.1.2: + resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + + js-base64@2.6.4: + resolution: {integrity: sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jstoxml@2.2.9: + resolution: {integrity: sha512-OYWlK0j+roh+eyaMROlNbS5cd5R25Y+IUpdl7cNdB8HNrkgwQzIS7L9MegxOiWNBj9dQhA/yAxiMwCC5mwNoBw==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + 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==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + lucide-react@0.468.0: + resolution: {integrity: sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + next@15.5.23: + resolution: {integrity: sha512-Gvd2WKgvxIXCGotxcI1im/Uf3rS3J3oZGw0g/uskg6AVBZhyE3aAbujkYWzS3xLmEPEtTLfkaVQUKK0KMTSIkA==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + node-hex@1.0.1: + resolution: {integrity: sha512-iwpZdvW6Umz12ICmu9IYPRxg0tOLGmU3Tq2tKetejCj3oZd7b2nUXwP3a7QA5M9glWy8wlPS1G3RwM/CdsUbdQ==} + engines: {node: '>=8.0.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + os-name@1.0.3: + resolution: {integrity: sha512-f5estLO2KN8vgtTRaILIgEGBoBrMnZ3JQ7W9TMZCnOIGwHe8TRGSpcagnWDo+Dfhd/z08k9Xe75hvciJJ8Qaew==} + engines: {node: '>=0.10.0'} + hasBin: true + + osx-release@1.1.0: + resolution: {integrity: sha512-ixCMMwnVxyHFQLQnINhmIpWqXIfS2YOXchwQrk+OFzmo6nDjQ0E4KXAyyUh0T0MZgV4bUhkRrAbVqlE4yLVq4A==} + engines: {node: '>=0.10.0'} + hasBin: true + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pause-stream@0.0.11: + resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + 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} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + 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==} + engines: {node: '>=0.10.0'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + rolldown@1.2.3: + resolution: {integrity: sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + sdk-base@2.0.1: + resolution: {integrity: sha512-eeG26wRwhtwYuKGCDM3LixCaxY27Pa/5lK4rLKhQa7HBjJ3U3Y+f81MMZQRsDw/8SC2Dao/83yJTXJ8aULuN8Q==} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + stream-http@2.8.2: + resolution: {integrity: sha512-QllfrBhqF1DPcz46WxKTs6Mz1Bpc+8Qm6vbqOpVav5odAXwbyzwnEczoWqtxrsmlO+cJqtPrp/8gWKWjaKLLlA==} + + stream-wormhole@1.1.0: + resolution: {integrity: sha512-gHFfL3px0Kctd6Po0M8TzEvt3De/xu6cnRrjlfYNhwbhLPLwigI2t1nc6jrzNuaYg5C4YF78PPFuQPzRiqn9ew==} + engines: {node: '>=4.0.0'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + to-arraybuffer@1.0.1: + resolution: {integrity: sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unescape@1.0.1: + resolution: {integrity: sha512-O0+af1Gs50lyH1nUu3ZyYS1cRh01Q/kUKatTOkSs7jukXE6/NebucDVxyiDsA9AQ4JC1V1jUH9EO8JX2nMDgGQ==} + engines: {node: '>=0.10.0'} + + urllib@2.44.1: + resolution: {integrity: sha512-vreOVvFizoiIz5NK9IYMgUknkriHHBVccn2VFfJhgKz6O2qwm0SgjFk4OpXFRDXpdrTx8EzM1DB0/pejrqXwPA==} + engines: {node: '>= 0.10.0'} + peerDependencies: + proxy-agent: ^5.0.0 + peerDependenciesMeta: + proxy-agent: + optional: true + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utility@1.18.0: + resolution: {integrity: sha512-PYxZDA+6QtvRvm//++aGdmKG/cI07jNwbROz0Ql+VzFV1+Z0Dy55NI4zZ7RHc9KKpBePNFwoErqIuqQv/cjiTA==} + engines: {node: '>= 0.12.0'} + + vite@8.2.1: + resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + 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 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + win-release@1.1.1: + resolution: {integrity: sha512-iCRnKVvGxOQdsKhcQId2PXV1vV3J/sDPXKA4Oe9+Eti2nb2ESEsYHRYls/UjoUW3bIc5ZDO8dTH50A/5iVN+bw==} + engines: {node: '>=0.10.0'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@adobe/css-tools@4.5.0': {} + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/runtime@7.29.7': {} + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@next/env@15.5.23': {} + + '@next/swc-darwin-arm64@15.5.23': + optional: true + + '@next/swc-darwin-x64@15.5.23': + optional: true + + '@next/swc-linux-arm64-gnu@15.5.23': + optional: true + + '@next/swc-linux-arm64-musl@15.5.23': + optional: true + + '@next/swc-linux-x64-gnu@15.5.23': + optional: true + + '@next/swc-linux-x64-musl@15.5.23': + optional: true + + '@next/swc-win32-arm64-msvc@15.5.23': + optional: true + + '@next/swc-win32-x64-msvc@15.5.23': + optional: true + + '@oxc-project/types@0.143.0': {} + + '@rolldown/binding-android-arm64@1.2.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.3': + optional: true + + '@rolldown/binding-darwin-x64@1.2.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.3': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@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 + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.10.0(@testing-library/dom@10.4.1)': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + 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)': + 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) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.2.4(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@22.20.1))': + dependencies: + '@vitest/spy': 4.1.10 + 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': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + address@1.2.2: {} + + agentkeepalive@3.5.3: + dependencies: + humanize-ms: 1.2.1 + + ali-oss@6.23.0: + dependencies: + address: 1.2.2 + agentkeepalive: 3.5.3 + bowser: 1.9.4 + copy-to: 2.0.1 + dateformat: 2.2.0 + debug: 4.4.3 + destroy: 1.2.0 + end-or-error: 1.0.1 + get-ready: 1.0.0 + humanize-ms: 1.2.1 + is-type-of: 1.4.0 + js-base64: 2.6.4 + jstoxml: 2.2.9 + lodash: 4.18.1 + merge-descriptors: 1.0.3 + mime: 2.6.0 + platform: 1.3.6 + pump: 3.0.4 + qs: 6.15.3 + sdk-base: 2.0.1 + stream-http: 2.8.2 + stream-wormhole: 1.1.0 + urllib: 2.44.1 + utility: 1.18.0 + xml2js: 0.6.2 + transitivePeerDependencies: + - proxy-agent + - supports-color + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + any-promise@1.3.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + bowser@1.9.4: {} + + builtin-status-codes@3.0.0: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + caniuse-lite@1.0.30001809: {} + + chai@6.2.2: {} + + client-only@0.0.1: {} + + clsx@2.1.1: {} + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + copy-to@2.0.1: {} + + core-util-is@1.0.3: {} + + css.escape@1.5.1: {} + + csstype@3.2.3: {} + + dateformat@2.2.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + default-user-agent@1.0.0: + dependencies: + os-name: 1.0.3 + + dequal@2.0.3: {} + + destroy@1.2.0: {} + + detect-libc@2.1.2: {} + + digest-header@1.1.0: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + end-or-error@1.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.1: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + escape-html@1.0.3: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + formstream@1.5.2: + dependencies: + destroy: 1.2.0 + mime: 2.6.0 + node-hex: 1.0.1 + pause-stream: 0.0.11 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-ready@1.0.0: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + gsap@3.15.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + + iceberg-js@0.8.1: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + indent-string@4.0.0: {} + + inherits@2.0.4: {} + + is-class-hotfix@0.0.6: {} + + is-extendable@0.1.1: {} + + is-type-of@1.4.0: + dependencies: + core-util-is: 1.0.3 + is-class-hotfix: 0.0.6 + isstream: 0.1.2 + + isarray@1.0.0: {} + + isstream@0.1.2: {} + + js-base64@2.6.4: {} + + js-tokens@4.0.0: {} + + jstoxml@2.2.9: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lodash@4.18.1: {} + + lucide-react@0.468.0(react@19.2.8): + dependencies: + react: 19.2.8 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + merge-descriptors@1.0.3: {} + + mime@2.6.0: {} + + min-indent@1.0.1: {} + + minimist@1.2.8: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.18: {} + + next@15.5.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@next/env': 15.5.23 + '@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) + 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 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + node-hex@1.0.1: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + obug@2.1.4: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + os-name@1.0.3: + dependencies: + osx-release: 1.1.0 + win-release: 1.1.1 + + osx-release@1.1.0: + dependencies: + minimist: 1.2.8 + + pathe@2.0.3: {} + + pause-stream@0.0.11: + dependencies: + through: 2.3.8 + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + platform@1.3.6: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + process-nextick-args@2.0.1: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-is@17.0.2: {} + + react@19.2.8: {} + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + rolldown@1.2.3: + dependencies: + '@oxc-project/types': 0.143.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.3 + '@rolldown/binding-darwin-arm64': 1.2.3 + '@rolldown/binding-darwin-x64': 1.2.3 + '@rolldown/binding-freebsd-x64': 1.2.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.3 + '@rolldown/binding-linux-arm64-gnu': 1.2.3 + '@rolldown/binding-linux-arm64-musl': 1.2.3 + '@rolldown/binding-linux-ppc64-gnu': 1.2.3 + '@rolldown/binding-linux-s390x-gnu': 1.2.3 + '@rolldown/binding-linux-x64-gnu': 1.2.3 + '@rolldown/binding-linux-x64-musl': 1.2.3 + '@rolldown/binding-openharmony-arm64': 1.2.3 + '@rolldown/binding-win32-arm64-msvc': 1.2.3 + '@rolldown/binding-win32-x64-msvc': 1.2.3 + + safe-buffer@5.1.2: {} + + safer-buffer@2.1.2: {} + + sax@1.6.1: {} + + scheduler@0.27.0: {} + + sdk-base@2.0.1: + dependencies: + get-ready: 1.0.0 + + semver@5.7.2: {} + + semver@7.8.5: + optional: true + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + statuses@1.5.0: {} + + std-env@4.2.0: {} + + stream-http@2.8.2: + dependencies: + builtin-status-codes: 3.0.0 + inherits: 2.0.4 + readable-stream: 2.3.8 + to-arraybuffer: 1.0.1 + xtend: 4.0.2 + + stream-wormhole@1.1.0: {} + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + styled-jsx@5.1.6(react@19.2.8): + dependencies: + client-only: 0.0.1 + react: 19.2.8 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + through@2.3.8: {} + + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + + to-arraybuffer@1.0.1: {} + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + unescape@1.0.1: + dependencies: + extend-shallow: 2.0.1 + + urllib@2.44.1: + dependencies: + any-promise: 1.3.0 + content-type: 1.0.5 + default-user-agent: 1.0.0 + digest-header: 1.1.0 + ee-first: 1.1.1 + formstream: 1.5.2 + humanize-ms: 1.2.1 + iconv-lite: 0.6.3 + pump: 3.0.4 + qs: 6.15.3 + statuses: 1.5.0 + utility: 1.18.0 + + util-deprecate@1.0.2: {} + + utility@1.18.0: + dependencies: + copy-to: 2.0.1 + escape-html: 1.0.3 + mkdirp: 0.5.6 + mz: 2.7.0 + unescape: 1.0.1 + + vite@8.2.1(@types/node@22.20.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.2.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@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)): + 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 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.1(@types/node@22.20.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.1 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + win-release@1.1.1: + dependencies: + semver: 5.7.2 + + wrappy@1.0.2: {} + + xml2js@0.6.2: + dependencies: + sax: 1.6.1 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + + xtend@4.0.2: {} + + zod@3.25.76: {} diff --git a/progress.md b/progress.md index 1496a6d..c5f9d6b 100644 --- a/progress.md +++ b/progress.md @@ -1233,3 +1233,323 @@ - Stopped the dev server and cleared `.next` before running the production build. - `npm run build`: production build passed. - `git diff --check`: passed. + +## Session: 2026-08-11 - Platform-Owned Account System + +### Phase 51: Requirements Confirmed and Implementation Started +- **Status:** complete +- User-confirmed scope: + - Replace external OAuth2 account authentication with platform-owned phone/password accounts. + - Use Supabase/Postgres in production and local JSON fallback in development. + - Support `super_admin`, `organization_admin`, and `user` roles. + - Enforce one account per organization; no cross-organization membership or switching. + - Preserve legacy account/history through phone-based mapping; retain usage and archive assets/tasks on deletion. + - Keep admin-created accounts, unified login, immutable phone identifiers, administrator password initialization/reset, and user self-service password changes. + - Limit organization admins to their own ordinary-user management and aggregate usage; they cannot access logs/system settings or grant organization-admin roles. +- Actions taken: + - Read the current authentication, session, permission, account-management, data-store, and Supabase schema implementation. + - Read and activated the file-based planning workflow for this multi-phase change. + - Added this implementation phase and captured confirmed decisions/findings in the planning files. +- Next: + - For deployment, run the documented one-time super-admin bootstrap against the production Supabase database, then execute the operator-provided legacy account import file. + +### Phase 51: Implementation and Verification Complete +- **Status:** complete +- Implemented: + - Added platform organization/user/migration models to `lib/types.ts`, local JSON storage, and `supabase/schema.sql`. + - Replaced browser OAuth/password-grant login with normalized phone + scrypt password verification, signed/chunked HttpOnly platform sessions, account lockout after five failures, and IP rate limiting. + - Added role-aware access boundaries: super administrators control global logs/settings/organizations/accounts/usage; organization administrators are limited to ordinary users and their organization aggregate usage; ordinary users retain self-service creation and password changes. + - Added organization lifecycle APIs/UI, administrator-created account APIs/UI, reset-password flow, self-service password change, disable/delete behavior, archive reassignment, and immutable unique phone login identifiers. + - Preserved usage events during account deletion and added legacy import/bootstrap scripts with phone/legacy-owner mappings and history reassignment. + - Kept `/api/v1/*` and worker authentication separate from browser platform sessions. +- Verification: + - `tsc --noEmit`: passed. + - `vitest run`: passed, 22 test files / 80 tests. + - Bootstrap/import script syntax checks: passed. + - `next build`: passed. + - `git diff --check`: passed. + - HTTP smoke test on a temporary local account store: super-admin login and organization creation passed; organization-admin login passed; logs returned 403 for organization admin; ordinary-user self password change and relogin passed. + - Project is running on `http://127.0.0.1:3000`; `/api/health` and `/auth/login` both returned 200 after the final restart. + +## Session: 2026-08-11 - Enterprise Billing + +### Phase 53: Official Provider Price Catalog +- **Status:** complete +- Added an auto-seeded, editable base catalog for Bailian Wan 2.7, EvoLink GPT Image 2, Jimeng Seedream 4.6 reference pricing, and Volcengine Ark Seedance 2.0 resolution tiers. +- Standardized all ledger amounts in CNY fen. The final user charge is `ceil(base_fen × quantity × markup)`, with the default markup set to `1.5x`. +- Fixed EvoLink conversion at `1 USD = 7.20 CNY`; the GPT Image 2 medium/1K/no-reference estimate becomes ¥0.34 per image. +- Added `variantKey` resolution matching and source snapshots so later price edits do not change historical task charges. +- Added official source links and pricing-basis notes to the super-admin table and deployment/API documentation. +- Verification: `bun run test` passed with 23 files / 82 tests; `bun x tsc --noEmit`, `bun run build`, and `git diff --check` passed. + +### Phase 54: Billing Center Taste Redesign +- **Status:** complete +- Read the current super-admin billing screenshot and identified the main issue as a long, repetitive vertical stack that mixed organization balance, pricing operations, account setup, and recharge review at the same visual weight. +- Rebuilt `components/billing-manager.tsx` around a balance-first overview, a compact ledger, a dense but scannable price catalog, and role-specific action areas. Member recharge is separated from super-admin pricing and review actions; custom pricing creation is collapsed until needed. +- Added a restrained B2B visual system in `app/globals.css`: one green accent, dark balance hero, quiet surfaces, compact list rows, explicit form labels, focus states, loading skeletons, responsive breakpoints, and reduced-motion handling. +- Preserved existing API behavior and price-source links while improving hierarchy and mobile stacking. Browser smoke checks confirmed the super-admin page rendered at desktop and 390px viewport widths with no horizontal overflow. +- Verification: desktop/mobile screenshots, `bun x tsc --noEmit`, `bun run test` (23 files / 82 tests), `bun run build`, and `git diff --check` all passed. + +### Phase 55: Tabbed Billing Operations +- **Status:** complete +- Replaced the continuous super-admin/member billing stack with task tabs. Super admins now switch between 概览、价格与计费、余额管理、充值审核、收款设置; members switch between 概览、线下充值、账务流水. +- Added `/api/admin/billing/account` so super admins can configure account name, bank, account number, and recharge contact directly in the billing center. It reuses the existing settings persistence path and updates the member-facing account immediately. +- Added `/api/admin/billing/adjustments` for super-admin manual credit/debit entries. The organization wallet remains the only balance; an optional member selection only attributes the immutable ledger entry and does not create a personal wallet. +- Extended `/api/admin/billing` with organization members and recent ledger data. The balance tab now shows per-member net consumption and a one-click member attribution action for the adjustment form. +- Verification: browser tab and account-editor smoke checks, 390px mobile screenshot with no horizontal overflow, `/api/admin/billing` payload smoke, `bun run test` (23 files / 83 tests), `bun x tsc --noEmit`, `bun run build`, and `git diff --check` passed. + +### Phase 56: Billing Error Recovery +- **Status:** complete +- Local `GET /billing`, `/api/billing`, `/api/admin/billing`, and `/api/settings` all returned 200 under the local fallback runtime; no new billing 500 was present in `.runtime/logs/server-events.jsonl`. +- Added actionable detection for missing or outdated Supabase billing tables/columns, safe parsing for non-JSON proxy errors, and a billing route error boundary with migration guidance. +- Verification: `bun x tsc --noEmit`, `bun run test` (23 files / 83 tests), `bun run build`, production API smoke (all 200), production browser tab smoke, and `git diff --check` passed. + +### Phase 57: Super-Admin Billing Blank State +- **Status:** complete +- Reproduced the screenshot state: an unbound super-admin received 422 from the member-oriented `/api/billing`, while `/api/admin/billing` returned 200; the component then had no `billing` payload and hid every content section. +- Changed super-admin loading to use the platform admin payload directly and synthesize an all-organization wallet/ledger summary. Member loading still uses the organization-scoped `/api/billing` route. +- Verification: `bun x tsc --noEmit`, `bun run test` (23 files / 83 tests), `bun run build`, and production browser smoke confirmed the five super-admin tabs and overview render. + +### Phase 58: Parameterized Billing Rules +- **Status:** in_progress +- User confirmed the structured rule-matrix approach: normalize user-selected parameters, match the most specific server-side rule, expose a quote preview, and reject real jobs without a matching rule. +- Current code only derives `resolution` as a variant and derives quantity from `duration`/`n`; the new phase will cover quality, size, aspect ratio, reference-image count, and other provider request dimensions without trusting client-side prices. + +### Phase 58: Parameterized Billing Rules +- **Status:** complete +- Extended price rules with structured JSON conditions, explicit quantity sources (`request`, `image_count`, `duration`), and priorities. Existing `variantKey` resolution rules remain compatible and are translated into conditions at match time. +- Added server-side parameter normalization for model, resolution, size, aspect ratio, quality, duration, image count, reference-image count, scale, and audio flags. Matching now prefers exact `reqKey`, then the most-specific matching conditions, then priority; unresolved ties fail as an ambiguous configuration. +- Added `/api/billing/quote` and shared image/video preparation helpers so the preview and real submission use the same provider payload and billing matcher. Billing snapshots now include normalized parameters, conditions, quantity source, quantity, and final amount in the job quote and ledger metadata. +- Upgraded the super-admin price editor with condition fields for resolution, size, aspect ratio, quality, scale, reference-image count, duration, quantity source, and priority. The price table displays the configured scope; legacy rules remain editable. +- Verification: `bunx vitest run` passed with 23 files / 86 tests; `bunx tsc --noEmit`, Node-backed `next build` (32 static pages), `git diff --check`, local `/api/health` HTTP smoke (200), and unauthenticated quote-route smoke (401 as expected) passed. Browser smoke reached the login gate without a render/500 error. + +## Session: 2026-08-11 - Task Detail Modal and Result Directory Consolidation + +### Phase 59: Discovery and Decision Gate +- **Status:** complete +- Inspected the current `/assets` result page, create-page task module, generation job shape, image/video submission payloads, asset download route, and local generated-result storage. +- Confirmed that the visible result directory and the physical generated-result storage are separate concerns. +- Confirmed that task records already retain the prompt, input material IDs/URLs, normalized generation settings, provider/task metadata, billing state, errors, and output asset IDs needed for an in-place detail modal. +- User confirmed that the front-end result directory should be removed from the visible product flow, that the interaction should live in the task module, and that this is a desktop-only platform. + +### Phase 59: Task Detail Modal and Result Directory Consolidation +- **Status:** complete +- Removed the `结果` navigation entry and changed `/assets` into a compatibility redirect to `/create`; the asset APIs, download route, local uploads, and `.runtime/generated-results` storage remain available. +- Added task-card click and keyboard interaction with a desktop task detail modal covering task status, duration, prompt, input elements/material previews, normalized generation parameters, provider/request metadata, billing state, errors, and output previews. +- Added direct result download actions to completed task cards and per-output download links inside the modal. Card-level download/detail actions stop propagation so they do not reopen the modal. +- Updated product and deployment documentation to describe the task module as the single front-end location for task details and generated-result downloads. +- Verification: desktop browser smoke on `/create` confirmed the trimmed navigation, 17 task cards, prompt/material/parameter rendering, and download action; `bun run test` passed (23 files / 86 tests); `bun x tsc --noEmit`, `bun run build`, and `git diff --check` passed. +- Scope note: mobile layout work was intentionally excluded per the confirmed desktop-only product requirement. + +### Phase 60: Direct Billing Top-ups — Started +- Confirmed the new accounting rule: remove recharge review entirely; administrators post balance directly; future self-service payment success posts automatically. +- Located the active request/review chain in the billing manager, `/api/billing`, `/api/admin/billing`, `/api/admin/billing/recharges/[id]`, billing store/service, schema, docs, and tests. +- Confirmed the existing administrator adjustment endpoint can remain as the direct top-up implementation. +- Removed member recharge form/history and the super-admin review tab/metric from the billing manager; the overview now points to balance operations instead of pending requests. +- Removed recharge request listing/creation/review routes and active store/type mappings; direct credit adjustments now use `recharge` ledger entries through `postOrganizationTopUp`, while debits remain `adjustment` entries. +- Updated README/API/deployment documentation to describe direct administrator posting and future automatic posting after payment success. + +### Phase 60: Direct Billing Top-ups — Complete +- Removed the recharge request table from the active Supabase schema definition without dropping any existing deployed table; old data, if present, is no longer part of the application workflow. +- Renamed the super-admin operation to “余额与上账”, replaced the pending-review metric with organization cumulative charges, and kept corporate account settings as a future payment configuration surface. +- Verification passed: no stale request/review references, `bunx tsc --noEmit`, `bunx vitest run` (23 files / 86 tests), Node-backed `next build`, `git diff --check`, `/api/health` 200, unauthenticated admin billing 401, and billing page auth redirect 307. +- The local development server is running on `http://127.0.0.1:3000` after a clean restart. + +### Phase 61: Simplified Billing Price Controls — Started +- User clarified that provider standard prices and parameter tiers are platform-maintained; super administrators should only adjust the markup multiplier. +- Current UI/API surface is broader than the intended responsibility, so this phase will collapse it to a read-only catalog with multiplier-only maintenance. + +### Phase 61: Account Directory and Password Settings Consolidation — Started +- Read the `design-taste-frontend` skill and applied its audit-first redesign protocol to this data-heavy product surface rather than its marketing-page-only patterns. +- Audited the current account directory in the local desktop browser and found the password form only in `/settings`, admin-only `/accounts` access, and a cramped horizontal create-account row. +- Confirmed implementation direction: `/accounts` becomes authenticated-user accessible, self password change moves there for every role, admin organization/member APIs remain protected, and the page is rebuilt around a modern light minimalist desktop hierarchy. + +### Phase 61: Account Directory and Password Settings Consolidation — Complete +- Removed `AccountSecurityPanel` from `/settings` and mounted it in `/accounts`; the existing `/api/auth/password/change` contract and session refresh behavior remain unchanged. +- Made `/accounts` available to every authenticated user. Ordinary users see identity information and self-service password change; admin sessions additionally load the existing organization/member management APIs. +- Rebuilt the account page as a desktop-first two-column utility surface: personal security and member directory on the main column, identity, account creation, and organization controls in the rail. Added explicit status badges, avatars, skeleton loading, focus states, empty/error feedback, and restrained green accent styling. +- Updated navigation, middleware, README, Chinese README, and deployment notes to describe the new account access model. +- Verification: desktop browser smoke showed the password form and loaded member directory, settings no longer contained the password form, `bun run test` passed (23 files / 86 tests), `bun x tsc --noEmit`, `bun run build`, and `git diff --check` passed. +- Errors and resolutions: a hot-reload session held an obsolete client state and left the member skeleton visible; restarting the temporary dev server restored the expected list. The first production build emitted an autoprefixer warning for `align-items: end`; all new occurrences were changed to `flex-end`, and the final build completed without that warning. + +### Phase 62: Account Workspace Information Architecture Correction — Complete +- User feedback identified two concrete flaws in the first redesign: the current-user summary duplicated the “登录身份” card, and the account page was still fragmented into separate floating cards. +- Removed the duplicate account/profile panel and kept one current-user identity summary in the page header. +- Replaced the split main/rail composition with one continuous `account-workspace`, ordered as security settings, administrator organization/member management, and the member directory. Organization creation now sits beside account creation within the same administrator section. +- Desktop browser verification at 1280px confirmed one identity surface, zero profile-card duplicates, a single 1180px workspace, and `bodyScrollWidth === clientWidth`. + +### Phase 61: Simplified Billing Price Controls — Complete +- Reduced price maintenance to the intended product model: platform-owned standard cost and parameter catalog, with super-admin control limited to the markup multiplier. +- Replaced the multi-field rule editor with a compact read-only catalog showing service/parameters, standard cost, calculated customer price, multiplier, and a single `调整倍率` action. +- Made the admin price collection route read-only and restricted item updates to `markupMultiplier`; structural pricing fields are rejected server-side. +- Verification passed: `bunx tsc --noEmit`, `bunx vitest run` (23 files / 86 tests), Node-backed production build, `git diff --check`, browser legacy-form/overflow checks, and clean dev-server health (`/api/health` 200). + +### Phase 63: Parameterized Billing Catalog — Started +- User confirmed the recommended model: list parameter tiers under each service/model with platform-owned standard rates and multiplier controls; calculate the final quote from the selected parameter combination. +- The current implementation only has one generic EvoLink image rule based on medium / 1K / 1:1 / no reference image, so high quality currently falls through to the same price. The next implementation step is to add dimension-aware catalog data and matching without exposing structural editing in the admin UI. + +### Phase 63: Parameterized Billing Catalog — Complete +- Added platform-owned parameter dimensions and tiers to billing rules. EvoLink GPT Image 2 now lists quality, resolution, aspect-ratio, and reference-image tiers; video resolution variants are grouped under their service/model. +- Quote calculation now resolves every selected parameter, multiplies standard factors, applies one highest-selected markup multiplier, and snapshots the effective standard cost plus selected tiers into the task billing record. +- Added a multiplier-only tier PATCH path and a backfill-safe catalog seed update that preserves existing multiplier values while synchronizing platform standard metadata and parameter dimensions. +- Updated the billing center to show grouped service cards with child parameter rates, user prices, multipliers, source notes, and one `调整倍率` action per tier. No structural editor or custom rule form is exposed. +- Verification: `bunx tsc --noEmit`, `bunx vitest run` (23 files / 88 tests), `bun run build`, browser checks at 1280px (5 service cards / 4 EvoLink dimensions / 15 tiers / no horizontal overflow / no legacy form), `git diff --check`, and clean dev-server health. + +### Phase 64: Inline Generation Cost Estimate — Complete +- Applied the ui-ux-pro-max form guidance: keep labels associated with controls, provide immediate state feedback, and put the result summary at the decision point. +- Moved the generation quote from the top action bar into a compact `本次预估消耗` card beside the engine/parameter controls. The card shows the server-resolved amount, quantity, multiplier, and matched parameter tiers. +- Added explicit unavailable/loading states and cleared stale loading state when the prompt or required materials are removed. +- Added container-aware layout rules for the three-rail create workbench. At 1280px and 1440px the parameter controls and cost card align in one row; at narrower center columns they stack without horizontal overflow. +- Browser verification observed EvoLink medium/2K/1:1 at ¥2.04, high/2K/1:1 at ¥8.16, and Seedance 5 seconds at ¥7.43 in the local configured environment. + +### Phase 65: Billing UI Alignment and Native Multiplier Dialog — Complete +- Reworked the price-source metadata row so the source link, platform note, and service-card content share a predictable baseline and available width. +- Shortened the estimate card heading to `预估消耗` so the live amount, quantity, multiplier, and parameter summary remain aligned beside the controls. +- Replaced browser prompt editing with a native in-app multiplier modal that previews standard cost/current price/new price and validates `1–1000×` input. +- Verified parameter-driven quote refresh (`¥2.04` for the tested EvoLink Image2 configuration), Escape dismissal, `bunx vitest run` (23 files / 88 tests), `bunx tsc --noEmit`, `bun run build`, `git diff --check`, and `/api/health` HTTP 200 on the restarted port 3000 server. + +### Phase 66: Fixed EvoLink 1K Quote and User-Facing Estimate — Complete +- Fixed the EvoLink image payload to always request the platform-approved 1K resolution; removed the obsolete configurable resolution setting and synchronized the billing catalog default to 1K. +- Removed internal pricing metadata from the ordinary-user estimate card. It now contains only `本次预计消耗额度` and the amount/loading placeholder. +- Verified live values in the create page: standard quality `¥0.51`, high quality `¥2.04`; no `未开始`, automatic-calculation helper, platform multiplier, tier summary, or `2K` appeared in the user-facing page. +- Verification passed: `bunx vitest run` (23 files / 88 tests), `bunx tsc --noEmit`, `git diff --check`, and browser quote checks. + +### Phase 67: Unified Default Billing Multiplier — Complete +- Changed `DEFAULT_BILLING_MARKUP_MULTIPLIER` to `1.2` and applied it across all built-in image/video rules and parameter tiers. +- Synchronized `.runtime/data/billing-state.json` so the running local catalog no longer retains `1.5×` defaults. +- Updated billing docs and built-in quote assertions; intentional test-specific multiplier overrides remain explicit. + +## Session: 2026-08-12 - Seedance Native Usage Settlement + +### Phase 68: Cross-Provider Pricing Audit — Complete +- Reconciled Bailian image/video, EvoLink image, Jimeng reference, and Ark Seedance catalog entries against the supplied provider pricing sources. +- Confirmed the shared 1.20× platform multiplier and identified Seedance as the only active provider whose official final price depends on returned usage rather than only the submitted parameter set. +- Obtained confirmation to implement conservative submit-time reservation plus successful-task token reconciliation. + +### Phase 69: Seedance Native Usage Settlement — Complete +- Added official Seedance token rate mapping by resolution and input-video presence, with a formula-based conservative estimate for the initial reservation. +- Added provider usage extraction for both top-level and nested Seedance response shapes. +- Added idempotent final settlement: actual lower amount refunds the difference, actual higher amount charges the difference, and missing usage keeps the reserved amount. +- Added the 4K catalog/resolution variant and updated product/API documentation. +- Verification passed: `bunx vitest run` (24 files / 92 tests), `bunx tsc --noEmit`, `bun run build`, and `git diff --check`. + +### Phase 70: Unbound Account Quote Preview — Complete +- Traced the blank quote to the super-admin account having no organization binding; the quote request was incorrectly subject to the real-charge organization gate. +- Added a quote-only bypass for organization lookup while preserving the strict organization requirement during actual task submission and wallet charging. +- Added a regression test for the preview/charge boundary. +- Browser verification now shows `¥1.64` for Image2 / 9:16 / 精细. Full verification passed: Vitest 24 files / 93 tests, TypeScript, production build, local health check, and `git diff --check`. + +## Session: 2026-08-11 - Cross-Provider Pricing Audit + +### Phase 68: Cross-Provider Pricing Audit — In progress +- Audited `lib/server/billing-catalog.ts`, `lib/billing.ts`, `lib/server/billing-service.ts`, the provider payload builders, the runtime billing state, and current quote outputs. +- Confirmed Bailian image/video baselines and the one-time 1.20× markup behavior against the current official Alibaba model pages. +- Confirmed the Ark Seedance 2.0 sample values and identified the boundary: the official source is token-based and input-video dependent, while the current catalog is a no-input-video, 16:9, five-second reference estimate expressed per second. +- Confirmed the active UI forces Jimeng single-image output; its ¥0.20/image entry remains a documented reference baseline because the official API page does not publish a stable per-call price. +- No implementation changes made. Next step is to present the confirmed matches and the Seedance approximation decision to the user before changing the billing model. + +### Phase 52: Discovery and Decision Gate +- **Status:** complete +- Read and restored the existing planning files before starting the new multi-step phase. +- Inspected current types, generation services, data store, account roles, usage events/reports, routes, and package scripts. +- Confirmed current usage is analytics-only: one event per non-mock first-party job, with no wallet or monetary ledger. +- Confirmed organization identity and role boundaries are already available for billing integration. +- Paused implementation at the required decision gate: billing unit/settlement semantics must be confirmed before changing financial state. + +### Phase 52: Requirements Confirmed and Implementation Started +- **Status:** in_progress +- User confirmed the provider-native billing unit × super-admin markup model. +- User added the requirement to remove 高清/智能超清 (`image.upscale`) and 局部重绘 (`image.inpaint`) capabilities. +- Current removal targets and billing integration points are recorded in `findings.md`; implementation proceeds from the existing account-system worktree without resetting unrelated changes. + +### Phase 52: Capability Removal and Billing Core +- **Status:** complete +- Removed `image.inpaint` and `image.upscale` from active capability types, provider matrices, generation UI, asset edit APIs, settings assignments, usage filters, public API validation/OpenAPI, tests, environment examples, and docs. +- Kept historical `edited`/`upscaled` asset source values readable and labeled as historical results. +- Added billing domain types, integer-fen pricing helpers, local billing store, Supabase billing schema/RPC, organization wallet operations, price rules, recharge requests, immutable ledger entries, and idempotent job charge/refund service. +- Integrated pre-charge into image/video submission and final-state refunds into Worker failure handling, public API cancellation, and task deletion. +- Added `/api/billing`, `/api/admin/billing`, price-rule CRUD, and recharge review endpoints. + +### Phase 52: Billing Integration and Verification +- **Status:** complete +- Added organization-scoped integer-fen wallets, idempotent charge/refund ledger entries, provider-native quantity pricing, super-admin markup snapshots, recharge requests, review flow, and optional corporate-account display/configuration. +- Real first-party image/video jobs quote and debit before provider dispatch; pending charges are recovered by the Worker after a process interruption; final failed/expired/cancelled jobs refund once, while successful deletion never refunds. +- Added `/billing` for members to view organization balance, personal/org consumption, ledger, and offline recharge requests; super admins manage price rules, organization wallets, recharge review, and corporate transfer account details. +- Removed active high-resolution/upscale and inpaint capabilities, routes, editor surface, settings assignments, public API definitions, and environment keys; the legacy `/image-edit` path now redirects to normal creation and historical asset source values remain readable. +- Verification passed: `bun run test` (23 files / 81 tests), `bun x tsc --noEmit`, `bun run build`, `bun run info`, local `/api/health` HTTP smoke, `/billing` auth redirect, and OpenAPI stale-capability scan. + +## Session: 2026-08-12 - Organization-Only Billing Top-ups + +### Phase 70: Organization-Only Billing Top-ups — In progress +- User confirmed that every top-up belongs to the organization; organization administrators and employees share the organization quota, with no personal top-up ownership. +- Located the remaining personal-attribution path in the admin adjustment route, billing adjustment form, member “归属上账” action, and local/Supabase wallet-entry serialization. +- Scope decision: preserve account IDs on generation charges/refunds for member consumption reporting; normalize new recharge/adjustment entries to organization-only and leave historical entries untouched. + +### Phase 70: Organization-Only Billing Top-ups — Complete +- Removed `accountId` from the direct organization top-up service contract and admin adjustment request path. +- Normalized local wallet entries and the Supabase `billing_post_wallet_entry` RPC so `recharge` and `adjustment` rows never receive a personal account ID; generation `charge`/`refund` rows retain actor attribution. +- Removed the billing-center member selector, “流水归属” field, and “归属上账” buttons. Member usage remains read-only and explicitly describes shared organization quota. +- Updated the ledger renderer, API/deployment docs, and README wording to distinguish organization balance ownership from member consumption reporting. +- Verification passed: focused billing tests 13/13, full Vitest 24 files / 92 tests, TypeScript, production build, and reviewed-file `git diff --check`. + +### Errors encountered +| Error | Attempt | Resolution | +| --- | --- | --- | +| `npm` was not available in the shell | First test command | Loaded the workspace runtime dependencies and used the bundled Node executable directly. | +| Bundled `pnpm test` stopped at ignored `sharp` build scripts | Second test command | Invoked Vitest and TypeScript directly through the installed workspace dependencies, avoiding an install step. | + +## Session: 2026-08-12 - Frontend Encoding Diagnosis + +### Phase 71: Frontend Encoding Diagnosis — Awaiting reproduction +- Checked source bytes, HTML/CSS response headers, and served HTML for common mojibake markers; all are valid UTF-8. +- Used `agent-browser` to render `/auth/login` and an isolated auth-disabled `/billing` preview; Chinese labels and the billing screenshot rendered normally. +- Found two existing Next dev-server processes associated with this workspace and port 3000, which may produce stale/mixed browser state. +- No code change was applied because the reported garbling cannot yet be reproduced. Next input needed: the affected page URL and a screenshot or the exact garbled text. + +## Session: 2026-08-12 - Autofilled Login Submission + +### Phase 72: Autofilled Login Submission — In progress +- User supplied a screenshot showing phone and password visibly filled while the login action could not be activated. +- Traced the issue to the submit button depending on React state, which is not guaranteed to update for browser/password-manager autofill. +- Updated `components/auth-login-panel.tsx` to use named required fields and native `FormData` values at submit time; updated the focused source regression test. +- Browser check confirmed filled credentials trigger `POST /api/auth/password`; final test/build verification remains. + +### Phase 72: Autofilled Login Submission — Complete +- Focused auth-panel tests passed: 2/2. +- Full Vitest suite passed: 24 files / 92 tests. +- TypeScript and production build passed. +- Browser check confirmed the filled login form is clickable and sends the password-login request. + +## Session: 2026-08-12 - Next Development Cache Recovery + +### Phase 73: Next Development Cache Recovery — Complete +- Confirmed `.next/server/webpack-runtime.js` referenced missing chunk `9971.js`. +- Stopped the two duplicate Next development processes associated with this workspace. +- Moved the corrupted `.next` directory to `.next.corrupt-20260812-1042` for recovery and started one clean dev server on `127.0.0.1:3000`. +- Verified `/create` compiles, `/auth/login` renders, `/api/health` responds, and clicking the login form sends `/api/auth/password` without the runtime overlay. + +### Phase 74: Dev/Production Cache Isolation — Complete +- Changed Next output selection so development uses `.next-dev` and production uses `.next`; added `.next-dev/` to `.gitignore`. +- Moved the partially generated `.next-dev` cache to `.next-dev.corrupt-20260812-1108` instead of deleting it, then restarted one clean server on port 3000. +- Verified `/auth/login` renders correctly, `/api/health` returns 200, `/create` redirects to `/auth/login` when unauthenticated, and the Lucide vendor chunk is regenerated in `.next-dev`. +- TypeScript and diff checks remain clean for the cache-isolation change. + +### Phase 75: Local Super-Admin Credential Recovery — Complete +- Confirmed the requested account is the local super administrator `13800138000`; the previous plaintext password was not recoverable from its server-side hash. +- Generated a new strong password and reset it through the running admin password API, so the old password is invalidated. +- Saved the new credential as `super-admin` in the project browser vault, then cleared the browser cookies and verified the saved profile logs in automatically to `/create` with super-admin navigation. + +## Session: 2026-08-12 - Quota Guard and Super-Admin Billing Exemption + +### Phase 76: Quota Guard and Super-Admin Billing Exemption — In progress +- User confirmed the recommended boundary: ordinary users are blocked by insufficient shared organization balance; super-admins calculate and record cost but do not consume organization quota. +- Inspected the existing quote, submission, worker, Seedance settlement, usage-record, and UI error paths. Real platform submissions already reserve before provider dispatch, and `InsufficientBalanceError` already maps to HTTP 402; implementation will preserve that boundary. +- Planned changes: add role-aware usage context, persist `quotaExempt` on billing snapshots, bypass wallet charge/refund for super-admin jobs, keep Seedance actual-cost settlement metadata, and expose the existing balance error in the create UI. + +### Phase 76: Quota Guard and Super-Admin Billing Exemption — Complete +- Added `role` to platform usage context and persisted it through generation jobs so the billing service can identify super-admin generation independently of organization binding. +- Added `quotaExempt` to quote/job billing snapshots. Super-admin quotes work without an organization; submission marks them as calculated-but-not-charged, skips wallet charge/refund entries, and Seedance still reconciles actual completion-token cost in the job snapshot. Successful usage events retain `chargedAmountFen`. +- Kept ordinary generation strict: wallet reservation runs before provider dispatch, and insufficient balance raises the existing HTTP 402 error with `余额不足,请先充值。`; the create UI surfaces that response and labels super-admin estimates/tasks as not counted against quota. +- 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. diff --git a/scripts/bootstrap-admin.mjs b/scripts/bootstrap-admin.mjs new file mode 100644 index 0000000..cbfb70c --- /dev/null +++ b/scripts/bootstrap-admin.mjs @@ -0,0 +1,150 @@ +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"; + +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 || "平台超级管理员"; + +if (!/^\+?[0-9]{6,20}$/.test(phone)) fail("请通过 --phone 或 ZHINIAN_BOOTSTRAP_ADMIN_PHONE 提供有效手机号。"); +if (password.length < 8) fail("请通过 --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})`); + } +} 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 (!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 parseArgs(values) { + const result = {}; + for (let index = 0; index < values.length; index += 1) { + const value = values[index]; + if (!value.startsWith("--")) continue; + result[value.slice(2)] = values[index + 1] && !values[index + 1].startsWith("--") ? values[++index] : "true"; + } + return result; +} + +async function readState(path) { + if (!existsSync(path)) 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/)) { + 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/import-legacy-accounts.mjs b/scripts/import-legacy-accounts.mjs new file mode 100644 index 0000000..b779fa0 --- /dev/null +++ b/scripts/import-legacy-accounts.mjs @@ -0,0 +1,226 @@ +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"; + +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); +} + +console.log(`已迁移 ${accounts.length} 个账号及其历史归属。`); + +async function migrateSupabase(accounts, organizations, supabase) { + 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); + } + 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); + } +} + +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 }); + const path = join(dataDirectory, "platform-accounts.json"); + const state = await readState(path); + 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 index = state.organizations.findIndex((item) => item.id === next.id); + 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 + }; + 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 + }); + 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 mappingIndex = state.migrations.findIndex((item) => item.legacyOwnerId === record.legacyOwnerId); + 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; + } +} + +function normalizeAccount(account) { + if (!account?.legacyOwnerId || !account?.phone || !account?.password || !account?.displayName) { + fail("每个账号必须提供 legacyOwnerId、phone、displayName 和 password。"); + } + const phone = String(account.phone).trim().replace(/[\s()-]/g, ""); + if (!/^\+?[0-9]{6,20}$/.test(phone)) fail(`手机号格式不正确:${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 + }; +} + +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 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 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: [] }; + } +} diff --git a/scripts/print-app-info.mjs b/scripts/print-app-info.mjs index 1b5cecf..1c52648 100644 --- a/scripts/print-app-info.mjs +++ b/scripts/print-app-info.mjs @@ -17,13 +17,11 @@ console.log(JSON.stringify({ '/', '/create', '/assets', - '/image-edit', + '/billing', '/settings' ], imageCapabilities: [ - 'image.generate', - 'image.inpaint', - 'image.upscale' + 'image.generate' ], videoCapabilities: [ 'video.generate' diff --git a/supabase/schema.sql b/supabase/schema.sql index 7ba45d6..55f8ea5 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -42,6 +42,7 @@ create table if not exists generation_jobs ( 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() ); @@ -60,17 +61,63 @@ 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 references generation_jobs(id) on delete cascade, + 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 'image', + 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; + +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; + +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, @@ -104,6 +151,9 @@ 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); +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( @@ -143,3 +193,188 @@ begin select * from updated; end; $$; + +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 unique, + description text not null, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +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 +as $$ +declare + v_existing billing_ledger%rowtype; + v_wallet billing_wallets%rowtype; + v_entry billing_ledger%rowtype; +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; + + select * into v_existing from billing_ledger where idempotency_key = p_idempotency_key; + if found then + select * into v_wallet from billing_wallets where organization_id = v_existing.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, + case when p_kind in ('recharge', 'adjustment') then null else p_account_id end, + 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; +$$; diff --git a/task_plan.md b/task_plan.md index 9867674..fd4e098 100644 --- a/task_plan.md +++ b/task_plan.md @@ -1,10 +1,10 @@ -# Task Plan: EvoLink Image Engine Settings +# Task Plan: Platform-Owned Account System ## Goal -Add EvoLink GPT Image 2 as a selectable image creation engine in the settings flow, while preserving the existing Jimeng/Volcengine image engine and the current task/asset workflow. +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 39 - Task Module Width and Preview Polish complete +Phase 67 - Unified Default Billing Multiplier complete ## Phases @@ -366,6 +366,11 @@ Phase 39 - Task Module Width and Preview Polish complete | Running `npm run build` while the dev server was active caused the dev server to miss `.next/server/vendor-chunks/next.js` for the new dynamic template route | 1 | Restarted the dev server, confirmed it rebuilt the route, deleted the temporary verification template, then restarted the server under the normal auth-enabled environment | | Updated `hotelStaff` member-list proxy returned `仅管理员角色允许调用` for the current token | 1 | Classified the upstream permission denial and degraded only the member list to a warning/empty page instead of failing the whole accounts screen | | New account login returned 200 from `/api/auth/password` but was redirected back to `/auth/login?next=/create` | 1 | Added chunked session cookies so larger JWT/authority payloads are preserved across the browser redirect | +| Initial Phase 51 planning append did not match the stale task-plan/progress headings | 1 | Read the actual file tails and applied smaller header/append patches against current content | +| Existing auth regression tests still asserted external OAuth password grants | 1 | Replaced them with platform phone/password, unified-role, and lockout assertions | +| First TypeScript check rejected a test-only import of a non-exported local type | 1 | Imported `PlatformUserRecord` from the shared types module | +| A combined zsh HTTP smoke command had a quoting parse error | 1 | Re-ran the smoke checks as small, isolated curl commands | +| The old dev process served a corrupted `.next` chunk after the production build | 1 | Stopped the stale process, rebuilt, restarted dev on `127.0.0.1:3000`, and verified health/login pages | ## Notes - EvoLink docs: submit `POST /v1/images/generations`, query `GET /v1/tasks/{task_id}`, completed task exposes `results[]`. @@ -384,3 +389,215 @@ Phase 39 - Task Module Width and Preview Polish complete - Latest local startup verification used `npm run dev -- --hostname 127.0.0.1 --port 3003`; the server reached Ready and `/` returned a login redirect. - Current status check on 2026-06-09: no `next dev` / `next-server` process is listening on `3003`, so the dev server is not currently running. - Image templates are now account-scoped records exposed through `/api/image-templates`; the image creation page shows and manages them inside the image module. + +### Phase 51: Platform-Owned Account System +- [x] Establish platform user, organization, role, account-status, archive, and migration data models +- [x] Replace external OAuth2 password/authorization-code login with local phone/password authentication and signed sessions +- [x] Implement super-admin, organization-admin, and ordinary-user permission boundaries +- [x] Add organization-scoped account management, password changes/resets, disable/delete/archive behavior, and bootstrap super-admin initialization +- [x] Add legacy account/owner mapping import path keyed by old phone/account identity +- [x] Add brute-force protection, focused tests, migration documentation, and runtime verification +- **Status:** complete + +### Phase 52: Enterprise Billing and Organization Wallet +- [x] Confirm billing unit, pricing precision, and insufficient-balance behavior +- [x] Trace current generation submission, usage event, role boundaries, persistence, and UI entry points +- [x] Design and implement provider cost catalog, markup snapshots, organization wallet, recharge requests, and immutable ledger +- [x] Integrate atomic pre-charge/refund/settlement behavior into image and video generation, retry, cancellation, deletion, and public API paths +- [x] Add super-admin billing management, organization wallet/recharge management, and user/organization usage views +- [x] Add migration/backward compatibility for existing usage records and local/Supabase stores +- [x] Verify focused tests, full tests, production build, and authenticated route flows +- **Status:** complete + +### Phase 53: Provider Pricing Catalog and Variant Matching +- [x] Add official-source base pricing for the connected Bailian, EvoLink, Jimeng, and Seedance routes +- [x] Add source metadata, fixed FX conversion, model keys, and resolution variants to price rules and billing snapshots +- [x] Seed defaults without overwriting existing administrator rules and expose base/user prices in `/billing` +- [x] Extend local/Supabase persistence and migration indexes for price variants and source metadata +- [x] Verify focused tests, full tests, TypeScript, production build, and diff hygiene +- **Status:** complete + +### Phase 54: Billing Center Taste Redesign +- [x] Audit the current billing page hierarchy across super-admin and member actions +- [x] Rebuild the billing center around balance-first organization context and compact finance lists +- [x] Separate member recharge actions from super-admin pricing, account, wallet, and review controls +- [x] Add responsive, accessible, loading, focus, and reduced-motion states +- [x] Verify desktop/mobile visual smoke, TypeScript, full tests, production build, and diff hygiene +- **Status:** complete + +### Phase 55: Tabbed Billing Operations +- [x] Replace the long billing stack with role-specific keyboard-accessible task tabs +- [x] Add super-admin inline corporate account configuration from the billing center +- [x] Add organization wallet manual credit/debit adjustments with immutable ledger attribution +- [x] Expose organization members and per-user net consumption for quick ledger attribution +- [x] Verify responsive tab behavior, API payloads, tests, TypeScript, production build, and diff hygiene +- **Status:** complete + +### Phase 56: Billing Error Recovery +- [x] Trace the reported `Internal Server Error` against local page/API responses and runtime logs +- [x] Make missing Supabase billing schema errors actionable instead of returning an opaque server failure +- [x] Safely handle non-JSON 500 responses in billing actions and add a billing route error boundary +- [x] Verify the recovery changes with TypeScript, tests, production build, and browser smoke +- **Status:** complete + +### Phase 57: Super-Admin Billing Blank State +- [x] Reproduce the blank super-admin billing page with an unbound super-admin account +- [x] Render a platform-wide billing overview from the super-admin payload without requiring personal organization membership +- [x] Verify all super-admin tabs render in a production build +- **Status:** complete + +### Phase 58: Parameterized Billing Rules +- [x] Extend billing rules with structured request conditions and quantity sources while preserving legacy `variantKey` rules +- [x] Normalize image/video request parameters and deterministically select the most specific matching rule +- [x] Add a server-side quote endpoint and use the same matcher during task submission +- [x] Upgrade the super-admin price editor to configure parameter conditions and show match scope +- [x] Snapshot normalized parameters, matched rule, quantity, and final amount in billing quotes/ledger metadata +- [x] Verify focused pricing tests, full tests, TypeScript, production build, and browser smoke +- **Status:** complete + +### Phase 59: Task Detail Modal and Result Directory Consolidation +- [x] Confirm that only the visible `/assets` result page/navigation is removed; physical result storage remains intact +- [x] Replace the task-module result-page link with an in-place task detail modal +- [x] Show prompt, input elements/materials, generation parameters, task metadata, status/error, and available outputs in the modal +- [x] Add a direct download action to each completed task card without requiring the result page +- [x] Preserve result storage and download APIs while keeping `/assets` as a compatibility redirect to `/create` +- [x] Verify the desktop layout, full tests, TypeScript, production build, and browser behavior +- **Status:** complete + +### Phase 60: Direct Billing Top-ups +- [x] Trace all recharge-request and review dependencies +- [x] Remove the user-submitted recharge/review workflow and pending-review UI +- [x] Keep administrator balance adjustments as the current direct top-up path +- [x] Make the billing payload and documentation describe direct posting and future automatic posting +- [x] Verify type checks, tests, production build, and local billing routes +- **Status:** complete + +### Phase 61: Simplified Billing Price Controls +- [x] Inspect the current price catalog editor and admin price API +- [x] Replace multi-field rule creation/editing with read-only standards and multiplier-only updates +- [x] Prevent super-admin price APIs from changing provider costs, parameters, or rule structure +- [x] Verify the compact billing UI, type checks, tests, production build, and local server +- **Status:** complete + +### Phase 63: Parameterized Billing Catalog +- [x] Define platform-owned parameter dimensions and factor-based quote semantics +- [x] Extend billing catalog/storage/API to expose parameter tiers with standard costs and editable multipliers +- [x] Seed provider/model parameter tiers from the existing catalog and documented provider options +- [x] Replace the flat price list with grouped service and parameter-tier controls +- [x] Verify quote matching, admin multiplier updates, type checks, tests, production build, and browser behavior +- **Status:** complete + +### Phase 64: Inline Generation Cost Estimate +- [x] Move the generation cost preview beside the live parameter controls +- [x] Reuse the server quote path and expose loading, unavailable, quantity, multiplier, and matched-tier states +- [x] Align the parameter bar and estimate card with a container-aware desktop layout and safe narrow-width stacking +- [x] Verify quote changes, video duration estimates, type checks, tests, production build, and document overflow +- **Status:** complete + +### Phase 65: Billing UI Alignment and Native Multiplier Dialog +- [x] Align price-source actions and notes on a shared baseline without adding maintenance fields +- [x] Tighten the generation estimate card title and parameter-row composition at desktop width +- [x] Replace browser prompt editing with an accessible in-app multiplier dialog, validation, preview, and Escape dismissal +- [x] Verify desktop browser behavior, quote refresh, type checks, tests, production build, and local-server health +- **Status:** complete + +### Phase 66: Fixed EvoLink 1K Quote and User-Facing Estimate +- [x] Fix the hidden EvoLink resolution to the platform-approved 1K request and billing baseline +- [x] Keep visible quality pricing consistent: standard ¥0.51/image and high-quality ¥2.04/image at the configured multiplier +- [x] Reduce the ordinary-user estimate card to the final amount only, removing internal multiplier, tier, quantity, and helper copy +- [x] Verify the live standard/high quote, stale-copy removal, type checks, tests, and diff hygiene +- **Status:** complete + +### Phase 67: Unified Default Billing Multiplier +- [x] Change the platform default markup multiplier from 1.50× to 1.20× across all built-in services and parameter tiers +- [x] Synchronize the local initialized billing catalog so existing default rules charge at 1.20× +- [x] Update built-in quote expectations and billing documentation +- [x] Verify image/video quote amounts, type checks, tests, production build, and server health +- **Status:** complete + +### Phase 68: Cross-Provider Pricing Audit +- [x] Compare every active provider's standard cost and billing unit with its current official pricing source +- [x] Verify parameter-dependent cost dimensions, quantity extraction, and one-time platform markup application +- [x] Record confirmed matches, approximation boundaries, and any provider pricing gaps before proposing changes +- [x] Present the audit result and obtain confirmation before making material pricing-model changes +- **Status:** complete + +### Phase 69: Seedance Native Usage Settlement +- [x] Add official Seedance token-price dimensions for resolution and input-video presence +- [x] Freeze a conservative estimate at submission, including the maximum input-video duration when metadata is absent +- [x] Extract `usage.completion_tokens` from provider responses and settle the final amount exactly once +- [x] Refund or charge only the difference; preserve the frozen amount when upstream usage is absent +- [x] Verify idempotency, persistence compatibility, docs, type checks, tests, and production build +- **Status:** complete + +### Phase 70: Unbound Account Quote Preview +- [x] Trace the empty estimate state for the current super-admin session +- [x] Allow quote-only requests without an organization while keeping real submission organization-gated +- [x] Add regression coverage for the preview/charge boundary +- [x] Verify the exact Image2 / 9:16 / high-quality quote in the browser and run full checks +- **Status:** complete + +### Phase 61: Account Directory and Password Settings Consolidation +- [x] Audit the current account directory, settings password flow, account APIs, roles, and existing visual tokens +- [x] Move password change into the account directory while preserving the password API contract and admin API permissions +- [x] Redesign the account directory with a modern, restrained desktop-first layout using the taste-skill audit principles +- [x] Preserve account management actions, loading/error/empty states, keyboard access, and existing API contracts +- [x] Verify desktop browser behavior, settings separation, type checks, tests, production build, and diff hygiene +- **Status:** complete + +### Phase 62: Account Workspace Information Architecture Correction +- [x] Remove duplicate identity surfaces and consolidate current-user information into one header summary +- [x] Replace scattered account/profile/admin cards with one continuous account workspace +- [x] Keep the ordered flow of security, administrator operations, and member directory without changing API behavior +- [x] Verify the desktop screenshot, identity duplication, document width, type checks, production build, and diff hygiene +- **Status:** complete + +### Phase 70: Organization-Only Billing Top-ups +- [x] Remove personal attribution from administrator recharge and balance-adjustment inputs +- [x] Keep generation charge/refund actor attribution for member consumption reporting, while making all new organization balance entries organization-owned +- [x] Remove member selection and “归属上账” actions from the billing center +- [x] Update billing tests and documentation to state the shared organization quota model +- [x] Verify focused/full tests, TypeScript, production build, and diff hygiene +- **Status:** complete + +### Phase 71: Frontend Encoding Diagnosis +- [x] Check source-file encoding and response charset +- [x] Check rendered text in the login page and billing page with headless browser automation +- [ ] Reproduce the user's exact garbled page/browser state and apply a targeted fix +- **Status:** awaiting reproduction details + +### Phase 72: Autofilled Login Submission +- [x] Trace the login button disabled condition and browser autofill interaction +- [x] Read phone/password from native form controls at submit time +- [x] Keep required-field and server-side validation in place +- [x] Verify focused/full tests, TypeScript, production build, and browser click behavior +- **Status:** complete + +### Phase 73: Next Development Cache Recovery +- [x] Confirm the missing `9971.js` module is absent from the active `.next` cache +- [x] Stop duplicate Next development servers for this workspace +- [x] Move the corrupted cache to a recoverable backup and start one clean dev server +- [x] Verify the login page, login request path, and core page compilation +- **Status:** complete + +### Phase 74: Dev/Production Cache Isolation +- [x] Separate Next development output into `.next-dev` while retaining `.next` for production builds +- [x] Move the partially generated `.next-dev` cache to a recoverable backup before restarting +- [x] Start one clean development server and verify login rendering, health, and protected-route behavior +- [x] Run TypeScript and diff checks after the configuration change +- **Status:** complete + +### Phase 75: Local Super-Admin Credential Recovery +- [x] Confirm the requested account is the local super administrator +- [x] Replace the unreadable password hash with a generated strong password through the admin password API +- [x] Save the credential in the project browser vault without writing the password into source or tracking files +- [x] Verify the saved credential can log in and reaches the protected creation workspace +- **Status:** complete + +### Phase 76: Quota Guard and Super-Admin Billing Exemption +- [x] Thread the super-admin role through the generation usage context and billing snapshot +- [x] Reject ordinary generation submissions before provider dispatch when the organization balance is insufficient +- [x] Let super-admins calculate and record generation cost without checking, freezing, or refunding organization quota +- [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 diff --git a/tests/account-store.test.ts b/tests/account-store.test.ts new file mode 100644 index 0000000..e2968c5 --- /dev/null +++ b/tests/account-store.test.ts @@ -0,0 +1,97 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createAsset, + createGenerationJob, + createImageTemplate, + listAssets, + listGenerationJobs, + listImageTemplates, + listUsageEvents, + recordUsageEvent +} from "@/lib/server/data-store"; +import { + createPlatformOrganization, + createPlatformUser, + deletePlatformUser, + getPlatformUserById +} from "@/lib/server/account-store"; +import type { PlatformUserRecord } from "@/lib/types"; + +let dataDirectory = ""; +let user: PlatformUserRecord; +let archiveOwnerId = ""; + +describe("platform account ownership lifecycle", () => { + beforeEach(async () => { + dataDirectory = await mkdtemp(join(tmpdir(), "zhinian-account-store-")); + 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", ""); + const organization = await createPlatformOrganization("归档组织"); + archiveOwnerId = organization.archiveOwnerId; + user = await createPlatformUser({ + phone: "13800138000", + displayName: "待删除用户", + password: "ArchivePass123", + role: "user", + organizationId: organization.id + }); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await rm(dataDirectory, { force: true, recursive: true }); + }); + + it("removes login identity, archives business data, and retains usage history", async () => { + const asset = await createAsset({ + ownerId: user.id, + kind: "image", + name: "待归档素材", + url: "https://example.com/archive.png", + source: "upload", + tags: [], + metadata: {} + }); + const job = await createGenerationJob({ + ownerId: user.id, + capability: "image.generate", + provider: "evolink", + reqKey: "gpt-image-2", + status: "succeeded", + inputAssetIds: [asset.id], + inputUrls: [], + outputAssetIds: [asset.id], + requestPayload: {} + }); + await createImageTemplate({ + ownerId: user.id, + name: "待归档模板", + prompt: "测试提示词", + settings: {}, + sortOrder: 0 + }); + await recordUsageEvent({ + ownerId: user.id, + jobId: job.id, + source: "platform", + capability: job.capability, + provider: job.provider, + quantity: 1, + estimatedUnit: "image" + }); + + await deletePlatformUser(user.id); + + expect(await getPlatformUserById(user.id, { includeDisabled: true })).toBeNull(); + expect((await listAssets(archiveOwnerId)).map((item) => item.id)).toEqual([asset.id]); + expect((await listGenerationJobs(archiveOwnerId)).map((item) => item.id)).toEqual([job.id]); + expect((await listImageTemplates(archiveOwnerId)).map((item) => item.name)).toEqual(["待归档模板"]); + expect((await listUsageEvents()).find((event) => event.jobId === job.id)).toMatchObject({ ownerId: user.id }); + }); +}); diff --git a/tests/auth-login-panel.test.ts b/tests/auth-login-panel.test.ts index 0ca4934..31e3d51 100644 --- a/tests/auth-login-panel.test.ts +++ b/tests/auth-login-panel.test.ts @@ -12,15 +12,20 @@ describe("AuthLoginPanel", () => { expect(source).not.toContain("randomStr"); }); - it("supports switching between normal and admin login pages", async () => { + it("uses one platform phone/password login for every role", async () => { const panelSource = await readFile(join(process.cwd(), "components", "auth-login-panel.tsx"), "utf8"); const loginPageSource = await readFile(join(process.cwd(), "app", "auth", "login", "page.tsx"), "utf8"); const adminPageSource = await readFile(join(process.cwd(), "app", "auth", "admin-login", "page.tsx"), "utf8").catch(() => ""); - expect(panelSource).toContain("authMode"); - expect(panelSource).toContain("alternateHref"); - expect(loginPageSource).toContain("/auth/admin-login"); - expect(adminPageSource).toContain('authMode="admin"'); + expect(panelSource).toContain('phone: submittedPhone'); + expect(panelSource).toContain('inputMode="tel"'); + expect(panelSource).toContain('name="phone"'); + expect(panelSource).toContain('name="password"'); + expect(panelSource).toContain('new FormData(event.currentTarget)'); + expect(panelSource).not.toContain('disabled={!configured || submitting || !phone.trim() || !password}'); + expect(panelSource).not.toContain("authMode"); + expect(panelSource).not.toContain("alternateHref"); + expect(loginPageSource).not.toContain("/auth/admin-login"); expect(adminPageSource).toContain("/auth/login"); }); }); diff --git a/tests/auth-password-route.test.ts b/tests/auth-password-route.test.ts index d040149..b550746 100644 --- a/tests/auth-password-route.test.ts +++ b/tests/auth-password-route.test.ts @@ -1,212 +1,130 @@ -import { createSign, generateKeyPairSync, type KeyObject } from "node:crypto"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { clearJwksCacheForTests } from "@/lib/server/auth/jwt"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { parseSessionCookieValue } from "@/lib/auth/session"; +import { + createPlatformOrganization, + createPlatformUser, + updatePlatformUser +} from "@/lib/server/account-store"; +import { resetLocalAuthRateLimitForTests } from "@/lib/server/auth/local"; +import type { PlatformUserRecord } from "@/lib/types"; import { POST } from "@/app/api/auth/password/route"; -type TestJwk = JsonWebKey & { - kid?: string; - alg?: string; - use?: string; -}; +const SESSION_SECRET = "test-platform-session-secret-with-enough-entropy"; +let runtimeDir = ""; +let ordinaryUser: PlatformUserRecord; -const baseEnv = { - ZHINIAN_AUTH_REQUIRED: "1", - ZHINIAN_AUTH_BASE_URL: "https://gateway.example.com/auth", - ZHINIAN_AUTH_CLIENT_ID: "agentbus-client", - ZHINIAN_AUTH_CLIENT_SECRET: "client-secret", - ZHINIAN_AUTH_SCOPE: "server", - ZHINIAN_AUTH_ISSUER: "https://pig4cloud.com", - ZHINIAN_AUTH_SESSION_SECRET: "test-session-secret-with-enough-entropy" -}; +describe("platform password auth route", () => { + beforeEach(async () => { + runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-auth-")); + vi.stubEnv("ZHINIAN_DATA_DIR", runtimeDir); + 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", ""); + resetLocalAuthRateLimitForTests(); -describe("password auth route AgentBus compatibility", () => { - afterEach(() => { + const organization = await createPlatformOrganization("测试组织"); + ordinaryUser = await createPlatformUser({ + phone: "13800138000", + displayName: "测试用户", + password: "TestPass123", + role: "user", + organizationId: organization.id + }); + }); + + afterEach(async () => { + resetLocalAuthRateLimitForTests(); vi.unstubAllEnvs(); - vi.unstubAllGlobals(); - clearJwksCacheForTests(); + await rm(runtimeDir, { force: true, recursive: true }); }); - it("encrypts password with the configured AES-CFB key and does not require captcha fields", async () => { - for (const [key, value] of Object.entries(baseEnv)) vi.stubEnv(key, value); - vi.stubEnv("ZHINIAN_AUTH_PASSWORD_ENC_KEY", "thanks,pig4cloud"); - const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); - const jwk = publicKey.export({ format: "jwk" }) as TestJwk; - jwk.kid = "agentbus-key"; - const accessToken = signJwt({ - iss: baseEnv.ZHINIAN_AUTH_ISSUER, - sub: "subject-42", - user_id: "remote-42", - username: "user@example.com", - client_id: baseEnv.ZHINIAN_AUTH_CLIENT_ID, - scope: baseEnv.ZHINIAN_AUTH_SCOPE, - exp: Math.floor(Date.now() / 1000) + 600, - iat: Math.floor(Date.now() / 1000) - 10, - nbf: Math.floor(Date.now() / 1000) - 10 - }, privateKey, "agentbus-key"); - const seenBodies: URLSearchParams[] = []; - - vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - if (url.endsWith("/oauth2/jwks")) { - return new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }); - } - if (url.endsWith("/oauth2/token")) { - seenBodies.push(new URLSearchParams(String(init?.body))); - return new Response(JSON.stringify({ - access_token: accessToken, - refresh_token: "refresh-token-1", - token_type: "bearer", - expires_in: "3600" - }), { status: 200 }); - } - return new Response("not found", { status: 404 }); - }); - - const response = await POST(new Request("https://app.example.com/api/auth/password", { + it("logs in every role with the platform phone/password session", async () => { + const response = await POST(new Request("http://127.0.0.1/api/auth/password", { method: "POST", + headers: { "x-forwarded-for": "192.0.2.10" }, body: JSON.stringify({ - username: "user@example.com", - password: "123456", - next: "/create" + phone: "138 0013-8000", + password: "TestPass123", + next: "/assets" }) })); expect(response.status).toBe(200); - expect(seenBodies).toHaveLength(1); - expect(seenBodies[0].get("grant_type")).toBe("password"); - expect(seenBodies[0].get("username")).toBe("user@example.com"); - expect(seenBodies[0].get("password")).toBe("YehdBPev"); - expect(seenBodies[0].has("code")).toBe(false); - expect(seenBodies[0].has("randomStr")).toBe(false); + await expect(response.json()).resolves.toMatchObject({ + ok: true, + redirectTo: "/assets", + authMode: "user", + user: { id: ordinaryUser.id, phone: "13800138000", role: "user", clientId: "platform" } + }); + expect(await parseSessionCookieValue( + response.cookies.get("zhinian_session")?.value, + SESSION_SECRET + )).toMatchObject({ + authMode: "user", + sessionVersion: 1, + user: { id: ordinaryUser.id, role: "user", clientId: "platform" } + }); }); - it("passes the organization tenant id to platform password login", async () => { - for (const [key, value] of Object.entries(baseEnv)) vi.stubEnv(key, value); - vi.stubEnv("ZHINIAN_ORG_TENANT_ID", "999"); - const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); - const jwk = publicKey.export({ format: "jwk" }) as TestJwk; - jwk.kid = "tenant-key"; - const accessToken = signJwt({ - iss: baseEnv.ZHINIAN_AUTH_ISSUER, - sub: "platform-user", - user_id: "platform-user", - username: "platform@example.com", - client_id: baseEnv.ZHINIAN_AUTH_CLIENT_ID, - scope: baseEnv.ZHINIAN_AUTH_SCOPE, - tenant_id: "999", - exp: Math.floor(Date.now() / 1000) + 600, - iat: Math.floor(Date.now() / 1000) - 10, - nbf: Math.floor(Date.now() / 1000) - 10 - }, privateKey, "tenant-key"); - const seenBodies: URLSearchParams[] = []; - const seenTenantHeaders: Array = []; - - vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - if (url.endsWith("/oauth2/jwks")) { - return new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }); - } - if (url.endsWith("/oauth2/token")) { - seenBodies.push(new URLSearchParams(String(init?.body))); - seenTenantHeaders.push(headerValue(init?.headers, "tenantId")); - return new Response(JSON.stringify({ - access_token: accessToken, - token_type: "bearer", - expires_in: "3600" - }), { status: 200 }); - } - return new Response("not found", { status: 404 }); + it("logs in a super administrator through the same endpoint", async () => { + const admin = await createPlatformUser({ + phone: "13900139000", + displayName: "平台超级管理员", + password: "AdminPass123", + role: "super_admin" }); - const response = await POST(new Request("https://app.example.com/api/auth/password", { + const response = await POST(new Request("http://127.0.0.1/api/auth/password", { method: "POST", - body: JSON.stringify({ - username: "platform@example.com", - password: "123456" - }) + body: JSON.stringify({ phone: admin.phone, password: "AdminPass123", authMode: "admin" }) })); expect(response.status).toBe(200); - expect(seenBodies[0].get("tenantId")).toBe("999"); - expect(seenTenantHeaders).toEqual(["999"]); + await expect(response.json()).resolves.toMatchObject({ + authMode: "admin", + user: { id: admin.id, role: "super_admin" } + }); }); - it("uses the admin OAuth client for admin password login", async () => { - for (const [key, value] of Object.entries(baseEnv)) vi.stubEnv(key, value); - vi.stubEnv("ZHINIAN_ADMIN_AUTH_CLIENT_ID", "app"); - vi.stubEnv("ZHINIAN_ADMIN_AUTH_CLIENT_SECRET", "app"); - const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); - const jwk = publicKey.export({ format: "jwk" }) as TestJwk; - jwk.kid = "admin-key"; - const accessToken = signJwt({ - iss: baseEnv.ZHINIAN_AUTH_ISSUER, - sub: "admin", - user_id: "admin", - username: "admin", - client_id: "app", - scope: baseEnv.ZHINIAN_AUTH_SCOPE, - exp: Math.floor(Date.now() / 1000) + 600, - iat: Math.floor(Date.now() / 1000) - 10, - nbf: Math.floor(Date.now() / 1000) - 10 - }, privateKey, "admin-key"); - const seenAuthorizations: string[] = []; + it("locks an account after five failed passwords", async () => { + let lastResponse: Response | undefined; + for (let attempt = 1; attempt <= 5; attempt += 1) { + lastResponse = await POST(new Request("http://127.0.0.1/api/auth/password", { + method: "POST", + headers: { "x-forwarded-for": "192.0.2.11" }, + body: JSON.stringify({ phone: ordinaryUser.phone, password: "wrong-pass" }) + })); + expect(lastResponse.status).toBe(attempt === 5 ? 423 : 401); + } + await expect(lastResponse?.json()).resolves.toMatchObject({ error: "登录失败次数过多,请 15 分钟后再试。" }); - vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - if (url.endsWith("/oauth2/jwks")) { - return new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }); - } - if (url.endsWith("/oauth2/token")) { - const headers = init?.headers as Record | undefined; - seenAuthorizations.push(headers?.Authorization || headers?.authorization || ""); - return new Response(JSON.stringify({ - access_token: accessToken, - token_type: "bearer", - expires_in: "3600" - }), { status: 200 }); - } - return new Response("not found", { status: 404 }); - }); - - const response = await POST(new Request("https://app.example.com/api/auth/password", { + const lockedResponse = await POST(new Request("http://127.0.0.1/api/auth/password", { method: "POST", - body: JSON.stringify({ - username: "admin", - password: "123456", - authMode: "admin", - next: "/accounts" - }) + body: JSON.stringify({ phone: ordinaryUser.phone, password: "TestPass123" }) })); + expect(lockedResponse.status).toBe(423); + }); - expect(response.status).toBe(200); - expect(seenAuthorizations).toEqual([ - `Basic ${Buffer.from("app:app").toString("base64")}` - ]); + it("rejects disabled accounts and duplicate phone identities", async () => { + await expect(createPlatformUser({ + phone: ordinaryUser.phone, + displayName: "重复账号", + password: "AnotherPass123", + role: "user", + organizationId: ordinaryUser.organizationId + })).rejects.toThrow("该手机号已创建账号。"); + + await updatePlatformUser(ordinaryUser.id, { status: "disabled" }); + const response = await POST(new Request("http://127.0.0.1/api/auth/password", { + method: "POST", + body: JSON.stringify({ phone: ordinaryUser.phone, password: "TestPass123" }) + })); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ error: "账号已停用,请联系管理员。" }); }); }); - -function headerValue(headers: HeadersInit | undefined, name: string): string | null { - if (!headers) return null; - if (headers instanceof Headers) return headers.get(name); - if (Array.isArray(headers)) { - const found = headers.find(([key]) => key.toLowerCase() === name.toLowerCase()); - return found?.[1] ?? null; - } - const record = headers as Record; - return record[name] ?? record[name.toLowerCase()] ?? null; -} - -function signJwt(payload: Record, privateKey: KeyObject, kid: string): string { - const header = base64UrlJson({ alg: "RS256", typ: "JWT", kid }); - const body = base64UrlJson(payload); - const signingInput = `${header}.${body}`; - const signer = createSign("RSA-SHA256"); - signer.update(signingInput); - signer.end(); - const signature = signer.sign(privateKey).toString("base64url"); - return `${signingInput}.${signature}`; -} - -function base64UrlJson(value: unknown): string { - return Buffer.from(JSON.stringify(value)).toString("base64url"); -} diff --git a/tests/auth-permissions.test.ts b/tests/auth-permissions.test.ts index 2c07c42..80f3ea3 100644 --- a/tests/auth-permissions.test.ts +++ b/tests/auth-permissions.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { configuredAdminAuthorities, configuredAdminUsers, hasAdminAccess } from "@/lib/auth/permissions"; -import type { AuthUser } from "@/lib/auth/session"; +import { + configuredAdminAuthorities, + configuredAdminUsers, + hasAdminAccess, + hasAdminSessionAccess +} from "@/lib/auth/permissions"; +import type { AuthSession, AuthUser } from "@/lib/auth/session"; const baseUser: AuthUser = { id: "auth:app:1", @@ -21,6 +26,12 @@ describe("auth permission helpers", () => { expect(hasAdminAccess({ ...baseUser, authorities: ["ROLE_USER"] })).toBe(false); }); + it("uses the platform role as the single source of browser permissions", () => { + expect(hasAdminAccess({ ...baseUser, role: "user", authorities: ["ROLE_SUPER_ADMIN"] })).toBe(false); + expect(hasAdminAccess({ ...baseUser, role: "organization_admin", authorities: [] })).toBe(true); + expect(hasAdminAccess({ ...baseUser, role: "super_admin", authorities: [] })).toBe(true); + }); + it("treats ceshiop as the default administrator account", () => { expect(configuredAdminUsers()).toEqual(["ceshiop"]); expect(hasAdminAccess({ ...baseUser, username: "ceshiop", subject: "ceshiop", authorities: [] })).toBe(true); @@ -31,22 +42,43 @@ describe("auth permission helpers", () => { expect(configuredAdminUsers()).toEqual(["ops-admin"]); expect(hasAdminAccess({ ...baseUser, username: "ceshiop", subject: "ceshiop", authorities: [] })).toBe(false); expect(hasAdminAccess({ ...baseUser, username: "ops-admin", subject: "ops-admin", authorities: [] })).toBe(true); + expect(hasAdminAccess({ ...baseUser, displayName: "ops-admin", authorities: [] })).toBe(false); }); - it("uses configured admin usernames without falling back to default authority grants", () => { + it("uses configured admin usernames without granting common platform permissions", () => { vi.stubEnv("ZHINIAN_ADMIN_USERS", "admin"); expect(hasAdminAccess({ ...baseUser, username: "staff", subject: "staff", authorities: ["sys_user_view"] })).toBe(false); expect(hasAdminAccess({ ...baseUser, username: "admin", subject: "admin", authorities: [] })).toBe(true); }); - it("accepts configured admin authorities and normalizes case", () => { - vi.stubEnv("ZHINIAN_ADMIN_AUTHORITIES", "custom-admin, sys_config_view"); - expect(configuredAdminAuthorities()).toEqual(["custom-admin", "sys_config_view"]); + it("accepts exact configured admin authorities and normalizes case", () => { + vi.stubEnv("ZHINIAN_ADMIN_AUTHORITIES", "custom-admin, role_ops_admin"); + expect(configuredAdminAuthorities()).toEqual(["custom-admin", "role_ops_admin"]); expect(hasAdminAccess({ ...baseUser, authorities: ["CUSTOM_ADMIN"] })).toBe(true); + expect(hasAdminAccess({ ...baseUser, authorities: ["CUSTOM_ADMIN_EXTRA"] })).toBe(false); }); - it("accepts known system admin permission prefixes", () => { - expect(hasAdminAccess({ ...baseUser, authorities: ["sys_user_view"] })).toBe(true); - expect(hasAdminAccess({ ...baseUser, authorities: ["admin:accounts"] })).toBe(true); + it("accepts dedicated admin roles but rejects generic roles and SYS permissions", () => { + expect(hasAdminAccess({ ...baseUser, authorities: ["ROLE_ADMIN"] })).toBe(true); + expect(hasAdminAccess({ ...baseUser, authorities: ["SUPER_ADMIN"] })).toBe(true); + expect(hasAdminAccess({ ...baseUser, authorities: ["ROLE_1"] })).toBe(false); + expect(hasAdminAccess({ ...baseUser, authorities: ["1"] })).toBe(false); + expect(hasAdminAccess({ ...baseUser, authorities: ["sys_user_view"] })).toBe(false); + expect(hasAdminAccess({ ...baseUser, authorities: ["admin:accounts"] })).toBe(false); + }); + + it("requires both admin login mode and an administrator identity", () => { + const adminUser = { ...baseUser, authorities: ["ROLE_ADMIN"] }; + const session = (authMode: AuthSession["authMode"], user = adminUser): AuthSession => ({ + version: 1, + authMode, + issuedAt: 100, + expiresAt: 200, + user + }); + + expect(hasAdminSessionAccess(session("user"))).toBe(false); + expect(hasAdminSessionAccess(session("admin"))).toBe(true); + expect(hasAdminSessionAccess(session("admin", { ...baseUser, authorities: ["ROLE_USER"] }))).toBe(false); }); }); diff --git a/tests/auth-session.test.ts b/tests/auth-session.test.ts index 77325a5..153eb04 100644 --- a/tests/auth-session.test.ts +++ b/tests/auth-session.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { chunkCookieValue, chunkedCookieName, + createSignedJsonValue, createSessionCookieValue, parseSessionCookieValue, readChunkedCookieValue, @@ -43,6 +44,7 @@ describe("SSO auth helpers", () => { it("round-trips signed session cookies and rejects tampering or expiry", async () => { const session: AuthSession = { version: 1, + authMode: "admin", issuedAt: 100, expiresAt: 200, accessToken: "access-token-1", @@ -60,6 +62,7 @@ describe("SSO auth helpers", () => { const cookie = await createSessionCookieValue(session, authConfig.sessionSecret || ""); expect(await parseSessionCookieValue(cookie, authConfig.sessionSecret || "", 150)).toMatchObject({ + authMode: "admin", accessToken: "access-token-1", tokenType: "bearer", user: { id: "auth:customPC:1", displayName: "张三" } @@ -71,6 +74,7 @@ describe("SSO auth helpers", () => { it("reassembles chunked session cookies for large auth payloads", async () => { const session: AuthSession = { version: 1, + authMode: "user", issuedAt: 100, expiresAt: 200, accessToken: "token.".repeat(1200), @@ -98,6 +102,28 @@ describe("SSO auth helpers", () => { }); }); + it("treats legacy sessions without an auth mode as ordinary user sessions", async () => { + const legacyCookie = await createSignedJsonValue({ + version: 1, + issuedAt: 100, + expiresAt: 200, + user: { + id: "auth:customPC:legacy-admin", + subject: "legacy-admin", + username: "legacy-admin", + displayName: "旧管理员", + clientId: "customPC", + authorities: ["ROLE_ADMIN"], + scope: ["server"] + } + }, authConfig.sessionSecret || ""); + + expect(await parseSessionCookieValue(legacyCookie, authConfig.sessionSecret || "", 150)).toMatchObject({ + authMode: "user", + user: { username: "legacy-admin", authorities: ["ROLE_ADMIN"] } + }); + }); + it("verifies RS256 JWTs from JWKS and maps stable owner ids", async () => { const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); const jwk = publicKey.export({ format: "jwk" }) as TestJwk; diff --git a/tests/bailian-client.test.ts b/tests/bailian-client.test.ts index d55929b..f3c7a4c 100644 --- a/tests/bailian-client.test.ts +++ b/tests/bailian-client.test.ts @@ -63,6 +63,7 @@ describe("Bailian client", () => { { type: "last_frame", url: "https://app.example.com/last.png" } ]); expect(payload.parameters).toMatchObject({ duration: 10, resolution: "1080P", prompt_extend: true, watermark: false }); + expect(payload.parameters).not.toHaveProperty("size"); }); it("normalizes task state and result URLs", () => { diff --git a/tests/billing.test.ts b/tests/billing.test.ts new file mode 100644 index 0000000..3d85cec --- /dev/null +++ b/tests/billing.test.ts @@ -0,0 +1,631 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { calculateBillingAmountFen } from "@/lib/billing"; +import { + BillingConfigurationError, + chargeGenerationJob, + findMatchingBillingPriceRule, + normalizeBillingParameters, + postOrganizationTopUp, + quoteGenerationCharge, + refundGenerationCharge, + settleSeedanceGenerationCharge +} from "@/lib/server/billing-service"; +import { + createBillingPriceRule, + listBillingLedgerEntries, + postWalletEntry, + InsufficientBalanceError, + updateBillingPriceTierMultiplier +} from "@/lib/server/billing-store"; +import { createGenerationJob, getGenerationJob, listUsageEvents, recordUsageForJob, updateGenerationJob } from "@/lib/server/data-store"; + +let runtimeDir = ""; + +describe("organization billing", () => { + beforeEach(async () => { + 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", ""); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await rm(runtimeDir, { recursive: true, force: true }); + }); + + it("calculates provider quantity multiplied by the super-admin markup", () => { + expect(calculateBillingAmountFen(35, 2, 1.5)).toBe(105); + expect(calculateBillingAmountFen(1, 1, 1.01)).toBe(2); + }); + + it("matches the most specific parameter rule and snapshots normalized parameters", async () => { + await createBillingPriceRule({ + id: "rule-parameter-generic", + provider: "evolink", + capability: "image.generate", + reqKey: "gpt-image-2-custom", + unit: "image", + standardUnitPriceFen: 30, + markupMultiplier: 1.5, + enabled: true + }); + await createBillingPriceRule({ + id: "rule-parameter-specific", + provider: "evolink", + capability: "image.generate", + reqKey: "gpt-image-2-custom", + unit: "image", + standardUnitPriceFen: 80, + markupMultiplier: 1.5, + enabled: true, + conditions: { + resolution: "2K", + quality: "high", + referenceImageCount: { min: 1 } + }, + quantitySource: "image_count" + }); + + const requestPayload = { + settings: { resolution: " 2K " }, + input: { quality: "HIGH", imageUrls: ["https://example.com/reference.png"] }, + providerPayload: { model: "gpt-image-2-custom", parameters: { n: 1, size: "2K" } } + }; + expect(normalizeBillingParameters(requestPayload)).toMatchObject({ + resolution: "2k", + quality: "high", + referenceImageCount: 1, + imageCount: 1 + }); + const quote = await quoteGenerationCharge({ + provider: "evolink", + capability: "image.generate", + reqKey: "gpt-image-2-custom", + requestPayload, + usageContext: { source: "platform", accountId: "user-1", displayName: "测试用户", organizationId: "org-1" } + }); + expect(quote).toMatchObject({ + priceRuleId: "rule-parameter-specific", + standardUnitPriceFen: 80, + amountFen: 120, + conditions: { resolution: "2K", quality: "high", referenceImageCount: { min: 1 } }, + parameters: { resolution: "2k", quality: "high", referenceImageCount: 1 } + }); + }); + + it("uses an explicit duration quantity source for video rules", async () => { + await createBillingPriceRule({ + id: "rule-duration-specific", + provider: "seedance", + capability: "video.generate", + reqKey: "seedance-custom", + unit: "video_second", + standardUnitPriceFen: 100, + markupMultiplier: 1.2, + enabled: true, + conditions: { resolution: "1080p", aspectRatio: "16:9" }, + quantitySource: "duration" + }); + const quote = await quoteGenerationCharge({ + provider: "seedance", + capability: "video.generate", + reqKey: "seedance-custom", + requestPayload: { settings: { duration: 6, resolution: "1080p", ratio: "16:9" } }, + usageContext: { source: "platform", accountId: "user-1", displayName: "测试用户", organizationId: "org-1" } + }); + expect(quote).toMatchObject({ quantity: 6, amountFen: 720, quantitySource: "duration" }); + }); + + it("rejects ambiguous rules and returns no rule when conditions do not match", () => { + const base = { + provider: "evolink" as const, + capability: "image.generate" as const, + reqKey: "custom", + unit: "image" as const, + standardUnitPriceFen: 10, + markupMultiplier: 1.5, + enabled: true, + conditions: { quality: "high" } + }; + const input = { + provider: "evolink" as const, + capability: "image.generate" as const, + reqKey: "custom", + requestPayload: { input: { quality: "high" } } + }; + expect(() => findMatchingBillingPriceRule([ + { ...base, id: "ambiguous-a", createdAt: "", updatedAt: "" }, + { ...base, id: "ambiguous-b", createdAt: "", updatedAt: "" } + ], input)).toThrow(BillingConfigurationError); + expect(findMatchingBillingPriceRule([{ ...base, id: "only-1080p", conditions: { resolution: "1080p" }, createdAt: "", updatedAt: "" }], { + ...input, + requestPayload: { input: { quality: "high", resolution: "720p" } } + })).toBeNull(); + }); + + it("seeds the official base catalog and matches video resolution variants", async () => { + const quote = await quoteGenerationCharge({ + provider: "seedance", + capability: "video.generate", + reqKey: "doubao-seedance-2-0-260128", + requestPayload: { settings: { duration: 5, resolution: "720p" } }, + usageContext: { + source: "platform", + accountId: "user-1", + displayName: "测试用户", + organizationId: "org-1" + } + }); + expect(quote).toMatchObject({ + variantKey: "resolution=720p", + standardUnitPriceFen: 99, + quantity: 5, + amountFen: 597, + markupMultiplier: 1.2, + status: "pending" + }); + expect(quote?.source?.url).toContain("volcengine.com/docs/82379/1544106"); + }); + + it("uses the conservative input-video upper bound for the initial Seedance reserve", async () => { + const quote = await quoteGenerationCharge({ + provider: "seedance", + capability: "video.generate", + reqKey: "doubao-seedance-2-0-260128", + requestPayload: { + settings: { duration: 5, resolution: "720p", ratio: "9:16" }, + assembled: { materials: [{ type: "video", url: "https://example.com/reference.mp4" }] } + }, + usageContext: { + source: "platform", + accountId: "user-1", + displayName: "测试用户", + organizationId: "org-1" + } + }); + expect(quote).toMatchObject({ + amountFen: 1452, + reservedAmountFen: 1452, + settlementStatus: "pending", + parameters: { inputVideo: true } + }); + }); + + it("matches EvoLink quality tiers from the platform catalog", async () => { + const quote = await quoteGenerationCharge({ + provider: "evolink", + capability: "image.generate", + reqKey: "gpt-image-2", + requestPayload: { input: { quality: "high" }, providerPayload: { resolution: "1K", size: "1:1" } }, + usageContext: { + source: "platform", + accountId: "user-1", + displayName: "测试用户", + organizationId: "org-1" + } + }); + expect(quote).toMatchObject({ standardUnitPriceFen: 136, amountFen: 164, markupMultiplier: 1.2 }); + }); + + it("settles Seedance from actual completion tokens with an idempotent difference entry", async () => { + const quote = await quoteGenerationCharge({ + provider: "seedance", + capability: "video.generate", + reqKey: "doubao-seedance-2-0-260128", + requestPayload: { settings: { duration: 5, resolution: "720p", ratio: "16:9" } }, + usageContext: { + source: "platform", + accountId: "user-1", + displayName: "测试用户", + organizationId: "org-1" + } + }); + expect(quote?.amountFen).toBe(597); + await postOrganizationTopUp({ + organizationId: "org-1", + amountFen: 5000, + idempotencyKey: "seedance-settlement-recharge" + }); + const job = await createGenerationJob({ + ownerId: "user-1", + capability: "video.generate", + provider: "seedance", + reqKey: "doubao-seedance-2-0-260128", + status: "running", + prompt: "测试", + inputAssetIds: [], + inputUrls: [], + outputAssetIds: [], + requestPayload: { settings: { duration: 5, resolution: "720p", ratio: "16:9" } }, + usageContext: { + source: "platform", + accountId: "user-1", + displayName: "测试用户", + organizationId: "org-1" + }, + billing: quote + }); + const charged = await chargeGenerationJob(job); + const settled = await settleSeedanceGenerationCharge(charged, 100_000); + expect(settled?.billing).toMatchObject({ + amountFen: 552, + reservedAmountFen: 597, + settlementStatus: "settled", + providerUsage: { + completionTokens: 100_000, + resolution: "720p", + inputVideo: false, + tokenPriceFenPerMillion: 4600 + } + }); + const repeated = await settleSeedanceGenerationCharge(settled!, 130_000); + expect(repeated?.billing?.amountFen).toBe(552); + const ledger = await listBillingLedgerEntries({ organizationId: "org-1", limit: 10 }); + expect(ledger.map((entry) => entry.kind)).toEqual(["refund", "charge", "recharge"]); + expect(ledger[0].deltaFen).toBe(45); + }); + + it("keeps the frozen Seedance estimate when the provider omits usage", async () => { + const quote = await quoteGenerationCharge({ + provider: "seedance", + capability: "video.generate", + reqKey: "doubao-seedance-2-0-260128", + requestPayload: { settings: { duration: 5, resolution: "720p" } }, + usageContext: { + source: "platform", + accountId: "user-1", + displayName: "测试用户", + organizationId: "org-1" + } + }); + await postOrganizationTopUp({ organizationId: "org-1", amountFen: 1000, idempotencyKey: "seedance-no-usage-recharge" }); + const job = await createGenerationJob({ + ownerId: "user-1", + capability: "video.generate", + provider: "seedance", + reqKey: "doubao-seedance-2-0-260128", + status: "running", + prompt: "测试", + inputAssetIds: [], + inputUrls: [], + outputAssetIds: [], + requestPayload: { settings: { duration: 5, resolution: "720p" } }, + usageContext: { + source: "platform", + accountId: "user-1", + displayName: "测试用户", + organizationId: "org-1" + }, + billing: quote + }); + const charged = await chargeGenerationJob(job); + const settled = await settleSeedanceGenerationCharge(charged); + expect(settled?.billing).toMatchObject({ amountFen: 597, settlementStatus: "estimated" }); + expect((await listBillingLedgerEntries({ organizationId: "org-1", limit: 10 })).map((entry) => entry.kind)).toEqual(["charge", "recharge"]); + }); + + it("combines platform parameter tiers and charges high quality at its own standard rate", async () => { + await createBillingPriceRule({ + id: "rule-parameter-dimensions", + provider: "evolink", + capability: "image.generate", + reqKey: "gpt-image-2-dimensions", + unit: "image", + standardUnitPriceFen: 34, + markupMultiplier: 1.5, + enabled: true, + parameterDimensions: [ + { + key: "quality", + label: "生成质量", + baselineValue: "medium", + defaultValue: "medium", + tiers: [ + { value: "medium", label: "标准", standardFactor: 1, markupMultiplier: 1.5, enabled: true }, + { value: "high", label: "精细", standardFactor: 4, markupMultiplier: 1.5, enabled: true } + ] + }, + { + key: "resolution", + label: "分辨率", + baselineValue: "1K", + defaultValue: "1K", + tiers: [ + { value: "1K", label: "1K", standardFactor: 1, markupMultiplier: 1.5, enabled: true }, + { value: "2K", label: "2K", standardFactor: 4, markupMultiplier: 1.5, enabled: true } + ] + } + ] + }); + + const baseInput = { + provider: "evolink" as const, + capability: "image.generate" as const, + reqKey: "gpt-image-2-dimensions", + usageContext: { source: "platform" as const, accountId: "user-1", displayName: "测试用户", organizationId: "org-1" } + }; + const medium = await quoteGenerationCharge({ + ...baseInput, + requestPayload: { input: { quality: "medium" }, providerPayload: { resolution: "1K" } } + }); + const high = await quoteGenerationCharge({ + ...baseInput, + requestPayload: { input: { quality: "high" }, providerPayload: { resolution: "1K" } } + }); + expect(medium).toMatchObject({ standardUnitPriceFen: 34, markupMultiplier: 1.5, amountFen: 51 }); + expect(high).toMatchObject({ standardUnitPriceFen: 136, markupMultiplier: 1.5, amountFen: 204 }); + expect(high?.parameterTiers).toEqual(expect.arrayContaining([ + expect.objectContaining({ dimensionKey: "quality", label: "精细", standardUnitPriceFen: 136 }) + ])); + + await updateBillingPriceTierMultiplier({ ruleId: "rule-parameter-dimensions", dimensionKey: "quality", tierValue: "high", markupMultiplier: 2 }); + const adjustedHigh = await quoteGenerationCharge({ + ...baseInput, + requestPayload: { input: { quality: "high" }, providerPayload: { resolution: "1K" } } + }); + expect(adjustedHigh).toMatchObject({ standardUnitPriceFen: 136, markupMultiplier: 2, amountFen: 272 }); + }); + + it("charges once, keeps the organization wallet shared, and refunds terminal failures", async () => { + await createBillingPriceRule({ + id: "rule-image", + provider: "volcengine-visual", + capability: "image.generate", + unit: "image", + standardUnitPriceFen: 35, + markupMultiplier: 1.5, + enabled: true + }); + await postWalletEntry({ + organizationId: "org-1", + kind: "recharge", + deltaFen: 1000, + idempotencyKey: "recharge-1", + description: "测试充值" + }); + + const quote = await quoteGenerationCharge({ + provider: "volcengine-visual", + capability: "image.generate", + reqKey: "jimeng_seedream46_cvtob", + requestPayload: { providerPayload: { n: 2 } }, + usageContext: { + source: "platform", + accountId: "user-1", + displayName: "测试用户", + organizationId: "org-1" + } + }); + expect(quote).toMatchObject({ amountFen: 105, quantity: 2, status: "pending" }); + + const job = await createGenerationJob({ + ownerId: "user-1", + capability: "image.generate", + provider: "volcengine-visual", + reqKey: "jimeng_seedream46_cvtob", + status: "queued", + prompt: "测试", + inputAssetIds: [], + inputUrls: [], + outputAssetIds: [], + requestPayload: { providerPayload: { n: 2 } }, + usageContext: { + source: "platform", + accountId: "user-1", + displayName: "测试用户", + organizationId: "org-1" + }, + billing: quote + }); + + const charged = await chargeGenerationJob(job); + expect(charged.billing?.status).toBe("charged"); + expect((await getGenerationJob(job.id))?.billing?.ledgerEntryId).toBeTruthy(); + expect((await postWalletEntry({ + organizationId: "org-1", + kind: "charge", + deltaFen: -105, + accountId: "user-1", + jobId: job.id, + idempotencyKey: `job-charge:${job.id}`, + description: "重复扣费" + })).entry.id).toBe(charged.billing?.ledgerEntryId); + + const failed = await updateGenerationJob(charged.id, { status: "failed" }); + const refunded = await refundGenerationCharge(failed, "任务失败"); + expect(refunded?.billing?.status).toBe("refunded"); + const ledger = await listBillingLedgerEntries({ organizationId: "org-1", limit: 20 }); + expect(ledger.map((entry) => entry.kind)).toEqual(["refund", "charge", "recharge"]); + expect(ledger[0].deltaFen).toBe(105); + expect(ledger[0].balanceAfterFen).toBe(1000); + }); + + it("blocks an ordinary generation before provider dispatch when the organization balance is insufficient", async () => { + await createBillingPriceRule({ + id: "rule-insufficient-image", + provider: "volcengine-visual", + capability: "image.generate", + unit: "image", + standardUnitPriceFen: 35, + markupMultiplier: 1.2, + enabled: true + }); + const quote = await quoteGenerationCharge({ + provider: "volcengine-visual", + capability: "image.generate", + reqKey: "jimeng_seedream46_cvtob", + requestPayload: { providerPayload: { n: 1 } }, + usageContext: { + source: "platform", + accountId: "user-empty", + displayName: "余额不足用户", + organizationId: "org-empty" + } + }); + const job = await createGenerationJob({ + ownerId: "user-empty", + capability: "image.generate", + provider: "volcengine-visual", + reqKey: "jimeng_seedream46_cvtob", + status: "queued", + prompt: "测试", + inputAssetIds: [], + inputUrls: [], + outputAssetIds: [], + requestPayload: { providerPayload: { n: 1 } }, + usageContext: { + source: "platform", + accountId: "user-empty", + displayName: "余额不足用户", + organizationId: "org-empty" + }, + billing: quote + }); + + await expect(chargeGenerationJob(job)).rejects.toBeInstanceOf(InsufficientBalanceError); + expect((await getGenerationJob(job.id))?.billing?.status).toBe("pending"); + expect(await listBillingLedgerEntries({ organizationId: "org-empty", limit: 10 })).toEqual([]); + }); + + it("rejects real platform generation without an organization rule or balance", async () => { + await expect(quoteGenerationCharge({ + provider: "volcengine-visual", + capability: "image.generate", + reqKey: "jimeng_seedream46_cvtob", + requestPayload: { providerPayload: { n: 1 } }, + usageContext: { source: "platform", accountId: "user-1", displayName: "测试用户" } + })).rejects.toBeInstanceOf(BillingConfigurationError); + + await expect(postWalletEntry({ + organizationId: "org-empty", + kind: "charge", + deltaFen: -1, + idempotencyKey: "charge-empty", + description: "余额不足" + })).rejects.toBeInstanceOf(InsufficientBalanceError); + }); + + it("lets an unbound super-admin calculate cost without consuming organization quota", async () => { + const quote = await quoteGenerationCharge({ + provider: "evolink", + capability: "image.generate", + reqKey: "gpt-image-2", + requestPayload: { + input: { quality: "high", width: 1440, height: 2560, materials: [{ type: "image", url: "https://example.com/ref.jpg" }] }, + providerPayload: { model: "gpt-image-2", resolution: "1K", size: "9:16" } + }, + usageContext: { source: "platform", accountId: "super-admin", displayName: "平台超级管理员", role: "super_admin" } + }); + expect(quote).toMatchObject({ amountFen: 164, status: "pending", quotaExempt: true }); + + const job = await createGenerationJob({ + ownerId: "super-admin", + capability: "image.generate", + provider: "evolink", + reqKey: "gpt-image-2", + status: "queued", + prompt: "测试", + inputAssetIds: [], + inputUrls: [], + outputAssetIds: [], + requestPayload: { + input: { quality: "high", width: 1440, height: 2560, materials: [{ type: "image", url: "https://example.com/ref.jpg" }] }, + providerPayload: { model: "gpt-image-2", resolution: "1K", size: "9:16" } + }, + usageContext: { source: "platform", accountId: "super-admin", displayName: "平台超级管理员", role: "super_admin" }, + billing: quote + }); + const charged = await chargeGenerationJob(job); + expect(charged.billing).toMatchObject({ amountFen: 164, status: "not_charged", quotaExempt: true }); + expect(await listBillingLedgerEntries({ limit: 10 })).toEqual([]); + + const succeeded = await updateGenerationJob(charged.id, { status: "succeeded" }); + const usage = await recordUsageForJob(succeeded); + expect(usage).toMatchObject({ chargedAmountFen: 164, currency: "CNY" }); + expect(await listUsageEvents({ source: "platform" })).toHaveLength(1); + }); + + it("settles a super-admin Seedance cost snapshot without touching a wallet", async () => { + const quote = await quoteGenerationCharge({ + provider: "seedance", + capability: "video.generate", + reqKey: "doubao-seedance-2-0-260128", + requestPayload: { settings: { duration: 5, resolution: "720p", ratio: "16:9" } }, + usageContext: { source: "platform", accountId: "super-admin", displayName: "平台超级管理员", role: "super_admin" } + }); + const job = await createGenerationJob({ + ownerId: "super-admin", + capability: "video.generate", + provider: "seedance", + reqKey: "doubao-seedance-2-0-260128", + status: "running", + prompt: "测试", + inputAssetIds: [], + inputUrls: [], + outputAssetIds: [], + requestPayload: { settings: { duration: 5, resolution: "720p", ratio: "16:9" } }, + usageContext: { source: "platform", accountId: "super-admin", displayName: "平台超级管理员", role: "super_admin" }, + billing: quote + }); + + const charged = await chargeGenerationJob(job); + const settled = await settleSeedanceGenerationCharge(charged, 100_000); + expect(settled?.billing).toMatchObject({ + amountFen: 552, + reservedAmountFen: 597, + status: "not_charged", + quotaExempt: true, + settlementStatus: "settled" + }); + expect(await listBillingLedgerEntries({ limit: 10 })).toEqual([]); + }); + + it("keeps an unbound ordinary account from submitting a real charge", async () => { + await expect(quoteGenerationCharge({ + provider: "evolink", + capability: "image.generate", + reqKey: "gpt-image-2", + requestPayload: { providerPayload: { model: "gpt-image-2", resolution: "1K", size: "1:1" } }, + usageContext: { source: "platform", accountId: "user-unbound", displayName: "未绑定普通用户" } + })).rejects.toBeInstanceOf(BillingConfigurationError); + }); + + it("keeps top-ups and manual balance adjustments on the organization ledger without personal attribution", async () => { + const credited = await postOrganizationTopUp({ + organizationId: "org-1", + amountFen: 5000, + idempotencyKey: "manual-adjustment-credit", + description: "管理员上账" + }); + expect(credited.wallet.balanceFen).toBe(5000); + expect(credited.entry.accountId).toBeUndefined(); + expect(credited.entry.kind).toBe("recharge"); + + const directCredited = await postWalletEntry({ + organizationId: "org-1", + accountId: "member-1", + kind: "recharge", + deltaFen: 100, + idempotencyKey: "manual-adjustment-direct-credit", + description: "兼容路径上账" + }); + expect(directCredited.entry.accountId).toBeUndefined(); + + const debited = await postWalletEntry({ + organizationId: "org-1", + accountId: "member-1", + kind: "adjustment", + deltaFen: -1200, + idempotencyKey: "manual-adjustment-debit", + description: "管理员扣减" + }); + expect(debited.wallet.balanceFen).toBe(3900); + expect(debited.entry.accountId).toBeUndefined(); + expect((await listBillingLedgerEntries({ organizationId: "org-1", accountId: "member-1", limit: 10 })).map((entry) => entry.deltaFen)).toEqual([]); + }); +}); diff --git a/tests/evolink-image-client.test.ts b/tests/evolink-image-client.test.ts index 2cc6c3e..2e68c18 100644 --- a/tests/evolink-image-client.test.ts +++ b/tests/evolink-image-client.test.ts @@ -7,7 +7,7 @@ import { } from "@/lib/evolink/image-client"; describe("EvoLink image client helpers", () => { - it("builds payloads for GPT Image 2 generation", () => { + it("builds GPT Image 2 payloads with the platform 1K resolution", () => { const payload = buildEvolinkImagePayload("image.generate", { prompt: "商品海报", imageUrls: ["https://example.com/ref.png"], @@ -16,8 +16,7 @@ describe("EvoLink image client helpers", () => { }, { baseUrl: "https://api.evolink.ai", model: "gpt-image-2", - quality: "medium", - resolution: "2K" + quality: "medium" }); expect(payload).toMatchObject({ @@ -26,29 +25,11 @@ describe("EvoLink image client helpers", () => { image_urls: ["https://example.com/ref.png"], size: "1:1", quality: "medium", - resolution: "2K", + resolution: "1K", n: 1 }); }); - it("maps inpainting original and mask URLs", () => { - const payload = buildEvolinkImagePayload("image.inpaint", { - prompt: "移除背景杂物", - quality: "high", - imageUrls: ["https://example.com/original.png", "https://example.com/mask.png"] - }, { - baseUrl: "https://api.evolink.ai", - model: "gpt-image-2", - quality: "medium" - }); - - expect(payload).toMatchObject({ - image_urls: ["https://example.com/original.png"], - mask_url: "https://example.com/mask.png", - quality: "high" - }); - }); - it("normalizes task ids, statuses, and result URLs", () => { const response = { data: { diff --git a/tests/frontend-environment-copy.test.ts b/tests/frontend-environment-copy.test.ts new file mode 100644 index 0000000..dbb21ae --- /dev/null +++ b/tests/frontend-environment-copy.test.ts @@ -0,0 +1,40 @@ +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const forbiddenCopy = [ + "本地", + "开发环境", + "Mock", + ".runtime/", + "演示用户" +]; + +describe("production-facing copy", () => { + it("does not expose development or implementation labels in React UI", async () => { + const files = [ + ...await sourceFiles(join(process.cwd(), "app")), + ...await sourceFiles(join(process.cwd(), "components")) + ]; + const matches: string[] = []; + + for (const file of files) { + const source = await readFile(file, "utf8"); + for (const copy of forbiddenCopy) { + if (source.includes(copy)) matches.push(`${file}: ${copy}`); + } + } + + expect(matches).toEqual([]); + }); +}); + +async function sourceFiles(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + const files = await Promise.all(entries.map(async (entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return sourceFiles(path); + return entry.isFile() && path.endsWith(".tsx") ? [path] : []; + })); + return files.flat(); +} diff --git a/tests/jimeng-capabilities.test.ts b/tests/jimeng-capabilities.test.ts index d0723d2..f5a655e 100644 --- a/tests/jimeng-capabilities.test.ts +++ b/tests/jimeng-capabilities.test.ts @@ -7,18 +7,10 @@ import { } from "@/lib/jimeng/capabilities"; describe("Jimeng capability matrix", () => { - it("only exposes the three supported image capabilities", () => { + it("only exposes image generation", () => { const capabilities = getJimengCapabilities(); - expect(Object.keys(capabilities)).toEqual([ - "image.generate", - "image.inpaint", - "image.upscale" - ]); - expect(getVisibleImageCapabilities().map((capability) => capability.id)).toEqual([ - "image.generate", - "image.inpaint", - "image.upscale" - ]); + expect(Object.keys(capabilities)).toEqual(["image.generate"]); + expect(getVisibleImageCapabilities().map((capability) => capability.id)).toEqual(["image.generate"]); }); it("builds payloads for image generation 4.6", () => { @@ -41,14 +33,6 @@ describe("Jimeng capability matrix", () => { }); }); - it("requires original and mask URLs for inpainting", () => { - expect(() => - buildJimengPayload("image.inpaint", "jimeng_image2image_dream_inpaint", { - imageUrls: ["https://example.com/original.png"] - }) - ).toThrow(/exactly two/); - }); - it("uses return_url query payload for polling", () => { const payload = buildJimengQueryPayload("jimeng_i2i_seed3_tilesr_cvtob", "task-1"); expect(payload.req_key).toBe("jimeng_i2i_seed3_tilesr_cvtob"); diff --git a/tests/organization-client.test.ts b/tests/organization-client.test.ts index a77f154..df87eba 100644 --- a/tests/organization-client.test.ts +++ b/tests/organization-client.test.ts @@ -18,6 +18,8 @@ describe("organization account client", () => { it("tracks required organization and staff API configuration separately", () => { vi.stubEnv("ZHINIAN_ORG_API_BASE_URL", "https://gateway.example.com/basic"); vi.stubEnv("ZHINIAN_ORG_API_TOKEN", "org-token"); + vi.stubEnv("ZHINIAN_STAFF_API_BASE_URL", ""); + vi.stubEnv("ZHINIAN_ORG_TENANT_ID", ""); expect(getOrganizationApiConfig()).toMatchObject({ configured: true, diff --git a/tests/seedance-client.test.ts b/tests/seedance-client.test.ts new file mode 100644 index 0000000..892eb22 --- /dev/null +++ b/tests/seedance-client.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { extractSeedanceUsage } from "@/lib/seedance/client"; + +describe("Seedance usage extraction", () => { + it("reads completion_tokens from top-level and nested provider responses", () => { + expect(extractSeedanceUsage({ usage: { completion_tokens: 12345 } })).toEqual({ completionTokens: 12345 }); + expect(extractSeedanceUsage({ data: { usage: { completionTokens: 67890 } } })).toEqual({ completionTokens: 67890 }); + expect(extractSeedanceUsage({ usage: { prompt_tokens: 100 } })).toBeUndefined(); + }); +}); diff --git a/tests/task-management.test.ts b/tests/task-management.test.ts index e78eead..0a6587e 100644 --- a/tests/task-management.test.ts +++ b/tests/task-management.test.ts @@ -29,6 +29,11 @@ const envNames = [ "EVOLINK_API_KEY", "VOLCENGINE_ACCESS_KEY_ID", "VOLCENGINE_SECRET_ACCESS_KEY", + "ALI_OSS_ENDPOINT", + "ALI_OSS_BUCKET", + "ALI_OSS_ACCESS_KEY_ID", + "ALI_OSS_ACCESS_KEY_SECRET", + "ALI_OSS_PUBLIC_BASE_URL", "ZHINIAN_WEBHOOK_SECRET" ]; @@ -46,6 +51,11 @@ describe("task management and public API helpers", () => { 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; + delete process.env.ALI_OSS_BUCKET; + delete process.env.ALI_OSS_ACCESS_KEY_ID; + delete process.env.ALI_OSS_ACCESS_KEY_SECRET; + delete process.env.ALI_OSS_PUBLIC_BASE_URL; }); afterEach(async () => { diff --git a/tests/usage-service.test.ts b/tests/usage-service.test.ts new file mode 100644 index 0000000..9f99c1a --- /dev/null +++ b/tests/usage-service.test.ts @@ -0,0 +1,179 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + createGenerationJob, + deleteGenerationJob, + listUsageEvents, + recordUsageEvent, + recordUsageForJob +} from "@/lib/server/data-store"; +import { getAdminUsageReport, getPersonalUsageReport } from "@/lib/server/usage-service"; +import { usagePresetRange } from "@/lib/usage"; +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"]; + +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; + }); + + afterEach(async () => { + for (const name of envNames) restoreEnv(name, previousEnv.get(name)); + previousEnv.clear(); + await rm(runtimeDir, { force: true, recursive: true }); + }); + + it("uses China Standard Time for preset boundaries", () => { + const range = usagePresetRange("month", new Date("2026-07-27T16:30:00.000Z")); + expect(range).toMatchObject({ + startDate: "2026-07-01", + endDate: "2026-07-28", + from: "2026-07-01T00:00:00+08:00", + to: "2026-07-29T00:00:00+08:00", + dayCount: 28 + }); + }); + + it("records one immutable event per real platform job", async () => { + const job = await createUsageJob("job-platform", platformContext("owner-a", "tenant-a", "org-a")); + const first = await recordUsageForJob(job); + const second = await recordUsageForJob(job); + expect(second?.id).toBe(first?.id); + expect(await listUsageEvents({ source: "platform" })).toHaveLength(1); + + await deleteGenerationJob(job.id); + const retained = await listUsageEvents({ source: "platform" }); + expect(retained).toHaveLength(1); + expect(retained[0]).toMatchObject({ + jobId: job.id, + quantity: 1, + estimatedUnit: "job", + organizationId: "org-a" + }); + }); + + it("does not meter mock jobs or public API clients", async () => { + const mock = await createUsageJob("job-mock", platformContext("owner-a"), { provider: "mock" }); + const api = await createUsageJob("job-api", undefined, { externalClientId: "partner-a" }); + expect(await recordUsageForJob(mock)).toBeNull(); + expect(await recordUsageForJob(api)).toBeNull(); + expect(await listUsageEvents()).toHaveLength(0); + }); + + it("builds personal and administrator reports from distinct successful jobs", async () => { + await recordUsageEvent({ + ownerId: "owner-a", + jobId: "job-a", + source: "platform", + capability: "image.generate", + provider: "bailian", + reqKey: "wan2.2-t2i-plus", + accountDisplayName: "账号 A", + tenantId: "tenant-a", + organizationId: "org-a", + organizationName: "组织 A", + quantity: 4, + estimatedUnit: "image", + createdAt: "2026-07-03T01:00:00.000Z" + }); + await recordUsageEvent({ + ownerId: "owner-b", + jobId: "job-b", + source: "platform", + capability: "video.generate", + provider: "seedance", + accountDisplayName: "账号 B", + quantity: 1, + estimatedUnit: "job", + createdAt: "2026-07-04T01:00:00.000Z" + }); + await recordUsageEvent({ + ownerId: "api:partner-a", + jobId: "job-api", + source: "api", + capability: "image.generate", + provider: "bailian", + quantity: 1, + estimatedUnit: "job", + createdAt: "2026-07-05T01:00:00.000Z" + }); + await recordUsageEvent({ + ownerId: "owner-a", + jobId: "job-mock", + source: "platform", + capability: "image.generate", + provider: "mock", + quantity: 1, + estimatedUnit: "job", + createdAt: "2026-07-06T01:00:00.000Z" + }); + + const now = new Date("2026-07-28T04:00:00.000Z"); + const personal = await getPersonalUsageReport("owner-a", "month", now); + expect(personal.total).toBe(1); + expect(personal.byCapability.find((item) => item.key === "image.generate")?.count).toBe(1); + + const report = await getAdminUsageReport({}, [{ + organizationId: "org-a", + organizationName: "组织 A", + organizationBindTenantId: 1 + }], now); + expect(report.summary).toMatchObject({ total: 2, activeAccounts: 2, activeOrganizations: 1 }); + expect(report.organizations.map((row) => row.organizationName)).toEqual(["组织 A", "未归属组织"]); + + const filtered = await getAdminUsageReport({ organizationId: "org-a" }, [], now); + expect(filtered.summary.total).toBe(1); + expect(filtered.accounts[0]?.accountName).toBe("账号 A"); + expect(filtered.options.accounts.map((option) => option.value)).toEqual(["owner-a"]); + }); +}); + +async function createUsageJob( + id: string, + usageContext?: UsageContext, + overrides: Partial = {} +): Promise { + return createGenerationJob({ + id, + ownerId: usageContext?.accountId || "api:partner-a", + externalClientId: overrides.externalClientId, + capability: "image.generate", + provider: overrides.provider || "bailian", + reqKey: "wan2.2-t2i-plus", + status: "running", + inputAssetIds: [], + inputUrls: [], + outputAssetIds: [], + requestPayload: {}, + usageContext + }); +} + +function platformContext(accountId: string, tenantId?: string, organizationId?: string): UsageContext { + return { + source: "platform", + accountId, + username: accountId, + displayName: accountId, + tenantId, + organizationId, + organizationName: organizationId ? "组织 A" : undefined + }; +} + +function restoreEnv(name: string, value: string | undefined) { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +} diff --git a/tsconfig.json b/tsconfig.json index db4e547..aa46979 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2022", - "lib": ["dom", "dom.iterable", "es2022"], + "lib": [ + "dom", + "dom.iterable", + "es2022" + ], "allowJs": false, "skipLibCheck": true, "strict": true, @@ -19,9 +23,20 @@ } ], "paths": { - "@/*": ["./*"] + "@/*": [ + "./*" + ] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules", "runtime"] + "include": [ + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "next-env.d.ts", + ".next-dev/types/**/*.ts" + ], + "exclude": [ + "node_modules", + "runtime" + ] }