Skip to content

Commit 71a77cd

Browse files
committed
feat(credentials): 单条凭证导出(JSON,可原样再导入)(issue #31)
管理台凭证行「更多操作 → 导出凭证」下载与批量导入同形的 JSON(provider / credential / nickname / tags),导出的文件可直接粘回「批量导入」或批量脚本。 - 新端点 GET /api/credentials/{id}/export:require_operator、Content-Disposition 附件头、no-store;内容是明文令牌,落 credential.export 审计(谁带走了哪个号) - 前端 api.exportCredential + 菜单项 + 提示「含明文令牌,请妥善保管」 - README / TECHNICAL / PROPOSAL 同步端点与审计动作 签到日志功能(#31 第 2 条)已由 b42b108 实现,本次只补导出。
1 parent 4797317 commit 71a77cd

9 files changed

Lines changed: 157 additions & 3 deletions

File tree

‎PROPOSAL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ DDL 以 [src/db/schema.sql](src/db/schema.sql) 为准,共 14 张表,`SCHEMA_
194194
**管理台(Cookie + RBAC)**:凭证管理、API Key(含六项配额,超限 429 + `Retry-After`,判定异常一律放行并记 warning)、用量统计、月度账单、Playground、模型列表、用户管理、审计、告警、上游状态、任务与配置、服务端日志。**出口 IP 粘性**(`CREDENTIAL_PROXY_POOL`):按凭证 id 稳定分配出口,推理与后台任务同出口。
195195

196196
- 账号:`GET|POST /api/users`、`PATCH /api/users/{username}`、`PUT /api/users/{username}/policy`(用户级模型白名单 + 配额)、`POST /api/users/{username}/{disable|enable|reset-password}`(**无 DELETE**);`POST /api/auth/password`;`GET|POST /api/auth/activate`;`GET /api/audit`。
197-
- 凭证运维:`GET|POST /api/credentials`(列表 / 新增)+ `DELETE /api/credentials/{id}`;动作端点 `checkin`(GET 查状态 / POST 执行)、`toggle`、`revive`、`probe`、`activity`、`pin`、`accounts/select`;`PUT /api/credentials/{id}/limits|tags`、`GET|POST /api/credentials/{id}/growth`、`GET /api/credentials/{id}/credit-events|checkin-events|accounts`。
197+
- 凭证运维:`GET|POST /api/credentials`(列表 / 新增)+ `DELETE /api/credentials/{id}`;动作端点 `checkin`(GET 查状态 / POST 执行)、`toggle`、`revive`、`probe`、`activity`、`pin`、`accounts/select`、`export`(单条 JSON 导出);`PUT /api/credentials/{id}/limits|tags`、`GET|POST /api/credentials/{id}/growth`、`GET /api/credentials/{id}/credit-events|checkin-events|accounts`。
198198
- 模型:`GET /api/model-catalog`、`GET /api/models/availability`、`POST /api/models/probes/{probe,manual}`(`manual=on|off` 人工接管,优先于自动探活)。
199199
- 批量:`POST /api/credentials/batch`(`probe|toggle|checkin|revive`)。逐条隔离,响应 `{total, succeeded, failed, results[]}`;`toggle`/`revive` 落一条汇总审计。
200200
- 批量导入:`POST /api/credentials/batch-import`(`items[]`,≤100 条)。逐条校验,响应 `{created[], skipped[], failed[]}` 均带 `index`;同渠道内容完全一致去重;逐条审计 + 入队探测。

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,7 @@ claude
375375

376376
- **端点**:`POST /api/credentials/batch`,body `{action: "probe"|"toggle"|"checkin"|"revive", ids: [...], enabled?, force?}`。一次 ≤200 条,重复 id 去重。`force` 只对 `revive` 有效(见下)。
377377
- **批量导入**:`POST /api/credentials/batch-import`,body `{items: [{provider, credential, nickname?, tags?}]}`。一次 ≤100 条;逐条校验,坏条目只进 `failed`(带 `index` 与原因);同渠道解密后内容完全一致视为重复,进 `skipped`。管理台凭证页「批量导入」支持粘贴 JSON 数组 / JSONL 或选文件。
378+
- **单条导出**:`GET /api/credentials/{id}/export` 返回与批量导入**同形**的 JSON(`provider` / `credential` / `nickname` / `tags`),可原样再导入。响应带 `Content-Disposition` 附件头与 `no-store`,内容是**明文凭证**——只对 admin/operator 开放,且落 `credential.export` 审计。管理台入口:凭证行「更多操作 → 导出凭证」。
378379
- **逐条隔离**:单条失败不影响其余。响应 `{action, total, succeeded, failed, results: [{id, ok, error?, detail?}]}`。
379380
- **签到失败是结果不是故障**:上游拒绝(账号被禁用 / 登录过期 / 限流)回 `ok=false` + 可读 `message`,不 500——否则管理台只弹「签到失败」,看不出真实原因。
380381
- **暂停是显式状态而非取反**:`toggle` 的 `enabled` 由请求体给出 → 操作条拆「暂停」/「取消暂停」两按钮。

‎src/api/admin_credentials.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616

1717
import httpx
1818
from fastapi import APIRouter, Depends
19+
from fastapi.responses import JSONResponse
1920

2021
from ..audit.actions import (
2122
ACTION_CREDENTIAL_DELETE,
23+
ACTION_CREDENTIAL_EXPORT,
2224
ACTION_CREDENTIAL_IMPORT,
2325
ACTION_CREDENTIAL_LIMITS,
2426
ACTION_CREDENTIAL_PIN,
@@ -630,6 +632,31 @@ async def checkin_events_history(credential_id: str, limit: int = 20,
630632
raise InvalidRequest("credential not found")
631633
return {"events": services.checkin_events.recent(credential_id, limit=limit)}
632634

635+
@router.get("/api/credentials/{credential_id}/export")
636+
async def export_credential(credential_id: str,
637+
principal=Depends(principal_from_request)):
638+
"""单条凭证导出(JSON):与「批量导入」的条目同形,可原样再导入。
639+
640+
导出**明文凭证**,因此只对 admin/operator 开放,并落一条
641+
`credential.export` 审计(谁把哪个号的令牌带出了系统)。响应带
642+
`Content-Disposition` 便于浏览器直接存文件,`no-store` 防中间层缓存。
643+
"""
644+
require_operator(principal)
645+
entry = services.credentials.export_entry(credential_id)
646+
if entry is None:
647+
raise InvalidRequest("credential not found")
648+
services.audit.record(actor=principal.username,
649+
action=ACTION_CREDENTIAL_EXPORT,
650+
target=credential_id,
651+
detail=f"上游 {entry['provider']}")
652+
logger.info("管理员 %s 导出凭证 %s(上游 %s)", principal.username,
653+
credential_id, entry["provider"])
654+
return JSONResponse(entry, headers={
655+
"Content-Disposition":
656+
f'attachment; filename="coding2api-credential-{credential_id}.json"',
657+
"Cache-Control": "no-store",
658+
})
659+
633660
@router.get("/api/credentials/{credential_id}/growth")
634661
async def growth_history(credential_id: str, limit: int = 20,
635662
principal=Depends(principal_from_request)):

‎src/audit/actions.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
ACTION_CREDENTIAL_SWITCH_ACCOUNT = "credential.switch_account"
2626
ACTION_CREDENTIAL_LIMITS = "credential.limits"
2727
ACTION_CREDENTIAL_TAGS = "credential.tags"
28+
ACTION_CREDENTIAL_EXPORT = "credential.export"
2829
ACTION_API_KEY_QUOTA = "api_key.quota"
2930
ACTION_API_KEY_CREATE = "api_key.create"
3031
ACTION_API_KEY_DELETE = "api_key.delete"
@@ -45,7 +46,7 @@
4546
ACTION_USER_PASSWORD_CHANGE, ACTION_USER_PASSWORD_RESET, ACTION_USER_DELETE,
4647
ACTION_CREDENTIAL_IMPORT, ACTION_CREDENTIAL_DELETE, ACTION_CREDENTIAL_TOGGLE,
4748
ACTION_CREDENTIAL_PIN, ACTION_CREDENTIAL_REVIVE, ACTION_CREDENTIAL_SWITCH_ACCOUNT,
48-
ACTION_CREDENTIAL_LIMITS, ACTION_CREDENTIAL_TAGS,
49+
ACTION_CREDENTIAL_LIMITS, ACTION_CREDENTIAL_TAGS, ACTION_CREDENTIAL_EXPORT,
4950
ACTION_USER_POLICY,
5051
ACTION_API_KEY_QUOTA,
5152
ACTION_API_KEY_CREATE, ACTION_API_KEY_DELETE,
@@ -78,6 +79,7 @@
7879
ACTION_CREDENTIAL_SWITCH_ACCOUNT: "切换账号",
7980
ACTION_CREDENTIAL_LIMITS: "设置限额",
8081
ACTION_CREDENTIAL_TAGS: "设置标签",
82+
ACTION_CREDENTIAL_EXPORT: "导出凭证",
8183
ACTION_API_KEY_QUOTA: "设置 Key 配额",
8284
ACTION_API_KEY_CREATE: "创建 API Key",
8385
ACTION_API_KEY_DELETE: "删除 API Key",

‎src/db/repo.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,23 @@ def credential_data(self, credential_id: str) -> dict[str, Any] | None:
716716
plaintext = self._cipher.decrypt(row["data_enc"])
717717
return json.loads(plaintext.decode("utf-8"))
718718

719+
def export_entry(self, credential_id: str) -> dict[str, Any] | None:
720+
"""单条凭证导出:与 `POST /api/credentials` 的导入项同形,可原样再导入。
721+
722+
复用 `import_credential` 接受的结构(`provider` + `credential` +
723+
`nickname` + `tags`),因此导出的文件既能备份,也能直接粘进「批量导入」。
724+
解密失败照旧抛 `CredentialDecryptError`,与 `credential_data` 同一口径。
725+
"""
726+
row = self._db.connect().execute(
727+
"SELECT provider, nickname, tags, data_enc FROM credentials WHERE id = ?",
728+
(credential_id,)).fetchone()
729+
if row is None:
730+
return None
731+
plaintext = self._cipher.decrypt(row["data_enc"])
732+
return {"provider": row["provider"], "nickname": row["nickname"],
733+
"tags": tags_value(row["tags"]),
734+
"credential": json.loads(plaintext.decode("utf-8"))}
735+
719736
def nickname_of(self, credential_id: str) -> str | None:
720737
"""单条凭证昵称(供实时流按需补名字,避免每事件拉整份列表)。
721738

‎tests/api/test_admin_credentials.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,33 @@ def test_credential_account_endpoints_unsupported_for_trae(admin_client):
119119
json={"account_id": "a"}).status_code == 400
120120
assert client.get("/api/credentials/ghost/accounts").status_code == 400
121121

122+
123+
def test_credential_export_returns_importable_json_and_audits(admin_client):
124+
"""单条导出:与批量导入同形的明文条目 + 存文件头 + 审计留痕。"""
125+
from src.audit.actions import ACTION_CREDENTIAL_EXPORT
126+
127+
app, client = admin_client
128+
credential_id = app.state.credentials.add(
129+
provider="codebuddy", credential_data={"bearer_token": "secret-token"},
130+
nickname="主账号", tags=["团队", "备用"])
131+
response = client.get(f"/api/credentials/{credential_id}/export")
132+
assert response.status_code == 200
133+
assert response.headers["cache-control"] == "no-store"
134+
assert response.headers["content-disposition"].startswith("attachment;")
135+
assert response.json() == {
136+
"provider": "codebuddy", "nickname": "主账号", "tags": ["团队", "备用"],
137+
"credential": {"bearer_token": "secret-token"},
138+
}
139+
rows = app.state.services.audit.query(action=ACTION_CREDENTIAL_EXPORT)
140+
assert len(rows) == 1
141+
assert rows[0]["actor"] == "root" and rows[0]["target"] == credential_id
142+
143+
assert client.get("/api/credentials/ghost/export").status_code == 400
144+
145+
# viewer 不能把令牌带出系统(与写操作同一道 require_operator)
146+
client.cookies.set("coding2api_session", create_session_token("guest", SECRET))
147+
assert client.get(f"/api/credentials/{credential_id}/export").status_code == 403
148+
122149
def test_select_account_rejects_vanished_credential(admin_client):
123150
"""provider 支持切换但持锁后凭证已消失 → 400,不拿 None 去打上游。"""
124151
app, client = admin_client

‎web/src/api/client.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,23 @@ export const api = {
258258
method: "POST",
259259
...json({ provider, credential, nickname }),
260260
}),
261+
/** 单条凭证导出:返回 JSON 文件内容,页面触发下载(含明文令牌)。 */
262+
exportCredential: async (id: string) => {
263+
const response = await fetch(
264+
`/api/credentials/${encodeURIComponent(id)}/export`,
265+
{ credentials: "same-origin" });
266+
if (!response.ok) {
267+
let message = `导出失败(HTTP ${response.status})`;
268+
try {
269+
const body = await response.json();
270+
if (body?.error?.message) message = body.error.message;
271+
} catch {
272+
// 非 JSON 错误体:保留 HTTP 状态提示
273+
}
274+
throw new ApiError(response.status, "export_failed", message);
275+
}
276+
return { blob: await response.blob() };
277+
},
261278
// ---------------------------------------------------------------- 备份
262279
backups: () => request<BackupListResponse>("/api/backups"),
263280
createBackup: () =>

‎web/src/pages/CredentialsPage.tsx‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
CircleCheck,
1212
Coins,
1313
Database,
14+
Download,
1415
Flame,
1516
Gauge,
1617
HeartPulse,
@@ -331,6 +332,7 @@ interface Actions {
331332
checkinLog: (credential: Credential) => void;
332333
limits: (credential: Credential) => void;
333334
tags: (credential: Credential) => void;
335+
export: (credential: Credential) => void;
334336
}
335337

336338
export function CredentialsPage() {
@@ -735,6 +737,25 @@ export function CredentialsPage() {
735737
// 只开对话框;保存与刷新由对话框自己回调(见 CredentialLimitsDialog)
736738
limits: (credential) => setLimitsFor(credential.id),
737739
tags: (credential) => setTagsFor(credential.id),
740+
// 单条导出:文件内容与「批量导入」的条目同形,可原样再导入。
741+
export: (credential) =>
742+
void (async () => {
743+
try {
744+
const { blob } = await api.exportCredential(credential.id);
745+
const url = URL.createObjectURL(blob);
746+
const anchor = document.createElement("a");
747+
anchor.href = url;
748+
anchor.download =
749+
`coding2api-credential-${credential.provider}-${credential.id}.json`;
750+
document.body.appendChild(anchor);
751+
anchor.click();
752+
anchor.remove();
753+
URL.revokeObjectURL(url);
754+
showNotice("已导出凭证 JSON(含明文令牌,请妥善保管)");
755+
} catch (caught) {
756+
showError(caught instanceof Error ? caught.message : "导出凭证失败");
757+
}
758+
})(),
738759
};
739760

740761
if (isLoading) return <PageSkeleton variant="cards" />;
@@ -2000,6 +2021,12 @@ function Row({
20002021
<Tag className="size-4" />
20012022
{credential.tags.length ? "编辑标签" : "设置标签"}
20022023
</DropdownMenuItem>
2024+
<DropdownMenuItem
2025+
onSelect={() => actions.export(credential)}
2026+
title="导出为 JSON(含明文令牌,可原样再导入;操作会记审计)"
2027+
>
2028+
<Download className="size-4" /> 导出凭证
2029+
</DropdownMenuItem>
20032030
<DropdownMenuSeparator />
20042031
<DropdownMenuItem
20052032
onSelect={() => onConfirmDelete(credential.id)}

‎web/src/pages/__tests__/CredentialsPage.test.tsx‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { screen, waitFor, within } from "@testing-library/react";
2-
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33
import { CredentialsPage } from "../CredentialsPage";
44
import {
55
expandCredentialGroups, jsonResponse, makeCredential, renderPage, settle, userEvent,
@@ -27,6 +27,12 @@ async function openAddDialog() {
2727
await screen.findByTestId("add-credential-dialog");
2828
}
2929

30+
// 导出用例会替换全局 URL(createObjectURL 在 jsdom 缺失);不在用例末尾
31+
// 手动恢复的话,后续 describe 的 useSearchParams 会拿到残缺的 URL 构造器。
32+
afterEach(() => {
33+
vi.unstubAllGlobals();
34+
});
35+
3036
describe("CredentialsPage", () => {
3137
beforeEach(() => {
3238
vi.unstubAllGlobals();
@@ -1706,6 +1712,36 @@ describe("CredentialsPage", () => {
17061712
expect(screen.getByTestId("tags-cred_1")).toBeInTheDocument();
17071713
expect(screen.getByTestId("credential-tag-filter")).toBeInTheDocument();
17081714
});
1715+
1716+
it("导出凭证:请求 export 端点并落盘 JSON", async () => {
1717+
const blobs: string[] = [];
1718+
vi.stubGlobal("URL", {
1719+
...URL,
1720+
createObjectURL: () => { blobs.push("blob"); return "blob:x"; },
1721+
revokeObjectURL: () => undefined,
1722+
});
1723+
const fetchSpy = mockFetch({
1724+
"/export": {
1725+
provider: "trae", nickname: "主账号", tags: ["生产"],
1726+
credential: { accessToken: "secret" },
1727+
},
1728+
"/api/credentials": listBody([makeCredential({ id: "cred_1", provider: "trae" })]),
1729+
});
1730+
renderPage(<CredentialsPage />, ADMIN);
1731+
await settle();
1732+
await expandCredentialGroups();
1733+
1734+
await userEvent.click(screen.getByTestId("actions-cred_1"));
1735+
await userEvent.click(screen.getByRole("menuitem", { name: "导出凭证" }));
1736+
1737+
await waitFor(() => {
1738+
expect(fetchSpy.mock.calls.some(([url]) =>
1739+
String(url).includes("/api/credentials/cred_1/export"))).toBe(true);
1740+
});
1741+
await waitFor(() => expect(blobs).toHaveLength(1));
1742+
expect(await screen.findByTestId("credentials-notice"))
1743+
.toHaveTextContent("已导出凭证 JSON");
1744+
});
17091745
});
17101746

17111747

0 commit comments

Comments
 (0)