核心内容摘要
自助棋牌室微信小程序开发全流程:从注册到上线【附源码】
大家好我是小悟。
需求描述用户场景用户在PC网站端需要上传图片网站生成二维码用户使用手机APP扫码手机APP选择/拍摄图片后上传PC端实时接收并显示上传的图片技术需求双向通信PC端与手机端建立连接实时同步图片上传进度和结果实时同步安全性防止未授权的上传和恶意文件兼容性支持多种图片格式
详细实现步骤整体架构用户操作流程
PC网站生成二维码包含会话ID和WebSocket连接信息
手机APP扫码建立WebSocket连接
APP选择/拍摄图片分块上传
PC端接收并重组图片显示上传结果步骤1PC网站后端实现
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(
.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, base
; 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 *
; res.json({ sessionId: sessionId, qrData: qrData, qrUrl: https://api.qrserver.com/v1/create-qr-code/?size200x200data${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
; }); // 辅助函数 function getFileExtension(filename) { return filename.split(.).pop().toLowerCase(); }步骤2PC网站前端实现!-- public/index.html -- !DOCTYPE html html head title扫码上传图片/title script srchttps://cdn.jsdelivr.net/npm/qrcode
1.
3/build/qrcode.min.js/script /head body div idapp h1手机扫码上传图片/h1 !-- 二维码显示区域 -- div idqrcode-container div idqrcode/div p idstatus正在生成二维码.../p /div !-- 上传结果显示区域 -- div idupload-result styledisplay:none; h2上传完成/h2 img idpreview-image stylemax-width: 500px; p idfile-name/p button onclicklocation.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}; } },
; } // 页面加载时生成二维码 window.onload generateQRCode; /script /body /html步骤3Android 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 okhttp
* 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(),
} } 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) { thisMainActivity.webSocket webSocket runOnUiThread { Toast.makeText(thisMainActivity, 已连接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(thisMainActivity, 连接失败, 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 } ! -
{ val chunkData if (bytesRead buffer.size) { buffer.copyOf(bytesRead) } else { buffer } val chunkMessage JSONObject().apply { put(type, chunk) put(data, android.util.Base
encodeToString(chunkData, android.util.Base
DEFAULT)) } webSocket.send(chunkMessage.toString()) totalRead bytesRead // 更新进度可选 val progress ((totalRead.toDouble() / fileSize) *
.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 } }步骤4iOS 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: .utf
, 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.
else { return } let tempURL FileManager.default.temporaryDirectory .appendingPathComponent(upload_\(Date().timeIntervalSince
.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: .utf
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: .utf
, 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:
8 services: web: build: . ports: - 8080:8080 volumes: - ./uploads:/app/uploads environment: - NODE_ENVproduction - MAX_FILE_SIZE10485760# nginx配置示例 server { listen 80; server_name upload.example.com; location / { proxy_pass http://localhost:8080; proxy_http_version
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; } }
详细
总结
技术架构亮点双向通信机制使用WebSocket实现实时双向通信相比HTTP轮询更高效支持实时进度反馈和即时状态同步分块上传策略大文件分块传输避免内存溢出支持断点续传可扩展降低网络错误导致的失败率会话管理唯一会话ID确保连接对应关系会话过期机制防止资源泄露状态跟踪便于调试和监控
安全性考虑已实现的安全措施会话验证只有合法sessionId才能建立连接文件类型验证通过扩展名限制文件类型大小限制防止超大文件上传可增强的安全措施JWT令牌验证文件内容类型检测Magic Number上传频率限制恶意文件扫描
性能优化前端优化二维码生成使用客户端库减轻服务器压力WebSocket连接复用避免重复连接传输优化Base64编码虽然增加33%体积但简化了JSON传输分块传输支持并行上传可扩展服务器优化流式处理避免大文件内存占用异步非阻塞I/O操作
扩展性设计功能扩展方向多文件批量上传图片压缩和预处理云端存储集成AWS S
阿里云OSS等视频和文档支持架构扩展方向支持WebRTC直连传输分布式会话管理CDN集成加速
实际应用开发建议添加详细的错误处理和用户反馈实现重试机制和断点续传添加操作日志和监控考虑离线使用场景部署建议使用HTTPS/WSS确保传输安全配置合适的WebSocket连接限制设置文件存储配额和清理策略实施负载均衡和高可用方案
优缺点优点用户体验好手机操作更便捷适合图片上传跨平台一套方案支持多平台实时性强进度反馈即时资源友好PC端无需处理复杂的图片选择逻辑缺点实现复杂度高需要两端开发网络依赖需要稳定的网络连接扫码步骤增加操作步骤
替代方案对比方案优点缺点扫码上传本文手机操作便利适合移动场景实现复杂需要两端开发网页直接上传简单直接无需额外设备移动端体验差文件大小受限邮箱上传无实时性要求异步处理延迟高操作繁琐局域网共享速度快无流量消耗需要同一网络配置复杂
实际应用场景电商平台商家用手机上传商品图片到PC后台办公系统手机拍摄文档上传到PC进行编辑社交应用手机相册图片分享到PC端教育平台学生作业拍照上传到PC端批改系统这种扫码上传方案特别适合需要频繁在手机和PC间传输图片的场景结合了手机的便捷性和PC的处理能力提供了良好的用户体验。
谢谢你看我的文章既然看到这里了如果觉得不错随手点个赞、转发、在看三连吧感谢感谢。
那我们下次再见。
您的一键三连是我更新的最大动力谢谢山水有相逢来日皆可期谢谢阅读我们再会我手中的金箍棒上能通天下能探海