diff --git a/config/sql/init.sql b/config/sql/init.sql index 82f11c1d..a4cec285 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 00000000..935e24c8 --- /dev/null +++ b/internal/course/service/exam_snapshot.go @@ -0,0 +1,149 @@ +/* +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), + }) + } + + // 统一考试快照的顺序,避免教务处返回顺序变化导致 hash 变化。 + 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)) + } + // map 遍历顺序不固定,排序后保证多条考试变化的处理顺序稳定。 + 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 { + // Hash 描述一次具体的考试状态迁移,用于跨用户全局去重。 + 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) { + // 快照 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 { + 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 00000000..420c8f84 --- /dev/null +++ b/internal/course/service/exam_snapshot_test.go @@ -0,0 +1,199 @@ +/* +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) { + testCases := []struct { + name string + courses []*jwch.Course + expected []CourseExamInfo + }{ + { + 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", + }, + }, + }, + } + + 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) { + testCases := []struct { + name string + exam CourseExamInfo + identity string + }{ + { + name: "course exam identity", + exam: CourseExamInfo{Name: "数据结构", Teacher: "张老师", Credit: "4.0"}, + identity: "数据结构|张老师|4.0", + }, + } + + 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) { + 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"}, + }, + }, + } + + 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) { + 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: "新时间"}, + }, + }, + } + + 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) + } + }) + } +} + +func TestCourseServiceSendExamNotification(t *testing.T) { + tests := []struct { + name string + androidErr error + iosErr error + }{ + { + name: "AndroidErrorIgnored", + androidErr: assert.AnError, + }, + { + name: "IOSErrorIgnored", + iosErr: assert.AnError, + }, + } + + for _, tt := range tests { + mockey.PatchConvey(tt.name, t, func() { + 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() + + new(CourseService).sendExamNotification(courseExamChange{ + Tag: utils.MD5("数据结构|张老师|4.0"), + Exam: CourseExamInfo{Name: "数据结构"}, + }) + }) + } +} diff --git a/internal/course/service/get_course_list.go b/internal/course/service/get_course_list.go index f83d4f09..1bd24966 100644 --- a/internal/course/service/get_course_list.go +++ b/internal/course/service/get_course_list.go @@ -34,7 +34,9 @@ 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" "github.com/west2-online/jwch" "github.com/west2-online/yjsy" @@ -90,7 +92,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.putCourseToDatabase(stuId, req.Term, originalCourses) + 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) @@ -169,6 +174,110 @@ func (s *CourseService) putCourseToDatabase(stuId string, term string, courses [ return nil } +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.putExamToDatabase: encode exam info failed: %v", err) + } + examInfoSHA256, err := courseExamInfoHash(exams) + if err != nil { + return errno.Errorf(errno.InternalJSONErrorCode, + "service.putExamToDatabase: 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.putExamToDatabase: 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 { + // CreateExamOffering 依赖 exam_hash 唯一索引原子抢占发送资格。 + // 返回 nil 表示其他用户已经处理过相同变化,本次不再重复推送。 + offering, createErr := s.db.Course.CreateExamOffering(s.ctx, &model.ExamOffering{ + ExamHash: change.ExamHash, + Tag: change.Tag, + }) + if createErr != nil { + return createErr + } + if offering != nil { + claimed = append(claimed, change) + } + } + + if len(claimed) == 0 { + // 所有变化都已被其他任务去重,本次只同步本地快照,不重复发送通知。 + return s.updateExamSnapshot(old.Id, examInfo, examInfoSHA256) + } + + for _, change := range claimed { + // 单个考试变化对应一个 dispatcher task,避免一批变化绕过 Umeng 限流。 + _ = umeng.EnqueueAsync(func() error { + s.sendExamNotification(change) + return nil + }) + } + + return s.updateExamSnapshot(old.Id, examInfo, examInfoSHA256) +} + +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) sendExamNotification(change courseExamChange) { + // 与成绩通知一致,推送失败仅由 Umeng 任务队列统一记录,不影响业务快照。 + // 这里直接不返回错误了,直接打印错误日志,因为就是安卓跟iOS都直推送一次,如果错过就直接算了 + title := fmt.Sprintf("%v考试信息更新啦", change.Exam.Name) + description := fmt.Sprintf("考试信息更新%v", change.Tag[:12]) + if err := umeng.SendAndroidGroupcastWithGoApp( + title, "", "", change.Tag, description, constants.UmengExamRoomDeeplink, + ); 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) { var err error diff --git a/internal/course/service/get_course_list_test.go b/internal/course/service/get_course_list_test.go index 9a36491e..a9c623d9 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" @@ -620,6 +621,126 @@ func TestCourseToDatabase(t *testing.T) { } } +func TestPutExamToDatabase(t *testing.T) { + rawCourses := []*jwch.Course{ + { + Name: "数据结构", + Teacher: "张老师", + Credits: "4.0", + RawExamTime: "2026年6月20日 09:00-11:00 旗山校区", + }, + } + exams := buildCourseExamInfo(rawCourses) + examInfo, err := utils.JSONEncode(exams) + assert.NoError(t, err) + examInfoSHA256, err := courseExamInfoHash(exams) + assert.NoError(t, err) + + 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"}, + }, + oldCourse: &dbmodel.UserCourse{ + Id: 1, + ExamInfo: `[{"name":"数据结构","teacher":"张老师","credit":"4.0","exam_time":"旧时间"},{"name":"高等数学","teacher":"李老师","credit":"5.0","exam_time":"旧时间2"}]`, + ExamInfoSHA256: "old-sha256", + }, + 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() + + courses := rawCourses + if tc.rawCourses != nil { + courses = tc.rawCourses + } + err := NewCourseService(context.Background(), mockClientSet, new(taskqueue.BaseTaskQueue)). + putExamToDatabase("102301517", "202401", courses) + + 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) { type testCase struct { name string diff --git a/pkg/constants/db.go b/pkg/constants/db.go index 948e5c7f..e50c2592 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/constants/umeng.go b/pkg/constants/umeng.go index d91e6a88..990a3294 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 ) diff --git a/pkg/db/course/create_course_test.go b/pkg/db/course/create_course_test.go index c5bb95d0..af5ff1bc 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 00000000..a9168971 --- /dev/null +++ b/pkg/db/course/exam_offering.go @@ -0,0 +1,41 @@ +/* +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) 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 { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return nil, nil + } + return nil, errno.Errorf(errno.InternalDatabaseErrorCode, "dal.CreateExamOffering error: %v", err) + } + return offering, nil +} diff --git a/pkg/db/course/exam_offering_test.go b/pkg/db/course/exam_offering_test.go new file mode 100644 index 00000000..5223cf88 --- /dev/null +++ b/pkg/db/course/exam_offering_test.go @@ -0,0 +1,73 @@ +/* +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" + "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_CreateExamOffering(t *testing.T) { + 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(), expected) + + 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) + }) + } +} diff --git a/pkg/db/course/get_course.go b/pkg/db/course/get_course.go index 943f96db..05103c74 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 4a0551fc..076c090d 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 b1b0381e..e0db0d90 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 71f63585..963a530b 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