feat(api): implement exam and grade synchronization modules
Add new functionality for managing and synchronizing academic grades and exam schedules. This includes new models, repositories, services, and HTTP handlers, along with updated gRPC definitions and dependency injection configuration. Additionally, improve the reliability of unread message count tracking by implementing an atomic Lua script for Redis operations in the conversation cache.
This commit is contained in:
58
internal/cache/conversation_cache.go
vendored
58
internal/cache/conversation_cache.go
vendored
@@ -670,31 +670,51 @@ func (c *ConversationCache) IncrementUnread(ctx context.Context, userID, convID
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearUnread 清零用户在某会话的未读数
|
||||
// ClearUnread 清零用户在某会话的未读数(原子操作,防止与 IncrementUnread 竞态)
|
||||
func (c *ConversationCache) ClearUnread(ctx context.Context, userID, convID string) error {
|
||||
hashKey := UnreadHashKey(userID)
|
||||
totalKey := UnreadTotalKey(userID)
|
||||
|
||||
// 读取当前未读数
|
||||
currentStr, err := c.cache.HGet(ctx, hashKey, convID)
|
||||
redisCache, ok := c.cache.(*RedisCache)
|
||||
if !ok {
|
||||
// miniredis/内存模式:保持原逻辑(非原子,但无并发竞态风险)
|
||||
currentStr, err := c.cache.HGet(ctx, hashKey, convID)
|
||||
if err != nil {
|
||||
currentStr = "0"
|
||||
}
|
||||
current := int64(0)
|
||||
if v, parseErr := fmt.Sscanf(currentStr, "%d", ¤t); parseErr != nil || v != 1 {
|
||||
current = 0
|
||||
}
|
||||
if err := c.cache.HSet(ctx, hashKey, convID, "0"); err != nil {
|
||||
return fmt.Errorf("hset clear unread failed: %w", err)
|
||||
}
|
||||
if current > 0 {
|
||||
c.cache.IncrBySeq(ctx, totalKey, -current)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
rdb := redisCache.GetRedisClient().GetClient()
|
||||
nHashKey := normalizeKey(hashKey)
|
||||
nTotalKey := normalizeKey(totalKey)
|
||||
|
||||
script := redis.NewScript(`
|
||||
local current = tonumber(redis.call('HGET', KEYS[1], ARGV[1]))
|
||||
if current == nil or current <= 0 then
|
||||
return 0
|
||||
end
|
||||
redis.call('HSET', KEYS[1], ARGV[1], '0')
|
||||
local newTotal = tonumber(redis.call('INCRBY', KEYS[2], -current))
|
||||
if newTotal < 0 then
|
||||
redis.call('SET', KEYS[2], '0')
|
||||
end
|
||||
return current
|
||||
`)
|
||||
_, err := script.Run(ctx, rdb, []string{nHashKey, nTotalKey}, convID).Result()
|
||||
if err != nil {
|
||||
currentStr = "0"
|
||||
return fmt.Errorf("lua clear unread failed: %w", err)
|
||||
}
|
||||
current := int64(0)
|
||||
if v, parseErr := fmt.Sscanf(currentStr, "%d", ¤t); parseErr != nil || v != 1 {
|
||||
current = 0
|
||||
}
|
||||
|
||||
// 清零该会话未读数
|
||||
if err := c.cache.HSet(ctx, hashKey, convID, "0"); err != nil {
|
||||
return fmt.Errorf("hset clear unread failed: %w", err)
|
||||
}
|
||||
|
||||
// 减少总未读数
|
||||
if current > 0 {
|
||||
c.cache.IncrBySeq(ctx, totalKey, -current)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
78
internal/handler/exam_handler.go
Normal file
78
internal/handler/exam_handler.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/repository"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
type ExamHandler struct {
|
||||
examSyncService service.ExamSyncService
|
||||
examRepo repository.ExamRepository
|
||||
}
|
||||
|
||||
func NewExamHandler(examSyncService service.ExamSyncService, examRepo repository.ExamRepository) *ExamHandler {
|
||||
return &ExamHandler{
|
||||
examSyncService: examSyncService,
|
||||
examRepo: examRepo,
|
||||
}
|
||||
}
|
||||
|
||||
type syncExamsRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Semester string `json:"semester"`
|
||||
}
|
||||
|
||||
func (h *ExamHandler) SyncExams(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
var req syncExamsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.examSyncService.SyncUserExams(c.Request.Context(), userID, &service.SyncExamsRequest{
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
Semester: req.Semester,
|
||||
})
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "failed to sync exams")
|
||||
return
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
response.Success(c, result)
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, result.Message, result)
|
||||
}
|
||||
|
||||
func (h *ExamHandler) ListExams(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
semester := c.Query("semester")
|
||||
if semester == "" {
|
||||
response.BadRequest(c, "semester is required")
|
||||
return
|
||||
}
|
||||
|
||||
exams, err := h.examRepo.ListByUserAndSemester(userID, semester)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "failed to list exams")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"exams": exams})
|
||||
}
|
||||
83
internal/handler/grade_handler.go
Normal file
83
internal/handler/grade_handler.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/repository"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
type GradeHandler struct {
|
||||
gradeSyncService service.GradeSyncService
|
||||
gradeRepo repository.GradeRepository
|
||||
}
|
||||
|
||||
func NewGradeHandler(gradeSyncService service.GradeSyncService, gradeRepo repository.GradeRepository) *GradeHandler {
|
||||
return &GradeHandler{
|
||||
gradeSyncService: gradeSyncService,
|
||||
gradeRepo: gradeRepo,
|
||||
}
|
||||
}
|
||||
|
||||
type syncGradesRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Semester string `json:"semester"`
|
||||
}
|
||||
|
||||
func (h *GradeHandler) SyncGrades(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
var req syncGradesRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.gradeSyncService.SyncUserGrades(c.Request.Context(), userID, &service.SyncGradesRequest{
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
Semester: req.Semester,
|
||||
})
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "failed to sync grades")
|
||||
return
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
response.Success(c, result)
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, result.Message, result)
|
||||
}
|
||||
|
||||
func (h *GradeHandler) ListGrades(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
semester := c.Query("semester")
|
||||
if semester == "" {
|
||||
response.BadRequest(c, "semester is required")
|
||||
return
|
||||
}
|
||||
|
||||
grades, err := h.gradeRepo.ListByUserAndSemester(userID, semester)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "failed to list grades")
|
||||
return
|
||||
}
|
||||
|
||||
gpaSummary, _ := h.gradeRepo.GetGpaSummary(userID, semester)
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"grades": grades,
|
||||
"gpa_summary": gpaSummary,
|
||||
})
|
||||
}
|
||||
28
internal/model/exam.go
Normal file
28
internal/model/exam.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Exam struct {
|
||||
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
|
||||
UserID string `json:"user_id" gorm:"type:varchar(36);index;not null"`
|
||||
Semester string `json:"semester" gorm:"type:varchar(30);not null"`
|
||||
CourseName string `json:"course_name" gorm:"type:varchar(120);not null"`
|
||||
ExamType string `json:"exam_type" gorm:"type:varchar(30)"`
|
||||
Date string `json:"date" gorm:"type:varchar(20)"`
|
||||
Time string `json:"time" gorm:"type:varchar(30)"`
|
||||
Week string `json:"week" gorm:"type:varchar(20)"`
|
||||
Weekday string `json:"weekday" gorm:"type:varchar(10)"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (e *Exam) BeforeCreate(tx *gorm.DB) error {
|
||||
SetUUIDIfEmpty(&e.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (Exam) TableName() string { return "exams" }
|
||||
51
internal/model/grade.go
Normal file
51
internal/model/grade.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Grade struct {
|
||||
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
|
||||
UserID string `json:"user_id" gorm:"type:varchar(36);index;not null"`
|
||||
Term string `json:"term" gorm:"type:varchar(30);not null"`
|
||||
CourseCode string `json:"course_code" gorm:"type:varchar(30)"`
|
||||
CourseName string `json:"course_name" gorm:"type:varchar(120);not null"`
|
||||
Nature string `json:"nature" gorm:"type:varchar(30)"`
|
||||
Category string `json:"category" gorm:"type:varchar(30)"`
|
||||
Credit string `json:"credit" gorm:"type:varchar(10)"`
|
||||
Score string `json:"score" gorm:"type:varchar(20)"`
|
||||
GradePoint string `json:"grade_point" gorm:"type:varchar(10)"`
|
||||
IsExam string `json:"is_exam" gorm:"type:varchar(10)"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (g *Grade) BeforeCreate(tx *gorm.DB) error {
|
||||
SetUUIDIfEmpty(&g.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (Grade) TableName() string { return "grades" }
|
||||
|
||||
type GpaSummary struct {
|
||||
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
|
||||
UserID string `json:"user_id" gorm:"type:varchar(36);index;not null"`
|
||||
Semester string `json:"semester" gorm:"type:varchar(30);not null"`
|
||||
AverageGpa string `json:"average_gpa" gorm:"type:varchar(20)"`
|
||||
Rank string `json:"rank" gorm:"type:varchar(30)"`
|
||||
ExamTotalGpa string `json:"exam_total_gpa" gorm:"type:varchar(20)"`
|
||||
ExamTotalCredits string `json:"exam_total_credits" gorm:"type:varchar(20)"`
|
||||
FailCredits string `json:"fail_credits" gorm:"type:varchar(20)"`
|
||||
Semesters string `json:"semesters" gorm:"type:varchar(20)"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (g *GpaSummary) BeforeCreate(tx *gorm.DB) error {
|
||||
SetUUIDIfEmpty(&g.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (GpaSummary) TableName() string { return "gpa_summaries" }
|
||||
@@ -154,6 +154,11 @@ func autoMigrate(db *gorm.DB) error {
|
||||
// 课表
|
||||
&ScheduleCourse{},
|
||||
|
||||
// 成绩与考试
|
||||
&Grade{},
|
||||
&GpaSummary{},
|
||||
&Exam{},
|
||||
|
||||
// 用户活跃相关
|
||||
&UserActiveLog{},
|
||||
&UserActivityStat{},
|
||||
|
||||
41
internal/repository/exam_repo.go
Normal file
41
internal/repository/exam_repo.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"with_you/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ExamRepository interface {
|
||||
ListByUserAndSemester(userID, semester string) ([]*model.Exam, error)
|
||||
DeleteByUserAndSemester(ctx context.Context, userID, semester string) error
|
||||
BatchCreate(ctx context.Context, exams []*model.Exam) error
|
||||
}
|
||||
|
||||
type examRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewExamRepository(db *gorm.DB) ExamRepository {
|
||||
return &examRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *examRepository) ListByUserAndSemester(userID, semester string) ([]*model.Exam, error) {
|
||||
var exams []*model.Exam
|
||||
err := r.db.Where("user_id = ? AND semester = ?", userID, semester).
|
||||
Order("created_at ASC").
|
||||
Find(&exams).Error
|
||||
return exams, err
|
||||
}
|
||||
|
||||
func (r *examRepository) DeleteByUserAndSemester(ctx context.Context, userID, semester string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Where("user_id = ? AND semester = ?", userID, semester).
|
||||
Delete(&model.Exam{}).Error
|
||||
}
|
||||
|
||||
func (r *examRepository) BatchCreate(ctx context.Context, exams []*model.Exam) error {
|
||||
return r.db.WithContext(ctx).CreateInBatches(exams, 100).Error
|
||||
}
|
||||
56
internal/repository/grade_repo.go
Normal file
56
internal/repository/grade_repo.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"with_you/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type GradeRepository interface {
|
||||
ListByUserAndSemester(userID, semester string) ([]*model.Grade, error)
|
||||
DeleteByUserAndSemester(ctx context.Context, userID, semester string) error
|
||||
BatchCreate(ctx context.Context, grades []*model.Grade) error
|
||||
GetGpaSummary(userID, semester string) (*model.GpaSummary, error)
|
||||
SaveGpaSummary(ctx context.Context, summary *model.GpaSummary) error
|
||||
}
|
||||
|
||||
type gradeRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewGradeRepository(db *gorm.DB) GradeRepository {
|
||||
return &gradeRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *gradeRepository) ListByUserAndSemester(userID, semester string) ([]*model.Grade, error) {
|
||||
var grades []*model.Grade
|
||||
err := r.db.Where("user_id = ? AND term = ?", userID, semester).
|
||||
Order("created_at ASC").
|
||||
Find(&grades).Error
|
||||
return grades, err
|
||||
}
|
||||
|
||||
func (r *gradeRepository) DeleteByUserAndSemester(ctx context.Context, userID, semester string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Where("user_id = ? AND term = ?", userID, semester).
|
||||
Delete(&model.Grade{}).Error
|
||||
}
|
||||
|
||||
func (r *gradeRepository) BatchCreate(ctx context.Context, grades []*model.Grade) error {
|
||||
return r.db.WithContext(ctx).CreateInBatches(grades, 100).Error
|
||||
}
|
||||
|
||||
func (r *gradeRepository) GetGpaSummary(userID, semester string) (*model.GpaSummary, error) {
|
||||
var summary model.GpaSummary
|
||||
err := r.db.Where("user_id = ? AND semester = ?", userID, semester).First(&summary).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func (r *gradeRepository) SaveGpaSummary(ctx context.Context, summary *model.GpaSummary) error {
|
||||
return r.db.WithContext(ctx).Save(summary).Error
|
||||
}
|
||||
@@ -26,6 +26,8 @@ type RouterDeps struct {
|
||||
VoteHandler *handler.VoteHandler
|
||||
ChannelHandler *handler.ChannelHandler
|
||||
ScheduleHandler *handler.ScheduleHandler
|
||||
GradeHandler *handler.GradeHandler
|
||||
ExamHandler *handler.ExamHandler
|
||||
RoleHandler *handler.RoleHandler
|
||||
AdminUserHandler *handler.AdminUserHandler
|
||||
AdminPostHandler *handler.AdminPostHandler
|
||||
@@ -67,6 +69,8 @@ type Router struct {
|
||||
voteHandler *handler.VoteHandler
|
||||
channelHandler *handler.ChannelHandler
|
||||
scheduleHandler *handler.ScheduleHandler
|
||||
gradeHandler *handler.GradeHandler
|
||||
examHandler *handler.ExamHandler
|
||||
roleHandler *handler.RoleHandler
|
||||
adminUserHandler *handler.AdminUserHandler
|
||||
adminPostHandler *handler.AdminPostHandler
|
||||
@@ -111,6 +115,8 @@ func New(deps RouterDeps) *Router {
|
||||
voteHandler: deps.VoteHandler,
|
||||
channelHandler: deps.ChannelHandler,
|
||||
scheduleHandler: deps.ScheduleHandler,
|
||||
gradeHandler: deps.GradeHandler,
|
||||
examHandler: deps.ExamHandler,
|
||||
roleHandler: deps.RoleHandler,
|
||||
adminUserHandler: deps.AdminUserHandler,
|
||||
adminPostHandler: deps.AdminPostHandler,
|
||||
@@ -346,6 +352,26 @@ func (r *Router) setupRoutes() {
|
||||
}
|
||||
}
|
||||
|
||||
// 成绩路由
|
||||
if r.gradeHandler != nil {
|
||||
grades := v1.Group("/grades")
|
||||
grades.Use(authMiddleware)
|
||||
{
|
||||
grades.GET("", r.gradeHandler.ListGrades)
|
||||
grades.POST("/sync", r.gradeHandler.SyncGrades)
|
||||
}
|
||||
}
|
||||
|
||||
// 考试安排路由
|
||||
if r.examHandler != nil {
|
||||
exams := v1.Group("/exams")
|
||||
exams.Use(authMiddleware)
|
||||
{
|
||||
exams.GET("", r.examHandler.ListExams)
|
||||
exams.POST("/sync", r.examHandler.SyncExams)
|
||||
}
|
||||
}
|
||||
|
||||
// 学习资料路由(公开)
|
||||
if r.materialHandler != nil {
|
||||
materials := v1.Group("/materials")
|
||||
|
||||
@@ -387,6 +387,36 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
|
||||
// 获取会话中的参与者并发送消息
|
||||
participants, err := s.getParticipants(ctx, conversationID)
|
||||
if err == nil {
|
||||
// 先更新未读计数器,再推送 WS 事件,确保事件中的 total_unread 已包含新消息
|
||||
if s.conversationCache != nil {
|
||||
for _, p := range participants {
|
||||
if p.UserID == senderID {
|
||||
continue
|
||||
}
|
||||
if incrErr := s.conversationCache.IncrementUnread(context.Background(), p.UserID, conversationID); incrErr != nil {
|
||||
zap.L().Warn("increment unread from redis hash failed",
|
||||
zap.String("userID", p.UserID),
|
||||
zap.String("convID", conversationID),
|
||||
zap.Error(incrErr),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(participants)*2)
|
||||
for _, p := range participants {
|
||||
keys = append(keys,
|
||||
cache.UnreadDetailKey(p.UserID, conversationID),
|
||||
cache.UnreadConversationKey(p.UserID),
|
||||
)
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
s.cache.DeleteBatch(keys)
|
||||
}
|
||||
for _, p := range participants {
|
||||
s.conversationCache.InvalidateConversationList(p.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
targetIDs := make([]string, 0, len(participants))
|
||||
for _, p := range participants {
|
||||
if conv.Type == model.ConversationTypePrivate && p.UserID == senderID {
|
||||
@@ -422,36 +452,6 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
|
||||
}
|
||||
}
|
||||
|
||||
if s.conversationCache != nil {
|
||||
// 使用 Redis Hash 预计算未读数
|
||||
for _, p := range participants {
|
||||
if p.UserID == senderID {
|
||||
continue
|
||||
}
|
||||
if incrErr := s.conversationCache.IncrementUnread(context.Background(), p.UserID, conversationID); incrErr != nil {
|
||||
zap.L().Warn("increment unread from redis hash failed",
|
||||
zap.String("userID", p.UserID),
|
||||
zap.String("convID", conversationID),
|
||||
zap.Error(incrErr),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(participants)*2)
|
||||
for _, p := range participants {
|
||||
keys = append(keys,
|
||||
cache.UnreadDetailKey(p.UserID, conversationID),
|
||||
cache.UnreadConversationKey(p.UserID),
|
||||
)
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
s.cache.DeleteBatch(keys)
|
||||
}
|
||||
for _, p := range participants {
|
||||
s.conversationCache.InvalidateConversationList(p.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
if s.pushSvc != nil && len(participants) > 0 {
|
||||
sender := ChatMessageSender{ID: senderID}
|
||||
if senderUser, sErr := s.userRepo.GetByID(senderID); sErr == nil {
|
||||
@@ -695,7 +695,8 @@ func (s *chatServiceImpl) GetAllUnreadCount(ctx context.Context, userID string)
|
||||
maxSeqs[conv.ID] = maxSeq
|
||||
}
|
||||
}
|
||||
if len(maxSeqs) > 0 {
|
||||
// 只有全部会话的 msg_seq 都命中时才用算术结果,避免部分缺失导致低估
|
||||
if len(maxSeqs) == len(convIDs) {
|
||||
if total, err := s.conversationCache.ComputeAllUnreadCount(ctx, userID, convIDs, maxSeqs); err == nil {
|
||||
return total, nil
|
||||
}
|
||||
|
||||
131
internal/service/exam_sync_service.go
Normal file
131
internal/service/exam_sync_service.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"with_you/internal/grpc/runner"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/repository"
|
||||
pb "with_you/proto/runner"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type SyncExamsRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Semester string `json:"semester"`
|
||||
}
|
||||
|
||||
type SyncExamsResult struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
SyncedCount int `json:"synced_count"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
}
|
||||
|
||||
type ExamSyncService interface {
|
||||
SyncUserExams(ctx context.Context, userID string, req *SyncExamsRequest) (*SyncExamsResult, error)
|
||||
}
|
||||
|
||||
type examSyncService struct {
|
||||
taskManager *runner.TaskManager
|
||||
examRepo repository.ExamRepository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewExamSyncService(
|
||||
taskManager *runner.TaskManager,
|
||||
examRepo repository.ExamRepository,
|
||||
logger *zap.Logger,
|
||||
) ExamSyncService {
|
||||
return &examSyncService{
|
||||
taskManager: taskManager,
|
||||
examRepo: examRepo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *examSyncService) SyncUserExams(ctx context.Context, userID string, req *SyncExamsRequest) (*SyncExamsResult, error) {
|
||||
payload := &pb.GetExamsPayload{
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
Semester: req.Semester,
|
||||
}
|
||||
|
||||
taskResult, err := s.taskManager.SubmitTaskSync(ctx, pb.TaskType_TASK_TYPE_GET_EXAMS, payload)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to submit exams sync task",
|
||||
zap.String("user_id", userID),
|
||||
zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to submit task: %w", err)
|
||||
}
|
||||
|
||||
if taskResult.Status != pb.TaskStatus_TASK_STATUS_SUCCESS {
|
||||
return &SyncExamsResult{
|
||||
Success: false,
|
||||
Message: "考试安排获取失败",
|
||||
ErrorMessage: taskResult.ErrorMessage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var resultData pb.ExamsResultData
|
||||
if err := json.Unmarshal(taskResult.Data, &resultData); err != nil {
|
||||
s.logger.Error("failed to parse exams result",
|
||||
zap.String("user_id", userID),
|
||||
zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to parse result: %w", err)
|
||||
}
|
||||
|
||||
exams := s.convertExams(userID, &resultData)
|
||||
if err := s.saveExams(ctx, userID, resultData.Semester, exams); err != nil {
|
||||
s.logger.Error("failed to save exams",
|
||||
zap.String("user_id", userID),
|
||||
zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to save exams: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("exams sync completed",
|
||||
zap.String("user_id", userID),
|
||||
zap.Int("exam_count", len(exams)))
|
||||
|
||||
return &SyncExamsResult{
|
||||
Success: true,
|
||||
Message: "考试安排同步成功",
|
||||
SyncedCount: len(exams),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *examSyncService) convertExams(userID string, data *pb.ExamsResultData) []*model.Exam {
|
||||
exams := make([]*model.Exam, 0, len(data.Exams))
|
||||
for _, e := range data.Exams {
|
||||
exams = append(exams, &model.Exam{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Semester: data.Semester,
|
||||
CourseName: e.CourseName,
|
||||
ExamType: e.ExamType,
|
||||
Date: e.Date,
|
||||
Time: e.Time,
|
||||
Week: e.Week,
|
||||
Weekday: e.Weekday,
|
||||
})
|
||||
}
|
||||
return exams
|
||||
}
|
||||
|
||||
func (s *examSyncService) saveExams(ctx context.Context, userID, semester string, exams []*model.Exam) error {
|
||||
if err := s.examRepo.DeleteByUserAndSemester(ctx, userID, semester); err != nil {
|
||||
return fmt.Errorf("failed to delete old exams: %w", err)
|
||||
}
|
||||
if len(exams) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := s.examRepo.BatchCreate(ctx, exams); err != nil {
|
||||
return fmt.Errorf("failed to create exams: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
156
internal/service/grade_sync_service.go
Normal file
156
internal/service/grade_sync_service.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"with_you/internal/grpc/runner"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/repository"
|
||||
pb "with_you/proto/runner"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type SyncGradesRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Semester string `json:"semester"`
|
||||
}
|
||||
|
||||
type SyncGradesResult struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
SyncedCount int `json:"synced_count"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
}
|
||||
|
||||
type GradeSyncService interface {
|
||||
SyncUserGrades(ctx context.Context, userID string, req *SyncGradesRequest) (*SyncGradesResult, error)
|
||||
}
|
||||
|
||||
type gradeSyncService struct {
|
||||
taskManager *runner.TaskManager
|
||||
gradeRepo repository.GradeRepository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewGradeSyncService(
|
||||
taskManager *runner.TaskManager,
|
||||
gradeRepo repository.GradeRepository,
|
||||
logger *zap.Logger,
|
||||
) GradeSyncService {
|
||||
return &gradeSyncService{
|
||||
taskManager: taskManager,
|
||||
gradeRepo: gradeRepo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *gradeSyncService) SyncUserGrades(ctx context.Context, userID string, req *SyncGradesRequest) (*SyncGradesResult, error) {
|
||||
payload := &pb.GetGradesPayload{
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
Semester: req.Semester,
|
||||
}
|
||||
|
||||
taskResult, err := s.taskManager.SubmitTaskSync(ctx, pb.TaskType_TASK_TYPE_GET_GRADES, payload)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to submit grades sync task",
|
||||
zap.String("user_id", userID),
|
||||
zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to submit task: %w", err)
|
||||
}
|
||||
|
||||
if taskResult.Status != pb.TaskStatus_TASK_STATUS_SUCCESS {
|
||||
return &SyncGradesResult{
|
||||
Success: false,
|
||||
Message: "成绩获取失败",
|
||||
ErrorMessage: taskResult.ErrorMessage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var resultData pb.GradesResultData
|
||||
if err := json.Unmarshal(taskResult.Data, &resultData); err != nil {
|
||||
s.logger.Error("failed to parse grades result",
|
||||
zap.String("user_id", userID),
|
||||
zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to parse result: %w", err)
|
||||
}
|
||||
|
||||
grades := s.convertGrades(userID, &resultData)
|
||||
if err := s.saveGrades(ctx, userID, resultData.Semester, grades); err != nil {
|
||||
s.logger.Error("failed to save grades",
|
||||
zap.String("user_id", userID),
|
||||
zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to save grades: %w", err)
|
||||
}
|
||||
|
||||
if resultData.GpaSummary != nil {
|
||||
summary := s.convertGpaSummary(userID, resultData.Semester, resultData.GpaSummary)
|
||||
if err := s.gradeRepo.SaveGpaSummary(ctx, summary); err != nil {
|
||||
s.logger.Warn("failed to save gpa summary",
|
||||
zap.String("user_id", userID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Info("grades sync completed",
|
||||
zap.String("user_id", userID),
|
||||
zap.Int("grade_count", len(grades)))
|
||||
|
||||
return &SyncGradesResult{
|
||||
Success: true,
|
||||
Message: "成绩同步成功",
|
||||
SyncedCount: len(grades),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *gradeSyncService) convertGrades(userID string, data *pb.GradesResultData) []*model.Grade {
|
||||
grades := make([]*model.Grade, 0, len(data.Grades))
|
||||
for _, g := range data.Grades {
|
||||
grades = append(grades, &model.Grade{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Term: g.Term,
|
||||
CourseCode: g.CourseCode,
|
||||
CourseName: g.CourseName,
|
||||
Nature: g.Nature,
|
||||
Category: g.Category,
|
||||
Credit: g.Credit,
|
||||
Score: g.Score,
|
||||
GradePoint: g.GradePoint,
|
||||
IsExam: g.IsExam,
|
||||
})
|
||||
}
|
||||
return grades
|
||||
}
|
||||
|
||||
func (s *gradeSyncService) convertGpaSummary(userID, semester string, gpa *pb.GpaSummary) *model.GpaSummary {
|
||||
return &model.GpaSummary{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Semester: semester,
|
||||
AverageGpa: gpa.AverageGpa,
|
||||
Rank: gpa.Rank,
|
||||
ExamTotalGpa: gpa.ExamTotalGpa,
|
||||
ExamTotalCredits: gpa.ExamTotalCredits,
|
||||
FailCredits: gpa.FailCredits,
|
||||
Semesters: gpa.Semesters,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *gradeSyncService) saveGrades(ctx context.Context, userID, semester string, grades []*model.Grade) error {
|
||||
if err := s.gradeRepo.DeleteByUserAndSemester(ctx, userID, semester); err != nil {
|
||||
return fmt.Errorf("failed to delete old grades: %w", err)
|
||||
}
|
||||
if len(grades) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := s.gradeRepo.BatchCreate(ctx, grades); err != nil {
|
||||
return fmt.Errorf("failed to create grades: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -35,6 +35,8 @@ var HandlerSet = wire.NewSet(
|
||||
handler.NewAdminProfileAuditHandler,
|
||||
handler.NewPostHandler,
|
||||
handler.NewTradeHandler,
|
||||
handler.NewGradeHandler,
|
||||
handler.NewExamHandler,
|
||||
|
||||
// 需要特殊处理的 Handler
|
||||
ProvideUserHandler,
|
||||
|
||||
@@ -24,6 +24,8 @@ var RepositorySet = wire.NewSet(
|
||||
repository.NewVoteRepository,
|
||||
repository.NewChannelRepository,
|
||||
repository.NewScheduleRepository,
|
||||
repository.NewGradeRepository,
|
||||
repository.NewExamRepository,
|
||||
repository.NewMaterialSubjectRepository,
|
||||
repository.NewMaterialFileRepository,
|
||||
repository.NewCallRepository,
|
||||
|
||||
@@ -46,6 +46,8 @@ var ServiceSet = wire.NewSet(
|
||||
ProvideChatService,
|
||||
ProvideScheduleService,
|
||||
ProvideScheduleSyncService,
|
||||
ProvideGradeSyncService,
|
||||
ProvideExamSyncService,
|
||||
ProvideGroupService,
|
||||
ProvideUploadService,
|
||||
ProvideUserActivityService,
|
||||
@@ -230,6 +232,24 @@ func ProvideScheduleSyncService(
|
||||
return service.NewScheduleSyncService(taskManager, scheduleRepo, logger)
|
||||
}
|
||||
|
||||
// ProvideGradeSyncService 提供成绩同步服务
|
||||
func ProvideGradeSyncService(
|
||||
taskManager *runner.TaskManager,
|
||||
gradeRepo repository.GradeRepository,
|
||||
logger *zap.Logger,
|
||||
) service.GradeSyncService {
|
||||
return service.NewGradeSyncService(taskManager, gradeRepo, logger)
|
||||
}
|
||||
|
||||
// ProvideExamSyncService 提供考试安排同步服务
|
||||
func ProvideExamSyncService(
|
||||
taskManager *runner.TaskManager,
|
||||
examRepo repository.ExamRepository,
|
||||
logger *zap.Logger,
|
||||
) service.ExamSyncService {
|
||||
return service.NewExamSyncService(taskManager, examRepo, logger)
|
||||
}
|
||||
|
||||
// ProvideGroupService 提供群组服务
|
||||
func ProvideGroupService(
|
||||
groupRepo repository.GroupRepository,
|
||||
|
||||
Reference in New Issue
Block a user