diff --git a/backend/app/api/admin_users.py b/backend/app/api/admin_users.py new file mode 100644 index 0000000..2254e86 --- /dev/null +++ b/backend/app/api/admin_users.py @@ -0,0 +1,197 @@ +"""Owner 端用户管理 API。 + +- GET /admin/users - 列出全部用户 +- POST /admin/users - 创建 member(固定角色,不允许创建 owner) +- PATCH /admin/users/{id} - 重置密码 / 启停 / 改昵称 / 改邮箱(role 永远不可改) +- DELETE /admin/users/{id} - 软删除(置 is_active=False;保留外键完整性) + +设计原则: +- 软删除而非硬删除:用户有 bookmarks / subscriptions / api_tokens / article_reads 等外键, + 硬删会污染历史数据(收藏的"作者"凭空消失)。禁用后无法登录,效果等同删除,可恢复。 +- 不允许 owner 把自己禁用/删除,避免自锁。 +- API 层禁止提权:不能创建 owner,不能修改 role。 +- 列表/详情绝不返回 password_hash。 +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import require_owner +from app.core.security import hash_password +from app.database import get_session +from app.models.user import User, UserRole +from app.schemas.user import UserCreate, UserDeleteResult, UserOut, UserUpdate + +router = APIRouter( + prefix="/admin/users", + tags=["admin"], + dependencies=[Depends(require_owner)], +) + +logger = logging.getLogger("news.admin.users") + + +def _to_out(u: User) -> UserOut: + return UserOut.model_validate(u) + + +@router.get("", response_model=list[UserOut]) +async def list_users(session: AsyncSession = Depends(get_session)): + """列出全部用户(含已禁用)。按 id 升序。""" + result = await session.execute(select(User).order_by(User.id)) + return [_to_out(u) for u in result.scalars()] + + +@router.post( + "", + response_model=UserOut, + status_code=status.HTTP_201_CREATED, +) +async def create_user( + body: UserCreate, + session: AsyncSession = Depends(get_session), + actor: User = Depends(require_owner), +): + """创建 member。role 固定为 member,前端不需要传(后端兜底)。""" + # 防御性兜底:即便 schemas 接受 owner,这里也强制锁住 member + u = User( + username=body.username, + email=body.email, + password_hash=hash_password(body.password), + role=UserRole.MEMBER, + is_active=True, + display_name=body.display_name, + ) + session.add(u) + try: + await session.commit() + except IntegrityError as e: + await session.rollback() + # username 或 email 唯一冲突 + msg = str(e.orig).lower() + if "username" in msg: + raise HTTPException( + status.HTTP_409_CONFLICT, + f"用户名 '{body.username}' 已存在", + ) from e + if "email" in msg: + raise HTTPException( + status.HTTP_409_CONFLICT, + f"邮箱 '{body.email}' 已被使用", + ) from e + raise HTTPException(status.HTTP_409_CONFLICT, "用户已存在") from e + + await session.refresh(u) + logger.info( + "user created: id=%s username=%s by owner_id=%s", + u.id, u.username, actor.id, + ) + return _to_out(u) + + +@router.patch("/{user_id}", response_model=UserOut) +async def update_user( + user_id: int, + body: UserUpdate, + session: AsyncSession = Depends(get_session), + actor: User = Depends(require_owner), +): + """修改用户:重置密码 / 启停 / 改昵称 / 改邮箱。 + - role 永远不能改(防越权) + - owner 不能禁用自己(防自锁) + """ + result = await session.execute(select(User).where(User.id == user_id)) + u = result.scalar_one_or_none() + if not u: + raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在") + + data = body.model_dump(exclude_unset=True) + + # 自锁保护 + if data.get("is_active") is False and u.id == actor.id: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "不能禁用自己", + ) + + # 永远不接收 role 字段(就算有人塞进来也忽略) + data.pop("role", None) + + if "password" in data and data["password"]: + u.password_hash = hash_password(data.pop("password")) + elif "password" in data: + # 空串 = 不改 + data.pop("password") + + for k, v in data.items(): + setattr(u, k, v) + + try: + await session.commit() + except IntegrityError as e: + await session.rollback() + msg = str(e.orig).lower() + if "email" in msg: + raise HTTPException( + status.HTTP_409_CONFLICT, + f"邮箱 '{data.get('email')}' 已被使用", + ) from e + raise HTTPException(status.HTTP_409_CONFLICT, "更新冲突") from e + + await session.refresh(u) + logger.info( + "user updated: id=%s username=%s fields=%s by owner_id=%s", + u.id, u.username, sorted(data.keys()), actor.id, + ) + return _to_out(u) + + +@router.delete("/{user_id}", response_model=UserDeleteResult) +async def delete_user( + user_id: int, + session: AsyncSession = Depends(get_session), + actor: User = Depends(require_owner), +): + """软删除用户(置 is_active=False)。 + + - owner 不能删除自己 + - 已经是 inactive 的:返回 200 + already_inactive=True + - 不删除 owner(防止误删唯一管理员导致失联) + """ + result = await session.execute(select(User).where(User.id == user_id)) + u = result.scalar_one_or_none() + if not u: + raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在") + + if u.id == actor.id: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "不能删除自己") + if u.role == UserRole.OWNER: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "不能删除 owner(防失联;请直接在服务器上调整)", + ) + if not u.is_active: + return UserDeleteResult( + id=u.id, + username=u.username, + is_active=False, + detail="用户已经是禁用状态", + ) + + u.is_active = False + await session.commit() + logger.info( + "user soft-deleted (deactivated): id=%s username=%s by owner_id=%s", + u.id, u.username, actor.id, + ) + return UserDeleteResult( + id=u.id, + username=u.username, + is_active=False, + detail=f"已禁用用户 '{u.username}'(可重新启用)", + ) \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index 114f6be..fd29ef9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,7 +16,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from starlette.exceptions import HTTPException as StarletteHTTPException -from app.api import admin, admin_llm, articles, auth, bookmarks, ingest, me, search, sources, subscriptions +from app.api import admin, admin_llm, admin_users, articles, auth, bookmarks, ingest, me, search, sources, subscriptions from app.config import settings from app.database import engine from app.redis_client import close_redis, get_redis @@ -103,6 +103,7 @@ app.include_router(ingest.router, prefix=API_PREFIX) app.include_router(search.router, prefix=API_PREFIX) app.include_router(admin.router, prefix=API_PREFIX) app.include_router(admin_llm.router, prefix=API_PREFIX) +app.include_router(admin_users.router, prefix=API_PREFIX) # === 健康检查 === diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..e92675f --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,64 @@ +"""User schemas(用于 owner 端管理用户)。""" +from __future__ import annotations + +import re +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator + +from app.models.user import UserRole + +# 用户名规则:3-32 字符,小写字母 / 数字 / 下划线 / 连字符;首字符必须为字母或数字 +# 与 create_user.py 的 CLI 行为保持一致(允许任意大小写),但前端界面化时引导用小写 +_USERNAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{2,31}$") + + +class UserOut(BaseModel): + """用户列表 / 详情(永远不返回 password_hash)。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + username: str + email: str | None = None + role: UserRole + is_active: bool + display_name: str | None = None + created_at: datetime + last_login_at: datetime | None = None + + +class UserCreate(BaseModel): + """owner 创建新用户。固定 role=member(不允许提权)。""" + + username: str = Field(min_length=3, max_length=32) + password: str = Field(min_length=6, max_length=128) + email: EmailStr | None = None + display_name: str | None = Field(default=None, max_length=128) + + @field_validator("username") + @classmethod + def _v_username(cls, v: str) -> str: + if not _USERNAME_RE.match(v): + raise ValueError( + "用户名只能包含小写字母、数字、下划线、连字符,且首字符必须为字母或数字(3-32 字符)" + ) + return v + + +class UserUpdate(BaseModel): + """owner 修改用户。重置密码 / 启停 / 改昵称 / 改邮箱。role 永远不可改(防越权)。""" + + password: str | None = Field(default=None, min_length=6, max_length=128) + is_active: bool | None = None + email: EmailStr | None = None + display_name: str | None = Field(default=None, max_length=128) + + +class UserDeleteResult(BaseModel): + """软删除响应 — 实际是禁用,user_id 用于前端确认。""" + + id: int + username: str + is_active: bool + detail: str \ No newline at end of file diff --git a/frontend/src/api/articles.ts b/frontend/src/api/articles.ts index 2cf2db8..5cb0b76 100644 --- a/frontend/src/api/articles.ts +++ b/frontend/src/api/articles.ts @@ -260,6 +260,42 @@ export const adminApi = { `/admin/ingest-tokens/${tokenId}` ).then((r) => r.data) }, + // === 用户管理(仅 owner)=== + listUsers() { + return http.get('/admin/users').then((r) => r.data) + }, + createUser(body: { + username: string + password: string + email?: string + display_name?: string + }) { + return http.post('/admin/users', body).then((r) => r.data) + }, + updateUser(id: number, body: { + password?: string + is_active?: boolean + email?: string | null + display_name?: string | null + }) { + return http.patch(`/admin/users/${id}`, body).then((r) => r.data) + }, + deleteUser(id: number) { + return http.delete<{ id: number; username: string; is_active: boolean; detail: string }>( + `/admin/users/${id}` + ).then((r) => r.data) + }, +} + +export interface UserOut { + id: number + username: string + email?: string | null + role: 'owner' | 'member' + is_active: boolean + display_name?: string | null + created_at: string + last_login_at?: string | null } export interface IngestTokenOut { diff --git a/frontend/src/components/AppLayout.vue b/frontend/src/components/AppLayout.vue index f8a38a5..c1084d0 100644 --- a/frontend/src/components/AppLayout.vue +++ b/frontend/src/components/AppLayout.vue @@ -37,6 +37,7 @@ const menu = computed(() => [ { key: '/bookmarks', label: '收藏', icon: () => '⭐' }, ...(auth.isOwner ? [ { key: '/admin/sources', label: '源管理(Admin)', icon: () => '🛠' }, + { key: '/admin/users', label: '用户管理(Admin)', icon: () => '👥' }, { key: '/admin/llm', label: 'LLM 智能增强', icon: () => '🤖' }, ] : []), ]) diff --git a/frontend/src/router.ts b/frontend/src/router.ts index f1481da..bf743d7 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -13,6 +13,7 @@ const routes: RouteRecordRaw[] = [ { path: 'sources', component: () => import('@/views/Sources.vue') }, { path: 'bookmarks', component: () => import('@/views/Bookmarks.vue') }, { path: 'admin/sources', component: () => import('@/views/AdminSources.vue'), meta: { ownerOnly: true } }, + { path: 'admin/users', component: () => import('@/views/AdminUsers.vue'), meta: { ownerOnly: true } }, { path: 'admin/llm', component: () => import('@/views/AdminLlmSettings.vue'), meta: { ownerOnly: true } }, ], }, diff --git a/frontend/src/views/AdminUsers.vue b/frontend/src/views/AdminUsers.vue new file mode 100644 index 0000000..71c3a0b --- /dev/null +++ b/frontend/src/views/AdminUsers.vue @@ -0,0 +1,289 @@ + + + \ No newline at end of file