简介:开箱即用的Django服务端模板,专为微信小程序定制。内置完整登录链路:接收小程序传来的code,调用微信接口换取session_key和openid,解密用户敏感信息(如encryptedData),完成用户绑定与会话维护;支持图片、音频、PDF、Word等常见格式的文件上传,含文件类型校验、大小限制、本地存储路径管理,预留云存储(如OSS、COS)扩展接口。配套基础权限控制(如登录态校验装饰器)、标准化RESTful路由设计、跨域(CORS)预配置、统一响应格式(含code/msg/data结构)、微信签名工具、缓存封装类及通用加密解密方法。项目结构规范,包含models定义、views逻辑分层、urls路由映射、migrations数据库迁移脚本、单元测试用例、requirements依赖清单和详细README说明,可直接作为小程序后端启动基础。
1. 项目概述:为什么这个Django模板值得你花十分钟读完
我做过6个上线的小程序后端,从日活500的工具类,到日活3万的本地生活平台,踩过所有你能想到的坑——微信登录回调超时、encryptedData解密失败、文件上传被Nginx截断、session_key复用导致用户信息错乱、OSS签名过期、跨域配置漏掉OPTIONS预检……每次重搭一套基础服务,至少浪费两天。直到我把这些高频需求全部沉淀下来,打磨出这个真正能“开箱即用”的Django小程序后端模板。它不是Demo,不是教学示例,而是我在生产环境反复验证过的最小可行骨架。
核心关键词——Django小程序、微信登录接口、文件上传服务——这三个词背后对应的是小程序开发中最刚性、最易出错、又最不该重复造轮子的三块基石。微信登录接口不是简单调个API,它涉及code时效性(5分钟)、session_key安全性(不可存储、不可复用)、敏感数据解密(需要iv+encryptedData+appid+appsecret四要素)、openid与unionid绑定逻辑;文件上传服务也不是request.FILES一扔就完事,它必须处理MIME类型白名单校验(比如.docx实际可能是application/vnd.openxmlformats-officedocument.wordprocessingml.document)、文件大小动态限制(图片2MB、音频10MB、PDF50MB)、路径安全(防止../路径遍历)、存储策略抽象(本地调试 vs 云存储上线)。这个模板把所有这些“隐性成本”都显性化、标准化、可配置化了。
适合谁?如果你是独立开发者或小团队后端,正在启动一个微信小程序项目,不想在基础链路上卡壳;如果你是前端同学,需要快速联调后端接口,希望文档清晰、错误码明确、响应结构统一;如果你是技术负责人,需要给新人提供一份有生产级约束的脚手架——那它就是为你准备的。它不追求炫技,不堆砌框架,只做三件事:让登录链路稳如磐石、让文件上传牢不可破、让后续扩展毫无阻力。接下来,我会带你一层层拆开它的设计逻辑、实操细节和那些只有踩过坑才懂的注意事项。
2. 整体架构设计:为什么选择这个分层与模块划分
2.1 核心设计哲学:解耦、可替换、零魔法
很多开源Django小程序模板的问题在于“过度封装”——把微信登录、文件上传、缓存、响应格式全塞进一个utils.py里,函数命名像get_user_info_from_wechat(),参数传一堆字典,内部逻辑混乱,改一处牵全身。这个模板反其道而行之,坚持三个原则:
- 解耦到原子粒度:微信登录流程被拆成
code2session(网络请求)、decrypt_user_data(密码学操作)、bind_or_create_user(业务逻辑)三个独立函数,每个函数职责单一、输入输出明确、单元测试覆盖率100%。 - 可替换无侵入:文件存储不是硬编码
os.path.join(settings.MEDIA_ROOT, ...),而是通过StorageBackend抽象基类定义接口,LocalFileStorage和AliyunOSSStorage都继承它,切换只需改一行配置DEFAULT_FILE_STORAGE = 'apps.utils.storage.AliyunOSSStorage'。 - 零魔法配置驱动:所有关键行为由
settings.py中的常量控制,比如WECHAT_MINI_PROGRAM_APPID、WECHAT_MINI_PROGRAM_SECRET、FILE_UPLOAD_MAX_SIZE、ALLOWED_FILE_TYPES。没有隐藏的环境变量、没有运行时动态加载,部署时一眼看清所有依赖项。
这种设计不是为了炫技,而是为了解决真实协作痛点。我曾接手一个项目,前任把微信解密逻辑写在views里,还用了自定义的base64解码方式,结果小程序升级后encryptedData格式微调,整个登录就崩了,排查三天才发现问题出在base64的padding处理上。而在这个模板里,decrypt_user_data函数只做一件事:调用Python标准库Crypto.Cipher.AES,严格按照微信官方文档的CBC模式、PKCS7填充、16字节IV处理,输入是原始bytes,输出是解密后的JSON字符串,边界清晰,替换方便。
2.2 目录结构解析:每一层都在解决什么问题
项目目录不是随意组织的,而是严格遵循Django最佳实践与小程序后端特需的混合体:
apps/
├── user/ # 用户核心模块:模型定义、登录视图、绑定逻辑
│ ├── models.py # User(扩展AbstractUser)、WechatUser(openid/unionid/session_key)
│ ├── views.py # login_view(主入口)、decrypt_view(解密专用)、bind_view(显式绑定)
│ └── serializers.py # 序列化器:LoginRequestSerializer(校验code)、UserInfoSerializer(输出用户信息)
├── resource/ # 资源模块:文件上传、下载、管理
│ ├── models.py # ResourceFile(文件元数据:original_name、file_type、size、storage_path)
│ ├── views.py # upload_file_view(主上传)、batch_upload_view(多文件)、download_view(带权限校验)
│ └── storage.py # StorageBackend抽象类 + LocalFileStorage / AliyunOSSStorage实现
├── common/ # 公共组件:不属特定业务,但全局可用
│ ├── utils.py # 微信签名生成(get_signature)、通用响应封装(success_response/error_response)
│ ├── cache.py # 缓存封装:支持Redis/Memcached,自动序列化,带TTL前缀隔离
│ └── decorators.py # 登录态校验装饰器(@login_required_api)、权限校验(@permission_required)
└── core/ # 框架级增强:ASGI配置、中间件、异常处理器
├── middleware.py # CORS中间件(精确控制Origin/Methods/Headers)、请求日志中间件(记录耗时/IP/UA)
├── exceptions.py # 自定义异常类(WechatApiError、FileUploadError),统一转为HTTP响应
└── asgi.py # 生产环境ASGI配置(支持Uvicorn/Daphne)
特别说明utils和libs两个顶层目录的区别:utils存放项目内通用工具(如响应格式、缓存封装),libs则存放第三方SDK的轻量级封装(如libs/wechat/api.py只包装微信code2session接口的requests调用,不做任何业务逻辑)。这样划分,确保业务代码永远不直接依赖requests或redis,只依赖apps.common.utils和apps.libs.wechat,未来换SDK或升级版本,影响范围被锁死在libs层。
2.3 关键技术选型背后的权衡
- Django REST Framework (DRF) vs 原生Django Views:选择DRF,不是因为它“高级”,而是因为小程序API天然符合RESTful规范(
POST /api/v1/login/,POST /api/v1/upload/),DRF的序列化器(Serializer)能自动校验code长度、encryptedData是否base64编码、iv是否16字节,比手写if not request.POST.get('code')严谨十倍;它的APIView类自带self.request.user,配合TokenAuthentication或自定义认证后端,权限控制一行代码搞定。 - SQLite vs PostgreSQL:模板默认用SQLite,因为它是零配置、单文件、适合快速启动。但
settings.py里已预留DATABASES配置,注释掉SQLite,取消注释PostgreSQL部分,改4个环境变量即可切换。为什么不用MySQL?因为PostgreSQL对JSON字段原生支持更好,小程序用户扩展属性(如地址簿、偏好设置)未来大概率用JSONB存储。 - 本地存储 vs 云存储:
storage.py里LocalFileStorage是默认实现,但它不是“临时方案”,而是生产可用的基准线。它做了三件事:1)自动创建按日期分层的目录(/media/2024/06/15/xxx.jpg),避免单目录文件过多;2)文件名重命名(UUID+原始扩展名),杜绝重名覆盖;3)路径安全校验(os.path.normpath过滤../)。云存储(OSS/COS)只是同一接口的另一个实现,不是“高级功能”,而是“可选插件”。
3. 微信登录链路详解:从code到用户会话的每一步
3.1 小程序端与后端的协作契约
登录不是后端单方面的事,它依赖小程序端严格遵守微信规范。模板的README.md里明确写了小程序端必须做的三件事:
- 调用
wx.login()获取code:必须在用户触发登录动作(如点击“授权登录”按钮)后立即调用,不能页面加载就调,否则code可能过期。 - 调用
wx.getUserInfo()(或wx.getProfile())获取encryptedData和iv:注意!wx.getUserInfo()在2023年已废弃,新项目必须用wx.getProfile(),它返回的encryptedData结构与旧版一致,但要求用户主动触发(不能静默获取)。 - 将code、encryptedData、iv、signature(小程序端生成的签名)一起POST到
/api/v1/login/:签名用于防篡改,小程序端用wx.getStorageSync('session_key')(注意:这是小程序端的session_key,非后端获取的)和encryptedData生成HMAC-SHA256,后端用同样的算法校验,确保请求未被中间人篡改。
这个契约是链路稳定的基础。我见过太多项目把encryptedData和iv拼错顺序,或者小程序端用wx.login()的code去解密wx.getProfile()的数据,结果永远解密失败。模板的LoginRequestSerializer强制校验这四个字段缺一不可,且code长度必须是32位十六进制字符串(微信官方规定),iv必须是24位base64字符串(AES-CBC固定IV长度),从源头拦截无效请求。
3.2 后端核心流程:code2session → 解密 → 绑定
整个流程在apps/user/views.py的login_view中完成,分为三个原子步骤:
步骤一:code2session换取session_key和openid
# apps/libs/wechat/api.py
def code2session(appid, secret, js_code):
"""
调用微信接口换取session_key和openid
:param appid: 小程序AppID
:param secret: 小程序AppSecret
:param js_code: 小程序端wx.login()返回的code
:return: dict with keys 'session_key', 'openid', 'unionid'(optional)
"""
url = "https://api.weixin.qq.com/sns/jscode2session"
params = {
"appid": appid,
"secret": secret,
"js_code": js_code,
"grant_type": "authorization_code"
}
try:
response = requests.get(url, params=params, timeout=5)
data = response.json()
if "errcode" in data:
raise WechatApiError(f"Wechat API error: {data['errmsg']}, code={data['errcode']}")
return data
except requests.exceptions.RequestException as e:
raise WechatApiError(f"Network error calling wechat API: {e}")
关键点:
- 超时设置为5秒:微信接口平均响应200ms,设5秒足够,避免阻塞。我曾遇到微信服务器偶发延迟到8秒,没设超时会导致Django进程卡死。
- 错误分类处理:WechatApiError继承自APIException,会被全局异常处理器捕获,转为标准错误响应{"code": 5001, "msg": "微信服务暂时不可用", "data": {}},前端可统一提示。
- unionid存在性判断:只有当用户在该微信开放平台下绑定了多个公众号/小程序时,才会返回unionid。模板在bind_or_create_user函数里,优先用unionid查用户(更唯一),不存在则用openid,确保同一用户在不同小程序间身份一致。
步骤二:AES-CBC解密encryptedData
# apps/common/utils.py
def decrypt_user_data(encrypted_data: str, iv: str, session_key: str, appid: str) -> dict:
"""
解密微信encryptedData,返回用户信息字典
:param encrypted_data: base64编码的加密数据
:param iv: base64编码的初始化向量
:param session_key: 微信返回的session_key(base64编码)
:param appid: 小程序AppID(用于校验)
:return: 解密后的dict,包含nickName, avatarUrl, gender, city, province, country等
"""
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import base64
import json
# 1. Base64解码
encrypted_data_bytes = base64.b64decode(encrypted_data)
iv_bytes = base64.b64decode(iv)
session_key_bytes = base64.b64decode(session_key)
# 2. AES-CBC解密
cipher = AES.new(session_key_bytes, AES.MODE_CBC, iv_bytes)
decrypted = unpad(cipher.decrypt(encrypted_data_bytes), AES.block_size)
# 3. JSON解析并校验appid
user_data = json.loads(decrypted.decode('utf-8'))
if user_data.get('watermark', {}).get('appid') != appid:
raise ValueError("appid in watermark does not match")
return user_data
关键点:
- 严格遵循微信文档:AES.MODE_CBC、PKCS7填充(unpad)、16字节block_size,任何偏差都会解密失败。网上很多教程用AES.MODE_ECB或pkcs5,都是错的。
- watermark校验:解密后JSON里有个watermark字段,包含appid和timestamp,必须校验appid是否匹配,防止攻击者伪造数据。
- 字符编码:decrypted.decode('utf-8')必须指定UTF-8,否则中文昵称会乱码。我曾因没指定编码,用户昵称显示为b'\xe5\xbc\xa0\xe4\xb8\x89'。
步骤三:用户绑定与会话创建
# apps/user/models.py
class WechatUser(models.Model):
openid = models.CharField(max_length=128, unique=True, db_index=True)
unionid = models.CharField(max_length=128, blank=True, null=True, db_index=True)
session_key = models.CharField(max_length=255) # 注意:仅存储一次,用于后续解密,不用于鉴权
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='wechat')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# apps/user/views.py
def bind_or_create_user(openid, unionid, user_data, session_key):
"""
根据openid/unionid查找或创建用户,并绑定WechatUser
:return: User instance
"""
# 1. 优先用unionid查找
if unionid:
try:
wechat_user = WechatUser.objects.select_related('user').get(unionid=unionid)
return wechat_user.user
except WechatUser.DoesNotExist:
pass
# 2. 再用openid查找
try:
wechat_user = WechatUser.objects.select_related('user').get(openid=openid)
# 更新session_key(每次登录都更新,保证最新)
wechat_user.session_key = session_key
wechat_user.save()
return wechat_user.user
except WechatUser.DoesNotExist:
pass
# 3. 创建新用户
user = User.objects.create(
username=f"wx_{uuid.uuid4().hex[:8]}", # 避免用户名冲突
nickname=user_data.get('nickName', ''),
avatar=user_data.get('avatarUrl', ''),
gender=user_data.get('gender', 0),
city=user_data.get('city', ''),
province=user_data.get('province', ''),
country=user_data.get('country', '')
)
WechatUser.objects.create(
openid=openid,
unionid=unionid,
session_key=session_key,
user=user
)
return user
关键点:
- session_key只存不用于鉴权:微信官方强调session_key是临时密钥,不应作为长期会话凭证。模板用Django的TokenAuthentication或JWT生成access_token,session_key仅用于本次解密,存入数据库是为了后续可能的敏感操作(如支付回调验签)。
- username生成策略:wx_前缀+8位随机字符串,确保唯一性,避免用户注册邮箱/手机号时冲突。
- select_related优化:select_related('user')一次查询就拿到关联的User对象,避免N+1查询。
3.3 会话管理与权限控制:如何让登录态既安全又易用
登录成功后,前端需要一个access_token来访问其他受保护接口。模板采用JWT(JSON Web Token) 方案,而非Django默认的Session,原因有三:
- 无状态:小程序是纯前端应用,没有Cookie上下文,JWT放在Header里(
Authorization: Bearer <token>)更自然。 - 可控过期:Token有效期设为7天,比微信
session_key的30天更短,降低泄露风险。 - 负载丰富:JWT Payload里可塞入
user_id、openid、role(角色)、exp(过期时间),后端无需查库就能鉴权。
# apps/common/utils.py
from rest_framework_simplejwt.tokens import AccessToken, RefreshToken
def generate_jwt_tokens(user):
"""生成access_token和refresh_token"""
access_token = AccessToken.for_user(user)
refresh_token = RefreshToken.for_user(user)
# 在access_token里添加自定义字段
access_token['openid'] = getattr(user, 'wechat', None).openid if hasattr(user, 'wechat') else ''
access_token['nickname'] = user.nickname
access_token['avatar'] = user.avatar
return {
'access_token': str(access_token),
'refresh_token': str(refresh_token),
'expires_in': int(access_token.lifetime.total_seconds())
}
# apps/common/decorators.py
def login_required_api(view_func):
"""API登录态校验装饰器"""
@wraps(view_func)
def wrapper(request, *args, **kwargs):
auth_header = request.META.get('HTTP_AUTHORIZATION', '')
if not auth_header or not auth_header.startswith('Bearer '):
return error_response(code=401, msg="未授权,请先登录")
token = auth_header[7:]
try:
access_token = AccessToken(token)
user_id = access_token['user_id']
user = User.objects.get(id=user_id)
# 将user注入request,后续视图可直接用request.user
request.user = user
except (InvalidToken, TokenError, User.DoesNotExist):
return error_response(code=401, msg="登录态无效,请重新登录")
return view_func(request, *args, **kwargs)
return wrapper
使用方式极其简单:
# apps/user/views.py
@api_view(['GET'])
@login_required_api
def get_user_profile(request):
"""获取用户个人信息"""
serializer = UserInfoSerializer(request.user)
return success_response(data=serializer.data)
提示:
@login_required_api装饰器是轻量级的,它只做JWT校验和user注入,不涉及数据库查询(除了最后一步User.objects.get)。如果对性能极致要求,可在JWT Payload里直接放user_id和openid,完全避免查库,但牺牲了实时性(如用户头像更新后Token里还是旧值)。
4. 文件上传服务实现:从接收、校验到存储的全流程
4.1 上传接口设计:RESTful与小程序端的无缝对接
小程序端调用wx.uploadFile时,必须指定url和filePath,后端接口需满足两点:
- 接受multipart/form-data:这是
wx.uploadFile的默认Content-Type,不能用application/json。 - 支持单文件与多文件:小程序可能一次传一张图,也可能一次传一个录音+一张封面图。
模板提供两个接口:
POST /api/v1/upload/:单文件上传,formData里file字段是文件。POST /api/v1/upload/batch/:多文件上传,formData里files[]是文件数组(注意[]语法,小程序端需用wx.uploadFile循环调用或用wx.chooseMessageFile)。
# apps/resource/views.py
from rest_framework.parsers import MultiPartParser
from rest_framework.views import APIView
from rest_framework.response import Response
class UploadFileView(APIView):
parser_classes = [MultiPartParser] # 必须指定,否则无法解析multipart
@login_required_api
def post(self, request):
file_obj = request.FILES.get('file')
if not file_obj:
return error_response(code=400, msg="未上传文件")
# 校验文件
validation_result = validate_file(file_obj)
if not validation_result['valid']:
return error_response(code=400, msg=validation_result['msg'])
# 存储文件
storage_backend = get_storage_backend()
storage_path = storage_backend.save(file_obj.name, file_obj)
# 创建数据库记录
resource = ResourceFile.objects.create(
original_name=file_obj.name,
file_type=validation_result['file_type'],
size=file_obj.size,
storage_path=storage_path,
uploaded_by=request.user,
mime_type=file_obj.content_type
)
return success_response(data={
'id': resource.id,
'url': storage_backend.url(storage_path),
'name': file_obj.name,
'size': file_obj.size
})
class BatchUploadView(APIView):
parser_classes = [MultiPartParser]
@login_required_api
def post(self, request):
files = request.FILES.getlist('files[]') # 注意key名
if not files:
return error_response(code=400, msg="未上传任何文件")
results = []
for file_obj in files:
validation_result = validate_file(file_obj)
if not validation_result['valid']:
continue # 跳过无效文件,不中断整个批次
storage_backend = get_storage_backend()
storage_path = storage_backend.save(file_obj.name, file_obj)
resource = ResourceFile.objects.create(
original_name=file_obj.name,
file_type=validation_result['file_type'],
size=file_obj.size,
storage_path=storage_path,
uploaded_by=request.user,
mime_type=file_obj.content_type
)
results.append({
'id': resource.id,
'url': storage_backend.url(storage_path),
'name': file_obj.name,
'size': file_obj.size
})
return success_response(data=results)
注意:
parser_classes = [MultiPartParser]必须显式声明,Django REST Framework默认不启用它,否则request.FILES永远为空。
4.2 文件校验:不只是后缀名,更是MIME类型的精准匹配
仅校验文件后缀名(.jpg)是危险的。攻击者可以上传一个.jpg后缀的PHP木马,服务器若按后缀执行,就会被黑。模板采用双重校验:
- MIME类型白名单:基于文件内容(magic bytes)检测,而非后缀名。
- 后缀名与MIME一致性:确保
.pdf文件的MIME确实是application/pdf。
# apps/resource/utils.py
import mimetypes
import magic # python-magic库,依赖libmagic
def validate_file(file_obj) -> dict:
"""
校验文件:大小、MIME类型、后缀名一致性
:return: {'valid': bool, 'msg': str, 'file_type': str}
"""
# 1. 大小校验
max_size = settings.FILE_UPLOAD_MAX_SIZE.get(file_obj.content_type, 0)
if max_size == 0:
return {'valid': False, 'msg': f"不支持的文件类型: {file_obj.content_type}"}
if file_obj.size > max_size:
return {'valid': False, 'msg': f"文件大小超出限制: {file_obj.size} > {max_size}"}
# 2. MIME类型检测(基于内容)
mime = magic.from_buffer(file_obj.read(2048), mime=True) # 读前2KB足够识别
file_obj.seek(0) # 重置文件指针,否则后续存储会读空
# 3. 白名单校验
allowed_mimes = settings.ALLOWED_FILE_TYPES
if mime not in allowed_mimes:
return {'valid': False, 'msg': f"不支持的文件类型: {mime}"}
# 4. 后缀名与MIME一致性(可选,增强安全)
extension = mimetypes.guess_extension(mime)
if extension and not file_obj.name.lower().endswith(extension):
return {'valid': False, 'msg': f"文件后缀名与MIME类型不匹配: {file_obj.name} -> {mime}"}
return {
'valid': True,
'msg': '',
'file_type': allowed_mimes[mime] # 映射为业务类型,如'image/jpeg' -> 'image'
}
# settings.py 示例
FILE_UPLOAD_MAX_SIZE = {
'image/jpeg': 2 * 1024 * 1024, # 2MB
'image/png': 2 * 1024 * 1024,
'audio/mpeg': 10 * 1024 * 1024, # 10MB
'application/pdf': 50 * 1024 * 1024, # 50MB
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 20 * 1024 * 1024, # .docx
}
ALLOWED_FILE_TYPES = {
'image/jpeg': 'image',
'image/png': 'image',
'audio/mpeg': 'audio',
'application/pdf': 'document',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'document',
}
关键点:
- magic.from_buffer:python-magic库的核心,它读取文件头部字节(magic bytes),比后缀名可靠一万倍。安装时需pip install python-magic,Linux/macOS还需brew install libmagic或apt-get install libmagic1。
- file_obj.seek(0):magic.from_buffer会消耗文件指针,必须重置,否则storage_backend.save()读不到内容。
- 动态大小限制:不同MIME类型不同上限,比全局MAX_UPLOAD_SIZE更合理。
4.3 存储策略:本地存储的健壮实现与云存储扩展
storage.py是模板的精华之一,它把存储抽象成接口,本地实现已做到生产级:
# apps/resource/storage.py
import os
import uuid
from django.core.files.storage import FileSystemStorage
from django.conf import settings
from datetime import datetime
class LocalFileStorage(FileSystemStorage):
"""本地文件存储,支持按日期分层、安全重命名"""
def __init__(self, location=None, base_url=None):
if location is None:
location = os.path.join(settings.MEDIA_ROOT, 'uploads')
if base_url is None:
base_url = settings.MEDIA_URL + 'uploads/'
super().__init__(location, base_url)
def _save(self, name, content):
# 1. 生成安全文件名:UUID + 原始扩展名
ext = os.path.splitext(name)[1].lower()
filename = f"{uuid.uuid4().hex}{ext}"
# 2. 构建按日期分层的路径:/2024/06/15/filename.jpg
today = datetime.now()
path = os.path.join(
str(today.year),
f"{today.month:02d}",
f"{today.day:02d}",
filename
)
# 3. 确保目录存在
full_path = os.path.join(self.location, path)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
# 4. 调用父类保存
return super()._save(path, content)
def url(self, name):
# 重写url方法,确保返回绝对URL(带域名),便于小程序展示
if name.startswith('http'):
return name
return super().url(name)
# settings.py 中启用
DEFAULT_FILE_STORAGE = 'apps.resource.storage.LocalFileStorage'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
MEDIA_URL = '/media/'
云存储扩展只需新增一个类:
# apps/resource/storage.py
from aliyunsdkcore.auth.credentials import AccessKeyCredential
from aliyunsdkoss import OSSClient
class AliyunOSSStorage():
def __init__(self, bucket_name, endpoint, access_key_id, access_key_secret):
self.bucket_name = bucket_name
self.endpoint = endpoint
self.client = OSSClient(
credential=AccessKeyCredential(access_key_id, access_key_secret),
endpoint=endpoint
)
def save(self, name, content):
# 生成唯一key
ext = os.path.splitext(name)[1].lower()
key = f"uploads/{datetime.now().strftime('%Y/%m/%d')}/{uuid.uuid4().hex}{ext}"
# 上传到OSS
self.client.put_object(self.bucket_name, key, content)
return key
def url(self, key):
return f"https://{self.bucket_name}.{self.endpoint}/{key}"
注意:云存储类不继承
FileSystemStorage,因为OSS API完全不同。模板通过get_storage_backend()工厂函数统一调度:
python def get_storage_backend(): if settings.USE_ALIYUN_OSS: return AliyunOSSStorage( bucket_name=settings.ALIYUN_OSS_BUCKET, endpoint=settings.ALIYUN_OSS_ENDPOINT, access_key_id=settings.ALIYUN_OSS_ACCESS_KEY_ID, access_key_secret=settings.ALIYUN_OSS_ACCESS_KEY_SECRET ) return LocalFileStorage()
5. 实战避坑指南:那些文档里不会写的血泪教训
5.1 微信登录常见问题速查表
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
{"code":5001,"msg":"解密失败","data":{}} | 小程序端encryptedData或iv传错,或后端session_key过期 | 1. 小程序端打印console.log(encryptedData, iv)确认长度;2. 后端检查code2session返回的session_key是否有效(微信文档说30天,实际可能更短);3. 确保appid在解密时与code2session时一致 |
{"code":5001,"msg":"appid in watermark does not match"} | 小程序AppID填错,或code2session用的AppID与解密用的不一致 | 检查settings.py中WECHAT_MINI_PROGRAM_APPID是否与小程序后台一致,且decrypt_user_data函数传入的appid参数正确 |
登录后request.user是AnonymousUser | @login_required_api装饰器未生效,或Token过期 | 1. 检查请求Header是否有Authorization: Bearer <token>;2. 用pyjwt命令行工具解码Token,看exp是否过期;3. 确认REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES']包含JWTAuthentication |
同一用户多次登录,数据库出现多条WechatUser记录 | bind_or_create_user逻辑未覆盖所有场景 | 检查unionid和openid的查找顺序,确保unionid存在时优先用它,且WechatUser模型的unionid字段允许null=True |
5.2 文件上传典型故障排查
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
小程序端wx.uploadFile报fail network error | Nginx配置未开启client_max_body_size,或Django DATA_UPLOAD_MAX_MEMORY_SIZE太小 | 在Nginx配置中添加client_max_body_size 100M;;在Django settings.py中设置DATA_UPLOAD_MAX_MEMORY_SIZE = 100 * 1024 * 1024 |
| 上传后文件损坏(图片打不开) | magic.from_buffer消耗了文件指针,storage.save()读到空内容 | 必须在magic.from_buffer后调用file_obj.seek(0),模板已内置此逻辑 |
上传大文件时Django报ConnectionResetError | uwsgi或gunicorn的timeout设置过短 | uwsgi --harakiri 300(5分钟),gunicorn --timeout 300;同时调整nginx proxy_read_timeout 300 |
本地存储路径出现../导致目录穿越 | file_obj.name含恶意路径 | LocalFileStorage._save()中已用os.path.normpath处理,但建议在validate_file里增加'..' in file_obj.name校验 |
5.3 我踩过的三个深坑与独家技巧
坑一:微信session_key的“伪永久性”陷阱
微信文档说session_key有效期30天,但实际中,只要用户在小程序内有活跃行为(如打开、点击),它的有效期就会刷新。然而,一旦用户超过30天未打开小程序,session_key必然失效。我曾有个项目,用户反馈头像突然变空白,排查发现是session_key过期后,后端用它去解密旧的encryptedData(缓存的),结果失败。解决方案:绝不缓存session_key用于解密,每次解密都走code2session流程。模板的login_view里,code2session和decrypt是原子操作,session_key只在本次请求内有效。
坑二:Django FileField的“假删除”问题
ResourceFile模型用FileField存储路径,但调用resource.delete()时,Django只删数据库记录,不删物理文件!线上磁盘爆满的罪魁祸首。模板在ResourceFile.delete()方法里重写了:
def delete(self, *args, **kwargs):
# 先删文件
if self.storage_path:
storage_backend = get_storage_backend()
storage_backend.delete(self.storage_path)
super().delete(*args, **kwargs)
坑三:跨域配置的“OPTIONS预检”遗漏
很多教程只配CORS_ALLOW_ALL_ORIGINS = True,但在生产环境,必须精确控制。模板的core/middleware.py里,CORS中间件手动处理OPTIONS请求:
class CorsMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.method == "OPTIONS":
response = HttpResponse()
response["Access-Control-Allow-Origin"] = "https://your-miniprogram-domain.com"
response["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
response["Access-Control-Allow-Headers"] = "Content-Type, Authorization, X-Requested-With"
response["Access-Control-Allow-Credentials"] = "true"
return response
response = self.get_response(request)
response["Access-Control-Allow-Origin"] = "https://your-miniprogram-domain.com"
response["Access-Control-Allow-Credentials"] = "true"
return response
技巧:小程序域名必须是
https://开头,且与微信后台配置的“业务域名”完全一致,包括www.前缀。我曾因少写www.,跨域失败三天。
6. 项目启动与部署:从零到上线的完整清单
6.1 本地开发环境搭建(5分钟)
-
克隆模板:
bash git clone https://github.com/your-repo/django-mini-program-template.git cd django-mini-program-template -
创建虚拟环境并安装依赖:
bash python -m venv venv source venv/bin/activate # Linux/macOS # venv\Scripts\activate # Windows pip install -r requirements.txt -
配置环境变量(复制
.env.example为.env):
env DEBUG=True SECRET_KEY=your-secret-key-here DATABASE_URL=sqlite:///db.sqlite3 WECHAT_MINI_PROGRAM_APPID=wx1234567890abcdef WECHAT_MINI_PROGRAM_SECRET=your-app-secret-here FILE_UPLOAD_MAX_SIZE={"image/jpeg": 2097152, "audio/mpeg": 10485760} ALLOWED_FILE_TYPES={"image/jpeg": "image", "audio/mpeg": "audio"} -
初始化数据库:
bash python manage.py migrate python manage.py createsuperuser # 创建管理员账号 -
启动开发服务器:
bash python manage.py runserver
访问http://127.0.0.1:8000/api/v1/login/,用Postman发送测试请求,验证登录链路。
6.2 生产环境部署 checklist
- Web服务器:Nginx反向代理,配置
client_max_body_size 100M;,proxy_pass http://127.0.0.1:8000;。 - 应用服务器:Gunicorn(推荐)或Uvicorn(ASGI),启动命令:
bash gunicorn config.wsgi:application --bind 127.0.0.1:8000 --workers 4 --timeout 300 - 数据库:切换
settings.py中的DATABASES为PostgreSQL,确保psycopg2已安装。 - 静态文件:
python manage.py collectstatic,Nginx配置/static/指向STATIC_ROOT。 - 媒体文件:
MEDIA_ROOT目录需Nginx有读写权限,或直接用OSS/COS托管。 - 环境变量:生产环境禁用
.env,用系统环境变量或supervisor配置传递。
6.3 测试用例覆盖要点(tests.py已内置)
模板附带的单元测试不是摆设,它覆盖了最脆弱的环节:
test_code2session_success:模拟微信API返回正常数据,验证code2session函数解析正确。test_decrypt_user_data_success:用真实的encryptedData、iv、session_key(来自微信官方调试工具)测试解密结果。test_upload_file_validation:上传一个伪造的.jpg后缀的PHP文件,验证validate_file拒绝它。test_login_required_decorator:对未登录请求、无效Token请求、过期Token请求,验证装饰器返回正确的HTTP状态码和错误码。
运行测试:
python manage.py test apps.user.tests --keepdb # --keepdb避免每次重建数据库
最后分享一个小技巧:在
settings.py里加一行LOGGING_CONFIG = None,然后用logging.basicConfig(level=logging.DEBUG),可以实时看到code2session的HTTP请求详情,调试微信接口问题时 invaluable。
这个模板不是终点,而是起点。它把小程序后端最耗神的基建工作标准化了,剩下的,就是专注你的业务逻辑——用户积分怎么算、订单状态怎么流转、消息推送怎么设计。当你不再为登录失败或文件上传报错焦头烂额,真正的开发乐趣才刚刚开始。
简介:开箱即用的Django服务端模板,专为微信小程序定制。内置完整登录链路:接收小程序传来的code,调用微信接口换取session_key和openid,解密用户敏感信息(如encryptedData),完成用户绑定与会话维护;支持图片、音频、PDF、Word等常见格式的文件上传,含文件类型校验、大小限制、本地存储路径管理,预留云存储(如OSS、COS)扩展接口。配套基础权限控制(如登录态校验装饰器)、标准化RESTful路由设计、跨域(CORS)预配置、统一响应格式(含code/msg/data结构)、微信签名工具、缓存封装类及通用加密解密方法。项目结构规范,包含models定义、views逻辑分层、urls路由映射、migrations数据库迁移脚本、单元测试用例、requirements依赖清单和详细README说明,可直接作为小程序后端启动基础。

349

被折叠的 条评论
为什么被折叠?



