
做企业或高校内部系统这些年我越来越觉得“项目申报”是个被低估的开发场景。很多人以为它只是做一个表单页面真正动手后才发现申报人和审核人两类角色草稿、待提交、初审、复审、驳回、通过这一大串状态主表、附件、审核日志好几张表再加上微信登录、文件上传、列表分页这些小程序通用能力一次性全做对并不容易。这套基于 Python 和 uni-app 的微信小程序项目申报系统正是围绕这些真实需求搭出来的一套可落地雏形。如果你正准备接手类似项目或者想在简历里放一个“前后端分离 微信小程序”的完整案例这篇文章应该能省掉你不少弯路。我下面讲的内容全部按实际开发顺序来先讲需求和技术选型再拆后端接口设计然后过一遍 uni-app 前端的关键页面最后是联调打包阶段的踩坑记录和排查方法。项目本身不复杂但涉及的点很典型做完这一套你基本就摸清了“Python API 微信小程序”这种组合的完整链路。1. 先从需求说起申报系统到底要解决什么问题1.1 传统申报流程的痛点项目申报这个业务尤其在企业内部或高校科研管理场景里以前最常见的方式是发一个 Excel 模板下去让大家填好再发回给管理员管理员手动汇总、手动催交、手动反馈意见。材料多的时候文件夹里全是“申报书最终版”“申报书最终版2”“申报书真最终版”附件命名混乱版本覆盖审核意见分散在微信聊天记录里想追都追不回来。微信小程序做项目申报最直接的价值是把“散落”变成“集中”。申报人不用再找模板、存文件、发邮件打开小程序就能看到申报通知填过的内容存在服务端换手机也不会丢审核人看到的是统一格式的列表和详情点一下就能通过或驳回整个过程有记录。这套系统表面上是个表单应用实际上解决的是流程管理、文件管理和进度可见性问题。1.2 角色拆解与状态流转我习惯在写代码之前先把业务流程画成一张状态图不画在纸上而是写在文档里。这个系统的核心角色只有两个申报人创建申报、填写内容、上传附件、提交审核、查看审核进度。审核人查看申报列表、下载附件、填写审核意见、通过或驳回。申报单的状态流转也很固定草稿 - 待审核 - 审核中 - 通过 / 驳回。被驳回的申报单可以修改后重新提交重新提交后状态回到待审核。这个状态机是后面所有接口设计的核心前后端对状态的命名必须完全一致不然会出现“页面显示待审核接口返回 status1”这种对不上的问题。1.3 为什么选 Python uni-app 而不是其他方案选型的时候我对比过三套方案。第一套是原生微信小程序 Node.js原生小程序对微信 API 支持最直接但以后想做 App 或者 H5 就得重写不划算。第二套是纯 Web 管理系统PC 上填表确实方便但申报人大部分时间在手机上网页的登录和通知体验远不如小程序。第三套就是我最终采用的 Python uni-app。Python 后端我选了 FastAPI。原因很实在FastAPI 自带 OpenAPI 文档写完接口直接有一个可交互的调试页面对前后端联调特别友好加上 Pydantic 做参数校验很多低级错误在请求入口就被拦住了不用在业务代码里写一堆 if else。uni-app 这边最大的优势是“一套代码多端编译”。这次目标平台是微信小程序但等项目跑起来后领导大概率会问“能不能做个企业微信版能不能做个 App”用 uni-app 的话这些后续需求不用从零开始。2. Python 后端接口设计把申报业务拆成清晰的 REST API2.1 工程初始化与依赖管理后端工程我建议用一个简洁的 FastAPI 模板目录不要搞得太复杂。新手容易犯的错误是把所有代码堆在一个 main.py 里项目一复杂就很难维护。我常用的结构是这样的project-server/ ├── app/ │ ├── main.py # FastAPI 实例、路由注册、静态文件挂载 │ ├── config.py # 配置项数据库地址、小程序 appid/secret、上传路径 │ ├── models.py # SQLAlchemy ORM 模型 │ ├── schemas.py # Pydantic 请求/响应模型 │ ├── auth.py # 登录、token 校验依赖 │ ├── routes/ │ │ ├── user.py # 用户相关接口 │ │ ├── declaration.py # 申报单相关接口 │ │ └── file.py # 文件上传/下载接口 │ └── utils.py # 公共函数 ├── uploads/ # 附件存储目录 ├── requirements.txt └── run.py # 启动入口Python 环境这里多说一句。如果你本地还没装 Python直接去官网下安装包安装时记得勾选“Add Python to PATH”不然命令行里敲 python 提示找不到命令。装完依赖建议使用虚拟环境避免污染全局环境。requirements.txt 里核心就几个fastapi、uvicorn、sqlalchemy、pymysql、python-jose、passlib。启动命令也简单pip install -r requirements.txt uvicorn app.main:app --reload --host 0.0.0.0 --port 8000加上--reload后改完代码保存服务自动重启开发阶段非常省时间。2.2 数据模型申报主表、附件表、审核记录表数据库我用 MySQL如果只是本地演示或者数据量很小用 SQLite 更省事。核心三张表申报主表、附件表、审核记录表。主表存申报业务的公共字段附件表用来挂文件审核记录表用来追溯每一次审核动作。这是我会直接写进项目里的简化版模型from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey from sqlalchemy.ext.declarative import declarative_base Base declarative_base() class Declaration(Base): __tablename__ declaration id Column(Integer, primary_keyTrue, indexTrue) user_id Column(Integer, indexTrue) # 申报人ID title Column(String(200)) # 项目名称 category Column(String(50)) # 项目类型 budget Column(String(20)) # 申报金额前端传入字符串 summary Column(Text) # 项目简介 status Column(String(20), defaultdraft) # draft/pending/approved/rejected created_at Column(DateTime) updated_at Column(DateTime) class Attachment(Base): __tablename__ attachment id Column(Integer, primary_keyTrue) declaration_id Column(Integer, ForeignKey(declaration.id)) file_name Column(String(255)) file_path Column(String(255)) file_size Column(Integer) uploaded_at Column(DateTime) class ReviewRecord(Base): __tablename__ review_record id Column(Integer, primary_keyTrue) declaration_id Column(Integer, ForeignKey(declaration.id)) reviewer_id Column(Integer) action Column(String(20)) # approve / reject comment Column(Text) # 审核意见 reviewed_at Column(DateTime)为什么附件要单独建表因为一个申报项目可能对应多个附件如果都在主表里拼逗号字符串后面想统计每个附件的大小、下载次数、单独替换某个文件都会很麻烦。用一张子表挂主表 ID逻辑清楚SQL 也好写。审核记录同理它是多对一关系单独存才能形成完整的审批链路。2.3 登录接口微信小程序手机号快捷登录背后的细节微信小程序登录是第一个要攻克的点。我见过很多人一上来就做“账号密码登录”在小程序里体验非常差。正确做法是前端调用 uni.login 获取临时 code后端拿 code 去微信接口换 openid如果还需要手机号就引导用户点“手机号快捷登录”按钮拿到手机号动态令牌后再去微信接口换真实手机号。后端处理 code 的代码大致是这样的import requests from fastapi import APIRouter, Depends, HTTPException router APIRouter() app_config None # 里面存 appid、secret def code2session(code: str): url https://api.weixin.qq.com/sns/jscode2session params { appid: app_config.APPID, secret: app_config.SECRET, js_code: code, grant_type: authorization_code, } resp requests.get(url, paramsparams).json() if errcode in resp: raise HTTPException(status_code400, detailresp.get(errmsg)) return resp # openid, session_key拿到 openid 后我习惯直接把它作为用户唯一标识查询用户表不存在就自动注册一个新用户。登录成功后返回一个自定义 token后续接口通过请求头携带这个 token 识别用户身份。token 的生成用 python-jose 签一个 JWT 就行里面只放 user_id 和过期时间不要放手机号、身份证这类敏感信息。手机号获取是另一套逻辑uni-app 里的button open-typegetPhoneNumber在用户点击后会返回一个 code后端拿着这个 code 调用微信的phonenumber.getPhoneNumber接口才能拿到明文手机号。注意这个接口必须在小程序后台开通权限而且小程序主体必须是企业或个体工商户个人主体的小程序一般没有这个能力。2.4 申报提交与状态流转接口业务接口我用 RESTful 风格来设计。围绕申报单最主要的几个接口POST /api/declarations 创建草稿 GET /api/declarations?status 查询申报列表支持按状态筛选 GET /api/declarations/{id} 查询申报详情 PUT /api/declarations/{id} 修改草稿或驳回后的申报单 POST /api/declarations/{id}/submit 提交审核 POST /api/declarations/{id}/review 审核通过/驳回这里最容易被忽略的是状态校验。比如草稿状态不允许调审核接口已提交状态不允许直接修改内容被驳回状态修改后必须重新提交。我的做法是在接口层做一个状态守卫函数ALLOWED_TRANSITIONS { draft: [pending], pending: [approved, rejected], rejected: [pending], approved: [], } def assert_transition(current, target): if target not in ALLOWED_TRANSITIONS.get(current, []): raise HTTPException(status_code400, detailf不允许从 {current} 变更为 {target})这段代码看着简单但能把很多脏数据挡在门外。否则审核接口一旦被重复请求同一个申报单就可能被通过两次状态记录全乱套。审核接口还需要记录审核人、审核意见和审核时间。我通常在事务里同时完成两件事更新主表状态 插入审核记录。两条写操作要么都成功要么都失败保证数据一致。2.5 文件上传接口与静态资源处理项目申报一定会涉及到附件比如申报书 PDF、营业执照照片、预算表。后端需要一个统一的上传接口。我设计的接口接收declaration_id和file字段保存文件后把记录插入附件表。几个关键参数要提前想好单文件大小限制、允许的扩展名、存储路径。我一般把最大限制设为 20MB因为微信小程序上传大文件时容易超时不如让用户在 PC 侧上传更大文件允许的类型包括 pdf、jpg、png、zip、doc、docx。为了防文件名乱码和路径穿越保存时用 UUID 重命名文件原文件名单独存进数据库下载时再通过接口返回原名。FastAPI 的文件接收代码很简洁from fastapi import UploadFile, File UPLOAD_DIR Path(uploads) ALLOWED_EXT {pdf, jpg, jpeg, png, zip, doc, docx} router.post(/api/files) async def upload_file(declaration_id: int Form(...), file: UploadFile File(...)): ext file.filename.rsplit(., 1)[-1].lower() if ext not in ALLOWED_EXT: raise HTTPException(status_code400, detail不支持的文件类型) save_name f{uuid4().hex}.{ext} save_path UPLOAD_DIR / save_name with open(save_path, wb) as f: f.write(await file.read()) return {file_name: file.filename, file_path: f/uploads/{save_name}}文件不要直接存到项目根目录下最好配置一个独立的静态路径并且在 Nginx 层做代理。开发阶段直接挂载 FastAPI 的 StaticFiles 就行。生产环境建议用 OSS 对象存储把上传接口换成“获取临时上传凭证小程序直传 OSS”减轻后端压力这块等真有并发量了再折腾也来得及。3. uni-app 前端实现从登录到申报完成的完整链路3.1 创建 uni-app 项目与工程配置前端我用的 HBuilderX。创建项目时直接选“uni-app 项目”模板选 Vue 3 TypeScript。很多教程默认用 JavaScript但我这次选 TS原因是申报系统里表单字段太多JS 写久了很容易出现“参数名拼错但运行时才发现”TS 能在编译阶段就提示字段错误。创建项目后第一步是配置 manifest.json。小程序的 AppID 需要在微信公众平台申请拿到后填到 manifest 的“微信小程序配置”里。如果你的项目还没有 AppID可以先使用测试号但注意测试号不支持某些能力比如获取手机号。开发时我会把 request 的合法域名校验暂时关掉这样本地调试最方便。路径在微信开发者工具的“详情 - 本地设置”里勾选“不校验合法域名、web-view业务域名、TLS 版本以及 HTTPS 证书”。这只用于开发打包上线前必须取消并且后端域名必须配置 HTTPS。3.2 自定义导航栏与顶部安全区适配项目申报系统的页面层级不多但顶部导航需要展示业务状态或自定义按钮所以我选择了自定义导航栏。在 pages.json 对应页面的 style 里设置navigationStyle: custom原生导航栏就会被隐藏掉页面的布局从顶部安全区开始。自定义导航栏最麻烦的是计算高度因为不同手机的刘海屏、状态栏高度不一样。直接写死一个数值在真机上一定会错位。我封装了一个获取高度的工具export function getNavBarHeight() { const systemInfo uni.getSystemInfoSync(); const statusBarHeight systemInfo.statusBarHeight || 0; // 胶囊按钮是微信小程序特有的用 uni.getMenuButtonBoundingClientRect 获取 const menuButton uni.getMenuButtonBoundingClientRect(); const navBarHeight (menuButton.top - statusBarHeight) * 2 menuButton.height; return { statusBarHeight, navBarHeight, }; }这个方案简单可靠。拿到高度后导航栏外层容器加上padding-top: statusBarHeight navBarHeight页面内容就不会被刘海遮挡了。热词里的“微信小程序顶部导航栏高度”实际问的就是这个适配逻辑。3.3 手机号登录与登录态管理前端的登录逻辑分为两步。第一步进小程序后先静默登录调用uni.login拿 code发给后端换取 token这个过程用户无感。第二步如果后端判断这个用户是新用户或者业务上必须绑定手机号就弹出一个“手机号快捷登录”按钮用户点击后拿到手机号 code再调后端手机号绑定接口。代码大体是这样uni.login({ provider: weixin, success: async (res) { const loginRes await request.post(/api/auth/login, { code: res.code }); if (loginRes.data.needPhone) { // 控制页面显示手机号快捷登录按钮 } else { uni.setStorageSync(token, loginRes.data.token); } } });手机上获取手机号时按钮必须使用 open-typebutton open-typegetPhoneNumber getphonenumberhandlePhoneNumber 手机号快捷登录 /button在handlePhoneNumber里e.detail.code就是动态令牌把它发给后端后端换手机号并绑定用户。这里要注意用户拒绝授权时e.detail.errMsg会包含deny前端要给出友好提示不能一直卡在登录页面。登录态管理也很重要。token 我会存到uni.setStorageSync每次请求拦截器里带上Authorization: Bearer token。如果接口返回 401就清理 token 并跳转登录页。现在开发还应该加一层静默刷新机制避免 token 过期后用户正在填写表单提交时才发现登录失效。3.4 申报表单设计单选框、多步骤校验与草稿保存申报表单是整个前端的工作量大头因为字段多类型杂。我做了三个基础设计第一项目类型使用单选。radio-group配合radio组件实现数据源可以由后端接口下发方便以后增删选项。第二项目所属领域使用多选。比如“信息技术、生物医药、新材料”这种分类用checkbox-group。第三金额和日期这类特殊字段单独处理。金额我直接用input输入数字字符串提交前做格式校验日期用picker模式选择避免手输格式错误。多步骤表单我也建议加一个步骤条。比如分成三步基本信息、项目简介、附件上传。每步一个独立组件下一步之前先校验当前步骤的必填项。这样做的好处是用户负担小不会一上来面对十几个字段不知所措。还有一点容易被忽略草稿自动保存。申报人填了一半退出去回来发现内容全没了这种体验非常糟糕。我每隔 30 秒把当前表单数据 POST 到草稿保存接口同时切页面前也会主动保存一次。保存成功后在页面右上角显示“草稿已保存”用户心里有底。3.5 列表分页加载与下拉刷新申报系统的首页通常是“我的申报”列表需要按状态切换草稿、待审核、审核中、已通过、已驳回。列表请求我统一用GET /api/declarations?page1page_size10statuspending后端返回{ items: [], total: 32, page: 1, page_size: 10, has_more: true }前端用onReachBottom监听触底加载下一页配合uni.showLoading做加载状态。为了避免用户快速滚动导致重复请求我加了一个loadingMore标志位请求期间如果再次触发就直接 return。let page 1; const pageSize 10; let hasMore true; let loadingMore false; async function loadMore() { if (!hasMore || loadingMore) return; loadingMore true; const res await getDeclarations(page 1, pageSize); list.value.push(...res.items); hasMore res.has_more; page 1; loadingMore false; }需要提醒的是不同状态页的列表数据要独立维护。如果你把所有状态的数据塞在同一个数组里切 tab 后过滤会出现首页已经加载了 3 页另一个 tab 却只有第 1 页的错觉。我做法是一个状态对应一个独立的分页游标切 tab 时重新请求。4. 联调、打包、上线与排坑真实项目里的高频问题4.1 本地联调与真机调试前后端联调阶段最影响效率的是域名问题和日志问题。本地开发时前端请求地址一般写成http://localhost:8000或局域网 IP但微信开发者工具默认要求 HTTPS。解决办法是在开发者工具里勾选“不校验合法域名”这样能直接用本地 IP 调试。真机调试时手机和电脑必须在同一局域网后端启动时 uvicorn 要监听0.0.0.0否则手机访问不到电脑的接口。另外微信开发者工具的“真机调试”模式可以看前端 console 日志但有时候 uni-app 的console.log在真机上不打印别慌先看 HBuilderX 的控制台或开发者工具的 console 面板再确认是不是代码里用了console.info被生产环境过滤了。我习惯在关键接口成功后做一个onRequestSuccess回调把请求耗时和状态码统一打出来。抓包工具我常用 Charles 来看小程序请求的完整报文。用 Charles 需要先安装根证书再在手机里配置代理过程略麻烦但排查“前端参数没传过去”这类问题很有效。Charles 能看到实际发出的 URL、Header、请求体一对比就知道问题出在前端还是后端。4.2 小程序主包体积超限怎么办“source size 2612kb exceed max limit 2mb”这个报错很多人都会遇到。微信小程序主包上限 2MB超出后上传代码直接被拦下来。项目申报系统出现体积超限最常见的原因有三个static 目录里塞了很多设计稿、背景图、图标。引入了比较大的 UI 库比如 uView 全家桶。把所有页面都放在主包 pages 里分包功能没用起来。解决策略我一般按顺序做先压缩图片。很多时候 UI 给的切图是 2x 甚至 3x 图小程序里用不到那么高分辨率用工具把图片压到合适尺寸体积能降一半。再检查组件库。如果只是用到几个组件不要全量引入按需引入能省不少。最后上分包。把“申报填表页”“审核详情页”这类不经常访问的页面放到subPackages里。分包的页面可以通过uni.navigateTo直接访问路径前加/subpkg/...即可。我自己处理过一个案例一个 2.6MB 的项目压缩图片后降到 1.9MB再分包后主包只有 1.2MB。所以遇到体积超限先别慌着重构优先检查资源文件。4.3 高频报错排查速查表我把实际调试中遇到的高频问题整理成了一张表做同类项目时可以直接对照排查。现象可能原因解决思路request:fail url not in domain list后端域名没有加到小程序后台的 request 合法域名列表配置合法域名且必须是 HTTPS开发工具暂时勾选不校验code2session 返回 40029前端传的 code 无效或已过期code 有效期约 5 分钟检查是否重复使用后端不要再缓存旧 code登录后拿不到手机号小程序主体是个人或未开通接口权限确认小程序主体类型在公众平台申请“手机号快捷验证”能力真机上 console 无日志控制台级别过滤或代码被压缩使用console.info并在开发者工具中切换日志级别检查是否手动关闭了日志页面高度空白/导航栏错位navigationStyle: custom后未做安全区适配用statusBarHeight 菜单按钮位置计算实际导航高度上传文件返回 413后端或 Nginx 限制了请求体积调大 Nginx client_max_body_size并检查后端文件大小限制微信接口提示 10002调用凭据无效或权限不足确认 access_token 有效确认接口使用正确的凭据类型和调用方式这些报错看着零散但根源大多集中在配置、权限、数据一致性三个方向。建议固定一套排查顺序先看请求有没有到达后端再看前端参数是否正确最后检查微信开放平台的配置和权限。4.4 上线前检查清单与隐私合规小程序上线前有一件事不做审核一定被拒——隐私政策。微信从 2024 年起强制要求开发者在小程序后台配置“用户隐私保护指引”并且在小程序调用隐私接口前弹出隐私授权弹窗。项目申报系统里手机号、头像、相册这几个隐私声明必须勾选如果上传附件需要访问相册或相机也要对应的声明。我通常会在代码里主动处理隐私授权弹窗避免用户点上传时突然被系统弹窗打断。在 App.vue 的 onLaunch 里调用wx.requirePrivacySetting如果用户未同意就跳转到一个隐私说明页同意后再继续操作。uni-app 中可以用uni.requirePrivacySetting来判断uni.requirePrivacySetting({ success: () { // 用户已同意可以直接使用隐私接口 }, fail: () { // 用户未同意引导去设置页 } });另外发布前记得在小程序后台配置服务器域名。request 合法域名和 uploadFile 合法域名是分开配置的很多人只配了前者结果真机上传附件一直失败。测试的时候用开发者工具不校验域名没感觉上线后就暴露了。最后再说几句我的体会这套项目申报系统技术上没有特别高深的东西但它把小程序开发里最常用的能力串了一遍登录授权、动态表单、文件上传、列表分页、状态流转、分包打包。做完以后我对 uni-app 和 FastAPI 的配合模式算是彻底摸透了。我个人建议如果你要复刻或者二次开发尽量先做 MVP。第一版先跑通“创建申报 - 提交审核 - 审核通过”这条主干链路登录做成最简版附件上传先不做多文件列表不分状态。等主干稳定了再逐步加筛选、草稿、消息通知这些锦上添花的功能。我发现很多项目死在“想一步到位”结果页面写了一堆接口逻辑没跑通。最后分享一个小技巧前后端字段命名一定要保持一致。我踩过最大的坑就是前端传apply_date后端模型里叫applyDate查了半天才发现是命名风格不统一。后来我在项目里加了一个约定所有 JSON 字段一律使用小驼峰数据库字段用下划线由 Pydantic 负责自动映射。前端写起来顺畅后端也不容易出错。项目可以继续扩展的方向也很多比如用订阅消息提醒申报人审核结果、加一个管理后台让审核人在电脑上批量操作这些都能让这套系统从“能用”变成“好用”。