- 替换开发环境API基础URL为本地服务地址 - 实现签名URL本地缓存机制,解决微信按URL缓存图片导致重复下载问题 - 添加缓存清理功能,包括过期清理和按需删除特定缓存项 - 集成阿里云OSS上传功能,支持直传减少服务器带宽占用 - 优化头像地址处理逻辑,统一使用签名缓存避免地址频繁变化 - 移除过时的OSS上传示例文件oss-upload.js - 更新课程页面实现跳转第三方小程序功能 - 调整打卡记录图片地址获取方式,统一使用新的签名缓存逻辑 - 优化图片上传流程,增加压缩处理和批量上传支持 - 完善认证模块中的缓存清理逻辑,切换账号时清除相关图片缓存 - 添加后端环境变量配置模板和代理文档说明
115 lines
4.5 KiB
Python
115 lines
4.5 KiB
Python
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):
|
||
"""头像规范路径 -> 可直接喂给 `<image>` 的签名地址(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)}
|