最近在做在线学习平台的后端服务,接到一个任务编号是“Day02-07”,内容为分析产品原型、设计查询指定课程学习状态接口。一开始以为是普通的CRUD,真正动手才发现这个接口涉及的远不止一张表和一条SQL,还牵扯到状态机设计、进度计算口径、缓存策略这些细节。这篇把整个设计和落地过程整理出来,给同样做在线教育、知识付费类产品的后端朋友一个参考。
1. 拿到产品原型,先把页面元素“翻译”成数据需求
1.1 原型图上每一个动态元素,背后都是一个数据来源
产品经理丢过来一份原型,页面上有课程卡片、学习进度条、状态标签、继续学习按钮、最近学习时间。看起来都是UI展示,但对后端来说,这些都是要提供数据的接口契约。
我在收到原型后做了一件事:把页面上的动态元素全部列出来,挨个标注数据来源。做这个动作的时候,最好找产品经理或前端一起确认,避免信息差。经过确认后的需求来源分析如下:
- 课程状态标签:需要后端返回用户与课程的关系状态,来源是用户课程关系表。
- 学习进度百分比:需要返回已完成章节数与总章节数,来源是学习记录表和课程章节表。
- 最近学习时间:需要返回最后一次学习操作的时间,来源是学习记录表。
- 继续学习按钮跳转位置:需要返回当前学习到哪个章节,来源同样是学习记录表。
- 课程有效期截止时间:需要返回课程截止时间,来源是课程表或用户课程关系表。
很多后端新手拿到原型会直接开始建表写接口,跳过这一步。实际上,前后端联调时大部分问题都出在“页面要的数据没在接口里定义”或“接口返回了字段但前端用不上”这两种情况上。先做一张“原型元素→数据字段”的映射表,可以省下后面大量沟通成本。
1.2 从页面交互反推接口的边界
界面上的元素不只是静态展示,有些交互行为会直接决定接口的边界。
比如原型上有一个“继续学习”按钮,点击之后要跳转到上次学习的章节。这个交互看着简单,但它说明接口不仅要返回课程状态、进度、时间这几个字段,还必须返回“当前章节ID”。否则前端是没法定位“继续学习”的位置的。
还有一类场景是:如果课程已过期,原型的按钮状态是置灰不可点击。这意味着接口必须返回“是否过期”或“deadline”字段,前端根据这个字段控制按钮状态。此时单靠一个状态枚举可能不够,最好把deadline单独返回,方便前端做更灵活的展示。
再比如原型课程卡片上展示了“已完成 14/25 章”,这个数据用进度百分比虽然能展示,但前端如果要做“14/25”这种明细展示,后端就需要返回completedSectionCount和totalSectionCount两个独立字段。这就是为什么接口设计不能只拍脑袋定一两个“够用就好”的字段。
2. 课程学习状态的数据模型,先把状态机和进度口径定清楚
2.1 状态枚举不要只拍脑袋定两个值
最初设计状态时,第一反应就是“未开始”和“已完成”两个状态。但仔细看了原型之后发现不对,页面上还有“学习中”和“已过期”这两种标签。如果课程是训练营模式,有固定的有效期,那么“已过期”是必须存在的状态;如果用户报名后还没开始学习,那“未开始”也要有。
最终状态枚举定了四个:
- not_started:未开始,用户已报名但没有任何学习记录。
- learning:学习中,有学习记录但未达到完成条件。
- completed:已完成,达到课程完成条件。
- expired:已过期,课程有效期已结束,不能再学习。
这里有几个细节需要注意。第一,状态字段用字符串枚举而不是数字。虽然数据库存储上数字更节省空间,但接口层用字符串可读性更好,前端switch-case也直观。第二,状态流转是有向的,不是所有状态之间都能互相切换。主链路是not_started → learning → completed,expired是一个终态,由课程有效期决定。如果产品允许“过期后重新激活”,那还要把expired → learning这条路径补上,并且记录激活时间,否则对账容易出问题。
2.2 学习百分之进度到底怎么算
“进度80%”是怎么定义出来的?这个口径在原型阶段就要确认清楚。
目前业界常见的有两种口径:
- 按章节数完成比例计算,即已完成章节数除以总章节数。
- 按视频观看时长计算,即已观看总时长除以课程总时长。
按章节算的好处是实现简单、数据可靠。学习记录表只需要在用户完成某个章节时插入一条记录,统计时COUNT一下就行。按视频时长算的好处是更精细,能反映“看了但没看完”的真实情况,但问题也明显:前端需要频繁上报播放心跳,数据量大,而且存在“挂机刷时长”的作弊问题。
我实测下来最终采用的是按章节数计算。原因有三个,第一,章节是结构化数据,天然有稳定的数量口径;第二,统计逻辑简单,COUNT即可完成,不需要依赖复杂的时长汇总;第三,用户对“完成章节数量”有感知,进度条变化明确。
2.3 章节完成判定的阈值设计
如果按章节数算进度,那“什么算完成一个章节”就必须定义清楚。
最简单的方式是:用户点击“下一节”或播放到视频最后一秒时标记完成。但这种方式很脆弱,用户中途退出视频,或者把播放器拖到结尾骗进度,都会导致判定不准。
一个更稳定的方案是:前端定期上报播放进度,后端判断播放位置超过章节总时长的80%或播放到末尾时,将章节标记为完成。阈值可以做成后台可配置的参数,默认80%。这样即使播放器中间有几次心跳丢失,也不容易误判。
不过要注意,这里“完成”和“学习过”是两回事。只要用户打开过章节页面,就应该记录一条“学习过”的痕迹,用于更新最近学习时间;只有进度达到阈值才标记“完成”。两个事件分开记录,后面查询状态时才会准确。
3. 接口定义:路径、请求参数、响应结构、状态码
3.1 属于查询语义,用GET而不是POST
查询指定课程学习状态这个接口,从语义上讲是一个纯读取操作,所以第一版设计定的是GET请求。
接口路径设计为:
GET /api/v1/courses/{courseId}/learning-status课程ID作为路径参数,因为它在RESTful语义里是资源的标识。用户ID怎么传?如果系统有统一的登录态,后端从token里解析用户ID即可,不需要前端显式传参。如果没有登录态,就作为query参数传,例如?userId=123。
为什么不把courseId也放进query参数或者放进request body?因为从资源路径表达语义,路径参数比query参数更清晰,也更方便网关层做权限校验和日志记录。而用POST + body的方式虽然也能实现,但语义上不够直观,还容易被人质疑“查询为什么用POST”。
3.2 响应结构里多给几个字段,前端好干活
响应结构定义为:
{ "code": 0, "message": "success", "data": { "courseId": 1001, "status": "learning", "progress": 56, "completedSectionCount": 14, "totalSectionCount": 25, "currentSectionId": 15, "lastLearnTime": "2025-01-10T14:30:00+08:00", "deadline": "2025-03-31T23:59:59+08:00" } }这里每个字段都有它的用途:
- status:核心字段,前端展示状态标签。
- progress:学习进度百分比,前端画进度条。
- completedSectionCount和totalSectionCount:前端可以直接展示“14/25”。
- currentSectionId:用于“继续学习”跳转定位。
- lastLearnTime:展示“最近学习:1月10日”这类文案。
- deadline:课程有效期截止时间,前端据此判断是否需要展示“已过期”标签。
有的团队会把completedSectionCount直接省掉,只给一个progress百分比。但实际做下来发现,给了明细字段以后,前端做起来舒服很多,还不用自己根据百分比逆推章节数量。
3.3 异常场景的错误码不能少
接口设计不只包含正常链路,还要预判异常场景。
我整理了这张错误码表,直接在接口文档里同步给前端:
| 异常场景 | HTTP状态码 | 业务错误码 | 提示信息 |
|---|---|---|---|
| 参数缺失或格式错误 | 400 | 40001 | 参数错误 |
| 课程不存在 | 404 | 40401 | 课程不存在 |
| 用户未报名该课程 | 409 | 40901 | 用户未报名该课程 |
| 课程已下架 | 410 | 41001 | 课程已下架 |
| 服务端异常 | 500 | 50000 | 系统繁忙,请稍后重试 |
“用户未报名”这个错误码特别容易漏。原型页面上,未报名用户根本不展示学习状态卡片,所以前端正常路径下不会触发这个错误。但接口设计要考虑非正常调用,比如用户手动构造请求,或者前端状态管理出现异常,这时候有一个明确的错误信息比空数据可排查得多。
4. 底层数据支持:表结构、SQL查询与性能优化
4.1 三张表的分工各司其职
查询课程学习状态,底层数据来自三张核心表:
用户课程关系表(user_course)记录用户与课程之间的归属关系,包括报名时间、有效期、关系状态:
CREATE TABLE user_course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, course_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1-正常 0-已退课', expire_time DATETIME DEFAULT NULL COMMENT '课程到期时间', enroll_time DATETIME NOT NULL COMMENT '报名时间', create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_user_course (user_id, course_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;课程章节表(course_section)记录课程包含哪些章节,用于统计总章节数和定位当前章节:
CREATE TABLE course_section ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, title VARCHAR(255) NOT NULL, sort_order INT NOT NULL COMMENT '章节排序', duration INT DEFAULT 0 COMMENT '视频时长(秒)', is_deleted TINYINT NOT NULL DEFAULT 0, UNIQUE KEY uk_course_sort (course_id, sort_order) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;学习记录表(learning_record)记录用户对每个章节的学习痕迹,是查询进度和最后学习时间的核心依赖:
CREATE TABLE learning_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, course_id BIGINT NOT NULL, section_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0-学习中 1-已完成', last_position INT DEFAULT 0 COMMENT '播放位置(秒)', create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_user_section (user_id, course_id, section_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;4.2 核心SQL:三次查询组合出最终状态
查询逻辑拆解为三步:查课程关系、查已完成章节数、查总章节数。
-- 第一步:查询用户与课程的关系 SELECT user_id, course_id, status, expire_time FROM user_course WHERE user_id = #{userId} AND course_id = #{courseId} LIMIT 1; -- 第二步:查询当前用户已完成章节数 SELECT COUNT(*) FROM learning_record WHERE user_id = #{userId} AND course_id = #{courseId} AND status = 1; -- 第三步:查询课程总章节数 SELECT COUNT(*) FROM course_section WHERE course_id = #{courseId} AND is_deleted = 0;这三步查询在业务量不大时,完全够用。如果对性能有更高要求,可以将第二和第三步合并,通过子查询实现,但可读性会变差。实际项目里我倾向于保留三个独立查询,逻辑一目了然,后续要加缓存也方便。
4.3 索引设计和缓存策略
索引设计上,三张表都有联合索引或唯一索引,上面建表语句已经体现。需要强调的是learning_record表上的(user_id, course_id, section_id)唯一索引,这个索引除了加速查询外,还天然承担了幂等约束,防止同一用户对同一章节插入多条重复学习记录。
缓存方面,课程学习状态是一个读多写少的场景。用户每次打开课程列表页和学习页都会调用这个接口,而状态的写入只发生在用户完成章节时。可以直接用Redis缓存:
key: course:learning:status:{userId}:{courseId} value: JSON字符串(status、progress、currentSectionId等) ttl: 600秒在章节完成事件上报时,主动删除对应用户课程的缓存key,保证下一次查询能拿到最新状态。如果担心缓存雪崩或击穿,可以在回源查询时加一个互斥锁,或者缓存空值一段时间。实际项目如果流量规模不大,不一定要做这么复杂的保护,但ttl策略和主动失效是必须的。
另外,列表页如果同时展示多门课的学习状态,不要循环调用单查询接口,会出现N+1问题。建议单独设计批量查询接口,传入courseId列表,后端一次查出多门课的状态,返回Map结构。
5. 从原型到接口落地,这几个坑我提前帮你踩过了
5.1 学习状态和学习记录不同步
项目联调时发现过一个怪问题:用户明明在播放器里看了视频,回到课程列表页,课程状态还是“未开始”。定位后发现,视频播放模块上报的只是“播放位置”,没有更新“章节完成”状态,而课程状态依赖章节完成记录。这属于典型的模块间数据联动缺失。最终处理方案是:在用户播放进度达到阈值时,同时触发“章节完成”和“课程状态更新”两个事件,哪怕状态更新晚一点,也要保证事件最终一致。后端的接口层只负责查询,状态更新由事件处理程序负责,不耦合在视频播放上报接口里。
5.2 并发重复学习导致的脏数据
有段时间出现过学习记录翻倍的情况。排查后发现是用户同时开了多个标签页,前端并发上报了多个“章节完成”事件。虽然唯一索引兜底避免了真正插入重复记录,但接口层返回的“操作成功”在不同线程之间竞争时,出现了部分记录update_time被旧值覆盖的情况。这里给出一个具体的解决方案,就是用INSERT ... ON DUPLICATE KEY UPDATE,让写入操作具备幂等性,同时将update_time设置为当前时间,而不是传参值。另外insert时带上所有业务字段,避免老线程把新数据覆盖掉。
5.3 用户回退学习,进度条倒退了怎么办
有的用户学完第五章后,回头重新学第三章。按照“最近学习位置”逻辑,进度会显示倒退,用户会觉得进度条“缩水”。这个问题在原型评审阶段产品经理没提,直到测试用例写出来才发现。最终定的规则是:章节一旦标记为“完成”,状态永远不回退。用户回看章节只更新last_position,不改变已完成标记。这样进度是单调递增的,用户体验上也更好理解。
5.4 查询接口的性能边界与批量场景
单查询接口性能没太大压力,因为走的是唯一索引。但课程列表页的场景麻烦一些,如果用户首页要展示“最近学习的8门课”,前端对每门课调一次单查接口,就会产生8次HTTP请求,后端还要承担8次查询。
实际项目里后续补充了批量查询接口,路径为POST /api/v1/courses/batch-learning-status,请求体传入courseIds数组,后端批量查询后返回课程ID为key的Map。批量接口里对courseIds做了上限限制,单次最多20个,超过则报参数错误。这既控制了数据库压力,也防止接口被恶意传超大数组。
5.5 时间字段的时区和格式问题
lastLearnTime和deadline这两个时间字段,在后端返回时必须明确时区。最初接口返回的是UTC时间,前端没做转换,直接把字符串展示出来,导致用户看到的时间和本地时间差了8小时。这是一个很简单但很典型的问题。
最终处理方案是:接口统一返回带时区偏移的ISO 8601格式,例如2025-01-10T14:30:00+08:00,前端直接用原生Date解析,不需要自己拼接时区。数据存储层统一使用UTC时间,只在接口出参时转换为东八区。这样一套规则下来,三端(App、H5、小程序)的时间显示就统一了。
回到最开始那句话,“分析产品原型、设计查询指定课程学习状态接口”,这个任务名称看起来平淡,但做下来一整轮才发现,真正的工作量不在写接口那一下,而在于把产品原型里那些标签、按钮、进度条背后的业务规则都拆出来,翻译成清楚的数据模型和接口契约。我最大的体会是,拿到原型不要急着动工,先和产品经理把状态枚举、进度口径、完成判定这三个问题对齐,后面可以少走很多弯路。另外,接口设计时多给前端返回几个“看似多余”的展示字段,联调阶段会顺滑很多。