简介面向Java Web初学者与移动端Web开发者的JSP摄像头拍照上传示例包围绕手机浏览器调用摄像头、前端拍照后经JSP后端接收并上传至阿里云服务器的完整链路展开同时融入微信技术相关场景适合课程实训、毕业设计原型及功能验证时参考。压缩包共130个文件约13.16MB包含31个java源码、33个class编译文件、36个jar依赖以及2个jsp页面、3个js脚本、3个xml配置和若干png、jpg图片素材基本覆盖工程源码、运行依赖与静态资源便于导入IDE后查看工程结构。已有253人学习说明该示例在同类需求中具备一定参考价值。读者可从中了解JSP接收上传请求、调用摄像头采集图像、服务端文件处理与阿里云存储对接的实现方式并借助现有工程与依赖排查环境配置、接口调用等常见问题。1. 老后台也要拍照上传这套 JSP 方案解决什么问题去年接手一个内网登记系统的改造后台是十几年前的 JSP 页面客户提的要求只有一句打开网页就能拍张照拍完直接存到服务器上别装插件、别装客户端。很多人听到“JSP 调用摄像头拍照上传”会下意识觉得该上 React 加 WebRTC 的全套方案结果到了现场才发现机器还是老浏览器前端构建工具一套都跑不起来。这套资源的做法刚好相反前端只用浏览器原生的getUserMedia取流canvas截一帧把图片丢给一个 Servlet后端落到磁盘目录JSP 页面刷新就能看到缩略图列表。整套流程零构建、零 npm 依赖扔进 Tomcat 就能跑。它适合内网访客登记、工位抓拍、证件照初筛这类“能跑通就行”的场景也适合接手老 JSP 项目、需要在现有后台里补一个采集入口的开发者。2. 技术选型与工程结构为什么是 JSP Servlet 原生 JS2.1 选型账本不打包前端不引后端框架先要把这套方案的取舍讲明白否则后面改代码会一直纠结“为什么不用 XX”。浏览器调摄像头这件事本质上跟后端语言没关系navigator.mediaDevices.getUserMedia是浏览器提供的 API只要页面跑在安全上下文里就能用。真正决定技术栈的是部署环境如果客户服务器上只装了 Tomcat运维不肯装 Node、不肯配 CI那前端构建工具就是负担如果项目本身就是 JSP 页面直出那硬塞一个前后端分离工程光是跨域和静态资源映射就够折腾半天。这套资源选 JSP 直出页面好处是页面和接口同源fetch上传不用处理 CORS上传完直接location.reload()就能看到新图。后端不用 Spring 而用裸 Servlet也是同一个逻辑。整个后端只有两三个类一个上传 Servlet、一个列表 Servlet、可能再加一个删除 Servlet用WebServlet注解注册连web.xml都不用写全。引入 Spring 反而要处理容器、依赖注入、配置文件对一个只有拍照上传功能的模块来说属于过度设计。关注点这套方案的选择换成主流方案的成本页面渲染JSP 直出同源前后端分离要配 CORS 或反向代理摄像头取流原生getUserMediacanvas引入 WebRTC 库体积翻几倍上传接口Servlet 3.0Part需要引入文件上传组件并配置文件存储本地磁盘按日期分目录对象存储要额外开通和签名部署打 war 丢 Tomcat需要容器化或独立进程这张表不是为了证明哪种更好而是说明一件事当你的需求是“在一个已经存在的 JSP 后台里补一个拍照入口”这套组合的阻力最小。我见过太多项目在这个场景里上了重方案最后卡在运维审批上。2.2 目录结构与关键文件清单把 war 包解开之后结构大致是这样我按“你改代码时会打开哪些文件”的顺序列路径作用改动频率src/main/webapp/camera.jsp采集页video canvas 按钮高src/main/webapp/js/camera.js取流、截帧、上传逻辑高src/main/webapp/list.jsp已上传照片列表与回显中src/main/java/.../PhotoUploadServlet.java接收上传、校验、落盘高src/main/java/.../PhotoListServlet.java扫描目录返回 JSON低src/main/java/.../UploadConfig.java根目录、大小上限等常量中src/main/resources/upload.properties存储根路径、允许后缀中WEB-INF/web.xml欢迎页、编码过滤器可选低upload.properties里一般就三四个键upload.root绝对路径例如/data/photo、upload.maxSize单文件字节数、upload.allowExtjpg,jpeg,png。把存储根路径做成外部配置而不是写死在代码里是这套代码里我最认可的一处设计因为部署到不同机器上目录结构几乎必然不一样。2.3 环境准备与跑通第一步运行环境不复杂JDK 8 及以上、Tomcat 8.5 或 9、一个能编辑 JSP 的 IDE。Servlet 3.0 的Part接口要求 Tomcat 7 以上实际建议直接上 Tomcat 9避免后面踩老版本的坑。如果用 Maven 管理pom.xml里只需要一个 servlet-apiscope必须是provided因为 Tomcat 自带dependencies !-- 容器已提供打进 war 会与 Tomcat 冲突 -- dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency !-- 可选JSON 序列化不想引也可以手拼字符串 -- dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.15.2/version /dependency /dependencies打包后把 war 丢进webapps启动 Tomcat访问http://localhost:8080/photo/camera.jsp。注意这里用localhost而不是局域网 IP原因在 3.3 节会展开这是新手最容易卡住的一步。web.xml里我通常会加一个编码过滤器省得后面到处写setCharacterEncodingfilter filter-nameencoding/filter-name filter-classorg.apache.catalina.filters.SetCharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param /filter filter-mapping filter-nameencoding/filter-name url-pattern/*/url-pattern /filter-mappingSetCharacterEncodingFilter是 Tomcat 自带的类不用额外引包url-pattern配成/*能覆盖所有请求包括静态资源。如果你的项目要部署到别的容器换成自己写的Filter也一样逻辑就是request.setCharacterEncoding(UTF-8)加response.setCharacterEncoding(UTF-8)。3. 前端取流从 getUserMedia 到 canvas 截帧3.1 打开摄像头video getUserMedia 的最小实现页面结构尽量简单video负责实时预览canvas放在旁边隐藏起来只做截帧用!-- camera.jsp 的采集区 -- video idvideo autoplay playsinline muted/video canvas idcanvas styledisplay:none/canvas button idbtnOpen打开摄像头/button button idbtnShot disabled拍照上传/button img idpreview alt预览 /autoplay让流接上就播muted是自动播放策略的硬要求没有声音也必须标静音否则部分浏览器会拦playsinline是给 iOS 用的不加的话视频会被强制全屏播放页面上就看不到预览框了。这三个属性缺一个都可能有诡异表现属于血泪经验。取流的 JavaScript 部分let stream null; const video document.getElementById(video); const canvas document.getElementById(canvas); async function openCamera() { // audio: false 必须显式声明否则部分设备会申请麦克风权限用户直接拒绝 const constraints { audio: false, video: { width: { ideal: 1280 }, // ideal 是期望值设备不支持会自动降级 height: { ideal: 720 }, facingMode: user // user 前置environment 后置 } }; try { stream await navigator.mediaDevices.getUserMedia(constraints); video.srcObject stream; await video.play(); document.getElementById(btnShot).disabled false; } catch (e) { // 常见值NotAllowedError用户拒绝、NotFoundError无设备、NotReadableError被占用 alert(摄像头打开失败 e.name); } } function closeCamera() { if (stream) { stream.getTracks().forEach(t t.stop()); // 必须 stop否则摄像头指示灯常亮 stream null; } }width和height用ideal而不是exact因为exact在设备不支持时会直接抛OverconstrainedError而ideal只是尽量满足。1280×720 是我一般推荐的取值再高对登记照片没有意义反而让上传体积翻倍再低人脸细节不够后期做人脸比对会吃亏。closeCamera里那个getTracks().forEach(t t.stop())不要省。浏览器不会因为页面跳转就释放摄像头用户看到摄像头灯一直亮着会直接怀疑这个系统在偷拍——这种事在客户现场解释起来很麻烦。3.2 截帧与编码toDataURL 还是 toBlob截帧就是把video当前画面画到canvas上function capture() { const w video.videoWidth; const h video.videoHeight; if (!w || !h) return null; // 流还没就绪时宽高为 0直接返回 canvas.width w; canvas.height h; const ctx canvas.getContext(2d); // 前置摄像头画面是镜像的需要翻转回来否则照片上的字是反的 ctx.translate(w, 0); ctx.scale(-1, 1); ctx.drawImage(video, 0, 0, w, h); ctx.setTransform(1, 0, 0, 1, 0, 0); // 恢复变换避免影响下次绘制 // quality 0.92JPEG 在这个值附近肉眼几乎无损体积约为 PNG 的 1/5 到 1/10 return canvas.toDataURL(image/jpeg, 0.92); }几个参数值得单独说。video.videoWidth和canvas.width必须一致不一致就会拉伸变形很多人写死canvas.width 640结果摄像头实际输出 1280照片横向被压扁。toDataURL的第二个参数只对image/jpeg和image/webp生效image/png会忽略它。0.92 这个值是我压出来的经验值低于 0.8 时人脸边缘开始出现明显块状噪点高于 0.95 体积涨幅大但肉眼看不出差别。toDataURL返回的是data:image/jpeg;base64,...字符串直接FormData提交会让请求体膨胀约 33%。所以上传时我会先转成Blobasync function dataUrlToBlob(dataUrl) { const resp await fetch(dataUrl); // 利用 fetch 解析 dataURL比手写 atob 更省事 return await resp.blob(); } async function uploadPhoto() { const dataUrl capture(); if (!dataUrl) { alert(画面还没准备好); return; } const blob await dataUrlToBlob(dataUrl); const fd new FormData(); fd.append(photo, blob, capture.jpg); // 第三个参数决定后端拿到的文件名 fd.append(bizId, document.getElementById(bizId).value); const resp await fetch(upload/photo, { method: POST, body: fd }); const result await resp.json(); if (result.code 0) { document.getElementById(preview).src result.url; } else { alert(上传失败 result.msg); } }FormData的好处是不用自己拼multipart/form-data的边界字符串浏览器会处理好boundary。第三个参数capture.jpg只是给后端一个提示真正的文件名后端一定会重新生成这一点在 4.2 节会讲。3.3 安全上下文与微信内置浏览器的兼容处理这一段是这套代码里最容易翻车的地方单独拎出来说。navigator.mediaDevices只在安全上下文里存在。安全上下文包括https://开头的地址以及http://localhost、http://127.0.0.1。也就是说你把项目部署到内网http://192.168.1.50:8080navigator.mediaDevices直接是undefined代码报Cannot read property getUserMedia of undefined跟摄像头坏没坏一点关系都没有。解决办法有两个一是本地开发就用localhost访问别用 IP二是内网正式部署时给 Tomcat 前面的 Nginx 配一张自签证书走https。自签证书浏览器会拦一次让用户点“继续访问”即可内网场景可以接受。移动端还要多考虑一层。微信内置浏览器WebView里跑 H5 页面调摄像头安卓上一般正常iOS 上则比较挑剔页面必须在https下、video必须带playsinline、打开摄像头的动作必须由用户的点击手势直接触发。如果写成页面加载完自动openCamera()iOS 上大概率黑屏。所以按钮触发是必须的不要图省事做自动打开。如果业务场景本身就是“用户在微信里打开链接拍照上传”那还有一种更省事的路径走微信 JSSDK 的图片接口让用户从相册选或者调起系统相机拿到的是本地文件 ID再由后端换取图片。代价是要做签名、要配可信域名而且拿到的图片尺寸被压缩过。我的判断标准是需要实时预览、需要限定拍摄角度、需要现场取证的用getUserMedia只是让用户交一张照片的用 JSSDK 更省心。两套代码可以并存用 UA 判断一下就行。4. 后端落盘Servlet 如何接住一张照片4.1 三种上传接法的取舍JSP 时代传文件有三条路选错了后面全是坑方式依赖适用场景主要问题request.getPart()Servlet 3.0新项目首选需要MultipartConfigCommons FileUpload额外 jar老容器、需要细粒度控制要自己解析ServletFileUploadbase64 塞进表单字段无图片很小、接口风格统一体积膨胀 33%大图易超限这套资源走的是第一条。前提是 Servlet 上必须加MultipartConfig不加的话getPart会抛IllegalStateException错误信息还挺含糊新手容易卡住。WebServlet(/upload/photo) MultipartConfig( fileSizeThreshold 1024 * 1024, // 超过 1MB 写临时文件不占堆内存 maxFileSize 8L * 1024 * 1024, // 单张照片上限 8MB maxRequestSize 10L * 1024 * 1024 // 整个请求上限留 2MB 给其他字段 ) public class PhotoUploadServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType(application/json;charsetUTF-8); Part part req.getPart(photo); if (part null || part.getSize() 0) { writeJson(resp, 400, 没有收到图片, null); return; } // 后续校验与落盘见 4.2 } }fileSizeThreshold决定多大的文件开始写磁盘临时文件。设太小会让每张小图都产生一次磁盘 IO设太大则大图会堆在内存里1MB 是个折中值。maxFileSize和maxRequestSize一定要设不设的话默认是-1无限制一个恶意请求就能把磁盘写满。8MB 对应的是 1280×720 JPEG 的高质量档实际一般只有 200KB 到 500KB留这个上限主要是防异常。4.2 文件名、目录与安全校验这一段是后端最需要认真写的地方直接决定系统会不会被传上来一个.jsp文件然后被人访问执行。private static final SetString ALLOW_EXT new HashSet(Arrays.asList(jpg, jpeg, png)); private String savePhoto(Part part, String root) throws IOException { // 1. 后缀白名单只从原始文件名里取后缀并且强制小写比对 String submitted part.getSubmittedFileName(); // Servlet 3.13.0 需解析 content-disposition String ext ; if (submitted ! null submitted.lastIndexOf(.) 0) { ext submitted.substring(submitted.lastIndexOf(.) 1).toLowerCase(); } if (!ALLOW_EXT.contains(ext)) { throw new IOException(不支持的文件类型: ext); } // 2. 用 ImageIO 真正读一遍防止改后缀绕过 try (InputStream in part.getInputStream()) { BufferedImage img ImageIO.read(in); if (img null) { throw new IOException(不是有效图片); } } // 3. 目录按日期分片避免单目录文件过多导致 ls 卡死 String datePath new SimpleDateFormat(yyyy/MM/dd).format(new Date()); Path base Paths.get(root).toAbsolutePath().normalize(); Path dir base.resolve(datePath).normalize(); if (!dir.startsWith(base)) { // 防路径穿越虽然这里 datePath 是内部生成的 throw new IOException(非法路径); } Files.createDirectories(dir); // 4. 文件名与原始名彻底脱钩杜绝覆盖和脚本后缀 String name UUID.randomUUID().toString().replace(-, ) . ext; Path target dir.resolve(name); // 5. 用 Files.copy 而不是 part.write后者相对 multipart 临时目录路径极易搞错 try (InputStream in part.getInputStream()) { Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); } return datePath / name; // 返回相对路径存库和拼 URL 都用它 }逐条解释一下为什么这么写。后缀白名单是必须的因为 Tomcat 默认会执行webapp目录下的.jsp如果上传目录恰好在webapp里面一个改名的jsp就是现成的后门。ImageIO.read那一遍是第二道闸它会把文件头读出来解析改后缀的文本文件、压缩包在这里就会被拦下返回null。当然这一步也有代价大图会多一次解码开销所以我在前面用maxFileSize把上限压住了。part.write()这个方法我基本不用它的行为依赖MultipartConfig的location参数不配的话写到容器临时目录很多人调完发现文件“消失”了其实是写到了 Tomcat 的work目录下。Files.copy从part.getInputStream()直接拷到目标路径行为一目了然。存储根目录一定要放在webapp之外比如/data/photo然后通过一个读取 Servlet 或者 Nginx 的alias把它映射成 URL。放在webapp里图省事等于把上传目录变成了可执行目录。4.3 返回 JSON 与列表回显上传成功后返回的 JSON 里带一个可访问的 URL前端直接塞给img.srcprivate void writeJson(HttpServletResponse resp, int code, String msg, String url) throws IOException { MapString, Object body new HashMap(); body.put(code, code); body.put(msg, msg); body.put(url, url); resp.getWriter().write(new ObjectMapper().writeValueAsString(body)); }code用 0 表示成功、非 0 表示失败是我沿用下来的一套习惯好处是前端判断简单。URL 拼成photo/view?path2025/06/12/xxxx.jpg这种形式由一个ViewServlet读文件写出好处是能在这里加权限校验不是谁拿到链接都能看。如果量比较大直接让 Nginx 走alias性能更好但就失去了鉴权能力按业务敏感度选。列表页用 JSP 直出比较省事% page contentTypetext/html;charsetUTF-8 % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % c:forEach items${photos} varp div classitem img srcphoto/view?path${p.path} width160 / span${p.uploadTime}/span /div /c:forEachphotos由PhotoListServlet扫描日期目录后放进request再forward过来。扫描目录时注意别一次列出几万个文件加个时间范围参数比如只查最近三天。5. 避坑与排查跑不起来、拍不出来、传不上去5.1 摄像头这一侧黑屏与 undefined现象一页面加载后控制台报Cannot read property getUserMedia of undefined或者navigator.mediaDevices是undefined。原因页面不在安全上下文里。用内网 IP 的http地址访问时浏览器会隐藏这个 API。解决本地调试统一用http://localhost:8080正式部署在 Nginx 上配自签证书走https或者只在内网可信环境下用localhost端口转发访问。现象二iOS 上点了按钮视频区域黑的或者只显示第一帧不动。原因video缺playsinline和muted或者openCamera不是在用户手势的调用栈里执行的比如放在setTimeout里。解决三个属性补齐并且把openCamera()直接绑在click事件上不要在await之后再调用。5.2 上传这一侧Part 为空与连接被重置现象三后端req.getPart(photo)返回null或者直接抛IllegalStateException: Request doesnt contain a multipart/form-data。原因Servlet 上漏了MultipartConfig或者在web.xml里注册 Servlet 时没配multipart-config。解决注解和 XML 二选一注解方式要注意 Servlet 必须用WebServlet注册用 XML 注册的话注解不生效。现象四前端上传大图时报net::ERR_CONNECTION_RESET后端日志里什么都看不到。原因Tomcat 的maxSwallowSize默认 2MB请求体超限后连接被直接中止客户端看到的是连接重置不是 413。解决在server.xml的Connector上把maxSwallowSize调到和maxRequestSize匹配或者在业务层捕获SizeLimitExceededException返回友好提示。Connector port8080 protocolHTTP/1.1 maxSwallowSize20971520 connectionTimeout20000 /5.3 落盘这一侧0 字节与乱码现象五文件创建出来了但大小是 0图片打不开。原因用了part.write()而location指向相对路径或者InputStream在try外被提前关闭。解决改用Files.copy(part.getInputStream(), target)并且确保流在try-with-resources里关闭。现象六中文业务字段比如访客姓名存进去变成???。原因请求体编码没设Tomcat 8 以后默认URIEncoding是 UTF-8但 POST 表单和getPart的文本字段仍取决于request.setCharacterEncoding。解决MultipartConfig那一段之外在过滤器里统一设request.setCharacterEncoding(UTF-8)并且在读取任何getParameter之前执行。6. 二次开发改造从拍照存档到微信内置浏览器里的采集闭环6.1 加字段、加压缩、加水印原始代码只存图片和上传时间实际业务一定要落库。我会在建表时把业务 ID 和文件路径分开存方便后面按业务查询CREATE TABLE photo_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_id VARCHAR(64) NOT NULL COMMENT 业务单据号, rel_path VARCHAR(255) NOT NULL COMMENT 相对存储根目录的路径, file_size INT NOT NULL, upload_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_biz (biz_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;落盘成功后插一条记录返回的url由rel_path拼出来不要在数据库里存完整 URL否则换域名时全表都要改。压缩和水印放在落盘之前用Graphics2D画一遍就行不用引第三方库BufferedImage src ImageIO.read(part.getInputStream()); int w Math.min(src.getWidth(), 1280); // 超过 1280 宽就等比缩 int h src.getHeight() * w / src.getWidth(); BufferedImage out new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); Graphics2D g out.createGraphics(); g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); g.drawImage(src, 0, 0, w, h, null); g.setColor(new Color(255, 255, 255, 120)); g.setFont(new Font(SansSerif, Font.PLAIN, Math.max(14, w / 40))); g.drawString(采集时间 new SimpleDateFormat(yyyy-MM-dd HH:mm).format(new Date()), 20, h - 20); g.dispose(); ImageIO.write(out, jpg, target.toFile());水印位置固定在左下角字号按图宽动态算w / 40保证不同分辨率下水印大小观感一致。透明度和白字是内网场景的妥协深色背景上也能看清。6.2 验证与回归两条命令跑完整链路改完之后我一般先绕开浏览器用curl直接打接口确认后端没问题再回来看前端# 1. 用一张本地图片模拟上传-F 会自动拼 multipart 边界 curl -i -X POST http://localhost:8080/photo/upload/photo \ -F photo/tmp/test.jpg;typeimage/jpeg \ -F bizIdORDER-20250612-001 # 期望返回 {code:0,url:photo/view?path2025/06/12/xxxx.jpg} # 2. 验证文件真的落到了磁盘上 ls -lh /data/photo/2025/06/12/ | tail -n 5第一条命令重点看两处HTTP 状态码是不是 200返回 JSON 里的code是不是 0。如果返回 400多半是MultipartConfig没生效或者字段名不一致如果连接被重置回 5.2 节看maxSwallowSize。第二条命令确认文件大小不是 0文件名是 UUID 而不是原始名——如果还是capture.jpg说明 4.2 节那段重命名逻辑没走到。前端回归就三步localhost下打开摄像头看预览是否流畅、拍一张看预览图是否正确前置摄像头要检查文字是否反了、刷新列表页看新图是否出现。三步都过这套链路就通了。从那以后我每次接这类采集需求都会先把curl那条命令跑一遍再动前端代码因为浏览器报的错经常是障眼法后端到底收没收到、收到的是什么只有绕过浏览器才看得清。这套 JSP 调用摄像头拍照上传的骨架不复杂但把安全上下文、MultipartConfig、路径落盘这三个点踩实了后面接人脸比对、接微信内置浏览器里的 H5 采集、接对象存储都只是换实现的事。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?