feat(server): 管理面其余端点(阶段 3)

- purchases 五端点:清单(paid_at DESC)· 登记(必填校验 + 期间顺序)· 编辑(部分更新语义,null = 显式清空)· 删除(物理)· 观测(jsonb 聚合 subscription_*)
- 观测 matched_purchase_id:按假设 #6 匹配(subscription_id 优先 / 同 ID 多条取期间最近 / 唯一无期间兜底)
- qpu 时间线只读端点(effective_from 升序)
- 单测:matchPurchaseId 规则 5 项
- 实施计划:进度表阶段 3 ✅
This commit is contained in:
2026-09-15 00:39:58 +08:00
parent c26012a040
commit 4b5d21a418
5 changed files with 292 additions and 68 deletions
@@ -0,0 +1,43 @@
/**
* 观测项 → 购买记录匹配的单测(实施计划「实现假设」#6 的规则固化)。
*/
import { describe, expect, test } from 'bun:test'
import { matchPurchaseId, type PurchaseMatchCandidate } from './purchases-match'
const P = (
id: number,
subscriptionId: number | null,
periodStart: Date | null = null,
periodEnd: Date | null = null,
): PurchaseMatchCandidate => ({ id, subscriptionId, periodStart, periodEnd })
const d = (s: string) => new Date(s)
describe('matchPurchaseId(假设 #6)', () => {
test('无同 ID 记录 → null(观测到但未登记)', () => {
expect(matchPurchaseId(90, [P(1, 91), P(2, null)])).toBeNull()
expect(matchPurchaseId(90, [])).toBeNull()
})
test('同 ID 唯一 → 该条(即便无期间信息)', () => {
expect(matchPurchaseId(90, [P(7, 90)])).toBe(7)
})
test('同 ID 多条:优先「有期间」者,按 period_start 取最近', () => {
const rows = [
P(1, 90, d('2026-07-01'), d('2026-08-01')),
P(2, 90, d('2026-09-01'), d('2026-10-01')),
P(3, 90), // 无期间
]
expect(matchPurchaseId(90, rows)).toBe(2)
})
test('同 ID 多条且都无期间 → 取 id 最大者(最近登记)', () => {
expect(matchPurchaseId(90, [P(1, 90), P(5, 90), P(3, 90)])).toBe(5)
})
test('不同 subscription_id 不串号', () => {
expect(matchPurchaseId(91, [P(1, 90, d('2026-09-01'))])).toBeNull()
})
})
+34
View File
@@ -0,0 +1,34 @@
/**
* 观测项 ↔ 购买记录的匹配(实施计划「实现假设」#6:`subscription_id` 优先;
* 同 ID 多条按期间选最近的一条;无期间记录仅在「同 ID 唯一」时兜底)。
* 供 purchases 观测端点(`matched_purchase_id`)使用;阶段 5 的折算链按购买期间分段取尺。
*/
export interface PurchaseMatchCandidate {
id: number
subscriptionId: number | null
periodStart: Date | null
periodEnd: Date | null
}
/**
* 为观测到的 `subscription_id` 找已登记的购买记录:
* - 无同 ID 记录 → null(观测到但未登记——补录引导的目标)
* - 同 ID 唯一 → 该条(无论期间)
* - 同 ID 多条 → 优先「有期间」者,按 `period_start` 取最近的一条;全部无期间时取 id 最大者
*/
export function matchPurchaseId(
subscriptionId: number,
candidates: PurchaseMatchCandidate[],
): number | null {
const same = candidates.filter((p) => p.subscriptionId === subscriptionId)
if (same.length === 0) return null
if (same.length === 1) return same[0]!.id
const withPeriod = same.filter((p) => p.periodStart !== null || p.periodEnd !== null)
const pool = withPeriod.length > 0 ? withPeriod : same
const sorted = [...pool].sort(
(a, b) => (b.periodStart?.getTime() ?? 0) - (a.periodStart?.getTime() ?? 0) || b.id - a.id,
)
return sorted[0]!.id
}
+205 -55
View File
@@ -1,6 +1,13 @@
import { Hono } from 'hono'
import { desc, eq, sql } from 'drizzle-orm'
import { apiError } from '../../lib/api-error'
import { matchPurchaseId } from '../../lib/purchases-match'
import { toPurchaseRow } from '../../lib/serialize'
import { parseInputTime, toBeijingIso } from '../../lib/time'
import { manageAuth } from '../../middleware/manage-auth'
import { getDb } from '../../db/client'
import { purchases, usageLogs } from '../../db/schema'
/**
* 管理面 · 购买记录(契约 §2.3):清单 / 登记 / 编辑 / 删除 / 观测(核对提示)。
@@ -10,67 +17,210 @@ export const purchasesRoutes = new Hono()
purchasesRoutes.use(manageAuth)
type ColumnValues = Partial<typeof purchases.$inferInsert>
const has = (body: Record<string, unknown>, k: string) =>
Object.prototype.hasOwnProperty.call(body, k)
/** 宽严相济的字段解析:出现即校验;`null` 表示「显式清空」(仅可空列) */
function parseBody(
body: Record<string, unknown>,
partial: boolean,
): { error: string } | { values: ColumnValues } {
const values: ColumnValues = {}
if (!partial || has(body, 'kind')) {
const kind = body.kind
if (kind !== 'subscription' && kind !== 'topup') {
return { error: "kind 必须是 'subscription' 或 'topup'" }
}
values.kind = kind
}
if (!partial || has(body, 'paid_at')) {
const paidAt = typeof body.paid_at === 'string' ? parseInputTime(body.paid_at) : null
if (!paidAt) return { error: 'paid_at 缺失或无法解析(ISO 8601)' }
values.paidAt = paidAt
}
if (!partial || has(body, 'paid_amount')) {
const amount = body.paid_amount
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0) {
return { error: 'paid_amount 必须是非负数字' }
}
values.paidAmount = amount
}
if (has(body, 'quota_granted')) {
const q = body.quota_granted
if (q === null) {
if (!partial) return { error: 'quota_granted 必填(折算链的分母)' }
values.quotaGranted = null
} else if (typeof q !== 'number' || !Number.isInteger(q) || q < 0) {
return { error: 'quota_granted 必须是非负整数' }
} else {
values.quotaGranted = q
}
} else if (!partial) {
return { error: 'quota_granted 必填(折算链的分母)' }
}
if (has(body, 'note')) {
if (body.note === null) values.note = null
else if (typeof body.note === 'string') values.note = body.note
else return { error: 'note 必须是字符串或 null' }
}
for (const key of ['period_start', 'period_end'] as const) {
if (!has(body, key)) continue
const raw = body[key]
if (raw === null) {
values[key === 'period_start' ? 'periodStart' : 'periodEnd'] = null
continue
}
const parsed = typeof raw === 'string' ? parseInputTime(raw) : null
if (!parsed) return { error: `${key} 无法解析(ISO 8601)或为 null` }
values[key === 'period_start' ? 'periodStart' : 'periodEnd'] = parsed
}
if (has(body, 'subscription_id')) {
const sid = body.subscription_id
if (sid === null) {
values.subscriptionId = null
} else if (typeof sid === 'number' && Number.isInteger(sid) && sid > 0) {
values.subscriptionId = sid
} else {
return { error: 'subscription_id 必须是正整数或 null' }
}
}
return { values }
}
/** 清单(按 paid_at DESC,同时间按 id DESC;不分页) */
purchasesRoutes.get('/', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:purchases 清单(见 docs/design/API契约.md §2.3)',
},
},
501,
),
)
purchasesRoutes.get('/', async (c) => {
const rows = await getDb()
.select()
.from(purchases)
.orderBy(desc(purchases.paidAt), desc(purchases.id))
return c.json(rows.map(toPurchaseRow))
})
/** 观测(核对提示的数据面):按日志 other.subscription_id 聚合 */
purchasesRoutes.get('/observations', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:观测(见 docs/design/API契约.md §2.3)',
},
},
501,
),
)
purchasesRoutes.get('/observations', async (c) => {
const db = getDb()
const raw = (await db.execute(sql`
select
(${usageLogs.other} ->> 'subscription_id')::bigint as subscription_id,
(array_agg(${usageLogs.other} ->> 'subscription_plan_title' order by ${usageLogs.createdAt} desc))[1] as plan_title,
max((${usageLogs.other} ->> 'subscription_total')::bigint) as observed_quota_total,
min(${usageLogs.createdAt}) as first_seen_at,
max(${usageLogs.createdAt}) as last_seen_at,
count(*)::int as log_count
from ${usageLogs}
where ${usageLogs.other} ? 'subscription_id'
group by 1
order by 1
`)) as unknown
// 驱动差异兜底:bun-sql 直接返回行数组,部分驱动包一层 { rows }
const rows = (Array.isArray(raw) ? raw : ((raw as { rows?: unknown[] }).rows ?? [])) as Array<
Record<string, unknown>
>
const candidates = await db
.select({
id: purchases.id,
subscriptionId: purchases.subscriptionId,
periodStart: purchases.periodStart,
periodEnd: purchases.periodEnd,
})
.from(purchases)
const toDate = (v: unknown): Date => (v instanceof Date ? v : new Date(String(v)))
const toNumber = (v: unknown): number | null => (v === null || v === undefined ? null : Number(v))
const items = rows.map((r) => {
const subscriptionId = Number(r.subscription_id)
return {
subscription_id: subscriptionId,
plan_title: (r.plan_title ?? null) as string | null,
observed_quota_total: toNumber(r.observed_quota_total),
first_seen_at: toBeijingIso(toDate(r.first_seen_at)),
last_seen_at: toBeijingIso(toDate(r.last_seen_at)),
log_count: Number(r.log_count ?? 0),
matched_purchase_id: matchPurchaseId(subscriptionId, candidates),
}
})
return c.json({ items })
})
/** 登记 */
purchasesRoutes.post('/', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:登记购买记录(见 docs/design/API契约.md §2.3)',
},
},
501,
),
)
purchasesRoutes.post('/', async (c) => {
let raw: unknown
try {
raw = await c.req.json()
} catch {
return apiError(c, 400, 'invalid_params', '请求体必须是 JSON')
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return apiError(c, 400, 'invalid_params', '请求体必须是 JSON 对象')
}
const parsed = parseBody(raw as Record<string, unknown>, false)
if ('error' in parsed) return apiError(c, 400, 'invalid_params', parsed.error)
const values = parsed.values
if (
values.periodStart &&
values.periodEnd &&
values.periodStart.getTime() >= values.periodEnd.getTime()
) {
return apiError(c, 400, 'invalid_params', 'period_start 必须早于 period_end(左闭右开)')
}
const [row] = await getDb().insert(purchases).values(values as typeof purchases.$inferInsert).returning()
return c.json(toPurchaseRow(row!), 201)
})
/** 编辑(部分更新) */
purchasesRoutes.patch('/:id', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:编辑购买记录(见 docs/design/API契约.md §2.3)',
},
},
501,
),
)
purchasesRoutes.patch('/:id', async (c) => {
const id = Number(c.req.param('id'))
if (!Number.isInteger(id) || id <= 0) return apiError(c, 400, 'invalid_params', 'id 非法')
let raw: unknown
try {
raw = await c.req.json()
} catch {
return apiError(c, 400, 'invalid_params', '请求体必须是 JSON')
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return apiError(c, 400, 'invalid_params', '请求体必须是 JSON 对象')
}
const parsed = parseBody(raw as Record<string, unknown>, true)
if ('error' in parsed) return apiError(c, 400, 'invalid_params', parsed.error)
const db = getDb()
const [existing] = await db.select().from(purchases).where(eq(purchases.id, id)).limit(1)
if (!existing) return apiError(c, 404, 'not_found', `id=${id} 的购买记录不存在`)
// 期间顺序用「改后」的值校验(部分更新时另一头取库中现值)
const periodStart = 'periodStart' in parsed.values ? parsed.values.periodStart : existing.periodStart
const periodEnd = 'periodEnd' in parsed.values ? parsed.values.periodEnd : existing.periodEnd
if (periodStart && periodEnd && periodStart.getTime() >= periodEnd.getTime()) {
return apiError(c, 400, 'invalid_params', 'period_start 必须早于 period_end(左闭右开)')
}
const [row] = await db
.update(purchases)
.set(parsed.values)
.where(eq(purchases.id, id))
.returning()
return c.json(toPurchaseRow(row!))
})
/** 删除(物理) */
purchasesRoutes.delete('/:id', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:删除购买记录(见 docs/design/API契约.md §2.3)',
},
},
501,
),
)
purchasesRoutes.delete('/:id', async (c) => {
const id = Number(c.req.param('id'))
if (!Number.isInteger(id) || id <= 0) return apiError(c, 400, 'invalid_params', 'id 非法')
const rows = await getDb().delete(purchases).where(eq(purchases.id, id)).returning()
if (rows.length === 0) return apiError(c, 404, 'not_found', `id=${id} 的购买记录不存在`)
return c.json(toPurchaseRow(rows[0]!))
})
+8 -11
View File
@@ -1,6 +1,10 @@
import { Hono } from 'hono'
import { asc } from 'drizzle-orm'
import { toQpuRow } from '../../lib/serialize'
import { manageAuth } from '../../middleware/manage-auth'
import { getDb } from '../../db/client'
import { qpuHistory } from '../../db/schema'
/**
* 管理面 · 换算参数时间线(契约 §2.4):纯只读——`qpu_history` 唯一写入者是同步引擎。
@@ -10,14 +14,7 @@ export const qpuRoutes = new Hono()
qpuRoutes.use(manageAuth)
/** 时间线清单(按 effective_from 升序;不分页) */
qpuRoutes.get('/', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:QPU 时间线(见 docs/design/API契约.md §2.4)',
},
},
501,
),
)
qpuRoutes.get('/', async (c) => {
const rows = await getDb().select().from(qpuHistory).orderBy(asc(qpuHistory.effectiveFrom))
return c.json(rows.map(toQpuRow))
})
+2 -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 / 1 / 2 已完成;下一批:**阶段 3**(管理面其余端点:purchases + qpu) |
| 当前进度 | 阶段 0–3 已完成;下一批:**阶段 4**(同步引擎:tick / 日志入库 / 额度 / QPU / 401 状态机 / seed) |
---
@@ -54,7 +54,7 @@
| 0 | 脚手架 + 契约类型 + 501 骨架 | 命令链全绿(type-check / test / build) | ✅ 2026-09-14 |
| 1 | **数据层与密钥**:crypto(HKDF 派生 / HMAC-SHA256 / AES-256-GCM)· 时间工具(+08:00 序列化、UTC 秒转换)· 建表 | 单测:哈希确定性 + 加解密往返;`db:push` 后四张表在库 | ✅ 2026-09-14(15 项单测绿;四表在库——`api_keys` 17 列 / `usage_logs` 23 列 / 索引 8 个) |
| 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` 判定正确) | ☐ |
| 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`) | ☐ |
| 5 | **查看面数据面**:`logs/query` + `summary` + 折算链(按购买期间分段取尺) | curl 实测:字段齐全、`meta.data_until` 取最慢、金额按购买分段累加;**无归属购买的行 `amount_rmb = null` 且计入 `unpriced_count`**(2026-09-14 定) | ☐ |
| 6 | **前端 `/usage-log`**:筛选区(key 标签 + datetimerange + 快捷项)· 卡片 · 表格 + 展开行 · 分页器 · 六种状态模式 | 浏览器实测:seed 数据走一遍;六态逐一可见(加载 / 初始 / 未收录 / 无记录 / 同步中 / 失败);金额为 `null` 显示「—」 | ☐ |