This commit is contained in:
2026-06-29 23:45:08 +07:00
parent 5290eeeb91
commit c736326162
15 changed files with 3067 additions and 388 deletions

44
server/internal/db/db.go Normal file
View File

@@ -0,0 +1,44 @@
package db
import (
"fmt"
"log"
"server/internal/models"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func ConnectDB(host, port, user, password, dbName string) (*gorm.DB, error) {
// Kết nối ban đầu không chọn database để tạo database nếu chưa tồn tại
dsnWithoutDB := fmt.Sprintf("%s:%s@tcp(%s:%s)/?charset=utf8mb4&parseTime=True&loc=Local", user, password, host, port)
tmpDB, err := gorm.Open(mysql.Open(dsnWithoutDB), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("failed to connect to MySQL server: %w", err)
}
createDBQuery := fmt.Sprintf("CREATE DATABASE IF NOT EXISTS `%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;", dbName)
if err := tmpDB.Exec(createDBQuery).Error; err != nil {
return nil, fmt.Errorf("failed to create database: %w", err)
}
// Kết nối chính thức vào database chỉ định
dsnWithDB := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", user, password, host, port, dbName)
gormDB, err := gorm.Open(mysql.Open(dsnWithDB), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("failed to connect to database %s: %w", dbName, err)
}
log.Printf("Successfully connected to MySQL database: %s", dbName)
return gormDB, nil
}
func AutoMigrate(db *gorm.DB) error {
log.Println("Running AutoMigrate...")
return db.AutoMigrate(
&models.Class{},
&models.ClassCourseLink{},
&models.Student{},
&models.ClassStudent{},
)
}

View File

@@ -0,0 +1,298 @@
package handlers
import (
"strconv"
"strings"
"server/internal/models"
"server/internal/qldt"
"server/internal/syncjobs"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// Helper convert string to int
func atoiDefault(s string, def int) int {
if s == "" {
return def
}
v, err := strconv.Atoi(s)
if err != nil {
return def
}
return v
}
// Handler bắt đầu đồng bộ lớp học
func StartClassesSyncHandler(db *gorm.DB, client *qldt.Client, job *syncjobs.ClassesSyncJob, token string) fiber.Handler {
return func(c *fiber.Ctx) error {
if token == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing token QLDT_TOKEN"})
}
started := job.Start(db, client, token)
if !started {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "Sync classes is already running"})
}
return c.JSON(fiber.Map{"ok": true, "message": "Classes sync job started"})
}
}
// Handler lấy status đồng bộ lớp học
func GetClassesSyncStatusHandler(job *syncjobs.ClassesSyncJob) fiber.Handler {
return func(c *fiber.Ctx) error {
return c.JSON(job.Status())
}
}
// Handler bắt đầu đồng bộ toàn bộ sinh viên
func StartStudentsSyncHandler(db *gorm.DB, client *qldt.Client, job *syncjobs.StudentsSyncJob, token string) fiber.Handler {
return func(c *fiber.Ctx) error {
if token == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing token QLDT_TOKEN"})
}
started := job.Start(db, client, token)
if !started {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "Sync students is already running"})
}
return c.JSON(fiber.Map{"ok": true, "message": "Students sync job started"})
}
}
// Handler lấy status đồng bộ sinh viên
func GetStudentsSyncStatusHandler(job *syncjobs.StudentsSyncJob) fiber.Handler {
return func(c *fiber.Ctx) error {
return c.JSON(job.Status())
}
}
type courseRefItem struct {
CourseRkID int64 `json:"courseRkId"`
CourseName string `json:"courseName"`
}
type classItem struct {
RkID int64 `json:"rkId"`
Name string `json:"name"`
ClassCode string `json:"classCode"`
Type string `json:"type"`
StudentCount int `json:"studentCount"`
SpecializeRkID *int64 `json:"specializeRkId"`
SpecializeName *string `json:"specializeName"`
SystemRkID *int64 `json:"systemRkId"`
SystemCode *string `json:"systemCode"`
SystemName *string `json:"systemName"`
IsStudying bool `json:"isStudying"`
Courses []courseRefItem `json:"courses"`
}
// Handler danh sách lớp học
func ListClassesHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
page := atoiDefault(c.Query("page"), 1)
pageSize := atoiDefault(c.Query("pageSize"), 20)
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
queryDB := db.Model(&models.Class{})
if sys := c.Query("systemRkId"); sys != "" {
if id, err := strconv.ParseInt(sys, 10, 64); err == nil {
queryDB = queryDB.Where("system_rk_id = ?", id)
}
}
if s := strings.TrimSpace(c.Query("q")); s != "" {
pat := "%" + s + "%"
queryDB = queryDB.Where("(name LIKE ? OR class_code LIKE ? OR type LIKE ? OR specialize_name LIKE ? OR system_name LIKE ?)", pat, pat, pat, pat, pat)
}
studyingOnly := c.Query("studyingOnly")
if studyingOnly == "1" || studyingOnly == "true" {
queryDB = queryDB.Where("is_studying = ?", true)
}
var total int64
if err := queryDB.Count(&total).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to count classes: " + err.Error()})
}
var rows []models.Class
if err := queryDB.Order("rk_id desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&rows).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to query classes: " + err.Error()})
}
// Kéo danh sách Courses cho các lớp
var classIDs []int64
for _, r := range rows {
classIDs = append(classIDs, r.RkID)
}
coursesByClass := map[int64][]courseRefItem{}
if len(classIDs) > 0 {
var links []models.ClassCourseLink
if err := db.Where("class_rk_id IN ?", classIDs).Order("course_order asc").Find(&links).Error; err == nil {
for _, link := range links {
coursesByClass[link.ClassRkID] = append(coursesByClass[link.ClassRkID], courseRefItem{
CourseRkID: link.CourseRkID,
CourseName: link.CourseName,
})
}
}
}
out := make([]classItem, 0, len(rows))
for _, r := range rows {
courses := coursesByClass[r.RkID]
if courses == nil {
courses = []courseRefItem{}
}
out = append(out, classItem{
RkID: r.RkID,
Name: r.Name,
ClassCode: r.ClassCode,
Type: r.Type,
StudentCount: r.StudentCount,
SpecializeRkID: r.SpecializeRkID,
SpecializeName: r.SpecializeName,
SystemRkID: r.SystemRkID,
SystemCode: r.SystemCode,
SystemName: r.SystemName,
IsStudying: r.IsStudying,
Courses: courses,
})
}
return c.JSON(fiber.Map{
"data": out,
"total": total,
"page": page,
"pageSize": pageSize,
})
}
}
// Handler cập nhật trạng thái đang học (isStudying) của lớp học
func PatchClassStudyingHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
rkIDStr := c.Params("rkId")
rkID, err := strconv.ParseInt(rkIDStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
}
var body struct {
IsStudying bool `json:"isStudying"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body payload"})
}
var cl models.Class
if err := db.Where("rk_id = ?", rkID).First(&cl).Error; err != nil {
if gorm.ErrRecordNotFound == err {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Class not found"})
}
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
cl.IsStudying = body.IsStudying
if err := db.Save(&cl).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update class: " + err.Error()})
}
return c.JSON(fiber.Map{"ok": true, "data": cl})
}
}
// Handler lấy danh sách sinh viên của lớp học cụ thể
func ListClassStudentsHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
rkIDStr := c.Params("rkId")
classRkID, err := strconv.ParseInt(rkIDStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
}
// Tìm danh sách student_rk_id thuộc lớp
var links []models.ClassStudent
if err := db.Where("class_rk_id = ?", classRkID).Find(&links).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to query class students: " + err.Error()})
}
var studentRkIDs []int64
for _, link := range links {
studentRkIDs = append(studentRkIDs, link.StudentRkID)
}
students := []models.Student{}
if len(studentRkIDs) > 0 {
if err := db.Where("rk_id IN ?", studentRkIDs).Order("full_name asc").Find(&students).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load students info: " + err.Error()})
}
}
return c.JSON(fiber.Map{"data": students, "total": len(students)})
}
}
// Handler danh sách toàn bộ sinh viên (đã sync)
func ListAllStudentsHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
page := atoiDefault(c.Query("page"), 1)
pageSize := atoiDefault(c.Query("pageSize"), 20)
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
queryDB := db.Model(&models.Student{})
if s := strings.TrimSpace(c.Query("q")); s != "" {
pat := "%" + s + "%"
queryDB = queryDB.Where("(full_name LIKE ? OR student_code LIKE ? OR email LIKE ? OR phone LIKE ?)", pat, pat, pat, pat)
}
var total int64
if err := queryDB.Count(&total).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to count students: " + err.Error()})
}
var students []models.Student
if err := queryDB.Order("id desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&students).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load students: " + err.Error()})
}
return c.JSON(fiber.Map{
"data": students,
"total": total,
"page": page,
"pageSize": pageSize,
})
}
}
// Handler lấy stats nhanh hiển thị ở Dashboard
func GetStatsHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var totalClasses int64
var activeClasses int64
var totalStudents int64
db.Model(&models.Class{}).Count(&totalClasses)
db.Model(&models.Class{}).Where("is_studying = ?", true).Count(&activeClasses)
db.Model(&models.Student{}).Count(&totalStudents)
return c.JSON(fiber.Map{
"totalClasses": totalClasses,
"activeClasses": activeClasses,
"totalStudents": totalStudents,
})
}
}

View File

@@ -0,0 +1,61 @@
package models
import "time"
// Class đại diện cho lớp học từ QLĐT
type Class struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
RkID int64 `gorm:"column:rk_id;uniqueIndex;not null" json:"rkId"`
Name string `gorm:"column:name;size:512" json:"name"`
ClassCode string `gorm:"column:class_code;size:128" json:"classCode"`
Type string `gorm:"column:type;size:64" json:"type"`
StudentCount int `gorm:"column:student_count;default:0" json:"studentCount"`
SpecializeRkID *int64 `gorm:"column:specialize_rk_id" json:"specializeRkId"`
SpecializeName *string `gorm:"column:specialize_name;size:255" json:"specializeName"`
SystemRkID *int64 `gorm:"column:system_rk_id;index" json:"systemRkId"`
SystemCode *string `gorm:"column:system_code;size:64" json:"systemCode"`
SystemName *string `gorm:"column:system_name;size:512" json:"systemName"`
IsStudying bool `gorm:"column:is_studying;default:false;index" json:"isStudying"`
}
func (Class) TableName() string { return "classes" }
// ClassCourseLink lưu danh sách môn học của mỗi lớp
type ClassCourseLink struct {
ClassRkID int64 `gorm:"column:class_rk_id;primaryKey;not null" json:"classRkId"`
CourseRkID int64 `gorm:"column:course_rk_id;primaryKey;not null" json:"courseRkId"`
CourseName string `gorm:"column:course_name;size:512" json:"courseName"`
CourseOrder int `gorm:"column:course_order;default:0" json:"courseOrder"`
}
func (ClassCourseLink) TableName() string { return "class_course_links" }
// Student đại diện cho sinh viên
type Student struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
RkID int64 `gorm:"column:rk_id;uniqueIndex;not null" json:"rkId"`
StudentCode string `gorm:"column:student_code;size:64;index" json:"studentCode"`
FullName string `gorm:"column:full_name;size:255;index" json:"fullName"`
Phone *string `gorm:"column:phone;size:32" json:"phone"`
Email string `gorm:"column:email;size:255;index" json:"email"`
DateOfBirth *time.Time `gorm:"column:date_of_birth" json:"dateOfBirth"`
Gender *int `gorm:"column:gender" json:"gender"`
Status *string `gorm:"column:status;size:64" json:"status"`
Location *string `gorm:"column:location;size:32" json:"location"`
SystemID *int64 `gorm:"column:system_id" json:"systemId"`
SystemName *string `gorm:"column:system_name;size:255" json:"systemName"`
}
func (Student) TableName() string { return "students" }
// ClassStudent map sinh viên vào lớp tương ứng
type ClassStudent struct {
ClassRkID int64 `gorm:"column:class_rk_id;primaryKey;not null;index" json:"classRkId"`
StudentRkID int64 `gorm:"column:student_rk_id;primaryKey;not null;index" json:"studentRkId"`
}
func (ClassStudent) TableName() string { return "class_students" }

View File

@@ -0,0 +1,199 @@
package qldt
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
type Client struct {
baseURL string
origin string
http *http.Client
}
func NewClient(baseURL, origin string) *Client {
baseURL = strings.TrimRight(baseURL, "/")
return &Client{
baseURL: baseURL,
origin: origin,
http: &http.Client{
Timeout: 45 * time.Second,
},
}
}
type StudentsResponse struct {
Message string `json:"message"`
StatusCode int `json:"statusCode"`
Data []StudentDTO `json:"data"`
Total int `json:"total"`
Page string `json:"page"`
PageSize string `json:"pageSize"`
}
type StudentDTO struct {
ID int64 `json:"id"`
StudentCode string `json:"studentCode"`
FullName string `json:"fullName"`
Phone *string `json:"phone"`
Email string `json:"email"`
DateOfBirth *string `json:"dateOfBirth"`
Gender *int `json:"gender"`
Status *string `json:"status"`
Location *string `json:"location"`
System *struct {
ID int64 `json:"id"`
Name string `json:"name"`
} `json:"system"`
}
type ClassesResponse struct {
Data []ClassDTO `json:"data"`
StatusCode int `json:"statusCode"`
}
type ClassDTO struct {
ID int64 `json:"id"`
Name string `json:"name"`
ClassCode string `json:"classCode"`
Type string `json:"type"`
StudentCount int `json:"studentCount"`
CreatedAt string `json:"createdAt"`
UpdatedAt *string `json:"updatedAt"`
Specializes *struct {
ID int64 `json:"id"`
Name string `json:"name"`
Systems *struct {
ID int64 `json:"id"`
SystemCode string `json:"systemCode"`
Name string `json:"name"`
} `json:"systems"`
} `json:"specializes"`
Courses []classCourseNestDTO `json:"courses"`
}
type classCourseNestDTO struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
func (c *Client) GetStudents(ctx context.Context, token string, page, pageSize int) (*StudentsResponse, error) {
u, err := url.Parse(c.baseURL + "/students")
if err != nil {
return nil, err
}
q := u.Query()
q.Set("page", strconv.Itoa(page))
q.Set("pageSize", strconv.Itoa(pageSize))
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if c.origin != "" {
req.Header.Set("Origin", c.origin)
req.Header.Set("Referer", c.origin+"/")
}
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("qldt students http %d: %s", resp.StatusCode, string(body))
}
var out StudentsResponse
if err := json.Unmarshal(body, &out); err != nil {
return nil, err
}
if out.StatusCode != 0 && out.StatusCode != 200 {
return nil, fmt.Errorf("qldt statusCode %d: %s", out.StatusCode, out.Message)
}
return &out, nil
}
func (c *Client) GetClassesBySystem(ctx context.Context, token string, systemID int64) (*ClassesResponse, error) {
u := fmt.Sprintf("%s/classes/system/%d", c.baseURL, systemID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if c.origin != "" {
req.Header.Set("Origin", c.origin)
req.Header.Set("Referer", c.origin+"/")
}
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("qldt classes/system/%d http %d: %s", systemID, resp.StatusCode, string(body))
}
var outClasses ClassesResponse
if err := json.Unmarshal(body, &outClasses); err != nil {
return nil, err
}
if outClasses.StatusCode != 0 && outClasses.StatusCode != 200 {
return nil, fmt.Errorf("qldt classes statusCode %d", outClasses.StatusCode)
}
return &outClasses, nil
}
func (c *Client) GetClassDashboard(ctx context.Context, token string, classID, courseID int64) ([]byte, error) {
u := fmt.Sprintf("%s/classes/dashboard/%d/%d", c.baseURL, classID, courseID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if c.origin != "" {
req.Header.Set("Origin", c.origin)
req.Header.Set("Referer", c.origin+"/")
}
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("qldt classes/dashboard/%d/%d http %d: %s", classID, courseID, resp.StatusCode, string(body))
}
var probe struct {
StatusCode int `json:"statusCode"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &probe); err == nil {
if probe.StatusCode != 0 && probe.StatusCode != 200 {
return nil, fmt.Errorf("qldt classes/dashboard statusCode %d: %s", probe.StatusCode, probe.Message)
}
}
return body, nil
}

