简介:这份资源面向需要在网页端实现手机摄像头拍照并上传的Java Web开发者,聚焦JSP页面调用摄像头、图片传输与服务器端接收的完整链路,并涉及微信技术相关的签名与消息处理工具类,适合具备一定JSP与Servlet基础、希望快速落地移动端拍照上传功能的中级开发者参考。压缩包共130个文件,约13.16MB,以36个jar依赖、31个java源码与33个class编译文件为主体,辅以jsp页面、js脚本、xml配置及少量png、jpg图片素材,源码与依赖齐全,便于直接导入工程调试。已有253人学习下载。资源内含微信JSSDK签名、access token获取、消息加解密及HTTP请求封装等工具类,可帮助读者理解拍照上传流程中的参数签名、请求转发与文件落盘思路,并对照源码排查常见配置与路径问题,节省从零搭建的时间。
1. 一个老项目里的摄像头拍照上传,为什么今天还值得拆
接手一个后台管理系统时,需求方只提了一句话:员工入职要现场拍照,照片直接存到服务器,别让 HR 再手动收图。翻遍现有代码,发现项目里已经躺着一份 JSP 调用摄像头拍照上传的实现,浏览器端调起摄像头、Canvas 抓帧、Base64 转二进制、后端落盘,一条链路全在。这类方案在微信技术生态里也常见——H5 页面调 getUserMedia 拍照,再回传服务端做存档。它不依赖任何前端框架,纯 JSP + Servlet + 原生 JS,扔进 Tomcat 就能跑,适合还在维护传统 Java Web 项目、需要快速补一个拍照上传能力的开发者。下面按「能干什么 → 怎么跑 → 怎么改」把这份源码拆开讲。
2. 技术栈与目录结构:一个 JSP 项目的最小闭环
2.1 技术选型为什么是 JSP + Servlet + 原生 JS
这份源码没有引入 Spring、没有 Vue、没有 Webpack,整个拍照上传链路靠三样东西撑起来:JSP 负责渲染页面和承载表单,Servlet 负责接收上传数据并写文件,浏览器原生 API(navigator.mediaDevices.getUserMedia+canvas.toDataURL)负责采集图像。选这套组合的原因很直接——部署成本低,一个 Tomcat 加一个 war 包就能跑,不需要 Node 环境,也不需要额外配置跨域代理。
常见做法是把摄像头采集放在前端完成,后端只做「收二进制、存文件、返回路径」三件事。这样前后端职责清晰,Servlet 不需要理解图像格式,只按字节流处理即可。对于还在用 JSP 的老系统,这种拆分方式改动面最小,不会牵动现有登录、权限、页面布局。
需要提前说明一个现实约束:getUserMedia只在安全上下文(HTTPS 或 localhost)下可用。本地开发用http://localhost:8080没问题,一旦部署到测试机用 IP 访问,浏览器会直接拒绝调起摄像头。这是这类项目上线前最容易翻车的地方,后面避坑章节会展开。
2.2 目录结构逐层说明
按典型 JSP Web 工程的组织方式,源码目录大致如下(不同 IDE 生成的结构会有细微差异,以实际解压为准):
camera-upload/ ├── src/ │ └── com/example/camera/ │ ├── UploadServlet.java # 接收 Base64/二进制并落盘 │ └── FileUtil.java # 文件名生成、目录创建 ├── WebContent/ │ ├── index.jsp # 拍照页面:video + canvas + 按钮 │ ├── js/ │ │ └── camera.js # getUserMedia 调起、抓帧、提交 │ ├── css/ │ │ └── style.css # 预览区与按钮样式 │ └── WEB-INF/ │ ├── web.xml # Servlet 映射 │ └── lib/ # 第三方 jar(如有) └── uploads/ # 照片落盘目录(运行时创建)index.jsp是入口,页面里放一个<video>做实时预览、一个隐藏<canvas>做抓帧、一个<img>做拍后回显。camera.js承担全部浏览器侧逻辑:请求摄像头权限、把视频流绑到 video 元素、点击拍照时把当前帧画到 canvas、再转成 Base64 通过fetch或表单提交给 Servlet。UploadServlet只做三件事:解析请求体、把 Base64 解码成字节数组、按时间戳命名写入uploads/目录。
web.xml里配置 Servlet 映射路径,常见写法是/upload。如果项目用 Servlet 3.0+ 的注解方式,@WebServlet("/upload")也能替代 XML 配置,具体看源码里用的是哪种。uploads/目录不建议放在WebContent下,否则照片会随 war 包一起被打进去,重新部署时容易丢;放在项目根目录或配置成外部绝对路径更稳妥。
2.3 一次拍照上传的完整数据流
把链路串起来看:用户打开index.jsp→ 页面加载时camera.js调getUserMedia({video: true})→ 浏览器弹出权限请求 → 授权后视频流绑定到<video>实时预览 → 用户点「拍照」→ canvas 以 video 当前帧绘制 →canvas.toDataURL('image/jpeg', 0.8)得到 Base64 字符串 → 前端把字符串 POST 到/upload→ Servlet 读取参数、Base64 解码、写文件 → 返回 JSON 或跳转成功页。
这条链路里有两个容易忽略的细节。第一,toDataURL的第二个参数是 JPEG 质量,取值 0 到 1,默认 0.92 左右,照片体积会偏大,实际项目里调到 0.7~0.8 能明显减小传输量。第二,Base64 字符串比原始二进制大约膨胀 33%,如果照片分辨率高,单次请求体可能到几 MB,Servlet 容器默认的 POST 大小限制需要确认,否则会收到 413 或空请求。
3. 核心代码拆解:从调起摄像头到文件落盘
3.1 前端调起摄像头与抓帧
camera.js的核心逻辑集中在三个函数:初始化视频流、抓帧转 Base64、提交到后端。下面这段是常见写法,参数和错误分支都保留:
// 初始化摄像头,绑定到 video 元素 async function initCamera(videoEl) { try { // 优先请求前置摄像头,移动端场景更常用 const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false }); videoEl.srcObject = stream; await videoEl.play(); } catch (err) { // 权限被拒、设备不存在、非安全上下文都会走到这里 console.error('摄像头调起失败:', err.name, err.message); alert('无法访问摄像头,请检查权限或使用 HTTPS 访问'); } } // 抓取当前帧并转为 Base64(JPEG) function captureFrame(videoEl, canvasEl, quality = 0.8) { const ctx = canvasEl.getContext('2d'); canvasEl.width = videoEl.videoWidth; canvasEl.height = videoEl.videoHeight; ctx.drawImage(videoEl, 0, 0, canvasEl.width, canvasEl.height); // quality 控制 JPEG 压缩率,0.7~0.8 是体积与清晰度的平衡点 return canvasEl.toDataURL('image/jpeg', quality); } // 提交 Base64 到后端 async function uploadPhoto(base64Data, uploadUrl) { const resp = await fetch(uploadUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'photo=' + encodeURIComponent(base64Data) }); return resp.json(); }initCamera里facingMode: 'user'指定前置摄像头,桌面端一般忽略这个参数;width/height用ideal而不是固定值,浏览器会尽量满足但不保证。captureFrame先把 canvas 尺寸设成视频实际分辨率,再drawImage,顺序不能反,否则抓出来是空白或拉伸。uploadPhoto用application/x-www-form-urlencoded提交,Base64 里的+、/、=必须经过encodeURIComponent,否则后端解码会出错——这是血泪经验,少这一步照片就是打不开的乱码。
3.2 Servlet 接收与 Base64 解码落盘
后端UploadServlet的doPost承担解码和写文件。关键点是先去掉 Base64 的 data URL 前缀,再解码:
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String photo = req.getParameter("photo"); resp.setContentType("application/json;charset=UTF-8"); if (photo == null || photo.isEmpty()) { resp.getWriter().write("{\"ok\":false,\"msg\":\"empty photo\"}"); return; } // 去掉 "data:image/jpeg;base64," 前缀 int comma = photo.indexOf(','); String base64 = comma >= 0 ? photo.substring(comma + 1) : photo; byte[] bytes; try { bytes = Base64.getDecoder().decode(base64); } catch (IllegalArgumentException e) { resp.getWriter().write("{\"ok\":false,\"msg\":\"invalid base64\"}"); return; } // 按时间戳命名,避免覆盖 String fileName = "photo_" + System.currentTimeMillis() + ".jpg"; File dir = new File(getServletContext().getRealPath("/uploads")); if (!dir.exists()) dir.mkdirs(); File target = new File(dir, fileName); try (FileOutputStream fos = new FileOutputStream(target)) { fos.write(bytes); } resp.getWriter().write("{\"ok\":true,\"path\":\"uploads/" + fileName + "\"}"); }req.setCharacterEncoding("UTF-8")必须在读参数之前调用,否则中文文件名或参数会乱码。indexOf(',')那一步是防御性处理:前端传的可能是纯 Base64,也可能带 data URL 前缀,两种都兼容。Base64.getDecoder()是 Java 8 自带工具,不需要额外引入 commons-codec,老项目如果还在 Java 7,得换成sun.misc.BASE64Decoder或第三方库。
落盘路径用getServletContext().getRealPath("/uploads")拿到的是 war 解压后的物理路径,重新部署会丢文件。生产环境我一般改成配置一个外部绝对路径,比如从web.xml的<context-param>读upload.dir,指向/data/uploads这类固定目录,避免部署即丢。
3.3 web.xml 映射与请求大小限制
Servlet 映射和上传大小限制都在web.xml里配:
<servlet> <servlet-name>UploadServlet</servlet-name> <servlet-class>com.example.camera.UploadServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>UploadServlet</servlet-name> <url-pattern>/upload</url-pattern> </servlet-mapping> <!-- 限制 POST 大小,防止超大 Base64 撑爆内存 --> <multipart-config> <max-file-size>5242880</max-file-size> <max-request-size>6291456</max-request-size> </multipart-config>max-file-size单位是字节,5 MB 对单张照片够用;max-request-size略大于文件上限,留出表单其他字段的空间。注意multipart-config只对multipart/form-data生效,如果前端用x-www-form-urlencoded提交 Base64,这个限制不生效,得靠 Tomcat 的maxPostSize(默认 2 MB)兜底。两种提交方式选一种即可,混用容易在排查时找不到限制来源。
4. 本地跑起来:环境、部署与验证
4.1 环境准备与部署步骤
跑这个项目需要 JDK 8 及以上、Tomcat 8/9(Tomcat 10 的包名从javax.*变成jakarta.*,老代码直接扔进去会报 ClassNotFound,要么换 Tomcat 9,要么改 import)。IDE 用 Eclipse 或 IntelliJ IDEA 都行,导入方式选「Existing Java Web Project」或手动配 Artifact。
部署步骤按顺序走:第一步,确认src下的 Java 文件编译输出到WEB-INF/classes;第二步,把项目打成 war 或配置 Tomcat 的 context 指向WebContent;第三步,启动 Tomcat,访问http://localhost:8080/camera-upload/index.jsp;第四步,浏览器弹权限请求时点允许,页面应出现实时画面;第五步,点拍照,检查uploads/目录是否生成 jpg 文件。
如果访问 404,先看 Tomcat 日志里 context 路径是不是项目名,web.xml的url-pattern是否和前端fetch的地址一致。如果页面能开但视频黑屏,打开浏览器控制台看getUserMedia的报错名:NotAllowedError是权限被拒,NotFoundError是没检测到摄像头,NotReadableError是设备被其他程序占用。
4.2 验证上传链路是否通
验证分三层,逐层排除。第一层,前端抓帧是否成功:在captureFrame返回后console.log(base64Data.length),长度应该在几万到几十万字符之间,为 0 说明 canvas 没画上。第二层,请求是否到达后端:在doPost开头打日志或断点,看photo参数是否为空。第三层,文件是否写成功:检查目标目录权限,Linux 下 Tomcat 运行用户对uploads/要有写权限,否则FileOutputStream抛AccessDeniedException。
一个快速自测办法:不经过摄像头,直接在浏览器控制台构造一个 1x1 像素的 Base64 图片,手动fetch到/upload,能落盘就说明后端链路没问题,剩下只需排查前端采集。这种分层验证比盯着整条链路猜要快得多。
5. 避坑与排查:五条上线前必须过的坎
5.1 非 HTTPS 环境下摄像头调不起来
现象:本地 localhost 正常,部署到测试机用 IP 访问,点拍照没反应,控制台报getUserMedia is not a function或NotAllowedError。原因:浏览器要求安全上下文,http://加非 localhost 的地址不满足条件。解决:测试环境配自签证书走 HTTPS,或者用localhost做端口转发访问;生产环境必须上 HTTPS,这不是可选项。
5.2 Base64 里的加号被当成空格
现象:照片能落盘,但打开是损坏的,文件头不对。原因:application/x-www-form-urlencoded提交时,Base64 中的+被服务端解码成空格,字节流错位。解决:前端encodeURIComponent包一层,后端req.getParameter拿到的已经是解码后的值,不要再手动URLDecoder一次,否则会二次解码出错。
5.3 重新部署后照片全丢
现象:每次重新发布 war 包,之前上传的照片都不见了。原因:落盘路径用了getRealPath("/uploads"),指向 war 解压目录,重新部署时整个目录被清空重建。解决:把上传目录配成 war 外的绝对路径,通过context-param或环境变量注入,部署脚本不碰这个目录。
5.4 大图上传返回 413 或空请求
现象:低分辨率照片正常,手机拍的高清图提交后后端收不到参数。原因:Base64 膨胀后请求体超过容器maxPostSize或max-request-size。解决:前端toDataURL质量降到 0.7,并在抓帧前把 canvas 尺寸限制在 1280 宽以内;后端同步调大限制,两边一起改才稳。
5.5 Tomcat 10 下 Servlet 类找不到
现象:换到 Tomcat 10 启动报java.lang.ClassNotFoundException: javax.servlet.http.HttpServlet。原因:Tomcat 10 起 Servlet API 包名迁移到jakarta.servlet.*。解决:要么继续用 Tomcat 9,要么把源码里所有javax.servlet改成jakarta.servlet并换依赖 jar,改之前先确认项目里没有其他 javax 依赖被牵连。
6. 二次开发:把拍照上传改成可复用的组件
这份源码最值得改的地方,是把「拍照 + 上传」从页面逻辑里抽出来,做成一个可复用的 JS 组件,让其他 JSP 页面一行调用就能用。改造思路是:把initCamera、captureFrame、uploadPhoto收进一个立即执行函数,暴露open和close两个方法,页面只负责传容器元素和上传地址。
// camera-widget.js:对外只暴露 open/close const CameraWidget = (function () { let stream = null; return { async open({ videoEl, canvasEl, onDone, uploadUrl }) { stream = await navigator.mediaDevices.getUserMedia({ video: true }); videoEl.srcObject = stream; await videoEl.play(); // 拍照按钮绑定一次即可,避免重复绑定 document.getElementById('snapBtn').onclick = async () => { const base64 = captureFrame(videoEl, canvasEl, 0.75); const result = await uploadPhoto(base64, uploadUrl); onDone && onDone(result); }; }, close() { // 释放摄像头,否则页面切走后设备一直被占用 if (stream) stream.getTracks().forEach(t => t.stop()); stream = null; } }; })();close里getTracks().forEach(t => t.stop())是关键,不释放的话,用户切到别的页面再回来,摄像头可能被占用导致NotReadableError。onDone回调把上传结果交给调用方,页面自己决定是回显图片还是跳转。
验证改造是否成功,我一般走三步:先在原页面替换成组件调用,确认拍照上传行为和改造前一致;再新开一个空白 JSP 页面接入组件,确认不依赖原页面的 DOM 结构;最后模拟权限拒绝和上传失败两个分支,确认回调里能拿到错误信息而不是静默失败。三步都过,这个组件才算能拿去别的页面用。
从那以后我每次抽公共组件,都强制先写一个「最小接入页面」跑通再合并,避免组件带着原页面的隐式依赖到处跑。希望帮到你。
本文还有配套的精品资源,点击获取