using System.Security.Cryptography; using System.Text; using System.Text.Json; using CatLink.Models; using Microsoft.AspNetCore.Mvc; namespace CatLink.Api { [ApiController] [Route("recruit")] public class FutariLobby : ControllerBase { private static readonly Dictionary _recruits = new(); private static readonly object _recruitsLock = new(); private static readonly string _logFilePath = "recruit.log"; private readonly ILogger _logger; private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; public FutariLobby(ILogger logger) { _logger = logger; } [HttpPost("start")] public IActionResult StartRecruit([FromBody] JsonElement json) { try { var record = JsonSerializer.Deserialize(json.GetRawText(), _jsonOptions); if (record == null) { throw new ApiException(400, "Invalid request body"); } record.Time = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); var key = record.RecruitInfo.MechaInfo.IpAddress; lock (_recruitsLock) { var isNew = !_recruits.ContainsKey(key); _recruits[key] = record; if (isNew) { LogRecruit("START", record); } } // Hash user IDs (commented out - client already sends numeric IDs) // if (record.RecruitInfo.MechaInfo.UserIDs != null) // { // record.RecruitInfo.MechaInfo.UserIDs = record.RecruitInfo.MechaInfo.UserIDs // .Select(HashUserId) // .ToList(); // } return Ok(new { success = true }); } catch (ApiException ex) { return StatusCode(ex.Code, ex.Message); } catch (Exception ex) { _logger.LogError(ex, "Error starting recruit"); return StatusCode(500, "Internal server error"); } } [HttpPost("finish")] public IActionResult FinishRecruit([FromBody] JsonElement json) { try { var record = JsonSerializer.Deserialize(json.GetRawText(), _jsonOptions); if (record == null) { throw new ApiException(400, "Invalid request body"); } var key = record.RecruitInfo.MechaInfo.IpAddress; lock (_recruitsLock) { if (!_recruits.ContainsKey(key)) { throw new ApiException(404, "Recruit not found"); } var existing = _recruits[key]; if (existing.Keychip != record.Keychip) { throw new ApiException(403, "Keychip mismatch"); } _recruits.Remove(key); LogRecruit("FINISH", existing); } return Ok(new { success = true }); } catch (ApiException ex) { return StatusCode(ex.Code, ex.Message); } catch (Exception ex) { _logger.LogError(ex, "Error finishing recruit"); return StatusCode(500, "Internal server error"); } } [HttpGet("list")] public IActionResult GetRecruitList() { try { var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); var timeout = 30000; // 30 seconds List records; lock (_recruitsLock) { // Remove expired records var expiredKeys = _recruits .Where(kvp => now - kvp.Value.Time > timeout) .Select(kvp => kvp.Key) .ToList(); foreach (var key in expiredKeys) { _recruits.Remove(key); } records = _recruits.Values.ToList(); } // Return NDJSON format (newline-delimited JSON) var sb = new System.Text.StringBuilder(); foreach (var record in records) { var json = System.Text.Json.JsonSerializer.Serialize(record, new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); sb.AppendLine(json); } return Content(sb.ToString(), "text/plain"); } catch (Exception ex) { _logger.LogError(ex, "Error getting recruit list"); return StatusCode(500, "Internal server error"); } } private static string HashUserId(long userId) { var hash = MD5.HashData(Encoding.UTF8.GetBytes(userId.ToString())); return Convert.ToHexString(hash).ToLowerInvariant(); } private void LogRecruit(string action, RecruitRecord record) { try { var line = $"[{DateTimeOffset.UtcNow:yyyy-MM-dd HH:mm:ss}] {action} {record.RecruitInfo.MechaInfo.IpAddress} {record.Keychip} MusicID={record.RecruitInfo.MusicID}"; lock (_recruitsLock) { System.IO.File.AppendAllText(_logFilePath, line + Environment.NewLine); } _logger.LogInformation(line); } catch (Exception ex) { _logger.LogError(ex, "Error writing to recruit log"); } } } }