校园二手交易微信小程序开发:登录鉴权、数据库设计与订单状态管理
2026/9/10 4:52:47 网站建设 项目流程

简介:本资源是一套基于微信平台的校园二手交易平台小程序完整项目,面向计算机相关专业学生、毕业设计开发者及小程序入门学习者。项目采用SSM框架、Java技术、MySQL数据库与微信小程序端配合B/S架构实现,覆盖登录注册、商品发布与交易、管理员后台及卖家功能模块等核心流程,并配有详细说明文档与系统测试内容,适合用于课程设计、毕设选题及实战练手。压缩包共1412个文件,约24.8MB,包含Java源码、Vue后台页面、微信小程序wxml/wxss/js文件、数据库SQL脚本及说明文档等,整体目录结构清晰,便于按前后端模块定位学习。已有1500余人浏览学习。借助这份资源,读者可获得可直接运行的源码框架、数据库设计思路以及从需求分析到系统实现的完整项目文档,有助于快速理解校园二手交易类小程序的开发全流程。

1. 校园二手交易选择微信小程序而不是 App 的原因与边界

校园二手交易平台真正的技术考题不在“二手”两个字,而在“发现”和“信任”:买家怎么知道你有什么,双方怎么不见面就把东西卖出去。微信小程序在这里占了两点便宜——学生离不开微信,扫码就能进,不需要应用商店审核;商品卡片可以直接甩进宿舍群和朋友圈,获客半径天然比 H5 大。这篇文章按我自己搭这类项目时会走的路线来讲:先定数据库边界,再写小程序端最核心的登录、发布、下单三条链路,接着补齐后端鉴权和图片存储,最后落到开发者工具里的验证和审核避坑。适合正在做毕设、校园创业 MVP 或想熟悉微信生态开发的读者。提前说一个判断:支付相关的能力需求弱,后文会专门解释为什么校园二手别急着接微信支付 V3,也别指望文档里把证书问题写清楚就能省掉资质门槛。

2. 数据库与功能模块:把闲鱼那套交易模型压缩进微信小程序

2.1 功能边界先定下来:不做什么,比做什么更重要

实际做校园二手交易平台,最容易犯的错是把闲鱼的所有功能都搬过来。闲鱼有闲鱼币、直播、验货宝,那是阿里生态的资源位,不是一个校园团队该承担的复杂度。我一般会把功能边界收敛成一张表:

模块必须做必须不做微信平台对应能力
用户微信一键登录、学号展示、个人主页积分系统、关注关系wx.login、getUserProfile
商品发布、多图上传、编辑、下架、搜索筛选拍卖、竞价、直播wx.chooseMedia、wx.uploadFile
订单买家发起意向、卖家确认、线下成交标记在线支付、退款订阅消息、客服会话
消息订阅消息推送、订单留言实时 IM、已读回执requestSubscribeMessage

表里的“必须不做”不是偷懒,而是对应微信平台的合规成本。比如在线支付在小程序里要走微信支付 V3,光有商户资质还不够,申请后要在商户平台 API 安全里配置平台证书,我见过很多项目卡在“无可用的平台证书”这一步,实际上要先去商户平台下载证书并配置 API v3 密钥,才能继续调起支付。校园二手每单金额通常只有几十元,支付链路带来的研发和审核成本远比成交价值高,所以项目标准做法是“站内约定、线下支付、线上确认”。

边界定完,数据库结构就清楚了:核心只有用户表、商品表、订单表,外加一张可选的收藏表。

2.2 用户表:用 openid 做唯一键,学号只做展示字段

微信小程序没有密码登录这个概念。用户进入小程序后,前端调用 wx.login 拿到 code,后端拿 code 换 openid。openid 是用户在某个小程序下的稳定身份标识,同一用户在不同小程序里 openid 不同,但在同一小程序里不会变。因此用户表主键直接用自增 ID,再加一个 openid 唯一索引,而不是反过来用学号做主键。

CREATE TABLE `user` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `openid` CHAR(28) NOT NULL COMMENT '微信 openid,小程序内唯一', `nickname` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '昵称,前端展示用', `avatar_url` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '头像地址', `student_no` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '学号,仅展示用,不做登录凭证', `campus` TINYINT NOT NULL DEFAULT 0 COMMENT '校区:0 未填,1 本部,2 东区,3 西区', `credit_score` SMALLINT NOT NULL DEFAULT 100 COMMENT '信用分,初始 100', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1 正常,0 封禁', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_openid` (`openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

