Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf7473b373 | |||
| 35c53cc54c | |||
| 016824e846 | |||
| d62c8cef34 | |||
| 190e1a3a16 |
@@ -32,6 +32,7 @@ func Init(di *dig.Container, app *fiber.App) {
|
|||||||
Membership: groups.Group("/membership"),
|
Membership: groups.Group("/membership"),
|
||||||
System: groups.Group("/system"),
|
System: groups.Group("/system"),
|
||||||
WebSocket: groups.Group("/ws"),
|
WebSocket: groups.Group("/ws"),
|
||||||
|
Leaderboard: serverIdGroup.Group("/leaderboard"),
|
||||||
}
|
}
|
||||||
|
|
||||||
accessKeyMiddleware := middleware.NewAccessKeyMiddleware()
|
accessKeyMiddleware := middleware.NewAccessKeyMiddleware()
|
||||||
|
|||||||
@@ -59,4 +59,9 @@ func InitializeControllers(c *dig.Container) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Panic("unable to initialize websocket controller")
|
logging.Panic("unable to initialize websocket controller")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = c.Invoke(NewLeaderboardController)
|
||||||
|
if err != nil {
|
||||||
|
logging.Panic("unable to initialize leaderboard controller")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
69
local/controller/leaderboard.go
Normal file
69
local/controller/leaderboard.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"acc-server-manager/local/middleware"
|
||||||
|
"acc-server-manager/local/model"
|
||||||
|
"acc-server-manager/local/service"
|
||||||
|
"acc-server-manager/local/utl/common"
|
||||||
|
"acc-server-manager/local/utl/error_handler"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LeaderboardController struct {
|
||||||
|
service *service.LeaderboardService
|
||||||
|
errorHandler *error_handler.ControllerErrorHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLeaderboardController(ls *service.LeaderboardService, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware) *LeaderboardController {
|
||||||
|
lc := &LeaderboardController{
|
||||||
|
service: ls,
|
||||||
|
errorHandler: error_handler.NewControllerErrorHandler(),
|
||||||
|
}
|
||||||
|
|
||||||
|
apiServerRoutes := routeGroups.Api.Group("/server/:id")
|
||||||
|
apiServerRoutes.Get("/leaderboard", lc.Get)
|
||||||
|
|
||||||
|
routeGroups.Leaderboard.Get("/", lc.Get)
|
||||||
|
routeGroups.Leaderboard.Put("/", auth.Authenticate, lc.Update)
|
||||||
|
|
||||||
|
return lc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the leaderboard for a server (public, no auth required).
|
||||||
|
func (lc *LeaderboardController) Get(c *fiber.Ctx) error {
|
||||||
|
serverIDStr := c.Params("id")
|
||||||
|
serverID, err := uuid.Parse(serverIDStr)
|
||||||
|
if err != nil {
|
||||||
|
return lc.errorHandler.HandleUUIDError(c, "server ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := lc.service.Get(c.UserContext(), serverID)
|
||||||
|
if err != nil {
|
||||||
|
return lc.errorHandler.HandleServiceError(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update replaces the leaderboard for a server (requires JWT auth).
|
||||||
|
func (lc *LeaderboardController) Update(c *fiber.Ctx) error {
|
||||||
|
serverIDStr := c.Params("id")
|
||||||
|
serverID, err := uuid.Parse(serverIDStr)
|
||||||
|
if err != nil {
|
||||||
|
return lc.errorHandler.HandleUUIDError(c, "server ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
var input model.Leaderboard
|
||||||
|
if err := c.BodyParser(&input); err != nil {
|
||||||
|
return lc.errorHandler.HandleParsingError(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := lc.service.Update(c.UserContext(), serverID, &input)
|
||||||
|
if err != nil {
|
||||||
|
return lc.errorHandler.HandleServiceError(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(data)
|
||||||
|
}
|
||||||
106
local/migrations/004_fix_ddl_comments.go
Normal file
106
local/migrations/004_fix_ddl_comments.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"acc-server-manager/local/utl/logging"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var sqlLineCommentRegexp = regexp.MustCompile(`--[^\n]*`)
|
||||||
|
|
||||||
|
type Migration004FixDDLComments struct {
|
||||||
|
DB *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMigration004FixDDLComments(db *gorm.DB) *Migration004FixDDLComments {
|
||||||
|
return &Migration004FixDDLComments{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Migration004FixDDLComments) Up() error {
|
||||||
|
if err := m.DB.AutoMigrate(&MigrationRecord{}); err != nil {
|
||||||
|
return fmt.Errorf("failed to create migration tracking table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var record MigrationRecord
|
||||||
|
if err := m.DB.Where("migration_name = ?", "004_fix_ddl_comments").First(&record).Error; err == nil {
|
||||||
|
logging.Info("DDL comment fix migration already applied, skipping")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var tables []string
|
||||||
|
if err := m.DB.Raw(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE '%--%' ORDER BY name",
|
||||||
|
).Scan(&tables).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to query tables with DDL comments: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tables) == 0 {
|
||||||
|
logging.Info("No tables with DDL comments found, skipping")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, table := range tables {
|
||||||
|
if err := m.rebuildTable(table); err != nil {
|
||||||
|
return fmt.Errorf("failed to rebuild table %q: %v", table, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record = MigrationRecord{
|
||||||
|
MigrationName: "004_fix_ddl_comments",
|
||||||
|
AppliedAt: "datetime('now')",
|
||||||
|
Success: true,
|
||||||
|
Notes: fmt.Sprintf("Rebuilt %d table(s): %s", len(tables), strings.Join(tables, ", ")),
|
||||||
|
}
|
||||||
|
if err := m.DB.Create(&record).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to record migration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Migration004FixDDLComments) rebuildTable(table string) error {
|
||||||
|
var originalDDL string
|
||||||
|
if err := m.DB.Raw(
|
||||||
|
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", table,
|
||||||
|
).Row().Scan(&originalDDL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanDDL := sqlLineCommentRegexp.ReplaceAllString(originalDDL, "")
|
||||||
|
|
||||||
|
tmpTable := table + "__fixed"
|
||||||
|
createTmp := strings.Replace(cleanDDL, `"`+table+`"`, "`"+tmpTable+"`", 1)
|
||||||
|
if createTmp == cleanDDL {
|
||||||
|
createTmp = strings.Replace(cleanDDL, table, "`"+tmpTable+"`", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.Info("Rebuilding table %q to remove DDL comments...", table)
|
||||||
|
|
||||||
|
return m.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
for _, sql := range []string{
|
||||||
|
"PRAGMA foreign_keys = OFF",
|
||||||
|
fmt.Sprintf("DROP TABLE IF EXISTS `%s`", tmpTable),
|
||||||
|
createTmp,
|
||||||
|
fmt.Sprintf("INSERT INTO `%s` SELECT * FROM `%s`", tmpTable, table),
|
||||||
|
fmt.Sprintf("DROP TABLE `%s`", table),
|
||||||
|
fmt.Sprintf("ALTER TABLE `%s` RENAME TO `%s`", tmpTable, table),
|
||||||
|
"PRAGMA foreign_keys = ON",
|
||||||
|
} {
|
||||||
|
if err := tx.Exec(sql).Error; err != nil {
|
||||||
|
return fmt.Errorf("exec %q: %w", sql, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logging.Info("Table %q rebuilt successfully", table)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Migration004FixDDLComments) Down() error {
|
||||||
|
return fmt.Errorf("DDL comment fix migration rollback is not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunFixDDLCommentsMigration(db *gorm.DB) error {
|
||||||
|
return NewMigration004FixDDLComments(db).Up()
|
||||||
|
}
|
||||||
113
local/model/leaderboard.go
Normal file
113
local/model/leaderboard.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Score is a custom type that accepts both numbers and strings in JSON.
|
||||||
|
type Score string
|
||||||
|
|
||||||
|
func (s *Score) UnmarshalJSON(data []byte) error {
|
||||||
|
var n json.Number
|
||||||
|
if err := json.Unmarshal(data, &n); err == nil {
|
||||||
|
*s = Score(n.String())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var str string
|
||||||
|
if err := json.Unmarshal(data, &str); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*s = Score(str)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Score) MarshalJSON() ([]byte, error) {
|
||||||
|
if n, err := strconv.Atoi(string(s)); err == nil {
|
||||||
|
return json.Marshal(n)
|
||||||
|
}
|
||||||
|
return json.Marshal(string(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
type Leaderboard struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
||||||
|
ServerID uuid.UUID `gorm:"uniqueIndex;not null;type:uuid" json:"serverId"`
|
||||||
|
FLPoints int `gorm:"default:1" json:"flPoints"`
|
||||||
|
FLColor string `gorm:"default:'#8b5cf6'" json:"flColor"`
|
||||||
|
FLTextColor string `gorm:"default:'#000000'" json:"flTextColor"`
|
||||||
|
Drivers []LeaderboardDriver `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"drivers"`
|
||||||
|
Races []LeaderboardRace `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"tracks"`
|
||||||
|
PointRows []LeaderboardPointRow `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"pointsTable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LeaderboardDriver struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
||||||
|
LeaderboardID uuid.UUID `gorm:"not null;type:uuid" json:"-"`
|
||||||
|
Name string `gorm:"not null" json:"name"`
|
||||||
|
Initials string `json:"initials"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Position int `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LeaderboardRace struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
||||||
|
LeaderboardID uuid.UUID `gorm:"not null;type:uuid" json:"-"`
|
||||||
|
Name string `gorm:"not null" json:"name"`
|
||||||
|
Position int `json:"-"`
|
||||||
|
FastestLapDriverID *uuid.UUID `gorm:"type:uuid" json:"fastestLapDriverId"`
|
||||||
|
Results []LeaderboardResult `gorm:"foreignKey:RaceID;constraint:OnDelete:CASCADE" json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LeaderboardResult struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"-"`
|
||||||
|
RaceID uuid.UUID `gorm:"not null;type:uuid" json:"-"`
|
||||||
|
DriverID uuid.UUID `gorm:"not null;type:uuid" json:"driverId"`
|
||||||
|
Score Score `json:"score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LeaderboardPointRow struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
||||||
|
LeaderboardID uuid.UUID `gorm:"not null;type:uuid" json:"-"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Points int `json:"points"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
TextColor string `json:"textColor"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Leaderboard) BeforeCreate(tx *gorm.DB) error {
|
||||||
|
if l.ID == uuid.Nil {
|
||||||
|
l.ID = uuid.New()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *LeaderboardDriver) BeforeCreate(tx *gorm.DB) error {
|
||||||
|
if d.ID == uuid.Nil {
|
||||||
|
d.ID = uuid.New()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LeaderboardRace) BeforeCreate(tx *gorm.DB) error {
|
||||||
|
if r.ID == uuid.Nil {
|
||||||
|
r.ID = uuid.New()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LeaderboardResult) BeforeCreate(tx *gorm.DB) error {
|
||||||
|
if r.ID == uuid.Nil {
|
||||||
|
r.ID = uuid.New()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LeaderboardPointRow) BeforeCreate(tx *gorm.DB) error {
|
||||||
|
if p.ID == uuid.Nil {
|
||||||
|
p.ID = uuid.New()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
144
local/repository/leaderboard.go
Normal file
144
local/repository/leaderboard.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"acc-server-manager/local/model"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LeaderboardRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLeaderboardRepository(db *gorm.DB) *LeaderboardRepository {
|
||||||
|
return &LeaderboardRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrCreateByServerID fetches the leaderboard for a server, creating an empty one if it doesn't exist.
|
||||||
|
func (r *LeaderboardRepository) GetOrCreateByServerID(ctx context.Context, serverID uuid.UUID) (*model.Leaderboard, error) {
|
||||||
|
lb := new(model.Leaderboard)
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Preload("Drivers").
|
||||||
|
Preload("Races.Results").
|
||||||
|
Preload("PointRows").
|
||||||
|
Where("server_id = ?", serverID).
|
||||||
|
First(lb).Error
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
return lb, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, fmt.Errorf("error fetching leaderboard: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lb = &model.Leaderboard{
|
||||||
|
ServerID: serverID,
|
||||||
|
FLPoints: 1,
|
||||||
|
FLColor: "#8b5cf6",
|
||||||
|
FLTextColor: "#000000",
|
||||||
|
}
|
||||||
|
if err := r.db.WithContext(ctx).Create(lb).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("error creating leaderboard: %w", err)
|
||||||
|
}
|
||||||
|
return lb, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullReplace replaces all leaderboard data for a server in a single transaction.
|
||||||
|
func (r *LeaderboardRepository) FullReplace(ctx context.Context, serverID uuid.UUID, lb *model.Leaderboard) (*model.Leaderboard, error) {
|
||||||
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
existing := new(model.Leaderboard)
|
||||||
|
err := tx.Where("server_id = ?", serverID).First(existing).Error
|
||||||
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return fmt.Errorf("error fetching existing leaderboard: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
lb.ServerID = serverID
|
||||||
|
return tx.Create(lb).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete children
|
||||||
|
if err := tx.Where("leaderboard_id = ?", existing.ID).Delete(&model.LeaderboardPointRow{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Where("leaderboard_id = ?", existing.ID).Delete(&model.LeaderboardDriver{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get race IDs to delete results
|
||||||
|
var raceIDs []uuid.UUID
|
||||||
|
tx.Model(&model.LeaderboardRace{}).Where("leaderboard_id = ?", existing.ID).Pluck("id", &raceIDs)
|
||||||
|
if len(raceIDs) > 0 {
|
||||||
|
if err := tx.Where("race_id IN ?", raceIDs).Delete(&model.LeaderboardResult{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Where("leaderboard_id = ?", existing.ID).Delete(&model.LeaderboardRace{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update parent fields
|
||||||
|
existing.FLPoints = lb.FLPoints
|
||||||
|
existing.FLColor = lb.FLColor
|
||||||
|
existing.FLTextColor = lb.FLTextColor
|
||||||
|
if err := tx.Save(existing).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-insert children with correct leaderboard ID
|
||||||
|
for i := range lb.Drivers {
|
||||||
|
lb.Drivers[i].LeaderboardID = existing.ID
|
||||||
|
}
|
||||||
|
if len(lb.Drivers) > 0 {
|
||||||
|
if err := tx.Create(&lb.Drivers).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range lb.PointRows {
|
||||||
|
lb.PointRows[i].LeaderboardID = existing.ID
|
||||||
|
lb.PointRows[i].ID = uuid.Nil
|
||||||
|
}
|
||||||
|
if len(lb.PointRows) > 0 {
|
||||||
|
if err := tx.Create(&lb.PointRows).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range lb.Races {
|
||||||
|
lb.Races[i].LeaderboardID = existing.ID
|
||||||
|
}
|
||||||
|
if len(lb.Races) > 0 {
|
||||||
|
// Create races first (without results) to get IDs
|
||||||
|
for i := range lb.Races {
|
||||||
|
results := lb.Races[i].Results
|
||||||
|
lb.Races[i].Results = nil
|
||||||
|
if err := tx.Create(&lb.Races[i]).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Re-attach results with correct race ID
|
||||||
|
for j := range results {
|
||||||
|
results[j].RaceID = lb.Races[i].ID
|
||||||
|
results[j].ID = uuid.Nil
|
||||||
|
}
|
||||||
|
if len(results) > 0 {
|
||||||
|
if err := tx.Create(&results).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lb.ID = existing.ID
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.GetOrCreateByServerID(ctx, serverID)
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ func InitializeRepositories(c *dig.Container) {
|
|||||||
c.Provide(NewLookupRepository)
|
c.Provide(NewLookupRepository)
|
||||||
c.Provide(NewSteamCredentialsRepository)
|
c.Provide(NewSteamCredentialsRepository)
|
||||||
c.Provide(NewMembershipRepository)
|
c.Provide(NewMembershipRepository)
|
||||||
|
c.Provide(NewLeaderboardRepository)
|
||||||
|
|
||||||
if err := c.Provide(func() *model.Steam2FAManager {
|
if err := c.Provide(func() *model.Steam2FAManager {
|
||||||
manager := model.NewSteam2FAManager()
|
manager := model.NewSteam2FAManager()
|
||||||
|
|||||||
37
local/service/leaderboard.go
Normal file
37
local/service/leaderboard.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"acc-server-manager/local/model"
|
||||||
|
"acc-server-manager/local/repository"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LeaderboardService struct {
|
||||||
|
repo *repository.LeaderboardRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLeaderboardService(repo *repository.LeaderboardRepository) *LeaderboardService {
|
||||||
|
return &LeaderboardService{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the leaderboard for a server.
|
||||||
|
func (s *LeaderboardService) Get(ctx context.Context, serverID uuid.UUID) (*model.Leaderboard, error) {
|
||||||
|
return s.repo.GetOrCreateByServerID(ctx, serverID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update replaces the leaderboard for a server.
|
||||||
|
func (s *LeaderboardService) Update(ctx context.Context, serverID uuid.UUID, lb *model.Leaderboard) (*model.Leaderboard, error) {
|
||||||
|
if lb == nil {
|
||||||
|
return nil, fmt.Errorf("input is required")
|
||||||
|
}
|
||||||
|
for i := range lb.Drivers {
|
||||||
|
lb.Drivers[i].Position = i
|
||||||
|
}
|
||||||
|
for i := range lb.Races {
|
||||||
|
lb.Races[i].Position = i
|
||||||
|
}
|
||||||
|
return s.repo.FullReplace(ctx, serverID, lb)
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ func InitializeServices(c *dig.Container) {
|
|||||||
c.Provide(NewFirewallService)
|
c.Provide(NewFirewallService)
|
||||||
c.Provide(NewMembershipService)
|
c.Provide(NewMembershipService)
|
||||||
c.Provide(NewWebSocketService)
|
c.Provide(NewWebSocketService)
|
||||||
|
c.Provide(NewLeaderboardService)
|
||||||
|
|
||||||
logging.Debug("Initializing service dependencies")
|
logging.Debug("Initializing service dependencies")
|
||||||
err := c.Invoke(func(server *ServerService, api *ServiceControlService, config *ConfigService) {
|
err := c.Invoke(func(server *ServerService, api *ServiceControlService, config *ConfigService) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type RouteGroups struct {
|
|||||||
Membership fiber.Router
|
Membership fiber.Router
|
||||||
System fiber.Router
|
System fiber.Router
|
||||||
WebSocket fiber.Router
|
WebSocket fiber.Router
|
||||||
|
Leaderboard fiber.Router
|
||||||
}
|
}
|
||||||
|
|
||||||
func CheckError(err error) {
|
func CheckError(err error) {
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ func Start(di *dig.Container) {
|
|||||||
func Migrate(db *gorm.DB) {
|
func Migrate(db *gorm.DB) {
|
||||||
logging.Info("Migrating database")
|
logging.Info("Migrating database")
|
||||||
|
|
||||||
|
runMigrations(db)
|
||||||
|
|
||||||
err := db.AutoMigrate(
|
err := db.AutoMigrate(
|
||||||
&model.ServiceControlModel{},
|
&model.ServiceControlModel{},
|
||||||
&model.Config{},
|
&model.Config{},
|
||||||
@@ -47,6 +49,11 @@ func Migrate(db *gorm.DB) {
|
|||||||
&model.User{},
|
&model.User{},
|
||||||
&model.Role{},
|
&model.Role{},
|
||||||
&model.Permission{},
|
&model.Permission{},
|
||||||
|
&model.Leaderboard{},
|
||||||
|
&model.LeaderboardDriver{},
|
||||||
|
&model.LeaderboardRace{},
|
||||||
|
&model.LeaderboardResult{},
|
||||||
|
&model.LeaderboardPointRow{},
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -61,6 +68,10 @@ func Migrate(db *gorm.DB) {
|
|||||||
func runMigrations(db *gorm.DB) {
|
func runMigrations(db *gorm.DB) {
|
||||||
logging.Info("Running custom database migrations...")
|
logging.Info("Running custom database migrations...")
|
||||||
|
|
||||||
|
if err := migrations.RunFixDDLCommentsMigration(db); err != nil {
|
||||||
|
logging.Error("Failed to run DDL comment fix migration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := migrations.RunPasswordSecurityMigration(db); err != nil {
|
if err := migrations.RunPasswordSecurityMigration(db); err != nil {
|
||||||
logging.Error("Failed to run password security migration: %v", err)
|
logging.Error("Failed to run password security migration: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
57
scripts/build.ps1
Normal file
57
scripts/build.ps1
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
param(
|
||||||
|
[string]$BinaryName = "acc-server-manager",
|
||||||
|
[string]$MigrateBinaryName = "acc-migrate",
|
||||||
|
[string]$GOOS = "windows",
|
||||||
|
[string]$GOARCH = "amd64",
|
||||||
|
[string]$OutputPath = ".\build",
|
||||||
|
[switch]$SkipTests
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function Write-Status {
|
||||||
|
param([string]$Message, [string]$Color = "Green")
|
||||||
|
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] $Message" -ForegroundColor $Color
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if Go is installed
|
||||||
|
if (-not (Get-Command go -ErrorAction SilentlyContinue)) {
|
||||||
|
throw "Go is not installed or not in PATH"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clean output directory
|
||||||
|
if (Test-Path $OutputPath) {
|
||||||
|
Remove-Item -Path $OutputPath -Recurse -Force
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
if (-not $SkipTests) {
|
||||||
|
Write-Status "Running tests..."
|
||||||
|
& go test ./...
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Tests failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set build environment
|
||||||
|
$env:GOOS = $GOOS
|
||||||
|
$env:GOARCH = $GOARCH
|
||||||
|
|
||||||
|
$ext = if ($GOOS -eq "windows") { ".exe" } else { "" }
|
||||||
|
|
||||||
|
# Build API binary
|
||||||
|
Write-Status "Building API binary ($GOOS/$GOARCH)..."
|
||||||
|
& go build -o "$OutputPath\$BinaryName$ext" .\cmd\api
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Failed to build API binary" }
|
||||||
|
|
||||||
|
# Build migration binary
|
||||||
|
Write-Status "Building migration binary ($GOOS/$GOARCH)..."
|
||||||
|
& go build -o "$OutputPath\$MigrateBinaryName$ext" .\cmd\migrate
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Failed to build migration binary" }
|
||||||
|
|
||||||
|
# Reset environment
|
||||||
|
Remove-Item Env:\GOOS -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item Env:\GOARCH -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
Write-Status "Build completed -> $OutputPath"
|
||||||
79
scripts/build.sh
Executable file
79
scripts/build.sh
Executable file
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BINARY_NAME="acc-server-manager"
|
||||||
|
MIGRATE_BINARY_NAME="acc-migrate"
|
||||||
|
GOOS="windows"
|
||||||
|
GOARCH="amd64"
|
||||||
|
OUTPUT_PATH="./build"
|
||||||
|
SKIP_TESTS=false
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $0 [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--binary-name <name> Main binary name (default: acc-server-manager)
|
||||||
|
--migrate-binary-name <n> Migration binary name (default: acc-migrate)
|
||||||
|
--goos <os> Target OS (default: windows)
|
||||||
|
--goarch <arch> Target arch (default: amd64)
|
||||||
|
--output-path <path> Output directory (default: ./build)
|
||||||
|
--skip-tests Skip running tests
|
||||||
|
-h, --help Show this help
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
log() { echo "[$(date '+%H:%M:%S')] $*"; }
|
||||||
|
die() { echo "[$(date '+%H:%M:%S')] ERROR: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--binary-name) BINARY_NAME="$2"; shift 2 ;;
|
||||||
|
--migrate-binary-name) MIGRATE_BINARY_NAME="$2"; shift 2 ;;
|
||||||
|
--goos) GOOS="$2"; shift 2 ;;
|
||||||
|
--goarch) GOARCH="$2"; shift 2 ;;
|
||||||
|
--output-path) OUTPUT_PATH="$2"; shift 2 ;;
|
||||||
|
--skip-tests) SKIP_TESTS=true; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) die "Unknown option: $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
command -v go >/dev/null 2>&1 || die "Go is not installed or not in PATH"
|
||||||
|
|
||||||
|
# Clean output directory
|
||||||
|
rm -rf "$OUTPUT_PATH"
|
||||||
|
mkdir -p "$OUTPUT_PATH"
|
||||||
|
|
||||||
|
# Run tests on host before setting cross-compilation environment
|
||||||
|
if [[ "$SKIP_TESTS" == false ]]; then
|
||||||
|
log "Running tests..."
|
||||||
|
go test ./...
|
||||||
|
fi
|
||||||
|
|
||||||
|
# CGo cross-compilation setup (set after tests so they run natively)
|
||||||
|
HOST_OS="$(go env GOHOSTOS)"
|
||||||
|
if [[ "$GOOS" == "windows" && "$HOST_OS" != "windows" ]]; then
|
||||||
|
command -v x86_64-w64-mingw32-gcc >/dev/null 2>&1 \
|
||||||
|
|| die "mingw-w64 not found. Install it with: sudo apt install gcc-mingw-w64-x86-64"
|
||||||
|
export CC=x86_64-w64-mingw32-gcc
|
||||||
|
export CGO_ENABLED=1
|
||||||
|
log "Cross-compiling for Windows (CC=$CC)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
EXT=""
|
||||||
|
[[ "$GOOS" == "windows" ]] && EXT=".exe"
|
||||||
|
|
||||||
|
export GOOS GOARCH
|
||||||
|
|
||||||
|
# Build API binary
|
||||||
|
log "Building API binary ($GOOS/$GOARCH)..."
|
||||||
|
go build -o "$OUTPUT_PATH/$BINARY_NAME$EXT" ./cmd/api
|
||||||
|
|
||||||
|
# Build migration binary
|
||||||
|
log "Building migration binary ($GOOS/$GOARCH)..."
|
||||||
|
go build -o "$OUTPUT_PATH/$MIGRATE_BINARY_NAME$EXT" ./cmd/migrate
|
||||||
|
|
||||||
|
unset GOOS GOARCH CC CGO_ENABLED
|
||||||
|
|
||||||
|
log "Build completed -> $OUTPUT_PATH"
|
||||||
@@ -61,44 +61,20 @@ function Build-Application {
|
|||||||
|
|
||||||
Write-Status "Building application..."
|
Write-Status "Building application..."
|
||||||
|
|
||||||
# Clean build directory
|
$buildScript = Join-Path $PSScriptRoot "build.ps1"
|
||||||
if (Test-Path $LocalBuildPath) {
|
$buildArgs = @(
|
||||||
Remove-Item -Path $LocalBuildPath -Recurse -Force
|
"-BinaryName", $BinaryName,
|
||||||
}
|
"-MigrateBinaryName", $MigrateBinaryName,
|
||||||
New-Item -ItemType Directory -Path $LocalBuildPath -Force | Out-Null
|
"-OutputPath", $LocalBuildPath
|
||||||
|
)
|
||||||
|
if ($SkipTests) { $buildArgs += "-SkipTests" }
|
||||||
|
|
||||||
# Run tests
|
& $buildScript @buildArgs
|
||||||
if (-not $SkipTests) {
|
|
||||||
Write-Status "Running tests..."
|
|
||||||
& go test -v ./...
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
throw "Tests failed"
|
throw "Build failed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build API for Windows
|
|
||||||
Write-Status "Building API binary..."
|
|
||||||
$env:GOOS = "windows"
|
|
||||||
$env:GOARCH = "amd64"
|
|
||||||
& go build -o "$LocalBuildPath\$BinaryName.exe" .\cmd\api
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
throw "Failed to build API binary"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build migration tool for Windows
|
|
||||||
Write-Status "Building migration binary..."
|
|
||||||
& go build -o "$LocalBuildPath\$MigrateBinaryName.exe" .\cmd\migrate
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
throw "Failed to build migration binary"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Reset environment variables
|
|
||||||
Remove-Item Env:\GOOS -ErrorAction SilentlyContinue
|
|
||||||
Remove-Item Env:\GOARCH -ErrorAction SilentlyContinue
|
|
||||||
|
|
||||||
Write-Status "Build completed successfully"
|
|
||||||
}
|
|
||||||
|
|
||||||
function Copy-FilesToServer {
|
function Copy-FilesToServer {
|
||||||
Write-Status "Copying files to server..."
|
Write-Status "Copying files to server..."
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user