去年帮朋友找考研专业课真题翻了三个旧群、点了十几个失效网盘链接之后我决定用 Python 做后端、微信小程序做前端自己搞一个考研资料共享平台。这年头考研资料不是没有而是碎得离谱网盘链接说挂就挂群文件永远处于已满状态某宝某鱼上卖的资料要么年份对不上要么干脆就是网上拼凑的。做一个集中、有序、还有一点点激励机制的共享平台是我这个项目的出发点。这篇文章我会把整个项目的关键环节拆开讲为什么这么选技术栈、数据库表怎么设计才扛得住实际使用、小程序端自定义导航栏和搜索交互怎么处理、Python 后端怎么写登录和文件上传下载、积分扣减的原子性问题、以及我上线后被微信支付功能停用、被 iOS 视频编码折磨的经历。如果你是准备做课程设计、毕业设计的在校生或者单纯想用 Python 和小程序搭一个内容共享类产品这篇应该能帮你绕开我踩过的不少坑。1. 先想清楚再动手这个平台到底要解决什么问题很多同学做项目上来就建工程写代码写到一半发现业务边界糊成一团。我这次先花了两天把问题盘清楚后面开发反而很快。1.1 考研资料领域三个真实痛点第一个痛点是资料极度分散。同一个学校的专业课真题可能分散在学长朋友圈、考研群聊记录、百度网盘分享链接、公众号推文四个地方而且这些来源之间完全没有统一检索入口。第二个痛点是质量无法鉴别。我见过有人把某一年的期末试题当作考研真题发出来也见过封面写着 2024 但内容实际是 2014 的年份穿越资料。第三个痛点是分享没有正向激励。上传者把资料发到群里除了句谢谢大佬什么也得不到时间长了就没有人愿意整理好资料分享。这三个痛点直接决定了平台的功能边界需要解决检索问题所以要有分类、搜索、年份筛选需要解决质量问题所以要有上传审核、资料描述、学科标签需要解决激励问题所以要有积分体系和下载机制。功能不需要多但这三条线必须贯穿前后端所有设计。1.2 为什么后端用 Python 而不是 Node 或 Java选 Python 不是因为 Python 在所有场景都最优秀而是这个项目最合适。考研资料平台的核心接口无非就是用户登录、资料列表、上传下载、积分变动用 FastAPI 写这一层非常快自带 OpenAPI 文档调试接口的时候省了很多事。更关键的是后续扩展能力。我当时列过几个想做但没时间做的功能上传资料自动查重、标题内容的关键词提取、基于学科标签的推荐。这些用 Python 生态做几乎都是现成的difflib能做相似度比对jieba可以搞分词scikit-learn的 TF-IDF 可以做简单推荐。如果后端用 Java这些功能当然也能实现但开发成本会高不少。我不要把话说死如果你们团队更熟 Node.js 或者 Java Spring完全可以用自己熟悉的这个项目的业务复杂度远没到非 Python 不可的程度。但我个人体验下来FastAPI SQLAlchemy 这套组合在开发速度和代码可读性上确实舒服。1.3 为什么选择微信小程序而不是 App / H5考研人群有一个非常典型的特征手机内存常年不够用。刷题 App、背单词 App、视频课 App动辄好几个 G让他们再装一个原生 App 的难度极高。微信小程序完美绕开了这个问题扫码即用分享到考研群的时候对方点开就能看到内容传播成本几乎为零。微信生态自带的能力帮了大忙登录不需要手机号验证码而是直接走微信授权支付有微信支付虽然我后面翻车了订阅消息可以做审核结果通知官方的内容安全接口可以过滤违规文本。这些如果全部自建工作量至少翻倍。小程序还有一个隐藏优势审核环境倒逼你把内容合规做好。普通网页挂了就挂了但小程序一被投诉就会下架或者限制能力。1.4 整体架构在不引入中间件的前提下怎么落地架构我没有做得太重一台云服务器全搞定小程序端通过 HTTPS 请求 Python 后端 API后端连接 MySQL 存业务数据上传的文件落在服务器磁盘上同时把文件路径和元信息记录到数据库。评论、消息之类的关系数据也用同一套 MySQL 处理。很多教程会建议你上 Redis 做缓存、用对象存储放文件、用消息队列处理任务。考研资料这个体量现阶段这些全上只会增加部署成本。我在项目里只做了一件轻优化高频访问的学科分类列表用 Python 内存里的字典做了 60 秒缓存避免每次请求都查一次数据库。Redis 等日活过万再上完全来得及。2. 数据模型是地基四张核心表把业务边界画清楚了数据库设计我改过两版。第一版把上传记录、审核记录、下载记录全塞在一张行为表里后来发现后台统计要么写一堆CASE WHEN要么做笛卡尔积查询简直灾难。第二版重新拆成了四张核心表逻辑就顺多了。2.1 用户表与积分字段为什么我用整型而不是余额用户表字段如下字段名类型说明idINT 自增主键openidVARCHAR(64) 唯一微信身份标识nicknameVARCHAR(64)昵称avatar_urlVARCHAR(255)头像地址pointsINT 默认 0积分statusTINYINT0 正常1 封禁created_atDATETIME注册时间积分字段一开始我考虑过用 DECIMAL 模拟余额后来想明白了这个平台的积分是贡献值而不是货币不会产生利息、不涉及提现、不需要找零用 INT 就能表达。真要发资料赚收益那是另外一个涉及交易流水和合规问题的产品形态了。唯一需要留意的是积分的增减必须走后端接口绝不开放客户端直接改。哪怕用户恶意刷积分最多就是把注册送的分刷走不会出现负数漏洞。2.2 资料表设计学校、年份、学科这些冗余字段一定要有资料表是核心字段名类型说明idINT 自增主键titleVARCHAR(128)资料标题descriptionTEXT资料描述subjectVARCHAR(32)学科政治/英语/数学/专业课categoryVARCHAR(32)类型真题/笔记/讲义/视频schoolVARCHAR(64)院校名称yearSMALLINT年份比如 2024file_nameVARCHAR(255)原始文件名file_pathVARCHAR(255)服务器存储路径file_typeVARCHAR(16)pdf/mp4/zip 等file_sizeBIGINT文件字节数uploader_idINT上传用户 IDdownload_countINT 默认 0下载次数point_priceINT 默认 0下载所需积分statusTINYINT0 待审1 通过2 拒绝3 下架created_atDATETIME上传时间school、year、subject这些字段看起来像是反规范化设计但这是故意的。考研用户的检索习惯高度固定我要找XX大学计算机专业课2024年真题。如果把院校拆成单独的外键表、把年份做成字典表查询的时候要 JOIN 两次反而拖慢性能。对这类查询场景冗余字段是最实用的方案。一个很容易踩的坑description必须用 TEXT 而不是 VARCHAR(255)。考研笔记、复习心得这种文本几百字太正常了255 根本不够用。另一个坑是file_size用 BIGINT 存字节数不要在数据库里存 12MB 这种字符串——排序、统计、前端展示时格式化都很麻烦。2.3 审核记录表UGC 内容审核是生死线不是可选项小程序审核对用户生成内容UGC社区有明确规定平台必须有内容过滤机制和投诉入口。考研资料平台恰好是典型的 UGC 产品用户上传资料如果全自动发布一旦被投诉传播盗版资料或违规内容轻则功能限制重则封禁。我的审核表结构字段名类型说明idINT 自增主键material_idINT关联资料 IDauditor_idINT审核人管理员 IDactionTINYINT通过 / 拒绝reasonVARCHAR(255)拒绝原因created_atDATETIME审核时间审核不是做个样子审核意见要能回传给上传人。用户上传资料之后会进入审核中状态管理员在后台审核通过或拒绝并填写原因系统通过微信订阅消息把结果推送给用户。这个闭环做完整用户才会觉得平台是有人管的同时也满足平台合规要求。2.4 下载记录和索引细节防刷、防重复扣费都靠它下载记录表字段名类型说明idINT 自增主键user_idINT下载用户material_idINT资料 IDpointsINT本次消耗积分created_atDATETIME下载时间这张表有两大用处。第一防止同一个用户对同一份资料重复扣积分每次下载前先查这张表如果用户已经下载过直接返回原文件不扣分。第二后台报表全靠它统计热门资料、用户活跃度、积分消耗量。索引方面我的经验openid必须加唯一索引material表建一个(status, subject, school, created_at)的组合索引后台列表和前台分类页都能用上下载记录建(user_id, material_id)唯一索引直接把重复下载在数据库层面挡住。3. 小程序端不是套模板导航栏、列表、搜索三个关键交互的落地细节小程序端的页面无非是首页、分类页、资料详情页、上传页、个人中心但真正决定用户体验的是导航栏适配、列表加载、搜索交互这三件小事。3.1 自定义导航栏顶部高度的计算方式小程序默认导航栏的样式受限比较大无法自定义背景渐变、标题颜色、左侧按钮行为所以我选择了自定义导航栏。在app.json中设置navigationStyle: custom之后整个页面就会顶到屏幕最上方这时候必须手动计算导航栏高度不然页面内容会和状态栏时间、电量重叠。核心代码就三行const system wx.getSystemInfoSync(); const menu wx.getMenuButtonBoundingClientRect(); const statusBarHeight system.statusBarHeight; const navBarHeight (menu.top - statusBarHeight) * 2 menu.height;statusBarHeight是状态栏高度iOS 一般是 44 左右Android 各种品牌差异很大menu是右上角胶囊按钮的信息menu.top是胶囊顶部离屏幕顶部的距离menu.height是胶囊按钮自身高度。(menu.top - statusBarHeight)是胶囊按钮相对于状态栏底部往下偏移的距离乘以 2 再加上按钮本身高度就是导航栏的总高度。这个方法网上到处都有我要补充的是一个很多人忽略的点这个计算必须在页面onLoad里做不要放在app.js里做成全局一次性计算。原因很简单不同型号手机的wx.getMenuButtonBoundingClientRect()返回值有差异而且某些 Android 机在横竖屏切换后这个值会变。每次进页面动态算一次撑死也就几毫秒的开销。3.2 首页分类 Tab 和资料卡片流触底加载的正确姿态首页我用了顶部横向滚动的分类 Tab政治、英语、数学、专业课、院校资料。这里用 scroll-view 横向滚动而不是默认的 tab 组件因为学科分类多起来之后肯定要左右滑动。资料卡片流的重点是触底加载。小程序有个天然的声明周期函数onReachBottom页面滚到底部时自动触发。我维护了page、pageSize、hasMore三个数据字段每次触底把page加一请求下一页直到后端返回has_more为 false。这里有个体验细节列表请求期间要加loading状态但loading不能用全屏遮罩否则用户想快速滑动看前面的内容会被卡住。我用的是底部小圆圈加载提示配合内容区的骨架屏。3.3 搜索防抖告别每一键都请求服务器搜索框如果不做防抖用户输入考研英语这四个字的过程中后端会收到考考研考研英考研英语四次请求既浪费带宽又容易造成数据库压力。防抖实现很简单let timer null; function onSearchInput(e) { clearTimeout(timer); timer setTimeout(() { this.setData({ keyword: e.detail.value }); this.fetchList(1); }, 300); }这个 300 毫秒的延时意味着用户停止输入 300 毫秒后才发起请求。还有一个附加技巧如果用户两次输入的关键词一样比如删掉一个字又打回来前端可以直接忽略这次请求不需要重发。3.4 搜索结果的键盘遮挡问题iOS 软键盘的天坑iOS 上点击搜索框弹出软键盘后键盘区域会挡住搜索结果列表用户看下面几条数据得先收起键盘体验非常差。这个问题我在热搜里看到在 uni-app 项目里也经常出现原生小程序同样存在。我的解决方案分两步。第一步在输入框confirm-typesearch属性让键盘右下角按钮变成搜索用户点击后收起键盘并提交搜索第二步动态监听键盘高度wx.onKeyboardHeightChange(res { this.setData({ keyboardHeight: res.height }); });拿到keyboardHeight之后把它加在搜索列表的外层容器padding-bottom上这样软键盘弹出来时列表底部会自动留出空间用户可以看到最后一条搜索结果。3.5 资料详情页预览、下载、重复下载三个动作的处理资料详情页有三个核心操作预览、下载、收藏。预览 PDF 和图片用的是wx.downloadFile拿到本地临时路径再调用wx.openDocument打开视频资料直接用video组件的src指向后端签名好的播放地址。下载这个动作需要注意文件保存逻辑。wx.downloadFile默认下载到临时目录用户退出小程序临时文件会被清理。如果要做已购买资料长久可用得用wx.getFileSystemManager().saveFile把文件保存到本地用户目录或者保存到FileSystemManager管理的持久目录。重复下载的处理我会在后端做用户已经下载过的资料后端会返回一个已下载标记前端直接显示再次查看而不是积分下载按钮。这个细节能减少很多用户投诉。4. Python 后端接口设计登录、上传、下载、积分扣减的完整链路后端接口设计我用了一个原则小程序端永远只做展示和交互所有业务规则全部下沉到 Python 后端。这样即使换一个前端后端逻辑完全不用动。4.1 微信登录code2Session 之后为什么不直接拿 openid 当身份标识小程序的登录流程是前端调用wx.login()拿到一个临时 code然后把 code 传给后端后端拿着 code appid secret 去微信接口换用户的 openid。app.post(/api/auth/login) async def login(req: LoginRequest): resp httpx.get( https://api.weixin.qq.com/sns/jscode2session, params{ appid: APPID, secret: SECRET, js_code: req.code, grant_type: authorization_code } ) openid resp.json().get(openid) if not openid: raise HTTPException(status_code401, detail登录失败) user get_or_create_user(openid) token create_jwt_token({uid: user[id]}, expires_in7 * 24 * 3600) return {code: 0, data: {token: token, uid: user[id]}}拿到 openid 之后我签发了一个 JWT 返回给前端。为什么不直接把这个 openid 当作令牌原因有三一是 openid 是用户的全球唯一身份标识如果存在小程序端被反编译提取出来这个用户可以被永久冒充二是 JWT 可以设置 7 天有效期配合刷新机制比一个永远有效的字符串安全得多三是 JWT 里可以携带 uid、角色等业务字段后续后台接口只需要解析 token 就能知道是普通用户还是管理员。这里有个容易踩坑的点code2Session接口在服务器端调用时要走外网本地开发时很多人因代理或者 IP 白名单问题拿不到返回。建议用腾讯云函数的代理或者直接在服务器本地调试别在本地开代理测试微信接口。4.2 文件上传类型校验、改名策略、MD5 去重三层防护小程序端上传文件用一个特殊接口wx.uploadFile({ url: https://api.example.com/api/material/upload, filePath: filePath, name: file, formData: { title: this.data.title, subject: this.data.subject, school: this.data.school, year: this.data.year }, success: res { ... } });后端的校验顺序很重要。第一步校验文件扩展名我只允许 PDF、图片、ZIP、MP4、Word、PPT 等明确允许的格式第二步校验文件大小单文件最大 100MB超过的直接拒绝第三步计算文件的 MD5 值去资料表里查有没有相同文件相同的直接返回该资料已存在可以去搜索。文件落盘之后的改名策略我用的是时间戳 随机串 原扩展名。为什么不用原始文件名因为用户上传的文件名五花八门可能有中文、特殊字符、超级长文件名直接存服务器会造成路径处理问题。落盘在uploads/目录数据库里只存相对路径。4.3 文件下载与防盗链临时签名 URL 是怎么生成的文件如果直接通过静态 URL 暴露任何人都可以拿着 URL 分享给其他人积分体系就形同虚设。我的实现是下载接口返回的不是文件内容而是一个带上签名和过期时间的临时 URL。def sign_download_url(file_path: str, uid: int, expire_seconds: int 600) - str: expire int(time.time()) expire_seconds payload f{file_path}:{uid}:{expire} sign hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest() return f/api/material/download?path{file_path}uid{uid}expire{expire}sign{sign}客户端拿到这个临时 URL 后在 10 分钟内请求下载。后端收到请求先校验 sign 是否一致、expire 是否过期再检查用户积分是否足够最后通过FileResponse返回文件流。这个方式的好处是就算别人把下载链接转发出去链接也会在 10 分钟内失效而且签名绑定了用户名转发出去的文件名直接暴露了原始用户的 uid一旦盗链被人上传滥用能追溯到来源。4.4 积分扣减的原子性别用先查再扣要用一次 UPDATE 搞定这是整个项目里我自认为最有含金量的一段。最开始我的积分扣减逻辑是SELECT points FROM user WHERE id %s -- 如果 points price再执行 UPDATE user SET points points - %s WHERE id %s这个写法在单用户时没问题但一旦有并发请求——同一个用户同时下载两份资料——两个请求可能同时读到 points100都判断积分足够然后都执行 UPDATE导致积分被扣两次却只记录了零次或记录混乱。正确做法是让数据库自己做原子判断UPDATE user SET points points - %s WHERE id %s AND points %s执行之后检查cursor.rowcount如果返回 1 说明扣减成功如果返回 0 说明积分不足直接返回积分不够请先签到或上传资料。同理下载成功后插入下载记录表也要注意顺序先扣积分、再写下载记录两条操作放在同一个事务里一旦任一步失败就整体回滚。这样哪怕服务器在中间崩溃也不会出现积分扣了但没拿到文件或者文件拿到了但积分没扣的数据不一致。5. 上线之后才暴露的五个真实坑这个项目从开发到上线前两周堪称顺风顺水上线之后问题开始扎堆出现。以下五个是我印象最深、也是最想让后来的同学避开的坑。5.1 微信支付功能被停用知识付费平台的版权红线上线初期我觉得可以接微信支付实现付费下载结果用了半个月支付功能被微信风控判定为平台涉嫌传播未经授权内容而暂停使用。我当时还申诉了一轮但知识付费类目需要相应资质和版权证明材料个人开发者根本凑不齐。被停用之后我做了个减法彻底移除支付功能把整个平台的激励体系改成积分制。用户不能直接花钱买积分积分只能通过注册、签到、上传资料、被下载来获得。这个改动的核心逻辑是积分是荣誉体系和贡献度量而不是交易媒介。想做收费变现的同学合规做法是把交易引导到企业微信、公众号或者自有店铺小程序端只做展示和内容管理。这个教训我吃得很亏分享出来给你们省点时间。5.2 video 组件在 iOS 上不能播放视频编码格式是罪魁祸首上线后不少用户反馈 Android 上视频正常iOS 点击视频就转圈黑屏。查了很久最后确认是视频文件的编码格式问题。iOS 的 WebView 对视频编码支持比较严格很多流传的 MP4 文件其实是从其他格式直接改扩展名来的内部编码可能是 H.265 或者没有 moov atom 元数据导致 iOS 播放器无法解析。解决方案是用 ffmpeg 统一转码ffmpeg -i input.mp4 -vcodec libx264 -acodec aac -movflags faststart output.mp4-vcodec libx264指定视频编码为 H.264-acodec aac指定音频编码为 AAC-movflags faststart把 moov atom 移到文件开头这样视频可以在未完全下载时就开始播放。转码之后 iOS 和 Android 都能正常播放了。5.3 swiper 嵌套 video 全屏错位别在轮播图里直接放视频组件有段时间我想在资料详情页做一个图片 视频混合轮播 Swiper结果 iOS 上视频全屏播放后退出整个页面布局全部错位视频区域变黑滚动卡顿。这个坑最后用了两个办法解决第一Swiper 里不要直接渲染多个 video 组件只渲染当前激活项的视频用current变量控制wx:if判断第二给每个 video 设置object-fitcontain避免视频比例拉伸撑破父容器。实际开发中更推荐的做法是列表里只显示视频封面图用户点击封面后跳转到独立的视频播放页。这样能省去 Swiper 和 video 层级冲突的大量麻烦。5.4 UGC 内容审核别让平台变成垃圾资料集散地一开始我的审核是人工看后来下载量上来之后光靠我一个人盯后台根本盯不过来。随即接入了微信官方的内容安全接口msgSecCheck用户上传资料的标题和描述先过一遍机器审核违规的直接挡掉机器审核通过后再进人工审核池。这个双层机制上线后平台上的违规内容数量直线下降。更重要的一点是一定要在用户上传页面显著位置加上禁止上传侵权内容的协议提示并在小程序后台配置好投诉入口。这不是走形式这是小程序类目审核的明确要求也是平台自保护的必要手段。5.5 抓包和密钥泄露别把 secret 写进小程序代码网上有很多小程序抓包的讨论我自己的习惯是调试阶段用微信开发者工具自带的 Network 面板上线前再用抓包工具整体过一遍自己的请求重点看有没有敏感信息泄露。我见过有些同学为了方便把微信支付商户密钥、云开发环境 ID 直接写进前端代码里这样小程序包一旦被下载分析密钥直接就裸奔了。我的做法是所有密钥只存在于 Python 后端的环境变量中小程序端只保存 JWT 的 token所有敏感操作必须经过后端中转。后端代码上线前也做一次检查确保.env文件不会被提交到代码仓库。6. 让平台活起来的运营闭环审核、积分与数据代码写完只是开始真正要让平台有人用、有内容、能持续运转运营闭环才是重头。这一节讲的是我把平台从能跑变成有人用过程中实际验证过的逻辑。6.1 上传审核机制通过率控制在多少最合理审核不是越严越好也不是越松越好。我统计过后台数据审核通过率大约在 75% 左右的时候用户上传积极性最高。要是通过率太高比如 95%说明审核流于形式垃圾内容已经开始混进来了要是通过率太低比如 40%用户上传两三次都被拒可能就再也不来了。被拒绝的时候必须给理由。最简单直接的理由选项有资料不完整与现有资料重复难以辨认疑似侵权。合理的拒绝理由能有效减少用户重复提交同一份文件的概率。6.2 积分体系的设计怎么解决先有鸡还是先有蛋积分体系最大的坑是新用户没有积分没积分就不能下载优质资料下载不了就不想上传不上传平台就没内容。怎么破这个循环我用了三个手段第一注册送 30 积分保证新用户至少能下载两三份基础资料。第二每日签到随机送 1 到 5 积分让用户每天愿意打开小程序。第三上传资料审核通过后送 20 积分资料每被下载一次再额外送 5 积分鼓励深度贡献。这个体系的重点是让积分动起来签到和注册是积分的出口上传和贡献是积分的入口。平台通过限制每天签到积分上限控制积分总量不会通胀。还有一个防薅羊毛的细节新注册用户前 3 天每天最多下载 3 份资料防止有人注册小号批量薅积分然后跑路。6.3 数据指标哪些数据值得每天看平台运营阶段我每天关注的数据就四个日活用户数、上传审核通过率、人均下载次数、搜索词 Top20。其中搜索词 Top20 是最有用的因为它是用户真实需求的直接反馈。有一阵子某大学计算机真题这个词搜索量暴涨我就主动去找了一批相关资料上传平台上这类资料的下载量立刻带起来了。后台统计我基本靠 SQL 聚合完成不需要专门的数据分析工具。每天跑一遍定时脚本把关键指标汇总成一张日报清单发到管理群。这样即使平台体量不大也能保持数据敏感度。6.4 下一阶段的功能方向如果平台能积累到一定用户我规划的下一个功能是院校真题匹配推荐。用户关注目标院校之后新上传的该校资料自动通过订阅消息推送给用户。这个功能能显著提升用户粘性技术上不复杂核心就是数据库里加一张关注表上传审核通过后做一次消息推送。另外一个值得做的方向是资料自动查重。现在靠人工去看上传文件是否重复效率太低。用 Python 对文件做内容 Hash 或者抽样比对标题相似度用difflib.SequenceMatcher判断可以拦截掉 80% 以上的重复资料。做完整套项目我最大的体感是一个垂直领域的小平台技术难题并不在用什么高大上的框架而在有没有把用户的真实使用路径走顺。考研资料共享平台最核心的一点是用户打开小程序三秒内能不能找到自己想要的资料上传一份资料后能不能感受到平台的正反馈。把这两件事做透了项目就已经成功了一大半。