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 取最慢
This commit is contained in:
2026-09-15 00:51:39 +08:00
parent e1c6d2153e
commit 8252b25396
7 changed files with 546 additions and 40 deletions
+44 -13
View File
@@ -8,7 +8,8 @@
* 查询面可用该常量 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`(订阅路径;**金额为占位值**,用于验证折算链)
* 4. 一条示例 `purchases`:为样例中**日志行数最多**的订阅登记(**金额为占位值**);
* 其余订阅保持未登记——保留「未折算(`amount_rmb = null`)」的展示样本
*
* 注:快照文件未入库(`.gitignore` 的 `/api*.json`),本脚本只在本地开发环境使用。
*/
@@ -20,8 +21,9 @@ 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, toLogRow } from '../src/sync/log-row'
import { parseOther, toJsonb, toLogRow } from '../src/sync/log-row'
import type { UpstreamLogRecord } from '../src/upstream/types'
/** 假 key 常量:查看面 / 管理面用它做本地验证(可用 env SEED_KEY 覆盖) */
@@ -73,7 +75,7 @@ if (!keyRow) {
totalUsed,
totalAvailable: totalGranted - totalUsed,
unlimitedQuota: false,
modelLimits: {},
modelLimits: toJsonb({}),
modelLimitsEnabled: false,
expiresAt: null,
})
@@ -103,12 +105,38 @@ if (!qpuRow) {
}
// ---------- 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) {
const first = logs[0]!
const other = parseOther(first.other)
const earliestSeconds = Math.min(...logs.map((l) => l.created_at))
const earliest = fromUnixSeconds(earliestSeconds)
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,
@@ -116,12 +144,15 @@ if (!purchaseRow) {
note: 'seed 示例(金额为占位值,非真实)',
periodStart: earliest,
periodEnd: null,
subscriptionId: asNumber(other.subscription_id, 90),
quotaGranted: asNumber(other.subscription_total, 800_000_000),
subscriptionId: mainSid,
quotaGranted: info.total > 0 ? info.total : 800_000_000,
})
console.log('[seed] purchases:已插入示例一条(折算链验证用)')
} else {
console.log(`[seed] purchases:已有数据(id=${purchaseRow.id}),跳过`)
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。`)
+79
View File
@@ -0,0 +1,79 @@
/**
* 折算链单测(purchases 表设计 §1 · 实施计划「实现假设」#6)。
*/
import { describe, expect, test } from 'bun:test'
import { amountRmbFor, resolvePurchaseForLog, type PurchaseLite } from './pricing'
const d = (s: string) => new Date(s)
const P = (over: Partial<PurchaseLite> = {}): PurchaseLite => ({
id: 1,
paidAmount: 200,
quotaGranted: 1_000_000_000,
subscriptionId: 90,
periodStart: d('2026-09-01T00:00:00+08:00'),
periodEnd: d('2026-10-01T00:00:00+08:00'),
...over,
})
describe('resolvePurchaseForLog(日志 → 归属购买)', () => {
test('同 ID 唯一 → 该条(期间内)', () => {
const p = P()
expect(resolvePurchaseForLog(d('2026-09-10T12:00:00+08:00'), 90, 'subscription', [p])).toBe(p)
})
test('充值(wallet)→ null(池子近似后议,先按未折算)', () => {
expect(resolvePurchaseForLog(d('2026-09-10T12:00:00+08:00'), 90, 'wallet', [P()])).toBeNull()
})
test('无 subscription_id → null', () => {
expect(resolvePurchaseForLog(d('2026-09-10T12:00:00+08:00'), null, 'subscription', [P()])).toBeNull()
})
test('同 ID 多条:取「期间覆盖日志时刻」的那条(跨期连续两笔)', () => {
const p1 = P({ id: 1, periodStart: d('2026-08-01T00:00:00+08:00'), periodEnd: d('2026-09-01T00:00:00+08:00') })
const p2 = P({ id: 2, periodStart: d('2026-09-01T00:00:00+08:00'), periodEnd: d('2026-10-01T00:00:00+08:00') })
expect(resolvePurchaseForLog(d('2026-09-10T00:00:00+08:00'), 90, 'subscription', [p1, p2])?.id).toBe(2)
expect(resolvePurchaseForLog(d('2026-08-10T00:00:00+08:00'), 90, 'subscription', [p1, p2])?.id).toBe(1)
})
test('同 ID 多条且都不覆盖 → null(不猜)', () => {
const p1 = P({ id: 1, periodStart: d('2026-07-01T00:00:00+08:00'), periodEnd: d('2026-08-01T00:00:00+08:00') })
const p2 = P({ id: 2, periodStart: d('2026-08-01T00:00:00+08:00'), periodEnd: d('2026-09-01T00:00:00+08:00') })
expect(resolvePurchaseForLog(d('2026-09-10T00:00:00+08:00'), 90, 'subscription', [p1, p2])).toBeNull()
})
test('期间边界:左闭右开(period_end 时刻本身不属于该笔)', () => {
const p = P({ periodEnd: d('2026-09-01T00:00:00+08:00') })
expect(resolvePurchaseForLog(d('2026-08-31T23:59:59+08:00'), 90, 'subscription', [p])).toBe(p)
// 同 ID 唯一时不做期间判定(唯一即兜底),故用多条场景验证边界
const p2 = P({ id: 2, periodStart: d('2026-09-01T00:00:00+08:00'), periodEnd: null })
expect(resolvePurchaseForLog(d('2026-09-01T00:00:00+08:00'), 90, 'subscription', [p, p2])?.id).toBe(2)
})
})
describe('amountRmbFor(quota 占比 × 该笔实付)', () => {
test('正常折算:quota × paid_amount ÷ quota_granted', () => {
// 1e6 quota / 1e9 总额 × 200 元 = 0.2 元
expect(amountRmbFor(1_000_000, P())).toBeCloseTo(0.2, 10)
})
test('错误行 quota = 0 → 0(有归属时)', () => {
expect(amountRmbFor(0, P())).toBe(0)
})
test('无归属购买 → null(未登记 / 充值来源)', () => {
expect(amountRmbFor(1234, null)).toBeNull()
})
test('quota_granted 缺失或非正 → null(分母不成立)', () => {
expect(amountRmbFor(1234, P({ quotaGranted: null }))).toBeNull()
expect(amountRmbFor(1234, P({ quotaGranted: 0 }))).toBeNull()
})
test('全精度:不做舍入(约定 14)', () => {
const v = amountRmbFor(702, P({ paidAmount: 168.5, quotaGranted: 800_000_000 }))
expect(v).toBe((702 * 168.5) / 800_000_000)
})
})
+72
View File
@@ -0,0 +1,72 @@
/**
* 折算链(`purchases` 表设计 §1 · `qpu_history` 表设计 §3.3 · API 契约 §1.4/§2.1):
*
* 日志.quota ÷ 购买时刻的 qpu = 额度美元;× 购买汇率 = 实付人民币
* 购买汇率 = paid_amount ÷ (quota_granted ÷ 购买时刻的 qpu)
*
* 两处 qpu 同为「该日志所属购买的购买时刻」取值,代数上抵消,落到逐行计算即:
*
* amount_rmb = quota × paid_amount ÷ quota_granted (按 quota 占比分摊该笔实付)
*
* 这正是设计所称「跨期按购买期间边界分段,每段 quota 占比 × 该笔实付」的逐行等价形式
* ——每一行都按自己的归属购买取尺,跨期自然分段;`qpu_history` 不参与金额计算。
*/
export interface PurchaseLite {
id: number
paidAmount: number
quotaGranted: number | null
subscriptionId: number | null
periodStart: Date | null
periodEnd: Date | null
}
/** 期间覆盖判定:`[period_start, period_end)` 左闭右开;两端都可为空(未填 = 不设限) */
function covers(p: PurchaseLite, t: Date): boolean {
return (
(!p.periodStart || t.getTime() >= p.periodStart.getTime()) &&
(!p.periodEnd || t.getTime() < p.periodEnd.getTime())
)
}
/**
* 日志 → 归属购买(实施计划「实现假设」#6:`subscription_id` 优先;同 ID 多条按
* `[period_start, period_end)`;无期间记录仅在「同 ID 唯一」时兜底)。
* 充值(`billing_source = "wallet"`)路径的池子近似后议——先按「未折算」处理(返回 null)。
*/
export function resolvePurchaseForLog(
logTime: Date,
subscriptionId: number | null,
billingSource: unknown,
purchases: PurchaseLite[],
): PurchaseLite | null {
if (billingSource === 'wallet') return null
if (subscriptionId === null) return null
const same = purchases.filter((p) => p.subscriptionId === subscriptionId)
if (same.length === 0) return null
if (same.length === 1) return same[0]!
// 同 ID 多条(续费 / 跨期):只认「期间覆盖该日志时刻」者;都不覆盖则不猜(null)
const inPeriod = same.filter((p) => covers(p, logTime))
if (inPeriod.length === 0) return null
return [...inPeriod].sort(
(a, b) => (b.periodStart?.getTime() ?? 0) - (a.periodStart?.getTime() ?? 0),
)[0]!
}
/**
* 单行折算:无归属购买(未登记 / 充值来源 / 无法判定)→ `null`;
* 有归属但 `quota_granted` 缺失 / 非正 → 折算分母不成立 → `null`(展示「—」,计入 `unpriced_count`)。
*/
export function amountRmbFor(quota: number, purchase: PurchaseLite | null): number | null {
if (!purchase) return null
if (purchase.quotaGranted === null || purchase.quotaGranted <= 0) return null
return (quota * purchase.paidAmount) / purchase.quotaGranted
}
/** `other.subscription_id` 的宽容读取(jsonb 里可能是数字或字符串) */
export function readSubscriptionId(other: Record<string, unknown>): number | null {
const raw = other.subscription_id
const n = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN
return Number.isFinite(n) ? n : null
}
+3 -1
View File
@@ -2,6 +2,7 @@
* 上游字段全组(`api_keys` 表 8 个上游列)——登记 / 启用 / 每轮额度采集共用一处,
* 避免三处各写一份导致漏列(api_keys 表设计 §3.4/§3.5:整组一起写)。
*/
import { toJsonb } from '../sync/log-row'
import type { UpstreamTokenUsage } from '../upstream/types'
import { expiresAtFromUnix } from './time'
@@ -12,7 +13,8 @@ export function upstreamColumns(usage: UpstreamTokenUsage) {
totalUsed: usage.total_used,
totalAvailable: usage.total_available,
unlimitedQuota: usage.unlimited_quota,
modelLimits: usage.model_limits,
// jsonb 列需显式 ::jsonb(见 log-row.ts 文件头注释的写入陷阱)
modelLimits: toJsonb(usage.model_limits),
modelLimitsEnabled: usage.model_limits_enabled,
expiresAt: expiresAtFromUnix(usage.expires_at),
}
+327 -18
View File
@@ -1,4 +1,25 @@
import { Hono } from 'hono'
import { and, desc, eq, gte, inArray, lt, sql } from 'drizzle-orm'
import type { Context } from 'hono'
import {
KEYS_MAX,
LOGS_DEFAULT_LIMIT,
LOGS_MAX_LIMIT,
type ApiKeyRow as ApiKeyRowContract,
} from '@naku/shared'
import type { LogOther, LogType, QueryMeta, QueryMetaKey, SummaryResponse, UsageLogRow } from '@naku/shared'
import { apiError } from '../lib/api-error'
import { getKeySecrets } from '../lib/keys'
import {
amountRmbFor,
readSubscriptionId,
resolvePurchaseForLog,
type PurchaseLite,
} from '../lib/pricing'
import { parseInputTime, toBeijingIso } from '../lib/time'
import { getDb } from '../db/client'
import { apiKeys, purchases, usageLogs } from '../db/schema'
/**
* 查看面(契约 §2.1):无会话、无服务端状态——key 集合与过滤参数全在请求体。
@@ -6,26 +27,314 @@ import { Hono } from 'hono'
*/
export const usageRoutes = new Hono()
usageRoutes.post('/logs/query', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:明细查询(见 docs/design/API契约.md §2.1)',
},
},
501,
type ApiKeyRow = typeof apiKeys.$inferSelect
type UsageLogDbRow = typeof usageLogs.$inferSelect
// ---------- 请求解析 ----------
async function readJsonObject(
c: Context,
): Promise<{ error: string } | { value: Record<string, unknown> }> {
let raw: unknown
try {
raw = await c.req.json()
} catch {
return { error: '请求体必须是 JSON' }
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return { error: '请求体必须是 JSON 对象' }
}
return { value: raw as Record<string, unknown> }
}
interface QueryInput {
keys: string[]
start: Date
end: Date
}
/** 公共参数(keys / start / end)——两个端点同一套(约定 17/19 + §2.1 参数清单) */
function parseBase(body: Record<string, unknown>): { error: string } | { input: QueryInput } {
const rawKeys = body.keys
if (!Array.isArray(rawKeys) || rawKeys.length === 0) {
return { error: 'keys 必填(非空数组)' }
}
const keys = [
...new Set(
rawKeys
.filter((k): k is string => typeof k === 'string')
.map((k) => k.trim())
.filter((k) => k.length > 0),
),
]
if (keys.length === 0) return { error: 'keys 必填(非空数组)' }
if (keys.length > KEYS_MAX) {
return { error: `keys 数量超过上限(去重后最多 ${KEYS_MAX} 个)` }
}
const start = typeof body.start === 'string' ? parseInputTime(body.start) : null
if (!start) return { error: 'start 缺失或无法解析(ISO 8601)' }
const end = typeof body.end === 'string' ? parseInputTime(body.end) : null
if (!end) return { error: 'end 缺失或无法解析(ISO 8601)' }
if (start.getTime() >= end.getTime()) {
return { error: 'start 必须早于 end(左闭右开)' }
}
return { input: { keys, start, end } }
}
// ---------- key 命中与 meta(约定 5 / 22 / 23) ----------
interface ResolvedKey {
plain: string
row: ApiKeyRow | null
}
/** 未收录 key 的即时提示:`sk-…a1b2`(即时生成、不落库——api_keys 表设计 §5) */
function keyHint(plainKey: string): string {
const tail = plainKey.slice(-4)
return plainKey.length <= 8 ? `…${tail}` : `${plainKey.slice(0, 3)}…${tail}`
}
async function resolveKeys(keys: string[]): Promise<ResolvedKey[]> {
const secrets = getKeySecrets()
const hashes = keys.map((k) => secrets.keyHash(k))
const rows = await getDb().select().from(apiKeys).where(inArray(apiKeys.keyHash, hashes))
const byHash = new Map(rows.map((r) => [r.keyHash, r]))
return keys.map((plain, i) => ({ plain, row: byHash.get(hashes[i]!) ?? null }))
}
/** meta:`data_until` 取参与 key 中「非 null 的最慢 `log_synced_at`」;全部未同步时为 null */
function buildMeta(resolved: ResolvedKey[]): QueryMeta {
const keys: QueryMetaKey[] = resolved.map(({ plain, row }) =>
row
? ({
key_id: row.id,
token_name: row.tokenName,
registered: true,
status: row.status as ApiKeyRowContract['status'],
log_synced_at: row.logSyncedAt ? toBeijingIso(row.logSyncedAt) : null,
} satisfies QueryMetaKey)
: ({ registered: false, key_hint: keyHint(plain) } satisfies QueryMetaKey),
)
const syncedMs = resolved
.map((r) => r.row?.logSyncedAt)
.filter((d): d is Date => d instanceof Date)
.map((d) => d.getTime())
return {
data_until: syncedMs.length > 0 ? toBeijingIso(new Date(Math.max(...syncedMs))) : null,
served_at: toBeijingIso(new Date()),
keys,
}
}
// ---------- 行映射与折算 ----------
async function loadPurchases(): Promise<PurchaseLite[]> {
return await getDb()
.select({
id: purchases.id,
paidAmount: purchases.paidAmount,
quotaGranted: purchases.quotaGranted,
subscriptionId: purchases.subscriptionId,
periodStart: purchases.periodStart,
periodEnd: purchases.periodEnd,
})
.from(purchases)
}
/** 明细行 → 契约行:表 1:1(除 `dedupe_key`)+ `token_name` 取当前名 + `amount_rmb` 折算 */
function toUsageLogRow(
log: UsageLogDbRow,
currentTokenName: string,
allPurchases: PurchaseLite[],
): UsageLogRow {
const other = (log.other ?? {}) as LogOther
const purchase = resolvePurchaseForLog(
log.createdAt,
readSubscriptionId(other),
other.billing_source,
allPurchases,
)
return {
id: log.id,
key_id: log.keyId,
user_id: log.userId,
username: log.username,
created_at: toBeijingIso(log.createdAt),
type: log.type as LogType,
content: log.content,
token_name: currentTokenName,
model_name: log.modelName,
quota: log.quota,
prompt_tokens: log.promptTokens,
completion_tokens: log.completionTokens,
use_time: log.useTime,
is_stream: log.isStream,
channel: log.channel,
channel_name: log.channelName,
token_id: log.tokenId,
group_name: log.groupName,
ip: log.ip,
request_id: log.requestId,
upstream_request_id: log.upstreamRequestId,
amount_rmb: amountRmbFor(log.quota, purchase),
other,
}
}
// ---------- 端点 ----------
/** 明细(分页;排序固定 created_at DESC——约定 16) */
usageRoutes.post('/logs/query', async (c) => {
const body = await readJsonObject(c)
if ('error' in body) return apiError(c, 400, 'invalid_params', body.error)
const parsed = parseBase(body.value)
if ('error' in parsed) return apiError(c, 400, 'invalid_params', parsed.error)
const { keys, start, end } = parsed.input
let types: number[] | null = null
if (body.value.type !== undefined) {
const raw = body.value.type
if (
!Array.isArray(raw) ||
raw.length === 0 ||
raw.some((t) => !Number.isInteger(t) || (t as number) < 0 || (t as number) > 7)
) {
return apiError(c, 400, 'invalid_params', 'type 必须是非空的 0–7 整数数组')
}
types = [...new Set(raw as number[])]
}
const limit = body.value.limit === undefined ? LOGS_DEFAULT_LIMIT : Number(body.value.limit)
if (!Number.isInteger(limit) || limit <= 0) {
return apiError(c, 400, 'invalid_params', 'limit 必须是正整数')
}
if (limit > LOGS_MAX_LIMIT) {
return apiError(c, 400, 'invalid_params', `limit 超过上限(最多 ${LOGS_MAX_LIMIT})`)
}
const offset = body.value.offset === undefined ? 0 : Number(body.value.offset)
if (!Number.isInteger(offset) || offset < 0) {
return apiError(c, 400, 'invalid_params', 'offset 必须是非负整数')
}
const db = getDb()
const resolved = await resolveKeys(keys)
const keyIds = resolved.filter((r) => r.row !== null).map((r) => r.row!.id)
const meta = buildMeta(resolved)
// 全部未收录:200 + 空结果(约定 23——未收录是结果、不是错误)
if (keyIds.length === 0) {
return c.json({ items: [], total: 0, meta })
}
const conditions = [
inArray(usageLogs.keyId, keyIds),
gte(usageLogs.createdAt, start),
lt(usageLogs.createdAt, end),
]
if (types) conditions.push(inArray(usageLogs.type, types))
const where = and(...conditions)
const totalRows = await db
.select({ total: sql<number>`count(*)::int` })
.from(usageLogs)
.where(where)
const rows = await db
.select({ log: usageLogs, currentTokenName: apiKeys.tokenName })
.from(usageLogs)
.innerJoin(apiKeys, eq(usageLogs.keyId, apiKeys.id))
.where(where)
.orderBy(desc(usageLogs.createdAt))
.limit(limit)
.offset(offset)
const allPurchases = await loadPurchases()
const items = rows.map(({ log, currentTokenName }) =>
toUsageLogRow(log, currentTokenName, allPurchases),
)
return c.json({ items, total: Number(totalRows[0]?.total ?? 0), meta })
})
/** 汇总(卡片;无 type / limit / offset) */
usageRoutes.post('/summary', async (c) => {
const body = await readJsonObject(c)
if ('error' in body) return apiError(c, 400, 'invalid_params', body.error)
const parsed = parseBase(body.value)
if ('error' in parsed) return apiError(c, 400, 'invalid_params', parsed.error)
const { keys, start, end } = parsed.input
const db = getDb()
const resolved = await resolveKeys(keys)
const keyIds = resolved.filter((r) => r.row !== null).map((r) => r.row!.id)
const meta = buildMeta(resolved)
const empty: SummaryResponse = {
request_count: 0,
consume_count: 0,
error_count: 0,
prompt_tokens: 0,
completion_tokens: 0,
cache_tokens: 0,
quota: 0,
amount_rmb: 0,
unpriced_count: 0,
meta,
}
if (keyIds.length === 0) return c.json(empty)
const where = and(
inArray(usageLogs.keyId, keyIds),
gte(usageLogs.createdAt, start),
lt(usageLogs.createdAt, end),
)
usageRoutes.post('/summary', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:卡片汇总(见 docs/design/API契约.md §2.1)',
},
},
501,
const aggRows = await db
.select({
request_count: sql<number>`count(*)::int`,
consume_count: sql<number>`count(*) filter (where ${usageLogs.type} = 2)::int`,
error_count: sql<number>`count(*) filter (where ${usageLogs.type} = 5)::int`,
prompt_tokens: sql<number>`coalesce(sum(${usageLogs.promptTokens}), 0)::bigint`,
completion_tokens: sql<number>`coalesce(sum(${usageLogs.completionTokens}), 0)::bigint`,
cache_tokens: sql<number>`coalesce(sum((${usageLogs.other} ->> 'cache_tokens')::bigint), 0)::bigint`,
quota: sql<number>`coalesce(sum(${usageLogs.quota}), 0)::bigint`,
})
.from(usageLogs)
.where(where)
const agg = aggRows[0]
// 折算:按行取归属购买(跨期自然分段);`unpriced_count` = 无法折算的行数
const pricingRows = await db
.select({ quota: usageLogs.quota, createdAt: usageLogs.createdAt, other: usageLogs.other })
.from(usageLogs)
.where(where)
const allPurchases = await loadPurchases()
let amountRmb = 0
let unpricedCount = 0
for (const r of pricingRows) {
const other = (r.other ?? {}) as LogOther
const amount = amountRmbFor(
r.quota,
resolvePurchaseForLog(
r.createdAt,
readSubscriptionId(other),
other.billing_source,
allPurchases,
),
)
if (amount === null) unpricedCount++
else amountRmb += amount
}
const response: SummaryResponse = {
request_count: Number(agg?.request_count ?? 0),
consume_count: Number(agg?.consume_count ?? 0),
error_count: Number(agg?.error_count ?? 0),
prompt_tokens: Number(agg?.prompt_tokens ?? 0),
completion_tokens: Number(agg?.completion_tokens ?? 0),
cache_tokens: Number(agg?.cache_tokens ?? 0),
quota: Number(agg?.quota ?? 0),
amount_rmb: amountRmb,
unpriced_count: unpricedCount,
meta,
}
return c.json(response)
})
+13 -2
View File
@@ -1,12 +1,18 @@
/**
* 上游日志行 → `usage_logs` 列值(同步引擎与 seed 脚本共用,避免两份映射漂移)。
*
* ⚠️ jsonb 写入陷阱(2026-09-15 实测):drizzle + `bun-sql` 组合下直接传 JS 对象会
* **双重编码**(落库成 `jsonb` 的 *string* 值,`other->>'k'` / `other ? 'k'` 全部失效)。
* 正确写法是 `sql\`${obj}::jsonb\``——由驱动序列化对象一次并由 PG 解析为 object。
*/
import { sql } from 'drizzle-orm'
import { fromUnixSeconds } from '../lib/time'
import type { usageLogs } from '../db/schema'
import type { UpstreamLogRecord } from '../upstream/types'
import { dedupeKeyFor } from './dedupe'
/** 上游 `other`(JSON 字符串)→ jsonb;解析失败退化为空对象(保真优先,不丢整条日志) */
/** 上游 `other`(JSON 字符串)→ jsonb 值;解析失败退化为空对象(保真优先,不丢整条日志) */
export function parseOther(raw: string | undefined | null): Record<string, unknown> {
if (!raw) return {}
try {
@@ -17,6 +23,11 @@ export function parseOther(raw: string | undefined | null): Record<string, unkno
}
}
/** JS 对象 → 正确的 jsonb 列值(见文件头注释的写入陷阱) */
export function toJsonb(value: unknown) {
return sql`${value}::jsonb`
}
export function toLogRow(keyId: number, log: UpstreamLogRecord): typeof usageLogs.$inferInsert {
return {
keyId,
@@ -40,6 +51,6 @@ export function toLogRow(keyId: number, log: UpstreamLogRecord): typeof usageLog
ip: log.ip ?? '',
requestId: log.request_id ?? '',
upstreamRequestId: log.upstream_request_id ?? null,
other: parseOther(log.other),
other: toJsonb(parseOther(log.other)),
}
}
+4 -2
View File
@@ -7,7 +7,7 @@
| 状态 | 定稿(2026-09-14;**进度表随 loop 持续更新**) |
| 关联 | `docs/design/API契约.md` · `docs/design/同步引擎设计.md` · `docs/design/交互设计.md` · 四张表字段设计 · `AGENTS.md`(命令) |
| 范围 | 阶段划分、每批验收标准、loop 机制与推进规则;技术细节一律以设计文档为准 |
| 当前进度 | 阶段 0–4 已完成;下一批:**阶段 5**(查看面数据面:`logs/query` + `summary` + 折算链) |
| 当前进度 | 阶段 0–5 已完成;下一批:**阶段 6**(前端 `/usage-log`) |
---
@@ -42,6 +42,8 @@
| 14 | `PATCH` 幂等分支不重复调上游验证(直接返回当前行);启用时凭证解密失败 → `500 internal`(提示需重新登记) | 与「解密失败不摘除、需人工重新登记」一致;可调 |
| 15 | 一轮内「日志 + 额度」两个调用的合并判定:**任一 401 → 本轮计 1 次 401**(401 优先);否则任一成功 → 计数清零;全失败 → 计数不动 | 设计(§3.4)未覆盖「一成功一 401」的冲突情形;401 优先 + N=3 阈值使单次误报无害 |
| 16 | seed 脚本用假 key 常量 `sk-seed-demo-key-0001`(`SEED_KEY` 可覆盖);`apps/server/scripts/seed.ts` 幂等可重跑 | 实施计划 §6 已定「假 key 常量」;仅开发期 |
| 17 | **jsonb 列写入统一走 `sql\`${obj}::jsonb\``**(`toJsonb()`,见 `src/sync/log-row.ts`)——drizzle + `bun-sql` 组合直接传 JS 对象会**双重编码**成 `jsonb` 的 *string* 值,SQL 侧 `other->>'k'` / `? 'k'` 全部失效(2026-09-15 实测定位) | 已在 `other` / `model_limits` 落地;新增 jsonb 列时照此办理 |
| 18 | seed 为「日志行数最多的订阅」登记示例购买,其余订阅保持未登记(保留 `amount_rmb = null` 的展示样本) | 样例实测含两个订阅(89 / 90);可改 |
## 2. 前提(2026-09-14 定)
@@ -58,7 +60,7 @@
| 2 | **上游客户端 + key 管理**(`GET` / `POST` / `PATCH` 三端点)· 客户端接口化(§6) | curl 实测:登记 → 清单 → 停用/启用(**注入样例实现**:登记成功路径用样例响应驱动);三类错误码分支正确(`key_rejected` / `already_registered` / `upstream_unavailable`) | ✅ 2026-09-15(28 项单测绿;curl 16 步——登记 201 / 重复 409 / `key_rejected` 400 / `upstream_unavailable` 502 / 停用与幂等 / 启用清 `consecutive_401s` / `not_found` 404 / 非法参数 400;顺带修正「`load-env` 未被服务端接线」) |
| 3 | **管理面其余**:purchases 五端点 + qpu 一端点 | curl 实测:CRUD 全流程 + 观测聚合(`matched_purchase_id` 判定正确) | ✅ 2026-09-15(33 项单测绿;curl 16 步——登记 201 / 缺必填与非法值与期间顺序 400 / 部分更新与显式清空 / 排序 / 404 / 物理删除 / 观测聚合 `matched_purchase_id` 判定正确) |
| 4 | **同步引擎**:tick(5 分钟 + 防重入 + 启动即跑)· 日志整批事务入库 · 额度采集 · QPU 采样 · 401 状态机(N=3)· **seed 脚本**(§6) | 库中出现数据(seed 或真实采集);`[sync]` 日志符合引擎设计;401 状态机单测(连续 401 → 摘除且 `disabled_source = auto_401`) | ✅ 2026-09-15(45 项单测绿;seed 灌入 984 条日志 + QPU 基线 + 示例购买;引擎实测——幂等重跑新增 0 条、摘要日志符合设计、**401 端到端:1→2→3 次摘除且摘除后不再轮询**) |
| 5 | **查看面数据面**:`logs/query` + `summary` + 折算链(按购买期间分段取尺) | curl 实测:字段齐全、`meta.data_until` 取最慢、金额按购买分段累加;**无归属购买的行 `amount_rmb = null` 且计入 `unpriced_count`**(2026-09-14 定) | ☐ |
| 5 | **查看面数据面**:`logs/query` + `summary` + 折算链(按购买期间分段取尺) | curl 实测:字段齐全、`meta.data_until` 取最慢、金额按购买分段累加;**无归属购买的行 `amount_rmb = null` 且计入 `unpriced_count`**(2026-09-14 定) | ✅ 2026-09-15(56 项单测绿;curl 17 步——明细字段齐全 / 分页 / type 过滤 / 参数校验 6 类 400 / 未收录 200 空结果 / `data_until` 取最慢 / 金额按归属购买折算、未折算行 `null` + `unpriced_count` 计数正确) |
| 6 | **前端 `/usage-log`**:筛选区(key 标签 + datetimerange + 快捷项)· 卡片 · 表格 + 展开行 · 分页器 · 六种状态模式 | 浏览器实测:seed 数据走一遍;六态逐一可见(加载 / 初始 / 未收录 / 无记录 / 同步中 / 失败);金额为 `null` 显示「—」 | ☐ |
| 7 | **前端管理面**:口令门帘 + 侧边栏 + 三页(key 管理 / 购买记录含核对提示与补录 / 换算参数) | 浏览器实测:登记、启停、CRUD、补录、时间线全操作 | ☐ |
| 8 | **收尾**:部署文档(`docs/delivery/`)· 端到端走查 · 文档状态更新(设计文档 → 已实现) | 一条命令链可部署(前后端独立 + 后端单实例注记);走查无阻 | ☐ |