Skip to content

Commit 00f418f

Browse files
committed
feat:add exam notice
1 parent be2811f commit 00f418f

3 files changed

Lines changed: 34 additions & 0 deletions

File tree

internal/course/service/exam_snapshot.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type CourseExamInfo struct {
3333
}
3434

3535
func buildCourseExamInfo(courses []*jwch.Course) []CourseExamInfo {
36+
// 从选课接口结果中提取考试快照;没有考试时间的课程不参与考试通知。
3637
exams := make([]CourseExamInfo, 0, len(courses))
3738
for _, course := range courses {
3839
if course == nil || strings.TrimSpace(course.RawExamTime) == "" {
@@ -46,6 +47,7 @@ func buildCourseExamInfo(courses []*jwch.Course) []CourseExamInfo {
4647
})
4748
}
4849

50+
// 统一考试快照的顺序,避免教务处返回顺序变化导致 hash 变化。
4951
sort.Slice(exams, func(i, j int) bool {
5052
left, right := courseExamIdentity(exams[i]), courseExamIdentity(exams[j])
5153
if left != right {
@@ -71,6 +73,7 @@ type courseExamChange struct {
7173
}
7274

7375
func buildCourseExamChanges(term string, oldExams, newExams []CourseExamInfo) []courseExamChange {
76+
// 按课程身份比较新旧考试时间,支持考试新增、时间变更和考试信息清除。
7477
oldByIdentity := make(map[string]CourseExamInfo, len(oldExams))
7578
for _, exam := range oldExams {
7679
oldByIdentity[courseExamIdentity(exam)] = exam
@@ -102,13 +105,15 @@ func buildCourseExamChanges(term string, oldExams, newExams []CourseExamInfo) []
102105
}
103106
changes = append(changes, newCourseExamChange(term, oldExam, newExam))
104107
}
108+
// map 遍历顺序不固定,排序后保证多条考试变化的处理顺序稳定。
105109
sort.Slice(changes, func(i, j int) bool {
106110
return changes[i].ExamHash < changes[j].ExamHash
107111
})
108112
return changes
109113
}
110114

111115
func newCourseExamChange(term string, oldExam, newExam CourseExamInfo) courseExamChange {
116+
// Hash 描述一次具体的考试状态迁移,用于跨用户全局去重。
112117
examHash := utils.SHA256(strings.Join([]string{
113118
newExam.Name,
114119
term,
@@ -125,7 +130,9 @@ func newCourseExamChange(term string, oldExam, newExam CourseExamInfo) courseExa
125130
}
126131

127132
func courseExamInfoHash(exams []CourseExamInfo) (string, error) {
133+
// 快照 hash 只反映考试内容,不依赖教务处返回的课程排列顺序。
128134
ordered := append([]CourseExamInfo(nil), exams...)
135+
// 对副本排序,保证相同考试内容始终生成相同的快照 hash。
129136
sort.Slice(ordered, func(i, j int) bool {
130137
left, right := courseExamIdentity(ordered[i]), courseExamIdentity(ordered[j])
131138
if left != right {

internal/course/service/get_course_list.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ func (s *CourseService) putCourseToDatabase(stuId string, term string, courses [
174174
}
175175

176176
func (s *CourseService) putExamToDatabase(stuId string, term string, rawCourses []*jwch.Course) error {
177+
// 考试通知采用“先抢占全局去重记录,再异步发送,成功后提交快照”的顺序。
178+
// 发送或快照提交失败时释放去重记录,确保后续刷新仍然可以重试。
177179
exams := buildCourseExamInfo(rawCourses)
178180
examInfo, err := utils.JSONEncode(exams)
179181
if err != nil {
@@ -205,21 +207,27 @@ func (s *CourseService) putExamToDatabase(stuId string, term string, rawCourses
205207
}
206208
}
207209
if old.ExamInfoSHA256 == "" {
210+
// 历史数据没有考试快照时只建立基线,不把已有考试信息当作新增变化通知。
208211
return s.updateExamSnapshot(old.Id, examInfo, examInfoSHA256)
209212
}
210213

211214
changes := buildCourseExamChanges(term, oldExams, exams)
212215
if len(changes) == 0 {
216+
// 内容没有实际变化时只更新快照,避免重复进入全局去重和推送流程。
213217
return s.updateExamSnapshot(old.Id, examInfo, examInfoSHA256)
214218
}
215219

216220
claimed := make([]courseExamChange, 0, len(changes))
217221
for _, change := range changes {
222+
// CreateExamOffering 依赖 exam_hash 唯一索引原子抢占发送资格。
223+
// 返回 nil 表示其他用户已经处理过相同变化,本次不再重复推送。
218224
offering, createErr := s.db.Course.CreateExamOffering(s.ctx, &model.ExamOffering{
219225
ExamHash: change.ExamHash,
220226
Tag: change.Tag,
221227
})
222228
if createErr != nil {
229+
// 本批次后续无法继续抢占时,释放已经抢到的去重记录。
230+
// 否则这些考试变化虽然没有完成通知流程,却会被全局去重表永久拦截。
223231
return errors.Join(createErr, s.releaseExamOfferings(claimed))
224232
}
225233
if offering != nil {
@@ -228,17 +236,26 @@ func (s *CourseService) putExamToDatabase(stuId string, term string, rawCourses
228236
}
229237

230238
if len(claimed) == 0 {
239+
// 所有变化都已被其他任务去重,本次只同步本地快照,不重复发送通知。
231240
return s.updateExamSnapshot(old.Id, examInfo, examInfoSHA256)
232241
}
242+
// 去重记录已经抢占成功,但通知和快照更新放入同一个异步任务。
243+
// 只有两步都成功,exam_offerings 记录才会继续保留。
233244
if !umeng.EnqueueAsync(func() error {
234245
if err := s.sendExamNotifications(claimed); err != nil {
246+
// 推送失败时释放去重记录;考试快照也不会更新,后续刷新仍能识别到这次变化。
247+
// 这样可以允许后续课程刷新重新抢占并重试通知。
235248
return errors.Join(err, s.releaseExamOfferings(claimed))
236249
}
237250
if err := s.updateExamSnapshot(old.Id, examInfo, examInfoSHA256); err != nil {
251+
// 快照更新失败时释放去重记录,避免通知状态未完整落库却被永久去重。
252+
// 下次刷新会再次发现变化,并重新执行通知流程。
238253
return errors.Join(err, s.releaseExamOfferings(claimed))
239254
}
240255
return nil
241256
}) {
257+
// 队列已满时任务没有进入异步流程,当前请求不会再发送这些通知。
258+
// 因此需要释放已抢到的去重记录,避免这次未发送的变化无法重试。
242259
return errors.Join(
243260
errno.NewErrNo(errno.InternalQueueErrorCode, "service.putExamToDatabase: exam notification queue is full"),
244261
s.releaseExamOfferings(claimed),
@@ -248,6 +265,7 @@ func (s *CourseService) putExamToDatabase(stuId string, term string, rawCourses
248265
}
249266

250267
func (s *CourseService) updateExamSnapshot(id int64, examInfo, examInfoSHA256 string) error {
268+
// 快照更新是本次考试变化处理的提交步骤;成功后下一次刷新不会再次识别同一变化。
251269
_, err := s.db.Course.UpdateUserTermCourse(s.ctx, &model.UserCourse{
252270
Id: id,
253271
ExamInfo: examInfo,
@@ -257,8 +275,13 @@ func (s *CourseService) updateExamSnapshot(id int64, examInfo, examInfoSHA256 st
257275
}
258276

259277
func (s *CourseService) releaseExamOfferings(changes []courseExamChange) error {
278+
// exam_offerings 既是全局去重记录,也是本次通知流程的占位状态。
279+
// 仅在发送或快照提交失败时调用,释放后下一次刷新可以重新抢占并重试。
260280
var releaseErr error
261281
for _, change := range changes {
282+
// exam_offerings 记录表示本次变化已经被某个刷新任务抢占。
283+
// 只有通知发送和考试快照更新都成功后才应保留;失败时删除该记录,
284+
// 让后续刷新可以重新抢占,避免考试变化被错误地永久去重。
262285
if err := s.db.Course.DeleteExamOfferingByHash(s.ctx, change.ExamHash); err != nil {
263286
releaseErr = errors.Join(releaseErr, err)
264287
}
@@ -267,6 +290,7 @@ func (s *CourseService) releaseExamOfferings(changes []courseExamChange) error {
267290
}
268291

269292
func (s *CourseService) sendExamNotifications(changes []courseExamChange) error {
293+
// 同一批变化同时发送 Android 和 iOS;记录首个错误,但继续处理剩余通知。
270294
var firstErr error
271295
for _, change := range changes {
272296
title := fmt.Sprintf("%v考试信息更新啦", change.Exam.Name)

pkg/db/course/exam_offering.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
)
2929

3030
func (c *DBCourse) GetExamOfferingByHash(ctx context.Context, examHash string) (*model.ExamOffering, error) {
31+
// 查询全局考试变化是否已经被其他刷新任务占用。
3132
offering := new(model.ExamOffering)
3233
if err := c.client.WithContext(ctx).
3334
Table(constants.ExamOfferingsTableName).
@@ -42,6 +43,7 @@ func (c *DBCourse) GetExamOfferingByHash(ctx context.Context, examHash string) (
4243
}
4344

4445
func (c *DBCourse) CreateExamOffering(ctx context.Context, offering *model.ExamOffering) (*model.ExamOffering, error) {
46+
// 依靠 exam_hash 唯一索引原子抢占发送资格;重复键表示已经被全局去重。
4547
if err := c.client.WithContext(ctx).
4648
Table(constants.ExamOfferingsTableName).
4749
Create(offering).Error; err != nil {
@@ -54,6 +56,7 @@ func (c *DBCourse) CreateExamOffering(ctx context.Context, offering *model.ExamO
5456
}
5557

5658
func (c *DBCourse) DeleteExamOfferingByHash(ctx context.Context, examHash string) error {
59+
// 释放失败通知的全局去重记录,使用硬删除确保后续可以再次插入相同 hash。
5760
if err := c.client.WithContext(ctx).
5861
Table(constants.ExamOfferingsTableName).
5962
Where("exam_hash = ?", examHash).

0 commit comments

Comments
 (0)