feat: 支付登记从平台选择订阅——登录拉取预填,密码不落库

This commit is contained in:
2026-10-08 22:32:33 +08:00
parent 29e0317155
commit ed2ff0f950
8 changed files with 551 additions and 4 deletions
+5
View File
@@ -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;
+2
View File
@@ -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());
+143
View File
@@ -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;
}
+35
View File
@@ -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<PlatformLoginResult> {
const { data } = await adminHttp.post<PlatformLoginResult>(
"/platform/login",
body,
);
return data;
}
export async function listPlatformSubscriptions(
accessToken: string,
): Promise<PlatformSubscription[]> {
const { data } = await adminHttp.post<{ subscriptions: PlatformSubscription[] }>(
"/platform/subscriptions",
{ access_token: accessToken },
);
return data.subscriptions;
}
@@ -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() {
<a-radio value="topup">充值</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"
@@ -242,14 +279,24 @@ async function onSubmit() {
</a-form-item>
<a-alert v-if="error" type="error" show-icon :message="error" />
</a-form>
<PlatformSubscriptionPicker
v-model:open="pickerOpen"
@select="onPickSubscription"
/>
</a-modal>
</template>
<style scoped>
.pay-form__pick {
margin-bottom: 16px;
}
.pay-form__note {
margin-top: 4px;
font-size: 12px;
line-height: 20px;
color: rgba(0, 0, 0, 0.45);
}
</style>
@@ -0,0 +1,203 @@
<script setup lang="ts">
/**
* 订阅选择弹层:登录(仅一次/标签页)+ 列表选用,与登记表单解耦
*/
import { ref } from "vue";
import {
listPlatformSubscriptions,
loginPlatform,
type PlatformSubscription,
} from "@/api/admin/platform.ts";
import type { ApiError } from "@/api/http.ts";
import { formatDateTime } from "@/utils/time.ts";
const props = defineProps<{
open: boolean;
}>();
const emit = defineEmits<{
(e: "update:open", v: boolean): void;
(e: "select", sub: PlatformSubscription): void;
}>();
const TOKEN_KEY = "naku_platform_token";
const username = ref("");
const password = ref("");
const error = ref("");
const loading = ref(false);
const list = ref<PlatformSubscription[]>([]);
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 load() {
if (!username.value.trim() || !password.value || loading.value) return;
error.value = "";
loading.value = true;
try {
let token = readToken();
if (!token) {
const login = await loginPlatform({
username: username.value.trim(),
password: password.value,
});
token = login.access_token;
writeToken(token);
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) {
writeToken("");
error.value = "平台登录已失效——请重新输入密码";
} else {
error.value = err.message || "拉取失败";
}
list.value = [];
password.value = "";
} finally {
loading.value = false;
}
}
function onUse(s: PlatformSubscription) {
emit("select", s);
emit("update:open", false);
}
</script>
<template>
<a-modal
:open="open"
title="从平台选择订阅"
:footer="null"
:width="520"
destroy-on-hidden
@update:open="emit('update:open', $event)"
>
<template v-if="list.length === 0">
<p class="plat-pick__hint">
使用 NewAPI 控制台账号登录后拉取订阅,仅用于预填登记;密码不保存。
</p>
<a-form layout="vertical" @finish="load">
<a-form-item label="账号">
<a-input
v-model:value="username"
autocomplete="username"
@press-enter="load"
/>
</a-form-item>
<a-form-item label="密码">
<a-input-password
v-model:value="password"
autocomplete="current-password"
@press-enter="load"
/>
</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="plat-pick__list">
<div
v-for="s in list"
:key="s.id"
class="plat-pick__item"
@click="onUse(s)"
>
<div class="plat-pick__head">
<strong>订阅 #{{ s.id }}</strong>
<span class="plat-pick__status">{{ s.status }}</span>
</div>
<div class="plat-pick__meta">
{{ formatDateTime(s.start_time) }} ~
{{ s.end_time ? formatDateTime(s.end_time) : "进行中" }}
· 额度 {{ s.amount_total }}
</div>
</div>
</div>
</template>
</a-modal>
</template>
<style scoped>
.plat-pick__hint {
margin: 0 0 16px;
font-size: 12px;
line-height: 20px;
color: rgba(0, 0, 0, 0.45);
}
.plat-pick__list {
max-height: 360px;
overflow-y: auto;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.plat-pick__item {
padding: 12px 16px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
}
.plat-pick__item:hover {
background: #fafafa;
}
.plat-pick__item:last-child {
border-bottom: none;
}
.plat-pick__head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 4px;
}
.plat-pick__status {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.plat-pick__meta {
font-size: 12px;
color: rgba(0, 0, 0, 0.65);
font-variant-numeric: tabular-nums;
}
</style>
+12 -3
View File
@@ -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 参数被忽略的情况(实测)
+103
View File
@@ -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<UpstreamResult<PlatformLoginResult>> {
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<string, unknown>;
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<UpstreamResult<PlatformSubscription[]>> {
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);
}
}