refactor: 后端用户管理重构 - 新增 user_router,移除旧的 auth_apikey_router

- 新增 user_router.py 统一用户管理路由
- 删除 auth_apikey_router.py,功能迁移至 user_router
- 更新 __init__.py 路由注册
- 同步更新前端 apikey_api.js 适配接口变更
This commit is contained in:
Wenjie Zhang 2026-05-21 22:09:07 +08:00
parent b6a8ea9343
commit b9f0579ef2
4 changed files with 296 additions and 264 deletions

View File

@ -13,7 +13,7 @@ from server.routers.subagent_router import subagents_router
from server.routers.system_router import system
from server.routers.system_task_router import tasks
from server.routers.tool_router import tools
from server.routers.auth_apikey_router import apikey_router
from server.routers.user_router import user_router
from server.routers.filesystem_router import filesystem_router
from server.routers.workspace_router import workspace
from server.routers.mention_router import mention_router
@ -36,7 +36,7 @@ router.include_router(model_providers) # /api/system/model-providers/* 独立
router.include_router(skills) # /api/system/skills/* Skills 管理
router.include_router(subagents_router) # /api/system/subagents/* 子智能体管理
router.include_router(tools) # /api/system/tools/* 工具列表与配置
router.include_router(apikey_router) # /api/apikey/* API Key 管理
router.include_router(user_router) # /api/user/* 用户级配置与凭据
router.include_router(filesystem_router) # /api/viewer/filesystem/* 工作台文件系统视图
router.include_router(workspace) # /api/workspace/* 用户个人工作区
router.include_router(mention_router) # /api/mention/* 提及文件搜索接口

View File

