import logging import os from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from .. import db, oss_store from ..user import user_payload from .auth import get_current_user logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v1/profile", tags=["profile"]) MAX_AVATAR_SIZE = 4 * 1024 * 1024 ALLOWED_AVATAR_EXT = {".png", ".jpg", ".jpeg", ".webp"} LEGACY_AVATAR_PREFIX = "/clock/static/" def avatar_key(path): """头像路径 -> OSS 对象 key。 兼容两种格式,因为老数据还在用旧的那种: - `avatars/{uid}/avatar.jpg` 新格式(2026-09-22 起),等于对象 key - `/clock/static/avatars/{uid}/avatar.jpg` 旧格式,原本是 nginx 静态路径 ⚠️ 旧格式**不能删**:库里仍有这种值,而它对应的文件现在也在 OSS 上, 对象 key 就是剥掉 `/clock/static/` 之后的那串 —— 剥离这一步是它唯一的读法。 两种格式指向同一个对象。不是头像路径则返回空串。 """ text = (path or "").strip() if text.startswith(LEGACY_AVATAR_PREFIX): text = text[len(LEGACY_AVATAR_PREFIX):] text = text.lstrip("/") if not text.startswith("avatars/") or ".." in text: return "" return text def avatar_url(path): """头像规范路径 -> 可直接喂给 `` 的签名地址(OSS 未启用时为空串)。""" text = (path or "").strip() if not text: return "" if text.startswith(("http://", "https://")): return text key = avatar_key(text) if not key or not oss_store.is_enabled(): return "" return oss_store.signed_url(key) def _profile(user): payload = user_payload(user) return { "name": payload["name"], "student_id": payload["student_id"], "display_name": payload["display_name"], "phone": user.get("phone_display", "") or "", "group": payload["group"], "role": payload["role"], "class_groups": payload["class_groups"], # wechat_avatar 保持规范路径不变(会写回库),avatar_url 是展示专用的签名地址 "wechat_avatar": user.get("wechat_avatar", ""), "avatar_url": avatar_url(user.get("wechat_avatar", "")), "login_type": user.get("login_type", ""), } @router.get("") def get_profile(user=Depends(get_current_user)): return {"success": True, **_profile(user)} @router.post("/avatar") def upload_avatar(file: UploadFile = File(...), user=Depends(get_current_user)): data = file.file.read() if len(data) > MAX_AVATAR_SIZE: raise HTTPException(status_code=400, detail="头像不能超过4MB") safe_name = os.path.basename(file.filename or "") ext = os.path.splitext(safe_name)[1].lower() if ext not in ALLOWED_AVATAR_EXT: raise HTTPException(status_code=400, detail="不支持的文件类型") uid = str(user["id"]) filename = "avatar" + ext # 新格式:直接就是 OSS 对象 key,去掉 /clock/static 这层 nginx 前缀 url_path = "avatars/" + uid + "/" + filename if not oss_store.is_enabled(): # OSS 是现在唯一的头像存储,没有磁盘兜底了。 # 未配置就明确报错,而不是悄悄写本地 —— 本地文件取不到,写了等于丢。 raise HTTPException(status_code=503, detail="对象存储未配置,无法上传头像") try: oss_store.put_bytes(url_path, data, content_type=oss_store.guess_content_type(filename)) except Exception: # 写失败就明确报错让用户重试,**不落磁盘**:磁盘上的文件已经没有任何读路径 # 能取到(前端取头像走的是 OSS 签名 URL),落盘只是留下一份永远没人读的副本。 logger.exception("头像上传到 OSS 失败: %s", url_path) raise HTTPException(status_code=500, detail="头像保存失败,请重试") # 换扩展名时清掉同目录下其它扩展名的旧头像,否则 avatar.jpg / avatar.png 会并存 for other in ALLOWED_AVATAR_EXT: other_name = "avatar" + other if other_name != filename: try: oss_store.delete("avatars/" + uid + "/" + other_name) except Exception: logger.debug("清理旧头像对象失败: %s", other_name, exc_info=True) db.execute("UPDATE auth_users SET wechat_avatar=%s WHERE id=%s", (url_path, user["id"])) return {"success": True, "wechat_avatar": url_path, "avatar_url": avatar_url(url_path)}