t1
This commit is contained in:
26
server/go.mod
Normal file
26
server/go.mod
Normal file
@@ -0,0 +1,26 @@
|
||||
module server
|
||||
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/gofiber/fiber/v2 v2.52.13 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.51.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.20.0 // indirect
|
||||
gorm.io/driver/mysql v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.2 // indirect
|
||||
)
|
||||
43
server/go.sum
Normal file
43
server/go.sum
Normal file
@@ -0,0 +1,43 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk=
|
||||
github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
|
||||
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
|
||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
44
server/internal/db/db.go
Normal file
44
server/internal/db/db.go
Normal 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{},
|
||||
)
|
||||
}
|
||||
298
server/internal/handlers/handlers.go
Normal file
298
server/internal/handlers/handlers.go
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
61
server/internal/models/models.go
Normal file
61
server/internal/models/models.go
Normal 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" }
|
||||
199
server/internal/qldt/client.go
Normal file
199
server/internal/qldt/client.go
Normal 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
|
||||
}
|
||||
556
server/internal/syncjobs/jobs.go
Normal file
556
server/internal/syncjobs/jobs.go
Normal 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
|
||||
}
|
||||
99
server/main.go
Normal file
99
server/main.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"server/internal/db"
|
||||
"server/internal/handlers"
|
||||
"server/internal/qldt"
|
||||
"server/internal/syncjobs"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Nạp file env nếu có
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Println("Note: .env file not found or couldn't be loaded, using system environments.")
|
||||
}
|
||||
|
||||
// Đọc cấu hình database
|
||||
dbHost := getEnv("DB_HOST", "127.0.0.1")
|
||||
dbPort := getEnv("DB_PORT", "3306")
|
||||
dbUser := getEnv("DB_USER", "naruto")
|
||||
dbPass := getEnv("DB_PASSWORD", "29121999aA@")
|
||||
dbName := getEnv("DB_NAME", "simple_care")
|
||||
|
||||
// Đọc token và link QLĐT
|
||||
qldtToken := getEnv("QLDT_TOKEN", "")
|
||||
qldtBaseURL := getEnv("QLDT_BASE_URL", "https://apiportal.rikkei.edu.vn")
|
||||
qldtOrigin := getEnv("QLDT_ORIGIN", "https://qldt.rikkei.edu.vn")
|
||||
|
||||
if qldtToken == "" {
|
||||
log.Println("WARNING: QLDT_TOKEN is empty! Sync features will fail until QLDT_TOKEN is configured in environment.")
|
||||
}
|
||||
|
||||
// 1. Kết nối DB & Chạy Migration
|
||||
gormDB, err := db.ConnectDB(dbHost, dbPort, dbUser, dbPass, dbName)
|
||||
if err != nil {
|
||||
log.Fatalf("Database connection failed: %v", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(gormDB); err != nil {
|
||||
log.Fatalf("Database migration failed: %v", err)
|
||||
}
|
||||
|
||||
// 2. Tạo Client QLĐT & Sync Jobs
|
||||
qldtClient := qldt.NewClient(qldtBaseURL, qldtOrigin)
|
||||
classesJob := syncjobs.NewClassesSyncJob()
|
||||
studentsJob := syncjobs.NewStudentsSyncJob()
|
||||
|
||||
// 3. Khởi tạo Fiber Web Server
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "Simple Care Sync Backend",
|
||||
})
|
||||
|
||||
// Bật CORS để Client có thể gọi API mà không bị chặn
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOrigins: "*",
|
||||
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
|
||||
AllowMethods: "GET, POST, PATCH, PUT, DELETE, OPTIONS",
|
||||
}))
|
||||
|
||||
// 4. Setup Routes
|
||||
api := app.Group("/api")
|
||||
|
||||
// Dashboard Stats
|
||||
api.Get("/stats", handlers.GetStatsHandler(gormDB))
|
||||
|
||||
// Classes endpoints
|
||||
api.Get("/classes", handlers.ListClassesHandler(gormDB))
|
||||
api.Patch("/classes/:rkId/studying", handlers.PatchClassStudyingHandler(gormDB))
|
||||
api.Get("/classes/:rkId/students", handlers.ListClassStudentsHandler(gormDB))
|
||||
|
||||
// Students endpoints
|
||||
api.Get("/students", handlers.ListAllStudentsHandler(gormDB))
|
||||
|
||||
// Sync endpoints
|
||||
api.Post("/sync/classes/start", handlers.StartClassesSyncHandler(gormDB, qldtClient, classesJob, qldtToken))
|
||||
api.Get("/sync/classes/status", handlers.GetClassesSyncStatusHandler(classesJob))
|
||||
api.Post("/sync/students/start", handlers.StartStudentsSyncHandler(gormDB, qldtClient, studentsJob, qldtToken))
|
||||
api.Get("/sync/students/status", handlers.GetStudentsSyncStatusHandler(studentsJob))
|
||||
|
||||
// Chạy app ở port 8080
|
||||
port := getEnv("PORT", "8080")
|
||||
log.Printf("Server starting on port %s...", port)
|
||||
if err := app.Listen(":" + port); err != nil {
|
||||
log.Fatalf("Failed to start web server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, defaultVal string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
Reference in New Issue
Block a user