1. 项目背景与整体设计思路
1.1 为什么选 Python + Uniapp + 微信小程序这套组合
做乒乓球课程的教务管理,一开始我其实纠结过技术选型。市面上现成的教务系统不少,但要么是通用型SaaS,课程类型、计分规则、考勤逻辑全是固定死的,改起来比重新写还痛苦;要么就是纯Web后台,学生和教练在手机上用起来体验很差。所以当决定自己搭一套“乒乓球课专用”的教务选课、成绩、考试管理系统时,我在技术栈上定了一个原则:后端要快、前端要跨端、入口要微信。
后端选 Python,理由很直接——这个项目里成绩计算、课次统计、考勤率分析这类逻辑偏多,Python 写起来最顺。用 FastAPI 做接口层,配合 SQLAlchemy 做 ORM,开发效率比 Java 那套高出一大截。而且后期如果要接个简单的推荐算法(比如根据学生水平推荐对应班型),Python 生态里现成的库直接就能用。
前端选 Uniapp 而不是原生小程序,核心原因是不想被微信生态绑架。Uniapp 是基于 Vue 语法的跨端框架,一套代码能编译到微信小程序、H5、App。对于这种学校场景,今天可能只要微信小程序,明天教练可能想要个 iOS 端记录成绩,家长可能想在浏览器里直接打开看课表——用 Uniapp,这些需求都只是“重新编译”的事,不是“重新开发”的事。
微信小程序作为载体,则是纯粹从用户习惯出发。乒乓球课程的学员大多是学生和家长,微信是他们每天打开次数最多的应用,小程序不用下载安装、扫码即用,自然触达率高。而且微信自带的订阅消息能力,可以用来做上课提醒、成绩通知,这些都是原生 App 很难低成本实现的。
1.2 系统核心需求拆解
把标题拆开看,这套系统本质上覆盖了四个业务域:
教务管理:课程维护、班型设置、教练排课、场地管理。这是整个系统的基础模块,所有选课、成绩、考试都围绕它展开。
选课系统:学生查看可报名课程、提交选课申请、管理员审核或系统自动确认、班级人数控制。这里面最核心的是一个“课时库存”概念——每个班容量有限,满员后自动截止。
成绩管理:教练录入学生每次训练课的完成情况、阶段测评成绩、期末总评。乒乓球课的特点是成绩维度多,既有技术动作评分(正手、反手、发球、步法),也有实战对抗结果,还要综合出勤率,所以在设计成绩表时不能只放一个分数字段。
考试管理:设定考试场次、关联课程班级、录入考试成绩、学生端查看考试安排与结果。这是普通教务系统经常忽略的一块,但乒乓球课的阶段考核是很重要的教学环节。
在业务模式上,我把角色清晰地分成了四类:系统管理员(超管,管所有基础数据)、教务老师(管课程和考试的安排)、教练(录入成绩、管理自己班级)、学生/家长(小程序端操作:选课、查成绩、看考试安排)。这也是后来设计权限系统的主线。
1.3 整体架构与数据流向
整体架构走的是经典的前后端分离:
微信小程序端(Uniapp 编译产物) ↓ HTTPS/JSON FastAPI 后端(Python 3.10 + SQLAlchemy) ↓ MySQL 8.0(主库)+ Redis(缓存/高并发场景)数据流向简单说就是:小程序端发请求 → FastAPI 接收 → JWT 校验身份 → 业务逻辑层处理 → 数据库读写 → 结果返回前端渲染。
后端没有搞微服务,因为项目体量远没到那个程度。一台云服务器 + MySQL + Redis 就够支撑几千个学生的使用规模。服务端部署用 Docker Compose 一把梭,nginx 做反向代理和 HTTPS 终结,这里有个关键点:微信小程序要求所有请求域名必须是 HTTPS,而且需要在微信公众平台配置 request 合法域名,开发时很容易忽略。
2. 数据库设计:核心表结构与字段要点
2.1 用户与角色设计
用户表是所有业务的基础。在设计时我没有用简单的单一用户表 + 角色字段,而是把用户基础信息和角色拆开,原因是教练、学生、教务管理员虽然都是“用户”,但各自的扩展字段差异很大。学生需要记录学号、年级、技术水平等级;教练需要记录擅长的技术方向、带班数量上限;教务管理员则基本只有账号和权限。
CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, openid VARCHAR(64) UNIQUE NOT NULL COMMENT '微信openid', nickname VARCHAR(50), avatar_url VARCHAR(255), role TINYINT NOT NULL DEFAULT 3 COMMENT '1超管 2教务 3教练 4学生', phone VARCHAR(20), status TINYINT DEFAULT 1 COMMENT '1正常 0禁用', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE student_profiles ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, student_no VARCHAR(20) UNIQUE COMMENT '学号', grade VARCHAR(20) COMMENT '年级', level TINYINT DEFAULT 1 COMMENT '1零基础 2初级 3中级 4高级', emergency_contact VARCHAR(20), FOREIGN KEY (user_id) REFERENCES users(id) );角色字段我特意用 TINYINT 而不是字符串,虽然可读性差一点,但检索效率高、存储省。每种角色对应一张扩展表,左连接查询即可。
2.2 课程与选课模块的表设计
选课模块是事务性最强的部分,设计的关键在于课程实例(Class)和课程模板(Course)分离。课程模板定义的是“这是什么课”——比如《乒乓球初级正手攻球训练》,包含名称、适用水平、课时数、教学大纲;课程实例则是“什么时候在哪上、谁教”——关联了具体时间、场地、教练、学期、容量。
为什么要这样设计?因为同一个课程模板会被很多学期、很多校区复用。比如“正手攻球基础”这个课,春季班开两期、秋季班开两期,每期的上课时间和教练都不一样。如果不区分模板和实例,等同一个课程要复开时,所有配置都要重输一遍。
CREATE TABLE courses ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, level TINYINT DEFAULT 1, total_hours INT DEFAULT 16, syllabus TEXT COMMENT '教学大纲', status TINYINT DEFAULT 1 ); CREATE TABLE class_courses ( id INT PRIMARY KEY AUTO_INCREMENT, course_id INT NOT NULL, teacher_id INT NOT NULL COMMENT '教练user_id', semester VARCHAR(20) NOT NULL COMMENT '学期标识,如2025-01', start_date DATE, end_date DATE, capacity INT DEFAULT 12 COMMENT '班级容量', enrolled_count INT DEFAULT 0 COMMENT '已选人数', status TINYINT DEFAULT 1 COMMENT '1报名中 2已满员 3已结束', schedule_json JSON COMMENT '上课时间安排', INDEX idx_semester_course (semester, course_id) );选课表负责记录学生选了什么班、什么时候选的、状态如何:
CREATE TABLE enrollments ( id INT PRIMARY KEY AUTO_INCREMENT, student_user_id INT NOT NULL, class_id INT NOT NULL, status TINYINT DEFAULT 0 COMMENT '0待确认 1已确认 2已取消 3已结课 4退课', source TINYINT DEFAULT 1 COMMENT '1学生自选 2教务代报', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_class (student_user_id, class_id) );这里有个容易踩的坑:普通选课直接插入记录是没有问题的,但并发抢课场景下必须要加锁。后面会专门讲怎么处理。
2.3 成绩与考试模块的数据库设计
乒乓球课的成绩和普通学科类课程差异较大。普通课程存一个分数就行,乒乓球课往往是多次训练课加上一次期末考试,而且每次训练可能分多个技术动作评分。如果一张表硬放所有维度,SQL 查询会很痛苦。
我最终拆成了三张表:
CREATE TABLE class_sessions ( id INT PRIMARY KEY AUTO_INCREMENT, class_id INT NOT NULL, session_no INT NOT NULL COMMENT '第几次课', session_date DATE, topic VARCHAR(100) COMMENT '本次课主题', UNIQUE KEY uk_class_session (class_id, session_no) ); CREATE TABLE session_scores ( id INT PRIMARY KEY AUTO_INCREMENT, session_id INT NOT NULL, student_user_id INT NOT NULL, forehand_score DECIMAL(5,2) COMMENT '正手评分', backhand_score DECIMAL(5,2) COMMENT '反手评分', footwork_score DECIMAL(5,2) COMMENT '步法评分', attendance TINYINT DEFAULT 0 COMMENT '0缺勤 1出勤', comment TEXT, INDEX idx_student_session (student_user_id, session_id) );考试部分类似,但多了考试类型和成绩等级:
CREATE TABLE exams ( id INT PRIMARY KEY AUTO_INCREMENT, class_id INT NOT NULL, exam_name VARCHAR(50) NOT NULL COMMENT '如阶段考核、期末考核', exam_date DATETIME, total_score DECIMAL(5,2) DEFAULT 100, pass_score DECIMAL(5,2) DEFAULT 60, status TINYINT DEFAULT 0 COMMENT '0未开始 1进行中 2已结束' ); CREATE TABLE exam_results ( id INT PRIMARY KEY AUTO_INCREMENT, exam_id INT NOT NULL, student_user_id INT NOT NULL, score DECIMAL(5,2) NOT NULL, grade VARCHAR(10) COMMENT 'A/B/C/D', evaluator_id INT COMMENT '评分教练', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_exam_student (exam_id, student_user_id) );这里有个设计心得:成绩字段一律用 DECIMAL 而不是 INT。虽然乒乓球课一般打整数分,但有的教练会打出 8.5 这样的小数分,用 INT 就尴尬了。另外 score 字段直接存百分制,等级(grade)可以事后根据分数计算出来,不需要在录入时单独维护。
2.4 课时与签到记录的冗余设计
传统的教务系统有个经典问题:教练改一次课,所有学生的签到记录都要跟着变。比如原定周三的课因为场地原因调到周五,如果签到表里存的是日期而不是 class_session_id,就会导致旧日期的记录作废,新日期的记录要重新产生。
所以我在设计时坚持一个原则:一切业务记录尽量关联到 class_session_id(课程实例),而不是一个具体日期。调课只是修改 class_sessions 表的 session_date 字段,签到记录完全不用动。这个设计一开始多费了点心思,但后面处理调课、补课、临时加课都变得很简单。
3. 后端核心接口设计与实现要点
3.1 FastAPI + SQLAlchemy 的项目结构
项目结构我采用了大纲式分层,确保业务逻辑和数据访问不纠缠在一起:
pingpong_admin/ ├── app/ │ ├── main.py # 应用入口 │ ├── config.py # 配置读取 │ ├── database.py # 数据库连接 │ ├── models/ # ORM模型 │ │ ├── user.py │ │ ├── course.py │ │ ├── enrollment.py │ │ ├── exam.py │ │ └── ... │ ├── schemas/ # Pydantic数据校验 │ ├── api/ │ │ ├── auth.py # 登录注册 │ │ ├── courses.py # 课程查询 │ │ ├── enroll.py # 选课 │ │ ├── scores.py # 成绩录入 │ │ ├── exams.py # 考试管理 │ │ └── dashboard.py # 首页统计 │ ├── core/ │ │ ├── security.py # JWT生成与校验 │ │ └── deps.py # 依赖注入(获取当前用户) │ └── utils/ │ ├── excel_export.py # 成绩导出 │ └── wechat.py # 微信登录/订阅消息用 FastAPI 的好处是自动生成 Swagger 文档,联调效率极高。前端写完页面,我直接打开/docs就能测试所有接口,不需要先写好 Postman 集合。而且 Pydantic 的响应模型直接把输出数据结构定义清楚,前端同学照着类型定义写代码,类型错误能提前暴露。
3.2 微信登录与 JWT 鉴权
小程序的登录流程其实是固定的套路:前端调用wx.login()获取临时 code,把 code 发给后端,后端拿着 code 加上小程序的 AppID 和 AppSecret 去微信接口换取 openid 和 session_key,用 openid 作为用户唯一标识,签发自己的 JWT。
@app.post("/api/auth/wx_login") async def wx_login(data: WxLoginRequest, db: Session = Depends(get_db)): # 1. 前端传来的临时 code 换取 openid url = "https://api.weixin.qq.com/sns/jscode2session" params = { "appid": settings.WX_APPID, "secret": settings.WX_APPSECRET, "js_code": data.code, "grant_type": "authorization_code" } resp = httpx.get(url, params=params) result = resp.json() if "errcode" in result: raise HTTPException(status_code=400, detail=f"微信登录失败: {result['errmsg']}") openid = result["openid"] # 2. 查找用户,不存在则注册 user = db.query(User).filter(User.openid == openid).first() if not user: user = User(openid=openid, role=UserRole.STUDENT) db.add(user) db.commit() db.refresh(user) # 3. 签发自定义 JWT token = create_access_token(user.id, expires_delta=timedelta(days=30)) return {"token": token, "user_info": {"nickname": user.nickname, "role": user.role}}JWT 有效期我设了 30 天,学生端基本是“登录一次管一个月”,体验比较好。这里注意:后端拿到的是 openid,不是 unionid。如果以后系统还要接公众号、App,必须用 unionid 做统一标识,否则同一个用户在不同端会生成多条用户记录。
另一个容易出问题的点是:微信的jscode2session接口有频率限制。如果学生集中同时打开小程序(比如选课开始那一刻),后端对微信接口的调用会瞬间暴涨,可能触发限流。我的做法是加了一层 Redis 缓存:openid 获取结果缓存 5 分钟,同一个 code 只允许请求一次,重复请求直接返回缓存结果。
3.3 选课并发控制:怎么避免同一个班被抢爆
选课是这套系统里对一致性要求最高的操作。乒乓球课程每个班容量有限,如果多个学生同时点击选同一个最后名额,处理不好就会超选。
当时我实验过三种方案:
方案一:先查后插(不加锁)。100% 不行,并发下会超卖。两个请求同时读到 enrolled_count=11,都判断小于 12,都执行插入,最后班级变成 13 人。
方案二:在插入记录前用 SELECT FOR UPDATE 锁住班级行。这个方案可行,但对数据库连接占用时间较长,高峰期可能拖垮数据库连接池。
方案三:条件更新(乐观锁)。这是我最推荐的方式,用一条 SQL 原子地完成“判断容量 + 更新名额”:
@app.post("/api/enroll") async def create_enrollment(data: EnrollRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): class_course = db.query(ClassCourse).filter( ClassCourse.id == data.class_id, ClassCourse.status == 1 ).first() if not class_course: raise HTTPException(status_code=404, detail="课程不存在") # 关键:条件更新,只有 enrolled_count < capacity 才会更新成功 updated = db.execute( update(ClassCourse) .where( ClassCourse.id == data.class_id, ClassCourse.enrolled_count < ClassCourse.capacity ) .values(enrolled_count=ClassCourse.enrolled_count + 1) ) if updated.rowcount == 0: raise HTTPException(status_code=400, detail="该班级已满员") enrollment = Enrollment( student_user_id=current_user.id, class_id=data.class_id, status=1 ) db.add(enrollment) db.commit() return {"code": 0, "message": "选课成功"}这里的关键在于enrolled_count < ClassCourse.capacity这个条件是在 UPDATE 语句的 WHERE 子句里判断的,数据库行锁会保证同一时间只有一个事务能把这个字段加一。实测 1000 个学生同时抢一个 12 人班,最终选中的恰好是 12 人,数据库层面就不会超卖。
当然,事务提交前最好再查一次 enrollment 唯一索引是否冲突,防止同一个学生重复选同一门课。这个靠数据库的唯一索引兜底,应用层也要做一次判断。
3.4 成绩录入与自动计算总评
成绩录入的接口设计成了“批量提交”模式,而不是单个保存。一个教练带着十几个学生上完一次课,他需要能一次性录入全班所有人的评分。前端传一个数组,后端批量校验、批量写入。
总评的计算规则是:期末总评 = 每次训练课平均分 × 60% + 期末考试成绩 × 30% + 出勤分(10%)。出勤分按出勤率换算,满勤就是满分,缺一次课扣若干分。这个规则我集成在了一个计算函数里,教务端可以随时调整权重,不需要改代码。
def calculate_final_score(db: Session, class_id: int, student_user_id: int): # 训练课成绩平均值 session_scores = db.query(SessionScore).join(ClassSession).filter( ClassSession.class_id == class_id, SessionScore.student_user_id == student_user_id ).all() if not session_scores: return None, "无训练课成绩" total_session_score = sum( (s.forehand_score + s.backhand_score + s.footwork_score) / 3 for s in session_scores ) session_avg = total_session_score / len(session_scores) # 期末考试成绩 exam_result = db.query(ExamResult).join(Exam).filter( Exam.class_id == class_id, ExamResult.student_user_id == student_user_id ).first() # 出勤率 attendance_rate = sum(s.attendance for s in session_scores) / len(session_scores) final_score = session_avg * 0.6 + (exam_result.score if exam_result else 0) * 0.3 \ + attendance_rate * 100 * 0.1 return round(final_score, 2), None这个函数可以在每次录入成绩后调用,也可以批量在考试结束后统一重新计算。因为最终成绩只是一个综合指标,原始训练数据都保留在明细表里,重算成本很低,所以我选择不落库,每次查询时实时计算。当然如果数据量大到明显影响查询速度,可以考虑把最终成绩缓存到一张汇总表,用后台任务异步更新。
3.5 考试管理:从排考到成绩发布
考试流程分三个阶段:创建考试场次 → 学生查看考试安排 → 教练录入成绩 → 系统发布结果。
创建考试时,除了基本信息,还需要关联班级和教练。这里有个业务细节:同一场考试可能涉及多个班的学员(比如期末统一考核,多个平行班合并考),所以考试表不能只关联一个 class_id,而是要加一个考试_班级关联表。我最初设计时忽略了这点,导致后来不得不重构,这里提醒大家一开始就把多对多关系设计好。
考试前 3 天,系统通过微信订阅消息提醒学生考试时间和地点。这是微信比较“坑”的地方:订阅消息需要用户在小程序内主动点击授权同意,而且一次性订阅只能推送一条消息。所以我在学生查看考试详情页时放了一个“订阅考试提醒”按钮,用户点击后后端用这个授权记录推送消息。想让学生自动收到提醒是不可能的,这也是微信政策的限制。
成绩发布这块,学生端只能看到自己的成绩,教练端可以看到整个班的成绩分布。我这里实现了一个简单的统计接口,返回平均分、最高分、及格率等指标,方便教练了解班级整体情况。
4. 前端 Uniapp 小程序端实战
4.1 项目初始化和目录规划
Uniapp 项目创建后,我按模块去组织页面目录:
src/ ├── pages/ │ ├── login/login.vue # 登录页 │ ├── index/index.vue # 首页/课程列表 │ ├── course/detail.vue # 课程详情 │ ├── enroll/my.vue # 我的选课 │ ├── score/list.vue # 成绩列表 │ ├── score/detail.vue # 成绩详情 │ ├── exam/list.vue # 考试列表 │ └── mine/mine.vue # 个人中心 ├── api/ # 接口请求封装 │ ├── request.js # 请求拦截器 │ ├── auth.js │ ├── course.js │ ├── score.js │ └── exam.js ├── store/ # Pinia 状态管理 │ └── user.js ├── utils/ │ ├── auth.js # token 存取 │ ├── date.js # 日期格式化 │ └── role.js # 角色判断 └── static/页面按功能模块命名,每个页面尽量保持单一职责。我在实际开发中吃过亏:一开始把课程列表、选课状态、课程详情全放在一个页面,逻辑缠在一起后,改一个需求另一处跟着出 bug。后来拆成列表页和详情页,数据流清晰多了。
4.2 请求封装与登录态管理
请求封装是整个小程序端的地基。我用uni.request包了一层,统一处理 token 注入、错误提示、401 跳转:
// api/request.js export function request(options) { return new Promise((resolve, reject) => { const token = uni.getStorageSync('token') uni.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, header: { 'Content-Type': 'application/json', 'Authorization': token ? `Bearer ${token}` : '' }, success: (res) => { if (res.statusCode === 401) { // token 失效,重新登录 uni.removeStorageSync('token') uni.navigateTo({ url: '/pages/login/login' }) reject(res) return } if (res.data.code !== 0) { uni.showToast({ title: res.data.message, icon: 'none' }) reject(res) return } resolve(res.data) }, fail: (err) => { uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' }) reject(err) } }) }) }登录态的保持策略是:先检查本地有没有 token,有就直接进首页;没有才调wx.login()。很多新手写登录时习惯每个页面都检查登录态,导致页面跳转逻辑复杂。我的做法是在 App.vue 的onLaunch里统一检查一次,之后所有页面都假定用户携带 token 请求,后端返回 401 再统一拦截处理。
4.3 课程列表与选课页面的核心逻辑
课程列表页的展示逻辑比较简单,但有一个点值得注意:根据角色显示不同的数据。学生登录后看到的是所有可选的课程班型;教练登录后看到的应该改成“我带的班”入口;教务管理员看到的则是所有班级的管理入口。这个角色判断放在路由跳转时判断比放在页面渲染时判断更轻量。
选课页面最关键的是“立即选课”按钮的状态控制:
// 根据课程状态动态控制按钮 function getEnrollBtnStatus(course) { if (course.status === 2) return { text: '已满员', disabled: true } if (course.status === 3) return { text: '已结束', disabled: true } if (course.enrolled) return { text: '已选课', disabled: true } if (course.status === 1) return { text: '立即选课', disabled: false } return { text: '不可选', disabled: true } }这里还有一个体验细节:点击选课按钮后,要进入“提交中”状态,防止用户重复点击造成重复请求。后端做了唯一索引来兜底,但前端也不应该制造这种并发请求。
4.4 成绩与考试页面的展示实现
成绩页面我用了“班级维度 + 学员维度”的双视图模式。学生进去看到自己的历次训练课成绩和最终总评,用表格和柱状图展示趋势;教练进去看到全班同学的成绩汇总。
图表展示我用的是lime-echart(基于 ECharts 的 Uniapp 适配组件)。用它的原因是在微信小程序端的性能表现比直接用 canvas 画要好。展示每次训练课的正手、反手、步法三项得分趋势,ECharts 的雷达图非常直观。
考试页面的核心是一个“考试详情卡片”,展示考试名称、时间、地点、状态。状态有三种:未开始(灰色)、进行中(绿色)、已结束(可查看成绩)。我在这里做了一个倒计时展示,用setInterval每秒更新,考试当天之前3天自动提示“距离考试还有X天”。
4.5 微信小程序原生适配问题
Uniapp 开发小程序有个很烦人的问题:同一个代码,H5 端和微信小程序端的行为不完全一致。我踩过最坑的是onPullDownRefresh这个下拉刷新事件,在 H5 端直接不触发,必须通过uni.startPullDownRefresh()手动调用。
另一个问题是微信小程序的导航栏高度不统一。iPhone X 系列和安卓机的状态栏高度不同,如果页面里用了自定义导航栏,需要动态获取uni.getSystemInfoSync().statusBarHeight来适配。
打包时还需要注意manifest.json里的mp-weixin配置。AppID 一定要填对,不然真机预览时会报appid not found之类的错。开发时我用测试号,发布前必须换成正式 AppID,否则微信审核不会通过。
5. 常见问题与排查技巧实录
5.1 微信登录失败:获取不到 openid
这是所有微信小程序开发者的第一道坎。现象是调用wx.login()拿到 code 后,后端请求jscode2session报错,常见错误码有 40013(invalid appid)、40125(invalid appsecret)、45011(api minute-quota reach limit)。
排查顺序是:
- 先确认
manifest.json里的小程序 AppID 是否填写正确。 - 再确认后端配置的 AppSecret 和 AppID 是否配对。很多人会把测试号的 secret 配到正式号的 appid 上,导致失败。
- 然后检查后端服务器的出口 IP 是否被微信限流。如果同一 IP 在短时间内大量调用
jscode2session,会被临时封禁一段时间。 - 最后看 code 是否使用了两次。微信的 code 是一次性的,用一次就失效,如果前端因为网络重试把同一个 code 发了多次,后端就会拿到失效 code。
项目中还遇到过一次wx.login返回成功但 openid 为空的诡异情况,最后发现是小程序后台的“个人信息保护指引”没配置完整,用户授权弹窗没有正常弹出导致的。这个属于平台侧问题,处理方式是引导用户在小程序设置页清除授权后重新进入。
5.2 Uniapp 编译报错 not found: page
这个报错很常见但也很迷惑。现象是运行到微信开发者工具时,提示找不到 page,但代码里明明有这个页面。
我排查下来的原因有这几个:
pages.json里注册的页面路径写错了,多写或少写一个斜杠。- 页面文件没有保存,或者新建页面后没有重新编译,工具缓存了旧的页面列表。
uni.simpleRouter或者自定义路由配置里引用了不存在的路径。- 微信开发者工具的“编译模式”配置里设置了自定义启动页面,而这个页面路径已失效。
最省事的排查方式是:先把微信开发者工具关掉,在 HBuilderX 里执行“重新编译”,如果还不行就删掉unpackage/dist/dev/mp-weixin目录重新来一次,基本能解决 80% 的缓存问题。
5.3 微信开发者工具里小程序 ID 一直是旧的
这个问题有两种典型场景。第一种是换了一个项目,但开发者工具右上角的 AppID 还是上一个项目的;第二种是在 HBuilderX 里修改了manifest.json的mp-weixin.appid,但运行到开发者工具后还是旧值。
第一种情况,直接在微信开发者工具的“详情 - 基本信息 - AppID”里点击修改按钮,重新填写新的 AppID 即可。
第二种情况,是因为 HBuilderX 会将manifest.json中的配置编译写入到项目下的project.config.json文件里。如果改了manifest.json,但没有重新编译,生成的project.config.json还是旧配置。解决方式很简单:重新运行到微信开发者工具,确保每次修改配置后都触发一次完整编译。如果还是不生效,可以手动打开unpackage/dist/dev/mp-weixin/project.config.json,确认里面的appid字段是否正确。
5.4 数据一致性:选课人数和实际记录对不上
这个问题在测试环境出现过一次。某个班级的enrolled_count显示 11,但 enrollment 表里实际有 12 条记录。排查后发现是有的选课记录被取消了(status=2),但enrolled_count没有回退。
我在最初的后端逻辑里只在“新增选课”时加一,忘了在“取消选课”时减一。修复方式是:
@app.post("/api/enroll/cancel") async def cancel_enrollment(data: CancelEnrollRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): enrollment = db.query(Enrollment).filter( Enrollment.id == data.enrollment_id, Enrollment.student_user_id == current_user.id ).first() if not enrollment or enrollment.status != 1: raise HTTPException(status_code=400, detail="选课记录不存在或无法取消") enrollment.status = 2 # 回退班级人数 db.execute( update(ClassCourse) .where(ClassCourse.id == enrollment.class_id, ClassCourse.enrolled_count > 0) .values(enrolled_count=ClassCourse.enrolled_count - 1) ) db.commit()事后我加了一个定时任务,每天检查enrolled_count与enrollment实际记录是否一致,不一致则自动修正。这种一致性校验在教务系统里很有必要,因为你永远不知道哪个接口漏了逻辑。
5.5 小程序真机预览与体验版的问题
开发调试时用“开发者工具”一切正常,但手机上预览就会出现白屏或接口报错。这个九成是域名配置问题。微信小程序的要求是:
- 必须使用 HTTPS 请求,且域名必须在微信公众平台“开发管理 - 开发设置 - 服务器域名”中配置过。
- 开发者工具可以勾选“不校验合法域名”,但真机上必须开启校验,所以测试阶段就要把后端域名配好。
- 域名备案是国内服务器的前提,如果后端部署在未备案的服务器或 IP 上,真机请求会被拦截。
另外,真机预览时微信开发者工具会生成一个预览二维码,用测试号扫码进入。但如果项目里使用了自定义组件或动态库,预览包可能不包含所有资源,这时需要改用“上传”功能生成体验版,在“成员管理”里把测试人员加入体验成员列表。
5.6 上线前 CheckList
系统上线前,我把容易遗漏的点整理成了一份清单,分享给大家参照:
- [ ] 小程序正式 AppID 已配置,且在微信公众平台完成认证
- [ ] 服务器域名已配置为 HTTPS,且证书有效
- [ ] 后端接口全部走 HTTPS,没有明文 HTTP 请求
- [ ] 微信订阅消息模板 ID 已申请并通过审核
- [ ] 用户协议和隐私政策页面已上线,并在小程序后台填写
- [ ] 测试数据已清理,生产环境数据库迁移已完成
- [ ] 并发选课的乐观锁逻辑已压测,无超卖情况
- [ ] 管理员可正常导出 Excel 成绩单
- [ ] 成绩计算规则已由教务老师确认无误
- [ ] 小程序的体验版已完整走通一遍“选课 → 上课 → 成绩 → 考试”全流程
6. 部署与后续扩展建议
6.1 生产环境部署要点
部署我推荐直接用 Docker Compose 编排,三件套搞定:nginx、后端、MySQL,外加一个 Redis。这是最省心的配置方式,不用手动装环境。
version: "3.8" services: mysql: image: mysql:8.0 container_name: pingpong-mysql environment: MYSQL_ROOT_PASSWORD: your_password MYSQL_DATABASE: pingpong_admin volumes: - ./mysql_data:/var/lib/mysql ports: - "3306:3306" redis: image: redis:7-alpine container_name: pingpong-redis ports: - "6379:6379" backend: build: . container_name: pingpong-backend depends_on: - mysql - redis environment: DATABASE_URL: mysql+pymysql://root:your_password@mysql:3306/pingpong_admin REDIS_URL: redis://redis:6379/0 WX_APPID: your_appid WX_APPSECRET: your_appsecret JWT_SECRET: your_jwt_secret ports: - "8000:8000" nginx: image: nginx:alpine container_name: pingpong-nginx ports: - "80:80" - "443:443" volumes: - ./nginx.conf:/etc/nginx/nginx.conf - ./ssl:/etc/nginx/ssl depends_on: - backend有一个细节:MySQL 的密码不要写死到 docker-compose.yml 里,最好用环境变量文件.env管理,这样即使代码库被泄露,密码也不会暴露。
6.2 后续功能扩展方向
系统做到这里,已经能完整跑通教务选课、成绩、考试三大流程。但基于实际使用反馈,以下几个方向值得后续扩展:
课程评价与反馈模块。学生上完课可以给教练打分、留言,这会倒逼教学质量提升,同时为教务管理提供考核依据。
自动排课与冲突检测。现在教练的排课是手工配置的,如果课程数量多了,容易出现场地、教练时间冲突。接入一个排课算法,根据场地空闲时间、教练可用时段、班级容量自动生成排课方案,效率会大幅提升。
数据分析看板。比如统计每个班级的选课转化率、学生的成绩趋势、教练带班的整体成绩分布,用数据驱动教学管理决策。
多校区支持。当前所有表结构都隐含“单校区”的假设,如果要扩展到多个校区,需要给课程表、班级表加校区字段,并在查询时按校区过滤。
这些扩展方向在最初的表结构设计里都已经预留了扩展位,比如用户表有 status 字段、课程表有 status 字段,后续加软删除、停用等操作不需要改表结构。
根据我在实际搭建这套系统过程中的体会,最值得分享的一点是:教务系统的核心不在“功能多”,而在“数据对”。选课人数能不能对上、成绩计算规则是否一致、调课后签到数据是否错乱,这些看起来不起眼的细节才是决定教务系统好不好用的关键。技术选型反而相对简单,Python、Uniapp、微信小程序这套组合开发效率高、生态成熟,对做中小型场景的团队来说是性价比很高的选择。
如果你也要做类似的课程教务管理系统,建议先从“数据一致性”这个角度出发去设计表结构和接口逻辑,而不是急着堆功能。把选课并发、成绩计算、考试发布这几条主链路的数据流打通,剩下的页面和交互只是锦上添花。