简介:面向Java Web与微信开发场景的完整示例项目,基于微信JS-SDK实现手机浏览器调用摄像头拍照,并将图片上传至阿里云服务器,适合需要快速落地“移动端拍照上传”功能的中级开发者参考。项目覆盖微信OAuth授权、AccessToken获取、JS-SDK签名生成、消息加解密、自定义菜单交互等环节,核心逻辑封装在CoreController、WeixinJSSDKSignUtil、WXBizMsgCrypt等类中,拍照上传链路清晰且作者标注亲测可用。 压缩包共130个文件,包含31个Java源码、33个编译后class、36个jar依赖库、2个JSP页面、3个XML配置及多张图片资源,整体约13.16MB,便于直接导入IDE对照学习,也可作为微信服务号或企业号开发的脚手架。目前已有252人学习下载,适合正处理微信网页开发与OSS上传联调问题的工程师快速查阅。
1. 一个JSP项目能干什么:摄像头拍照上传的完整实现
做Web开发的人早晚会遇到一个需求:页面里直接调起摄像头,拍一张照,然后传到服务器保存。表面看只是“调接口存文件”,真做起来却要绕好几个弯——浏览器安全策略要求HTTPS或localhost、不同浏览器对摄像头API的兼容性参差不齐、拍照后的图片编码格式要选对、后端保存路径要处理中文文件名。最近拆的这个“JSP调用摄像头拍照上传服务器”项目,正好把这一整条链路完整实现了,而且用的是最传统的JSP+Servlet+原生JavaScript,不依赖任何前端框架,对想搞懂Web摄像头原理的人非常友好。
这个项目适合谁?一是手头要做身份认证、访客登记、在线考试拍照之类功能的开发者,可以直接拿它当底座改;二是想搞明白getUserMedia、Canvas截图、FormData上传和Java后端文件接收这套组合拳的新手。代码量不大,但每一环节都有代表性,跑通了能帮你省掉一大半查资料的功夫。
2. 技术选型与项目结构:从浏览器摄像头到服务器磁盘的链路
2.1 技术栈为什么选JSP+Servlet+原生JS
先说结论:这个项目没有引入Spring Boot、Vue之类的现代框架,而是老老实实地用了JSP+Servlet+原生JavaScript。原因不难猜——这种“摄像头拍照上传”的核心逻辑其实不依赖后端框架,Servlet负责接收上传的二进制文件,JSP作为页面容器,前端用浏览器原生API就能完成全部交互。框架越薄,理解成本越低,二次开发反而更自由。
前端部分,调用摄像头用的是navigator.mediaDevices.getUserMedia(),这是HTML5标准API,现代浏览器都支持,包括Chrome、Firefox、Edge,以及手机上的主流浏览器。拍照动作是通过<canvas>把视频流的当前帧“截”下来,再转成Blob对象,最后用FormData通过Ajax提交到Servlet。整个过程不涉及任何第三方插件,也没有ActiveX控件那种只能在IE里用的老古董。
后端部分,Servlet继承HttpServlet,重写doPost方法,使用Part接口直接获取上传文件。Java EE的Servlet 3.0起原生支持文件上传,request.getPart("file")就能拿到文件流,然后写入服务器目录。存储位置是一个可配置的上传目录,文件名默认用时间戳加工,避免重名覆盖。
为什么这个组合值得参考?因为它在“能干活”和“看得懂”之间取到了平衡。你不需要先搞懂一堆依赖注入和自动配置,就能看清一个请求从浏览器到服务器的每一步。对初学者来说,这是最好的教材;对熟手来说,替换成Spring Boot或改造为微服务,改动点也非常明确。
2.2 目录结构与关键文件说明
一个标准的Eclipse或IDEA动态Web项目结构,整个工程拆开来看比较清爽。项目名假设是CameraUploadDemo,核心目录大致如下:
CameraUploadDemo ├── src │ ├── com.demo.camera.servlet │ │ └── UploadServlet.java // 接收文件的后端入口 │ └── com.demo.camera.util │ └── FileNameUtil.java // 文件名生成工具 ├── WebContent │ ├── index.jsp // 入口页面 │ ├── camera.jsp // 摄像头调用与拍照页面 │ ├── success.jsp // 上传成功回显页面 │ ├── WEB-INF │ │ └── web.xml // Servlet映射与欢迎页配置 │ └── upload // 上传目录(运行时自动创建) └── build/pom.xml(如果是Maven项目)UploadServlet是核心后端类,访问路径配置为/upload,前端Ajax提交到就是这个地址。camera.jsp里是全部前端逻辑,包括启动摄像头、取景、拍照、压缩、上传。web.xml里除了配置Servlet映射,还需要设置multipart-config支持,否则request.getPart会直接报错。
这里有个容易掉坑的点:如果项目是Tomcat 8之前的老版本,getPart需要额外引入commons-fileupload库。这个项目默认采用Servlet 3.0规范,所以直接可用,部署到Tomcat 8+就省去依赖。
2.3 请求链路与数据流转
整个流程可以用一句话描述:浏览器通过getUserMedia获得到视频流,显示在<video>元素里;用户点击拍照按钮后,把视频当前帧绘制到<canvas>,再通过canvas.toBlob()转成JPEG格式的Blob;随后这个Blob被放进FormData,以file字段名通过XMLHttpRequestPOST到/upload;服务器端UploadServlet用request.getPart("file")拿到Part对象,读取输入流,写入upload目录下的新文件,最后向前端返回一段JSON或HTML,告知上传结果。
这个链路里有三个关键决策点,值得展开说说。
第一,为什么用canvas.toBlob而不是canvas.toDataURL?toDataURL生成的是Base64字符串,而toBlob生成的是二进制大对象。Base64体积比原图大三分之一,上传消耗流量多,后端还要解码后存文件,绕了一圈。toBlob能直接用原始二进制通过FormData上传,后端只需要读取输入流即可,更干净高效。
第二,为什么拍照前要先设置canvas的宽高?因为视频流中每个视频帧都有固定的分辨率,如果canvas尺寸和视频显示尺寸不一致,画出来的图会被拉伸或裁剪,造成人脸变形。正确做法是读取video.videoWidth和video.videoHeight,动态设置canvas宽高,保证像素一一对应。
第三,为什么不直接用原图上传?移动端摄像头的分辨率动辄几千万像素,一张照片可能五六兆,上传慢且占服务器空间。项目里在canvas绘制后调用了canvas.toBlob(..., 'image/jpeg', 0.6),第三个参数是图片质量压缩比,0到1之间,0.6是个常用折中值,能在画质和体积之间取平衡。对身份验证类场景,人脸清晰度够用。
3. 核心模块拆解:前端拍照与后端接收的逐段实现
3.1 前端调用摄像头:getUserMedia的兼容封装
camera.jsp里有一段封装函数,专门处理浏览器兼容性。老版本的Chrome和Firefox需要加navigator.getUserMedia || navigator.webkitGetUserMedia前缀,现在统一用navigator.mediaDevices.getUserMedia,但在某些WebView里仍然需要兜底。
// 获取视频流并绑定到video元素 function startCamera() { const video = document.getElementById('video'); const frontCamera = 'user'; // 前置摄像头 const backCamera = 'environment'; // 后置摄像头,手机端有用 const constraints = { video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: frontCamera // 默认前置 }, audio: false // 只取视频流 }; if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { alert("当前浏览器不支持摄像头访问,请使用HTTPS协议或localhost"); return; } navigator.mediaDevices.getUserMedia(constraints) .then(function(stream) { video.srcObject = stream; video.play(); // 把stream保存在全局变量,后面停止摄像头时可用 window.localStream = stream; }) .catch(function(error) { console.log("摄像头启动失败:", error); var msg = ""; switch (error.name) { case "NotAllowedError": msg = "用户拒绝了摄像头权限"; break; case "NotFoundError": msg = "未检测到可用摄像头设备"; break; case "NotReadableError": msg = "摄像头已被其他应用占用"; break; default: msg = "摄像头启动错误:" + error.message; } alert(msg); }); }这段代码里最值得关注的是constraints里的facingMode参数。在手机上,'user'指的是前置摄像头,'environment'是后置摄像头。这个项目原本面向PC,所以默认前置,但做移动端适配时会用到后置。注意audio: false必须写,否则在部分浏览器中会同时请求麦克风权限,导致用户弹窗出现“摄像头和麦克风”两项授权,体验不好。
启动失败的错误处理不能马虎。实际开发时最常见的错误是NotAllowedError,也就是用户没授权或者浏览器设置里禁用了摄像头权限。另一种常见情况是本地调试用file://协议打开HTML,浏览器直接拒绝。项目里已经在代码上方提示了“HTTPS或localhost”,但很多人还是会在Chrome里以file://方式打开,然后一脸懵。这个我在后面避坑章节会专门提。
3.2 拍照与Canvas压缩:从视频流到图片数据
拍照的核心逻辑是截帧。video元素正在播放视频流时,可以用canvas的drawImage方法把当前帧画到画布上,然后转成Blob。
// 拍照按钮点击事件 function takePhoto() { const video = document.getElementById('video'); const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); // 根据视频真实尺寸调整canvas大小,避免拉伸变形 canvas.width = video.videoWidth; canvas.height = video.videoHeight; // 把视频当前帧绘制到canvas ctx.drawImage(video, 0, 0, canvas.width, canvas.height); // 转成JPEG格式Blob,quality取值0到1,0.6是压缩质量 canvas.toBlob(function(blob) { if (blob) { // 保存blob到全局,等待上传 window.uploadBlob = blob; // 生成图片预览 const preview = document.getElementById('preview'); preview.src = URL.createObjectURL(blob); // 显示预览区,隐藏视频区(可选) document.getElementById('videoArea').style.display = 'none'; document.getElementById('previewArea').style.display = 'block'; alert("拍照成功,点击上传按钮完成提交"); } else { alert("拍照失败,请重试"); } }, 'image/jpeg', 0.6); }这里有几个细节必须说明。video.videoWidth和video.videoHeight拿到的是视频流当前帧的原始尺寸,注意不是CSS里设定的width和height。很多新手直接把canvas.width写成video.style.width,典型的320px或640px,拍出来的照片严重模糊。无论视频显示的尺寸多小,原始像素分辨率是固定的。
toBlob回调里的blob对象是二进制的,可以直接用来做URL.createObjectURL(blob)生成一个临时路径预览,也可以直接放进FormData。URL.createObjectURL创建的URL必须用URL.revokeObjectURL释放,否则会有内存泄漏。这个项目里因为上传后页面就跳转了,所以没做释放,但如果你要做单页应用,一定要记得在图片显示完或上传完成后调用URL.revokeObjectURL(preview.src)。
压缩质量参数0.6需要按场景调。如果只是在普通屏幕上看,0.6完全够;如果要打印或做OCR识别,建议调到0.8以上。别担心文件会太大,因为canvas的尺寸已经是视频原始分辨率的压缩,通常一张1280x720的JPEG在质量0.6下只有100KB左右。
3.3 上传与后端Servlet:FormData与文件保存
前端上传用XMLHttpRequest,避免整页刷新。当然也可以用fetch,但XHR的进度回调更直观,兼容也更好。
function uploadPhoto() { const blob = window.uploadBlob; if (!blob) { alert("请先拍照"); return; } const formData = new FormData(); // 字段名为file,后端通过getPart("file")获取 formData.append("file", blob, "camera_" + Date.now() + ".jpg"); const xhr = new XMLHttpRequest(); xhr.open("POST", "upload", true); // upload是Servlet映射路径 // 页面显示上传进度 xhr.upload.onprogress = function(e) { if (e.lengthComputable) { const percent = Math.round(e.loaded / e.total * 100); document.getElementById("progressBar").style.width = percent + "%"; } }; xhr.onload = function() { if (xhr.status === 200) { // 返回JSON,格式:{code:1, msg:"上传成功", url:"upload/xxx.jpg"} const result = JSON.parse(xhr.responseText); if (result.code === 1) { window.location.href = "success.jsp?url=" + encodeURIComponent(result.url); } else { alert(result.msg); } } else { alert("上传失败,服务器错误:" + xhr.status); } }; xhr.send(formData); }注意formData.append的第三个参数是文件名,这里用了时间戳,防止文件名重复。后端在保存时如果用原文件名,就极可能因为重名把前一张照片覆盖掉。我在改造时通常会结合用户ID或会话ID来命名,比如“用户ID_时间戳.jpg”。
对应后端Java代码,放在UploadServlet.java里:
package com.demo.camera.servlet; import java.io.File; import java.io.IOException; import java.io.PrintWriter; import java.util.UUID; import javax.servlet.ServletException; import javax.servlet.annotation.MultipartConfig; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.Part; @WebServlet("/upload") @MultipartConfig( maxFileSize = 1024 * 1024 * 5, // 单个文件最大5MB maxRequestSize = 1024 * 1024 * 10, // 请求体最大10MB fileSizeThreshold = 1024 * 1024 // 超过1MB则写入磁盘临时文件 ) public class UploadServlet extends HttpServlet { private static final long serialVersionUID = 1L; protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); response.setContentType("application/json;charset=UTF-8"); PrintWriter out = response.getWriter(); // 1. 获取上传目录,上传到项目upload目录下 String uploadPath = getServletContext().getRealPath("/upload"); File uploadDir = new File(uploadPath); if (!uploadDir.exists()) { uploadDir.mkdirs(); } try { // 2. 从请求中解析file字段 Part part = request.getPart("file"); if (part == null || part.getSize() == 0) { out.write("{\"code\":0,\"msg\":\"未获取到上传文件\"}"); return; } // 3. 根据后缀名和唯一ID生成新文件名 String fileName = UUID.randomUUID().toString().replace("-", "") + ".jpg"; String filePath = uploadPath + File.separator + fileName; // 4. 将上传流写入文件 part.write(filePath); // 5. 返回相对URL,用于页面回显 out.write("{\"code\":1,\"msg\":\"上传成功\",\"url\":\"upload/" + fileName + "\"}"); } catch (Exception e) { e.printStackTrace(); out.write("{\"code\":0,\"msg\":\"服务器异常:" + e.getMessage() + "\"}"); } finally { out.close(); } } }@MultipartConfig注解中三个参数都很关键。maxFileSize限制单个文件大小,这里5MB足够应付压缩后的照片;maxRequestSize限制整个请求体的总大小,防止有人一次提交多个大文件造成内存溢出;fileSizeThreshold表示超过1MB后,上传内容会先写入操作系统临时目录而不是全放内存,避免大文件占满JVM堆内存。
getServletContext().getRealPath("/upload")在Tomcat中会映射到项目部署目录下的upload文件夹。但它有一个隐患:修改代码并重新部署时,Tomcat可能会清空这个目录,导致用户历史照片丢失。我一般会建议改成外部目录,比如/data/upload/或者配置文件的路径,但项目为了演示方便,写在了应用目录内。
4. 部署与验证:把项目跑起来的完整步骤
4.1 环境准备与导入工程
你需要准备的东西不多:JDK 8+、Eclipse或IDEA、Tomcat 8.5+。项目解压后,在IDEA里选“打开”,定位到解压后的目录,选择作为Maven项目导入(如果带pom.xml),或者直接导入为Web项目。没有pom.xml的话,推荐用Eclipse“导入现有项目”方式,直接识别WebContent结构。
导入后先检查项目构建路径里有没有Tomcat的servlet-api.jar依赖。IDEA中通常需要手动添加:文件-项目结构-库-加号-选择Tomcat安装目录中的lib/servlet-api.jar。如果不加,javax.servlet包会全部报红,但编译时因为提交到Tomcat运行,缺这个依赖不会导致项目跑不起来,只影响开发期的语法检查。
4.2 配置Tomcat与Artifact
在IDEA中运行Tomcat需要配置一个Artifact。打开运行配置,选择“Tomcat Server -> Local”,在“部署”选项卡里把导出的war包或exploded目录加进去。项目不需要配置数据库,所以启动特别快,Tomcat启动后,地址栏输入http://localhost:8080/项目上下文/即可看到首页。
web.xml里已经配置好了欢迎页,默认进入index.jsp。index.jsp里只有一个链接指向camera.jsp。进入camera.jsp后,浏览器会弹出摄像头权限请求,允许后视频画面就出来了。
注意:本地用http://localhost:8080访问是被浏览器允许的,但如果你用http://127.0.0.1:8080访问,在部分Chrome版本里会被当作“不安全来源”而拒绝摄像头访问。这不是项目bug,是浏览器安全策略的差异。最简单的办法是统一用localhost访问。
4.3 启动测试与常见验证点
验证项目是否跑通,不要只点“拍照”和“上传”,要分步骤验证每个环节是否正常。我给一个验证脚本思路:
- 第一步:打开camera.jsp,看video区是否出现画面。如果画面黑屏,先按F12查看控制台有没有报错,多半是权限拒绝。
- 第二步:点击拍照,看预览区是否出现一张跟video画面一致的图。如果图是黑的或全白,检查canvas宽高是不是为0。
- 第三步:点击上传,观察进度条移动,然后页面跳转到success.jsp,上面显示一张服务器返回的URL图片。这个URL是
upload/xxx.jpg,相对路径。 - 第四步:到Tomcat部署目录的
upload文件夹下,确认刚才的图片确实写入了磁盘。这一步能排除“看起来上传成功但实际是前端假象”的问题。
还有一个容易被忽略的验证点:图片的Exif信息。因为拍照直接来自视频流,不是原生相机,所以图片没有Exif方向信息。如果你拍的照片在电脑上旋转了90度,可能是浏览器里显示没问题但系统看图器自动旋转的结果,这在二次开发做门禁识别时会有影响。
5. 避坑指南:摄像头调用与上传的经典翻车现场
5.1 现象:摄像头打开后黑屏,控制台报NotAllowedError
原因:浏览器安全策略规定,摄像头API只能在HTTPS加密连接或localhost下使用。很多开发者为了局域网演示,用http://192.168.1.100:8080访问,摄像头完全无法启动,报错信息虽然明确,但实际部署时大家往往忽略。
解决:必须给服务器配置SSL证书(开发环境可以用自签名证书),或者让用户使用localhost访问。如果要支持局域网IP访问,一个绕路方案是使用Nginx反代,把HTTP转发到后端Tomcat,但摄像头页面本身必须通过HTTPS访问。项目中已包含一个Spring Boot嵌入式模式下的测试方案,但本项目的原生态架构下,用HTTPS是唯一正路。
5.2 现象:上传到服务器后图片无法显示,浏览器打开404
原因:上面提到过,getRealPath("/upload")获取的是Tomcat部署目录下的upload文件夹,但如果你在页面中用相对路径upload/xxx.jpg去访问,需要确保URL上下文路径正确。例如你的项目部署在webapps/CameraUploadDemo下,正确URL是/CameraUploadDemo/upload/xxx.jpg。如果你把页面直接放在根目录下,URL会找不到文件。
解决:后端返回URL时不要写死upload/xxx.jpg,要拼接request.getContextPath()。修改UploadServlet里的返回值:
String contextPath = request.getContextPath(); out.write("{\"code\":1,\"msg\":\"上传成功\",\"url\":\"" + contextPath + "/upload/" + fileName + "\"}");这样无论项目部署在哪个上下文,前端都能拿到正确的绝对路径。
5.3 现象:上传文件名带中文,保存后乱码或无法读取扩展名
原因:前端在formData.append里设置了文件名,但很多浏览器会把中文文件名用UTF-8编码,后端request.getCharacterEncoding("UTF-8")只能设置请求体的文本字段编码,文件名的编码是另一个维度,容易出错。
解决:最省心的做法是后端不管上传来的原始文件名,直接用UUID生成新文件名。项目里已经是这样做了,所以这一坑在项目里不存在,但如果你是照着自己原来的代码改,请务必抛弃part.getSubmittedFileName()去取原始文件名的方式。扩展名统一约定是.jpg,因为前端拍照的格式固定为JPEG。如果你的场景需要支持PNG上传,要先判断getContentType()或请求头里的Content-Type,再决定扩展名。
5.4 现象:微信内置浏览器无法调用摄像头
原因:微信内置浏览器的WebView版本和权限策略与原生浏览器不同。在某些系统上,WebView不支持navigator.mediaDevices.getUserMedia,或者权限弹窗被微信拦截,导致摄像头无法启动。这也是本项目的关键词“微信技术”最常涉及的实战痛点。
解决:要在微信里用摄像头拍照,通常有两种方案。方案一:判断当前是否在微信内置浏览器中(通过navigator.userAgent),如果是,改用wx.chooseImage这类微信JSSDK接口,调起原生相机拍照,返回本地图片ID再上传。方案二:用H5+等混合开发框架封装一层原生调用。如果坚持纯H5,那只能期望微信WebView的版本更新到Chromium内核支持,但遇到旧设备依然抓瞎。
6. 二次开发思路:从拍照上传到人脸识别与微信适配
6.1 改造点一:把照片改成Base64直传
有些后端接口对接方(尤其是第三方认证平台)不希望接收二进制文件流,只接受Base64字符串。这时前端可以用canvas.toDataURL('image/jpeg', 0.6)替代toBlob,得到形如data:image/jpeg;base64,/9j/4AAQ...的字符串。发送时转成普通文本字段,后端用Base64.getDecoder().decode()还原为byte[],再写入文件。
// 用toDataURL替代toBlob const base64 = canvas.toDataURL('image/jpeg', 0.6); // 去掉data:image/jpeg;base64,前缀 const base64Data = base64.split(',')[1]; // 放入JSON请求体或FormData后端对应处理时,要注意Base64的字符串体积比原图大三分之一,maxFileSize要相应调大。
6.2 改造点二:适配微信内置浏览器与移动端
如果你的业务重点是“微信内网页提交照片”,建议一开始就检测环境。通过UserAgent判断是否包含MicroMessenger,是则跳转到微信JSSDK流程,否则走标准H5摄像头。这个项目的价值在于它能给你一个完整的H5回退方案,让你在非微信浏览器里依然可以用同样的页面完成拍照。
实践中还会遇到“切换摄像头”的需求。在constraints里动态改facingMode,重新调用getUserMedia,然后替换video.srcObject即可。注意要先关掉之前的stream,避免多个摄像头同时占用报NotReadableError。
6.3 改造点三:接入后端存储与权限校验
当前项目把图片保存到本地磁盘,生产环境一般需要改造为对象存储,或者至少存到指定的外部路径。改造时在UploadServlet里注入一个存储服务接口,把part.write(filePath)替换成上传到云端。同时要考虑到接口鉴权,比如从Session判断用户是否登录,否则任何人只要知道你的Servlet地址,就能无限上传文件,分分钟把磁盘塞满。我从那以后每次做这类上传接口,都会强制加上“会话校验+文件类型白名单+大小限制”三道关卡,缺一道都觉得心里不踏实,希望帮到你。
本文还有配套的精品资源,点击获取