feat(api): 重构图片地址管理与上传机制

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

View File

@@ -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)}