feat(api): 重构图片地址管理与上传机制
- 替换开发环境API基础URL为本地服务地址 - 实现签名URL本地缓存机制,解决微信按URL缓存图片导致重复下载问题 - 添加缓存清理功能,包括过期清理和按需删除特定缓存项 - 集成阿里云OSS上传功能,支持直传减少服务器带宽占用 - 优化头像地址处理逻辑,统一使用签名缓存避免地址频繁变化 - 移除过时的OSS上传示例文件oss-upload.js - 更新课程页面实现跳转第三方小程序功能 - 调整打卡记录图片地址获取方式,统一使用新的签名缓存逻辑 - 优化图片上传流程,增加压缩处理和批量上传支持 - 完善认证模块中的缓存清理逻辑,切换账号时清除相关图片缓存 - 添加后端环境变量配置模板和代理文档说明
This commit is contained in:
@@ -1,16 +1,54 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
|
||||
from .. import config, db
|
||||
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)
|
||||
@@ -22,7 +60,9 @@ def _profile(user):
|
||||
"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", ""),
|
||||
}
|
||||
|
||||
@@ -43,23 +83,32 @@ def upload_avatar(file: UploadFile = File(...), user=Depends(get_current_user)):
|
||||
if ext not in ALLOWED_AVATAR_EXT:
|
||||
raise HTTPException(status_code=400, detail="不支持的文件类型")
|
||||
|
||||
avatar_base = config.AVATAR_DIR
|
||||
uid = str(user["id"])
|
||||
save_dir = os.path.join(avatar_base, uid)
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
for old_name in os.listdir(save_dir):
|
||||
if old_name.startswith("avatar.") and old_name != "avatar" + ext:
|
||||
try:
|
||||
os.remove(os.path.join(save_dir, old_name))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
filename = "avatar" + ext
|
||||
file_path = os.path.join(save_dir, filename)
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(data)
|
||||
# 新格式:直接就是 OSS 对象 key,去掉 /clock/static 这层 nginx 前缀
|
||||
url_path = "avatars/" + uid + "/" + filename
|
||||
|
||||
url_path = "/clock/static/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}
|
||||
return {"success": True, "wechat_avatar": url_path, "avatar_url": avatar_url(url_path)}
|
||||
|
||||
Reference in New Issue
Block a user