using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Npgsql; using NpgsqlTypes; using Yuanshen.Gameserver.Options; namespace Yuanshen.Gameserver.Database; internal sealed class PostgresPlayerRepository : IPlayerRepository, IAsyncDisposable { private const string CreateTableSql = """ CREATE TABLE IF NOT EXISTS players ( uid bigint PRIMARY KEY, data jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now() ) """; private const string UpsertSql = """ INSERT INTO players (uid, data, updated_at) VALUES (@uid, @data, now()) ON CONFLICT (uid) DO UPDATE SET data = EXCLUDED.data, updated_at = now() """; private const string SelectSql = "SELECT data FROM players WHERE uid = @uid"; private static readonly JsonSerializerOptions SerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; private readonly NpgsqlDataSource _dataSource; private readonly ILogger _logger; private readonly SemaphoreSlim _schemaLock = new(1, 1); private volatile bool _schemaReady; public PostgresPlayerRepository(IOptions options, ILogger logger) { _logger = logger; _dataSource = new NpgsqlDataSourceBuilder(options.Value.ConnectionString).Build(); } private async ValueTask EnsureSchemaAsync() { if (_schemaReady) return true; await _schemaLock.WaitAsync(); try { if (_schemaReady) return true; await using NpgsqlCommand command = _dataSource.CreateCommand(CreateTableSql); await command.ExecuteNonQueryAsync(); _schemaReady = true; _logger.LogInformation("PostgreSQL connected, player storage ready"); return true; } catch (Exception exception) { _logger.LogWarning("PostgreSQL unavailable, persistence disabled for now: {message}", exception.Message); return false; } finally { _schemaLock.Release(); } } public async ValueTask LoadAsync(uint uid) { if (!await EnsureSchemaAsync()) return null; try { await using NpgsqlCommand command = _dataSource.CreateCommand(SelectSql); command.Parameters.AddWithValue("uid", (long)uid); object? result = await command.ExecuteScalarAsync(); if (result is not string json) return null; return JsonSerializer.Deserialize(json, SerializerOptions); } catch (Exception exception) { _logger.LogError("Failed to load player {uid}: {message}", uid, exception.Message); return null; } } public async ValueTask SaveAsync(PlayerRecord record) { if (!await EnsureSchemaAsync()) return false; record.LastSeen = DateTimeOffset.UtcNow; try { await using NpgsqlCommand command = _dataSource.CreateCommand(UpsertSql); command.Parameters.AddWithValue("uid", (long)record.Uid); command.Parameters.Add(new NpgsqlParameter("data", NpgsqlDbType.Jsonb) { Value = JsonSerializer.Serialize(record, SerializerOptions) }); await command.ExecuteNonQueryAsync(); _logger.LogDebug("Saved player {uid}", record.Uid); return true; } catch (Exception exception) { _logger.LogError("Failed to save player {uid}: {message}", record.Uid, exception.Message); return false; } } public ValueTask DisposeAsync() { _schemaLock.Dispose(); return _dataSource.DisposeAsync(); } }