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))
})