- 채널 입장 시 API 서버에서 플레이어 프로필 로드 (레벨/스탯/위치) - 채널 퇴장 시 위치/플레이타임 DB 저장 (SaveGameDataAsync) - Player.cs에 AttackPower/AttackRange/SprintMultiplier/Experience 필드 추가 - ToPlayerInfo에서 전투 스탯 매핑 추가 - Session에 ChannelJoinedAt 추가 (플레이타임 계산용) - PartyUpdateType에 INVITE/KICK 추가 - RequestPartyPacket에 TargetPlayerId 필드 추가 - GameServer에 INVITE/KICK 핸들러 구현 - Channel에 GetPeer() 메서드 추가 - RestApi에 GetPlayerProfileAsync/SaveGameDataAsync 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
285 lines
9.6 KiB
C#
285 lines
9.6 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json.Serialization;
|
|
using MMOserver.Config;
|
|
using MMOserver.Utils;
|
|
using Serilog;
|
|
|
|
namespace MMOserver.Api;
|
|
|
|
public class RestApi : Singleton<RestApi>
|
|
{
|
|
private readonly HttpClient httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
|
|
|
|
private const int MAX_RETRY = 3;
|
|
private static readonly TimeSpan RETRY_DELAY = TimeSpan.FromSeconds(1);
|
|
|
|
public RestApi()
|
|
{
|
|
httpClient.DefaultRequestHeaders.Add("X-API-Key", AppConfig.RestApi.ApiKey);
|
|
}
|
|
|
|
// 토큰 검증 - 성공 시 username 반환
|
|
// 401 → 재시도 없이 즉시 null 반환 (토큰 자체가 무효)
|
|
// 타임아웃/네트워크 오류 → 최대 MAX_RETRY회 재시도 후 null 반환
|
|
public async Task<string?> VerifyTokenAsync(string token)
|
|
{
|
|
string url = AppConfig.RestApi.BaseUrl + AppConfig.RestApi.VerifyToken;
|
|
for (int attempt = 1; attempt <= MAX_RETRY; attempt++)
|
|
{
|
|
try
|
|
{
|
|
HttpResponseMessage response = await httpClient.PostAsJsonAsync(url, new { token });
|
|
|
|
// 401: 토큰 자체가 무효 → 재시도해도 같은 결과, 즉시 반환
|
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
|
{
|
|
Log.Warning("[RestApi] 토큰 인증 실패 (401)");
|
|
return null;
|
|
}
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
AuthVerifyResponse? result = await response.Content.ReadFromJsonAsync<AuthVerifyResponse>();
|
|
return result?.Username;
|
|
}
|
|
catch (Exception ex) when (attempt < MAX_RETRY)
|
|
{
|
|
// 일시적 장애 (타임아웃, 네트워크 오류 등) → 재시도
|
|
Log.Warning("[RestApi] 통신 실패 (시도 {Attempt}/{Max}): {Message}", attempt, MAX_RETRY, ex.Message);
|
|
await Task.Delay(RETRY_DELAY);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// 마지막 시도까지 실패
|
|
Log.Error("[RestApi] 최종 통신 실패 ({Max}회 시도): {Message}", MAX_RETRY, ex.Message);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// 플레이어 프로필 조회 - 성공 시 PlayerProfileResponse 반환
|
|
public async Task<PlayerProfileResponse?> GetPlayerProfileAsync(string username)
|
|
{
|
|
string url = AppConfig.RestApi.BaseUrl + "/api/internal/player/profile?username=" + Uri.EscapeDataString(username);
|
|
for (int attempt = 1; attempt <= MAX_RETRY; attempt++)
|
|
{
|
|
try
|
|
{
|
|
HttpResponseMessage response = await httpClient.GetAsync(url);
|
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
|
{
|
|
Log.Warning("[RestApi] 프로필 없음 username={Username}", username);
|
|
return null;
|
|
}
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
return await response.Content.ReadFromJsonAsync<PlayerProfileResponse>();
|
|
}
|
|
catch (Exception ex) when (attempt < MAX_RETRY)
|
|
{
|
|
Log.Warning("[RestApi] 프로필 조회 실패 (시도 {Attempt}/{Max}): {Message}", attempt, MAX_RETRY, ex.Message);
|
|
await Task.Delay(RETRY_DELAY);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error("[RestApi] 프로필 조회 최종 실패 ({Max}회 시도): {Message}", MAX_RETRY, ex.Message);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private sealed class AuthVerifyResponse
|
|
{
|
|
[JsonPropertyName("username")]
|
|
public string? Username
|
|
{
|
|
get;
|
|
set;
|
|
}
|
|
}
|
|
|
|
public sealed class PlayerProfileResponse
|
|
{
|
|
[JsonPropertyName("nickname")]
|
|
public string Nickname { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("level")]
|
|
public int Level { get; set; }
|
|
|
|
[JsonPropertyName("experience")]
|
|
public int Experience { get; set; }
|
|
|
|
[JsonPropertyName("nextExp")]
|
|
public int NextExp { get; set; }
|
|
|
|
[JsonPropertyName("maxHp")]
|
|
public double MaxHp { get; set; }
|
|
|
|
[JsonPropertyName("maxMp")]
|
|
public double MaxMp { get; set; }
|
|
|
|
[JsonPropertyName("attackPower")]
|
|
public double AttackPower { get; set; }
|
|
|
|
[JsonPropertyName("attackRange")]
|
|
public double AttackRange { get; set; }
|
|
|
|
[JsonPropertyName("sprintMultiplier")]
|
|
public double SprintMultiplier { get; set; }
|
|
|
|
[JsonPropertyName("lastPosX")]
|
|
public double LastPosX { get; set; }
|
|
|
|
[JsonPropertyName("lastPosY")]
|
|
public double LastPosY { get; set; }
|
|
|
|
[JsonPropertyName("lastPosZ")]
|
|
public double LastPosZ { get; set; }
|
|
|
|
[JsonPropertyName("lastRotY")]
|
|
public double LastRotY { get; set; }
|
|
}
|
|
|
|
// 레이드 채널 접속 여부 체크
|
|
// 성공 시 BossRaidResult 반환, 실패/거절 시 null 반환
|
|
public async Task<BossRaidResult?> BossRaidAccessAsync(List<string> userNames, int bossId)
|
|
{
|
|
string url = AppConfig.RestApi.BaseUrl + AppConfig.RestApi.BossRaidAccess;
|
|
|
|
for (int attempt = 1; attempt <= MAX_RETRY; attempt++)
|
|
{
|
|
try
|
|
{
|
|
HttpResponseMessage response = await httpClient.PostAsJsonAsync(url, new { usernames = userNames, bossId = bossId });
|
|
|
|
// 401: API 키 인증 실패
|
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
|
{
|
|
Log.Warning("[RestApi] 보스 레이드 접속 인증 실패 (401)");
|
|
return null;
|
|
}
|
|
|
|
// 400: 입장 조건 미충족 / 409: 이미 레이드 중 등
|
|
if (response.StatusCode == HttpStatusCode.BadRequest ||
|
|
response.StatusCode == HttpStatusCode.Conflict)
|
|
{
|
|
Log.Warning("[RestApi] 보스 레이드 입장 거절 ({Status}) BossId={BossId}",
|
|
(int)response.StatusCode, bossId);
|
|
return null;
|
|
}
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
BossRaidAccessResponse? raw = await response.Content.ReadFromJsonAsync<BossRaidAccessResponse>();
|
|
if (raw == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// API 응답 → 도메인 모델 매핑
|
|
return new BossRaidResult
|
|
{
|
|
RoomId = raw.RoomId,
|
|
SessionName = raw.SessionName ?? string.Empty,
|
|
BossId = raw.BossId,
|
|
Players = raw.Players,
|
|
Status = raw.Status ?? string.Empty,
|
|
Tokens = raw.Tokens
|
|
};
|
|
}
|
|
catch (Exception ex) when (attempt < MAX_RETRY)
|
|
{
|
|
Log.Warning("[RestApi] 보스 레이드 통신 실패 (시도 {Attempt}/{Max}): {Message}", attempt, MAX_RETRY, ex.Message);
|
|
await Task.Delay(RETRY_DELAY);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error("[RestApi] 보스 레이드 최종 통신 실패 ({Max}회 시도): {Message}", MAX_RETRY, ex.Message);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// 게임 데이터 저장 (채널 퇴장 시 위치/플레이타임 저장)
|
|
public async Task<bool> SaveGameDataAsync(string username, float? posX, float? posY, float? posZ, float? rotY, long? playTimeDelta)
|
|
{
|
|
string url = AppConfig.RestApi.BaseUrl + "/api/internal/player/save?username=" + Uri.EscapeDataString(username);
|
|
|
|
var body = new Dictionary<string, object?>();
|
|
if (posX.HasValue) body["lastPosX"] = posX.Value;
|
|
if (posY.HasValue) body["lastPosY"] = posY.Value;
|
|
if (posZ.HasValue) body["lastPosZ"] = posZ.Value;
|
|
if (rotY.HasValue) body["lastRotY"] = rotY.Value;
|
|
if (playTimeDelta.HasValue) body["playTimeDelta"] = playTimeDelta.Value;
|
|
|
|
for (int attempt = 1; attempt <= MAX_RETRY; attempt++)
|
|
{
|
|
try
|
|
{
|
|
HttpResponseMessage response = await httpClient.PostAsJsonAsync(url, body);
|
|
response.EnsureSuccessStatusCode();
|
|
return true;
|
|
}
|
|
catch (Exception ex) when (attempt < MAX_RETRY)
|
|
{
|
|
Log.Warning("[RestApi] 게임 데이터 저장 실패 (시도 {Attempt}/{Max}): {Message}", attempt, MAX_RETRY, ex.Message);
|
|
await Task.Delay(RETRY_DELAY);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error("[RestApi] 게임 데이터 저장 최종 실패 ({Max}회 시도): {Message}", MAX_RETRY, ex.Message);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private sealed class BossRaidAccessResponse
|
|
{
|
|
[JsonPropertyName("roomId")]
|
|
public int RoomId
|
|
{
|
|
get;
|
|
set;
|
|
}
|
|
|
|
[JsonPropertyName("sessionName")]
|
|
public string? SessionName
|
|
{
|
|
get;
|
|
set;
|
|
}
|
|
|
|
[JsonPropertyName("bossId")]
|
|
public int BossId
|
|
{
|
|
get;
|
|
set;
|
|
}
|
|
|
|
[JsonPropertyName("players")]
|
|
public List<string> Players
|
|
{
|
|
get;
|
|
set;
|
|
} = new();
|
|
|
|
[JsonPropertyName("status")]
|
|
public string? Status
|
|
{
|
|
get;
|
|
set;
|
|
}
|
|
|
|
[JsonPropertyName("tokens")]
|
|
public Dictionary<string, string>? Tokens
|
|
{
|
|
get;
|
|
set;
|
|
} = new();
|
|
}
|
|
}
|