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

@@ -4,11 +4,11 @@ import os
from datetime import date, datetime, time, timedelta
from typing import List
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from .. import config, db
from .. import db, oss_store
from .auth import get_current_user
@@ -24,16 +24,8 @@ MAX_CONTENT_LEN = 1000
MAX_FILE_SIZE = 10 * 1024 * 1024
MAX_FILES = 4
ALLOWED_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
ATTACH_DIR = config.PLAN_ATTACH_DIR
SELECT_COLS = "id,user_id,plan_type,period_key,status,checked_at,content,attachments,created_at,updated_at"
CONTENT_TYPES = {
"pdf": "application/pdf", "png": "image/png", "jpg": "image/jpeg",
"jpeg": "image/jpeg", "gif": "image/gif", "webp": "image/webp",
"bmp": "image/bmp", "doc": "application/msword",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
}
def iso_week_key(day):
iso = day.isocalendar()
@@ -282,7 +274,56 @@ def _parse_attachments(raw):
return raw or []
ATTACH_PREFIX = "attachments/"
OSS_PLAN_PREFIX = "plan/"
def oss_key(rel_path):
"""规范相对路径 -> OSS 对象 key。
`attachments/{user_id}/{plan_type}/{period_key}/{filename}` 对应的对象 key 是
`plan/{user_id}/{plan_type}/{period_key}/{filename}` —— 只差一个前缀,
所以从磁盘换成 OSS 时**数据库里的相对路径一个字都不用改**。
外链(http/https)不是本桶的对象,返回空串。
"""
if not isinstance(rel_path, str) or not rel_path.startswith(ATTACH_PREFIX):
return ""
tail = rel_path[len(ATTACH_PREFIX):].strip("/")
if not tail or ".." in tail:
return ""
return OSS_PLAN_PREFIX + tail
def media_url(rel_path):
"""把规范相对路径换成可直接喂给 `<image>` 的地址。
- 外链(http/https):原样返回。库里确实存在外链图片(小鹅通导入那批),必须放行。
- OSS 未启用 / key 非法:空串。前端**已经没有静态地址可退**,那张图就是不显示 ——
这是刻意的:图片只存在于 OSS,退回 nginx 静态地址只会得到一张 404 的裂图。
- 其余:OSS 签名 URL(纯本地计算,不发网络请求,可放心循环批量签)。
"""
if not isinstance(rel_path, str) or not rel_path.strip():
return ""
text = rel_path.strip()
if text.startswith(("http://", "https://")):
return text
key = oss_key(text)
if not key or not oss_store.is_enabled():
return ""
return oss_store.signed_url(key)
def media_urls(paths):
"""批量签名,**长度与顺序和入参严格一致**,签不出来的位置留空串。
保持等长是有意的:前端按同一个下标去取 `attachment_urls[i]`,
一旦长度不等就会出现"缩略图和正文错位"这种很难查的问题。
"""
return [media_url(p) for p in (paths or [])]
def fmt_rec(rec):
files = _parse_attachments(rec.get("attachments"))
return {
"id": rec["id"],
"user_id": rec["user_id"],
@@ -291,7 +332,13 @@ def fmt_rec(rec):
"status": rec.get("status", DEFAULT_STATUS),
"checked_at": fmt_dt(rec.get("checked_at")),
"content": rec.get("content", ""),
"attachments": _parse_attachments(rec.get("attachments")),
# attachments 是**规范值**,会被 /plan/checkin 原样回写进库,格式不许变。
"attachments": files,
# 展示专用的签名 URL,与 attachments 一一对应。
# 为什么另开一个字段而不是直接把 attachments 换成 URL:小程序把
# this.data.attachments 原样提交回 /plan/checkin,一旦这里返回签名 URL,
# 用户保存一次打卡就会把带 Expires/Signature 的地址写进数据库。
"attachment_urls": media_urls(files),
"created_at": fmt_dt(rec.get("created_at")),
"updated_at": fmt_dt(rec.get("updated_at")),
}
@@ -539,17 +586,22 @@ def group(type: str, group_name: str, period_key: str = "", user=Depends(get_cur
result = []
for m in members:
rec = rec_map.get(m["user_id"])
record = None
if rec:
files = _parse_attachments(rec.get("attachments"))
record = {
"status": rec["status"],
"checked_at": fmt_dt(rec.get("checked_at")),
"content": rec.get("content") or "",
"attachments": files,
"attachment_urls": media_urls(files),
}
result.append({
"user_id": m["user_id"],
"student_id": m.get("student_id") or "",
"name": m["name"],
"filled": rec is not None,
"record": {
"status": rec["status"],
"checked_at": fmt_dt(rec.get("checked_at")),
"content": rec.get("content") or "",
"attachments": _parse_attachments(rec.get("attachments")),
} if rec else None,
"record": record,
})
result.sort(key=lambda item: (0 if not item["filled"] else 1, item["user_id"]))
return {
@@ -647,7 +699,9 @@ def checkin(req: PlanCheckinRequest, user=Depends(get_current_user)):
content = req.content.strip()
if len(content) > MAX_CONTENT_LEN:
content = content[:MAX_CONTENT_LEN]
attachments = [a for a in req.attachments if isinstance(a, str) and a.strip()][:MAX_FILES]
# 落库前的最后一道闸:直传模式下客户端能往自己目录塞任意后缀的文件,
# 签名只能锁目录、锁不住扩展名,所以必须在这里再筛一遍。
attachments = [a for a in (req.attachments or []) if _is_allowed_attachment(a)][:MAX_FILES]
attachments_json = json.dumps(attachments, ensure_ascii=False)
now = datetime.now()
db.execute(
@@ -684,59 +738,113 @@ def history(type: str, page: int = 1, size: int = 10, user=Depends(get_current_u
return {"list": [fmt_rec(r) for r in rows], "total": total, "page": page, "size": size}
@router.post("/upload")
def upload(
plan_type: str = Form(...),
period_key: str = Form(""),
files: List[UploadFile] = File(...),
user=Depends(get_current_user),
):
plan_type = plan_type.strip()
def _safe_period_key(value):
"""period_key 会直接拼进 OSS 对象 key,必须挡住路径穿越。
传 `../../..` 就能把文件写到预期目录之外 —— 需求文档 V3.0 列为待修项。
非法值一律当作"没传",回落到当前周期,而不是报错中断(保持旧调用方兼容)。
"""
text = (value or "").strip()
if not text or "/" in text or "\\" in text or ".." in text:
return ""
return text
def _is_allowed_attachment(rel_path):
"""落库前的最后一道闸:只收「规范相对路径 + 白名单扩展名」。
⚠️ 这道校验在直传模式下**不能省**。PostObject 的 policy 用 `starts-with`
锁住了目录,但**锁不住扩展名** —— 一个被改过的客户端可以往自己的目录里
POST 任意后缀的文件(比如 .html)。所以真正决定「什么能进 attachments
字段」的地方是这里,而不是前端、也不是签名接口。
"""
if not isinstance(rel_path, str):
return False
text = rel_path.strip()
if not text:
return False
if text.startswith(("http://", "https://")):
return True # 库里确实存在外链图(小鹅通导入那批),必须放行
if not oss_key(text):
return False # 非 attachments/ 前缀、含 `..` 的都在这里被挡掉
return os.path.splitext(text)[1].lower() in ALLOWED_IMAGE_EXT
class UploadSignatureRequest(BaseModel):
plan_type: str = ""
period_key: str = ""
@router.post("/upload-signature")
def upload_signature(req: UploadSignatureRequest, user=Depends(get_current_user)):
"""给「小程序直传 OSS」签一份 PostObject 表单签名。
直传后图片字节只走「小程序 → OSS」,服务器带宽为零;后端只签发这一小段 policy。
安全要点(改前先读 `oss_store.post_form` 的注释):
1. **key 前缀由服务端用 session 里的 user_id 拼,前端无法指定。**
若让前端传 key,它就能写到 `plan/{别人的 uid}/...` 覆盖他人附件。
2. 返回的是**目录级**签名(`plan/{uid}/{type}/{period}/`),前端在该目录内
自行命名。原因是小程序会先压缩再上传,而压缩会换掉文件名 ——
签完整 key 就必须提前知道最终文件名,做不到「一次签名、多张复用」。
3. 扩展名**不靠签名兜**,而靠 `/plan/checkin` 落库时再校验一次
(`_is_allowed_attachment`)。所以那道校验不能删。
未配置 OSS 时返回 `enabled=false`,前端据此提示「上传服务不可用」——
这不是错误分支,而是设计好的提示路径(没有中转兜底,图片只走直传)。
"""
plan_type = (req.plan_type or "").strip()
if plan_type not in PLAN_TYPES:
raise HTTPException(status_code=400, detail="无效的计划类型")
if len(files) > MAX_FILES:
raise HTTPException(status_code=400, detail="最多上传4个文件")
period_key = period_key.strip() or get_period_key(plan_type)
save_dir = os.path.join(ATTACH_DIR, str(user["id"]), plan_type, period_key)
os.makedirs(save_dir, exist_ok=True)
saved = []
for f in files:
data = f.file.read()
if len(data) > MAX_FILE_SIZE:
continue
safe_name = os.path.basename(f.filename or "")
if not safe_name:
continue
ext = os.path.splitext(safe_name)[1].lower()
if ext not in ALLOWED_IMAGE_EXT:
continue
save_path = os.path.join(save_dir, safe_name)
with open(save_path, "wb") as fh:
fh.write(data)
rel_path = "attachments/" + str(user["id"]) + "/" + plan_type + "/" + period_key + "/" + safe_name
saved.append(rel_path)
if not saved:
raise HTTPException(status_code=400, detail="文件保存失败")
return {"success": True, "attachments": saved}
if not oss_store.is_enabled():
return {"success": True, "enabled": False}
period_key = _safe_period_key(req.period_key) or get_period_key(plan_type)
tail = str(user["id"]) + "/" + plan_type + "/" + period_key + "/"
form = oss_store.post_form(OSS_PLAN_PREFIX + tail, max_size=MAX_FILE_SIZE)
return {
"success": True,
"enabled": True,
"rel_prefix": ATTACH_PREFIX + tail,
"key_prefix": form["key_prefix"],
"host": form["host"],
"expires_in": form["expires_in"],
"form_data": form["form_data"],
}
@router.get("/file/{sid}/{plan_type}/{period}/{filename}")
def get_file(sid: str, plan_type: str, period: str, filename: str):
def get_file(sid: str, plan_type: str, period: str, filename: str, user=Depends(get_current_user)):
"""旧版取图接口:302 到 OSS 签名地址。
⚠️ 2026-09-22 之前这个接口**没有任何鉴权**(紧邻的 DELETE 反而有),
只要按 {sid}/{plan_type}/{period}/{filename} 猜路径就能下载任意学员的打卡图,
需求文档 V3.0 把它列为高风险项。同日补上了登录校验。
图片现在只在 OSS 上,所以这里不再读磁盘,直接 302 —— 只是转个地址,
图片字节不经过服务器。小程序取图实际不走这个接口,而是用读接口并排返回的
`attachment_urls`;保留它只为兼容已经发出去的老地址。未配 OSS 时返回 404。
"""
safe_name = os.path.basename(filename)
file_path = os.path.join(ATTACH_DIR, sid, plan_type, period, safe_name)
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="not found")
ext = os.path.splitext(safe_name)[1].lstrip(".").lower()
media_type = CONTENT_TYPES.get(ext, "application/octet-stream")
return FileResponse(file_path, media_type=media_type)
key = oss_key("attachments/" + sid + "/" + plan_type + "/" + period + "/" + safe_name)
if key and oss_store.is_enabled():
return RedirectResponse(oss_store.signed_url(key), status_code=302)
raise HTTPException(status_code=404, detail="not found")
@router.delete("/file/{sid}/{plan_type}/{period}/{filename}")
def delete_file(sid: str, plan_type: str, period: str, filename: str, user=Depends(get_current_user)):
"""删除附件对象。只能删自己的。
OSS 的 delete 是幂等的(删不存在的 key 也算成功),所以不需要先查存在性。
"""
if str(sid) != str(user["id"]):
raise HTTPException(status_code=403, detail="无权限")
safe_name = os.path.basename(filename)
file_path = os.path.join(ATTACH_DIR, sid, plan_type, period, safe_name)
if os.path.exists(file_path):
os.remove(file_path)
key = oss_key("attachments/" + sid + "/" + plan_type + "/" + period + "/" + safe_name)
if key and oss_store.is_enabled():
try:
oss_store.delete(key)
except Exception:
logger.exception("删除 OSS 对象失败: %s", key)
return {"success": True}