社区团购小程序开发复盘:Python + Django 从零到上线全记录
最近刚完成了一个社区团购微信小程序的从零开发到上线,技术栈选了 Python + Django + 微信小程序原生框架。整套东西做下来,踩了不少坑,也沉淀了不少经验,趁着热乎劲儿把完整过程做个复盘,从业务设计、数据库建模、后端接口开发,到小程序端实现、支付对接、生产部署,再到线上问题的排查,全都过一遍。
如果你正在准备做一个微信小程序电商类项目,或者想了解 Django 在实际项目里怎么组织代码、怎么跟小程序优雅配合,这篇内容应该能帮你省不少时间。我会尽可能把关键操作用大白话讲透,参数怎么定、代码怎么组织、坑在哪里,都说清楚。
1. 项目到底要解决什么问题
1.1 社区团购的业务本质拆解
社区团购听起来是个流行词,但拆开看,业务模型其实很清晰。它本质上是一种“预售 + 集中配送 + 自提”的零售模式:平台或团长提前一天发布商品,用户下单,平台统一采购或配货,第二天把货送到小区自提点,用户自己过去取。
这个模式和普通电商最大的区别在于三个地方:
第一,履约方式不同。普通电商是仓库发货、快递到家,社区团购是批量配送到自提点、用户自取。这意味着系统里必须有“自提点”这个实体,而且用户下单时要选择自提点,平台发货时按自提点聚合订单。
第二,商品管理方式不同。社区团购的商品往往有“当天可售、隔天清仓”的特点,商品上架时间很短,而且经常有“限量”“秒杀”性质的活动。所以商品表需要一个日期维度,比如商品属于哪一天的活动,而不是永久有效。
第三,信任关系不同。社区团购高度依赖团长这个角色,团长既是小区的推广者,也是自提点的经营者,还承担了一部分售后服务的职责。系统里团长和自提点应该是一对一绑定,而且订单归属需要记录到团长维度。
所以做系统设计之前,先别急着写代码,把这几条业务逻辑理清楚,后面所有表结构、接口设计、前端页面都会围绕这几条线展开。
1.2 为什么选 Django 而不是其他框架
选 Django 的原因很实际。
项目要求是 Python,Python 生态里做 Web 后端的主流选择无非是 Django、Flask、FastAPI。Flask 灵活但东西都要自己搭,FastAPI 适合接口纯后端项目,而 Django 自带 Admin 后台、ORM、迁移工具、认证系统,对“需要运营后台管理商品和订单”的团购项目来说,真的太合适了。
Django Admin 可以直接当运营后台用,商品管理、订单查看、团长审核这些操作,开发阶段甚至可以直接在 Admin 里完成,不需要额外开发一套后台管理界面。对于小团队或者个人开发者来说,这能省出至少两周的开发量。
还有一点是 Django ORM 的迁移机制非常成熟。小程序端和后端接口的开发往往是同步推进的,字段说加就加、说改就改,python manage.py makemigrations 加 migrate 两步走,数据库同步非常方便。用原生 SQL 的话,这种频繁改动会很痛苦。
另外选 Django 其实还考虑了后续扩展的余地。社区团购跑起来之后,大概率会加积分系统、优惠券、会员等级、分销返利这些功能。Django 的 APP 模块化结构天然适合这种渐进式扩展,一个功能一个 APP,互不干扰。
1.3 整体功能模块的划分
开始写代码之前,我把系统拆成了这几个核心模块:
- 用户模块:微信登录授权、用户信息维护、地址管理,区分普通用户和团长角色
- 商品模块:商品分类、商品发布、库存管理、上下架状态,按日期维度组织
- 订单模块:购物车、下单、订单状态流转、订单列表、订单详情
- 支付模块:微信支付下单、支付回调处理、退款
- 团长/自提点模块:团长申请与审核、自提点管理,跟用户绑定
- 运营后台:基于 Django Admin 实现,管理商品、订单、用户、团长审核
其中订单状态流转是最容易写乱的,先设计好状态机再写代码。我的订单状态是这样的:待支付(用户下单未付款)、已支付(回调成功)、配送中(平台发货)、待自提(到达自提点)、已完成(用户确认或超时自动确认)、已取消(超时未支付或用户主动取消)、售后中、已退款。
这个状态机设计是整个订单系统的骨架,后面做支付回调、订单列表筛选、后台订单管理,全都围着它转。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计和 Django 项目骨架搭建
2.1 核心数据模型的设计思路
数据模型是系统的地基,这块一开始要是设计错了,后面改起来真的会想哭。我最终的表结构是这样的,给大家参考。
用户表直接复用 Django 自带的 User 模型,然后通过一个 OneToOne 的 Profile 表扩展微信用户信息。微信小程序登录后拿到的 openid 是用户在微信生态里的唯一标识,这个字段必须有,而且建议加唯一索引。注意一点,同一个微信号在微信开放平台、公众号、小程序下的 openid 是不同的,跨平台识别需要 unionid,但社区团购这种场景基本都是小程序内闭环,用 openid 就够了。
商品表要注意的地方是增加了一个 activity_date 字段,表示这个商品属于哪一天的团购活动。这样做的好处是,用户端默认只显示今天的活动商品,运营后台可以提前配置后面几天的商品,到点自动切换。
关键模型大概是这样的(精简版):
python复制from django.db import models
from django.contrib.auth.models import User
class UserProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
openid = models.CharField(max_length=64, unique=True, db_index=True)
nickname = models.CharField(max_length=64, blank=True)
avatar_url = models.CharField(max_length=255, blank=True)
phone = models.CharField(max_length=20, blank=True)
role = models.CharField(max_length=10, choices=(
('user', '普通用户'),
('leader', '团长'),
), default='user')
created_at = models.DateTimeField(auto_now_add=True)
class Category(models.Model):
name = models.CharField(max_length=32)
sort_order = models.IntegerField(default=0)
is_active = models.BooleanField(default=True)
class Product(models.Model):
category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name='products')
name = models.CharField(max_length=128)
desc = models.TextField(blank=True)
image = models.CharField(max_length=255)
price = models.DecimalField(max_digits=10, decimal_places=2)
original_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
stock = models.IntegerField(default=0)
sales = models.IntegerField(default=0)
activity_date = models.DateField(db_index=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class PickerPoint(models.Model):
name = models.CharField(max_length=64)
address = models.CharField(max_length=255)
leader = models.OneToOneField(User, on_delete=models.CASCADE, related_name='picker_point')
phone = models.CharField(max_length=20)
lat = models.FloatField(null=True, blank=True)
lng = models.FloatField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Order(models.Model):
order_no = models.CharField(max_length=32, unique=True)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='orders')
picker_point = models.ForeignKey(PickerPoint, on_delete=models.SET_NULL, null=True)
total_amount = models.DecimalField(max_digits=10, decimal_places=2)
status = models.CharField(max_length=20, choices=(
('pending_payment', '待支付'),
('paid', '已支付'),
('shipping', '配送中'),
('pending_pickup', '待自提'),
('completed', '已完成'),
('cancelled', '已取消'),
('refunding', '退款中'),
('refunded', '已退款'),
), default='pending_payment')
transaction_id = models.CharField(max_length=64, blank=True)
remark = models.CharField(max_length=255, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
paid_at = models.DateTimeField(null=True, blank=True)
class OrderItem(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items')
product = models.ForeignKey(Product, on_delete=models.CASCADE)
product_name = models.CharField(max_length=128)
product_image = models.CharField(max_length=255)
price = models.DecimalField(max_digits=10, decimal_places=2)
quantity = models.IntegerField(default=1)
订单表里有一个细节值得注意:OrderItem 里我冗余存了 product_name 和 product_image,而不是下单时通过外键去关联商品表。为什么要这么做?因为商品表的数据是可能变的,如果运营把商品改名了或者删了,历史订单里的商品信息也会跟着变,这在订单系统里是不能接受的。所以要在订单项里做一个快照,记录下单那一刻的商品名称和图片。
2.2 Django 项目初始化和 APP 划分
项目骨架我按功能划分为几个 APP,这样各模块之间解耦清楚,后面维护也方便:
users:用户、团长相关products:商品、分类orders:订单、订单项payment:微信支付逻辑common:公共工具、常量、统一响应
创建好项目后,第一件事是改配置。settings.py 里几个必改项:
python复制# settings.py 关键配置
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'corsheaders',
'users',
'products',
'orders',
'payment',
'common',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
# 如果用的是前后端分离 + JWT 认证,这里可以注释掉 CSRF
# 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Shanghai'
USE_I18N = True
USE_TZ = True
# 数据库用 MySQL 的话,添加如下配置
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'community_group',
'USER': 'root',
'PASSWORD': 'your_password',
'HOST': '127.0.0.1',
'PORT': '3306',
'OPTIONS': {
'charset': 'utf8mb4',
},
}
}
有几个细节说一下。一是 LANGUAGE_CODE 改成 zh-hans,TIME_ZONE 改成 Asia/Shanghai,这样 Admin 后台是中文的,时间也是北京时间。二是 USE_TZ = True 的情况下,Django 在数据库里存的是带时区的时间,但如果你直接查数据库,看到的时间可能跟北京时间差8个小时,这是正常的,处理逻辑里注意转换就行。三是数据库编码尽量用 utf8mb4,因为微信用户的昵称里可能有 emoji 表情,utf8 存不下四字节字符,会报错。
不过为了快速上线,我开发阶段其实用的是 Django 默认的 SQLite,配置简单零成本。这里给大家一个建议:小项目开发阶段直接用 SQLite 完全够,不用纠结上不上 MySQL,等确定要部署了再切换也不迟。Django ORM 的兼容性做得很好,切换数据库只需要改 settings.py 的 DATABASES 配置,模型不用动。
2.3 用 Django Admin 当运营后台
Django Admin 是 Django 最香的功能之一,真正开发的时候你会感受到它的强大。我甚至觉得,对于社区团购这种业务来说,Django Admin 比很多定制化的后台管理系统都好用。
在 admin.py 里面注册模型,顺便定制一下展示效果:
python复制from django.contrib import admin
from .models import Product, Category
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ('name', 'category', 'price', 'stock', 'sales', 'activity_date', 'is_active')
list_filter = ('category', 'is_active', 'activity_date')
search_fields = ('name',)
list_editable = ('price', 'stock', 'is_active')
date_hierarchy = 'activity_date'
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'sort_order', 'is_active')
有几点经验分享给大家。list_editable 可以让列表页直接改价格、库存和上下架状态,运营每天上架商品的时候效率特别高。date_hierarchy 按日期筛选,方便查看某一天的活动商品。这些配置都是白嫖 Django 自带功能,不用额外写一行业务代码。
3. 后端核心接口开发与调试
3.1 小程序登录与 JWT 认证
微信小程序的登录流程是项目最基础的一环:小程序端调 wx.login() 拿到临时 code,发给后端;后端拿 code 去微信服务器换 openid 和 session_key;然后后端给小程序端发一个登录凭证,之后所有请求都带着这个凭证来认证。
社区团购这种小项目,我推荐用 JWT(JSON Web Token)方案,无状态、不占服务器存储、小程序端拿到 token 存起来就能用。实现也不难,用 PyJWT 这个库,自己封装签发和校验逻辑就行,不用引一个庞大的认证框架。
绑定这个关系:code 换 openid 的过程,需要调用微信接口:
python复制import json
import requests
import jwt
import time
from django.conf import settings
def code2session(code):
url = (
f"https://api.weixin.qq.com/sns/jscode2session?"
f"appid={settings.WX_APPID}&secret={settings.WX_SECRET}&"
f"js_code={code}&grant_type=authorization_code"
)
resp = requests.get(url, timeout=5)
data = resp.json()
if 'openid' not in data:
raise Exception(f"微信登录失败: {data}")
return data['openid'], data.get('session_key', '')
def create_token(user):
payload = {
'user_id': user.id,
'exp': int(time.time()) + 7 * 24 * 3600, # 7天有效
'iat': int(time.time()),
}
token = jwt.encode(payload, settings.SECRET_KEY, algorithm='HS256')
return token
获取 openid 之后,先去 UserProfile 表查这个 openid 是否已经存在。如果存在,直接返回 token 和用户信息;如果不存在,创建一个新用户。这里有一个很关键的坑:不能先创建一个 User 对象再保存 UserProfile,要用 get_or_create 或者手动判断,否则会重复创建用户。
登录接口的完整逻辑大概是这样:
python复制def wx_login(request):
code = request.POST.get('code')
if not code:
return json_response(error='缺少code参数')
openid, _ = code2session(code)
user = get_or_create_user_by_openid(openid)
token = create_token(user)
return json_response(data={
'token': token,
'user': build_user_info(user),
})
认证逻辑写好后,封装一个登录装饰器或中间件,让需要登录的接口自动校验 token:
python复制from functools import wraps
import jwt
from django.http import JsonResponse
from django.contrib.auth.models import User
from .models import UserProfile
def login_required(view_func):
@wraps(view_func)
def wrapper(request, *args, **kwargs):
token = request.META.get('HTTP_AUTHORIZATION', '')
if token.startswith('Bearer '):
token = token[7:]
if not token:
return JsonResponse({'code': 401, 'msg': '未登录'})
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256'])
user = User.objects.get(id=payload['user_id'])
request.user = user
return view_func(request, *args, **kwargs)
except (jwt.ExpiredSignatureError, jwt.DecodeError, User.DoesNotExist):
return JsonResponse({'code': 401, 'msg': '登录失效'})
return wrapper
这里建议大家在返回用户信息的时候,把 UserProfile 里的信息也带上,前端一次拿到全部用户数据。另外,token 有效期建议 7 天,小程序端 7 天内重新进入都不需要重新登录,体验比较好。
3.2 商品列表和商品详情接口
商品接口的逻辑比较直接,但有几个点值得展开说说。
商品列表接口,小程序端首页需要一个“当天活动商品”列表,所以接口要支持按 activity_date 过滤器,默认查当天:
python复制def product_list(request):
date_str = request.GET.get('date', time.strftime('%Y-%m-%d'))
category_id = request.GET.get('category_id')
products = Product.objects.filter(
is_active=True,
activity_date=date_str,
stock__gt=0,
).select_related('category')
if category_id:
products = products.filter(category_id=category_id)
data = [{
'id': p.id,
'name': p.name,
'image': p.image,
'price': str(p.price),
'original_price': str(p.original_price) if p.original_price else '',
'stock': p.stock,
'sales': p.sales,
'category_name': p.category.name,
} for p in products]
return json_response(data=data)
注意价格字段用 str(p.price) 转成字符串返回,因为 Decimal 类型不能直接 JSON 序列化。这里我踩过坑,返回 price 的时候直接序列化会报 Object of type Decimal is not JSON serializable 错误。
商品详情接口除了商品信息,通常还要带上这个商品所属的分类信息,方便前端做面包屑导航和推荐位展示。另外如果商品有规格(比如重量、包装规格),可以加一个 spec 字段或者单独的规格表。社区团购的商品规格一般比较简单,先不做多规格,等业务规模大了再扩展也不迟。
3.3 下单接口和库存扣减的并发处理
下单是整个系统里最容易踩坑的地方,核心问题是并发。想象一个场景:某个爆款商品库存只剩 5 件,有 10 个人同时点击下单,如果你的代码写的是“先查库存,再扣库存”,那最后卖出 10 件,库存变成负数,系统就崩了。
正确做法是用 Django ORM 的原子更新操作,一条 SQL 完成“检查库存并扣减”:
python复制from django.db import transaction
from django.db.models import F
@transaction.atomic
def create_order(request):
user = request.user
product_id = request.POST.get('product_id')
quantity = int(request.POST.get('quantity', 1))
picker_point_id = request.POST.get('picker_point_id')
product = Product.objects.select_for_update().get(id=product_id)
if product.stock < quantity:
return json_response(error='库存不足')
# 原子扣减库存
updated = Product.objects.filter(
id=product_id, stock__gte=quantity
).update(stock=F('stock') - quantity)
if updated == 0:
return json_response(error='库存不足')
# 生成订单号和订单
order_no = generate_order_no()
...
select_for_update() 是悲观锁,会把这一行数据锁住,其他事务必须等当前事务提交才能操作这行。用在这里是保证并发安全的。不过要注意,select_for_update 只在事务里有意义,所以必须搭配 @transaction.atomic 装饰器。
另一个细节是下单时校验 picker_point(自提点)是否存在且有效。用户选择自提点后,前端传的是自提点 ID,后端要校验这个 ID 有效,并且不是被禁用状态。不要盲目信任前端传的任何数据。
订单号生成我用的方案是:时间戳 + 用户ID + 随机数,简单实现且基本不会重复:
python复制import time
import random
def generate_order_no():
ts = time.strftime('%Y%m%d%H%M%S')
rand = random.randint(1000, 9999)
return f'{ts}{rand}'
3.4 微信支付对接的全流程
微信支付是最繁琐也最容易出问题的部分。介绍一下流程和关键代码。
小程序端拿到后端返回的订单信息后,调 wx.requestPayment 拉起支付,传给小程序的是 timeStamp、nonceStr、package、signType、paySign 这五个参数。而后端生成这些参数,需要用商户号、API 密钥、证书等信息调用微信支付统一下单接口。
统一下单接口的核心代码:
python复制import hashlib
import time
import random
import requests
import xmltodict
def wx_pay_unified_order(order, user_openid):
url = "https://api.mch.weixin.qq.com/pay/unifiedorder"
params = {
'appid': settings.WX_APPID,
'mch_id': settings.WX_MCH_ID,
'nonce_str': ''.join(random.sample('abcdefghijklmnopqrstuvwxyz1234567890', 16)),
'body': f'社区团购-{order.order_no}',
'out_trade_no': order.order_no,
'total_fee': int(order.total_amount * 100), # 单位是分
'spbill_create_ip': '你的服务器IP',
'notify_url': settings.WX_PAY_NOTIFY_URL,
'trade_type': 'JSAPI',
'openid': user_openid,
}
# 生成签名
sign = generate_sign(params)
params['sign'] = sign
# 转XML并发请求
xml_data = dict_to_xml(params)
resp = requests.post(url, data=xml_data.encode('utf-8'), timeout=10)
xml_resp = resp.text
return xml_to_dict(xml_resp)
签名算法是微信支付的重头戏,规则是:把所有参数按字典序排序,拼接成 key1=value1&key2=value2...&key=商户API密钥,然后做 MD5 哈希。我见过不少人在这一步出问题,总结几个常见原因:
参数排序必须用字典序而不是普通顺序,有人在这里用错了顺序导致签名一直不对。
total_fee 单位是分,不是元,10.50 元要传 1050。
sign 本身不参与签名,拼接的时候不要包含它。
如果返回 签名错误,微信支付官方文档里有签名校验工具,可以把参数贴进去对比,快速定位问题。
支付回调是另一个容易出问题的点。你下单之后,微信支付服务器会主动请求你的 notify_url,告诉你支付结果。回调里要做三件事:验签、查订单、更新订单状态。
python复制def wx_pay_notify(request):
xml_data = request.body
data = xml_to_dict(xml_data)
# 1. 验签
sign = data.pop('sign', '')
if generate_sign(data) != sign:
return HttpResponse('FAIL')
# 2. 找订单
order_no = data.get('out_trade_no')
order = Order.objects.select_for_update().get(order_no=order_no)
# 3. 校验金额
if int(order.total_amount * 100) != int(data.get('total_fee')):
return HttpResponse('FAIL')
# 4. 更新状态
if order.status == 'pending_payment':
order.status = 'paid'
order.transaction_id = data.get('transaction_id')
order.paid_at = timezone.now()
order.save()
# 关键:返回SUCCESS给微信,否则微信会一直重试回调
return HttpResponse('<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>')
回调返回的 XML 字符串必须精确匹配 SUCCESS,否则微信会一直重试。另外回调可能会重复推送,所以更新订单状态前要先判断当前状态,避免重复处理。这里用 select_for_update() 也是防止并发问题。
还有一个很重要的细节:支付回调的接口地址必须是公网可以访问的 HTTPS 地址。开发阶段没有公网服务器的话,可以用内网穿透工具把本机服务暴露出去。但生产环境一定要用正式的 HTTPS 域名,微信支付和微信小程序都强制要求 HTTPS。
4. 小程序端开发与前后端联调
4.1 小程序项目结构和页面划分
小程序端我用的是原生开发,没有用 uni-app 或 Taro。原因很简单:原生框架对微信生态的兼容性最好,小程序独有的功能(比如 wx.login、wx.requestPayment)直接用起来最顺手,而且社区团购这种项目,页面数量不多,原生开发完全够用。
页面划分大概是这样的:
pages/login/login:登录页,展示用户头像、昵称,登录按钮pages/index/index:首页,轮播图、分类、当日商品列表pages/category/category:分类页(可以并入首页)pages/detail/detail:商品详情页pages/cart/cart:购物车pages/order/confirm:确认订单页pages/order/list:订单列表pages/order/detail:订单详情pages/user/user:个人中心pages/address/address:自提点选择页
小程序最核心的入口是 app.js。我在这里处理了登录态的初始化:启动小程序时先检查本地有没有 token,没有的话走静默登录(wx.login 拿 code,调后端接口换 token),同时把用户信息拉下来存到全局变量里。
javascript复制// app.js 核心逻辑
App({
globalData: {
token: '',
userInfo: null,
isLogin: false
},
onLaunch() {
this.initLogin()
},
initLogin() {
const token = wx.getStorageSync('token')
if (token) {
this.globalData.token = token
this.getUserInfo()
} else {
this.wxLogin()
}
},
wxLogin() {
wx.login({
success: (res) => {
wx.request({
url: `${BASE_URL}/api/wx/login/`,
method: 'POST',
data: { code: res.code },
success: (resp) => {
if (resp.data.code === 0) {
this.globalData.token = resp.data.data.token
this.globalData.userInfo = resp.data.data.user
wx.setStorageSync('token', resp.data.data.token)
}
}
})
}
})
}
})
这里有一个体验细节:很多开发者喜欢在用户点击“登录”按钮时才调 wx.login,这样用户体验其实不好。小程序不同于 H5,wx.login 是静默的,不需要用户任何授权操作就可以拿到 code。所以启动时直接静默登录,用户在首页浏览商品的时候已经是登录状态了,只有需要获取微信头像和昵称时才提示用户授权。
4.2 小程序端数据请求的封装
小程序里到处都会用 wx.request,如果每次都写一遍完整调用逻辑,代码会很冗余,而且统一处理 token、错误码、加载提示的需求无法满足。我封装了一个 request.js 工具:
javascript复制// utils/request.js
const BASE_URL = 'https://api.example.com'
function request(url, method = 'GET', data = {}) {
return new Promise((resolve, reject) => {
wx.request({
url: `${BASE_URL}${url}`,
method,
data,
header: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${wx.getStorageSync('token')}`
},
success: (res) => {
if (res.statusCode === 200 && res.data.code === 0) {
resolve(res.data.data)
} else if (res.statusCode === 401) {
// 登录失效,跳转登录
wx.navigateTo({ url: '/pages/login/login' })
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 }
封装之后,页面里获取商品列表的代码就非常干净了:
javascript复制const { request } = require('../../utils/request')
Page({
data: {
products: []
},
onLoad() {
this.loadProducts()
},
async loadProducts() {
try {
const data = await request('/api/product/list/')
this.setData({ products: data })
} catch (e) {
console.error('加载失败', e)
}
}
})
4.3 登录态失效和自定义登录弹窗
做小程序的时候会遇到一个很常见的场景:用户打开小程序,后端返回 401(登录失效),这时候不能直接把用户踢出页面,也不能强行跳到登录页打断用户操作,应该用弹窗或蒙层的提示去引导用户去登录。
我这里的做法是:在 request.js 里检测到 401 时,先检查有没有显示过登录弹窗,如果没显示过,就触发一个全局事件,在页面层监听这个事件,展示自定义登录弹窗。
javascript复制// utils/event.js 简单的事件订阅实现
const listeners = {}
function on(event, callback) {
if (!listeners[event]) listeners[event] = []
listeners[event].push(callback)
}
function emit(event, data) {
if (listeners[event]) {
listeners[event].forEach(cb => cb(data))
}
}
这个方案的体验要好很多,而且实现成本很低。
4.4 下单和支付的小程序端实现
下单页的核心逻辑:用户从购物车或商品详情页进入确认订单页,选择自提点,点击支付按钮,前端把商品信息发到后端创建订单,拿到订单号后调微信支付。
支付部分的代码大致是这样的:
javascript复制async submitOrder() {
const res = await request('/api/order/create/', 'POST', {
product_id: this.data.productId,
quantity: this.data.quantity,
picker_point_id: this.data.pickerPointId
})
const payParams = await request('/api/order/pay/', 'POST', {
order_no: res.order_no
})
wx.requestPayment({
timeStamp: payParams.timeStamp,
nonceStr: payParams.nonceStr,
package: payParams.package,
signType: 'RSA',
paySign: payParams.paySign,
success: () => {
// 支付成功,跳转订单详情
wx.redirectTo({ url: `/pages/order/detail?order_no=${res.order_no}` })
},
fail: (err) => {
if (err.errMsg.includes('cancel')) {
wx.showToast({ title: '已取消支付', icon: 'none' })
} else {
wx.showToast({ title: '支付失败,请重试', icon: 'none' })
}
}
})
}
注意:wx.requestPayment 的 timeStamp 必须是字符串类型,不能用整数。这是小程序端最常见的报错之一。后端在组装支付参数时就要注意,返回给前端的一定要是字符串。
关于沙箱环境和真实支付:开发阶段建议先在微信支付商户平台开通“产品中心”里的 JSAPI 支付能力,并配置好支付回调域名。如果没有真实的商户号,也可以先用模拟支付跑通整个流程,但上线前一定要换成真实支付并完整测试支付回调链路。
5. 生产环境部署:Django 项目上线流程
5.1 服务器选型和基础环境配置
生产环境我选的是一台 2核4G 的云服务器,Linux 系统。这个配置对社区团购这种量级的项目来说完全够,同时跑 Django、MySQL、Nginx 都很轻松。
环境配置这块可以直接用宝塔面板来操作,它会帮你装好 Nginx、MySQL、Python 环境。不过 Django 项目的部署我们还是手动跑一遍核心命令,这样你能理解每一步在干什么。
Python 虚拟环境是 Django 项目部署中必须做的一件事,不同项目依赖不同的包版本,如果不做隔离,多个项目在同一个服务器上会互相干扰。创建虚拟环境的步骤:
bash复制# 1. 安装 Python 3(如果系统没有的话)
sudo apt update
sudo apt install python3 python3-pip python3-venv
# 2. 创建项目目录和虚拟环境
mkdir -p /srv/community_group
cd /srv/community_group
python3 -m venv venv
# 3. 激活虚拟环境并安装依赖
source venv/bin/activate
pip install django mysqlclient requests PyJWT 等依赖
依赖管理这一点我特别建议用 requirements.txt 固定版本,在本地用 pip freeze 生成:
bash复制pip freeze > requirements.txt
这样在服务器上一条命令就能把所有依赖装好,而且版本一致,不会出现“本地能跑线上报错”的情况。
5.2 使用 Gunicorn 运行 Django
Django 自带的开发服务器(runserver)只能用于本地开发,并发能力很差,绝不能用于生产环境。生产环境你需要一个 WSGI 服务器,我用的是 Gunicorn,它简单、稳定、文档全。
先安装:
bash复制pip install gunicorn
然后在项目根目录(有 manage.py 的地方)运行:
bash复制gunicorn community_group.wsgi:application -b 127.0.0.1:8000 --workers=3
注意 community_group.wsgi:application 里的 community_group 是你的 Django 项目名,wsgi.application 是项目里自带的 WSGI 入口文件。--workers=3 表示开 3 个工作进程,一般选择 CPU 核数 * 2 + 1 比较合理。
为了让 Gunicorn 在后台持续运行,可以用 systemd 来管理服务:
code复制# /etc/systemd/system/community_group.service
[Unit]
Description=Community Group Buy Django Service
After=network.target
[Service]
User=root
WorkingDirectory=/srv/community_group
ExecStart=/srv/community_group/venv/bin/gunicorn community_group.wsgi:application -b 127.0.0.1:8000 --workers=3
Restart=always
[Install]
WantedBy=multi-user.target
配置好后启动服务并设置开机自启:
bash复制sudo systemctl daemon-reload
sudo systemctl start community_group
sudo systemctl enable community_group
5.3 Nginx 反向代理和 HTTPS 配置
Django 跑起来后,Nginx 的作用有两个:一是把外界请求代理给 Gunicorn,二是配合 Certbot 自动配置 HTTPS。
Nginx 配置片段:
nginx复制server {
listen 80;
server_name api.example.com;
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 静态文件
location /static/ {
alias /srv/community_group/static/;
}
# 媒体文件
location /media/ {
alias /srv/community_group/media/;
}
}
静态文件路径要和 Django 配置对应:
python复制# settings.py
STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
部署前记得执行 python manage.py collectstatic,把 Django Admin 的静态文件集中收集到一个目录,Nginx 直接引用。
HTTPS 是必须的,微信小程序的生产环境要求所有请求域名必须配置 HTTPS 证书。用 Certbot 可以免费自动申请和续期 Let's Encrypt 证书:
bash复制sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d api.example.com
Certbot 会自动修改 Nginx 配置,加载证书并配置 HTTP 自动跳转 HTTPS。
5.4 小程序后台域名配置
小程序开发者在微信公众平台的后台,要配置 request 合法域名、socket 合法域名、uploadFile 合法域名、downloadFile 合法域名。这里要注意几点:
第一,必须是公网可以访问的 HTTPS 域名,不能是 IP 地址。
第二,域名需要有备案,否则微信不让你配置。这一步最好项目启动前就提前准备,因为备案周期可能长达一两周。
第三,配置好了之后,小程序开发者工具里要勾选“不校验合法域名”才能在本地开发环境调试,但真机预览的时候还是必须走正式域名。
支付相关还需要在微信支付商户平台配置“支付回调通知地址”,这个 URL 必须是公网可访问的 HTTPS 地址。
5.5 静态文件、图片上传和数据库备份
社区团购的商品图片是运营在后台管理的,图片上传功能我用的是 Django 自带的 ImageField,存储到服务器的媒体目录。但生产环境有个隐患:如果把图片都存在服务器本地磁盘,随着业务增长,磁盘很快会被占满,而且 Nginx 直接处理图片访问请求对服务器压力也大。
建议的做法是图片上传到对象存储,然后数据库里存对象存储的 URL。这样图片访问不消耗服务器带宽,容量也可以弹性扩展。如果项目初期想省事,先把图片存在服务器本地也问题不大,但要定期留意磁盘空间。
数据库备份是很多小项目的盲区,我吃过亏。在服务器上写一个定时任务,每天凌晨自动备份 MySQL 数据库,保留最近 7 天的备份:
bash复制#!/bin/bash
BACKUP_DIR="/srv/backups/mysql"
DATE=$(date +%Y%m%d)
mysqldump -u root community_group > $BACKUP_DIR/community_group_$DATE.sql
find $BACKUP_DIR -type f -mtime +7 -exec rm {} \;
把脚本加入 crontab:
bash复制0 2 * * * /srv/backups/backup_mysql.sh
这个习惯花两分钟配置好,能省掉未来可能的一场灾。
6. 微信小程序常见报错与排查实录
6.1 获取微信用户信息失败的几个原因
开发过程中最常遇到的问题就是“小程序获取登录后的微信用户失败”,各种机型、各种版本环境下的表现都不太一样。按我的排查经验,原因大概有这几类。
第一类是用户授权被拒绝。现在微信调整了用户头像昵称的获取规则,不再支持直接通过 wx.getUserInfo 弹窗获取头像昵称,而是需要用户手动点“头像昵称填写能力”(也就是让用户自己选择微信头像和昵称,通过 button 的 open-type="chooseAvatar" 和 input 的 type="nickname" 来实现)。很多旧教程里的写法已经废了,如果还按老方法写,会拿不到数据或者拿到的还是默认头像。
第二类是openid 缓存导致的数据不一致。多人共用同一台手机或同一个微信号在多种设备间切换时,小程序本地缓存的 token 可能对应着旧的 openid。遇到这类问题,最简单的办法是让用户删除小程序重新进入,或者在小程序端主动清理本地缓存后重新登录。
第三类是网络问题。在微信开发者工具里,模拟器网络和你本机网络共用一个出口,如果公司内网有防火墙或者代理设置不对,请求可能一直超时。这种问题在真机上测试反而正常。
6.2 支付回调不触发的排查流程
“支付成功但订单状态不变”是我见过最多的问题。用户明明在微信里付了钱,但小程序端订单一直显示“待付款”。这个问题的根源基本都在支付回调链路上。
排查时按这个顺序来:
先确认支付回调地址是否配置正确。微信商户平台里设置的 notify_url 必须能公网访问,而且路径要和后端代码里的 URL 完全一致。
再看 Nginx 访问日志和 Django 日志,确认微信支付服务器是否真的请求了回调接口。如果根本没有请求进来,说明回调地址配置有问题或者域名不可达。
如果请求进来了但返回的不是 SUCCESS,看日志里具体卡的哪一步。签名错误、订单号不存在、金额不匹配,每种情况都有对应的错误日志。
如果回调处理正常但订单状态还是没变,检查是不是有事务没有提交,或者 update 条件里多了不该有的条件(比如在外键关联了错误状态)。
6.3 小程序首页白屏和顶部导航栏高度适配
小程序真机上偶尔会遇到首页白屏的问题。可能的原因比较多,但最常见的是“请求接口的超时时间设置过短”、“接口返回的数据结构不符合预期”、“setData 的数据量过大”。
开发阶段建议把网络的超时时间设得大一些(比如 15 秒),真机预览时网络环境不稳定,超时时间太短容易导致白屏。同时一定要做错误处理和兜底提示,接口失败时页面显示“加载失败,点击重试”,而不是一直白屏。
顶部导航栏高度适配是老生常谈的问题了。不同机型(尤其是刘海屏和灵动岛机型)的导航栏高度不同,如果你自定义了导航栏样式,需要在 app.js 的 onLaunch 里获取系统信息:
javascript复制const systemInfo = wx.getSystemInfoSync()
this.globalData.statusBarHeight = systemInfo.statusBarHeight
this.globalData.navBarHeight = systemInfo.statusBarHeight + 44
statusBarHeight 是状态栏高度,导航栏高度一般是 statusBarHeight + 44(Android 大部分需要计算,iOS 上大多是固定 44)。拿到这两个高度后,自定义导航栏的页面就能计算出正确的高度,避免内容被刘海遮挡。
6.4 数据库时区问题导致的订单时间错乱
使用 Django + MySQL 部署后,我发现数据库里订单的 created_at 比真实时间少了 8 个小时,但 Django Admin 里显示的时间又是正常的。这个问题在最初排查时绕了不少弯路,后来才明白是 USE_TZ = True 导致的。
USE_TZ = True 时,Django 在写入数据库前会把时间转成 UTC,读出来再转回本地时区。如果你直接用 SQL 客户端(比如 Navicat)去查数据库,看到的是 UTC 时间,所以显得“少了 8 小时”。这其实是正常现象,数据库里存的时间本来就是 UTC,Django ORM 读取时自动做了转换。
不过如果你有 timezone.localtime() 没有正确使用的情况,会导致显示的时间不对。排查建议:任何展示给用户的时间,都要确保最终经过 timezone.localtime() 转换,或者干脆在 settings.py 里设置 USE_TZ = False。项目里如果不涉及跨时区用户,直接设置 USE_TZ = False 是最省心的方案,存进去什么时间就是什么时间,不会有魔法。
6.5 图片上传失败和文件路径问题
Django 图片上传在部署后很容易遇到 403 或 404。403 通常是 Nginx 对 media 目录的权限配置不对,Nginx 工作进程没有读取文件的权限。404 则是 MEDIA_URL 和 Nginx location 配置不对应。
还有一个小坑是图片路径里包含中文或特殊字符,Nginx 默认可能对 URL 编码处理有问题。建议上传时把文件名统一重命名为时间戳或随机字符串:
python复制import os
import uuid
def upload_to(instance, filename):
ext = os.path.splitext(filename)[-1].lower()
filename = f'{uuid.uuid4().hex}{ext}'
return os.path.join('product_images', filename)
这样既避免了文件名冲突,也杜绝了特殊字符引发的问题。
7. 项目复盘:做社区团购系统值得注意的几件事
整个项目从零开发到上线,用了大概三周左右的时间。其中第一周在做设计和技术选型,第二周在写代码,第三周在联调、部署和修 Bug。如果让我重新做一遍,有几个地方我会有不同的处理方式,这里一并分享出来。
先把各种“预生成单 + 支付结果异步通知”的状态差异处理清楚,再写下单接口。 我因为前期没有把“未支付订单超过 30 分钟自动失效”这个规则设计进去,导致数据库里有不少脏数据。后来补了定时任务去清掉超时未支付的订单,但如果一开始就设计好,后期就能省这个麻烦。
自提点和团长的关系,最好在用户端就能提前标注“这个自提点今天是否支持自提”。 因为有的自提点可能周末休息,或者临时不开放。我最初只做了自提点是否存在的校验,没做“当天是否营业”的校验,结果有一次某自提点临时闭店,用户到了发现没人,体验很不好。后来加了一个 service_dates 字段来管理自提点的服务日期。
关于小程序端缓存,商品列表页和购物车要做数据一致性。 否则用户在一个页面看到的价格和另一个页面看到的不一样,很容易产生纠纷。
还有一件最重要的事:一定要留出一个“运营手动改单”的入口。 社区团购这种业务,团长在群里收集订单是常态,经常会遇到用户下单后说“我要加一个”“我地址写错了”“我今天不去了”等情况。如果系统不支持人工改单,运营会疯掉。Django Admin 里直接给 order 模型注册一个 action,支持改状态、改自提点、改商品,这个功能看着不起眼,但对运营效率的提升非常明显。
另外给新手的建议是:不要一开始就追求大而全的功能设计。第一版能把“用户下单 - 支付 - 后台发货 - 用户自提”这条最核心的链路跑通,就已经完成了 80% 的工作。拼团、砍价、分销、积分商城这些花式玩法,等核心链路稳定之后再加不迟。
这个项目做完之后,我心里比较踏实的部分是数据库模型和订单状态机的设计,这两块是业务逻辑的根基,地基稳了,后面不管加什么功能都比较顺。比较遗憾的部分是测试环节做得不够充分,尤其是并发场景下的库存扣减测试,只做了简单的 JMeter 压测,没有写完整的自动化测试用例。如果项目后面还要继续迭代,我第一件要做的事就是把核心接口的单元测试和集成测试补上。
