Files
new-api-key-usage/apps/server/scripts/seed.ts
T
skycurtain 8252b25396 feat(server): 查看面数据面(阶段 5)
- logs/query:明细 1:1(除 dedupe_key)+ 当前 token_name(join)+ amount_rmb;分页(limit/offset,默认 50 / 上限 200);排序 created_at DESC;type 过滤
- summary:卡片汇总(含 cache_tokens 的 jsonb 聚合)+ 折算累加 + unpriced_count
- meta(约定 22/23):data_until 取参与 key 中非 null 的最慢值、per-key 状态、未收录 key_hint 即时生成;全未收录仍 200 空结果
- 折算链(pricing.ts):按归属购买取尺(quota × paid_amount ÷ quota_granted = 按 quota 占比分摊实付);wallet 路径按未折算(null);假设 #6 的多条匹配规则
- 修复 jsonb 双重编码(实测定位):drizzle + bun-sql 直接传 JS 对象会落成 jsonb string,SQL 侧 ->> / ? 全失效 → 统一 toJsonb()(sql`${obj}::jsonb`),影响 other 与 model_limits
- seed 调整为「为行数最多的订阅登记购买」,保留未折算展示样本
- 实测:jsonb_typeof=object、SQL 聚合恢复、金额与 unpriced_count 计数正确、data_until 取最慢
2026-09-15 00:51:39 +08:00