@ -1,256 +0,0 @@
"""API Key 管理路由"""
import hashlib
import secrets
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.storage.postgres.models_business import User, APIKey
from server.utils.auth_middleware import get_db, get_required_user, get_superadmin_user
from yuxi.utils.datetime_utils import coerce_any_to_utc_datetime, utc_now_naive
apikey_router = APIRouter(prefix="/apikey", tags=["apikey"])
def generate_api_key() -> tuple[str, str, str]:
"""生成新的 API Key
Returns: (full_key, key_hash, key_prefix)
- full_key: 完整密钥仅在创建时返回一次
- key_hash: 存储到数据库的哈希值
- key_prefix: 保存前缀用于显示
"""
random_part = secrets.token_hex(24)
full_key = f"yxkey_{random_part}"
key_hash = hashlib.sha256(full_key.encode()).hexdigest()
key_prefix = full_key[:12]
return full_key, key_hash, key_prefix
class APIKeyCreate(BaseModel):
name: str
user_id: int | None = None
department_id: int | None = None
expires_at: str | None = None
class APIKeyUpdate(BaseModel):
name: str | None = None
expires_at: str | None = None
is_enabled: bool | None = None
class APIKeyResponse(BaseModel):
id: int
key_prefix: str
name: str
user_id: int | None
department_id: int | None
expires_at: str | None
is_enabled: bool
last_used_at: str | None
created_by: str
created_at: str
class APIKeyCreateResponse(BaseModel):
api_key: APIKeyResponse
secret: str
@apikey_router.get("/", response_model=dict)
async def list_api_keys(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
"""列出当前用户的 API Keys"""
# 普通用户只能看到自己的 API Keys
if current_user.role == "superadmin":
# superadmin 可以看到所有
result = await db.execute(select(APIKey).order_by(APIKey.created_at.desc()).offset(skip).limit(limit))
api_keys = result.scalars().all()
total_result = await db.execute(select(func.count(APIKey.id)))
else:
# 普通用户只看自己的
result = await db.execute(
select(APIKey)
.filter(APIKey.user_id == current_user.id)
.order_by(APIKey.created_at.desc())
.offset(skip)
.limit(limit)
)
api_keys = result.scalars().all()
total_result = await db.execute(select(func.count(APIKey.id)).filter(APIKey.user_id == current_user.id))
total = total_result.scalar()
return {
"api_keys": [key.to_dict() for key in api_keys],
"total": total,
}
@apikey_router.post("/", response_model=APIKeyCreateResponse)
async def create_api_key(
data: APIKeyCreate,
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
"""创建新 API Keysecret 仅在此处返回一次)"""
# 生成 Key
full_key, key_hash, key_prefix = generate_api_key()
# 普通用户只能为自己创建 API Key不能指定其他用户
if data.user_id and data.user_id != current_user.id and current_user.role != "superadmin":
raise HTTPException(status_code=403, detail="无权为其他用户创建 API Key")
# 验证关联用户
if data.user_id:
result = await db.execute(select(User).filter(User.id == data.user_id))
user = result.scalar_one_or_none()
if not user or user.is_deleted:
raise HTTPException(status_code=404, detail="关联的用户不存在")
else:
# 自动绑定为当前登录用户
data.user_id = current_user.id
# 解析过期时间(转换为 naive datetime 以匹配数据库字段)
expires_at = None
if data.expires_at:
aware_dt = coerce_any_to_utc_datetime(data.expires_at)
if aware_dt:
expires_at = aware_dt.replace(tzinfo=None)
# 创建记录
api_key = APIKey(
key_hash=key_hash,
key_prefix=key_prefix,
name=data.name,
user_id=data.user_id,
department_id=data.department_id,
expires_at=expires_at,
created_by=str(current_user.id),
)
db.add(api_key)
await db.commit()
await db.refresh(api_key)
return APIKeyCreateResponse(
api_key=APIKeyResponse(**api_key.to_dict()),
secret=full_key,
)
@apikey_router.get("/{api_key_id}", response_model=dict)
async def get_api_key(
api_key_id: int,
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
"""获取单个 API Key只能操作自己的 Key"""
result = await db.execute(select(APIKey).filter(APIKey.id == api_key_id))
api_key = result.scalar_one_or_none()
if not api_key:
raise HTTPException(status_code=404, detail="API Key 不存在")
# 检查权限:只能操作自己的 Key或者 superadmin 可以操作所有
if api_key.user_id != current_user.id and current_user.role != "superadmin":
raise HTTPException(status_code=403, detail="无权操作此 API Key")
return {"api_key": api_key.to_dict()}
@apikey_router.put("/{api_key_id}", response_model=dict)
async def update_api_key(
api_key_id: int,
data: APIKeyUpdate,
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
"""更新 API Key只能操作自己的 Key"""
result = await db.execute(select(APIKey).filter(APIKey.id == api_key_id))
api_key = result.scalar_one_or_none()
if not api_key:
raise HTTPException(status_code=404, detail="API Key 不存在")
# 检查权限:只能操作自己的 Key或者 superadmin 可以操作所有
if api_key.user_id != current_user.id and current_user.role != "superadmin":
raise HTTPException(status_code=403, detail="无权操作此 API Key")
if data.name is not None:
api_key.name = data.name
if data.expires_at is not None:
aware_dt = coerce_any_to_utc_datetime(data.expires_at)
api_key.expires_at = aware_dt.replace(tzinfo=None) if aware_dt else None
if data.is_enabled is not None:
api_key.is_enabled = data.is_enabled
await db.commit()
await db.refresh(api_key)
return {"api_key": api_key.to_dict()}
@apikey_router.delete("/{api_key_id}", response_model=dict)
async def delete_api_key(
api_key_id: int,
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
"""删除 API Key只能操作自己的 Key"""
result = await db.execute(select(APIKey).filter(APIKey.id == api_key_id))
api_key = result.scalar_one_or_none()
if not api_key:
raise HTTPException(status_code=404, detail="API Key 不存在")
# 检查权限:只能操作自己的 Key或者 superadmin 可以操作所有
if api_key.user_id != current_user.id and current_user.role != "superadmin":
raise HTTPException(status_code=403, detail="无权操作此 API Key")
await db.delete(api_key)
await db.commit()
return {"success": True}
@apikey_router.post("/{api_key_id}/regenerate", response_model=APIKeyCreateResponse)
async def regenerate_api_key(
api_key_id: int,
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
"""重新生成 API Key 密钥secret 仅在此处返回一次,只能操作自己的 Key"""
result = await db.execute(select(APIKey).filter(APIKey.id == api_key_id))
api_key = result.scalar_one_or_none()
if not api_key:
raise HTTPException(status_code=404, detail="API Key 不存在")
# 检查权限:只能操作自己的 Key或者 superadmin 可以操作所有
if api_key.user_id != current_user.id and current_user.role != "superadmin":
raise HTTPException(status_code=403, detail="无权操作此 API Key")
# 生成新密钥
full_key, key_hash, key_prefix = generate_api_key()
api_key.key_hash = key_hash
api_key.key_prefix = key_prefix
await db.commit()
await db.refresh(api_key)
return APIKeyCreateResponse(
api_key=APIKeyResponse(**api_key.to_dict()),
secret=full_key,
)

View File

@ -0,0 +1,286 @@
"""用户级配置与凭据路由"""
import hashlib
import re
import secrets
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from server.utils.auth_middleware import get_db, get_required_user
from yuxi.storage.postgres.models_business import APIKey, AgentEnv, User
from yuxi.utils.datetime_utils import coerce_any_to_utc_datetime, format_utc_datetime, utc_now_naive
user_router = APIRouter(prefix="/user", tags=["user"])
ENV_KEY_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
MAX_ENV_COUNT = 200
MAX_ENV_KEY_LENGTH = 128
MAX_ENV_VALUE_LENGTH = 32768
def generate_api_key() -> tuple[str, str, str]:
random_part = secrets.token_hex(24)
full_key = f"yxkey_{random_part}"
key_hash = hashlib.sha256(full_key.encode()).hexdigest()
key_prefix = full_key[:12]
return full_key, key_hash, key_prefix
class APIKeyCreate(BaseModel):
name: str
user_id: int | None = None
department_id: int | None = None
expires_at: str | None = None
class APIKeyUpdate(BaseModel):
name: str | None = None
expires_at: str | None = None
is_enabled: bool | None = None
class APIKeyResponse(BaseModel):
id: int
key_prefix: str
name: str
user_id: int | None
department_id: int | None
expires_at: str | None
is_enabled: bool
last_used_at: str | None
created_by: str
created_at: str
class APIKeyCreateResponse(BaseModel):
api_key: APIKeyResponse
secret: str
class AgentEnvUpdate(BaseModel):
env: dict[str, Any] = Field(default_factory=dict)
class AgentEnvResponse(BaseModel):
env: dict[str, str]
updated_at: str | None = None
def validate_agent_env(env: dict[str, Any]) -> dict[str, str]:
if len(env) > MAX_ENV_COUNT:
raise HTTPException(status_code=400, detail=f"环境变量数量不能超过 {MAX_ENV_COUNT}")
normalized: dict[str, str] = {}
for key, value in env.items():
if not isinstance(key, str):
raise HTTPException(status_code=400, detail="环境变量名必须是字符串")
name = key.strip()
if not name:
raise HTTPException(status_code=400, detail="环境变量名不能为空")
if len(name) > MAX_ENV_KEY_LENGTH:
raise HTTPException(status_code=400, detail=f"环境变量名长度不能超过 {MAX_ENV_KEY_LENGTH}")
if not ENV_KEY_PATTERN.match(name):
raise HTTPException(status_code=400, detail=f"环境变量名 {name} 格式不正确")
if name in normalized:
raise HTTPException(status_code=400, detail=f"环境变量名 {name} 重复")
if not isinstance(value, str):
raise HTTPException(status_code=400, detail=f"环境变量 {name} 的值必须是字符串")
if len(value) > MAX_ENV_VALUE_LENGTH:
raise HTTPException(status_code=400, detail=f"环境变量 {name} 的值过长")
normalized[name] = value
return normalized
def ensure_api_key_owner(api_key: APIKey, current_user: User) -> None:
if api_key.user_id != current_user.id and current_user.role != "superadmin":
raise HTTPException(status_code=403, detail="无权操作此 API Key")
async def get_accessible_api_key(db: AsyncSession, api_key_id: int, current_user: User) -> APIKey:
result = await db.execute(select(APIKey).filter(APIKey.id == api_key_id))
api_key = result.scalar_one_or_none()
if not api_key:
raise HTTPException(status_code=404, detail="API Key 不存在")
ensure_api_key_owner(api_key, current_user)
return api_key
@user_router.get("/apikey/", response_model=dict)
async def list_api_keys(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
query = select(APIKey).order_by(APIKey.created_at.desc()).offset(skip).limit(limit)
count_query = select(func.count(APIKey.id))
if current_user.role != "superadmin":
query = query.filter(APIKey.user_id == current_user.id)
count_query = count_query.filter(APIKey.user_id == current_user.id)
result = await db.execute(query)
api_keys = result.scalars().all()
total_result = await db.execute(count_query)
return {
"api_keys": [key.to_dict() for key in api_keys],
"total": total_result.scalar(),
}
@user_router.post("/apikey/", response_model=APIKeyCreateResponse)
async def create_api_key(
data: APIKeyCreate,
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
target_user_id = data.user_id or current_user.id
if data.user_id and data.user_id != current_user.id and current_user.role != "superadmin":
raise HTTPException(status_code=403, detail="无权为其他用户创建 API Key")
if data.user_id:
result = await db.execute(select(User).filter(User.id == data.user_id))
user = result.scalar_one_or_none()
if not user or user.is_deleted:
raise HTTPException(status_code=404, detail="关联的用户不存在")
full_key, key_hash, key_prefix = generate_api_key()
expires_at = None
if data.expires_at:
aware_dt = coerce_any_to_utc_datetime(data.expires_at)
if aware_dt:
expires_at = aware_dt.replace(tzinfo=None)
api_key = APIKey(
key_hash=key_hash,
key_prefix=key_prefix,
name=data.name,
user_id=target_user_id,
department_id=data.department_id,
expires_at=expires_at,
created_by=str(current_user.id),
)
db.add(api_key)
await db.commit()
await db.refresh(api_key)
return APIKeyCreateResponse(
api_key=APIKeyResponse(**api_key.to_dict()),
secret=full_key,
)
@user_router.get("/apikey/{api_key_id}", response_model=dict)
async def get_api_key(
api_key_id: int,
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
api_key = await get_accessible_api_key(db, api_key_id, current_user)
return {"api_key": api_key.to_dict()}
@user_router.put("/apikey/{api_key_id}", response_model=dict)
async def update_api_key(
api_key_id: int,
data: APIKeyUpdate,
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
api_key = await get_accessible_api_key(db, api_key_id, current_user)
if data.name is not None:
api_key.name = data.name
if data.expires_at is not None:
aware_dt = coerce_any_to_utc_datetime(data.expires_at)
api_key.expires_at = aware_dt.replace(tzinfo=None) if aware_dt else None
if data.is_enabled is not None:
api_key.is_enabled = data.is_enabled
await db.commit()
await db.refresh(api_key)
return {"api_key": api_key.to_dict()}
@user_router.delete("/apikey/{api_key_id}", response_model=dict)
async def delete_api_key(
api_key_id: int,
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
api_key = await get_accessible_api_key(db, api_key_id, current_user)
await db.delete(api_key)
await db.commit()
return {"success": True}
@user_router.post("/apikey/{api_key_id}/regenerate", response_model=APIKeyCreateResponse)
async def regenerate_api_key(
api_key_id: int,
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
api_key = await get_accessible_api_key(db, api_key_id, current_user)
full_key, key_hash, key_prefix = generate_api_key()
api_key.key_hash = key_hash
api_key.key_prefix = key_prefix
await db.commit()
await db.refresh(api_key)
return APIKeyCreateResponse(
api_key=APIKeyResponse(**api_key.to_dict()),
secret=full_key,
)
@user_router.get("/agent-env", response_model=AgentEnvResponse)
async def get_agent_env(
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(AgentEnv).filter(AgentEnv.uid == current_user.uid))
agent_env = result.scalar_one_or_none()
if agent_env is None:
return AgentEnvResponse(env={})
return AgentEnvResponse(env=agent_env.env or {}, updated_at=format_utc_datetime(agent_env.updated_at))
@user_router.put("/agent-env", response_model=AgentEnvResponse)
async def update_agent_env(
data: AgentEnvUpdate,
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
env = validate_agent_env(data.env)
result = await db.execute(select(AgentEnv).filter(AgentEnv.uid == current_user.uid))
current_agent_env = result.scalar_one_or_none()
if current_agent_env is not None and (current_agent_env.env or {}) == env:
return AgentEnvResponse(
env=current_agent_env.env or {},
updated_at=format_utc_datetime(current_agent_env.updated_at),
)
now = utc_now_naive()
stmt = (
pg_insert(AgentEnv)
.values(uid=current_user.uid, env=env, updated_at=now)
.on_conflict_do_update(
index_elements=[AgentEnv.uid],
set_={"env": env, "updated_at": now},
)
.returning(AgentEnv)
)
result = await db.execute(stmt)
agent_env = result.scalar_one()
await db.commit()
return AgentEnvResponse(env=agent_env.env or {}, updated_at=format_utc_datetime(agent_env.updated_at))

View File

@ -1,15 +1,17 @@
import { apiGet, apiPost, apiPut, apiDelete } from './base'
const API_KEY_BASE_PATH = '/api/user/apikey'
export const apikeyApi = {
list: (skip = 0, limit = 100) => apiGet('/api/apikey/', { params: { skip, limit } }),
list: (skip = 0, limit = 100) => apiGet(`${API_KEY_BASE_PATH}/`, { params: { skip, limit } }),
create: (data) => apiPost('/api/apikey/', data),
create: (data) => apiPost(`${API_KEY_BASE_PATH}/`, data),
get: (id) => apiGet(`/api/apikey/${id}`),
get: (id) => apiGet(`${API_KEY_BASE_PATH}/${id}`),
update: (id, data) => apiPut(`/api/apikey/${id}`, data),
update: (id, data) => apiPut(`${API_KEY_BASE_PATH}/${id}`, data),
delete: (id) => apiDelete(`/api/apikey/${id}`),
delete: (id) => apiDelete(`${API_KEY_BASE_PATH}/${id}`),
regenerate: (id) => apiPost(`/api/apikey/${id}/regenerate`)
regenerate: (id) => apiPost(`${API_KEY_BASE_PATH}/${id}/regenerate`)
}