From e8ab86ed97682c07b57d82e0368281f130b79041 Mon Sep 17 00:00:00 2001 From: CutebreadCat Date: Wed, 22 Jul 2026 19:08:28 +0800 Subject: [PATCH 1/9] feat:add exam notice --- config/sql/init.sql | 13 ++ internal/course/service/exam_snapshot.go | 142 ++++++++++++++ internal/course/service/exam_snapshot_test.go | 121 ++++++++++++ internal/course/service/get_course_list.go | 123 +++++++++++- .../course/service/get_course_list_test.go | 101 ++++++++++ pkg/constants/db.go | 1 + pkg/db/course/create_course_test.go | 2 + pkg/db/course/exam_offering.go | 65 +++++++ pkg/db/course/exam_offering_test.go | 178 ++++++++++++++++++ pkg/db/course/get_course.go | 2 +- pkg/db/course/get_course_test.go | 6 +- pkg/db/course/update_course_test.go | 4 + pkg/db/model/course.go | 11 ++ 13 files changed, 764 insertions(+), 5 deletions(-) create mode 100644 internal/course/service/exam_snapshot.go create mode 100644 internal/course/service/exam_snapshot_test.go create mode 100644 pkg/db/course/exam_offering.go create mode 100644 pkg/db/course/exam_offering_test.go diff --git a/config/sql/init.sql b/config/sql/init.sql index 82f11c1d7..a4cec2857 100644 --- a/config/sql/init.sql +++ b/config/sql/init.sql @@ -51,6 +51,17 @@ CREATE TABLE `fzu-helper`.`course_offerings` ( UNIQUE INDEX `uniq_course_hash` (`course_hash`) ) ENGINE=InnoDB CHARSET=utf8mb4; +CREATE TABLE `fzu-helper`.`exam_offerings` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `exam_hash` CHAR(64) NOT NULL COMMENT '通过课程和新旧考试信息生成的唯一hash', + `tag` VARCHAR(32) NOT NULL COMMENT '考试/考场通知使用的友盟tag', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE INDEX `uniq_exam_hash` (`exam_hash`) +) ENGINE=InnoDB CHARSET=utf8mb4; + create table `fzu-helper`.`launch_screen`( `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID', `url` tinytext NULL COMMENT '图片url', @@ -80,6 +91,8 @@ CREATE TABLE `fzu-helper`.`course`( `term` varchar(16) NOT NULL COMMENT '学期', `term_courses` json NOT NULL COMMENT '学期课程信息', `term_courses_sha256` varchar(64) NOT NULL COMMENT '学期课程信息SHA256', + `exam_info` json NULL COMMENT '我的选课页面考试信息', + `exam_info_sha256` varchar(64) NULL COMMENT '考试信息SHA256', `created_at` timestamp NOT NULL DEFAULT current_timestamp, `updated_at` timestamp NOT NULL DEFAULT current_timestamp ON UPDATE current_timestamp, `deleted_at` timestamp NULL DEFAULT NULL, diff --git a/internal/course/service/exam_snapshot.go b/internal/course/service/exam_snapshot.go new file mode 100644 index 000000000..0c059833e --- /dev/null +++ b/internal/course/service/exam_snapshot.go @@ -0,0 +1,142 @@ +/* +Copyright 2024 The west2-online Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package service + +import ( + "sort" + "strings" + + "github.com/west2-online/fzuhelper-server/pkg/utils" + "github.com/west2-online/jwch" +) + +// CourseExamInfo stores the internal exam snapshot extracted from a course. +type CourseExamInfo struct { + Name string `json:"name"` + Teacher string `json:"teacher"` + Credit string `json:"credit"` + ExamTime string `json:"exam_time"` +} + +func buildCourseExamInfo(courses []*jwch.Course) []CourseExamInfo { + exams := make([]CourseExamInfo, 0, len(courses)) + for _, course := range courses { + if course == nil || strings.TrimSpace(course.RawExamTime) == "" { + continue + } + exams = append(exams, CourseExamInfo{ + Name: course.Name, + Teacher: course.Teacher, + Credit: course.Credits, + ExamTime: strings.TrimSpace(course.RawExamTime), + }) + } + + sort.Slice(exams, func(i, j int) bool { + left, right := courseExamIdentity(exams[i]), courseExamIdentity(exams[j]) + if left != right { + return left < right + } + return exams[i].ExamTime < exams[j].ExamTime + }) + return exams +} + +func courseExamIdentity(exam CourseExamInfo) string { + return strings.Join([]string{exam.Name, exam.Teacher, exam.Credit}, "|") +} + +func courseExamTag(exam CourseExamInfo) string { + return utils.MD5(courseExamIdentity(exam)) +} + +type courseExamChange struct { + ExamHash string + Tag string + Exam CourseExamInfo +} + +func buildCourseExamChanges(term string, oldExams, newExams []CourseExamInfo) []courseExamChange { + oldByIdentity := make(map[string]CourseExamInfo, len(oldExams)) + for _, exam := range oldExams { + oldByIdentity[courseExamIdentity(exam)] = exam + } + + newByIdentity := make(map[string]CourseExamInfo, len(newExams)) + for _, exam := range newExams { + newByIdentity[courseExamIdentity(exam)] = exam + } + + identities := make(map[string]struct{}, len(oldByIdentity)+len(newByIdentity)) + for identity := range oldByIdentity { + identities[identity] = struct{}{} + } + for identity := range newByIdentity { + identities[identity] = struct{}{} + } + + changes := make([]courseExamChange, 0) + for identity := range identities { + oldExam := oldByIdentity[identity] + newExam := newByIdentity[identity] + if oldExam.ExamTime == newExam.ExamTime { + continue + } + if newExam.Name == "" { + newExam = oldExam + newExam.ExamTime = "" + } + changes = append(changes, newCourseExamChange(term, oldExam, newExam)) + } + sort.Slice(changes, func(i, j int) bool { + return changes[i].ExamHash < changes[j].ExamHash + }) + return changes +} + +func newCourseExamChange(term string, oldExam, newExam CourseExamInfo) courseExamChange { + examHash := utils.SHA256(strings.Join([]string{ + newExam.Name, + term, + newExam.Teacher, + newExam.Credit, + oldExam.ExamTime, + newExam.ExamTime, + }, "|")) + return courseExamChange{ + ExamHash: examHash, + Tag: courseExamTag(newExam), + Exam: newExam, + } +} + +func courseExamInfoHash(exams []CourseExamInfo) (string, error) { + ordered := append([]CourseExamInfo(nil), exams...) + sort.Slice(ordered, func(i, j int) bool { + left, right := courseExamIdentity(ordered[i]), courseExamIdentity(ordered[j]) + if left != right { + return left < right + } + return ordered[i].ExamTime < ordered[j].ExamTime + }) + + data, err := utils.JSONEncode(ordered) + if err != nil { + return "", err + } + return utils.SHA256(data), nil +} diff --git a/internal/course/service/exam_snapshot_test.go b/internal/course/service/exam_snapshot_test.go new file mode 100644 index 000000000..eb09c6156 --- /dev/null +++ b/internal/course/service/exam_snapshot_test.go @@ -0,0 +1,121 @@ +/* +Copyright 2024 The west2-online Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package service + +import ( + "testing" + + "github.com/bytedance/mockey" + "github.com/stretchr/testify/assert" + + "github.com/west2-online/fzuhelper-server/pkg/umeng" + "github.com/west2-online/fzuhelper-server/pkg/utils" + "github.com/west2-online/jwch" +) + +func TestBuildCourseExamInfo(t *testing.T) { + courses := []*jwch.Course{ + { + Name: "数据结构", + Teacher: "张老师", + Credits: "4.0", + RawExamTime: " 2026年6月20日 09:00-11:00 旗山校区 ", + }, + nil, + { + Name: "无考试课程", + RawExamTime: " ", + }, + { + Name: "高等数学", + Teacher: "李老师", + Credits: "5.0", + RawExamTime: "2026年6月21日 09:00-11:00", + }, + } + + result := buildCourseExamInfo(courses) + + assert.Equal(t, []CourseExamInfo{ + { + Name: "数据结构", + Teacher: "张老师", + Credit: "4.0", + ExamTime: "2026年6月20日 09:00-11:00 旗山校区", + }, + { + Name: "高等数学", + Teacher: "李老师", + Credit: "5.0", + ExamTime: "2026年6月21日 09:00-11:00", + }, + }, result) +} + +func TestCourseExamIdentityAndTag(t *testing.T) { + exam := CourseExamInfo{Name: "数据结构", Teacher: "张老师", Credit: "4.0"} + + assert.Equal(t, "数据结构|张老师|4.0", courseExamIdentity(exam)) + assert.Equal(t, utils.MD5("数据结构|张老师|4.0"), courseExamTag(exam)) +} + +func TestCourseExamInfoHashIsOrderIndependent(t *testing.T) { + first := []CourseExamInfo{ + {Name: "A", Teacher: "T", Credit: "1", ExamTime: "time-a"}, + {Name: "B", Teacher: "T", Credit: "2", ExamTime: "time-b"}, + } + second := []CourseExamInfo{first[1], first[0]} + + firstHash, err := courseExamInfoHash(first) + assert.NoError(t, err) + secondHash, err := courseExamInfoHash(second) + assert.NoError(t, err) + assert.Equal(t, firstHash, secondHash) +} + +func TestBuildCourseExamChanges(t *testing.T) { + oldExams := []CourseExamInfo{ + {Name: "数据结构", Teacher: "张老师", Credit: "4.0", ExamTime: "旧时间"}, + } + newExams := []CourseExamInfo{ + {Name: "数据结构", Teacher: "张老师", Credit: "4.0", ExamTime: "新时间"}, + } + + changes := buildCourseExamChanges("202401", oldExams, newExams) + + if assert.Len(t, changes, 1) { + assert.Equal(t, courseExamTag(newExams[0]), changes[0].Tag) + assert.Equal(t, utils.SHA256("数据结构|202401|张老师|4.0|旧时间|新时间"), changes[0].ExamHash) + } +} + +func TestCourseServiceSendExamNotifications(t *testing.T) { + defer mockey.UnPatchAll() + + androidErr := assert.AnError + mockey.Mock(umeng.SendAndroidGroupcastWithGoApp).Return(androidErr).Build() + mockey.Mock(umeng.SendIOSGroupcast).Return(nil).Build() + + err := new(CourseService).sendExamNotifications([]courseExamChange{ + { + Tag: utils.MD5("数据结构|张老师|4.0"), + Exam: CourseExamInfo{Name: "数据结构"}, + }, + }) + + assert.ErrorIs(t, err, androidErr) +} diff --git a/internal/course/service/get_course_list.go b/internal/course/service/get_course_list.go index f83d4f093..8f24ddfa4 100644 --- a/internal/course/service/get_course_list.go +++ b/internal/course/service/get_course_list.go @@ -35,6 +35,7 @@ import ( "github.com/west2-online/fzuhelper-server/pkg/db/model" "github.com/west2-online/fzuhelper-server/pkg/errno" "github.com/west2-online/fzuhelper-server/pkg/taskqueue" + "github.com/west2-online/fzuhelper-server/pkg/umeng" "github.com/west2-online/fzuhelper-server/pkg/utils" "github.com/west2-online/jwch" "github.com/west2-online/yjsy" @@ -90,7 +91,7 @@ func (s *CourseService) GetCourseList(req *course.CourseListRequest, loginData * // 数据库存储原始的课表信息(不包含调课信息) originalCourses := pack.BuildCourse(courses) s.taskQueue.Add(fmt.Sprintf("putCourse:%s", stuId), taskqueue.QueueTask{Execute: func() error { - return s.putCourseToDatabase(stuId, req.Term, originalCourses) + return s.putCourseAndExamToDatabase(stuId, req.Term, originalCourses, courses) }}) adjustCourses, err := s.GetAutoAdjustCourseList(req.Term) @@ -169,6 +170,126 @@ func (s *CourseService) putCourseToDatabase(stuId string, term string, courses [ return nil } +func (s *CourseService) putCourseAndExamToDatabase(stuId string, term string, courses []*kitexModel.Course, rawCourses []*jwch.Course) error { + if err := s.putCourseToDatabase(stuId, term, courses); err != nil { + return err + } + + exams := buildCourseExamInfo(rawCourses) + examInfo, err := utils.JSONEncode(exams) + if err != nil { + return errno.Errorf(errno.InternalJSONErrorCode, + "service.putCourseAndExamToDatabase: encode exam info failed: %v", err) + } + examInfoSHA256, err := courseExamInfoHash(exams) + if err != nil { + return errno.Errorf(errno.InternalJSONErrorCode, + "service.putCourseAndExamToDatabase: hash exam info failed: %v", err) + } + + old, err := s.db.Course.GetUserTermCourseByStuIdAndTerm(s.ctx, stuId, term) + if err != nil { + return err + } + if old == nil { + return nil + } + if old.ExamInfoSHA256 == examInfoSHA256 { + return nil + } + + var oldExams []CourseExamInfo + if old.ExamInfo != "" { + if err = sonic.Unmarshal([]byte(old.ExamInfo), &oldExams); err != nil { + return errno.Errorf(errno.InternalJSONErrorCode, + "service.putCourseAndExamToDatabase: decode exam info failed: %v", err) + } + } + if old.ExamInfoSHA256 == "" { + return s.updateExamSnapshot(old.Id, examInfo, examInfoSHA256) + } + + changes := buildCourseExamChanges(term, oldExams, exams) + if len(changes) == 0 { + return s.updateExamSnapshot(old.Id, examInfo, examInfoSHA256) + } + + claimed := make([]courseExamChange, 0, len(changes)) + for _, change := range changes { + offering, createErr := s.db.Course.CreateExamOffering(s.ctx, &model.ExamOffering{ + ExamHash: change.ExamHash, + Tag: change.Tag, + }) + if createErr != nil { + return errors.Join(createErr, s.releaseExamOfferings(claimed)) + } + if offering != nil { + claimed = append(claimed, change) + } + } + + if len(claimed) == 0 { + return s.updateExamSnapshot(old.Id, examInfo, examInfoSHA256) + } + if !umeng.EnqueueAsync(func() error { + if err := s.sendExamNotifications(claimed); err != nil { + return errors.Join(err, s.releaseExamOfferings(claimed)) + } + if err := s.updateExamSnapshot(old.Id, examInfo, examInfoSHA256); err != nil { + return errors.Join(err, s.releaseExamOfferings(claimed)) + } + return nil + }) { + return errors.Join( + errno.NewErrNo(errno.InternalQueueErrorCode, "service.putCourseAndExamToDatabase: exam notification queue is full"), + s.releaseExamOfferings(claimed), + ) + } + return nil +} + +func (s *CourseService) updateExamSnapshot(id int64, examInfo, examInfoSHA256 string) error { + _, err := s.db.Course.UpdateUserTermCourse(s.ctx, &model.UserCourse{ + Id: id, + ExamInfo: examInfo, + ExamInfoSHA256: examInfoSHA256, + }) + return err +} + +func (s *CourseService) releaseExamOfferings(changes []courseExamChange) error { + var releaseErr error + for _, change := range changes { + if err := s.db.Course.DeleteExamOfferingByHash(s.ctx, change.ExamHash); err != nil { + releaseErr = errors.Join(releaseErr, err) + } + } + return releaseErr +} + +func (s *CourseService) sendExamNotifications(changes []courseExamChange) error { + var firstErr error + for _, change := range changes { + title := fmt.Sprintf("%v考试信息更新啦", change.Exam.Name) + description := fmt.Sprintf("考试信息更新%v", change.Tag[:12]) + if err := umeng.SendAndroidGroupcastWithGoApp( + title, "", "", change.Tag, description, constants.UmengGradeDeeplink, + ); err != nil { + if firstErr == nil { + firstErr = err + } + } + if err := umeng.SendIOSGroupcast( + title, "", "", change.Tag, description, constants.UmengGradeDeeplink, + ); err != nil { + if firstErr == nil { + firstErr = err + } + } + } + return firstErr +} + func (s *CourseService) GetCourseListYjsy(req *course.CourseListRequest, loginData *kitexModel.LoginData) ([]*kitexModel.Course, error) { var err error diff --git a/internal/course/service/get_course_list_test.go b/internal/course/service/get_course_list_test.go index 9a36491e5..4f54a3a05 100644 --- a/internal/course/service/get_course_list_test.go +++ b/internal/course/service/get_course_list_test.go @@ -620,6 +620,107 @@ func TestCourseToDatabase(t *testing.T) { } } +func TestPutCourseAndExamToDatabase(t *testing.T) { + rawCourses := []*jwch.Course{ + { + Name: "数据结构", + Teacher: "张老师", + Credits: "4.0", + RawExamTime: "2026年6月20日 09:00-11:00 旗山校区", + }, + } + courses := pack.BuildCourse(rawCourses) + exams := buildCourseExamInfo(rawCourses) + examInfo, err := utils.JSONEncode(exams) + assert.NoError(t, err) + examInfoSHA256, err := courseExamInfoHash(exams) + assert.NoError(t, err) + coursesJSON, err := utils.JSONEncode(courses) + assert.NoError(t, err) + coursesSHA256 := utils.SHA256(coursesJSON) + + t.Run("exam snapshot is created after course snapshot", func(t *testing.T) { + defer mockey.UnPatchAll() + + mockClientSet := &base.ClientSet{ + SFClient: new(utils.Snowflake), + DBClient: new(db.Database), + CacheClient: new(cache.Cache), + } + mockey.Mock((*dbcourse.DBCourse).GetUserTermCourseSha256ByStuIdAndTerm). + Return(nil, nil).Build() + mockey.Mock((*utils.Snowflake).NextVal).Return(int64(1), nil).Build() + mockey.Mock((*dbcourse.DBCourse).CreateUserTermCourse).Return(nil, nil).Build() + mockey.Mock((*dbcourse.DBCourse).GetUserTermCourseByStuIdAndTerm). + Return(&dbmodel.UserCourse{Id: 1}, nil).Build() + mockey.Mock((*dbcourse.DBCourse).UpdateUserTermCourse).To( + func(_ context.Context, course *dbmodel.UserCourse) (*dbmodel.UserCourse, error) { + assert.Equal(t, int64(1), course.Id) + assert.Equal(t, examInfo, course.ExamInfo) + assert.Equal(t, examInfoSHA256, course.ExamInfoSHA256) + return course, nil + }, + ).Build() + + err := NewCourseService(context.Background(), mockClientSet, new(taskqueue.BaseTaskQueue)). + putCourseAndExamToDatabase("102301517", "202401", courses, rawCourses) + + assert.NoError(t, err) + }) + + t.Run("unchanged exam snapshot is not updated", func(t *testing.T) { + defer mockey.UnPatchAll() + + mockClientSet := &base.ClientSet{ + SFClient: new(utils.Snowflake), + DBClient: new(db.Database), + CacheClient: new(cache.Cache), + } + mockey.Mock((*dbcourse.DBCourse).GetUserTermCourseSha256ByStuIdAndTerm). + Return(&dbmodel.UserCourse{Id: 1, TermCoursesSha256: coursesSHA256}, nil).Build() + mockey.Mock((*dbcourse.DBCourse).GetUserTermCourseByStuIdAndTerm). + Return(&dbmodel.UserCourse{Id: 1, ExamInfoSHA256: examInfoSHA256}, nil).Build() + updateCalled := false + mockey.Mock((*dbcourse.DBCourse).UpdateUserTermCourse).To( + func(_ context.Context, _ *dbmodel.UserCourse) (*dbmodel.UserCourse, error) { + updateCalled = true + return nil, nil + }, + ).Build() + + err := NewCourseService(context.Background(), mockClientSet, new(taskqueue.BaseTaskQueue)). + putCourseAndExamToDatabase("102301517", "202401", courses, rawCourses) + + assert.NoError(t, err) + assert.False(t, updateCalled) + }) + + t.Run("course snapshot error stops exam snapshot update", func(t *testing.T) { + defer mockey.UnPatchAll() + + mockClientSet := &base.ClientSet{ + SFClient: new(utils.Snowflake), + DBClient: new(db.Database), + CacheClient: new(cache.Cache), + } + mockey.Mock((*dbcourse.DBCourse).GetUserTermCourseSha256ByStuIdAndTerm). + Return(nil, assert.AnError).Build() + getExamCalled := false + mockey.Mock((*dbcourse.DBCourse).GetUserTermCourseByStuIdAndTerm).To( + func(context.Context, string, string) (*dbmodel.UserCourse, error) { + getExamCalled = true + return nil, nil + }, + ).Build() + + err := NewCourseService(context.Background(), mockClientSet, new(taskqueue.BaseTaskQueue)). + putCourseAndExamToDatabase("102301517", "202401", courses, rawCourses) + + assert.Error(t, err) + assert.False(t, getExamCalled) + }) +} + func TestGetAdjustRules(t *testing.T) { type testCase struct { name string diff --git a/pkg/constants/db.go b/pkg/constants/db.go index 948e5c7f7..e50c25924 100644 --- a/pkg/constants/db.go +++ b/pkg/constants/db.go @@ -30,6 +30,7 @@ const ( UserTableName = "student" UserRelationTableName = "follow_relation" CourseTableName = "course" + ExamOfferingsTableName = "exam_offerings" TermTableName = "term" LaunchScreenTableName = "launch_screen" NoticeTableName = "notice" diff --git a/pkg/db/course/create_course_test.go b/pkg/db/course/create_course_test.go index c5bb95d02..af5ff1bc6 100644 --- a/pkg/db/course/create_course_test.go +++ b/pkg/db/course/create_course_test.go @@ -44,6 +44,8 @@ func TestDBCourse_CreateUserTermCourse(t *testing.T) { Term: "202401", TermCourses: `[{"courseId":"C123","courseName":"Math"}]`, TermCoursesSha256: "abc123def456", + ExamInfo: `[{"name":"Math","exam_time":"2026-06-20 09:00"}]`, + ExamInfoSHA256: "exam-sha256", } testCases := []testCase{ diff --git a/pkg/db/course/exam_offering.go b/pkg/db/course/exam_offering.go new file mode 100644 index 000000000..c6cba5952 --- /dev/null +++ b/pkg/db/course/exam_offering.go @@ -0,0 +1,65 @@ +/* +Copyright 2024 The west2-online Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package course + +import ( + "context" + "errors" + + "gorm.io/gorm" + + "github.com/west2-online/fzuhelper-server/pkg/constants" + "github.com/west2-online/fzuhelper-server/pkg/db/model" + "github.com/west2-online/fzuhelper-server/pkg/errno" +) + +func (c *DBCourse) GetExamOfferingByHash(ctx context.Context, examHash string) (*model.ExamOffering, error) { + offering := new(model.ExamOffering) + if err := c.client.WithContext(ctx). + Table(constants.ExamOfferingsTableName). + Where("exam_hash = ?", examHash). + First(offering).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, errno.Errorf(errno.InternalDatabaseErrorCode, "dal.GetExamOfferingByHash error: %v", err) + } + return offering, nil +} + +func (c *DBCourse) CreateExamOffering(ctx context.Context, offering *model.ExamOffering) (*model.ExamOffering, error) { + if err := c.client.WithContext(ctx). + Table(constants.ExamOfferingsTableName). + Create(offering).Error; err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return nil, nil + } + return nil, errno.Errorf(errno.InternalDatabaseErrorCode, "dal.CreateExamOffering error: %v", err) + } + return offering, nil +} + +func (c *DBCourse) DeleteExamOfferingByHash(ctx context.Context, examHash string) error { + if err := c.client.WithContext(ctx). + Table(constants.ExamOfferingsTableName). + Where("exam_hash = ?", examHash). + Unscoped(). + Delete(&model.ExamOffering{}).Error; err != nil { + return errno.Errorf(errno.InternalDatabaseErrorCode, "dal.DeleteExamOfferingByHash error: %v", err) + } + return nil +} diff --git a/pkg/db/course/exam_offering_test.go b/pkg/db/course/exam_offering_test.go new file mode 100644 index 000000000..6370b49de --- /dev/null +++ b/pkg/db/course/exam_offering_test.go @@ -0,0 +1,178 @@ +/* +Copyright 2024 The west2-online Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package course + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/bytedance/mockey" + "github.com/stretchr/testify/assert" + "gorm.io/gorm" + + "github.com/west2-online/fzuhelper-server/pkg/db/model" + "github.com/west2-online/fzuhelper-server/pkg/utils" +) + +func TestDBCourse_GetExamOfferingByHash(t *testing.T) { + t.Run("success", func(t *testing.T) { + defer mockey.UnPatchAll() + + expected := &model.ExamOffering{ + ID: 1, + ExamHash: "exam-hash", + Tag: "exam-tag", + } + mockDB := new(gorm.DB) + mockey.Mock((*gorm.DB).WithContext).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Table).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Where).Return(mockDB).Build() + mockey.Mock((*gorm.DB).First).To(func(dest interface{}, _ ...interface{}) *gorm.DB { + offering, ok := dest.(*model.ExamOffering) + if !ok { + return &gorm.DB{Error: errors.New("unexpected destination type")} + } + *offering = *expected + return mockDB + }).Build() + + result, err := NewDBCourse(mockDB, new(utils.Snowflake)). + GetExamOfferingByHash(context.Background(), expected.ExamHash) + + assert.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("not found", func(t *testing.T) { + defer mockey.UnPatchAll() + + mockDB := new(gorm.DB) + mockey.Mock((*gorm.DB).WithContext).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Table).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Where).Return(mockDB).Build() + mockey.Mock((*gorm.DB).First).Return(&gorm.DB{Error: gorm.ErrRecordNotFound}).Build() + + result, err := NewDBCourse(mockDB, new(utils.Snowflake)). + GetExamOfferingByHash(context.Background(), "missing") + + assert.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("database error", func(t *testing.T) { + defer mockey.UnPatchAll() + + mockDB := new(gorm.DB) + mockey.Mock((*gorm.DB).WithContext).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Table).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Where).Return(mockDB).Build() + mockey.Mock((*gorm.DB).First).Return(&gorm.DB{Error: errors.New("query failed")}).Build() + + result, err := NewDBCourse(mockDB, new(utils.Snowflake)). + GetExamOfferingByHash(context.Background(), "broken") + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "dal.GetExamOfferingByHash error") + }) +} + +func TestDBCourse_CreateExamOffering(t *testing.T) { + t.Run("success", func(t *testing.T) { + defer mockey.UnPatchAll() + + expected := &model.ExamOffering{ExamHash: "exam-hash", Tag: "exam-tag"} + mockDB := new(gorm.DB) + mockey.Mock((*gorm.DB).WithContext).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Table).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Create).Return(mockDB).Build() + + result, err := NewDBCourse(mockDB, new(utils.Snowflake)). + CreateExamOffering(context.Background(), expected) + + assert.NoError(t, err) + assert.Same(t, expected, result) + }) + + t.Run("database error", func(t *testing.T) { + defer mockey.UnPatchAll() + + mockDB := new(gorm.DB) + mockey.Mock((*gorm.DB).WithContext).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Table).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Create).Return(&gorm.DB{Error: fmt.Errorf("insert failed")}).Build() + + result, err := NewDBCourse(mockDB, new(utils.Snowflake)). + CreateExamOffering(context.Background(), &model.ExamOffering{}) + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "dal.CreateExamOffering error") + }) + + t.Run("duplicated", func(t *testing.T) { + defer mockey.UnPatchAll() + + mockDB := new(gorm.DB) + mockey.Mock((*gorm.DB).WithContext).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Table).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Create).Return(&gorm.DB{Error: gorm.ErrDuplicatedKey}).Build() + + result, err := NewDBCourse(mockDB, new(utils.Snowflake)). + CreateExamOffering(context.Background(), &model.ExamOffering{}) + + assert.NoError(t, err) + assert.Nil(t, result) + }) +} + +func TestDBCourse_DeleteExamOfferingByHash(t *testing.T) { + t.Run("success", func(t *testing.T) { + defer mockey.UnPatchAll() + + mockDB := new(gorm.DB) + mockey.Mock((*gorm.DB).WithContext).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Table).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Where).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Unscoped).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Delete).Return(mockDB).Build() + + err := NewDBCourse(mockDB, new(utils.Snowflake)). + DeleteExamOfferingByHash(context.Background(), "exam-hash") + + assert.NoError(t, err) + }) + + t.Run("database error", func(t *testing.T) { + defer mockey.UnPatchAll() + + mockDB := new(gorm.DB) + mockey.Mock((*gorm.DB).WithContext).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Table).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Where).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Unscoped).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Delete).Return(&gorm.DB{Error: errors.New("delete failed")}).Build() + + err := NewDBCourse(mockDB, new(utils.Snowflake)). + DeleteExamOfferingByHash(context.Background(), "exam-hash") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "dal.DeleteExamOfferingByHash error") + }) +} diff --git a/pkg/db/course/get_course.go b/pkg/db/course/get_course.go index 943f96db2..05103c74c 100644 --- a/pkg/db/course/get_course.go +++ b/pkg/db/course/get_course.go @@ -42,7 +42,7 @@ func (c *DBCourse) GetUserTermCourseSha256ByStuIdAndTerm(ctx context.Context, st userCourseModel := new(model.UserCourse) if err := c.client.WithContext(ctx). Table(constants.CourseTableName). - Select("id", "term_courses_sha256"). + Select("id", "term_courses_sha256", "exam_info_sha256"). Where("stu_id = ? and term = ?", stuId, term). First(userCourseModel).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/pkg/db/course/get_course_test.go b/pkg/db/course/get_course_test.go index 4a0551fcd..076c090d4 100644 --- a/pkg/db/course/get_course_test.go +++ b/pkg/db/course/get_course_test.go @@ -53,6 +53,8 @@ func TestDBCourse_GetUserTermCourseByStuIdAndTerm(t *testing.T) { Term: "202401", TermCourses: `[{"courseId":"C123","courseName":"Math"}]`, TermCoursesSha256: "abc123def456", + ExamInfo: `[{"name":"Math","exam_time":"2026-06-20 09:00"}]`, + ExamInfoSHA256: "exam-sha256", }, expectingError: false, }, @@ -145,10 +147,8 @@ func TestDBCourse_GetUserTermCourseSha256ByStuIdAndTerm(t *testing.T) { term: "202401", expectedResult: &model.UserCourse{ Id: 1001, - StuId: "222200311", - Term: "202401", - TermCourses: `[{"courseId":"C123","courseName":"Math"}]`, TermCoursesSha256: "abc123def456", + ExamInfoSHA256: "exam-sha256", }, expectingError: false, }, diff --git a/pkg/db/course/update_course_test.go b/pkg/db/course/update_course_test.go index b1b0381ef..e0db0d900 100644 --- a/pkg/db/course/update_course_test.go +++ b/pkg/db/course/update_course_test.go @@ -48,6 +48,8 @@ func TestDBCourse_UpdateUserTermCourse(t *testing.T) { Term: "202401", TermCourses: `[{"courseId":"C123","courseName":"Math"}]`, TermCoursesSha256: "abc123def456", + ExamInfo: `[{"name":"Math","exam_time":"2026-06-20 09:00"}]`, + ExamInfoSHA256: "exam-sha256", }, expectedResult: &model.UserCourse{ Id: 1001, @@ -55,6 +57,8 @@ func TestDBCourse_UpdateUserTermCourse(t *testing.T) { Term: "202401", TermCourses: `[{"courseId":"C123","courseName":"Math"}]`, TermCoursesSha256: "abc123def456", + ExamInfo: `[{"name":"Math","exam_time":"2026-06-20 09:00"}]`, + ExamInfoSHA256: "exam-sha256", }, expectingError: false, }, diff --git a/pkg/db/model/course.go b/pkg/db/model/course.go index 71f635854..963a530b2 100644 --- a/pkg/db/model/course.go +++ b/pkg/db/model/course.go @@ -28,11 +28,22 @@ type UserCourse struct { Term string TermCourses string TermCoursesSha256 string + ExamInfo string + ExamInfoSHA256 string CreatedAt time.Time UpdatedAt time.Time DeletedAt gorm.DeletedAt `sql:"index"` } +type ExamOffering struct { + ID int64 `json:"id"` + ExamHash string `json:"exam_hash"` + Tag string `json:"tag"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"deleted_at,omitempty"` +} + type UserTerm struct { Id int64 StuId string From be2811f19a4c4f15e902988ba8647c8c65b8337d Mon Sep 17 00:00:00 2001 From: CutebreadCat Date: Wed, 22 Jul 2026 21:49:56 +0800 Subject: [PATCH 2/9] feat:add exam notice --- internal/course/service/get_course_list.go | 19 ++++++------ .../course/service/get_course_list_test.go | 30 ++++--------------- 2 files changed, 15 insertions(+), 34 deletions(-) diff --git a/internal/course/service/get_course_list.go b/internal/course/service/get_course_list.go index 8f24ddfa4..73b7f8a36 100644 --- a/internal/course/service/get_course_list.go +++ b/internal/course/service/get_course_list.go @@ -91,7 +91,10 @@ func (s *CourseService) GetCourseList(req *course.CourseListRequest, loginData * // 数据库存储原始的课表信息(不包含调课信息) originalCourses := pack.BuildCourse(courses) s.taskQueue.Add(fmt.Sprintf("putCourse:%s", stuId), taskqueue.QueueTask{Execute: func() error { - return s.putCourseAndExamToDatabase(stuId, req.Term, originalCourses, courses) + if err := s.putCourseToDatabase(stuId, req.Term, originalCourses); err != nil { + return err + } + return s.putExamToDatabase(stuId, req.Term, courses) }}) adjustCourses, err := s.GetAutoAdjustCourseList(req.Term) @@ -170,21 +173,17 @@ func (s *CourseService) putCourseToDatabase(stuId string, term string, courses [ return nil } -func (s *CourseService) putCourseAndExamToDatabase(stuId string, term string, courses []*kitexModel.Course, rawCourses []*jwch.Course) error { - if err := s.putCourseToDatabase(stuId, term, courses); err != nil { - return err - } - +func (s *CourseService) putExamToDatabase(stuId string, term string, rawCourses []*jwch.Course) error { exams := buildCourseExamInfo(rawCourses) examInfo, err := utils.JSONEncode(exams) if err != nil { return errno.Errorf(errno.InternalJSONErrorCode, - "service.putCourseAndExamToDatabase: encode exam info failed: %v", err) + "service.putExamToDatabase: encode exam info failed: %v", err) } examInfoSHA256, err := courseExamInfoHash(exams) if err != nil { return errno.Errorf(errno.InternalJSONErrorCode, - "service.putCourseAndExamToDatabase: hash exam info failed: %v", err) + "service.putExamToDatabase: hash exam info failed: %v", err) } old, err := s.db.Course.GetUserTermCourseByStuIdAndTerm(s.ctx, stuId, term) @@ -202,7 +201,7 @@ func (s *CourseService) putCourseAndExamToDatabase(stuId string, term string, co if old.ExamInfo != "" { if err = sonic.Unmarshal([]byte(old.ExamInfo), &oldExams); err != nil { return errno.Errorf(errno.InternalJSONErrorCode, - "service.putCourseAndExamToDatabase: decode exam info failed: %v", err) + "service.putExamToDatabase: decode exam info failed: %v", err) } } if old.ExamInfoSHA256 == "" { @@ -241,7 +240,7 @@ func (s *CourseService) putCourseAndExamToDatabase(stuId string, term string, co return nil }) { return errors.Join( - errno.NewErrNo(errno.InternalQueueErrorCode, "service.putCourseAndExamToDatabase: exam notification queue is full"), + errno.NewErrNo(errno.InternalQueueErrorCode, "service.putExamToDatabase: exam notification queue is full"), s.releaseExamOfferings(claimed), ) } diff --git a/internal/course/service/get_course_list_test.go b/internal/course/service/get_course_list_test.go index 4f54a3a05..22ac4f6e9 100644 --- a/internal/course/service/get_course_list_test.go +++ b/internal/course/service/get_course_list_test.go @@ -620,7 +620,7 @@ func TestCourseToDatabase(t *testing.T) { } } -func TestPutCourseAndExamToDatabase(t *testing.T) { +func TestPutExamToDatabase(t *testing.T) { rawCourses := []*jwch.Course{ { Name: "数据结构", @@ -629,15 +629,11 @@ func TestPutCourseAndExamToDatabase(t *testing.T) { RawExamTime: "2026年6月20日 09:00-11:00 旗山校区", }, } - courses := pack.BuildCourse(rawCourses) exams := buildCourseExamInfo(rawCourses) examInfo, err := utils.JSONEncode(exams) assert.NoError(t, err) examInfoSHA256, err := courseExamInfoHash(exams) assert.NoError(t, err) - coursesJSON, err := utils.JSONEncode(courses) - assert.NoError(t, err) - coursesSHA256 := utils.SHA256(coursesJSON) t.Run("exam snapshot is created after course snapshot", func(t *testing.T) { defer mockey.UnPatchAll() @@ -647,10 +643,6 @@ func TestPutCourseAndExamToDatabase(t *testing.T) { DBClient: new(db.Database), CacheClient: new(cache.Cache), } - mockey.Mock((*dbcourse.DBCourse).GetUserTermCourseSha256ByStuIdAndTerm). - Return(nil, nil).Build() - mockey.Mock((*utils.Snowflake).NextVal).Return(int64(1), nil).Build() - mockey.Mock((*dbcourse.DBCourse).CreateUserTermCourse).Return(nil, nil).Build() mockey.Mock((*dbcourse.DBCourse).GetUserTermCourseByStuIdAndTerm). Return(&dbmodel.UserCourse{Id: 1}, nil).Build() mockey.Mock((*dbcourse.DBCourse).UpdateUserTermCourse).To( @@ -663,7 +655,7 @@ func TestPutCourseAndExamToDatabase(t *testing.T) { ).Build() err := NewCourseService(context.Background(), mockClientSet, new(taskqueue.BaseTaskQueue)). - putCourseAndExamToDatabase("102301517", "202401", courses, rawCourses) + putExamToDatabase("102301517", "202401", rawCourses) assert.NoError(t, err) }) @@ -676,8 +668,6 @@ func TestPutCourseAndExamToDatabase(t *testing.T) { DBClient: new(db.Database), CacheClient: new(cache.Cache), } - mockey.Mock((*dbcourse.DBCourse).GetUserTermCourseSha256ByStuIdAndTerm). - Return(&dbmodel.UserCourse{Id: 1, TermCoursesSha256: coursesSHA256}, nil).Build() mockey.Mock((*dbcourse.DBCourse).GetUserTermCourseByStuIdAndTerm). Return(&dbmodel.UserCourse{Id: 1, ExamInfoSHA256: examInfoSHA256}, nil).Build() updateCalled := false @@ -689,13 +679,13 @@ func TestPutCourseAndExamToDatabase(t *testing.T) { ).Build() err := NewCourseService(context.Background(), mockClientSet, new(taskqueue.BaseTaskQueue)). - putCourseAndExamToDatabase("102301517", "202401", courses, rawCourses) + putExamToDatabase("102301517", "202401", rawCourses) assert.NoError(t, err) assert.False(t, updateCalled) }) - t.Run("course snapshot error stops exam snapshot update", func(t *testing.T) { + t.Run("exam snapshot query error stops update", func(t *testing.T) { defer mockey.UnPatchAll() mockClientSet := &base.ClientSet{ @@ -703,21 +693,13 @@ func TestPutCourseAndExamToDatabase(t *testing.T) { DBClient: new(db.Database), CacheClient: new(cache.Cache), } - mockey.Mock((*dbcourse.DBCourse).GetUserTermCourseSha256ByStuIdAndTerm). + mockey.Mock((*dbcourse.DBCourse).GetUserTermCourseByStuIdAndTerm). Return(nil, assert.AnError).Build() - getExamCalled := false - mockey.Mock((*dbcourse.DBCourse).GetUserTermCourseByStuIdAndTerm).To( - func(context.Context, string, string) (*dbmodel.UserCourse, error) { - getExamCalled = true - return nil, nil - }, - ).Build() err := NewCourseService(context.Background(), mockClientSet, new(taskqueue.BaseTaskQueue)). - putCourseAndExamToDatabase("102301517", "202401", courses, rawCourses) + putExamToDatabase("102301517", "202401", rawCourses) assert.Error(t, err) - assert.False(t, getExamCalled) }) } From 00f418fafd7d95de5e099861ad1a1934ed2c8ead Mon Sep 17 00:00:00 2001 From: CutebreadCat Date: Wed, 22 Jul 2026 22:56:15 +0800 Subject: [PATCH 3/9] feat:add exam notice --- internal/course/service/exam_snapshot.go | 7 +++++++ internal/course/service/get_course_list.go | 24 ++++++++++++++++++++++ pkg/db/course/exam_offering.go | 3 +++ 3 files changed, 34 insertions(+) diff --git a/internal/course/service/exam_snapshot.go b/internal/course/service/exam_snapshot.go index 0c059833e..935e24c80 100644 --- a/internal/course/service/exam_snapshot.go +++ b/internal/course/service/exam_snapshot.go @@ -33,6 +33,7 @@ type CourseExamInfo struct { } func buildCourseExamInfo(courses []*jwch.Course) []CourseExamInfo { + // 从选课接口结果中提取考试快照;没有考试时间的课程不参与考试通知。 exams := make([]CourseExamInfo, 0, len(courses)) for _, course := range courses { if course == nil || strings.TrimSpace(course.RawExamTime) == "" { @@ -46,6 +47,7 @@ func buildCourseExamInfo(courses []*jwch.Course) []CourseExamInfo { }) } + // 统一考试快照的顺序,避免教务处返回顺序变化导致 hash 变化。 sort.Slice(exams, func(i, j int) bool { left, right := courseExamIdentity(exams[i]), courseExamIdentity(exams[j]) if left != right { @@ -71,6 +73,7 @@ type courseExamChange struct { } func buildCourseExamChanges(term string, oldExams, newExams []CourseExamInfo) []courseExamChange { + // 按课程身份比较新旧考试时间,支持考试新增、时间变更和考试信息清除。 oldByIdentity := make(map[string]CourseExamInfo, len(oldExams)) for _, exam := range oldExams { oldByIdentity[courseExamIdentity(exam)] = exam @@ -102,6 +105,7 @@ func buildCourseExamChanges(term string, oldExams, newExams []CourseExamInfo) [] } changes = append(changes, newCourseExamChange(term, oldExam, newExam)) } + // map 遍历顺序不固定,排序后保证多条考试变化的处理顺序稳定。 sort.Slice(changes, func(i, j int) bool { return changes[i].ExamHash < changes[j].ExamHash }) @@ -109,6 +113,7 @@ func buildCourseExamChanges(term string, oldExams, newExams []CourseExamInfo) [] } func newCourseExamChange(term string, oldExam, newExam CourseExamInfo) courseExamChange { + // Hash 描述一次具体的考试状态迁移,用于跨用户全局去重。 examHash := utils.SHA256(strings.Join([]string{ newExam.Name, term, @@ -125,7 +130,9 @@ func newCourseExamChange(term string, oldExam, newExam CourseExamInfo) courseExa } func courseExamInfoHash(exams []CourseExamInfo) (string, error) { + // 快照 hash 只反映考试内容,不依赖教务处返回的课程排列顺序。 ordered := append([]CourseExamInfo(nil), exams...) + // 对副本排序,保证相同考试内容始终生成相同的快照 hash。 sort.Slice(ordered, func(i, j int) bool { left, right := courseExamIdentity(ordered[i]), courseExamIdentity(ordered[j]) if left != right { diff --git a/internal/course/service/get_course_list.go b/internal/course/service/get_course_list.go index 73b7f8a36..46aa351ae 100644 --- a/internal/course/service/get_course_list.go +++ b/internal/course/service/get_course_list.go @@ -174,6 +174,8 @@ func (s *CourseService) putCourseToDatabase(stuId string, term string, courses [ } func (s *CourseService) putExamToDatabase(stuId string, term string, rawCourses []*jwch.Course) error { + // 考试通知采用“先抢占全局去重记录,再异步发送,成功后提交快照”的顺序。 + // 发送或快照提交失败时释放去重记录,确保后续刷新仍然可以重试。 exams := buildCourseExamInfo(rawCourses) examInfo, err := utils.JSONEncode(exams) if err != nil { @@ -205,21 +207,27 @@ func (s *CourseService) putExamToDatabase(stuId string, term string, rawCourses } } if old.ExamInfoSHA256 == "" { + // 历史数据没有考试快照时只建立基线,不把已有考试信息当作新增变化通知。 return s.updateExamSnapshot(old.Id, examInfo, examInfoSHA256) } changes := buildCourseExamChanges(term, oldExams, exams) if len(changes) == 0 { + // 内容没有实际变化时只更新快照,避免重复进入全局去重和推送流程。 return s.updateExamSnapshot(old.Id, examInfo, examInfoSHA256) } claimed := make([]courseExamChange, 0, len(changes)) for _, change := range changes { + // CreateExamOffering 依赖 exam_hash 唯一索引原子抢占发送资格。 + // 返回 nil 表示其他用户已经处理过相同变化,本次不再重复推送。 offering, createErr := s.db.Course.CreateExamOffering(s.ctx, &model.ExamOffering{ ExamHash: change.ExamHash, Tag: change.Tag, }) if createErr != nil { + // 本批次后续无法继续抢占时,释放已经抢到的去重记录。 + // 否则这些考试变化虽然没有完成通知流程,却会被全局去重表永久拦截。 return errors.Join(createErr, s.releaseExamOfferings(claimed)) } if offering != nil { @@ -228,17 +236,26 @@ func (s *CourseService) putExamToDatabase(stuId string, term string, rawCourses } if len(claimed) == 0 { + // 所有变化都已被其他任务去重,本次只同步本地快照,不重复发送通知。 return s.updateExamSnapshot(old.Id, examInfo, examInfoSHA256) } + // 去重记录已经抢占成功,但通知和快照更新放入同一个异步任务。 + // 只有两步都成功,exam_offerings 记录才会继续保留。 if !umeng.EnqueueAsync(func() error { if err := s.sendExamNotifications(claimed); err != nil { + // 推送失败时释放去重记录;考试快照也不会更新,后续刷新仍能识别到这次变化。 + // 这样可以允许后续课程刷新重新抢占并重试通知。 return errors.Join(err, s.releaseExamOfferings(claimed)) } if err := s.updateExamSnapshot(old.Id, examInfo, examInfoSHA256); err != nil { + // 快照更新失败时释放去重记录,避免通知状态未完整落库却被永久去重。 + // 下次刷新会再次发现变化,并重新执行通知流程。 return errors.Join(err, s.releaseExamOfferings(claimed)) } return nil }) { + // 队列已满时任务没有进入异步流程,当前请求不会再发送这些通知。 + // 因此需要释放已抢到的去重记录,避免这次未发送的变化无法重试。 return errors.Join( errno.NewErrNo(errno.InternalQueueErrorCode, "service.putExamToDatabase: exam notification queue is full"), s.releaseExamOfferings(claimed), @@ -248,6 +265,7 @@ func (s *CourseService) putExamToDatabase(stuId string, term string, rawCourses } func (s *CourseService) updateExamSnapshot(id int64, examInfo, examInfoSHA256 string) error { + // 快照更新是本次考试变化处理的提交步骤;成功后下一次刷新不会再次识别同一变化。 _, err := s.db.Course.UpdateUserTermCourse(s.ctx, &model.UserCourse{ Id: id, ExamInfo: examInfo, @@ -257,8 +275,13 @@ func (s *CourseService) updateExamSnapshot(id int64, examInfo, examInfoSHA256 st } func (s *CourseService) releaseExamOfferings(changes []courseExamChange) error { + // exam_offerings 既是全局去重记录,也是本次通知流程的占位状态。 + // 仅在发送或快照提交失败时调用,释放后下一次刷新可以重新抢占并重试。 var releaseErr error for _, change := range changes { + // exam_offerings 记录表示本次变化已经被某个刷新任务抢占。 + // 只有通知发送和考试快照更新都成功后才应保留;失败时删除该记录, + // 让后续刷新可以重新抢占,避免考试变化被错误地永久去重。 if err := s.db.Course.DeleteExamOfferingByHash(s.ctx, change.ExamHash); err != nil { releaseErr = errors.Join(releaseErr, err) } @@ -267,6 +290,7 @@ func (s *CourseService) releaseExamOfferings(changes []courseExamChange) error { } func (s *CourseService) sendExamNotifications(changes []courseExamChange) error { + // 同一批变化同时发送 Android 和 iOS;记录首个错误,但继续处理剩余通知。 var firstErr error for _, change := range changes { title := fmt.Sprintf("%v考试信息更新啦", change.Exam.Name) diff --git a/pkg/db/course/exam_offering.go b/pkg/db/course/exam_offering.go index c6cba5952..b4b45fbd5 100644 --- a/pkg/db/course/exam_offering.go +++ b/pkg/db/course/exam_offering.go @@ -28,6 +28,7 @@ import ( ) func (c *DBCourse) GetExamOfferingByHash(ctx context.Context, examHash string) (*model.ExamOffering, error) { + // 查询全局考试变化是否已经被其他刷新任务占用。 offering := new(model.ExamOffering) if err := c.client.WithContext(ctx). Table(constants.ExamOfferingsTableName). @@ -42,6 +43,7 @@ func (c *DBCourse) GetExamOfferingByHash(ctx context.Context, examHash string) ( } func (c *DBCourse) CreateExamOffering(ctx context.Context, offering *model.ExamOffering) (*model.ExamOffering, error) { + // 依靠 exam_hash 唯一索引原子抢占发送资格;重复键表示已经被全局去重。 if err := c.client.WithContext(ctx). Table(constants.ExamOfferingsTableName). Create(offering).Error; err != nil { @@ -54,6 +56,7 @@ func (c *DBCourse) CreateExamOffering(ctx context.Context, offering *model.ExamO } func (c *DBCourse) DeleteExamOfferingByHash(ctx context.Context, examHash string) error { + // 释放失败通知的全局去重记录,使用硬删除确保后续可以再次插入相同 hash。 if err := c.client.WithContext(ctx). Table(constants.ExamOfferingsTableName). Where("exam_hash = ?", examHash). From b1ee6f797c5626e4892e5311d7ca4dd298635185 Mon Sep 17 00:00:00 2001 From: CutebreadCat Date: Mon, 27 Jul 2026 22:54:05 +0800 Subject: [PATCH 4/9] feat: add exam notice --- internal/course/service/exam_snapshot_test.go | 44 ++++++++++++++----- internal/course/service/get_course_list.go | 19 +++----- 2 files changed, 40 insertions(+), 23 deletions(-) diff --git a/internal/course/service/exam_snapshot_test.go b/internal/course/service/exam_snapshot_test.go index eb09c6156..15e106024 100644 --- a/internal/course/service/exam_snapshot_test.go +++ b/internal/course/service/exam_snapshot_test.go @@ -104,18 +104,40 @@ func TestBuildCourseExamChanges(t *testing.T) { } func TestCourseServiceSendExamNotifications(t *testing.T) { - defer mockey.UnPatchAll() - - androidErr := assert.AnError - mockey.Mock(umeng.SendAndroidGroupcastWithGoApp).Return(androidErr).Build() - mockey.Mock(umeng.SendIOSGroupcast).Return(nil).Build() - - err := new(CourseService).sendExamNotifications([]courseExamChange{ + tests := []struct { + name string + androidErr error + iosErr error + wantErr error + }{ + { + name: "AndroidErrorReturned", + androidErr: assert.AnError, + wantErr: assert.AnError, + }, { - Tag: utils.MD5("数据结构|张老师|4.0"), - Exam: CourseExamInfo{Name: "数据结构"}, + name: "IOSErrorIgnored", + iosErr: assert.AnError, }, - }) + } - assert.ErrorIs(t, err, androidErr) + for _, tt := range tests { + mockey.PatchConvey(tt.name, t, func() { + mockey.Mock(umeng.SendAndroidGroupcastWithGoApp).Return(tt.androidErr).Build() + mockey.Mock(umeng.SendIOSGroupcast).Return(tt.iosErr).Build() + + err := new(CourseService).sendExamNotifications([]courseExamChange{ + { + Tag: utils.MD5("数据结构|张老师|4.0"), + Exam: CourseExamInfo{Name: "数据结构"}, + }, + }) + + if tt.wantErr == nil { + assert.NoError(t, err) + return + } + assert.ErrorIs(t, err, tt.wantErr) + }) + } } diff --git a/internal/course/service/get_course_list.go b/internal/course/service/get_course_list.go index 46aa351ae..30addcc82 100644 --- a/internal/course/service/get_course_list.go +++ b/internal/course/service/get_course_list.go @@ -290,27 +290,22 @@ func (s *CourseService) releaseExamOfferings(changes []courseExamChange) error { } func (s *CourseService) sendExamNotifications(changes []courseExamChange) error { - // 同一批变化同时发送 Android 和 iOS;记录首个错误,但继续处理剩余通知。 - var firstErr error + // Android 发送失败会阻塞快照提交,便于后续刷新重试。 + // iOS 失败直接丢弃,避免 iOS 异常导致 Android 用户重复收到通知。 + var androidErr error for _, change := range changes { title := fmt.Sprintf("%v考试信息更新啦", change.Exam.Name) description := fmt.Sprintf("考试信息更新%v", change.Tag[:12]) if err := umeng.SendAndroidGroupcastWithGoApp( title, "", "", change.Tag, description, constants.UmengGradeDeeplink, ); err != nil { - if firstErr == nil { - firstErr = err - } - } - if err := umeng.SendIOSGroupcast( - title, "", "", change.Tag, description, constants.UmengGradeDeeplink, - ); err != nil { - if firstErr == nil { - firstErr = err + if androidErr == nil { + androidErr = err } } + _ = umeng.SendIOSGroupcast(title, "", "", change.Tag, description, constants.UmengGradeDeeplink) } - return firstErr + return androidErr } func (s *CourseService) GetCourseListYjsy(req *course.CourseListRequest, loginData *kitexModel.LoginData) ([]*kitexModel.Course, error) { From 71cc0f631d21037026db5f366878bc3f49f44a08 Mon Sep 17 00:00:00 2001 From: CutebreadCat Date: Wed, 29 Jul 2026 13:35:24 +0800 Subject: [PATCH 5/9] feat: add exam notice --- internal/course/service/exam_snapshot_test.go | 20 ++--- internal/course/service/get_course_list.go | 79 +++++-------------- .../course/service/get_course_list_test.go | 50 ++++++++++++ 3 files changed, 76 insertions(+), 73 deletions(-) diff --git a/internal/course/service/exam_snapshot_test.go b/internal/course/service/exam_snapshot_test.go index 15e106024..4ab51014b 100644 --- a/internal/course/service/exam_snapshot_test.go +++ b/internal/course/service/exam_snapshot_test.go @@ -103,17 +103,15 @@ func TestBuildCourseExamChanges(t *testing.T) { } } -func TestCourseServiceSendExamNotifications(t *testing.T) { +func TestCourseServiceSendExamNotification(t *testing.T) { tests := []struct { name string androidErr error iosErr error - wantErr error }{ { - name: "AndroidErrorReturned", + name: "AndroidErrorIgnored", androidErr: assert.AnError, - wantErr: assert.AnError, }, { name: "IOSErrorIgnored", @@ -126,18 +124,12 @@ func TestCourseServiceSendExamNotifications(t *testing.T) { mockey.Mock(umeng.SendAndroidGroupcastWithGoApp).Return(tt.androidErr).Build() mockey.Mock(umeng.SendIOSGroupcast).Return(tt.iosErr).Build() - err := new(CourseService).sendExamNotifications([]courseExamChange{ - { - Tag: utils.MD5("数据结构|张老师|4.0"), - Exam: CourseExamInfo{Name: "数据结构"}, - }, + err := new(CourseService).sendExamNotification(courseExamChange{ + Tag: utils.MD5("数据结构|张老师|4.0"), + Exam: CourseExamInfo{Name: "数据结构"}, }) - if tt.wantErr == nil { - assert.NoError(t, err) - return - } - assert.ErrorIs(t, err, tt.wantErr) + assert.NoError(t, err) }) } } diff --git a/internal/course/service/get_course_list.go b/internal/course/service/get_course_list.go index 30addcc82..9a2f84199 100644 --- a/internal/course/service/get_course_list.go +++ b/internal/course/service/get_course_list.go @@ -174,8 +174,8 @@ func (s *CourseService) putCourseToDatabase(stuId string, term string, courses [ } func (s *CourseService) putExamToDatabase(stuId string, term string, rawCourses []*jwch.Course) error { - // 考试通知采用“先抢占全局去重记录,再异步发送,成功后提交快照”的顺序。 - // 发送或快照提交失败时释放去重记录,确保后续刷新仍然可以重试。 + // 考试通知与成绩通知保持一致:先抢占全局去重记录,再按单个变化异步发送。 + // 推送失败和队列满都按尽力而为处理,不阻塞考试快照更新。 exams := buildCourseExamInfo(rawCourses) examInfo, err := utils.JSONEncode(exams) if err != nil { @@ -226,9 +226,7 @@ func (s *CourseService) putExamToDatabase(stuId string, term string, rawCourses Tag: change.Tag, }) if createErr != nil { - // 本批次后续无法继续抢占时,释放已经抢到的去重记录。 - // 否则这些考试变化虽然没有完成通知流程,却会被全局去重表永久拦截。 - return errors.Join(createErr, s.releaseExamOfferings(claimed)) + return createErr } if offering != nil { claimed = append(claimed, change) @@ -239,29 +237,15 @@ func (s *CourseService) putExamToDatabase(stuId string, term string, rawCourses // 所有变化都已被其他任务去重,本次只同步本地快照,不重复发送通知。 return s.updateExamSnapshot(old.Id, examInfo, examInfoSHA256) } - // 去重记录已经抢占成功,但通知和快照更新放入同一个异步任务。 - // 只有两步都成功,exam_offerings 记录才会继续保留。 - if !umeng.EnqueueAsync(func() error { - if err := s.sendExamNotifications(claimed); err != nil { - // 推送失败时释放去重记录;考试快照也不会更新,后续刷新仍能识别到这次变化。 - // 这样可以允许后续课程刷新重新抢占并重试通知。 - return errors.Join(err, s.releaseExamOfferings(claimed)) - } - if err := s.updateExamSnapshot(old.Id, examInfo, examInfoSHA256); err != nil { - // 快照更新失败时释放去重记录,避免通知状态未完整落库却被永久去重。 - // 下次刷新会再次发现变化,并重新执行通知流程。 - return errors.Join(err, s.releaseExamOfferings(claimed)) - } - return nil - }) { - // 队列已满时任务没有进入异步流程,当前请求不会再发送这些通知。 - // 因此需要释放已抢到的去重记录,避免这次未发送的变化无法重试。 - return errors.Join( - errno.NewErrNo(errno.InternalQueueErrorCode, "service.putExamToDatabase: exam notification queue is full"), - s.releaseExamOfferings(claimed), - ) + + for _, change := range claimed { + // 单个考试变化对应一个 dispatcher task,避免一批变化绕过 Umeng 限流。 + _ = umeng.EnqueueAsync(func() error { + return s.sendExamNotification(change) + }) } - return nil + + return s.updateExamSnapshot(old.Id, examInfo, examInfoSHA256) } func (s *CourseService) updateExamSnapshot(id int64, examInfo, examInfoSHA256 string) error { @@ -274,38 +258,15 @@ func (s *CourseService) updateExamSnapshot(id int64, examInfo, examInfoSHA256 st return err } -func (s *CourseService) releaseExamOfferings(changes []courseExamChange) error { - // exam_offerings 既是全局去重记录,也是本次通知流程的占位状态。 - // 仅在发送或快照提交失败时调用,释放后下一次刷新可以重新抢占并重试。 - var releaseErr error - for _, change := range changes { - // exam_offerings 记录表示本次变化已经被某个刷新任务抢占。 - // 只有通知发送和考试快照更新都成功后才应保留;失败时删除该记录, - // 让后续刷新可以重新抢占,避免考试变化被错误地永久去重。 - if err := s.db.Course.DeleteExamOfferingByHash(s.ctx, change.ExamHash); err != nil { - releaseErr = errors.Join(releaseErr, err) - } - } - return releaseErr -} - -func (s *CourseService) sendExamNotifications(changes []courseExamChange) error { - // Android 发送失败会阻塞快照提交,便于后续刷新重试。 - // iOS 失败直接丢弃,避免 iOS 异常导致 Android 用户重复收到通知。 - var androidErr error - for _, change := range changes { - title := fmt.Sprintf("%v考试信息更新啦", change.Exam.Name) - description := fmt.Sprintf("考试信息更新%v", change.Tag[:12]) - if err := umeng.SendAndroidGroupcastWithGoApp( - title, "", "", change.Tag, description, constants.UmengGradeDeeplink, - ); err != nil { - if androidErr == nil { - androidErr = err - } - } - _ = umeng.SendIOSGroupcast(title, "", "", change.Tag, description, constants.UmengGradeDeeplink) - } - return androidErr +func (s *CourseService) sendExamNotification(change courseExamChange) error { + // 与成绩通知一致,推送失败仅由 Umeng 任务队列统一记录,不影响业务快照。 + title := fmt.Sprintf("%v考试信息更新啦", change.Exam.Name) + description := fmt.Sprintf("考试信息更新%v", change.Tag[:12]) + _ = umeng.SendAndroidGroupcastWithGoApp( + title, "", "", change.Tag, description, constants.UmengGradeDeeplink, + ) + _ = umeng.SendIOSGroupcast(title, "", "", change.Tag, description, constants.UmengGradeDeeplink) + return nil } func (s *CourseService) GetCourseListYjsy(req *course.CourseListRequest, loginData *kitexModel.LoginData) ([]*kitexModel.Course, error) { diff --git a/internal/course/service/get_course_list_test.go b/internal/course/service/get_course_list_test.go index 22ac4f6e9..b030e4415 100644 --- a/internal/course/service/get_course_list_test.go +++ b/internal/course/service/get_course_list_test.go @@ -34,6 +34,7 @@ import ( dbcourse "github.com/west2-online/fzuhelper-server/pkg/db/course" dbmodel "github.com/west2-online/fzuhelper-server/pkg/db/model" "github.com/west2-online/fzuhelper-server/pkg/taskqueue" + "github.com/west2-online/fzuhelper-server/pkg/umeng" "github.com/west2-online/fzuhelper-server/pkg/utils" "github.com/west2-online/jwch" "github.com/west2-online/yjsy" @@ -701,6 +702,55 @@ func TestPutExamToDatabase(t *testing.T) { assert.Error(t, err) }) + + t.Run("changed exams are enqueued one by one", func(t *testing.T) { + defer mockey.UnPatchAll() + + mockClientSet := &base.ClientSet{ + SFClient: new(utils.Snowflake), + DBClient: new(db.Database), + CacheClient: new(cache.Cache), + } + changedRawCourses := []*jwch.Course{ + {Name: "数据结构", Teacher: "张老师", Credits: "4.0", RawExamTime: "新时间"}, + {Name: "高等数学", Teacher: "李老师", Credits: "5.0", RawExamTime: "新时间2"}, + } + oldExams := []CourseExamInfo{ + {Name: "数据结构", Teacher: "张老师", Credit: "4.0", ExamTime: "旧时间"}, + {Name: "高等数学", Teacher: "李老师", Credit: "5.0", ExamTime: "旧时间2"}, + } + oldExamInfo, err := utils.JSONEncode(oldExams) + assert.NoError(t, err) + + mockey.Mock((*dbcourse.DBCourse).GetUserTermCourseByStuIdAndTerm). + Return(&dbmodel.UserCourse{Id: 1, ExamInfo: oldExamInfo, ExamInfoSHA256: "old-sha256"}, nil).Build() + mockey.Mock((*dbcourse.DBCourse).CreateExamOffering). + To(func(_ context.Context, offering *dbmodel.ExamOffering) (*dbmodel.ExamOffering, error) { + return offering, nil + }).Build() + enqueueCount := 0 + mockey.Mock(umeng.EnqueueAsync).To(func(_ func() error) bool { + enqueueCount++ + return true + }).Build() + updateCalled := false + mockey.Mock((*dbcourse.DBCourse).UpdateUserTermCourse).To( + func(_ context.Context, course *dbmodel.UserCourse) (*dbmodel.UserCourse, error) { + updateCalled = true + assert.Equal(t, int64(1), course.Id) + assert.NotEmpty(t, course.ExamInfo) + assert.NotEmpty(t, course.ExamInfoSHA256) + return course, nil + }, + ).Build() + + err = NewCourseService(context.Background(), mockClientSet, new(taskqueue.BaseTaskQueue)). + putExamToDatabase("102301517", "202401", changedRawCourses) + + assert.NoError(t, err) + assert.Equal(t, 2, enqueueCount) + assert.True(t, updateCalled) + }) } func TestGetAdjustRules(t *testing.T) { From dadd8578e36266bec713482121328c18f173a8b4 Mon Sep 17 00:00:00 2001 From: CutebreadCat Date: Wed, 29 Jul 2026 16:58:30 +0800 Subject: [PATCH 6/9] feat: add exam notice --- internal/course/service/exam_snapshot_test.go | 14 ++++++++++++-- internal/course/service/get_course_list.go | 4 ++-- pkg/constants/umeng.go | 1 + 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/internal/course/service/exam_snapshot_test.go b/internal/course/service/exam_snapshot_test.go index 4ab51014b..0f308bfc5 100644 --- a/internal/course/service/exam_snapshot_test.go +++ b/internal/course/service/exam_snapshot_test.go @@ -121,8 +121,18 @@ func TestCourseServiceSendExamNotification(t *testing.T) { for _, tt := range tests { mockey.PatchConvey(tt.name, t, func() { - mockey.Mock(umeng.SendAndroidGroupcastWithGoApp).Return(tt.androidErr).Build() - mockey.Mock(umeng.SendIOSGroupcast).Return(tt.iosErr).Build() + mockey.Mock(umeng.SendAndroidGroupcastWithGoApp).To( + func(title, text, ticker, tag, description, deeplink string) error { + assert.Equal(t, "fzuhelper://exam-room", deeplink) + return tt.androidErr + }, + ).Build() + mockey.Mock(umeng.SendIOSGroupcast).To( + func(title, subtitle, body, tag, description, deeplink string) error { + assert.Equal(t, "fzuhelper://exam-room", deeplink) + return tt.iosErr + }, + ).Build() err := new(CourseService).sendExamNotification(courseExamChange{ Tag: utils.MD5("数据结构|张老师|4.0"), diff --git a/internal/course/service/get_course_list.go b/internal/course/service/get_course_list.go index 9a2f84199..d8beae443 100644 --- a/internal/course/service/get_course_list.go +++ b/internal/course/service/get_course_list.go @@ -263,9 +263,9 @@ func (s *CourseService) sendExamNotification(change courseExamChange) error { title := fmt.Sprintf("%v考试信息更新啦", change.Exam.Name) description := fmt.Sprintf("考试信息更新%v", change.Tag[:12]) _ = umeng.SendAndroidGroupcastWithGoApp( - title, "", "", change.Tag, description, constants.UmengGradeDeeplink, + title, "", "", change.Tag, description, constants.UmengExamRoomDeeplink, ) - _ = umeng.SendIOSGroupcast(title, "", "", change.Tag, description, constants.UmengGradeDeeplink) + _ = umeng.SendIOSGroupcast(title, "", "", change.Tag, description, constants.UmengExamRoomDeeplink) return nil } diff --git a/pkg/constants/umeng.go b/pkg/constants/umeng.go index d91e6a888..990a3294e 100644 --- a/pkg/constants/umeng.go +++ b/pkg/constants/umeng.go @@ -33,5 +33,6 @@ const ( const ( UmengGradeDeeplink = "fzuhelper://grade" // 成绩查询的deeplink + UmengExamRoomDeeplink = "fzuhelper://exam-room" // 考场查询的deeplink UmengJwchNoticeDeeplink = "fzuhelper://office_notice" // 教务处通知的deeplink ) From 92596ba81d0e6e4ef9d0b3938f3d0b90d1dd7295 Mon Sep 17 00:00:00 2001 From: CutebreadCat Date: Thu, 30 Jul 2026 15:37:44 +0800 Subject: [PATCH 7/9] feat: add exam notice --- internal/course/service/exam_snapshot_test.go | 164 +++++++---- .../course/service/get_course_list_test.go | 208 +++++++------- pkg/db/course/exam_offering_test.go | 271 +++++++++--------- 3 files changed, 345 insertions(+), 298 deletions(-) diff --git a/internal/course/service/exam_snapshot_test.go b/internal/course/service/exam_snapshot_test.go index 0f308bfc5..ad626289e 100644 --- a/internal/course/service/exam_snapshot_test.go +++ b/internal/course/service/exam_snapshot_test.go @@ -28,78 +28,134 @@ import ( ) func TestBuildCourseExamInfo(t *testing.T) { - courses := []*jwch.Course{ - { - Name: "数据结构", - Teacher: "张老师", - Credits: "4.0", - RawExamTime: " 2026年6月20日 09:00-11:00 旗山校区 ", - }, - nil, - { - Name: "无考试课程", - RawExamTime: " ", - }, + testCases := []struct { + name string + courses []*jwch.Course + expected []CourseExamInfo + }{ { - Name: "高等数学", - Teacher: "李老师", - Credits: "5.0", - RawExamTime: "2026年6月21日 09:00-11:00", + name: "build exam info and ignore invalid courses", + courses: []*jwch.Course{ + { + Name: "数据结构", + Teacher: "张老师", + Credits: "4.0", + RawExamTime: " 2026年6月20日 09:00-11:00 旗山校区 ", + }, + nil, + { + Name: "无考试课程", + RawExamTime: " ", + }, + { + Name: "高等数学", + Teacher: "李老师", + Credits: "5.0", + RawExamTime: "2026年6月21日 09:00-11:00", + }, + }, + expected: []CourseExamInfo{ + { + Name: "数据结构", + Teacher: "张老师", + Credit: "4.0", + ExamTime: "2026年6月20日 09:00-11:00 旗山校区", + }, + { + Name: "高等数学", + Teacher: "李老师", + Credit: "5.0", + ExamTime: "2026年6月21日 09:00-11:00", + }, + }, }, } - result := buildCourseExamInfo(courses) - - assert.Equal(t, []CourseExamInfo{ - { - Name: "数据结构", - Teacher: "张老师", - Credit: "4.0", - ExamTime: "2026年6月20日 09:00-11:00 旗山校区", - }, - { - Name: "高等数学", - Teacher: "李老师", - Credit: "5.0", - ExamTime: "2026年6月21日 09:00-11:00", - }, - }, result) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, buildCourseExamInfo(tc.courses)) + }) + } } func TestCourseExamIdentityAndTag(t *testing.T) { - exam := CourseExamInfo{Name: "数据结构", Teacher: "张老师", Credit: "4.0"} + testCases := []struct { + name string + exam CourseExamInfo + identity string + }{ + { + name: "course exam identity", + exam: CourseExamInfo{Name: "数据结构", Teacher: "张老师", Credit: "4.0"}, + identity: "数据结构|张老师|4.0", + }, + } - assert.Equal(t, "数据结构|张老师|4.0", courseExamIdentity(exam)) - assert.Equal(t, utils.MD5("数据结构|张老师|4.0"), courseExamTag(exam)) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.identity, courseExamIdentity(tc.exam)) + assert.Equal(t, utils.MD5(tc.identity), courseExamTag(tc.exam)) + }) + } } func TestCourseExamInfoHashIsOrderIndependent(t *testing.T) { - first := []CourseExamInfo{ - {Name: "A", Teacher: "T", Credit: "1", ExamTime: "time-a"}, - {Name: "B", Teacher: "T", Credit: "2", ExamTime: "time-b"}, + testCases := []struct { + name string + first []CourseExamInfo + second []CourseExamInfo + }{ + { + name: "exam order does not affect hash", + first: []CourseExamInfo{ + {Name: "A", Teacher: "T", Credit: "1", ExamTime: "time-a"}, + {Name: "B", Teacher: "T", Credit: "2", ExamTime: "time-b"}, + }, + second: []CourseExamInfo{ + {Name: "B", Teacher: "T", Credit: "2", ExamTime: "time-b"}, + {Name: "A", Teacher: "T", Credit: "1", ExamTime: "time-a"}, + }, + }, } - second := []CourseExamInfo{first[1], first[0]} - firstHash, err := courseExamInfoHash(first) - assert.NoError(t, err) - secondHash, err := courseExamInfoHash(second) - assert.NoError(t, err) - assert.Equal(t, firstHash, secondHash) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + firstHash, err := courseExamInfoHash(tc.first) + assert.NoError(t, err) + secondHash, err := courseExamInfoHash(tc.second) + assert.NoError(t, err) + assert.Equal(t, firstHash, secondHash) + }) + } } func TestBuildCourseExamChanges(t *testing.T) { - oldExams := []CourseExamInfo{ - {Name: "数据结构", Teacher: "张老师", Credit: "4.0", ExamTime: "旧时间"}, - } - newExams := []CourseExamInfo{ - {Name: "数据结构", Teacher: "张老师", Credit: "4.0", ExamTime: "新时间"}, + testCases := []struct { + name string + term string + oldExams []CourseExamInfo + newExams []CourseExamInfo + }{ + { + name: "exam time changed", + term: "202401", + oldExams: []CourseExamInfo{ + {Name: "数据结构", Teacher: "张老师", Credit: "4.0", ExamTime: "旧时间"}, + }, + newExams: []CourseExamInfo{ + {Name: "数据结构", Teacher: "张老师", Credit: "4.0", ExamTime: "新时间"}, + }, + }, } - changes := buildCourseExamChanges("202401", oldExams, newExams) - - if assert.Len(t, changes, 1) { - assert.Equal(t, courseExamTag(newExams[0]), changes[0].Tag) - assert.Equal(t, utils.SHA256("数据结构|202401|张老师|4.0|旧时间|新时间"), changes[0].ExamHash) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + changes := buildCourseExamChanges(tc.term, tc.oldExams, tc.newExams) + if assert.Len(t, changes, 1) { + assert.Equal(t, courseExamTag(tc.newExams[0]), changes[0].Tag) + assert.Equal(t, utils.SHA256("数据结构|202401|张老师|4.0|旧时间|新时间"), changes[0].ExamHash) + } + }) } } diff --git a/internal/course/service/get_course_list_test.go b/internal/course/service/get_course_list_test.go index b030e4415..a9c623d9b 100644 --- a/internal/course/service/get_course_list_test.go +++ b/internal/course/service/get_course_list_test.go @@ -636,121 +636,109 @@ func TestPutExamToDatabase(t *testing.T) { examInfoSHA256, err := courseExamInfoHash(exams) assert.NoError(t, err) - t.Run("exam snapshot is created after course snapshot", func(t *testing.T) { - defer mockey.UnPatchAll() - - mockClientSet := &base.ClientSet{ - SFClient: new(utils.Snowflake), - DBClient: new(db.Database), - CacheClient: new(cache.Cache), - } - mockey.Mock((*dbcourse.DBCourse).GetUserTermCourseByStuIdAndTerm). - Return(&dbmodel.UserCourse{Id: 1}, nil).Build() - mockey.Mock((*dbcourse.DBCourse).UpdateUserTermCourse).To( - func(_ context.Context, course *dbmodel.UserCourse) (*dbmodel.UserCourse, error) { - assert.Equal(t, int64(1), course.Id) - assert.Equal(t, examInfo, course.ExamInfo) - assert.Equal(t, examInfoSHA256, course.ExamInfoSHA256) - return course, nil + type testCase struct { + name string + rawCourses []*jwch.Course + oldCourse *dbmodel.UserCourse + queryError error + expectError bool + expectUpdate bool + expectEnqueue int + expectExamInfo string + expectExamHash string + } + + testCases := []testCase{ + { + name: "exam snapshot is created after course snapshot", + oldCourse: &dbmodel.UserCourse{Id: 1}, + expectUpdate: true, + expectExamInfo: examInfo, + expectExamHash: examInfoSHA256, + }, + { + name: "unchanged exam snapshot is not updated", + oldCourse: &dbmodel.UserCourse{Id: 1, ExamInfoSHA256: examInfoSHA256}, + expectUpdate: false, + expectEnqueue: 0, + }, + { + name: "exam snapshot query error stops update", + queryError: assert.AnError, + expectError: true, + }, + { + name: "changed exams are enqueued one by one", + rawCourses: []*jwch.Course{ + {Name: "数据结构", Teacher: "张老师", Credits: "4.0", RawExamTime: "新时间"}, + {Name: "高等数学", Teacher: "李老师", Credits: "5.0", RawExamTime: "新时间2"}, }, - ).Build() - - err := NewCourseService(context.Background(), mockClientSet, new(taskqueue.BaseTaskQueue)). - putExamToDatabase("102301517", "202401", rawCourses) - - assert.NoError(t, err) - }) - - t.Run("unchanged exam snapshot is not updated", func(t *testing.T) { - defer mockey.UnPatchAll() - - mockClientSet := &base.ClientSet{ - SFClient: new(utils.Snowflake), - DBClient: new(db.Database), - CacheClient: new(cache.Cache), - } - mockey.Mock((*dbcourse.DBCourse).GetUserTermCourseByStuIdAndTerm). - Return(&dbmodel.UserCourse{Id: 1, ExamInfoSHA256: examInfoSHA256}, nil).Build() - updateCalled := false - mockey.Mock((*dbcourse.DBCourse).UpdateUserTermCourse).To( - func(_ context.Context, _ *dbmodel.UserCourse) (*dbmodel.UserCourse, error) { - updateCalled = true - return nil, nil + oldCourse: &dbmodel.UserCourse{ + Id: 1, + ExamInfo: `[{"name":"数据结构","teacher":"张老师","credit":"4.0","exam_time":"旧时间"},{"name":"高等数学","teacher":"李老师","credit":"5.0","exam_time":"旧时间2"}]`, + ExamInfoSHA256: "old-sha256", }, - ).Build() - - err := NewCourseService(context.Background(), mockClientSet, new(taskqueue.BaseTaskQueue)). - putExamToDatabase("102301517", "202401", rawCourses) - - assert.NoError(t, err) - assert.False(t, updateCalled) - }) - - t.Run("exam snapshot query error stops update", func(t *testing.T) { - defer mockey.UnPatchAll() - - mockClientSet := &base.ClientSet{ - SFClient: new(utils.Snowflake), - DBClient: new(db.Database), - CacheClient: new(cache.Cache), - } - mockey.Mock((*dbcourse.DBCourse).GetUserTermCourseByStuIdAndTerm). - Return(nil, assert.AnError).Build() - - err := NewCourseService(context.Background(), mockClientSet, new(taskqueue.BaseTaskQueue)). - putExamToDatabase("102301517", "202401", rawCourses) - - assert.Error(t, err) - }) - - t.Run("changed exams are enqueued one by one", func(t *testing.T) { - defer mockey.UnPatchAll() - - mockClientSet := &base.ClientSet{ - SFClient: new(utils.Snowflake), - DBClient: new(db.Database), - CacheClient: new(cache.Cache), - } - changedRawCourses := []*jwch.Course{ - {Name: "数据结构", Teacher: "张老师", Credits: "4.0", RawExamTime: "新时间"}, - {Name: "高等数学", Teacher: "李老师", Credits: "5.0", RawExamTime: "新时间2"}, - } - oldExams := []CourseExamInfo{ - {Name: "数据结构", Teacher: "张老师", Credit: "4.0", ExamTime: "旧时间"}, - {Name: "高等数学", Teacher: "李老师", Credit: "5.0", ExamTime: "旧时间2"}, - } - oldExamInfo, err := utils.JSONEncode(oldExams) - assert.NoError(t, err) - - mockey.Mock((*dbcourse.DBCourse).GetUserTermCourseByStuIdAndTerm). - Return(&dbmodel.UserCourse{Id: 1, ExamInfo: oldExamInfo, ExamInfoSHA256: "old-sha256"}, nil).Build() - mockey.Mock((*dbcourse.DBCourse).CreateExamOffering). - To(func(_ context.Context, offering *dbmodel.ExamOffering) (*dbmodel.ExamOffering, error) { - return offering, nil + expectUpdate: true, + expectEnqueue: 2, + }, + } + + for _, tc := range testCases { + mockey.PatchConvey(tc.name, t, func() { + defer mockey.UnPatchAll() + + mockClientSet := &base.ClientSet{ + SFClient: new(utils.Snowflake), + DBClient: new(db.Database), + CacheClient: new(cache.Cache), + } + + mockey.Mock((*dbcourse.DBCourse).GetUserTermCourseByStuIdAndTerm). + Return(tc.oldCourse, tc.queryError).Build() + mockey.Mock((*dbcourse.DBCourse).CreateExamOffering). + To(func(_ context.Context, offering *dbmodel.ExamOffering) (*dbmodel.ExamOffering, error) { + return offering, nil + }).Build() + var updatedCourse *dbmodel.UserCourse + mockey.Mock((*dbcourse.DBCourse).UpdateUserTermCourse). + To(func(_ context.Context, course *dbmodel.UserCourse) (*dbmodel.UserCourse, error) { + updatedCourse = course + return course, nil + }).Build() + enqueueCount := 0 + mockey.Mock(umeng.EnqueueAsync).To(func(_ func() error) bool { + enqueueCount++ + return true }).Build() - enqueueCount := 0 - mockey.Mock(umeng.EnqueueAsync).To(func(_ func() error) bool { - enqueueCount++ - return true - }).Build() - updateCalled := false - mockey.Mock((*dbcourse.DBCourse).UpdateUserTermCourse).To( - func(_ context.Context, course *dbmodel.UserCourse) (*dbmodel.UserCourse, error) { - updateCalled = true - assert.Equal(t, int64(1), course.Id) - assert.NotEmpty(t, course.ExamInfo) - assert.NotEmpty(t, course.ExamInfoSHA256) - return course, nil - }, - ).Build() - err = NewCourseService(context.Background(), mockClientSet, new(taskqueue.BaseTaskQueue)). - putExamToDatabase("102301517", "202401", changedRawCourses) + courses := rawCourses + if tc.rawCourses != nil { + courses = tc.rawCourses + } + err := NewCourseService(context.Background(), mockClientSet, new(taskqueue.BaseTaskQueue)). + putExamToDatabase("102301517", "202401", courses) - assert.NoError(t, err) - assert.Equal(t, 2, enqueueCount) - assert.True(t, updateCalled) - }) + if tc.expectError { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.expectEnqueue, enqueueCount) + if !tc.expectUpdate { + assert.Nil(t, updatedCourse) + return + } + assert.NotNil(t, updatedCourse) + assert.Equal(t, int64(1), updatedCourse.Id) + if tc.expectExamInfo != "" { + assert.Equal(t, tc.expectExamInfo, updatedCourse.ExamInfo) + assert.Equal(t, tc.expectExamHash, updatedCourse.ExamInfoSHA256) + } else { + assert.NotEmpty(t, updatedCourse.ExamInfo) + assert.NotEmpty(t, updatedCourse.ExamInfoSHA256) + } + }) + } } func TestGetAdjustRules(t *testing.T) { diff --git a/pkg/db/course/exam_offering_test.go b/pkg/db/course/exam_offering_test.go index 6370b49de..c7bee3c23 100644 --- a/pkg/db/course/exam_offering_test.go +++ b/pkg/db/course/exam_offering_test.go @@ -19,7 +19,6 @@ package course import ( "context" "errors" - "fmt" "testing" "github.com/bytedance/mockey" @@ -31,148 +30,152 @@ import ( ) func TestDBCourse_GetExamOfferingByHash(t *testing.T) { - t.Run("success", func(t *testing.T) { - defer mockey.UnPatchAll() - - expected := &model.ExamOffering{ - ID: 1, - ExamHash: "exam-hash", - Tag: "exam-tag", - } - mockDB := new(gorm.DB) - mockey.Mock((*gorm.DB).WithContext).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Table).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Where).Return(mockDB).Build() - mockey.Mock((*gorm.DB).First).To(func(dest interface{}, _ ...interface{}) *gorm.DB { - offering, ok := dest.(*model.ExamOffering) - if !ok { - return &gorm.DB{Error: errors.New("unexpected destination type")} + expected := &model.ExamOffering{ + ID: 1, + ExamHash: "exam-hash", + Tag: "exam-tag", + } + testCases := []struct { + name string + examHash string + firstResult *gorm.DB + expected *model.ExamOffering + expectError bool + expectMissing bool + }{ + { + name: "success", + examHash: expected.ExamHash, + expected: expected, + firstResult: new(gorm.DB), + }, + { + name: "not found", + examHash: "missing", + firstResult: &gorm.DB{Error: gorm.ErrRecordNotFound}, + expectMissing: true, + }, + { + name: "database error", + examHash: "broken", + firstResult: &gorm.DB{Error: errors.New("query failed")}, + expectError: true, + }, + } + + for _, tc := range testCases { + mockey.PatchConvey(tc.name, t, func() { + mockDB := new(gorm.DB) + mockey.Mock((*gorm.DB).WithContext).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Table).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Where).Return(mockDB).Build() + if tc.expected != nil { + mockey.Mock((*gorm.DB).First).To(func(dest interface{}, _ ...interface{}) *gorm.DB { + offering, ok := dest.(*model.ExamOffering) + if !ok { + return &gorm.DB{Error: errors.New("unexpected destination type")} + } + *offering = *tc.expected + return mockDB + }).Build() + } else { + mockey.Mock((*gorm.DB).First).Return(tc.firstResult).Build() } - *offering = *expected - return mockDB - }).Build() - result, err := NewDBCourse(mockDB, new(utils.Snowflake)). - GetExamOfferingByHash(context.Background(), expected.ExamHash) + result, err := NewDBCourse(mockDB, new(utils.Snowflake)). + GetExamOfferingByHash(context.Background(), tc.examHash) - assert.NoError(t, err) - assert.Equal(t, expected, result) - }) - - t.Run("not found", func(t *testing.T) { - defer mockey.UnPatchAll() - - mockDB := new(gorm.DB) - mockey.Mock((*gorm.DB).WithContext).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Table).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Where).Return(mockDB).Build() - mockey.Mock((*gorm.DB).First).Return(&gorm.DB{Error: gorm.ErrRecordNotFound}).Build() - - result, err := NewDBCourse(mockDB, new(utils.Snowflake)). - GetExamOfferingByHash(context.Background(), "missing") - - assert.NoError(t, err) - assert.Nil(t, result) - }) - - t.Run("database error", func(t *testing.T) { - defer mockey.UnPatchAll() - - mockDB := new(gorm.DB) - mockey.Mock((*gorm.DB).WithContext).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Table).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Where).Return(mockDB).Build() - mockey.Mock((*gorm.DB).First).Return(&gorm.DB{Error: errors.New("query failed")}).Build() - - result, err := NewDBCourse(mockDB, new(utils.Snowflake)). - GetExamOfferingByHash(context.Background(), "broken") - - assert.Error(t, err) - assert.Nil(t, result) - assert.Contains(t, err.Error(), "dal.GetExamOfferingByHash error") - }) + if tc.expectError { + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "dal.GetExamOfferingByHash error") + return + } + assert.NoError(t, err) + if tc.expectMissing { + assert.Nil(t, result) + return + } + assert.Equal(t, tc.expected, result) + }) + } } func TestDBCourse_CreateExamOffering(t *testing.T) { - t.Run("success", func(t *testing.T) { - defer mockey.UnPatchAll() - - expected := &model.ExamOffering{ExamHash: "exam-hash", Tag: "exam-tag"} - mockDB := new(gorm.DB) - mockey.Mock((*gorm.DB).WithContext).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Table).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Create).Return(mockDB).Build() - - result, err := NewDBCourse(mockDB, new(utils.Snowflake)). - CreateExamOffering(context.Background(), expected) - - assert.NoError(t, err) - assert.Same(t, expected, result) - }) - - t.Run("database error", func(t *testing.T) { - defer mockey.UnPatchAll() - - mockDB := new(gorm.DB) - mockey.Mock((*gorm.DB).WithContext).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Table).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Create).Return(&gorm.DB{Error: fmt.Errorf("insert failed")}).Build() - - result, err := NewDBCourse(mockDB, new(utils.Snowflake)). - CreateExamOffering(context.Background(), &model.ExamOffering{}) - - assert.Error(t, err) - assert.Nil(t, result) - assert.Contains(t, err.Error(), "dal.CreateExamOffering error") - }) - - t.Run("duplicated", func(t *testing.T) { - defer mockey.UnPatchAll() - - mockDB := new(gorm.DB) - mockey.Mock((*gorm.DB).WithContext).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Table).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Create).Return(&gorm.DB{Error: gorm.ErrDuplicatedKey}).Build() + testCases := []struct { + name string + createError error + expectError bool + expectNil bool + }{ + {name: "success"}, + {name: "database error", createError: errors.New("insert failed"), expectError: true}, + {name: "duplicated", createError: gorm.ErrDuplicatedKey, expectNil: true}, + } + + for _, tc := range testCases { + mockey.PatchConvey(tc.name, t, func() { + expected := &model.ExamOffering{ExamHash: "exam-hash", Tag: "exam-tag"} + mockDB := new(gorm.DB) + mockey.Mock((*gorm.DB).WithContext).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Table).Return(mockDB).Build() + if tc.createError == nil { + mockey.Mock((*gorm.DB).Create).Return(mockDB).Build() + } else { + mockey.Mock((*gorm.DB).Create).Return(&gorm.DB{Error: tc.createError}).Build() + } - result, err := NewDBCourse(mockDB, new(utils.Snowflake)). - CreateExamOffering(context.Background(), &model.ExamOffering{}) + result, err := NewDBCourse(mockDB, new(utils.Snowflake)). + CreateExamOffering(context.Background(), expected) - assert.NoError(t, err) - assert.Nil(t, result) - }) + if tc.expectError { + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "dal.CreateExamOffering error") + return + } + assert.NoError(t, err) + if tc.expectNil { + assert.Nil(t, result) + return + } + assert.Same(t, expected, result) + }) + } } func TestDBCourse_DeleteExamOfferingByHash(t *testing.T) { - t.Run("success", func(t *testing.T) { - defer mockey.UnPatchAll() - - mockDB := new(gorm.DB) - mockey.Mock((*gorm.DB).WithContext).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Table).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Where).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Unscoped).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Delete).Return(mockDB).Build() - - err := NewDBCourse(mockDB, new(utils.Snowflake)). - DeleteExamOfferingByHash(context.Background(), "exam-hash") - - assert.NoError(t, err) - }) - - t.Run("database error", func(t *testing.T) { - defer mockey.UnPatchAll() - - mockDB := new(gorm.DB) - mockey.Mock((*gorm.DB).WithContext).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Table).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Where).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Unscoped).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Delete).Return(&gorm.DB{Error: errors.New("delete failed")}).Build() - - err := NewDBCourse(mockDB, new(utils.Snowflake)). - DeleteExamOfferingByHash(context.Background(), "exam-hash") - - assert.Error(t, err) - assert.Contains(t, err.Error(), "dal.DeleteExamOfferingByHash error") - }) + testCases := []struct { + name string + deleteError error + expectError bool + }{ + {name: "success"}, + {name: "database error", deleteError: errors.New("delete failed"), expectError: true}, + } + + for _, tc := range testCases { + mockey.PatchConvey(tc.name, t, func() { + mockDB := new(gorm.DB) + mockey.Mock((*gorm.DB).WithContext).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Table).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Where).Return(mockDB).Build() + mockey.Mock((*gorm.DB).Unscoped).Return(mockDB).Build() + if tc.deleteError == nil { + mockey.Mock((*gorm.DB).Delete).Return(mockDB).Build() + } else { + mockey.Mock((*gorm.DB).Delete).Return(&gorm.DB{Error: tc.deleteError}).Build() + } + + err := NewDBCourse(mockDB, new(utils.Snowflake)). + DeleteExamOfferingByHash(context.Background(), "exam-hash") + + if tc.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), "dal.DeleteExamOfferingByHash error") + return + } + assert.NoError(t, err) + }) + } } From 0035701579fc2586712d9a71c1830f2b94597632 Mon Sep 17 00:00:00 2001 From: CutebreadCat Date: Fri, 31 Jul 2026 22:52:04 +0800 Subject: [PATCH 8/9] refactor: delete unuse function and test --- pkg/db/course/exam_offering.go | 27 ------- pkg/db/course/exam_offering_test.go | 108 ---------------------------- 2 files changed, 135 deletions(-) diff --git a/pkg/db/course/exam_offering.go b/pkg/db/course/exam_offering.go index b4b45fbd5..a91689712 100644 --- a/pkg/db/course/exam_offering.go +++ b/pkg/db/course/exam_offering.go @@ -27,21 +27,6 @@ import ( "github.com/west2-online/fzuhelper-server/pkg/errno" ) -func (c *DBCourse) GetExamOfferingByHash(ctx context.Context, examHash string) (*model.ExamOffering, error) { - // 查询全局考试变化是否已经被其他刷新任务占用。 - offering := new(model.ExamOffering) - if err := c.client.WithContext(ctx). - Table(constants.ExamOfferingsTableName). - Where("exam_hash = ?", examHash). - First(offering).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - return nil, errno.Errorf(errno.InternalDatabaseErrorCode, "dal.GetExamOfferingByHash error: %v", err) - } - return offering, nil -} - func (c *DBCourse) CreateExamOffering(ctx context.Context, offering *model.ExamOffering) (*model.ExamOffering, error) { // 依靠 exam_hash 唯一索引原子抢占发送资格;重复键表示已经被全局去重。 if err := c.client.WithContext(ctx). @@ -54,15 +39,3 @@ func (c *DBCourse) CreateExamOffering(ctx context.Context, offering *model.ExamO } return offering, nil } - -func (c *DBCourse) DeleteExamOfferingByHash(ctx context.Context, examHash string) error { - // 释放失败通知的全局去重记录,使用硬删除确保后续可以再次插入相同 hash。 - if err := c.client.WithContext(ctx). - Table(constants.ExamOfferingsTableName). - Where("exam_hash = ?", examHash). - Unscoped(). - Delete(&model.ExamOffering{}).Error; err != nil { - return errno.Errorf(errno.InternalDatabaseErrorCode, "dal.DeleteExamOfferingByHash error: %v", err) - } - return nil -} diff --git a/pkg/db/course/exam_offering_test.go b/pkg/db/course/exam_offering_test.go index c7bee3c23..5223cf88f 100644 --- a/pkg/db/course/exam_offering_test.go +++ b/pkg/db/course/exam_offering_test.go @@ -29,78 +29,6 @@ import ( "github.com/west2-online/fzuhelper-server/pkg/utils" ) -func TestDBCourse_GetExamOfferingByHash(t *testing.T) { - expected := &model.ExamOffering{ - ID: 1, - ExamHash: "exam-hash", - Tag: "exam-tag", - } - testCases := []struct { - name string - examHash string - firstResult *gorm.DB - expected *model.ExamOffering - expectError bool - expectMissing bool - }{ - { - name: "success", - examHash: expected.ExamHash, - expected: expected, - firstResult: new(gorm.DB), - }, - { - name: "not found", - examHash: "missing", - firstResult: &gorm.DB{Error: gorm.ErrRecordNotFound}, - expectMissing: true, - }, - { - name: "database error", - examHash: "broken", - firstResult: &gorm.DB{Error: errors.New("query failed")}, - expectError: true, - }, - } - - for _, tc := range testCases { - mockey.PatchConvey(tc.name, t, func() { - mockDB := new(gorm.DB) - mockey.Mock((*gorm.DB).WithContext).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Table).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Where).Return(mockDB).Build() - if tc.expected != nil { - mockey.Mock((*gorm.DB).First).To(func(dest interface{}, _ ...interface{}) *gorm.DB { - offering, ok := dest.(*model.ExamOffering) - if !ok { - return &gorm.DB{Error: errors.New("unexpected destination type")} - } - *offering = *tc.expected - return mockDB - }).Build() - } else { - mockey.Mock((*gorm.DB).First).Return(tc.firstResult).Build() - } - - result, err := NewDBCourse(mockDB, new(utils.Snowflake)). - GetExamOfferingByHash(context.Background(), tc.examHash) - - if tc.expectError { - assert.Error(t, err) - assert.Nil(t, result) - assert.Contains(t, err.Error(), "dal.GetExamOfferingByHash error") - return - } - assert.NoError(t, err) - if tc.expectMissing { - assert.Nil(t, result) - return - } - assert.Equal(t, tc.expected, result) - }) - } -} - func TestDBCourse_CreateExamOffering(t *testing.T) { testCases := []struct { name string @@ -143,39 +71,3 @@ func TestDBCourse_CreateExamOffering(t *testing.T) { }) } } - -func TestDBCourse_DeleteExamOfferingByHash(t *testing.T) { - testCases := []struct { - name string - deleteError error - expectError bool - }{ - {name: "success"}, - {name: "database error", deleteError: errors.New("delete failed"), expectError: true}, - } - - for _, tc := range testCases { - mockey.PatchConvey(tc.name, t, func() { - mockDB := new(gorm.DB) - mockey.Mock((*gorm.DB).WithContext).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Table).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Where).Return(mockDB).Build() - mockey.Mock((*gorm.DB).Unscoped).Return(mockDB).Build() - if tc.deleteError == nil { - mockey.Mock((*gorm.DB).Delete).Return(mockDB).Build() - } else { - mockey.Mock((*gorm.DB).Delete).Return(&gorm.DB{Error: tc.deleteError}).Build() - } - - err := NewDBCourse(mockDB, new(utils.Snowflake)). - DeleteExamOfferingByHash(context.Background(), "exam-hash") - - if tc.expectError { - assert.Error(t, err) - assert.Contains(t, err.Error(), "dal.DeleteExamOfferingByHash error") - return - } - assert.NoError(t, err) - }) - } -} From 011f7f2c82f61a4ef729abee98779e24cf910d33 Mon Sep 17 00:00:00 2001 From: CutebreadCat Date: Fri, 31 Jul 2026 23:08:46 +0800 Subject: [PATCH 9/9] fix:fix some uncorrect description --- internal/course/service/exam_snapshot_test.go | 4 +--- internal/course/service/get_course_list.go | 21 +++++++++++++------ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/internal/course/service/exam_snapshot_test.go b/internal/course/service/exam_snapshot_test.go index ad626289e..420c8f848 100644 --- a/internal/course/service/exam_snapshot_test.go +++ b/internal/course/service/exam_snapshot_test.go @@ -190,12 +190,10 @@ func TestCourseServiceSendExamNotification(t *testing.T) { }, ).Build() - err := new(CourseService).sendExamNotification(courseExamChange{ + new(CourseService).sendExamNotification(courseExamChange{ Tag: utils.MD5("数据结构|张老师|4.0"), Exam: CourseExamInfo{Name: "数据结构"}, }) - - assert.NoError(t, err) }) } } diff --git a/internal/course/service/get_course_list.go b/internal/course/service/get_course_list.go index d8beae443..1bd249668 100644 --- a/internal/course/service/get_course_list.go +++ b/internal/course/service/get_course_list.go @@ -34,6 +34,7 @@ import ( "github.com/west2-online/fzuhelper-server/pkg/constants" "github.com/west2-online/fzuhelper-server/pkg/db/model" "github.com/west2-online/fzuhelper-server/pkg/errno" + "github.com/west2-online/fzuhelper-server/pkg/logger" "github.com/west2-online/fzuhelper-server/pkg/taskqueue" "github.com/west2-online/fzuhelper-server/pkg/umeng" "github.com/west2-online/fzuhelper-server/pkg/utils" @@ -241,7 +242,8 @@ func (s *CourseService) putExamToDatabase(stuId string, term string, rawCourses for _, change := range claimed { // 单个考试变化对应一个 dispatcher task,避免一批变化绕过 Umeng 限流。 _ = umeng.EnqueueAsync(func() error { - return s.sendExamNotification(change) + s.sendExamNotification(change) + return nil }) } @@ -258,15 +260,22 @@ func (s *CourseService) updateExamSnapshot(id int64, examInfo, examInfoSHA256 st return err } -func (s *CourseService) sendExamNotification(change courseExamChange) error { +func (s *CourseService) sendExamNotification(change courseExamChange) { // 与成绩通知一致,推送失败仅由 Umeng 任务队列统一记录,不影响业务快照。 + // 这里直接不返回错误了,直接打印错误日志,因为就是安卓跟iOS都直推送一次,如果错过就直接算了 title := fmt.Sprintf("%v考试信息更新啦", change.Exam.Name) description := fmt.Sprintf("考试信息更新%v", change.Tag[:12]) - _ = umeng.SendAndroidGroupcastWithGoApp( + if err := umeng.SendAndroidGroupcastWithGoApp( title, "", "", change.Tag, description, constants.UmengExamRoomDeeplink, - ) - _ = umeng.SendIOSGroupcast(title, "", "", change.Tag, description, constants.UmengExamRoomDeeplink) - return nil + ); err != nil { + logger.Errorf("CourseService.sendExamNotification: send Android notification failed: %v", err) + } + + if err := umeng.SendIOSGroupcast( + title, "", "", change.Tag, description, constants.UmengExamRoomDeeplink, + ); err != nil { + logger.Errorf("CourseService.sendExamNotification: send iOS notification failed: %v", err) + } } func (s *CourseService) GetCourseListYjsy(req *course.CourseListRequest, loginData *kitexModel.LoginData) ([]*kitexModel.Course, error) {