chore(prod): real leaderboard, prod guards, payment hardening
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 2m4s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 2m11s

Production-readiness pass — remove mock-in-prod and harden the server:
- leaderboard: new DB-backed LeaderboardService + /api/leaderboard (ranked by
  rating, 30s cache, bounded scan); client now calls it instead of mock fake data.
- online count: client uses real /api/stats/online (dropped the fabricated ≥50 floor).
- boot guards (Production): refuse to start if Sms:ApiKey is missing (OTP would
  run in dev mode = fixed code for any phone) or Iab:AllowUnverified is true
  (forged tokens could mint coins).
- payments: ZarinPal + IAB HttpClients get 15s timeouts; ZarinPal/FlatPay gateway
  failures are now logged instead of silently swallowed.
- OTP: periodic prune of expired codes + stale rate-limit logs (was an unbounded
  in-memory leak over a long-running process).
- DB: EnableRetryOnFailure for Postgres (transient-fault resilience).
- docker-compose: ZarinPal sandbox now defaults to false (real payments).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-17 09:03:12 +03:30
parent 4739018488
commit 0790ad6fe0
8 changed files with 150 additions and 16 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ services:
# Comma-separated origins the browser uses to reach the web app.
Cors__Origins: ${CORS_ORIGINS:-http://localhost:1500}
Zarinpal__MerchantId: ${ZARINPAL_MERCHANT_ID:-299685fb-cadf-4dfc-98e2-d4af5d81528d}
Zarinpal__Sandbox: ${ZARINPAL_SANDBOX:-true}
Zarinpal__Sandbox: ${ZARINPAL_SANDBOX:-false}
Zarinpal__CallbackUrl: ${ZARINPAL_CALLBACK_URL:-http://localhost:1505/api/coins/pay/callback}
Zarinpal__ClientReturnUrl: ${ZARINPAL_CLIENT_RETURN_URL:-http://localhost:1500}
# FlatRender Pay broker (pay.flatrender.ir): shared ZarinPal via the single
+30 -1
View File
@@ -39,7 +39,7 @@ public sealed class SmsOptions
public readonly record struct OtpResult(bool Ok, string? DevCode, string? Error, int RetryAfterSeconds);
/// <summary>Generates, sends (Kavenegar) and verifies phone OTP codes.</summary>
public sealed class OtpService
public sealed class OtpService : IDisposable
{
private static readonly HttpClient Http = new();
private readonly SmsOptions _opts;
@@ -50,6 +50,9 @@ public sealed class OtpService
private readonly ConcurrentDictionary<string, List<DateTime>> _sendLog = new();
private readonly object _globalLock = new();
private readonly List<DateTime> _globalLog = new();
// Periodic prune so expired codes / stale rate-limit logs don't accumulate
// unboundedly over a long-running process.
private readonly Timer _cleanup;
private readonly record struct Entry(string Code, DateTime Expires, int Tries);
@@ -57,8 +60,34 @@ public sealed class OtpService
{
_opts = opts;
_log = log;
_cleanup = new Timer(_ => Prune(), null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5));
}
/// <summary>Drop expired OTP codes and stale (>1h) rate-limit entries.</summary>
private void Prune()
{
try
{
var now = DateTime.UtcNow;
foreach (var kv in _codes)
if (now > kv.Value.Expires) _codes.TryRemove(kv.Key, out _);
var hour = TimeSpan.FromHours(1);
foreach (var kv in _sendLog)
{
lock (kv.Value)
{
kv.Value.RemoveAll(t => now - t >= hour);
if (kv.Value.Count == 0) _sendLog.TryRemove(kv.Key, out _);
}
}
lock (_globalLock) _globalLog.RemoveAll(t => now - t >= hour);
}
catch (Exception ex) { _log.LogWarning(ex, "OTP prune failed"); }
}
public void Dispose() => _cleanup.Dispose();
/// <summary>Dev mode = explicitly on, or no API key configured.</summary>
public bool IsDev => _opts.DevMode || string.IsNullOrWhiteSpace(_opts.ApiKey);
@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace Hokm.Server.Payments;
@@ -29,10 +30,15 @@ public sealed class FlatPayService
{
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(20) };
private readonly FlatPayOptions _opts;
private readonly ILogger<FlatPayService> _log;
// Idempotency: broker webhooks may be delivered more than once.
private readonly ConcurrentDictionary<string, byte> _processed = new();
public FlatPayService(FlatPayOptions opts) => _opts = opts;
public FlatPayService(FlatPayOptions opts, ILogger<FlatPayService> log)
{
_opts = opts;
_log = log;
}
public bool Enabled =>
!string.IsNullOrWhiteSpace(_opts.ApiKey) && !string.IsNullOrWhiteSpace(_opts.Secret);
@@ -72,7 +78,7 @@ public sealed class FlatPayService
doc.RootElement.TryGetProperty("payment_url", out var url))
return url.GetString();
}
catch { /* broker unreachable */ }
catch (Exception ex) { _log.LogWarning(ex, "FlatPay broker payment request failed"); }
return null;
}
@@ -45,7 +45,8 @@ public sealed class IabOptions
/// </summary>
public sealed class IabService
{
private static readonly HttpClient Http = new();
// Bounded timeout so a hung store API can't tie up request threads.
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(15) };
private readonly IabOptions _opts;
private readonly ILogger<IabService> _log;
@@ -1,6 +1,7 @@
using System.Collections.Concurrent;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace Hokm.Server.Payments;
@@ -22,11 +23,17 @@ public sealed record PendingPayment(string UserId, string PackId, int AmountRial
/// </summary>
public sealed class ZarinpalService
{
private static readonly HttpClient Http = new();
// Bounded timeout so a hung gateway can't tie up request threads.
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(15) };
private readonly ZarinpalOptions _opts;
private readonly ILogger<ZarinpalService> _log;
private readonly ConcurrentDictionary<string, PendingPayment> _pending = new();
public ZarinpalService(ZarinpalOptions opts) => _opts = opts;
public ZarinpalService(ZarinpalOptions opts, ILogger<ZarinpalService> log)
{
_opts = opts;
_log = log;
}
public string ClientReturnUrl => _opts.ClientReturnUrl;
@@ -59,7 +66,7 @@ public sealed class ZarinpalService
return $"{Base}/pg/StartPay/{authority}";
}
}
catch { /* gateway unreachable */ }
catch (Exception ex) { _log.LogWarning(ex, "ZarinPal payment request failed for user {User}", userId); }
return null;
}
@@ -82,7 +89,7 @@ public sealed class ZarinpalService
return pending;
}
}
catch { /* gateway unreachable */ }
catch (Exception ex) { _log.LogWarning(ex, "ZarinPal payment verify failed for authority {Authority}", authority); }
return null;
}
}
@@ -0,0 +1,72 @@
using System.Text.Json;
using Hokm.Server.Data;
using Microsoft.EntityFrameworkCore;
namespace Hokm.Server.Profiles;
public record LeaderboardEntryDto(
int Rank, string Id, string DisplayName, string Avatar, string? AvatarImage,
int Level, int Rating, double LevelProgress, bool IsYou);
/// <summary>
/// Real, DB-backed leaderboard. Profiles are stored as JSON blobs (no rating
/// column to ORDER BY), so we load and rank in memory behind a short cache to
/// keep it cheap under load. Bounded scan so a large table can't exhaust memory.
/// </summary>
public sealed class LeaderboardService
{
private sealed record Row(string Id, string Name, string Avatar, string? Img, int Level, int Xp, int Rating);
private readonly IServiceScopeFactory _scopes;
private readonly object _lock = new();
private static readonly TimeSpan Ttl = TimeSpan.FromSeconds(30);
private List<Row> _cache = new();
private DateTime _cachedAt = DateTime.MinValue;
public LeaderboardService(IServiceScopeFactory scopes) => _scopes = scopes;
private List<Row> Snapshot()
{
lock (_lock)
{
if (_cache.Count > 0 && DateTime.UtcNow - _cachedAt < Ttl) return _cache;
}
var list = new List<Row>();
using var scope = _scopes.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// Cap the scan; ranking is by rating which lives inside the JSON blob.
var rows = db.Profiles.AsNoTracking().Take(5000).ToList();
foreach (var r in rows)
{
try
{
var p = JsonSerializer.Deserialize<ProfileDto>(r.Json, JsonOpts.Default);
if (p == null) continue;
list.Add(new Row(
string.IsNullOrEmpty(p.Id) ? r.Id : p.Id,
p.DisplayName, p.Avatar, p.AvatarImage, p.Level, p.Xp, p.Rating));
}
catch { /* skip malformed rows */ }
}
var top = list.OrderByDescending(x => x.Rating).ThenByDescending(x => x.Level).Take(100).ToList();
lock (_lock) { _cache = top; _cachedAt = DateTime.UtcNow; }
return top;
}
public List<LeaderboardEntryDto> Top(string? meId)
{
var snap = Snapshot();
var result = new List<LeaderboardEntryDto>(snap.Count);
for (int i = 0; i < snap.Count; i++)
{
var r = snap[i];
var need = Gamification.XpForLevel(r.Level);
var progress = need > 0 ? Math.Clamp((double)r.Xp / need, 0, 1) : 0;
result.Add(new LeaderboardEntryDto(
i + 1, r.Id, r.Name, r.Avatar, r.Img, r.Level, r.Rating, progress,
meId != null && r.Id == meId));
}
return result;
}
}
+18 -1
View File
@@ -34,12 +34,15 @@ var dbConn = builder.Configuration.GetConnectionString("Default");
builder.Services.AddDbContext<AppDbContext>(o =>
{
if (dbProvider.Equals("postgres", StringComparison.OrdinalIgnoreCase))
o.UseNpgsql(dbConn ?? "");
// Retry transient Postgres failures (network blips, DB restarts) so a
// brief outage doesn't surface as request errors in production.
o.UseNpgsql(dbConn ?? "", npg => npg.EnableRetryOnFailure(maxRetryCount: 5));
else
o.UseSqlite(dbConn ?? "Data Source=hokm.db");
});
builder.Services.AddScoped<ProfileService>();
builder.Services.AddScoped<SocialService>();
builder.Services.AddSingleton<LeaderboardService>();
// --- ZarinPal (sandbox) — merchant id is config-driven (admin panel later) ---
var zp = builder.Configuration.GetSection("Zarinpal").Get<ZarinpalOptions>() ?? new ZarinpalOptions();
@@ -56,11 +59,21 @@ builder.Services.AddSingleton<FlatPayService>();
// --- Store in-app billing (Cafe Bazaar / Myket) verification ---
var iab = builder.Configuration.GetSection("Iab").Get<IabOptions>() ?? new IabOptions();
// Production guard: AllowUnverified credits coins WITHOUT verifying the purchase
// with the store — a forged token could mint coins. Never allow it in prod.
if (builder.Environment.IsProduction() && iab.AllowUnverified)
throw new InvalidOperationException(
"Iab:AllowUnverified (env IAB_ALLOW_UNVERIFIED) must be false in Production.");
builder.Services.AddSingleton(iab);
builder.Services.AddSingleton<IabService>();
// --- SMS OTP (Kavenegar). No ApiKey ⇒ dev mode (fixed code, no SMS sent). ---
var sms = builder.Configuration.GetSection("Sms").Get<SmsOptions>() ?? new SmsOptions();
// Production guard: with no API key the OTP service runs in DEV mode (accepts a
// fixed code for ANY phone), which would let anyone log in. Require a real key.
if (builder.Environment.IsProduction() && string.IsNullOrWhiteSpace(sms.ApiKey))
throw new InvalidOperationException(
"Sms:ApiKey (env SMS_API_KEY) is mandatory in Production — without it OTP runs in dev mode.");
builder.Services.AddSingleton(sms);
builder.Services.AddSingleton<OtpService>();
@@ -214,6 +227,10 @@ app.MapGet("/api/players/search", async (string? q, ClaimsPrincipal u, SocialSer
app.MapGet("/api/players/suggested", async (ClaimsPrincipal u, SocialService s) =>
Results.Json(await s.Suggested(Uid(u)), JsonOpts.Default)).RequireAuthorization();
// Real, DB-backed leaderboard (top players by rating).
app.MapGet("/api/leaderboard", (ClaimsPrincipal u, LeaderboardService lb) =>
Results.Json(lb.Top(Uid(u)), JsonOpts.Default)).RequireAuthorization();
app.MapPost("/api/profile/plan", async (ClaimsPrincipal u, ProfileService svc) =>
Results.Json(await svc.UpgradePlan(Uid(u)), JsonOpts.Default))
.RequireAuthorization();
+8 -6
View File
@@ -547,21 +547,23 @@ export class SignalrService implements OnlineService {
}
async getOnlineCount(): Promise<number> {
// Always show a believable floor (≥50) — never the raw small/zero real count.
const floor = await this.mock.getOnlineCount(); // drifts, min 50
// Real count from the server (no fabricated floor).
try {
const res = await fetch(`${SERVER}/api/stats/online`);
if (res.ok) {
const j = (await res.json()) as { online: number };
return Math.max(j.online ?? 0, floor);
return Math.max(0, j.online ?? 0);
}
} catch {
/* fall through */
/* server unreachable */
}
return floor;
return 0;
}
getLeaderboard(): Promise<LeaderboardEntry[]> { return this.mock.getLeaderboard(); }
async getLeaderboard(): Promise<LeaderboardEntry[]> {
// Real, server-ranked leaderboard.
return this.getJson<LeaderboardEntry[]>("/api/leaderboard");
}
// shop catalog stays client-side; the purchase is server-authoritative
getShopItems(): Promise<ShopItem[]> { return this.mock.getShopItems(); }