Civitai 的@civitai/buzz包:Buzz 服务类型化 HTTP 客户端与共享货币化规则解析
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
导读
@civitai/buzz是 Civitai 仓库(packages/civitai-buzz)中面向服务端的 Buzz 服务客户端,统一封装了 buzz 服务的fetch + 重试 + 状态码→异常的 HTTP 传输层,并为每个端点提供类型化方法,同时以纯函数、浏览器安全的方式承载账户类型映射与货币化规则,被 Next.js 主应用与 SvelteKit 各 spoke 共同复用。读完本文,你将掌握该包的边界划分、客户端初始化与全部端点方法、BUZZ_ENDPOINT环境变量契约、错误映射与可安全重试语义,以及配套的授权费、付费访问、定价额度等纯规则模块的设计意图与调用方式。
包的定位与设计边界
一个被两个应用共享的"单一 buzz 传输层"
仓库中同时存在多个消费方:Next.js 主应用(src/server/services/buzz.service.ts中的buzzService)与 SvelteKit 的 creator-studio spoke(apps/creator-studio/src/lib/server/buzz.ts)。在引入本包之前,各应用各自手写 buzz HTTP 调用,容易产生重复与漂移。本包的 README 明确其设计意图:
Shared by the main app and the SvelteKit spokes so nobody hand-rolls the buzz HTTP transport.
即"让任何人都不再手写 buzz 的 HTTP 传输层"。包内client.ts头部注释进一步界定了职责:
Owns the HTTP transport (fetch + retry + status→error) and typed per-endpoint methods. Domain concerns (balance pre-checks, entity lookups, DB co-writes) stay in the consuming app.
也就是说,客户端只负责传输与端点方法,而余额预检、实体归属查询、数据库协同写入等更上层的"buzz 编排"逻辑保留在消费应用内(或未来建立在它之上的@civitai/monetization中)。
服务端专属:浏览器中不可调用
Buzz 服务是内部服务(BUZZ_ENDPOINT指向的 .NET/Postgres API),永不对外暴露,因此:
- client(
createBuzzClient)是 server-only,禁止在浏览器代码中调用; - account-types 辅助模块(
toApiType、BuzzAccountType等)是纯函数、浏览器安全,可安全地在客户端代码中使用。
这一约束也体现在消费端:creator-studio 的apps/creator-studio/src/lib/server/buzz.ts将客户端惰性构造并缓存在globalThis上(vite build阶段不解析端点,dev HMR 复用同一实例):
import { createBuzzClient } from '@civitai/buzz'; type BuzzClient = ReturnType<typeof createBuzzClient>; const g = globalThis as unknown as { buzzClient?: BuzzClient }; export function getBuzz(): BuzzClient { if (!g.buzzClient) g.buzzClient = createBuzzClient(); return g.buzzClient; }以"raw"方式发布:消费方自行转译
package.json中"main": "./src/index.ts"、"types": "./src/index.ts",即包以原始 TypeScript 发布,与其他@civitai/*包保持一致,由消费方转译:
- Next 主应用在 next.config.mjs 的
transpilePackages中列出@civitai/buzz; - creator-studio 在 apps/creator-studio/vite.config.ts 的
ssr.noExternal中列出@civitai/buzz(源码注释:@civitai/* packages ship raw TS (main: ./src/index.ts) — let Vite transpile them)。
其运行依赖极简:仅@civitai/shared(workspace)与zod,测试通过vitest(vitest.config.ts限定src/**/*.test.ts,Node 环境)。
快速上手:初始化客户端
基础用法
import { createBuzzClient } from '@civitai/buzz'; const buzzService = createBuzzClient({ // endpoint 可选,缺省时读取 BUZZ_ENDPOINT 环境变量 mapError: (e) => { /* 可选:把 BuzzApiError.status 转成你的框架错误类型 */ }, }); const account = await buzzService.getUserBuzzByAccountType(userId, 'yellow'); const report = await buzzService.getUserTransactionsReport(userId, query); await buzzService.createTransaction({ fromAccountId, toAccountId, amount, type, /* … */ });createBuzzClient(options)接受CreateBuzzClientOptions(定义于 client.ts),关键选项如下:
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
endpoint | string | BUZZ_ENDPOINT | 覆盖环境变量指定的服务基地址 |
retries | number | 3 | 失败重试次数,与主应用此前行为一致 |
transactionTimeoutMs | number | 无(不超时) | 写调用的默认请求超时;缺省保持历史的无界行为 |
log | BuzzLogFn | no-op | 调试日志函数(应用自定义) |
mapError | (error: BuzzApiError) => unknown | 直接抛BuzzApiError | 重试耗尽后将错误映射为消费方自己的错误(如 tRPC/HTTP) |
端点方法清单
客户端返回带类型的端点方法(全部在 client.ts 中返回):
- 读:
getAccount、getUserBuzzByAccountType、getUserAccounts、getAccountTransactions、getUserTransactionsReport、getAccountBalances、getAccountSummary、getContributors、getCounterparties、previewMultiTransaction、listMultiTransactions,另有getTransactionByExternalId(对 404 返回null)。 - 写:
createTransaction、createTransactions、refundTransaction、createMultiTransaction、refundMultiTransaction。 - 逃生舱:
request<T>(urlPart, init?)可调用任何尚未封装成方法的端点;此外还有ping(timeoutMs = 1000)用于轻量存活探测(永不抛错,任何异常均返回false,绕过重试与mapError)。
非 2xx 状态码时客户端抛出BuzzApiError(携带status),若提供了mapError则抛出其返回值。
账户类型:友好名 ↔ API 名的唯一契约
账户类型映射是@civitai/buzz承担的最重要的"单一事实来源"。应用侧使用友好账户类型(yellow/blue/green/red、creatorProgramBank、cashPending等),而 buzz 服务端使用 PascalCase 的 API 账户类型(User/Generation/Blue/FakeRed等)。映射表clientToApiAccountType定义于 account-types.ts:
export const clientToApiAccountType: Record<BuzzAccountType, BuzzApiAccountType> = { blue: 'Generation', green: 'Green', yellow: 'User', red: 'FakeRed', creatorProgramBank: 'CreatorProgramBank', creatorProgramBankGreen: 'CreatorProgramBankGreen', cashPending: 'CashPending', cashSettled: 'CashSettled', club: 'Club', };toApiType(type)负责友好→API 映射;toClientType(value)反向解析(同时容忍 API 名、小写 API 名与友好名三种写法,见apiTypesMap的构造);toApiTransaction(transaction)将事务负载中的fromAccountType/toAccountType一并转换。Buzz API 侧完整账户类型枚举buzzApiAccountTypes也在此定义,并在主应用 src/shared/constants/buzz.constants.ts 中重新导出(BuzzTypes类将toApiType委托给包内实现),确保映射只有一个来源、不会漂移;而 UX 配置(buzzTypeConfig的nsfw/purchasable/bankable/disabled等标志)保留在应用侧。
环境变量:BUZZ_ENDPOINT 契约
包内 env.ts 用 zod 定义了包自有环境变量模式:
const isProd = process.env.NODE_ENV === 'production'; const schema = z.object({ BUZZ_ENDPOINT: isProd ? z.url() : z.url().optional(), });| 变量 | 是否必需 | 说明 |
|---|---|---|
BUZZ_ENDPOINT | 仅生产环境 | buzz 服务的基地址 URL;开发环境可选(缺省时客户端按需抛错) |
关键实现细节:
- 惰性且记忆化:
loadBuzzEnv()仅在客户端首次解析端点时才执行 zod 校验并缓存结果,裸import永不触碰process.env,因此构建、测试、脚本阶段不会抛错; - 每次请求惰性解析:
createBuzzClient中的endpoint()每次请求时解析(envOverrides.endpoint ?? loadBuzzEnv().endpoint),缺省且未提供endpoint时抛出Missing BUZZ_ENDPOINT(client.ts)。
传输层原理:查询序列化、重试与错误映射
查询参数序列化
toQueryString(client.ts)将结构化查询对象序列化为查询串:丢弃undefined/null/'',数组展开为重复键,其余值字符串化;无参数时返回空串(不带?)。日期序列化分两种:
isoDate:Date→ 完整 ISO 字符串(用于cursor/start/end等);dateOnly:Date→YYYY-MM-DD(UTC,匹配 report/summary 端点的日期语义)。
各端点查询类型集中在 queries.ts,例如GetAccountTransactionsQuery(type、cursor、start、end、limit、descending)与GetTransactionsReportQuery(accountType、window、start、end)。window取值'hour' | 'day' | 'week' | 'month',getAccountSummary额外支持'year'。
写操作的重试语义:只有"确定未发生"才重试
包内对非幂等写操作的重试做了精心设计。withRetries(client.ts)在缺省谓词时保留历史行为(全部重试);但一旦提供了shouldRetry谓词,谓词不确定(返回非 true 或抛错)时绝不重试——对非幂等写而言,"我不知道"必须等于"不要重发"。
导出的isSafeToRetry(client.ts)是一个刻意收窄的允许清单:
export const isSafeToRetry = (error: unknown) => { const code = (error as { cause?: { code?: string } } | null)?.cause?.code; return ( error instanceof TypeError && (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'EAI_AGAIN') ); };其设计理由(源码注释明确阐述):
- 判断准则是"服务器可能已经执行过这个请求吗?"——只有连接被拒(
ECONNREFUSED)、域名不存在(ENOTFOUND)、DNS 临时失败(EAI_AGAIN)这些 Node fetch 报出的TypeError(携带 syscall 错误码)才能确定"什么都没发生"; - 反例:超时绝不应重试——客户端 abort 不会取消服务端执行,重试超时的非幂等写会把第二个相同 external id 的请求送上线路;504只说明网关放弃,不说明写是否落库;
- 收益:滚动重启导致的几秒拒连可以被客户端毫秒级吸收,而调用方自己的重试循环通常粒度更粗。
BuzzWriteOptions(client.ts)支持按调用覆盖:timeoutMs(opt-in,缺省保持无界——持有锁的调用方需要"请求不再在途"这一事实最终成立,无超时则不存在该时点,锁 TTL 无从设定)、retries(0完全禁用内部重试)、shouldRetry。所有写调用统一经过post()(携带JSON_HEADERS与可配置的timeoutMs/retries/shouldRetry),读调用保持原状。
错误映射:主应用的真实案例
主应用 src/server/services/buzz.service.ts 集中演示了mapError的用法——把 buzz 状态码映射为 tRPC 错误:
export const buzzService = createBuzzClient({ endpoint: env.BUZZ_ENDPOINT, log: isDev ? (message, ...args) => console.log(message, ...args) : undefined, mapError: (error) => { switch (error.status) { case 400: throw throwBadRequestError(null, error); case 404: throw new TRPCError({ code: 'NOT_FOUND', message: 'Not found', cause: error }); case 409: throw throwBadRequestError('There is a conflict with the transaction', error); default: throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'An unexpected error ocurred, please try again later', cause: error, }); } }, });由于该映射是有损的(400 与 409 共享同一 code 与 message),每个分支都把BuzzApiError作为cause保留,调用方需要区分状态码时通过 src/server/utils/buzz-error.ts 的getBuzzApiStatus(error)读取原始 status(兼容BuzzApiError直抛与包在TRPCError内的两种情况)。
响应模型:线上数据的原始形状
responses.ts 定义各端点的线上响应类型(消费方转换前的原始形状),账户类型字段一律为 API 值(PascalCaseBuzzApiAccountType)。要点:
BuzzTransactionResponse:单笔事务(date、type、from/toAccountId、from/toAccountType、amount、可选description/details);GetAccountTransactionsResponse:{ cursor?, transactions[] },游标分页;GetTransactionsReportResponse:BuzzTransactionsReportEntry[],按天聚合accounts[{accountType, spent, gained}];GetAccountBalancesResponse:BuzzAccountBalance[](accountId/accountType/balance);GetAccountSummaryResponse:按账户 id 分组的{ data: BuzzAccountSummaryRecord[], cursor }记录;GetContributorsResponse:按账户分组的BuzzContributor[];GetCounterpartiesResponse:对端聚合(inwardsBalance/outwardsBalance/totalBalance);PreviewMultiTransactionResponse:预演多账户事务,isPossible分支携带remainingAmount,余额不足分支携带shortfall,两者在线上互斥;CreateMultiTransactionResponse/RefundMultiTransactionResponse:返回transactionIds[](含duplicate标记)与退款明细;CreateTransactionResponse:{ transactionId: string | null, remainingBalance: number | null }——当 from 账户未在本地追踪(如 bank/0)时remainingBalance为 null。
共享货币化规则:纯函数模块
@civitai/buzz的价值不止于传输层。仓库将主应用与 creator-studio 必须一致执行的货币化规则下沉为纯函数模块(浏览器安全、无框架依赖),每个模块都配套 vitest 测试。
licensing-fee:授权费的"每张图"换算
licensing-fee.ts 是授权费的唯一换算来源:创作者以整数比"N ⚡ / M 张图"表达授权费(绝不用小数),存储/收费值为每张图费用(ModelVersion.licensingFee DECIMAL(10,2)的 0.01 精度)。核心常量与函数:
MAX_LICENSING_FEE = 100:单图授权费上限(Buzz);VIDEO_CAP_MULTIPLIER = 5:视频生成成本远高于图片,故视频模型的费用上限是图片的 5 倍(不作用于月度定价额度,因为额度计"数"而非"量");FEE_IMAGE_OPTIONS = [1, 10, 20, 50, 100]:创作者 UI 提供的分母(下拉选择而非自由输入),每个已存储费用都能映射到其中之一,保持升序且必须保留 100(0.01 列精度下的最细粒度,保证每个费用精确可表示);DEFAULT_FEE_IMAGES = 10;feeToRatio(perImage):单图费用 → 整数比(用整数百分位运算保持浮点安全),如1 → {1,1}、0.1 → {1,10}、0.5 → {5,10}、0.05 → {1,20}、0.01 → {1,100};null/0 → off;ratioToFee(buzz, images):其逆运算,结果始终是 0.01 的整数倍,满足列与 schema 的multipleOf(0.01);suggestedFeePerImage(modelType, mediaType?):按模型类型给出建议值——Checkpoint 为1(SUGGESTED_FEE_PER_IMAGE = { Checkpoint: 1 }),其余默认0.1(即 1 ⚡/10 张),视频乘 5;maxFeeBuzzForRatio(images, mediaType?):floor(上限 × images),供编辑器在整数域显示"N buzz per M generations"的最大值;- 展示辅助:
formatFeeCadence(count)("per generation"/"per 10 generations")与formatFeeRatio(perImage)("1 ⚡ / generation"、"5 ⚡ / 10 generations"或 "Off")。
media-type:上限的媒体轴
media-type.ts 的capMediaType(baseModel)决定费用上限沿哪条轴解析:基于@civitai/shared/basemodel.constants的getBaseModelMediaType,命中'video'则按视频,其余(含未知 base model、'Other'、音频、3D、双类型生态)一律按图片——图片是两者中更严格的,匹配失败只会少收、绝不会让创作者超出其未挣得的上限。
monetization-limits:一个调用拿到全部上限
monetization-limits.ts 将各上限组装为monetizationLimits({ tier, baseModel })返回的MonetizationLimits:
type MonetizationLimits = { fee: { maxPerGeneration: number; // 每次生成的费用上限;对所有创作者相同,仅媒体轴改变它 denominators: number[]; // 编辑器可提供的分母 }; allowance: { monthlyPrices: number | null }; // 本月可新增价格数;null = 无限 };配套规则(全部有测试佐证,见 monetization-limits.test.ts):
resolveCapTier(membership):唯一的层级归一规则——非会员回退free(而非"无访问"),founder按bronze计,永远返回真实层级(非 null),调用方不再需要到处写?? 'free';- 会员层级只决定月度额度,费用上限对所有人相同(
free与gold的maxPerGeneration相等); suggestedFee刻意不纳入MonetizationLimits:它随模型类型与媒体变化、与层级无关;接收baseModel而非媒体类型,避免调用方命名错误的轴;seedFeeRatio决定编辑器打开时的初值:已有费用优先,否则按类型建议;只有建议分支被denominators钳制,已有费用永不被钳制(创作者始终以其实际存储的分母打开);feeMaxFor(limits, images)在整数域给出编辑器上限(测试验证与maxFeeBuzzForRatio在 [1,2,10,20,50,100] 全部分母一致);- 测试还固化了一条容易被忽视的规则:版主不豁免额度——他们豁免的是费用上限(在写路径应用),但从不豁免资格下限与额度,否则 UI 上报的无限额度会被服务端拒绝。
paid-access:付费访问门槛与定时销售
paid-access.ts 定义PaidAccessEntityType = 'ModelVersion' | 'ComicChapter'的付费访问模型:
ModelVersionTerms:download为全访问档(购买即含生成),generation描述非买家如何生成({ free: true }免费 / 带price?、trialLimit?的付费纯生成档),acceptsBlueBuzz表示创作者同意同时接受 Blue Buzz 付款;- 门槛判活:
isPaidAccessActive(endsAt == null永久 或endsAt > now)、isTimedGateActive(排除永久门槛,回答"是否存在可能提前结束的限时窗口"); buildModelVersionTerms:把编辑器定价(accessPrice、generationPrice、freePreviewGenerations、genOnly、freeGeneration、acceptsBlueBuzz)统一构造成 terms,供站内表单与 Creator Studio 使用,避免两处漂移;- 政策门槛:
paidAccessBlockedFor(POI 模型或Private模型禁止付费门槛)、licensingFeeBlockedFor(POI 模型禁止授权费,私有模型保留); - 定时销售(CU 868ktk1ku):
ModelVersionSaleWindow(Fixed/Percent折扣、startsAt/endsAt/canceledAt),配套SALE_DAYS_BY_TIER(free 3 / founder 7 / bronze 7 / silver 14 / gold 30)、MAX_SALE_LEAD_DAYS = 14、MIN_SALE_PRICE = 1(零 Buzz 购买不写账本行,且 30 天退款路径从账本读回金额,免费购买将无法退款且对报表不可见);saleDiscountFor的百分比向下取整(33% 的 33 取 10 而非 11),源码注释明确警告不要"好心"改成Math.round;discountedPrice/discountedTerms是唯一的折后价计算入口(仓库曾因"按钮减了服务端从未应用的折扣"而付出代价);bestSaleFor在重叠销售中取"减得最多"者;saleDaysCharged计算已取消销售返还尾段天数,saleDaysUsed按开始月份计(跨月销售不重复计费)。
pricing-allowance:月度定价额度
pricing-allowance.ts 定义会员实际"治理"的唯一货币化维度:
export const MONTHLY_PRICING_ALLOWANCE_BY_TIER: Record<string, number> = { free: 3, founder: 10, // 遗留付费层级,额度对齐 bronze bronze: 10, silver: 25, gold: Infinity, };- 一个"价格"= 授权费或永久付费门槛;定时 Early Access 窗口不占额度(窗口关闭即自我出价淘汰),编辑已设价格也不占(额度按"实体首次定价"计数,每个实体无论带几种价格只占一格);
- 未知或失效层级回退free 额度而非 0:失去会员绝不能剥夺定价能力,也绝不能影响已设价格;
- 资格下限
MONETIZATION_MIN_CREATOR_SCORE = 10_000(paid-access.ts),pricingEligibility(score)对缺失/异常分数关闭失败(按 0 处理,因为这决定谁能开始收费);pricingFloorMessage提供带个人进度的拒绝文案; - 状态工具:
pricingAllowanceState(unlimited/remaining/atLimit,exempt用于"正在编辑的项已定价"时避免误报"已用完")、formatPricingAllowance统一计数文案、shouldUpsellAllowance(达到CAP_UPSELL_THRESHOLD = 0.8且存在更高层级时提示升级)、tierAllowanceRows(展示用,Infinity以null序列化)。
rights-affirmation:货币化的权利声明
rights-affirmation.ts 承载创作者在版本被货币化(付费访问或授权费)前必须接受的权利声明:
MONETIZATION_RIGHTS_AFFIRMATION_VERSION = 1与MONETIZATION_RIGHTS_AFFIRMATION_STATEMENT(声明文本原样存储在版本上,改词即升版,旧记录保留其实际同意的文本);buildRightsAffirmation(userId)构造记录;readRightsAffirmation(meta)逐字段校验(这整条记录的证明价值,半成品绝不能通过门槛;读取失败则视为缺失、要求重新确认);hasCurrentRightsAffirmation(meta, ownerId?)校验当前措辞版本,并可要求确认者仍是当前所有者(声明是具名者承担责任,不随模型转移);paidAccessCharges(input)判定输入是否真的收费(无永久标记且无时限、或{ free: true }的生成授权都不算收费,因此无需声明)。
creator-program:补偿池价值换算
creator-program.ts 是 Creator Program 补偿池的纯价值换算(主应用src/server/utils/creator-program.utils.ts重新导出,creator-studio 的apps/creator-studio/src/lib/server/creator-program.ts同样复用):
getForecastedValue(toBank, pool):按预测池规模折算"你的 Buzz 可能值 $X"的入池/赚取预估;getCurrentValue(toBank, pool):按**当前(实时)**池规模折算已入池金额,实时池为空时返回 0;- 两者都硬性封顶$1 / 1000 buzz(
Math.min(比例折算, toBank / 1000)),池规模为零/缺失时按上限处理(÷∞)。
测试与消费方式
包的 vitest 配置(vitest.config.ts)限定 Node 环境、include: ['src/**/*.test.ts'],运行pnpm --filter @civitai/buzz test即可执行。仓库内的测试覆盖:
- monetization-limits.test.ts:层级归一(founder→bronze、失效会员→free、永远非 null)、视频 5 倍上限、各层级月度额度(3/10/25/∞)、版主不豁免额度、编辑器建议可保存性等;
- paid-access.test.ts、pricing-allowance.test.ts、rights-affirmation.test.ts:分别覆盖门槛/销售、额度计数、声明校验。
主应用在 src/server/services/tests/ 下的多个测试(如buzz.service.transactions-report.test.ts、buzz.service.multi-account-destination.test.ts、buzz.service.multiplier-floor.test.ts)以vi.mock('@civitai/buzz')的方式 mockcreateBuzzClient,从中可以直观看到消费方与包的契约:mock 只 stub 被测试路径实际用到的端点方法(getUserTransactionsReport、createMultiTransaction等)。creator-studio 侧除服务端 apps/creator-studio/src/lib/server/buzz.ts 外,apps/creator-studio/src/lib/components/下大量 Svelte 组件(BulkActionDialog.svelte、PaidAccessEditor.svelte、LicensingFeeFields.svelte等)与apps/creator-studio/src/lib/monetization/的测试(如gate-eligibility.test.ts、sales.test.ts)直接消费本包的纯规则模块。
小结
@civitai/buzz的架构可以概括为"传输层收拢、规则层下沉":HTTP 传输(fetch + 重试 + 状态→异常)与类型化端点方法收拢到一个 server-only 客户端,让主应用与 SvelteKit spokes 不再各写各的;账户类型映射、授权费换算、付费门槛、定价额度、权利声明等跨应用必须一致的规则,则以纯函数形式下沉到浏览器安全的共享模块并配以 vitest 测试固化。这种"共享规则 + 应用各自编排"的边界划分,正是多应用 monorepo 中避免业务逻辑漂移的实践范本——在@civitai/buzz之上,未来的@civitai/monetization可以进一步承接更上层的货币化编排。
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考