手机APP扫码上传图片到PC网站端实现方案
大家好,我是小悟。
一、需求描述
- 用户场景:
- 用户在PC网站端需要上传图片
- 网站生成二维码,用户使用手机APP扫码
- 手机APP选择/拍摄图片后上传
- PC端实时接收并显示上传的图片
- 技术需求:
- 双向通信:PC端与手机端建立连接
- 实时同步:图片上传进度和结果实时同步
- 安全性:防止未授权的上传和恶意文件
- 兼容性:支持多种图片格式
二、详细实现步骤
整体架构
用户操作流程:
1. PC网站生成二维码(包含会话ID和WebSocket连接信息)
2. 手机APP扫码,建立WebSocket连接
3. APP选择/拍摄图片,分块上传
4. PC端接收并重组图片,显示上传结果
步骤1:PC网站后端实现
1.1 创建Express服务器(Node.js)
// server.js
const express = require('express');
const http = require('http');
const WebSocket = require('ws');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs');
const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
// 存储会话信息
const sessions = new Map();
// 生成唯一会话ID
function generateSessionId() {
return crypto.randomBytes(16).toString('hex');
}
// WebSocket连接处理
wss.on('connection', (ws, req) => {
const urlParams = new URLSearchParams(req.url.split('?')[1]);
const sessionId = urlParams.get('sessionId');
if (!sessionId || !sessions.has(sessionId)) {
ws.close();
return;
}
const session = sessions.get(sessionId);
session.ws = ws;
session.connected = true;
ws.on('message', (data) => {
handleMessage(sessionId, data.toString());
});
ws.on('close', () => {
if (sessions.has(sessionId)) {
sessions.get(sessionId).connected = false;
}
});
});
// 处理上传的图片数据
async function handleMessage(sessionId, data) {
try {
const message = JSON.parse(data);
const session = sessions.get(sessionId);
switch (message.type) {
case 'upload_start':
session.fileInfo = {
fileName: message.fileName,
fileSize: message.fileSize,
chunks: [],
receivedSize: 0
};
break;
case 'chunk':
if (session.fileInfo) {
const chunkData = Buffer.from(message.data, 'base64');
session.fileInfo.chunks.push(chunkData);
session.fileInfo.receivedSize += chunkData.length;
// 发送进度更新
if (session.ws) {
const progress = Math.round(
(session.fileInfo.receivedSize / session.fileInfo.fileSize) * 100
);
session.ws.send(JSON.stringify({
type: 'progress',
progress: progress
}));
}
}
break;
case 'upload_complete':
if (session.fileInfo) {
// 合并所有分块
const fullBuffer = Buffer.concat(session.fileInfo.chunks);
// 保存文件
const fileName = `upload_${sessionId}_${Date.now()}.${getFileExtension(session.fileInfo.fileName)}`;
const filePath = path.join(__dirname, 'uploads', fileName);
fs.writeFileSync(filePath, fullBuffer);
// 发送完成消息
if (session.ws) {
session.ws.send(JSON.stringify({
type: 'complete',
fileUrl: `/uploads/${fileName}`,
fileName: session.fileInfo.fileName
}));
}
// 清理会话文件信息
delete session.fileInfo;
}
break;
}
} catch (error) {
console.error('Error handling message:', error);
}
}
// 生成二维码信息接口
app.get('/api/generate-qr', (req, res) => {
const sessionId = generateSessionId();
const qrData = JSON.stringify({
sessionId: sessionId,
wsUrl: 'ws://your-server-domain:8080'
});
// 创建会话
sessions.set(sessionId, {
id: sessionId,
createdAt: Date.now(),
connected: false,
ws: null
});
// 设置会话过期(10分钟)
setTimeout(() => {
if (sessions.has(sessionId)) {
sessions.delete(sessionId);
}
}, 10 * 60 * 1000);
res.json({
sessionId: sessionId,
qrData: qrData,
qrUrl: `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrData)}`
});
});
// 检查上传状态接口
app.get('/api/upload-status/:sessionId', (req, res) => {
const sessionId = req.params.sessionId;
const session = sessions.get(sessionId);
if (!session) {
return res.json({ status: 'expired' });
}
res.json({
status: session.connected ? 'connected' : 'waiting',
fileUrl: session.fileUrl || null
});
});
// 静态文件服务
app.use('/uploads', express.static('uploads'));
app.use(express.static('public'));
server.listen(8080, () => {
console.log('Server running on port 8080');
});
// 辅助函数
function getFileExtension(filename) {
return filename.split('.').pop().toLowerCase();
}
步骤2:PC网站前端实现
<!-- public/index.html -->
<!DOCTYPE html>
<html>
<head>
<title>扫码上传图片</title>
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
</head>
<body>
<div id="app">
<h1>手机扫码上传图片</h1>
<!-- 二维码显示区域 -->
<div id="qrcode-container">
<div id="qrcode"></div>
<p id="status">正在生成二维码...</p>
</div>
<!-- 上传结果显示区域 -->
<div id="upload-result" style="display:none;">
<h2>上传完成!</h2>
<img id="preview-image" style="max-width: 500px;">
<p id="file-name"></p>
<button onclick="location.reload()">上传新图片</button>
</div>
</div>
<script>
let sessionId = null;
let checkInterval = null;
// 生成二维码
async function generateQRCode() {
try {
const response = await fetch('/api/generate-qr');
const data = await response.json();
sessionId = data.sessionId;
// 显示二维码
QRCode.toCanvas(document.getElementById('qrcode'), data.qrData, {
width: 200,
height: 200
});
document.getElementById('status').textContent = '请使用手机APP扫码';
// 开始检查上传状态
startCheckingStatus();
} catch (error) {
console.error('生成二维码失败:', error);
document.getElementById('status').textContent = '生成二维码失败,请刷新页面重试';
}
}
// 开始检查上传状态
function startCheckingStatus() {
checkInterval = setInterval(async () => {
if (!sessionId) return;
const response = await fetch(`/api/upload-status/${sessionId}`);
const status = await response.json();
if (status.status === 'connected') {
document.getElementById('status').textContent = '手机已连接,请选择图片上传';
} else if (status.status === 'expired') {
document.getElementById('status').textContent = '二维码已过期,正在重新生成...';
clearInterval(checkInterval);
generateQRCode();
}
// 如果上传完成,显示结果
if (status.fileUrl) {
clearInterval(checkInterval);
document.getElementById('qrcode-container').style.display = 'none';
document.getElementById('upload-result').style.display = 'block';
document.getElementById('preview-image').src = status.fileUrl;
document.getElementById('file-name').textContent = `文件名: ${status.fileName}`;
}
}, 2000);
}
// 页面加载时生成二维码
window.onload = generateQRCode;
</script>
</body>
</html>
步骤3:Android APP实现(Kotlin简化版)
// MainActivity.kt
package com.example.qrupload
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.provider.MediaStore
import android.util.Log
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.google.zxing.integration.android.IntentIntegrator
import okhttp3.*
import okio.ByteString
import org.json.JSONObject
import java.io.File
import java.io.FileInputStream
import java.util.concurrent.TimeUnit
class MainActivity : AppCompatActivity() {
private lateinit var webSocket: WebSocket
private var sessionId: String? = null
private val CHUNK_SIZE = 1024 * 64 // 64KB分块
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
checkPermissions()
// 启动二维码扫描
startQRScanner()
}
private fun checkPermissions() {
val permissions = arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.READ_EXTERNAL_STORAGE
)
val missingPermissions = permissions.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (missingPermissions.isNotEmpty()) {
ActivityCompat.requestPermissions(this, missingPermissions.toTypedArray(), 1)
}
}
private fun startQRScanner() {
IntentIntegrator(this)
.setPrompt("扫描PC端的二维码")
.setOrientationLocked(false)
.initiateScan()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == Activity.RESULT_OK) {
val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data)
if (result != null) {
handleQRResult(result.contents)
} else if (requestCode == PICK_IMAGE_REQUEST) {
handleImageSelection(data?.data)
}
}
}
private fun handleQRResult(qrData: String) {
try {
val json = JSONObject(qrData)
sessionId = json.getString("sessionId")
val wsUrl = json.getString("wsUrl")
// 连接WebSocket
connectWebSocket("$wsUrl?sessionId=$sessionId")
// 请求选择图片
selectImage()
} catch (e: Exception) {
Toast.makeText(this, "二维码解析失败", Toast.LENGTH_SHORT).show()
startQRScanner()
}
}
private fun connectWebSocket(url: String) {
val client = OkHttpClient.Builder()
.readTimeout(3, TimeUnit.SECONDS)
.build()
val request = Request.Builder().url(url).build()
client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
this@MainActivity.webSocket = webSocket
runOnUiThread {
Toast.makeText(this@MainActivity, "已连接PC端", Toast.LENGTH_SHORT).show()
}
}
override fun onMessage(webSocket: WebSocket, text: String) {
// 处理服务器消息(进度更新、完成通知等)
handleServerMessage(text)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
runOnUiThread {
Toast.makeText(this@MainActivity, "连接失败", Toast.LENGTH_SHORT).show()
}
}
})
}
private fun selectImage() {
val intent = Intent(Intent.ACTION_PICK).apply {
type = "image/*"
}
startActivityForResult(intent, PICK_IMAGE_REQUEST)
}
private fun handleImageSelection(uri: Uri?) {
uri ?: return
val filePath = getRealPathFromURI(uri) ?: return
val file = File(filePath)
uploadImage(file)
}
private fun uploadImage(file: File) {
try {
val fileSize = file.length()
// 发送开始上传消息
val startMessage = JSONObject().apply {
put("type", "upload_start")
put("fileName", file.name)
put("fileSize", fileSize)
}
webSocket.send(startMessage.toString())
// 分块上传文件
FileInputStream(file).use { fis ->
val buffer = ByteArray(CHUNK_SIZE)
var bytesRead: Int
var totalRead = 0L
while (fis.read(buffer).also { bytesRead = it } != -1) {
val chunkData = if (bytesRead < buffer.size) {
buffer.copyOf(bytesRead)
} else {
buffer
}
val chunkMessage = JSONObject().apply {
put("type", "chunk")
put("data", android.util.Base64.encodeToString(chunkData, android.util.Base64.DEFAULT))
}
webSocket.send(chunkMessage.toString())
totalRead += bytesRead
// 更新进度(可选)
val progress = ((totalRead.toDouble() / fileSize) * 100).toInt()
Log.d("Upload", "Progress: $progress%")
}
// 发送完成消息
val completeMessage = JSONObject().apply {
put("type", "upload_complete")
}
webSocket.send(completeMessage.toString())
runOnUiThread {
Toast.makeText(this, "上传完成", Toast.LENGTH_SHORT).show()
}
}
} catch (e: Exception) {
e.printStackTrace()
runOnUiThread {
Toast.makeText(this, "上传失败", Toast.LENGTH_SHORT).show()
}
}
}
private fun handleServerMessage(message: String) {
try {
val json = JSONObject(message)
when (json.getString("type")) {
"progress" -> {
val progress = json.getInt("progress")
Log.d("ServerMessage", "上传进度: $progress%")
}
"complete" -> {
Log.d("ServerMessage", "上传完成")
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun getRealPathFromURI(uri: Uri): String? {
val projection = arrayOf(MediaStore.Images.Media.DATA)
val cursor = contentResolver.query(uri, projection, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val columnIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
return it.getString(columnIndex)
}
}
return null
}
companion object {
private const val PICK_IMAGE_REQUEST = 1001
}
}
步骤4:iOS APP实现(Swift简化版)
// QRUploadViewController.swift
import UIKit
import MobileCoreServices
import AVFoundation
import WebKit
class QRUploadViewController: UIViewController {
private var webSocketTask: URLSessionWebSocketTask?
private var sessionId: String?
private let chunkSize = 64 * 1024 // 64KB
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
requestPermissions()
}
private func setupUI() {
view.backgroundColor = .white
let scanButton = UIButton(type: .system)
scanButton.setTitle("扫描二维码", for: .normal)
scanButton.addTarget(self, action: #selector(startQRScanner), for: .touchUpInside)
// ... 布局代码
}
private func requestPermissions() {
AVCaptureDevice.requestAccess(for: .video) { _ in }
// 请求相册权限
}
@objc private func startQRScanner() {
let scannerVC = QRScannerViewController()
scannerVC.delegate = self
present(scannerVC, animated: true)
}
private func connectWebSocket(url: URL) {
let session = URLSession(configuration: .default)
webSocketTask = session.webSocketTask(with: url)
webSocketTask?.resume()
receiveMessage()
}
private func receiveMessage() {
webSocketTask?.receive { [weak self] result in
switch result {
case .success(let message):
switch message {
case .string(let text):
self?.handleServerMessage(text)
default:
break
}
self?.receiveMessage()
case .failure(let error):
print("WebSocket接收错误: \(error)")
}
}
}
private func handleQRResult(_ qrData: String) {
guard let data = qrData.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let sessionId = json["sessionId"] as? String,
let wsUrl = json["wsUrl"] as? String else {
return
}
self.sessionId = sessionId
if let url = URL(string: "\(wsUrl)?sessionId=\(sessionId)") {
connectWebSocket(url: url)
selectImage()
}
}
private func selectImage() {
let imagePicker = UIImagePickerController()
imagePicker.delegate = self
imagePicker.sourceType = .photoLibrary
present(imagePicker, animated: true)
}
private func uploadImage(_ image: UIImage) {
guard let imageData = image.jpegData(compressionQuality: 0.8) else { return }
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent("upload_\(Date().timeIntervalSince1970).jpg")
do {
try imageData.write(to: tempURL)
uploadFile(at: tempURL)
} catch {
print("保存临时文件失败: \(error)")
}
}
private func uploadFile(at fileURL: URL) {
do {
let fileSize = try FileManager.default.attributesOfItem(atPath: fileURL.path)[.size] as? Int64 ?? 0
// 发送开始消息
let startMessage: [String: Any] = [
"type": "upload_start",
"fileName": fileURL.lastPathComponent,
"fileSize": fileSize
]
sendMessage(startMessage)
// 分块上传
let fileHandle = try FileHandle(forReadingFrom: fileURL)
var offset: UInt64 = 0
while offset < fileSize {
let remaining = fileSize - Int64(offset)
let chunkSize = min(self.chunkSize, Int(remaining))
fileHandle.seek(toFileOffset: offset)
let chunkData = fileHandle.readData(ofLength: chunkSize)
let chunkMessage: [String: Any] = [
"type": "chunk",
"data": chunkData.base64EncodedString()
]
sendMessage(chunkMessage)
offset += UInt64(chunkSize)
}
fileHandle.closeFile()
// 发送完成消息
let completeMessage: [String: Any] = [
"type": "upload_complete"
]
sendMessage(completeMessage)
} catch {
print("上传文件失败: \(error)")
}
}
private func sendMessage(_ message: [String: Any]) {
guard let data = try? JSONSerialization.data(withJSONObject: message),
let jsonString = String(data: data, encoding: .utf8) else { return }
webSocketTask?.send(.string(jsonString)) { error in
if let error = error {
print("发送消息失败: \(error)")
}
}
}
private func handleServerMessage(_ message: String) {
guard let data = message.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = json["type"] as? String else { return }
switch type {
case "progress":
if let progress = json["progress"] as? Int {
print("上传进度: \(progress)%")
}
case "complete":
DispatchQueue.main.async {
let alert = UIAlertController(title: "上传完成", message: nil, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "确定", style: .default))
self.present(alert, animated: true)
}
default:
break
}
}
}
extension QRUploadViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
picker.dismiss(animated: true)
if let image = info[.originalImage] as? UIImage {
uploadImage(image)
}
}
}
extension QRUploadViewController: QRScannerDelegate {
func didScanQRCode(_ code: String) {
dismiss(animated: true) {
self.handleQRResult(code)
}
}
}
步骤5:配置和部署
# docker-compose.yml 示例
version: '3.8'
services:
web:
build: .
ports:
- "8080:8080"
volumes:
- ./uploads:/app/uploads
environment:
- NODE_ENV=production
- MAX_FILE_SIZE=10485760
# nginx配置示例
server {
listen 80;
server_name upload.example.com;
location / {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
location /uploads {
alias /path/to/uploads;
expires 1h;
add_header Cache-Control "public";
}
}
三、详细总结
1. 技术架构亮点
双向通信机制:
- 使用WebSocket实现实时双向通信,相比HTTP轮询更高效
- 支持实时进度反馈和即时状态同步
分块上传策略:
- 大文件分块传输,避免内存溢出
- 支持断点续传(可扩展)
- 降低网络错误导致的失败率
会话管理:
- 唯一会话ID确保连接对应关系
- 会话过期机制防止资源泄露
- 状态跟踪便于调试和监控
2. 安全性考虑
已实现的安全措施:
- 会话验证:只有合法sessionId才能建立连接
- 文件类型验证:通过扩展名限制文件类型
- 大小限制:防止超大文件上传
可增强的安全措施:
- JWT令牌验证
- 文件内容类型检测(Magic Number)
- 上传频率限制
- 恶意文件扫描
3. 性能优化
前端优化:
- 二维码生成使用客户端库,减轻服务器压力
- WebSocket连接复用,避免重复连接
传输优化:
- Base64编码虽然增加33%体积,但简化了JSON传输
- 分块传输支持并行上传(可扩展)
服务器优化:
- 流式处理,避免大文件内存占用
- 异步非阻塞I/O操作
4. 扩展性设计
功能扩展方向:
- 多文件批量上传
- 图片压缩和预处理
- 云端存储集成(AWS S3、阿里云OSS等)
- 视频和文档支持
架构扩展方向:
- 支持WebRTC直连传输
- 分布式会话管理
- CDN集成加速
5. 实际应用
开发建议:
- 添加详细的错误处理和用户反馈
- 实现重试机制和断点续传
- 添加操作日志和监控
- 考虑离线使用场景
部署建议:
- 使用HTTPS/WSS确保传输安全
- 配置合适的WebSocket连接限制
- 设置文件存储配额和清理策略
- 实施负载均衡和高可用方案
6. 优缺点
优点:
- 用户体验好:手机操作更便捷,适合图片上传
- 跨平台:一套方案支持多平台
- 实时性强:进度反馈即时
- 资源友好:PC端无需处理复杂的图片选择逻辑
缺点:
- 实现复杂度高:需要两端开发
- 网络依赖:需要稳定的网络连接
- 扫码步骤:增加操作步骤
7. 替代方案对比
| 方案 | 优点 | 缺点 |
|---|---|---|
| 扫码上传(本文) | 手机操作便利,适合移动场景 | 实现复杂,需要两端开发 |
| 网页直接上传 | 简单直接,无需额外设备 | 移动端体验差,文件大小受限 |
| 邮箱上传 | 无实时性要求,异步处理 | 延迟高,操作繁琐 |
| 局域网共享 | 速度快,无流量消耗 | 需要同一网络,配置复杂 |
8. 实际应用场景
- 电商平台:商家用手机上传商品图片到PC后台
- 办公系统:手机拍摄文档上传到PC进行编辑
- 社交应用:手机相册图片分享到PC端
- 教育平台:学生作业拍照上传到PC端批改系统
这种扫码上传方案特别适合需要频繁在手机和PC间传输图片的场景,结合了手机的便捷性和PC的处理能力,提供了良好的用户体验。
谢谢你看我的文章,既然看到这里了,如果觉得不错,随手点个赞、转发、在看三连吧,感谢感谢。那我们,下次再见。
您的一键三连,是我更新的最大动力,谢谢
山水有相逢,来日皆可期,谢谢阅读,我们再会
我手中的金箍棒,上能通天,下能探海