ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

微信小程序登录与文件上传:Django后端实现完整指南

微信小程序登录与文件上传:Django后端实现完整指南 简介这是一套基于Django框架开发的微信小程序登录与资源上传接口项目读者需要具备Python后端基础知识适合后端工程师、小程序开发者以及即将完成毕业设计的学生。项目的核心目标是打通微信小程序与服务器之间的身份认证和文件传输链路包含微信授权登录后的凭证换取与用户信息获取以及通过文件字段实现资源上传、存储与访问地址返回。实现时综合运用了Django的ORM模型、认证权限体系以及Django REST Framework的序列化与视图工具同时关注接口安全、异常处理和并发上传等常见问题。资源压缩包内共四十五个文件主体为四十二个Python源码文件另有说明文档、依赖清单和代码忽略配置文件整个压缩包只有四十四KB结构精简适合快速阅读源码。整体代码沿用Django标准工程目录将接口应用、工具模块、核心配置、数据库迁移与测试用例分开组织便于按功能模块逐层拆解。该项目目前已有六百三十八人学习下载既能作为小程序后端开发的入门练手素材也能为实际项目中的登录与上传功能提供直接的代码参考。1. 小程序绕不开的登录与上传微信小程序上线第一天就有两件事绕不开让用户以稳定的身份进来以及把手机里的图片、音视频交到后端。前者是 wx.login 的 code 换身份流程后者是 multipart/form-data 的二进制上传链路。两件事单独看都不复杂但放进 Django 项目里一起设计时用户模型怎么建、token 存哪里、上传文件落哪个目录、生产环境由谁服务细节马上冒出来。这组接口是后端工程师和全栈开发者的日常聊天工具、内容社区、活动报名小程序几乎每个项目都要重复实现一遍。下面的内容按一个 Django 项目中可以直接落地的顺序展开登录接口怎么写、openid 怎么入库、上传接口如何做类型与大小校验、token 如何贯穿两个接口的鉴权最后是生产环境的配置建议和排错方法。2. 微信登录接口code 换身份、token 换鉴权2.1 登录链路拆干净code、session_key、openid 各管什么直接说结论小程序端的 wx.login 不会把用户的 openid 直接发给你它只返回一个有效期五分钟的临时凭证 code。后端拿到 code 之后要拿它加上小程序的 appid 和 secret调微信的jscode2session接口才能换回 openid、session_key 和 unionid。三个值意义完全不同code一次性凭证5 分钟有效只能换一次不能在客户端缓存。openid用户在当前小程序下的唯一标识是后端建用户表时的天然主键。session_key会话密钥只有解密用户手机号、运动数据等敏感信息时才用到。有了这个前提Django 侧的接口设计方向就定了。登录接口收到 code 后调微信接口换 openid查数据库里有没有这个用户没有就创建一个最后给小程序端返回一个自定义 token。这里有一个新手常犯的错误直接拿 openid 当 token 用。openid 是稳定身份标识一旦在小程序端被取走别人就可以模拟你的身份上传文件。自定义 token 相当于隔离层小程序端只持有随机字符串后端靠映射关系识别用户。2.2 Django 登录视图的最小可运行代码我一般习惯把认证逻辑单独放进一个 app比如accounts。下面的 login 视图可以直接放进项目依赖第 4 章的Profile和UserToken模型先用最少的代码跑通链路import json import time import uuid import requests from django.conf import settings from django.contrib.auth.models import User from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST from .models import Profile, UserToken csrf_exempt require_POST def wechat_login(request): try: body json.loads(request.body) code body.get(code) except (ValueError, AttributeError): return JsonResponse({code: 400, msg: 请求体不是合法 JSON}, status400) if not code: return JsonResponse({code: 400, msg: 缺少 code 参数}, status400) url ( https://api.weixin.qq.com/sns/jscode2session f?appid{settings.WX_APPID} fsecret{settings.WX_SECRET} fjs_code{code} grant_typeauthorization_code ) resp requests.get(url, timeout5) data resp.json() if openid not in data: return JsonResponse({code: 400, msg: code 无效或已过期, detail: data}, status400) openid data[openid] try: profile Profile.objects.select_related(user).get(openidopenid) created False except Profile.DoesNotExist: user User.objects.create_user(usernameopenid, passworduuid.uuid4().hex) profile Profile.objects.create( useruser, openidopenid, nicknamefwx_{openid[-6:]}, avatar, ) created True token uuid.uuid4().hex UserToken.objects.create( userprofile.user, tokentoken, expires_atint(time.time()) 7 * 86400, ) return JsonResponse({ code: 0, msg: ok, data: { token: token, is_new: created, expires_in: 7 * 86400, }, })这段代码的执行顺序很直白解析请求体拿 code请求微信接口换身份根据 openid 查Profile查不到就顺手创建一个 DjangoUser和对应的Profile最后生成随机 token 写表并返回。几个关键点requests.get(..., timeout5)设置超时微信接口抖动时不能让请求一直挂着uuid.uuid4().hex生成 32 位 token熵足够不需要再拼接时间戳expires_in用 Unix 时间戳后续判断过期只做一次整数比较。csrf_exempt必须加。小程序不是浏览器环境不维护 CookieDjango 默认的 CSRF 中间件会拦截所有 POST。如果你的项目中还有浏览器端页面可以只在登录和上传这类纯 API 视图上局部使用这个装饰器不要全局关闭 CSRF。提示如果请求微信接口返回了errcode而不是openid把detail原样返回给调用方方便小程序端直接看到微信侧的错误码。2.3 登录参数表微信侧字段与开发配置把jscode2session涉及的参数收成表开发时对照着配置最省心参数类型必填说明appidstring是小程序唯一 ID从微信公众平台获取secretstring是小程序密钥与 appid 一一对应js_codestring是wx.login 返回的临时凭证只能使用一次grant_typestring是固定值 authorization_codeopenidstring响应用户在当前小程序下的唯一标识session_keystring响应会话密钥用于解密敏感数据unionidstring响应开放平台用户统一标识绑定到开放平台才有appid 和 secret 的配置建议放到settings.py里通过环境变量注入不要硬编码在代码或 Git 仓库里# settings.py import os WX_APPID os.environ.get(WX_APPID, ) WX_SECRET os.environ.get(WX_SECRET, )微信公众平台支持定期重置 secret重置后旧密钥立即失效。生产环境发现登录接口大面积失败时先去管理后台确认一下是不是有人重置过 secret。2.4 小程序端调用 login 接口的完整逻辑原生微信小程序端的调用方式如下放在app.js的onLaunch里每次冷启动都重新登录一次App({ onLaunch() { wx.login({ success: async (res) { if (!res.code) return; try { const resp await new Promise((resolve, reject) { wx.request({ url: https://your-domain.com/api/wechat/login, method: POST, data: { code: res.code }, success: resolve, fail: reject, }); }); if (resp.data.code 0) { wx.setStorageSync(token, resp.data.data.token); } } catch (err) { console.error(login failed, err); } }, }); }, });这里要注意wx.login的 code 必须在需要登录时临时获取不能复用旧的 code。后端返回code 无效或已过期时小程序端应该重新调一次wx.login再发请求而不是拿着旧 token 死磕。token 存到本地 storage 后后续所有请求都从 storage 取出来放在Authorization: Bearer token头里。如果用的是 uniapp把wx.request换成uni.requestwx.setStorageSync换成uni.setStorageSync逻辑不变。3. 资源上传接口multipart 表单与 Django 文件落地3.1 为什么文件上传不能走 Base64 JSON上传图片第一反应可能是转成 Base64 塞进 JSON。这个方案在演示项目里能跑但真实场景三个硬伤第一Base64 使原始体积膨胀约 33%。5 MB 的图片转完变成 6.6 MB 字符串流量白付三分之一小程序端更费电。第二Django 解析 JSON body 会把整个请求加载进内存8 MB 的 JSON 字符串意味着至少 8 MB 内存占用建立在该请求的整个生命周期上并发一高服务就抖。第三Base64 无法让 Django 进入 multipart 解析逻辑request.FILES直接为空文件大小校验、分块写入全部无从谈起。Django 对multipart/form-data的支持已经很成熟。request.FILES会按配置把文件对象放在内存或临时目录默认的文件上传处理器在FILE_UPLOAD_MAX_MEMORY_SIZE以内走内存超出就落临时文件。整个过程中文件内容不会一次性全量加载进 Python 进程配合chunks()分块读取内存占用是可控的。3.2 Django 接收 multipart 数据的完整代码上传视图放在apiapp 下逻辑直接可用import os import uuid from django.conf import settings from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST from .models import UploadedResource from .auth import get_user_from_request ALLOWED_EXTENSIONS {jpg, jpeg, png, gif, webp, mp4, pdf} MAX_FILE_SIZE 10 * 1024 * 1024 # 10MB csrf_exempt require_POST def upload_file(request): user get_user_from_request(request) if not user: return JsonResponse({code: 401, msg: 未登录或 token 已过期}, status401) if file not in request.FILES: return JsonResponse({code: 400, msg: 缺少 file 字段}, status400) upload request.FILES[file] ext upload.name.rsplit(., 1)[-1].lower() if . in upload.name else if ext not in ALLOWED_EXTENSIONS: return JsonResponse({code: 400, msg: 不支持的扩展名}, status400) if upload.size MAX_FILE_SIZE: return JsonResponse({code: 400, msg: 文件大小超过限制}, status400) new_name f{uuid.uuid4().hex}.{ext} relative_path os.path.join(uploads, new_name) abs_path os.path.join(settings.MEDIA_ROOT, relative_path) os.makedirs(os.path.dirname(abs_path), exist_okTrue) with open(abs_path, wb) as dest: for chunk in upload.chunks(chunk_size64 * 1024): dest.write(chunk) resource UploadedResource.objects.create( useruser, file_pathrelative_path, original_nameupload.name, sizeupload.size, content_typeupload.content_type, ) return JsonResponse({ code: 0, msg: ok, data: { resource_id: resource.id, url: fhttps://your-domain.com/media/{relative_path}, }, })这段代码的关键在upload.chunks(chunk_size64 * 1024)。chunk_size控制每批读入内存的字节数64 KB 是常见折中值既不会因块太小导致大量磁盘 IO也不会因块太大拉高内存。os.makedirs(..., exist_okTrue)保证MEDIA_ROOT/uploads在首次上传时自动创建。新文件名完全由后端生成原始文件名只作为展示字段存到original_name。两个容易混淆的参数需要单独点一下upload.size是 Django 从请求头里拿到的文件大小上传完成后才可靠upload.content_type来自客户端声明的 MIME 类型可以伪造只能参考不能作为安全依据。注意如果后续在文件保存前还要做内容检测必须调用upload.seek(0)把文件指针复位否则chunks()会从上一次读到的位置继续写入的文件会缺头。3.3 上传校验参数表与默认值把常碰到的配置项收进一张表方便对照设计参数建议默认值作用注意点扩展名白名单jpg/png/gif/webp/pdf/mp4阻断明显不该上传的文件白名单无法防伪造扩展名单文件大小上限10MB限制单个请求的磁盘占用要在写文件前判断分块读取大小64KB控制内存占用不是越小越好DATA_UPLOAD_MAX_MEMORY_SIZE1MB非文件字段的最大内存占用防止大量表单字段攻击FILE_UPLOAD_MAX_MEMORY_SIZE1MB文件小于该值时放内存调大后小文件速度快大流量消耗内存DATA_UPLOAD_MAX_MEMORY_SIZE和FILE_UPLOAD_MAX_MEMORY_SIZE是 Django 最容易混的两个配置。前者是 POST 请求中非文件字段能占用的最大内存默认 2.5 MB后者是上传文件小于多少字节时直接放内存而不是临时文件。生产环境都建议显式调成 1 MB 左右并降低DATA_UPLOAD_MAX_NUMBER_FIELDS。3.4 用 Authorization 头把上传接口绑到登录态登录接口做完了上传接口却没校验 token 是这组 API 最常见的疏漏。正确的做法是先解析请求头里的Authorization: Bearer token查表得到用户拿不到就 401。一个可复用的工具函数如下from .models import UserToken def get_user_from_request(request): auth_header request.headers.get(Authorization, ) if not auth_header.startswith(Bearer ): return None token_value auth_header[7:] try: token UserToken.objects.select_related(user).get(tokentoken_value) except UserToken.DoesNotExist: return None if token.is_expired(): token.delete() return None return token.user这个函数只处理三件事从请求头切出 token 字符串到UserToken表查询对应记录判断是否过期并顺手删除过期记录。select_related(user)一次 join 查出关联用户避免后续访问token.user时产生额外查询。上传视图拿到 user 后文件记录才能归属到具体账号后续做配额、审计和封禁才有数据基础。4. 用户模型与 token 持久化建模时避开返工陷阱4.1 微信用户映射 Django User 的三种方案开放登录做完建模选型绕不开。常见有三种做法方案是否复用 auth.User适合场景主要代价username 字段直接存 openid是最小演示用户名不可读字段扩展受限独立表存微信身份否只做微信业务Django 权限体系难复用User 加 Profile 扩展表是要后台、权限、业务闭环多维护一张表多数生产项目我推荐第三种。auth.User承担账号主身份点赞、评论、收藏都挂在request.user上微信身份用Profile扩展表承接openid、unionid、头像昵称都放在这里微信字段只影响登录模块不影响业务表。代码定义如下from django.contrib.auth.models import User from django.db import models class Profile(models.Model): user models.OneToOneField(User, on_deletemodels.CASCADE, related_nameprofile) openid models.CharField(max_length128, uniqueTrue, db_indexTrue) unionid models.CharField(max_length128, blankTrue, default) nickname models.CharField(max_length64, blankTrue, default) avatar models.URLField(blankTrue, default) created_at models.DateTimeField(auto_now_addTrue) class UserToken(models.Model): user models.ForeignKey(User, on_deletemodels.CASCADE, related_nametokens) token models.CharField(max_length32, uniqueTrue, db_indexTrue) expires_at models.IntegerField() created_at models.DateTimeField(auto_now_addTrue) def is_expired(self): import time return int(time.time()) self.expires_at class UploadedResource(models.Model): user models.ForeignKey(User, on_deletemodels.CASCADE, related_nameresources) file_path models.CharField(max_length255) original_name models.CharField(max_length255, blankTrue, default) size models.IntegerField(default0) content_type models.CharField(max_length100, blankTrue, default) created_at models.DateTimeField(auto_now_addTrue)openid加uniqueTrue和db_indexTrue登录流程每次都用 openid 查资料有索引才能稳定在毫秒级返回。expires_at用IntegerField存 Unix 时间戳避免和时区换算较劲is_expired方法在每次鉴权时都会被调用天然做一次时间比较而已。4.2 Token 表建模与自动过期逻辑这里要说明为什么不用 Django 自带的 session 框架。小程序端对 Cookie 的处理不如浏览器完整部分基础库版本下Set-Cookie不生效Django session 依赖 Cookie 的机制在小程序端直接不可靠。更关键的是clearsessions要手动维护 cron生产环境漏配会导致 session 表无限膨胀。自建 token 表把主动权握回手里登录时生成 token 写入UserToken上传接口从请求头解析 token 并用is_expired判断主动踢用户直接删记录下一请求查无此 token 返回 401。整条链路不存在过期记录清理问题查询时顺手删除即可。4.3 URL 路由与 settings 配置清单路由按模块拆开两个 app 各管一摊from django.conf import settings from django.conf.urls.static import static from django.contrib import admin from django.urls import include, path urlpatterns [ path(admin/, admin.site.urls), path(api/wechat/, include(accounts.urls)), path(api/, include(api.urls)), ] if settings.DEBUG: urlpatterns static(settings.MEDIA_URL, document_rootsettings.MEDIA_ROOT)accounts负责登录接口api负责上传和下载接口职责清晰不互相依赖。static()只服务于 DEBUG 模式生产环境必须让 Nginx 接管/media/目录不能让 Django 进程处理文件 IO。settings 里这几项要显式配置MEDIA_URL /media/ MEDIA_ROOT os.path.join(BASE_DIR, media) FILE_UPLOAD_MAX_MEMORY_SIZE 1024 * 1024 # 1MB DATA_UPLOAD_MAX_MEMORY_SIZE 1024 * 1024 # 1MB DATA_UPLOAD_MAX_NUMBER_FIELDS 100第一个字段是文件访问前缀第二个是磁盘落体目录生产环境建议指向独立数据盘。DATA_UPLOAD_MAX_NUMBER_FIELDS默认 1000显式降为 100避免攻击者用海量表单字段消耗内存。5. 资源下载、权限控制与三项安全加固5.1 下载接口的权限控制与文件名处理上传接口完成后资源访问接口也要跟着设计。常见做法是不直接暴露/media/路径而是经过一个视图转发方便控制权限和记录下载日志import os from django.conf import settings from django.http import FileResponse, Http404, JsonResponse from django.views.decorators.http import require_GET from .models import UploadedResource from .auth import get_user_from_request require_GET def download_resource(request, resource_id): user get_user_from_request(request) if not user: return JsonResponse({code: 401, msg: 未登录}, status401) try: resource UploadedResource.objects.get(pkresource_id) except UploadedResource.DoesNotExist: raise Http404(资源不存在) abs_path os.path.join(settings.MEDIA_ROOT, resource.file_path) return FileResponse( open(abs_path, rb), as_attachmentTrue, filenameresource.original_name, )FileResponse是流式读取文件不会整体加载进内存。as_attachmentTrue强制下载改为 False 会在浏览器里直接预览图片。filename会自动处理中文字符的编码前端拿到后仍是原始文件名。这里没有给下载接口加csrf_exemptGET 请求不会被 CSRF 校验拦截。5.2 文件内容嗅探、上传限流与路径穿越防护扩展名白名单能挡住误操作挡不住恶意用户。攻击者把木马文件改成.jpg后缀传上来再用 URL 地址直接访问就能让服务器分发非法内容。三个加固动作按优先级做第一文件内容嗅探。用python-magic读文件头判断真实 MIME 类型在扩展名校验之后执行import magic mime magic.from_buffer(upload.read(1024), mimeTrue) if mime not in {image/jpeg, image/png, image/gif, application/pdf, video/mp4}: return JsonResponse({code: 400, msg: 文件内容类型不合法}, status400) upload.seek(0)read(1024)只读 1 KB内存压力可忽略。seek(0)必须有否则后续 chunks 从偏移 1024 开始文件头缺失。图片类资源还可以用 Pillow 重新编码去掉 Exif、GPS 等元数据等于给图片整体消毒。第二上传限流。同一用户在上传视图内做频率控制一小时 30 次是偏低的上限超出返回 429。第三方库django-ratelimit直接装饰器搞定或者用 Redis 的 INCR EXPIRE 自己实现import time from django.conf import settings from django.core.cache import cache def allow_upload(user_id): key fupload_limit:{user_id} count cache.get(key, 0) if count 30: return False cache.set(key, count 1, timeout3600) return True骨架约 10 行不引依赖。限流必须按用户维度做按 IP 限流在小程序环境没有意义因为所有请求几乎都走同样的运营商出口。第三路径穿越防护。前文已经用uuid.uuid4().hex重新生成文件名天然规避了../../这类路径构造。Django 的upload.name本身不允许路径分隔符但原始文件名依然不能直接拼接存储路径随机文件名是最干净的方案。6. 高频排错对照表与上线前验证命令小程序登录、上传、下载三条链路全部接通后把常见故障列成对照表排查时直接按表操作现象原因处理方式登录返回 500微信接口超时或 secret 错误查看日志手动 curl 确认微信接口是否可达上传返回 403CSRF 校验拦截视图加csrf_exempt别全局关闭上传返回 413Nginx body 大小限制配置client_max_body_size 20m;并 reload上传后图片打不开写入不完整或目录权限检查 media 目录属主确认 Nginx 用户可读小程序端请求无响应域名未在后台配置微信公众平台增加 request 合法域名token 一小时后失效expires_at 配置错误检查 token 表中存储的时间戳是否正确调试接口时用 curl 模拟最直接先测试登录接口curl -v -X POST https://your-domain.com/api/wechat/login \ -H Content-Type: application/json \ -d {code:test_code}如果返回 JSON 里detail字段含微信错误码对照微信官方文档定位参数问题。再测上传接口-F直接构造 multipart 请求curl -X POST https://your-domain.com/api/upload \ -H Authorization: Bearer token \ -F file./test.jpg这条命令返回资源 ID 和访问 URL。如果 token 无效会带 401 状态码加上-v查看响应头确认Authorization是否传到后端。微信开发者工具里可以临时勾选“不校验合法域名”但那个选项只对本地开发有效发布前必须以 HTTPS 域名为准。上线前最后做一次全链路验证DEBUG改为 False执行python manage.py collectstatic重启 gunicorn 或 uwsgi 让配置生效然后在微信公众平台核对服务器域名与上传接口返回的访问地址是否完全一致再提交审核发布。本文还有配套的精品资源点击获取
返回列表