简介:这是一套基于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"}, status=400) if not code: return JsonResponse({"code": 400, "msg": "缺少 code 参数"}, status=400) url = ( "https://api.weixin.qq.com/sns/jscode2session" f"?appid={settings.WX_APPID}" f"&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: return JsonResponse({"code": 400, "msg": "code 无效或已过期", "detail": data}, status=400) openid = data["openid"] try: profile = Profile.objects.select_related("user").get(openid=openid) created = False except Profile.DoesNotExist: user = User.objects.create_user(username=openid, password=uuid.uuid4().hex) profile = Profile.objects.create( user=user, openid=openid, nickname=f"wx_{openid[-6:]}", avatar="", ) created = True token = uuid.uuid4().hex UserToken.objects.create( user=profile.user, token=token, expires_at=int(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(..., timeout=5)设置超时,微信接口抖动时不能让请求一直挂着;uuid.uuid4().hex生成 32 位 token,熵足够,不需要再拼接时间戳;expires_in用 Unix 时间戳,后续判断过期只做一次整数比较。
csrf_exempt必须加。小程序不是浏览器环境,不维护 Cookie,Django 默认的 CSRF 中间件会拦截所有 POST。如果你的项目中还有浏览器端页面,可以只在登录和上传这类纯 API 视图上局部使用这个装饰器,不要全局关闭 CSRF。
提示:如果请求微信接口返回了
errcode而不是openid,把detail原样返回给调用方,方便小程序端直接看到微信侧的错误码。
2.3 登录参数表:微信侧字段与开发配置
把jscode2session涉及的参数收成表,开发时对照着配置最省心:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| appid | string | 是 | 小程序唯一 ID,从微信公众平台获取 |
| secret | string | 是 | 小程序密钥,与 appid 一一对应 |
| js_code | string | 是 | wx.login 返回的临时凭证,只能使用一次 |
| grant_type | string | 是 | 固定值 authorization_code |
| openid | string | 响应 | 用户在当前小程序下的唯一标识 |
| session_key | string | 响应 | 会话密钥,用于解密敏感数据 |
| unionid | string | 响应 | 开放平台用户统一标识,绑定到开放平台才有 |
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.request,wx.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 已过期"}, status=401) if "file" not in request.FILES: return JsonResponse({"code": 400, "msg": "缺少 file 字段"}, status=400) 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": "不支持的扩展名"}, status=400) if upload.size > MAX_FILE_SIZE: return JsonResponse({"code": 400, "msg": "文件大小超过限制"}, status=400) 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_ok=True) with open(abs_path, "wb") as dest: for chunk in upload.chunks(chunk_size=64 * 1024): dest.write(chunk) resource = UploadedResource.objects.create( user=user, file_path=relative_path, original_name=upload.name, size=upload.size, content_type=upload.content_type, ) return JsonResponse({ "code": 0, "msg": "ok", "data": { "resource_id": resource.id, "url": f"https://your-domain.com/media/{relative_path}", }, })这段代码的关键在upload.chunks(chunk_size=64 * 1024)。chunk_size控制每批读入内存的字节数,64 KB 是常见折中值,既不会因块太小导致大量磁盘 IO,也不会因块太大拉高内存。os.makedirs(..., exist_ok=True)保证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_SIZE | 1MB | 非文件字段的最大内存占用 | 防止大量表单字段攻击 |
| FILE_UPLOAD_MAX_MEMORY_SIZE | 1MB | 文件小于该值时放内存 | 调大后小文件速度快,大流量消耗内存 |
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(token=token_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_delete=models.CASCADE, related_name="profile") openid = models.CharField(max_length=128, unique=True, db_index=True) unionid = models.CharField(max_length=128, blank=True, default="") nickname = models.CharField(max_length=64, blank=True, default="") avatar = models.URLField(blank=True, default="") created_at = models.DateTimeField(auto_now_add=True) class UserToken(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tokens") token = models.CharField(max_length=32, unique=True, db_index=True) expires_at = models.IntegerField() created_at = models.DateTimeField(auto_now_add=True) def is_expired(self): import time return int(time.time()) > self.expires_at class UploadedResource(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="resources") file_path = models.CharField(max_length=255) original_name = models.CharField(max_length=255, blank=True, default="") size = models.IntegerField(default=0) content_type = models.CharField(max_length=100, blank=True, default="") created_at = models.DateTimeField(auto_now_add=True)openid加unique=True和db_index=True,登录流程每次都用 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_root=settings.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": "未登录"}, status=401) try: resource = UploadedResource.objects.get(pk=resource_id) except UploadedResource.DoesNotExist: raise Http404("资源不存在") abs_path = os.path.join(settings.MEDIA_ROOT, resource.file_path) return FileResponse( open(abs_path, "rb"), as_attachment=True, filename=resource.original_name, )FileResponse是流式读取,文件不会整体加载进内存。as_attachment=True强制下载,改为 False 会在浏览器里直接预览图片。filename会自动处理中文字符的编码,前端拿到后仍是原始文件名。这里没有给下载接口加csrf_exempt,GET 请求不会被 CSRF 校验拦截。
5.2 文件内容嗅探、上传限流与路径穿越防护
扩展名白名单能挡住误操作,挡不住恶意用户。攻击者把木马文件改成.jpg后缀传上来,再用 URL 地址直接访问,就能让服务器分发非法内容。三个加固动作按优先级做:
第一,文件内容嗅探。用python-magic读文件头判断真实 MIME 类型,在扩展名校验之后执行:
import magic mime = magic.from_buffer(upload.read(1024), mime=True) if mime not in {"image/jpeg", "image/png", "image/gif", "application/pdf", "video/mp4"}: return JsonResponse({"code": 400, "msg": "文件内容类型不合法"}, status=400) 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 = f"upload_limit:{user_id}" count = cache.get(key, 0) if count >= 30: return False cache.set(key, count + 1, timeout=3600) return True骨架约 10 行,不引依赖。限流必须按用户维度做,按 IP 限流在小程序环境没有意义,因为所有请求几乎都走同样的运营商出口。
第三,路径穿越防护。前文已经用uuid.uuid4().hex重新生成文件名,天然规避了../../这类路径构造。Django 的upload.name本身不允许路径分隔符,但原始文件名依然不能直接拼接存储路径,随机文件名是最干净的方案。
6. 高频排错对照表与上线前验证命令
小程序登录、上传、下载三条链路全部接通后,把常见故障列成对照表,排查时直接按表操作:
| 现象 | 原因 | 处理方式 |
|---|---|---|
| 登录返回 500 | 微信接口超时或 secret 错误 | 查看日志,手动 curl 确认微信接口是否可达 |
| 上传返回 403 | CSRF 校验拦截 | 视图加@csrf_exempt,别全局关闭 |
| 上传返回 413 | Nginx 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 让配置生效,然后在微信公众平台核对服务器域名与上传接口返回的访问地址是否完全一致,再提交审核发布。
本文还有配套的精品资源,点击获取