diff --git a/apps/server/src/lib/purchases-match.test.ts b/apps/server/src/lib/purchases-match.test.ts new file mode 100644 index 0000000..c3b7e8d --- /dev/null +++ b/apps/server/src/lib/purchases-match.test.ts @@ -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() + }) +}) diff --git a/apps/server/src/lib/purchases-match.ts b/apps/server/src/lib/purchases-match.ts new file mode 100644 index 0000000..022bddb --- /dev/null +++ b/apps/server/src/lib/purchases-match.ts @@ -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 +} diff --git a/apps/server/src/routes/manage/purchases.ts b/apps/server/src/routes/manage/purchases.ts index 6801026..735492a 100644 --- a/apps/server/src/routes/manage/purchases.ts +++ b/apps/server/src/routes/manage/purchases.ts @@ -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 + +const has = (body: Record, k: string) => + Object.prototype.hasOwnProperty.call(body, k) + +/** 宽严相济的字段解析:出现即校验;`null` 表示「显式清空」(仅可空列) */ +function parseBody( + body: Record, + 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 + > + + 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, 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, 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]!)) +}) diff --git a/apps/server/src/routes/manage/qpu.ts b/apps/server/src/routes/manage/qpu.ts index 00b0edd..a7a5dc7 100644 --- a/apps/server/src/routes/manage/qpu.ts +++ b/apps/server/src/routes/manage/qpu.ts @@ -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)) +}) diff --git a/docs/design/实施计划.md b/docs/design/实施计划.md index c6a4ae6..b0b80a1 100644 --- a/docs/design/实施计划.md +++ b/docs/design/实施计划.md @@ -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` 显示「—」 | ☐ |