Compare commits

...
10 Commits
Author SHA1 Message Date
skycurtain 4a7a3e76ee fix(web): 成员侧边栏品牌与管理端对齐 2026-10-09 23:20:41 +08:00
skycurtain 502f985147 feat(web): 成员端仅总览轮询,相对时间窗定格 2026-10-09 23:20:05 +08:00
skycurtain e3e0978b95 fix(web): 订阅表付款列头改为「付款金额 / 时间」 2026-10-09 22:43:18 +08:00
skycurtain c32548e598 fix(web): 支付表列与表单语义校准
充值金额/实付金额文案与 dataIndex;期间两行;备注列收窄;
选用弹层 booting 不闪登录;订阅 paid_at 不默认当前时间。
2026-10-09 22:41:04 +08:00
skycurtain 00c08b37ff docs: payments 拆表设计定稿与契约/交互对齐
新增建模评估与 subscriptions/topups 表设计;删除 payments 单表篇;
契约、支付交互、需求清单与文档地图同步。
2026-10-09 22:03:43 +08:00
skycurtain c03fdc3d67 feat(web): 支付记录双 Tab 与平台选用弹层
订阅/充值分形态表单(秒级时间、金额非负、额度换算预览);
选用弹层已登记灰显、每次打开重拉、关弹窗清密码。
2026-10-09 22:02:36 +08:00
skycurtain 10d8f4c915 feat(server): 订阅/充值 CRUD 与平台选用分页
admin-subscriptions/topups 取代 payments;member overview 分两集;
platform/topups 单页代拉;上游 token 失效回 422,避免误踢管理端会话。
2026-10-09 22:02:20 +08:00
skycurtain c7cc6f6dfd feat(db): payments 拆为 subscriptions 与 topups
订阅权益与充值入账分表;topups 对齐上游充值单(无 quota_granted),
paid_at=complete_time;quota_derived 查询层派生。
2026-10-09 22:02:10 +08:00
skycurtain d4d740222e fix(web): 平台订阅选用弹窗居中并限高内滚 2026-10-09 20:50:15 +08:00
skycurtain 0269cf3386 feat(web): 成员端同构三页与日志详情,管理端轻量筛选
成员视图改管理端壳(总览/调用日志/支付参数),行内详情弹窗含原始数据;
成员日志含错误行;共享时间范围。管理端成员/支付筛选;支付仅留类型筛选。
2026-10-09 17:04:59 +08:00
62 changed files with 5686 additions and 1631 deletions
+5 -4
View File
@@ -15,7 +15,7 @@
## 项目速览(速记)
- **`v1`(本分支)= 设计文档中心 + 实现主线**:实现代码与文档同分支演进(代码落点已定案),monorepo 结构(`apps/server` · `apps/web` · `packages/core` · `packages/db`)见 `docs/design/技术选型.md` §4
- 数据模型:四张基线表(`api_keys` · `usage_logs` · `payments` · `qpu_history`)+ 三张访问主体表(`members` · `users` · `sessions`)设计见本目录,**七篇已定稿(2026-10-01,经作者逐篇审阅)**;同步架构(轮询 + `request_id` 去重 + 攒库)的动因由上游物理约束决定,架构结论不变
- 数据模型:基线表(`api_keys` · `usage_logs` · `subscriptions` · `topups` · `qpu_history`)+ 三张访问主体表(`members` · `users` · `sessions`)——`payments` 单表已按方案 B 拆为 `subscriptions` + `topups`(见 `docs/design/payments建模评估.md`);同步架构(轮询 + `request_id` 去重 + 攒库)的动因由上游物理约束决定,架构结论不变
- 需求总集与待裁决项(平台身份体系、费用分摊与账单、充值折算、多订阅、数据资产保护)见 `docs/design/需求清单.md`;交付形态已裁决(双形态并行,完整形态先行)
- 上游约束速记:只有 sk-key、无时间过滤接口 → 轮询攒库是唯一路径(README「架构的根本动因」)
@@ -27,14 +27,15 @@ Bun 工具链(选型见 `docs/design/技术选型.md`)。**Windows 上 `bun`
|------|------|
| `bun install` | 安装 / 链接 workspace 依赖(在仓库根) |
| `bun test` | 全部测试(core 契约 + server HTTP + **集成**);集成需 `DATABASE_URL`(compose 中的 PG),无库时自动跳过 |
| `bun --env-file=.env scripts/seed-from-samples.ts` | 从根目录 `api*.json` 实测快照灌开发库(幂等);验收接口口径用 |
| `bun run db:generate` | 生成 SQL 迁移(`packages/db/drizzle/`) |
| `bun run db:push` | 推送到本地库(开发期) |
| `bun run db:studio` | Drizzle Studio 看库 |
| `bun run typecheck` | 根 tsconfig 类型检查(不含 web `.vue`) |
| `bun run dev:server` | 开发跑母体后端(hot reload) |
| `bun run dev:engine` | 开发跑同步引擎(独立进程,读 `SYNC_*` env) |
| `bun run dev:web` | 开发跑前端 Vite |
| `bun run build:web` | 前端类型检查 + 产物构建 |
| `docker compose up -d` | 起开发用 PostgreSQL |
| `bun run --filter @new-api-key-usage/db db:generate` | 生成 SQL 迁移(`packages/db/drizzle/`) |
| `bun run --filter @new-api-key-usage/db db:push` | 推送到本地库(开发期) |
环境变量见 `.env.example`(复制为 `.env` 后填写)。
+8 -6
View File
@@ -22,11 +22,13 @@
| 文档 | 什么时候读 | 状态 |
|------|-----------|------|
| [`docs/design/需求清单.md`](docs/design/需求清单.md) | **需求权威索引与裁决档案**——R1–R15 全部定案(含裁撤与延后),R 编号体系为后续设计文档的引用锚点 | 定稿(2026-10-01) |
| [`docs/design/需求清单.md`](docs/design/需求清单.md) | **需求权威索引与裁决档案**——R1–R15 全部定案;2026-10-09 增补支付拆表裁决 | 定稿(2026-10-09) |
| [`docs/design/技术选型.md`](docs/design/技术选型.md) | 母体技术栈裁决(CFW 延后至功能全集实现后,仅零成本 R14 预留):全栈 TS;Bun + Hono + Drizzle/pg;前端 Vue 3 + antdv-next;分层落地与回退;antdv-next 核验通过(§7.1) | 定稿(2026-10-02) |
| [`docs/design/api_keys表字段设计.md`](docs/design/api_keys表字段设计.md) | 数据模型第一张表:key 登记、生命周期与成员归属 | 定稿(2026-10-01) |
| [`docs/design/usage_logs表字段设计.md`](docs/design/usage_logs表字段设计.md) | 数据模型第二张表:日志明细、来源提列、去重与写入策略 | 同上 |
| [`docs/design/payments表字段设计.md`](docs/design/payments表字段设计.md) | 数据模型第三张表:支付记录(订阅/充值同构,币种无关,只存事实) | 同上 |
| [`docs/design/payments建模评估.md`](docs/design/payments建模评估.md) | 支付建模复盘:订阅权益 vs 充值入账两分;**方案 B 拆表已采纳**(2026-10-09) | 定稿(2026-10-09) |
| [`docs/design/subscriptions表字段设计.md`](docs/design/subscriptions表字段设计.md) | 订阅权益:期间 + 额度预算 + 可选付款事实;`subscription_id` 直连日志 | 待审阅(2026-10-09) |
| [`docs/design/topups表字段设计.md`](docs/design/topups表字段设计.md) | 充值入账事件:`paid_at` 主轴;不挂订阅 | 待审阅(2026-10-09) |
| [`docs/design/qpu_history表字段设计.md`](docs/design/qpu_history表字段设计.md) | 数据模型第四张表:换算参数时间线(自动采样、只读) | 同上 |
| [`docs/design/members表字段设计.md`](docs/design/members表字段设计.md) | 数据模型新增表:成员实体——key 归属、成员视图身份与按成员统计的锚点 | 定稿(2026-10-01) |
| [`docs/design/users表字段设计.md`](docs/design/users表字段设计.md) | 数据模型新增表:管理员(单行)——登录体系的身份落点 | 同上 |
@@ -36,13 +38,13 @@
| [`docs/design/交互设计-全员总览.md`](docs/design/交互设计-全员总览.md) | 管理面首页:健康带 + 按成员 / key 汇总 | 同上 |
| [`docs/design/交互设计-调用日志.md`](docs/design/交互设计-调用日志.md) | 管理面调用日志:筛选、明细表、与 key/成员/总览的跳转链 | 草案(待审阅) |
| [`docs/design/交互设计-key管理.md`](docs/design/交互设计-key管理.md) | 登记(sk-key 唯一界面露面)、启停、归属、死亡显形 | 同上 |
| [`docs/design/交互设计-支付记录.md`](docs/design/交互设计-支付记录.md) | 订阅/充值同构表单、核对提示 | 同上 |
| [`docs/design/交互设计-支付记录.md`](docs/design/交互设计-支付记录.md) | 支付记录页(两 Tab):订阅权益 / 充值入账登记、核对提示 | 待审阅(2026-10-09 拆表重写) |
| [`docs/design/交互设计-成员管理.md`](docs/design/交互设计-成员管理.md) | 开户、令牌签发/重发(明文仅一次)、停用 | 同上 |
| [`docs/design/交互设计-审计日志.md`](docs/design/交互设计-审计日志.md) | 管理操作留痕时间线 | 同上 |
| [`docs/design/交互设计-成员视图.md`](docs/design/交互设计-成员视图.md) | 成员单页世界——R12 携带形态定案(徽章 + 展开行) | 同上 |
| [`docs/design/交互设计-成员视图.md`](docs/design/交互设计-成员视图.md) | 成员世界——管理端同构壳三页(总览 / 调用日志 / 支付参数);R12:订阅徽章弹窗 + 行展开调用详情 | 定稿(2026-10-09) |
| [`docs/design/API契约-公共约定.md`](docs/design/API契约-公共约定.md) | API 契约总纲:路由隔离与鉴权两档、错误信封、时间 / 数值 / 分页 / 命名(8 条拟定约定经审通过)——后两篇的引用锚点 | 定稿(2026-10-02) |
| [`docs/design/API契约-管理面.md`](docs/design/API契约-管理面.md) | 管理面 16 端点:引导与会话三态、引擎状态、总览聚合、key 登记四态、下钻明细、支付、成员(令牌明文仅一次)、审计;5 项裁定(经审通过)+ 4 项遗留(key 详情已裁决:实现期定) | 定稿(2026-10-02) |
| [`docs/design/API契约-成员视图.md`](docs/design/API契约-成员视图.md) | 成员视图 3 端点:令牌验证与页头、单页聚合(含支付参数卡判定)、日志明细(DTO 与管理面同构);4 项裁定(经审通过)+ 1 项遗留 | 定稿(2026-10-02) |
| [`docs/design/API契约-管理面.md`](docs/design/API契约-管理面.md) | 管理面 18 端点:引导与会话、引擎、总览、key、日志、**订阅/充值拆资源**、成员、审计 | 待审阅(2026-10-09 §7 拆表修订) |
| [`docs/design/API契约-成员视图.md`](docs/design/API契约-成员视图.md) | 成员视图 3 端点:`/me` · `/overview`(`subscriptions`+`topups`)· `/logs` | 待审阅(2026-10-09 拆表修订) |
| [`docs/design/同步引擎设计.md`](docs/design/同步引擎设计.md) | 独立 `apps/engine` 进程:手写 `setTimeout` 轮次、失败只记录、`engine_status` 快照供 `/api/admin/engine`;周期可配、**不为 CFW 预留** | 已实现(定稿 2026-10-05) |
| [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md) | 计费平台在 NewAPI 中可用的接口参考:鉴权档位、请求与返回结构、字段语义、实测行为、源码出处(**唯一上游参考**) | 事实依据 |
+11 -2
View File
@@ -3,6 +3,8 @@ import "./zod-openapi-setup.ts";
import {
changePasswordBody,
createMemberBody,
createSubscriptionBody,
createTopupBody,
engineStatus,
errorEnvelope,
keyItem,
@@ -14,7 +16,8 @@ import {
memberWithToken,
patchKeyBody,
patchMemberBody,
paymentItem,
subscriptionItem,
topupItem,
auditLogItem,
registerKeyBody,
setupBody,
@@ -50,12 +53,18 @@ export const memberOverviewSchema = nameSchema(
);
export const logItemSchema = nameSchema(logItem, "LogItem");
export const logPageSchema = nameSchema(logPage, "LogPage");
export const paymentItemSchema = nameSchema(paymentItem, "PaymentItem");
export const subscriptionItemSchema = nameSchema(
subscriptionItem,
"SubscriptionItem",
);
export const topupItemSchema = nameSchema(topupItem, "TopupItem");
export const auditLogItemSchema = nameSchema(auditLogItem, "AuditLogItem");
export {
changePasswordBody as changePasswordBodySchema,
createMemberBody as createMemberBodySchema,
createSubscriptionBody as createSubscriptionBodySchema,
createTopupBody as createTopupBodySchema,
loginBody as loginBodySchema,
patchKeyBody as patchKeyBodySchema,
patchMemberBody as patchMemberBodySchema,
+4 -2
View File
@@ -9,7 +9,8 @@ import { adminEngineRoutes } from "./routes/admin-engine.ts";
import { adminKeyRoutes } from "./routes/admin-keys.ts";
import { adminMemberRoutes } from "./routes/admin-members.ts";
import { adminUsageRoutes } from "./routes/admin-usage.ts";
import { adminPaymentRoutes } from "./routes/admin-payments.ts";
import { adminSubscriptionRoutes } from "./routes/admin-subscriptions.ts";
import { adminTopupRoutes } from "./routes/admin-topups.ts";
import { adminAuditRoutes } from "./routes/admin-audit.ts";
import { memberViewRoutes } from "./routes/member-view.ts";
@@ -117,7 +118,8 @@ export function createApp(opts: CreateAppOptions) {
admin.route("/", adminKeyRoutes());
admin.route("/", adminMemberRoutes());
admin.route("/", adminUsageRoutes());
admin.route("/", adminPaymentRoutes());
admin.route("/", adminSubscriptionRoutes());
admin.route("/", adminTopupRoutes());
admin.route("/", adminAuditRoutes());
admin.route("/", adminPlatformRoutes());
app.route("/api/admin", admin);
-287
View File
@@ -1,287 +0,0 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import {
ERR,
qpuAt,
quotaToUsdStr,
resolveQpuTimeline,
toUnixSec,
type PaymentItem,
} from "@new-api-key-usage/core";
import type { PaymentRow } from "@new-api-key-usage/db";
import {
jsonError,
requireAdminSession,
type AppVars,
} from "../middleware.ts";
import { err401, err404, err422, paymentItemSchema } from "../api-schemas.ts";
import { z } from "zod";
const createBody = z.object({
kind: z.enum(["subscription", "topup"]),
paid_at: z.string(),
paid_amount: z.number(),
remark: z.string().optional().default(""),
period_start: z.string().nullable().optional(),
period_end: z.string().nullable().optional(),
subscription_id: z.number().int().positive().nullable().optional(),
quota_granted: z.number().int().nullable().optional(),
});
function toPaymentItem(row: PaymentRow, quota_usd: string): PaymentItem {
return {
id: row.id,
kind: row.kind as PaymentItem["kind"],
paid_at: toUnixSec(row.paid_at)!,
paid_amount: row.paid_amount,
remark: row.remark,
period_start: toUnixSec(row.period_start),
period_end: toUnixSec(row.period_end),
subscription_id: row.subscription_id,
quota_granted: row.quota_granted,
quota_usd,
};
}
function validatePaymentBody(
body: Record<string, unknown>,
):
| { fields: Record<string, string> }
| {
values: {
kind: string;
paid_at: Date;
paid_amount: number;
remark: string;
period_start: Date | null;
period_end: Date | null;
subscription_id: number | null;
quota_granted: number | null;
};
} {
const fields: Record<string, string> = {};
const kind = body?.kind;
if (kind !== "subscription" && kind !== "topup") {
fields.kind = "请选择订阅或充值";
return { fields };
}
if (!body?.paid_at) fields.paid_at = "请填写支付时间";
if (typeof body?.paid_amount !== "number") {
fields.paid_amount = "请填写支付金额";
}
if (body?.quota_granted == null) fields.quota_granted = "请填写本笔额度";
if (kind === "subscription") {
if (!body?.period_start) fields.period_start = "订阅必须填写期间起点";
if (body?.subscription_id == null) {
fields.subscription_id = "订阅必须填写订阅编号";
}
}
if (Object.keys(fields).length > 0) return { fields };
return {
values: {
kind: kind as string,
paid_at: new Date(String(body.paid_at)),
paid_amount: Number(body.paid_amount),
remark: typeof body.remark === "string" ? body.remark : "",
period_start:
kind === "subscription" && body.period_start
? new Date(String(body.period_start))
: null,
period_end:
body.period_end != null && body.period_end !== ""
? new Date(String(body.period_end))
: null,
subscription_id:
kind === "subscription" && body.subscription_id != null
? Number(body.subscription_id)
: null,
quota_granted: Number(body.quota_granted),
},
};
}
export function adminPaymentRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
r.use("*", requireAdminSession);
r.openapi(
createRoute({
method: "get",
path: "/payments",
summary: "支付记录清单",
description: "全量直返、支付时间倒序;quota_usd 按 paid_at 时刻 QPU 换算",
tags: ["支付记录"],
security: [{ adminSession: [] }],
responses: {
200: {
description: "payment 数组",
content: {
"application/json": { schema: z.array(paymentItemSchema) },
},
},
401: err401,
},
}),
async (c) => {
const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const rows = await repos.payments.listAll();
const items = rows.map((row) => {
const qpu = qpuAt(row.paid_at, timeline);
return toPaymentItem(
row,
row.quota_granted != null
? quotaToUsdStr(row.quota_granted, qpu)
: "0.00",
);
});
return c.json(items, 200);
},
);
r.openapi(
createRoute({
method: "post",
path: "/payments",
summary: "登记支付",
description: "订阅/充值同构;kind 决定期间与订阅编号是否必填",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
body: {
content: { "application/json": { schema: createBody } },
required: true,
},
},
responses: {
201: {
description: "payment",
content: { "application/json": { schema: paymentItemSchema } },
},
401: err401,
422: err422,
},
}),
async (c) => {
const body = (await c.req.json().catch(() => ({}))) as Record<
string,
unknown
>;
const parsed = validatePaymentBody(body);
if ("fields" in parsed) {
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
}
const repos = c.get("repos");
const row = await repos.payments.insert(parsed.values);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "payment.create",
summary: `登记支付 #${row.id} ${row.kind}`,
});
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const qpu = qpuAt(row.paid_at, timeline);
return c.json(
toPaymentItem(
row,
row.quota_granted != null
? quotaToUsdStr(row.quota_granted, qpu)
: "0.00",
),
201,
);
},
);
r.openapi(
createRoute({
method: "put",
path: "/payments/{id}",
summary: "编辑支付",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
body: {
content: { "application/json": { schema: createBody } },
required: true,
},
},
responses: {
200: {
description: "payment",
content: { "application/json": { schema: paymentItemSchema } },
},
401: err401,
404: err404,
422: err422,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const body = (await c.req.json().catch(() => ({}))) as Record<
string,
unknown
>;
const parsed = validatePaymentBody(body);
if ("fields" in parsed) {
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
}
const repos = c.get("repos");
if (!(await repos.payments.findById(id))) {
return jsonError(c, 404, ERR.not_found());
}
const row = await repos.payments.update(id, parsed.values);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "payment.update",
summary: `编辑支付 #${row.id}`,
});
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const qpu = qpuAt(row.paid_at, timeline);
return c.json(
toPaymentItem(
row,
row.quota_granted != null
? quotaToUsdStr(row.quota_granted, qpu)
: "0.00",
),
200,
);
},
);
r.openapi(
createRoute({
method: "delete",
path: "/payments/{id}",
summary: "删除支付",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
},
responses: {
204: { description: "No Content" },
401: err401,
404: err404,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const repos = c.get("repos");
const before = await repos.payments.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found());
await repos.payments.remove(id);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "payment.delete",
summary: `删除支付 #${id}`,
});
return c.body(null, 204);
},
);
return r;
}
+70 -1
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import {
ERR,
fetchPlatformSubscriptions,
fetchPlatformTopups,
platformLogin,
} from "@new-api-key-usage/core";
import { jsonError, requireAdminSession, type AppVars } from "../middleware.ts";
@@ -131,7 +132,8 @@ export function adminPlatformRoutes() {
const subs = await fetchPlatformSubscriptions(c.get("newapiBase"), token);
if (!subs.ok) {
if (subs.kind === "rejected") {
return jsonError(c, 401, ERR.session_expired());
// 上游 token 失效 ≠ 管理端会话过期:绝不能回 401(前端会误登出)
return jsonError(c, 422, ERR.platform_token_invalid());
}
return jsonError(c, 502, ERR.upstream_unreachable());
}
@@ -139,5 +141,72 @@ export function adminPlatformRoutes() {
},
);
r.openapi(
createRoute({
method: "post",
path: "/platform/topups",
summary: "分页拉取充值单(登记辅助)",
tags: ["支付"],
security: [{ adminSession: [] }],
request: {
body: {
content: {
"application/json": {
schema: listBody.extend({
page: z.number().int().positive().optional(),
page_size: z.number().int().positive().max(50).optional(),
}),
},
},
required: true,
},
},
responses: {
200: {
description: "topups page",
content: {
"application/json": {
schema: z.object({
items: z.array(z.looseObject({ id: z.number().int() })),
total: z.number().int(),
page: z.number().int(),
page_size: z.number().int(),
}),
},
},
},
401: err401,
422: err422,
502: err502,
},
}),
async (c) => {
const body = await c.req.json().catch(() => null);
const token = String(body?.access_token ?? "");
if (!token) {
return jsonError(
c,
422,
ERR.validation_failed({ access_token: "缺少 access_token" }),
);
}
const page = Number(body?.page ?? 1) || 1;
const pageSize = Math.min(50, Number(body?.page_size ?? 10) || 10);
const tops = await fetchPlatformTopups(
c.get("newapiBase"),
token,
page,
pageSize,
);
if (!tops.ok) {
if (tops.kind === "rejected") {
return jsonError(c, 422, ERR.platform_token_invalid());
}
return jsonError(c, 502, ERR.upstream_unreachable());
}
return c.json(tops.data, 200);
},
);
return r;
}
@@ -0,0 +1,282 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import {
ERR,
errorBody,
qpuAt,
quotaToUsdStr,
resolveQpuTimeline,
toUnixSec,
type SubscriptionItem,
} from "@new-api-key-usage/core";
import type { SubscriptionRow } from "@new-api-key-usage/db";
import {
jsonError,
requireAdminSession,
type AppVars,
} from "../middleware.ts";
import {
createSubscriptionBodySchema,
err401,
err404,
err422,
errorSchema,
subscriptionItemSchema,
} from "../api-schemas.ts";
import { z } from "zod";
function toSubscriptionItem(
row: SubscriptionRow,
quota_usd: string,
): SubscriptionItem {
return {
id: row.id,
subscription_id: row.subscription_id,
period_start: toUnixSec(row.period_start)!,
period_end: toUnixSec(row.period_end),
quota_granted: row.quota_granted,
quota_usd,
paid_amount: row.paid_amount,
paid_at: toUnixSec(row.paid_at),
remark: row.remark,
created_at: toUnixSec(row.created_at)!,
};
}
function parseSubBody(body: z.infer<typeof createSubscriptionBodySchema>):
| { fields: Record<string, string> }
| {
values: {
subscription_id: number;
period_start: Date;
period_end: Date | null;
quota_granted: number;
paid_amount: number | null;
paid_at: Date | null;
remark: string;
};
} {
const fields: Record<string, string> = {};
if (body.subscription_id == null || body.subscription_id <= 0) {
fields.subscription_id = "请填写订阅编号";
}
if (!body.period_start) fields.period_start = "请填写期间起点";
if (body.quota_granted == null || body.quota_granted <= 0) {
fields.quota_granted = "请填写套餐总额度";
}
if (Object.keys(fields).length > 0) return { fields };
return {
values: {
subscription_id: body.subscription_id,
period_start: new Date(body.period_start),
period_end: body.period_end
? new Date(body.period_end)
: null,
quota_granted: body.quota_granted,
paid_amount: body.paid_amount ?? null,
paid_at: body.paid_at ? new Date(body.paid_at) : null,
remark: body.remark ?? "",
},
};
}
export function adminSubscriptionRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
r.use("*", requireAdminSession);
r.openapi(
createRoute({
method: "get",
path: "/subscriptions",
summary: "订阅权益清单",
tags: ["支付记录"],
security: [{ adminSession: [] }],
responses: {
200: {
description: "subscription 数组",
content: {
"application/json": { schema: z.array(subscriptionItemSchema) },
},
},
401: err401,
},
}),
async (c) => {
const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const rows = await repos.subscriptions.listAll();
return c.json(
rows.map((row) =>
toSubscriptionItem(
row,
quotaToUsdStr(
row.quota_granted,
qpuAt(row.paid_at ?? row.created_at, timeline),
),
),
),
200,
);
},
);
r.openapi(
createRoute({
method: "post",
path: "/subscriptions",
summary: "登记订阅权益",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
body: {
content: {
"application/json": { schema: createSubscriptionBodySchema },
},
},
},
responses: {
201: {
description: "subscription",
content: {
"application/json": { schema: subscriptionItemSchema },
},
},
401: err401,
409: {
description: "重复登记",
content: { "application/json": { schema: errorSchema } },
},
422: err422,
},
}),
async (c) => {
const body = c.req.valid("json");
const parsed = parseSubBody(body);
if ("fields" in parsed) {
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
}
const repos = c.get("repos");
const exists = await repos.subscriptions.findBySubscriptionId(
parsed.values.subscription_id,
);
if (exists) {
return jsonError(
c,
409,
errorBody(
"duplicate",
`订阅 #${parsed.values.subscription_id} 已有登记`,
),
);
}
const row = await repos.subscriptions.insert(parsed.values);
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "subscription.create",
summary: `订阅 #${row.subscription_id} · 登记`,
});
return c.json(
toSubscriptionItem(
row,
quotaToUsdStr(
row.quota_granted,
qpuAt(row.paid_at ?? row.created_at, timeline),
),
),
201,
);
},
);
r.openapi(
createRoute({
method: "put",
path: "/subscriptions/{id}",
summary: "编辑订阅权益",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
body: {
content: {
"application/json": { schema: createSubscriptionBodySchema },
},
},
},
responses: {
200: {
description: "subscription",
content: {
"application/json": { schema: subscriptionItemSchema },
},
},
401: err401,
404: err404,
422: err422,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const body = c.req.valid("json");
const parsed = parseSubBody(body);
if ("fields" in parsed) {
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
}
const repos = c.get("repos");
if (!(await repos.subscriptions.findById(id))) {
return jsonError(c, 404, ERR.not_found());
}
const row = await repos.subscriptions.update(id, parsed.values);
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "subscription.update",
summary: `订阅 #${row.subscription_id} · 编辑`,
});
return c.json(
toSubscriptionItem(
row,
quotaToUsdStr(
row.quota_granted,
qpuAt(row.paid_at ?? row.created_at, timeline),
),
),
200,
);
},
);
r.openapi(
createRoute({
method: "delete",
path: "/subscriptions/{id}",
summary: "删除订阅权益",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: { params: z.object({ id: z.string() }) },
responses: {
204: { description: "deleted" },
401: err401,
404: err404,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const repos = c.get("repos");
const before = await repos.subscriptions.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found());
await repos.subscriptions.remove(id);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "subscription.delete",
summary: `订阅 #${before.subscription_id} · 删除`,
});
return c.body(null, 204);
},
);
return r;
}
+238
View File
@@ -0,0 +1,238 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import {
ERR,
qpuAt,
quotaToUsdStr,
resolveQpuTimeline,
toUnixSec,
type TopupItem,
} from "@new-api-key-usage/core";
import type { TopupRow } from "@new-api-key-usage/db";
import {
jsonError,
requireAdminSession,
type AppVars,
} from "../middleware.ts";
import {
createTopupBodySchema,
err401,
err404,
err422,
topupItemSchema,
} from "../api-schemas.ts";
import { z } from "zod";
function toTopupItem(row: TopupRow, qpu: number): TopupItem {
const quota_derived = Math.round(row.amount * qpu);
return {
id: row.id,
upstream_id: row.upstream_id,
amount: row.amount,
money: row.money,
trade_no: row.trade_no,
payment_method: row.payment_method,
payment_provider: row.payment_provider,
status: row.status,
paid_at: toUnixSec(row.paid_at)!,
quota_derived,
quota_usd: quotaToUsdStr(quota_derived, qpu),
remark: row.remark,
created_at: toUnixSec(row.created_at)!,
};
}
function parseTopupBody(body: z.infer<typeof createTopupBodySchema>):
| { fields: Record<string, string> }
| {
values: {
upstream_id: number | null;
amount: number;
money: number;
trade_no: string | null;
payment_method: string | null;
payment_provider: string | null;
status: string | null;
paid_at: Date;
remark: string;
};
} {
const fields: Record<string, string> = {};
if (!body.paid_at) fields.paid_at = "请填写入账时间";
if (typeof body.amount !== "number") fields.amount = "请填写计费单位金额";
if (typeof body.money !== "number") fields.money = "请填写实付金额";
if (Object.keys(fields).length > 0) return { fields };
return {
values: {
upstream_id: body.upstream_id ?? null,
amount: body.amount,
money: body.money,
trade_no: body.trade_no ?? null,
payment_method: body.payment_method ?? null,
payment_provider: body.payment_provider ?? null,
status: body.status ?? "success",
paid_at: new Date(body.paid_at),
remark: body.remark ?? "",
},
};
}
export function adminTopupRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
r.use("*", requireAdminSession);
r.openapi(
createRoute({
method: "get",
path: "/topups",
summary: "充值入账清单",
tags: ["支付记录"],
security: [{ adminSession: [] }],
responses: {
200: {
description: "topup 数组",
content: {
"application/json": { schema: z.array(topupItemSchema) },
},
},
401: err401,
},
}),
async (c) => {
const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const rows = await repos.topups.listAll();
return c.json(
rows.map((row) => toTopupItem(row, qpuAt(row.paid_at, timeline))),
200,
);
},
);
r.openapi(
createRoute({
method: "post",
path: "/topups",
summary: "登记充值",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
body: {
content: {
"application/json": { schema: createTopupBodySchema },
},
required: true,
},
},
responses: {
201: {
description: "topup",
content: { "application/json": { schema: topupItemSchema } },
},
401: err401,
422: err422,
},
}),
async (c) => {
const body = (await c.req.json().catch(() => ({}))) as z.infer<
typeof createTopupBodySchema
>;
const parsed = parseTopupBody(body as z.infer<typeof createTopupBodySchema>);
if ("fields" in parsed) {
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
}
const repos = c.get("repos");
const row = await repos.topups.insert(parsed.values);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "topup.create",
summary: `充值 #${row.id} · 登记`,
});
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
return c.json(toTopupItem(row, qpuAt(row.paid_at, timeline)), 201);
},
);
r.openapi(
createRoute({
method: "put",
path: "/topups/{id}",
summary: "编辑充值",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
body: {
content: {
"application/json": { schema: createTopupBodySchema },
},
required: true,
},
},
responses: {
200: {
description: "topup",
content: { "application/json": { schema: topupItemSchema } },
},
401: err401,
404: err404,
422: err422,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const body = (await c.req.json().catch(() => ({}))) as z.infer<
typeof createTopupBodySchema
>;
const parsed = parseTopupBody(body as z.infer<typeof createTopupBodySchema>);
if ("fields" in parsed) {
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
}
const repos = c.get("repos");
if (!(await repos.topups.findById(id))) {
return jsonError(c, 404, ERR.not_found());
}
const row = await repos.topups.update(id, parsed.values);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "topup.update",
summary: `充值 #${row.id} · 编辑`,
});
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
return c.json(toTopupItem(row, qpuAt(row.paid_at, timeline)), 200);
},
);
r.openapi(
createRoute({
method: "delete",
path: "/topups/{id}",
summary: "删除充值",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: { params: z.object({ id: z.string() }) },
responses: {
204: { description: "deleted" },
401: err401,
404: err404,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const repos = c.get("repos");
const before = await repos.topups.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found());
await repos.topups.remove(id);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "topup.delete",
summary: `充值 #${before.id} · 删除`,
});
return c.body(null, 204);
},
);
return r;
}
+27
View File
@@ -359,6 +359,33 @@ export function adminUsageRoutes() {
is_stream: row.is_stream,
billing_source: row.billing_source as LogItem["billing_source"],
subscription_id: row.subscription_id,
raw: {
id: row.id,
api_key_id: row.api_key_id,
dedupe_key: row.dedupe_key,
billing_source: row.billing_source,
subscription_id: row.subscription_id,
user_id: row.user_id,
created_at: toUnixSec(row.created_at),
type: row.type,
content: row.content,
username: row.username,
token_name: row.token_name,
model_name: row.model_name,
quota: row.quota,
prompt_tokens: row.prompt_tokens,
completion_tokens: row.completion_tokens,
use_time: row.use_time,
is_stream: row.is_stream,
channel: row.channel,
channel_name: row.channel_name,
token_id: row.token_id,
group_name: row.group_name,
ip: row.ip,
request_id: row.request_id,
upstream_request_id: row.upstream_request_id,
other: row.other,
},
};
});
+90 -42
View File
@@ -6,7 +6,6 @@ import {
usageRowPrecise,
toUnixSec,
type LogItem,
type PaymentItem,
} from "@new-api-key-usage/core";
import {
jsonError,
@@ -19,7 +18,6 @@ import {
logPageSchema,
memberMeSchema,
memberOverviewSchema,
paymentItemSchema,
} from "../api-schemas.ts";
import { z } from "zod";
@@ -161,42 +159,55 @@ export function memberViewRoutes() {
usage: sumUsage(v.rows, timeline),
}));
// 「该时段涉及」支付:订阅 = 日志出现过的 subscription_id ∪ 进行中;充值 = paid_at ∈ [from,to)
const paymentRows = await repos.payments.listAll();
// 「该时段涉及」:订阅 = 日志出现过的 subscription_id ∪ 进行中;充值 = paid_at ∈ [from,to)
const { quotaToUsdStr, qpuAt } = await import("@new-api-key-usage/core");
const subRows = await repos.subscriptions.listAll();
const usedSubIds = new Set(
rows.map((x) => x.subscription_id).filter((v): v is number => v != null),
);
const payments: PaymentItem[] = [];
const { quotaToUsdStr, qpuAt } = await import("@new-api-key-usage/core");
for (const p of paymentRows) {
const inRange = p.paid_at >= range.from && p.paid_at < range.to;
const subscriptionsOut = [];
for (const s of subRows) {
const ongoing =
p.kind === "subscription" &&
(p.period_end == null ||
p.period_end > new Date() ||
(p.period_start != null &&
p.period_start <= range.to &&
(p.period_end ?? new Date()) >= range.from));
const involved =
p.kind === "topup"
? inRange
: usedSubIds.has(p.subscription_id ?? -1) || ongoing;
s.period_end == null ||
s.period_end > new Date() ||
(s.period_start <= range.to &&
(s.period_end ?? new Date()) >= range.from);
const involved = usedSubIds.has(s.subscription_id) || ongoing;
if (!involved) continue;
const qpu = qpuAt(p.paid_at, timeline);
payments.push({
id: p.id,
kind: p.kind as PaymentItem["kind"],
paid_at: toUnixSec(p.paid_at)!,
paid_amount: p.paid_amount,
remark: p.remark,
period_start: toUnixSec(p.period_start),
period_end: toUnixSec(p.period_end),
subscription_id: p.subscription_id,
quota_granted: p.quota_granted,
quota_usd:
p.quota_granted != null
? quotaToUsdStr(p.quota_granted, qpu)
: "0.00",
const qpu = qpuAt(s.paid_at ?? s.created_at, timeline);
subscriptionsOut.push({
id: s.id,
subscription_id: s.subscription_id,
period_start: toUnixSec(s.period_start)!,
period_end: toUnixSec(s.period_end),
quota_granted: s.quota_granted,
quota_usd: quotaToUsdStr(s.quota_granted, qpu),
paid_amount: s.paid_amount,
paid_at: toUnixSec(s.paid_at),
remark: s.remark,
created_at: toUnixSec(s.created_at)!,
});
}
const topupRows = await repos.topups.listAll();
const topupsOut = [];
for (const t of topupRows) {
if (!(t.paid_at >= range.from && t.paid_at < range.to)) continue;
const qpu = qpuAt(t.paid_at, timeline);
const quota_derived = Math.round(t.amount * qpu);
topupsOut.push({
id: t.id,
upstream_id: t.upstream_id,
amount: t.amount,
money: t.money,
trade_no: t.trade_no,
payment_method: t.payment_method,
payment_provider: t.payment_provider,
status: t.status,
paid_at: toUnixSec(t.paid_at)!,
quota_derived,
quota_usd: quotaToUsdStr(quota_derived, qpu),
remark: t.remark,
created_at: toUnixSec(t.created_at)!,
});
}
@@ -206,14 +217,8 @@ export function memberViewRoutes() {
usage,
by_source,
keys,
payments: payments.map((p) => ({
...p,
kind: p.kind as string,
period_start: p.period_start ?? null,
period_end: p.period_end ?? null,
subscription_id: p.subscription_id ?? null,
quota_granted: p.quota_granted ?? null,
})),
subscriptions: subscriptionsOut,
topups: topupsOut,
},
200,
);
@@ -225,7 +230,7 @@ export function memberViewRoutes() {
method: "get",
path: "/logs",
summary: "日志明细(名下 key)",
description: "分页;恒 type=2;DTO 与管理面同构",
description: "分页;默认全类型(可 type 筛);DTO 与管理面同构",
tags: ["成员视图"],
security: [{ memberToken: [] }],
request: {
@@ -234,6 +239,10 @@ export function memberViewRoutes() {
to: z.string(),
page: z.string().optional(),
page_size: z.string().optional(),
api_key_id: z.string().optional(),
billing_source: z.string().optional(),
subscription_id: z.string().optional(),
type: z.string().optional(),
}),
},
responses: {
@@ -264,6 +273,18 @@ export function memberViewRoutes() {
to: range.to,
page,
page_size,
api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined,
billing_source:
q.billing_source === "none" ? "none" : q.billing_source || undefined,
subscription_id: q.subscription_id
? Number(q.subscription_id)
: undefined,
type:
q.type === "error"
? "error"
: q.type != null && q.type !== ""
? Number(q.type)
: undefined,
});
const items: LogItem[] = rows.map((row) => {
const { quota_usd } = usageRowPrecise(
@@ -287,6 +308,33 @@ export function memberViewRoutes() {
is_stream: row.is_stream,
billing_source: row.billing_source as LogItem["billing_source"],
subscription_id: row.subscription_id,
raw: {
id: row.id,
api_key_id: row.api_key_id,
dedupe_key: row.dedupe_key,
billing_source: row.billing_source,
subscription_id: row.subscription_id,
user_id: row.user_id,
created_at: toUnixSec(row.created_at),
type: row.type,
content: row.content,
username: row.username,
token_name: row.token_name,
model_name: row.model_name,
quota: row.quota,
prompt_tokens: row.prompt_tokens,
completion_tokens: row.completion_tokens,
use_time: row.use_time,
is_stream: row.is_stream,
channel: row.channel,
channel_name: row.channel_name,
token_id: row.token_id,
group_name: row.group_name,
ip: row.ip,
request_id: row.request_id,
upstream_request_id: row.upstream_request_id,
other: row.other,
},
};
});
return c.json({ items, total }, 200);
-26
View File
@@ -1,26 +0,0 @@
import type { PaymentItem } from "@new-api-key-usage/core";
import { adminHttp } from "../http.ts";
export type PaymentBody = Record<string, unknown>;
export async function fetchPayments(): Promise<PaymentItem[]> {
const { data } = await adminHttp.get<PaymentItem[]>("/payments");
return data;
}
export async function createPayment(body: PaymentBody): Promise<PaymentItem> {
const { data } = await adminHttp.post<PaymentItem>("/payments", body);
return data;
}
export async function updatePayment(
id: number,
body: PaymentBody,
): Promise<PaymentItem> {
const { data } = await adminHttp.put<PaymentItem>(`/payments/${id}`, body);
return data;
}
export async function deletePayment(id: number): Promise<void> {
await adminHttp.delete(`/payments/${id}`);
}
+35
View File
@@ -10,6 +10,18 @@ export type PlatformSubscription = {
amount_used: number;
};
export type PlatformTopup = {
id: number;
amount: number;
money: number;
trade_no?: string;
payment_method?: string;
payment_provider?: string;
create_time?: number;
complete_time?: number;
status?: string;
};
export type PlatformLoginResult = { access_token: string };
/** 登录控制台(密码不落库);token 由调用方短暂保存 */
@@ -33,3 +45,26 @@ export async function listPlatformSubscriptions(
);
return data.subscriptions;
}
export async function listPlatformTopups(
accessToken: string,
page = 1,
pageSize = 10,
): Promise<{
items: PlatformTopup[];
total: number;
page: number;
page_size: number;
}> {
const { data } = await adminHttp.post<{
items: PlatformTopup[];
total: number;
page: number;
page_size: number;
}>("/platform/topups", {
access_token: accessToken,
page,
page_size: pageSize,
});
return data;
}
+39
View File
@@ -0,0 +1,39 @@
import type { SubscriptionItem } from "@new-api-key-usage/core";
import { adminHttp } from "../http.ts";
export type SubscriptionBody = {
subscription_id: number;
period_start: string;
period_end?: string | null;
quota_granted: number;
paid_amount?: number | null;
paid_at?: string | null;
remark?: string;
};
export async function fetchSubscriptions(): Promise<SubscriptionItem[]> {
const { data } = await adminHttp.get<SubscriptionItem[]>("/subscriptions");
return data;
}
export async function createSubscription(
body: SubscriptionBody,
): Promise<SubscriptionItem> {
const { data } = await adminHttp.post<SubscriptionItem>("/subscriptions", body);
return data;
}
export async function updateSubscription(
id: number,
body: SubscriptionBody,
): Promise<SubscriptionItem> {
const { data } = await adminHttp.put<SubscriptionItem>(
`/subscriptions/${id}`,
body,
);
return data;
}
export async function deleteSubscription(id: number): Promise<void> {
await adminHttp.delete(`/subscriptions/${id}`);
}
+36
View File
@@ -0,0 +1,36 @@
import type { TopupItem } from "@new-api-key-usage/core";
import { adminHttp } from "../http.ts";
export type TopupBody = {
upstream_id?: number | null;
amount: number;
money: number;
trade_no?: string | null;
payment_method?: string | null;
payment_provider?: string | null;
status?: string | null;
paid_at: string;
remark?: string;
};
export async function fetchTopups(): Promise<TopupItem[]> {
const { data } = await adminHttp.get<TopupItem[]>("/topups");
return data;
}
export async function createTopup(body: TopupBody): Promise<TopupItem> {
const { data } = await adminHttp.post<TopupItem>("/topups", body);
return data;
}
export async function updateTopup(
id: number,
body: TopupBody,
): Promise<TopupItem> {
const { data } = await adminHttp.put<TopupItem>(`/topups/${id}`, body);
return data;
}
export async function deleteTopup(id: number): Promise<void> {
await adminHttp.delete(`/topups/${id}`);
}
+5 -1
View File
@@ -77,7 +77,11 @@ function attachInterceptors(client: AxiosInstance, world: AuthWorld): void {
(response) => response,
(error: AxiosError) => {
const apiError = toApiError(error, world);
if (apiError.status === 401) {
// 401 仅表示「本世界凭证失效」;上游平台 token 失效是 422 platform_token_invalid
if (
apiError.status === 401 &&
apiError.code !== "platform_token_invalid"
) {
redirectAuth(world, "expired");
}
return Promise.reject(apiError);
+17 -6
View File
@@ -1,7 +1,8 @@
import type {
LogItem,
Paginated,
PaymentItem,
SubscriptionItem,
TopupItem,
UnixSec,
UsageTotals,
} from "@new-api-key-usage/core";
@@ -27,7 +28,8 @@ export type MemberOverview = {
log_synced_at: UnixSec | null;
usage: UsageTotals;
}>;
payments: PaymentItem[];
subscriptions: SubscriptionItem[];
topups: TopupItem[];
};
export async function fetchMe(): Promise<MemberMe> {
@@ -45,11 +47,20 @@ export async function fetchOverview(range: {
return data;
}
export async function fetchLogs(
range: { from: string; to: string; page: number; page_size: number },
): Promise<Paginated<LogItem>> {
export type MemberLogQuery = {
from: string;
to: string;
page: number;
page_size: number;
api_key_id?: number;
billing_source?: string;
subscription_id?: number;
type?: number | "error";
};
export async function fetchLogs(query: MemberLogQuery): Promise<Paginated<LogItem>> {
const { data } = await memberHttp.get<Paginated<LogItem>>("/logs", {
params: range,
params: query,
});
return data;
}
+15 -3
View File
@@ -40,7 +40,8 @@ export const qk = {
range.subscription_id ?? "",
range.type ?? "",
] as const,
payments: () => ["admin", "payments"] as const,
subscriptions: () => ["admin", "subscriptions"] as const,
topups: () => ["admin", "topups"] as const,
qpuLatest: () => ["admin", "qpu-latest"] as const,
members: () => ["admin", "members"] as const,
auditLogs: (q: PageQuery & { domain?: string; from?: string; to?: string }) =>
@@ -50,7 +51,18 @@ export const qk = {
me: () => ["member", "me"] as const,
overview: (range: TimeRange) =>
["member", "overview", range.from, range.to] as const,
logs: (range: TimeRange & PageQuery) =>
["member", "logs", range.from, range.to, range.page, range.page_size] as const,
logs: (range: TimeRange & PageQuery & Record<string, unknown>) =>
[
"member",
"logs",
range.from,
range.to,
range.page,
range.page_size,
range.api_key_id ?? "",
range.billing_source ?? "",
range.subscription_id ?? "",
range.type ?? "",
] as const,
},
};
@@ -1,184 +1,290 @@
<script setup lang="ts">
/**
* 支付登记/编辑:订阅与充值同构;额度双输入(落库恒 quota 原值)
* 支付登记弹窗:订阅 / 充值分形态(拆表后)
* 时间控件统一精确到秒(show-time)
*/
import { computed, reactive, ref, watch } from "vue";
import type { Dayjs } from "dayjs";
import dayjs from "dayjs";
import type { PaymentItem } from "@new-api-key-usage/core";
import { computed, reactive, ref, watch } from "vue";
import { message } from "antdv-next";
import type { SubscriptionItem, TopupItem } from "@new-api-key-usage/core";
import type { ApiError } from "@/api/http.ts";
import { unixToBeijingWall, wallAsBeijingIso } from "@/utils/time.ts";
import {
useCreatePayment,
usePayments,
useQpuLatest,
useUpdatePayment,
} from "@/queries/admin.ts";
import PlatformSubscriptionPicker from "@/components/admin/platform-subscription-picker.vue";
import type { PlatformSubscription } from "@/api/admin/platform.ts";
import PlatformTopupPicker from "@/components/admin/platform-topup-picker.vue";
import type {
PlatformSubscription,
PlatformTopup,
} from "@/api/admin/platform.ts";
import {
useCreateSubscription,
useCreateTopup,
useQpuLatest,
useSubscriptions,
useTopups,
useUpdateSubscription,
useUpdateTopup,
} from "@/queries/admin.ts";
import { unixToBeijingWall, wallAsBeijingIso } from "@/utils/time.ts";
const TIME_FMT = "YYYY-MM-DD HH:mm:ss";
const props = defineProps<{
open: boolean;
/** 有则为编辑 */
editing?: PaymentItem | null;
tab: "subs" | "topups";
editingSub?: SubscriptionItem | null;
editingTop?: TopupItem | null;
}>();
const emit = defineEmits<{
(e: "update:open", v: boolean): void;
}>();
const emit = defineEmits<{ (e: "update:open", v: boolean): void }>();
const createPay = useCreatePayment();
const updatePay = useUpdatePayment();
const createSub = useCreateSubscription();
const updateSub = useUpdateSubscription();
const createTop = useCreateTopup();
const updateTop = useUpdateTopup();
const subsQuery = useSubscriptions();
const topupsQuery = useTopups();
const qpuQuery = useQpuLatest();
const paymentsQuery = usePayments();
const qpu = computed(() => qpuQuery.data.value?.qpu ?? null);
const form = reactive({
kind: "subscription" as "subscription" | "topup",
paid_amount: null as number | null,
paid_at: null as Dayjs | null,
period_start: null as Dayjs | null,
period_end: null as Dayjs | null,
subscription_id: null as number | null,
remark: "",
quotaMode: "quota" as "quota" | "usd",
quota_granted: null as number | null,
quota_usd_input: null as number | null,
});
const error = ref("");
const pickerOpen = ref(false);
function onPickSubscription(s: PlatformSubscription) {
form.kind = "subscription";
form.subscription_id = s.id;
form.period_start = unixToBeijingWall(s.start_time);
form.period_end = s.end_time > 0 ? unixToBeijingWall(s.end_time) : null;
form.quotaMode = "quota";
form.quota_granted = s.amount_total;
form.quota_usd_input = null;
}
const isEdit = computed(() => props.editing != null);
const saving = computed(() => createPay.isPending.value || updatePay.isPending.value);
/** 额度双输入旁的换算预览(不入库,仅展示) */
const quotaPreview = computed(() => {
if (form.quotaMode === "quota") {
const q = form.quota_granted;
if (isSub.value) {
if (subForm.quotaMode === "quota") {
const q = subForm.quota_granted;
if (q == null || !qpu.value) return null;
return `≈ $${(q / qpu.value).toFixed(2)}(按当前换算率)`;
}
const u = form.quota_usd_input;
const u = subForm.quota_usd;
if (u == null || !qpu.value) return null;
return `≈ ${Math.round(u * qpu.value)} quota`;
}
const a = topForm.amount;
if (a == null || !qpu.value) return null;
return `≈ ${Math.round(a * qpu.value)} quota(按当前换算率)`;
});
const quotaGrantedFinal = computed(() => {
if (form.quotaMode === "quota") return form.quota_granted;
if (form.quota_usd_input == null || !qpu.value) return null;
return Math.round(form.quota_usd_input * qpu.value);
const pickerOpen = ref(false);
const topPickerOpen = ref(false);
const error = ref("");
const isSub = ref(props.tab === "subs");
const registeredSubIds = computed(
() => (subsQuery.data.value ?? []).map((s) => s.subscription_id),
);
const registeredTopIds = computed(
() =>
(topupsQuery.data.value ?? [])
.map((t) => t.upstream_id)
.filter((v): v is number => v != null),
);
const subForm = reactive({
subscription_id: null as number | null,
period_start: null as Dayjs | null,
period_end: null as Dayjs | null,
quota_granted: null as number | null,
quotaMode: "quota" as "quota" | "usd",
quota_usd: null as number | null,
paid_amount: null as number | null,
paid_at: null as Dayjs | null,
remark: "",
});
const topForm = reactive({
upstream_id: null as number | null,
amount: null as number | null,
money: null as number | null,
trade_no: "",
payment_method: "",
payment_provider: "",
paid_at: null as Dayjs | null,
remark: "",
});
function resetSub() {
Object.assign(subForm, {
subscription_id: null,
period_start: null,
period_end: null,
quota_granted: null,
quotaMode: "quota",
quota_usd: null,
paid_amount: null,
paid_at: null,
remark: "",
});
}
function resetTop() {
Object.assign(topForm, {
upstream_id: null,
amount: null,
money: null,
trade_no: "",
payment_method: "",
payment_provider: "",
paid_at: null,
remark: "",
});
}
watch(
() => props.open,
(v) => {
if (!v) return;
error.value = "";
const ed = props.editing;
if (ed) {
form.kind = ed.kind;
form.paid_amount = ed.paid_amount;
// 北京墙钟进控件,保存时 wallAsBeijingIso 可无损回写
form.paid_at = ed.paid_at != null ? unixToBeijingWall(ed.paid_at) : null;
form.period_start =
ed.period_start != null ? unixToBeijingWall(ed.period_start) : null;
form.period_end =
ed.period_end != null ? unixToBeijingWall(ed.period_end) : null;
form.subscription_id = ed.subscription_id;
form.remark = ed.remark;
form.quotaMode = "quota";
form.quota_granted = ed.quota_granted;
form.quota_usd_input = null;
isSub.value = props.tab === "subs";
if (props.editingSub) {
isSub.value = true;
const s = props.editingSub;
subForm.subscription_id = s.subscription_id;
subForm.period_start = unixToBeijingWall(s.period_start);
subForm.period_end =
s.period_end != null ? unixToBeijingWall(s.period_end) : null;
subForm.quota_granted = s.quota_granted;
subForm.quotaMode = "quota";
subForm.quota_usd = null;
subForm.paid_amount = s.paid_amount;
subForm.paid_at =
s.paid_at != null ? unixToBeijingWall(s.paid_at) : null;
subForm.remark = s.remark;
} else {
form.kind = "subscription";
form.paid_amount = null;
form.paid_at = dayjs();
form.period_start = null;
form.period_end = null;
form.subscription_id = null;
form.remark = "";
form.quotaMode = "quota";
form.quota_granted = null;
form.quota_usd_input = null;
// 订阅的 paid_at 是「套餐付款时刻」,可空——不默认填今天
resetSub();
}
if (props.editingTop) {
isSub.value = false;
const t = props.editingTop;
topForm.upstream_id = t.upstream_id;
topForm.amount = t.amount;
topForm.money = t.money;
topForm.trade_no = t.trade_no ?? "";
topForm.payment_method = t.payment_method ?? "";
topForm.payment_provider = t.payment_provider ?? "";
topForm.paid_at = unixToBeijingWall(t.paid_at);
topForm.remark = t.remark;
} else {
resetTop();
topForm.paid_at = dayjs();
}
},
);
function toIso(d: Dayjs | null): string | null {
return d ? wallAsBeijingIso(d) : null;
function onPickSub(s: PlatformSubscription) {
subForm.subscription_id = s.id;
subForm.period_start = unixToBeijingWall(s.start_time);
subForm.period_end =
s.end_time != null ? unixToBeijingWall(s.end_time) : null;
subForm.quota_granted = s.amount_total;
subForm.quotaMode = "quota";
}
function onPickTop(t: PlatformTopup & { paid_at_iso: string }) {
topForm.upstream_id = t.id;
topForm.amount = t.amount;
topForm.money = t.money;
topForm.trade_no = t.trade_no ?? "";
topForm.payment_method = t.payment_method ?? "";
topForm.payment_provider = t.payment_provider ?? "";
const sec = t.complete_time ?? t.create_time;
topForm.paid_at =
sec != null ? unixToBeijingWall(sec) : unixToBeijingWall(Date.now() / 1000);
}
function iso(v: Dayjs | null): string | null {
// 北京墙钟进控件 → wallAsBeijingIso 无损回写(与旧登记表单一致)
return v ? wallAsBeijingIso(v) : null;
}
async function onSubmit() {
error.value = "";
if (form.paid_amount == null) {
error.value = "请填写金额";
try {
if (isSub.value) {
if (!subForm.period_start) {
error.value = "请填写期间起点";
return;
}
if (!form.paid_at) {
error.value = "请填写支付时间";
if (subForm.subscription_id == null) {
error.value = "请填写订阅编号";
return;
}
const quota = quotaGrantedFinal.value;
const qpu = qpuQuery.data.value?.qpu ?? 500000;
const quota =
subForm.quotaMode === "quota"
? subForm.quota_granted
: subForm.quota_usd != null
? Math.round(subForm.quota_usd * qpu)
: null;
if (quota == null) {
error.value = "请填写额度";
error.value = "请填写套餐总额度";
return;
}
if (form.kind === "subscription") {
if (!form.period_start) {
error.value = "订阅必须填写期间起点";
if (subForm.paid_amount != null && subForm.paid_amount < 0) {
error.value = "付款金额不能为负数";
return;
}
if (form.subscription_id == null) {
error.value = "订阅必须填写订阅编号";
return;
}
// 一笔订阅只应登记一条支付记录;改错走「编辑」
const dup = (paymentsQuery.data.value ?? []).find(
const dup = (subsQuery.data.value ?? []).find(
(p) =>
p.subscription_id === form.subscription_id &&
p.id !== (props.editing?.id ?? -1),
p.subscription_id === subForm.subscription_id &&
p.id !== props.editingSub?.id,
);
if (dup) {
error.value = `订阅 #${form.subscription_id} 已有支付登记——请编辑原记录,不要重复登记`;
error.value = `订阅 #${subForm.subscription_id} 已有登记——请编辑原记录`;
return;
}
const body = {
subscription_id: subForm.subscription_id,
period_start: iso(subForm.period_start)!,
period_end: iso(subForm.period_end),
quota_granted: quota,
paid_amount: subForm.paid_amount,
paid_at: iso(subForm.paid_at),
remark: subForm.remark,
};
if (props.editingSub) {
await updateSub.mutateAsync({ id: props.editingSub.id, body });
} else {
await createSub.mutateAsync(body);
}
} else {
if (topForm.amount == null || topForm.amount <= 0) {
error.value = "请填写充值金额 amount(须为正)";
return;
}
if (topForm.money == null || topForm.money < 0) {
error.value = "实付金额不能为负数";
return;
}
if (!topForm.paid_at) {
error.value = "请填写入账时间";
return;
}
const body = {
kind: form.kind,
paid_at: toIso(form.paid_at)!,
paid_amount: form.paid_amount,
remark: form.remark.trim(),
quota_granted: quota,
...(form.kind === "subscription"
? {
period_start: toIso(form.period_start),
period_end: toIso(form.period_end),
subscription_id: form.subscription_id,
}
: {
period_start: null,
period_end: null,
subscription_id: null,
}),
upstream_id: topForm.upstream_id,
amount: topForm.amount,
money: topForm.money,
trade_no: topForm.trade_no || null,
payment_method: topForm.payment_method || null,
payment_provider: topForm.payment_provider || null,
status: props.editingTop?.status ?? "success",
paid_at: iso(topForm.paid_at)!,
remark: topForm.remark,
};
try {
if (isEdit.value && props.editing) {
await updatePay.mutateAsync({ id: props.editing.id, body });
if (props.editingTop) {
await updateTop.mutateAsync({ id: props.editingTop.id, body });
} else {
await createPay.mutateAsync(body);
await createTop.mutateAsync(body);
}
}
message.success(props.editingSub || props.editingTop ? "已保存" : "已登记");
emit("update:open", false);
} catch (e) {
error.value = (e as ApiError).message || "保存失败";
const err = e as ApiError;
if (err.fields) {
error.value = Object.values(err.fields)[0] || "请检查表单";
} else {
error.value = err.message || "保存失败";
}
}
}
</script>
@@ -186,117 +292,170 @@ async function onSubmit() {
<template>
<a-modal
:open="open"
:title="isEdit ? '编辑支付记录' : '登记支付'"
:confirm-loading="saving"
:title="isSub ? (editingSub ? '编辑订阅' : '登记订阅') : editingTop ? '编辑充值' : '登记充值'"
:confirm-loading="createSub.isPending.value || createTop.isPending.value || updateSub.isPending.value || updateTop.isPending.value"
ok-text="保存"
cancel-text="取消"
:styles="{ body: { maxHeight: '62vh', overflowY: 'auto' } }"
@update:open="emit('update:open', $event)"
width="560"
centered
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
@ok="onSubmit"
@update:open="emit('update:open', $event)"
>
<a-form layout="vertical">
<a-form-item label="类型" required>
<a-radio-group v-model:value="form.kind" :disabled="isEdit">
<a-radio value="subscription">订阅</a-radio>
<a-radio value="topup">充值</a-radio>
<a-radio-group
v-if="!editingSub && !editingTop"
v-model:value="isSub"
style="margin-bottom: 16px"
>
<a-radio :value="true">订阅</a-radio>
<a-radio :value="false">充值</a-radio>
</a-radio-group>
</a-form-item>
<a-button
v-if="form.kind === 'subscription' && !isEdit"
class="pay-form__pick"
block
@click="pickerOpen = true"
>
从平台选择订阅(自动填写编号、期间与额度)
</a-button>
<a-form-item label="金额(币种自定)" required>
<a-input-number
v-model:value="form.paid_amount"
:min="0"
:precision="2"
style="width: 100%"
/>
<div class="pay-form__note">金额数值——币种由你们约定,系统不预设</div>
</a-form-item>
<a-form-item label="支付时间" required>
<a-date-picker
v-model:value="form.paid_at"
show-time
style="width: 100%"
/>
</a-form-item>
<template v-if="form.kind === 'subscription'">
<a-form-item label="期间起" required>
<a-date-picker
v-model:value="form.period_start"
show-time
style="width: 100%"
/>
</a-form-item>
<a-form-item label="期间止(空 = 进行中)">
<a-date-picker
v-model:value="form.period_end"
show-time
style="width: 100%"
/>
</a-form-item>
<a-form v-if="isSub" layout="vertical">
<a-form-item label="订阅 ID" required>
<a-space>
<a-input-number
v-model:value="form.subscription_id"
v-model:value="subForm.subscription_id"
:min="1"
:precision="0"
style="width: 100%"
placeholder="钱包页面「订阅 #N」"
style="width: 160px"
/>
<a-button @click="pickerOpen = true">从平台选用</a-button>
</a-space>
</a-form-item>
<a-form-item label="期间起止" required>
<a-date-picker
v-model:value="subForm.period_start"
:format="TIME_FMT"
:show-time="{ defaultValue: undefined }"
style="width: 200px"
/>
<span style="margin: 0 8px">~</span>
<a-date-picker
v-model:value="subForm.period_end"
:format="TIME_FMT"
:show-time="{ defaultValue: undefined }"
placeholder="止(空=进行中)"
style="width: 200px"
/>
</a-form-item>
</template>
<a-form-item label="本笔额度" required>
<a-radio-group v-model:value="form.quotaMode">
<a-radio value="quota">原始 quota</a-radio>
<a-radio value="usd">按美元换算</a-radio>
<a-form-item label="套餐总额度" required>
<a-radio-group v-model:value="subForm.quotaMode" style="margin-right: 8px">
<a-radio-button value="quota">quota</a-radio-button>
<a-radio-button value="usd">美元</a-radio-button>
</a-radio-group>
<a-input-number
v-if="form.quotaMode === 'quota'"
v-model:value="form.quota_granted"
v-if="subForm.quotaMode === 'quota'"
v-model:value="subForm.quota_granted"
:min="0"
:precision="0"
style="width: 100%; margin-top: 8px"
style="width: 180px"
/>
<a-input-number
v-else
v-model:value="form.quota_usd_input"
v-model:value="subForm.quota_usd"
:min="0"
:precision="2"
style="width: 100%; margin-top: 8px"
placeholder="美元金额"
style="width: 180px"
/>
<div v-if="quotaPreview" class="pay-form__note">
<div v-if="quotaPreview" class="form-note">
{{ quotaPreview }} · 落库为原始 quota
</div>
</a-form-item>
<a-form-item label="付款金额 / 付款时间(可空)">
<a-input-number
v-model:value="subForm.paid_amount"
:min="0"
:precision="2"
style="width: 140px"
placeholder="金额"
/>
<a-date-picker
v-model:value="subForm.paid_at"
:format="TIME_FMT"
:show-time="{ defaultValue: undefined }"
placeholder="钱出手时刻(可空)"
style="width: 200px; margin-left: 8px"
/>
<div class="form-note">
社交层付款事实:金额数值、币种由你们约定;未知可留空。登记时刻由系统另记
</div>
</a-form-item>
<a-form-item label="备注">
<a-input v-model:value="form.remark" placeholder="备注(可选)" />
<a-input v-model:value="subForm.remark" />
</a-form-item>
<a-alert v-if="error" type="error" show-icon :message="error" />
</a-form>
<a-form v-else layout="vertical">
<a-form-item label="充值金额 amount" required>
<a-space>
<a-input-number
v-model:value="topForm.amount"
:min="0"
:precision="6"
style="width: 180px"
/>
<a-button @click="topPickerOpen = true">从平台选用</a-button>
</a-space>
<div class="form-note">
平台计费侧充值额(不标币种);与「实付」之间汇率由管理员约定。换得 quota ≈ amount × QPU
<template v-if="quotaPreview"> · {{ quotaPreview }}</template>
</div>
</a-form-item>
<a-form-item label="实付金额 money" required>
<a-input-number
v-model:value="topForm.money"
:min="0"
:precision="2"
style="width: 180px"
/>
<div class="form-note">实际支付金额(不预设币种)</div>
</a-form-item>
<a-form-item label="入账时间 paid_at" required>
<a-date-picker
v-model:value="topForm.paid_at"
:format="TIME_FMT"
:show-time="{ defaultValue: undefined }"
style="width: 220px"
/>
</a-form-item>
<a-form-item label="交易号 / 渠道">
<a-input v-model:value="topForm.trade_no" placeholder="trade_no" style="width: 220px" />
<a-input
v-model:value="topForm.payment_method"
placeholder="method"
style="width: 120px; margin-left: 8px"
/>
<a-input
v-model:value="topForm.payment_provider"
placeholder="provider"
style="width: 120px; margin-left: 8px"
/>
</a-form-item>
<a-form-item label="备注">
<a-input v-model:value="topForm.remark" />
</a-form-item>
</a-form>
<a-alert v-if="error" type="error" show-icon :message="error" />
<PlatformSubscriptionPicker
v-model:open="pickerOpen"
@select="onPickSubscription"
:registered-ids="registeredSubIds"
@select="onPickSub"
/>
<PlatformTopupPicker
v-model:open="topPickerOpen"
:registered-ids="registeredTopIds"
@select="onPickTop"
/>
</a-modal>
</template>
<style scoped>
.pay-form__pick {
margin-bottom: 16px;
}
.pay-form__note {
.form-note {
margin-top: 4px;
font-size: 12px;
line-height: 20px;
color: rgba(0, 0, 0, 0.45);
}
</style>
@@ -2,7 +2,7 @@
/**
* 订阅选择弹层:登录(仅一次/标签页)+ 列表选用,与登记表单解耦
*/
import { ref } from "vue";
import { computed, ref, watch } from "vue";
import {
listPlatformSubscriptions,
loginPlatform,
@@ -13,6 +13,8 @@ import { formatDateTime } from "@/utils/time.ts";
const props = defineProps<{
open: boolean;
/** 已登记的 subscription_id——灰显不可选用 */
registeredIds?: number[];
}>();
const emit = defineEmits<{
@@ -26,6 +28,8 @@ const username = ref("");
const password = ref("");
const error = ref("");
const loading = ref(false);
/** 打开后检查登录并拉取中——避免闪现登录表单 */
const booting = ref(false);
const list = ref<PlatformSubscription[]>([]);
function readToken(): string {
@@ -45,8 +49,29 @@ function writeToken(token: string) {
}
}
async function load() {
if (!username.value.trim() || !password.value || loading.value) return;
watch(
() => props.open,
(v) => {
if (v) {
// 有 token:先显示「检查中」,拉完再进列表/登录,不闪登录表单
if (readToken()) {
booting.value = true;
void load(true).finally(() => {
booting.value = false;
});
}
} else {
list.value = [];
error.value = "";
password.value = "";
booting.value = false;
}
},
);
async function load(skipLoginCheck = false) {
if (loading.value) return;
if (!skipLoginCheck && (!username.value.trim() || !password.value)) return;
error.value = "";
loading.value = true;
try {
@@ -58,15 +83,15 @@ async function load() {
});
token = login.access_token;
writeToken(token);
password.value = "";
}
password.value = "";
list.value = await listPlatformSubscriptions(token);
if (list.value.length === 0) {
error.value = "账号下没有订阅记录";
}
} catch (e) {
const err = e as ApiError;
if (err.status === 401) {
if (err.status === 401 || err.code === "platform_token_invalid") {
writeToken("");
error.value = "平台登录已失效——请重新输入密码";
} else {
@@ -79,7 +104,12 @@ async function load() {
}
}
function isRegistered(s: PlatformSubscription): boolean {
return (props.registeredIds ?? []).includes(s.id);
}
function onUse(s: PlatformSubscription) {
if (isRegistered(s)) return;
emit("select", s);
emit("update:open", false);
}
@@ -91,10 +121,16 @@ function onUse(s: PlatformSubscription) {
title="从平台选择订阅"
:footer="null"
:width="520"
centered
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
destroy-on-hidden
@update:open="emit('update:open', $event)"
>
<template v-if="list.length === 0">
<div v-if="booting || (loading && list.length === 0)" class="plat-pick__boot">
<a-spin />
<span class="plat-pick__boot-text">正在检查登录并拉取…</span>
</div>
<template v-else-if="list.length === 0">
<p class="plat-pick__hint">
使用 NewAPI 控制台账号登录后拉取订阅,仅用于预填登记;密码不保存。
</p>
@@ -137,11 +173,14 @@ function onUse(s: PlatformSubscription) {
v-for="s in list"
:key="s.id"
class="plat-pick__item"
:class="{ 'is-registered': isRegistered(s) }"
@click="onUse(s)"
>
<div class="plat-pick__head">
<strong>订阅 #{{ s.id }}</strong>
<span class="plat-pick__status">{{ s.status }}</span>
<span class="plat-pick__status">
{{ isRegistered(s) ? "已登记" : s.status }}
</span>
</div>
<div class="plat-pick__meta">
{{ formatDateTime(s.start_time) }} ~
@@ -155,6 +194,19 @@ function onUse(s: PlatformSubscription) {
</template>
<style scoped>
.plat-pick__boot {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 0;
}
.plat-pick__boot-text {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.plat-pick__hint {
margin: 0 0 16px;
font-size: 12px;
@@ -179,6 +231,15 @@ function onUse(s: PlatformSubscription) {
background: #fafafa;
}
.plat-pick__item.is-registered {
opacity: 0.45;
cursor: not-allowed;
}
.plat-pick__item.is-registered:hover {
background: transparent;
}
.plat-pick__item:last-child {
border-bottom: none;
}
@@ -0,0 +1,312 @@
<script setup lang="ts">
/**
* 充值选用弹层:登录 + 传统分页;已登记按 upstream_id 灰显
*/
import { computed, ref, watch } from "vue";
import {
listPlatformTopups,
loginPlatform,
type PlatformTopup,
} from "@/api/admin/platform.ts";
import type { ApiError } from "@/api/http.ts";
import { formatDateTime } from "@/utils/time.ts";
const props = defineProps<{
open: boolean;
registeredIds?: number[];
}>();
const emit = defineEmits<{
(e: "update:open", v: boolean): void;
(e: "select", topup: PlatformTopup & { paid_at_iso: string }): void;
}>();
const TOKEN_KEY = "naku_platform_token";
const PAGE_SIZE = 10;
const username = ref("");
const password = ref("");
const error = ref("");
const loading = ref(false);
/** 打开后检查登录并拉取中——避免闪现登录表单 */
const booting = ref(false);
const items = ref<PlatformTopup[]>([]);
const total = ref(0);
const page = ref(1);
const totalPages = computed(() =>
Math.max(1, Math.ceil(total.value / PAGE_SIZE)),
);
function readToken(): string {
try {
return sessionStorage.getItem(TOKEN_KEY) ?? "";
} catch {
return "";
}
}
function writeToken(token: string) {
try {
if (token) sessionStorage.setItem(TOKEN_KEY, token);
else sessionStorage.removeItem(TOKEN_KEY);
} catch {
/* ignore */
}
}
async function ensureToken(): Promise<string> {
let token = readToken();
if (token) return token;
const login = await loginPlatform({
username: username.value.trim(),
password: password.value,
});
token = login.access_token;
writeToken(token);
password.value = "";
return token;
}
async function loadPage(p: number, opts?: { allowTokenOnly?: boolean }) {
if (loading.value) return;
if (!opts?.allowTokenOnly && !readToken() && (!username.value.trim() || !password.value)) {
return;
}
error.value = "";
loading.value = true;
try {
const token = await ensureToken();
password.value = "";
const data = await listPlatformTopups(token, p, PAGE_SIZE);
items.value = data.items;
total.value = data.total;
page.value = data.page || p;
if (items.value.length === 0 && p === 1) {
error.value = "账号下没有充值记录";
}
} catch (e) {
const err = e as ApiError;
if (err.status === 401 || err.code === "platform_token_invalid") {
writeToken("");
error.value = "平台登录已失效——请重新输入密码";
} else {
error.value = err.message || "拉取失败";
}
items.value = [];
password.value = "";
} finally {
loading.value = false;
}
}
async function onLogin() {
if (!username.value.trim() || !password.value || loading.value) return;
await loadPage(1);
}
function isRegistered(t: PlatformTopup): boolean {
return (props.registeredIds ?? []).includes(t.id);
}
function onUse(t: PlatformTopup) {
if (isRegistered(t)) return;
const sec = t.complete_time ?? t.create_time ?? Math.floor(Date.now() / 1000);
emit("select", {
...t,
paid_at_iso: new Date(sec * 1000).toISOString(),
});
emit("update:open", false);
}
watch(
() => props.open,
(v) => {
if (v) {
if (readToken()) {
booting.value = true;
void loadPage(1, { allowTokenOnly: true }).finally(() => {
booting.value = false;
});
}
} else {
items.value = [];
total.value = 0;
page.value = 1;
error.value = "";
password.value = "";
booting.value = false;
}
},
);
</script>
<template>
<a-modal
:open="open"
title="从平台选择充值"
:footer="null"
:width="560"
centered
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
destroy-on-hidden
@update:open="emit('update:open', $event)"
>
<div
v-if="booting || (loading && items.length === 0)"
class="top-pick__boot"
>
<a-spin />
<span class="top-pick__boot-text">正在检查登录并拉取…</span>
</div>
<template v-else-if="items.length === 0 && !loading">
<p class="top-pick__hint">
登录 NewAPI 控制台拉取充值单(传统分页);密码不保存。
</p>
<a-form layout="vertical" @finish="onLogin">
<a-form-item label="账号">
<a-input v-model:value="username" autocomplete="username" />
</a-form-item>
<a-form-item label="密码">
<a-input-password
v-model:value="password"
autocomplete="current-password"
/>
</a-form-item>
<a-button
type="primary"
html-type="submit"
block
:loading="loading"
:disabled="!username.trim() || !password"
>
登录并拉取
</a-button>
</a-form>
<a-alert
v-if="error"
type="warning"
show-icon
:message="error"
style="margin-top: 12px"
/>
</template>
<template v-else>
<div class="top-pick__list">
<div
v-for="t in items"
:key="t.id"
class="top-pick__item"
:class="{ 'is-registered': isRegistered(t) }"
@click="onUse(t)"
>
<div class="top-pick__head">
<strong>
{{ formatDateTime(t.complete_time ?? t.create_time) }}
</strong>
<span class="top-pick__status">
{{ isRegistered(t) ? "已登记" : t.status || "" }}
</span>
</div>
<div class="top-pick__meta">
amount {{ t.amount }} · money {{ t.money }}
<template v-if="t.trade_no"> · {{ t.trade_no }}</template>
</div>
</div>
</div>
<div class="top-pick__pager">
<a-pagination
:current="page"
:page-size="PAGE_SIZE"
:total="total"
size="small"
:show-total="(t: number) => `共 ${t} 条`"
@change="(p: number) => loadPage(p)"
/>
</div>
<a-alert
v-if="error"
type="warning"
show-icon
:message="error"
style="margin-top: 8px"
/>
</template>
</a-modal>
</template>
<style scoped>
.top-pick__boot {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 0;
}
.top-pick__boot-text {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.top-pick__hint {
margin: 0 0 16px;
font-size: 12px;
line-height: 20px;
color: rgba(0, 0, 0, 0.45);
}
.top-pick__list {
max-height: 360px;
overflow-y: auto;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.top-pick__item {
padding: 12px 16px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
}
.top-pick__item:hover {
background: #fafafa;
}
.top-pick__item.is-registered {
opacity: 0.45;
cursor: not-allowed;
}
.top-pick__item.is-registered:hover {
background: transparent;
}
.top-pick__item:last-child {
border-bottom: none;
}
.top-pick__head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 4px;
}
.top-pick__status {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.top-pick__meta {
font-size: 12px;
color: rgba(0, 0, 0, 0.65);
font-variant-numeric: tabular-nums;
}
.top-pick__pager {
display: flex;
justify-content: center;
margin-top: 12px;
}
</style>
@@ -0,0 +1,64 @@
import { computed, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
useMemberRangeStore,
type RangeKind,
type RangeValue,
} from "@/stores/member-range.ts";
/**
* 成员端共享时间范围 + 当前页 URL(from/to)双向同步。
* 切页时布局菜单带上 from/to,状态从 store 读——对账窗口不打断。
*/
export function useMemberRange() {
const store = useMemberRangeStore();
const route = useRoute();
const router = useRouter();
const range = computed(() => store.range);
function syncFromRoute() {
const from = route.query.from;
const to = route.query.to;
if (typeof from === "string" && from && typeof to === "string" && to) {
store.setRange({ from, to }, "fixed");
}
}
/** 在当前路径上写入 from/to(可并入页内其它 query) */
function pushRangeQuery(extra: Record<string, string> = {}) {
void router.replace({
path: route.path,
query: {
...pickNonRangeQuery(route.query),
...extra,
from: store.range.from,
to: store.range.to,
},
});
}
function onRangeChange(v: RangeValue, k: RangeKind) {
store.setRange(v, k);
pushRangeQuery();
}
syncFromRoute();
watch(() => route.query, () => syncFromRoute());
return {
range,
setRange: store.setRange,
onRangeChange,
pushRangeQuery,
};
}
function pickNonRangeQuery(q: Record<string, unknown>): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(q)) {
if (k === "from" || k === "to") continue;
if (typeof v === "string" && v) out[k] = v;
}
return out;
}
+245 -43
View File
@@ -1,71 +1,273 @@
<script setup lang="ts">
import { computed } from "vue";
import type { MenuInfo, MenuItemType } from "antdv-next";
import { theme } from "antdv-next";
import {
AccountBookOutlined,
DashboardOutlined,
DownOutlined,
FileTextOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
UserOutlined,
} from "@antdv-next/icons";
import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useMemberTokenStore } from "@/stores/member-token.ts";
import { useMemberRangeStore } from "@/stores/member-range.ts";
import { useMemberMe } from "@/queries/member.ts";
const { token } = theme.useToken();
const route = useRoute();
const router = useRouter();
const tokenStore = useMemberTokenStore();
const rangeStore = useMemberRangeStore();
const meQuery = useMemberMe();
const collapsed = ref(false);
const brandTitle = "NAKU";
const brandDescription = "NewAPI 用量统计";
const memberName = computed(
() => tokenStore.memberName || meQuery.data.value?.name || "",
);
const items: MenuItemType[] = [
{ key: "/member", icon: DashboardOutlined, label: "总览" },
{ key: "/member/logs", icon: FileTextOutlined, label: "调用日志" },
{ key: "/member/payments", icon: AccountBookOutlined, label: "支付参数" },
];
const selectedKeys = ref<string[]>([route.path === "/member" ? "/member" : route.path]);
watch(
() => route.path,
(path) => {
selectedKeys.value = [path === "/member" ? "/member" : path];
},
);
function onMenuClick({ key }: MenuInfo) {
// 切页保留对账窗口(from/to),不带页内筛选
void router.push({
path: String(key),
query: {
from: rangeStore.range.from,
to: rangeStore.range.to,
},
});
}
const userMenuItems: MenuItemType[] = [
{ key: "logout", icon: LogoutOutlined, label: "退出", danger: true },
];
function onUserMenuClick({ key }: MenuInfo) {
if (key === "logout") {
tokenStore.clearToken();
void router.replace({ name: "member-login" });
}
}
</script>
<template>
<div class="member-layout">
<header class="member-layout__header">
<div class="member-layout__brand">成员入口</div>
<div class="member-layout__name">{{ memberName }}</div>
</header>
<main class="member-layout__main">
<div class="member-layout__surface">
<a-layout class="member-layout">
<a-layout-sider
v-model:collapsed="collapsed"
collapsible
:width="220"
:collapsed-width="80"
>
<template #trigger>
<MenuUnfoldOutlined v-if="collapsed" />
<MenuFoldOutlined v-else />
</template>
<div
class="member-layout__brand"
:class="{ 'is-collapsed': collapsed }"
:title="collapsed ? `${brandTitle} · ${brandDescription}` : undefined"
>
<span class="member-layout__mark" aria-hidden="true">N</span>
<div v-if="!collapsed" class="member-layout__brand-text">
<div class="member-layout__brand-title">{{ brandTitle }}</div>
<div class="member-layout__brand-desc">{{ brandDescription }}</div>
</div>
</div>
<a-menu
theme="dark"
mode="inline"
:inline-collapsed="collapsed"
:selected-keys="selectedKeys"
:items="items"
@click="onMenuClick"
/>
</a-layout-sider>
<a-layout class="member-layout__main">
<a-layout-header class="member-layout__header">
<a-dropdown
:menu="{ items: userMenuItems }"
placement="bottomRight"
:trigger="['click']"
@menu-click="onUserMenuClick"
>
<a class="member-layout__account">
<UserOutlined class="member-layout__account-icon" />
<span v-if="memberName" class="member-layout__username">{{ memberName }}</span>
<span v-else class="member-layout__username is-muted">…</span>
<DownOutlined class="member-layout__account-caret" />
</a>
</a-dropdown>
</a-layout-header>
<a-layout-content class="member-layout__content">
<div
class="member-layout__surface"
:style="{
background: token.colorBgContainer,
borderRadius: `${token.borderRadiusLG}px`,
}"
>
<router-view />
</div>
</main>
</div>
</a-layout-content>
</a-layout>
</a-layout>
</template>
<style scoped>
/* design.md:bg-layout 底 + bg-container 内容面;间距 24/16 对齐 spacing lg/md */
.member-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
background: #f5f5f5;
}
.member-layout__header {
height: 64px;
padding: 0 24px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: center;
justify-content: space-between;
}
.member-layout__brand {
font-weight: 600;
font-size: 16px;
line-height: 24px;
}
.member-layout__name {
color: rgba(0, 0, 0, 0.65);
font-size: 14px;
height: 100vh;
overflow: hidden;
}
.member-layout__main {
flex: 1;
width: 100%;
max-width: 960px;
margin: 0 auto;
padding: 24px 16px 48px;
height: 100vh;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.member-layout__brand {
height: 64px;
display: flex;
align-items: center;
gap: 12px;
padding: 0 24px;
overflow: hidden;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
box-sizing: border-box;
}
.member-layout__brand.is-collapsed {
justify-content: center;
padding: 0;
gap: 0;
}
.member-layout__mark {
flex: 0 0 28px;
width: 28px;
height: 28px;
border-radius: 6px;
background: #1677ff;
color: #fff;
font-size: 15px;
font-weight: 600;
line-height: 28px;
text-align: center;
user-select: none;
}
.member-layout__brand-text {
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0;
}
.member-layout__brand-title {
color: rgba(255, 255, 255, 0.92);
font-size: 14px;
font-weight: 600;
line-height: 22px;
letter-spacing: 0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.member-layout__brand-desc {
color: rgba(255, 255, 255, 0.45);
font-size: 12px;
font-weight: 400;
line-height: 20px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.member-layout__header {
flex: 0 0 64px;
display: flex;
align-items: center;
justify-content: flex-end;
background: #fff;
padding: 0 24px;
height: 64px;
line-height: 64px;
border-bottom: 1px solid #f0f0f0;
}
.member-layout__account {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 8px;
height: 40px;
color: rgba(0, 0, 0, 0.88);
cursor: pointer;
border-radius: 6px;
transition: background-color 0.2s;
}
.member-layout__account:hover {
background: rgba(0, 0, 0, 0.06);
}
.member-layout__account-icon {
font-size: 14px;
color: rgba(0, 0, 0, 0.65);
}
.member-layout__account-caret {
font-size: 10px;
color: rgba(0, 0, 0, 0.45);
}
.member-layout__username {
color: rgba(0, 0, 0, 0.88);
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-layout__username.is-muted {
color: rgba(0, 0, 0, 0.45);
}
.member-layout__content {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
background: #f5f5f5;
padding: 24px;
}
.member-layout__surface {
background: #fff;
border-radius: 8px;
padding: 24px;
min-height: 100%;
}
</style>
+66 -18
View File
@@ -8,11 +8,19 @@ import {
import { fetchOverview } from "@/api/admin/overview.ts";
import { fetchEngine, fetchQpuLatest } from "@/api/admin/engine.ts";
import {
createPayment,
deletePayment,
fetchPayments,
updatePayment,
} from "@/api/admin/payments.ts";
createSubscription,
deleteSubscription,
fetchSubscriptions,
updateSubscription,
type SubscriptionBody,
} from "@/api/admin/subscriptions.ts";
import {
createTopup,
deleteTopup,
fetchTopups,
updateTopup,
type TopupBody,
} from "@/api/admin/topups.ts";
import {
createMember,
fetchMembers,
@@ -94,40 +102,80 @@ export function useAdminLogs(
});
}
export function usePayments() {
export function useSubscriptions() {
return useQuery({
queryKey: qk.admin.payments(),
queryFn: fetchPayments,
queryKey: qk.admin.subscriptions(),
queryFn: fetchSubscriptions,
staleTime: 0,
});
}
export function useCreatePayment() {
export function useCreateSubscription() {
const qc = useQueryClient();
return useMutation({
mutationFn: createPayment,
mutationFn: (body: SubscriptionBody) => createSubscription(body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.payments() });
void qc.invalidateQueries({ queryKey: qk.admin.subscriptions() });
},
});
}
export function useUpdatePayment() {
export function useUpdateSubscription() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: number; body: Record<string, unknown> }) =>
updatePayment(id, body),
mutationFn: ({ id, body }: { id: number; body: SubscriptionBody }) =>
updateSubscription(id, body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.payments() });
void qc.invalidateQueries({ queryKey: qk.admin.subscriptions() });
},
});
}
export function useDeletePayment() {
export function useDeleteSubscription() {
const qc = useQueryClient();
return useMutation({
mutationFn: deletePayment,
mutationFn: deleteSubscription,
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.payments() });
void qc.invalidateQueries({ queryKey: qk.admin.subscriptions() });
},
});
}
export function useTopups() {
return useQuery({
queryKey: qk.admin.topups(),
queryFn: fetchTopups,
staleTime: 0,
});
}
export function useCreateTopup() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: TopupBody) => createTopup(body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.topups() });
},
});
}
export function useUpdateTopup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: number; body: TopupBody }) =>
updateTopup(id, body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.topups() });
},
});
}
export function useDeleteTopup() {
const qc = useQueryClient();
return useMutation({
mutationFn: deleteTopup,
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.topups() });
},
});
}
+13 -3
View File
@@ -3,8 +3,9 @@ import {
fetchLogs,
fetchMe,
fetchOverview,
type MemberLogQuery,
} from "@/api/member/me.ts";
import { qk, type PageQuery, type TimeRange } from "@/api/query-keys.ts";
import { qk, type TimeRange } from "@/api/query-keys.ts";
export function useMemberMe() {
return useQuery({
@@ -14,14 +15,23 @@ export function useMemberMe() {
});
}
export function useMemberOverview(range: () => TimeRange) {
/**
* 总览聚合。仅「总览」传 `refetchInterval`(看板心智);日志/支付不轮询。
*/
export function useMemberOverview(
range: () => TimeRange,
opts?: { refetchInterval?: number },
) {
return useQuery({
queryKey: () => qk.member.overview(range()),
queryFn: () => fetchOverview(range()),
...(opts?.refetchInterval != null
? { refetchInterval: opts.refetchInterval }
: {}),
});
}
export function useMemberLogs(params: () => TimeRange & PageQuery) {
export function useMemberLogs(params: () => MemberLogQuery) {
return useQuery({
queryKey: () => qk.member.logs(params()),
queryFn: () => fetchLogs(params()),
+13 -3
View File
@@ -1,7 +1,7 @@
import type { RouteRecordRaw } from "vue-router";
import MemberLayout from "@/layouts/member-layout.vue";
/** 成员视图 `/member/*`:登录壳 → 单页(交互共性 §1) */
/** 成员视图 `/member/*`:登录壳 → 侧边栏三页(与管理端同构壳) */
export const memberRoutes: RouteRecordRaw[] = [
{
path: "/member/login",
@@ -16,8 +16,18 @@ export const memberRoutes: RouteRecordRaw[] = [
children: [
{
path: "",
name: "member-home",
component: () => import("@/views/member/home-view.vue"),
name: "member-overview",
component: () => import("@/views/member/overview-view.vue"),
},
{
path: "logs",
name: "member-logs",
component: () => import("@/views/member/logs-view.vue"),
},
{
path: "payments",
name: "member-payments",
component: () => import("@/views/member/payments-view.vue"),
},
],
},
+29
View File
@@ -0,0 +1,29 @@
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { recentDaysRange } from "@/utils/time.ts";
export type RangeValue = { from: string; to: string };
export type RangeKind = "recent-7" | "recent-30" | "fixed";
/**
* 成员端全局时间范围——总览 / 调用日志 / 支付参数共用。
* 相对窗口在 setRange 时定格,避免每分钟改 key 打穿缓存;
* 「看板自动刷新」只属于总览页(refetchInterval),其余页按需请求。
*/
export const useMemberRangeStore = defineStore("member-range", () => {
const kind = ref<RangeKind>("recent-30");
const windowRange = ref<RangeValue>(recentDaysRange(30));
const fixed = ref<RangeValue>(recentDaysRange(30));
const range = computed<RangeValue>(() =>
kind.value === "fixed" ? fixed.value : windowRange.value,
);
function setRange(value: RangeValue, nextKind: RangeKind) {
kind.value = nextKind;
if (nextKind === "fixed") fixed.value = value;
else windowRange.value = value;
}
return { kind, range, setRange };
});
+144 -11
View File
@@ -78,8 +78,22 @@ const columns = [
{ title: "来源", key: "source", width: 110 },
{ title: "用量", key: "usage", width: 140 },
{ title: "Tokens", key: "tokens", width: 110 },
{ title: "操作", key: "actions", width: 72 },
];
const detailOpen = ref(false);
const detailRecord = ref<LogItem | null>(null);
function openDetail(record: LogItem) {
detailRecord.value = record;
detailOpen.value = true;
}
function formatRaw(record: LogItem | null): string {
if (!record) return "";
return JSON.stringify(record.raw ?? record, null, 2);
}
function syncFromRoute() {
const q = route.query;
const m = q.member_id;
@@ -159,6 +173,21 @@ function goMember(memberId: number | null) {
}
let ro: ResizeObserver | null = null;
function onRangeChange(
v: { from: string; to: string },
k: "recent-7" | "recent-30" | "fixed",
) {
setRange(v, k);
page.value = 1;
pushQuery();
}
function onPageChange(p: number, ps: number) {
page.value = p;
pageSize.value = ps;
}
const boxRef = ref<HTMLElement | null>(null);
const bodyY = ref(480);
const CHROME = 96;
@@ -190,13 +219,7 @@ onUnmounted(() => {
<span class="call-logs__label">时间范围</span>
<TimeRangePicker
:model-value="range"
@change="
(v, k) => {
setRange(v, k);
page = 1;
pushQuery();
}
"
@change="onRangeChange"
/>
</template>
</PageHeader>
@@ -266,10 +289,7 @@ onUnmounted(() => {
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100', '200'],
showTotal: (t: number) => `共 ${t} 条`,
onChange: (p: number, ps: number) => {
page = p;
pageSize = ps;
},
onChange: onPageChange,
}"
size="middle"
:scroll="{ y: bodyY }"
@@ -306,9 +326,74 @@ onUnmounted(() => {
<template v-else-if="column.key === 'tokens'">
{{ formatInt(record.prompt_tokens) }} / {{ formatInt(record.completion_tokens) }}
</template>
<template v-else-if="column.key === 'actions'">
<a @click="openDetail(record)">详情</a>
</template>
</template>
</a-table>
</div>
<a-modal
v-model:open="detailOpen"
title="调用详情"
:footer="null"
width="720"
centered
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
>
<div v-if="detailRecord" class="call-logs__detail">
<div class="call-logs__kv">
<span class="call-logs__note">时间</span>
<span>{{ formatDateTime(detailRecord.created_at) }}</span>
</div>
<div class="call-logs__kv">
<span class="call-logs__note">Key / 归属</span>
<span>
{{ detailRecord.token_name }}
·
{{ keyById.get(detailRecord.api_key_id)?.member_name ?? "未归属" }}
</span>
</div>
<div class="call-logs__kv">
<span class="call-logs__note">模型 / 类型</span>
<span>
{{ detailRecord.model_name }}
·
{{ detailRecord.type === 2 ? "消费" : "错误" }}
</span>
</div>
<div class="call-logs__kv">
<span class="call-logs__note">用量</span>
<QuotaText :quota="detailRecord.quota" :quota-usd="detailRecord.quota_usd" />
</div>
<div class="call-logs__kv">
<span class="call-logs__note">Tokens</span>
<span class="call-logs__num">
提示 {{ formatInt(detailRecord.prompt_tokens) }} · 补全 {{ formatInt(detailRecord.completion_tokens) }}
</span>
</div>
<div class="call-logs__kv">
<span class="call-logs__note">耗时</span>
<span class="call-logs__num">
{{ detailRecord.use_time }}s · {{ detailRecord.is_stream ? "流式" : "非流式" }}
</span>
</div>
<div class="call-logs__kv">
<span class="call-logs__note">来源</span>
<a-tag :color="sourceBadge(detailRecord).color">
{{ sourceBadge(detailRecord).text }}
</a-tag>
</div>
<div v-if="detailRecord.content" class="call-logs__kv">
<span class="call-logs__note">内容</span>
<span class="call-logs__content">{{ detailRecord.content }}</span>
</div>
<div class="call-logs__raw">
<div class="call-logs__note">原始数据</div>
<pre class="call-logs__json">{{ formatRaw(detailRecord) }}</pre>
</div>
</div>
</a-modal>
</section>
</template>
@@ -337,4 +422,52 @@ onUnmounted(() => {
flex: 1;
min-height: 0;
}
.call-logs__detail {
display: flex;
flex-direction: column;
gap: 12px;
}
.call-logs__kv {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 12px;
}
.call-logs__note {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
min-width: 72px;
flex-shrink: 0;
}
.call-logs__num {
font-variant-numeric: tabular-nums;
}
.call-logs__content {
white-space: pre-wrap;
word-break: break-all;
flex: 1;
min-width: 0;
}
.call-logs__raw {
margin-top: 4px;
}
.call-logs__json {
margin: 4px 0 0;
padding: 12px;
max-height: 360px;
overflow: auto;
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 6px;
font-size: 12px;
line-height: 20px;
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
}
</style>
+74 -5
View File
@@ -2,7 +2,7 @@
/**
* 成员管理(交互设计-成员管理):清单表 · 开户 · 令牌明文/重发 · 启停
*/
import { computed, h, reactive, ref } from "vue";
import { computed, h, reactive, ref, watch } from "vue";
import { Tooltip, message } from "antdv-next";
import { QuestionCircleOutlined } from "@antdv-next/icons";
import { useRoute, useRouter } from "vue-router";
@@ -34,6 +34,29 @@ const tokenMemberName = ref("");
const createForm = reactive({ name: "", remark: "" });
const createError = ref("");
const filterName = ref("");
const filterStatus = ref<"active" | "disabled" | null>(null);
function syncFiltersFromRoute() {
const q = route.query;
filterName.value = typeof q.name === "string" ? q.name : "";
filterStatus.value =
q.status === "active" || q.status === "disabled" ? q.status : null;
}
function pushFilterQuery() {
const query: Record<string, string> = {};
const mid = route.query.member_id;
if (typeof mid === "string" && mid) query.member_id = mid;
if (filterName.value.trim()) query.name = filterName.value.trim();
if (filterStatus.value) query.status = filterStatus.value;
void router.replace({ path: "/admin/members", query });
}
syncFiltersFromRoute();
watch(() => route.query, () => syncFiltersFromRoute());
watch([filterName, filterStatus], () => pushFilterQuery());
function tipTitle(label: string, tip: string) {
return h("span", { class: "overview-col-title" }, [
label,
@@ -63,15 +86,34 @@ const columns = [
];
const rows = computed(() => {
const list = membersQuery.data.value ?? [];
let list = membersQuery.data.value ?? [];
const mid = route.query.member_id;
if (typeof mid === "string" && mid) {
const n = Number(mid);
return list.filter((m) => m.id === n);
list = list.filter((m) => m.id === n);
}
const name = filterName.value.trim();
if (name) {
const lower = name.toLowerCase();
list = list.filter(
(m) =>
m.name.toLowerCase().includes(lower) ||
(m.remark ?? "").toLowerCase().includes(lower),
);
}
if (filterStatus.value) {
list = list.filter((m) => m.status === filterStatus.value);
}
return list;
});
const hasFilter = computed(
() =>
!!filterName.value.trim() ||
filterStatus.value != null ||
(typeof route.query.member_id === "string" && !!route.query.member_id),
);
async function onCreate() {
createError.value = "";
const name = createForm.name.trim();
@@ -133,11 +175,31 @@ async function onToggle(record: MemberItem) {
</template>
</PageHeader>
<div class="members-filters">
<a-input
v-model:value="filterName"
allow-clear
placeholder="名称 / 备注"
style="width: 180px"
/>
<a-select
v-model:value="filterStatus"
:options="[
{ value: null, label: '全部状态' },
{ value: 'active', label: '启用中' },
{ value: 'disabled', label: '已停用' },
]"
style="width: 120px"
allow-clear
placeholder="状态"
/>
</div>
<a-empty
v-if="!membersQuery.isPending.value && rows.length === 0"
description="还没有成员——为每位分摊朋友开户并发放访问令牌"
:description="hasFilter ? '没有符合筛选的成员' : '还没有成员——为每位分摊朋友开户并发放访问令牌'"
>
<a-button @click="createOpen = true">新建成员</a-button>
<a-button v-if="!hasFilter" @click="createOpen = true">新建成员</a-button>
</a-empty>
<a-table
@@ -230,6 +292,13 @@ async function onToggle(record: MemberItem) {
</template>
<style scoped>
.members-filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.overview-col-title {
display: inline-flex;
align-items: center;
+4 -4
View File
@@ -16,7 +16,7 @@ import {
type RangeKind,
type RangeValue,
} from "@/composables/use-live-time-range.ts";
import { useAdminOverview, useEngine, usePayments } from "@/queries/admin.ts";
import { useAdminOverview, useEngine, useSubscriptions } from "@/queries/admin.ts";
import { formatMinute, toRequestIso } from "@/utils/time.ts";
import { formatInt } from "@/utils/format.ts";
@@ -25,7 +25,7 @@ const { range, setRange } = useLiveTimeRange("recent-30");
const overviewQuery = useAdminOverview(() => range.value);
const engineQuery = useEngine();
const paymentsQuery = usePayments();
const paymentsQuery = useSubscriptions();
const overview = computed(() => overviewQuery.data.value);
const engine = computed(() => engineQuery.data.value);
@@ -37,9 +37,9 @@ const loading = computed(
const extraPresets = computed(() => {
const list = paymentsQuery.data.value ?? [];
return list
.filter((p) => p.kind === "subscription" && p.period_start != null)
.filter((p) => p.period_start != null)
.map((p) => ({
label: `订阅 #${p.subscription_id ?? p.id} 期间`,
label: `订阅 #${p.subscription_id} 期间`,
from: toRequestIso(new Date((p.period_start ?? 0) * 1000)),
to: toRequestIso(
new Date((p.period_end ?? Math.floor(Date.now() / 1000)) * 1000),
+227 -81
View File
@@ -1,103 +1,169 @@
<script setup lang="ts">
/**
* 支付记录(交互设计-支付记录):同构登记 · 编辑删除 · 核对提示
* 支付记录(交互设计-支付记录):订阅 / 充值两 Tab · 核对提示
*/
import { computed, ref, watch } from "vue";
import { message } from "antdv-next";
import type { PaymentItem } from "@new-api-key-usage/core";
import type { SubscriptionItem, TopupItem } from "@new-api-key-usage/core";
import type { ApiError } from "@/api/http.ts";
import { fetchLogs } from "@/api/admin/logs.ts";
import { useRoute, useRouter } from "vue-router";
import PageHeader from "@/components/common/page-header.vue";
import PaymentFormModal from "@/components/admin/payment-form-modal.vue";
import QuotaText from "@/components/common/quota-text.vue";
import { useDeletePayment, usePayments } from "@/queries/admin.ts";
import {
useDeleteSubscription,
useDeleteTopup,
useSubscriptions,
useTopups,
} from "@/queries/admin.ts";
import { formatDateTime, toRequestIso } from "@/utils/time.ts";
import { formatPaidAmount } from "@/utils/format.ts";
const paymentsQuery = usePayments();
const deletePay = useDeletePayment();
const route = useRoute();
const router = useRouter();
const subsQuery = useSubscriptions();
const topupsQuery = useTopups();
const deleteSub = useDeleteSubscription();
const deleteTop = useDeleteTopup();
const tab = ref<"subs" | "topups">("subs");
const formOpen = ref(false);
const editing = ref<PaymentItem | null>(null);
const editingSub = ref<SubscriptionItem | null>(null);
const editingTop = ref<TopupItem | null>(null);
const missingSubs = ref<number[]>([]);
const checking = ref(false);
const rows = computed(() => {
const list = [...(paymentsQuery.data.value ?? [])];
function syncFromRoute() {
tab.value = route.query.tab === "topups" ? "topups" : "subs";
}
function pushTab() {
void router.replace({
path: "/admin/payments",
query: tab.value === "topups" ? { tab: "topups" } : {},
});
}
syncFromRoute();
watch(() => route.query, () => syncFromRoute());
watch(tab, () => pushTab());
const subRows = computed(() => [...(subsQuery.data.value ?? [])]);
const topRows = computed(() => {
const list = [...(topupsQuery.data.value ?? [])];
list.sort((a, b) => (b.paid_at ?? 0) - (a.paid_at ?? 0));
return list;
});
const columns = [
{ title: "类型", key: "kind", width: 90 },
{ title: "金额(币种自定)", key: "paid_amount", width: 140 },
{ title: "时间", key: "time", width: 180 },
{ title: "订阅 ID", key: "subscription_id", width: 100 },
const subColumns = [
{ title: "订阅 ID", dataIndex: "subscription_id", key: "subscription_id", width: 100 },
{ title: "期间", key: "period", width: 200 },
{ title: "额度", key: "quota", width: 160 },
{ title: "付款金额 / 时间", key: "paid", width: 170 },
{ title: "备注", key: "remark" },
{ title: "登记", key: "created_at", width: 150 },
{ title: "操作", key: "actions", width: 120 },
];
const topColumns = [
{ title: "时间", key: "paid_at", width: 150 },
{
title: "充值金额",
dataIndex: "amount",
key: "amount",
width: 100,
},
{ title: "实付金额", dataIndex: "money", key: "money", width: 100 },
{ title: "交易号 / 渠道", key: "trade", width: 180 },
{ title: "派生额度", key: "quota", width: 150 },
{ title: "备注", key: "remark" },
{ title: "操作", key: "actions", width: 120 },
];
function timeText(p: PaymentItem): string {
if (p.kind === "topup") return formatDateTime(p.paid_at);
const start = formatDateTime(p.period_start);
const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end);
return `${start} ~ ${end}`;
function periodLines(s: SubscriptionItem): { start: string; end: string } {
return {
start: formatDateTime(s.period_start),
end: s.period_end == null ? "进行中" : formatDateTime(s.period_end),
};
}
function onCreate() {
editing.value = null;
function onCreateSub() {
editingSub.value = null;
editingTop.value = null;
formOpen.value = true;
}
function onEdit(p: PaymentItem) {
editing.value = p;
function onCreateTop() {
editingSub.value = null;
editingTop.value = null;
tab.value = "topups";
formOpen.value = true;
}
async function onDelete(p: PaymentItem) {
function onEditSub(s: SubscriptionItem) {
editingSub.value = s;
editingTop.value = null;
formOpen.value = true;
}
function onEditTop(t: TopupItem) {
editingTop.value = t;
editingSub.value = null;
formOpen.value = true;
}
async function onDeleteSub(s: SubscriptionItem) {
try {
await deletePay.mutateAsync(p.id);
await deleteSub.mutateAsync(s.id);
message.success("已删除");
} catch (e) {
message.error((e as ApiError).message || "删除失败");
}
}
/** 观测订阅号 ↔ 已登记:只提示不回填;payments 全空则静默 */
async function reconcile(pays: PaymentItem[]) {
if (pays.length === 0) {
async function onDeleteTop(t: TopupItem) {
try {
await deleteTop.mutateAsync(t.id);
message.success("已删除");
} catch (e) {
message.error((e as ApiError).message || "删除失败");
}
}
/** 观测订阅号 ↔ 已登记:只提示不回填;双表皆空则静默 */
async function reconcile(subs: SubscriptionItem[]) {
if (subs.length === 0 && topupsQuery.data.value?.length === 0) {
missingSubs.value = [];
return;
}
if (subs.length === 0) {
missingSubs.value = [];
return;
}
checking.value = true;
try {
const known = new Set(
pays.map((p) => p.subscription_id).filter((v): v is number => v != null),
const known = new Set(subs.map((s) => s.subscription_id));
const earliest = Math.min(
...subs.map((s) => s.period_start ?? 0),
Date.now() / 1000,
);
const earliest = Math.min(...pays.map((p) => p.paid_at ?? 0), Date.now() / 1000);
const from = toRequestIso(new Date((earliest - 30 * 86400) * 1000));
const to = toRequestIso(new Date());
const observed = new Set<number>();
let page = 1;
for (;;) {
const res = await fetchLogs({
const page = await fetchLogs({
from,
to,
page,
page: 1,
page_size: 200,
type: 2,
});
for (const it of res.items) {
const observed = new Set<number>();
for (const it of page.items) {
if (it.type === 2 && it.subscription_id != null) {
observed.add(it.subscription_id);
}
}
if (page * 200 >= res.total || page >= 20) break;
page += 1;
}
missingSubs.value = [...observed]
.filter((id) => !known.has(id))
.sort((a, b) => a - b);
missingSubs.value = [...observed].filter((id) => !known.has(id)).sort((a, b) => a - b);
} catch {
missingSubs.value = [];
} finally {
@@ -105,12 +171,11 @@ async function reconcile(pays: PaymentItem[]) {
}
}
// 数据到达/失效刷新后再核对(挂载时尚未 resolve)
watch(
() => paymentsQuery.data.value,
(pays) => {
if (!pays) return;
void reconcile(pays);
() => subsQuery.data.value,
(subs) => {
if (!subs) return;
void reconcile(subs);
},
{ immediate: true },
);
@@ -118,19 +183,26 @@ watch(
<template>
<section>
<PageHeader title="支付记录" subtitle="订阅与充值的人工登记">
<PageHeader title="支付记录" subtitle="订阅权益与充值入账登记">
<template #actions>
<a-button type="primary" @click="onCreate">登记支付</a-button>
<a-button v-if="tab === 'subs'" type="primary" @click="onCreateSub">
登记订阅
</a-button>
<a-button v-else type="primary" @click="onCreateTop">登记充值</a-button>
</template>
</PageHeader>
<a-tabs v-model:active-key="tab">
<a-tab-pane key="subs" tab="订阅" />
<a-tab-pane key="topups" tab="充值" />
</a-tabs>
<a-alert
v-if="missingSubs.length"
v-if="tab === 'subs' && missingSubs.length"
type="warning"
show-icon
style="margin-bottom: 16px"
:message="`观测到 ${missingSubs.length} 个订阅有消耗但尚无对应登记`"
description="若本部署不登记支付,可忽略此提示;需要入账的请核对补录。"
>
<template #description>
<div class="pay-missing__tags">
@@ -142,53 +214,54 @@ watch(
</template>
</a-alert>
<template v-if="tab === 'subs'">
<a-empty
v-if="!paymentsQuery.isPending.value && rows.length === 0"
description="还没有支付记录——订阅/充值同构登记"
v-if="!subsQuery.isPending.value && subRows.length === 0"
description="还没有订阅登记——从平台选用或手工登记"
>
<a-button @click="onCreate">登记支付</a-button>
<a-button @click="onCreateSub">登记订阅</a-button>
</a-empty>
<a-table
v-else
:columns="columns"
:data-source="rows"
:row-key="(r: PaymentItem) => r.id"
:loading="paymentsQuery.isPending.value || paymentsQuery.isFetching.value || checking"
:columns="subColumns"
:data-source="subRows"
:row-key="(r: SubscriptionItem) => r.id"
:loading="subsQuery.isPending.value || subsQuery.isFetching.value || checking"
:pagination="false"
size="middle"
:scroll="{ x: 1000 }"
:scroll="{ x: 960 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'kind'">
<a-tag :color="record.kind === 'subscription' ? 'blue' : 'gold'">
{{ record.kind === "subscription" ? "订阅" : "充值" }}
</a-tag>
</template>
<template v-else-if="column.key === 'paid_amount'">
{{ formatPaidAmount(record.paid_amount) }}
</template>
<template v-else-if="column.key === 'time'">
{{ timeText(record) }}
</template>
<template v-else-if="column.key === 'subscription_id'">
{{ record.subscription_id ?? "—" }}
<template v-if="column.key === 'period'">
<div class="pay-period">
<span>{{ periodLines(record).start }}</span>
<span class="pay-period__end">~ {{ periodLines(record).end }}</span>
</div>
</template>
<template v-else-if="column.key === 'quota'">
<QuotaText :quota="record.quota_granted" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'paid'">
{{
record.paid_amount != null
? `${formatPaidAmount(record.paid_amount)} · ${formatDateTime(record.paid_at)}`
: "—"
}}
</template>
<template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }}
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button size="small" @click="() => onEdit(record)">编辑</a-button>
<a-button size="small" @click="() => onEditSub(record)">编辑</a-button>
<a-popconfirm
title="删除后该条支付记录将消失"
description="改错即修,删除即消失"
title="删除后该条将消失"
ok-text="删除"
cancel-text="取消"
@confirm="() => onDelete(record)"
@confirm="() => onDeleteSub(record)"
>
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
@@ -196,12 +269,85 @@ watch(
</template>
</template>
</a-table>
</template>
<PaymentFormModal v-model:open="formOpen" :editing="editing" />
<template v-else>
<a-empty
v-if="!topupsQuery.isPending.value && topRows.length === 0"
description="还没有充值登记"
>
<a-button @click="onCreateTop">登记充值</a-button>
</a-empty>
<a-table
v-else
:columns="topColumns"
:data-source="topRows"
:row-key="(r: TopupItem) => r.id"
:loading="topupsQuery.isPending.value || topupsQuery.isFetching.value"
:pagination="false"
size="middle"
:scroll="{ x: 900 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'paid_at'">
{{ formatDateTime(record.paid_at) }}
</template>
<template v-else-if="column.key === 'amount'">
{{ formatPaidAmount(record.amount) }}
</template>
<template v-else-if="column.key === 'money'">
{{ formatPaidAmount(record.money) }}
</template>
<template v-else-if="column.key === 'trade'">
{{ record.trade_no || "—" }}
<span v-if="record.payment_method" class="pay-note">
· {{ record.payment_method }}
</span>
</template>
<template v-else-if="column.key === 'quota'">
<QuotaText :quota="record.quota_derived" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button size="small" @click="() => onEditTop(record)">编辑</a-button>
<a-popconfirm
title="删除后该条将消失"
ok-text="删除"
cancel-text="取消"
@confirm="() => onDeleteTop(record)"
>
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</template>
<PaymentFormModal
v-model:open="formOpen"
:tab="tab"
:editing-sub="editingSub"
:editing-top="editingTop"
/>
</section>
</template>
<style scoped>
.pay-period {
display: flex;
flex-direction: column;
line-height: 20px;
font-variant-numeric: tabular-nums;
}
.pay-period__end {
color: rgba(0, 0, 0, 0.65);
}
.pay-missing__tags {
display: flex;
flex-wrap: wrap;
@@ -209,9 +355,9 @@ watch(
margin-bottom: 4px;
}
.pay-missing__note {
.pay-missing__note,
.pay-note {
font-size: 12px;
line-height: 20px;
color: rgba(0, 0, 0, 0.65);
color: rgba(0, 0, 0, 0.45);
}
</style>
-524
View File
@@ -1,524 +0,0 @@
<script setup lang="ts">
/**
* 成员视图单页(交互设计-成员视图):聚合 · 支付参数 · 我的 key · 日志徽章展开
*/
import { computed, h, onMounted, onUnmounted, ref, watch } from "vue";
import type { LogItem, PaymentItem } from "@new-api-key-usage/core";
import { Tooltip } from "antdv-next";
import { QuestionCircleOutlined } from "@antdv-next/icons";
import { useRoute, useRouter } from "vue-router";
import PageHeader from "@/components/common/page-header.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue";
import QuotaText from "@/components/common/quota-text.vue";
import SourceBar from "@/components/common/source-bar.vue";
import { useLiveTimeRange } from "@/composables/use-live-time-range.ts";
import { useMemberLogs, useMemberOverview, useMemberMe } from "@/queries/member.ts";
import { formatDateTime, formatMinute, hoursSince } from "@/utils/time.ts";
import { formatInt } from "@/utils/format.ts";
const route = useRoute();
const router = useRouter();
const { range, setRange } = useLiveTimeRange("recent-30");
const meQuery = useMemberMe();
const overviewQuery = useMemberOverview(() => range.value);
const page = ref(1);
const pageSize = ref(50);
const logsQuery = useMemberLogs(() => ({
...range.value,
page: page.value,
page_size: pageSize.value,
}));
const overview = computed(() => overviewQuery.data.value);
const me = computed(() => overview.value?.member ?? meQuery.data.value ?? null);
const usage = computed(() => overview.value?.usage);
const bySource = computed(() => overview.value?.by_source ?? []);
const keys = computed(() => overview.value?.keys ?? []);
const payments = computed(() => overview.value?.payments ?? []);
const extraPresets = computed(() =>
payments.value
.filter((p) => p.kind === "subscription" && p.period_start != null)
.map((p) => ({
label: `订阅 #${p.subscription_id ?? p.id} 期间`,
from: new Date((p.period_start ?? 0) * 1000).toISOString(),
to: new Date(
(p.period_end ?? Math.floor(Date.now() / 1000)) * 1000,
).toISOString(),
}))
.slice(0, 4),
);
const paymentBySub = computed(() => {
const m = new Map<number, PaymentItem>();
for (const p of payments.value) {
if (p.subscription_id != null) m.set(p.subscription_id, p);
}
return m;
});
const logs = computed(() => logsQuery.data.value?.items ?? []);
const logTotal = computed(() => logsQuery.data.value?.total ?? 0);
const expandedRowKeys = ref<number[]>([]);
function tipTitle(label: string, tip: string) {
return h("span", { class: "mv-col-title" }, [
label,
h(
Tooltip,
{ title: tip },
() =>
h(QuestionCircleOutlined, {
class: "mv-col-title__icon",
"aria-hidden": "true",
}),
),
]);
}
const logColumns = [
{ title: "时间", key: "created_at", width: 150 },
{ title: "Key", key: "token_name", dataIndex: "token_name", width: 120 },
{
title: "模型",
key: "model_name",
dataIndex: "model_name",
width: 120,
},
{ title: "来源", key: "source", width: 120 },
{ title: "用量", key: "usage", width: 150 },
{
title: tipTitle("Tokens", "提示 tokens / 补全 tokens"),
key: "tokens",
width: 110,
},
];
function syncFromRoute() {
const from = route.query.from;
const to = route.query.to;
if (typeof from === "string" && from && typeof to === "string" && to) {
setRange({ from, to }, "fixed");
}
}
function pushRangeQuery() {
void router.replace({
path: "/member",
query: { from: range.value.from, to: range.value.to },
});
}
syncFromRoute();
watch(
() => route.query,
() => syncFromRoute(),
);
function onRangeChange(
v: { from: string; to: string },
kind: "recent-7" | "recent-30" | "fixed",
) {
setRange(v, kind);
page.value = 1;
expandedRowKeys.value = [];
pushRangeQuery();
}
function isStalled(at: number | null | undefined): boolean {
const h = hoursSince(at);
return h != null && h > 2;
}
function sourceBadge(record: LogItem) {
if (record.billing_source === "subscription" && record.subscription_id != null) {
return {
text: `订阅 #${record.subscription_id}`,
kind: "sub" as const,
id: record.subscription_id,
};
}
if (record.billing_source === "wallet") {
return { text: "充值", kind: "wallet" as const, id: null };
}
return { text: "未关联", kind: "none" as const, id: null };
}
function onBadge(record: LogItem) {
const b = sourceBadge(record);
if (b.kind !== "sub") return;
const i = expandedRowKeys.value.indexOf(record.id);
if (i >= 0) expandedRowKeys.value.splice(i, 1);
else expandedRowKeys.value.push(record.id);
}
function onExpand(expanded: boolean, record: LogItem) {
const i = expandedRowKeys.value.indexOf(record.id);
if (expanded && i < 0) expandedRowKeys.value.push(record.id);
if (!expanded && i >= 0) expandedRowKeys.value.splice(i, 1);
}
/** 表体滚动,避免整页被明细撑高 */
const boxRef = ref<HTMLElement | null>(null);
const bodyY = ref(420);
const CHROME = 120;
function measure() {
const h = boxRef.value?.clientHeight ?? 0;
if (h > 0) bodyY.value = Math.max(220, h - CHROME);
}
let ro: ResizeObserver | null = null;
onMounted(() => {
measure();
if (typeof ResizeObserver !== "undefined" && boxRef.value) {
ro = new ResizeObserver(() => measure());
ro.observe(boxRef.value);
}
window.addEventListener("resize", measure);
});
onUnmounted(() => {
ro?.disconnect();
window.removeEventListener("resize", measure);
});
function periodText(p: PaymentItem): string {
if (p.kind !== "subscription") return formatDateTime(p.paid_at);
const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end);
return `${formatDateTime(p.period_start)} ~ ${end}`;
}
function expandPayment(record: LogItem): PaymentItem | undefined {
return paymentBySub.value.get(sourceBadge(record).id ?? -1);
}
</script>
<template>
<section class="mv">
<PageHeader
:title="me?.name ? me.name : '我的用量'"
subtitle="你在本池的名下用量"
>
<template #actions>
<span class="mv-range-label">时间范围</span>
<TimeRangePicker
:model-value="range"
:extra-presets="extraPresets"
@change="onRangeChange"
/>
</template>
</PageHeader>
<a-skeleton
v-if="overviewQuery.isPending.value"
active
:paragraph="{ rows: 4 }"
/>
<a-empty
v-else-if="keys.length === 0"
description="作者尚未为你分配 key"
/>
<div v-else class="mv-body">
<section class="mv-block">
<h2 class="mv-block__title">总用量</h2>
<template v-if="usage && usage.count > 0">
<div class="mv-agg">
<span>调用 {{ formatInt(usage.count) }} 次</span>
<QuotaText :quota="usage.quota" :quota-usd="usage.quota_usd" />
<span class="mv-note">按当时换算率</span>
</div>
<SourceBar :rows="bySource" />
</template>
<div v-else class="mv-empty">该时段内无调用</div>
</section>
<section class="mv-block">
<h2 class="mv-block__title">支付参数</h2>
<div v-if="payments.length === 0" class="mv-empty">暂无支付记录信息</div>
<template v-else>
<div v-for="p in payments" :key="p.id" class="mv-pay">
<div class="mv-pay__head">
<a-tag :color="p.kind === 'subscription' ? 'blue' : 'gold'">
{{ p.kind === "subscription" ? "订阅" : "充值" }}
</a-tag>
<strong v-if="p.kind === 'subscription'">
订阅 #{{ p.subscription_id }}
</strong>
</div>
<div class="mv-pay__grid">
<div class="mv-pay__row">
<span class="mv-pay__k">{{ p.kind === "subscription" ? "期间" : "支付时间" }}</span>
<span>{{ periodText(p) }}</span>
</div>
<div class="mv-pay__row">
<span class="mv-pay__k">额度</span>
<QuotaText :quota="p.quota_granted" :quota-usd="p.quota_usd" />
</div>
<div class="mv-pay__row">
<span class="mv-pay__k">金额</span>
<span class="mv-num">{{ p.paid_amount }}</span>
</div>
<div v-if="p.remark" class="mv-pay__row">
<span class="mv-pay__k">备注</span>
<span>{{ p.remark }}</span>
</div>
</div>
</div>
<div class="mv-note mv-foot">
支付参数对成员完全公开;币种由成员与作者约定
</div>
</template>
</section>
<section class="mv-block">
<h2 class="mv-block__title">我的 key</h2>
<div class="mv-keys">
<div v-for="k in keys" :key="k.key_id" class="mv-key">
<strong>{{ k.token_name }}</strong>
<span v-if="isStalled(k.log_synced_at)" class="mv-stall">
同步截止 {{ formatMinute(k.log_synced_at) }} · 停滞
</span>
<span v-else class="mv-note">
同步截止 {{ formatMinute(k.log_synced_at) }}
</span>
<span>调用 {{ formatInt(k.usage.count) }} 次</span>
<QuotaText :quota="k.usage.quota" :quota-usd="k.usage.quota_usd" />
</div>
</div>
</section>
<section class="mv-block mv-block--logs">
<h2 class="mv-block__title">日志明细</h2>
<div ref="boxRef" class="mv-logs">
<a-table
:columns="logColumns"
:data-source="logs"
:row-key="(r: LogItem) => r.id"
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
:pagination="{
current: page,
pageSize,
total: logTotal,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100'],
onChange: (p: number, ps: number) => {
page = p;
pageSize = ps;
},
}"
size="middle"
:scroll="{ x: 800, y: bodyY }"
:expanded-row-keys="expandedRowKeys"
:expandable="{
showExpandColumn: false,
rowExpandable: (r: LogItem) => sourceBadge(r).kind === 'sub',
}"
@expand="onExpand"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'usage'">
<QuotaText :quota="record.quota" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'tokens'">
{{ formatInt(record.prompt_tokens) }} / {{ formatInt(record.completion_tokens) }}
</template>
<template v-else-if="column.key === 'source'">
<a-tooltip
v-if="sourceBadge(record).kind === 'sub'"
title="点击查看该订阅支付参数"
>
<a-tag color="blue" class="mv-badge" @click="onBadge(record)">
{{ sourceBadge(record).text }}
</a-tag>
</a-tooltip>
<a-tag v-else-if="sourceBadge(record).kind === 'wallet'" color="gold">
充值
</a-tag>
<a-tag v-else>未关联</a-tag>
</template>
</template>
<template #expandedRowRender="{ record }">
<div class="mv-expand">
<template v-if="expandPayment(record)">
<div class="mv-expand__title">订阅支付参数</div>
<div class="mv-pay__grid">
<div class="mv-pay__row">
<span class="mv-pay__k">期间</span>
<span>{{ periodText(expandPayment(record)!) }}</span>
</div>
<div class="mv-pay__row">
<span class="mv-pay__k">额度</span>
<QuotaText
:quota="expandPayment(record)!.quota_granted"
:quota-usd="expandPayment(record)!.quota_usd"
/>
</div>
<div class="mv-pay__row">
<span class="mv-pay__k">金额</span>
<span class="mv-num">
{{ expandPayment(record)!.paid_amount }}
</span>
</div>
<div v-if="expandPayment(record)!.remark" class="mv-pay__row">
<span class="mv-pay__k">备注</span>
<span>{{ expandPayment(record)!.remark }}</span>
</div>
</div>
</template>
<div v-else class="mv-empty">该订阅无登记信息</div>
</div>
</template>
</a-table>
</div>
</section>
</div>
</section>
</template>
<style scoped>
.mv {
min-height: 0;
}
.mv-range-label {
font-size: 13px;
line-height: 32px;
color: rgba(0, 0, 0, 0.65);
}
.mv-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.mv-block {
margin-bottom: 16px;
}
.mv-block--logs {
flex: 1;
min-height: 280px;
display: flex;
flex-direction: column;
}
.mv-logs {
flex: 1;
min-height: 0;
}
.mv-block__title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
line-height: 22px;
}
.mv-agg {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 16px;
margin-bottom: 12px;
font-variant-numeric: tabular-nums;
}
.mv-note {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.mv-foot {
margin-top: 8px;
}
.mv-empty {
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
}
.mv-num {
font-variant-numeric: tabular-nums;
}
.mv-pay {
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.mv-pay__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.mv-pay__grid {
display: grid;
gap: 4px;
font-size: 13px;
}
.mv-pay__row {
display: flex;
gap: 12px;
}
.mv-pay__k {
width: 56px;
flex-shrink: 0;
color: rgba(0, 0, 0, 0.45);
}
.mv-keys {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.mv-key {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 16px;
border: 1px solid #f0f0f0;
border-radius: 8px;
font-size: 13px;
}
.mv-stall {
color: #d48806;
font-weight: 600;
}
.mv-badge {
cursor: pointer;
}
.mv-expand {
padding: 8px 12px;
font-size: 13px;
}
.mv-expand__title {
font-weight: 600;
margin-bottom: 8px;
}
.mv-col-title {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.mv-col-title__icon {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
cursor: help;
}
</style>
+1 -1
View File
@@ -20,7 +20,7 @@ async function onFinish() {
tokenStore.setToken(model.token.trim());
const me = await fetchMe();
tokenStore.setMemberName(me.name);
void router.replace({ name: "member-home" });
void router.replace({ name: "member-overview" });
} catch {
tokenStore.clearToken();
error.value = "令牌无效或已停用";
+548
View File
@@ -0,0 +1,548 @@
<script setup lang="ts">
/**
* 成员调用日志(交互设计-成员视图 §4):
* 行展开 = 模型调用详情;来源徽章 = 订阅支付参数弹窗(R12)
*/
import { computed, h, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import type { LogItem, SubscriptionItem } from "@new-api-key-usage/core";
import { Tooltip } from "antdv-next";
import { QuestionCircleOutlined } from "@antdv-next/icons";
import { useRoute, useRouter } from "vue-router";
import PageHeader from "@/components/common/page-header.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue";
import QuotaText from "@/components/common/quota-text.vue";
import { useMemberRange } from "@/composables/use-member-range.ts";
import { useMemberLogs, useMemberOverview } from "@/queries/member.ts";
import { formatDateTime } from "@/utils/time.ts";
import { formatInt, formatPaidAmount } from "@/utils/format.ts";
const route = useRoute();
const router = useRouter();
const { range, setRange } = useMemberRange();
const apiKeyId = ref<number | null>(null);
const billingSource = ref<string | null>(null);
const subscriptionId = ref<number | null>(null);
const typeFilter = ref<number | "error" | null>(null);
const page = ref(1);
const pageSize = ref(50);
const overviewQuery = useMemberOverview(() => range.value);
const payments = computed(
() => overviewQuery.data.value?.subscriptions ?? [],
);
const keys = computed(() => overviewQuery.data.value?.keys ?? []);
const params = computed(() => ({
...range.value,
page: page.value,
page_size: pageSize.value,
api_key_id: apiKeyId.value ?? undefined,
billing_source: billingSource.value ?? undefined,
subscription_id: subscriptionId.value ?? undefined,
type: typeFilter.value ?? undefined,
}));
const logsQuery = useMemberLogs(() => params.value);
const rows = computed(() => logsQuery.data.value?.items ?? []);
const total = computed(() => logsQuery.data.value?.total ?? 0);
const keyOptions = computed(() => [
{ value: null, label: "全部 Key" },
...keys.value.map((k) => ({ value: k.key_id, label: k.token_name })),
]);
const paymentBySub = computed(() => {
const m = new Map<number, SubscriptionItem>();
for (const p of payments.value) {
m.set(p.subscription_id, p);
}
return m;
});
function tipTitle(label: string, tip: string) {
return h("span", { class: "mv-col-title" }, [
label,
h(Tooltip, { title: tip }, () => h(QuestionCircleOutlined, { class: "mv-col-title__icon" })),
]);
}
const columns = [
{ title: "时间", key: "created_at", width: 150 },
{ title: "Key", key: "token_name", dataIndex: "token_name", width: 120 },
{ title: "模型", key: "model_name", dataIndex: "model_name", width: 120 },
{ title: "类型", key: "type", width: 80 },
{ title: "来源", key: "source", width: 120 },
{
title: tipTitle("用量", "quota 原值与按当时换算率折算的美元"),
key: "usage",
width: 150,
},
{ title: "操作", key: "actions", width: 72 },
];
const detailOpen = ref(false);
const detailRecord = ref<LogItem | null>(null);
/** 正在从 URL 灌入状态——避免筛选 watcher 误重置 page */
const applyingRoute = ref(false);
function openDetail(record: LogItem) {
detailRecord.value = record;
detailOpen.value = true;
}
function formatRaw(record: LogItem | null): string {
if (!record) return "";
return JSON.stringify(record.raw ?? record, null, 2);
}
function sourceBadge(record: LogItem) {
if (record.billing_source === "subscription" && record.subscription_id != null) {
return {
text: `订阅 #${record.subscription_id}`,
kind: "sub" as const,
id: record.subscription_id,
};
}
if (record.billing_source === "wallet") {
return { text: "充值", kind: "wallet" as const, id: null };
}
return { text: "未关联", kind: "none" as const, id: null };
}
function onBadge(record: LogItem, e: MouseEvent) {
e.stopPropagation();
if (sourceBadge(record).kind !== "sub") return;
payModalSubId.value = sourceBadge(record).id;
payModalOpen.value = true;
}
function periodText(p: SubscriptionItem): string {
const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end);
return `${formatDateTime(p.period_start)} ~ ${end}`;
}
const payModalOpen = ref(false);
const payModalSubId = ref<number | null>(null);
const payModalPayment = computed(() =>
payModalSubId.value == null
? undefined
: paymentBySub.value.get(payModalSubId.value),
);
function syncFromRoute() {
applyingRoute.value = true;
const q = route.query;
const k = q.api_key_id ?? q.key_id;
apiKeyId.value = typeof k === "string" && k ? Number(k) || null : null;
billingSource.value =
typeof q.billing_source === "string" && q.billing_source
? q.billing_source
: null;
const s = q.subscription_id;
subscriptionId.value = typeof s === "string" && s ? Number(s) || null : null;
const t = q.type;
if (t === "2") typeFilter.value = 2;
else if (t === "error") typeFilter.value = "error";
else typeFilter.value = null;
const p = q.page;
page.value = typeof p === "string" && p ? Number(p) || 1 : 1;
void nextTick(() => {
applyingRoute.value = false;
});
}
function pushQuery() {
const query: Record<string, string> = {
from: range.value.from,
to: range.value.to,
};
if (apiKeyId.value != null) query.api_key_id = String(apiKeyId.value);
if (billingSource.value) query.billing_source = billingSource.value;
if (subscriptionId.value != null) {
query.subscription_id = String(subscriptionId.value);
}
if (typeFilter.value) query.type = String(typeFilter.value);
if (page.value > 1) query.page = String(page.value);
void router.replace({ path: "/member/logs", query });
}
syncFromRoute();
watch(() => route.query, () => syncFromRoute());
watch([apiKeyId, billingSource, subscriptionId, typeFilter], () => {
if (applyingRoute.value) return;
page.value = 1;
pushQuery();
});
watch(page, () => pushQuery());
function onRangeChange(v: { from: string; to: string }, k: "recent-7" | "recent-30" | "fixed") {
setRange(v, k);
page.value = 1;
pushQuery();
}
function onPageChange(p: number, ps: number) {
page.value = p;
pageSize.value = ps;
}
const boxRef = ref<HTMLElement | null>(null);
const bodyY = ref(480);
const CHROME = 96;
let ro: ResizeObserver | null = null;
function measure() {
const h = boxRef.value?.clientHeight ?? 0;
if (h > 0) bodyY.value = Math.max(200, h - CHROME);
}
function bindObserve() {
ro?.disconnect();
ro = null;
if (typeof ResizeObserver !== "undefined" && boxRef.value) {
ro = new ResizeObserver(() => measure());
ro.observe(boxRef.value);
}
measure();
}
onMounted(() => {
bindObserve();
window.addEventListener("resize", measure);
});
onUnmounted(() => {
ro?.disconnect();
window.removeEventListener("resize", measure);
});
// 空态/有数据切换会重建 box 节点,需重新挂 RO
watch(
() => rows.value.length === 0 && !logsQuery.isPending.value,
async () => {
await nextTick();
bindObserve();
},
);
</script>
<template>
<section class="member-logs">
<PageHeader title="调用日志" subtitle="名下 key 的模型请求流水">
<template #actions>
<span class="member-logs__label">时间范围</span>
<TimeRangePicker :model-value="range" @change="onRangeChange" />
</template>
</PageHeader>
<div class="member-logs__filters">
<a-select
v-model:value="apiKeyId"
:options="keyOptions"
style="width: 160px"
allow-clear
placeholder="Key"
show-search
option-filter-prop="label"
/>
<a-select
v-model:value="billingSource"
:options="[
{ value: null, label: '全部来源' },
{ value: 'subscription', label: '订阅' },
{ value: 'wallet', label: '充值' },
{ value: 'none', label: '未关联' },
]"
style="width: 120px"
allow-clear
placeholder="来源"
/>
<a-input-number
v-model:value="subscriptionId"
:min="1"
:precision="0"
style="width: 120px"
placeholder="订阅 ID"
/>
<a-select
v-model:value="typeFilter"
:options="[
{ value: null, label: '全部类型' },
{ value: 2, label: '消费' },
{ value: 'error', label: '错误' },
]"
style="width: 120px"
placeholder="类型"
/>
</div>
<a-alert
v-if="logsQuery.isError.value"
type="error"
show-icon
message="调用日志加载失败"
:description="logsQuery.error.value?.message"
style="margin-bottom: 16px"
>
<template #action>
<a-button size="small" @click="() => logsQuery.refetch()">重试</a-button>
</template>
</a-alert>
<a-empty
v-if="!logsQuery.isPending.value && !logsQuery.isError.value && rows.length === 0"
description="该时段内无调用日志"
/>
<div v-else ref="boxRef" class="member-logs__box">
<a-table
:columns="columns"
:data-source="rows"
:row-key="(r: LogItem) => r.id"
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
:pagination="{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100', '200'],
showTotal: (t: number) => `共 ${t} 条`,
onChange: onPageChange,
}"
size="middle"
:scroll="{ x: 780, y: bodyY }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'usage'">
<QuotaText :quota="record.quota" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'type'">
<a-tag :color="record.type === 2 ? 'blue' : 'red'">
{{ record.type === 2 ? "消费" : "错误" }}
</a-tag>
</template>
<template v-else-if="column.key === 'source'">
<a-tooltip
v-if="sourceBadge(record).kind === 'sub'"
title="点击查看该订阅支付参数"
>
<a-tag
color="blue"
class="member-logs__badge"
@click="(e: MouseEvent) => onBadge(record, e)"
>
{{ sourceBadge(record).text }}
</a-tag>
</a-tooltip>
<a-tag v-else-if="sourceBadge(record).kind === 'wallet'" color="gold">
充值
</a-tag>
<a-tag v-else>未关联</a-tag>
</template>
<template v-else-if="column.key === 'actions'">
<a @click="openDetail(record)">详情</a>
</template>
</template>
</a-table>
</div>
<a-modal
v-model:open="detailOpen"
title="调用详情"
:footer="null"
width="720"
centered
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
>
<div v-if="detailRecord" class="member-logs__detail">
<div class="member-logs__kv">
<span class="member-logs__note">时间</span>
<span>{{ formatDateTime(detailRecord.created_at) }}</span>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">Key / 模型</span>
<span>{{ detailRecord.token_name }} · {{ detailRecord.model_name }}</span>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">类型</span>
<a-tag :color="detailRecord.type === 2 ? 'blue' : 'red'">
{{ detailRecord.type === 2 ? "消费" : "错误" }}
</a-tag>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">用量</span>
<QuotaText :quota="detailRecord.quota" :quota-usd="detailRecord.quota_usd" />
</div>
<div class="member-logs__kv">
<span class="member-logs__note">Tokens</span>
<span class="member-logs__num">
提示 {{ formatInt(detailRecord.prompt_tokens) }} · 补全 {{ formatInt(detailRecord.completion_tokens) }}
</span>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">耗时</span>
<span class="member-logs__num">
{{ detailRecord.use_time }}s · {{ detailRecord.is_stream ? "流式" : "非流式" }}
</span>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">来源</span>
<a-tag :color="sourceBadge(detailRecord).kind === 'sub' ? 'blue' : sourceBadge(detailRecord).kind === 'wallet' ? 'gold' : undefined">
{{ sourceBadge(detailRecord).text }}
</a-tag>
</div>
<div v-if="detailRecord.content" class="member-logs__kv">
<span class="member-logs__note">内容</span>
<span class="member-logs__content">{{ detailRecord.content }}</span>
</div>
<div class="member-logs__raw">
<div class="member-logs__note">原始数据</div>
<pre class="member-logs__json">{{ formatRaw(detailRecord) }}</pre>
</div>
</div>
</a-modal>
<a-modal
v-model:open="payModalOpen"
:title="payModalSubId != null ? `订阅 #${payModalSubId} 支付参数` : '支付参数'"
:footer="null"
width="480"
centered
:styles="{ body: { maxHeight: '56vh', overflowY: 'auto' } }"
>
<div v-if="payModalPayment" class="member-logs__pay">
<div class="member-logs__kv">
<span class="member-logs__note">期间</span>
<span>{{ periodText(payModalPayment) }}</span>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">额度</span>
<QuotaText
:quota="payModalPayment.quota_granted"
:quota-usd="payModalPayment.quota_usd"
/>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">金额</span>
<span class="member-logs__num">{{ formatPaidAmount(payModalPayment.paid_amount) }}</span>
</div>
<div v-if="payModalPayment.remark" class="member-logs__kv">
<span class="member-logs__note">备注</span>
<span>{{ payModalPayment.remark }}</span>
</div>
<div class="member-logs__foot">
支付参数对成员完全公开;币种由成员与作者约定
</div>
</div>
<div v-else class="member-logs__note">该订阅无登记信息</div>
</a-modal>
</section>
</template>
<style scoped>
.member-logs {
display: flex;
flex-direction: column;
height: calc(100vh - 64px - 48px - 48px);
min-height: 0;
}
.member-logs__label {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.member-logs__filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.member-logs__box {
flex: 1;
min-height: 0;
}
.member-logs__badge {
cursor: pointer;
}
.member-logs__detail {
display: flex;
flex-direction: column;
gap: 12px;
}
.member-logs__expand {
display: flex;
flex-direction: column;
gap: 8px;
padding: 4px 0;
}
.member-logs__pay {
display: flex;
flex-direction: column;
gap: 12px;
}
.member-logs__foot {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.member-logs__kv {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.member-logs__note {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
min-width: 72px;
}
.member-logs__num {
font-variant-numeric: tabular-nums;
}
.member-logs__content {
white-space: pre-wrap;
word-break: break-all;
flex: 1;
min-width: 0;
}
.member-logs__raw {
margin-top: 4px;
}
.member-logs__json {
margin: 4px 0 0;
padding: 12px;
max-height: 360px;
overflow: auto;
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 6px;
font-size: 12px;
line-height: 20px;
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
}
.member-logs :deep(.mv-col-title) {
display: inline-flex;
align-items: center;
gap: 4px;
}
.member-logs :deep(.mv-col-title__icon) {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
</style>
+240
View File
@@ -0,0 +1,240 @@
<script setup lang="ts">
/**
* 成员总览(交互设计-成员视图 §3):紧凑摘要 + 我的 Key 表 + 支付入口
*/
import { computed } from "vue";
import { useRouter } from "vue-router";
import PageHeader from "@/components/common/page-header.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue";
import SourceBar from "@/components/common/source-bar.vue";
import QuotaText from "@/components/common/quota-text.vue";
import { useMemberRange } from "@/composables/use-member-range.ts";
import { useMemberOverview } from "@/queries/member.ts";
import { formatMinute, formatRangeCaption, hoursSince } from "@/utils/time.ts";
import { formatInt } from "@/utils/format.ts";
const router = useRouter();
const { range, onRangeChange } = useMemberRange();
/** 看板心智:仅总览自动刷新;其它成员页不轮询 */
const overviewQuery = useMemberOverview(() => range.value, {
refetchInterval: 60_000,
});
const usage = computed(() => overviewQuery.data.value?.usage);
const bySource = computed(() => overviewQuery.data.value?.by_source ?? []);
const keys = computed(() => overviewQuery.data.value?.keys ?? []);
const payments = computed(
() => overviewQuery.data.value?.subscriptions ?? [],
);
const extraPresets = computed(() =>
payments.value
.map((p) => ({
label: `订阅 #${p.subscription_id} 期间`,
from: new Date((p.period_start ?? 0) * 1000).toISOString(),
to: new Date(
(p.period_end ?? Math.floor(Date.now() / 1000)) * 1000,
).toISOString(),
}))
.slice(0, 4),
);
const rangeCaption = computed(() =>
formatRangeCaption(range.value.from, range.value.to),
);
function isStalled(at: number | null | undefined): boolean {
const h = hoursSince(at);
return h != null && h > 2;
}
function goLogs(keyId?: number) {
const query: Record<string, string> = {
from: range.value.from,
to: range.value.to,
};
if (keyId != null) query.api_key_id = String(keyId);
void router.push({ path: "/member/logs", query });
}
const keyColumns = [
{ title: "Key 名称", dataIndex: "token_name", key: "token_name" },
{
title: "数据截止",
key: "data_cutoff",
},
{ title: "调用次数", key: "count" },
{ title: "额度消耗", key: "usage" },
{ title: "", key: "actions", width: 80 },
];
</script>
<template>
<section>
<PageHeader title="总览" subtitle="你在本池的名下用量 · 仅统计消费行">
<template #actions>
<span class="mv-range-label">时间范围</span>
<TimeRangePicker
:model-value="range"
:extra-presets="extraPresets"
@change="onRangeChange"
/>
</template>
</PageHeader>
<a-alert
v-if="overviewQuery.isError.value"
type="error"
show-icon
message="总览数据加载失败"
:description="overviewQuery.error.value?.message"
style="margin-bottom: 16px"
>
<template #action>
<a-button size="small" @click="() => overviewQuery.refetch()">重试</a-button>
</template>
</a-alert>
<a-skeleton
v-if="overviewQuery.isPending.value"
active
:paragraph="{ rows: 4 }"
style="margin-bottom: 24px"
/>
<a-empty
v-else-if="!overviewQuery.isError.value && keys.length === 0"
description="作者尚未为你分配 key"
style="margin-bottom: 24px"
/>
<template v-else-if="keys.length > 0">
<section class="mv-block">
<h2 class="mv-block__title">
用量摘要
<span class="mv-caption">{{ rangeCaption }}</span>
</h2>
<div class="mv-summary">
<div class="mv-summary__main">
<QuotaText
v-if="usage"
:quota="usage.quota"
:quota-usd="usage.quota_usd"
/>
<div class="mv-summary__sub">
调用 {{ formatInt(usage?.count ?? 0) }} 次 · 按当时换算率
<span v-if="usage && usage.count === 0" class="mv-note">· 该时段内无调用</span>
</div>
</div>
<SourceBar :rows="bySource" />
</div>
</section>
<section class="mv-block">
<h2 class="mv-block__title">我的 Key</h2>
<a-table
:columns="keyColumns"
:data-source="keys"
:row-key="(r: { key_id: number }) => r.key_id"
size="middle"
:pagination="false"
:scroll="{ x: 720 }"
:loading="overviewQuery.isFetching.value"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'data_cutoff'">
<span
v-if="record.log_synced_at == null"
class="mv-syncing"
>同步中…</span>
<span
v-else
:class="{ 'is-stall': isStalled(record.log_synced_at) }"
>
{{ isStalled(record.log_synced_at) ? "同步停滞 " : "同步 " }}{{ formatMinute(record.log_synced_at) }}
</span>
</template>
<template v-else-if="column.key === 'count'">
{{ formatInt(record.usage.count) }}
</template>
<template v-else-if="column.key === 'usage'">
<QuotaText :quota="record.usage.quota" :quota-usd="record.usage.quota_usd" />
</template>
<template v-else-if="column.key === 'actions'">
<a-button size="small" @click="() => goLogs(record.key_id)">查调用</a-button>
</template>
</template>
</a-table>
</section>
<section class="mv-block">
<h2 class="mv-block__title">支付参数</h2>
<div class="mv-pay-entry">
<span v-if="payments.length === 0" class="mv-note">暂无支付记录信息</span>
<span v-else class="mv-note">
该时段涉及 {{ payments.length }} 笔登记参数(订阅 / 充值)
</span>
<a @click="() => router.push('/member/payments')">查看完整参数</a>
</div>
</section>
</template>
</section>
</template>
<style scoped>
.mv-range-label {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.mv-block {
margin-bottom: 24px;
}
.mv-block__title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
line-height: 22px;
}
.mv-caption {
margin-left: 8px;
font-size: 12px;
font-weight: 400;
color: rgba(0, 0, 0, 0.45);
}
.mv-syncing {
color: rgba(0, 0, 0, 0.45);
}
.mv-summary {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.mv-summary__sub {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.mv-note {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.mv-pay-entry {
display: flex;
align-items: center;
gap: 16px;
}
.is-stall {
color: #faad14;
}
</style>
+223
View File
@@ -0,0 +1,223 @@
<script setup lang="ts">
/**
* 成员支付参数(交互设计-成员视图 §5):订阅 + 充值清单
*/
import { computed } from "vue";
import PageHeader from "@/components/common/page-header.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue";
import QuotaText from "@/components/common/quota-text.vue";
import { useMemberRange } from "@/composables/use-member-range.ts";
import { useMemberOverview } from "@/queries/member.ts";
import { formatDateTime } from "@/utils/time.ts";
import { formatPaidAmount } from "@/utils/format.ts";
const { range, onRangeChange } = useMemberRange();
const overviewQuery = useMemberOverview(() => range.value);
const subs = computed(() => overviewQuery.data.value?.subscriptions ?? []);
const tops = computed(() => overviewQuery.data.value?.topups ?? []);
const extraPresets = computed(() =>
subs.value
.map((p) => ({
label: `订阅 #${p.subscription_id} 期间`,
from: new Date((p.period_start ?? 0) * 1000).toISOString(),
to: new Date(
(p.period_end ?? Math.floor(Date.now() / 1000)) * 1000,
).toISOString(),
}))
.slice(0, 4),
);
function periodLines(p: {
period_start: number;
period_end: number | null;
}): { start: string; end: string } {
return {
start: formatDateTime(p.period_start),
end: p.period_end == null ? "进行中" : formatDateTime(p.period_end),
};
}
const subColumns = [
{
title: "订阅 ID",
dataIndex: "subscription_id",
key: "subscription_id",
width: 100,
},
{ title: "期间", key: "period", width: 200 },
{ title: "额度", key: "quota", width: 160 },
{ title: "付款金额 / 时间", key: "paid", width: 170 },
{ title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true },
];
const topColumns = [
{ title: "时间", key: "paid_at", width: 150 },
{
title: "充值金额",
dataIndex: "amount",
key: "amount",
width: 100,
},
{
title: "实付金额",
dataIndex: "money",
key: "money",
width: 100,
},
{ title: "交易号 / 渠道", key: "trade", width: 180 },
{ title: "派生额度", key: "quota", width: 150 },
{ title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true },
];
</script>
<template>
<section>
<PageHeader
title="支付参数"
subtitle="订阅与充值的登记参数 · 对成员完全公开"
>
<template #actions>
<span class="mv-range-label">时间范围</span>
<TimeRangePicker
:model-value="range"
:extra-presets="extraPresets"
@change="onRangeChange"
/>
</template>
</PageHeader>
<a-alert
v-if="overviewQuery.isError.value"
type="error"
show-icon
message="支付参数加载失败"
:description="overviewQuery.error.value?.message"
style="margin-bottom: 16px"
>
<template #action>
<a-button size="small" @click="() => overviewQuery.refetch()">重试</a-button>
</template>
</a-alert>
<a-skeleton
v-if="overviewQuery.isPending.value"
active
:paragraph="{ rows: 4 }"
/>
<a-empty
v-else-if="!overviewQuery.isError.value && subs.length === 0 && tops.length === 0"
description="暂无支付记录信息"
/>
<template v-else>
<h3 class="mv-sec">订阅</h3>
<a-empty v-if="subs.length === 0" description="暂无订阅登记" />
<a-table
v-else
:columns="subColumns"
:data-source="subs"
:row-key="(r: { id: number }) => r.id"
size="middle"
:pagination="false"
:scroll="{ x: 760 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'period'">
<div class="mv-period">
<span>{{ periodLines(record).start }}</span>
<span class="mv-period__end">~ {{ periodLines(record).end }}</span>
</div>
</template>
<template v-else-if="column.key === 'quota'">
<QuotaText :quota="record.quota_granted" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'paid'">
{{
record.paid_amount != null
? `${formatPaidAmount(record.paid_amount)} · ${formatDateTime(record.paid_at)}`
: "—"
}}
</template>
<template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }}
</template>
</template>
</a-table>
<h3 class="mv-sec">充值</h3>
<a-empty v-if="tops.length === 0" description="暂无充值登记" />
<a-table
v-else
:columns="topColumns"
:data-source="tops"
:row-key="(r: { id: number }) => r.id"
size="middle"
:pagination="false"
:scroll="{ x: 840 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'paid_at'">
{{ formatDateTime(record.paid_at) }}
</template>
<template v-else-if="column.key === 'amount'">
{{ formatPaidAmount(record.amount) }}
</template>
<template v-else-if="column.key === 'money'">
{{ formatPaidAmount(record.money) }}
</template>
<template v-else-if="column.key === 'trade'">
{{ record.trade_no || "—" }}
<span v-if="record.payment_method" class="mv-note">
· {{ record.payment_method }}
</span>
</template>
<template v-else-if="column.key === 'quota'">
<QuotaText :quota="record.quota_derived" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }}
</template>
</template>
</a-table>
<div class="mv-foot">
支付参数对成员完全公开;币种由成员与作者约定
</div>
</template>
</section>
</template>
<style scoped>
.mv-period {
display: flex;
flex-direction: column;
line-height: 20px;
font-variant-numeric: tabular-nums;
}
.mv-period__end {
color: rgba(0, 0, 0, 0.65);
}
.mv-range-label {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.mv-sec {
margin: 20px 0 12px;
font-size: 14px;
font-weight: 600;
}
.mv-note {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.mv-foot {
margin-top: 16px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
</style>
+1 -1
View File
@@ -57,7 +57,7 @@
## 4. 时间口径
- **响应时刻字段:Unix 秒整数**(UTC 时刻,如 `1780000000`)——无时区歧义;本地/北京时间由展示层转换
- **请求时间参数**(`from` / `to`、支付 `paid_at` / `period_*` 等):**ISO 8601 带时区偏移**(如 `2026-09-08T00:00:00.000+08:00`,便于人读;亦接受同绝对时刻的 `…Z`);范围 **左闭右开 `[from, to)`**
- **请求时间参数**(`from` / `to`、`paid_at` / `period_*` 等):**ISO 8601 带时区偏移**(如 `2026-09-08T00:00:00.000+08:00`,便于人读;亦接受同绝对时刻的 `…Z`);范围 **左闭右开 `[from, to)`**
- 存储层维持 timestamptz / 上游 Unix 秒;DTO 输出统一 `toUnixSec`
- 快捷项(最近 7 / 30 天、本订阅期间、自定义起止)是前端预置逻辑:前端算出 `from/to` 再请求,后端无快捷参数
+29 -19
View File
@@ -1,11 +1,11 @@
# API契约-成员视图
> 成员视图(令牌档)全部端点:令牌验证与页头、页面聚合、日志明细。《API契约-公共约定》(定稿)全部条款在此适用不再重述——前缀 `/api/member`、Bearer 鉴权、ISO 8601 UTC 左闭右开、`quota_usd` 后端换算、snake_case 同构;管理面草案 §10 的裁定 1(聚合仅 `type = 2`)与裁定 2(`token_name` 取当前名)在此沿用。成员世界**无**审计、无健康带、无改密(定位边界,《交互设计-成员视图》§3)。
> 成员视图(令牌档)全部端点:令牌验证与页头、总览/支付参数聚合、日志明细。《API契约-公共约定》(定稿)全部条款在此适用不再重述——前缀 `/api/member`、Bearer 鉴权、ISO 8601 UTC 左闭右开、`quota_usd` 后端换算、snake_case 同构;管理面草案 §10 的裁定 1(聚合仅 `type = 2`)与裁定 2(`token_name` 取当前名)在此沿用。成员世界**无**审计、无健康带、无改密(定位边界,《交互设计-成员视图》§2)。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-02) |
| 关联 | 《API契约-公共约定》(定稿——总纲)·《API契约-管理面》(草案——裁定沿用)·《交互设计-成员视图》·《交互设计-登录与会话》§4(登录壳)· `members` / `usage_logs` / `payments` 表字段设计(DTO 字段权威) |
| 状态 | 待审阅(2026-10-09 支付拆表——overview 分 `subscriptions`/`topups`) |
| 关联 | 《API契约-公共约定》(定稿——总纲)·《API契约-管理面》·《交互设计-成员视图》·《交互设计-登录与会话》§4(登录壳)· `members` / `usage_logs` / `subscriptions` / `topups` 表字段设计(DTO 字段权威) |
| 产出 | 端点清单(3 个)、请求 / 响应字段、裁定项(§5)、遗留项(§6) |
---
@@ -15,7 +15,7 @@
| # | 端点 | 方法 | 用途 |
|---|------|------|------|
| 1 | `/api/member/me` | GET | 令牌验证 + 页头(登录壳提交的落点) |
| 2 | `/api/member/overview` | GET | 单页聚合:总用量、按来源分解、我的 key、支付参数卡 |
| 2 | `/api/member/overview` | GET | 总览与支付参数聚合:总用量、按来源分解、我的 key、支付参数列表 |
| 3 | `/api/member/logs` | GET | 日志明细(名下 key,分页) |
## 2. 令牌验证与页头
@@ -27,7 +27,7 @@
- 登录壳提交 = 前端将令牌存 sessionStorage 后调本端点(`Authorization: Bearer`):验证通过(哈希命中 + `status = active`)→ 进入成员视图;`401` → 登录壳(前端固定文案「令牌无效或已停用」,不区分停用与无效——公共约定 §2)
- **不建会话**:令牌即长期凭证(`members` 表设计 3.2——无会话语义、无登出端点);旧令牌在作者重发后于下一请求自然 `401`
## 3. 单页聚合
## 3. 总览与支付参数聚合
### GET `/api/member/overview?from&to` → 200
@@ -45,24 +45,33 @@
{ "key_id": 5, "token_name": "小张-01", "log_synced_at": "2026-10-02T08:00:00Z",
"usage": { "count": 300, "quota": 1500000, "quota_usd": "3.00" } }
],
"payments": [
{ "id": 3, "kind": "subscription", "paid_at": "2026-09-01T00:00:00Z",
"paid_amount": 170, "remark": "九月拼车",
"period_start": "2026-09-01T00:00:00Z", "period_end": "2026-10-01T00:00:00Z",
"subscription_id": 3, "quota_granted": 12500000, "quota_usd": "25.00" }
"subscriptions": [
{ "id": 3, "subscription_id": 44, "period_start": "2026-09-01T00:00:00Z",
"period_end": "2026-10-01T00:00:00Z", "quota_granted": 12500000, "quota_usd": "25.00",
"paid_amount": 170, "paid_at": "2026-08-28T10:00:00Z", "remark": "九月拼车",
"created_at": "2026-09-02T12:00:00Z" }
],
"topups": [
{ "id": 1, "upstream_id": 17, "amount": 1, "money": 2.04,
"trade_no": "USR6…", "payment_method": "alipay", "payment_provider": "epay",
"status": "success", "paid_at": "…",
"quota_derived": 500000, "quota_usd": "1.00",
"remark": "", "created_at": "…" }
]
}
```
- `usage`:该时段总用量(聚合卡);`by_source`:订阅 / 充值分解(分段条);`keys`:我的 key 窄卡(`log_synced_at` 支撑停滞标注)
- `payments`:**支付参数卡 + 徽章展开行 + 「本订阅期间」快捷项的数据源**,判定规则见 §5 裁定 2;对成员**全字段可见**(含 `remark`——完全公开透明裁决);`payments` 全空 = 纯用量模式中性空态(前端事)
- 徽章展开行仅限「订阅 #N」徽章:「充值」不展开(余额池扣款挂不到具体某笔,展开无答案——§5 裁定 4)、「未关联」(日志缺 `subscription_id`)不可展开;降级:徽章 `subscription_id` 不在 `payments` 列表 → 「该订阅无登记信息」——空值场景各自归位(《交互设计-成员视图》§1.6)
- `usage`:该时段总用量(总览摘要条);`by_source`:订阅 / 充值分解(分段条);`keys`:我的 Key 表(`log_synced_at` 支撑停滞标注)
- `subscriptions` / `topups`:**支付参数页 + 订阅徽章弹窗 + 「本订阅期间」快捷项**的数据源,判定见 §5 裁定 2;对成员**全字段可见**(含 `remark`);两集皆空 = 纯用量模式中性空态
- 订阅徽章弹窗仅限「订阅 #N」(查 `subscriptions`):「充值」不弹窗(余额池无逐笔)、「未关联」不可点;降级「该订阅无登记信息」
## 4. 日志明细
### GET `/api/member/logs?from&to` → 200 `{items, total}`
分页(公共约定 §6);过滤 `from/to`(必填);范围恒为名下 key、恒 `type = 2`(§5 裁定 1——成员世界是「我的用量」视角,错误行排查属管理面)。
分页(公共约定 §6);过滤 `from/to`(必填);范围恒为名下 key;**默认全类型**(消费 + 错误,可 `type` 筛——2 / `error`)。
可选筛选(调用日志页,与管理面 `GET /api/admin/logs` 同名同义):`api_key_id` · `billing_source`(`subscription` / `wallet` / `none`)· `subscription_id` · `type`(2 / `error`)。
明细行 DTO 与管理面 `GET /api/admin/logs` **同构**(公共约定 §7——一份 DTO 类型两处复用):
@@ -73,18 +82,19 @@
"quota": 250000, "quota_usd": "0.50",
"prompt_tokens": 1200, "completion_tokens": 800,
"use_time": 3, "is_stream": true,
"billing_source": "subscription", "subscription_id": 3
"billing_source": "subscription", "subscription_id": 3,
"raw": { "id": 12345, "request_id": "…", "other": {}, "channel": 1 }
}
```
`token_name` 取当前名(管理面裁定 2 沿用)。
`token_name` 取当前名(管理面裁定 2 沿用)。`raw` 为**完整落库行**(上游字段原貌 + 系统字段,`created_at` 为 Unix 秒)——界面「原始数据」视图的数据源;管理面 `GET /api/admin/logs` 同构带出。
## 5. 本稿裁定项(请作者重点审)
1. **成员视图明细恒 `type = 2`**——对齐聚合口径;无 `type` 筛选参数(管理面明细的全类型排查能力不进成员世界);
2. **「该时段涉及」支付记录的判定**:订阅 = 该时段日志(`type = 2`)出现过的 `subscription_id` 对应记录 **∪ 进行中的订阅**(`period_end` 为空或未来——支撑「本订阅期间」快捷项与「该订阅无登记信息」降级);充值 = `paid_at ∈ [from, to)`(充值消耗无逐笔关联可查,按支付时间锚定);
1. **成员视图明细默认全类型**(消费 + 错误)——对齐「事实可查」;聚合仍仅 `type = 2`(用量口径不变);`type` 可筛 2 / `error`;
2. **「该时段涉及」登记的判定**:订阅 = 该时段日志(`type = 2`)出现过的 `subscription_id` 对应 `subscriptions` 行 **∪ 进行中的订阅**(`period_end` 为空或未来——支撑「本订阅期间」快捷项);充值 = `paid_at ∈ [from, to)`(入账事件按付费时刻锚定);补录审计看各表 `created_at`(不进本判定);
3. **明细行 DTO 与管理面同构**:同一 `LogItem` 类型,成员端经归属与 `type` 恒定过滤;
4. **「充值」徽章不展开**——余额池扣款与具体充值记录无逐笔关联,展开回答不了「这笔花在哪」(《交互设计-成员视图》§1.6 未定义处的落定:徽章展开行仅限订阅徽章,「充值」为纯标识)。
4. **「充值」徽章不弹窗**——余额池扣款与具体充值记录无逐笔关联,弹窗回答不了「这笔花在哪」(《交互设计-成员视图》§4.2:订阅徽章才开支付参数弹窗,「充值」为纯标识;行展开只放模型调用详情)。
## 6. 遗留项
+59 -23
View File
@@ -1,11 +1,11 @@
# API契约-管理面
> 管理面(作者,会话档)全部端点:引导与会话、引擎状态、全员总览、key 管理、日志明细(下钻)、支付记录、成员管理、审计日志。《API契约-公共约定》(定稿)全部条款在此适用不再重述——前缀 `/api/admin`、会话 cookie 鉴权、错误信封、ISO 8601 UTC 左闭右开、`quota_usd` 后端换算、snake_case 同构。
> 管理面(作者,会话档)全部端点:引导与会话、引擎状态、全员总览、key 管理、日志明细(下钻)、订阅权益 / 充值入账、成员管理、审计日志。《API契约-公共约定》(定稿)全部条款在此适用不再重述——前缀 `/api/admin`、会话 cookie 鉴权、错误信封、ISO 8601 UTC 左闭右开、`quota_usd` 后端换算、snake_case 同构。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-02) |
| 关联 | 《API契约-公共约定》(定稿——总纲)· 八篇交互设计(定稿)· `api_keys` / `usage_logs` / `payments` / `qpu_history` / `members` 表字段设计(DTO 字段权威)· `需求清单.md` §9(访问主体模型) |
| 状态 | 待审阅(2026-10-09 支付拆表修订——§7) |
| 关联 | 《API契约-公共约定》(定稿——总纲)· 八篇交互设计 · `api_keys` / `usage_logs` / `subscriptions` / `topups` / `qpu_history` / `members` 表字段设计(DTO 字段权威)· `需求清单.md` §9(访问主体模型) |
| 产出 | 端点清单、请求 / 响应字段、错误码、本稿裁定项(§10)与遗留项(§11) |
---
@@ -23,13 +23,15 @@
| 7 | `/api/admin/keys` | GET / POST | key 清单 / 登记 |
| 8 | `/api/admin/keys/:id` | PATCH | 启停 / 归属变更 |
| 9 | `/api/admin/logs` | GET | 日志明细(下钻,分页) |
| 10 | `/api/admin/payments` | GET / POST | 支付记录清单 / 登记 |
| 11 | `/api/admin/payments/:id` | PUT / DELETE | 编辑 / 删除 |
| 12 | `/api/admin/qpu/latest` | GET | 当前换算率(表单美元双输入用) |
| 13 | `/api/admin/members` | GET / POST | 成员清单 / 开户(令牌明文仅一次) |
| 14 | `/api/admin/members/:id` | PATCH | 停用 / 启用 |
| 15 | `/api/admin/members/:id/token` | POST | 令牌重发(明文仅一次) |
| 16 | `/api/admin/audit-logs` | GET | 审计日志(分页) |
| 10 | `/api/admin/subscriptions` | GET / POST | 订阅权益清单 / 登记 |
| 11 | `/api/admin/subscriptions/:id` | PUT / DELETE | 编辑 / 删除 |
| 12 | `/api/admin/topups` | GET / POST | 充值入账清单 / 登记 |
| 13 | `/api/admin/topups/:id` | PUT / DELETE | 编辑 / 删除 |
| 14 | `/api/admin/qpu/latest` | GET | 当前换算率(表单美元双输入用) |
| 15 | `/api/admin/members` | GET / POST | 成员清单 / 开户(令牌明文仅一次) |
| 16 | `/api/admin/members/:id` | PATCH | 停用 / 启用 |
| 17 | `/api/admin/members/:id/token` | POST | 令牌重发(明文仅一次) |
| 18 | `/api/admin/audit-logs` | GET | 审计日志(分页) |
## 2. 引导与会话
@@ -168,28 +170,62 @@
> **界面落点**:《交互设计-调用日志》——管理面独立查询页;总览/Key/成员筛选深链 + 行内回跳。
## 7. 支付记录
## 7. 订阅权益与充值入账
### GET `/api/admin/payments` → 200 `[payment]`
> 原 `payments` 单表已拆(`payments建模评估.md` 方案 B):**订阅 = 权益**(期间 + 额度预算 + 可选付款事实),**充值 = 入账事件**。UI 菜单仍叫「支付记录」,API 按资源分组。
全量直返(年十位数,不分页),时间倒序(后端排序)。
### GET `/api/admin/subscriptions` → 200 `[subscription]`
全量直返(年个位数,不分页),`period_start` 倒序。
```json
{
"id": 3, "kind": "subscription", "paid_at": "2026-09-01T00:00:00Z",
"paid_amount": 170, "remark": "九月拼车",
"id": 3, "subscription_id": 44,
"period_start": "2026-09-01T00:00:00Z", "period_end": "2026-10-01T00:00:00Z",
"subscription_id": 3, "quota_granted": 12500000, "quota_usd": "25.00"
"quota_granted": 12500000, "quota_usd": "25.00",
"paid_amount": 170, "paid_at": "2026-08-28T10:00:00Z",
"remark": "九月拼车", "created_at": "2026-09-02T12:00:00Z"
}
```
`quota_usd` 按支付时刻(`paid_at`)QPU 换算(§10 裁定 4);`kind = topup` 行 `period_*` / `subscription_id` 为 `null`。
- `quota_usd` 换算锚点优先级:`paid_at` → 否则 `created_at`(§10 裁定 4 修订)
- `paid_amount` / `paid_at` 可空(付款事实未知)
### POST `/api/admin/payments` → 201 `payment`
### POST `/api/admin/subscriptions` → 201 `subscription`
`kind` 分形态必填校验(`422`):公共 `{kind, paid_at, paid_amount, remark?}`;`subscription` 追加必填 `period_start` / `subscription_id` / `quota_granted`(`period_end` 可空 = 进行中);`topup` 追加必填 `quota_granted`。表单美元双输入为前端换算(按 `qpu/latest` 预览),落库恒原始 quota(表设计 3.6)。
必填:`subscription_id` · `period_start` · `quota_granted`;可空:`period_end`(=进行中)· `paid_amount` · `paid_at` · `remark`。美元双输入为前端换算,落库恒原始 quota。
### PUT `/api/admin/payments/:id` → 200 `payment` / DELETE → 204
### PUT `/api/admin/subscriptions/:id` / DELETE → 204
### GET `/api/admin/topups` → 200 `[topup]`
全量直返,`paid_at` 倒序。对齐上游充值单;**事件时刻单列**(上游 `complete_time`),**无 `quota_granted` 入库列**:
```json
{
"id": 1, "upstream_id": 17,
"amount": 1, "money": 2.04,
"trade_no": "USR6NOLqTPGa1791534153",
"payment_method": "alipay", "payment_provider": "epay",
"status": "success",
"paid_at": "2026-10-09T12:22:56Z",
"quota_derived": 500000, "quota_usd": "1.00",
"remark": "", "created_at": "2026-10-09T13:00:00Z"
}
```
`quota_derived` / `quota_usd` 为**派生**(`amount × qpu`,锚点 `paid_at`),不入库。
### POST `/api/admin/topups` → 201 `topup`
必填:`paid_at` · `amount` · `money`;可空:`upstream_id` · `trade_no` · `payment_method` · `payment_provider` · `status` · `remark`。
### PUT `/api/admin/topups/:id` / DELETE → 204
### 平台辅助(登录代拉,密码不入库)
已有:`POST /api/admin/platform/login` · `POST /api/admin/platform/subscriptions`。
**新增**:`POST /api/admin/platform/topups` body `{ access_token, page, page_size }` → `{ items, total, page, page_size }`——透传 `/api/user/topup/self` **单页**;前端传统分页、可翻至尽头、无时间参数、无页数硬上限(见交互篇 §4.1)。不做后台定时同步。
编辑全字段、删除有确认(前端);改错即修,均落审计。
@@ -248,7 +284,7 @@
| 域 | action 枚举 |
|----|------------|
| key | `key.register` / `key.enable` / `key.disable` / `key.reassign`(归属变更) |
| payment | `payment.create` / `payment.update` / `payment.delete` |
| payment | `subscription.create` / `subscription.update` / `subscription.delete` / `topup.create` / `topup.update` / `topup.delete`(UI 菜单「支付记录」) |
| member | `member.create` / `member.enable` / `member.disable` / `member.token_issue`(开户自动签发)/ `member.token_reissue` |
| account | `account.password_change` / `account.init`(系统,operator null) |
@@ -258,8 +294,8 @@
1. **用量聚合口径仅 `type = 2`(消费行)**——usage_logs §5 待定「type 过滤策略」落定:聚合与统计只计消费行(错误行入聚合即失真);明细查询默认全类型、可按 `type` 筛(错误排查入口);
2. **日志明细 `token_name` 取当前名**(联查 `api_keys`;快照名存库不出 API)——表设计 2.3 待定「展示口径」落定;
3. **有限基数清单不分页**:`keys` / `members` / `payments` 直返全量数组;公共约定 §6 的「列表类分页」指无界列表(`logs` / `audit-logs`);
4. **支付记录 `quota_usd` 按支付时刻(`paid_at`)QPU 换算**——对齐「按日志时刻取值」的历史正确性原则;
3. **有限基数清单不分页**:`keys` / `members` / `subscriptions` / `topups` 直返全量数组;公共约定 §6 的「列表类分页」指无界列表(`logs` / `audit-logs`);
4. **`quota_usd` 换算锚点**:充值恒 `paid_at`(= 上游 `complete_time`);订阅优先 `paid_at`、否则 `created_at`——对齐「按日志时刻取值」的历史正确性原则;
5. **公共约定随本稿与审阅增补四处**:状态表加 `429`(登录节流)、错误信封允许端点附机器可读附注字段(`409 key_already_registered` 附 `key_id`)、`/api` 命名空间专属接口(页面路由永不进入)、端点面向交互设计不与数据表对齐。
## 11. 遗留项(归后续立稿或作者裁决)
+1 -1
View File
@@ -97,7 +97,7 @@
### 3.7 表名:`api_keys`
- 理由:与页面 `/manage/api-keys` 同词对齐;`sk-` 只是 key 的实际格式前缀(不属表名职责),`api_keys` 为领域通用词,与其余各表(`usage_logs` / `payments` / `qpu_history`)风格一致
- 理由:与页面 `/manage/api-keys` 同词对齐;`sk-` 只是 key 的实际格式前缀(不属表名职责),`api_keys` 为领域通用词,与其余各表(`usage_logs` / `subscriptions` / `topups` / `qpu_history`)风格一致
### 3.8 归属列 `member_id`:外键在本表,且可空
+141
View File
@@ -0,0 +1,141 @@
# payments 建模评估:充值与订阅要不要拆表
> 对「一张 `payments` 同构装下订阅 + 充值」的全面复盘。触发点:`paid_at` 语义在订阅行上更像 `created_at`,按付费时间筛会在**后补录**时丢行。**结论:方案 B 拆表已采纳**(2026-10-09)。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-09,作者采纳方案 B) |
| 关联 | `subscriptions表字段设计.md` · `topups表字段设计.md`(替代原 `payments` 单表)· `NewAPI-接口范围.md` §2.⑨ §4.3 · `需求清单.md` R7/R8/R9 · `usage_logs表字段设计.md` 3.6 |
| 依据 | 上游订阅实体字段(钱包页 / `GET /api/subscription/self`)· 日志 `billing_source` / `subscription_id` · 现行登记与筛选交互 |
---
## 1. 领域里其实有两种东西
NewAPI 侧(接口参考 §4.3、日志 `other`):
| 上游概念 | 本质 | 时间轴 | 与消耗的关系 |
|----------|------|--------|--------------|
| **订阅**(`all_subscriptions[].id`) | **权益包**:`start_time`/`end_time` 覆盖期 + `amount_total`/`amount_used` 额度预算 + `created_at` | **期间** | 日志 `billing_source=subscription` + `subscription_id` 直连 |
| **充值**(`LogTypeTopup=1`) | **入账事件**:钱 → 钱包额度;账号级、无 token_id | **事件时刻** | 日志 `billing_source=wallet`,**无**逐笔 `subscription_id`(余额池) |
注意:上游**没有**「订阅付款单」——订阅的 `created_at` 是权益创建时刻,不是「作者何时把钱付给组织者」。后者只存在于社交层,正是 `payments` 要手工抄录的「支付事实」。
因此登记行混合了两类事实:
1. **权益事实**(订阅:编号、期间、总额度)——来自钱包页,客观
2. **金钱事实**(谁付了多少)——来自支付凭证/转账,社交层,**可与权益不同日**(预付、分期、事后补账)
## 2. 现行单表哪里拧着
`payments` 九列里约一半是「订阅专用可空」:
| 列 | 订阅 | 充值 | 问题 |
|----|------|------|------|
| `kind` | `subscription` | `topup` | 判别式列(合法但胖) |
| `paid_at` | 混用:真实付款日 **或** 登记日 | 真实充值日 | **语义分叉**(本次痛点) |
| `paid_amount` | 套餐价(可能与期间不同日) | 充值额 | 尚可 |
| `period_start/end` | 必填 | NULL | 可空列 |
| `subscription_id` | 必填 | NULL | 可空列 |
| `quota_granted` | 套餐总额度 | 充值换得额度 | 语义都是「换得的量」,可共用 |
| `remark` | ✓ | ✓ | |
| (无) | **登记时刻** | **登记时刻** | 补录/审计缺失 |
连带后果:
- **筛选**:按 `paid_at` 筛「这段时间的账」→ 后补录的订阅落在补录日,对不上用量窗口;按期间筛又对不上充值(无期间)。
- **QPU 换算锚点**:契约写「按 `paid_at` 换算 `quota_usd`」——若 `paid_at` 在订阅行上是登记日,换算率锚点也跟着漂。
- **校验**:kind 分支必填(契约已做),但类型系统层仍是「全可空 + 运行时 if」。
## 3. 三个方案
### 方案 A:单表保留,时间语义拆开(小改)
```
payments
kind, paid_amount, remark, quota_granted ← 共用
created_at timestamptz NOT NULL ← 新增:登记入库时刻
paid_at timestamptz? NULLABLE ← 仅「钱出手」时刻;订阅可空
period_start/end, subscription_id ← 仅订阅
```
| 筛选口径 | 规则 |
|----------|------|
| 登记时间 | `created_at`(后补录可查) |
| 充值 | `paid_at ∈ [from,to)` |
| 订阅 | 期间重叠:`period_start < to AND (period_end IS NULL OR period_end > from)` |
- **利**:迁移小(加一列 + 语义清洗存量 `paid_at`);API/DTO 形状基本不动。
- **弊**:仍是判别式胖行;「订阅的付款日」与「权益期间」继续挤在同一行心智里。
### 方案 B:拆两张表(对齐上游)
```
subscriptions topups
id PK id PK
subscription_id UNIQUE upstream_id? ← 上游充值单 id
period_start NOT NULL amount NOT NULL ← 平台计费金额(充值额)
period_end NULL money NOT NULL ← 实付
quota_granted NOT NULL trade_no / payment_* ← 渠道事实
paid_amount NULL paid_at NOT NULL ← = 上游 complete_time(唯一事件时刻)
paid_at NULL status
remark remark
created_at NOT NULL created_at NOT NULL
```
(2026-10-09 实测修正:上游充值单**无 `quota_granted`**——`amount × qpu` 派生;事件时刻**只留一条** `paid_at`=`complete_time`,`create_time` 不入库。)
- **利**:字段全非空(或意义明确);时间轴各归各;与 NewAPI「权益 vs 入账」同构;筛选规则按表定义,无 if-else。
- **弊**:登记 UI 两种表单(可仍一页两 Tab);「统一支付流水」要 UNION 视图;迁移把现表按 `kind` 拆走。
### 方案 C:三张表(金钱流水与权益彻底解耦)——**不建议**
`money_events` + `subscriptions` + 关联。年登记量个位数~十位数,过重。
## 4. 建议
**采用方案 B(拆表),展示层合并流水。**
理由摘要:
1. **领域本来就是两种东西**——上游也是。单表同构是登记方便,不是概念等价。
2. **时间轴不同**是刚性的:权益用期间、充值用事件、补录用登记时刻;塞进一列必然语义污染。
3. 规模极小(设计稿:年十行级)——拆表成本几乎只有代码路径,没有分片/联查负担。
4. `paid_at` 问题在 B 里消失:`topups.paid_at` 恒为事件;`subscriptions.paid_at` 明确为「套餐付款日,可空」;`created_at` 两表都有。
**统一入口**(交互不变感):
- 管理面「支付记录」页 = 两 Tab(订阅 / 充值)或一张 UNION 列表(按时间轴分别标注「期间 / 付费 / 登记」)。
- 成员「支付参数」页同源两集,R12 弹窗仍只挂订阅。
- 「该时段涉及」:订阅看期间重叠 ∪ 进行中;充值看 `paid_at`;补录审计看 `created_at`。
**字段语义钉死(两表通用原则)**:
| 时刻 | 含义 | 谁写 |
|------|------|------|
| `created_at` | 作者按下「登记」的时刻 | 系统 |
| `paid_at` | 钱真实出手时刻(凭证上的时间) | 人工;不知则空 |
| `period_*` | 用量覆盖期 `[start, end)` | 人工(抄钱包页) |
**迁移要点**(若采纳):
1. 存量 `kind=subscription` 行 → `subscriptions`;`kind=topup` → `topups`。
2. 存量订阅行的 `paid_at`:若当时填的是登记日 → 迁到 `created_at`,`paid_at` 置空(或作者按凭证补真付款日)。
3. `quota_usd` 换算锚点:优先 `paid_at`(有则),否则 `created_at` 或 `period_start`——契约里写死优先级。
4. 日志 `subscription_id` 直连改为指向 `subscriptions.subscription_id`(语义不变)。
5. 纯用量模式(零登记)不受影响。
**方案 A 的适用情形**:只想先止血筛选/补录、暂不动 CRUD 与 API 形状——则 A 是可接受的过渡;终态仍建议 B。
## 5. 与既有裁决的关系
| 裁决 | 拆表后 |
|------|--------|
| R7 充值与订阅同构(登记形态) | **展示/哲学同构**(只存事实、无分摊)不变;**物理模型分表** |
| R8 `subscription_id` 直连 | 不变,指向订阅表 |
| R9 币种无关、只存事实 | 不变 |
| 「payments」表名 | 订阅表可名 `subscriptions`;充值表 `topups`;若保留对外「支付记录」只作 UI 名 |
---
**已采纳方案 B(2026-10-09)**:表设计见 `subscriptions表字段设计.md` / `topups表字段设计.md`;契约与交互随拆表修订。
-98
View File
@@ -1,98 +0,0 @@
# payments 表字段设计
> 数据模型第三张表:支付记录——每笔支付事件(订阅购买 / 充值)一行。平台只存**事实**(支付金额、换得额度、来源参数),「支付金额与额度之间的比率」是查询时的派生视图,不存储、不做任何分摊计算。本文记录字段清单、语义与决策理由。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | `docs/design/usage_logs表字段设计.md` 3.6(`subscription_id` 直连与本表)· `docs/design/qpu_history表字段设计.md`(quota → 额度美元的换算时间线)· `docs/references/NewAPI-接口范围.md` §2.⑨(`other.subscription_*` 字段依据) |
| 范围 | 列结构 + 语义 + 写入时机;方言映射归数据访问层设计 |
| 字段数 | 9 = 1 主键 + 8 人工录入(支付 4 · 期间 2 · 订阅 2) |
---
## 1. 表定位
- **一行 = 一笔支付事件**(订阅购买 / 充值)——同一模型兼容两种形态(`kind` 列区分)
- **本表是「只有作者知道」的信息的唯一落点**:支付金额、额度、订阅 ID——上游没有免 auth 接口可查,全靠作者手工抄录
- **币种无关**:`paid_amount` 是支付金额数值,**系统不预设币种**——币种是使用者之间的约定;「金额 ↔ 额度」的比率同样是派生视图(查询时相除即得)
- **不做分摊计算**:平台不把支付金额按用量比例分摊到日志或成员——分账是社交层问题;本表与日志的交汇只发生在**事实关联**(`subscription_id` 直连)与**参数展示**(订阅信息对使用者完全公开透明)
- 数据规模:年个位数到十位数行——无需索引设计
## 2. 字段清单
### 2.1 主键与支付字段(5 个)
| 列 | 类型 | 值来源 | 说明 |
|----|------|--------|------|
| `id` | bigserial PK | 自增 | |
| `kind` | text | 人工选择 | `subscription`(订阅)/ `topup`(充值) |
| `paid_at` | timestamptz | 人工填写 | 支付时间 |
| `paid_amount` | numeric | **人工必填** | **支付金额**(币种无关——数值本身,币种由使用者约定) |
| `remark` | text | 人工填写 | 备注 |
### 2.2 期间与订阅字段(4 个)
| 列 | 类型 | 值来源 | 说明 |
|----|------|--------|------|
| `period_start` | timestamptz? | **人工录入**(钱包页面) | 仅订阅:该笔覆盖的用量期间起点;左闭 |
| `period_end` | timestamptz? | **人工录入**(钱包页面) | 期间终点;左开;未结束可空 |
| `subscription_id` | bigint? | **人工录入**(钱包页面「订阅 #N」) | 上游订阅实例 ID;与日志 `subscription_id` 直连(事实关联,不推算) |
| `quota_granted` | bigint? | **人工录入**(钱包页面「总额度」/ 充值记录) | 本笔换得的额度——**单位钉死为原始 quota 整数**(如 $25 额度 = 12,500,000;见 3.6) |
## 3. 决策记录(含理由)
### 3.1 登记 = 手工抄录(来源只有两处)
支付记录全部由作者手工录入,来源仅两处:**支付凭证**(`kind` / `paid_at` / `paid_amount`)与**钱包页面**(订阅 ID / 期间 / 总额度)。订阅关键信息只有作者可见(没有免 auth 接口可查)——系统不做回填与推断,日志观测仅用于核对提示(见 §4);必填 / 可空的校验细节归技术稿。
### 3.2 只存事实,不存派生数
- `paid_amount` 与 `quota_granted` 是不可替代的事实;「金额 ↔ 额度」的比率是派生数——查询时相除即得,事后核对永远可用;只存比率会丢失核对依据
- **日志同样不加任何金额 / 比率列**(`usage_logs` 零分摊字段):把支付金额按用量分摊到日志行的任何计算都不做——分摊口径是社交层问题,账本上只有客观事实
- 关联维度(`billing_source` / `subscription_id`)已在日志侧提为独立列(见 `usage_logs表字段设计.md` 3.6),统计按事实关联
### 3.3 为什么额度也手工录入(不采用日志感知)
日志逐条自带 `subscription_total`(实测),但回填它需要「订阅 ID → 该 ID 日志 total」的关联逻辑;钱包页面已直接给出总额度——手工抄录一步到位。**系统对支付记录零回填、零推断**,观测仅用于核对(见 §4)。
### 3.4 币种无关(不预设人民币)
`paid_amount` 是纯数值。作者记账用什么货币是他的事,展示层的货币符号是界面约定——数据层与币种彻底解耦,更换支付渠道 / 币种时零 schema 变化。
### 3.5 命名与类型
- 表名 `payments`(支付记录):订阅与充值同构,`purchases`(购买)只覆盖前者,`payments` 是两者的上位词
- 金额列 `numeric`(金额不用浮点);时间列统一 `timestamptz`
- 期间左闭右开(`[period_start, period_end)`)——与全项目期间惯例一致
- 订阅实例 ID 与日志同名 `subscription_id`——同一体系、同一含义(直连即用此列)
### 3.6 `quota_granted` 的单位:原始 quota,不是美元
- **存真值,不存换算快照**:上游订阅套餐的额度本体是 quota 整数,「$25」只是界面显示形态(quota ÷ `quota_per_unit`,随 `quota_display_type` 而变)——本列存本体
- **域一致性**:日志的 `quota` 同为原始整数——`SUM(usage_logs.quota)` 与 `quota_granted` 同域直接可比,零换算;若存美元则每次比较都要过 ÷QPU,而 QPU 是时间线变量(`qpu_history`),凭空引入取值时刻问题
- **类型精确**:quota 恒为整数;美元额度可能有小数,`bigint` 装不下
- **录入体验归交互层**:钱包页面显示美元形态——管理面表单支持输入美元、按当时 QPU 换算落库(或展示两态),换算逻辑不进数据模型
- 美元形态随时可派生:`quota_granted ÷ QPU`(展示层)
- **与 qpu_history 无概念重复**:quota 是**量**,QPU 是**换算率**——记录量不等于记录单位定义(记「¥170」不涵盖「1 美元 = 6.8 人民币」)。真值域(quota)不携带任何换算,换算率只在一处(`qpu_history` 时间线),美元永远现算现用;反过来若直接存美元,每个值都是某次换算的快照、各自隐含一个换算时刻——那才是真正的冗余,且上游改 QPU 后与日志统计失去同域可比性
## 4. 写入时机与维护
| 时机 | 动作 |
|------|------|
| 人工登记(管理通道) | INSERT:录入支付信息(时间 / 金额)与订阅信息(ID / 期间 / 总额度,抄自钱包页面)→ 确认 |
| 系统观测(仅核对) | 日志观测到的订阅 ID / `subscription_total` 仅作核对提示(如「观测到 #N 未对应任何支付记录」——即「未关联」分组的健康信号),不参与回填 |
| 编辑 / 删除 | 数据为人工登记,改错即修 |
## 5. 展示层边界(界面设计的输入)
- **订阅与支付参数对使用者完全公开透明**(访问主体模型裁决):期间、额度、支付金额、订阅 ID——成员视图与管理面同源可见;不存在「管理面独占的金额信息」
- 「按订阅维度统计」(某笔订阅的消耗占比等)数据天然支持——按 `subscription_id` 直连聚合即可
- 成员视角的日志行携带所属订阅/充值信息的具体形态,交互设计期定(见 `docs/design/需求清单.md` R12)
## 6. 待定事项(关联后续环节)
| 事项 | 归属 |
|------|------|
| 核对提示(观测到的订阅 ID ↔ 已录入记录比对)的交互 | 交互设计(待立) |
| 充值(`topup`)的 `quota_granted` 抄录来源(充值记录页)核实 | 实现期真实数据核查 |
@@ -0,0 +1,64 @@
# subscriptions 表字段设计
> 数据模型:**订阅权益登记**——一行 = 一笔上游订阅实例(覆盖期 + 额度预算 + 可选的套餐付款事实)。与 `topups`(充值入账事件)分表,对齐 NewAPI「权益 vs 入账」两分(见 `payments建模评估.md`,方案 B 已采纳)。平台只存**事实**,不做分摊计算。
| 项目 | 内容 |
|------|------|
| 状态 | 待审阅(2026-10-09 拆出自原 `payments` 单表) |
| 关联 | `topups表字段设计.md`(充值事件)· `payments建模评估.md`(拆表裁决)· `usage_logs表字段设计.md` 3.6(`subscription_id` 直连)· `qpu_history表字段设计.md` · `docs/references/NewAPI-接口范围.md` §4.3 |
| 范围 | 列结构 + 语义 + 写入时机;方言映射归数据访问层 |
| 字段数 | 9 = 1 主键 + 1 上游编号 + 2 期间 + 1 额度 + 2 付款(可空) + 1 备注 + 1 登记时刻 |
---
## 1. 表定位
- **一行 = 一笔上游订阅实例**(权益),不是「一张付款单」
- 权益事实(`subscription_id` / 期间 / 总额度)抄自钱包页(`GET /api/subscription/self`);金钱事实(付了多少、何时付)来自支付凭证,**可空**——分期、赠送、未掌握凭证时允许不填
- `subscription_id` 与日志 `other.subscription_id` **同体系直连**(事实关联,不推算)
- 数据规模:年个位数行——无需独立索引设计(`subscription_id` UNIQUE 足够)
## 2. 字段清单
| 列 | 类型 | 值来源 | 说明 |
|----|------|--------|------|
| `id` | bigserial PK | 自增 | 本地主键 |
| `subscription_id` | bigint UNIQUE | 人工录入(钱包页「订阅 #N」) | 上游订阅实例 ID;日志直连键 |
| `period_start` | timestamptz | 人工录入 | 用量覆盖期起点;左闭 |
| `period_end` | timestamptz? | 人工录入 | 期间终点;左开;未结束可空 = 进行中 |
| `quota_granted` | bigint | 人工录入(钱包页 `amount_total`) | 套餐总额度——**原始 quota 整数**(与日志 `quota` 同域;充值侧无此列,额度由 `amount × qpu` 派生) |
| `paid_amount` | numeric? | 人工录入(支付凭证) | 该套餐实付金额;币种无关纯数值;未知可空 |
| `paid_at` | timestamptz? | 人工录入(支付凭证) | **钱出手时刻**(非登记时刻);未知可空 |
| `remark` | text | 人工填写 | 备注 |
| `created_at` | timestamptz | 系统 | **登记入库时刻**(补录审计) |
## 3. 时刻语义(三时刻钉死)
| 时刻 | 含义 | 谁写 |
|------|------|------|
| `created_at` | 作者按下「登记」 | 系统 |
| `paid_at` | 钱真实出手(凭证时间) | 人工;不知则空 |
| `period_start` / `period_end` | 用量覆盖期 | 人工(抄钱包页) |
筛选约定:**用量窗口看期间重叠**;**补录审计看 `created_at`**;**付款账看 `paid_at`(有则)**。
## 4. 决策摘要
- **不与充值同表**:时间轴不同(期间 vs 事件);见 `payments建模评估.md` 方案 B
- **只存事实、不存比率**:`paid_amount` 与 `quota_granted` 相除得比率;不落分摊
- **额度存原始 quota**(原 payments 3.6):与日志同域可比;美元展示现算
- **`paid_amount` / `paid_at` 可空**:社交层付款信息允许缺失;不阻塞权益登记
- **多订阅期间可重叠**(R8):不设互斥约束
## 5. 写入与维护
| 时机 | 动作 |
|------|------|
| 人工登记 | INSERT(管理通道);来源:钱包页 JSON 预填 + **辅助登录选用**(按 `subscription_id` 去重,已登记灰显)+ 支付凭证 |
| 编辑 / 删除 | 改错即修(人工数据) |
| 日志观测 | 仅核对提示(观测到 #N 无登记),不回填 |
## 6. 对外边界
- 参数对成员**完全公开透明**(含 `remark` / 金额)——访问主体模型裁决
- 无「应付」、无分摊字段
+77
View File
@@ -0,0 +1,77 @@
# topups 表字段设计
> 数据模型:**充值入账事件**——一行 = 一次「钱 → 账号额度」的入账。与 `subscriptions`(订阅权益)分表(见 `payments建模评估.md`,方案 B)。字段对齐上游实测 `GET /api/user/topup/self`(2026-10-09 样本);**无 `quota_granted` 列**(派生),**事件时刻只存一条**(上游 `complete_time`,即平台列表展示的时间)。
| 项目 | 内容 |
|------|------|
| 状态 | 待审阅(2026-10-09 拆表 + 充值接口实测;事件时刻单列) |
| 关联 | `subscriptions表字段设计.md` · `payments建模评估.md` · `NewAPI-接口范围.md` §4.5 · `qpu_history表字段设计.md` |
| 范围 | 列结构 + 语义 + 写入时机 |
| 字段数 | 10 = 1 主键 + 8 上游/业务事实 + 1 登记时刻 |
---
## 1. 表定位
- **一行 = 一次充值入账**(事件);时间主轴 = **`paid_at`**(上游 `complete_time`,平台展示时刻)
- 与消耗的关系:钱包/账号余额池,**无法**逐笔对应模型日志(`billing_source=wallet` 无 `subscription_id` 是正常态)
- 不存 `quota_granted` 真值列(上游没有;见 §3)
## 2. 字段清单
| 列 | 类型 | 上游字段 | 说明 |
|----|------|----------|------|
| `id` | bigserial PK | — | 本地主键 |
| `upstream_id` | bigint? | `id` | 上游充值单 ID(对账;手工补录可空) |
| `amount` | numeric | `amount` | **平台计费金额**(充值记账额,样本 `1`)——与实付 `money` 之间由**管理员约定汇率**换算;**不标币种/单位** |
| `money` | numeric | `money` | **实付金额**(样本 `2.04`);同样不预设币种 |
| `trade_no` | text? | `trade_no` | 交易号 |
| `payment_method` | text? | `payment_method` | 如 `alipay` |
| `payment_provider` | text? | `payment_provider` | 如 `epay` |
| `status` | text? | `status` | 如 `success`;失败单一般不入账本 |
| `paid_at` | timestamptz | **`complete_time`** | **入账时刻(唯一事件时间)**——平台列表展示的就是它;上游 `create_time` 不入库 |
| `remark` | text | — | 人工备注 |
| `created_at` | timestamptz | — | 本系统登记时刻(补录审计) |
> 样本(2026-10-09):`amount: 1` · `money: 2.04` · `trade_no: "USR6NOLqTPGa1791534153"` · `alipay`/`epay` · `create_time`/`complete_time` Unix 秒 · `status: "success"`。展示与筛选一律用 `complete_time` 落入的 `paid_at`。
## 3. 「换得额度」是派生
上游无 `quota_granted`:派生 `amount × quota_per_unit`(默认 QPU 500000)——`amount` 是平台计费侧充值额,与 quota 同域量纲。
- **只存事实**:`amount`(平台计费)与 `money`(实付)两真值;**汇率不入库**(管理员社交约定,查询/展示可现算 `money/amount`)
- 不在库中固化派生额度
## 4. 时刻语义
| 时刻 | 含义 |
|------|------|
| `paid_at` | 入账(= 上游 `complete_time`)——筛选、排序、QPU 锚点 |
| `created_at` | 登记进本表(补录审计) |
## 5. 写入与维护
| 时机 | 动作 |
|------|------|
| **辅助登录选用**(与订阅选择器同模式) | 控制台登录(密码不落库)→ 分页拉 `/api/user/topup/self` → 选用预填表单 → 确认入库 |
| 纯手工登记 | 抄充值页 / 账单;或粘贴 JSON 预填 |
| 编辑 / 删除 | 改错即修 |
| 后台定时同步 | **不做**(辅助登录仅代拉一次、token 会话内用) |
**分页请求策略**(接口仅 `p` / `page_size`,**无时间过滤**):
1. **传统分页 UI**:弹层底部「共 N 条 · 页码 / 上一页 / 下一页」;每次请求只取当前页(`page_size=10`)
2. **不设硬上限**——用户可一直翻到 `total` 尽头(找旧账必须靠翻页)
3. **不提供时间范围筛选**(上游收窄不了结果集;本地筛只会误导读不出的更早页)
4. **已登记去重**:按 `upstream_id` 灰显,不可重复选用
5. **401**:清会话 token → 重新登录;token 仅 sessionStorage
## 6. 对外边界
- 成员可见:`amount`、`money`、渠道、`paid_at`、备注;无逐笔与模型日志的连线
- 纯用量模式可不登记
## 7. 迁移说明
- 原 `payments.kind=topup`:`paid_at` 语义本就落在入账日 → 直接迁入本表 `paid_at`
- 若曾手填 `quota_granted`:可反推 `amount = quota_granted / qpu` 填 `amount`,否则按实付与约定补记
+2 -2
View File
@@ -34,7 +34,7 @@
| 列 | 类型 | 上游来源 | 说明 |
|----|------|---------|------|
| `billing_source` | text | `other.billing_source` | 计费来源:`subscription`(订阅)/ `wallet`(充值)——按来源统计的维度 |
| `subscription_id` | bigint NULL | `other.subscription_id` | 该次消耗所属的上游订阅实例 ID——与 `payments.subscription_id` 直连;缺失时 NULL(归「未关联」分组,见 3.6) |
| `subscription_id` | bigint NULL | `other.subscription_id` | 该次消耗所属的上游订阅实例 ID——与 `subscriptions.subscription_id` 直连;缺失时 NULL(归「未关联」分组,见 3.6) |
### 2.3 上游字段(20 个,`/api/log/token` 响应照收)
@@ -109,7 +109,7 @@
### 3.6 来源提列:`billing_source` 与 `subscription_id` 升为独立列
- **为什么提列**:两者是按来源统计与日志-支付记录直连的核心维度——`other` jsonb 的键查询无法走索引(SQLite/D1 的 `json_extract` 同理),高频过滤维度应独立成列
- **`subscription_id` 直连语义**(事实关联,非推算):日志按 `subscription_id = payments.subscription_id` 关联支付记录;**不做期间推定**——订阅来源但该列缺失(NULL)的行归「未关联订阅」分组,统计中透明显示(正常应恒为零,非零即「缺登记」或「上游行为变化」的异常信号)
- **`subscription_id` 直连语义**(事实关联,非推算):日志按 `subscription_id = subscriptions.subscription_id` 关联订阅登记;**不做期间推定**——订阅来源但该列缺失(NULL)的行归「未关联订阅」分组,统计中透明显示(正常应恒为零,非零即「缺登记」或「上游行为变化」的异常信号)
- `billing_source = wallet`(充值)的日志不挂订阅(余额扣款与订阅实例无关),`subscription_id` 为 NULL 是**正常状态**而非异常——按来源统计时它们在「充值」分组,不进「未关联订阅」分组
- **其他键留在 `other`**:提列只针对这两个一等维度;`cache_tokens`、倍率等仍留在 jsonb,出现真实分析需求再评估(见 §5)
+1 -1
View File
@@ -26,7 +26,7 @@
| 域 | 类型 |
|----|------|
| Key | 登记 / 启用 / 停用 / 归属变更 |
| 支付记录 | 登记 / 编辑 / 删除 |
| 支付记录 | 订阅 / 充值登记、编辑、删除 |
| 成员 | 开户 / 停用 / 启用 / 令牌签发 / 令牌重发 |
| 账号 | 改密 /(空库重建为系统事件,记「初始化」) |
+6 -6
View File
@@ -17,7 +17,7 @@
```
- **管理面**(作者专属):入口为登录页(账号密码)→ 侧边栏布局;空库时按三态分流(见《交互设计-登录与会话》)
- **成员视图**(成员专属):入口为登录壳(令牌)→ 单页;与管理面物理隔离(两套路由、两套凭证中间件,令牌进不了管理面,会话进不了成员视图)
- **成员视图**(成员专属):入口为登录壳(令牌)→ 侧边栏布局(与管理端同构壳,菜单三项:总览 / 调用日志 / 支付参数);与管理面物理隔离(两套路由、两套凭证中间件,令牌进不了管理面,会话进不了成员视图)
- **没有第三种入口**:无开放查询页、无游客态;任何人第一眼看到的必然是两个登录入口之一
- 401 分流统一约定:管理面任意请求 401 → 登录页(提示「会话已过期」);成员视图任意请求 401 → 登录壳(提示「令牌无效或已停用」)——各自回各自的家,不交叉
@@ -38,7 +38,7 @@
顺序逻辑:先看全景 → 立对象(成员 → Key → 支付)→ 查事实(调用日志)→ 系统(审计 / 账号)。
- 侧边栏底部:当前用户名 + 登出(登出即删会话行 → 回登录页)
- 成员视图**无侧边栏**(单页,页头导航)
- 成员视图**同构侧边栏**(三项菜单,见《交互设计-成员视图》§2):布局壳与管理端一致,菜单与数据不同;顶栏下拉「退出」= 清 sessionStorage 令牌
## 3. 跨页组件与呈现规则
@@ -85,7 +85,7 @@
八篇交互设计实际用到的组件能力——组件库与前端框架是实现期自由度,本清单是选型的验收标准而非锁定:
1. **布局**:可折叠侧边栏 / 抽屉、栅格系统(布局响应,见 §3.5)
2. **表格**:密集列、行内编辑(归属下拉)、**展开行**(来源徽章的展开层)、行级操作、窄屏横向滚动
2. **表格**:密集列、行内编辑(归属下拉)、行级操作、窄屏横向滚动;详情弹窗(调用详情 / 订阅参数)
3. **浮层**:对话框(含二次确认)、toast(复制反馈)、顶部提示条(可关闭)
4. **表单**:日期范围选择(时间选择器的快捷项 + 自定义起止)、大号粘贴输入框、双输入切换(支付记录的额度模式)
5. **展示**:徽章 / 标签(来源、状态)、分段条(来源分解)、骨架屏、分页
@@ -94,9 +94,9 @@
平台支持**不登记任何支付记录**的部署形态(如部门实例:只看用量、不掌握订阅套餐信息)——用量统计链路(登记 → 采集 → 聚合)独立于金额层,**零改造支持**:
- 成员视图支付参数卡:中性空态「暂无支付记录信息」——**不设行动引导**(不登记是合法使用形态,非遗漏)
- 日志来源徽章的展开行:无对应登记时显示「该订阅无登记信息」
- 核对提示条:`payments` 全空时**自动静默**(作者显然选择了不登记,此时提示是噪音而非健康信号)
- 成员视图支付参数页:中性空态「暂无支付记录信息」——**不设行动引导**(不登记是合法使用形态,非遗漏)
- 日志来源徽章的弹窗:无对应登记时显示「该订阅无登记信息」
- 核对提示条:`subscriptions` + `topups` 皆空时**自动静默**(作者显然选择了不登记,此时提示是噪音而非健康信号)
- 「本订阅期间」快捷项自动隐藏(无期间数据)
- 完全不受影响:健康带、按成员 / key 聚合、来源徽章与分解、全部管理操作、审计
+2
View File
@@ -12,6 +12,8 @@
## 1. 成员清单(页面主体)
列表上方轻量筛选:名称/备注搜索 · 状态(全部/启用/停用);URL 可深链(`name` / `status` / `member_id`)。
卡片栅格(成员量级:个位数):
| 元素 | 内容 |
+130 -35
View File
@@ -1,76 +1,171 @@
# 交互设计 · 成员视图
> 成员的单页世界——名下 key 的调用日志、用量聚合、完全公开透明的支付参数。**R12 的交互落点**:含「日志行携带所属订阅/充值信息」的形态定案。页面无任何「应付」概念(分摊哲学裁决)。
> 成员世界——**布局与信息结构对齐管理端**(侧边栏壳 + 页内区块 + 表格优先),内容仍是「我的用量」:名下 key 的调用日志、用量聚合、完全公开透明的支付参数。**R12 的交互落点**(日志行携带所属订阅/充值信息:徽章弹窗 + 行展开调用详情)。页面无任何「应付」概念(分摊哲学裁决)。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | 《交互设计-总览与共性》(数值呈现、时间选择器)· R12(裁决记录)· `members表字段设计.md`(令牌)· `usage_logs表字段设计.md`(来源提列)· `payments表字段设计.md`(参数卡数据源) |
| 数据 | 名下 key 的 `usage_logs` 聚合与明细 · 相关 `payments` 参数(完全可见) |
| 前置 | 经登录壳(令牌)进入;无侧边栏、单页 |
| 状态 | 定稿(2026-10-09,作者认可布局重写) |
| 关联 | 《交互设计-总览与共性》(数值呈现、时间选择器、响应式)·《交互设计-调用日志》(明细表列语义同构参照)· R12(裁决记录)· `members表字段设计.md`(令牌)· `usage_logs表字段设计.md`(来源提列)· `subscriptions` / `topups` 表字段设计(参数数据源)·《API契约-成员视图》 |
| 数据 | 名下 key 的 `usage_logs` 聚合与明细 · 相关 `subscriptions` / `topups`(完全可见) |
| 前置 | 经登录壳(令牌)进入;**侧边栏布局**(与管理端同构,菜单与数据不同) |
---
## 1. 单页结构(自上而下,滚动即全部)
## 1. 布局壳(与管理端同构)
### 1.1 页头
成员视图**不再做单页长卷**,改为与管理端同一套页面壳:
成员名 + 一句话定位「你在本池的名下用量」(「你」= 该成员;页面文案以成员视角书写)
| 构件 | 形态 |
|------|------|
| 整体 | `a-layout`:左侧固定可折叠侧边栏 + 右侧顶栏 / 内容区;高度锁 `100vh`,**仅内容区滚动** |
| 品牌区 | 侧边栏顶部两行:`NAKU` +「成员用量」(收起只留 N 徽标)——与管理端同源,描述行区分世界 |
| 菜单 | 深色 `a-menu`,三项(见 §2);收起态只留图标 |
| 顶栏 | 右侧成员名 + 下拉「退出」(与管理端账号菜单位置一致) |
| 内容区 | `bg-layout` 上的白色内容面(8px 圆角);页内统一 `PageHeader` + 区块 |
| 退出 | 清 sessionStorage 令牌 → 登录壳(无服务端登出——令牌档语义) |
### 1.2 时间范围选择器
> 与管理端**物理隔离**不变:两套路由、两套凭证中间件;布局同构 ≠ 世界打通。
共性组件;默认最近 30 天,快捷项含「本订阅期间」
## 2. 导航结构
### 1.3 聚合卡(该时段总览)
| 菜单 | 页面 | 职责一句话 | 登录后落地 |
|------|------|-----------|-----------|
| 总览 | 成员总览 | 该时段用量与名下 key 全景 | **是** |
| 调用日志 | 调用明细 | 名下 key 的模型请求流水 | |
| 支付参数 | 支付参数 | 订阅 / 充值登记参数(完全公开) | |
顺序逻辑:先看汇总 → 查事实(流水)→ 对参数(支付)——与管理端「总览 → 对象 → 事实」同型,体量收成三项。
**时间范围全局共享**:三页共用同一对账窗口——切页保留当前范围(菜单带 `from/to`),任一页改范围即全局生效;URL 可深链。支付参数页的「该时段涉及」:订阅看期间重叠 ∪ 进行中,充值看 `paid_at`。
**数据刷新心智**:**仅总览**作看板自动刷新(约 60s);调用日志 / 支付参数为查询台,**不主动轮询**(改条件或手动重试才拉);相对时间窗在选定后定格,避免每分钟打穿缓存。
无账号设置、无审计、无健康带(成员世界定位边界,见《API契约-成员视图》导语)。
## 3. 总览页
### 3.1 页头
`PageHeader`:标题「总览」+ 副文案「你在本池的名下用量 · 仅统计消费行」;**右侧操作位放时间范围选择器**(共性 §3.1,默认最近 30 天,快捷项含「本订阅期间」)。
### 3.2 用量摘要条(页首,非海报)
一行紧凑摘要,**不做超大号 Hero 数字**(与管理端密度一致):
- 总用量:次数 / quota / 美元(标注「按当时换算率」)
- **按来源分解**:订阅消耗 / 充值消耗(分段条)——回答「我的用量花在哪个池子」
- 口径标注:「2026-09-02 ~ 10-01」(共性:杜绝无口径数字)
### 1.4 支付参数卡(完全公开透明的落点)
### 3.3 我的 Key(表格,非窄卡)
该时段涉及的订阅与充值,**参数原貌**逐笔列出:
管理端总览「按 key」区的成员版——**表格优先**:
- 订阅:期间、订阅 ID、额度(quota + $)、金额(纯数字)
- 充值:支付时间、额度、金额
- 脚注:「支付参数对成员完全公开;币种由成员与作者约定」——透明裁决的界面声明
| 列 | 内容 |
|----|------|
| Key 名称 | `token_name` |
| 数据截止 | 分钟级;落后 > 2h 停滞标注 |
| 调用次数 | 该时段消费日志条数 |
| 额度消耗 | quota + $ 双单位 |
> **不出现**任何派生金额:无「折合人民币」、无「占比 × 金额」、无「应付」——账本是事实,算账是社交层的事。
>
> 空态:无登记的部署形态(纯用量模式,《总览与共性》§3.8)下该卡显示中性文案「暂无支付记录信息」——不设行动引导(不登记是合法使用形态)。
- 行操作「查调用」→ 调用日志页并带上该 `api_key_id` + 当前时间范围
- 空态:名下无 key →「作者尚未为你分配 key」;有 key 无日志 → 表正常、用量 0 +「该时段内无调用」
### 1.5 我的 key 列表
### 3.4 支付参数(摘要入口)
每 key 一张窄卡:`token_name`、数据截止(停滞标注)、该时段用量(次数 / quota / $)。
表头或区块脚注链到**支付参数页**;总览可放 1–2 行摘要(进行中订阅一行)或仅入口链接——**完整参数不在此页堆叠**(对齐管理端「对象各归其页」)。
### 1.6 日志明细表
- 纯用量模式(无登记):不放行动引导;入口仍可进支付参数页看中性空态
## 4. 调用日志页
与管理端调用日志**列语义同构**(《交互设计-调用日志》为参照),范围恒为**名下 key**;明细**默认全类型**(消费 + 错误,可筛);聚合仍仅消费行。
### 4.1 页头与筛选
- `PageHeader`「调用日志」+ 副文案「名下 key 的模型请求流水」;右侧时间范围(同上)
- 筛选一行:Key(Select)· 类型(全部 / 消费 / 错误)· 计费来源(全部 / 订阅 / 充值 / 未关联)· 订阅 ID(可空)
- 切换任一筛选 → 回第 1 页;URL 同步 `from/to` + 筛选 + `page`(可深链)
### 4.2 明细表(页面主体,表体滚动)
| 列 | 内容 |
|----|------|
| 时间 | 到秒(北京时间) |
| key | `token_name` |
| Key | `token_name` |
| 模型 | `model_name` |
| 来源 | **来源徽章**:`订阅 #N` / `充值` / `未关联`(灰)——携带形态的第一层(一眼识别) |
| 类型 | 消费 / 错误徽章 |
| 来源 | **来源徽章**:`订阅 #N` / `充值` / `未关联`(灰)——R12 携带形态第一层 |
| 用量 | quota + 美元双单位 |
| tokens | prompt + completion |
| 操作 | 「详情」按钮 → 详情弹窗 |
**携带形态定案(R12 悬置项的本设计结论)**:两层实现——
**携带形态定案(R12,2026-10-09 修订)** 两层分工——
1. **行内徽章**(常驻):来源类型 + 订阅编号,零噪声
2. **展开行**(点击徽章):该笔支付的完整参数(期间 / 额度 / 金额 / 备注)——完整透明的第二层,按需展开不打扰
1. **行内徽章**(常驻):来源类型 + 订阅编号,零噪声;**点击「订阅 #N」→ 弹窗**展示该笔支付完整参数(期间 / 额度 / 金额 / 备注)——与详情解耦,扫读不被打断
2. **「详情」弹窗**(操作列,居中定高内滚):调用事实——时间 / Key / 模型 / 类型 / 用量 / Tokens / 耗时流式 / 来源 / 内容(错误详情),以及**原始数据**(落库行 JSON,内滚)
> 展开行降级:徽章对应的订阅**无支付记录**时,展开显示「该订阅无登记信息」(纯用量模式的常态,见《总览与共性》§3.8);徽章显示「未关联」的行(日志缺 `subscription_id`)不可展开——两种空值场景各自归位,见 `usage_logs表字段设计.md` 3.6。
> 弹窗降级:徽章对应订阅**无登记** →「该订阅无登记信息」;「充值 / 未关联」徽章不弹窗(余额池无逐笔关联 / 缺 `subscription_id`)。与《API契约-成员视图》§5 裁定 4 同步:「充值」为纯标识。
> 该形态随实现期真实数据核查(订阅编号同一性)后再终验;徽章文案预留「未关联」的正常位(健康指示语义)。
### 4.3 表体高度(对齐管理端)
## 2. 状态与边界
- 内容区固定壳内:**表体 `scroll.y` 按容器实测**(ResizeObserver),分页条贴底;页面不因明细变长而整页膨胀
- 分页:默认 50,档位 20 / 50 / 100 / 200
- 空态:「该时段内无调用日志」
## 5. 支付参数页
完全公开透明的落点——**订阅权益 + 充值入账**两组清单(Tab 或上下两节),参数原貌成表。
### 5.1 页头
`PageHeader`「支付参数」+ 副文案「订阅与充值的登记参数 · 对成员完全公开」;右侧时间范围(全局共享窗口)。
### 5.2 订阅表
| 列 | 内容 |
|----|------|
| 订阅 ID | `subscription_id` |
| 期间 | `period_start` ~ `period_end`(进行中) |
| 额度 | quota + $(`quota_granted`) |
| 付款 | 金额 + `paid_at`(可空显示「—」) |
| 备注 | `remark` |
### 5.3 充值表
| 列 | 内容 |
|----|------|
| 时间 | `paid_at`(入账完成) |
| 充值金额 / 实付金额 | `amount` · `money`(汇率约定,不标币种) |
| 交易号 / 渠道 | `trade_no` · 支付方式 |
| 派生额度 | `amount × qpu` + $(展示) |
| 备注 | `remark` |
- 脚注:「支付参数对成员完全公开;币种由成员与作者约定」——透明裁决的界面声明
- **不出现**任何派生金额:无「折合人民币」、无「占比 × 金额」、无「应付」
- 空态(纯用量模式):中性文案「暂无支付记录信息」——**不设行动引导**
### 5.4 与日志订阅弹窗的关系
本页是**完整清单**(订阅 + 充值两组);日志页徽章弹窗是**单笔快捷透视**——同一数据源(`overview.subscriptions`),不两套口径。
## 6. 状态与边界
| 场景 | 表现 |
|------|------|
| 该时段无日志 | 聚合区「该时段内无调用」+ 明细空态 |
| 名下无 key(刚开户) | 全页空态「作者尚未为你分配 key」 |
| 该时段无日志 | 总览摘要为 0 +「该时段内无调用」;日志页空态 |
| 名下无 key(刚开户) | 总览空态「作者尚未为你分配 key」;其余页正常空态 |
| 令牌失效/停用 | 任意请求 401 → 登录壳(「令牌无效或已停用」) |
| 旧令牌(作者重发后) | 同上——旧令牌即刻作废 |
| 纯用量模式 | 支付参数页中性空态;订阅徽章弹窗「该订阅无登记信息」;「本订阅期间」快捷项隐藏 |
## 3. 窄屏说明
## 7. 窄屏说明
成员可能用手机查看本页——但按《总览与共性》§3.5 的裁决:**不做移动端 UI 设计**,窄屏下依赖布局响应(区块纵向堆叠、宽度弹性)保证可读不崩;明细表保持表格形态,横向滚动即可。
成员可能用手机查看——按《总览与共性》§3.5:**不做移动端 UI 设计**。侧边栏窄屏折叠为抽屉;区块纵向堆叠;明细与参数表保持表格形态,横向滚动即可。
## 8. 与前稿的差异(本次重写)
| 前稿(单页海报) | 本稿(管理端同构) |
|------------------|-------------------|
| 无侧边栏、单页五区滚动 | 侧边栏三页 + 固定壳、内容区滚动 |
| 超大 Hero 数字 + key 胶囊墙 | 紧凑摘要条 + 表格 |
| 支付参数卡堆在长卷里 | 独立「支付参数」页 |
| 明细表嵌在页尾 | 独立「调用日志」页,表体视口滚动 |
+72 -27
View File
@@ -1,47 +1,92 @@
# 交互设计 · 支付记录
> 订阅与充值的登记面(同构表单)+ 系统核对提示的呈现——「只有作者知道」的信息的唯一录入处(payments 定位)。
> 订阅权益与充值入账的登记面(管理入口名「支付记录」)+ 系统核对提示——「只有作者知道」的信息的唯一录入处。物理上分 `subscriptions` / `topups` 两表(`payments建模评估.md` 方案 B),界面仍作**一个菜单项**,两 Tab 各登记各的。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | 《交互设计-总览与共性》(数值呈现:金额无符号、额度双单位)· `payments表字段设计.md`(字段与「只存事实」)· R7(充值登记)· R8(`subscription_id` 直连) |
| 数据 | `payments` 全表 · 日志观测的订阅 ID(核对提示) |
| 状态 | 待审阅(2026-10-09 随拆表重写) |
| 关联 | 《交互设计-总览与共性》· `subscriptions表字段设计.md` · `topups表字段设计.md` · R7/R8 |
| 数据 | `subscriptions` + `topups` · 日志观测的订阅 ID(核对提示) |
---
## 1. 记录清单(页面主体)
## 1. 页结构:两 Tab
| 列 | 内容 | 备注 |
|----|------|------|
| 类型 | `subscription` / `topup` 徽章(订阅 / 充值) | 同构表的两种形态 |
| 金额 | **纯数字**(如「170」) | 币种由你们约定——列头标注「金额(币种自定)」 |
| 时间 | 订阅:期间 `[start, end)`,未结束显示「进行中」;充值:`paid_at` | 期间左闭右开,展示为「09-01 ~ 10-01」 |
| 订阅 ID | `subscription_id`(充值留空) | 直连日志的分源键 |
| 额度 | 原始 quota + 美元换算双单位(如「12,500,000($25.00)」) | 美元按 QPU 换算,标注「按当时换算率」 |
| 备注 | `remark` | |
菜单仍叫「支付记录」;页内 Tab:
- 排序:时间倒序;编辑 / 删除(删除有确认弹窗——「改错即修,删除即消失」,人工登记数据)
| Tab | 清单 | 登记 |
|-----|------|------|
| **订阅** | 订阅权益表 | 登记订阅 |
| **充值** | 充值入账表 | 登记充值 |
## 2. 登记表单(订阅 / 充值同构,`kind` 切换)
筛选随 Tab:
**公共字段**:金额(必填,纯数字输入;脚注「金额数值——币种由你们约定,系统不预设」)、备注(可空)
| Tab | 筛选 | URL |
|-----|------|-----|
| 订阅 | 订阅 ID / 期间与所选窗口重叠(拆表后) | `tab=subs` |
| 充值 | **仅类型**(付费时间筛选已撤——`paid_at` 语义待拆表钉死) | `tab=topups` · `kind` |
**订阅形态追加**:
- 支付时间、期间起止(结束可空 = 进行中)
- 订阅 ID(抄自钱包页面「订阅 #N」)
- 额度——**双输入模式**:直接输原始 quota,或输美元由系统按当前 QPU 换算落库(表单内二选一切换,换算即时预览;录入体验归交互层、数据落原始 quota——payments 3.6)
当前实现(拆表前):仅「类型」筛选;补录审计可看「登记」列(`created_at`)。
**充值形态追加**:支付时间、额度(同上双输入)
## 2. 订阅清单列
## 3. 核对提示(系统观测 ↔ 人工登记的对照)
| 列 | 内容 |
|----|------|
| 订阅 ID | `subscription_id` |
| 期间 | `[period_start, period_end)`,未结束「进行中」 |
| 额度 | quota + $ 双单位 |
| 付款 | `paid_amount` / `paid_at`(可空显示「—」) |
| 备注 | `remark` |
| 登记 | `created_at` |
| 操作 | 编辑 / 删除 |
页面顶部提示区,由日志观测驱动:
## 3. 充值清单列(对齐上游充值单)
| 列 | 内容 |
|----|------|
| 时间 | `paid_at`(= 上游 `complete_time`,平台展示时刻;倒序) |
| 充值金额 | `amount`(平台计费侧充值额,不标币种) |
| 实付金额 | `money`(如 `2.04`,纯数字) |
| 交易号 / 渠道 | `trade_no` · `payment_method` / `payment_provider` |
| 派生额度 | `amount × qpu` + $(展示,不入库) |
| 备注 / 登记 | `remark` · `created_at` |
| 操作 | 编辑 / 删除 |
## 4. 登记表单(分 Tab,不再 `kind` 切换)
**订阅**:订阅 ID · 期间起止(结束可空)· 额度(quota / 美元双输入)· 付款金额(可空)· 付款时间(可空)· 备注 · **「从平台选用」**(辅助登录 → 订阅列表)
**充值**:时间 `paid_at`(必填)· `amount`(必填)· `money`(必填)· 交易号 / 渠道(可空)· 备注 · **「从平台选用」**(辅助登录 → 充值单分页列表);**无「换得额度」输入框**(派生)
### 4.1 选用弹层(订阅 / 充值同规则)
与 `platform-subscription-picker` 同壳:账号密码 → 代拉 → 选用。**已登记灰显**两套一致:
| 实体 | 去重键 | 已登记表现 |
|------|--------|------------|
| 订阅 | `subscription_id` | 灰 +「已登记」,不可选用 |
| 充值 | `upstream_id` | 灰 +「已登记」,不可选用 |
**充值分页**(上游仅 `p` / `page_size`,无时间参数):
| 行为 | 策略 |
|------|------|
| 列表 | 当前页(`page_size=10`) |
| 翻页 | 传统分页:共 N 条 · 上一页 / 页码 / 下一页 |
| 范围 | 可翻至尽头;**不做时间筛选** |
| 401 | 重登;token 仅 sessionStorage |
订阅列表为全量一次拉取(`all_subscriptions`),无分页问题。
- 金额脚注:「金额数值——币种由你们约定,系统不预设」
- 订阅额度双输入:原始 quota 或美元(按 `qpu/latest` 预览),落库恒 quota
- 编辑 / 删除有确认;改错即修
## 5. 核对提示(只对订阅 Tab)
| 提示 | 触发 | 文案 |
|------|------|------|
| 未对应登记 | 观测到某 `subscription_id` 有消耗、但 `payments` 无此订阅 | 「观测到订阅 #N 有消耗,但尚无对应登记——请核对补录」 |
| 正常 | 全部观测到的订阅均有登记 | 不显示(健康默认无声) |
| 未对应登记 | 观测到某 `subscription_id` 有消耗、`subscriptions` 无此行 | 「观测到订阅 #N 有消耗,但尚无对应登记——请核对补录」 |
| 正常 | 均有登记 | 不显示 |
- 观测**只提示、不回填**(payments 定稿语义);提示区与全员总览的「未关联订阅」分组互为印证(一个说「有 ID 没账」,一个说「有账路没 ID」)
- **静默规则**:`payments` 全空时本提示区自动静默——纯用量模式(《总览与共性》§3.8,如部门实例)下「无登记」是作者的有意选择,此时提示是噪音而非健康信号
- 只提示不回填;`subscriptions` + `topups` 皆空时**自动静默**(纯用量模式)
+1 -1
View File
@@ -41,7 +41,7 @@ GET 管理面入口
- 成员视图的唯一入口:令牌输入框(单字段)+ 进入按钮
- 提交 → 服务端哈希比对 + `status` 检查:
- 通过 → 令牌存 sessionStorage → 进入成员视图(单页,见《交互设计-成员视图》)
- 通过 → 令牌存 sessionStorage → 进入成员视图(侧边栏布局,落地「总览」,见《交互设计-成员视图》)
- 失败/停用 → 「令牌无效或已停用」(同一提示,不泄露停用与错误的区别——成员侧无需区分,反正都是找作者)
- 页面氛围:与登录页同构但**视觉上明确这是「成员入口」**(页头「成员入口」标识),防止你自己走错门
- 无「找回令牌」链接:丢了找作者重发(裁决语义,界面不设自助入口)
+2 -1
View File
@@ -42,9 +42,10 @@
| 来源 | 订阅 #N / 充值 / 未关联徽章 | 与成员视图同构 |
| 用量 | quota + $ 双单位 | 错误行可显示 `—` 或 0 |
| Tokens | prompt / completion | |
| 操作 | 「详情」按钮 | 开详情弹窗 |
- 分页:默认 50,档位 20 / 50 / 100 / 200(无界列表)
- **行内不展开支付参数**(管理面支付另页;此处保持扫读密度)——需要看订阅登记时从来源徽章旁的链接或工具提示指向支付记录筛选(实现期可只做 tooltip 文案)
- **「详情」弹窗**(操作列,居中定高内滚,宽约 720):时间 / Key·归属 / 模型·类型 / 用量 / Tokens / 耗时流式 / 来源 / 内容(错误详情),以及**原始数据**(落库行 JSON,内滚);**不承载支付参数**(管理面支付另页)——需要看订阅登记时从来源徽章旁的链接或工具提示指向支付记录筛选
- 空态:「该时段内无调用日志」
- 窄屏:表格横向滚动(共性 §3.5)
+8 -1
View File
@@ -69,7 +69,7 @@
| # | 需求 | 说明 | 状态 |
|---|------|------|------|
| R15 | 数据模型修订 | 七篇表设计(`api_keys` · `usage_logs` · `payments` · `qpu_history` · `members` · `users` · `sessions`)经作者逐篇审阅**定稿**:四张基线表复审落点 + `payments` 重定义(订阅/充值同构、币种无关、只存事实)+ 访问主体三新表 + `usage_logs` 来源提列(`billing_source` / `subscription_id`)与错误行去重修订(非消费行 `dedupe_key` 追加含 `content` 的指纹)+ `api_keys.member_id` 归属列 + 401 自动摘除状态机裁撤(引擎记录不决策);方言映射归数据访问层设计 | **定稿**(2026-10-01) |
| R15 | 数据模型修订 | 七篇表设计经作者逐篇审阅**定稿**(2026-10-01);其后 `payments` 按 2026-10-09 裁决拆为 `subscriptions` + `topups`(见裁决记录)——基线域现为 `api_keys` · `usage_logs` · `subscriptions` · `topups` · `qpu_history` + 访问主体三表 | **已裁决**(2026-10-01;2026-10-09 拆表修订) |
## 7. R13 的支撑材料:CFW 可行性调研(2026-09-29,developers.cloudflare.com 官方文档核实,五项全部无阻断)
@@ -157,6 +157,13 @@
- **未关联分组 = 查询层产物**(LEFT JOIN 未匹配集,无新字段),正常时恒为零,非零即异常信号;两种未关联(日志缺 ID / 有 ID 无登记)的展示区分留设计期
- **统计 = 派生视图**:数据库只存事实行,一切分组/分解/汇总为查询时聚合,无预计算存储
### 2026-10-09 · 支付建模拆表(方案 B)
- **裁决:`payments` 单表拆为 `subscriptions`(订阅权益)+ `topups`(充值入账事件)**——`paid_at` 在订阅行上语义污染(更像登记时刻),后补录无法按付款时间对齐用量窗口;对齐 NewAPI「权益 vs 入账」两分(评估见 `payments建模评估.md`)
- **三时刻钉死**:`created_at`(登记)· `paid_at`(钱出手,订阅可空)· `period_*`(用量覆盖期);筛选:订阅看期间重叠、充值看 `paid_at`、补录看 `created_at`
- **展示仍称「支付记录」**:管理面一菜单两 Tab;成员「支付参数」两组清单;R7/R8/R9 哲学不变(只存事实、`subscription_id` 直连、币种无关)
- **原 `payments表字段设计.md` 删除**,由两篇表设计替代;契约与交互随拆表修订
### 2026-09-29 · 数据资产域延后(R3 / R4)
**裁决:不纳入现阶段**(锦上添花能力,核心功能实现后再议)。提案存档如下,供重启讨论时作起点:
+21 -1
View File
@@ -288,7 +288,7 @@ quota = ( (prompt_tokens − cache_tokens) ← 提示 tokens(未
- **sk-key(`TokenAuth` / `TokenAuthReadOnly`)挂不上这些路由**——钱包页在 F12 里看到的 JSON 即 `GET /api/subscription/self`,需网页登录 Cookie/会话,不是 `Authorization: Bearer sk-…`
- 「没有免 auth 的接口」结论仍成立;**并非漏查**,而是查询面从未对 sk-key 开放。UI 只显示过期时间,是前端展示裁剪,底层数据在上述接口里
- 订阅 `id` 与日志 `other.subscription_id` 同体系(作者 JSON 中 `id: 44/22/6` 可与日志对账)
- 对应设计:订阅信息由购买者手工录入(`payments表字段设计`);登记时可粘贴 `all_subscriptions` JSON 预填(见支付表单),**不引入账号密码自动化**
- 对应设计:订阅信息由购买者手工录入或**辅助登录选用**(`subscriptions表字段设计`;密码不落库、token 会话内);登记时可粘贴 `all_subscriptions` JSON 预填;**不做后台账号自动同步**
### 4.4 参数被忽略的情况(实测)
@@ -299,6 +299,26 @@ quota = ( (prompt_tokens − cache_tokens) ← 提示 tokens(未
| `/api/log/token?start_timestamp=1751328000` | 参数被忽略,仍返回最近 1000 条 |
| `/v1/dashboard/billing/usage?start_date=2025-07-01` | 参数被忽略 |
### 4.5 充值记录(2026-10-09 实测)
- **`GET /api/user/topup/self?p=1&page_size=10`**——分页充值单;路径属 `/api/user/*`,**预期 `UserAuth()`**(账号登录态,非 sk-key)
- 响应 `{ data: { page, page_size, total, items }, success }`;单条字段与样本:
| 字段 | 样本 | 含义 |
|------|------|------|
| `id` | `17` | 上游充值单 ID |
| `user_id` | `6` | 账号 |
| `amount` | `1` | **平台计费金额**(充值记账额;与 `money` 之间为管理员约定汇率,不标币种) |
| `money` | `2.04` | **实付金额** |
| `trade_no` | `USR6…` | 交易号 |
| `payment_method` / `payment_provider` | `alipay` / `epay` | 渠道 |
| `create_time` / `complete_time` | Unix 秒 | 发起 / **入账完成** |
| `status` | `success` | 单据状态 |
- **无 `quota_granted` 字段**——换得额度为派生(`amount × quota_per_unit`);登记不固化该列(`topups表字段设计.md`)
- **展示时刻 = `complete_time`**(平台列表即显示它);`create_time` 仅发起时刻——设计只存一条事件时间 `paid_at` = `complete_time`
- **辅助登录代拉**(与订阅同模式):`UserAuth` access_token、密码不落库;**仅 `p` / `page_size` 分页、无时间参数**——前端传统分页、可翻至尽头(不设页数硬上限);不做后台定时同步
## 5. 行为事实:写权限与 key 生命周期
- **sk-key 没有任何写接口**:没有 POST/PUT/DELETE 能改 token 自身;无法通过 sk-key 重置 `used_quota`(没有"重置用量"接口)
+3
View File
@@ -27,6 +27,9 @@ export const ERR = {
errorBody("invalid_credentials", "用户名或密码错误"),
session_expired: () =>
errorBody("session_expired", "会话已过期,请重新登录"),
/** 上游控制台 token 失效——**不是**管理端会话,禁止走 401 登出 */
platform_token_invalid: () =>
errorBody("platform_token_invalid", "平台登录已失效,请重新登录上游账号"),
already_initialized: () =>
errorBody("already_initialized", "系统已初始化,无法重复引导"),
key_already_registered: (key_id: number) =>
+49 -42
View File
@@ -65,6 +65,7 @@ export const logItem = z.object({
is_stream: z.boolean(),
billing_source: z.string().nullable(),
subscription_id: z.number().int().nullable(),
raw: z.record(z.string(), z.unknown()).optional(),
});
export const logPage = z.object({
@@ -129,17 +130,33 @@ export const memberMe = z.object({
name: z.string(),
});
export const paymentItem = z.object({
export const subscriptionItem = z.object({
id: z.number().int(),
kind: z.enum(["subscription", "topup"]),
paid_at: z.number().int(),
paid_amount: z.number(),
remark: z.string(),
period_start: z.number().int().nullable(),
subscription_id: z.number().int(),
period_start: z.number().int(),
period_end: z.number().int().nullable(),
subscription_id: z.number().int().nullable(),
quota_granted: z.number().int().nullable(),
quota_granted: z.number().int(),
quota_usd: z.string(),
paid_amount: z.number().nullable(),
paid_at: z.number().int().nullable(),
remark: z.string(),
created_at: z.number().int(),
});
export const topupItem = z.object({
id: z.number().int(),
upstream_id: z.number().int().nullable(),
amount: z.number(),
money: z.number(),
trade_no: z.string().nullable(),
payment_method: z.string().nullable(),
payment_provider: z.string().nullable(),
status: z.string().nullable(),
paid_at: z.number().int(),
quota_derived: z.number().int(),
quota_usd: z.string(),
remark: z.string(),
created_at: z.number().int(),
});
export const engineStatus = z.object({
@@ -187,44 +204,31 @@ export const memberOverview = z.object({
usage: usageTotals,
}),
),
payments: z.array(z.looseObject({ id: z.number().int() })),
subscriptions: z.array(subscriptionItem),
topups: z.array(topupItem),
});
export const createPaymentBody = z
export const createSubscriptionBody = z
.object({
kind: z.enum(["subscription", "topup"]),
paid_at: z.string().min(1),
paid_amount: z.number(),
remark: z.string().default(""),
period_start: z.string().min(1).nullable().optional(),
subscription_id: z.number().int().positive(),
period_start: z.string().min(1),
period_end: z.string().min(1).nullable().optional(),
subscription_id: z.number().int().positive().nullable().optional(),
quota_granted: z.number().int().nullable().optional(),
})
.superRefine((val, ctx) => {
if (val.kind === "subscription") {
if (!val.period_start) {
ctx.addIssue({
code: "custom",
message: "订阅必须填写期间起点",
path: ["period_start"],
quota_granted: z.number().int().positive(),
paid_amount: z.number().nullable().optional(),
paid_at: z.string().min(1).nullable().optional(),
remark: z.string().default(""),
});
}
if (val.subscription_id == null) {
ctx.addIssue({
code: "custom",
message: "订阅必须填写订阅编号",
path: ["subscription_id"],
});
}
}
if (val.quota_granted == null) {
ctx.addIssue({
code: "custom",
message: "必须填写本笔额度",
path: ["quota_granted"],
});
}
export const createTopupBody = z.object({
upstream_id: z.number().int().nullable().optional(),
amount: z.number().positive(),
money: z.number().positive(),
trade_no: z.string().nullable().optional(),
payment_method: z.string().nullable().optional(),
payment_provider: z.string().nullable().optional(),
status: z.string().nullable().optional(),
paid_at: z.string().min(1),
remark: z.string().default(""),
});
export {
@@ -235,7 +239,10 @@ export {
patchKeyBody as patchKeyBodySchema,
createMemberBody as createMemberBodySchema,
patchMemberBody as patchMemberBodySchema,
createPaymentBody as createPaymentBodySchema,
createSubscriptionBody as createSubscriptionBodySchema,
createTopupBody as createTopupBodySchema,
subscriptionItem as subscriptionItemSchema,
topupItem as topupItemSchema,
timeRangeQuery as timeRangeQuerySchema,
pageQuery as pageQuerySchema,
};
+28 -9
View File
@@ -11,7 +11,6 @@ export type Quota = number;
export type QuotaUsd = string;
export type BillingSource = "subscription" | "wallet";
export type PaymentKind = "subscription" | "topup";
export type KeyStatus = "active" | "disabled";
export type MemberStatus = "active" | "disabled";
@@ -37,6 +36,8 @@ export interface LogItem {
is_stream: boolean;
billing_source: BillingSource | null;
subscription_id: number | null;
/** 完整落库行(上游字段原貌)——「原始数据」视图 */
raw?: Record<string, unknown>;
}
export interface KeyItem {
@@ -55,17 +56,35 @@ export interface KeyItem {
expires_at: UnixSec | null;
}
export interface PaymentItem {
/** 订阅权益登记 */
export interface SubscriptionItem {
id: number;
kind: PaymentKind;
paid_at: UnixSec;
paid_amount: PaidAmount;
remark: string;
period_start: UnixSec | null;
subscription_id: number;
period_start: UnixSec;
period_end: UnixSec | null;
subscription_id: number | null;
quota_granted: number | null;
quota_granted: Quota;
quota_usd: QuotaUsd;
paid_amount: PaidAmount | null;
paid_at: UnixSec | null;
remark: string;
created_at: UnixSec;
}
/** 充值入账(对齐上游充值单;无 quota_granted 入库列) */
export interface TopupItem {
id: number;
upstream_id: number | null;
amount: number;
money: PaidAmount;
trade_no: string | null;
payment_method: string | null;
payment_provider: string | null;
status: string | null;
paid_at: UnixSec;
quota_derived: Quota;
quota_usd: QuotaUsd;
remark: string;
created_at: UnixSec;
}
export interface MemberItem {
+73
View File
@@ -241,3 +241,76 @@ export async function fetchPlatformSubscriptions(
clearTimeout(timer);
}
}
/** 平台充值单(GET /api/user/topup/self 单页) */
export interface PlatformTopup {
id: number;
user_id?: number;
amount: number;
money: number;
trade_no?: string;
payment_method?: string;
payment_provider?: string;
create_time?: number;
complete_time?: number;
status?: string;
}
/** 用控制台 access_token 分页拉充值单(仅 p / page_size,无时间参数) */
export async function fetchPlatformTopups(
baseUrl: string,
accessToken: string,
page = 1,
pageSize = 10,
timeoutMs = 8_000,
): Promise<
UpstreamResult<{
items: PlatformTopup[];
total: number;
page: number;
page_size: number;
}>
> {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
const full = `${joinUrl(baseUrl, "/api/user/topup/self")}?p=${page}&page_size=${pageSize}`;
const res = await fetch(full, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
signal: ac.signal,
});
if (res.status === 401) return { ok: false, kind: "rejected" };
if (!res.ok) return { ok: false, kind: "http", status: res.status };
const body = (await res.json()) as {
code?: boolean | number;
success?: boolean;
data?: {
page?: number;
page_size?: number;
total?: number;
items?: PlatformTopup[];
};
};
if (body.code === false || body.success === false) {
return { ok: false, kind: "rejected" };
}
const data = body.data ?? {};
return {
ok: true,
data: {
items: data.items ?? [],
total: data.total ?? 0,
page: data.page ?? page,
page_size: data.page_size ?? pageSize,
},
};
} catch {
return { ok: false, kind: "unreachable" };
} finally {
clearTimeout(timer);
}
}
@@ -0,0 +1,138 @@
CREATE TABLE "api_keys" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "api_keys_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"key_hash" text NOT NULL,
"key_ciphertext" "bytea" NOT NULL,
"status" text DEFAULT 'active' NOT NULL,
"member_id" bigint,
"registered_at" timestamp with time zone DEFAULT now() NOT NULL,
"log_synced_at" timestamp with time zone,
"usage_synced_at" timestamp with time zone,
"token_name" text DEFAULT '' NOT NULL,
"total_granted" bigint DEFAULT 0 NOT NULL,
"total_used" bigint DEFAULT 0 NOT NULL,
"total_available" bigint DEFAULT 0 NOT NULL,
"unlimited_quota" boolean DEFAULT false NOT NULL,
"model_limits" jsonb,
"model_limits_enabled" boolean DEFAULT false NOT NULL,
"expires_at" timestamp with time zone,
CONSTRAINT "api_keys_key_hash_unique" UNIQUE("key_hash")
);
--> statement-breakpoint
CREATE TABLE "audit_logs" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "audit_logs_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"operator" text,
"domain" text NOT NULL,
"action" text NOT NULL,
"summary" text DEFAULT '' NOT NULL
);
--> statement-breakpoint
CREATE TABLE "engine_status" (
"id" integer PRIMARY KEY DEFAULT 1 NOT NULL,
"running" boolean DEFAULT false NOT NULL,
"sync_interval_ms" bigint DEFAULT 300000 NOT NULL,
"last_round" jsonb,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "members" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "members_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"name" text NOT NULL,
"remark" text DEFAULT '' NOT NULL,
"status" text DEFAULT 'active' NOT NULL,
"token_hash" text NOT NULL,
"token_issued_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "members_name_unique" UNIQUE("name"),
CONSTRAINT "members_token_hash_unique" UNIQUE("token_hash")
);
--> statement-breakpoint
CREATE TABLE "qpu_history" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "qpu_history_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"qpu" numeric(18, 6) NOT NULL,
"effective_from" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "sessions_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"user_id" bigint NOT NULL,
"token_hash" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "sessions_token_hash_unique" UNIQUE("token_hash")
);
--> statement-breakpoint
CREATE TABLE "subscriptions" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "subscriptions_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"subscription_id" bigint NOT NULL,
"period_start" timestamp with time zone NOT NULL,
"period_end" timestamp with time zone,
"quota_granted" bigint NOT NULL,
"paid_amount" numeric(18, 2),
"paid_at" timestamp with time zone,
"remark" text DEFAULT '' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "subscriptions_subscription_id_unique" UNIQUE("subscription_id")
);
--> statement-breakpoint
CREATE TABLE "topups" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "topups_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"upstream_id" bigint,
"amount" numeric(18, 6) NOT NULL,
"money" numeric(18, 2) NOT NULL,
"trade_no" text,
"payment_method" text,
"payment_provider" text,
"status" text,
"paid_at" timestamp with time zone NOT NULL,
"remark" text DEFAULT '' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "usage_logs" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "usage_logs_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"api_key_id" bigint NOT NULL,
"dedupe_key" text NOT NULL,
"billing_source" text,
"subscription_id" bigint,
"user_id" bigint DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone NOT NULL,
"type" smallint DEFAULT 2 NOT NULL,
"content" text DEFAULT '' NOT NULL,
"username" text DEFAULT '' NOT NULL,
"token_name" text DEFAULT '' NOT NULL,
"model_name" text DEFAULT '' NOT NULL,
"quota" bigint DEFAULT 0 NOT NULL,
"prompt_tokens" integer DEFAULT 0 NOT NULL,
"completion_tokens" integer DEFAULT 0 NOT NULL,
"use_time" integer DEFAULT 0 NOT NULL,
"is_stream" boolean DEFAULT false NOT NULL,
"channel" integer DEFAULT 0 NOT NULL,
"channel_name" text DEFAULT '' NOT NULL,
"token_id" bigint DEFAULT 0 NOT NULL,
"group_name" text DEFAULT '' NOT NULL,
"ip" text DEFAULT '' NOT NULL,
"request_id" text DEFAULT '' NOT NULL,
"upstream_request_id" text,
"other" jsonb,
CONSTRAINT "usage_logs_dedupe_key_unique" UNIQUE("dedupe_key")
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "users_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"username" text NOT NULL,
"password_hash" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "users_username_unique" UNIQUE("username")
);
--> statement-breakpoint
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "usage_logs" ADD CONSTRAINT "usage_logs_api_key_id_api_keys_id_fk" FOREIGN KEY ("api_key_id") REFERENCES "public"."api_keys"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "api_keys_member_id_idx" ON "api_keys" USING btree ("member_id");--> statement-breakpoint
CREATE INDEX "audit_logs_created_at_idx" ON "audit_logs" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "sessions_user_id_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "subscriptions_period_idx" ON "subscriptions" USING btree ("period_start","period_end");--> statement-breakpoint
CREATE INDEX "usage_logs_created_at_idx" ON "usage_logs" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "usage_logs_api_key_created_idx" ON "usage_logs" USING btree ("api_key_id","created_at");--> statement-breakpoint
CREATE INDEX "usage_logs_subscription_idx" ON "usage_logs" USING btree ("subscription_id");
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1791551016051,
"tag": "0000_loose_goblin_queen",
"breakpoints": true
}
]
}
+103 -25
View File
@@ -4,17 +4,19 @@ import {
auditLogs,
engineStatus,
members,
payments,
subscriptions,
qpuHistory,
sessions,
topups,
usageLogs,
users,
type ApiKeyRow,
type AuditLogRow,
type EngineStatusRow,
type MemberRow,
type PaymentRow,
type SubscriptionRow,
type SessionRow,
type TopupRow,
type UsageLogRow,
type UserRow,
} from "./schema.ts";
@@ -384,41 +386,87 @@ export function createRepos(db: Db) {
},
},
payments: {
async listAll(): Promise<PaymentRow[]> {
return db.select().from(payments).orderBy(desc(payments.paid_at));
subscriptions: {
async listAll(): Promise<SubscriptionRow[]> {
return db
.select()
.from(subscriptions)
.orderBy(desc(subscriptions.period_start));
},
async findById(id: number): Promise<PaymentRow | null> {
async findById(id: number): Promise<SubscriptionRow | null> {
const rows = await db
.select()
.from(payments)
.where(eq(payments.id, id))
.from(subscriptions)
.where(eq(subscriptions.id, id))
.limit(1);
return rows[0] ?? null;
},
async insert(values: {
kind: string;
paid_at: Date;
paid_amount: number;
remark: string;
period_start: Date | null;
period_end: Date | null;
subscription_id: number | null;
quota_granted: number | null;
}): Promise<PaymentRow> {
const [row] = await db.insert(payments).values(values).returning();
async findBySubscriptionId(
subscriptionId: number,
): Promise<SubscriptionRow | null> {
const rows = await db
.select()
.from(subscriptions)
.where(eq(subscriptions.subscription_id, subscriptionId))
.limit(1);
return rows[0] ?? null;
},
async insert(
values: typeof subscriptions.$inferInsert,
): Promise<SubscriptionRow> {
const [row] = await db.insert(subscriptions).values(values).returning();
return row!;
},
async update(id: number, patch: Partial<typeof payments.$inferInsert>) {
async update(
id: number,
patch: Partial<typeof subscriptions.$inferInsert>,
) {
const [row] = await db
.update(payments)
.update(subscriptions)
.set(patch)
.where(eq(payments.id, id))
.where(eq(subscriptions.id, id))
.returning();
return row!;
},
async remove(id: number): Promise<void> {
await db.delete(payments).where(eq(payments.id, id));
await db.delete(subscriptions).where(eq(subscriptions.id, id));
},
},
topups: {
async listAll(): Promise<TopupRow[]> {
return db.select().from(topups).orderBy(desc(topups.paid_at));
},
async findById(id: number): Promise<TopupRow | null> {
const rows = await db
.select()
.from(topups)
.where(eq(topups.id, id))
.limit(1);
return rows[0] ?? null;
},
async findByUpstreamId(upstreamId: number): Promise<TopupRow | null> {
const rows = await db
.select()
.from(topups)
.where(eq(topups.upstream_id, upstreamId))
.limit(1);
return rows[0] ?? null;
},
async insert(values: typeof topups.$inferInsert): Promise<TopupRow> {
const [row] = await db.insert(topups).values(values).returning();
return row!;
},
async update(id: number, patch: Partial<typeof topups.$inferInsert>) {
const [row] = await db
.update(topups)
.set(patch)
.where(eq(topups.id, id))
.returning();
return row!;
},
async remove(id: number): Promise<void> {
await db.delete(topups).where(eq(topups.id, id));
},
},
@@ -597,20 +645,39 @@ export function createRepos(db: Db) {
);
},
/** 成员视图明细分页(恒 type=2) */
/** 成员视图明细分页(可 type 筛;默认全类型) */
async listMemberPage(params: {
member_id: number;
from: Date;
to: Date;
page: number;
page_size: number;
api_key_id?: number;
billing_source?: string | "none";
subscription_id?: number;
type?: number | "error";
}) {
const conds = [
eq(apiKeys.member_id, params.member_id),
gte(usageLogs.created_at, params.from),
lt(usageLogs.created_at, params.to),
eq(usageLogs.type, 2),
];
if (params.type === "error") {
conds.push(ne(usageLogs.type, 2));
} else if (typeof params.type === "number") {
conds.push(eq(usageLogs.type, params.type));
}
if (params.api_key_id != null) {
conds.push(eq(usageLogs.api_key_id, params.api_key_id));
}
if (params.billing_source === "none") {
conds.push(isNull(usageLogs.billing_source));
} else if (params.billing_source) {
conds.push(eq(usageLogs.billing_source, params.billing_source));
}
if (params.subscription_id != null) {
conds.push(eq(usageLogs.subscription_id, params.subscription_id));
}
const totalRows = await db
.select({ n: count() })
.from(usageLogs)
@@ -621,6 +688,7 @@ export function createRepos(db: Db) {
.select({
id: usageLogs.id,
api_key_id: usageLogs.api_key_id,
dedupe_key: usageLogs.dedupe_key,
created_at: usageLogs.created_at,
token_name: apiKeys.token_name,
model_name: usageLogs.model_name,
@@ -633,6 +701,16 @@ export function createRepos(db: Db) {
is_stream: usageLogs.is_stream,
billing_source: usageLogs.billing_source,
subscription_id: usageLogs.subscription_id,
user_id: usageLogs.user_id,
username: usageLogs.username,
channel: usageLogs.channel,
channel_name: usageLogs.channel_name,
token_id: usageLogs.token_id,
group_name: usageLogs.group_name,
ip: usageLogs.ip,
request_id: usageLogs.request_id,
upstream_request_id: usageLogs.upstream_request_id,
other: usageLogs.other,
log_synced_at: apiKeys.log_synced_at,
})
.from(usageLogs)
+47 -9
View File
@@ -117,20 +117,57 @@ export const usageLogs = pgTable(
],
);
export const payments = pgTable("payments", {
/** 订阅权益登记(原 payments.kind=subscription) */
export const subscriptions = pgTable(
"subscriptions",
{
id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(),
kind: text("kind").notNull(),
paid_at: timestamp("paid_at", { withTimezone: true, mode: "date" }).notNull(),
subscription_id: bigint("subscription_id", { mode: "number" }).notNull().unique(),
period_start: timestamp("period_start", {
withTimezone: true,
mode: "date",
}).notNull(),
period_end: timestamp("period_end", { withTimezone: true, mode: "date" }),
quota_granted: bigint("quota_granted", { mode: "number" }).notNull(),
paid_amount: numeric("paid_amount", {
precision: 18,
scale: 2,
mode: "number",
}).notNull(),
}),
paid_at: timestamp("paid_at", { withTimezone: true, mode: "date" }),
remark: text("remark").notNull().default(""),
period_start: timestamp("period_start", { withTimezone: true, mode: "date" }),
period_end: timestamp("period_end", { withTimezone: true, mode: "date" }),
subscription_id: bigint("subscription_id", { mode: "number" }),
quota_granted: bigint("quota_granted", { mode: "number" }),
created_at: timestamp("created_at", {
withTimezone: true,
mode: "date",
})
.notNull()
.defaultNow(),
},
(t) => [index("subscriptions_period_idx").on(t.period_start, t.period_end)],
);
/** 充值入账事件(原 payments.kind=topup;无 quota_granted 列) */
export const topups = pgTable("topups", {
id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(),
upstream_id: bigint("upstream_id", { mode: "number" }),
amount: numeric("amount", { precision: 18, scale: 6, mode: "number" }).notNull(),
money: numeric("money", {
precision: 18,
scale: 2,
mode: "number",
}).notNull(),
trade_no: text("trade_no"),
payment_method: text("payment_method"),
payment_provider: text("payment_provider"),
status: text("status"),
paid_at: timestamp("paid_at", { withTimezone: true, mode: "date" }).notNull(),
remark: text("remark").notNull().default(""),
created_at: timestamp("created_at", {
withTimezone: true,
mode: "date",
})
.notNull()
.defaultNow(),
});
export const qpuHistory = pgTable("qpu_history", {
@@ -207,7 +244,8 @@ export const engineStatus = pgTable("engine_status", {
export type MemberRow = typeof members.$inferSelect;
export type ApiKeyRow = typeof apiKeys.$inferSelect;
export type UsageLogRow = typeof usageLogs.$inferSelect;
export type PaymentRow = typeof payments.$inferSelect;
export type SubscriptionRow = typeof subscriptions.$inferSelect;
export type TopupRow = typeof topups.$inferSelect;
export type QpuHistoryRow = typeof qpuHistory.$inferSelect;
export type UserRow = typeof users.$inferSelect;
export type SessionRow = typeof sessions.$inferSelect;