diff --git a/Fantasy-server/.agents/skills/build/SKILL.md b/Fantasy-server/.agents/skills/build/SKILL.md new file mode 100644 index 0000000..460bd4f --- /dev/null +++ b/Fantasy-server/.agents/skills/build/SKILL.md @@ -0,0 +1,18 @@ +--- +name: build +description: Builds the Fantasy.Server 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. + +## Steps + +1. Run the build command: + ```bash + dotnet build Fantasy.Server/Fantasy.Server.csproj + ``` +2. Check the build output: + - If the build **succeeds**: confirm success and show the summary (warnings, if any) + - If the build **fails**: list each error with its file path and line number, then explain the likely cause and how to fix it diff --git a/Fantasy-server/.agents/skills/code-review/SKILL.md b/Fantasy-server/.agents/skills/code-review/SKILL.md new file mode 100644 index 0000000..d4901d0 --- /dev/null +++ b/Fantasy-server/.agents/skills/code-review/SKILL.md @@ -0,0 +1,158 @@ +--- +name: code-review +description: Run a structured checklist over changed files against project conventions (architecture, code style, EF Core, DI, testing). Produces a ✓/⚠/✗ report in Korean. +allowed-tools: Bash(git diff:*), Bash(git log:*), Bash(git branch:*), Read, Glob, Grep +context: fork +--- + +# Code Review + +Review changed files against project conventions and produce a Korean report. + +## Step 1 — Determine Scope + +1. Get current branch: `git branch --show-current` +2. Determine base branch: + - If an argument is provided (e.g., `/code-review develop`) → use that branch as base + - Otherwise → use `main` as base +3. List changed files: `git diff {base}...HEAD --name-only` +4. Get detailed diff: `git diff {base}...HEAD` +5. Get commit list: `git log {base}..HEAD --oneline` + +## Step 2 — Read Changed Files + +- Read each changed `.cs` file with the Read tool. +- Read non-`.cs` files (`.json`, `.md`, `.csproj`) only if relevant to the checklist. + +## Step 3 — Apply Checklist + +Review only files that were actually changed. Skip categories with no relevant changes. + +--- + +### [ARCH] Architecture & Layering + +- [ ] Controllers depend only on service interfaces — no concrete service classes +- [ ] Services depend only on repository interfaces — no direct `AppDbContext` access +- [ ] Only repositories access `AppDbContext` +- [ ] New domain follows `Domain/{Name}/` structure (Config / Controller / Dto / Entity / Repository / Service) +- [ ] New domain DI extension method is called from `Program.cs` + +### [STYLE] C# Code Style + +- [ ] `var` used only when type is obvious from the right-hand side +- [ ] Private fields: `_camelCase`; everything else: `PascalCase` +- [ ] Dependencies injected via constructor into `private readonly` fields +- [ ] Constructors contain no logic — assignments only +- [ ] Single-expression methods use expression-body (`=>`) +- [ ] No XML doc comments (`///`) unless explicitly requested +- [ ] No `#region` blocks + +### [DTO] DTO Pattern + +- [ ] `record` types with positional parameters +- [ ] DataAnnotations applied directly on parameters (`[Required]`, `[MaxLength]`, etc.) +- [ ] Requests in `Dto/Request/`, Responses in `Dto/Response/` + +### [ENTITY] Entity Pattern + +- [ ] All setters are `private set` +- [ ] Static factory method `Create(...)` used instead of public constructor +- [ ] Timestamps use `DateTime.UtcNow` +- [ ] No DataAnnotations on entities — EF config via Fluent API only +- [ ] EF Fluent config implements `IEntityTypeConfiguration` in `Entity/Config/` +- [ ] `DbSet` registered in `AppDbContext` +- [ ] Table/column names: `snake_case`, schema-qualified (`"schema"."table"`) +- [ ] Enums use `HasConversion()` + +### [SERVICE] Service Pattern + +- [ ] One use case = one class + one interface +- [ ] Interface exposes exactly one `ExecuteAsync` method +- [ ] Business exceptions use Gamism.SDK types (`ConflictException`, `NotFoundException`, etc.) +- [ ] No empty catch blocks; no bare re-throw without added context + +### [REPO] Repository Pattern + +- [ ] Read-only queries use `AsNoTracking()` +- [ ] `SaveAsync` checks Detached state before calling `AddAsync` + +### [CONTROLLER] Controller Pattern + +- [ ] Return type is `CommonApiResponse` (Gamism.SDK) +- [ ] Only service interfaces injected — no concrete classes +- [ ] Rate limiting applied with `[EnableRateLimiting("login"|"game")]` where needed +- [ ] Authenticated endpoints annotated with `[Authorize]` + +### [ASYNC] Async Pattern + +- [ ] All I/O methods are `async Task` / `async Task` +- [ ] No `.Result` or `.Wait()` calls +- [ ] No unintentional fire-and-forget — every async call is awaited + +### [SECURITY] Security + +- [ ] No plain-text passwords — `BCrypt.Net.BCrypt.HashPassword` required +- [ ] No hardcoded secrets (API keys, passwords, connection strings) +- [ ] No sensitive data (passwords, tokens) written to logs + +### [REDIS] Redis Cache Pattern (only when Redis code changed) + +- [ ] Read flow: check Redis first → on miss query DB → SET cache → return +- [ ] Write flow: update DB → DEL cache key (invalidate) + +### [DI] DI Registration + +- [ ] Domain services registered via `{Name}ServiceConfig.cs` extension method +- [ ] Extension method called from `Program.cs` + +### [TEST] Tests (only when Fantasy.Test files changed) + +- [ ] Test class name: `{ServiceName}Test` +- [ ] Test method name: `{MethodName}_{Scenario}_{ExpectedResult}` +- [ ] No direct `AppDbContext` mocks — mock repository interfaces instead +- [ ] `NSubstitute` used for mocking +- [ ] Arrange / Act / Assert structure with blank line separators + +--- + +## Step 4 — Output Report + +Write the report in Korean using the following format: + +``` +## 코드 리뷰 리포트 + +### 변경 범위 +- 브랜치: {current} ← {base} +- 변경 파일 수: N개 +- 커밋: {commit summary} + +--- + +### [ARCH] 아키텍처 · 레이어링 +✓ ... +⚠ ... +✗ ... + +(repeat for each category that has changes; skip empty categories) + +--- + +### 종합 결과 +| 등급 | 건수 | +|------|------| +| ✓ 통과 | N | +| ⚠ 경고 | N | +| ✗ 오류 | N | + +총 N개 항목 검토 — 오류 N건, 경고 N건 +``` + +**Symbol meanings:** +- ✓ — convention followed +- ⚠ — recommendation (optional fix) +- ✗ — convention violation (fix required) + +For each ✗ item, include the file name, approximate line, and suggested fix. +Skip categories with no relevant changes. diff --git a/Fantasy-server/.agents/skills/commit/SKILL.md b/Fantasy-server/.agents/skills/commit/SKILL.md index e250c3d..cdb80b7 100644 --- a/Fantasy-server/.agents/skills/commit/SKILL.md +++ b/Fantasy-server/.agents/skills/commit/SKILL.md @@ -1,4 +1,4 @@ ---- +--- 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:*) @@ -6,18 +6,32 @@ allowed-tools: Bash(git status:*), Bash(git diff:*), Bash(git add:*), Bash(git c Create Git commits following the project's commit conventions. +## Argument + +`$ARGUMENTS` — optional GitHub issue number (e.g. `/commit 42`) + +- If provided, add `#42` as the commit body (blank line after subject, then the reference). +- If omitted, commit without any issue reference. + ## Commit Message Format +Subject line only (no issue): ``` {type}: {Korean description} ``` +With issue number: +``` +{type}: {Korean description} + +#{issue} +``` + **Types**: - `feat` — new feature added - `fix` — bug fix, missing config, or missing DI registration - `update` — modification to existing code -- `docs` - documentation-only changes -- `cicd` — changes to CI/CD configuration, scripts, or workflows +- `chore` — tooling, CI/CD, dependency updates, config changes unrelated to app logic **Description rules**: - Written in **Korean** @@ -28,29 +42,30 @@ Create Git commits following the project's commit conventions. **Examples**: ``` feat: 로그인 로직 추가 +``` +``` fix: 세션 DI 누락 수정 -update: Account 엔터티 수정 -docs: API 명세서 업데이트 -cicd: GitHub Actions 워크플로우 수정 + +#12 ``` +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` + - New feature addition → `feat` + - Bug / missing registration fix → `fix` + - Modification to existing code → `update` 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 - - **IMPORTANT: Display the staged files and the proposed commit message to the user.** - - **Ask the user: "이 내용으로 커밋하시겠습니까? (Y/n)"** - - **Only execute `git commit -m "message"` if the user approves.** + - Stage only the relevant files with `git add ` + - Write a concise commit message following the format above + - If `$ARGUMENTS` is provided: `git commit -m "{subject}" -m "#{issue}"` + - If `$ARGUMENTS` is omitted: `git commit -m "{subject}"` 5. Verify results with `git log --oneline -n {number of commits made}` diff --git a/Fantasy-server/.agents/skills/commit/examples/type-guide.md b/Fantasy-server/.agents/skills/commit/examples/type-guide.md new file mode 100644 index 0000000..140de16 --- /dev/null +++ b/Fantasy-server/.agents/skills/commit/examples/type-guide.md @@ -0,0 +1,82 @@ +# Commit Type Guide — Fantasy 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 AuthController.cs | `feat: Auth 컨트롤러 추가` | +| Add RefreshTokenRedisRepository.cs | `feat: 리프레시 토큰 Redis 레포지토리 추가` | +| 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, middleware) 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()` in `Program.cs` | `fix: IAccountRepository DI 누락 수정` | +| Fix wrong prefix on Redis key | `fix: Redis 리프레시 토큰 키 prefix 수정` | +| Remove misconfigured EF Core ValueGeneration | `fix: UpdatedAt 자동 생성 설정 제거` | +| Fix typo in port value in `appsettings.json` | `fix: 개발 환경 DB 포트 수정` | +| Fix password comparison using HashPassword instead of BCrypt.Verify | `fix: 비밀번호 검증 로직 수정` | + +--- + +## update — Existing code modified without adding a new capability + +Use when modifying files that already exist — renaming, restructuring, adjusting behavior, etc. + +**Examples from this project:** + +| Change | Commit message | +|---|---| +| Change response type from `T` to `CommonApiResponse` | `update: Auth 응답 타입을 CommonApiResponse로 변경` | +| Move JwtProvider to a different namespace | `update: JwtProvider를 Jwt 네임스페이스로 이동` | +| Update port/connection string in appsettings | `update: Docker 서비스 이름 통일 및 연결 문자열 수정` | +| Modify a property on an existing Entity | `update: Account 엔터티 수정` | +| Add a test method to an existing test class | `update: LoginService 테스트 추가` | +| Merge CI workflow steps | `update: CI 워크플로우 빌드·테스트 단계 통합` | + +--- + +## 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 (`Program.cs`, `*Config.cs`) | `fix` | +| New service file + its DI registration added together | `feat` (same logical unit) | +| New migration file added | `feat` | +| Existing migration file corrected (column issue) | `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 + +If a branch mixes new features with unrelated bug fixes, split them: + +``` +# New service + its DI registration → one logical unit, commit together +git add Domain/Auth/Service/LoginService.cs Domain/Auth/Config/AuthServiceConfig.cs +git commit -m "feat: 로그인 서비스 추가" + +# Separate Redis key bug fix → independent fix +git add Domain/Auth/Repository/RefreshTokenRedisRepository.cs +git commit -m "fix: 리프레시 토큰 Redis 키 prefix 수정" +``` diff --git a/Fantasy-server/.agents/skills/db-migrate/SKILL.md b/Fantasy-server/.agents/skills/db-migrate/SKILL.md new file mode 100644 index 0000000..83e16e8 --- /dev/null +++ b/Fantasy-server/.agents/skills/db-migrate/SKILL.md @@ -0,0 +1,79 @@ +--- +name: db-migrate +description: Manages EF Core migrations. Supports add/update/list/remove subcommands. e.g. /db-migrate add CreateAccountTable +argument-hint: [add | update | list | remove] +allowed-tools: Bash(dotnet ef migrations:*), Bash(dotnet ef database:*), AskUserQuestion +context: fork +--- + +Manage EF Core migrations for Fantasy.Server. + +**Project**: `Fantasy.Server/Fantasy.Server.csproj` +**DbContext**: `AppDbContext` (`Global/Infrastructure/AppDbContext.cs`) + +## Current migrations state + +!`dotnet ef migrations list --project Fantasy.Server/Fantasy.Server.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: + > "마이그레이션 이름을 입력해주세요. (예: CreateAccountTable)" +2. Run: + ```bash + dotnet ef migrations add {MigrationName} --project Fantasy.Server/Fantasy.Server.csproj + ``` +3. Report the generated files under `Fantasy.Server/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 Fantasy.Server/Fantasy.Server.csproj + ``` +4. Confirm success or surface any connection/schema errors. + +--- + +### `list` + +Run: +```bash +dotnet ef migrations list --project Fantasy.Server/Fantasy.Server.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 Fantasy.Server/Fantasy.Server.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/Fantasy-server/.agents/skills/db-migrate/examples/naming.md b/Fantasy-server/.agents/skills/db-migrate/examples/naming.md new file mode 100644 index 0000000..c1fb777 --- /dev/null +++ b/Fantasy-server/.agents/skills/db-migrate/examples/naming.md @@ -0,0 +1,75 @@ +# Migration Naming Guide — Fantasy Server + +## Format + +**PascalCase**, no spaces, clearly describes the schema operation being performed. + +--- + +## Naming patterns by operation + +### New table +``` +Create{TableName}Table +``` +e.g. `CreateAccountTable`, `CreateGameRoomTable`, `CreatePlayerTable` + +### Initial schema (multiple tables at once) +``` +CreateTables +InitialSchema +``` +e.g. `CreateTables` ← the actual name of the first migration in this project + +### Add column to existing table +``` +Add{ColumnName}To{TableName} +``` +e.g. `AddNicknameToAccount`, `AddStatusToGameRoom`, `AddRefreshTokenToAuth` + +### Remove column +``` +Remove{ColumnName}From{TableName} +``` +e.g. `RemoveDeprecatedFieldFromAccount` + +### Fix column type, constraint, or misconfiguration +``` +Fix{ColumnName}{Issue} +Change{ColumnName}In{TableName} +``` +e.g. +- `FixUpdatedAtValueGeneration` ← actual fix migration in this project +- `ChangeEmailMaxLengthInAccount` + +### Add index +``` +Add{Description}IndexTo{TableName} +``` +e.g. `AddEmailUniqueIndexToAccount`, `AddCreatedAtIndexToGameRoom` + +### Add foreign key +``` +Add{Relation}ForeignKeyTo{TableName} +``` +e.g. `AddAccountForeignKeyToGamePlayer` + +--- + +## Anti-patterns (avoid) + +| Bad | Good | +|---|---| +| `Migration1` | `CreateAccountTable` | +| `FixAccount` | `FixUpdatedAtValueGeneration` | +| `UpdateSchema` | `AddNicknameToAccount` | +| `Temp` | (describe what actually changed) | +| `Fix` | `FixEmailConstraintInAccount` | + +--- + +## Migration history in this project + +| Migration name | What it does | +|---|---| +| _(no migrations yet)_ | First migration should be named `CreateTables` | diff --git a/Fantasy-server/.agents/skills/plan-deep-dive/SKILL.md b/Fantasy-server/.agents/skills/plan-deep-dive/SKILL.md new file mode 100644 index 0000000..31577f5 --- /dev/null +++ b/Fantasy-server/.agents/skills/plan-deep-dive/SKILL.md @@ -0,0 +1,8 @@ +--- +name: plan-deep-dive +description: Conduct an in-depth structured interview with the user to uncover non-obvious requirements, tradeoffs, and constraints, then produce a detailed implementation spec file. +argument-hint: [instructions] +allowed-tools: AskUserQuestion, Write +--- + +Follow the user instructions and interview me in detail using the AskUserQuestionTool about literally anything: technical implementation, UI & UX, concerns, tradeoffs, etc. but make sure the questions are not obvious. be very in-depth and continue interviewing me continually until it's complete. then, write the spec to a file. $ARGUMENTS \ No newline at end of file diff --git a/Fantasy-server/.agents/skills/pr/SKILL.md b/Fantasy-server/.agents/skills/pr/SKILL.md deleted file mode 100644 index a675d48..0000000 --- a/Fantasy-server/.agents/skills/pr/SKILL.md +++ /dev/null @@ -1,197 +0,0 @@ ---- -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, AskUserQuestion -context: fork ---- - -Generate a PR based on the current branch. Behavior differs depending on the branch. - -## Steps - -### 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 `{target}` as the **Base Branch** and proceed directly to **Case 3**. - - **If no argument is provided**: Follow the **Branch-Based Behavior** below. - ---- - -## Branch-Based Behavior (Default) - -### Case 1: Current branch is `develop` - -**Step 1. Check the current version** - -- Check git tags: `git tag --sort=-v:refname | head -10` -- Check existing release branches: `git branch -a | grep release` -- Determine the latest version (e.g., `1.0.0`) - -**Step 2. Analyze changes and recommend version bump** - -- Commits: `git log main..HEAD --oneline` -- Diff stats: `git diff main...HEAD --stat` -- Recommend one of: - - **Major** (x.0.0): Breaking changes, incompatible API changes - - **Minor** (0.x.0): New backward-compatible features - - **Patch** (0.0.x): Bug fixes only -- Briefly explain why you chose that level - -**Step 3. Ask the user for a version number** - -Use AskUserQuestion: -> "현재 버전: {current_version} -> 추천 버전 업: {Major/Minor/Patch} → {recommended_version} -> 이유: {brief reason} -> -> 사용할 버전 번호를 입력해주세요. (예: 1.0.1)" - -**Step 4. Create a release branch** - -```bash -git checkout -b release/{version} -``` - -**Step 5. Write PR body** following the PR Body Template below -- Analyze changes from `main` branch -- Save to `PR_BODY.md` - -**Step 6. Create PR to `main`** - -```bash -gh pr create --title "release/{version}" --body-file PR_BODY.md --base main -``` - -**Step 7. Delete PR_BODY.md** - -```bash -rm PR_BODY.md -``` - ---- - -### Case 2: Current branch is `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`** - -- Commits: `git log main..HEAD --oneline` -- Diff stats: `git diff main...HEAD --stat` - -**Step 3. Write PR body** following the PR Body Template below -- Save to `PR_BODY.md` - -**Step 4. Create PR to `main`** - -```bash -gh pr create --title "release/{version}" --body-file PR_BODY.md --base main -``` - -**Step 5. Delete PR_BODY.md** - -```bash -rm PR_BODY.md -``` - ---- - -### Case 3: Any other branch - -**Step 1. Analyze changes from `develop`** - -- Commits: `git log develop..HEAD --oneline` -- Diff stats: `git diff develop...HEAD --stat` -- Detailed diff: `git diff develop...HEAD` - -**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 6. Create PR to `develop`** - -- If the user answered 1, 2, or 3, use the corresponding suggested title -- If the user typed a custom title, use it as-is - -```bash -gh pr create --title "{chosen title}" --body-file PR_BODY.md --base develop -``` - -**Step 7. Delete PR_BODY.md** - -```bash -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 - -**Rules:** -- Description in Korean -- Short and imperative (단문) -- No trailing punctuation - -**Examples:** -- `feature: 방 생성 API 추가` -- `fix: Key Vault 연동 방식을 AddAzureKeyVault으로 변경` -- `refactor: 로그인 로직 리팩토링` - ---- - -## PR Body Template - -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] 변경한 기능이 다른 기능을 깨뜨리지 않나요? - - -> *추후 필요한 체크리스트는 업데이트 될 예정입니다.* -``` - -**Rules:** -- Analyze commits and diffs to fill in `작업 내용` with a concise bullet list -- Fill in `참고 사항` with any important context (architecture decisions, before/after, warnings). Write `.` if nothing relevant. -- Keep the total body under 2500 characters -- Write in Korean -- No emojis in text content (keep the section header emojis) diff --git a/Fantasy-server/.agents/skills/review-pr/SKILL.md b/Fantasy-server/.agents/skills/review-pr/SKILL.md new file mode 100644 index 0000000..2878760 --- /dev/null +++ b/Fantasy-server/.agents/skills/review-pr/SKILL.md @@ -0,0 +1,98 @@ +--- +name: review-pr +description: Review a pull request or branch diff against a base branch and produce a Korean PR review report focused on bugs, regressions, missing tests, API or schema risks, and project convention violations. Use when asked to review a PR before merge, inspect branch changes, or give merge-readiness feedback. +allowed-tools: Bash(git diff:*), Bash(git log:*), Bash(git branch:*), Read, Glob, Grep +context: fork +--- + +# Review PR + +Review the branch as a pull request and report only meaningful findings. + +If `scripts/collect-pr-data.sh` exists and `gh` is available, run it first to collect PR metadata, comments, commit list, changed files, and diff into `.pr-tmp/{pr-number}/`. Use those artifacts as the primary review input before falling back to manual `git` and `gh` commands. + +## Step 1 - Determine Scope + +1. Get the current branch with `git branch --show-current`. +2. Determine the base branch: + - If an argument is provided, for example `/review-pr develop`, use that branch as base. + - Otherwise, use `main` when reviewing `develop`. + - Otherwise, use `develop`. +3. Collect review material: + - Changed files: `git diff {base}...HEAD --name-only` + - Full diff: `git diff {base}...HEAD` + - Commit list: `git log {base}..HEAD --oneline` + - Diff stat: `git diff {base}...HEAD --stat` + - If available, prefer artifacts from `.pr-tmp/{pr-number}/changed_files.txt`, `.pr-tmp/{pr-number}/diff.txt`, `.pr-tmp/{pr-number}/commits.txt`, `.pr-tmp/{pr-number}/review_comments.json`, and `.pr-tmp/{pr-number}/issue_comments.json` + +## Step 2 - Load Review Context + +Before commenting on conventions, read the repository guidance that applies to the changed files. + +- Always read `AGENTS.md`. +- Read `.claude/rules/architecture.md`, `.claude/rules/code-style.md`, `.claude/rules/conventions.md`, `.claude/rules/domain-patterns.md`, `.claude/rules/testing.md`, and `.claude/rules/verify.md`. +- Read `.claude/rules/flows.md` when controller, service, auth, or request/response behavior changed. +- Read only the files that actually changed, plus nearby files if needed to validate behavior. + +## Step 3 - Review With PR Mindset + +Prioritize correctness over style. Ignore untouched code unless the change relies on it. + +Look for: + +- Functional bugs and behavioral regressions +- Missing null handling, validation, authorization, or transaction boundaries +- API contract changes without corresponding DTO, controller, or test updates +- EF Core or persistence risks such as missing config, migration mismatch, tracking issues, or cache invalidation gaps +- DI registration omissions +- Async misuse, swallowed exceptions, or incorrect error mapping +- Security issues such as secrets, token leakage, weak password handling, or sensitive logging +- Missing or insufficient tests for newly introduced behavior +- Maintainability issues only when they create clear follow-up risk + +Do not pad the review with low-signal style nits. Report a finding only when you can explain the concrete risk. + +## Step 4 - Output Format + +Write the review in Korean. Present findings first, ordered by severity. + +Use this structure: + +```md +## PR 리뷰 +### 범위 +- 브랜치: {current} -> {base} +- 변경 파일: {count}개 +- 커밋: {short summary} + +### 주요 발견사항 +1. [심각도] 제목 + - 위치: {file}:{line} + - 문제: {what is wrong} + - 영향: {why it matters} + - 제안: {specific fix} + +### 확인 필요 +- 가정이나 누락된 정보가 있으면 작성 + +### 요약 +- 머지 전 수정 필요: {count}건 +- 권장 확인 사항: {count}건 +``` + +Severity labels: + +- `치명적`: merge blocker, production failure, security issue, data corruption risk +- `높음`: likely bug or regression that should be fixed before merge +- `보통`: real risk or missing coverage worth addressing soon + +If there are no findings, say that explicitly under `주요 발견사항` and include any residual test or verification gaps in `확인 필요`. + +## Step 5 - Reply To GitHub Comments + +When replying to inline review comments, read `references/github-reply-formats.md` and follow it exactly. + +- Always write replies in Korean. +- Always quote `comment_id` when building shell commands or `gh api` payloads. +- Use only the approved reply formats for `VALID`, `INVALID`, and `PARTIAL` cases. +- Keep the reply short and decisive. Do not add extra argument unless the format explicitly allows it. diff --git a/Fantasy-server/.agents/skills/review-pr/references/github-reply-formats.md b/Fantasy-server/.agents/skills/review-pr/references/github-reply-formats.md new file mode 100644 index 0000000..89219dd --- /dev/null +++ b/Fantasy-server/.agents/skills/review-pr/references/github-reply-formats.md @@ -0,0 +1,39 @@ +# GitHub Reply Formats + +Use these templates when posting inline replies in Step 5. +Always quote `comment_id` to prevent shell injection. +All replies must be written in Korean. + +## VALID - fix succeeded + +```text + 에서 반영했습니다. (근거: <출처>) +``` + +## VALID - fix failed + +```text +지적 사항이 타당합니다. 직접 수정이 필요하여 별도 처리하겠습니다. +``` + +## INVALID + +Do not use a long rebuttal or quote repository rules verbatim. + +## PARTIAL - accepted + +```text +부분적으로 타당하다고 판단하여 에서 반영했습니다. +``` + +## PARTIAL - rejected + +```text +검토 결과 이 방향으로는 적용하지 않기로 결정했습니다. +``` + +## PARTIAL - pending + +```text +검토 중입니다. 추후 답변드리겠습니다. +``` diff --git a/Fantasy-server/.agents/skills/review-pr/scripts/collect-pr-data.sh b/Fantasy-server/.agents/skills/review-pr/scripts/collect-pr-data.sh new file mode 100644 index 0000000..7bbf144 --- /dev/null +++ b/Fantasy-server/.agents/skills/review-pr/scripts/collect-pr-data.sh @@ -0,0 +1,39 @@ +#!/bin/bash +set -euo pipefail + +if ! command -v gh >/dev/null 2>&1; then + echo "ERROR: gh is required." >&2 + exit 1 +fi + +PR_NUMBER=$(gh pr view --json number -q .number 2>/dev/null || true) +if [ -z "${PR_NUMBER:-}" ]; then + echo "ERROR: No open PR found for current branch." >&2 + exit 1 +fi + +REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) +BASE=$(gh pr view "$PR_NUMBER" --json baseRefName -q .baseRefName) + +OUT_DIR=".pr-tmp/$PR_NUMBER" +mkdir -p "$OUT_DIR" + +git fetch origin "$BASE" --quiet || true + +gh pr view "$PR_NUMBER" --json number,title,url,baseRefName,headRefName,author \ + > "$OUT_DIR/pr_meta.json" + +gh api "repos/$REPO/pulls/$PR_NUMBER/comments" \ + --jq '[.[] | {id, path, line, side, body, user: .user.login, createdAt: .created_at}]' \ + > "$OUT_DIR/review_comments.json" + +gh api "repos/$REPO/issues/$PR_NUMBER/comments" \ + --jq '[.[] | {id, body, user: .user.login, createdAt: .created_at}]' \ + > "$OUT_DIR/issue_comments.json" + +git log "origin/$BASE..HEAD" --pretty=format:"%H %h %s" > "$OUT_DIR/commits.txt" +git diff "origin/$BASE...HEAD" --name-only > "$OUT_DIR/changed_files.txt" +git diff "origin/$BASE...HEAD" > "$OUT_DIR/diff.txt" + +echo "PR #$PR_NUMBER | Repo: $REPO | Base: $BASE | Output: $OUT_DIR" +echo "Review comments: $(gh api --method GET "repos/$REPO/pulls/$PR_NUMBER/comments" --jq 'length'), Issue comments: $(gh api --method GET "repos/$REPO/issues/$PR_NUMBER/comments" --jq 'length'), Changed files: $(wc -l < "$OUT_DIR/changed_files.txt" | tr -d ' ')" diff --git a/Fantasy-server/.agents/skills/test/SKILL.md b/Fantasy-server/.agents/skills/test/SKILL.md new file mode 100644 index 0000000..6fea35d --- /dev/null +++ b/Fantasy-server/.agents/skills/test/SKILL.md @@ -0,0 +1,60 @@ +--- +name: test +description: Builds Fantasy.Server then runs all tests in Fantasy.Test. Reports pass/fail results. e.g. /test CreateAccountService +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 Fantasy.Server/Fantasy.Server.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 Fantasy.Test/Fantasy.Test.csproj --no-build +``` + +If `$ARGUMENTS` is provided, filter by class or method name: + +```bash +dotnet test Fantasy.Test/Fantasy.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/Fantasy-server/.agents/skills/test/examples/filter-patterns.md b/Fantasy-server/.agents/skills/test/examples/filter-patterns.md new file mode 100644 index 0000000..365f09a --- /dev/null +++ b/Fantasy-server/.agents/skills/test/examples/filter-patterns.md @@ -0,0 +1,71 @@ +# Test Filter Patterns — Fantasy 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: +``` +Fantasy.Test.Account.Service.CreateAccountServiceTests+이메일이_존재하지_않을_때.회원가입_요청_시_계정이_저장된다 +``` + +**Korean class and method names work as-is. No escaping required.** + +--- + +## Current test classes in this project + +| `/test` argument | What runs | +|---|---| +| _(no argument)_ | All tests | +| `CreateAccountServiceTests` | All CreateAccountService tests | +| `DeleteAccountServiceTests` | All DeleteAccountService tests (currently empty) | +| `LoginServiceTests` | All LoginService tests | +| `이메일이_존재하지_않을_때` | Email-not-found scenario (CreateAccount) | +| `이미_사용중인_이메일일_때` | Duplicate email scenario (CreateAccount) | +| `유효한_자격증명일_때` | Valid credentials scenario (Login) | +| `존재하지_않는_이메일일_때` | Email-not-found scenario (Login) | +| `잘못된_비밀번호일_때` | Wrong password scenario (Login) | + +--- + +## Pattern examples + +### Filter by outer class — runs all tests for a service +``` +/test CreateAccountServiceTests +``` +→ Matches all of `Fantasy.Test.Account.Service.CreateAccountServiceTests+*.*` + +### Filter by Korean inner class — runs all tests in a scenario +``` +/test 이메일이_존재하지_않을_때 +``` +→ Matches all methods inside that inner class + +### Filter by method name — runs a single test +``` +/test 회원가입_요청_시_비밀번호가_해싱된다 +``` +→ Matches the single method by name substring + +### Filter by domain +``` +/test LoginService +``` +→ Matches all of `Fantasy.Test.Auth.Service.LoginServiceTests+*.*` + +### Filter by namespace — runs an entire domain +``` +/test Fantasy.Test.Account +``` +→ Matches everything under the Account namespace diff --git a/Fantasy-server/.agents/skills/write-pr/SKILL.md b/Fantasy-server/.agents/skills/write-pr/SKILL.md new file mode 100644 index 0000000..59c7d9a --- /dev/null +++ b/Fantasy-server/.agents/skills/write-pr/SKILL.md @@ -0,0 +1,203 @@ +--- +name: write-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, AskUserQuestion +context: fork +--- + +Generate a PR based on the current branch. Behavior differs depending on the branch. + +Use `references/label.md` to select 1-2 PR labels before creating the PR. Apply the selected labels when running the PR creation step. + +## Steps + +### Step 0. Initialize & Branch Discovery +1. Identify the current branch using `git branch --show-current`. +2. Check for arguments: + - If an argument is provided, for example `/write-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 + +### Case 1. Current branch is `develop` + +**Step 1. Check the current version** + +- Check git tags: `git tag --sort=-v:refname | head -10` +- Check existing release branches: `git branch -a | grep release` +- Determine the latest version, for example `1.0.0` + +**Step 2. Analyze changes and recommend version bump** + +- Commits: `git log main..HEAD --oneline` +- Diff stats: `git diff main...HEAD --stat` +- Recommend one of: + - Major (`x.0.0`): breaking changes or incompatible API changes + - Minor (`0.x.0`): new backward-compatible features + - Patch (`0.0.x`): bug fixes only +- Briefly explain why you chose that level + +**Step 3. Ask the user for a version number** + +Use AskUserQuestion: +> Current version: {current_version} +> Recommended bump: {Major/Minor/Patch} -> {recommended_version} +> Reason: {brief reason} +> +> Enter the release version. Example: `1.0.1` + +**Step 4. Create a release branch** + +```bash +git checkout -b release/{version} +``` + +**Step 5. Write PR body** + +- Analyze changes from `main` +- Follow the PR Body Template below +- Save to `PR_BODY.md` + +**Step 6. Select labels** + +- Follow `references/label.md` +- Select 1-2 PR-eligible labels that match the change + +**Step 7. Create PR to `main`** + +```bash +./scripts/create-pr.sh "release/{version}" PR_BODY.md "{label1,label2}" +``` + +**Step 8. Delete PR_BODY.md** + +```bash +rm PR_BODY.md +``` + +--- + +### Case 2. Current branch is `release/x.x.x` + +**Step 1. Extract version** + +- Extract the version from the branch name, for example `release/1.2.0` -> `1.2.0` + +**Step 2. Analyze changes from `main`** + +- Commits: `git log main..HEAD --oneline` +- Diff stats: `git diff main...HEAD --stat` + +**Step 3. Write PR body** + +- Follow the PR Body Template below +- Save to `PR_BODY.md` + +**Step 4. Select labels** + +- Follow `references/label.md` +- Select 1-2 PR-eligible labels that match the change + +**Step 5. Create PR to `main`** + +```bash +./scripts/create-pr.sh "release/{version}" PR_BODY.md "{label1,label2}" +``` + +**Step 6. Delete PR_BODY.md** + +```bash +rm PR_BODY.md +``` + +--- + +### Case 3. Any other branch + +**Step 1. Analyze changes from `{Base Branch}`** + +- 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 three PR titles** + +- Follow the PR Title Convention below + +**Step 3. Write PR body** + +- Follow the PR Body Template below +- Save to `PR_BODY.md` + +**Step 4. Ask the user** + +Use AskUserQuestion with a `choices` array: +- Options: the 3 generated titles plus `직접 입력` as the last option +- If the user selects `직접 입력`, ask a follow-up AskUserQuestion for the custom title + +**Step 5. Select labels** + +- Follow `references/label.md` +- Select 1-2 PR-eligible labels that match the change + +**Step 6. Create PR to `{Base Branch}`** + +- Use the selected title, or the custom title if the user chose `직접 입력` + +```bash +./scripts/create-pr.sh "{chosen title}" PR_BODY.md "{label1,label2}" +``` + +**Step 7. Delete PR_BODY.md** + +```bash +rm PR_BODY.md +``` + +--- + +## PR Title Convention + +Format: `{type}: {Korean description}` + +**Types:** +- `feat`: new feature added +- `fix`: bug fix or missing configuration or DI registration +- `update`: modification to existing code +- `docs`: documentation changes +- `refactor`: refactoring without behavior change +- `test`: adding or updating tests +- `chore`: tooling, CI/CD, dependency updates, or config changes unrelated to app logic + +**Rules:** +- Description in Korean +- Short and imperative +- No trailing punctuation + +**Examples:** +- `feat: 계정 생성 API 추가` +- `fix: Key Vault 연동 방식을 AddAzureKeyVault로 변경` +- `refactor: 로그 처리 로직 분리` + +See `examples/feature-to-develop.md` for a complete example of a feature -> develop PR. + +## Labels + +Follow `references/label.md` and select 1-2 labels before the PR creation step. + +## PR Body Template + +Follow this exact structure: + +`templates/pr-body.md` + +**Rules:** +- Analyze commits and diffs to fill in the work summary with concise bullet points +- Keep the total body under 2500 characters +- Write in Korean +- Do not add emojis in the body text diff --git a/Fantasy-server/.agents/skills/write-pr/examples/feature-to-develop.md b/Fantasy-server/.agents/skills/write-pr/examples/feature-to-develop.md new file mode 100644 index 0000000..3150adc --- /dev/null +++ b/Fantasy-server/.agents/skills/write-pr/examples/feature-to-develop.md @@ -0,0 +1,49 @@ +# Example: Feature Branch PR (feature → develop) + +## Branch context + +- Current branch: `feat/auth-api` +- Base branch: `develop` + +## Suggested PR titles (3 options) + +1. `feat: JWT 기반 로그인·로그아웃 API 추가` +2. `feat: Auth 도메인 로그인·로그아웃 엔드포인트 구현` +3. `feat: 로그인·리프레시토큰·로그아웃 서비스 추가` + +## Completed PR body example + +--- + +## 📚작업 내용 + +- LoginService 구현 — 이메일·비밀번호 검증 및 JWT 발급 +- LogoutService 구현 — Redis 리프레시 토큰 삭제 +- RefreshTokenRedisRepository 추가 — Redis key: `refresh:{accountId}`, TTL 30일 +- AuthController 추가 — `POST /v1/auth/login`, `POST /v1/auth/logout` +- 로그인 엔드포인트에 `"login"` RateLimit 정책 적용 + +## ◀️참고 사항 + +액세스 토큰 만료 시간은 `appsettings.json`의 `Jwt:AccessTokenExpirationMinutes` 값을 사용합니다. +로그아웃은 리프레시 토큰만 삭제하며, 액세스 토큰은 만료 전까지 유효합니다. + +## ✅체크리스트 + +> `[ ]`안에 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/Fantasy-server/.agents/skills/write-pr/references/label.md b/Fantasy-server/.agents/skills/write-pr/references/label.md new file mode 100644 index 0000000..ea788a8 --- /dev/null +++ b/Fantasy-server/.agents/skills/write-pr/references/label.md @@ -0,0 +1,22 @@ +# GitHub Labels Reference + +Select **1–2 labels** from the PR-eligible list below. Do NOT use issue-only or manual labels. + +## PR-Eligible Labels (auto-selectable) + +| Label | When to use | +|-------------------|-----------------------------------------------------------| +| `enhancement:개선사항` | New feature, improvement to existing feature, refactoring | +| `bug:버그` | Bug fix | +| `documentation:문서` | Docs-only changes (README, CONTRIBUTING, comments) | + | + + +## Quick Decision + +``` +Bug fix? → bug:버그 +New feature or improvement? → enhancement:개선사항 +Docs only? → documentation:문서 +Unsure? → enhancement:개선사항 +``` \ No newline at end of file diff --git a/Fantasy-server/.agents/skills/write-pr/scripts/create-pr.sh b/Fantasy-server/.agents/skills/write-pr/scripts/create-pr.sh new file mode 100644 index 0000000..23b4415 --- /dev/null +++ b/Fantasy-server/.agents/skills/write-pr/scripts/create-pr.sh @@ -0,0 +1,41 @@ +#!/bin/bash +set -e + +TITLE="${1:?Error: PR title is required. Usage: create-pr.sh <body-file> [label1,label2,...]}" +BODY_FILE="${2:?Error: Body file is required. Usage: create-pr.sh <title> <body-file> [label1,label2,...]}" +LABELS="${3:-}" + +if [ ! -f "$BODY_FILE" ]; then + echo "ERROR: Body file not found: $BODY_FILE" >&2 + exit 1 +fi + +if ! command -v gh >/dev/null 2>&1; then + echo "ERROR: GitHub CLI (gh) is not installed." >&2 + exit 1 +fi + +CURRENT=$(git branch --show-current) +case "$CURRENT" in + feature/*) BASE="develop" ;; + develop) BASE="main" ;; + *) BASE=$(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || echo "develop") ;; +esac + +ARGS=(gh pr create --title "$TITLE" --body-file "$BODY_FILE" --base "$BASE") + +if [ -n "$LABELS" ]; then + IFS=',' read -ra LABEL_ARRAY <<< "$LABELS" + for label in "${LABEL_ARRAY[@]}"; do + trimmed=$(echo "$label" | xargs) + [ -n "$trimmed" ] && ARGS+=(--label "$trimmed") + done +fi + +echo "Creating PR..." +echo " Title : $TITLE" +echo " Base : $BASE" +[ -n "$LABELS" ] && echo " Labels: $LABELS" +echo "" + +"${ARGS[@]}" diff --git a/Fantasy-server/.agents/skills/write-pr/templates/pr-body.md b/Fantasy-server/.agents/skills/write-pr/templates/pr-body.md new file mode 100644 index 0000000..4817475 --- /dev/null +++ b/Fantasy-server/.agents/skills/write-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/Fantasy-server/.claude/rules/flows.md b/Fantasy-server/.claude/rules/flows.md index 97fdc8d..3be384f 100644 --- a/Fantasy-server/.claude/rules/flows.md +++ b/Fantasy-server/.claude/rules/flows.md @@ -1,64 +1,34 @@ -## API Flow Diagrams +## Request Flow -### POST /v1/account/signup — 회원가입 +All API endpoints follow this layer order: -```mermaid -sequenceDiagram - Client->>AccountController: POST /v1/account/signup - AccountController->>CreateAccountService: ExecuteAsync(request) - CreateAccountService->>AccountRepository: ExistsByEmailAsync(email) - AccountRepository-->>CreateAccountService: true → ConflictException - AccountRepository-->>CreateAccountService: false → 계속 - CreateAccountService->>CreateAccountService: BCrypt.HashPassword(password) - CreateAccountService->>AccountRepository: SaveAsync(account) - AccountRepository->>PostgreSQL: INSERT account - AccountController-->>Client: 201 Created +``` +Client → Controller → Service → Repository → PostgreSQL / Redis ``` -### DELETE /v1/account — 회원탈퇴 +### Layer Responsibilities -```mermaid -sequenceDiagram - Client->>AccountController: DELETE /v1/account (JWT) - AccountController->>DeleteAccountService: ExecuteAsync(request) - DeleteAccountService->>CurrentUserProvider: GetEmail() - CurrentUserProvider-->>DeleteAccountService: email (from JWT claims) - DeleteAccountService->>AccountRepository: FindByEmailAsync(email) - AccountRepository-->>DeleteAccountService: null → UnauthorizedException - AccountRepository-->>DeleteAccountService: account - DeleteAccountService->>DeleteAccountService: BCrypt.Verify(password) - DeleteAccountService->>AccountRepository: DeleteAsync(account) - AccountRepository->>PostgreSQL: DELETE account -``` +| Layer | Responsibility | +|---|---| +| Controller | Receive request, call service, return `CommonApiResponse` | +| Service | Business logic, exception handling (`NotFoundException`, `ConflictException`, etc.) | +| Repository | Exclusive DB / Redis access (`AppDbContext`, `IConnectionMultiplexer`) | + +### Authenticated Endpoints -### POST /v1/auth/login — 로그인 +Controllers or actions annotated with `[Authorize]` pass through `JwtAuthenticationFilter` on every request. +When the service needs the current user, it extracts claims via `ICurrentUserProvider`. -```mermaid -sequenceDiagram - Client->>AuthController: POST /v1/auth/login - Note over AuthController: RateLimit "login" (5 req/min) - AuthController->>LoginService: ExecuteAsync(request) - LoginService->>AccountRepository: FindByEmailAsync(email) - AccountRepository-->>LoginService: null → UnauthorizedException - AccountRepository-->>LoginService: account - LoginService->>LoginService: BCrypt.Verify(password) - LoginService->>JwtProvider: GenerateAccessToken(account) - LoginService->>JwtProvider: GenerateRefreshToken() - LoginService->>RefreshTokenRedisRepository: SaveAsync(id, refreshToken, 30d TTL) - RefreshTokenRedisRepository->>Redis: SET key value EX - AuthController-->>Client: TokenResponse (accessToken, refreshToken, expiresAt) ``` +Client → JwtAuthenticationFilter → Controller → Service → ICurrentUserProvider (claims) → Repository +``` + +### Redis Cache Pattern -### POST /v1/auth/logout — 로그아웃 +Always check Redis first on reads; only query the DB on a cache miss, then populate the cache. +After any write (update / delete), invalidate the relevant key. -```mermaid -sequenceDiagram - Client->>AuthController: POST /v1/auth/logout (JWT) - Note over AuthController: [Authorize] → JwtAuthenticationFilter - AuthController->>LogoutService: ExecuteAsync() - LogoutService->>CurrentUserProvider: GetAccountAsync() - CurrentUserProvider->>AccountRepository: FindByEmailAsync(email from claims) - AccountRepository-->>CurrentUserProvider: account - LogoutService->>RefreshTokenRedisRepository: DeleteAsync(account.Id) - RefreshTokenRedisRepository->>Redis: DEL key +``` +Read : Redis hit → return / miss → query DB → Redis SET → return +Write: update DB → Redis DEL ``` diff --git a/Fantasy-server/.claude/settings.local.json b/Fantasy-server/.claude/settings.local.json index 344db70..c5ed720 100644 --- a/Fantasy-server/.claude/settings.local.json +++ b/Fantasy-server/.claude/settings.local.json @@ -24,7 +24,8 @@ "Skill(test)", "Bash(cat /c/Users/USER/Documents/GitHub/fantasy-server/Fantasy-server/.claude/rules/*)", "Skill(commit)", - "Bash(grep -r \"0.2.8\\\\|Gamism\" . --include=*.cs --include=*.csproj --include=*.json)" + "Bash(grep -r \"0.2.8\\\\|Gamism\" . --include=*.cs --include=*.csproj --include=*.json)", + "Bash(gh api:*)" ] } } diff --git a/Fantasy-server/.claude/skills/code-review/SKILL.md b/Fantasy-server/.claude/skills/code-review/SKILL.md new file mode 100644 index 0000000..d4901d0 --- /dev/null +++ b/Fantasy-server/.claude/skills/code-review/SKILL.md @@ -0,0 +1,158 @@ +--- +name: code-review +description: Run a structured checklist over changed files against project conventions (architecture, code style, EF Core, DI, testing). Produces a ✓/⚠/✗ report in Korean. +allowed-tools: Bash(git diff:*), Bash(git log:*), Bash(git branch:*), Read, Glob, Grep +context: fork +--- + +# Code Review + +Review changed files against project conventions and produce a Korean report. + +## Step 1 — Determine Scope + +1. Get current branch: `git branch --show-current` +2. Determine base branch: + - If an argument is provided (e.g., `/code-review develop`) → use that branch as base + - Otherwise → use `main` as base +3. List changed files: `git diff {base}...HEAD --name-only` +4. Get detailed diff: `git diff {base}...HEAD` +5. Get commit list: `git log {base}..HEAD --oneline` + +## Step 2 — Read Changed Files + +- Read each changed `.cs` file with the Read tool. +- Read non-`.cs` files (`.json`, `.md`, `.csproj`) only if relevant to the checklist. + +## Step 3 — Apply Checklist + +Review only files that were actually changed. Skip categories with no relevant changes. + +--- + +### [ARCH] Architecture & Layering + +- [ ] Controllers depend only on service interfaces — no concrete service classes +- [ ] Services depend only on repository interfaces — no direct `AppDbContext` access +- [ ] Only repositories access `AppDbContext` +- [ ] New domain follows `Domain/{Name}/` structure (Config / Controller / Dto / Entity / Repository / Service) +- [ ] New domain DI extension method is called from `Program.cs` + +### [STYLE] C# Code Style + +- [ ] `var` used only when type is obvious from the right-hand side +- [ ] Private fields: `_camelCase`; everything else: `PascalCase` +- [ ] Dependencies injected via constructor into `private readonly` fields +- [ ] Constructors contain no logic — assignments only +- [ ] Single-expression methods use expression-body (`=>`) +- [ ] No XML doc comments (`///`) unless explicitly requested +- [ ] No `#region` blocks + +### [DTO] DTO Pattern + +- [ ] `record` types with positional parameters +- [ ] DataAnnotations applied directly on parameters (`[Required]`, `[MaxLength]`, etc.) +- [ ] Requests in `Dto/Request/`, Responses in `Dto/Response/` + +### [ENTITY] Entity Pattern + +- [ ] All setters are `private set` +- [ ] Static factory method `Create(...)` used instead of public constructor +- [ ] Timestamps use `DateTime.UtcNow` +- [ ] No DataAnnotations on entities — EF config via Fluent API only +- [ ] EF Fluent config implements `IEntityTypeConfiguration<T>` in `Entity/Config/` +- [ ] `DbSet<T>` registered in `AppDbContext` +- [ ] Table/column names: `snake_case`, schema-qualified (`"schema"."table"`) +- [ ] Enums use `HasConversion<string>()` + +### [SERVICE] Service Pattern + +- [ ] One use case = one class + one interface +- [ ] Interface exposes exactly one `ExecuteAsync` method +- [ ] Business exceptions use Gamism.SDK types (`ConflictException`, `NotFoundException`, etc.) +- [ ] No empty catch blocks; no bare re-throw without added context + +### [REPO] Repository Pattern + +- [ ] Read-only queries use `AsNoTracking()` +- [ ] `SaveAsync` checks Detached state before calling `AddAsync` + +### [CONTROLLER] Controller Pattern + +- [ ] Return type is `CommonApiResponse` (Gamism.SDK) +- [ ] Only service interfaces injected — no concrete classes +- [ ] Rate limiting applied with `[EnableRateLimiting("login"|"game")]` where needed +- [ ] Authenticated endpoints annotated with `[Authorize]` + +### [ASYNC] Async Pattern + +- [ ] All I/O methods are `async Task` / `async Task<T>` +- [ ] No `.Result` or `.Wait()` calls +- [ ] No unintentional fire-and-forget — every async call is awaited + +### [SECURITY] Security + +- [ ] No plain-text passwords — `BCrypt.Net.BCrypt.HashPassword` required +- [ ] No hardcoded secrets (API keys, passwords, connection strings) +- [ ] No sensitive data (passwords, tokens) written to logs + +### [REDIS] Redis Cache Pattern (only when Redis code changed) + +- [ ] Read flow: check Redis first → on miss query DB → SET cache → return +- [ ] Write flow: update DB → DEL cache key (invalidate) + +### [DI] DI Registration + +- [ ] Domain services registered via `{Name}ServiceConfig.cs` extension method +- [ ] Extension method called from `Program.cs` + +### [TEST] Tests (only when Fantasy.Test files changed) + +- [ ] Test class name: `{ServiceName}Test` +- [ ] Test method name: `{MethodName}_{Scenario}_{ExpectedResult}` +- [ ] No direct `AppDbContext` mocks — mock repository interfaces instead +- [ ] `NSubstitute` used for mocking +- [ ] Arrange / Act / Assert structure with blank line separators + +--- + +## Step 4 — Output Report + +Write the report in Korean using the following format: + +``` +## 코드 리뷰 리포트 + +### 변경 범위 +- 브랜치: {current} ← {base} +- 변경 파일 수: N개 +- 커밋: {commit summary} + +--- + +### [ARCH] 아키텍처 · 레이어링 +✓ ... +⚠ ... +✗ ... + +(repeat for each category that has changes; skip empty categories) + +--- + +### 종합 결과 +| 등급 | 건수 | +|------|------| +| ✓ 통과 | N | +| ⚠ 경고 | N | +| ✗ 오류 | N | + +총 N개 항목 검토 — 오류 N건, 경고 N건 +``` + +**Symbol meanings:** +- ✓ — convention followed +- ⚠ — recommendation (optional fix) +- ✗ — convention violation (fix required) + +For each ✗ item, include the file name, approximate line, and suggested fix. +Skip categories with no relevant changes. diff --git a/Fantasy-server/.claude/skills/plan-deep-dive/SKILL.md b/Fantasy-server/.claude/skills/plan-deep-dive/SKILL.md new file mode 100644 index 0000000..31577f5 --- /dev/null +++ b/Fantasy-server/.claude/skills/plan-deep-dive/SKILL.md @@ -0,0 +1,8 @@ +--- +name: plan-deep-dive +description: Conduct an in-depth structured interview with the user to uncover non-obvious requirements, tradeoffs, and constraints, then produce a detailed implementation spec file. +argument-hint: [instructions] +allowed-tools: AskUserQuestion, Write +--- + +Follow the user instructions and interview me in detail using the AskUserQuestionTool about literally anything: technical implementation, UI & UX, concerns, tradeoffs, etc. but make sure the questions are not obvious. be very in-depth and continue interviewing me continually until it's complete. then, write the spec to a file. <instructions>$ARGUMENTS</instructions> \ No newline at end of file diff --git a/Fantasy-server/.claude/skills/review-pr/SKILL.md b/Fantasy-server/.claude/skills/review-pr/SKILL.md new file mode 100644 index 0000000..45c416f --- /dev/null +++ b/Fantasy-server/.claude/skills/review-pr/SKILL.md @@ -0,0 +1,109 @@ +--- +name: review-pr +description: Collect PR review comments, critically assess each one against project conventions, auto-apply valid ones, post refutation replies for invalid ones, and prompt for partial ones. Replaces resolve-pr-comments. +compatibility: Requires git, gh (GitHub CLI), and jq +--- + +## Step 1 — Collect PR Data + +```bash +bash .claude/skills/review-pr/scripts/collect-pr-data.sh +``` + +Output directory: `.pr-tmp/<PR_NUMBER>/` + +Output files: +- `pr_meta.json` — PR metadata (number, title, url, base/head branch, author) +- `review_comments.json` — inline review comments (id, path, line, side, body, user, createdAt) +- `issue_comments.json` — PR-level (non-inline) comments (id, body, user, createdAt) +- `commits.txt` — commits in this PR +- `changed_files.txt` — changed file paths +- `diff.txt` — full diff + +## Step 2 — Assess Each Comment + +For each comment in `review_comments.json` (and `issue_comments.json` if it references code), apply the following **layered judgment criteria**: + +### Judgment criteria (priority order) + +1. **Project conventions** (primary): cross-reference the following rule files + - `CLAUDE.md` — tech stack, Context7 usage + - `.claude/rules/architecture.md` — directory structure, layering rules + - `.claude/rules/code-style.md` — C# naming, DTO/entity patterns, async rules + - `.claude/rules/conventions.md` — DB naming, EF Core Fluent API, DI registration, password hashing + - `.claude/rules/domain-patterns.md` — service, repository, controller implementation patterns + - `.claude/rules/global-patterns.md` — JWT, Redis, rate limiting infrastructure patterns + - `.claude/rules/testing.md` — test naming, mocking with NSubstitute, FluentAssertions +2. **Language/framework best practices** (secondary): C# / ASP.NET Core / .NET 10 official guidelines + - Apply only when no matching project rule exists + +### Verdicts + +- **VALID**: reviewer is correct → attempt auto code fix +- **INVALID**: reviewer is wrong with a clear refutation → skip, post refutation reply +- **PARTIAL**: intent is correct but application method or scope is ambiguous → confirm with AskUserQuestion + +Always cite a specific source in the rationale (e.g. `code-style.md §Naming`, `conventions.md §Entity Configuration`). + +## Step 3 — Act on Each Verdict + +### VALID → Auto fix + +1. Read the target file with the Read tool +2. Apply the reviewer's concern with the Edit tool +3. Run `/test` to verify the build and tests pass; fix any failures before continuing +4. Commit the change +5. Record the short commit hash for use in Step 5: + ```bash + git rev-parse --short HEAD + ``` + +On failure: record the reason and fall back to PARTIAL. + +### INVALID → Skip + +Do not modify any code. Record the refutation rationale for Step 5. + +### PARTIAL → Confirm with AskUserQuestion + +Use the AskUserQuestion tool to ask: + +``` +⚠️ PARTIAL: [file:line] (reviewer) +Review: "..." +Rationale: ... +Accept? (y / n / s = skip for now) +``` + +- `y`: treat as VALID, attempt code fix +- `n`: treat as INVALID, skip +- `s` / other: record as PENDING + +## Step 4 — Print Report + +``` +## review-pr Results + +| # | Reviewer | File | Verdict | Rationale | Action | +|---|----------|------|---------|-----------|--------| +| 1 | alice | Foo.cs:12 | ✅ VALID | code-style.md §Naming | Auto-fixed (abc1234) | +| 2 | bob | Bar.cs:34 | ❌ INVALID | conventions.md §Entity Configuration | Skipped | +| 3 | alice | Baz.cs:56 | ⚠️ PARTIAL | - | PENDING | +``` + +## Step 5 — Post GitHub Replies + +Post an inline reply for each `review_comments.json` entry. Always quote `comment_id` to prevent shell injection. + +```bash +gh api "repos/<owner>/<repo>/pulls/<pr_number>/comments/<comment_id>/replies" \ + -f body="<reply_body>" +``` + +For reply body templates, read `.claude/skills/review-pr/references/github-reply-formats.md`. + +## Step 6 — Cleanup + +```bash +rm -rf .pr-tmp +``` \ No newline at end of file diff --git a/Fantasy-server/.claude/skills/review-pr/references/github-reply-formats.md b/Fantasy-server/.claude/skills/review-pr/references/github-reply-formats.md new file mode 100644 index 0000000..89219dd --- /dev/null +++ b/Fantasy-server/.claude/skills/review-pr/references/github-reply-formats.md @@ -0,0 +1,39 @@ +# GitHub Reply Formats + +Use these templates when posting inline replies in Step 5. +Always quote `comment_id` to prevent shell injection. +All replies must be written in Korean. + +## VALID - fix succeeded + +```text +<abc1234> 에서 반영했습니다. (근거: <출처>) +``` + +## VALID - fix failed + +```text +지적 사항이 타당합니다. 직접 수정이 필요하여 별도 처리하겠습니다. +``` + +## INVALID + +Do not use a long rebuttal or quote repository rules verbatim. + +## PARTIAL - accepted + +```text +부분적으로 타당하다고 판단하여 <abc1234> 에서 반영했습니다. +``` + +## PARTIAL - rejected + +```text +검토 결과 이 방향으로는 적용하지 않기로 결정했습니다. +``` + +## PARTIAL - pending + +```text +검토 중입니다. 추후 답변드리겠습니다. +``` diff --git a/Fantasy-server/.claude/skills/review-pr/scripts/collect-pr-data.sh b/Fantasy-server/.claude/skills/review-pr/scripts/collect-pr-data.sh new file mode 100644 index 0000000..7bbf144 --- /dev/null +++ b/Fantasy-server/.claude/skills/review-pr/scripts/collect-pr-data.sh @@ -0,0 +1,39 @@ +#!/bin/bash +set -euo pipefail + +if ! command -v gh >/dev/null 2>&1; then + echo "ERROR: gh is required." >&2 + exit 1 +fi + +PR_NUMBER=$(gh pr view --json number -q .number 2>/dev/null || true) +if [ -z "${PR_NUMBER:-}" ]; then + echo "ERROR: No open PR found for current branch." >&2 + exit 1 +fi + +REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) +BASE=$(gh pr view "$PR_NUMBER" --json baseRefName -q .baseRefName) + +OUT_DIR=".pr-tmp/$PR_NUMBER" +mkdir -p "$OUT_DIR" + +git fetch origin "$BASE" --quiet || true + +gh pr view "$PR_NUMBER" --json number,title,url,baseRefName,headRefName,author \ + > "$OUT_DIR/pr_meta.json" + +gh api "repos/$REPO/pulls/$PR_NUMBER/comments" \ + --jq '[.[] | {id, path, line, side, body, user: .user.login, createdAt: .created_at}]' \ + > "$OUT_DIR/review_comments.json" + +gh api "repos/$REPO/issues/$PR_NUMBER/comments" \ + --jq '[.[] | {id, body, user: .user.login, createdAt: .created_at}]' \ + > "$OUT_DIR/issue_comments.json" + +git log "origin/$BASE..HEAD" --pretty=format:"%H %h %s" > "$OUT_DIR/commits.txt" +git diff "origin/$BASE...HEAD" --name-only > "$OUT_DIR/changed_files.txt" +git diff "origin/$BASE...HEAD" > "$OUT_DIR/diff.txt" + +echo "PR #$PR_NUMBER | Repo: $REPO | Base: $BASE | Output: $OUT_DIR" +echo "Review comments: $(gh api --method GET "repos/$REPO/pulls/$PR_NUMBER/comments" --jq 'length'), Issue comments: $(gh api --method GET "repos/$REPO/issues/$PR_NUMBER/comments" --jq 'length'), Changed files: $(wc -l < "$OUT_DIR/changed_files.txt" | tr -d ' ')" diff --git a/Fantasy-server/.gitignore b/Fantasy-server/.gitignore index 9a0f124..20a70b1 100644 --- a/Fantasy-server/.gitignore +++ b/Fantasy-server/.gitignore @@ -35,4 +35,6 @@ Thumbs.db Desktop.ini # Claude Code -PR_BODY.md \ No newline at end of file +PR_BODY.md +.dotnet/ +.pr-tmp/ diff --git a/Fantasy-server/AGENTS.md b/Fantasy-server/AGENTS.md new file mode 100644 index 0000000..62ef78f --- /dev/null +++ b/Fantasy-server/AGENTS.md @@ -0,0 +1,26 @@ +# Repository Guidelines + +## Project Structure & Module Organization +`Fantasy-server.sln` contains the API and test projects. Core application code lives in `Fantasy.Server/`, organized by domain under `Domain/Account`, `Domain/Auth`, and `Domain/Player`, with shared infrastructure in `Global/` and EF Core migrations in `Migrations/`. Local container setup lives in `Fantasy.Server/deploy/`. Tests live in `Fantasy.Test/`, mirroring the server by domain and layer, for example `Fantasy.Test/Auth/Service/`. + +## Build, Test, and Development Commands +Use the .NET CLI from the repository root. + +- `dotnet restore Fantasy-server.sln`: restore NuGet packages. +- `dotnet build Fantasy-server.sln`: compile the API and test project. +- `dotnet run --project Fantasy.Server`: start the API locally. +- `dotnet test Fantasy.Test`: run the xUnit suite. +- `dotnet test Fantasy.Test --collect:"XPlat Code Coverage"`: run tests with Coverlet coverage output. +- `docker compose -f Fantasy.Server/deploy/compose.dev.yaml up --build`: start PostgreSQL, Redis, and the API with the development settings. + +## Coding Style & Naming Conventions +Use 4-space indentation and nullable-enabled C# conventions. Prefer `PascalCase` for types, methods, and properties, `camelCase` for locals and parameters, `_camelCase` for private fields, and `IPascalCase` for interfaces. Use `var` only when the type is obvious. DTOs should be positional `record` types in `Dto/Request` or `Dto/Response`; entities should expose `private set` and use static `Create(...)` factories. EF Core table names use `snake_case`, and configuration classes belong in `Domain/{Name}/Entity/Config/`. + +## Testing Guidelines +The test stack is xUnit v3 with FluentAssertions, NSubstitute, and Coverlet. Place service tests under the matching domain folder and name files `*Tests.cs`, for example `CreateAccountServiceTests.cs`. Name test methods as `Method_Scenario_ExpectedResult`. Mock repository interfaces, not `AppDbContext`, and keep Arrange / Act / Assert blocks visually separated. + +## Commit & Pull Request Guidelines +Follow the repository commit convention: `{type}: {Korean imperative summary}`. Common prefixes are `feat`, `fix`, `update`, `docs`, `chore`, and `cicd`. Keep commits focused on one logical change and avoid mixing feature work with unrelated fixes. PRs should explain the behavior change, list validation steps such as `dotnet test Fantasy.Test`, reference the related issue, and include request/response samples when an API contract changes. + +## Security & Configuration Tips +Do not commit real secrets. Use `Fantasy.Server/appsettings.Development.json` only for local defaults, and override database, Redis, and JWT settings through environment variables or compose files for shared environments. diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Config/PlayerServiceConfig.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Config/PlayerServiceConfig.cs new file mode 100644 index 0000000..fb243a3 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Config/PlayerServiceConfig.cs @@ -0,0 +1,30 @@ +using Fantasy.Server.Domain.Player.Repository; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service; +using Fantasy.Server.Domain.Player.Service.Interface; + +namespace Fantasy.Server.Domain.Player.Config; + +public static class PlayerServiceConfig +{ + public static IServiceCollection AddPlayerServices(this IServiceCollection services) + { + services.AddScoped<IPlayerRepository, PlayerRepository>(); + services.AddScoped<IPlayerResourceRepository, PlayerResourceRepository>(); + services.AddScoped<IPlayerStageRepository, PlayerStageRepository>(); + services.AddScoped<IPlayerSessionRepository, PlayerSessionRepository>(); + services.AddScoped<IPlayerWeaponRepository, PlayerWeaponRepository>(); + services.AddScoped<IPlayerSkillRepository, PlayerSkillRepository>(); + services.AddScoped<IPlayerRedisRepository, PlayerRedisRepository>(); + + services.AddScoped<IInitPlayerService, InitPlayerService>(); + services.AddScoped<IEndPlayerSessionService, EndPlayerSessionService>(); + services.AddScoped<IUpdatePlayerLevelService, UpdatePlayerLevelService>(); + services.AddScoped<IUpdatePlayerStageService, UpdatePlayerStageService>(); + services.AddScoped<IUpdatePlayerResourceService, UpdatePlayerResourceService>(); + services.AddScoped<IUpdatePlayerWeaponService, UpdatePlayerWeaponService>(); + services.AddScoped<IUpdatePlayerSkillService, UpdatePlayerSkillService>(); + + return services; + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerController.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerController.cs new file mode 100644 index 0000000..93d1cad --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerController.cs @@ -0,0 +1,43 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Dto.Response; +using Fantasy.Server.Domain.Player.Service.Interface; +using Gamism.SDK.Core.Network; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace Fantasy.Server.Domain.Player.Controller; + +[ApiController] +[Route("v1/player")] +[Authorize] +[EnableRateLimiting("game")] +public class PlayerController : ControllerBase +{ + private readonly IInitPlayerService _initPlayerService; + private readonly IUpdatePlayerLevelService _updatePlayerLevelService; + + public PlayerController( + IInitPlayerService initPlayerService, + IUpdatePlayerLevelService updatePlayerLevelService) + { + _initPlayerService = initPlayerService; + _updatePlayerLevelService = updatePlayerLevelService; + } + + [HttpPost("init")] + public async Task<CommonApiResponse<PlayerDataResponse>> Init([FromBody] InitPlayerRequest request) + { + var (data, isNew) = await _initPlayerService.ExecuteAsync(request); + return isNew + ? CommonApiResponse.Created("플레이어가 생성되었습니다.", data) + : CommonApiResponse.Success("플레이어 데이터를 불러왔습니다.", data); + } + + [HttpPatch("level")] + public async Task<CommonApiResponse> UpdateLevel([FromBody] UpdatePlayerLevelRequest request) + { + await _updatePlayerLevelService.ExecuteAsync(request); + return CommonApiResponse.Success("레벨이 업데이트되었습니다."); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerResourceController.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerResourceController.cs new file mode 100644 index 0000000..eb6e01d --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerResourceController.cs @@ -0,0 +1,29 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Service.Interface; +using Gamism.SDK.Core.Network; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace Fantasy.Server.Domain.Player.Controller; + +[ApiController] +[Route("v1/player")] +[Authorize] +[EnableRateLimiting("game")] +public class PlayerResourceController : ControllerBase +{ + private readonly IUpdatePlayerResourceService _updatePlayerResourceService; + + public PlayerResourceController(IUpdatePlayerResourceService updatePlayerResourceService) + { + _updatePlayerResourceService = updatePlayerResourceService; + } + + [HttpPatch("resource")] + public async Task<CommonApiResponse> UpdateResource([FromBody] UpdatePlayerResourceRequest request) + { + await _updatePlayerResourceService.ExecuteAsync(request); + return CommonApiResponse.Success("재화가 업데이트되었습니다."); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerSessionController.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerSessionController.cs new file mode 100644 index 0000000..26a6086 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerSessionController.cs @@ -0,0 +1,29 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Service.Interface; +using Gamism.SDK.Core.Network; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace Fantasy.Server.Domain.Player.Controller; + +[ApiController] +[Route("v1/player")] +[Authorize] +[EnableRateLimiting("game")] +public class PlayerSessionController : ControllerBase +{ + private readonly IEndPlayerSessionService _endPlayerSessionService; + + public PlayerSessionController(IEndPlayerSessionService endPlayerSessionService) + { + _endPlayerSessionService = endPlayerSessionService; + } + + [HttpPatch("session/end")] + public async Task<CommonApiResponse> EndSession([FromBody] EndPlayerSessionRequest request) + { + await _endPlayerSessionService.ExecuteAsync(request); + return CommonApiResponse.Success("게임 종료 데이터가 저장되었습니다."); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerSkillController.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerSkillController.cs new file mode 100644 index 0000000..2ec6039 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerSkillController.cs @@ -0,0 +1,29 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Service.Interface; +using Gamism.SDK.Core.Network; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace Fantasy.Server.Domain.Player.Controller; + +[ApiController] +[Route("v1/player")] +[Authorize] +[EnableRateLimiting("game")] +public class PlayerSkillController : ControllerBase +{ + private readonly IUpdatePlayerSkillService _updatePlayerSkillService; + + public PlayerSkillController(IUpdatePlayerSkillService updatePlayerSkillService) + { + _updatePlayerSkillService = updatePlayerSkillService; + } + + [HttpPatch("skill")] + public async Task<CommonApiResponse> UpdateSkill([FromBody] UpdatePlayerSkillRequest request) + { + await _updatePlayerSkillService.ExecuteAsync(request); + return CommonApiResponse.Success("스킬 정보가 업데이트되었습니다."); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerStageController.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerStageController.cs new file mode 100644 index 0000000..109a393 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerStageController.cs @@ -0,0 +1,29 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Service.Interface; +using Gamism.SDK.Core.Network; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace Fantasy.Server.Domain.Player.Controller; + +[ApiController] +[Route("v1/player")] +[Authorize] +[EnableRateLimiting("game")] +public class PlayerStageController : ControllerBase +{ + private readonly IUpdatePlayerStageService _updatePlayerStageService; + + public PlayerStageController(IUpdatePlayerStageService updatePlayerStageService) + { + _updatePlayerStageService = updatePlayerStageService; + } + + [HttpPatch("stage")] + public async Task<CommonApiResponse> UpdateStage([FromBody] UpdatePlayerStageRequest request) + { + await _updatePlayerStageService.ExecuteAsync(request); + return CommonApiResponse.Success("스테이지가 업데이트되었습니다."); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerWeaponController.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerWeaponController.cs new file mode 100644 index 0000000..47fd67b --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerWeaponController.cs @@ -0,0 +1,29 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Service.Interface; +using Gamism.SDK.Core.Network; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace Fantasy.Server.Domain.Player.Controller; + +[ApiController] +[Route("v1/player")] +[Authorize] +[EnableRateLimiting("game")] +public class PlayerWeaponController : ControllerBase +{ + private readonly IUpdatePlayerWeaponService _updatePlayerWeaponService; + + public PlayerWeaponController(IUpdatePlayerWeaponService updatePlayerWeaponService) + { + _updatePlayerWeaponService = updatePlayerWeaponService; + } + + [HttpPatch("weapon")] + public async Task<CommonApiResponse> UpdateWeapon([FromBody] UpdatePlayerWeaponRequest request) + { + await _updatePlayerWeaponService.ExecuteAsync(request); + return CommonApiResponse.Success("무기 정보가 업데이트되었습니다."); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/EndPlayerSessionRequest.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/EndPlayerSessionRequest.cs new file mode 100644 index 0000000..84d6635 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/EndPlayerSessionRequest.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.Player.Dto.Request; + +public record EndPlayerSessionRequest( + [Required] JobType JobType, + [Required] int LastWeaponId, + [Required] int[] ActiveSkills, + [Range(0, long.MaxValue)] long? Gold, + [Range(0, long.MaxValue)] long? Exp +); diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/InitPlayerRequest.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/InitPlayerRequest.cs new file mode 100644 index 0000000..fb29731 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/InitPlayerRequest.cs @@ -0,0 +1,8 @@ +using System.ComponentModel.DataAnnotations; +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.Player.Dto.Request; + +public record InitPlayerRequest( + [Required] JobType JobType +); diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/PlayerChangeItems.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/PlayerChangeItems.cs new file mode 100644 index 0000000..f5c416a --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/PlayerChangeItems.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fantasy.Server.Domain.Player.Dto.Request; + +public record WeaponChangeItem( + [Required] int WeaponId, + [Range(0, long.MaxValue)] long Count, + [Range(0, long.MaxValue)] long EnhancementLevel, + [Range(0, long.MaxValue)] long AwakeningCount +); + +public record SkillChangeItem( + [Required] int SkillId, + [Required] bool IsUnlocked +); diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerLevelRequest.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerLevelRequest.cs new file mode 100644 index 0000000..806e492 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerLevelRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.Player.Dto.Request; + +public record UpdatePlayerLevelRequest( + [Required] JobType JobType, + [Range(1, long.MaxValue)] long Level +); diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerResourceRequest.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerResourceRequest.cs new file mode 100644 index 0000000..f1bfc41 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerResourceRequest.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.Player.Dto.Request; + +public record UpdatePlayerResourceRequest( + [Required] JobType JobType, + [Range(0, long.MaxValue)] long? EnhancementScroll, + [Range(0, long.MaxValue)] long? Mithril, + [Range(0, long.MaxValue)] long? Sp +); diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerSkillRequest.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerSkillRequest.cs new file mode 100644 index 0000000..bf2ecd2 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerSkillRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.Player.Dto.Request; + +public record UpdatePlayerSkillRequest( + [Required] JobType JobType, + [Required] List<SkillChangeItem> Skills +); diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerStageRequest.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerStageRequest.cs new file mode 100644 index 0000000..785db4c --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerStageRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.Player.Dto.Request; + +public record UpdatePlayerStageRequest( + [Required] JobType JobType, + [Range(1, long.MaxValue)] long MaxStage +); diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerWeaponRequest.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerWeaponRequest.cs new file mode 100644 index 0000000..c87f61c --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerWeaponRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.Player.Dto.Request; + +public record UpdatePlayerWeaponRequest( + [Required] JobType JobType, + [Required] List<WeaponChangeItem> Weapons +); diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Response/PlayerDataResponse.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Response/PlayerDataResponse.cs new file mode 100644 index 0000000..7a15a8d --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Response/PlayerDataResponse.cs @@ -0,0 +1,22 @@ +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.Player.Dto.Response; + +public record PlayerDataResponse( + JobType JobType, + long Level, + long MaxStage, + int? LastWeaponId, + int[] ActiveSkills, + long Gold, + long Exp, + long EnhancementScroll, + long Mithril, + long Sp, + List<WeaponInfoResponse> Weapons, + List<SkillInfoResponse> Skills +); + +public record WeaponInfoResponse(int WeaponId, long Count, long EnhancementLevel, long AwakeningCount); + +public record SkillInfoResponse(int SkillId, bool IsUnlocked); diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerConfig.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerConfig.cs new file mode 100644 index 0000000..c121610 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerConfig.cs @@ -0,0 +1,43 @@ +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Enum; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fantasy.Server.Domain.Player.Entity.Config; + +public class PlayerConfig : IEntityTypeConfiguration<Player> +{ + public void Configure(EntityTypeBuilder<Player> builder) + { + builder.ToTable("player", "player"); + + builder.HasKey(p => p.Id); + + builder.Property(p => p.Id) + .ValueGeneratedOnAdd(); + + builder.Property(p => p.AccountId) + .IsRequired(); + + builder.Property(p => p.JobType) + .IsRequired() + .HasConversion<string>(); + + builder.Property(p => p.Level) + .IsRequired() + .HasDefaultValue(1L); + + builder.Property(p => p.Exp) + .IsRequired() + .HasDefaultValue(0L); + + builder.Property(p => p.CreatedAt) + .IsRequired(); + + builder.Property(p => p.UpdatedAt) + .IsRequired(); + + builder.HasIndex(p => new { p.AccountId, p.JobType }) + .IsUnique(); + } +} \ No newline at end of file diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerResourceConfig.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerResourceConfig.cs new file mode 100644 index 0000000..89e93cd --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerResourceConfig.cs @@ -0,0 +1,48 @@ +using Fantasy.Server.Domain.Player.Entity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fantasy.Server.Domain.Player.Entity.Config; + +public class PlayerResourceConfig : IEntityTypeConfiguration<PlayerResource> +{ + public void Configure(EntityTypeBuilder<PlayerResource> builder) + { + builder.ToTable("player_resource", "player"); + + builder.HasKey(r => r.Id); + + builder.Property(r => r.Id) + .ValueGeneratedOnAdd(); + + builder.Property(r => r.PlayerId) + .IsRequired(); + + builder.Property(r => r.Gold) + .IsRequired() + .HasDefaultValue(0L); + + builder.Property(r => r.EnhancementScroll) + .IsRequired() + .HasDefaultValue(0L); + + builder.Property(r => r.Mithril) + .IsRequired() + .HasDefaultValue(0L); + + builder.Property(r => r.Sp) + .IsRequired() + .HasDefaultValue(0L); + + builder.Property(r => r.UpdatedAt) + .IsRequired(); + + builder.HasIndex(r => r.PlayerId) + .IsUnique(); + + builder.HasOne<Player>() + .WithOne() + .HasForeignKey<PlayerResource>(r => r.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + } +} \ No newline at end of file diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerSessionConfig.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerSessionConfig.cs new file mode 100644 index 0000000..2d77755 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerSessionConfig.cs @@ -0,0 +1,38 @@ +using Fantasy.Server.Domain.Player.Entity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fantasy.Server.Domain.Player.Entity.Config; + +public class PlayerSessionConfig : IEntityTypeConfiguration<PlayerSession> +{ + public void Configure(EntityTypeBuilder<PlayerSession> builder) + { + builder.ToTable("player_session", "player"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id) + .ValueGeneratedOnAdd(); + + builder.Property(s => s.PlayerId) + .IsRequired(); + + builder.Property(s => s.LastWeaponId); + + builder.Property(s => s.ActiveSkills) + .IsRequired() + .HasDefaultValueSql("ARRAY[]::integer[]"); + + builder.Property(s => s.UpdatedAt) + .IsRequired(); + + builder.HasIndex(s => s.PlayerId) + .IsUnique(); + + builder.HasOne<Player>() + .WithOne() + .HasForeignKey<PlayerSession>(s => s.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerSkillConfig.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerSkillConfig.cs new file mode 100644 index 0000000..bf4d031 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerSkillConfig.cs @@ -0,0 +1,36 @@ +using Fantasy.Server.Domain.Player.Entity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fantasy.Server.Domain.Player.Entity.Config; + +public class PlayerSkillConfig : IEntityTypeConfiguration<PlayerSkill> +{ + public void Configure(EntityTypeBuilder<PlayerSkill> builder) + { + builder.ToTable("player_skill", "player"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id) + .ValueGeneratedOnAdd(); + + builder.Property(s => s.PlayerId) + .IsRequired(); + + builder.Property(s => s.SkillId) + .IsRequired(); + + builder.Property(s => s.IsUnlocked) + .IsRequired() + .HasDefaultValue(false); + + builder.HasIndex(s => new { s.PlayerId, s.SkillId }) + .IsUnique(); + + builder.HasOne<Player>() + .WithMany() + .HasForeignKey(s => s.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerStageConfig.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerStageConfig.cs new file mode 100644 index 0000000..883934e --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerStageConfig.cs @@ -0,0 +1,33 @@ +using Fantasy.Server.Domain.Player.Entity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fantasy.Server.Domain.Player.Entity.Config; + +public class PlayerStageConfig : IEntityTypeConfiguration<PlayerStage> +{ + public void Configure(EntityTypeBuilder<PlayerStage> builder) + { + builder.ToTable("player_stage", "player"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id) + .ValueGeneratedOnAdd(); + + builder.Property(s => s.PlayerId) + .IsRequired(); + + builder.Property(s => s.MaxStage) + .IsRequired() + .HasDefaultValue(1L); + + builder.HasIndex(s => s.PlayerId) + .IsUnique(); + + builder.HasOne<Player>() + .WithOne() + .HasForeignKey<PlayerStage>(s => s.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerWeaponConfig.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerWeaponConfig.cs new file mode 100644 index 0000000..679b0b3 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerWeaponConfig.cs @@ -0,0 +1,44 @@ +using Fantasy.Server.Domain.Player.Entity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fantasy.Server.Domain.Player.Entity.Config; + +public class PlayerWeaponConfig : IEntityTypeConfiguration<PlayerWeapon> +{ + public void Configure(EntityTypeBuilder<PlayerWeapon> builder) + { + builder.ToTable("player_weapon", "player"); + + builder.HasKey(w => w.Id); + + builder.Property(w => w.Id) + .ValueGeneratedOnAdd(); + + builder.Property(w => w.PlayerId) + .IsRequired(); + + builder.Property(w => w.WeaponId) + .IsRequired(); + + builder.Property(w => w.Count) + .IsRequired() + .HasDefaultValue(0L); + + builder.Property(w => w.EnhancementLevel) + .IsRequired() + .HasDefaultValue(0L); + + builder.Property(w => w.AwakeningCount) + .IsRequired() + .HasDefaultValue(0L); + + builder.HasIndex(w => new { w.PlayerId, w.WeaponId }) + .IsUnique(); + + builder.HasOne<Player>() + .WithMany() + .HasForeignKey(w => w.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Player.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Player.cs new file mode 100644 index 0000000..4967c2a --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Player.cs @@ -0,0 +1,36 @@ +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.Player.Entity; + +public class Player +{ + public long Id { get; private set; } + public long AccountId { get; private set; } + public JobType JobType { get; private set; } + public long Level { get; private set; } + public long Exp { get; private set; } + public DateTime CreatedAt { get; private set; } + public DateTime UpdatedAt { get; private set; } + + public static Player Create(long accountId, JobType jobType) => new() + { + AccountId = accountId, + JobType = jobType, + Level = 1, + Exp = 0, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + public void UpdateLevel(long level) + { + Level = level; + UpdatedAt = DateTime.UtcNow; + } + + public void UpdateExp(long exp) + { + Exp = exp; + UpdatedAt = DateTime.UtcNow; + } +} \ No newline at end of file diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerResource.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerResource.cs new file mode 100644 index 0000000..f61a60c --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerResource.cs @@ -0,0 +1,36 @@ +namespace Fantasy.Server.Domain.Player.Entity; + +public class PlayerResource +{ + public long Id { get; private set; } + public long PlayerId { get; private set; } + public long Gold { get; private set; } + public long EnhancementScroll { get; private set; } + public long Mithril { get; private set; } + public long Sp { get; private set; } + public DateTime UpdatedAt { get; private set; } + + public static PlayerResource Create(long playerId) => new() + { + PlayerId = playerId, + Gold = 0, + EnhancementScroll = 0, + Mithril = 0, + Sp = 0, + UpdatedAt = DateTime.UtcNow + }; + + public void UpdateGold(long gold) + { + Gold = gold; + UpdatedAt = DateTime.UtcNow; + } + + public void UpdateChangeData(long? enhancementScroll, long? mithril, long? sp) + { + if (enhancementScroll.HasValue) EnhancementScroll = enhancementScroll.Value; + if (mithril.HasValue) Mithril = mithril.Value; + if (sp.HasValue) Sp = sp.Value; + UpdatedAt = DateTime.UtcNow; + } +} \ No newline at end of file diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerSession.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerSession.cs new file mode 100644 index 0000000..fc58502 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerSession.cs @@ -0,0 +1,25 @@ +namespace Fantasy.Server.Domain.Player.Entity; + +public class PlayerSession +{ + public long Id { get; private set; } + public long PlayerId { get; private set; } + public int? LastWeaponId { get; private set; } + public int[] ActiveSkills { get; private set; } = []; + public DateTime UpdatedAt { get; private set; } + + public static PlayerSession Create(long playerId) => new() + { + PlayerId = playerId, + LastWeaponId = null, + ActiveSkills = [], + UpdatedAt = DateTime.UtcNow + }; + + public void Update(int lastWeaponId, int[] activeSkills) + { + LastWeaponId = lastWeaponId; + ActiveSkills = activeSkills; + UpdatedAt = DateTime.UtcNow; + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerSkill.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerSkill.cs new file mode 100644 index 0000000..4d5c7ee --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerSkill.cs @@ -0,0 +1,21 @@ +namespace Fantasy.Server.Domain.Player.Entity; + +public class PlayerSkill +{ + public long Id { get; private set; } + public long PlayerId { get; private set; } + public int SkillId { get; private set; } + public bool IsUnlocked { get; private set; } + + public static PlayerSkill Create(long playerId, int skillId, bool isUnlocked) => new() + { + PlayerId = playerId, + SkillId = skillId, + IsUnlocked = isUnlocked + }; + + public void Update(bool isUnlocked) + { + IsUnlocked = isUnlocked; + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerStage.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerStage.cs new file mode 100644 index 0000000..6ba3064 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerStage.cs @@ -0,0 +1,19 @@ +namespace Fantasy.Server.Domain.Player.Entity; + +public class PlayerStage +{ + public long Id { get; private set; } + public long PlayerId { get; private set; } + public long MaxStage { get; private set; } + + public static PlayerStage Create(long playerId) => new() + { + PlayerId = playerId, + MaxStage = 1 + }; + + public void Update(long maxStage) + { + MaxStage = maxStage; + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerWeapon.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerWeapon.cs new file mode 100644 index 0000000..8ec6fe0 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerWeapon.cs @@ -0,0 +1,27 @@ +namespace Fantasy.Server.Domain.Player.Entity; + +public class PlayerWeapon +{ + public long Id { get; private set; } + public long PlayerId { get; private set; } + public int WeaponId { get; private set; } + public long Count { get; private set; } + public long EnhancementLevel { get; private set; } + public long AwakeningCount { get; private set; } + + public static PlayerWeapon Create(long playerId, int weaponId, long count, long enhancementLevel, long awakeningCount) => new() + { + PlayerId = playerId, + WeaponId = weaponId, + Count = count, + EnhancementLevel = enhancementLevel, + AwakeningCount = awakeningCount + }; + + public void Update(long count, long enhancementLevel, long awakeningCount) + { + Count = count; + EnhancementLevel = enhancementLevel; + AwakeningCount = awakeningCount; + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Enum/ArcherSkillId.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/ArcherSkillId.cs new file mode 100644 index 0000000..eb2dd89 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/ArcherSkillId.cs @@ -0,0 +1,7 @@ +namespace Fantasy.Server.Domain.Player.Enum; + +public enum ArcherSkillId +{ + MultiShot = 1, + Dodge = 2 +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Enum/ArcherWeaponId.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/ArcherWeaponId.cs new file mode 100644 index 0000000..100467d --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/ArcherWeaponId.cs @@ -0,0 +1,7 @@ +namespace Fantasy.Server.Domain.Player.Enum; + +public enum ArcherWeaponId +{ + Bow = 1, + Crossbow = 2 +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Enum/JobType.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/JobType.cs new file mode 100644 index 0000000..a57b546 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/JobType.cs @@ -0,0 +1,8 @@ +namespace Fantasy.Server.Domain.Player.Enum; + +public enum JobType +{ + Warrior, + Archer, + Mage +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Enum/MageSkillId.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/MageSkillId.cs new file mode 100644 index 0000000..d437d87 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/MageSkillId.cs @@ -0,0 +1,7 @@ +namespace Fantasy.Server.Domain.Player.Enum; + +public enum MageSkillId +{ + Fireball = 1, + IceBolt = 2 +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Enum/MageWeaponId.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/MageWeaponId.cs new file mode 100644 index 0000000..f38c5e3 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/MageWeaponId.cs @@ -0,0 +1,7 @@ +namespace Fantasy.Server.Domain.Player.Enum; + +public enum MageWeaponId +{ + Staff = 1, + Wand = 2 +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Enum/WarriorSkillId.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/WarriorSkillId.cs new file mode 100644 index 0000000..11778fb --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/WarriorSkillId.cs @@ -0,0 +1,7 @@ +namespace Fantasy.Server.Domain.Player.Enum; + +public enum WarriorSkillId +{ + SlashAttack = 1, + ShieldBlock = 2 +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Enum/WarriorWeaponId.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/WarriorWeaponId.cs new file mode 100644 index 0000000..0f294dc --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/WarriorWeaponId.cs @@ -0,0 +1,7 @@ +namespace Fantasy.Server.Domain.Player.Enum; + +public enum WarriorWeaponId +{ + Sword = 1, + GreatSword = 2 +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerRedisRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerRedisRepository.cs new file mode 100644 index 0000000..80e13a8 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerRedisRepository.cs @@ -0,0 +1,11 @@ +using Fantasy.Server.Domain.Player.Dto.Response; +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.Player.Repository.Interface; + +public interface IPlayerRedisRepository +{ + Task SetPlayerDataAsync(long accountId, JobType jobType, PlayerDataResponse data); + Task<PlayerDataResponse?> GetPlayerDataAsync(long accountId, JobType jobType); + Task DeleteAsync(long accountId, JobType jobType); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerRepository.cs new file mode 100644 index 0000000..ec56bca --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerRepository.cs @@ -0,0 +1,11 @@ +using Fantasy.Server.Domain.Player.Enum; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Server.Domain.Player.Repository.Interface; + +public interface IPlayerRepository +{ + Task<PlayerEntity?> FindByAccountAndJobAsync(long accountId, JobType jobType); + Task<PlayerEntity> SaveAsync(PlayerEntity player); + Task UpdateAsync(PlayerEntity player); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerResourceRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerResourceRepository.cs new file mode 100644 index 0000000..c851f05 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerResourceRepository.cs @@ -0,0 +1,10 @@ +using Fantasy.Server.Domain.Player.Entity; + +namespace Fantasy.Server.Domain.Player.Repository.Interface; + +public interface IPlayerResourceRepository +{ + Task<PlayerResource?> FindByPlayerIdAsync(long playerId); + Task<PlayerResource> SaveAsync(PlayerResource resource); + Task UpdateAsync(PlayerResource resource); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerSessionRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerSessionRepository.cs new file mode 100644 index 0000000..4d37366 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerSessionRepository.cs @@ -0,0 +1,10 @@ +using Fantasy.Server.Domain.Player.Entity; + +namespace Fantasy.Server.Domain.Player.Repository.Interface; + +public interface IPlayerSessionRepository +{ + Task<PlayerSession?> FindByPlayerIdAsync(long playerId); + Task<PlayerSession> SaveAsync(PlayerSession session); + Task UpdateAsync(PlayerSession session); +} \ No newline at end of file diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerSkillRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerSkillRepository.cs new file mode 100644 index 0000000..df828fc --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerSkillRepository.cs @@ -0,0 +1,10 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Entity; + +namespace Fantasy.Server.Domain.Player.Repository.Interface; + +public interface IPlayerSkillRepository +{ + Task<List<PlayerSkill>> FindAllByPlayerIdAsync(long playerId); + Task UpsertRangeAsync(long playerId, List<SkillChangeItem> items); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerStageRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerStageRepository.cs new file mode 100644 index 0000000..6b7b849 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerStageRepository.cs @@ -0,0 +1,10 @@ +using Fantasy.Server.Domain.Player.Entity; + +namespace Fantasy.Server.Domain.Player.Repository.Interface; + +public interface IPlayerStageRepository +{ + Task<PlayerStage?> FindByPlayerIdAsync(long playerId); + Task<PlayerStage> SaveAsync(PlayerStage stage); + Task UpdateAsync(PlayerStage stage); +} \ No newline at end of file diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerWeaponRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerWeaponRepository.cs new file mode 100644 index 0000000..3c16d14 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerWeaponRepository.cs @@ -0,0 +1,10 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Entity; + +namespace Fantasy.Server.Domain.Player.Repository.Interface; + +public interface IPlayerWeaponRepository +{ + Task<List<PlayerWeapon>> FindAllByPlayerIdAsync(long playerId); + Task UpsertRangeAsync(long playerId, List<WeaponChangeItem> items); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerRedisRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerRedisRepository.cs new file mode 100644 index 0000000..7cbd962 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerRedisRepository.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using Fantasy.Server.Domain.Player.Dto.Response; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using StackExchange.Redis; + +namespace Fantasy.Server.Domain.Player.Repository; + +public class PlayerRedisRepository : IPlayerRedisRepository +{ + private const string Prefix = "fantasy:player:"; + + private readonly IDatabase _db; + + public PlayerRedisRepository(IConnectionMultiplexer multiplexer) + { + _db = multiplexer.GetDatabase(); + } + + private static string CacheKey(long accountId, JobType jobType) => + $"{Prefix}{accountId}:{jobType}"; + + public async Task SetPlayerDataAsync(long accountId, JobType jobType, PlayerDataResponse data) + { + var json = JsonSerializer.Serialize(data); + await _db.StringSetAsync(CacheKey(accountId, jobType), json, TimeSpan.FromMinutes(30)); + } + + public async Task<PlayerDataResponse?> GetPlayerDataAsync(long accountId, JobType jobType) + { + var json = await _db.StringGetAsync(CacheKey(accountId, jobType)); + if (!json.HasValue) + return null; + return JsonSerializer.Deserialize<PlayerDataResponse>(json.ToString()); + } + + public async Task DeleteAsync(long accountId, JobType jobType) + { + await _db.KeyDeleteAsync(CacheKey(accountId, jobType)); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerRepository.cs new file mode 100644 index 0000000..9815849 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerRepository.cs @@ -0,0 +1,33 @@ +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; +using Microsoft.EntityFrameworkCore; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Server.Domain.Player.Repository; + +public class PlayerRepository : IPlayerRepository +{ + private readonly AppDbContext _db; + + public PlayerRepository(AppDbContext db) => _db = db; + + public async Task<PlayerEntity?> FindByAccountAndJobAsync(long accountId, JobType jobType) + => await _db.Players + .AsNoTracking() + .FirstOrDefaultAsync(p => p.AccountId == accountId && p.JobType == jobType); + + public async Task<PlayerEntity> SaveAsync(PlayerEntity player) + { + if (_db.Players.Entry(player).State == EntityState.Detached) + await _db.Players.AddAsync(player); + await _db.SaveChangesAsync(); + return player; + } + + public async Task UpdateAsync(PlayerEntity player) + { + _db.Players.Update(player); + await _db.SaveChangesAsync(); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerResourceRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerResourceRepository.cs new file mode 100644 index 0000000..f9e8b50 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerResourceRepository.cs @@ -0,0 +1,32 @@ +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace Fantasy.Server.Domain.Player.Repository; + +public class PlayerResourceRepository : IPlayerResourceRepository +{ + private readonly AppDbContext _db; + + public PlayerResourceRepository(AppDbContext db) => _db = db; + + public async Task<PlayerResource?> FindByPlayerIdAsync(long playerId) + => await _db.PlayerResources + .AsNoTracking() + .FirstOrDefaultAsync(r => r.PlayerId == playerId); + + public async Task<PlayerResource> SaveAsync(PlayerResource resource) + { + if (_db.PlayerResources.Entry(resource).State == EntityState.Detached) + await _db.PlayerResources.AddAsync(resource); + await _db.SaveChangesAsync(); + return resource; + } + + public async Task UpdateAsync(PlayerResource resource) + { + _db.PlayerResources.Update(resource); + await _db.SaveChangesAsync(); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerSessionRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerSessionRepository.cs new file mode 100644 index 0000000..70e2e6a --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerSessionRepository.cs @@ -0,0 +1,32 @@ +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace Fantasy.Server.Domain.Player.Repository; + +public class PlayerSessionRepository : IPlayerSessionRepository +{ + private readonly AppDbContext _db; + + public PlayerSessionRepository(AppDbContext db) => _db = db; + + public async Task<PlayerSession?> FindByPlayerIdAsync(long playerId) + => await _db.PlayerSessions + .AsNoTracking() + .FirstOrDefaultAsync(s => s.PlayerId == playerId); + + public async Task<PlayerSession> SaveAsync(PlayerSession session) + { + if (_db.PlayerSessions.Entry(session).State == EntityState.Detached) + await _db.PlayerSessions.AddAsync(session); + await _db.SaveChangesAsync(); + return session; + } + + public async Task UpdateAsync(PlayerSession session) + { + _db.PlayerSessions.Update(session); + await _db.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerSkillRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerSkillRepository.cs new file mode 100644 index 0000000..523a597 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerSkillRepository.cs @@ -0,0 +1,48 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace Fantasy.Server.Domain.Player.Repository; + +public class PlayerSkillRepository : IPlayerSkillRepository +{ + private readonly AppDbContext _db; + + public PlayerSkillRepository(AppDbContext db) => _db = db; + + public async Task<List<PlayerSkill>> FindAllByPlayerIdAsync(long playerId) + => await _db.PlayerSkills + .AsNoTracking() + .Where(skill => skill.PlayerId == playerId) + .ToListAsync(); + + public async Task UpsertRangeAsync(long playerId, List<SkillChangeItem> items) + { + List<SkillChangeItem> normalizedItems = items + .GroupBy(item => item.SkillId) + .Select(group => group.Last()) + .ToList(); + + List<int> skillIds = normalizedItems.Select(item => item.SkillId).ToList(); + Dictionary<int, PlayerSkill> existing = await _db.PlayerSkills + .Where(skill => skill.PlayerId == playerId && skillIds.Contains(skill.SkillId)) + .ToDictionaryAsync(skill => skill.SkillId); + + foreach (SkillChangeItem item in normalizedItems) + { + if (existing.TryGetValue(item.SkillId, out PlayerSkill? skill)) + { + skill.Update(item.IsUnlocked); + _db.PlayerSkills.Update(skill); + continue; + } + + await _db.PlayerSkills.AddAsync( + PlayerSkill.Create(playerId, item.SkillId, item.IsUnlocked)); + } + + await _db.SaveChangesAsync(); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerStageRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerStageRepository.cs new file mode 100644 index 0000000..dc0199a --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerStageRepository.cs @@ -0,0 +1,32 @@ +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace Fantasy.Server.Domain.Player.Repository; + +public class PlayerStageRepository : IPlayerStageRepository +{ + private readonly AppDbContext _db; + + public PlayerStageRepository(AppDbContext db) => _db = db; + + public async Task<PlayerStage?> FindByPlayerIdAsync(long playerId) + => await _db.PlayerStages + .AsNoTracking() + .FirstOrDefaultAsync(s => s.PlayerId == playerId); + + public async Task<PlayerStage> SaveAsync(PlayerStage stage) + { + if (_db.PlayerStages.Entry(stage).State == EntityState.Detached) + await _db.PlayerStages.AddAsync(stage); + await _db.SaveChangesAsync(); + return stage; + } + + public async Task UpdateAsync(PlayerStage stage) + { + _db.PlayerStages.Update(stage); + await _db.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerWeaponRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerWeaponRepository.cs new file mode 100644 index 0000000..3acb2a8 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerWeaponRepository.cs @@ -0,0 +1,48 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace Fantasy.Server.Domain.Player.Repository; + +public class PlayerWeaponRepository : IPlayerWeaponRepository +{ + private readonly AppDbContext _db; + + public PlayerWeaponRepository(AppDbContext db) => _db = db; + + public async Task<List<PlayerWeapon>> FindAllByPlayerIdAsync(long playerId) + => await _db.PlayerWeapons + .AsNoTracking() + .Where(w => w.PlayerId == playerId) + .ToListAsync(); + + public async Task UpsertRangeAsync(long playerId, List<WeaponChangeItem> items) + { + List<WeaponChangeItem> normalizedItems = items + .GroupBy(item => item.WeaponId) + .Select(group => group.Last()) + .ToList(); + + List<int> weaponIds = normalizedItems.Select(item => item.WeaponId).ToList(); + Dictionary<int, PlayerWeapon> existing = await _db.PlayerWeapons + .Where(weapon => weapon.PlayerId == playerId && weaponIds.Contains(weapon.WeaponId)) + .ToDictionaryAsync(weapon => weapon.WeaponId); + + foreach (WeaponChangeItem item in normalizedItems) + { + if (existing.TryGetValue(item.WeaponId, out PlayerWeapon? weapon)) + { + weapon.Update(item.Count, item.EnhancementLevel, item.AwakeningCount); + _db.PlayerWeapons.Update(weapon); + continue; + } + + await _db.PlayerWeapons.AddAsync( + PlayerWeapon.Create(playerId, item.WeaponId, item.Count, item.EnhancementLevel, item.AwakeningCount)); + } + + await _db.SaveChangesAsync(); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/EndPlayerSessionService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/EndPlayerSessionService.cs new file mode 100644 index 0000000..b0a62ef --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/EndPlayerSessionService.cs @@ -0,0 +1,67 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service.Interface; +using Fantasy.Server.Global.Infrastructure; +using Fantasy.Server.Global.Security.Provider; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; + +namespace Fantasy.Server.Domain.Player.Service; + +public class EndPlayerSessionService : IEndPlayerSessionService +{ + private readonly IPlayerRepository _playerRepository; + private readonly IPlayerResourceRepository _playerResourceRepository; + private readonly IPlayerSessionRepository _playerSessionRepository; + private readonly IPlayerRedisRepository _playerRedisRepository; + private readonly ICurrentUserProvider _currentUserProvider; + private readonly IAppDbTransactionRunner _transactionRunner; + + public EndPlayerSessionService( + IPlayerRepository playerRepository, + IPlayerResourceRepository playerResourceRepository, + IPlayerSessionRepository playerSessionRepository, + IPlayerRedisRepository playerRedisRepository, + ICurrentUserProvider currentUserProvider, + IAppDbTransactionRunner transactionRunner) + { + _playerRepository = playerRepository; + _playerResourceRepository = playerResourceRepository; + _playerSessionRepository = playerSessionRepository; + _playerRedisRepository = playerRedisRepository; + _currentUserProvider = currentUserProvider; + _transactionRunner = transactionRunner; + } + + public async Task ExecuteAsync(EndPlayerSessionRequest request) + { + var accountId = _currentUserProvider.GetAccountId(); + + var player = await _playerRepository.FindByAccountAndJobAsync(accountId, request.JobType) + ?? throw new NotFoundException("플레이어 데이터를 찾을 수 없습니다."); + + var session = await _playerSessionRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 세션 데이터를 찾을 수 없습니다."); + + await _transactionRunner.ExecuteAsync(async () => + { + session.Update(request.LastWeaponId, request.ActiveSkills); + await _playerSessionRepository.UpdateAsync(session); + + if (request.Exp.HasValue) + { + player.UpdateExp(request.Exp.Value); + await _playerRepository.UpdateAsync(player); + } + + if (request.Gold.HasValue) + { + var resource = await _playerResourceRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 재화 데이터를 찾을 수 없습니다."); + resource.UpdateGold(request.Gold.Value); + await _playerResourceRepository.UpdateAsync(resource); + } + }); + + await _playerRedisRepository.DeleteAsync(accountId, request.JobType); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/InitPlayerService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/InitPlayerService.cs new file mode 100644 index 0000000..42b3710 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/InitPlayerService.cs @@ -0,0 +1,133 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Dto.Response; +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service.Interface; +using Fantasy.Server.Global.Infrastructure; +using Fantasy.Server.Global.Security.Provider; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Server.Domain.Player.Service; + +public class InitPlayerService : IInitPlayerService +{ + private readonly IPlayerRepository _playerRepository; + private readonly IPlayerResourceRepository _playerResourceRepository; + private readonly IPlayerStageRepository _playerStageRepository; + private readonly IPlayerSessionRepository _playerSessionRepository; + private readonly IPlayerWeaponRepository _playerWeaponRepository; + private readonly IPlayerSkillRepository _playerSkillRepository; + private readonly IPlayerRedisRepository _playerRedisRepository; + private readonly ICurrentUserProvider _currentUserProvider; + private readonly IAppDbTransactionRunner _transactionRunner; + + public InitPlayerService( + IPlayerRepository playerRepository, + IPlayerResourceRepository playerResourceRepository, + IPlayerStageRepository playerStageRepository, + IPlayerSessionRepository playerSessionRepository, + IPlayerWeaponRepository playerWeaponRepository, + IPlayerSkillRepository playerSkillRepository, + IPlayerRedisRepository playerRedisRepository, + ICurrentUserProvider currentUserProvider, + IAppDbTransactionRunner transactionRunner) + { + _playerRepository = playerRepository; + _playerResourceRepository = playerResourceRepository; + _playerStageRepository = playerStageRepository; + _playerSessionRepository = playerSessionRepository; + _playerWeaponRepository = playerWeaponRepository; + _playerSkillRepository = playerSkillRepository; + _playerRedisRepository = playerRedisRepository; + _currentUserProvider = currentUserProvider; + _transactionRunner = transactionRunner; + } + + public async Task<(PlayerDataResponse Data, bool IsNew)> ExecuteAsync(InitPlayerRequest request) + { + long accountId = _currentUserProvider.GetAccountId(); + + PlayerDataResponse? cached = await _playerRedisRepository.GetPlayerDataAsync(accountId, request.JobType); + if (cached != null) + return (cached, false); + + PlayerEntity? player = await _playerRepository.FindByAccountAndJobAsync(accountId, request.JobType); + if (player == null) + { + var created = await _transactionRunner.ExecuteAsync(async () => + { + PlayerEntity newPlayer = PlayerEntity.Create(accountId, request.JobType); + await _playerRepository.SaveAsync(newPlayer); + + PlayerResource resource = PlayerResource.Create(newPlayer.Id); + await _playerResourceRepository.SaveAsync(resource); + + PlayerStage stage = PlayerStage.Create(newPlayer.Id); + await _playerStageRepository.SaveAsync(stage); + + PlayerSession session = PlayerSession.Create(newPlayer.Id); + await _playerSessionRepository.SaveAsync(session); + + return (Player: newPlayer, Resource: resource, Stage: stage, Session: session); + }); + + List<Entity.PlayerWeapon> createdWeapons = await _playerWeaponRepository.FindAllByPlayerIdAsync(created.Player.Id); + List<Entity.PlayerSkill> createdSkills = await _playerSkillRepository.FindAllByPlayerIdAsync(created.Player.Id); + + PlayerDataResponse createdResponse = BuildResponse( + created.Player, + created.Resource, + created.Stage, + created.Session, + createdWeapons, + createdSkills); + + await _playerRedisRepository.SetPlayerDataAsync(accountId, request.JobType, createdResponse); + return (createdResponse, true); + } + + PlayerResource existingResource = await _playerResourceRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 재화 데이터를 찾을 수 없습니다."); + PlayerStage existingStage = await _playerStageRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 스테이지 데이터를 찾을 수 없습니다."); + PlayerSession existingSession = await _playerSessionRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 세션 데이터를 찾을 수 없습니다."); + + List<Entity.PlayerWeapon> weapons = await _playerWeaponRepository.FindAllByPlayerIdAsync(player.Id); + List<Entity.PlayerSkill> skills = await _playerSkillRepository.FindAllByPlayerIdAsync(player.Id); + + PlayerDataResponse response = BuildResponse( + player, + existingResource, + existingStage, + existingSession, + weapons, + skills); + + await _playerRedisRepository.SetPlayerDataAsync(accountId, request.JobType, response); + return (response, false); + } + + private static PlayerDataResponse BuildResponse( + PlayerEntity player, + PlayerResource resource, + PlayerStage stage, + PlayerSession session, + List<Entity.PlayerWeapon> weapons, + List<Entity.PlayerSkill> skills) => + new( + player.JobType, + player.Level, + stage.MaxStage, + session.LastWeaponId, + session.ActiveSkills, + resource.Gold, + player.Exp, + resource.EnhancementScroll, + resource.Mithril, + resource.Sp, + weapons.Select(w => new WeaponInfoResponse(w.WeaponId, w.Count, w.EnhancementLevel, w.AwakeningCount)).ToList(), + skills.Select(s => new SkillInfoResponse(s.SkillId, s.IsUnlocked)).ToList() + ); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IEndPlayerSessionService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IEndPlayerSessionService.cs new file mode 100644 index 0000000..ca542fd --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IEndPlayerSessionService.cs @@ -0,0 +1,8 @@ +using Fantasy.Server.Domain.Player.Dto.Request; + +namespace Fantasy.Server.Domain.Player.Service.Interface; + +public interface IEndPlayerSessionService +{ + Task ExecuteAsync(EndPlayerSessionRequest request); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IInitPlayerService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IInitPlayerService.cs new file mode 100644 index 0000000..74f92ff --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IInitPlayerService.cs @@ -0,0 +1,9 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Dto.Response; + +namespace Fantasy.Server.Domain.Player.Service.Interface; + +public interface IInitPlayerService +{ + Task<(PlayerDataResponse Data, bool IsNew)> ExecuteAsync(InitPlayerRequest request); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerLevelService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerLevelService.cs new file mode 100644 index 0000000..32b86fc --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerLevelService.cs @@ -0,0 +1,8 @@ +using Fantasy.Server.Domain.Player.Dto.Request; + +namespace Fantasy.Server.Domain.Player.Service.Interface; + +public interface IUpdatePlayerLevelService +{ + Task ExecuteAsync(UpdatePlayerLevelRequest request); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerResourceService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerResourceService.cs new file mode 100644 index 0000000..6b160d6 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerResourceService.cs @@ -0,0 +1,8 @@ +using Fantasy.Server.Domain.Player.Dto.Request; + +namespace Fantasy.Server.Domain.Player.Service.Interface; + +public interface IUpdatePlayerResourceService +{ + Task ExecuteAsync(UpdatePlayerResourceRequest request); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerSkillService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerSkillService.cs new file mode 100644 index 0000000..fb03012 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerSkillService.cs @@ -0,0 +1,8 @@ +using Fantasy.Server.Domain.Player.Dto.Request; + +namespace Fantasy.Server.Domain.Player.Service.Interface; + +public interface IUpdatePlayerSkillService +{ + Task ExecuteAsync(UpdatePlayerSkillRequest request); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerStageService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerStageService.cs new file mode 100644 index 0000000..6b07b21 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerStageService.cs @@ -0,0 +1,8 @@ +using Fantasy.Server.Domain.Player.Dto.Request; + +namespace Fantasy.Server.Domain.Player.Service.Interface; + +public interface IUpdatePlayerStageService +{ + Task ExecuteAsync(UpdatePlayerStageRequest request); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerWeaponService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerWeaponService.cs new file mode 100644 index 0000000..d5dadbf --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerWeaponService.cs @@ -0,0 +1,8 @@ +using Fantasy.Server.Domain.Player.Dto.Request; + +namespace Fantasy.Server.Domain.Player.Service.Interface; + +public interface IUpdatePlayerWeaponService +{ + Task ExecuteAsync(UpdatePlayerWeaponRequest request); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerLevelService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerLevelService.cs new file mode 100644 index 0000000..fc2f195 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerLevelService.cs @@ -0,0 +1,37 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service.Interface; +using Fantasy.Server.Global.Security.Provider; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; + +namespace Fantasy.Server.Domain.Player.Service; + +public class UpdatePlayerLevelService : IUpdatePlayerLevelService +{ + private readonly IPlayerRepository _playerRepository; + private readonly IPlayerRedisRepository _playerRedisRepository; + private readonly ICurrentUserProvider _currentUserProvider; + + public UpdatePlayerLevelService( + IPlayerRepository playerRepository, + IPlayerRedisRepository playerRedisRepository, + ICurrentUserProvider currentUserProvider) + { + _playerRepository = playerRepository; + _playerRedisRepository = playerRedisRepository; + _currentUserProvider = currentUserProvider; + } + + public async Task ExecuteAsync(UpdatePlayerLevelRequest request) + { + var accountId = _currentUserProvider.GetAccountId(); + + var player = await _playerRepository.FindByAccountAndJobAsync(accountId, request.JobType) + ?? throw new NotFoundException("플레이어 데이터를 찾을 수 없습니다."); + + player.UpdateLevel(request.Level); + await _playerRepository.UpdateAsync(player); + + await _playerRedisRepository.DeleteAsync(accountId, request.JobType); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerResourceService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerResourceService.cs new file mode 100644 index 0000000..04fe8f0 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerResourceService.cs @@ -0,0 +1,43 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service.Interface; +using Fantasy.Server.Global.Security.Provider; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; + +namespace Fantasy.Server.Domain.Player.Service; + +public class UpdatePlayerResourceService : IUpdatePlayerResourceService +{ + private readonly IPlayerRepository _playerRepository; + private readonly IPlayerResourceRepository _playerResourceRepository; + private readonly IPlayerRedisRepository _playerRedisRepository; + private readonly ICurrentUserProvider _currentUserProvider; + + public UpdatePlayerResourceService( + IPlayerRepository playerRepository, + IPlayerResourceRepository playerResourceRepository, + IPlayerRedisRepository playerRedisRepository, + ICurrentUserProvider currentUserProvider) + { + _playerRepository = playerRepository; + _playerResourceRepository = playerResourceRepository; + _playerRedisRepository = playerRedisRepository; + _currentUserProvider = currentUserProvider; + } + + public async Task ExecuteAsync(UpdatePlayerResourceRequest request) + { + var accountId = _currentUserProvider.GetAccountId(); + + var player = await _playerRepository.FindByAccountAndJobAsync(accountId, request.JobType) + ?? throw new NotFoundException("플레이어 데이터를 찾을 수 없습니다."); + + var resource = await _playerResourceRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 재화 데이터를 찾을 수 없습니다."); + + resource.UpdateChangeData(request.EnhancementScroll, request.Mithril, request.Sp); + await _playerResourceRepository.UpdateAsync(resource); + + await _playerRedisRepository.DeleteAsync(accountId, request.JobType); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerSkillService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerSkillService.cs new file mode 100644 index 0000000..7c1d805 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerSkillService.cs @@ -0,0 +1,39 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service.Interface; +using Fantasy.Server.Global.Security.Provider; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; + +namespace Fantasy.Server.Domain.Player.Service; + +public class UpdatePlayerSkillService : IUpdatePlayerSkillService +{ + private readonly IPlayerRepository _playerRepository; + private readonly IPlayerSkillRepository _playerSkillRepository; + private readonly IPlayerRedisRepository _playerRedisRepository; + private readonly ICurrentUserProvider _currentUserProvider; + + public UpdatePlayerSkillService( + IPlayerRepository playerRepository, + IPlayerSkillRepository playerSkillRepository, + IPlayerRedisRepository playerRedisRepository, + ICurrentUserProvider currentUserProvider) + { + _playerRepository = playerRepository; + _playerSkillRepository = playerSkillRepository; + _playerRedisRepository = playerRedisRepository; + _currentUserProvider = currentUserProvider; + } + + public async Task ExecuteAsync(UpdatePlayerSkillRequest request) + { + var accountId = _currentUserProvider.GetAccountId(); + + var player = await _playerRepository.FindByAccountAndJobAsync(accountId, request.JobType) + ?? throw new NotFoundException("플레이어 데이터를 찾을 수 없습니다."); + + await _playerSkillRepository.UpsertRangeAsync(player.Id, request.Skills); + + await _playerRedisRepository.DeleteAsync(accountId, request.JobType); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerStageService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerStageService.cs new file mode 100644 index 0000000..dffa3a2 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerStageService.cs @@ -0,0 +1,43 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service.Interface; +using Fantasy.Server.Global.Security.Provider; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; + +namespace Fantasy.Server.Domain.Player.Service; + +public class UpdatePlayerStageService : IUpdatePlayerStageService +{ + private readonly IPlayerRepository _playerRepository; + private readonly IPlayerStageRepository _playerStageRepository; + private readonly IPlayerRedisRepository _playerRedisRepository; + private readonly ICurrentUserProvider _currentUserProvider; + + public UpdatePlayerStageService( + IPlayerRepository playerRepository, + IPlayerStageRepository playerStageRepository, + IPlayerRedisRepository playerRedisRepository, + ICurrentUserProvider currentUserProvider) + { + _playerRepository = playerRepository; + _playerStageRepository = playerStageRepository; + _playerRedisRepository = playerRedisRepository; + _currentUserProvider = currentUserProvider; + } + + public async Task ExecuteAsync(UpdatePlayerStageRequest request) + { + var accountId = _currentUserProvider.GetAccountId(); + + var player = await _playerRepository.FindByAccountAndJobAsync(accountId, request.JobType) + ?? throw new NotFoundException("플레이어 데이터를 찾을 수 없습니다."); + + var stage = await _playerStageRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 스테이지 데이터를 찾을 수 없습니다."); + + stage.Update(request.MaxStage); + await _playerStageRepository.UpdateAsync(stage); + + await _playerRedisRepository.DeleteAsync(accountId, request.JobType); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerWeaponService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerWeaponService.cs new file mode 100644 index 0000000..fb3d0f4 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerWeaponService.cs @@ -0,0 +1,39 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service.Interface; +using Fantasy.Server.Global.Security.Provider; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; + +namespace Fantasy.Server.Domain.Player.Service; + +public class UpdatePlayerWeaponService : IUpdatePlayerWeaponService +{ + private readonly IPlayerRepository _playerRepository; + private readonly IPlayerWeaponRepository _playerWeaponRepository; + private readonly IPlayerRedisRepository _playerRedisRepository; + private readonly ICurrentUserProvider _currentUserProvider; + + public UpdatePlayerWeaponService( + IPlayerRepository playerRepository, + IPlayerWeaponRepository playerWeaponRepository, + IPlayerRedisRepository playerRedisRepository, + ICurrentUserProvider currentUserProvider) + { + _playerRepository = playerRepository; + _playerWeaponRepository = playerWeaponRepository; + _playerRedisRepository = playerRedisRepository; + _currentUserProvider = currentUserProvider; + } + + public async Task ExecuteAsync(UpdatePlayerWeaponRequest request) + { + var accountId = _currentUserProvider.GetAccountId(); + + var player = await _playerRepository.FindByAccountAndJobAsync(accountId, request.JobType) + ?? throw new NotFoundException("플레이어 데이터를 찾을 수 없습니다."); + + await _playerWeaponRepository.UpsertRangeAsync(player.Id, request.Weapons); + + await _playerRedisRepository.DeleteAsync(accountId, request.JobType); + } +} diff --git a/Fantasy-server/Fantasy.Server/Global/Config/DatabaseConfig.cs b/Fantasy-server/Fantasy.Server/Global/Config/DatabaseConfig.cs index 654e235..b23e613 100644 --- a/Fantasy-server/Fantasy.Server/Global/Config/DatabaseConfig.cs +++ b/Fantasy-server/Fantasy.Server/Global/Config/DatabaseConfig.cs @@ -14,6 +14,7 @@ public static IServiceCollection AddDatabase( services.AddDbContext<AppDbContext>(options => options.UseNpgsql(connectionString)); + services.AddScoped<IAppDbTransactionRunner, AppDbTransactionRunner>(); return services; } diff --git a/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContext.cs b/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContext.cs index 762632d..7a86382 100644 --- a/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContext.cs +++ b/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContext.cs @@ -1,4 +1,5 @@ using Fantasy.Server.Domain.Account.Entity; +using Fantasy.Server.Domain.Player.Entity; using Microsoft.EntityFrameworkCore; namespace Fantasy.Server.Global.Infrastructure; @@ -11,6 +12,12 @@ public AppDbContext(DbContextOptions<AppDbContext> options) } public DbSet<Account> Accounts => Set<Account>(); + public DbSet<Player> Players => Set<Player>(); + public DbSet<PlayerResource> PlayerResources => Set<PlayerResource>(); + public DbSet<PlayerStage> PlayerStages => Set<PlayerStage>(); + public DbSet<PlayerSession> PlayerSessions => Set<PlayerSession>(); + public DbSet<PlayerWeapon> PlayerWeapons => Set<PlayerWeapon>(); + public DbSet<PlayerSkill> PlayerSkills => Set<PlayerSkill>(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContextFactory.cs b/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContextFactory.cs new file mode 100644 index 0000000..280a2e5 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContextFactory.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Fantasy.Server.Global.Infrastructure; + +public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext> +{ + public AppDbContext CreateDbContext(string[] args) + { + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false) + .AddJsonFile("appsettings.Development.json", optional: true) + .AddEnvironmentVariables() + .Build(); + + var connectionString = configuration.GetConnectionString("Database") + ?? throw new InvalidOperationException("데이터베이스 연결 문자열이 설정되지 않았습니다."); + + var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>(); + optionsBuilder.UseNpgsql(connectionString); + + return new AppDbContext(optionsBuilder.Options); + } +} diff --git a/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbTransactionRunner.cs b/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbTransactionRunner.cs new file mode 100644 index 0000000..be11b76 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbTransactionRunner.cs @@ -0,0 +1,48 @@ +using System.Data; +using Microsoft.EntityFrameworkCore; + +namespace Fantasy.Server.Global.Infrastructure; + +public class AppDbTransactionRunner : IAppDbTransactionRunner +{ + private readonly AppDbContext _dbContext; + + public AppDbTransactionRunner(AppDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task ExecuteAsync(Func<Task> action, IsolationLevel? isolationLevel = null) + { + await ExecuteAsync<object?>(async () => + { + await action(); + return null; + }, isolationLevel); + } + + public async Task<T> ExecuteAsync<T>(Func<Task<T>> action, IsolationLevel? isolationLevel = null) + { + if (_dbContext.Database.CurrentTransaction != null) + return await action(); + + var transaction = isolationLevel.HasValue + ? await _dbContext.Database.BeginTransactionAsync(isolationLevel.Value) + : await _dbContext.Database.BeginTransactionAsync(); + + await using (transaction) + { + try + { + var result = await action(); + await transaction.CommitAsync(); + return result; + } + catch + { + await transaction.RollbackAsync(); + throw; + } + } + } +} diff --git a/Fantasy-server/Fantasy.Server/Global/Infrastructure/IAppDbTransactionRunner.cs b/Fantasy-server/Fantasy.Server/Global/Infrastructure/IAppDbTransactionRunner.cs new file mode 100644 index 0000000..fa18f84 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Global/Infrastructure/IAppDbTransactionRunner.cs @@ -0,0 +1,9 @@ +using System.Data; + +namespace Fantasy.Server.Global.Infrastructure; + +public interface IAppDbTransactionRunner +{ + Task ExecuteAsync(Func<Task> action, IsolationLevel? isolationLevel = null); + Task<T> ExecuteAsync<T>(Func<Task<T>> action, IsolationLevel? isolationLevel = null); +} diff --git a/Fantasy-server/Fantasy.Server/Global/Security/Provider/CurrentUserProvider.cs b/Fantasy-server/Fantasy.Server/Global/Security/Provider/CurrentUserProvider.cs index 50c7d1f..9e66001 100644 --- a/Fantasy-server/Fantasy.Server/Global/Security/Provider/CurrentUserProvider.cs +++ b/Fantasy-server/Fantasy.Server/Global/Security/Provider/CurrentUserProvider.cs @@ -33,6 +33,17 @@ public string GetEmail() ?? throw new UnauthorizedException("이메일 클레임을 찾을 수 없습니다."); } + public long GetAccountId() + { + var sub = GetUser().FindFirstValue(JwtRegisteredClaimNames.Sub) + ?? throw new UnauthorizedException("사용자 ID 클레임을 찾을 수 없습니다."); + + if (!long.TryParse(sub, out var accountId)) + throw new UnauthorizedException("사용자 ID 클레임이 유효하지 않습니다."); + + return accountId; + } + private ClaimsPrincipal GetUser() { var context = _httpContextAccessor.HttpContext diff --git a/Fantasy-server/Fantasy.Server/Global/Security/Provider/ICurrentUserProvider.cs b/Fantasy-server/Fantasy.Server/Global/Security/Provider/ICurrentUserProvider.cs index e9ae290..36ffbda 100644 --- a/Fantasy-server/Fantasy.Server/Global/Security/Provider/ICurrentUserProvider.cs +++ b/Fantasy-server/Fantasy.Server/Global/Security/Provider/ICurrentUserProvider.cs @@ -6,4 +6,5 @@ public interface ICurrentUserProvider { Task<Account> GetAccountAsync(); string GetEmail(); + long GetAccountId(); } diff --git a/Fantasy-server/Fantasy.Server/Migrations/20260406154118_AddPlayerTables.Designer.cs b/Fantasy-server/Fantasy.Server/Migrations/20260406154118_AddPlayerTables.Designer.cs new file mode 100644 index 0000000..c3bf5ef --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Migrations/20260406154118_AddPlayerTables.Designer.cs @@ -0,0 +1,257 @@ +// <auto-generated /> +using System; +using Fantasy.Server.Global.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fantasy.Server.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260406154118_AddPlayerTables")] + partial class AddPlayerTables + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Fantasy.Server.Domain.Account.Entity.Account", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<DateTime>("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property<bool>("IsNewAccount") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property<string>("Password") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("account", "account"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.Player", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<long>("AccountId") + .HasColumnType("bigint"); + + b.PrimitiveCollection<int[]>("ActiveSkills") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("integer[]") + .HasDefaultValueSql("ARRAY[]::integer[]"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("JobType") + .IsRequired() + .HasColumnType("text"); + + b.Property<int?>("LastWeaponId") + .HasColumnType("integer"); + + b.Property<long>("Level") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property<long>("MaxStage") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "JobType") + .IsUnique(); + + b.ToTable("player", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerResource", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<long>("EnhancementScroll") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("Exp") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("Gold") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("Mithril") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("PlayerId") + .HasColumnType("bigint"); + + b.Property<long>("Sp") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("player_resource", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSkill", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<bool>("IsUnlocked") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property<long>("PlayerId") + .HasColumnType("bigint"); + + b.Property<int>("SkillId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "SkillId") + .IsUnique(); + + b.ToTable("player_skill", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerWeapon", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<long>("AwakeningCount") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("Count") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("EnhancementLevel") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("PlayerId") + .HasColumnType("bigint"); + + b.Property<int>("WeaponId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "WeaponId") + .IsUnique(); + + b.ToTable("player_weapon", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerResource", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithOne() + .HasForeignKey("Fantasy.Server.Domain.Player.Entity.PlayerResource", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSkill", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithMany() + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerWeapon", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithMany() + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Fantasy-server/Fantasy.Server/Migrations/20260406154118_AddPlayerTables.cs b/Fantasy-server/Fantasy.Server/Migrations/20260406154118_AddPlayerTables.cs new file mode 100644 index 0000000..42e6a2f --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Migrations/20260406154118_AddPlayerTables.cs @@ -0,0 +1,163 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fantasy.Server.Migrations +{ + /// <inheritdoc /> + public partial class AddPlayerTables : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "player"); + + migrationBuilder.CreateTable( + name: "player", + schema: "player", + columns: table => new + { + Id = table.Column<long>(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + AccountId = table.Column<long>(type: "bigint", nullable: false), + JobType = table.Column<string>(type: "text", nullable: false), + Level = table.Column<long>(type: "bigint", nullable: false, defaultValue: 1L), + MaxStage = table.Column<long>(type: "bigint", nullable: false, defaultValue: 1L), + LastWeaponId = table.Column<int>(type: "integer", nullable: true), + ActiveSkills = table.Column<int[]>(type: "integer[]", nullable: false, defaultValueSql: "ARRAY[]::integer[]"), + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_player", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "player_resource", + schema: "player", + columns: table => new + { + Id = table.Column<long>(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + PlayerId = table.Column<long>(type: "bigint", nullable: false), + Gold = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L), + Exp = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L), + EnhancementScroll = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L), + Mithril = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L), + Sp = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L), + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_player_resource", x => x.Id); + table.ForeignKey( + name: "FK_player_resource_player_PlayerId", + column: x => x.PlayerId, + principalSchema: "player", + principalTable: "player", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "player_skill", + schema: "player", + columns: table => new + { + Id = table.Column<long>(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + PlayerId = table.Column<long>(type: "bigint", nullable: false), + SkillId = table.Column<int>(type: "integer", nullable: false), + IsUnlocked = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("PK_player_skill", x => x.Id); + table.ForeignKey( + name: "FK_player_skill_player_PlayerId", + column: x => x.PlayerId, + principalSchema: "player", + principalTable: "player", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "player_weapon", + schema: "player", + columns: table => new + { + Id = table.Column<long>(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + PlayerId = table.Column<long>(type: "bigint", nullable: false), + WeaponId = table.Column<int>(type: "integer", nullable: false), + Count = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L), + EnhancementLevel = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L), + AwakeningCount = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L) + }, + constraints: table => + { + table.PrimaryKey("PK_player_weapon", x => x.Id); + table.ForeignKey( + name: "FK_player_weapon_player_PlayerId", + column: x => x.PlayerId, + principalSchema: "player", + principalTable: "player", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_player_AccountId_JobType", + schema: "player", + table: "player", + columns: new[] { "AccountId", "JobType" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_player_resource_PlayerId", + schema: "player", + table: "player_resource", + column: "PlayerId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_player_skill_PlayerId_SkillId", + schema: "player", + table: "player_skill", + columns: new[] { "PlayerId", "SkillId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_player_weapon_PlayerId_WeaponId", + schema: "player", + table: "player_weapon", + columns: new[] { "PlayerId", "WeaponId" }, + unique: true); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "player_resource", + schema: "player"); + + migrationBuilder.DropTable( + name: "player_skill", + schema: "player"); + + migrationBuilder.DropTable( + name: "player_weapon", + schema: "player"); + + migrationBuilder.DropTable( + name: "player", + schema: "player"); + } + } +} diff --git a/Fantasy-server/Fantasy.Server/Migrations/20260407052845_NormalizePlayerTables.Designer.cs b/Fantasy-server/Fantasy.Server/Migrations/20260407052845_NormalizePlayerTables.Designer.cs new file mode 100644 index 0000000..cde5c8b --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Migrations/20260407052845_NormalizePlayerTables.Designer.cs @@ -0,0 +1,316 @@ +// <auto-generated /> +using System; +using Fantasy.Server.Global.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fantasy.Server.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260407052845_NormalizePlayerTables")] + partial class NormalizePlayerTables + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Fantasy.Server.Domain.Account.Entity.Account", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<DateTime>("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property<bool>("IsNewAccount") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property<string>("Password") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("account", "account"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.Player", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<long>("AccountId") + .HasColumnType("bigint"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<long>("Exp") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<string>("JobType") + .IsRequired() + .HasColumnType("text"); + + b.Property<long>("Level") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "JobType") + .IsUnique(); + + b.ToTable("player", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerResource", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<long>("EnhancementScroll") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("Gold") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("Mithril") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("PlayerId") + .HasColumnType("bigint"); + + b.Property<long>("Sp") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("player_resource", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSession", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.PrimitiveCollection<int[]>("ActiveSkills") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("integer[]") + .HasDefaultValueSql("ARRAY[]::integer[]"); + + b.Property<int?>("LastWeaponId") + .HasColumnType("integer"); + + b.Property<long>("PlayerId") + .HasColumnType("bigint"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("player_session", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSkill", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<bool>("IsUnlocked") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property<long>("PlayerId") + .HasColumnType("bigint"); + + b.Property<int>("SkillId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "SkillId") + .IsUnique(); + + b.ToTable("player_skill", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerStage", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<long>("MaxStage") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property<long>("PlayerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("player_stage", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerWeapon", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<long>("AwakeningCount") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("Count") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("EnhancementLevel") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("PlayerId") + .HasColumnType("bigint"); + + b.Property<int>("WeaponId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "WeaponId") + .IsUnique(); + + b.ToTable("player_weapon", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerResource", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithOne() + .HasForeignKey("Fantasy.Server.Domain.Player.Entity.PlayerResource", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSession", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithOne() + .HasForeignKey("Fantasy.Server.Domain.Player.Entity.PlayerSession", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSkill", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithMany() + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerStage", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithOne() + .HasForeignKey("Fantasy.Server.Domain.Player.Entity.PlayerStage", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerWeapon", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithMany() + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Fantasy-server/Fantasy.Server/Migrations/20260407052845_NormalizePlayerTables.cs b/Fantasy-server/Fantasy.Server/Migrations/20260407052845_NormalizePlayerTables.cs new file mode 100644 index 0000000..d930d28 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Migrations/20260407052845_NormalizePlayerTables.cs @@ -0,0 +1,156 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fantasy.Server.Migrations +{ + /// <inheritdoc /> + public partial class NormalizePlayerTables : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + // player 테이블에서 정규화 대상 컬럼 제거 + migrationBuilder.DropColumn( + name: "ActiveSkills", + schema: "player", + table: "player"); + + migrationBuilder.DropColumn( + name: "LastWeaponId", + schema: "player", + table: "player"); + + migrationBuilder.DropColumn( + name: "MaxStage", + schema: "player", + table: "player"); + + // player 테이블에 Exp 추가 + migrationBuilder.AddColumn<long>( + name: "Exp", + schema: "player", + table: "player", + type: "bigint", + nullable: false, + defaultValue: 0L); + + // player_resource 테이블에서 Exp 제거 + migrationBuilder.DropColumn( + name: "Exp", + schema: "player", + table: "player_resource"); + + // player_stage 테이블 생성 + migrationBuilder.CreateTable( + name: "player_stage", + schema: "player", + columns: table => new + { + Id = table.Column<long>(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + PlayerId = table.Column<long>(type: "bigint", nullable: false), + MaxStage = table.Column<long>(type: "bigint", nullable: false, defaultValue: 1L) + }, + constraints: table => + { + table.PrimaryKey("PK_player_stage", x => x.Id); + table.ForeignKey( + name: "FK_player_stage_player_PlayerId", + column: x => x.PlayerId, + principalSchema: "player", + principalTable: "player", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_player_stage_PlayerId", + schema: "player", + table: "player_stage", + column: "PlayerId", + unique: true); + + // player_session 테이블 생성 + migrationBuilder.CreateTable( + name: "player_session", + schema: "player", + columns: table => new + { + Id = table.Column<long>(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + PlayerId = table.Column<long>(type: "bigint", nullable: false), + LastWeaponId = table.Column<int>(type: "integer", nullable: true), + ActiveSkills = table.Column<int[]>(type: "integer[]", nullable: false, defaultValueSql: "ARRAY[]::integer[]"), + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_player_session", x => x.Id); + table.ForeignKey( + name: "FK_player_session_player_PlayerId", + column: x => x.PlayerId, + principalSchema: "player", + principalTable: "player", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_player_session_PlayerId", + schema: "player", + table: "player_session", + column: "PlayerId", + unique: true); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "player_session", + schema: "player"); + + migrationBuilder.DropTable( + name: "player_stage", + schema: "player"); + + migrationBuilder.DropColumn( + name: "Exp", + schema: "player", + table: "player"); + + migrationBuilder.AddColumn<long>( + name: "Exp", + schema: "player", + table: "player_resource", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn<long>( + name: "MaxStage", + schema: "player", + table: "player", + type: "bigint", + nullable: false, + defaultValue: 1L); + + migrationBuilder.AddColumn<int>( + name: "LastWeaponId", + schema: "player", + table: "player", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn<int[]>( + name: "ActiveSkills", + schema: "player", + table: "player", + type: "integer[]", + nullable: false, + defaultValueSql: "ARRAY[]::integer[]"); + } + } +} diff --git a/Fantasy-server/Fantasy.Server/Migrations/AppDbContextModelSnapshot.cs b/Fantasy-server/Fantasy.Server/Migrations/AppDbContextModelSnapshot.cs index 916a766..e8d4612 100644 --- a/Fantasy-server/Fantasy.Server/Migrations/AppDbContextModelSnapshot.cs +++ b/Fantasy-server/Fantasy.Server/Migrations/AppDbContextModelSnapshot.cs @@ -62,6 +62,251 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("account", "account"); }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.Player", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<long>("AccountId") + .HasColumnType("bigint"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<long>("Exp") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<string>("JobType") + .IsRequired() + .HasColumnType("text"); + + b.Property<long>("Level") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "JobType") + .IsUnique(); + + b.ToTable("player", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerResource", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<long>("EnhancementScroll") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("Gold") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("Mithril") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("PlayerId") + .HasColumnType("bigint"); + + b.Property<long>("Sp") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("player_resource", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSession", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.PrimitiveCollection<int[]>("ActiveSkills") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("integer[]") + .HasDefaultValueSql("ARRAY[]::integer[]"); + + b.Property<int?>("LastWeaponId") + .HasColumnType("integer"); + + b.Property<long>("PlayerId") + .HasColumnType("bigint"); + + b.Property<DateTime>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("player_session", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSkill", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<bool>("IsUnlocked") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property<long>("PlayerId") + .HasColumnType("bigint"); + + b.Property<int>("SkillId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "SkillId") + .IsUnique(); + + b.ToTable("player_skill", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerStage", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<long>("MaxStage") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property<long>("PlayerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("player_stage", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerWeapon", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<long>("AwakeningCount") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("Count") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("EnhancementLevel") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property<long>("PlayerId") + .HasColumnType("bigint"); + + b.Property<int>("WeaponId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "WeaponId") + .IsUnique(); + + b.ToTable("player_weapon", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerResource", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithOne() + .HasForeignKey("Fantasy.Server.Domain.Player.Entity.PlayerResource", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSession", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithOne() + .HasForeignKey("Fantasy.Server.Domain.Player.Entity.PlayerSession", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSkill", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithMany() + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerStage", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithOne() + .HasForeignKey("Fantasy.Server.Domain.Player.Entity.PlayerStage", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerWeapon", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithMany() + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); #pragma warning restore 612, 618 } } diff --git a/Fantasy-server/Fantasy.Server/Program.cs b/Fantasy-server/Fantasy.Server/Program.cs index 75fb8aa..c210e84 100644 --- a/Fantasy-server/Fantasy.Server/Program.cs +++ b/Fantasy-server/Fantasy.Server/Program.cs @@ -1,5 +1,6 @@ using Fantasy.Server.Domain.Account.Config; using Fantasy.Server.Domain.Auth.Config; +using Fantasy.Server.Domain.Player.Config; using Fantasy.Server.Global.Config; using Fantasy.Server.Global.Security.Config; using Gamism.SDK.Extensions.AspNetCore; @@ -22,6 +23,7 @@ builder.Services.AddAccountServices(); builder.Services.AddAuthServices(); +builder.Services.AddPlayerServices(); builder.Services.AddSecurityServices(); var app = builder.Build(); diff --git a/Fantasy-server/Fantasy.Test/Fantasy.Test.csproj b/Fantasy-server/Fantasy.Test/Fantasy.Test.csproj index 7b6b7f4..c6e5b8b 100644 --- a/Fantasy-server/Fantasy.Test/Fantasy.Test.csproj +++ b/Fantasy-server/Fantasy.Test/Fantasy.Test.csproj @@ -25,6 +25,7 @@ <PackageReference Include="FluentAssertions" Version="8.9.0" /> <PackageReference Include="Gamism.SDK.Extensions.AspNetCore" Version="0.2.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.5" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" /> <PackageReference Include="NSubstitute" Version="5.3.0" /> </ItemGroup> @@ -32,4 +33,4 @@ <ProjectReference Include="..\Fantasy.Server\Fantasy.Server.csproj" /> </ItemGroup> -</Project> \ No newline at end of file +</Project> diff --git a/Fantasy-server/Fantasy.Test/Global/Infrastructure/AppDbTransactionRunnerTests.cs b/Fantasy-server/Fantasy.Test/Global/Infrastructure/AppDbTransactionRunnerTests.cs new file mode 100644 index 0000000..169250d --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Global/Infrastructure/AppDbTransactionRunnerTests.cs @@ -0,0 +1,113 @@ +using System.Data; +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Global.Infrastructure; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Test.Global.Infrastructure; + +public class AppDbTransactionRunnerTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly AppDbContext _dbContext; + private readonly AppDbTransactionRunner _sut; + + public AppDbTransactionRunnerTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder<AppDbContext>() + .UseSqlite(_connection) + .Options; + + _dbContext = new TestAppDbContext(options); + _dbContext.Database.EnsureCreated(); + _sut = new AppDbTransactionRunner(_dbContext); + } + + [Fact] + public async Task ExecuteAsync_예외가_발생하면_롤백한다() + { + var cancellationToken = TestContext.Current.CancellationToken; + + var act = async () => await _sut.ExecuteAsync(async () => + { + await _dbContext.Players.AddAsync(PlayerEntity.Create(1L, JobType.Warrior), cancellationToken); + await _dbContext.SaveChangesAsync(cancellationToken); + throw new InvalidOperationException("rollback"); + }); + + await act.Should().ThrowAsync<InvalidOperationException>() + .WithMessage("rollback"); + var count = await _dbContext.Players.CountAsync(cancellationToken); + count.Should().Be(0); + } + + [Fact] + public async Task ExecuteAsyncT_결과를_반환한다() + { + var result = await _sut.ExecuteAsync(async () => + { + await Task.CompletedTask; + return 42; + }); + + result.Should().Be(42); + } + + [Fact] + public async Task ExecuteAsync_중첩_호출이면_기존_트랜잭션을_재사용한다() + { + var cancellationToken = TestContext.Current.CancellationToken; + + await _sut.ExecuteAsync(async () => + { + var outerTransaction = _dbContext.Database.CurrentTransaction; + + await _sut.ExecuteAsync(async () => + { + _dbContext.Database.CurrentTransaction.Should().BeSameAs(outerTransaction); + await _dbContext.Players.AddAsync(PlayerEntity.Create(2L, JobType.Archer), cancellationToken); + await _dbContext.SaveChangesAsync(cancellationToken); + }, IsolationLevel.Serializable); + }); + + var count = await _dbContext.Players.CountAsync(cancellationToken); + count.Should().Be(1); + } + + public void Dispose() + { + _dbContext.Dispose(); + _connection.Dispose(); + } + + private sealed class TestAppDbContext : AppDbContext + { + public TestAppDbContext(DbContextOptions<AppDbContext> options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity<PlayerEntity>(entity => + { + entity.ToTable("players"); + entity.HasKey(player => player.Id); + entity.Property(player => player.Id).ValueGeneratedOnAdd(); + entity.Property(player => player.AccountId).IsRequired(); + entity.Property(player => player.JobType).HasConversion<string>().IsRequired(); + entity.Property(player => player.Level).IsRequired(); + entity.Property(player => player.Exp).IsRequired(); + entity.Property(player => player.CreatedAt).IsRequired(); + entity.Property(player => player.UpdatedAt).IsRequired(); + }); + } + } +} diff --git a/Fantasy-server/Fantasy.Test/Player/Repository/PlayerSkillRepositoryTests.cs b/Fantasy-server/Fantasy.Test/Player/Repository/PlayerSkillRepositoryTests.cs new file mode 100644 index 0000000..d5b4008 --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Player/Repository/PlayerSkillRepositoryTests.cs @@ -0,0 +1,86 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Repository; +using Fantasy.Server.Global.Infrastructure; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Fantasy.Test.Player.Repository; + +public class PlayerSkillRepositoryTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly AppDbContext _dbContext; + private readonly PlayerSkillRepository _sut; + + public PlayerSkillRepositoryTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder<AppDbContext>() + .UseSqlite(_connection) + .Options; + + _dbContext = new TestAppDbContext(options); + _dbContext.Database.EnsureCreated(); + _sut = new PlayerSkillRepository(_dbContext); + } + + [Fact] + public async Task UpsertRangeAsync_중복_스킬_ID가_있으면_마지막_값으로_저장한다() + { + var cancellationToken = TestContext.Current.CancellationToken; + + await _dbContext.PlayerSkills.AddAsync(PlayerSkill.Create(1L, 1, false), cancellationToken); + await _dbContext.SaveChangesAsync(cancellationToken); + + List<SkillChangeItem> items = + [ + new(1, false), + new(1, true), + new(2, true) + ]; + + await _sut.UpsertRangeAsync(1L, items); + + List<PlayerSkill> saved = await _dbContext.PlayerSkills + .OrderBy(skill => skill.SkillId) + .ToListAsync(cancellationToken); + + saved.Should().HaveCount(2); + saved[0].SkillId.Should().Be(1); + saved[0].IsUnlocked.Should().BeTrue(); + saved[1].SkillId.Should().Be(2); + } + + public void Dispose() + { + _dbContext.Dispose(); + _connection.Dispose(); + } + + private sealed class TestAppDbContext : AppDbContext + { + public TestAppDbContext(DbContextOptions<AppDbContext> options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity<PlayerSkill>(entity => + { + entity.ToTable("player_skills"); + entity.HasKey(skill => skill.Id); + entity.Property(skill => skill.Id).ValueGeneratedOnAdd(); + entity.Property(skill => skill.PlayerId).IsRequired(); + entity.Property(skill => skill.SkillId).IsRequired(); + entity.Property(skill => skill.IsUnlocked).IsRequired(); + entity.HasIndex(skill => new { skill.PlayerId, skill.SkillId }).IsUnique(); + }); + } + } +} diff --git a/Fantasy-server/Fantasy.Test/Player/Repository/PlayerWeaponRepositoryTests.cs b/Fantasy-server/Fantasy.Test/Player/Repository/PlayerWeaponRepositoryTests.cs new file mode 100644 index 0000000..138d52b --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Player/Repository/PlayerWeaponRepositoryTests.cs @@ -0,0 +1,90 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Repository; +using Fantasy.Server.Global.Infrastructure; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Fantasy.Test.Player.Repository; + +public class PlayerWeaponRepositoryTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly AppDbContext _dbContext; + private readonly PlayerWeaponRepository _sut; + + public PlayerWeaponRepositoryTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder<AppDbContext>() + .UseSqlite(_connection) + .Options; + + _dbContext = new TestAppDbContext(options); + _dbContext.Database.EnsureCreated(); + _sut = new PlayerWeaponRepository(_dbContext); + } + + [Fact] + public async Task UpsertRangeAsync_중복_무기_ID가_있으면_마지막_값으로_저장한다() + { + var cancellationToken = TestContext.Current.CancellationToken; + + await _dbContext.PlayerWeapons.AddAsync(PlayerWeapon.Create(1L, 1, 2L, 1L, 0L), cancellationToken); + await _dbContext.SaveChangesAsync(cancellationToken); + + List<WeaponChangeItem> items = + [ + new(1, 3L, 2L, 0L), + new(1, 7L, 4L, 1L), + new(2, 1L, 0L, 0L) + ]; + + await _sut.UpsertRangeAsync(1L, items); + + List<PlayerWeapon> saved = await _dbContext.PlayerWeapons + .OrderBy(weapon => weapon.WeaponId) + .ToListAsync(cancellationToken); + + saved.Should().HaveCount(2); + saved[0].WeaponId.Should().Be(1); + saved[0].Count.Should().Be(7L); + saved[0].EnhancementLevel.Should().Be(4L); + saved[0].AwakeningCount.Should().Be(1L); + saved[1].WeaponId.Should().Be(2); + } + + public void Dispose() + { + _dbContext.Dispose(); + _connection.Dispose(); + } + + private sealed class TestAppDbContext : AppDbContext + { + public TestAppDbContext(DbContextOptions<AppDbContext> options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity<PlayerWeapon>(entity => + { + entity.ToTable("player_weapons"); + entity.HasKey(weapon => weapon.Id); + entity.Property(weapon => weapon.Id).ValueGeneratedOnAdd(); + entity.Property(weapon => weapon.PlayerId).IsRequired(); + entity.Property(weapon => weapon.WeaponId).IsRequired(); + entity.Property(weapon => weapon.Count).IsRequired(); + entity.Property(weapon => weapon.EnhancementLevel).IsRequired(); + entity.Property(weapon => weapon.AwakeningCount).IsRequired(); + entity.HasIndex(weapon => new { weapon.PlayerId, weapon.WeaponId }).IsUnique(); + }); + } + } +} diff --git a/Fantasy-server/Fantasy.Test/Player/Service/EndPlayerSessionServiceTests.cs b/Fantasy-server/Fantasy.Test/Player/Service/EndPlayerSessionServiceTests.cs new file mode 100644 index 0000000..7b6292b --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Player/Service/EndPlayerSessionServiceTests.cs @@ -0,0 +1,190 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service; +using Fantasy.Server.Global.Infrastructure; +using Fantasy.Server.Global.Security.Provider; +using FluentAssertions; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; +using NSubstitute; +using Xunit; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; +using PlayerResourceEntity = Fantasy.Server.Domain.Player.Entity.PlayerResource; + +namespace Fantasy.Test.Player.Service; + +public class EndPlayerSessionServiceTests +{ + public class 정상_요청일_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For<IPlayerResourceRepository>(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For<IPlayerSessionRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly IAppDbTransactionRunner _transactionRunner = Substitute.For<IAppDbTransactionRunner>(); + private readonly EndPlayerSessionService _sut; + private readonly EndPlayerSessionRequest _request = new(JobType.Warrior, 1, [1, 2], 5000L, 3000L); + + public 정상_요청일_때() + { + _transactionRunner.ExecuteAsync(Arg.Any<Func<Task>>()) + .Returns(callInfo => callInfo.Arg<Func<Task>>()()); + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + _playerSessionRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerSession.Create(1L)); + _playerResourceRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerResourceEntity.Create(1L)); + + _sut = new EndPlayerSessionService( + _playerRepository, + _playerResourceRepository, + _playerSessionRepository, + _playerRedisRepository, + _currentUserProvider, + _transactionRunner); + } + + [Fact] + public async Task 트랜잭션_안에서_세션_종료를_처리한다() + { + await _sut.ExecuteAsync(_request); + + await _transactionRunner.Received(1).ExecuteAsync(Arg.Any<Func<Task>>()); + } + + [Fact] + public async Task 세션_데이터가_저장된다() + { + await _sut.ExecuteAsync(_request); + + await _playerSessionRepository.Received(1).UpdateAsync(Arg.Any<PlayerSession>()); + } + + [Fact] + public async Task Exp가_플레이어에_저장된다() + { + await _sut.ExecuteAsync(_request); + + await _playerRepository.Received(1).UpdateAsync(Arg.Any<PlayerEntity>()); + } + + [Fact] + public async Task Gold가_재화에_저장된다() + { + await _sut.ExecuteAsync(_request); + + await _playerResourceRepository.Received(1).UpdateAsync(Arg.Any<PlayerResourceEntity>()); + } + + [Fact] + public async Task Redis_캐시가_무효화된다() + { + await _sut.ExecuteAsync(_request); + + await _playerRedisRepository.Received(1).DeleteAsync(1L, JobType.Warrior); + } + } + + public class Gold_Exp가_null일_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For<IPlayerResourceRepository>(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For<IPlayerSessionRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly IAppDbTransactionRunner _transactionRunner = Substitute.For<IAppDbTransactionRunner>(); + private readonly EndPlayerSessionService _sut; + private readonly EndPlayerSessionRequest _request = new(JobType.Archer, 1, [], null, null); + + public Gold_Exp가_null일_때() + { + _transactionRunner.ExecuteAsync(Arg.Any<Func<Task>>()) + .Returns(callInfo => callInfo.Arg<Func<Task>>()()); + _currentUserProvider.GetAccountId().Returns(2L); + _playerRepository.FindByAccountAndJobAsync(2L, JobType.Archer) + .Returns(PlayerEntity.Create(2L, JobType.Archer)); + _playerSessionRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerSession.Create(2L)); + + _sut = new EndPlayerSessionService( + _playerRepository, + _playerResourceRepository, + _playerSessionRepository, + _playerRedisRepository, + _currentUserProvider, + _transactionRunner); + } + + [Fact] + public async Task 세션_데이터는_저장된다() + { + await _sut.ExecuteAsync(_request); + + await _playerSessionRepository.Received(1).UpdateAsync(Arg.Any<PlayerSession>()); + } + + [Fact] + public async Task 플레이어_Exp가_업데이트되지_않는다() + { + await _sut.ExecuteAsync(_request); + + await _playerRepository.DidNotReceive().UpdateAsync(Arg.Any<PlayerEntity>()); + } + + [Fact] + public async Task 재화가_업데이트되지_않는다() + { + await _sut.ExecuteAsync(_request); + + await _playerResourceRepository.DidNotReceive().UpdateAsync(Arg.Any<PlayerResourceEntity>()); + } + + [Fact] + public async Task Redis_캐시가_무효화된다() + { + await _sut.ExecuteAsync(_request); + + await _playerRedisRepository.Received(1).DeleteAsync(2L, JobType.Archer); + } + } + + public class 플레이어가_존재하지_않을_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For<IPlayerResourceRepository>(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For<IPlayerSessionRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly IAppDbTransactionRunner _transactionRunner = Substitute.For<IAppDbTransactionRunner>(); + private readonly EndPlayerSessionService _sut; + + public 플레이어가_존재하지_않을_때() + { + _currentUserProvider.GetAccountId().Returns(99L); + _playerRepository.FindByAccountAndJobAsync(Arg.Any<long>(), Arg.Any<JobType>()) + .Returns((PlayerEntity?)null); + + _sut = new EndPlayerSessionService( + _playerRepository, + _playerResourceRepository, + _playerSessionRepository, + _playerRedisRepository, + _currentUserProvider, + _transactionRunner); + } + + [Fact] + public async Task NotFoundException이_발생한다() + { + var request = new EndPlayerSessionRequest(JobType.Mage, 1, [], null, null); + + var act = async () => await _sut.ExecuteAsync(request); + + await act.Should().ThrowAsync<NotFoundException>(); + } + } +} diff --git a/Fantasy-server/Fantasy.Test/Player/Service/InitPlayerServiceTests.cs b/Fantasy-server/Fantasy.Test/Player/Service/InitPlayerServiceTests.cs new file mode 100644 index 0000000..aa2707d --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Player/Service/InitPlayerServiceTests.cs @@ -0,0 +1,273 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Dto.Response; +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service; +using Fantasy.Server.Global.Infrastructure; +using Fantasy.Server.Global.Security.Provider; +using FluentAssertions; +using NSubstitute; +using Xunit; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; +using PlayerResourceEntity = Fantasy.Server.Domain.Player.Entity.PlayerResource; + +namespace Fantasy.Test.Player.Service; + +public class InitPlayerServiceTests +{ + public class 캐시가_있을_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For<IPlayerResourceRepository>(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For<IPlayerStageRepository>(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For<IPlayerSessionRepository>(); + private readonly IPlayerWeaponRepository _playerWeaponRepository = Substitute.For<IPlayerWeaponRepository>(); + private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For<IPlayerSkillRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly IAppDbTransactionRunner _transactionRunner = Substitute.For<IAppDbTransactionRunner>(); + private readonly InitPlayerService _sut; + private readonly InitPlayerRequest _request = new(JobType.Warrior); + private readonly PlayerDataResponse _cached = new( + JobType.Warrior, 5L, 3L, null, [], 1000L, 2000L, 0L, 0L, 0L, [], []); + + public 캐시가_있을_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRedisRepository.GetPlayerDataAsync(1L, JobType.Warrior).Returns(_cached); + + _sut = new InitPlayerService( + _playerRepository, + _playerResourceRepository, + _playerStageRepository, + _playerSessionRepository, + _playerWeaponRepository, + _playerSkillRepository, + _playerRedisRepository, + _currentUserProvider, + _transactionRunner); + } + + [Fact] + public async Task 캐시된_데이터가_반환된다() + { + var (data, _) = await _sut.ExecuteAsync(_request); + + data.Should().Be(_cached); + } + + [Fact] + public async Task DB_조회가_발생하지_않는다() + { + await _sut.ExecuteAsync(_request); + + await _playerRepository.DidNotReceive().FindByAccountAndJobAsync(Arg.Any<long>(), Arg.Any<JobType>()); + } + + [Fact] + public async Task 트랜잭션이_실행되지_않는다() + { + await _sut.ExecuteAsync(_request); + + await _transactionRunner.DidNotReceiveWithAnyArgs() + .ExecuteAsync(default(Func<Task<(PlayerEntity Player, PlayerResource Resource, PlayerStage Stage, PlayerSession Session)>>)!); + } + + [Fact] + public async Task isNew가_false로_반환된다() + { + var (_, isNew) = await _sut.ExecuteAsync(_request); + + isNew.Should().BeFalse(); + } + } + + public class 신규_플레이어일_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For<IPlayerResourceRepository>(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For<IPlayerStageRepository>(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For<IPlayerSessionRepository>(); + private readonly IPlayerWeaponRepository _playerWeaponRepository = Substitute.For<IPlayerWeaponRepository>(); + private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For<IPlayerSkillRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly IAppDbTransactionRunner _transactionRunner = Substitute.For<IAppDbTransactionRunner>(); + private readonly InitPlayerService _sut; + private readonly InitPlayerRequest _request = new(JobType.Warrior); + + public 신규_플레이어일_때() + { + _transactionRunner.ExecuteAsync(Arg.Any<Func<Task<(PlayerEntity Player, PlayerResource Resource, PlayerStage Stage, PlayerSession Session)>>>()) + .Returns(callInfo => callInfo.Arg<Func<Task<(PlayerEntity, PlayerResource, PlayerStage, PlayerSession)>>>()()); + _currentUserProvider.GetAccountId().Returns(1L); + _playerRedisRepository.GetPlayerDataAsync(1L, JobType.Warrior).Returns((PlayerDataResponse?)null); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior).Returns((PlayerEntity?)null); + _playerRepository.SaveAsync(Arg.Any<PlayerEntity>()) + .Returns(callInfo => callInfo.Arg<PlayerEntity>()); + _playerResourceRepository.SaveAsync(Arg.Any<PlayerResourceEntity>()) + .Returns(callInfo => callInfo.Arg<PlayerResourceEntity>()); + _playerStageRepository.SaveAsync(Arg.Any<PlayerStage>()) + .Returns(callInfo => callInfo.Arg<PlayerStage>()); + _playerSessionRepository.SaveAsync(Arg.Any<PlayerSession>()) + .Returns(callInfo => callInfo.Arg<PlayerSession>()); + _playerWeaponRepository.FindAllByPlayerIdAsync(Arg.Any<long>()).Returns([]); + _playerSkillRepository.FindAllByPlayerIdAsync(Arg.Any<long>()).Returns([]); + + _sut = new InitPlayerService( + _playerRepository, + _playerResourceRepository, + _playerStageRepository, + _playerSessionRepository, + _playerWeaponRepository, + _playerSkillRepository, + _playerRedisRepository, + _currentUserProvider, + _transactionRunner); + } + + [Fact] + public async Task 트랜잭션_안에서_플레이어를_초기_생성한다() + { + await _sut.ExecuteAsync(_request); + + await _transactionRunner.Received(1) + .ExecuteAsync(Arg.Any<Func<Task<(PlayerEntity Player, PlayerResource Resource, PlayerStage Stage, PlayerSession Session)>>>()); + } + + [Fact] + public async Task 플레이어_데이터가_저장된다() + { + await _sut.ExecuteAsync(_request); + + await _playerRepository.Received(1).SaveAsync(Arg.Any<PlayerEntity>()); + } + + [Fact] + public async Task 재화_데이터가_저장된다() + { + await _sut.ExecuteAsync(_request); + + await _playerResourceRepository.Received(1).SaveAsync(Arg.Any<PlayerResourceEntity>()); + } + + [Fact] + public async Task 스테이지_데이터가_저장된다() + { + await _sut.ExecuteAsync(_request); + + await _playerStageRepository.Received(1).SaveAsync(Arg.Any<PlayerStage>()); + } + + [Fact] + public async Task 세션_데이터가_저장된다() + { + await _sut.ExecuteAsync(_request); + + await _playerSessionRepository.Received(1).SaveAsync(Arg.Any<PlayerSession>()); + } + + [Fact] + public async Task isNew가_true로_반환된다() + { + var (_, isNew) = await _sut.ExecuteAsync(_request); + + isNew.Should().BeTrue(); + } + + [Fact] + public async Task Redis에_플레이어_데이터가_캐싱된다() + { + await _sut.ExecuteAsync(_request); + + await _playerRedisRepository.Received(1) + .SetPlayerDataAsync(1L, JobType.Warrior, Arg.Any<PlayerDataResponse>()); + } + } + + public class 기존_플레이어일_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For<IPlayerResourceRepository>(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For<IPlayerStageRepository>(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For<IPlayerSessionRepository>(); + private readonly IPlayerWeaponRepository _playerWeaponRepository = Substitute.For<IPlayerWeaponRepository>(); + private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For<IPlayerSkillRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly IAppDbTransactionRunner _transactionRunner = Substitute.For<IAppDbTransactionRunner>(); + private readonly InitPlayerService _sut; + private readonly InitPlayerRequest _request = new(JobType.Warrior); + + public 기존_플레이어일_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRedisRepository.GetPlayerDataAsync(1L, JobType.Warrior).Returns((PlayerDataResponse?)null); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + _playerResourceRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerResourceEntity.Create(1L)); + _playerStageRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerStage.Create(1L)); + _playerSessionRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerSession.Create(1L)); + _playerWeaponRepository.FindAllByPlayerIdAsync(Arg.Any<long>()).Returns([]); + _playerSkillRepository.FindAllByPlayerIdAsync(Arg.Any<long>()).Returns([]); + + _sut = new InitPlayerService( + _playerRepository, + _playerResourceRepository, + _playerStageRepository, + _playerSessionRepository, + _playerWeaponRepository, + _playerSkillRepository, + _playerRedisRepository, + _currentUserProvider, + _transactionRunner); + } + + [Fact] + public async Task 플레이어_데이터가_저장되지_않는다() + { + await _sut.ExecuteAsync(_request); + + await _playerRepository.DidNotReceive().SaveAsync(Arg.Any<PlayerEntity>()); + } + + [Fact] + public async Task 트랜잭션이_실행되지_않는다() + { + await _sut.ExecuteAsync(_request); + + await _transactionRunner.DidNotReceiveWithAnyArgs() + .ExecuteAsync(default(Func<Task<(PlayerEntity Player, PlayerResource Resource, PlayerStage Stage, PlayerSession Session)>>)!); + } + + [Fact] + public async Task isNew가_false로_반환된다() + { + var (_, isNew) = await _sut.ExecuteAsync(_request); + + isNew.Should().BeFalse(); + } + + [Fact] + public async Task 기존_데이터가_반환된다() + { + var (data, _) = await _sut.ExecuteAsync(_request); + + data.JobType.Should().Be(JobType.Warrior); + data.Level.Should().Be(1L); + } + + [Fact] + public async Task Redis에_플레이어_데이터가_캐싱된다() + { + await _sut.ExecuteAsync(_request); + + await _playerRedisRepository.Received(1) + .SetPlayerDataAsync(1L, JobType.Warrior, Arg.Any<PlayerDataResponse>()); + } + } +} diff --git a/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerLevelServiceTests.cs b/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerLevelServiceTests.cs new file mode 100644 index 0000000..7054b02 --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerLevelServiceTests.cs @@ -0,0 +1,76 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service; +using Fantasy.Server.Global.Security.Provider; +using FluentAssertions; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; +using NSubstitute; +using Xunit; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Test.Player.Service; + +public class UpdatePlayerLevelServiceTests +{ + public class 플레이어가_존재할_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly UpdatePlayerLevelService _sut; + private readonly UpdatePlayerLevelRequest _request = new(JobType.Warrior, 10L); + + public 플레이어가_존재할_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + + _sut = new UpdatePlayerLevelService(_playerRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task 플레이어_레벨이_업데이트된다() + { + await _sut.ExecuteAsync(_request); + + await _playerRepository.Received(1).UpdateAsync(Arg.Any<PlayerEntity>()); + } + + [Fact] + public async Task Redis_캐시가_무효화된다() + { + await _sut.ExecuteAsync(_request); + + await _playerRedisRepository.Received(1).DeleteAsync(1L, JobType.Warrior); + } + } + + public class 플레이어가_존재하지_않을_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly UpdatePlayerLevelService _sut; + + public 플레이어가_존재하지_않을_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(Arg.Any<long>(), Arg.Any<JobType>()) + .Returns((PlayerEntity?)null); + + _sut = new UpdatePlayerLevelService(_playerRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task NotFoundException이_발생한다() + { + var request = new UpdatePlayerLevelRequest(JobType.Warrior, 5L); + + var act = async () => await _sut.ExecuteAsync(request); + + await act.Should().ThrowAsync<NotFoundException>(); + } + } +} diff --git a/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerResourceServiceTests.cs b/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerResourceServiceTests.cs new file mode 100644 index 0000000..e566237 --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerResourceServiceTests.cs @@ -0,0 +1,96 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service; +using Fantasy.Server.Global.Security.Provider; +using FluentAssertions; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; +using NSubstitute; +using Xunit; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; +using PlayerResourceEntity = Fantasy.Server.Domain.Player.Entity.PlayerResource; + +namespace Fantasy.Test.Player.Service; + +public class UpdatePlayerResourceServiceTests +{ + public class 정상_요청일_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For<IPlayerResourceRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly UpdatePlayerResourceService _sut; + + public 정상_요청일_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + _playerResourceRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerResourceEntity.Create(1L)); + + _sut = new UpdatePlayerResourceService( + _playerRepository, _playerResourceRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task 재화가_업데이트된다() + { + var request = new UpdatePlayerResourceRequest(JobType.Warrior, 10L, 5L, 20L); + + await _sut.ExecuteAsync(request); + + await _playerResourceRepository.Received(1).UpdateAsync(Arg.Any<PlayerResourceEntity>()); + } + + [Fact] + public async Task 일부_필드만_있어도_업데이트된다() + { + var request = new UpdatePlayerResourceRequest(JobType.Warrior, 10L, null, null); + + await _sut.ExecuteAsync(request); + + await _playerResourceRepository.Received(1).UpdateAsync(Arg.Any<PlayerResourceEntity>()); + } + + [Fact] + public async Task Redis_캐시가_무효화된다() + { + var request = new UpdatePlayerResourceRequest(JobType.Warrior, null, null, 5L); + + await _sut.ExecuteAsync(request); + + await _playerRedisRepository.Received(1).DeleteAsync(1L, JobType.Warrior); + } + } + + public class 플레이어가_존재하지_않을_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For<IPlayerResourceRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly UpdatePlayerResourceService _sut; + + public 플레이어가_존재하지_않을_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(Arg.Any<long>(), Arg.Any<JobType>()) + .Returns((PlayerEntity?)null); + + _sut = new UpdatePlayerResourceService( + _playerRepository, _playerResourceRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task NotFoundException이_발생한다() + { + var request = new UpdatePlayerResourceRequest(JobType.Warrior, 10L, null, null); + + var act = async () => await _sut.ExecuteAsync(request); + + await act.Should().ThrowAsync<NotFoundException>(); + } + } +} diff --git a/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerSkillServiceTests.cs b/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerSkillServiceTests.cs new file mode 100644 index 0000000..8c5acff --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerSkillServiceTests.cs @@ -0,0 +1,84 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service; +using Fantasy.Server.Global.Security.Provider; +using FluentAssertions; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; +using NSubstitute; +using Xunit; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Test.Player.Service; + +public class UpdatePlayerSkillServiceTests +{ + public class 정상_요청일_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For<IPlayerSkillRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly UpdatePlayerSkillService _sut; + private readonly List<SkillChangeItem> _skills = [new(1, true)]; + + public 정상_요청일_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + + _sut = new UpdatePlayerSkillService( + _playerRepository, _playerSkillRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task 스킬_Upsert가_호출된다() + { + var request = new UpdatePlayerSkillRequest(JobType.Warrior, _skills); + + await _sut.ExecuteAsync(request); + + await _playerSkillRepository.Received(1).UpsertRangeAsync(Arg.Any<long>(), _skills); + } + + [Fact] + public async Task Redis_캐시가_무효화된다() + { + var request = new UpdatePlayerSkillRequest(JobType.Warrior, _skills); + + await _sut.ExecuteAsync(request); + + await _playerRedisRepository.Received(1).DeleteAsync(1L, JobType.Warrior); + } + } + + public class 플레이어가_존재하지_않을_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For<IPlayerSkillRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly UpdatePlayerSkillService _sut; + + public 플레이어가_존재하지_않을_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(Arg.Any<long>(), Arg.Any<JobType>()) + .Returns((PlayerEntity?)null); + + _sut = new UpdatePlayerSkillService( + _playerRepository, _playerSkillRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task NotFoundException이_발생한다() + { + var request = new UpdatePlayerSkillRequest(JobType.Warrior, [new(1, true)]); + + var act = async () => await _sut.ExecuteAsync(request); + + await act.Should().ThrowAsync<NotFoundException>(); + } + } +} diff --git a/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerStageServiceTests.cs b/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerStageServiceTests.cs new file mode 100644 index 0000000..cceb65f --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerStageServiceTests.cs @@ -0,0 +1,114 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service; +using Fantasy.Server.Global.Security.Provider; +using FluentAssertions; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; +using NSubstitute; +using Xunit; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Test.Player.Service; + +public class UpdatePlayerStageServiceTests +{ + public class 정상_요청일_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For<IPlayerStageRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly UpdatePlayerStageService _sut; + private readonly UpdatePlayerStageRequest _request = new(JobType.Warrior, 5L); + + public 정상_요청일_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + _playerStageRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns(PlayerStage.Create(1L)); + + _sut = new UpdatePlayerStageService( + _playerRepository, _playerStageRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task 스테이지가_업데이트된다() + { + await _sut.ExecuteAsync(_request); + + await _playerStageRepository.Received(1).UpdateAsync(Arg.Any<PlayerStage>()); + } + + [Fact] + public async Task Redis_캐시가_무효화된다() + { + await _sut.ExecuteAsync(_request); + + await _playerRedisRepository.Received(1).DeleteAsync(1L, JobType.Warrior); + } + } + + public class 플레이어가_존재하지_않을_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For<IPlayerStageRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly UpdatePlayerStageService _sut; + + public 플레이어가_존재하지_않을_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(Arg.Any<long>(), Arg.Any<JobType>()) + .Returns((PlayerEntity?)null); + + _sut = new UpdatePlayerStageService( + _playerRepository, _playerStageRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task NotFoundException이_발생한다() + { + var request = new UpdatePlayerStageRequest(JobType.Warrior, 3L); + + var act = async () => await _sut.ExecuteAsync(request); + + await act.Should().ThrowAsync<NotFoundException>(); + } + } + + public class 스테이지_데이터가_존재하지_않을_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For<IPlayerStageRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly UpdatePlayerStageService _sut; + + public 스테이지_데이터가_존재하지_않을_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + _playerStageRepository.FindByPlayerIdAsync(Arg.Any<long>()) + .Returns((PlayerStage?)null); + + _sut = new UpdatePlayerStageService( + _playerRepository, _playerStageRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task NotFoundException이_발생한다() + { + var request = new UpdatePlayerStageRequest(JobType.Warrior, 3L); + + var act = async () => await _sut.ExecuteAsync(request); + + await act.Should().ThrowAsync<NotFoundException>(); + } + } +} diff --git a/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerWeaponServiceTests.cs b/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerWeaponServiceTests.cs new file mode 100644 index 0000000..7054038 --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerWeaponServiceTests.cs @@ -0,0 +1,84 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service; +using Fantasy.Server.Global.Security.Provider; +using FluentAssertions; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; +using NSubstitute; +using Xunit; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Test.Player.Service; + +public class UpdatePlayerWeaponServiceTests +{ + public class 정상_요청일_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerWeaponRepository _playerWeaponRepository = Substitute.For<IPlayerWeaponRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly UpdatePlayerWeaponService _sut; + private readonly List<WeaponChangeItem> _weapons = [new(1, 2L, 1L, 0L)]; + + public 정상_요청일_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + + _sut = new UpdatePlayerWeaponService( + _playerRepository, _playerWeaponRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task 무기_Upsert가_호출된다() + { + var request = new UpdatePlayerWeaponRequest(JobType.Warrior, _weapons); + + await _sut.ExecuteAsync(request); + + await _playerWeaponRepository.Received(1).UpsertRangeAsync(Arg.Any<long>(), _weapons); + } + + [Fact] + public async Task Redis_캐시가_무효화된다() + { + var request = new UpdatePlayerWeaponRequest(JobType.Warrior, _weapons); + + await _sut.ExecuteAsync(request); + + await _playerRedisRepository.Received(1).DeleteAsync(1L, JobType.Warrior); + } + } + + public class 플레이어가_존재하지_않을_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For<IPlayerRepository>(); + private readonly IPlayerWeaponRepository _playerWeaponRepository = Substitute.For<IPlayerWeaponRepository>(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly UpdatePlayerWeaponService _sut; + + public 플레이어가_존재하지_않을_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(Arg.Any<long>(), Arg.Any<JobType>()) + .Returns((PlayerEntity?)null); + + _sut = new UpdatePlayerWeaponService( + _playerRepository, _playerWeaponRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task NotFoundException이_발생한다() + { + var request = new UpdatePlayerWeaponRequest(JobType.Warrior, [new(1, 1L, 0L, 0L)]); + + var act = async () => await _sut.ExecuteAsync(request); + + await act.Should().ThrowAsync<NotFoundException>(); + } + } +}