- 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 取最慢
159 lines
6.5 KiB
TypeScript
159 lines
6.5 KiB
TypeScript
/**
|
||
* 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。`)
|