feat(render+identity): daily render-limit — consume on submit, refund on admin-stop
Build backend images / build content-svc (push) Failing after 51s
Build backend images / build file-svc (push) Failing after 53s
Build backend images / build gateway (push) Failing after 1m1s
Build backend images / build identity-svc (push) Failing after 48s
Build backend images / build notification-svc (push) Failing after 42s
Build backend images / build render-svc (push) Failing after 47s
Build backend images / build studio-svc (push) Failing after 1m13s

Business rule: each user has a daily render limit. Admin-stop refunds the used
charge (not the user's fault); a user's own cancel does not.

- identity: ConsumeRenderChargeAsync / RefundRenderChargeAsync on DailyRemainRenderCount
  with lazy daily reset (mig 24: daily_renders_reset_at). Convention: max=0 ⇒ UNLIMITED,
  so existing 0/0 users keep rendering until an admin sets a real limit.
- identity InternalController (service-token): POST /v1/internal/render-charge/{consume,refund}
- render-svc: identityclient + on Create consume (block 429 when limit reached, fail-open
  on identity outage); on admin Stop refund the job owner; user /cancel unchanged
- compose: IDENTITY_URL for render-svc, ServiceToken for identity-svc

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-03 02:18:00 +03:30
parent 7f7feabb85
commit 1f52f53cf7
9 changed files with 215 additions and 13 deletions
@@ -102,6 +102,43 @@ public class AdminService(IdentityDbContext db)
return new UserCrmResponse(c.Tags, c.Note, c.Status);
}
// ── Render charge (daily render limit) — consume / refund ─────────────────
// Convention: MaxDailyRenderCount == 0 means UNLIMITED (no enforcement).
public async Task<(bool Allowed, int Remaining)> ConsumeRenderChargeAsync(Guid userId)
{
var u = await db.Users.FindAsync(userId);
if (u == null) return (false, 0);
if (u.MaxDailyRenderCount <= 0) return (true, -1); // unlimited
// Lazy daily reset (UTC day boundary).
var today = DateTime.UtcNow.Date;
if (u.DailyRendersResetAt == null || u.DailyRendersResetAt.Value.Date < today)
{
u.DailyRemainRenderCount = u.MaxDailyRenderCount;
u.DailyRendersResetAt = DateTime.UtcNow;
}
if (u.DailyRemainRenderCount <= 0)
{
await db.SaveChangesAsync(); // persist any reset
return (false, 0);
}
u.DailyRemainRenderCount -= 1;
u.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return (true, u.DailyRemainRenderCount);
}
public async Task RefundRenderChargeAsync(Guid userId)
{
var u = await db.Users.FindAsync(userId);
if (u == null || u.MaxDailyRenderCount <= 0) return; // nothing to refund / unlimited
u.DailyRemainRenderCount = Math.Min(u.DailyRemainRenderCount + 1, u.MaxDailyRenderCount);
u.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
// ── Per-user power-actions ──────────────────────────────────────────────
private async Task<User> RequireUser(Guid id) =>
@@ -0,0 +1,43 @@
using FlatRender.IdentitySvc.Application.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace FlatRender.IdentitySvc.Controllers;
/// <summary>Service-to-service endpoints (render-svc → identity). Auth via shared service token,
/// reachable only on the internal network (the gateway does not route /v1/internal).</summary>
[ApiController]
[AllowAnonymous]
[Route("v1/internal")]
public class InternalController(AdminService svc, IConfiguration config) : ControllerBase
{
public record ChargeReq(Guid UserId);
private bool ServiceTokenValid()
{
var expected = config["ServiceToken"] ?? Environment.GetEnvironmentVariable("SERVICE_TOKEN") ?? "internal-service-secret";
var got = Request.Headers["X-Service-Token"].ToString();
if (string.IsNullOrEmpty(got))
{
var auth = Request.Headers["Authorization"].ToString();
if (auth.StartsWith("Bearer ", StringComparison.Ordinal)) got = auth[7..];
}
return !string.IsNullOrEmpty(got) && got == expected;
}
[HttpPost("render-charge/consume")]
public async Task<IActionResult> Consume([FromBody] ChargeReq req)
{
if (!ServiceTokenValid()) return Unauthorized();
var (allowed, remaining) = await svc.ConsumeRenderChargeAsync(req.UserId);
return Ok(new { allowed, remaining });
}
[HttpPost("render-charge/refund")]
public async Task<IActionResult> Refund([FromBody] ChargeReq req)
{
if (!ServiceTokenValid()) return Unauthorized();
await svc.RefundRenderChargeAsync(req.UserId);
return Ok(new { ok = true });
}
}
@@ -49,6 +49,7 @@ public class User
// Render quotas
public int DailyRemainRenderCount { get; set; }
public int MaxDailyRenderCount { get; set; }
public DateTime? DailyRendersResetAt { get; set; }
public int ParallelRenderingCeiling { get; set; } = 1;
public int UserDailyFreeChargeSec { get; set; }
public DateTime? DailyFreeChargeResetDate { get; set; }