feat(server): 同步引擎与 seed(阶段 4)
- 引擎:tick(5 分钟 + 防重入 + 启动即跑)· 单轮串行遍历 active key · 日志整批单事务入库(ON CONFLICT DO NOTHING + log_synced_at 取请求发起时刻)· 额度采集(上游字段全组,独立 try)· QPU 采样(基线 / 值变化才追加)· 每轮摘要与超期 warning - 401 状态机:N=3 摘除(disabled_source=auto_401);403/500/网络不计数;解密失败跳过不计数 - 纯逻辑抽层:policy(计数 / 合并判定)· dedupe(去重键与内容指纹)· log-row(日志映射,与 seed 共用) - seed 脚本(幂等):假 key + 984 条日志 + QPU 基线 + 示例购买 - 实测:401 端到端 1→2→3 摘除且摘除后不再轮询;幂等重跑新增 0 条 - 实证发现:错误行 request_id 不唯一(8 组 ×3)——记入实施计划「待定问题 #5」与 upstream 文档,待作者裁决 - AGENTS.md / 根 package.json 增 seed 命令
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 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`(订阅路径;**金额为占位值**,用于验证折算链)
|
||||
*
|
||||
* 注:快照文件未入库(`.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 { fromUnixSeconds } from '../src/lib/time'
|
||||
import { parseOther, 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: {},
|
||||
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(金额为占位值) ----------
|
||||
const [purchaseRow] = await db.select().from(purchases).limit(1)
|
||||
if (!purchaseRow) {
|
||||
const first = logs[0]!
|
||||
const other = parseOther(first.other)
|
||||
const earliestSeconds = Math.min(...logs.map((l) => l.created_at))
|
||||
const earliest = fromUnixSeconds(earliestSeconds)
|
||||
await db.insert(purchases).values({
|
||||
kind: 'subscription',
|
||||
paidAt: earliest,
|
||||
paidAmount: 168.5, // 占位值:实付金额只有作者知道
|
||||
note: 'seed 示例(金额为占位值,非真实)',
|
||||
periodStart: earliest,
|
||||
periodEnd: null,
|
||||
subscriptionId: asNumber(other.subscription_id, 90),
|
||||
quotaGranted: asNumber(other.subscription_total, 800_000_000),
|
||||
})
|
||||
console.log('[seed] purchases:已插入示例一条(折算链验证用)')
|
||||
} else {
|
||||
console.log(`[seed] purchases:已有数据(id=${purchaseRow.id}),跳过`)
|
||||
}
|
||||
|
||||
console.log(`[seed] 完成。查询面可用假 key「${SEED_KEY}」命中已登记 key。`)
|
||||
Reference in New Issue
Block a user