From ed2ff0f9500f5e725d0a56ee03799534d47a2ee7 Mon Sep 17 00:00:00 2001 From: skycurtain Date: Thu, 8 Oct 2026 22:32:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E4=BB=98=E7=99=BB=E8=AE=B0?= =?UTF-8?q?=E4=BB=8E=E5=B9=B3=E5=8F=B0=E9=80=89=E6=8B=A9=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E2=80=94=E2=80=94=E7=99=BB=E5=BD=95=E6=8B=89=E5=8F=96=E9=A2=84?= =?UTF-8?q?=E5=A1=AB=EF=BC=8C=E5=AF=86=E7=A0=81=E4=B8=8D=E8=90=BD=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/api-schemas.ts | 5 + apps/server/src/app.ts | 2 + apps/server/src/routes/admin-platform.ts | 143 ++++++++++++ apps/web/src/api/admin/platform.ts | 35 +++ .../components/admin/payment-form-modal.vue | 49 ++++- .../admin/platform-subscription-picker.vue | 203 ++++++++++++++++++ docs/references/NewAPI-接口范围.md | 15 +- packages/core/src/upstream.ts | 103 +++++++++ 8 files changed, 551 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/routes/admin-platform.ts create mode 100644 apps/web/src/api/admin/platform.ts create mode 100644 apps/web/src/components/admin/platform-subscription-picker.vue diff --git a/apps/server/src/api-schemas.ts b/apps/server/src/api-schemas.ts index 160bb8a..2819da9 100644 --- a/apps/server/src/api-schemas.ts +++ b/apps/server/src/api-schemas.ts @@ -84,3 +84,8 @@ export const err422 = { description: "校验失败", content: { "application/json": { schema: errorSchema } }, } as const; + +export const err502 = { + description: "上游不可达", + content: { "application/json": { schema: errorSchema } }, +} as const; diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 4878119..71cc71d 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -4,6 +4,7 @@ import { ERR, appLogger } from "@new-api-key-usage/core"; import { createRepos, type Db } from "@new-api-key-usage/db"; import { jsonError, type AppVars } from "./middleware.ts"; import { adminSessionRoutes } from "./routes/admin-session.ts"; +import { adminPlatformRoutes } from "./routes/admin-platform.ts"; import { adminEngineRoutes } from "./routes/admin-engine.ts"; import { adminKeyRoutes } from "./routes/admin-keys.ts"; import { adminMemberRoutes } from "./routes/admin-members.ts"; @@ -118,6 +119,7 @@ export function createApp(opts: CreateAppOptions) { admin.route("/", adminUsageRoutes()); admin.route("/", adminPaymentRoutes()); admin.route("/", adminAuditRoutes()); + admin.route("/", adminPlatformRoutes()); app.route("/api/admin", admin); app.route("/api/member", memberViewRoutes()); diff --git a/apps/server/src/routes/admin-platform.ts b/apps/server/src/routes/admin-platform.ts new file mode 100644 index 0000000..592d4d2 --- /dev/null +++ b/apps/server/src/routes/admin-platform.ts @@ -0,0 +1,143 @@ +import { OpenAPIHono, createRoute } from "@hono/zod-openapi"; +import { z } from "zod"; +import { + ERR, + fetchPlatformSubscriptions, + platformLogin, +} from "@new-api-key-usage/core"; +import { jsonError, requireAdminSession, type AppVars } from "../middleware.ts"; +import { err401, err422, err502 } from "../api-schemas.ts"; + +const platformSubsSchema = z.object({ + subscriptions: z.array( + z.object({ + id: z.number().int(), + plan_id: z.number().int(), + start_time: z.number().int(), + end_time: z.number().int(), + status: z.string(), + amount_total: z.number(), + amount_used: z.number(), + }), + ), +}); + +const loginBody = z.object({ + username: z.string().min(1), + password: z.string().min(1), +}); + +const listBody = z.object({ + access_token: z.string().min(1), +}); + +/** + * 登记辅助:控制台账号登录 + 拉订阅。密码不落库;access_token 由前端短暂持有。 + */ +export function adminPlatformRoutes() { + const r = new OpenAPIHono<{ Variables: AppVars }>(); + r.use("*", requireAdminSession); + + r.openapi( + createRoute({ + method: "post", + path: "/platform/login", + summary: "登录 NewAPI 控制台(登记辅助)", + tags: ["支付"], + security: [{ adminSession: [] }], + request: { + body: { + content: { "application/json": { schema: loginBody } }, + required: true, + }, + }, + responses: { + 200: { + description: "access_token", + content: { + "application/json": { + schema: z.object({ access_token: z.string() }), + }, + }, + }, + 401: err401, + 422: err422, + 502: err502, + }, + }), + async (c) => { + const body = await c.req.json().catch(() => null); + const username = String(body?.username ?? ""); + const password = String(body?.password ?? ""); + if (!username || !password) { + return jsonError( + c, + 422, + ERR.validation_failed({ + username: username ? "" : "请填写用户名", + password: password ? "" : "请填写密码", + }), + ); + } + const login = await platformLogin(c.get("newapiBase"), username, password); + if (!login.ok) { + if (login.kind === "rejected") return jsonError(c, 422, ERR.invalid_credentials()); + if (login.kind === "unreachable") return jsonError(c, 502, ERR.upstream_unreachable()); + return jsonError( + c, + 422, + ERR.validation_failed({ + password: "登录未成功——可能需验证码/加密登录,或账号密码不正确", + }), + ); + } + return c.json({ access_token: login.data.access_token }, 200); + }, + ); + + r.openapi( + createRoute({ + method: "post", + path: "/platform/subscriptions", + summary: "拉取订阅列表(登记辅助)", + tags: ["支付"], + security: [{ adminSession: [] }], + request: { + body: { + content: { "application/json": { schema: listBody } }, + required: true, + }, + }, + responses: { + 200: { + description: "subscriptions", + content: { "application/json": { schema: platformSubsSchema } }, + }, + 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 subs = await fetchPlatformSubscriptions(c.get("newapiBase"), token); + if (!subs.ok) { + if (subs.kind === "rejected") { + return jsonError(c, 401, ERR.session_expired()); + } + return jsonError(c, 502, ERR.upstream_unreachable()); + } + return c.json({ subscriptions: subs.data }, 200); + }, + ); + + return r; +} diff --git a/apps/web/src/api/admin/platform.ts b/apps/web/src/api/admin/platform.ts new file mode 100644 index 0000000..20ed4a9 --- /dev/null +++ b/apps/web/src/api/admin/platform.ts @@ -0,0 +1,35 @@ +import { adminHttp } from "../http.ts"; + +export type PlatformSubscription = { + id: number; + plan_id: number; + start_time: number; + end_time: number; + status: string; + amount_total: number; + amount_used: number; +}; + +export type PlatformLoginResult = { access_token: string }; + +/** 登录控制台(密码不落库);token 由调用方短暂保存 */ +export async function loginPlatform(body: { + username: string; + password: string; +}): Promise { + const { data } = await adminHttp.post( + "/platform/login", + body, + ); + return data; +} + +export async function listPlatformSubscriptions( + accessToken: string, +): Promise { + const { data } = await adminHttp.post<{ subscriptions: PlatformSubscription[] }>( + "/platform/subscriptions", + { access_token: accessToken }, + ); + return data.subscriptions; +} diff --git a/apps/web/src/components/admin/payment-form-modal.vue b/apps/web/src/components/admin/payment-form-modal.vue index 560d3fc..c23716f 100644 --- a/apps/web/src/components/admin/payment-form-modal.vue +++ b/apps/web/src/components/admin/payment-form-modal.vue @@ -7,8 +7,15 @@ import type { Dayjs } from "dayjs"; import dayjs from "dayjs"; import type { PaymentItem } from "@new-api-key-usage/core"; import type { ApiError } from "@/api/http.ts"; -import { useCreatePayment, useQpuLatest, useUpdatePayment } from "@/queries/admin.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"; const props = defineProps<{ open: boolean; @@ -23,6 +30,7 @@ const emit = defineEmits<{ const createPay = useCreatePayment(); const updatePay = useUpdatePayment(); const qpuQuery = useQpuLatest(); +const paymentsQuery = usePayments(); const qpu = computed(() => qpuQuery.data.value?.qpu ?? null); const form = reactive({ @@ -38,6 +46,17 @@ const form = reactive({ 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); @@ -122,6 +141,16 @@ async function onSubmit() { error.value = "订阅必须填写订阅编号"; return; } + // 一笔订阅只应登记一条支付记录;改错走「编辑」 + const dup = (paymentsQuery.data.value ?? []).find( + (p) => + p.subscription_id === form.subscription_id && + p.id !== (props.editing?.id ?? -1), + ); + if (dup) { + error.value = `订阅 #${form.subscription_id} 已有支付登记——请编辑原记录,不要重复登记`; + return; + } } const body = { kind: form.kind, @@ -172,6 +201,14 @@ async function onSubmit() { 充值 + + 从平台选择订阅(自动填写编号、期间与额度) + + diff --git a/apps/web/src/components/admin/platform-subscription-picker.vue b/apps/web/src/components/admin/platform-subscription-picker.vue new file mode 100644 index 0000000..2dce22d --- /dev/null +++ b/apps/web/src/components/admin/platform-subscription-picker.vue @@ -0,0 +1,203 @@ + + + + + diff --git a/docs/references/NewAPI-接口范围.md b/docs/references/NewAPI-接口范围.md index 5f980cd..023d84e 100644 --- a/docs/references/NewAPI-接口范围.md +++ b/docs/references/NewAPI-接口范围.md @@ -277,9 +277,18 @@ quota = ( (prompt_tokens − cache_tokens) ← 提示 tokens(未 ### 4.3 订阅信息 -- **没有免 auth 的接口可直接查询**(作者实测,2026-09-13);登录态的「钱包」页面可见——显示订阅编号(形如「订阅 #N」)、状态(生效中 / 已过期 + 过期时间)、总额度与已用 / 剩余 -- 订阅编号**推测**与日志 `other.subscription_id` 为同一体系(实现期以真实数据核对) -- 对应设计:订阅信息由购买者手工录入(见 `docs/design/payments表字段设计.md`) +- **存在完整列表接口,但鉴权是 `UserAuth()`(账号登录态),不是 sk-key**(源码 `router/api-router.go` · `controller/subscription.go:GetSubscriptionSelf`,2026-10-08 核对): + + | 路径 | 鉴权 | 能力 | + |------|------|------| + | `GET /api/subscription/self` | `UserAuth()` | 返回 `all_subscriptions`(含已过期):`id`、`start_time`/`end_time`(Unix 秒)、`status`、`amount_total`/`amount_used`、`created_at` 等;另有 `subscriptions`、`billing_preference` | + | `GET /api/subscription/plans` | `UserAuth()` | 套餐列表 | + | `/api/subscription/admin/*` | `AdminAuth()` | 管理端绑定/作废等,与本项目无关 | + +- **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 预填(见支付表单),**不引入账号密码自动化** ### 4.4 参数被忽略的情况(实测) diff --git a/packages/core/src/upstream.ts b/packages/core/src/upstream.ts index 7c99fe0..2707708 100644 --- a/packages/core/src/upstream.ts +++ b/packages/core/src/upstream.ts @@ -138,3 +138,106 @@ export function normalizeExpiresAt(unixSeconds: number): Date | null { if (!unixSeconds) return null; return new Date(unixSeconds * 1000); } + +/** 平台账号订阅(GET /api/subscription/self → all_subscriptions) */ +export interface PlatformSubscription { + id: number; + plan_id: number; + start_time: number; + end_time: number; + status: string; + amount_total: number; + amount_used: number; +} + +export interface PlatformLoginResult { + access_token: string; +} + +/** + * 登录 NewAPI 控制台账号(UserAuth 体系,非 sk-key)。 + * 密码与 access_token 均不落库,仅本次代拉使用。 + */ +export async function platformLogin( + baseUrl: string, + username: string, + password: string, + timeoutMs = 8_000, +): Promise> { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), timeoutMs); + try { + const res = await fetch(joinUrl(baseUrl, "/api/user/login"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ username, password }), + 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?: PlatformLoginResult & Record; + message?: string; + }; + if (body.code === false || body.success === false) { + return { ok: false, kind: "rejected" }; + } + const token = body.data?.access_token; + if (typeof token !== "string" || !token) { + return { ok: false, kind: "http", status: res.status }; + } + return { ok: true, data: { access_token: token } }; + } catch { + return { ok: false, kind: "unreachable" }; + } finally { + clearTimeout(timer); + } +} + +/** 用控制台 access_token 拉订阅列表(含已过期) */ +export async function fetchPlatformSubscriptions( + baseUrl: string, + accessToken: string, + timeoutMs = 8_000, +): Promise> { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), timeoutMs); + try { + const res = await fetch(joinUrl(baseUrl, "/api/subscription/self"), { + 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?: { + all_subscriptions?: Array<{ subscription?: PlatformSubscription }>; + }; + }; + if (body.code === false || body.success === false) { + return { ok: false, kind: "rejected" }; + } + const raw = body.data?.all_subscriptions ?? []; + const list: PlatformSubscription[] = []; + for (const item of raw) { + const s = item?.subscription; + if (s && typeof s.id === "number") list.push(s); + } + return { ok: true, data: list }; + } catch { + return { ok: false, kind: "unreachable" }; + } finally { + clearTimeout(timer); + } +}