From 1f52f53cf71432191fc48de5ff3205644f265276 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Wed, 3 Jun 2026 02:18:00 +0330 Subject: [PATCH] =?UTF-8?q?feat(render+identity):=20daily=20render-limit?= =?UTF-8?q?=20=E2=80=94=20consume=20on=20submit,=20refund=20on=20admin-sto?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../24_identity_daily_render_reset.sql | 11 +++ docker-compose.v2.yml | 2 + .../Application/Services/AdminService.cs | 37 +++++++++ .../Controllers/InternalController.cs | 43 +++++++++++ .../Domain/Entities/User.cs | 1 + services/render/cmd/server/main.go | 5 +- services/render/internal/db/db.go | 20 +++-- services/render/internal/handlers/renders.go | 32 +++++++- .../render/internal/identityclient/client.go | 77 +++++++++++++++++++ 9 files changed, 215 insertions(+), 13 deletions(-) create mode 100644 backend/db/migrations/24_identity_daily_render_reset.sql create mode 100644 services/identity/FlatRender.IdentitySvc/Controllers/InternalController.cs create mode 100644 services/render/internal/identityclient/client.go diff --git a/backend/db/migrations/24_identity_daily_render_reset.sql b/backend/db/migrations/24_identity_daily_render_reset.sql new file mode 100644 index 0000000..c94cf73 --- /dev/null +++ b/backend/db/migrations/24_identity_daily_render_reset.sql @@ -0,0 +1,11 @@ +-- ===================================================================== +-- IDENTITY SCHEMA — Part 24: daily render-quota reset timestamp +-- Supports lazy daily reset of DailyRemainRenderCount. Convention: +-- max_daily_render_count = 0 ⇒ UNLIMITED (no enforcement) — so existing +-- users (all 0/0) keep rendering until an admin sets a real limit. +-- ===================================================================== + +SET search_path TO identity, public; + +ALTER TABLE identity.users + ADD COLUMN IF NOT EXISTS daily_renders_reset_at TIMESTAMPTZ; diff --git a/docker-compose.v2.yml b/docker-compose.v2.yml index eb1bc74..ec92ab9 100644 --- a/docker-compose.v2.yml +++ b/docker-compose.v2.yml @@ -72,6 +72,7 @@ services: Jwt__Secret: "${JWT_SECRET}" Jwt__Issuer: "flatrender-identity" Jwt__Audience: "flatrender" + ServiceToken: "${SERVICE_TOKEN:-internal-service-secret}" ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}" ZarinPal__CallbackUrl: "${ZARINPAL_CALLBACK_URL:-http://localhost:8080/v1/payments/callback/zarinpal}" ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}" @@ -189,6 +190,7 @@ services: MINIO_USE_SSL: "false" MINIO_BUCKET: "${MINIO_BUCKET:-flatrender-exports}" NOTIFICATION_URL: "http://notification-svc:8080" + IDENTITY_URL: "http://identity-svc:8080" SERVICE_TOKEN: "${SERVICE_TOKEN:-internal-service-secret}" PORT: "8080" depends_on: diff --git a/services/identity/FlatRender.IdentitySvc/Application/Services/AdminService.cs b/services/identity/FlatRender.IdentitySvc/Application/Services/AdminService.cs index fe9373b..351cd31 100644 --- a/services/identity/FlatRender.IdentitySvc/Application/Services/AdminService.cs +++ b/services/identity/FlatRender.IdentitySvc/Application/Services/AdminService.cs @@ -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 RequireUser(Guid id) => diff --git a/services/identity/FlatRender.IdentitySvc/Controllers/InternalController.cs b/services/identity/FlatRender.IdentitySvc/Controllers/InternalController.cs new file mode 100644 index 0000000..18753af --- /dev/null +++ b/services/identity/FlatRender.IdentitySvc/Controllers/InternalController.cs @@ -0,0 +1,43 @@ +using FlatRender.IdentitySvc.Application.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace FlatRender.IdentitySvc.Controllers; + +/// 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). +[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 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 Refund([FromBody] ChargeReq req) + { + if (!ServiceTokenValid()) return Unauthorized(); + await svc.RefundRenderChargeAsync(req.UserId); + return Ok(new { ok = true }); + } +} diff --git a/services/identity/FlatRender.IdentitySvc/Domain/Entities/User.cs b/services/identity/FlatRender.IdentitySvc/Domain/Entities/User.cs index f2be848..a0879dc 100644 --- a/services/identity/FlatRender.IdentitySvc/Domain/Entities/User.cs +++ b/services/identity/FlatRender.IdentitySvc/Domain/Entities/User.cs @@ -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; } diff --git a/services/render/cmd/server/main.go b/services/render/cmd/server/main.go index f26d1cc..1851f9d 100644 --- a/services/render/cmd/server/main.go +++ b/services/render/cmd/server/main.go @@ -8,6 +8,7 @@ import ( "github.com/flatrender/render-svc/internal/db" "github.com/flatrender/render-svc/internal/handlers" + "github.com/flatrender/render-svc/internal/identityclient" "github.com/flatrender/render-svc/internal/middleware" "github.com/flatrender/render-svc/internal/notifier" "github.com/gin-gonic/gin" @@ -35,6 +36,7 @@ func main() { minioBucket := getEnv("MINIO_BUCKET", "flatrender-exports") minioTemplatesBucket := getEnv("MINIO_TEMPLATES_BUCKET", "flatrender-templates") notificationURL := getEnv("NOTIFICATION_URL", "http://localhost:8080") + identityURL := getEnv("IDENTITY_URL", "") serviceToken := getEnv("SERVICE_TOKEN", "internal-service-secret") port := getEnv("PORT", "8080") @@ -60,7 +62,8 @@ func main() { // ── Store + handlers ────────────────────────────────────────────────────── store := db.NewStore(pool) notifyClient := notifier.New(notificationURL, serviceToken) - renderH := handlers.NewRenderHandler(store) + identityClient := identityclient.New(identityURL, serviceToken) + renderH := handlers.NewRenderHandler(store, identityClient) snapH := handlers.NewSnapshotHandler(store) exportH := handlers.NewExportHandler(store, mc, minioBucket) nodeH := handlers.NewNodeHandler(store) diff --git a/services/render/internal/db/db.go b/services/render/internal/db/db.go index a1ca355..44de654 100644 --- a/services/render/internal/db/db.go +++ b/services/render/internal/db/db.go @@ -597,22 +597,26 @@ func (s *Store) CancelJob(ctx context.Context, id, userID uuid.UUID) (bool, erro return tag.RowsAffected() > 0, err } -// StopJob cancels any in-progress job regardless of owner (admin action). Also -// frees the assigned node so it can pick up new work. -func (s *Store) StopJob(ctx context.Context, id uuid.UUID) (bool, error) { - tag, err := s.pool.Exec(ctx, ` +// StopJob cancels any in-progress job regardless of owner (admin action), frees the +// assigned node, and returns the job's owner id so the caller can refund their charge. +func (s *Store) StopJob(ctx context.Context, id uuid.UUID) (bool, uuid.UUID, error) { + var ownerID uuid.UUID + err := s.pool.QueryRow(ctx, ` UPDATE render.render_jobs SET step = 'Cancelled'::render_step, completed_at = NOW(), updated_at = NOW() - WHERE id = $1 AND step NOT IN ('Done','Failed','Cancelled')`, - id) + WHERE id = $1 AND step NOT IN ('Done','Failed','Cancelled') + RETURNING user_id`, id).Scan(&ownerID) + if err == pgx.ErrNoRows { + return false, uuid.Nil, nil + } if err != nil { - return false, err + return false, uuid.Nil, err } // Release any node that was actively working this job. _, _ = s.pool.Exec(ctx, `UPDATE render.render_nodes SET status = 'Ready'::node_status, current_frame_job_id = NULL, updated_at = NOW() WHERE current_frame_job_id IN (SELECT id FROM render.frame_jobs WHERE render_job_id = $1)`, id) - return tag.RowsAffected() > 0, err + return true, ownerID, nil } func (s *Store) GetJobProgress(ctx context.Context, id, userID uuid.UUID) (*models.RenderJob, error) { diff --git a/services/render/internal/handlers/renders.go b/services/render/internal/handlers/renders.go index bfe0cb8..e349f9a 100644 --- a/services/render/internal/handlers/renders.go +++ b/services/render/internal/handlers/renders.go @@ -1,10 +1,12 @@ package handlers import ( + "log" "net/http" "strconv" "github.com/flatrender/render-svc/internal/db" + "github.com/flatrender/render-svc/internal/identityclient" "github.com/flatrender/render-svc/internal/middleware" "github.com/flatrender/render-svc/internal/models" "github.com/gin-gonic/gin" @@ -12,11 +14,12 @@ import ( ) type RenderHandler struct { - store *db.Store + store *db.Store + identity *identityclient.Client } -func NewRenderHandler(store *db.Store) *RenderHandler { - return &RenderHandler{store: store} +func NewRenderHandler(store *db.Store, identity *identityclient.Client) *RenderHandler { + return &RenderHandler{store: store, identity: identity} } // GET /v1/renders @@ -61,8 +64,23 @@ func (h *RenderHandler) Create(c *gin.Context) { c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: err.Error()}) return } + + // Daily render-limit: consume one render charge (0 max = unlimited). + allowed, err := h.identity.Consume(c.Request.Context(), userID) + if err != nil { + log.Printf("render-charge consume failed (allowing render): %v", err) + } + if !allowed { + c.JSON(http.StatusTooManyRequests, models.APIError{ + Code: "daily_render_limit", Message: "سقف رندر روزانهٔ شما به پایان رسیده است.", + }) + return + } + job, err := h.store.CreateJob(c.Request.Context(), userID, tenantID, &req) if err != nil { + // Creation failed after consuming — return the charge. + _ = h.identity.Refund(c.Request.Context(), userID) c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()}) return } @@ -138,11 +156,17 @@ func (h *RenderHandler) Stop(c *gin.Context) { c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid job_id"}) return } - stopped, err := h.store.StopJob(c.Request.Context(), jobID) + stopped, ownerID, err := h.store.StopJob(c.Request.Context(), jobID) if err != nil { c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()}) return } + // Admin stop → refund the user's render charge (not their fault). + if stopped && ownerID != uuid.Nil { + if err := h.identity.Refund(c.Request.Context(), ownerID); err != nil { + log.Printf("render-charge refund failed for %s: %v", ownerID, err) + } + } c.JSON(http.StatusOK, gin.H{"stopped": stopped}) } diff --git a/services/render/internal/identityclient/client.go b/services/render/internal/identityclient/client.go new file mode 100644 index 0000000..fa31267 --- /dev/null +++ b/services/render/internal/identityclient/client.go @@ -0,0 +1,77 @@ +// Package identityclient calls identity-svc's internal endpoints for render-charge +// consume/refund (service-to-service, shared service token). +package identityclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/google/uuid" +) + +type Client struct { + baseURL string + token string + http *http.Client +} + +func New(baseURL, token string) *Client { + return &Client{baseURL: baseURL, token: token, http: &http.Client{Timeout: 8 * time.Second}} +} + +func (c *Client) configured() bool { return c.baseURL != "" } + +type consumeResp struct { + Allowed bool `json:"allowed"` + Remaining int `json:"remaining"` +} + +func (c *Client) post(ctx context.Context, path string, userID uuid.UUID) (*http.Response, error) { + body, _ := json.Marshal(map[string]string{"user_id": userID.String()}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Service-Token", c.token) + return c.http.Do(req) +} + +// Consume decrements the user's daily render count. Returns whether the render is +// allowed. Fails OPEN (allowed=true) if identity is unreachable, to avoid blocking +// renders on a transient outage. +func (c *Client) Consume(ctx context.Context, userID uuid.UUID) (bool, error) { + if !c.configured() { + return true, nil + } + resp, err := c.post(ctx, "/v1/internal/render-charge/consume", userID) + if err != nil { + return true, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return true, fmt.Errorf("consume status %d", resp.StatusCode) + } + var r consumeResp + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return true, err + } + return r.Allowed, nil +} + +// Refund returns one render to the user's daily count (admin-stop only). +func (c *Client) Refund(ctx context.Context, userID uuid.UUID) error { + if !c.configured() { + return nil + } + resp, err := c.post(ctx, "/v1/internal/render-charge/refund", userID) + if err != nil { + return err + } + resp.Body.Close() + return nil +}