事情的起点很简单:一个十来人的小团队要共享文件。公共网盘的问题也很简单——权限太粗、目录结构受限于别人的产品逻辑、 稍微敏感一点的东西不敢放上去。于是决定自己写一个。
这个决定的风险在于:文件共享看起来简单,实际上牵扯到权限模型、大文件传输、断点续传、并发写入、 以及"怎么让不懂技术的人也能启动它"。如果一开始就奔着"做个 NAS"去,大概率三个月也上不了线。 所以我把目标压到一句话:十到二十个人,能按目录分权限,能在浏览器里传下载,能一键启动。
一、技术选型:为什么是 FastAPI + SQLite
先说结论:因为并发量低、单机部署、数据量小,SQLite 完全够用,而 FastAPI 的依赖注入刚好能承载权限校验。
| 需求 | 选择 | 理由 |
|---|---|---|
| 并发 | SQLite(WAL 模式) | 同时在线人数个位数,写操作集中在管理动作上 |
| 接口 | FastAPI | 依赖注入做权限、自动生成文档、异步流式响应方便 |
| 文件存储 | 本地磁盘 | 不引入对象存储,避免多一套凭据和费用 |
| 前端 | 原生 HTML/JS | 页面不多,避免构建链,改完刷新即可 |
开 WAL 模式这一条值得单独说。SQLite 默认的日志模式在"读的时候不能写",页面一多就会遇到
database is locked。执行一次 PRAGMA journal_mode=WAL; 并设置
busy_timeout,绝大部分偶发锁冲突会消失。
二、目录树怎么建模
我用了最朴素的邻接表:每个节点记住自己的父节点。
class Node(Base):
__tablename__ = "nodes"
id = Column(Integer, primary_key=True)
parent_id = Column(Integer, ForeignKey("nodes.id"), nullable=True)
name = Column(String(255), nullable=False)
is_dir = Column(Boolean, default=False)
size = Column(Integer, default=0)
owner_id = Column(Integer, ForeignKey("users.id"))
created_at = Column(DateTime, default=datetime.utcnow)
邻接表的优点是移动目录只改一个字段,缺点是查子孙需要递归。文件共享场景里"查子孙"主要出现在两个地方: 删除目录时的级联、以及统计目录大小。前者我用 Python 侧广度遍历解决,后者干脆不做实时统计—— 目录大小显示为"计算中",点进去才异步算。这不是偷懒,是承认这类数字不值得为它加一套缓存。
一个实用判断:如果一个指标只在界面上占一行小字,就不该为它引入缓存层。先用最笨的办法,等它真的慢了再说。
三、权限校验必须下沉
这是整个项目里我改动最多的地方。第一版我在每个路由里手动校验,写了七八个路由之后发现: 有的地方忘了判断"是不是目录",有的地方忘了判断"是不是访客",还有的地方重复查了三次数据库。
后来改成统一的依赖:
def require_access(node_id: int, need: str = "read"):
def dep(
node_id: int = Path(...),
user: User = Depends(current_user_optional),
db: Session = Depends(get_db),
):
node = db.get(Node, node_id)
if node is None:
raise HTTPException(404)
if not check_permission(db, node, user, need):
raise HTTPException(403)
return node
return Depends(dep)
路由里只声明自己需要什么级别的权限,判断逻辑只有一份。这样一来,"给访客隐藏某些节点"这种需求,
只需要改 check_permission 一个函数,不用去翻二十个路由。
权限模型本身刻意做得简单:角色(管理员 / 普通用户 / 访客)× 节点 ACL(可读 / 可写 / 可管理)。 没有做继承链,父目录的权限不会自动往下传——因为一旦引入继承,用户就会问"为什么我在子目录里不能写", 然后我们就需要一个解释权限来源的界面。对二十人的团队来说,显式授权比聪明更重要。
四、上传:先跑通,再优化
第一版就是最普通的一次性上传:整个文件读进内存,再写盘。几十兆的文件没问题,几百兆就开始吃内存。 改成流式之后问题解决了一半:
async def save_stream(upload: UploadFile, dest: Path):
tmp = dest.with_suffix(dest.suffix + ".part")
with tmp.open("wb") as f:
while chunk := await upload.read(1024 * 1024):
f.write(chunk)
tmp.replace(dest) # 原子替换,避免半截文件被当成完整文件
这里的 .part 加原子替换是个小细节,但很关键:如果进程在写盘途中被杀掉,
目录里会留下一个后缀为 .part 的临时文件,而不是一个看起来正常、实际损坏的原文件。
清理任务只需要扫 *.part。
分片上传是在真实用户抱怨"传到 80% 断了要重来"之后才加的。分片键用
文件哈希 + 分片序号,服务端记录已收到的分片,前端重试时先问一次已上传列表。
这套东西复杂度不低,我建议先不做,除非你的用户真的在传大文件。
五、访客与登录用户:两套可见性
系统里有一类"访客"——没有账号,通过分享链接进来。访客不该看到目录树,只该看到被明确分享的那个目录里的内容; 而普通用户登录后应该默认看到完整共享目录,点进去再按 ACL 收敛。
实现上分两层:接口层根据身份返回不同的数据结构(访客只拿到扁平列表,登录用户拿到树), 前端根据结构不同渲染不同组件。这里我犯过一个错:一开始想用同一份数据、前端控制显示, 结果访客打开开发者工具就能看到全部目录名。权限永远要在服务端收敛。
六、桌面启动器:打包比写功能更耗时
目标用户里有人不会用命令行,所以交付物必须是一个双击就能跑的 exe。 用 PyInstaller 打包 FastAPI 应用有一堆细节:
- 模板和静态文件不会自动收集,要显式写进
datas; - 打包后
__file__的路径逻辑会变,资源路径要统一走一个resource_path()函数; - 首次启动要初始化数据库,必须做成幂等;
- 每次功能变更都要重新打包,版本号写进启动器标题,否则用户分不清自己装的是哪一版。
最后这条看起来是流程问题,实际上是产品问题。我现在的做法是:功能改完 → 重建 exe → 打包成 zip → 版本号和变更说明放在同一个目录里。省下来的不是我的时间,是用户来问"我这是不是最新版"的时间。
七、复盘:哪些做对了,哪些是过度设计
做对的:
- 权限校验下沉到依赖层。后期加了订阅档位、访客分享,改动都只落在一处。
- 用 SQLite。到目前为止没有一次因为数据库本身出问题。
- 前端不引入构建链。改样式刷新页面就能看,排查问题心里没负担。
过度设计的:
- 一开始设计的"目录权限继承 + 例外规则",规则本身比使用场景复杂,最后被砍成显式 ACL。
- 做了一套操作审计日志的完整界面,实际上没人看。日志留着,界面删了。
- 订阅档位做了四档,实际使用中大部分人在第一档和第三档之间跳,中间档的存在感很低。
如果重来一次,我会先用两天写出"能传能下能分权限"的最小版本给真实用户用, 再根据他们的抱怨决定加什么。功能不是想出来的,是被需求追出来的。