diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8c3f444 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,25 @@ +# Normalize line endings to LF in the repository +* text=auto eol=lf + +# Explicitly declare text files +*.cs text eol=lf +*.csproj text eol=lf +*.sln text eol=lf +*.json text eol=lf +*.md text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.sh text eol=lf +*.toml text eol=lf +*.dockerfile text eol=lf +*.dockerignore text eol=lf +*.gitignore text eol=lf + +# Binary files — no line ending conversion +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.zip binary +*.tar.gz binary diff --git a/.github/workflows/Deploy-Server.yaml b/.github/workflows/Deploy-Server.yaml index 2ff3eb5..1bbb1bc 100644 --- a/.github/workflows/Deploy-Server.yaml +++ b/.github/workflows/Deploy-Server.yaml @@ -19,13 +19,24 @@ jobs: run: | docker build \ -t seanyee1227/pushandpull-server:latest \ - -f PushAndPull/PushAndPull/Dockerfile \ - PushAndPull/PushAndPull + -f PushAndPull/deploy/prod.dockerfile \ + PushAndPull - name: Push Docker image run: docker push seanyee1227/pushandpull-server:latest - - name: Deploy to server via SSH + - name: Copy compose file to server + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USERNAME }} + port: ${{ secrets.SSH_PORT }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + source: "PushAndPull/deploy/compose.prod.yaml" + target: "~/deploy/" + strip_components: 2 + + - name: Deploy via Docker Compose uses: appleboy/ssh-action@v0.1.10 with: host: ${{ secrets.SSH_HOST }} @@ -33,19 +44,17 @@ jobs: port: ${{ secrets.SSH_PORT }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | - docker pull seanyee1227/pushandpull-server:latest - docker stop pushandpull-server || true - docker rm pushandpull-server || true - - docker run -d \ - --name pushandpull-server \ - --network pushandpull-network \ - --restart unless-stopped \ - -p 21754:80 \ - -e ASPNETCORE_URLS=http://+:80 \ - -e ASPNETCORE_ENVIRONMENT=Production \ - -e "ConnectionStrings__Default=${{ secrets.DB_CONNECTION_STRING }}" \ - seanyee1227/pushandpull-server:latest + mkdir -p ~/deploy + + printf 'DB_CONNECTION_STRING=%s\nREDIS_CONNECTION_STRING=%s\nSTEAM_WEB_API_KEY=%s\nSTEAM_APP_ID=%s\n' \ + "${{ secrets.DB_CONNECTION_STRING }}" \ + "${{ secrets.REDIS_CONNECTION_STRING }}" \ + "${{ secrets.STEAM_WEB_API_KEY }}" \ + "${{ secrets.STEAM_APP_ID }}" \ + > ~/deploy/.env + + docker compose -f ~/deploy/compose.prod.yaml --env-file ~/deploy/.env pull + docker compose -f ~/deploy/compose.prod.yaml --env-file ~/deploy/.env up -d sleep 5 docker ps | grep pushandpull-server diff --git a/PushAndPull/.claude/commands/commit.md b/PushAndPull/.claude/commands/commit.md deleted file mode 100644 index 24d32b6..0000000 --- a/PushAndPull/.claude/commands/commit.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -description: Create Git commits by splitting changes into logical units -allowed-tools: Bash ---- - -Create Git commits following the project's commit conventions. - -## Commit Message Format - -``` -{type}: {Korean description} -``` - -**Types**: -- `feat` — new feature added -- `fix` — bug fix, missing config, or missing DI registration -- `update` — modification to existing code -- `docs` — documentation changes - -**Description rules**: -- Written in **Korean** -- Short and imperative (단문) -- No trailing punctuation (`.`, `!` etc.) -- Avoid noun-ending style — prefer verb style - -**Examples**: -``` -feat: 방 생성 API 추가 -fix: 세션 DI 누락 수정 -update: Room 엔터티 수정 -``` - -**Do NOT**: -- Add Claude as co-author -- Write descriptions in English -- Add a commit body — subject line only - -## Splitting Rules - -**Changes must be split into logical units. Never combine unrelated changes into a single commit.** - -Splitting criteria: -- Different domains → separate commits (Auth changes / Room changes) -- Different roles → separate commits (feature addition / bug fix / refactoring) -- Multiple files with a single purpose → can be grouped into one commit - -Example — correct splitting: -``` -feat: 방 참여 API 추가 ← Room feature files -fix: 세션 DI 누락 수정 ← Auth DI registration fix -update: Room 엔터티 수정 ← Room Entity change -``` - -Example — incorrect splitting: -``` -feat: 방 참여 API 추가 및 세션 버그 수정 ← two purposes in one commit ❌ -``` - -## Steps - -1. Run `git status` and `git diff` to review all changes -2. Classify changed files into logical groups: - - New feature → `feat` - - Bug fix / missing registration → `fix` - - Modification to existing code → `update` - - Documentation changes → `docs` -3. If there are 2+ groups, they must be committed separately -4. For each group: - - Stage only the relevant files with `git add ` - - Write a commit message following the format above - - Run `git commit -m "message"` -5. Verify with `git log --oneline -n {commit count}` diff --git a/PushAndPull/.claude/commands/migrate.md b/PushAndPull/.claude/commands/migrate.md deleted file mode 100644 index ace8101..0000000 --- a/PushAndPull/.claude/commands/migrate.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -description: Add an EF Core migration and optionally update the database -allowed-tools: Bash, Read ---- - -Add an EF Core migration using the name provided as the argument. - -Migration name must follow the convention: `{Verb}{Target}{Change}` (e.g. `AddRoomPasswordColumn`, `RemoveUserNicknameColumn`). - -Usage: `/migrate ` - -## Steps - -1. Run the migration command using the argument as the migration name: - ```bash - dotnet ef migrations add $ARGUMENTS --project PushAndPull - ``` -2. After the migration is created: - - Read the generated migration file under `PushAndPull/Migrations/` - - Briefly summarize what the migration does (tables created/altered, columns added/removed, indexes, etc.) -3. Ask the user whether to apply the migration to the database: - - If yes, run: - ```bash - dotnet ef database update --project PushAndPull - ``` - - Confirm the update result when done diff --git a/PushAndPull/.claude/commands/test.md b/PushAndPull/.claude/commands/test.md deleted file mode 100644 index c0cb798..0000000 --- a/PushAndPull/.claude/commands/test.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -description: Run all tests and analyze failures -allowed-tools: Bash ---- - -Run the full test suite and report results. - -## Steps - -1. Run all tests: - ```bash - dotnet test PushAndPull.sln --logger "console;verbosity=normal" - ``` -2. Check the test output: - - If all tests **pass**: confirm success and show the summary (total tests, duration) - - If any tests **fail**: list each failing test by name, show the failure message and stack trace, then explain the likely cause and how to fix it diff --git a/PushAndPull/.claude/hooks/commit-msg.sh b/PushAndPull/.claude/hooks/commit-msg.sh deleted file mode 100644 index 75b5ee2..0000000 --- a/PushAndPull/.claude/hooks/commit-msg.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/bin/bash -# .claude/hooks/commit-msg.sh -# Validate commit message format for git commit commands (Claude Code PreToolUse hook) - -INPUT=$(cat) -TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty') -COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') - -# Only handle Bash tool -if [[ "$TOOL_NAME" != "Bash" ]]; then - exit 0 -fi - -# Only handle git commit commands -if [[ ! "$COMMAND" =~ git[[:space:]]+commit ]]; then - exit 0 -fi - -# Extract commit message from -m "..." or -m '...' -COMMIT_MSG=$(echo "$COMMAND" | grep -oP '(?<=-m )["\x27]?\K[^"'\'']+') - -if [[ -z "$COMMIT_MSG" ]]; then - exit 0 -fi - -# Trim whitespace -COMMIT_MSG=$(echo "$COMMIT_MSG" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') - -TYPES="feat|fix|update|docs|refactor|test|chore" -PATTERN="^($TYPES): .+" - -if [[ ! "$COMMIT_MSG" =~ $PATTERN ]]; then - echo "[Hook] ✗ Invalid commit message format" - echo "" - echo "Expected: {type}: {Korean description}" - echo "Allowed types: feat, fix, update, docs, refactor, test, chore" - echo "" - echo "Examples:" - echo " feat: 방 생성 API 추가" - echo " fix: 세션 DI 누락 수정" - exit 2 -fi - -if [[ "$COMMIT_MSG" =~ [\.\!]$ ]]; then - echo "[Hook] ✗ Do not end the message with punctuation" - exit 2 -fi - -LENGTH=${#COMMIT_MSG} -if (( LENGTH < 10 )); then - echo "[Hook] ✗ Commit message too short (min: 10 chars)" - exit 2 -fi - -if (( LENGTH > 72 )); then - echo "[Hook] ✗ Commit message too long (max: 72 chars)" - exit 2 -fi - -echo "[Hook] ✓ Commit message valid" -exit 0 diff --git a/PushAndPull/.claude/hooks/postToolUse.sh b/PushAndPull/.claude/hooks/postToolUse.sh new file mode 100644 index 0000000..47ca941 --- /dev/null +++ b/PushAndPull/.claude/hooks/postToolUse.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# .claude/hooks/postToolUse.sh + +INPUT=$(cat) +TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty') +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') + +if [[ "$TOOL_NAME" != "Edit" && "$TOOL_NAME" != "Write" ]]; then + exit 0 +fi + +if [[ "$FILE_PATH" != *.cs ]]; then + exit 0 +fi + +echo "[Hook] C# file modified: $FILE_PATH" >&2 + +dotnet format --no-restore 2>/dev/null || echo "[Hook] format failed (ignored)" >&2 + +CACHE_FILE=".claude/.last_build_hash" + +CURRENT_HASH=$(find . -name "*.cs" -not -path "*/obj/*" | sort | xargs md5sum 2>/dev/null | md5sum | cut -d' ' -f1) + +LAST_HASH="" +if [[ -f "$CACHE_FILE" ]]; then + LAST_HASH=$(cat "$CACHE_FILE") +fi + +if [[ "$CURRENT_HASH" != "$LAST_HASH" ]]; then + echo "[Hook] Running dotnet build..." >&2 + if dotnet build PushAndPull/PushAndPull.csproj --no-restore; then + echo "$CURRENT_HASH" > "$CACHE_FILE" + else + echo "[Hook] Build failed" >&2 + exit 2 + fi +else + echo "[Hook] Skip build (no source changes)" >&2 +fi + +echo "[Hook] Running tests..." >&2 +if dotnet test PushAndPull.Test/PushAndPull.Test.csproj --no-build --verbosity minimal; then + echo "[Hook] Tests passed" >&2 +else + echo "[Hook] Tests failed" >&2 + exit 2 +fi + +exit 0 diff --git a/PushAndPull/.claude/hooks/preCommit.sh b/PushAndPull/.claude/hooks/preCommit.sh index 80605df..176e9ee 100644 --- a/PushAndPull/.claude/hooks/preCommit.sh +++ b/PushAndPull/.claude/hooks/preCommit.sh @@ -1,28 +1,42 @@ #!/bin/bash # .claude/hooks/preCommit.sh -# Ensure tests pass before allowing dotnet build/run/publish (Claude Code PreToolUse hook) -INPUT=$(cat) -TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty') -COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') +COMMIT_MSG="$TOOL_PARAMS_MESSAGE" -if [[ "$TOOL_NAME" != "Bash" ]]; then - exit 0 -fi +# allowed types +PATTERN="^(feat|fix|update|docs): .+" -if [[ ! "$COMMAND" =~ dotnet[[:space:]]+(build|run|publish) ]]; then - exit 0 +if [[ ! "$COMMIT_MSG" =~ $PATTERN ]]; then + echo "[Hook] ✗ Invalid commit message format" + echo "" + echo "Expected:" + echo " {type}: {Korean description}" + echo "" + echo "Types:" + echo " feat — new feature" + echo " fix — bug fix or missing DI/config" + echo " update — modification to existing code" + echo " docs — documentation changes" + echo "" + echo "Examples:" + echo " feat: 방 생성 API 추가" + echo " fix: 세션 DI 누락 수정" + echo " update: Room 엔터티 수정" + exit 1 fi -echo "[Hook] Checking tests before proceeding..." - -dotnet test PushAndPull/PushAndPull.sln --nologo --no-build 2>/dev/null -RESULT=$? +# punctuation check +if [[ "$COMMIT_MSG" =~ [\.\!]$ ]]; then + echo "[Hook] ✗ Do not end the message with punctuation" + echo "Example: feat: 방 생성 API 추가" + exit 1 +fi -if [ $RESULT -ne 0 ]; then - echo "[Hook] ✗ Tests failed — fix tests before running dotnet $BASH_REMATCH[1]" - exit 2 +# ensure single line +if [[ "$COMMIT_MSG" == *$'\n'* ]]; then + echo "[Hook] ✗ Commit body is not allowed" + echo "Use subject line only" + exit 1 fi -echo "[Hook] ✓ Tests passed" -exit 0 +echo "[Hook] ✓ Commit message format valid" diff --git a/PushAndPull/.claude/hooks/preToolUse.sh b/PushAndPull/.claude/hooks/preToolUse.sh index be5993b..1f34294 100644 --- a/PushAndPull/.claude/hooks/preToolUse.sh +++ b/PushAndPull/.claude/hooks/preToolUse.sh @@ -1,39 +1,46 @@ #!/bin/bash -# .claude/hooks/preToolUser.sh +# .claude/hooks/preToolUse.sh # Block dangerous commands before execution (Claude Code PreToolUse hook) INPUT=$(cat) TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty') COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') -if [[ "$TOOL_NAME" != "Bash" ]]; then - exit 0 -fi +[[ "$TOOL_NAME" != "Bash" ]] && exit 0 -NORMALIZED=$(echo "$COMMAND" | tr -s ' ') +NORMALIZED=$(echo "$COMMAND" | tr -s ' ' | tr -d '\n') -BLOCKED_PATTERNS=( - "rm -rf /" - "rm -rf \." - "rm -rf ~" - "rm -rf \*" - "sudo .*rm" - "> /dev/" - "dd if=" +DANGEROUS_KEYWORDS=( + "rm -rf" "mkfs" - "curl .*\| .*sh" - "curl .*\| .*bash" - "wget .*\| .*sh" - "wget .*\| .*bash" + "dd if=" + "shutdown" + "reboot" + "kill -9 -1" + ":(){" + "> /dev/sda" + "chmod -R 777 /" + "chown -R root" ) -for pattern in "${BLOCKED_PATTERNS[@]}"; do - if [[ "$NORMALIZED" =~ $pattern ]]; then - echo "[Hook] ✗ Blocked dangerous command" - echo "Command: $COMMAND" - echo "Reason: matched pattern '$pattern'" +for keyword in "${DANGEROUS_KEYWORDS[@]}"; do + if [[ "$NORMALIZED" == *"$keyword"* ]]; then + echo "[Hook] ✗ Blocked dangerous keyword: $keyword" >&2 + echo "Command: $COMMAND" >&2 exit 2 fi done +# rm -rf / or rm -rf * +if [[ "$NORMALIZED" =~ rm[[:space:]]+-rf.*(/|\*) ]]; then + echo "[Hook] ✗ Blocked root/wildcard deletion" >&2 + exit 2 +fi + +# curl/wget → pipe/redirect → sh/bash +if [[ "$NORMALIZED" =~ (curl|wget).*(\||>).*(sh|bash) ]]; then + echo "[Hook] ✗ Blocked remote execution" >&2 + exit 2 +fi + exit 0 diff --git a/PushAndPull/.claude/rules/architecture.md b/PushAndPull/.claude/rules/architecture.md new file mode 100644 index 0000000..6cbaf87 --- /dev/null +++ b/PushAndPull/.claude/rules/architecture.md @@ -0,0 +1,59 @@ +--- +description: Overall architecture, directory structure, and layering rules. Always applied. +globs: +alwaysApply: true +--- + +## Directory Structure + +``` +PushAndPull/ +├── Domain/ +│ ├── {DomainName}/ +│ │ ├── Config/ # DI registration extension methods +│ │ ├── Controller/ # ASP.NET Core controllers +│ │ ├── Dto/ +│ │ │ ├── Request/ +│ │ │ └── Response/ +│ │ ├── Entity/ +│ │ │ └── Config/ # EF Core Fluent API configurations +│ │ ├── Exception/ # Domain-specific exceptions +│ │ ├── Repository/ +│ │ │ └── Interface/ +│ │ └── Service/ +│ │ └── Interface/ # Service interface + Command/Result records +└── Global/ + ├── Auth/ # Steam ticket validation (IAuthTicketValidator) + ├── Cache/ # Redis cache (ICacheStore, CacheKey) + ├── Config/ # Infrastructure registrations (DB, Redis) + ├── Infrastructure/ # AppDbContext + ├── Security/ # [SessionAuthorize], ClaimsPrincipalExtensions + └── Service/ # Shared utilities (IPasswordHasher, IRoomCodeGenerator) +``` + +## Layering Rules + +- **Controllers** depend on service interfaces only — never concrete services or repositories. +- **Services** depend on repository interfaces and global service interfaces only. +- **Repositories** are the only layer that touches `AppDbContext`. +- Interfaces (repository + service) are co-located within their domain folder. + +Forbidden: +``` +Controller → Repository ❌ +Controller → concrete class ❌ +Service → concrete Repository ❌ +Entity → Service / Repository ❌ +``` + +## Adding a New Domain + +1. Define entity in `Domain/{Name}/Entity/` +2. Add EF Fluent config in `Domain/{Name}/Entity/Config/` +3. Register `DbSet` in `AppDbContext` +4. Define DTOs (records) in `Domain/{Name}/Dto/Request/` and `Dto/Response/` +5. Define repository interface in `Domain/{Name}/Repository/Interface/` +6. Define service interface(s) + Command/Result records in `Domain/{Name}/Service/Interface/` +7. Implement repository and service +8. Create `Domain/{Name}/Config/{Name}ServiceConfig.cs` with DI extension method +9. Call the extension method from `Program.cs` diff --git a/PushAndPull/.claude/rules/code-style.md b/PushAndPull/.claude/rules/code-style.md new file mode 100644 index 0000000..b1f54cf --- /dev/null +++ b/PushAndPull/.claude/rules/code-style.md @@ -0,0 +1,66 @@ +--- +description: C# code style rules. Applied for all .cs files. +globs: ["**/*.cs"] +alwaysApply: false +--- + +## C# Code Style + +### General + +- Use `var` only when the type is obvious from the right-hand side. +- Prefer expression-body methods for single-line implementations. +- No XML doc comments unless explicitly requested. +- No `#region` blocks. + +### Naming + +- Private fields: `_camelCase` (underscore prefix) +- Properties, methods, classes: `PascalCase` +- Local variables and parameters: `camelCase` +- Interfaces: `IPascalCase` +- Database column names: `snake_case` + +### Classes & Constructors + +- Constructor-inject all dependencies; assign to `private readonly` fields. +- Keep constructors minimal — no logic, only assignments. + +### DTOs + +- Always use `record` types. +- Request DTOs in `Dto/Request/`, Response DTOs in `Dto/Response/`. + +```csharp +public record CreateRoomRequest( + long LobbyId, + string RoomName, + bool IsPrivate, + string? Password +); +``` + +### Entities + +- Default constructor: `protected` or `private`. +- Public constructor accepts required fields only. +- State changes only through domain methods (`room.Join()`, `user.UpdateNickname()`). +- No direct field mutation from outside the entity. + +### Service Command/Result Pattern + +- Each service interface defines its own `Command` (input) and `Result` (output) records. +- Co-located in the same Interface file. + +### Async + +- All I/O methods are `async Task` or `async Task`. +- Never use `.Result` or `.Wait()`. +- Always `await` — no fire-and-forget unless intentional. +- Pass `CancellationToken` through to repository/async calls where appropriate. + +### Exception Handling + +- Throw domain-specific exceptions (e.g., `NotFoundException`, `UnauthorizedException` from `Gamism.SDK`). +- Do not catch and re-throw unless adding context. +- No empty catch blocks. diff --git a/PushAndPull/.claude/rules/conventions.md b/PushAndPull/.claude/rules/conventions.md new file mode 100644 index 0000000..cc6018a --- /dev/null +++ b/PushAndPull/.claude/rules/conventions.md @@ -0,0 +1,65 @@ +--- +description: Naming, DTO, EF Core entity configuration, and cache conventions. Applied for .cs files. +globs: ["**/*.cs"] +alwaysApply: false +--- + +## Naming Conventions + +| Type | Convention | Example | +|------|-----------|---------| +| Service Interface | `I{Action}Service` | `ILoginService` | +| Repository Interface | `I{Entity}Repository` | `IUserRepository` | +| Command | `{Action}Command` | `LoginCommand` | +| Result | `{Action}Result` | `LoginResult` | +| Request DTO | `{Action}Request` | `CreateRoomRequest` | +| Response DTO | `{Action}Response` | `CreateRoomResponse` | +| Service impl | `{Action}Service` | `LoginService` | + +- Database column names: `snake_case` +- Timestamps: `timestamptz` +- Enums stored as strings: `.HasConversion()` + +## DTOs + +- Use `record` types only — `Request/` for HTTP input, `Response/` for HTTP output. +- No DataAnnotations on entities — only on Request DTOs if needed. + +## Entity Configuration (EF Core) + +- Use `IEntityTypeConfiguration` Fluent API — **never DataAnnotations on entities**. +- Place in `Domain/{Name}/Entity/Config/`. +- `AppDbContext.OnModelCreating` calls only `modelBuilder.ApplyConfigurationsFromAssembly(...)`. + +```csharp +public class RoomConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("room", "room"); + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).HasColumnName("id"); + builder.Property(e => e.Status) + .HasColumnName("status") + .HasConversion(); + builder.Property(e => e.CreatedAt) + .HasColumnName("created_at") + .HasColumnType("timestamptz"); + } +} +``` + +## Cache (Redis) + +All Redis keys must go through `CacheKey` — hardcoded strings are **forbidden**. +- Correct: `CacheKey.Session.ById(sessionId)` +- Forbidden: `"session:" + sessionId` + +## Domain DI Registration + +Each domain exposes a single `Add{Domain}Services(this IServiceCollection)` extension method in `Config/`. +Call all domain configs from `Program.cs`. **Never skip DI registration after adding a new service.** + +## Read Queries + +Always use `AsNoTracking()` for queries that do not modify entities. diff --git a/PushAndPull/.claude/rules/db-migration.md b/PushAndPull/.claude/rules/db-migration.md new file mode 100644 index 0000000..4186b58 --- /dev/null +++ b/PushAndPull/.claude/rules/db-migration.md @@ -0,0 +1,99 @@ +--- +description: EF Core migration workflow, naming, and entity modification patterns. Applied when modifying Entity or Entity/Config files. +globs: ["**/Entity/**/*.cs", "**/Entity/Config/**/*.cs"] +alwaysApply: false +--- + +## Entity Change Checklist + +- [ ] Analyzed impact on existing data +- [ ] Determined need for migration script +- [ ] Planned 2-phase deployment strategy for column deletion +- [ ] Prepared rollback strategy + +## Change Order + +1. **Modify Entity**: `Domain/{DomainName}/Entity/` +2. **Modify EF Config**: `Domain/{DomainName}/Entity/Config/{Entity}Config.cs` +3. **Modify DTO**: Update `Request/`, `Response/` records +4. **Modify Repository**: Adjust queries if needed +5. **Modify Service**: Update business logic +6. **Generate and apply migration**: `/db-migrate add ` + +## Migration Name Convention + +``` +{Verb}{Target}{Change} + +Examples: +AddRoomPasswordColumn +RemoveUserNicknameColumn +RenameRoomStatusToState +AddIndexOnRoomCode +CreateUserTable +``` + +## Entity Modification Patterns + +### Adding a Column + +```csharp +// 1. Add property to Entity +public class Room +{ + public string? Description { get; private set; } + + public void UpdateDescription(string description) + { + Description = description; + } +} + +// 2. Add mapping in Config +builder.Property(e => e.Description) + .HasColumnName("description") + .IsRequired(false); + +// 3. Generate migration +// /db-migrate add AddRoomDescriptionColumn +``` + +### Deleting a Column (2-Phase Deployment) + +``` +Phase 1 — Deprecate: + - Keep the Entity property + - Stop using the column in new code + - Run data migration if needed + +Phase 2 — Delete: + - Remove the Entity property + - Remove mapping from Config + - /db-migrate add Remove{Column}Column +``` + +### Adding / Changing an Enum + +```csharp +// 1. Define or modify the Enum +public enum RoomStatus { Open, Closed, Full } + +// 2. Keep string conversion in Config +builder.Property(e => e.Status).HasConversion(); + +// 3. Verify that existing string values in DB match the Enum member names +``` + +## Rollback Strategy + +```bash +# Rollback DB to a previous migration +dotnet ef database update --project PushAndPull + +# Remove the migration file after rollback (local only) +dotnet ef migrations remove --project PushAndPull +``` + +For production rollbacks: +- Always verify the `Down()` method in the migration file is correctly implemented +- If there is a risk of data loss (`DropColumn`, `DropTable`), take a backup first diff --git a/PushAndPull/.claude/rules/domain-patterns.md b/PushAndPull/.claude/rules/domain-patterns.md new file mode 100644 index 0000000..b19a3f9 --- /dev/null +++ b/PushAndPull/.claude/rules/domain-patterns.md @@ -0,0 +1,96 @@ +--- +description: Domain layer patterns (Service, Repository, Controller). Applied when working on Domain/** files. +globs: ["PushAndPull/Domain/**"] +alwaysApply: false +--- + +## Service Pattern + +Each use case is a single class implementing a single interface with one `ExecuteAsync` method. +`Command` (input) and `Result` (output) records are defined in the same Interface file. + +```csharp +// Interface file: Domain/Room/Service/Interface/ICreateRoomService.cs +public interface ICreateRoomService +{ + Task ExecuteAsync(CreateRoomCommand command, CancellationToken ct = default); +} + +public record CreateRoomCommand(ulong HostSteamId, long LobbyId, string RoomName, bool IsPrivate, string? Password); +public record CreateRoomResult(string RoomCode); + +// Implementation: Domain/Room/Service/CreateRoomService.cs +public class CreateRoomService : ICreateRoomService +{ + private readonly IRoomRepository _roomRepository; + private readonly IRoomCodeGenerator _codeGenerator; + + public CreateRoomService(IRoomRepository roomRepository, IRoomCodeGenerator codeGenerator) + { + _roomRepository = roomRepository; + _codeGenerator = codeGenerator; + } + + public async Task ExecuteAsync(CreateRoomCommand command, CancellationToken ct = default) + { + var code = _codeGenerator.Generate(); + var room = new Room(code, command.RoomName, command.HostSteamId); + await _roomRepository.AddAsync(room, ct); + return new CreateRoomResult(code); + } +} +``` + +## Repository Pattern + +- Interface in `Repository/Interface/`, implementation in `Repository/`. +- Only repositories access `AppDbContext`. + +```csharp +public class RoomRepository : IRoomRepository +{ + private readonly AppDbContext _db; + + public RoomRepository(AppDbContext db) => _db = db; + + public async Task GetByCodeAsync(string roomCode, CancellationToken ct = default) + => await _db.Rooms.AsNoTracking().FirstOrDefaultAsync(r => r.RoomCode == roomCode, ct); + + public async Task AddAsync(Room room, CancellationToken ct = default) + { + await _db.Rooms.AddAsync(room, ct); + await _db.SaveChangesAsync(ct); + } +} +``` + +## Controller Pattern + +- Inject service interfaces only. +- Map `Request` → `Command`, call service, map `Result` → `Response`. +- Apply `[SessionAuthorize]` on authenticated endpoints. +- Use `User.GetSteamId()` / `User.GetSessionId()` extension methods for claims. + +```csharp +[ApiController] +[Route("api/v1/room")] +public class RoomController : ControllerBase +{ + private readonly ICreateRoomService _createRoomService; + + public RoomController(ICreateRoomService createRoomService) + { + _createRoomService = createRoomService; + } + + [SessionAuthorize] + [HttpPost] + public async Task> CreateRoom([FromBody] CreateRoomRequest request) + { + var steamId = User.GetSteamId(); + var result = await _createRoomService.ExecuteAsync( + new CreateRoomCommand(steamId, request.LobbyId, request.RoomName, request.IsPrivate, request.Password)); + return CommonApiResponse.Created("Room created.", new CreateRoomResponse(result.RoomCode)); + } +} +``` diff --git a/PushAndPull/.claude/rules/flows.md b/PushAndPull/.claude/rules/flows.md new file mode 100644 index 0000000..0f893c5 --- /dev/null +++ b/PushAndPull/.claude/rules/flows.md @@ -0,0 +1,85 @@ +## API Flow Diagrams + +### POST /api/v1/auth/login — Steam 로그인 + +```mermaid +sequenceDiagram + Client->>AuthController: POST /api/v1/auth/login {ticket, nickname} + AuthController->>LoginService: ExecuteAsync(LoginCommand) + LoginService->>IAuthTicketValidator: ValidateAsync(ticket) + IAuthTicketValidator->>SteamAPI: AuthenticateUserTicket + SteamAPI-->>IAuthTicketValidator: steamId + IAuthTicketValidator-->>LoginService: AuthTicketValidationResult + LoginService->>IUserRepository: GetBySteamIdAsync(steamId) + IUserRepository-->>LoginService: null → CreateAsync(user) + IUserRepository-->>LoginService: user → UpdateAsync(nickname) + LoginService->>ISessionService: CreateAsync(steamId, ttl) + ISessionService->>Redis: SET session:{id} steamId EX + ISessionService-->>LoginService: PlayerSession + AuthController-->>Client: 200 {sessionId} +``` + +### POST /api/v1/auth/logout — 로그아웃 + +```mermaid +sequenceDiagram + Client->>AuthController: POST /api/v1/auth/logout (Session-Id) + Note over AuthController: [SessionAuthorize] + AuthController->>LogoutService: ExecuteAsync(LogoutCommand) + LogoutService->>ISessionService: DeleteAsync(sessionId) + ISessionService->>Redis: DEL session:{id} + AuthController-->>Client: 200 +``` + +### POST /api/v1/room — 방 생성 + +```mermaid +sequenceDiagram + Client->>RoomController: POST /api/v1/room {lobbyId, roomName, ...} + Note over RoomController: [SessionAuthorize] + RoomController->>CreateRoomService: ExecuteAsync(CreateRoomCommand) + CreateRoomService->>IRoomCodeGenerator: Generate() + CreateRoomService->>IRoomRepository: AddAsync(room) + IRoomRepository->>PostgreSQL: INSERT room + RoomController-->>Client: 201 {roomCode} +``` + +### GET /api/v1/room/all — 방 목록 조회 + +```mermaid +sequenceDiagram + Client->>RoomController: GET /api/v1/room/all + RoomController->>GetAllRoomService: ExecuteAsync() + GetAllRoomService->>IRoomRepository: GetAllActiveAsync() + IRoomRepository->>PostgreSQL: SELECT rooms (AsNoTracking) + IRoomRepository-->>GetAllRoomService: rooms + RoomController-->>Client: 200 [roomList] +``` + +### GET /api/v1/room/{roomCode} — 방 상세 조회 + +```mermaid +sequenceDiagram + Client->>RoomController: GET /api/v1/room/{roomCode} + RoomController->>GetRoomService: ExecuteAsync(GetRoomCommand) + GetRoomService->>IRoomRepository: GetByCodeAsync(roomCode) + IRoomRepository->>PostgreSQL: SELECT room (AsNoTracking) + IRoomRepository-->>GetRoomService: null → NotFoundException + IRoomRepository-->>GetRoomService: room + RoomController-->>Client: 200 {roomDetail} +``` + +### POST /api/v1/room/{roomCode}/join — 방 참여 + +```mermaid +sequenceDiagram + Client->>RoomController: POST /api/v1/room/{roomCode}/join + Note over RoomController: [SessionAuthorize] + RoomController->>JoinRoomService: ExecuteAsync(JoinRoomCommand) + JoinRoomService->>IRoomRepository: GetByCodeAsync(roomCode) + IRoomRepository-->>JoinRoomService: null → NotFoundException + JoinRoomService->>Room: Join(steamId) + JoinRoomService->>IRoomRepository: UpdateAsync(room) + IRoomRepository->>PostgreSQL: UPDATE room + RoomController-->>Client: 200 {lobbyId} +``` diff --git a/PushAndPull/.claude/rules/global-patterns.md b/PushAndPull/.claude/rules/global-patterns.md new file mode 100644 index 0000000..d3ae475 --- /dev/null +++ b/PushAndPull/.claude/rules/global-patterns.md @@ -0,0 +1,60 @@ +--- +description: Global infrastructure patterns (Steam Auth, Redis Session, CacheKey, Security). Applied when working on Global/** files. +globs: ["PushAndPull/Global/**"] +alwaysApply: false +--- + +## Global Config Extension Methods + +All infrastructure registrations are `IServiceCollection` extension methods in `Global/Config/`: + +| Class | Method | Purpose | +|---|---|---| +| `DatabaseConfig` | `AddDatabase` | EF Core + PostgreSQL (Npgsql) | +| `RedisConfig` | `AddRedis` | StackExchange.Redis + IDistributedCache | + +## Steam Authentication + +Steam ticket validation is handled by `IAuthTicketValidator` in `Global/Auth/`: + +- `ValidateAsync(ticket)` — calls `ISteamUserAuth/AuthenticateUserTicket/v1` and returns `AuthTicketValidationResult` +- Returns `steamId` (`ulong`) on success +- Throws `UnauthorizedException` on invalid/expired ticket + +## Session Store (Redis) + +Sessions are stored in Redis via `ISessionService` / `ISessionStore`: + +- Session key: `CacheKey.Session.ById(sessionId)` +- Session contains `SteamId` (`ulong`) +- TTL: 15 days (configurable) + +**All Redis keys must use `CacheKey` — no hardcoded strings.** + +## Security + +`[SessionAuthorize]` attribute validates `Session-Id` header on each request: +- Reads session from Redis +- Populates `ClaimsPrincipal` with `SteamId` and `SessionId` + +Extension methods for claims extraction: + +```csharp +// In controller (after [SessionAuthorize]) +ulong steamId = User.GetSteamId(); +string sessionId = User.GetSessionId(); +``` + +**SteamId type rule:** +- Entity: `ulong` +- Claims: `long` (stored as long in JWT/claims) +- Always convert when crossing the boundary + +## Shared Global Services + +Registered via `GlobalServiceConfig` in `Global/Config/`: + +- `IPasswordHasher` — BCrypt wrapper +- `IRoomCodeGenerator` — generates unique room codes +- `IAuthTicketValidator` — Steam ticket validator +- `ISessionService` — Redis session management diff --git a/PushAndPull/.claude/rules/testing.md b/PushAndPull/.claude/rules/testing.md new file mode 100644 index 0000000..f25a539 --- /dev/null +++ b/PushAndPull/.claude/rules/testing.md @@ -0,0 +1,77 @@ +--- +description: xUnit + Moq test conventions. Applied when working on PushAndPull.Test/** files. +globs: ["PushAndPull.Test/**"] +alwaysApply: false +--- + +## Test Project Structure + +``` +PushAndPull.Test/ +├── Domain/ +│ └── {DomainName}/ # Entity unit tests +└── Service/ + └── {DomainName}/ # Service unit tests +``` + +## Conventions + +- Test class name: `{ServiceName}Tests` — e.g., `LoginServiceTests` +- Nested class per scenario (English): `WhenANewUserLogsIn`, `WhenTheRoomIsFull` +- Test method name: `It_{ExpectedResult}` — e.g., `It_CreatesANewUser`, `It_ThrowsNotFoundException` +- Use `Moq` for mocking — mock repository interfaces, not `AppDbContext` directly. +- Constructor for mock setup; each test method for assertion + verification. + +```csharp +public class LoginServiceTests +{ + public class WhenANewUserLogsInForTheFirstTime + { + private readonly Mock _validatorMock = new(); + private readonly Mock _sessionServiceMock = new(); + private readonly Mock _userRepositoryMock = new(); + private readonly LoginService _sut; + + private const string Ticket = "valid-ticket"; + private const ulong SteamId = 76561198000000001UL; + + public WhenANewUserLogsInForTheFirstTime() + { + _validatorMock + .Setup(v => v.ValidateAsync(Ticket)) + .ReturnsAsync(new AuthTicketValidationResult(SteamId, SteamId, false, false)); + + _userRepositoryMock + .Setup(r => r.GetBySteamIdAsync(SteamId, CancellationToken.None)) + .ReturnsAsync((User?)null); + + _sut = new LoginService(_validatorMock.Object, _sessionServiceMock.Object, _userRepositoryMock.Object); + } + + [Fact] + public async Task It_CreatesANewUser() + { + await _sut.ExecuteAsync(new LoginCommand(Ticket, "Player")); + + _userRepositoryMock.Verify(r => r.CreateAsync( + It.Is(u => u.SteamId == SteamId), + CancellationToken.None), Times.Once); + } + + [Fact] + public async Task It_DoesNotCallUpdateUser() + { + await _sut.ExecuteAsync(new LoginCommand(Ticket, "Player")); + + _userRepositoryMock.Verify(r => r.UpdateAsync( + It.IsAny(), It.IsAny(), It.IsAny(), CancellationToken.None), Times.Never); + } + } +} +``` + +## Arrange / Act / Assert + +- Separate Arrange (constructor), Act, Assert with blank lines. +- Use `xunit` built-in `Assert` — no FluentAssertions in this project. +- Verify both positive behavior (`Times.Once`) and negative behavior (`Times.Never`). diff --git a/PushAndPull/.claude/rules/verify.md b/PushAndPull/.claude/rules/verify.md new file mode 100644 index 0000000..b2aba9d --- /dev/null +++ b/PushAndPull/.claude/rules/verify.md @@ -0,0 +1,17 @@ +--- +description: Build-and-verify workflow. Run build then tests after any C# code change. +globs: ["**/*.cs"] +alwaysApply: false +--- + +## Build-and-Verify Workflow + +After any `.cs` file change, the `postToolUse` hook runs build and tests automatically. + +**If the build fails:** fix the build errors. + +**If tests fail:** +- Production code bug → fix the production code. +- Test is outdated → fix the test code. + +Only consider the task complete when the build succeeds and all tests pass. diff --git a/PushAndPull/.claude/settings.json b/PushAndPull/.claude/settings.json deleted file mode 100644 index 383b898..0000000 --- a/PushAndPull/.claude/settings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "bash .claude/hooks/preToolUse.sh" - }, - { - "type": "command", - "command": "bash .claude/hooks/preCommit.sh" - }, - { - "type": "command", - "command": "bash .claude/hooks/commit-msg.sh" - } - ] - } - ] - } -} \ No newline at end of file diff --git a/PushAndPull/.claude/settings.local.json b/PushAndPull/.claude/settings.local.json new file mode 100644 index 0000000..0d3bef2 --- /dev/null +++ b/PushAndPull/.claude/settings.local.json @@ -0,0 +1,41 @@ +{ + "permissions": { + "allow": [ + "Bash(claude mcp:*)", + "mcp__context7__resolve-library-id", + "mcp__context7__query-docs", + "Bash(dotnet --version)", + "Bash(dotnet restore:*)", + "Bash(dotnet build:*)", + "Bash(dotnet test:*)", + "Bash(dotnet ef migrations:*)", + "Bash(dotnet ef database:*)", + "Bash(dotnet format:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git status:*)", + "Bash(git diff:*)", + "Bash(git log:*)", + "Bash(git branch:*)", + "Bash(git checkout:*)", + "Bash(git tag:*)", + "Bash(gh pr create:*)", + "Bash(rm PR_BODY.md)", + "Skill(build)", + "Skill(test)", + "Skill(commit)", + "Skill(db-migrate)", + "WebFetch(domain:github.com)", + "WebFetch(domain:raw.githubusercontent.com)", + "Skill(pr)", + "Bash(dotnet tool update:*)", + "Bash(dotnet tool:*)", + "Bash(cmd.exe:*)", + "Bash(dotnet --list-sdks)", + "Bash(dotnet list:*)", + "Bash(~/.dotnet/dotnet build:*)", + "Bash(/home/seanyee1227/.dotnet/dotnet build:*)", + "Bash(/home/seanyee1227/.dotnet/dotnet test:*)" + ] + } +} diff --git a/PushAndPull/.claude/commands/build.md b/PushAndPull/.claude/skills/build/SKILL.md similarity index 68% rename from PushAndPull/.claude/commands/build.md rename to PushAndPull/.claude/skills/build/SKILL.md index 5824297..ed55a54 100644 --- a/PushAndPull/.claude/commands/build.md +++ b/PushAndPull/.claude/skills/build/SKILL.md @@ -1,6 +1,8 @@ --- -description: Build the server project and report errors -allowed-tools: Bash +name: build +description: Builds the PushAndPull project and reports errors. Use for checking build failures and debugging compile errors. +allowed-tools: Bash(dotnet build:*) +context: fork --- Build the server project and report any errors. diff --git a/PushAndPull/.claude/skills/commit/SKILL.md b/PushAndPull/.claude/skills/commit/SKILL.md new file mode 100644 index 0000000..d5f5797 --- /dev/null +++ b/PushAndPull/.claude/skills/commit/SKILL.md @@ -0,0 +1,57 @@ +--- +name: commit +description: Creates Git commits by splitting changes into logical units. Use for staging files and writing commit messages. +allowed-tools: Bash(git status:*), Bash(git diff:*), Bash(git add:*), Bash(git commit:*), Bash(git log:*) +--- + +Create Git commits following the project's commit conventions. + +## Commit Message Format + +``` +{type}: {Korean description} +``` + +**Types**: +- `feat` — new feature added +- `fix` — bug fix, missing config, or missing DI registration +- `update` — modification to existing code +- `docs` — documentation changes +- `chore` — tooling, CI/CD, dependency updates, config changes without code behavior change + +**Description rules**: +- Written in **Korean** +- Short and imperative (단문) +- No trailing punctuation (`.`, `!` etc.) +- Avoid noun-ending style — prefer verb style + +**Examples**: +``` +feat: 방 생성 API 추가 +fix: 세션 DI 누락 수정 +update: Room 엔터티 수정 +docs: API 엔드포인트 목록 업데이트 +``` + +See `.claude/skills/commit/examples/type-guide.md` for a boundary-rule table and real scenarios from this project. + +**Do NOT**: +- Add Claude as co-author +- Write descriptions in English +- Add a commit body — subject line only + +## Steps + +1. Check all changes with `git status` and `git diff` +2. Categorize changes into logical units: + - New feature addition → `feat` + - Bug / missing registration fix → `fix` + - Modification to existing code → `update` + - Documentation changes → `docs` + - Tooling / config / dependency changes → `chore` +3. Group files by each logical unit +4. For each group: + - Stage only the relevant files with `git add ` + - Write a concise commit message following the format above + - Execute `git commit -m "message"` +5. Verify results with `git log --oneline -n {number of commits made}` diff --git a/PushAndPull/.claude/skills/commit/examples/type-guide.md b/PushAndPull/.claude/skills/commit/examples/type-guide.md new file mode 100644 index 0000000..ce1c73b --- /dev/null +++ b/PushAndPull/.claude/skills/commit/examples/type-guide.md @@ -0,0 +1,78 @@ +# Commit Type Guide — Push & Pull Server + +## feat — New capability added to the codebase + +Use when creating new files is the primary change. + +**Examples from this project:** + +| Change | Commit message | +|---|---| +| Add LoginService.cs, LogoutService.cs | `feat: 로그인·로그아웃 서비스 추가` | +| Add RoomController.cs | `feat: Room 컨트롤러 추가` | +| Add JoinRoomService.cs | `feat: 방 참여 서비스 추가` | +| Add new Entity class | `feat: {EntityName} 엔터티 추가` | +| Add new test class | `feat: {ServiceName} 테스트 추가` | +| Add new migration file | `feat: {MigrationName} 마이그레이션 추가` | + +--- + +## fix — Broken behavior or missing registration/config corrected + +Use when existing code is wrong, or a required wiring (DI, config key) is absent. +Adding only a DI registration line without adding the service file itself is also `fix`. + +**Examples from this project:** + +| Change | Commit message | +|---|---| +| Add missing `services.AddScoped()` | `fix: IRoomRepository DI 누락 수정` | +| Fix wrong CacheKey prefix | `fix: 세션 캐시 키 prefix 수정` | +| Fix SteamId type mismatch (ulong vs long) | `fix: SteamId 타입 불일치 수정` | +| Fix missing `[SessionAuthorize]` on endpoint | `fix: 방 참여 인증 누락 수정` | + +--- + +## update — Existing code modified without adding a new capability + +Use when modifying files that already exist — renaming, restructuring, adjusting behavior. + +**Examples from this project:** + +| Change | Commit message | +|---|---| +| Change response type to `CommonApiResponse` | `update: 방 생성 응답 타입을 CommonApiResponse로 변경` | +| Modify entity domain method | `update: Room 참여 로직 수정` | +| Add a test method to an existing test class | `update: LoginService 테스트 추가` | +| Refactoring without behavior change | `update: RoomService 리팩토링` | +| Update appsettings connection string | `update: DB 연결 문자열 수정` | + +--- + +## Boundary rules + +| Situation | Type | +|---|---| +| New `.cs` service/repository/controller file added | `feat` | +| New method added to an existing `.cs` file | `update` | +| DI registration line added alone, no new service file | `fix` | +| New service file + its DI registration added together | `feat` (same logical unit) | +| New migration file added | `feat` | +| Existing migration file corrected | `fix` | +| New test class added | `feat` | +| Test method added to an existing test class | `update` | +| Refactoring without behavior change | `update` | + +--- + +## When to split into multiple commits + +``` +# New service + its DI registration → one logical unit, commit together +git add Domain/Room/Service/JoinRoomService.cs Domain/Room/Config/RoomServiceConfig.cs +git commit -m "feat: 방 참여 서비스 추가" + +# Separate DI fix → independent fix +git add Domain/Auth/Config/AuthServiceConfig.cs +git commit -m "fix: ISessionService DI 누락 수정" +``` diff --git a/PushAndPull/.claude/skills/db-migrate/SKILL.md b/PushAndPull/.claude/skills/db-migrate/SKILL.md new file mode 100644 index 0000000..e04cebd --- /dev/null +++ b/PushAndPull/.claude/skills/db-migrate/SKILL.md @@ -0,0 +1,79 @@ +--- +name: db-migrate +description: Manages EF Core migrations for PushAndPull. Supports add/update/list/remove subcommands. e.g. /db-migrate add AddRoomPasswordColumn +argument-hint: [add | update | list | remove] +allowed-tools: Bash(dotnet ef migrations:*), Bash(dotnet ef database:*), AskUserQuestion +context: fork +--- + +Manage EF Core migrations for PushAndPull. + +**Project**: `PushAndPull/PushAndPull.csproj` +**DbContext**: `AppDbContext` (`Global/Infrastructure/AppDbContext.cs`) + +## Current migrations state + +!`dotnet ef migrations list --project PushAndPull/PushAndPull.csproj 2>&1 || echo "(no migrations yet)"` + +--- + +## Dispatch on $ARGUMENTS + +Parse the first word of `$ARGUMENTS` as the subcommand. + +--- + +### `add ` + +1. Validate that a migration name was provided. If missing, use AskUserQuestion to ask: + > "마이그레이션 이름을 입력해주세요. (예: AddRoomDescriptionColumn)" +2. Run: + ```bash + dotnet ef migrations add {MigrationName} --project PushAndPull/PushAndPull.csproj + ``` +3. Report the generated files under `PushAndPull/Migrations/`. +4. Remind the user to review the generated `Up()` / `Down()` methods before applying. + +--- + +### `update` + +1. Show pending migrations from the list above (migrations not yet applied). +2. If there are no pending migrations, report "적용할 마이그레이션이 없습니다." and stop. +3. If there are pending migrations, run: + ```bash + dotnet ef database update --project PushAndPull/PushAndPull.csproj + ``` +4. Confirm success or surface any connection/schema errors. + +--- + +### `list` + +Run: +```bash +dotnet ef migrations list --project PushAndPull/PushAndPull.csproj +``` +Display the output, marking applied migrations with ✓ and pending ones with ○. + +--- + +### `remove` + +1. Warn the user: + > "마지막 마이그레이션을 삭제합니다. DB에 이미 적용된 경우 먼저 rollback이 필요합니다. 계속할까요?" +2. Use AskUserQuestion to confirm. +3. If confirmed, run: + ```bash + dotnet ef migrations remove --project PushAndPull/PushAndPull.csproj + ``` +4. Report which files were deleted. + +--- + +### Unknown subcommand + +If `$ARGUMENTS` is empty or doesn't match any subcommand, show: +``` +사용법: /db-migrate [add | update | list | remove] +``` diff --git a/PushAndPull/.claude/skills/db-migrate/examples/naming.md b/PushAndPull/.claude/skills/db-migrate/examples/naming.md new file mode 100644 index 0000000..b1db7c6 --- /dev/null +++ b/PushAndPull/.claude/skills/db-migrate/examples/naming.md @@ -0,0 +1,66 @@ +# Migration Naming Guide — Push & Pull Server + +## Format + +**PascalCase**, no spaces, clearly describes the schema operation being performed. + +--- + +## Naming patterns by operation + +### New table +``` +Create{TableName}Table +``` +e.g. `CreateRoomTable`, `CreateUserTable`, `CreateRoomMemberTable` + +### Initial schema (multiple tables at once) +``` +CreateTables +InitialSchema +``` + +### Add column to existing table +``` +Add{ColumnName}To{TableName} +``` +e.g. `AddPasswordToRoom`, `AddNicknameToUser`, `AddStatusToRoom` + +### Remove column +``` +Remove{ColumnName}From{TableName} +``` +e.g. `RemoveDeprecatedFieldFromRoom` + +### Fix column type, constraint, or misconfiguration +``` +Fix{ColumnName}{Issue} +Change{ColumnName}In{TableName} +``` +e.g. +- `FixRoomCodeUniqueConstraint` +- `ChangeStatusColumnTypeInRoom` + +### Add index +``` +Add{Description}IndexTo{TableName} +``` +e.g. `AddRoomCodeUniqueIndexToRoom`, `AddSteamIdIndexToUser` + +### Add foreign key +``` +Add{Relation}ForeignKeyTo{TableName} +``` +e.g. `AddRoomForeignKeyToRoomMember` + +--- + +## Anti-patterns (avoid) + +| Bad | Good | +|---|---| +| `Migration1` | `CreateRoomTable` | +| `FixRoom` | `FixRoomCodeUniqueConstraint` | +| `UpdateSchema` | `AddPasswordToRoom` | +| `Temp` | (describe what actually changed) | +| `Fix` | `FixCreatedAtColumnTypeInRoom` | diff --git a/PushAndPull/.claude/skills/db-migration-guide/SKILL.md b/PushAndPull/.claude/skills/db-migration-guide/SKILL.md deleted file mode 100644 index 54f05b4..0000000 --- a/PushAndPull/.claude/skills/db-migration-guide/SKILL.md +++ /dev/null @@ -1,172 +0,0 @@ ---- -description: EF Core migration guide (.NET/C# - PushAndPull). TRIGGER when: user modifies or creates Entity classes or Entity/Config files. ---- - -# EF Core Migration Guide - -## Entity Change Checklist - -- [ ] Analyzed impact on existing data -- [ ] Determined need for migration script -- [ ] Planned 2-phase deployment strategy for column deletion -- [ ] Prepared rollback strategy - -## Change Order - -1. **Modify Entity**: `Domain/{DomainName}/Entity/` -2. **Modify AppDbContext**: `OnModelCreating` in `Global/Infrastructure/AppDbContext.cs` -3. **Modify DTO**: Update `Request/`, `Response/` records -4. **Modify Repository**: Adjust queries if needed -5. **Modify Service**: Update business logic -6. **Generate and apply migration** - -## EF Core Mapping Rules - -All EF mappings must be configured in a dedicated `IEntityTypeConfiguration` class per entity. DataAnnotations are forbidden. - -**File location:** `Domain/{DomainName}/Entity/Config/{Entity}Config.cs` - -```csharp -public class RoomConfig : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("room", "room"); - builder.HasKey(e => e.Id); - builder.Property(e => e.Id).HasColumnName("id"); - builder.Property(e => e.Status) - .HasColumnName("status") - .HasConversion(); // Enum → string - builder.Property(e => e.CreatedAt) - .HasColumnName("created_at") - .HasColumnType("timestamptz"); // timestamp with time zone - } -} -``` - -`AppDbContext.OnModelCreating` calls only one line — all configs are auto-discovered: - -```csharp -protected override void OnModelCreating(ModelBuilder modelBuilder) -{ - base.OnModelCreating(modelBuilder); - modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); -} -``` - -### Column Naming Rules - -| C# Property | DB Column | -|-------------|-----------| -| `Id` | `id` | -| `RoomCode` | `room_code` | -| `CreatedAt` | `created_at` | -| `SteamId` | `steam_id` | - -- DB column names: `snake_case` -- Timestamp type: `timestamptz` (stores UTC, converts to local timezone on read) -- Enum storage: `.HasConversion()` - -## Migration Commands - -```bash -# Generate migration file -dotnet ef migrations add --project PushAndPull - -# Apply to database -dotnet ef database update --project PushAndPull - -# List all migrations -dotnet ef migrations list --project PushAndPull - -# Rollback to a specific migration -dotnet ef database update --project PushAndPull - -# Remove last migration (only if not yet applied) -dotnet ef migrations remove --project PushAndPull -``` - -## Migration Name Convention - -``` -{Verb}{Target}{Change} - -Examples: -AddRoomPasswordColumn -RemoveUserNicknameColumn -RenameRoomStatusToState -AddIndexOnRoomCode -CreateUserTable -``` - -## Entity Modification Patterns - -### Adding a Column - -```csharp -// 1. Add property to Entity -public class Room -{ - public string? Description { get; private set; } // add as nullable - - public void UpdateDescription(string description) - { - Description = description; - } -} - -// 2. Add mapping in OnModelCreating -entity.Property(e => e.Description) - .HasColumnName("description") - .IsRequired(false); - -// 3. Generate migration -// dotnet ef migrations add AddRoomDescriptionColumn --project PushAndPull -``` - -### Deleting a Column (2-Phase Deployment) - -``` -Phase 1 — Deprecate: - - Keep the Entity property - - Stop using the column in new code - - Run data migration if needed - -Phase 2 — Delete: - - Remove the Entity property - - Remove mapping from OnModelCreating - - dotnet ef migrations add Remove{Column}Column -``` - -### Adding / Changing an Enum - -```csharp -// 1. Define or modify the Enum -public enum RoomStatus { Open, Closed, Full } - -// 2. Keep string conversion in OnModelCreating -entity.Property(e => e.Status).HasConversion(); - -// 3. Verify that existing string values in DB match the Enum member names -``` - -## Rollback Strategy - -```bash -# Rollback DB to a previous migration -dotnet ef database update --project PushAndPull - -# Remove the migration file after rollback (local only) -dotnet ef migrations remove --project PushAndPull -``` - -For production rollbacks: -- Always verify the `Down()` method in the migration file is correctly implemented -- If there is a risk of data loss (`DropColumn`, `DropTable`), take a backup first - -## Important Notes - -- **Never map EF via DataAnnotations** — use `OnModelCreating` only -- Always use `AsNoTracking()` for read-only queries -- Do not manually edit migration files (share with team if unavoidable) -- Always use 2-phase deployment before deleting a column diff --git a/PushAndPull/.claude/commands/pr.md b/PushAndPull/.claude/skills/pr/SKILL.md similarity index 59% rename from PushAndPull/.claude/commands/pr.md rename to PushAndPull/.claude/skills/pr/SKILL.md index 35ff547..bb490c2 100644 --- a/PushAndPull/.claude/commands/pr.md +++ b/PushAndPull/.claude/skills/pr/SKILL.md @@ -1,17 +1,25 @@ --- -description: Generate PR title suggestions and body based on changes from develop -allowed-tools: Bash(git log:*), Bash(git diff:*), Bash(git branch:*), Bash(git tag:*), Bash(git checkout:*), Bash(gh pr create:*), Bash(rm:*), Write, AskUserQuestion +name: pr +description: Generates a PR title suggestion and body based on the current branch, then creates a GitHub PR. Supports develop/release/feature branches. +allowed-tools: Bash(git log:*), Bash(git diff:*), Bash(git branch:*), Bash(git tag:*), Bash(git checkout:*), Bash(gh pr create:*), Bash(rm:*), Write, Read, AskUserQuestion --- Generate a PR based on the current branch. Behavior differs depending on the branch. -## Context +## Steps -- Current branch: !`git branch --show-current` +### Step 0. Initialize & Branch Discovery +1. Identify the current branch using `git branch --show-current`. +2. **Check for Arguments**: + - **If an argument is provided (e.g., `/pr {target}`)**: Set `{Base Branch}` = `{target}` and proceed directly to **Case 3**. + - **If no argument is provided**: Follow the **Branch-Based Behavior** below: + - Current branch is `develop` → **Case 1** + - Current branch matches `release/x.x.x` → **Case 2** + - Any other branch → **Case 3** with `{Base Branch}` = `develop` --- -## Branch-Based Behavior +## Branch-Based Behavior (Default) ### Case 1: Current branch is `develop` @@ -31,16 +39,16 @@ Generate a PR based on the current branch. Behavior differs depending on the bra - **Patch** (0.0.x): Bug fixes only - Briefly explain why you chose that level -**Step 3. Ask user for version number** +**Step 3. Ask the user for a version number** Use AskUserQuestion: -> 현재 버전: {current_version} +> "현재 버전: {current_version} > 추천 버전 업: {Major/Minor/Patch} → {recommended_version} > 이유: {brief reason} > > 사용할 버전 번호를 입력해주세요. (예: 1.0.1)" -**Step 4. Create release branch** +**Step 4. Create a release branch** ```bash git checkout -b release/{version} @@ -92,40 +100,35 @@ rm PR_BODY.md ### Case 3: Any other branch -**Step 1. Analyze changes from `develop`** +**Step 1. Analyze changes from `{Base Branch}`** -- Commits: `git log develop..HEAD --oneline` -- Diff stats: `git diff develop...HEAD --stat` -- Detailed diff: `git diff develop...HEAD` +- Commits: `git log {Base Branch}..HEAD --oneline` +- Diff stats: `git diff {Base Branch}...HEAD --stat` +- Detailed diff: `git diff {Base Branch}...HEAD` -**Step 2. Suggest 3 PR titles** following the PR Title Convention below +**Step 2. Suggest three PR titles** following the PR Title Convention below **Step 3. Write PR body** following the PR Body Template below - Save to `PR_BODY.md` **Step 4. Output** in this format: ``` -## 추천 PR 제목 - -1. [title1] -2. [title2] -3. [title3] - ## PR 본문 (PR_BODY.md에 저장됨) [full body preview] ``` -**Step 5. Ask the user** using AskUserQuestion: -> "어떤 제목을 사용할까요? (1 / 2 / 3 또는 직접 입력)" +**Step 5. Ask the user** using AskUserQuestion — you MUST call this tool, do NOT print a text prompt: +- `question`: "PR 제목을 선택해주세요." +- `choices`: the 3 generated titles + "직접 입력" as the last option +- If the user selects "직접 입력", immediately call AskUserQuestion again with `question`: "PR 제목을 입력해주세요." -**Step 6. Create PR to `develop`** +**Step 6. Create PR to `{Base Branch}`** -- If the user answered 1, 2, or 3, use the corresponding suggested title -- If the user typed a custom title, use it as-is +- Use the selected title, or the custom title if the user chose "직접 입력" ```bash -gh pr create --title "{chosen title}" --body-file PR_BODY.md --base develop +gh pr create --title "{chosen title}" --body-file PR_BODY.md --base {Base Branch} ``` **Step 7. Delete PR_BODY.md** @@ -145,7 +148,8 @@ Format: `{type}: {Korean description}` - `fix` — bug fix or missing configuration/DI registration - `update` — modification to existing code - `refactor` — refactoring without behavior change -- `docs` - documentation changes +- `docs` — documentation changes +- `chore` — tooling, CI/CD, dependency updates **Rules:** - Description in Korean @@ -154,8 +158,10 @@ Format: `{type}: {Korean description}` **Examples:** - `feature: 방 생성 API 추가` -- `fix: Key Vault 연동 방식을 AddAzureKeyVault으로 변경` -- `refactor: 로그인 로직 리팩토링` +- `fix: 세션 DI 누락 수정` +- `refactor: Room 서비스 리팩토링` + +See `.claude/skills/pr/examples/feature-to-develop.md` for a complete example. --- @@ -163,30 +169,10 @@ Format: `{type}: {Korean description}` Follow this exact structure (keep the emoji headers as-is): -``` -## 📚작업 내용 - -- {change item 1} -- {change item 2} - -## ◀️참고 사항 - -{additional notes, context, before/after comparisons if relevant. Write "." if nothing to add.} - -## ✅체크리스트 - -> `[ ]`안에 x를 작성하면 체크박스를 체크할 수 있습니다. - -- [x] 현재 의도하고자 하는 기능이 정상적으로 작동하나요? -- [x] 변경한 기능이 다른 기능을 깨뜨리지 않나요? - - -> *추후 필요한 체크리스트는 업데이트 될 예정입니다.* -``` +!.claude/skills/pr/templates/pr-body.md **Rules:** - Analyze commits and diffs to fill in `작업 내용` with a concise bullet list -- Fill in `참고 사항` with any important context (architecture decisions, before/after, caveats). Write `.` if nothing relevant. -- Keep total body under 2500 characters +- Keep the total body under 2500 characters - Write in Korean - No emojis in text content (keep the section header emojis) diff --git a/PushAndPull/.claude/skills/pr/examples/feature-to-develop.md b/PushAndPull/.claude/skills/pr/examples/feature-to-develop.md new file mode 100644 index 0000000..87b2ec7 --- /dev/null +++ b/PushAndPull/.claude/skills/pr/examples/feature-to-develop.md @@ -0,0 +1,48 @@ +# Example: Feature Branch PR (feature → develop) + +## Branch context + +- Current branch: `feat/join-room-api` +- Base branch: `develop` + +## Suggested PR titles (3 options) + +1. `feature: 방 참여 API 추가` +2. `feature: Room 도메인 참여 엔드포인트 구현` +3. `feature: 방 참여 서비스·컨트롤러 추가` + +## Completed PR body example + +--- + +## 📚작업 내용 + +- JoinRoomService 구현 — 방 코드 조회 및 참여자 추가 +- RoomController에 `POST /api/v1/room/{roomCode}/join` 엔드포인트 추가 +- JoinRoomServiceTests 추가 — 정상 참여 및 예외 케이스 검증 +- RoomServiceConfig에 JoinRoomService DI 등록 + +## ◀️참고 사항 + +비공개 방 참여 시 비밀번호 검증은 `IPasswordHasher`를 통해 처리됩니다. +방이 가득 찬 경우 `ConflictException`을 반환합니다. + +## ✅체크리스트 + +> `[ ]`안에 x를 작성하면 체크박스를 체크할 수 있습니다. + +- [x] 현재 의도하고자 하는 기능이 정상적으로 작동하나요? +- [x] 변경한 기능이 다른 기능을 깨뜨리지 않나요? + + +> *추후 필요한 체크리스트는 업데이트 될 예정입니다.* + +--- + +## Writing rules + +- **작업 내용 bullets**: group by meaningful change, not by raw commit +- **참고 사항**: configuration notes, before/after comparisons, etc. Use `"."` if nothing to add +- Keep the total body under 2500 characters +- All text content in Korean (keep section header emojis as-is) +- No emojis in body text — section headers only diff --git a/PushAndPull/.claude/skills/pr/templates/pr-body.md b/PushAndPull/.claude/skills/pr/templates/pr-body.md new file mode 100644 index 0000000..4817475 --- /dev/null +++ b/PushAndPull/.claude/skills/pr/templates/pr-body.md @@ -0,0 +1,18 @@ +## 📚작업 내용 + +- {change item 1} +- {change item 2} + +## ◀️참고 사항 + +{additional notes, context, before/after comparisons if relevant. Write "." if nothing to add.} + +## ✅체크리스트 + +> `[ ]`안에 x를 작성하면 체크박스를 체크할 수 있습니다. + +- [x] 현재 의도하고자 하는 기능이 정상적으로 작동하나요? +- [x] 변경한 기능이 다른 기능을 깨뜨리지 않나요? + + +> *추후 필요한 체크리스트는 업데이트 될 예정입니다.* diff --git a/PushAndPull/.claude/skills/test/SKILL.md b/PushAndPull/.claude/skills/test/SKILL.md new file mode 100644 index 0000000..6bfb2dd --- /dev/null +++ b/PushAndPull/.claude/skills/test/SKILL.md @@ -0,0 +1,60 @@ +--- +name: test +description: Builds PushAndPull then runs all tests in PushAndPull.Test. Reports pass/fail results. e.g. /test LoginServiceTests +argument-hint: [ClassName or MethodName (optional)] +allowed-tools: Bash(dotnet build:*), Bash(dotnet test:*) +context: fork +--- + +Build the server project, then run the test suite and report results. + +## Steps + +### Step 1 — Build + +```bash +dotnet build PushAndPull/PushAndPull.csproj +``` + +- Build **fails**: list each error with its file path and line number, explain the likely cause, then stop. +- Build **succeeds**: continue to Step 2. + +### Step 2 — Run Tests + +If `$ARGUMENTS` is empty, run all tests: + +```bash +dotnet test PushAndPull.Test/PushAndPull.Test.csproj --no-build +``` + +If `$ARGUMENTS` is provided, filter by class or method name: + +```bash +dotnet test PushAndPull.Test/PushAndPull.Test.csproj --no-build --filter "FullyQualifiedName~$ARGUMENTS" +``` + +### Step 3 — Report Results + +Report in this format: + +``` +Test Results + +Passed: N +Failed: N +Skipped: N +``` + +If all tests pass, add: "All tests passed." + +If any tests fail, list each failure: + +``` +Failed Tests: + +- {FullyQualifiedTestName} + Error: {exception type and message} + Location: {file path and line number if available} +``` + +Do not truncate error messages. If the failure output contains an inner exception, include it. diff --git a/PushAndPull/.claude/skills/test/examples/filter-patterns.md b/PushAndPull/.claude/skills/test/examples/filter-patterns.md new file mode 100644 index 0000000..09f3cc8 --- /dev/null +++ b/PushAndPull/.claude/skills/test/examples/filter-patterns.md @@ -0,0 +1,70 @@ +# Test Filter Patterns — Push & Pull Server + +## How filtering works + +```bash +dotnet test --filter "FullyQualifiedName~{value}" +``` + +The `~` operator matches any fully qualified test name that **contains** the value as a substring. + +Fully qualified name format: +``` +{Namespace}.{OuterClass}+{InnerClass}.{MethodName} +``` + +Example: +``` +PushAndPull.Test.Service.Auth.LoginServiceTests+WhenANewUserLogsInForTheFirstTime.It_CreatesANewUser +``` + +--- + +## Current test classes in this project + +| `/test` argument | What runs | +|---|---| +| _(no argument)_ | All tests | +| `LoginServiceTests` | All LoginService tests | +| `LogoutServiceTests` | All LogoutService tests | +| `CreateRoomServiceTests` | All CreateRoomService tests | +| `GetAllRoomServiceTests` | All GetAllRoomService tests | +| `GetRoomServiceTests` | All GetRoomService tests | +| `JoinRoomServiceTests` | All JoinRoomService tests | +| `WhenANewUserLogsIn` | New user login scenario | +| `WhenAnExistingUserLogsIn` | Existing user login scenario | +| `It_CreatesANewUser` | Specific test method | + +--- + +## Pattern examples + +### Filter by outer class — runs all tests for a service +``` +/test LoginServiceTests +``` +→ Matches all of `PushAndPull.Test.Service.Auth.LoginServiceTests+*.*` + +### Filter by inner class — runs all tests in a scenario +``` +/test WhenANewUserLogsInForTheFirstTime +``` +→ Matches all methods inside that inner class + +### Filter by method name — runs a single test +``` +/test It_CreatesANewUser +``` +→ Matches the single method by name substring + +### Filter by service name +``` +/test JoinRoom +``` +→ Matches all of `PushAndPull.Test.Service.Room.JoinRoomServiceTests+*.*` + +### Filter by namespace — runs an entire domain +``` +/test PushAndPull.Test.Service.Auth +``` +→ Matches everything under the Auth service namespace diff --git a/PushAndPull/.gemini/commands/commit.toml b/PushAndPull/.gemini/commands/commit.toml new file mode 100644 index 0000000..7dcac02 --- /dev/null +++ b/PushAndPull/.gemini/commands/commit.toml @@ -0,0 +1,71 @@ +description = "Git 변경사항을 논리적 단위로 분리하여 커밋합니다." + +prompt = """ +Create Git commits following the project's commit conventions. + +## Current Changes + +### git status +!{git status} + +### git diff (unstaged) +!{git diff} + +### git diff (staged) +!{git diff --cached} + +--- + +## Commit Message Format + +{type}: {Korean description} + +**Types:** +| Type | When to use | +|--------|-------------| +| feat | New file(s) added (new service / controller / entity / test class / migration) | +| fix | Broken behavior fixed, or missing DI registration / config corrected | +| update | Existing file(s) modified — rename, restructure, method added to existing class | +| docs | Documentation changes only | +| chore | Tooling, CI/CD, dependency updates, config changes with no behavior change | + +**Boundary rules:** +- New .cs service/repository/controller file added → `feat` +- New method added to an existing .cs file → `update` +- DI registration line added alone (no new service file) → `fix` +- New service file + its DI registration together → `feat` (one logical unit) +- New migration file → `feat` +- Existing migration corrected → `fix` +- New test class → `feat` +- Test method added to existing test class → `update` +- Refactoring without behavior change → `update` + +**Description rules:** +- Written in **Korean** +- Short and imperative (단문) +- No trailing punctuation (`.`, `!`, etc.) +- Prefer verb style over noun style + +**Examples:** +- feat: 방 생성 API 추가 +- fix: 세션 DI 누락 수정 +- update: Room 엔터티 수정 +- chore: 의존성 버전 업데이트 + +**Do NOT:** +- Add Claude/Gemini as co-author +- Write descriptions in English +- Add a commit body — subject line only + +--- + +## Steps + +1. Analyze all changes from the git status and diff above. +2. Categorize changes into **logical units** — group files that belong to the same feature or fix. +3. For each logical unit: + a. Stage only the relevant files with `git add ` + b. Write a concise commit message following the format above + c. Execute `git commit -m "message"` +4. After all commits, verify with `git log --oneline -n {number of commits made}`. +""" diff --git a/PushAndPull/.gemini/commands/pr.toml b/PushAndPull/.gemini/commands/pr.toml new file mode 100644 index 0000000..ada4fd3 --- /dev/null +++ b/PushAndPull/.gemini/commands/pr.toml @@ -0,0 +1,157 @@ +description = "현재 브랜치 기반으로 GitHub PR을 생성합니다. 사용법: /pr 또는 /pr {base-branch}" + +prompt = """ +Generate and create a GitHub Pull Request based on the current branch. + +## Runtime Context + +### Current branch +!{git branch --show-current} + +### Recent tags +!{git tag --sort=-v:refname | head -10} + +### Existing release branches +!{git branch -a | grep release} + +### User-provided argument (base branch override) +{{args}} + +--- + +## Step 0. Determine behavior + +- If `{{args}}` is **not empty**: set Base Branch = `{{args}}`, skip to **Case 3** immediately. +- If `{{args}}` is **empty**: check the current branch name and follow the rules below. + +--- + +## Case 1: Current branch is `develop` + +**Step 1.** Determine the latest version from tags and release branches. + +**Step 2.** Analyze changes from `main`: +- `git log main..HEAD --oneline` +- `git diff main...HEAD --stat` + +**Step 3.** Recommend a version bump (Major / Minor / Patch) and explain why briefly. + +**Step 4.** Ask the user: "현재 버전: {current_version} / 추천: {recommended_version} ({reason}) — 사용할 버전 번호를 입력해주세요. (예: 1.0.1)" + +**Step 5.** After the user replies with a version number: +- Run: `git checkout -b release/{version}` +- Analyze changes from `main` for the PR body + +**Step 6.** Write the PR body following the **PR Body Template** below. Save to `PR_BODY.md`. + +**Step 7.** Run: +``` +gh pr create --title "release/{version}" --body-file PR_BODY.md --base main +``` + +**Step 8.** Run: `rm PR_BODY.md` + +--- + +## Case 2: Current branch matches `release/x.x.x` + +**Step 1.** Extract version from branch name (e.g., `release/1.2.0` → `1.2.0`). + +**Step 2.** Analyze changes from `main`: +- `git log main..HEAD --oneline` +- `git diff main...HEAD --stat` + +**Step 3.** Write PR body following the **PR Body Template** below. Save to `PR_BODY.md`. + +**Step 4.** Run: +``` +gh pr create --title "release/{version}" --body-file PR_BODY.md --base main +``` + +**Step 5.** Run: `rm PR_BODY.md` + +--- + +## Case 3: Any other branch (or base branch was specified via argument) + +**Step 1.** Set Base Branch: +- If `{{args}}` is not empty → Base Branch = `{{args}}` +- Otherwise → Base Branch = `develop` + +**Step 2.** Analyze changes from Base Branch: +- `git log {Base Branch}..HEAD --oneline` +- `git diff {Base Branch}...HEAD --stat` +- `git diff {Base Branch}...HEAD` + +**Step 3.** Suggest **three PR title options** following the **PR Title Convention** below. + +**Step 4.** Write the PR body following the **PR Body Template** below. Save to `PR_BODY.md`. + +**Step 5.** Show the PR body preview and the three title options to the user. Ask: +"PR 제목을 선택해주세요. (1 / 2 / 3 / 직접 입력)" + +**Step 6.** After the user selects or types a title: +``` +gh pr create --title "{chosen title}" --body-file PR_BODY.md --base {Base Branch} +``` + +**Step 7.** Run: `rm PR_BODY.md` + +--- + +## PR Title Convention + +Format: `{type}: {Korean description}` + +**Types:** +- `feature` — new feature added +- `fix` — bug fix or missing configuration/DI registration +- `update` — modification to existing code +- `refactor` — refactoring without behavior change +- `docs` — documentation changes +- `chore` — tooling, CI/CD, dependency updates + +**Rules:** +- Description in Korean +- Short and imperative (단문) +- No trailing punctuation + +**Examples:** +- `feature: 방 생성 API 추가` +- `fix: 세션 DI 누락 수정` +- `refactor: Room 서비스 리팩토링` + +--- + +## PR Body Template + +Use this exact structure (keep the emoji headers): + +``` +## 📚작업 내용 + +- {change item 1} +- {change item 2} + +## ◀️참고 사항 + +{additional notes, context, before/after comparisons if relevant. Write "." if nothing to add.} + +## ✅체크리스트 + +> `[ ]`안에 x를 작성하면 체크박스를 체크할 수 있습니다. + +- [x] 현재 의도하고자 하는 기능이 정상적으로 작동하나요? +- [x] 변경한 기능이 다른 기능을 깨뜨리지 않나요? + + +> *추후 필요한 체크리스트는 업데이트 될 예정입니다.* +``` + +**Writing rules:** +- Fill `작업 내용` bullets by grouping commits meaningfully — not one bullet per commit +- `참고 사항`: configuration notes, before/after comparisons, etc. Write `"."` if nothing to add +- Keep total body under 2500 characters +- All text content in Korean (keep section header emojis as-is) +- No emojis in body text — section headers only +""" diff --git a/PushAndPull/CLAUDE.md b/PushAndPull/CLAUDE.md index 3d31d97..7407001 100644 --- a/PushAndPull/CLAUDE.md +++ b/PushAndPull/CLAUDE.md @@ -1,255 +1,64 @@ -# CLAUDE.md — Push & Pull Server +# Push & Pull Server — Claude Code Guide **Always respond in Korean.** **All skill files, command files, hooks, and sub-agent configuration files must be written in English.** ---- +## Solution Overview -# Project Overview +.NET 9 single-solution backend: -Steam-based multiplayer matchmaking / lobby server. - -Players authenticate via **Steam tickets**, create or join rooms, and exchange **Steam Lobby IDs**. Actual gameplay runs over **Steam P2P** — this server only handles session auth, room management, and discovery. +| Project | Type | Role | +|---|---|---| +| `PushAndPull` | ASP.NET Core Web API | Steam auth, room management, session handling | +| `PushAndPull.Test` | xUnit Test Project | Unit tests for services and entities | | Item | Value | -|------|-------| -| Framework | net9.0 | +|---|---| | Runtime | Linux container, port 8080 | -| Database | PostgreSQL (EF Core 9) | +| Database | PostgreSQL (EF Core 9 + Npgsql) | | Cache | Redis (session store) | | Auth | Steam ticket (`Session-Id` header, no Bearer) | ---- - -# Quick Start (Read This First) - -If you're new to this project, read in this order: - -1. Architecture -2. Layer Responsibilities -3. Common Mistakes -4. Coding Conventions - -Key rules: - -- Controller → Service Interface only -- Service → Repository Interface only -- No direct Entity field mutation — use domain methods -- Always `AsNoTracking()` for read queries -- Always `CacheKey` for Redis keys — no hardcoded strings -- EF mapping in `IEntityTypeConfiguration` only — no DataAnnotations -- Register new services in `Domain/{Domain}/Config/` after adding them - ---- - -# Commands - -```bash -# Build -dotnet build PushAndPull/PushAndPull.csproj - -# Run locally -dotnet run --project PushAndPull/PushAndPull.csproj - -# Add EF migration -dotnet ef migrations add --project PushAndPull - -# Apply migration -dotnet ef database update --project PushAndPull - -# Docker -docker build -f PushAndPull/Dockerfile -t pushandpull-server . -docker run -p 8080:8080 pushandpull-server -``` - ---- - -# Architecture +## Tech Stack -Domain-centric layered architecture. Each domain (`Auth`, `Room`) is self-contained. Cross-cutting concerns live under `Global/`. +| Package | Version | Context7 ID | +|---|---|---| +| .NET / ASP.NET Core | 9.0 | `/dotnet/docs` | +| `Microsoft.EntityFrameworkCore` | 9.0.12 | `/dotnet/docs` | +| `Npgsql.EntityFrameworkCore.PostgreSQL` | 9.0.4 | `/npgsql/efcore.pg` | +| `Microsoft.Extensions.Caching.StackExchangeRedis` | 9.0.3 | `/stackexchange/stackexchange.redis` | +| `Dapper` | 2.1.66 | `/dotnet/docs` | +| `BCrypt.Net-Next` | 4.0.3 | — (no Context7 entry) | +| `Gamism.SDK.Extensions.AspNetCore` | 0.2.8 | — (no Context7 entry) | +| `xunit` | 2.9.2 | `/xunit/xunit.net` | +| `Moq` | 4.20.72 | — (no Context7 entry) | -## Dependency Rules +## Context7 Usage -``` -Controller → Service Interface -Service → Repository Interface, Global Service Interface -Repository → AppDbContext -``` +When working with any library listed above, use the Context7 MCP to fetch version-accurate official documentation before writing or modifying code. -Forbidden: - -``` -Controller → Repository ❌ -Controller → concrete class ❌ -Service → concrete Repository ❌ -Entity → Service / Repository ❌ ``` +# Example: EF Core Fluent API +mcp__context7__query-docs(libraryId: "/dotnet/docs", query: "EF Core IEntityTypeConfiguration fluent API", version: "9.0") -## Key Paths - -| Layer | Path | -|-------|------| -| Controller | `Domain/{Domain}/Controller/` | -| Service Interface + Command/Result | `Domain/{Domain}/Service/Interface/` | -| Service | `Domain/{Domain}/Service/` | -| Repository Interface | `Domain/{Domain}/Repository/Interface/` | -| Repository | `Domain/{Domain}/Repository/` | -| Entity | `Domain/{Domain}/Entity/` | -| Entity EF Config | `Domain/{Domain}/Entity/Config/` | -| DTO (Request/Response) | `Domain/{Domain}/Dto/Request/`, `Dto/Response/` | -| Exception | `Domain/{Domain}/Exception/` | -| DI Registration | `Domain/{Domain}/Config/` | - -## Global/ - -| Folder | Role | -|--------|------| -| `Auth/` | Steam ticket validation (`IAuthTicketValidator`) | -| `Cache/` | Redis cache (`ICacheStore`, `CacheKey`) | -| `Config/` | Global DI, DB/Redis config | -| `Infrastructure/` | `AppDbContext` | -| `Security/` | `[SessionAuthorize]`, `ClaimsPrincipalExtensions` | -| `Service/` | Shared utilities (`IPasswordHasher`, `IRoomCodeGenerator`) | - ---- - -# Layer Responsibilities - -### Controller -- Maps `Request` → `Command`, calls service, maps `Result` → `Response` -- Applies `[SessionAuthorize]` on authenticated endpoints -- Depends **only on Service Interfaces** - -### Service -- Implements business logic via `ExecuteAsync(...)` -- `Command` (input) and `Result` (output) records defined in the same Interface file -- Depends only on Repository/Global Interfaces - -### Repository -- Abstracts data access (EF Core or Dapper) -- **Read queries must use `AsNoTracking()`** - -### Entity -- Pure domain model, no external dependencies -- State changes only through methods (`room.Join()`, `user.UpdateNickname()`) -- Default constructor: `protected`/`private`; public constructor accepts required fields only - -### DTO -- `record` type only -- `Request/` for HTTP input, `Response/` for HTTP output - ---- - -# Coding Conventions +# Example: Npgsql EF Core setup +mcp__context7__query-docs(libraryId: "/npgsql/efcore.pg", query: "UseNpgsql configuration") -## Naming - -| Type | Convention | Example | -|------|-----------|---------| -| Service Interface | `I{Action}Service` | `ILoginService` | -| Repository Interface | `I{Entity}Repository` | `IUserRepository` | -| Command | `{Action}Command` | `LoginCommand` | -| Result | `{Action}Result` | `LoginResult` | -| Request DTO | `{Action}Request` | `LoginRequest` | -| Response DTO | `{Action}Response` | `LoginResponse` | -| Service impl | `{Action}Service` | `LoginService` | - -## General Rules - -- DTOs: `record` type -- Entities / Services: `class` -- Constructor injection only -- Nullable reference types: enabled -- Implicit usings: enabled - ---- - -# Database Rules - -- Column naming: `snake_case` -- Timestamps: `timestamptz` -- Enums: `.HasConversion()` -- Read queries: always `AsNoTracking()` - -**EF mapping must use `IEntityTypeConfiguration` per entity** (`Domain/{Domain}/Entity/Config/`). DataAnnotations are forbidden. - -`AppDbContext.OnModelCreating` contains only: - -```csharp -base.OnModelCreating(modelBuilder); -modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); -``` - ---- - -# Cache Rules - -Redis is used as a **session store**. All keys must go through `CacheKey` — hardcoded strings are forbidden. - -```csharp -// Correct -CacheKey.Session.ById(sessionId) - -// Forbidden -"session:" + sessionId -``` - ---- - -# Authentication - -- Steam: `ISteamUserAuth/AuthenticateUserTicket/v1` -- Session via header: `Session-Id: ` (no Bearer tokens) -- SteamId: `ulong` in Entity, `long` in Claims - ---- - -# API Endpoints - -| Method | Path | Auth | Description | -|--------|------|------|-------------| -| POST | `/api/v1/auth/login` | No | Steam ticket login | -| POST | `/api/v1/auth/logout` | Session | Logout | -| POST | `/api/v1/room` | Session | Create room | -| GET | `/api/v1/room/all` | No | List active rooms | -| GET | `/api/v1/room/{roomCode}` | No | Get room info | -| POST | `/api/v1/room/{roomCode}/join` | Session | Join room | - ---- - -# Commit Message Style - -``` -{type}: {Korean description} -``` - -| Type | When | -|------|------| -| `feat` | New feature | -| `fix` | Bug fix, missing config/DI | -| `update` | Modification to existing code | -| `docs` | Documentation changes | -| `merge` | Branch merge | -| `release` | Release (`release/x.x.x`) | - -Rules: Korean description, imperative, no trailing punctuation. - -``` -feat: 방 생성 API 추가 -fix: 세션 DI 누락 수정 -modify: Room 엔터티 수정 +# Example: Redis session +mcp__context7__query-docs(libraryId: "/stackexchange/stackexchange.redis", query: "IDistributedCache SetString GetString") ``` ---- +`BCrypt.Net-Next`, `Moq`, and `Gamism.SDK.Extensions.AspNetCore` have no Context7 entry — refer to the source code directly. -# Common Mistakes +## Reference Docs -- Missing `[SessionAuthorize]` on authenticated endpoints -- SteamId type mismatch (`ulong` vs `long`) -- Modifying Entity fields directly instead of through domain methods -- Hardcoding Redis keys instead of using `CacheKey` -- Controller calling Repository directly -- Service referencing concrete class instead of Interface -- EF mapping via DataAnnotation instead of `IEntityTypeConfiguration` -- Missing DI registration after adding a new service +- `.claude/rules/architecture.md` — directory structure and layering rules (Controllers → Services → Repositories) +- `.claude/rules/code-style.md` — C# naming conventions, entity/DTO/async/Command-Result patterns +- `.claude/rules/conventions.md` — DB naming, EF Core Fluent API config, CacheKey usage, DI registration +- `.claude/rules/db-migration.md` — migration workflow, naming convention, entity modification patterns, rollback strategy +- `.claude/rules/domain-patterns.md` — service, repository, and controller implementation patterns +- `.claude/rules/global-patterns.md` — Steam auth, Redis session store, CacheKey, SessionAuthorize +- `.claude/rules/testing.md` — test project structure, naming conventions, Moq patterns +- `.claude/rules/flows.md` — Mermaid sequence diagrams for each API endpoint +- `.claude/rules/verify.md` — build-and-verify workflow (auto build + test after every C# code change) diff --git a/PushAndPull/PushAndPull.Test/Service/Auth/LogoutServiceTests.cs b/PushAndPull/PushAndPull.Test/Service/Auth/LogoutServiceTests.cs index 0c82c6b..1bf477a 100644 --- a/PushAndPull/PushAndPull.Test/Service/Auth/LogoutServiceTests.cs +++ b/PushAndPull/PushAndPull.Test/Service/Auth/LogoutServiceTests.cs @@ -16,7 +16,7 @@ public class WhenAUserLogsOutWithAValidSession public WhenAUserLogsOutWithAValidSession() { _sessionServiceMock - .Setup(s => s.DeleteAsync(SessionId)) + .Setup(s => s.DeleteAsync(SessionId, It.IsAny())) .Returns(Task.CompletedTask); _sut = new LogoutService(_sessionServiceMock.Object); @@ -27,7 +27,7 @@ public async Task It_DeletesTheSession() { await _sut.ExecuteAsync(new LogoutCommand(SessionId)); - _sessionServiceMock.Verify(s => s.DeleteAsync(SessionId), Times.Once); + _sessionServiceMock.Verify(s => s.DeleteAsync(SessionId, It.IsAny()), Times.Once); } } } diff --git a/PushAndPull/PushAndPull.Test/Service/Room/CreateRoomServiceTests.cs b/PushAndPull/PushAndPull.Test/Service/Room/CreateRoomServiceTests.cs index a1e0d91..0832747 100644 --- a/PushAndPull/PushAndPull.Test/Service/Room/CreateRoomServiceTests.cs +++ b/PushAndPull/PushAndPull.Test/Service/Room/CreateRoomServiceTests.cs @@ -55,7 +55,7 @@ public async Task It_SavesTheRoomToRepository() room.RoomName == _command.RoomName && room.IsPrivate == false && room.PasswordHash == null - )), Times.Once); + ), It.IsAny()), Times.Once); } [Fact] @@ -106,7 +106,7 @@ public async Task It_SavesTheHashedPassword() _roomRepositoryMock.Verify(r => r.CreateAsync( It.Is(room => room.PasswordHash == HashedPassword - )), Times.Once); + ), It.IsAny()), Times.Once); } [Fact] diff --git a/PushAndPull/PushAndPull.Test/Service/Room/GetRoomServiceTests.cs b/PushAndPull/PushAndPull.Test/Service/Room/GetRoomServiceTests.cs index a7354aa..b199345 100644 --- a/PushAndPull/PushAndPull.Test/Service/Room/GetRoomServiceTests.cs +++ b/PushAndPull/PushAndPull.Test/Service/Room/GetRoomServiceTests.cs @@ -37,7 +37,7 @@ public class WhenTheRoomDoesNotExist public WhenTheRoomDoesNotExist() { _roomRepositoryMock - .Setup(r => r.GetAsync(RoomCode)) + .Setup(r => r.GetAsync(RoomCode, It.IsAny())) .ReturnsAsync((EntityRoom?)null); _sut = new GetRoomService(_roomRepositoryMock.Object); @@ -65,7 +65,7 @@ public WhenTheRoomExists() _room = new EntityRoom(RoomCode, RoomName, 444UL, 76561198000000001UL, false, null); _roomRepositoryMock - .Setup(r => r.GetAsync(RoomCode)) + .Setup(r => r.GetAsync(RoomCode, It.IsAny())) .ReturnsAsync(_room); _sut = new GetRoomService(_roomRepositoryMock.Object); diff --git a/PushAndPull/PushAndPull.Test/Service/Room/JoinRoomServiceTests.cs b/PushAndPull/PushAndPull.Test/Service/Room/JoinRoomServiceTests.cs index a44a53a..1b03f11 100644 --- a/PushAndPull/PushAndPull.Test/Service/Room/JoinRoomServiceTests.cs +++ b/PushAndPull/PushAndPull.Test/Service/Room/JoinRoomServiceTests.cs @@ -21,7 +21,7 @@ public class WhenTheRoomDoesNotExist public WhenTheRoomDoesNotExist() { _roomRepositoryMock - .Setup(r => r.GetAsync(RoomCode)) + .Setup(r => r.GetAsync(RoomCode, It.IsAny())) .ReturnsAsync((EntityRoom?)null); _sut = new JoinRoomService(_roomRepositoryMock.Object, _passwordHasherMock.Object); @@ -49,7 +49,7 @@ public WhenTheRoomIsNotActive() closedRoom.Close(); _roomRepositoryMock - .Setup(r => r.GetAsync(RoomCode)) + .Setup(r => r.GetAsync(RoomCode, It.IsAny())) .ReturnsAsync(closedRoom); _sut = new JoinRoomService(_roomRepositoryMock.Object, _passwordHasherMock.Object); @@ -63,6 +63,35 @@ await Assert.ThrowsAsync( } } + public class WhenAPrivateRoomIsJoinedWithoutAPassword + { + private readonly Mock _roomRepositoryMock = new(); + private readonly Mock _passwordHasherMock = new(); + private readonly JoinRoomService _sut; + + private const string RoomCode = "PRIV02"; + + public WhenAPrivateRoomIsJoinedWithoutAPassword() + { + var privateRoom = new EntityRoom(RoomCode, "Private Room", 222UL, 76561198000000001UL, true, "some-hash"); + + _roomRepositoryMock + .Setup(r => r.GetAsync(RoomCode, It.IsAny())) + .ReturnsAsync(privateRoom); + + _sut = new JoinRoomService(_roomRepositoryMock.Object, _passwordHasherMock.Object); + } + + [Fact] + public async Task It_ThrowsInvalidOperationExceptionWithPasswordRequiredMessage() + { + var ex = await Assert.ThrowsAsync( + () => _sut.ExecuteAsync(new JoinRoomCommand(RoomCode, null))); + + Assert.Equal("PASSWORD_REQUIRED", ex.Message); + } + } + public class WhenTheWrongPasswordIsProvidedForAPrivateRoom { private readonly Mock _roomRepositoryMock = new(); @@ -78,7 +107,7 @@ public WhenTheWrongPasswordIsProvidedForAPrivateRoom() var privateRoom = new EntityRoom(RoomCode, "Private Room", 222UL, 76561198000000001UL, true, StoredHash); _roomRepositoryMock - .Setup(r => r.GetAsync(RoomCode)) + .Setup(r => r.GetAsync(RoomCode, It.IsAny())) .ReturnsAsync(privateRoom); _passwordHasherMock @@ -111,15 +140,11 @@ public WhenIncrementFailsBecauseRoomDisappearedConcurrently() var activeRoom = new EntityRoom(RoomCode, "Disappearing Room", 444UL, 76561198000000001UL, false, null); _roomRepositoryMock - .Setup(r => r.GetAsync(RoomCode)) - .ReturnsAsync(activeRoom); - - _roomRepositoryMock - .Setup(r => r.IncrementPlayerCountAsync(RoomCode)) + .Setup(r => r.IncrementPlayerCountAsync(RoomCode, It.IsAny())) .ReturnsAsync(false); _roomRepositoryMock - .SetupSequence(r => r.GetAsync(RoomCode)) + .SetupSequence(r => r.GetAsync(RoomCode, It.IsAny())) .ReturnsAsync(activeRoom) .ReturnsAsync((EntityRoom?)null); @@ -149,12 +174,12 @@ public WhenIncrementFailsBecauseRoomBecameInactiveConcurrently() closedRoom.Close(); _roomRepositoryMock - .SetupSequence(r => r.GetAsync(RoomCode)) + .SetupSequence(r => r.GetAsync(RoomCode, It.IsAny())) .ReturnsAsync(activeRoom) .ReturnsAsync(closedRoom); _roomRepositoryMock - .Setup(r => r.IncrementPlayerCountAsync(RoomCode)) + .Setup(r => r.IncrementPlayerCountAsync(RoomCode, It.IsAny())) .ReturnsAsync(false); _sut = new JoinRoomService(_roomRepositoryMock.Object, _passwordHasherMock.Object); @@ -181,11 +206,11 @@ public WhenIncrementFailsBecauseRoomIsFullConcurrently() var activeRoom = new EntityRoom(RoomCode, "Full Room", 666UL, 76561198000000001UL, false, null); _roomRepositoryMock - .Setup(r => r.GetAsync(RoomCode)) + .Setup(r => r.GetAsync(RoomCode, It.IsAny())) .ReturnsAsync(activeRoom); _roomRepositoryMock - .Setup(r => r.IncrementPlayerCountAsync(RoomCode)) + .Setup(r => r.IncrementPlayerCountAsync(RoomCode, It.IsAny())) .ReturnsAsync(false); _sut = new JoinRoomService(_roomRepositoryMock.Object, _passwordHasherMock.Object); @@ -215,11 +240,11 @@ public WhenAllConditionsAreValidForJoiningARoom() _activeRoom = new EntityRoom(RoomCode, "Open Room", 333UL, 76561198000000001UL, false, null); _roomRepositoryMock - .Setup(r => r.GetAsync(RoomCode)) + .Setup(r => r.GetAsync(RoomCode, It.IsAny())) .ReturnsAsync(_activeRoom); _roomRepositoryMock - .Setup(r => r.IncrementPlayerCountAsync(RoomCode)) + .Setup(r => r.IncrementPlayerCountAsync(RoomCode, It.IsAny())) .ReturnsAsync(true); _sut = new JoinRoomService(_roomRepositoryMock.Object, _passwordHasherMock.Object); @@ -230,17 +255,13 @@ public async Task It_CallsIncrementPlayerCount() { await _sut.ExecuteAsync(new JoinRoomCommand(RoomCode, null)); - _roomRepositoryMock.Verify(r => r.IncrementPlayerCountAsync(RoomCode), Times.Once); + _roomRepositoryMock.Verify(r => r.IncrementPlayerCountAsync(RoomCode, It.IsAny()), Times.Once); } [Fact] - public async Task It_IncreasesCurrentPlayerCount() + public async Task It_DoesNotThrowAnyException() { - var before = _activeRoom.CurrentPlayers; - await _sut.ExecuteAsync(new JoinRoomCommand(RoomCode, null)); - - Assert.Equal(before + 1, _activeRoom.CurrentPlayers); } } } diff --git a/PushAndPull/PushAndPull/Dockerfile b/PushAndPull/PushAndPull/Dockerfile deleted file mode 100644 index 7b97a9c..0000000 --- a/PushAndPull/PushAndPull/Dockerfile +++ /dev/null @@ -1,19 +0,0 @@ -FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base -WORKDIR /app -EXPOSE 8080 - -FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build -ARG BUILD_CONFIGURATION=Release -WORKDIR /src - -COPY ["PushAndPull.csproj", "./"] -RUN dotnet restore "PushAndPull.csproj" - -COPY . . -RUN dotnet publish "PushAndPull.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false - -FROM base AS final -WORKDIR /app -COPY --from=build /app/publish . -ENV ASPNETCORE_URLS=http://+:8080 -ENTRYPOINT ["dotnet", "PushAndPull.dll"] diff --git a/PushAndPull/PushAndPull/Domain/Auth/Controller/AuthController.cs b/PushAndPull/PushAndPull/Domain/Auth/Controller/AuthController.cs index cd1801a..5e219ba 100644 --- a/PushAndPull/PushAndPull/Domain/Auth/Controller/AuthController.cs +++ b/PushAndPull/PushAndPull/Domain/Auth/Controller/AuthController.cs @@ -1,5 +1,6 @@ using Gamism.SDK.Core.Network; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using PushAndPull.Domain.Auth.Dto.Request; using PushAndPull.Domain.Auth.Dto.Response; using PushAndPull.Domain.Auth.Service.Interface; @@ -24,27 +25,28 @@ ILogoutService logoutService } [HttpPost("login")] - public async Task Login( - [FromBody] LoginRequest request + [EnableRateLimiting("login")] + public async Task> Login( + [FromBody] LoginRequest request, + CancellationToken ct ) { var result = await _loginService.ExecuteAsync(new LoginCommand( request.SteamTicket, request.Nickname - ) - ); + ), ct); - return new LoginResponse(result.SessionId); + return CommonApiResponse.Success("로그인되었습니다.", new LoginResponse(result.SessionId)); } [SessionAuthorize] [HttpPost("logout")] - public async Task Logout() + public async Task Logout(CancellationToken ct) { var sessionId = User.GetSessionId(); - await _logoutService.ExecuteAsync( - new LogoutCommand(sessionId) - ); + await _logoutService.ExecuteAsync(new LogoutCommand(sessionId), ct); + + return CommonApiResponse.Success("로그아웃되었습니다."); } } diff --git a/PushAndPull/PushAndPull/Domain/Auth/Entity/Config/UserConfig.cs b/PushAndPull/PushAndPull/Domain/Auth/Entity/Config/UserConfig.cs index c6f7adf..24b9250 100644 --- a/PushAndPull/PushAndPull/Domain/Auth/Entity/Config/UserConfig.cs +++ b/PushAndPull/PushAndPull/Domain/Auth/Entity/Config/UserConfig.cs @@ -7,7 +7,7 @@ public class UserConfig : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { - builder.ToTable("user", "game_user"); + builder.ToTable("user", "auth"); builder.HasKey(x => x.SteamId); diff --git a/PushAndPull/PushAndPull/Domain/Auth/Repository/UserRepository.cs b/PushAndPull/PushAndPull/Domain/Auth/Repository/UserRepository.cs index 4f2800a..053086d 100644 --- a/PushAndPull/PushAndPull/Domain/Auth/Repository/UserRepository.cs +++ b/PushAndPull/PushAndPull/Domain/Auth/Repository/UserRepository.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Npgsql; using PushAndPull.Domain.Auth.Entity; using PushAndPull.Domain.Auth.Repository.Interface; using PushAndPull.Global.Infrastructure; @@ -23,17 +24,16 @@ public UserRepository(AppDbContext context) public async Task CreateAsync(User user, CancellationToken ct = default) { - await _context.Database.ExecuteSqlRawAsync( - """ - INSERT INTO game_user."user" (steam_id, nickname, created_at, last_login_at) - VALUES ({0}, {1}, {2}, {3}) - ON CONFLICT (steam_id) DO UPDATE - SET nickname = EXCLUDED.nickname, - last_login_at = EXCLUDED.last_login_at - """, - [user.SteamId, user.Nickname, user.CreatedAt, user.LastLoginAt], - ct - ); + try + { + await _context.Users.AddAsync(user, ct); + await _context.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" }) + { + _context.Entry(user).State = EntityState.Detached; + await UpdateAsync(user.SteamId, user.Nickname, DateTime.UtcNow, ct); + } } public async Task UpdateAsync(ulong steamId, string nickname, DateTime lastLoginAt, CancellationToken ct = default) diff --git a/PushAndPull/PushAndPull/Domain/Auth/Service/Interface/ILoginService.cs b/PushAndPull/PushAndPull/Domain/Auth/Service/Interface/ILoginService.cs index 83e818f..f01814c 100644 --- a/PushAndPull/PushAndPull/Domain/Auth/Service/Interface/ILoginService.cs +++ b/PushAndPull/PushAndPull/Domain/Auth/Service/Interface/ILoginService.cs @@ -2,7 +2,7 @@ namespace PushAndPull.Domain.Auth.Service.Interface; public interface ILoginService { - Task ExecuteAsync(LoginCommand request); + Task ExecuteAsync(LoginCommand request, CancellationToken ct = default); } public record LoginCommand(string Ticket, string Nickname); diff --git a/PushAndPull/PushAndPull/Domain/Auth/Service/Interface/ILogoutService.cs b/PushAndPull/PushAndPull/Domain/Auth/Service/Interface/ILogoutService.cs index 58943e1..9f95de2 100644 --- a/PushAndPull/PushAndPull/Domain/Auth/Service/Interface/ILogoutService.cs +++ b/PushAndPull/PushAndPull/Domain/Auth/Service/Interface/ILogoutService.cs @@ -2,7 +2,7 @@ namespace PushAndPull.Domain.Auth.Service.Interface; public interface ILogoutService { - Task ExecuteAsync(LogoutCommand request); + Task ExecuteAsync(LogoutCommand request, CancellationToken ct = default); } public record LogoutCommand(string SessionId); diff --git a/PushAndPull/PushAndPull/Domain/Auth/Service/Interface/ISessionService.cs b/PushAndPull/PushAndPull/Domain/Auth/Service/Interface/ISessionService.cs index c57264d..14b57b2 100644 --- a/PushAndPull/PushAndPull/Domain/Auth/Service/Interface/ISessionService.cs +++ b/PushAndPull/PushAndPull/Domain/Auth/Service/Interface/ISessionService.cs @@ -6,5 +6,5 @@ public interface ISessionService { Task CreateAsync(ulong steamId, TimeSpan ttl); Task GetAsync(string sessionId); - Task DeleteAsync(string sessionId); + Task DeleteAsync(string sessionId, CancellationToken ct = default); } diff --git a/PushAndPull/PushAndPull/Domain/Auth/Service/LoginService.cs b/PushAndPull/PushAndPull/Domain/Auth/Service/LoginService.cs index 6b32b0c..34c58b7 100644 --- a/PushAndPull/PushAndPull/Domain/Auth/Service/LoginService.cs +++ b/PushAndPull/PushAndPull/Domain/Auth/Service/LoginService.cs @@ -23,15 +23,24 @@ IUserRepository userRepository _userRepository = userRepository; } - public async Task ExecuteAsync(LoginCommand request) + public async Task ExecuteAsync(LoginCommand request, CancellationToken ct = default) { var authResult = await _validator.ValidateAsync(request.Ticket); if (authResult.IsFamilySharing) throw new FamilySharingNotAllowedException(authResult.SteamId); - var user = new User(authResult.SteamId, request.Nickname); - await _userRepository.CreateAsync(user); + var existingUser = await _userRepository.GetBySteamIdAsync(authResult.SteamId, ct); + + if (existingUser is null) + { + var user = new User(authResult.SteamId, request.Nickname); + await _userRepository.CreateAsync(user, ct); + } + else + { + await _userRepository.UpdateAsync(authResult.SteamId, request.Nickname, DateTime.UtcNow, ct); + } var session = await _sessionService.CreateAsync( authResult.SteamId, TimeSpan.FromDays(15) diff --git a/PushAndPull/PushAndPull/Domain/Auth/Service/LogoutService.cs b/PushAndPull/PushAndPull/Domain/Auth/Service/LogoutService.cs index 48178d5..dd44f28 100644 --- a/PushAndPull/PushAndPull/Domain/Auth/Service/LogoutService.cs +++ b/PushAndPull/PushAndPull/Domain/Auth/Service/LogoutService.cs @@ -11,8 +11,8 @@ public LogoutService(ISessionService sessionService) _sessionService = sessionService; } - public async Task ExecuteAsync(LogoutCommand request) + public async Task ExecuteAsync(LogoutCommand request, CancellationToken ct = default) { - await _sessionService.DeleteAsync(request.SessionId); + await _sessionService.DeleteAsync(request.SessionId, ct); } } diff --git a/PushAndPull/PushAndPull/Domain/Auth/Service/SessionService.cs b/PushAndPull/PushAndPull/Domain/Auth/Service/SessionService.cs index 3476d40..c63de84 100644 --- a/PushAndPull/PushAndPull/Domain/Auth/Service/SessionService.cs +++ b/PushAndPull/PushAndPull/Domain/Auth/Service/SessionService.cs @@ -31,7 +31,7 @@ await _cacheStore.SetAsync( return await _cacheStore.GetAsync(CacheKey.Session.ById(sessionId)); } - public async Task DeleteAsync(string sessionId) + public async Task DeleteAsync(string sessionId, CancellationToken ct = default) { await _cacheStore.DeleteAsync(CacheKey.Session.ById(sessionId)); } diff --git a/PushAndPull/PushAndPull/Domain/Room/Controller/RoomController.cs b/PushAndPull/PushAndPull/Domain/Room/Controller/RoomController.cs index d308742..6ebb9e3 100644 --- a/PushAndPull/PushAndPull/Domain/Room/Controller/RoomController.cs +++ b/PushAndPull/PushAndPull/Domain/Room/Controller/RoomController.cs @@ -32,7 +32,8 @@ IJoinRoomService joinRoomService [SessionAuthorize] [HttpPost] public async Task> CreateRoom( - [FromBody] CreateRoomRequest request + [FromBody] CreateRoomRequest request, + CancellationToken ct ) { var hostSteamId = User.GetSteamId(); @@ -43,54 +44,49 @@ [FromBody] CreateRoomRequest request request.IsPrivate, request.Password, hostSteamId - ) - ); + ), ct); return CommonApiResponse.Created("방이 생성되었습니다.", new CreateRoomResponse(result.RoomCode)); } [HttpGet("{roomCode}")] - public async Task GetRoom( - [FromRoute] string roomCode + public async Task> GetRoom( + [FromRoute] string roomCode, + CancellationToken ct ) { - var result = await _getRoomService.ExecuteAsync( - new GetRoomCommand(roomCode) - ); + var result = await _getRoomService.ExecuteAsync(new GetRoomCommand(roomCode), ct); - return new GetRoomResponse( + return CommonApiResponse.Success("방 조회 성공.", new GetRoomResponse( result.RoomCode, result.RoomName, result.CurrentPlayers, result.IsPrivate - ); + )); } [HttpGet("all")] - public async Task GetAllRoom(CancellationToken ct) + public async Task> GetAllRoom(CancellationToken ct) { var result = await _getAllRoomService.ExecuteAsync(ct); - return new GetAllRoomResponse( - result.Rooms.Select(r => new GetRoomResponse( - r.RoomCode, - r.RoomName, - r.CurrentPlayers, - r.IsPrivate - )).ToList() - ); + var rooms = result.Rooms + .Select(s => new GetRoomResponse(s.RoomCode, s.RoomName, s.CurrentPlayers, s.IsPrivate)) + .ToList(); + + return CommonApiResponse.Success("방 목록 조회 성공.", new GetAllRoomResponse(rooms)); } [SessionAuthorize] [HttpPost("{roomCode}/join")] - public async Task JoinRoom( + public async Task JoinRoom( [FromRoute] string roomCode, - [FromBody] JoinRoomRequest request + [FromBody] JoinRoomRequest request, + CancellationToken ct ) { - await _joinRoomService.ExecuteAsync(new JoinRoomCommand( - roomCode, - request.Password) - ); + await _joinRoomService.ExecuteAsync(new JoinRoomCommand(roomCode, request.Password), ct); + + return CommonApiResponse.Success("방에 참여했습니다."); } } diff --git a/PushAndPull/PushAndPull/Domain/Room/Entity/Room.cs b/PushAndPull/PushAndPull/Domain/Room/Entity/Room.cs index df2ba23..c73946a 100644 --- a/PushAndPull/PushAndPull/Domain/Room/Entity/Room.cs +++ b/PushAndPull/PushAndPull/Domain/Room/Entity/Room.cs @@ -2,25 +2,27 @@ namespace PushAndPull.Domain.Room.Entity; public class Room { - public long Id { get; set; } - public string RoomName { get; set; } + public long Id { get; private set; } + public string RoomName { get; private set; } - public string RoomCode { get; set; } = null!; - public ulong SteamLobbyId { get; set; } + public string RoomCode { get; private set; } = null!; + public ulong SteamLobbyId { get; private set; } public Auth.Entity.User Host { get; private set; } - public ulong HostSteamId { get; set; } + public ulong HostSteamId { get; private set; } - public int CurrentPlayers { get; set; } + public int CurrentPlayers { get; private set; } public int MaxPlayers { get; private set; } - public bool IsPrivate { get; set; } - public string? PasswordHash { get; set; } + public bool IsPrivate { get; private set; } + public string? PasswordHash { get; private set; } - public RoomStatus Status { get; set; } + public RoomStatus Status { get; private set; } - public DateTimeOffset CreatedAt { get; set; } - public DateTimeOffset? ExpiresAt { get; set; } + public DateTimeOffset CreatedAt { get; private set; } + public DateTimeOffset? ExpiresAt { get; private set; } + + private const int DefaultMaxPlayers = 2; protected Room() { } @@ -38,7 +40,7 @@ public Room( SteamLobbyId = steamLobbyId; HostSteamId = hostSteamId; CurrentPlayers = 1; - MaxPlayers = 2; + MaxPlayers = DefaultMaxPlayers; IsPrivate = isPrivate; PasswordHash = passwordHash; Status = RoomStatus.Active; diff --git a/PushAndPull/PushAndPull/Domain/Room/Repository/Interface/IRoomRepository.cs b/PushAndPull/PushAndPull/Domain/Room/Repository/Interface/IRoomRepository.cs index 99c6c8d..3ecfa21 100644 --- a/PushAndPull/PushAndPull/Domain/Room/Repository/Interface/IRoomRepository.cs +++ b/PushAndPull/PushAndPull/Domain/Room/Repository/Interface/IRoomRepository.cs @@ -4,9 +4,9 @@ namespace PushAndPull.Domain.Room.Repository.Interface; public interface IRoomRepository { - Task GetAsync(string roomCode); - Task> GetAllAsync(CancellationToken ct); - Task CreateAsync(RoomEntity room); - Task IncrementPlayerCountAsync(string roomCode); - Task CloseAsync(string roomCode); + Task GetAsync(string roomCode, CancellationToken ct = default); + Task> GetAllAsync(CancellationToken ct = default); + Task CreateAsync(RoomEntity room, CancellationToken ct = default); + Task IncrementPlayerCountAsync(string roomCode, CancellationToken ct = default); + Task CloseAsync(string roomCode, CancellationToken ct = default); } diff --git a/PushAndPull/PushAndPull/Domain/Room/Repository/RoomRepository.cs b/PushAndPull/PushAndPull/Domain/Room/Repository/RoomRepository.cs index c6d5858..7ff0450 100644 --- a/PushAndPull/PushAndPull/Domain/Room/Repository/RoomRepository.cs +++ b/PushAndPull/PushAndPull/Domain/Room/Repository/RoomRepository.cs @@ -15,14 +15,14 @@ public RoomRepository(AppDbContext context) _context = context; } - public async Task GetAsync(string roomCode) + public async Task GetAsync(string roomCode, CancellationToken ct = default) { return await _context.Rooms .AsNoTracking() - .FirstOrDefaultAsync(x => x.RoomCode == roomCode); + .FirstOrDefaultAsync(x => x.RoomCode == roomCode, ct); } - public async Task> GetAllAsync(CancellationToken ct) + public async Task> GetAllAsync(CancellationToken ct = default) { return await _context.Rooms .AsNoTracking() @@ -31,32 +31,30 @@ public async Task> GetAllAsync(CancellationToken ct) .ToListAsync(ct); } - public async Task CreateAsync(RoomEntity room) + public async Task CreateAsync(RoomEntity room, CancellationToken ct = default) { _context.Rooms.Add(room); - await _context.SaveChangesAsync(); + await _context.SaveChangesAsync(ct); } - public async Task IncrementPlayerCountAsync(string roomCode) + public async Task IncrementPlayerCountAsync(string roomCode, CancellationToken ct = default) { var updated = await _context.Rooms .Where(x => x.RoomCode == roomCode && x.Status == RoomStatus.Active && x.CurrentPlayers < x.MaxPlayers) .ExecuteUpdateAsync(s => s - .SetProperty(x => x.CurrentPlayers, x => x.CurrentPlayers + 1)); + .SetProperty(x => x.CurrentPlayers, x => x.CurrentPlayers + 1), ct); return updated > 0; } - // 주의: 이 메서드는 Room.Close()의 로직(Status = Closed, ExpiresAt = UtcNow)을 직접 반영하고 있습니다. - // Room.Close()에 새로운 비즈니스 로직이 추가될 경우 이 메서드도 함께 수정해야 합니다. - public async Task CloseAsync(string roomCode) + public async Task CloseAsync(string roomCode, CancellationToken ct = default) { await _context.Rooms .Where(x => x.RoomCode == roomCode) .ExecuteUpdateAsync(s => s .SetProperty(x => x.Status, RoomStatus.Closed) - .SetProperty(x => x.ExpiresAt, DateTimeOffset.UtcNow)); + .SetProperty(x => x.ExpiresAt, DateTimeOffset.UtcNow), ct); } } diff --git a/PushAndPull/PushAndPull/Domain/Room/Service/CreateRoomService.cs b/PushAndPull/PushAndPull/Domain/Room/Service/CreateRoomService.cs index d1a52a7..7ce1a13 100644 --- a/PushAndPull/PushAndPull/Domain/Room/Service/CreateRoomService.cs +++ b/PushAndPull/PushAndPull/Domain/Room/Service/CreateRoomService.cs @@ -21,7 +21,7 @@ IPasswordHasher passwordHasher _passwordHasher = passwordHasher; } - public async Task ExecuteAsync(CreateRoomCommand request) + public async Task ExecuteAsync(CreateRoomCommand request, CancellationToken ct = default) { string? passwordHash = null; if (!string.IsNullOrWhiteSpace(request.Password)) @@ -38,7 +38,7 @@ public async Task ExecuteAsync(CreateRoomCommand request) passwordHash: passwordHash ); - await _roomRepository.CreateAsync(room); + await _roomRepository.CreateAsync(room, ct); return new CreateRoomResult( room.RoomCode diff --git a/PushAndPull/PushAndPull/Domain/Room/Service/GetAllRoomService.cs b/PushAndPull/PushAndPull/Domain/Room/Service/GetAllRoomService.cs index 2e7fb35..51ac6ad 100644 --- a/PushAndPull/PushAndPull/Domain/Room/Service/GetAllRoomService.cs +++ b/PushAndPull/PushAndPull/Domain/Room/Service/GetAllRoomService.cs @@ -17,9 +17,9 @@ public async Task ExecuteAsync(CancellationToken ct = default) var rooms = await _roomRepository.GetAllAsync(ct); var summaries = rooms - .Select(room => new RoomSummary( - room.RoomName, + .Select(room => new RoomInfo( room.RoomCode, + room.RoomName, room.CurrentPlayers, room.IsPrivate )) diff --git a/PushAndPull/PushAndPull/Domain/Room/Service/GetRoomService.cs b/PushAndPull/PushAndPull/Domain/Room/Service/GetRoomService.cs index ec9b9d5..11cf2bb 100644 --- a/PushAndPull/PushAndPull/Domain/Room/Service/GetRoomService.cs +++ b/PushAndPull/PushAndPull/Domain/Room/Service/GetRoomService.cs @@ -13,12 +13,12 @@ public GetRoomService(IRoomRepository roomRepository) _roomRepository = roomRepository; } - public async Task ExecuteAsync(GetRoomCommand request) + public async Task ExecuteAsync(GetRoomCommand request, CancellationToken ct = default) { if (string.IsNullOrEmpty(request.RoomCode)) throw new ArgumentException("REQUIRED_ROOMCODE"); - var room = await _roomRepository.GetAsync(request.RoomCode) + var room = await _roomRepository.GetAsync(request.RoomCode, ct) ?? throw new RoomNotFoundException(request.RoomCode); return new GetRoomResult( diff --git a/PushAndPull/PushAndPull/Domain/Room/Service/Interface/ICreateRoomService.cs b/PushAndPull/PushAndPull/Domain/Room/Service/Interface/ICreateRoomService.cs index 0bc5a3d..eb91d41 100644 --- a/PushAndPull/PushAndPull/Domain/Room/Service/Interface/ICreateRoomService.cs +++ b/PushAndPull/PushAndPull/Domain/Room/Service/Interface/ICreateRoomService.cs @@ -2,7 +2,7 @@ namespace PushAndPull.Domain.Room.Service.Interface; public interface ICreateRoomService { - Task ExecuteAsync(CreateRoomCommand request); + Task ExecuteAsync(CreateRoomCommand request, CancellationToken ct = default); } public record CreateRoomCommand( diff --git a/PushAndPull/PushAndPull/Domain/Room/Service/Interface/IGetAllRoomService.cs b/PushAndPull/PushAndPull/Domain/Room/Service/Interface/IGetAllRoomService.cs index e6af738..f0aaff3 100644 --- a/PushAndPull/PushAndPull/Domain/Room/Service/Interface/IGetAllRoomService.cs +++ b/PushAndPull/PushAndPull/Domain/Room/Service/Interface/IGetAllRoomService.cs @@ -5,6 +5,11 @@ public interface IGetAllRoomService Task ExecuteAsync(CancellationToken ct = default); } -public record GetAllRoomResult( - IReadOnlyList Rooms - ); +public record GetAllRoomResult(IReadOnlyList Rooms); + +public record RoomInfo( + string RoomCode, + string RoomName, + int CurrentPlayers, + bool IsPrivate +); diff --git a/PushAndPull/PushAndPull/Domain/Room/Service/Interface/IGetRoomService.cs b/PushAndPull/PushAndPull/Domain/Room/Service/Interface/IGetRoomService.cs index aad0c7f..ab2bba8 100644 --- a/PushAndPull/PushAndPull/Domain/Room/Service/Interface/IGetRoomService.cs +++ b/PushAndPull/PushAndPull/Domain/Room/Service/Interface/IGetRoomService.cs @@ -2,7 +2,7 @@ namespace PushAndPull.Domain.Room.Service.Interface; public interface IGetRoomService { - Task ExecuteAsync(GetRoomCommand request); + Task ExecuteAsync(GetRoomCommand request, CancellationToken ct = default); } public record GetRoomCommand( diff --git a/PushAndPull/PushAndPull/Domain/Room/Service/Interface/IJoinRoomService.cs b/PushAndPull/PushAndPull/Domain/Room/Service/Interface/IJoinRoomService.cs index 139741f..d98e0b3 100644 --- a/PushAndPull/PushAndPull/Domain/Room/Service/Interface/IJoinRoomService.cs +++ b/PushAndPull/PushAndPull/Domain/Room/Service/Interface/IJoinRoomService.cs @@ -2,7 +2,7 @@ namespace PushAndPull.Domain.Room.Service.Interface; public interface IJoinRoomService { - Task ExecuteAsync(JoinRoomCommand request); + Task ExecuteAsync(JoinRoomCommand request, CancellationToken ct = default); } public record JoinRoomCommand( diff --git a/PushAndPull/PushAndPull/Domain/Room/Service/JoinRoomService.cs b/PushAndPull/PushAndPull/Domain/Room/Service/JoinRoomService.cs index e834deb..a3fbf25 100644 --- a/PushAndPull/PushAndPull/Domain/Room/Service/JoinRoomService.cs +++ b/PushAndPull/PushAndPull/Domain/Room/Service/JoinRoomService.cs @@ -20,29 +20,27 @@ IPasswordHasher passwordHasher _passwordHasher = passwordHasher; } - public async Task ExecuteAsync(JoinRoomCommand request) + public async Task ExecuteAsync(JoinRoomCommand request, CancellationToken ct = default) { - var room = await _roomRepository.GetAsync(request.RoomCode) + var room = await _roomRepository.GetAsync(request.RoomCode, ct) ?? throw new RoomNotFoundException(request.RoomCode); if (room.Status != RoomStatus.Active) throw new RoomNotActiveException(request.RoomCode); - if (request.Password != null) + if (room.IsPrivate) { if (string.IsNullOrWhiteSpace(request.Password)) throw new InvalidOperationException("PASSWORD_REQUIRED"); - if (!_passwordHasher.Verify( request.Password, room.PasswordHash!)) + if (!_passwordHasher.Verify(request.Password, room.PasswordHash!)) throw new InvalidOperationException("INVALID_PASSWORD"); } - room.Join(); - - var success = await _roomRepository.IncrementPlayerCountAsync(request.RoomCode); + var success = await _roomRepository.IncrementPlayerCountAsync(request.RoomCode, ct); if (!success) { - var roomAfterAttempt = await _roomRepository.GetAsync(request.RoomCode); + var roomAfterAttempt = await _roomRepository.GetAsync(request.RoomCode, ct); if (roomAfterAttempt == null) throw new RoomNotFoundException(request.RoomCode); if (roomAfterAttempt.Status != RoomStatus.Active) diff --git a/PushAndPull/PushAndPull/Domain/Room/Service/RoomSummary.cs b/PushAndPull/PushAndPull/Domain/Room/Service/RoomSummary.cs deleted file mode 100644 index 67b2348..0000000 --- a/PushAndPull/PushAndPull/Domain/Room/Service/RoomSummary.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace PushAndPull.Domain.Room.Service; - -public record RoomSummary( - string RoomName, - string RoomCode, - int CurrentPlayers, - bool IsPrivate - ); diff --git a/PushAndPull/PushAndPull/Global/Config/DatabaseConfig.cs b/PushAndPull/PushAndPull/Global/Config/DatabaseConfig.cs index 381e885..2c4cd10 100644 --- a/PushAndPull/PushAndPull/Global/Config/DatabaseConfig.cs +++ b/PushAndPull/PushAndPull/Global/Config/DatabaseConfig.cs @@ -16,7 +16,7 @@ public static IServiceCollection AddDatabase( services.AddDbContext(options => { options.UseNpgsql(connectionString, npgsql => - npgsql.MigrationsHistoryTable("__EFMigrationsHistory", "room")); + npgsql.MigrationsHistoryTable("__EFMigrationsHistory", "public")); }); return services; diff --git a/PushAndPull/PushAndPull/Global/Config/RateLimitConfig.cs b/PushAndPull/PushAndPull/Global/Config/RateLimitConfig.cs new file mode 100644 index 0000000..d3e0c34 --- /dev/null +++ b/PushAndPull/PushAndPull/Global/Config/RateLimitConfig.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using Microsoft.AspNetCore.RateLimiting; + +namespace PushAndPull.Global.Config; + +public static class RateLimitConfig +{ + public static IServiceCollection AddRateLimit(this IServiceCollection services) + { + services.AddRateLimiter(options => + { + options.AddFixedWindowLimiter("login", opt => + { + opt.PermitLimit = 5; + opt.Window = TimeSpan.FromMinutes(1); + }); + + options.OnRejected = async (context, token) => + { + context.HttpContext.Response.StatusCode = 429; + context.HttpContext.Response.ContentType = "application/json"; + var body = JsonSerializer.Serialize(new + { + success = false, + message = "요청이 너무 많습니다. 잠시 후 다시 시도해주세요.", + data = (object?)null + }); + await context.HttpContext.Response.WriteAsync(body, token); + }; + }); + + return services; + } +} diff --git a/PushAndPull/PushAndPull/Migrations/20260312060119_AddRoomStatusCreatedAtIndex.cs b/PushAndPull/PushAndPull/Migrations/20260312060119_AddRoomStatusCreatedAtIndex.cs deleted file mode 100644 index 4f29912..0000000 --- a/PushAndPull/PushAndPull/Migrations/20260312060119_AddRoomStatusCreatedAtIndex.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace PushAndPull.Migrations -{ - /// - public partial class AddRoomStatusCreatedAtIndex : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateIndex( - name: "idx_room_status_created_at", - schema: "room", - table: "room", - columns: new[] { "status", "created_at" }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "idx_room_status_created_at", - schema: "room", - table: "room"); - } - } -} diff --git a/PushAndPull/PushAndPull/Migrations/20260312060119_AddRoomStatusCreatedAtIndex.Designer.cs b/PushAndPull/PushAndPull/Migrations/20260402065544_InitialCreateTables.Designer.cs similarity index 92% rename from PushAndPull/PushAndPull/Migrations/20260312060119_AddRoomStatusCreatedAtIndex.Designer.cs rename to PushAndPull/PushAndPull/Migrations/20260402065544_InitialCreateTables.Designer.cs index ecc8385..518affe 100644 --- a/PushAndPull/PushAndPull/Migrations/20260312060119_AddRoomStatusCreatedAtIndex.Designer.cs +++ b/PushAndPull/PushAndPull/Migrations/20260402065544_InitialCreateTables.Designer.cs @@ -12,8 +12,8 @@ namespace PushAndPull.Migrations { [DbContext(typeof(AppDbContext))] - [Migration("20260312060119_AddRoomStatusCreatedAtIndex")] - partial class AddRoomStatusCreatedAtIndex + [Migration("20260402065544_InitialCreateTables")] + partial class InitialCreateTables { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -25,7 +25,33 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Server.Domain.Entity.Room", b => + modelBuilder.Entity("PushAndPull.Domain.Auth.Entity.User", b => + { + b.Property("SteamId") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)") + .HasColumnName("steam_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz") + .HasColumnName("created_at"); + + b.Property("LastLoginAt") + .HasColumnType("timestamptz") + .HasColumnName("last_login_at"); + + b.Property("Nickname") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("nickname"); + + b.HasKey("SteamId"); + + b.ToTable("user", "auth"); + }); + + modelBuilder.Entity("PushAndPull.Domain.Room.Entity.Room", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -109,35 +135,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("room", "room"); }); - modelBuilder.Entity("Server.Domain.Entity.User", b => - { - b.Property("SteamId") - .ValueGeneratedOnAdd() - .HasColumnType("numeric(20,0)") - .HasColumnName("steam_id"); - - b.Property("CreatedAt") - .HasColumnType("timestamptz") - .HasColumnName("created_at"); - - b.Property("LastLoginAt") - .HasColumnType("timestamptz") - .HasColumnName("last_login_at"); - - b.Property("Nickname") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)") - .HasColumnName("nickname"); - - b.HasKey("SteamId"); - - b.ToTable("user", "user"); - }); - - modelBuilder.Entity("Server.Domain.Entity.Room", b => + modelBuilder.Entity("PushAndPull.Domain.Room.Entity.Room", b => { - b.HasOne("Server.Domain.Entity.User", "Host") + b.HasOne("PushAndPull.Domain.Auth.Entity.User", "Host") .WithMany() .HasForeignKey("HostSteamId") .OnDelete(DeleteBehavior.Restrict) diff --git a/PushAndPull/PushAndPull/Migrations/20260402065544_InitialCreateTables.cs b/PushAndPull/PushAndPull/Migrations/20260402065544_InitialCreateTables.cs new file mode 100644 index 0000000..25b2546 --- /dev/null +++ b/PushAndPull/PushAndPull/Migrations/20260402065544_InitialCreateTables.cs @@ -0,0 +1,117 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace PushAndPull.Migrations +{ + /// + public partial class InitialCreateTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "room"); + + migrationBuilder.EnsureSchema( + name: "auth"); + + migrationBuilder.CreateTable( + name: "user", + schema: "auth", + columns: table => new + { + steam_id = table.Column(type: "numeric(20,0)", nullable: false), + nickname = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + created_at = table.Column(type: "timestamptz", nullable: false), + last_login_at = table.Column(type: "timestamptz", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_user", x => x.steam_id); + }); + + migrationBuilder.CreateTable( + name: "room", + schema: "room", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + room_name = table.Column(type: "text", nullable: false), + room_code = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + steam_lobby_id = table.Column(type: "numeric(20,0)", nullable: false), + host_steam_id = table.Column(type: "numeric(20,0)", nullable: false), + current_players = table.Column(type: "integer", nullable: false), + max_players = table.Column(type: "integer", nullable: false), + is_private = table.Column(type: "boolean", nullable: false, defaultValue: false), + password_hash = table.Column(type: "text", nullable: true), + status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + created_at = table.Column(type: "timestamptz", nullable: false), + expires_at = table.Column(type: "timestamptz", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_room", x => x.id); + table.ForeignKey( + name: "FK_room_user_host_steam_id", + column: x => x.host_steam_id, + principalSchema: "auth", + principalTable: "user", + principalColumn: "steam_id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "idx_room_expires_at", + schema: "room", + table: "room", + column: "expires_at"); + + migrationBuilder.CreateIndex( + name: "idx_room_host_steam_id", + schema: "room", + table: "room", + column: "host_steam_id"); + + migrationBuilder.CreateIndex( + name: "idx_room_room_code", + schema: "room", + table: "room", + column: "room_code", + unique: true); + + migrationBuilder.CreateIndex( + name: "idx_room_status", + schema: "room", + table: "room", + column: "status"); + + migrationBuilder.CreateIndex( + name: "idx_room_status_created_at", + schema: "room", + table: "room", + columns: new[] { "status", "created_at" }); + + migrationBuilder.CreateIndex( + name: "idx_room_status_private", + schema: "room", + table: "room", + columns: new[] { "status", "is_private" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "room", + schema: "room"); + + migrationBuilder.DropTable( + name: "user", + schema: "auth"); + } + } +} diff --git a/PushAndPull/PushAndPull/Migrations/AppDbContextModelSnapshot.cs b/PushAndPull/PushAndPull/Migrations/AppDbContextModelSnapshot.cs index d0da5e0..8511547 100644 --- a/PushAndPull/PushAndPull/Migrations/AppDbContextModelSnapshot.cs +++ b/PushAndPull/PushAndPull/Migrations/AppDbContextModelSnapshot.cs @@ -22,7 +22,33 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Server.Domain.Entity.Room", b => + modelBuilder.Entity("PushAndPull.Domain.Auth.Entity.User", b => + { + b.Property("SteamId") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)") + .HasColumnName("steam_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz") + .HasColumnName("created_at"); + + b.Property("LastLoginAt") + .HasColumnType("timestamptz") + .HasColumnName("last_login_at"); + + b.Property("Nickname") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("nickname"); + + b.HasKey("SteamId"); + + b.ToTable("user", "auth"); + }); + + modelBuilder.Entity("PushAndPull.Domain.Room.Entity.Room", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -106,35 +132,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("room", "room"); }); - modelBuilder.Entity("Server.Domain.Entity.User", b => - { - b.Property("SteamId") - .ValueGeneratedOnAdd() - .HasColumnType("numeric(20,0)") - .HasColumnName("steam_id"); - - b.Property("CreatedAt") - .HasColumnType("timestamptz") - .HasColumnName("created_at"); - - b.Property("LastLoginAt") - .HasColumnType("timestamptz") - .HasColumnName("last_login_at"); - - b.Property("Nickname") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)") - .HasColumnName("nickname"); - - b.HasKey("SteamId"); - - b.ToTable("user", "user"); - }); - - modelBuilder.Entity("Server.Domain.Entity.Room", b => + modelBuilder.Entity("PushAndPull.Domain.Room.Entity.Room", b => { - b.HasOne("Server.Domain.Entity.User", "Host") + b.HasOne("PushAndPull.Domain.Auth.Entity.User", "Host") .WithMany() .HasForeignKey("HostSteamId") .OnDelete(DeleteBehavior.Restrict) diff --git a/PushAndPull/PushAndPull/Program.cs b/PushAndPull/PushAndPull/Program.cs index 88ec7f3..425f4dd 100644 --- a/PushAndPull/PushAndPull/Program.cs +++ b/PushAndPull/PushAndPull/Program.cs @@ -18,9 +18,11 @@ builder.Services.AddGlobalServices(); builder.Services.AddAuthServices(); builder.Services.AddRoomServices(); +builder.Services.AddRateLimit(); var app = builder.Build(); +app.UseRateLimiter(); app.UseGamismSdk(); app.MapControllers(); app.Run(); diff --git a/PushAndPull/PushAndPull/PushAndPull.csproj b/PushAndPull/PushAndPull/PushAndPull.csproj index f2f222d..2a23f0c 100644 --- a/PushAndPull/PushAndPull/PushAndPull.csproj +++ b/PushAndPull/PushAndPull/PushAndPull.csproj @@ -5,6 +5,7 @@ enable enable Linux + false fa34aedf-d249-45e4-9b9f-2899dbcd05d6 @@ -21,7 +22,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + @@ -32,4 +33,8 @@ + + + + diff --git a/PushAndPull/PushAndPull/appsettings.Development.json b/PushAndPull/PushAndPull/appsettings.Development.json index 77c55eb..b2d1017 100644 --- a/PushAndPull/PushAndPull/appsettings.Development.json +++ b/PushAndPull/PushAndPull/appsettings.Development.json @@ -10,9 +10,9 @@ "AppId": 480 }, "Redis": { - "ConnectionString": "localhost:6379,abortConnect=false" + "ConnectionString": "127.0.0.1:6381,abortConnect=false" }, "ConnectionStrings": { - "Default": "" + "Default": "Host=localhost;Port=5434;Database=pushandpull_dev;Username=pushandpull;Password=pushandpull" } } \ No newline at end of file diff --git a/PushAndPull/PushAndPull/appsettings.Production.json b/PushAndPull/PushAndPull/appsettings.Production.json index f7d893f..bbf8906 100644 --- a/PushAndPull/PushAndPull/appsettings.Production.json +++ b/PushAndPull/PushAndPull/appsettings.Production.json @@ -7,10 +7,10 @@ }, "Steam": { "WebApiKey": "Steam-ApiKey", - "AppId": 480 + "AppId": 0 }, "Redis": { - "ConnectionString": "redis:6379,abortConnect=false" + "ConnectionString": "" }, "ConnectionStrings": { "Default": "" diff --git a/PushAndPull/PushAndPull/appsettings.json b/PushAndPull/PushAndPull/appsettings.json index 3c15f13..1d4a3d3 100644 --- a/PushAndPull/PushAndPull/appsettings.json +++ b/PushAndPull/PushAndPull/appsettings.json @@ -7,8 +7,8 @@ }, "AllowedHosts": "*", "Steam": { - "WebApiKey": "Steam-ApiKey", - "AppId": 480 + "WebApiKey": "", + "AppId": 0 }, "ConnectionStrings": { "Default": "" diff --git a/PushAndPull/deploy/compose.dev.yaml b/PushAndPull/deploy/compose.dev.yaml new file mode 100644 index 0000000..301d857 --- /dev/null +++ b/PushAndPull/deploy/compose.dev.yaml @@ -0,0 +1,40 @@ +name: pushandpull-dev + +services: + pushandpull-db: + image: postgres:17 + environment: + POSTGRES_DB: pushandpull_dev + POSTGRES_USER: pushandpull + POSTGRES_PASSWORD: pushandpull + ports: + - "5434:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + pushandpull-redis: + image: redis:7-alpine + ports: + - "6381:6379" + + pushandpull-server: + image: pushpull.server + build: + context: .. + dockerfile: deploy/dev.dockerfile + ports: + - "8081:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - DOTNET_USE_POLLING_FILE_WATCHER=1 + - ConnectionStrings__Default=Host=pushandpull-db;Port=5432;Database=pushandpull_dev;Username=pushandpull;Password=pushandpull + - Redis__ConnectionString=pushandpull-redis:6379,abortConnect=false + - Steam__WebApiKey=Steam-ApiKey + volumes: + - ..:/src + depends_on: + - pushandpull-db + - pushandpull-redis + +volumes: + postgres_data: diff --git a/PushAndPull/deploy/compose.prod.yaml b/PushAndPull/deploy/compose.prod.yaml new file mode 100644 index 0000000..0df9f6f --- /dev/null +++ b/PushAndPull/deploy/compose.prod.yaml @@ -0,0 +1,36 @@ +name: pushandpull-prod + +services: + pushandpull-redis: + image: redis:7-alpine + container_name: pushandpull-redis + volumes: + - redis_data:/data + networks: + - pushandpull-network + restart: unless-stopped + + pushandpull-server: + image: seanyee1227/pushandpull-server:latest + container_name: pushandpull-server + ports: + - "21754:80" + environment: + - ASPNETCORE_ENVIRONMENT=Production + - ASPNETCORE_URLS=http://+:80 + - ConnectionStrings__Default=${DB_CONNECTION_STRING} + - Redis__ConnectionString=${REDIS_CONNECTION_STRING} + - Steam__WebApiKey=${STEAM_WEB_API_KEY} + - Steam__AppId=${STEAM_APP_ID} + depends_on: + - pushandpull-redis + networks: + - pushandpull-network + restart: unless-stopped + +volumes: + redis_data: + +networks: + pushandpull-network: + driver: bridge diff --git a/PushAndPull/deploy/dev.dockerfile b/PushAndPull/deploy/dev.dockerfile new file mode 100644 index 0000000..cd12e10 --- /dev/null +++ b/PushAndPull/deploy/dev.dockerfile @@ -0,0 +1,6 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0 +WORKDIR /src +COPY . . +WORKDIR /src/PushAndPull +RUN dotnet restore +ENTRYPOINT ["dotnet", "watch", "run", "--urls", "http://+:8080"] diff --git a/PushAndPull/deploy/prod.dockerfile b/PushAndPull/deploy/prod.dockerfile new file mode 100644 index 0000000..914961e --- /dev/null +++ b/PushAndPull/deploy/prod.dockerfile @@ -0,0 +1,12 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src +COPY . . +RUN dotnet publish PushAndPull/PushAndPull.csproj \ + --configuration Release \ + --output /app/publish \ + /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:9.0 +WORKDIR /app +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "PushAndPull.dll"]