diff --git a/README.md b/README.md index 2bf73ac..c02c058 100644 --- a/README.md +++ b/README.md @@ -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: ``` 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 次,可主动打开授权延期窗口。 diff --git a/app/api/routers/api_tokens.py b/app/api/routers/api_tokens.py index 5c5d944..0ff28ff 100644 --- a/app/api/routers/api_tokens.py +++ b/app/api/routers/api_tokens.py @@ -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: diff --git a/docs/project_context.md b/docs/project_context.md index bdd1821..3491c7b 100644 --- a/docs/project_context.md +++ b/docs/project_context.md @@ -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 ` 和 `X-API-Token`。Token 管理、系统配置、授权和 API 文档仍要求登录后访问。 - `app/main.py` 的全局鉴权中间件改为 JWT + API Token 双鉴权;业务 API 可由 API Token 调用,`/api/openapi.json` 和 `/api/docs-info` 仅登录后可见。OpenAPI schema 同时声明 `BearerAuth` 和 `ApiTokenHeader`,便于外部系统对接。 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e44d303..123a95e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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) { diff --git a/frontend/src/components/ApiCenter.vue b/frontend/src/components/ApiCenter.vue index a0473e7..a583756 100644 --- a/frontend/src/components/ApiCenter.vue +++ b/frontend/src/components/ApiCenter.vue @@ -50,7 +50,7 @@ 复制传参示例 - OpenAPI JSON + OpenAPI JSON @@ -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; + 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: () => {