
前一阵朋友问我说出去玩给一群人拍了几百张照片回来后怎么把原图一次性交给所有人还不被微信压缩。我第一反应是“网盘呗”结果他吐槽了一串网盘问题要注册、要下客户端、免费用户下载限速、链接发群里三天就失效。我也跟着火大索性自己写了个小工具——只要生成一条链接别人打开就能看到照片还能一键打包下载。今天把源码和完整搭建过程都整理出来项目不大但很多细节值得展开说说。这个项目说白了就是一个“以链接为中心的临时照片分享服务”。上传者把照片扔进来系统生成一个带随机 token 的访问地址接收者拿到链接以后打开就是干净清爽的照片墙想看哪张点哪张想一次拿完就点打包下载。没有注册登录没有广告页也没有各种“分享文件已过期”的尴尬。下面我会从思路拆解、架构设计、源码实现到问题排查完整走一遍。1. 这需求背后的正经产品思路1.1 为什么“一条链接”是最好的交付形式以前大家传照片主要三种方式各有各的难受第一种是微信、QQ 直接传原图。方便是真方便但照片一多就纠结几十张原图要在聊天记录里翻半天遇到群聊还会被系统自动压缩发的人心累收的人也未必能存到真正的原图。第二种是传到网盘。这是个看似万能、实际烦人的方案。接收方要么输入提取码要么下载专属客户端免费账号下载速度还懂得都懂。有一次我给几十个团友发照片光是教其中三位注册网盘就花了半小时后面还有两个人一直说打不开链接体验极其割裂。第三种是搭个图库站。功能很完整但杀鸡用牛刀动辄要配置数据库、CDN、用户体系维护成本直接劝退。所以回头来看这个需求本质上是三个词临时、轻量、省心。照片不是长期档案分享对象也不是陌生人就是一次团建、一次旅行、一次活动的几十上百张图。接收者拿到链接就能看不想要任何额外的动作。一条链接就是最简交付物——它不需要安装不需要注册点开即用而且天然适合在微信、短信、邮件里传播。1.2 自研服务的适用边界再往下想一层这个工具不是要替代正经的云盘或图床它服务的是“熟人之间的批量传图”这个窄场景。我给自己划了几条底线部署环境简单最好一台小服务器甚至一台旧笔记本就能跑起来代码逻辑直白出现问题能当场改当场跑照片到期自动删除不在服务器上堆垃圾链接本身就是访问凭证不需要额外的账号登录流程基于这些边界我没有选前后端分离的重架构也没有引入关系型数据库直接用 Python Flask 写了全部逻辑。后面你会发现核心代码真的不多但每一个设计点都是为“链接分享”这个场景服务的。2. 技术选型与整体架构2.1 为什么是 Flask而不是 Express 或 Spring Boot技术选型这件事关键不是用多新的框架而是和问题的复杂度匹配。这个项目单机就能扛住日常分享流量真正卡的环节是磁盘 IO 和网络带宽而不是 Web 框架本身。Python Flask 的优势恰好在这里代码量小一个文件就能跑通全流程本地起服务零负担调试方便Flask 处理文件上传和静态文件访问非常顺手send_from_directory 之类的内置能力够用如果你平时更熟 Node.js用 Express 也能实现同样效果思路完全一样上传文件、生成 token、按 token 返回文件列表。核心取舍不是语言而是不要让技术选型拖累你自己解决问题的速度。2.2 关键设计用 token 做访问入口这一类工具最常见的坑就是链接被猜出来。如果分享地址直接是/photo/123.jpg别人把数字改一下就能翻到你所有照片那就漏成筛子了。所以这里一定要用不可枚举的 token 代替自增 ID。我直接用 Python 的uuid.uuid4()生成一个 32 位十六进制随机字符串别人理论上有 2 的 128 次方种组合靠暴力枚举基本不可能。这个 token 就相当于照片集的钥匙谁拿到链接谁就能看钥匙本身不落盘、不打印、不记录日志。与此同时token 还兼任了“租约”功能。每一条链接都有过期时间默认 24 小时或 7 天自由选择。过期之后接口直接返回“链接已过期”照片在后台定时清理。这样一来分享是临时的数据也不会无限占据磁盘。2.3 目录结构和数据组织方式我用的是“一个 token 对应一个目录”的朴素组织方式项目目录/ ├── app.py ├── templates/ │ └── index.html └── uploads/ └── 32位token/ ├── a1b2c3d4.jpg ├── 5678ef90.png └── ...上传者一次传了多少张照片就在uploads/下建一个以 token 命名的文件夹所有照片都放到这个文件夹里。程序里只需要维护一个简单的内存字典记录 token 到照片列表、过期时间的映射关系。生产环境当然可以用 Redis 或 SQLite但作为起点这个设计已经足够撑起几百个并发链接的分享场景。真实跑起来后我建议把内存字典这部分换成 SQLite。原因后面“问题排查”那一节会细说服务重启后映射关系会丢而文件夹里的照片还在接口却找不到对应信息了会非常尴尬。3. 核心实现过程源码逐段拆解3.1 上传照片从表单到生成链接先看一个完整的 Flask 上传接口。这部分有两个重点文件名校验和token 生成。# -*- coding: utf-8 -*- import os import io import uuid import time import zipfile from flask import Flask, request, jsonify, render_template, send_file, send_from_directory, abort BASE_DIR os.path.dirname(os.path.abspath(__file__)) UPLOAD_FOLDER os.path.join(BASE_DIR, uploads) ALLOWED_EXTENSIONS {jpg, jpeg, png, gif, webp, bmp} MAX_CONTENT_LENGTH 50 * 1024 * 1024 # 单次请求最大 50MB app Flask(__name__) app.config[UPLOAD_FOLDER] UPLOAD_FOLDER app.config[MAX_CONTENT_LENGTH] MAX_CONTENT_LENGTH os.makedirs(UPLOAD_FOLDER, exist_okTrue) # 内存映射token - 文件信息 TOKENS {} def gen_token(): 生成不可猜测的随机 token return uuid.uuid4().hex def allowed_file(filename): 只允许常见图片格式防止用户上传可执行文件 return . in filename and filename.rsplit(., 1)[1].lower() in ALLOWED_EXTENSIONS上传接口本身app.route(/upload, methods[POST]) def upload(): files request.files.getlist(photos) if not files: return jsonify({code: 400, msg: 没有找到文件}), 400 token gen_token() token_dir os.path.join(app.config[UPLOAD_FOLDER], token) os.makedirs(token_dir, exist_okTrue) saved_names [] for f in files: if f and f.filename and allowed_file(f.filename): # 扩展名取白名单里的小写形式避免恶意构造 ext os.path.splitext(f.filename)[1].lower() new_name uuid.uuid4().hex[:8] ext f.save(os.path.join(token_dir, new_name)) saved_names.append(new_name) if not saved_names: return jsonify({code: 400, msg: 文件格式不支持}), 400 # 支持自定义有效期默认 24 小时 expires request.form.get(expires, default24, typeint) if expires 0: expires 24 TOKENS[token] { created: time.time(), expires_in: expires * 3600, files: saved_names, } link request.host_url share/ token return jsonify({code: 0, link: link, token: token, files: saved_names})这段代码里有几个容易被忽略的点逐个说。文件名校验不是只检查扩展名那么简单。很多新手喜欢直接拿f.filename去拼路径结果一拼就拼出路径穿越漏洞。我每次存文件时都会重命名用uuid.uuid4().hex生成完全随机的新文件名只保留原来的扩展名。这样有两个好处一是接收端不可能通过文件名猜到其他文件二是中文文件名不再产生乱码问题因为这里根本不用原始文件名。request.files.getlist(photos)获取的是前端多文件上传字段一次可以上传几十张照片。前端表单里input必须写namephotos并且加multiple属性否则一次只能传一张。此外上传时我允许用户通过表单字段expires自定义链接有效期比如别人旅游回来想给照片留一周的下载时间可以填 168 小时。这个设计看着简单实际操作中非常实用因为“一天后过期”和“一周后过期”的使用体验完全不同。3.2 照片访问和打包下载有了 token 之后下一步就是让接收者能打开链接看到照片并且能一次下载全部图片。先说照片预览接口app.route(/share/token) def share(token): info TOKENS.get(token) if not info: return render_template(expired.html, message链接不存在或已被删除), 404 now time.time() if now - info[created] info[expires_in]: return render_template(expired.html, message链接已过期), 410 photos [] for name in info[files]: photos.append({ name: name, url: /file/ token / name, }) return render_template( share.html, photosphotos, tokentoken, expires_inint((info[created] info[expires_in] - now) // 60) )预览接口的逻辑不复杂Token 在字典里查得到就渲染照片墙查不到就返回“链接无效”过期则返回“链接已过期”。这里我给埋了一个细节把剩余有效时间算出来放到模板上显示“该链接还有 XX 分钟有效”。使用者看到这个数字会很安心不会再问“这链接到底什么时候过期”体验感瞬间上升一个档次。照片文件本身单独用一个接口返回目的是控制读取范围以及拦截掉路径穿越请求app.route(/file/token/filename) def get_file(token, filename): # 一律拒绝带斜杠的文件名防止路径穿越 if / in filename or \\ in filename or .. in filename: abort(404) info TOKENS.get(token) if not info: abort(404) now time.time() if now - info[created] info[expires_in]: abort(410) token_dir os.path.join(app.config[UPLOAD_FOLDER], token) if not os.path.isdir(token_dir): abort(404) return send_from_directory(token_dir, filename)send_from_directory是 Flask 内置的安全文件发送方法它会确保最终文件路径始终落在上传根目录下面不会跳出去。这是处理用户上传文件时的一个标准姿势千万不能自己拿 os.path.join 拼完路径再 open 一发那是一种非常危险的写法。然后是很多人都会需要的“一键下载全部照片”。我之前遇到的使用场景是接收者不仅要看照片更想把原图一次性存到手机里慢慢挑。这时候一张张点保存会疯掉所以必须提供整包下载。实现方式也很朴素——内存里打 zip 包再返回app.route(/download/token) def download_zip(token): info TOKENS.get(token) if not info: abort(404) now time.time() if now - info[created] info[expires_in]: abort(410) token_dir os.path.join(app.config[UPLOAD_FOLDER], token) if not os.path.isdir(token_dir): abort(404) # 用 BytesIO 生成 zip避免临时文件残留 buf io.BytesIO() with zipfile.ZipFile(buf, w, zipfile.ZIP_STORED) as zf: for name in info[files]: file_path os.path.join(token_dir, name) if os.path.exists(file_path): zf.write(file_path, arcnamename) buf.seek(0) return send_file( buf, as_attachmentTrue, download_namephotos.zip, mimetypeapplication/zip )这里我把压缩格式设成了ZIP_STORED而不是ZIP_DEFLATED。原因很实际图片文件本身已经压缩过再用 deflate 压缩一遍又慢又省不了多少体积。如果是往下传原图直接用不压缩的存储模式效率更高。当然如果你的场景里混了大量 PNG 截图或 BMP 大图那再考虑换成带压缩的模式也不迟。3.3 前端页面与交互体验后端接口跑通以后前端的体验同样重要。这个项目的前端我尽量保持“所见即所得”不做花哨动画只保证三件事能顺畅完成上传、预览、下载。先看上传页面的核心表单!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title一条链接获取你的照片/title style body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; color: #333; } .card { border: 1px solid #ddd; border-radius: 12px; padding: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); } input[typefile] { display: block; margin: 16px 0; } button { background: #1a73e8; color: #fff; border: none; padding: 10px 20px; border-radius: 8px; font-size: 16px; cursor: pointer; } .link-box { margin-top: 20px; padding: 16px; background: #f8f9fa; border-radius: 8px; word-break: break-all; } .link-box a { color: #1a73e8; } /style /head body div classcard h2上传照片生成分享链接/h2 form iduploadForm enctypemultipart/form-data input typefile namephotos multiple acceptimage/* label forexpires有效期小时/label input typenumber idexpires nameexpires value24 min1 max720 button typesubmit生成链接/button /form div classlink-box idresult styledisplay:none; p照片已上传复制下面链接发给别人/p a idshareLink href# target_blank/a /div /div script const form document.getElementById(uploadForm); form.addEventListener(submit, async function (e) { e.preventDefault(); const formData new FormData(form); const res await fetch(/upload, { method: POST, body: formData }); const data await res.json(); if (data.code 0) { const box document.getElementById(result); box.style.display block; const link document.getElementById(shareLink); link.href data.link; link.textContent data.link; } else { alert(data.msg); } }); /script /body /html接入端页面则采用网格布局展示照片每张照片都是原图链接点开即看。页面底部放两个按钮一个“打包下载全部图片”另一个“复制链接”。前者直接指向/download/token后者把当前浏览器地址复制到剪贴板方便发到微信或群里。这里有个小细节复制链接不要用后端拼好的字符串而是直接取location.href。因为部署时如果经过了反向代理后端拿到的request.host_url可能和用户实际访问的域名不一致导致生成一条打不开的链接。直接取当前页面地址永远是最保险的做法这也是我在真实部署中被坑过以后才改掉的。3.4 本地运行和一次完整实测把代码保存成app.py前端模板放到templates/目录下然后命令行执行pip install flask python app.py默认 Flask 起在5000端口浏览器打开http://127.0.0.1:5000上传几张测试照片得到一条类似这样的链接http://127.0.0.1:5000/share/3f7a2c51b0a04f3ea1c2d58a2c4f8b27用浏览器无痕模式打开这条链接看到照片墙正常展示点“打包下载”能拿到一个 zip 文件。整个过程不超过三分钟项目就能跑起来。如果你想让同伴在同一个局域网内访问把启动方式改成flask run --host0.0.0.0 --port5000然后别人用http://你的局域网IP:5000访问即可。这里注意云服务器部署时一定要在安全组或者防火墙里放行对应端口否则外部还是访问不到。4. 常见问题与排查技巧实录实际操作总会遇到比预期多的问题我把自己踩过的坑整理成一个速查表方便你直接对照处理。现象可能原因排查与解决打开链接 404token 不存在或已被内存清理服务重启后内存字典清空需改用 SQLite/Redis 持久化提示“链接已过期”超过了设置的有效时间调大expires参数或把默认值改成 168 小时上传大文件报错请求体积超过 50MB 限制调大MAX_CONTENT_LENGTH或前端压缩后再传图片一直加载不出来文件类型不在白名单检查扩展名是否在ALLOWED_EXTENSIONS里可以预览但下载 zip 为空文件被移动或下载接口读取不到检查uploads/token目录里文件是否还在局域网外访问不了端口未开放或绑定了 127.0.0.1用--host0.0.0.0启动并在防火墙放行端口服务器重启后所有链接全部失效内存字典丢失用 SQLite 保存 token 映射启动时自动加载还有几个比较隐蔽的问题展开说第一上传请求超时。如果你一次传几百张原图浏览器和服务器之间的连接可能持续很久。默认 Flask 开发服务器在弱网环境下扛不住这个频率。我建议生产环境用 gunicorn 跑并设置合理的 timeout 和 worker 数量gunicorn -w 4 -b 0.0.0.0:5000 --timeout 120 app:app第二文件名重复问题。前面代码里已经用uuid重命名了但如果你拿这个项目改造成“保留原始文件名”的模式一定要处理重名覆盖。比如同一张照片在 iPhone 和电脑上可能都叫IMG_1234.jpg后存的会把先存的覆盖掉。最简单的解法就是我现在这种“重命名为随机名”或者主动检查文件是否存在存在就加后缀再存。第三防盗链和来源判断。因为是熟人传图场景我没有做复杂的防盗链。但如果你要把这个服务暴露到公网建议加一层来源域名校验或者限制每个 token 的总访问次数。否则链接一旦流出服务器带宽会被不知情的人白白消耗。我自己的做法是在 nginx 层面对/download/做流量限制配合 token 过期时间基本够用。5. 进一步扩展从玩具到生产级小工具项目跑通以后想真正用起来还有三件小事值得做。5.1 把内存字典换成 SQLite现在的TOKENS字典一旦服务重启就没了这在真实使用中是不可接受的。解决办法很直接用 SQLite 存 token 和过期时间import sqlite3 def init_db(): conn sqlite3.connect(share.db) conn.execute( CREATE TABLE IF NOT EXISTS shares ( token TEXT PRIMARY KEY, created REAL, expires_in REAL, files TEXT ) ) conn.commit() conn.close() def save_share(token, created, expires_in, files): conn sqlite3.connect(share.db) conn.execute( INSERT INTO shares (token, created, expires_in, files) VALUES (?, ?, ?, ?), (token, created, expires_in, ,.join(files)) ) conn.commit() conn.close()这样重启以后TOKENS从数据库里恢复链接就不会突然 404 了。每次访问时顺带检查过期时间过期记录可以定时清理。5.2 增加访问次数和统计如果想更清晰地知道这张照片链接有没有被人打开过可以在记录表里加两个字段view_count和last_view_at。每次访问/share/token时更新这两个字段。虽然对私人传图场景不是刚需但真的运营起来后你会想知道“我发出去的那条链接到底还有没有人点开过”。5.3 跑起来之后记得做定时清理照片文件一直堆在uploads/里会占用磁盘空间建议加一个后台任务定期扫描所有 token 目录把已经过期的记录和文件一并删除def clean_expired(): conn sqlite3.connect(share.db) rows conn.execute(SELECT token, created, expires_in FROM shares).fetchall() now time.time() for token, created, expires_in in rows: if now - created expires_in: token_dir os.path.join(UPLOAD_FOLDER, token) shutil.rmtree(token_dir, ignore_errorsTrue) conn.execute(DELETE FROM shares WHERE token ?, (token,)) conn.commit() conn.close()用 Linux 上的 crontab 每半小时跑一次或者直接用APScheduler在应用内部起个后台任务都能实现。这个清理动作看着不起眼但它在长期运营中能为你省掉大量磁盘空间也能避免服务器上堆一堆“裸照片”带来不必要的风险。最后再分享一个我很个人的习惯这种临时分享链接我从来不会设置超过 7 天的有效期。照片这种事大家真正需要下载的时间窗口通常就是前几天后面留着链接的意义不大。设置短有效期既是一次礼貌的提醒也是在明明白白告诉接收者“趁现在赶紧存过期不候。”这也让服务本身变得更轻、更省心。希望你照着这篇文章搭起来之后也能体会到这种自己动手解决问题的快乐。