fix: 完善 API Token 验收细节
This commit is contained in:
@@ -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 次,可主动打开授权延期窗口。
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`,便于外部系统对接。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
Reference in New Issue
Block a user