View File

@@ -0,0 +1,556 @@
package syncjobs
import (
"context"
"encoding/json"
"errors"
"log"
"sync"
"time"
"server/internal/models"
"server/internal/qldt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Trạng thái đồng bộ sinh viên toàn hệ thống
type StudentsSyncStatus struct {
Running bool `json:"running"`
Done bool `json:"done"`
Error string `json:"error,omitempty"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int `json:"total"`
Synced int `json:"synced"`
UpdatedAt int64 `json:"updatedAt"`
}
type StudentsSyncJob struct {
mu sync.RWMutex
status StudentsSyncStatus
}
func NewStudentsSyncJob() *StudentsSyncJob {
return &StudentsSyncJob{
status: StudentsSyncStatus{UpdatedAt: time.Now().Unix()},
}
}
func (j *StudentsSyncJob) Status() StudentsSyncStatus {
j.mu.RLock()
defer j.mu.RUnlock()
return j.status
}
func (j *StudentsSyncJob) Start(db *gorm.DB, client *qldt.Client, token string) bool {
j.mu.Lock()
if j.status.Running {
j.mu.Unlock()
return false
}
j.status = StudentsSyncStatus{
Running: true,
Done: false,
Page: 0,
PageSize: 100,
Total: 0,
Synced: 0,
UpdatedAt: time.Now().Unix(),
}
j.mu.Unlock()
go j.run(db, client, token)
return true
}
func (j *StudentsSyncJob) run(db *gorm.DB, client *qldt.Client, token string) {
defer func() {
j.mu.Lock()
j.status.Running = false
j.status.Done = j.status.Error == ""
j.status.UpdatedAt = time.Now().Unix()
j.mu.Unlock()
}()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
defer cancel()
pageSize := 100
page := 1
total := 0
synced := 0
for {
res, err := client.GetStudents(ctx, token, page, pageSize)
if err != nil {
j.setError(err.Error())
return
}
if total == 0 {
total = res.Total
}
if len(res.Data) == 0 {
break
}
batch := make([]models.Student, 0, len(res.Data))
for _, s := range res.Data {
var dob *time.Time
if s.DateOfBirth != nil && *s.DateOfBirth != "" {
if t, err := time.Parse("2006-01-02", *s.DateOfBirth); err == nil {
dob = &t
}
}
st := models.Student{
RkID: s.ID,
StudentCode: s.StudentCode,
FullName: s.FullName,
Phone: s.Phone,
Email: s.Email,
DateOfBirth: dob,
Gender: s.Gender,
Status: s.Status,
Location: s.Location,
}
if s.System != nil {
sysID := s.System.ID
sysName := s.System.Name
st.SystemID = &sysID
st.SystemName = &sysName
}
batch = append(batch, st)
}
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "rk_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"student_code",
"full_name",
"phone",
"email",
"date_of_birth",
"gender",
"status",
"location",
"system_id",
"system_name",
"updated_at",
}),
}).CreateInBatches(batch, 100).Error; err != nil {
j.setError("Failed to save students to DB: " + err.Error())
return
}
synced += len(batch)
j.mu.Lock()
j.status.Page = page
j.status.PageSize = pageSize
j.status.Total = total
j.status.Synced = synced
j.status.UpdatedAt = time.Now().Unix()
j.mu.Unlock()
if synced >= total || len(res.Data) < pageSize {
break
}
page++
time.Sleep(50 * time.Millisecond) // Tránh spam API quá tải
}
}
func (j *StudentsSyncJob) setError(msg string) {
j.mu.Lock()
defer j.mu.Unlock()
j.status.Error = msg
j.status.UpdatedAt = time.Now().Unix()
}
// Trạng thái đồng bộ lớp học và sinh viên của lớp
type ClassesSyncStatus struct {
Running bool `json:"running"`
Done bool `json:"done"`
Error string `json:"error,omitempty"`
Total int `json:"total"`
Synced int `json:"synced"`
UpdatedAt int64 `json:"updatedAt"`
}
type ClassesSyncJob struct {
mu sync.RWMutex
status ClassesSyncStatus
}
func NewClassesSyncJob() *ClassesSyncJob {
return &ClassesSyncJob{
status: ClassesSyncStatus{UpdatedAt: time.Now().Unix()},
}
}
func (j *ClassesSyncJob) Status() ClassesSyncStatus {
j.mu.RLock()
defer j.mu.RUnlock()
return j.status
}
func (j *ClassesSyncJob) Start(db *gorm.DB, client *qldt.Client, token string) bool {
j.mu.Lock()
if j.status.Running {
j.mu.Unlock()
return false
}
j.status = ClassesSyncStatus{
Running: true,
Done: false,
Total: 0,
Synced: 0,
UpdatedAt: time.Now().Unix(),
}
j.mu.Unlock()
go j.run(db, client, token)
return true
}
func (j *ClassesSyncJob) run(db *gorm.DB, client *qldt.Client, token string) {
defer func() {
j.mu.Lock()
j.status.Running = false
j.status.Done = j.status.Error == ""
j.status.UpdatedAt = time.Now().Unix()
j.mu.Unlock()
}()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
defer cancel()
// 1. Lấy toàn bộ lớp học từ hệ thống chính (System ID = 0 đại diện cho tất cả)
res, err := client.GetClassesBySystem(ctx, token, 0)
if err != nil {
j.setError("Failed to fetch classes: " + err.Error())
return
}
j.mu.Lock()
j.status.Total = len(res.Data)
j.status.UpdatedAt = time.Now().Unix()
j.mu.Unlock()
var syncedClassIDs []int64
for _, c := range res.Data {
syncedClassIDs = append(syncedClassIDs, c.ID)
}
// Đồng bộ từng lớp
for idx, cl := range res.Data {
tx := db.Begin()
if tx.Error != nil {
j.setError("Failed to start transaction: " + tx.Error.Error())
return
}
// Map thông tin class
classRow := models.Class{
RkID: cl.ID,
Name: cl.Name,
ClassCode: cl.ClassCode,
Type: cl.Type,
StudentCount: cl.StudentCount,
}
if cl.Specializes != nil {
specID := cl.Specializes.ID
specName := cl.Specializes.Name
classRow.SpecializeRkID = &specID
classRow.SpecializeName = &specName
if cl.Specializes.Systems != nil {
sysID := cl.Specializes.Systems.ID
sysCode := cl.Specializes.Systems.SystemCode
sysName := cl.Specializes.Systems.Name
classRow.SystemRkID = &sysID
classRow.SystemCode = &sysCode
classRow.SystemName = &sysName
}
}
// Xem lớp học hiện tại trong DB
var existing models.Class
err := tx.Where("rk_id = ?", cl.ID).First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
classRow.IsStudying = false // Lớp mới mặc định chưa học
if err := tx.Create(&classRow).Error; err != nil {
tx.Rollback()
j.setError("Failed to insert class: " + err.Error())
return
}
} else if err != nil {
tx.Rollback()
j.setError("Lookup class failed: " + err.Error())
return
} else {
// Giữ trạng thái IsStudying cũ
classRow.ID = existing.ID
classRow.IsStudying = existing.IsStudying
classRow.CreatedAt = existing.CreatedAt
if err := tx.Save(&classRow).Error; err != nil {
tx.Rollback()
j.setError("Failed to update class: " + err.Error())
return
}
}
// Lưu liên kết Course
if err := tx.Where("class_rk_id = ?", cl.ID).Delete(&models.ClassCourseLink{}).Error; err != nil {
tx.Rollback()
j.setError("Failed to clear old class course links: " + err.Error())
return
}
var courseLinks []models.ClassCourseLink
for ord, co := range cl.Courses {
if co.ID == 0 {
continue
}
courseLinks = append(courseLinks, models.ClassCourseLink{
ClassRkID: cl.ID,
CourseRkID: co.ID,
CourseName: co.Name,
CourseOrder: ord,
})
}
if len(courseLinks) > 0 {
if err := tx.Create(&courseLinks).Error; err != nil {
tx.Rollback()
j.setError("Failed to insert class course links: " + err.Error())
return
}
}
// Đồng bộ sinh viên của lớp: Gọi class dashboard để trích xuất sinh viên
// Ta chọn course đầu tiên của lớp để kéo thông tin sinh viên
var classStudentsToMap []models.ClassStudent
if len(cl.Courses) > 0 && cl.Courses[0].ID > 0 {
dashboardJSON, err := client.GetClassDashboard(ctx, token, cl.ID, cl.Courses[0].ID)
if err != nil {
// Nếu lấy dashboard lỗi thì log lại nhưng không dừng tiến trình sync lớp
log.Printf("Warning: failed to get dashboard for class ID %d, course ID %d: %v", cl.ID, cl.Courses[0].ID, err)
} else {
stList, err := extractStudentsFromDashboard(dashboardJSON)
if err != nil {
log.Printf("Warning: failed to parse students from dashboard class %d: %v", cl.ID, err)
} else if len(stList) > 0 {
// Upsert sinh viên vào table students
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "rk_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"student_code",
"full_name",
"phone",
"email",
"date_of_birth",
"gender",
"status",
"location",
"system_id",
"system_name",
"updated_at",
}),
}).Create(&stList).Error; err != nil {
tx.Rollback()
j.setError("Failed to save class students: " + err.Error())
return
}
// Lưu map Class-Student
for _, st := range stList {
classStudentsToMap = append(classStudentsToMap, models.ClassStudent{
ClassRkID: cl.ID,
StudentRkID: st.RkID,
})
}
}
}
}
// Cập nhật map Class-Student trong DB (xóa map cũ, insert map mới)
if err := tx.Where("class_rk_id = ?", cl.ID).Delete(&models.ClassStudent{}).Error; err != nil {
tx.Rollback()
j.setError("Failed to clear old class students mapping: " + err.Error())
return
}
if len(classStudentsToMap) > 0 {
if err := tx.Clauses(clause.OnConflict{
DoNothing: true, // Tránh trùng lặp do lỗi nào đó
}).Create(&classStudentsToMap).Error; err != nil {
tx.Rollback()
j.setError("Failed to map class students: " + err.Error())
return
}
}
if err := tx.Commit().Error; err != nil {
j.setError("Transaction commit failed: " + err.Error())
return
}
j.mu.Lock()
j.status.Synced = idx + 1
j.status.UpdatedAt = time.Now().Unix()
j.mu.Unlock()
time.Sleep(50 * time.Millisecond) // Thở tí tránh spam API
}
// 2. Xóa các lớp học không còn tồn tại trên QLĐT
if len(syncedClassIDs) > 0 {
db.Where("rk_id NOT IN ?", syncedClassIDs).Delete(&models.Class{})
} else {
db.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&models.Class{})
}
}
func (j *ClassesSyncJob) setError(msg string) {
j.mu.Lock()
defer j.mu.Unlock()
j.status.Error = msg
j.status.UpdatedAt = time.Now().Unix()
}
// Helper giải nén danh sách sinh viên từ Dashboard JSON
func extractStudentsFromDashboard(payloadJSON []byte) ([]models.Student, error) {
var root interface{}
if err := json.Unmarshal(payloadJSON, &root); err != nil {
return nil, err
}
var arr []interface{}
if m, ok := root.(map[string]interface{}); ok {
if dataVal, exists := m["data"]; exists {
if dArr, ok := dataVal.([]interface{}); ok {
arr = dArr
} else if dMap, ok := dataVal.(map[string]interface{}); ok {
for _, k := range []string{"studentsInClass", "students", "rows", "items"} {
if val, exists := dMap[k]; exists {
if innerArr, ok := val.([]interface{}); ok {
arr = innerArr
break
}
}
}
}
}
if len(arr) == 0 {
for _, k := range []string{"studentsInClass", "students", "rows", "items"} {
if val, exists := m[k]; exists {
if dArr, ok := val.([]interface{}); ok {
arr = dArr
break
}
}
}
}
} else if sliceVal, ok := root.([]interface{}); ok {
arr = sliceVal
}
var students []models.Student
for _, item := range arr {
m, ok := item.(map[string]interface{})
if !ok || m == nil {
continue
}
studentObj, ok := m["student"].(map[string]interface{})
if !ok || studentObj == nil {
continue
}
var rkID int64
if idVal, exists := studentObj["id"]; exists {
switch v := idVal.(type) {
case float64:
rkID = int64(v)
case json.Number:
if id64, err := v.Int64(); err == nil {
rkID = id64
}
case int:
rkID = int64(v)
case int64:
rkID = v
}
}
if rkID == 0 {
continue
}
stCode, _ := studentObj["studentCode"].(string)
fullName, _ := studentObj["fullName"].(string)
email, _ := studentObj["email"].(string)
var phone *string
if p, ok := studentObj["phone"].(string); ok && p != "" {
phone = &p
}
var dob *time.Time
if dobStr, ok := studentObj["dateOfBirth"].(string); ok && dobStr != "" {
if t, err := time.Parse("2006-01-02", dobStr); err == nil {
dob = &t
}
}
var gender *int
if gVal, exists := studentObj["gender"]; exists {
var g int
switch v := gVal.(type) {
case float64:
g = int(v)
case json.Number:
if i64, err := v.Int64(); err == nil {
g = int(i64)
}
case int:
g = v
}
gender = &g
}
status, _ := studentObj["status"].(string)
location, _ := studentObj["location"].(string)
student := models.Student{
RkID: rkID,
StudentCode: stCode,
FullName: fullName,
Phone: phone,
Email: email,
DateOfBirth: dob,
Gender: gender,
Status: &status,
Location: &location,
}
if sysVal, ok := studentObj["system"].(map[string]interface{}); ok && sysVal != nil {
var sysID int64
if sID, exists := sysVal["id"]; exists {
switch v := sID.(type) {
case float64:
sysID = int64(v)
case int64:
sysID = v
}
}
sysName, _ := sysVal["name"].(string)
if sysID > 0 {
student.SystemID = &sysID
student.SystemName = &sysName
}
}
students = append(students, student)
}
return students, nil
}