手机APP扫码上传图片到PC网站端实现方案

32 阅读5分钟

手机APP扫码上传图片到PC网站端实现方案

大家好,我是小悟。

一、需求描述

  1. 用户场景
    • 用户在PC网站端需要上传图片
    • 网站生成二维码,用户使用手机APP扫码
    • 手机APP选择/拍摄图片后上传
    • PC端实时接收并显示上传的图片
  2. 技术需求
    • 双向通信: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. 实际应用

开发建议

  1. 添加详细的错误处理和用户反馈
  2. 实现重试机制和断点续传
  3. 添加操作日志和监控
  4. 考虑离线使用场景

部署建议

  1. 使用HTTPS/WSS确保传输安全
  2. 配置合适的WebSocket连接限制
  3. 设置文件存储配额和清理策略
  4. 实施负载均衡和高可用方案

6. 优缺点

优点

  • 用户体验好:手机操作更便捷,适合图片上传
  • 跨平台:一套方案支持多平台
  • 实时性强:进度反馈即时
  • 资源友好:PC端无需处理复杂的图片选择逻辑

缺点

  • 实现复杂度高:需要两端开发
  • 网络依赖:需要稳定的网络连接
  • 扫码步骤:增加操作步骤

7. 替代方案对比

方案优点缺点
扫码上传(本文)手机操作便利,适合移动场景实现复杂,需要两端开发
网页直接上传简单直接,无需额外设备移动端体验差,文件大小受限
邮箱上传无实时性要求,异步处理延迟高,操作繁琐
局域网共享速度快,无流量消耗需要同一网络,配置复杂

8. 实际应用场景

  1. 电商平台:商家用手机上传商品图片到PC后台
  2. 办公系统:手机拍摄文档上传到PC进行编辑
  3. 社交应用:手机相册图片分享到PC端
  4. 教育平台:学生作业拍照上传到PC端批改系统

这种扫码上传方案特别适合需要频繁在手机和PC间传输图片的场景,结合了手机的便捷性和PC的处理能力,提供了良好的用户体验。

手机APP扫码上传图片到PC网站端实现方案.png

谢谢你看我的文章,既然看到这里了,如果觉得不错,随手点个赞、转发、在看三连吧,感谢感谢。那我们,下次再见。

您的一键三连,是我更新的最大动力,谢谢

山水有相逢,来日皆可期,谢谢阅读,我们再会

我手中的金箍棒,上能通天,下能探海