ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Python+Django+Vue3:校园失物招领系统设计与实现全解析

Python+Django+Vue3:校园失物招领系统设计与实现全解析 1. 从一次丢校园卡的惨痛经历说起做这个系统的念头其实特别朴素我在学校食堂丢了三次校园卡三次都是靠运气找回来的。第一次是去食堂窗口挨个问第二次是刷朋友圈看到别人转发的好心人消息第三次干脆是补办完第二天才在宿舍楼下公告栏看到招领信息。三次都没赶上热乎的三次信息都藏在犄角旮旯里。后来跟几个做学生工作的朋友聊起来才发现全校每天丢失物品的数量远比想象中多——校园卡、耳机、雨伞、眼镜、书包、证件甚至笔记本整机而真正回到失主手里的比例低得可怜。信息散落在表白墙、年级群、失物招领处的小本子上丢失的人不知道去哪里找捡到的人不知道交给谁。就算发了朋友圈消息也会在几十条动态之后被淹没。后来我决定自己动手做一个面向校园场景的失物招领系统技术栈就是我项目标题里写的这套Python Django Vue3前后端分离覆盖物品发布、智能匹配、认领申请、状态流转的完整闭环。这篇文章我会从需求拆解、数据模型、后端API、前端交互、部署运维到实际踩坑把整个设计和实现过程完整讲一遍。无论你是计算机专业的在校生、准备做毕业设计的同学还是刚学完Django和Vue3想找个完整项目练手的前端/后端开发者应该都能从里面拿走一些可以直接用在自己项目里的东西。2. 校园场景的特殊性为什么不能直接套用通用的失物招领方案动手写代码之前我把需求逻辑先捋了几个晚上。很多人一听说失物招领系统第一反应就是“做一个信息发布平台不就行了”但实际上校园场景跟社会上的失物招领完全不是一回事会直接影响表设计和业务流程设计。2.1 人群相对固定信息可以更精准在校园里丢东西的人几乎一定能被限定在某个范围内学生有学号、学院、年级教职工有工号、部门。这意味着系统可以做“圈定范围推送”拾主捡到一张校园卡可以快速通过学号定位失主所在的学院甚至年级减少中间环节。社会化的失物招领平台很难做到这一点因为用户之间基本没有组织关系。这个属性在业务上会带来一个直接变化系统需要记录用户的身份类型和院系信息发布失物和招领时都可以带组织标签搜索和推送也要支持按院系过滤。2.2 物品流转周期短需要有“冷静期”和“认领凭证”校园里丢的多数东西价值不高但很常用比如校园卡、钥匙、伞失主通常会在一到两天内发现自己丢了东西。这要求系统对“招领信息发布后多久没人认领”“失主提交认领后如何确认归属”有一套明确流程而不是简单给个联系方式让双方私下对接就行。认领环节尤其关键。我在最初设计时想过只做“搭桥”就是让失主看到信息后自己去联系拾主。但后来否决了。原因有两点公开联系方式会带来隐私问题校园系统里学生手机号、微信、宿舍楼栋这些信息不能随意暴露。没有平台介入的认领流程可能出现冒领纠纷。现实中就有不少一卡通被冒领盗刷的案例。所以最终的设计是拾主发布招领后失主必须在系统内提交认领申请填写物品特征、丢失时间、丢失地点等验证信息拾主根据申请信息判断是否通过。平台保留完整的申请记录谁在什么时候申请了什么物品、通过了没有、有没有争议全部可追溯。2.3 校园物品有很高的重复性搜索和匹配必须做“模糊化”校园里最常见的情况是一天有七八个人丢的都是黑色雨伞或者同款白色耳机。光靠标题搜索是找不到的因为所有人都写“黑色雨伞”四个字。想要让系统真正好用必须把“颜色”“物品类型”“品牌”“地点”这类结构化的属性抽出来单独建字段搜索时按属性组合匹配同时还要支持同一关键词命中多条近似记录。这一点在数据模型设计时我会细讲属于整个项目里最容易被低估、又最影响体验的部分。3. 技术选型为什么后端是Django前端是Vue3作为前后端分离项目选型的核心考量是团队熟悉度、开发效率、生态成熟度和后续维护成本。我用了 Python Django DRF SimpleJWT 做后端Vue3 Vite Pinia Element Plus 做前端数据库用的 MySQL。下面说清楚每个选择的理由。3.1 Django自带“管理层”校园业务天然需要后台校园失物招领系统不是一个纯C端产品它绕不开学校管理方的介入。比如校学生会或保卫处需要看到所有未处理的物品记录管理员要把长期无人认领的物品标记为移交处理需要统计每个楼栋、每个时段的丢失/找回率用于后勤优化Django自带Admin后台注册模型后几乎零成本获得一套可用的管理界面。这对学生团队来说非常实用不需要额外开发一个管理端前端页面就能先跑起来。而且Django的ORM在建模复杂的关联关系失物、招领、申请、通知、评论时比直接写SQL高效得多迁移工具也能自动同步表结构变更。后端框架对比上我也认真考虑过Flask和FastAPI。Flask灵活但过于自由所有东西都要自己拼装不适合一个需要多人协作、有较长生命周期的项目FastAPI的性能确实好异步支持也强但团队里当时没人写过FastAPI入门成本比Django高。Django的“全家桶”风格——自带ORM、Admin、Session、认证、模板、迁移——恰恰是校园项目最需要的能减少踩坑也方便把代码交给下一届学弟学妹维护。3.2 Django REST FrameworkAPI开发的默认选择没有之一选Django之后API层基本就是DRFDjango REST Framework了。它有现成的Serializer、ViewSet、Router能快速把Model结构转成标准REST API。分页、过滤、排序、认证这些高频需求都有插件文档也极其完善遇到问题基本搜索一下就有答案。认证方案我用了SimpleJWT来做Token认证。没有用Django自带的Session因为Vue3前端和后端完全分离前端可能部署在另一台服务器或另一个域名下Session的Cookie跨域处理很麻烦。JWT的优点是后端不存状态、前端存Token、请求头里带Authorization字段天然适合前后端分离架构。3.3 Vue3适合校园项目的渐进式框架前端为什么选Vue3而不是Vue2或者React原因主要有三个。一是Vue3的组合式APIComposition API让复杂页面的逻辑复用变得很轻松。比如“图片上传组件”“物品属性筛选组件”这类在不同页面都会用到的功能抽成一个useXxx组合函数就能复用代码量比Vue2的Options API少很多。二是Vue3对TypeScript支持更友好即使不用TS写全量类型在项目里逐步混用也很自然。作为团队项目类型约束能在编译期发现很多低级错误。三是生态配套成熟。Vite启动快、热更新爽Element Plus是现成的后台风格组件库做管理页面很顺手。Pinia作为新一代状态管理工具API设计比Vuex简洁太多没有Mutation那层冗余的东西写起来非常舒服。3.4 整体架构一览前端Vue3 Vite Pinia Element Plus | | HTTP/JSON JWT Token | 后端Django DRF SimpleJWT MySQL | | ORM | MySQL物品数据 / 用户数据 / 申请记录 / 通知记录前端只负责渲染页面和收集操作后端负责所有业务逻辑、数据校验、权限控制和文件存储。图片文件不直接存数据库而是存放在服务器的媒体目录MEDIA_ROOT数据库只存储文件URL路径。这套架构也是大多数中小型前后端分离项目的标准姿势。4. 数据模型设计围绕“物品生命周期”建表不搞花活数据模型是整个系统最核心的部分一旦设计不清晰后面写API和前端页面都会被拖累。我在建模时遵循的原则是围绕“物品从丢失到找回的完整生命周期”来设计实体一条主线贯穿始终不要一上来就设计一堆花哨的表。4.1 核心实体失物、招领、认领申请、用户系统的主要实体有四类我用表格把职责边界列一下实体对应模型核心职责主要状态用户User身份认证、个人信息、院系信息正常/禁用失物LostItem失主发布的“我在找什么”寻找中/已找到/已关闭招领FoundItem拾主发布的“我捡到了什么”招领中/已被认领/已移交认领申请ClaimRequest失主发起的“这件东西应该是我的”待审核/已通过/已拒绝/已撤销其中用户这里我扩展了Django自带的AbstractUser增加了学号/工号、所属院系、用户类型等业务字段没有直接把学生信息塞进单独的一张Profile表——对于这个规模的项目扩展自带User模型是最省事、查询效率也最高的方式。4.2 LostItem 和 FoundItem 的字段设计细节失物和招领两张表结构高度相似我分别建立模型不合并成一张“物品表”。原因很简单两边发布的本质方向不同一个表达“正在寻找”一个表达“正在提供”后续业务状态流转不一样失物最终是“找到并关闭”招领最终是“被认领并关闭”分开后查询各自业务数据时SQL更简单管理后台统计也更清晰LostItem 核心字段class LostItem(models.Model): STATUS_CHOICES ( (searching, 寻找中), (found, 已找到), (closed, 已关闭), ) user models.ForeignKey(settings.AUTH_USER_MODEL, on_deletemodels.CASCADE, verbose_name丢失人) title models.CharField(max_length100, verbose_name标题) category models.CharField(max_length50, choicesCATEGORY_CHOICES, verbose_name物品分类) color models.CharField(max_length50, blankTrue, verbose_name颜色) brand models.CharField(max_length50, blankTrue, verbose_name品牌) location models.CharField(max_length100, verbose_name丢失地点) lost_time models.DateTimeField(verbose_name丢失时间) description models.TextField(blankTrue, verbose_name详细描述) images models.JSONField(defaultlist, verbose_name图片列表) status models.CharField(max_length20, choicesSTATUS_CHOICES, defaultsearching) created_at models.DateTimeField(auto_now_addTrue) updated_at models.DateTimeField(auto_nowTrue)这里有两个特殊的设计点我专门解释一下。第一images字段用了JSONField存一个URL列表而不是建一张图片子表。因为大多数丢东西的帖子很少超过三张图片提前建子表还要维护外键关系和级联删除非常麻烦。JSONField直接搞定多图存储后端只用ImageField.upload_to配置一下目录即可。缺点是如果你后续要做图片的单独管理比如后台统一预览所有图片就需要自己写处理逻辑。就这个项目的体量来说JSONField是最合理的取舍。第二category和color这两个“结构化属性”字段必须单独存不能只写在description里。这是我在前面提到的“模糊搜索和匹配”需求的基础。类似的还有品牌brand虽然可以留空但只要用户填了就能成为高价值匹配条件。4.3 认领申请模型与状态机ClaimRequest 是整个系统里业务逻辑最重的表认领流程的每一步都记录在案class ClaimRequest(models.Model): STATUS_CHOICES ( (pending, 待审核), (approved, 已通过), (rejected, 已拒绝), (withdrawn, 已撤销), ) found_item models.ForeignKey(FoundItem, on_deletemodels.CASCADE, related_nameclaim_requests) applicant models.ForeignKey(settings.AUTH_USER_MODEL, on_deletemodels.CASCADE, verbose_name申请人) evidence models.TextField(verbose_name认领说明/凭证信息) contact models.CharField(max_length50, verbose_name联系方式) status models.CharField(max_length20, choicesSTATUS_CHOICES, defaultpending) created_at models.DateTimeField(auto_now_addTrue) reviewed_at models.DateTimeField(nullTrue, blankTrue, verbose_name审核时间)认领流程的状态机可以直接这样描述拾主发布招领物品状态为招领中失主浏览招领信息提交认领申请申请状态为待审核拾主查看申请列表通过或拒绝申请通过后招领物品状态自动变为已被认领失主确认收到物品申请状态保持已通过如果超过一定时间没人认领管理员可以手动将物品标记为已移交我特别加了一个evidence凭证信息字段。它不是简单写一句“这是我丢的”而是要求申请人描述物品的细节特征比如校园卡背面的贴纸、耳机充电舱的划痕、雨伞伞柄的系绳标记。拾主可以根据这些细节筛选真实失主。这一步在真实场景中非常关键能极大降低冒领概率。4.4 分类标签和匹配辅助字段为了让搜索和匹配更强大我在category之外还设计了一个tagsJSONField用于存放一些额外的特征标签比如“带挂绳”“有贴纸”“刻字”“特定图案”等。这些特征在表单里不是让用户随便输入的而是系统预设一批常见标签让用户勾选避免出现“黑色”“黑”“黑sai”这种同义词污染。预设标签的好处还有一个——后端能做精准的标签权重匹配这个我在下一章具体说实现方式。5. 后端核心业务实现匹配算法、认领流程、权限控制模型设计好之后后端实现的重点就是三个物品匹配推荐、认领流程接口、权限控制。5.1 物品匹配推荐不是搜索引擎而是“属性打分排序”校园失物招领里的搜索跟搜索引擎不一样用户不会去输入长句多数情况是输入“丢了”“雨伞”“主楼”这类短词或者什么都不输入直接看列表。我从一开始就没打算上Elasticsearch那个对这个场景太重了。我的做法是给后端加一个“匹配度打分”方法根据两条记录的属性重合程度算匹配分。核心思路很简单把失物和招领的category、color、brand、location、tags拿出来逐一比较相同项加权得分。权重设计是属性权重理由category物品类型40分类型都不同就不用看了最高的区分度color颜色20分同色是重要线索但黑色白色太多权重不宜太高location地点区域20分丢失和捡到地点接近说明相关性高brand品牌15分能够匹配上说明大概率是同一件tags特征标签5分/个精确特征每匹配一个加5分上不封顶匹配度计算是一个独立的services.py模块不耦合在View里。丢失方发布一条失物信息后后端会定时比如每五分钟把所有状态为招领中的物品按匹配度排序把匹配度达到60分以上的记录推送给失主。我的实现方式是用Django的signal在FoundItem创建后立刻跑一次所有searching状态的失物匹配再异步生成站内通知反过来失物发布的时候也会立刻扫描一次招领库。这里有个实现细节容易踩坑如果直接对所有记录两两匹配物品量少时无所谓但一旦积累到千条以上又会很慢。我在设计的时候加了两个前置过滤条件先粗筛再精算category必须一致否则直接跳过不参与打分丢失时间在招领时间之前理论上丢失时间不可能晚于被捡到时间这两条用ORM的filter先过滤把参与计算的记录缩小一个数量级再做字段级的属性比较性能完全够用。5.2 认领流程接口设计状态校验放后端不信任前端认领相关的API一共设计成四个POST /api/claims/提交认领申请GET /api/claims/?found_itemxx查看某个招领物下的申请列表仅拾主可见POST /api/claims/{id}/approve/通过申请POST /api/claims/{id}/reject/拒绝申请这里最需要注意的是接口幂等和状态校验。比如一个已经被认领的物品不能再次提交认领申请一个已经通过审核的申请不能再次通过。我写了个专门的校验逻辑任何状态变更前先锁住记录再判断def approve_claim(claim_id, reviewer): claim ClaimRequest.objects.select_for_update().get(idclaim_id) if claim.status ! pending: raise ValidationError(当前申请状态不可审核) if claim.found_item.status ! available: raise ValidationError(该物品当前状态不可被认领) claim.status approved claim.reviewed_at timezone.now() claim.reviewer reviewer claim.found_item.status claimed claim.found_item.save() claim.save() # 发送站内通知给申请人 notify_user(claim.applicant, f你的认领申请已通过物品{claim.found_item.title})select_for_update()是Django ORM里的行级锁在高并发场景下防止两个人同时申请同一个物品导致状态错乱。校园项目虽然并发不大但这是一种好习惯写进去成本很低将来接公众号或迎新季大流量时能少踩坑。5.3 权限控制与接口分层接口根据用户角色做了三级权限游客可以浏览失物/招领列表和详情但不能发布、不能申请认领登录用户可以发布失物/招领可以提交认领申请可以管理自己发布的内容管理员is_staff可以查看所有数据可以执行强制关闭、移交、删除违规信息DRF做权限控制很简单ViewSet的permission_classes和get_permissions()方法配合使用class FoundItemViewSet(viewsets.ModelViewSet): serializer_class FoundItemSerializer permission_classes [IsAuthenticatedOrReadOnly] def get_queryset(self): qs FoundItem.objects.filter(statusavailable) category self.request.query_params.get(category) keyword self.request.query_params.get(keyword) if category: qs qs.filter(categorycategory) if keyword: qs qs.filter(Q(title__icontainskeyword) | Q(description__icontainskeyword)) return qs def perform_create(self, serializer): serializer.save(userself.request.user)IsAuthenticatedOrReadOnly意思是登录用户可以写游客只能读。这个类已经能满足大部分校园公开信息的需求。6. Vue3前端实现重点列表筛选、图片上传、状态管理前端页面拆成三大块首页信息流与筛选、发布表单、个人中心。Vue3的工程化配置我用Vite创建组件库使用Element Plus全局状态管理使用Pinia。6.1 列表页和搜索筛选的设计首页就是一个物品信息流顶部是搜索框左侧或顶部是分类筛选。这里我设计成“招领”和“失物”两个Tab分别对应两个列表接口互不干扰。列表页的核心是筛选条件联动。我封装了一个useFilterState组合函数管理分类、颜色、时间范围、关键词等筛选状态// composables/useFilterState.ts import { reactive, ref } from vue export function useFilterState() { const filters reactive({ category: , color: , location: , keyword: , timeRange: all // all | today | week | month }) const currentPage ref(1) const pageSize ref(10) const resetFilters () { filters.category filters.color filters.location filters.keyword filters.timeRange all currentPage.value 1 } return { filters, currentPage, pageSize, resetFilters } }每个筛选值变化时自动触发请求监听方式直接用watch带flush: post避免竞态watch(filters, () { currentPage.value 1 loadList() }, { deep: true })6.2 发布表单图片上传的分片与回显发布失物和招领的表单结构几乎一样我用了一个公共组件ItemPublishForm.vue。最重要的部分是图片上传。图片上传我用Element Plus的el-upload组件走的是自定义http-request而不是默认的action属性。原因是默认走action会把文件直接通过form-data提交不方便在请求头里带JWT Token和动态协商接口地址。自定义上传方法里我用axios手动发送POST /api/upload/成功后把返回的URL写入表单的images数组。图片上传接口在后端比较简单就是一个视图接收文件保存到MEDIA_ROOT/uploads/返回拼接好的URLclass FileUploadView(APIView): permission_classes [IsAuthenticated] def post(self, request): file request.FILES.get(file) if not file: return Response({error: 未接收到文件}, status400) ext os.path.splitext(file.name)[-1].lower() if ext not in [.jpg, .jpeg, .png, .gif, .webp]: return Response({error: 不支持的图片格式}, status400) if file.size 5 * 1024 * 1024: return Response({error: 图片大小不能超过5MB}, status400) filename f{uuid4().hex}{ext} full_path os.path.join(settings.MEDIA_ROOT, uploads, filename) with open(full_path, wb) as dest: for chunk in file.chunks(): dest.write(chunk) file_url request.build_absolute_uri( settings.MEDIA_URL uploads/ filename ) return Response({url: file_url}, status201)前端在用户选择图片后先做一次本地预览等用户点击“提交”时才批量上传图片。这一步看起来是小事但很影响体验如果选图后立即上传用户还没编辑表单内容就传了好几个废弃文件服务器会堆很多垃圾图。6.3 Pinia管理登录态和全局消息登录状态用Pinia存一个authStore持久化到localStorage初始化时恢复。这个在页面刷新时可以保持登录不需要重新登录// stores/auth.ts import { defineStore } from pinia export const useAuthStore defineStore(auth, { state: () ({ token: localStorage.getItem(access_token) || , userInfo: JSON.parse(localStorage.getItem(user_info) || {}) }), getters: { isLogin: (state) Boolean(state.token) }, actions: { setLogin(payload: { token: string, user: any }) { this.token payload.token this.userInfo payload.user localStorage.setItem(access_token, payload.token) localStorage.setItem(user_info, JSON.stringify(payload.user)) }, logout() { this.token this.userInfo {} localStorage.removeItem(access_token) localStorage.removeItem(user_info) } } })Axios请求封装里加一个响应拦截器发现401时自动清掉本地登录态跳回首页。Token过期后的静默刷新逻辑我在第一版没做后来发现很多用户挂机久了再操作突然弹回首页体验很差才加上了用refresh_token重新换access_token的逻辑。这个不建议第一版就做先把主体跑通再说但心里要有数。7. 部署上线踩坑记录从开发环境到能稳定跑起来项目开发完成只是第一步真正让它能稳定给别人用部署阶段才是重头戏。这个部分我按一次完整的上线过程来复盘。7.1 服务器的选型与环境安装服务器我用的是最普通的Linux云主机2核4G配置。系统是Ubuntu 22.04。部署方案用的是Nginx uWSGI MySQL。为什么用uWSGI而不是Gunicorn差别不大uWSGI对Django的支持非常成熟配置文档也齐全项目小团队的话用起来顺手。部署前有几个坑要先处理Python版本问题。服务器系统自带的Python版本可能比较老比如Ubuntu 22.04自带Python 3.10这个版本没问题。但如果你用的是CentOS或者老版本系统可能只有Python 3.6Django 4.x以上需要Python 3.8甚至Django 5.x需要Python 3.10以上。建议用pyenv或直接在系统里安装指定版本的Python不要直接在系统自带的解释器上硬怼。虚拟环境一定不能省。强烈建议每个项目使用独立的虚拟环境。我用的是venv简单可靠python3 -m venv /data/web/myenv source /data/web/myenv/bin/activate pip install -r requirements.txt7.2 Nginx配置里最容易出问题的三个点Nginx配置我贴一份完整版本标注了重点注意事项server { listen 80; server_name yourdomain.com; client_max_body_size 10m; # 前端打包后的静态文件 location / { root /data/web/frontend/dist; try_files $uri $uri/ /index.html; } # 后端API转发 location /api { 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; } # 图片文件访问 location /media/ { alias /data/web/media/; } # 后端静态文件访问 location /static/ { alias /data/web/static/; } }第一个坑是client_max_body_size。前端允许一次传多张图片每张最大5MB如果不设置这个值或者设太小Nginx默认只允许1MB的请求体用户一传图就报413错误后端根本不会收到请求。我一开始忘了配置排查了很久才发现问题出在Nginx而不是后端。第二个坑是前端路由的try_files。Vue3的history路由模式页面跳转路径比如/item/123直接刷新时Nginx会去服务器找这个路径的文件找不到就404。try_files $uri $uri/ /index.html;的作用是所有找不到的路径都回退到 index.html由前端路由自己处理这是Vue3部署中最常见的问题。第三个坑是媒体目录权限。Nginx进程默认是www-data用户而目录如果是root创建的Nginx没有权限读取。项目部署时我建议把media目录的用户和组改成Nginx运行的userchown -R www-data:www-data /data/web/media否则你会看到前端页面明明返回了图片URL但浏览器访问图片就403。7.3 uWSGI配置和Django的环境变量管理uWSGI的配置文件我写在uwsgi.ini里[uwsgi] # 项目目录 chdir /data/web/backend # 加载Django WSGI模块 module school_lost.wsgi:application # uWSGI进程数 master true processes 4 threads 2 # socket文件路径 socket /tmp/school_lost.sock # 权限 chmod-socket 666 vacuum true # 日志 daemonize /data/web/logs/uwsgi.log需要特别注意的是chmod-socket 666。这个socket文件是Nginx和uWSGI之间的桥梁如果权限不够Nginx无法访问会报502 Bad Gateway。你可以在Nginx配置里通过uwsgi_pass unix:/tmp/school_lost.sock;来对接。问题高发点其实在第二步就是你写好了uwsgi.ini之后直接在系统里运行uwsgi --ini uwsgi.ini不代表它能开机自启。我用的是systemd服务写一个服务文件保证服务器重启之后服务自动恢复。Django的部署配置里ALLOWED_HOSTS必须填入你的域名或者服务器IP否则Django会拒绝请求返回400。DEBUG一定要设置为False否则一报错就把完整的堆栈信息暴露给前端非常不安全。这两点我在部署时反复提醒自己很少会被提到但每个新手都容易掉进去。7.4 HTTPS证书的配置现在的校园项目就算只是学生搭建的课程设计我也会建议上HTTPS。原因很简单登录涉及密码和Token传输明文HTTP在公共WiFi下容易被嗅探。申请免费证书直接用腾讯云或阿里云的免费证书配置到Nginx里就行server { listen 443 ssl http2; server_name yourdomain.com; ssl_certificate /data/web/certs/yourdomain.pem; ssl_certificate_key /data/web/certs/yourdomain.key; ssl_protocols TLSv1.2 TLSv1.3; # ... 与上面一致的location配置 }如果是自己申请了证书注意证书文件权限不要设置得太公开建议chmod 600。证书目录不要让Nginx的user以外的人有读写权限否则有被降级攻击的风险。8. 真实开发中反复踩过的坑说点文档里不会写的东西这一章我想分享的是那些不实际动手就很难遇到的细节。它们不致命但每一个都能浪费你半小时到一天的时间。8.1 Django的DateTimeField时区问题这是我在这个项目里遇到的最隐蔽的坑之一。Django默认时区是UTC而本地时间是UTC8。如果开发时没统一设置会出现“提交丢失时间是12:00保存后变成04:00”的情况。解决方法很简单在settings.py里设置TIME_ZONE Asia/Shanghai USE_TZ True但设置完还有一个坑如果USE_TZTrue那么所有从数据库读出的时间都是带时区信息的datetime对象而你前端如果要显示2025-01-15 14:30这种格式需要在前端做一次格式化。我的做法是后端DRF的Serializer里直接在字段上输出格式化的时间字符串class LostItemSerializer(serializers.ModelSerializer): lost_time_display serializers.SerializerMethodField() class Meta: model LostItem fields [id, title, lost_time, lost_time_display] def get_lost_time_display(self, obj): return obj.lost_time.strftime(%Y-%m-%d %H:%M)这样前端拿到就是纯字符串不用再做dayjs处理减少一层转换出错的可能。8.2 ORM查询的N1问题列表页为什么越来越慢学校海报厅同学反馈首页列表打开要两三秒。我一开始以为是服务器带宽问题后来查了一下SQL日志才知道列表接口在返回每条记录时都去查询用户信息和图片信息这就是经典的N1查询。Django ORM解决这个问题非常简单在查询时用select_related关联一对一/多对一和prefetch_related关联一对多/多对多def get_queryset(self): return super().get_queryset().select_related(user).prefetch_related(claim_requests)加上之后列表接口直接从几十条SQL变成几条首页打开速度从3秒降到300毫秒以内。这个优化属于“十分钟改动体验翻倍”的典型。8.3 图片路径拼接request.build_absolute_uri在不同环境下不一致我在开发环境本地跑的时候图片URL是http://localhost:8000/media/uploads/xxx.jpg一切正常。部署到服务器后因为需要通过域名访问图片URL应该变成https://yourdomain.com/media/uploads/xxx.jpg。Django的request.build_absolute_uri会根据当前请求的Host自动拼接如果你通过Nginx代理转发的请求头里带了Host和X-Forwarded-Proto它就能生成正确的完整URL。但如果Nginx没设置好这两个请求头图片URL就会生成内网IP或http链接前端展示时就会出现混合内容问题HTTPS页面加载HTTP资源被浏览器拦截。我的解决方法是配置好Nginx转发头同时在Django的设置里加一个SECURE_PROXY_SSL_HEADERSECURE_PROXY_SSL_HEADER (HTTP_X_FORWARDED_PROTO, https)这个配置告诉Django当请求头里说明协议是https时视为在HTTPS环境下运行。没这个配置build_absolute_uri在HTTPS反代后会生成http://开头的URL。8.4 认领并发下的重复申请理论上同一时间不应该有两个用户对同一个招领物品提交认领申请。但我在压测时发现因为前端有用户连点两次提交按钮或者用客户端工具同时发两个请求后端可能产生两条pending状态的申请记录。这会导致拾主看到两个申请无法判断该处理哪一个。解决办法有二。一是在数据库层面给ClaimRequest加唯一约束用found_item和applicant加UniqueConstraint然后处理IntegrityError异常二是在后端加一层基于Redis的分布式锁或者简单的队列但校园项目用不上这么重的东西唯一约束就够了class Meta: constraints [ models.UniqueConstraint( fields[found_item, applicant], conditionQ(statuspending), nameunique_pending_claim ) ]这里用条件约束意思是同一个申请人对同一个招领物只能有一条待审核的记录但如果在同一个物品上被拒绝后还可以再次提交新申请。这个逻辑非常贴合真实需求。9. 最后再分享一点经验做一个能交付的系统重点不是功能堆砌项目收尾时我复盘了一下真正让系统变得有用的不是某一个炫酷功能而是“信息结构化”和“流程有闭环”这两件事。信息结构化让搜索推荐成为可能流程闭环让每一件物品有始有终不会变成一个只进不出的信息坟场。实际运行之后我还补了几个容易被忽视的小功能这里一并提一下供大家参考站内通知当自己发布的失物匹配到招领信息时立即发通知提示查看。这是找回率提升最大的一个功能。截止时间自动关闭招领信息超过30天无人认领自动变为“已移交”状态保证信息列表不过期堆积。物品去重展示同一拾主重复发布同一物品会被系统提示避免垃圾信息充斥列表。最后再分享一个我的个人体会校园类项目的核心不在技术难度而在“懂业务”。给失主和拾主省时间比把代码写得多么优雅更有说服力。如果你也想做一个类似项目我建议你先去找学校保卫处或者学生会聊半小时问问他们每天怎么登记失物、最头疼的是什么这比上网看十篇框架教程都管用。产品逻辑清楚了写代码只是时间问题。
返回列表