core v1
This commit is contained in:
@@ -1 +1 @@
|
||||
QLDT_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InBodW9jbnRiQHJpa2tlaWFjYWRlbXkuY29tIiwibmFtZSI6Ik5ndXnhu4VuIFRoYW5oIELDrG5oIFBoxrDhu5tjIiwiaWQiOjI0LCJyb2xlIjpbeyJpZCI6MSwibmFtZSI6IkFETUlOIn0seyJpZCI6MywibmFtZSI6IlRFQUNIRVIifV0sInR5cGUiOiJ1c2VyIiwiaWF0IjoxNzgyNjg5MjY5LCJleHAiOjE3ODI3NzU2Njl9.0DDBte7Mm9TJhMRs5cvqBkBK-YPi-79XB-qWcrzC-SI
|
||||
QLDT_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InBodW9jbnRiQHJpa2tlaWFjYWRlbXkuY29tIiwibmFtZSI6Ik5ndXnhu4VuIFRoYW5oIELDrG5oIFBoxrDhu5tjIiwiaWQiOjI0LCJyb2xlIjpbeyJpZCI6MSwibmFtZSI6IkFETUlOIn0seyJpZCI6MywibmFtZSI6IlRFQUNIRVIifV0sInR5cGUiOiJ1c2VyIiwiaWF0IjoxNzgyNzc3NzEzLCJleHAiOjE3ODI4NjQxMTN9.bj4nMPYZWVIsuiuA3XHLtc8yFFwAC9MDlPx-FnIM6z4
|
||||
@@ -5,8 +5,10 @@ go 1.26.3
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
github.com/fasthttp/websocket v1.5.3 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/gofiber/fiber/v2 v2.52.13 // indirect
|
||||
github.com/gofiber/websocket/v2 v2.2.1 // 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
|
||||
@@ -16,6 +18,7 @@ require (
|
||||
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/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // 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
|
||||
|
||||
@@ -2,10 +2,14 @@ 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/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek=
|
||||
github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs=
|
||||
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/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w=
|
||||
github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU=
|
||||
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=
|
||||
@@ -25,6 +29,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T
|
||||
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/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
|
||||
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
|
||||
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=
|
||||
|
||||
4
server/internal/constants/apps.go
Normal file
4
server/internal/constants/apps.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package constants
|
||||
|
||||
// DefaultAllowedApps — whitelist mặc định khi kích hoạt lớp (từ khóa khớp tên tiến trình hoặc tiêu đề cửa sổ).
|
||||
const DefaultAllowedApps = "chrome,idea64,vscode,wails,simple_care,client"
|
||||
96
server/internal/db/active_class.go
Normal file
96
server/internal/db/active_class.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func scheduleDayIndex(t time.Time) int {
|
||||
wd := t.Weekday()
|
||||
if wd == time.Sunday {
|
||||
return 6
|
||||
}
|
||||
return int(wd) - 1
|
||||
}
|
||||
|
||||
func parseTimeMinutes(tStr string) int {
|
||||
parts := strings.Split(tStr, ":")
|
||||
if len(parts) != 2 {
|
||||
return 0
|
||||
}
|
||||
hours, _ := strconv.Atoi(parts[0])
|
||||
mins, _ := strconv.Atoi(parts[1])
|
||||
return hours*60 + mins
|
||||
}
|
||||
|
||||
func studentClassIDs(db *gorm.DB, studentRkID int64) []int64 {
|
||||
var classRkIDs []int64
|
||||
_ = db.Model(&models.ClassStudent{}).
|
||||
Where("student_rk_id = ?", studentRkID).
|
||||
Pluck("class_rk_id", &classRkIDs).Error
|
||||
return classRkIDs
|
||||
}
|
||||
|
||||
// FindActiveClassAndPeriodForStudent trả về lớp + ca đang trong khung giờ học.
|
||||
func FindActiveClassAndPeriodForStudent(db *gorm.DB, studentRkID int64) (int64, int) {
|
||||
classRkIDs := studentClassIDs(db, studentRkID)
|
||||
if len(classRkIDs) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
var schedules []models.ClassSchedule
|
||||
if err := db.Where("class_rk_id IN ? AND is_active = ?", classRkIDs, true).Find(&schedules).Error; err != nil || len(schedules) == 0 {
|
||||
return classRkIDs[0], 1
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
day := scheduleDayIndex(now)
|
||||
currMin := now.Hour()*60 + now.Minute()
|
||||
|
||||
for _, s := range schedules {
|
||||
if s.DayOfWeek != day {
|
||||
continue
|
||||
}
|
||||
startMin := parseTimeMinutes(s.StartTime)
|
||||
endMin := parseTimeMinutes(s.EndTime)
|
||||
if currMin >= startMin && currMin <= endMin {
|
||||
period := s.Period
|
||||
if period < 1 {
|
||||
period = 1
|
||||
}
|
||||
return s.ClassRkID, period
|
||||
}
|
||||
}
|
||||
|
||||
return classRkIDs[0], 1
|
||||
}
|
||||
|
||||
func FindActiveClassForStudent(db *gorm.DB, studentRkID int64) int64 {
|
||||
classID, _ := FindActiveClassAndPeriodForStudent(db, studentRkID)
|
||||
return classID
|
||||
}
|
||||
|
||||
// ResolveSessionPeriodForDate lấy ca theo ngày (dùng khi xem nhật ký quá khứ).
|
||||
func ResolveSessionPeriodForDate(db *gorm.DB, classRkID int64, date string, period int) int {
|
||||
if period > 0 {
|
||||
return period
|
||||
}
|
||||
day, err := time.Parse("2006-01-02", date)
|
||||
if err != nil {
|
||||
return 1
|
||||
}
|
||||
var slots []models.ClassSchedule
|
||||
if err := db.Where("class_rk_id = ? AND day_of_week = ? AND is_active = ?", classRkID, scheduleDayIndex(day), true).
|
||||
Order("period asc, start_time asc").Find(&slots).Error; err != nil || len(slots) == 0 {
|
||||
return 1
|
||||
}
|
||||
if slots[0].Period > 0 {
|
||||
return slots[0].Period
|
||||
}
|
||||
return 1
|
||||
}
|
||||
85
server/internal/db/courses.go
Normal file
85
server/internal/db/courses.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"server/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type ClassCourseDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CourseCode string `json:"courseCode"`
|
||||
}
|
||||
|
||||
func UpsertClassCourses(db *gorm.DB, classRkID int64, items []ClassCourseDTO) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
rows := make([]models.ClassCourse, 0, len(items))
|
||||
for _, it := range items {
|
||||
if it.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, models.ClassCourse{
|
||||
ClassRkID: classRkID,
|
||||
CourseRkID: it.ID,
|
||||
Name: it.Name,
|
||||
CourseCode: it.CourseCode,
|
||||
})
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
return db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "class_rk_id"}, {Name: "course_rk_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"name", "course_code", "updated_at"}),
|
||||
}).Create(&rows).Error
|
||||
}
|
||||
|
||||
func ListClassCourses(db *gorm.DB, classRkID int64) ([]ClassCourseDTO, error) {
|
||||
var rows []models.ClassCourse
|
||||
if err := db.Where("class_rk_id = ?", classRkID).Order("name asc").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ClassCourseDTO, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, ClassCourseDTO{
|
||||
ID: r.CourseRkID,
|
||||
Name: r.Name,
|
||||
CourseCode: r.CourseCode,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func MergeClassCourses(base []ClassCourseDTO, extra []ClassCourseDTO) []ClassCourseDTO {
|
||||
merged := make(map[int64]ClassCourseDTO, len(base)+len(extra))
|
||||
for _, c := range base {
|
||||
if c.ID > 0 {
|
||||
merged[c.ID] = c
|
||||
}
|
||||
}
|
||||
for _, c := range extra {
|
||||
if c.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
if existing, ok := merged[c.ID]; ok {
|
||||
if c.Name != "" {
|
||||
existing.Name = c.Name
|
||||
}
|
||||
if c.CourseCode != "" {
|
||||
existing.CourseCode = c.CourseCode
|
||||
}
|
||||
merged[c.ID] = existing
|
||||
} else {
|
||||
merged[c.ID] = c
|
||||
}
|
||||
}
|
||||
out := make([]ClassCourseDTO, 0, len(merged))
|
||||
for _, c := range merged {
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -3,6 +3,9 @@ package db
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/internal/models"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
@@ -35,10 +38,110 @@ func ConnectDB(host, port, user, password, dbName string) (*gorm.DB, error) {
|
||||
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
log.Println("Running AutoMigrate...")
|
||||
return db.AutoMigrate(
|
||||
if err := migrateLegacyWifiTables(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&models.Class{},
|
||||
&models.ClassCourseLink{},
|
||||
&models.Student{},
|
||||
&models.ClassStudent{},
|
||||
)
|
||||
&models.ClassSchedule{},
|
||||
&models.ClassCourse{},
|
||||
&models.ClassAllowedApp{},
|
||||
&models.AppPoolEntry{},
|
||||
&models.WifiPoolEntry{},
|
||||
&models.AcceptedWifi{},
|
||||
&models.StudentSession{},
|
||||
&models.AttendanceResult{},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := purgeLegacyWifiWithoutBSSID(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return migrateLegacyDiscoveredApps(db)
|
||||
}
|
||||
|
||||
// Bảng WiFi cũ dùng ssid_key làm PK; schema mới cần id + bssid_key — GORM không tự đổi PK.
|
||||
func migrateLegacyWifiTables(db *gorm.DB) error {
|
||||
if db.Migrator().HasTable("accepted_wifis") {
|
||||
needsReset := !db.Migrator().HasColumn(&models.AcceptedWifi{}, "id") ||
|
||||
!db.Migrator().HasColumn(&models.AcceptedWifi{}, "bssid_key")
|
||||
if needsReset {
|
||||
log.Println("Dropping legacy accepted_wifis (SSID-only schema) — will recreate with BSSID...")
|
||||
if err := db.Migrator().DropTable("accepted_wifis"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasTable("wifi_pool") {
|
||||
needsReset := !db.Migrator().HasColumn(&models.WifiPoolEntry{}, "bssid_key")
|
||||
if needsReset {
|
||||
log.Println("Dropping legacy wifi_pool (SSID-only schema) — will recreate with BSSID...")
|
||||
if err := db.Migrator().DropTable("wifi_pool"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func purgeLegacyWifiWithoutBSSID(db *gorm.DB) error {
|
||||
_ = db.Where("bssid = '' OR bssid_key = '' OR LENGTH(bssid_key) <> 12").Delete(&models.AcceptedWifi{}).Error
|
||||
_ = db.Where("bssid = '' OR bssid_key = '' OR LENGTH(bssid_key) <> 12").Delete(&models.WifiPoolEntry{}).Error
|
||||
return nil
|
||||
}
|
||||
|
||||
// Gộp dữ liệu cũ (theo lớp) vào kho global nếu bảng discovered_apps còn tồn tại.
|
||||
func migrateLegacyDiscoveredApps(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable("discovered_apps") {
|
||||
return nil
|
||||
}
|
||||
type legacyRow struct {
|
||||
ProcessName string
|
||||
WindowTitle string
|
||||
Keyword string
|
||||
HitCount int
|
||||
LastSeenAt time.Time
|
||||
StudentRkID int64
|
||||
ClassRkID int64
|
||||
}
|
||||
var rows []legacyRow
|
||||
if err := db.Table("discovered_apps").Find(&rows).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, r := range rows {
|
||||
pKey := strings.ToLower(strings.TrimSpace(r.ProcessName))
|
||||
if pKey == "" {
|
||||
continue
|
||||
}
|
||||
kw := strings.TrimSuffix(pKey, ".exe")
|
||||
if r.Keyword != "" {
|
||||
kw = r.Keyword
|
||||
}
|
||||
var existing models.AppPoolEntry
|
||||
if err := db.Where("process_key = ?", pKey).First(&existing).Error; err == nil {
|
||||
existing.HitCount += r.HitCount
|
||||
if r.LastSeenAt.After(existing.LastSeenAt) {
|
||||
existing.LastSeenAt = r.LastSeenAt
|
||||
existing.WindowTitle = r.WindowTitle
|
||||
existing.ProcessName = r.ProcessName
|
||||
existing.LastStudentRkID = r.StudentRkID
|
||||
existing.LastClassRkID = r.ClassRkID
|
||||
}
|
||||
_ = db.Save(&existing).Error
|
||||
} else {
|
||||
_ = db.Create(&models.AppPoolEntry{
|
||||
ProcessName: r.ProcessName,
|
||||
ProcessKey: pKey,
|
||||
WindowTitle: r.WindowTitle,
|
||||
Keyword: kw,
|
||||
HitCount: r.HitCount,
|
||||
LastSeenAt: r.LastSeenAt,
|
||||
LastStudentRkID: r.StudentRkID,
|
||||
LastClassRkID: r.ClassRkID,
|
||||
}).Error
|
||||
}
|
||||
}
|
||||
return db.Migrator().DropTable("discovered_apps")
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package handlers
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
internalDb "server/internal/db"
|
||||
"server/internal/models"
|
||||
"server/internal/qldt"
|
||||
"server/internal/syncjobs"
|
||||
@@ -126,31 +128,8 @@ func ListClassesHandler(db *gorm.DB) fiber.Handler {
|
||||
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,
|
||||
@@ -163,7 +142,7 @@ func ListClassesHandler(db *gorm.DB) fiber.Handler {
|
||||
SystemCode: r.SystemCode,
|
||||
SystemName: r.SystemName,
|
||||
IsStudying: r.IsStudying,
|
||||
Courses: courses,
|
||||
Courses: []courseRefItem{},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -176,6 +155,48 @@ func ListClassesHandler(db *gorm.DB) fiber.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/classes/:rkId - Lấy thông tin chi tiết một lớp học
|
||||
func GetClassHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
rkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
|
||||
}
|
||||
|
||||
var row models.Class
|
||||
if err := db.Where("rk_id = ?", rkID).First(&row).Error; err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Class not found"})
|
||||
}
|
||||
|
||||
coursesOut := []courseRefItem{}
|
||||
if cached, err := internalDb.ListClassCourses(db, rkID); err == nil {
|
||||
for _, co := range cached {
|
||||
coursesOut = append(coursesOut, courseRefItem{
|
||||
CourseRkID: co.ID,
|
||||
CourseName: co.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
out := classItem{
|
||||
RkID: row.RkID,
|
||||
Name: row.Name,
|
||||
ClassCode: row.ClassCode,
|
||||
Type: row.Type,
|
||||
StudentCount: row.StudentCount,
|
||||
SpecializeRkID: row.SpecializeRkID,
|
||||
SpecializeName: row.SpecializeName,
|
||||
SystemRkID: row.SystemRkID,
|
||||
SystemCode: row.SystemCode,
|
||||
SystemName: row.SystemName,
|
||||
IsStudying: row.IsStudying,
|
||||
Courses: coursesOut,
|
||||
}
|
||||
|
||||
return c.JSON(out)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -200,6 +221,45 @@ func PatchClassStudyingHandler(db *gorm.DB) fiber.Handler {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
if body.IsStudying {
|
||||
// 1. Kiểm tra xem lớp đã cấu hình app được phép chưa
|
||||
var appConfig models.ClassAllowedApp
|
||||
errApp := db.Where("class_rk_id = ?", rkID).First(&appConfig).Error
|
||||
if errApp == gorm.ErrRecordNotFound || strings.TrimSpace(appConfig.Keywords) == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Không thể kích hoạt trạng thái đang học: Lớp chưa được cấu hình danh sách ứng dụng cho phép."})
|
||||
}
|
||||
|
||||
// 2. Kiểm tra xem có trong giờ học không
|
||||
var schedules []models.ClassSchedule
|
||||
errSched := db.Where("class_rk_id = ?", rkID).Find(&schedules).Error
|
||||
inSchedule := false
|
||||
if errSched == nil && len(schedules) > 0 {
|
||||
now := time.Now()
|
||||
goWeekday := now.Weekday()
|
||||
var scheduleDay int
|
||||
if goWeekday == time.Sunday {
|
||||
scheduleDay = 6
|
||||
} else {
|
||||
scheduleDay = int(goWeekday) - 1
|
||||
}
|
||||
currMin := now.Hour()*60 + now.Minute()
|
||||
for _, s := range schedules {
|
||||
if s.DayOfWeek == scheduleDay {
|
||||
startMin := parseTimeMinutes(s.StartTime)
|
||||
endMin := parseTimeMinutes(s.EndTime)
|
||||
if currMin >= startMin && currMin <= endMin {
|
||||
inSchedule = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !inSchedule {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Không thể kích hoạt trạng thái đang học: Hiện tại ngoài giờ học theo lịch đã cài đặt."})
|
||||
}
|
||||
}
|
||||
|
||||
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()})
|
||||
@@ -236,7 +296,32 @@ func ListClassStudentsHandler(db *gorm.DB) fiber.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"data": students, "total": len(students)})
|
||||
type studentRow struct {
|
||||
ID uint `json:"id"`
|
||||
RkID int64 `json:"rkId"`
|
||||
StudentCode string `json:"studentCode"`
|
||||
FullName string `json:"fullName"`
|
||||
Phone *string `json:"phone"`
|
||||
Email string `json:"email"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
out := make([]studentRow, 0, len(students))
|
||||
for _, st := range students {
|
||||
row := studentRow{
|
||||
ID: st.ID,
|
||||
RkID: st.RkID,
|
||||
StudentCode: st.StudentCode,
|
||||
FullName: st.FullName,
|
||||
Phone: st.Phone,
|
||||
Email: st.Email,
|
||||
}
|
||||
if st.Avatar != nil {
|
||||
row.Avatar = *st.Avatar
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"data": out, "total": len(out)})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
462
server/internal/handlers/handlers_attendance.go
Normal file
462
server/internal/handlers/handlers_attendance.go
Normal file
@@ -0,0 +1,462 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
internalDb "server/internal/db"
|
||||
"server/internal/models"
|
||||
"server/internal/qldt"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var standardShiftTemplate = []struct {
|
||||
Period int
|
||||
StartTime string
|
||||
EndTime string
|
||||
}{
|
||||
{1, "07:00", "09:00"},
|
||||
{2, "09:10", "11:10"},
|
||||
{3, "12:10", "14:10"},
|
||||
{4, "14:20", "16:20"},
|
||||
}
|
||||
|
||||
// GET /api/classes/:rkId/courses
|
||||
func GetClassCoursesHandler(db *gorm.DB, qldtClient *qldt.Client, token string) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
rkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
|
||||
}
|
||||
|
||||
var liveErr error
|
||||
if token != "" {
|
||||
res, err := qldtClient.GetClassCourses(context.Background(), token, rkID)
|
||||
if err == nil && len(res.Data) > 0 {
|
||||
dtos := qldtCourseItemsToDTO(res.Data)
|
||||
_ = internalDb.UpsertClassCourses(db, rkID, dtos)
|
||||
return c.JSON(fiber.Map{"data": dtos, "source": "qldt"})
|
||||
}
|
||||
liveErr = err
|
||||
} else {
|
||||
liveErr = fmt.Errorf("QLDT_TOKEN chưa cấu hình")
|
||||
}
|
||||
|
||||
cached, err := internalDb.ListClassCourses(db, rkID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if len(cached) > 0 {
|
||||
resp := fiber.Map{
|
||||
"data": cached,
|
||||
"source": "cache",
|
||||
"cached": true,
|
||||
}
|
||||
if liveErr != nil {
|
||||
resp["warning"] = liveErr.Error()
|
||||
}
|
||||
return c.JSON(resp)
|
||||
}
|
||||
|
||||
if liveErr != nil {
|
||||
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{
|
||||
"error": liveErr.Error(),
|
||||
"hint": "Cập nhật QLDT_TOKEN trong server/.env rồi chạy Sync lớp học để tải danh sách môn.",
|
||||
})
|
||||
}
|
||||
return c.JSON(fiber.Map{"data": []internalDb.ClassCourseDTO{}})
|
||||
}
|
||||
}
|
||||
|
||||
func qldtCourseItemsToDTO(items []qldt.CourseItem) []internalDb.ClassCourseDTO {
|
||||
out := make([]internalDb.ClassCourseDTO, 0, len(items))
|
||||
for _, it := range items {
|
||||
if it.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, internalDb.ClassCourseDTO{
|
||||
ID: it.ID,
|
||||
Name: it.Name,
|
||||
CourseCode: it.CourseCode,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// POST /api/classes/:rkId/schedule/apply-template
|
||||
func ApplyScheduleTemplateHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
rkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
|
||||
}
|
||||
|
||||
created := 0
|
||||
skipped := 0
|
||||
for day := 0; day <= 4; day++ {
|
||||
for _, slot := range standardShiftTemplate {
|
||||
var existing int64
|
||||
db.Model(&models.ClassSchedule{}).
|
||||
Where("class_rk_id = ? AND day_of_week = ? AND period = ?", rkID, day, slot.Period).
|
||||
Count(&existing)
|
||||
if existing > 0 {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
row := models.ClassSchedule{
|
||||
ClassRkID: rkID,
|
||||
DayOfWeek: day,
|
||||
Period: slot.Period,
|
||||
StartTime: slot.StartTime,
|
||||
EndTime: slot.EndTime,
|
||||
CourseID: 0,
|
||||
CourseName: "",
|
||||
IsActive: false,
|
||||
}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
created++
|
||||
}
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true, "created": created, "skipped": skipped})
|
||||
}
|
||||
}
|
||||
|
||||
type attendanceRow struct {
|
||||
StudentRkID int64 `json:"studentRkId"`
|
||||
StudentCode string `json:"studentCode"`
|
||||
FullName string `json:"fullName"`
|
||||
Email string `json:"email"`
|
||||
Status int `json:"status"`
|
||||
StatusLabel string `json:"statusLabel"`
|
||||
OnlineMinutes int `json:"onlineMinutes"`
|
||||
StatusEditedByTeacher bool `json:"statusEditedByTeacher"`
|
||||
}
|
||||
|
||||
func resolveScheduleForPeriod(db *gorm.DB, classRkID int64, dayOfWeek, period int) (*models.ClassSchedule, error) {
|
||||
var row models.ClassSchedule
|
||||
err := db.Where("class_rk_id = ? AND day_of_week = ? AND period = ?", classRkID, dayOfWeek, period).First(&row).Error
|
||||
if err == nil {
|
||||
return &row, nil
|
||||
}
|
||||
var slots []models.ClassSchedule
|
||||
if err := db.Where("class_rk_id = ? AND day_of_week = ?", classRkID, dayOfWeek).
|
||||
Order("start_time").Find(&slots).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if period < 1 || period > len(slots) {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
return &slots[period-1], nil
|
||||
}
|
||||
|
||||
func recalcAttendanceForPeriod(db *gorm.DB, classRkID int64, date string, period int, dayOfWeek int) error {
|
||||
sched, err := resolveScheduleForPeriod(db, classRkID, dayOfWeek, period)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shiftMins := qldt.ShiftDurationMinutes(sched.StartTime, sched.EndTime)
|
||||
|
||||
var mappings []models.ClassStudent
|
||||
if err := db.Where("class_rk_id = ?", classRkID).Find(&mappings).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, m := range mappings {
|
||||
var session models.StudentSession
|
||||
onlineMins := 0
|
||||
periods := []int{period}
|
||||
if period == 1 {
|
||||
periods = append(periods, 0)
|
||||
}
|
||||
err := db.Where("student_rk_id = ? AND class_rk_id = ? AND session_date = ? AND period IN ?",
|
||||
m.StudentRkID, classRkID, date, periods).
|
||||
Order("period DESC").First(&session).Error
|
||||
if err == nil {
|
||||
onlineMins = session.OnlineSeconds / 60
|
||||
}
|
||||
|
||||
status := qldt.InferStatusFromOnlineMinutes(onlineMins, shiftMins)
|
||||
label := qldt.AttendanceStatusLabel(status)
|
||||
|
||||
row := models.AttendanceResult{
|
||||
ClassRkID: classRkID,
|
||||
SessionDate: date,
|
||||
Period: period,
|
||||
StudentRkID: m.StudentRkID,
|
||||
Status: status,
|
||||
StatusLabel: label,
|
||||
OnlineMinutes: onlineMins,
|
||||
}
|
||||
|
||||
if err := db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{
|
||||
{Name: "class_rk_id"},
|
||||
{Name: "session_date"},
|
||||
{Name: "period"},
|
||||
{Name: "student_rk_id"},
|
||||
},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"online_minutes": gorm.Expr("VALUES(online_minutes)"),
|
||||
"status": gorm.Expr("IF(status_edited_by_teacher, status, VALUES(status))"),
|
||||
"status_label": gorm.Expr("IF(status_edited_by_teacher, status_label, VALUES(status_label))"),
|
||||
"updated_at": time.Now(),
|
||||
}),
|
||||
}).Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scheduleDayFromDate(date string) (int, error) {
|
||||
t, err := time.Parse("2006-01-02", date)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
wd := t.Weekday()
|
||||
if wd == time.Sunday {
|
||||
return 6, nil
|
||||
}
|
||||
return int(wd) - 1, nil
|
||||
}
|
||||
|
||||
// GET /api/classes/:rkId/attendance?date=&period=
|
||||
func GetClassAttendanceHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
classRkID, _ := strconv.ParseInt(c.Params("rkId"), 10, 64)
|
||||
date := c.Query("date", qldt.NowDateVN())
|
||||
period, _ := strconv.Atoi(c.Query("period", "1"))
|
||||
if period < 1 {
|
||||
period = 1
|
||||
}
|
||||
|
||||
dayOfWeek, err := scheduleDayFromDate(date)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid date"})
|
||||
}
|
||||
|
||||
_ = recalcAttendanceForPeriod(db, classRkID, date, period, dayOfWeek)
|
||||
|
||||
var mappings []models.ClassStudent
|
||||
db.Where("class_rk_id = ?", classRkID).Find(&mappings)
|
||||
studentIDs := make([]int64, 0, len(mappings))
|
||||
for _, m := range mappings {
|
||||
studentIDs = append(studentIDs, m.StudentRkID)
|
||||
}
|
||||
if len(studentIDs) == 0 {
|
||||
return c.JSON(fiber.Map{"data": []any{}, "period": period, "date": date})
|
||||
}
|
||||
|
||||
var students []models.Student
|
||||
db.Where("rk_id IN ?", studentIDs).Find(&students)
|
||||
|
||||
var results []models.AttendanceResult
|
||||
db.Where("class_rk_id = ? AND session_date = ? AND period = ?", classRkID, date, period).Find(&results)
|
||||
resMap := map[int64]models.AttendanceResult{}
|
||||
for _, r := range results {
|
||||
resMap[r.StudentRkID] = r
|
||||
}
|
||||
|
||||
out := make([]attendanceRow, 0, len(students))
|
||||
for _, st := range students {
|
||||
r, ok := resMap[st.RkID]
|
||||
row := attendanceRow{
|
||||
StudentRkID: st.RkID,
|
||||
StudentCode: st.StudentCode,
|
||||
FullName: st.FullName,
|
||||
Email: st.Email,
|
||||
}
|
||||
if ok {
|
||||
row.Status = r.Status
|
||||
row.StatusLabel = r.StatusLabel
|
||||
row.OnlineMinutes = r.OnlineMinutes
|
||||
row.StatusEditedByTeacher = r.StatusEditedByTeacher
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
|
||||
sched, _ := resolveScheduleForPeriod(db, classRkID, dayOfWeek, period)
|
||||
shiftInfo := fiber.Map{}
|
||||
if sched != nil {
|
||||
shiftInfo = fiber.Map{
|
||||
"startTime": sched.StartTime,
|
||||
"endTime": sched.EndTime,
|
||||
"courseId": sched.CourseID,
|
||||
"courseName": sched.CourseName,
|
||||
"isActive": sched.IsActive,
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"data": out, "period": period, "date": date, "shift": shiftInfo})
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/classes/:rkId/attendance/status
|
||||
func UpdateAttendanceStatusHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
classRkID, _ := strconv.ParseInt(c.Params("rkId"), 10, 64)
|
||||
var req struct {
|
||||
Date string `json:"date"`
|
||||
Period int `json:"period"`
|
||||
StudentRkID int64 `json:"studentRkId"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
|
||||
}
|
||||
if req.Date == "" {
|
||||
req.Date = qldt.NowDateVN()
|
||||
}
|
||||
if req.Period < 1 {
|
||||
req.Period = 1
|
||||
}
|
||||
|
||||
label := qldt.AttendanceStatusLabel(req.Status)
|
||||
var row models.AttendanceResult
|
||||
err := db.Where("class_rk_id = ? AND session_date = ? AND period = ? AND student_rk_id = ?",
|
||||
classRkID, req.Date, req.Period, req.StudentRkID).First(&row).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
row = models.AttendanceResult{
|
||||
ClassRkID: classRkID,
|
||||
SessionDate: req.Date,
|
||||
Period: req.Period,
|
||||
StudentRkID: req.StudentRkID,
|
||||
Status: req.Status,
|
||||
StatusLabel: label,
|
||||
StatusEditedByTeacher: true,
|
||||
}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
} else if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
} else {
|
||||
row.Status = req.Status
|
||||
row.StatusLabel = label
|
||||
row.StatusEditedByTeacher = true
|
||||
if err := db.Save(&row).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true, "data": row})
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/classes/:rkId/attendance/push-qldt
|
||||
func PushAttendanceToQLDTHandler(db *gorm.DB, qldtClient *qldt.Client, token string) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
classRkID, _ := strconv.ParseInt(c.Params("rkId"), 10, 64)
|
||||
var req struct {
|
||||
Date string `json:"date"`
|
||||
Period int `json:"period"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
|
||||
}
|
||||
if req.Date == "" {
|
||||
req.Date = qldt.NowDateVN()
|
||||
}
|
||||
if req.Period < 1 {
|
||||
req.Period = 1
|
||||
}
|
||||
if token == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "QLDT_TOKEN chưa cấu hình"})
|
||||
}
|
||||
|
||||
dayOfWeek, _ := scheduleDayFromDate(req.Date)
|
||||
sched, err := resolveScheduleForPeriod(db, classRkID, dayOfWeek, req.Period)
|
||||
if err != nil || sched == nil || sched.CourseID == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Ca học chưa cấu hình môn học (courseId)"})
|
||||
}
|
||||
|
||||
userID, err := qldt.ParseUserIDFromToken(token)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Không đọc được users_id từ QLDT_TOKEN: " + err.Error()})
|
||||
}
|
||||
|
||||
var results []models.AttendanceResult
|
||||
db.Where("class_rk_id = ? AND session_date = ? AND period = ?", classRkID, req.Date, req.Period).Find(&results)
|
||||
if len(results) == 0 {
|
||||
_ = recalcAttendanceForPeriod(db, classRkID, req.Date, req.Period, dayOfWeek)
|
||||
db.Where("class_rk_id = ? AND session_date = ? AND period = ?", classRkID, req.Date, req.Period).Find(&results)
|
||||
}
|
||||
|
||||
students := make([]qldt.AttendanceStudent, 0, len(results))
|
||||
for _, r := range results {
|
||||
students = append(students, qldt.AttendanceStudent{
|
||||
StudentID: r.StudentRkID,
|
||||
Status: r.Status,
|
||||
})
|
||||
}
|
||||
|
||||
payload := &qldt.AttendancePayload{
|
||||
UsersID: userID,
|
||||
CoursesID: int(sched.CourseID),
|
||||
Period: req.Period,
|
||||
Students: students,
|
||||
ClassID: classRkID,
|
||||
Date: req.Date,
|
||||
Type: "OFFLINE",
|
||||
}
|
||||
|
||||
body, code, err := qldtClient.SaveAttendance(context.Background(), token, payload)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error(), "statusCode": code, "body": string(body)})
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
db.Model(&models.AttendanceResult{}).
|
||||
Where("class_rk_id = ? AND session_date = ? AND period = ?", classRkID, req.Date, req.Period).
|
||||
Update("pushed_to_qldt_at", now)
|
||||
|
||||
return c.JSON(fiber.Map{"ok": true, "message": "Đã đẩy điểm danh lên QLĐT", "studentCount": len(students)})
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/classes/:rkId/attendance/shifts?date=
|
||||
func ListAttendanceShiftsHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
classRkID, _ := strconv.ParseInt(c.Params("rkId"), 10, 64)
|
||||
date := c.Query("date", qldt.NowDateVN())
|
||||
dayOfWeek, err := scheduleDayFromDate(date)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid date"})
|
||||
}
|
||||
|
||||
var slots []models.ClassSchedule
|
||||
db.Where("class_rk_id = ? AND day_of_week = ?", classRkID, dayOfWeek).
|
||||
Order("period, start_time").Find(&slots)
|
||||
|
||||
if len(slots) == 0 {
|
||||
for p := 1; p <= 4; p++ {
|
||||
slots = append(slots, models.ClassSchedule{
|
||||
Period: p, DayOfWeek: dayOfWeek, IsActive: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]fiber.Map, 0, len(slots))
|
||||
for _, s := range slots {
|
||||
period := s.Period
|
||||
if period == 0 {
|
||||
period = 1
|
||||
}
|
||||
out = append(out, fiber.Map{
|
||||
"period": period,
|
||||
"startTime": s.StartTime,
|
||||
"endTime": s.EndTime,
|
||||
"courseId": s.CourseID,
|
||||
"courseName": s.CourseName,
|
||||
"isActive": s.IsActive,
|
||||
})
|
||||
}
|
||||
return c.JSON(fiber.Map{"data": out, "date": date, "dayOfWeek": dayOfWeek})
|
||||
}
|
||||
}
|
||||
629
server/internal/handlers/handlers_learning.go
Normal file
629
server/internal/handlers/handlers_learning.go
Normal file
@@ -0,0 +1,629 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
internalDb "server/internal/db"
|
||||
"server/internal/models"
|
||||
"server/internal/util"
|
||||
internalWs "server/internal/websocket"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type classWithScheduleItem struct {
|
||||
RkID int64 `json:"rkId"`
|
||||
Name string `json:"name"`
|
||||
ClassCode string `json:"classCode"`
|
||||
Type string `json:"type"`
|
||||
StudentCount int `json:"studentCount"`
|
||||
SpecializeName string `json:"specializeName"`
|
||||
SystemName string `json:"systemName"`
|
||||
Schedules []models.ClassSchedule `json:"schedules"`
|
||||
AllowedApps string `json:"allowedApps"`
|
||||
}
|
||||
|
||||
// GET /api/classes/schedules - Lấy các lớp có lịch học trong tuần
|
||||
func ListActiveSchedulesHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
// Tìm các classRkId có lịch học
|
||||
var activeClassRkIDs []int64
|
||||
if err := db.Model(&models.ClassSchedule{}).Distinct("class_rk_id").Pluck("class_rk_id", &activeClassRkIDs).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
if len(activeClassRkIDs) == 0 {
|
||||
return c.JSON(fiber.Map{"data": []any{}})
|
||||
}
|
||||
|
||||
// Lấy thông tin lớp học
|
||||
var classes []models.Class
|
||||
if err := db.Where("rk_id IN ?", activeClassRkIDs).Find(&classes).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Lấy toàn bộ lịch học của các lớp này
|
||||
var schedules []models.ClassSchedule
|
||||
if err := db.Where("class_rk_id IN ?", activeClassRkIDs).Order("day_of_week, start_time").Find(&schedules).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Lấy cấu hình allowed apps của các lớp này
|
||||
var allowedApps []models.ClassAllowedApp
|
||||
if err := db.Where("class_rk_id IN ?", activeClassRkIDs).Find(&allowedApps).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Map dữ liệu
|
||||
schedulesMap := map[int64][]models.ClassSchedule{}
|
||||
for _, s := range schedules {
|
||||
schedulesMap[s.ClassRkID] = append(schedulesMap[s.ClassRkID], s)
|
||||
}
|
||||
|
||||
allowedAppsMap := map[int64]string{}
|
||||
for _, a := range allowedApps {
|
||||
allowedAppsMap[a.ClassRkID] = a.Keywords
|
||||
}
|
||||
|
||||
out := make([]classWithScheduleItem, 0, len(classes))
|
||||
for _, cl := range classes {
|
||||
sList := schedulesMap[cl.RkID]
|
||||
if sList == nil {
|
||||
sList = []models.ClassSchedule{}
|
||||
}
|
||||
specName := ""
|
||||
if cl.SpecializeName != nil {
|
||||
specName = *cl.SpecializeName
|
||||
}
|
||||
sysName := ""
|
||||
if cl.SystemName != nil {
|
||||
sysName = *cl.SystemName
|
||||
}
|
||||
|
||||
out = append(out, classWithScheduleItem{
|
||||
RkID: cl.RkID,
|
||||
Name: cl.Name,
|
||||
ClassCode: cl.ClassCode,
|
||||
Type: cl.Type,
|
||||
StudentCount: cl.StudentCount,
|
||||
SpecializeName: specName,
|
||||
SystemName: sysName,
|
||||
Schedules: sList,
|
||||
AllowedApps: allowedAppsMap[cl.RkID],
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"data": out})
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/classes/:rkId/schedule - Lấy lịch học của lớp
|
||||
func GetClassScheduleHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
rkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
|
||||
}
|
||||
|
||||
var schedules []models.ClassSchedule
|
||||
if err := db.Where("class_rk_id = ?", rkID).Order("day_of_week, period, start_time").Find(&schedules).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"data": schedules})
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/classes/:rkId/schedule - Lưu lịch học của lớp
|
||||
func SaveClassScheduleHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
rkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
|
||||
}
|
||||
|
||||
type reqSchedule struct {
|
||||
DayOfWeek int `json:"dayOfWeek"`
|
||||
Period int `json:"period"`
|
||||
StartTime string `json:"startTime"`
|
||||
EndTime string `json:"endTime"`
|
||||
CourseID int64 `json:"courseId"`
|
||||
CourseName string `json:"courseName"`
|
||||
IsActive bool `json:"isActive"`
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Schedules []reqSchedule `json:"schedules"`
|
||||
}
|
||||
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload format"})
|
||||
}
|
||||
|
||||
tx := db.Begin()
|
||||
// Xóa lịch cũ
|
||||
if err := tx.Where("class_rk_id = ?", rkID).Delete(&models.ClassSchedule{}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to clear old schedules"})
|
||||
}
|
||||
|
||||
// Insert lịch mới
|
||||
if len(req.Schedules) > 0 {
|
||||
var newSchedules []models.ClassSchedule
|
||||
for _, s := range req.Schedules {
|
||||
if strings.TrimSpace(s.StartTime) == "" || strings.TrimSpace(s.EndTime) == "" {
|
||||
continue
|
||||
}
|
||||
period := s.Period
|
||||
if period < 1 {
|
||||
period = 1
|
||||
}
|
||||
if period > 4 {
|
||||
period = 4
|
||||
}
|
||||
newSchedules = append(newSchedules, models.ClassSchedule{
|
||||
ClassRkID: rkID,
|
||||
DayOfWeek: s.DayOfWeek,
|
||||
Period: period,
|
||||
StartTime: s.StartTime,
|
||||
EndTime: s.EndTime,
|
||||
CourseID: s.CourseID,
|
||||
CourseName: s.CourseName,
|
||||
IsActive: s.IsActive,
|
||||
})
|
||||
}
|
||||
if err := tx.Create(&newSchedules).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to insert new schedules"})
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Transaction commit failed"})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"ok": true, "message": "Schedule saved successfully"})
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/classes/:rkId/schedule - Xóa lịch học của lớp (rút lớp khỏi active list)
|
||||
func DeleteClassScheduleHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
rkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
|
||||
}
|
||||
|
||||
if err := db.Where("class_rk_id = ?", rkID).Delete(&models.ClassSchedule{}).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"ok": true, "message": "Schedule deleted successfully"})
|
||||
}
|
||||
}
|
||||
|
||||
func parseTimeMinutes(tStr string) int {
|
||||
parts := strings.Split(tStr, ":")
|
||||
if len(parts) != 2 {
|
||||
return 0
|
||||
}
|
||||
hours, _ := strconv.Atoi(parts[0])
|
||||
mins, _ := strconv.Atoi(parts[1])
|
||||
return hours*60 + mins
|
||||
}
|
||||
|
||||
// GET /api/classes/:rkId/allowed-apps - Lấy danh sách app được phép
|
||||
func GetAllowedAppsHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
rkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
|
||||
}
|
||||
|
||||
studentIDStr := c.Query("studentId", "0")
|
||||
studentID, _ := strconv.ParseInt(studentIDStr, 10, 64)
|
||||
if studentID > 0 {
|
||||
resolvedClassID := internalDb.FindActiveClassForStudent(db, studentID)
|
||||
if resolvedClassID > 0 {
|
||||
rkID = resolvedClassID
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Kiểm tra xem lớp đã cấu hình app được phép chưa
|
||||
var appConfig models.ClassAllowedApp
|
||||
errApp := db.Where("class_rk_id = ?", rkID).First(&appConfig).Error
|
||||
hasAppsConfigured := (errApp == nil && strings.TrimSpace(appConfig.Keywords) != "")
|
||||
|
||||
// 2. Kiểm tra xem có trong giờ học không
|
||||
var schedules []models.ClassSchedule
|
||||
errSched := db.Where("class_rk_id = ?", rkID).Find(&schedules).Error
|
||||
inSchedule := false
|
||||
if errSched == nil && len(schedules) > 0 {
|
||||
now := time.Now()
|
||||
goWeekday := now.Weekday()
|
||||
var scheduleDay int
|
||||
if goWeekday == time.Sunday {
|
||||
scheduleDay = 6
|
||||
} else {
|
||||
scheduleDay = int(goWeekday) - 1
|
||||
}
|
||||
currMin := now.Hour()*60 + now.Minute()
|
||||
for _, s := range schedules {
|
||||
if s.DayOfWeek == scheduleDay && s.IsActive {
|
||||
startMin := parseTimeMinutes(s.StartTime)
|
||||
endMin := parseTimeMinutes(s.EndTime)
|
||||
if currMin >= startMin && currMin <= endMin {
|
||||
inSchedule = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nếu lớp chưa cấu hình app được phép, hoặc không trong giờ học
|
||||
if !hasAppsConfigured || !inSchedule {
|
||||
// Tắt trạng thái đang học của lớp trong DB (nếu đang bật)
|
||||
var cl models.Class
|
||||
if errClass := db.Where("rk_id = ?", rkID).First(&cl).Error; errClass == nil {
|
||||
if cl.IsStudying {
|
||||
cl.IsStudying = false
|
||||
db.Save(&cl)
|
||||
}
|
||||
}
|
||||
|
||||
reason := ""
|
||||
if !hasAppsConfigured {
|
||||
reason = "Lớp chưa được xét danh sách ứng dụng được phép."
|
||||
} else {
|
||||
reason = "Hiện tại ngoài giờ học theo lịch đã cài đặt."
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"classRkId": rkID,
|
||||
"keywords": "",
|
||||
"exit": true,
|
||||
"reason": reason,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(appConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/classes/:rkId/allowed-apps - Lưu danh sách app được phép
|
||||
func SaveAllowedAppsHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
rkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Keywords string `json:"keywords"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload format"})
|
||||
}
|
||||
|
||||
appConfig := models.ClassAllowedApp{
|
||||
ClassRkID: rkID,
|
||||
Keywords: req.Keywords,
|
||||
}
|
||||
|
||||
if err := db.Save(&appConfig).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"ok": true, "data": appConfig})
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/student/sync-log - Nhận log học tập (online/offline time, wifi SSID) tích lũy từ client
|
||||
func SyncStudentSessionLogHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
StudentRkID int64 `json:"studentRkId"`
|
||||
ClassRkID int64 `json:"classRkId"`
|
||||
SessionDate string `json:"sessionDate"` // YYYY-MM-DD
|
||||
AddOnlineSecs int `json:"addOnlineSeconds"`
|
||||
AddOfflineSecs int `json:"addOfflineSeconds"`
|
||||
WifiSSID string `json:"wifiSsid"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload format"})
|
||||
}
|
||||
|
||||
if req.StudentRkID == 0 || req.SessionDate == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Missing key parameters"})
|
||||
}
|
||||
|
||||
// Dynamically resolve active class + ca theo lịch học
|
||||
resolvedClassID, resolvedPeriod := internalDb.FindActiveClassAndPeriodForStudent(db, req.StudentRkID)
|
||||
if resolvedClassID > 0 {
|
||||
req.ClassRkID = resolvedClassID
|
||||
}
|
||||
period := resolvedPeriod
|
||||
if period < 1 {
|
||||
period = 1
|
||||
}
|
||||
|
||||
if req.ClassRkID == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Student does not belong to any class"})
|
||||
}
|
||||
|
||||
if avatar := util.NormalizeAvatarURL(req.Avatar); avatar != "" {
|
||||
_ = db.Model(&models.Student{}).Where("rk_id = ?", req.StudentRkID).Update("avatar", avatar).Error
|
||||
}
|
||||
|
||||
var session models.StudentSession
|
||||
err := db.Where("student_rk_id = ? AND class_rk_id = ? AND session_date = ? AND period = ?",
|
||||
req.StudentRkID, req.ClassRkID, req.SessionDate, period).First(&session).Error
|
||||
|
||||
now := time.Now()
|
||||
if gorm.ErrRecordNotFound == err {
|
||||
// Tạo record mới
|
||||
session = models.StudentSession{
|
||||
StudentRkID: req.StudentRkID,
|
||||
ClassRkID: req.ClassRkID,
|
||||
SessionDate: req.SessionDate,
|
||||
Period: period,
|
||||
OnlineSeconds: req.AddOnlineSecs,
|
||||
OfflineSeconds: req.AddOfflineSecs,
|
||||
WifiSSIDs: req.WifiSSID,
|
||||
LastActiveAt: now,
|
||||
}
|
||||
if err := db.Create(&session).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create student session: " + err.Error()})
|
||||
}
|
||||
} else if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
} else {
|
||||
// Cộng dồn thời gian
|
||||
session.OnlineSeconds += req.AddOnlineSecs
|
||||
session.OfflineSeconds += req.AddOfflineSecs
|
||||
session.LastActiveAt = now
|
||||
|
||||
// Thêm Wifi SSID nếu chưa tồn tại trong danh sách
|
||||
if req.WifiSSID != "" {
|
||||
ssids := strings.Split(session.WifiSSIDs, ",")
|
||||
exists := false
|
||||
for _, s := range ssids {
|
||||
if strings.TrimSpace(s) == req.WifiSSID {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !exists {
|
||||
if session.WifiSSIDs == "" {
|
||||
session.WifiSSIDs = req.WifiSSID
|
||||
} else {
|
||||
session.WifiSSIDs = session.WifiSSIDs + "," + req.WifiSSID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Save(&session).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update student session: " + err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"ok": true, "session": session})
|
||||
}
|
||||
}
|
||||
|
||||
type studentSessionLogItem struct {
|
||||
ID uint `json:"id"`
|
||||
StudentRkID int64 `json:"studentRkId"`
|
||||
StudentCode string `json:"studentCode"`
|
||||
FullName string `json:"fullName"`
|
||||
Email string `json:"email"`
|
||||
Period int `json:"period"`
|
||||
OnlineSeconds int `json:"onlineSeconds"`
|
||||
OfflineSeconds int `json:"offlineSeconds"`
|
||||
WifiSSIDs string `json:"wifiSsids"`
|
||||
LastActiveAt time.Time `json:"lastActiveAt"`
|
||||
}
|
||||
|
||||
// GET /api/classes/:rkId/session-logs - Lấy danh sách logs điểm danh của lớp trong ngày
|
||||
func ListClassSessionLogsHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
classRkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
|
||||
}
|
||||
|
||||
date := c.Query("date")
|
||||
if date == "" {
|
||||
date = time.Now().Format("2006-01-02")
|
||||
}
|
||||
period := atoiDefault(c.Query("period"), 0)
|
||||
if period <= 0 {
|
||||
period = internalDb.ResolveSessionPeriodForDate(db, classRkID, date, 0)
|
||||
}
|
||||
|
||||
// Lấy danh sách học sinh của lớp từ class_students mapping
|
||||
var mappings []models.ClassStudent
|
||||
if err := db.Where("class_rk_id = ?", classRkID).Find(&mappings).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
if len(mappings) == 0 {
|
||||
return c.JSON(fiber.Map{"data": []any{}})
|
||||
}
|
||||
|
||||
var studentRkIDs []int64
|
||||
for _, m := range mappings {
|
||||
studentRkIDs = append(studentRkIDs, m.StudentRkID)
|
||||
}
|
||||
|
||||
var students []models.Student
|
||||
if err := db.Where("rk_id IN ?", studentRkIDs).Find(&students).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Lấy log session theo ca trong ngày
|
||||
var sessions []models.StudentSession
|
||||
periods := []int{period}
|
||||
if period == 1 {
|
||||
periods = append(periods, 0) // dữ liệu cũ gộp vào ca 1
|
||||
}
|
||||
if err := db.Where("class_rk_id = ? AND session_date = ? AND student_rk_id IN ? AND period IN ?",
|
||||
classRkID, date, studentRkIDs, periods).Find(&sessions).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
sessionsMap := map[int64]models.StudentSession{}
|
||||
for _, s := range sessions {
|
||||
existing, ok := sessionsMap[s.StudentRkID]
|
||||
if !ok || s.Period > existing.Period {
|
||||
sessionsMap[s.StudentRkID] = s
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]studentSessionLogItem, 0, len(students))
|
||||
for _, st := range students {
|
||||
sess, ok := sessionsMap[st.RkID]
|
||||
onlineSec := 0
|
||||
offlineSec := 0
|
||||
wifi := "—"
|
||||
var lastAct time.Time
|
||||
if ok {
|
||||
onlineSec = sess.OnlineSeconds
|
||||
offlineSec = sess.OfflineSeconds
|
||||
wifi = sess.WifiSSIDs
|
||||
lastAct = sess.LastActiveAt
|
||||
}
|
||||
|
||||
out = append(out, studentSessionLogItem{
|
||||
ID: sess.ID,
|
||||
StudentRkID: st.RkID,
|
||||
StudentCode: st.StudentCode,
|
||||
FullName: st.FullName,
|
||||
Email: st.Email,
|
||||
Period: period,
|
||||
OnlineSeconds: onlineSec,
|
||||
OfflineSeconds: offlineSec,
|
||||
WifiSSIDs: wifi,
|
||||
LastActiveAt: lastAct,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"data": out, "period": period, "date": date})
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/classes/:rkId/online-students - Lấy danh sách ID sinh viên đang online
|
||||
func GetOnlineStudentsHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
classRkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
|
||||
}
|
||||
|
||||
onlineIDs := internalWs.Hub.GetOnlineStudentIDs(classRkID)
|
||||
return c.JSON(fiber.Map{"onlineStudentIds": onlineIDs})
|
||||
}
|
||||
}
|
||||
|
||||
func processKeyword(processName string) string {
|
||||
name := strings.ToLower(strings.TrimSpace(processName))
|
||||
return strings.TrimSuffix(name, ".exe")
|
||||
}
|
||||
|
||||
func processKey(processName string) string {
|
||||
return strings.ToLower(strings.TrimSpace(processName))
|
||||
}
|
||||
|
||||
// POST /api/student/report-blocked-app — client báo app vừa bị chặn → kho global
|
||||
func ReportBlockedAppHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
StudentRkID int64 `json:"studentRkId"`
|
||||
ProcessName string `json:"processName"`
|
||||
WindowTitle string `json:"windowTitle"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload format"})
|
||||
}
|
||||
if req.StudentRkID <= 0 || strings.TrimSpace(req.ProcessName) == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "studentRkId and processName are required"})
|
||||
}
|
||||
|
||||
processName := strings.TrimSpace(req.ProcessName)
|
||||
pKey := processKey(processName)
|
||||
keyword := processKeyword(processName)
|
||||
if pKey == "" || keyword == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid processName"})
|
||||
}
|
||||
|
||||
lastClassID := internalDb.FindActiveClassForStudent(db, req.StudentRkID)
|
||||
now := time.Now()
|
||||
|
||||
var row models.AppPoolEntry
|
||||
err := db.Where("process_key = ?", pKey).First(&row).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
row = models.AppPoolEntry{
|
||||
ProcessName: processName,
|
||||
ProcessKey: pKey,
|
||||
WindowTitle: req.WindowTitle,
|
||||
Keyword: keyword,
|
||||
HitCount: 1,
|
||||
LastSeenAt: now,
|
||||
LastStudentRkID: req.StudentRkID,
|
||||
LastClassRkID: lastClassID,
|
||||
}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
} else if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
} else {
|
||||
row.HitCount++
|
||||
row.LastSeenAt = now
|
||||
row.WindowTitle = req.WindowTitle
|
||||
row.ProcessName = processName
|
||||
if req.StudentRkID > 0 {
|
||||
row.LastStudentRkID = req.StudentRkID
|
||||
}
|
||||
if lastClassID > 0 {
|
||||
row.LastClassRkID = lastClassID
|
||||
}
|
||||
if err := db.Save(&row).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"ok": true, "data": row})
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/app-pool — kho app bị chặn toàn hệ thống (?q= tìm kiếm, limit= mặc định 50)
|
||||
func ListAppPoolHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
q := strings.TrimSpace(c.Query("q", ""))
|
||||
limit := 50
|
||||
if l, err := strconv.Atoi(c.Query("limit", "50")); err == nil && l > 0 && l <= 200 {
|
||||
limit = l
|
||||
}
|
||||
|
||||
query := db.Model(&models.AppPoolEntry{})
|
||||
if q != "" {
|
||||
like := "%" + q + "%"
|
||||
query = query.Where(
|
||||
"keyword LIKE ? OR process_name LIKE ? OR window_title LIKE ? OR process_key LIKE ?",
|
||||
like, like, like, like,
|
||||
)
|
||||
}
|
||||
|
||||
var rows []models.AppPoolEntry
|
||||
if err := query.Order("hit_count DESC, last_seen_at DESC").Limit(limit).Find(&rows).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"data": rows, "q": q, "limit": limit})
|
||||
}
|
||||
}
|
||||
235
server/internal/handlers/handlers_network.go
Normal file
235
server/internal/handlers/handlers_network.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/internal/models"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func normalizeSSID(ssid string) string {
|
||||
return strings.TrimSpace(ssid)
|
||||
}
|
||||
|
||||
func ssidKey(ssid string) string {
|
||||
return strings.ToLower(strings.TrimSpace(ssid))
|
||||
}
|
||||
|
||||
func normalizeBSSID(bssid string) string {
|
||||
var hex []rune
|
||||
for _, c := range strings.ToLower(bssid) {
|
||||
if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') {
|
||||
hex = append(hex, c)
|
||||
}
|
||||
}
|
||||
if len(hex) != 12 {
|
||||
return strings.TrimSpace(bssid)
|
||||
}
|
||||
s := string(hex)
|
||||
return s[0:2] + ":" + s[2:4] + ":" + s[4:6] + ":" + s[6:8] + ":" + s[8:10] + ":" + s[10:12]
|
||||
}
|
||||
|
||||
func bssidKey(bssid string) string {
|
||||
var hex []rune
|
||||
for _, c := range strings.ToLower(bssid) {
|
||||
if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') {
|
||||
hex = append(hex, c)
|
||||
}
|
||||
}
|
||||
return string(hex)
|
||||
}
|
||||
|
||||
type wifiItemPayload struct {
|
||||
SSID string `json:"ssid"`
|
||||
BSSID string `json:"bssid"`
|
||||
}
|
||||
|
||||
// POST /api/student/report-wifi — ghi nhận SSID + BSSID vào kho
|
||||
func ReportWifiHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
StudentRkID int64 `json:"studentRkId"`
|
||||
SSID string `json:"ssid"`
|
||||
BSSID string `json:"bssid"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
|
||||
}
|
||||
ssid := normalizeSSID(req.SSID)
|
||||
bssid := normalizeBSSID(req.BSSID)
|
||||
sKey := ssidKey(ssid)
|
||||
bKey := bssidKey(bssid)
|
||||
if sKey == "" || bKey == "" || len(bKey) != 12 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "ssid and valid bssid are required"})
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var row models.WifiPoolEntry
|
||||
err := db.Where("bssid_key = ?", bKey).First(&row).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
row = models.WifiPoolEntry{
|
||||
SSID: ssid,
|
||||
SSIDKey: sKey,
|
||||
BSSID: bssid,
|
||||
BSSIDKey: bKey,
|
||||
HitCount: 1,
|
||||
LastSeenAt: now,
|
||||
LastStudentRkID: req.StudentRkID,
|
||||
}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
} else if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
} else {
|
||||
row.HitCount++
|
||||
row.LastSeenAt = now
|
||||
row.SSID = ssid
|
||||
row.SSIDKey = sKey
|
||||
row.BSSID = bssid
|
||||
if req.StudentRkID > 0 {
|
||||
row.LastStudentRkID = req.StudentRkID
|
||||
}
|
||||
if err := db.Save(&row).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true, "data": row})
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/student/wifi-policy — danh sách điểm phát được phép (theo BSSID)
|
||||
func GetStudentWifiPolicyHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var rows []models.AcceptedWifi
|
||||
if err := db.Where("bssid_key <> '' AND LENGTH(bssid_key) = 12").Order("ssid asc").Find(&rows).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
items := make([]wifiItemPayload, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, wifiItemPayload{SSID: r.SSID, BSSID: r.BSSID})
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"enforce": len(items) > 0,
|
||||
"accepted": items,
|
||||
"acceptedSsids": nil, // legacy — client mới dùng accepted
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/wifi-pool?q=&limit=
|
||||
func ListWifiPoolHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
q := strings.TrimSpace(c.Query("q", ""))
|
||||
limit := 50
|
||||
if l, err := strconv.Atoi(c.Query("limit", "50")); err == nil && l > 0 && l <= 200 {
|
||||
limit = l
|
||||
}
|
||||
query := db.Model(&models.WifiPoolEntry{}).Where("bssid_key <> '' AND LENGTH(bssid_key) = 12")
|
||||
if q != "" {
|
||||
like := "%" + q + "%"
|
||||
query = query.Where("ssid LIKE ? OR ssid_key LIKE ? OR bssid LIKE ? OR bssid_key LIKE ?", like, like, like, like)
|
||||
}
|
||||
var rows []models.WifiPoolEntry
|
||||
if err := query.Order("hit_count DESC, last_seen_at DESC").Limit(limit).Find(&rows).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"data": rows, "q": q, "limit": limit})
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/network/accepted-wifis
|
||||
func ListAcceptedWifisHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var rows []models.AcceptedWifi
|
||||
if err := db.Order("ssid asc, bssid asc").Find(&rows).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"data": rows})
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/network/accepted-wifis — thay toàn bộ danh sách (cần SSID + BSSID)
|
||||
func SaveAcceptedWifisHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
Items []wifiItemPayload `json:"items"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
|
||||
}
|
||||
if err := db.Where("1 = 1").Delete(&models.AcceptedWifi{}).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
now := time.Now()
|
||||
count := 0
|
||||
seen := map[string]bool{}
|
||||
for _, item := range req.Items {
|
||||
ssid := normalizeSSID(item.SSID)
|
||||
bssid := normalizeBSSID(item.BSSID)
|
||||
sKey := ssidKey(ssid)
|
||||
bKey := bssidKey(bssid)
|
||||
if sKey == "" || bKey == "" || len(bKey) != 12 || seen[bKey] {
|
||||
continue
|
||||
}
|
||||
seen[bKey] = true
|
||||
if err := db.Create(&models.AcceptedWifi{
|
||||
SSID: ssid,
|
||||
SSIDKey: sKey,
|
||||
BSSID: bssid,
|
||||
BSSIDKey: bKey,
|
||||
CreatedAt: now,
|
||||
}).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
count++
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true, "count": count})
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/network/accepted-wifis/add
|
||||
func AddAcceptedWifiHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var req wifiItemPayload
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
|
||||
}
|
||||
ssid := normalizeSSID(req.SSID)
|
||||
bssid := normalizeBSSID(req.BSSID)
|
||||
sKey := ssidKey(ssid)
|
||||
bKey := bssidKey(bssid)
|
||||
if sKey == "" || bKey == "" || len(bKey) != 12 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "ssid and valid bssid are required"})
|
||||
}
|
||||
row := models.AcceptedWifi{
|
||||
SSID: ssid,
|
||||
SSIDKey: sKey,
|
||||
BSSID: bssid,
|
||||
BSSIDKey: bKey,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := db.Where("bssid_key = ?", bKey).FirstOrCreate(&row).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true, "data": row})
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/network/accepted-wifis/:id
|
||||
func DeleteAcceptedWifiHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"})
|
||||
}
|
||||
if err := db.Delete(&models.AcceptedWifi{}, id).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
}
|
||||
212
server/internal/handlers/handlers_student.go
Normal file
212
server/internal/handlers/handlers_student.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
internalDb "server/internal/db"
|
||||
"server/internal/models"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type studentShiftItem struct {
|
||||
Period int `json:"period"`
|
||||
CourseName string `json:"courseName"`
|
||||
StartTime string `json:"startTime"`
|
||||
EndTime string `json:"endTime"`
|
||||
IsActiveNow bool `json:"isActiveNow"`
|
||||
OnlineSeconds int `json:"onlineSeconds"`
|
||||
OfflineSeconds int `json:"offlineSeconds"`
|
||||
AttendanceStatus int `json:"attendanceStatus"`
|
||||
AttendanceLabel string `json:"attendanceLabel"`
|
||||
}
|
||||
|
||||
// GET /api/student/status — trạng thái giám sát, lớp/ca hiện tại, tích lũy & điểm danh hôm nay
|
||||
func GetStudentStatusHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
studentID, err := strconv.ParseInt(c.Query("studentRkId", "0"), 10, 64)
|
||||
if err != nil || studentID <= 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "studentRkId is required"})
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
today := now.Format("2006-01-02")
|
||||
dayIdx := weekdayIndex(now)
|
||||
currMin := now.Hour()*60 + now.Minute()
|
||||
|
||||
classID, activePeriod := internalDb.FindActiveClassAndPeriodForStudent(db, studentID)
|
||||
if classID <= 0 {
|
||||
var link models.ClassStudent
|
||||
if err := db.Where("student_rk_id = ?", studentID).First(&link).Error; err == nil {
|
||||
classID = link.ClassRkID
|
||||
}
|
||||
}
|
||||
|
||||
className, classCode := "", ""
|
||||
if classID > 0 {
|
||||
var cl models.Class
|
||||
if err := db.Where("rk_id = ?", classID).First(&cl).Error; err == nil {
|
||||
className = cl.Name
|
||||
classCode = cl.ClassCode
|
||||
}
|
||||
}
|
||||
|
||||
var todaySchedules []models.ClassSchedule
|
||||
if classID > 0 {
|
||||
_ = db.Where("class_rk_id = ? AND day_of_week = ? AND is_active = ?", classID, dayIdx, true).
|
||||
Order("period asc, start_time asc").Find(&todaySchedules).Error
|
||||
}
|
||||
|
||||
inScheduleNow := false
|
||||
currentPeriod := 0
|
||||
currentCourse, currentStart, currentEnd := "", "", ""
|
||||
for _, s := range todaySchedules {
|
||||
startMin := parseTimeMinutes(s.StartTime)
|
||||
endMin := parseTimeMinutes(s.EndTime)
|
||||
if currMin >= startMin && currMin <= endMin {
|
||||
inScheduleNow = true
|
||||
currentPeriod = s.Period
|
||||
if currentPeriod < 1 {
|
||||
currentPeriod = 1
|
||||
}
|
||||
currentCourse = s.CourseName
|
||||
currentStart = s.StartTime
|
||||
currentEnd = s.EndTime
|
||||
break
|
||||
}
|
||||
}
|
||||
if !inScheduleNow && activePeriod > 0 {
|
||||
currentPeriod = activePeriod
|
||||
}
|
||||
|
||||
hasApps := false
|
||||
keywords := ""
|
||||
if classID > 0 {
|
||||
var appConfig models.ClassAllowedApp
|
||||
if err := db.Where("class_rk_id = ?", classID).First(&appConfig).Error; err == nil {
|
||||
keywords = strings.TrimSpace(appConfig.Keywords)
|
||||
hasApps = keywords != ""
|
||||
}
|
||||
}
|
||||
|
||||
monitorMode := "outside_schedule"
|
||||
monitorLabel := "Ngoài giờ học"
|
||||
if classID <= 0 {
|
||||
monitorMode = "not_configured"
|
||||
monitorLabel = "Chưa xác định được lớp học"
|
||||
} else if !hasApps {
|
||||
monitorMode = "not_configured"
|
||||
monitorLabel = "Lớp chưa cấu hình ứng dụng được phép"
|
||||
} else if inScheduleNow {
|
||||
monitorMode = "learning"
|
||||
if currentCourse != "" {
|
||||
monitorLabel = fmt.Sprintf("Ca %d — %s (%s–%s)", currentPeriod, currentCourse, currentStart, currentEnd)
|
||||
} else {
|
||||
monitorLabel = fmt.Sprintf("Đang giám sát — Ca %d", currentPeriod)
|
||||
}
|
||||
}
|
||||
// monitorMode = "exam" — dành cho phòng thi (sẽ bổ sung sau)
|
||||
|
||||
sessionMap := map[int]models.StudentSession{}
|
||||
attMap := map[int]models.AttendanceResult{}
|
||||
if classID > 0 {
|
||||
var sessions []models.StudentSession
|
||||
_ = db.Where("student_rk_id = ? AND class_rk_id = ? AND session_date = ?", studentID, classID, today).Find(&sessions).Error
|
||||
for _, s := range sessions {
|
||||
p := s.Period
|
||||
if p < 1 {
|
||||
p = 1
|
||||
}
|
||||
sessionMap[p] = s
|
||||
}
|
||||
var attendances []models.AttendanceResult
|
||||
_ = db.Where("student_rk_id = ? AND class_rk_id = ? AND session_date = ?", studentID, classID, today).Find(&attendances).Error
|
||||
for _, a := range attendances {
|
||||
p := a.Period
|
||||
if p < 1 {
|
||||
p = 1
|
||||
}
|
||||
attMap[p] = a
|
||||
}
|
||||
}
|
||||
|
||||
shifts := make([]studentShiftItem, 0, len(todaySchedules))
|
||||
for _, sch := range todaySchedules {
|
||||
period := sch.Period
|
||||
if period < 1 {
|
||||
period = 1
|
||||
}
|
||||
sess := sessionMap[period]
|
||||
att := attMap[period]
|
||||
startMin := parseTimeMinutes(sch.StartTime)
|
||||
endMin := parseTimeMinutes(sch.EndTime)
|
||||
isActiveNow := currMin >= startMin && currMin <= endMin
|
||||
|
||||
attStatus := -1
|
||||
attLabel := "Chưa tính"
|
||||
if att.ID > 0 {
|
||||
attStatus = att.Status
|
||||
attLabel = att.StatusLabel
|
||||
if attLabel == "" {
|
||||
attLabel = attendanceLabelFallback(att.Status)
|
||||
}
|
||||
}
|
||||
|
||||
shifts = append(shifts, studentShiftItem{
|
||||
Period: period,
|
||||
CourseName: sch.CourseName,
|
||||
StartTime: sch.StartTime,
|
||||
EndTime: sch.EndTime,
|
||||
IsActiveNow: isActiveNow,
|
||||
OnlineSeconds: sess.OnlineSeconds,
|
||||
OfflineSeconds: sess.OfflineSeconds,
|
||||
AttendanceStatus: attStatus,
|
||||
AttendanceLabel: attLabel,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"sessionDate": today,
|
||||
"monitorMode": monitorMode,
|
||||
"monitorLabel": monitorLabel,
|
||||
"classRkId": classID,
|
||||
"className": className,
|
||||
"classCode": classCode,
|
||||
"currentPeriod": currentPeriod,
|
||||
"currentCourseName": currentCourse,
|
||||
"currentShiftStart": currentStart,
|
||||
"currentShiftEnd": currentEnd,
|
||||
"inScheduleNow": inScheduleNow,
|
||||
"blockerActive": hasApps && inScheduleNow,
|
||||
"allowedKeywords": keywords,
|
||||
"shifts": shifts,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func weekdayIndex(t time.Time) int {
|
||||
wd := t.Weekday()
|
||||
if wd == time.Sunday {
|
||||
return 6
|
||||
}
|
||||
return int(wd) - 1
|
||||
}
|
||||
|
||||
func attendanceLabelFallback(status int) string {
|
||||
switch status {
|
||||
case 1:
|
||||
return "Nghỉ có phép"
|
||||
case 2:
|
||||
return "Nghỉ nửa buổi"
|
||||
case 3:
|
||||
return "Đi học muộn"
|
||||
case 4:
|
||||
return "Đi học đầy đủ"
|
||||
default:
|
||||
return "Nghỉ không phép"
|
||||
}
|
||||
}
|
||||
@@ -22,16 +22,6 @@ type Class struct {
|
||||
|
||||
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"`
|
||||
@@ -48,6 +38,7 @@ type Student struct {
|
||||
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"`
|
||||
Avatar *string `gorm:"column:avatar;size:512" json:"avatar"`
|
||||
}
|
||||
|
||||
func (Student) TableName() string { return "students" }
|
||||
@@ -59,3 +50,119 @@ type ClassStudent struct {
|
||||
}
|
||||
|
||||
func (ClassStudent) TableName() string { return "class_students" }
|
||||
|
||||
// ClassCourse môn học thuộc lớp (cache từ QLĐT)
|
||||
type ClassCourse struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ClassRkID int64 `gorm:"column:class_rk_id;not null;uniqueIndex:uq_class_course,priority:1" json:"classRkId"`
|
||||
CourseRkID int64 `gorm:"column:course_rk_id;not null;uniqueIndex:uq_class_course,priority:2" json:"courseRkId"`
|
||||
Name string `gorm:"column:name;size:512" json:"name"`
|
||||
CourseCode string `gorm:"column:course_code;size:128" json:"courseCode"`
|
||||
}
|
||||
|
||||
func (ClassCourse) TableName() string { return "class_courses" }
|
||||
|
||||
// ClassSchedule lưu cấu hình lịch học theo tuần của lớp (nhiều ca/ngày)
|
||||
type ClassSchedule struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ClassRkID int64 `gorm:"column:class_rk_id;index;not null" json:"classRkId"`
|
||||
DayOfWeek int `gorm:"column:day_of_week;not null" json:"dayOfWeek"` // 0=Monday, 6=Sunday
|
||||
Period int `gorm:"column:period;not null;default:1" json:"period"` // Ca 1..4
|
||||
StartTime string `gorm:"column:start_time;size:8;not null" json:"startTime"`
|
||||
EndTime string `gorm:"column:end_time;size:8;not null" json:"endTime"`
|
||||
CourseID int64 `gorm:"column:course_id;not null" json:"courseId"`
|
||||
CourseName string `gorm:"column:course_name;size:512;not null" json:"courseName"`
|
||||
IsActive bool `gorm:"column:is_active;not null;default:true" json:"isActive"`
|
||||
}
|
||||
|
||||
func (ClassSchedule) TableName() string { return "class_schedules" }
|
||||
|
||||
// AttendanceResult điểm danh theo ca — khóa khi giáo viên sửa thủ công
|
||||
type AttendanceResult struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ClassRkID int64 `gorm:"column:class_rk_id;not null;uniqueIndex:idx_att_lookup,priority:1" json:"classRkId"`
|
||||
SessionDate string `gorm:"column:session_date;size:10;not null;uniqueIndex:idx_att_lookup,priority:2" json:"sessionDate"`
|
||||
Period int `gorm:"column:period;not null;uniqueIndex:idx_att_lookup,priority:3" json:"period"`
|
||||
StudentRkID int64 `gorm:"column:student_rk_id;not null;uniqueIndex:idx_att_lookup,priority:4" json:"studentRkId"`
|
||||
Status int `gorm:"column:status;not null;default:0" json:"status"`
|
||||
StatusLabel string `gorm:"column:status_label;size:64;not null" json:"statusLabel"`
|
||||
OnlineMinutes int `gorm:"column:online_minutes;not null;default:0" json:"onlineMinutes"`
|
||||
StatusEditedByTeacher bool `gorm:"column:status_edited_by_teacher;not null;default:false" json:"statusEditedByTeacher"`
|
||||
PushedToQLDTAt *time.Time `gorm:"column:pushed_to_qldt_at" json:"pushedToQldtAt,omitempty"`
|
||||
}
|
||||
|
||||
func (AttendanceResult) TableName() string { return "attendance_results" }
|
||||
|
||||
// ClassAllowedApp lưu từ khóa app được mở của lớp
|
||||
type ClassAllowedApp struct {
|
||||
ClassRkID int64 `gorm:"column:class_rk_id;primaryKey;not null" json:"classRkId"`
|
||||
Keywords string `gorm:"column:keywords;type:text" json:"keywords"` // phân tách bằng dấu phẩy
|
||||
}
|
||||
|
||||
func (ClassAllowedApp) TableName() string { return "class_allowed_apps" }
|
||||
|
||||
// AppPoolEntry — kho app bị chặn toàn hệ thống (mọi lớp dùng chung để gợi ý whitelist)
|
||||
type AppPoolEntry struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ProcessName string `gorm:"column:process_name;size:255;not null" json:"processName"`
|
||||
ProcessKey string `gorm:"column:process_key;size:255;not null;uniqueIndex" json:"-"`
|
||||
WindowTitle string `gorm:"column:window_title;type:text" json:"windowTitle"`
|
||||
Keyword string `gorm:"column:keyword;size:100;not null" json:"keyword"`
|
||||
HitCount int `gorm:"column:hit_count;default:1" json:"hitCount"`
|
||||
LastSeenAt time.Time `gorm:"column:last_seen_at" json:"lastSeenAt"`
|
||||
LastStudentRkID int64 `gorm:"column:last_student_rk_id" json:"lastStudentRkId,omitempty"`
|
||||
LastClassRkID int64 `gorm:"column:last_class_rk_id" json:"lastClassRkId,omitempty"`
|
||||
}
|
||||
|
||||
func (AppPoolEntry) TableName() string { return "app_pool" }
|
||||
|
||||
// WifiPoolEntry — kho WiFi phát hiện từ máy sinh viên (SSID + BSSID điểm phát)
|
||||
type WifiPoolEntry struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
SSID string `gorm:"column:ssid;size:255;not null" json:"ssid"`
|
||||
SSIDKey string `gorm:"column:ssid_key;size:255;not null;index" json:"-"`
|
||||
BSSID string `gorm:"column:bssid;size:32;not null" json:"bssid"`
|
||||
BSSIDKey string `gorm:"column:bssid_key;size:32;not null;uniqueIndex" json:"-"`
|
||||
HitCount int `gorm:"column:hit_count;default:1" json:"hitCount"`
|
||||
LastSeenAt time.Time `gorm:"column:last_seen_at" json:"lastSeenAt"`
|
||||
LastStudentRkID int64 `gorm:"column:last_student_rk_id" json:"lastStudentRkId,omitempty"`
|
||||
}
|
||||
|
||||
func (WifiPoolEntry) TableName() string { return "wifi_pool" }
|
||||
|
||||
// AcceptedWifi — điểm phát được phép (xác thực theo BSSID, không chỉ tên SSID)
|
||||
type AcceptedWifi struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
SSID string `gorm:"column:ssid;size:255;not null" json:"ssid"`
|
||||
SSIDKey string `gorm:"column:ssid_key;size:255;not null;index" json:"-"`
|
||||
BSSID string `gorm:"column:bssid;size:32;not null" json:"bssid"`
|
||||
BSSIDKey string `gorm:"column:bssid_key;size:32;not null;uniqueIndex" json:"-"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (AcceptedWifi) TableName() string { return "accepted_wifis" }
|
||||
|
||||
// StudentSession lưu log học tập theo ca trong ngày
|
||||
type StudentSession struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
StudentRkID int64 `gorm:"column:student_rk_id;not null;uniqueIndex:idx_student_session,priority:1" json:"studentRkId"`
|
||||
ClassRkID int64 `gorm:"column:class_rk_id;not null;uniqueIndex:idx_student_session,priority:2" json:"classRkId"`
|
||||
SessionDate string `gorm:"column:session_date;size:10;not null;uniqueIndex:idx_student_session,priority:3" json:"sessionDate"` // YYYY-MM-DD
|
||||
Period int `gorm:"column:period;not null;default:0;uniqueIndex:idx_student_session,priority:4" json:"period"` // Ca 1..4 (0 = dữ liệu cũ)
|
||||
OnlineSeconds int `gorm:"column:online_seconds;default:0" json:"onlineSeconds"`
|
||||
OfflineSeconds int `gorm:"column:offline_seconds;default:0" json:"offlineSeconds"`
|
||||
WifiSSIDs string `gorm:"column:wifi_ssids;type:text" json:"wifiSsids"`
|
||||
LastActiveAt time.Time `gorm:"column:last_active_at" json:"lastActiveAt"`
|
||||
}
|
||||
|
||||
func (StudentSession) TableName() string { return "student_sessions" }
|
||||
|
||||
257
server/internal/qldt/attendance.go
Normal file
257
server/internal/qldt/attendance.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package qldt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CourseItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CourseCode string `json:"courseCode"`
|
||||
}
|
||||
|
||||
type CoursesResponse struct {
|
||||
Data []CourseItem `json:"data"`
|
||||
Message string `json:"message"`
|
||||
StatusCode int `json:"statusCode"`
|
||||
}
|
||||
|
||||
type AttendanceStudent struct {
|
||||
StudentID int64 `json:"student_id"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type AttendancePayload struct {
|
||||
UsersID int `json:"users_id"`
|
||||
CoursesID int `json:"courses_id"`
|
||||
Period int `json:"period"`
|
||||
Students []AttendanceStudent `json:"students"`
|
||||
ClassID int64 `json:"class_id"`
|
||||
Date string `json:"date"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func ParseUserIDFromToken(token string) (int, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) < 2 {
|
||||
return 0, fmt.Errorf("invalid jwt")
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var claims struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if claims.ID == 0 {
|
||||
return 0, fmt.Errorf("token missing user id")
|
||||
}
|
||||
return claims.ID, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetClassCourses(ctx context.Context, token string, classID int64) (*CoursesResponse, error) {
|
||||
u := fmt.Sprintf("%s/courses/class/%d", c.baseURL, classID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.setAuthHeaders(req, token)
|
||||
|
||||
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 courses http %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var out CoursesResponse
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.StatusCode == 401 || strings.Contains(strings.ToLower(out.Message), "expired") {
|
||||
return nil, fmt.Errorf("qldt token invalid: %s", out.Message)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *Client) SaveAttendance(ctx context.Context, token string, payload *AttendancePayload) ([]byte, int, error) {
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
u := c.baseURL + "/attendance"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.setAuthHeaders(req, token)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return body, resp.StatusCode, fmt.Errorf("qldt attendance post %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return body, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func (c *Client) UpdateAttendanceDetailStatus(ctx context.Context, token string, detailID int64, status string) error {
|
||||
payload := map[string]string{"status": status}
|
||||
jsonData, _ := json.Marshal(payload)
|
||||
|
||||
u := fmt.Sprintf("%s/attendance-detail/status/%d", c.baseURL, detailID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, u, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.setAuthHeaders(req, token)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("qldt patch detail %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type PortalAttendanceDetail struct {
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Student struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"student"`
|
||||
}
|
||||
|
||||
type PortalAttendanceSession struct {
|
||||
ID int64 `json:"id"`
|
||||
Date string `json:"date"`
|
||||
Period int `json:"period"`
|
||||
AttendanceDetail []PortalAttendanceDetail `json:"attendanceDetail"`
|
||||
}
|
||||
|
||||
type PortalAttendanceResponse struct {
|
||||
Data map[string][]PortalAttendanceSession `json:"data"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (c *Client) GetAttendance(ctx context.Context, token string, classID int64, courseID int64) (*PortalAttendanceResponse, error) {
|
||||
u, _ := url.Parse(c.baseURL + "/attendance")
|
||||
q := u.Query()
|
||||
q.Set("class_id", fmt.Sprintf("%d", classID))
|
||||
q.Set("course_id", fmt.Sprintf("%d", courseID))
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.setAuthHeaders(req, token)
|
||||
|
||||
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 get attendance %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var out PortalAttendanceResponse
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *Client) setAuthHeaders(req *http.Request, token string) {
|
||||
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")
|
||||
}
|
||||
|
||||
func AttendanceStatusLabel(status int) string {
|
||||
switch status {
|
||||
case 1:
|
||||
return "Nghỉ có phép"
|
||||
case 2:
|
||||
return "Nghỉ nửa buổi"
|
||||
case 3:
|
||||
return "Đi học muộn"
|
||||
case 4:
|
||||
return "Đi học đầy đủ"
|
||||
default:
|
||||
return "Nghỉ không phép"
|
||||
}
|
||||
}
|
||||
|
||||
func ShiftDurationMinutes(start, end string) int {
|
||||
return parseHM(end) - parseHM(start)
|
||||
}
|
||||
|
||||
func parseHM(t string) int {
|
||||
parts := strings.Split(t, ":")
|
||||
if len(parts) < 2 {
|
||||
return 0
|
||||
}
|
||||
h, m := 0, 0
|
||||
fmt.Sscanf(parts[0], "%d", &h)
|
||||
fmt.Sscanf(parts[1], "%d", &m)
|
||||
return h*60 + m
|
||||
}
|
||||
|
||||
func InferStatusFromOnlineMinutes(onlineMins, shiftMins int) int {
|
||||
if shiftMins <= 0 {
|
||||
if onlineMins > 0 {
|
||||
return 4
|
||||
}
|
||||
return 0
|
||||
}
|
||||
rate := float64(onlineMins) / float64(shiftMins)
|
||||
switch {
|
||||
case rate >= 0.85:
|
||||
return 4
|
||||
case rate >= 0.5:
|
||||
return 2
|
||||
case onlineMins > 0:
|
||||
return 3
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func NowDateVN() string {
|
||||
return time.Now().Format("2006-01-02")
|
||||
}
|
||||
@@ -48,6 +48,7 @@ type StudentDTO struct {
|
||||
Gender *int `json:"gender"`
|
||||
Status *string `json:"status"`
|
||||
Location *string `json:"location"`
|
||||
Avatar *string `json:"avatar"`
|
||||
System *struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
"time"
|
||||
|
||||
"server/internal/models"
|
||||
internalDb "server/internal/db"
|
||||
"server/internal/qldt"
|
||||
"server/internal/util"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -104,6 +106,15 @@ func (j *StudentsSyncJob) run(db *gorm.DB, client *qldt.Client, token string) {
|
||||
}
|
||||
}
|
||||
|
||||
avatarVal := ""
|
||||
if s.Avatar != nil {
|
||||
avatarVal = util.NormalizeAvatarURL(*s.Avatar)
|
||||
}
|
||||
var avatar *string
|
||||
if avatarVal != "" {
|
||||
avatar = &avatarVal
|
||||
}
|
||||
|
||||
st := models.Student{
|
||||
RkID: s.ID,
|
||||
StudentCode: s.StudentCode,
|
||||
@@ -114,6 +125,7 @@ func (j *StudentsSyncJob) run(db *gorm.DB, client *qldt.Client, token string) {
|
||||
Gender: s.Gender,
|
||||
Status: s.Status,
|
||||
Location: s.Location,
|
||||
Avatar: avatar,
|
||||
}
|
||||
if s.System != nil {
|
||||
sysID := s.System.ID
|
||||
@@ -135,6 +147,7 @@ func (j *StudentsSyncJob) run(db *gorm.DB, client *qldt.Client, token string) {
|
||||
"gender",
|
||||
"status",
|
||||
"location",
|
||||
"avatar",
|
||||
"system_id",
|
||||
"system_name",
|
||||
"updated_at",
|
||||
@@ -302,29 +315,35 @@ func (j *ClassesSyncJob) run(db *gorm.DB, client *qldt.Client, token string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// Lưu danh sách môn học của lớp (từ API lớp + portal courses)
|
||||
courseDTOs := make([]internalDb.ClassCourseDTO, 0, len(cl.Courses))
|
||||
for _, co := range cl.Courses {
|
||||
if co.ID > 0 {
|
||||
courseDTOs = append(courseDTOs, internalDb.ClassCourseDTO{
|
||||
ID: co.ID,
|
||||
Name: co.Name,
|
||||
})
|
||||
}
|
||||
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 {
|
||||
if portalRes, err := client.GetClassCourses(ctx, token, cl.ID); err == nil && len(portalRes.Data) > 0 {
|
||||
portalDTOs := make([]internalDb.ClassCourseDTO, 0, len(portalRes.Data))
|
||||
for _, co := range portalRes.Data {
|
||||
if co.ID > 0 {
|
||||
portalDTOs = append(portalDTOs, internalDb.ClassCourseDTO{
|
||||
ID: co.ID,
|
||||
Name: co.Name,
|
||||
CourseCode: co.CourseCode,
|
||||
})
|
||||
}
|
||||
}
|
||||
courseDTOs = internalDb.MergeClassCourses(courseDTOs, portalDTOs)
|
||||
}
|
||||
if len(courseDTOs) > 0 {
|
||||
if err := internalDb.UpsertClassCourses(tx, cl.ID, courseDTOs); err != nil {
|
||||
tx.Rollback()
|
||||
j.setError("Failed to insert class course links: " + err.Error())
|
||||
j.setError("Failed to save class courses: " + err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -354,6 +373,7 @@ func (j *ClassesSyncJob) run(db *gorm.DB, client *qldt.Client, token string) {
|
||||
"gender",
|
||||
"status",
|
||||
"location",
|
||||
"avatar",
|
||||
"system_id",
|
||||
"system_name",
|
||||
"updated_at",
|
||||
@@ -521,6 +541,11 @@ func extractStudentsFromDashboard(payloadJSON []byte) ([]models.Student, error)
|
||||
status, _ := studentObj["status"].(string)
|
||||
location, _ := studentObj["location"].(string)
|
||||
|
||||
var avatar *string
|
||||
if av := util.PickAvatarFromMap(studentObj); av != "" {
|
||||
avatar = &av
|
||||
}
|
||||
|
||||
student := models.Student{
|
||||
RkID: rkID,
|
||||
StudentCode: stCode,
|
||||
@@ -531,6 +556,7 @@ func extractStudentsFromDashboard(payloadJSON []byte) ([]models.Student, error)
|
||||
Gender: gender,
|
||||
Status: &status,
|
||||
Location: &location,
|
||||
Avatar: avatar,
|
||||
}
|
||||
|
||||
if sysVal, ok := studentObj["system"].(map[string]interface{}); ok && sysVal != nil {
|
||||
|
||||
28
server/internal/util/avatar.go
Normal file
28
server/internal/util/avatar.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package util
|
||||
|
||||
import "strings"
|
||||
|
||||
func NormalizeAvatarURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(raw, "//") {
|
||||
return "https:" + raw
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func PickAvatarFromMap(m map[string]interface{}) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"avatar", "avatarUrl", "avatar_url", "profileImage"} {
|
||||
if av, ok := m[key].(string); ok {
|
||||
if normalized := NormalizeAvatarURL(av); normalized != "" {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
286
server/internal/websocket/websocket.go
Normal file
286
server/internal/websocket/websocket.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/gofiber/websocket/v2"
|
||||
"gorm.io/gorm"
|
||||
internalDb "server/internal/db"
|
||||
)
|
||||
|
||||
type SocketMsg struct {
|
||||
Event string `json:"event"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
type SocketClient struct {
|
||||
Conn *websocket.Conn
|
||||
StudentID int64
|
||||
ClassID int64
|
||||
Role string // "student" | "teacher"
|
||||
Addr string
|
||||
}
|
||||
|
||||
type WsHub struct {
|
||||
mu sync.RWMutex
|
||||
students map[int64]*SocketClient
|
||||
teachers map[string]*SocketClient
|
||||
subscribers map[int64][]string // studentId -> list of teacher connection addresses
|
||||
}
|
||||
|
||||
var Hub = &WsHub{
|
||||
students: make(map[int64]*SocketClient),
|
||||
teachers: make(map[string]*SocketClient),
|
||||
subscribers: make(map[int64][]string),
|
||||
}
|
||||
|
||||
func (h *WsHub) GetOnlineStudentIDs(classID int64) []int64 {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
var ids []int64
|
||||
for _, client := range h.students {
|
||||
if client.ClassID == classID {
|
||||
ids = append(ids, client.StudentID)
|
||||
}
|
||||
}
|
||||
if ids == nil {
|
||||
return []int64{}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (h *WsHub) Register(c *SocketClient) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
c.Addr = c.Conn.RemoteAddr().String()
|
||||
if c.Role == "student" {
|
||||
h.students[c.StudentID] = c
|
||||
log.Printf("[WS] Student %d registered (Address: %s, Class: %d)", c.StudentID, c.Addr, c.ClassID)
|
||||
} else if c.Role == "teacher" {
|
||||
h.teachers[c.Addr] = c
|
||||
log.Printf("[WS] Teacher registered (Address: %s)", c.Addr)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WsHub) Unregister(c *SocketClient) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if c.Role == "student" {
|
||||
delete(h.students, c.StudentID)
|
||||
log.Printf("[WS] Student %d disconnected", c.StudentID)
|
||||
|
||||
// Báo cho các giáo viên đang xem là stream của học sinh đã dừng
|
||||
if teachers, exists := h.subscribers[c.StudentID]; exists {
|
||||
for _, tAddr := range teachers {
|
||||
if t, found := h.teachers[tAddr]; found {
|
||||
_ = t.Conn.WriteJSON(SocketMsg{
|
||||
Event: "teacher:stream-stopped",
|
||||
Data: map[string]any{"studentId": c.StudentID},
|
||||
})
|
||||
}
|
||||
}
|
||||
delete(h.subscribers, c.StudentID)
|
||||
}
|
||||
} else if c.Role == "teacher" {
|
||||
delete(h.teachers, c.Addr)
|
||||
log.Printf("[WS] Teacher %s disconnected", c.Addr)
|
||||
|
||||
// Dọn dẹp subscriptions của giáo viên này
|
||||
for sID, teachersList := range h.subscribers {
|
||||
newList := []string{}
|
||||
for _, addr := range teachersList {
|
||||
if addr != c.Addr {
|
||||
newList = append(newList, addr)
|
||||
}
|
||||
}
|
||||
if len(newList) == 0 {
|
||||
delete(h.subscribers, sID)
|
||||
// Nếu không còn ai xem học sinh này, gửi lệnh tắt camera/screen cho client học sinh
|
||||
if student, exists := h.students[sID]; exists {
|
||||
_ = student.Conn.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"})
|
||||
_ = student.Conn.WriteJSON(SocketMsg{Event: "stop_webcam_stream"})
|
||||
}
|
||||
} else {
|
||||
h.subscribers[sID] = newList
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Teacher bắt đầu xem stream của Student
|
||||
func (h *WsHub) Subscribe(teacherAddr string, studentID int64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
// Thêm giáo viên vào list người xem của học sinh
|
||||
teachersList := h.subscribers[studentID]
|
||||
alreadySubscribed := false
|
||||
for _, addr := range teachersList {
|
||||
if addr == teacherAddr {
|
||||
alreadySubscribed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !alreadySubscribed {
|
||||
h.subscribers[studentID] = append(teachersList, teacherAddr)
|
||||
log.Printf("[WS] Teacher %s subscribed to student %d stream", teacherAddr, studentID)
|
||||
}
|
||||
|
||||
// Phát lệnh cho máy học sinh bật stream (nếu học sinh đang online)
|
||||
if student, exists := h.students[studentID]; exists {
|
||||
_ = student.Conn.WriteJSON(SocketMsg{Event: "start_screenshot_stream"})
|
||||
_ = student.Conn.WriteJSON(SocketMsg{Event: "start_webcam_stream"})
|
||||
}
|
||||
}
|
||||
|
||||
// Teacher dừng xem stream của Student
|
||||
func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
teachersList, exists := h.subscribers[studentID]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
newList := []string{}
|
||||
for _, addr := range teachersList {
|
||||
if addr != teacherAddr {
|
||||
newList = append(newList, addr)
|
||||
}
|
||||
}
|
||||
|
||||
if len(newList) == 0 {
|
||||
delete(h.subscribers, studentID)
|
||||
log.Printf("[WS] Student %d has no more proctor subscribers. Stopping streams.", studentID)
|
||||
|
||||
// Báo học sinh tắt camera & screen stream để tiết kiệm mạng và CPU
|
||||
if student, exists := h.students[studentID]; exists {
|
||||
_ = student.Conn.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"})
|
||||
_ = student.Conn.WriteJSON(SocketMsg{Event: "stop_webcam_stream"})
|
||||
}
|
||||
} else {
|
||||
h.subscribers[studentID] = newList
|
||||
}
|
||||
}
|
||||
|
||||
// Chuyển tiếp frame ảnh từ Student đến các Teacher đã subscribe
|
||||
func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
teachersList, exists := h.subscribers[studentID]
|
||||
if !exists || len(teachersList) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
relayEvent := "teacher:screenshot-stream-frame"
|
||||
if event == "webcam_stream_frame" {
|
||||
relayEvent = "teacher:webcam-stream-frame"
|
||||
}
|
||||
|
||||
msg := SocketMsg{
|
||||
Event: relayEvent,
|
||||
Data: map[string]any{
|
||||
"studentId": studentID,
|
||||
"imageBuffer": data["imageBuffer"],
|
||||
},
|
||||
}
|
||||
|
||||
for _, addr := range teachersList {
|
||||
if t, found := h.teachers[addr]; found {
|
||||
_ = t.Conn.WriteJSON(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket handler cho Fiber route
|
||||
func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
||||
return func(c *websocket.Conn) {
|
||||
role := c.Query("role", "student")
|
||||
studentIDStr := c.Query("studentId", "0")
|
||||
classIDStr := c.Query("classId", "0")
|
||||
|
||||
studentID, _ := strconv.ParseInt(studentIDStr, 10, 64)
|
||||
classID, _ := strconv.ParseInt(classIDStr, 10, 64)
|
||||
|
||||
if role == "student" && db != nil {
|
||||
resolvedClassID := internalDb.FindActiveClassForStudent(db, studentID)
|
||||
if resolvedClassID > 0 {
|
||||
classID = resolvedClassID
|
||||
}
|
||||
}
|
||||
|
||||
client := &SocketClient{
|
||||
Conn: c,
|
||||
StudentID: studentID,
|
||||
ClassID: classID,
|
||||
Role: role,
|
||||
}
|
||||
|
||||
Hub.Register(client)
|
||||
defer func() {
|
||||
Hub.Unregister(client)
|
||||
c.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
_, msgBytes, err := c.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
var msg SocketMsg
|
||||
if err := json.Unmarshal(msgBytes, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Xử lý các sự kiện
|
||||
switch msg.Event {
|
||||
case "screenshot_stream_frame", "webcam_stream_frame":
|
||||
// Nhận frame từ học sinh, chuyển tiếp về các thầy cô
|
||||
Hub.RelayFrame(client.StudentID, msg.Event, msg.Data)
|
||||
|
||||
case "teacher:subscribe":
|
||||
// Giáo viên đăng ký xem học sinh cụ thể
|
||||
if client.Role == "teacher" {
|
||||
if sIDVal, ok := msg.Data["studentId"]; ok {
|
||||
var sID int64
|
||||
switch v := sIDVal.(type) {
|
||||
case float64:
|
||||
sID = int64(v)
|
||||
case string:
|
||||
sID, _ = strconv.ParseInt(v, 10, 64)
|
||||
}
|
||||
if sID > 0 {
|
||||
Hub.Subscribe(client.Addr, sID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "teacher:unsubscribe":
|
||||
// Giáo viên hủy đăng ký
|
||||
if client.Role == "teacher" {
|
||||
if sIDVal, ok := msg.Data["studentId"]; ok {
|
||||
var sID int64
|
||||
switch v := sIDVal.(type) {
|
||||
case float64:
|
||||
sID = int64(v)
|
||||
case string:
|
||||
sID, _ = strconv.ParseInt(v, 10, 64)
|
||||
}
|
||||
if sID > 0 {
|
||||
Hub.Unsubscribe(client.Addr, sID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,26 +8,25 @@ import (
|
||||
"server/internal/handlers"
|
||||
"server/internal/qldt"
|
||||
"server/internal/syncjobs"
|
||||
internalWs "server/internal/websocket"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
gofiberWs "github.com/gofiber/websocket/v2"
|
||||
"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")
|
||||
@@ -36,7 +35,6 @@ func main() {
|
||||
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)
|
||||
@@ -46,24 +44,32 @@ func main() {
|
||||
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
|
||||
// WebSocket Upgrade Check
|
||||
app.Use("/ws", func(c *fiber.Ctx) error {
|
||||
if gofiberWs.IsWebSocketUpgrade(c) {
|
||||
c.Locals("allowed", true)
|
||||
return c.Next()
|
||||
}
|
||||
return fiber.ErrUpgradeRequired
|
||||
})
|
||||
|
||||
// WebSocket Route
|
||||
app.Get("/ws", gofiberWs.New(internalWs.WebSocketHandler(gormDB)))
|
||||
|
||||
api := app.Group("/api")
|
||||
|
||||
// Dashboard Stats
|
||||
@@ -71,6 +77,8 @@ func main() {
|
||||
|
||||
// Classes endpoints
|
||||
api.Get("/classes", handlers.ListClassesHandler(gormDB))
|
||||
api.Get("/classes/schedules", handlers.ListActiveSchedulesHandler(gormDB))
|
||||
api.Get("/classes/:rkId", handlers.GetClassHandler(gormDB))
|
||||
api.Patch("/classes/:rkId/studying", handlers.PatchClassStudyingHandler(gormDB))
|
||||
api.Get("/classes/:rkId/students", handlers.ListClassStudentsHandler(gormDB))
|
||||
|
||||
@@ -83,7 +91,36 @@ func main() {
|
||||
api.Post("/sync/students/start", handlers.StartStudentsSyncHandler(gormDB, qldtClient, studentsJob, qldtToken))
|
||||
api.Get("/sync/students/status", handlers.GetStudentsSyncStatusHandler(studentsJob))
|
||||
|
||||
// Chạy app ở port 8080
|
||||
// Learning Management endpoints
|
||||
api.Get("/classes/:rkId/schedule", handlers.GetClassScheduleHandler(gormDB))
|
||||
api.Post("/classes/:rkId/schedule", handlers.SaveClassScheduleHandler(gormDB))
|
||||
api.Delete("/classes/:rkId/schedule", handlers.DeleteClassScheduleHandler(gormDB))
|
||||
api.Get("/classes/:rkId/allowed-apps", handlers.GetAllowedAppsHandler(gormDB))
|
||||
api.Post("/classes/:rkId/allowed-apps", handlers.SaveAllowedAppsHandler(gormDB))
|
||||
api.Get("/app-pool", handlers.ListAppPoolHandler(gormDB))
|
||||
|
||||
api.Get("/wifi-pool", handlers.ListWifiPoolHandler(gormDB))
|
||||
api.Get("/network/accepted-wifis", handlers.ListAcceptedWifisHandler(gormDB))
|
||||
api.Post("/network/accepted-wifis", handlers.SaveAcceptedWifisHandler(gormDB))
|
||||
api.Post("/network/accepted-wifis/add", handlers.AddAcceptedWifiHandler(gormDB))
|
||||
api.Delete("/network/accepted-wifis/:id", handlers.DeleteAcceptedWifiHandler(gormDB))
|
||||
api.Get("/classes/:rkId/session-logs", handlers.ListClassSessionLogsHandler(gormDB))
|
||||
api.Get("/classes/:rkId/online-students", handlers.GetOnlineStudentsHandler(gormDB))
|
||||
|
||||
api.Get("/classes/:rkId/courses", handlers.GetClassCoursesHandler(gormDB, qldtClient, qldtToken))
|
||||
api.Post("/classes/:rkId/schedule/apply-template", handlers.ApplyScheduleTemplateHandler(gormDB))
|
||||
api.Get("/classes/:rkId/attendance", handlers.GetClassAttendanceHandler(gormDB))
|
||||
api.Get("/classes/:rkId/attendance/shifts", handlers.ListAttendanceShiftsHandler(gormDB))
|
||||
api.Put("/classes/:rkId/attendance/status", handlers.UpdateAttendanceStatusHandler(gormDB))
|
||||
api.Post("/classes/:rkId/attendance/push-qldt", handlers.PushAttendanceToQLDTHandler(gormDB, qldtClient, qldtToken))
|
||||
|
||||
// Student syncing offline-resilient log
|
||||
api.Post("/student/sync-log", handlers.SyncStudentSessionLogHandler(gormDB))
|
||||
api.Get("/student/status", handlers.GetStudentStatusHandler(gormDB))
|
||||
api.Get("/student/wifi-policy", handlers.GetStudentWifiPolicyHandler(gormDB))
|
||||
api.Post("/student/report-wifi", handlers.ReportWifiHandler(gormDB))
|
||||
api.Post("/student/report-blocked-app", handlers.ReportBlockedAppHandler(gormDB))
|
||||
|
||||
port := getEnv("PORT", "8080")
|
||||
log.Printf("Server starting on port %s...", port)
|
||||
if err := app.Listen(":" + port); err != nil {
|
||||
|
||||
BIN
server/server.exe
Normal file
BIN
server/server.exe
Normal file
Binary file not shown.
Reference in New Issue
Block a user