从一个传文件的破需求,到一个能挂公网的“瞬传“:我用 WorkBuddy 把它从 HTML 一路做到了 Java

📅 2026/6/25 15:03:11
从一个传文件的破需求,到一个能挂公网的“瞬传“:我用 WorkBuddy 把它从 HTML 一路做到了 Java
作为一个天天连服务器的人,我的痛点很具体:跨机器搬运小数据,成本高得离谱。我连生产/测试机大多走向日葵或者远程桌面。干活没问题,搬东西是真折磨。本地一个改好的配置、一段现场报错日志、一个临时打的 jar,要送到那台机器上——微信文件助手得两头各登一次;走网盘得先传再下,还留记录;rz/sz在弱网下能卡你到怀疑人生。文字比文件更糟:远程桌面里的剪贴板时灵时不灵,一段 nginx 配置、一串临时 token,经常只能对着屏幕一个字一个字手敲。我要的东西其实极简:一个网页,丢进去,对面输个码就拿到,用完自动销毁,全程不注册、不登录、不留历史。市面上的工具要么太重、要么强制登录、要么把你每一次传输都记下来。没有一个戳中我。那就自己写。后端 Java 我没问题,前端是真不想碰——正好让 WorkBuddy 陪我从头走一遍,看看它到底能帮到哪一步。第一步:选对赛道进 WorkBuddy 首页,顶部摆着几条主线:日常办公、代码开发、设计创意。我要写工程,直接点代码开发。它没急着写代码,先帮我把需求解构了我没整需求文档,就把上面那段抱怨原样甩进去。它最让我意外的是:没有立刻进入写代码的兴奋状态,而是先把我的痛点抽象成了一句话——“远程环境下文件与文本的临时互传”,然后直接给了我三条技术路线,还配了一张优缺点对照表:方案 A · 纯 Web 文件中转:文件落服务器,浏览器直传直取。零配置、开箱即用,代价是文件要在服务器落地。方案 B · 实时剪贴板同步(WebSocket):双端实时,像一个在线剪贴板。文字体验极好,但只解决文字。方案 C · 综合双通道:文字 文件两条通道都要,前两者的优点合并。它自己倾向C,并说明了理由。这一步的体验已经赢了一半。我喂的是一句模糊抱怨,它回的是一个有取舍的技术选型——这才是一个工程师想要的对话方式,而不是上来就甩两百行能跑但跑偏的代码。围绕安全反复对线我没拍板,先抛了我最在意的约束:这玩意要挂公网给陌生人用,不能裸奔。它顺着这条线把安全模型拆成用户侧和服务端侧两块,颗粒度细到能直接抄进设计文档:用户/访问侧——身份认证用一次性访问码(口令),不建注册体系,每次访问临时生成、进独立空间;分享走带 token 的邀请链接,链接自带有效期、过期即失效。上传侧——强制 HTTPS,危险后缀(exe/sh/php)走黑名单拦截,限单文件大小、限单用户总份数。下载侧——下载链接带有效期、不可被猜测的 URL,支持阅后即焚(下完一次即删)。服务端侧——文件按用户隔离存储,互相不可见;文字内容加密落盘(类 AES,服务端只存密文);统一TTL自动清理;同 IP 限频防刷;审计日志只记时间与元信息、不记内容。它对安全的敏感度是超出我预期的。我只说了别裸奔三个字,它把密钥模型、隔离存储、限频、审计边界一次性铺开了。这些点后面几乎原封不动落进了最终实现。把功能与流程钉死,顺手砍掉一半需求安全聊透,接着定功能。我把发送方、接收方的流程口述了一遍,它顺手画了张端到端的状态流转:发送方页面 → 上传文件/文字 → 生成唯一口令(URL)→ 设置有效期 / 下载次数 / 口令 → 接收方页面凭链接查看、下载。然后它把多人房间单独拆成一个模块:临时空间走 WebSocket,多人输同一口令进同一房间收发文字;房间有创建者、有有效期、有人数上限;创建者可踢人、可销毁,到期消息全清。真正值钱的是收尾那句建议——别一口吃成胖子,按交付价值拆两期:MVP:单文件/文字分享 链接 有效期/下载次数;增强版:在 MVP 之上叠房间、二维码、密码保护。“先做 MVP,跑通了再加功能,成本最低。”我当时正处在功能我全都要的上头状态,它没有顺着我堆,反而帮我做减法。一个 AI 助手能在你兴奋的时候踩一脚刹车、把你拽回 MVP 节奏,这点比写得快重要得多。后面证明这个拆法是对的。一张参考图,它读出了一份设计规范功能定了,长相还没谱。我懒得描述,直接截了张看着顺眼的风格图丢过去。它没瞎夸,而是把这张图反解成了一份可执行的视觉规范:浅灰底 白色卡片、绿色主色(用于按钮高亮与选中态)、大圆角、低信息密度、顶部三大入口(接收/发送/房间)、右侧信息面板 二维码、底部安全提示。随后它定了 MVP 形态,还顺嘴问要不要给项目建长期档案。我说行,起个名叫一只牛博,照这方向开干。它回:“好,牛博,开始动手。”从一张随手截的图到一份结构化设计 token,这一步把我说不清的审美翻译成了它能落地的规则,沟通成本直接砍半。第一版:原生 HTML/JS 先把骨架立起来第一版来得很快,纯原生 HTML/JS,跑在localhost:3000。骨架已经齐了:顶部三 tab、中间发送区、有效期选择、右侧信息卡。糙是糙但参考图那个味儿出来了。我让它直接跑,它把启动指令一并给全:npm install、npm start,几行就起来了。它不只交代码,连怎么把它跑起来都替你想好了。第二版:换 Vue,顺手要个毛玻璃原生写着写着,命令式的 DOM 操作堆起来结构开始发散。我让它用 Vue 重构一版,顺便提了个我一直想要的效果——毛玻璃磨砂。它上了backdrop-filter: blur()那一套半透明,整体质感立刻不一样了。第三版:纯抠细节,把磨砂透明度调到位毛玻璃第一版太糊,背景插画全被磨没了,白瞎。我让它把透明度往回收,这步没技术含量、纯审美来回磨——从很高的透明度一点点压,大概落在 45% 上下,背景插画能隐约透出来又不抢前景内容。截图里我标了句有点效果,就是这版终于看顺眼了。值得一提的是,这种差一点的体感调优,它接得很稳:我不给具体数值,只说太透了/再实一点,它能顺着语义往对的方向收敛,而不是要我报参数。最后落到 Java:技术栈是被部署推着走的聊到上线,方向变了——而且是合理地变。这东西要长期挂公网给人用,Node 那套部署还得装运行时、起进程、配守护、挂了得拉起。我后端本就是 Java,干脆整体落到 Spring Boot 3.3.5 Java 17:打成单个可执行 jar,配多阶段 Dockerfile(maven 先构建、产物塞进 jre 运行镜像),docker-compose一拉即起,前置Nginx 反代 Let’s Encrypt 自动签证书。前端反而收了回来:不再背 Vue 的构建链,而是回到模块化原生 JS——features / ui / utils / api分目录拆清。后端 Java 接管所有重活:存储、限流、定时清理、口令生成、二维码(ZXing 直出)。一个 jar 梭哈,部署这件事一下就干净了。它把上线命令也逐条列了出来。HTML → Vue → Java 这条看似跳跃的路线,其实有一条暗线:原生用来验形态,Vue 用来试交互与质感,Java 用来扛部署与安全。技术栈不是它拍脑袋换的,是跟着这东西到底怎么用、怎么上线自然长出来的。这种为约束选型的判断力,正是它专业的地方。到这里,最终落地的工程能力已经不是一个玩具了。随手贴几个我最后定稿里的真实参数,佐证它给的不是花架子:端到端加密:前端用AES-GCM 256bit加解密,密钥编码进 URL 的#k片段、绝不上传服务端,服务端从头到尾只摸得到密文。这正是首页服务器只保存密文那句话的底气。滑动窗口限流:同 IP 上传3 次/分、30 次/时,口令查询30 次/分,连续 20 次口令试错触发 10 分钟冷却——直接掐死了暴力猜码。并发闸:全局并发上传10、单 IP2,防止有人拿上传打满磁盘。分级下载配额:按体积分档,≤10MB 给 20 次、≤100MB 给 10 次、更大给 5 次。定时清理:60 秒一轮扫过期内容,**最长保留 120 分钟(2 小时)**封顶。翻开代码:它写得到底怎么样参数好看不代表代码好。我专门挑了几段它生成的关键实现贴出来——这几段恰恰是最容易写烂、最能看出功底的地方。第一段,端到端加密的密钥处理。整个 E2E 的命门在于密钥到底放哪。它的做法是:密钥只编码进 URL 的#k片段——而 URL fragment 按浏览器规范根本不会随请求发往服务端,于是服务端从物理上就拿不到密钥,只能存密文。// 加密后,密钥拼进 URL 的 hash 片段,绝不进入请求体exportfunctionencryptedUrl(url,key){return${url}#k${encodeURIComponent(key)};}// 接收端从 location.hash 里把密钥取回来,在本地解密exportfunctionkeyFromLocation(){constparamsnewURLSearchParams(location.hash.replace(/^#/,));returnparams.get(k)||;}asyncfunctionencryptBytes(plainBytes,rawKey){constivrandomBytes(IV_BYTES);// 每次随机 12 字节 IVconstkeyawaitimportKey(rawKey);constcipherBufferawaitcrypto.subtle.encrypt({name:AES-GCM,iv},key,plainBytes);returnconcatBytes(iv,newUint8Array(cipherBuffer));// IV 前置拼进密文,解密时再切出来}用 WebCrypto 的AES-GCM、每次随机 IV、IV 前置拼接——这是教科书级的正确姿势,既没有自己造轮子,也没有把密钥误传上服务端。一个非密码学背景的开发者很容易在这里翻车,它没有。第二段,限流。它用的是带时间窗的滑动计数器,而且对计数器对象做了synchronized,在并发下不会把窗口算错:publicvoidrequire(Stringkey,intmaxCount,Durationwindow,Stringmessage){InstantnowInstant.now();WindowCountercountercounters.computeIfAbsent(key,ignored-newWindowCounter(now,0));synchronized(counter){if(Duration.between(counter.windowStart,now).compareTo(window)0){counter.windowStartnow;// 窗口过期,重置counter.count0;}if(counter.countmaxCount){thrownewApiException(HttpStatus.TOO_MANY_REQUESTS,message);}counter.count;}}一个方法靠传入的keywindowmaxCount同时服务上传按分钟/按小时“查询按分钟”消息按分钟等所有场景,没有为每种限流复制一份逻辑。锁的粒度也压在单个计数器对象上、而不是整张表,并发吞吐不会被一把大锁拖死。第三段,我最服的一处——上传并发闸为什么放在 Filter 层。它没把这个保护写进 Controller,而是单独做了个OncePerRequestFilter,并且在注释里写清了原因:/** * Acquires upload concurrency permits before Spring parses multipart bodies. * * pController-level guards run too late for large uploads because the request * body may already be parsed. Keeping this protection at the filter layer * limits concurrent body ingestion from the same IP as well as application * processing./p */ComponentpublicclassUploadConcurrencyFilterextendsOncePerRequestFilter{// ...try(TransferGuardService.GuardignoredtransferGuardService.upload(ClientIpUtil.resolve(request))){filterChain.doFilter(request,response);// 拿到许可才放行,出了作用域自动释放}catch(ApiExceptionexception){writeApiError(response,exception);}}“Controller 层的限制对大文件来说太晚了,因为请求体可能已经被解析”——这是一个踩过坑、真懂 Spring 请求生命周期的人才会写的注释。配合try-with-resources让许可自动释放,既挡住了恶意并发上传打满磁盘,又不会泄漏许可。这一段单拎出来,放进任何一个生产项目的 Code Review 都挑不出毛病。第四段,清理调度的克制。所有过期数据(分享、僵尸上传、房间、限流计数)的回收,只用一个Scheduled方法串起来,周期还能从配置注入:Scheduled(fixedDelayString${app.cleanup-interval-seconds:60}000)publicvoidcleanup(){shareStorageService.cleanupExpired();shareStorageService.cleanupStaleUploads();roomStorageService.cleanupExpired();rateLimitService.cleanup();}没有为每类数据各起一个定时器,也没把清理逻辑散落到各个 Service 里偷偷跑——收口到一处、依赖注入、周期可配,该简单的地方就让它保持简单。把这四段连起来看,它的代码不是能跑就行的水平:关注点分离清楚、并发与边界考虑到位、注释写在真正需要解释的地方、不重复也不过度设计。说实话,这个质量已经接近一个还不错的中高级工程师的手笔了。成品:那些被产品逻辑反推出来的设计界面我就不挨个念了。我更想说的是,最终这套交互里有几个决策,是被临时传输这个内核反过来逼出来的——它们看着是 UI,本质是产品判断。这些点,WorkBuddy 在前面的对话里基本都替我想到了。第一个决策:默认落在接收,而不是发送。这个选择我很认同。发送的人是主动的,他知道自己要干嘛;接收的人是被动的,他多半是被一个口令或二维码引过来的,越早让他看到在哪输码越好。所以首页一进来就是接收态、一个大口令框怼在中央,把最高频、最没耐心的那条路径放在了零点击的位置。右侧那条信息栏(最长 2 小时、单文件 200MB、无需登录)则在不打扰主流程的前提下,一句话讲清了这是个临时的东西。第二个决策:把有效期和接收次数做成一等公民。普通网盘的分享,过期是个藏在二级菜单里的高级选项;在这里,它俩是创建流程里跑不掉的两个旋钮——有效期(10 分钟到 2 小时)直接平铺成按钮,接收次数(默认 1 次)摆在显眼处。这不是堆功能,而是用交互把产品价值观顶到用户脸上:这东西生来就是要消失的,你必须为它的短命做一次决定。文本框右下实时跳的字节数和 256KB 上限,也在持续暗示边界感。第三个决策:口令、二维码、链接,三个入口一次性全给。这是被真实场景逼的——我的原始痛点就是跨设备,而跨设备意味着没有统一的复制粘贴通道。所以生成结果页同时吐出 8 位口令(适合念给旁边的人 / 手敲)、二维码(适合电脑发手机扫)、带密钥的完整链接(适合 IM 里甩过去),三条路通向同一份内容。值得单独说的是链接尾巴上那截#kE6LWXY1i0Ptt...——它就是前文那段加密代码里只活在 fragment 里、永不上送服务端的 AES 密钥。底部「端到端加密 · 服务器只保存密文」这句话,到这里是有代码兜底的,不是贴上去好看的。第四个决策:让安全可被感知。加密这件事,做了但用户看不见,等于没做。接收页每一条内容前面都挂着E2E 标记,旁边跟着剩余次数、字节数、创建时间,顶上是还在跳的销毁倒计时。用户不需要懂 AES-GCM,但他需要在那一眼里相信这东西是加密的、是会过期的、是只给我看的——这种把后端保证翻译成前端可见信号的处理,是很多工具会省掉、但恰恰最影响信任的一环。第五个决策:销毁态是被当成一个正经状态来设计的,不是甩一个报错。大多数应用对内容没了的处理就是一个 404 或一行红字。但对一个主打阅后即焚的产品来说,已销毁恰恰是它最该讲好的故事。所以这里是一块完整的状态卡:明确告诉你接收次数已用完或内容已过期,服务端已删除临时内容,并直接给出「重新创建」的下一步。关键是这块文案背后是真的——服务端定时任务把数据物理删了,不是前端藏起来骗你。我最初要的那句用完自动没、不留痕,在这一屏被兑现了。第六个决策:增强版的多人房间,真的落地了,而且是移动端优先验证的。前面设计阶段被单独拆出去、靠 WebSocket 撑起来的那个临时空间,没有停在 PPT 上。手机进房后,顶部直接是30 人在线 01:58:13 销毁倒计时——把多人和临时两个最核心的属性摆在第一屏,下面才是带时间戳和字节数的实时消息流、二维码邀请和退出。一个 AI 协助搭的项目,能把二期功能也照着一期的设计语言完整收尾、并在窄屏上保持同样克制的排版,这个完成度是超出我预期的。写在最后整个项目断断续续聊下来,代码我几乎没自己敲,但说实话也没省到动动嘴就行的程度。真正花时间的是前面那些来回——三套方案选哪条、安全到底要做到哪一层、功能砍到什么程度算 MVP、参考图那个调调怎么落地。这些想清楚了,后面写代码反倒是最不费劲的部分。一个传文件传文字好烦的破念头,就这么变成了一个能挂公网、扫码就用、用完自动没的小站。