feat(api): 实装 /api/status 接口——无鉴权公开接口,换算比率权威来源

- endpoints:AuthMode 扩展 'none'(完全公开),追加 /api/status 定义(考证实证文案 + 实测示例响应),置于列表首位
- endpoint-info-line:鉴权徽标三态化(公开/宽松/严格,map 驱动 + none 中性样式)
- endpoint-panel:requiresKey——无鉴权接口隐藏 sk-key 输入行、按钮不因空 key 禁用、空态文案区分
- spec:接口数 5→6、顺序、鉴权分布(none+strict×3+lenient×2)断言更新
- docs:接口文档同步 479 行新版(/api/status 详解 + QuotaPerUnit 考证);交互设计 §1 清单加第 0 行;README 与 side-nav 计数更新
- 验证:test 64/64 + type-check + lint + build 四绿
This commit is contained in:
2026-09-09 01:27:02 +08:00
parent 19b69e2d5a
commit 86d2ac304b
8 changed files with 143 additions and 28 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ bun run build # type-check + vite build(dist/)
## 使用
1. 左侧选择接口(5 个,平铺不分组)→ 右侧开 tab
1. 左侧选择接口(6 个,平铺不分组)→ 右侧开 tab
2. tab 内展开「注意事项」看该接口的实证调研坑文档
3. 填 sk-key(每 tab 独立、仅存内存、刷新即重置,不落盘;切换 tab 输入保留)
4. 点「发送请求」→ 响应区显示元信息(状态/耗时/字符数)+ 虚拟滚动高亮 JSON
+52 -2
View File
@@ -37,6 +37,14 @@ Authorization: Bearer sk-xxxxxxxxxxxx
|------|-----|------|
| `QuotaPerUnit` | 500000 | 实测验证:接口① `total_used` ÷ 500000 = 接口④ `total_usage` ÷ 100 |
**此值是 NewAPI 的默认值(源码 `common/constants.go:22`:`var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens`),并非硬编码定律**:
- 它是 `var` 且挂入 OptionMap(`model/option.go:166`),站长可在后台修改(`model/option.go:605` 会用数据库值覆盖)
- 接口①④交叉实测吻合的原因:billing 接口内部就是拿 quota `÷ QuotaPerUnit` 换算(`controller/billing.go:50`),同一常量自证,**吻合验证不了管理员是否改过此值**
- **正确做法:换算比率不硬编码,从 `/api/status` 的 `quota_per_unit` 字段读取**(见接口⓪),启动时拉取并定期校验,站点改配置后工具自动跟随
> `500000` 的设计来由:NewAPI(继承自 One API)以 OpenAI 早期基准定价 **$0.002 / 1K tokens** 为锚点,内部用整数 quota 计费避免浮点误差,1 quota = $0.000002,反推 1 美元 = 500,000 quota。
### 关键开关:DisplayTokenStatEnabled
| 接口 | 受开关影响? |
@@ -54,6 +62,7 @@ Authorization: Bearer sk-xxxxxxxxxxxx
| 序号 | 方法 | 路径 | 鉴权 | 用途 |
|------|------|------|------|------|
| ⓪ | GET | `/api/status` | **无(完全公开)** | 站点配置与计费参数(含 `quota_per_unit`),工具校准换算比率的依据 |
| ① | GET | `/api/usage/token` | TokenAuthReadOnly | 查 token 的累计已用/剩余/总额(原始 quota) |
| ② | GET | `/api/log/token` | TokenAuthReadOnly | 查该 key 最近 1000 条调用日志 |
| ③ | GET | `/v1/dashboard/billing/subscription` | TokenAuth | 查总额度上限(OpenAI 兼容,美元) |
@@ -73,6 +82,45 @@ Authorization: Bearer sk-xxxxxxxxxxxx
## 2. 用量/额度查询接口详解
### ⓪ GET `/api/status` - 站点配置与计费参数(完全公开)
**鉴权**:无。挂载于 `router/api-router.go:26`,在鉴权中间件组之外,任何人(不持有任何 key)可直接访问。本清单中唯一无需凭证的接口。
**请求参数**:无
**请求示例**:
```
GET /api/status
```
**返回结构**(源码:`controller/misc.go:44` -> `GetStatus`):外层为 `{ success, message, data }`,`data` 含 60+ 字段(版本、登录方式、前端模块开关、公告等)。**与本项目相关的计费字段**:
| 字段 | 类型 | 说明 | 实测值(2026-09-09) |
|------|------|------|------|
| `quota_per_unit` | float | **QuotaPerUnit 当前生效值**。quota ÷ 该值 = 美元。管理员可改,工具不得硬编码 | `500000` |
| `quota_display_type` | string | 站点额度展示模式:`USD` / `CNY` / `TOKENS`。影响 billing 接口(③④)的语义 | `"USD"` |
| `usd_exchange_rate` | float | USD→CNY 展示汇率(`quota_display_type=CNY` 时 billing 接口用它换算) | `6.8` |
| `display_in_currency` | bool | 是否以货币展示额度(旧字段,兼容保留) | `true` |
| `custom_currency_symbol` | string | 自定义货币符号(`¤` = 未自定义) | `"¤"` |
| `custom_currency_exchange_rate` | float | 自定义货币汇率 | `1` |
| `price` | float | 充值价格(人民币/单位),站点充值汇率相关 | `6.8` |
| `version` | string | NewAPI 版本号 | `"v1.0.0-rc.35"` |
**对本项目的意义**:
1. **校准换算比率**:`quota_per_unit` 是工具换算美元的唯一权威来源——管理员改配置后,工具启动时拉取 + 定期校验即可自动跟随,不会算错
2. **感知展示模式切换**:若管理员把 `quota_display_type` 切到 `CNY` 或 `TOKENS`,billing 接口(③④)返回的数字语义会变(CNY 是先 ÷QuotaPerUnit 再 ×`usd_exchange_rate`;TOKENS 直接返回 token 数),工具可提前探测到,避免拿旧逻辑解析新语义
3. **零成本**:完全公开、无鉴权、无调用门槛,可放心定期轮询
**注意事项**:
- 返回字段很多且含大量前端 UI 配置(`HeaderNavModules`、`SidebarModulesAdmin` 等),解析时只取计费相关字段,其余忽略
- 字段集合随版本演进(如开启公告开关会追加 `announcements`),解析需容忍未知字段
- 该接口不泄露敏感信息(secret 类 OptionMap 键已被 NewAPI 过滤),这也是它敢完全公开的原因
---
### ① GET `/api/usage/token` - token 级用量汇总
**鉴权**:`TokenAuthReadOnly()`(宽松版,key 过期/耗尽也能查)
@@ -364,7 +412,7 @@ total_usage = 134683.8914 美分 ÷ 100 = 1346.84 美元
### 核心换算公式
```
原始 quota ÷ 500000 = 美元
原始 quota ÷ quota_per_unit(/api/status 实时读取,当前站点值 500000)= 美元
```
### 实测数据验证
@@ -423,7 +471,9 @@ total_usage = 134683.8914 美分 ÷ 100 = 1346.84 美元
| billing 查询 | `controller/billing.go:75` | `GetUsage` |
| 显示开关 | `common/constants.go` | `DisplayTokenStatEnabled` |
| 日志上限 | `common/constants.go` | `MaxRecentItems = 1000` |
| 配额单位 | `common/constants.go` | `QuotaPerUnit`(默认 500000) |
| 配额单位 | `common/constants.go:22` | `QuotaPerUnit`(var,默认 500000,`// $0.002 / 1K tokens`,可被 OptionMap 覆盖) |
| 配额单位(覆盖入口) | `model/option.go:605` | `case "QuotaPerUnit"` 运行时改写 |
| 站点配置暴露 | `controller/misc.go:44` + `router/api-router.go:26` | `GetStatus`(`/api/status`,无鉴权,含 `quota_per_unit`) |
| token 更新 | `model/token.go` | `Update()`(Select 不含 used_quota) |
| 额度增减 | `model/token.go` | `increaseTokenQuota` / `decreaseTokenQuota` |
+5 -4
View File
@@ -8,20 +8,21 @@
## 1. 项目定位
替代 Postman 的专用调试台(bench):左侧选接口 → 右侧 tab 内看文档、填 key、发请求、看原始响应。对接 5 个接口,服务 karpool 拼车项目的接口调试与换算验证。
替代 Postman 的专用调试台(bench):左侧选接口 → 右侧 tab 内看文档、填 key、发请求、看原始响应。对接 6 个接口,服务 karpool 拼车项目的接口调试与换算验证。
**接口清单**(数据源:`docs/NewAPI-sk-key接口文档.md`,按此顺序展示,不分组):
| 序 | 路径 | 鉴权 | 响应特征 |
|---|------|------|---------|
| 0 | `GET /api/status` | 无(完全公开) | 站点配置与计费参数:`quota_per_unit`(换算比率权威来源)、`quota_display_type`、汇率等 |
| 1 | `GET /v1/dashboard/billing/subscription` | TokenAuth(严格) | 小 JSON,美元额度(无限额时 1 亿占位) |
| 2 | `GET /v1/dashboard/billing/usage` | TokenAuth(严格) | 小 JSON,累计美分 |
| 3 | `GET /v1/models` | TokenAuth(严格) | 中等 JSON,模型数组 |
| 4 | `GET /api/usage/token` | TokenAuthReadOnly(宽松) | 小 JSON,累计 quota |
| 5 | `GET /api/log/token` | TokenAuthReadOnly(宽松) | **大响应**:最近 1000 条日志,pretty-print 2~4MB |
> 4、5 宽松鉴权(key 过期/耗尽仍可查);1~3 严格鉴权(key 无效直接被拒)。tab 内标出该差异,避免误判「接口坏了」。
> 4 与 2 构成换算交叉验证对(total_used ÷ 500000 ≈ total_usage ÷ 100),Tabs 并行对账是核心工作流。
> 0 完全公开(无需任何凭证,面板隐藏 sk-key 输入行);4、5 宽松鉴权(key 过期/耗尽仍可查);1~3 严格鉴权(key 无效直接被拒)。tab 内标出该差异,避免误判「接口坏了」。
> 4 与 2 构成换算交叉验证对(total_used ÷ 500000 ≈ total_usage ÷ 100),Tabs 并行对账是核心工作流。0 是换算比率的权威来源(quota_per_unit 可被管理员修改,500000 只是默认值)。
---
@@ -52,7 +53,7 @@
无分组无编号,按清单顺序)
```
- **左 sider**(~300px):接口列表,**不分组、不编号**,按 §1 清单顺序平铺 5 个路径;选中态高亮
- **左 sider**(~300px):接口列表,**不分组、不编号**,按 §1 清单顺序平铺 6 个路径;选中态高亮
- **右主区**:`t-tabs` 顶部,激活 tab 内容 = 请求行(method + 路径 + 鉴权徽标)→ 调试行(sk-key 输入 + 发送按钮)→ 响应区(元信息 + 虚拟滚动 JSON)
- 暗色默认(TDesign dark theme + CSS 变量)
+7 -5
View File
@@ -3,17 +3,18 @@ import { describe, expect, it } from 'vitest';
import { endpointList, findEndpointByPath } from './endpoints';
describe('endpointList 数据完整性', () => {
it('恰好 5 个接口(设计文档 §1 定案的对接清单)', () => {
expect(endpointList).toHaveLength(5);
it('恰好 6 个接口(设计文档 §1 定案的对接清单)', () => {
expect(endpointList).toHaveLength(6);
});
it('路径唯一(path 是 tab / queryKey 的唯一标识)', () => {
const pathList = endpointList.map((endpoint) => endpoint.path);
expect(new Set(pathList).size).toBe(5);
expect(new Set(pathList).size).toBe(6);
});
it('顺序与用户指定清单一致', () => {
expect(endpointList.map((endpoint) => endpoint.path)).toEqual([
'/api/status',
'/v1/dashboard/billing/subscription',
'/v1/dashboard/billing/usage',
'/v1/models',
@@ -25,13 +26,14 @@ describe('endpointList 数据完整性', () => {
it('全部为 GET 且带鉴权元数据', () => {
for (const endpoint of endpointList) {
expect(endpoint.method).toBe('GET');
expect(['strict', 'lenient']).toContain(endpoint.authMode);
expect(['strict', 'lenient', 'none']).toContain(endpoint.authMode);
expect(endpoint.authMiddlewareName).toBeTruthy();
}
});
it('严格/宽松鉴权分布正确(前 3 严格,后 2 宽松)', () => {
it('鉴权模式分布正确(公开 1 + 严格 3 + 宽松 2)', () => {
expect(endpointList.map((endpoint) => endpoint.authMode)).toEqual([
'none',
'strict',
'strict',
'strict',
+32 -3
View File
@@ -1,17 +1,17 @@
/**
* 5 个对接接口的静态元数据
* 对接接口的静态元数据
* 数据源:docs/NewAPI-sk-key接口文档.md(源码实证调研)
* 顺序即 sider / tab 展示顺序(设计文档 §1 定案,不分组不编号)
*/
export type AuthMode = 'strict' | 'lenient';
export type AuthMode = 'strict' | 'lenient' | 'none';
export interface EndpointDefinition {
/** 接口路径(唯一标识,同时用作 tab 标签与 queryKey 组成部分) */
readonly path: string;
/** HTTP 方法(当前全部为 GET,保留扩展位) */
readonly method: 'GET';
/** 鉴权模式:strict = TokenAuth(key 无效/过期/耗尽直接被拒);lenient = TokenAuthReadOnly(宽松,废 key 也能查) */
/** 鉴权模式:strict = TokenAuth(key 无效/过期/耗尽直接被拒);lenient = TokenAuthReadOnly(宽松,废 key 也能查);none = 完全公开(无需任何凭证) */
readonly authMode: AuthMode;
/** 鉴权中间件名(展示用) */
readonly authMiddlewareName: string;
@@ -24,6 +24,35 @@ export interface EndpointDefinition {
}
export const endpointList: readonly EndpointDefinition[] = [
{
path: '/api/status',
method: 'GET',
authMode: 'none',
authMiddlewareName: '无(公开接口)',
responseSummary: '站点配置与计费参数(含 quota_per_unit)',
notes: [
'完全公开:无任何鉴权,不持有 sk-key 也能访问(本工具对接清单中唯一)',
'quota_per_unit 是 QuotaPerUnit 当前生效值(默认 500000,源码 $0.002 / 1K tokens 锚点)——quota ÷ 该值 = 美元,换算比率的唯一权威来源,管理员可改,不要硬编码',
'接口①④交叉实测吻合验证不了管理员是否改过此值:billing 接口内部就是拿 quota ÷ QuotaPerUnit 换算,同一常量自证',
'quota_display_type 影响 billing 接口语义:USD 直接是美元;CNY 先 ÷QuotaPerUnit 再 ×usd_exchange_rate;TOKENS 是原始 token 数',
'返回 60+ 字段(版本、登录方式、前端模块开关、公告等),解析时只取计费相关字段,其余忽略;字段集合随版本演进,需容忍未知字段',
],
sampleResponse: {
success: true,
message: '',
data: {
quota_per_unit: 500000,
quota_display_type: 'USD',
usd_exchange_rate: 6.8,
display_in_currency: true,
custom_currency_symbol: '¤',
custom_currency_exchange_rate: 1,
price: 6.8,
system_name: 'AI Gateway',
version: 'v1.0.0-rc.35',
},
},
},
{
path: '/v1/dashboard/billing/subscription',
method: 'GET',
+27 -7
View File
@@ -1,18 +1,33 @@
<script setup lang="ts">
/**
* 接口信息行:method 徽标 + 路径 + 鉴权徽标(宽松/严格)
* 接口信息行:method 徽标 + 路径 + 鉴权徽标(公开/宽松/严格)
*/
import type { EndpointDefinition } from '@/api/endpoints';
const props = defineProps<{ endpoint: EndpointDefinition }>();
const authBadgeText = props.endpoint.authMode === 'lenient' ? '宽松' : '严格';
const authBadgeTitle = `${props.endpoint.authMiddlewareName}(${
props.endpoint.authMode === 'lenient'
? 'key 过期/禁用/额度耗尽仍可查询'
: 'key 无效/过期/额度耗尽会被拒绝'
})`;
const authBadgeTextMap: Record<string, string> = {
lenient: '宽松',
strict: '严格',
none: '公开',
};
const authBadgeTitleMap: Record<string, string> = {
lenient: 'TokenAuthReadOnly(key 过期/禁用/额度耗尽仍可查询)',
strict: 'TokenAuth(key 无效/过期/额度耗尽会被拒绝)',
none: '无鉴权中间件(不持有任何 key 也能访问)',
};
const authBadgeText = authBadgeTextMap[props.endpoint.authMode] ?? '未知';
const authBadgeTitle =
props.endpoint.authMode === 'none'
? authBadgeTitleMap.none
: `${props.endpoint.authMiddlewareName}(${
props.endpoint.authMode === 'lenient'
? 'key 过期/禁用/额度耗尽仍可查询'
: 'key 无效/过期/额度耗尽会被拒绝'
})`;
</script>
<template>
@@ -64,6 +79,11 @@ const authBadgeTitle = `${props.endpoint.authMiddlewareName}(${
border: 1px solid var(--td-success-color);
}
.auth-badge.none {
color: var(--td-text-color-secondary);
border: 1px solid var(--td-component-border);
}
.response-summary {
font-size: 12px;
color: var(--td-text-color-placeholder);
+18 -5
View File
@@ -44,12 +44,17 @@ const {
sendRequest,
} = useManualEndpointQuery(endpointPathRef, skKeyInput);
/** 无鉴权接口(如 /api/status)不需要 sk-key:隐藏输入行、按钮不因空 key 禁用 */
const requiresKey = computed(() => endpoint.value.authMode !== 'none');
const keyInputStatus = computed(() =>
hasSent.value && !skKeyInput.value.trim() ? 'error' : 'default',
hasSent.value && requiresKey.value && !skKeyInput.value.trim() ? 'error' : 'default',
);
const sendButtonDisabled = computed(() => requiresKey.value && !skKeyInput.value.trim());
function handleSend(): void {
if (!skKeyInput.value.trim()) return; // 空 key 不发(输入框已标红提示)
if (sendButtonDisabled.value) return; // 空 key 不发(输入框已标红提示);无鉴权接口直接发
sendRequest();
}
@@ -100,10 +105,11 @@ function downloadResponseAsFile(): void {
</t-collapse>
</section>
<!-- 调试行:sk-key + 发送 -->
<!-- 调试行:sk-key + 发送(无鉴权接口隐藏 key 输入) -->
<section class="panel-section">
<div class="request-row">
<t-input
v-if="requiresKey"
v-model="skKeyInput"
class="sk-key-input"
type="password"
@@ -113,10 +119,11 @@ function downloadResponseAsFile(): void {
:tips="keyInputStatus === 'error' ? '请填写 sk-key' : ''"
@enter="handleSend"
/>
<span v-else class="no-key-hint">公开接口,无需 sk-key</span>
<t-button
theme="primary"
:loading="isFetching"
:disabled="!skKeyInput.trim()"
:disabled="sendButtonDisabled"
@click="handleSend"
>
发送请求
@@ -156,7 +163,7 @@ function downloadResponseAsFile(): void {
<JsonTextView :json="responseData.json" :raw-text="responseData.text" />
</section>
<section v-else-if="!hasSent" class="panel-section response-placeholder">
填写 sk-key 后点击「发送请求」
{{ requiresKey ? '填写 sk-key 后点击「发送请求」' : '点击「发送请求」查看站点配置' }}
</section>
</div>
</template>
@@ -186,6 +193,12 @@ function downloadResponseAsFile(): void {
max-width: 480px;
}
.no-key-hint {
align-self: center;
font-size: 12.5px;
color: var(--td-text-color-placeholder);
}
.response-section {
flex: 1;
min-height: 220px; /* 响应区保底高度:窄窗口下不被上部固定分区压缩到不可用 */
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* 左侧接口导航:5 个路径平铺(不分组不编号,按 endpoints.ts 定案顺序)
* 左侧接口导航:路径平铺(不分组不编号,按 endpoints.ts 定案顺序)
* 点击 → openOrActivateTab(已开则激活,未开则新开)
*/