fix: 完善 API Token 验收细节

This commit is contained in:
2026-05-23 03:09:40 +08:00
parent 4784ed36ca
commit 68e297b0ad
5 changed files with 31 additions and 11 deletions
+2
View File
@@ -225,6 +225,7 @@ API Token 保存在本地 `api_tokens.json`,只保存 HMAC 哈希和显示用
## API Token 与文档
登录后进入左侧 `API 中心` 可生成、停用、设置永久或指定日期到期的 API Token,并查看内置 API 文档。API 文档基于本地 `swagger-ui-dist` 打包,不依赖外网 CDN。
如果 Token 未设置为永久有效,则必须明确选择到期日期;到期、停用或重生成后的旧 Token 都不能继续调用业务 API。
Token 调用方式:
```text
@@ -237,6 +238,7 @@ X-API-Token: <token>
```
API Token 与登录态一样可访问业务 API,包括文件上传、远程下载并处理、数据库表查询、筛选查询、导出以及 `/api/database/execute` 自定义 SQL 执行。Token 管理、系统配置、授权和 API 文档本身仍要求登录后访问。
桌面端和配置了 `VITE_API_BASE` 的部署中,API 文档会自动使用当前后端基址加载 OpenAPI,并且不会覆盖用户在 Swagger UI 中手动填写的 API Token。
授权到期日期保存在本地加密文件 `license.dat`,默认到期日由 `app/services/license.py` 中的 `DEFAULT_EXPIRES_ON` 控制,当前为 `2026-06-20`。处理任务不会读取系统日期,而是从任务目录 ZIP 文件名中的 `YYYYMMDDHHMM` 或 `YYYYMMDDHHMMSS` 时间戳取最大日期进行比对。登录后连续点击左上角品牌图标 8 次,可主动打开授权延期窗口。
+12 -6
View File
@@ -18,6 +18,16 @@ def _bad_expiration_error(exc: ValueError) -> HTTPException:
return HTTPException(status_code=400, detail="到期日期格式无效,请使用 YYYY-MM-DD 或 ISO 日期时间")
def _resolve_expires_at(payload: dict[str, Any]) -> str | None:
if bool(payload.get("permanent", False)):
return None
expires_at = str(payload.get("expires_at") or "").strip()
if not expires_at:
raise HTTPException(status_code=400, detail="请选择 Token 到期日期,或设置为永久有效")
return expires_at
@router.get("/api/tokens")
async def get_tokens(request: Request):
_require_login(request)
@@ -28,18 +38,14 @@ async def get_tokens(request: Request):
async def create_api_token(request: Request, payload: dict[str, Any] = Body(...)):
_require_login(request)
name = str(payload.get("name", "")).strip()
expires_at = payload.get("expires_at")
enabled = bool(payload.get("enabled", True))
permanent = bool(payload.get("permanent", False))
expires_in_days = payload.get("expires_in_days")
raw_expires_at = None if permanent else (str(expires_at).strip() if expires_at else None)
raw_expires_at = _resolve_expires_at(payload)
try:
raw_token, record = create_token(
name=name,
expires_at=raw_expires_at,
enabled=enabled,
expires_in_days=int(expires_in_days) if expires_in_days is not None else None,
)
except ValueError as exc:
raise _bad_expiration_error(exc) from exc
@@ -63,7 +69,7 @@ async def update_api_token(request: Request, payload: dict[str, Any] = Body(...)
token_id,
name=payload.get("name"),
enabled=payload.get("enabled"),
expires_at=None if payload.get("permanent") else payload.get("expires_at"),
expires_at=_resolve_expires_at(payload),
)
return {"success": True, "message": "API Token 已更新", "record": record}
except ValueError as exc:
+2
View File
@@ -1,6 +1,8 @@
# 项目上下文记录
## 2026-05-23:新增 API Token 和离线 API 文档
- API Token 验收补强:非永久 Token 必须明确传入到期日期,非法或缺失到期日期返回 400;到期、停用、重生成旧 Token 均会拒绝业务 API。已实测 API Token 可执行 `/api/database/execute` 和 `/api/database/table/query`,但不能访问 `/api/tokens` 管理接口。
- `frontend/src/components/ApiCenter.vue` 的 Swagger UI 改为通过 `apiUrl('/api/openapi.json')` 加载文档,并在请求拦截器中只在未手动填写 Authorization 时补登录 JWT;桌面端或配置 `VITE_API_BASE` 时,Try it out 请求会自动补全后端基址,避免相对 `/api/*` 请求打到错误 origin。
- 新增 `app/services/api_tokens.py` 和 `app/api/routers/api_tokens.py`:API Token 存储在运行时 `api_tokens.json`,只保存 HMAC-SHA256 哈希、前后缀、启用状态、到期时间和最近使用信息;完整 Token 仅在创建或重生成时返回一次。`api_tokens.json` 已加入 `.gitignore`。
- `app/auth.py` 增加登录态和访问态解析:登录态只接受 JWT/cookie,业务访问态接受登录 JWT、`Authorization: Bearer <api-token>` 和 `X-API-Token`。Token 管理、系统配置、授权和 API 文档仍要求登录后访问。
- `app/main.py` 的全局鉴权中间件改为 JWT + API Token 双鉴权;业务 API 可由 API Token 调用,`/api/openapi.json` 和 `/api/docs-info` 仅登录后可见。OpenAPI schema 同时声明 `BearerAuth` 和 `ApiTokenHeader`,便于外部系统对接。
+5 -1
View File
@@ -357,13 +357,17 @@ function parseFilename(disposition: string | null): string {
return plainMatch?.[1] || '';
}
function apiUrl(url: string): string {
export function apiUrl(url: string): string {
if (!API_BASE || /^https?:\/\//i.test(url)) {
return url;
}
return `${API_BASE}${url.startsWith('/') ? url : `/${url}`}`;
}
export function getApiBaseUrl(): string {
return API_BASE || window.location.origin;
}
function resolveApiBase(): string {
const configured = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
if (configured) {
+10 -4
View File
@@ -50,7 +50,7 @@
</div>
<n-space size="small">
<n-button size="small" tertiary @click="copyHeaderSample">复制传参示例</n-button>
<n-button size="small" tertiary tag="a" href="/api/openapi.json" target="_blank">OpenAPI JSON</n-button>
<n-button size="small" tertiary tag="a" :href="openApiUrl" target="_blank">OpenAPI JSON</n-button>
</n-space>
</div>
</template>
@@ -135,7 +135,7 @@ import { AddOutline, RefreshOutline } from '@vicons/ionicons5';
import SwaggerUIBundle from 'swagger-ui-dist/swagger-ui-bundle.js';
import 'swagger-ui-dist/swagger-ui.css';
import { apiGet, apiPost, getToken } from '../api/client';
import { apiGet, apiPost, apiUrl, getApiBaseUrl, getToken } from '../api/client';
import type { ApiMessage, ApiTokenListResponse, ApiTokenMutationResponse, ApiTokenRecord } from '../types';
import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
@@ -149,6 +149,7 @@ type SwaggerSystem = {
interface SwaggerRequest {
headers: Record<string, string>;
url?: string;
}
const message = useMessage();
@@ -167,6 +168,7 @@ const tokenForm = reactive({
expires_at: '',
enabled: true
});
const openApiUrl = apiUrl('/api/openapi.json');
let swaggerUi: SwaggerSystem | undefined;
onMounted(async () => {
@@ -201,8 +203,9 @@ function initSwagger() {
swaggerHost.value.innerHTML = '';
swaggerUi = SwaggerUIBundle({
url: '/api/openapi.json',
url: openApiUrl,
domNode: swaggerHost.value,
requestSnippetsEnabled: true,
deepLinking: true,
docExpansion: 'none',
persistAuthorization: true,
@@ -211,9 +214,12 @@ function initSwagger() {
showExtensions: true,
requestInterceptor: (request: SwaggerRequest) => {
const token = getToken();
if (token) {
if (token && !request.headers.Authorization && !request.headers.authorization) {
request.headers.Authorization = `Bearer ${token}`;
}
if (request.url?.startsWith('/')) {
request.url = `${getApiBaseUrl()}${request.url}`;
}
return request;
},
onComplete: () => {