这里有两个参数值得留意。第一个是uk_openid唯一索引必须加,否则 code2Session 重复回调时可能插入两条同一用户的记录。第二个是student_no字段只做展示、不做登录判断,道理很直接:学号可以被旁人知道,拿学号当凭证等于裸奔。需要真实身份认证时,正规做法是接学校统一身份认证或邮件验证码,而不是前端传一个学号字符串就当成认证结果。

2.3 商品表和订单表:状态字段不要散着写

商品表的常见误区是用“在售/已售”两个状态搞定一切,结果买家点了“我想要”,商品却还挂在列表里,产生超卖。我习惯用四个状态:1 在售、2 已被锁定、3 已卖出、4 已下架。

CREATE TABLE `goods` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `seller_id` INT UNSIGNED NOT NULL COMMENT '对应 user.id', `title` VARCHAR(100) NOT NULL COMMENT '商品标题,搜索关键词来源', `description` TEXT NOT NULL, `price_cents` INT UNSIGNED NOT NULL COMMENT '价格,单位分,避免浮点误差', `images` JSON NOT NULL COMMENT '图片 URL 数组,最多 9 张', `category` TINYINT NOT NULL DEFAULT 0 COMMENT '分类:0 教材,1 数码,2 生活用品,3 其他', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1 在售,2 已被锁定,3 已卖出,4 已下架', `view_count` INT UNSIGNED NOT NULL DEFAULT 0, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_status_created` (`status`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';

注意price_cents用整数存分而不是 DECIMAL 或 FLOAT,这能避免后续计算时 0.1 加 0.2 这类浮点问题。images用 JSON 字段而不是单独建图片表,因为查询商品详情时不需要 JOIN 一次图片表,微信平台限制最多 9 张图,JSON 数组足够用。

订单表比商品表多一个买家维度,同时要在业务上保证“一件商品只有一个进行中的订单”:

