Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions config/sql/init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
149 changes: 149 additions & 0 deletions internal/course/service/exam_snapshot.go
Original file line number Diff line number Diff line change
@@ -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
}
199 changes: 199 additions & 0 deletions internal/course/service/exam_snapshot_test.go
Original file line number Diff line number Diff line change
@@ -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: "数据结构"},
})
})
}
}
Loading
Loading