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:
+44
-13
@@ -8,7 +8,8 @@
|
|||||||
* 查询面可用该常量 key 命中已登记 key)
|
* 查询面可用该常量 key 命中已登记 key)
|
||||||
* 2. `usage_logs`:`api.log.token.json` 的 1000 条样例日志(`ON CONFLICT DO NOTHING`)
|
* 2. `usage_logs`:`api.log.token.json` 的 1000 条样例日志(`ON CONFLICT DO NOTHING`)
|
||||||
* 3. `qpu_history` 基线:`api.status.json` 的 `quota_per_unit`(已有数据则跳过)
|
* 3. `qpu_history` 基线:`api.status.json` 的 `quota_per_unit`(已有数据则跳过)
|
||||||
* 4. 一条示例 `purchases`(订阅路径;**金额为占位值**,用于验证折算链)
|
* 4. 一条示例 `purchases`:为样例中**日志行数最多**的订阅登记(**金额为占位值**);
|
||||||
|
* 其余订阅保持未登记——保留「未折算(`amount_rmb = null`)」的展示样本
|
||||||
*
|
*
|
||||||
* 注:快照文件未入库(`.gitignore` 的 `/api*.json`),本脚本只在本地开发环境使用。
|
* 注:快照文件未入库(`.gitignore` 的 `/api*.json`),本脚本只在本地开发环境使用。
|
||||||
*/
|
*/
|
||||||
@@ -20,8 +21,9 @@ import { DEFAULT_QUOTA_PER_UNIT } from '@naku/shared'
|
|||||||
import { getDb } from '../src/db/client'
|
import { getDb } from '../src/db/client'
|
||||||
import { apiKeys, purchases, qpuHistory, usageLogs } from '../src/db/schema'
|
import { apiKeys, purchases, qpuHistory, usageLogs } from '../src/db/schema'
|
||||||
import { getKeySecrets } from '../src/lib/keys'
|
import { getKeySecrets } from '../src/lib/keys'
|
||||||
|
import { readSubscriptionId } from '../src/lib/pricing'
|
||||||
import { fromUnixSeconds } from '../src/lib/time'
|
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'
|
import type { UpstreamLogRecord } from '../src/upstream/types'
|
||||||
|
|
||||||
/** 假 key 常量:查看面 / 管理面用它做本地验证(可用 env SEED_KEY 覆盖) */
|
/** 假 key 常量:查看面 / 管理面用它做本地验证(可用 env SEED_KEY 覆盖) */
|
||||||
@@ -73,7 +75,7 @@ if (!keyRow) {
|
|||||||
totalUsed,
|
totalUsed,
|
||||||
totalAvailable: totalGranted - totalUsed,
|
totalAvailable: totalGranted - totalUsed,
|
||||||
unlimitedQuota: false,
|
unlimitedQuota: false,
|
||||||
modelLimits: {},
|
modelLimits: toJsonb({}),
|
||||||
modelLimitsEnabled: false,
|
modelLimitsEnabled: false,
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
})
|
})
|
||||||
@@ -103,12 +105,38 @@ if (!qpuRow) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 4. 示例 purchases(金额为占位值) ----------
|
// ---------- 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)
|
const [purchaseRow] = await db.select().from(purchases).limit(1)
|
||||||
if (!purchaseRow) {
|
if (purchaseRow) {
|
||||||
const first = logs[0]!
|
console.log(`[seed] purchases:已有数据(id=${purchaseRow.id}),跳过`)
|
||||||
const other = parseOther(first.other)
|
} else if (ranked.length === 0) {
|
||||||
const earliestSeconds = Math.min(...logs.map((l) => l.created_at))
|
console.log('[seed] purchases:样例日志中无 subscription_id,跳过')
|
||||||
const earliest = fromUnixSeconds(earliestSeconds)
|
} else {
|
||||||
|
const [mainSid, info] = ranked[0]!
|
||||||
|
const earliest = fromUnixSeconds(info.earliest)
|
||||||
await db.insert(purchases).values({
|
await db.insert(purchases).values({
|
||||||
kind: 'subscription',
|
kind: 'subscription',
|
||||||
paidAt: earliest,
|
paidAt: earliest,
|
||||||
@@ -116,12 +144,15 @@ if (!purchaseRow) {
|
|||||||
note: 'seed 示例(金额为占位值,非真实)',
|
note: 'seed 示例(金额为占位值,非真实)',
|
||||||
periodStart: earliest,
|
periodStart: earliest,
|
||||||
periodEnd: null,
|
periodEnd: null,
|
||||||
subscriptionId: asNumber(other.subscription_id, 90),
|
subscriptionId: mainSid,
|
||||||
quotaGranted: asNumber(other.subscription_total, 800_000_000),
|
quotaGranted: info.total > 0 ? info.total : 800_000_000,
|
||||||
})
|
})
|
||||||
console.log('[seed] purchases:已插入示例一条(折算链验证用)')
|
console.log(
|
||||||
} else {
|
`[seed] purchases:为订阅 #${mainSid}(${info.plan ?? '未知计划'},${info.count} 行日志)插入示例一条`,
|
||||||
console.log(`[seed] purchases:已有数据(id=${purchaseRow.id}),跳过`)
|
)
|
||||||
|
for (const [sid, extra] of ranked.slice(1)) {
|
||||||
|
console.log(`[seed] 订阅 #${sid}(${extra.count} 行)保持未登记——用于演示「未折算」展示`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[seed] 完成。查询面可用假 key「${SEED_KEY}」命中已登记 key。`)
|
console.log(`[seed] 完成。查询面可用假 key「${SEED_KEY}」命中已登记 key。`)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
* 上游字段全组(`api_keys` 表 8 个上游列)——登记 / 启用 / 每轮额度采集共用一处,
|
* 上游字段全组(`api_keys` 表 8 个上游列)——登记 / 启用 / 每轮额度采集共用一处,
|
||||||
* 避免三处各写一份导致漏列(api_keys 表设计 §3.4/§3.5:整组一起写)。
|
* 避免三处各写一份导致漏列(api_keys 表设计 §3.4/§3.5:整组一起写)。
|
||||||
*/
|
*/
|
||||||
|
import { toJsonb } from '../sync/log-row'
|
||||||
import type { UpstreamTokenUsage } from '../upstream/types'
|
import type { UpstreamTokenUsage } from '../upstream/types'
|
||||||
import { expiresAtFromUnix } from './time'
|
import { expiresAtFromUnix } from './time'
|
||||||
|
|
||||||
@@ -12,7 +13,8 @@ export function upstreamColumns(usage: UpstreamTokenUsage) {
|
|||||||
totalUsed: usage.total_used,
|
totalUsed: usage.total_used,
|
||||||
totalAvailable: usage.total_available,
|
totalAvailable: usage.total_available,
|
||||||
unlimitedQuota: usage.unlimited_quota,
|
unlimitedQuota: usage.unlimited_quota,
|
||||||
modelLimits: usage.model_limits,
|
// jsonb 列需显式 ::jsonb(见 log-row.ts 文件头注释的写入陷阱)
|
||||||
|
modelLimits: toJsonb(usage.model_limits),
|
||||||
modelLimitsEnabled: usage.model_limits_enabled,
|
modelLimitsEnabled: usage.model_limits_enabled,
|
||||||
expiresAt: expiresAtFromUnix(usage.expires_at),
|
expiresAt: expiresAtFromUnix(usage.expires_at),
|
||||||
}
|
}
|
||||||
|
|||||||
+331
-22
@@ -1,4 +1,25 @@
|
|||||||
import { Hono } from 'hono'
|
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 集合与过滤参数全在请求体。
|
* 查看面(契约 §2.1):无会话、无服务端状态——key 集合与过滤参数全在请求体。
|
||||||
@@ -6,26 +27,314 @@ import { Hono } from 'hono'
|
|||||||
*/
|
*/
|
||||||
export const usageRoutes = new Hono()
|
export const usageRoutes = new Hono()
|
||||||
|
|
||||||
usageRoutes.post('/logs/query', (c) =>
|
type ApiKeyRow = typeof apiKeys.$inferSelect
|
||||||
c.json(
|
type UsageLogDbRow = typeof usageLogs.$inferSelect
|
||||||
{
|
|
||||||
error: {
|
|
||||||
code: 'internal',
|
|
||||||
message: '未实现:明细查询(见 docs/design/API契约.md §2.1)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
501,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
usageRoutes.post('/summary', (c) =>
|
// ---------- 请求解析 ----------
|
||||||
c.json(
|
|
||||||
{
|
async function readJsonObject(
|
||||||
error: {
|
c: Context,
|
||||||
code: 'internal',
|
): Promise<{ error: string } | { value: Record<string, unknown> }> {
|
||||||
message: '未实现:卡片汇总(见 docs/design/API契约.md §2.1)',
|
let raw: unknown
|
||||||
},
|
try {
|
||||||
},
|
raw = await c.req.json()
|
||||||
501,
|
} 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),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* 上游日志行 → `usage_logs` 列值(同步引擎与 seed 脚本共用,避免两份映射漂移)。
|
* 上游日志行 → `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 { fromUnixSeconds } from '../lib/time'
|
||||||
import type { usageLogs } from '../db/schema'
|
import type { usageLogs } from '../db/schema'
|
||||||
import type { UpstreamLogRecord } from '../upstream/types'
|
import type { UpstreamLogRecord } from '../upstream/types'
|
||||||
import { dedupeKeyFor } from './dedupe'
|
import { dedupeKeyFor } from './dedupe'
|
||||||
|
|
||||||
/** 上游 `other`(JSON 字符串)→ jsonb;解析失败退化为空对象(保真优先,不丢整条日志) */
|
/** 上游 `other`(JSON 字符串)→ jsonb 值;解析失败退化为空对象(保真优先,不丢整条日志) */
|
||||||
export function parseOther(raw: string | undefined | null): Record<string, unknown> {
|
export function parseOther(raw: string | undefined | null): Record<string, unknown> {
|
||||||
if (!raw) return {}
|
if (!raw) return {}
|
||||||
try {
|
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 {
|
export function toLogRow(keyId: number, log: UpstreamLogRecord): typeof usageLogs.$inferInsert {
|
||||||
return {
|
return {
|
||||||
keyId,
|
keyId,
|
||||||
@@ -40,6 +51,6 @@ export function toLogRow(keyId: number, log: UpstreamLogRecord): typeof usageLog
|
|||||||
ip: log.ip ?? '',
|
ip: log.ip ?? '',
|
||||||
requestId: log.request_id ?? '',
|
requestId: log.request_id ?? '',
|
||||||
upstreamRequestId: log.upstream_request_id ?? null,
|
upstreamRequestId: log.upstream_request_id ?? null,
|
||||||
other: parseOther(log.other),
|
other: toJsonb(parseOther(log.other)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -7,7 +7,7 @@
|
|||||||
| 状态 | 定稿(2026-09-14;**进度表随 loop 持续更新**) |
|
| 状态 | 定稿(2026-09-14;**进度表随 loop 持续更新**) |
|
||||||
| 关联 | `docs/design/API契约.md` · `docs/design/同步引擎设计.md` · `docs/design/交互设计.md` · 四张表字段设计 · `AGENTS.md`(命令) |
|
| 关联 | `docs/design/API契约.md` · `docs/design/同步引擎设计.md` · `docs/design/交互设计.md` · 四张表字段设计 · `AGENTS.md`(命令) |
|
||||||
| 范围 | 阶段划分、每批验收标准、loop 机制与推进规则;技术细节一律以设计文档为准 |
|
| 范围 | 阶段划分、每批验收标准、loop 机制与推进规则;技术细节一律以设计文档为准 |
|
||||||
| 当前进度 | 阶段 0–4 已完成;下一批:**阶段 5**(查看面数据面:`logs/query` + `summary` + 折算链) |
|
| 当前进度 | 阶段 0–5 已完成;下一批:**阶段 6**(前端 `/usage-log`) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -42,6 +42,8 @@
|
|||||||
| 14 | `PATCH` 幂等分支不重复调上游验证(直接返回当前行);启用时凭证解密失败 → `500 internal`(提示需重新登记) | 与「解密失败不摘除、需人工重新登记」一致;可调 |
|
| 14 | `PATCH` 幂等分支不重复调上游验证(直接返回当前行);启用时凭证解密失败 → `500 internal`(提示需重新登记) | 与「解密失败不摘除、需人工重新登记」一致;可调 |
|
||||||
| 15 | 一轮内「日志 + 额度」两个调用的合并判定:**任一 401 → 本轮计 1 次 401**(401 优先);否则任一成功 → 计数清零;全失败 → 计数不动 | 设计(§3.4)未覆盖「一成功一 401」的冲突情形;401 优先 + N=3 阈值使单次误报无害 |
|
| 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 常量」;仅开发期 |
|
| 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 定)
|
## 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` 未被服务端接线」) |
|
| 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` 判定正确) |
|
| 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 次摘除且摘除后不再轮询**) |
|
| 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` 显示「—」 | ☐ |
|
| 6 | **前端 `/usage-log`**:筛选区(key 标签 + datetimerange + 快捷项)· 卡片 · 表格 + 展开行 · 分页器 · 六种状态模式 | 浏览器实测:seed 数据走一遍;六态逐一可见(加载 / 初始 / 未收录 / 无记录 / 同步中 / 失败);金额为 `null` 显示「—」 | ☐ |
|
||||||
| 7 | **前端管理面**:口令门帘 + 侧边栏 + 三页(key 管理 / 购买记录含核对提示与补录 / 换算参数) | 浏览器实测:登记、启停、CRUD、补录、时间线全操作 | ☐ |
|
| 7 | **前端管理面**:口令门帘 + 侧边栏 + 三页(key 管理 / 购买记录含核对提示与补录 / 换算参数) | 浏览器实测:登记、启停、CRUD、补录、时间线全操作 | ☐ |
|
||||||
| 8 | **收尾**:部署文档(`docs/delivery/`)· 端到端走查 · 文档状态更新(设计文档 → 已实现) | 一条命令链可部署(前后端独立 + 后端单实例注记);走查无阻 | ☐ |
|
| 8 | **收尾**:部署文档(`docs/delivery/`)· 端到端走查 · 文档状态更新(设计文档 → 已实现) | 一条命令链可部署(前后端独立 + 后端单实例注记);走查无阻 | ☐ |
|
||||||
|
|||||||
Reference in New Issue
Block a user