diff --git a/cmd/server.go b/cmd/server.go index 7425be2..03e50a6 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -12,11 +12,11 @@ import ( "github.com/FOR-GAMERS/GAMERS-BE/internal/contest" "github.com/FOR-GAMERS/GAMERS-BE/internal/discord" "github.com/FOR-GAMERS/GAMERS-BE/internal/game" + "github.com/FOR-GAMERS/GAMERS-BE/internal/lol" "github.com/FOR-GAMERS/GAMERS-BE/internal/global/common/router" "github.com/FOR-GAMERS/GAMERS-BE/internal/global/config" "github.com/FOR-GAMERS/GAMERS-BE/internal/global/middleware" authProvider "github.com/FOR-GAMERS/GAMERS-BE/internal/global/security/jwt" - "github.com/FOR-GAMERS/GAMERS-BE/internal/notification" "github.com/FOR-GAMERS/GAMERS-BE/internal/oauth2" "github.com/FOR-GAMERS/GAMERS-BE/internal/point" "github.com/FOR-GAMERS/GAMERS-BE/internal/storage" @@ -136,6 +136,9 @@ func main() { gameDeps.TeamService.SetContestRepository(contestDeps.ContestRepository) gameDeps.TournamentResultService.SetContestDBPort(contestDeps.ContestRepository) + // LoL module - provides LoL-specific team balancing and custom match sessions + lolDeps := lol.ProvideLolDependencies(appRouter, db, redisClient, tokenService) + commentDeps := comment.ProvideCommentDependencies(db, appRouter, contestDeps.ContestRepository) // Point module - provides Valorant score table management @@ -160,20 +163,13 @@ func main() { // Banner module - provides main banner management for homepage bannerDeps := banner.ProvideBannerDependencies(db, appRouter) - // Notification module - provides SSE real-time notifications - notificationDeps := notification.ProvideNotificationDependencies(db, appRouter) - // Wire score table port for point calculation during contest application contestDeps.ApplicationService.SetScoreTablePort(pointDeps.ScoreTableRepository) - // Wire notification handler to contest and game services - contestDeps.ApplicationService.SetNotificationHandler(notificationDeps.Service) - gameDeps.TeamService.SetNotificationHandler(notificationDeps.Service) - // Start Team Persistence Consumer for Write-Behind pattern startTeamPersistenceConsumer(ctx, gameDeps) - setupRouter(appRouter, authDeps, userDeps, oauth2Deps, contestDeps, commentDeps, discordDeps, gameDeps, pointDeps, valorantDeps, storageDeps, bannerDeps, notificationDeps) + setupRouter(appRouter, authDeps, userDeps, oauth2Deps, contestDeps, commentDeps, discordDeps, gameDeps, pointDeps, valorantDeps, storageDeps, bannerDeps, lolDeps) startServer(appRouter.Engine()) } @@ -213,7 +209,7 @@ func setupRouter( valorantDeps *valorant.Dependencies, storageDeps *storage.Dependencies, bannerDeps *banner.Dependencies, - notificationDeps *notification.Dependencies, + lolDeps *lol.Dependencies, ) *router.Router { appRouter.Engine().Use(middleware.PrometheusMetrics()) @@ -241,8 +237,8 @@ func setupRouter( if bannerDeps != nil { bannerDeps.Controller.RegisterRoutes() } - // Notification routes are registered in provider - _ = notificationDeps + lolDeps.Controller.RegisterRoutes() + lolDeps.SessionController.RegisterRoutes() return appRouter } diff --git a/db/migrations/000026_create_lol_custom_matches_table.down.sql b/db/migrations/000026_create_lol_custom_matches_table.down.sql new file mode 100644 index 0000000..88976a5 --- /dev/null +++ b/db/migrations/000026_create_lol_custom_matches_table.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS lol_custom_match_players; +DROP TABLE IF EXISTS lol_custom_matches; diff --git a/db/migrations/000026_create_lol_custom_matches_table.up.sql b/db/migrations/000026_create_lol_custom_matches_table.up.sql new file mode 100644 index 0000000..0027331 --- /dev/null +++ b/db/migrations/000026_create_lol_custom_matches_table.up.sql @@ -0,0 +1,40 @@ +CREATE TABLE lol_custom_matches ( + match_id BIGINT AUTO_INCREMENT PRIMARY KEY, + creator_user_id BIGINT NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'PENDING', + winner VARCHAR(8) NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + modified_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT fk_lol_matches_creator + FOREIGN KEY (creator_user_id) REFERENCES users(user_id) + ON DELETE CASCADE, + + CONSTRAINT chk_lol_match_status + CHECK (status IN ('PENDING', 'FINISHED', 'CANCELLED')), + + CONSTRAINT chk_lol_match_winner + CHECK (winner IN ('TEAM_A', 'TEAM_B') OR winner IS NULL), + + INDEX idx_lol_match_creator (creator_user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE lol_custom_match_players ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + match_id BIGINT NOT NULL, + team VARCHAR(8) NOT NULL, + username VARCHAR(64) NOT NULL, + tag VARCHAR(16) NOT NULL, + rank VARCHAR(32) NOT NULL, + position VARCHAR(8) NOT NULL, + position_preference INT NOT NULL, + + CONSTRAINT fk_lol_players_match + FOREIGN KEY (match_id) REFERENCES lol_custom_matches(match_id) + ON DELETE CASCADE, + + CONSTRAINT chk_lol_player_team + CHECK (team IN ('TEAM_A', 'TEAM_B')), + + INDEX idx_lol_players_match (match_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/docs/docs.go b/docs/docs.go index 988ded6..14d3a05 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -587,6 +587,69 @@ const docTemplate = `{ } } }, + "/api/contests/lol/temporal": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Accepts exactly 10 players each with their rank and position preferences (all 5 positions in priority order). Returns a balanced 5v5 team assignment using 3-step filtering: team MMR balance → lane matchup balance → position satisfaction. No data is persisted.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "contests" + ], + "summary": "Balance teams for a temporary LoL custom game", + "parameters": [ + { + "description": "10 players with position preferences", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalContestRequestV2" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_global_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalContestResponseV2" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_global_response.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_global_response.Response" + } + } + } + } + }, "/api/contests/me": { "get": { "security": [ @@ -725,7 +788,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Apply to participate in a contest", + "description": "Apply to participate in a contest with optional valorant roles and description", "consumes": [ "application/json" ], @@ -743,6 +806,14 @@ const docTemplate = `{ "name": "contestId", "in": "path", "required": true + }, + { + "description": "Participation request with optional valorant roles and description", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.RequestParticipateRequest" + } } ], "responses": { @@ -5838,6 +5909,98 @@ const docTemplate = `{ } } }, + "github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalAssignedPlayer": { + "type": "object", + "properties": { + "position": { + "type": "string" + }, + "position_preference": { + "description": "1 (most preferred) to 5 (least)", + "type": "integer" + }, + "rank": { + "type": "string" + }, + "tag": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalContestRequestV2": { + "type": "object", + "required": [ + "members" + ], + "properties": { + "members": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalPlayerV2" + } + } + } + }, + "github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalContestResponseV2": { + "type": "object", + "properties": { + "team_a": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalAssignedPlayer" + } + }, + "team_b": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalAssignedPlayer" + } + } + } + }, + "github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalPlayerV2": { + "type": "object", + "required": [ + "positions", + "rank", + "tag", + "username" + ], + "properties": { + "positions": { + "type": "array", + "items": { + "type": "string" + } + }, + "rank": { + "type": "string" + }, + "tag": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.RequestParticipateRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "valorant_roles": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_contest_domain.ValorantRole" + } + } + } + }, "github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.ThumbnailUploadResponse": { "type": "object", "properties": { @@ -5991,6 +6154,21 @@ const docTemplate = `{ "MemberTypeNormal" ] }, + "github_com_FOR-GAMERS_GAMERS-BE_internal_contest_domain.ValorantRole": { + "type": "string", + "enum": [ + "DUELIST", + "INITIATOR", + "CONTROLLER", + "SENTINEL" + ], + "x-enum-varnames": [ + "ValorantRoleDuelist", + "ValorantRoleInitiator", + "ValorantRoleController", + "ValorantRoleSentinel" + ] + }, "github_com_FOR-GAMERS_GAMERS-BE_internal_discord_application_dto.DiscordChannel": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 66148eb..69807a9 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -581,6 +581,69 @@ } } }, + "/api/contests/lol/temporal": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Accepts exactly 10 players each with their rank and position preferences (all 5 positions in priority order). Returns a balanced 5v5 team assignment using 3-step filtering: team MMR balance → lane matchup balance → position satisfaction. No data is persisted.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "contests" + ], + "summary": "Balance teams for a temporary LoL custom game", + "parameters": [ + { + "description": "10 players with position preferences", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalContestRequestV2" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_global_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalContestResponseV2" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_global_response.Response" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_global_response.Response" + } + } + } + } + }, "/api/contests/me": { "get": { "security": [ @@ -719,7 +782,7 @@ "BearerAuth": [] } ], - "description": "Apply to participate in a contest", + "description": "Apply to participate in a contest with optional valorant roles and description", "consumes": [ "application/json" ], @@ -737,6 +800,14 @@ "name": "contestId", "in": "path", "required": true + }, + { + "description": "Participation request with optional valorant roles and description", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.RequestParticipateRequest" + } } ], "responses": { @@ -5832,6 +5903,98 @@ } } }, + "github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalAssignedPlayer": { + "type": "object", + "properties": { + "position": { + "type": "string" + }, + "position_preference": { + "description": "1 (most preferred) to 5 (least)", + "type": "integer" + }, + "rank": { + "type": "string" + }, + "tag": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalContestRequestV2": { + "type": "object", + "required": [ + "members" + ], + "properties": { + "members": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalPlayerV2" + } + } + } + }, + "github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalContestResponseV2": { + "type": "object", + "properties": { + "team_a": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalAssignedPlayer" + } + }, + "team_b": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalAssignedPlayer" + } + } + } + }, + "github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalPlayerV2": { + "type": "object", + "required": [ + "positions", + "rank", + "tag", + "username" + ], + "properties": { + "positions": { + "type": "array", + "items": { + "type": "string" + } + }, + "rank": { + "type": "string" + }, + "tag": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.RequestParticipateRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "valorant_roles": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_contest_domain.ValorantRole" + } + } + } + }, "github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.ThumbnailUploadResponse": { "type": "object", "properties": { @@ -5985,6 +6148,21 @@ "MemberTypeNormal" ] }, + "github_com_FOR-GAMERS_GAMERS-BE_internal_contest_domain.ValorantRole": { + "type": "string", + "enum": [ + "DUELIST", + "INITIATOR", + "CONTROLLER", + "SENTINEL" + ], + "x-enum-varnames": [ + "ValorantRoleDuelist", + "ValorantRoleInitiator", + "ValorantRoleController", + "ValorantRoleSentinel" + ] + }, "github_com_FOR-GAMERS_GAMERS-BE_internal_discord_application_dto.DiscordChannel": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 1968772..511bfe0 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -222,6 +222,67 @@ definitions: oauth_url: type: string type: object + github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalAssignedPlayer: + properties: + position: + type: string + position_preference: + description: 1 (most preferred) to 5 (least) + type: integer + rank: + type: string + tag: + type: string + username: + type: string + type: object + github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalContestRequestV2: + properties: + members: + items: + $ref: '#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalPlayerV2' + type: array + required: + - members + type: object + github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalContestResponseV2: + properties: + team_a: + items: + $ref: '#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalAssignedPlayer' + type: array + team_b: + items: + $ref: '#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalAssignedPlayer' + type: array + type: object + github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalPlayerV2: + properties: + positions: + items: + type: string + type: array + rank: + type: string + tag: + type: string + username: + type: string + required: + - positions + - rank + - tag + - username + type: object + github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.RequestParticipateRequest: + properties: + description: + type: string + valorant_roles: + items: + $ref: '#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_contest_domain.ValorantRole' + type: array + type: object github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.ThumbnailUploadResponse: properties: key: @@ -329,6 +390,18 @@ definitions: x-enum-varnames: - MemberTypeStaff - MemberTypeNormal + github_com_FOR-GAMERS_GAMERS-BE_internal_contest_domain.ValorantRole: + enum: + - DUELIST + - INITIATOR + - CONTROLLER + - SENTINEL + type: string + x-enum-varnames: + - ValorantRoleDuelist + - ValorantRoleInitiator + - ValorantRoleController + - ValorantRoleSentinel github_com_FOR-GAMERS_GAMERS-BE_internal_discord_application_dto.DiscordChannel: properties: guild_id: @@ -1515,13 +1588,19 @@ paths: post: consumes: - application/json - description: Apply to participate in a contest + description: Apply to participate in a contest with optional valorant roles + and description parameters: - description: Contest ID in: path name: contestId required: true type: integer + - description: Participation request with optional valorant roles and description + in: body + name: request + schema: + $ref: '#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.RequestParticipateRequest' produces: - application/json responses: @@ -3249,6 +3328,46 @@ paths: summary: Get contest point tags: - valorant + /api/contests/lol/temporal: + post: + consumes: + - application/json + description: 'Accepts exactly 10 players each with their rank and position preferences + (all 5 positions in priority order). Returns a balanced 5v5 team assignment + using 3-step filtering: team MMR balance → lane matchup balance → position + satisfaction. No data is persisted.' + parameters: + - description: 10 players with position preferences + in: body + name: request + required: true + schema: + $ref: '#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalContestRequestV2' + produces: + - application/json + responses: + "201": + description: Created + schema: + allOf: + - $ref: '#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_global_response.Response' + - properties: + data: + $ref: '#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_contest_application_dto.LolTemporalContestResponseV2' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_global_response.Response' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/github_com_FOR-GAMERS_GAMERS-BE_internal_global_response.Response' + security: + - BearerAuth: [] + summary: Balance teams for a temporary LoL custom game + tags: + - contests /api/contests/me: get: consumes: diff --git a/go.mod b/go.mod index c8b80d0..03097fa 100644 --- a/go.mod +++ b/go.mod @@ -3,20 +3,19 @@ module github.com/FOR-GAMERS/GAMERS-BE go 1.25.5 require ( - github.com/aws/aws-sdk-go-v2 v1.41.1 - github.com/aws/aws-sdk-go-v2/config v1.32.7 - github.com/aws/aws-sdk-go-v2/credentials v1.19.7 - github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1 + cloud.google.com/go/storage v1.61.3 github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.11.0 github.com/go-sql-driver/mysql v1.9.3 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 github.com/prometheus/client_golang v1.23.2 github.com/rabbitmq/amqp091-go v1.9.0 github.com/redis/go-redis/v9 v9.17.2 + github.com/shirou/gopsutil/v4 v4.25.6 github.com/stretchr/testify v1.11.1 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 @@ -27,6 +26,7 @@ require ( golang.org/x/crypto v0.48.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 + google.golang.org/api v0.271.0 gorm.io/driver/mysql v1.6.0 gorm.io/gorm v1.31.1 ) @@ -39,7 +39,6 @@ require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect cloud.google.com/go/monitoring v1.24.3 // indirect - cloud.google.com/go/storage v1.61.3 // indirect dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect @@ -48,21 +47,6 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect - github.com/aws/smithy-go v1.24.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.14.2 // indirect @@ -110,7 +94,6 @@ require ( github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/googleapis/gax-go/v2 v2.17.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect @@ -144,7 +127,6 @@ require ( github.com/prometheus/procfs v0.16.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.58.0 // indirect - github.com/shirou/gopsutil/v4 v4.25.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/stretchr/objx v0.5.2 // indirect @@ -171,7 +153,6 @@ require ( golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.41.0 // indirect - google.golang.org/api v0.271.0 // indirect google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect diff --git a/go.sum b/go.sum index e6a916c..779708b 100644 --- a/go.sum +++ b/go.sum @@ -10,10 +10,16 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY= +cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw= +cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= +cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg= cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk= +cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= +cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= @@ -26,50 +32,14 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= -github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= -github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY= -github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY= -github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8= -github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g= -github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1 h1:C2dUPSnEpy4voWFIq3JNd8gN0Y5vYGDo44eUE58a/p8= -github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= -github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= -github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -121,8 +91,11 @@ github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -191,10 +164,14 @@ github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9v github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -346,32 +323,24 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= -go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 h1:dIIDULZJpgdiHz5tXrTgKIMLkus6jEFa7x5SOKcyR7E= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0/go.mod h1:jlRVBe7+Z1wyxFSUs48L6OBQZ5JwH2Hg/Vbl+t9rAgI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= -go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= -go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= -go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= -go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= -go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= -go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= -go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= +go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= @@ -385,31 +354,21 @@ golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -425,52 +384,38 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.271.0 h1:cIPN4qcUc61jlh7oXu6pwOQqbJW2GqYh5PS6rB2C/JY= google.golang.org/api v0.271.0/go.mod h1:CGT29bhwkbF+i11qkRUJb2KMKqcJ1hdFceEIRd9u64Q= -google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= -google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E= -google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk= google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 h1:7ei4lp52gK1uSejlA8AZl5AJjeLUOHBQscRQZUgAcu0= google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20/go.mod h1:ZdbssH/1SOVnjnDlXzxDHK2MCidiqXtbYccJNzNYPEE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/internal/contest/application/contest_application_service.go b/internal/contest/application/contest_application_service.go index dc2018d..3972f89 100644 --- a/internal/contest/application/contest_application_service.go +++ b/internal/contest/application/contest_application_service.go @@ -6,7 +6,6 @@ import ( "github.com/FOR-GAMERS/GAMERS-BE/internal/contest/domain" commonDto "github.com/FOR-GAMERS/GAMERS-BE/internal/global/common/dto" "github.com/FOR-GAMERS/GAMERS-BE/internal/global/exception" - notificationPort "github.com/FOR-GAMERS/GAMERS-BE/internal/notification/application/port" oauth2Port "github.com/FOR-GAMERS/GAMERS-BE/internal/oauth2/application/port" pointPort "github.com/FOR-GAMERS/GAMERS-BE/internal/point/application/port" userQueryPort "github.com/FOR-GAMERS/GAMERS-BE/internal/user/application/port/port" @@ -23,10 +22,9 @@ type ContestApplicationService struct { contestRepo port.ContestDatabasePort memberRepo port.ContestMemberDatabasePort eventPublisher port.EventPublisherPort - oauth2Repository oauth2Port.OAuth2DatabasePort - userQueryRepo userQueryPort.UserQueryPort - notificationHandler notificationPort.NotificationHandlerPort - scoreTableRepo pointPort.ValorantScoreTableDatabasePort + oauth2Repository oauth2Port.OAuth2DatabasePort + userQueryRepo userQueryPort.UserQueryPort + scoreTableRepo pointPort.ValorantScoreTableDatabasePort } func NewContestApplicationService( @@ -47,11 +45,6 @@ func NewContestApplicationService( } } -// SetNotificationHandler sets the notification handler (to avoid circular dependency) -func (s *ContestApplicationService) SetNotificationHandler(handler notificationPort.NotificationHandlerPort) { - s.notificationHandler = handler -} - // SetScoreTablePort sets the score table port (to avoid circular dependency) func (s *ContestApplicationService) SetScoreTablePort(scoreTableRepo pointPort.ValorantScoreTableDatabasePort) { s.scoreTableRepo = scoreTableRepo @@ -181,7 +174,16 @@ func (s *ContestApplicationService) AcceptApplication(ctx context.Context, conte } // Get application data before accepting (to preserve valorant roles and description) - application, appErr := s.applicationRepo.GetApplication(ctx, contestId, userId) + application, err := s.applicationRepo.GetApplication(ctx, contestId, userId) + if err != nil { + return fmt.Errorf("[AcceptApplication] failed to get application (contestId=%d, userId=%d): %w", contestId, userId, err) + } + if application == nil { + return exception.ErrApplicationNotFound + } + if application.Status != port.ApplicationStatusPending { + return exception.ErrApplicationNotPending + } err = s.applicationRepo.AcceptRequest(ctx, contestId, userId, leaderUserId) if err != nil { @@ -190,13 +192,9 @@ func (s *ContestApplicationService) AcceptApplication(ctx context.Context, conte // Restore point, valorant roles and description from the application sender snapshot point := 0 - if appErr == nil && application != nil && application.Sender != nil { - point = application.Sender.Point - } - member := domain.NewContestMember(userId, contestId, domain.MemberTypeNormal, domain.LeaderTypeMember, point) - - if appErr == nil && application != nil && application.Sender != nil { + if application.Sender != nil { + member.Point = application.Sender.Point member.ValorantRoles = application.Sender.ValorantRoles member.Description = application.Sender.Description } @@ -207,9 +205,6 @@ func (s *ContestApplicationService) AcceptApplication(ctx context.Context, conte go s.publishApplicationAcceptedEvent(context.Background(), contest, userId, leaderUserId) - // Send SSE notification to the applicant - go s.sendApplicationAcceptedNotification(contest, userId) - return nil } @@ -239,9 +234,6 @@ func (s *ContestApplicationService) RejectApplication(ctx context.Context, conte // 이벤트 발행 (비동기) go s.publishApplicationRejectedEvent(context.Background(), contest, userId, leaderUserId) - // Send SSE notification to the applicant - go s.sendApplicationRejectedNotification(contest, userId, "") - return nil } @@ -599,24 +591,3 @@ func (s *ContestApplicationService) publishApplicationCancelledEvent( } } -// sendApplicationAcceptedNotification sends SSE notification when application is accepted -func (s *ContestApplicationService) sendApplicationAcceptedNotification(contest *domain.Contest, userId int64) { - if s.notificationHandler == nil { - return - } - - if err := s.notificationHandler.HandleApplicationAccepted(userId, contest.ContestID, contest.Title); err != nil { - log.Printf("Failed to send application accepted notification: %v", err) - } -} - -// sendApplicationRejectedNotification sends SSE notification when application is rejected -func (s *ContestApplicationService) sendApplicationRejectedNotification(contest *domain.Contest, userId int64, reason string) { - if s.notificationHandler == nil { - return - } - - if err := s.notificationHandler.HandleApplicationRejected(userId, contest.ContestID, contest.Title, reason); err != nil { - log.Printf("Failed to send application rejected notification: %v", err) - } -} diff --git a/internal/contest/application/contest_service.go b/internal/contest/application/contest_service.go index d3bd513..1d9dbb6 100644 --- a/internal/contest/application/contest_service.go +++ b/internal/contest/application/contest_service.go @@ -507,9 +507,12 @@ func (c *ContestService) UploadThumbnail(ctx context.Context, contestId, userId // Delete old thumbnail from storage if exists if contest.BannerKey != nil && *contest.BannerKey != "" { + oldKey := *contest.BannerKey go func() { - if delErr := c.storagePort.Delete(context.Background(), *contest.BannerKey); delErr != nil { - log.Printf("[UploadThumbnail] Failed to delete old thumbnail %s: %v", *contest.BannerKey, delErr) + delCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if delErr := c.storagePort.Delete(delCtx, oldKey); delErr != nil { + log.Printf("[UploadThumbnail] Failed to delete old thumbnail %s: %v", oldKey, delErr) } }() } @@ -530,65 +533,3 @@ func (c *ContestService) UploadThumbnail(ctx context.Context, contestId, userId }, nil } -// BalanceLolTemporalContest accepts 10 players (2 per lane) and returns a balanced -// 5v5 team assignment where the total rank score difference is minimised. -// No DB persistence — this is a stateless computation. -func (c *ContestService) BalanceLolTemporalContest(req *dto.LolTemporalContestRequest) (*dto.LolTemporalContestResponse, error) { - if err := req.Validate(); err != nil { - return nil, err - } - - lanes := []string{"TOP", "JG", "MID", "ADC", "SUP"} - - // Build score matrix: scores[i][0] and scores[i][1] for each lane. - scores := make([][2]int, len(lanes)) - for i, lane := range lanes { - players := req.Members[lane] - s0, err := dto.ParseLolRankScore(players[0].Rank) - if err != nil { - return nil, err - } - s1, err := dto.ParseLolRankScore(players[1].Rank) - if err != nil { - return nil, err - } - scores[i] = [2]int{s0, s1} - } - - // Exhaustive search over 2^5=32 combinations. - // Bit i=0: players[0] of lane i → Team1; bit i=1: players[1] → Team1. - bestMask, bestDiff := 0, int(^uint(0)>>1) - for mask := 0; mask < 32; mask++ { - t1, t2 := 0, 0 - for i := range lanes { - if (mask>>i)&1 == 0 { - t1 += scores[i][0] - t2 += scores[i][1] - } else { - t1 += scores[i][1] - t2 += scores[i][0] - } - } - diff := t1 - t2 - if diff < 0 { - diff = -diff - } - if diff < bestDiff { - bestDiff = diff - bestMask = mask - } - } - - // Reorder each lane's slice so index 0 = Team1, index 1 = Team2. - result := make(map[string][]*dto.LolTemporalMember, len(lanes)) - for i, lane := range lanes { - players := req.Members[lane] - if (bestMask>>i)&1 == 0 { - result[lane] = []*dto.LolTemporalMember{players[0], players[1]} - } else { - result[lane] = []*dto.LolTemporalMember{players[1], players[0]} - } - } - - return &dto.LolTemporalContestResponse{Members: result}, nil -} diff --git a/internal/contest/application/dto/lol_temporal_dto.go b/internal/contest/application/dto/lol_temporal_dto.go deleted file mode 100644 index 21357f1..0000000 --- a/internal/contest/application/dto/lol_temporal_dto.go +++ /dev/null @@ -1,91 +0,0 @@ -package dto - -import ( - "strings" - - "github.com/FOR-GAMERS/GAMERS-BE/internal/global/exception" -) - -var lolTemporalLanes = []string{"TOP", "JG", "MID", "ADC", "SUP"} - -// LolTemporalMember represents a single player with their lane assignment info. -type LolTemporalMember struct { - Username string `json:"username" binding:"required"` - Tag string `json:"tag" binding:"required"` - Rank string `json:"rank" binding:"required"` -} - -// LolTemporalContestRequest is the request body for temporal LoL team balancing. -// Each lane must have exactly 2 players. -type LolTemporalContestRequest struct { - Members map[string][]*LolTemporalMember `json:"members" binding:"required"` -} - -func (r *LolTemporalContestRequest) Validate() error { - for _, lane := range lolTemporalLanes { - players, ok := r.Members[lane] - if !ok || len(players) != 2 { - return exception.ErrLolTemporalInvalidLane - } - for _, p := range players { - if p.Username == "" || p.Tag == "" || p.Rank == "" { - return exception.ErrLolTemporalInvalidLane - } - } - } - return nil -} - -// LolTemporalContestResponse contains the balanced team assignment. -// For each lane, index 0 is Team1's player and index 1 is Team2's player. -type LolTemporalContestResponse struct { - Members map[string][]*LolTemporalMember `json:"members"` -} - -// ParseLolRankScore converts a LoL rank string (e.g. "GOLD II", "MASTER") to a numeric score. -// Scores: IRON IV=1 … IRON I=4, BRONZE IV=5 … DIAMOND I=28, MASTER=29, GM=30, CHALLENGER=31. -func ParseLolRankScore(rank string) (int, error) { - rank = strings.ToUpper(strings.TrimSpace(rank)) - - tierBase := map[string]int{ - "IRON": 0, - "BRONZE": 4, - "SILVER": 8, - "GOLD": 12, - "PLATINUM": 16, - "EMERALD": 20, - "DIAMOND": 24, - "MASTER": 29, - "GRANDMASTER": 30, - "CHALLENGER": 31, - } - divisionScore := map[string]int{ - "IV": 1, "III": 2, "II": 3, "I": 4, - } - - parts := strings.Fields(rank) - if len(parts) == 0 { - return 0, exception.ErrLolTemporalInvalidRank - } - - base, ok := tierBase[parts[0]] - if !ok { - return 0, exception.ErrLolTemporalInvalidRank - } - - // Master+ tiers have no division - if parts[0] == "MASTER" || parts[0] == "GRANDMASTER" || parts[0] == "CHALLENGER" { - return base, nil - } - - if len(parts) < 2 { - return 0, exception.ErrLolTemporalInvalidRank - } - - div, ok := divisionScore[parts[1]] - if !ok { - return 0, exception.ErrLolTemporalInvalidRank - } - - return base + div, nil -} diff --git a/internal/contest/presentation/contest_controller.go b/internal/contest/presentation/contest_controller.go index bc23ea3..7eb198e 100644 --- a/internal/contest/presentation/contest_controller.go +++ b/internal/contest/presentation/contest_controller.go @@ -40,7 +40,6 @@ func (c *ContestController) RegisterRoute() { privateGroup.POST("/:id/thumbnail", c.UploadThumbnail) privateGroup.POST("/:id/start", c.StartContest) privateGroup.POST("/:id/stop", c.StopContest) - privateGroup.POST("/lol/temporal", c.LolTemporalContest) publicGroup := c.router.PublicGroup("/api/contests") publicGroup.GET("", c.GetAllContests) @@ -359,29 +358,6 @@ func (c *ContestController) GetMyContests(ctx *gin.Context) { c.helper.RespondOK(ctx, paginationResp, nil, "my contests retrieved successfully") } -// LolTemporalContest godoc -// @Summary Balance teams for a temporary LoL custom game -// @Description Accepts 10 players (2 per lane: TOP/JG/MID/ADC/SUP) with their ranks and returns a balanced 5v5 team assignment. No data is persisted. -// @Tags contests -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param request body dto.LolTemporalContestRequest true "Players by lane" -// @Success 201 {object} response.Response{data=dto.LolTemporalContestResponse} -// @Failure 400 {object} response.Response -// @Failure 401 {object} response.Response -// @Router /api/contests/lol/temporal [post] -func (c *ContestController) LolTemporalContest(ctx *gin.Context) { - var req dto.LolTemporalContestRequest - - if !c.helper.BindJSON(ctx, &req) { - return - } - - result, err := c.service.BalanceLolTemporalContest(&req) - c.helper.RespondCreated(ctx, result, err, "teams balanced successfully") -} - // ==================== Testable Handler Functions ==================== // These functions are exposed for unit testing without router dependency @@ -507,13 +483,4 @@ func HandleUploadThumbnail(ctx *gin.Context, service *application.ContestService helper.RespondCreated(ctx, result, err, "thumbnail uploaded successfully") } -func HandleLolTemporalContest(ctx *gin.Context, service *application.ContestService, helper *handler.ControllerHelper) { - var req dto.LolTemporalContestRequest - if err := ctx.ShouldBindJSON(&req); err != nil { - response.JSON(ctx, response.BadRequest(err.Error())) - return - } - result, err := service.BalanceLolTemporalContest(&req) - helper.RespondCreated(ctx, result, err, "teams balanced successfully") -} diff --git a/internal/game/application/team_service.go b/internal/game/application/team_service.go index 7bb7111..20a95b9 100644 --- a/internal/game/application/team_service.go +++ b/internal/game/application/team_service.go @@ -6,7 +6,6 @@ import ( "github.com/FOR-GAMERS/GAMERS-BE/internal/game/application/port" "github.com/FOR-GAMERS/GAMERS-BE/internal/game/domain" "github.com/FOR-GAMERS/GAMERS-BE/internal/global/exception" - notificationPort "github.com/FOR-GAMERS/GAMERS-BE/internal/notification/application/port" oauth2Port "github.com/FOR-GAMERS/GAMERS-BE/internal/oauth2/application/port" userQueryPort "github.com/FOR-GAMERS/GAMERS-BE/internal/user/application/port/port" "context" @@ -27,7 +26,6 @@ type TeamService struct { userQueryRepo userQueryPort.UserQueryPort eventPublisher port.TeamEventPublisherPort persistencePublisher port.TeamPersistencePublisherPort - notificationHandler notificationPort.NotificationHandlerPort } func NewTeamService( @@ -50,11 +48,6 @@ func NewTeamService( } } -// SetNotificationHandler sets the notification handler (to avoid circular dependency) -func (s *TeamService) SetNotificationHandler(handler notificationPort.NotificationHandlerPort) { - s.notificationHandler = handler -} - // SetContestRepository sets the contest repository (to avoid circular dependency) func (s *TeamService) SetContestRepository(repository contestPort.ContestDatabasePort) { s.contestRepository = repository @@ -298,16 +291,6 @@ func (s *TeamService) InviteMember(ctx context.Context, contestID, inviterUserID go s.publishInviteEventForContest(ctx, contest, inviterUserID, inviterDiscordID, inviter.Username, inviteeUserID, inviteeDiscordID, invitee.Username) } - // Get team name for notification - cachedTeam, _ := s.teamRedisRepo.GetTeam(ctx, contestID, teamID) - teamName := "" - if cachedTeam != nil && cachedTeam.TeamName != nil { - teamName = *cachedTeam.TeamName - } - - // Send SSE notification to invitee - go s.sendTeamInviteReceivedNotification(inviteeUserID, inviter.Username, teamName, 0, contestID) - return invite, nil } @@ -391,13 +374,6 @@ func (s *TeamService) AcceptInvite(ctx context.Context, contestID, teamID, invit go s.publishMemberJoinedEventForContest(ctx, contest, member, newCount, maxMembers) } - // Send SSE notification to inviter (the leader or whoever invited) - teamName := "" - if cachedTeam.TeamName != nil { - teamName = *cachedTeam.TeamName - } - go s.sendTeamInviteAcceptedNotification(cachedTeam.LeaderUserID, invitee.Username, teamName, 0, contestID) - // Publish member added for persistence (Write-Behind) go s.publishMemberAddedForPersistence(ctx, cachedTeam, member) @@ -406,23 +382,11 @@ func (s *TeamService) AcceptInvite(ctx context.Context, contestID, teamID, invit // RejectInvite rejects a team invitation func (s *TeamService) RejectInvite(ctx context.Context, contestID, teamID, inviteeUserID int64) error { - cachedTeam, _ := s.teamRedisRepo.GetTeam(ctx, contestID, teamID) - invitee, _ := s.userQueryRepo.FindById(inviteeUserID) - // Reject the invite if err := s.teamRedisRepo.RejectInvite(ctx, contestID, teamID, inviteeUserID); err != nil { return err } - // Send SSE notification to leader - if cachedTeam != nil && invitee != nil { - teamName := "" - if cachedTeam.TeamName != nil { - teamName = *cachedTeam.TeamName - } - go s.sendTeamInviteRejectedNotification(cachedTeam.LeaderUserID, invitee.Username, teamName, 0, contestID) - } - return nil } @@ -939,41 +903,6 @@ func (s *TeamService) publishContestTeamsReadyEvent(ctx context.Context, contest } } -// SSE notification helper methods - -// sendTeamInviteReceivedNotification sends SSE notification when user receives team invite -func (s *TeamService) sendTeamInviteReceivedNotification(inviteeUserID int64, inviterUsername, teamName string, gameID, contestID int64) { - if s.notificationHandler == nil { - return - } - - if err := s.notificationHandler.HandleTeamInviteReceived(inviteeUserID, inviterUsername, teamName, gameID, contestID); err != nil { - log.Printf("Failed to send team invite received notification: %v", err) - } -} - -// sendTeamInviteAcceptedNotification sends SSE notification when invite is accepted -func (s *TeamService) sendTeamInviteAcceptedNotification(inviterUserID int64, inviteeUsername, teamName string, gameID, contestID int64) { - if s.notificationHandler == nil { - return - } - - if err := s.notificationHandler.HandleTeamInviteAccepted(inviterUserID, inviteeUsername, teamName, gameID, contestID); err != nil { - log.Printf("Failed to send team invite accepted notification: %v", err) - } -} - -// sendTeamInviteRejectedNotification sends SSE notification when invite is rejected -func (s *TeamService) sendTeamInviteRejectedNotification(inviterUserID int64, inviteeUsername, teamName string, gameID, contestID int64) { - if s.notificationHandler == nil { - return - } - - if err := s.notificationHandler.HandleTeamInviteRejected(inviterUserID, inviteeUsername, teamName, gameID, contestID); err != nil { - log.Printf("Failed to send team invite rejected notification: %v", err) - } -} - // Write-Behind Pattern: Persistence event publishing helper methods // publishTeamCreatedForPersistence publishes event for async DB persistence diff --git a/internal/global/exception/contest_error_status.go b/internal/global/exception/contest_error_status.go index 1d739b2..1bf41c8 100644 --- a/internal/global/exception/contest_error_status.go +++ b/internal/global/exception/contest_error_status.go @@ -44,6 +44,8 @@ var ( ErrContestStartTimePassed = NewBadRequestError("contest start time has already passed", "CT038") // LoL Temporal Contest errors - ErrLolTemporalInvalidLane = NewBadRequestError("all lanes (TOP, JG, MID, ADC, SUP) must be provided with exactly 2 players each", "CT046") - ErrLolTemporalInvalidRank = NewBadRequestError("invalid lol rank format, expected e.g. GOLD II or MASTER", "CT047") + ErrLolTemporalInvalidLane = NewBadRequestError("all lanes (TOP, JG, MID, ADC, SUP) must be provided with exactly 2 players each", "CT046") + ErrLolTemporalInvalidRank = NewBadRequestError("invalid lol rank format, expected e.g. GOLD II or MASTER", "CT047") + ErrLolTemporalInvalidPlayerCount = NewBadRequestError("exactly 10 players are required", "CT048") + ErrLolTemporalInvalidPositions = NewBadRequestError("each player must provide all 5 positions (TOP, JG, MID, ADC, SUP) in preference order without duplicates", "CT049") ) diff --git a/internal/global/exception/lol_error_status.go b/internal/global/exception/lol_error_status.go new file mode 100644 index 0000000..258680b --- /dev/null +++ b/internal/global/exception/lol_error_status.go @@ -0,0 +1,21 @@ +package exception + +import "net/http" + +var ( + ErrLolCustomMatchNotFound = NewNotFoundError("lol custom match not found", "LOL001") + ErrLolCustomMatchForbidden = NewBusinessError(http.StatusForbidden, "you are not the creator of this match", "LOL002") + ErrLolCustomMatchAlreadyFinished = NewBadRequestError("match result has already been recorded", "LOL003") + ErrLolCustomMatchInvalidWinner = NewBadRequestError("winner must be TEAM_A or TEAM_B", "LOL004") + + // 세션 에러 + ErrLolSessionNotFound = NewNotFoundError("lol session not found", "LOL010") + ErrLolSessionForbidden = NewBusinessError(http.StatusForbidden, "only the host can perform this action", "LOL011") + ErrLolSessionFull = NewBadRequestError("session is full (max 10 players)", "LOL012") + ErrLolSessionAlreadyJoined = NewBadRequestError("you have already joined this session", "LOL013") + ErrLolSessionPlayerNotFound = NewNotFoundError("player not found in session", "LOL014") + ErrLolSessionNotWaiting = NewBadRequestError("session is not in WAITING status", "LOL015") + ErrLolSessionNotActive = NewBadRequestError("session is no longer active", "LOL016") + ErrLolSessionNotBalancing = NewBadRequestError("session must be in BALANCING status to record result", "LOL017") + ErrLolSessionInsufficientPlayers = NewBadRequestError("exactly 10 players are required to balance teams", "LOL018") +) diff --git a/internal/global/exception/notification_error_status.go b/internal/global/exception/notification_error_status.go deleted file mode 100644 index a0e0c26..0000000 --- a/internal/global/exception/notification_error_status.go +++ /dev/null @@ -1,21 +0,0 @@ -package exception - -var ( - ErrNotificationNotFound = &BusinessError{ - Status: 404, - Code: "NOTIFICATION_NOT_FOUND", - Message: "Notification not found", - } - - ErrNotificationSendFailed = &BusinessError{ - Status: 500, - Code: "NOTIFICATION_SEND_FAILED", - Message: "Failed to send notification", - } - - ErrSSEConnectionFailed = &BusinessError{ - Status: 500, - Code: "SSE_CONNECTION_FAILED", - Message: "Failed to establish SSE connection", - } -) diff --git a/internal/lol/application/dto/lol_session_dto.go b/internal/lol/application/dto/lol_session_dto.go new file mode 100644 index 0000000..50ec187 --- /dev/null +++ b/internal/lol/application/dto/lol_session_dto.go @@ -0,0 +1,84 @@ +package dto + +import "encoding/json" + +// WebSocket action types (클라이언트 → 서버) +const ( + WsActionJoin = "JOIN" + WsActionLeave = "LEAVE" + WsActionBalance = "BALANCE" + WsActionResult = "RESULT" + WsActionCancel = "CANCEL" +) + +// WebSocket event types (서버 → 클라이언트) +const ( + WsEventConnected = "CONNECTED" + WsEventPlayerJoined = "PLAYER_JOINED" + WsEventPlayerLeft = "PLAYER_LEFT" + WsEventBalanceDone = "BALANCE_DONE" + WsEventResultRecorded = "RESULT_RECORDED" + WsEventSessionClosed = "SESSION_CLOSED" + WsEventError = "ERROR" +) + +// WsClientMessage 는 클라이언트가 서버로 보내는 메시지 형식 +type WsClientMessage struct { + Type string `json:"type"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +// WsServerMessage 는 서버가 클라이언트에게 브로드캐스트하는 메시지 형식 +type WsServerMessage struct { + Event string `json:"event"` + Data interface{} `json:"data,omitempty"` +} + +// JOIN 액션 payload +type WsJoinPayload struct { + Username string `json:"username" binding:"required"` + Tag string `json:"tag" binding:"required"` + Rank string `json:"rank" binding:"required"` + Positions []string `json:"positions" binding:"required"` // 선호 순서 5개 +} + +// RESULT 액션 payload +type WsResultPayload struct { + Winner string `json:"winner" binding:"required"` +} + +// CONNECTED 이벤트 data — 연결 직후 현재 세션 상태 전송 +type WsConnectedData struct { + SessionID string `json:"session_id"` + HostUserID int64 `json:"host_user_id"` + Status string `json:"status"` + Players interface{} `json:"players"` +} + +// BALANCE_DONE 이벤트 data +type WsBalanceDoneData struct { + MatchID int64 `json:"match_id"` + TeamA interface{} `json:"team_a"` + TeamB interface{} `json:"team_b"` +} + +// RESULT_RECORDED 이벤트 data +type WsResultRecordedData struct { + MatchID int64 `json:"match_id"` + Winner string `json:"winner"` +} + +// REST: 세션 생성 응답 +type CreateSessionResponse struct { + SessionID string `json:"session_id"` +} + +// REST: 세션 조회 응답 +type GetSessionResponse struct { + SessionID string `json:"session_id"` + HostUserID int64 `json:"host_user_id"` + Status string `json:"status"` + Players interface{} `json:"players"` + BalanceResult interface{} `json:"balance_result,omitempty"` + CreatedAt string `json:"created_at"` +} diff --git a/internal/lol/application/dto/lol_temporal_dto.go b/internal/lol/application/dto/lol_temporal_dto.go new file mode 100644 index 0000000..4ace216 --- /dev/null +++ b/internal/lol/application/dto/lol_temporal_dto.go @@ -0,0 +1,167 @@ +package dto + +import ( + "strings" + + "github.com/FOR-GAMERS/GAMERS-BE/internal/global/exception" +) + +// ==================== V2: Position-preference based 5v5 balancing ==================== + +var lolAllPositions = []string{"TOP", "JG", "MID", "ADC", "SUP"} + +// LolTemporalPlayerV2 represents a player with their ranked position preferences. +// Positions must be all 5 lanes in preference order (1st = most preferred). +type LolTemporalPlayerV2 struct { + Username string `json:"username" binding:"required"` + Tag string `json:"tag" binding:"required"` + Rank string `json:"rank" binding:"required"` + Positions []string `json:"positions" binding:"required"` +} + +// LolTemporalContestRequestV2 is the request body for the V2 team balancing. +// Exactly 10 players must be provided; each player lists all 5 positions in preference order. +type LolTemporalContestRequestV2 struct { + Members []*LolTemporalPlayerV2 `json:"members" binding:"required"` +} + +func (r *LolTemporalContestRequestV2) Validate() error { + if len(r.Members) != 10 { + return exception.ErrLolTemporalInvalidPlayerCount + } + + validPos := map[string]bool{"TOP": true, "JG": true, "MID": true, "ADC": true, "SUP": true} + + for _, p := range r.Members { + if p.Username == "" || p.Tag == "" || p.Rank == "" { + return exception.ErrLolTemporalInvalidPlayerCount + } + if len(p.Positions) != 5 { + return exception.ErrLolTemporalInvalidPositions + } + seen := make(map[string]bool, 5) + for i, pos := range p.Positions { + pos = strings.ToUpper(strings.TrimSpace(pos)) + p.Positions[i] = pos + if !validPos[pos] { + return exception.ErrLolTemporalInvalidPositions + } + if seen[pos] { + return exception.ErrLolTemporalInvalidPositions + } + seen[pos] = true + } + } + return nil +} + +// LolTemporalAssignedPlayer is a player with their assigned position and how preferred it was. +type LolTemporalAssignedPlayer struct { + Username string `json:"username"` + Tag string `json:"tag"` + Rank string `json:"rank"` + Position string `json:"position"` + PositionPreference int `json:"position_preference"` // 1 (most preferred) to 5 (least) +} + +// LolTemporalContestResponseV2 contains the balanced team assignment. +type LolTemporalContestResponseV2 struct { + TeamA []*LolTemporalAssignedPlayer `json:"team_a"` + TeamB []*LolTemporalAssignedPlayer `json:"team_b"` +} + +// LolTemporalContestResponseWithMatchID wraps the balanced result with the persisted match ID. +type LolTemporalContestResponseWithMatchID struct { + MatchID int64 `json:"match_id"` + TeamA []*LolTemporalAssignedPlayer `json:"team_a"` + TeamB []*LolTemporalAssignedPlayer `json:"team_b"` +} + +// LolMatchResultRequest is the request body for recording match outcome. +type LolMatchResultRequest struct { + Winner string `json:"winner" binding:"required"` // "TEAM_A" or "TEAM_B" +} + +var lolTemporalLanes = []string{"TOP", "JG", "MID", "ADC", "SUP"} + +// LolTemporalMember represents a single player with their lane assignment info. +type LolTemporalMember struct { + Username string `json:"username" binding:"required"` + Tag string `json:"tag" binding:"required"` + Rank string `json:"rank" binding:"required"` +} + +// LolTemporalContestRequest is the request body for temporal LoL team balancing. +// Each lane must have exactly 2 players. +type LolTemporalContestRequest struct { + Members map[string][]*LolTemporalMember `json:"members" binding:"required"` +} + +func (r *LolTemporalContestRequest) Validate() error { + for _, lane := range lolTemporalLanes { + players, ok := r.Members[lane] + if !ok || len(players) != 2 { + return exception.ErrLolTemporalInvalidLane + } + for _, p := range players { + if p.Username == "" || p.Tag == "" || p.Rank == "" { + return exception.ErrLolTemporalInvalidLane + } + } + } + return nil +} + +// LolTemporalContestResponse contains the balanced team assignment. +// For each lane, index 0 is Team1's player and index 1 is Team2's player. +type LolTemporalContestResponse struct { + Members map[string][]*LolTemporalMember `json:"members"` +} + +// ParseLolRankScore converts a LoL rank string (e.g. "GOLD II", "MASTER") to a numeric score. +// Scores: IRON IV=1 … IRON I=4, BRONZE IV=5 … DIAMOND I=28, MASTER=29, GM=30, CHALLENGER=31. +func ParseLolRankScore(rank string) (int, error) { + rank = strings.ToUpper(strings.TrimSpace(rank)) + + tierBase := map[string]int{ + "IRON": 0, + "BRONZE": 4, + "SILVER": 8, + "GOLD": 12, + "PLATINUM": 16, + "EMERALD": 20, + "DIAMOND": 24, + "MASTER": 29, + "GRANDMASTER": 30, + "CHALLENGER": 31, + } + divisionScore := map[string]int{ + "IV": 1, "III": 2, "II": 3, "I": 4, + } + + parts := strings.Fields(rank) + if len(parts) == 0 { + return 0, exception.ErrLolTemporalInvalidRank + } + + base, ok := tierBase[parts[0]] + if !ok { + return 0, exception.ErrLolTemporalInvalidRank + } + + // Master+ tiers have no division + if parts[0] == "MASTER" || parts[0] == "GRANDMASTER" || parts[0] == "CHALLENGER" { + return base, nil + } + + if len(parts) < 2 { + return 0, exception.ErrLolTemporalInvalidRank + } + + div, ok := divisionScore[parts[1]] + if !ok { + return 0, exception.ErrLolTemporalInvalidRank + } + + return base + div, nil +} diff --git a/internal/lol/application/lol_session_service.go b/internal/lol/application/lol_session_service.go new file mode 100644 index 0000000..ffeb3a8 --- /dev/null +++ b/internal/lol/application/lol_session_service.go @@ -0,0 +1,202 @@ +package application + +import ( + "github.com/FOR-GAMERS/GAMERS-BE/internal/global/exception" + "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/application/dto" + "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/application/port" + "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/domain" + "github.com/google/uuid" +) + +type LolSessionService struct { + sessionRepo port.LolSessionRepositoryPort + balanceService *LolTeamBalanceService +} + +func NewLolSessionService( + sessionRepo port.LolSessionRepositoryPort, + balanceService *LolTeamBalanceService, +) *LolSessionService { + return &LolSessionService{ + sessionRepo: sessionRepo, + balanceService: balanceService, + } +} + +// CreateSession 은 새로운 커스텀 매치 세션을 생성하고 Redis에 저장한다. +func (s *LolSessionService) CreateSession(hostUserID int64) (*domain.LolSession, error) { + sessionID := uuid.New().String() + session := domain.NewLolSession(sessionID, hostUserID) + if err := s.sessionRepo.Save(session); err != nil { + return nil, err + } + return session, nil +} + +// GetSession 은 세션 상태를 조회한다. +func (s *LolSessionService) GetSession(sessionID string) (*domain.LolSession, error) { + return s.sessionRepo.FindByID(sessionID) +} + +// JoinSession 은 플레이어를 세션에 추가하고 Redis에 저장한다. +func (s *LolSessionService) JoinSession(sessionID string, userID int64, payload *dto.WsJoinPayload) error { + session, err := s.sessionRepo.FindByID(sessionID) + if err != nil { + return err + } + + player := &domain.LolSessionPlayer{ + UserID: userID, + Username: payload.Username, + Tag: payload.Tag, + Rank: payload.Rank, + Positions: payload.Positions, + } + + if err := session.AddPlayer(player); err != nil { + return err + } + + return s.sessionRepo.Save(session) +} + +// LeaveSession 은 플레이어를 세션에서 제거한다. +func (s *LolSessionService) LeaveSession(sessionID string, userID int64) error { + session, err := s.sessionRepo.FindByID(sessionID) + if err != nil { + return err + } + + if err := session.RemovePlayer(userID); err != nil { + return err + } + + return s.sessionRepo.Save(session) +} + +// BalanceSession 은 10명의 플레이어를 5v5로 배분하고 결과를 DB와 Redis에 저장한다. +// 방장만 호출할 수 있으며, 정확히 10명의 플레이어가 있어야 한다. +func (s *LolSessionService) BalanceSession(sessionID string, requesterUserID int64) (*domain.LolBalancedTeam, error) { + session, err := s.sessionRepo.FindByID(sessionID) + if err != nil { + return nil, err + } + + if !session.IsHost(requesterUserID) { + return nil, exception.ErrLolSessionForbidden + } + + if !session.IsActive() { + return nil, exception.ErrLolSessionNotActive + } + + if len(session.Players) != domain.LolSessionMaxPlayers { + return nil, exception.ErrLolSessionInsufficientPlayers + } + + req := buildBalanceRequest(session.Players) + result, err := s.balanceService.BalanceAndSave(requesterUserID, req) + if err != nil { + return nil, err + } + + balancedTeam := convertToBalancedTeam(result) + if err := session.ApplyBalanceResult(balancedTeam); err != nil { + return nil, err + } + + if err := s.sessionRepo.Save(session); err != nil { + return nil, err + } + + return balancedTeam, nil +} + +// RecordSessionResult 는 경기 결과를 DB에 기록하고 세션 상태를 FINISHED로 변경한다. +// 방장만 호출할 수 있으며, BALANCING 상태여야 한다. +func (s *LolSessionService) RecordSessionResult(sessionID string, requesterUserID int64, winner string) error { + session, err := s.sessionRepo.FindByID(sessionID) + if err != nil { + return err + } + + if !session.IsHost(requesterUserID) { + return exception.ErrLolSessionForbidden + } + + if session.Status != domain.LolSessionStatusBalancing || session.BalanceResult == nil { + return exception.ErrLolSessionNotBalancing + } + + matchID := session.BalanceResult.MatchID + if err := s.balanceService.RecordMatchResult(matchID, requesterUserID, winner); err != nil { + return err + } + + if err := session.Finish(); err != nil { + return err + } + + return s.sessionRepo.Save(session) +} + +// CancelSession 은 세션을 취소한다. 방장만 호출할 수 있다. +func (s *LolSessionService) CancelSession(sessionID string, requesterUserID int64) error { + session, err := s.sessionRepo.FindByID(sessionID) + if err != nil { + return err + } + + if !session.IsHost(requesterUserID) { + return exception.ErrLolSessionForbidden + } + + if err := session.Cancel(); err != nil { + return err + } + + return s.sessionRepo.Save(session) +} + +// buildBalanceRequest 는 세션 플레이어 목록을 팀 배분 요청 DTO로 변환한다. +func buildBalanceRequest(players []*domain.LolSessionPlayer) *dto.LolTemporalContestRequestV2 { + members := make([]*dto.LolTemporalPlayerV2, len(players)) + for i, p := range players { + members[i] = &dto.LolTemporalPlayerV2{ + Username: p.Username, + Tag: p.Tag, + Rank: p.Rank, + Positions: p.Positions, + } + } + return &dto.LolTemporalContestRequestV2{Members: members} +} + +// convertToBalancedTeam 은 팀 배분 결과를 도메인 타입으로 변환한다. +func convertToBalancedTeam(result *dto.LolTemporalContestResponseWithMatchID) *domain.LolBalancedTeam { + toAssigned := func(p *dto.LolTemporalAssignedPlayer) *domain.LolSessionAssignedPlayer { + return &domain.LolSessionAssignedPlayer{ + Username: p.Username, + Tag: p.Tag, + Rank: p.Rank, + Position: p.Position, + PositionPreference: p.PositionPreference, + } + } + + teamA := make([]*domain.LolSessionAssignedPlayer, len(result.TeamA)) + for i, p := range result.TeamA { + teamA[i] = toAssigned(p) + } + + teamB := make([]*domain.LolSessionAssignedPlayer, len(result.TeamB)) + for i, p := range result.TeamB { + teamB[i] = toAssigned(p) + } + + return &domain.LolBalancedTeam{ + MatchID: result.MatchID, + TeamA: teamA, + TeamB: teamB, + } +} diff --git a/internal/lol/application/lol_team_balance.go b/internal/lol/application/lol_team_balance.go new file mode 100644 index 0000000..34a55ee --- /dev/null +++ b/internal/lol/application/lol_team_balance.go @@ -0,0 +1,197 @@ +package application + +import ( + "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/application/dto" +) + +// lolPositions is the canonical order of LoL positions used as indices 0-4. +var lolPositions = []string{"TOP", "JG", "MID", "ADC", "SUP"} + +// positionIndex maps position name to its index in lolPositions. +var positionIndex = map[string]int{"TOP": 0, "JG": 1, "MID": 2, "ADC": 3, "SUP": 4} + +// precomputed combination/permutation tables — computed once at package init. +var ( + allLolCombos = generateCombinations(10, 5) // C(10,5) = 252 + allLolPerms = generatePermutations(5) // 5! = 120 +) + +// preferenceRankFor returns the 1-based preference rank (1=most preferred, 5=least) of pos +// in the given preference list. Returns 5 if not found (validation should prevent this). +func preferenceRankFor(positions []string, pos string) int { + for i, p := range positions { + if p == pos { + return i + 1 + } + } + return 5 +} + +// adjustedMMRScore returns the position-adjusted MMR score scaled by 16. +// Formula: baseMMR × (17 - preferenceRank) +// +// rank 1 → 16/16 = 100% +// rank 2 → 15/16 = 93.75% +// rank 5 → 12/16 = 75% +func adjustedMMRScore(baseMMR, preferenceRank int) int { + return baseMMR * (17 - preferenceRank) +} + +// lolTeamCandidate holds a particular (team split + position assignment) and its metric scores. +type lolTeamCandidate struct { + teamAIndices [5]int // indices into the 10-player slice + teamBIndices [5]int + permA [5]int // permA[i] = position index for teamA[i] + permB [5]int + teamBalance int // |adj_mmr_sum_A - adj_mmr_sum_B| (lower is better) + laneDeviation int // sum of per-position |adj_mmr_A - adj_mmr_B| (lower is better) + posSatisfaction int // sum of all 10 preference ranks (lower is better) +} + +func isBetterLolCandidate(a, b lolTeamCandidate) bool { + if a.teamBalance != b.teamBalance { + return a.teamBalance < b.teamBalance + } + if a.laneDeviation != b.laneDeviation { + return a.laneDeviation < b.laneDeviation + } + return a.posSatisfaction < b.posSatisfaction +} + +// generateCombinations returns all C(n,k) index combinations of [0..n-1]. +func generateCombinations(n, k int) [][]int { + result := make([][]int, 0) + combo := make([]int, 0, k) + var rec func(start int) + rec = func(start int) { + if len(combo) == k { + c := make([]int, k) + copy(c, combo) + result = append(result, c) + return + } + remaining := k - len(combo) + for i := start; i <= n-remaining; i++ { + combo = append(combo, i) + rec(i + 1) + combo = combo[:len(combo)-1] + } + } + rec(0) + return result +} + +// generatePermutations returns all n! permutations of [0..n-1]. +func generatePermutations(n int) [][]int { + result := make([][]int, 0) + perm := make([]int, n) + for i := range perm { + perm[i] = i + } + var rec func(start int) + rec = func(start int) { + if start == n { + p := make([]int, n) + copy(p, perm) + result = append(result, p) + return + } + for i := start; i < n; i++ { + perm[start], perm[i] = perm[i], perm[start] + rec(start + 1) + perm[start], perm[i] = perm[i], perm[start] + } + } + rec(0) + return result +} + +// balanceLolTeamsV2 runs the 3-step filtering algorithm over all C(10,5) × 5! × 5! +// combinations and returns the best team assignment. +// +// Step 1 – minimize |team_A_adj_mmr_sum - team_B_adj_mmr_sum| +// Step 2 – minimize sum of per-position |adj_mmr_A - adj_mmr_B| (lane matchup balance) +// Step 3 – minimize total preference rank sum across all 10 players (position satisfaction) +func balanceLolTeamsV2(players []*dto.LolTemporalPlayerV2, baseMMRs [10]int) lolTeamCandidate { + // Precompute per-player per-position adjusted MMR and preference rank. + var adjMMRTable [10][5]int + var prefRankTable [10][5]int + for p := 0; p < 10; p++ { + for posIdx, pos := range lolPositions { + rank := preferenceRankFor(players[p].Positions, pos) + prefRankTable[p][posIdx] = rank + adjMMRTable[p][posIdx] = adjustedMMRScore(baseMMRs[p], rank) + } + } + + var best lolTeamCandidate + bestSet := false + + for _, comboA := range allLolCombos { + inA := [10]bool{} + for _, idx := range comboA { + inA[idx] = true + } + comboB := make([]int, 0, 5) + for i := 0; i < 10; i++ { + if !inA[i] { + comboB = append(comboB, i) + } + } + + for _, permA := range allLolPerms { + adjMMR_A := 0 + posSat_A := 0 + var posAdjA [5]int + + for i, playerIdx := range comboA { + posIdx := permA[i] + adj := adjMMRTable[playerIdx][posIdx] + adjMMR_A += adj + posSat_A += prefRankTable[playerIdx][posIdx] + posAdjA[posIdx] = adj + } + + for _, permB := range allLolPerms { + adjMMR_B := 0 + posSat_B := 0 + laneDeviation := 0 + + for i, playerIdx := range comboB { + posIdx := permB[i] + adj := adjMMRTable[playerIdx][posIdx] + adjMMR_B += adj + posSat_B += prefRankTable[playerIdx][posIdx] + + diff := posAdjA[posIdx] - adj + if diff < 0 { + diff = -diff + } + laneDeviation += diff + } + + teamBalance := adjMMR_A - adjMMR_B + if teamBalance < 0 { + teamBalance = -teamBalance + } + + if !bestSet || teamBalance < best.teamBalance || + (teamBalance == best.teamBalance && laneDeviation < best.laneDeviation) || + (teamBalance == best.teamBalance && laneDeviation == best.laneDeviation && posSat_A+posSat_B < best.posSatisfaction) { + best.teamBalance = teamBalance + best.laneDeviation = laneDeviation + best.posSatisfaction = posSat_A + posSat_B + copy(best.teamAIndices[:], comboA) + copy(best.teamBIndices[:], comboB) + for j := 0; j < 5; j++ { + best.permA[j] = permA[j] + best.permB[j] = permB[j] + } + bestSet = true + } + } + } + } + + return best +} diff --git a/internal/lol/application/lol_team_balance_service.go b/internal/lol/application/lol_team_balance_service.go new file mode 100644 index 0000000..cf54278 --- /dev/null +++ b/internal/lol/application/lol_team_balance_service.go @@ -0,0 +1,196 @@ +package application + +import ( + "github.com/FOR-GAMERS/GAMERS-BE/internal/global/exception" + "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/application/dto" + "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/application/port" + "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/domain" +) + +type LolTeamBalanceService struct { + matchRepo port.LolCustomMatchRepositoryPort +} + +func NewLolTeamBalanceService(matchRepo port.LolCustomMatchRepositoryPort) *LolTeamBalanceService { + return &LolTeamBalanceService{matchRepo: matchRepo} +} + +// BalanceLolTemporalContestV2 accepts 10 players each with ranked position preferences +// and returns a balanced 5v5 team assignment using 3-step filtering: +// 1. Minimise |team_A_adj_mmr - team_B_adj_mmr| (adjusted for position preference) +// 2. Minimise total per-position matchup deviation +// 3. Minimise total position preference rank sum (highest satisfaction) +// +// No DB persistence — stateless computation. +func (s *LolTeamBalanceService) BalanceLolTemporalContestV2(req *dto.LolTemporalContestRequestV2) (*dto.LolTemporalContestResponseV2, error) { + if err := req.Validate(); err != nil { + return nil, err + } + + players := req.Members + var baseMMRs [10]int + for i, p := range players { + score, err := dto.ParseLolRankScore(p.Rank) + if err != nil { + return nil, err + } + baseMMRs[i] = score + } + + best := balanceLolTeamsV2(players, baseMMRs) + + buildPlayer := func(playerIdx, permIdx int) *dto.LolTemporalAssignedPlayer { + pos := lolPositions[permIdx] + p := players[playerIdx] + return &dto.LolTemporalAssignedPlayer{ + Username: p.Username, + Tag: p.Tag, + Rank: p.Rank, + Position: pos, + PositionPreference: preferenceRankFor(p.Positions, pos), + } + } + + teamA := make([]*dto.LolTemporalAssignedPlayer, 5) + teamB := make([]*dto.LolTemporalAssignedPlayer, 5) + for i := 0; i < 5; i++ { + teamA[i] = buildPlayer(best.teamAIndices[i], best.permA[i]) + teamB[i] = buildPlayer(best.teamBIndices[i], best.permB[i]) + } + + return &dto.LolTemporalContestResponseV2{TeamA: teamA, TeamB: teamB}, nil +} + +// BalanceAndSave balances 10 players into a 5v5 and persists the result for the given user. +// Returns the response including the created match_id. +func (s *LolTeamBalanceService) BalanceAndSave(creatorUserID int64, req *dto.LolTemporalContestRequestV2) (*dto.LolTemporalContestResponseWithMatchID, error) { + result, err := s.BalanceLolTemporalContestV2(req) + if err != nil { + return nil, err + } + + if s.matchRepo == nil { + return &dto.LolTemporalContestResponseWithMatchID{TeamA: result.TeamA, TeamB: result.TeamB}, nil + } + + match := domain.NewLolCustomMatch(creatorUserID) + players := buildMatchPlayers(result) + + if err := s.matchRepo.Save(match, players); err != nil { + return nil, err + } + + return &dto.LolTemporalContestResponseWithMatchID{ + MatchID: match.MatchID, + TeamA: result.TeamA, + TeamB: result.TeamB, + }, nil +} + +// RecordMatchResult records the winner for a previously saved custom match. +// Only the creator of the match can record the result. +func (s *LolTeamBalanceService) RecordMatchResult(matchID, creatorUserID int64, winner string) error { + w, err := domain.ParseLolMatchWinner(winner) + if err != nil { + return err + } + + match, err := s.matchRepo.FindByID(matchID) + if err != nil { + return err + } + + if match.CreatorUserID != creatorUserID { + return exception.ErrLolCustomMatchForbidden + } + + if err := match.RecordResult(w); err != nil { + return err + } + + return s.matchRepo.UpdateResult(match) +} + +// BalanceLolTemporalContest accepts 10 players (2 per lane) and returns a balanced +// 5v5 team assignment where the total rank score difference is minimised. +// No DB persistence — this is a stateless computation. +func (s *LolTeamBalanceService) BalanceLolTemporalContest(req *dto.LolTemporalContestRequest) (*dto.LolTemporalContestResponse, error) { + if err := req.Validate(); err != nil { + return nil, err + } + + lanes := []string{"TOP", "JG", "MID", "ADC", "SUP"} + + scores := make([][2]int, len(lanes)) + for i, lane := range lanes { + players := req.Members[lane] + s0, err := dto.ParseLolRankScore(players[0].Rank) + if err != nil { + return nil, err + } + s1, err := dto.ParseLolRankScore(players[1].Rank) + if err != nil { + return nil, err + } + scores[i] = [2]int{s0, s1} + } + + bestMask, bestDiff := 0, int(^uint(0)>>1) + for mask := 0; mask < 32; mask++ { + t1, t2 := 0, 0 + for i := range lanes { + if (mask>>i)&1 == 0 { + t1 += scores[i][0] + t2 += scores[i][1] + } else { + t1 += scores[i][1] + t2 += scores[i][0] + } + } + diff := t1 - t2 + if diff < 0 { + diff = -diff + } + if diff < bestDiff { + bestDiff = diff + bestMask = mask + } + } + + result := make(map[string][]*dto.LolTemporalMember, len(lanes)) + for i, lane := range lanes { + players := req.Members[lane] + if (bestMask>>i)&1 == 0 { + result[lane] = []*dto.LolTemporalMember{players[0], players[1]} + } else { + result[lane] = []*dto.LolTemporalMember{players[1], players[0]} + } + } + + return &dto.LolTemporalContestResponse{Members: result}, nil +} + +func buildMatchPlayers(result *dto.LolTemporalContestResponseV2) []*domain.LolCustomMatchPlayer { + players := make([]*domain.LolCustomMatchPlayer, 0, 10) + for _, p := range result.TeamA { + players = append(players, &domain.LolCustomMatchPlayer{ + Team: string(domain.LolMatchWinnerTeamA), + Username: p.Username, + Tag: p.Tag, + Rank: p.Rank, + Position: p.Position, + PositionPreference: p.PositionPreference, + }) + } + for _, p := range result.TeamB { + players = append(players, &domain.LolCustomMatchPlayer{ + Team: string(domain.LolMatchWinnerTeamB), + Username: p.Username, + Tag: p.Tag, + Rank: p.Rank, + Position: p.Position, + PositionPreference: p.PositionPreference, + }) + } + return players +} diff --git a/internal/lol/application/port/lol_custom_match_repository_port.go b/internal/lol/application/port/lol_custom_match_repository_port.go new file mode 100644 index 0000000..c84a662 --- /dev/null +++ b/internal/lol/application/port/lol_custom_match_repository_port.go @@ -0,0 +1,9 @@ +package port + +import "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/domain" + +type LolCustomMatchRepositoryPort interface { + Save(match *domain.LolCustomMatch, players []*domain.LolCustomMatchPlayer) error + FindByID(matchID int64) (*domain.LolCustomMatch, error) + UpdateResult(match *domain.LolCustomMatch) error +} diff --git a/internal/lol/application/port/lol_session_port.go b/internal/lol/application/port/lol_session_port.go new file mode 100644 index 0000000..1f9d117 --- /dev/null +++ b/internal/lol/application/port/lol_session_port.go @@ -0,0 +1,9 @@ +package port + +import "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/domain" + +type LolSessionRepositoryPort interface { + Save(session *domain.LolSession) error + FindByID(sessionID string) (*domain.LolSession, error) + Delete(sessionID string) error +} diff --git a/internal/lol/domain/lol_custom_match.go b/internal/lol/domain/lol_custom_match.go new file mode 100644 index 0000000..27384d7 --- /dev/null +++ b/internal/lol/domain/lol_custom_match.go @@ -0,0 +1,81 @@ +package domain + +import ( + "time" + + "github.com/FOR-GAMERS/GAMERS-BE/internal/global/exception" +) + +type LolMatchStatus string + +const ( + LolMatchStatusPending LolMatchStatus = "PENDING" + LolMatchStatusFinished LolMatchStatus = "FINISHED" + LolMatchStatusCancelled LolMatchStatus = "CANCELLED" +) + +type LolMatchWinner string + +const ( + LolMatchWinnerTeamA LolMatchWinner = "TEAM_A" + LolMatchWinnerTeamB LolMatchWinner = "TEAM_B" +) + +func ParseLolMatchWinner(s string) (LolMatchWinner, error) { + switch s { + case string(LolMatchWinnerTeamA): + return LolMatchWinnerTeamA, nil + case string(LolMatchWinnerTeamB): + return LolMatchWinnerTeamB, nil + default: + return "", exception.ErrLolCustomMatchInvalidWinner + } +} + +type LolCustomMatch struct { + MatchID int64 `gorm:"column:match_id;primaryKey;autoIncrement"` + CreatorUserID int64 `gorm:"column:creator_user_id;not null"` + Status LolMatchStatus `gorm:"column:status;type:varchar(16);not null;default:'PENDING'"` + Winner *LolMatchWinner `gorm:"column:winner;type:varchar(8)"` + CreatedAt time.Time `gorm:"column:created_at"` + ModifiedAt time.Time `gorm:"column:modified_at"` +} + +func NewLolCustomMatch(creatorUserID int64) *LolCustomMatch { + now := time.Now() + return &LolCustomMatch{ + CreatorUserID: creatorUserID, + Status: LolMatchStatusPending, + CreatedAt: now, + ModifiedAt: now, + } +} + +func (m *LolCustomMatch) TableName() string { + return "lol_custom_matches" +} + +func (m *LolCustomMatch) RecordResult(winner LolMatchWinner) error { + if m.Status != LolMatchStatusPending { + return exception.ErrLolCustomMatchAlreadyFinished + } + m.Status = LolMatchStatusFinished + m.Winner = &winner + m.ModifiedAt = time.Now() + return nil +} + +type LolCustomMatchPlayer struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement"` + MatchID int64 `gorm:"column:match_id;not null"` + Team string `gorm:"column:team;type:varchar(8);not null"` + Username string `gorm:"column:username;type:varchar(64);not null"` + Tag string `gorm:"column:tag;type:varchar(16);not null"` + Rank string `gorm:"column:rank;type:varchar(32);not null"` + Position string `gorm:"column:position;type:varchar(8);not null"` + PositionPreference int `gorm:"column:position_preference;not null"` +} + +func (p *LolCustomMatchPlayer) TableName() string { + return "lol_custom_match_players" +} diff --git a/internal/lol/domain/lol_session.go b/internal/lol/domain/lol_session.go new file mode 100644 index 0000000..f38123a --- /dev/null +++ b/internal/lol/domain/lol_session.go @@ -0,0 +1,119 @@ +package domain + +import ( + "time" + + "github.com/FOR-GAMERS/GAMERS-BE/internal/global/exception" +) + +type LolSessionStatus string + +const ( + LolSessionStatusWaiting LolSessionStatus = "WAITING" + LolSessionStatusBalancing LolSessionStatus = "BALANCING" + LolSessionStatusFinished LolSessionStatus = "FINISHED" + LolSessionStatusCancelled LolSessionStatus = "CANCELLED" +) + +const LolSessionMaxPlayers = 10 + +type LolSessionPlayer struct { + UserID int64 `json:"user_id"` + Username string `json:"username"` + Tag string `json:"tag"` + Rank string `json:"rank"` + Positions []string `json:"positions"` // 포지션 선호 순서 +} + +type LolSessionAssignedPlayer struct { + UserID int64 `json:"user_id"` + Username string `json:"username"` + Tag string `json:"tag"` + Rank string `json:"rank"` + Position string `json:"position"` + PositionPreference int `json:"position_preference"` +} + +type LolBalancedTeam struct { + MatchID int64 `json:"match_id"` + TeamA []*LolSessionAssignedPlayer `json:"team_a"` + TeamB []*LolSessionAssignedPlayer `json:"team_b"` +} + +type LolSession struct { + SessionID string `json:"session_id"` + HostUserID int64 `json:"host_user_id"` + Status LolSessionStatus `json:"status"` + Players []*LolSessionPlayer `json:"players"` + BalanceResult *LolBalancedTeam `json:"balance_result,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +func NewLolSession(sessionID string, hostUserID int64) *LolSession { + return &LolSession{ + SessionID: sessionID, + HostUserID: hostUserID, + Status: LolSessionStatusWaiting, + Players: make([]*LolSessionPlayer, 0), + CreatedAt: time.Now(), + } +} + +func (s *LolSession) IsHost(userID int64) bool { + return s.HostUserID == userID +} + +func (s *LolSession) IsActive() bool { + return s.Status == LolSessionStatusWaiting || s.Status == LolSessionStatusBalancing +} + +func (s *LolSession) AddPlayer(p *LolSessionPlayer) error { + if s.Status != LolSessionStatusWaiting { + return exception.ErrLolSessionNotWaiting + } + if len(s.Players) >= LolSessionMaxPlayers { + return exception.ErrLolSessionFull + } + for _, existing := range s.Players { + if existing.UserID == p.UserID { + return exception.ErrLolSessionAlreadyJoined + } + } + s.Players = append(s.Players, p) + return nil +} + +func (s *LolSession) RemovePlayer(userID int64) error { + for i, p := range s.Players { + if p.UserID == userID { + s.Players = append(s.Players[:i], s.Players[i+1:]...) + return nil + } + } + return exception.ErrLolSessionPlayerNotFound +} + +func (s *LolSession) ApplyBalanceResult(result *LolBalancedTeam) error { + if !s.IsActive() { + return exception.ErrLolSessionNotActive + } + s.BalanceResult = result + s.Status = LolSessionStatusBalancing + return nil +} + +func (s *LolSession) Finish() error { + if s.Status != LolSessionStatusBalancing { + return exception.ErrLolSessionNotBalancing + } + s.Status = LolSessionStatusFinished + return nil +} + +func (s *LolSession) Cancel() error { + if !s.IsActive() { + return exception.ErrLolSessionNotActive + } + s.Status = LolSessionStatusCancelled + return nil +} diff --git a/internal/lol/infra/persistence/adapter/lol_custom_match_database_adapter.go b/internal/lol/infra/persistence/adapter/lol_custom_match_database_adapter.go new file mode 100644 index 0000000..eddf365 --- /dev/null +++ b/internal/lol/infra/persistence/adapter/lol_custom_match_database_adapter.go @@ -0,0 +1,47 @@ +package adapter + +import ( + "github.com/FOR-GAMERS/GAMERS-BE/internal/global/exception" + "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/domain" + + "gorm.io/gorm" +) + +type LolCustomMatchDatabaseAdapter struct { + db *gorm.DB +} + +func NewLolCustomMatchDatabaseAdapter(db *gorm.DB) *LolCustomMatchDatabaseAdapter { + return &LolCustomMatchDatabaseAdapter{db: db} +} + +func (a *LolCustomMatchDatabaseAdapter) Save(match *domain.LolCustomMatch, players []*domain.LolCustomMatchPlayer) error { + return a.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(match).Error; err != nil { + return err + } + for _, p := range players { + p.MatchID = match.MatchID + } + return tx.Create(&players).Error + }) +} + +func (a *LolCustomMatchDatabaseAdapter) FindByID(matchID int64) (*domain.LolCustomMatch, error) { + var match domain.LolCustomMatch + if err := a.db.First(&match, "match_id = ?", matchID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, exception.ErrLolCustomMatchNotFound + } + return nil, err + } + return &match, nil +} + +func (a *LolCustomMatchDatabaseAdapter) UpdateResult(match *domain.LolCustomMatch) error { + return a.db.Model(match).Updates(map[string]interface{}{ + "status": match.Status, + "winner": match.Winner, + "modified_at": match.ModifiedAt, + }).Error +} diff --git a/internal/lol/infra/redis/lol_session_redis_adapter.go b/internal/lol/infra/redis/lol_session_redis_adapter.go new file mode 100644 index 0000000..9f71dff --- /dev/null +++ b/internal/lol/infra/redis/lol_session_redis_adapter.go @@ -0,0 +1,56 @@ +package redis + +import ( + "context" + "encoding/json" + "time" + + "github.com/FOR-GAMERS/GAMERS-BE/internal/global/exception" + "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/domain" + "github.com/redis/go-redis/v9" +) + +const ( + sessionKeyPrefix = "lol:session:" + sessionTTL = 2 * time.Hour +) + +type LolSessionRedisAdapter struct { + client *redis.Client +} + +func NewLolSessionRedisAdapter(client *redis.Client) *LolSessionRedisAdapter { + return &LolSessionRedisAdapter{client: client} +} + +func (a *LolSessionRedisAdapter) Save(session *domain.LolSession) error { + data, err := json.Marshal(session) + if err != nil { + return err + } + return a.client.Set(context.Background(), sessionKey(session.SessionID), data, sessionTTL).Err() +} + +func (a *LolSessionRedisAdapter) FindByID(sessionID string) (*domain.LolSession, error) { + data, err := a.client.Get(context.Background(), sessionKey(sessionID)).Bytes() + if err == redis.Nil { + return nil, exception.ErrLolSessionNotFound + } + if err != nil { + return nil, err + } + + var session domain.LolSession + if err := json.Unmarshal(data, &session); err != nil { + return nil, err + } + return &session, nil +} + +func (a *LolSessionRedisAdapter) Delete(sessionID string) error { + return a.client.Del(context.Background(), sessionKey(sessionID)).Err() +} + +func sessionKey(sessionID string) string { + return sessionKeyPrefix + sessionID +} diff --git a/internal/lol/infra/ws/lol_session_hub.go b/internal/lol/infra/ws/lol_session_hub.go new file mode 100644 index 0000000..0bf6287 --- /dev/null +++ b/internal/lol/infra/ws/lol_session_hub.go @@ -0,0 +1,178 @@ +package ws + +import ( + "log" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +const ( + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = (pongWait * 9) / 10 + maxMessageSize = 4096 + sendBufferSize = 256 +) + +var Upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + // CORS는 Gin 미들웨어에서 처리 — 여기서는 허용 + return true + }, +} + +// ActionHandler 는 클라이언트 메시지를 처리하는 콜백 타입 +type ActionHandler func(sessionID string, senderUserID int64, message []byte) + +// Client 는 단일 WebSocket 연결을 나타낸다 +type Client struct { + conn *websocket.Conn + send chan []byte + sessionID string + userID int64 + hub *SessionHub +} + +func NewClient(conn *websocket.Conn, sessionID string, userID int64, hub *SessionHub) *Client { + return &Client{ + conn: conn, + send: make(chan []byte, sendBufferSize), + sessionID: sessionID, + userID: userID, + hub: hub, + } +} + +// ReadPump 은 WebSocket으로부터 메시지를 읽어 handler로 전달한다. +// 연결이 끊어지면 Hub에서 Unregister한다. +func (c *Client) ReadPump(handler ActionHandler) { + defer func() { + c.hub.Unregister(c) + c.conn.Close() + }() + + c.conn.SetReadLimit(maxMessageSize) + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + c.conn.SetPongHandler(func(string) error { + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + + for { + _, message, err := c.conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("[SessionHub] ws error session=%s user=%d: %v", c.sessionID, c.userID, err) + } + break + } + handler(c.sessionID, c.userID, message) + } +} + +// WritePump 은 send 채널에서 메시지를 읽어 WebSocket으로 쓴다. +// send 채널이 닫히면 종료한다. +func (c *Client) WritePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.conn.Close() + }() + + for { + select { + case message, ok := <-c.send: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil { + log.Printf("[SessionHub] write error session=%s user=%d: %v", c.sessionID, c.userID, err) + return + } + case <-ticker.C: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +// SendMessage 는 클라이언트의 send 채널에 메시지를 넣는다. +func (c *Client) SendMessage(message []byte) { + select { + case c.send <- message: + default: + log.Printf("[SessionHub] send buffer full, dropping message for user=%d", c.userID) + } +} + +// SessionHub 는 세션별 WebSocket 연결 집합을 관리한다. +type SessionHub struct { + sessions map[string]map[*Client]bool + mu sync.RWMutex +} + +func NewSessionHub() *SessionHub { + return &SessionHub{ + sessions: make(map[string]map[*Client]bool), + } +} + +// Register 는 클라이언트를 세션에 등록한다. +func (h *SessionHub) Register(client *Client) { + h.mu.Lock() + defer h.mu.Unlock() + if _, ok := h.sessions[client.sessionID]; !ok { + h.sessions[client.sessionID] = make(map[*Client]bool) + } + h.sessions[client.sessionID][client] = true + log.Printf("[SessionHub] registered user=%d session=%s total=%d", client.userID, client.sessionID, len(h.sessions[client.sessionID])) +} + +// Unregister 는 클라이언트를 세션에서 제거하고 send 채널을 닫는다. +func (h *SessionHub) Unregister(client *Client) { + h.mu.Lock() + defer h.mu.Unlock() + clients, ok := h.sessions[client.sessionID] + if !ok { + return + } + if _, exists := clients[client]; !exists { + return + } + delete(clients, client) + close(client.send) + if len(clients) == 0 { + delete(h.sessions, client.sessionID) + } + log.Printf("[SessionHub] unregistered user=%d session=%s", client.userID, client.sessionID) +} + +// Broadcast 는 세션 내 모든 클라이언트에게 메시지를 전송한다. +func (h *SessionHub) Broadcast(sessionID string, message []byte) { + h.mu.RLock() + defer h.mu.RUnlock() + for client := range h.sessions[sessionID] { + client.SendMessage(message) + } +} + +// SendToUser 는 세션 내 특정 유저에게만 메시지를 전송한다. +func (h *SessionHub) SendToUser(sessionID string, userID int64, message []byte) { + h.mu.RLock() + defer h.mu.RUnlock() + for client := range h.sessions[sessionID] { + if client.userID == userID { + client.SendMessage(message) + return + } + } +} diff --git a/internal/lol/presentation/lol_session_controller.go b/internal/lol/presentation/lol_session_controller.go new file mode 100644 index 0000000..8f48374 --- /dev/null +++ b/internal/lol/presentation/lol_session_controller.go @@ -0,0 +1,319 @@ +package presentation + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/FOR-GAMERS/GAMERS-BE/internal/auth/middleware" + "github.com/FOR-GAMERS/GAMERS-BE/internal/global/common/handler" + "github.com/FOR-GAMERS/GAMERS-BE/internal/global/common/router" + "github.com/FOR-GAMERS/GAMERS-BE/internal/global/response" + jwtApp "github.com/FOR-GAMERS/GAMERS-BE/internal/global/security/jwt/application" + jwtDomain "github.com/FOR-GAMERS/GAMERS-BE/internal/global/security/jwt/domain" + "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/application" + "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/application/dto" + "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/infra/ws" + + "github.com/gin-gonic/gin" +) + +type LolSessionController struct { + router *router.Router + sessionService *application.LolSessionService + hub *ws.SessionHub + tokenService *jwtApp.TokenService + helper *handler.ControllerHelper +} + +func NewLolSessionController( + r *router.Router, + sessionService *application.LolSessionService, + hub *ws.SessionHub, + tokenService *jwtApp.TokenService, + helper *handler.ControllerHelper, +) *LolSessionController { + return &LolSessionController{ + router: r, + sessionService: sessionService, + hub: hub, + tokenService: tokenService, + helper: helper, + } +} + +func (c *LolSessionController) RegisterRoutes() { + protected := c.router.ProtectedGroup("/api/lol/sessions") + protected.POST("", c.CreateSession) + protected.DELETE("/:sessionId", c.CancelSession) + + public := c.router.PublicGroup("/api/lol/sessions") + public.GET("/:sessionId", c.GetSession) + public.GET("/:sessionId/ws", c.HandleWebSocket) +} + +// CreateSession godoc +// @Summary LoL 커스텀 매치 세션 생성 +// @Description 방장이 새로운 커스텀 매치 세션을 생성한다. 반환된 session_id를 공유하여 참가자를 모집한다. +// @Tags lol-session +// @Produce json +// @Security BearerAuth +// @Success 201 {object} response.Response{data=dto.CreateSessionResponse} +// @Failure 401 {object} response.Response +// @Router /api/lol/sessions [post] +func (c *LolSessionController) CreateSession(ctx *gin.Context) { + userID, ok := getAuthenticatedUserID(ctx) + if !ok { + return + } + + session, err := c.sessionService.CreateSession(userID) + c.helper.RespondCreated(ctx, &dto.CreateSessionResponse{SessionID: session.SessionID}, err, "session created") +} + +// GetSession godoc +// @Summary LoL 커스텀 매치 세션 조회 +// @Description 세션 ID로 현재 세션 상태를 조회한다. 인증 불필요. +// @Tags lol-session +// @Produce json +// @Param sessionId path string true "Session ID (UUID)" +// @Success 200 {object} response.Response{data=dto.GetSessionResponse} +// @Failure 404 {object} response.Response +// @Router /api/lol/sessions/{sessionId} [get] +func (c *LolSessionController) GetSession(ctx *gin.Context) { + sessionID := ctx.Param("sessionId") + + session, err := c.sessionService.GetSession(sessionID) + if err != nil { + c.helper.HandleError(ctx, err) + return + } + + resp := &dto.GetSessionResponse{ + SessionID: session.SessionID, + HostUserID: session.HostUserID, + Status: string(session.Status), + Players: session.Players, + BalanceResult: session.BalanceResult, + CreatedAt: session.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + response.JSON(ctx, response.Success(resp, "session retrieved")) +} + +// CancelSession godoc +// @Summary LoL 커스텀 매치 세션 취소 +// @Description 방장이 세션을 취소한다. +// @Tags lol-session +// @Produce json +// @Security BearerAuth +// @Param sessionId path string true "Session ID (UUID)" +// @Success 200 {object} response.Response +// @Failure 401 {object} response.Response +// @Failure 403 {object} response.Response +// @Failure 404 {object} response.Response +// @Router /api/lol/sessions/{sessionId} [delete] +func (c *LolSessionController) CancelSession(ctx *gin.Context) { + userID, ok := getAuthenticatedUserID(ctx) + if !ok { + return + } + + sessionID := ctx.Param("sessionId") + if err := c.sessionService.CancelSession(sessionID, userID); err != nil { + c.helper.HandleError(ctx, err) + return + } + + // 세션 취소 이벤트를 WebSocket으로 연결된 모든 클라이언트에게 브로드캐스트 + c.broadcastToSession(sessionID, dto.WsEventSessionClosed, nil) + response.JSON(ctx, response.Success[any](nil, "session cancelled")) +} + +// HandleWebSocket godoc +// @Summary LoL 커스텀 매치 세션 WebSocket 연결 +// @Description WebSocket으로 세션에 연결한다. JWT는 ?token= 쿼리 파라미터로 전달한다. +// @Tags lol-session +// @Param sessionId path string true "Session ID (UUID)" +// @Param token query string false "JWT Access Token (인증 필요 시)" +// @Router /api/lol/sessions/{sessionId}/ws [get] +func (c *LolSessionController) HandleWebSocket(ctx *gin.Context) { + sessionID := ctx.Param("sessionId") + + // JWT 검증 (query param) + tokenStr := ctx.Query("token") + var connectedUserID int64 + if tokenStr != "" { + claims, err := c.tokenService.Validate(jwtDomain.TokenTypeAccess, tokenStr) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + connectedUserID = claims.UserID + } + + // 세션 존재 확인 + if _, err := c.sessionService.GetSession(sessionID); err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + return + } + + // WebSocket 업그레이드 + conn, err := ws.Upgrader.Upgrade(ctx.Writer, ctx.Request, nil) + if err != nil { + log.Printf("[LolSessionController] ws upgrade failed: %v", err) + return + } + + client := ws.NewClient(conn, sessionID, connectedUserID, c.hub) + c.hub.Register(client) + + // 연결 즉시 현재 세션 상태 전송 + session, _ := c.sessionService.GetSession(sessionID) + if session != nil { + connectedMsg := c.buildEvent(dto.WsEventConnected, &dto.WsConnectedData{ + SessionID: session.SessionID, + HostUserID: session.HostUserID, + Status: string(session.Status), + Players: session.Players, + }) + client.SendMessage(connectedMsg) + } + + go client.WritePump() + go client.ReadPump(c.handleAction) +} + +// handleAction 는 클라이언트로부터 받은 WS 메시지를 처리하고 결과를 브로드캐스트한다. +func (c *LolSessionController) handleAction(sessionID string, senderUserID int64, rawMsg []byte) { + var msg dto.WsClientMessage + if err := json.Unmarshal(rawMsg, &msg); err != nil { + c.sendError(sessionID, senderUserID, "invalid message format") + return + } + + switch msg.Type { + case dto.WsActionJoin: + c.handleJoin(sessionID, senderUserID, msg.Payload) + + case dto.WsActionLeave: + c.handleLeave(sessionID, senderUserID) + + case dto.WsActionBalance: + c.handleBalance(sessionID, senderUserID) + + case dto.WsActionResult: + c.handleResult(sessionID, senderUserID, msg.Payload) + + case dto.WsActionCancel: + c.handleCancel(sessionID, senderUserID) + + default: + c.sendError(sessionID, senderUserID, "unknown action type: "+msg.Type) + } +} + +func (c *LolSessionController) handleJoin(sessionID string, userID int64, payload json.RawMessage) { + var p dto.WsJoinPayload + if err := json.Unmarshal(payload, &p); err != nil { + c.sendError(sessionID, userID, "invalid JOIN payload") + return + } + + if err := c.sessionService.JoinSession(sessionID, userID, &p); err != nil { + c.sendError(sessionID, userID, err.Error()) + return + } + + session, _ := c.sessionService.GetSession(sessionID) + if session != nil { + c.broadcastToSession(sessionID, dto.WsEventPlayerJoined, gin.H{"players": session.Players}) + } +} + +func (c *LolSessionController) handleLeave(sessionID string, userID int64) { + if err := c.sessionService.LeaveSession(sessionID, userID); err != nil { + c.sendError(sessionID, userID, err.Error()) + return + } + + session, _ := c.sessionService.GetSession(sessionID) + if session != nil { + c.broadcastToSession(sessionID, dto.WsEventPlayerLeft, gin.H{"players": session.Players}) + } +} + +func (c *LolSessionController) handleBalance(sessionID string, userID int64) { + result, err := c.sessionService.BalanceSession(sessionID, userID) + if err != nil { + c.sendError(sessionID, userID, err.Error()) + return + } + + c.broadcastToSession(sessionID, dto.WsEventBalanceDone, &dto.WsBalanceDoneData{ + MatchID: result.MatchID, + TeamA: result.TeamA, + TeamB: result.TeamB, + }) +} + +func (c *LolSessionController) handleResult(sessionID string, userID int64, payload json.RawMessage) { + var p dto.WsResultPayload + if err := json.Unmarshal(payload, &p); err != nil { + c.sendError(sessionID, userID, "invalid RESULT payload") + return + } + + session, err := c.sessionService.GetSession(sessionID) + if err != nil { + c.sendError(sessionID, userID, err.Error()) + return + } + + matchID := int64(0) + if session.BalanceResult != nil { + matchID = session.BalanceResult.MatchID + } + + if err := c.sessionService.RecordSessionResult(sessionID, userID, p.Winner); err != nil { + c.sendError(sessionID, userID, err.Error()) + return + } + + c.broadcastToSession(sessionID, dto.WsEventResultRecorded, &dto.WsResultRecordedData{ + MatchID: matchID, + Winner: p.Winner, + }) +} + +func (c *LolSessionController) handleCancel(sessionID string, userID int64) { + if err := c.sessionService.CancelSession(sessionID, userID); err != nil { + c.sendError(sessionID, userID, err.Error()) + return + } + c.broadcastToSession(sessionID, dto.WsEventSessionClosed, nil) +} + +func (c *LolSessionController) broadcastToSession(sessionID string, event string, data interface{}) { + msg := c.buildEvent(event, data) + c.hub.Broadcast(sessionID, msg) +} + +func (c *LolSessionController) sendError(sessionID string, userID int64, message string) { + msg := c.buildEvent(dto.WsEventError, gin.H{"message": message}) + c.hub.SendToUser(sessionID, userID, msg) +} + +func (c *LolSessionController) buildEvent(event string, data interface{}) []byte { + msg := dto.WsServerMessage{Event: event, Data: data} + b, _ := json.Marshal(msg) + return b +} + +func getAuthenticatedUserID(ctx *gin.Context) (int64, bool) { + userID, ok := middleware.GetUserIdFromContext(ctx) + if !ok { + response.JSON(ctx, response.Error(401, "user not authenticated")) + return 0, false + } + return userID, true +} diff --git a/internal/lol/presentation/lol_temporal_controller.go b/internal/lol/presentation/lol_temporal_controller.go new file mode 100644 index 0000000..37a6f0e --- /dev/null +++ b/internal/lol/presentation/lol_temporal_controller.go @@ -0,0 +1,149 @@ +package presentation + +import ( + "strconv" + + "github.com/FOR-GAMERS/GAMERS-BE/internal/auth/middleware" + "github.com/FOR-GAMERS/GAMERS-BE/internal/global/common/handler" + "github.com/FOR-GAMERS/GAMERS-BE/internal/global/common/router" + "github.com/FOR-GAMERS/GAMERS-BE/internal/global/exception" + "github.com/FOR-GAMERS/GAMERS-BE/internal/global/response" + "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/application" + "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/application/dto" + + "github.com/gin-gonic/gin" +) + +type LolTemporalController struct { + router *router.Router + service *application.LolTeamBalanceService + helper *handler.ControllerHelper +} + +func NewLolTemporalController( + r *router.Router, + service *application.LolTeamBalanceService, + helper *handler.ControllerHelper, +) *LolTemporalController { + return &LolTemporalController{ + router: r, + service: service, + helper: helper, + } +} + +func (c *LolTemporalController) RegisterRoutes() { + protected := c.router.ProtectedGroup("/api/lol") + protected.POST("/temporal", c.BalanceTemporalV2) + protected.PATCH("/temporal/:matchId/result", c.RecordMatchResult) +} + +// BalanceTemporalV2 godoc +// @Summary Balance teams for a temporary LoL custom game and save the result +// @Description Accepts exactly 10 players each with their rank and position preferences (all 5 positions in priority order). Returns a balanced 5v5 team assignment and persists the match for the authenticated user. +// @Tags lol +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body dto.LolTemporalContestRequestV2 true "10 players with position preferences" +// @Success 201 {object} response.Response{data=dto.LolTemporalContestResponseWithMatchID} +// @Failure 400 {object} response.Response +// @Failure 401 {object} response.Response +// @Router /api/lol/temporal [post] +func (c *LolTemporalController) BalanceTemporalV2(ctx *gin.Context) { + userID, ok := middleware.GetUserIdFromContext(ctx) + if !ok { + response.JSON(ctx, response.Error(401, "user not authenticated")) + return + } + + var req dto.LolTemporalContestRequestV2 + if !c.helper.BindJSON(ctx, &req) { + return + } + + result, err := c.service.BalanceAndSave(userID, &req) + c.helper.RespondCreated(ctx, result, err, "teams balanced successfully") +} + +// RecordMatchResult godoc +// @Summary Record the winner of a custom LoL match +// @Description Only the creator of the match can record the result. Winner must be "TEAM_A" or "TEAM_B". +// @Tags lol +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param matchId path int true "Match ID" +// @Param request body dto.LolMatchResultRequest true "Match result" +// @Success 200 {object} response.Response +// @Failure 400 {object} response.Response +// @Failure 401 {object} response.Response +// @Failure 403 {object} response.Response +// @Failure 404 {object} response.Response +// @Router /api/lol/temporal/{matchId}/result [patch] +func (c *LolTemporalController) RecordMatchResult(ctx *gin.Context) { + userID, ok := middleware.GetUserIdFromContext(ctx) + if !ok { + response.JSON(ctx, response.Error(401, "user not authenticated")) + return + } + + matchID, err := strconv.ParseInt(ctx.Param("matchId"), 10, 64) + if err != nil || matchID <= 0 { + response.JSON(ctx, response.BadRequest("invalid match id")) + return + } + + var req dto.LolMatchResultRequest + if !c.helper.BindJSON(ctx, &req) { + return + } + + serviceErr := c.service.RecordMatchResult(matchID, userID, req.Winner) + if serviceErr != nil { + c.helper.HandleError(ctx, serviceErr) + return + } + + ctx.JSON(200, response.Response{ + Status: 200, + Message: "match result recorded", + Data: nil, + }) +} + +// HandleBalanceLolTemporalV2 is exposed for unit testing without router dependency. +func HandleBalanceLolTemporalV2(ctx *gin.Context, service *application.LolTeamBalanceService, helper *handler.ControllerHelper) { + userID, ok := middleware.GetUserIdFromContext(ctx) + if !ok { + response.JSON(ctx, response.Error(401, "user not authenticated")) + return + } + + var req dto.LolTemporalContestRequestV2 + if err := ctx.ShouldBindJSON(&req); err != nil { + response.JSON(ctx, response.BadRequest(err.Error())) + return + } + + result, err := service.BalanceAndSave(userID, &req) + if err != nil { + var bizErr *exception.BusinessError + if ok2 := isBusinessError(err, &bizErr); ok2 { + ctx.JSON(bizErr.Status, bizErr) + return + } + response.JSON(ctx, response.InternalServerError("something went wrong")) + return + } + + helper.RespondCreated(ctx, result, nil, "teams balanced successfully") +} + +func isBusinessError(err error, out **exception.BusinessError) bool { + if be, ok := err.(*exception.BusinessError); ok { + *out = be + return true + } + return false +} diff --git a/internal/lol/provider.go b/internal/lol/provider.go new file mode 100644 index 0000000..315fb18 --- /dev/null +++ b/internal/lol/provider.go @@ -0,0 +1,45 @@ +package lol + +import ( + "github.com/FOR-GAMERS/GAMERS-BE/internal/global/common/handler" + "github.com/FOR-GAMERS/GAMERS-BE/internal/global/common/router" + jwtApp "github.com/FOR-GAMERS/GAMERS-BE/internal/global/security/jwt/application" + "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/application" + "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/infra/persistence/adapter" + lolRedis "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/infra/redis" + "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/infra/ws" + "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/presentation" + + goredis "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +type Dependencies struct { + Service *application.LolTeamBalanceService + SessionService *application.LolSessionService + Controller *presentation.LolTemporalController + SessionController *presentation.LolSessionController + Hub *ws.SessionHub +} + +func ProvideLolDependencies(r *router.Router, db *gorm.DB, redisClient *goredis.Client, tokenService *jwtApp.TokenService) *Dependencies { + matchRepo := adapter.NewLolCustomMatchDatabaseAdapter(db) + balanceService := application.NewLolTeamBalanceService(matchRepo) + + sessionRepo := lolRedis.NewLolSessionRedisAdapter(redisClient) + sessionService := application.NewLolSessionService(sessionRepo, balanceService) + + hub := ws.NewSessionHub() + helper := handler.NewControllerHelper() + + controller := presentation.NewLolTemporalController(r, balanceService, helper) + sessionController := presentation.NewLolSessionController(r, sessionService, hub, tokenService, helper) + + return &Dependencies{ + Service: balanceService, + SessionService: sessionService, + Controller: controller, + SessionController: sessionController, + Hub: hub, + } +} diff --git a/internal/notification/application/dto/notification_dto.go b/internal/notification/application/dto/notification_dto.go deleted file mode 100644 index bb2c5b9..0000000 --- a/internal/notification/application/dto/notification_dto.go +++ /dev/null @@ -1,63 +0,0 @@ -package dto - -import ( - "github.com/FOR-GAMERS/GAMERS-BE/internal/notification/domain" - "time" -) - -// NotificationResponse represents a single notification response -type NotificationResponse struct { - ID int64 `json:"id"` - Type domain.NotificationType `json:"type"` - Title string `json:"title"` - Message string `json:"message"` - Data map[string]interface{} `json:"data,omitempty"` - IsRead bool `json:"is_read"` - CreatedAt time.Time `json:"created_at"` -} - -// NotificationListResponse represents a list of notifications -type NotificationListResponse struct { - Notifications []NotificationResponse `json:"notifications"` - UnreadCount int64 `json:"unread_count"` - Total int64 `json:"total"` -} - -// FromNotification converts a domain notification to a response DTO -func FromNotification(n *domain.Notification) *NotificationResponse { - return &NotificationResponse{ - ID: n.ID, - Type: n.Type, - Title: n.Title, - Message: n.Message, - Data: n.NotificationData(), - IsRead: n.IsRead, - CreatedAt: n.CreatedAt, - } -} - -// FromNotifications converts a list of domain notifications to response DTOs -func FromNotifications(notifications []*domain.Notification) []NotificationResponse { - result := make([]NotificationResponse, len(notifications)) - for i, n := range notifications { - result[i] = *FromNotification(n) - } - return result -} - -// SSEEvent represents an event sent through SSE -type SSEEvent struct { - ID string `json:"id"` - Type domain.NotificationType `json:"type"` - Title string `json:"title"` - Message string `json:"message"` - Data map[string]interface{} `json:"data,omitempty"` - Timestamp time.Time `json:"timestamp"` -} - -// GetNotificationsRequest represents the query parameters for listing notifications -type GetNotificationsRequest struct { - Limit int `form:"limit" binding:"omitempty,min=1,max=100"` - Offset int `form:"offset" binding:"omitempty,min=0"` - Unread bool `form:"unread"` -} diff --git a/internal/notification/application/notification_service.go b/internal/notification/application/notification_service.go deleted file mode 100644 index 4b70805..0000000 --- a/internal/notification/application/notification_service.go +++ /dev/null @@ -1,223 +0,0 @@ -package application - -import ( - "github.com/FOR-GAMERS/GAMERS-BE/internal/global/exception" - "github.com/FOR-GAMERS/GAMERS-BE/internal/notification/application/dto" - "github.com/FOR-GAMERS/GAMERS-BE/internal/notification/application/port" - "github.com/FOR-GAMERS/GAMERS-BE/internal/notification/domain" - "fmt" - "log" - "strconv" - "time" -) - -// NotificationService handles notification business logic -type NotificationService struct { - databasePort port.NotificationDatabasePort - sseManager *SSEManager -} - -// NewNotificationService creates a new notification service -func NewNotificationService( - databasePort port.NotificationDatabasePort, - sseManager *SSEManager, -) *NotificationService { - return &NotificationService{ - databasePort: databasePort, - sseManager: sseManager, - } -} - -// CreateAndSendNotification creates a notification and sends it via SSE -func (s *NotificationService) CreateAndSendNotification( - userID int64, - notifType domain.NotificationType, - title, message string, - data map[string]interface{}, -) error { - // Create notification - notification, err := domain.NewNotification(userID, notifType, title, message, data) - if err != nil { - return fmt.Errorf("failed to create notification: %w", err) - } - - // Save to config - if err := s.databasePort.Save(notification); err != nil { - return fmt.Errorf("failed to save notification: %w", err) - } - - // Send via SSE if user is connected - sseMessage := &domain.SSEMessage{ - ID: strconv.FormatInt(notification.ID, 10), - Type: notification.Type, - Title: notification.Title, - Message: notification.Message, - Data: notification.NotificationData(), - Timestamp: notification.CreatedAt, - } - - if err := s.sseManager.SendToUser(userID, sseMessage); err != nil { - log.Printf("Failed to send SSE notification to user %d: %v", userID, err) - // Don't return error - notification is saved, SSE is best effort - } - - return nil -} - -// GetNotifications returns notifications for a user with pagination -func (s *NotificationService) GetNotifications(userID int64, req *dto.GetNotificationsRequest) (*dto.NotificationListResponse, error) { - // Set defaults - limit := req.Limit - if limit == 0 { - limit = 20 - } - - var notifications []*domain.Notification - var err error - - if req.Unread { - notifications, err = s.databasePort.FindUnreadByUserID(userID, limit, req.Offset) - } else { - notifications, err = s.databasePort.FindByUserID(userID, limit, req.Offset) - } - - if err != nil { - return nil, fmt.Errorf("failed to get notifications: %w", err) - } - - // Get counts - total, err := s.databasePort.CountByUserID(userID) - if err != nil { - return nil, fmt.Errorf("failed to count notifications: %w", err) - } - - unreadCount, err := s.databasePort.CountUnreadByUserID(userID) - if err != nil { - return nil, fmt.Errorf("failed to count unread notifications: %w", err) - } - - return &dto.NotificationListResponse{ - Notifications: dto.FromNotifications(notifications), - UnreadCount: unreadCount, - Total: total, - }, nil -} - -// MarkAsRead marks a notification as read -func (s *NotificationService) MarkAsRead(userID, notificationID int64) error { - // Verify notification belongs to user - notification, err := s.databasePort.FindByID(notificationID) - if err != nil { - return exception.ErrNotificationNotFound - } - - if notification.UserID != userID { - return exception.ErrNotificationNotFound - } - - return s.databasePort.MarkAsRead(notificationID) -} - -// MarkAllAsRead marks all notifications as read for a user -func (s *NotificationService) MarkAllAsRead(userID int64) error { - return s.databasePort.MarkAllAsRead(userID) -} - -// GetSSEManager returns the SSE manager -func (s *NotificationService) GetSSEManager() *SSEManager { - return s.sseManager -} - -// HandleTeamInviteReceived handles team invite received event -func (s *NotificationService) HandleTeamInviteReceived(inviteeUserID int64, inviterUsername, teamName string, gameID, contestID int64) error { - data := map[string]interface{}{ - "inviter_username": inviterUsername, - "team_name": teamName, - "game_id": gameID, - "contest_id": contestID, - } - - title := "팀 초대" - message := fmt.Sprintf("%s님이 %s 팀에 초대했습니다.", inviterUsername, teamName) - - return s.CreateAndSendNotification(inviteeUserID, domain.NotificationTypeTeamInviteReceived, title, message, data) -} - -// HandleTeamInviteAccepted handles team invite accepted event -func (s *NotificationService) HandleTeamInviteAccepted(inviterUserID int64, inviteeUsername, teamName string, gameID, contestID int64) error { - data := map[string]interface{}{ - "invitee_username": inviteeUsername, - "team_name": teamName, - "game_id": gameID, - "contest_id": contestID, - } - - title := "초대 수락됨" - message := fmt.Sprintf("%s님이 팀 초대를 수락했습니다.", inviteeUsername) - - return s.CreateAndSendNotification(inviterUserID, domain.NotificationTypeTeamInviteAccepted, title, message, data) -} - -// HandleTeamInviteRejected handles team invite rejected event -func (s *NotificationService) HandleTeamInviteRejected(inviterUserID int64, inviteeUsername, teamName string, gameID, contestID int64) error { - data := map[string]interface{}{ - "invitee_username": inviteeUsername, - "team_name": teamName, - "game_id": gameID, - "contest_id": contestID, - } - - title := "초대 거절됨" - message := fmt.Sprintf("%s님이 팀 초대를 거절했습니다.", inviteeUsername) - - return s.CreateAndSendNotification(inviterUserID, domain.NotificationTypeTeamInviteRejected, title, message, data) -} - -// HandleApplicationAccepted handles contest application accepted event -func (s *NotificationService) HandleApplicationAccepted(userID, contestID int64, contestTitle string) error { - data := map[string]interface{}{ - "contest_id": contestID, - "contest_title": contestTitle, - } - - title := "참가 신청 승인" - message := fmt.Sprintf("%s 대회 참가 신청이 승인되었습니다.", contestTitle) - - return s.CreateAndSendNotification(userID, domain.NotificationTypeApplicationAccepted, title, message, data) -} - -// HandleApplicationRejected handles contest application rejected event -func (s *NotificationService) HandleApplicationRejected(userID, contestID int64, contestTitle, reason string) error { - data := map[string]interface{}{ - "contest_id": contestID, - "contest_title": contestTitle, - "reason": reason, - } - - title := "참가 신청 거절" - message := fmt.Sprintf("%s 대회 참가 신청이 거절되었습니다.", contestTitle) - if reason != "" { - message += fmt.Sprintf(" 사유: %s", reason) - } - - return s.CreateAndSendNotification(userID, domain.NotificationTypeApplicationRejected, title, message, data) -} - -// CleanupOldNotifications removes old notifications -func (s *NotificationService) CleanupOldNotifications(days int) error { - return s.databasePort.DeleteOldNotifications(days) -} - -// SendTestNotification sends a test notification (for debugging) -func (s *NotificationService) SendTestNotification(userID int64) error { - return s.CreateAndSendNotification( - userID, - domain.NotificationTypeTeamInviteReceived, - "테스트 알림", - "SSE 알림 테스트 메시지입니다.", - map[string]interface{}{ - "test": true, - "timestamp": time.Now().Unix(), - }, - ) -} diff --git a/internal/notification/application/port/notification_handler_port.go b/internal/notification/application/port/notification_handler_port.go deleted file mode 100644 index d1fe981..0000000 --- a/internal/notification/application/port/notification_handler_port.go +++ /dev/null @@ -1,14 +0,0 @@ -package port - -// NotificationHandlerPort defines the interface for sending notifications -// This interface is used by other services to send notifications -type NotificationHandlerPort interface { - // Team invite notifications - HandleTeamInviteReceived(inviteeUserID int64, inviterUsername, teamName string, gameID, contestID int64) error - HandleTeamInviteAccepted(inviterUserID int64, inviteeUsername, teamName string, gameID, contestID int64) error - HandleTeamInviteRejected(inviterUserID int64, inviteeUsername, teamName string, gameID, contestID int64) error - - // Contest application notifications - HandleApplicationAccepted(userID, contestID int64, contestTitle string) error - HandleApplicationRejected(userID, contestID int64, contestTitle, reason string) error -} diff --git a/internal/notification/application/port/notification_port.go b/internal/notification/application/port/notification_port.go deleted file mode 100644 index 4e60ee1..0000000 --- a/internal/notification/application/port/notification_port.go +++ /dev/null @@ -1,87 +0,0 @@ -package port - -import ( - "github.com/FOR-GAMERS/GAMERS-BE/internal/notification/domain" - "context" -) - -// NotificationDatabasePort defines the interface for notification config operations -type NotificationDatabasePort interface { - // Save saves a new notification to the config - Save(notification *domain.Notification) error - - // FindByID finds a notification by ID - FindByID(id int64) (*domain.Notification, error) - - // FindByUserID finds all notifications for a user with pagination - FindByUserID(userID int64, limit, offset int) ([]*domain.Notification, error) - - // FindUnreadByUserID finds unread notifications for a user - FindUnreadByUserID(userID int64, limit, offset int) ([]*domain.Notification, error) - - // CountByUserID counts total notifications for a user - CountByUserID(userID int64) (int64, error) - - // CountUnreadByUserID counts unread notifications for a user - CountUnreadByUserID(userID int64) (int64, error) - - // MarkAsRead marks a notification as read - MarkAsRead(id int64) error - - // MarkAllAsRead marks all notifications as read for a user - MarkAllAsRead(userID int64) error - - // Delete deletes a notification - Delete(id int64) error - - // DeleteOldNotifications deletes notifications older than a certain time - DeleteOldNotifications(days int) error -} - -// SSEClientPort defines the interface for SSE client operations -type SSEClientPort interface { - // Send sends a message to the client - Send(message *domain.SSEMessage) error - - // Close closes the client connection - Close() - - // IsClosed checks if the client connection is closed - IsClosed() bool - - // GetUserID returns the user ID of the client - GetUserID() int64 -} - -// SSEManagerPort defines the interface for managing SSE connections -type SSEManagerPort interface { - // RegisterClient registers a new SSE client - RegisterClient(client SSEClientPort) - - // UnregisterClient removes an SSE client - UnregisterClient(client SSEClientPort) - - // SendToUser sends a notification to a specific user - SendToUser(userID int64, message *domain.SSEMessage) error - - // Broadcast sends a notification to all connected clients - Broadcast(message *domain.SSEMessage) - - // GetConnectedUsers returns the list of connected user IDs - GetConnectedUsers() []int64 - - // IsUserConnected checks if a user is connected - IsUserConnected(userID int64) bool -} - -// NotificationConsumerPort defines the interface for consuming notification events -type NotificationConsumerPort interface { - // Start starts consuming messages from the queue - Start(ctx context.Context) error - - // Stop stops the consumer - Stop() error - - // SetHandler sets the handler function for processing notifications - SetHandler(handler func(notification *domain.Notification) error) -} diff --git a/internal/notification/application/sse_manager.go b/internal/notification/application/sse_manager.go deleted file mode 100644 index 33b9e9c..0000000 --- a/internal/notification/application/sse_manager.go +++ /dev/null @@ -1,174 +0,0 @@ -package application - -import ( - "github.com/FOR-GAMERS/GAMERS-BE/internal/notification/application/port" - "github.com/FOR-GAMERS/GAMERS-BE/internal/notification/domain" - "log" - "sync" -) - -// SSEManager manages SSE client connections -type SSEManager struct { - clients map[int64][]port.SSEClientPort // userID -> clients (one user can have multiple connections) - mu sync.RWMutex - register chan port.SSEClientPort - unregister chan port.SSEClientPort - broadcast chan *domain.SSEMessage - stopCh chan struct{} -} - -// NewSSEManager creates a new SSE manager -func NewSSEManager() *SSEManager { - manager := &SSEManager{ - clients: make(map[int64][]port.SSEClientPort), - register: make(chan port.SSEClientPort, 100), - unregister: make(chan port.SSEClientPort, 100), - broadcast: make(chan *domain.SSEMessage, 100), - stopCh: make(chan struct{}), - } - - go manager.run() - return manager -} - -// run starts the main event loop -func (m *SSEManager) run() { - for { - select { - case client := <-m.register: - m.addClient(client) - case client := <-m.unregister: - m.removeClient(client) - case message := <-m.broadcast: - m.broadcastMessage(message) - case <-m.stopCh: - m.closeAllClients() - return - } - } -} - -// addClient adds a client to the manager -func (m *SSEManager) addClient(client port.SSEClientPort) { - m.mu.Lock() - defer m.mu.Unlock() - - userID := client.GetUserID() - m.clients[userID] = append(m.clients[userID], client) - log.Printf("SSE: Client connected for user %d (total connections: %d)", userID, len(m.clients[userID])) -} - -// removeClient removes a client from the manager -func (m *SSEManager) removeClient(client port.SSEClientPort) { - m.mu.Lock() - defer m.mu.Unlock() - - userID := client.GetUserID() - clients := m.clients[userID] - for i, c := range clients { - if c == client { - m.clients[userID] = append(clients[:i], clients[i+1:]...) - break - } - } - - // Remove user entry if no more clients - if len(m.clients[userID]) == 0 { - delete(m.clients, userID) - } - - client.Close() - log.Printf("SSE: Client disconnected for user %d", userID) -} - -// broadcastMessage sends a message to all connected clients -func (m *SSEManager) broadcastMessage(message *domain.SSEMessage) { - m.mu.RLock() - defer m.mu.RUnlock() - - for _, clients := range m.clients { - for _, client := range clients { - if !client.IsClosed() { - if err := client.Send(message); err != nil { - log.Printf("SSE: Failed to send broadcast message: %v", err) - } - } - } - } -} - -// closeAllClients closes all client connections -func (m *SSEManager) closeAllClients() { - m.mu.Lock() - defer m.mu.Unlock() - - for _, clients := range m.clients { - for _, client := range clients { - client.Close() - } - } - m.clients = make(map[int64][]port.SSEClientPort) -} - -// RegisterClient registers a new SSE client -func (m *SSEManager) RegisterClient(client port.SSEClientPort) { - m.register <- client -} - -// UnregisterClient removes an SSE client -func (m *SSEManager) UnregisterClient(client port.SSEClientPort) { - m.unregister <- client -} - -// SendToUser sends a notification to a specific user -func (m *SSEManager) SendToUser(userID int64, message *domain.SSEMessage) error { - m.mu.RLock() - defer m.mu.RUnlock() - - clients, exists := m.clients[userID] - if !exists || len(clients) == 0 { - log.Printf("SSE: No active connections for user %d", userID) - return nil // Not an error, user just not connected - } - - for _, client := range clients { - if !client.IsClosed() { - if err := client.Send(message); err != nil { - log.Printf("SSE: Failed to send message to user %d: %v", userID, err) - } - } - } - - return nil -} - -// Broadcast sends a notification to all connected clients -func (m *SSEManager) Broadcast(message *domain.SSEMessage) { - m.broadcast <- message -} - -// GetConnectedUsers returns the list of connected user IDs -func (m *SSEManager) GetConnectedUsers() []int64 { - m.mu.RLock() - defer m.mu.RUnlock() - - users := make([]int64, 0, len(m.clients)) - for userID := range m.clients { - users = append(users, userID) - } - return users -} - -// IsUserConnected checks if a user is connected -func (m *SSEManager) IsUserConnected(userID int64) bool { - m.mu.RLock() - defer m.mu.RUnlock() - - clients, exists := m.clients[userID] - return exists && len(clients) > 0 -} - -// Stop stops the SSE manager -func (m *SSEManager) Stop() { - close(m.stopCh) -} diff --git a/internal/notification/domain/notification.go b/internal/notification/domain/notification.go deleted file mode 100644 index aaaf741..0000000 --- a/internal/notification/domain/notification.go +++ /dev/null @@ -1,108 +0,0 @@ -package domain - -import ( - "encoding/json" - "time" -) - -// NotificationType represents the type of notification -type NotificationType string - -const ( - // Team invitation notifications - NotificationTypeTeamInviteReceived NotificationType = "TEAM_INVITE_RECEIVED" - NotificationTypeTeamInviteAccepted NotificationType = "TEAM_INVITE_ACCEPTED" - NotificationTypeTeamInviteRejected NotificationType = "TEAM_INVITE_REJECTED" - - // Contest application notifications - NotificationTypeApplicationAccepted NotificationType = "APPLICATION_ACCEPTED" - NotificationTypeApplicationRejected NotificationType = "APPLICATION_REJECTED" -) - -// Notification represents a user notification entity -type Notification struct { - ID int64 `gorm:"primaryKey;column:id;autoIncrement" json:"id"` - UserID int64 `gorm:"column:user_id;not null;index" json:"user_id"` - Type NotificationType `gorm:"column:type;type:varchar(50);not null" json:"type"` - Title string `gorm:"column:title;type:varchar(255);not null" json:"title"` - Message string `gorm:"column:message;type:text" json:"message"` - Data string `gorm:"column:data;type:json" json:"-"` - IsRead bool `gorm:"column:is_read;default:false" json:"is_read"` - CreatedAt time.Time `gorm:"column:created_at;type:timestamp;autoCreateTime" json:"created_at"` -} - -func (n *Notification) TableName() string { - return "notifications" -} - -// NotificationData returns the parsed JSON data -func (n *Notification) NotificationData() map[string]interface{} { - if n.Data == "" { - return nil - } - var data map[string]interface{} - if err := json.Unmarshal([]byte(n.Data), &data); err != nil { - return nil - } - return data -} - -// SetNotificationData sets the JSON data from a map -func (n *Notification) SetNotificationData(data map[string]interface{}) error { - if data == nil { - n.Data = "" - return nil - } - bytes, err := json.Marshal(data) - if err != nil { - return err - } - n.Data = string(bytes) - return nil -} - -// NewNotification creates a new notification -func NewNotification(userID int64, notifType NotificationType, title, message string, data map[string]interface{}) (*Notification, error) { - notification := &Notification{ - UserID: userID, - Type: notifType, - Title: title, - Message: message, - IsRead: false, - } - - if data != nil { - if err := notification.SetNotificationData(data); err != nil { - return nil, err - } - } - - return notification, nil -} - -// MarkAsRead marks the notification as read -func (n *Notification) MarkAsRead() { - n.IsRead = true -} - -// SSEMessage represents a message sent through SSE -type SSEMessage struct { - ID string `json:"id"` - Type NotificationType `json:"type"` - Title string `json:"title"` - Message string `json:"message"` - Data interface{} `json:"data,omitempty"` - Timestamp time.Time `json:"timestamp"` -} - -// ToSSEMessage converts a notification to an SSE message -func (n *Notification) ToSSEMessage() *SSEMessage { - return &SSEMessage{ - ID: string(rune(n.ID)), - Type: n.Type, - Title: n.Title, - Message: n.Message, - Data: n.NotificationData(), - Timestamp: n.CreatedAt, - } -} diff --git a/internal/notification/infra/persistence/adapter/notification_database_adapter.go b/internal/notification/infra/persistence/adapter/notification_database_adapter.go deleted file mode 100644 index 32b8e3a..0000000 --- a/internal/notification/infra/persistence/adapter/notification_database_adapter.go +++ /dev/null @@ -1,95 +0,0 @@ -package adapter - -import ( - "github.com/FOR-GAMERS/GAMERS-BE/internal/notification/domain" - "time" - - "gorm.io/gorm" -) - -// NotificationDatabaseAdapter implements the NotificationDatabasePort interface -type NotificationDatabaseAdapter struct { - db *gorm.DB -} - -// NewNotificationDatabaseAdapter creates a new notification config adapter -func NewNotificationDatabaseAdapter(db *gorm.DB) *NotificationDatabaseAdapter { - return &NotificationDatabaseAdapter{db: db} -} - -// Save saves a new notification to the config -func (a *NotificationDatabaseAdapter) Save(notification *domain.Notification) error { - return a.db.Create(notification).Error -} - -// FindByID finds a notification by ID -func (a *NotificationDatabaseAdapter) FindByID(id int64) (*domain.Notification, error) { - var notification domain.Notification - if err := a.db.First(¬ification, id).Error; err != nil { - return nil, err - } - return ¬ification, nil -} - -// FindByUserID finds all notifications for a user with pagination -func (a *NotificationDatabaseAdapter) FindByUserID(userID int64, limit, offset int) ([]*domain.Notification, error) { - var notifications []*domain.Notification - err := a.db.Where("user_id = ?", userID). - Order("created_at DESC"). - Limit(limit). - Offset(offset). - Find(¬ifications).Error - if err != nil { - return nil, err - } - return notifications, nil -} - -// FindUnreadByUserID finds unread notifications for a user -func (a *NotificationDatabaseAdapter) FindUnreadByUserID(userID int64, limit, offset int) ([]*domain.Notification, error) { - var notifications []*domain.Notification - err := a.db.Where("user_id = ? AND is_read = ?", userID, false). - Order("created_at DESC"). - Limit(limit). - Offset(offset). - Find(¬ifications).Error - if err != nil { - return nil, err - } - return notifications, nil -} - -// CountByUserID counts total notifications for a user -func (a *NotificationDatabaseAdapter) CountByUserID(userID int64) (int64, error) { - var count int64 - err := a.db.Model(&domain.Notification{}).Where("user_id = ?", userID).Count(&count).Error - return count, err -} - -// CountUnreadByUserID counts unread notifications for a user -func (a *NotificationDatabaseAdapter) CountUnreadByUserID(userID int64) (int64, error) { - var count int64 - err := a.db.Model(&domain.Notification{}).Where("user_id = ? AND is_read = ?", userID, false).Count(&count).Error - return count, err -} - -// MarkAsRead marks a notification as read -func (a *NotificationDatabaseAdapter) MarkAsRead(id int64) error { - return a.db.Model(&domain.Notification{}).Where("id = ?", id).Update("is_read", true).Error -} - -// MarkAllAsRead marks all notifications as read for a user -func (a *NotificationDatabaseAdapter) MarkAllAsRead(userID int64) error { - return a.db.Model(&domain.Notification{}).Where("user_id = ? AND is_read = ?", userID, false).Update("is_read", true).Error -} - -// Delete deletes a notification -func (a *NotificationDatabaseAdapter) Delete(id int64) error { - return a.db.Delete(&domain.Notification{}, id).Error -} - -// DeleteOldNotifications deletes notifications older than a certain number of days -func (a *NotificationDatabaseAdapter) DeleteOldNotifications(days int) error { - cutoff := time.Now().AddDate(0, 0, -days) - return a.db.Where("created_at < ?", cutoff).Delete(&domain.Notification{}).Error -} diff --git a/internal/notification/infra/sse/sse_client.go b/internal/notification/infra/sse/sse_client.go deleted file mode 100644 index 2b5339b..0000000 --- a/internal/notification/infra/sse/sse_client.go +++ /dev/null @@ -1,94 +0,0 @@ -package sse - -import ( - "github.com/FOR-GAMERS/GAMERS-BE/internal/notification/domain" - "encoding/json" - "fmt" - "io" - "sync" -) - -// SSEClient represents an SSE client connection -type SSEClient struct { - userID int64 - writer io.Writer - closed bool - mu sync.RWMutex -} - -// NewSSEClient creates a new SSE client -func NewSSEClient(userID int64, writer io.Writer) *SSEClient { - return &SSEClient{ - userID: userID, - writer: writer, - closed: false, - } -} - -// Send sends a message to the client -func (c *SSEClient) Send(message *domain.SSEMessage) error { - c.mu.RLock() - if c.closed { - c.mu.RUnlock() - return fmt.Errorf("client is closed") - } - c.mu.RUnlock() - - data, err := json.Marshal(message) - if err != nil { - return fmt.Errorf("failed to marshal message: %w", err) - } - - // SSE format: "data: \n\n" - _, err = fmt.Fprintf(c.writer, "event: notification\ndata: %s\n\n", string(data)) - if err != nil { - return fmt.Errorf("failed to write message: %w", err) - } - - // Flush if possible - if flusher, ok := c.writer.(interface{ Flush() }); ok { - flusher.Flush() - } - - return nil -} - -// SendHeartbeat sends a heartbeat to keep the connection alive -func (c *SSEClient) SendHeartbeat() error { - c.mu.RLock() - if c.closed { - c.mu.RUnlock() - return fmt.Errorf("client is closed") - } - c.mu.RUnlock() - - _, err := fmt.Fprintf(c.writer, ": heartbeat\n\n") - if err != nil { - return fmt.Errorf("failed to write heartbeat: %w", err) - } - - if flusher, ok := c.writer.(interface{ Flush() }); ok { - flusher.Flush() - } - - return nil -} - -// Close closes the client connection -func (c *SSEClient) Close() { - c.mu.Lock() - defer c.mu.Unlock() - c.closed = true -} - -// IsClosed checks if the client connection is closed -func (c *SSEClient) IsClosed() bool { - c.mu.RLock() - defer c.mu.RUnlock() - return c.closed -} - -// GetUserID returns the user ID of the client -func (c *SSEClient) GetUserID() int64 { - return c.userID -} diff --git a/internal/notification/presentation/notification_controller.go b/internal/notification/presentation/notification_controller.go deleted file mode 100644 index 540badf..0000000 --- a/internal/notification/presentation/notification_controller.go +++ /dev/null @@ -1,195 +0,0 @@ -package presentation - -import ( - "github.com/FOR-GAMERS/GAMERS-BE/internal/auth/middleware" - "github.com/FOR-GAMERS/GAMERS-BE/internal/global/common/router" - "github.com/FOR-GAMERS/GAMERS-BE/internal/global/response" - "github.com/FOR-GAMERS/GAMERS-BE/internal/notification/application" - "github.com/FOR-GAMERS/GAMERS-BE/internal/notification/application/dto" - "github.com/FOR-GAMERS/GAMERS-BE/internal/notification/infra/sse" - "log" - "strconv" - "time" - - "github.com/gin-gonic/gin" -) - -// swagger type alias -var _ = dto.NotificationResponse{} -var _ = dto.NotificationListResponse{} - -// NotificationController handles notification-related HTTP requests -type NotificationController struct { - router *router.Router - service *application.NotificationService -} - -// NewNotificationController creates a new notification controller -func NewNotificationController(router *router.Router, service *application.NotificationService) *NotificationController { - return &NotificationController{ - router: router, - service: service, - } -} - -// RegisterRoutes registers notification routes -func (c *NotificationController) RegisterRoutes() { - group := c.router.ProtectedGroup("/api/notifications") - group.GET("/stream", c.SSEStream) - group.GET("", c.GetNotifications) - group.PATCH("/:id/read", c.MarkAsRead) - group.PATCH("/read-all", c.MarkAllAsRead) -} - -// SSEStream godoc -// @Summary Subscribe to real-time notifications -// @Description Establishes an SSE connection for receiving real-time notifications -// @Tags notifications -// @Produce text/event-stream -// @Security BearerAuth -// @Success 200 {string} string "SSE stream" -// @Failure 401 {object} response.Response -// @Router /api/notifications/stream [get] -func (c *NotificationController) SSEStream(ctx *gin.Context) { - userID, ok := middleware.GetUserIdFromContext(ctx) - if !ok { - response.JSON(ctx, response.Error(401, "user not authenticated")) - return - } - - // Set SSE headers - ctx.Header("Content-Type", "text/event-stream") - ctx.Header("Cache-Control", "no-cache") - ctx.Header("Connection", "keep-alive") - ctx.Header("Access-Control-Allow-Origin", "*") - ctx.Header("X-Accel-Buffering", "no") - - // Create SSE client - client := sse.NewSSEClient(userID, ctx.Writer) - manager := c.service.GetSSEManager() - - // Register client - manager.RegisterClient(client) - defer manager.UnregisterClient(client) - - // Send initial connection message - ctx.SSEvent("connected", map[string]interface{}{ - "message": "Connected to notification stream", - "user_id": userID, - "timestamp": time.Now().Unix(), - }) - ctx.Writer.Flush() - - log.Printf("SSE: User %d connected to notification stream", userID) - - // Keep connection alive with heartbeat - heartbeatTicker := time.NewTicker(30 * time.Second) - defer heartbeatTicker.Stop() - - // Listen for client disconnect - clientGone := ctx.Request.Context().Done() - - for { - select { - case <-clientGone: - log.Printf("SSE: User %d disconnected from notification stream", userID) - return - case <-heartbeatTicker.C: - if err := client.SendHeartbeat(); err != nil { - log.Printf("SSE: Failed to send heartbeat to user %d: %v", userID, err) - return - } - } - } -} - -// GetNotifications godoc -// @Summary Get notifications -// @Description Retrieves notifications for the authenticated user with pagination -// @Tags notifications -// @Produce json -// @Security BearerAuth -// @Param limit query int false "Number of notifications to return (default: 20, max: 100)" -// @Param offset query int false "Offset for pagination" -// @Param unread query bool false "Filter only unread notifications" -// @Success 200 {object} response.Response{data=dto.NotificationListResponse} -// @Failure 401 {object} response.Response -// @Router /api/notifications [get] -func (c *NotificationController) GetNotifications(ctx *gin.Context) { - userID, ok := middleware.GetUserIdFromContext(ctx) - if !ok { - response.JSON(ctx, response.Error(401, "user not authenticated")) - return - } - - var req dto.GetNotificationsRequest - if err := ctx.ShouldBindQuery(&req); err != nil { - response.JSON(ctx, response.BadRequest("invalid query parameters")) - return - } - - result, err := c.service.GetNotifications(userID, &req) - if err != nil { - response.JSON(ctx, response.InternalServerError(err.Error())) - return - } - - response.JSON(ctx, response.Success(result, "notifications retrieved successfully")) -} - -// MarkAsRead godoc -// @Summary Mark notification as read -// @Description Marks a specific notification as read -// @Tags notifications -// @Produce json -// @Security BearerAuth -// @Param id path int true "Notification ID" -// @Success 200 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 404 {object} response.Response -// @Router /api/notifications/{id}/read [patch] -func (c *NotificationController) MarkAsRead(ctx *gin.Context) { - userID, ok := middleware.GetUserIdFromContext(ctx) - if !ok { - response.JSON(ctx, response.Error(401, "user not authenticated")) - return - } - - idStr := ctx.Param("id") - notificationID, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - response.JSON(ctx, response.BadRequest("invalid notification id")) - return - } - - if err := c.service.MarkAsRead(userID, notificationID); err != nil { - response.JSON(ctx, response.NotFound("notification not found")) - return - } - - response.JSON(ctx, response.Success[any](nil, "notification marked as read")) -} - -// MarkAllAsRead godoc -// @Summary Mark all notifications as read -// @Description Marks all notifications as read for the authenticated user -// @Tags notifications -// @Produce json -// @Security BearerAuth -// @Success 200 {object} response.Response -// @Failure 401 {object} response.Response -// @Router /api/notifications/read-all [patch] -func (c *NotificationController) MarkAllAsRead(ctx *gin.Context) { - userID, ok := middleware.GetUserIdFromContext(ctx) - if !ok { - response.JSON(ctx, response.Error(401, "user not authenticated")) - return - } - - if err := c.service.MarkAllAsRead(userID); err != nil { - response.JSON(ctx, response.InternalServerError(err.Error())) - return - } - - response.JSON(ctx, response.Success[any](nil, "all notifications marked as read")) -} diff --git a/internal/notification/provider.go b/internal/notification/provider.go deleted file mode 100644 index 039d1ef..0000000 --- a/internal/notification/provider.go +++ /dev/null @@ -1,43 +0,0 @@ -package notification - -import ( - "github.com/FOR-GAMERS/GAMERS-BE/internal/global/common/router" - "github.com/FOR-GAMERS/GAMERS-BE/internal/notification/application" - "github.com/FOR-GAMERS/GAMERS-BE/internal/notification/infra/persistence/adapter" - "github.com/FOR-GAMERS/GAMERS-BE/internal/notification/presentation" - - "gorm.io/gorm" -) - -// Dependencies holds all notification domain dependencies -type Dependencies struct { - Controller *presentation.NotificationController - Service *application.NotificationService - SSEManager *application.SSEManager - NotificationAdapter *adapter.NotificationDatabaseAdapter -} - -// ProvideNotificationDependencies creates and wires all notification dependencies -func ProvideNotificationDependencies(db *gorm.DB, router *router.Router) *Dependencies { - // Create adapters - notificationAdapter := adapter.NewNotificationDatabaseAdapter(db) - - // Create SSE manager - sseManager := application.NewSSEManager() - - // Create service - notificationService := application.NewNotificationService(notificationAdapter, sseManager) - - // Create controller - notificationController := presentation.NewNotificationController(router, notificationService) - - // Register routes - notificationController.RegisterRoutes() - - return &Dependencies{ - Controller: notificationController, - Service: notificationService, - SSEManager: sseManager, - NotificationAdapter: notificationAdapter, - } -} diff --git a/test/contest/presentation/lol_temporal_controller_test.go b/test/contest/presentation/lol_temporal_controller_test.go deleted file mode 100644 index a998c15..0000000 --- a/test/contest/presentation/lol_temporal_controller_test.go +++ /dev/null @@ -1,190 +0,0 @@ -package presentation_test - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/FOR-GAMERS/GAMERS-BE/internal/contest/application" - "github.com/FOR-GAMERS/GAMERS-BE/internal/contest/presentation" - "github.com/FOR-GAMERS/GAMERS-BE/internal/global/common/handler" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" -) - -// ==================== Helpers ==================== - -func newTemporalService() *application.ContestService { - return application.NewContestService(nil, nil, nil, nil, nil) -} - -func temporalBody(t *testing.T, body map[string]interface{}) *bytes.Buffer { - t.Helper() - b, err := json.Marshal(body) - assert.NoError(t, err) - return bytes.NewBuffer(b) -} - -func validTemporalBody() map[string]interface{} { - lane := func(r1, r2 string) []map[string]string { - return []map[string]string{ - {"username": "PlayerA", "tag": "KR1", "rank": r1}, - {"username": "PlayerB", "tag": "KR1", "rank": r2}, - } - } - return map[string]interface{}{ - "members": map[string]interface{}{ - "TOP": lane("GOLD I", "SILVER I"), - "JG": lane("PLATINUM IV", "GOLD IV"), - "MID": lane("DIAMOND IV", "EMERALD II"), - "ADC": lane("GOLD II", "SILVER II"), - "SUP": lane("BRONZE I", "IRON I"), - }, - } -} - -// ==================== Controller Tests ==================== - -func TestLolTemporalController_Success(t *testing.T) { - // Given - router := setupTestRouter() - svc := newTemporalService() - helper := handler.NewControllerHelper() - - router.POST("/api/contests/lol/temporal", func(c *gin.Context) { - presentation.HandleLolTemporalContest(c, svc, helper) - }) - - // When - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/api/contests/lol/temporal", temporalBody(t, validTemporalBody())) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - // Then - assert.Equal(t, http.StatusCreated, w.Code) - - var resp map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err) - assert.Equal(t, float64(201), resp["status"]) - - data, ok := resp["data"].(map[string]interface{}) - assert.True(t, ok) - members, ok := data["members"].(map[string]interface{}) - assert.True(t, ok) - - for _, lane := range []string{"TOP", "JG", "MID", "ADC", "SUP"} { - players, ok := members[lane].([]interface{}) - assert.True(t, ok, "lane %s should exist", lane) - assert.Len(t, players, 2, "lane %s should have 2 players", lane) - } -} - -func TestLolTemporalController_MissingLane(t *testing.T) { - // Given - router := setupTestRouter() - svc := newTemporalService() - helper := handler.NewControllerHelper() - - router.POST("/api/contests/lol/temporal", func(c *gin.Context) { - presentation.HandleLolTemporalContest(c, svc, helper) - }) - - body := map[string]interface{}{ - "members": map[string]interface{}{ - "TOP": []map[string]string{ - {"username": "A", "tag": "KR1", "rank": "GOLD II"}, - {"username": "B", "tag": "KR1", "rank": "GOLD II"}, - }, - // JG, MID, ADC, SUP 누락 - }, - } - - // When - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/api/contests/lol/temporal", temporalBody(t, body)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - // Then - assert.Equal(t, http.StatusBadRequest, w.Code) -} - -func TestLolTemporalController_InvalidRank(t *testing.T) { - // Given - router := setupTestRouter() - svc := newTemporalService() - helper := handler.NewControllerHelper() - - router.POST("/api/contests/lol/temporal", func(c *gin.Context) { - presentation.HandleLolTemporalContest(c, svc, helper) - }) - - lane := func(r1, r2 string) []map[string]string { - return []map[string]string{ - {"username": "A", "tag": "KR1", "rank": r1}, - {"username": "B", "tag": "KR1", "rank": r2}, - } - } - body := map[string]interface{}{ - "members": map[string]interface{}{ - "TOP": lane("GOLD I", "NOT_A_RANK"), - "JG": lane("SILVER I", "SILVER I"), - "MID": lane("GOLD IV", "GOLD IV"), - "ADC": lane("GOLD IV", "GOLD IV"), - "SUP": lane("BRONZE II", "BRONZE II"), - }, - } - - // When - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/api/contests/lol/temporal", temporalBody(t, body)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - // Then - assert.Equal(t, http.StatusBadRequest, w.Code) -} - -func TestLolTemporalController_InvalidJSON(t *testing.T) { - // Given - router := setupTestRouter() - svc := newTemporalService() - helper := handler.NewControllerHelper() - - router.POST("/api/contests/lol/temporal", func(c *gin.Context) { - presentation.HandleLolTemporalContest(c, svc, helper) - }) - - // When - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/api/contests/lol/temporal", bytes.NewBufferString("not json")) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - // Then - assert.Equal(t, http.StatusBadRequest, w.Code) -} - -func TestLolTemporalController_EmptyBody(t *testing.T) { - // Given - router := setupTestRouter() - svc := newTemporalService() - helper := handler.NewControllerHelper() - - router.POST("/api/contests/lol/temporal", func(c *gin.Context) { - presentation.HandleLolTemporalContest(c, svc, helper) - }) - - // When - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/api/contests/lol/temporal", bytes.NewBufferString("{}")) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - // Then - assert.Equal(t, http.StatusBadRequest, w.Code) -} diff --git a/test/lol/application/lol_team_balance_bench_test.go b/test/lol/application/lol_team_balance_bench_test.go new file mode 100644 index 0000000..5298113 --- /dev/null +++ b/test/lol/application/lol_team_balance_bench_test.go @@ -0,0 +1,47 @@ +package application_test + +import ( + "testing" + + "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/application" + "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/application/dto" +) + +func makePlayers() []*dto.LolTemporalPlayerV2 { + return []*dto.LolTemporalPlayerV2{ + {Username: "P1", Tag: "KR1", Rank: "CHALLENGER", Positions: []string{"MID", "JG", "TOP", "ADC", "SUP"}}, + {Username: "P2", Tag: "KR1", Rank: "GRANDMASTER", Positions: []string{"ADC", "SUP", "MID", "TOP", "JG"}}, + {Username: "P3", Tag: "KR1", Rank: "MASTER", Positions: []string{"JG", "TOP", "MID", "ADC", "SUP"}}, + {Username: "P4", Tag: "KR1", Rank: "DIAMOND I", Positions: []string{"TOP", "MID", "JG", "SUP", "ADC"}}, + {Username: "P5", Tag: "KR1", Rank: "DIAMOND II", Positions: []string{"SUP", "ADC", "MID", "JG", "TOP"}}, + {Username: "P6", Tag: "KR1", Rank: "PLATINUM I", Positions: []string{"MID", "TOP", "ADC", "JG", "SUP"}}, + {Username: "P7", Tag: "KR1", Rank: "GOLD I", Positions: []string{"ADC", "MID", "SUP", "TOP", "JG"}}, + {Username: "P8", Tag: "KR1", Rank: "GOLD III", Positions: []string{"JG", "MID", "TOP", "ADC", "SUP"}}, + {Username: "P9", Tag: "KR1", Rank: "SILVER I", Positions: []string{"TOP", "JG", "MID", "SUP", "ADC"}}, + {Username: "P10", Tag: "KR1", Rank: "BRONZE II", Positions: []string{"SUP", "TOP", "ADC", "MID", "JG"}}, + } +} + +// BenchmarkBalanceV2 — 알고리즘 전체 실행 시간 (3.6M iteration 포함) +func BenchmarkBalanceV2(b *testing.B) { + svc := application.NewLolTeamBalanceService(nil) + req := &dto.LolTemporalContestRequestV2{Members: makePlayers()} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = svc.BalanceLolTemporalContestV2(req) + } +} + +// BenchmarkBalanceV2Parallel — 동시 요청 시나리오 +func BenchmarkBalanceV2Parallel(b *testing.B) { + svc := application.NewLolTeamBalanceService(nil) + req := &dto.LolTemporalContestRequestV2{Members: makePlayers()} + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + _, _ = svc.BalanceLolTemporalContestV2(req) + } + }) +} diff --git a/test/contest/application/lol_temporal_service_test.go b/test/lol/application/lol_temporal_service_test.go similarity index 90% rename from test/contest/application/lol_temporal_service_test.go rename to test/lol/application/lol_temporal_service_test.go index 8d0c841..754f88e 100644 --- a/test/contest/application/lol_temporal_service_test.go +++ b/test/lol/application/lol_temporal_service_test.go @@ -3,16 +3,16 @@ package application_test import ( "testing" - "github.com/FOR-GAMERS/GAMERS-BE/internal/contest/application" - "github.com/FOR-GAMERS/GAMERS-BE/internal/contest/application/dto" + lolApp "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/application" + "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/application/dto" "github.com/FOR-GAMERS/GAMERS-BE/internal/global/exception" "github.com/stretchr/testify/assert" ) // ==================== Helpers ==================== -func newService() *application.ContestService { - return application.NewContestService(nil, nil, nil, nil, nil) +func newService() *lolApp.LolTeamBalanceService { + return lolApp.NewLolTeamBalanceService(nil) } func player(username, tag, rank string) *dto.LolTemporalMember { @@ -97,12 +97,9 @@ func TestBalanceLolTemporalContest_Success(t *testing.T) { } func TestBalanceLolTemporalContest_MinimizerTeamScoreDiff(t *testing.T) { - // All players same rank except one lane where one player is clearly better. - // GOLD II (15) vs IRON I (4) in TOP → optimal: put the better one in each team. svc := newService() req := &dto.LolTemporalContestRequest{ Members: map[string][]*dto.LolTemporalMember{ - // Same score per lane except TOP where diff=11 "TOP": {player("TopA", "KR1", "GOLD II"), player("TopB", "KR1", "IRON I")}, "JG": {player("JgA", "KR1", "SILVER I"), player("JgB", "KR1", "SILVER I")}, "MID": {player("MidA", "KR1", "GOLD IV"), player("MidB", "KR1", "GOLD IV")}, @@ -116,14 +113,12 @@ func TestBalanceLolTemporalContest_MinimizerTeamScoreDiff(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, resp) - // Team1 TOP and Team2 TOP should be different players (best possible split) topTeam1 := resp.Members["TOP"][0] topTeam2 := resp.Members["TOP"][1] assert.NotEqual(t, topTeam1.Username, topTeam2.Username) } func TestBalanceLolTemporalContest_SymmetricScores_ReturnsBothPlayers(t *testing.T) { - // All players have equal rank → any combination has diff=0, just ensure all 10 players appear. svc := newService() req := &dto.LolTemporalContestRequest{ Members: map[string][]*dto.LolTemporalMember{ @@ -149,7 +144,6 @@ func TestBalanceLolTemporalContest_MissingLane(t *testing.T) { req := &dto.LolTemporalContestRequest{ Members: map[string][]*dto.LolTemporalMember{ "TOP": {player("T1", "KR1", "GOLD II"), player("T2", "KR1", "GOLD II")}, - // JG, MID, ADC, SUP missing }, } @@ -162,7 +156,7 @@ func TestBalanceLolTemporalContest_LaneWithOnePlayer(t *testing.T) { svc := newService() req := &dto.LolTemporalContestRequest{ Members: map[string][]*dto.LolTemporalMember{ - "TOP": {player("T1", "KR1", "GOLD II")}, // only 1 player instead of 2 + "TOP": {player("T1", "KR1", "GOLD II")}, "JG": {player("J1", "KR1", "SILVER I"), player("J2", "KR1", "SILVER I")}, "MID": {player("M1", "KR1", "GOLD IV"), player("M2", "KR1", "GOLD IV")}, "ADC": {player("A1", "KR1", "GOLD IV"), player("A2", "KR1", "GOLD IV")}, diff --git a/test/lol/application/lol_temporal_v2_service_test.go b/test/lol/application/lol_temporal_v2_service_test.go new file mode 100644 index 0000000..f52558a --- /dev/null +++ b/test/lol/application/lol_temporal_v2_service_test.go @@ -0,0 +1,239 @@ +package application_test + +import ( + "testing" + + "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/application/dto" + "github.com/FOR-GAMERS/GAMERS-BE/internal/global/exception" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// allPositions is the full preference list in default order. +var allPositions = []string{"TOP", "JG", "MID", "ADC", "SUP"} + +// playerV2 builds a LolTemporalPlayerV2 with the given preferences. +func playerV2(username, rank string, positions []string) *dto.LolTemporalPlayerV2 { + return &dto.LolTemporalPlayerV2{ + Username: username, + Tag: "KR1", + Rank: rank, + Positions: positions, + } +} + +// validRequestV2 builds a request with 10 distinct players. +func validRequestV2() *dto.LolTemporalContestRequestV2 { + return &dto.LolTemporalContestRequestV2{ + Members: []*dto.LolTemporalPlayerV2{ + playerV2("P1", "GOLD I", []string{"TOP", "JG", "MID", "ADC", "SUP"}), + playerV2("P2", "GOLD III", []string{"JG", "TOP", "MID", "ADC", "SUP"}), + playerV2("P3", "PLATINUM IV", []string{"MID", "ADC", "SUP", "TOP", "JG"}), + playerV2("P4", "SILVER I", []string{"ADC", "SUP", "MID", "JG", "TOP"}), + playerV2("P5", "DIAMOND IV", []string{"SUP", "MID", "ADC", "JG", "TOP"}), + playerV2("P6", "EMERALD II", []string{"TOP", "MID", "JG", "SUP", "ADC"}), + playerV2("P7", "GOLD II", []string{"JG", "ADC", "TOP", "MID", "SUP"}), + playerV2("P8", "SILVER IV", []string{"MID", "TOP", "ADC", "SUP", "JG"}), + playerV2("P9", "BRONZE I", []string{"ADC", "JG", "MID", "TOP", "SUP"}), + playerV2("P10", "IRON I", []string{"SUP", "ADC", "JG", "MID", "TOP"}), + }, + } +} + +// ==================== BalanceLolTemporalContestV2 Tests ==================== + +func TestBalanceLolTemporalContestV2_Success(t *testing.T) { + svc := newService() + req := validRequestV2() + + resp, err := svc.BalanceLolTemporalContestV2(req) + + require.NoError(t, err) + require.NotNil(t, resp) + assert.Len(t, resp.TeamA, 5) + assert.Len(t, resp.TeamB, 5) + + seen := make(map[string]int) + for _, p := range resp.TeamA { + seen[p.Username]++ + } + for _, p := range resp.TeamB { + seen[p.Username]++ + } + assert.Len(t, seen, 10, "all 10 players must appear") + for _, count := range seen { + assert.Equal(t, 1, count, "each player appears exactly once") + } +} + +func TestBalanceLolTemporalContestV2_EachPlayerHasPosition(t *testing.T) { + svc := newService() + req := validRequestV2() + + resp, err := svc.BalanceLolTemporalContestV2(req) + + require.NoError(t, err) + validPos := map[string]bool{"TOP": true, "JG": true, "MID": true, "ADC": true, "SUP": true} + + for _, p := range append(resp.TeamA, resp.TeamB...) { + assert.True(t, validPos[p.Position], "position %q must be valid", p.Position) + assert.GreaterOrEqual(t, p.PositionPreference, 1) + assert.LessOrEqual(t, p.PositionPreference, 5) + } +} + +func TestBalanceLolTemporalContestV2_EachPositionFilledOnce(t *testing.T) { + svc := newService() + req := validRequestV2() + + resp, err := svc.BalanceLolTemporalContestV2(req) + + require.NoError(t, err) + + checkTeam := func(team []*dto.LolTemporalAssignedPlayer, name string) { + posCounts := make(map[string]int) + for _, p := range team { + posCounts[p.Position]++ + } + for _, pos := range allPositions { + assert.Equal(t, 1, posCounts[pos], "%s: position %s must appear exactly once", name, pos) + } + } + checkTeam(resp.TeamA, "TeamA") + checkTeam(resp.TeamB, "TeamB") +} + +func TestBalanceLolTemporalContestV2_PreferredPositionReflected(t *testing.T) { + svc := newService() + req := &dto.LolTemporalContestRequestV2{ + Members: func() []*dto.LolTemporalPlayerV2 { + players := make([]*dto.LolTemporalPlayerV2, 10) + for i := 0; i < 10; i++ { + players[i] = playerV2("P"+string(rune('0'+i+1)), "GOLD II", []string{"TOP", "JG", "MID", "ADC", "SUP"}) + } + return players + }(), + } + + resp, err := svc.BalanceLolTemporalContestV2(req) + + require.NoError(t, err) + for _, p := range append(resp.TeamA, resp.TeamB...) { + expectedRank := 0 + for i, pos := range allPositions { + if pos == p.Position { + expectedRank = i + 1 + break + } + } + assert.Equal(t, expectedRank, p.PositionPreference, "player %s: preference rank mismatch", p.Username) + } +} + +func TestBalanceLolTemporalContestV2_MinimisesTeamMMRDiff(t *testing.T) { + svc := newService() + members := make([]*dto.LolTemporalPlayerV2, 10) + for i := 0; i < 5; i++ { + members[i] = playerV2("High"+string(rune('A'+i)), "CHALLENGER", allPositions) + } + for i := 0; i < 5; i++ { + members[5+i] = playerV2("Low"+string(rune('A'+i)), "IRON IV", allPositions) + } + req := &dto.LolTemporalContestRequestV2{Members: members} + + resp, err := svc.BalanceLolTemporalContestV2(req) + + require.NoError(t, err) + countHigh := func(team []*dto.LolTemporalAssignedPlayer) int { + c := 0 + for _, p := range team { + if p.Rank == "CHALLENGER" { + c++ + } + } + return c + } + aHigh := countHigh(resp.TeamA) + bHigh := countHigh(resp.TeamB) + assert.NotEqual(t, 5, aHigh, "team A must not take all high-MMR players") + assert.NotEqual(t, 5, bHigh, "team B must not take all high-MMR players") +} + +// ==================== Validation Tests ==================== + +func TestBalanceLolTemporalContestV2_NotEnoughPlayers(t *testing.T) { + svc := newService() + req := &dto.LolTemporalContestRequestV2{ + Members: []*dto.LolTemporalPlayerV2{ + playerV2("P1", "GOLD II", allPositions), + }, + } + + _, err := svc.BalanceLolTemporalContestV2(req) + + assert.ErrorIs(t, err, exception.ErrLolTemporalInvalidPlayerCount) +} + +func TestBalanceLolTemporalContestV2_TooManyPlayers(t *testing.T) { + svc := newService() + members := make([]*dto.LolTemporalPlayerV2, 11) + for i := range members { + members[i] = playerV2("P", "GOLD II", allPositions) + } + req := &dto.LolTemporalContestRequestV2{Members: members} + + _, err := svc.BalanceLolTemporalContestV2(req) + + assert.ErrorIs(t, err, exception.ErrLolTemporalInvalidPlayerCount) +} + +func TestBalanceLolTemporalContestV2_MissingPosition(t *testing.T) { + svc := newService() + req := validRequestV2() + req.Members[0].Positions = []string{"TOP", "JG", "MID", "ADC"} + + _, err := svc.BalanceLolTemporalContestV2(req) + + assert.ErrorIs(t, err, exception.ErrLolTemporalInvalidPositions) +} + +func TestBalanceLolTemporalContestV2_DuplicatePosition(t *testing.T) { + svc := newService() + req := validRequestV2() + req.Members[0].Positions = []string{"TOP", "TOP", "MID", "ADC", "SUP"} + + _, err := svc.BalanceLolTemporalContestV2(req) + + assert.ErrorIs(t, err, exception.ErrLolTemporalInvalidPositions) +} + +func TestBalanceLolTemporalContestV2_InvalidPositionName(t *testing.T) { + svc := newService() + req := validRequestV2() + req.Members[0].Positions = []string{"TOP", "JG", "MID", "ADC", "SUPPORT"} + + _, err := svc.BalanceLolTemporalContestV2(req) + + assert.ErrorIs(t, err, exception.ErrLolTemporalInvalidPositions) +} + +func TestBalanceLolTemporalContestV2_InvalidRank(t *testing.T) { + svc := newService() + req := validRequestV2() + req.Members[0].Rank = "HYPERCARRY" + + _, err := svc.BalanceLolTemporalContestV2(req) + + assert.ErrorIs(t, err, exception.ErrLolTemporalInvalidRank) +} + +func TestBalanceLolTemporalContestV2_PositionsNormalizedToUpper(t *testing.T) { + svc := newService() + req := validRequestV2() + req.Members[0].Positions = []string{"top", "jg", "mid", "adc", "sup"} + + resp, err := svc.BalanceLolTemporalContestV2(req) + + assert.NoError(t, err) + assert.NotNil(t, resp) +} diff --git a/test/lol/presentation/lol_temporal_controller_test.go b/test/lol/presentation/lol_temporal_controller_test.go new file mode 100644 index 0000000..fb1b539 --- /dev/null +++ b/test/lol/presentation/lol_temporal_controller_test.go @@ -0,0 +1,175 @@ +package presentation_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + lolApp "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/application" + lolPresentation "github.com/FOR-GAMERS/GAMERS-BE/internal/lol/presentation" + "github.com/FOR-GAMERS/GAMERS-BE/internal/global/common/handler" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +// ==================== Helpers ==================== + +func setupTestRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + return gin.New() +} + +func newLolService() *lolApp.LolTeamBalanceService { + return lolApp.NewLolTeamBalanceService(nil) +} + +func temporalBody(t *testing.T, body map[string]interface{}) *bytes.Buffer { + t.Helper() + b, err := json.Marshal(body) + assert.NoError(t, err) + return bytes.NewBuffer(b) +} + +func validTemporalBody() map[string]interface{} { + allPos := []string{"TOP", "JG", "MID", "ADC", "SUP"} + member := func(username, rank string, positions []string) map[string]interface{} { + return map[string]interface{}{ + "username": username, + "tag": "KR1", + "rank": rank, + "positions": positions, + } + } + return map[string]interface{}{ + "members": []interface{}{ + member("P1", "GOLD I", allPos), + member("P2", "GOLD III", []string{"JG", "TOP", "MID", "ADC", "SUP"}), + member("P3", "PLATINUM IV", []string{"MID", "ADC", "SUP", "TOP", "JG"}), + member("P4", "SILVER I", []string{"ADC", "SUP", "MID", "JG", "TOP"}), + member("P5", "DIAMOND IV", []string{"SUP", "MID", "ADC", "JG", "TOP"}), + member("P6", "EMERALD II", allPos), + member("P7", "GOLD II", []string{"JG", "ADC", "TOP", "MID", "SUP"}), + member("P8", "SILVER IV", []string{"MID", "TOP", "ADC", "SUP", "JG"}), + member("P9", "BRONZE I", []string{"ADC", "JG", "MID", "TOP", "SUP"}), + member("P10", "IRON I", []string{"SUP", "ADC", "JG", "MID", "TOP"}), + }, + } +} + +// ==================== Controller Tests ==================== + +func TestLolTemporalController_Success(t *testing.T) { + router := setupTestRouter() + svc := newLolService() + helper := handler.NewControllerHelper() + + router.POST("/api/lol/temporal", func(c *gin.Context) { + c.Set("userId", int64(1)) + lolPresentation.HandleBalanceLolTemporalV2(c, svc, helper) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/lol/temporal", temporalBody(t, validTemporalBody())) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + + var resp map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &resp) + assert.NoError(t, err) + assert.Equal(t, float64(201), resp["status"]) + + data, ok := resp["data"].(map[string]interface{}) + assert.True(t, ok) + teamA, ok := data["team_a"].([]interface{}) + assert.True(t, ok) + assert.Len(t, teamA, 5) + teamB, ok := data["team_b"].([]interface{}) + assert.True(t, ok) + assert.Len(t, teamB, 5) +} + +func TestLolTemporalController_NotEnoughPlayers(t *testing.T) { + router := setupTestRouter() + svc := newLolService() + helper := handler.NewControllerHelper() + + router.POST("/api/lol/temporal", func(c *gin.Context) { + c.Set("userId", int64(1)) + lolPresentation.HandleBalanceLolTemporalV2(c, svc, helper) + }) + + body := map[string]interface{}{ + "members": []interface{}{ + map[string]interface{}{"username": "A", "tag": "KR1", "rank": "GOLD II", "positions": []string{"TOP", "JG", "MID", "ADC", "SUP"}}, + }, + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/lol/temporal", temporalBody(t, body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestLolTemporalController_InvalidRank(t *testing.T) { + router := setupTestRouter() + svc := newLolService() + helper := handler.NewControllerHelper() + + router.POST("/api/lol/temporal", func(c *gin.Context) { + c.Set("userId", int64(1)) + lolPresentation.HandleBalanceLolTemporalV2(c, svc, helper) + }) + + body := validTemporalBody() + members := body["members"].([]interface{}) + members[0].(map[string]interface{})["rank"] = "NOT_A_RANK" + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/lol/temporal", temporalBody(t, body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestLolTemporalController_InvalidJSON(t *testing.T) { + router := setupTestRouter() + svc := newLolService() + helper := handler.NewControllerHelper() + + router.POST("/api/lol/temporal", func(c *gin.Context) { + c.Set("userId", int64(1)) + lolPresentation.HandleBalanceLolTemporalV2(c, svc, helper) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/lol/temporal", bytes.NewBufferString("not json")) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestLolTemporalController_EmptyBody(t *testing.T) { + router := setupTestRouter() + svc := newLolService() + helper := handler.NewControllerHelper() + + router.POST("/api/lol/temporal", func(c *gin.Context) { + c.Set("userId", int64(1)) + lolPresentation.HandleBalanceLolTemporalV2(c, svc, helper) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/lol/temporal", bytes.NewBufferString("{}")) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +}