
前阵子接手了一个挺有意思的项目内部代号叫 htgs84d4基于微信小程序的旧衣回收商品系统。需求方是一家做本地环保回收的小团队他们之前的回收业务全靠在微信群里手动登记用户约时间、师傅上门称重、积分记录、兑换商品全靠一张Excel表和群公告撑起来。一旦单量超过两位数账就对不上用户也经常在群里问我的积分到账了吗这个商品还能换吗运营同学被这种问题磨得没脾气。这个项目说白了就是把这套手工流程产品化用户在小程序里提交旧衣回收预约师傅上门取走衣物系统按重量规格给积分积分在商城兑换商品后台还有一套管理界面处理订单、分类、商品上下架。技术栈选的是原生微信小程序加 Django REST Framework前后端完全分离。如果你正好想找一个练手级的全栈项目或者公司真有旧衣回收、再生资源回收这类业务这篇文章应该能给你省不少弯路。1. 项目全貌这套系统到底解决了什么问题1.1 旧衣回收里最常见的四个业务痛点先聊聊业务侧。旧衣回收和普通电商完全不是一回事乍一看都是用户下单、平台发货/收货但细节差距非常大。第一个痛点是信息不透明。用户把旧衣交给回收员之后心理预期是至少知道我的衣服去哪了、能换多少积分。但传统流程里衣物被收走以后就进入了黑盒用户只能被动等通知甚至有些小回收点根本不通知用户觉得自己被白嫖了。这种信任危机直接导致复投率极低。第二个痛点是分类定价标准不统一。旧衣不是标准化商品羽绒服和T恤、童装和成人装的回收价值差很多。如果全靠人眼判断不同回收员给出的积分不一样线下还好解释线上用户一对比就会觉得平台黑心。第三个痛点是积分和商品兑换的账目管理混乱。人工记账时期积分错漏是家常便饭用户投诉无门运营核账又费时费力。第四个痛点是订单状态没法追踪。用户想知道师傅几点来、衣物是否在质检、积分什么时候到账在微信群模式下这些需求全部无法满足。所以这套系统的核心并不只是做一个电商小程序而是要把回收履约链路线上化。系统设计上要同时满足用户端、回收师傅端、运营后台三方的诉求我这边最终落地的方案是用户小程序端负责预约和查询后台用 Django Admin 承担订单审核和商品管理回收员上门后通过后台或接口更新状态全程数据闭环。1.2 技术选型为什么是微信小程序加 Django技术选型这块我和需求方做过好几轮沟通。前端选微信小程序几乎是必然的。旧衣回收的目标用户覆盖全年龄段其中相当一部分是中老年用户让他们下载一个独立 App 去预约回收门槛太高。微信小程序即用即走还能通过微信支付、订阅消息做服务触达对这类本地生活服务场景非常合适。后端选 Django核心原因是开发效率和内置后台。我是团队里唯一的后端开发业务方还需要运营后台来管理订单和商品Django 自带的 Admin 后台像作弊器一样配置好 Model 之后运营人员直接就能登录录入商品、审核订单不用我再额外写一套管理前端。加上 Django ORM、迁移工具、DRF 的序列化器整个 CRUD 开发速度非常快大概一周就能把主体接口全部打通。数据库用的 MySQL 8虽然 SQLite 本地调试更方便但生产环境的并发写入和事务支持还是 MySQL 更稳。整个部署链路是微信小程序 - Nginx - uWSGI - Django - MySQL图片和静态文件直接走对象存储避免应用服务器扛流量。这里多说一句如果你是自己学习或者做毕设完全可以不用服务器直接把 Django 跑在本地小程序开发者工具里勾选不校验合法域名后端地址填局域网 IP 就能联调成本几乎为零。但真正上生产环境HTTPS 证书、备案域名、小程序后台合法域名配置每一项都得提前准备好后面我会细讲。2. 数据建模表不在多够用且不挖坑就行2.1 核心数据表与模型代码这套系统数据模型不算复杂核心就五张表用户表、衣物分类表、商品表、回收订单表、积分明细表。轮播图、地址薄这些属于锦上添花第一版可以不做后面再补。用户表不搞复杂的注册逻辑小程序的 openid 就是天然主键。# models/user.py from django.db import models class User(models.Model): openid models.CharField(max_length64, uniqueTrue, verbose_name微信openid) nickname models.CharField(max_length32, blankTrue, verbose_name昵称) avatar models.URLField(blankTrue, verbose_name头像) phone models.CharField(max_length11, blankTrue, verbose_name手机号) address models.CharField(max_length128, blankTrue, verbose_name默认上门地址) points models.IntegerField(default0, verbose_name当前积分) created_at models.DateTimeField(auto_now_addTrue, verbose_name注册时间) class Meta: db_table user这里有个细节积分字段直接冗余在用户表里。从范式角度可以拆成积分账户表但实际业务中用户积分查询频率极高而积分变动又只集中在回收完成这一个环节冗余字段配合事务就能搞定没必要为了范式增加复杂度。衣物分类表是旧衣回收系统里最有行业特色的表。回收定价和积分计算都跟分类强相关class Category(models.Model): name models.CharField(max_length32, verbose_name分类名称) unit_price models.DecimalField(max_digits6, decimal_places2, default0, verbose_name每公斤积分单价) description models.TextField(blankTrue, verbose_name分类说明) icon models.URLField(blankTrue, verbose_name分类图标) is_active models.BooleanField(defaultTrue, verbose_name是否启用)比如羽绒服/棉服单价 50 积分/公斤、夏装T恤单价 20 积分/公斤这个单价就是用户端的计价依据。注意 unit_price 用 DecimalField 而不用 FloatField浮点数计算会出现 0.1 0.2 不等于 0.3 的问题涉及积分和钱必须用高精度类型。商品表就是标准的商城商品模型但加了一个来源字段标识商品是回收翻新还是积分兑换专用class Product(models.Model): title models.CharField(max_length64, verbose_name商品标题) description models.TextField(verbose_name商品详情) price models.DecimalField(max_digits8, decimal_places2, verbose_name积分价格) images models.TextField(verbose_name图片URL列表JSON格式) category models.ForeignKey(Category, on_deletemodels.CASCADE, verbose_name所属分类, nullTrue, blankTrue) source models.SmallIntegerField(choices[(0, 回收翻新), (1, 积分兑换)], default1, verbose_name商品来源) stock models.IntegerField(default0, verbose_name库存) status models.SmallIntegerField(choices[(0, 上架中), (1, 已下架), (2, 已售完)], default0, verbose_name状态) created_at models.DateTimeField(auto_now_addTrue, verbose_name创建时间)回收订单表是最核心的一张表它记录了整个履约链路class RecycleOrder(models.Model): user models.ForeignKey(User, on_deletemodels.CASCADE, verbose_name下单用户) category models.ForeignKey(Category, on_deletemodels.CASCADE, verbose_name回收分类) estimated_weight models.DecimalField(max_digits5, decimal_places2, default0, verbose_name预估重量(kg)) actual_weight models.DecimalField(max_digits5, decimal_places2, nullTrue, blankTrue, verbose_name实收重量(kg)) pickup_time models.DateTimeField(verbose_name预约上门时间) address models.CharField(max_length128, verbose_name上门地址) status models.SmallIntegerField(choices[ (0, 待上门), (1, 已收取), (2, 质检中), (3, 已完成), (4, 已取消) ], default0, verbose_name订单状态) points_awarded models.IntegerField(default0, verbose_name实际发放积分) remark models.CharField(max_length255, blankTrue, verbose_name用户备注) created_at models.DateTimeField(auto_now_addTrue, verbose_name下单时间)2.2 订单状态机把信任感设计进流程里旧衣回收订单的状态设计我的体会是一定要把质检中这个状态单独拎出来。回收员上门取走衣物之后衣物要先送到分拣中心做质检确认没有混入不可回收物再称重计算积分。这个质检环节是保护平台利益的关键如果省略掉用户随便塞一堆杂物也能拿积分平台很快就赔穿。但质检需要时间用户下单后如果长时间看到订单停在已收取又会觉得平台拖着不给积分。所以我在订单状态流转上加了一条消息推送逻辑每次状态变更都通过微信订阅消息通知用户其中质检中转已完成的时候在接口里同时发放积分并生成积分明细。订单状态流转是系统的业务命脉我通过 DRF 的 action 接口来控制流转而不是让前端随意改 status 字段# serializers/recycle_order.py from rest_framework.decorators import action from rest_framework.response import Response action(detailTrue, methods[post]) def complete(self, request, pkNone): 回收员或管理员调用完成质检并发放积分 order self.get_object() if order.status ! 2: return Response({detail: 当前状态不可完成}, status400) # 积分发放必须在事务里执行防止并发重复发放 from django.db import transaction with transaction.atomic(): order.lock True # select_for_update 加行锁 order.status 3 order.actual_weight request.data.get(actual_weight, order.estimated_weight) awarded int(order.actual_weight * order.category.unit_price) order.points_awarded awarded order.save(update_fields[status, actual_weight, points_awarded]) user request.user user.points awarded user.save(update_fields[points]) # 写入积分流水 from .models import PointsLog PointsLog.objects.create( useruser, orderorder, pointsawarded, change_typerecycle_income ) return Response({points_awarded: awarded})这个接口里我用了 transaction.atomic()并且在查询订单时加行锁目的就是防并发。实际运营中确实出现过重复点击导致积分发两次的情况代码层面的保险是必须的。3. 后端实现Django REST Framework 从零到能跑3.1 项目初始化与关键配置后端项目我用的是 Django 4.x 加 Django REST Framework建项目这些基础操作就不啰嗦了重点说几个容易踩坑的配置。第一个是跨域问题。小程序端请求后端接口属于跨域必须在 Django 里配置 CORS。我用的是 django-cors-headers# settings.py INSTALLED_APPS [ # ... rest_framework, corsheaders, ] MIDDLEWARE [ corsheaders.middleware.CorsMiddleware, # 注意 CorsMiddleware 要放在 CommonMiddleware 前面 ] CORS_ALLOWED_ORIGINS [ http://localhost:3000, # 本地调试 ] CORS_ALLOW_CREDENTIALS True我在本地开发时为了方便直接用了CORS_ALLOW_ALL_ORIGINS True但上线前必须改回白名单模式否则任何网站都能调用你的接口安全隐患很大。第二个是数据库连接。Django 默认连 SQLite生产换 MySQL 只要改 DATABASES 配置同时注意 MySQL 的字符集要设置成 utf8mb4DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: recycle_db, USER: recycle_user, PASSWORD: ********, HOST: 127.0.0.1, PORT: 3306, OPTIONS: { charset: utf8mb4, }, } }utf8mb4 这个必须强调用户微信昵称经常带 emoji 表情你用普通 utf8 存进去直接报错别问我怎么知道的。第三个是 DRF 的分页配置。商品列表、订单列表都需要分页我在 settings.py 里统一配置REST_FRAMEWORK { DEFAULT_PAGINATION_CLASS: rest_framework.pagination.PageNumberPagination, PAGE_SIZE: 10, DEFAULT_AUTHENTICATION_CLASSES: [ apps.users.authentication.WxTokenAuthentication, ], }分页统一了前端只需要传 page 参数就能翻页不需要每个 viewset 单独写。3.2 微信登录与自定义 token 鉴权微信小程序登录流程是固定的前端 wx.login 拿 code后端拿 code 向微信接口换 openid然后给前端签发一个登录态 token。这里有个技术选择问题。Django 自带的 session 机制在前后端分离场景下不太好用我选择了自己签发 token 字符串存到数据库里每次请求时校验。简单粗暴胜在可控。核心代码我封装成了一个独立的认证类# apps/users/authentication.py import hashlib import requests from rest_framework.authentication import BaseAuthentication from rest_framework.exceptions import AuthenticationFailed from django.conf import settings def wx_code_to_openid(code): 调用微信接口换取 openid url https://api.weixin.qq.com/sns/jscode2session params { appid: settings.WX_APPID, secret: settings.WX_SECRET, js_code: code, grant_type: authorization_code, } resp requests.get(url, paramsparams, timeout5).json() if openid not in resp: raise AuthenticationFailed(微信登录失败: %s % resp.get(errmsg)) return resp[openid] def generate_token(openid): raw f{openid}-{settings.SECRET_KEY} return hashlib.sha256(raw.encode()).hexdigest() class WxTokenAuthentication(BaseAuthentication): def authenticate(self, request): token request.headers.get(Authorization, ) if token.startswith(Bearer ): token token[7:] # 查 token 表找不到抛异常 from .models import UserToken try: token_obj UserToken.objects.select_related(user).get(tokentoken) except UserToken.DoesNotExist: raise AuthenticationFailed(无效的登录凭证) return (token_obj.user, token_obj)登录接口长这样# apps/users/views.py from rest_framework.views import APIView from rest_framework.response import Response from .models import User, UserToken class WxLoginView(APIView): authentication_classes [] # 登录接口不鉴权 permission_classes [] def post(self, request): code request.data.get(code) openid wx_code_to_openid(code) user, _ User.objects.get_or_create(openidopenid) token generate_token(openid) # token 存库带过期时间 UserToken.objects.update_or_create( useruser, defaults{token: token, expired_at: ...} ) return Response({token: token, user_id: user.id})注意登录接口必须设置authentication_classes []否则 DRF 默认全局鉴权会把你这个接口也拦了。3.3 回收预约和商品接口的落地细节商品列表接口是一个标准的 ViewSet但有几个地方要注意。旧衣回收商城里的商品并不是所有都显示给所有用户比如回收翻新类商品库存很不稳定经常要下架。我用 status 过滤加上缓存减少数据库压力# apps/products/views.py from rest_framework import viewsets from rest_framework.decorators import action from .models import Product from .serializers import ProductSerializer class ProductViewSet(viewsets.ReadOnlyModelViewSet): 商品只读接口用户端只能看不能改 serializer_class ProductSerializer permission_classes [] # 公开商品信息 def get_queryset(self): qs Product.objects.filter(status0) cat self.request.query_params.get(category) if cat: qs qs.filter(category_idcat) return qs回收预约接口是最核心的下单接口。前端提交的数据包含分类、预估重量、上门时间、地址后端需要做几件事校验时间不能是过去时间、校验地址非空、校验同一用户不能同时有多个待上门订单。同用户不能有多个待上门订单这个限制很多新手想不到。如果不加限制用户一气儿下五个单回收员跑一趟扑空四次运营成本直接爆炸。我在下单接口里做了一行判断# apps/recycle/views.py class RecycleOrderCreateView(APIView): def post(self, request): data request.data # 同一个用户只能有一个进行中的预约 pending RecycleOrder.objects.filter( userrequest.user, status__in[0, 1, 2] ).exists() if pending: return Response({detail: 您有正在进行中的预约}, status400) # 其他字段校验和创建...这个限制在实际运营中非常有用避免了大量无效上门。4. 小程序端从登录到下单的完整链路4.1 页面结构规划小程序端我采用了经典的 TabBar 五页面结构首页、回收预约、商城、订单、我的。微信小程序官方推荐 tabBar 是 2-5 个 tab五个刚好占满。app.json 配置{ pages: [ pages/index/index, pages/recycle/recycle, pages/mall/mall, pages/order/order, pages/profile/profile ], tabBar: { list: [ { pagePath: pages/index/index, text: 首页 }, { pagePath: pages/recycle/recycle, text: 回收 }, { pagePath: pages/mall/mall, text: 商城 }, { pagePath: pages/order/order, text: 订单 }, { pagePath: pages/profile/profile, text: 我的 } ] } }首页放公告、分类入口、绿色环保理念文案让用户一眼看懂平台是做什么的。回收预约页是核心交互页做成一整个表单从选择衣物分类、预估重量到选择上门时间段、填写地址。商城页面就是标准商品卡片流支持分类筛选。订单页展示用户所有回收订单每个订单卡片上显示状态和积分。我的页面展示积分余额、个人资料编辑、关于我们。4.2 登录态处理与 request 封装小程序端的登录态处理是整个前端的基建。我封装了一个 wob 接口所有 wx.request 都走这个封装自动带上 token// utils/request.js const BASE_URL https://api.yourdomain.com; function request(path, method GET, data {}) { return new Promise((resolve, reject) { const token wx.getStorageSync(token); wx.request({ url: ${BASE_URL}${path}, method, data, header: { Content-Type: application/json, Authorization: token ? Bearer ${token} : }, success(res) { if (res.statusCode 401) { // token 失效重新静默登录 wx.removeStorageSync(token); login().then(() { // 重新请求 resolve(request(path, method, data)); }); return; } if (res.statusCode 200 res.statusCode 300) { resolve(res.data); } else { wx.showToast({ title: res.data.detail || 请求失败, icon: none }); reject(res.data); } }, fail(err) { wx.showToast({ title: 网络异常, icon: none }); reject(err); } }); }); } function login() { return new Promise((resolve) { wx.login({ success(res) { wx.request({ url: ${BASE_URL}/api/user/login/, method: POST, data: { code: res.code }, success(loginRes) { wx.setStorageSync(token, loginRes.data.token); resolve(); } }); } }); }); } module.exports { request, login };这里有个细节login 之后重新请求原接口能保证用户无感知续期。实际运营中确实会出现 token 过期尤其是用户长期不打开小程序重新打开时如果直接让用户重新登录流失率会很高。首页加载商品列表的代码// pages/index/index.js const { request } require(../../utils/request); Page({ data: { categories: [], products: [], currentCategory: null, page: 1, loading: false }, onLoad() { this.fetchCategories(); this.fetchProducts(); }, fetchCategories() { request(/api/categories/).then((res) { this.setData({ categories: res.results }); }); }, fetchProducts() { const { page, currentCategory, loading, products } this.data; if (loading) return; this.setData({ loading: true }); let url /api/products/?page${page}; if (currentCategory) url category${currentCategory}; request(url).then((res) { this.setData({ products: [...products, ...res.results], page: page 1, loading: false }); }); } });4.3 回收预约表单一次完整的提交流程回收预约页提交表单时前端要做一轮基础校验减少无效请求。联系电话要正则校验上门时间要选未来时间。用户填完表单点提交时我先把数据拼装好调用后端接口// pages/recycle/recycle.js submitOrder() { const { categoryId, weight, pickupTime, address, phone, remark } this.data; if (!categoryId) return wx.showToast({ title: 请选择衣物分类, icon: none }); if (!weight || weight 0) return wx.showToast({ title: 请输入有效重量, icon: none }); if (!pickupTime) return wx.showToast({ title: 请选择上门时间, icon: none }); if (!/^1\d{10}$/.test(phone)) return wx.showToast({ title: 手机号格式错误, icon: none }); this.setData({ submitting: true }); request(/api/recycle/orders/, POST, { category_id: categoryId, estimated_weight: weight, pickup_time: pickupTime, address, phone, remark }) .then((res) { wx.showToast({ title: 预约成功, icon: success }); // 跳转到订单列表 wx.switchTab({ url: /pages/order/order }); }) .finally(() this.setData({ submitting: false })); }这里我要讲一个经验提交按钮一定要加 loading 状态防重复点击。小程序端用户网络不好时容易双击表单提交按钮后端虽然做了幂等控制但前端层面也不能放任。5. 常见问题与排查技巧实录5.1 一张表解决大部分运行期问题整个项目跑下来最有价值的经验全在坑里。我整理一个速查表都是真实踩过的问题现象根本原因解决方式小程序请求失败提示url not in domain list开发者工具里未勾选不校验合法域名或线上域名未配置本地开发在详情 - 本地设置勾选不校验线上在微信公众平台配置 request 合法域名Django 后台密码重置无效终端改了字符集但表还是旧字符集确认表和库都是 utf8mb4ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4商品图片显示裂图图片域名未加入 downloadFile 合法域名公众平台添加图片域名白名单开发时也需勾选不校验积分发放重复前端双击提交后端事务边界不对后端加事务和行锁接口做幂等处理用户昵称带表情导致入库报错数据库字符集不是 utf8mb4建库时就指定 utf8mb4连接配置再加 charset真机预览请求不到本地服务手机和电脑不在同一局域网或防火墙拦截保证同一 Wi-Fi电脑防火墙放行 8000 端口用局域网 IP 而非 localhostDEBUGFalse 时静态文件丢失Django 生产环境不处理静态文件用 Nginx 托管静态文件或配置 whitenoise 中间件5.2 实操经验这些坑不踩一遍不会信第一个坑是微信登录的 code 二次使用问题。wx.login 返回的 code 只能用一次换过 openid 之后立即失效。我开始把 wx.login 写在 onLaunch 里每次冷启动都调一次没问题。但后来加了token 过期自动重新 login的逻辑如果 login 和请求接口并发执行就会出现 code 被后端重复使用的情况第二个请求直接报invalid code。解决办法是把 login 封装成单例 Promise保证同一时间只有一个登录请求在跑。第二个坑是 Django 的 DateTimeField 时区问题。本地联调时录入的上门时间部署到云服务器后差了 8 个小时。这是 USE_TZ 的典型表现。我建议这类业务系统直接USE_TZ True同时TIME_ZONE Asia/Shanghai前端传时间字符串时带时区偏移后端存 UTC展示时转本地。如果嫌麻烦想用USE_TZ False必须确保服务器时区也是东八区否则排期信息直接乱掉。第三个坑是图片上传方案。旧衣回收的商品图片、分类图标都在后台录入如果直接用 Django Admin 的默认 FileField图片存在应用服务器上后续扩展和备份都麻烦。我最终把图片上传改成了对象存储直传前端拿到存储签名后直接传给对象存储后端只保存 URL 字符串。这个改造虽然费了点功夫但上线后效果非常好服务器磁盘压力为零。第四个坑是回收订单的取消逻辑。用户取消待上门订单时如果回收员已经出发了会造成体验事故。我加了一个时间判断预约上门时间前 2 小时内不允许取消只能联系客服人工关闭。这个规则看着简单但能避免大部分纠纷。第五个经验是关于业务扩展的。这套系统上线后业务方很快提出了新需求旧衣回收的同时想要附带旧书、旧手机回收。因为这个项目的数据模型把回收品类抽象成了 Category 表新品类只是加一条记录的事不需要改表结构。这个设计当初是无意中做对的但也说明了一个通用原则凡是存在扩展可能性的字段尽量做成字典表而不是写死枚举。6. 一些后续可做的优化方向最后再分享一个我的个人判断。如果你准备在旧衣回收这个方向继续深入有几个点值得优先考虑。第一是接入微信订阅消息推送。现在用户需要主动打开小程序才能看订单状态体验一般。微信订阅消息可以在状态变化时推送通知这个对回收类业务的用户粘性提升非常明显用户最关心的就是师傅什么时候到积分什么时候到账。第二是增加顺手回收品类。旧衣回收只是废弃资源回收的冰山一角图书、家电、手机、塑料瓶都可以同构复用到这套预约上门回收的流程里。我刚说过 Category 表已经预留了扩展空间后端几乎不用改。第三是回收员端的小程序。目前回收员是通过后台账号操作订单状态但回收员在外面跑手机后台操作不方便。后续可以给回收员做一个独立小程序只做接单、状态变更、称重记录三个功能用户体验会好非常多。这套系统从开发到上线最大的感悟是代码并不难难的是把业务流程里的每个角色都服务好。用户要的是省心和信任运营要的是效率和可控回收员要的是简单和稳定。作为一个全栈开发者时刻想着这三方需求设计出来的系统才能真的可用。如果你也准备做类似的项目建议先花两天时间把业务方拉到一起把回收流程的每一个环节画清楚再动手写代码。磨刀不误砍柴工这句话在这个项目里是实打实的真理。