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

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

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}

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