简介:一套基于微信小程序的校园二手物品交易平台设计源码,面向计算机相关专业学生、毕业设计者及小程序开发者,提供了从前端展示到后端服务的完整技术方案,涵盖了用户、商品、订单等核心业务模块,适用于课程设计、毕设课题或实际项目原型。压缩包共含398个文件,大小18.57MB,主要包含82个Java后端逻辑文件、59个JavaScript脚本、28套WXML+WXSS小程序页面文件、21个HTML页面,以及SQL脚本、GIF与PNG图片素材等,目录结构清晰,便于按需检索与二次开发。目前已有411人学习浏览。系统采用模块化开发,覆盖用户登录、商品发布、订单管理等典型交易流程,并预留扩展空间;同时提供完整源码、数据库脚本、配置文件与界面素材,从页面布局、接口交互到数据存储均有代码支撑,读者可借此快速掌握微信小程序与Java、JavaScript、HTML/CSS的协同开发方式,也可直接部署运行作为项目基础。
1. 为什么校园二手交易平台要拆成微信小程序 + Java 两段
看到“基于微信小程序的校园二手物品交易平台设计源码”时,别以为它只是一套小程序前端页面。资源里同时存在 82 个 Java 文件和 75 个 GIF 文件,说明后端接口和资源确实占了大头。校园二手交易和普通商城不一样,用户既是买家也是卖家,商品状态在“在售 / 已预约 / 已售出”之间来回切换,还要处理学号认证、校园区域过滤、图片上传这些偏业务逻辑的东西。如果全部塞进小程序端,代码会很快失控。更合适的做法是:小程序只负责页面渲染和请求分发,Java 端统一管理商品、订单和用户身份。拿这套源码做毕业设计或项目实例时,先把这条前后端边界摸清楚,后续改哪里、加什么都好定位。
2. 拆开 399 个文件:小程序端 WXML 数据绑定和管理端 layui 的分工
拿到源码后,第一眼看到的是 layui.css、layer.css、layui.mobile.css、laydate.css、iconfonts.css、topic.css、login.css、code.css、iconfont.eot,以及大量 59.gif 之类的资源文件。这些多半属于 Web 管理后台和静态演示资源,不代表小程序页面本身。小程序的样式由 WXSS 负责,页面结构由 WXML 负责,逻辑由 JS 负责。先按文件归属重新归类,后面改造时才不会把后台样式错误地引到小程序里。
2.1 先按文件类型建一张归属表
我一般会先做一次目录扫描,把文件按下面这张表分组,再决定先读哪一部分。
| 文件类型 | 常见位置 | 资源里的归属 |
|---|---|---|
| .wxml / .wxss / .js / .json | pages/、components/ | 微信小程序端 |
| layui.css / layer.css / login.css | static/、admin/ | 后台管理页面 |
| .java / .xml | src/main/java、mapper/ | Java 后端 |
| .gif | resource/static/ | 加载动画或操作演示 |
| iconfont.eot / iconfonts.css | static/font/ | 图标字体,前后台通用性低 |
82 个 Java 文件说明后端不是空壳,通常对应商品、订单、用户、分类四个模块。Controller、Service、Mapper、Entity、DTO 五层加起来很容易到这个数量。75 个 GIF 文件里,相当一部分是交易流程的演示图或加载动画,前端静态资源可以直接替换,不会影响后端逻辑。真正决定项目能不能跑起来的,是 Java 文件里的接口和小程序 pages 目录下的页面文件。
2.2 商品列表:从 WXML 到 wx.request 跑通数据链路
小程序端最核心的数据链路是:页面加载时发起请求,拿到 JSON 后 setData 渲染列表。下面这段商品列表代码,是这套系统里最常见的写法。先看 WXML:
<view class="goods-list"> <view class="goods-item" wx:for="{{goodsList}}" wx:key="id" bindtap="goDetail">Page({ data: { goodsList: [], page: 1, pageSize: 10, hasMore: true }, onLoad() { this.loadGoods(true); }, loadGoods(reset) { const that = this; wx.request({ url: 'http://192.168.1.10:8080/api/goods/list', data: { page: that.data.page, pageSize: that.data.pageSize }, success(res) { if (res.data.code !== 0) return; const records = res.data.data.records; that.setData({ goodsList: reset ? records : that.data.goodsList.concat(records), hasMore: records.length >= that.data.pageSize }); } }); }, onReachBottom() { if (this.data.hasMore) { this.setData({ page: this.data.page + 1 }); this.loadGoods(false); } } });这里的 page 和 pageSize 是后端分页参数,后端返回的数据结构为{ code, data: { records } },records 就是当前页商品数组。reset参数区分下拉刷新和触底加载,第一次进入页面时传 true,触底翻页时传 false,这样列表不会重复拼接。URL 中的192.168.1.10是局域网调试地址,真机上不能继续用,要么改成线上 HTTPS 域名,要么在开发者工具里临时关闭域名校验。
需要注意,wx.request的 success 回调内部不能用外层的this,所以代码里先取了const that = this,再用that.setData。很多刚接触微信小程序源码的人在这里报错,原因是把this当成 Vue 实例直接用,但小程序 Page 里的方法默认并不绑定到 request 回调上。
2.3 管理端为什么要保留 layui 而不是移植到小程序
资源里的 layui.css、layer.css、laydate.css 是标准 Web 管理后台样式,基于 jQuery 和 DOM 实现。小程序没有 DOM 树,不能加载 jQuery,也不能直接用 layUI 组件。后台管理页面和小程序前端必须分开维护。一个常见分工是:小程序端做商品发布、浏览、下单,Web 管理端做商品审核、用户禁用、订单导出。
如果想把小程序端做得更规范,我一般会把重复出现的商品卡片抽成自定义组件。在components/goods-card/目录下放一个 JSON:
{ "component": true, "usingComponents": {} }组件 WXML 保持独立:
<view class="goods-card" bindtap="onTap"> <image src="{{cover}}" mode="aspectFill"></image> <text class="title">{{title}}</text> <text class="price">¥{{price}}</text> </view>组件 JS 里用Component注册:
Component({ properties: { id: Number, cover: String, title: String, price: Number }, methods: { onTap() { this.triggerEvent('tapcard', { id: this.data.id }); } } });这样商品卡片在首页、搜索结果页、我的发布页都可以复用。父页面只需要在 JSON 里注册组件,再用bind:tapcard接收点击事件。把可复用单元做成组件,比每个页面复制一份 WXML 更利于后续维护,这也是这套源码里模块化开发方式的落地体现。
3. 基于 Java 的发布接口与订单状态机:从 Controller 到 Mapper
第 2 章把文件归属理清了,这里进入 Java 后端的核心。82 个 Java 文件里,真正决定业务能否闭环的是商品发布、状态流转、分页查询三个点。设计接口时不能只想着能跑通,还得考虑并发场景下商品会不会被重复下单。
3.1 商品发布接口的字段校验和事务边界
发布商品时,小程序端提交的是一个 JSON,里面包含标题、描述、价格、图片列表、分类等字段。后端先用 DTO 接收并做校验,避免脏数据落入数据库。
public class GoodsPublishDTO { @NotBlank(message = "标题不能为空") @Size(max = 40, message = "标题最多40个字") private String title; private String description; @NotNull(message = "价格不能为空") @DecimalMin(value = "0.01", message = "价格必须大于0") private BigDecimal price; private List<String> images; private Integer categoryId; }Controller 层保持薄:
@PostMapping("/api/goods/publish") @ResponseBody public Result publish(@RequestBody @Valid GoodsPublishDTO dto) { return goodsService.publish(dto); }Service 层需要把主表和图片表放在同一个事务里:
@Transactional(rollbackFor = Exception.class) public Result publish(GoodsPublishDTO dto) { Long userId = SecurityContext.getUserId(); Goods goods = new Goods(); goods.setUserId(userId); goods.setTitle(dto.getTitle()); goods.setPrice(dto.getPrice()); goods.setStatus(0); goods.setCreateTime(new Date()); goodsMapper.insert(goods); if (dto.getImages() != null) { for (String url : dto.getImages()) { goodsImageMapper.insert(new GoodsImage(goods.getId(), url)); } } return Result.ok(); }这段代码里最值得注意的是一级参数说明:@PostMapping接收 JSON,@RequestBody @Valid触发 DTO 校验,@Transactional保证商品主记录和图片记录要么都成功,要么都回滚。如果图片表插入失败而商品已经写入数据库,用户会看到一条没有图片的商品,这在二手交易场景里非常影响信任度。
| DTO 字段 | 类型 | 校验规则 | 说明 |
|---|---|---|---|
| title | string | 必填,最长 40 | 商品名称,列表页直接展示 |
| description | string | 选填 | 详细描述,支持换行 |
| price | BigDecimal | 必填,大于 0 | 用户填写价格,单位元 |
| images | List<String> | 选填 | 上传图片接口返回的 URL |
| categoryId | Integer | 选填 | 分类筛选用,建议前端传默认值 |
3.2 二手交易状态机:预约后不能被别人直接下单
校园二手交易的商品状态比普通电商更细,至少包含在售、已预约、已售出、下架四类。状态字段用 int 维护,不建议直接用字符串,因为字符串容易写错且排序不好处理。
| 状态码 | 状态含义 | 可流转到 |
|---|---|---|
| 0 | 在售 | 1、2、-1 |
| 1 | 已预约 | 2、0 |
| 2 | 已售出 | 无 |
| -1 | 已下架 | 0 |
用户点击“我要预约”时,不能只判断当前商品 status 等于 0,还要保证更新语句本身带有状态条件。我一般会用乐观锁写法:
int rows = goodsMapper.updateStatusByCondition(goodsId, 1, 0); if (rows == 0) { throw new BizException("商品已被预约或售出"); }对应的 Mapper XML:
<update id="updateStatusByCondition"> UPDATE goods SET status = #{targetStatus}, updated_at = NOW() WHERE id = #{id} AND status = #{expectedStatus} </update>这里的关键是WHERE status = #{expectedStatus}。两个用户同时发起预约请求时,数据库行锁会保证只有一个 update 影响一行,另一个 update 影响 0 行。拿到 0 行的一方直接提示“商品已被预约或售出”,而不是覆盖前一个人的状态。这个写法比先 select 再 update 的常规流程更稳,因为它把状态判断和状态更新放在同一条 SQL 里完成。
3.3 MyBatis 分页查询和索引选择
商品列表接口用 MyBatis-Plus 的 Page 来实现分页,代码里常见的写法是:
public Page<GoodsVO> queryList(String keyword, Integer sort, int pageNum, int pageSize) { Page<Goods> page = new Page<>(pageNum, pageSize); QueryWrapper<Goods> qw = new QueryWrapper<>(); qw.eq("status", 0); if (StringUtils.hasText(keyword)) { qw.like("title", keyword); } if (sort == 1) { qw.orderByAsc("price"); } else { qw.orderByDesc("updated_at"); } goodsMapper.selectPage(page, qw); return page.convert(goods -> convertToVO(goods)); }selectPage内部会执行 count 和 limit 两条 SQL,pageNum 过大时 limit 偏移量很高,性能会明显下降。配合这张表的查询条件,建议在 goods 表上建联合索引:
ALTER TABLE goods ADD INDEX idx_status_update (status, updated_at);这条索引看起来简单,实际能同时服务“按状态过滤”和“按更新时间排序”两个语义。分页查询时,MySQL 先通过索引定位 status=0 的数据,再按 updated_at 降序读取,避免在临时表里做 filesort。标题搜索仍然是LIKE '%keyword%',这类模糊查询无法直接命中索引,校园二手场景下数据量一般只有几千条,问题不大。如果未来商品量到十万级,就需要接入全文索引或 Elasticsearch,那是另一个层面的优化。
4. 登录态、图片上传与搜索排序:影响体验的三个工程点
商品和订单是骨架,登录态、图片上传、搜索排序才是真正影响日常使用的细节。很多基于微信小程序的校园二手项目,后端接口写好了,但登录态和图片链路没处理好,真机一测就开始暴露问题。
4.1 用 wx.login + code2Session 换身份,而不是 getUserInfo
微信官方已经不建议通过wx.getUserInfo直接获取用户身份,更合理的流程是:小程序端先调用wx.login拿到临时 code,再把 code 传给 Java 后端,由后端调用微信接口换取 openid。
小程序端代码:
wx.login({ success(res) { wx.request({ url: 'https://api.example.com/api/user/login', method: 'POST', data: { code: res.code }, success(r) { wx.setStorageSync('token', r.data.data); } }); } });Java 后端处理 code 换 session:
@PostMapping("/api/user/login") public Result login(@RequestParam String code) { String url = "https://api.weixin.qq.com/sns/jscode2session" + "?appid=" + appId + "&secret=" + appSecret + "&js_code=" + code + "&grant_type=authorization_code"; String body = restTemplate.getForObject(url, String.class); JSONObject json = JSON.parseObject(body); String openid = json.getString("openid"); if (StringUtils.isBlank(openid)) { return Result.fail("code 无效或已过期"); } User user = userMapper.selectByOpenid(openid); if (user == null) { user = new User(openid); userMapper.insert(user); } String token = UUID.randomUUID().toString().replace("-", ""); redisTemplate.opsForValue().set( "login:token:" + token, user.getId().toString(), 7, TimeUnit.DAYS ); return Result.ok(token); }这里有一个容易踩的坑:code 只能使用一次,有效期大约 5 分钟。如果小程序端多次调用登录接口,第二次必然失败。所以登录逻辑要加状态判断,不能每次页面加载都无脑换 code。openid 是用户在小程序里的唯一身份标识,不能返回到前端,否则任何人都能伪造成其他用户。
| 存储方式 | 过期控制 | 适用场景 |
|---|---|---|
| Redis | EXPIRE 自动清理 | 多实例部署,推荐 |
| 数据库 token 表 | 定时任务扫描 | 单机课设可用 |
4.2 图片上传:wx.uploadFile 与 MultipartFile 的对应关系
发布商品时图片上传是一个独立接口。小程序端用wx.uploadFile把文件提交到后端,注意name参数必须和后端接收参数名一致。先看前端:
wx.chooseMedia({ count: 6, mediaType: ['image'], sizeType: ['compressed'], success(res) { const tempFilePath = res.tempFiles[0].tempFilePath; wx.uploadFile({ url: 'https://api.example.com/api/upload', filePath: tempFilePath, name: 'file', success(uploadRes) { const data = JSON.parse(uploadRes.data); if (data.code === 0) { console.log(data.data.url); } } }); } });后端用 Spring MVC 接收:
@PostMapping("/api/upload") public Result upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return Result.fail("文件为空"); } if (file.getSize() > 2 * 1024 * 1024) { return Result.fail("图片大小不能超过2MB"); } String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String filename = System.currentTimeMillis() + "_" + UUID.randomUUID().toString().replace("-", "") + ext; Path path = Paths.get(uploadDir, filename); try { Files.copy(file.getInputStream(), path, StandardCopyOption.REPLACE_EXISTING); } catch (IOException e) { return Result.fail("上传失败"); } return Result.ok("/upload/" + filename); }这里的文件名用“时间戳 + UUID + 原扩展名”重新生成,主要目的是避免中文文件名和路径穿越问题。@RequestParam("file")必须和前端 uploadFile 的 name 字段完全一致,否则后端直接报 400。上传目录通常放在服务器磁盘或对象存储里,如果部署在云服务器,要确认这是一个外部可访问的静态资源路径。
4.3 搜索排序和热门词统计
商品搜索不能只做精确匹配,校园二手场景里用户会搜“自行车”“高数书”“洗衣机”这类短词,所以列表接口要支持关键词模糊查询,并记录搜索热度。排序参数简单一点,前端传 sort:
| sort 值 | 排序逻辑 |
|---|---|
| 0 | 默认按最新发布排序 |
| 1 | 价格从低到高 |
| 2 | 价格从高到低 |
| 3 | 按热度优先 |
搜索关键词热度统计用 Redis 比较合适。每来一次搜索,就对对应关键词做一次自增:
public void incr(String keyword) { String key = "hot:keyword:" + keyword; Long count = redisTemplate.opsForValue().increment(key); if (count != null && count == 1L) { redisTemplate.expire(key, Duration.ofDays(7)); } }首次计数时设置 7 天过期,避免没有被消费的 key 永久占内存。查询商品时把关键词透传到这个方法里,统计逻辑和业务逻辑完全解耦。很多“热搜词排行榜”功能就这么几行代码实现的。
5. 真机调试与二次开发时值得保留的四个技巧
5.1 修改刚进入的加载页面
微信小程序启动后默认加载app.json里pages数组的第一个页面。如果想把入口从首页改成发布页,或者更换启动时的 loading 页,直接调整pages顺序:
{ "pages": [ "pages/index/index", "pages/release/release", "pages/mine/mine" ] }pages第一项就是编译后的初始路由。部分项目还会在app.json里配置entryPagePath,它优先级高于pages第一项。改完后要在开发者工具里重新编译,只保存不编译有时看不到效果。
5.2 真机流量抓包
开发阶段遇到接口在开发者工具里正常、真机上报错时,首先要确认是不是域名校验问题。开发者工具里勾选“不校验合法域名”只能覆盖工具环境,真机预览仍然会校验。需要看真实请求内容时,用 Charles 抓包比较直接:电脑端打开 Charles 的 SSL 代理,手机 Wi-Fi 的 HTTP 设置指向电脑 IP 和 8888 端口,再打开小程序,请求的域名、路径、header、返回体都能看到。抓包时注意区分 devtools 请求和真机请求,别把开发者工具里的缓存数据当成线上结果。
5.3 用 EXPLAIN 验证索引是否生效
给商品表建了联合索引后,不要只看接口响应时间,直接用 EXPLAIN 确认执行计划:
EXPLAIN SELECT id, title, price FROM goods WHERE status = 0 ORDER BY updated_at DESC LIMIT 10;看type和key两列。如果type是 ALL 或key是 NULL,说明索引没有命中;正常情况type至少是 ref 或 range,key会显示idx_status_update。如果发现排序字段导致 filesort,检查联合索引字段顺序是不是把等值条件放前面、排序字段放后面。
5.4 保留统一的登录态过滤器
小程序端和管理后台都依赖同一个用户体系时,建议在 Java 端加一个 TokenFilter,只做一件事:从请求头里取 token,校验 Redis 中是否存在,存在就把 userId 写入 request attribute。后续接口不需要每个 Controller 重复写登录校验,直接读request.getAttribute("userId")就能拿到当前用户,省掉的重复代码比你能想到的还要多。
本文还有配套的精品资源,点击获取