feat: Key batch management (Key Manager service, API, frontend) (Phase 6)

Backend service for reading/writing profile .env files with masking,
auto-backup, batch overwrite, batch scan, diff view, and CSV export.
Owner/operator-only API endpoints. Frontend KeysPage with batch-overwrite
modal, batch-scan results, diff view with highlight, and CSV download.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
enzo
2026-07-22 17:24:53 +08:00
parent 670d222665
commit 3d905b606a
5 changed files with 998 additions and 3 deletions
+106
View File
@@ -0,0 +1,106 @@
"""Keys API — 密钥批量管理。"""
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import PlainTextResponse
from app.database import get_db
from app.middleware.auth import require_role
from app.models.audit import AuditLog
from app.models.user import User
from app.services.key_manager import (
WHITELIST_FIELDS,
get_keys,
batch_overwrite,
batch_scan as scan_profiles,
get_diff,
export_csv,
list_profiles,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/keys", tags=["keys"])
@router.get("")
async def list_keys(
profiles: str = Query("", description="逗号分隔的 Profile 名"),
_user: User = Depends(require_role("owner", "operator")),
):
"""返回密钥字段列表(脱敏显示)。"""
profile_list = [p.strip() for p in profiles.split(",") if p.strip()] if profiles else None
data = get_keys(profile_list)
return {"data": data}
@router.post("/batch-overwrite")
async def batch_overwrite_keys(
body: dict,
user: User = Depends(require_role("owner", "operator")),
db: AsyncSession = Depends(get_db),
):
"""批量覆盖密钥。"""
profile_names = body.get("profile_names", [])
key_name = body.get("key_name", "")
new_value = body.get("new_value", "")
confirmation = body.get("confirmation", "")
if key_name not in WHITELIST_FIELDS:
raise HTTPException(status_code=400, detail=f"不支持的密钥字段: {key_name}")
# 验证确认短语
expected = f"我确认覆盖 {len(profile_names)} 个 Profile"
if confirmation != expected:
raise HTTPException(status_code=400, detail="确认短语不匹配")
results = batch_overwrite(profile_names, key_name, new_value)
# 审计日志(不记录原文)
audit = AuditLog(
user_id=user.id,
username=user.username,
action="keys:batch-overwrite",
target=f"profiles:{','.join(profile_names)}",
detail=f"key={key_name}, profiles={','.join(profile_names)}, "
f"success={sum(1 for r in results if r['success'])}/{len(results)}",
)
db.add(audit)
await db.commit()
return {"results": results}
@router.post("/batch-scan")
async def batch_scan_keys(
body: dict,
_user: User = Depends(require_role("owner", "operator")),
):
"""批量检查缺失密钥。"""
profile_names = body.get("profile_names")
if profile_names is not None and not isinstance(profile_names, list):
raise HTTPException(status_code=400, detail="profile_names 必须为数组")
results = scan_profiles(profile_names)
return {"results": results}
@router.get("/diff")
async def diff_keys(
profiles: str = Query("", description="逗号分隔的 Profile 名"),
_user: User = Depends(require_role("owner", "operator")),
):
"""差异视图。"""
profile_list = [p.strip() for p in profiles.split(",") if p.strip()] if profiles else list_profiles()
rows = get_diff(profile_list)
return {"profiles": profile_list, "rows": rows}
@router.get("/export", response_class=PlainTextResponse)
async def export_keys_csv(
profiles: str = Query("", description="逗号分隔的 Profile 名"),
_user: User = Depends(require_role("owner", "operator")),
):
"""CSV 导出批量检查结果。"""
profile_list = [p.strip() for p in profiles.split(",") if p.strip()] if profiles else None
return export_csv(profile_list)
+2 -1
View File
@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.database import engine, Base
from app.api import auth, users, audit, dashboard, gateway, health, cron
from app.api import auth, users, audit, dashboard, gateway, health, cron, keys
from app.middleware.audit import AuditMiddleware
@@ -38,6 +38,7 @@ app.include_router(dashboard.router, prefix="/api/v1")
app.include_router(gateway.router, prefix="/api/v1")
app.include_router(health.router, prefix="/api/v1")
app.include_router(cron.router, prefix="/api/v1")
app.include_router(keys.router, prefix="/api/v1")
@app.get("/api/v1/health")
+196
View File
@@ -0,0 +1,196 @@
"""Key Manager 服务 — .env 密钥读写、脱敏、批量操作。"""
import logging
import os
import shutil
from pathlib import Path
from typing import Optional
from app.config import settings
logger = logging.getLogger(__name__)
# 可管理的密钥白名单
WHITELIST_FIELDS = [
"DEEPSEEK_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"GOOGLE_API_KEY",
"FEISHU_APP_ID",
"FEISHU_APP_SECRET",
]
PROFILES_ROOT = Path(settings.hermes_profiles_root)
def _parse_env(content: str) -> dict[str, str]:
"""解析 .env 文件内容为 key-value 字典。"""
result: dict[str, str] = {}
for line in content.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if "=" not in stripped:
continue
key, _, val = stripped.partition("=")
key = key.strip()
val = val.strip().strip("\"'")
if key in WHITELIST_FIELDS:
result[key] = val
return result
def _mask_value(value: str) -> str:
"""脱敏: 前3字符 + **...** + 后4字符。"""
if len(value) <= 7:
return value[:3] + "**...**" + (value[-4:] if len(value) > 3 else "")
return value[:3] + "**...**" + value[-4:]
def _read_env_file(profile: str) -> dict[str, str]:
"""读取指定 Profile 的 .env 文件中白名单字段。"""
env_path = PROFILES_ROOT / profile / ".env"
if not env_path.exists():
return {}
try:
content = env_path.read_text(encoding="utf-8")
return _parse_env(content)
except OSError as e:
logger.warning("读取 %s/.env 失败: %s", profile, e)
return {}
def _write_env_file(profile: str, updates: dict[str, str]) -> bool:
"""写入指定 Profile 的 .env 文件,操作前自动备份。"""
env_path = PROFILES_ROOT / profile / ".env"
if not env_path.exists():
return False
# 备份
try:
shutil.copy2(env_path, env_path.with_suffix(".env.bak"))
except OSError as e:
logger.warning("备份 %s/.env 失败: %s", profile, e)
try:
content = env_path.read_text(encoding="utf-8")
lines = content.splitlines(keepends=True)
updated_keys = set(updates.keys())
new_lines: list[str] = []
found: set[str] = set()
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
new_lines.append(line)
continue
if "=" not in stripped:
new_lines.append(line)
continue
key = stripped.partition("=")[0].strip()
if key in updated_keys:
new_lines.append(f"{key}={updates[key]}\n")
found.add(key)
else:
new_lines.append(line)
# 追加未找到的字段
for key in updated_keys:
if key not in found:
new_lines.append(f"{key}={updates[key]}\n")
env_path.write_text("".join(new_lines), encoding="utf-8")
logger.info("已写入 %s/.env (%d 个字段)", profile, len(updates))
return True
except OSError as e:
logger.error("写入 %s/.env 失败: %s", profile, e)
return False
def list_profiles() -> list[str]:
"""返回所有包含 .env 文件的 Profile 名称列表。"""
if not PROFILES_ROOT.exists():
return []
return sorted(
d.name
for d in PROFILES_ROOT.iterdir()
if d.is_dir() and (d / ".env").exists()
)
def get_keys(profile_names: Optional[list[str]] = None) -> list[dict]:
"""获取指定 Profile 的密钥列表(脱敏显示)。"""
profiles = profile_names if profile_names else list_profiles()
result: list[dict] = []
for name in profiles:
fields = _read_env_file(name)
masked = {k: _mask_value(v) for k, v in fields.items()}
missing = len(WHITELIST_FIELDS) - len(fields)
result.append({"profile": name, "fields": masked, "missing": missing})
return result
def batch_overwrite(
profile_names: list[str],
key_name: str,
new_value: str,
) -> list[dict]:
"""批量覆盖指定 Profile 的某个密钥字段。"""
results: list[dict] = []
for name in profile_names:
success = _write_env_file(name, {key_name: new_value})
results.append({"profile": name, "success": success})
return results
def batch_scan(profile_names: Optional[list[str]] = None) -> list[dict]:
"""批量检查缺失的密钥字段。"""
profiles = profile_names if profile_names else list_profiles()
results: list[dict] = []
for name in profiles:
fields = _read_env_file(name)
missing = [f for f in WHITELIST_FIELDS if f not in fields]
results.append({
"profile": name,
"missing": missing,
"missing_count": len(missing),
"total": len(WHITELIST_FIELDS),
})
return results
def get_diff(profile_names: list[str]) -> list[dict]:
"""差异视图: 横向 Profile, 纵向字段, 值状态(有/无/长度)。"""
profiles = profile_names if profile_names else list_profiles()
field_map: dict[str, dict[str, str | int]] = {}
for name in profiles:
fields = _read_env_file(name)
for key in WHITELIST_FIELDS:
if key not in field_map:
field_map[key] = {}
if key in fields:
field_map[key][name] = len(fields[key])
else:
field_map[key][name] = 0 # 0 表示缺失
rows: list[dict] = []
for key in WHITELIST_FIELDS:
row: dict[str, str | int] = {"field": key}
for name in profiles:
val = field_map.get(key, {}).get(name, 0)
row[name] = val
rows.append(row)
return rows
def export_csv(profile_names: Optional[list[str]] = None) -> str:
"""CSV 导出批量检查结果。"""
scan = batch_scan(profile_names)
import csv
import io
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(["Profile", "缺失字段", "缺失数", "总字段"])
for row in scan:
writer.writerow([
row["profile"],
"; ".join(row["missing"]),
row["missing_count"],
row["total"],
])
return buf.getvalue()