feat(api): 重构图片地址管理与上传机制
- 替换开发环境API基础URL为本地服务地址 - 实现签名URL本地缓存机制,解决微信按URL缓存图片导致重复下载问题 - 添加缓存清理功能,包括过期清理和按需删除特定缓存项 - 集成阿里云OSS上传功能,支持直传减少服务器带宽占用 - 优化头像地址处理逻辑,统一使用签名缓存避免地址频繁变化 - 移除过时的OSS上传示例文件oss-upload.js - 更新课程页面实现跳转第三方小程序功能 - 调整打卡记录图片地址获取方式,统一使用新的签名缓存逻辑 - 优化图片上传流程,增加压缩处理和批量上传支持 - 完善认证模块中的缓存清理逻辑,切换账号时清除相关图片缓存 - 添加后端环境变量配置模板和代理文档说明
This commit is contained in:
115
routes/auth.py
115
routes/auth.py
@@ -22,6 +22,28 @@ class BindPhoneRequest(BaseModel):
|
||||
phone_code: str
|
||||
|
||||
|
||||
class PasswordLoginRequest(BaseModel):
|
||||
phone: str
|
||||
password: str
|
||||
|
||||
|
||||
class SetPasswordRequest(BaseModel):
|
||||
"""运营更改单个学员登录密码的入参(需验证老密码)。
|
||||
|
||||
- `old_password`:老密码(`password_hash` 为空时默认 = 手机号后6位)。
|
||||
- `new_password`:新密码。
|
||||
"""
|
||||
|
||||
phone: str
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
# 密码长度约束(设置密码时校验;登录时不校验,避免历史弱密码登不进来)
|
||||
PASSWORD_MIN_LEN = 8
|
||||
PASSWORD_MAX_LEN = 64
|
||||
|
||||
|
||||
def get_current_user(request: Request):
|
||||
token = ""
|
||||
|
||||
@@ -100,6 +122,99 @@ def bind_phone(req: BindPhoneRequest):
|
||||
return {"success": True, "is_new": True, "token": token, **user_payload(user)}
|
||||
|
||||
|
||||
@router.post("/password-login")
|
||||
def password_login(req: PasswordLoginRequest):
|
||||
"""手机号 + 密码登录(与微信登录并列的第二条登录路径,2026-09-22 新增)。
|
||||
|
||||
- 查询靠 `phone_encrypted` 的 HMAC 比对,**库里不存明文手机号**,
|
||||
做法与 `bind-phone` 完全一致(同一个 `encrypt_phone`)。
|
||||
- 错误文案统一成「手机号或密码错误」,**刻意不区分**「账号不存在」与「密码不对」——
|
||||
否则这个接口就成了「哪些手机号是有效学员」的枚举器。
|
||||
- 响应结构与 `/wechat-login` 一致,前端两条登录路径可共用后续处理。
|
||||
- **未设置密码(`password_hash` 为空)时,默认密码 = 手机号后6位**,便于学员免初始设置直接登录。
|
||||
- ⚠️ 与微信登录保持一致,**不校验 `status`**:`暂停` 学员仍然能登录。
|
||||
"""
|
||||
phone = (req.phone or "").strip()
|
||||
password = req.password or ""
|
||||
if not phone or not password:
|
||||
raise HTTPException(status_code=400, detail="手机号与密码均不能为空")
|
||||
|
||||
user = db.fetch_one(
|
||||
"SELECT * FROM auth_users WHERE phone_encrypted=%s",
|
||||
(security.encrypt_phone(phone),),
|
||||
)
|
||||
if not user:
|
||||
logger.info("密码登录失败:手机号未匹配到账号")
|
||||
raise HTTPException(status_code=401, detail="手机号或密码错误")
|
||||
stored = user.get("password_hash")
|
||||
used_default = False
|
||||
if not stored:
|
||||
# 未设置密码的账号,默认密码 = 手机号后6位(便于学员免初始设置直接登录)。
|
||||
stored = security.hash_password(phone[-6:])
|
||||
used_default = True
|
||||
if not security.verify_password(password, stored):
|
||||
# 日志里区分「默认密码也不对」与「密码错」,方便运营排查;对外文案保持一致。
|
||||
# 典型情况:006 迁移还没执行 → password_hash 列取不到 → 这里走默认密码分支。
|
||||
reason = "默认密码(手机号后6位)不正确" if used_default else "密码不正确"
|
||||
logger.info("密码登录失败:user_id=%s %s", user["id"], reason)
|
||||
raise HTTPException(status_code=401, detail="手机号或密码错误")
|
||||
|
||||
token = _record_session(user["id"])
|
||||
return {"success": True, "token": token, **user_payload(user)}
|
||||
|
||||
|
||||
def _check_password_input(phone, password):
|
||||
"""校验一条待设置的账号密码,返回错误原因;空串表示通过。"""
|
||||
if not phone:
|
||||
return "手机号不能为空"
|
||||
if not password:
|
||||
return "密码不能为空"
|
||||
if len(password) < PASSWORD_MIN_LEN or len(password) > PASSWORD_MAX_LEN:
|
||||
return "密码长度需为 %d~%d 位" % (PASSWORD_MIN_LEN, PASSWORD_MAX_LEN)
|
||||
if password == phone:
|
||||
return "密码不能与手机号相同"
|
||||
return ""
|
||||
|
||||
|
||||
@router.post("/set-password")
|
||||
def set_password(req: SetPasswordRequest, request: Request):
|
||||
"""
|
||||
- **改密码需先验证老密码**:`old_password` 正确才更新;`password_hash` 为空时
|
||||
默认老密码 = 手机号后6位(与 `password-login` 的默认密码逻辑一致)。
|
||||
- **直接覆盖已有密码**("重置"就是这个语义),没有二次确认。
|
||||
- 手机号必须能匹配到 `auth_users`(用 `encrypt_phone` 比对);
|
||||
匹配不到即报错,**不会新建账号** —— 本系统没有注册流程,用户由运营导入。
|
||||
"""
|
||||
phone = (req.phone or "").strip()
|
||||
old_password = req.old_password or ""
|
||||
new_password = req.new_password or ""
|
||||
|
||||
err = _check_password_input(phone, new_password)
|
||||
if err:
|
||||
raise HTTPException(status_code=400, detail=err)
|
||||
|
||||
user = db.fetch_one(
|
||||
"SELECT * FROM auth_users WHERE phone_encrypted=%s",
|
||||
(security.encrypt_phone(phone),),
|
||||
)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="手机号未匹配到学员账号")
|
||||
|
||||
# 验证老密码:password_hash 为空时,默认老密码 = 手机号后6位。
|
||||
stored = user.get("password_hash")
|
||||
if not stored:
|
||||
stored = security.hash_password(phone[-6:])
|
||||
if not security.verify_password(old_password, stored):
|
||||
raise HTTPException(status_code=403, detail="老密码不正确")
|
||||
|
||||
db.execute(
|
||||
"UPDATE auth_users SET password_hash=%s WHERE id=%s",
|
||||
(security.hash_password(new_password), user["id"]),
|
||||
)
|
||||
logger.info("设置登录密码:user_id=%s", user["id"])
|
||||
return {"success": True, "user_id": user["id"]}
|
||||
|
||||
|
||||
@router.get("/verify")
|
||||
def verify(user=Depends(get_current_user)):
|
||||
return {"success": True, **user_payload(user)}
|
||||
|
||||
230
routes/plan.py
230
routes/plan.py
@@ -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}
|
||||
|
||||
@@ -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