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
51 changes: 51 additions & 0 deletions cmd/academic/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ limitations under the License.
package main

import (
"context"
"flag"
"fmt"
"time"

"github.com/cloudwego/kitex/pkg/limit"
"github.com/cloudwego/kitex/pkg/rpcinfo"
"github.com/cloudwego/kitex/server"
Expand All @@ -25,6 +30,7 @@ import (

"github.com/west2-online/fzuhelper-server/config"
"github.com/west2-online/fzuhelper-server/internal/academic"
"github.com/west2-online/fzuhelper-server/internal/academic/service"
"github.com/west2-online/fzuhelper-server/kitex_gen/academic/academicservice"
"github.com/west2-online/fzuhelper-server/pkg/base"
"github.com/west2-online/fzuhelper-server/pkg/constants"
Expand All @@ -37,6 +43,7 @@ var (
serviceName = constants.AcademicServiceName
clientSet *base.ClientSet
taskQueue taskqueue.TaskQueue
runTask = flag.String("run-task", "", "manually run a specific task and exit")
)

func init() {
Expand All @@ -47,6 +54,16 @@ func init() {
}

func main() {
flag.Parse()

if *runTask != "" {
if err := runManualTask(*runTask); err != nil {
logger.Fatalf("Academic: manual task %s failed: %v", *runTask, err)
}
logger.Infof("Academic: manual task %s completed successfully", *runTask)
return
}

r, err := etcd.NewEtcdRegistry([]string{config.Etcd.Addr})
if err != nil {
logger.Fatalf("Academic: etcd registry failed, error: %v", err)
Expand Down Expand Up @@ -75,8 +92,42 @@ func main() {
)
server.RegisterShutdownHook(clientSet.Close)

taskQueue.AddSchedule(constants.CourseTeacherScoresTaskKey, taskqueue.ScheduleQueueTask{
Execute: updateCourseTeacherScoresTask,
GetScheduleTime: func() time.Duration {
// 每天凌晨4点
now := time.Now()
next := time.Date(now.Year(), now.Month(), now.Day(), 4, 0, 0, 0, now.Location())
if !next.After(now) {
next = next.Add(constants.CourseTeacherScoresInterval)
}
return next.Sub(now)
},
})

taskQueue.Start()
if err = svr.Run(); err != nil {
logger.Fatalf("Academic: server run failed: %v", err)
}
}

func runManualTask(taskName string) error {
switch taskName {
case constants.CourseTeacherScoresTaskKey:
return updateCourseTeacherScoresTask()
default:
return fmt.Errorf("unknown task: %s", taskName)
}
}

func updateCourseTeacherScoresTask() error {
logger.Infof("Academic: update course teacher scores task start")
ctx := context.Background()
svc := service.NewAcademicService(ctx, clientSet, nil)
if err := svc.UpdateCourseTeacherScores(); err != nil {
logger.Errorf("Academic: update course teacher scores task failed: %v", err)
return err
}
logger.Infof("Academic: update course teacher scores task finished")
return nil
}
58 changes: 40 additions & 18 deletions config/sql/init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -28,27 +28,27 @@ create table `fzu-helper`.`term`
)engine=InnoDB default charset=utf8mb4;

CREATE TABLE `fzu-helper`.`scores` (
`stu_id` varchar(16) NOT NULL COMMENT '学生ID',
`scores_info` json NOT NULL COMMENT '学生成绩信息',
`scores_info_sha256` varchar(64) NOT 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,
PRIMARY KEY (`stu_id`)
`stu_id` varchar(16) NOT NULL COMMENT '学生ID',
`scores_info` json NOT NULL COMMENT '学生成绩信息',
`scores_info_sha256` varchar(64) NOT 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,
PRIMARY KEY (`stu_id`)
) ENGINE = InnoDB CHARSET = utf8mb4;

CREATE TABLE `fzu-helper`.`course_offerings` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(64) NOT NULL COMMENT '课程名',
`term` VARCHAR(16) NOT NULL COMMENT '学期',
`teacher` VARCHAR(255) NOT NULL COMMENT '教师全名',
`elective_type` VARCHAR(64) NOT NULL COMMENT '选修类型',
`course_hash` CHAR(64) NOT NULL COMMENT '通过name、term、teacher、elective_type生成的唯一hash',
`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_course_hash` (`course_hash`)
`id` BIGINT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(64) NOT NULL COMMENT '课程名',
`term` VARCHAR(16) NOT NULL COMMENT '学期',
`teacher` VARCHAR(255) NOT NULL COMMENT '教师全名',
`elective_type` VARCHAR(64) NOT NULL COMMENT '选修类型',
`course_hash` CHAR(64) NOT NULL COMMENT '通过name、term、teacher、elective_type生成的唯一hash',
`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_course_hash` (`course_hash`)
) ENGINE=InnoDB CHARSET=utf8mb4;

create table `fzu-helper`.`launch_screen`(
Expand Down Expand Up @@ -175,3 +175,25 @@ CREATE TABLE `fzu-helper`.`friend_config` (

INSERT INTO `fzu-helper`.`friend_config` (`config_key`, `value`, `student_id`)
VALUES ('max_num', '3', '');

CREATE TABLE `course_teacher_scores` (
`id` BIGINT NOT NULL COMMENT '雪花ID,由应用层生成',
`stu_id_sha256` VARCHAR(64) NOT NULL COMMENT 'SHA-256 哈希后的学生ID',
`course_name` VARCHAR(50) NOT NULL COMMENT '课程名称',
`elective_type` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '选修类型,从electiveType提取',
`teacher_name` VARCHAR(30) NOT NULL COMMENT '教师姓名',
`semester` CHAR(6) NOT NULL COMMENT '学期',
`score` DECIMAL(5,2) NOT NULL DEFAULT -10.00 COMMENT '数值化成绩,-10 表示未知或其他',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '记录最后更新时间',
`deleted_at` TIMESTAMP NULL DEFAULT NULL COMMENT '逻辑删除时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_record` (`course_name`, `teacher_name`, `semester`, `stu_id_sha256`),
KEY `idx_course` (`course_name`),
KEY `idx_teacher` (`teacher_name`),
KEY `idx_score` (`score`),
KEY `idx_course_teacher_sem_score` (`course_name`, `teacher_name`, `semester`, `score`)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_0900_ai_ci
COMMENT='展开自 scores.scores_info 的课程-教师-学期-成绩记录,主键由雪花生成';
3 changes: 3 additions & 0 deletions internal/academic/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@ import (
"github.com/west2-online/fzuhelper-server/pkg/cache"
"github.com/west2-online/fzuhelper-server/pkg/db"
"github.com/west2-online/fzuhelper-server/pkg/taskqueue"
"github.com/west2-online/fzuhelper-server/pkg/utils"
)

type AcademicService struct {
ctx context.Context
cache *cache.Cache
db *db.Database
sf *utils.Snowflake
taskQueue taskqueue.TaskQueue
}

Expand All @@ -37,6 +39,7 @@ func NewAcademicService(ctx context.Context, clientset *base.ClientSet, taskQueu
ctx: ctx,
cache: clientset.CacheClient,
db: clientset.DBClient,
sf: clientset.SFClient,
taskQueue: taskQueue,
}
}
143 changes: 143 additions & 0 deletions internal/academic/service/update_course_teacher_scores.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/*
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 (
"fmt"
"regexp"
"strconv"
"strings"

"github.com/bytedance/sonic"

"github.com/west2-online/fzuhelper-server/pkg/constants"
"github.com/west2-online/fzuhelper-server/pkg/db/model"
"github.com/west2-online/fzuhelper-server/pkg/logger"
"github.com/west2-online/fzuhelper-server/pkg/utils"
"github.com/west2-online/jwch"
)

var numericScoreRegexp = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`)

const (
InvalidScoreValue = -10.00 // 无法识别的成绩
AbsentScoreValue = -1.00 // 缺考
CheatingScoreValue = -2.00 // 作弊
ExcellentScoreValue = 90.00 // 优秀
GoodScoreValue = 80.00 // 良好
MediumScoreValue = 68.00 // 中等
PassScoreValue = 60.00 // 及格/合格
FailScoreValue = 0.00 // 不及格/不合格
)

func convertScore(raw string) float64 {
if numericScoreRegexp.MatchString(raw) {
v, err := strconv.ParseFloat(raw, 64)
if err == nil {
return v
}
}
switch raw {
case "优秀", "优":
return ExcellentScoreValue
case "良好", "良":
return GoodScoreValue
case "中等":
return MediumScoreValue
case "及格", "合格":
return PassScoreValue
case "不及格", "不合格":
return FailScoreValue
case "缺考":
return AbsentScoreValue
case "作弊":
return CheatingScoreValue
default:
return InvalidScoreValue
}
}

func (s *AcademicService) UpdateCourseTeacherScores() error {
lastStuId := ""
batchSize := constants.CourseTeacherScoresBatchReadSize

for {
scores, err := s.db.Academic.GetScoresBatchByStuId(s.ctx, lastStuId, batchSize)
if err != nil {
return fmt.Errorf("UpdateCourseTeacherScores: get scores batch error: %w", err)
}
if len(scores) == 0 {
logger.Infof("UpdateCourseTeacherScores: all records have been processed")
break
}

logger.Infof("UpdateCourseTeacherScores: processing batch, stu_id > %s, count=%d", lastStuId, len(scores))

var records []*model.CourseTeacherScore
for _, score := range scores {
stuIdSHA256 := utils.SHA256(score.StuID)

var marks []*jwch.Mark
if err = sonic.UnmarshalString(score.ScoresInfo, &marks); err != nil {
logger.Errorf("UpdateCourseTeacherScores: unmarshal scores_info for stu_id=%s error: %v", score.StuID, err)
continue
}

for _, mark := range marks {
teachers := splitTeachers(mark.Teacher)
numericScore := convertScore(mark.Score)

for _, teacher := range teachers {
id, err := s.sf.NextVal()
if err != nil {
return fmt.Errorf("UpdateCourseTeacherScores: generate snowflake id error: %w", err)
}
records = append(records, &model.CourseTeacherScore{
ID: id,
StuIdSHA256: stuIdSHA256,
CourseName: mark.Name,
ElectiveType: mark.ElectiveType,
TeacherName: teacher,
Semester: mark.Semester,
Score: numericScore,
})
}
}
}

if err = s.db.Academic.UpsertCourseTeacherScores(s.ctx, records); err != nil {
return fmt.Errorf("UpdateCourseTeacherScores: upsert batch error: %w", err)
}

lastStuId = scores[len(scores)-1].StuID
}

return nil
}

func splitTeachers(teacherList string) []string {
if strings.TrimSpace(teacherList) == "" {
return []string{""}
}
parts := strings.Split(teacherList, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
t := strings.TrimSpace(p)
result = append(result, t)
}
return result
}
27 changes: 14 additions & 13 deletions pkg/constants/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,20 @@ const (

// Table Name
const (
UserTableName = "student"
UserRelationTableName = "follow_relation"
CourseTableName = "course"
TermTableName = "term"
LaunchScreenTableName = "launch_screen"
NoticeTableName = "notice"
ScoreTableName = "scores"
VisitTableName = "visit"
CourseOfferingsTableName = "course_offerings"
ToolboxConfigTableName = "toolbox_config"
AdminSecretTableName = "admin_secrets"
FeedbackTableName = "feedback"
FriendConfigTableName = "friend_config"
UserTableName = "student"
UserRelationTableName = "follow_relation"
CourseTableName = "course"
TermTableName = "term"
LaunchScreenTableName = "launch_screen"
NoticeTableName = "notice"
ScoreTableName = "scores"
VisitTableName = "visit"
CourseOfferingsTableName = "course_offerings"
ToolboxConfigTableName = "toolbox_config"
AdminSecretTableName = "admin_secrets"
FeedbackTableName = "feedback"
FriendConfigTableName = "friend_config"
CourseTeacherScoresTableName = "course_teacher_scores"
)

// Biz
Expand Down
8 changes: 8 additions & 0 deletions pkg/constants/syncer.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,11 @@ const (
// "https://avatars.githubusercontent.com/u/%s
AvatarProxy = "https://fuu.api.baoshuo.dev/avatar/%s"
)

// course_teacher_scores 课程-教师-学生成绩排布定时任务
const (
CourseTeacherScoresTaskKey = "courseTeacherScoresTask"
CourseTeacherScoresInterval = 24 * time.Hour
CourseTeacherScoresBatchReadSize = 200
CourseTeacherScoresBatchUpsertSize = 1000
)
Loading