【学习记录】构建安全的文件上传系统:FastAPI + JWT 认证 + Streamlit 前端 + SQLite 数据库
在企业级应用中,文件上传功能通常需要集成用户认证 和数据隔离------每个用户只能管理自己的文件,并且操作需要经过身份验证。本文基于 FastAPI、JWT、Streamlit 和 SQLite 构建一个完整的文件上传系统,涵盖用户登录(Token 签发)、安全上传、文件记录存储与查询,并详细讲解其中的关键技术点。代码完整可直接运行,适合作为文档管理、RAG 知识库上传组件的原型。
📌 系统架构与技术栈
| 组件 | 技术 | 职责 |
|---|---|---|
| 后端框架 | FastAPI | 提供 RESTful API,处理认证、文件上传、记录查询 |
| 认证机制 | JWT (JSON Web Token) | 无状态身份验证,签发与验证 Token |
| 前端界面 | Streamlit | 提供用户登录、文件上传、历史记录查看的交互界面 |
| 数据库 | SQLite + aiosqlite | 存储上传记录(用户 ID、文件名、时间等),支持异步操作 |
| 文件存储 | 本地磁盘 | 保存文件,使用 UUID 重命名,防止冲突和路径遍历攻击 |
核心流程:
- 用户通过前端输入用户名,调用后端
/login接口获取 JWT Token。 - 前端将 Token 保存在
st.session_state中,后续请求附加在Authorization: Bearer <token>头。 - 后端通过依赖
Depends(get_current_user)验证 Token 并提取user_id,只有验证通过才能上传文件或查询记录。 - 上传文件时,后端检查文件大小,生成唯一文件名保存到磁盘,并将记录写入 SQLite 数据库。
- 用户可以查询自己的上传历史,数据按用户隔离。
🔐 JWT 认证原理与实现
JWT 结构
JWT 由三部分组成:Header、Payload、Signature,用 . 分隔。
- Header:指定签名算法(如 HS256)。
- Payload :存放声明(claims),如用户 ID (
sub)、过期时间 (exp) 等。 - Signature:对前两部分使用密钥签名,防止篡改。
后端 JWT 工具函数
python
def create_access_token(user_id: str):
payload = {"sub": user_id, "exp": datetime.utcnow() + timedelta(hours=24)}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
security = HTTPBearer()
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
token = credentials.credentials
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
user_id = payload.get("sub")
if not user_id:
raise HTTPException(401, "无效Token")
return user_id
except jwt.ExpiredSignatureError:
raise HTTPException(401, "Token已过期")
except jwt.InvalidTokenError:
raise HTTPException(401, "Token无效")
关键点:
create_access_token:生成有效期 24 小时的 Token,sub字段存放用户名。HTTPBearer:FastAPI 提供的安全工具,自动从请求头提取Authorization: Bearer <token>。get_current_user:作为依赖项,可被任何需要认证的路由使用,验证失败时抛出 401。
🗄️ 数据库设计(SQLite)
表 upload_records 结构:
| 字段 | 类型 | 说明 |
|---|---|---|
id |
INTEGER | 自增主键 |
original_filename |
TEXT | 用户上传时的原始文件名 |
saved_filename |
TEXT | 实际保存的文件名(UUID) |
saved_path |
TEXT | 文件存储的绝对路径 |
file_size |
INTEGER | 文件大小(字节) |
user_id |
TEXT | 上传者 ID(来自 JWT) |
upload_time |
TIMESTAMP | 上传时间,默认当前时间 |
异步操作 :使用 aiosqlite 实现非阻塞数据库写入和查询,避免阻塞 FastAPI 事件循环。
python
async def insert_upload_record(...):
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(...)
await db.commit()
📂 文件上传流程
- 接收文件 :FastAPI 使用
UploadFile类型,自动解析multipart/form-data。 - 大小检查 :通过
file.file.seek(0, 2)获取文件总大小,然后seek(0)复位指针。 - 唯一命名 :
uuid.uuid4().hex生成 32 位随机字符串,保留原扩展名。 - 保存文件 :使用
shutil.copyfileobj以块形式写入磁盘,内存友好。 - 记录入库 :调用
insert_upload_record异步写入数据库。 - 返回响应:JSON 格式返回成功信息及保存的文件名。
安全措施:
- 文件大小限制(默认 50 MB)。
- 文件名完全由系统生成,忽略用户提供的文件名(仅记录元数据),防止路径遍历攻击。
- 上传目录
uploaded_files不在 Web 可访问路径下(若需要下载需额外接口)。
🎨 Streamlit 前端实现
Streamlit 前端负责:
- 登录表单,获取 Token 并存入
st.session_state。 - 展示文件上传组件,发送携带 Token 的请求。
- 查询当前用户的上传记录并展示。
- 退出登录,清空 Session。
Session 状态管理
python
if "token" not in st.session_state:
st.session_state.token = None
if "user_id" not in st.session_state:
st.session_state.user_id = None
登录组件
python
if st.session_state.token is None:
username = st.text_input("用户名", value="test_user")
if st.button("登录"):
resp = requests.post(LOGIN_URL, params={"username": username})
if resp.status_code == 200:
data = resp.json()
st.session_state.token = data["access_token"]
st.session_state.user_id = data["user_id"]
st.success("登录成功")
st.rerun()
st.stop()
已登录界面上传与查询
python
headers = {"Authorization": f"Bearer {st.session_state.token}"}
uploaded_file = st.file_uploader("选择文件")
if uploaded_file and st.button("上传文件"):
files = {"file": (uploaded_file.name, uploaded_file.getvalue(), uploaded_file.type)}
resp = requests.post(UPLOAD_URL, files=files, headers=headers)
# 处理响应...
if st.button("查看我的上传记录"):
resp = requests.get(RECORDS_URL, headers=headers)
# 展示记录...
🚀 完整代码(修正版)
后端代码(backend.py)
python
import os
import shutil
import uuid
import sqlite3
from datetime import datetime, timedelta
import aiosqlite
from fastapi import (
FastAPI,
File,
UploadFile,
HTTPException,
Depends
)
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import jwt
# =====================================================
# 配置
# =====================================================
UPLOAD_DIR = "./uploaded_files"
DATABASE_PATH = "./upload_records.db"
MAX_FILE_SIZE = 50 * 1024 * 1024
JWT_SECRET = "replace_with_your_secret_key"
JWT_ALGORITHM = "HS256"
os.makedirs(UPLOAD_DIR, exist_ok=True)
# =====================================================
# 初始化数据库
# =====================================================
def init_db():
conn = sqlite3.connect(DATABASE_PATH)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS upload_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
original_filename TEXT NOT NULL,
saved_filename TEXT NOT NULL,
saved_path TEXT NOT NULL,
file_size INTEGER NOT NULL,
user_id TEXT NOT NULL,
upload_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
conn.close()
# =====================================================
# JWT 工具
# =====================================================
def create_access_token(user_id: str):
payload = {
"sub": user_id,
"exp": datetime.utcnow() + timedelta(hours=24)
}
return jwt.encode(
payload,
JWT_SECRET,
algorithm=JWT_ALGORITHM
)
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security)
):
token = credentials.credentials
try:
payload = jwt.decode(
token,
JWT_SECRET,
algorithms=[JWT_ALGORITHM]
)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=401,
detail="无效Token"
)
return user_id
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=401,
detail="Token已过期"
)
except jwt.InvalidTokenError:
raise HTTPException(
status_code=401,
detail="Token无效"
)
# =====================================================
# 数据库操作
# =====================================================
async def insert_upload_record(
original_filename,
saved_filename,
saved_path,
file_size,
user_id
):
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"""
INSERT INTO upload_records
(
original_filename,
saved_filename,
saved_path,
file_size,
user_id,
upload_time
)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
original_filename,
saved_filename,
saved_path,
file_size,
user_id,
datetime.now()
)
)
await db.commit()
# =====================================================
# FastAPI
# =====================================================
app = FastAPI(title="文档上传服务")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup")
async def startup():
init_db()
@app.get("/")
async def root():
return {"message": "服务运行正常"}
# =====================================================
# 示例登录接口
# 实际项目应连接用户系统
# =====================================================
@app.post("/login")
async def login(username: str):
token = create_access_token(username)
return {
"access_token": token,
"token_type": "bearer",
"user_id": username
}
# =====================================================
# 上传文件
# =====================================================
@app.post("/upload")
async def upload_document(
file: UploadFile = File(...),
current_user: str = Depends(get_current_user)
):
file.file.seek(0, 2)
size = file.file.tell()
if size > MAX_FILE_SIZE:
raise HTTPException(
status_code=413,
detail=f"文件超过 {MAX_FILE_SIZE // (1024*1024)}MB"
)
file.file.seek(0)
original_filename = file.filename
ext = os.path.splitext(original_filename)[1]
unique_filename = f"{uuid.uuid4().hex}{ext}"
save_path = os.path.join(
UPLOAD_DIR,
unique_filename
)
try:
with open(save_path, "wb") as buffer:
shutil.copyfileobj(
file.file,
buffer
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"保存失败: {str(e)}"
)
await insert_upload_record(
original_filename=original_filename,
saved_filename=unique_filename,
saved_path=save_path,
file_size=size,
user_id=current_user
)
return JSONResponse(
content={
"status": "success",
"message": "上传成功",
"user_id": current_user,
"saved_filename": unique_filename,
"file_size": size
}
)
# =====================================================
# 查询当前用户上传记录
# =====================================================
@app.get("/records")
async def get_records(
current_user: str = Depends(get_current_user),
limit: int = 50
):
async with aiosqlite.connect(DATABASE_PATH) as db:
cursor = await db.execute(
"""
SELECT *
FROM upload_records
WHERE user_id = ?
ORDER BY upload_time DESC
LIMIT ?
""",
(
current_user,
limit
)
)
rows = await cursor.fetchall()
columns = [
desc[0]
for desc in cursor.description
]
return [
dict(zip(columns, row))
for row in rows
]
if __name__ == "__main__":
import uvicorn
uvicorn.run(
app,
host="0.0.0.0",
port=8005
)
前端代码(frontend.py)
python
import streamlit as st
import requests
BASE_URL = "http://localhost:8005"
LOGIN_URL = f"{BASE_URL}/login"
UPLOAD_URL = f"{BASE_URL}/upload"
RECORDS_URL = f"{BASE_URL}/records"
st.set_page_config(page_title="文档上传系统", page_icon="📄")
st.title("📄 文档上传系统")
# Session 状态
if "token" not in st.session_state:
st.session_state.token = None
if "user_id" not in st.session_state:
st.session_state.user_id = None
# 未登录时显示登录界面
if st.session_state.token is None:
st.subheader("登录")
username = st.text_input("用户名", value="test_user")
if st.button("登录"):
try:
resp = requests.post(LOGIN_URL, params={"username": username})
if resp.status_code == 200:
data = resp.json()
st.session_state.token = data["access_token"]
st.session_state.user_id = data["user_id"]
st.success("登录成功")
st.rerun()
else:
st.error("登录失败")
except Exception as e:
st.error(str(e))
st.stop() # 停止后续渲染
# 已登录
st.success(f"当前用户:{st.session_state.user_id}")
headers = {"Authorization": f"Bearer {st.session_state.token}"}
# 文件上传
uploaded_file = st.file_uploader("选择文件")
if uploaded_file:
st.write("文件名:", uploaded_file.name)
st.write("大小:", uploaded_file.size)
if st.button("上传文件"):
files = {"file": (uploaded_file.name, uploaded_file.getvalue(), uploaded_file.type)}
try:
with st.spinner("上传中..."):
resp = requests.post(UPLOAD_URL, files=files, headers=headers)
if resp.status_code == 200:
st.success("上传成功")
st.json(resp.json())
else:
st.error(resp.text)
except Exception as e:
st.error(str(e))
# 查询记录
st.markdown("---")
if st.button("查看我的上传记录"):
try:
resp = requests.get(RECORDS_URL, headers=headers)
if resp.status_code == 200:
records = resp.json()
if not records:
st.info("暂无记录")
else:
st.subheader("上传历史")
for rec in records:
st.markdown(
f"""
**文件名:** {rec['original_filename']}
**保存名:** `{rec['saved_filename']}`
**上传时间:** {rec['upload_time']}
**文件大小:** {rec['file_size']} 字节
---
"""
)
else:
st.error(resp.text)
except Exception as e:
st.error(str(e))
# 退出登录
if st.button("退出登录"):
st.session_state.token = None
st.session_state.user_id = None
st.rerun()
⚙️ 运行与部署
本地运行
-
安装依赖 (
requirements.txt):fastapi uvicorn streamlit requests python-multipart aiosqlite PyJWT -
启动后端:
bashpython backend.py后端默认运行在
http://0.0.0.0:8005 -
启动前端:
bashstreamlit run frontend.py --server.address 0.0.0.0 --server.port 6006前端将运行在
http://0.0.0.0:6006 -
访问 :浏览器打开前端地址,输入任意用户名(如
test_user),上传文件测试。
生产环境注意事项
- JWT Secret :不要硬编码,使用环境变量
JWT_SECRET。 - HTTPS:生产环境应启用 HTTPS,防止 Token 窃取。
- 文件存储 :可将
UPLOAD_DIR改为云存储(如阿里云 OSS、MinIO)。 - 数据库:SQLite 适合中小规模,高并发可迁移至 PostgreSQL。
- 认证增强:实际项目应验证用户密码,而不是仅凭用户名签发 Token。
🧠 技术要点总结
| 模块 | 核心技术 | 关键代码片段 |
|---|---|---|
| JWT 签发 | jwt.encode |
create_access_token |
| JWT 验证 | jwt.decode + HTTPBearer |
get_current_user 依赖 |
| 数据库异步 | aiosqlite |
async with aiosqlite.connect |
| 文件上传 | UploadFile + shutil.copyfileobj |
upload_document |
| 前端状态 | Streamlit session_state |
st.session_state.token |
| 跨域 | CORSMiddleware |
allow_origins=["*"] |
📈 扩展方向
- 文件下载接口:通过保存的路径生成签名 URL 或提供直接下载。
- 分片上传:支持大文件断点续传。
- 文件类型过滤:限制上传的扩展名或 MIME 类型。
- 用户管理:集成真实的用户注册/登录(密码加密、数据库存储用户表)。
- 日志审计:记录上传/下载行为,便于追溯。
🎯 结语
本文从零构建了一个具备认证授权 、文件上传 、记录存储的完整系统,代码清晰、可扩展,适合作为内部工具或知识库管理系统的原型。通过理解 JWT 认证、异步数据库操作以及 Streamlit 会话管理,你可以快速搭建类似的企业级应用。