微信小程序全栈开发实战:从零构建名片管理系统 简介微信小程序开发已成为连接用户与服务的重要技术其核心在于前后端分离架构与数据通信。理解其原理需要掌握前端界面构建、后端API设计以及数据库操作等关键技术。这些技术共同支撑了现代Web应用的高效运行与数据安全。在工程实践中通过实现一个具体的业务系统如名片管理系统可以深入理解用户认证、数据CRUD、文件上传等通用模块的实现细节。本项目以Node.js Koa2 MySQL为技术栈详细拆解了从环境搭建、核心功能实现到生产部署的全过程并重点融入了JWT鉴权与OCR集成等热词技术为开发者提供了一个完整的全栈学习范例。1. 项目概述一个能跑起来的微信小程序名片管理系统最近在整理硬盘翻出来一个老项目——“微信小程序名片管理系统源码数据库.zip”。这名字听起来就很有年代感一个典型的“毕业设计”或“个人练手”项目集合。但你别小看它这种项目麻雀虽小五脏俱全非常适合想入门微信小程序全栈开发的朋友。它通常包含了前端小程序代码、后端服务可能是PHP、Java或Node.js以及一个数据库文件比如MySQL的.sql文件。今天我就以这个“压缩包”为引子带你从零开始把它变成一个真正能在你电脑上跑起来甚至能部署上线的完整系统。我们不光要让它跑起来更要拆解清楚每一个环节的设计思路、技术选型和那些新手最容易踩的坑。这个系统的核心逻辑很简单用户通过微信小程序扫描或手动添加名片信息存入数据库后续可以分类、搜索、管理。但实现起来就涉及到微信小程序前端界面与交互、后端API接口设计、数据库表结构规划以及前后端数据通信。整个过程就是一个微型的全栈开发实战。无论你是想学习小程序开发还是想了解一个完整应用的后台是如何运作的这个拆解过程都会给你带来实实在在的收获。2. 核心需求与功能模块拆解拿到一个项目源码包第一步不是急着运行而是先理解它要做什么。一个名片管理系统核心需求无非是围绕“名片”这个实体进行增删改查CRUD并附加一些提升体验的功能。2.1 核心实体与字段分析名片Business Card是系统的核心实体。一个设计良好的名片表应该包含哪些字段呢这直接决定了系统的实用性和扩展性。基础身份信息name姓名、company公司、title职位。这是名片的三要素。多维联系方式phone电话、email邮箱、wechat微信、address地址。这里要注意电话可能有多个工作电话、私人电话邮箱也可能有多个。在简单的实现中我们可以用单个字段但如果考虑扩展性更好的设计是为联系方式建立单独的表与名片主表关联。媒体与附件avatar头像。通常存储的是图片在服务器上的URL地址而不是直接存图片文件。小程序端上传图片到后端后端将文件保存到云存储或服务器目录并将可访问的URL返回存入数据库。系统元信息id主键自增、create_time创建时间、update_time更新时间、user_id所属用户ID。user_id是关键它关联到用户表实现了数据隔离确保每个用户只能看到和管理自己的名片。分类与标签category分类或通过关联表实现标签Tag功能。方便用户对名片进行分组管理例如“客户”、“供应商”、“同事”。在查看源码中的数据库SQL文件时重点就是看business_card这张表的结构。如果它缺少user_id那说明这是一个单用户系统或者权限控制存在缺陷。如果所有字段都挤在一张表里可能不利于复杂查询但作为入门项目完全可以接受。2.2 前后端功能模块划分基于核心实体我们可以将系统划分为清晰的前后端模块前端微信小程序模块用户认证模块调用wx.login获取code向后端换取自定义登录态如token。这是小程序访问后端数据的“门票”。名片列表模块以列表或卡片形式展示所有名片支持下拉刷新、上拉加载更多。通常会有搜索框和分类筛选器。名片详情模块点击某张名片后进入展示所有详细信息。提供编辑和删除按钮。新增/编辑名片模块一个表单页包含多个输入框姓名、公司、电话等和图片上传组件。提交时调用后端API。扫描名片模块集成小程序的相机API拍照后调用后端的OCR光学字符识别接口解析图片中的文字自动填充表单。这是提升体验的关键功能。我的/设置模块管理用户个人信息、分类标签等。后端服务端模块用户鉴权接口/api/auth/login处理wx.login的code验证后生成并返回token。名片CRUD接口组POST /api/card- 新增名片GET /api/card- 获取名片列表支持分页、筛选、搜索GET /api/card/:id- 获取单张名片详情PUT /api/card/:id- 更新名片DELETE /api/card/:id- 删除名片文件上传接口POST /api/upload接收小程序端上传的图片文件保存到指定位置返回文件URL。OCR识别接口POST /api/ocr接收名片图片调用第三方OCR服务如百度AI、腾讯云OCR或使用开源库解析返回结构化数据。数据库操作层封装所有对数据库如MySQL的增删改查操作。注意很多教学源码为了简化可能将后端逻辑直接写在了小程序的云函数里或者使用了小程序云开发。这确实是快速开发的方案但不利于理解传统的、分离式的Web服务架构。我们今天的讨论基于更通用的前后端分离模式。3. 技术栈选型与环境搭建实操“源码数据库.zip”里可能包含了多种技术栈的组合。我们假设一个最经典、学习资料最丰富的组合微信小程序前端 Node.js Koa2后端 MySQL数据库。3.1 前端微信小程序基础配置首先你需要安装微信开发者工具。创建一个新的小程序项目AppID可以先使用测试号。项目结构通常如下miniprogram/ ├── pages/ │ ├── index/ # 名片列表页 │ ├── detail/ # 详情页 │ ├── edit/ # 编辑/新增页 │ └── scan/ # 扫描页 ├── components/ # 自定义组件如名片卡片 ├── utils/ │ └── request.js # 封装网络请求 ├── app.js # 小程序入口 ├── app.json # 全局配置 └── app.wxss # 全局样式关键一步是封装网络请求。在utils/request.js中我们需要统一处理URL、token、错误码。// utils/request.js const BASE_URL http://localhost:3000; // 你的后端服务地址 const request (options) { // 从本地存储获取token const token wx.getStorageSync(token); return new Promise((resolve, reject) { wx.request({ url: BASE_URL options.url, method: options.method || GET, data: options.data || {}, header: { Content-Type: application/json, Authorization: token ? Bearer ${token} : // 将token放入请求头 }, success: (res) { if (res.statusCode 200) { // 假设后端统一返回 { code: 0, data: ..., msg: success } 的格式 if (res.data.code 0) { resolve(res.data.data); } else { // 业务逻辑错误如 token 过期 wx.showToast({ title: res.data.msg, icon: none }); if (res.data.code 401) { // token无效跳转到登录页或重新登录 wx.navigateTo({ url: /pages/login/login }); } reject(res.data); } } else { // HTTP状态码错误 wx.showToast({ title: 请求失败: ${res.statusCode}, icon: none }); reject(res); } }, fail: (err) { wx.showToast({ title: 网络连接失败, icon: none }); reject(err); } }); }); }; module.exports request;这个封装拦截了所有请求自动添加token并统一处理了成功和失败的逻辑让业务代码更简洁。3.2 后端Node.js Koa2 服务搭建在后端目录初始化项目并安装核心依赖。mkdir server cd server npm init -y npm install koa koa-router koa-bodyparser koa-static koa-jwt mysql2 dotenv npm install -D nodemonkoa: Web框架。koa-router: 路由中间件。koa-bodyparser: 解析POST请求体。koa-static: 托管静态文件如图片。koa-jwt: 用于生成和验证JWTJSON Web Token令牌。mysql2: 连接和操作MySQL数据库。dotenv: 从.env文件加载环境变量。nodemon: 开发热重载工具。创建入口文件app.js// server/app.js const Koa require(koa); const Router require(koa-router); const bodyParser require(koa-bodyparser); const static require(koa-static); const jwt require(koa-jwt); const path require(path); require(dotenv).config(); const app new Koa(); const router new Router(); // 中间件 app.use(bodyParser()); // 解析请求体 app.use(static(path.join(__dirname, public))); // 托管public目录下的静态资源 // 错误处理中间件 app.use(async (ctx, next) { try { await next(); } catch (err) { ctx.status err.status || 500; ctx.body { code: ctx.status, msg: err.message }; ctx.app.emit(error, err, ctx); // 触发错误事件 } }); // 公开路径无需token router.post(/api/auth/login, authController.login); router.post(/api/upload, uploadController.upload); // 上传可能也需要先登录这里仅为示例 // JWT 鉴权中间件从此以下所有路由需要token app.use(jwt({ secret: process.env.JWT_SECRET }).unless({ path: [/^\/api\/auth/] })); // 需要鉴权的路由 router.get(/api/card, cardController.list); router.post(/api/card, cardController.create); // ... 其他CRUD路由 app.use(router.routes()).use(router.allowedMethods()); const PORT process.env.PORT || 3000; app.listen(PORT, () { console.log(Server is running on http://localhost:${PORT}); });这里的关键是使用koa-jwt中间件。所有路由在默认情况下都需要有效的JWT Token才能访问除非使用unless方法排除了登录等公开接口。Token通常由前端在登录成功后保存在wx.setStorageSync(token, res.token)中并在每次请求时通过Authorization头发送。3.3 数据库MySQL初始化与连接在MySQL中创建一个数据库例如business_card_db。然后运行源码包中提供的.sql文件来创建表。如果没有你需要根据分析自行创建。一个简化的business_cards表创建语句如下CREATE TABLE business_cards ( id int(11) NOT NULL AUTO_INCREMENT, user_id varchar(100) NOT NULL COMMENT 关联用户ID通常用微信OpenID, name varchar(50) NOT NULL, company varchar(100) DEFAULT NULL, title varchar(50) DEFAULT NULL, phone varchar(20) DEFAULT NULL, email varchar(100) DEFAULT NULL, wechat varchar(50) DEFAULT NULL, address varchar(255) DEFAULT NULL, avatar varchar(500) DEFAULT NULL COMMENT 头像URL, category varchar(50) DEFAULT NULL, create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id), -- 为user_id建立索引加速查询 KEY idx_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT名片表;注意使用了utf8mb4字符集以支持存储Emoji等特殊字符并为user_id和name创建了索引以提高查询效率。在后端我们创建数据库连接工具db.js// server/utils/db.js const mysql require(mysql2/promise); // 使用Promise版本 require(dotenv).config(); const pool mysql.createPool({ host: process.env.DB_HOST || localhost, user: process.env.DB_USER || root, password: process.env.DB_PASSWORD || yourpassword, database: process.env.DB_NAME || business_card_db, waitForConnections: true, connectionLimit: 10, // 连接池大小 queueLimit: 0 }); module.exports pool;使用连接池createPool而不是单连接可以避免频繁创建和销毁连接的开销提升性能。4. 核心功能实现与代码解析环境搭好接下来就是实现核心业务逻辑。我们挑两个最典型的场景用户登录鉴权和名片增删改查。4.1 用户登录与JWT鉴权流程小程序登录流程是固定的前端调用wx.login获取临时凭证code将这个code发送到我们自己的后端服务器后端再用code、小程序的AppID和AppSecret去微信接口服务换取该用户的唯一标识openid和会话密钥session_key。后端登录控制器 (authController.js)const axios require(axios); const jwt require(jsonwebtoken); const pool require(../utils/db); exports.login async (ctx) { const { code } ctx.request.body; if (!code) { ctx.throw(400, Code is required); } // 1. 向微信服务器换取 openid 和 session_key const appid process.env.WX_APPID; const secret process.env.WX_SECRET; const url https://api.weixin.qq.com/sns/jscode2session?appid${appid}secret${secret}js_code${code}grant_typeauthorization_code; try { const response await axios.get(url); const { openid, session_key, errcode, errmsg } response.data; if (errcode) { ctx.throw(400, WeChat API error: ${errmsg}); } // 2. 业务处理根据openid查找或创建用户 const [users] await pool.execute(SELECT * FROM users WHERE openid ?, [openid]); let userId; if (users.length 0) { // 新用户插入数据库 const [result] await pool.execute( INSERT INTO users (openid, created_at) VALUES (?, NOW()), [openid] ); userId result.insertId; } else { userId users[0].id; } // 3. 生成JWT Token将用户id和openid存入token const token jwt.sign( { userId, openid }, process.env.JWT_SECRET, { expiresIn: 7d } // Token有效期7天 ); // 4. 返回token和用户基本信息给前端 ctx.body { code: 0, msg: success, data: { token, userInfo: { userId, openid } } }; } catch (error) { console.error(Login error:, error); ctx.throw(500, Internal server error during login); } };实操心得AppSecret是极其敏感的信息必须放在后端环境变量.env文件中绝对不要写死在前端代码里。前端只需要传code。另外微信返回的session_key是微信端用于解密用户敏感数据的后端通常不需要存储它除非你要做手机号解密等操作。4.2 名片CRUD与数据库交互以创建名片和获取名片列表为例展示如何安全地进行数据库操作。创建名片 (cardController.js- create)exports.create async (ctx) { // 从JWT Token中解析出的用户信息由koa-jwt中间件挂载到ctx.state const { userId } ctx.state.user; const cardData ctx.request.body; // 简单的数据验证 if (!cardData.name) { ctx.throw(400, Name is required); } // 构造SQL插入语句确保user_id来自token防止越权创建 const sql INSERT INTO business_cards (user_id, name, company, title, phone, email, wechat, address, avatar, category) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); const params [ userId, cardData.name, cardData.company || null, cardData.title || null, cardData.phone || null, cardData.email || null, cardData.wechat || null, cardData.address || null, cardData.avatar || null, cardData.category || null ]; try { const [result] await pool.execute(sql, params); ctx.body { code: 0, msg: Card created successfully, data: { id: result.insertId } }; } catch (error) { console.error(Create card error:, error); // 处理重复插入等数据库错误 if (error.code ER_DUP_ENTRY) { ctx.throw(409, Card already exists); } ctx.throw(500, Failed to create card); } };获取名片列表 (cardController.js- list)exports.list async (ctx) { const { userId } ctx.state.user; const { page 1, pageSize 10, keyword, category } ctx.query; // 从查询参数获取分页和筛选条件 // 计算偏移量 const offset (page - 1) * pageSize; // 构建查询条件和参数 let whereClause WHERE user_id ?; const queryParams [userId]; if (keyword) { whereClause AND (name LIKE ? OR company LIKE ? OR phone LIKE ?); const likeKeyword %${keyword}%; queryParams.push(likeKeyword, likeKeyword, likeKeyword); } if (category) { whereClause AND category ?; queryParams.push(category); } // 查询总条数用于前端分页器 const countSql SELECT COUNT(*) as total FROM business_cards ${whereClause}; const [countResult] await pool.execute(countSql, queryParams); const total countResult[0].total; // 查询当前页数据 const dataSql SELECT * FROM business_cards ${whereClause} ORDER BY update_time DESC LIMIT ? OFFSET ?; const dataParams [...queryParams, parseInt(pageSize), offset]; const [rows] await pool.execute(dataSql, dataParams); ctx.body { code: 0, msg: success, data: { list: rows, pagination: { page: parseInt(page), pageSize: parseInt(pageSize), total, totalPages: Math.ceil(total / pageSize) } } }; };注意事项1.SQL注入防护我们始终使用参数化查询?占位符而不是拼接字符串这是安全底线。2.权限校验每个查询都强制带上user_id ?条件这是实现数据隔离的核心确保用户只能操作自己的数据。3.分页一定要做分页尤其是数据量可能增长时。LIMIT ? OFFSET ?是标准做法同时返回总数便于前端显示总页数。5. 高级功能实现图片上传与OCR集成一个现代化的名片管理系统手动输入太麻烦。核心体验提升点在于拍照扫描和自动填充。5.1 小程序端图片上传小程序端使用wx.chooseImage选择图片然后使用wx.uploadFile上传。// pages/scan/scan.js - 选择并上传图片 Page({ data: { tempFilePath: }, chooseImage() { wx.chooseImage({ count: 1, sizeType: [compressed], // 压缩图 sourceType: [album, camera], success: (res) { const tempFilePath res.tempFilePaths[0]; this.setData({ tempFilePath }); this.uploadImage(tempFilePath); } }); }, uploadImage(filePath) { const token wx.getStorageSync(token); wx.uploadFile({ url: http://localhost:3000/api/upload, filePath: filePath, name: file, header: { Authorization: Bearer ${token} }, formData: { type: avatar }, // 可传递其他参数 success: (res) { const data JSON.parse(res.data); if (data.code 0) { const imageUrl data.data.url; // 上传成功将imageUrl用于名片表单或调用OCR接口 this.callOcrService(imageUrl); } else { wx.showToast({ title: 上传失败, icon: none }); } }, fail: (err) { console.error(err); wx.showToast({ title: 网络错误, icon: none }); } }); } });5.2 服务端文件接收与存储后端使用koa-bodyparser无法处理文件需要借助koa-body或koa/multer。这里以koa-body为例。npm install koa-body// app.js 中替换 bodyParser const koaBody require(koa-body); app.use(koaBody({ multipart: true, // 支持文件上传 formidable: { maxFileSize: 5 * 1024 * 1024, // 限制5MB uploadDir: path.join(__dirname, public/uploads), // 上传目录 keepExtensions: true, // 保留扩展名 } }));// controller/uploadController.js const path require(path); exports.upload async (ctx) { // 文件信息在 ctx.request.files.file 中 const file ctx.request.files.file; if (!file) { ctx.throw(400, No file uploaded); } // 生成一个唯一的文件名防止覆盖 const ext path.extname(file.originalFilename); const newFilename Date.now() - Math.round(Math.random() * 1E9) ext; const newPath path.join(uploads, newFilename); // 相对于静态资源目录的路径 // 移动文件koa-body已保存这里主要是重命名逻辑实际可能需fs.rename const fs require(fs).promises; const oldPath file.filepath; const absoluteNewPath path.join(__dirname, ../public, newPath); await fs.rename(oldPath, absoluteNewPath); // 返回可访问的URL const fileUrl http://localhost:3000/${newPath}; ctx.body { code: 0, msg: success, data: { url: fileUrl } }; };5.3 集成OCR服务自动填充上传图片后我们可以将图片URL发送到OCR接口。这里以调用百度AI开放平台的通用文字识别高精度版为例。// controller/ocrController.js const axios require(axios); exports.recognize async (ctx) { const { imageUrl } ctx.request.body; // 1. 从URL下载图片到Buffer或直接处理base64 const imageResponse await axios.get(imageUrl, { responseType: arraybuffer }); const imageBase64 Buffer.from(imageResponse.data, binary).toString(base64); // 2. 调用百度OCR API const ACCESS_TOKEN process.env.BAIDU_OCR_TOKEN; // 需提前获取 const ocrUrl https://aip.baidubce.com/rest/2.0/ocr/v1/accurate_basic?access_token${ACCESS_TOKEN}; const params new URLSearchParams(); params.append(image, imageBase64); params.append(detect_direction, true); // 检测朝向 const ocrResponse await axios.post(ocrUrl, params, { headers: { Content-Type: application/x-www-form-urlencoded } }); // 3. 解析OCR结果 const { words_result } ocrResponse.data; const text words_result.map(item item.words).join(\n); // 4. 进阶使用规则或NLP模型从text中提取结构化信息 // 例如通过正则表达式匹配手机号、邮箱等 const phoneMatch text.match(/(1[3-9]\d{9})/); const emailMatch text.match(/([a-zA-Z0-9._-][a-zA-Z0-9._-]\.[a-zA-Z0-9._-])/i); const structuredData { rawText: text, name: , // 需要更复杂的算法提取 phone: phoneMatch ? phoneMatch[1] : null, email: emailMatch ? emailMatch[1] : null, company: , title: }; // 5. 返回结构化的名片信息给前端 ctx.body { code: 0, msg: success, data: structuredData }; };实操心得OCR识别出的是一段文本如何从中精准提取“姓名”、“公司”、“职位”是真正的难点。简单的规则匹配正则表达式效果有限。对于生产环境可以考虑1. 使用百度、腾讯云等提供的名片识别专用API它们直接返回结构化字段。2. 训练一个简单的命名实体识别NER模型。3. 设计一个交互界面将OCR文本和高亮字段对应让用户辅助确认和修正。6. 项目部署与上线前关键配置让项目在本地运行只是第一步要真正能用还需要部署到服务器并完成一系列生产环境配置。6.1 后端服务生产环境部署不建议直接用node app.js运行。使用进程管理工具PM2可以保证服务稳定运行崩溃后自动重启。npm install -g pm2 pm2 start app.js --name business-card-api pm2 save pm2 startup # 设置开机自启关键生产配置.env.production文件NODE_ENVproduction PORT3000 JWT_SECRETyour_super_strong_jwt_secret_here # 务必使用强随机字符串 DB_HOSTyour_production_db_host DB_USERyour_production_db_user DB_PASSWORDyour_production_db_password DB_NAMEyour_production_db_name WX_APPIDyour_wechat_appid WX_SECRETyour_wechat_appsecret BAIDU_OCR_TOKENyour_baidu_ocr_tokenJWT_SECRET这是签名令牌的密钥一旦泄露攻击者可以伪造任意用户的token。必须使用强密码生成器生成并妥善保管。数据库连接生产环境数据库应与应用服务器分离使用内网地址连接并设置强密码。HTTPS小程序要求后端接口必须是HTTPS。你需要在服务器如Nginx上配置SSL证书。可以使用Let‘s Encrypt免费申请。6.2 微信小程序配置与提审服务器域名配置在小程序管理后台 - 开发 - 开发设置 - 服务器域名中配置request合法域名你的后端API地址如https://api.yourdomain.com和uploadFile合法域名文件上传地址。AppID和AppSecret使用正式AppID并在后台获取AppSecret填入后端环境变量。体验版与提审在开发者工具上传代码在管理后台设置为体验版供测试。完成测试后提交审核填写清晰的功能描述通常个人开发的名片管理工具审核通过率较高。6.3 数据库备份与安全定期备份使用mysqldump命令或面板工具定期备份数据库。mysqldump -u username -p database_name backup_$(date %Y%m%d).sql权限最小化为后端应用创建独立的数据库用户只授予其必要的INSERT,SELECT,UPDATE,DELETE权限不要用root账户。SQL注入再次强调坚持使用参数化查询这是最重要的安全防线。7. 常见问题排查与性能优化在实际开发和运行中你肯定会遇到各种问题。这里记录一些典型问题的排查思路。7.1 连接与跨域问题问题现象可能原因解决方案小程序网络请求报fail1. 后端服务未启动2. 服务器防火墙未开放端口3. 域名未配置1. 检查pm2 list或服务状态2. 开放服务器安全组/防火墙端口如30003. 在小程序后台配置合法域名请求返回403或跨域错误1. 后端未设置CORS头部2. 复杂请求如带自定义头Authorization未处理在后端Koa中增加CORS中间件npm install koa/cors并在app.js中app.use(cors())上传图片失败返回4041. 上传接口路由不存在2.koa-body配置不正确3. 静态资源目录未正确配置1. 检查路由2. 确认koa-body的multipart: true3. 确认koa-static中间件托管了上传目录7.2 数据库与性能问题问题现象可能原因解决方案数据库连接超时或报ER_CON_COUNT_ERROR1. 连接数过多未使用连接池或连接池过小2. 连接未正确释放1. 使用连接池并适当调大connectionLimit如502. 确保每次查询后连接池会自动回收连接使用mysql2/promise无需手动释放列表查询随着数据量变大越来越慢1. 未对常用查询条件如user_id,name建立索引2. 一次性查询全部数据未分页1. 为WHERE和ORDER BY子句中的字段添加索引2.强制实施分页即使前端不要求后端也要限制单次返回数量如LIMIT 1000并发上传时服务器卡死1. 文件同步处理阻塞事件循环2. 磁盘IO成为瓶颈1. 使用异步文件操作fs.promises2. 考虑使用对象存储服务如腾讯云COS、阿里云OSS将文件上传压力转移到云服务7.3 小程序端特定问题wx.login和getUserInfo新版小程序获取用户头像昵称已改为button open-typegetUserInfo和getUserProfileAPI注意区分登录和获取用户信息的流程。真机预览白屏检查app.json中页面路径是否正确检查网络请求域名是否在真机调试模式下已勾选“不校验合法域名”使用开发者工具的“真机调试”功能查看具体错误。图片显示问题小程序中的图片链接必须是HTTPS且域名已加入downloadFile合法域名列表。这个从“源码压缩包”到“可运行系统”的完整过程几乎涵盖了一个小型Web应用从开发到上线的所有核心环节。每个步骤里都有选择也有陷阱。我的建议是不要只满足于让它跑起来。多问几个为什么为什么用JWT不用Session为什么连接池大小是10为什么字段要建索引把这些问题的答案搞明白你从这个项目中获得的经验值会翻倍。最后试着给它加点新功能比如给名片加标签、按标签筛选、导出为vCard格式或者做一个简单的数据统计图表挑战一下自己这个项目就真正变成你的了。本文还有配套的精品资源点击获取