From f88c7120d7cf0c002cc33bbf8678ef8416dc1293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E9=A2=86?= Date: Sat, 25 Jul 2026 18:16:10 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=E9=A1=B5=E9=9D=A2=E5=A4=B4?= =?UTF-8?q?=E9=83=A8=E8=B4=A6=E6=88=B7=E4=B8=8B=E6=8B=89=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E7=94=A8=E6=88=B7=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppHeader 下拉顶部新增用户卡片:大头像/显示名/@用户名/ 角色徽章(管理员/成员)/admin 可见租户 - 组件内读 auth session,零页面调用点改动;无 session 时与现状一致 - 全站此前无任何用户信息展示(官方最新代码亦缺失) --- .../src/components/AppHeader.tsx | 43 ++++++++++++++++++- frontend-enterprise/src/i18n/en.json | 3 +- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/frontend-enterprise/src/components/AppHeader.tsx b/frontend-enterprise/src/components/AppHeader.tsx index 64047113..55096168 100644 --- a/frontend-enterprise/src/components/AppHeader.tsx +++ b/frontend-enterprise/src/components/AppHeader.tsx @@ -10,6 +10,7 @@ import { cn } from '@/lib/utils'; import IconChevronDown from '../assets/icons/chevron-down.svg?react'; import IconLogout from '../assets/icons/logout.svg?react'; +import { getEnterpriseAuthSession } from '../auth'; import LanguageSwitcher from './LanguageSwitcher'; export type AppHeaderProps = { @@ -51,7 +52,10 @@ export default function AppHeader({ userName, className, }: AppHeaderProps) { - const initial = userName?.trim()?.[0]?.toUpperCase(); + const user = getEnterpriseAuthSession()?.user; + const displayName = user?.display_name || user?.username || ''; + const initial = (displayName || userName || '').trim()?.[0]?.toUpperCase(); + const isAdmin = user?.role === 'admin'; const leftContent = left ?? ( (title !== undefined || description !== undefined) ? ( @@ -86,6 +90,43 @@ export default function AppHeader({ align="end" className="w-fit min-w-0 rounded-[14px] border-0 bg-white p-[6px] shadow-[0px_16px_15px_rgba(0,0,0,0.1)] ring-0 [--accent:#F6F6F6] [--accent-foreground:#18181A]" > + {user && ( + <> +
+
+ + {initial ?? '--'} + +
+ + {displayName} + + {user.username && user.username !== displayName && ( + + @{user.username} + + )} +
+
+
+ + {isAdmin ? '管理员' : '成员'} + + {isAdmin && ( + + {user.tenant_id} + + )} +
+
+
+ + )} onLogout?.()} className="h-[36px] cursor-pointer gap-2 rounded-[10px] px-[12px] text-[14px] text-[#464C5E]" diff --git a/frontend-enterprise/src/i18n/en.json b/frontend-enterprise/src/i18n/en.json index 25e26457..92edd4f2 100644 --- a/frontend-enterprise/src/i18n/en.json +++ b/frontend-enterprise/src/i18n/en.json @@ -2057,5 +2057,6 @@ "五": "Fri", "六": "Sat", "…等": "…and ", - "还有": "+ " + "还有": "+ ", + "成员": "Member" } From cd67ae3a3f1f2e9521c45e419ff3749d0536da2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E9=A2=86?= Date: Sat, 25 Jul 2026 18:54:55 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=E7=94=A8=E6=88=B7=E5=A4=B4?= =?UTF-8?q?=E5=83=8F=E4=B8=8A=E4=BC=A0=E4=B8=8E=E5=B1=95=E7=A4=BA(?= =?UTF-8?q?=E5=B9=B6=E5=85=A5=E7=94=A8=E6=88=B7=E4=BF=A1=E6=81=AF=E5=8D=A1?= =?UTF-8?q?=20PR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:user_avatars 独立小表(data_url 存库), PUT/DELETE /api/auth/me/avatar(字节头嗅探防伪装,≤2MB), login 与 auth/me 返回 avatar_url - 前端:用户卡片更换头像(选图-预览-保存)与移除头像, 上传后刷新会话,AppHeader 有图显图无图回首字母 - 浏览器实测上传全流程通过 --- backend/app/api/auth.py | 86 +++++++- backend/app/db/models.py | 13 ++ backend/tests/test_user_avatar.py | 191 ++++++++++++++++++ frontend-enterprise/src/auth.ts | 1 + .../src/components/AppHeader.tsx | 186 +++++++++++++++-- frontend-enterprise/src/i18n/en.json | 5 + 6 files changed, 455 insertions(+), 27 deletions(-) create mode 100644 backend/tests/test_user_avatar.py diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 43f650af..abf3dae2 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,13 +1,14 @@ from __future__ import annotations +import base64 from typing import Literal, Optional -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile from pydantic import BaseModel from sqlmodel import Session, select from app.db import get_session -from app.db.models import User, utc_now +from app.db.models import User, UserAvatar, utc_now from app.security.auth import create_access_token, get_current_user, hash_password, verify_password from app.security.permissions import MEMBER_ROLE, is_admin_user from app.security.tenant import ensure_tenant @@ -44,10 +45,16 @@ class UserRead(BaseModel): display_name: Optional[str] = None role: Literal["admin", "member"] source: str = "web" + # 仅 /me 与 /login 带出(头像为大字段,用户列表等批量端点不携带) + avatar_url: Optional[str] = None created_at: Optional[str] = None updated_at: Optional[str] = None +class AvatarRead(BaseModel): + avatar_url: str + + class LoginResponse(BaseModel): token: str user: UserRead @@ -66,12 +73,73 @@ def login(request: LoginRequest, db: Session = Depends(get_session)) -> LoginRes if not user or not verify_password(request.password, user.password_hash): raise HTTPException(status_code=401, detail="Invalid username or password") - return LoginResponse(token=create_access_token(user), user=_user_read(user)) + return LoginResponse( + token=create_access_token(user), + user=_user_read(user, _avatar_url_for(db, user.id)), + ) @router.get("/me", response_model=UserRead) -def me(user: User = Depends(get_current_user)) -> UserRead: - return _user_read(user) +def me(user: User = Depends(get_current_user), db: Session = Depends(get_session)) -> UserRead: + return _user_read(user, _avatar_url_for(db, user.id)) + + +MAX_AVATAR_BYTES = 2 * 1024 * 1024 +# 头像类型嗅探:以实际字节头为准(防伪装 content-type),仅 png/jpeg/webp/gif +_AVATAR_MAGIC: tuple[tuple[bytes, str], ...] = ( + (b"\x89PNG\r\n\x1a\n", "image/png"), + (b"\xff\xd8\xff", "image/jpeg"), + (b"GIF87a", "image/gif"), + (b"GIF89a", "image/gif"), +) + + +def _sniff_avatar_content_type(data: bytes) -> Optional[str]: + """按字节头识别图片类型,返回规范 content-type;非支持图片返回 None。""" + for magic, content_type in _AVATAR_MAGIC: + if data.startswith(magic): + return content_type + if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return "image/webp" + return None + + +@router.put("/me/avatar", response_model=AvatarRead) +async def update_my_avatar( + file: UploadFile = File(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_session), +) -> AvatarRead: + """上传/覆盖当前用户头像:multipart 单文件,图片 ≤2MB,以 data_url 存库(upsert)。""" + data = await file.read() + if len(data) > MAX_AVATAR_BYTES: + raise HTTPException(status_code=413, detail="头像文件超过 2MB 大小限制") + content_type = _sniff_avatar_content_type(data) + if not content_type: + raise HTTPException(status_code=400, detail="仅支持 png/jpeg/webp/gif 格式的图片") + data_url = f"data:{content_type};base64,{base64.b64encode(data).decode('ascii')}" + avatar = db.get(UserAvatar, current_user.id) + if avatar: + avatar.data_url = data_url + avatar.updated_at = utc_now() + else: + avatar = UserAvatar(user_id=current_user.id, data_url=data_url) + db.add(avatar) + db.commit() + return AvatarRead(avatar_url=data_url) + + +@router.delete("/me/avatar", status_code=204) +def delete_my_avatar( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_session), +) -> Response: + """删除当前用户头像(无头像时幂等 204)。""" + avatar = db.get(UserAvatar, current_user.id) + if avatar: + db.delete(avatar) + db.commit() + return Response(status_code=204) @router.post("/users", response_model=UserRead) @@ -168,7 +236,7 @@ def delete_user( return {"ok": True} -def _user_read(user: User) -> UserRead: +def _user_read(user: User, avatar_url: Optional[str] = None) -> UserRead: return UserRead( id=user.id, tenant_id=user.tenant_id, @@ -176,11 +244,17 @@ def _user_read(user: User) -> UserRead: display_name=user.display_name, role=user.role, source=user.source, + avatar_url=avatar_url, created_at=user.created_at.isoformat() if user.created_at else None, updated_at=user.updated_at.isoformat() if user.updated_at else None, ) +def _avatar_url_for(db: Session, user_id: str) -> Optional[str]: + avatar = db.get(UserAvatar, user_id) + return avatar.data_url if avatar else None + + def _require_admin(user: User, tenant_id: str) -> None: if not is_admin_user(user): raise HTTPException(status_code=403, detail="Only administrator can manage accounts") diff --git a/backend/app/db/models.py b/backend/app/db/models.py index e3a5957c..df0ba06d 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -41,6 +41,19 @@ class User(SQLModel, table=True): updated_at: datetime = Field(default_factory=utc_now) +class UserAvatar(SQLModel, table=True): + """用户头像:小图以 data_url 直接存库(与聊天附件内联方式一致), + + 独立小表避免 users 热表膨胀;create_all 建表,无需 ALTER。 + """ + + __tablename__ = "user_avatars" + + user_id: str = Field(primary_key=True) + data_url: str + updated_at: datetime = Field(default_factory=utc_now) + + class Skill(SQLModel, table=True): __tablename__ = "skills" __table_args__ = (UniqueConstraint("tenant_id", "skill_id", name="uq_skill_tenant_skill_id"),) diff --git a/backend/tests/test_user_avatar.py b/backend/tests/test_user_avatar.py new file mode 100644 index 00000000..1cd62db7 --- /dev/null +++ b/backend/tests/test_user_avatar.py @@ -0,0 +1,191 @@ +"""用户头像上传:data_url 存库、字节头嗅探、大小限制、me/login 带出。""" + +import base64 + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine, select + +import app.api.auth as auth_api +from app.db import get_session +from app.db.models import Tenant, User, UserAvatar +from app.security.auth import create_access_token, hash_password + +_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32 +_GIF_BYTES = b"GIF89a" + b"\x00" * 32 +_WEBP_BYTES = b"RIFF" + b"\x24\x00\x00\x00" + b"WEBP" + b"VP8 " + b"\x00" * 16 + + +def _test_engine(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return engine + + +def _make_client(engine): + app = FastAPI() + app.include_router(auth_api.router) + + def override_get_session(): + with Session(engine) as session: + yield session + + app.dependency_overrides[get_session] = override_get_session + return TestClient(app) + + +def _seed_user(engine, *, password: str = "secret") -> User: + with Session(engine) as db: + db.add(Tenant(id="tenant_demo", name="Demo")) + user = User( + id="user_web", + tenant_id="tenant_demo", + username="zhangsan", + display_name="张三", + password_hash=hash_password(password), + ) + db.add(user) + db.commit() + db.refresh(user) + db.expunge(user) + return user + + +def _auth(user: User) -> dict[str, str]: + return {"Authorization": f"Bearer {create_access_token(user)}"} + + +def _upload( + client: TestClient, + user: User, + data: bytes, + *, + content_type: str = "image/png", + filename: str = "avatar.png", +): + return client.put( + "/api/auth/me/avatar", + files={"file": (filename, data, content_type)}, + headers=_auth(user), + ) + + +def test_upload_avatar_and_me_login_carry_avatar_url() -> None: + engine = _test_engine() + user = _seed_user(engine) + client = _make_client(engine) + + response = _upload(client, user, _PNG_BYTES) + assert response.status_code == 200 + avatar_url = response.json()["avatar_url"] + assert avatar_url == f"data:image/png;base64,{base64.b64encode(_PNG_BYTES).decode('ascii')}" + + me = client.get("/api/auth/me", headers=_auth(user)) + assert me.status_code == 200 + assert me.json()["avatar_url"] == avatar_url + + login = client.post( + "/api/auth/login", + json={"tenant_id": "tenant_demo", "username": "zhangsan", "password": "secret"}, + ) + assert login.status_code == 200 + assert login.json()["user"]["avatar_url"] == avatar_url + + with Session(engine) as db: + row = db.get(UserAvatar, user.id) + assert row is not None and row.data_url == avatar_url + + +def test_upload_avatar_overwrites_existing() -> None: + engine = _test_engine() + user = _seed_user(engine) + client = _make_client(engine) + + assert _upload(client, user, _PNG_BYTES).status_code == 200 + replaced = _upload(client, user, _GIF_BYTES, content_type="image/gif", filename="avatar.gif") + assert replaced.status_code == 200 + assert replaced.json()["avatar_url"].startswith("data:image/gif;base64,") + + with Session(engine) as db: + rows = db.exec(select(UserAvatar)).all() + # upsert:仍只有一行,内容为后传的 gif + assert len(rows) == 1 + assert rows[0].data_url == replaced.json()["avatar_url"] + + +def test_upload_accepts_webp_by_magic() -> None: + engine = _test_engine() + user = _seed_user(engine) + client = _make_client(engine) + + response = _upload(client, user, _WEBP_BYTES, content_type="image/webp", filename="a.webp") + assert response.status_code == 200 + assert response.json()["avatar_url"].startswith("data:image/webp;base64,") + + +def test_delete_avatar_is_idempotent() -> None: + engine = _test_engine() + user = _seed_user(engine) + client = _make_client(engine) + + assert _upload(client, user, _PNG_BYTES).status_code == 200 + assert client.delete("/api/auth/me/avatar", headers=_auth(user)).status_code == 204 + me = client.get("/api/auth/me", headers=_auth(user)) + assert me.json()["avatar_url"] is None + with Session(engine) as db: + assert db.exec(select(UserAvatar)).all() == [] + # 再删一次仍 204 + assert client.delete("/api/auth/me/avatar", headers=_auth(user)).status_code == 204 + + +def test_upload_rejects_non_image() -> None: + engine = _test_engine() + user = _seed_user(engine) + client = _make_client(engine) + + response = _upload( + client, user, b"plain text content", content_type="text/plain", filename="a.txt" + ) + assert response.status_code == 400 + + +def test_upload_rejects_oversize() -> None: + engine = _test_engine() + user = _seed_user(engine) + client = _make_client(engine) + + oversize = b"\x89PNG\r\n\x1a\n" + b"\x00" * (2 * 1024 * 1024) + response = _upload(client, user, oversize) + assert response.status_code == 413 + + +def test_upload_rejects_fake_content_type() -> None: + """声明 image/png 但字节头不是图片:按内容嗅探拒绝。""" + engine = _test_engine() + user = _seed_user(engine) + client = _make_client(engine) + + response = _upload(client, user, b"definitely not an image", content_type="image/png") + assert response.status_code == 400 + + +def test_me_and_login_without_avatar_return_none() -> None: + engine = _test_engine() + user = _seed_user(engine) + client = _make_client(engine) + + me = client.get("/api/auth/me", headers=_auth(user)) + assert me.status_code == 200 + assert me.json()["avatar_url"] is None + + login = client.post( + "/api/auth/login", + json={"tenant_id": "tenant_demo", "username": "zhangsan", "password": "secret"}, + ) + assert login.status_code == 200 + assert login.json()["user"]["avatar_url"] is None diff --git a/frontend-enterprise/src/auth.ts b/frontend-enterprise/src/auth.ts index 33eff375..f1c30f21 100644 --- a/frontend-enterprise/src/auth.ts +++ b/frontend-enterprise/src/auth.ts @@ -4,6 +4,7 @@ export type EnterpriseAuthUser = { username: string; display_name?: string; role: 'admin' | 'member'; + avatar_url?: string; }; export type EnterpriseAuthSession = { diff --git a/frontend-enterprise/src/components/AppHeader.tsx b/frontend-enterprise/src/components/AppHeader.tsx index 55096168..85d68d78 100644 --- a/frontend-enterprise/src/components/AppHeader.tsx +++ b/frontend-enterprise/src/components/AppHeader.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import { useRef, useState, type ReactNode } from 'react'; import { DropdownMenu, @@ -6,11 +6,19 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui'; +import { Button as UIButton } from '@/components/ui/button'; +import { notify } from '@/components/ui/app-toast'; import { cn } from '@/lib/utils'; import IconChevronDown from '../assets/icons/chevron-down.svg?react'; +import IconEdit from '../assets/icons/edit.svg?react'; import IconLogout from '../assets/icons/logout.svg?react'; -import { getEnterpriseAuthSession } from '../auth'; +import { api } from '../api/client'; +import { + getEnterpriseAuthSession, + setEnterpriseAuthSession, + type EnterpriseAuthUser, +} from '../auth'; import LanguageSwitcher from './LanguageSwitcher'; export type AppHeaderProps = { @@ -52,10 +60,80 @@ export default function AppHeader({ userName, className, }: AppHeaderProps) { - const user = getEnterpriseAuthSession()?.user; + const [user, setUser] = useState(() => getEnterpriseAuthSession()?.user); + const [pendingFile, setPendingFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(''); + const [avatarSaving, setAvatarSaving] = useState(false); + const fileInputRef = useRef(null); + const displayName = user?.display_name || user?.username || ''; const initial = (displayName || userName || '').trim()?.[0]?.toUpperCase(); const isAdmin = user?.role === 'admin'; + const avatarUrl = previewUrl || user?.avatar_url || ''; + + function clearPendingAvatar() { + if (previewUrl) URL.revokeObjectURL(previewUrl); + setPreviewUrl(''); + setPendingFile(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + + function pickAvatar(file: File | null) { + if (!file) return; + clearPendingAvatar(); + setPendingFile(file); + setPreviewUrl(URL.createObjectURL(file)); + } + + async function refreshSessionUser() { + const session = getEnterpriseAuthSession(); + if (!session?.token) return; + try { + const fresh = await api.get('/api/auth/me'); + setEnterpriseAuthSession({ token: session.token, user: fresh }); + setUser(fresh); + } catch { + // 头像操作已成功时会话刷新失败不阻断,下次登录/刷新自然同步 + } + } + + async function saveAvatar() { + if (!pendingFile || avatarSaving) return; + setAvatarSaving(true); + try { + const session = getEnterpriseAuthSession(); + const form = new FormData(); + form.append('file', pendingFile); + const apiBase = import.meta.env.VITE_API_BASE_URL || ''; + const response = await fetch(`${apiBase}/api/auth/me/avatar`, { + method: 'PUT', + headers: session?.token ? { Authorization: `Bearer ${session.token}` } : {}, + body: form, + }); + if (!response.ok) throw new Error('上传头像失败'); + notify.success('头像已更新'); + clearPendingAvatar(); + await refreshSessionUser(); + } catch (error) { + notify.error(error instanceof Error ? error.message : '上传头像失败'); + } finally { + setAvatarSaving(false); + } + } + + async function removeAvatar() { + if (avatarSaving) return; + setAvatarSaving(true); + try { + await api.delete('/api/auth/me/avatar'); + notify.success('头像已移除'); + await refreshSessionUser(); + } catch (error) { + notify.error(error instanceof Error ? error.message : '移除头像失败'); + } finally { + setAvatarSaving(false); + } + } const leftContent = left ?? ( (title !== undefined || description !== undefined) ? ( @@ -82,7 +160,11 @@ export default function AppHeader({ className="flex h-[32px] shrink-0 items-center gap-[8px] rounded-[10px] pl-[4px] pr-[8px] outline-none" > - {initial ?? '--'} + {avatarUrl ? ( + + ) : ( + (initial ?? '--') + )} @@ -94,9 +176,37 @@ export default function AppHeader({ <>
- - {initial ?? '--'} - +
+ + + + +
+ pickAvatar(event.target.files?.[0] || null)} + />
{displayName} @@ -108,21 +218,55 @@ export default function AppHeader({ )}
-
- + void saveAvatar()} + disabled={avatarSaving} + className="h-7 rounded-[8px] bg-[#18181a] px-[12px] text-[12px] font-normal text-white hover:bg-[#303030]" + > + 保存 + + + 取消 + +
+ ) : ( +
+
+ + {isAdmin ? '管理员' : '成员'} + + {user.avatar_url && ( + + )} +
+ {isAdmin && ( + + {user.tenant_id} + )} - > - {isAdmin ? '管理员' : '成员'} - - {isAdmin && ( - - {user.tenant_id} - - )} -
+
+ )}
diff --git a/frontend-enterprise/src/i18n/en.json b/frontend-enterprise/src/i18n/en.json index 92edd4f2..46dc000f 100644 --- a/frontend-enterprise/src/i18n/en.json +++ b/frontend-enterprise/src/i18n/en.json @@ -1993,6 +1993,11 @@ "待配置": "Pending configuration", "收到确认": "Receipt reaction", "确认清理": "Receipt cleanup", + "移除头像": "Remove Avatar", + "头像已更新": "Avatar updated", + "头像已移除": "Avatar removed", + "上传头像失败": "Failed to upload avatar", + "移除头像失败": "Failed to remove avatar", "企业: {1}": "Enterprise: {1}", "token 已失效,请重新扫码": "Token expired. Please scan the QR code again", "当前未连接,请检查凭证或网络": "Disconnected. Check credentials or network", From d91cf73c3a913cc24963ac71477b56c105ae521b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E9=A2=86?= Date: Sat, 25 Jul 2026 23:01:05 +0800 Subject: [PATCH 3/9] =?UTF-8?q?fix:=20=E5=A4=B4=E5=83=8F=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E5=99=A8=E9=87=8D=E5=A4=8D=E9=80=89=E6=8B=A9=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E4=B8=8D=E8=A7=A6=E5=8F=91=20onChange?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 打开文件对话框前清空 input.value,确保用户多次选择 同一图片时预览与保存流程始终可用 --- frontend-enterprise/src/components/AppHeader.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend-enterprise/src/components/AppHeader.tsx b/frontend-enterprise/src/components/AppHeader.tsx index 85d68d78..e13cdef4 100644 --- a/frontend-enterprise/src/components/AppHeader.tsx +++ b/frontend-enterprise/src/components/AppHeader.tsx @@ -181,7 +181,11 @@ export default function AppHeader({ type="button" title="更换头像" aria-label="更换头像" - onClick={() => fileInputRef.current?.click()} + onClick={() => { + // 打开文件对话框前清空 input,确保重复选择同一文件也能触发 onChange + if (fileInputRef.current) fileInputRef.current.value = ''; + fileInputRef.current?.click(); + }} className="block size-[40px] overflow-hidden rounded-full" > {avatarUrl ? ( From daa9d7668265f6cc1d628d227cd0df77c0550017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E9=A2=86?= Date: Sat, 25 Jul 2026 23:13:07 +0800 Subject: [PATCH 4/9] =?UTF-8?q?fix:=20=E5=A4=B4=E5=83=8F=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E7=8B=AC=E7=AB=8B=E5=BC=B9=E7=AA=97,?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20macOS=20=E5=8E=9F=E7=94=9F=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=AF=B9=E8=AF=9D=E6=A1=86=E7=84=A6=E7=82=B9=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 文件 input 移出下拉菜单常驻 header 根部,菜单关闭不再卸载 input - 预览/保存改为独立 Dialog,下拉菜单因系统对话框失焦关闭也不影响流程 - 修复真实桌面环境(非 Playwright)选图后无任何反馈的问题 --- .../src/components/AppHeader.tsx | 123 ++++++++++-------- 1 file changed, 71 insertions(+), 52 deletions(-) diff --git a/frontend-enterprise/src/components/AppHeader.tsx b/frontend-enterprise/src/components/AppHeader.tsx index e13cdef4..49bcbd3c 100644 --- a/frontend-enterprise/src/components/AppHeader.tsx +++ b/frontend-enterprise/src/components/AppHeader.tsx @@ -1,6 +1,9 @@ import { useRef, useState, type ReactNode } from 'react'; import { + Dialog, + DialogContent, + DialogTitle, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -204,13 +207,6 @@ export default function AppHeader({
- pickAvatar(event.target.files?.[0] || null)} - />
{displayName} @@ -222,55 +218,35 @@ export default function AppHeader({ )}
- {pendingFile ? ( -
- void saveAvatar()} - disabled={avatarSaving} - className="h-7 rounded-[8px] bg-[#18181a] px-[12px] text-[12px] font-normal text-white hover:bg-[#303030]" - > - 保存 - - +
+ - 取消 - -
- ) : ( -
-
- + {user.avatar_url && ( + - )} -
- {isAdmin && ( - - {user.tenant_id} - + 移除头像 + )}
- )} + {isAdmin && ( + + {user.tenant_id} + + )} +
@@ -286,6 +262,49 @@ export default function AppHeader({ )}
+ {/* 文件 input 常驻在 header 根部(不在下拉菜单内),菜单关闭也不会被卸载; + 预览与保存放在独立 Dialog 中,不受下拉焦点变化影响 */} + pickAvatar(event.target.files?.[0] || null)} + /> + { + if (!open && !avatarSaving) clearPendingAvatar(); + }} + > + + 更换头像 + {previewUrl && ( + + )} +
+ void saveAvatar()} + disabled={avatarSaving} + className="h-8 rounded-[10px] bg-[#18181a] px-[16px] text-[12px] font-normal text-white hover:bg-[#303030]" + > + 保存 + + + 取消 + +
+
+
); } From fffb083f3281b287a674569ec3da6afca9f5bc26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E9=A2=86?= Date: Sat, 25 Jul 2026 23:21:11 +0800 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20=E5=A4=B4=E5=83=8F=20URL=20=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E5=A2=9E=E5=8A=A0=E5=8D=8F=E8=AE=AE=E7=99=BD=E5=90=8D?= =?UTF-8?q?=E5=8D=95(CodeQL=20=E5=AE=89=E5=85=A8=E5=91=8A=E8=AD=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit img src 动态值限制为 http(s)/data:image/blob 协议, 修复 3 处 js/xss 高危告警 --- frontend-enterprise/src/components/AppHeader.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend-enterprise/src/components/AppHeader.tsx b/frontend-enterprise/src/components/AppHeader.tsx index 49bcbd3c..802874ed 100644 --- a/frontend-enterprise/src/components/AppHeader.tsx +++ b/frontend-enterprise/src/components/AppHeader.tsx @@ -73,6 +73,9 @@ export default function AppHeader({ const initial = (displayName || userName || '').trim()?.[0]?.toUpperCase(); const isAdmin = user?.role === 'admin'; const avatarUrl = previewUrl || user?.avatar_url || ''; + // 仅放行 http(s)/data:image/blob 协议,防止动态 URL 被注入 javascript: 等可执行协议 + const safeAvatarUrl = /^(https:\/\/|data:image\/|blob:)/i.test(avatarUrl.trim()) ? avatarUrl : ''; + const safePreviewUrl = /^(https:\/\/|data:image\/|blob:)/i.test(previewUrl.trim()) ? previewUrl : ''; function clearPendingAvatar() { if (previewUrl) URL.revokeObjectURL(previewUrl); @@ -163,8 +166,8 @@ export default function AppHeader({ className="flex h-[32px] shrink-0 items-center gap-[8px] rounded-[10px] pl-[4px] pr-[8px] outline-none" > - {avatarUrl ? ( - + {safeAvatarUrl ? ( + ) : ( (initial ?? '--') )} @@ -191,9 +194,9 @@ export default function AppHeader({ }} className="block size-[40px] overflow-hidden rounded-full" > - {avatarUrl ? ( + {safeAvatarUrl ? ( @@ -281,7 +284,7 @@ export default function AppHeader({ 更换头像 {previewUrl && ( From c04c7f37f0bb118315ba854affe4c125319908ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E9=A2=86?= Date: Sat, 25 Jul 2026 23:26:42 +0800 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20=E7=94=A8=20URL=20=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=81=9A=E5=A4=B4=E5=83=8F=E5=9C=B0=E5=9D=80=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C(CodeQL=20=E5=91=8A=E8=AD=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/AppHeader.tsx | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/frontend-enterprise/src/components/AppHeader.tsx b/frontend-enterprise/src/components/AppHeader.tsx index 802874ed..fa22d7b0 100644 --- a/frontend-enterprise/src/components/AppHeader.tsx +++ b/frontend-enterprise/src/components/AppHeader.tsx @@ -24,6 +24,21 @@ import { } from '../auth'; import LanguageSwitcher from './LanguageSwitcher'; +/** 只允许 http/https/data:image/blob 协议的图片地址,其余一律视为无效。 */ +function safeImageUrl(value: string): string { + const text = value.trim(); + if (!text) return ''; + try { + const parsed = new URL(text, window.location.origin); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return parsed.href; + if (parsed.protocol === 'blob:') return parsed.href; + if (parsed.protocol === 'data:' && /^data:image\//i.test(text)) return text; + } catch { + // 非法 URL 视为无效 + } + return ''; +} + export type AppHeaderProps = { /** * Page-specific content rendered on the left side of the header. When @@ -73,9 +88,9 @@ export default function AppHeader({ const initial = (displayName || userName || '').trim()?.[0]?.toUpperCase(); const isAdmin = user?.role === 'admin'; const avatarUrl = previewUrl || user?.avatar_url || ''; - // 仅放行 http(s)/data:image/blob 协议,防止动态 URL 被注入 javascript: 等可执行协议 - const safeAvatarUrl = /^(https:\/\/|data:image\/|blob:)/i.test(avatarUrl.trim()) ? avatarUrl : ''; - const safePreviewUrl = /^(https:\/\/|data:image\/|blob:)/i.test(previewUrl.trim()) ? previewUrl : ''; + // 仅放行 http/https/data:image/blob 协议,阻止 javascript: 等可执行协议注入 + const safeAvatarUrl = safeImageUrl(avatarUrl); + const safePreviewUrl = safeImageUrl(previewUrl); function clearPendingAvatar() { if (previewUrl) URL.revokeObjectURL(previewUrl); From 775af999153c992a30f4f09d50dbf9df56d92632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E9=A2=86?= Date: Sat, 25 Jul 2026 23:44:04 +0800 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20=E5=A4=B4=E5=83=8F=E9=80=89?= =?UTF-8?q?=E5=9B=BE=E5=8D=B3=E4=BC=A0,=E5=85=8D=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E4=B8=A4=E6=AD=A5=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 选择图片后立即上传并乐观渲染,带上传中指示 - 失败自动回滚并提示;移除预览/保存独立弹窗 --- .../src/components/AppHeader.tsx | 89 ++++++------------- 1 file changed, 28 insertions(+), 61 deletions(-) diff --git a/frontend-enterprise/src/components/AppHeader.tsx b/frontend-enterprise/src/components/AppHeader.tsx index fa22d7b0..9262e0e5 100644 --- a/frontend-enterprise/src/components/AppHeader.tsx +++ b/frontend-enterprise/src/components/AppHeader.tsx @@ -1,9 +1,6 @@ import { useRef, useState, type ReactNode } from 'react'; import { - Dialog, - DialogContent, - DialogTitle, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -79,33 +76,23 @@ export default function AppHeader({ className, }: AppHeaderProps) { const [user, setUser] = useState(() => getEnterpriseAuthSession()?.user); - const [pendingFile, setPendingFile] = useState(null); - const [previewUrl, setPreviewUrl] = useState(''); + const [uploadPreviewUrl, setUploadPreviewUrl] = useState(''); const [avatarSaving, setAvatarSaving] = useState(false); const fileInputRef = useRef(null); const displayName = user?.display_name || user?.username || ''; const initial = (displayName || userName || '').trim()?.[0]?.toUpperCase(); const isAdmin = user?.role === 'admin'; - const avatarUrl = previewUrl || user?.avatar_url || ''; + const avatarUrl = uploadPreviewUrl || user?.avatar_url || ''; // 仅放行 http/https/data:image/blob 协议,阻止 javascript: 等可执行协议注入 const safeAvatarUrl = safeImageUrl(avatarUrl); - const safePreviewUrl = safeImageUrl(previewUrl); - function clearPendingAvatar() { - if (previewUrl) URL.revokeObjectURL(previewUrl); - setPreviewUrl(''); - setPendingFile(null); + function clearUploadPreview() { + if (uploadPreviewUrl) URL.revokeObjectURL(uploadPreviewUrl); + setUploadPreviewUrl(''); if (fileInputRef.current) fileInputRef.current.value = ''; } - function pickAvatar(file: File | null) { - if (!file) return; - clearPendingAvatar(); - setPendingFile(file); - setPreviewUrl(URL.createObjectURL(file)); - } - async function refreshSessionUser() { const session = getEnterpriseAuthSession(); if (!session?.token) return; @@ -118,13 +105,17 @@ export default function AppHeader({ } } - async function saveAvatar() { - if (!pendingFile || avatarSaving) return; + // 选图即传:本地预览乐观渲染,成功后刷新会话;失败回滚并提示 + async function pickAvatar(file: File | null) { + if (!file || avatarSaving) return; + clearUploadPreview(); + const objectUrl = URL.createObjectURL(file); + setUploadPreviewUrl(objectUrl); setAvatarSaving(true); try { const session = getEnterpriseAuthSession(); const form = new FormData(); - form.append('file', pendingFile); + form.append('file', file); const apiBase = import.meta.env.VITE_API_BASE_URL || ''; const response = await fetch(`${apiBase}/api/auth/me/avatar`, { method: 'PUT', @@ -133,12 +124,12 @@ export default function AppHeader({ }); if (!response.ok) throw new Error('上传头像失败'); notify.success('头像已更新'); - clearPendingAvatar(); await refreshSessionUser(); } catch (error) { notify.error(error instanceof Error ? error.message : '上传头像失败'); } finally { setAvatarSaving(false); + clearUploadPreview(); } } @@ -207,7 +198,10 @@ export default function AppHeader({ if (fileInputRef.current) fileInputRef.current.value = ''; fileInputRef.current?.click(); }} - className="block size-[40px] overflow-hidden rounded-full" + className={cn( + 'block size-[40px] overflow-hidden rounded-full transition-opacity', + avatarSaving && 'pointer-events-none opacity-60', + )} > {safeAvatarUrl ? ( )} - - - + {avatarSaving && ( + + + + )} + {!avatarSaving && ( + + + + )}
@@ -281,7 +282,7 @@ export default function AppHeader({ )}
{/* 文件 input 常驻在 header 根部(不在下拉菜单内),菜单关闭也不会被卸载; - 预览与保存放在独立 Dialog 中,不受下拉焦点变化影响 */} + 选图即传:本地预览乐观渲染,上传成功刷新会话,失败回滚 */} pickAvatar(event.target.files?.[0] || null)} /> - { - if (!open && !avatarSaving) clearPendingAvatar(); - }} - > - - 更换头像 - {previewUrl && ( - - )} -
- void saveAvatar()} - disabled={avatarSaving} - className="h-8 rounded-[10px] bg-[#18181a] px-[16px] text-[12px] font-normal text-white hover:bg-[#303030]" - > - 保存 - - - 取消 - -
-
-
); } From e1da6110fc60bb82f551ae0e109b0872549db00c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E9=A2=86?= Date: Mon, 27 Jul 2026 21:43:02 +0800 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20=E5=A4=8D=E6=A0=B8=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=A4=B4=E5=83=8F=E9=A2=84=E8=A7=88=20blob?= =?UTF-8?q?=20URL=20=E9=87=8A=E6=94=BE=E4=B8=8E=E4=B8=8A=E4=BC=A0=E5=A4=A7?= =?UTF-8?q?=E5=B0=8F=E9=A2=84=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppHeader 预览 blob URL 改 ref 跟踪,替换/清除/组件卸载均正确 revoke, 不受 pickAvatar 闭包旧 state 快照影响 - 头像上传先按 Content-Length 预检(>2MB+64KB 直接 413),再限量读取 (最多 MAX+1 字节)硬兜底,超限不再完整读入内存 - 回归测试:Content-Length 预检路径 413 --- backend/app/api/auth.py | 13 +++++++++-- backend/tests/test_user_avatar.py | 11 +++++++++ .../src/components/AppHeader.tsx | 23 +++++++++++++++---- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index abf3dae2..88936c99 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -3,7 +3,7 @@ import base64 from typing import Literal, Optional -from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile +from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, Response, UploadFile from pydantic import BaseModel from sqlmodel import Session, select @@ -85,6 +85,8 @@ def me(user: User = Depends(get_current_user), db: Session = Depends(get_session MAX_AVATAR_BYTES = 2 * 1024 * 1024 +# multipart 边界与头部开销的上限估计:Content-Length 预检放行正常图片,拦截明显超限请求 +_AVATAR_MULTIPART_OVERHEAD = 64 * 1024 # 头像类型嗅探:以实际字节头为准(防伪装 content-type),仅 png/jpeg/webp/gif _AVATAR_MAGIC: tuple[tuple[bytes, str], ...] = ( (b"\x89PNG\r\n\x1a\n", "image/png"), @@ -106,12 +108,19 @@ def _sniff_avatar_content_type(data: bytes) -> Optional[str]: @router.put("/me/avatar", response_model=AvatarRead) async def update_my_avatar( + request: Request, file: UploadFile = File(...), current_user: User = Depends(get_current_user), db: Session = Depends(get_session), ) -> AvatarRead: """上传/覆盖当前用户头像:multipart 单文件,图片 ≤2MB,以 data_url 存库(upsert)。""" - data = await file.read() + # 先按 Content-Length 快速拒绝明显超限的请求,避免把超大请求体完整读入内存 + content_length = request.headers.get("content-length") + if content_length and content_length.isdigit(): + if int(content_length) > MAX_AVATAR_BYTES + _AVATAR_MULTIPART_OVERHEAD: + raise HTTPException(status_code=413, detail="头像文件超过 2MB 大小限制") + # 限量读取(最多 MAX+1 字节)做硬性兜底,覆盖 Content-Length 缺失或虚报的情况 + data = await file.read(MAX_AVATAR_BYTES + 1) if len(data) > MAX_AVATAR_BYTES: raise HTTPException(status_code=413, detail="头像文件超过 2MB 大小限制") content_type = _sniff_avatar_content_type(data) diff --git a/backend/tests/test_user_avatar.py b/backend/tests/test_user_avatar.py index 1cd62db7..cd890fad 100644 --- a/backend/tests/test_user_avatar.py +++ b/backend/tests/test_user_avatar.py @@ -164,6 +164,17 @@ def test_upload_rejects_oversize() -> None: assert response.status_code == 413 +def test_upload_rejects_oversize_by_content_length() -> None: + """请求体 Content-Length 明显超限(>2MB+multipart 开销):预检直接 413,不读完整内容。""" + engine = _test_engine() + user = _seed_user(engine) + client = _make_client(engine) + + oversize = b"\x89PNG\r\n\x1a\n" + b"\x00" * (2 * 1024 * 1024 + 128 * 1024) + response = _upload(client, user, oversize) + assert response.status_code == 413 + + def test_upload_rejects_fake_content_type() -> None: """声明 image/png 但字节头不是图片:按内容嗅探拒绝。""" engine = _test_engine() diff --git a/frontend-enterprise/src/components/AppHeader.tsx b/frontend-enterprise/src/components/AppHeader.tsx index 9262e0e5..1162aff0 100644 --- a/frontend-enterprise/src/components/AppHeader.tsx +++ b/frontend-enterprise/src/components/AppHeader.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, type ReactNode } from 'react'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; import { DropdownMenu, @@ -77,6 +77,7 @@ export default function AppHeader({ }: AppHeaderProps) { const [user, setUser] = useState(() => getEnterpriseAuthSession()?.user); const [uploadPreviewUrl, setUploadPreviewUrl] = useState(''); + const uploadPreviewUrlRef = useRef(''); const [avatarSaving, setAvatarSaving] = useState(false); const fileInputRef = useRef(null); @@ -87,12 +88,26 @@ export default function AppHeader({ // 仅放行 http/https/data:image/blob 协议,阻止 javascript: 等可执行协议注入 const safeAvatarUrl = safeImageUrl(avatarUrl); + // 预览 blob URL 由 ref 跟踪:替换/清除/组件卸载时都能 revoke 到最新值,不受闭包快照影响 + function replaceUploadPreview(next: string) { + const prev = uploadPreviewUrlRef.current; + if (prev && prev !== next) URL.revokeObjectURL(prev); + uploadPreviewUrlRef.current = next; + setUploadPreviewUrl(next); + } + function clearUploadPreview() { - if (uploadPreviewUrl) URL.revokeObjectURL(uploadPreviewUrl); - setUploadPreviewUrl(''); + replaceUploadPreview(''); if (fileInputRef.current) fileInputRef.current.value = ''; } + useEffect( + () => () => { + if (uploadPreviewUrlRef.current) URL.revokeObjectURL(uploadPreviewUrlRef.current); + }, + [], + ); + async function refreshSessionUser() { const session = getEnterpriseAuthSession(); if (!session?.token) return; @@ -110,7 +125,7 @@ export default function AppHeader({ if (!file || avatarSaving) return; clearUploadPreview(); const objectUrl = URL.createObjectURL(file); - setUploadPreviewUrl(objectUrl); + replaceUploadPreview(objectUrl); setAvatarSaving(true); try { const session = getEnterpriseAuthSession(); From df35e0558421c10c5fb106b4c3712c8a62d9dfef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E9=A2=86?= Date: Tue, 28 Jul 2026 23:06:43 +0800 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20=E5=A4=8D=E6=A0=B8=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=A4=B4=E5=83=8F=E4=B8=8D=E5=86=8D=E5=86=85?= =?UTF-8?q?=E8=81=94=E8=BF=9B=20login/me=20=E4=B8=8E=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=AD=98=E5=82=A8,=E5=88=A0=E7=94=A8=E6=88=B7=E7=BA=A7?= =?UTF-8?q?=E8=81=94=E5=88=A0=E5=A4=B4=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - login/me/上传响应的 avatar_url 一律为资源指针 /api/auth/me/avatar, 不再内联 base64 二进制(原 2MB 图≈2.67MB JSON 会把 localStorage 撑爆,QuotaExceededError 还被登录页误报为账号密码错误) - 新增 GET /me/avatar 字节端点(content-type 嗅探+no-cache), 前端凭指针用认证请求拉字节转 blob URL 渲染;blob 生命周期沿用 ref 跟踪(替换/卸载均 revoke) - setEnterpriseAuthSession 存储异常降级重试,最终抛真实原因, 不再被误报为凭据错误 - delete_user 显式级联删除 UserAvatar,不留孤儿记录 - 测试:指针契约/字节端点(200/404/401)/覆盖写/级联删除共 12 例 --- backend/app/api/auth.py | 57 +++++++++++-- backend/tests/test_user_avatar.py | 79 ++++++++++++++++--- frontend-enterprise/src/auth.ts | 16 +++- .../src/components/AppHeader.tsx | 57 +++++++++++-- frontend-enterprise/src/i18n/en.json | 1 + 5 files changed, 188 insertions(+), 22 deletions(-) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 88936c99..1337be02 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import logging from typing import Literal, Optional from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, Response, UploadFile @@ -14,6 +15,8 @@ from app.security.tenant import ensure_tenant +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/api/auth", tags=["auth"]) @@ -45,7 +48,8 @@ class UserRead(BaseModel): display_name: Optional[str] = None role: Literal["admin", "member"] source: str = "web" - # 仅 /me 与 /login 带出(头像为大字段,用户列表等批量端点不携带) + # 仅 /me 与 /login 带出:头像资源指针(存在性标识),不内联二进制—— + # 完整 data_url 可达 2.67MB,内联会把登录/会话刷新响应与前端 localStorage 撑爆 avatar_url: Optional[str] = None created_at: Optional[str] = None updated_at: Optional[str] = None @@ -75,18 +79,20 @@ def login(request: LoginRequest, db: Session = Depends(get_session)) -> LoginRes return LoginResponse( token=create_access_token(user), - user=_user_read(user, _avatar_url_for(db, user.id)), + user=_user_read(user, _avatar_pointer_for(db, user.id)), ) @router.get("/me", response_model=UserRead) def me(user: User = Depends(get_current_user), db: Session = Depends(get_session)) -> UserRead: - return _user_read(user, _avatar_url_for(db, user.id)) + return _user_read(user, _avatar_pointer_for(db, user.id)) MAX_AVATAR_BYTES = 2 * 1024 * 1024 # multipart 边界与头部开销的上限估计:Content-Length 预检放行正常图片,拦截明显超限请求 _AVATAR_MULTIPART_OVERHEAD = 64 * 1024 +# 头像资源路径:login/me 返回的 avatar_url 即此指针,前端凭它用认证请求拉取字节 +AVATAR_RESOURCE_PATH = "/api/auth/me/avatar" # 头像类型嗅探:以实际字节头为准(防伪装 content-type),仅 png/jpeg/webp/gif _AVATAR_MAGIC: tuple[tuple[bytes, str], ...] = ( (b"\x89PNG\r\n\x1a\n", "image/png"), @@ -106,6 +112,27 @@ def _sniff_avatar_content_type(data: bytes) -> Optional[str]: return None +@router.get("/me/avatar") +def get_my_avatar( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_session), +) -> Response: + """头像资源端点:返回图片字节(不内联进 login/me,避免大字段进会话存储)。""" + avatar = db.get(UserAvatar, current_user.id) + if not avatar: + raise HTTPException(status_code=404, detail="Avatar not found") + parsed = _parse_avatar_data_url(avatar.data_url) + if not parsed: + logger.warning("用户 %s 的头像数据损坏,按不存在处理", current_user.id) + raise HTTPException(status_code=404, detail="Avatar not found") + data, content_type = parsed + return Response( + content=data, + media_type=content_type, + headers={"Cache-Control": "private, no-cache"}, + ) + + @router.put("/me/avatar", response_model=AvatarRead) async def update_my_avatar( request: Request, @@ -135,7 +162,8 @@ async def update_my_avatar( avatar = UserAvatar(user_id=current_user.id, data_url=data_url) db.add(avatar) db.commit() - return AvatarRead(avatar_url=data_url) + # 响应同样不内联二进制:返回资源指针,前端经 GET /me/avatar 拉取字节 + return AvatarRead(avatar_url=AVATAR_RESOURCE_PATH) @router.delete("/me/avatar", status_code=204) @@ -240,6 +268,10 @@ def delete_user( raise HTTPException(status_code=404, detail="Account not found") if user.id == current_user.id or is_admin_user(user): raise HTTPException(status_code=400, detail="Administrator account cannot be deleted") + # 头像为独立小表、无外键级联:显式随用户删除,避免残留孤儿记录 + avatar = db.get(UserAvatar, user_id) + if avatar: + db.delete(avatar) db.delete(user) db.commit() return {"ok": True} @@ -259,9 +291,22 @@ def _user_read(user: User, avatar_url: Optional[str] = None) -> UserRead: ) -def _avatar_url_for(db: Session, user_id: str) -> Optional[str]: +def _avatar_pointer_for(db: Session, user_id: str) -> Optional[str]: + """头像存在性指针:有头像返回资源路径,无返回 None(绝不内联二进制)。""" avatar = db.get(UserAvatar, user_id) - return avatar.data_url if avatar else None + return AVATAR_RESOURCE_PATH if avatar else None + + +def _parse_avatar_data_url(data_url: str) -> Optional[tuple[bytes, str]]: + """拆解 data:image/*;base64,... 为(字节, content-type);非法返回 None。""" + try: + meta, payload = data_url.split(",", 1) + content_type = meta.removeprefix("data:").removesuffix(";base64") + if not content_type.startswith("image/"): + return None + return base64.b64decode(payload), content_type + except (ValueError, TypeError): + return None def _require_admin(user: User, tenant_id: str) -> None: diff --git a/backend/tests/test_user_avatar.py b/backend/tests/test_user_avatar.py index cd890fad..e6700488 100644 --- a/backend/tests/test_user_avatar.py +++ b/backend/tests/test_user_avatar.py @@ -75,30 +75,54 @@ def _upload( ) -def test_upload_avatar_and_me_login_carry_avatar_url() -> None: +def test_upload_avatar_and_me_login_carry_avatar_pointer() -> None: engine = _test_engine() user = _seed_user(engine) client = _make_client(engine) response = _upload(client, user, _PNG_BYTES) assert response.status_code == 200 - avatar_url = response.json()["avatar_url"] - assert avatar_url == f"data:image/png;base64,{base64.b64encode(_PNG_BYTES).decode('ascii')}" + # 响应不内联二进制:avatar_url 一律为资源指针 + assert response.json()["avatar_url"] == "/api/auth/me/avatar" + expected_data_url = f"data:image/png;base64,{base64.b64encode(_PNG_BYTES).decode('ascii')}" me = client.get("/api/auth/me", headers=_auth(user)) assert me.status_code == 200 - assert me.json()["avatar_url"] == avatar_url + assert me.json()["avatar_url"] == "/api/auth/me/avatar" login = client.post( "/api/auth/login", json={"tenant_id": "tenant_demo", "username": "zhangsan", "password": "secret"}, ) assert login.status_code == 200 - assert login.json()["user"]["avatar_url"] == avatar_url + assert login.json()["user"]["avatar_url"] == "/api/auth/me/avatar" with Session(engine) as db: row = db.get(UserAvatar, user.id) - assert row is not None and row.data_url == avatar_url + # 完整 data_url 只存在于库里 + assert row is not None and row.data_url == expected_data_url + + +def test_get_avatar_returns_image_bytes() -> None: + engine = _test_engine() + user = _seed_user(engine) + client = _make_client(engine) + + assert _upload(client, user, _PNG_BYTES).status_code == 200 + response = client.get("/api/auth/me/avatar", headers=_auth(user)) + assert response.status_code == 200 + assert response.headers["content-type"].startswith("image/png") + assert response.content == _PNG_BYTES + assert "no-cache" in response.headers.get("cache-control", "") + + +def test_get_avatar_404_without_avatar_and_requires_auth() -> None: + engine = _test_engine() + user = _seed_user(engine) + client = _make_client(engine) + + assert client.get("/api/auth/me/avatar", headers=_auth(user)).status_code == 404 + assert client.get("/api/auth/me/avatar").status_code == 401 def test_upload_avatar_overwrites_existing() -> None: @@ -109,13 +133,17 @@ def test_upload_avatar_overwrites_existing() -> None: assert _upload(client, user, _PNG_BYTES).status_code == 200 replaced = _upload(client, user, _GIF_BYTES, content_type="image/gif", filename="avatar.gif") assert replaced.status_code == 200 - assert replaced.json()["avatar_url"].startswith("data:image/gif;base64,") + assert replaced.json()["avatar_url"] == "/api/auth/me/avatar" + + fetched = client.get("/api/auth/me/avatar", headers=_auth(user)) + assert fetched.content == _GIF_BYTES + assert fetched.headers["content-type"].startswith("image/gif") with Session(engine) as db: rows = db.exec(select(UserAvatar)).all() # upsert:仍只有一行,内容为后传的 gif assert len(rows) == 1 - assert rows[0].data_url == replaced.json()["avatar_url"] + assert rows[0].data_url.startswith("data:image/gif;base64,") def test_upload_accepts_webp_by_magic() -> None: @@ -125,7 +153,10 @@ def test_upload_accepts_webp_by_magic() -> None: response = _upload(client, user, _WEBP_BYTES, content_type="image/webp", filename="a.webp") assert response.status_code == 200 - assert response.json()["avatar_url"].startswith("data:image/webp;base64,") + assert response.json()["avatar_url"] == "/api/auth/me/avatar" + fetched = client.get("/api/auth/me/avatar", headers=_auth(user)) + assert fetched.headers["content-type"].startswith("image/webp") + assert fetched.content == _WEBP_BYTES def test_delete_avatar_is_idempotent() -> None: @@ -137,12 +168,42 @@ def test_delete_avatar_is_idempotent() -> None: assert client.delete("/api/auth/me/avatar", headers=_auth(user)).status_code == 204 me = client.get("/api/auth/me", headers=_auth(user)) assert me.json()["avatar_url"] is None + assert client.get("/api/auth/me/avatar", headers=_auth(user)).status_code == 404 with Session(engine) as db: assert db.exec(select(UserAvatar)).all() == [] # 再删一次仍 204 assert client.delete("/api/auth/me/avatar", headers=_auth(user)).status_code == 204 +def test_delete_user_cascades_avatar() -> None: + """管理员删除用户:其头像记录一并删除,不留孤儿行。""" + engine = _test_engine() + member = _seed_user(engine) + with Session(engine) as db: + admin = User( + id="user_admin", + tenant_id="tenant_demo", + username="admin2", + display_name="管理员", + password_hash=hash_password("secret"), + role="admin", + ) + db.add(admin) + db.commit() + db.refresh(admin) + db.expunge(admin) + client = _make_client(engine) + + assert _upload(client, member, _PNG_BYTES).status_code == 200 + deleted = client.delete( + "/api/auth/users/user_web?tenant_id=tenant_demo", headers=_auth(admin) + ) + assert deleted.status_code == 200 + with Session(engine) as db: + assert db.get(User, "user_web") is None + assert db.get(UserAvatar, "user_web") is None + + def test_upload_rejects_non_image() -> None: engine = _test_engine() user = _seed_user(engine) diff --git a/frontend-enterprise/src/auth.ts b/frontend-enterprise/src/auth.ts index f1c30f21..afc1ffce 100644 --- a/frontend-enterprise/src/auth.ts +++ b/frontend-enterprise/src/auth.ts @@ -19,7 +19,21 @@ export function getEnterpriseAuthSession(): EnterpriseAuthSession | null { } export function setEnterpriseAuthSession(session: EnterpriseAuthSession): void { - window.localStorage.setItem(ENTERPRISE_AUTH_STORAGE_KEY, JSON.stringify(session)); + try { + window.localStorage.setItem(ENTERPRISE_AUTH_STORAGE_KEY, JSON.stringify(session)); + } catch { + // 存储超限等异常(极端情况):降级为不带头像字段的最小会话再试一次 + try { + const minimal: EnterpriseAuthSession = { + ...session, + user: { ...session.user, avatar_url: undefined }, + }; + window.localStorage.setItem(ENTERPRISE_AUTH_STORAGE_KEY, JSON.stringify(minimal)); + } catch { + // 抛出真实原因,避免被登录流程误报为账号/密码错误 + throw new Error('浏览器存储空间不足,请清理站点数据后重试'); + } + } } export function clearEnterpriseAuthSession(): void { diff --git a/frontend-enterprise/src/components/AppHeader.tsx b/frontend-enterprise/src/components/AppHeader.tsx index 1162aff0..28a5e221 100644 --- a/frontend-enterprise/src/components/AppHeader.tsx +++ b/frontend-enterprise/src/components/AppHeader.tsx @@ -78,22 +78,33 @@ export default function AppHeader({ const [user, setUser] = useState(() => getEnterpriseAuthSession()?.user); const [uploadPreviewUrl, setUploadPreviewUrl] = useState(''); const uploadPreviewUrlRef = useRef(''); + const [avatarBlobUrl, setAvatarBlobUrl] = useState(''); + const avatarBlobUrlRef = useRef(''); const [avatarSaving, setAvatarSaving] = useState(false); const fileInputRef = useRef(null); const displayName = user?.display_name || user?.username || ''; const initial = (displayName || userName || '').trim()?.[0]?.toUpperCase(); const isAdmin = user?.role === 'admin'; - const avatarUrl = uploadPreviewUrl || user?.avatar_url || ''; + // avatar_url 是资源指针(非图片地址):渲染只用预览/已拉取的 blob URL + const avatarUrl = uploadPreviewUrl || avatarBlobUrl; // 仅放行 http/https/data:image/blob 协议,阻止 javascript: 等可执行协议注入 const safeAvatarUrl = safeImageUrl(avatarUrl); - // 预览 blob URL 由 ref 跟踪:替换/清除/组件卸载时都能 revoke 到最新值,不受闭包快照影响 - function replaceUploadPreview(next: string) { - const prev = uploadPreviewUrlRef.current; + // blob URL 由 ref 跟踪:替换/清除/组件卸载时都能 revoke 到最新值,不受闭包快照影响 + function replaceTrackedUrl(ref: { current: string }, set: (v: string) => void, next: string) { + const prev = ref.current; if (prev && prev !== next) URL.revokeObjectURL(prev); - uploadPreviewUrlRef.current = next; - setUploadPreviewUrl(next); + ref.current = next; + set(next); + } + + function replaceUploadPreview(next: string) { + replaceTrackedUrl(uploadPreviewUrlRef, setUploadPreviewUrl, next); + } + + function replaceAvatarBlob(next: string) { + replaceTrackedUrl(avatarBlobUrlRef, setAvatarBlobUrl, next); } function clearUploadPreview() { @@ -101,9 +112,40 @@ export default function AppHeader({ if (fileInputRef.current) fileInputRef.current.value = ''; } + // 头像二进制不随 login/me 内联:凭指针用认证请求拉字节,转 blob URL 渲染 + async function loadAvatar() { + const session = getEnterpriseAuthSession(); + if (!session?.token || !session.user?.avatar_url) { + replaceAvatarBlob(''); + return; + } + try { + const apiBase = import.meta.env.VITE_API_BASE_URL || ''; + const response = await fetch(`${apiBase}/api/auth/me/avatar`, { + headers: { Authorization: `Bearer ${session.token}` }, + }); + if (!response.ok) { + replaceAvatarBlob(''); + return; + } + const blob = await response.blob(); + replaceAvatarBlob(URL.createObjectURL(blob)); + } catch { + // 头像加载失败不阻断,回退为首字母 + } + } + + const avatarPointer = user?.avatar_url || ''; + const userId = user?.id || ''; + useEffect(() => { + void loadAvatar(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [avatarPointer, userId]); + useEffect( () => () => { if (uploadPreviewUrlRef.current) URL.revokeObjectURL(uploadPreviewUrlRef.current); + if (avatarBlobUrlRef.current) URL.revokeObjectURL(avatarBlobUrlRef.current); }, [], ); @@ -140,6 +182,8 @@ export default function AppHeader({ if (!response.ok) throw new Error('上传头像失败'); notify.success('头像已更新'); await refreshSessionUser(); + // 覆盖上传时指针字符串不变,effect 不会重触发,显式重拉头像字节 + await loadAvatar(); } catch (error) { notify.error(error instanceof Error ? error.message : '上传头像失败'); } finally { @@ -155,6 +199,7 @@ export default function AppHeader({ await api.delete('/api/auth/me/avatar'); notify.success('头像已移除'); await refreshSessionUser(); + await loadAvatar(); } catch (error) { notify.error(error instanceof Error ? error.message : '移除头像失败'); } finally { diff --git a/frontend-enterprise/src/i18n/en.json b/frontend-enterprise/src/i18n/en.json index 46dc000f..58359a82 100644 --- a/frontend-enterprise/src/i18n/en.json +++ b/frontend-enterprise/src/i18n/en.json @@ -1998,6 +1998,7 @@ "头像已移除": "Avatar removed", "上传头像失败": "Failed to upload avatar", "移除头像失败": "Failed to remove avatar", + "浏览器存储空间不足,请清理站点数据后重试": "Browser storage is full. Clear site data and try again.", "企业: {1}": "Enterprise: {1}", "token 已失效,请重新扫码": "Token expired. Please scan the QR code again", "当前未连接,请检查凭证或网络": "Disconnected. Check credentials or network",