Flask+微信小程序构建同城二手交易系统:从数据库到部署全指南

发布时间:2026/10/7 16:52:57
Flask+微信小程序构建同城二手交易系统:从数据库到部署全指南 最近一直在忙一个完整的同城二手交易系统后端用 Python 的 Flask 框架前端是微信小程序。项目做完交付出去以后问得最多的问题反而不是功能本身而是小程序源码拿到手了但不知道怎么让它跑起来后端接口到底是怎么和小程序对上的。这确实是小程序项目跟普通网页项目最大的区别——你不能双击一个 HTML 文件就看到效果。所以这篇就把整套东西捋一遍从业务拆解、数据库设计、Flask 后端接口到小程序前端的关键代码、部署上线最后是我实际调试中踩过的坑尽量能让一个有一定 Python 基础、但没完整做过小程序项目的同学也能照着做出个能跑的系统。1. 从二手闲置群到线上交易这套系统的业务拆解1.1 同城二手交易到底在解决什么问题先说需求。二手交易这个事最大的痛点不是有没有商品而是信任和交付。你在闲鱼上买个东西最怕的是货不对板其次是运费比东西还贵。同城二手交易的核心逻辑就是通过地理距离把这两个成本压下来——同城可以线下看货、当面交易纠纷概率大幅下降物流成本几乎为零。所以你会发现这类系统特别适合几个场景大学校园毕业生离校甩卖、大型小区家具、婴儿车、公司园区离职闲置。这些场景的共性是人密集、需求高频、地理位置天然聚集。我在设计的时候业务上就锚定了校园 社区这个方向因为你一旦把范围铺到全网等于跟闲鱼正面竞争反而没有优势。1.2 核心用户角色和功能模块这套系统有三角色买家、卖家、平台管理员管理员可以先不做但数据库里要留出字段和状态位。买家能做什么浏览商品、按城市/分类筛选、搜索、收藏、留言咨询、下单购买、确认收货、评价卖家。卖家能做什么微信登录后发布商品传图、填价格、选成色、上下架商品、回复留言、处理订单、查看卖出记录。模块上拆成六个用户模块、商品模块、订单模块、留言模块、收藏模块、管理系统可选。这六个模块里最核心也是最容易做砸的是商品模块和订单模块。商品模块决定用户体验订单模块决定交易能否闭环。我见过很多二手交易项目做到发布商品 浏览列表就停了没有订单流转买家留言全靠线下自己聊那其实只是一个信息发布栏不算交易系统。1.3 业务状态流转是设计的起点设计数据库之前一定要先把业务流程走一遍并且把每个流程节点的状态列出来。商品从发布到售出的状态草稿用户填了一半没提交上架中正常展示可被搜索到交易中买家已下单这时候商品应该从列表里消失或打上标签避免多人同时下单已售出已下架卖家主动下架或被管理员强制下架订单的状态待付款待确认付款后等待卖家确认并发货/约定线下交付已完成已取消售后中一开始就定好这些状态非常重要。我见过有人用单个字段is_sold布尔值表示商品是否卖出结果订单状态稍微一复杂就没法玩了。状态字段设计得太粗糙后边所有逻辑都得跟着改。这也是我反复强调数据库先行的原因。2. 选型复盘为什么后端锁定 Python Flask前端锁定微信小程序2.1 Flask 和 FastAPI 该怎么选热词里有一条flask 与 fastapi 比较这几乎是每个现在学 Flask 的人都会问的问题。我的看法很直接做这种典型的小程序 Web API项目Flask 比 FastAPI 更稳妥尤其是在教学、毕设、快速交付这个场景下。FastAPI 的优势是性能好、自带 OpenAPI 文档、基于 Pydantic 做参数校验这些确实很现代化。但它的高性能建立在异步 IO 上新手一旦理解不透 async/await写出来的异步代码可能比同步还慢还容易出线程安全类问题。而这个同城二手交易系统的性能瓶颈根本不在框架而在数据库查询和图片传输上。你用 FastAPI 换来的那点性能优势在这个业务场景下完全感知不到。Flask 则相反它是同步框架逻辑直白。它有一个最大的隐藏资产生态极其成熟。Flask-SQLAlchemy 管 ORMFlask-Migrate 管数据库迁移Flask-CORS 管跨域PyJWT 管 token全部组装起来非常顺畅。更关键的是Flask 的中文资料和现成案例多到你搜都搜不完——这意味着你卡住的时候几乎一定能搜到答案。对学习者来说这是最大的确定性。2.2 为什么前端不用 App 或 H5而是微信小程序同城二手交易是典型的低频但刚需场景。用户不会为了卖一个旧书架专门去下载一个 App但他大概率用微信。小程序即用即走没有安装成本还能通过微信的社交关系链传播比如把商品分享到班级群、小区群。这一段是业务层面的理由。技术上小程序给了一个天然的环境微信登录解决了用户身份问题不需要自己做账号体系wx.chooseMedia一键调起相机/相册定位接口wx.getLocation直接拿到经纬度这些能力让开发成本大幅下降。你做 H5 的话登录要做手机号验证码定位要处理各种浏览器的授权差异全都是额外的活。2.3 项目目录结构怎么组织这块我用的是 Flask 蓝图Blueprint组织方式后端一个 App 拆成多个模块小程序端单独一个目录flask-secondhand/ ├── app/ │ ├── __init__.py # 创建 Flask app、初始化扩展、注册蓝图 │ ├── config.py # 配置类数据库地址、微信 appid/secret、token 密钥 │ ├── models/ │ │ ├── __init__.py │ │ ├── user.py # 用户模型 │ │ ├── goods.py # 商品模型 │ │ └── order.py # 订单模型 │ ├── api/ │ │ ├── __init__.py # 蓝图注册入口 │ │ ├── user_api.py # 登录、获取用户信息 │ │ ├── goods_api.py # 商品发布、列表、详情、上下架 │ │ ├── order_api.py # 创建订单、确认收货 │ │ └── message_api.py # 留言 │ └── utils/ │ ├── token.py # token 生成与校验 │ ├── geo.py # 经纬度距离计算 │ └── upload.py # 图片上传处理 ├── miniprogram/ # 微信小程序工程目录 ├── requirements.txt └── run.py # 启动入口这种拆法好处是API 路由按业务分文件models 单独一层小程序工程和后端在同一个仓库里方便同步。多人协作或者以后加功能都很好扩展。3. 数据库先行用户、商品、订单三张核心表怎么设计3.1 用户表openid 是唯一身份凭证微信小程序登录后后端拿到的核心数据是用户的openid。一个 openid 对应一个用户在一款小程序里的唯一身份所以用户表里 openid 必须是唯一索引。注意同一个用户在不同小程序里的 openid 不一样不同用户在同一小程序里的 openid 也都不一样所以 openid 天然的适合做主键或者唯一键。CREATE TABLE user ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, openid VARCHAR(64) NOT NULL UNIQUE COMMENT 微信小程序用户唯一标识, nickname VARCHAR(64) DEFAULT COMMENT 昵称, avatar_url VARCHAR(255) DEFAULT COMMENT 头像地址, phone CHAR(11) DEFAULT COMMENT 手机号, credit_score INT DEFAULT 100 COMMENT 信用分, city VARCHAR(32) DEFAULT COMMENT 常用城市, status TINYINT DEFAULT 1 COMMENT 1正常 0禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有个设计细节phone字段不是注册时必须填的。微信小程序有个getPhoneNumber接口可以直接拿用户手机号但用户可以先跳过等他发布商品或者下单时再引导绑定。这样能降低注册门槛毕竟二手交易的第一诉求是赶紧把东西挂上去不是填一堆表单。3.2 商品表价格用 DECIMAL经纬度要分开存商品表是整个系统最核心的表。先看 DDLCREATE TABLE goods ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL COMMENT 卖家ID, category_id INT UNSIGNED DEFAULT 0 COMMENT 分类ID, title VARCHAR(64) NOT NULL COMMENT 商品标题, description TEXT COMMENT 商品描述, price DECIMAL(10,2) NOT NULL COMMENT 售价, original_price DECIMAL(10,2) DEFAULT 0.00 COMMENT 原价/参考价, condition_level TINYINT DEFAULT 3 COMMENT 成色1全新 2几乎全新 3轻微使用痕迹 4明显使用痕迹, city VARCHAR(32) DEFAULT COMMENT 城市, district VARCHAR(32) DEFAULT COMMENT 区/县, longitude DECIMAL(10,6) DEFAULT 0 COMMENT 经度, latitude DECIMAL(10,6) DEFAULT 0 COMMENT 纬度, status TINYINT DEFAULT 1 COMMENT 1上架 2交易中 3已售 4下架, view_count INT UNSIGNED DEFAULT 0 COMMENT 浏览次数, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_user_id (user_id), KEY idx_city (city), KEY idx_status (status), KEY idx_create_time (create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个容易踩的坑价格必须用 DECIMAL。千万别用 FLOAT 或 DOUBLE浮点数在二进制里无法精确保存0.1 0.2 会等于 0.30000000000000004涉及钱必须用定点数。经纬度分开存成 DECIMAL(10,6)不要拼成一个字符串。排序、范围筛选时拆开算方便。DECIMAL(10,6) 精度大概到 0.1 米完全够用。status 要建索引。列表页所有查询都会过滤 status 1不建索引的话商品一多查询就变慢。view_count这种计数我用UPDATE goods SET view_count view_count 1 WHERE id ...原子自增避免先查后写出现并发覆盖。商品图片我单独建了一张goods_image表一商品多图。这张表很简单id、goods_id、image_url、sort_order。按 goods_id 查询后按 sort_order 排序即可。3.3 订单表order_no 要自己生成别用自增 ID 当订单号CREATE TABLE order ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT 订单号, goods_id INT UNSIGNED NOT NULL, seller_id INT UNSIGNED NOT NULL, buyer_id INT UNSIGNED NOT NULL, amount DECIMAL(10,2) NOT NULL COMMENT 成交金额, status TINYINT DEFAULT 1 COMMENT 1待付款 2已付款待确认 3已完成 4已取消 5售后中, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, finish_time DATETIME DEFAULT NULL COMMENT 完成时间, KEY idx_seller (seller_id), KEY idx_buyer (buyer_id), UNIQUE KEY uk_goods (goods_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;订单号为什么不用自增 ID因为订单号会暴露给用户订单详情页、支付回调自增 ID 会让别人容易枚举你平台的总订单量而且看着不专业。我用的是时间戳 用户ID后四位 随机数拼成 20 位左右的字符串保证唯一即可。UNIQUE KEY uk_goods (goods_id)是个关键约束一件商品同时只能生成一个有效订单。这样从数据库层面就防止了多人同时下单同一商品的并发问题比在代码里加判断可靠得多。当然如果要做商品被下单后自动下架的逻辑最好把生成订单 改商品状态放在一个事务里保证原子性。3.4 Redis 在这里的定位这个项目里我用了 Redis 做三件事缓存 token 会话、缓存首页商品列表降低数据库压力、记录商品浏览量定时刷回数据库。Redis 不是必须的但如果要上线部署建议还是装上部署成本很低收益却很爽。token 存 Redis 而不是只用 JWT 的另一个好处是你可以随时把某个用户的 token 踢下线Redis 里删掉即可而纯 JWT 是无状态的签发出去就没办法主动让它失效了。4. Flask 后端骨架从登录到下单的核心接口实现4.1 微信登录拿到 code 换 openid 再发 token微信小程序登录的标准流程是小程序端wx.login()获取一个临时code把 code 发给后端后端拿 code 去微信接口换openid和session_key。这个code有效期只有 5 分钟且只能使用一次所以后端必须一收到就立刻去换。import requests from flask import Blueprint, request, jsonify from app.models.user import User from app.utils.token import generate_token from app import db user_api Blueprint(user, __name__, url_prefix/api/user) user_api.route(/login, methods[POST]) def login(): data request.get_json() code data.get(code) if not code: return jsonify({code: 1, msg: 缺少code}) resp requests.get( https://api.weixin.qq.com/sns/jscode2session, params{ appid: app.config[WX_APPID], secret: app.config[WX_SECRET], js_code: code, grant_type: authorization_code }, timeout5 ).json() openid resp.get(openid) if not openid: return jsonify({code: 1, msg: resp.get(errmsg, code已失效)}) user User.query.filter_by(openidopenid).first() if not user: user User(openidopenid, nickname微信用户) db.session.add(user) db.session.commit() token generate_token(user.id) return jsonify({ code: 0, data: { token: token, user_id: user.id, nickname: user.nickname, avatar_url: user.avatar_url, credit_score: user.credit_score } })这里有个容易犯的错误直接把微信返回的session_key当 token 发给前端。session_key是用来解密手机号等敏感信息的不应该暴露给前端。正确做法是后端自己发一个业务 token我用的是hmac签名生成的 token存 Redis 并设置 7 天有效期。前端后续所有请求在 header 里带Authorization: token后端用 before_request 钩子统一校验。4.2 发布商品图片上传走 multipart商品信息走 JSON小程序的图片上传用的是wx.uploadFile这个 API 发起的是multipart/form-data请求。所以后端的商品发布接口需要接收两部分文件字段file和表单字段title、price、city等。import os import uuid from flask import request, jsonify goods_api.route(/publish, methods[POST]) login_required def publish(): user_id g.user_id files request.files.getlist(file) # 多图上传 title request.form.get(title) price request.form.get(price) city request.form.get(city) district request.form.get(district) longitude request.form.get(longitude) latitude request.form.get(latitude) if not title or len(title) 64: return jsonify({code: 1, msg: 标题必填且不能超过64个字符}) if not price or float(price) 0: return jsonify({code: 1, msg: 价格有误}) if len(files) 0: return jsonify({code: 1, msg: 至少上传一张图片}) # 保存图片到本地 uploads 目录生成唯一文件名 img_urls [] for f in files: ext os.path.splitext(f.filename)[1].lower() filename f{uuid.uuid4().hex}{ext} save_path os.path.join(app.config[UPLOAD_DIR], filename) f.save(save_path) img_urls.append(f/static/uploads/{filename}) goods Goods( user_iduser_id, titletitle, priceprice, citycity, districtdistrict, longitudelongitude, latitudelatitude, status1 ) db.session.add(goods) db.session.flush() # 拿到 goods.id for url in img_urls: db.session.add(GoodsImage(goods_idgoods.id, image_urlurl)) db.session.commit() return jsonify({code: 0, msg: 发布成功, data: {goods_id: goods.id}})注意多图片上传时前端字段名是fileFlask 用request.files.getlist(file)一次性全取出来。图片名用 uuid 生成避免中文文件名和重名问题。生产环境图片不能存本地磁盘要存对象存储腾讯云 COS / 阿里云 OSS但接口逻辑是一样的只是把f.save()换成上传到云存储。4.3 商品列表与附近筛选经纬度范围计算同城二手交易系统最核心的筛选就是 附近的商品。小程序端用wx.getLocation拿用户经纬度传给后端。后端收到经纬度后不能直接对全表跑一遍球面距离公式——那样数据量大了会很慢。我的做法是先粗筛再精算先圈一个经纬度矩形范围大约 1 度的范围相当于 111 公里左右SQL 在这个范围内过滤再用 Haversine 公式在 Python 里精算出距离并排序。from math import radians, sin, cos, asin, sqrt def haversine(lat1, lon1, lat2, lon2): 计算两个经纬度之间的球面距离单位公里 R 6371.0 dlat radians(lat2 - lat1) dlon radians(lon2 - lon1) a sin(dlat / 2) ** 2 cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2 return 2 * R * asin(sqrt(a)) goods_api.route(/list, methods[GET]) def goods_list(): page int(request.args.get(page, 1)) size int(request.args.get(size, 10)) city request.args.get(city, ) keyword request.args.get(keyword, ) lat request.args.get(lat, typefloat) lon request.args.get(lon, typefloat) query Goods.query.filter(Goods.status 1) if city: query query.filter(Goods.city city) if keyword: query query.filter(Goods.title.like(f%{keyword}%)) if lat and lon: # 粗筛经纬度偏移约 0.5 度的矩形范围 query query.filter( Goods.latitude.between(lat - 0.5, lat 0.5), Goods.longitude.between(lon - 0.5, lon 0.5) ) total query.count() goods query.order_by(Goods.create_time.desc()).offset((page - 1) * size).limit(size).all() items [] for g in goods: item g.to_dict() if lat and lon: item[distance] round(haversine(lat, lon, g.latitude, g.longitude), 2) items.append(item) return jsonify({code: 0, data: {items: items, total: total, page: page}})粗筛和精算结合的方式在几万条商品量的前提下完全够用。如果以后数据量上来了再用 PostGIS 或者 Geohash 做更高效的地理索引。不过对同城交易这种业务商用场景也不会一开始就有百万级商品别过度设计。4.4 下单流程一单只能抢一件买家看中商品后点立即购买后端创建订单。这个接口有几个关键点商品必须存在且 status 为 1上架中买家不能买自己的商品同一商品不能已有未完成订单。from sqlalchemy.exc import IntegrityError order_api.route(/create, methods[POST]) login_required def create_order(): data request.get_json() goods_id data.get(goods_id) buyer_id g.user_id goods Goods.query.get(goods_id) if not goods or goods.status ! 1: return jsonify({code: 1, msg: 商品不存在或已下架}) if goods.user_id buyer_id: return jsonify({code: 1, msg: 不能购买自己的商品}) order_no generate_order_no(buyer_id) order Order( order_noorder_no, goods_idgoods.id, seller_idgoods.user_id, buyer_idbuyer_id, amountgoods.price, status1 ) # 事务创建订单 把商品改成“交易中” try: goods.status 2 db.session.add(order) db.session.commit() except IntegrityError: db.session.rollback() return jsonify({code: 1, msg: 该商品已被下单}) return jsonify({code: 0, data: {order_no: order.order_no, amount: str(order.amount)}})IntegrityError就是靠order表里uk_goods的唯一约束兜底如果两个请求同时进来数据库只让一个成功。这样即使代码里有并发漏洞数据库也能拦住。5. 小程序端对接页面结构、请求封装与关键交互代码5.1 小程序项目基础结构小程序工程我按微信原生语法组织没上 uni-app 这类跨端框架。原因很简单这个项目只跑在微信里用原生能少一层编译调试也更直接页面栈、生命周期、API 都是第一手的遇到问题搜索时匹配度也高。miniprogram/ ├── app.js # 全局逻辑启动时静默登录 ├── app.json # 页面注册、tabBar 配置 ├── app.wxss # 全局样式 ├── utils/ │ ├── request.js # wx.request 封装统一带 token │ └── util.js # 格式化时间、价格等工具函数 ├── pages/ │ ├── index/ # 首页搜索、分类、商品列表 │ ├── publish/ # 发布选图、填信息 │ ├── detail/ # 商品详情 下单按钮 │ ├── order/ # 订单列表 │ └── mine/ # 我的用户信息、我发布的、我买到的 └── components/ # 自定义组件分类标签等app.json里的 tabBar 配置四个入口首页、发布、订单、我的。发布页放在 tabBar 中间有个好处是入口路径短用户随手就能点进去发东西。5.2 请求封装所有请求自动带 token这个文件可以说是小程序前端最值得写好的一个文件。没有它每个页面里都要重复写wx.request的 header 和错误处理代码会变得又臭又长。// utils/request.js const BASE_URL https://api.yourdomain.com; // 上线后用正式域名 function request(path, method GET, data {}) { return new Promise((resolve, reject) { wx.request({ url: BASE_URL path, method, data, header: { Content-Type: application/json, Authorization: wx.getStorageSync(token) || }, success(res) { if (res.statusCode 200 res.data.code 0) { resolve(res.data.data); } else if (res.data.code 401) { // token 过期跳转登录流程 wx.removeStorageSync(token); wx.navigateTo({ url: /pages/mine/mine }); reject(res.data); } else { wx.showToast({ title: res.data.msg || 请求失败, icon: none }); reject(res.data); } }, fail(err) { wx.showToast({ title: 网络异常, icon: none }); reject(err); } }); }); } module.exports { request, BASE_URL };统一封装之后每个页面的请求代码就非常干净了。比如首页拿列表const { request } require(../../utils/request); Page({ data: { goodsList: [], page: 1, hasMore: true, isLoading: false }, onLoad() { this.loadGoods(); }, loadGoods() { if (this.data.isLoading || !this.data.hasMore) return; this.setData({ isLoading: true }); request(/api/goods/list?page${this.data.page}size10).then(data { this.setData({ goodsList: this.data.goodsList.concat(data.items), page: this.data.page 1, hasMore: this.data.items.length 0 }); }).finally(() { this.setData({ isLoading: false }); }); }, onReachBottom() { this.loadGoods(); } });onReachBottom是页面滚动到底部时自动触发配合isLoading和hasMore两个标志位去做触底加载就是热词里说的小程序页面列表加载更多的正确做法。注意每次加载完必须把isLoading复位否则上拉手势会失效。5.3 发布页wx.chooseMedia 选图 wx.uploadFile 上传发布页是小程序端交互最复杂的页面。用户选图、填标题、选分类、选成色、填价格、选城市、定位这一套下来有很多细节。const { request } require(../../utils/request); Page({ data: { images: [], title: , price: , categoryIndex: 0, conditionIndex: 3, city: , latitude: , longitude: }, onLoad() { // 进入页面就尝试拿定位和城市 wx.getLocation({ type: gcj02, success: (res) { this.setData({ latitude: res.latitude, longitude: res.longitude }); } }); }, chooseImage() { wx.chooseMedia({ count: 9 - this.data.images.length, mediaType: [image], sizeType: [compressed], // 必须用 compressed不然图片太大上传容易失败 success: (res) { const newImages this.data.images.concat(res.tempFiles.map(f f.tempFilePath)); this.setData({ images: newImages.slice(0, 9) }); } }); }, uploadImages() { // 按顺序上传每张图片得到 URL 列表 const tasks this.data.images.map((imgPath, index) { return new Promise((resolve, reject) { wx.uploadFile({ url: ${BASE_URL}/api/goods/upload, filePath: imgPath, name: file, success(res) { const data JSON.parse(res.data); if (data.code 0) { resolve(data.data.url); } else { reject(new Error(上传失败)); } }, fail: reject }); }); }); return Promise.all(tasks); }, submit() { if (!this.data.images.length) { wx.showToast({ title: 请先上传图片, icon: none }); return; } wx.showLoading({ title: 发布中... }); this.uploadImages().then(urls { return request(/api/goods/publish, POST, { title: this.data.title, price: this.data.price, city: this.data.city, latitude: this.data.latitude, longitude: this.data.longitude, image_urls: urls }); }).then(() { wx.hideLoading(); wx.showToast({ title: 发布成功, icon: success }); setTimeout(() wx.switchTab({ url: /pages/index/index }), 1000); }).catch(err { wx.hideLoading(); wx.showToast({ title: 发布失败, icon: none }); }); } });这里最关键的是sizeType: [compressed]。微信相册里的原图动辄 3-5MB如果不压缩上传慢且容易失败服务器还得限制文件大小用户体验非常差。压缩后的图一般一两百 KB首屏加载也快很多。5.4 登录态管理wx.login 静默登录 手机号授权小程序启动时在app.js的onLaunch里做静默登录。所谓静默登录就是用wx.login拿 code 去后端换 token整个过程用户无感知// app.js App({ onLaunch() { this.silentLogin(); }, silentLogin() { return new Promise((resolve) { wx.login({ success: (res) { wx.request({ url: ${BASE_URL}/api/user/login, method: POST, data: { code: res.code }, success: (resp) { if (resp.data.code 0) { wx.setStorageSync(token, resp.data.data.token); } resolve(); }, fail: () resolve() }); }, fail: () resolve() }); }); } });注意wx.login里不能用之前封装的 request因为这个时候还没有 token而且 request 封装里对 401 的处理会陷入死循环。手机号获取是另一个独立的流程。要放在一个button open-typegetPhoneNumber的回调里拿到的detail.code也是临时 code要传给后端去调微信接口换手机号button open-typegetPhoneNumber bindgetphonenumberonGetPhone绑定手机号/buttononGetPhone(e) { if (e.detail.code) { request(/api/user/bind_phone, POST, { code: e.detail.code }) .then(() wx.showToast({ title: 绑定成功, icon: success })); } }手机号绑定适合放在用户要下单或发布时做引导不要在首页一进来就强制否则转化率太难看。5.5 页面细节导航栏高度、动态标题、单选分类几个小但重要的点自定义导航栏高度如果小程序用了自定义导航栏navigationStyle: custom顶部状态栏高度要自己用wx.getWindowInfo()取statusBarHeight否则内容会被状态栏盖住。我的做法是做个.nav-bar组件内部用padding-top: statusBarHeight 44rpx撑开。动态设置标题商品详情页进入时用wx.setNavigationBarTitle({ title: 商品标题 })把页面标题改成当前商品名比统一叫商品详情体验好得多。分类选择发布页成色这类枚举值我用原生picker组件range绑定一个数组即可实现简单也不用引第三方组件库。单选框需要单选分组的地方比如筛选离我最近/最新发布可以用radio-group但如果是 2-3 个短选项我建议直接用按钮高亮状态切换交互更轻。6. 上线部署Gunicorn、Nginx、Supervisor 和 HTTPS 域名这一套6.1 本地能跑 ≠ 能上线开发服务器和 WSGI 服务器的差异很多同学在本地用python run.py启动 Flask看到* Running on http://127.0.0.1:5000就以为可以上线了。这是最危险的一个认知。Flask 自带的开发服务器是单进程的只能用于本地调试不支持并发直接暴露到公网分分钟卡死。上线必须换成生产级 WSGI 服务器我用的是 Gunicorn。它是 Python 写的安装简单配合 Nginx 反向代理是目前 Flask 部署最主流的方案。启动命令gunicorn -w 2 -b 127.0.0.1:8000 run:app-w 2是启动 2 个 worker 进程run:app表示从run.py里导入名为app的 Flask 实例。2 个 worker 对这个项目足够worker 数不是越多越好每个 worker 都会占内存而且 Python 的 GIL 决定了多 worker 才真正利用多核但 worker 间如果要共享内存状态就会出问题。所以任何需要共享的东西token、验证码、计数一律放 Redis别放进程内存。6.2 Supervisor 守护进程Gunicorn 进程如果崩了服务器上没有人盯着的话服务就挂了。用 Supervisor 做守护进程退出后自动拉起。Supervisor 配置[program:secondhand] command/root/.virtualenvs/secondhand/bin/gunicorn -w 2 -b 127.0.0.1:8000 run:app directory/data/www/flask-secondhand autostarttrue autorestarttrue redirect_stderrtrue stdout_logfile/var/log/secondhand/stdout.log stderr_logfile/var/log/secondhand/stderr.logsupervisorctl reread supervisorctl update supervisorctl status secondhand做完这一步你就不用担心 Gunicorn 进程挂掉没人管了。6.3 Nginx 反向代理 HTTPS小程序官方要求所有wx.request的域名必须是 HTTPS并且要在小程序后台配置为request 合法域名和uploadFile 合法域名。没有 HTTPS小程序真机一调接口就报url not in domain list或直接请求失败。Nginx 配置server { listen 443 ssl; server_name api.yourdomain.com; ssl_certificate /etc/nginx/ssl/yourdomain.pem; ssl_certificate_key /etc/nginx/ssl/yourdomain.key; client_max_body_size 20m; # 允许上传大图片默认1m太小 location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }client_max_body_size 20m这行必须加。Nginx 默认只允许请求体 1MB你发布商品一次传 4 张压缩图可能就超了会直接给你返回 413 Request Entity Too Large小程序端表现为上传失败。另外小程序后台的服务器域名配置里request 合法域名填https://api.yourdomain.comuploadFile 合法域名也填同一个域名如果图片上传接口和业务接口同源。这个域名必须有 ICP 备案否则微信连配置都不让你填。开发阶段可以先在微信开发者工具里勾选不校验合法域名、web-view业务域名等配置但真机预览时这个选项不生效必须走后端配合。6.4 本地联调没有服务器时怎么边开发边调试开发阶段没有公网服务器时小程序没法直接请求你电脑上的127.0.0.1:5000。我的做法是两个方案同网段真机调试手机和电脑连同一个 WiFi小程序请求地址改成电脑的局域网 IP比如http://192.168.31.100:5000同时开发者工具和真机都开启不校验合法域名。这个方案零成本适合纯本地联调。用内网穿透工具临时开一个公网 HTTPS 地址映射到本机 5000 端口让不在同一 WiFi 的手机也能访问。这个适合给客户或朋友演示用但只适合临时使用上线前一定要撤掉。7. 调试实录我在实际开发中踩过的坑和解决办法7.1 微信登录 code 只能用一次还容易踩到缓存有个问题很隐蔽小程序端wx.login拿到的 code 如果用了一次第二次再拿同一个 code 去请求微信会直接报invalid code。我在联调时遇到过原因是小程序app.js的silentLogin和后端登录接口之间有个中间环节把 code 缓存了结果重复提交。这提醒我code 必须在调用微信接口那一刻实时生成、立即使用绝对不要缓存。顺带一提如果后端逻辑是前端每次启动都静默登录换 token那用户每次冷启动都会调一次微信接口。这个量不大可以接受但如果想更省可以做成本地有 token 且没过期就直接用没有才走 wx.login。7.2 Python 的坑requests 请求微信接口必须设 timeout微信的jscode2session接口偶尔会抽风如果不设timeoutPython 的 requests 会一直等下去用户就被卡在登录页了。我所有外部请求都习惯性加timeout5配合 try-except 兜底微信接口超时就返回提示登录超时请重试而不是让整个请求挂死。7.3 小程序端 setData 的性能陷阱列表页如果一次 setData 塞几百条商品数据在小程序里会出现明显的卡顿。我的优化是列表一次只加载 10 条配合触底加载。另一个优化是商品图片列表不要用大数组直接 setData而是分批渲染。小程序 setData 是同步通信到视图层的一次数据量越大越卡这个跟 Web 端的 DOM 渲染直觉不一样要专门留意。7.4 Flask 的 JSON 返回datetime 不是天然可序列化的Flask 的jsonify能序列化 dict、list、str、int但序列化不了 datetime 对象。商品模型里的create_time是 datetime直接放进 dict 返回会报TypeError: Object of type datetime is not JSON serializable。我的做法是模型里写一个to_dict方法把时间格式化好再返回class Goods(db.Model): __tablename__ goods # ...字段定义... def to_dict(self): return { id: self.id, title: self.title, price: str(self.price), # Decimal 也要转 str否则同样不能序列化 condition_level: self.condition_level, city: self.city, district: self.district, view_count: self.view_count, create_time: self.create_time.strftime(%Y-%m-%d %H:%M:%S) }看见没Decimal类型也不能直接序列化必须先str()。这种小问题积累多了就是为什么别人能跑我不能跑的根源。7.5 商品列表的 N1 查询问题列表页每个商品要显示卖家昵称和头像如果先在循环里查商品再在循环体里User.query.get(goods.user_id)那 10 条商品就要查 11 次数据库。这就是经典的 N1 问题。用 SQLAlchemy 的话查询商品时直接 join 用户表并且用only指定字段避免把 description 这种大字段拖进列表查询goods (db.session.query(Goods, User.nickname, User.avatar_url) .join(User, User.id Goods.user_id) .filter(Goods.status 1) .offset((page - 1) * size) .limit(size) .all())这样一条 SQL 全查出来。数据量大了之后性能差异会非常明显。7.6 用户授权定位被拒必须有兜底小程序wx.getLocation如果用户点了拒绝授权success回调不执行fail会执行你的经纬度就是空。如果不做兜底发布的商品就没有位置信息同城筛选就筛不到。我的兜底方案是发布页在拿不到定位时弹出一个城市选择器让用户手动选城市至少保证城市字段有值。同时在小程序后台配置好app.json里permission的描述文案说明定位用途能提高用户授权通过率。7.7 测试阶段花式掉链子的 token 过期问题用户如果用你的 token 调接口后端返回 401小程序收到后应该跳回登录页重新走静默登录而不是给用户弹一个登录过期然后就卡住。前端 request.js 里我专门加了 401 处理分支拿到 401 先删本地 token再走一次silentLogin拿新 token然后重新请求一次原接口。这个体验优化看起来很细节但在内测阶段用户天天会遇到处理不好会被追着骂。最后再说一点个人的体会这类交易系统功能上最核心的不是炫技的算法而是把登录、发商品、看列表、下单这条主链路做到极少出错。我在开发时始终按这个顺序推进先跑通登录和商品列表再补发布最后做订单闭环。你如果也是从零开始做建议跟我一样先把链路走通再加边角功能否则很容易在无关紧要的页面上耗掉大量时间最后主链路反而一堆问题。