Files
wehub-resource-sync 1443d3fdf9
Ruff Format Check / Ruff Format & Lint (push) Failing after 7m39s
Deploy VitePress site to Pages / build (push) Failing after 9m11s
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:26 +08:00

328 lines
11 KiB
Python

"""用户级配置与凭据路由"""
import re
from typing import Any
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
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_current_user, get_db, get_required_user
from yuxi.config import UserConfig, UserConfigSchema
from yuxi.storage.minio import upload_image_to_minio
from yuxi.storage.postgres.models_business import APIKey, AgentEnv, User
from yuxi.utils.auth_utils import AuthUtils
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
MAX_USER_IMAGE_SIZE_BYTES = 5 * 1024 * 1024
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
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
async def get_logged_in_user(user: User | None = Depends(get_current_user)) -> User:
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="请登录后再访问",
headers={"WWW-Authenticate": "Bearer"},
)
return user
@user_router.get("/config", response_model=dict)
async def get_user_config(
current_user: User = Depends(get_logged_in_user),
db: AsyncSession = Depends(get_db),
):
user_config = await UserConfig.load(db, current_user.uid)
return user_config.dump_config()
@user_router.put("/config", response_model=dict)
async def update_user_config(
data: UserConfigSchema,
current_user: User = Depends(get_logged_in_user),
db: AsyncSession = Depends(get_db),
):
user_config = await UserConfig(uid=current_user.uid, schema=data).save(db)
return user_config.dump_config()
@user_router.post("/upload-image", response_model=dict)
async def upload_user_image(file: UploadFile = File(...), current_user: User = Depends(get_required_user)):
try:
image_url = await upload_image_to_minio(
file,
object_prefix=f"images/{current_user.uid}",
max_size_bytes=MAX_USER_IMAGE_SIZE_BYTES,
too_large_message="图片大小不能超过 5MB",
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return {"success": True, "image_url": image_url, "url": image_url}
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),
):
if data.user_id and data.user_id != current_user.id and current_user.role != "superadmin":
raise HTTPException(status_code=403, detail="无权为其他用户创建 API Key")
target_user = current_user
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="关联的用户不存在")
target_user = user
if data.department_id is not None and data.department_id != target_user.department_id:
raise HTTPException(status_code=403, detail="API Key 部门必须与关联用户部门一致")
full_key, key_hash, key_prefix = AuthUtils.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 = AuthUtils.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)
)
await db.execute(stmt)
await db.commit()
# 直接返回刚写入的 env/now,避免身份映射中的旧实例属性导致返回陈旧值
return AgentEnvResponse(env=env, updated_at=format_utc_datetime(now))