CREATE TABLE `orders` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `goods_id` INT UNSIGNED NOT NULL, `buyer_id` INT UNSIGNED NOT NULL, `seller_id` INT UNSIGNED NOT NULL, `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1 买家发起,2 卖家确认,3 已成交,4 已取消', `meet_point` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '线下交易地点,如 图书馆北门', `remark` VARCHAR(255) NOT NULL DEFAULT '', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_goods_active` (`goods_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

uk_goods_active这个唯一索引在不同状态语义下有不同含义,这里真正要保证的是同一商品同时只有一个状态为 1 的有效订单。数据库层面能兜住业务并发,比在代码里 SELECT 再 INSERT 两个步骤更可靠。这也是说明文档里我会单独画状态流转图的地方,因为前后端联调时几乎所有争议都发生在“商品被锁定后还能不能改价”“订单取消后商品要不要回到在售”这类状态迁移问题上。

3. 小程序端核心链路:登录态、发布商品、下单操作的可运行代码

3.1 登录态:wx.login 到 code2Session 再到自定义 token

微信小程序的官方登录流程是:前端 wx.login() 拿临时 code,传给后端,后端用 code 调 code2Session 接口换 openid 和 session_key,再返回给前端一个业务 token。实际代码层面最常见的错误是前端每次进页面都重新 wx.login(),正确做法是登录一次拿 token,后续请求都带 token。

// utils/login.js const login = () => { return new Promise((resolve, reject) => { wx.login({ success: async (res) => { if (!res.code) { reject(new Error('wx.login 拿不到 code')); return; } try { const resp = await wx.request({ url: 'https://your.domain.com/api/auth/login', method: 'POST', data: { code: res.code } }); const { token } = resp.data; wx.setStorageSync('token', token); resolve(token); } catch (e) { reject(e); } }, fail: reject }); }); }; module.exports = { login };

后端拿到 code 后调微信接口,要用 appid 和 secret。这里有个安全点:secret 绝不能出现在小程序前端代码里,必须放在后端环境变量中。

# app.py(Flask 示例) import requests from flask import Flask, request, jsonify app = Flask(__name__) @app.post('/api/auth/login') def auth_login(): code = request.json.get('code') appid = app.config['WX_APPID'] secret = app.config['WX_SECRET'] resp = requests.get( 'https://api.weixin.qq.com/sns/jscode2session', params={ 'appid': appid, 'secret': secret, 'js_code': code, 'grant_type': 'authorization_code' } ).json() # resp 形如 {"openid": "...", "session_key": "..."} # 出错了是 {"errcode": 40029, "errmsg": "invalid code"} if 'openid' not in resp: return jsonify({'error': 'code 已失效或 appid/secret 不匹配'}), 400 token = create_token(resp['openid']) # 业务自签 token,内部映射 openid return jsonify({'token': token})

参数说明:js_code 是一次性的,有效期约 5 分钟,不能重复换取,在开发者工具里连续点登录按钮容易报40029错误,这不是代码问题,重新触发一次 wx.login 即可。session_key 不要下发到前端,它用于解密手机号和后续敏感操作,前端拿到它没有意义。如果后端查日志发现 code2Session 返回40163,说明 code 被用过,检查是不是前端重复提交了登录请求。

3.2 发布商品:先传图片拿 URL,再提交商品表单

发布商品看似是选图、填表、提交三步,实际有前后依赖。正确顺序是先上传图片,拿到图片 URL 数组后,再连同表单数据提交给后端。如果反过来先提交商品再传图片,商品与图片之间会形成两阶段写入,失败时要清理脏数据。

// pages/publish/index.js Page({ async onChooseImage() { const res = await wx.chooseMedia({ count: 9, // 小程序限制一次最多 9 张 mediaType: ['image'], sourceType: ['album', 'camera'], sizeType: ['compressed'] // 拿压缩图,原图可能 5MB 以上 }); const tasks = res.tempFiles.map((file, i) => this.uploadOne(file.tempFilePath, i) ); const urls = await Promise.all(tasks); this.setData({ imageUrls: urls }); }, uploadOne(filePath, index) { return new Promise((resolve, reject) => { wx.uploadFile({ url: 'https://your.domain.com/api/upload', filePath, name: 'file', formData: { index: String(index) }, success: (res) => { const data = JSON.parse(res.data); resolve(data.url); }, fail: reject }); }); }, async onSubmit(e) { const form = e.detail.value; // 通过 form 组件拿数据 const payload = { title: form.title, description: form.desc, price_cents: Math.round(parseFloat(form.price) * 100), images: this.data.imageUrls }; const resp = await wx.request({ url: 'https://your.domain.com/api/goods', method: 'POST', data: payload, header: { Authorization: `Bearer ${wx.getStorageSync('token')}` } }); if (resp.data.id) { wx.showToast({ title: '发布成功', icon: 'success' }); } } });

价格用Math.round(parseFloat(form.price) * 100)转成整数分,避免“9.99 元”在小数运算中变成 9.989999。上传并发没有特殊设置,wx.uploadFile 默认并发数是 10,校园 Wi-Fi 下够用;如果发现图片顺序乱了,在 formData 里带上 index,再用 Promise.all 保持顺序拼接 URL 数组。图片尺寸上,chooseMedia 已经返回压缩图,但如果选原图再压缩,可以在本地用 canvas 缩放一次,把单张限制在 500KB 以内,能显著减少后端存储压力,详情页加载也不会白屏。

3.3 下单流程:一个“我想要”按钮同时改商品和订单状态

买家在商品详情页点“我想要”,前端把商品 ID 传给后端,后端在一个数据库事务里做两件事:把 goods.status 从 1 改成 2,插入一条订单记录。两个操作必须放在同一事务,否则会出现“订单插进去了但商品还能被别人买”的竞态。

// pages/goods-detail/index.js async onTapWant() { const goodsId = this.data.goods.id; try { const resp = await wx.request({ url: 'https://your.domain.com/api/orders', method: 'POST', data: { goods_id: goodsId, meet_point: '图书馆北门' }, header: { Authorization: `Bearer ${wx.getStorageSync('token')}` } }); if (resp.data.order_id) { wx.showToast({ title: '已通知卖家', icon: 'none' }); this.setData({ goodsStatus: 2 }); } } catch (e) { wx.showToast({ title: e.errMsg || '下单失败', icon: 'none' }); } }

卖家端是订单列表页,每个订单有“确认交易”按钮。确认后订单状态变为 2,此时调订阅消息接口通知买家,并展示约定见面地点和时间。不要在这个页面堆叠太多跳转,微信小程序页面栈最多 10 层,如果从商品页跳到详情页再跳到订单确认页,用户反复进出很容易触发navigateTo栈溢出报错,页面直接卡死,这类问题在真机上复现概率比开发者工具高得多。

3.4 请求层封装:不要在每个页面重复写 wx.request

整个小程序至少有登录、发商品、下订单、拉列表、改状态五个地方要发请求,我习惯封装一个 request 函数,统一处理 token 注入、超时、401 重新登录:

// utils/request.js const request = (options) => { const token = wx.getStorageSync('token'); return new Promise((resolve, reject) => { wx.request({ ...options, url: 'https://your.domain.com' + options.url, header: { 'Content-Type': 'application/json', 'Authorization': token ? `Bearer ${token}` : '' }, timeout: 8000, success: (res) => { if (res.statusCode === 401) { wx.removeStorageSync('token'); wx.navigateTo({ url: '/pages/login/index' }); reject(new Error('登录已过期')); return; } resolve(res.data); }, fail: reject }); }); }; module.exports = { request };

timeout 8000 这个值,校园网高峰期延迟可能到两三秒,太短会让请求被误判失败,太长用户体验差。上传图片要用 wx.uploadFile,它用的是 uploadTask 独立通道,超时控制另算,不要和 request 混在一起。还有一点容易被忽略:小程序端拿到的 res.data 在开发阶段可能是字符串,因为后端返回的 Content-Type 没设对,前端要能兼容typeof res.data === 'string'时先 JSON.parse 一下,否则真机上报错很难排查。

4. 后端接口与图片存储:用 Flask 几十行代码搭起最小可用方案

4.1 接口路由设计:RESTful 风格但不过度抽象

后端这里直接用 Flask 单文件就能跑通。接口一共五条,覆盖最核心流程:

方法路径作用
POST/api/auth/login登录,返回 token
POST/api/upload上传图片,返回 URL
POST/api/goods发布商品
PUT/api/goods/{id}修改或下架商品
POST/api/orders发起订单意向
@app.post('/api/goods') def create_goods(): uid = get_current_user_id() # 从 token 解析 data = request.json goods = Goods( seller_id=uid, title=data['title'], description=data['description'], price_cents=data['price_cents'], images=json.dumps(data['images']), status=1 ) db.session.add(goods) db.session.commit() return jsonify({'id': goods.id}), 201

参数说明:get_current_user_id() 是从请求头 Authorization 里解析 token,具体实现在下一节。写接口时注意不要把 seller_id 放在请求体里让前端传,否则任何用户都能指定别人的 seller_id 冒充发布,这个值必须从 token 里解出来。price_cents 在后端也可以再校验一次必须大于 0,前端做校验只是体验,后端做校验才是安全。

4.2 鉴权中间件:统一校验 token 和资源归属

微信生态里接口鉴权最常见的错误是每个视图函数各写一套 token 解析,有的还漏掉资源归属校验。统一中间件可以这样写:

from functools import wraps from flask import request, jsonify, g def login_required(f): @wraps(f) def wrapper(*args, **kwargs): auth = request.headers.get('Authorization', '') token = auth.replace('Bearer ', '') user_id = verify_token(token) if user_id is None: return jsonify({'error': 'unauthorized'}), 401 g.user_id = user_id return f(*args, **kwargs) return wrapper @app.get('/api/orders/mine') @login_required def my_orders(): uid = g.user_id return jsonify(db_get_orders_by_buyer(uid))

很多跨用户越权漏洞出在资源归属校验上。修改商品接口要校验当前登录用户是不是商品卖家:

@app.put('/api/goods/<int:goods_id>') @login_required def update_goods(goods_id): goods = db.session.get(Goods, goods_id) if not goods: return jsonify({'error': 'not found'}), 404 if goods.seller_id != g.user_id: return jsonify({'error': 'forbidden'}), 403 # 只允许在售和已下架之间切换,已锁定或已卖出不允许修改 if goods.status not in (1, 4): return jsonify({'error': 'current status not editable'}), 400 return jsonify({'ok': True})

这部分代码量不大,但它是整个源码里安全价值最高的十几行。开发时容易犯的错是只校验“是否登录”,不校验“是否是资源所有者”,这样只要拿到商品 ID 就能任意操作。还有一点:订单接口要校验买家不能对自己发布的商品下单,即 goods.seller_id 不能等于 g.user_id,业务逻辑虽小,漏了就会产生自买自卖的刷数据问题。

4.3 图片存储:本地磁盘写一版,再决定要不要迁对象存储

毕设和校园项目初期没有云存储账号,首先用 Flask 把图片保存到服务器本地磁盘:

import os from flask import request from werkzeug.utils import secure_filename UPLOAD_DIR = '/data/campus_secondhand/images' ALLOWED_EXT = {'jpg', 'jpeg', 'png', 'webp'} @app.post('/api/upload') @login_required def upload_image(): file = request.files.get('file') if not file: return jsonify({'error': 'no file'}), 400 ext = file.filename.rsplit('.', 1)[-1].lower() if ext not in ALLOWED_EXT: return jsonify({'error': 'bad extension'}), 400 folder = str(g.user_id) os.makedirs(os.path.join(UPLOAD_DIR, folder), exist_ok=True) fname = secure_filename(f'{uuid4().hex}.{ext}') file.save(os.path.join(UPLOAD_DIR, folder, fname)) return jsonify({'url': f'/images/{folder}/{fname}'})

这个方案有几个要注意的地方。图片名不要直接用用户传来的原名,会产生撞名和解码问题;扩展名必须做白名单,遇到 php、py 这类直接拒绝。本地磁盘方案缺点明显:单机磁盘扩容要动服务器、没有备份,适合日 UV 低于 500 的阶段性项目。流量起来后建议迁对象存储,迁移成本集中在图片 URL 域名变化,代码逻辑几乎不用动。

对比项本地磁盘对象存储
部署成本零额外依赖需要 Bucket 和密钥
扩容手动挂盘按量自动
外链访问需要 Nginx 映射静态目录自带 CDN 不耗服务器带宽
安全需自己加防盗链提供签名 URL

4.4 订阅消息:额度有限,只用在订单关键节点

订阅消息是小程序触达用户的唯一官方推送通道,规则是“用户点击一次授权,只能推送一次”。所以不要在所有行为上都弹订阅授权,把额度留给最关键的“卖家确认交易后通知买家”这一个节点。

// 买家发起订单时预先请求订阅授权 await wx.requestSubscribeMessage({ tmplIds: ['YOUR_TEMPLATE_ID'] });

后端在卖家确认订单时推送:

def send_order_notify(openid, order_id, goods_title): body = { 'touser': openid, 'template_id': 'YOUR_TEMPLATE_ID', 'page': f'/pages/order/detail?id={order_id}', 'data': { 'thing1': {'value': goods_title}, 'thing2': {'value': '卖家已确认,请联系交易'} } } token = get_access_token() resp = requests.post( 'https://api.weixin.qq.com/cgi-bin/message/subscribe/send', params={'access_token': token}, json=body ) return resp.json()

推送时绑定的 openid 是买家的,不是卖家的。模板内容要和申请模板时字段保持一致,模板里有的字段才能填,没有的字段填了会被微信拒绝。订阅消息一次授权的额度只有一次,买家取消订单再重新下单时会发现推送次数用完,需要考虑前端在什么时机请求授权,通常在用户首次点击“我想要”时请求比较自然。

5. 上线前验证:开发者工具调试、审核被拒原因与交易安全边界

5.1 开发者工具上必做的三个调试场景

第一是登录态失效:把开发者工具“清除缓存 → 清除数据缓存”后立刻进入商品详情页,能正常弹回登录页才算过关。第二是弱网模拟:开发者工具的 Network 面板可以限速到 2G/3G,这时候发布商品如果超时,要看前端有没有展示重试按钮而不是白屏。第三是图片顺序:上传时故意乱序选择图片,发布后在详情页检查图片顺序是否和用户点选顺序一致。这三个场景覆盖了登录、上传、状态同步三条最容易出问题的链路。

5.2 审核常见被拒原因

微信小程序审核和普通网站不同,它先看资质和页面是否符合类目,再检查功能。校园二手一般选“二手闲置交易”或“电商平台”类目,需要提交营业执照或学校社团的资质证明。另一个高频拒审原因是隐私协议缺失:小程序里如果收集了学号、手机号甚至仅收集 openid,都需要在小程序后台配置《用户隐私保护指引》,并把弹窗说明写清楚。订阅消息的授权文案必须写“用于接收交易订单进度通知”,不能写“为了更好的服务体验”这种含糊描述。源码包里附带的说明文档最好专门写一节审核材料清单,包括需要准备什么证照、每个权限对应的用途描述,节省反复提交的时间。

5.3 为什么校园二手不建议接微信支付 V3

最后说回开头那个判断。微信支付 V3 需要企业主体申请商户号,个人开发者拿不到;即使有营业执照,还要申请平台证书、配置 API v3 密钥,中间任何环节不一致就报“无可用的平台证书”错误。校园二手每笔几十块钱,手续费、退款、对账的成本远大于利润空间,而且一旦接入支付就涉及售后纠纷和资金池问题,学生团队没有客服团队去处理这些。行业成熟做法是“线下当面交易,线上确认状态”,平台只负责撮合和规则,不碰资金流。这个边界想清楚,整个项目周期可以从一个月压缩到两周,文档里需要写清楚的核心逻辑,也会从支付对账变成状态机、鉴权和图片上传这三件事。

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

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

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

立即咨询