微信小程序+Java校园二手交易平台源码解析:前后端分离与状态机设计
2026/9/15 4:14:05 网站建设 项目流程

简介:一套基于微信小程序的校园二手物品交易平台设计源码,面向计算机相关专业学生、毕业设计者及小程序开发者,提供了从前端展示到后端服务的完整技术方案,涵盖了用户、商品、订单等核心业务模块,适用于课程设计、毕设课题或实际项目原型。压缩包共含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 / .jsonpages/、components/微信小程序端
layui.css / layer.css / login.cssstatic/、admin/后台管理页面
.java / .xmlsrc/main/java、mapper/Java 后端
.gifresource/static/加载动画或操作演示
iconfont.eot / iconfonts.cssstatic/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 字段类型校验规则说明
titlestring必填,最长 40商品名称,列表页直接展示
descriptionstring选填详细描述,支持换行
priceBigDecimal必填,大于 0用户填写价格,单位元
imagesList<String>选填上传图片接口返回的 URL
categoryIdInteger选填分类筛选用,建议前端传默认值

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 是用户在小程序里的唯一身份标识,不能返回到前端,否则任何人都能伪造成其他用户。

存储方式过期控制适用场景
RedisEXPIRE 自动清理多实例部署,推荐
数据库 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.jsonpages数组的第一个页面。如果想把入口从首页改成发布页,或者更换启动时的 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;

typekey两列。如果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")就能拿到当前用户,省掉的重复代码比你能想到的还要多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询