1. 校园二手场景的真实痛点:为什么群聊和闲鱼都顶不上
大学里的二手交易,其实每天都在发生,但大多数还停留在"毕业群一声吼、楼道海报贴一周、宿舍门口蹲一个学弟"的状态。我做这个基于微信小程序的校园二手交易平台时,最先想明白的一件事就是:二手交易不缺商品,缺的是把"发布—浏览—联系—成交—确认"这条链路结构化下来的工具。项目采用 Java Spring Boot 作为后端,小程序作为前端,最终目标是让一个普通学生从点开小程序到卖掉闲置,全程不超过几分钟。
先说痛点。微信群交易看起来方便,实际上有三个硬伤:信息流太乱,几十条消息里夹着三五个在售商品,翻记录翻到怀疑人生;图片和视频会过期,过了两天想回看某个物品,打不开;最致命的是没有交易凭证,双方在群里聊好、私下见面,之后出现纠纷几乎没法复盘。闲鱼这类大平台则走了另一个极端:物流、验货、售后服务一整套流程对"宿舍楼对面见面"的校园场景来说太重了,用户挂一个小台灯还得写清楚成色、包不包邮,买家还要担心卖家靠不靠谱,成交周期被拉得很长。
校园场景本身有很强的一致性:信任半径小,同校学生见面成本极低;交易频次低但会集中在开学季和毕业季形成波峰;单笔金额小,商品以教材、小电器、生活用品为主。这些特点决定了业务上不需要复杂的电商体系,反而需要一种"轻撮合"模式——平台只负责把卖家的商品信息清晰地展示给买家,帮双方建立联系,成交之后做状态确认。我把产品边界划成四条:不接支付通道,不引入物流跟踪,不做积分和评价系统,不做店铺装修。交易双方线下见面、当面验货、自己扫码转账,平台只记录订单状态,这个边界在合规性和开发成本上都安全很多。
项目最终以源码、文档、运行视频和讲解视频的方式整体交付,这种东西最怕"能跑但看不懂"。所以我在代码里刻意做了三件事:接口命名和前端请求一一对应,数据库注释写清每个字段的业务含义,README 里直接给出一分钟跑通步骤。对想拿来学习或者二次开发的同学来说,完整的纵向链路比堆一堆高深技术更能说明问题——从数据库建表到前端渲染,每一环都能对上。
2. 技术选型逻辑:Spring Boot + 微信小程序这套组合的价值在哪
2.1 微信小程序为什么是最贴场景的前端形态
学生群体对微信的依赖程度不用多说,小程序天然寄生在微信生态里,最重要的一点是不需要用户额外下载 App。校园二手这种低频工具属性,让用户为了卖一个路由器专门装一个 App,转化率会非常低,而小程序点开即用、用完即走,分享到班级群不用跳转外部链接,这一步就省掉了大量获客阻力。
另外,微信小程序的开放能力对二手场景足够用:wx.login能拿到临时凭证换取 openid,直接作为用户的唯一身份标识;wx.chooseMedia可以拍照或从相册选图,发布商品的图片上传没有障碍;转发到聊天和朋友圈更是标准能力。我在文档里也对比过原生小程序和跨端框架的取舍,如果团队里已有 Vue 经验,用跨端框架可以一次编码多端复用,但就"单人维护一个校园系统"的实际情况来看,原生小程序的结构更直观,调试时微信开发者工具的错误定位也更准,所以我最终选择原生。
2.2 后端为什么用 Spring Boot 单体而不是微服务
校园二手平台的并发量天花板并不高,高峰期也就是毕业季那几周,单体服务完全扛得住。选择 Spring Boot 不是因为它最先进,而是因为它在 Java 生态里是最"稳"的起步方案:内嵌 Tomcat 免去独立部署容器的麻烦,Maven 管理依赖足够干净,社区案例多到任何报错都能搜到解法。微服务在这个场景里属于过度设计——分布式事务、服务注册发现、链路追踪一套下来,系统复杂度翻倍,对学习和交付都没好处。
持久层我用 MyBatis-Plus 而不是纯 MyBatis,看中的是单表 CRUD 不需要手写 SQL,配合 LambdaQueryWrapper 写查询条件非常直观。但复杂的多表关联和条件更新,我仍然会手写 SQL 放进 XML 里,避免框架生成的语句有性能隐患。认证鉴权这块没有引入 Spring Security,而是自己用 JWT 实现了轻量登录态——校园项目就三个角色(学生、管理员、游客),Security 那一套过滤器链配置成本反而更高,手写一个拦截器加 ThreadLocal 存取用户信息,几百行能搞定还完全可控。
2.3 依赖清单与整体分层
| 组件 | 选型 | 理由 |
|---|---|---|
| 后端框架 | Spring Boot 2.7.x | 成熟稳定,内嵌容器,资料多 |
| 持久层 | MyBatis-Plus | 单表 CRUD 快,条件构造器好用 |
| 数据库 | MySQL 8.x | 成本低、通用性强,开发环境与部署环境一致 |
| 认证 | JWT | 无状态,小程序端方便携带 |
| 前端 | 微信小程序原生 | 调试直观,平台能力够用 |
| 文件存储 | 本地磁盘 + 对象存储 | 开发期本地,上线切云存储 |
代码分层按 controller / service / mapper / entity / dto 划分,包名按业务模块组织,比如user、goods、order、category、upload。这样做的原因是让阅读者顺着包名就能找到功能入口,讲解视频里也可以按模块拆成小节来讲。
3. 数据库与状态机设计:把交易闭环拆成可追踪的记录
3.1 六张核心表的结构与字段设计
| 表名 | 用途 | 核心字段 |
|---|---|---|
| user | 用户信息 | id, openid(唯一), nickname, avatar, phone, student_no, school, create_time |
| goods_category | 商品分类 | id, name, sort |
| goods | 商品信息 | id, user_id, category_id, title, description, price, cover, pics, status, view_count, create_time, update_time, deleted |
| order | 交易订单 | id, order_no(唯一), goods_id, seller_id, buyer_id, status, transaction_code, create_time, finish_time |
| favorite | 收藏记录 | id, user_id, goods_id, create_time |
| message | 留言咨询 | id, goods_id, from_user_id, content, create_time |
user 表里重点强调openid的唯一性,这是微信登录体系与业务用户关联的钥匙,数据库层面要建唯一索引,防止同一微信号重复注册出多个账号。goodsid 表的status是业务核心字段,下面是详细状态机设计。favorite表对 user_id 和 goods_id 建立联合唯一索引,保证同一个用户对同一商品只能收藏一次。message表在早期版本里可以精简成商品详情页的直接展示留言板,双方联系方式通过订单流程解锁,不需要做即时聊天。
订单表单独拆出来的原因是"交易"是一个过程,商品表记录的是物,订单表记录的是"谁在什么时间用多少钱买了什么东西"。我先给每个订单生成唯一的order_no,再生成6位数字的transaction_code作为线下核销码,约定只有卖家主动确认核销,订单才进入已完成状态。这样业务的每一步都有据可查。
3.2 商品和订单的状态流转设计
商品状态我设了5个值:0代表上架、1代表已下架、2代表交易中、3代表已完成、99代表逻辑删除。订单状态设了3个值:0待交易、1已完成、2已取消。
| 当前状态 | 触发动作 | 目标状态 | 说明 |
|---|---|---|---|
| 上架(0) | 用户点击"我想买" | 交易中(2) | 商品被锁定,防止并发再买 |
| 上架(0) | 卖家主动下架 | 已下架(1) | 前台不再展示 |
| 已下架(1) | 卖家重新上架 | 上架(0) | 商品状态恢复可交易 |
| 交易中(2) | 卖家确认核销码 | 已完成(3) | 订单同步变为已完成 |
| 交易中(2) | 买家或卖家取消 | 上架(0) | 商品释放回可售池 |
| 任意业务状态 | 删除 | 99 | 逻辑删除,不走物理删除 |
这套状态机的收益非常明显:从"商品上架"到"订单完成"的每一步都可通过字段追溯,避免超卖、重复下单、订单悬挂等问题。我写文档时会用一张类似上面的表格把状态流转画清楚,代码实现里再配条件更新 SQL,业务逻辑就不容易乱。
3.3 冗余字段、逻辑删除与查询性能的取舍
列表页如果每次都要 JOIN 用户表拿卖家昵称和头像,数据量大一点就会明显变慢。所以在商品表里冗余保存了user_name和user_avatar字段,发布时从用户表快照填充。用户修改昵称后商品列表不会立刻变,但相比于联表查询带来的性能损失,这个副作用完全可接受。同样,商品多图用逗号分隔存在pics字段里,另外单独存一个cover字段作为列表页封面,详情页再切完整图片列表,列表接口可以少解析很多无用数据。
逻辑删除是另一个重要选择。交易记录和商品记录需要保留历史,不能删库跑路。MyBatis-Plus 的@TableLogic注解可以直接做到自动拼接deleted = 0条件,省了手写覆盖查询的麻烦。需要提醒的是:如果业务里用了唯一索引,逻辑删除仍然可能触发冲突——比如用户删除再发布同标题商品标题还是原值,这类场景真遇到再说,起码交易相关表必须保留历史数据。
4. 小程序端核心流程:登录授权、商品发布、“我想买”
4.1 微信登录:code 换 openid,后端签发自己的 token
小程序端登录不是传统意义上的账号密码登录。小程序调用wx.login拿到一个临时 code,这个 code 只能使用一次,由后端拿着 code 去微信接口换 openid 和 session_key。openid 就是微信用户在当前小程序下的唯一 ID。为了不在每次请求时都去微信侧验证,后端拿到 openid 后查用户表,存在就直接签 JWT,不存在就先插一条新用户再签。小程序把 token 存到本地存储,后续请求带上这个 token。
wx.login({ success: async (res) => { const { code } = res const resp = await wx.request({ url: `${baseUrl}/api/user/login`, method: 'POST', data: { code } }) const { token } = resp.data.data wx.setStorageSync('token', token) } })后端收到 code 后会请求微信的jscode2session接口,拿到openid后入库,再返回自定义 token。这里必须强调一个小程序端的安全红线:appid 和 secret 只能保存在后端配置里,绝不能写进小程序代码,否则任何人可以反编译拿到这个密钥去伪装修登录。token 本身只包含 userId 和过期时间,用 HMAC 签名防止篡改即可。
4.2 首页列表、搜索与详情浏览的交互细节
首页商品列表核心是分页拉取,小程序端滚动到底部触发下一页。请求参数我设计为page / size / keyword / categoryId / sort,后端用 MyBatis-Plus 的 LambdaQueryWrapper 拼条件:关键字对标题和描述做 LIKE 匹配,分类是精确过滤,状态只查上架的商品,排序按创建时间倒序。搜索框增加 500ms 防抖,防止用户每敲一个字符就发一次请求。列表接口特意不查大字段,只查id, title, price, cover, user_name, create_time这些列表页需要的列,详情页再单独请求完整数据,流量能省不少。
详情页除了商品基础信息,还要展示卖家联系方式。这里我加了一个边界判断:只有当订单创建成功或者咨询留言后,双方手机号才对彼此可见,避免公开联系方式被爬虫抓取。浏览量字段view_count不能用简单set view_count = 1,要执行update goods set view_count = view_count + 1 where id = ?,放在一个带事务的方法里,防止并发计数丢失。
4.3 发布商品:先传图后提交表单,防止一次丢全部
发布商品分两步提交。第一步用户选择图片后立即调用上传接口,后端把图片存到磁盘并返回 URL 列表;第二步用户填写标题、描述、价格、分类、联系方式,连同图片 URL 一起提交发布接口。这样设计的好处是:用户填了一长串描述之后,网络抖动不至于把图片和文字一起丢掉;上传进度可以单独做提示,体验更稳。
发布接口的具体字段为title / description / price / categoryId / pics。价格这里建议统一用"元"范围内的 Decimal 类型,数据库存decimal(10,2),前端展示用toFixed(2),计算时用精度得当的 BigDecimal,不能直接用 double 做累计。商品状态初始值设为0,也就是上架。发布成功之后跳转"我的发布"页面,卖家可以看到自己所有商品的状态,比如几件在上架、几件交易中、几件已完成。
4.4 线下交易闭环:核销码与卖家确认
"我想买"是整个系统的核心动作。用户点下按钮后,后端先检查商品状态是否还是上架,再用条件更新把商品状态改为交易中,如果影响行数为0就说明商品已被抢或已下架。交易中状态下创建订单,生成订单号和6位核销码。
整个线下闭环是这样的:买家下单后页面展示核销码,并与卖家约定线下见面;买家当面看货、确认没问题后,把核销码出示给卖家;卖家在"我的卖出"订单列表中找到这笔订单,点击"确认成交",输入或者语音播报核销码后验证明确;系统校验操作者确实是这笔订单的卖家,然后把订单状态改为已完成,商品状态同步改为已完成。这一步完成后双方记录都留痕,后续商品直接从货架消失,不会出现"卖完了还能下单"的情况。
关于为什么坚决不做在线支付,我在文档里也写了一段说明:校园二手是高度依赖线下信任的小额交易,当面转账更符合习惯;接入支付系统意味着要处理分账、退款、资金流水合规这些问题,对一个学习型的项目来说负担太重。平台的角色是交易撮合和状态记录,不碰资金,风险小得多,这也是产品边界中很重要的一条。
5. 后端接口与安全细节:接口权限、并发兜底、文件上传
5.1 RESTful 接口规划与统一返回结构
| 模块 | 方法/路径 | 说明 | 是否需 token |
|---|---|---|---|
| 用户 | POST /api/user/login | 微信登录换 token | 否 |
| 用户 | GET /api/user/profile | 用户信息 | 是 |
| 分类 | GET /api/category/list | 分类列表 | 否 |
| 商品 | GET /api/goods/page | 分页搜索 | 否 |
| 商品 | GET /api/goods/{id} | 商品详情 | 否 |
| 商品 | POST /api/goods | 发布商品 | 是 |
| 商品 | PUT /api/goods/{id} | 上下架/修改 | 是 |
| 商品 | DELETE /api/goods/{id} | 删除商品 | 是 |
| 订单 | POST /api/order/create | 创建订单 | 是 |
| 订单 | POST /api/order/confirm | 卖家确认成交 | 是 |
| 上传 | POST /api/upload/image | 图片上传 | 是 |
后端所有接口返回统一结构为{ code, msg, data }。业务成功code=0,异常时为非0,msg给用户可读的出错信息,data是业务数据。分页接口的data固定为{ total, records }。这样小程序端只需要封装一个wx.request的 Promise 函数,统一处理状态码和错误提示,不用在每个页面重复做异常判断。
5.2 身份校验与越权防护
JWT 的校验用一个 Spring 拦截器来实现。配置里放行白名单,比如登录接口、分类列表、商品列表和详情;其余/api/**请求都会进入拦截器,从请求头取出 token,解密出 userId,放到ThreadLocal里供后续业务使用。用户登录后要获取自己的信息、查看自己的订单列表,这些操作都直接取 ThreadLocal 里的 userId。
越权防护是一条铁律:前端隐藏按钮不等于安全,接口层面必须验证归属权。比如修改商品时,service 层先selectById(goodsId)查出这条商品的userId,只有和当前登录用户一致才允许继续;卖家确认订单时,会校验订单里的sellerId是否等于当前用户。如果不做这一层校验,别人伪装请求直接传一个goodsId就能把你的商品下架或者冒认成交,这种漏洞在毕设答辩现场是非常容易暴露的。因为系统的安全边界在接口层做全了,小程序端其实可以不校验按钮权限,但前端依然做了显示判断,这是双保险,也是经验所在。
5.3 并发抢购和重复下单怎么兜底
并发问题最典型的场景是同一个热门商品(比如低价冰箱、教材套组)被多人同时点击"我想买"。如果代码是"先 select 后 update",两个请求同时查到商品还在上架状态,然后都去创建订单,就会出现超卖。解决办法是让"锁定商品"这个动作变成一个具备原子性的条件更新:只用一条 SQL 把状态从"上架"改成"交易中",并且用where status = 0来保证只有上架状态的商品能被锁住。
boolean locked = goodsMapper.update(null, new LambdaUpdateWrapper<Goods>() .set(Goods::getStatus, 2) // 2=交易中 .eq(Goods::getId, goodsId) .eq(Goods::getStatus, 0) // 0=上架,只有该状态能被锁定 ) == 1; if (locked) { // 创建订单,推送核销码 } else { throw new BusinessException("该商品已被他人抢购或已下架"); }这个条件更新利用数据库行级锁和行锁语义实现了乐观锁的效果。两个并发请求同时执行时,只有一条语句会更新成功,另一个请求影响行数为0,直接提示失败。相比select + update两步操作,这种方式既快又安全。订单号生成我采用的是yyyyMMddHHmmss + 4位随机数,有一定概率冲突,所以订单号加唯一索引,插入失败时重试一次,实际足够用。
5.4 图片上传与静态资源访问
开发阶段图片上传到本地磁盘,在application.yml里配置一个upload.path绝对路径,再写一个静态资源映射,把/img/**的请求指向这个目录,前端就能通过http://localhost:8080/img/xxx.jpg访问。上传时做两层校验:文件类型只允许 jpg/jpeg/png/webp,文件大小限制在5MB内。文件名用 UUID 重命名,避免中文名或重复名导致路径错乱。部署上线时建议换成对象存储云服务,把上传接口改成直传云端,返回文件的 CDN 地址,性能和扩展性都好得多,开发阶段用本地盘足以完成整个业务流程。
这里有一个容易踩的细节:Windows 本地路径分隔符是\,Linux 是/,拼接路径一定不能用字符串硬拼,要统一用正向斜杠存相对路径,或者用File.separator处理,否则换环境跑会随机出现图片加载失败。
6. 从源码到运行:本地跑通与正式部署的避坑清单
6.1 本地运行的标准步骤
把项目从仓库拉到本地后,按这个顺序跑通:
- 初始化数据库:执行
school_secondhand.sql,建库建表并插入基础分类数据。 - 修改
application.yml:数据库连接地址、用户名密码、图片存放路径,如果是 MySQL 8 必须在 JDBC URL 写serverTimezone=Asia/Shanghai。 - 启动后端,看到 "Started" 日志说明端口起来了。
- 用微信开发者工具导入小程序项目,修改
util/config.js里的baseUrl为http://localhost:8080。 - 在开发者工具设置里勾选"不校验合法域名、web-view、TLS 版本",这一步只用于本地开发。
- 真机预览时把
baseUrl改成电脑的局域网 IP,手机和电脑连同一个 Wi-Fi,小程序内登录、发商品、下单、核销整套流程跑通。
这套步骤我写在 README 靠前的位置,就是为了让人别在环境配置上消耗太多时间。视频里我会演示完整流程,特别是微信开发者工具的导入方式,文字操作有时讲不清。
6.2 小程序正式发布前要处理的域名与 HTTPS
小程序正式版本有一个绕不开的限制:所有 request、uploadFile 请求的域名必须是备案过的 HTTPS 域名,并且要在小程序后台把域名加入合法列表。这意味着后端需要一个公网服务器、一个已备案域名、一张 HTTPS 证书。开发到这一步,很多人会卡在域名和证书上,但这是微信生态的硬规则,没有捷径。本地调试可以绕过校验,发布体验版和正式版时绝对不能。
另外一个容易被忽略的点:如果后端做了context-path: /api这样的统一前缀,小程序端配置baseUrl时会把域名和前缀拼在一起,比如https://example.com/api,接口路径里再写/api/user/login就会变成/api/api/user/login。我建议后端接口统一在 Controller 层级设计路径,不要额外叠加 context-path,减少这类低级错误。
6.3 我在实测中反复遇到的五个坑
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 后端正常返回,小程序报"不在合法域名" | 本地调试没勾选"不校验合法域名" | 开发者工具勾选开关;真机调试用调试版或体验版 |
| 真机预览图片不显示 | 图片域名不在 uploadFile 合法域名列表里 | 本地开发可勾不校验;正式版必须配好图片域名 |
| 数据库时间是本地时间,接口返回却是 UTC | JDBC 时区参数缺失 | URL 末尾加serverTimezone=Asia/Shanghai |
| 商品详情浏览量不涨 | 用了set view_count = 1覆盖更新 | 改成view_count = view_count + 1 |
| 重复点击"我想买"生成了多笔订单 | 没有加状态条件更新 | 用update goods set status=交易中 where id=? and status=上架兜底 |
这几个问题基本覆盖了新手把项目跑起来时最常撞到的墙。每个问题我都录了短视频演示排查过程,对照文档里的报错截图和解决办法,很快能找到入口。
7. 回头看这个项目,最值得沉淀的三个设计习惯
整个项目完成并跑通之后,我复盘过最有价值的经验,集中在对业务状态的管理方式上。先说第一点:画状态机永远排在写代码前面。商品和订单的状态流转如果一开始没想清楚,后面随便加一个"取消订单"的功能,就可能导致商品被永久锁死在交易中状态无法释放。先把每个状态怎么进入、怎么退出、退出时对关联表做什么操作画在文档里,再按图写条件更新 SQL,逻辑就顺了。
第二点是接口层的权限和数据校验必须闭环。很多人做毕设的时候只关注"功能能不能跑通",很少去想"这个接口被别的用户调用会怎样"。我后来在代码里把所有修改类接口都加上了归属校验,把异常统一收敛到全局处理器,小程序端收到错误码就能展示友好提示,这套小机制让系统抗住了答辩现场各种意外的操作顺序。
第三点是时刻考虑代码的可读性和可交付性。写注释的时候不是简单描述"这里查询商品",而是写清楚"该查询仅供列表页使用,不含 description 大字段",别人看代码时能直接理解设计意图;文档、运行视频、讲解视频的拆分也是同一个目的——先看到整体效果,再顺着代码往下钻,才能真正把项目变成自己的东西。这个项目如果再扩展,管理端可以加一个 Spring Boot 后台的角色体系,审核商品、封禁用户、统计交易数据都能基于现有接口快速延伸。对我个人来说,能把这样一套"发布—交易—核销"的完整业务跑通,就已经把全栈开发最关键的骨架摸了一遍,后面的项目再复杂,也无非是往这个骨架上填充血肉。