最近,我的职业状态有点像浏览器开了很多标签页。
以前是前端开发,后来开始接触后端,慢慢做成了全栈。再后来带过团队,开始关心研发流程、项目协作和交付质量。最近又因为业务需要,开始研究产品设计、用户流程,以及一个很具体的问题:
甲方想看网页效果时,怎样给他一个真正好用的链接?
事情听起来很简单。把项目压缩一下,找个平台上传,再把链接发给客户就行。
我确实认真考虑过第三方静态托管平台。它们通常上手很快,但用在临时客户演示上,还是有一些现实问题:项目需要在多个平台之间来回切换,配置和上传流程不够统一;有些页面会出现平台广告或品牌标识;项目文件和访问权限交给外部平台后,安全边界也需要额外评估。免费额度、访问限制、自定义域名等规则,也可能在关键时候给你补上一道小考题。
如果只是偶尔演示一次,第三方平台当然够用。可当演示项目开始变多,我更希望它们能在自己的后台里集中管理:上传、发布、下线、过期和删除,都有明确的状态,链接也不用到聊天记录里考古。
所以我决定自己动手做一个轻量的静态演示发布系统。

我真正想解决的,不只是上传文件
系统最终形成了一条比较清晰的流程:
text
创建项目
↓
上传 ZIP
↓
临时目录解压
↓
安全校验
↓
生成版本目录
↓
确认发布
↓
生成演示链接
管理员在后台创建一个项目,上传静态网站压缩包,系统会生成一个随机 Token,例如:
text
https://demo.example.com/p/8f7c2a1e9d4b6c30
客户打开链接即可查看页面,不需要安装 Node.js,也不需要关心项目是 Vue、React 还是手写 HTML。
系统的边界也很明确:只发布静态文件,不执行任何服务端代码。 HTML、CSS、JavaScript、图片可以,PHP、Java、Python 后端程序不可以。它是演示系统,不准备偷偷转型成云服务器。
前端:上传和发布分成两步
前端使用 Vue 3。上传功能本身并不复杂,但我把"上传版本"和"正式发布"拆成了两个动作。
js
const formData = new FormData()
formData.append('file', selectedFile)
const version = await request.post(
'/demo/' + projectId + '/upload',
formData,
{
headers: {
'Content-Type': 'multipart/form-data'
},
timeout: 0
}
)
// 上传成功后,再由管理员确认发布
await request.post(
'/demo/' + projectId + '/v/' + version.id + '/pub'
)
为什么不上传完自动发布?
因为客户演示现场最怕的不是报错,而是你以为发布成功了,客户看到的却是半成品。
上传只生成待发布版本,管理员确认无误后才切换线上版本。这样既减少误操作,也为版本对比、回滚和发布说明留下了空间。
后端:ZIP 文件不能想怎么解压就怎么解压
静态发布系统最需要认真对待的地方,是 ZIP 文件安全。
压缩包里可能出现这样的路径:
text
../../application.properties
如果直接拼接路径解压,文件就可能写到演示目录之外。教学版代码可以这样处理:
java
Path target = tempDir
.resolve(entry.getName())
.normalize();
if (!target.startsWith(tempDir)) {
throw new PublishException("非法文件路径");
}
除了路径穿越,还需要限制 ZIP 大小、解压后的总大小、文件数量和允许的扩展名,同时确认根目录或一层子目录里存在 index.html。
java
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
"html", "css", "js", "json",
"png", "jpg", "jpeg", "gif", "svg", "webp", "woff2"
);
上传文件先进入临时目录。校验成功后,再移动到正式版本目录。
text
/data/demo/
├── projects/
│ └── project-001/
│ └── versions/
│ ├── v1/
│ └── v2/
└── temp/
项目源码目录和运行数据目录也必须分开。客户上传的 ZIP、解压文件和版本目录,不应该放进前端源码目录或 Spring Boot 的静态资源目录,否则重新部署时很容易误删业务数据。
发布版本,本质上是一次"切换指针"
系统不会直接覆盖当前线上文件。上传新版本后,先生成独立目录:
text
versions/v1/
versions/v2/
versions/v3/
数据库记录当前发布的是哪一个版本。发布时只更新当前版本关系:
java
public void publish(Long projectId, Long versionId) {
DemoProjectVersion version = versionMapper.findById(versionId);
if (version == null || !projectId.equals(version.getProjectId())) {
throw new PublishException("版本不存在");
}
versionMapper.markPublished(versionId);
projectMapper.switchCurrentVersion(projectId, versionId);
}
这样一来,新版本上传失败时,旧版本仍然可以正常访问。客户看到的是稳定链接,开发者也不用在演示前祈祷服务器状态良好。
Nginx:负责入口、HTTPS 和请求转发
Nginx 在这里主要负责域名、HTTPS 和请求转发。下面是简化后的示例配置:
nginx
server {
listen 443 ssl http2;
server_name demo.example.com;
ssl_certificate /etc/nginx/cert/fullchain.pem;
ssl_certificate_key /etc/nginx/cert/privkey.pem;
location /demo/ {
proxy_pass http://127.0.0.1:9000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
}
当前版本先由后端根据 Token 找到对应版本目录,再返回静态文件。后续如果访问量上升,可以让后端只负责鉴权和路径解析,再配合 Nginx 的 X-Accel-Redirect 发送文件,减少应用线程参与大文件传输。
配置修改后,先检查语法,再重载服务:
bash
nginx -t && systemctl reload nginx
这条命令看起来朴素,却是部署时非常实用的"心理按摩"。
过期和下线,不能靠记忆力
临时演示项目通常不是永久有效。系统支持项目下线和过期处理,访问时会检查当前状态:
java
if (!"PUBLISHED".equals(project.getStatus())) {
return AccessResult.notFound();
}
if (project.getExpiresAt() != null
&& project.getExpiresAt().isBefore(LocalDateTime.now())) {
projectMapper.markExpired(project.getId());
return AccessResult.expired();
}
后台定时任务会处理过期项目,也会清理长时间未完成的临时上传目录。否则服务器磁盘迟早会变成"历史上传文件博物馆"。
做完以后,我发现自己做的是一条业务流程
表面上看,它解决的是静态文件发布。
实际上,管理员关心的是版本能不能控制,客户关心的是链接能不能打开,开发者关心的是路径安全、文件边界和部署稳定性。
这也是我从前端转向全栈、再接触业务和产品之后,越来越明显的感受:
技术不是把功能堆出来,而是把一个模糊需求变成一条能稳定运行的流程。
这套静态演示发布系统已经完成并上线。它没有复杂到需要画几十张架构图,但确实解决了工作中的一个小麻烦:客户需要看页面时,我可以直接发一个链接;项目需要更新时,也不必重新寻找平台;演示结束后,还能把项目下线或设置过期时间。
很多系统的起点,可能就是一句很普通的话:
"能不能发我个链接看看?"
如果你也有类似的演示、预览或临时发布需求,欢迎交流。后续我也会根据实际情况,把入口逐步开放出来。
