Files
wechat_backend/routes/profile.py
王宏豪 3f1a13936d feat(api): 重构图片地址管理与上传机制
- 替换开发环境API基础URL为本地服务地址
- 实现签名URL本地缓存机制,解决微信按URL缓存图片导致重复下载问题
- 添加缓存清理功能,包括过期清理和按需删除特定缓存项
- 集成阿里云OSS上传功能,支持直传减少服务器带宽占用
- 优化头像地址处理逻辑,统一使用签名缓存避免地址频繁变化
- 移除过时的OSS上传示例文件oss-upload.js
- 更新课程页面实现跳转第三方小程序功能
- 调整打卡记录图片地址获取方式,统一使用新的签名缓存逻辑
- 优化图片上传流程,增加压缩处理和批量上传支持
- 完善认证模块中的缓存清理逻辑,切换账号时清除相关图片缓存
- 添加后端环境变量配置模板和代理文档说明
2026-09-23 17:46:42 +08:00

115 lines
4.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)}