159 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* seed 脚本(实施计划 §6):从仓库根的实测响应快照灌入开发数据——**幂等**,可重复执行。
*
* 用法:在 `apps/server` 下 `bun run seed`(环境变量自动经 `src/lib/env.ts` 加载仓库根 `.env`)。
*
* 灌入内容:
* 1. 一个占位 `api_keys` 行(假 key 常量 `SEED_KEY`,哈希 / 密文走真实 crypto 逻辑——
* 查询面可用该常量 key 命中已登记 key)
* 2. `usage_logs`:`api.log.token.json` 的 1000 条样例日志(`ON CONFLICT DO NOTHING`)
* 3. `qpu_history` 基线:`api.status.json` 的 `quota_per_unit`(已有数据则跳过)
* 4. 一条示例 `purchases`:为样例中**日志行数最多**的订阅登记(**金额为占位值**);
* 其余订阅保持未登记——保留「未折算(`amount_rmb = null`)」的展示样本
*
* 注:快照文件未入库(`.gitignore` 的 `/api*.json`),本脚本只在本地开发环境使用。
*/
import { readFileSync } from 'node:fs'
import { eq } from 'drizzle-orm'
import { DEFAULT_QUOTA_PER_UNIT } from '@naku/shared'
import { getDb } from '../src/db/client'
import { apiKeys, purchases, qpuHistory, usageLogs } from '../src/db/schema'
import { getKeySecrets } from '../src/lib/keys'
import { readSubscriptionId } from '../src/lib/pricing'
import { fromUnixSeconds } from '../src/lib/time'
import { parseOther, toJsonb, toLogRow } from '../src/sync/log-row'
import type { UpstreamLogRecord } from '../src/upstream/types'
/** 假 key 常量:查看面 / 管理面用它做本地验证(可用 env SEED_KEY 覆盖) */
const SEED_KEY = process.env.SEED_KEY ?? 'sk-seed-demo-key-0001'
/** 仓库根(本文件位于 apps/server/scripts/) */
const REPO_ROOT = new URL('../../../', import.meta.url)
function readSnapshot<T>(name: string): T {
return JSON.parse(readFileSync(new URL(name, REPO_ROOT), 'utf8')) as T
}
function asNumber(v: unknown, fallback: number): number {
return typeof v === 'number' && Number.isFinite(v) ? v : fallback
}
const db = getDb()
const secrets = getKeySecrets()
// ---------- 样例快照 ----------
const logs = readSnapshot<{ data?: UpstreamLogRecord[] }>('api.log.token.json').data
if (!Array.isArray(logs) || logs.length === 0) {
console.error('[seed] api.log.token.json 缺失或结构异常(缺 data[])——请确认样例文件在仓库根')
process.exit(1)
}
const status = readSnapshot<{ data?: Record<string, unknown> }>('api.status.json').data ?? {}
// ---------- 1. api_keys ----------
const keyHash = secrets.keyHash(SEED_KEY)
let [keyRow] = await db.select().from(apiKeys).where(eq(apiKeys.keyHash, keyHash)).limit(1)
if (!keyRow) {
const first = logs[0]!
const other = parseOther(first.other)
const totalGranted = asNumber(other.subscription_total, 800_000_000)
const totalUsed = asNumber(other.subscription_used, 85_579_458)
const now = new Date()
;[keyRow] = await db
.insert(apiKeys)
.values({
keyHash,
keyCiphertext: secrets.encryptKey(SEED_KEY),
status: 'active',
disabledSource: null,
consecutive401s: 0,
logSyncedAt: now,
usageSyncedAt: now,
tokenName: first.token_name ?? 'seed-token',
totalGranted,
totalUsed,
totalAvailable: totalGranted - totalUsed,
unlimitedQuota: false,
modelLimits: toJsonb({}),
modelLimitsEnabled: false,
expiresAt: null,
})
.returning()
console.log(`[seed] api_keys:已插入 id=${keyRow!.id}(token_name=${keyRow!.tokenName})`)
} else {
console.log(`[seed] api_keys:已存在(id=${keyRow.id}),跳过`)
}
// ---------- 2. usage_logs ----------
const rows = logs.map((log) => toLogRow(keyRow!.id, log))
const inserted = await db
.insert(usageLogs)
.values(rows)
.onConflictDoNothing({ target: usageLogs.dedupeKey })
.returning({ id: usageLogs.id })
console.log(`[seed] usage_logs:新增 ${inserted.length} 条(样例共 ${logs.length} 条)`)
// ---------- 3. qpu_history 基线 ----------
const [qpuRow] = await db.select().from(qpuHistory).limit(1)
if (!qpuRow) {
const qpu = asNumber(status.quota_per_unit, DEFAULT_QUOTA_PER_UNIT)
await db.insert(qpuHistory).values({ qpu, effectiveFrom: new Date() })
console.log(`[seed] qpu_history:已插入基线 qpu=${qpu}`)
} else {
console.log(`[seed] qpu_history:已有数据(qpu=${qpuRow.qpu}),跳过`)
}
// ---------- 4. 示例 purchases(金额为占位值) ----------
// 样例快照里可能有多个订阅(实测 89 / 90 两组):只为行数最多的那笔登记购买记录,
// 其余保持未登记——既保留「未折算」样本,也贴近真实的「多订阅并存」形状
interface SubInfo {
count: number
earliest: number
total: number
plan: string | null
}
const bySubscription = new Map<number, SubInfo>()
for (const log of logs) {
const other = parseOther(log.other)
const sid = readSubscriptionId(other)
if (sid === null) continue
const cur =
bySubscription.get(sid) ??
({ count: 0, earliest: Number.POSITIVE_INFINITY, total: 0, plan: null } satisfies SubInfo)
cur.count += 1
cur.earliest = Math.min(cur.earliest, log.created_at)
cur.total = Math.max(cur.total, asNumber(other.subscription_total, 0))
cur.plan = typeof other.subscription_plan_title === 'string' ? other.subscription_plan_title : cur.plan
bySubscription.set(sid, cur)
}
const ranked = [...bySubscription.entries()].sort((a, b) => b[1].count - a[1].count)
const [purchaseRow] = await db.select().from(purchases).limit(1)
if (purchaseRow) {
console.log(`[seed] purchases:已有数据(id=${purchaseRow.id}),跳过`)
} else if (ranked.length === 0) {
console.log('[seed] purchases:样例日志中无 subscription_id,跳过')
} else {
const [mainSid, info] = ranked[0]!
const earliest = fromUnixSeconds(info.earliest)
await db.insert(purchases).values({
kind: 'subscription',
paidAt: earliest,
paidAmount: 168.5, // 占位值:实付金额只有作者知道
note: 'seed 示例(金额为占位值,非真实)',
periodStart: earliest,
periodEnd: null,
subscriptionId: mainSid,
quotaGranted: info.total > 0 ? info.total : 800_000_000,
})
console.log(
`[seed] purchases:为订阅 #${mainSid}(${info.plan ?? '未知计划'},${info.count} 行日志)插入示例一条`,
)
for (const [sid, extra] of ranked.slice(1)) {
console.log(`[seed] 订阅 #${sid}(${extra.count} 行)保持未登记——用于演示「未折算」展示`)
}
}
console.log(`[seed] 完成。查询面可用假 key「${SEED_KEY}」命中已登记 key。`)