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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]!))
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user