import "server-only" import type { NextRequest } from "next/server" import { runWithAiCredentials } from "@/lib/ai/credentials" import { runWithBilling, type BillingContext } from "@/lib/credits/context" import { assertCanSpendText, ensureSignupGrant } from "@/lib/credits/store" import { jsonError } from "@/lib/http" import { requireProjectAccess } from "@/lib/projects/access" import { resolvePlatformCredentials } from "./platform" // 会调 AI 的项目级路由,统一用这个包一层:项目权限 + **操作者本人**的服务密钥 + 凭证作用域。 // // export const POST = withProjectAi(async (request, { projectId, session }) => { // ...原来的逻辑;里面所有 AI 调用自动用这个人的 key,不用传参... // }) // // 密钥取的是【当前登录的人】,不是【项目所属的工作区】:密钥是私人的,加入别人的工作区 // 共享的是资产(项目、技巧库、素材),不是花钱的能力。在别人的工作区里跑生成,烧的是 // 自己的额度 —— 这也意味着**没配密钥的成员进来只能看,不能生成**,那是刻意的。 // // 为什么是包装器,而不是「守卫函数里 enterWith 一下」: // AsyncLocalStorage.enterWith 只影响【当前同步执行及其后续异步续接】。在一个被 await 的 // 辅助函数里调它,调用方 resume 时用的是自己的上下文,store 直接丢——踩过这个坑: // 路由明明配了 key 还报「尚未配置」。包装器把 handler 整体放进 runWithAiCredentials, // 作用域从头覆盖到尾(含 fire-and-forget 的后台生成任务),想漏都漏不掉。 // // 另一个坑记在这:凭证要在【构造模型/客户端时】就取走,别等发请求时再取——流式回复的 // HTTP 请求可能在 handler 返回之后才发出。lib/ai/providers/evolink.ts 的 chatConfig() 如此。 type ProjectGate = Extract>, { ok: true }> export type AiRouteArgs = { projectId: string session: ProjectGate["session"] } type ProjectRouteContext = { params: Promise<{ projectId: string }> } export function withProjectAi( handler: (request: NextRequest, args: AiRouteArgs) => Promise, ) { return async (request: NextRequest, context: ProjectRouteContext): Promise => { const { projectId } = await context.params const gate = await requireProjectAccess(request, projectId) if (!gate.ok) return gate.response // 密钥是**全站一把**(管理员在 /admin/ai 配),所有人一律走积分。 // 没配就如实说是**平台**没配 —— 别让用户以为是自己少填了什么 const credentials = await resolvePlatformCredentials() if (!credentials) { return jsonError("平台还没有配置 AI 服务密钥,请联系管理员在「管理 → AI 服务」填写", 503) } const billing: BillingContext = { userId: gate.session.user.id, projectId } // 新用户先发一次赠送积分(幂等),否则注册完一次都跑不了 await ensureSignupGrant(billing.userId) // 余额为 0 就别让这一轮开始。**这道闸主要是为文本加的**:文本按实际 token // 后置结算,拦不住一个已经生成完的回复,只能在入口挡一次。 // 图片/视频本来就会在扣费时精确报「还差多少」,这里提前失败只是更友好。 // 判断收敛在 assertCanSpendText 一处 —— 它认 markup.text,所以把文本倍率调回 0 //(「文本免费」)时,余额为 0 的人照样能聊天,只是不能出图。 try { await assertCanSpendText(billing.userId) } catch { return jsonError("积分已用完,请先充值后再继续", 402) } // 两层作用域都要从 handler 的头覆盖到尾:扣费点(网关装饰器)埋在 LangGraph 节点和 // 后台任务里,和凭证同一个理由——包起来就漏不掉 return runWithAiCredentials(credentials, () => runWithBilling(billing, () => handler(request, { projectId, session: gate.session })), ) } }